blume 0.7.0 → 0.8.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 +618 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/dist/cli/index.js +1487 -360
- package/dist/cli/index.js.map +91 -85
- package/dist/types/ai/component-markdown.d.ts +34 -0
- package/dist/types/components/content/youtube.d.ts +18 -0
- package/dist/types/core/base-path.d.ts +9 -0
- package/dist/types/core/config-input.d.ts +36 -2
- package/dist/types/core/config.d.ts +3 -2
- package/dist/types/core/data.d.ts +2 -0
- package/dist/types/core/i18n-ui.d.ts +476 -132
- package/dist/types/core/schema.d.ts +216 -145
- package/dist/types/index.d.ts +1 -0
- package/dist/types/openapi/references.d.ts +60 -0
- package/docs/01-quickstart.mdx +5 -2
- package/docs/02-deployment.mdx +8 -8
- package/docs/03-faq.mdx +46 -16
- package/docs/advanced/custom-pages.mdx +1 -1
- package/docs/advanced/skills.mdx +1 -1
- package/docs/configuration/ai.mdx +49 -10
- package/docs/configuration/customization.mdx +11 -0
- package/docs/configuration/index.mdx +27 -3
- package/docs/configuration/seo.mdx +2 -2
- package/docs/content/components.mdx +1 -1
- package/docs/content/i18n.mdx +1 -1
- package/docs/content/navigation.mdx +3 -3
- package/docs/content/sources.mdx +1 -1
- package/docs/content/syntax.mdx +4 -2
- package/docs/index.mdx +2 -2
- package/docs/reference/cli.mdx +8 -6
- package/package.json +14 -4
- package/skills/blume/SKILL.md +5 -3
- package/skills/blume-update-docs/SKILL.md +3 -2
- package/src/ai/agent-readability.ts +9 -8
- package/src/ai/ask-context.ts +7 -2
- package/src/ai/ask-data.ts +3 -0
- package/src/ai/component-markdown.ts +461 -0
- package/src/ai/llms.ts +135 -26
- package/src/ai/markdown.ts +35 -6
- package/src/ai/mcp/data.ts +25 -4
- package/src/ai/mcp/discovery.ts +10 -3
- package/src/ai/mcp/server.ts +21 -7
- package/src/ai/visibility.ts +74 -0
- package/src/astro/component-slots.ts +11 -1
- package/src/astro/generate.ts +76 -45
- package/src/astro/integration.ts +1 -1
- package/src/astro/markdown-negotiation.ts +1 -1
- package/src/astro/pages.ts +81 -19
- package/src/astro/templates.ts +99 -12
- package/src/blume-modules.d.ts +8 -0
- package/src/cli/commands/build.ts +99 -19
- package/src/cli/commands/check.ts +1 -1
- package/src/cli/commands/dev.ts +26 -5
- package/src/cli/commands/eject.ts +47 -19
- package/src/cli/commands/init.ts +120 -180
- package/src/cli/commands/preview.ts +4 -1
- package/src/cli/commands/validate.ts +43 -2
- package/src/cli/dev-lock.ts +8 -4
- package/src/cli/eject-scripts.ts +72 -0
- package/src/cli/env.ts +15 -5
- package/src/cli/init/questions.ts +158 -0
- package/src/cli/init/scaffold.ts +380 -0
- package/src/components/content/AccordionItem.astro +23 -4
- package/src/components/content/Badge.astro +3 -1
- package/src/components/content/Card.astro +4 -2
- package/src/components/content/Step.astro +10 -1
- package/src/components/content/Tabs.astro +15 -3
- package/src/components/content/Tile.astro +2 -1
- package/src/components/content/Tooltip.astro +3 -1
- package/src/components/content/Update.astro +9 -2
- package/src/components/content/auto-type-table.ts +7 -1
- package/src/components/content/base-href.ts +33 -0
- package/src/components/content/changelog-element.ts +9 -2
- package/src/components/content/mermaid-element.ts +7 -2
- package/src/components/islands/AskAI.astro +5 -2
- package/src/components/islands/ask-ai.tsx +56 -6
- package/src/components/islands/hooks.ts +28 -8
- package/src/components/layout/Banner.astro +10 -2
- package/src/components/layout/Header.astro +13 -4
- package/src/components/layout/Logo.astro +11 -3
- package/src/components/layout/NavTree.astro +17 -3
- package/src/components/layout/PageActions.astro +25 -10
- package/src/components/layout/PageLayout.astro +45 -8
- package/src/components/layout/ReferenceLayout.astro +8 -1
- package/src/components/layout/RootLayout.astro +67 -9
- package/src/components/layout/Search.astro +94 -22
- package/src/components/layout/search/algolia.ts +11 -2
- package/src/components/layout/search/endpoint.ts +11 -5
- package/src/components/layout/search/orama-cloud.ts +8 -2
- package/src/components/layout/search/types.ts +5 -1
- package/src/components/layout/search/typesense.ts +4 -1
- package/src/components/layout/toc-element.ts +1 -1
- package/src/components/openapi/ApiTagOperations.astro +2 -1
- package/src/components/openapi/Operation.astro +47 -40
- package/src/components/openapi/RequestPanel.astro +1 -1
- package/src/components/openapi/helpers.ts +71 -3
- package/src/components/openapi/panel.ts +1 -1
- package/src/core/base-path.ts +24 -0
- package/src/core/builtin-tags.ts +2 -0
- package/src/core/config-input.ts +37 -2
- package/src/core/config.ts +3 -2
- package/src/core/data.ts +2 -0
- package/src/core/graph.ts +15 -5
- package/src/core/i18n-ui.ts +45 -0
- package/src/core/last-modified.ts +13 -6
- package/src/core/links.ts +32 -8
- package/src/core/navigation.ts +29 -4
- package/src/core/package-json.ts +17 -2
- package/src/core/project-graph.ts +15 -6
- package/src/core/schema.ts +36 -2
- package/src/core/sources/assets.ts +6 -1
- package/src/core/sources/filesystem.ts +4 -0
- package/src/core/sources/mdx-remote.ts +23 -14
- package/src/core/sources/normalize.ts +152 -50
- package/src/core/sources/notion.ts +8 -8
- package/src/core/ui-packs/ar.ts +1 -0
- package/src/core/ui-packs/bg.ts +1 -0
- package/src/core/ui-packs/bn.ts +1 -0
- package/src/core/ui-packs/ca.ts +1 -0
- package/src/core/ui-packs/cs.ts +1 -0
- package/src/core/ui-packs/da.ts +1 -0
- package/src/core/ui-packs/de.ts +1 -0
- package/src/core/ui-packs/el.ts +1 -0
- package/src/core/ui-packs/es.ts +1 -0
- package/src/core/ui-packs/fa.ts +1 -0
- package/src/core/ui-packs/fi.ts +1 -0
- package/src/core/ui-packs/fr.ts +2 -1
- package/src/core/ui-packs/he.ts +1 -0
- package/src/core/ui-packs/hi.ts +1 -0
- package/src/core/ui-packs/hr.ts +1 -0
- package/src/core/ui-packs/hu.ts +1 -0
- package/src/core/ui-packs/id.ts +1 -0
- package/src/core/ui-packs/it.ts +1 -0
- package/src/core/ui-packs/ja.ts +1 -0
- package/src/core/ui-packs/ko.ts +1 -0
- package/src/core/ui-packs/nl.ts +1 -0
- package/src/core/ui-packs/no.ts +1 -0
- package/src/core/ui-packs/pl.ts +1 -0
- package/src/core/ui-packs/pt-br.ts +1 -0
- package/src/core/ui-packs/pt.ts +1 -0
- package/src/core/ui-packs/ro.ts +1 -0
- package/src/core/ui-packs/ru.ts +1 -0
- package/src/core/ui-packs/sk.ts +1 -0
- package/src/core/ui-packs/sr.ts +1 -0
- package/src/core/ui-packs/sv.ts +1 -0
- package/src/core/ui-packs/th.ts +1 -0
- package/src/core/ui-packs/tr.ts +1 -0
- package/src/core/ui-packs/uk.ts +1 -0
- package/src/core/ui-packs/vi.ts +1 -0
- package/src/core/ui-packs/zh-tw.ts +1 -0
- package/src/core/ui-packs/zh.ts +1 -0
- package/src/deploy/adapter-output.ts +18 -8
- package/src/deploy/redirects.ts +7 -2
- package/src/deploy/sitemap.ts +53 -11
- package/src/index.ts +5 -0
- package/src/markdown/base-links.ts +10 -8
- package/src/markdown/index.ts +15 -3
- package/src/markdown/inline-code.ts +7 -2
- package/src/markdown/package-commands.ts +10 -4
- package/src/openapi/model.ts +12 -4
- package/src/openapi/parse.ts +21 -0
- package/src/openapi/references.ts +38 -8
- package/src/openapi/source.ts +59 -10
- package/src/registry/eject.ts +184 -12
- package/src/registry/registry.ts +0 -3
- package/src/search/documents.ts +34 -2
- package/src/seo/jsonld.ts +13 -12
package/src/astro/generate.ts
CHANGED
|
@@ -54,7 +54,12 @@ import { planComponentSlots } from "./component-slots.ts";
|
|
|
54
54
|
import type { ComponentSlotPlan } from "./component-slots.ts";
|
|
55
55
|
import { discoverExamples } from "./examples.ts";
|
|
56
56
|
import { discoverIslands } from "./islands.ts";
|
|
57
|
-
import {
|
|
57
|
+
import {
|
|
58
|
+
customOgRoutes,
|
|
59
|
+
discoverPages,
|
|
60
|
+
hasGeneratedChangelog,
|
|
61
|
+
routeIsTaken,
|
|
62
|
+
} from "./pages.ts";
|
|
58
63
|
import {
|
|
59
64
|
askEndpointTemplate,
|
|
60
65
|
astroConfigTemplate,
|
|
@@ -377,6 +382,16 @@ const ISLAND_FRAMEWORK_DEPS: Record<string, string> = {
|
|
|
377
382
|
vue: "@astrojs/vue",
|
|
378
383
|
};
|
|
379
384
|
|
|
385
|
+
/**
|
|
386
|
+
* Adapter package the project must install itself for each deployment
|
|
387
|
+
* platform whose adapter Blume doesn't ship. Node and Vercel ship with Blume,
|
|
388
|
+
* so they never need this.
|
|
389
|
+
*/
|
|
390
|
+
const DEPLOYMENT_ADAPTER_DEPS: Record<string, string> = {
|
|
391
|
+
cloudflare: "@astrojs/cloudflare",
|
|
392
|
+
netlify: "@astrojs/netlify",
|
|
393
|
+
};
|
|
394
|
+
|
|
380
395
|
/**
|
|
381
396
|
* Warn when a Vue/Svelte island is present but its Astro integration isn't
|
|
382
397
|
* installed — Vite would otherwise fail opaquely on the generated config import.
|
|
@@ -398,6 +413,34 @@ const islandFrameworkWarnings = (
|
|
|
398
413
|
return warnings;
|
|
399
414
|
};
|
|
400
415
|
|
|
416
|
+
/**
|
|
417
|
+
* Warn when the resolved server-output adapter is one the project must install
|
|
418
|
+
* itself (Netlify/Cloudflare; Node and Vercel ship with Blume). The generated
|
|
419
|
+
* astro.config.mjs imports the adapter package directly — and on those
|
|
420
|
+
* platforms the adapter is even auto-selected from env vars — so warn early
|
|
421
|
+
* rather than let the build die with an opaque ERR_MODULE_NOT_FOUND from the
|
|
422
|
+
* hidden generated config. Availability mirrors the search-provider check: a
|
|
423
|
+
* dep resolves from the project root or from the Blume package itself.
|
|
424
|
+
*/
|
|
425
|
+
const deploymentAdapterWarnings = (
|
|
426
|
+
deployment: ResolvedConfig["deployment"],
|
|
427
|
+
root: string
|
|
428
|
+
): string[] => {
|
|
429
|
+
const dep =
|
|
430
|
+
deployment.output === "server" && deployment.adapter
|
|
431
|
+
? DEPLOYMENT_ADAPTER_DEPS[deployment.adapter]
|
|
432
|
+
: undefined;
|
|
433
|
+
if (
|
|
434
|
+
dep &&
|
|
435
|
+
!(canResolveFrom(root, dep) || canResolveFrom(packageRoot(), dep))
|
|
436
|
+
) {
|
|
437
|
+
return [
|
|
438
|
+
`Deployment adapter "${deployment.adapter}" needs "${dep}", which isn't installed. Run \`npm install ${dep}\` (or your package manager's equivalent).`,
|
|
439
|
+
];
|
|
440
|
+
}
|
|
441
|
+
return [];
|
|
442
|
+
};
|
|
443
|
+
|
|
401
444
|
/** Absolute path to the configured `examples.css`, or null when unset. */
|
|
402
445
|
const examplesCssFile = (root: string, config: ResolvedConfig): string | null =>
|
|
403
446
|
config.examples.css ? join(root, config.examples.css) : null;
|
|
@@ -461,16 +504,24 @@ export const detectNeedsReact = async (root: string): Promise<boolean> => {
|
|
|
461
504
|
return matches.length > 0;
|
|
462
505
|
};
|
|
463
506
|
|
|
507
|
+
/** Block math (`$$…$$`) or an explicitly authored `<Math …>` component. */
|
|
508
|
+
const containsMath = (content: string): boolean =>
|
|
509
|
+
content.includes("$$") || content.includes("<Math");
|
|
510
|
+
|
|
464
511
|
/**
|
|
465
|
-
* Detect whether the project
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
512
|
+
* Detect whether the project can render math: block math (`$$…$$`) or an
|
|
513
|
+
* explicit `<Math>` tag in any local `.md`/`.mdx`, or in staged (non-filesystem)
|
|
514
|
+
* source bodies. Drives whether the generated runtime imports the `<Math>`
|
|
515
|
+
* component and KaTeX's stylesheet, so a math-free site ships no KaTeX CSS.
|
|
516
|
+
* Math parsing itself is always on but block-only, so one of those literals is
|
|
517
|
+
* a necessary condition — no false negatives. A stray `$$` (e.g. inside a code
|
|
518
|
+
* fence) merely over-includes the idempotent import, which is harmless.
|
|
471
519
|
*/
|
|
472
|
-
export const detectUsesMath = async (
|
|
473
|
-
|
|
520
|
+
export const detectUsesMath = async (
|
|
521
|
+
root: string,
|
|
522
|
+
staged: Iterable<string> = []
|
|
523
|
+
): Promise<boolean> => {
|
|
524
|
+
const files = await glob(["**/*.{md,mdx}"], {
|
|
474
525
|
cwd: root,
|
|
475
526
|
ignore: ["**/node_modules/**", "**/.blume/**", "**/dist/**"],
|
|
476
527
|
onlyFiles: true,
|
|
@@ -478,7 +529,7 @@ export const detectUsesMath = async (root: string): Promise<boolean> => {
|
|
|
478
529
|
const contents = await Promise.all(
|
|
479
530
|
files.map((file) => readOptional(join(root, file)))
|
|
480
531
|
);
|
|
481
|
-
return contents.some(
|
|
532
|
+
return [...contents, ...staged].some(containsMath);
|
|
482
533
|
};
|
|
483
534
|
|
|
484
535
|
const writeIfChanged = async (
|
|
@@ -792,6 +843,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
|
|
|
792
843
|
? { suggestions: config.ai.ask.suggestions }
|
|
793
844
|
: null,
|
|
794
845
|
banner: resolveBanner(config),
|
|
846
|
+
basePath: config.basePath,
|
|
795
847
|
codeThemes: config.markdown.codeBlocks.theme,
|
|
796
848
|
codeWrap: config.markdown.code.wrap,
|
|
797
849
|
description: config.description,
|
|
@@ -934,6 +986,7 @@ const writeMcpFiles = async (
|
|
|
934
986
|
}
|
|
935
987
|
const data = await buildMcpData(project);
|
|
936
988
|
const discoveryInput = {
|
|
989
|
+
base: data.base,
|
|
937
990
|
name: data.name,
|
|
938
991
|
route: plan.route,
|
|
939
992
|
site: data.site,
|
|
@@ -1012,32 +1065,6 @@ export interface GenerateResult {
|
|
|
1012
1065
|
warnings: string[];
|
|
1013
1066
|
}
|
|
1014
1067
|
|
|
1015
|
-
/**
|
|
1016
|
-
* Whether to generate the default `/changelog` index. Written when there are
|
|
1017
|
-
* `type: changelog` entries — or when a release-backed changelog source is
|
|
1018
|
-
* configured, so its route (and any nav tab pointing at it) still resolves to an
|
|
1019
|
-
* empty timeline on a build where the source could not be fetched (e.g. CI
|
|
1020
|
-
* without a token). Skipped when a user content page or a custom `.astro` page
|
|
1021
|
-
* already owns `/changelog`.
|
|
1022
|
-
*/
|
|
1023
|
-
const shouldGenerateChangelog = (
|
|
1024
|
-
project: BlumeProject,
|
|
1025
|
-
userPages: { pattern: string }[]
|
|
1026
|
-
): boolean => {
|
|
1027
|
-
const hasChangelog = project.graph.pages.some(
|
|
1028
|
-
(page) =>
|
|
1029
|
-
page.contentType === "changelog" &&
|
|
1030
|
-
!(page.meta.draft || page.meta.sidebar.hidden)
|
|
1031
|
-
);
|
|
1032
|
-
const hasChangelogSource = (project.config.content.sources ?? []).some(
|
|
1033
|
-
(source) => source.type === "github-releases"
|
|
1034
|
-
);
|
|
1035
|
-
return (
|
|
1036
|
-
(hasChangelog || hasChangelogSource) &&
|
|
1037
|
-
!routeIsTaken(userPages, project.graph.pages, "/changelog")
|
|
1038
|
-
);
|
|
1039
|
-
};
|
|
1040
|
-
|
|
1041
1068
|
/**
|
|
1042
1069
|
* Statically analyze the user's `components.ts` (never executing it) and plan the
|
|
1043
1070
|
* generated `components.ts` module plus any hydration wrappers. Returns the plan
|
|
@@ -1097,6 +1124,10 @@ export const generateRuntime = async (
|
|
|
1097
1124
|
const askEnabled = config.ai.ask?.enabled ?? false;
|
|
1098
1125
|
const exportPdf = config.export.pdf;
|
|
1099
1126
|
const exportEpub = config.export.epub;
|
|
1127
|
+
// Staged (non-filesystem) sources materialize into `.blume/content`; keyed by
|
|
1128
|
+
// entryId so i18n duplicates of one entry write a single file. Collected here
|
|
1129
|
+
// so math detection also sees staged bodies (they never live under root).
|
|
1130
|
+
const staged = collectStaged(project);
|
|
1100
1131
|
// Statically analyze `components.ts` overrides (never executed): drives the
|
|
1101
1132
|
// `islands` group, hydration on layout/mdx overrides, string-path resolution,
|
|
1102
1133
|
// and the "framework component with no client mode" diagnostic. Independent of
|
|
@@ -1113,7 +1144,7 @@ export const generateRuntime = async (
|
|
|
1113
1144
|
] = await Promise.all([
|
|
1114
1145
|
context.pagesRoot ? discoverPages(context.pagesRoot) : Promise.resolve([]),
|
|
1115
1146
|
detectNeedsReact(context.root),
|
|
1116
|
-
detectUsesMath(context.root),
|
|
1147
|
+
detectUsesMath(context.root, staged.values()),
|
|
1117
1148
|
readOptional(context.themeFile),
|
|
1118
1149
|
readOptional(examplesCssFile(context.root, config)),
|
|
1119
1150
|
discoverIslands(context.root),
|
|
@@ -1156,9 +1187,6 @@ export const generateRuntime = async (
|
|
|
1156
1187
|
const mcp = planMcp(project, srcDir, pages);
|
|
1157
1188
|
pages.push(...mcp.discoveryPages);
|
|
1158
1189
|
|
|
1159
|
-
// Staged (non-filesystem) sources materialize into `.blume/content`; keyed by
|
|
1160
|
-
// entryId so i18n duplicates of one entry write a single file.
|
|
1161
|
-
const staged = collectStaged(project);
|
|
1162
1190
|
const hasStaged = staged.size > 0;
|
|
1163
1191
|
// Only emit a project-scanning `docs` collection when a filesystem source
|
|
1164
1192
|
// actually feeds it. An all-staged project (openapi/notion/…) has only staged
|
|
@@ -1303,7 +1331,7 @@ export const generateRuntime = async (
|
|
|
1303
1331
|
}
|
|
1304
1332
|
|
|
1305
1333
|
// Changelog index (`/changelog`), rendered through the Update timeline layout.
|
|
1306
|
-
if (
|
|
1334
|
+
if (hasGeneratedChangelog(project, pages)) {
|
|
1307
1335
|
await write(
|
|
1308
1336
|
join(srcDir, "pages", "changelog.astro"),
|
|
1309
1337
|
changelogIndexTemplate({
|
|
@@ -1363,11 +1391,11 @@ export const generateRuntime = async (
|
|
|
1363
1391
|
),
|
|
1364
1392
|
write(
|
|
1365
1393
|
join(srcDir, "pages", "[...slug].md.ts"),
|
|
1366
|
-
rawMarkdownEndpointTemplate()
|
|
1394
|
+
rawMarkdownEndpointTemplate("md")
|
|
1367
1395
|
),
|
|
1368
1396
|
write(
|
|
1369
1397
|
join(srcDir, "pages", "[...slug].mdx.ts"),
|
|
1370
|
-
rawMarkdownEndpointTemplate()
|
|
1398
|
+
rawMarkdownEndpointTemplate("mdx")
|
|
1371
1399
|
),
|
|
1372
1400
|
]);
|
|
1373
1401
|
|
|
@@ -1410,7 +1438,7 @@ export const generateRuntime = async (
|
|
|
1410
1438
|
...pages.map((page) => page.pattern),
|
|
1411
1439
|
...referenceTabs(config).map((tab) => tab.path),
|
|
1412
1440
|
]);
|
|
1413
|
-
if (
|
|
1441
|
+
if (hasGeneratedChangelog(project, pages)) {
|
|
1414
1442
|
navTargetRoutes.add("/changelog");
|
|
1415
1443
|
}
|
|
1416
1444
|
warnings.push(
|
|
@@ -1461,7 +1489,10 @@ export const generateRuntime = async (
|
|
|
1461
1489
|
|
|
1462
1490
|
// React ships with Blume; Vue/Svelte islands need their Astro integration
|
|
1463
1491
|
// installed by the project. Warn early rather than let Vite fail to resolve it.
|
|
1464
|
-
warnings.push(
|
|
1492
|
+
warnings.push(
|
|
1493
|
+
...deploymentAdapterWarnings(config.deployment, context.root),
|
|
1494
|
+
...islandFrameworkWarnings(frameworks, context.root)
|
|
1495
|
+
);
|
|
1465
1496
|
if (hasScalarReferences(config)) {
|
|
1466
1497
|
const references = await buildReferenceFiles({
|
|
1467
1498
|
config,
|
package/src/astro/integration.ts
CHANGED
|
@@ -108,7 +108,7 @@ const negotiateMarkdown =
|
|
|
108
108
|
* Blume's Astro integration. Mounts user-authored pages from `pages/` into the
|
|
109
109
|
* generated runtime via `injectRoute`, keeping each file in its original
|
|
110
110
|
* location so relative imports and `getStaticPaths` keep working, and teaches
|
|
111
|
-
* the dev server to
|
|
111
|
+
* the dev server to honor `Accept: text/markdown`.
|
|
112
112
|
*/
|
|
113
113
|
export const blumeIntegration = (
|
|
114
114
|
options: BlumeIntegrationOptions
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* HTTP content negotiation for the raw-Markdown variants. The `<route>.md`
|
|
3
3
|
* endpoints already serve a page's source verbatim; these helpers let the dev
|
|
4
|
-
* server
|
|
4
|
+
* server honor `Accept: text/markdown` by transparently rewriting a page
|
|
5
5
|
* request to its `.md` variant.
|
|
6
6
|
*/
|
|
7
7
|
|
package/src/astro/pages.ts
CHANGED
|
@@ -1,23 +1,14 @@
|
|
|
1
1
|
import { extname, relative } from "pathe";
|
|
2
|
-
import { glob } from "tinyglobby";
|
|
2
|
+
import { glob, globSync } from "tinyglobby";
|
|
3
3
|
|
|
4
|
+
import type { BlumeProject } from "../core/project-graph.ts";
|
|
4
5
|
import type { BlumePageRoute } from "./integration.ts";
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
* Discover user `.astro` pages and map them to route patterns. Files keep their
|
|
8
|
-
* original location; only the route pattern is derived (index -> parent,
|
|
9
|
-
* dynamic `[param]` segments preserved).
|
|
10
|
-
*/
|
|
11
|
-
export const discoverPages = async (
|
|
12
|
-
pagesRoot: string
|
|
13
|
-
): Promise<BlumePageRoute[]> => {
|
|
14
|
-
const files = await glob(["**/*.astro"], {
|
|
15
|
-
absolute: true,
|
|
16
|
-
cwd: pagesRoot,
|
|
17
|
-
onlyFiles: true,
|
|
18
|
-
});
|
|
19
|
-
files.sort();
|
|
7
|
+
const PAGE_GLOB = ["**/*.astro"];
|
|
20
8
|
|
|
9
|
+
/** Map discovered page files to routes; shared by the async/sync discoverers. */
|
|
10
|
+
const toPageRoutes = (pagesRoot: string, files: string[]): BlumePageRoute[] => {
|
|
11
|
+
files.sort();
|
|
21
12
|
return files.map((file) => {
|
|
22
13
|
const rel = relative(pagesRoot, file);
|
|
23
14
|
const withoutExt = rel.slice(0, rel.length - extname(rel).length);
|
|
@@ -32,6 +23,26 @@ export const discoverPages = async (
|
|
|
32
23
|
});
|
|
33
24
|
};
|
|
34
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Discover user `.astro` pages and map them to route patterns. Files keep their
|
|
28
|
+
* original location; only the route pattern is derived (index -> parent,
|
|
29
|
+
* dynamic `[param]` segments preserved).
|
|
30
|
+
*/
|
|
31
|
+
export const discoverPages = async (
|
|
32
|
+
pagesRoot: string
|
|
33
|
+
): Promise<BlumePageRoute[]> =>
|
|
34
|
+
toPageRoutes(
|
|
35
|
+
pagesRoot,
|
|
36
|
+
await glob(PAGE_GLOB, { absolute: true, cwd: pagesRoot, onlyFiles: true })
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
/** {@link discoverPages} for synchronous callers (e.g. the sitemap builder). */
|
|
40
|
+
export const discoverPagesSync = (pagesRoot: string): BlumePageRoute[] =>
|
|
41
|
+
toPageRoutes(
|
|
42
|
+
pagesRoot,
|
|
43
|
+
globSync(PAGE_GLOB, { absolute: true, cwd: pagesRoot, onlyFiles: true })
|
|
44
|
+
);
|
|
45
|
+
|
|
35
46
|
/**
|
|
36
47
|
* Whether the project already owns `route` — through a custom `.astro` page
|
|
37
48
|
* (injected, so matched on `pattern`) or a content page (matched on `route`).
|
|
@@ -57,6 +68,59 @@ export interface OgCustomRoute {
|
|
|
57
68
|
/** Skip private (`_partial`, `.well-known`) and Astro dynamic (`[param]`) parts. */
|
|
58
69
|
const PRIVATE_SEGMENT = /^[._]/u;
|
|
59
70
|
|
|
71
|
+
/** Segments of a static, shareable page pattern; null for dynamic/private ones. */
|
|
72
|
+
const staticSegments = (pattern: string): string[] | null => {
|
|
73
|
+
const segments = pattern.split("/").filter(Boolean);
|
|
74
|
+
return segments.some(
|
|
75
|
+
(part) => PRIVATE_SEGMENT.test(part) || part.includes("[")
|
|
76
|
+
)
|
|
77
|
+
? null
|
|
78
|
+
: segments;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The static routes served by custom `.astro` pages — the same filtering as
|
|
83
|
+
* {@link customOgRoutes}, but yielding the routes themselves. Feeds the route
|
|
84
|
+
* sets that must know every servable page beyond the content graph (the link
|
|
85
|
+
* checker, the sitemap); dynamic (`[param]`) and private segments are skipped
|
|
86
|
+
* because their concrete URLs can't be enumerated statically.
|
|
87
|
+
*/
|
|
88
|
+
export const customStaticRoutes = (pages: { pattern: string }[]): string[] => {
|
|
89
|
+
const routes = new Set<string>();
|
|
90
|
+
for (const { pattern } of pages) {
|
|
91
|
+
const segments = staticSegments(pattern);
|
|
92
|
+
if (segments !== null) {
|
|
93
|
+
routes.add(segments.length === 0 ? "/" : `/${segments.join("/")}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return [...routes];
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Whether the generated `/changelog` index route exists for this project —
|
|
101
|
+
* `generate.ts` (which writes the page) and the sitemap/link validator all
|
|
102
|
+
* share this check: there are visible `type: changelog` entries — or a
|
|
103
|
+
* release-backed changelog source, whose route must resolve even when a fetch
|
|
104
|
+
* fails — and no user content or custom page already owns `/changelog`.
|
|
105
|
+
*/
|
|
106
|
+
export const hasGeneratedChangelog = (
|
|
107
|
+
project: BlumeProject,
|
|
108
|
+
userPages: { pattern: string }[]
|
|
109
|
+
): boolean => {
|
|
110
|
+
const hasChangelog = project.graph.pages.some(
|
|
111
|
+
(page) =>
|
|
112
|
+
page.contentType === "changelog" &&
|
|
113
|
+
!(page.meta.draft || page.meta.sidebar.hidden)
|
|
114
|
+
);
|
|
115
|
+
const hasChangelogSource = (project.config.content.sources ?? []).some(
|
|
116
|
+
(source) => source.type === "github-releases"
|
|
117
|
+
);
|
|
118
|
+
return (
|
|
119
|
+
(hasChangelog || hasChangelogSource) &&
|
|
120
|
+
!routeIsTaken(userPages, project.graph.pages, "/changelog")
|
|
121
|
+
);
|
|
122
|
+
};
|
|
123
|
+
|
|
60
124
|
const humanizeSegment = (segment: string): string =>
|
|
61
125
|
segment
|
|
62
126
|
.split(/[-_]/u)
|
|
@@ -83,10 +147,8 @@ export const customOgRoutes = (
|
|
|
83
147
|
// Extracted so the skip paths become early `return`s (one `continue` budget
|
|
84
148
|
// per loop under the lint rule) instead of `continue` statements.
|
|
85
149
|
const collectRoute = (pattern: string): void => {
|
|
86
|
-
const segments = pattern
|
|
87
|
-
if (
|
|
88
|
-
segments.some((part) => PRIVATE_SEGMENT.test(part) || part.includes("["))
|
|
89
|
-
) {
|
|
150
|
+
const segments = staticSegments(pattern);
|
|
151
|
+
if (segments === null) {
|
|
90
152
|
return;
|
|
91
153
|
}
|
|
92
154
|
const slug = segments.length === 0 ? "index" : segments.join("/");
|
package/src/astro/templates.ts
CHANGED
|
@@ -329,15 +329,18 @@ export const astroConfigTemplate = (options: {
|
|
|
329
329
|
"transformerTwoslash({ explicitTrigger: true }), ";
|
|
330
330
|
|
|
331
331
|
// Content links are rewritten to their real served URL: the `deployment.base`
|
|
332
|
-
// subdirectory (Astro doesn't rewrite `<a href>`)
|
|
333
|
-
// `basePath` baked into routes. The
|
|
334
|
-
//
|
|
335
|
-
|
|
332
|
+
// subdirectory (Astro doesn't rewrite `<a href>`) layered over the site-wide
|
|
333
|
+
// `basePath` baked into routes. The layers are passed separately so a
|
|
334
|
+
// hand-written `basePath` link (`/docs/x`) isn't double-prefixed (see
|
|
335
|
+
// `withComposedBasePath`). The link checker validates the base-less authored
|
|
336
|
+
// path against `basePath` routes separately.
|
|
337
|
+
const deployBase = normalizeBasePath(deployment.base);
|
|
336
338
|
|
|
337
339
|
const integrations = [
|
|
338
340
|
`mdx({ processor: blumeMdxProcessor(${JSON.stringify({
|
|
339
|
-
basePath:
|
|
341
|
+
basePath: config.basePath,
|
|
340
342
|
codeThemes: config.markdown.codeBlocks.theme,
|
|
343
|
+
deployBase,
|
|
341
344
|
headingAnchors: config.markdown.headingAnchors,
|
|
342
345
|
})}) })`,
|
|
343
346
|
];
|
|
@@ -371,8 +374,9 @@ export default defineConfig({
|
|
|
371
374
|
integrations: [${integrations.join(", ")}],
|
|
372
375
|
markdown: {
|
|
373
376
|
processor: blumeMarkdownProcessor(${JSON.stringify({
|
|
374
|
-
basePath:
|
|
377
|
+
basePath: config.basePath,
|
|
375
378
|
codeThemes: config.markdown.codeBlocks.theme,
|
|
379
|
+
deployBase,
|
|
376
380
|
headingAnchors: config.markdown.headingAnchors,
|
|
377
381
|
})}),
|
|
378
382
|
shikiConfig: {
|
|
@@ -615,6 +619,31 @@ export const askEndpointTemplate = (
|
|
|
615
619
|
content: m.content,
|
|
616
620
|
role: m.role,
|
|
617
621
|
}));`;
|
|
622
|
+
// `streamText` returns synchronously and defers provider/auth/network errors
|
|
623
|
+
// to stream consumption, so the handler's try/catch never sees them: without
|
|
624
|
+
// these the client gets a 200 whose stream aborts mid-flight and nothing is
|
|
625
|
+
// logged server-side. A missing credential is rejected up front as a real
|
|
626
|
+
// 500; everything else is at least logged via `onError`.
|
|
627
|
+
const keyCheck =
|
|
628
|
+
backend.kind === "gateway"
|
|
629
|
+
? ` // The AI Gateway authenticates with an API key or Vercel's OIDC token.
|
|
630
|
+
if (!(process.env.AI_GATEWAY_API_KEY || process.env.VERCEL_OIDC_TOKEN)) {
|
|
631
|
+
return new Response(
|
|
632
|
+
"Ask AI is not configured: set AI_GATEWAY_API_KEY (or deploy on Vercel with OIDC).",
|
|
633
|
+
{ status: 500 }
|
|
634
|
+
);
|
|
635
|
+
}`
|
|
636
|
+
: ` if (!process.env[${JSON.stringify(backend.apiKeyEnv)}]) {
|
|
637
|
+
return new Response(
|
|
638
|
+
${JSON.stringify(`Ask AI is not configured: set ${backend.apiKeyEnv}.`)},
|
|
639
|
+
{ status: 500 }
|
|
640
|
+
);
|
|
641
|
+
}`;
|
|
642
|
+
// Provider errors surface mid-stream, after the 200 is committed; this is
|
|
643
|
+
// the only place they can be observed server-side.
|
|
644
|
+
const onError = ` onError({ error }) {
|
|
645
|
+
console.error("Ask AI provider error:", error);
|
|
646
|
+
},`;
|
|
618
647
|
const stream = grounded
|
|
619
648
|
? ` const system =
|
|
620
649
|
(await ground(messages, body.page)) ??
|
|
@@ -623,15 +652,18 @@ export const askEndpointTemplate = (
|
|
|
623
652
|
model: ${modelExpr},
|
|
624
653
|
system,
|
|
625
654
|
messages,
|
|
655
|
+
${onError}
|
|
626
656
|
});`
|
|
627
657
|
: ` const result = streamText({
|
|
628
658
|
model: ${modelExpr},
|
|
629
659
|
system:
|
|
630
660
|
"You are a helpful documentation assistant. Answer using the project's documentation.",
|
|
631
661
|
messages,
|
|
662
|
+
${onError}
|
|
632
663
|
});`;
|
|
633
664
|
const handler = `export const POST: APIRoute = async ({ request }) => {
|
|
634
665
|
${validate}
|
|
666
|
+
${keyCheck}
|
|
635
667
|
try {
|
|
636
668
|
${stream}
|
|
637
669
|
return result.toTextStreamResponse();
|
|
@@ -804,9 +836,13 @@ export const POST: APIRoute = async ({ request }) => {
|
|
|
804
836
|
|
|
805
837
|
/**
|
|
806
838
|
* Generate the raw-Markdown endpoints (`[...slug].md.ts` and `[...slug].mdx.ts`).
|
|
807
|
-
*
|
|
839
|
+
* Both read `raw-markdown.json`, whose entries hold the verbatim source (`mdx`)
|
|
840
|
+
* plus a component-downleveled variant (`md`) when the page uses components:
|
|
841
|
+
* `/<route>.mdx` serves the source exactly as written, `/<route>.md` serves
|
|
842
|
+
* plain Markdown with `<TypeTable>`-style components converted for consumers
|
|
843
|
+
* that can't interpret JSX.
|
|
808
844
|
*/
|
|
809
|
-
export const rawMarkdownEndpointTemplate = (): string =>
|
|
845
|
+
export const rawMarkdownEndpointTemplate = (kind: "md" | "mdx"): string =>
|
|
810
846
|
`// Generated by Blume. Do not edit.
|
|
811
847
|
import raw from "../generated/raw-markdown.json";
|
|
812
848
|
|
|
@@ -820,7 +856,10 @@ export function getStaticPaths() {
|
|
|
820
856
|
}
|
|
821
857
|
|
|
822
858
|
export function GET({ props }) {
|
|
823
|
-
|
|
859
|
+
const entry = raw[props.route];
|
|
860
|
+
return new Response(entry ? ${
|
|
861
|
+
kind === "md" ? "(entry.md ?? entry.mdx)" : "entry.mdx"
|
|
862
|
+
} : "", {
|
|
824
863
|
headers: { "Content-Type": "text/markdown; charset=utf-8" },
|
|
825
864
|
});
|
|
826
865
|
}
|
|
@@ -999,6 +1038,7 @@ const configuration = ${JSON.stringify(options.configuration, null, 2)};
|
|
|
999
1038
|
searchEnabled={data.config.search.enabled}
|
|
1000
1039
|
site={{ title: data.config.title, description: data.config.description }}
|
|
1001
1040
|
themeMode={data.config.theme.mode}
|
|
1041
|
+
ui={data.ui}
|
|
1002
1042
|
>
|
|
1003
1043
|
<ScalarComponent configuration={configuration} renderMode="client" />
|
|
1004
1044
|
</ReferenceLayout>
|
|
@@ -1314,6 +1354,7 @@ export const changelogIndexTemplate = (options: {
|
|
|
1314
1354
|
import { getCollection, render } from "astro:content";
|
|
1315
1355
|
import RootLayout from "blume/components/layout/RootLayout.astro";
|
|
1316
1356
|
import Update from "blume/components/content/Update.astro";
|
|
1357
|
+
import { withBase } from "blume/components/islands/base-path.ts";
|
|
1317
1358
|
import { resolveSlot } from "blume/components/layout/overrides.ts";
|
|
1318
1359
|
import { layoutOverrides } from "../generated/components.ts";
|
|
1319
1360
|
${askImport}import data from "../generated/data.json";
|
|
@@ -1394,6 +1435,20 @@ const items = await Promise.all(
|
|
|
1394
1435
|
})
|
|
1395
1436
|
);
|
|
1396
1437
|
|
|
1438
|
+
// Repeated labels slug to the same id (e.g. two entries with neither a title
|
|
1439
|
+
// nor a version both falling back to "update"); suffix the later ones -2, -3,
|
|
1440
|
+
// ... so every heading deep-links to its own entry. The first keeps the plain
|
|
1441
|
+
// slug, and the rendered ids stay in lockstep with the \`headings\` list below.
|
|
1442
|
+
const seenIds = new Set();
|
|
1443
|
+
for (const item of items) {
|
|
1444
|
+
let uniqueId = item.id;
|
|
1445
|
+
for (let n = 2; seenIds.has(uniqueId); n += 1) {
|
|
1446
|
+
uniqueId = item.id + "-" + n;
|
|
1447
|
+
}
|
|
1448
|
+
seenIds.add(uniqueId);
|
|
1449
|
+
item.id = uniqueId;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1397
1452
|
// A changelog is semver-paginated only when every visible release parses as
|
|
1398
1453
|
// semver and they span more than one major line. Older majors then collapse
|
|
1399
1454
|
// into groups the reader reveals one at a time; otherwise the timeline is flat.
|
|
@@ -1414,7 +1469,20 @@ const headings = items.map((item) => ({
|
|
|
1414
1469
|
}));
|
|
1415
1470
|
|
|
1416
1471
|
const base = data.config.site ? data.config.site.replace(/\\/$/, "") : null;
|
|
1417
|
-
|
|
1472
|
+
// The canonical URL carries the deployment base (the page is served under it),
|
|
1473
|
+
// matching how the catch-all canonicalizes via \`withBase(route)\`.
|
|
1474
|
+
const basedRoute = withBase("/changelog");
|
|
1475
|
+
const canonical = base ? base + basedRoute : null;
|
|
1476
|
+
|
|
1477
|
+
// The changelog is an unlocalized route, so its chrome renders in the default
|
|
1478
|
+
// locale's dictionary and direction (\`data.ui\` is the default locale's resolved
|
|
1479
|
+
// dictionary), mirroring the catch-all's locale wiring.
|
|
1480
|
+
const i18n = data.config.i18n;
|
|
1481
|
+
const localeMeta = i18n
|
|
1482
|
+
? i18n.locales.find((l) => l.code === i18n.defaultLocale)
|
|
1483
|
+
: null;
|
|
1484
|
+
const dir = localeMeta?.dir ?? "ltr";
|
|
1485
|
+
const htmlLang = i18n ? i18n.defaultLocale : "en";
|
|
1418
1486
|
|
|
1419
1487
|
const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
|
|
1420
1488
|
---
|
|
@@ -1431,6 +1499,9 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
|
|
|
1431
1499
|
imageZoom={data.config.imageZoom}
|
|
1432
1500
|
codeWrap={data.config.codeWrap}
|
|
1433
1501
|
navigation={data.navigation}
|
|
1502
|
+
locale={htmlLang}
|
|
1503
|
+
dir={dir}
|
|
1504
|
+
ui={data.ui}
|
|
1434
1505
|
page={{
|
|
1435
1506
|
title: data.config.title + " changelog",
|
|
1436
1507
|
description: "Product updates and release notes.",
|
|
@@ -1458,7 +1529,10 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
|
|
|
1458
1529
|
items.length === 0 ? (
|
|
1459
1530
|
<p>No changelog entries yet.</p>
|
|
1460
1531
|
) : paginate ? (
|
|
1461
|
-
<blume-changelog
|
|
1532
|
+
<blume-changelog
|
|
1533
|
+
class="not-prose mt-8 block"
|
|
1534
|
+
data-i18n-more={data.ui.changelog?.showReleases}
|
|
1535
|
+
>
|
|
1462
1536
|
{majorGroups[0].items.map(({ Content, href, id, label, date, tags }) => (
|
|
1463
1537
|
<Update description={date} href={href} id={id} label={label} tags={tags}>
|
|
1464
1538
|
<Content />
|
|
@@ -1517,11 +1591,22 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
|
|
|
1517
1591
|
export const notFoundPageTemplate = (): string => `---
|
|
1518
1592
|
// Generated by Blume. Do not edit. Override by adding \`pages/404.astro\`.
|
|
1519
1593
|
import PageLayout from "blume/components/layout/PageLayout.astro";
|
|
1594
|
+
import { withBase } from "blume/components/islands/base-path.ts";
|
|
1520
1595
|
import data from "../generated/data.json";
|
|
1521
1596
|
|
|
1522
1597
|
export const prerender = true;
|
|
1523
1598
|
|
|
1524
1599
|
const nf = data.ui.notFound;
|
|
1600
|
+
|
|
1601
|
+
// The 404 page is an unlocalized route, so its chrome renders in the default
|
|
1602
|
+
// locale's dictionary and direction (\`data.ui\` is the default locale's resolved
|
|
1603
|
+
// dictionary), mirroring the catch-all's locale wiring.
|
|
1604
|
+
const i18n = data.config.i18n;
|
|
1605
|
+
const localeMeta = i18n
|
|
1606
|
+
? i18n.locales.find((l) => l.code === i18n.defaultLocale)
|
|
1607
|
+
: null;
|
|
1608
|
+
const dir = localeMeta?.dir ?? "ltr";
|
|
1609
|
+
const htmlLang = i18n ? i18n.defaultLocale : "en";
|
|
1525
1610
|
---
|
|
1526
1611
|
|
|
1527
1612
|
<PageLayout
|
|
@@ -1536,6 +1621,8 @@ const nf = data.ui.notFound;
|
|
|
1536
1621
|
themeMode={data.config.theme.mode}
|
|
1537
1622
|
fontCssVars={data.fontCssVars}
|
|
1538
1623
|
searchEnabled={data.config.search.enabled}
|
|
1624
|
+
locale={htmlLang}
|
|
1625
|
+
dir={dir}
|
|
1539
1626
|
ui={data.ui}
|
|
1540
1627
|
noindex={true}
|
|
1541
1628
|
>
|
|
@@ -1547,7 +1634,7 @@ const nf = data.ui.notFound;
|
|
|
1547
1634
|
<p class="text-muted-foreground">{nf.description}</p>
|
|
1548
1635
|
<a
|
|
1549
1636
|
class="mt-2 rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-foreground"
|
|
1550
|
-
href="/">{nf.home}</a
|
|
1637
|
+
href={withBase("/")}>{nf.home}</a
|
|
1551
1638
|
>
|
|
1552
1639
|
</div>
|
|
1553
1640
|
</PageLayout>
|
package/src/blume-modules.d.ts
CHANGED
|
@@ -15,6 +15,14 @@ declare module "blume:search-client" {
|
|
|
15
15
|
export const createSearch: () => Fn | Promise<Fn>;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
declare module "blume:data" {
|
|
19
|
+
/** The generated per-project data snapshot (see `core/data.ts`). */
|
|
20
|
+
// biome-ignore lint/style/useImportType: ambient module must stay a global script
|
|
21
|
+
// oxlint-disable-next-line typescript/consistent-type-imports
|
|
22
|
+
const data: import("./core/data.ts").BlumeData;
|
|
23
|
+
export default data;
|
|
24
|
+
}
|
|
25
|
+
|
|
18
26
|
// Package-only shim so `components/props.ts` can extract `.astro` prop types with
|
|
19
27
|
// `ComponentProps<typeof import("./X.astro").default>` under the package's own
|
|
20
28
|
// `tsc` (where the Astro TS plugin isn't active). Not shipped in `dist/types`, so
|