jamdesk 1.1.143 → 1.1.145
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/normalize-config.d.ts +7 -3
- package/dist/lib/normalize-config.d.ts.map +1 -1
- package/dist/lib/normalize-config.js +21 -11
- package/dist/lib/normalize-config.js.map +1 -1
- package/package.json +1 -1
- package/vendored/components/layout/BannerBar.tsx +80 -0
- package/vendored/components/layout/LayoutWrapper.tsx +59 -50
- package/vendored/components/mdx/NativeSubscribeForm.tsx +53 -12
- package/vendored/components/navigation/Header.tsx +9 -5
- package/vendored/components/navigation/LastUpdated.tsx +44 -0
- package/vendored/components/navigation/Sidebar.tsx +1 -1
- package/vendored/components/search/SearchModal.tsx +10 -4
- package/vendored/lib/banner-dismiss.ts +53 -0
- package/vendored/lib/content-loader.ts +17 -2
- package/vendored/lib/docs-types.ts +15 -2
- package/vendored/lib/inline-markdown.tsx +59 -0
- package/vendored/lib/layout-helpers.tsx +27 -0
- package/vendored/lib/navigation-resolver.ts +5 -5
- package/vendored/lib/normalize-config.ts +32 -15
- package/vendored/lib/page-timestamps.ts +144 -0
- package/vendored/lib/render-doc-page.tsx +6 -0
- package/vendored/public/_jd/fonts/fontawesome/css/all.css +68 -1
- package/vendored/public/_jd/fonts/fontawesome/css/all.min.css +3 -1
- package/vendored/public/_jd/fonts/fontawesome/webfonts/fa-sharp-duotone-solid-900.woff2 +0 -0
- package/vendored/schema/docs-schema.json +3 -1
- package/vendored/workspace-package-lock.json +3 -3
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Banner dismissal persistence. The dismissal is keyed to a hash of the banner
|
|
2
|
+
// content so that editing the message makes it reappear for everyone.
|
|
3
|
+
// Modeled on lib/recent-searches.ts (SSR-safe localStorage access).
|
|
4
|
+
|
|
5
|
+
const PREFIX = 'jamdesk_banner_dismissed_';
|
|
6
|
+
|
|
7
|
+
export function bannerDismissKey(slug: string): string {
|
|
8
|
+
return `${PREFIX}${slug}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Small deterministic djb2 hash → base36. Not cryptographic; only needs to
|
|
12
|
+
// change when the content changes and stay stable otherwise.
|
|
13
|
+
export function bannerHash(content: string): string {
|
|
14
|
+
let h = 5381;
|
|
15
|
+
for (let i = 0; i < content.length; i++) {
|
|
16
|
+
h = ((h << 5) + h + content.charCodeAt(i)) | 0;
|
|
17
|
+
}
|
|
18
|
+
return (h >>> 0).toString(36);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isBannerDismissed(slug: string, content: string): boolean {
|
|
22
|
+
if (typeof window === 'undefined') return false;
|
|
23
|
+
try {
|
|
24
|
+
return localStorage.getItem(bannerDismissKey(slug)) === bannerHash(content);
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function dismissBanner(slug: string, content: string): void {
|
|
31
|
+
if (typeof window === 'undefined') return;
|
|
32
|
+
try {
|
|
33
|
+
localStorage.setItem(bannerDismissKey(slug), bannerHash(content));
|
|
34
|
+
} catch {
|
|
35
|
+
// Storage unavailable (private mode, quota) — fail open; banner just won't
|
|
36
|
+
// remember the dismissal.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Inline JS for the pre-paint <head> script. When the stored hash matches the
|
|
42
|
+
* current content it sets `data-jd-banner-dismissed` on <html>, so CSS can hide
|
|
43
|
+
* the bar before first paint (no flash for repeat visitors). slug + hash are
|
|
44
|
+
* JSON-encoded into the string for safety.
|
|
45
|
+
*/
|
|
46
|
+
export function bannerNoFlashScript(slug: string, content: string): string {
|
|
47
|
+
// Defense-in-depth: JSON.stringify escapes `<` in V8 but the spec does not
|
|
48
|
+
// require it, so escape it ourselves — a slug can never break out of the
|
|
49
|
+
// surrounding <script> tag.
|
|
50
|
+
const key = JSON.stringify(bannerDismissKey(slug)).replace(/</g, '\\u003c');
|
|
51
|
+
const hash = JSON.stringify(bannerHash(content)).replace(/</g, '\\u003c');
|
|
52
|
+
return `(function(){try{if(localStorage.getItem(${key})===${hash}){document.documentElement.setAttribute('data-jd-banner-dismissed','')}}catch(e){}})();`;
|
|
53
|
+
}
|
|
@@ -14,6 +14,7 @@ import fs from 'fs';
|
|
|
14
14
|
import path from 'path';
|
|
15
15
|
import { parseFrontmatterLenient } from './frontmatter-utils';
|
|
16
16
|
import { getDocsConfig as getStaticDocsConfig, getContentDir } from './docs';
|
|
17
|
+
import { getGitLastModified, injectLastUpdated } from './page-timestamps';
|
|
17
18
|
import {
|
|
18
19
|
getDocsConfig as getIsrDocsConfig,
|
|
19
20
|
getMdxContent,
|
|
@@ -41,6 +42,9 @@ export interface ContentLoader {
|
|
|
41
42
|
*/
|
|
42
43
|
class StaticContentLoader implements ContentLoader {
|
|
43
44
|
private contentDir: string;
|
|
45
|
+
// Per-page git date cache (dev preview): avoids a git spawn on every re-render.
|
|
46
|
+
// null = looked up, no committed date (untracked/new file or non-git dir).
|
|
47
|
+
private dateCache = new Map<string, string | null>();
|
|
44
48
|
|
|
45
49
|
constructor() {
|
|
46
50
|
this.contentDir = getContentDir();
|
|
@@ -51,8 +55,19 @@ class StaticContentLoader implements ContentLoader {
|
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
async getContent(pagePath: string): Promise<string> {
|
|
54
|
-
const
|
|
55
|
-
|
|
58
|
+
const relPath = pagePath + '.mdx';
|
|
59
|
+
const content = fs.readFileSync(path.join(this.contentDir, relPath), 'utf8');
|
|
60
|
+
|
|
61
|
+
// Dev parity with the build: when metadata.timestamp is on, inject the
|
|
62
|
+
// page's last git-commit date so the renderer shows "Last updated on …".
|
|
63
|
+
if (!this.getConfig().metadata?.timestamp) return content;
|
|
64
|
+
|
|
65
|
+
let date = this.dateCache.get(pagePath);
|
|
66
|
+
if (date === undefined) {
|
|
67
|
+
date = getGitLastModified(this.contentDir, relPath);
|
|
68
|
+
this.dateCache.set(pagePath, date);
|
|
69
|
+
}
|
|
70
|
+
return date ? injectLastUpdated(content, date) : content;
|
|
56
71
|
}
|
|
57
72
|
|
|
58
73
|
async getAllPaths(): Promise<string[]> {
|
|
@@ -685,7 +685,7 @@ export interface SearchConfig {
|
|
|
685
685
|
popularPages?: Array<{
|
|
686
686
|
title: string;
|
|
687
687
|
slug: string;
|
|
688
|
-
icon?:
|
|
688
|
+
icon?: IconConfig;
|
|
689
689
|
}>;
|
|
690
690
|
}
|
|
691
691
|
|
|
@@ -960,7 +960,7 @@ export function normalizeNavPage(page: NavigationPage): {
|
|
|
960
960
|
.pop()
|
|
961
961
|
?.replace(/-/g, ' ')
|
|
962
962
|
.replace(/\b\w/g, (l) => l.toUpperCase()) || page.page,
|
|
963
|
-
icon:
|
|
963
|
+
icon: getIconString(page.icon),
|
|
964
964
|
tag: page.tag,
|
|
965
965
|
};
|
|
966
966
|
}
|
|
@@ -1024,6 +1024,19 @@ export function getIconName(icon: IconConfig | undefined): string | undefined {
|
|
|
1024
1024
|
return icon.name;
|
|
1025
1025
|
}
|
|
1026
1026
|
|
|
1027
|
+
/**
|
|
1028
|
+
* Flatten an icon config to the string getIconClass() understands, preserving
|
|
1029
|
+
* the Font Awesome style as a "style/name" prefix (every IconStyle value is a
|
|
1030
|
+
* getIconClass style prefix). Use this — not getIconName — wherever an icon is
|
|
1031
|
+
* rendered, so a configured `style` survives the flatten. `library` is
|
|
1032
|
+
* intentionally dropped: all icons render via Font Awesome.
|
|
1033
|
+
*/
|
|
1034
|
+
export function getIconString(icon: IconConfig | undefined): string | undefined {
|
|
1035
|
+
if (!icon) return undefined;
|
|
1036
|
+
if (typeof icon === 'string') return icon;
|
|
1037
|
+
return icon.style ? `${icon.style}/${icon.name}` : icon.name;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1027
1040
|
/**
|
|
1028
1041
|
* Get icon library from config
|
|
1029
1042
|
*/
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
// Allow only safe URL schemes; everything else (javascript:, data:, etc.) is
|
|
4
|
+
// dropped so author content can't inject script URLs into the banner.
|
|
5
|
+
function sanitizeUrl(url: string): string | null {
|
|
6
|
+
const trimmed = url.trim();
|
|
7
|
+
// A single leading slash is a same-origin path; `//host` is protocol-relative
|
|
8
|
+
// (off-origin) and must NOT be treated as a safe relative link.
|
|
9
|
+
if (/^(\/(?!\/)|#|\.\.?\/)/.test(trimmed)) return trimmed; // relative / anchor
|
|
10
|
+
// mailto: is intentional — banner authors are trusted (docs.json owners);
|
|
11
|
+
// subject/body params are an accepted trade-off.
|
|
12
|
+
if (/^(https?:|mailto:)/i.test(trimmed)) return trimmed;
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Known limitation: a URL containing ')' truncates at the first ')'. Fine for
|
|
17
|
+
// short banner copy; authors should percent-encode parens in link targets.
|
|
18
|
+
// Matches [text](url), then **bold**, then *italic* — alternation order matters
|
|
19
|
+
// so `**` is consumed by the bold branch before the italic branch sees it.
|
|
20
|
+
const TOKEN = /\[([^\]]+)\]\(([^)\s]+)\)|\*\*([^*]+)\*\*|\*([^*]+)\*/g;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Render a string with basic inline markdown (links, bold, italic) as safe
|
|
24
|
+
* React nodes. No raw HTML, no nesting, no custom components.
|
|
25
|
+
*/
|
|
26
|
+
export function renderInlineMarkdown(input: string): React.ReactNode[] {
|
|
27
|
+
const nodes: React.ReactNode[] = [];
|
|
28
|
+
let lastIndex = 0;
|
|
29
|
+
let key = 0;
|
|
30
|
+
let m: RegExpExecArray | null;
|
|
31
|
+
TOKEN.lastIndex = 0;
|
|
32
|
+
while ((m = TOKEN.exec(input)) !== null) {
|
|
33
|
+
if (m.index > lastIndex) nodes.push(input.slice(lastIndex, m.index));
|
|
34
|
+
if (m[1] !== undefined) {
|
|
35
|
+
const href = sanitizeUrl(m[2]);
|
|
36
|
+
if (href) {
|
|
37
|
+
const external = /^https?:/i.test(href);
|
|
38
|
+
nodes.push(
|
|
39
|
+
<a
|
|
40
|
+
key={key++}
|
|
41
|
+
href={href}
|
|
42
|
+
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
|
43
|
+
>
|
|
44
|
+
{m[1]}
|
|
45
|
+
</a>,
|
|
46
|
+
);
|
|
47
|
+
} else {
|
|
48
|
+
nodes.push(m[1]); // unsafe URL → render the link text as plain text
|
|
49
|
+
}
|
|
50
|
+
} else if (m[3] !== undefined) {
|
|
51
|
+
nodes.push(<strong key={key++}>{m[3]}</strong>);
|
|
52
|
+
} else if (m[4] !== undefined) {
|
|
53
|
+
nodes.push(<em key={key++}>{m[4]}</em>);
|
|
54
|
+
}
|
|
55
|
+
lastIndex = TOKEN.lastIndex;
|
|
56
|
+
}
|
|
57
|
+
if (lastIndex < input.length) nodes.push(input.slice(lastIndex));
|
|
58
|
+
return nodes;
|
|
59
|
+
}
|
|
@@ -28,6 +28,7 @@ import { getAnalyticsScript } from '@/lib/analytics-script';
|
|
|
28
28
|
import { AgentDirective } from './agent-directive';
|
|
29
29
|
import { fetchCustomCss, fetchCustomJs } from '@/lib/r2-content';
|
|
30
30
|
import { toHreflang } from '@/lib/language-utils';
|
|
31
|
+
import { bannerNoFlashScript } from '@/lib/banner-dismiss';
|
|
31
32
|
|
|
32
33
|
const scrollLockBootstrap = `
|
|
33
34
|
(function() {
|
|
@@ -428,6 +429,13 @@ export async function DocsChrome({
|
|
|
428
429
|
? getAnalyticsScript(resolvedProjectSlug)
|
|
429
430
|
: null;
|
|
430
431
|
|
|
432
|
+
// Global banner: only emit the pre-paint no-flash guard for a dismissible
|
|
433
|
+
// banner (a non-dismissible banner is never hidden, so the guard would risk
|
|
434
|
+
// hiding a mandatory message via a stale dismissal hash). Trimmed content
|
|
435
|
+
// must match what BannerBar hashes.
|
|
436
|
+
const bannerContent = config.banner?.content?.trim() || '';
|
|
437
|
+
const bannerNoFlash = !embed && !!bannerContent && config.banner?.dismissible === true && !!resolvedProjectSlug;
|
|
438
|
+
|
|
431
439
|
// Font Awesome CSS uses preinit (not preload) so React 19 emits the
|
|
432
440
|
// stylesheet link in <head> at SSR time. The previous approach used a
|
|
433
441
|
// beforeInteractive Script that injected <link rel=stylesheet> at runtime —
|
|
@@ -473,6 +481,25 @@ export async function DocsChrome({
|
|
|
473
481
|
'<style>html[data-scroll-locked] #content-scroll-container{overflow-y:auto !important;}</style>',
|
|
474
482
|
}}
|
|
475
483
|
/>
|
|
484
|
+
{/* Banner no-flash: hide a previously-dismissed (dismissible) banner
|
|
485
|
+
before first paint. The <style> is inert until the script sets the
|
|
486
|
+
attribute; the script is prod-gated like the analytics inline script. */}
|
|
487
|
+
{bannerNoFlash && (
|
|
488
|
+
<>
|
|
489
|
+
<style
|
|
490
|
+
dangerouslySetInnerHTML={{
|
|
491
|
+
__html: 'html[data-jd-banner-dismissed] [data-jd-banner]{display:none!important}',
|
|
492
|
+
}}
|
|
493
|
+
/>
|
|
494
|
+
{process.env.NODE_ENV === 'production' && (
|
|
495
|
+
<script
|
|
496
|
+
dangerouslySetInnerHTML={{
|
|
497
|
+
__html: bannerNoFlashScript(resolvedProjectSlug, bannerContent),
|
|
498
|
+
}}
|
|
499
|
+
/>
|
|
500
|
+
)}
|
|
501
|
+
</>
|
|
502
|
+
)}
|
|
476
503
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
477
504
|
{/* Embed render (widget modal): default every in-frame link to a new tab.
|
|
478
505
|
Clicking a doc link should open the full docs site in a new tab, not
|
|
@@ -18,7 +18,7 @@ import type {
|
|
|
18
18
|
ExternalAnchorConfig,
|
|
19
19
|
} from './docs-types';
|
|
20
20
|
|
|
21
|
-
import { normalizeNavPage,
|
|
21
|
+
import { normalizeNavPage, getIconString } from './docs-types';
|
|
22
22
|
import { getLanguageDisplayInfo, extractLanguageFromPath } from './language-utils';
|
|
23
23
|
import { evictOldest } from './cache-utils';
|
|
24
24
|
|
|
@@ -225,7 +225,7 @@ function resolveGroup(group: GroupConfig): ResolvedGroup {
|
|
|
225
225
|
|
|
226
226
|
return {
|
|
227
227
|
name: group.group,
|
|
228
|
-
icon:
|
|
228
|
+
icon: getIconString(group.icon),
|
|
229
229
|
tag: group.tag,
|
|
230
230
|
pages: resolvedPages,
|
|
231
231
|
expanded: group.expanded,
|
|
@@ -268,7 +268,7 @@ function resolveTabGroups(tab: TabConfig): ResolvedGroup[] {
|
|
|
268
268
|
function resolveTab(tab: TabConfig): ResolvedTab {
|
|
269
269
|
return {
|
|
270
270
|
name: tab.tab,
|
|
271
|
-
icon:
|
|
271
|
+
icon: getIconString(tab.icon),
|
|
272
272
|
href: tab.href,
|
|
273
273
|
isExternal: !!tab.href && !tab.groups && !tab.pages,
|
|
274
274
|
};
|
|
@@ -465,7 +465,7 @@ export function resolveNavigation(
|
|
|
465
465
|
.map((anchor: ExternalAnchorConfig) => ({
|
|
466
466
|
name: anchor.name,
|
|
467
467
|
href: anchor.href,
|
|
468
|
-
icon:
|
|
468
|
+
icon: getIconString(anchor.icon),
|
|
469
469
|
}));
|
|
470
470
|
}
|
|
471
471
|
|
|
@@ -476,7 +476,7 @@ export function resolveNavigation(
|
|
|
476
476
|
result.externalAnchors.push({
|
|
477
477
|
name: anchor.anchor,
|
|
478
478
|
href: anchor.href,
|
|
479
|
-
icon:
|
|
479
|
+
icon: getIconString(anchor.icon),
|
|
480
480
|
});
|
|
481
481
|
}
|
|
482
482
|
}
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* Field mappings:
|
|
11
11
|
* - modeToggle.default -> appearance.default
|
|
12
12
|
* - modeToggle.isHidden -> appearance.strict
|
|
13
|
-
* - metadata ->
|
|
13
|
+
* - metadata.timestamp -> config.metadata.timestamp (real feature flag, kept)
|
|
14
|
+
* - metadata.<other> -> seo.metatags (legacy Mintlify metatags, deprecated)
|
|
14
15
|
* - navbar.style -> preserved but ignored in rendering
|
|
15
16
|
* - layout -> removed (theme determines layout)
|
|
16
17
|
*/
|
|
@@ -18,11 +19,15 @@
|
|
|
18
19
|
import type { DocsConfig, ModeToggleConfig, MintlifyLayout } from './docs-types';
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
|
-
* Input config type that includes Mintlify compatibility fields
|
|
22
|
+
* Input config type that includes Mintlify compatibility fields.
|
|
23
|
+
*
|
|
24
|
+
* `metadata` is overloaded for backward compatibility: historically it was a
|
|
25
|
+
* Mintlify metatags string-map, but it now also carries the real `timestamp`
|
|
26
|
+
* feature flag. Accept both shapes here and split them in normalizeConfig.
|
|
22
27
|
*/
|
|
23
28
|
interface DocsConfigInput extends Omit<DocsConfig, 'modeToggle' | 'metadata' | 'layout'> {
|
|
24
29
|
modeToggle?: ModeToggleConfig;
|
|
25
|
-
metadata?: Record<string,
|
|
30
|
+
metadata?: Record<string, unknown>;
|
|
26
31
|
layout?: MintlifyLayout;
|
|
27
32
|
}
|
|
28
33
|
|
|
@@ -61,19 +66,31 @@ export function normalizeConfig(config: DocsConfigInput): NormalizeResult {
|
|
|
61
66
|
}
|
|
62
67
|
}
|
|
63
68
|
|
|
64
|
-
// 2. Normalize metadata
|
|
69
|
+
// 2. Normalize metadata.
|
|
70
|
+
// - `timestamp` is a real page-metadata feature flag (show last-modified
|
|
71
|
+
// date on every page) -> config.metadata.timestamp. NOT deprecated.
|
|
72
|
+
// - any OTHER keys are legacy Mintlify metatags -> seo.metatags (deprecated).
|
|
65
73
|
if (metadata) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
const { timestamp, ...legacyMetatags } = metadata;
|
|
75
|
+
|
|
76
|
+
if (timestamp !== undefined) {
|
|
77
|
+
normalized.metadata = { timestamp: timestamp === true };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const legacyKeys = Object.keys(legacyMetatags);
|
|
81
|
+
if (legacyKeys.length > 0) {
|
|
82
|
+
warnings.push(
|
|
83
|
+
'metadata is deprecated. Use seo: { metatags: { ... } } instead.'
|
|
84
|
+
);
|
|
85
|
+
const existingMetatags = normalized.seo?.metatags || {};
|
|
86
|
+
normalized.seo = {
|
|
87
|
+
...normalized.seo,
|
|
88
|
+
metatags: {
|
|
89
|
+
...(legacyMetatags as Record<string, string>),
|
|
90
|
+
...existingMetatags,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
77
94
|
}
|
|
78
95
|
|
|
79
96
|
// 3. Warn about layout
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page "last updated" timestamps (build-time).
|
|
3
|
+
*
|
|
4
|
+
* When a project enables `metadata.timestamp` in docs.json, the build computes
|
|
5
|
+
* the last git-commit date for each page and injects it into the page's
|
|
6
|
+
* frontmatter (`lastUpdated: YYYY-MM-DD`). The renderer reads that field and
|
|
7
|
+
* shows a "Last updated on <date>" line — see components/navigation/LastUpdated.
|
|
8
|
+
*
|
|
9
|
+
* Why build-time: git history only exists in the Cloud Run clone, not at ISR
|
|
10
|
+
* render time (which sees only R2 content). The injected date rides R2 with the
|
|
11
|
+
* page and needs no git at render.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { execFileSync } from 'child_process';
|
|
15
|
+
// Extension-less import: this module is also pulled into the Next.js/ISR bundle
|
|
16
|
+
// (via content-loader), and Next prod rejects `.js` specifiers in TS source.
|
|
17
|
+
import { logger } from '../shared/logger';
|
|
18
|
+
|
|
19
|
+
// Marks a commit header line in `git log` output so it can't be confused with a
|
|
20
|
+
// file path (paths never contain control characters with core.quotePath=false).
|
|
21
|
+
const COMMIT_MARKER = '\x01';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Production clones are shallow (`--depth 1`), so `git log` would report the
|
|
25
|
+
* single clone commit's date for every file. Deepen to full history first.
|
|
26
|
+
* No-op for already-complete clones (e.g. local `jamdesk dev`). Best-effort:
|
|
27
|
+
* on failure the date map simply falls back to whatever history is present.
|
|
28
|
+
*/
|
|
29
|
+
function ensureFullHistory(repoDir: string): void {
|
|
30
|
+
try {
|
|
31
|
+
const isShallow = execFileSync(
|
|
32
|
+
'git',
|
|
33
|
+
['-C', repoDir, 'rev-parse', '--is-shallow-repository'],
|
|
34
|
+
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] },
|
|
35
|
+
).trim();
|
|
36
|
+
if (isShallow !== 'true') return;
|
|
37
|
+
|
|
38
|
+
execFileSync(
|
|
39
|
+
'git',
|
|
40
|
+
['-C', repoDir, 'fetch', '--unshallow', '--quiet'],
|
|
41
|
+
{ encoding: 'utf-8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'] },
|
|
42
|
+
);
|
|
43
|
+
} catch {
|
|
44
|
+
// Deliberately log no error detail: a failed git fetch can echo the
|
|
45
|
+
// tokenized origin URL in its output, which must not reach logs.
|
|
46
|
+
logger.warn('Could not deepen git history for timestamps; dates may reflect only the latest commit');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Map each repo-relative file path to the date (YYYY-MM-DD) of the most recent
|
|
52
|
+
* commit that added or modified it, via a single `git log` walk.
|
|
53
|
+
*
|
|
54
|
+
* Returns an empty map (logging a warning) if git is unavailable or the repo
|
|
55
|
+
* has no history — callers treat a missing entry as "no timestamp for this page".
|
|
56
|
+
*
|
|
57
|
+
* Caveat: renames reset a file's date to the rename commit (no `--follow`, which
|
|
58
|
+
* cannot run in a single multi-file pass). Acceptable for docs.
|
|
59
|
+
*/
|
|
60
|
+
export function buildGitLastModifiedMap(repoDir: string): Map<string, string> {
|
|
61
|
+
const map = new Map<string, string>();
|
|
62
|
+
|
|
63
|
+
ensureFullHistory(repoDir);
|
|
64
|
+
|
|
65
|
+
let output: string;
|
|
66
|
+
try {
|
|
67
|
+
output = execFileSync(
|
|
68
|
+
'git',
|
|
69
|
+
[
|
|
70
|
+
'-C', repoDir,
|
|
71
|
+
'-c', 'core.quotePath=false',
|
|
72
|
+
'log',
|
|
73
|
+
'--no-merges',
|
|
74
|
+
'--diff-filter=ACMRT',
|
|
75
|
+
'--name-only',
|
|
76
|
+
`--format=${COMMIT_MARKER}%cs`,
|
|
77
|
+
],
|
|
78
|
+
{ encoding: 'utf-8', maxBuffer: 512 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] },
|
|
79
|
+
);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
logger.warn('Failed to compute git last-modified map; timestamps omitted', {
|
|
82
|
+
error: err instanceof Error ? err.message : String(err),
|
|
83
|
+
});
|
|
84
|
+
return map;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Commits stream newest-first, so the first time a path appears is its most
|
|
88
|
+
// recent change — keep that and ignore later (older) occurrences.
|
|
89
|
+
let currentDate = '';
|
|
90
|
+
for (const line of output.split('\n')) {
|
|
91
|
+
if (line.startsWith(COMMIT_MARKER)) {
|
|
92
|
+
currentDate = line.slice(COMMIT_MARKER.length).trim();
|
|
93
|
+
} else if (line && currentDate && !map.has(line)) {
|
|
94
|
+
map.set(line, currentDate);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return map;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Last-modified date (YYYY-MM-DD) for a single file, for the local `jamdesk dev`
|
|
103
|
+
* preview where pages are read one at a time (vs. the batch map used at build).
|
|
104
|
+
* Returns null for an untracked/new file or a non-git directory.
|
|
105
|
+
*/
|
|
106
|
+
export function getGitLastModified(dir: string, relPath: string): string | null {
|
|
107
|
+
try {
|
|
108
|
+
const out = execFileSync(
|
|
109
|
+
'git',
|
|
110
|
+
['-C', dir, 'log', '-1', '--format=%cs', '--', relPath],
|
|
111
|
+
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] },
|
|
112
|
+
).trim();
|
|
113
|
+
return out || null;
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const FRONTMATTER_RE = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
|
|
120
|
+
const LAST_UPDATED_LINE_RE = /^lastUpdated:.*$/m;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Inject (or replace) a `lastUpdated: <date>` line in the page's YAML
|
|
124
|
+
* frontmatter, returning the new content. Adds a frontmatter block when the
|
|
125
|
+
* page has none. Preserves the page body verbatim.
|
|
126
|
+
*/
|
|
127
|
+
export function injectLastUpdated(content: string, date: string): string {
|
|
128
|
+
// Quote the value so YAML keeps it a string — an unquoted YYYY-MM-DD is
|
|
129
|
+
// auto-parsed into a Date, which then renders as a timezone-shifted
|
|
130
|
+
// Date.toString() in the <time dateTime> attribute.
|
|
131
|
+
const line = `lastUpdated: "${date}"`;
|
|
132
|
+
const match = FRONTMATTER_RE.exec(content);
|
|
133
|
+
|
|
134
|
+
if (!match) {
|
|
135
|
+
return `---\n${line}\n---\n\n${content}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const inner = match[1];
|
|
139
|
+
const newInner = LAST_UPDATED_LINE_RE.test(inner)
|
|
140
|
+
? inner.replace(LAST_UPDATED_LINE_RE, line)
|
|
141
|
+
: `${inner}\n${line}`;
|
|
142
|
+
|
|
143
|
+
return `---\n${newInner}\n---\n${content.slice(match[0].length)}`;
|
|
144
|
+
}
|
|
@@ -20,6 +20,7 @@ import { TableOfContents } from '@/components/navigation/TableOfContents';
|
|
|
20
20
|
import { PageColumns } from '@/components/layout/PageColumns';
|
|
21
21
|
import { EmbedLinkInterceptor } from '@/components/layout/EmbedLinkInterceptor';
|
|
22
22
|
import { PageNavigation } from '@/components/navigation/PageNavigation';
|
|
23
|
+
import { LastUpdated } from '@/components/navigation/LastUpdated';
|
|
23
24
|
import { SocialFooter } from '@/components/navigation/SocialFooter';
|
|
24
25
|
import { ApiPageWrapper } from '@/components/mdx/ApiPage';
|
|
25
26
|
import { OpenApiEndpoint } from '@/components/mdx/OpenApiEndpoint';
|
|
@@ -148,6 +149,9 @@ interface FrontmatterData {
|
|
|
148
149
|
mode?: string;
|
|
149
150
|
hideFooter?: boolean;
|
|
150
151
|
rss?: boolean;
|
|
152
|
+
// Injected at build time (YYYY-MM-DD) when metadata.timestamp is enabled —
|
|
153
|
+
// the date of the last git commit that touched this page. See LastUpdated.
|
|
154
|
+
lastUpdated?: string;
|
|
151
155
|
[key: string]: unknown;
|
|
152
156
|
}
|
|
153
157
|
|
|
@@ -767,6 +771,7 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
|
|
|
767
771
|
</div>
|
|
768
772
|
|
|
769
773
|
{!embed && <PageNavigation currentSlug={slug.join('/')} config={config} />}
|
|
774
|
+
{!embed && config.metadata?.timestamp && <LastUpdated date={data.lastUpdated} />}
|
|
770
775
|
<SocialFooter config={config} hidden={data.hideFooter} embed={embed} projectSlug={projectSlug ?? undefined} />
|
|
771
776
|
</article>,
|
|
772
777
|
)}
|
|
@@ -822,6 +827,7 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
|
|
|
822
827
|
|
|
823
828
|
{!embed && <PageNavigation currentSlug={slug.join('/')} config={config} isWideMode={isWideMode || hasPanel} />}
|
|
824
829
|
</div>
|
|
830
|
+
{!embed && config.metadata?.timestamp && <LastUpdated date={data.lastUpdated} />}
|
|
825
831
|
<SocialFooter config={config} hidden={data.hideFooter} embed={embed} projectSlug={projectSlug ?? undefined} />
|
|
826
832
|
</article>,
|
|
827
833
|
);
|
|
@@ -21846,4 +21846,71 @@
|
|
|
21846
21846
|
font-display: block;
|
|
21847
21847
|
src: url("../webfonts/fa-v4compatibility.woff2") format("woff2");
|
|
21848
21848
|
unicode-range: U+F041, U+F047, U+F065-F066, U+F07D-F07E, U+F080, U+F08B, U+F08E, U+F090, U+F09A, U+F0AC, U+F0AE, U+F0B2, U+F0D0, U+F0D6, U+F0E4, U+F0EC, U+F10A-F10B, U+F123, U+F13E, U+F148-F149, U+F14C, U+F156, U+F15E, U+F160-F161, U+F163, U+F175-F178, U+F195, U+F1F8, U+F219, U+F27A;
|
|
21849
|
-
}
|
|
21849
|
+
}
|
|
21850
|
+
/* jamdesk: sharp-duotone-solid bundled here (webfont fa-sharp-duotone-solid-900.woff2). FA's trimmed all.min.css omitted the sharp-duotone family, so sharp-duotone-solid icons rendered as doubled glyphs. See builder/CLAUDE.md icon note. */
|
|
21851
|
+
:root, :host {
|
|
21852
|
+
--fa-family-sharp-duotone: "Font Awesome 7 Sharp Duotone";
|
|
21853
|
+
--fa-font-sharp-duotone-solid: normal 900 1em/1 var(--fa-family-sharp-duotone);
|
|
21854
|
+
/* deprecated: this older custom property will be removed next major release */
|
|
21855
|
+
--fa-style-family-sharp-duotone: var(--fa-family-sharp-duotone);
|
|
21856
|
+
}
|
|
21857
|
+
|
|
21858
|
+
@font-face {
|
|
21859
|
+
font-family: "Font Awesome 7 Sharp Duotone";
|
|
21860
|
+
font-style: normal;
|
|
21861
|
+
font-weight: 900;
|
|
21862
|
+
font-display: block;
|
|
21863
|
+
src: url("../webfonts/fa-sharp-duotone-solid-900.woff2");
|
|
21864
|
+
}
|
|
21865
|
+
.fasds {
|
|
21866
|
+
--fa-family: var(--fa-family-sharp-duotone);
|
|
21867
|
+
--fa-style: 900;
|
|
21868
|
+
position: relative;
|
|
21869
|
+
letter-spacing: normal;
|
|
21870
|
+
}
|
|
21871
|
+
|
|
21872
|
+
.fa-sharp-duotone {
|
|
21873
|
+
--fa-family: var(--fa-family-sharp-duotone);
|
|
21874
|
+
position: relative;
|
|
21875
|
+
letter-spacing: normal;
|
|
21876
|
+
}
|
|
21877
|
+
|
|
21878
|
+
.fa-solid {
|
|
21879
|
+
--fa-style: 900;
|
|
21880
|
+
}
|
|
21881
|
+
|
|
21882
|
+
.fasds::before,
|
|
21883
|
+
.fa-sharp-duotone::before {
|
|
21884
|
+
position: absolute;
|
|
21885
|
+
color: var(--fa-primary-color, currentColor);
|
|
21886
|
+
opacity: var(--fa-primary-opacity, 1);
|
|
21887
|
+
}
|
|
21888
|
+
|
|
21889
|
+
.fasds::after,
|
|
21890
|
+
.fa-sharp-duotone::after {
|
|
21891
|
+
color: var(--fa-secondary-color, currentColor);
|
|
21892
|
+
opacity: var(--fa-secondary-opacity, 0.4);
|
|
21893
|
+
}
|
|
21894
|
+
|
|
21895
|
+
.fa-swap-opacity .fasds::before,
|
|
21896
|
+
.fa-swap-opacity .fa-sharp-duotone::before,
|
|
21897
|
+
.fa-swap-opacity.fasds::before,
|
|
21898
|
+
.fa-swap-opacity.fa-sharp-duotone::before {
|
|
21899
|
+
opacity: var(--fa-secondary-opacity, 0.4);
|
|
21900
|
+
}
|
|
21901
|
+
|
|
21902
|
+
.fa-swap-opacity .fasds::after,
|
|
21903
|
+
.fa-swap-opacity .fa-sharp-duotone::after,
|
|
21904
|
+
.fa-swap-opacity.fasds::after,
|
|
21905
|
+
.fa-swap-opacity.fa-sharp-duotone::after {
|
|
21906
|
+
opacity: var(--fa-primary-opacity, 1);
|
|
21907
|
+
}
|
|
21908
|
+
|
|
21909
|
+
.fa-li.fasds,
|
|
21910
|
+
.fa-li.fa-sharp-duotone,
|
|
21911
|
+
.fa-stack-1x.fasds,
|
|
21912
|
+
.fa-stack-1x.fa-sharp-duotone,
|
|
21913
|
+
.fa-stack-2x.fasds,
|
|
21914
|
+
.fa-stack-2x.fa-sharp-duotone {
|
|
21915
|
+
position: absolute;
|
|
21916
|
+
}
|