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.
Files changed (211) hide show
  1. package/CHANGELOG.md +618 -0
  2. package/LICENSE +21 -0
  3. package/README.md +107 -0
  4. package/dist/cli/index.js +2609 -1041
  5. package/dist/cli/index.js.map +110 -103
  6. package/dist/types/ai/component-markdown.d.ts +34 -0
  7. package/dist/types/components/content/youtube.d.ts +18 -0
  8. package/dist/types/core/base-path.d.ts +47 -0
  9. package/dist/types/core/config-input.d.ts +110 -12
  10. package/dist/types/core/config.d.ts +6 -4
  11. package/dist/types/core/data.d.ts +4 -0
  12. package/dist/types/core/i18n-ui.d.ts +477 -135
  13. package/dist/types/core/schema.d.ts +309 -195
  14. package/dist/types/core/sources/types.d.ts +2 -0
  15. package/dist/types/core/types.d.ts +6 -1
  16. package/dist/types/index.d.ts +1 -0
  17. package/dist/types/openapi/references.d.ts +60 -0
  18. package/docs/01-quickstart.mdx +5 -2
  19. package/docs/02-deployment.mdx +24 -9
  20. package/docs/03-faq.mdx +46 -16
  21. package/docs/advanced/custom-pages.mdx +1 -1
  22. package/docs/advanced/skills.mdx +1 -1
  23. package/docs/configuration/ai.mdx +49 -10
  24. package/docs/configuration/customization.mdx +11 -0
  25. package/docs/configuration/index.mdx +33 -3
  26. package/docs/configuration/seo.mdx +2 -2
  27. package/docs/content/components.mdx +30 -3
  28. package/docs/content/i18n.mdx +1 -1
  29. package/docs/content/islands.mdx +8 -0
  30. package/docs/content/navigation.mdx +3 -3
  31. package/docs/content/sources.mdx +1 -1
  32. package/docs/content/syntax.mdx +17 -2
  33. package/docs/index.mdx +2 -2
  34. package/docs/reference/cli.mdx +8 -6
  35. package/package.json +15 -4
  36. package/skills/blume/SKILL.md +5 -3
  37. package/skills/blume-update-docs/SKILL.md +3 -2
  38. package/src/ai/agent-readability.ts +11 -5
  39. package/src/ai/ask-context.ts +7 -2
  40. package/src/ai/ask-data.ts +3 -0
  41. package/src/ai/ask.ts +12 -7
  42. package/src/ai/component-markdown.ts +461 -0
  43. package/src/ai/llms.ts +143 -23
  44. package/src/ai/markdown.ts +35 -6
  45. package/src/ai/mcp/data.ts +33 -8
  46. package/src/ai/mcp/discovery.ts +10 -3
  47. package/src/ai/mcp/server.ts +24 -7
  48. package/src/ai/visibility.ts +74 -0
  49. package/src/astro/component-slots.ts +16 -4
  50. package/src/astro/examples.ts +12 -7
  51. package/src/astro/generate.ts +393 -189
  52. package/src/astro/index.ts +5 -1
  53. package/src/astro/integration.ts +9 -5
  54. package/src/astro/islands.ts +11 -5
  55. package/src/astro/markdown-negotiation.ts +2 -2
  56. package/src/astro/pages.ts +89 -22
  57. package/src/astro/templates.ts +259 -25
  58. package/src/blume-modules.d.ts +8 -0
  59. package/src/cli/commands/build.ts +131 -38
  60. package/src/cli/commands/check.ts +1 -1
  61. package/src/cli/commands/dev.ts +71 -17
  62. package/src/cli/commands/doctor.ts +2 -2
  63. package/src/cli/commands/eject.ts +47 -19
  64. package/src/cli/commands/init.ts +120 -180
  65. package/src/cli/commands/preview.ts +4 -1
  66. package/src/cli/commands/validate.ts +44 -2
  67. package/src/cli/dev-lock.ts +34 -19
  68. package/src/cli/eject-scripts.ts +72 -0
  69. package/src/cli/env.ts +15 -5
  70. package/src/cli/init/questions.ts +158 -0
  71. package/src/cli/init/scaffold.ts +380 -0
  72. package/src/cli/required-secrets.ts +2 -1
  73. package/src/components/content/AccordionItem.astro +23 -4
  74. package/src/components/content/Badge.astro +3 -1
  75. package/src/components/content/Card.astro +4 -2
  76. package/src/components/content/CodeBlock.astro +3 -0
  77. package/src/components/content/Component.astro +30 -16
  78. package/src/components/content/Diff.astro +3 -1
  79. package/src/components/content/Step.astro +10 -1
  80. package/src/components/content/Tabs.astro +15 -3
  81. package/src/components/content/Tile.astro +2 -1
  82. package/src/components/content/Tooltip.astro +3 -1
  83. package/src/components/content/Update.astro +9 -2
  84. package/src/components/content/auto-type-table.ts +25 -9
  85. package/src/components/content/base-href.ts +33 -0
  86. package/src/components/content/changelog-element.ts +9 -2
  87. package/src/components/content/diff.ts +12 -6
  88. package/src/components/content/mermaid-element.ts +10 -2
  89. package/src/components/index.ts +23 -1
  90. package/src/components/islands/AskAI.astro +5 -2
  91. package/src/components/islands/ask-ai.tsx +68 -12
  92. package/src/components/islands/base-path.ts +28 -0
  93. package/src/components/islands/hooks.ts +44 -9
  94. package/src/components/layout/Banner.astro +12 -3
  95. package/src/components/layout/Breadcrumbs.astro +2 -1
  96. package/src/components/layout/Favicon.astro +3 -2
  97. package/src/components/layout/Header.astro +15 -5
  98. package/src/components/layout/LanguageSwitcher.astro +2 -1
  99. package/src/components/layout/Logo.astro +13 -4
  100. package/src/components/layout/NavSelector.astro +2 -1
  101. package/src/components/layout/NavTree.astro +22 -7
  102. package/src/components/layout/PageActions.astro +25 -10
  103. package/src/components/layout/PageFeedback.astro +4 -1
  104. package/src/components/layout/PageLayout.astro +51 -9
  105. package/src/components/layout/Pagination.astro +3 -2
  106. package/src/components/layout/ReferenceLayout.astro +8 -1
  107. package/src/components/layout/RootLayout.astro +74 -13
  108. package/src/components/layout/Search.astro +107 -27
  109. package/src/components/layout/nav-utils.ts +18 -10
  110. package/src/components/layout/search/algolia.ts +11 -2
  111. package/src/components/layout/search/endpoint.ts +11 -5
  112. package/src/components/layout/search/orama-cloud.ts +8 -2
  113. package/src/components/layout/search/pagefind.ts +3 -0
  114. package/src/components/layout/search/types.ts +5 -1
  115. package/src/components/layout/search/typesense.ts +4 -1
  116. package/src/components/layout/toc-element.ts +8 -2
  117. package/src/components/openapi/ApiTagOperations.astro +2 -1
  118. package/src/components/openapi/Operation.astro +47 -40
  119. package/src/components/openapi/RequestPanel.astro +8 -2
  120. package/src/components/openapi/helpers.ts +71 -3
  121. package/src/components/openapi/panel.ts +1 -1
  122. package/src/components/openapi/snippets.ts +25 -11
  123. package/src/core/base-path.ts +94 -0
  124. package/src/core/builtin-tags.ts +2 -0
  125. package/src/core/component-overrides.ts +103 -74
  126. package/src/core/config-input.ts +118 -17
  127. package/src/core/config.ts +8 -5
  128. package/src/core/content.ts +2 -0
  129. package/src/core/data.ts +4 -0
  130. package/src/core/diagnostics.ts +54 -34
  131. package/src/core/gitignore.ts +4 -1
  132. package/src/core/graph.ts +166 -88
  133. package/src/core/i18n-ui.ts +63 -3
  134. package/src/core/last-modified.ts +15 -6
  135. package/src/core/links.ts +69 -25
  136. package/src/core/manifest.ts +62 -45
  137. package/src/core/nav-diagnostics.ts +1 -1
  138. package/src/core/navigation.ts +144 -58
  139. package/src/core/package-json.ts +17 -2
  140. package/src/core/project-graph.ts +25 -15
  141. package/src/core/schema.ts +605 -620
  142. package/src/core/sources/assets.ts +6 -1
  143. package/src/core/sources/filesystem.ts +4 -0
  144. package/src/core/sources/github-releases.ts +2 -1
  145. package/src/core/sources/mdx-remote.ts +76 -63
  146. package/src/core/sources/normalize.ts +236 -91
  147. package/src/core/sources/notion.ts +27 -18
  148. package/src/core/sources/types.ts +2 -0
  149. package/src/core/tsconfig-aliases.ts +59 -30
  150. package/src/core/types.ts +6 -1
  151. package/src/core/ui-packs/ar.ts +1 -0
  152. package/src/core/ui-packs/bg.ts +1 -0
  153. package/src/core/ui-packs/bn.ts +1 -0
  154. package/src/core/ui-packs/ca.ts +1 -0
  155. package/src/core/ui-packs/cs.ts +1 -0
  156. package/src/core/ui-packs/da.ts +1 -0
  157. package/src/core/ui-packs/de.ts +1 -0
  158. package/src/core/ui-packs/el.ts +1 -0
  159. package/src/core/ui-packs/es.ts +1 -0
  160. package/src/core/ui-packs/fa.ts +1 -0
  161. package/src/core/ui-packs/fi.ts +1 -0
  162. package/src/core/ui-packs/fr.ts +2 -1
  163. package/src/core/ui-packs/he.ts +1 -0
  164. package/src/core/ui-packs/hi.ts +1 -0
  165. package/src/core/ui-packs/hr.ts +1 -0
  166. package/src/core/ui-packs/hu.ts +1 -0
  167. package/src/core/ui-packs/id.ts +1 -0
  168. package/src/core/ui-packs/it.ts +1 -0
  169. package/src/core/ui-packs/ja.ts +1 -0
  170. package/src/core/ui-packs/ko.ts +1 -0
  171. package/src/core/ui-packs/nl.ts +1 -0
  172. package/src/core/ui-packs/no.ts +1 -0
  173. package/src/core/ui-packs/pl.ts +1 -0
  174. package/src/core/ui-packs/pt-br.ts +1 -0
  175. package/src/core/ui-packs/pt.ts +1 -0
  176. package/src/core/ui-packs/ro.ts +1 -0
  177. package/src/core/ui-packs/ru.ts +1 -0
  178. package/src/core/ui-packs/sk.ts +1 -0
  179. package/src/core/ui-packs/sr.ts +1 -0
  180. package/src/core/ui-packs/sv.ts +1 -0
  181. package/src/core/ui-packs/th.ts +1 -0
  182. package/src/core/ui-packs/tr.ts +1 -0
  183. package/src/core/ui-packs/uk.ts +1 -0
  184. package/src/core/ui-packs/vi.ts +1 -0
  185. package/src/core/ui-packs/zh-tw.ts +1 -0
  186. package/src/core/ui-packs/zh.ts +1 -0
  187. package/src/deploy/adapter-output.ts +18 -8
  188. package/src/deploy/redirects.ts +25 -2
  189. package/src/deploy/robots.ts +6 -1
  190. package/src/deploy/rss.ts +10 -3
  191. package/src/deploy/sitemap.ts +59 -13
  192. package/src/index.ts +5 -0
  193. package/src/markdown/base-links.ts +60 -0
  194. package/src/markdown/code-title.ts +11 -14
  195. package/src/markdown/index.ts +46 -9
  196. package/src/markdown/inline-code.ts +14 -4
  197. package/src/markdown/package-commands.ts +10 -4
  198. package/src/markdown/themes.ts +24 -0
  199. package/src/openapi/model.ts +15 -5
  200. package/src/openapi/parse.ts +21 -0
  201. package/src/openapi/references.ts +75 -21
  202. package/src/openapi/render-mdx.ts +11 -6
  203. package/src/openapi/scalar.ts +32 -16
  204. package/src/openapi/source.ts +59 -10
  205. package/src/registry/eject.ts +247 -19
  206. package/src/registry/registry.ts +0 -3
  207. package/src/search/build.ts +3 -0
  208. package/src/search/documents.ts +36 -4
  209. package/src/search/sync/typesense.ts +6 -4
  210. package/src/seo/jsonld.ts +28 -17
  211. 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
- const file = `${hashText(url)}${extFor(url)}`;
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 ref = `${slugifyTag(release.tag_name) || `release-${release.id}`}.md`;
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 char = pattern[i] ?? "";
46
- if (char === "*") {
47
- if (pattern[i + 1] === "*") {
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
- .filter((node) => node.type === "blob" && node.path.startsWith(prefix))
142
- .map((node) => node.path.slice(prefix.length))
143
- .filter((rel) => matchesInclude(rel, include))
144
- .map((rel) => ({
145
- editUrl: `https://github.com/${owner}/${repo}/edit/${ref}/${prefix}${rel}`,
146
- fetchUrl: `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${prefix}${rel}`,
147
- ref: rel,
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
- if (options.files && options.url) {
175
- const base = options.url.replace(/\/$/u, "");
176
- const refs = options.files
177
- .filter((ref) => matchesInclude(ref, options.include))
178
- .map((ref) => ({
179
- editUrl: `${base}/${ref}`,
180
- fetchUrl: `${base}/${ref}`,
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
- // A leading/trailing/double slash yields an empty part; keeping it would
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
- const CODE_FENCE = /^```/u;
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
- const ATX_HEADING = /^(?<hashes>#{1,6})\s+(?<text>.+?)(?:\s+#+)?\s*$/u;
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
- * Extract ATX headings from a markdown body, skipping fenced code blocks. Each
90
- * heading's anchor slug comes from a per-document `github-slugger` — the exact
91
- * slugger the renderer uses (`markdown/heading-anchors`) — advanced over every
92
- * `#`–`######` in document order. Matching it (rather than a hand-rolled
93
- * slugify) keeps the manifest's anchor ids identical to the rendered ones, so
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
- let inFence = false;
223
+ const state: HeadingScanState = { fence: null, paragraph: [] };
102
224
 
103
- for (const line of body.split("\n")) {
104
- if (CODE_FENCE.test(line.trimStart())) {
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
- export const extractLinks = (body: string): PageLink[] => {
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 inFence = false;
132
- let lineNumber = 0;
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
- if (CODE_FENCE.test(line.trimStart())) {
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 inFence = false;
326
+ let fence: FenceState = null;
185
327
  for (const line of body.split("\n")) {
186
- if (CODE_FENCE.test(line.trimStart())) {
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(entry.body.text),
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: i18n ? localizeRoute(logicalRoute, locale, i18n) : logicalRoute,
471
+ route: withBasePath(
472
+ ctx.basePath ?? "",
473
+ i18n ? localizeRoute(logicalRoute, locale, i18n) : logicalRoute
474
+ ),
330
475
  }));
331
476
 
332
477
  return { diagnostics: [], pages };