jamdesk 1.1.148 → 1.1.150

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.
Files changed (102) hide show
  1. package/README.md +28 -0
  2. package/dist/__tests__/integration/fix-scan-integration.test.d.ts +2 -0
  3. package/dist/__tests__/integration/fix-scan-integration.test.d.ts.map +1 -0
  4. package/dist/__tests__/integration/fix-scan-integration.test.js +77 -0
  5. package/dist/__tests__/integration/fix-scan-integration.test.js.map +1 -0
  6. package/dist/__tests__/unit/ai-fix-client.test.d.ts +2 -0
  7. package/dist/__tests__/unit/ai-fix-client.test.d.ts.map +1 -0
  8. package/dist/__tests__/unit/ai-fix-client.test.js +64 -0
  9. package/dist/__tests__/unit/ai-fix-client.test.js.map +1 -0
  10. package/dist/__tests__/unit/docs-config.test.js +66 -0
  11. package/dist/__tests__/unit/docs-config.test.js.map +1 -1
  12. package/dist/__tests__/unit/fix-ai-fallback.test.d.ts +2 -0
  13. package/dist/__tests__/unit/fix-ai-fallback.test.d.ts.map +1 -0
  14. package/dist/__tests__/unit/fix-ai-fallback.test.js +82 -0
  15. package/dist/__tests__/unit/fix-ai-fallback.test.js.map +1 -0
  16. package/dist/__tests__/unit/fix-engine-sync.test.d.ts +2 -0
  17. package/dist/__tests__/unit/fix-engine-sync.test.d.ts.map +1 -0
  18. package/dist/__tests__/unit/fix-engine-sync.test.js +16 -0
  19. package/dist/__tests__/unit/fix-engine-sync.test.js.map +1 -0
  20. package/dist/__tests__/unit/fix.test.d.ts +2 -0
  21. package/dist/__tests__/unit/fix.test.d.ts.map +1 -0
  22. package/dist/__tests__/unit/fix.test.js +61 -0
  23. package/dist/__tests__/unit/fix.test.js.map +1 -0
  24. package/dist/__tests__/unit/frontmatter-write.test.d.ts +2 -0
  25. package/dist/__tests__/unit/frontmatter-write.test.d.ts.map +1 -0
  26. package/dist/__tests__/unit/frontmatter-write.test.js +31 -0
  27. package/dist/__tests__/unit/frontmatter-write.test.js.map +1 -0
  28. package/dist/__tests__/unit/heading-extractor-sync.test.d.ts +2 -0
  29. package/dist/__tests__/unit/heading-extractor-sync.test.d.ts.map +1 -0
  30. package/dist/__tests__/unit/heading-extractor-sync.test.js +16 -0
  31. package/dist/__tests__/unit/heading-extractor-sync.test.js.map +1 -0
  32. package/dist/__tests__/unit/missing-description-scan.test.d.ts +2 -0
  33. package/dist/__tests__/unit/missing-description-scan.test.d.ts.map +1 -0
  34. package/dist/__tests__/unit/missing-description-scan.test.js +36 -0
  35. package/dist/__tests__/unit/missing-description-scan.test.js.map +1 -0
  36. package/dist/__tests__/unit/seo-ai-schema.test.d.ts +2 -0
  37. package/dist/__tests__/unit/seo-ai-schema.test.d.ts.map +1 -0
  38. package/dist/__tests__/unit/seo-ai-schema.test.js +40 -0
  39. package/dist/__tests__/unit/seo-ai-schema.test.js.map +1 -0
  40. package/dist/commands/fix.d.ts +97 -0
  41. package/dist/commands/fix.d.ts.map +1 -0
  42. package/dist/commands/fix.js +331 -0
  43. package/dist/commands/fix.js.map +1 -0
  44. package/dist/index.js +19 -0
  45. package/dist/index.js.map +1 -1
  46. package/dist/lib/ai-fix-client.d.ts +27 -0
  47. package/dist/lib/ai-fix-client.d.ts.map +1 -0
  48. package/dist/lib/ai-fix-client.js +45 -0
  49. package/dist/lib/ai-fix-client.js.map +1 -0
  50. package/dist/lib/deps.js +1 -1
  51. package/dist/lib/deps.js.map +1 -1
  52. package/dist/lib/docs-config.d.ts +1 -1
  53. package/dist/lib/docs-config.d.ts.map +1 -1
  54. package/dist/lib/docs-config.js +59 -4
  55. package/dist/lib/docs-config.js.map +1 -1
  56. package/dist/lib/fix-engine.d.ts +89 -0
  57. package/dist/lib/fix-engine.d.ts.map +1 -0
  58. package/dist/lib/fix-engine.js +236 -0
  59. package/dist/lib/fix-engine.js.map +1 -0
  60. package/dist/lib/frontmatter-write.d.ts +4 -0
  61. package/dist/lib/frontmatter-write.d.ts.map +1 -0
  62. package/dist/lib/frontmatter-write.js +10 -0
  63. package/dist/lib/frontmatter-write.js.map +1 -0
  64. package/dist/lib/heading-extractor.d.ts +63 -0
  65. package/dist/lib/heading-extractor.d.ts.map +1 -0
  66. package/dist/lib/heading-extractor.js +171 -0
  67. package/dist/lib/heading-extractor.js.map +1 -0
  68. package/dist/lib/missing-description-scan.d.ts +7 -0
  69. package/dist/lib/missing-description-scan.d.ts.map +1 -0
  70. package/dist/lib/missing-description-scan.js +20 -0
  71. package/dist/lib/missing-description-scan.js.map +1 -0
  72. package/package.json +3 -3
  73. package/vendored/components/mdx/Accordion.tsx +6 -1
  74. package/vendored/components/mdx/ApiEndpoint.tsx +1 -1
  75. package/vendored/components/mdx/MDXComponents.tsx +14 -1
  76. package/vendored/components/mdx/MermaidInner.tsx +2 -8
  77. package/vendored/components/mdx/OpenApiEndpoint.tsx +7 -2
  78. package/vendored/components/navigation/SocialFooter.tsx +9 -1
  79. package/vendored/lib/build/error-parser.ts +6 -3
  80. package/vendored/lib/docs-types.ts +1 -0
  81. package/vendored/lib/extract-faq.ts +54 -0
  82. package/vendored/lib/firestore-helpers.ts +2 -3
  83. package/vendored/lib/fix-engine.ts +293 -0
  84. package/vendored/lib/health-checks.ts +2 -2
  85. package/vendored/lib/json-ld.ts +113 -10
  86. package/vendored/lib/layout-helpers.tsx +14 -15
  87. package/vendored/lib/llms-flag.ts +5 -0
  88. package/vendored/lib/logo-warning.ts +35 -0
  89. package/vendored/lib/noindex-warning.ts +14 -0
  90. package/vendored/lib/r2-cleanup.ts +26 -0
  91. package/vendored/lib/render-doc-page.tsx +11 -2
  92. package/vendored/lib/search.ts +2 -2
  93. package/vendored/lib/seo.ts +49 -19
  94. package/vendored/lib/static-artifacts.ts +2 -2
  95. package/vendored/lib/ui-strings.ts +6 -0
  96. package/vendored/lib/validate-config.ts +63 -4
  97. package/vendored/lib/validate-page-frontmatter.ts +30 -0
  98. package/vendored/schema/docs-schema.json +10 -0
  99. package/vendored/scripts/build-search-index.cjs +2 -2
  100. package/vendored/shared/status-reporter.ts +27 -1
  101. package/vendored/shared/types.ts +1 -0
  102. package/vendored/workspace-package-lock.json +39 -39
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
3
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
4
4
  import mermaid from 'mermaid';
5
5
  import {
6
6
  CACHE_KEY_PREFIX,
@@ -355,11 +355,6 @@ export function MermaidInner({ children, className, minWidth }: MermaidInnerProp
355
355
  });
356
356
  const [error, setError] = useState<string | null>(null);
357
357
 
358
- // Reserves space so the skeleton → SVG swap doesn't shift layout below it.
359
- // Derived from svg (not separate state) so it can't desync. The regex is
360
- // cheap (small string, no backtracking).
361
- const minHeightPx = useMemo(() => (svg ? readSvgHeight(svg) : 0), [svg]);
362
-
363
358
  useEffect(() => {
364
359
  let cancelled = false;
365
360
 
@@ -482,8 +477,7 @@ export function MermaidInner({ children, className, minWidth }: MermaidInnerProp
482
477
  return (
483
478
  <div
484
479
  ref={containerRef}
485
- className={`mermaid-container my-6 flex justify-center overflow-x-auto ${className || ''}`}
486
- style={minHeightPx ? { minHeight: `${minHeightPx}px` } : undefined}
480
+ className={`mermaid-container my-6 flex justify-center items-start overflow-x-auto ${className || ''}`}
487
481
  dangerouslySetInnerHTML={{ __html: svg }}
488
482
  />
489
483
  );
@@ -829,7 +829,7 @@ export function OpenApiEndpoint({
829
829
 
830
830
  if (playgroundOnly) {
831
831
  return (
832
- <div className="openapi-endpoint">
832
+ <div className="openapi-endpoint" data-markdown-ignore>
833
833
  <ApiEndpoint
834
834
  method={method}
835
835
  path={path}
@@ -842,7 +842,12 @@ export function OpenApiEndpoint({
842
842
  }
843
843
 
844
844
  return (
845
- <div className="openapi-endpoint">
845
+ /* data-markdown-ignore: the endpoint UI is generated from the spec
846
+ and intentionally absent from the .md export (which points agents
847
+ at api-specs.zip). afdocs strips [data-markdown-ignore] before the
848
+ markdown-content-parity comparison, so OpenAPI pages don't false-
849
+ fail parity. */
850
+ <div className="openapi-endpoint" data-markdown-ignore>
846
851
  {/* Deprecation Warning */}
847
852
  {deprecated && (
848
853
  <div className="flex items-center gap-2 p-3 mb-4 bg-amber-500/10 border border-amber-500/20 rounded-lg text-amber-600 dark:text-amber-400">
@@ -16,6 +16,14 @@ const WORDMARK_STYLE: CSSProperties = {
16
16
  backgroundColor: 'currentColor',
17
17
  WebkitMask: WORDMARK_MASK,
18
18
  mask: WORDMARK_MASK,
19
+ // The wordmark SVG's viewBox runs from the j-dot (top) to the j-descender
20
+ // (bottom), so its typographic baseline sits ~76% down the box. Under the
21
+ // link's `items-baseline` the span's box-bottom lands on the text baseline,
22
+ // which leaves the glyphs ~3.3px ABOVE it (measured live). Nudge down by that
23
+ // much so the wordmark and "Powered by" share a baseline. Inline transform
24
+ // (not a Tailwind `translate-y-*` class) because the theme's CSS build purges
25
+ // arbitrary transform utilities — that silently dropped the previous fix.
26
+ transform: 'translateY(3.5px)',
19
27
  };
20
28
 
21
29
  interface SocialFooterProps {
@@ -155,7 +163,7 @@ function BrandingLink({ projectSlug }: { projectSlug?: string }) {
155
163
  <span
156
164
  role="img"
157
165
  aria-label="Jamdesk"
158
- className="inline-block translate-y-[2px] text-[var(--color-text-muted)]/85 group-hover:text-[var(--color-text-primary)]"
166
+ className="inline-block text-[var(--color-text-muted)]/85 group-hover:text-[var(--color-text-primary)]"
159
167
  style={WORDMARK_STYLE}
160
168
  />
161
169
  </a>
@@ -191,7 +191,9 @@ export function parseErrorDetails(
191
191
  '• "name": Your site name\n' +
192
192
  '• "theme": One of "jam", "nebula", or "pulsar"\n' +
193
193
  '• "colors": { "primary": "#hexcolor" }\n' +
194
- '• "navigation": { "anchors": [...] } or { "groups": [...] }\n\n';
194
+ '• "navigation": one of "tabs", "groups", "pages", "dropdowns", ' +
195
+ '"versions", "languages", or "products" ' +
196
+ '(e.g. { "groups": [...] })\n\n';
195
197
 
196
198
  let suggestion = `${intro}\n\n`;
197
199
  if (discoveryHint) {
@@ -249,10 +251,11 @@ export function parseErrorDetails(
249
251
  `Error occurred while building: ${file}`,
250
252
  suggestion:
251
253
  'Check your docs.json configuration file. The "navigation" field ' +
252
- 'must be an object with "anchors", "tabs", or "groups".\n\n' +
254
+ 'must be an object with one of "tabs", "groups", "pages", ' +
255
+ '"dropdowns", "versions", "languages", or "products".\n\n' +
253
256
  'Example:\n' +
254
257
  '"navigation": {\n' +
255
- ' "anchors": [{ "anchor": "Docs", "groups": [...] }]\n' +
258
+ ' "groups": [{ "group": "Docs", "pages": [...] }]\n' +
256
259
  '}\n\n' +
257
260
  'See https://www.jamdesk.com/docs/navigation for details.',
258
261
  };
@@ -711,6 +711,7 @@ export interface SeoConfig {
711
711
  indexing?: 'navigable' | 'all';
712
712
  /** Include hidden pages in sitemap (default: false) */
713
713
  indexHiddenPages?: boolean;
714
+ ai?: { llmsTxt?: boolean }; // default true; false suppresses llms.txt + llms-full.txt
714
715
  }
715
716
 
716
717
  /**
@@ -0,0 +1,54 @@
1
+ export interface FaqPair { question: string; answer: string; }
2
+
3
+ // Nested component subtrees that are NOT part of the FAQ answer prose (CTAs,
4
+ // cards, media, callouts). Drop them WITH their inner text before flattening.
5
+ const NESTED_BLOCK_TAGS = [
6
+ 'Card', 'CardGroup', 'Columns', 'Frame', 'Note', 'Warning', 'Tip', 'Info',
7
+ 'Steps', 'Step', 'Tabs', 'Tab', 'CodeGroup',
8
+ ];
9
+
10
+ function stripMarkdown(s: string): string {
11
+ return s
12
+ .replace(/```[\s\S]*?```/g, ' ') // fenced code
13
+ .replace(/`([^`]+)`/g, '$1') // inline code
14
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') // images
15
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') // links → text
16
+ .replace(/(\*\*|__)(.*?)\1/g, '$2') // bold
17
+ .replace(/(\*|_)(.*?)\1/g, '$2') // italic
18
+ .replace(/^#{1,6}\s+/gm, '') // ATX headings
19
+ .replace(/^\s*[-*+]\s+/gm, '') // bullet markers
20
+ .replace(/^\s*\d+\.\s+/gm, ''); // ordered markers
21
+ }
22
+
23
+ // Lightweight extraction of <Accordion title="...">body</Accordion>. Only the
24
+ // QUOTED-title form is matched (the title={expr} form is intentionally skipped —
25
+ // an expression can't be resolved to display text). Answers are reduced to plain
26
+ // text for schema.org: nested component subtrees dropped, markdown stripped.
27
+ //
28
+ // KNOWN LIMITATION (regex, not AST): an <Accordion> nested INSIDE another
29
+ // <Accordion> body truncates the outer answer at the first </Accordion> and
30
+ // merges the inner answer in (the inner question is lost). Harmless for the
31
+ // FAQPage JSON-LD — output stays valid, just an imperfect answer string on the
32
+ // rare nested page. Sibling accordions in an <AccordionGroup> are unaffected.
33
+ // The fix for true nesting is the compiled-MDX AST walk (mdxJsxFlowElement),
34
+ // the documented upgrade path; kept regex-first per the plan's decision.
35
+ export function extractFaqPairs(rawMdx: string): FaqPair[] {
36
+ const pairs: FaqPair[] = [];
37
+ const re = /<Accordion\b[^>]*?\btitle\s*=\s*("|')(.*?)\1[^>]*>([\s\S]*?)<\/Accordion>/g;
38
+ let m: RegExpExecArray | null;
39
+ while ((m = re.exec(rawMdx))) {
40
+ const question = m[2].trim();
41
+ let body = m[3];
42
+ for (const tag of NESTED_BLOCK_TAGS) {
43
+ body = body
44
+ .replace(new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*?<\\/${tag}>`, 'g'), ' ') // paired
45
+ .replace(new RegExp(`<${tag}\\b[^>]*/>`, 'g'), ' '); // self-closing
46
+ }
47
+ const answer = stripMarkdown(body)
48
+ .replace(/<[^>]+>/g, ' ') // any remaining tags
49
+ .replace(/\s+/g, ' ')
50
+ .trim();
51
+ if (question && answer) pairs.push({ question, answer });
52
+ }
53
+ return pairs;
54
+ }
@@ -1,4 +1,4 @@
1
- import admin from 'firebase-admin';
1
+ import { getFirestore } from 'firebase-admin/firestore';
2
2
 
3
3
  /**
4
4
  * True when a Firestore project claims this slug (the canonical slug lives at
@@ -10,8 +10,7 @@ import admin from 'firebase-admin';
10
10
  * dashboard/firestore.indexes.json, so this query needs no new index.
11
11
  */
12
12
  export async function isSlugActive(slug: string): Promise<boolean> {
13
- const snap = await admin
14
- .firestore()
13
+ const snap = await getFirestore()
15
14
  .collectionGroup('github')
16
15
  .where('slug', '==', slug)
17
16
  .limit(1)
@@ -0,0 +1,293 @@
1
+ // builder/build-service/lib/fix-engine.ts
2
+ //
3
+ // Dependency-free deterministic doc-warning fix engine. Imports NOTHING so it
4
+ // byte-syncs into the CLI's src/lib (cli/src cannot import cli/vendored).
5
+ //
6
+ // The engine does NO slugging and NO I/O: the caller injects heading data via
7
+ // FixContext.headingsOf(), populated from the canonical extractHeadings()
8
+ // (heading-extractor.ts) whose ids ARE the real rehype-slug DOM ids. Re-slugging
9
+ // here is forbidden — markdown-only re-slugging diverges from real ids (dedup
10
+ // suffixes shift when <Update>/<Step> anchors share heading text) and was the
11
+ // original wrong-fix bug.
12
+
13
+ // ---- Types ----------------------------------------------------------------
14
+
15
+ export interface Edit {
16
+ file: string;
17
+ /** 1-indexed source line the edit applies to (from the warning). */
18
+ line: number;
19
+ oldText: string;
20
+ newText: string;
21
+ }
22
+
23
+ export interface FixResult {
24
+ status: 'fixed' | 'skipped';
25
+ edits: Edit[];
26
+ reason: string;
27
+ }
28
+
29
+ export interface LocaleInfo {
30
+ codes: string[];
31
+ defaultCode: string;
32
+ }
33
+
34
+ export interface FixWarning {
35
+ type: string;
36
+ file: string;
37
+ line: number | null;
38
+ link: string;
39
+ message?: string;
40
+ }
41
+
42
+ /** Structural subset of heading-extractor's HeadingInfo — extractHeadings()
43
+ * output is assignable to EngineHeading[] directly (no mapping). */
44
+ export interface EngineHeading {
45
+ id: string;
46
+ stepNumber?: number;
47
+ isUpdate?: boolean;
48
+ }
49
+
50
+ export interface FixContext {
51
+ currentPagePath: string;
52
+ locales: LocaleInfo;
53
+ /** Resolve a doc link href to a normalized page path, or null if it does not exist. */
54
+ resolveLinkPagePath(href: string): string | null;
55
+ /** Ordered headings for a page path (no extension) via extractHeadings(), or null. */
56
+ headingsOf(pagePath: string): EngineHeading[] | null;
57
+ }
58
+
59
+ export type Fixer = (warning: FixWarning, ctx: FixContext) => FixResult;
60
+
61
+ /** A markdown heading entry (not a <Step> or <Update> component anchor). */
62
+ export function isMarkdownHeading(h: EngineHeading): boolean {
63
+ return h.stepNumber == null && !h.isUpdate;
64
+ }
65
+
66
+ /** True iff `candidate` is an exact heading id on `headings`. The canonical
67
+ * guard for any externally-sourced (e.g. AI) anchor suggestion — the engine
68
+ * itself uses the same `allIds.includes(...)` check inside fixBrokenLink's
69
+ * `emit`. Phase 2's AI tier MUST route every suggestion through this so a
70
+ * hallucinated fragment can never be written. Pure, dependency-free. */
71
+ export function isRealAnchor(candidate: string, headings: EngineHeading[]): boolean {
72
+ if (!candidate) return false;
73
+ return headings.some((h) => h.id === candidate);
74
+ }
75
+
76
+ /** Substring appended to the skip reason when the deterministic engine defers an
77
+ * anchor to the AI tier. Exported so the AI-tier callers match on this constant
78
+ * instead of a re-typed literal — producer and consumer cannot drift. */
79
+ export const NEEDS_AI_MARKER = '(needs AI)';
80
+
81
+ // ---- Edit application -----------------------------------------------------
82
+
83
+ // A flagged href is always the target of a markdown link `](href)` or an
84
+ // `href="…"`/`href='…'` attribute, so in source it is bounded on the left by a
85
+ // delimiter — never by another URL/path character. A match whose preceding char
86
+ // IS a path character is the href appearing as a SUBSTRING inside a longer URL
87
+ // (e.g. `/setup#x` inside `/guide/setup#x`) and must be skipped, or the edit
88
+ // would corrupt an unrelated, valid link on the same line.
89
+ const URL_PATH_CHAR = /[A-Za-z0-9\-._~%/#?:@]/;
90
+
91
+ /** Index of the next occurrence of `href` at/after `from` that sits at a link
92
+ * boundary (not as a substring inside a longer path), or -1. */
93
+ function findHrefBoundary(line: string, href: string, from: number): number {
94
+ let pos = line.indexOf(href, from);
95
+ while (pos !== -1) {
96
+ if (pos === 0 || !URL_PATH_CHAR.test(line[pos - 1])) return pos;
97
+ pos = line.indexOf(href, pos + 1);
98
+ }
99
+ return -1;
100
+ }
101
+
102
+ /**
103
+ * Apply edits, replacing each edit's oldText with newText on its specific
104
+ * (1-indexed) source line. Line-scoped, so an identical href on another line
105
+ * (e.g. a code sample) is never touched. Within a line: oldText is matched at a
106
+ * link boundary (so a short href is never replaced inside a longer one),
107
+ * matching runs against the ORIGINAL line (so an edit whose newText contains its
108
+ * oldText is never re-matched), and edits apply right-to-left so earlier
109
+ * positions keep their indices. Identical oldTexts on one line consume
110
+ * successive occurrences. Throws if a line is out of range or an oldText is
111
+ * absent — a missing match means the source changed since the fix was derived,
112
+ * and a partial/wrong edit must never be written.
113
+ */
114
+ export function applyEdits(content: string, edits: Edit[]): string {
115
+ if (edits.length === 0) return content;
116
+ const lines = content.split('\n');
117
+
118
+ const byLine = new Map<number, Edit[]>();
119
+ for (const edit of edits) {
120
+ const list = byLine.get(edit.line);
121
+ if (list) list.push(edit);
122
+ else byLine.set(edit.line, [edit]);
123
+ }
124
+
125
+ for (const [line, lineEdits] of byLine) {
126
+ const idx = line - 1;
127
+ if (idx < 0 || idx >= lines.length) {
128
+ throw new Error(`applyEdits: line ${line} out of range (${lines.length} lines)`);
129
+ }
130
+ const src = lines[idx];
131
+ // Resolve every edit to a concrete [start,end) span on the ORIGINAL line.
132
+ const spans: Array<{ start: number; end: number; newText: string }> = [];
133
+ const nextFrom = new Map<string, number>();
134
+ for (const edit of lineEdits) {
135
+ const from = nextFrom.get(edit.oldText) ?? 0;
136
+ const start = findHrefBoundary(src, edit.oldText, from);
137
+ if (start === -1) {
138
+ throw new Error(`applyEdits: oldText not found on line ${line}: ${JSON.stringify(edit.oldText)}`);
139
+ }
140
+ const end = start + edit.oldText.length;
141
+ nextFrom.set(edit.oldText, end);
142
+ spans.push({ start, end, newText: edit.newText });
143
+ }
144
+ // Apply right-to-left so an earlier span's indices survive later edits.
145
+ spans.sort((a, b) => b.start - a.start);
146
+ let out = src;
147
+ for (const s of spans) out = out.slice(0, s.start) + s.newText + out.slice(s.end);
148
+ lines[idx] = out;
149
+ }
150
+ return lines.join('\n');
151
+ }
152
+
153
+ // ---- Distance / nearest-slug (ported from validate-links.cjs) -------------
154
+
155
+ const MAX_SUGGESTION_DISTANCE = 2;
156
+
157
+ export function levenshtein(a: string, b: string): number {
158
+ if (a === b) return 0;
159
+ if (a.length === 0) return b.length;
160
+ if (b.length === 0) return a.length;
161
+ let prev = new Array<number>(b.length + 1);
162
+ let curr = new Array<number>(b.length + 1);
163
+ for (let j = 0; j <= b.length; j++) prev[j] = j;
164
+ for (let i = 0; i < a.length; i++) {
165
+ curr[0] = i + 1;
166
+ for (let j = 0; j < b.length; j++) {
167
+ const cost = a[i] === b[j] ? 0 : 1;
168
+ curr[j + 1] = Math.min(curr[j] + 1, prev[j + 1] + 1, prev[j] + cost);
169
+ }
170
+ [prev, curr] = [curr, prev];
171
+ }
172
+ return prev[b.length];
173
+ }
174
+
175
+ export function closestSlug(fragment: string, slugs: Iterable<string>): string | null {
176
+ if (!fragment) return null;
177
+ let best: string | null = null;
178
+ let bestDist = Infinity;
179
+ let secondDist = Infinity;
180
+ for (const slug of slugs) {
181
+ if (Math.abs(fragment.length - slug.length) > MAX_SUGGESTION_DISTANCE) continue;
182
+ const d = levenshtein(fragment, slug);
183
+ if (d < bestDist) {
184
+ secondDist = bestDist;
185
+ bestDist = d;
186
+ best = slug;
187
+ } else if (d < secondDist) {
188
+ secondDist = d;
189
+ }
190
+ }
191
+ return best !== null && bestDist >= 1 && bestDist <= MAX_SUGGESTION_DISTANCE && bestDist < secondDist
192
+ ? best
193
+ : null;
194
+ }
195
+
196
+ // ---- Link + locale helpers ------------------------------------------------
197
+
198
+ export function parseHref(href: string): { path: string | null; fragment: string | null } {
199
+ const i = href.indexOf('#');
200
+ if (i === -1) return { path: href, fragment: null };
201
+ return { path: i === 0 ? null : href.slice(0, i), fragment: href.slice(i + 1) || null };
202
+ }
203
+
204
+ export function normalizeLinkPath(linkPath: string, hostAtDocs: boolean): string {
205
+ let p = linkPath;
206
+ if (hostAtDocs && p.startsWith('/docs/')) p = p.slice('/docs/'.length);
207
+ if (p.startsWith('/')) p = p.slice(1);
208
+ return p.replace(/\.mdx$/, '');
209
+ }
210
+
211
+ /** The default-locale twin of a localized page path, or null if already default. */
212
+ export function defaultTwinPath(pagePath: string, locales: LocaleInfo): string | null {
213
+ const segments = pagePath.split('/');
214
+ const first = segments[0];
215
+ if (first !== locales.defaultCode && locales.codes.includes(first)) {
216
+ return segments.slice(1).join('/');
217
+ }
218
+ return null;
219
+ }
220
+
221
+ // ---- Broken-link fixer ----------------------------------------------------
222
+
223
+ function skip(reason: string): FixResult {
224
+ return { status: 'skipped', edits: [], reason };
225
+ }
226
+
227
+ export const fixBrokenLink: Fixer = (warning, ctx) => {
228
+ const { path: linkPath, fragment } = parseHref(warning.link);
229
+ if (!fragment) return skip('not a fragment link — out of scope for phase 1');
230
+ if (!linkPath) return skip('same-page fragment — out of scope for phase 1');
231
+ if (warning.line == null) return skip('warning has no line — cannot place a safe edit');
232
+
233
+ const targetPage = ctx.resolveLinkPagePath(linkPath);
234
+ if (!targetPage) return skip('target page could not be resolved');
235
+ const targetHeadings = ctx.headingsOf(targetPage);
236
+ if (!targetHeadings) return skip('target page headings could not be read');
237
+
238
+ const allIds = targetHeadings.map((h) => h.id);
239
+
240
+ // (0) Re-derivation: may already be valid in current source.
241
+ if (allIds.includes(fragment)) return skip('already valid — fragment exists on target');
242
+
243
+ // Emit only a fragment that is a REAL anchor on the target (never point at a
244
+ // non-existent or wrong-kind id), reconstructing the href (no substring replace).
245
+ const emit = (candidate: string, reason: string): FixResult => {
246
+ if (!allIds.includes(candidate)) return skip('internal: candidate is not a real anchor — refusing');
247
+ return {
248
+ status: 'fixed',
249
+ edits: [{ file: warning.file, line: warning.line as number, oldText: warning.link, newText: linkPath + '#' + candidate }],
250
+ reason,
251
+ };
252
+ };
253
+
254
+ // (1) Cross-locale positional map FIRST (high precision for localized targets).
255
+ // Ordinals over MARKDOWN headings only (so <Step>/<Update> can't misalign them),
256
+ // but the emitted id is the target heading's REAL id (full-set dedup).
257
+ const twinPage = defaultTwinPath(targetPage, ctx.locales);
258
+ if (twinPage) {
259
+ const twinHeadings = ctx.headingsOf(twinPage);
260
+ if (twinHeadings) {
261
+ const twinMd = twinHeadings.filter(isMarkdownHeading);
262
+ const targetMd = targetHeadings.filter(isMarkdownHeading);
263
+ const ordinal = twinMd.findIndex((h) => h.id === fragment);
264
+ if (ordinal !== -1 && twinMd.length === targetMd.length && targetMd[ordinal]) {
265
+ return emit(targetMd[ordinal].id, `cross-locale positional map: #${fragment} → #${targetMd[ordinal].id}`);
266
+ }
267
+ }
268
+ }
269
+
270
+ // (2) Near-miss fallback (typos within the same language).
271
+ const near = closestSlug(fragment, allIds);
272
+ if (near) return emit(near, `near-miss: #${fragment} → #${near}`);
273
+
274
+ return skip(`no confident deterministic fix — structures diverge or target is ambiguous ${NEEDS_AI_MARKER}`);
275
+ };
276
+
277
+ // ---- Registry + engine entry point ----------------------------------------
278
+
279
+ /** Fixer registry keyed by BuildWarningType. Phase 1: broken_link only. */
280
+ export const FIXERS: Record<string, Fixer> = {
281
+ broken_link: fixBrokenLink,
282
+ };
283
+
284
+ /**
285
+ * Run the registered fixer for a warning. Deterministic-first is implemented
286
+ * inside each fixer (exact strategies, then 'skipped' for AI cases). The AI
287
+ * fallback tier is wired in Phase 2; until then a 'skipped' result is terminal.
288
+ */
289
+ export function runFixer(warning: FixWarning, ctx: FixContext): FixResult {
290
+ const fixer = FIXERS[warning.type];
291
+ if (!fixer) return skip(`no fixer registered for type "${warning.type}"`);
292
+ return fixer(warning, ctx);
293
+ }
@@ -190,10 +190,10 @@ export async function checkFirestoreHealth(db: Firestore): Promise<FirestoreHeal
190
190
  let writeLatencyMs: number | null = null;
191
191
  try {
192
192
  const writeStart = Date.now();
193
- const admin = await import('firebase-admin');
193
+ const { FieldValue } = await import('firebase-admin/firestore');
194
194
  await Promise.race([
195
195
  healthRef.set(
196
- { lastCheck: admin.firestore.FieldValue.serverTimestamp() },
196
+ { lastCheck: FieldValue.serverTimestamp() },
197
197
  { merge: true }
198
198
  ),
199
199
  new Promise((_, reject) =>