blume 1.0.4 → 1.1.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 +65 -0
- package/dist/cli/index.js +13255 -10232
- package/dist/cli/index.js.map +91 -60
- package/dist/types/core/config-input.d.ts +61 -1
- package/dist/types/core/data.d.ts +9 -0
- package/dist/types/core/deployment-env.d.ts +6 -0
- package/dist/types/core/diagnostics.d.ts +23 -0
- package/dist/types/core/i18n-ui.d.ts +8 -8
- package/dist/types/core/schema.d.ts +131 -22
- package/dist/types/core/sources/types.d.ts +3 -1
- package/dist/types/core/standard-schema.d.ts +41 -0
- package/dist/types/core/types.d.ts +13 -0
- package/dist/types/og/card.d.ts +63 -0
- package/dist/types/og/dimensions.d.ts +12 -0
- package/docs/01-quickstart.mdx +1 -1
- package/docs/02-deployment.mdx +9 -1
- package/docs/advanced/api-reference.mdx +11 -0
- package/docs/advanced/changelog.mdx +1 -1
- package/docs/advanced/skills.mdx +1 -1
- package/docs/configuration/ai.mdx +1 -1
- package/docs/configuration/customization.mdx +1 -1
- package/docs/configuration/export.mdx +1 -1
- package/docs/configuration/index.mdx +21 -1
- package/docs/configuration/search.mdx +28 -1
- package/docs/configuration/seo.mdx +21 -2
- package/docs/configuration/theming.mdx +1 -1
- package/docs/content/components.mdx +14 -0
- package/docs/content/index.mdx +1 -1
- package/docs/content/meta.mdx +1 -1
- package/docs/content/navigation.mdx +1 -1
- package/docs/content/sources.mdx +1 -1
- package/docs/reference/cli.mdx +79 -1
- package/docs/reference/frontmatter.mdx +29 -1
- package/package.json +3 -3
- package/skills/blume-migrate/SKILL.md +1 -1
- package/skills/blume-migrate/references/mintlify.md +3 -2
- package/skills/blume-migrate/scripts/mintlify-codemod.mjs +16 -4
- package/src/ai/llms.ts +15 -0
- package/src/astro/adapter-root.ts +70 -0
- package/src/astro/generate.ts +50 -19
- package/src/astro/index.ts +1 -0
- package/src/astro/pages.ts +18 -3
- package/src/astro/templates.ts +65 -22
- package/src/audit/agent.ts +114 -0
- package/src/audit/catalog.ts +826 -0
- package/src/audit/checks/assets.ts +177 -0
- package/src/audit/checks/content.ts +231 -0
- package/src/audit/checks/duplicates.ts +131 -0
- package/src/audit/checks/i18n.ts +246 -0
- package/src/audit/checks/indexability.ts +213 -0
- package/src/audit/checks/links.ts +223 -0
- package/src/audit/checks/llms.ts +135 -0
- package/src/audit/checks/network.ts +272 -0
- package/src/audit/checks/og-image.ts +113 -0
- package/src/audit/checks/redirects.ts +87 -0
- package/src/audit/checks/robots.ts +114 -0
- package/src/audit/checks/sitemap.ts +229 -0
- package/src/audit/checks/social.ts +238 -0
- package/src/audit/crawl.ts +259 -0
- package/src/audit/graph.ts +74 -0
- package/src/audit/html.ts +54 -0
- package/src/audit/image-size.ts +63 -0
- package/src/audit/locate.ts +33 -0
- package/src/audit/redirects.ts +74 -0
- package/src/audit/report.ts +278 -0
- package/src/audit/run.ts +198 -0
- package/src/audit/snapshot.ts +189 -0
- package/src/audit/types.ts +214 -0
- package/src/audit/url.ts +103 -0
- package/src/cli/commands/audit.ts +205 -0
- package/src/cli/commands/build.ts +51 -12
- package/src/cli/index.ts +2 -0
- package/src/components/content/Tabs.astro +98 -15
- package/src/components/layout/Breadcrumbs.astro +1 -1
- package/src/components/layout/Header.astro +1 -0
- package/src/components/layout/PageFeedback.astro +1 -1
- package/src/components/layout/PageLayout.astro +5 -1
- package/src/components/layout/Pagination.astro +1 -1
- package/src/components/layout/RootLayout.astro +5 -3
- package/src/components/layout/Search.astro +35 -6
- package/src/components/layout/TableOfContents.astro +1 -1
- package/src/components/openapi/Authorization.astro +80 -0
- package/src/components/openapi/Operation.astro +19 -1
- package/src/components/openapi/ParametersTable.astro +1 -1
- package/src/components/openapi/security.ts +201 -0
- package/src/components/openapi/snippets.ts +42 -13
- package/src/core/config-input.ts +66 -1
- package/src/core/data.ts +9 -1
- package/src/core/deployment-env.ts +9 -0
- package/src/core/diagnostics.ts +59 -12
- package/src/core/links.ts +2 -91
- package/src/core/nav-diagnostics.ts +48 -4
- package/src/core/probe.ts +136 -0
- package/src/core/project-graph.ts +8 -0
- package/src/core/schema.ts +86 -3
- package/src/core/sources/normalize.ts +198 -25
- package/src/core/sources/types.ts +3 -1
- package/src/core/standard-schema.ts +54 -0
- package/src/core/types.ts +13 -0
- package/src/deploy/adapter-output.ts +27 -15
- package/src/deploy/headers.ts +66 -0
- package/src/deploy/redirects.ts +49 -9
- package/src/og/card.ts +98 -33
- package/src/og/index.ts +1 -1
- package/src/search/popular.ts +33 -0
- package/src/theme/entry.ts +6 -1
|
@@ -37,7 +37,7 @@ Resolve `$ref` includes first (Mintlify splits config across files). Map only wh
|
|
|
37
37
|
| `variables` (`{{name}}`) | **inline into content** | Blume has no runtime `{{var}}` substitution — replace each `{{name}}` with its value in the pages |
|
|
38
38
|
| `integrations.posthog` (`{ apiKey, apiHost }`) | `analytics.posthog` (`{ key, host }`) | preserve the host verbatim (e.g. `us.posthog.com` — Blume's default is `us.i.posthog.com`) |
|
|
39
39
|
| `integrations` (GA, Plausible, Fathom, …) | `analytics.scripts` / `analytics.vercel` | one `scripts[]` entry per provider (`{ src, strategy, attributes }`); no first-class mapping beyond PostHog/Vercel |
|
|
40
|
-
| `contextual` (`["copy","chatgpt","claude",…]`) | **mostly free** | Copy-as-Markdown and Open-in-chat are default page actions; `mcp` needs `mcp.enabled` + server output (report as a follow-up) |
|
|
40
|
+
| `contextual` (`["copy","chatgpt","claude",…]`) | **mostly free** | Copy-as-Markdown and Open-in-chat are default page actions; `mcp` needs `ai.mcp.enabled` + server output (report as a follow-up) |
|
|
41
41
|
| `redirects` | `redirects: [{ from, to }]` | static only — see below |
|
|
42
42
|
| `navigation.languages` | `i18n` | see i18n below |
|
|
43
43
|
|
|
@@ -107,7 +107,8 @@ Rewrite each page's MDX:
|
|
|
107
107
|
- **Callouts → directives** (directives are **MDX-only** — Mintlify content is already `.mdx`, so this just works; never rename a page to `.md`): `<Note>`→`:::note`, `<Tip>`→`:::tip`, `<Warning>`→`:::warning`, `<Info>`→`:::info`, `<Check>`→`:::success`, `<Danger>`/`<Error>`→`:::danger`. `<Callout type="x">` maps by type (`caution`→warning, `check`→success). A `title` attr → `:::type[Title]`. Drop `icon`/color props.
|
|
108
108
|
- **Accordions — container/item inversion!** Mintlify nests `<Accordion title="…">` inside `<AccordionGroup>`. Blume inverts: `<AccordionGroup>`→`<Accordion>` (container), and each Mintlify `<Accordion title="…">`→`<AccordionItem title="…">` (item).
|
|
109
109
|
- **`<RequestExample>`/`<ResponseExample>`** → `<CodeGroup>` (titled-fence tabs).
|
|
110
|
-
- **
|
|
110
|
+
- **`<Tabs>` → `<Tabs inline>`.** Mintlify renders tabs **borderless** — a strip on a full-width rule with the content flowing beneath as prose — while Blume's `<Tabs>` defaults to a bordered box. Add `inline` to each `<Tabs>` to preserve Mintlify's appearance; every child `<Tab title="…">` is unchanged. Don't add `param` — Mintlify tabs switch in place and don't deep-link to the URL, so plain `inline` is the faithful mapping.
|
|
111
|
+
- **These pass through — Blume ships them natively:** `<Columns>`/`<Column>`, `<Expandable>`, `<Tooltip>`, `<Frame>`, `<Panel>`, `<Card>`/`<CardGroup>`, `<Tab>`, `<Steps>`/`<Step>`. Keep them as-is.
|
|
111
112
|
- **API fields → `TypeTable`.** Blume does **not** ship `<ParamField>`/`<ResponseField>`/`<RequestField>`. Convert a cluster of fields into one `<TypeTable>` (rows keyed by field name, each `{ type, required?, default?, description }`). For a fully spec'd API, prefer deleting the hand-written fields and using the [OpenAPI reference](#openapi) instead.
|
|
112
113
|
- **`<Update>`** (a Mintlify changelog entry) has no component form → convert to a `type: changelog` page, or use the `github-releases` source.
|
|
113
114
|
- **Snippets are inlined, not imported.** Blume has no `/snippets` import mechanism. For each `import X from "/snippets/x.mdx"` + `<X prop="v" />`, inline the snippet's body (substituting `{prop}` placeholders), then delete the import and the `/snippets` file. Named string imports (`import { foo } from "/snippets/vars.mdx"`) → inline the value at each `{foo}`.
|
|
@@ -277,7 +277,8 @@ const remapIcons = (fm) => {
|
|
|
277
277
|
continue;
|
|
278
278
|
}
|
|
279
279
|
if (lucide !== raw) {
|
|
280
|
-
|
|
280
|
+
const comment = m.groups.comment ? ` ${m.groups.comment}` : "";
|
|
281
|
+
fm[i] = `${m.groups.indent}icon: ${lucide}${comment}`;
|
|
281
282
|
changes.push({ detail: `${raw} → ${lucide}`, kind: "icon-remap" });
|
|
282
283
|
}
|
|
283
284
|
i += 1;
|
|
@@ -301,11 +302,12 @@ const setNested = (fm, parent, child, value) => {
|
|
|
301
302
|
return { ok: false, reason: "child-exists" };
|
|
302
303
|
}
|
|
303
304
|
fm.splice(start + 1, 0, ` ${child}: ${value}`);
|
|
304
|
-
return { ok: true };
|
|
305
|
+
return { index: start + 1, ok: true };
|
|
305
306
|
}
|
|
306
307
|
// No parent block — append one at the end of the frontmatter.
|
|
308
|
+
const index = fm.length;
|
|
307
309
|
fm.push(`${parent}:`, ` ${child}: ${value}`);
|
|
308
|
-
return { ok: true };
|
|
310
|
+
return { index, ok: true };
|
|
309
311
|
};
|
|
310
312
|
|
|
311
313
|
// Drop unsupported keys, rename Mintlify-only keys, flag ambiguous ones.
|
|
@@ -341,14 +343,24 @@ const rewriteFields = (fm) => {
|
|
|
341
343
|
});
|
|
342
344
|
continue;
|
|
343
345
|
}
|
|
346
|
+
// Remove the source line before inserting: setNested splices into the
|
|
347
|
+
// parent block, and when that block sits above the source key the insert
|
|
348
|
+
// would otherwise shift `start` onto the wrong line. Failure paths don't
|
|
349
|
+
// mutate `fm`, so the line can be restored as-is on conflict.
|
|
350
|
+
const [removed] = fm.splice(start, 1);
|
|
344
351
|
const placed = setNested(fm, parent, child, tk.value);
|
|
345
352
|
if (placed.ok) {
|
|
346
|
-
|
|
353
|
+
if (placed.index < start) {
|
|
354
|
+
// The insert above the cursor pushed the unvisited lines down one
|
|
355
|
+
// slot; revisit the current index so none of them get skipped.
|
|
356
|
+
i += 1;
|
|
357
|
+
}
|
|
347
358
|
changes.push({
|
|
348
359
|
detail: `${tk.key} → ${parent}.${child}`,
|
|
349
360
|
kind: "rename",
|
|
350
361
|
});
|
|
351
362
|
} else {
|
|
363
|
+
fm.splice(start, 0, removed);
|
|
352
364
|
// Target already set, or parent is a scalar — leave the source in place so
|
|
353
365
|
// no data is lost, and report it for manual resolution.
|
|
354
366
|
changes.push({
|
package/src/ai/llms.ts
CHANGED
|
@@ -3,6 +3,7 @@ import matter from "../core/frontmatter.ts";
|
|
|
3
3
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
4
4
|
import { readEntryText } from "../core/sources/read.ts";
|
|
5
5
|
import type { NavNode, Navigation, PageRecord } from "../core/types.ts";
|
|
6
|
+
import { buildRssFeeds } from "../deploy/rss.ts";
|
|
6
7
|
import { downlevelComponents } from "./component-markdown.ts";
|
|
7
8
|
import { applyAgentVisibility } from "./visibility.ts";
|
|
8
9
|
|
|
@@ -136,6 +137,20 @@ const buildIndex = (project: BlumeProject): string => {
|
|
|
136
137
|
);
|
|
137
138
|
}
|
|
138
139
|
|
|
140
|
+
// Surface the RSS feeds so agents can find fresh content (new blog posts,
|
|
141
|
+
// changelog entries) without re-crawling the index. `buildRssFeeds` is empty
|
|
142
|
+
// unless RSS is enabled and an absolute `site` is set — the same condition
|
|
143
|
+
// under which agent-readability.json lists `artifacts.feeds`.
|
|
144
|
+
const feeds = buildRssFeeds(project);
|
|
145
|
+
if (feeds.length > 0) {
|
|
146
|
+
blocks.push(
|
|
147
|
+
"## RSS Feeds",
|
|
148
|
+
feeds
|
|
149
|
+
.map((feed) => `- [${feed.title}](${pageUrl(feed.path, site, base)})`)
|
|
150
|
+
.join("\n")
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
139
154
|
const header = config.description
|
|
140
155
|
? `# ${config.title}\n\n> ${config.description}`
|
|
141
156
|
: `# ${config.title}`;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { pathToFileURL } from "node:url";
|
|
2
|
+
|
|
3
|
+
import type { AstroIntegration } from "astro";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Present a deploy adapter with `root` pointed at the real project root rather
|
|
7
|
+
* than the hidden `.blume` runtime.
|
|
8
|
+
*
|
|
9
|
+
* Astro's `root` and `outDir` normally sit together (`outDir` defaults to
|
|
10
|
+
* `<root>/dist`), and `@astrojs/vercel` leans on that: it writes its Build
|
|
11
|
+
* Output tree to `<root>/.vercel/output` and — the part that bites — traces the
|
|
12
|
+
* function's dependency closure with `@vercel/nft` using a base derived from
|
|
13
|
+
* `root`, silently dropping every traced file that falls outside it.
|
|
14
|
+
*
|
|
15
|
+
* Blume splits the two: `root` is `<project>/.blume`, so Astro resolves the
|
|
16
|
+
* runtime's own `package.json` and its `node_modules` junction, while `outDir`
|
|
17
|
+
* stays at `<project>/dist` so the build lands where users expect. That puts
|
|
18
|
+
* `build.server` (`<outDir>/server`) *outside* `root`, so nft's base excludes
|
|
19
|
+
* the server bundle entirely: the traced file list collapses to `entry.mjs`
|
|
20
|
+
* alone, and the deployed function dies on its first import with
|
|
21
|
+
* ERR_MODULE_NOT_FOUND — missing its chunks, its virtual middleware, and every
|
|
22
|
+
* npm dependency.
|
|
23
|
+
*
|
|
24
|
+
* A project inside a workspace accidentally escapes this, because nft's base
|
|
25
|
+
* search climbs past `.blume` to the workspace root, which does contain both
|
|
26
|
+
* `dist/` and `node_modules` — which is why the bug only ever surfaced in
|
|
27
|
+
* standalone projects.
|
|
28
|
+
*
|
|
29
|
+
* Handing the adapter the root its own `outDir` assumption implies restores the
|
|
30
|
+
* invariant without moving Astro's real root: the trace covers `dist/server`
|
|
31
|
+
* and `node_modules`, and the Build Output tree lands at the project root
|
|
32
|
+
* natively, where `vercel deploy --prebuilt` looks for it.
|
|
33
|
+
*
|
|
34
|
+
* `astro:config:setup` and `astro:config:done` are the only hooks handed a
|
|
35
|
+
* `config`; an adapter reads `root` from one or both and closes over it for its
|
|
36
|
+
* later build hooks, so overriding it there covers the whole adapter.
|
|
37
|
+
*/
|
|
38
|
+
const stripTrailingSlashes = (value: string): string => {
|
|
39
|
+
let end = value.length;
|
|
40
|
+
|
|
41
|
+
while (end > 0 && value[end - 1] === "/") {
|
|
42
|
+
end -= 1;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return value.slice(0, end);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const withAdapterRoot = (
|
|
49
|
+
integration: AstroIntegration,
|
|
50
|
+
root: string
|
|
51
|
+
): AstroIntegration => {
|
|
52
|
+
const rootUrl = pathToFileURL(`${stripTrailingSlashes(root)}/`);
|
|
53
|
+
const setup = integration.hooks["astro:config:setup"];
|
|
54
|
+
const done = integration.hooks["astro:config:done"];
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
...integration,
|
|
58
|
+
hooks: {
|
|
59
|
+
...integration.hooks,
|
|
60
|
+
...(setup && {
|
|
61
|
+
"astro:config:setup": (options) =>
|
|
62
|
+
setup({ ...options, config: { ...options.config, root: rootUrl } }),
|
|
63
|
+
}),
|
|
64
|
+
...(done && {
|
|
65
|
+
"astro:config:done": (options) =>
|
|
66
|
+
done({ ...options, config: { ...options.config, root: rootUrl } }),
|
|
67
|
+
}),
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
};
|
package/src/astro/generate.ts
CHANGED
|
@@ -29,7 +29,10 @@ import type {
|
|
|
29
29
|
} from "../core/data.ts";
|
|
30
30
|
import { EN_UI, resolveUIStrings } from "../core/i18n-ui.ts";
|
|
31
31
|
import { resolveFallbackLocale } from "../core/i18n.ts";
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
validateNavTargets,
|
|
34
|
+
validateSearchPopularIcons,
|
|
35
|
+
} from "../core/nav-diagnostics.ts";
|
|
33
36
|
import { packageRoot } from "../core/package-root.ts";
|
|
34
37
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
35
38
|
import type { ResolvedConfig } from "../core/schema.ts";
|
|
@@ -43,6 +46,7 @@ import { buildReferenceFiles } from "../openapi/scalar.ts";
|
|
|
43
46
|
import { isOpenApiSource } from "../openapi/source.ts";
|
|
44
47
|
import { registry } from "../registry/registry.ts";
|
|
45
48
|
import { buildSearchDocuments } from "../search/documents.ts";
|
|
49
|
+
import { resolveSearchPopular } from "../search/popular.ts";
|
|
46
50
|
import { searchProviderMeta, servesStaticIndex } from "../search/providers.ts";
|
|
47
51
|
import {
|
|
48
52
|
examplesEntryTemplate,
|
|
@@ -145,23 +149,42 @@ const reactCompilerWarnings = (
|
|
|
145
149
|
]
|
|
146
150
|
: [];
|
|
147
151
|
|
|
148
|
-
/**
|
|
149
|
-
|
|
150
|
-
* none resolves. Comparing this for `.blume/` against Blume's own deps tells
|
|
151
|
-
* whether the runtime would bind to the *same* astro Blume uses or a different
|
|
152
|
-
* one shadowing it (the hoisted-conflict failure mode).
|
|
153
|
-
*/
|
|
154
|
-
const resolvedAstroPath = (fromDir: string): string | null => {
|
|
152
|
+
/** Resolve Astro's package.json directly inside a node_modules directory. */
|
|
153
|
+
const resolveAstroPackageJson = (modulesDir: string): string | null => {
|
|
155
154
|
try {
|
|
156
|
-
|
|
157
|
-
pathToFileURL(join(fromDir, "_.js")).href
|
|
158
|
-
).resolve("astro/package.json");
|
|
159
|
-
return realpathSync(pkg);
|
|
155
|
+
return realpathSync(join(modulesDir, "astro", "package.json"));
|
|
160
156
|
} catch {
|
|
161
157
|
return null;
|
|
162
158
|
}
|
|
163
159
|
};
|
|
164
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Realpath of the `astro` package reachable through the normal node_modules
|
|
163
|
+
* ancestor walk from a generated runtime, or null when none resolves.
|
|
164
|
+
*
|
|
165
|
+
* This deliberately does not use `createRequire().resolve()`. pnpm's generated
|
|
166
|
+
* bin shim adds Blume's virtual-store dependencies to `NODE_PATH`, which
|
|
167
|
+
* CommonJS resolution honors but ESM package resolution ignores. The generated
|
|
168
|
+
* Astro config uses ESM imports, so treating a NODE_PATH-only result as
|
|
169
|
+
* reachable skips the dependency link and makes `import "astro/config"` fail.
|
|
170
|
+
* Walking the physical node_modules ancestors mirrors the lookup that config
|
|
171
|
+
* actually gets.
|
|
172
|
+
*/
|
|
173
|
+
const resolvedAstroPath = (fromDir: string): string | null => {
|
|
174
|
+
let dir = normalize(fromDir);
|
|
175
|
+
while (true) {
|
|
176
|
+
const resolved = resolveAstroPackageJson(join(dir, "node_modules"));
|
|
177
|
+
if (resolved) {
|
|
178
|
+
return resolved;
|
|
179
|
+
}
|
|
180
|
+
const parent = dirname(dir);
|
|
181
|
+
if (parent === dir) {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
dir = parent;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
165
188
|
/**
|
|
166
189
|
* Locate the directory that holds Blume's installed dependencies (Astro and its
|
|
167
190
|
* integrations).
|
|
@@ -278,7 +301,7 @@ export const ensureDepsLink = async (
|
|
|
278
301
|
}
|
|
279
302
|
// Already correct when `.blume/` resolves the very same astro Blume's deps
|
|
280
303
|
// provide — the clean hoisted case, nothing to do.
|
|
281
|
-
const blumeAstro =
|
|
304
|
+
const blumeAstro = resolveAstroPackageJson(depsDir);
|
|
282
305
|
const outDirAstro = resolvedAstroPath(outDir);
|
|
283
306
|
if (blumeAstro && outDirAstro === blumeAstro) {
|
|
284
307
|
return null;
|
|
@@ -299,7 +322,7 @@ export const ensureDepsLink = async (
|
|
|
299
322
|
|
|
300
323
|
/**
|
|
301
324
|
* Vite plugin that makes Blume's externalized runtime deps (zod, shiki, sharp,
|
|
302
|
-
*
|
|
325
|
+
* `takumi-js`, …) resolvable when Astro executes the static prerender
|
|
303
326
|
* bundle under an isolated linker (Bun's `isolated` mode, pnpm).
|
|
304
327
|
*
|
|
305
328
|
* Astro's static build emits a self-contained SSR bundle to
|
|
@@ -931,12 +954,14 @@ export const buildRuntimeData = (project: BlumeProject): string => {
|
|
|
931
954
|
// the optional schema type so the serialized shape stays `boolean`.
|
|
932
955
|
og: {
|
|
933
956
|
enabled: config.seo.og.enabled ?? false,
|
|
957
|
+
fonts: config.seo.og.fonts ?? [],
|
|
934
958
|
logo: ogLogo,
|
|
935
959
|
palette: config.seo.og.palette,
|
|
936
960
|
},
|
|
937
961
|
repoUrl,
|
|
938
962
|
search: {
|
|
939
963
|
enabled: config.search.provider !== "none",
|
|
964
|
+
popular: resolveSearchPopular(config.search.popular, config.basePath),
|
|
940
965
|
provider: config.search.provider,
|
|
941
966
|
},
|
|
942
967
|
site: config.deployment.site ?? null,
|
|
@@ -1509,12 +1534,18 @@ export const generateRuntime = async (
|
|
|
1509
1534
|
if (hasGeneratedChangelog(project, pages)) {
|
|
1510
1535
|
navTargetRoutes.add("/changelog");
|
|
1511
1536
|
}
|
|
1537
|
+
// Curated `search.popular` icons live outside the navigation model, so they
|
|
1538
|
+
// miss `validateNavIcons` in the graph build — they're checked here too,
|
|
1539
|
+
// where the search config is known. A typo otherwise just renders the
|
|
1540
|
+
// default glyph.
|
|
1512
1541
|
warnings.push(
|
|
1513
|
-
...
|
|
1514
|
-
(
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1542
|
+
...[
|
|
1543
|
+
...validateNavTargets(project.graph.navigation, navTargetRoutes),
|
|
1544
|
+
...validateSearchPopularIcons(config.search.popular),
|
|
1545
|
+
].map((diagnostic) =>
|
|
1546
|
+
diagnostic.suggestion
|
|
1547
|
+
? `${diagnostic.message} ${diagnostic.suggestion}`
|
|
1548
|
+
: diagnostic.message
|
|
1518
1549
|
)
|
|
1519
1550
|
);
|
|
1520
1551
|
|
package/src/astro/index.ts
CHANGED
package/src/astro/pages.ts
CHANGED
|
@@ -6,11 +6,25 @@ import type { BlumePageRoute } from "./integration.ts";
|
|
|
6
6
|
|
|
7
7
|
const PAGE_GLOB = ["**/*.astro"];
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Astro's routing convention: a file or folder whose name starts with `_` is a
|
|
11
|
+
* private partial — importable (shared layouts, home-page sections), but never
|
|
12
|
+
* built into a route. Blume injects pages itself, so it must reproduce the same
|
|
13
|
+
* exclusion or every `pages/_home/Hero.astro`-style component ships as an HTML
|
|
14
|
+
* page.
|
|
15
|
+
*/
|
|
16
|
+
const isPrivatePage = (rel: string): boolean =>
|
|
17
|
+
rel.split("/").some((segment) => segment.startsWith("_"));
|
|
18
|
+
|
|
9
19
|
/** Map discovered page files to routes; shared by the async/sync discoverers. */
|
|
10
20
|
const toPageRoutes = (pagesRoot: string, files: string[]): BlumePageRoute[] => {
|
|
11
21
|
files.sort();
|
|
12
|
-
|
|
22
|
+
const routes: BlumePageRoute[] = [];
|
|
23
|
+
for (const file of files) {
|
|
13
24
|
const rel = relative(pagesRoot, file);
|
|
25
|
+
if (isPrivatePage(rel)) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
14
28
|
const withoutExt = rel.slice(0, rel.length - extname(rel).length);
|
|
15
29
|
const parts = withoutExt.split("/");
|
|
16
30
|
// Only a trailing `index` maps to its parent dir; a folder literally named
|
|
@@ -19,8 +33,9 @@ const toPageRoutes = (pagesRoot: string, files: string[]): BlumePageRoute[] => {
|
|
|
19
33
|
parts.pop();
|
|
20
34
|
}
|
|
21
35
|
const pattern = parts.length === 0 ? "/" : `/${parts.join("/")}`;
|
|
22
|
-
|
|
23
|
-
}
|
|
36
|
+
routes.push({ entrypoint: file, pattern });
|
|
37
|
+
}
|
|
38
|
+
return routes;
|
|
24
39
|
};
|
|
25
40
|
|
|
26
41
|
/**
|
package/src/astro/templates.ts
CHANGED
|
@@ -10,7 +10,7 @@ import type { ResolvedConfig } from "../core/schema.ts";
|
|
|
10
10
|
import { BLUME_IGNORE_DIRS } from "../core/sources/watch.ts";
|
|
11
11
|
import { trimChar } from "../core/trim.ts";
|
|
12
12
|
import type { ProjectContext } from "../core/types.ts";
|
|
13
|
-
import {
|
|
13
|
+
import { applyBaseToAstroRedirects } from "../deploy/redirects.ts";
|
|
14
14
|
import { hasScalarReferences } from "../openapi/references.ts";
|
|
15
15
|
import { searchProviderMeta } from "../search/providers.ts";
|
|
16
16
|
import { buildFontEntries } from "../theme/fonts.ts";
|
|
@@ -166,11 +166,16 @@ export const runtimeDependencies = (options: {
|
|
|
166
166
|
* static-prerender Vite environments.
|
|
167
167
|
*
|
|
168
168
|
* Two reasons a dep lands here:
|
|
169
|
-
* -
|
|
170
|
-
* platform-specific `.node` binding via
|
|
171
|
-
* Bundling it relocates `import.meta.url`
|
|
172
|
-
* ("Cannot find native binding") on other
|
|
173
|
-
* runner), so it must resolve from
|
|
169
|
+
* - `takumi-js` (OG image rendering) loads `@takumi-rs/core`, a native NAPI
|
|
170
|
+
* addon that finds its platform-specific `.node` binding via
|
|
171
|
+
* `createRequire(import.meta.url)`. Bundling it relocates `import.meta.url`
|
|
172
|
+
* and breaks the binding lookup ("Cannot find native binding") on other
|
|
173
|
+
* platforms (e.g. the Linux CI runner), so it must resolve from
|
|
174
|
+
* `node_modules` at runtime instead. The prerender env matches these by
|
|
175
|
+
* exact specifier, so every entry point Blume imports has to be listed:
|
|
176
|
+
* the bare `takumi-js` (render) plus `takumi-js/helpers` (the `googleFonts`
|
|
177
|
+
* OG-font loader). The `@takumi-rs/*` packages are listed too so the native
|
|
178
|
+
* backend is never pulled into a chunk down any transitive path.
|
|
174
179
|
* - The rest are pure-JS packages kept external so an isolated linker (Bun's
|
|
175
180
|
* `isolated` mode, pnpm) doesn't bundle their symlinked store copies. When
|
|
176
181
|
* Vite bundles such a package but leaves its own `node_modules` child
|
|
@@ -190,10 +195,13 @@ const RENDER_EXTERNAL_DEPS = [
|
|
|
190
195
|
"@shikijs/transformers",
|
|
191
196
|
"@takumi-rs/core",
|
|
192
197
|
"@takumi-rs/helpers",
|
|
198
|
+
"@takumi-rs/wasm",
|
|
193
199
|
"github-slugger",
|
|
194
200
|
"katex",
|
|
195
201
|
"shiki",
|
|
196
202
|
"simple-icons",
|
|
203
|
+
"takumi-js",
|
|
204
|
+
"takumi-js/helpers",
|
|
197
205
|
"zod",
|
|
198
206
|
];
|
|
199
207
|
|
|
@@ -212,6 +220,21 @@ const renderUserAliases = (
|
|
|
212
220
|
const astroOutDir = (context: ProjectContext): string =>
|
|
213
221
|
context.distDir ?? `${context.root}/dist`;
|
|
214
222
|
|
|
223
|
+
/**
|
|
224
|
+
* The root a deploy adapter is shown, in place of the `.blume` runtime Astro
|
|
225
|
+
* actually roots at. Adapters assume `outDir` is `<root>/dist` and resolve their
|
|
226
|
+
* own output (and Vercel's dependency trace) against `root`, so the root implied
|
|
227
|
+
* by Blume's `outDir` is the one that keeps that assumption true. See
|
|
228
|
+
* {@link withAdapterRoot}.
|
|
229
|
+
*
|
|
230
|
+
* For a normal build that is the project root (`<project>/dist` -> `<project>`).
|
|
231
|
+
* For a relocated runtime (`blume build --isolated`) it is the runtime dir
|
|
232
|
+
* itself (`<runtime>/dist` -> `<runtime>`), keeping a verify build's adapter
|
|
233
|
+
* output self-contained instead of overwriting the real `.vercel/output`.
|
|
234
|
+
*/
|
|
235
|
+
const adapterRoot = (context: ProjectContext): string =>
|
|
236
|
+
dirname(astroOutDir(context));
|
|
237
|
+
|
|
215
238
|
/**
|
|
216
239
|
* Excludes Vite's pre-bundled dep cache from @vitejs/plugin-react. Astro's
|
|
217
240
|
* react() replaces the plugin's default `/node_modules/` exclude with just
|
|
@@ -296,8 +319,17 @@ export const astroConfigTemplate = (options: {
|
|
|
296
319
|
}
|
|
297
320
|
return ADAPTER_OPTIONS[deployment.adapter] ?? "";
|
|
298
321
|
})();
|
|
322
|
+
// Vercel resolves its Build Output tree and its `@vercel/nft` dependency
|
|
323
|
+
// trace against the Astro root, which for Blume is the hidden `.blume`
|
|
324
|
+
// runtime — leaving the traced function without its chunks or node_modules.
|
|
325
|
+
// The other adapters emit into `outDir` (cloudflare, node) or are surfaced
|
|
326
|
+
// afterwards (netlify), so none of them read `root` this way.
|
|
327
|
+
const adapterExpr =
|
|
328
|
+
deployment.adapter === "vercel"
|
|
329
|
+
? `withAdapterRoot(adapter(${adapterArgs}), ${JSON.stringify(adapterRoot(context))})`
|
|
330
|
+
: `adapter(${adapterArgs})`;
|
|
299
331
|
const adapterOption =
|
|
300
|
-
server && deployment.adapter ? `\n adapter:
|
|
332
|
+
server && deployment.adapter ? `\n adapter: ${adapterExpr},` : "";
|
|
301
333
|
|
|
302
334
|
const siteOption = deployment.site
|
|
303
335
|
? `\n site: ${JSON.stringify(deployment.site)},`
|
|
@@ -320,10 +352,12 @@ export const astroConfigTemplate = (options: {
|
|
|
320
352
|
: "";
|
|
321
353
|
|
|
322
354
|
// Base the redirect paths the same way routes are based, so a redirect lands
|
|
323
|
-
// under `basePath` too. Astro layers its own `base` (deployment.base)
|
|
324
|
-
|
|
355
|
+
// under `basePath` too. Astro layers its own `base` (deployment.base) onto
|
|
356
|
+
// `from` when matching, but never onto `to` — see applyBaseToAstroRedirects.
|
|
357
|
+
const basedRedirects = applyBaseToAstroRedirects(
|
|
325
358
|
config.redirects,
|
|
326
|
-
config.basePath
|
|
359
|
+
config.basePath,
|
|
360
|
+
deployment.base ?? ""
|
|
327
361
|
);
|
|
328
362
|
const redirectsOption =
|
|
329
363
|
basedRedirects.length > 0
|
|
@@ -365,7 +399,13 @@ export const astroConfigTemplate = (options: {
|
|
|
365
399
|
const svelteImport = needsSvelte
|
|
366
400
|
? `import svelte from "@astrojs/svelte";\n`
|
|
367
401
|
: "";
|
|
368
|
-
const
|
|
402
|
+
const blumeImports = [
|
|
403
|
+
"blumeIntegration",
|
|
404
|
+
"prerenderDepsPlugin",
|
|
405
|
+
"serverAppResolvePlugin",
|
|
406
|
+
...(adapterOption.includes("withAdapterRoot") ? ["withAdapterRoot"] : []),
|
|
407
|
+
];
|
|
408
|
+
const blumeImport = `import { ${blumeImports.join(", ")} } from "blume/astro";\n`;
|
|
369
409
|
|
|
370
410
|
// Twoslash runs first, before the always-on transformers, but only on fences
|
|
371
411
|
// with the `twoslash` meta (explicitTrigger) — so it's opt-in per block with
|
|
@@ -1080,6 +1120,7 @@ export async function GET({ props }: { props: { title: string } }) {
|
|
|
1080
1120
|
accent: data.config.og.palette?.accent ?? data.config.theme.accent.light,
|
|
1081
1121
|
brand: data.config.title,
|
|
1082
1122
|
description: data.config.description,
|
|
1123
|
+
fonts: data.config.og.fonts,
|
|
1083
1124
|
logo: data.config.og.logo,
|
|
1084
1125
|
palette: data.config.og.palette,
|
|
1085
1126
|
repo: repoSlug,
|
|
@@ -1475,6 +1516,18 @@ const toTime = (value: string | null | undefined) => {
|
|
|
1475
1516
|
return Number.isNaN(date.getTime()) ? 0 : date.getTime();
|
|
1476
1517
|
};
|
|
1477
1518
|
|
|
1519
|
+
// The changelog is an unlocalized route, so its chrome renders in the default
|
|
1520
|
+
// locale's dictionary and direction (\`data.ui\` is the default locale's resolved
|
|
1521
|
+
// dictionary), mirroring the catch-all's locale wiring.
|
|
1522
|
+
const i18n = data.config.i18n;
|
|
1523
|
+
const localeMeta = i18n
|
|
1524
|
+
? i18n.locales.find((l) => l.code === i18n.defaultLocale)
|
|
1525
|
+
: null;
|
|
1526
|
+
const dir = localeMeta?.dir ?? "ltr";
|
|
1527
|
+
const htmlLang = i18n ? i18n.defaultLocale : "en";
|
|
1528
|
+
|
|
1529
|
+
// Formatted in the same locale as the chrome, and in UTC, to match the
|
|
1530
|
+
// per-page "last updated" stamp.
|
|
1478
1531
|
const formatDate = (value: string | null | undefined) => {
|
|
1479
1532
|
if (!value) {
|
|
1480
1533
|
return;
|
|
@@ -1482,7 +1535,7 @@ const formatDate = (value: string | null | undefined) => {
|
|
|
1482
1535
|
const date = new Date(value);
|
|
1483
1536
|
return Number.isNaN(date.getTime())
|
|
1484
1537
|
? undefined
|
|
1485
|
-
: new Intl.DateTimeFormat(
|
|
1538
|
+
: new Intl.DateTimeFormat(htmlLang, {
|
|
1486
1539
|
dateStyle: "long",
|
|
1487
1540
|
timeZone: "UTC",
|
|
1488
1541
|
}).format(date);
|
|
@@ -1579,16 +1632,6 @@ const base = data.config.site ? data.config.site.replace(/\\/$/, "") : null;
|
|
|
1579
1632
|
const basedRoute = withBase("/changelog");
|
|
1580
1633
|
const canonical = base ? base + basedRoute : null;
|
|
1581
1634
|
|
|
1582
|
-
// The changelog is an unlocalized route, so its chrome renders in the default
|
|
1583
|
-
// locale's dictionary and direction (\`data.ui\` is the default locale's resolved
|
|
1584
|
-
// dictionary), mirroring the catch-all's locale wiring.
|
|
1585
|
-
const i18n = data.config.i18n;
|
|
1586
|
-
const localeMeta = i18n
|
|
1587
|
-
? i18n.locales.find((l) => l.code === i18n.defaultLocale)
|
|
1588
|
-
: null;
|
|
1589
|
-
const dir = localeMeta?.dir ?? "ltr";
|
|
1590
|
-
const htmlLang = i18n ? i18n.defaultLocale : "en";
|
|
1591
|
-
|
|
1592
1635
|
// The page chrome (h1, title, description) comes from the same translatable
|
|
1593
1636
|
// \`changelog\` group as the reveal button; optional chaining tolerates a
|
|
1594
1637
|
// not-yet-regenerated data snapshot from before these keys existed.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
|
|
5
|
+
import { join } from "pathe";
|
|
6
|
+
|
|
7
|
+
import { reportJson } from "./report.ts";
|
|
8
|
+
import type { AuditResult } from "./run.ts";
|
|
9
|
+
|
|
10
|
+
/** A coding agent CLI the audit can hand its findings to (`--claude`, `--codex`). */
|
|
11
|
+
export interface AgentCli {
|
|
12
|
+
/** The executable to look up on PATH. */
|
|
13
|
+
bin: string;
|
|
14
|
+
/** How to install it, shown when the executable is missing. */
|
|
15
|
+
install: string;
|
|
16
|
+
/** Display name for messages. */
|
|
17
|
+
name: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type AgentKind = "claude" | "codex";
|
|
21
|
+
|
|
22
|
+
export const AGENTS: Record<AgentKind, AgentCli> = {
|
|
23
|
+
claude: {
|
|
24
|
+
bin: "claude",
|
|
25
|
+
install: "npm install -g @anthropic-ai/claude-code",
|
|
26
|
+
name: "Claude Code",
|
|
27
|
+
},
|
|
28
|
+
codex: {
|
|
29
|
+
bin: "codex",
|
|
30
|
+
install: "npm install -g @openai/codex",
|
|
31
|
+
name: "Codex",
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Write the full JSON report where the agent can read it. A file rather than
|
|
37
|
+
* inline prompt text: a large site's report can exceed the platform's argv
|
|
38
|
+
* limit, and the JSON already carries every finding untruncated — the terminal
|
|
39
|
+
* report previews three pages per check, the file never does.
|
|
40
|
+
*/
|
|
41
|
+
export const writeAgentReport = async (
|
|
42
|
+
result: AuditResult,
|
|
43
|
+
root: string
|
|
44
|
+
): Promise<string> => {
|
|
45
|
+
const dir = await mkdtemp(join(tmpdir(), "blume-audit-"));
|
|
46
|
+
const path = join(dir, "report.json");
|
|
47
|
+
await writeFile(path, reportJson(result, root));
|
|
48
|
+
return path;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** The handoff prompt: where the report is, how to read it, and the ground rules. */
|
|
52
|
+
export const fixPrompt = (reportPath: string): string =>
|
|
53
|
+
`Fix the issues found by \`blume audit\` in this project.
|
|
54
|
+
|
|
55
|
+
The full audit report is at ${reportPath}. It is JSON: each entry in \`diagnostics\` is one finding, with the check \`code\`, a \`message\` explaining what is wrong, the affected page \`url\`, the source \`file\` to edit (relative to the current directory, with a \`line\` when the finding points at a specific front matter key), and a \`suggestion\` describing the fix.
|
|
56
|
+
|
|
57
|
+
Work through every finding:
|
|
58
|
+
1. Read the report and group the findings by \`file\`.
|
|
59
|
+
2. Apply each finding's \`suggestion\` by editing the named source file — most fixes are front matter edits at the cited line.
|
|
60
|
+
3. Never fix a finding by deleting a page, removing content, or hiding it from the audit; if a finding genuinely needs a human decision, leave it and say so in your summary.
|
|
61
|
+
|
|
62
|
+
When you are done, run \`blume build\` and then \`blume audit\` to verify, and repeat until the audit reports no issues.`;
|
|
63
|
+
|
|
64
|
+
const spawnAgent = (
|
|
65
|
+
command: string,
|
|
66
|
+
args: string[],
|
|
67
|
+
shell: boolean
|
|
68
|
+
): Promise<number> =>
|
|
69
|
+
// oxlint-disable-next-line promise/avoid-new -- adapt spawn's event callbacks
|
|
70
|
+
new Promise((resolve, reject) => {
|
|
71
|
+
const child = spawn(command, args, { shell, stdio: "inherit" });
|
|
72
|
+
child.once("error", reject);
|
|
73
|
+
child.once("close", (code) => resolve(code ?? 1));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* cmd.exe reports a missing executable through this exit code instead of a
|
|
78
|
+
* spawn error, so a shell launch can't rely on the `error` event for the
|
|
79
|
+
* "not installed" diagnosis.
|
|
80
|
+
*/
|
|
81
|
+
export const WINDOWS_COMMAND_NOT_FOUND = 9009;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Run the agent CLI interactively with the handoff prompt, inheriting the
|
|
85
|
+
* terminal so the user watches and steers the fixes rather than granting a
|
|
86
|
+
* headless process blanket write access. Resolves with the agent's exit code;
|
|
87
|
+
* rejects when the executable isn't on PATH.
|
|
88
|
+
*
|
|
89
|
+
* On Windows, npm installs agent CLIs as `.cmd` shims, which Node refuses to
|
|
90
|
+
* spawn without a shell — and cmd.exe cannot carry the multi-line prompt as an
|
|
91
|
+
* argument (a newline ends the command). So there the prompt is written to a
|
|
92
|
+
* file next to the report and handed over via a one-line pointer that survives
|
|
93
|
+
* cmd.exe quoting; a missing executable surfaces as
|
|
94
|
+
* {@link WINDOWS_COMMAND_NOT_FOUND} rather than a rejection.
|
|
95
|
+
*/
|
|
96
|
+
export const launchAgent = async (
|
|
97
|
+
bin: string,
|
|
98
|
+
prompt: string,
|
|
99
|
+
platform: NodeJS.Platform = process.platform
|
|
100
|
+
): Promise<number> => {
|
|
101
|
+
if (platform !== "win32") {
|
|
102
|
+
return await spawnAgent(bin, [prompt], false);
|
|
103
|
+
}
|
|
104
|
+
const dir = await mkdtemp(join(tmpdir(), "blume-audit-"));
|
|
105
|
+
const promptPath = join(dir, "prompt.md");
|
|
106
|
+
await writeFile(promptPath, prompt);
|
|
107
|
+
// Double quotes are the one grouping cmd.exe respects; neither the temp
|
|
108
|
+
// path nor the fixed pointer text can contain one.
|
|
109
|
+
return await spawnAgent(
|
|
110
|
+
`"${bin}" "Read ${promptPath} and follow its instructions exactly."`,
|
|
111
|
+
[],
|
|
112
|
+
true
|
|
113
|
+
);
|
|
114
|
+
};
|