blume 1.5.2 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +85 -0
- package/dist/cli/index.js +3639 -1377
- package/dist/cli/index.js.map +103 -91
- package/dist/types/ai/component-markdown.d.ts +79 -0
- package/dist/types/components/layout/nav-utils.d.ts +60 -0
- package/dist/types/core/base-path.d.ts +9 -0
- package/dist/types/core/config-input.d.ts +206 -4
- package/dist/types/core/config.d.ts +6 -4
- package/dist/types/core/data.d.ts +23 -1
- package/dist/types/core/github.d.ts +35 -0
- package/dist/types/core/i18n-ui.d.ts +8 -0
- package/dist/types/core/navigation.d.ts +69 -0
- package/dist/types/core/schema.d.ts +117 -1
- package/dist/types/core/sources/types.d.ts +31 -6
- package/dist/types/core/types.d.ts +23 -2
- package/dist/types/markdown/features.d.ts +21 -0
- package/dist/types/openapi/references.d.ts +21 -1
- package/dist/types/seo/jsonld.d.ts +105 -0
- package/dist/types/theme/fonts.d.ts +34 -4
- package/docs/_snippets/include-demo.mdx +7 -0
- package/docs/advanced/api-reference.mdx +3 -3
- package/docs/advanced/custom-pages.mdx +1 -1
- package/docs/advanced/graphql.mdx +84 -0
- package/docs/advanced/meta.ts +8 -1
- package/docs/configuration/ai.mdx +21 -3
- package/docs/configuration/index.mdx +24 -0
- package/docs/configuration/search.mdx +13 -1
- package/docs/configuration/seo.mdx +27 -0
- package/docs/configuration/theming.mdx +17 -0
- package/docs/content/components.mdx +7 -0
- package/docs/content/includes.mdx +68 -0
- package/docs/content/meta.ts +1 -0
- package/docs/content/navigation.mdx +25 -0
- package/docs/content/sources.mdx +42 -1
- package/docs/content/syntax.mdx +69 -1
- package/docs/content/versioning.mdx +15 -9
- package/docs/reference/cli.mdx +2 -1
- package/package.json +23 -14
- package/skills/blume-migrate/SKILL.md +16 -7
- package/skills/blume-migrate/references/docusaurus.md +5 -3
- package/skills/blume-migrate/references/fumadocs.md +10 -2
- package/skills/blume-migrate/references/mintlify.md +3 -2
- package/skills/blume-migrate/references/nextra.md +2 -2
- package/skills/blume-migrate/references/starlight.md +1 -1
- package/src/ai/agent-readability.ts +2 -1
- package/src/ai/ask-data.ts +2 -1
- package/src/ai/component-markdown.ts +199 -36
- package/src/ai/llms.ts +93 -6
- package/src/ai/markdown.ts +2 -2
- package/src/ai/mcp/discovery.ts +10 -2
- package/src/ai/mcp/server.ts +74 -2
- package/src/astro/generate.ts +183 -116
- package/src/astro/include-hmr.ts +81 -0
- package/src/astro/include-refresh.ts +0 -0
- package/src/astro/index.ts +3 -5
- package/src/astro/templates.ts +125 -76
- package/src/cli/commands/build.ts +84 -15
- package/src/cli/init/questions.ts +1 -0
- package/src/cli/init/scaffold.ts +27 -4
- package/src/components/colors.ts +142 -0
- package/src/components/content/Badge.astro +5 -12
- package/src/components/content/Callout.astro +19 -36
- package/src/components/content/Card.astro +15 -21
- package/src/components/content/Component.astro +10 -1
- package/src/components/content/GithubInfo.astro +28 -9
- package/src/components/content/Tabs.astro +27 -5
- package/src/components/content/github-info.ts +20 -5
- package/src/components/dropdown-dismiss.ts +122 -0
- package/src/components/layout/Fonts.astro +15 -8
- package/src/components/layout/Header.astro +44 -0
- package/src/components/layout/LanguageSwitcher.astro +9 -1
- package/src/components/layout/NavSelector.astro +12 -3
- package/src/components/layout/NavTree.astro +6 -18
- package/src/components/layout/PageActions.astro +29 -8
- package/src/components/layout/PageLayout.astro +10 -1
- package/src/components/layout/ReferenceLayout.astro +6 -1
- package/src/components/layout/RootLayout.astro +46 -15
- package/src/components/layout/Search.astro +36 -4
- package/src/components/layout/TableOfContents.astro +8 -2
- package/src/components/layout/head-scripts.ts +53 -1
- package/src/components/openapi/ApiOverview.astro +13 -3
- package/src/components/openapi/AsyncApiOperation.astro +7 -14
- package/src/components/openapi/GraphqlChip.astro +33 -0
- package/src/components/openapi/GraphqlFieldsTable.astro +111 -0
- package/src/components/openapi/GraphqlOperation.astro +186 -0
- package/src/components/openapi/GraphqlType.astro +154 -0
- package/src/components/openapi/MethodBadge.astro +3 -14
- package/src/components/openapi/Operation.astro +12 -5
- package/src/components/openapi/OperationPanel.astro +43 -0
- package/src/components/openapi/RequestPanel.astro +5 -10
- package/src/components/openapi/Responses.astro +1 -16
- package/src/components/openapi/graphql-helpers.ts +466 -0
- package/src/components/openapi/playground-client.ts +15 -0
- package/src/components/openapi/sample-panels.ts +45 -0
- package/src/components/openapi/snippets.ts +13 -35
- package/src/core/base-path.ts +11 -0
- package/src/core/config-input.ts +209 -2
- package/src/core/config.ts +6 -4
- package/src/core/content-assets.ts +15 -4
- package/src/core/data.ts +18 -2
- package/src/core/diagnostics.ts +8 -0
- package/src/core/frontmatter.ts +20 -8
- package/src/core/github.ts +71 -0
- package/src/core/graph.ts +22 -8
- package/src/core/heading-markers.ts +96 -0
- package/src/core/i18n-ui.ts +11 -0
- package/src/core/includes.ts +632 -0
- package/src/core/last-modified.ts +36 -11
- package/src/core/links.ts +79 -13
- package/src/core/meta.ts +2 -1
- package/src/core/nav-diagnostics.ts +11 -2
- package/src/core/navigation.ts +27 -6
- package/src/core/project-graph.ts +61 -9
- package/src/core/schema.ts +226 -35
- package/src/core/server-features.ts +5 -9
- package/src/core/sources/github-releases.ts +2 -2
- package/src/core/sources/normalize.ts +502 -115
- package/src/core/sources/notion.ts +43 -8
- package/src/core/sources/obsidian.ts +1038 -0
- package/src/core/sources/read.ts +36 -1
- package/src/core/sources/resolve.ts +34 -1
- package/src/core/sources/types.ts +28 -6
- package/src/core/sources/watch.ts +12 -8
- package/src/core/tsconfig-aliases.ts +48 -35
- package/src/core/types.ts +25 -2
- package/src/core/ui-packs/ar.ts +1 -0
- package/src/core/ui-packs/bg.ts +2 -0
- package/src/core/ui-packs/bn.ts +1 -0
- package/src/core/ui-packs/ca.ts +2 -0
- package/src/core/ui-packs/cs.ts +1 -0
- package/src/core/ui-packs/da.ts +1 -0
- package/src/core/ui-packs/de.ts +2 -0
- package/src/core/ui-packs/el.ts +2 -0
- package/src/core/ui-packs/es.ts +2 -0
- package/src/core/ui-packs/fa.ts +1 -0
- package/src/core/ui-packs/fi.ts +1 -0
- package/src/core/ui-packs/fr.ts +2 -0
- package/src/core/ui-packs/he.ts +1 -0
- package/src/core/ui-packs/hi.ts +1 -0
- package/src/core/ui-packs/hr.ts +2 -0
- package/src/core/ui-packs/hu.ts +2 -0
- package/src/core/ui-packs/id.ts +2 -0
- package/src/core/ui-packs/it.ts +1 -0
- package/src/core/ui-packs/ja.ts +2 -0
- package/src/core/ui-packs/ko.ts +2 -0
- package/src/core/ui-packs/nl.ts +2 -0
- package/src/core/ui-packs/no.ts +2 -0
- package/src/core/ui-packs/pl.ts +2 -0
- package/src/core/ui-packs/pt-br.ts +2 -0
- package/src/core/ui-packs/pt.ts +2 -0
- package/src/core/ui-packs/ro.ts +2 -0
- package/src/core/ui-packs/ru.ts +2 -0
- package/src/core/ui-packs/sk.ts +1 -0
- package/src/core/ui-packs/sr.ts +1 -0
- package/src/core/ui-packs/sv.ts +2 -0
- package/src/core/ui-packs/th.ts +1 -0
- package/src/core/ui-packs/tr.ts +2 -0
- package/src/core/ui-packs/uk.ts +2 -0
- package/src/core/ui-packs/vi.ts +1 -0
- package/src/core/ui-packs/zh-tw.ts +1 -0
- package/src/core/ui-packs/zh.ts +1 -0
- package/src/core/version-cut.ts +21 -3
- package/src/core/yaml.ts +26 -0
- package/src/deploy/function-bundle.ts +251 -0
- package/src/eval/schema.ts +3 -1
- package/src/markdown/code-title.ts +22 -16
- package/src/markdown/features.ts +21 -0
- package/src/markdown/fence-meta.ts +50 -0
- package/src/markdown/heading-anchors.ts +198 -37
- package/src/markdown/include.ts +247 -0
- package/src/markdown/index.ts +43 -34
- package/src/markdown/language-icon.ts +2 -2
- package/src/markdown/mdast.ts +7 -3
- package/src/markdown/ts2js.ts +264 -0
- package/src/openapi/asyncapi.ts +4 -1
- package/src/openapi/graphql-build.ts +293 -0
- package/src/openapi/graphql.ts +212 -0
- package/src/openapi/model.ts +38 -5
- package/src/openapi/parse.ts +34 -0
- package/src/openapi/proxy.ts +30 -5
- package/src/openapi/references.ts +89 -13
- package/src/openapi/render-mdx.ts +48 -8
- package/src/openapi/scalar.ts +5 -12
- package/src/openapi/source.ts +91 -23
- package/src/registry/eject.ts +11 -0
- package/src/search/documents.ts +229 -37
- package/src/search/orama-index.ts +9 -5
- package/src/seo/jsonld.ts +293 -51
- package/src/theme/code-block-padding.ts +16 -0
- package/src/theme/entry.ts +65 -11
- package/src/theme/fonts.ts +189 -16
- package/src/translate/prompts.ts +2 -0
- package/src/translate/run.ts +7 -0
- package/src/translate/work-list.ts +0 -0
|
@@ -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
|
+
* `` 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
|
|
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
|
|
81
|
-
// log
|
|
82
|
-
|
|
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",
|