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.
Files changed (194) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/dist/cli/index.js +3639 -1377
  3. package/dist/cli/index.js.map +103 -91
  4. package/dist/types/ai/component-markdown.d.ts +79 -0
  5. package/dist/types/components/layout/nav-utils.d.ts +60 -0
  6. package/dist/types/core/base-path.d.ts +9 -0
  7. package/dist/types/core/config-input.d.ts +206 -4
  8. package/dist/types/core/config.d.ts +6 -4
  9. package/dist/types/core/data.d.ts +23 -1
  10. package/dist/types/core/github.d.ts +35 -0
  11. package/dist/types/core/i18n-ui.d.ts +8 -0
  12. package/dist/types/core/navigation.d.ts +69 -0
  13. package/dist/types/core/schema.d.ts +117 -1
  14. package/dist/types/core/sources/types.d.ts +31 -6
  15. package/dist/types/core/types.d.ts +23 -2
  16. package/dist/types/markdown/features.d.ts +21 -0
  17. package/dist/types/openapi/references.d.ts +21 -1
  18. package/dist/types/seo/jsonld.d.ts +105 -0
  19. package/dist/types/theme/fonts.d.ts +34 -4
  20. package/docs/_snippets/include-demo.mdx +7 -0
  21. package/docs/advanced/api-reference.mdx +3 -3
  22. package/docs/advanced/custom-pages.mdx +1 -1
  23. package/docs/advanced/graphql.mdx +84 -0
  24. package/docs/advanced/meta.ts +8 -1
  25. package/docs/configuration/ai.mdx +21 -3
  26. package/docs/configuration/index.mdx +24 -0
  27. package/docs/configuration/search.mdx +13 -1
  28. package/docs/configuration/seo.mdx +27 -0
  29. package/docs/configuration/theming.mdx +17 -0
  30. package/docs/content/components.mdx +7 -0
  31. package/docs/content/includes.mdx +68 -0
  32. package/docs/content/meta.ts +1 -0
  33. package/docs/content/navigation.mdx +25 -0
  34. package/docs/content/sources.mdx +42 -1
  35. package/docs/content/syntax.mdx +69 -1
  36. package/docs/content/versioning.mdx +15 -9
  37. package/docs/reference/cli.mdx +2 -1
  38. package/package.json +23 -14
  39. package/skills/blume-migrate/SKILL.md +16 -7
  40. package/skills/blume-migrate/references/docusaurus.md +5 -3
  41. package/skills/blume-migrate/references/fumadocs.md +10 -2
  42. package/skills/blume-migrate/references/mintlify.md +3 -2
  43. package/skills/blume-migrate/references/nextra.md +2 -2
  44. package/skills/blume-migrate/references/starlight.md +1 -1
  45. package/src/ai/agent-readability.ts +2 -1
  46. package/src/ai/ask-data.ts +2 -1
  47. package/src/ai/component-markdown.ts +199 -36
  48. package/src/ai/llms.ts +93 -6
  49. package/src/ai/markdown.ts +2 -2
  50. package/src/ai/mcp/discovery.ts +10 -2
  51. package/src/ai/mcp/server.ts +74 -2
  52. package/src/astro/generate.ts +183 -116
  53. package/src/astro/include-hmr.ts +81 -0
  54. package/src/astro/include-refresh.ts +0 -0
  55. package/src/astro/index.ts +3 -5
  56. package/src/astro/templates.ts +125 -76
  57. package/src/cli/commands/build.ts +84 -15
  58. package/src/cli/init/questions.ts +1 -0
  59. package/src/cli/init/scaffold.ts +27 -4
  60. package/src/components/colors.ts +142 -0
  61. package/src/components/content/Badge.astro +5 -12
  62. package/src/components/content/Callout.astro +19 -36
  63. package/src/components/content/Card.astro +15 -21
  64. package/src/components/content/Component.astro +10 -1
  65. package/src/components/content/GithubInfo.astro +28 -9
  66. package/src/components/content/Tabs.astro +27 -5
  67. package/src/components/content/github-info.ts +20 -5
  68. package/src/components/dropdown-dismiss.ts +122 -0
  69. package/src/components/layout/Fonts.astro +15 -8
  70. package/src/components/layout/Header.astro +44 -0
  71. package/src/components/layout/LanguageSwitcher.astro +9 -1
  72. package/src/components/layout/NavSelector.astro +12 -3
  73. package/src/components/layout/NavTree.astro +6 -18
  74. package/src/components/layout/PageActions.astro +29 -8
  75. package/src/components/layout/PageLayout.astro +10 -1
  76. package/src/components/layout/ReferenceLayout.astro +6 -1
  77. package/src/components/layout/RootLayout.astro +46 -15
  78. package/src/components/layout/Search.astro +36 -4
  79. package/src/components/layout/TableOfContents.astro +8 -2
  80. package/src/components/layout/head-scripts.ts +53 -1
  81. package/src/components/openapi/ApiOverview.astro +13 -3
  82. package/src/components/openapi/AsyncApiOperation.astro +7 -14
  83. package/src/components/openapi/GraphqlChip.astro +33 -0
  84. package/src/components/openapi/GraphqlFieldsTable.astro +111 -0
  85. package/src/components/openapi/GraphqlOperation.astro +186 -0
  86. package/src/components/openapi/GraphqlType.astro +154 -0
  87. package/src/components/openapi/MethodBadge.astro +3 -14
  88. package/src/components/openapi/Operation.astro +12 -5
  89. package/src/components/openapi/OperationPanel.astro +43 -0
  90. package/src/components/openapi/RequestPanel.astro +5 -10
  91. package/src/components/openapi/Responses.astro +1 -16
  92. package/src/components/openapi/graphql-helpers.ts +466 -0
  93. package/src/components/openapi/playground-client.ts +15 -0
  94. package/src/components/openapi/sample-panels.ts +45 -0
  95. package/src/components/openapi/snippets.ts +13 -35
  96. package/src/core/base-path.ts +11 -0
  97. package/src/core/config-input.ts +209 -2
  98. package/src/core/config.ts +6 -4
  99. package/src/core/content-assets.ts +15 -4
  100. package/src/core/data.ts +18 -2
  101. package/src/core/diagnostics.ts +8 -0
  102. package/src/core/frontmatter.ts +20 -8
  103. package/src/core/github.ts +71 -0
  104. package/src/core/graph.ts +22 -8
  105. package/src/core/heading-markers.ts +96 -0
  106. package/src/core/i18n-ui.ts +11 -0
  107. package/src/core/includes.ts +632 -0
  108. package/src/core/last-modified.ts +36 -11
  109. package/src/core/links.ts +79 -13
  110. package/src/core/meta.ts +2 -1
  111. package/src/core/nav-diagnostics.ts +11 -2
  112. package/src/core/navigation.ts +27 -6
  113. package/src/core/project-graph.ts +61 -9
  114. package/src/core/schema.ts +226 -35
  115. package/src/core/server-features.ts +5 -9
  116. package/src/core/sources/github-releases.ts +2 -2
  117. package/src/core/sources/normalize.ts +502 -115
  118. package/src/core/sources/notion.ts +43 -8
  119. package/src/core/sources/obsidian.ts +1038 -0
  120. package/src/core/sources/read.ts +36 -1
  121. package/src/core/sources/resolve.ts +34 -1
  122. package/src/core/sources/types.ts +28 -6
  123. package/src/core/sources/watch.ts +12 -8
  124. package/src/core/tsconfig-aliases.ts +48 -35
  125. package/src/core/types.ts +25 -2
  126. package/src/core/ui-packs/ar.ts +1 -0
  127. package/src/core/ui-packs/bg.ts +2 -0
  128. package/src/core/ui-packs/bn.ts +1 -0
  129. package/src/core/ui-packs/ca.ts +2 -0
  130. package/src/core/ui-packs/cs.ts +1 -0
  131. package/src/core/ui-packs/da.ts +1 -0
  132. package/src/core/ui-packs/de.ts +2 -0
  133. package/src/core/ui-packs/el.ts +2 -0
  134. package/src/core/ui-packs/es.ts +2 -0
  135. package/src/core/ui-packs/fa.ts +1 -0
  136. package/src/core/ui-packs/fi.ts +1 -0
  137. package/src/core/ui-packs/fr.ts +2 -0
  138. package/src/core/ui-packs/he.ts +1 -0
  139. package/src/core/ui-packs/hi.ts +1 -0
  140. package/src/core/ui-packs/hr.ts +2 -0
  141. package/src/core/ui-packs/hu.ts +2 -0
  142. package/src/core/ui-packs/id.ts +2 -0
  143. package/src/core/ui-packs/it.ts +1 -0
  144. package/src/core/ui-packs/ja.ts +2 -0
  145. package/src/core/ui-packs/ko.ts +2 -0
  146. package/src/core/ui-packs/nl.ts +2 -0
  147. package/src/core/ui-packs/no.ts +2 -0
  148. package/src/core/ui-packs/pl.ts +2 -0
  149. package/src/core/ui-packs/pt-br.ts +2 -0
  150. package/src/core/ui-packs/pt.ts +2 -0
  151. package/src/core/ui-packs/ro.ts +2 -0
  152. package/src/core/ui-packs/ru.ts +2 -0
  153. package/src/core/ui-packs/sk.ts +1 -0
  154. package/src/core/ui-packs/sr.ts +1 -0
  155. package/src/core/ui-packs/sv.ts +2 -0
  156. package/src/core/ui-packs/th.ts +1 -0
  157. package/src/core/ui-packs/tr.ts +2 -0
  158. package/src/core/ui-packs/uk.ts +2 -0
  159. package/src/core/ui-packs/vi.ts +1 -0
  160. package/src/core/ui-packs/zh-tw.ts +1 -0
  161. package/src/core/ui-packs/zh.ts +1 -0
  162. package/src/core/version-cut.ts +21 -3
  163. package/src/core/yaml.ts +26 -0
  164. package/src/deploy/function-bundle.ts +251 -0
  165. package/src/eval/schema.ts +3 -1
  166. package/src/markdown/code-title.ts +22 -16
  167. package/src/markdown/features.ts +21 -0
  168. package/src/markdown/fence-meta.ts +50 -0
  169. package/src/markdown/heading-anchors.ts +198 -37
  170. package/src/markdown/include.ts +247 -0
  171. package/src/markdown/index.ts +43 -34
  172. package/src/markdown/language-icon.ts +2 -2
  173. package/src/markdown/mdast.ts +7 -3
  174. package/src/markdown/ts2js.ts +264 -0
  175. package/src/openapi/asyncapi.ts +4 -1
  176. package/src/openapi/graphql-build.ts +293 -0
  177. package/src/openapi/graphql.ts +212 -0
  178. package/src/openapi/model.ts +38 -5
  179. package/src/openapi/parse.ts +34 -0
  180. package/src/openapi/proxy.ts +30 -5
  181. package/src/openapi/references.ts +89 -13
  182. package/src/openapi/render-mdx.ts +48 -8
  183. package/src/openapi/scalar.ts +5 -12
  184. package/src/openapi/source.ts +91 -23
  185. package/src/registry/eject.ts +11 -0
  186. package/src/search/documents.ts +229 -37
  187. package/src/search/orama-index.ts +9 -5
  188. package/src/seo/jsonld.ts +293 -51
  189. package/src/theme/code-block-padding.ts +16 -0
  190. package/src/theme/entry.ts +65 -11
  191. package/src/theme/fonts.ts +189 -16
  192. package/src/translate/prompts.ts +2 -0
  193. package/src/translate/run.ts +7 -0
  194. 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 { FrontmatterExtend, PageMeta } from "../schema.ts";
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
- /** Convert a content-root-relative path into URL + nav metadata. */
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. The scanners track which
133
- // delimiter opened the current fence (`null` when outside one) so a ``` line
134
- // inside a ~~~ block is content, not a toggle see `nextFenceState`.
135
- const CODE_FENCE = /^(?<delimiter>```|~~~)/u;
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 opening the current code block, or null outside one. */
138
- export type FenceState = "```" | "~~~" | null;
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 the matching delimiter closes it, and any other line leaves
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 delimiter = trimmed.match(CODE_FENCE)?.groups?.delimiter;
148
- // The `delimiter` group matches exactly ``` or ~~~; comparing against both
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
- const run = trimmed.match(/^`+/u)?.[0].length ?? 0;
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 ? null : fence;
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. Bodies from the
202
- * normalize pipeline are already frontmatter-stripped, but `extractHeadings`
203
- * also runs on raw documents where a leading `---` block (closed by `---` or
204
- * `...`) is front matter, not a thematic break whose closing `---` would
205
- * underline the last metadata line into a phantom setext heading.
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): 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 ? lines : lines.slice(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
- const atx = line.match(ATX_HEADING);
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
- export const extractHeadings = (body: string): Heading[] => {
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
- for (const line of linesWithoutFrontMatter(body)) {
358
- scanHeadingLine(line, state, slugger, headings);
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
- return headings;
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
  // (`[![alt](/img.png)](/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 = (raw: string | undefined, body: string): number =>
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 = (value: SourceEntry["data"][string]): value is string =>
538
- typeof value === "string";
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
- // The version is detected first: a snapshot directory is outermost on disk
721
- // (`v1.0/fr/page.mdx`), so the locale parser and route mapping must see a
722
- // version-stripped ref. The current version is `""` and lives at the root.
723
- const { versions } = ctx;
724
- const { version, rest: versionlessRef } = versions
725
- ? detectVersionRef(entry.ref, versions)
726
- : { rest: entry.ref, version: "" };
727
-
728
- // Locale and the locale-stripped nav path come from the entry's ref (a leading
729
- // dir, or a filename suffix under the `dot` parser), not the slug — the slug is
730
- // the logical, locale-agnostic path within a locale. A shared `$` file maps to
731
- // every locale. Remote/CMS sources without i18n placement map to one locale.
732
- const { i18n } = ctx;
733
- const { navPath: rawNavPath, locales } = i18n
734
- ? localePlacement(versionlessRef, ext, i18n)
735
- : { locales: [""], navPath: versionlessRef };
736
-
737
- const navPath = withPrefix(ctx.source.prefix, rawNavPath);
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(entry.body.text) : undefined,
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: extractLinks(
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
- i18n ? localizeRoute(logicalRoute, locale, i18n) : logicalRoute
1180
+ localizedRoute(logicalRoute, locale, ctx.i18n)
800
1181
  ),
801
1182
  }));
802
1183
 
803
- return { diagnostics: [], pages };
1184
+ return {
1185
+ diagnostics:
1186
+ format === "mdx"
1187
+ ? curlyMarkerDiagnostics(entry, curlyMarkers, ctx.source.name)
1188
+ : [],
1189
+ pages,
1190
+ };
804
1191
  };