blume 0.6.1 → 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 +5955 -5733
- package/dist/cli/index.js.map +37 -37
- package/dist/types/core/config-input.d.ts +1 -11
- package/dist/types/core/schema.d.ts +4 -4
- package/dist/types/core/sources/types.d.ts +6 -0
- package/package.json +1 -1
- package/src/astro/generate.ts +23 -13
- package/src/astro/markdown-negotiation.ts +12 -3
- package/src/astro/templates.ts +28 -7
- 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/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 +16 -2
- package/src/components/layout/Search.astro +8 -3
- package/src/components/layout/nav-utils.ts +7 -3
- package/src/core/config-input.ts +1 -11
- 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 +2 -1
- 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/rss.ts +3 -1
- package/src/markdown/code-title.ts +11 -4
- 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/search/documents.ts +4 -1
- package/src/theme/entry.ts +3 -0
- package/src/theme/icons.ts +7 -11
|
@@ -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. */
|
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))
|
|
@@ -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 => ({
|
|
@@ -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
|
};
|
|
@@ -25,7 +25,13 @@ const ENTITIES: Record<string, string> = {
|
|
|
25
25
|
// SDK…" is common spec prose). Entity-escape the keyword's first letter so the
|
|
26
26
|
// construct can't match; it still renders as the literal word.
|
|
27
27
|
const MDX_ESM_KEYWORD = /^(?<keyword>import|export)\b/gmu;
|
|
28
|
-
|
|
28
|
+
// Backtick code — inline spans and fences alike — is already literal in MDX,
|
|
29
|
+
// and entities are NOT decoded inside it, so escaping there would render the
|
|
30
|
+
// entity text verbatim (`/pets/{petId}`). Matching any balanced
|
|
31
|
+
// backtick run covers `code`, ``code``, and ```fences``` in one shot.
|
|
32
|
+
const BACKTICK_CODE = /(?<bt>`+)[\s\S]*?\k<bt>/gu;
|
|
33
|
+
|
|
34
|
+
const escapeProse = (text: string): string =>
|
|
29
35
|
text
|
|
30
36
|
.replace(MDX_UNSAFE, (char) => ENTITIES[char] ?? char)
|
|
31
37
|
.replace(
|
|
@@ -33,6 +39,19 @@ const mdxSafe = (text: string): string =>
|
|
|
33
39
|
(keyword) => `&#${keyword.codePointAt(0)};${keyword.slice(1)}`
|
|
34
40
|
);
|
|
35
41
|
|
|
42
|
+
/** Escape MDX-special syntax in prose while leaving backtick code verbatim. */
|
|
43
|
+
const mdxSafe = (text: string): string => {
|
|
44
|
+
let out = "";
|
|
45
|
+
let cursor = 0;
|
|
46
|
+
for (const match of text.matchAll(BACKTICK_CODE)) {
|
|
47
|
+
const start = match.index ?? 0;
|
|
48
|
+
out += escapeProse(text.slice(cursor, start));
|
|
49
|
+
out += match[0];
|
|
50
|
+
cursor = start + match[0].length;
|
|
51
|
+
}
|
|
52
|
+
return out + escapeProse(text.slice(cursor));
|
|
53
|
+
};
|
|
54
|
+
|
|
36
55
|
/** Frontmatter + body for one operation or overview page. */
|
|
37
56
|
export interface RenderedPage {
|
|
38
57
|
data: Record<string, unknown>;
|
|
@@ -80,8 +99,16 @@ export const overviewMdx = (spec: ApiSpecData): RenderedPage => {
|
|
|
80
99
|
// markdown pipeline gives them ids, permalink anchors, and table-of-contents
|
|
81
100
|
// entries; only the operation-link list defers to a component.
|
|
82
101
|
const operations = Object.values(spec.operations);
|
|
83
|
-
|
|
84
|
-
|
|
102
|
+
// Dedupe by slug: two declared tags that slugify identically (`Store` and
|
|
103
|
+
// `store`) must render one section, not the same operation list twice.
|
|
104
|
+
const sections: typeof spec.tags = [];
|
|
105
|
+
const known = new Set<string>();
|
|
106
|
+
for (const tag of spec.tags) {
|
|
107
|
+
if (!known.has(tag.slug)) {
|
|
108
|
+
known.add(tag.slug);
|
|
109
|
+
sections.push(tag);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
85
112
|
for (const operation of operations) {
|
|
86
113
|
if (!known.has(operation.tagSlug)) {
|
|
87
114
|
known.add(operation.tagSlug);
|
package/src/openapi/source.ts
CHANGED
|
@@ -59,8 +59,10 @@ const specEntries = (
|
|
|
59
59
|
);
|
|
60
60
|
// Overview last so an operation sets the section's routePath before the index
|
|
61
61
|
// page is inserted (the group's routePath is derived from its first child).
|
|
62
|
+
// A root-mounted reference refs `index.mdx`, not `/index.mdx`.
|
|
63
|
+
const base = routeToRef(spec.route);
|
|
62
64
|
entries.push(
|
|
63
|
-
toEntry(overviewMdx(spec), `${
|
|
65
|
+
toEntry(overviewMdx(spec), base ? `${base}/index.mdx` : "index.mdx")
|
|
64
66
|
);
|
|
65
67
|
return entries;
|
|
66
68
|
};
|
package/src/search/documents.ts
CHANGED
|
@@ -39,7 +39,10 @@ export interface SearchRecord {
|
|
|
39
39
|
|
|
40
40
|
const CODE_FENCE = /```[\s\S]*?```/gu;
|
|
41
41
|
const INLINE_CODE = /`(?<code>[^`]+)`/gu;
|
|
42
|
-
|
|
42
|
+
// Tag-shaped only: a name (or closing slash/fragment) right after `<`, and no
|
|
43
|
+
// newline inside. A bare `<` in prose ("costs < 5 credits") must not swallow
|
|
44
|
+
// everything up to some later `>` — potentially whole paragraphs.
|
|
45
|
+
const HTML_OR_JSX = /<\/?[a-zA-Z][^\n<>]*>|<\/?>/gu;
|
|
43
46
|
const IMAGE = /!\[[^\]]*\]\([^)]*\)/gu;
|
|
44
47
|
const LINK = /\[(?<text>[^\]]*)\]\([^)]*\)/gu;
|
|
45
48
|
const HEADING_MARK = /^#{1,6}\s+/gmu;
|
package/src/theme/entry.ts
CHANGED
|
@@ -435,6 +435,9 @@ blume-diff {
|
|
|
435
435
|
font-size: 0.8125rem;
|
|
436
436
|
}
|
|
437
437
|
|
|
438
|
+
/* GFM renders cells as <td><code> directly, which the descendant form alone
|
|
439
|
+
never matches (a cell is not its own descendant). */
|
|
440
|
+
.prose :where(td, th) > code,
|
|
438
441
|
.prose :where(td, th) :not(pre) > code {
|
|
439
442
|
white-space: nowrap;
|
|
440
443
|
}
|
package/src/theme/icons.ts
CHANGED
|
@@ -79,14 +79,10 @@ export const resolveIcon = (name: string): ResolvedIcon | null => {
|
|
|
79
79
|
return fromSet(DEFAULT_SET, normalized);
|
|
80
80
|
};
|
|
81
81
|
|
|
82
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
? normalized.slice(normalized.indexOf(":") + 1)
|
|
90
|
-
: normalized;
|
|
91
|
-
return Object.values(SETS).some((set) => getIconData(set, bare) !== null);
|
|
92
|
-
};
|
|
82
|
+
/**
|
|
83
|
+
* Whether a name resolves to a renderable icon. Exactly mirrors
|
|
84
|
+
* {@link resolveIcon}: a laxer check (e.g. matching the bare name under an
|
|
85
|
+
* unknown prefix) would let callers suppress their fallback and diagnostics
|
|
86
|
+
* for a name `<Icon>` then renders as nothing.
|
|
87
|
+
*/
|
|
88
|
+
export const hasIcon = (name: string): boolean => resolveIcon(name) !== null;
|