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.
@@ -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 filePath = path.join(this.contentDir, pagePath) + '.mdx';
55
- return fs.readFileSync(filePath, 'utf8');
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?: string;
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: getIconName(page.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, getIconName } from './docs-types';
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: getIconName(group.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: getIconName(tab.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: getIconName(anchor.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: getIconName(anchor.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 -> seo.metatags (merged, Jamdesk takes priority)
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, 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 -> seo.metatags
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
- warnings.push(
67
- 'metadata is deprecated. Use seo: { metatags: { ... } } instead.'
68
- );
69
- const existingMetatags = normalized.seo?.metatags || {};
70
- normalized.seo = {
71
- ...normalized.seo,
72
- metatags: {
73
- ...metadata,
74
- ...existingMetatags,
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
+ }