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.
Files changed (119) hide show
  1. package/dist/cli/index.js +2429 -792
  2. package/dist/cli/index.js.map +63 -44
  3. package/dist/types/core/data.d.ts +16 -0
  4. package/dist/types/core/define-components.d.ts +9 -2
  5. package/dist/types/core/diagnostics.d.ts +5 -0
  6. package/dist/types/core/schema.d.ts +313 -778
  7. package/dist/types/core/types.d.ts +2 -2
  8. package/dist/types/migrate/mintlify/assets.d.ts +8 -0
  9. package/docs/01-quickstart.mdx +5 -16
  10. package/docs/02-deployment.mdx +26 -40
  11. package/docs/advanced/api-reference.mdx +10 -37
  12. package/docs/advanced/blog.mdx +9 -25
  13. package/docs/advanced/changelog.mdx +10 -33
  14. package/docs/advanced/custom-pages.mdx +66 -61
  15. package/docs/configuration/ai.mdx +47 -91
  16. package/docs/configuration/analytics.mdx +20 -38
  17. package/docs/configuration/customization.mdx +92 -27
  18. package/docs/configuration/export.mdx +9 -34
  19. package/docs/configuration/index.mdx +78 -85
  20. package/docs/configuration/search.mdx +17 -54
  21. package/docs/configuration/seo.mdx +18 -44
  22. package/docs/configuration/theming.mdx +20 -42
  23. package/docs/content/components.mdx +42 -101
  24. package/docs/content/i18n.mdx +21 -72
  25. package/docs/content/index.mdx +18 -48
  26. package/docs/content/islands.mdx +79 -33
  27. package/docs/content/meta.mdx +23 -50
  28. package/docs/content/navigation.mdx +42 -56
  29. package/docs/content/sources.mdx +20 -83
  30. package/docs/content/syntax.mdx +37 -105
  31. package/docs/index.mdx +13 -51
  32. package/docs/reference/cli.mdx +49 -18
  33. package/docs/reference/frontmatter.mdx +2 -5
  34. package/package.json +3 -1
  35. package/src/ai/ask-context.ts +131 -0
  36. package/src/ai/ask-data.ts +25 -0
  37. package/src/astro/component-slots.ts +165 -0
  38. package/src/astro/generate.ts +132 -13
  39. package/src/astro/integration.ts +85 -3
  40. package/src/astro/islands.ts +6 -2
  41. package/src/astro/markdown-negotiation.ts +17 -3
  42. package/src/astro/pages.ts +11 -13
  43. package/src/astro/static-assets.ts +117 -0
  44. package/src/astro/templates.ts +120 -50
  45. package/src/blume-modules.d.ts +25 -0
  46. package/src/cli/args.ts +23 -0
  47. package/src/cli/commands/build.ts +209 -1
  48. package/src/cli/commands/check.ts +62 -0
  49. package/src/cli/commands/dev.ts +32 -3
  50. package/src/cli/commands/doctor.ts +32 -6
  51. package/src/cli/commands/eject.ts +3 -1
  52. package/src/cli/commands/init.ts +184 -16
  53. package/src/cli/commands/preview.ts +2 -1
  54. package/src/cli/commands/validate.ts +27 -2
  55. package/src/cli/dev-lock.ts +84 -0
  56. package/src/cli/index.ts +15 -0
  57. package/src/cli/internal-error.ts +63 -0
  58. package/src/cli/log.ts +41 -1
  59. package/src/cli/prepare.ts +17 -3
  60. package/src/cli/required-secrets.ts +44 -0
  61. package/src/components/BlumePage.astro +109 -0
  62. package/src/components/content/YouTube.astro +35 -0
  63. package/src/components/content/youtube.ts +46 -0
  64. package/src/components/index.ts +3 -3
  65. package/src/components/islands/ask-ai.tsx +29 -15
  66. package/src/components/islands/hooks.ts +188 -0
  67. package/src/components/layout/Empty.astro +6 -0
  68. package/src/components/layout/Header.astro +24 -39
  69. package/src/components/layout/Logo.astro +50 -0
  70. package/src/components/layout/NavSelector.astro +75 -0
  71. package/src/components/layout/PageLayout.astro +38 -2
  72. package/src/components/layout/RootLayout.astro +70 -4
  73. package/src/components/layout/hydration-hint.ts +30 -0
  74. package/src/components/layout/overrides.ts +6 -4
  75. package/src/components/props.ts +71 -0
  76. package/src/core/assets.ts +31 -0
  77. package/src/core/bridge.ts +10 -0
  78. package/src/core/builtin-tags.ts +40 -0
  79. package/src/core/component-diagnostics.ts +44 -0
  80. package/src/core/component-overrides.ts +478 -0
  81. package/src/core/config.ts +8 -0
  82. package/src/core/data.ts +14 -0
  83. package/src/core/define-components.ts +9 -2
  84. package/src/core/diagnostics.ts +95 -1
  85. package/src/core/gitignore.ts +30 -0
  86. package/src/core/graph.ts +7 -0
  87. package/src/core/links.ts +60 -19
  88. package/src/core/nav-diagnostics.ts +205 -0
  89. package/src/core/project-graph.ts +40 -1
  90. package/src/core/schema.ts +35 -96
  91. package/src/core/sources/mdx-remote.ts +54 -8
  92. package/src/core/sources/normalize.ts +57 -1
  93. package/src/core/sources/notion.ts +49 -5
  94. package/src/core/sources/sanity.ts +5 -1
  95. package/src/core/types.ts +2 -2
  96. package/src/deploy/redirects.ts +43 -0
  97. package/src/deploy/rss.ts +1 -8
  98. package/src/deploy/sitemap.ts +20 -1
  99. package/src/deploy/xml.ts +8 -0
  100. package/src/markdown/directives.ts +15 -7
  101. package/src/markdown/package-commands.ts +26 -4
  102. package/src/migrate/fumadocs/content.ts +14 -1
  103. package/src/migrate/fumadocs/groups.ts +7 -0
  104. package/src/migrate/fumadocs/index.ts +5 -2
  105. package/src/migrate/mintlify/assets.ts +46 -0
  106. package/src/migrate/mintlify/config.ts +1 -176
  107. package/src/migrate/mintlify/index.ts +53 -45
  108. package/src/migrate/shared.ts +12 -27
  109. package/src/migrate/starlight/config.ts +0 -4
  110. package/src/og/card.ts +175 -38
  111. package/src/registry/eject.ts +52 -12
  112. package/src/registry/registry.ts +172 -0
  113. package/src/registry/rewrite-imports.ts +31 -19
  114. package/src/runtime/index.ts +61 -0
  115. package/src/search/documents.ts +23 -5
  116. package/src/search/sync/algolia.ts +5 -1
  117. package/src/search/sync/typesense.ts +24 -16
  118. package/src/theme/palette.ts +26 -7
  119. package/src/vite-env.d.ts +14 -0
@@ -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 { tree?: GithubTreeEntry[] };
117
+ const body = (await res.json()) as {
118
+ tree?: GithubTreeEntry[];
119
+ truncated?: boolean;
120
+ };
117
121
  const prefix = base ? `${base}/` : "";
118
- return (body.tree ?? [])
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<RemoteRef[]> => {
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
- return options.files
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
- return await Promise.all(refs.map(fetchEntry));
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 result;
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: line.indexOf(target, match.index) + 1,
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
- client.blocks.children.list({ block_id: blockId, start_cursor: cursor })
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
- client.databases.query({
400
- database_id: options.database,
401
- start_cursor: cursor,
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
- const slug = slugify(slugValue) || "untitled";
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("&", "&amp;")
91
- .replaceAll("<", "&lt;")
92
- .replaceAll(">", "&gt;")
93
- .replaceAll('"', "&quot;")
94
- .replaceAll("'", "&apos;");
95
-
96
89
  const renderItem = (item: RssItem): string => {
97
90
  const parts = [
98
91
  ` <title>${escapeXml(item.title)}</title>`,
@@ -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
- .map((page) => ` <url><loc>${base}${page.route}</loc></url>`)
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("&", "&amp;")
5
+ .replaceAll("<", "&lt;")
6
+ .replaceAll(">", "&gt;")
7
+ .replaceAll('"', "&quot;")
8
+ .replaceAll("'", "&apos;");
@@ -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: MdastNode[];
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
- /** Concatenate the plain text of a node's immediate phrasing children. */
42
- const textOf = (node: MdastNode): string =>
43
- ((node.children as TextNode[] | undefined) ?? [])
44
- .map((child) => child.value ?? "")
45
- .join("");
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`.