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.
Files changed (65) hide show
  1. package/dist/cli/index.js +6070 -5718
  2. package/dist/cli/index.js.map +41 -40
  3. package/dist/types/core/config-input.d.ts +749 -0
  4. package/dist/types/core/config.d.ts +126 -3
  5. package/dist/types/core/schema.d.ts +10 -27
  6. package/dist/types/core/sources/types.d.ts +6 -0
  7. package/dist/types/index.d.ts +2 -1
  8. package/docs/advanced/changelog.mdx +10 -2
  9. package/docs/configuration/index.mdx +0 -2
  10. package/docs/content/syntax.mdx +4 -8
  11. package/package.json +1 -1
  12. package/src/astro/generate.ts +59 -24
  13. package/src/astro/markdown-negotiation.ts +12 -3
  14. package/src/astro/templates.ts +94 -12
  15. package/src/cli/commands/build.ts +26 -1
  16. package/src/cli/commands/dev.ts +30 -14
  17. package/src/cli/commands/doctor.ts +35 -7
  18. package/src/cli/commands/sync.ts +14 -2
  19. package/src/cli/dev-lock.ts +40 -10
  20. package/src/cli/env.ts +5 -1
  21. package/src/components/content/Update.astro +12 -2
  22. package/src/components/content/changelog-element.ts +62 -0
  23. package/src/components/islands/ask-ai.tsx +3 -1
  24. package/src/components/islands/hooks.ts +5 -1
  25. package/src/components/layout/Header.astro +10 -2
  26. package/src/components/layout/NavSelector.astro +5 -3
  27. package/src/components/layout/PageLayout.astro +2 -1
  28. package/src/components/layout/ReferenceLayout.astro +1 -0
  29. package/src/components/layout/RootLayout.astro +47 -10
  30. package/src/components/layout/Search.astro +8 -3
  31. package/src/components/layout/nav-utils.ts +7 -3
  32. package/src/core/config-input.ts +923 -0
  33. package/src/core/config.ts +126 -3
  34. package/src/core/i18n.ts +6 -5
  35. package/src/core/links.ts +16 -1
  36. package/src/core/meta.ts +112 -52
  37. package/src/core/navigation.ts +15 -5
  38. package/src/core/project-graph.ts +68 -2
  39. package/src/core/schema.ts +8 -14
  40. package/src/core/sources/assets.ts +21 -5
  41. package/src/core/sources/cache.ts +19 -1
  42. package/src/core/sources/github-releases.ts +9 -3
  43. package/src/core/sources/mdx-remote.ts +14 -4
  44. package/src/core/sources/normalize.ts +13 -1
  45. package/src/core/sources/notion.ts +43 -7
  46. package/src/core/sources/resolve.ts +44 -1
  47. package/src/core/sources/sanity.ts +9 -3
  48. package/src/core/sources/types.ts +6 -0
  49. package/src/deploy/adapter-output.ts +82 -0
  50. package/src/deploy/rss.ts +3 -1
  51. package/src/index.ts +1 -1
  52. package/src/markdown/code-title.ts +11 -4
  53. package/src/markdown/index.ts +28 -30
  54. package/src/markdown/math.ts +3 -2
  55. package/src/markdown/package-commands.ts +13 -0
  56. package/src/og/card.ts +3 -1
  57. package/src/openapi/model.ts +2 -1
  58. package/src/openapi/parse.ts +9 -1
  59. package/src/openapi/references.ts +11 -1
  60. package/src/openapi/render-mdx.ts +30 -3
  61. package/src/openapi/source.ts +3 -1
  62. package/src/registry/eject.ts +21 -14
  63. package/src/search/documents.ts +4 -1
  64. package/src/theme/entry.ts +10 -3
  65. 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 (): Promise<SourceLoadResult> => {
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
- ctx.refresh ?? true
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(load, options.pollInterval)
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 (): Promise<SourceLoadResult> => {
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
- ctx.refresh ?? true
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(load, options.pollInterval)
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
- const ATX_HEADING = /^(?<hashes>#{1,6})\s+(?<text>.+?)\s*#*$/u;
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
- renderLeaf(block) ?? renderContainer(client, block, renderBlocks)
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 (): Promise<SourceLoadResult> => {
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
- ctx?.refresh ?? true
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(load, options.pollInterval)
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 (): Promise<SourceLoadResult> => {
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
- ctx?.refresh ?? true
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(load, options.pollInterval)
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
- link: `${base}${page.route}`,
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
- const TITLE_ATTR = /title=(?:"(?<dq>[^"]*)"|'(?<sq>[^']*)')/u;
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 => ({
@@ -46,15 +46,16 @@ type HastPlugin = NonNullable<
46
46
 
47
47
  /**
48
48
  * Hast plugins enabled by config. Inline `` `code`{:lang} `` highlighting is
49
- * opt-in; self-linking heading anchors (`<h2>`–`<h6>` wrapped in an `<a>` to
50
- * their own id) are on unless `markdown.headingAnchors` is `false`. Inline code
51
- * runs first so the anchor wrap re-refs already-highlighted code.
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
- if (options.inline) {
56
- plugins.push(inlineCodeHighlightPlugin() as unknown as HastPlugin);
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 interface BlumeMdxOptions extends BlumeMarkdownOptions {
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>`, and ` ```mermaid ` → a `<blume-mermaid>` element. Used as the
227
- * `processor` for `@astrojs/mdx` so these apply to
228
- * `.mdx` only (plain `.md` uses {@link blumeMarkdownProcessor}). Math is opt-in
229
- * via config since `$` is common in prose and code.
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
- const plugins: unknown[] = [
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
- ...(options.math ? { math: true } : {}),
240
+ // Block-only: `$$…$$` parses, a bare `$` stays literal text.
241
+ math: { singleDollarTextMath: false },
248
242
  },
249
243
  hastPlugins: blumeHastPlugins(options),
250
- mdastPlugins: plugins as unknown as MdastPlugin[],
244
+ mdastPlugins: [
245
+ packageInstallPlugin(),
246
+ directiveToCalloutPlugin(),
247
+ mermaidPlugin(),
248
+ mathPlugin(),
249
+ ] as unknown as MdastPlugin[],
251
250
  });
252
- };
@@ -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; inline math (`$…$`) stays inline. Only active when math is
12
- * enabled in config, so `$` is otherwise left as literal text.
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
- const initial = brand ? brand.charAt(0).toUpperCase() : "";
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();
@@ -144,7 +144,8 @@ export const extractOperations = (
144
144
  method,
145
145
  operationId: operation.operationId,
146
146
  path,
147
- route: `${baseRoute}/${tagSlug}/${key}`,
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,
@@ -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(last.retryAfter ?? BASE_BACKOFF_MS * 2 ** attempt);
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
- result.push(ref);
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
  };