blume 1.6.0 → 1.6.1
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 +15 -0
- package/dist/cli/index.js +434 -150
- package/dist/cli/index.js.map +59 -56
- package/dist/types/core/data.d.ts +10 -1
- package/dist/types/core/i18n-ui.d.ts +2 -0
- package/dist/types/core/schema.d.ts +5 -0
- package/dist/types/core/types.d.ts +6 -0
- package/dist/types/openapi/references.d.ts +5 -0
- package/docs/07-faq.mdx +9 -9
- package/docs/advanced/api-reference.mdx +10 -1
- package/docs/advanced/custom-pages.mdx +3 -1
- package/docs/advanced/graphql.mdx +1 -1
- package/docs/configuration/ai.mdx +4 -0
- package/docs/configuration/seo.mdx +3 -3
- package/docs/configuration/theming.mdx +6 -0
- package/docs/content/components.mdx +8 -1
- package/package.json +53 -53
- package/src/astro/examples.ts +29 -2
- package/src/astro/generate.ts +99 -61
- package/src/astro/index.ts +7 -0
- package/src/astro/markdown-negotiation.ts +1 -1
- package/src/astro/runtime-modules.ts +196 -0
- package/src/astro/templates.ts +241 -38
- package/src/cli/commands/build.ts +7 -1
- package/src/cli/commands/dev.ts +6 -3
- package/src/cli/host-args.ts +18 -0
- package/src/cli/index.ts +2 -1
- package/src/components/copy-feedback.ts +93 -9
- package/src/components/islands/ask-ai.tsx +4 -1
- package/src/components/islands/hooks.ts +3 -1
- package/src/components/layout/PageActions.astro +25 -14
- package/src/core/data.ts +10 -1
- package/src/core/define-components.ts +2 -0
- package/src/core/i18n-ui.ts +1 -0
- package/src/core/includes.ts +2 -1
- package/src/core/manifest.ts +10 -0
- package/src/core/schema.ts +13 -5
- package/src/core/types.ts +6 -0
- 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 +1 -0
- 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/core/version-cut.ts +5 -3
- package/src/deploy/vercel-negotiation.ts +49 -6
- package/src/og/card.ts +1 -1
- package/src/openapi/references.ts +8 -0
- package/src/openapi/render-mdx.ts +18 -4
- package/src/openapi/scalar.ts +0 -4
- package/src/registry/eject.ts +36 -17
- package/src/theme/entry.ts +2 -2
- package/src/theme/sources.ts +49 -0
package/src/astro/generate.ts
CHANGED
|
@@ -91,10 +91,16 @@ import {
|
|
|
91
91
|
fontLocaleCodes,
|
|
92
92
|
} from "../theme/fonts.ts";
|
|
93
93
|
import { buildThemeCss } from "../theme/palette.ts";
|
|
94
|
+
import { rebaseSourceDirectives } from "../theme/sources.ts";
|
|
94
95
|
import { twoslashCss } from "../theme/twoslash.ts";
|
|
95
96
|
import { planComponentSlots } from "./component-slots.ts";
|
|
96
97
|
import type { ComponentSlotPlan } from "./component-slots.ts";
|
|
97
|
-
import {
|
|
98
|
+
import {
|
|
99
|
+
EXAMPLE_SCAN_GLOB,
|
|
100
|
+
discoverExamples,
|
|
101
|
+
exampleMarkdownLookup,
|
|
102
|
+
exampleScanRoots,
|
|
103
|
+
} from "./examples.ts";
|
|
98
104
|
import { discoverIslands } from "./islands.ts";
|
|
99
105
|
import {
|
|
100
106
|
customOgRoutes,
|
|
@@ -102,6 +108,8 @@ import {
|
|
|
102
108
|
hasGeneratedChangelog,
|
|
103
109
|
routeIsTaken,
|
|
104
110
|
} from "./pages.ts";
|
|
111
|
+
import { publishRuntimeModules } from "./runtime-modules.ts";
|
|
112
|
+
import type { RuntimeModuleId } from "./runtime-modules.ts";
|
|
105
113
|
import {
|
|
106
114
|
askComponentTemplate,
|
|
107
115
|
askEndpointTemplate,
|
|
@@ -120,6 +128,7 @@ import {
|
|
|
120
128
|
mcpEndpointTemplate,
|
|
121
129
|
mcpPageFile,
|
|
122
130
|
mixedbreadSearchEndpointTemplate,
|
|
131
|
+
notFoundMarkdownTemplate,
|
|
123
132
|
notFoundPageTemplate,
|
|
124
133
|
ogEndpointTemplate,
|
|
125
134
|
playgroundProxyTemplate,
|
|
@@ -714,6 +723,21 @@ const readOptional = async (path: string | null): Promise<string> => {
|
|
|
714
723
|
}
|
|
715
724
|
};
|
|
716
725
|
|
|
726
|
+
/**
|
|
727
|
+
* Read a user stylesheet (`theme.css`, `examples.css`) that gets inlined into
|
|
728
|
+
* a generated Tailwind entry under `outputDir`, re-rooting its relative
|
|
729
|
+
* `@source` paths so they still resolve from where the author wrote them.
|
|
730
|
+
*/
|
|
731
|
+
const readUserCss = async (
|
|
732
|
+
file: string | null,
|
|
733
|
+
outputDir: string
|
|
734
|
+
): Promise<string> => {
|
|
735
|
+
const css = await readOptional(file);
|
|
736
|
+
return file
|
|
737
|
+
? rebaseSourceDirectives(css, { from: file, to: outputDir })
|
|
738
|
+
: css;
|
|
739
|
+
};
|
|
740
|
+
|
|
717
741
|
/** Heuristically detect whether the project uses React islands. */
|
|
718
742
|
export const detectNeedsReact = async (root: string): Promise<boolean> => {
|
|
719
743
|
const matches = await glob(["**/*.{tsx,jsx}"], {
|
|
@@ -1169,7 +1193,11 @@ const resolveOgSite = (config: ResolvedConfig): string | undefined => {
|
|
|
1169
1193
|
: undefined;
|
|
1170
1194
|
};
|
|
1171
1195
|
|
|
1172
|
-
/**
|
|
1196
|
+
/**
|
|
1197
|
+
* The OG card's site-wide subtitle: `seo.og.description` (`false` omits it)
|
|
1198
|
+
* over the site description. The generated endpoint prefers a page's own
|
|
1199
|
+
* description and falls back to this.
|
|
1200
|
+
*/
|
|
1173
1201
|
const resolveOgDescription = (config: ResolvedConfig): string | undefined => {
|
|
1174
1202
|
const configured = config.seo.og.description;
|
|
1175
1203
|
if (configured === false) {
|
|
@@ -1390,6 +1418,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
|
|
|
1390
1418
|
routes: manifest.routes.map((route) => ({
|
|
1391
1419
|
alternates: route.alternates,
|
|
1392
1420
|
collection: route.collection,
|
|
1421
|
+
description: route.description ?? null,
|
|
1393
1422
|
draft: route.draft,
|
|
1394
1423
|
editUrl: route.editUrl ?? editUrlFor(route.sourcePath),
|
|
1395
1424
|
entryId: route.entryId,
|
|
@@ -1472,16 +1501,24 @@ const planMcp = (
|
|
|
1472
1501
|
};
|
|
1473
1502
|
};
|
|
1474
1503
|
|
|
1475
|
-
/**
|
|
1504
|
+
/** A pass's runtime data modules, published together once every writer ran. */
|
|
1505
|
+
type RuntimeModules = Map<RuntimeModuleId, string>;
|
|
1506
|
+
|
|
1507
|
+
/**
|
|
1508
|
+
* Publish the MCP data snapshot (`blume:mcp-data`) and write the server
|
|
1509
|
+
* endpoint and discovery documents.
|
|
1510
|
+
*/
|
|
1476
1511
|
const writeMcpFiles = async (
|
|
1477
1512
|
project: BlumeProject,
|
|
1478
1513
|
plan: McpPlan,
|
|
1479
|
-
write: (path: string, content: string) => Promise<boolean
|
|
1514
|
+
write: (path: string, content: string) => Promise<boolean>,
|
|
1515
|
+
modules: RuntimeModules
|
|
1480
1516
|
): Promise<void> => {
|
|
1481
1517
|
if (!plan.enabled) {
|
|
1482
1518
|
return;
|
|
1483
1519
|
}
|
|
1484
1520
|
const data = await buildMcpData(project);
|
|
1521
|
+
modules.set("blume:mcp-data", JSON.stringify(data));
|
|
1485
1522
|
const discoveryInput = {
|
|
1486
1523
|
base: data.base,
|
|
1487
1524
|
name: data.name,
|
|
@@ -1490,13 +1527,9 @@ const writeMcpFiles = async (
|
|
|
1490
1527
|
version: data.version,
|
|
1491
1528
|
};
|
|
1492
1529
|
await Promise.all([
|
|
1493
|
-
write(
|
|
1494
|
-
join(plan.srcDir, "generated", "mcp-data.json"),
|
|
1495
|
-
`${JSON.stringify(data)}\n`
|
|
1496
|
-
),
|
|
1497
1530
|
write(
|
|
1498
1531
|
join(plan.srcDir, "pages", mcpPageFile(plan.route)),
|
|
1499
|
-
mcpEndpointTemplate(
|
|
1532
|
+
mcpEndpointTemplate()
|
|
1500
1533
|
),
|
|
1501
1534
|
write(
|
|
1502
1535
|
join(plan.dir, "discovery.ts"),
|
|
@@ -1605,7 +1638,8 @@ const proxyAllowlistWarnings = (
|
|
|
1605
1638
|
const writeAskFiles = async (
|
|
1606
1639
|
project: BlumeProject,
|
|
1607
1640
|
srcDir: string,
|
|
1608
|
-
write: (path: string, content: string) => Promise<boolean
|
|
1641
|
+
write: (path: string, content: string) => Promise<boolean>,
|
|
1642
|
+
modules: RuntimeModules
|
|
1609
1643
|
): Promise<void> => {
|
|
1610
1644
|
const { ask } = project.config.ai;
|
|
1611
1645
|
if (!(ask?.enabled && !ask.endpoint)) {
|
|
@@ -1613,10 +1647,7 @@ const writeAskFiles = async (
|
|
|
1613
1647
|
}
|
|
1614
1648
|
const grounded = ask.provider !== "inkeep";
|
|
1615
1649
|
if (grounded) {
|
|
1616
|
-
await
|
|
1617
|
-
join(srcDir, "generated", "ask-data.json"),
|
|
1618
|
-
`${JSON.stringify(await buildAskData(project))}\n`
|
|
1619
|
-
);
|
|
1650
|
+
modules.set("blume:ask-data", JSON.stringify(await buildAskData(project)));
|
|
1620
1651
|
}
|
|
1621
1652
|
await write(
|
|
1622
1653
|
join(srcDir, "pages", "api", "ask.ts"),
|
|
@@ -1629,10 +1660,11 @@ const writeAskFiles = async (
|
|
|
1629
1660
|
|
|
1630
1661
|
/**
|
|
1631
1662
|
* Write the default 404 page at Astro's reserved `src/pages/404.astro` path so
|
|
1632
|
-
* static builds emit `dist/404.html
|
|
1633
|
-
*
|
|
1634
|
-
*
|
|
1635
|
-
*
|
|
1663
|
+
* static builds emit `dist/404.html`, plus its Markdown twin at `404.md.ts`
|
|
1664
|
+
* (`dist/404.md`) for agents that ask a missing URL for Markdown. Both are
|
|
1665
|
+
* skipped when the project already owns `/404` (a custom `pages/404.astro` or
|
|
1666
|
+
* a `404.md` content page), letting it be fully overridden without a route
|
|
1667
|
+
* collision; `pruneOrphans` then removes any previously-generated copies.
|
|
1636
1668
|
*/
|
|
1637
1669
|
const writeNotFoundPage = async (
|
|
1638
1670
|
write: (path: string, content: string) => Promise<boolean>,
|
|
@@ -1643,7 +1675,10 @@ const writeNotFoundPage = async (
|
|
|
1643
1675
|
if (routeIsTaken(pages, contentPages, "/404")) {
|
|
1644
1676
|
return;
|
|
1645
1677
|
}
|
|
1646
|
-
await
|
|
1678
|
+
await Promise.all([
|
|
1679
|
+
write(join(srcDir, "pages", "404.astro"), notFoundPageTemplate()),
|
|
1680
|
+
write(join(srcDir, "pages", "404.md.ts"), notFoundMarkdownTemplate()),
|
|
1681
|
+
]);
|
|
1647
1682
|
};
|
|
1648
1683
|
|
|
1649
1684
|
/**
|
|
@@ -1733,13 +1768,12 @@ export const generateRuntime = async (
|
|
|
1733
1768
|
assertFontFilesExist(project);
|
|
1734
1769
|
const out = context.outDir;
|
|
1735
1770
|
const srcDir = join(out, "src");
|
|
1771
|
+
const generatedDir = join(srcDir, "generated");
|
|
1736
1772
|
const askPath = join(srcDir, "generated", "Ask.astro");
|
|
1737
|
-
const dataPath = join(srcDir, "generated", "data.json");
|
|
1738
1773
|
const themePath = join(srcDir, "generated", "app.css");
|
|
1739
1774
|
const searchClientPath = join(srcDir, "generated", "search-client.ts");
|
|
1740
1775
|
const examplesPath = join(srcDir, "generated", "examples.ts");
|
|
1741
1776
|
const examplesThemePath = join(srcDir, "generated", "examples.css");
|
|
1742
|
-
const openapiPath = join(srcDir, "generated", "openapi.json");
|
|
1743
1777
|
|
|
1744
1778
|
// Record every file this pass writes so orphans (from a now-disabled feature)
|
|
1745
1779
|
// can be pruned afterwards. `write` wraps the atomic writer and tracks paths.
|
|
@@ -1748,6 +1782,11 @@ export const generateRuntime = async (
|
|
|
1748
1782
|
written.add(normalize(path));
|
|
1749
1783
|
return writeIfChanged(path, content);
|
|
1750
1784
|
};
|
|
1785
|
+
// The data snapshots the generated pages import (`blume:data`, the search
|
|
1786
|
+
// index, …) are collected here and published in memory at the end of the
|
|
1787
|
+
// pass — see `runtime-modules.ts`. An id a feature leaves unset is
|
|
1788
|
+
// unpublished, the in-memory counterpart of `pruneOrphans`.
|
|
1789
|
+
const modules: RuntimeModules = new Map();
|
|
1751
1790
|
|
|
1752
1791
|
const depsLinkWarning = await ensureDepsLink(out);
|
|
1753
1792
|
|
|
@@ -1776,8 +1815,8 @@ export const generateRuntime = async (
|
|
|
1776
1815
|
context.pagesRoot ? discoverPages(context.pagesRoot) : Promise.resolve([]),
|
|
1777
1816
|
detectNeedsReact(context.root),
|
|
1778
1817
|
detectUsesMath(context.root, staged.values()),
|
|
1779
|
-
|
|
1780
|
-
|
|
1818
|
+
readUserCss(context.themeFile, generatedDir),
|
|
1819
|
+
readUserCss(examplesCssFile(context.root, config), generatedDir),
|
|
1781
1820
|
loadIntegrationBridge(config, context),
|
|
1782
1821
|
discoverIslands(context.root),
|
|
1783
1822
|
discoverExamples(context.root, config.examples.source),
|
|
@@ -1870,14 +1909,12 @@ export const generateRuntime = async (
|
|
|
1870
1909
|
contentRoot: docsCollection.base,
|
|
1871
1910
|
contentRoutes: markdownRoutePaths(project),
|
|
1872
1911
|
context,
|
|
1873
|
-
dataPath,
|
|
1874
1912
|
examplesPath,
|
|
1875
1913
|
examplesThemePath,
|
|
1876
1914
|
integrationBridge,
|
|
1877
1915
|
needsReact,
|
|
1878
1916
|
needsSvelte,
|
|
1879
1917
|
needsVue,
|
|
1880
|
-
openapiPath,
|
|
1881
1918
|
pages,
|
|
1882
1919
|
reactCompilerPath,
|
|
1883
1920
|
searchClientPath,
|
|
@@ -1925,13 +1962,16 @@ export const generateRuntime = async (
|
|
|
1925
1962
|
exampleMapTemplate(exampleDiscovery.examples, config.basePath)
|
|
1926
1963
|
),
|
|
1927
1964
|
// The isolated Tailwind entry for `<Component />` preview frames: only
|
|
1928
|
-
//
|
|
1929
|
-
//
|
|
1965
|
+
// the project (and an out-of-root examples directory) is scanned, so the
|
|
1966
|
+
// docs theme never reaches a preview. Anything further afield — a sibling
|
|
1967
|
+
// package the examples import — is the user's `@source` in examples.css.
|
|
1930
1968
|
write(
|
|
1931
1969
|
examplesThemePath,
|
|
1932
1970
|
examplesEntryTemplate({
|
|
1933
1971
|
configTokens: buildThemeCss(config.theme),
|
|
1934
|
-
sources:
|
|
1972
|
+
sources: exampleScanRoots(context.root, exampleDiscovery.dir).map(
|
|
1973
|
+
(dir) => `${dir}/${EXAMPLE_SCAN_GLOB}`
|
|
1974
|
+
),
|
|
1935
1975
|
userCss: userExamplesCss,
|
|
1936
1976
|
})
|
|
1937
1977
|
),
|
|
@@ -1986,8 +2026,8 @@ export const generateRuntime = async (
|
|
|
1986
2026
|
)
|
|
1987
2027
|
)
|
|
1988
2028
|
),
|
|
1989
|
-
writeAskFiles(project, srcDir, write),
|
|
1990
|
-
writeMcpFiles(project, mcp, write),
|
|
2029
|
+
writeAskFiles(project, srcDir, write, modules),
|
|
2030
|
+
writeMcpFiles(project, mcp, write, modules),
|
|
1991
2031
|
playgroundProxy.enabled
|
|
1992
2032
|
? write(playgroundProxy.entrypoint, playgroundProxyTemplate(proxyOrigins))
|
|
1993
2033
|
: Promise.resolve(false),
|
|
@@ -1996,7 +2036,14 @@ export const generateRuntime = async (
|
|
|
1996
2036
|
if (config.seo.og.enabled) {
|
|
1997
2037
|
await write(
|
|
1998
2038
|
join(srcDir, "pages", "og", "[...slug].png.ts"),
|
|
1999
|
-
ogEndpointTemplate(
|
|
2039
|
+
ogEndpointTemplate(
|
|
2040
|
+
ogRoutes,
|
|
2041
|
+
{
|
|
2042
|
+
...projectOgFonts(project),
|
|
2043
|
+
pageDescriptions: config.seo.og.description !== false,
|
|
2044
|
+
},
|
|
2045
|
+
changelogIndex
|
|
2046
|
+
)
|
|
2000
2047
|
);
|
|
2001
2048
|
}
|
|
2002
2049
|
|
|
@@ -2034,10 +2081,7 @@ export const generateRuntime = async (
|
|
|
2034
2081
|
// Client-loaded providers (orama, flexsearch) ship a static index + endpoint.
|
|
2035
2082
|
if (servesStaticIndex(config.search.provider)) {
|
|
2036
2083
|
const documents = await buildSearchDocuments(project);
|
|
2037
|
-
|
|
2038
|
-
join(srcDir, "generated", "search.json"),
|
|
2039
|
-
`${JSON.stringify(documents)}\n`
|
|
2040
|
-
);
|
|
2084
|
+
modules.set("blume:search-index", JSON.stringify(documents));
|
|
2041
2085
|
await write(
|
|
2042
2086
|
join(srcDir, "pages", "blume-search.json.ts"),
|
|
2043
2087
|
searchEndpointTemplate()
|
|
@@ -2061,15 +2105,13 @@ export const generateRuntime = async (
|
|
|
2061
2105
|
);
|
|
2062
2106
|
|
|
2063
2107
|
const rawMarkdown = await buildRawMarkdown(project);
|
|
2108
|
+
modules.set("blume:raw-markdown", JSON.stringify(rawMarkdown));
|
|
2064
2109
|
// The originals behind the rewritten `/blume-assets/content/…` references in
|
|
2065
2110
|
// the agent-facing Markdown, plus the endpoint that serves them (and the
|
|
2066
2111
|
// remote-source assets materialized under `.blume/public/blume-assets`).
|
|
2067
2112
|
const contentAssets = await collectContentAssets(project);
|
|
2113
|
+
modules.set("blume:content-assets", JSON.stringify(contentAssets));
|
|
2068
2114
|
await Promise.all([
|
|
2069
|
-
write(
|
|
2070
|
-
join(srcDir, "generated", "raw-markdown.json"),
|
|
2071
|
-
`${JSON.stringify(rawMarkdown)}\n`
|
|
2072
|
-
),
|
|
2073
2115
|
write(
|
|
2074
2116
|
join(srcDir, "pages", "[...slug].md.ts"),
|
|
2075
2117
|
rawMarkdownEndpointTemplate("md")
|
|
@@ -2078,10 +2120,6 @@ export const generateRuntime = async (
|
|
|
2078
2120
|
join(srcDir, "pages", "[...slug].mdx.ts"),
|
|
2079
2121
|
rawMarkdownEndpointTemplate("mdx")
|
|
2080
2122
|
),
|
|
2081
|
-
write(
|
|
2082
|
-
join(srcDir, "generated", "content-assets.json"),
|
|
2083
|
-
`${JSON.stringify(contentAssets)}\n`
|
|
2084
|
-
),
|
|
2085
2123
|
write(
|
|
2086
2124
|
join(srcDir, "pages", "blume-assets", "[...asset].ts"),
|
|
2087
2125
|
contentAssetsEndpointTemplate(
|
|
@@ -2097,16 +2135,11 @@ export const generateRuntime = async (
|
|
|
2097
2135
|
const feedXml = Object.fromEntries(
|
|
2098
2136
|
feeds.map((feed) => [feed.type, renderRssFeed(feed)])
|
|
2099
2137
|
);
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
write(
|
|
2106
|
-
join(srcDir, "pages", "[section]", "rss.xml.ts"),
|
|
2107
|
-
rssEndpointTemplate()
|
|
2108
|
-
),
|
|
2109
|
-
]);
|
|
2138
|
+
modules.set("blume:rss", JSON.stringify(feedXml));
|
|
2139
|
+
await write(
|
|
2140
|
+
join(srcDir, "pages", "[section]", "rss.xml.ts"),
|
|
2141
|
+
rssEndpointTemplate()
|
|
2142
|
+
);
|
|
2110
2143
|
}
|
|
2111
2144
|
|
|
2112
2145
|
// Scalar-rendered API/AsyncAPI reference pages (`renderer: "scalar"`). One
|
|
@@ -2181,16 +2214,16 @@ export const generateRuntime = async (
|
|
|
2181
2214
|
);
|
|
2182
2215
|
}
|
|
2183
2216
|
|
|
2184
|
-
// `
|
|
2185
|
-
//
|
|
2186
|
-
//
|
|
2217
|
+
// `blume:openapi` is always published — even as `{}` — so the import resolves
|
|
2218
|
+
// whether or not a reference is enabled; the specs were parsed during the
|
|
2219
|
+
// scan, so this is just serialization. `blume:data` is the page data every
|
|
2220
|
+
// layout reads. Neither is "structural" for Astro; publishing hot-reloads.
|
|
2221
|
+
modules.set("blume:data", buildRuntimeData(project));
|
|
2222
|
+
modules.set("blume:openapi", JSON.stringify(openApiData));
|
|
2187
2223
|
// These write to distinct trees and never read one another, so they batch.
|
|
2188
|
-
// `
|
|
2189
|
-
//
|
|
2190
|
-
// own pruning), outside `.blume/src`, so a removed remote entry doesn't linger.
|
|
2224
|
+
// `writeStagedContent` owns the `.blume/content` tree (its own pruning),
|
|
2225
|
+
// outside `.blume/src`, so a removed remote entry doesn't linger.
|
|
2191
2226
|
await Promise.all([
|
|
2192
|
-
write(join(srcDir, "generated", "data.json"), buildRuntimeData(project)),
|
|
2193
|
-
write(openapiPath, `${JSON.stringify(openApiData)}\n`),
|
|
2194
2227
|
write(
|
|
2195
2228
|
join(out, "blume.manifest.json"),
|
|
2196
2229
|
`${JSON.stringify(project.manifest, null, 2)}\n`
|
|
@@ -2202,5 +2235,10 @@ export const generateRuntime = async (
|
|
|
2202
2235
|
// endpoint left behind after the feature was switched off.
|
|
2203
2236
|
await pruneOrphans(srcDir, written);
|
|
2204
2237
|
|
|
2238
|
+
// Publish last, once every page that imports a module is on disk: a live
|
|
2239
|
+
// dev server invalidates the changed modules and reloads the browser against
|
|
2240
|
+
// the finished tree, never a half-written one.
|
|
2241
|
+
publishRuntimeModules(modules);
|
|
2242
|
+
|
|
2205
2243
|
return { structuralChange: structural.some(Boolean), warnings };
|
|
2206
2244
|
};
|
package/src/astro/index.ts
CHANGED
|
@@ -5,3 +5,10 @@ export { withIncludeRefresh } from "./include-refresh.ts";
|
|
|
5
5
|
export type { GenerateResult } from "./generate.ts";
|
|
6
6
|
export { blumeIntegration } from "./integration.ts";
|
|
7
7
|
export type { BlumeIntegrationOptions, BlumePageRoute } from "./integration.ts";
|
|
8
|
+
export {
|
|
9
|
+
publishRuntimeModules,
|
|
10
|
+
readRuntimeModule,
|
|
11
|
+
RUNTIME_MODULE_FILES,
|
|
12
|
+
runtimeModulesPlugin,
|
|
13
|
+
} from "./runtime-modules.ts";
|
|
14
|
+
export type { RuntimeModuleId } from "./runtime-modules.ts";
|
|
@@ -29,7 +29,7 @@ const parseAccept = (accept: string): AcceptEntry[] =>
|
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* Whether the client explicitly prefers Markdown over HTML. Browsers never send
|
|
32
|
-
* `text/markdown`, so an ordinary page request (`text/html
|
|
32
|
+
* `text/markdown`, so an ordinary page request (`text/html` or the catch-all wildcard) is false.
|
|
33
33
|
*/
|
|
34
34
|
export const prefersMarkdown = (accept: string | null | undefined): boolean => {
|
|
35
35
|
if (!accept) {
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory runtime data modules.
|
|
3
|
+
*
|
|
4
|
+
* The generated runtime's data snapshots — the page data behind `blume:data`,
|
|
5
|
+
* the parsed API specs, the static search index, the raw-Markdown and
|
|
6
|
+
* content-asset maps, the MCP and Ask corpora, and the rendered RSS feeds —
|
|
7
|
+
* used to be written under `.blume/src/generated/*.json` and imported by the
|
|
8
|
+
* generated pages through aliases or relative paths. `generateRuntime` now
|
|
9
|
+
* publishes them here and `runtimeModulesPlugin` serves them to Vite as
|
|
10
|
+
* virtual modules, so a regeneration never round-trips the disk or waits on a
|
|
11
|
+
* file watcher: publishing invalidates the changed modules in every live dev
|
|
12
|
+
* server (Vite walks the importers, so the pages that render them re-evaluate
|
|
13
|
+
* on the next request) and asks the browser to reload — the same effect a JSON
|
|
14
|
+
* file change used to reach through the watcher, minus the write and the
|
|
15
|
+
* watch debounce.
|
|
16
|
+
*
|
|
17
|
+
* The registry hangs off `globalThis`, not module state. On a published
|
|
18
|
+
* install the CLI bundle (`dist/cli`) carries its own copy of this module,
|
|
19
|
+
* separate from the one Vite loads from `blume/astro` for the generated
|
|
20
|
+
* config, and both must see the same map. `blume dev`, `blume build`, and
|
|
21
|
+
* `blume check` all run Astro in-process, so the map the CLI fills is the map
|
|
22
|
+
* the plugin reads.
|
|
23
|
+
*
|
|
24
|
+
* `blume eject` keeps the file form: the ejected project has no CLI to
|
|
25
|
+
* publish, so its `astro.config.mjs` aliases each id to the JSON file eject
|
|
26
|
+
* writes under `src/generated/` — the names in {@link RUNTIME_MODULE_FILES}.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export type RuntimeModuleId =
|
|
30
|
+
| "blume:ask-data"
|
|
31
|
+
| "blume:content-assets"
|
|
32
|
+
| "blume:data"
|
|
33
|
+
| "blume:mcp-data"
|
|
34
|
+
| "blume:openapi"
|
|
35
|
+
| "blume:raw-markdown"
|
|
36
|
+
| "blume:rss"
|
|
37
|
+
| "blume:search-index";
|
|
38
|
+
|
|
39
|
+
/** Virtual module id → the `src/generated` JSON file eject writes for it. */
|
|
40
|
+
export const RUNTIME_MODULE_FILES: ReadonlyMap<RuntimeModuleId, string> =
|
|
41
|
+
new Map([
|
|
42
|
+
["blume:ask-data", "ask-data.json"],
|
|
43
|
+
["blume:content-assets", "content-assets.json"],
|
|
44
|
+
["blume:data", "data.json"],
|
|
45
|
+
["blume:mcp-data", "mcp-data.json"],
|
|
46
|
+
["blume:openapi", "openapi.json"],
|
|
47
|
+
["blume:raw-markdown", "raw-markdown.json"],
|
|
48
|
+
["blume:rss", "rss.json"],
|
|
49
|
+
["blume:search-index", "search.json"],
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
const RUNTIME_MODULE_IDS: ReadonlySet<string> = new Set(
|
|
53
|
+
RUNTIME_MODULE_FILES.keys()
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
/** Rollup's virtual-module convention: `\0` keeps other plugins off the id. */
|
|
57
|
+
const RESOLVED_PREFIX = "\0";
|
|
58
|
+
|
|
59
|
+
/** A node in Vite's module graph; only its identity matters here. */
|
|
60
|
+
interface RuntimeModuleNode {
|
|
61
|
+
id: string | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The Vite dev-server slice the registry touches (structurally typed, like
|
|
66
|
+
* every Blume-authored Vite plugin — see `includeHmrPlugin`).
|
|
67
|
+
*/
|
|
68
|
+
export interface RuntimeModuleServer {
|
|
69
|
+
httpServer?: {
|
|
70
|
+
once: (event: "close", listener: () => void) => void;
|
|
71
|
+
} | null;
|
|
72
|
+
moduleGraph: {
|
|
73
|
+
getModuleById: (id: string) => RuntimeModuleNode | undefined;
|
|
74
|
+
invalidateModule: (mod: RuntimeModuleNode) => void;
|
|
75
|
+
};
|
|
76
|
+
ws: { send: (payload: { type: "full-reload" }) => void };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface RuntimeModuleRegistry {
|
|
80
|
+
/** Published JSON text by module id. */
|
|
81
|
+
modules: Map<string, string>;
|
|
82
|
+
/** Live dev servers to invalidate on publish. */
|
|
83
|
+
servers: Set<RuntimeModuleServer>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const REGISTRY_KEY = Symbol.for("blume.runtime-modules");
|
|
87
|
+
|
|
88
|
+
type RegistryHost = typeof globalThis & {
|
|
89
|
+
[REGISTRY_KEY]?: RuntimeModuleRegistry;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const registry = (): RuntimeModuleRegistry => {
|
|
93
|
+
// SAFETY: the registry is stashed on globalThis under a well-known symbol so
|
|
94
|
+
// every copy of this module in the process shares it; the intersection only
|
|
95
|
+
// names that slot.
|
|
96
|
+
const host = globalThis as RegistryHost;
|
|
97
|
+
host[REGISTRY_KEY] ??= { modules: new Map(), servers: new Set() };
|
|
98
|
+
return host[REGISTRY_KEY];
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** The published JSON text for a module, if any (tests and diagnostics). */
|
|
102
|
+
export const readRuntimeModule = (id: RuntimeModuleId): string | undefined =>
|
|
103
|
+
registry().modules.get(id);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Replace the published snapshot set with `modules` (an id absent from the map
|
|
107
|
+
* is unpublished — its feature was switched off). Returns the ids whose text
|
|
108
|
+
* changed; each is invalidated in every live dev server, followed by one
|
|
109
|
+
* full-reload per server. Nothing is sent when nothing changed, so a
|
|
110
|
+
* regeneration triggered by an unrelated edit stays quiet — the same contract
|
|
111
|
+
* `writeIfChanged` gave the file form.
|
|
112
|
+
*/
|
|
113
|
+
export const publishRuntimeModules = (
|
|
114
|
+
modules: ReadonlyMap<RuntimeModuleId, string>
|
|
115
|
+
): RuntimeModuleId[] => {
|
|
116
|
+
const { modules: current, servers } = registry();
|
|
117
|
+
const changed: RuntimeModuleId[] = [];
|
|
118
|
+
for (const id of RUNTIME_MODULE_FILES.keys()) {
|
|
119
|
+
const next = modules.get(id);
|
|
120
|
+
if (current.get(id) === next) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (next === undefined) {
|
|
124
|
+
current.delete(id);
|
|
125
|
+
} else {
|
|
126
|
+
current.set(id, next);
|
|
127
|
+
}
|
|
128
|
+
changed.push(id);
|
|
129
|
+
}
|
|
130
|
+
if (changed.length === 0) {
|
|
131
|
+
return changed;
|
|
132
|
+
}
|
|
133
|
+
for (const server of servers) {
|
|
134
|
+
for (const id of changed) {
|
|
135
|
+
const mod = server.moduleGraph.getModuleById(`${RESOLVED_PREFIX}${id}`);
|
|
136
|
+
if (mod) {
|
|
137
|
+
server.moduleGraph.invalidateModule(mod);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
server.ws.send({ type: "full-reload" });
|
|
141
|
+
}
|
|
142
|
+
return changed;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export interface RuntimeModulesPlugin {
|
|
146
|
+
configureServer: (server: RuntimeModuleServer) => void;
|
|
147
|
+
enforce: "pre";
|
|
148
|
+
load: (id: string) => string | undefined;
|
|
149
|
+
name: string;
|
|
150
|
+
resolveId: (id: string) => string | undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Serve the published runtime modules to Vite. `enforce: "pre"` claims the
|
|
155
|
+
* `blume:*` ids before Vite's resolver would try (and fail) to find them as
|
|
156
|
+
* packages. Loading an unpublished id is a hard error rather than an empty
|
|
157
|
+
* module: the generated pages only import a module when its data exists, so a
|
|
158
|
+
* miss means the config was run outside the CLI that publishes.
|
|
159
|
+
*/
|
|
160
|
+
export const runtimeModulesPlugin = (): RuntimeModulesPlugin => ({
|
|
161
|
+
configureServer(server) {
|
|
162
|
+
const { servers } = registry();
|
|
163
|
+
servers.add(server);
|
|
164
|
+
// Astro restarts the dev container in place on a config change (and the
|
|
165
|
+
// CLI restarts it on a route-set change): the old Vite server closes and
|
|
166
|
+
// a new one registers, so drop the stale handle rather than invalidating
|
|
167
|
+
// into a dead graph.
|
|
168
|
+
server.httpServer?.once("close", () => {
|
|
169
|
+
servers.delete(server);
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
enforce: "pre",
|
|
173
|
+
load(id) {
|
|
174
|
+
if (!id.startsWith(RESOLVED_PREFIX)) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const moduleId = id.slice(RESOLVED_PREFIX.length);
|
|
178
|
+
if (!RUNTIME_MODULE_IDS.has(moduleId)) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const text = registry().modules.get(moduleId);
|
|
182
|
+
if (text === undefined) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`Blume runtime module "${moduleId}" was requested before it was published. Run the generated project through the Blume CLI (blume dev / blume build), which publishes the runtime data before starting Astro.`
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
// A JSON.parse over a string literal evaluates faster than an equivalent
|
|
188
|
+
// object literal for large snapshots (V8's guidance; Vite's own JSON
|
|
189
|
+
// plugin does the same past a size threshold).
|
|
190
|
+
return `export default JSON.parse(${JSON.stringify(text)});\n`;
|
|
191
|
+
},
|
|
192
|
+
name: "blume:runtime-modules",
|
|
193
|
+
resolveId(id) {
|
|
194
|
+
return RUNTIME_MODULE_IDS.has(id) ? `${RESOLVED_PREFIX}${id}` : undefined;
|
|
195
|
+
},
|
|
196
|
+
});
|