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.
- package/README.md +28 -0
- package/dist/__tests__/integration/fix-scan-integration.test.d.ts +2 -0
- package/dist/__tests__/integration/fix-scan-integration.test.d.ts.map +1 -0
- package/dist/__tests__/integration/fix-scan-integration.test.js +77 -0
- package/dist/__tests__/integration/fix-scan-integration.test.js.map +1 -0
- package/dist/__tests__/unit/ai-fix-client.test.d.ts +2 -0
- package/dist/__tests__/unit/ai-fix-client.test.d.ts.map +1 -0
- package/dist/__tests__/unit/ai-fix-client.test.js +64 -0
- package/dist/__tests__/unit/ai-fix-client.test.js.map +1 -0
- package/dist/__tests__/unit/docs-config.test.js +66 -0
- package/dist/__tests__/unit/docs-config.test.js.map +1 -1
- package/dist/__tests__/unit/fix-ai-fallback.test.d.ts +2 -0
- package/dist/__tests__/unit/fix-ai-fallback.test.d.ts.map +1 -0
- package/dist/__tests__/unit/fix-ai-fallback.test.js +82 -0
- package/dist/__tests__/unit/fix-ai-fallback.test.js.map +1 -0
- package/dist/__tests__/unit/fix-engine-sync.test.d.ts +2 -0
- package/dist/__tests__/unit/fix-engine-sync.test.d.ts.map +1 -0
- package/dist/__tests__/unit/fix-engine-sync.test.js +16 -0
- package/dist/__tests__/unit/fix-engine-sync.test.js.map +1 -0
- package/dist/__tests__/unit/fix.test.d.ts +2 -0
- package/dist/__tests__/unit/fix.test.d.ts.map +1 -0
- package/dist/__tests__/unit/fix.test.js +61 -0
- package/dist/__tests__/unit/fix.test.js.map +1 -0
- package/dist/__tests__/unit/frontmatter-write.test.d.ts +2 -0
- package/dist/__tests__/unit/frontmatter-write.test.d.ts.map +1 -0
- package/dist/__tests__/unit/frontmatter-write.test.js +31 -0
- package/dist/__tests__/unit/frontmatter-write.test.js.map +1 -0
- package/dist/__tests__/unit/heading-extractor-sync.test.d.ts +2 -0
- package/dist/__tests__/unit/heading-extractor-sync.test.d.ts.map +1 -0
- package/dist/__tests__/unit/heading-extractor-sync.test.js +16 -0
- package/dist/__tests__/unit/heading-extractor-sync.test.js.map +1 -0
- package/dist/__tests__/unit/missing-description-scan.test.d.ts +2 -0
- package/dist/__tests__/unit/missing-description-scan.test.d.ts.map +1 -0
- package/dist/__tests__/unit/missing-description-scan.test.js +36 -0
- package/dist/__tests__/unit/missing-description-scan.test.js.map +1 -0
- package/dist/__tests__/unit/seo-ai-schema.test.d.ts +2 -0
- package/dist/__tests__/unit/seo-ai-schema.test.d.ts.map +1 -0
- package/dist/__tests__/unit/seo-ai-schema.test.js +40 -0
- package/dist/__tests__/unit/seo-ai-schema.test.js.map +1 -0
- package/dist/commands/fix.d.ts +97 -0
- package/dist/commands/fix.d.ts.map +1 -0
- package/dist/commands/fix.js +331 -0
- package/dist/commands/fix.js.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/ai-fix-client.d.ts +27 -0
- package/dist/lib/ai-fix-client.d.ts.map +1 -0
- package/dist/lib/ai-fix-client.js +45 -0
- package/dist/lib/ai-fix-client.js.map +1 -0
- package/dist/lib/deps.js +1 -1
- package/dist/lib/deps.js.map +1 -1
- package/dist/lib/docs-config.d.ts +1 -1
- package/dist/lib/docs-config.d.ts.map +1 -1
- package/dist/lib/docs-config.js +59 -4
- package/dist/lib/docs-config.js.map +1 -1
- package/dist/lib/fix-engine.d.ts +89 -0
- package/dist/lib/fix-engine.d.ts.map +1 -0
- package/dist/lib/fix-engine.js +236 -0
- package/dist/lib/fix-engine.js.map +1 -0
- package/dist/lib/frontmatter-write.d.ts +4 -0
- package/dist/lib/frontmatter-write.d.ts.map +1 -0
- package/dist/lib/frontmatter-write.js +10 -0
- package/dist/lib/frontmatter-write.js.map +1 -0
- package/dist/lib/heading-extractor.d.ts +63 -0
- package/dist/lib/heading-extractor.d.ts.map +1 -0
- package/dist/lib/heading-extractor.js +171 -0
- package/dist/lib/heading-extractor.js.map +1 -0
- package/dist/lib/missing-description-scan.d.ts +7 -0
- package/dist/lib/missing-description-scan.d.ts.map +1 -0
- package/dist/lib/missing-description-scan.js +20 -0
- package/dist/lib/missing-description-scan.js.map +1 -0
- package/package.json +3 -3
- package/vendored/components/mdx/Accordion.tsx +6 -1
- package/vendored/components/mdx/ApiEndpoint.tsx +1 -1
- package/vendored/components/mdx/MDXComponents.tsx +14 -1
- package/vendored/components/mdx/MermaidInner.tsx +2 -8
- package/vendored/components/mdx/OpenApiEndpoint.tsx +7 -2
- package/vendored/components/navigation/SocialFooter.tsx +9 -1
- package/vendored/lib/build/error-parser.ts +6 -3
- package/vendored/lib/docs-types.ts +1 -0
- package/vendored/lib/extract-faq.ts +54 -0
- package/vendored/lib/firestore-helpers.ts +2 -3
- package/vendored/lib/fix-engine.ts +293 -0
- package/vendored/lib/health-checks.ts +2 -2
- package/vendored/lib/json-ld.ts +113 -10
- package/vendored/lib/layout-helpers.tsx +14 -15
- package/vendored/lib/llms-flag.ts +5 -0
- package/vendored/lib/logo-warning.ts +35 -0
- package/vendored/lib/noindex-warning.ts +14 -0
- package/vendored/lib/r2-cleanup.ts +26 -0
- package/vendored/lib/render-doc-page.tsx +11 -2
- package/vendored/lib/search.ts +2 -2
- package/vendored/lib/seo.ts +49 -19
- package/vendored/lib/static-artifacts.ts +2 -2
- package/vendored/lib/ui-strings.ts +6 -0
- package/vendored/lib/validate-config.ts +63 -4
- package/vendored/lib/validate-page-frontmatter.ts +30 -0
- package/vendored/schema/docs-schema.json +10 -0
- package/vendored/scripts/build-search-index.cjs +2 -2
- package/vendored/shared/status-reporter.ts +27 -1
- package/vendored/shared/types.ts +1 -0
- package/vendored/workspace-package-lock.json +39 -39
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useEffect, useLayoutEffect,
|
|
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
|
-
|
|
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
|
|
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":
|
|
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 "
|
|
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
|
-
' "
|
|
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
|
|
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
|
|
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
|
|
193
|
+
const { FieldValue } = await import('firebase-admin/firestore');
|
|
194
194
|
await Promise.race([
|
|
195
195
|
healthRef.set(
|
|
196
|
-
{ lastCheck:
|
|
196
|
+
{ lastCheck: FieldValue.serverTimestamp() },
|
|
197
197
|
{ merge: true }
|
|
198
198
|
),
|
|
199
199
|
new Promise((_, reject) =>
|