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.
- package/CHANGELOG.md +71 -0
- package/dist/cli/index.js +2260 -1100
- package/dist/cli/index.js.map +123 -117
- package/dist/types/ai/component-markdown.d.ts +14 -4
- package/dist/types/core/base-path.d.ts +8 -0
- package/dist/types/core/config-input.d.ts +87 -27
- package/dist/types/core/config.d.ts +2 -1
- package/dist/types/core/data.d.ts +16 -1
- package/dist/types/core/diagnostics.d.ts +5 -1
- package/dist/types/core/i18n-ui.d.ts +12 -0
- package/dist/types/core/schema.d.ts +116 -15
- package/dist/types/core/sources/types.d.ts +11 -1
- package/dist/types/core/standard-schema.d.ts +7 -3
- package/dist/types/core/types.d.ts +43 -2
- package/dist/types/core/ui-packs/index.d.ts +9 -1
- package/dist/types/openapi/references.d.ts +14 -7
- package/dist/types/seo/x-handle.d.ts +3 -2
- package/docs/advanced/api-reference.mdx +8 -6
- package/docs/configuration/ai.mdx +26 -8
- package/docs/configuration/search.mdx +2 -0
- package/docs/configuration/seo.mdx +1 -1
- package/docs/content/i18n.mdx +1 -1
- package/docs/content/meta.mdx +2 -1
- package/docs/content/meta.ts +1 -0
- package/docs/content/navigation.mdx +35 -1
- package/docs/content/sources.mdx +1 -1
- package/docs/content/versioning.mdx +106 -0
- package/docs/reference/cli.mdx +1 -0
- package/docs/reference/frontmatter.mdx +3 -0
- package/package.json +13 -1
- package/skills/blume-migrate/SKILL.md +2 -2
- package/skills/blume-migrate/references/docusaurus.md +1 -1
- package/skills/blume-migrate/references/fumadocs.md +1 -1
- package/skills/blume-migrate/references/mintlify.md +1 -1
- package/src/ai/agent-readability.ts +40 -12
- package/src/ai/api-catalog.ts +2 -2
- package/src/ai/ask-context.ts +49 -12
- package/src/ai/ask.ts +10 -1
- package/src/ai/component-markdown.ts +80 -43
- package/src/ai/llms.ts +42 -17
- package/src/ai/mcp/data.ts +48 -12
- package/src/ai/mcp/discovery.ts +52 -16
- package/src/ai/mcp/server.ts +280 -125
- package/src/ai/mcp/tools.ts +3 -3
- package/src/ai/skills.ts +32 -9
- package/src/ai/tar.ts +29 -70
- package/src/ai/visibility.ts +2 -2
- package/src/astro/component-slots.ts +2 -0
- package/src/astro/examples.ts +13 -5
- package/src/astro/generate.ts +113 -63
- package/src/astro/integration.ts +13 -2
- package/src/astro/islands.ts +23 -12
- package/src/astro/templates.ts +185 -41
- package/src/audit/agent.ts +16 -31
- package/src/audit/checks/content.ts +26 -11
- package/src/audit/checks/dns-aid.ts +3 -0
- package/src/audit/checks/indexability.ts +24 -6
- package/src/audit/checks/llms.ts +9 -4
- package/src/audit/checks/network.ts +2 -0
- package/src/audit/checks/social.ts +18 -10
- package/src/audit/crawl.ts +78 -25
- package/src/audit/report.ts +20 -19
- package/src/audit/run.ts +15 -5
- package/src/audit/snapshot.ts +29 -6
- package/src/audit/types.ts +25 -3
- package/src/blume-modules.d.ts +5 -1
- package/src/cli/commands/audit.ts +21 -21
- package/src/cli/commands/build.ts +30 -16
- package/src/cli/commands/dev.ts +15 -15
- package/src/cli/commands/doctor.ts +2 -0
- package/src/cli/commands/eject.ts +4 -4
- package/src/cli/commands/eval.ts +24 -30
- package/src/cli/commands/init.ts +9 -9
- package/src/cli/commands/mcp-stdio.ts +3 -0
- package/src/cli/commands/translate.ts +14 -3
- package/src/cli/commands/version.ts +85 -0
- package/src/cli/dev-lock.ts +31 -10
- package/src/cli/eject-scripts.ts +17 -2
- package/src/cli/env.ts +13 -30
- package/src/cli/index.ts +2 -0
- package/src/cli/init/questions.ts +1 -1
- package/src/cli/init/scaffold.ts +41 -13
- package/src/cli/internal-error.ts +1 -0
- package/src/cli/report-format.ts +22 -0
- package/src/components/content/AccordionItem.astro +2 -9
- package/src/components/content/ColorItem.astro +5 -13
- package/src/components/content/Component.astro +12 -8
- package/src/components/content/Frame.astro +2 -12
- package/src/components/content/Prompt.astro +12 -31
- package/src/components/content/Tab.astro +2 -9
- package/src/components/content/Tooltip.astro +1 -9
- package/src/components/content/Update.astro +2 -9
- package/src/components/content/auto-type-table.ts +3 -0
- package/src/components/content/diff.ts +9 -5
- package/src/components/content/github-info.ts +2 -0
- package/src/components/content/inline-markdown.ts +28 -0
- package/src/components/copy-feedback.ts +96 -0
- package/src/components/islands/ask-ai.tsx +111 -34
- package/src/components/islands/hooks.ts +5 -1
- package/src/components/islands/webmcp.ts +49 -12
- package/src/components/layout/Header.astro +25 -1
- package/src/components/layout/NavSelector.astro +11 -2
- package/src/components/layout/NavTree.astro +4 -2
- package/src/components/layout/PageActions.astro +20 -32
- package/src/components/layout/PageLayout.astro +8 -28
- package/src/components/layout/RootLayout.astro +24 -48
- package/src/components/layout/Search.astro +133 -22
- package/src/components/layout/VersionBanner.astro +39 -0
- package/src/components/layout/analytics-client.ts +8 -5
- package/src/components/layout/drawer-inert.ts +31 -0
- package/src/components/layout/hydration-hint.ts +1 -1
- package/src/components/layout/nav-utils.ts +1 -4
- package/src/components/layout/overrides.ts +25 -12
- package/src/components/layout/search/algolia.ts +18 -5
- package/src/components/layout/search/endpoint.ts +3 -0
- package/src/components/layout/search/flexsearch.ts +23 -7
- package/src/components/layout/search/orama-cloud.ts +1 -1
- package/src/components/layout/search/orama.ts +4 -1
- package/src/components/layout/search/pagefind.ts +8 -5
- package/src/components/layout/search/types.ts +45 -1
- package/src/components/layout/search/typesense.ts +19 -3
- package/src/components/openapi/ApiOverview.astro +32 -6
- package/src/components/openapi/AsyncApiOperation.astro +237 -0
- package/src/components/openapi/Bindings.astro +89 -0
- package/src/components/openapi/MethodBadge.astro +3 -0
- package/src/components/openapi/Operation.astro +7 -2
- package/src/components/openapi/PanelTabs.astro +131 -0
- package/src/components/openapi/ParametersTable.astro +2 -0
- package/src/components/openapi/RequestPanel.astro +12 -119
- package/src/components/openapi/async-snippets.ts +174 -0
- package/src/components/openapi/async.ts +348 -0
- package/src/components/openapi/helpers.ts +52 -20
- package/src/components/openapi/panel.ts +11 -8
- package/src/components/openapi/security.ts +102 -29
- package/src/components/openapi/snippets.ts +11 -11
- package/src/components/raf-throttle.ts +21 -0
- package/src/components/slug.ts +14 -0
- package/src/core/base-path.ts +18 -1
- package/src/core/component-overrides.ts +28 -23
- package/src/core/config-input.ts +96 -27
- package/src/core/config.ts +20 -7
- package/src/core/content.ts +3 -1
- package/src/core/data.ts +16 -1
- package/src/core/define-components.ts +5 -0
- package/src/core/diagnostics.ts +46 -38
- package/src/core/frontmatter.ts +74 -4
- package/src/core/graph.ts +137 -53
- package/src/core/i18n-ui.ts +15 -0
- package/src/core/i18n.ts +16 -8
- package/src/core/load-module.ts +1 -0
- package/src/core/manifest.ts +92 -3
- package/src/core/meta.ts +44 -14
- package/src/core/nav-diagnostics.ts +3 -3
- package/src/core/navigation.ts +247 -67
- package/src/core/probe.ts +7 -19
- package/src/core/project-graph.ts +27 -4
- package/src/core/schema.ts +219 -67
- package/src/core/site-url.ts +27 -0
- package/src/core/sources/assets.ts +2 -0
- package/src/core/sources/cache.ts +16 -8
- package/src/core/sources/github-releases.ts +39 -11
- package/src/core/sources/mdx-remote.ts +4 -0
- package/src/core/sources/normalize.ts +93 -22
- package/src/core/sources/notion.ts +76 -22
- package/src/core/sources/portable-text.ts +48 -12
- package/src/core/sources/resolve.ts +1 -0
- package/src/core/sources/sanity.ts +68 -14
- package/src/core/sources/types.ts +17 -1
- package/src/core/sources/watch.ts +1 -1
- package/src/core/standard-schema.ts +9 -3
- package/src/core/text-width.ts +26 -0
- package/src/core/tsconfig-aliases.ts +9 -5
- package/src/core/types.ts +45 -2
- package/src/core/ui-packs/index.ts +9 -1
- package/src/core/version-cut.ts +301 -0
- package/src/core/version.ts +2 -0
- package/src/core/versions.ts +170 -0
- package/src/deploy/adapter-output.ts +5 -2
- package/src/deploy/cloudflare-negotiation.ts +40 -11
- package/src/deploy/robots.ts +2 -1
- package/src/deploy/rss.ts +2 -1
- package/src/deploy/sitemap.ts +89 -8
- package/src/deploy/vercel-negotiation.ts +11 -4
- package/src/eval/agents.ts +13 -10
- package/src/eval/report.ts +5 -18
- package/src/eval/run.ts +2 -2
- package/src/eval/schema.ts +1 -1
- package/src/markdown/base-links.ts +6 -6
- package/src/markdown/directives.ts +7 -1
- package/src/markdown/heading-anchors.ts +17 -6
- package/src/markdown/index.ts +73 -24
- package/src/markdown/inline-code.ts +14 -2
- package/src/markdown/language-icon.ts +6 -2
- package/src/markdown/mdast.ts +18 -4
- package/src/markdown/package-commands.ts +63 -58
- package/src/markdown/table-wrap.ts +4 -1
- package/src/markdown/twoslash.ts +2 -0
- package/src/og/card.ts +50 -33
- package/src/og/derive.ts +43 -27
- package/src/openapi/asyncapi.ts +366 -0
- package/src/openapi/model.ts +135 -66
- package/src/openapi/parse.ts +166 -33
- package/src/openapi/references.ts +47 -22
- package/src/openapi/render-mdx.ts +137 -59
- package/src/openapi/scalar.ts +8 -10
- package/src/openapi/source.ts +126 -29
- package/src/registry/eject.ts +7 -2
- package/src/search/documents.ts +103 -39
- package/src/search/facets.ts +7 -5
- package/src/search/orama-index.ts +117 -32
- package/src/search/popular.ts +10 -5
- package/src/search/providers.ts +2 -2
- package/src/search/sync/index.ts +2 -0
- package/src/search/sync/typesense.ts +4 -2
- package/src/seo/jsonld.ts +24 -6
- package/src/seo/x-handle.ts +8 -3
- package/src/theme/chrome-icons.ts +7 -2
- package/src/theme/fonts.ts +8 -4
- package/src/theme/icons.ts +4 -2
- package/src/theme/palette.ts +27 -15
- package/src/translate/ledger.ts +4 -2
- package/src/translate/meta.ts +15 -6
- package/src/translate/report.ts +10 -19
- package/src/translate/run.ts +29 -38
- package/src/translate/validate.ts +52 -17
- package/src/translate/work-list.ts +0 -0
- 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
|
+
};
|
package/src/core/version.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
488
|
+
let config: JsonValue;
|
|
456
489
|
try {
|
|
457
490
|
config = JSON.parse(wranglerText);
|
|
458
491
|
} catch {
|
|
459
492
|
return null;
|
|
460
493
|
}
|
|
461
|
-
if (
|
|
494
|
+
if (!isJsonObject(config)) {
|
|
462
495
|
return null;
|
|
463
496
|
}
|
|
464
497
|
const { main } = config;
|
|
465
498
|
if (
|
|
466
|
-
|
|
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
|
|
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 {
|
package/src/deploy/robots.ts
CHANGED
|
@@ -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
|
|
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
|
|
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).
|