blume 1.4.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/cli/index.js +2260 -1100
  3. package/dist/cli/index.js.map +123 -117
  4. package/dist/types/ai/component-markdown.d.ts +14 -4
  5. package/dist/types/core/base-path.d.ts +8 -0
  6. package/dist/types/core/config-input.d.ts +87 -27
  7. package/dist/types/core/config.d.ts +2 -1
  8. package/dist/types/core/data.d.ts +16 -1
  9. package/dist/types/core/diagnostics.d.ts +5 -1
  10. package/dist/types/core/i18n-ui.d.ts +12 -0
  11. package/dist/types/core/schema.d.ts +116 -15
  12. package/dist/types/core/sources/types.d.ts +11 -1
  13. package/dist/types/core/standard-schema.d.ts +7 -3
  14. package/dist/types/core/types.d.ts +43 -2
  15. package/dist/types/core/ui-packs/index.d.ts +9 -1
  16. package/dist/types/openapi/references.d.ts +14 -7
  17. package/dist/types/seo/x-handle.d.ts +3 -2
  18. package/docs/advanced/api-reference.mdx +8 -6
  19. package/docs/configuration/ai.mdx +26 -8
  20. package/docs/configuration/search.mdx +2 -0
  21. package/docs/configuration/seo.mdx +1 -1
  22. package/docs/content/i18n.mdx +1 -1
  23. package/docs/content/meta.mdx +2 -1
  24. package/docs/content/meta.ts +1 -0
  25. package/docs/content/navigation.mdx +35 -1
  26. package/docs/content/sources.mdx +1 -1
  27. package/docs/content/versioning.mdx +106 -0
  28. package/docs/reference/cli.mdx +1 -0
  29. package/docs/reference/frontmatter.mdx +3 -0
  30. package/package.json +13 -1
  31. package/skills/blume-migrate/SKILL.md +2 -2
  32. package/skills/blume-migrate/references/docusaurus.md +1 -1
  33. package/skills/blume-migrate/references/fumadocs.md +1 -1
  34. package/skills/blume-migrate/references/mintlify.md +1 -1
  35. package/src/ai/agent-readability.ts +40 -12
  36. package/src/ai/api-catalog.ts +2 -2
  37. package/src/ai/ask-context.ts +49 -12
  38. package/src/ai/ask.ts +10 -1
  39. package/src/ai/component-markdown.ts +80 -43
  40. package/src/ai/llms.ts +42 -17
  41. package/src/ai/mcp/data.ts +48 -12
  42. package/src/ai/mcp/discovery.ts +52 -16
  43. package/src/ai/mcp/server.ts +280 -125
  44. package/src/ai/mcp/tools.ts +3 -3
  45. package/src/ai/skills.ts +32 -9
  46. package/src/ai/tar.ts +29 -70
  47. package/src/ai/visibility.ts +2 -2
  48. package/src/astro/component-slots.ts +2 -0
  49. package/src/astro/examples.ts +13 -5
  50. package/src/astro/generate.ts +113 -63
  51. package/src/astro/integration.ts +13 -2
  52. package/src/astro/islands.ts +23 -12
  53. package/src/astro/templates.ts +185 -41
  54. package/src/audit/agent.ts +16 -31
  55. package/src/audit/checks/content.ts +26 -11
  56. package/src/audit/checks/dns-aid.ts +3 -0
  57. package/src/audit/checks/indexability.ts +24 -6
  58. package/src/audit/checks/llms.ts +9 -4
  59. package/src/audit/checks/network.ts +2 -0
  60. package/src/audit/checks/social.ts +18 -10
  61. package/src/audit/crawl.ts +78 -25
  62. package/src/audit/report.ts +20 -19
  63. package/src/audit/run.ts +15 -5
  64. package/src/audit/snapshot.ts +29 -6
  65. package/src/audit/types.ts +25 -3
  66. package/src/blume-modules.d.ts +5 -1
  67. package/src/cli/commands/audit.ts +21 -21
  68. package/src/cli/commands/build.ts +30 -16
  69. package/src/cli/commands/dev.ts +15 -15
  70. package/src/cli/commands/doctor.ts +2 -0
  71. package/src/cli/commands/eject.ts +4 -4
  72. package/src/cli/commands/eval.ts +24 -30
  73. package/src/cli/commands/init.ts +9 -9
  74. package/src/cli/commands/mcp-stdio.ts +3 -0
  75. package/src/cli/commands/translate.ts +14 -3
  76. package/src/cli/commands/version.ts +85 -0
  77. package/src/cli/dev-lock.ts +31 -10
  78. package/src/cli/eject-scripts.ts +17 -2
  79. package/src/cli/env.ts +13 -30
  80. package/src/cli/index.ts +2 -0
  81. package/src/cli/init/questions.ts +1 -1
  82. package/src/cli/init/scaffold.ts +41 -13
  83. package/src/cli/internal-error.ts +1 -0
  84. package/src/cli/report-format.ts +22 -0
  85. package/src/components/content/AccordionItem.astro +2 -9
  86. package/src/components/content/ColorItem.astro +5 -13
  87. package/src/components/content/Component.astro +12 -8
  88. package/src/components/content/Frame.astro +2 -12
  89. package/src/components/content/Prompt.astro +12 -31
  90. package/src/components/content/Tab.astro +2 -9
  91. package/src/components/content/Tooltip.astro +1 -9
  92. package/src/components/content/Update.astro +2 -9
  93. package/src/components/content/auto-type-table.ts +3 -0
  94. package/src/components/content/diff.ts +9 -5
  95. package/src/components/content/github-info.ts +2 -0
  96. package/src/components/content/inline-markdown.ts +28 -0
  97. package/src/components/copy-feedback.ts +96 -0
  98. package/src/components/islands/ask-ai.tsx +111 -34
  99. package/src/components/islands/hooks.ts +5 -1
  100. package/src/components/islands/webmcp.ts +49 -12
  101. package/src/components/layout/Header.astro +25 -1
  102. package/src/components/layout/NavSelector.astro +11 -2
  103. package/src/components/layout/NavTree.astro +4 -2
  104. package/src/components/layout/PageActions.astro +20 -32
  105. package/src/components/layout/PageLayout.astro +8 -28
  106. package/src/components/layout/RootLayout.astro +24 -48
  107. package/src/components/layout/Search.astro +133 -22
  108. package/src/components/layout/VersionBanner.astro +39 -0
  109. package/src/components/layout/analytics-client.ts +8 -5
  110. package/src/components/layout/drawer-inert.ts +31 -0
  111. package/src/components/layout/hydration-hint.ts +1 -1
  112. package/src/components/layout/nav-utils.ts +1 -4
  113. package/src/components/layout/overrides.ts +25 -12
  114. package/src/components/layout/search/algolia.ts +18 -5
  115. package/src/components/layout/search/endpoint.ts +3 -0
  116. package/src/components/layout/search/flexsearch.ts +23 -7
  117. package/src/components/layout/search/orama-cloud.ts +1 -1
  118. package/src/components/layout/search/orama.ts +4 -1
  119. package/src/components/layout/search/pagefind.ts +8 -5
  120. package/src/components/layout/search/types.ts +45 -1
  121. package/src/components/layout/search/typesense.ts +19 -3
  122. package/src/components/openapi/ApiOverview.astro +32 -6
  123. package/src/components/openapi/AsyncApiOperation.astro +237 -0
  124. package/src/components/openapi/Bindings.astro +89 -0
  125. package/src/components/openapi/MethodBadge.astro +3 -0
  126. package/src/components/openapi/Operation.astro +7 -2
  127. package/src/components/openapi/PanelTabs.astro +131 -0
  128. package/src/components/openapi/ParametersTable.astro +2 -0
  129. package/src/components/openapi/RequestPanel.astro +12 -119
  130. package/src/components/openapi/async-snippets.ts +174 -0
  131. package/src/components/openapi/async.ts +348 -0
  132. package/src/components/openapi/helpers.ts +52 -20
  133. package/src/components/openapi/panel.ts +11 -8
  134. package/src/components/openapi/security.ts +102 -29
  135. package/src/components/openapi/snippets.ts +11 -11
  136. package/src/components/raf-throttle.ts +21 -0
  137. package/src/components/slug.ts +14 -0
  138. package/src/core/base-path.ts +18 -1
  139. package/src/core/component-overrides.ts +28 -23
  140. package/src/core/config-input.ts +96 -27
  141. package/src/core/config.ts +20 -7
  142. package/src/core/content.ts +3 -1
  143. package/src/core/data.ts +16 -1
  144. package/src/core/define-components.ts +5 -0
  145. package/src/core/diagnostics.ts +46 -38
  146. package/src/core/frontmatter.ts +74 -4
  147. package/src/core/graph.ts +137 -53
  148. package/src/core/i18n-ui.ts +15 -0
  149. package/src/core/i18n.ts +16 -8
  150. package/src/core/load-module.ts +1 -0
  151. package/src/core/manifest.ts +92 -3
  152. package/src/core/meta.ts +44 -14
  153. package/src/core/nav-diagnostics.ts +3 -3
  154. package/src/core/navigation.ts +247 -67
  155. package/src/core/probe.ts +7 -19
  156. package/src/core/project-graph.ts +27 -4
  157. package/src/core/schema.ts +219 -67
  158. package/src/core/site-url.ts +27 -0
  159. package/src/core/sources/assets.ts +2 -0
  160. package/src/core/sources/cache.ts +16 -8
  161. package/src/core/sources/github-releases.ts +39 -11
  162. package/src/core/sources/mdx-remote.ts +4 -0
  163. package/src/core/sources/normalize.ts +93 -22
  164. package/src/core/sources/notion.ts +76 -22
  165. package/src/core/sources/portable-text.ts +48 -12
  166. package/src/core/sources/resolve.ts +1 -0
  167. package/src/core/sources/sanity.ts +68 -14
  168. package/src/core/sources/types.ts +17 -1
  169. package/src/core/sources/watch.ts +1 -1
  170. package/src/core/standard-schema.ts +9 -3
  171. package/src/core/text-width.ts +26 -0
  172. package/src/core/tsconfig-aliases.ts +9 -5
  173. package/src/core/types.ts +45 -2
  174. package/src/core/ui-packs/index.ts +9 -1
  175. package/src/core/version-cut.ts +301 -0
  176. package/src/core/version.ts +2 -0
  177. package/src/core/versions.ts +170 -0
  178. package/src/deploy/adapter-output.ts +5 -2
  179. package/src/deploy/cloudflare-negotiation.ts +40 -11
  180. package/src/deploy/robots.ts +2 -1
  181. package/src/deploy/rss.ts +2 -1
  182. package/src/deploy/sitemap.ts +89 -8
  183. package/src/deploy/vercel-negotiation.ts +11 -4
  184. package/src/eval/agents.ts +13 -10
  185. package/src/eval/report.ts +5 -18
  186. package/src/eval/run.ts +2 -2
  187. package/src/eval/schema.ts +1 -1
  188. package/src/markdown/base-links.ts +6 -6
  189. package/src/markdown/directives.ts +7 -1
  190. package/src/markdown/heading-anchors.ts +17 -6
  191. package/src/markdown/index.ts +73 -24
  192. package/src/markdown/inline-code.ts +14 -2
  193. package/src/markdown/language-icon.ts +6 -2
  194. package/src/markdown/mdast.ts +18 -4
  195. package/src/markdown/package-commands.ts +63 -58
  196. package/src/markdown/table-wrap.ts +4 -1
  197. package/src/markdown/twoslash.ts +2 -0
  198. package/src/og/card.ts +50 -33
  199. package/src/og/derive.ts +43 -27
  200. package/src/openapi/asyncapi.ts +366 -0
  201. package/src/openapi/model.ts +135 -66
  202. package/src/openapi/parse.ts +166 -33
  203. package/src/openapi/references.ts +47 -22
  204. package/src/openapi/render-mdx.ts +137 -59
  205. package/src/openapi/scalar.ts +8 -10
  206. package/src/openapi/source.ts +126 -29
  207. package/src/registry/eject.ts +7 -2
  208. package/src/search/documents.ts +103 -39
  209. package/src/search/facets.ts +7 -5
  210. package/src/search/orama-index.ts +117 -32
  211. package/src/search/popular.ts +10 -5
  212. package/src/search/providers.ts +2 -2
  213. package/src/search/sync/index.ts +2 -0
  214. package/src/search/sync/typesense.ts +4 -2
  215. package/src/seo/jsonld.ts +24 -6
  216. package/src/seo/x-handle.ts +8 -3
  217. package/src/theme/chrome-icons.ts +7 -2
  218. package/src/theme/fonts.ts +8 -4
  219. package/src/theme/icons.ts +4 -2
  220. package/src/theme/palette.ts +27 -15
  221. package/src/translate/ledger.ts +4 -2
  222. package/src/translate/meta.ts +15 -6
  223. package/src/translate/report.ts +10 -19
  224. package/src/translate/run.ts +29 -38
  225. package/src/translate/validate.ts +52 -17
  226. package/src/translate/work-list.ts +0 -0
  227. package/src/cli/coalesce.ts +0 -43
@@ -0,0 +1,301 @@
1
+ import { existsSync } from "node:fs";
2
+ import { cp, readdir, readFile, rm } from "node:fs/promises";
3
+
4
+ import { join, relative } from "pathe";
5
+
6
+ import { stripBasePath } from "./base-path.ts";
7
+ import { writeTextAtomic } from "./fs-atomic.ts";
8
+ import { localizeRoute } from "./i18n.ts";
9
+ import type { BlumeProject } from "./project-graph.ts";
10
+ import { scanProject } from "./project-graph.ts";
11
+ import { VERSION_ID } from "./schema.ts";
12
+ import { nextFenceState } from "./sources/normalize.ts";
13
+ import type { FenceState } from "./sources/normalize.ts";
14
+ import { VERSION_LIKE, versionizeRoute } from "./versions.ts";
15
+
16
+ /** What `cutVersion` did, for the CLI to report. */
17
+ export interface CutResult {
18
+ /** Files copied into the snapshot. */
19
+ copied: number;
20
+ /** Markdown pages whose root-absolute links were rewritten, with counts. */
21
+ rewritten: { file: string; count: number }[];
22
+ /** Whether `blume.config.ts` was updated in place. */
23
+ configUpdated: boolean;
24
+ /** Ready-to-paste config snippet when in-place update was not possible. */
25
+ configSnippet: string | null;
26
+ /** Absolute path of the created snapshot directory. */
27
+ dir: string;
28
+ }
29
+
30
+ /** A `cutVersion` failure the CLI reports as a user error, not a crash. */
31
+ export class CutError extends Error {
32
+ constructor(message: string) {
33
+ super(message);
34
+ this.name = "CutError";
35
+ }
36
+ }
37
+
38
+ // Inline code spans are syntax being *shown*, not links — blank them before
39
+ // matching so replacements can splice into the real line by index (the mask
40
+ // preserves length). Mirrors the content-assets rewriter.
41
+ const INLINE_CODE = /`[^`]*`/gu;
42
+ // A markdown link/image target or an HTML href/src attribute whose value is
43
+ // root-absolute. Named groups carry the prefix (kept) and target (rewritten).
44
+ const ROOT_LINK = /(?<prefix>\]\(|href="|src=")(?<target>\/[^\s"')]*)/gu;
45
+
46
+ /**
47
+ * Build the link-rewrite table: every current-version route (basePath
48
+ * stripped, since authors write root-absolute links as if mounted at root)
49
+ * mapped to the same page's route inside the new snapshot. Only pages the
50
+ * snapshot actually contains qualify — filesystem pages under the content
51
+ * root. Spec-rendered references (`/api`, `/events`) and remote sources
52
+ * aren't copied, so links to them keep pointing at the live pages instead of
53
+ * a 404 inside the snapshot.
54
+ */
55
+ const buildRouteRewrites = (
56
+ project: BlumeProject,
57
+ id: string
58
+ ): Map<string, string> => {
59
+ const { basePath, i18n } = project.config;
60
+ const { contentRoot } = project.context;
61
+ const rewrites = new Map<string, string>();
62
+ for (const page of project.graph.pages) {
63
+ // `sourcePath` is set by the filesystem adapter only, so its absence
64
+ // already excludes generated and remote pages.
65
+ const { sourcePath } = page;
66
+ if (
67
+ page.version !== "" ||
68
+ !sourcePath ||
69
+ relative(contentRoot, sourcePath).startsWith("..")
70
+ ) {
71
+ continue;
72
+ }
73
+ const logical = versionizeRoute(page.versionKey, id);
74
+ rewrites.set(
75
+ stripBasePath(basePath, page.route),
76
+ i18n ? localizeRoute(logical, page.locale, i18n) : logical
77
+ );
78
+ }
79
+ return rewrites;
80
+ };
81
+
82
+ /** Rewrite one line's root-absolute internal links via the rewrite table. */
83
+ const rewriteLine = (line: string, rewrites: Map<string, string>): string => {
84
+ const masked = line.replaceAll(INLINE_CODE, (span) =>
85
+ " ".repeat(span.length)
86
+ );
87
+ let out = "";
88
+ let cursor = 0;
89
+ let count = 0;
90
+ for (const match of masked.matchAll(ROOT_LINK)) {
91
+ const target = match.groups?.target ?? "";
92
+ // An anchor or query stays attached to the rewritten path.
93
+ const hash = target.search(/[#?]/u);
94
+ const path = hash === -1 ? target : target.slice(0, hash);
95
+ const suffix = hash === -1 ? "" : target.slice(hash);
96
+ const bare = path !== "/" && path.endsWith("/") ? path.slice(0, -1) : path;
97
+ const replacement = rewrites.get(bare);
98
+ if (replacement === undefined) {
99
+ continue;
100
+ }
101
+ const offset = (match.index ?? 0) + (match.groups?.prefix?.length ?? 0);
102
+ out += line.slice(cursor, offset) + replacement + suffix;
103
+ cursor = offset + target.length;
104
+ count += 1;
105
+ }
106
+ return count === 0 ? line : out + line.slice(cursor);
107
+ };
108
+
109
+ /** A page's link-rewritten text and how many of its lines changed. */
110
+ export interface SnapshotRewrite {
111
+ text: string;
112
+ count: number;
113
+ }
114
+
115
+ /**
116
+ * Rewrite a copied page's root-absolute internal links to their snapshot
117
+ * equivalents, skipping fenced and inline code. Relative links need no
118
+ * rewriting — the whole tree copies together, so they stay self-contained.
119
+ */
120
+ export const rewriteSnapshotLinks = (
121
+ source: string,
122
+ rewrites: Map<string, string>
123
+ ): SnapshotRewrite => {
124
+ let fence: FenceState = null;
125
+ let count = 0;
126
+ const lines = source.split("\n").map((line) => {
127
+ const next = nextFenceState(line, fence);
128
+ const inFence = fence !== null || next !== null;
129
+ fence = next;
130
+ if (inFence) {
131
+ return line;
132
+ }
133
+ const rewrittenLine = rewriteLine(line, rewrites);
134
+ if (rewrittenLine !== line) {
135
+ count += 1;
136
+ }
137
+ return rewrittenLine;
138
+ });
139
+ return { count, text: lines.join("\n") };
140
+ };
141
+
142
+ /** The config entry to add, as a paste-ready snippet for the fallback path. */
143
+ const snippetFor = (id: string, hasVersions: boolean): string =>
144
+ hasVersions
145
+ ? `Add to versions.archived in blume.config.ts (newest first):\n\n { id: "${id}" },\n`
146
+ : `Add to blume.config.ts:\n\n versions: {\n archived: [{ id: "${id}" }],\n current: { label: "…" },\n },\n`;
147
+
148
+ /**
149
+ * Best-effort in-place config update: insert the new id at the head of an
150
+ * existing \`archived: [\` array literal, preserving indentation. Anything
151
+ * fancier (computed arrays, config spread across files) falls back to a
152
+ * printed snippet — the config is user-authored TypeScript, and string-level
153
+ * surgery beyond this simple shape risks corrupting it.
154
+ */
155
+ export const insertArchivedVersion = async (
156
+ configPath: string,
157
+ id: string
158
+ ): Promise<boolean> => {
159
+ let text: string;
160
+ try {
161
+ text = await readFile(configPath, "utf-8");
162
+ } catch {
163
+ return false;
164
+ }
165
+ const match = text.match(/(?<lead>archived:\s*\[)/u);
166
+ if (!match || match.index === undefined) {
167
+ return false;
168
+ }
169
+ const insertAt = match.index + (match.groups?.lead?.length ?? 0);
170
+ // Indentation: one level deeper than the line holding `archived:`.
171
+ const lineStart = text.lastIndexOf("\n", match.index) + 1;
172
+ const indent = text.slice(lineStart).match(/^\s*/u)?.[0] ?? "";
173
+ const rest = text.slice(insertAt);
174
+ // Match the array's authored shape: empty stays bare, an inline array gets
175
+ // an inline entry, a multiline array gets its own indented line.
176
+ let entry: string;
177
+ if (rest.trimStart().startsWith("]")) {
178
+ entry = `{ id: "${id}" }`;
179
+ } else if (rest.startsWith("\n")) {
180
+ entry = `\n${indent} { id: "${id}" },`;
181
+ } else {
182
+ entry = `{ id: "${id}" }, `;
183
+ }
184
+ await writeTextAtomic(configPath, text.slice(0, insertAt) + entry + rest);
185
+ return true;
186
+ };
187
+
188
+ /**
189
+ * Freeze the current docs as an archived version: copy the content tree into
190
+ * `<contentRoot>/<id>/` (excluding existing snapshots), rewrite root-absolute
191
+ * internal links so the copy is self-contained, and register the id in
192
+ * `blume.config.ts` (or print the snippet to paste).
193
+ */
194
+ export const cutVersion = async (
195
+ root: string,
196
+ id: string,
197
+ options: { force?: boolean } = {}
198
+ ): Promise<CutResult> => {
199
+ if (!VERSION_ID.test(id)) {
200
+ throw new CutError(
201
+ `Version ids must start with a letter (e.g. "v1.0") and contain only letters, digits, dots, hyphens, and underscores — got "${id}".`
202
+ );
203
+ }
204
+
205
+ const project = await scanProject(root);
206
+ const errors = project.diagnostics.filter(
207
+ (diagnostic) => diagnostic.severity === "error"
208
+ );
209
+ if (errors.length > 0) {
210
+ throw new CutError(
211
+ `The project has ${errors.length} error diagnostic(s) — fix them before cutting a version (run \`blume validate\`).`
212
+ );
213
+ }
214
+ // A missing content root already fails inside `scanProject` (the filesystem
215
+ // source's own validation), so only the version-level checks remain here.
216
+ const { contentRoot } = project.context;
217
+ if (project.config.versions?.archived.some((version) => version.id === id)) {
218
+ throw new CutError(
219
+ `Version "${id}" is already registered in versions.archived.`
220
+ );
221
+ }
222
+
223
+ const dir = join(contentRoot, id);
224
+ if (existsSync(dir)) {
225
+ if (!options.force) {
226
+ throw new CutError(
227
+ `${dir} already exists — pass --force to overwrite the snapshot.`
228
+ );
229
+ }
230
+ await rm(dir, { force: true, recursive: true });
231
+ }
232
+
233
+ // Existing snapshots must not nest inside the new one: configured ids, plus
234
+ // any version-shaped directory (`v1.0/`) that never made it into the config —
235
+ // e.g. a prior cut whose config update fell back to a printed snippet. A
236
+ // genuine content folder that merely looks like a version already draws the
237
+ // rename-or-register diagnostic, so skipping it here is consistent.
238
+ const excluded = new Set([
239
+ id,
240
+ "node_modules",
241
+ ...(project.config.versions?.archived.map((version) => version.id) ?? []),
242
+ ]);
243
+ const entries = await readdir(contentRoot, { withFileTypes: true });
244
+ let copied = 0;
245
+ await Promise.all(
246
+ entries.map(async (entry) => {
247
+ if (
248
+ excluded.has(entry.name) ||
249
+ entry.name.startsWith(".") ||
250
+ (entry.isDirectory() && VERSION_LIKE.test(entry.name))
251
+ ) {
252
+ return;
253
+ }
254
+ await cp(join(contentRoot, entry.name), join(dir, entry.name), {
255
+ recursive: true,
256
+ });
257
+ })
258
+ );
259
+
260
+ // Rewrite root-absolute internal links in every copied markdown page.
261
+ const rewrites = buildRouteRewrites(project, id);
262
+ const rewritten: { file: string; count: number }[] = [];
263
+ const walk = async (current: string): Promise<void> => {
264
+ const children = await readdir(current, { withFileTypes: true });
265
+ await Promise.all(
266
+ children.map(async (child) => {
267
+ const abs = join(current, child.name);
268
+ if (child.isDirectory()) {
269
+ await walk(abs);
270
+ return;
271
+ }
272
+ copied += 1;
273
+ if (!/\.mdx?$/u.test(child.name)) {
274
+ return;
275
+ }
276
+ const source = await readFile(abs, "utf-8");
277
+ const { text, count } = rewriteSnapshotLinks(source, rewrites);
278
+ if (count > 0) {
279
+ await writeTextAtomic(abs, text);
280
+ rewritten.push({ count, file: relative(dir, abs) });
281
+ }
282
+ })
283
+ );
284
+ };
285
+ await walk(dir);
286
+
287
+ const configPath = join(root, "blume.config.ts");
288
+ const configUpdated =
289
+ project.config.versions !== undefined &&
290
+ (await insertArchivedVersion(configPath, id));
291
+
292
+ return {
293
+ configSnippet: configUpdated
294
+ ? null
295
+ : snippetFor(id, project.config.versions !== undefined),
296
+ configUpdated,
297
+ copied,
298
+ dir,
299
+ rewritten,
300
+ };
301
+ };
@@ -16,6 +16,8 @@ let cached: string | undefined;
16
16
  export const getBlumeVersion = (): string => {
17
17
  if (cached === undefined) {
18
18
  const pkgPath = join(packageRoot(), "package.json");
19
+ // SAFETY: this reads blume's own package.json, which always declares a
20
+ // `version` (publishing requires it).
19
21
  cached = (JSON.parse(readFileSync(pkgPath, "utf-8")) as { version: string })
20
22
  .version;
21
23
  }
@@ -0,0 +1,170 @@
1
+ import { localizeRoute } from "./i18n.ts";
2
+ import type {
3
+ ArchivedVersionConfig,
4
+ ResolvedConfig,
5
+ ResolvedI18nConfig,
6
+ ResolvedVersionsConfig,
7
+ } from "./schema.ts";
8
+ import type { Diagnostic, PageRecord } from "./types.ts";
9
+
10
+ /**
11
+ * Version logic, centralized. Every seam that needs to reason about docs
12
+ * versions (content discovery, navigation, manifest, runtime generation, the
13
+ * catch-all) goes through these helpers so the routing rules live in exactly
14
+ * one place — the same contract `./i18n.ts` holds for locales.
15
+ *
16
+ * The current version is the empty string `""`: the latest docs live at the
17
+ * content root with unprefixed URLs, and only archived snapshots have a
18
+ * directory and a URL segment.
19
+ */
20
+
21
+ /**
22
+ * Top-level folders that look like a version (`v1`, `V2.0`) — used by the
23
+ * unconfigured-snapshot diagnostic and by `blume version` to keep such folders
24
+ * out of new snapshots.
25
+ */
26
+ export const VERSION_LIKE = /^v\d/iu;
27
+
28
+ /** True when the project opts into versioning. */
29
+ export const versionsEnabled = (
30
+ config: ResolvedConfig
31
+ ): config is ResolvedConfig & { versions: ResolvedVersionsConfig } =>
32
+ config.versions !== undefined;
33
+
34
+ /** All archived version ids, in configured (switcher) order. */
35
+ export const archivedIds = (versions: ResolvedVersionsConfig): string[] =>
36
+ versions.archived.map((version) => version.id);
37
+
38
+ /** Switcher label for a version: the current label for `""`, else per config. */
39
+ export const versionLabel = (
40
+ id: string,
41
+ versions: ResolvedVersionsConfig
42
+ ): string => {
43
+ if (id === "") {
44
+ return versions.current.label;
45
+ }
46
+ const archived = versions.archived.find((version) => version.id === id);
47
+ return archived?.label ?? id;
48
+ };
49
+
50
+ /** The archived version config for an id (`undefined` for current/unknown). */
51
+ export const archivedVersion = (
52
+ id: string,
53
+ versions: ResolvedVersionsConfig
54
+ ): ArchivedVersionConfig | undefined =>
55
+ versions.archived.find((version) => version.id === id);
56
+
57
+ /** A resolved version (`""` for current) and the version-stripped remainder. */
58
+ export interface DetectedVersion {
59
+ version: string;
60
+ rest: string[];
61
+ }
62
+
63
+ /** {@link DetectedVersion} for a slash-joined source ref. */
64
+ export interface DetectedVersionRef {
65
+ version: string;
66
+ rest: string;
67
+ }
68
+
69
+ /**
70
+ * Detect a leading archived-version directory in a path's segments. The
71
+ * current version lives at the content root, so only archived ids are matched
72
+ * as a leading segment. Returns the resolved version (`""` for current) and
73
+ * the remaining (version-stripped) segments. Runs BEFORE locale detection —
74
+ * the snapshot directory is outermost on disk, so the locale parser must see
75
+ * a version-stripped path.
76
+ */
77
+ export const detectVersion = (
78
+ parts: string[],
79
+ versions: ResolvedVersionsConfig
80
+ ): DetectedVersion => {
81
+ const [first] = parts;
82
+ if (first && versions.archived.some((version) => version.id === first)) {
83
+ return { rest: parts.slice(1), version: first };
84
+ }
85
+ return { rest: parts, version: "" };
86
+ };
87
+
88
+ /**
89
+ * {@link detectVersion} over a slash-joined source ref (`v1.0/fr/x.mdx`):
90
+ * returns the version and the version-stripped ref for the locale parser and
91
+ * route mapping to consume.
92
+ */
93
+ export const detectVersionRef = (
94
+ ref: string,
95
+ versions: ResolvedVersionsConfig
96
+ ): DetectedVersionRef => {
97
+ const { version, rest } = detectVersion(ref.split("/"), versions);
98
+ return { rest: rest.join("/"), version };
99
+ };
100
+
101
+ /**
102
+ * Prefix a version-agnostic logical route with its version: `/guides/x`
103
+ * becomes `/v1.0/guides/x`, `/` becomes `/v1.0`. The current version (`""`)
104
+ * is the identity.
105
+ */
106
+ export const versionizeRoute = (
107
+ logicalRoute: string,
108
+ version: string
109
+ ): string => {
110
+ if (!version) {
111
+ return logicalRoute;
112
+ }
113
+ return logicalRoute === "/" ? `/${version}` : `/${version}${logicalRoute}`;
114
+ };
115
+
116
+ /**
117
+ * The localized root route of a version: `/`, `/fr`, `/v1.0`, or `/fr/v1.0`.
118
+ * The locale prefix stays outermost, matching how page routes compose
119
+ * (`localizeRoute(versionizeRoute(...))`).
120
+ */
121
+ export const versionRoot = (
122
+ version: string,
123
+ locale?: string,
124
+ i18n?: ResolvedI18nConfig
125
+ ): string => {
126
+ const logical = versionizeRoute("/", version);
127
+ if (i18n && locale !== undefined) {
128
+ return localizeRoute(logical, locale, i18n);
129
+ }
130
+ return logical;
131
+ };
132
+
133
+ /**
134
+ * Warn about top-level content folders shaped like a version (`v1`, `v2.0`)
135
+ * that aren't declared in `versions.archived`. Without this they're silently
136
+ * treated as current-version content under a `/<folder>/…` route — usually a
137
+ * snapshot that wasn't registered in config.
138
+ */
139
+ export const versionsDiagnostics = (
140
+ pages: PageRecord[],
141
+ versions: ResolvedVersionsConfig
142
+ ): Diagnostic[] => {
143
+ // Exact-id comparison, matching `detectVersion`: a folder that differs from
144
+ // a configured id only by case (`V1.0/` vs `v1.0`) is NOT routed as that
145
+ // snapshot, so it must still warn. `VERSION_LIKE` is case-insensitive on
146
+ // its own.
147
+ const configured = new Set(versions.archived.map((version) => version.id));
148
+ const seen = new Set<string>();
149
+ const diagnostics: Diagnostic[] = [];
150
+ for (const page of pages) {
151
+ // The version-looking folder is the first segment of the source-local ref
152
+ // (e.g. `v1.0/guide.md`), not the namespaced id (`filesystem:v1.0/guide.md`).
153
+ const [first] = page.source.ref.split("/");
154
+ if (
155
+ first &&
156
+ !seen.has(first) &&
157
+ VERSION_LIKE.test(first) &&
158
+ !configured.has(first)
159
+ ) {
160
+ seen.add(first);
161
+ diagnostics.push({
162
+ code: "BLUME_VERSIONS_UNCONFIGURED_VERSION",
163
+ message: `Folder "${first}/" looks like a version, but "${first}" is not in versions.archived — its pages are treated as current-version content at /${first}/….`,
164
+ severity: "warning",
165
+ suggestion: `Add { id: "${first}" } to versions.archived, or rename the folder if it isn't a snapshot.`,
166
+ });
167
+ }
168
+ }
169
+ return diagnostics;
170
+ };
@@ -8,6 +8,9 @@ import type { ProjectContext } from "../core/types.ts";
8
8
 
9
9
  type Adapter = NonNullable<ResolvedConfig["deployment"]["adapter"]>;
10
10
 
11
+ /** An optional per-adapter path, keyed by the closed adapter union. */
12
+ type AdapterPathMap = { [A in Adapter]?: string };
13
+
11
14
  /**
12
15
  * Top-level directory each server adapter writes its deploy bundle into, for
13
16
  * `.gitignore` — the bundle is a build artifact, and the platform's own state
@@ -15,7 +18,7 @@ type Adapter = NonNullable<ResolvedConfig["deployment"]["adapter"]>;
15
18
  * whole directory is ignored. `node` and `cloudflare` emit into `dist/`, which
16
19
  * `blume init` already ignores.
17
20
  */
18
- export const ADAPTER_IGNORE_DIRS: Partial<Record<Adapter, string>> = {
21
+ export const ADAPTER_IGNORE_DIRS: AdapterPathMap = {
19
22
  netlify: ".netlify/",
20
23
  vercel: ".vercel/",
21
24
  };
@@ -40,7 +43,7 @@ export const ADAPTER_IGNORE_DIRS: Partial<Record<Adapter, string>> = {
40
43
  * chunks and `node_modules`. Given the right root it writes its Build Output
41
44
  * tree straight to `<root>/.vercel/output`, so there is nothing left to move.
42
45
  */
43
- export const ADAPTER_OUTPUT_PATHS: Partial<Record<Adapter, string>> = {
46
+ export const ADAPTER_OUTPUT_PATHS: AdapterPathMap = {
44
47
  netlify: ".netlify/v1",
45
48
  };
46
49
 
@@ -75,6 +75,25 @@ const FALLBACK_RULES = ["/*", "!/_astro/*", "!/*.md", "!/*.mdx", "!/*.txt"];
75
75
  const basePrefix = (base?: string): string =>
76
76
  base && base !== "/" ? base.replace(/\/$/u, "") : "";
77
77
 
78
+ /** A value as `JSON.parse` produces it (the wrangler config's value space). */
79
+ type JsonValue =
80
+ | string
81
+ | number
82
+ | boolean
83
+ | null
84
+ | JsonValue[]
85
+ | { [key: string]: JsonValue };
86
+
87
+ /** Narrow a parsed JSON value to a plain (non-array) object. */
88
+ const isJsonObject = (
89
+ value: JsonValue | undefined
90
+ ): value is { [key: string]: JsonValue } =>
91
+ typeof value === "object" && value !== null && !Array.isArray(value);
92
+
93
+ /** Narrow a parsed JSON value to a string. */
94
+ const isJsonString = (value: JsonValue | undefined): value is string =>
95
+ typeof value === "string";
96
+
78
97
  const isNegativeRule = (rule: string): boolean => rule.startsWith("!");
79
98
 
80
99
  const ruleBody = (rule: string): string =>
@@ -163,7 +182,7 @@ const coveredBy = (rule: string, other: string): boolean =>
163
182
  * validation error at deploy time.
164
183
  */
165
184
  export const mergeRunWorkerFirstRules = (
166
- existing: unknown,
185
+ existing: JsonValue | undefined,
167
186
  added: readonly string[]
168
187
  ): string[] | true => {
169
188
  if (existing === true) {
@@ -276,6 +295,20 @@ const redirectFor = (pathname) => {
276
295
  return Object.hasOwn(REDIRECTS, path) ? REDIRECTS[path] : null;
277
296
  };
278
297
 
298
+ // \`_redirects\` semantics, which the static layer applies to these same
299
+ // paths: the request's query string is forwarded unless the destination
300
+ // carries its own, and a destination fragment stays after the query.
301
+ const redirectLocation = (destination, search) => {
302
+ const hashIndex = destination.indexOf("#");
303
+ const bare = hashIndex === -1 ? destination : destination.slice(0, hashIndex);
304
+ if (!search || bare.includes("?")) {
305
+ return destination;
306
+ }
307
+ return hashIndex === -1
308
+ ? bare + search
309
+ : bare + search + destination.slice(hashIndex);
310
+ };
311
+
279
312
  const parseAccept = (accept) =>
280
313
  accept.split(",").map((part) => {
281
314
  const segments = part.trim().split(";");
@@ -355,7 +388,7 @@ export default {
355
388
  const redirect = redirectFor(url.pathname);
356
389
  if (redirect !== null) {
357
390
  return new Response(null, {
358
- headers: { location: redirect[0] },
391
+ headers: { location: redirectLocation(redirect[0], url.search) },
359
392
  status: redirect[1],
360
393
  });
361
394
  }
@@ -452,29 +485,25 @@ export const injectWorkerNegotiation = (
452
485
  if (options.routePaths.length === 0) {
453
486
  return null;
454
487
  }
455
- let config: Record<string, unknown>;
488
+ let config: JsonValue;
456
489
  try {
457
490
  config = JSON.parse(wranglerText);
458
491
  } catch {
459
492
  return null;
460
493
  }
461
- if (config === null || typeof config !== "object" || Array.isArray(config)) {
494
+ if (!isJsonObject(config)) {
462
495
  return null;
463
496
  }
464
497
  const { main } = config;
465
498
  if (
466
- typeof main !== "string" ||
499
+ !isJsonString(main) ||
467
500
  main.length === 0 ||
468
501
  main === NEGOTIATION_WORKER_FILE
469
502
  ) {
470
503
  return null;
471
504
  }
472
- const assets = config.assets as Record<string, unknown> | undefined;
473
- if (
474
- assets === null ||
475
- typeof assets !== "object" ||
476
- typeof assets.binding !== "string"
477
- ) {
505
+ const { assets } = config;
506
+ if (!isJsonObject(assets) || !isJsonString(assets.binding)) {
478
507
  return null;
479
508
  }
480
509
  const {
@@ -1,6 +1,7 @@
1
1
  import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
2
2
  import type { BlumeProject } from "../core/project-graph.ts";
3
3
  import type { ContentSignalPolicy, ContentSignals } from "../core/schema.ts";
4
+ import { absoluteUrl } from "../core/site-url.ts";
4
5
 
5
6
  /**
6
7
  * Ordered mapping from config field to its `Content-Signal` token. The order
@@ -52,7 +53,7 @@ export const buildRobots = (project: BlumeProject): string | null => {
52
53
  normalizeBasePath(config.deployment.base),
53
54
  "/sitemap.xml"
54
55
  );
55
- lines.push("", `Sitemap: ${site.replace(/\/$/u, "")}${sitemapPath}`);
56
+ lines.push("", `Sitemap: ${absoluteUrl(site, sitemapPath)}`);
56
57
  }
57
58
  return `${lines.join("\n")}\n`;
58
59
  };
package/src/deploy/rss.ts CHANGED
@@ -4,6 +4,7 @@ import { escape as escapeXml } from "html-escaper";
4
4
 
5
5
  import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
6
6
  import type { BlumeProject } from "../core/project-graph.ts";
7
+ import { siteRoot } from "../core/site-url.ts";
7
8
  import type { PageRecord } from "../core/types.ts";
8
9
 
9
10
  /** A single feed entry derived from a content page. */
@@ -55,7 +56,7 @@ export const buildRssFeeds = (project: BlumeProject): RssFeed[] => {
55
56
  if (!(rss.enabled && site)) {
56
57
  return [];
57
58
  }
58
- const base = site.replace(/\/$/u, "");
59
+ const base = siteRoot(site);
59
60
  // Routes carry `basePath`; a `deployment.base` subdirectory is layered on top.
60
61
  // The feed's own `link`/self URL points at the docs root under that base, while
61
62
  // `path` stays base-less (it's also the on-disk output location).