jamdesk 1.1.144 → 1.1.146

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[]> {
@@ -15,6 +15,8 @@ export interface HeadingInfo {
15
15
  line: number; // 1-indexed
16
16
  /** When the entry is a <Step> inside a <Steps> block, this is its 1-based index within the block. */
17
17
  stepNumber?: number;
18
+ /** True when the entry came from an <Update label="..."> anchor (vs a markdown heading). */
19
+ isUpdate?: boolean;
18
20
  }
19
21
 
20
22
  /**
@@ -44,7 +46,13 @@ export function generateSlug(text: string): string {
44
46
 
45
47
  const HEADING_REGEX = /^(#{1,6})\s+(.+)$/;
46
48
  const FENCE_REGEX = /^(`{3,}|~{3,})/;
47
- const UPDATE_LABEL_REGEX = /<Update\s+label=["']([^"']+)["']/;
49
+ // Global + matchAll (like STEP_TITLE_REGEX below) so multiple inline <Update>
50
+ // tags on one line each get an anchor, and `[^>]*` so `label=` need not be the
51
+ // first attribute (e.g. `<Update date=".." label="..">`). Alternation (not a
52
+ // character class) keeps an apostrophe inside a double-quoted label from
53
+ // terminating the capture. MUST only be used with matchAll — direct test/exec
54
+ // would share lastIndex across calls and miscount.
55
+ const UPDATE_LABEL_REGEX = /<Update\s+[^>]*label=(?:"([^"]+)"|'([^']+)')/g;
48
56
  const STEPS_OPEN_REGEX = /<Steps(\s|>)/;
49
57
  const STEPS_CLOSE_REGEX = /<\/Steps>/;
50
58
  // Global flag — we iterate with matchAll so authors who inline multiple
@@ -114,13 +122,11 @@ export function extractHeadings(content: string): HeadingInfo[] {
114
122
  }
115
123
  }
116
124
 
117
- const updateMatch = line.match(UPDATE_LABEL_REGEX);
118
- if (updateMatch) {
119
- const text = updateMatch[1];
120
- if (generateSlug(text)) {
121
- const id = slugger.slug(text);
122
- headings.push({ id, text, level: 2, line: i + 1 });
123
- }
125
+ for (const match of line.matchAll(UPDATE_LABEL_REGEX)) {
126
+ const text = match[1] ?? match[2];
127
+ if (!generateSlug(text)) continue;
128
+ const id = slugger.slug(text);
129
+ headings.push({ id, text, level: 2, line: i + 1, isUpdate: true });
124
130
  }
125
131
 
126
132
  if (inStepsBlock) {
@@ -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
@@ -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';
@@ -55,6 +56,7 @@ import { recmaGuardExpressions } from '@/lib/recma-guard-expressions';
55
56
  import { extractInlineComponents } from '@/lib/process-mdx-with-exports';
56
57
  import { extractHeadings } from '@/lib/heading-extractor';
57
58
  import { StepSlugProvider, type StepSlugEntry } from '@/components/mdx/StepSlugContext';
59
+ import { UpdateSlugProvider, type UpdateSlugEntry } from '@/components/mdx/UpdateSlugContext';
58
60
  import { buildEndpointFromMdx } from '@/lib/build-endpoint-from-mdx';
59
61
  import { mdxSecurityOptions } from '@/lib/mdx-security-options';
60
62
  import { getContentDir } from '@/lib/docs';
@@ -148,6 +150,9 @@ interface FrontmatterData {
148
150
  mode?: string;
149
151
  hideFooter?: boolean;
150
152
  rss?: boolean;
153
+ // Injected at build time (YYYY-MM-DD) when metadata.timestamp is enabled —
154
+ // the date of the last git commit that touched this page. See LastUpdated.
155
+ lastUpdated?: string;
151
156
  [key: string]: unknown;
152
157
  }
153
158
 
@@ -361,9 +366,16 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
361
366
  logger.warn(`[MDX] preprocess failed for ${pagePath}: ${preprocessError}`);
362
367
  }
363
368
 
364
- const stepEntries: StepSlugEntry[] = extractHeadings(content)
369
+ // Both lists are derived from the SAME page-scoped slugger pass (extractHeadings),
370
+ // so Step and Update ids share one dedup namespace and never collide with each
371
+ // other or with markdown heading ids.
372
+ const pageHeadings = extractHeadings(content);
373
+ const stepEntries: StepSlugEntry[] = pageHeadings
365
374
  .filter(h => typeof h.stepNumber === 'number')
366
375
  .map(h => ({ title: h.text, slug: h.id }));
376
+ const updateEntries: UpdateSlugEntry[] = pageHeadings
377
+ .filter(h => h.isUpdate)
378
+ .map(h => ({ label: h.text, slug: h.id }));
367
379
 
368
380
  // [render-timing] proves the snippet/inline parallel win. With Promise.all,
369
381
  // wall-clock should be ~max(R2_snippets, CPU_inline). Compare against the
@@ -750,23 +762,26 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
750
762
  ) : (
751
763
  <MdxRenderBoundary fallback={<MdxErrorBlock label="page content" />}>
752
764
  <StepSlugProvider entries={stepEntries}>
753
- <MDXRemote
754
- source={effectiveSource}
755
- components={ComponentsWithUnknownFallback}
756
- options={{
757
- ...mdxSecurityOptions,
758
- mdxOptions: {
759
- ...getCommonMdxOptions(config, highlighter),
760
- recmaPlugins: [recmaCompoundComponents, recmaGuardExpressions],
761
- },
762
- }}
763
- />
765
+ <UpdateSlugProvider entries={updateEntries}>
766
+ <MDXRemote
767
+ source={effectiveSource}
768
+ components={ComponentsWithUnknownFallback}
769
+ options={{
770
+ ...mdxSecurityOptions,
771
+ mdxOptions: {
772
+ ...getCommonMdxOptions(config, highlighter),
773
+ recmaPlugins: [recmaCompoundComponents, recmaGuardExpressions],
774
+ },
775
+ }}
776
+ />
777
+ </UpdateSlugProvider>
764
778
  </StepSlugProvider>
765
779
  </MdxRenderBoundary>
766
780
  )}
767
781
  </div>
768
782
 
769
783
  {!embed && <PageNavigation currentSlug={slug.join('/')} config={config} />}
784
+ {!embed && config.metadata?.timestamp && <LastUpdated date={data.lastUpdated} />}
770
785
  <SocialFooter config={config} hidden={data.hideFooter} embed={embed} projectSlug={projectSlug ?? undefined} />
771
786
  </article>,
772
787
  )}
@@ -779,17 +794,19 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
779
794
  ) : (
780
795
  <MdxRenderBoundary fallback={<MdxErrorBlock label="page content" />}>
781
796
  <StepSlugProvider entries={stepEntries}>
782
- <MDXRemote
783
- source={content}
784
- components={ComponentsWithUnknownFallback}
785
- options={{
786
- ...mdxSecurityOptions,
787
- mdxOptions: {
788
- ...getCommonMdxOptions(config, highlighter),
789
- recmaPlugins: [recmaCompoundComponents, recmaGuardExpressions],
790
- },
791
- }}
792
- />
797
+ <UpdateSlugProvider entries={updateEntries}>
798
+ <MDXRemote
799
+ source={content}
800
+ components={ComponentsWithUnknownFallback}
801
+ options={{
802
+ ...mdxSecurityOptions,
803
+ mdxOptions: {
804
+ ...getCommonMdxOptions(config, highlighter),
805
+ recmaPlugins: [recmaCompoundComponents, recmaGuardExpressions],
806
+ },
807
+ }}
808
+ />
809
+ </UpdateSlugProvider>
793
810
  </StepSlugProvider>
794
811
  </MdxRenderBoundary>
795
812
  );
@@ -822,6 +839,7 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
822
839
 
823
840
  {!embed && <PageNavigation currentSlug={slug.join('/')} config={config} isWideMode={isWideMode || hasPanel} />}
824
841
  </div>
842
+ {!embed && config.metadata?.timestamp && <LastUpdated date={data.lastUpdated} />}
825
843
  <SocialFooter config={config} hidden={data.hideFooter} embed={embed} projectSlug={projectSlug ?? undefined} />
826
844
  </article>,
827
845
  );
@@ -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
+ }