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.
Files changed (128) hide show
  1. package/dist/cli/index.js +1631 -940
  2. package/dist/cli/index.js.map +62 -50
  3. package/dist/types/core/data.d.ts +2 -0
  4. package/dist/types/core/project.d.ts +12 -2
  5. package/dist/types/core/schema.d.ts +442 -292
  6. package/dist/types/core/types.d.ts +7 -0
  7. package/dist/types/migrate/mintlify/assets.d.ts +8 -0
  8. package/docs/01-quickstart.mdx +5 -16
  9. package/docs/02-deployment.mdx +21 -54
  10. package/docs/advanced/api-reference.mdx +34 -51
  11. package/docs/advanced/blog.mdx +9 -25
  12. package/docs/advanced/bridge.mdx +74 -0
  13. package/docs/advanced/changelog.mdx +10 -33
  14. package/docs/advanced/custom-pages.mdx +21 -78
  15. package/docs/advanced/meta.ts +8 -1
  16. package/docs/advanced/migrate.mdx +119 -0
  17. package/docs/configuration/ai.mdx +42 -103
  18. package/docs/configuration/analytics.mdx +20 -38
  19. package/docs/configuration/customization.mdx +40 -73
  20. package/docs/configuration/export.mdx +9 -34
  21. package/docs/configuration/index.mdx +67 -87
  22. package/docs/configuration/search.mdx +17 -54
  23. package/docs/configuration/seo.mdx +17 -48
  24. package/docs/configuration/theming.mdx +20 -42
  25. package/docs/content/components.mdx +95 -101
  26. package/docs/content/i18n.mdx +21 -72
  27. package/docs/content/index.mdx +18 -48
  28. package/docs/content/islands.mdx +25 -52
  29. package/docs/content/meta.mdx +23 -50
  30. package/docs/content/navigation.mdx +23 -62
  31. package/docs/content/sources.mdx +20 -83
  32. package/docs/content/syntax.mdx +37 -105
  33. package/docs/index.mdx +12 -41
  34. package/docs/reference/cli.mdx +47 -30
  35. package/docs/reference/frontmatter.mdx +7 -5
  36. package/package.json +11 -1
  37. package/src/astro/generate.ts +18 -8
  38. package/src/astro/integration.ts +26 -3
  39. package/src/astro/islands.ts +6 -2
  40. package/src/astro/markdown-negotiation.ts +17 -3
  41. package/src/astro/pages.ts +6 -1
  42. package/src/astro/static-assets.ts +117 -0
  43. package/src/astro/templates.ts +76 -30
  44. package/src/cli/args.ts +23 -0
  45. package/src/cli/commands/build.ts +129 -62
  46. package/src/cli/commands/check.ts +20 -0
  47. package/src/cli/commands/dev.ts +11 -2
  48. package/src/cli/commands/doctor.ts +10 -1
  49. package/src/cli/commands/eject.ts +3 -1
  50. package/src/cli/commands/init.ts +21 -1
  51. package/src/cli/commands/preview.ts +2 -1
  52. package/src/cli/commands/validate.ts +12 -1
  53. package/src/cli/dev-lock.ts +92 -0
  54. package/src/cli/log.ts +11 -0
  55. package/src/cli/prepare.ts +3 -0
  56. package/src/components/BlumePage.astro +8 -0
  57. package/src/components/Icon.astro +13 -10
  58. package/src/components/content/ApiField.astro +75 -0
  59. package/src/components/content/ParamField.astro +39 -0
  60. package/src/components/content/RequestField.astro +23 -0
  61. package/src/components/content/ResponseField.astro +23 -0
  62. package/src/components/content/Step.astro +1 -1
  63. package/src/components/content/YouTube.astro +35 -0
  64. package/src/components/content/youtube.ts +46 -0
  65. package/src/components/islands/ask-ai.tsx +14 -14
  66. package/src/components/layout/Breadcrumbs.astro +7 -2
  67. package/src/components/layout/NavTree.astro +24 -8
  68. package/src/components/layout/RootLayout.astro +56 -34
  69. package/src/components/layout/Search.astro +1 -1
  70. package/src/components/openapi/ApiOverview.astro +84 -0
  71. package/src/components/openapi/MethodBadge.astro +28 -0
  72. package/src/components/openapi/Operation.astro +140 -0
  73. package/src/components/openapi/ParametersTable.astro +97 -0
  74. package/src/components/openapi/RequestBody.astro +58 -0
  75. package/src/components/openapi/RequestPanel.astro +169 -0
  76. package/src/components/openapi/Responses.astro +91 -0
  77. package/src/components/openapi/SchemaProperty.astro +118 -0
  78. package/src/components/openapi/SchemaTable.astro +86 -0
  79. package/src/components/openapi/helpers.ts +238 -0
  80. package/src/components/openapi/panel.ts +59 -0
  81. package/src/components/openapi/snippets.ts +201 -0
  82. package/src/components/props.ts +3 -0
  83. package/src/core/assets.ts +31 -0
  84. package/src/core/bridge.ts +10 -0
  85. package/src/core/builtin-tags.ts +6 -0
  86. package/src/core/data.ts +2 -0
  87. package/src/core/diagnostics.ts +6 -1
  88. package/src/core/gitignore.ts +30 -0
  89. package/src/core/links.ts +60 -19
  90. package/src/core/project-graph.ts +5 -1
  91. package/src/core/project.ts +25 -3
  92. package/src/core/schema.ts +54 -6
  93. package/src/core/sources/mdx-remote.ts +54 -8
  94. package/src/core/sources/mintlify.ts +1 -1
  95. package/src/core/sources/normalize.ts +6 -1
  96. package/src/core/sources/notion.ts +49 -5
  97. package/src/core/sources/resolve.ts +28 -6
  98. package/src/core/sources/sanity.ts +5 -1
  99. package/src/core/types.ts +7 -0
  100. package/src/deploy/rss.ts +1 -8
  101. package/src/deploy/sitemap.ts +20 -1
  102. package/src/deploy/xml.ts +8 -0
  103. package/src/markdown/directives.ts +15 -7
  104. package/src/markdown/package-commands.ts +26 -4
  105. package/src/migrate/fumadocs/content.ts +14 -1
  106. package/src/migrate/fumadocs/groups.ts +7 -0
  107. package/src/migrate/fumadocs/index.ts +5 -2
  108. package/src/migrate/mintlify/assets.ts +46 -0
  109. package/src/migrate/mintlify/config.ts +153 -1
  110. package/src/migrate/mintlify/content.ts +8 -2
  111. package/src/migrate/mintlify/index.ts +111 -46
  112. package/src/migrate/shared.ts +12 -27
  113. package/src/og/card.ts +14 -2
  114. package/src/openapi/model.ts +174 -0
  115. package/src/openapi/parse.ts +48 -0
  116. package/src/openapi/references.ts +164 -0
  117. package/src/openapi/render-mdx.ts +76 -0
  118. package/src/openapi/scalar.ts +15 -103
  119. package/src/openapi/source.ts +140 -0
  120. package/src/registry/eject.ts +28 -5
  121. package/src/registry/registry.ts +6 -0
  122. package/src/registry/rewrite-imports.ts +31 -19
  123. package/src/search/documents.ts +23 -5
  124. package/src/search/sync/algolia.ts +5 -1
  125. package/src/search/sync/typesense.ts +24 -16
  126. package/src/theme/chrome-icons.ts +22 -0
  127. package/src/theme/icons.ts +151 -161
  128. package/src/theme/palette.ts +26 -7
@@ -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 (Scalar). `spec` is a local path
900
- * or an `http(s)` URL; Scalar auto-detects OpenAPI vs AsyncAPI documents.
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, delegated wholesale to Scalar (`@scalar/astro`). The
917
- * reference is a self-contained embed on its own route it does not weave into
918
- * Blume's sidebar, search, or llms. Set `enabled: true` to opt in.
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; defaults to a Blume-derived accent override. */
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 { 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> => {
@@ -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(", ")}. Use the OpenAPI reference for API parameters.`,
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: line.indexOf(target, match.index) + 1,
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
- 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))
@@ -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
- * Build the ordered list of content sources for a project. With no
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
- 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
@@ -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("&", "&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`.
@@ -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 = "add" | "create" | "exec" | "install" | "remove" | "run";
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 "remove": {
138
+ case "ci": {
139
+ // `npm ci` maps to a frozen, lockfile-faithful install elsewhere.
129
140
  return manager === "npm"
130
- ? `npm uninstall ${args}`
131
- : `${manager} remove ${args}`;
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 { findOpenTagEnd, renameTag, rewriteCallouts } from "../shared.ts";
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, { filePath: abs });
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);