blume 1.4.2 → 1.5.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 +71 -0
- package/dist/cli/index.js +2260 -1100
- package/dist/cli/index.js.map +123 -117
- package/dist/types/ai/component-markdown.d.ts +14 -4
- package/dist/types/core/base-path.d.ts +8 -0
- package/dist/types/core/config-input.d.ts +87 -27
- package/dist/types/core/config.d.ts +2 -1
- package/dist/types/core/data.d.ts +16 -1
- package/dist/types/core/diagnostics.d.ts +5 -1
- package/dist/types/core/i18n-ui.d.ts +12 -0
- package/dist/types/core/schema.d.ts +116 -15
- package/dist/types/core/sources/types.d.ts +11 -1
- package/dist/types/core/standard-schema.d.ts +7 -3
- package/dist/types/core/types.d.ts +43 -2
- package/dist/types/core/ui-packs/index.d.ts +9 -1
- package/dist/types/openapi/references.d.ts +14 -7
- package/dist/types/seo/x-handle.d.ts +3 -2
- package/docs/advanced/api-reference.mdx +8 -6
- package/docs/configuration/ai.mdx +26 -8
- package/docs/configuration/search.mdx +2 -0
- package/docs/configuration/seo.mdx +1 -1
- package/docs/content/i18n.mdx +1 -1
- package/docs/content/meta.mdx +2 -1
- package/docs/content/meta.ts +1 -0
- package/docs/content/navigation.mdx +35 -1
- package/docs/content/sources.mdx +1 -1
- package/docs/content/versioning.mdx +106 -0
- package/docs/reference/cli.mdx +1 -0
- package/docs/reference/frontmatter.mdx +3 -0
- package/package.json +13 -1
- package/skills/blume-migrate/SKILL.md +2 -2
- package/skills/blume-migrate/references/docusaurus.md +1 -1
- package/skills/blume-migrate/references/fumadocs.md +1 -1
- package/skills/blume-migrate/references/mintlify.md +1 -1
- package/src/ai/agent-readability.ts +40 -12
- package/src/ai/api-catalog.ts +2 -2
- package/src/ai/ask-context.ts +49 -12
- package/src/ai/ask.ts +10 -1
- package/src/ai/component-markdown.ts +80 -43
- package/src/ai/llms.ts +42 -17
- package/src/ai/mcp/data.ts +48 -12
- package/src/ai/mcp/discovery.ts +52 -16
- package/src/ai/mcp/server.ts +280 -125
- package/src/ai/mcp/tools.ts +3 -3
- package/src/ai/skills.ts +32 -9
- package/src/ai/tar.ts +29 -70
- package/src/ai/visibility.ts +2 -2
- package/src/astro/component-slots.ts +2 -0
- package/src/astro/examples.ts +13 -5
- package/src/astro/generate.ts +113 -63
- package/src/astro/integration.ts +13 -2
- package/src/astro/islands.ts +23 -12
- package/src/astro/templates.ts +185 -41
- package/src/audit/agent.ts +16 -31
- package/src/audit/checks/content.ts +26 -11
- package/src/audit/checks/dns-aid.ts +3 -0
- package/src/audit/checks/indexability.ts +24 -6
- package/src/audit/checks/llms.ts +9 -4
- package/src/audit/checks/network.ts +2 -0
- package/src/audit/checks/social.ts +18 -10
- package/src/audit/crawl.ts +78 -25
- package/src/audit/report.ts +20 -19
- package/src/audit/run.ts +15 -5
- package/src/audit/snapshot.ts +29 -6
- package/src/audit/types.ts +25 -3
- package/src/blume-modules.d.ts +5 -1
- package/src/cli/commands/audit.ts +21 -21
- package/src/cli/commands/build.ts +30 -16
- package/src/cli/commands/dev.ts +15 -15
- package/src/cli/commands/doctor.ts +2 -0
- package/src/cli/commands/eject.ts +4 -4
- package/src/cli/commands/eval.ts +24 -30
- package/src/cli/commands/init.ts +9 -9
- package/src/cli/commands/mcp-stdio.ts +3 -0
- package/src/cli/commands/translate.ts +14 -3
- package/src/cli/commands/version.ts +85 -0
- package/src/cli/dev-lock.ts +31 -10
- package/src/cli/eject-scripts.ts +17 -2
- package/src/cli/env.ts +13 -30
- package/src/cli/index.ts +2 -0
- package/src/cli/init/questions.ts +1 -1
- package/src/cli/init/scaffold.ts +41 -13
- package/src/cli/internal-error.ts +1 -0
- package/src/cli/report-format.ts +22 -0
- package/src/components/content/AccordionItem.astro +2 -9
- package/src/components/content/ColorItem.astro +5 -13
- package/src/components/content/Component.astro +12 -8
- package/src/components/content/Frame.astro +2 -12
- package/src/components/content/Prompt.astro +12 -31
- package/src/components/content/Tab.astro +2 -9
- package/src/components/content/Tooltip.astro +1 -9
- package/src/components/content/Update.astro +2 -9
- package/src/components/content/auto-type-table.ts +3 -0
- package/src/components/content/diff.ts +9 -5
- package/src/components/content/github-info.ts +2 -0
- package/src/components/content/inline-markdown.ts +28 -0
- package/src/components/copy-feedback.ts +96 -0
- package/src/components/islands/ask-ai.tsx +111 -34
- package/src/components/islands/hooks.ts +5 -1
- package/src/components/islands/webmcp.ts +49 -12
- package/src/components/layout/Header.astro +25 -1
- package/src/components/layout/NavSelector.astro +11 -2
- package/src/components/layout/NavTree.astro +4 -2
- package/src/components/layout/PageActions.astro +20 -32
- package/src/components/layout/PageLayout.astro +8 -28
- package/src/components/layout/RootLayout.astro +24 -48
- package/src/components/layout/Search.astro +133 -22
- package/src/components/layout/VersionBanner.astro +39 -0
- package/src/components/layout/analytics-client.ts +8 -5
- package/src/components/layout/drawer-inert.ts +31 -0
- package/src/components/layout/hydration-hint.ts +1 -1
- package/src/components/layout/nav-utils.ts +1 -4
- package/src/components/layout/overrides.ts +25 -12
- package/src/components/layout/search/algolia.ts +18 -5
- package/src/components/layout/search/endpoint.ts +3 -0
- package/src/components/layout/search/flexsearch.ts +23 -7
- package/src/components/layout/search/orama-cloud.ts +1 -1
- package/src/components/layout/search/orama.ts +4 -1
- package/src/components/layout/search/pagefind.ts +8 -5
- package/src/components/layout/search/types.ts +45 -1
- package/src/components/layout/search/typesense.ts +19 -3
- package/src/components/openapi/ApiOverview.astro +32 -6
- package/src/components/openapi/AsyncApiOperation.astro +237 -0
- package/src/components/openapi/Bindings.astro +89 -0
- package/src/components/openapi/MethodBadge.astro +3 -0
- package/src/components/openapi/Operation.astro +7 -2
- package/src/components/openapi/PanelTabs.astro +131 -0
- package/src/components/openapi/ParametersTable.astro +2 -0
- package/src/components/openapi/RequestPanel.astro +12 -119
- package/src/components/openapi/async-snippets.ts +174 -0
- package/src/components/openapi/async.ts +348 -0
- package/src/components/openapi/helpers.ts +52 -20
- package/src/components/openapi/panel.ts +11 -8
- package/src/components/openapi/security.ts +102 -29
- package/src/components/openapi/snippets.ts +11 -11
- package/src/components/raf-throttle.ts +21 -0
- package/src/components/slug.ts +14 -0
- package/src/core/base-path.ts +18 -1
- package/src/core/component-overrides.ts +28 -23
- package/src/core/config-input.ts +96 -27
- package/src/core/config.ts +20 -7
- package/src/core/content.ts +3 -1
- package/src/core/data.ts +16 -1
- package/src/core/define-components.ts +5 -0
- package/src/core/diagnostics.ts +46 -38
- package/src/core/frontmatter.ts +74 -4
- package/src/core/graph.ts +137 -53
- package/src/core/i18n-ui.ts +15 -0
- package/src/core/i18n.ts +16 -8
- package/src/core/load-module.ts +1 -0
- package/src/core/manifest.ts +92 -3
- package/src/core/meta.ts +44 -14
- package/src/core/nav-diagnostics.ts +3 -3
- package/src/core/navigation.ts +247 -67
- package/src/core/probe.ts +7 -19
- package/src/core/project-graph.ts +27 -4
- package/src/core/schema.ts +219 -67
- package/src/core/site-url.ts +27 -0
- package/src/core/sources/assets.ts +2 -0
- package/src/core/sources/cache.ts +16 -8
- package/src/core/sources/github-releases.ts +39 -11
- package/src/core/sources/mdx-remote.ts +4 -0
- package/src/core/sources/normalize.ts +93 -22
- package/src/core/sources/notion.ts +76 -22
- package/src/core/sources/portable-text.ts +48 -12
- package/src/core/sources/resolve.ts +1 -0
- package/src/core/sources/sanity.ts +68 -14
- package/src/core/sources/types.ts +17 -1
- package/src/core/sources/watch.ts +1 -1
- package/src/core/standard-schema.ts +9 -3
- package/src/core/text-width.ts +26 -0
- package/src/core/tsconfig-aliases.ts +9 -5
- package/src/core/types.ts +45 -2
- package/src/core/ui-packs/index.ts +9 -1
- package/src/core/version-cut.ts +301 -0
- package/src/core/version.ts +2 -0
- package/src/core/versions.ts +170 -0
- package/src/deploy/adapter-output.ts +5 -2
- package/src/deploy/cloudflare-negotiation.ts +40 -11
- package/src/deploy/robots.ts +2 -1
- package/src/deploy/rss.ts +2 -1
- package/src/deploy/sitemap.ts +89 -8
- package/src/deploy/vercel-negotiation.ts +11 -4
- package/src/eval/agents.ts +13 -10
- package/src/eval/report.ts +5 -18
- package/src/eval/run.ts +2 -2
- package/src/eval/schema.ts +1 -1
- package/src/markdown/base-links.ts +6 -6
- package/src/markdown/directives.ts +7 -1
- package/src/markdown/heading-anchors.ts +17 -6
- package/src/markdown/index.ts +73 -24
- package/src/markdown/inline-code.ts +14 -2
- package/src/markdown/language-icon.ts +6 -2
- package/src/markdown/mdast.ts +18 -4
- package/src/markdown/package-commands.ts +63 -58
- package/src/markdown/table-wrap.ts +4 -1
- package/src/markdown/twoslash.ts +2 -0
- package/src/og/card.ts +50 -33
- package/src/og/derive.ts +43 -27
- package/src/openapi/asyncapi.ts +366 -0
- package/src/openapi/model.ts +135 -66
- package/src/openapi/parse.ts +166 -33
- package/src/openapi/references.ts +47 -22
- package/src/openapi/render-mdx.ts +137 -59
- package/src/openapi/scalar.ts +8 -10
- package/src/openapi/source.ts +126 -29
- package/src/registry/eject.ts +7 -2
- package/src/search/documents.ts +103 -39
- package/src/search/facets.ts +7 -5
- package/src/search/orama-index.ts +117 -32
- package/src/search/popular.ts +10 -5
- package/src/search/providers.ts +2 -2
- package/src/search/sync/index.ts +2 -0
- package/src/search/sync/typesense.ts +4 -2
- package/src/seo/jsonld.ts +24 -6
- package/src/seo/x-handle.ts +8 -3
- package/src/theme/chrome-icons.ts +7 -2
- package/src/theme/fonts.ts +8 -4
- package/src/theme/icons.ts +4 -2
- package/src/theme/palette.ts +27 -15
- package/src/translate/ledger.ts +4 -2
- package/src/translate/meta.ts +15 -6
- package/src/translate/report.ts +10 -19
- package/src/translate/run.ts +29 -38
- package/src/translate/validate.ts +52 -17
- package/src/translate/work-list.ts +0 -0
- package/src/cli/coalesce.ts +0 -43
|
@@ -2,8 +2,10 @@ import { fromMarkdown } from "mdast-util-from-markdown";
|
|
|
2
2
|
import { gfmFromMarkdown } from "mdast-util-gfm";
|
|
3
3
|
import { toString as mdastToString } from "mdast-util-to-string";
|
|
4
4
|
import { gfm } from "micromark-extension-gfm";
|
|
5
|
+
import stringWidth from "string-width";
|
|
5
6
|
|
|
6
7
|
import matter from "../frontmatter.ts";
|
|
8
|
+
import { columnsPrefix } from "../text-width.ts";
|
|
7
9
|
import {
|
|
8
10
|
hashText,
|
|
9
11
|
loadWithCache,
|
|
@@ -61,9 +63,10 @@ const LEADING_V = /^v/iu;
|
|
|
61
63
|
const NON_SLUG = /[^a-z0-9]+/gu;
|
|
62
64
|
const EDGE_DASHES = /^-+|-+$/gu;
|
|
63
65
|
|
|
64
|
-
// `blume audit` grades meta descriptions against the 110–160
|
|
65
|
-
// snippet range (audit/types.ts thresholds), so the derived summary
|
|
66
|
-
// the longest word-boundary cut
|
|
66
|
+
// `blume audit` grades meta descriptions against the 110–160 display-column
|
|
67
|
+
// search snippet range (audit/types.ts thresholds), so the derived summary
|
|
68
|
+
// budgets in the same columns and aims for the longest word-boundary cut
|
|
69
|
+
// under the cap.
|
|
67
70
|
const DESCRIPTION_MAX = 160;
|
|
68
71
|
const DESCRIPTION_MIN = 110;
|
|
69
72
|
|
|
@@ -103,15 +106,17 @@ const releaseDescription = (body: string): string | undefined => {
|
|
|
103
106
|
if (!text) {
|
|
104
107
|
return undefined;
|
|
105
108
|
}
|
|
106
|
-
if (text
|
|
109
|
+
if (stringWidth(text) <= DESCRIPTION_MAX) {
|
|
107
110
|
return text;
|
|
108
111
|
}
|
|
109
112
|
// Cut before the cap at a word boundary (kept only when it doesn't drop the
|
|
110
113
|
// summary under the minimum), shed any dangling punctuation, and mark the cut.
|
|
111
|
-
const slice = text
|
|
114
|
+
const slice = columnsPrefix(text, DESCRIPTION_MAX - 1);
|
|
112
115
|
const boundary = slice.lastIndexOf(" ");
|
|
113
116
|
const head = (
|
|
114
|
-
boundary
|
|
117
|
+
boundary !== -1 && stringWidth(slice.slice(0, boundary)) >= DESCRIPTION_MIN
|
|
118
|
+
? slice.slice(0, boundary)
|
|
119
|
+
: slice
|
|
115
120
|
).replace(TRAILING_FRAGMENT, "");
|
|
116
121
|
return `${head}…`;
|
|
117
122
|
};
|
|
@@ -130,6 +135,19 @@ const githubHeaders = (): Headers => {
|
|
|
130
135
|
return headers;
|
|
131
136
|
};
|
|
132
137
|
|
|
138
|
+
/**
|
|
139
|
+
* The changelog frontmatter one release lowers to. `title`/`type` are always
|
|
140
|
+
* assigned (optional only so assignment order can keep the emitted YAML key
|
|
141
|
+
* order — and so each entry's content hash — stable).
|
|
142
|
+
*/
|
|
143
|
+
interface ChangelogFrontmatter {
|
|
144
|
+
changelog: { category: string; version: string };
|
|
145
|
+
date: string;
|
|
146
|
+
seo?: { description: string };
|
|
147
|
+
title?: string;
|
|
148
|
+
type?: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
133
151
|
/**
|
|
134
152
|
* Lower one release to a staged Markdown entry: the notes become the body,
|
|
135
153
|
* `type: changelog` frontmatter (title/date/version/category) drives the
|
|
@@ -146,19 +164,25 @@ const releaseToEntry = (release: GithubRelease): SourceEntry => {
|
|
|
146
164
|
// description (instead of the site-wide fallback) without also rendering the
|
|
147
165
|
// visible lede paragraph a top-level `description` would add.
|
|
148
166
|
const description = releaseDescription(body);
|
|
149
|
-
|
|
167
|
+
// Assignment order matters: js-yaml serializes keys in insertion order, so
|
|
168
|
+
// `seo` lands between `date` and `title` exactly as it always has.
|
|
169
|
+
const data: ChangelogFrontmatter = {
|
|
150
170
|
changelog: { category, version },
|
|
151
171
|
date,
|
|
152
|
-
...(description ? { seo: { description } } : {}),
|
|
153
|
-
title,
|
|
154
|
-
type: "changelog",
|
|
155
172
|
};
|
|
173
|
+
if (description) {
|
|
174
|
+
data.seo = { description };
|
|
175
|
+
}
|
|
176
|
+
data.title = title;
|
|
177
|
+
data.type = "changelog";
|
|
156
178
|
const raw = matter.stringify(`${body}\n`, data);
|
|
157
179
|
const fallbackRef = `release-${release.id}`;
|
|
158
180
|
const ref = `${slugifyTag(release.tag_name) || fallbackRef}.md`;
|
|
159
181
|
return {
|
|
160
182
|
body: { format: "md", text: body },
|
|
161
|
-
data,
|
|
183
|
+
// Spread: `SourceEntry.data` is an open dictionary, which the interface
|
|
184
|
+
// (no index signature) only satisfies as a fresh object literal.
|
|
185
|
+
data: { ...data },
|
|
162
186
|
editUrl: release.html_url,
|
|
163
187
|
hash: hashText(raw),
|
|
164
188
|
lastModified: date,
|
|
@@ -194,6 +218,8 @@ export const githubReleasesSource = (
|
|
|
194
218
|
if (!res.ok) {
|
|
195
219
|
throw new Error(`${url} -> ${res.status}`);
|
|
196
220
|
}
|
|
221
|
+
// SAFETY: GitHub's releases endpoint returns a JSON array of release
|
|
222
|
+
// objects; `GithubRelease` models only the fields the adapter reads.
|
|
197
223
|
return (await res.json()) as GithubRelease[];
|
|
198
224
|
};
|
|
199
225
|
|
|
@@ -233,6 +259,8 @@ export const githubReleasesSource = (
|
|
|
233
259
|
// repo), degrade to an empty changelog with a warning rather than failing
|
|
234
260
|
// the whole build.
|
|
235
261
|
snapshot = new Map();
|
|
262
|
+
// SAFETY: everything thrown on this path is an Error — fetch rejects
|
|
263
|
+
// with a TypeError, fetchPage and loadWithCache throw Error instances.
|
|
236
264
|
return {
|
|
237
265
|
diagnostics: [
|
|
238
266
|
{
|
|
@@ -82,6 +82,8 @@ const enumerateGithub = async (
|
|
|
82
82
|
if (!res.ok) {
|
|
83
83
|
throw new Error(`${treeUrl} -> ${res.status}`);
|
|
84
84
|
}
|
|
85
|
+
// SAFETY: GitHub's git/trees endpoint returns this envelope; a missing or
|
|
86
|
+
// differently-typed field falls through the `?? []` and blob filters below.
|
|
85
87
|
const body = (await res.json()) as {
|
|
86
88
|
tree?: GithubTreeEntry[];
|
|
87
89
|
truncated?: boolean;
|
|
@@ -198,6 +200,8 @@ export const mdxRemoteSource = (
|
|
|
198
200
|
} catch (error) {
|
|
199
201
|
skipped.push({
|
|
200
202
|
code: "BLUME_SOURCE_FETCH_FAILED",
|
|
203
|
+
// SAFETY: fetch and decode failures throw Error instances;
|
|
204
|
+
// only the message is read for the skip diagnostic.
|
|
201
205
|
message: `Source "${options.name}" skipped "${ref.ref}" (${(error as Error).message}); the rest were imported.`,
|
|
202
206
|
severity: "warning",
|
|
203
207
|
});
|
|
@@ -9,6 +9,7 @@ import { localePlacement, localizeRoute } from "../i18n.ts";
|
|
|
9
9
|
import { pageMetaSchema } from "../schema.ts";
|
|
10
10
|
import type { FrontmatterExtend, PageMeta } from "../schema.ts";
|
|
11
11
|
import type { Diagnostic, Heading, PageLink, PageRecord } from "../types.ts";
|
|
12
|
+
import { detectVersionRef, versionizeRoute } from "../versions.ts";
|
|
12
13
|
import type { NormalizeContext, SourceEntry } from "./types.ts";
|
|
13
14
|
|
|
14
15
|
const NUMERIC_PREFIX = /^\d+[-_.]/u;
|
|
@@ -28,12 +29,19 @@ const groupLabel = (segment: string): string | null =>
|
|
|
28
29
|
* anchor ids are *not* slugged here — they use a `github-slugger` in
|
|
29
30
|
* {@link extractHeadings}, matching the renderer (see `markdown/heading-anchors`)
|
|
30
31
|
* so `blume validate` checks anchors against the exact rendered heading ids.
|
|
32
|
+
*
|
|
33
|
+
* The keep-class is Unicode letters/marks/numbers, not `\w`: ASCII slugs are
|
|
34
|
+
* unchanged, but a CJK/Cyrillic/accented slug keeps its characters instead of
|
|
35
|
+
* collapsing to `""` (which forced Sanity/Notion routes onto their opaque
|
|
36
|
+
* document-id fallbacks) or dropping accents (`café` → `caf`). NFC first so a
|
|
37
|
+
* macOS-NFD `é` (e + combining mark) slugs identically to the composed form.
|
|
31
38
|
*/
|
|
32
39
|
export const slugify = (text: string): string =>
|
|
33
40
|
text
|
|
41
|
+
.normalize("NFC")
|
|
34
42
|
.toLowerCase()
|
|
35
43
|
.trim()
|
|
36
|
-
.replaceAll(/[^\
|
|
44
|
+
.replaceAll(/[^\p{L}\p{M}\p{N}\s_-]/gu, "")
|
|
37
45
|
.replaceAll(/[\s_]+/gu, "-")
|
|
38
46
|
.replaceAll(/-+/gu, "-")
|
|
39
47
|
.replaceAll(/^-|-$/gu, "");
|
|
@@ -55,6 +63,18 @@ const titleCase = (value: string): string =>
|
|
|
55
63
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
56
64
|
.join(" ");
|
|
57
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Strip characters that cannot survive the route → URL → output-file round
|
|
68
|
+
* trip. A `:` ahead of the first `/` makes `new URL()` read the segment as a
|
|
69
|
+
* scheme (`Guide: Architecture.md` → `guide:`), which crashes Astro's
|
|
70
|
+
* prerender write with "The URL must be of scheme file"; control characters
|
|
71
|
+
* (an embedded newline in a filename) are silently dropped by the URL parser,
|
|
72
|
+
* desyncing the route from its output path. Both are legal in macOS/Linux
|
|
73
|
+
* filenames, so they are removed here rather than rejected.
|
|
74
|
+
*/
|
|
75
|
+
const sanitizeSegment = (segment: string): string =>
|
|
76
|
+
segment.replaceAll(/[:\p{Cc}]/gu, "");
|
|
77
|
+
|
|
58
78
|
/** Fold one raw path part into the accumulating route segments/groups. */
|
|
59
79
|
const addRouteSegment = (
|
|
60
80
|
part: string,
|
|
@@ -75,13 +95,23 @@ const addRouteSegment = (
|
|
|
75
95
|
if (clean === "index") {
|
|
76
96
|
return;
|
|
77
97
|
}
|
|
78
|
-
|
|
98
|
+
const safe = sanitizeSegment(clean);
|
|
99
|
+
// A part that was nothing but stripped characters cannot name a segment.
|
|
100
|
+
if (safe === "") {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
segments.push(safe);
|
|
79
104
|
};
|
|
80
105
|
|
|
106
|
+
/** URL + nav metadata mapped from one content-root-relative path. */
|
|
107
|
+
interface MappedRoute {
|
|
108
|
+
segments: string[];
|
|
109
|
+
groups: string[];
|
|
110
|
+
route: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
81
113
|
/** Convert a content-root-relative path into URL + nav metadata. */
|
|
82
|
-
const mapRoute = (
|
|
83
|
-
relativePath: string
|
|
84
|
-
): { segments: string[]; groups: string[]; route: string } => {
|
|
114
|
+
const mapRoute = (relativePath: string): MappedRoute => {
|
|
85
115
|
const withoutExt = relativePath.slice(
|
|
86
116
|
0,
|
|
87
117
|
relativePath.length - extname(relativePath).length
|
|
@@ -114,10 +144,10 @@ export type FenceState = "```" | "~~~" | null;
|
|
|
114
144
|
*/
|
|
115
145
|
export const nextFenceState = (line: string, fence: FenceState): FenceState => {
|
|
116
146
|
const trimmed = line.trimStart();
|
|
117
|
-
const delimiter = trimmed.match(CODE_FENCE)?.groups?.delimiter
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (delimiter
|
|
147
|
+
const delimiter = trimmed.match(CODE_FENCE)?.groups?.delimiter;
|
|
148
|
+
// The `delimiter` group matches exactly ``` or ~~~; comparing against both
|
|
149
|
+
// narrows it without a cast.
|
|
150
|
+
if (delimiter !== "```" && delimiter !== "~~~") {
|
|
121
151
|
return fence;
|
|
122
152
|
}
|
|
123
153
|
if (fence === null) {
|
|
@@ -495,6 +525,10 @@ const deriveTitle = (
|
|
|
495
525
|
const trimSlashes = (value: string): string =>
|
|
496
526
|
value.replaceAll(/^\/+|\/+$/gu, "");
|
|
497
527
|
|
|
528
|
+
/** Whether a raw frontmatter value is a string (e.g. the `type` override). */
|
|
529
|
+
const isStringValue = (value: SourceEntry["data"][string]): value is string =>
|
|
530
|
+
typeof value === "string";
|
|
531
|
+
|
|
498
532
|
const withPrefix = (prefix: string | undefined, path: string): string => {
|
|
499
533
|
const clean = prefix ? trimSlashes(prefix) : "";
|
|
500
534
|
return clean ? `${clean}/${path}` : path;
|
|
@@ -506,13 +540,27 @@ interface CustomKeyIssue {
|
|
|
506
540
|
path: (string | number)[];
|
|
507
541
|
}
|
|
508
542
|
|
|
543
|
+
/** The validated custom keys (if any survived) plus every failure found. */
|
|
544
|
+
interface CustomKeyValidation {
|
|
545
|
+
custom?: PageRecord["custom"];
|
|
546
|
+
issues: CustomKeyIssue[];
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Whether a Standard Schema path segment is the wrapped `{ key }` form. */
|
|
550
|
+
const isKeyCarrier = (
|
|
551
|
+
segment: PropertyKey | { readonly key: PropertyKey }
|
|
552
|
+
): segment is { readonly key: PropertyKey } =>
|
|
553
|
+
typeof segment === "object" && segment !== null;
|
|
554
|
+
|
|
555
|
+
const isSymbolKey = (key: PropertyKey): key is symbol =>
|
|
556
|
+
typeof key === "symbol";
|
|
557
|
+
|
|
509
558
|
/** Lower a Standard Schema path segment (`key` or `{ key }`) for joining. */
|
|
510
559
|
const segmentKey = (
|
|
511
560
|
segment: PropertyKey | { readonly key: PropertyKey }
|
|
512
561
|
): string | number => {
|
|
513
|
-
const key =
|
|
514
|
-
|
|
515
|
-
return typeof key === "symbol" ? String(key) : key;
|
|
562
|
+
const key = isKeyCarrier(segment) ? segment.key : segment;
|
|
563
|
+
return isSymbolKey(key) ? String(key) : key;
|
|
516
564
|
};
|
|
517
565
|
|
|
518
566
|
/**
|
|
@@ -525,10 +573,10 @@ const segmentKey = (
|
|
|
525
573
|
* synchronous, and frontmatter validation has no business awaiting I/O.
|
|
526
574
|
*/
|
|
527
575
|
const validateCustomKeys = (
|
|
528
|
-
data:
|
|
576
|
+
data: SourceEntry["data"],
|
|
529
577
|
extend: FrontmatterExtend
|
|
530
|
-
):
|
|
531
|
-
const custom:
|
|
578
|
+
): CustomKeyValidation => {
|
|
579
|
+
const custom: NonNullable<PageRecord["custom"]> = {};
|
|
532
580
|
const issues: CustomKeyIssue[] = [];
|
|
533
581
|
for (const [key, schema] of Object.entries(extend)) {
|
|
534
582
|
const outcome = schema["~standard"].validate(data[key]);
|
|
@@ -573,13 +621,14 @@ const parseEntryMeta = (
|
|
|
573
621
|
entry: SourceEntry,
|
|
574
622
|
ctx: NormalizeContext
|
|
575
623
|
):
|
|
576
|
-
| { meta: PageMeta; custom?:
|
|
624
|
+
| { meta: PageMeta; custom?: PageRecord["custom"]; diagnostics?: never }
|
|
577
625
|
| { meta?: never; diagnostics: Diagnostic[] } => {
|
|
578
626
|
// Resolved the same way `contentType` is after parsing (`meta.type` falling
|
|
579
627
|
// back to `defaultType`); a non-string `type` fails the strict parse below,
|
|
580
628
|
// so which per-type map was merged for that entry never matters.
|
|
581
|
-
const entryType =
|
|
582
|
-
|
|
629
|
+
const entryType = isStringValue(entry.data.type)
|
|
630
|
+
? entry.data.type
|
|
631
|
+
: ctx.defaultType;
|
|
583
632
|
const typeExtend = ctx.typeFrontmatter?.[entryType];
|
|
584
633
|
// Config validation rejects a key declared both site-wide and per-type, so
|
|
585
634
|
// this merge never has to pick a winner.
|
|
@@ -625,6 +674,12 @@ const parseEntryMeta = (
|
|
|
625
674
|
};
|
|
626
675
|
};
|
|
627
676
|
|
|
677
|
+
/** The per-locale page records and diagnostics from one source entry. */
|
|
678
|
+
export interface NormalizedEntry {
|
|
679
|
+
pages: PageRecord[];
|
|
680
|
+
diagnostics: Diagnostic[];
|
|
681
|
+
}
|
|
682
|
+
|
|
628
683
|
/**
|
|
629
684
|
* Normalize one source entry into per-locale `PageRecord`s. This is the single
|
|
630
685
|
* funnel every adapter's entries pass through, so route mapping, heading/link
|
|
@@ -633,7 +688,7 @@ const parseEntryMeta = (
|
|
|
633
688
|
export const normalizeEntry = (
|
|
634
689
|
entry: SourceEntry,
|
|
635
690
|
ctx: NormalizeContext
|
|
636
|
-
):
|
|
691
|
+
): NormalizedEntry => {
|
|
637
692
|
const { format } = entry.body;
|
|
638
693
|
const ext = format === "mdx" ? ".mdx" : ".md";
|
|
639
694
|
|
|
@@ -654,14 +709,22 @@ export const normalizeEntry = (
|
|
|
654
709
|
meta.seo.noindex = true;
|
|
655
710
|
}
|
|
656
711
|
|
|
712
|
+
// The version is detected first: a snapshot directory is outermost on disk
|
|
713
|
+
// (`v1.0/fr/page.mdx`), so the locale parser and route mapping must see a
|
|
714
|
+
// version-stripped ref. The current version is `""` and lives at the root.
|
|
715
|
+
const { versions } = ctx;
|
|
716
|
+
const { version, rest: versionlessRef } = versions
|
|
717
|
+
? detectVersionRef(entry.ref, versions)
|
|
718
|
+
: { rest: entry.ref, version: "" };
|
|
719
|
+
|
|
657
720
|
// Locale and the locale-stripped nav path come from the entry's ref (a leading
|
|
658
721
|
// dir, or a filename suffix under the `dot` parser), not the slug — the slug is
|
|
659
722
|
// the logical, locale-agnostic path within a locale. A shared `$` file maps to
|
|
660
723
|
// every locale. Remote/CMS sources without i18n placement map to one locale.
|
|
661
724
|
const { i18n } = ctx;
|
|
662
725
|
const { navPath: rawNavPath, locales } = i18n
|
|
663
|
-
? localePlacement(
|
|
664
|
-
: { locales: [""], navPath:
|
|
726
|
+
? localePlacement(versionlessRef, ext, i18n)
|
|
727
|
+
: { locales: [""], navPath: versionlessRef };
|
|
665
728
|
|
|
666
729
|
const navPath = withPrefix(ctx.source.prefix, rawNavPath);
|
|
667
730
|
// Frontmatter `slug` wins, then the adapter-supplied `entry.slug` (the typed
|
|
@@ -675,7 +738,13 @@ export const normalizeEntry = (
|
|
|
675
738
|
slug ? `${slug}${ext}` : rawNavPath
|
|
676
739
|
);
|
|
677
740
|
|
|
678
|
-
|
|
741
|
+
// The version prefixes the mapped route *after* `mapRoute` runs: the mapped
|
|
742
|
+
// route is the version-agnostic key, the config id is prepended verbatim
|
|
743
|
+
// (never numeric-prefix-stripped), a frontmatter `slug` gets versionized so
|
|
744
|
+
// snapshots can't collide with the live page, and `translationKey` becomes
|
|
745
|
+
// version-specific for free.
|
|
746
|
+
const { segments, groups, route: versionKey } = mapRoute(routeInput);
|
|
747
|
+
const logicalRoute = versionizeRoute(versionKey, version);
|
|
679
748
|
const headings = extractHeadings(entry.body.text);
|
|
680
749
|
const { staged } = ctx.source;
|
|
681
750
|
|
|
@@ -705,6 +774,8 @@ export const normalizeEntry = (
|
|
|
705
774
|
sourcePath: entry.sourcePath,
|
|
706
775
|
title: deriveTitle(meta, headings, navPath),
|
|
707
776
|
translationKey: logicalRoute,
|
|
777
|
+
version,
|
|
778
|
+
versionKey,
|
|
708
779
|
} satisfies Omit<PageRecord, "locale" | "route">;
|
|
709
780
|
|
|
710
781
|
// One record per locale this entry maps to (one normally; every locale for a
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
2
|
|
|
3
|
+
import pLimit from "p-limit";
|
|
3
4
|
import { join } from "pathe";
|
|
4
5
|
|
|
5
6
|
import { BlumeError } from "../diagnostics.ts";
|
|
@@ -46,11 +47,21 @@ interface NotionPage {
|
|
|
46
47
|
last_edited_time?: string;
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
/** The per-type payload a block carries under the key matching its `type`. */
|
|
51
|
+
interface NotionBlockPayload {
|
|
52
|
+
caption?: NotionRichText[];
|
|
53
|
+
checked?: boolean;
|
|
54
|
+
external?: { url: string };
|
|
55
|
+
file?: { url: string };
|
|
56
|
+
language?: string;
|
|
57
|
+
rich_text?: NotionRichText[];
|
|
58
|
+
}
|
|
59
|
+
|
|
49
60
|
interface NotionBlock {
|
|
50
61
|
id: string;
|
|
51
62
|
type: string;
|
|
52
63
|
has_children?: boolean;
|
|
53
|
-
[key: string]:
|
|
64
|
+
[key: string]: NotionBlockPayload | boolean | string | undefined;
|
|
54
65
|
}
|
|
55
66
|
|
|
56
67
|
interface NotionList<T> {
|
|
@@ -96,6 +107,12 @@ export interface NotionSourceOptions {
|
|
|
96
107
|
prefix?: string;
|
|
97
108
|
/** The Notion database id. */
|
|
98
109
|
database: string;
|
|
110
|
+
/**
|
|
111
|
+
* Maximum concurrent Notion API requests. Notion allows an average of 3
|
|
112
|
+
* requests per second per integration, so a large database must pace its
|
|
113
|
+
* block-tree fan-out or every request 429s. Default 3.
|
|
114
|
+
*/
|
|
115
|
+
concurrency?: number;
|
|
99
116
|
/** Integration token; defaults to `NOTION_TOKEN`. */
|
|
100
117
|
token?: string;
|
|
101
118
|
properties?: NotionPropertyMap;
|
|
@@ -113,6 +130,17 @@ export interface NotionSourceOptions {
|
|
|
113
130
|
fetchImpl?: typeof fetch;
|
|
114
131
|
}
|
|
115
132
|
|
|
133
|
+
/** The frontmatter Blume derives from a Notion page's properties. */
|
|
134
|
+
interface NotionFrontmatter {
|
|
135
|
+
description?: string;
|
|
136
|
+
draft?: boolean;
|
|
137
|
+
sidebar?: { order: number };
|
|
138
|
+
title?: string;
|
|
139
|
+
// Frontmatter stays an open bag downstream (`SourceEntry.data`), so admit
|
|
140
|
+
// the value shapes this source writes under any future key.
|
|
141
|
+
[key: string]: boolean | string | { order: number } | undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
116
144
|
const richToMarkdown = (rich: NotionRichText[] = []): string =>
|
|
117
145
|
rich
|
|
118
146
|
.map((node) => {
|
|
@@ -133,20 +161,32 @@ const richToMarkdown = (rich: NotionRichText[] = []): string =>
|
|
|
133
161
|
})
|
|
134
162
|
.join("");
|
|
135
163
|
|
|
164
|
+
const isBlockPayload = (
|
|
165
|
+
value: NotionBlockPayload | boolean | string | undefined
|
|
166
|
+
): value is NotionBlockPayload => typeof value === "object";
|
|
167
|
+
|
|
168
|
+
/** The payload object stored under a block's own `type` key, if present. */
|
|
169
|
+
const payloadOf = (block: NotionBlock): NotionBlockPayload | undefined => {
|
|
170
|
+
const value = block[block.type];
|
|
171
|
+
return isBlockPayload(value) ? value : undefined;
|
|
172
|
+
};
|
|
173
|
+
|
|
136
174
|
const blockField = (block: NotionBlock): NotionRichText[] =>
|
|
137
|
-
(
|
|
138
|
-
[]) as NotionRichText[];
|
|
175
|
+
payloadOf(block)?.rich_text ?? [];
|
|
139
176
|
|
|
140
177
|
const RATE_LIMITED = 429;
|
|
141
178
|
const MAX_RETRIES = 4;
|
|
142
179
|
const BASE_DELAY_MS = 500;
|
|
143
180
|
const SECOND_MS = 1000;
|
|
181
|
+
const DEFAULT_CONCURRENCY = 3;
|
|
144
182
|
|
|
145
183
|
/**
|
|
146
184
|
* Retry a Notion API call on a `429 rate_limited`, honoring the `Retry-After`
|
|
147
185
|
* header and otherwise backing off exponentially. A large workspace fans out
|
|
148
186
|
* many concurrent block-children requests, so without this a single 429 would
|
|
149
|
-
* reject the batch and abort the whole import.
|
|
187
|
+
* reject the batch and abort the whole import. The exponential wait is
|
|
188
|
+
* jittered so calls rate-limited together don't retry in lockstep and trip
|
|
189
|
+
* the limit again as a herd.
|
|
150
190
|
*/
|
|
151
191
|
const withNotionRetry = async <T>(
|
|
152
192
|
call: () => Promise<T>,
|
|
@@ -155,15 +195,23 @@ const withNotionRetry = async <T>(
|
|
|
155
195
|
try {
|
|
156
196
|
return await call();
|
|
157
197
|
} catch (error) {
|
|
198
|
+
// SAFETY: Notion SDK failures are APIResponseError-shaped, carrying the
|
|
199
|
+
// failed request's HTTP status; anything else reads `undefined` and is
|
|
200
|
+
// rethrown below.
|
|
158
201
|
const { status } = error as { status?: number };
|
|
159
202
|
if (status !== RATE_LIMITED || attempt === MAX_RETRIES) {
|
|
160
203
|
throw error;
|
|
161
204
|
}
|
|
205
|
+
// SAFETY: same APIResponseError shape — `headers` maps lower-cased HTTP
|
|
206
|
+
// header names to their values; a missing header yields `NaN` and falls
|
|
207
|
+
// back to exponential backoff.
|
|
162
208
|
const retryAfter = Number(
|
|
163
209
|
(error as { headers?: Record<string, string> }).headers?.["retry-after"]
|
|
164
210
|
);
|
|
165
211
|
const wait =
|
|
166
|
-
retryAfter > 0
|
|
212
|
+
retryAfter > 0
|
|
213
|
+
? retryAfter * SECOND_MS
|
|
214
|
+
: BASE_DELAY_MS * 2 ** attempt * (1 + Math.random());
|
|
167
215
|
await sleep(wait);
|
|
168
216
|
return withNotionRetry(call, attempt + 1);
|
|
169
217
|
}
|
|
@@ -194,7 +242,7 @@ const isListItem = (block: NotionBlock | undefined): boolean =>
|
|
|
194
242
|
|
|
195
243
|
/** Render a leaf (non-container) block to Markdown, or null for containers. */
|
|
196
244
|
const renderLeaf = (block: NotionBlock): string | null => {
|
|
197
|
-
const data = (block
|
|
245
|
+
const data = payloadOf(block) ?? {};
|
|
198
246
|
const text = richToMarkdown(blockField(block));
|
|
199
247
|
switch (block.type) {
|
|
200
248
|
case "paragraph": {
|
|
@@ -225,16 +273,11 @@ const renderLeaf = (block: NotionBlock): string | null => {
|
|
|
225
273
|
return "---";
|
|
226
274
|
}
|
|
227
275
|
case "code": {
|
|
228
|
-
return `\`\`\`${
|
|
276
|
+
return `\`\`\`${data.language ?? ""}\n${text}\n\`\`\``;
|
|
229
277
|
}
|
|
230
278
|
case "image": {
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
file?: { url: string };
|
|
234
|
-
caption?: NotionRichText[];
|
|
235
|
-
};
|
|
236
|
-
const url = media.external?.url ?? media.file?.url;
|
|
237
|
-
return url ? `` : "";
|
|
279
|
+
const url = data.external?.url ?? data.file?.url;
|
|
280
|
+
return url ? `` : "";
|
|
238
281
|
}
|
|
239
282
|
default: {
|
|
240
283
|
return null;
|
|
@@ -252,6 +295,16 @@ export const notionSource = (
|
|
|
252
295
|
ctx?: SourceContext
|
|
253
296
|
): ContentSource => {
|
|
254
297
|
const props = options.properties ?? {};
|
|
298
|
+
// A FIFO semaphore: at most N calls run at once, the rest queue. Notion's
|
|
299
|
+
// rate limit is per-integration (an average of 3 req/s), and a large
|
|
300
|
+
// database fans out one block-children request per page plus one per nested
|
|
301
|
+
// container — an unbounded burst guarantees 429s that even the retry loop
|
|
302
|
+
// can't recover from, so every API call funnels through this limiter.
|
|
303
|
+
const limit = pLimit(Math.max(1, options.concurrency ?? DEFAULT_CONCURRENCY));
|
|
304
|
+
// Every Notion API call goes through the limiter, inside the retry — so a
|
|
305
|
+
// call sleeping through a backoff doesn't hold a slot while it waits.
|
|
306
|
+
const notionCall = <T>(call: () => Promise<T>): Promise<T> =>
|
|
307
|
+
withNotionRetry(() => limit(call));
|
|
255
308
|
const cache = snapshotCache(
|
|
256
309
|
ctx?.cacheDir ?? join(".blume", "cache", options.name)
|
|
257
310
|
);
|
|
@@ -266,6 +319,9 @@ export const notionSource = (
|
|
|
266
319
|
}
|
|
267
320
|
let Client: new (config: { auth?: string }) => NotionClientLike;
|
|
268
321
|
try {
|
|
322
|
+
// SAFETY: `@notionhq/client` exports a `Client` class constructable with
|
|
323
|
+
// an `auth` token whose instances cover the NotionClientLike slice; the
|
|
324
|
+
// local type keeps the SDK mockable without importing its types.
|
|
269
325
|
({ Client } = (await import("@notionhq/client")) as {
|
|
270
326
|
Client: new (config: { auth?: string }) => NotionClientLike;
|
|
271
327
|
});
|
|
@@ -284,7 +340,7 @@ export const notionSource = (
|
|
|
284
340
|
blockId: string
|
|
285
341
|
): Promise<NotionBlock[]> =>
|
|
286
342
|
collectAll((cursor) =>
|
|
287
|
-
|
|
343
|
+
notionCall(() =>
|
|
288
344
|
client.blocks.children.list({ block_id: blockId, start_cursor: cursor })
|
|
289
345
|
)
|
|
290
346
|
);
|
|
@@ -399,13 +455,11 @@ export const notionSource = (
|
|
|
399
455
|
|
|
400
456
|
const orderOf = (page: NotionPage): number | undefined => {
|
|
401
457
|
const order = page.properties[props.order ?? "Order"]?.number;
|
|
402
|
-
return
|
|
458
|
+
return order === null ? undefined : order;
|
|
403
459
|
};
|
|
404
460
|
|
|
405
|
-
const frontmatter = (
|
|
406
|
-
|
|
407
|
-
): { data: Record<string, unknown>; slug: string } => {
|
|
408
|
-
const data: Record<string, unknown> = {};
|
|
461
|
+
const frontmatter = (page: NotionPage) => {
|
|
462
|
+
const data: NotionFrontmatter = {};
|
|
409
463
|
const title = richToMarkdown(titleProperty(page)?.title);
|
|
410
464
|
if (title) {
|
|
411
465
|
data.title = title;
|
|
@@ -458,12 +512,12 @@ export const notionSource = (
|
|
|
458
512
|
};
|
|
459
513
|
|
|
460
514
|
// Hoisted out of `load` so the retry closure doesn't nest past the linter's
|
|
461
|
-
// 4-level limit (source factory → queryDatabase →
|
|
515
|
+
// 4-level limit (source factory → queryDatabase → notionCall callback).
|
|
462
516
|
const queryDatabase = (
|
|
463
517
|
client: NotionClientLike,
|
|
464
518
|
cursor?: string
|
|
465
519
|
): Promise<NotionList<NotionPage>> =>
|
|
466
|
-
|
|
520
|
+
notionCall(() =>
|
|
467
521
|
client.databases.query({
|
|
468
522
|
database_id: options.database,
|
|
469
523
|
start_cursor: cursor,
|