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
@@ -1,24 +1,45 @@
1
1
  /**
2
- * Turn every section heading into its own permalink. A Satteri hast plugin runs
3
- * after Markdown is turned into hast and wraps each `<h2>`–`<h6>`'s content in an
4
- * `<a href="#slug">`, so a reader can click the heading to copy, bookmark, or
5
- * share a link straight to that section. `<h1>` (the page title) is slugged for
6
- * parity but left unwrapped.
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 authoritative
11
- * id setter: it slugs each heading with the same algorithm (a per-document
12
- * `github-slugger`, the library Satteri and rehype-slug both use) and writes the
13
- * `id`, which `heading-ids` then adopts — keeping the in-page anchor, the
14
- * heading's `id`, and the TOC entry in lockstep. To match Satteri's duplicate
15
- * disambiguation (`setup`, `setup-1`, …) exactly, it advances the slugger over
16
- * `<h1>`–`<h6>` in document order even though only `<h2>`–`<h6>` get wrapped.
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 slugger per document render. The plugin instance is shared across every
68
- // page, but slug disambiguation must reset per document; the render-scoped
69
- // `astro` data object is a stable, unique key for one render (entries are
70
- // dropped once the render is collected, so this never leaks).
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 sluggers = new WeakMap<object, GithubSlugger>();
108
+ const states = new WeakMap<object, RenderState>();
73
109
 
74
- const sluggerFor = (ctx: HastContext): GithubSlugger => {
110
+ const stateFor = (ctx: HastContext): RenderState => {
75
111
  const scope = ctx.data?.astro ?? ctx.data ?? FALLBACK_SCOPE;
76
- const existing = sluggers.get(scope);
112
+ const existing = states.get(scope);
77
113
  if (existing) {
78
114
  return existing;
79
115
  }
80
- const slugger = new GithubSlugger();
81
- sluggers.set(scope, slugger);
82
- return slugger;
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
- const rawText = ctx.textContent(node);
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 visited node always fits.
205
+ // helper walks (children/type/value), so the node always fits.
100
206
  const text = rawText.includes("frontmatter")
101
207
  ? satteriCollectHastText(
102
- node as Parameters<typeof satteriCollectHastText>[0],
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
- const existingId = node.properties?.id;
107
- return isStringId(existingId) ? existingId : slugger.slug(text);
215
+ return slugger.slug(text);
108
216
  };
109
217
 
110
- /** Build the plugin. Wraps `<h2>`–`<h6>` in self-linking anchors. */
111
- export const headingAnchorPlugin = (): HeadingAnchorPlugin => ({
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 slug = slugFor(node, ctx, sluggerFor(ctx));
116
- const wrap = node.tagName
117
- ? WRAPPED.has(node.tagName) && slug !== "" && !containsAnchor(node)
118
- : false;
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
- // Unwrapped headings (h1, an empty slug, or one that already links) still
121
- // need the id so `heading-ids` adopts it instead of re-slugging.
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: node.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
+ };