blume 1.5.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/dist/cli/index.js +3639 -1377
  3. package/dist/cli/index.js.map +103 -91
  4. package/dist/types/ai/component-markdown.d.ts +79 -0
  5. package/dist/types/components/layout/nav-utils.d.ts +60 -0
  6. package/dist/types/core/base-path.d.ts +9 -0
  7. package/dist/types/core/config-input.d.ts +206 -4
  8. package/dist/types/core/config.d.ts +6 -4
  9. package/dist/types/core/data.d.ts +23 -1
  10. package/dist/types/core/github.d.ts +35 -0
  11. package/dist/types/core/i18n-ui.d.ts +8 -0
  12. package/dist/types/core/navigation.d.ts +69 -0
  13. package/dist/types/core/schema.d.ts +117 -1
  14. package/dist/types/core/sources/types.d.ts +31 -6
  15. package/dist/types/core/types.d.ts +23 -2
  16. package/dist/types/markdown/features.d.ts +21 -0
  17. package/dist/types/openapi/references.d.ts +21 -1
  18. package/dist/types/seo/jsonld.d.ts +105 -0
  19. package/dist/types/theme/fonts.d.ts +34 -4
  20. package/docs/_snippets/include-demo.mdx +7 -0
  21. package/docs/advanced/api-reference.mdx +3 -3
  22. package/docs/advanced/custom-pages.mdx +1 -1
  23. package/docs/advanced/graphql.mdx +84 -0
  24. package/docs/advanced/meta.ts +8 -1
  25. package/docs/configuration/ai.mdx +21 -3
  26. package/docs/configuration/index.mdx +24 -0
  27. package/docs/configuration/search.mdx +13 -1
  28. package/docs/configuration/seo.mdx +27 -0
  29. package/docs/configuration/theming.mdx +17 -0
  30. package/docs/content/components.mdx +7 -0
  31. package/docs/content/includes.mdx +68 -0
  32. package/docs/content/meta.ts +1 -0
  33. package/docs/content/navigation.mdx +25 -0
  34. package/docs/content/sources.mdx +42 -1
  35. package/docs/content/syntax.mdx +69 -1
  36. package/docs/content/versioning.mdx +15 -9
  37. package/docs/reference/cli.mdx +2 -1
  38. package/package.json +23 -14
  39. package/skills/blume-migrate/SKILL.md +16 -7
  40. package/skills/blume-migrate/references/docusaurus.md +5 -3
  41. package/skills/blume-migrate/references/fumadocs.md +10 -2
  42. package/skills/blume-migrate/references/mintlify.md +3 -2
  43. package/skills/blume-migrate/references/nextra.md +2 -2
  44. package/skills/blume-migrate/references/starlight.md +1 -1
  45. package/src/ai/agent-readability.ts +2 -1
  46. package/src/ai/ask-data.ts +2 -1
  47. package/src/ai/component-markdown.ts +199 -36
  48. package/src/ai/llms.ts +93 -6
  49. package/src/ai/markdown.ts +2 -2
  50. package/src/ai/mcp/discovery.ts +10 -2
  51. package/src/ai/mcp/server.ts +74 -2
  52. package/src/astro/generate.ts +183 -116
  53. package/src/astro/include-hmr.ts +81 -0
  54. package/src/astro/include-refresh.ts +0 -0
  55. package/src/astro/index.ts +3 -5
  56. package/src/astro/templates.ts +125 -76
  57. package/src/cli/commands/build.ts +84 -15
  58. package/src/cli/init/questions.ts +1 -0
  59. package/src/cli/init/scaffold.ts +27 -4
  60. package/src/components/colors.ts +142 -0
  61. package/src/components/content/Badge.astro +5 -12
  62. package/src/components/content/Callout.astro +19 -36
  63. package/src/components/content/Card.astro +15 -21
  64. package/src/components/content/Component.astro +10 -1
  65. package/src/components/content/GithubInfo.astro +28 -9
  66. package/src/components/content/Tabs.astro +27 -5
  67. package/src/components/content/github-info.ts +20 -5
  68. package/src/components/dropdown-dismiss.ts +122 -0
  69. package/src/components/layout/Fonts.astro +15 -8
  70. package/src/components/layout/Header.astro +44 -0
  71. package/src/components/layout/LanguageSwitcher.astro +9 -1
  72. package/src/components/layout/NavSelector.astro +12 -3
  73. package/src/components/layout/NavTree.astro +6 -18
  74. package/src/components/layout/PageActions.astro +29 -8
  75. package/src/components/layout/PageLayout.astro +10 -1
  76. package/src/components/layout/ReferenceLayout.astro +6 -1
  77. package/src/components/layout/RootLayout.astro +46 -15
  78. package/src/components/layout/Search.astro +36 -4
  79. package/src/components/layout/TableOfContents.astro +8 -2
  80. package/src/components/layout/head-scripts.ts +53 -1
  81. package/src/components/openapi/ApiOverview.astro +13 -3
  82. package/src/components/openapi/AsyncApiOperation.astro +7 -14
  83. package/src/components/openapi/GraphqlChip.astro +33 -0
  84. package/src/components/openapi/GraphqlFieldsTable.astro +111 -0
  85. package/src/components/openapi/GraphqlOperation.astro +186 -0
  86. package/src/components/openapi/GraphqlType.astro +154 -0
  87. package/src/components/openapi/MethodBadge.astro +3 -14
  88. package/src/components/openapi/Operation.astro +12 -5
  89. package/src/components/openapi/OperationPanel.astro +43 -0
  90. package/src/components/openapi/RequestPanel.astro +5 -10
  91. package/src/components/openapi/Responses.astro +1 -16
  92. package/src/components/openapi/graphql-helpers.ts +466 -0
  93. package/src/components/openapi/playground-client.ts +15 -0
  94. package/src/components/openapi/sample-panels.ts +45 -0
  95. package/src/components/openapi/snippets.ts +13 -35
  96. package/src/core/base-path.ts +11 -0
  97. package/src/core/config-input.ts +209 -2
  98. package/src/core/config.ts +6 -4
  99. package/src/core/content-assets.ts +15 -4
  100. package/src/core/data.ts +18 -2
  101. package/src/core/diagnostics.ts +8 -0
  102. package/src/core/frontmatter.ts +20 -8
  103. package/src/core/github.ts +71 -0
  104. package/src/core/graph.ts +22 -8
  105. package/src/core/heading-markers.ts +96 -0
  106. package/src/core/i18n-ui.ts +11 -0
  107. package/src/core/includes.ts +632 -0
  108. package/src/core/last-modified.ts +36 -11
  109. package/src/core/links.ts +79 -13
  110. package/src/core/meta.ts +2 -1
  111. package/src/core/nav-diagnostics.ts +11 -2
  112. package/src/core/navigation.ts +27 -6
  113. package/src/core/project-graph.ts +61 -9
  114. package/src/core/schema.ts +226 -35
  115. package/src/core/server-features.ts +5 -9
  116. package/src/core/sources/github-releases.ts +2 -2
  117. package/src/core/sources/normalize.ts +502 -115
  118. package/src/core/sources/notion.ts +43 -8
  119. package/src/core/sources/obsidian.ts +1038 -0
  120. package/src/core/sources/read.ts +36 -1
  121. package/src/core/sources/resolve.ts +34 -1
  122. package/src/core/sources/types.ts +28 -6
  123. package/src/core/sources/watch.ts +12 -8
  124. package/src/core/tsconfig-aliases.ts +48 -35
  125. package/src/core/types.ts +25 -2
  126. package/src/core/ui-packs/ar.ts +1 -0
  127. package/src/core/ui-packs/bg.ts +2 -0
  128. package/src/core/ui-packs/bn.ts +1 -0
  129. package/src/core/ui-packs/ca.ts +2 -0
  130. package/src/core/ui-packs/cs.ts +1 -0
  131. package/src/core/ui-packs/da.ts +1 -0
  132. package/src/core/ui-packs/de.ts +2 -0
  133. package/src/core/ui-packs/el.ts +2 -0
  134. package/src/core/ui-packs/es.ts +2 -0
  135. package/src/core/ui-packs/fa.ts +1 -0
  136. package/src/core/ui-packs/fi.ts +1 -0
  137. package/src/core/ui-packs/fr.ts +2 -0
  138. package/src/core/ui-packs/he.ts +1 -0
  139. package/src/core/ui-packs/hi.ts +1 -0
  140. package/src/core/ui-packs/hr.ts +2 -0
  141. package/src/core/ui-packs/hu.ts +2 -0
  142. package/src/core/ui-packs/id.ts +2 -0
  143. package/src/core/ui-packs/it.ts +1 -0
  144. package/src/core/ui-packs/ja.ts +2 -0
  145. package/src/core/ui-packs/ko.ts +2 -0
  146. package/src/core/ui-packs/nl.ts +2 -0
  147. package/src/core/ui-packs/no.ts +2 -0
  148. package/src/core/ui-packs/pl.ts +2 -0
  149. package/src/core/ui-packs/pt-br.ts +2 -0
  150. package/src/core/ui-packs/pt.ts +2 -0
  151. package/src/core/ui-packs/ro.ts +2 -0
  152. package/src/core/ui-packs/ru.ts +2 -0
  153. package/src/core/ui-packs/sk.ts +1 -0
  154. package/src/core/ui-packs/sr.ts +1 -0
  155. package/src/core/ui-packs/sv.ts +2 -0
  156. package/src/core/ui-packs/th.ts +1 -0
  157. package/src/core/ui-packs/tr.ts +2 -0
  158. package/src/core/ui-packs/uk.ts +2 -0
  159. package/src/core/ui-packs/vi.ts +1 -0
  160. package/src/core/ui-packs/zh-tw.ts +1 -0
  161. package/src/core/ui-packs/zh.ts +1 -0
  162. package/src/core/version-cut.ts +21 -3
  163. package/src/core/yaml.ts +26 -0
  164. package/src/deploy/function-bundle.ts +251 -0
  165. package/src/eval/schema.ts +3 -1
  166. package/src/markdown/code-title.ts +22 -16
  167. package/src/markdown/features.ts +21 -0
  168. package/src/markdown/fence-meta.ts +50 -0
  169. package/src/markdown/heading-anchors.ts +198 -37
  170. package/src/markdown/include.ts +247 -0
  171. package/src/markdown/index.ts +43 -34
  172. package/src/markdown/language-icon.ts +2 -2
  173. package/src/markdown/mdast.ts +7 -3
  174. package/src/markdown/ts2js.ts +264 -0
  175. package/src/openapi/asyncapi.ts +4 -1
  176. package/src/openapi/graphql-build.ts +293 -0
  177. package/src/openapi/graphql.ts +212 -0
  178. package/src/openapi/model.ts +38 -5
  179. package/src/openapi/parse.ts +34 -0
  180. package/src/openapi/proxy.ts +30 -5
  181. package/src/openapi/references.ts +89 -13
  182. package/src/openapi/render-mdx.ts +48 -8
  183. package/src/openapi/scalar.ts +5 -12
  184. package/src/openapi/source.ts +91 -23
  185. package/src/registry/eject.ts +11 -0
  186. package/src/search/documents.ts +229 -37
  187. package/src/search/orama-index.ts +9 -5
  188. package/src/seo/jsonld.ts +293 -51
  189. package/src/theme/code-block-padding.ts +16 -0
  190. package/src/theme/entry.ts +65 -11
  191. package/src/theme/fonts.ts +189 -16
  192. package/src/translate/prompts.ts +2 -0
  193. package/src/translate/run.ts +7 -0
  194. package/src/translate/work-list.ts +0 -0
@@ -0,0 +1,1038 @@
1
+ import { existsSync, watch as fsWatch, statSync } from "node:fs";
2
+ import type { Dirent } from "node:fs";
3
+ import { readdir, readFile, stat } from "node:fs/promises";
4
+
5
+ import { basename, isAbsolute, join, relative, resolve } from "pathe";
6
+
7
+ import { BlumeError } from "../diagnostics.ts";
8
+ import matter from "../frontmatter.ts";
9
+ import { pageMetaSchema } from "../schema.ts";
10
+ import type { ResolvedI18nConfig, ResolvedVersionsConfig } from "../schema.ts";
11
+ import type { Diagnostic } from "../types.ts";
12
+ import { hashText } from "./cache.ts";
13
+ import type { EntryPlacement, FenceState } from "./normalize.ts";
14
+ import {
15
+ extractHeadings,
16
+ isStringValue,
17
+ localizedRoute,
18
+ nextFenceState,
19
+ placeEntryRef,
20
+ resolveEntryRoute,
21
+ slugifyPath,
22
+ } from "./normalize.ts";
23
+ import type {
24
+ ContentSource,
25
+ SourceContext,
26
+ SourceEntry,
27
+ SourceLoadResult,
28
+ } from "./types.ts";
29
+ import { BLUME_IGNORE_DIRS, ignoringWatchListener } from "./watch.ts";
30
+
31
+ /** Options for the built-in Obsidian vault source. */
32
+ export interface ObsidianSourceOptions {
33
+ /**
34
+ * The project's default content type (`content.defaultType`), which a note
35
+ * without a `type` resolves to when {@link typeFrontmatterKeys} are picked.
36
+ */
37
+ defaultType?: string;
38
+ /** Vault folder names to skip at any depth, in addition to dot-folders. */
39
+ exclude?: string[];
40
+ /**
41
+ * Frontmatter keys the project declares site-wide beyond Blume's page meta
42
+ * (`frontmatter.extend`). Any other Obsidian property is dropped when a note
43
+ * is lowered, since the strict meta schema would reject it and fail the
44
+ * build.
45
+ */
46
+ frontmatterKeys?: readonly string[];
47
+ /** The project's i18n config, when locale directories place vault notes. */
48
+ i18n?: ResolvedI18nConfig;
49
+ /** Stable source name; namespaces ids and diagnostics. */
50
+ name: string;
51
+ /** Namespaces the source's routes under `/<prefix>/`; e.g. `vault`. */
52
+ prefix?: string;
53
+ /**
54
+ * Frontmatter keys each content type declares (`content.types.<type>.frontmatter`),
55
+ * keyed by type. A note keeps only the keys of its own type — the meta parse
56
+ * merges only that type's schema, so a key another type declares would still
57
+ * reach the strict page schema and fail.
58
+ */
59
+ typeFrontmatterKeys?: Readonly<Record<string, readonly string[]>>;
60
+ /** Vault directory, absolute or relative to `projectRoot`. */
61
+ vault: string;
62
+ /** The project's versions config, when snapshot directories hold notes. */
63
+ versions?: ResolvedVersionsConfig;
64
+ }
65
+
66
+ const MARKDOWN_FILE = /\.md$/iu;
67
+ /** `%%comment%%` on a single line; multi-line comments are not stripped yet. */
68
+ const OBSIDIAN_COMMENT = /%%.*?%%/gu;
69
+ /**
70
+ * `[[target]]`, `[[target|alias]]`, `[[target#heading]]`, `![[embed]]`. The
71
+ * target and heading are lazy so the `\|` Obsidian writes for an alias inside
72
+ * a table cell (a bare `|` would end the cell) is read as the alias separator
73
+ * rather than as a backslash ending the target. A target cannot open with `[`:
74
+ * Obsidian reads `[[[Note]]]` as a literal `[`, the link, and a literal `]`,
75
+ * and so does the match once the first bracket is left out of it.
76
+ */
77
+ const WIKILINK =
78
+ /(?<embed>!)?\[\[(?<target>(?:[^\][|#\n][^\]|#\n]*?)?)(?:#(?<heading>[^\]|\n]+?))?(?:\\?\|(?<alias>[^\]\n]+))?\]\]/gu;
79
+
80
+ /** A vault note, read and split into frontmatter and body. */
81
+ interface ParsedNote {
82
+ absPath: string;
83
+ /** The note body, frontmatter stripped and otherwise untouched. */
84
+ content: string;
85
+ data: SourceEntry["data"];
86
+ placement: EntryPlacement;
87
+ /** Vault-relative path, e.g. `guides/Getting Started.md`. */
88
+ rel: string;
89
+ }
90
+
91
+ /**
92
+ * The key a note name or heading is indexed and looked up under. macOS writes
93
+ * filenames as NFD and editors type NFC, so both sides normalize before
94
+ * casefolding.
95
+ */
96
+ const indexKey = (value: string): string =>
97
+ value.normalize("NFC").toLowerCase();
98
+
99
+ /** A Markdown link or image, reduced to its text by {@link headingKey}. */
100
+ const INLINE_LINK = /!?\[(?<text>[^\]]*)\]\([^)]*\)/gu;
101
+ /**
102
+ * Inline formatting Obsidian drops when it autocompletes a heading link:
103
+ * emphasis and strikethrough marks, code-span backticks, and an `_` that opens
104
+ * or closes a word (one inside `snake_case` is text).
105
+ */
106
+ const INLINE_MARKS = /[*~`]+|(?<![\p{L}\p{N}])_+|_+(?![\p{L}\p{N}])/gu;
107
+
108
+ /**
109
+ * The key a heading is indexed and looked up under: its text with inline
110
+ * Markdown stripped, because Obsidian writes `[[Note#Bold heading]]` for a
111
+ * `## **Bold** heading` — and because that is the text the rendered id is
112
+ * slugged from.
113
+ */
114
+ const headingKey = (text: string): string =>
115
+ indexKey(
116
+ text.replaceAll(INLINE_LINK, "$<text>").replaceAll(INLINE_MARKS, "")
117
+ );
118
+
119
+ /**
120
+ * Obsidian's own default properties (the Properties UI writes the plural
121
+ * spellings; older vaults carry the singular ones). They are not Blume
122
+ * frontmatter, so they are dropped even when a project happens to declare a
123
+ * key of the same name. `aliases` is dropped rather than resolved; alias link
124
+ * targets are not supported yet.
125
+ */
126
+ const OBSIDIAN_NATIVE_KEYS = new Set([
127
+ "alias",
128
+ "aliases",
129
+ "cssclass",
130
+ "cssclasses",
131
+ "tag",
132
+ "tags",
133
+ ]);
134
+
135
+ /** Every key Blume's page meta schema accepts. */
136
+ const PAGE_META_KEYS = new Set<string>(pageMetaSchema.keyof().options);
137
+
138
+ /**
139
+ * A note's route input: its locale- and version-stripped path, slugged. Vault
140
+ * filenames are prose (`Getting Started.md`) and the route mapper does not
141
+ * slug, so this does — through {@link slugifyPath}, which keeps a non-Latin
142
+ * name routable.
143
+ */
144
+ const entrySlugFor = (navPath: string): string =>
145
+ slugifyPath(navPath.replace(MARKDOWN_FILE, ""));
146
+
147
+ /** One note as the wikilink index knows it: where it routes, and its anchors. */
148
+ interface IndexedNote {
149
+ /** Anchor id per heading, keyed by {@link headingKey}. */
150
+ anchors: Map<string, string>;
151
+ /** The locales the note publishes in — every locale for a shared `$` note. */
152
+ locales: string[];
153
+ /** The note's version-prefixed, locale-agnostic route, prefix included. */
154
+ logicalRoute: string;
155
+ }
156
+
157
+ /** The wikilink lookup table. */
158
+ interface LinkIndex {
159
+ /**
160
+ * Bare note names claimed by more than one file, keyed by {@link indexKey},
161
+ * with the diagnostic text naming the claimants. A name some note owns as
162
+ * its exact vault path is not here — Obsidian resolves a path before a name.
163
+ */
164
+ ambiguous: Map<string, string>;
165
+ i18n?: ResolvedI18nConfig;
166
+ notes: Map<string, IndexedNote>;
167
+ }
168
+
169
+ /** Dead or ambiguous wikilink targets, accumulated across one load. */
170
+ interface UnresolvedTargets {
171
+ /** Bare names a wikilink resolved through a collision, with the claimants. */
172
+ ambiguous: string[];
173
+ /** `Note#Heading` targets whose note exists but whose heading does not. */
174
+ anchors: string[];
175
+ /** Targets that matched no note in the vault. */
176
+ notes: string[];
177
+ }
178
+
179
+ /** A note paired with its own index entry, so a rewrite can address itself. */
180
+ interface IndexedPair {
181
+ note: ParsedNote;
182
+ self: IndexedNote;
183
+ }
184
+
185
+ /** The wikilink lookup table, plus each note's own entry in it. */
186
+ interface NoteIndex {
187
+ index: LinkIndex;
188
+ pairs: IndexedPair[];
189
+ }
190
+
191
+ /** A fresh accumulator. */
192
+ const noTargets = (): UnresolvedTargets => ({
193
+ ambiguous: [],
194
+ anchors: [],
195
+ notes: [],
196
+ });
197
+
198
+ /**
199
+ * The href a link from one note to another takes. A shared `Note.$.md`
200
+ * publishes in every locale, so a link to it stays in the linking note's
201
+ * locale rather than jumping to whichever locale is configured first; a target
202
+ * that does not publish in that locale links to the first locale it does. The
203
+ * Markdown pipeline prefixes every root-absolute link with `deployment.base`
204
+ * at render time, so the base is left off here.
205
+ */
206
+ const hrefBetween = (
207
+ target: IndexedNote,
208
+ from: IndexedNote,
209
+ i18n: ResolvedI18nConfig | undefined
210
+ ): string => {
211
+ const own = from.locales[0] ?? "";
212
+ const locale = target.locales.includes(own) ? own : (target.locales[0] ?? "");
213
+ return localizedRoute(target.logicalRoute, locale, i18n);
214
+ };
215
+
216
+ /**
217
+ * Every path suffix a wikilink can address a note by: `docs/guides/Setup.md`
218
+ * yields `docs/guides/setup`, `guides/setup`, and `setup`. Obsidian's default
219
+ * "shortest path when possible" setting writes any of them into a link, so all
220
+ * of them must resolve.
221
+ */
222
+ const suffixKeysOf = (rel: string): string[] => {
223
+ const segments = rel.replace(MARKDOWN_FILE, "").split("/");
224
+ return segments.map((_, from) => indexKey(segments.slice(from).join("/")));
225
+ };
226
+
227
+ /** Every run of backticks — the only delimiter a code span has. */
228
+ const BACKTICK_RUN = /`+/gu;
229
+
230
+ /** A run of backticks: where it starts and how many. */
231
+ interface BacktickRun {
232
+ at: number;
233
+ length: number;
234
+ }
235
+
236
+ /** Whether the character at `at` sits behind an odd number of backslashes. */
237
+ const isEscaped = (text: string, at: number): boolean => {
238
+ let backslashes = 0;
239
+ for (let i = at - 1; i >= 0 && text[i] === "\\"; i -= 1) {
240
+ backslashes += 1;
241
+ }
242
+ return backslashes % 2 === 1;
243
+ };
244
+
245
+ /**
246
+ * The backtick runs that can delimit a code span. A backslash escapes the one
247
+ * backtick after it (CommonMark 2.4), so an escaped run loses its first tick
248
+ * — and drops out entirely when that was its only one.
249
+ */
250
+ const backtickRuns = (text: string): BacktickRun[] => {
251
+ const runs: BacktickRun[] = [];
252
+ for (const match of text.matchAll(BACKTICK_RUN)) {
253
+ const escaped = isEscaped(text, match.index);
254
+ const at = escaped ? match.index + 1 : match.index;
255
+ const length = escaped ? match[0].length - 1 : match[0].length;
256
+ if (length > 0) {
257
+ runs.push({ at, length });
258
+ }
259
+ }
260
+ return runs;
261
+ };
262
+
263
+ /** One piece of a line: literal text the rewrite must not touch, or prose. */
264
+ interface LineChunk {
265
+ literal: boolean;
266
+ text: string;
267
+ }
268
+
269
+ /** A span of text that is literal: a code span or an HTML comment. */
270
+ interface LiteralSpan {
271
+ end: number;
272
+ start: number;
273
+ }
274
+
275
+ /** The index of the next backtick run of exactly `length`, or -1. */
276
+ const closerAfter = (
277
+ runs: BacktickRun[],
278
+ from: number,
279
+ length: number
280
+ ): number => {
281
+ for (let i = from; i < runs.length; i += 1) {
282
+ if (runs[i]?.length === length) {
283
+ return i;
284
+ }
285
+ }
286
+ return -1;
287
+ };
288
+
289
+ /**
290
+ * The first code span opening at or after `from`. A run of N backticks opens
291
+ * a span only if a run of exactly N follows — one regex expresses neither the
292
+ * length match nor that condition — and a run with no closer is literal text
293
+ * that the scan steps over.
294
+ */
295
+ const nextCodeSpan = (
296
+ runs: BacktickRun[],
297
+ from: number
298
+ ): LiteralSpan | null => {
299
+ for (let i = 0; i < runs.length; i += 1) {
300
+ const run = runs[i];
301
+ if (run === undefined || run.at < from) {
302
+ continue;
303
+ }
304
+ const close = closerAfter(runs, i + 1, run.length);
305
+ if (close !== -1) {
306
+ return { end: (runs[close]?.at ?? 0) + run.length, start: run.at };
307
+ }
308
+ }
309
+ return null;
310
+ };
311
+
312
+ const COMMENT_OPEN = "<!--";
313
+ const COMMENT_CLOSE = "-->";
314
+
315
+ /**
316
+ * The first HTML comment opening at or after `from`. Obsidian hides one in
317
+ * reading view, so a wikilink inside it is never a link the reader sees; an
318
+ * unclosed `<!--` is literal text (CommonMark 6.6), not a comment to the end.
319
+ */
320
+ const nextComment = (text: string, from: number): LiteralSpan | null => {
321
+ const start = text.indexOf(COMMENT_OPEN, from);
322
+ if (start === -1) {
323
+ return null;
324
+ }
325
+ const close = text.indexOf(COMMENT_CLOSE, start + COMMENT_OPEN.length);
326
+ return close === -1 ? null : { end: close + COMMENT_CLOSE.length, start };
327
+ };
328
+
329
+ /**
330
+ * Split prose into literal and rewritable chunks. Code spans and HTML comments
331
+ * have equal precedence and the leftmost wins (CommonMark 6.6): a `<!--`
332
+ * inside a code span is code, and a backtick inside a comment is comment. The
333
+ * scan runs over a whole run of prose rather than a line, because either may
334
+ * hold newlines while an unclosed opener is literal text.
335
+ */
336
+ const splitLiterals = (text: string): LineChunk[] => {
337
+ const runs = backtickRuns(text);
338
+ const chunks: LineChunk[] = [];
339
+ let cursor = 0;
340
+ for (;;) {
341
+ const code = nextCodeSpan(runs, cursor);
342
+ const comment = nextComment(text, cursor);
343
+ const span =
344
+ code === null || (comment !== null && comment.start < code.start)
345
+ ? comment
346
+ : code;
347
+ if (span === null) {
348
+ break;
349
+ }
350
+ chunks.push(
351
+ { literal: false, text: text.slice(cursor, span.start) },
352
+ { literal: true, text: text.slice(span.start, span.end) }
353
+ );
354
+ cursor = span.end;
355
+ }
356
+ chunks.push({ literal: false, text: text.slice(cursor) });
357
+ return chunks;
358
+ };
359
+
360
+ /** Rewrite one chunk's wikilinks and strip single-line comments. */
361
+ const transformChunk = (
362
+ chunk: string,
363
+ index: LinkIndex,
364
+ self: IndexedNote,
365
+ unresolved: UnresolvedTargets
366
+ ): string =>
367
+ chunk.replaceAll(OBSIDIAN_COMMENT, "").replaceAll(WIKILINK, (...args) => {
368
+ // SAFETY: `String.replaceAll` passes the groups object last whenever the
369
+ // pattern has named groups, and WIKILINK has four.
370
+ const groups = args.at(-1) as Record<string, string | undefined>;
371
+ const target = (groups.target ?? "").trim();
372
+ const heading = groups.heading?.trim();
373
+ const label = groups.alias?.trim() || heading || target;
374
+ // Rewriting an embed means serving the attachment, which this source
375
+ // does not do yet.
376
+ if (groups.embed) {
377
+ // SAFETY: the replacer's first argument is always the matched substring.
378
+ return args[0] as string;
379
+ }
380
+ // `[[#Heading]]` addresses the note it sits in; an empty target with no
381
+ // heading is not a link at all.
382
+ if (target === "") {
383
+ if (heading === undefined) {
384
+ // SAFETY: the replacer's first argument is always the matched substring.
385
+ return args[0] as string;
386
+ }
387
+ const ownHref = hrefBetween(self, self, index.i18n);
388
+ // `#^block-id` names a block, not a heading. Blocks render with no
389
+ // anchor to land on, so the link goes to the page itself rather than
390
+ // warning about a heading that never existed. The caret never reaches
391
+ // the label — `[^id]` would read as a GFM footnote reference.
392
+ if (heading.startsWith("^")) {
393
+ return `[${groups.alias?.trim() || heading.slice(1)}](${ownHref})`;
394
+ }
395
+ const own = self.anchors.get(headingKey(heading));
396
+ if (own === undefined) {
397
+ // Same rule as a missing heading in another note: keep the page link,
398
+ // drop only the anchor.
399
+ unresolved.anchors.push(`#${heading}`);
400
+ return `[${label}](${ownHref})`;
401
+ }
402
+ return `[${label}](${ownHref}#${own})`;
403
+ }
404
+ // `[[Note.md]]` is the path form Obsidian also accepts; the index is keyed
405
+ // without the extension.
406
+ const key = indexKey(target.replace(MARKDOWN_FILE, ""));
407
+ const note = index.notes.get(key);
408
+ if (note === undefined) {
409
+ unresolved.notes.push(target);
410
+ return label;
411
+ }
412
+ const clash = index.ambiguous.get(key);
413
+ if (clash !== undefined) {
414
+ unresolved.ambiguous.push(clash);
415
+ }
416
+ const href = hrefBetween(note, self, index.i18n);
417
+ if (heading === undefined) {
418
+ return `[${label}](${href})`;
419
+ }
420
+ // A block reference links to its note without an anchor; block ids are
421
+ // generated noise (`^a1b2c3`), so an unaliased one reads as the note name.
422
+ if (heading.startsWith("^")) {
423
+ return `[${groups.alias?.trim() || target}](${href})`;
424
+ }
425
+ const anchor = note.anchors.get(headingKey(heading));
426
+ if (anchor === undefined) {
427
+ // The note is real, so keep the link and drop only the anchor — landing
428
+ // on the page beats degrading the whole link to plain text.
429
+ unresolved.anchors.push(`${target}#${heading}`);
430
+ return `[${label}](${href})`;
431
+ }
432
+ return `[${label}](${href}#${anchor})`;
433
+ });
434
+
435
+ /**
436
+ * Rewrite a run of prose, leaving inline code spans (`` `[[x]]` ``) and HTML
437
+ * comments (`<!-- [[x]] -->`) verbatim.
438
+ */
439
+ const transformProse = (
440
+ text: string,
441
+ index: LinkIndex,
442
+ self: IndexedNote,
443
+ unresolved: UnresolvedTargets
444
+ ): string =>
445
+ splitLiterals(text)
446
+ .map((chunk) =>
447
+ chunk.literal
448
+ ? chunk.text
449
+ : transformChunk(chunk.text, index, self, unresolved)
450
+ )
451
+ .join("");
452
+
453
+ /** An indented code block's marker: four spaces or a tab (CommonMark 4.4). */
454
+ const INDENTED_CODE = /^(?: {4}|\t)/u;
455
+ /** A list item opener (CommonMark 5.2), which makes later indentation prose. */
456
+ const LIST_ITEM = /^ {0,3}(?:[-+*]|\d{1,9}[.)])(?: |$)/u;
457
+
458
+ /** How many leading whitespace characters a line carries. */
459
+ const indentOf = (line: string): number =>
460
+ line.length - line.trimStart().length;
461
+
462
+ /**
463
+ * Whether a list is open after `line`: an item opens one, and it stays open
464
+ * until a non-blank line that is neither an item nor indented.
465
+ */
466
+ const listAfter = (line: string, inList: boolean): boolean => {
467
+ if (line.trim() === "") {
468
+ return inList;
469
+ }
470
+ if (LIST_ITEM.test(line)) {
471
+ return true;
472
+ }
473
+ return line.startsWith(" ") || line.startsWith("\t") ? inList : false;
474
+ };
475
+
476
+ /**
477
+ * The fence state after `line`. Outside a list, a line indented four or more
478
+ * spaces is never a fence delimiter: it is indented code or a continuation
479
+ * line. Inside a list item the content indent is the item's, so a fence
480
+ * there is written four spaces deep (`1. step` then ` ```js`) and must
481
+ * still open. Once a fence is open only a run at most three spaces deeper
482
+ * than it (CommonMark 4.5) closes it; a deeper run is content.
483
+ */
484
+ const fenceAfter = (
485
+ line: string,
486
+ fence: FenceState,
487
+ fenceIndent: number,
488
+ inList: boolean
489
+ ): FenceState => {
490
+ const canDelimit =
491
+ fence === null
492
+ ? inList || !INDENTED_CODE.test(line)
493
+ : indentOf(line) <= fenceIndent + 3;
494
+ return canDelimit ? nextFenceState(line, fence) : fence;
495
+ };
496
+
497
+ /**
498
+ * Transform an Obsidian body to Blume-ready Markdown: wikilinks become route
499
+ * links and `%%comments%%` are stripped, while fenced, indented, and inline
500
+ * code pass through so a note documenting the syntax survives. Callouts stay
501
+ * blockquotes.
502
+ */
503
+ const transformBody = (
504
+ body: string,
505
+ index: LinkIndex,
506
+ self: IndexedNote,
507
+ unresolved: UnresolvedTargets
508
+ ): string => {
509
+ let fence: FenceState = null;
510
+ // The indent the open fence was written at: a closing fence may sit up to
511
+ // three spaces deeper (CommonMark 4.5), and a deeper run is content.
512
+ let fenceIndent = 0;
513
+ // An indented code block opens only after a blank line (CommonMark: it
514
+ // cannot interrupt a paragraph) and outside a list (a loose item's indented
515
+ // continuation paragraph is prose), then runs while lines stay indented or
516
+ // blank. A list stays open until a non-indented line that is not an item.
517
+ let afterBlank = true;
518
+ let indentedCode = false;
519
+ let inList = false;
520
+ const out: string[] = [];
521
+ let prose: string[] = [];
522
+ const flush = (): void => {
523
+ if (prose.length > 0) {
524
+ out.push(transformProse(prose.join("\n"), index, self, unresolved));
525
+ prose = [];
526
+ }
527
+ };
528
+ for (const line of body.split("\n")) {
529
+ const blank = line.trim() === "";
530
+ if (fence === null && indentedCode) {
531
+ if (blank || INDENTED_CODE.test(line)) {
532
+ out.push(line);
533
+ afterBlank = blank;
534
+ continue;
535
+ }
536
+ indentedCode = false;
537
+ }
538
+ inList = listAfter(line, inList);
539
+ if (
540
+ fence === null &&
541
+ afterBlank &&
542
+ !(blank || inList) &&
543
+ INDENTED_CODE.test(line)
544
+ ) {
545
+ flush();
546
+ indentedCode = true;
547
+ out.push(line);
548
+ afterBlank = false;
549
+ continue;
550
+ }
551
+ const next: FenceState = fenceAfter(line, fence, fenceIndent, inList);
552
+ if (fence !== null || next !== null) {
553
+ if (fence === null) {
554
+ fenceIndent = indentOf(line);
555
+ }
556
+ flush();
557
+ fence = next;
558
+ out.push(line);
559
+ afterBlank = blank;
560
+ continue;
561
+ }
562
+ if (blank) {
563
+ // A code span cannot cross a blank line (CommonMark 6.1), so each prose
564
+ // run flushes at one — a stray backtick stays confined to its paragraph
565
+ // instead of pairing with another paragraph's and swallowing the links
566
+ // between them.
567
+ flush();
568
+ out.push(line);
569
+ afterBlank = true;
570
+ continue;
571
+ }
572
+ prose.push(line);
573
+ afterBlank = false;
574
+ }
575
+ flush();
576
+ return out.join("\n");
577
+ };
578
+
579
+ /**
580
+ * Fill a note's anchors: the id the renderer emits for each heading, keyed by
581
+ * {@link headingKey}. The ids are whatever {@link extractHeadings} assigns —
582
+ * the same pass fills the page manifest from the staged body, so a
583
+ * `[#custom-id]` pin and the `setup-1` a later collision gets are honored
584
+ * rather than re-slugged. When no heading holds anything the rewrite changes,
585
+ * the raw body's headings are the staged body's; otherwise the body is lowered
586
+ * once more so the headings are scanned exactly as they ship. A link inside a
587
+ * heading is rewritten here before the other notes' anchors are known, so its
588
+ * own anchor is left off; that only moves the manifest id of a heading that
589
+ * itself holds a heading link, which the docs already flag as unaddressable.
590
+ */
591
+ const fillAnchors = (pair: IndexedPair, index: LinkIndex): void => {
592
+ const raw = extractHeadings(pair.note.content);
593
+ const rewritten = raw.some(
594
+ (heading) =>
595
+ transformProse(heading.text, index, pair.self, noTargets()) !==
596
+ heading.text
597
+ );
598
+ const headings = rewritten
599
+ ? extractHeadings(
600
+ transformBody(pair.note.content, index, pair.self, noTargets())
601
+ )
602
+ : raw;
603
+ for (const heading of headings) {
604
+ const key = headingKey(heading.text);
605
+ // Obsidian points a repeated-heading link at the first match; the manifest
606
+ // scan has already disambiguated the later ones (`setup-1`).
607
+ if (!pair.self.anchors.has(key)) {
608
+ pair.self.anchors.set(key, heading.slug);
609
+ }
610
+ }
611
+ };
612
+
613
+ /**
614
+ * Obsidian addresses `[[Name]]` by note name, not only by path; index each note
615
+ * under every suffix of its vault-relative path, bare basename included. A key
616
+ * two notes share resolves to the first in vault order — Obsidian disambiguates
617
+ * by the linking note's location, which a rewrite cannot know — except that a
618
+ * note's exact full path always wins for its own key, the way Obsidian resolves
619
+ * a link as a path before a name. Only a bare-name collision is recorded, and
620
+ * only so a link that actually resolves through it can warn; a longer shared
621
+ * suffix is already the author's disambiguation.
622
+ */
623
+ const buildLinkIndex = (
624
+ notes: ParsedNote[],
625
+ options: Pick<ObsidianSourceOptions, "i18n" | "prefix" | "versions">
626
+ ): NoteIndex => {
627
+ const entries = new Map<string, IndexedNote>();
628
+ const indexed = (note: ParsedNote): IndexedNote => {
629
+ const cached = entries.get(note.rel);
630
+ if (cached) {
631
+ return cached;
632
+ }
633
+ // The route the pipeline will assign, from the same resolver
634
+ // `normalizeEntry` uses — keyed by the same ref, slug, and frontmatter
635
+ // `slug` the staged entry carries, so the two cannot disagree.
636
+ const route = resolveEntryRoute(
637
+ { ref: note.rel, slug: entrySlugFor(note.placement.navPath) },
638
+ ".md",
639
+ isStringValue(note.data.slug) ? note.data.slug : undefined,
640
+ options
641
+ );
642
+ const built: IndexedNote = {
643
+ anchors: new Map(),
644
+ locales: route.locales,
645
+ logicalRoute: route.logicalRoute,
646
+ };
647
+ entries.set(note.rel, built);
648
+ return built;
649
+ };
650
+ const claims = new Map<string, [ParsedNote, ...ParsedNote[]]>();
651
+ for (const note of notes) {
652
+ for (const key of suffixKeysOf(note.rel)) {
653
+ const claimed = claims.get(key);
654
+ if (claimed) {
655
+ claimed.push(note);
656
+ } else {
657
+ claims.set(key, [note]);
658
+ }
659
+ }
660
+ }
661
+ const exactKeys = new Set(
662
+ notes.map((note) => indexKey(note.rel.replace(MARKDOWN_FILE, "")))
663
+ );
664
+ const index: LinkIndex = {
665
+ ambiguous: new Map(),
666
+ i18n: options.i18n,
667
+ notes: new Map(),
668
+ };
669
+ for (const [key, claimants] of claims) {
670
+ const [first, ...rest] = claimants;
671
+ if (rest.length > 0 && !key.includes("/") && !exactKeys.has(key)) {
672
+ index.ambiguous.set(
673
+ key,
674
+ `${key} (${claimants.map((note) => note.rel).join(", ")})`
675
+ );
676
+ }
677
+ index.notes.set(key, indexed(first));
678
+ }
679
+ const pairs = notes.map((note) => {
680
+ const self = indexed(note);
681
+ // Set last, over any name or suffix claim another note holds on this key:
682
+ // an exact vault-relative path is never ambiguous.
683
+ index.notes.set(indexKey(note.rel.replace(MARKDOWN_FILE, "")), self);
684
+ return { note, self };
685
+ });
686
+ // Anchors need every href in place (a heading may hold a wikilink), so they
687
+ // fill in after the routes.
688
+ for (const pair of pairs) {
689
+ fillAnchors(pair, index);
690
+ }
691
+ return { index, pairs };
692
+ };
693
+
694
+ /**
695
+ * An explicit frontmatter title wins, then the filename — how Obsidian titles
696
+ * notes. `index` names a route rather than a note, so an untitled one falls
697
+ * through to Blume's own derivation instead of publishing as "index".
698
+ */
699
+ const titleFor = (
700
+ rel: string,
701
+ frontmatterTitle: SourceEntry["data"][string]
702
+ ): string | undefined => {
703
+ if (isStringValue(frontmatterTitle)) {
704
+ return frontmatterTitle;
705
+ }
706
+ const noteName = basename(rel).replace(MARKDOWN_FILE, "");
707
+ return noteName.toLowerCase() === "index" ? undefined : noteName;
708
+ };
709
+
710
+ /**
711
+ * The declared keys a note keeps: the site-wide ones plus those of its own
712
+ * content type, resolved the way the meta parse resolves it (`type` falling
713
+ * back to the project default). A non-string `type` fails the strict parse
714
+ * later regardless, so which per-type list it picks never matters.
715
+ */
716
+ const keptKeysFor = (
717
+ data: SourceEntry["data"],
718
+ options: Pick<
719
+ ObsidianSourceOptions,
720
+ "defaultType" | "frontmatterKeys" | "typeFrontmatterKeys"
721
+ >
722
+ ): Set<string> => {
723
+ const entryType = isStringValue(data.type) ? data.type : options.defaultType;
724
+ const typeKeys =
725
+ entryType === undefined
726
+ ? []
727
+ : (options.typeFrontmatterKeys?.[entryType] ?? []);
728
+ return new Set([...(options.frontmatterKeys ?? []), ...typeKeys]);
729
+ };
730
+
731
+ /**
732
+ * Lower one vault note to a staged Markdown entry, keyed by the same route
733
+ * input the link index used when rewriting links to it. Frontmatter keeps
734
+ * what Blume's page meta accepts plus what the project declares for the note's
735
+ * type; every other Obsidian property (Dataview fields, Templater dates,
736
+ * `publish`, …) is dropped rather than failing the strict schema.
737
+ */
738
+ const noteToEntry = (
739
+ pair: IndexedPair,
740
+ index: LinkIndex,
741
+ options: Pick<
742
+ ObsidianSourceOptions,
743
+ "defaultType" | "frontmatterKeys" | "typeFrontmatterKeys"
744
+ >,
745
+ unresolved: UnresolvedTargets
746
+ ): SourceEntry => {
747
+ const { note } = pair;
748
+ const title = titleFor(note.rel, note.data.title);
749
+ const keep = keptKeysFor(note.data, options);
750
+ const data = Object.fromEntries(
751
+ Object.entries(note.data).filter(
752
+ ([key]) =>
753
+ !OBSIDIAN_NATIVE_KEYS.has(key) &&
754
+ (PAGE_META_KEYS.has(key) || keep.has(key))
755
+ )
756
+ );
757
+ const merged = title === undefined ? data : { ...data, title };
758
+ const text = transformBody(note.content.trim(), index, pair.self, unresolved);
759
+ const raw = matter.stringify(`${text}\n`, merged);
760
+ return {
761
+ body: { format: "md", text },
762
+ data: merged,
763
+ hash: hashText(raw),
764
+ raw,
765
+ ref: note.rel,
766
+ slug: entrySlugFor(note.placement.navPath),
767
+ sourcePath: note.absPath,
768
+ };
769
+ };
770
+
771
+ /**
772
+ * Read and split one note, or null when it vanished between the walk and the
773
+ * read. Obsidian renames and deletes notes while a dev server watches the
774
+ * vault, and one file disappearing mid-load must not fail the whole reload.
775
+ * Any other read failure still throws.
776
+ */
777
+ const readNote = async (
778
+ vaultDir: string,
779
+ rel: string,
780
+ options: Pick<ObsidianSourceOptions, "i18n" | "versions">
781
+ ): Promise<ParsedNote | null> => {
782
+ const absPath = join(vaultDir, rel);
783
+ try {
784
+ const { content, data } = matter(await readFile(absPath, "utf-8"));
785
+ return {
786
+ absPath,
787
+ content,
788
+ data,
789
+ // Version outermost, then locale, the way `normalizeEntry` reads the
790
+ // ref — the slug is built from what remains, so the pipeline's own
791
+ // re-prefixing does not stack a second `fr/` or `v1.0/` onto the route.
792
+ placement: placeEntryRef(rel, ".md", options),
793
+ rel,
794
+ };
795
+ } catch (error) {
796
+ // SAFETY: a rejected `readFile` always yields a Node system error, whose
797
+ // `code` is the only field read here.
798
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
799
+ return null;
800
+ }
801
+ throw error;
802
+ }
803
+ };
804
+
805
+ /** A directory entry with symlinks resolved to what they point at. */
806
+ interface VaultEntry {
807
+ directory: boolean;
808
+ file: boolean;
809
+ name: string;
810
+ }
811
+
812
+ /**
813
+ * A dot-name is never content: Obsidian's own `.obsidian/` and `.trash/`, and
814
+ * the caches plugins keep in dot-folders. The walk and the watcher apply the
815
+ * same rule, so a write into one of them neither publishes nor rescans.
816
+ */
817
+ const isHidden = (name: string): boolean => name.startsWith(".");
818
+
819
+ /**
820
+ * Classify one directory entry, following a symlink to what it points at the
821
+ * way the filesystem source's glob does — a vault that symlinks a shared
822
+ * folder in must publish it, not report every link into it as a missing
823
+ * note. A link that cannot be followed (dangling, or a loop) is left to the
824
+ * read when it names a note: a missing target is then the same vanished file
825
+ * a mid-load delete produces, and anything else is the real failure it is.
826
+ */
827
+ const classify = async (dir: string, entry: Dirent): Promise<VaultEntry> => {
828
+ if (!entry.isSymbolicLink()) {
829
+ return {
830
+ directory: entry.isDirectory(),
831
+ file: entry.isFile(),
832
+ name: entry.name,
833
+ };
834
+ }
835
+ try {
836
+ const target = await stat(join(dir, entry.name));
837
+ return {
838
+ directory: target.isDirectory(),
839
+ file: target.isFile(),
840
+ name: entry.name,
841
+ };
842
+ } catch {
843
+ return {
844
+ directory: false,
845
+ file: MARKDOWN_FILE.test(entry.name),
846
+ name: entry.name,
847
+ };
848
+ }
849
+ };
850
+
851
+ /**
852
+ * Vault order, the way Obsidian's file explorer lists a folder: subfolders
853
+ * first, then notes, each case-insensitively and numeric-aware (`Note 2`
854
+ * before `Note 10`), with a code-point tiebreak so the order is total.
855
+ */
856
+ const byVaultOrder = (a: VaultEntry, b: VaultEntry): number =>
857
+ Number(b.directory) - Number(a.directory) ||
858
+ a.name.localeCompare(b.name, "en", { numeric: true, sensitivity: "base" }) ||
859
+ (a.name < b.name ? -1 : 1);
860
+
861
+ /** Recursively list vault-relative `.md` paths, skipping dot/excluded dirs. */
862
+ const walkVault = async (
863
+ dir: string,
864
+ root: string,
865
+ exclude: ReadonlySet<string>
866
+ ): Promise<string[]> => {
867
+ const found: string[] = [];
868
+ const listed = await readdir(dir, { withFileTypes: true });
869
+ const entries = await Promise.all(
870
+ listed
871
+ .filter((entry) => !(isHidden(entry.name) || exclude.has(entry.name)))
872
+ .map((entry) => classify(dir, entry))
873
+ );
874
+ for (const entry of entries.toSorted(byVaultOrder)) {
875
+ const full = join(dir, entry.name);
876
+ if (entry.directory) {
877
+ // oxlint-disable-next-line no-await-in-loop -- vault trees are shallow; parallelizing complicates ordering for no measurable win.
878
+ found.push(...(await walkVault(full, root, exclude)));
879
+ } else if (entry.file && MARKDOWN_FILE.test(entry.name)) {
880
+ found.push(relative(root, full));
881
+ }
882
+ }
883
+ return found;
884
+ };
885
+
886
+ /** The first few dead targets, deduplicated, for a diagnostic message. */
887
+ const sample = (targets: string[]): string =>
888
+ [...new Set(targets)].slice(0, 5).join(", ");
889
+
890
+ /** The warnings one load raises: dead notes, then dead headings. */
891
+ const unresolvedDiagnostics = (
892
+ name: string,
893
+ unresolved: UnresolvedTargets
894
+ ): Diagnostic[] => {
895
+ const diagnostics: Diagnostic[] = [];
896
+ if (unresolved.notes.length > 0) {
897
+ diagnostics.push({
898
+ code: "BLUME_WIKILINK_UNRESOLVED",
899
+ message: `Source "${name}" found ${unresolved.notes.length} wikilink(s) to a missing note (${sample(unresolved.notes)}); rendered as plain text.`,
900
+ severity: "warning",
901
+ suggestion:
902
+ "Create the missing note, or fix the link target in Obsidian — note names are matched case-insensitively across the whole vault.",
903
+ });
904
+ }
905
+ if (unresolved.ambiguous.length > 0) {
906
+ const names = new Set(unresolved.ambiguous).size;
907
+ diagnostics.push({
908
+ code: "BLUME_WIKILINK_AMBIGUOUS",
909
+ message: `Source "${name}" found ${unresolved.ambiguous.length} wikilink(s) to ${names} note name(s) claimed by more than one file (${sample(unresolved.ambiguous)}); each resolves to the first note in vault order.`,
910
+ severity: "warning",
911
+ suggestion:
912
+ "Rename one of the notes, or link to the full vault-relative path (`[[folder/Note]]`) so the target is unambiguous.",
913
+ });
914
+ }
915
+ if (unresolved.anchors.length > 0) {
916
+ diagnostics.push({
917
+ code: "BLUME_WIKILINK_UNRESOLVED",
918
+ message: `Source "${name}" found ${unresolved.anchors.length} wikilink(s) to a missing heading (${sample(unresolved.anchors)}); linked to the page without an anchor.`,
919
+ severity: "warning",
920
+ suggestion:
921
+ "Fix the heading text in the link, or add the heading to the target note.",
922
+ });
923
+ }
924
+ return diagnostics;
925
+ };
926
+
927
+ /**
928
+ * The built-in Obsidian vault source: read a vault directly — no export step,
929
+ * no generated files in the user's repo — lowering Obsidian's dialect to
930
+ * Blume-ready Markdown at load time.
931
+ *
932
+ * Staged, since the body is rewritten before Blume sees it; `sourcePath` still
933
+ * points at the note, so diagnostics and relative image checks name the real
934
+ * file. A wikilink Blume cannot resolve degrades rather than failing the build.
935
+ */
936
+ export const obsidianSource = (
937
+ options: ObsidianSourceOptions,
938
+ ctx: SourceContext
939
+ ): ContentSource => {
940
+ const vaultDir = resolve(ctx.projectRoot, options.vault);
941
+ // The never-content directories every filesystem scan skips, so a vault
942
+ // rooted at the project (`vault: "."`) doesn't publish dependency READMEs
943
+ // or build output — and so the scan and the watcher agree on what is
944
+ // content.
945
+ const exclude = new Set([...BLUME_IGNORE_DIRS, ...(options.exclude ?? [])]);
946
+
947
+ const load = async (): Promise<SourceLoadResult> => {
948
+ const files = await walkVault(vaultDir, vaultDir, exclude);
949
+ const read = await Promise.all(
950
+ files.map((rel) => readNote(vaultDir, rel, options))
951
+ );
952
+ const notes = read.filter((note): note is ParsedNote => note !== null);
953
+ // Resolving `[[Note#H]]` needs the target's route and headings, so every
954
+ // note is parsed and indexed before any body is rewritten.
955
+ const { index, pairs } = buildLinkIndex(notes, options);
956
+ const unresolved = noTargets();
957
+ const entries = pairs.map((pair) =>
958
+ noteToEntry(pair, index, options, unresolved)
959
+ );
960
+ return {
961
+ diagnostics: unresolvedDiagnostics(options.name, unresolved),
962
+ entries,
963
+ };
964
+ };
965
+
966
+ // The note as written, for the SPI's lazy read. The lowered body is what
967
+ // `load` stages, and the pipeline reads that copy; this serves the vault
968
+ // file itself, the way the filesystem source does, without pinning a second
969
+ // copy of every note for the life of a dev server.
970
+ const read = async (ref: string): Promise<string> => {
971
+ const absPath = resolve(vaultDir, ref);
972
+ // Lexical containment only — `..` and sibling paths are refused, but a
973
+ // symlink inside the vault is followed wherever it points, the way
974
+ // `walkVault` and the filesystem source follow theirs. Vault symlinks are
975
+ // the author's own and are trusted; the Obsidian source docs say so.
976
+ const rel = relative(vaultDir, absPath);
977
+ if (rel.startsWith("..") || isAbsolute(rel)) {
978
+ throw new BlumeError({
979
+ code: "BLUME_SOURCE_MISCONFIGURED",
980
+ file: absPath,
981
+ message: `Source "${options.name}" cannot read "${ref}": it resolves outside the vault.`,
982
+ severity: "error",
983
+ suggestion: "Reference notes by their vault-relative path.",
984
+ });
985
+ }
986
+ return await readFile(absPath, "utf-8");
987
+ };
988
+
989
+ const validate = (): void => {
990
+ // `existsSync` alone also accepts a regular file, which `validate` would
991
+ // wave through and `load` would then fail on with a raw ENOTDIR.
992
+ const stats = statSync(vaultDir, { throwIfNoEntry: false });
993
+ if (stats?.isDirectory()) {
994
+ return;
995
+ }
996
+ const problem = stats ? "is not a directory" : "does not exist";
997
+ throw new BlumeError({
998
+ code: "BLUME_SOURCE_MISCONFIGURED",
999
+ file: vaultDir,
1000
+ message: `Source "${options.name}" points at "${options.vault}", which ${problem}.`,
1001
+ severity: "error",
1002
+ suggestion:
1003
+ "Set `vault` to your Obsidian vault directory, relative to the project root.",
1004
+ });
1005
+ };
1006
+
1007
+ // Obsidian rewrites `.obsidian/workspace.json` as you move a pane and moves
1008
+ // a deleted note into `.trash/`; plugins keep caches in dot-folders of their
1009
+ // own. The walk publishes none of them, so a write there must not trigger a
1010
+ // rescan either. See {@link ignoringWatchListener}.
1011
+ const watch = (onChange: () => void): (() => void) => {
1012
+ if (!existsSync(vaultDir)) {
1013
+ return () => {
1014
+ // Nothing to dispose when the vault doesn't exist yet.
1015
+ };
1016
+ }
1017
+ const watcher = fsWatch(
1018
+ vaultDir,
1019
+ { recursive: true },
1020
+ ignoringWatchListener(onChange, exclude, isHidden)
1021
+ );
1022
+ return () => watcher.close();
1023
+ };
1024
+
1025
+ return {
1026
+ // The vault is a real on-disk tree, so exposing it lets git last-modified
1027
+ // bound its log pathspec to the notes. Folder-meta discovery still skips
1028
+ // it — that scan is guarded on `staged`.
1029
+ contentRoot: vaultDir,
1030
+ load,
1031
+ name: options.name,
1032
+ prefix: options.prefix,
1033
+ read,
1034
+ staged: true,
1035
+ validate,
1036
+ watch,
1037
+ };
1038
+ };