blume 1.5.2 → 1.6.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 +85 -0
- package/dist/cli/index.js +3639 -1377
- package/dist/cli/index.js.map +103 -91
- package/dist/types/ai/component-markdown.d.ts +79 -0
- package/dist/types/components/layout/nav-utils.d.ts +60 -0
- package/dist/types/core/base-path.d.ts +9 -0
- package/dist/types/core/config-input.d.ts +206 -4
- package/dist/types/core/config.d.ts +6 -4
- package/dist/types/core/data.d.ts +23 -1
- package/dist/types/core/github.d.ts +35 -0
- package/dist/types/core/i18n-ui.d.ts +8 -0
- package/dist/types/core/navigation.d.ts +69 -0
- package/dist/types/core/schema.d.ts +117 -1
- package/dist/types/core/sources/types.d.ts +31 -6
- package/dist/types/core/types.d.ts +23 -2
- package/dist/types/markdown/features.d.ts +21 -0
- package/dist/types/openapi/references.d.ts +21 -1
- package/dist/types/seo/jsonld.d.ts +105 -0
- package/dist/types/theme/fonts.d.ts +34 -4
- package/docs/_snippets/include-demo.mdx +7 -0
- package/docs/advanced/api-reference.mdx +3 -3
- package/docs/advanced/custom-pages.mdx +1 -1
- package/docs/advanced/graphql.mdx +84 -0
- package/docs/advanced/meta.ts +8 -1
- package/docs/configuration/ai.mdx +21 -3
- package/docs/configuration/index.mdx +24 -0
- package/docs/configuration/search.mdx +13 -1
- package/docs/configuration/seo.mdx +27 -0
- package/docs/configuration/theming.mdx +17 -0
- package/docs/content/components.mdx +7 -0
- package/docs/content/includes.mdx +68 -0
- package/docs/content/meta.ts +1 -0
- package/docs/content/navigation.mdx +25 -0
- package/docs/content/sources.mdx +42 -1
- package/docs/content/syntax.mdx +69 -1
- package/docs/content/versioning.mdx +15 -9
- package/docs/reference/cli.mdx +2 -1
- package/package.json +23 -14
- package/skills/blume-migrate/SKILL.md +16 -7
- package/skills/blume-migrate/references/docusaurus.md +5 -3
- package/skills/blume-migrate/references/fumadocs.md +10 -2
- package/skills/blume-migrate/references/mintlify.md +3 -2
- package/skills/blume-migrate/references/nextra.md +2 -2
- package/skills/blume-migrate/references/starlight.md +1 -1
- package/src/ai/agent-readability.ts +2 -1
- package/src/ai/ask-data.ts +2 -1
- package/src/ai/component-markdown.ts +199 -36
- package/src/ai/llms.ts +93 -6
- package/src/ai/markdown.ts +2 -2
- package/src/ai/mcp/discovery.ts +10 -2
- package/src/ai/mcp/server.ts +74 -2
- package/src/astro/generate.ts +183 -116
- package/src/astro/include-hmr.ts +81 -0
- package/src/astro/include-refresh.ts +0 -0
- package/src/astro/index.ts +3 -5
- package/src/astro/templates.ts +125 -76
- package/src/cli/commands/build.ts +84 -15
- package/src/cli/init/questions.ts +1 -0
- package/src/cli/init/scaffold.ts +27 -4
- package/src/components/colors.ts +142 -0
- package/src/components/content/Badge.astro +5 -12
- package/src/components/content/Callout.astro +19 -36
- package/src/components/content/Card.astro +15 -21
- package/src/components/content/Component.astro +10 -1
- package/src/components/content/GithubInfo.astro +28 -9
- package/src/components/content/Tabs.astro +27 -5
- package/src/components/content/github-info.ts +20 -5
- package/src/components/dropdown-dismiss.ts +122 -0
- package/src/components/layout/Fonts.astro +15 -8
- package/src/components/layout/Header.astro +44 -0
- package/src/components/layout/LanguageSwitcher.astro +9 -1
- package/src/components/layout/NavSelector.astro +12 -3
- package/src/components/layout/NavTree.astro +6 -18
- package/src/components/layout/PageActions.astro +29 -8
- package/src/components/layout/PageLayout.astro +10 -1
- package/src/components/layout/ReferenceLayout.astro +6 -1
- package/src/components/layout/RootLayout.astro +46 -15
- package/src/components/layout/Search.astro +36 -4
- package/src/components/layout/TableOfContents.astro +8 -2
- package/src/components/layout/head-scripts.ts +53 -1
- package/src/components/openapi/ApiOverview.astro +13 -3
- package/src/components/openapi/AsyncApiOperation.astro +7 -14
- package/src/components/openapi/GraphqlChip.astro +33 -0
- package/src/components/openapi/GraphqlFieldsTable.astro +111 -0
- package/src/components/openapi/GraphqlOperation.astro +186 -0
- package/src/components/openapi/GraphqlType.astro +154 -0
- package/src/components/openapi/MethodBadge.astro +3 -14
- package/src/components/openapi/Operation.astro +12 -5
- package/src/components/openapi/OperationPanel.astro +43 -0
- package/src/components/openapi/RequestPanel.astro +5 -10
- package/src/components/openapi/Responses.astro +1 -16
- package/src/components/openapi/graphql-helpers.ts +466 -0
- package/src/components/openapi/playground-client.ts +15 -0
- package/src/components/openapi/sample-panels.ts +45 -0
- package/src/components/openapi/snippets.ts +13 -35
- package/src/core/base-path.ts +11 -0
- package/src/core/config-input.ts +209 -2
- package/src/core/config.ts +6 -4
- package/src/core/content-assets.ts +15 -4
- package/src/core/data.ts +18 -2
- package/src/core/diagnostics.ts +8 -0
- package/src/core/frontmatter.ts +20 -8
- package/src/core/github.ts +71 -0
- package/src/core/graph.ts +22 -8
- package/src/core/heading-markers.ts +96 -0
- package/src/core/i18n-ui.ts +11 -0
- package/src/core/includes.ts +632 -0
- package/src/core/last-modified.ts +36 -11
- package/src/core/links.ts +79 -13
- package/src/core/meta.ts +2 -1
- package/src/core/nav-diagnostics.ts +11 -2
- package/src/core/navigation.ts +27 -6
- package/src/core/project-graph.ts +61 -9
- package/src/core/schema.ts +226 -35
- package/src/core/server-features.ts +5 -9
- package/src/core/sources/github-releases.ts +2 -2
- package/src/core/sources/normalize.ts +502 -115
- package/src/core/sources/notion.ts +43 -8
- package/src/core/sources/obsidian.ts +1038 -0
- package/src/core/sources/read.ts +36 -1
- package/src/core/sources/resolve.ts +34 -1
- package/src/core/sources/types.ts +28 -6
- package/src/core/sources/watch.ts +12 -8
- package/src/core/tsconfig-aliases.ts +48 -35
- package/src/core/types.ts +25 -2
- package/src/core/ui-packs/ar.ts +1 -0
- package/src/core/ui-packs/bg.ts +2 -0
- package/src/core/ui-packs/bn.ts +1 -0
- package/src/core/ui-packs/ca.ts +2 -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 +2 -0
- package/src/core/ui-packs/el.ts +2 -0
- package/src/core/ui-packs/es.ts +2 -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 -0
- 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 +2 -0
- package/src/core/ui-packs/hu.ts +2 -0
- package/src/core/ui-packs/id.ts +2 -0
- package/src/core/ui-packs/it.ts +1 -0
- package/src/core/ui-packs/ja.ts +2 -0
- package/src/core/ui-packs/ko.ts +2 -0
- package/src/core/ui-packs/nl.ts +2 -0
- package/src/core/ui-packs/no.ts +2 -0
- package/src/core/ui-packs/pl.ts +2 -0
- package/src/core/ui-packs/pt-br.ts +2 -0
- package/src/core/ui-packs/pt.ts +2 -0
- package/src/core/ui-packs/ro.ts +2 -0
- package/src/core/ui-packs/ru.ts +2 -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 +2 -0
- package/src/core/ui-packs/th.ts +1 -0
- package/src/core/ui-packs/tr.ts +2 -0
- package/src/core/ui-packs/uk.ts +2 -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/core/version-cut.ts +21 -3
- package/src/core/yaml.ts +26 -0
- package/src/deploy/function-bundle.ts +251 -0
- package/src/eval/schema.ts +3 -1
- package/src/markdown/code-title.ts +22 -16
- package/src/markdown/features.ts +21 -0
- package/src/markdown/fence-meta.ts +50 -0
- package/src/markdown/heading-anchors.ts +198 -37
- package/src/markdown/include.ts +247 -0
- package/src/markdown/index.ts +43 -34
- package/src/markdown/language-icon.ts +2 -2
- package/src/markdown/mdast.ts +7 -3
- package/src/markdown/ts2js.ts +264 -0
- package/src/openapi/asyncapi.ts +4 -1
- package/src/openapi/graphql-build.ts +293 -0
- package/src/openapi/graphql.ts +212 -0
- package/src/openapi/model.ts +38 -5
- package/src/openapi/parse.ts +34 -0
- package/src/openapi/proxy.ts +30 -5
- package/src/openapi/references.ts +89 -13
- package/src/openapi/render-mdx.ts +48 -8
- package/src/openapi/scalar.ts +5 -12
- package/src/openapi/source.ts +91 -23
- package/src/registry/eject.ts +11 -0
- package/src/search/documents.ts +229 -37
- package/src/search/orama-index.ts +9 -5
- package/src/seo/jsonld.ts +293 -51
- package/src/theme/code-block-padding.ts +16 -0
- package/src/theme/entry.ts +65 -11
- package/src/theme/fonts.ts +189 -16
- package/src/translate/prompts.ts +2 -0
- package/src/translate/run.ts +7 -0
- package/src/translate/work-list.ts +0 -0
|
@@ -5,9 +5,15 @@ import { extname } from "pathe";
|
|
|
5
5
|
|
|
6
6
|
import { withBasePath } from "../base-path.ts";
|
|
7
7
|
import { diagnosticsFromIssues, diagnosticsFromZod } from "../diagnostics.ts";
|
|
8
|
+
import { occupySlug, parseHeadingMarkers } from "../heading-markers.ts";
|
|
8
9
|
import { localePlacement, localizeRoute } from "../i18n.ts";
|
|
9
10
|
import { pageMetaSchema } from "../schema.ts";
|
|
10
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
FrontmatterExtend,
|
|
13
|
+
PageMeta,
|
|
14
|
+
ResolvedI18nConfig,
|
|
15
|
+
} from "../schema.ts";
|
|
16
|
+
import { trimChar } from "../trim.ts";
|
|
11
17
|
import type { Diagnostic, Heading, PageLink, PageRecord } from "../types.ts";
|
|
12
18
|
import { detectVersionRef, versionizeRoute } from "../versions.ts";
|
|
13
19
|
import type { NormalizeContext, SourceEntry } from "./types.ts";
|
|
@@ -110,7 +116,11 @@ interface MappedRoute {
|
|
|
110
116
|
route: string;
|
|
111
117
|
}
|
|
112
118
|
|
|
113
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* Convert a content-root-relative path into URL + nav metadata. Not exported:
|
|
121
|
+
* a source that needs to predict a route goes through
|
|
122
|
+
* {@link resolveEntryRoute}, so there is exactly one derivation.
|
|
123
|
+
*/
|
|
114
124
|
const mapRoute = (relativePath: string): MappedRoute => {
|
|
115
125
|
const withoutExt = relativePath.slice(
|
|
116
126
|
0,
|
|
@@ -129,41 +139,42 @@ const mapRoute = (relativePath: string): MappedRoute => {
|
|
|
129
139
|
return { groups, route, segments };
|
|
130
140
|
};
|
|
131
141
|
|
|
132
|
-
// CommonMark allows backtick *and* tilde fences
|
|
133
|
-
// delimiter opened the current fence
|
|
134
|
-
//
|
|
135
|
-
|
|
142
|
+
// CommonMark allows backtick *and* tilde fences, three or more characters
|
|
143
|
+
// long. The scanners track which delimiter opened the current fence and how
|
|
144
|
+
// long its run was (`null` when outside one), so a ``` line inside a ~~~
|
|
145
|
+
// block — or inside a ````-delimited block (the wrapper `codeBlockLines`
|
|
146
|
+
// emits around code that contains its own ``` fence) — is content, not a
|
|
147
|
+
// toggle. See `nextFenceState`.
|
|
148
|
+
const CODE_FENCE = /^(?<run>`{3,}|~{3,})/u;
|
|
136
149
|
|
|
137
|
-
/** The fence delimiter
|
|
138
|
-
export type FenceState = "
|
|
150
|
+
/** The open fence's delimiter char and run length, or null outside one. */
|
|
151
|
+
export type FenceState = { delimiter: "`" | "~"; length: number } | null;
|
|
139
152
|
|
|
140
153
|
/**
|
|
141
154
|
* Advance the fenced-code state for one line: an opening fence records its
|
|
142
|
-
* delimiter, only
|
|
143
|
-
* the state untouched.
|
|
155
|
+
* delimiter and run length, only a run of the same character at least as long
|
|
156
|
+
* closes it (CommonMark), and any other line leaves the state untouched.
|
|
144
157
|
*/
|
|
145
158
|
export const nextFenceState = (line: string, fence: FenceState): FenceState => {
|
|
146
159
|
const trimmed = line.trimStart();
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
// narrows it without a cast.
|
|
150
|
-
if (delimiter !== "```" && delimiter !== "~~~") {
|
|
160
|
+
const run = trimmed.match(CODE_FENCE)?.groups?.run;
|
|
161
|
+
if (run === undefined) {
|
|
151
162
|
return fence;
|
|
152
163
|
}
|
|
164
|
+
const delimiter = run.startsWith("`") ? ("`" as const) : ("~" as const);
|
|
153
165
|
if (fence === null) {
|
|
154
166
|
// A backtick fence's info string cannot itself contain a backtick
|
|
155
167
|
// (CommonMark) — a line-leading ```inline``` span is a paragraph, and
|
|
156
168
|
// opening a phantom fence on it would swallow every heading and link
|
|
157
169
|
// after it. Tilde fences carry no such rule.
|
|
158
|
-
if (delimiter === "
|
|
159
|
-
|
|
160
|
-
if (trimmed.slice(run).includes("`")) {
|
|
161
|
-
return fence;
|
|
162
|
-
}
|
|
170
|
+
if (delimiter === "`" && trimmed.slice(run.length).includes("`")) {
|
|
171
|
+
return fence;
|
|
163
172
|
}
|
|
164
|
-
return delimiter;
|
|
173
|
+
return { delimiter, length: run.length };
|
|
165
174
|
}
|
|
166
|
-
return fence === delimiter
|
|
175
|
+
return fence.delimiter === delimiter && run.length >= fence.length
|
|
176
|
+
? null
|
|
177
|
+
: fence;
|
|
167
178
|
};
|
|
168
179
|
// A closing hash sequence must be preceded by whitespace (CommonMark), so a
|
|
169
180
|
// heading like `## What is C#` keeps its trailing `#`. Up to 3 leading spaces
|
|
@@ -198,35 +209,81 @@ const PROMPT_OPEN = /^<Prompt(?![\w-])/u;
|
|
|
198
209
|
const PROMPT_CLOSE = /<\/Prompt>/u;
|
|
199
210
|
|
|
200
211
|
/**
|
|
201
|
-
* The body lines, minus a leading front matter block
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
212
|
+
* The body lines, minus a leading front matter block, plus the height of the
|
|
213
|
+
* block that was dropped (`offset`) so line numbers can be reported against
|
|
214
|
+
* the whole body. Bodies from the normalize pipeline are already
|
|
215
|
+
* frontmatter-stripped, but `scanBody` also runs on raw documents — where a
|
|
216
|
+
* leading `---` block (closed by `---` or `...`) is front matter, not a
|
|
217
|
+
* thematic break whose closing `---` would underline the last metadata line
|
|
218
|
+
* into a phantom setext heading.
|
|
206
219
|
*/
|
|
207
|
-
const linesWithoutFrontMatter = (body: string)
|
|
220
|
+
const linesWithoutFrontMatter = (body: string) => {
|
|
208
221
|
const lines = body.split("\n");
|
|
209
222
|
if (!/^-{3}\s*$/u.test(lines[0] ?? "")) {
|
|
210
|
-
return lines;
|
|
223
|
+
return { lines, offset: 0 };
|
|
211
224
|
}
|
|
212
225
|
// A blank line directly after the dashes means the body *opens* with a
|
|
213
226
|
// thematic break, not front matter — YAML metadata starts on the very next
|
|
214
227
|
// line. Treating it as an unclosed block ate everything up to the next
|
|
215
228
|
// `---`/`...` line of an already-stripped body.
|
|
216
229
|
if ((lines[1] ?? "").trim() === "") {
|
|
217
|
-
return lines;
|
|
230
|
+
return { lines, offset: 0 };
|
|
218
231
|
}
|
|
219
232
|
const close = lines.findIndex(
|
|
220
233
|
(line, index) => index > 0 && FRONT_MATTER_CLOSE.test(line)
|
|
221
234
|
);
|
|
222
|
-
return close === -1
|
|
235
|
+
return close === -1
|
|
236
|
+
? { lines, offset: 0 }
|
|
237
|
+
: { lines: lines.slice(close + 1), offset: close + 1 };
|
|
223
238
|
};
|
|
224
239
|
|
|
240
|
+
// A trailing `{#id}` heading marker written without a backslash escape. Both
|
|
241
|
+
// pipelines resolve escapes before parsing markers (see `ESCAPED_PUNCTUATION`),
|
|
242
|
+
// so `\{#id\}` is the same marker — and the only spelling that survives the
|
|
243
|
+
// MDX parser, where a bare `{…}` is a JSX expression and `#id` is not a valid
|
|
244
|
+
// one (`Could not parse expression with acorn`). Further bracket markers may
|
|
245
|
+
// follow the brace (`{#id} [toc]`), nothing else.
|
|
246
|
+
const BARE_CURLY_MARKER =
|
|
247
|
+
/(?<!\\)\{#(?<id>[^\s}]+)\}(?:\s*\[(?:#[^\s\]]+|!?toc)\])*\s*$/u;
|
|
248
|
+
|
|
249
|
+
// A raw HTML element carrying an `id` — `<a id="…">`, `<section id='…'>`,
|
|
250
|
+
// the unquoted `<a id=plain>`, or the JSX spelling `<div id={"…"}>` — whose
|
|
251
|
+
// element is a fragment-link target outside headings. Lowercase tags only: a
|
|
252
|
+
// capitalized `<YouTube id="…">` is a component prop, not a DOM id (the
|
|
253
|
+
// `JSX_OPEN` split below). `[^<>]` bounds the attribute run, so a wrapped tag
|
|
254
|
+
// (`<div\n id="x"\n>`) still matches while a long tag-free line can't
|
|
255
|
+
// backtrack quadratically, and the run never reaches into a neighboring tag.
|
|
256
|
+
const HTML_ID =
|
|
257
|
+
/<[a-z][a-z0-9-]*(?:\s[^<>]*?)?\sid=(?:(?<quote>["'])(?<quoted>[^"'<>]+)\k<quote>|\{(?<jsxQuote>["'])(?<jsx>[^"'<>]+)\k<jsxQuote>\}|(?<bare>[^\s"'=<>`{}]+))/gu;
|
|
258
|
+
// Commented-out markup renders nothing; matched across lines once the
|
|
259
|
+
// scannable lines are joined back together. Shared with the search
|
|
260
|
+
// extractor so both agree on what a comment is.
|
|
261
|
+
export const HTML_COMMENT = /<!--[\s\S]*?-->/gu;
|
|
262
|
+
export const INLINE_CODE = /`[^`]*`/gu;
|
|
263
|
+
|
|
264
|
+
/** A heading whose trailing `{#id}` marker is unescaped — see `BARE_CURLY_MARKER`. */
|
|
265
|
+
export interface CurlyMarker {
|
|
266
|
+
id: string;
|
|
267
|
+
/** 1-based line of the heading (a setext heading's first text line) in the body. */
|
|
268
|
+
line: number;
|
|
269
|
+
}
|
|
270
|
+
|
|
225
271
|
/** Scanner state: the open fence plus the paragraph lines accumulated so far. */
|
|
226
272
|
interface HeadingScanState {
|
|
273
|
+
/**
|
|
274
|
+
* Lines eligible to hold an HTML `id` — outside fences and `<Prompt>`
|
|
275
|
+
* regions, inline code masked — collected for one `HTML_ID` pass at the
|
|
276
|
+
* end so a tag wrapped over several lines still matches.
|
|
277
|
+
*/
|
|
278
|
+
anchorLines: string[];
|
|
279
|
+
curlyMarkers: CurlyMarker[];
|
|
227
280
|
fence: FenceState;
|
|
281
|
+
/** 1-based body line of the line being scanned. */
|
|
282
|
+
line: number;
|
|
228
283
|
/** Consecutive paragraph lines — the candidate text for a setext underline. */
|
|
229
284
|
paragraph: string[];
|
|
285
|
+
/** Body line of the first line in `paragraph`. */
|
|
286
|
+
paragraphStart: number;
|
|
230
287
|
/** Nesting depth inside `<Prompt>...</Prompt>` — 0 when outside one. */
|
|
231
288
|
promptDepth: number;
|
|
232
289
|
/** True inside a multi-line `<Prompt` opening tag, awaiting its `>`. */
|
|
@@ -275,12 +332,155 @@ const finishPromptTag = (
|
|
|
275
332
|
* collapses `--`; github-slugger keeps it) and resolves repeated headings the
|
|
276
333
|
* same way (`setup`, `setup-1`).
|
|
277
334
|
*/
|
|
335
|
+
// CommonMark's escapable ASCII punctuation. The renderer only ever sees
|
|
336
|
+
// heading text *after* the Markdown parser has resolved backslash escapes, so
|
|
337
|
+
// `\[toc]` reaches the hast as plain `[toc]` and the marker still applies;
|
|
338
|
+
// resolving escapes here keeps the two pipelines identical (there is no
|
|
339
|
+
// inline way to write a literal trailing marker — use inline code instead).
|
|
340
|
+
const ESCAPED_PUNCTUATION = /\\(?<char>[!-/:-@[-`{-~])/gu;
|
|
341
|
+
|
|
342
|
+
// The start of a link-reference definition, as the renderer accepts it:
|
|
343
|
+
// `[label]:` after up to 3 leading spaces, optionally inside block-quote or
|
|
344
|
+
// list-item containers (`> [label]: /url`, `- [label]: /url`) — a definition
|
|
345
|
+
// nested in either still defines the label document-wide. `rest` is whatever
|
|
346
|
+
// follows the colon on the same line.
|
|
347
|
+
//
|
|
348
|
+
// Every container iteration begins with its marker and ends with the
|
|
349
|
+
// whitespace that follows it, so a whitespace run belongs to exactly one
|
|
350
|
+
// iteration. Letting an iteration start with optional whitespace as well
|
|
351
|
+
// gave each run two homes and backtracked exponentially on `>\t>\t>…` and
|
|
352
|
+
// `*\t\t*\t\t*…` lines (CodeQL js/redos).
|
|
353
|
+
const REF_DEFINITION =
|
|
354
|
+
/^(?: {0,3}|[ \t]*(?:(?:>|(?:[-*+]|\d{1,9}[.)])[ \t])[ \t]*)+)\[(?<label>[^\]]+)\]:[ \t]*(?<rest>.*)$/u;
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The normalized labels of every link-reference definition in the body
|
|
358
|
+
* (outside fenced code). A trailing heading bracket whose label is defined is
|
|
359
|
+
* a CommonMark shortcut link, not a marker — the renderer leaves it in the
|
|
360
|
+
* heading as an `<a>`, so the marker parse must skip it too. Labels match
|
|
361
|
+
* case-insensitively with collapsed internal whitespace (CommonMark).
|
|
362
|
+
*
|
|
363
|
+
* Only a *valid* definition defines a label: the label must contain a
|
|
364
|
+
* non-whitespace character, and a destination must follow — on the same line
|
|
365
|
+
* or, as CommonMark allows, alone on the next. A bare `[toc]:` with nothing
|
|
366
|
+
* after it is paragraph text, and the renderer still treats the heading's
|
|
367
|
+
* `[toc]` as literal — which Blume reads as a marker — so it must not be
|
|
368
|
+
* recorded here.
|
|
369
|
+
*/
|
|
370
|
+
const refDefinitionLabels = (lines: readonly string[]): Set<string> => {
|
|
371
|
+
const labels = new Set<string>();
|
|
372
|
+
let fence: FenceState = null;
|
|
373
|
+
for (const [index, line] of lines.entries()) {
|
|
374
|
+
const next = nextFenceState(line, fence);
|
|
375
|
+
if (fence !== null || next !== null) {
|
|
376
|
+
fence = next;
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
const groups = line.match(REF_DEFINITION)?.groups;
|
|
380
|
+
if (groups?.label === undefined || groups.label.trim() === "") {
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
const destination = groups.rest ?? "";
|
|
384
|
+
if (destination === "" && (lines[index + 1] ?? "").trim() === "") {
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
labels.add(groups.label.trim().replaceAll(/\s+/gu, " ").toLowerCase());
|
|
388
|
+
}
|
|
389
|
+
return labels;
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* A heading record from raw heading text: escapes resolved and trailing
|
|
394
|
+
* markers stripped, exactly as the renderer sees them. A `[#custom-id]` pin
|
|
395
|
+
* becomes the slug verbatim and — matching the renderer — occupies its id in
|
|
396
|
+
* the slugger, so a later heading whose auto-slug collides disambiguates
|
|
397
|
+
* (`setup` → `setup-1`). `[!toc]`/`[toc]` headings stay in the record: their
|
|
398
|
+
* ids exist in the rendered page, so links to them are valid anchors
|
|
399
|
+
* regardless of TOC visibility. A heading that is nothing but markers keeps
|
|
400
|
+
* them as literal text, mirroring the renderer.
|
|
401
|
+
*/
|
|
402
|
+
const toHeading = (
|
|
403
|
+
depth: number,
|
|
404
|
+
raw: string,
|
|
405
|
+
slugger: GithubSlugger,
|
|
406
|
+
isRefDefined: (label: string) => boolean
|
|
407
|
+
): Heading => {
|
|
408
|
+
const unescaped = raw.replaceAll(ESCAPED_PUNCTUATION, "$<char>");
|
|
409
|
+
const markers = parseHeadingMarkers(unescaped, isRefDefined);
|
|
410
|
+
const text = markers.text.trim();
|
|
411
|
+
if (text === "" && (markers.id !== undefined || markers.toc !== undefined)) {
|
|
412
|
+
return { depth, slug: slugger.slug(unescaped), text: unescaped };
|
|
413
|
+
}
|
|
414
|
+
if (markers.id !== undefined) {
|
|
415
|
+
occupySlug(slugger, markers.id);
|
|
416
|
+
return { depth, slug: markers.id, text };
|
|
417
|
+
}
|
|
418
|
+
return { depth, slug: slugger.slug(text), text };
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
/** Record a heading's unescaped trailing `{#id}` so `.mdx` pages can be warned. */
|
|
422
|
+
const noteCurlyMarker = (
|
|
423
|
+
text: string,
|
|
424
|
+
line: number,
|
|
425
|
+
state: HeadingScanState
|
|
426
|
+
): void => {
|
|
427
|
+
const id = text.match(BARE_CURLY_MARKER)?.groups?.id;
|
|
428
|
+
if (id !== undefined) {
|
|
429
|
+
state.curlyMarkers.push({ id, line });
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
/** Scan a line outside fences and prompts: an anchor host, a heading, or prose. */
|
|
434
|
+
const scanContentLine = (
|
|
435
|
+
line: string,
|
|
436
|
+
state: HeadingScanState,
|
|
437
|
+
slugger: GithubSlugger,
|
|
438
|
+
headings: Heading[],
|
|
439
|
+
isRefDefined: (label: string) => boolean
|
|
440
|
+
): void => {
|
|
441
|
+
// Inline code is masked so a documented `<a id="…">` isn't an anchor.
|
|
442
|
+
state.anchorLines.push(line.replaceAll(INLINE_CODE, ""));
|
|
443
|
+
const atx = line.match(ATX_HEADING);
|
|
444
|
+
if (atx?.groups) {
|
|
445
|
+
const depth = atx.groups.hashes?.length ?? 1;
|
|
446
|
+
const text = (atx.groups.text ?? "").trim();
|
|
447
|
+
headings.push(toHeading(depth, text, slugger, isRefDefined));
|
|
448
|
+
noteCurlyMarker(text, state.line, state);
|
|
449
|
+
state.paragraph = [];
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
const setext = line.match(SETEXT_UNDERLINE);
|
|
453
|
+
if (setext?.groups && state.paragraph.length > 0) {
|
|
454
|
+
// Setext wins over thematic break when it closes a paragraph (CommonMark);
|
|
455
|
+
// a multi-line paragraph renders as one heading, soft breaks as spaces.
|
|
456
|
+
const depth = setext.groups.marker?.startsWith("=") ? 1 : 2;
|
|
457
|
+
const text = state.paragraph.join(" ").trim();
|
|
458
|
+
headings.push(toHeading(depth, text, slugger, isRefDefined));
|
|
459
|
+
noteCurlyMarker(text, state.paragraphStart, state);
|
|
460
|
+
state.paragraph = [];
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
if (
|
|
464
|
+
line.trim() === "" ||
|
|
465
|
+
THEMATIC_BREAK.test(line) ||
|
|
466
|
+
PARAGRAPH_INTERRUPT.test(line)
|
|
467
|
+
) {
|
|
468
|
+
state.paragraph = [];
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
if (state.paragraph.length === 0) {
|
|
472
|
+
state.paragraphStart = state.line;
|
|
473
|
+
}
|
|
474
|
+
state.paragraph.push(line.trim());
|
|
475
|
+
};
|
|
476
|
+
|
|
278
477
|
/** Scan one line for a heading, advancing the fence/paragraph state. */
|
|
279
478
|
const scanHeadingLine = (
|
|
280
479
|
line: string,
|
|
281
480
|
state: HeadingScanState,
|
|
282
481
|
slugger: GithubSlugger,
|
|
283
|
-
headings: Heading[]
|
|
482
|
+
headings: Heading[],
|
|
483
|
+
isRefDefined: (label: string) => boolean
|
|
284
484
|
): void => {
|
|
285
485
|
const next = nextFenceState(line, state.fence);
|
|
286
486
|
// Skip fence delimiter lines themselves and anything inside a fence. A fence
|
|
@@ -312,55 +512,68 @@ const scanHeadingLine = (
|
|
|
312
512
|
state.paragraph = [];
|
|
313
513
|
return;
|
|
314
514
|
}
|
|
315
|
-
|
|
316
|
-
if (atx?.groups) {
|
|
317
|
-
const depth = atx.groups.hashes?.length ?? 1;
|
|
318
|
-
const text = (atx.groups.text ?? "").trim();
|
|
319
|
-
headings.push({ depth, slug: slugger.slug(text), text });
|
|
320
|
-
state.paragraph = [];
|
|
321
|
-
return;
|
|
322
|
-
}
|
|
323
|
-
const setext = line.match(SETEXT_UNDERLINE);
|
|
324
|
-
if (setext?.groups && state.paragraph.length > 0) {
|
|
325
|
-
// Setext wins over thematic break when it closes a paragraph (CommonMark);
|
|
326
|
-
// a multi-line paragraph renders as one heading, soft breaks as spaces.
|
|
327
|
-
const text = state.paragraph.join(" ").trim();
|
|
328
|
-
headings.push({
|
|
329
|
-
depth: setext.groups.marker?.startsWith("=") ? 1 : 2,
|
|
330
|
-
slug: slugger.slug(text),
|
|
331
|
-
text,
|
|
332
|
-
});
|
|
333
|
-
state.paragraph = [];
|
|
334
|
-
return;
|
|
335
|
-
}
|
|
336
|
-
if (
|
|
337
|
-
line.trim() === "" ||
|
|
338
|
-
THEMATIC_BREAK.test(line) ||
|
|
339
|
-
PARAGRAPH_INTERRUPT.test(line)
|
|
340
|
-
) {
|
|
341
|
-
state.paragraph = [];
|
|
342
|
-
return;
|
|
343
|
-
}
|
|
344
|
-
state.paragraph.push(line.trim());
|
|
515
|
+
scanContentLine(line, state, slugger, headings, isRefDefined);
|
|
345
516
|
};
|
|
346
517
|
|
|
347
|
-
|
|
518
|
+
/** Everything one walk over a body yields for the anchor index. */
|
|
519
|
+
export interface BodyScan {
|
|
520
|
+
/**
|
|
521
|
+
* Raw HTML element ids (`<a id="…">`) outside headings, fences, inline
|
|
522
|
+
* code, comments, and `<Prompt>` regions — fragment-link targets that
|
|
523
|
+
* `blume validate` accepts alongside heading slugs. Deduplicated, in
|
|
524
|
+
* document order.
|
|
525
|
+
*/
|
|
526
|
+
anchors: string[];
|
|
527
|
+
/** Headings whose trailing `{#id}` marker is unescaped, for `.mdx` pages. */
|
|
528
|
+
curlyMarkers: CurlyMarker[];
|
|
529
|
+
headings: Heading[];
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Scan a body for its headings, explicit HTML anchors, and unescaped `{#id}`
|
|
534
|
+
* markers in one fence-aware walk (the same walk `extractHeadings` exposes for
|
|
535
|
+
* headings alone).
|
|
536
|
+
*/
|
|
537
|
+
export const scanBody = (body: string): BodyScan => {
|
|
348
538
|
const headings: Heading[] = [];
|
|
349
539
|
const slugger = new GithubSlugger();
|
|
350
540
|
const state: HeadingScanState = {
|
|
541
|
+
anchorLines: [],
|
|
542
|
+
curlyMarkers: [],
|
|
351
543
|
fence: null,
|
|
544
|
+
line: 0,
|
|
352
545
|
paragraph: [],
|
|
546
|
+
paragraphStart: 0,
|
|
353
547
|
promptDepth: 0,
|
|
354
548
|
promptTag: false,
|
|
355
549
|
};
|
|
356
550
|
|
|
357
|
-
|
|
358
|
-
|
|
551
|
+
const { lines, offset } = linesWithoutFrontMatter(body);
|
|
552
|
+
const definedLabels = refDefinitionLabels(lines);
|
|
553
|
+
const isRefDefined = (label: string): boolean =>
|
|
554
|
+
definedLabels.has(label.toLowerCase());
|
|
555
|
+
for (const [index, line] of lines.entries()) {
|
|
556
|
+
state.line = index + offset + 1;
|
|
557
|
+
scanHeadingLine(line, state, slugger, headings, isRefDefined);
|
|
359
558
|
}
|
|
360
559
|
|
|
361
|
-
|
|
560
|
+
const anchors = new Set<string>();
|
|
561
|
+
// Replaced with a space rather than removed: dropping a comment outright
|
|
562
|
+
// can splice its neighbors into a new `<!--` (`<!-<!-- x -->->`), and a
|
|
563
|
+
// space keeps a tag from fusing with an `id=` that only a comment separated.
|
|
564
|
+
const markup = state.anchorLines.join("\n").replaceAll(HTML_COMMENT, " ");
|
|
565
|
+
for (const match of markup.matchAll(HTML_ID)) {
|
|
566
|
+
const id = match.groups?.quoted ?? match.groups?.jsx ?? match.groups?.bare;
|
|
567
|
+
if (id !== undefined) {
|
|
568
|
+
anchors.add(id);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return { anchors: [...anchors], curlyMarkers: state.curlyMarkers, headings };
|
|
362
572
|
};
|
|
363
573
|
|
|
574
|
+
export const extractHeadings = (body: string): Heading[] =>
|
|
575
|
+
scanBody(body).headings;
|
|
576
|
+
|
|
364
577
|
// The label admits one level of nested brackets so an image-wrapped link
|
|
365
578
|
// (`[](/target)`) matches as the *outer* link — with a flat
|
|
366
579
|
// `[^\]]*` label the match stopped at the image's `]` and the outer target was
|
|
@@ -372,7 +585,6 @@ const MD_LINK =
|
|
|
372
585
|
// link of its own before labels admitted nesting, and still should be.
|
|
373
586
|
export const MD_IMAGE =
|
|
374
587
|
/!\[[^\]]*\]\((?<target>(?:[^()\s]|\([^()\s]*\))+)(?<title>\s+"[^"]*")?\)/gu;
|
|
375
|
-
export const INLINE_CODE = /`[^`]*`/gu;
|
|
376
588
|
|
|
377
589
|
/** Column (0-based, within `matched`) where a link/image match's target starts. */
|
|
378
590
|
export const targetOffsetIn = (
|
|
@@ -510,9 +722,90 @@ export const extractComponentTags = (body: string): string[] => {
|
|
|
510
722
|
* extracted from the stripped body, but diagnostics point into the raw
|
|
511
723
|
* document — recorded lines must shift by this offset to match it.
|
|
512
724
|
*/
|
|
513
|
-
const strippedLineOffset = (
|
|
725
|
+
export const strippedLineOffset = (
|
|
726
|
+
raw: string | undefined,
|
|
727
|
+
body: string
|
|
728
|
+
): number =>
|
|
514
729
|
raw ? Math.max(0, raw.split("\n").length - body.split("\n").length) : 0;
|
|
515
730
|
|
|
731
|
+
/**
|
|
732
|
+
* Map links extracted from include-expanded text back to the file and raw
|
|
733
|
+
* line each expanded line came from, so a broken link inside a partial is
|
|
734
|
+
* reported against the partial. Links whose origin is the page's own source
|
|
735
|
+
* carry no `file` override (origins already hold raw-file lines).
|
|
736
|
+
*/
|
|
737
|
+
const remapExpandedLinks = (
|
|
738
|
+
links: PageLink[],
|
|
739
|
+
origins: { file: string; line: number }[],
|
|
740
|
+
sourcePath: string | undefined
|
|
741
|
+
): PageLink[] =>
|
|
742
|
+
links.map((link) => {
|
|
743
|
+
const origin = origins[link.line - 1];
|
|
744
|
+
if (!origin) {
|
|
745
|
+
return link;
|
|
746
|
+
}
|
|
747
|
+
const remapped: PageLink = { ...link, line: origin.line };
|
|
748
|
+
if (origin.file !== sourcePath) {
|
|
749
|
+
remapped.file = origin.file;
|
|
750
|
+
}
|
|
751
|
+
return remapped;
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
/** The entry's transitively included files, when the scan expanded any. */
|
|
755
|
+
const entryIncludes = (entry: SourceEntry): string[] | undefined =>
|
|
756
|
+
entry.expanded && entry.expanded.includes.length > 0
|
|
757
|
+
? entry.expanded.includes
|
|
758
|
+
: undefined;
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Extract an entry's links for validation. When the scan expanded includes,
|
|
762
|
+
* extraction runs over the expanded text (origins already hold raw-file
|
|
763
|
+
* lines); otherwise over the stripped body, shifted by the stripped front
|
|
764
|
+
* matter block's height.
|
|
765
|
+
*/
|
|
766
|
+
const entryLinks = (entry: SourceEntry): PageLink[] =>
|
|
767
|
+
entry.expanded
|
|
768
|
+
? remapExpandedLinks(
|
|
769
|
+
extractLinks(entry.expanded.text),
|
|
770
|
+
entry.expanded.origins,
|
|
771
|
+
entry.sourcePath
|
|
772
|
+
)
|
|
773
|
+
: extractLinks(
|
|
774
|
+
entry.body.text,
|
|
775
|
+
strippedLineOffset(entry.raw, entry.body.text)
|
|
776
|
+
);
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Diagnostics for `{#id}` heading markers in an `.mdx` page. The MDX parser
|
|
780
|
+
* reads a bare `{…}` as a JSX expression, so the page fails to compile —
|
|
781
|
+
* reported here, at the marker's source line, instead of as a raw acorn error
|
|
782
|
+
* at render time. An included `.md` partial is spliced into the including
|
|
783
|
+
* page and parsed in *its* format (see `markdown/include.ts`), so a partial's
|
|
784
|
+
* markers count too and are reported against the partial via the expansion's
|
|
785
|
+
* origins. `.md` pages render the marker as text and get no diagnostic.
|
|
786
|
+
*/
|
|
787
|
+
const curlyMarkerDiagnostics = (
|
|
788
|
+
entry: SourceEntry,
|
|
789
|
+
markers: CurlyMarker[],
|
|
790
|
+
sourceName: string
|
|
791
|
+
): Diagnostic[] =>
|
|
792
|
+
markers.map(({ id, line }) => {
|
|
793
|
+
const origin = entry.expanded?.origins[line - 1];
|
|
794
|
+
const page = entry.sourcePath ?? `${sourceName}:${entry.ref}`;
|
|
795
|
+
const inPartial = origin !== undefined && origin.file !== entry.sourcePath;
|
|
796
|
+
return {
|
|
797
|
+
code: "BLUME_MDX_CURLY_ANCHOR",
|
|
798
|
+
file: origin?.file ?? page,
|
|
799
|
+
line:
|
|
800
|
+
origin?.line ?? line + strippedLineOffset(entry.raw, entry.body.text),
|
|
801
|
+
message: inPartial
|
|
802
|
+
? `\`{#${id}}\` is a JSX expression once this partial is included in ${page} (.mdx), so that page fails to compile.`
|
|
803
|
+
: `\`{#${id}}\` is a JSX expression in .mdx, so this page fails to compile.`,
|
|
804
|
+
severity: "error",
|
|
805
|
+
suggestion: `Write \`[#${id}]\` or escape it as \`\\{#${id}\\}\` — both pin the same anchor in .md and .mdx.`,
|
|
806
|
+
};
|
|
807
|
+
});
|
|
808
|
+
|
|
516
809
|
const deriveTitle = (
|
|
517
810
|
meta: PageMeta,
|
|
518
811
|
headings: Heading[],
|
|
@@ -530,18 +823,126 @@ const deriveTitle = (
|
|
|
530
823
|
};
|
|
531
824
|
|
|
532
825
|
/** Strip habitual leading/trailing slashes (`/getting-started`, `guides/`). */
|
|
533
|
-
const trimSlashes = (value: string): string =>
|
|
534
|
-
value.replaceAll(/^\/+|\/+$/gu, "");
|
|
826
|
+
const trimSlashes = (value: string): string => trimChar(value, "/");
|
|
535
827
|
|
|
536
828
|
/** Whether a raw frontmatter value is a string (e.g. the `type` override). */
|
|
537
|
-
const isStringValue = (
|
|
538
|
-
|
|
829
|
+
export const isStringValue = (
|
|
830
|
+
value: SourceEntry["data"][string]
|
|
831
|
+
): value is string => typeof value === "string";
|
|
539
832
|
|
|
833
|
+
/** Mount a source-relative path under the source's route prefix. */
|
|
540
834
|
const withPrefix = (prefix: string | undefined, path: string): string => {
|
|
541
835
|
const clean = prefix ? trimSlashes(prefix) : "";
|
|
542
836
|
return clean ? `${clean}/${path}` : path;
|
|
543
837
|
};
|
|
544
838
|
|
|
839
|
+
/** What a route resolution needs from the owning source and the config. */
|
|
840
|
+
export type RouteContext = Pick<NormalizeContext, "i18n" | "versions"> & {
|
|
841
|
+
/** The source's route prefix (`NormalizeContext["source"]["prefix"]`). */
|
|
842
|
+
prefix?: string;
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
/** Where an entry's ref places it once its directories are read off. */
|
|
846
|
+
export interface EntryPlacement {
|
|
847
|
+
/**
|
|
848
|
+
* The locale codes the entry publishes in: `[""]` without i18n, one code for
|
|
849
|
+
* a placed file, every configured code for a shared `$` file.
|
|
850
|
+
*/
|
|
851
|
+
locales: string[];
|
|
852
|
+
/** The ref with its version and locale directories stripped, prefix-less. */
|
|
853
|
+
navPath: string;
|
|
854
|
+
/** The version snapshot the entry belongs to (`""` for current). */
|
|
855
|
+
version: string;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Read the version and locale directories off an entry's ref. The version is
|
|
860
|
+
* detected first: a snapshot directory is outermost on disk
|
|
861
|
+
* (`v1.0/fr/page.mdx`), so the locale parser must see a version-stripped ref.
|
|
862
|
+
* The current version is `""` and lives at the root. Locale placement comes
|
|
863
|
+
* from the ref (a leading dir, or a filename suffix under the `dot` parser),
|
|
864
|
+
* never the slug — the slug is the logical, locale-agnostic path within a
|
|
865
|
+
* locale. A shared `$` file maps to every locale; a source without i18n
|
|
866
|
+
* placement maps to one.
|
|
867
|
+
*/
|
|
868
|
+
export const placeEntryRef = (
|
|
869
|
+
ref: string,
|
|
870
|
+
ext: string,
|
|
871
|
+
ctx: Pick<RouteContext, "i18n" | "versions">
|
|
872
|
+
): EntryPlacement => {
|
|
873
|
+
const { version, rest } = ctx.versions
|
|
874
|
+
? detectVersionRef(ref, ctx.versions)
|
|
875
|
+
: { rest: ref, version: "" };
|
|
876
|
+
const { navPath, locales } = ctx.i18n
|
|
877
|
+
? localePlacement(rest, ext, ctx.i18n)
|
|
878
|
+
: { locales: [""], navPath: rest };
|
|
879
|
+
return { locales, navPath, version };
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
/** Everything `normalizeEntry` derives from an entry's ref and slug. */
|
|
883
|
+
export interface EntryRoute extends Pick<
|
|
884
|
+
EntryPlacement,
|
|
885
|
+
"locales" | "version"
|
|
886
|
+
> {
|
|
887
|
+
groups: string[];
|
|
888
|
+
/**
|
|
889
|
+
* The version-prefixed, locale-agnostic route — the translation key. Pass it
|
|
890
|
+
* through {@link localizedRoute} for the route one locale publishes at.
|
|
891
|
+
*/
|
|
892
|
+
logicalRoute: string;
|
|
893
|
+
/** The prefixed, locale- and version-stripped nav path. */
|
|
894
|
+
navPath: string;
|
|
895
|
+
segments: string[];
|
|
896
|
+
/** The version-agnostic mapped route. */
|
|
897
|
+
versionKey: string;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* The canonical route resolution, shared by {@link normalizeEntry} and any
|
|
902
|
+
* source that must predict the route an entry will publish at — the Obsidian
|
|
903
|
+
* source turns `[[Note]]` into a real href, and a second derivation of a route
|
|
904
|
+
* is a second answer.
|
|
905
|
+
*
|
|
906
|
+
* A frontmatter `slug` wins, then the adapter-supplied `entry.slug` (the typed
|
|
907
|
+
* SPI's "logical route input; defaults to ref if omitted"), then the ref. The
|
|
908
|
+
* extension is re-appended so `mapRoute`'s extname strip can't eat a dotted
|
|
909
|
+
* slug segment (`v1.2`). A slug that trims to nothing falls back. The version
|
|
910
|
+
* prefixes the mapped route *after* `mapRoute` runs: the mapped route is the
|
|
911
|
+
* version-agnostic key, the config id is prepended verbatim (never
|
|
912
|
+
* numeric-prefix-stripped), a frontmatter `slug` gets versionized so snapshots
|
|
913
|
+
* can't collide with the live page, and `translationKey` becomes
|
|
914
|
+
* version-specific for free. `basePath` is not applied here — it is outermost,
|
|
915
|
+
* after locale prefixing — so the result reads `{locale?}/{prefix?}/…`.
|
|
916
|
+
*/
|
|
917
|
+
export const resolveEntryRoute = (
|
|
918
|
+
entry: Pick<SourceEntry, "ref" | "slug">,
|
|
919
|
+
ext: string,
|
|
920
|
+
frontmatterSlug: string | undefined,
|
|
921
|
+
ctx: RouteContext
|
|
922
|
+
): EntryRoute => {
|
|
923
|
+
const { locales, navPath, version } = placeEntryRef(entry.ref, ext, ctx);
|
|
924
|
+
const slugInput = frontmatterSlug ?? entry.slug;
|
|
925
|
+
const slug = slugInput ? trimSlashes(slugInput) : "";
|
|
926
|
+
const routeInput = withPrefix(ctx.prefix, slug ? `${slug}${ext}` : navPath);
|
|
927
|
+
const { segments, groups, route: versionKey } = mapRoute(routeInput);
|
|
928
|
+
return {
|
|
929
|
+
groups,
|
|
930
|
+
locales,
|
|
931
|
+
logicalRoute: versionizeRoute(versionKey, version),
|
|
932
|
+
navPath: withPrefix(ctx.prefix, navPath),
|
|
933
|
+
segments,
|
|
934
|
+
version,
|
|
935
|
+
versionKey,
|
|
936
|
+
};
|
|
937
|
+
};
|
|
938
|
+
|
|
939
|
+
/** The route a logical route publishes at in one locale, base path excluded. */
|
|
940
|
+
export const localizedRoute = (
|
|
941
|
+
logicalRoute: string,
|
|
942
|
+
locale: string,
|
|
943
|
+
i18n: ResolvedI18nConfig | undefined
|
|
944
|
+
): string => (i18n ? localizeRoute(logicalRoute, locale, i18n) : logicalRoute);
|
|
945
|
+
|
|
545
946
|
/** A custom-key validation failure, lowered to a joinable diagnostic path. */
|
|
546
947
|
interface CustomKeyIssue {
|
|
547
948
|
message: string;
|
|
@@ -717,50 +1118,32 @@ export const normalizeEntry = (
|
|
|
717
1118
|
meta.seo.noindex = true;
|
|
718
1119
|
}
|
|
719
1120
|
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
const
|
|
738
|
-
// Frontmatter `slug` wins, then the adapter-supplied `entry.slug` (the typed
|
|
739
|
-
// SPI's "logical route input; defaults to ref if omitted"), then the ref.
|
|
740
|
-
// The extension is re-appended so mapRoute's extname strip can't eat a
|
|
741
|
-
// dotted slug segment (`v1.2`). A slug that trims to nothing falls back.
|
|
742
|
-
const slugInput = meta.slug ?? entry.slug;
|
|
743
|
-
const slug = slugInput ? trimSlashes(slugInput) : "";
|
|
744
|
-
const routeInput = withPrefix(
|
|
745
|
-
ctx.source.prefix,
|
|
746
|
-
slug ? `${slug}${ext}` : rawNavPath
|
|
747
|
-
);
|
|
748
|
-
|
|
749
|
-
// The version prefixes the mapped route *after* `mapRoute` runs: the mapped
|
|
750
|
-
// route is the version-agnostic key, the config id is prepended verbatim
|
|
751
|
-
// (never numeric-prefix-stripped), a frontmatter `slug` gets versionized so
|
|
752
|
-
// snapshots can't collide with the live page, and `translationKey` becomes
|
|
753
|
-
// version-specific for free.
|
|
754
|
-
const { segments, groups, route: versionKey } = mapRoute(routeInput);
|
|
755
|
-
const logicalRoute = versionizeRoute(versionKey, version);
|
|
756
|
-
const headings = extractHeadings(entry.body.text);
|
|
1121
|
+
const {
|
|
1122
|
+
groups,
|
|
1123
|
+
locales,
|
|
1124
|
+
logicalRoute,
|
|
1125
|
+
navPath,
|
|
1126
|
+
segments,
|
|
1127
|
+
version,
|
|
1128
|
+
versionKey,
|
|
1129
|
+
} = resolveEntryRoute(entry, ext, meta.slug, {
|
|
1130
|
+
i18n: ctx.i18n,
|
|
1131
|
+
prefix: ctx.source.prefix,
|
|
1132
|
+
versions: ctx.versions,
|
|
1133
|
+
});
|
|
1134
|
+
// Extraction runs on the include-expanded body when the scan expanded one,
|
|
1135
|
+
// so a partial's headings anchor-index and TOC under every including page
|
|
1136
|
+
// and its components register for the runtime import map.
|
|
1137
|
+
const bodyText = entry.expanded?.text ?? entry.body.text;
|
|
1138
|
+
const { anchors, curlyMarkers, headings } = scanBody(bodyText);
|
|
757
1139
|
const { staged } = ctx.source;
|
|
758
1140
|
|
|
759
1141
|
const base = {
|
|
1142
|
+
anchors,
|
|
760
1143
|
body: staged ? { format, text: entry.raw ?? entry.body.text } : undefined,
|
|
761
1144
|
collection: staged ? "staged" : undefined,
|
|
762
1145
|
componentsUsed:
|
|
763
|
-
format === "mdx" ? extractComponentTags(
|
|
1146
|
+
format === "mdx" ? extractComponentTags(bodyText) : undefined,
|
|
764
1147
|
contentType: meta.type ?? ctx.defaultType,
|
|
765
1148
|
custom: parsed.custom,
|
|
766
1149
|
description: meta.description,
|
|
@@ -770,11 +1153,9 @@ export const normalizeEntry = (
|
|
|
770
1153
|
groups,
|
|
771
1154
|
headings,
|
|
772
1155
|
id: `${ctx.source.name}:${entry.ref}`,
|
|
1156
|
+
includes: entryIncludes(entry),
|
|
773
1157
|
lastModified: meta.lastModified ?? entry.lastModified,
|
|
774
|
-
links:
|
|
775
|
-
entry.body.text,
|
|
776
|
-
strippedLineOffset(entry.raw, entry.body.text)
|
|
777
|
-
),
|
|
1158
|
+
links: entryLinks(entry),
|
|
778
1159
|
meta,
|
|
779
1160
|
navPath,
|
|
780
1161
|
segments,
|
|
@@ -796,9 +1177,15 @@ export const normalizeEntry = (
|
|
|
796
1177
|
locale,
|
|
797
1178
|
route: withBasePath(
|
|
798
1179
|
ctx.basePath ?? "",
|
|
799
|
-
|
|
1180
|
+
localizedRoute(logicalRoute, locale, ctx.i18n)
|
|
800
1181
|
),
|
|
801
1182
|
}));
|
|
802
1183
|
|
|
803
|
-
return {
|
|
1184
|
+
return {
|
|
1185
|
+
diagnostics:
|
|
1186
|
+
format === "mdx"
|
|
1187
|
+
? curlyMarkerDiagnostics(entry, curlyMarkers, ctx.source.name)
|
|
1188
|
+
: [],
|
|
1189
|
+
pages,
|
|
1190
|
+
};
|
|
804
1191
|
};
|