blume 0.6.7 → 0.8.0
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/CHANGELOG.md +618 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/dist/cli/index.js +2609 -1041
- package/dist/cli/index.js.map +110 -103
- package/dist/types/ai/component-markdown.d.ts +34 -0
- package/dist/types/components/content/youtube.d.ts +18 -0
- package/dist/types/core/base-path.d.ts +47 -0
- package/dist/types/core/config-input.d.ts +110 -12
- package/dist/types/core/config.d.ts +6 -4
- package/dist/types/core/data.d.ts +4 -0
- package/dist/types/core/i18n-ui.d.ts +477 -135
- package/dist/types/core/schema.d.ts +309 -195
- package/dist/types/core/sources/types.d.ts +2 -0
- package/dist/types/core/types.d.ts +6 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/openapi/references.d.ts +60 -0
- package/docs/01-quickstart.mdx +5 -2
- package/docs/02-deployment.mdx +24 -9
- package/docs/03-faq.mdx +46 -16
- package/docs/advanced/custom-pages.mdx +1 -1
- package/docs/advanced/skills.mdx +1 -1
- package/docs/configuration/ai.mdx +49 -10
- package/docs/configuration/customization.mdx +11 -0
- package/docs/configuration/index.mdx +33 -3
- package/docs/configuration/seo.mdx +2 -2
- package/docs/content/components.mdx +30 -3
- package/docs/content/i18n.mdx +1 -1
- package/docs/content/islands.mdx +8 -0
- package/docs/content/navigation.mdx +3 -3
- package/docs/content/sources.mdx +1 -1
- package/docs/content/syntax.mdx +17 -2
- package/docs/index.mdx +2 -2
- package/docs/reference/cli.mdx +8 -6
- package/package.json +15 -4
- package/skills/blume/SKILL.md +5 -3
- package/skills/blume-update-docs/SKILL.md +3 -2
- package/src/ai/agent-readability.ts +11 -5
- package/src/ai/ask-context.ts +7 -2
- package/src/ai/ask-data.ts +3 -0
- package/src/ai/ask.ts +12 -7
- package/src/ai/component-markdown.ts +461 -0
- package/src/ai/llms.ts +143 -23
- package/src/ai/markdown.ts +35 -6
- package/src/ai/mcp/data.ts +33 -8
- package/src/ai/mcp/discovery.ts +10 -3
- package/src/ai/mcp/server.ts +24 -7
- package/src/ai/visibility.ts +74 -0
- package/src/astro/component-slots.ts +16 -4
- package/src/astro/examples.ts +12 -7
- package/src/astro/generate.ts +393 -189
- package/src/astro/index.ts +5 -1
- package/src/astro/integration.ts +9 -5
- package/src/astro/islands.ts +11 -5
- package/src/astro/markdown-negotiation.ts +2 -2
- package/src/astro/pages.ts +89 -22
- package/src/astro/templates.ts +259 -25
- package/src/blume-modules.d.ts +8 -0
- package/src/cli/commands/build.ts +131 -38
- package/src/cli/commands/check.ts +1 -1
- package/src/cli/commands/dev.ts +71 -17
- package/src/cli/commands/doctor.ts +2 -2
- package/src/cli/commands/eject.ts +47 -19
- package/src/cli/commands/init.ts +120 -180
- package/src/cli/commands/preview.ts +4 -1
- package/src/cli/commands/validate.ts +44 -2
- package/src/cli/dev-lock.ts +34 -19
- package/src/cli/eject-scripts.ts +72 -0
- package/src/cli/env.ts +15 -5
- package/src/cli/init/questions.ts +158 -0
- package/src/cli/init/scaffold.ts +380 -0
- package/src/cli/required-secrets.ts +2 -1
- package/src/components/content/AccordionItem.astro +23 -4
- package/src/components/content/Badge.astro +3 -1
- package/src/components/content/Card.astro +4 -2
- package/src/components/content/CodeBlock.astro +3 -0
- package/src/components/content/Component.astro +30 -16
- package/src/components/content/Diff.astro +3 -1
- package/src/components/content/Step.astro +10 -1
- package/src/components/content/Tabs.astro +15 -3
- package/src/components/content/Tile.astro +2 -1
- package/src/components/content/Tooltip.astro +3 -1
- package/src/components/content/Update.astro +9 -2
- package/src/components/content/auto-type-table.ts +25 -9
- package/src/components/content/base-href.ts +33 -0
- package/src/components/content/changelog-element.ts +9 -2
- package/src/components/content/diff.ts +12 -6
- package/src/components/content/mermaid-element.ts +10 -2
- package/src/components/index.ts +23 -1
- package/src/components/islands/AskAI.astro +5 -2
- package/src/components/islands/ask-ai.tsx +68 -12
- package/src/components/islands/base-path.ts +28 -0
- package/src/components/islands/hooks.ts +44 -9
- package/src/components/layout/Banner.astro +12 -3
- package/src/components/layout/Breadcrumbs.astro +2 -1
- package/src/components/layout/Favicon.astro +3 -2
- package/src/components/layout/Header.astro +15 -5
- package/src/components/layout/LanguageSwitcher.astro +2 -1
- package/src/components/layout/Logo.astro +13 -4
- package/src/components/layout/NavSelector.astro +2 -1
- package/src/components/layout/NavTree.astro +22 -7
- package/src/components/layout/PageActions.astro +25 -10
- package/src/components/layout/PageFeedback.astro +4 -1
- package/src/components/layout/PageLayout.astro +51 -9
- package/src/components/layout/Pagination.astro +3 -2
- package/src/components/layout/ReferenceLayout.astro +8 -1
- package/src/components/layout/RootLayout.astro +74 -13
- package/src/components/layout/Search.astro +107 -27
- package/src/components/layout/nav-utils.ts +18 -10
- package/src/components/layout/search/algolia.ts +11 -2
- package/src/components/layout/search/endpoint.ts +11 -5
- package/src/components/layout/search/orama-cloud.ts +8 -2
- package/src/components/layout/search/pagefind.ts +3 -0
- package/src/components/layout/search/types.ts +5 -1
- package/src/components/layout/search/typesense.ts +4 -1
- package/src/components/layout/toc-element.ts +8 -2
- package/src/components/openapi/ApiTagOperations.astro +2 -1
- package/src/components/openapi/Operation.astro +47 -40
- package/src/components/openapi/RequestPanel.astro +8 -2
- package/src/components/openapi/helpers.ts +71 -3
- package/src/components/openapi/panel.ts +1 -1
- package/src/components/openapi/snippets.ts +25 -11
- package/src/core/base-path.ts +94 -0
- package/src/core/builtin-tags.ts +2 -0
- package/src/core/component-overrides.ts +103 -74
- package/src/core/config-input.ts +118 -17
- package/src/core/config.ts +8 -5
- package/src/core/content.ts +2 -0
- package/src/core/data.ts +4 -0
- package/src/core/diagnostics.ts +54 -34
- package/src/core/gitignore.ts +4 -1
- package/src/core/graph.ts +166 -88
- package/src/core/i18n-ui.ts +63 -3
- package/src/core/last-modified.ts +15 -6
- package/src/core/links.ts +69 -25
- package/src/core/manifest.ts +62 -45
- package/src/core/nav-diagnostics.ts +1 -1
- package/src/core/navigation.ts +144 -58
- package/src/core/package-json.ts +17 -2
- package/src/core/project-graph.ts +25 -15
- package/src/core/schema.ts +605 -620
- package/src/core/sources/assets.ts +6 -1
- package/src/core/sources/filesystem.ts +4 -0
- package/src/core/sources/github-releases.ts +2 -1
- package/src/core/sources/mdx-remote.ts +76 -63
- package/src/core/sources/normalize.ts +236 -91
- package/src/core/sources/notion.ts +27 -18
- package/src/core/sources/types.ts +2 -0
- package/src/core/tsconfig-aliases.ts +59 -30
- package/src/core/types.ts +6 -1
- package/src/core/ui-packs/ar.ts +1 -0
- package/src/core/ui-packs/bg.ts +1 -0
- package/src/core/ui-packs/bn.ts +1 -0
- package/src/core/ui-packs/ca.ts +1 -0
- package/src/core/ui-packs/cs.ts +1 -0
- package/src/core/ui-packs/da.ts +1 -0
- package/src/core/ui-packs/de.ts +1 -0
- package/src/core/ui-packs/el.ts +1 -0
- package/src/core/ui-packs/es.ts +1 -0
- package/src/core/ui-packs/fa.ts +1 -0
- package/src/core/ui-packs/fi.ts +1 -0
- package/src/core/ui-packs/fr.ts +2 -1
- package/src/core/ui-packs/he.ts +1 -0
- package/src/core/ui-packs/hi.ts +1 -0
- package/src/core/ui-packs/hr.ts +1 -0
- package/src/core/ui-packs/hu.ts +1 -0
- package/src/core/ui-packs/id.ts +1 -0
- package/src/core/ui-packs/it.ts +1 -0
- package/src/core/ui-packs/ja.ts +1 -0
- package/src/core/ui-packs/ko.ts +1 -0
- package/src/core/ui-packs/nl.ts +1 -0
- package/src/core/ui-packs/no.ts +1 -0
- package/src/core/ui-packs/pl.ts +1 -0
- package/src/core/ui-packs/pt-br.ts +1 -0
- package/src/core/ui-packs/pt.ts +1 -0
- package/src/core/ui-packs/ro.ts +1 -0
- package/src/core/ui-packs/ru.ts +1 -0
- package/src/core/ui-packs/sk.ts +1 -0
- package/src/core/ui-packs/sr.ts +1 -0
- package/src/core/ui-packs/sv.ts +1 -0
- package/src/core/ui-packs/th.ts +1 -0
- package/src/core/ui-packs/tr.ts +1 -0
- package/src/core/ui-packs/uk.ts +1 -0
- package/src/core/ui-packs/vi.ts +1 -0
- package/src/core/ui-packs/zh-tw.ts +1 -0
- package/src/core/ui-packs/zh.ts +1 -0
- package/src/deploy/adapter-output.ts +18 -8
- package/src/deploy/redirects.ts +25 -2
- package/src/deploy/robots.ts +6 -1
- package/src/deploy/rss.ts +10 -3
- package/src/deploy/sitemap.ts +59 -13
- package/src/index.ts +5 -0
- package/src/markdown/base-links.ts +60 -0
- package/src/markdown/code-title.ts +11 -14
- package/src/markdown/index.ts +46 -9
- package/src/markdown/inline-code.ts +14 -4
- package/src/markdown/package-commands.ts +10 -4
- package/src/markdown/themes.ts +24 -0
- package/src/openapi/model.ts +15 -5
- package/src/openapi/parse.ts +21 -0
- package/src/openapi/references.ts +75 -21
- package/src/openapi/render-mdx.ts +11 -6
- package/src/openapi/scalar.ts +32 -16
- package/src/openapi/source.ts +59 -10
- package/src/registry/eject.ts +247 -19
- package/src/registry/registry.ts +0 -3
- package/src/search/build.ts +3 -0
- package/src/search/documents.ts +36 -4
- package/src/search/sync/typesense.ts +6 -4
- package/src/seo/jsonld.ts +28 -17
- package/src/theme/entry.ts +85 -20
|
@@ -68,7 +68,12 @@ export const materializeAssets = async (
|
|
|
68
68
|
throw new Error(`${res.status}`);
|
|
69
69
|
}
|
|
70
70
|
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
71
|
-
|
|
71
|
+
// Hash the query-less URL (as `extFor` does): CMS asset URLs are
|
|
72
|
+
// pre-signed, so the query changes on every fetch of the same image —
|
|
73
|
+
// hashing it would mint a new file each refresh and re-dirty the
|
|
74
|
+
// content digest. Two real assets sharing scheme+host+path and
|
|
75
|
+
// differing only in query are rare enough to accept colliding.
|
|
76
|
+
const file = `${hashText(url.split("?")[0] ?? url)}${extFor(url)}`;
|
|
72
77
|
await mkdir(ctx.assetsDir, { recursive: true });
|
|
73
78
|
await writeFile(join(ctx.assetsDir, file), bytes);
|
|
74
79
|
rewrites.set(url, `${ctx.assetsBaseUrl}/${file}`);
|
|
@@ -63,6 +63,10 @@ export const filesystemSource = (
|
|
|
63
63
|
return {
|
|
64
64
|
body: { format, text: parsed.content },
|
|
65
65
|
data: parsed.data,
|
|
66
|
+
// The unstripped text: lets `normalizeEntry` offset link line numbers
|
|
67
|
+
// by the frontmatter block's height, so diagnostics point at the real
|
|
68
|
+
// file line (and spares a re-read on the frontmatter-error path).
|
|
69
|
+
raw: source,
|
|
66
70
|
ref: relative(contentRoot, file),
|
|
67
71
|
sourcePath: file,
|
|
68
72
|
};
|
|
@@ -88,7 +88,8 @@ const releaseToEntry = (release: GithubRelease): SourceEntry => {
|
|
|
88
88
|
type: "changelog",
|
|
89
89
|
};
|
|
90
90
|
const raw = matter.stringify(`${body}\n`, data);
|
|
91
|
-
const
|
|
91
|
+
const fallbackRef = `release-${release.id}`;
|
|
92
|
+
const ref = `${slugifyTag(release.tag_name) || fallbackRef}.md`;
|
|
92
93
|
return {
|
|
93
94
|
body: { format: "md", text: body },
|
|
94
95
|
data,
|
|
@@ -37,49 +37,48 @@ const REGEX_SPECIAL = /[.*+?^${}()|[\]\\]/u;
|
|
|
37
37
|
const escapeChar = (char: string): string =>
|
|
38
38
|
REGEX_SPECIAL.test(char) ? `\\${char}` : char;
|
|
39
39
|
|
|
40
|
+
/** Translate one glob token at `i` into RegExp source + the next index. */
|
|
41
|
+
const globToken = (
|
|
42
|
+
pattern: string,
|
|
43
|
+
i: number
|
|
44
|
+
): { source: string; next: number } => {
|
|
45
|
+
const char = pattern[i] ?? "";
|
|
46
|
+
if (char === "*") {
|
|
47
|
+
if (pattern[i + 1] === "*") {
|
|
48
|
+
// `**/` spans zero or more whole segments — `docs/**/guide.md` must
|
|
49
|
+
// match `docs/guide.md` and `docs/a/guide.md` but not `docs/subguide.md`.
|
|
50
|
+
if (pattern[i + 2] === "/") {
|
|
51
|
+
return { next: i + 3, source: "(?:.*/)?" };
|
|
52
|
+
}
|
|
53
|
+
return { next: i + 2, source: ".*" };
|
|
54
|
+
}
|
|
55
|
+
return { next: i + 1, source: "[^/]*" };
|
|
56
|
+
}
|
|
57
|
+
if (char === "?") {
|
|
58
|
+
return { next: i + 1, source: "[^/]" };
|
|
59
|
+
}
|
|
60
|
+
if (char === "{") {
|
|
61
|
+
const end = pattern.indexOf("}", i);
|
|
62
|
+
if (end !== -1) {
|
|
63
|
+
const options = pattern
|
|
64
|
+
.slice(i + 1, end)
|
|
65
|
+
.split(",")
|
|
66
|
+
.map((part) => [...part].map(escapeChar).join(""))
|
|
67
|
+
.join("|");
|
|
68
|
+
return { next: end + 1, source: `(?:${options})` };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { next: i + 1, source: escapeChar(char) };
|
|
72
|
+
};
|
|
73
|
+
|
|
40
74
|
/** Compile a glob (`**`, `*`, `?`, `{a,b}`) into an anchored RegExp. */
|
|
41
75
|
const globToRegExp = (pattern: string): RegExp => {
|
|
42
76
|
let source = "";
|
|
43
77
|
let i = 0;
|
|
44
78
|
while (i < pattern.length) {
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
i += 2;
|
|
49
|
-
// `**/` spans zero or more whole segments — `docs/**/guide.md` must
|
|
50
|
-
// match `docs/guide.md` and `docs/a/guide.md` but not `docs/subguide.md`.
|
|
51
|
-
if (pattern[i] === "/") {
|
|
52
|
-
i += 1;
|
|
53
|
-
source += "(?:.*/)?";
|
|
54
|
-
} else {
|
|
55
|
-
source += ".*";
|
|
56
|
-
}
|
|
57
|
-
continue;
|
|
58
|
-
}
|
|
59
|
-
source += "[^/]*";
|
|
60
|
-
i += 1;
|
|
61
|
-
continue;
|
|
62
|
-
}
|
|
63
|
-
if (char === "?") {
|
|
64
|
-
source += "[^/]";
|
|
65
|
-
i += 1;
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
if (char === "{") {
|
|
69
|
-
const end = pattern.indexOf("}", i);
|
|
70
|
-
if (end !== -1) {
|
|
71
|
-
const options = pattern
|
|
72
|
-
.slice(i + 1, end)
|
|
73
|
-
.split(",")
|
|
74
|
-
.map((part) => [...part].map(escapeChar).join(""))
|
|
75
|
-
.join("|");
|
|
76
|
-
source += `(?:${options})`;
|
|
77
|
-
i = end + 1;
|
|
78
|
-
continue;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
source += escapeChar(char);
|
|
82
|
-
i += 1;
|
|
79
|
+
const token = globToken(pattern, i);
|
|
80
|
+
source += token.source;
|
|
81
|
+
i = token.next;
|
|
83
82
|
}
|
|
84
83
|
return new RegExp(`^${source}$`, "u");
|
|
85
84
|
};
|
|
@@ -137,15 +136,22 @@ const enumerateGithub = async (
|
|
|
137
136
|
truncated?: boolean;
|
|
138
137
|
};
|
|
139
138
|
const prefix = base ? `${base}/` : "";
|
|
140
|
-
const refs = (body.tree ?? [])
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
139
|
+
const refs = (body.tree ?? []).flatMap((node) => {
|
|
140
|
+
if (!(node.type === "blob" && node.path.startsWith(prefix))) {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
const rel = node.path.slice(prefix.length);
|
|
144
|
+
if (!matchesInclude(rel, include)) {
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
return [
|
|
148
|
+
{
|
|
149
|
+
editUrl: `https://github.com/${owner}/${repo}/edit/${ref}/${prefix}${rel}`,
|
|
150
|
+
fetchUrl: `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${prefix}${rel}`,
|
|
151
|
+
ref: rel,
|
|
152
|
+
},
|
|
153
|
+
];
|
|
154
|
+
});
|
|
149
155
|
// GitHub caps the recursive tree response (~100k entries / 7MB) and flags it
|
|
150
156
|
// with `truncated`; ignoring it would silently import only part of the repo.
|
|
151
157
|
return { refs, truncated: body.truncated === true };
|
|
@@ -164,6 +170,21 @@ export const mdxRemoteSource = (
|
|
|
164
170
|
const cache = snapshotCache(ctx.cacheDir);
|
|
165
171
|
let snapshot = new Map<string, SourceEntry>();
|
|
166
172
|
|
|
173
|
+
// Validated up front in `load`, *before* the cached-fetch path: thrown from
|
|
174
|
+
// inside `loadWithCache`'s fetch callback, a misconfiguration would be masked
|
|
175
|
+
// as BLUME_SOURCE_FETCH_FAILED (no cache) or downgraded to a stale-cache
|
|
176
|
+
// BLUME_SOURCE_OFFLINE warning (cache present).
|
|
177
|
+
const assertConfigured = (): void => {
|
|
178
|
+
if (options.github || (options.files && options.url)) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
throw new BlumeError({
|
|
182
|
+
code: "BLUME_SOURCE_MISCONFIGURED",
|
|
183
|
+
message: `Source "${options.name}" needs either { github } or { url, files }.`,
|
|
184
|
+
severity: "error",
|
|
185
|
+
});
|
|
186
|
+
};
|
|
187
|
+
|
|
167
188
|
const enumerate = async (): Promise<{
|
|
168
189
|
refs: RemoteRef[];
|
|
169
190
|
truncated: boolean;
|
|
@@ -171,22 +192,13 @@ export const mdxRemoteSource = (
|
|
|
171
192
|
if (options.github) {
|
|
172
193
|
return await enumerateGithub(options.github, options.include, doFetch);
|
|
173
194
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
ref,
|
|
182
|
-
}));
|
|
183
|
-
return { refs, truncated: false };
|
|
184
|
-
}
|
|
185
|
-
throw new BlumeError({
|
|
186
|
-
code: "BLUME_SOURCE_MISCONFIGURED",
|
|
187
|
-
message: `Source "${options.name}" needs either { github } or { url, files }.`,
|
|
188
|
-
severity: "error",
|
|
189
|
-
});
|
|
195
|
+
const base = (options.url ?? "").replace(/\/$/u, "");
|
|
196
|
+
const refs = (options.files ?? []).flatMap((ref) =>
|
|
197
|
+
matchesInclude(ref, options.include)
|
|
198
|
+
? [{ editUrl: `${base}/${ref}`, fetchUrl: `${base}/${ref}`, ref }]
|
|
199
|
+
: []
|
|
200
|
+
);
|
|
201
|
+
return { refs, truncated: false };
|
|
190
202
|
};
|
|
191
203
|
|
|
192
204
|
const fetchEntry = async (item: RemoteRef): Promise<SourceEntry> => {
|
|
@@ -212,6 +224,7 @@ export const mdxRemoteSource = (
|
|
|
212
224
|
const load = async (
|
|
213
225
|
refresh = ctx.refresh ?? true
|
|
214
226
|
): Promise<SourceLoadResult> => {
|
|
227
|
+
assertConfigured();
|
|
215
228
|
const skipped: Diagnostic[] = [];
|
|
216
229
|
const result = await loadWithCache(
|
|
217
230
|
options.name,
|
|
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
3
3
|
import GithubSlugger from "github-slugger";
|
|
4
4
|
import { extname } from "pathe";
|
|
5
5
|
|
|
6
|
+
import { withBasePath } from "../base-path.ts";
|
|
6
7
|
import { diagnosticsFromZod } from "../diagnostics.ts";
|
|
7
8
|
import { localePlacement, localizeRoute } from "../i18n.ts";
|
|
8
9
|
import { pageMetaSchema } from "../schema.ts";
|
|
@@ -45,6 +46,29 @@ const titleCase = (value: string): string =>
|
|
|
45
46
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
46
47
|
.join(" ");
|
|
47
48
|
|
|
49
|
+
/** Fold one raw path part into the accumulating route segments/groups. */
|
|
50
|
+
const addRouteSegment = (
|
|
51
|
+
part: string,
|
|
52
|
+
segments: string[],
|
|
53
|
+
groups: string[]
|
|
54
|
+
): void => {
|
|
55
|
+
// A leading/trailing/double slash yields an empty part; keeping it would
|
|
56
|
+
// produce a malformed route (`//foo`, `/foo/`) that nothing can link to.
|
|
57
|
+
if (part === "") {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const group = groupLabel(part);
|
|
61
|
+
if (group !== null) {
|
|
62
|
+
groups.push(group);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const clean = stripNumericPrefix(part);
|
|
66
|
+
if (clean === "index") {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
segments.push(clean);
|
|
70
|
+
};
|
|
71
|
+
|
|
48
72
|
/** Convert a content-root-relative path into URL + nav metadata. */
|
|
49
73
|
const mapRoute = (
|
|
50
74
|
relativePath: string
|
|
@@ -59,61 +83,147 @@ const mapRoute = (
|
|
|
59
83
|
const groups: string[] = [];
|
|
60
84
|
|
|
61
85
|
for (const part of rawParts) {
|
|
62
|
-
|
|
63
|
-
// produce a malformed route (`//foo`, `/foo/`) that nothing can link to.
|
|
64
|
-
if (part === "") {
|
|
65
|
-
continue;
|
|
66
|
-
}
|
|
67
|
-
const group = groupLabel(part);
|
|
68
|
-
if (group !== null) {
|
|
69
|
-
groups.push(group);
|
|
70
|
-
continue;
|
|
71
|
-
}
|
|
72
|
-
const clean = stripNumericPrefix(part);
|
|
73
|
-
if (clean === "index") {
|
|
74
|
-
continue;
|
|
75
|
-
}
|
|
76
|
-
segments.push(clean);
|
|
86
|
+
addRouteSegment(part, segments, groups);
|
|
77
87
|
}
|
|
78
88
|
|
|
79
89
|
const route = segments.length === 0 ? "/" : `/${segments.join("/")}`;
|
|
80
90
|
return { groups, route, segments };
|
|
81
91
|
};
|
|
82
92
|
|
|
83
|
-
|
|
93
|
+
// CommonMark allows backtick *and* tilde fences. The scanners track which
|
|
94
|
+
// delimiter opened the current fence (`null` when outside one) so a ``` line
|
|
95
|
+
// inside a ~~~ block is content, not a toggle — see `nextFenceState`.
|
|
96
|
+
const CODE_FENCE = /^(?<delimiter>```|~~~)/u;
|
|
97
|
+
|
|
98
|
+
/** The fence delimiter opening the current code block, or null outside one. */
|
|
99
|
+
type FenceState = "```" | "~~~" | null;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Advance the fenced-code state for one line: an opening fence records its
|
|
103
|
+
* delimiter, only the matching delimiter closes it, and any other line leaves
|
|
104
|
+
* the state untouched.
|
|
105
|
+
*/
|
|
106
|
+
const nextFenceState = (line: string, fence: FenceState): FenceState => {
|
|
107
|
+
const delimiter = line.trimStart().match(CODE_FENCE)?.groups?.delimiter as
|
|
108
|
+
| Exclude<FenceState, null>
|
|
109
|
+
| undefined;
|
|
110
|
+
if (delimiter === undefined) {
|
|
111
|
+
return fence;
|
|
112
|
+
}
|
|
113
|
+
if (fence === null) {
|
|
114
|
+
return delimiter;
|
|
115
|
+
}
|
|
116
|
+
return fence === delimiter ? null : fence;
|
|
117
|
+
};
|
|
84
118
|
// A closing hash sequence must be preceded by whitespace (CommonMark), so a
|
|
85
|
-
// heading like `## What is C#` keeps its trailing `#`.
|
|
86
|
-
|
|
119
|
+
// heading like `## What is C#` keeps its trailing `#`. Up to 3 leading spaces
|
|
120
|
+
// are allowed; 4+ is an indented code block.
|
|
121
|
+
const ATX_HEADING = /^ {0,3}(?<hashes>#{1,6})\s+(?<text>.+?)(?:\s+#+)?\s*$/u;
|
|
122
|
+
// A setext underline: a run of `=` (level 1) or `-` (level 2) alone on a line,
|
|
123
|
+
// up to 3 leading spaces. It only forms a heading directly under paragraph
|
|
124
|
+
// text — see `scanHeadingLine`.
|
|
125
|
+
const SETEXT_UNDERLINE = /^ {0,3}(?<marker>=+|-+)\s*$/u;
|
|
126
|
+
// Lines that end a paragraph without being one (CommonMark): blank lines are
|
|
127
|
+
// checked separately; these cover list items, blockquotes, and thematic
|
|
128
|
+
// breaks, so a `---` after any of them stays a thematic break, not an
|
|
129
|
+
// underline promoting the list/quote text to a heading.
|
|
130
|
+
const PARAGRAPH_INTERRUPT = /^ {0,3}(?:[-+*][ \t]|\d{1,9}[.)][ \t]|>)/u;
|
|
131
|
+
const THEMATIC_BREAK =
|
|
132
|
+
/^ {0,3}(?:(?:-[ \t]*){3,}|(?:\*[ \t]*){3,}|(?:_[ \t]*){3,})$/u;
|
|
133
|
+
const FRONT_MATTER_CLOSE = /^(?:-{3}|\.{3})\s*$/u;
|
|
87
134
|
|
|
88
135
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* `blume validate` stops false-flagging links like `#the-read--write-fallback`
|
|
95
|
-
* (a hand slugify collapses `--`; github-slugger keeps it) and resolves repeated
|
|
96
|
-
* headings the same way (`setup`, `setup-1`).
|
|
136
|
+
* The body lines, minus a leading front matter block. Bodies from the
|
|
137
|
+
* normalize pipeline are already frontmatter-stripped, but `extractHeadings`
|
|
138
|
+
* also runs on raw documents — where a leading `---` block (closed by `---` or
|
|
139
|
+
* `...`) is front matter, not a thematic break whose closing `---` would
|
|
140
|
+
* underline the last metadata line into a phantom setext heading.
|
|
97
141
|
*/
|
|
142
|
+
const linesWithoutFrontMatter = (body: string): string[] => {
|
|
143
|
+
const lines = body.split("\n");
|
|
144
|
+
if (!/^-{3}\s*$/u.test(lines[0] ?? "")) {
|
|
145
|
+
return lines;
|
|
146
|
+
}
|
|
147
|
+
const close = lines.findIndex(
|
|
148
|
+
(line, index) => index > 0 && FRONT_MATTER_CLOSE.test(line)
|
|
149
|
+
);
|
|
150
|
+
return close === -1 ? lines : lines.slice(close + 1);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/** Scanner state: the open fence plus the paragraph lines accumulated so far. */
|
|
154
|
+
interface HeadingScanState {
|
|
155
|
+
fence: FenceState;
|
|
156
|
+
/** Consecutive paragraph lines — the candidate text for a setext underline. */
|
|
157
|
+
paragraph: string[];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Extract ATX and setext headings from a markdown body, skipping fenced code
|
|
162
|
+
* blocks, exactly as the renderer sees them: ATX headings may be indented up
|
|
163
|
+
* to 3 spaces, and a paragraph underlined with `=`/`-` is a level 1/2 setext
|
|
164
|
+
* heading. Each heading's anchor slug comes from a per-document
|
|
165
|
+
* `github-slugger` — the exact slugger the renderer uses
|
|
166
|
+
* (`markdown/heading-anchors`) — advanced over every heading in document
|
|
167
|
+
* order. Matching it (rather than a hand-rolled slugify) keeps the manifest's
|
|
168
|
+
* anchor ids identical to the rendered ones, so `blume validate` stops
|
|
169
|
+
* false-flagging links like `#the-read--write-fallback` (a hand slugify
|
|
170
|
+
* collapses `--`; github-slugger keeps it) and resolves repeated headings the
|
|
171
|
+
* same way (`setup`, `setup-1`).
|
|
172
|
+
*/
|
|
173
|
+
/** Scan one line for a heading, advancing the fence/paragraph state. */
|
|
174
|
+
const scanHeadingLine = (
|
|
175
|
+
line: string,
|
|
176
|
+
state: HeadingScanState,
|
|
177
|
+
slugger: GithubSlugger,
|
|
178
|
+
headings: Heading[]
|
|
179
|
+
): void => {
|
|
180
|
+
const next = nextFenceState(line, state.fence);
|
|
181
|
+
// Skip fence delimiter lines themselves and anything inside a fence. A fence
|
|
182
|
+
// also ends any open paragraph, so no underline can reach across it.
|
|
183
|
+
if (state.fence !== null || next !== null) {
|
|
184
|
+
state.fence = next;
|
|
185
|
+
state.paragraph = [];
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const atx = line.match(ATX_HEADING);
|
|
189
|
+
if (atx?.groups) {
|
|
190
|
+
const depth = atx.groups.hashes?.length ?? 1;
|
|
191
|
+
const text = (atx.groups.text ?? "").trim();
|
|
192
|
+
headings.push({ depth, slug: slugger.slug(text), text });
|
|
193
|
+
state.paragraph = [];
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const setext = line.match(SETEXT_UNDERLINE);
|
|
197
|
+
if (setext?.groups && state.paragraph.length > 0) {
|
|
198
|
+
// Setext wins over thematic break when it closes a paragraph (CommonMark);
|
|
199
|
+
// a multi-line paragraph renders as one heading, soft breaks as spaces.
|
|
200
|
+
const text = state.paragraph.join(" ").trim();
|
|
201
|
+
headings.push({
|
|
202
|
+
depth: setext.groups.marker?.startsWith("=") ? 1 : 2,
|
|
203
|
+
slug: slugger.slug(text),
|
|
204
|
+
text,
|
|
205
|
+
});
|
|
206
|
+
state.paragraph = [];
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (
|
|
210
|
+
line.trim() === "" ||
|
|
211
|
+
THEMATIC_BREAK.test(line) ||
|
|
212
|
+
PARAGRAPH_INTERRUPT.test(line)
|
|
213
|
+
) {
|
|
214
|
+
state.paragraph = [];
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
state.paragraph.push(line.trim());
|
|
218
|
+
};
|
|
219
|
+
|
|
98
220
|
export const extractHeadings = (body: string): Heading[] => {
|
|
99
221
|
const headings: Heading[] = [];
|
|
100
222
|
const slugger = new GithubSlugger();
|
|
101
|
-
|
|
223
|
+
const state: HeadingScanState = { fence: null, paragraph: [] };
|
|
102
224
|
|
|
103
|
-
for (const line of body
|
|
104
|
-
|
|
105
|
-
inFence = !inFence;
|
|
106
|
-
continue;
|
|
107
|
-
}
|
|
108
|
-
if (inFence) {
|
|
109
|
-
continue;
|
|
110
|
-
}
|
|
111
|
-
const match = line.match(ATX_HEADING);
|
|
112
|
-
if (match?.groups) {
|
|
113
|
-
const depth = match.groups.hashes?.length ?? 1;
|
|
114
|
-
const text = (match.groups.text ?? "").trim();
|
|
115
|
-
headings.push({ depth, slug: slugger.slug(text), text });
|
|
116
|
-
}
|
|
225
|
+
for (const line of linesWithoutFrontMatter(body)) {
|
|
226
|
+
scanHeadingLine(line, state, slugger, headings);
|
|
117
227
|
}
|
|
118
228
|
|
|
119
229
|
return headings;
|
|
@@ -125,42 +235,53 @@ const INLINE_CODE = /`[^`]*`/gu;
|
|
|
125
235
|
/**
|
|
126
236
|
* Extract link targets from a markdown body for later validation, recording the
|
|
127
237
|
* 1-based line/column of each target. Skips fenced code blocks and inline code.
|
|
238
|
+
* `lineOffset` shifts every recorded line: the body is frontmatter-stripped, so
|
|
239
|
+
* diagnostics that point into the raw file must add the stripped block's height.
|
|
128
240
|
*/
|
|
129
|
-
|
|
241
|
+
/** Scan one line for link targets; returns the next fenced-block state. */
|
|
242
|
+
const scanLinkLine = (
|
|
243
|
+
line: string,
|
|
244
|
+
lineNumber: number,
|
|
245
|
+
fence: FenceState,
|
|
246
|
+
links: PageLink[]
|
|
247
|
+
): FenceState => {
|
|
248
|
+
const next = nextFenceState(line, fence);
|
|
249
|
+
// Skip fence delimiter lines themselves and anything inside a fence.
|
|
250
|
+
if (fence !== null || next !== null) {
|
|
251
|
+
return next;
|
|
252
|
+
}
|
|
253
|
+
// Blank out inline code spans (`[label](/x)` shown as syntax, not a link)
|
|
254
|
+
// with same-length padding so recorded columns stay accurate.
|
|
255
|
+
const masked = line.replaceAll(INLINE_CODE, (span) =>
|
|
256
|
+
" ".repeat(span.length)
|
|
257
|
+
);
|
|
258
|
+
for (const match of masked.matchAll(MD_LINK)) {
|
|
259
|
+
const target = match.groups?.target;
|
|
260
|
+
if (target === undefined || match.index === undefined) {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
// Locate the target from the `](` boundary rather than searching for the
|
|
264
|
+
// target text from the match start — otherwise a label that contains the
|
|
265
|
+
// same text (e.g. `[/a/b](/a/b)`) reports the column inside the label. The
|
|
266
|
+
// label can't contain `]`, so `](` is unambiguous.
|
|
267
|
+
const targetOffset = match.index + match[0].indexOf("](") + "](".length;
|
|
268
|
+
links.push({
|
|
269
|
+
column: targetOffset + 1,
|
|
270
|
+
line: lineNumber,
|
|
271
|
+
target,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
return next;
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
export const extractLinks = (body: string, lineOffset = 0): PageLink[] => {
|
|
130
278
|
const links: PageLink[] = [];
|
|
131
|
-
let
|
|
132
|
-
let lineNumber =
|
|
279
|
+
let fence: FenceState = null;
|
|
280
|
+
let lineNumber = lineOffset;
|
|
133
281
|
|
|
134
282
|
for (const line of body.split("\n")) {
|
|
135
283
|
lineNumber += 1;
|
|
136
|
-
|
|
137
|
-
inFence = !inFence;
|
|
138
|
-
continue;
|
|
139
|
-
}
|
|
140
|
-
if (inFence) {
|
|
141
|
-
continue;
|
|
142
|
-
}
|
|
143
|
-
// Blank out inline code spans (`[label](/x)` shown as syntax, not a link)
|
|
144
|
-
// with same-length padding so recorded columns stay accurate.
|
|
145
|
-
const masked = line.replaceAll(INLINE_CODE, (span) =>
|
|
146
|
-
" ".repeat(span.length)
|
|
147
|
-
);
|
|
148
|
-
for (const match of masked.matchAll(MD_LINK)) {
|
|
149
|
-
const target = match.groups?.target;
|
|
150
|
-
if (target === undefined || match.index === undefined) {
|
|
151
|
-
continue;
|
|
152
|
-
}
|
|
153
|
-
// Locate the target from the `](` boundary rather than searching for the
|
|
154
|
-
// target text from the match start — otherwise a label that contains the
|
|
155
|
-
// same text (e.g. `[/a/b](/a/b)`) reports the column inside the label. The
|
|
156
|
-
// label can't contain `]`, so `](` is unambiguous.
|
|
157
|
-
const targetOffset = match.index + match[0].indexOf("](") + "](".length;
|
|
158
|
-
links.push({
|
|
159
|
-
column: targetOffset + 1,
|
|
160
|
-
line: lineNumber,
|
|
161
|
-
target,
|
|
162
|
-
});
|
|
163
|
-
}
|
|
284
|
+
fence = scanLinkLine(line, lineNumber, fence, links);
|
|
164
285
|
}
|
|
165
286
|
|
|
166
287
|
return links;
|
|
@@ -179,30 +300,45 @@ const JSX_OPEN = /<(?<tag>[A-Z][A-Za-z0-9]*)/gu;
|
|
|
179
300
|
* strings so code samples and prose don't count. Powers the missing-component
|
|
180
301
|
* diagnostic.
|
|
181
302
|
*/
|
|
303
|
+
/** Scan one line for JSX component tags; returns the next fenced-block state. */
|
|
304
|
+
const scanTagLine = (
|
|
305
|
+
line: string,
|
|
306
|
+
fence: FenceState,
|
|
307
|
+
tags: Set<string>
|
|
308
|
+
): FenceState => {
|
|
309
|
+
const next = nextFenceState(line, fence);
|
|
310
|
+
// Skip fence delimiter lines themselves and anything inside a fence.
|
|
311
|
+
if (fence !== null || next !== null) {
|
|
312
|
+
return next;
|
|
313
|
+
}
|
|
314
|
+
const clean = line.replaceAll(INLINE_CODE, "").replaceAll(DOUBLE_QUOTED, "");
|
|
315
|
+
for (const match of clean.matchAll(JSX_OPEN)) {
|
|
316
|
+
const tag = match.groups?.tag;
|
|
317
|
+
if (tag) {
|
|
318
|
+
tags.add(tag);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return next;
|
|
322
|
+
};
|
|
323
|
+
|
|
182
324
|
export const extractComponentTags = (body: string): string[] => {
|
|
183
325
|
const tags = new Set<string>();
|
|
184
|
-
let
|
|
326
|
+
let fence: FenceState = null;
|
|
185
327
|
for (const line of body.split("\n")) {
|
|
186
|
-
|
|
187
|
-
inFence = !inFence;
|
|
188
|
-
continue;
|
|
189
|
-
}
|
|
190
|
-
if (inFence) {
|
|
191
|
-
continue;
|
|
192
|
-
}
|
|
193
|
-
const clean = line
|
|
194
|
-
.replaceAll(INLINE_CODE, "")
|
|
195
|
-
.replaceAll(DOUBLE_QUOTED, "");
|
|
196
|
-
for (const match of clean.matchAll(JSX_OPEN)) {
|
|
197
|
-
const tag = match.groups?.tag;
|
|
198
|
-
if (tag) {
|
|
199
|
-
tags.add(tag);
|
|
200
|
-
}
|
|
201
|
-
}
|
|
328
|
+
fence = scanTagLine(line, fence, tags);
|
|
202
329
|
}
|
|
203
330
|
return [...tags];
|
|
204
331
|
};
|
|
205
332
|
|
|
333
|
+
/**
|
|
334
|
+
* Height of the frontmatter block stripped from `raw` to produce `body` (0
|
|
335
|
+
* when the raw text is unknown or nothing was stripped). Link positions are
|
|
336
|
+
* extracted from the stripped body, but diagnostics point into the raw
|
|
337
|
+
* document — recorded lines must shift by this offset to match it.
|
|
338
|
+
*/
|
|
339
|
+
const strippedLineOffset = (raw: string | undefined, body: string): number =>
|
|
340
|
+
raw ? Math.max(0, raw.split("\n").length - body.split("\n").length) : 0;
|
|
341
|
+
|
|
206
342
|
const deriveTitle = (
|
|
207
343
|
meta: PageMeta,
|
|
208
344
|
headings: Heading[],
|
|
@@ -311,7 +447,10 @@ export const normalizeEntry = (
|
|
|
311
447
|
headings,
|
|
312
448
|
id: `${ctx.source.name}:${entry.ref}`,
|
|
313
449
|
lastModified: meta.lastModified ?? entry.lastModified,
|
|
314
|
-
links: extractLinks(
|
|
450
|
+
links: extractLinks(
|
|
451
|
+
entry.body.text,
|
|
452
|
+
strippedLineOffset(entry.raw, entry.body.text)
|
|
453
|
+
),
|
|
315
454
|
meta,
|
|
316
455
|
navPath,
|
|
317
456
|
segments,
|
|
@@ -323,10 +462,16 @@ export const normalizeEntry = (
|
|
|
323
462
|
|
|
324
463
|
// One record per locale this entry maps to (one normally; every locale for a
|
|
325
464
|
// shared `$` file). All share the same id, source ref, and translation key.
|
|
465
|
+
// `basePath` is applied outermost — after locale prefixing — so the route
|
|
466
|
+
// reads `{basePath}/{locale?}/{prefix?}/…`; `navPath` and `translationKey`
|
|
467
|
+
// stay base-less so the nav tree and translation matching are unaffected.
|
|
326
468
|
const pages = locales.map((locale) => ({
|
|
327
469
|
...base,
|
|
328
470
|
locale,
|
|
329
|
-
route:
|
|
471
|
+
route: withBasePath(
|
|
472
|
+
ctx.basePath ?? "",
|
|
473
|
+
i18n ? localizeRoute(logicalRoute, locale, i18n) : logicalRoute
|
|
474
|
+
),
|
|
330
475
|
}));
|
|
331
476
|
|
|
332
477
|
return { diagnostics: [], pages };
|