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
@@ -0,0 +1,632 @@
1
+ import { readFile } from "node:fs/promises";
2
+
3
+ import { dirname, extname, join, relative, resolve } from "pathe";
4
+
5
+ import matter from "./frontmatter.ts";
6
+ import type { FenceState } from "./sources/normalize.ts";
7
+ import {
8
+ INLINE_CODE,
9
+ MD_IMAGE,
10
+ nextFenceState,
11
+ targetOffsetIn,
12
+ } from "./sources/normalize.ts";
13
+ import type { Diagnostic } from "./types.ts";
14
+
15
+ /**
16
+ * Content includes: `<include>./relative.mdx</include>` on a line of its own
17
+ * splices another file into the page at build time — the syntax Fumadocs
18
+ * ships, so migrated content works unchanged. Markdown/MDX targets are
19
+ * spliced as content (front matter stripped, nested includes resolved, cycle
20
+ * detection); any other extension — or an explicit `lang` attribute — embeds
21
+ * the file as a fenced code block, with optional `meta` for the fence meta
22
+ * string (e.g. `title="config.ts"`).
23
+ *
24
+ * This module is the single owner of include semantics. The string-level
25
+ * {@link expandIncludes} powers every surface that reads raw markdown source
26
+ * (heading/link extraction, search indexing, the `/route.md` mirrors,
27
+ * llms-full.txt), while {@link expandIncludeTarget} powers the Sätteri render
28
+ * plugin, so what renders and what's indexed can't drift apart.
29
+ */
30
+
31
+ /** Docs page targets: `.md`/`.mdx` splice as content; anything else is code. */
32
+ const CONTENT_EXTENSIONS = new Set([".md", ".mdx"]);
33
+
34
+ /**
35
+ * A full include statement occupying one (trimmed) line: optional lowercase
36
+ * attributes, then the target path as the element's text. Attribute values
37
+ * accept both quote styles; a bare attribute (no `=`) is allowed so future
38
+ * boolean flags parse rather than break the statement match.
39
+ *
40
+ * The target is anchored to non-space characters on both ends (rather than a
41
+ * lazy run trimmed by the following `\s*`): a lazy `[^<>]*?` overlapping that
42
+ * `\s*` backtracks quadratically on inputs like `<include>x` plus a long run
43
+ * of spaces with no closing tag (CodeQL js/polynomial-redos). With both ends
44
+ * pinned, a trailing space run is consumed by `\s*` alone and a failed match
45
+ * stays linear.
46
+ */
47
+ const INCLUDE_STATEMENT =
48
+ /^<include(?<attrs>(?:\s+[a-z][\w-]*(?:\s*=\s*(?:"[^"]*"|'[^']*'))?)*)\s*>\s*(?<target>[^<>\s](?:[^<>]*[^<>\s])?)\s*<\/include\s*>$/u;
49
+
50
+ /** One attribute within a statement's attrs run. */
51
+ const INCLUDE_ATTRIBUTE =
52
+ /(?<name>[a-z][\w-]*)(?:\s*=\s*(?:"(?<double>[^"]*)"|'(?<single>[^']*)'))?/gu;
53
+
54
+ /**
55
+ * Cheap pre-filter so include-free content (the overwhelmingly common case)
56
+ * skips the line scan and file reads entirely.
57
+ */
58
+ export const hasIncludeStatements = (text: string): boolean =>
59
+ text.includes("<include");
60
+
61
+ /** A line indented four spaces (or a tab) — an indented code block in `.md`,
62
+ * where the renderer shows a statement literally instead of splicing it. */
63
+ const INDENTED_CODE = /^(?: {4}|\t)/u;
64
+
65
+ /** A setext underline (`===`/`---`, up to 3 leading spaces): directly under a
66
+ * statement line it folds the statement into a heading instead of a splice. */
67
+ const SETEXT_UNDERLINE = /^ {0,3}(?:=+|-+)\s*$/u;
68
+
69
+ /** Comment delimiters per format: `.md` uses HTML comments, `.mdx` JSX ones. */
70
+ const COMMENT_DELIMITERS = {
71
+ md: { close: "-->", open: "<!--" },
72
+ mdx: { close: "*/}", open: "{/*" },
73
+ } as const;
74
+
75
+ /**
76
+ * Advance the comment state across one line: each open/close marker toggles
77
+ * it, left to right. A line that *matches* a statement can't contain a marker
78
+ * (the statement regex spans the whole trimmed line), so per-line state at the
79
+ * line's start is all the statement scan needs.
80
+ */
81
+ const advanceCommentState = (
82
+ line: string,
83
+ delimiters: { open: string; close: string },
84
+ inComment: boolean
85
+ ): boolean => {
86
+ let inside = inComment;
87
+ let cursor = 0;
88
+ for (;;) {
89
+ const marker = inside ? delimiters.close : delimiters.open;
90
+ const index = line.indexOf(marker, cursor);
91
+ if (index === -1) {
92
+ return inside;
93
+ }
94
+ inside = !inside;
95
+ cursor = index + marker.length;
96
+ }
97
+ };
98
+
99
+ /** The attributes an include statement supports. */
100
+ export interface IncludeAttributes {
101
+ /** Force code-block mode with this language (even for `.md`/`.mdx`). */
102
+ lang?: string;
103
+ /** Fence meta string in code-block mode (e.g. `title="lib.ts"`). */
104
+ meta?: string;
105
+ }
106
+
107
+ /** A parsed include statement. */
108
+ export interface IncludeStatement {
109
+ target: string;
110
+ attributes: IncludeAttributes;
111
+ }
112
+
113
+ /**
114
+ * Parse one trimmed line as an include statement, or `null` when it isn't
115
+ * one. Only `lang` and `meta` are meaningful; unknown attributes parse and
116
+ * are ignored so a future attribute degrades gracefully on older versions.
117
+ */
118
+ export const parseIncludeStatement = (
119
+ line: string
120
+ ): IncludeStatement | null => {
121
+ const match = INCLUDE_STATEMENT.exec(line);
122
+ if (!match?.groups) {
123
+ return null;
124
+ }
125
+ const attributes: IncludeAttributes = {};
126
+ for (const attr of (match.groups.attrs ?? "").matchAll(INCLUDE_ATTRIBUTE)) {
127
+ const name = attr.groups?.name;
128
+ const value = attr.groups?.double ?? attr.groups?.single;
129
+ if (name === "lang" && value) {
130
+ attributes.lang = value;
131
+ }
132
+ if (name === "meta" && value) {
133
+ attributes.meta = value;
134
+ }
135
+ }
136
+ return { attributes, target: match.groups.target ?? "" };
137
+ };
138
+
139
+ /**
140
+ * Parse one raw `.md` source line as an include statement, applying the
141
+ * line-level rules the renderer sees: a line indented like code (4 spaces or
142
+ * a tab) is an indented code block, shown literally rather than spliced. The
143
+ * render plugin's `.md` visitors share this so the two halves can't drift.
144
+ */
145
+ export const parseIncludeLine = (line: string): IncludeStatement | null =>
146
+ INDENTED_CODE.test(line) ? null : parseIncludeStatement(line.trim());
147
+
148
+ /**
149
+ * Advance the HTML comment state across one `.md` line — the render plugin's
150
+ * half of the comment rule (a statement inside `<!-- -->` never splices),
151
+ * sharing the same tracking the string-level scanner uses.
152
+ */
153
+ export const advanceHtmlCommentState = (
154
+ line: string,
155
+ inComment: boolean
156
+ ): boolean => advanceCommentState(line, COMMENT_DELIMITERS.md, inComment);
157
+
158
+ /** Provenance of one line of expanded output. */
159
+ export interface LineOrigin {
160
+ /** Absolute path of the file the line came from. */
161
+ file: string;
162
+ /** 1-based line number within that file's raw source. */
163
+ line: number;
164
+ }
165
+
166
+ /** The result of expanding a document's include statements. */
167
+ export interface IncludeExpansion {
168
+ /** The document with every include statement replaced by its content. */
169
+ text: string;
170
+ /** Per output line: the source file and raw-file line it came from. */
171
+ origins: LineOrigin[];
172
+ /** Absolute paths of every file included, transitively. */
173
+ includes: string[];
174
+ /** Structured errors (missing target, cycle, escape); statements with an
175
+ * error stay verbatim in the output. */
176
+ errors: Diagnostic[];
177
+ }
178
+
179
+ interface ExpandContext {
180
+ /** The owning source's content root; bounds resolution when set. */
181
+ contentRoot?: string;
182
+ includes: Set<string>;
183
+ errors: Diagnostic[];
184
+ }
185
+
186
+ const DOCS_SUGGESTION =
187
+ "Include paths resolve relative to the including file; paths starting with / resolve from the content root.";
188
+
189
+ const includeError = (
190
+ code: string,
191
+ message: string,
192
+ file: string,
193
+ line: number,
194
+ suggestion: string
195
+ ): Diagnostic => ({ code, file, line, message, severity: "error", suggestion });
196
+
197
+ /**
198
+ * Whether `path` lies outside `root`. The relative path must be `..` itself or
199
+ * start with a `../` *segment* — a bare `.startsWith("..")` would also reject
200
+ * a legal in-root directory whose name begins with two dots (`..archive/`).
201
+ */
202
+ const escapesRoot = (root: string, path: string): boolean => {
203
+ const rel = relative(root, path);
204
+ return rel === ".." || rel.startsWith("../");
205
+ };
206
+
207
+ /**
208
+ * Resolve a statement's target to an absolute path, or an error when it can't
209
+ * be resolved safely. `/`-leading targets resolve from the content root;
210
+ * everything else resolves from the including file's directory. Targets must
211
+ * stay within the content root — a partial outside it would be silently
212
+ * dropped from version snapshots and ejects (and an escaping path could
213
+ * splice arbitrary files into published pages). Both branches enforce the
214
+ * bound: a root-relative target can still climb out through `..` segments.
215
+ */
216
+ const resolveIncludePath = (
217
+ target: string,
218
+ filePath: string,
219
+ ctx: ExpandContext,
220
+ line: number
221
+ ): { path: string } | { error: Diagnostic } => {
222
+ if (target.startsWith("/")) {
223
+ if (!ctx.contentRoot) {
224
+ return {
225
+ error: includeError(
226
+ "BLUME_INCLUDE_OUTSIDE_ROOT",
227
+ `Include target ${target} is root-relative, but no content root is configured here.`,
228
+ filePath,
229
+ line,
230
+ "Use a path relative to the including file instead."
231
+ ),
232
+ };
233
+ }
234
+ const path = join(ctx.contentRoot, target);
235
+ if (escapesRoot(ctx.contentRoot, path)) {
236
+ return {
237
+ error: includeError(
238
+ "BLUME_INCLUDE_OUTSIDE_ROOT",
239
+ `Include target ${target} resolves outside the content root.`,
240
+ filePath,
241
+ line,
242
+ "Root-relative include paths resolve from the content root and cannot climb above it."
243
+ ),
244
+ };
245
+ }
246
+ return { path };
247
+ }
248
+ const path = resolve(dirname(filePath), target);
249
+ if (ctx.contentRoot && escapesRoot(ctx.contentRoot, path)) {
250
+ return {
251
+ error: includeError(
252
+ "BLUME_INCLUDE_OUTSIDE_ROOT",
253
+ `Include target ${target} resolves outside the content root.`,
254
+ filePath,
255
+ line,
256
+ "Move the included file under the content root so builds, version snapshots, and ejects all see it."
257
+ ),
258
+ };
259
+ }
260
+ return { path };
261
+ };
262
+
263
+ /**
264
+ * Rewrite one line's relative image targets from the included file's
265
+ * directory to the including file's, so a partial's colocated
266
+ * `![](./diagram.png)` still resolves once its markdown lives in the
267
+ * includer. Mirrors `rewriteLine` in `content-assets.ts`: matches run on an
268
+ * inline-code-masked copy while replacements splice into the real line.
269
+ */
270
+ const rebaseImageLine = (
271
+ line: string,
272
+ fromDir: string,
273
+ toDir: string
274
+ ): string => {
275
+ const masked = line.replaceAll(INLINE_CODE, (span) =>
276
+ " ".repeat(span.length)
277
+ );
278
+ let out = "";
279
+ let cursor = 0;
280
+ for (const match of masked.matchAll(MD_IMAGE)) {
281
+ const target = match.groups?.target ?? "";
282
+ // Only filesystem-relative targets move with the file: URLs, public-dir
283
+ // absolutes, and anchors mean the same thing from either directory.
284
+ if (
285
+ target.startsWith("/") ||
286
+ target.startsWith("#") ||
287
+ URL.canParse(target)
288
+ ) {
289
+ continue;
290
+ }
291
+ const rebased = relative(toDir, resolve(fromDir, target));
292
+ const url = rebased.startsWith(".") ? rebased : `./${rebased}`;
293
+ const offset =
294
+ (match.index ?? 0) +
295
+ targetOffsetIn(match[0], target, match.groups?.title);
296
+ out += line.slice(cursor, offset) + url;
297
+ cursor = offset + target.length;
298
+ }
299
+ return out + line.slice(cursor);
300
+ };
301
+
302
+ /** Rebase every relative image target in expanded lines, skipping fences. */
303
+ const rebaseImages = (
304
+ lines: string[],
305
+ fromDir: string,
306
+ toDir: string
307
+ ): string[] => {
308
+ if (fromDir === toDir) {
309
+ return lines;
310
+ }
311
+ let fence: FenceState = null;
312
+ return lines.map((line) => {
313
+ const next = nextFenceState(line, fence);
314
+ const inFence = fence !== null || next !== null;
315
+ fence = next;
316
+ return inFence ? line : rebaseImageLine(line, fromDir, toDir);
317
+ });
318
+ };
319
+
320
+ /** Wrap raw file content as a fenced code block that can't be broken by the
321
+ * content's own backtick runs. */
322
+ const codeBlockLines = (
323
+ content: string,
324
+ path: string,
325
+ attributes: IncludeAttributes
326
+ ): string[] => {
327
+ const body = content.replace(/\n$/u, "");
328
+ let longestRun = 0;
329
+ for (const run of body.match(/`+/gu) ?? []) {
330
+ longestRun = Math.max(longestRun, run.length);
331
+ }
332
+ const fence = "`".repeat(Math.max(3, longestRun + 1));
333
+ const ext = extname(path);
334
+ const lang = attributes.lang ?? (ext ? ext.slice(1) : "text");
335
+ const meta = attributes.meta ? ` ${attributes.meta}` : "";
336
+ return [`${fence}${lang}${meta}`, ...body.split("\n"), fence];
337
+ };
338
+
339
+ /** Lines-with-origins pair every splice step produces. */
340
+ interface ExpandedLines {
341
+ lines: string[];
342
+ origins: LineOrigin[];
343
+ }
344
+
345
+ /**
346
+ * Resolve one include statement to its expanded lines. Content targets are
347
+ * front-matter-stripped, recursively expanded, and image-rebased into the
348
+ * includer's directory; code targets are fence-wrapped verbatim.
349
+ */
350
+ const expandStatement = async (
351
+ statement: IncludeStatement,
352
+ filePath: string,
353
+ line: number,
354
+ ctx: ExpandContext,
355
+ stack: readonly string[]
356
+ ): Promise<ExpandedLines | { error: Diagnostic }> => {
357
+ const resolved = resolveIncludePath(statement.target, filePath, ctx, line);
358
+ if ("error" in resolved) {
359
+ return resolved;
360
+ }
361
+ const { path } = resolved;
362
+ if (stack.includes(path)) {
363
+ return {
364
+ error: includeError(
365
+ "BLUME_INCLUDE_CYCLE",
366
+ `Circular include: ${[...stack, path]
367
+ .map((entry) => relative(ctx.contentRoot ?? dirname(path), entry))
368
+ .join(" -> ")}.`,
369
+ filePath,
370
+ line,
371
+ "Remove the include statement that closes the loop."
372
+ ),
373
+ };
374
+ }
375
+ let content: string;
376
+ try {
377
+ content = await readFile(path, "utf-8");
378
+ } catch (error) {
379
+ return {
380
+ error: includeError(
381
+ "BLUME_INCLUDE_NOT_FOUND",
382
+ `Include target ${statement.target} was not found (looked at ${path}): ${
383
+ error instanceof Error ? error.message : String(error)
384
+ }.`,
385
+ filePath,
386
+ line,
387
+ DOCS_SUGGESTION
388
+ ),
389
+ };
390
+ }
391
+ ctx.includes.add(path);
392
+
393
+ const asContent =
394
+ !statement.attributes.lang &&
395
+ CONTENT_EXTENSIONS.has(extname(path).toLowerCase());
396
+ if (!asContent) {
397
+ const lines = codeBlockLines(content, path, statement.attributes);
398
+ // Fence delimiters are synthetic; anchor them (and the verbatim body,
399
+ // which link extraction skips as fenced code anyway) to the target file.
400
+ return {
401
+ lines,
402
+ origins: lines.map((_, i) => ({ file: path, line: Math.max(1, i) })),
403
+ };
404
+ }
405
+
406
+ const parsed = matter(content);
407
+ const body = parsed.content;
408
+ const strippedOffset = Math.max(
409
+ 0,
410
+ content.split("\n").length - body.split("\n").length
411
+ );
412
+ // The stack is copied per branch (never mutated) so sibling statements can
413
+ // expand concurrently without seeing each other's frames as cycles.
414
+ // oxlint-disable-next-line no-use-before-define -- mutual recursion: a partial expands its own includes
415
+ const expanded = await expandLines(body, path, strippedOffset, ctx, [
416
+ ...stack,
417
+ path,
418
+ ]);
419
+ // Blank edge lines (the file's trailing newline, cosmetic leading gaps)
420
+ // carry no markdown meaning; trimming them keeps splices tight and the
421
+ // padding in `expandLines` the only blank-line authority.
422
+ const lines = [...expanded.lines];
423
+ const origins = [...expanded.origins];
424
+ while (lines.at(-1)?.trim() === "") {
425
+ lines.pop();
426
+ origins.pop();
427
+ }
428
+ while (lines[0]?.trim() === "") {
429
+ lines.shift();
430
+ origins.shift();
431
+ }
432
+ return {
433
+ lines: rebaseImages(lines, dirname(path), dirname(filePath)),
434
+ origins,
435
+ };
436
+ };
437
+
438
+ /**
439
+ * Walk a document's lines, replacing each include statement (outside fenced
440
+ * code, comments, and — in `.md` — indented code blocks) with the target's
441
+ * expanded lines. Statements that error stay verbatim so downstream surfaces
442
+ * show what the author wrote; a blank line is padded around each splice so a
443
+ * partial can't merge into an adjacent paragraph.
444
+ */
445
+ const expandLines = async (
446
+ body: string,
447
+ filePath: string,
448
+ lineOffset: number,
449
+ ctx: ExpandContext,
450
+ stack: readonly string[]
451
+ ): Promise<ExpandedLines> => {
452
+ const sourceLines = body.split("\n");
453
+ const isMdx = extname(filePath).toLowerCase() === ".mdx";
454
+ const delimiters = COMMENT_DELIMITERS[isMdx ? "mdx" : "md"];
455
+
456
+ // Pass 1: statement detection per line, tracking fenced code and comments —
457
+ // a commented-out statement never renders, so expanding it would leak the
458
+ // partial into search/mirror surfaces the page doesn't show. Indented code
459
+ // and setext underlines only exist in `.md` (MDX has neither, and an
460
+ // indented `<include>` there is still JSX flow the renderer splices).
461
+ let fence: FenceState = null;
462
+ let inComment = false;
463
+ const statements = sourceLines.map((line, index) => {
464
+ if (inComment) {
465
+ inComment = advanceCommentState(line, delimiters, true);
466
+ return null;
467
+ }
468
+ const next = nextFenceState(line, fence);
469
+ const inFence = fence !== null || next !== null;
470
+ fence = next;
471
+ if (inFence) {
472
+ return null;
473
+ }
474
+ inComment = advanceCommentState(line, delimiters, false);
475
+ if (inComment || !hasIncludeStatements(line)) {
476
+ return null;
477
+ }
478
+ const statement = isMdx
479
+ ? parseIncludeStatement(line.trim())
480
+ : parseIncludeLine(line);
481
+ // A setext underline directly below folds the statement line into a
482
+ // heading — the renderer shows a heading, not a splice, so skip it.
483
+ if (
484
+ statement &&
485
+ !isMdx &&
486
+ SETEXT_UNDERLINE.test(sourceLines[index + 1] ?? "")
487
+ ) {
488
+ return null;
489
+ }
490
+ return statement;
491
+ });
492
+
493
+ // Pass 2: expand every statement concurrently — reads are independent, and
494
+ // each branch carries its own cycle stack.
495
+ const expansions = await Promise.all(
496
+ statements.map((statement, index) =>
497
+ statement
498
+ ? expandStatement(
499
+ statement,
500
+ filePath,
501
+ lineOffset + index + 1,
502
+ ctx,
503
+ stack
504
+ )
505
+ : null
506
+ )
507
+ );
508
+
509
+ // Pass 3: assemble sequentially — the blank-line padding depends on the
510
+ // accumulated output, and error order should follow document order.
511
+ const lines: string[] = [];
512
+ const origins: LineOrigin[] = [];
513
+ for (const [index, line] of sourceLines.entries()) {
514
+ const rawLine = lineOffset + index + 1;
515
+ const expanded = expansions[index];
516
+ if (!expanded || "error" in expanded) {
517
+ if (expanded) {
518
+ ctx.errors.push(expanded.error);
519
+ }
520
+ lines.push(line);
521
+ origins.push({ file: filePath, line: rawLine });
522
+ continue;
523
+ }
524
+ const pad = (): void => {
525
+ lines.push("");
526
+ origins.push({ file: filePath, line: rawLine });
527
+ };
528
+ if (lines.at(-1)?.trim()) {
529
+ pad();
530
+ }
531
+ lines.push(...expanded.lines);
532
+ origins.push(...expanded.origins);
533
+ if (sourceLines[index + 1]?.trim()) {
534
+ pad();
535
+ }
536
+ }
537
+ return { lines, origins };
538
+ };
539
+
540
+ /**
541
+ * Expand a document's include statements at the string level. `lineOffset`
542
+ * shifts the includer's own recorded origin lines — pass the stripped front
543
+ * matter block's height when `body` is the stripped text, so origins point at
544
+ * real file lines. Never throws: unresolvable statements stay verbatim and
545
+ * surface through `errors`.
546
+ */
547
+ export const expandIncludes = async (
548
+ body: string,
549
+ options: {
550
+ /** Absolute path of the file being expanded. */
551
+ sourcePath: string;
552
+ /** The owning source's content root; bounds include resolution. */
553
+ contentRoot?: string;
554
+ /** Raw-file line of `body`'s first line, minus one (default `0`). */
555
+ lineOffset?: number;
556
+ }
557
+ ): Promise<IncludeExpansion> => {
558
+ const ctx: ExpandContext = {
559
+ contentRoot: options.contentRoot,
560
+ errors: [],
561
+ includes: new Set(),
562
+ };
563
+ const { lines, origins } = await expandLines(
564
+ body,
565
+ options.sourcePath,
566
+ options.lineOffset ?? 0,
567
+ ctx,
568
+ [options.sourcePath]
569
+ );
570
+ return {
571
+ errors: ctx.errors,
572
+ includes: [...ctx.includes],
573
+ origins,
574
+ text: lines.join("\n"),
575
+ };
576
+ };
577
+
578
+ /**
579
+ * Resolve one include statement to its fully-expanded markdown text — the
580
+ * render plugin's entry point (it finds the statements as AST nodes and only
581
+ * needs each target spliced). Shares every semantic with
582
+ * {@link expandIncludes}.
583
+ */
584
+ export const expandIncludeTarget = async (
585
+ statement: IncludeStatement,
586
+ options: { sourcePath: string; contentRoot?: string }
587
+ ): Promise<{ text: string; errors: Diagnostic[] } | { error: Diagnostic }> => {
588
+ const ctx: ExpandContext = {
589
+ contentRoot: options.contentRoot,
590
+ errors: [],
591
+ includes: new Set(),
592
+ };
593
+ const expanded = await expandStatement(
594
+ statement,
595
+ options.sourcePath,
596
+ 0,
597
+ ctx,
598
+ [options.sourcePath]
599
+ );
600
+ if ("error" in expanded) {
601
+ return { error: expanded.error };
602
+ }
603
+ // Nested statements that errored stay verbatim inside the splice (matching
604
+ // the string-level surfaces); they're reported so the render can warn.
605
+ return { errors: ctx.errors, text: expanded.lines.join("\n") };
606
+ };
607
+
608
+ /**
609
+ * Invert the scan's page → included-partials edges into the partial →
610
+ * including-pages map `includeHmrPlugin` reads (`generated/includes.json`),
611
+ * so editing a partial invalidates every page that splices it. Localized
612
+ * pages share a source path, hence the dedupe. Shared by `generateRuntime`
613
+ * and `eject`, whose configs both wire the plugin at the same path.
614
+ */
615
+ export const buildIncludeGraph = (
616
+ pages: { sourcePath?: string; includes?: string[] }[]
617
+ ) => {
618
+ const graph: Record<string, string[]> = {};
619
+ for (const page of pages) {
620
+ const { sourcePath } = page;
621
+ if (!sourcePath) {
622
+ continue;
623
+ }
624
+ for (const partial of page.includes ?? []) {
625
+ const includers = (graph[partial] ??= []);
626
+ if (!includers.includes(sourcePath)) {
627
+ includers.push(sourcePath);
628
+ }
629
+ }
630
+ }
631
+ return graph;
632
+ };
@@ -63,10 +63,30 @@ export const parseGitLog = (output: string): Map<string, string> => {
63
63
  return times;
64
64
  };
65
65
 
66
+ /**
67
+ * The toplevel of the repository containing `root`, or null when git is
68
+ * unavailable or the project isn't a repo. Callers use it to decide which
69
+ * content roots a `git log` pathspec can cover at all — a root outside the
70
+ * repository would fail the log outright and can never yield dates.
71
+ */
72
+ export const gitRepositoryRoot = (root: string): string | null => {
73
+ try {
74
+ return execFileSync(
75
+ // oxlint-disable-next-line sonarjs/no-os-command-from-path -- git is a required dev-tool dependency resolved from PATH
76
+ "git",
77
+ ["-C", root, "rev-parse", "--show-toplevel"],
78
+ // stderr silenced: outside a repository the probe fails by design.
79
+ { encoding: "utf-8", env: gitEnv(), stdio: ["ignore", "pipe", "ignore"] }
80
+ ).trim();
81
+ } catch {
82
+ return null;
83
+ }
84
+ };
85
+
66
86
  /**
67
87
  * Resolve each source file's last-modified date from git history, keyed by
68
88
  * absolute source path. Runs a single `git log` over the given content roots
69
- * (each filesystem source's own root, which may diverge from `content.root`)
89
+ * (each local source's own root, which may diverge from `content.root`)
70
90
  * and maps repo-root-relative paths back to the given absolute paths
71
91
  * (monorepo-safe via `rev-parse --show-toplevel`). Returns an empty map if git
72
92
  * is unavailable or the project isn't a repo — the feature then simply shows
@@ -75,20 +95,25 @@ export const parseGitLog = (output: string): Map<string, string> => {
75
95
  export const gitLastModifiedTimes = (
76
96
  root: string,
77
97
  contentRoots: string[],
78
- sourcePaths: string[]
98
+ sourcePaths: string[],
99
+ repositoryRoot?: string | null
79
100
  ): Map<string, string> => {
80
- // Nothing to date — don't pay for a git scan (an empty pathspec list would
81
- // log the entire repository).
82
- if (sourcePaths.length === 0) {
101
+ // Nothing to date, or nowhere bounded to look — either way, don't pay for a
102
+ // git scan. Both guards matter: `git log -- ` with no pathspec logs the
103
+ // entire repository, which is what an all-staged project produces (a staged
104
+ // source contributes no content root, yet its entries can still carry a
105
+ // `sourcePath`).
106
+ if (sourcePaths.length === 0 || contentRoots.length === 0) {
107
+ return new Map();
108
+ }
109
+ // The caller that bounded `contentRoots` already resolved the repo root;
110
+ // reuse it rather than spawning `rev-parse` a second time per scan.
111
+ const gitRoot =
112
+ repositoryRoot === undefined ? gitRepositoryRoot(root) : repositoryRoot;
113
+ if (gitRoot === null) {
83
114
  return new Map();
84
115
  }
85
116
  try {
86
- const gitRoot = execFileSync(
87
- // oxlint-disable-next-line sonarjs/no-os-command-from-path -- git is a required dev-tool dependency resolved from PATH
88
- "git",
89
- ["-C", root, "rev-parse", "--show-toplevel"],
90
- { encoding: "utf-8", env: gitEnv() }
91
- ).trim();
92
117
  const output = execFileSync(
93
118
  // oxlint-disable-next-line sonarjs/no-os-command-from-path -- git is a required dev-tool dependency resolved from PATH
94
119
  "git",