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