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
|
@@ -1,24 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* after Markdown is turned into hast and
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Heading ids, trailing markers, and self-linking anchors. A Satteri hast
|
|
3
|
+
* plugin runs after Markdown is turned into hast and, for every heading:
|
|
4
|
+
*
|
|
5
|
+
* - parses trailing markers (`[#custom-id]`, `{#custom-id}`, `[!toc]`, `[toc]`
|
|
6
|
+
* — see `core/heading-markers.ts`) and strips them from the rendered text;
|
|
7
|
+
|
|
8
|
+
* - assigns the anchor `id` (the `[#custom-id]` pin, else a `github-slugger`
|
|
9
|
+
* slug of the marker-free text);
|
|
10
|
+
* - wraps `<h2>`–`<h6>` content in an `<a href="#slug">` so a reader can click
|
|
11
|
+
* the heading to copy, bookmark, or share a link straight to that section
|
|
12
|
+
* (`<h1>` — the page title — is slugged for parity but left unwrapped, and
|
|
13
|
+
* `wrap: false` turns the anchor links off without losing the markers).
|
|
7
14
|
*
|
|
8
15
|
* Satteri's own `heading-ids` plugin (which assigns the `id` used by the table
|
|
9
16
|
* of contents) runs *after* every user hast plugin, and it reuses an `id` that
|
|
10
|
-
* is already present rather than re-slugging. So this plugin is the
|
|
11
|
-
* id setter: it slugs each heading with the same algorithm (a
|
|
12
|
-
* `github-slugger`, the library Satteri and rehype-slug both use)
|
|
13
|
-
* `id`, which `heading-ids` then adopts — keeping the in-page
|
|
14
|
-
* heading's `id`, and the TOC entry in lockstep. To match
|
|
15
|
-
* disambiguation (`setup`, `setup-1`, …) exactly, it
|
|
16
|
-
* `<h1>`–`<h6>` in document order even though only
|
|
17
|
+
* is already present rather than re-slugging. So this plugin is the
|
|
18
|
+
* authoritative id setter: it slugs each heading with the same algorithm (a
|
|
19
|
+
* per-document `github-slugger`, the library Satteri and rehype-slug both use)
|
|
20
|
+
* and writes the `id`, which `heading-ids` then adopts — keeping the in-page
|
|
21
|
+
* anchor, the heading's `id`, and the TOC entry in lockstep. To match
|
|
22
|
+
* Satteri's duplicate disambiguation (`setup`, `setup-1`, …) exactly, it
|
|
23
|
+
* advances the slugger over `<h1>`–`<h6>` in document order even though only
|
|
24
|
+
* `<h2>`–`<h6>` get wrapped.
|
|
25
|
+
*
|
|
26
|
+
* TOC visibility flows out through the render's frontmatter: the slugs of
|
|
27
|
+
* `[!toc]` headings are pushed onto `frontmatter[TOC_HIDDEN_KEY]`, which Astro
|
|
28
|
+
* surfaces as `remarkPluginFrontmatter` so the page template can filter them
|
|
29
|
+
* out of the headings list. `[toc]` headings render with the
|
|
30
|
+
* `blume-toc-only` class (visually hidden, still a live anchor target) so the
|
|
31
|
+
* TOC entry has somewhere to scroll to.
|
|
17
32
|
*/
|
|
18
33
|
|
|
19
34
|
import { satteriCollectHastText } from "@astrojs/markdown-satteri";
|
|
20
35
|
import GithubSlugger from "github-slugger";
|
|
21
36
|
|
|
37
|
+
import {
|
|
38
|
+
occupySlug,
|
|
39
|
+
parseHeadingMarkers,
|
|
40
|
+
TOC_HIDDEN_KEY,
|
|
41
|
+
} from "../core/heading-markers.ts";
|
|
42
|
+
|
|
22
43
|
/** A hast property value: an attribute primitive or a token list. */
|
|
23
44
|
type HastPropertyValue = string | number | boolean | (string | number)[];
|
|
24
45
|
|
|
@@ -50,10 +71,18 @@ export interface HeadingAnchorPlugin {
|
|
|
50
71
|
};
|
|
51
72
|
}
|
|
52
73
|
|
|
74
|
+
export interface HeadingAnchorOptions {
|
|
75
|
+
/** Wrap `<h2>`–`<h6>` in self-linking anchors (`markdown.headingAnchors`). */
|
|
76
|
+
wrap?: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
53
79
|
/** Headings slugged for id parity with Satteri; only a subset gets wrapped. */
|
|
54
80
|
const HEADINGS = ["h1", "h2", "h3", "h4", "h5", "h6"];
|
|
55
81
|
const WRAPPED = new Set(["h2", "h3", "h4", "h5", "h6"]);
|
|
56
82
|
|
|
83
|
+
/** The class that renders a `[toc]`-only heading as an invisible anchor. */
|
|
84
|
+
const TOC_ONLY_CLASS = "blume-toc-only";
|
|
85
|
+
|
|
57
86
|
/** True if the subtree already contains an `<a>`, so wrapping would nest links. */
|
|
58
87
|
const containsAnchor = (node: HastNode): boolean => {
|
|
59
88
|
for (const child of node.children ?? []) {
|
|
@@ -64,61 +93,193 @@ const containsAnchor = (node: HastNode): boolean => {
|
|
|
64
93
|
return false;
|
|
65
94
|
};
|
|
66
95
|
|
|
67
|
-
// One
|
|
68
|
-
// page, but slug disambiguation
|
|
69
|
-
// `astro` data object is a stable, unique key
|
|
70
|
-
// dropped once the render is collected, so this
|
|
96
|
+
// One state record per document render. The plugin instance is shared across
|
|
97
|
+
// every page, but slug disambiguation (and the hidden-heading list) must reset
|
|
98
|
+
// per document; the render-scoped `astro` data object is a stable, unique key
|
|
99
|
+
// for one render (entries are dropped once the render is collected, so this
|
|
100
|
+
// never leaks).
|
|
101
|
+
interface RenderState {
|
|
102
|
+
/** Slugs of `[!toc]` headings, shared by reference with the frontmatter. */
|
|
103
|
+
hidden: string[];
|
|
104
|
+
slugger: GithubSlugger;
|
|
105
|
+
}
|
|
106
|
+
|
|
71
107
|
const FALLBACK_SCOPE = {};
|
|
72
|
-
const
|
|
108
|
+
const states = new WeakMap<object, RenderState>();
|
|
73
109
|
|
|
74
|
-
const
|
|
110
|
+
const stateFor = (ctx: HastContext): RenderState => {
|
|
75
111
|
const scope = ctx.data?.astro ?? ctx.data ?? FALLBACK_SCOPE;
|
|
76
|
-
const existing =
|
|
112
|
+
const existing = states.get(scope);
|
|
77
113
|
if (existing) {
|
|
78
114
|
return existing;
|
|
79
115
|
}
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
116
|
+
const state: RenderState = { hidden: [], slugger: new GithubSlugger() };
|
|
117
|
+
states.set(scope, state);
|
|
118
|
+
// Surface the hidden list on the render's frontmatter (Astro's
|
|
119
|
+
// `remarkPluginFrontmatter`) by reference, so slugs pushed later in the
|
|
120
|
+
// document flow through. Assigning a fresh array on state creation also
|
|
121
|
+
// clears a stale list left by a previous render of the same entry.
|
|
122
|
+
const frontmatter = ctx.data?.astro?.frontmatter;
|
|
123
|
+
if (frontmatter) {
|
|
124
|
+
frontmatter[TOC_HIDDEN_KEY] = state.hidden;
|
|
125
|
+
}
|
|
126
|
+
return state;
|
|
83
127
|
};
|
|
84
128
|
|
|
85
129
|
/** Whether a heading already carries a usable string `id`. */
|
|
86
130
|
const isStringId = (value: HastPropertyValue | undefined): value is string =>
|
|
87
131
|
typeof value === "string";
|
|
88
132
|
|
|
133
|
+
/** A parsed heading: marker-free children plus what the markers pinned. */
|
|
134
|
+
interface StrippedHeading {
|
|
135
|
+
children: HastNode[];
|
|
136
|
+
id?: string;
|
|
137
|
+
/** Characters the marker strip removed from the heading's text content. */
|
|
138
|
+
strippedLength: number;
|
|
139
|
+
toc?: "hide" | "only";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Strip trailing markers from a heading's last direct text child. Markers only
|
|
144
|
+
* count at the very end of the heading, so a heading ending in inline code,
|
|
145
|
+
* emphasis, or an expression has no marker position — mirroring the scan-time
|
|
146
|
+
* source scanner, which likewise only matches markers that end the raw line.
|
|
147
|
+
*/
|
|
148
|
+
const stripMarkers = (node: HastNode): StrippedHeading => {
|
|
149
|
+
const children = node.children ?? [];
|
|
150
|
+
const last = children.at(-1);
|
|
151
|
+
const none = { children, strippedLength: 0 };
|
|
152
|
+
if (last?.type !== "text" || last.value === undefined) {
|
|
153
|
+
return none;
|
|
154
|
+
}
|
|
155
|
+
const markers = parseHeadingMarkers(last.value);
|
|
156
|
+
if (markers.id === undefined && markers.toc === undefined) {
|
|
157
|
+
return none;
|
|
158
|
+
}
|
|
159
|
+
const kept =
|
|
160
|
+
markers.text === ""
|
|
161
|
+
? children.slice(0, -1)
|
|
162
|
+
: [...children.slice(0, -1), { ...last, value: markers.text }];
|
|
163
|
+
// A heading that is nothing but markers (`## [toc]`) keeps them as literal
|
|
164
|
+
// text: with no heading text left there is nothing to annotate, and
|
|
165
|
+
// stripping would leave an invisible empty element with an empty id and a
|
|
166
|
+
// blank TOC entry. The scan-time scanner and the search extractor mirror
|
|
167
|
+
// this rule.
|
|
168
|
+
if (kept.length === 0) {
|
|
169
|
+
return none;
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
children: kept,
|
|
173
|
+
id: markers.id,
|
|
174
|
+
strippedLength: last.value.length - markers.text.length,
|
|
175
|
+
toc: markers.toc,
|
|
176
|
+
};
|
|
177
|
+
};
|
|
178
|
+
|
|
89
179
|
/** The slug for a heading, mirroring Satteri's `heading-ids` exactly. */
|
|
90
180
|
const slugFor = (
|
|
91
181
|
node: HastNode,
|
|
92
182
|
ctx: HastContext,
|
|
93
|
-
slugger: GithubSlugger
|
|
183
|
+
slugger: GithubSlugger,
|
|
184
|
+
stripped: StrippedHeading
|
|
94
185
|
): string => {
|
|
95
|
-
|
|
186
|
+
if (stripped.id !== undefined) {
|
|
187
|
+
// Pinning occupies the id, so a later heading whose auto-slug collides
|
|
188
|
+
// disambiguates (`setup` → `setup-1`) instead of duplicating the anchor.
|
|
189
|
+
occupySlug(slugger, stripped.id);
|
|
190
|
+
return stripped.id;
|
|
191
|
+
}
|
|
192
|
+
const existingId = node.properties?.id;
|
|
193
|
+
if (isStringId(existingId)) {
|
|
194
|
+
return existingId;
|
|
195
|
+
}
|
|
196
|
+
// The marker suffix is a trailing slice of the text content, so the
|
|
197
|
+
// marker-free text is the content minus exactly what the strip removed.
|
|
198
|
+
const fullText = ctx.textContent(node);
|
|
199
|
+
const rawText = stripped.strippedLength
|
|
200
|
+
? fullText.slice(0, fullText.length - stripped.strippedLength)
|
|
201
|
+
: fullText;
|
|
96
202
|
// `frontmatter`-interpolated MDX headings (`## {frontmatter.title}`) need the
|
|
97
203
|
// resolved value; the helper is the same one `heading-ids` defers to.
|
|
98
204
|
// SAFETY: HastNode is a structural subset of the hast element shape the
|
|
99
|
-
// helper walks (children/type/value), so the
|
|
205
|
+
// helper walks (children/type/value), so the node always fits.
|
|
100
206
|
const text = rawText.includes("frontmatter")
|
|
101
207
|
? satteriCollectHastText(
|
|
102
|
-
|
|
208
|
+
{
|
|
209
|
+
...node,
|
|
210
|
+
children: stripped.children,
|
|
211
|
+
} as Parameters<typeof satteriCollectHastText>[0],
|
|
103
212
|
ctx.data?.astro?.frontmatter ?? {}
|
|
104
213
|
)
|
|
105
214
|
: rawText;
|
|
106
|
-
|
|
107
|
-
return isStringId(existingId) ? existingId : slugger.slug(text);
|
|
215
|
+
return slugger.slug(text);
|
|
108
216
|
};
|
|
109
217
|
|
|
110
|
-
/**
|
|
111
|
-
|
|
218
|
+
/** The heading's class list with `blume-toc-only` appended. */
|
|
219
|
+
const withTocOnlyClass = (
|
|
220
|
+
value: HastPropertyValue | undefined
|
|
221
|
+
): (string | number)[] => {
|
|
222
|
+
if (Array.isArray(value)) {
|
|
223
|
+
return [...value, TOC_ONLY_CLASS];
|
|
224
|
+
}
|
|
225
|
+
return isStringId(value) && value !== ""
|
|
226
|
+
? [value, TOC_ONLY_CLASS]
|
|
227
|
+
: [TOC_ONLY_CLASS];
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
/** True if the heading already carries the `[toc]`-only class (a re-visit). */
|
|
231
|
+
const hasTocOnlyClass = (node: HastNode): boolean => {
|
|
232
|
+
const value = node.properties?.className;
|
|
233
|
+
return Array.isArray(value)
|
|
234
|
+
? value.includes(TOC_ONLY_CLASS)
|
|
235
|
+
: value === TOC_ONLY_CLASS;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Build the plugin. Always parses markers and assigns ids; `wrap: false` only
|
|
240
|
+
* disables the self-linking anchor wrap on `<h2>`–`<h6>`.
|
|
241
|
+
*/
|
|
242
|
+
export const headingAnchorPlugin = (
|
|
243
|
+
options: HeadingAnchorOptions = {}
|
|
244
|
+
): HeadingAnchorPlugin => ({
|
|
112
245
|
element: {
|
|
113
246
|
filter: HEADINGS,
|
|
114
247
|
visit(node, ctx) {
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
248
|
+
const state = stateFor(ctx);
|
|
249
|
+
const stripped = stripMarkers(node);
|
|
250
|
+
const slug = slugFor(node, ctx, state.slugger, stripped);
|
|
251
|
+
if (stripped.toc === "hide") {
|
|
252
|
+
state.hidden.push(slug);
|
|
253
|
+
}
|
|
254
|
+
const tocOnly = stripped.toc === "only" || hasTocOnlyClass(node);
|
|
255
|
+
const wrap =
|
|
256
|
+
options.wrap !== false &&
|
|
257
|
+
node.tagName !== undefined &&
|
|
258
|
+
WRAPPED.has(node.tagName) &&
|
|
259
|
+
slug !== "" &&
|
|
260
|
+
!tocOnly &&
|
|
261
|
+
!containsAnchor(node);
|
|
119
262
|
if (!wrap) {
|
|
120
|
-
//
|
|
121
|
-
//
|
|
263
|
+
// Marker-free headings mutate in place; a stripped or `[toc]`-only one
|
|
264
|
+
// needs its children (and class) replaced, so it re-emits as a new
|
|
265
|
+
// element carrying the original children as refs.
|
|
266
|
+
if (stripped.strippedLength || (tocOnly && !hasTocOnlyClass(node))) {
|
|
267
|
+
const properties = tocOnly
|
|
268
|
+
? {
|
|
269
|
+
...node.properties,
|
|
270
|
+
className: withTocOnlyClass(node.properties?.className),
|
|
271
|
+
id: slug,
|
|
272
|
+
}
|
|
273
|
+
: { ...node.properties, id: slug };
|
|
274
|
+
return {
|
|
275
|
+
children: stripped.children,
|
|
276
|
+
properties,
|
|
277
|
+
tagName: node.tagName,
|
|
278
|
+
type: "element",
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
// Unwrapped headings (h1, an empty slug, or one that already links)
|
|
282
|
+
// still need the id so `heading-ids` adopts it instead of re-slugging.
|
|
122
283
|
if (!isStringId(node.properties?.id)) {
|
|
123
284
|
ctx.setProperty(node, "id", slug);
|
|
124
285
|
}
|
|
@@ -129,7 +290,7 @@ export const headingAnchorPlugin = (): HeadingAnchorPlugin => ({
|
|
|
129
290
|
return {
|
|
130
291
|
children: [
|
|
131
292
|
{
|
|
132
|
-
children:
|
|
293
|
+
children: stripped.children,
|
|
133
294
|
properties: {
|
|
134
295
|
className: ["blume-heading-anchor"],
|
|
135
296
|
href: `#${slug}`,
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
|
|
3
|
+
import { resolve } from "pathe";
|
|
4
|
+
|
|
5
|
+
import type { IncludeStatement } from "../core/includes.ts";
|
|
6
|
+
import {
|
|
7
|
+
advanceHtmlCommentState,
|
|
8
|
+
expandIncludeTarget,
|
|
9
|
+
hasIncludeStatements,
|
|
10
|
+
parseIncludeLine,
|
|
11
|
+
} from "../core/includes.ts";
|
|
12
|
+
import type { MdastNode, MdastValue } from "./mdast.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Sätteri MDAST plugin for `<include>` statements — the render half of
|
|
16
|
+
* content includes (see `core/includes.ts` for the semantics and the
|
|
17
|
+
* string-level half that powers search, llms.txt, and the `.md` mirrors).
|
|
18
|
+
* Runs first in the plugin chain: mutations apply before the next plugin
|
|
19
|
+
* visits, so callouts, mermaid, and math inside a spliced partial transform
|
|
20
|
+
* exactly like inline content. Replacements use Sätteri's `{ raw }` escape
|
|
21
|
+
* hatch, so the target is parsed in the including page's format.
|
|
22
|
+
*
|
|
23
|
+
* In `.mdx`, a lowercase `<include>` arrives as an `mdxJsxFlowElement`. In
|
|
24
|
+
* plain `.md`, `<include>` isn't a known block-level HTML tag, so CommonMark
|
|
25
|
+
* parses the statement line as a *paragraph* holding inline `html` nodes —
|
|
26
|
+
* the paragraph visitor slices the statement back out of the source by
|
|
27
|
+
* position. A statement swallowed into a block `html` node (adjacent to real
|
|
28
|
+
* block HTML) is handled line-wise too. Statements must occupy their own
|
|
29
|
+
* line — includes are block-level.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** The attribute slice of an `mdxJsxAttribute` node the plugin reads. The
|
|
33
|
+
* index signature keeps the array assignable to `MdastNode`'s `MdastValue`
|
|
34
|
+
* properties. */
|
|
35
|
+
interface JsxAttributeNode {
|
|
36
|
+
[key: string]: MdastValue;
|
|
37
|
+
type?: string;
|
|
38
|
+
name?: string;
|
|
39
|
+
value?: MdastValue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The `mdxJsxFlowElement` slice the plugin reads. */
|
|
43
|
+
interface JsxFlowNode extends MdastNode {
|
|
44
|
+
name?: string | null;
|
|
45
|
+
attributes?: JsxAttributeNode[];
|
|
46
|
+
position?: {
|
|
47
|
+
start?: { line?: number };
|
|
48
|
+
end?: { line?: number };
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The raw-HTML node slice (`.md` pages) the plugin reads. */
|
|
53
|
+
interface HtmlNode extends MdastNode {
|
|
54
|
+
value: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The position slice used to recover a paragraph's raw source text. */
|
|
58
|
+
interface PositionedNode extends MdastNode {
|
|
59
|
+
position?: {
|
|
60
|
+
start?: { offset?: number };
|
|
61
|
+
end?: { offset?: number };
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The visitor-context slice the plugin uses (see `mdast.ts` for the model). */
|
|
66
|
+
interface IncludeVisitorContext {
|
|
67
|
+
fileURL: URL | undefined;
|
|
68
|
+
source: string;
|
|
69
|
+
replaceNode: (node: MdastNode, replacement: { raw: string }) => void;
|
|
70
|
+
textContent: (node: MdastNode) => string;
|
|
71
|
+
report: (report: {
|
|
72
|
+
message: string;
|
|
73
|
+
node?: MdastNode;
|
|
74
|
+
severity?: "error" | "warning" | "info";
|
|
75
|
+
}) => void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A visible stand-in for a statement that failed to resolve, so a broken
|
|
79
|
+
* include can't silently render as nothing in dev. */
|
|
80
|
+
const errorBlock = (message: string): string =>
|
|
81
|
+
`> **Include error:** ${message}`;
|
|
82
|
+
|
|
83
|
+
export interface IncludePluginOptions {
|
|
84
|
+
/** The docs content root; bounds include resolution when set. */
|
|
85
|
+
contentRoot?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A plain string attribute value; expression values don't carry a path. */
|
|
89
|
+
const isStringValue = (value: MdastValue): value is string =>
|
|
90
|
+
typeof value === "string";
|
|
91
|
+
|
|
92
|
+
const statementFromJsx = (
|
|
93
|
+
node: JsxFlowNode,
|
|
94
|
+
ctx: IncludeVisitorContext
|
|
95
|
+
): IncludeStatement => {
|
|
96
|
+
const attributes: IncludeStatement["attributes"] = {};
|
|
97
|
+
for (const attr of node.attributes ?? []) {
|
|
98
|
+
if (attr.type !== "mdxJsxAttribute" || !isStringValue(attr.value)) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (attr.name === "lang" && attr.value) {
|
|
102
|
+
attributes.lang = attr.value;
|
|
103
|
+
}
|
|
104
|
+
if (attr.name === "meta" && attr.value) {
|
|
105
|
+
attributes.meta = attr.value;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { attributes, target: ctx.textContent(node).trim() };
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const includePlugin = (options: IncludePluginOptions = {}) => {
|
|
112
|
+
// An ejected config carries a project-relative content root ("docs"); the
|
|
113
|
+
// generated `.blume` config an absolute one. Resolve once — `resolve` is a
|
|
114
|
+
// no-op for absolute paths and anchors relative ones at the process cwd,
|
|
115
|
+
// which is the project root wherever Astro runs.
|
|
116
|
+
const contentRoot = options.contentRoot
|
|
117
|
+
? resolve(options.contentRoot)
|
|
118
|
+
: undefined;
|
|
119
|
+
|
|
120
|
+
const splice = async (
|
|
121
|
+
node: MdastNode,
|
|
122
|
+
statement: IncludeStatement,
|
|
123
|
+
ctx: IncludeVisitorContext
|
|
124
|
+
): Promise<string> => {
|
|
125
|
+
if (!statement.target) {
|
|
126
|
+
const message = "<include> needs a file path as its text.";
|
|
127
|
+
ctx.report({ message, node, severity: "warning" });
|
|
128
|
+
return errorBlock(message);
|
|
129
|
+
}
|
|
130
|
+
if (!ctx.fileURL) {
|
|
131
|
+
const message = `Include target ${statement.target} can't resolve: the compiler received no file URL.`;
|
|
132
|
+
ctx.report({ message, node, severity: "warning" });
|
|
133
|
+
return errorBlock(message);
|
|
134
|
+
}
|
|
135
|
+
const expanded = await expandIncludeTarget(statement, {
|
|
136
|
+
contentRoot,
|
|
137
|
+
sourcePath: fileURLToPath(ctx.fileURL),
|
|
138
|
+
});
|
|
139
|
+
if ("error" in expanded) {
|
|
140
|
+
ctx.report({
|
|
141
|
+
message: expanded.error.message,
|
|
142
|
+
node,
|
|
143
|
+
severity: "warning",
|
|
144
|
+
});
|
|
145
|
+
return errorBlock(expanded.error.message);
|
|
146
|
+
}
|
|
147
|
+
for (const nested of expanded.errors) {
|
|
148
|
+
ctx.report({ message: nested.message, node, severity: "warning" });
|
|
149
|
+
}
|
|
150
|
+
return expanded.text;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Splice every statement line in a text block; `null` when none matched.
|
|
155
|
+
* Statement detection mirrors the string-level scanner's `.md` line rules
|
|
156
|
+
* (`parseIncludeLine`, HTML comment tracking) so the rendered page and the
|
|
157
|
+
* indexed/mirrored surfaces agree on which lines splice: a statement inside
|
|
158
|
+
* `<!-- -->` or indented like code stays verbatim on both sides.
|
|
159
|
+
*/
|
|
160
|
+
const spliceLines = async (
|
|
161
|
+
node: MdastNode,
|
|
162
|
+
text: string,
|
|
163
|
+
ctx: IncludeVisitorContext
|
|
164
|
+
): Promise<string | null> => {
|
|
165
|
+
let matched = false;
|
|
166
|
+
let inComment = false;
|
|
167
|
+
const lines = await Promise.all(
|
|
168
|
+
text.split("\n").map((line) => {
|
|
169
|
+
const wasInComment = inComment;
|
|
170
|
+
inComment = advanceHtmlCommentState(line, inComment);
|
|
171
|
+
const statement = wasInComment ? null : parseIncludeLine(line);
|
|
172
|
+
if (!statement) {
|
|
173
|
+
return line;
|
|
174
|
+
}
|
|
175
|
+
matched = true;
|
|
176
|
+
return splice(node, statement, ctx);
|
|
177
|
+
})
|
|
178
|
+
);
|
|
179
|
+
return matched ? lines.join("\n") : null;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
async html(node: HtmlNode, ctx: IncludeVisitorContext) {
|
|
184
|
+
if (!hasIncludeStatements(node.value)) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
// A statement adjacent to real block HTML gets swallowed into that
|
|
188
|
+
// block's `html` node; splice each statement line, keep the rest.
|
|
189
|
+
const replaced = await spliceLines(node, node.value, ctx);
|
|
190
|
+
if (replaced !== null) {
|
|
191
|
+
ctx.replaceNode(node, { raw: replaced });
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
async mdxJsxFlowElement(node: JsxFlowNode, ctx: IncludeVisitorContext) {
|
|
195
|
+
if (node.name !== "include") {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
// The string-level scanner (search, mirrors, llms-full.txt, the HMR
|
|
199
|
+
// graph) only recognizes single-line statements; a wrapped element
|
|
200
|
+
// would render content those surfaces never see, so reject it loudly
|
|
201
|
+
// instead of splicing it invisibly.
|
|
202
|
+
const start = node.position?.start?.line;
|
|
203
|
+
const end = node.position?.end?.line;
|
|
204
|
+
if (start !== undefined && end !== undefined && start !== end) {
|
|
205
|
+
const message =
|
|
206
|
+
"<include> must be written on a single line: <include>./path.mdx</include>.";
|
|
207
|
+
ctx.report({ message, node, severity: "warning" });
|
|
208
|
+
ctx.replaceNode(node, { raw: errorBlock(message) });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
ctx.replaceNode(node, {
|
|
212
|
+
raw: await splice(node, statementFromJsx(node, ctx), ctx),
|
|
213
|
+
});
|
|
214
|
+
},
|
|
215
|
+
name: "blume-include",
|
|
216
|
+
// Positions are opt-in since satteri 0.10 (the parse skips the line index
|
|
217
|
+
// when no plugin reads them); both the paragraph slice recovery and the
|
|
218
|
+
// multi-line JSX rejection depend on them.
|
|
219
|
+
options: { position: true },
|
|
220
|
+
async paragraph(node: PositionedNode, ctx: IncludeVisitorContext) {
|
|
221
|
+
// `<include>` isn't a known block-level HTML tag, so in plain `.md` a
|
|
222
|
+
// statement line parses as a paragraph of inline `html` + text nodes.
|
|
223
|
+
// Recover the raw text by position and splice the statement lines. The
|
|
224
|
+
// slice is widened to whole source lines so container markers the
|
|
225
|
+
// paragraph position excludes (a blockquote's `>`, a list item's `-`)
|
|
226
|
+
// stay visible — a statement inside those containers is not on a line
|
|
227
|
+
// of its own, and the string-level scanner never expands it, so
|
|
228
|
+
// splicing here would render content search and the mirrors never see.
|
|
229
|
+
const start = node.position?.start?.offset;
|
|
230
|
+
const end = node.position?.end?.offset;
|
|
231
|
+
if (start === undefined || end === undefined) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const lineStart = ctx.source.lastIndexOf("\n", start - 1) + 1;
|
|
235
|
+
const lineEndIndex = ctx.source.indexOf("\n", end);
|
|
236
|
+
const lineEnd = lineEndIndex === -1 ? ctx.source.length : lineEndIndex;
|
|
237
|
+
const text = ctx.source.slice(lineStart, lineEnd);
|
|
238
|
+
if (!hasIncludeStatements(text)) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const replaced = await spliceLines(node, text, ctx);
|
|
242
|
+
if (replaced !== null) {
|
|
243
|
+
ctx.replaceNode(node, { raw: replaced });
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
};
|