blume 0.3.0 → 0.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/dist/cli/index.js +1631 -940
- package/dist/cli/index.js.map +62 -50
- package/dist/types/core/data.d.ts +2 -0
- package/dist/types/core/project.d.ts +12 -2
- package/dist/types/core/schema.d.ts +442 -292
- package/dist/types/core/types.d.ts +7 -0
- package/dist/types/migrate/mintlify/assets.d.ts +8 -0
- package/docs/01-quickstart.mdx +5 -16
- package/docs/02-deployment.mdx +21 -54
- package/docs/advanced/api-reference.mdx +34 -51
- package/docs/advanced/blog.mdx +9 -25
- package/docs/advanced/bridge.mdx +74 -0
- package/docs/advanced/changelog.mdx +10 -33
- package/docs/advanced/custom-pages.mdx +21 -78
- package/docs/advanced/meta.ts +8 -1
- package/docs/advanced/migrate.mdx +119 -0
- package/docs/configuration/ai.mdx +42 -103
- package/docs/configuration/analytics.mdx +20 -38
- package/docs/configuration/customization.mdx +40 -73
- package/docs/configuration/export.mdx +9 -34
- package/docs/configuration/index.mdx +67 -87
- package/docs/configuration/search.mdx +17 -54
- package/docs/configuration/seo.mdx +17 -48
- package/docs/configuration/theming.mdx +20 -42
- package/docs/content/components.mdx +95 -101
- package/docs/content/i18n.mdx +21 -72
- package/docs/content/index.mdx +18 -48
- package/docs/content/islands.mdx +25 -52
- package/docs/content/meta.mdx +23 -50
- package/docs/content/navigation.mdx +23 -62
- package/docs/content/sources.mdx +20 -83
- package/docs/content/syntax.mdx +37 -105
- package/docs/index.mdx +12 -41
- package/docs/reference/cli.mdx +47 -30
- package/docs/reference/frontmatter.mdx +7 -5
- package/package.json +11 -1
- package/src/astro/generate.ts +18 -8
- package/src/astro/integration.ts +26 -3
- package/src/astro/islands.ts +6 -2
- package/src/astro/markdown-negotiation.ts +17 -3
- package/src/astro/pages.ts +6 -1
- package/src/astro/static-assets.ts +117 -0
- package/src/astro/templates.ts +76 -30
- package/src/cli/args.ts +23 -0
- package/src/cli/commands/build.ts +129 -62
- package/src/cli/commands/check.ts +20 -0
- package/src/cli/commands/dev.ts +11 -2
- package/src/cli/commands/doctor.ts +10 -1
- package/src/cli/commands/eject.ts +3 -1
- package/src/cli/commands/init.ts +21 -1
- package/src/cli/commands/preview.ts +2 -1
- package/src/cli/commands/validate.ts +12 -1
- package/src/cli/dev-lock.ts +92 -0
- package/src/cli/log.ts +11 -0
- package/src/cli/prepare.ts +3 -0
- package/src/components/BlumePage.astro +8 -0
- package/src/components/Icon.astro +13 -10
- package/src/components/content/ApiField.astro +75 -0
- package/src/components/content/ParamField.astro +39 -0
- package/src/components/content/RequestField.astro +23 -0
- package/src/components/content/ResponseField.astro +23 -0
- package/src/components/content/Step.astro +1 -1
- package/src/components/content/YouTube.astro +35 -0
- package/src/components/content/youtube.ts +46 -0
- package/src/components/islands/ask-ai.tsx +14 -14
- package/src/components/layout/Breadcrumbs.astro +7 -2
- package/src/components/layout/NavTree.astro +24 -8
- package/src/components/layout/RootLayout.astro +56 -34
- package/src/components/layout/Search.astro +1 -1
- package/src/components/openapi/ApiOverview.astro +84 -0
- package/src/components/openapi/MethodBadge.astro +28 -0
- package/src/components/openapi/Operation.astro +140 -0
- package/src/components/openapi/ParametersTable.astro +97 -0
- package/src/components/openapi/RequestBody.astro +58 -0
- package/src/components/openapi/RequestPanel.astro +169 -0
- package/src/components/openapi/Responses.astro +91 -0
- package/src/components/openapi/SchemaProperty.astro +118 -0
- package/src/components/openapi/SchemaTable.astro +86 -0
- package/src/components/openapi/helpers.ts +238 -0
- package/src/components/openapi/panel.ts +59 -0
- package/src/components/openapi/snippets.ts +201 -0
- package/src/components/props.ts +3 -0
- package/src/core/assets.ts +31 -0
- package/src/core/bridge.ts +10 -0
- package/src/core/builtin-tags.ts +6 -0
- package/src/core/data.ts +2 -0
- package/src/core/diagnostics.ts +6 -1
- package/src/core/gitignore.ts +30 -0
- package/src/core/links.ts +60 -19
- package/src/core/project-graph.ts +5 -1
- package/src/core/project.ts +25 -3
- package/src/core/schema.ts +54 -6
- package/src/core/sources/mdx-remote.ts +54 -8
- package/src/core/sources/mintlify.ts +1 -1
- package/src/core/sources/normalize.ts +6 -1
- package/src/core/sources/notion.ts +49 -5
- package/src/core/sources/resolve.ts +28 -6
- package/src/core/sources/sanity.ts +5 -1
- package/src/core/types.ts +7 -0
- package/src/deploy/rss.ts +1 -8
- package/src/deploy/sitemap.ts +20 -1
- package/src/deploy/xml.ts +8 -0
- package/src/markdown/directives.ts +15 -7
- package/src/markdown/package-commands.ts +26 -4
- package/src/migrate/fumadocs/content.ts +14 -1
- package/src/migrate/fumadocs/groups.ts +7 -0
- package/src/migrate/fumadocs/index.ts +5 -2
- package/src/migrate/mintlify/assets.ts +46 -0
- package/src/migrate/mintlify/config.ts +153 -1
- package/src/migrate/mintlify/content.ts +8 -2
- package/src/migrate/mintlify/index.ts +111 -46
- package/src/migrate/shared.ts +12 -27
- package/src/og/card.ts +14 -2
- package/src/openapi/model.ts +174 -0
- package/src/openapi/parse.ts +48 -0
- package/src/openapi/references.ts +164 -0
- package/src/openapi/render-mdx.ts +76 -0
- package/src/openapi/scalar.ts +15 -103
- package/src/openapi/source.ts +140 -0
- package/src/registry/eject.ts +28 -5
- package/src/registry/registry.ts +6 -0
- package/src/registry/rewrite-imports.ts +31 -19
- package/src/search/documents.ts +23 -5
- package/src/search/sync/algolia.ts +5 -1
- package/src/search/sync/typesense.ts +24 -16
- package/src/theme/chrome-icons.ts +22 -0
- package/src/theme/icons.ts +151 -161
- package/src/theme/palette.ts +26 -7
package/src/core/schema.ts
CHANGED
|
@@ -69,9 +69,30 @@ const changelogMetaSchema = z
|
|
|
69
69
|
})
|
|
70
70
|
.strict();
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* A post author: a bare name/handle, or an object with a name plus optional
|
|
74
|
+
* avatar/URL. The object is passthrough so richer author metadata (social
|
|
75
|
+
* handles, roles) survives untouched — Blume doesn't render authors yet, so
|
|
76
|
+
* this exists to preserve the field (common on blog/changelog pages) rather
|
|
77
|
+
* than have a strict scan reject it.
|
|
78
|
+
*/
|
|
79
|
+
const authorSchema = z.union([
|
|
80
|
+
z.string(),
|
|
81
|
+
z
|
|
82
|
+
.object({
|
|
83
|
+
avatar: z.string().optional(),
|
|
84
|
+
image: z.string().optional(),
|
|
85
|
+
name: z.string(),
|
|
86
|
+
url: z.string().optional(),
|
|
87
|
+
})
|
|
88
|
+
.passthrough(),
|
|
89
|
+
]);
|
|
90
|
+
|
|
72
91
|
/** Frontmatter accepted on any content page. */
|
|
73
92
|
const pageMetaBaseSchema = z
|
|
74
93
|
.object({
|
|
94
|
+
/** Post author(s) for blog/changelog content; preserved, not yet rendered. */
|
|
95
|
+
authors: z.union([authorSchema, z.array(authorSchema)]).optional(),
|
|
75
96
|
changelog: changelogMetaSchema.optional(),
|
|
76
97
|
/** Publish date for feed-backed content like blog/changelog. */
|
|
77
98
|
date: dateSchema.optional(),
|
|
@@ -365,6 +386,13 @@ export type ContentSourceConfig = z.infer<typeof contentSourceSchema>;
|
|
|
365
386
|
|
|
366
387
|
const contentConfigSchema = z
|
|
367
388
|
.object({
|
|
389
|
+
/**
|
|
390
|
+
* Extra top-level directories (relative to the project root) served as
|
|
391
|
+
* static assets at the site root, alongside `public/`. Lets projects keep
|
|
392
|
+
* root-served asset folders in place — e.g. a Mintlify migration keeps
|
|
393
|
+
* `images/` where it is instead of relocating it under `public/`.
|
|
394
|
+
*/
|
|
395
|
+
assets: z.array(z.string()).default([]),
|
|
368
396
|
defaultType: z.string().default("doc"),
|
|
369
397
|
exclude: z.array(z.string()).default(["**/_*", "**/.*"]),
|
|
370
398
|
include: z.array(z.string()).default(["**/*.{md,mdx}"]),
|
|
@@ -896,8 +924,8 @@ const markdownConfigSchema = z
|
|
|
896
924
|
.strict();
|
|
897
925
|
|
|
898
926
|
/**
|
|
899
|
-
* A single spec rendered by the API reference
|
|
900
|
-
*
|
|
927
|
+
* A single spec rendered by the API reference. `spec` is a local path or an
|
|
928
|
+
* `http(s)` URL (OpenAPI for the Blume renderer; OpenAPI or AsyncAPI for Scalar).
|
|
901
929
|
*/
|
|
902
930
|
const openapiSourceSchema = z
|
|
903
931
|
.object({
|
|
@@ -913,20 +941,28 @@ const openapiSourceSchema = z
|
|
|
913
941
|
export type OpenApiSource = z.infer<typeof openapiSourceSchema>;
|
|
914
942
|
|
|
915
943
|
/**
|
|
916
|
-
* OpenAPI reference
|
|
917
|
-
*
|
|
918
|
-
*
|
|
944
|
+
* OpenAPI reference. By default (`renderer: "blume"`) Blume parses the spec with
|
|
945
|
+
* Scalar's parser and renders its own UI: one real page per operation, grouped
|
|
946
|
+
* by tag in the sidebar and included in site search, llms.txt, and OG. Set
|
|
947
|
+
* `renderer: "scalar"` to fall back to the embedded Scalar SPA (a single
|
|
948
|
+
* self-contained route that doesn't weave into the sidebar or search).
|
|
919
949
|
*/
|
|
920
950
|
const openapiConfigSchema = z
|
|
921
951
|
.object({
|
|
952
|
+
/** Code-sample languages shown per operation (Blume renderer). */
|
|
953
|
+
codeSamples: z.array(z.string()).default(["curl", "js", "python"]),
|
|
922
954
|
enabled: z.boolean().default(false),
|
|
955
|
+
/** Start nested schema rows expanded rather than collapsed (Blume renderer). */
|
|
956
|
+
expandSchemas: z.boolean().default(false),
|
|
957
|
+
/** Who renders the reference: Blume's own UI, or the embedded Scalar SPA. */
|
|
958
|
+
renderer: z.enum(["blume", "scalar"]).default("blume"),
|
|
923
959
|
/** Where the reference mounts. */
|
|
924
960
|
route: z.string().default("/reference"),
|
|
925
961
|
/** One or more specs; each renders on its own route by default. */
|
|
926
962
|
sources: z.array(openapiSourceSchema).default([]),
|
|
927
963
|
/** Shorthand for a single source: `sources: [{ spec }]`. */
|
|
928
964
|
spec: z.string().optional(),
|
|
929
|
-
/** Scalar theme name
|
|
965
|
+
/** Scalar theme name (Scalar renderer only). */
|
|
930
966
|
theme: z.string().optional(),
|
|
931
967
|
})
|
|
932
968
|
.strict();
|
|
@@ -973,6 +1009,17 @@ const tocConfigSchema = z
|
|
|
973
1009
|
};
|
|
974
1010
|
});
|
|
975
1011
|
|
|
1012
|
+
/**
|
|
1013
|
+
* Which icon library bare `icon` names resolve against (mirrors Mintlify's
|
|
1014
|
+
* `icons.library`). Names can always opt into a specific set with an explicit
|
|
1015
|
+
* `prefix:name` (`lucide:rocket`, `fa6-brands:github`) regardless of this.
|
|
1016
|
+
*/
|
|
1017
|
+
const iconsConfigSchema = z
|
|
1018
|
+
.object({
|
|
1019
|
+
library: z.enum(["lucide", "fontawesome", "tabler"]).default("lucide"),
|
|
1020
|
+
})
|
|
1021
|
+
.strict();
|
|
1022
|
+
|
|
976
1023
|
export const blumeConfigSchema = z
|
|
977
1024
|
.object({
|
|
978
1025
|
ai: aiConfigSchema.default({}),
|
|
@@ -1001,6 +1048,7 @@ export const blumeConfigSchema = z
|
|
|
1001
1048
|
feedback: z.boolean().default(true),
|
|
1002
1049
|
github: githubConfigSchema.optional(),
|
|
1003
1050
|
i18n: i18nConfigSchema.optional(),
|
|
1051
|
+
icons: iconsConfigSchema.default({}),
|
|
1004
1052
|
lastModified: lastModifiedConfigSchema.default(false),
|
|
1005
1053
|
logo: logoConfigSchema.optional(),
|
|
1006
1054
|
markdown: markdownConfigSchema.default({}),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BlumeError } from "../diagnostics.ts";
|
|
2
2
|
import matter from "../frontmatter.ts";
|
|
3
|
+
import type { Diagnostic } from "../types.ts";
|
|
3
4
|
import {
|
|
4
5
|
hashText,
|
|
5
6
|
loadWithCache,
|
|
@@ -105,7 +106,7 @@ const enumerateGithub = async (
|
|
|
105
106
|
github: { owner: string; repo: string; ref: string; path: string },
|
|
106
107
|
include: string[],
|
|
107
108
|
doFetch: typeof fetch
|
|
108
|
-
): Promise<RemoteRef[]> => {
|
|
109
|
+
): Promise<{ refs: RemoteRef[]; truncated: boolean }> => {
|
|
109
110
|
const { owner, repo, ref } = github;
|
|
110
111
|
const base = github.path.replaceAll(/^\/|\/$/gu, "");
|
|
111
112
|
const treeUrl = `https://api.github.com/repos/${owner}/${repo}/git/trees/${ref}?recursive=1`;
|
|
@@ -113,9 +114,12 @@ const enumerateGithub = async (
|
|
|
113
114
|
if (!res.ok) {
|
|
114
115
|
throw new Error(`${treeUrl} -> ${res.status}`);
|
|
115
116
|
}
|
|
116
|
-
const body = (await res.json()) as {
|
|
117
|
+
const body = (await res.json()) as {
|
|
118
|
+
tree?: GithubTreeEntry[];
|
|
119
|
+
truncated?: boolean;
|
|
120
|
+
};
|
|
117
121
|
const prefix = base ? `${base}/` : "";
|
|
118
|
-
|
|
122
|
+
const refs = (body.tree ?? [])
|
|
119
123
|
.filter((node) => node.type === "blob" && node.path.startsWith(prefix))
|
|
120
124
|
.map((node) => node.path.slice(prefix.length))
|
|
121
125
|
.filter((rel) => matchesInclude(rel, include))
|
|
@@ -124,6 +128,9 @@ const enumerateGithub = async (
|
|
|
124
128
|
fetchUrl: `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${prefix}${rel}`,
|
|
125
129
|
ref: rel,
|
|
126
130
|
}));
|
|
131
|
+
// GitHub caps the recursive tree response (~100k entries / 7MB) and flags it
|
|
132
|
+
// with `truncated`; ignoring it would silently import only part of the repo.
|
|
133
|
+
return { refs, truncated: body.truncated === true };
|
|
127
134
|
};
|
|
128
135
|
|
|
129
136
|
/**
|
|
@@ -139,19 +146,23 @@ export const mdxRemoteSource = (
|
|
|
139
146
|
const cache = snapshotCache(ctx.cacheDir);
|
|
140
147
|
let snapshot = new Map<string, SourceEntry>();
|
|
141
148
|
|
|
142
|
-
const enumerate = async (): Promise<
|
|
149
|
+
const enumerate = async (): Promise<{
|
|
150
|
+
refs: RemoteRef[];
|
|
151
|
+
truncated: boolean;
|
|
152
|
+
}> => {
|
|
143
153
|
if (options.github) {
|
|
144
154
|
return await enumerateGithub(options.github, options.include, doFetch);
|
|
145
155
|
}
|
|
146
156
|
if (options.files && options.url) {
|
|
147
157
|
const base = options.url.replace(/\/$/u, "");
|
|
148
|
-
|
|
158
|
+
const refs = options.files
|
|
149
159
|
.filter((ref) => matchesInclude(ref, options.include))
|
|
150
160
|
.map((ref) => ({
|
|
151
161
|
editUrl: `${base}/${ref}`,
|
|
152
162
|
fetchUrl: `${base}/${ref}`,
|
|
153
163
|
ref,
|
|
154
164
|
}));
|
|
165
|
+
return { refs, truncated: false };
|
|
155
166
|
}
|
|
156
167
|
throw new BlumeError({
|
|
157
168
|
code: "BLUME_SOURCE_MISCONFIGURED",
|
|
@@ -179,17 +190,52 @@ export const mdxRemoteSource = (
|
|
|
179
190
|
};
|
|
180
191
|
|
|
181
192
|
const load = async (): Promise<SourceLoadResult> => {
|
|
193
|
+
const skipped: Diagnostic[] = [];
|
|
182
194
|
const result = await loadWithCache(
|
|
183
195
|
options.name,
|
|
184
196
|
cache,
|
|
185
197
|
async () => {
|
|
186
|
-
const refs = await enumerate();
|
|
187
|
-
|
|
198
|
+
const { refs, truncated } = await enumerate();
|
|
199
|
+
if (truncated) {
|
|
200
|
+
skipped.push({
|
|
201
|
+
code: "BLUME_SOURCE_TRUNCATED",
|
|
202
|
+
message: `Source "${options.name}" hit GitHub's tree listing limit; some files were not enumerated. Narrow the source path or split the repo.`,
|
|
203
|
+
severity: "warning",
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
const settled = await Promise.all(
|
|
207
|
+
refs.map(async (ref) => {
|
|
208
|
+
try {
|
|
209
|
+
return await fetchEntry(ref);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
skipped.push({
|
|
212
|
+
code: "BLUME_SOURCE_FETCH_FAILED",
|
|
213
|
+
message: `Source "${options.name}" skipped "${ref.ref}" (${(error as Error).message}); the rest were imported.`,
|
|
214
|
+
severity: "warning",
|
|
215
|
+
});
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
);
|
|
220
|
+
const entries = settled.filter(
|
|
221
|
+
(entry): entry is SourceEntry => entry !== null
|
|
222
|
+
);
|
|
223
|
+
// Only a total wipeout is a hard failure — let loadWithCache fall back
|
|
224
|
+
// to cache or fail loudly rather than silently importing nothing. A
|
|
225
|
+
// partial failure keeps the healthy pages and warns about the rest.
|
|
226
|
+
if (refs.length > 0 && entries.length === 0) {
|
|
227
|
+
skipped.length = 0;
|
|
228
|
+
throw new Error(`all ${refs.length} remote file(s) failed to fetch`);
|
|
229
|
+
}
|
|
230
|
+
return entries;
|
|
188
231
|
},
|
|
189
232
|
ctx.refresh ?? true
|
|
190
233
|
);
|
|
191
234
|
snapshot = new Map(result.entries.map((entry) => [entry.ref, entry]));
|
|
192
|
-
return
|
|
235
|
+
return {
|
|
236
|
+
...result,
|
|
237
|
+
diagnostics: [...result.diagnostics, ...skipped],
|
|
238
|
+
};
|
|
193
239
|
};
|
|
194
240
|
|
|
195
241
|
const read = async (ref: string): Promise<string> => {
|
|
@@ -102,7 +102,7 @@ export const mintlifySource = (
|
|
|
102
102
|
? [
|
|
103
103
|
{
|
|
104
104
|
code: "BLUME_MINTLIFY_UNSUPPORTED",
|
|
105
|
-
message: `Mintlify components without a Blume equivalent were left as-is: ${[...unsupported].toSorted().join(", ")}.
|
|
105
|
+
message: `Mintlify components without a Blume equivalent were left as-is: ${[...unsupported].toSorted().join(", ")}. Replace them by hand or provide a matching component.`,
|
|
106
106
|
severity: "warning",
|
|
107
107
|
},
|
|
108
108
|
]
|
|
@@ -137,8 +137,13 @@ export const extractLinks = (body: string): PageLink[] => {
|
|
|
137
137
|
if (target === undefined || match.index === undefined) {
|
|
138
138
|
continue;
|
|
139
139
|
}
|
|
140
|
+
// Locate the target from the `](` boundary rather than searching for the
|
|
141
|
+
// target text from the match start — otherwise a label that contains the
|
|
142
|
+
// same text (e.g. `[/a/b](/a/b)`) reports the column inside the label. The
|
|
143
|
+
// label can't contain `]`, so `](` is unambiguous.
|
|
144
|
+
const targetOffset = match.index + match[0].indexOf("](") + "](".length;
|
|
140
145
|
links.push({
|
|
141
|
-
column:
|
|
146
|
+
column: targetOffset + 1,
|
|
142
147
|
line: lineNumber,
|
|
143
148
|
target,
|
|
144
149
|
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
|
+
|
|
1
3
|
import { join } from "pathe";
|
|
2
4
|
|
|
3
5
|
import { BlumeError } from "../diagnostics.ts";
|
|
@@ -136,6 +138,44 @@ const blockField = (block: NotionBlock): NotionRichText[] =>
|
|
|
136
138
|
((block[block.type] as { rich_text?: NotionRichText[] })?.rich_text ??
|
|
137
139
|
[]) as NotionRichText[];
|
|
138
140
|
|
|
141
|
+
const RATE_LIMITED = 429;
|
|
142
|
+
const MAX_RETRIES = 4;
|
|
143
|
+
const BASE_DELAY_MS = 500;
|
|
144
|
+
const SECOND_MS = 1000;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Retry a Notion API call on a `429 rate_limited`, honoring the `Retry-After`
|
|
148
|
+
* header and otherwise backing off exponentially. A large workspace fans out
|
|
149
|
+
* many concurrent block-children requests, so without this a single 429 would
|
|
150
|
+
* reject the batch and abort the whole import.
|
|
151
|
+
*/
|
|
152
|
+
const withNotionRetry = async <T>(call: () => Promise<T>): Promise<T> => {
|
|
153
|
+
let lastError: unknown;
|
|
154
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) {
|
|
155
|
+
try {
|
|
156
|
+
// oxlint-disable-next-line no-await-in-loop -- sequential retry attempts
|
|
157
|
+
return await call();
|
|
158
|
+
} catch (error) {
|
|
159
|
+
lastError = error;
|
|
160
|
+
const { status } = error as { status?: number };
|
|
161
|
+
if (status !== RATE_LIMITED || attempt === MAX_RETRIES) {
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
const retryAfter = Number(
|
|
165
|
+
(error as { headers?: Record<string, string> }).headers?.["retry-after"]
|
|
166
|
+
);
|
|
167
|
+
const wait =
|
|
168
|
+
retryAfter > 0 ? retryAfter * SECOND_MS : BASE_DELAY_MS * 2 ** attempt;
|
|
169
|
+
// oxlint-disable-next-line no-await-in-loop -- back off before retrying
|
|
170
|
+
await sleep(wait);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// Unreachable — the loop always returns or rethrows — but keeps types honest.
|
|
174
|
+
throw lastError instanceof Error
|
|
175
|
+
? lastError
|
|
176
|
+
: new Error("Notion request failed after retries.");
|
|
177
|
+
};
|
|
178
|
+
|
|
139
179
|
/** Paginate a Notion list endpoint via recursion (no await-in-loop). */
|
|
140
180
|
const collectAll = async <T>(
|
|
141
181
|
page: (cursor?: string) => Promise<NotionList<T>>,
|
|
@@ -251,7 +291,9 @@ export const notionSource = (
|
|
|
251
291
|
blockId: string
|
|
252
292
|
): Promise<NotionBlock[]> =>
|
|
253
293
|
collectAll((cursor) =>
|
|
254
|
-
|
|
294
|
+
withNotionRetry(() =>
|
|
295
|
+
client.blocks.children.list({ block_id: blockId, start_cursor: cursor })
|
|
296
|
+
)
|
|
255
297
|
);
|
|
256
298
|
|
|
257
299
|
// `render` is injected (rather than referenced) so this stays a forward-free
|
|
@@ -396,10 +438,12 @@ export const notionSource = (
|
|
|
396
438
|
async () => {
|
|
397
439
|
const client = await resolveClient();
|
|
398
440
|
const pages = await collectAll((cursor) =>
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
441
|
+
withNotionRetry(() =>
|
|
442
|
+
client.databases.query({
|
|
443
|
+
database_id: options.database,
|
|
444
|
+
start_cursor: cursor,
|
|
445
|
+
})
|
|
446
|
+
)
|
|
403
447
|
);
|
|
404
448
|
const built = await Promise.all(
|
|
405
449
|
pages.map((page) => toEntry(client, page))
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { join } from "pathe";
|
|
2
2
|
|
|
3
|
+
import { blumeReferences } from "../../openapi/references.ts";
|
|
4
|
+
import { openApiSource } from "../../openapi/source.ts";
|
|
3
5
|
import type { ContentSourceConfig, ResolvedConfig } from "../schema.ts";
|
|
4
6
|
import type { ProjectContext } from "../types.ts";
|
|
5
7
|
import { filesystemSource } from "./filesystem.ts";
|
|
@@ -144,12 +146,8 @@ const baseName = (def: ContentSourceConfig): string => {
|
|
|
144
146
|
return def.prefix ?? def.type;
|
|
145
147
|
};
|
|
146
148
|
|
|
147
|
-
/**
|
|
148
|
-
|
|
149
|
-
* `content.sources` configured, the top-level `root`/`include`/`exclude` desugar
|
|
150
|
-
* to a single implicit filesystem source, so existing projects are untouched.
|
|
151
|
-
*/
|
|
152
|
-
export const resolveSources = (
|
|
149
|
+
/** The content sources declared by config (implicit filesystem when none). */
|
|
150
|
+
const contentSources = (
|
|
153
151
|
config: ResolvedConfig,
|
|
154
152
|
context: ProjectContext,
|
|
155
153
|
runtime: SourceRuntime
|
|
@@ -172,3 +170,27 @@ export const resolveSources = (
|
|
|
172
170
|
buildSource(def, nameFor(baseName(def)), context, runtime)
|
|
173
171
|
);
|
|
174
172
|
};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Build the ordered list of content sources for a project. With no
|
|
176
|
+
* `content.sources` configured, the top-level `root`/`include`/`exclude` desugar
|
|
177
|
+
* to a single implicit filesystem source, so existing projects are untouched.
|
|
178
|
+
* A Blume-rendered OpenAPI reference contributes an internal staged source that
|
|
179
|
+
* lowers each operation into a real content page (routing/nav/search/OG).
|
|
180
|
+
*/
|
|
181
|
+
export const resolveSources = (
|
|
182
|
+
config: ResolvedConfig,
|
|
183
|
+
context: ProjectContext,
|
|
184
|
+
runtime: SourceRuntime
|
|
185
|
+
): ContentSource[] => {
|
|
186
|
+
const sources = contentSources(config, context, runtime);
|
|
187
|
+
|
|
188
|
+
const references = blumeReferences(config);
|
|
189
|
+
if (references.length > 0) {
|
|
190
|
+
sources.push(
|
|
191
|
+
openApiSource(references, sourceContext(context, "openapi", runtime))
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return sources;
|
|
196
|
+
};
|
|
@@ -140,7 +140,11 @@ export const sanitySource = (
|
|
|
140
140
|
asString(getPath(doc, fields.slug ?? "slug.current")) ??
|
|
141
141
|
asString(doc._id) ??
|
|
142
142
|
"untitled";
|
|
143
|
-
|
|
143
|
+
// Fall back to the unique `_id` when a slug (e.g. a non-ASCII `slug.current`)
|
|
144
|
+
// slugifies to empty, so distinct documents don't all collapse to the same
|
|
145
|
+
// `untitled.md` ref and silently overwrite each other.
|
|
146
|
+
const slug =
|
|
147
|
+
slugify(slugValue) || slugify(asString(doc._id) ?? "") || "untitled";
|
|
144
148
|
|
|
145
149
|
const data: Record<string, unknown> = {};
|
|
146
150
|
const title = asString(getPath(doc, fields.title ?? "title"));
|
package/src/core/types.ts
CHANGED
|
@@ -54,6 +54,13 @@ export interface ProjectContext {
|
|
|
54
54
|
pagesRoot: string | null;
|
|
55
55
|
/** Absolute path to the generated runtime (`<root>/.blume`). */
|
|
56
56
|
outDir: string;
|
|
57
|
+
/**
|
|
58
|
+
* Absolute path to the Astro build output. `<root>/dist` normally; for a
|
|
59
|
+
* relocated runtime (isolated verify build) it lives under the runtime dir so
|
|
60
|
+
* it never empties the real `dist/`. Optional so hand-built test contexts and
|
|
61
|
+
* older callers still typecheck; `resolveProjectContext` always sets it.
|
|
62
|
+
*/
|
|
63
|
+
distDir?: string;
|
|
57
64
|
/** Absolute path to the user `theme.css`, if present. */
|
|
58
65
|
themeFile: string | null;
|
|
59
66
|
/** Absolute path to the user `components.ts`/`.tsx`, if present. */
|
package/src/deploy/rss.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
2
2
|
import type { PageRecord } from "../core/types.ts";
|
|
3
|
+
import { escapeXml } from "./xml.ts";
|
|
3
4
|
|
|
4
5
|
/** A single feed entry derived from a content page. */
|
|
5
6
|
export interface RssItem {
|
|
@@ -85,14 +86,6 @@ export const buildRssFeeds = (project: BlumeProject): RssFeed[] => {
|
|
|
85
86
|
return feeds;
|
|
86
87
|
};
|
|
87
88
|
|
|
88
|
-
const escapeXml = (value: string): string =>
|
|
89
|
-
value
|
|
90
|
-
.replaceAll("&", "&")
|
|
91
|
-
.replaceAll("<", "<")
|
|
92
|
-
.replaceAll(">", ">")
|
|
93
|
-
.replaceAll('"', """)
|
|
94
|
-
.replaceAll("'", "'");
|
|
95
|
-
|
|
96
89
|
const renderItem = (item: RssItem): string => {
|
|
97
90
|
const parts = [
|
|
98
91
|
` <title>${escapeXml(item.title)}</title>`,
|
package/src/deploy/sitemap.ts
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
2
|
+
import { escapeXml } from "./xml.ts";
|
|
3
|
+
|
|
4
|
+
/** A `<lastmod>` element (W3C date) when the page has a valid modified date. */
|
|
5
|
+
const lastmodTag = (value: string | undefined): string => {
|
|
6
|
+
if (!value) {
|
|
7
|
+
return "";
|
|
8
|
+
}
|
|
9
|
+
const date = new Date(value);
|
|
10
|
+
return Number.isNaN(date.getTime())
|
|
11
|
+
? ""
|
|
12
|
+
: `<lastmod>${date.toISOString().slice(0, 10)}</lastmod>`;
|
|
13
|
+
};
|
|
2
14
|
|
|
3
15
|
/**
|
|
4
16
|
* Build a sitemap.xml from the route manifest. Returns null when the sitemap is
|
|
@@ -17,7 +29,14 @@ export const buildSitemap = (project: BlumeProject): string | null => {
|
|
|
17
29
|
(page) =>
|
|
18
30
|
!(page.meta.draft || page.meta.sidebar.hidden || page.meta.seo.noindex)
|
|
19
31
|
)
|
|
20
|
-
|
|
32
|
+
// `<loc>` must be a well-formed, XML-escaped URL: percent-encode the path,
|
|
33
|
+
// then escape XML metacharacters (notably `&`) so a route like
|
|
34
|
+
// `/Tips & Tricks` doesn't produce invalid XML that gets the whole sitemap
|
|
35
|
+
// rejected.
|
|
36
|
+
.map(
|
|
37
|
+
(page) =>
|
|
38
|
+
` <url><loc>${escapeXml(encodeURI(`${base}${page.route}`))}</loc>${lastmodTag(page.lastModified)}</url>`
|
|
39
|
+
)
|
|
21
40
|
.toSorted();
|
|
22
41
|
|
|
23
42
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Escape a string for safe inclusion in XML text or attribute content. */
|
|
2
|
+
export const escapeXml = (value: string): string =>
|
|
3
|
+
value
|
|
4
|
+
.replaceAll("&", "&")
|
|
5
|
+
.replaceAll("<", "<")
|
|
6
|
+
.replaceAll(">", ">")
|
|
7
|
+
.replaceAll('"', """)
|
|
8
|
+
.replaceAll("'", "'");
|
|
@@ -3,7 +3,8 @@ import type { MdastNode, MdastVisitorContext } from "./mdast.ts";
|
|
|
3
3
|
|
|
4
4
|
interface DirectiveNode extends MdastNode {
|
|
5
5
|
attributes?: Record<string, string | null | undefined> | null;
|
|
6
|
-
children:
|
|
6
|
+
// Satteri gives an empty container directive (`:::note\n:::`) `children: null`.
|
|
7
|
+
children?: MdastNode[] | null;
|
|
7
8
|
name: string;
|
|
8
9
|
}
|
|
9
10
|
|
|
@@ -38,11 +39,18 @@ interface TextNode extends MdastNode {
|
|
|
38
39
|
value?: string;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Concatenate the plain text of a node, recursing through phrasing children so
|
|
44
|
+
* formatted labels keep every word — `:::note[Read **this**]` yields
|
|
45
|
+
* `Read this`, not `Read ` (the bolded run dropped).
|
|
46
|
+
*/
|
|
47
|
+
const textOf = (node: MdastNode): string => {
|
|
48
|
+
const { children } = node as { children?: MdastNode[] };
|
|
49
|
+
if (children && children.length > 0) {
|
|
50
|
+
return children.map(textOf).join("");
|
|
51
|
+
}
|
|
52
|
+
return (node as TextNode).value ?? "";
|
|
53
|
+
};
|
|
46
54
|
|
|
47
55
|
/**
|
|
48
56
|
* Satteri MDAST plugin mapping container directives (`:::note`, `:::warning`,
|
|
@@ -57,7 +65,7 @@ export const directiveToCalloutPlugin = () => ({
|
|
|
57
65
|
return;
|
|
58
66
|
}
|
|
59
67
|
|
|
60
|
-
const children = [...node.children];
|
|
68
|
+
const children = [...(node.children ?? [])];
|
|
61
69
|
let title = node.attributes?.title ?? undefined;
|
|
62
70
|
|
|
63
71
|
// A leading `:::name[Label]` parses to a paragraph flagged `directiveLabel`.
|
|
@@ -10,7 +10,14 @@ const WHITESPACE = /\s+/u;
|
|
|
10
10
|
const WHITESPACE_RUN = /\s+/gu;
|
|
11
11
|
const GLOBAL_FLAGS = new Set(["-g", "--global"]);
|
|
12
12
|
|
|
13
|
-
type Operation =
|
|
13
|
+
type Operation =
|
|
14
|
+
| "add"
|
|
15
|
+
| "ci"
|
|
16
|
+
| "create"
|
|
17
|
+
| "exec"
|
|
18
|
+
| "install"
|
|
19
|
+
| "remove"
|
|
20
|
+
| "run";
|
|
14
21
|
|
|
15
22
|
interface Intent {
|
|
16
23
|
args: string[];
|
|
@@ -26,6 +33,9 @@ const normalizeVerb = (verb: string): Operation | null => {
|
|
|
26
33
|
case "install": {
|
|
27
34
|
return "add";
|
|
28
35
|
}
|
|
36
|
+
case "ci": {
|
|
37
|
+
return "ci";
|
|
38
|
+
}
|
|
29
39
|
case "create":
|
|
30
40
|
case "init": {
|
|
31
41
|
return "create";
|
|
@@ -125,10 +135,22 @@ const buildCommand = (manager: PackageManager, intent: Intent): string => {
|
|
|
125
135
|
}
|
|
126
136
|
return `${manager} dlx ${args}`;
|
|
127
137
|
}
|
|
128
|
-
case "
|
|
138
|
+
case "ci": {
|
|
139
|
+
// `npm ci` maps to a frozen, lockfile-faithful install elsewhere.
|
|
129
140
|
return manager === "npm"
|
|
130
|
-
?
|
|
131
|
-
: `${manager}
|
|
141
|
+
? "npm ci"
|
|
142
|
+
: `${manager} install --frozen-lockfile`;
|
|
143
|
+
}
|
|
144
|
+
case "remove": {
|
|
145
|
+
if (manager === "npm") {
|
|
146
|
+
return `npm uninstall ${args}`;
|
|
147
|
+
}
|
|
148
|
+
// Yarn Classic has no `remove -g`; the global form is `yarn global remove`.
|
|
149
|
+
if (manager === "yarn" && intent.args.some((a) => GLOBAL_FLAGS.has(a))) {
|
|
150
|
+
const pkgs = intent.args.filter((a) => !GLOBAL_FLAGS.has(a)).join(" ");
|
|
151
|
+
return `yarn global remove ${pkgs}`;
|
|
152
|
+
}
|
|
153
|
+
return `${manager} remove ${args}`;
|
|
132
154
|
}
|
|
133
155
|
case "run": {
|
|
134
156
|
return `${manager} run ${args}`;
|
|
@@ -4,7 +4,12 @@ import { readFile as readFileFromDisk } from "node:fs/promises";
|
|
|
4
4
|
import { dirname, resolve } from "pathe";
|
|
5
5
|
|
|
6
6
|
import matter from "../../core/frontmatter.ts";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
findOpenTagEnd,
|
|
9
|
+
isInsideRoot,
|
|
10
|
+
renameTag,
|
|
11
|
+
rewriteCallouts,
|
|
12
|
+
} from "../shared.ts";
|
|
8
13
|
|
|
9
14
|
/**
|
|
10
15
|
* Source-to-source rewrites that turn Fumadocs-only MDX into idiomatic Blume
|
|
@@ -291,6 +296,8 @@ const INCLUDE = /<include\b[^>]*>(?<path>[\s\S]*?)<\/include>/gu;
|
|
|
291
296
|
interface IncludeOptions {
|
|
292
297
|
filePath: string;
|
|
293
298
|
readFile?: (file: string) => Promise<string>;
|
|
299
|
+
/** Docs root the include must stay within; targets escaping it are skipped. */
|
|
300
|
+
root: string;
|
|
294
301
|
seen?: Set<string>;
|
|
295
302
|
}
|
|
296
303
|
|
|
@@ -321,6 +328,12 @@ export const inlineFumadocsIncludes = async (
|
|
|
321
328
|
continue;
|
|
322
329
|
}
|
|
323
330
|
const target = resolve(dirname(options.filePath), rawPath);
|
|
331
|
+
if (!isInsideRoot(options.root, target)) {
|
|
332
|
+
warnings.push(
|
|
333
|
+
`<include> target "${rawPath}" is outside the docs tree — left as-is.`
|
|
334
|
+
);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
324
337
|
if (seen.has(target)) {
|
|
325
338
|
warnings.push(`Circular <include> "${rawPath}" — left as-is.`);
|
|
326
339
|
continue;
|
|
@@ -3,6 +3,7 @@ import { mkdir, rename, writeFile } from "node:fs/promises";
|
|
|
3
3
|
|
|
4
4
|
import { basename, join } from "pathe";
|
|
5
5
|
|
|
6
|
+
import { isInsideRoot } from "../shared.ts";
|
|
6
7
|
import { renderMetaModule } from "./meta.ts";
|
|
7
8
|
import type {
|
|
8
9
|
FumadocsPageItem,
|
|
@@ -47,6 +48,12 @@ const isDirectory = (path: string): boolean => {
|
|
|
47
48
|
|
|
48
49
|
/** Resolve a `pages` name to its on-disk page file or folder under `docsDir`. */
|
|
49
50
|
const resolveEntry = (docsDir: string, name: string): ResolvedEntry | null => {
|
|
51
|
+
// A `pages` entry is author-controlled; reject any that escapes `docsDir`
|
|
52
|
+
// (e.g. `"../../victim"`) so the later `rename` can't move a file out of the
|
|
53
|
+
// docs tree.
|
|
54
|
+
if (!isInsideRoot(docsDir, join(docsDir, name))) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
50
57
|
for (const ext of PAGE_EXTS) {
|
|
51
58
|
const file = join(docsDir, `${name}${ext}`);
|
|
52
59
|
if (existsSync(file)) {
|
|
@@ -5,9 +5,9 @@ import { dirname, join, relative } from "pathe";
|
|
|
5
5
|
import { glob } from "tinyglobby";
|
|
6
6
|
|
|
7
7
|
import matter from "../../core/frontmatter.ts";
|
|
8
|
+
import { ensureGitignore } from "../../core/gitignore.ts";
|
|
8
9
|
import type { FolderMeta } from "../../core/schema.ts";
|
|
9
10
|
import {
|
|
10
|
-
ensureGitignore,
|
|
11
11
|
leftoverFiles,
|
|
12
12
|
rewriteFrameworkScripts,
|
|
13
13
|
writeBlumeConfig,
|
|
@@ -85,7 +85,10 @@ const movePage = async (
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
const raw = await readFile(abs, "utf-8");
|
|
88
|
-
const included = await inlineFumadocsIncludes(raw, {
|
|
88
|
+
const included = await inlineFumadocsIncludes(raw, {
|
|
89
|
+
filePath: abs,
|
|
90
|
+
root: base,
|
|
91
|
+
});
|
|
89
92
|
let text = stripFumadocsImports(included.content);
|
|
90
93
|
text = rewriteFumadocsCallouts(text);
|
|
91
94
|
text = rewriteFumadocsContainers(text);
|