blume 0.2.0 → 0.4.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 +2429 -792
- package/dist/cli/index.js.map +63 -44
- package/dist/types/core/data.d.ts +16 -0
- package/dist/types/core/define-components.d.ts +9 -2
- package/dist/types/core/diagnostics.d.ts +5 -0
- package/dist/types/core/schema.d.ts +313 -778
- package/dist/types/core/types.d.ts +2 -2
- package/dist/types/migrate/mintlify/assets.d.ts +8 -0
- package/docs/01-quickstart.mdx +5 -16
- package/docs/02-deployment.mdx +26 -40
- package/docs/advanced/api-reference.mdx +10 -37
- package/docs/advanced/blog.mdx +9 -25
- package/docs/advanced/changelog.mdx +10 -33
- package/docs/advanced/custom-pages.mdx +66 -61
- package/docs/configuration/ai.mdx +47 -91
- package/docs/configuration/analytics.mdx +20 -38
- package/docs/configuration/customization.mdx +92 -27
- package/docs/configuration/export.mdx +9 -34
- package/docs/configuration/index.mdx +78 -85
- package/docs/configuration/search.mdx +17 -54
- package/docs/configuration/seo.mdx +18 -44
- package/docs/configuration/theming.mdx +20 -42
- package/docs/content/components.mdx +42 -101
- package/docs/content/i18n.mdx +21 -72
- package/docs/content/index.mdx +18 -48
- package/docs/content/islands.mdx +79 -33
- package/docs/content/meta.mdx +23 -50
- package/docs/content/navigation.mdx +42 -56
- package/docs/content/sources.mdx +20 -83
- package/docs/content/syntax.mdx +37 -105
- package/docs/index.mdx +13 -51
- package/docs/reference/cli.mdx +49 -18
- package/docs/reference/frontmatter.mdx +2 -5
- package/package.json +3 -1
- package/src/ai/ask-context.ts +131 -0
- package/src/ai/ask-data.ts +25 -0
- package/src/astro/component-slots.ts +165 -0
- package/src/astro/generate.ts +132 -13
- package/src/astro/integration.ts +85 -3
- package/src/astro/islands.ts +6 -2
- package/src/astro/markdown-negotiation.ts +17 -3
- package/src/astro/pages.ts +11 -13
- package/src/astro/static-assets.ts +117 -0
- package/src/astro/templates.ts +120 -50
- package/src/blume-modules.d.ts +25 -0
- package/src/cli/args.ts +23 -0
- package/src/cli/commands/build.ts +209 -1
- package/src/cli/commands/check.ts +62 -0
- package/src/cli/commands/dev.ts +32 -3
- package/src/cli/commands/doctor.ts +32 -6
- package/src/cli/commands/eject.ts +3 -1
- package/src/cli/commands/init.ts +184 -16
- package/src/cli/commands/preview.ts +2 -1
- package/src/cli/commands/validate.ts +27 -2
- package/src/cli/dev-lock.ts +84 -0
- package/src/cli/index.ts +15 -0
- package/src/cli/internal-error.ts +63 -0
- package/src/cli/log.ts +41 -1
- package/src/cli/prepare.ts +17 -3
- package/src/cli/required-secrets.ts +44 -0
- package/src/components/BlumePage.astro +109 -0
- package/src/components/content/YouTube.astro +35 -0
- package/src/components/content/youtube.ts +46 -0
- package/src/components/index.ts +3 -3
- package/src/components/islands/ask-ai.tsx +29 -15
- package/src/components/islands/hooks.ts +188 -0
- package/src/components/layout/Empty.astro +6 -0
- package/src/components/layout/Header.astro +24 -39
- package/src/components/layout/Logo.astro +50 -0
- package/src/components/layout/NavSelector.astro +75 -0
- package/src/components/layout/PageLayout.astro +38 -2
- package/src/components/layout/RootLayout.astro +70 -4
- package/src/components/layout/hydration-hint.ts +30 -0
- package/src/components/layout/overrides.ts +6 -4
- package/src/components/props.ts +71 -0
- package/src/core/assets.ts +31 -0
- package/src/core/bridge.ts +10 -0
- package/src/core/builtin-tags.ts +40 -0
- package/src/core/component-diagnostics.ts +44 -0
- package/src/core/component-overrides.ts +478 -0
- package/src/core/config.ts +8 -0
- package/src/core/data.ts +14 -0
- package/src/core/define-components.ts +9 -2
- package/src/core/diagnostics.ts +95 -1
- package/src/core/gitignore.ts +30 -0
- package/src/core/graph.ts +7 -0
- package/src/core/links.ts +60 -19
- package/src/core/nav-diagnostics.ts +205 -0
- package/src/core/project-graph.ts +40 -1
- package/src/core/schema.ts +35 -96
- package/src/core/sources/mdx-remote.ts +54 -8
- package/src/core/sources/normalize.ts +57 -1
- package/src/core/sources/notion.ts +49 -5
- package/src/core/sources/sanity.ts +5 -1
- package/src/core/types.ts +2 -2
- package/src/deploy/redirects.ts +43 -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 +1 -176
- package/src/migrate/mintlify/index.ts +53 -45
- package/src/migrate/shared.ts +12 -27
- package/src/migrate/starlight/config.ts +0 -4
- package/src/og/card.ts +175 -38
- package/src/registry/eject.ts +52 -12
- package/src/registry/registry.ts +172 -0
- package/src/registry/rewrite-imports.ts +31 -19
- package/src/runtime/index.ts +61 -0
- 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/palette.ts +26 -7
- package/src/vite-env.d.ts +14 -0
package/src/core/schema.ts
CHANGED
|
@@ -365,6 +365,13 @@ export type ContentSourceConfig = z.infer<typeof contentSourceSchema>;
|
|
|
365
365
|
|
|
366
366
|
const contentConfigSchema = z
|
|
367
367
|
.object({
|
|
368
|
+
/**
|
|
369
|
+
* Extra top-level directories (relative to the project root) served as
|
|
370
|
+
* static assets at the site root, alongside `public/`. Lets projects keep
|
|
371
|
+
* root-served asset folders in place — e.g. a Mintlify migration keeps
|
|
372
|
+
* `images/` where it is instead of relocating it under `public/`.
|
|
373
|
+
*/
|
|
374
|
+
assets: z.array(z.string()).default([]),
|
|
368
375
|
defaultType: z.string().default("doc"),
|
|
369
376
|
exclude: z.array(z.string()).default(["**/_*", "**/.*"]),
|
|
370
377
|
include: z.array(z.string()).default(["**/*.{md,mdx}"]),
|
|
@@ -462,38 +469,6 @@ const sidebarVariantSchema = z
|
|
|
462
469
|
})
|
|
463
470
|
.strict();
|
|
464
471
|
|
|
465
|
-
const navbarLinkTypeSchema = z.enum(["github", "discord"]);
|
|
466
|
-
|
|
467
|
-
const navbarLinkSchema = z
|
|
468
|
-
.object({
|
|
469
|
-
href: z.string(),
|
|
470
|
-
icon: iconName.optional(),
|
|
471
|
-
label: z.string().optional(),
|
|
472
|
-
type: navbarLinkTypeSchema.optional(),
|
|
473
|
-
})
|
|
474
|
-
.strict()
|
|
475
|
-
.refine((value) => value.label !== undefined || value.type !== undefined, {
|
|
476
|
-
message: "Navbar links require either label or type.",
|
|
477
|
-
});
|
|
478
|
-
|
|
479
|
-
const navbarPrimarySchema = z
|
|
480
|
-
.object({
|
|
481
|
-
href: z.string(),
|
|
482
|
-
label: z.string().optional(),
|
|
483
|
-
type: z.enum(["button", "github", "discord"]).default("button"),
|
|
484
|
-
})
|
|
485
|
-
.strict()
|
|
486
|
-
.refine((value) => value.label !== undefined || value.type !== "button", {
|
|
487
|
-
message: "Navbar primary button links require a label.",
|
|
488
|
-
});
|
|
489
|
-
|
|
490
|
-
const navbarConfigSchema = z
|
|
491
|
-
.object({
|
|
492
|
-
links: z.array(navbarLinkSchema).default([]),
|
|
493
|
-
primary: navbarPrimarySchema.optional(),
|
|
494
|
-
})
|
|
495
|
-
.strict();
|
|
496
|
-
|
|
497
472
|
const variablesConfigSchema = z
|
|
498
473
|
.record(z.string().regex(/^[A-Za-z0-9-]+$/u), z.string())
|
|
499
474
|
.default({});
|
|
@@ -528,12 +503,6 @@ const themeConfigSchema = z
|
|
|
528
503
|
})
|
|
529
504
|
.strict();
|
|
530
505
|
|
|
531
|
-
const iconsConfigSchema = z
|
|
532
|
-
.object({
|
|
533
|
-
library: z.enum(["fontawesome", "lucide", "tabler"]).default("lucide"),
|
|
534
|
-
})
|
|
535
|
-
.strict();
|
|
536
|
-
|
|
537
506
|
/** Public credentials for the Algolia search backend (sync key is an env var). */
|
|
538
507
|
const algoliaSearchSchema = z
|
|
539
508
|
.object({
|
|
@@ -661,56 +630,9 @@ const aiConfigSchema = z
|
|
|
661
630
|
})
|
|
662
631
|
.strict();
|
|
663
632
|
|
|
664
|
-
const contextualOptionSchema = z.union([
|
|
665
|
-
z.string(),
|
|
666
|
-
z
|
|
667
|
-
.object({
|
|
668
|
-
description: z.string().optional(),
|
|
669
|
-
href: z.string().optional(),
|
|
670
|
-
icon: iconName.optional(),
|
|
671
|
-
title: z.string(),
|
|
672
|
-
})
|
|
673
|
-
.passthrough(),
|
|
674
|
-
]);
|
|
675
|
-
|
|
676
|
-
const contextualConfigSchema = z
|
|
677
|
-
.object({
|
|
678
|
-
display: z.enum(["header", "toc"]).default("header"),
|
|
679
|
-
options: z.array(contextualOptionSchema).default([]),
|
|
680
|
-
})
|
|
681
|
-
.strict();
|
|
682
|
-
|
|
683
|
-
const footerConfigSchema = z
|
|
684
|
-
.object({
|
|
685
|
-
links: z
|
|
686
|
-
.array(
|
|
687
|
-
z
|
|
688
|
-
.object({
|
|
689
|
-
header: z.string().optional(),
|
|
690
|
-
items: z
|
|
691
|
-
.array(
|
|
692
|
-
z
|
|
693
|
-
.object({
|
|
694
|
-
href: z.string(),
|
|
695
|
-
label: z.string(),
|
|
696
|
-
})
|
|
697
|
-
.strict()
|
|
698
|
-
)
|
|
699
|
-
.default([]),
|
|
700
|
-
})
|
|
701
|
-
.strict()
|
|
702
|
-
)
|
|
703
|
-
.max(4)
|
|
704
|
-
.default([]),
|
|
705
|
-
socials: z.record(z.string(), z.string()).default({}),
|
|
706
|
-
})
|
|
707
|
-
.strict();
|
|
708
|
-
|
|
709
633
|
const chromeVariantSchema = z
|
|
710
634
|
.object({
|
|
711
635
|
banner: bannerConfigSchema.optional(),
|
|
712
|
-
footer: footerConfigSchema.optional(),
|
|
713
|
-
navbar: navbarConfigSchema.optional(),
|
|
714
636
|
path: z.string(),
|
|
715
637
|
})
|
|
716
638
|
.strict();
|
|
@@ -980,12 +902,6 @@ const markdownConfigSchema = z
|
|
|
980
902
|
})
|
|
981
903
|
.strict();
|
|
982
904
|
|
|
983
|
-
const stylingConfigSchema = z
|
|
984
|
-
.object({
|
|
985
|
-
eyebrows: z.enum(["breadcrumbs", "section"]).default("section"),
|
|
986
|
-
})
|
|
987
|
-
.strict();
|
|
988
|
-
|
|
989
905
|
/**
|
|
990
906
|
* A single spec rendered by the API reference (Scalar). `spec` is a local path
|
|
991
907
|
* or an `http(s)` URL; Scalar auto-detects OpenAPI vs AsyncAPI documents.
|
|
@@ -1037,6 +953,33 @@ const asyncapiConfigSchema = z
|
|
|
1037
953
|
.strict();
|
|
1038
954
|
|
|
1039
955
|
/** Full user-facing config schema. All fields optional with defaults. */
|
|
956
|
+
/**
|
|
957
|
+
* Table-of-contents config. `true`/`false` toggles it; an object narrows the
|
|
958
|
+
* heading range. Normalized to `{ enabled, minLevel, maxLevel }` (default: on,
|
|
959
|
+
* H2–H3, matching the historical hardcoded range).
|
|
960
|
+
*/
|
|
961
|
+
const tocConfigSchema = z
|
|
962
|
+
.union([
|
|
963
|
+
z.boolean(),
|
|
964
|
+
z
|
|
965
|
+
.object({
|
|
966
|
+
maxHeadingLevel: z.number().int().min(1).max(6).optional(),
|
|
967
|
+
minHeadingLevel: z.number().int().min(1).max(6).optional(),
|
|
968
|
+
})
|
|
969
|
+
.strict(),
|
|
970
|
+
])
|
|
971
|
+
.default(true)
|
|
972
|
+
.transform((value) => {
|
|
973
|
+
if (typeof value === "boolean") {
|
|
974
|
+
return { enabled: value, maxLevel: 3, minLevel: 2 };
|
|
975
|
+
}
|
|
976
|
+
return {
|
|
977
|
+
enabled: true,
|
|
978
|
+
maxLevel: value.maxHeadingLevel ?? 3,
|
|
979
|
+
minLevel: value.minHeadingLevel ?? 2,
|
|
980
|
+
};
|
|
981
|
+
});
|
|
982
|
+
|
|
1040
983
|
export const blumeConfigSchema = z
|
|
1041
984
|
.object({
|
|
1042
985
|
ai: aiConfigSchema.default({}),
|
|
@@ -1044,7 +987,6 @@ export const blumeConfigSchema = z
|
|
|
1044
987
|
asyncapi: asyncapiConfigSchema.default({}),
|
|
1045
988
|
banner: bannerConfigSchema.optional(),
|
|
1046
989
|
content: contentConfigSchema.default({}),
|
|
1047
|
-
contextual: contextualConfigSchema.default({}),
|
|
1048
990
|
deployment: deploymentConfigSchema.default({}),
|
|
1049
991
|
description: z.string().optional(),
|
|
1050
992
|
/**
|
|
@@ -1064,23 +1006,20 @@ export const blumeConfigSchema = z
|
|
|
1064
1006
|
export: exportConfigSchema.default(false),
|
|
1065
1007
|
favicon: faviconConfigSchema.optional(),
|
|
1066
1008
|
feedback: z.boolean().default(true),
|
|
1067
|
-
footer: footerConfigSchema.default({}),
|
|
1068
1009
|
github: githubConfigSchema.optional(),
|
|
1069
1010
|
i18n: i18nConfigSchema.optional(),
|
|
1070
|
-
icons: iconsConfigSchema.default({}),
|
|
1071
1011
|
lastModified: lastModifiedConfigSchema.default(false),
|
|
1072
1012
|
logo: logoConfigSchema.optional(),
|
|
1073
1013
|
markdown: markdownConfigSchema.default({}),
|
|
1074
1014
|
mcp: mcpConfigSchema.default({}),
|
|
1075
|
-
navbar: navbarConfigSchema.default({}),
|
|
1076
1015
|
navigation: navigationConfigSchema.default({}),
|
|
1077
1016
|
openapi: openapiConfigSchema.default({}),
|
|
1078
1017
|
redirects: z.array(redirectSchema).default([]),
|
|
1079
1018
|
search: searchConfigSchema.default({}),
|
|
1080
1019
|
seo: seoConfigSchema.default({}),
|
|
1081
|
-
styling: stylingConfigSchema.default({}),
|
|
1082
1020
|
theme: themeConfigSchema.default({}),
|
|
1083
1021
|
title: z.string().default("Documentation"),
|
|
1022
|
+
toc: tocConfigSchema,
|
|
1084
1023
|
variables: variablesConfigSchema,
|
|
1085
1024
|
})
|
|
1086
1025
|
.strict();
|
|
@@ -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> => {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
|
|
1
3
|
import GithubSlugger from "github-slugger";
|
|
2
4
|
import { extname } from "pathe";
|
|
3
5
|
|
|
@@ -135,8 +137,13 @@ export const extractLinks = (body: string): PageLink[] => {
|
|
|
135
137
|
if (target === undefined || match.index === undefined) {
|
|
136
138
|
continue;
|
|
137
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;
|
|
138
145
|
links.push({
|
|
139
|
-
column:
|
|
146
|
+
column: targetOffset + 1,
|
|
140
147
|
line: lineNumber,
|
|
141
148
|
target,
|
|
142
149
|
});
|
|
@@ -146,6 +153,44 @@ export const extractLinks = (body: string): PageLink[] => {
|
|
|
146
153
|
return links;
|
|
147
154
|
};
|
|
148
155
|
|
|
156
|
+
const INLINE_CODE = /`[^`]*`/gu;
|
|
157
|
+
// Double-quoted strings hold JSX attribute values and JSON in `{...}` props; a
|
|
158
|
+
// `<Tag>` written inside prose there (e.g. an "Astro <Font> integration" note)
|
|
159
|
+
// isn't a real usage. Single quotes are left alone so prose apostrophes don't
|
|
160
|
+
// swallow a real tag between two words.
|
|
161
|
+
const DOUBLE_QUOTED = /"[^"]*"/gu;
|
|
162
|
+
const JSX_OPEN = /<(?<tag>[A-Z][A-Za-z0-9]*)/gu;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Capitalized JSX component tags used in an `.mdx` body (`<Callout>`,
|
|
166
|
+
* `<Tree.File>` → `Tree`). Skips fenced code, inline code, and double-quoted
|
|
167
|
+
* strings so code samples and prose don't count. Powers the missing-component
|
|
168
|
+
* diagnostic.
|
|
169
|
+
*/
|
|
170
|
+
export const extractComponentTags = (body: string): string[] => {
|
|
171
|
+
const tags = new Set<string>();
|
|
172
|
+
let inFence = false;
|
|
173
|
+
for (const line of body.split("\n")) {
|
|
174
|
+
if (CODE_FENCE.test(line.trimStart())) {
|
|
175
|
+
inFence = !inFence;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (inFence) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const clean = line
|
|
182
|
+
.replaceAll(INLINE_CODE, "")
|
|
183
|
+
.replaceAll(DOUBLE_QUOTED, "");
|
|
184
|
+
for (const match of clean.matchAll(JSX_OPEN)) {
|
|
185
|
+
const tag = match.groups?.tag;
|
|
186
|
+
if (tag) {
|
|
187
|
+
tags.add(tag);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return [...tags];
|
|
192
|
+
};
|
|
193
|
+
|
|
149
194
|
const deriveTitle = (
|
|
150
195
|
meta: PageMeta,
|
|
151
196
|
headings: Heading[],
|
|
@@ -179,10 +224,19 @@ export const normalizeEntry = (
|
|
|
179
224
|
|
|
180
225
|
const result = pageMetaSchema.safeParse(entry.data);
|
|
181
226
|
if (!result.success) {
|
|
227
|
+
// Source text lets the error carry a line/column into the frontmatter block:
|
|
228
|
+
// `entry.raw` for non-filesystem sources, else the file itself (read only on
|
|
229
|
+
// this rare error path, so filesystem entries stay cheap in the happy path).
|
|
230
|
+
const source =
|
|
231
|
+
entry.raw ??
|
|
232
|
+
(entry.sourcePath && existsSync(entry.sourcePath)
|
|
233
|
+
? readFileSync(entry.sourcePath, "utf-8")
|
|
234
|
+
: undefined);
|
|
182
235
|
return {
|
|
183
236
|
diagnostics: diagnosticsFromZod(result.error, {
|
|
184
237
|
code: "BLUME_FRONTMATTER_INVALID",
|
|
185
238
|
file: entry.sourcePath ?? `${ctx.source.name}:${entry.ref}`,
|
|
239
|
+
source,
|
|
186
240
|
}),
|
|
187
241
|
pages: [],
|
|
188
242
|
};
|
|
@@ -212,6 +266,8 @@ export const normalizeEntry = (
|
|
|
212
266
|
const base = {
|
|
213
267
|
body: staged ? { format, text: entry.raw ?? entry.body.text } : undefined,
|
|
214
268
|
collection: staged ? "staged" : undefined,
|
|
269
|
+
componentsUsed:
|
|
270
|
+
format === "mdx" ? extractComponentTags(entry.body.text) : undefined,
|
|
215
271
|
contentType: meta.type ?? ctx.defaultType,
|
|
216
272
|
description: meta.description,
|
|
217
273
|
editUrl: entry.editUrl,
|
|
@@ -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))
|
|
@@ -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
|
@@ -113,6 +113,8 @@ export interface PageRecord {
|
|
|
113
113
|
format: "md" | "mdx";
|
|
114
114
|
/** Internal/asset links discovered in the page (for validation). */
|
|
115
115
|
links: PageLink[];
|
|
116
|
+
/** Capitalized JSX component tags used in the body (`.mdx` only). */
|
|
117
|
+
componentsUsed?: string[];
|
|
116
118
|
/** Resolved "last updated" ISO date, when the feature is enabled. */
|
|
117
119
|
lastModified?: string;
|
|
118
120
|
}
|
|
@@ -181,8 +183,6 @@ export interface NavSidebarVariant {
|
|
|
181
183
|
export interface NavChromeVariant {
|
|
182
184
|
path: string;
|
|
183
185
|
banner?: ResolvedConfig["banner"];
|
|
184
|
-
footer?: ResolvedConfig["footer"];
|
|
185
|
-
navbar?: ResolvedConfig["navbar"];
|
|
186
186
|
}
|
|
187
187
|
|
|
188
188
|
/** The complete navigation model derived from the content graph. */
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ResolvedConfig } from "../core/schema.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Platform redirect files for a static build. Astro already emits redirect HTML
|
|
5
|
+
* (meta-refresh) pages for `deployment.output: "static"`, but that's a soft
|
|
6
|
+
* client redirect. These give the host a real HTTP 3xx: Netlify/Cloudflare read
|
|
7
|
+
* `_redirects`, Vercel reads `vercel.json`, and `blume-redirects.json` is a
|
|
8
|
+
* structured manifest for anything else (Apache/nginx rules, an edge worker).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
type Redirect = ResolvedConfig["redirects"][number];
|
|
12
|
+
|
|
13
|
+
/** `_redirects` text (Netlify + Cloudflare Pages): `from to status` per line. */
|
|
14
|
+
export const buildNetlifyRedirects = (redirects: Redirect[]): string =>
|
|
15
|
+
`${redirects
|
|
16
|
+
.map((redirect) => `${redirect.from} ${redirect.to} ${redirect.status}`)
|
|
17
|
+
.join("\n")}\n`;
|
|
18
|
+
|
|
19
|
+
/** `vercel.json` contents with a `redirects` array (permanent = 301/308). */
|
|
20
|
+
export const buildVercelConfig = (redirects: Redirect[]): string =>
|
|
21
|
+
`${JSON.stringify(
|
|
22
|
+
{
|
|
23
|
+
redirects: redirects.map((redirect) => ({
|
|
24
|
+
destination: redirect.to,
|
|
25
|
+
permanent: redirect.status === 301 || redirect.status === 308,
|
|
26
|
+
source: redirect.from,
|
|
27
|
+
})),
|
|
28
|
+
},
|
|
29
|
+
null,
|
|
30
|
+
2
|
|
31
|
+
)}\n`;
|
|
32
|
+
|
|
33
|
+
/** Structured manifest for hosts that need manual wiring. */
|
|
34
|
+
export const buildRedirectManifest = (redirects: Redirect[]): string =>
|
|
35
|
+
`${JSON.stringify(
|
|
36
|
+
redirects.map((redirect) => ({
|
|
37
|
+
from: redirect.from,
|
|
38
|
+
status: redirect.status,
|
|
39
|
+
to: redirect.to,
|
|
40
|
+
})),
|
|
41
|
+
null,
|
|
42
|
+
2
|
|
43
|
+
)}\n`;
|
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`.
|