Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion docusaurus.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ function hideIndexFromSidebarItems(items) {
return result;
}

const injectDocStats = require('./plugins/remark-inject-doc-stats.cjs');

const config: Config = {
title: 'Harness Developer Hub',
tagline:
Expand Down Expand Up @@ -538,7 +540,7 @@ const config: Config = {
routeBasePath: 'university',
exclude: ['**/shared/**', '**/static/**'],
sidebarPath: require.resolve('./sidebars-university.js'),
editUrl: 'https://github.com/harness/developer-hub/tree/main',
editUrl: 'https://github.com/harness/developer-hub/tree/main',
// ... other options
},
],
Expand Down Expand Up @@ -577,8 +579,11 @@ const config: Config = {
editUrl: 'https://github.com/harness/developer-hub/tree/main', // /tree/main/packages/create-docusaurus/templates/shared/
// include: ["tutorials/**/*.{md, mdx}", "docs/**/*.{md, mdx}"],
exclude: ['**/shared/**', '**/static/**', '**/content/**'],
showLastUpdateTime: true,
showLastUpdateAuthor: false,
routeBasePath: 'docs', //CHANGE HERE
remarkPlugins: [
injectDocStats,
[
remarkMath,
{
Expand Down
84 changes: 84 additions & 0 deletions plugins/remark-inject-doc-stats.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
const {execSync} = require('child_process');

const cache = new Map();
const WPM = 200;

function getLastCommitISO(filePath) {
if (!filePath) return null;
const k = `last:${filePath}`;
if (cache.has(k)) return cache.get(k);
try {
const iso = execSync(`git log -1 --format=%cI -- "${filePath}"`, {
stdio: ['ignore', 'pipe', 'ignore'],
}).toString().trim();
cache.set(k, iso || null);
return iso || null;
} catch {
cache.set(k, null);
return null;
}
}

function getFirstCommitISO(filePath) {
if (!filePath) return null;
const k = `first:${filePath}`;
if (cache.has(k)) return cache.get(k);
try {
// first commit touching the file
const iso = execSync(`git log --diff-filter=A --format=%cI -1 -- "${filePath}"`, {
stdio: ['ignore', 'pipe', 'ignore'],
}).toString().trim();
cache.set(k, iso || null);
return iso || null;
} catch {
cache.set(k, null);
return null;
}
}

function estimateMinutesFromMarkdown(vfile) {
// vfile.value contains raw md/mdx; strip code fences lightly and count words
const raw = String(vfile?.value || '');
if (!raw) return null;
const noCode = raw.replace(/```[\s\S]*?```/g, ' ');
const words = (noCode.match(/\S+/g) || []).length;
if (!words) return 1;
return Math.max(1, Math.round(words / WPM));
}

module.exports = function remarkInjectDocStats() {
return (tree, vfile) => {
// Avoid double-inject on HMR
const first = tree.children?.[0];
const already =
first &&
(first.type === 'mdxJsxFlowElement' || first.type === 'mdxJsxTextElement') &&
first.name === 'DocStatsBlock';

const attrs = [];

const filePath = vfile?.path;
const updatedISO = getLastCommitISO(filePath);
const publishedISO = getFirstCommitISO(filePath);
const minutes = estimateMinutesFromMarkdown(vfile);

if (updatedISO) {
attrs.push({type: 'mdxJsxAttribute', name: 'overrideUpdated', value: updatedISO});
}
if (publishedISO) {
attrs.push({type: 'mdxJsxAttribute', name: 'overridePublished', value: publishedISO});
}
if (typeof minutes === 'number') {
attrs.push({type: 'mdxJsxAttribute', name: 'overrideReadingMin', value: String(minutes)});
}

if (!already) {
tree.children.unshift({
type: 'mdxJsxFlowElement',
name: 'DocStatsBlock',
attributes: attrs,
children: [],
});
}
};
};
15 changes: 15 additions & 0 deletions src/components/DocStats/DocLastUpdated.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import React from 'react';
import {useDoc} from '@docusaurus/plugin-content-docs/client';
import {formatDateUS} from './formatDate';

export default function DocLastUpdated({overrideUpdated}: {overrideUpdated?: string}) {
try {
const {metadata} = useDoc();
const source = overrideUpdated ?? (metadata?.lastUpdatedAt as any);
const label = formatDateUS(source);
if (!label) return null;
return <span className="docStat docStat--updated">Last updated: {label}</span>;
} catch {
return null;
}
}
16 changes: 16 additions & 0 deletions src/components/DocStats/DocPublished.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import React from 'react';
import {useDoc} from '@docusaurus/plugin-content-docs/client';
import {formatDateUS} from './formatDate';

export default function DocPublished({overridePublished}: {overridePublished?: string}) {
try {
const {frontMatter} = useDoc();
const fm = frontMatter as any;
const publishedRaw = fm?.published ?? overridePublished; // frontmatter wins; else git first commit
const label = formatDateUS(publishedRaw);
if (!label) return null;
return <span className="docStat docStat--published">Published: {label}</span>;
} catch {
return null;
}
}
46 changes: 46 additions & 0 deletions src/components/DocStats/DocStats.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
.docStatsBox {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 8px 12px;
margin-top: -0.5rem;
margin-bottom: 1rem;
border: 1px solid var(--ifm-color-emphasis-200);
background: var(--ifm-background-surface);
border-radius: 6px;
}

.docStatsLeft {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--ifm-color-emphasis-700);
}

.docStatsDot {
opacity: 0.6;
}

.docStatsRight {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--ifm-color-emphasis-700);
min-width: 110px;
justify-content: flex-end;
}

.docStatsIcon {
width: 16px;
height: 16px;
opacity: 0.8;
}

.theme-last-updated {
display: none !important;
}
37 changes: 37 additions & 0 deletions src/components/DocStats/DocStatsBlock.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React from 'react';
import DocLastUpdated from './DocLastUpdated';
import DocPublished from './DocPublished';
import DocTimeToRead from './DocTimeToRead';
import './DocStats.css';

type Props = {
overrideUpdated?: string;
overridePublished?: string;
overrideReadingMin?: string; // ignored now since we always compute at runtime
};

export default function DocStatsBlock({overrideUpdated, overridePublished}: Props) {
const hasUpdated = Boolean(overrideUpdated); // DocLastUpdated will no-op if it can't format
const hasPublished = Boolean(overridePublished); // DocPublished will no-op if none

return (
<div className="docStatsBox" data-testid="doc-stats">
<div className="docStatsLeft" aria-label="Document metadata">
{/* Updated */}
<DocLastUpdated overrideUpdated={overrideUpdated} />

{/* Separator only if both left-side items exist */}
{hasUpdated && hasPublished && <span className="docStatsDot">•</span>}

{/* Published (frontmatter or injected first-commit) */}
<DocPublished overridePublished={overridePublished} />
</div>

{/* Right side is always shown: clock + TTR */}
<div className="docStatsRight" aria-label="Estimated reading time">
<img src="/img/icon-clock.svg" />
<DocTimeToRead />
</div>
</div>
);
}
38 changes: 38 additions & 0 deletions src/components/DocStats/DocTimeToRead.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React, {useEffect, useRef, useState} from 'react';

function countWordsFromContainer(): number {
const el = document.querySelector('.theme-doc-markdown');
if (!el) return 0;
const text = el.textContent || '';
const words = (text.trim().match(/\S+/g) || []).length;
return words;
}

export default function DocTimeToRead({wpm = 200}: {wpm?: number}) {
const [minutes, setMinutes] = useState<number>(1);
const observerRef = useRef<MutationObserver | null>(null);

useEffect(() => {
const compute = () => {
const words = countWordsFromContainer();
const mins = Math.max(1, Math.round(words / wpm));
setMinutes(mins);
};

// Initial compute after hydration
compute();

// Recompute if the page content swaps (route change/HMR)
const target = document.querySelector('.theme-doc-markdown');
if (target) {
observerRef.current?.disconnect();
observerRef.current = new MutationObserver(() => compute());
observerRef.current.observe(target, {childList: true, subtree: true});
}

return () => observerRef.current?.disconnect();
}, [wpm]);

const label = `${minutes} minute read`;
return <span className="docStat docStat--ttr">{label}</span>;
}
52 changes: 52 additions & 0 deletions src/components/DocStats/LastUpdatedTop.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import React from "react";
import { useDoc } from "@docusaurus/plugin-content-docs/client";

function formatDateLong(ts: number | string | undefined): string | null {
if (!ts) return null;
let date: Date;
if (typeof ts === "number") {
date = new Date(ts < 1e12 ? ts * 1000 : ts);
} else {
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return null;
date = d;
}
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(date);
}

export default function LastUpdatedTop() {
let metadata: any | undefined;
try {
({ metadata } = useDoc());
} catch {
return null; // not a docs route
}

// Try both common Docusaurus fields for published date
const published = formatDateLong(metadata?.date || metadata?.createdAt);
const updated = formatDateLong(metadata?.lastUpdatedAt);

if (!published && !updated) return null;

return (
<div
style={{
marginTop: "-0.5rem",
marginBottom: "1rem",
fontSize: "0.875rem",
fontWeight: 600,
color: "var(--ifm-color-emphasis-700)",
}}
aria-label="Document last updated date"
data-testid="last-updated-top"
>
{updated && <span>{`Updated on ${updated}`}</span>}
{updated && published && <span>{" • "}</span>}
{published && <span>{`Published on ${published}`}</span>}
</div>
);
}
19 changes: 19 additions & 0 deletions src/components/DocStats/formatDate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export function formatDateUS(ts: number | string | undefined): string | null {
if (!ts) return null;

let date: Date;
if (typeof ts === 'number') {
// Heuristic: <1e12 = seconds, otherwise milliseconds
date = new Date(ts < 1e12 ? ts * 1000 : ts);
} else {
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return null;
date = d;
}

return new Intl.DateTimeFormat('en-US', {
month: 'short', // Jun
day: '2-digit', // 27
year: 'numeric', // 2025
}).format(date);
}
13 changes: 13 additions & 0 deletions src/css/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -1767,3 +1767,16 @@ html[data-theme='dark'] .sidebar-opensource > a::before {
background: linear-gradient(135deg, #3dc7f6, #00ade4);
box-shadow: 0 2px 4px rgba(61, 199, 246, 0.4);
}

[data-testid="last-updated-top"] {
display: block;
margin-top: -0.5rem;
margin-bottom: 1rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--ifm-color-emphasis-700);
}

.theme-last-updated {
display: none !important;
}
10 changes: 10 additions & 0 deletions src/theme/MDXComponents.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import DocsButton from "../components/DocsButton";
import DocsTag from "../components/DocsTag";
import Telemetry from "../components/Telemetry";
import HarnessApiData from "../components/HarnessApiData";
import LastUpdatedTop from "../components/DocStats/LastUpdatedTop";
import DocLastUpdated from "../components/DocStats/DocLastUpdated";
import DocPublished from "../components/DocStats/DocPublished";
import DocTimeToRead from "../components/DocStats/DocTimeToRead";
import DocStatsBlock from "../components/DocStats/DocStatsBlock";

export default {
// Re-use the default mapping
Expand All @@ -23,4 +28,9 @@ export default {
DocsTag: DocsTag,
Telemetry: Telemetry,
HarnessApiData: HarnessApiData,
LastUpdatedTop: LastUpdatedTop,
DocLastUpdated,
DocPublished,
DocTimeToRead,
DocStatsBlock,
};
1 change: 1 addition & 0 deletions static/img/icon-clock.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.