blume 0.6.0 → 0.6.2
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/dist/cli/index.js +6070 -5718
- package/dist/cli/index.js.map +41 -40
- package/dist/types/core/config-input.d.ts +749 -0
- package/dist/types/core/config.d.ts +126 -3
- package/dist/types/core/schema.d.ts +10 -27
- package/dist/types/core/sources/types.d.ts +6 -0
- package/dist/types/index.d.ts +2 -1
- package/docs/advanced/changelog.mdx +10 -2
- package/docs/configuration/index.mdx +0 -2
- package/docs/content/syntax.mdx +4 -8
- package/package.json +1 -1
- package/src/astro/generate.ts +59 -24
- package/src/astro/markdown-negotiation.ts +12 -3
- package/src/astro/templates.ts +94 -12
- package/src/cli/commands/build.ts +26 -1
- package/src/cli/commands/dev.ts +30 -14
- package/src/cli/commands/doctor.ts +35 -7
- package/src/cli/commands/sync.ts +14 -2
- package/src/cli/dev-lock.ts +40 -10
- package/src/cli/env.ts +5 -1
- package/src/components/content/Update.astro +12 -2
- package/src/components/content/changelog-element.ts +62 -0
- package/src/components/islands/ask-ai.tsx +3 -1
- package/src/components/islands/hooks.ts +5 -1
- package/src/components/layout/Header.astro +10 -2
- package/src/components/layout/NavSelector.astro +5 -3
- package/src/components/layout/PageLayout.astro +2 -1
- package/src/components/layout/ReferenceLayout.astro +1 -0
- package/src/components/layout/RootLayout.astro +47 -10
- package/src/components/layout/Search.astro +8 -3
- package/src/components/layout/nav-utils.ts +7 -3
- package/src/core/config-input.ts +923 -0
- package/src/core/config.ts +126 -3
- package/src/core/i18n.ts +6 -5
- package/src/core/links.ts +16 -1
- package/src/core/meta.ts +112 -52
- package/src/core/navigation.ts +15 -5
- package/src/core/project-graph.ts +68 -2
- package/src/core/schema.ts +8 -14
- package/src/core/sources/assets.ts +21 -5
- package/src/core/sources/cache.ts +19 -1
- package/src/core/sources/github-releases.ts +9 -3
- package/src/core/sources/mdx-remote.ts +14 -4
- package/src/core/sources/normalize.ts +13 -1
- package/src/core/sources/notion.ts +43 -7
- package/src/core/sources/resolve.ts +44 -1
- package/src/core/sources/sanity.ts +9 -3
- package/src/core/sources/types.ts +6 -0
- package/src/deploy/adapter-output.ts +82 -0
- package/src/deploy/rss.ts +3 -1
- package/src/index.ts +1 -1
- package/src/markdown/code-title.ts +11 -4
- package/src/markdown/index.ts +28 -30
- package/src/markdown/math.ts +3 -2
- package/src/markdown/package-commands.ts +13 -0
- package/src/og/card.ts +3 -1
- package/src/openapi/model.ts +2 -1
- package/src/openapi/parse.ts +9 -1
- package/src/openapi/references.ts +11 -1
- package/src/openapi/render-mdx.ts +30 -3
- package/src/openapi/source.ts +3 -1
- package/src/registry/eject.ts +21 -14
- package/src/search/documents.ts +4 -1
- package/src/theme/entry.ts +10 -3
- package/src/theme/icons.ts +7 -11
|
@@ -27,14 +27,32 @@ export const entriesDigest = (entries: SourceEntry[]): string =>
|
|
|
27
27
|
* Build an opt-in polling watcher for a remote source: re-`load()` on an
|
|
28
28
|
* interval and fire `onChange` only when the entry digest changes, so a remote
|
|
29
29
|
* source can hot-reload in dev without refetching the world on every keystroke.
|
|
30
|
+
*
|
|
31
|
+
* `load` must fetch fresh (bypassing the cache-first dev path) — polling the
|
|
32
|
+
* cache-first loader would serve the identical snapshot on every tick and
|
|
33
|
+
* never observe a remote change. `seed` (the source's regular, cache-first
|
|
34
|
+
* loader) establishes the baseline digest from what dev actually served, so a
|
|
35
|
+
* remote change landing before the first tick still fires.
|
|
30
36
|
*/
|
|
31
37
|
export const pollingWatch =
|
|
32
38
|
(
|
|
33
39
|
load: () => Promise<SourceLoadResult>,
|
|
34
|
-
intervalSeconds: number
|
|
40
|
+
intervalSeconds: number,
|
|
41
|
+
seed?: () => Promise<SourceLoadResult>
|
|
35
42
|
): ((onChange: () => void) => () => void) =>
|
|
36
43
|
(onChange) => {
|
|
37
44
|
let last = "";
|
|
45
|
+
if (seed) {
|
|
46
|
+
const seedBaseline = async (): Promise<void> => {
|
|
47
|
+
try {
|
|
48
|
+
const { entries } = await seed();
|
|
49
|
+
last ||= entriesDigest(entries);
|
|
50
|
+
} catch {
|
|
51
|
+
// Fall back to first-tick seeding.
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
void seedBaseline();
|
|
55
|
+
}
|
|
38
56
|
const tick = async (): Promise<void> => {
|
|
39
57
|
try {
|
|
40
58
|
const { entries } = await load();
|
|
@@ -145,7 +145,9 @@ export const githubReleasesSource = (
|
|
|
145
145
|
return collected.slice(0, max);
|
|
146
146
|
};
|
|
147
147
|
|
|
148
|
-
const load = async (
|
|
148
|
+
const load = async (
|
|
149
|
+
refresh = ctx.refresh ?? true
|
|
150
|
+
): Promise<SourceLoadResult> => {
|
|
149
151
|
try {
|
|
150
152
|
const result = await loadWithCache(
|
|
151
153
|
options.name,
|
|
@@ -154,7 +156,7 @@ export const githubReleasesSource = (
|
|
|
154
156
|
const releases = await fetchReleases();
|
|
155
157
|
return releases.map(releaseToEntry);
|
|
156
158
|
},
|
|
157
|
-
|
|
159
|
+
refresh
|
|
158
160
|
);
|
|
159
161
|
snapshot = new Map(result.entries.map((entry) => [entry.ref, entry]));
|
|
160
162
|
return result;
|
|
@@ -194,7 +196,11 @@ export const githubReleasesSource = (
|
|
|
194
196
|
read,
|
|
195
197
|
staged: true,
|
|
196
198
|
watch: options.pollInterval
|
|
197
|
-
? pollingWatch(
|
|
199
|
+
? pollingWatch(
|
|
200
|
+
() => load(true),
|
|
201
|
+
options.pollInterval,
|
|
202
|
+
() => load()
|
|
203
|
+
)
|
|
198
204
|
: undefined,
|
|
199
205
|
};
|
|
200
206
|
};
|
|
@@ -45,10 +45,14 @@ const globToRegExp = (pattern: string): RegExp => {
|
|
|
45
45
|
const char = pattern[i] ?? "";
|
|
46
46
|
if (char === "*") {
|
|
47
47
|
if (pattern[i + 1] === "*") {
|
|
48
|
-
source += ".*";
|
|
49
48
|
i += 2;
|
|
49
|
+
// `**/` spans zero or more whole segments — `docs/**/guide.md` must
|
|
50
|
+
// match `docs/guide.md` and `docs/a/guide.md` but not `docs/subguide.md`.
|
|
50
51
|
if (pattern[i] === "/") {
|
|
51
52
|
i += 1;
|
|
53
|
+
source += "(?:.*/)?";
|
|
54
|
+
} else {
|
|
55
|
+
source += ".*";
|
|
52
56
|
}
|
|
53
57
|
continue;
|
|
54
58
|
}
|
|
@@ -205,7 +209,9 @@ export const mdxRemoteSource = (
|
|
|
205
209
|
};
|
|
206
210
|
};
|
|
207
211
|
|
|
208
|
-
const load = async (
|
|
212
|
+
const load = async (
|
|
213
|
+
refresh = ctx.refresh ?? true
|
|
214
|
+
): Promise<SourceLoadResult> => {
|
|
209
215
|
const skipped: Diagnostic[] = [];
|
|
210
216
|
const result = await loadWithCache(
|
|
211
217
|
options.name,
|
|
@@ -245,7 +251,7 @@ export const mdxRemoteSource = (
|
|
|
245
251
|
}
|
|
246
252
|
return entries;
|
|
247
253
|
},
|
|
248
|
-
|
|
254
|
+
refresh
|
|
249
255
|
);
|
|
250
256
|
snapshot = new Map(result.entries.map((entry) => [entry.ref, entry]));
|
|
251
257
|
return {
|
|
@@ -271,7 +277,11 @@ export const mdxRemoteSource = (
|
|
|
271
277
|
read,
|
|
272
278
|
staged: true,
|
|
273
279
|
watch: options.pollInterval
|
|
274
|
-
? pollingWatch(
|
|
280
|
+
? pollingWatch(
|
|
281
|
+
() => load(true),
|
|
282
|
+
options.pollInterval,
|
|
283
|
+
() => load()
|
|
284
|
+
)
|
|
275
285
|
: undefined,
|
|
276
286
|
};
|
|
277
287
|
};
|
|
@@ -81,7 +81,9 @@ const mapRoute = (
|
|
|
81
81
|
};
|
|
82
82
|
|
|
83
83
|
const CODE_FENCE = /^```/u;
|
|
84
|
-
|
|
84
|
+
// A closing hash sequence must be preceded by whitespace (CommonMark), so a
|
|
85
|
+
// heading like `## What is C#` keeps its trailing `#`.
|
|
86
|
+
const ATX_HEADING = /^(?<hashes>#{1,6})\s+(?<text>.+?)(?:\s+#+)?\s*$/u;
|
|
85
87
|
|
|
86
88
|
/**
|
|
87
89
|
* Extract ATX headings from a markdown body, skipping fenced code blocks. Each
|
|
@@ -260,6 +262,16 @@ export const normalizeEntry = (
|
|
|
260
262
|
|
|
261
263
|
const meta = result.data;
|
|
262
264
|
|
|
265
|
+
// Top-level `hidden`/`noindex` are accepted as shorthands for their nested
|
|
266
|
+
// equivalents — the schema declares them, so silently ignoring them would
|
|
267
|
+
// strand authors with no diagnostic.
|
|
268
|
+
if (meta.hidden) {
|
|
269
|
+
meta.sidebar.hidden = true;
|
|
270
|
+
}
|
|
271
|
+
if (meta.noindex) {
|
|
272
|
+
meta.seo.noindex = true;
|
|
273
|
+
}
|
|
274
|
+
|
|
263
275
|
// Locale and the locale-stripped nav path come from the entry's ref (a leading
|
|
264
276
|
// dir, or a filename suffix under the `dot` parser), not the slug — the slug is
|
|
265
277
|
// the logical, locale-agnostic path within a locale. A shared `$` file maps to
|
|
@@ -335,10 +335,40 @@ export const notionSource = (
|
|
|
335
335
|
blocks: NotionBlock[]
|
|
336
336
|
): Promise<string> => {
|
|
337
337
|
const parts = await Promise.all(
|
|
338
|
-
blocks.map(
|
|
339
|
-
(block)
|
|
340
|
-
|
|
341
|
-
|
|
338
|
+
blocks.map(async (block) => {
|
|
339
|
+
const leaf = renderLeaf(block);
|
|
340
|
+
if (leaf === null) {
|
|
341
|
+
return renderContainer(client, block, renderBlocks);
|
|
342
|
+
}
|
|
343
|
+
// Leaf blocks can still carry children (nested list items, indented
|
|
344
|
+
// paragraphs); dropping them would silently lose content.
|
|
345
|
+
if (!block.has_children) {
|
|
346
|
+
return leaf;
|
|
347
|
+
}
|
|
348
|
+
const nested = await renderBlocks(
|
|
349
|
+
client,
|
|
350
|
+
await childrenOf(client, block.id)
|
|
351
|
+
);
|
|
352
|
+
if (!nested) {
|
|
353
|
+
return leaf;
|
|
354
|
+
}
|
|
355
|
+
if (isListItem(block)) {
|
|
356
|
+
// Indent past the list marker so the children belong to the item
|
|
357
|
+
// (`1. ` needs three columns, `- `/`- [x] ` two).
|
|
358
|
+
const indent = " ".repeat(
|
|
359
|
+
block.type === "numbered_list_item" ? 3 : 2
|
|
360
|
+
);
|
|
361
|
+
const indented = nested
|
|
362
|
+
.split("\n")
|
|
363
|
+
.map((line) => (line ? `${indent}${line}` : line))
|
|
364
|
+
.join("\n");
|
|
365
|
+
return `${leaf}\n${indented}`;
|
|
366
|
+
}
|
|
367
|
+
// Other leaves (paragraph, quote) keep their children as following
|
|
368
|
+
// sibling blocks — the indentation semantics are lost but the content
|
|
369
|
+
// survives.
|
|
370
|
+
return `${leaf}\n\n${nested}`;
|
|
371
|
+
})
|
|
342
372
|
);
|
|
343
373
|
// Join with a blank line, except between consecutive list items, which stay
|
|
344
374
|
// tight so they render as a single list rather than separate loose ones.
|
|
@@ -430,7 +460,9 @@ export const notionSource = (
|
|
|
430
460
|
};
|
|
431
461
|
};
|
|
432
462
|
|
|
433
|
-
const load = async (
|
|
463
|
+
const load = async (
|
|
464
|
+
refresh = ctx?.refresh ?? true
|
|
465
|
+
): Promise<SourceLoadResult> => {
|
|
434
466
|
const assetDiagnostics: Diagnostic[] = [];
|
|
435
467
|
const result = await loadWithCache(
|
|
436
468
|
options.name,
|
|
@@ -453,7 +485,7 @@ export const notionSource = (
|
|
|
453
485
|
}
|
|
454
486
|
return built.map((item) => item.entry);
|
|
455
487
|
},
|
|
456
|
-
|
|
488
|
+
refresh
|
|
457
489
|
);
|
|
458
490
|
snapshot = new Map(result.entries.map((entry) => [entry.ref, entry]));
|
|
459
491
|
return {
|
|
@@ -478,7 +510,11 @@ export const notionSource = (
|
|
|
478
510
|
read,
|
|
479
511
|
staged: true,
|
|
480
512
|
watch: options.pollInterval
|
|
481
|
-
? pollingWatch(
|
|
513
|
+
? pollingWatch(
|
|
514
|
+
() => load(true),
|
|
515
|
+
options.pollInterval,
|
|
516
|
+
() => load()
|
|
517
|
+
)
|
|
482
518
|
: undefined,
|
|
483
519
|
};
|
|
484
520
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { join } from "pathe";
|
|
1
|
+
import { isAbsolute, join, resolve } from "pathe";
|
|
2
2
|
|
|
3
3
|
import { blumeReferences } from "../../openapi/references.ts";
|
|
4
4
|
import { openApiSource } from "../../openapi/source.ts";
|
|
@@ -125,6 +125,49 @@ const buildSource = (
|
|
|
125
125
|
);
|
|
126
126
|
};
|
|
127
127
|
|
|
128
|
+
/** Resolve a source `root` against the project root (absolute passes through). */
|
|
129
|
+
const resolveRoot = (projectRoot: string, root: string): string =>
|
|
130
|
+
isAbsolute(root) ? root : join(resolve(projectRoot), root);
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The generated `docs` glob collection: its base directory and the include /
|
|
134
|
+
* exclude globs applied under it. Astro's glob loader ids each entry by its path
|
|
135
|
+
* relative to `base`, and a filesystem source ids each entry relative to its own
|
|
136
|
+
* root — so the two only agree when the collection is rooted at that source. A
|
|
137
|
+
* project with exactly one filesystem source therefore roots the collection at
|
|
138
|
+
* *that* source (honoring a non-default `root`), rather than the global
|
|
139
|
+
* `content.root`. With no sources (the implicit source) or several, the base
|
|
140
|
+
* stays `content.root`; a second filesystem source rooted elsewhere can't share
|
|
141
|
+
* one base and is caught by the entry-id guard in `scanProject`.
|
|
142
|
+
*/
|
|
143
|
+
export interface DocsCollection {
|
|
144
|
+
base: string;
|
|
145
|
+
include: string[];
|
|
146
|
+
exclude: string[];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export const resolveDocsCollection = (
|
|
150
|
+
config: ResolvedConfig,
|
|
151
|
+
context: ProjectContext
|
|
152
|
+
): DocsCollection => {
|
|
153
|
+
const filesystem = (config.content.sources ?? []).filter(
|
|
154
|
+
(def) => def.type === "filesystem"
|
|
155
|
+
);
|
|
156
|
+
const only = filesystem.length === 1 ? filesystem[0] : undefined;
|
|
157
|
+
if (only) {
|
|
158
|
+
return {
|
|
159
|
+
base: resolveRoot(context.root, only.root),
|
|
160
|
+
exclude: only.exclude,
|
|
161
|
+
include: only.include,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
base: context.contentRoot,
|
|
166
|
+
exclude: config.content.exclude,
|
|
167
|
+
include: config.content.include,
|
|
168
|
+
};
|
|
169
|
+
};
|
|
170
|
+
|
|
128
171
|
/** The base name to allocate for a source config (before deduplication). */
|
|
129
172
|
const baseName = (def: ContentSourceConfig): string => {
|
|
130
173
|
if (def.type === "custom") {
|
|
@@ -183,7 +183,9 @@ export const sanitySource = (
|
|
|
183
183
|
};
|
|
184
184
|
};
|
|
185
185
|
|
|
186
|
-
const load = async (
|
|
186
|
+
const load = async (
|
|
187
|
+
refresh = ctx?.refresh ?? true
|
|
188
|
+
): Promise<SourceLoadResult> => {
|
|
187
189
|
const result = await loadWithCache(
|
|
188
190
|
options.name,
|
|
189
191
|
cache,
|
|
@@ -194,7 +196,7 @@ export const sanitySource = (
|
|
|
194
196
|
);
|
|
195
197
|
return docs.map(toEntry);
|
|
196
198
|
},
|
|
197
|
-
|
|
199
|
+
refresh
|
|
198
200
|
);
|
|
199
201
|
snapshot = new Map(result.entries.map((entry) => [entry.ref, entry]));
|
|
200
202
|
return result;
|
|
@@ -216,7 +218,11 @@ export const sanitySource = (
|
|
|
216
218
|
read,
|
|
217
219
|
staged: true,
|
|
218
220
|
watch: options.pollInterval
|
|
219
|
-
? pollingWatch(
|
|
221
|
+
? pollingWatch(
|
|
222
|
+
() => load(true),
|
|
223
|
+
options.pollInterval,
|
|
224
|
+
() => load()
|
|
225
|
+
)
|
|
220
226
|
: undefined,
|
|
221
227
|
};
|
|
222
228
|
};
|
|
@@ -84,6 +84,12 @@ export interface ContentSource {
|
|
|
84
84
|
readonly staged: boolean;
|
|
85
85
|
/** Optional route prefix; the source's routes namespace under `/<prefix>/`. */
|
|
86
86
|
readonly prefix?: string;
|
|
87
|
+
/**
|
|
88
|
+
* Resolved on-disk root, set by filesystem-backed sources only. Drives
|
|
89
|
+
* folder-meta discovery (scan under this root) and the docs-collection base;
|
|
90
|
+
* omitted by remote/CMS/staged sources that have no local tree.
|
|
91
|
+
*/
|
|
92
|
+
readonly contentRoot?: string;
|
|
87
93
|
/** Pull every entry. Called once per scan. */
|
|
88
94
|
load: () => Promise<SourceLoadResult>;
|
|
89
95
|
/** Validate the source is usable; throws a BlumeError when not. */
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { cp, mkdir, rm } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
import { dirname, join } from "pathe";
|
|
5
|
+
|
|
6
|
+
import type { ResolvedConfig } from "../core/schema.ts";
|
|
7
|
+
import type { ProjectContext } from "../core/types.ts";
|
|
8
|
+
|
|
9
|
+
type Adapter = NonNullable<ResolvedConfig["deployment"]["adapter"]>;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Server adapters whose deploy bundle lands *outside* Astro's `outDir`, at a
|
|
13
|
+
* path relative to the Astro project root. Blume points the Astro root at the
|
|
14
|
+
* hidden `<root>/.blume` runtime, so these adapters write their bundle to
|
|
15
|
+
* `<root>/.blume/<path>` — where the deploy platform never looks. Each value is
|
|
16
|
+
* the sub-path to surface up to the real project root.
|
|
17
|
+
*
|
|
18
|
+
* `vercel` writes a Build Output API v3 tree at `.vercel/output`; only that
|
|
19
|
+
* subtree is moved, so a `vercel pull`-ed `.vercel/project.json` sitting at the
|
|
20
|
+
* project root survives the relocation. `netlify` owns its whole `.netlify`
|
|
21
|
+
* dir. `node` and `cloudflare` emit into `dist/` (already at the project root),
|
|
22
|
+
* so they are absent here and need no relocation.
|
|
23
|
+
*/
|
|
24
|
+
export const ADAPTER_OUTPUT_PATHS: Partial<Record<Adapter, string>> = {
|
|
25
|
+
netlify: ".netlify",
|
|
26
|
+
vercel: ".vercel/output",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Directory whose contents the deploy platform serves as static files. Build
|
|
31
|
+
* artifacts (robots.txt, sitemap.xml, llms.txt, …) must be written here to be
|
|
32
|
+
* served. For a Vercel server build that is the adapter's
|
|
33
|
+
* `.vercel/output/static`; every other build serves `dist/`.
|
|
34
|
+
*/
|
|
35
|
+
export const deployStaticDir = (
|
|
36
|
+
config: ResolvedConfig,
|
|
37
|
+
context: ProjectContext
|
|
38
|
+
): string => {
|
|
39
|
+
const { adapter, output } = config.deployment;
|
|
40
|
+
if (output === "server" && adapter === "vercel") {
|
|
41
|
+
return join(context.root, ".vercel", "output", "static");
|
|
42
|
+
}
|
|
43
|
+
return context.distDir ?? join(context.root, "dist");
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Outcome of {@link surfaceAdapterOutput}, for logging and `.gitignore`. */
|
|
47
|
+
export type SurfaceResult =
|
|
48
|
+
| { moved: false }
|
|
49
|
+
| { from: string; ignore: string; moved: true; to: string };
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Move a server adapter's deploy bundle out of the hidden `.blume` runtime and
|
|
53
|
+
* up to the project root, where the deploy platform (and `vercel deploy
|
|
54
|
+
* --prebuilt`) expects it. A no-op for static builds, for adapters that emit
|
|
55
|
+
* into `dist/`, and when the expected output is absent.
|
|
56
|
+
*/
|
|
57
|
+
export const surfaceAdapterOutput = async (
|
|
58
|
+
config: ResolvedConfig,
|
|
59
|
+
context: ProjectContext
|
|
60
|
+
): Promise<SurfaceResult> => {
|
|
61
|
+
const { adapter, output } = config.deployment;
|
|
62
|
+
if (output !== "server" || !adapter) {
|
|
63
|
+
return { moved: false };
|
|
64
|
+
}
|
|
65
|
+
const rel = ADAPTER_OUTPUT_PATHS[adapter];
|
|
66
|
+
if (!rel) {
|
|
67
|
+
return { moved: false };
|
|
68
|
+
}
|
|
69
|
+
const from = join(context.outDir, rel);
|
|
70
|
+
const to = join(context.root, rel);
|
|
71
|
+
if (!existsSync(from)) {
|
|
72
|
+
return { moved: false };
|
|
73
|
+
}
|
|
74
|
+
await mkdir(dirname(to), { recursive: true });
|
|
75
|
+
await rm(to, { force: true, recursive: true });
|
|
76
|
+
await cp(from, to, { recursive: true });
|
|
77
|
+
await rm(from, { force: true, recursive: true });
|
|
78
|
+
// The `.gitignore` entry is the surfaced top-level dir (`.vercel`/`.netlify`),
|
|
79
|
+
// never the moved sub-path — Vercel's own `.vercel/project.json` lives there
|
|
80
|
+
// too and must also be ignored.
|
|
81
|
+
return { from, ignore: `${rel.split("/")[0]}/`, moved: true, to };
|
|
82
|
+
};
|
package/src/deploy/rss.ts
CHANGED
|
@@ -68,7 +68,9 @@ export const buildRssFeeds = (project: BlumeProject): RssFeed[] => {
|
|
|
68
68
|
.map((page) => ({
|
|
69
69
|
date: pageDate(page),
|
|
70
70
|
description: page.description,
|
|
71
|
-
|
|
71
|
+
// Encode like the sitemap does: a route with spaces or non-ASCII
|
|
72
|
+
// must still yield a valid <link>/<guid> URL after XML decoding.
|
|
73
|
+
link: encodeURI(`${base}${page.route}`),
|
|
72
74
|
title: page.title,
|
|
73
75
|
}))
|
|
74
76
|
.toSorted((a, b) => (b.date?.getTime() ?? 0) - (a.date?.getTime() ?? 0))
|
package/src/index.ts
CHANGED
|
@@ -22,8 +22,8 @@ export type {
|
|
|
22
22
|
FolderMetaFactory,
|
|
23
23
|
} from "./core/define-meta.ts";
|
|
24
24
|
export type { UIStrings } from "./core/i18n-ui.ts";
|
|
25
|
+
export type { BlumeConfig } from "./core/config-input.ts";
|
|
25
26
|
export type {
|
|
26
|
-
BlumeConfig,
|
|
27
27
|
FolderMeta,
|
|
28
28
|
HydrationMode,
|
|
29
29
|
ResolvedConfig,
|
|
@@ -26,9 +26,16 @@ export interface CodeTitleTransformer {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
// The body excludes only the delimiting quote, so `title="foo's file.ts"`
|
|
29
|
-
// (an apostrophe inside double quotes) still matches.
|
|
30
|
-
|
|
29
|
+
// (an apostrophe inside double quotes) still matches. The left boundary stops
|
|
30
|
+
// `subtitle="..."` (or any `*title=` attr) from reading as a title.
|
|
31
|
+
const TITLE_ATTR = /(?:^|\s)title=(?:"(?<dq>[^"]*)"|'(?<sq>[^']*)')/u;
|
|
31
32
|
const LINE_NUMBERS = /(?:^|\s)lineNumbers(?=\s|$)/u;
|
|
33
|
+
// Any quoted `key="..."` attr — blanked before keyword/bare-token scans so a
|
|
34
|
+
// quoted value can't leak tokens (`title="enable lineNumbers later"`).
|
|
35
|
+
const QUOTED_ATTR = /[\w-]+=(?:"[^"]*"|'[^']*')/gu;
|
|
36
|
+
|
|
37
|
+
const withoutQuotedAttrs = (raw: string): string =>
|
|
38
|
+
raw.replace(QUOTED_ATTR, " ");
|
|
32
39
|
|
|
33
40
|
const parseTitle = (raw: string | undefined): string | undefined => {
|
|
34
41
|
if (!raw) {
|
|
@@ -42,7 +49,7 @@ const parseTitle = (raw: string | undefined): string | undefined => {
|
|
|
42
49
|
// The first bare token is the title (```ts blume.config.ts), skipping Shiki
|
|
43
50
|
// line ranges (`{1,3-5}`), `key=value` attrs, and the reserved `lineNumbers`
|
|
44
51
|
// and `twoslash` keywords.
|
|
45
|
-
return raw
|
|
52
|
+
return withoutQuotedAttrs(raw)
|
|
46
53
|
.trim()
|
|
47
54
|
.split(/\s+/u)
|
|
48
55
|
.find(
|
|
@@ -56,7 +63,7 @@ const parseTitle = (raw: string | undefined): string | undefined => {
|
|
|
56
63
|
};
|
|
57
64
|
|
|
58
65
|
const hasLineNumbers = (raw: string | undefined): boolean =>
|
|
59
|
-
Boolean(raw && LINE_NUMBERS.test(raw));
|
|
66
|
+
Boolean(raw && LINE_NUMBERS.test(withoutQuotedAttrs(raw)));
|
|
60
67
|
|
|
61
68
|
/** Build the transformer. Runs after Shiki's built-in `data-language` hook. */
|
|
62
69
|
export const codeTitleTransformer = (): CodeTitleTransformer => ({
|
package/src/markdown/index.ts
CHANGED
|
@@ -46,15 +46,16 @@ type HastPlugin = NonNullable<
|
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
48
|
* Hast plugins enabled by config. Inline `` `code`{:lang} `` highlighting is
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
49
|
+
* always on: it only fires on an explicit trailing `{:lang}` marker, so plain
|
|
50
|
+
* inline code is untouched and there's nothing to opt out of. Self-linking
|
|
51
|
+
* heading anchors (`<h2>`–`<h6>` wrapped in an `<a>` to their own id) are on
|
|
52
|
+
* unless `markdown.headingAnchors` is `false`. Inline code runs first so the
|
|
53
|
+
* anchor wrap re-refs already-highlighted code.
|
|
52
54
|
*/
|
|
53
55
|
const blumeHastPlugins = (options: BlumeMarkdownOptions): HastPlugin[] => {
|
|
54
|
-
const plugins: HastPlugin[] = [
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
56
|
+
const plugins: HastPlugin[] = [
|
|
57
|
+
inlineCodeHighlightPlugin() as unknown as HastPlugin,
|
|
58
|
+
];
|
|
58
59
|
if (options.headingAnchors !== false) {
|
|
59
60
|
plugins.push(headingAnchorPlugin() as unknown as HastPlugin);
|
|
60
61
|
}
|
|
@@ -204,8 +205,6 @@ export interface BlumeMarkdownOptions {
|
|
|
204
205
|
* On unless explicitly `false`.
|
|
205
206
|
*/
|
|
206
207
|
headingAnchors?: boolean;
|
|
207
|
-
/** Highlight inline `` `code`{:lang} `` snippets (`markdown.code.inline`). */
|
|
208
|
-
inline?: boolean;
|
|
209
208
|
}
|
|
210
209
|
|
|
211
210
|
/** Sätteri processor for plain `.md`, with Blume's curated feature set. */
|
|
@@ -215,38 +214,37 @@ export const blumeMarkdownProcessor = (options: BlumeMarkdownOptions = {}) =>
|
|
|
215
214
|
hastPlugins: blumeHastPlugins(options),
|
|
216
215
|
});
|
|
217
216
|
|
|
218
|
-
export
|
|
219
|
-
/** Enable KaTeX math parsing and rendering. */
|
|
220
|
-
math?: boolean;
|
|
221
|
-
}
|
|
217
|
+
export type BlumeMdxOptions = BlumeMarkdownOptions;
|
|
222
218
|
|
|
223
219
|
/**
|
|
224
220
|
* Sätteri MDX processor: Blume's feature set plus the MDAST plugins that target
|
|
225
221
|
* components — `package-install` → package-manager tabs, `:::note` →
|
|
226
|
-
* `<Callout>`,
|
|
227
|
-
*
|
|
228
|
-
* `.mdx` only (plain `.md` uses
|
|
229
|
-
*
|
|
222
|
+
* `<Callout>`, ` ```mermaid ` → a `<blume-mermaid>` element, and block math
|
|
223
|
+
* (`$$…$$`) → the `<Math>` component. Used as the `processor` for
|
|
224
|
+
* `@astrojs/mdx` so these apply to `.mdx` only (plain `.md` uses
|
|
225
|
+
* {@link blumeMarkdownProcessor}).
|
|
226
|
+
*
|
|
227
|
+
* Math is always on but block-only: `singleDollarTextMath: false` keeps a bare
|
|
228
|
+
* `$` (currency, shell, code) as literal text and only parses `$$…$$`. The
|
|
229
|
+
* generated runtime imports the `<Math>` component (and KaTeX's stylesheet) only
|
|
230
|
+
* when content actually uses `$$`, so a math-free site ships no KaTeX CSS.
|
|
230
231
|
*
|
|
231
232
|
* The plugins are modeled with minimal structural types; bridge them to
|
|
232
233
|
* Satteri's full `MdastPlugin` type at this single boundary.
|
|
233
234
|
*/
|
|
234
|
-
export const blumeMdxProcessor = (options: BlumeMdxOptions = {}) =>
|
|
235
|
-
|
|
236
|
-
packageInstallPlugin(),
|
|
237
|
-
directiveToCalloutPlugin(),
|
|
238
|
-
mermaidPlugin(),
|
|
239
|
-
];
|
|
240
|
-
if (options.math) {
|
|
241
|
-
plugins.push(mathPlugin());
|
|
242
|
-
}
|
|
243
|
-
return satteri({
|
|
235
|
+
export const blumeMdxProcessor = (options: BlumeMdxOptions = {}) =>
|
|
236
|
+
satteri({
|
|
244
237
|
features: {
|
|
245
238
|
...FEATURES,
|
|
246
239
|
directive: true,
|
|
247
|
-
|
|
240
|
+
// Block-only: `$$…$$` parses, a bare `$` stays literal text.
|
|
241
|
+
math: { singleDollarTextMath: false },
|
|
248
242
|
},
|
|
249
243
|
hastPlugins: blumeHastPlugins(options),
|
|
250
|
-
mdastPlugins:
|
|
244
|
+
mdastPlugins: [
|
|
245
|
+
packageInstallPlugin(),
|
|
246
|
+
directiveToCalloutPlugin(),
|
|
247
|
+
mermaidPlugin(),
|
|
248
|
+
mathPlugin(),
|
|
249
|
+
] as unknown as MdastPlugin[],
|
|
251
250
|
});
|
|
252
|
-
};
|
package/src/markdown/math.ts
CHANGED
|
@@ -8,8 +8,9 @@ interface MathNode extends MdastNode {
|
|
|
8
8
|
/**
|
|
9
9
|
* Satteri MDAST plugin that turns math nodes into Blume's `<Math>` component,
|
|
10
10
|
* which renders them with KaTeX at build time. Block math (`$$…$$`) becomes a
|
|
11
|
-
* block element
|
|
12
|
-
*
|
|
11
|
+
* block element. Blume runs the parser block-only (`singleDollarTextMath:
|
|
12
|
+
* false`), so a bare `$` stays literal and no `inlineMath` nodes are produced;
|
|
13
|
+
* the `inlineMath` visitor remains as a harmless safety net.
|
|
13
14
|
*/
|
|
14
15
|
export const mathPlugin = () => ({
|
|
15
16
|
inlineMath(node: MathNode, ctx: MdastVisitorContext) {
|
|
@@ -98,6 +98,19 @@ const parseIntent = (input: string): Intent => {
|
|
|
98
98
|
if (!verb) {
|
|
99
99
|
return { args: [], operation: "install" };
|
|
100
100
|
}
|
|
101
|
+
// Yarn Classic spells global installs `yarn global <add|remove> …`; map it
|
|
102
|
+
// onto the flag-style intent so every manager renders its own global form
|
|
103
|
+
// instead of falling into the run-as-script branch.
|
|
104
|
+
if (first === "yarn" && verb === "global") {
|
|
105
|
+
const [globalVerb, ...globalArgs] = verbArgs;
|
|
106
|
+
const globalOp = globalVerb ? normalizeVerb(globalVerb) : null;
|
|
107
|
+
if (globalOp === "add" || globalOp === "remove") {
|
|
108
|
+
return {
|
|
109
|
+
args: [...normalizeFlags(globalArgs), "-g"],
|
|
110
|
+
operation: globalOp,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
101
114
|
const operation = normalizeVerb(verb);
|
|
102
115
|
if (operation === null) {
|
|
103
116
|
// Unknown subcommand (e.g. `npm test`); run it as a script.
|
package/src/og/card.ts
CHANGED
|
@@ -145,7 +145,9 @@ export const renderOgImage = (options: OgCardOptions): Promise<Buffer> => {
|
|
|
145
145
|
const accent = resolveAccent(options.accent ?? "blue");
|
|
146
146
|
const brand = options.brand?.trim();
|
|
147
147
|
const logo = options.logo?.trim();
|
|
148
|
-
|
|
148
|
+
// Slice by code point, not code unit — `charAt(0)` would split a leading
|
|
149
|
+
// surrogate pair (an emoji brand initial) into a lone half that renders blank.
|
|
150
|
+
const initial = brand ? ([...brand][0]?.toUpperCase() ?? "") : "";
|
|
149
151
|
const description = options.description?.trim();
|
|
150
152
|
const repo = options.repo?.trim();
|
|
151
153
|
const site = options.site?.trim();
|
package/src/openapi/model.ts
CHANGED
|
@@ -144,7 +144,8 @@ export const extractOperations = (
|
|
|
144
144
|
method,
|
|
145
145
|
operationId: operation.operationId,
|
|
146
146
|
path,
|
|
147
|
-
route:
|
|
147
|
+
// A root-mounted reference (`route: "/"`) must not emit `//tag/key`.
|
|
148
|
+
route: `${baseRoute === "/" ? "" : baseRoute}/${tagSlug}/${key}`,
|
|
148
149
|
summary: operation.summary ?? "",
|
|
149
150
|
tag,
|
|
150
151
|
tagSlug,
|
package/src/openapi/parse.ts
CHANGED
|
@@ -26,6 +26,9 @@ const URL_SPEC = /^https?:\/\//u;
|
|
|
26
26
|
const FETCH_TIMEOUT_MS = 15_000;
|
|
27
27
|
const MAX_ATTEMPTS = 3;
|
|
28
28
|
const BASE_BACKOFF_MS = 500;
|
|
29
|
+
// Honor Retry-After only up to a sane ceiling: a server answering with
|
|
30
|
+
// `Retry-After: 3600` must not stall a build for an hour per attempt.
|
|
31
|
+
const MAX_RETRY_WAIT_MS = 10_000;
|
|
29
32
|
const SECOND_MS = 1000;
|
|
30
33
|
// Worth another try: request timeout, too-early, rate-limited, and the 5xx range.
|
|
31
34
|
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
@@ -138,7 +141,12 @@ const fetchSpecText = async (spec: string): Promise<string> => {
|
|
|
138
141
|
throw last.error;
|
|
139
142
|
}
|
|
140
143
|
// oxlint-disable-next-line no-await-in-loop -- back off before retrying
|
|
141
|
-
await sleep(
|
|
144
|
+
await sleep(
|
|
145
|
+
Math.min(
|
|
146
|
+
last.retryAfter ?? BASE_BACKOFF_MS * 2 ** attempt,
|
|
147
|
+
MAX_RETRY_WAIT_MS
|
|
148
|
+
)
|
|
149
|
+
);
|
|
142
150
|
}
|
|
143
151
|
throw last.error;
|
|
144
152
|
};
|
|
@@ -145,6 +145,7 @@ export const referenceTabs = (config: ResolvedConfig): NavTab[] =>
|
|
|
145
145
|
/** Blume-rendered OpenAPI references, deduped by route (first wins). */
|
|
146
146
|
export const blumeReferences = (config: ResolvedConfig): ReferenceSource[] => {
|
|
147
147
|
const seen = new Set<string>();
|
|
148
|
+
const usedSlugs = new Set<string>();
|
|
148
149
|
const result: ReferenceSource[] = [];
|
|
149
150
|
for (const ref of resolveReferences(config)) {
|
|
150
151
|
if (ref.kind !== "openapi" || ref.renderer !== "blume") {
|
|
@@ -154,7 +155,16 @@ export const blumeReferences = (config: ResolvedConfig): ReferenceSource[] => {
|
|
|
154
155
|
continue;
|
|
155
156
|
}
|
|
156
157
|
seen.add(ref.route);
|
|
157
|
-
|
|
158
|
+
// Distinct routes can slugify identically (`/api/v1` and `/api-v1` both
|
|
159
|
+
// yield `api-v1`). The slug keys the `blume:openapi` data module, so a
|
|
160
|
+
// collision would let one spec silently overwrite the other while the
|
|
161
|
+
// loser's pages still point at the shared key — disambiguate.
|
|
162
|
+
let { slug } = ref;
|
|
163
|
+
for (let n = 2; usedSlugs.has(slug); n += 1) {
|
|
164
|
+
slug = `${ref.slug}-${n}`;
|
|
165
|
+
}
|
|
166
|
+
usedSlugs.add(slug);
|
|
167
|
+
result.push(slug === ref.slug ? ref : { ...ref, slug });
|
|
158
168
|
}
|
|
159
169
|
return result;
|
|
160
170
|
};
|