blume 0.0.0 → 0.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/bin/blume.mjs +18 -0
- package/dist/cli/index.js +11989 -0
- package/dist/cli/index.js.map +141 -0
- package/docs/01-quickstart.mdx +99 -0
- package/docs/02-deployment.mdx +129 -0
- package/docs/advanced/api-reference.mdx +114 -0
- package/docs/advanced/blog.mdx +121 -0
- package/docs/advanced/changelog.mdx +113 -0
- package/docs/advanced/custom-pages.mdx +187 -0
- package/docs/advanced/meta.ts +7 -0
- package/docs/changelog/v0-1-0.mdx +12 -0
- package/docs/changelog/v0-2-0.mdx +16 -0
- package/docs/configuration/ai.mdx +228 -0
- package/docs/configuration/analytics.mdx +98 -0
- package/docs/configuration/customization.mdx +91 -0
- package/docs/configuration/export.mdx +70 -0
- package/docs/configuration/index.mdx +271 -0
- package/docs/configuration/meta.ts +15 -0
- package/docs/configuration/search.mdx +172 -0
- package/docs/configuration/seo.mdx +196 -0
- package/docs/configuration/theming.mdx +178 -0
- package/docs/content/components.mdx +565 -0
- package/docs/content/i18n.mdx +205 -0
- package/docs/content/index.mdx +161 -0
- package/docs/content/islands.mdx +94 -0
- package/docs/content/meta.mdx +119 -0
- package/docs/content/meta.ts +15 -0
- package/docs/content/navigation.mdx +168 -0
- package/docs/content/sources.mdx +216 -0
- package/docs/content/syntax.mdx +445 -0
- package/docs/index.mdx +112 -0
- package/docs/reference/cli.mdx +43 -0
- package/docs/reference/frontmatter.mdx +74 -0
- package/docs/reference/meta.ts +7 -0
- package/package.json +140 -6
- package/src/ai/ask.ts +93 -0
- package/src/ai/llms.ts +65 -0
- package/src/ai/markdown.ts +31 -0
- package/src/ai/mcp/data.ts +74 -0
- package/src/ai/mcp/discovery.ts +49 -0
- package/src/ai/mcp/server.ts +225 -0
- package/src/ai/mcp/tools.ts +47 -0
- package/src/assets/icon.png +0 -0
- package/src/astro/generate.ts +878 -0
- package/src/astro/index.ts +4 -0
- package/src/astro/integration.ts +74 -0
- package/src/astro/islands.ts +131 -0
- package/src/astro/markdown-negotiation.ts +68 -0
- package/src/astro/pages.ts +28 -0
- package/src/astro/templates.ts +1199 -0
- package/src/cli/commands/add.ts +81 -0
- package/src/cli/commands/build.ts +103 -0
- package/src/cli/commands/dev.ts +108 -0
- package/src/cli/commands/doctor.ts +74 -0
- package/src/cli/commands/eject.ts +57 -0
- package/src/cli/commands/init.ts +98 -0
- package/src/cli/commands/migrate.ts +39 -0
- package/src/cli/commands/preview.ts +39 -0
- package/src/cli/commands/sync.ts +52 -0
- package/src/cli/commands/validate.ts +60 -0
- package/src/cli/index.ts +35 -0
- package/src/cli/log.ts +37 -0
- package/src/cli/prepare.ts +80 -0
- package/src/components/Icon.astro +99 -0
- package/src/components/content/Accordion.astro +8 -0
- package/src/components/content/AccordionItem.astro +121 -0
- package/src/components/content/AutoTypeTable.astro +51 -0
- package/src/components/content/Badge.astro +124 -0
- package/src/components/content/Callout.astro +73 -0
- package/src/components/content/Card.astro +104 -0
- package/src/components/content/CardGroup.astro +14 -0
- package/src/components/content/CodeGroup.astro +13 -0
- package/src/components/content/Color.astro +15 -0
- package/src/components/content/ColorItem.astro +87 -0
- package/src/components/content/ColorRow.astro +10 -0
- package/src/components/content/Column.astro +6 -0
- package/src/components/content/Columns.astro +9 -0
- package/src/components/content/Expandable.astro +11 -0
- package/src/components/content/FileTree.astro +8 -0
- package/src/components/content/Frame.astro +70 -0
- package/src/components/content/GithubInfo.astro +110 -0
- package/src/components/content/Math.astro +24 -0
- package/src/components/content/Panel.astro +20 -0
- package/src/components/content/Prompt.astro +129 -0
- package/src/components/content/Step.astro +34 -0
- package/src/components/content/Steps.astro +20 -0
- package/src/components/content/Tab.astro +40 -0
- package/src/components/content/Tabs.astro +273 -0
- package/src/components/content/Tile.astro +42 -0
- package/src/components/content/Tooltip.astro +68 -0
- package/src/components/content/Tree.astro +300 -0
- package/src/components/content/TreeFile.astro +15 -0
- package/src/components/content/TreeFolder.astro +62 -0
- package/src/components/content/TypeTable.astro +106 -0
- package/src/components/content/Update.astro +66 -0
- package/src/components/content/Visibility.astro +12 -0
- package/src/components/content/Warning.astro +9 -0
- package/src/components/content/auto-type-table.ts +141 -0
- package/src/components/content/github-info.ts +79 -0
- package/src/components/content/mermaid-element.ts +68 -0
- package/src/components/github-mark.ts +9 -0
- package/src/components/index.ts +14 -0
- package/src/components/islands/AskAI.astro +12 -0
- package/src/components/islands/ask-ai.tsx +156 -0
- package/src/components/layout/Analytics.astro +63 -0
- package/src/components/layout/Banner.astro +50 -0
- package/src/components/layout/Breadcrumbs.astro +31 -0
- package/src/components/layout/Favicon.astro +15 -0
- package/src/components/layout/Fonts.astro +14 -0
- package/src/components/layout/Header.astro +188 -0
- package/src/components/layout/LanguageSwitcher.astro +56 -0
- package/src/components/layout/NavTree.astro +462 -0
- package/src/components/layout/PageActions.astro +438 -0
- package/src/components/layout/PageFeedback.astro +58 -0
- package/src/components/layout/Pagination.astro +56 -0
- package/src/components/layout/ReferenceLayout.astro +102 -0
- package/src/components/layout/RootLayout.astro +533 -0
- package/src/components/layout/Search.astro +608 -0
- package/src/components/layout/TableOfContents.astro +68 -0
- package/src/components/layout/analytics-client.ts +38 -0
- package/src/components/layout/nav-utils.ts +87 -0
- package/src/components/layout/overrides.ts +32 -0
- package/src/components/layout/search/algolia.ts +43 -0
- package/src/components/layout/search/endpoint.ts +22 -0
- package/src/components/layout/search/flexsearch.ts +52 -0
- package/src/components/layout/search/orama-cloud.ts +41 -0
- package/src/components/layout/search/orama.ts +26 -0
- package/src/components/layout/search/pagefind.ts +43 -0
- package/src/components/layout/search/types.ts +163 -0
- package/src/components/layout/search/typesense.ts +60 -0
- package/src/components/layout/toc-element.ts +108 -0
- package/src/core/bridge.ts +92 -0
- package/src/core/config.ts +112 -0
- package/src/core/content.ts +50 -0
- package/src/core/define-components.ts +34 -0
- package/src/core/define-meta.ts +20 -0
- package/src/core/deployment-env.ts +73 -0
- package/src/core/diagnostics.ts +104 -0
- package/src/core/graph.ts +128 -0
- package/src/core/i18n-ui.ts +171 -0
- package/src/core/i18n.ts +169 -0
- package/src/core/last-modified.ts +88 -0
- package/src/core/links.ts +336 -0
- package/src/core/load-module.ts +15 -0
- package/src/core/manifest.ts +126 -0
- package/src/core/meta.ts +97 -0
- package/src/core/navigation.ts +392 -0
- package/src/core/package-root.ts +37 -0
- package/src/core/project-graph.ts +153 -0
- package/src/core/project.ts +56 -0
- package/src/core/schema.ts +1057 -0
- package/src/core/server-features.ts +23 -0
- package/src/core/sources/assets.ts +77 -0
- package/src/core/sources/cache.ts +122 -0
- package/src/core/sources/filesystem.ts +99 -0
- package/src/core/sources/mdx-remote.ts +216 -0
- package/src/core/sources/mintlify.ts +161 -0
- package/src/core/sources/normalize.ts +227 -0
- package/src/core/sources/notion.ts +440 -0
- package/src/core/sources/portable-text.ts +143 -0
- package/src/core/sources/read.ts +36 -0
- package/src/core/sources/resolve.ts +158 -0
- package/src/core/sources/sanity.ts +218 -0
- package/src/core/sources/types.ts +105 -0
- package/src/core/types.ts +261 -0
- package/src/core/ui-packs/ar.ts +47 -0
- package/src/core/ui-packs/bg.ts +47 -0
- package/src/core/ui-packs/bn.ts +47 -0
- package/src/core/ui-packs/ca.ts +47 -0
- package/src/core/ui-packs/cs.ts +47 -0
- package/src/core/ui-packs/da.ts +47 -0
- package/src/core/ui-packs/de.ts +47 -0
- package/src/core/ui-packs/el.ts +47 -0
- package/src/core/ui-packs/es.ts +47 -0
- package/src/core/ui-packs/fa.ts +47 -0
- package/src/core/ui-packs/fi.ts +47 -0
- package/src/core/ui-packs/fr.ts +47 -0
- package/src/core/ui-packs/he.ts +47 -0
- package/src/core/ui-packs/hi.ts +47 -0
- package/src/core/ui-packs/hr.ts +47 -0
- package/src/core/ui-packs/hu.ts +47 -0
- package/src/core/ui-packs/id.ts +47 -0
- package/src/core/ui-packs/index.ts +87 -0
- package/src/core/ui-packs/it.ts +47 -0
- package/src/core/ui-packs/ja.ts +47 -0
- package/src/core/ui-packs/ko.ts +47 -0
- package/src/core/ui-packs/nl.ts +47 -0
- package/src/core/ui-packs/no.ts +47 -0
- package/src/core/ui-packs/pl.ts +47 -0
- package/src/core/ui-packs/pt-br.ts +47 -0
- package/src/core/ui-packs/pt.ts +47 -0
- package/src/core/ui-packs/ro.ts +47 -0
- package/src/core/ui-packs/ru.ts +47 -0
- package/src/core/ui-packs/sk.ts +47 -0
- package/src/core/ui-packs/sr.ts +47 -0
- package/src/core/ui-packs/sv.ts +47 -0
- package/src/core/ui-packs/th.ts +47 -0
- package/src/core/ui-packs/tr.ts +47 -0
- package/src/core/ui-packs/uk.ts +47 -0
- package/src/core/ui-packs/vi.ts +47 -0
- package/src/core/ui-packs/zh-tw.ts +47 -0
- package/src/core/ui-packs/zh.ts +47 -0
- package/src/core/version.ts +23 -0
- package/src/deploy/robots.ts +20 -0
- package/src/deploy/rss.ts +128 -0
- package/src/deploy/sitemap.ts +28 -0
- package/src/index.ts +27 -0
- package/src/markdown/code-title.ts +71 -0
- package/src/markdown/directives.ts +83 -0
- package/src/markdown/heading-anchors.ts +137 -0
- package/src/markdown/index.ts +159 -0
- package/src/markdown/inline-code.ts +108 -0
- package/src/markdown/language-icon.ts +172 -0
- package/src/markdown/math.ts +32 -0
- package/src/markdown/mdast.ts +48 -0
- package/src/markdown/mermaid.ts +37 -0
- package/src/markdown/package-commands.ts +159 -0
- package/src/markdown/package-install.ts +40 -0
- package/src/migrate/fumadocs/config.ts +106 -0
- package/src/migrate/fumadocs/content.ts +365 -0
- package/src/migrate/fumadocs/frontmatter.ts +18 -0
- package/src/migrate/fumadocs/index.ts +252 -0
- package/src/migrate/fumadocs/meta.ts +114 -0
- package/src/migrate/migrate.ts +53 -0
- package/src/migrate/mintlify/config.ts +1040 -0
- package/src/migrate/mintlify/content.ts +98 -0
- package/src/migrate/mintlify/frontmatter.ts +126 -0
- package/src/migrate/mintlify/i18n.ts +51 -0
- package/src/migrate/mintlify/icons.ts +128 -0
- package/src/migrate/mintlify/index.ts +266 -0
- package/src/migrate/mintlify/snippets.ts +305 -0
- package/src/migrate/mintlify/transform.ts +81 -0
- package/src/migrate/nextra/content.ts +46 -0
- package/src/migrate/nextra/frontmatter.ts +40 -0
- package/src/migrate/nextra/index.ts +374 -0
- package/src/migrate/nextra/meta.ts +266 -0
- package/src/migrate/shared.ts +623 -0
- package/src/migrate/starlight/config.ts +459 -0
- package/src/migrate/starlight/content.ts +78 -0
- package/src/migrate/starlight/frontmatter.ts +111 -0
- package/src/migrate/starlight/i18n.ts +54 -0
- package/src/migrate/starlight/index.ts +131 -0
- package/src/og/card.ts +92 -0
- package/src/og/index.ts +2 -0
- package/src/openapi/scalar.ts +246 -0
- package/src/registry/eject.ts +263 -0
- package/src/registry/registry.ts +100 -0
- package/src/registry/rewrite-imports.ts +39 -0
- package/src/runtime/index.ts +14 -0
- package/src/search/build.ts +23 -0
- package/src/search/documents.ts +165 -0
- package/src/search/orama-index.ts +66 -0
- package/src/search/providers.ts +91 -0
- package/src/search/sync/algolia.ts +30 -0
- package/src/search/sync/index.ts +50 -0
- package/src/search/sync/orama-cloud.ts +40 -0
- package/src/search/sync/typesense.ts +65 -0
- package/src/seo/jsonld.ts +113 -0
- package/src/theme/entry.ts +608 -0
- package/src/theme/fonts.ts +198 -0
- package/src/theme/icons.ts +184 -0
- package/src/theme/palette.ts +143 -0
- package/src/theme/twoslash.ts +81 -0
|
@@ -0,0 +1,878 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
mkdir,
|
|
4
|
+
readFile,
|
|
5
|
+
rename,
|
|
6
|
+
rm,
|
|
7
|
+
symlink,
|
|
8
|
+
writeFile,
|
|
9
|
+
} from "node:fs/promises";
|
|
10
|
+
import { createRequire } from "node:module";
|
|
11
|
+
import { pathToFileURL } from "node:url";
|
|
12
|
+
|
|
13
|
+
import { dirname, join, normalize, relative } from "pathe";
|
|
14
|
+
import { glob } from "tinyglobby";
|
|
15
|
+
|
|
16
|
+
import { resolveAskBackend } from "../ai/ask.ts";
|
|
17
|
+
import { buildRawMarkdown } from "../ai/markdown.ts";
|
|
18
|
+
import { buildMcpData } from "../ai/mcp/data.ts";
|
|
19
|
+
import { buildMcpDiscovery, buildMcpServerCard } from "../ai/mcp/discovery.ts";
|
|
20
|
+
import { EN_UI, resolveUIStrings } from "../core/i18n-ui.ts";
|
|
21
|
+
import { resolveFallbackLocale } from "../core/i18n.ts";
|
|
22
|
+
import { packageRoot } from "../core/package-root.ts";
|
|
23
|
+
import type { BlumeProject } from "../core/project-graph.ts";
|
|
24
|
+
import type { ResolvedConfig } from "../core/schema.ts";
|
|
25
|
+
import type { Navigation } from "../core/types.ts";
|
|
26
|
+
import { buildRssFeeds, renderRssFeed } from "../deploy/rss.ts";
|
|
27
|
+
import {
|
|
28
|
+
buildReferenceFiles,
|
|
29
|
+
hasReferences,
|
|
30
|
+
referenceTabs,
|
|
31
|
+
} from "../openapi/scalar.ts";
|
|
32
|
+
import { buildSearchDocuments } from "../search/documents.ts";
|
|
33
|
+
import { searchProviderMeta, servesStaticIndex } from "../search/providers.ts";
|
|
34
|
+
import { tailwindEntryTemplate } from "../theme/entry.ts";
|
|
35
|
+
import { buildFontsCss, configuredCssVars } from "../theme/fonts.ts";
|
|
36
|
+
import { buildThemeCss } from "../theme/palette.ts";
|
|
37
|
+
import { twoslashCss } from "../theme/twoslash.ts";
|
|
38
|
+
import { discoverIslands } from "./islands.ts";
|
|
39
|
+
import { discoverPages } from "./pages.ts";
|
|
40
|
+
import {
|
|
41
|
+
askEndpointTemplate,
|
|
42
|
+
astroConfigTemplate,
|
|
43
|
+
catchAllPageTemplate,
|
|
44
|
+
changelogIndexTemplate,
|
|
45
|
+
contentConfigTemplate,
|
|
46
|
+
envTemplate,
|
|
47
|
+
islandMapTemplate,
|
|
48
|
+
islandWrapperTemplate,
|
|
49
|
+
mcpEndpointTemplate,
|
|
50
|
+
mcpPageFile,
|
|
51
|
+
mixedbreadSearchEndpointTemplate,
|
|
52
|
+
ogEndpointTemplate,
|
|
53
|
+
rawMarkdownEndpointTemplate,
|
|
54
|
+
rssEndpointTemplate,
|
|
55
|
+
staticJsonEndpointTemplate,
|
|
56
|
+
runtimeDependencies,
|
|
57
|
+
runtimePackageTemplate,
|
|
58
|
+
runtimeTsconfigTemplate,
|
|
59
|
+
searchClientTemplate,
|
|
60
|
+
searchEndpointTemplate,
|
|
61
|
+
stagedContentDir,
|
|
62
|
+
userComponentsTemplate,
|
|
63
|
+
} from "./templates.ts";
|
|
64
|
+
|
|
65
|
+
/** Absolute path to the Blume package `src` directory. */
|
|
66
|
+
const BLUME_SRC = join(packageRoot(), "src");
|
|
67
|
+
/** The Blume package's own `node_modules` (where Astro and friends live). */
|
|
68
|
+
const BLUME_NODE_MODULES = join(packageRoot(), "node_modules");
|
|
69
|
+
|
|
70
|
+
/** Whether a module specifier resolves from a directory via node resolution. */
|
|
71
|
+
const canResolveFrom = (fromDir: string, spec: string): boolean => {
|
|
72
|
+
try {
|
|
73
|
+
createRequire(pathToFileURL(join(fromDir, "_.js")).href).resolve(spec);
|
|
74
|
+
return true;
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Whether Astro resolves from a directory via normal node resolution. */
|
|
81
|
+
const canResolveAstro = (fromDir: string): boolean =>
|
|
82
|
+
canResolveFrom(fromDir, "astro/package.json");
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Make the generated runtime resolve Astro and its integrations. When they are
|
|
86
|
+
* hoisted into the project (the usual case for published installs), resolution
|
|
87
|
+
* already works. When they are nested and unreachable (workspaces, strict
|
|
88
|
+
* package managers), symlink Blume's own dependencies into `.blume`.
|
|
89
|
+
*/
|
|
90
|
+
const ensureDepsLink = async (outDir: string): Promise<void> => {
|
|
91
|
+
if (canResolveAstro(outDir)) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (!existsSync(join(BLUME_NODE_MODULES, "astro"))) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const link = join(outDir, "node_modules");
|
|
98
|
+
if (existsSync(link)) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
await mkdir(outDir, { recursive: true });
|
|
102
|
+
await symlink(BLUME_NODE_MODULES, link, "junction");
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** Astro integration package each non-React island framework needs installed. */
|
|
106
|
+
const ISLAND_FRAMEWORK_DEPS: Record<string, string> = {
|
|
107
|
+
svelte: "@astrojs/svelte",
|
|
108
|
+
vue: "@astrojs/vue",
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Warn when a Vue/Svelte island is present but its Astro integration isn't
|
|
113
|
+
* installed — Vite would otherwise fail opaquely on the generated config import.
|
|
114
|
+
* React ships with Blume, so it never needs this.
|
|
115
|
+
*/
|
|
116
|
+
const islandFrameworkWarnings = (
|
|
117
|
+
frameworks: Set<string>,
|
|
118
|
+
root: string
|
|
119
|
+
): string[] => {
|
|
120
|
+
const warnings: string[] = [];
|
|
121
|
+
for (const framework of frameworks) {
|
|
122
|
+
const dep = ISLAND_FRAMEWORK_DEPS[framework];
|
|
123
|
+
if (dep && !canResolveFrom(root, dep)) {
|
|
124
|
+
warnings.push(
|
|
125
|
+
`Islands use ${framework}, which needs "${dep}". Install it (e.g. \`npm install ${dep} ${framework}\`).`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return warnings;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/** Read a file's contents, or return an empty string if it is absent. */
|
|
133
|
+
const readOptional = async (path: string | null): Promise<string> => {
|
|
134
|
+
if (!path) {
|
|
135
|
+
return "";
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
return await readFile(path, "utf-8");
|
|
139
|
+
} catch {
|
|
140
|
+
return "";
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** Heuristically detect whether the project uses React islands. */
|
|
145
|
+
export const detectNeedsReact = async (root: string): Promise<boolean> => {
|
|
146
|
+
const matches = await glob(["**/*.{tsx,jsx}"], {
|
|
147
|
+
cwd: root,
|
|
148
|
+
ignore: ["**/node_modules/**", "**/.blume/**", "**/dist/**"],
|
|
149
|
+
onlyFiles: true,
|
|
150
|
+
});
|
|
151
|
+
return matches.length > 0;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const writeIfChanged = async (
|
|
155
|
+
path: string,
|
|
156
|
+
content: string
|
|
157
|
+
): Promise<boolean> => {
|
|
158
|
+
let existing: string | null = null;
|
|
159
|
+
try {
|
|
160
|
+
existing = await readFile(path, "utf-8");
|
|
161
|
+
} catch {
|
|
162
|
+
existing = null;
|
|
163
|
+
}
|
|
164
|
+
if (existing === content) {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
await mkdir(dirname(path), { recursive: true });
|
|
168
|
+
// Write to a temp file then atomically rename into place, so a watching dev
|
|
169
|
+
// server never observes a missing or half-written file mid-regeneration.
|
|
170
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
171
|
+
await writeFile(tmp, content, "utf-8");
|
|
172
|
+
try {
|
|
173
|
+
await rename(tmp, path);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
await rm(tmp, { force: true });
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
return true;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Delete generated files under `srcDir` that this pass didn't (re)write. The
|
|
183
|
+
* generator emits many files conditionally — an Ask AI endpoint, OG images, a
|
|
184
|
+
* search index, RSS feeds, reference pages, the MCP server — so toggling a
|
|
185
|
+
* feature off would otherwise leave a stale file behind, and a leftover
|
|
186
|
+
* server-rendered endpoint breaks the static build. `writeIfChanged` only ever
|
|
187
|
+
* adds or updates, so this closes the loop. Scoped to `.blume/src`, so it never
|
|
188
|
+
* touches Astro's `dist/`, `.astro/` cache, or the symlinked `node_modules`
|
|
189
|
+
* (all of which live outside `src`). `written` holds normalized absolute paths.
|
|
190
|
+
*/
|
|
191
|
+
export const pruneOrphans = async (
|
|
192
|
+
srcDir: string,
|
|
193
|
+
written: Set<string>
|
|
194
|
+
): Promise<void> => {
|
|
195
|
+
const existing = await glob("**/*", {
|
|
196
|
+
absolute: true,
|
|
197
|
+
cwd: srcDir,
|
|
198
|
+
onlyFiles: true,
|
|
199
|
+
});
|
|
200
|
+
await Promise.all(
|
|
201
|
+
existing
|
|
202
|
+
.map((path) => normalize(path))
|
|
203
|
+
.filter((path) => !written.has(path))
|
|
204
|
+
.map((path) => rm(path, { force: true }))
|
|
205
|
+
);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Collect staged (non-filesystem) page bodies keyed by their Astro entry id, so
|
|
210
|
+
* i18n duplicates of one entry collapse to a single materialized file. Shared
|
|
211
|
+
* with `eject`, which materializes the same bodies into the owned project.
|
|
212
|
+
*/
|
|
213
|
+
export const collectStaged = (project: BlumeProject): Map<string, string> => {
|
|
214
|
+
const staged = new Map<string, string>();
|
|
215
|
+
for (const page of project.graph.pages) {
|
|
216
|
+
if (page.collection === "staged" && page.entryId && page.body) {
|
|
217
|
+
staged.set(page.entryId, page.body.text);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return staged;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Materialize staged source bodies under `.blume/content` and prune orphans in
|
|
225
|
+
* that tree (separate from `.blume/src`), so a removed remote entry is cleaned up.
|
|
226
|
+
*/
|
|
227
|
+
const writeStagedContent = async (
|
|
228
|
+
out: string,
|
|
229
|
+
staged: Map<string, string>
|
|
230
|
+
): Promise<void> => {
|
|
231
|
+
const contentDir = stagedContentDir(out);
|
|
232
|
+
const written = new Set<string>();
|
|
233
|
+
await Promise.all(
|
|
234
|
+
[...staged].map(async ([entryId, text]) => {
|
|
235
|
+
const path = join(contentDir, entryId);
|
|
236
|
+
written.add(normalize(path));
|
|
237
|
+
await writeIfChanged(path, text);
|
|
238
|
+
})
|
|
239
|
+
);
|
|
240
|
+
if (existsSync(contentDir)) {
|
|
241
|
+
await pruneOrphans(contentDir, written);
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
/** The logo shape the runtime consumes: an inline SVG or image URL(s). */
|
|
246
|
+
interface ResolvedLogo {
|
|
247
|
+
svg?: string;
|
|
248
|
+
light?: string;
|
|
249
|
+
dark?: string;
|
|
250
|
+
alt: string;
|
|
251
|
+
href: string;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Resolve the configured logo. A single SVG is read and inlined so a
|
|
256
|
+
* `currentColor` logo follows the theme; other images keep their URL for an
|
|
257
|
+
* `<img>`. The file is looked up under `public/` and the project root.
|
|
258
|
+
*/
|
|
259
|
+
const resolveLogo = (project: BlumeProject): ResolvedLogo | null => {
|
|
260
|
+
const { logo } = project.config;
|
|
261
|
+
if (!logo) {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
const config = typeof logo === "string" ? { light: logo } : logo;
|
|
265
|
+
const light = config.light ?? config.dark;
|
|
266
|
+
const dark = config.dark ?? config.light;
|
|
267
|
+
const alt = config.alt ?? "";
|
|
268
|
+
const href = config.href ?? "/";
|
|
269
|
+
|
|
270
|
+
if (light && light === dark && light.toLowerCase().endsWith(".svg")) {
|
|
271
|
+
const rel = light.replace(/^\//u, "");
|
|
272
|
+
const file = [
|
|
273
|
+
join(project.context.root, "public", rel),
|
|
274
|
+
join(project.context.root, rel),
|
|
275
|
+
].find((path) => existsSync(path));
|
|
276
|
+
if (file) {
|
|
277
|
+
return { alt, href, svg: readFileSync(file, "utf-8") };
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return { alt, dark, href, light };
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
/** The favicon shape the runtime consumes: a link href plus optional MIME type. */
|
|
284
|
+
interface ResolvedFavicon {
|
|
285
|
+
href: string;
|
|
286
|
+
type?: string;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Favicon filenames Blume auto-detects, in priority order. Mirrors the Next.js
|
|
291
|
+
* convention: an `icon.*` or `favicon.*` file in `public/` or the project root
|
|
292
|
+
* becomes the site favicon, no config required.
|
|
293
|
+
*/
|
|
294
|
+
const FAVICON_CANDIDATES = [
|
|
295
|
+
"icon.svg",
|
|
296
|
+
"favicon.svg",
|
|
297
|
+
"icon.png",
|
|
298
|
+
"favicon.png",
|
|
299
|
+
"favicon.ico",
|
|
300
|
+
"icon.ico",
|
|
301
|
+
];
|
|
302
|
+
|
|
303
|
+
/** `<link type>` MIME for the favicon extensions we recognize. */
|
|
304
|
+
const FAVICON_TYPES: Record<string, string> = {
|
|
305
|
+
ico: "image/x-icon",
|
|
306
|
+
png: "image/png",
|
|
307
|
+
svg: "image/svg+xml",
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
/** Infer the `<link type>` MIME from a filename, when we recognize the extension. */
|
|
311
|
+
const faviconType = (name: string): string | undefined => {
|
|
312
|
+
const ext = name.split(".").pop()?.toLowerCase();
|
|
313
|
+
return ext ? FAVICON_TYPES[ext] : undefined;
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
/** Read a file and encode it as a `data:` URI of the given MIME type. */
|
|
317
|
+
const inlineDataUri = (file: string, type: string): string =>
|
|
318
|
+
`data:${type};base64,${readFileSync(file).toString("base64")}`;
|
|
319
|
+
|
|
320
|
+
/** The bundled Blume favicon, inlined as a data URI so it needs no public file. */
|
|
321
|
+
const defaultFavicon = (): ResolvedFavicon => ({
|
|
322
|
+
href: inlineDataUri(join(BLUME_SRC, "assets", "icon.png"), "image/png"),
|
|
323
|
+
type: "image/png",
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Resolve the site favicon by convention. An `icon.*`/`favicon.*` file in
|
|
328
|
+
* `public/` is served as-is and referenced by URL; one at the project root is
|
|
329
|
+
* inlined as a data URI (the root isn't a served directory). Falls back to the
|
|
330
|
+
* bundled Blume mark when the project ships no icon.
|
|
331
|
+
*/
|
|
332
|
+
const resolveFavicon = (project: BlumeProject): ResolvedFavicon => {
|
|
333
|
+
const { root } = project.context;
|
|
334
|
+
for (const name of FAVICON_CANDIDATES) {
|
|
335
|
+
if (existsSync(join(root, "public", name))) {
|
|
336
|
+
return { href: `/${name}`, type: faviconType(name) };
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
for (const name of FAVICON_CANDIDATES) {
|
|
340
|
+
const file = join(root, name);
|
|
341
|
+
if (existsSync(file)) {
|
|
342
|
+
const type = faviconType(name);
|
|
343
|
+
return { href: inlineDataUri(file, type ?? "image/x-icon"), type };
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return defaultFavicon();
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
/** The announcement banner shape the runtime consumes. */
|
|
350
|
+
interface ResolvedBanner {
|
|
351
|
+
content: string;
|
|
352
|
+
link?: { href: string; text: string };
|
|
353
|
+
dismissible: boolean;
|
|
354
|
+
/** Dismissal key: the configured id, else the content itself. */
|
|
355
|
+
key: string;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Normalize the banner config (string shorthand or object) for the runtime. */
|
|
359
|
+
const resolveBanner = (config: ResolvedConfig): ResolvedBanner | null => {
|
|
360
|
+
const { banner } = config;
|
|
361
|
+
if (!banner) {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
if (typeof banner === "string") {
|
|
365
|
+
return { content: banner, dismissible: false, key: banner };
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
content: banner.content,
|
|
369
|
+
dismissible: banner.dismissible,
|
|
370
|
+
key: banner.id ?? banner.content,
|
|
371
|
+
link: banner.link,
|
|
372
|
+
};
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
/** Serialize the content graph into the data module the runtime consumes. */
|
|
376
|
+
export const buildRuntimeData = (project: BlumeProject): string => {
|
|
377
|
+
const { config, context, graph, manifest } = project;
|
|
378
|
+
const { github } = config;
|
|
379
|
+
const repoUrl = github
|
|
380
|
+
? `https://github.com/${github.owner}/${github.repo}`
|
|
381
|
+
: null;
|
|
382
|
+
const editBase = github ? `${repoUrl}/edit/${github.branch}` : null;
|
|
383
|
+
|
|
384
|
+
const editUrlFor = (sourcePath?: string): string | null => {
|
|
385
|
+
if (!(editBase && sourcePath)) {
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
const rel = relative(context.root, sourcePath).split("\\").join("/");
|
|
389
|
+
return `${editBase}/${github?.dir ? `${github.dir}/${rel}` : rel}`;
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
const { i18n } = config;
|
|
393
|
+
|
|
394
|
+
// API reference routes (Scalar) surface as header tabs alongside the
|
|
395
|
+
// content-derived ones, so the reference stays discoverable in every locale.
|
|
396
|
+
const withReferenceTabs = (nav: Navigation): Navigation => ({
|
|
397
|
+
...nav,
|
|
398
|
+
repoUrl: config.navigation.repo && repoUrl ? repoUrl : null,
|
|
399
|
+
tabs: [...nav.tabs, ...referenceTabs(config)],
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// Resolved UI dictionaries: one per locale under i18n, English baseline
|
|
403
|
+
// otherwise. Threaded into chrome so the catch-all can pick the active locale.
|
|
404
|
+
const uiByLocale = i18n
|
|
405
|
+
? Object.fromEntries(
|
|
406
|
+
i18n.locales.map(({ code }) => [
|
|
407
|
+
code,
|
|
408
|
+
resolveUIStrings(code, {
|
|
409
|
+
defaultLocale: i18n.defaultLocale,
|
|
410
|
+
overrides: i18n.ui,
|
|
411
|
+
}),
|
|
412
|
+
])
|
|
413
|
+
)
|
|
414
|
+
: {};
|
|
415
|
+
const defaultUi = i18n
|
|
416
|
+
? resolveUIStrings(i18n.defaultLocale, {
|
|
417
|
+
defaultLocale: i18n.defaultLocale,
|
|
418
|
+
overrides: i18n.ui,
|
|
419
|
+
})
|
|
420
|
+
: EN_UI;
|
|
421
|
+
|
|
422
|
+
const navigationByLocale = i18n
|
|
423
|
+
? Object.fromEntries(
|
|
424
|
+
i18n.locales.map(({ code }) => [
|
|
425
|
+
code,
|
|
426
|
+
withReferenceTabs(
|
|
427
|
+
graph.navigationByLocale[code] ?? {
|
|
428
|
+
chromeVariants: [],
|
|
429
|
+
selectors: [],
|
|
430
|
+
sidebar: [],
|
|
431
|
+
sidebarVariants: [],
|
|
432
|
+
tabs: [],
|
|
433
|
+
}
|
|
434
|
+
),
|
|
435
|
+
])
|
|
436
|
+
)
|
|
437
|
+
: {};
|
|
438
|
+
|
|
439
|
+
const data = {
|
|
440
|
+
config: {
|
|
441
|
+
analytics: config.analytics ?? null,
|
|
442
|
+
banner: resolveBanner(config),
|
|
443
|
+
codeWrap: config.markdown.code.wrap,
|
|
444
|
+
description: config.description,
|
|
445
|
+
favicon: resolveFavicon(project),
|
|
446
|
+
feedback: config.feedback,
|
|
447
|
+
i18n: i18n
|
|
448
|
+
? {
|
|
449
|
+
defaultLocale: i18n.defaultLocale,
|
|
450
|
+
// The locale fallback content is rendered from, so the catch-all can
|
|
451
|
+
// set the content direction to the language it's actually written in.
|
|
452
|
+
fallbackLocale: resolveFallbackLocale(i18n),
|
|
453
|
+
hideDefaultLocalePrefix: i18n.hideDefaultLocalePrefix,
|
|
454
|
+
locales: i18n.locales.map(({ code, dir, label }) => ({
|
|
455
|
+
code,
|
|
456
|
+
dir,
|
|
457
|
+
label,
|
|
458
|
+
})),
|
|
459
|
+
}
|
|
460
|
+
: null,
|
|
461
|
+
imageZoom: config.markdown.imageZoom,
|
|
462
|
+
logo: resolveLogo(project),
|
|
463
|
+
mcp: config.mcp.enabled
|
|
464
|
+
? { name: config.mcp.name ?? config.title, route: config.mcp.route }
|
|
465
|
+
: null,
|
|
466
|
+
og: { enabled: config.seo.og.enabled },
|
|
467
|
+
repoUrl,
|
|
468
|
+
search: {
|
|
469
|
+
enabled: config.search.provider !== "none",
|
|
470
|
+
provider: config.search.provider,
|
|
471
|
+
},
|
|
472
|
+
site: config.deployment.site ?? null,
|
|
473
|
+
structuredData: config.seo.structuredData,
|
|
474
|
+
theme: config.theme,
|
|
475
|
+
title: config.title,
|
|
476
|
+
},
|
|
477
|
+
feeds: buildRssFeeds(project).map((feed) => ({
|
|
478
|
+
href: feed.path,
|
|
479
|
+
title: feed.title,
|
|
480
|
+
})),
|
|
481
|
+
// CSS variables for Astro's <Font> component; matches the astro.config
|
|
482
|
+
// `fonts:` entries derived from the same theme.fonts config.
|
|
483
|
+
fontCssVars: configuredCssVars(config.theme.fonts),
|
|
484
|
+
navigation: withReferenceTabs(graph.navigation),
|
|
485
|
+
// Per-locale navigation; the catch-all selects the active locale's tree.
|
|
486
|
+
navigationByLocale,
|
|
487
|
+
routes: manifest.routes.map((route) => ({
|
|
488
|
+
alternates: route.alternates,
|
|
489
|
+
collection: route.collection,
|
|
490
|
+
draft: route.draft,
|
|
491
|
+
editUrl: route.editUrl ?? editUrlFor(route.sourcePath),
|
|
492
|
+
entryId: route.entryId,
|
|
493
|
+
fallback: route.fallback ?? false,
|
|
494
|
+
hidden: route.hidden,
|
|
495
|
+
id: route.id,
|
|
496
|
+
indexable: route.indexable,
|
|
497
|
+
lastModified: route.lastModified ?? null,
|
|
498
|
+
locale: route.locale,
|
|
499
|
+
path: route.path,
|
|
500
|
+
title: route.title,
|
|
501
|
+
})),
|
|
502
|
+
// Default-locale chrome strings (English baseline when not under i18n).
|
|
503
|
+
ui: defaultUi,
|
|
504
|
+
// Per-locale chrome strings, selected by the catch-all under i18n.
|
|
505
|
+
uiByLocale,
|
|
506
|
+
};
|
|
507
|
+
return `${JSON.stringify(data, null, 2)}\n`;
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
/** The resolved plan for the hosted MCP server within a single generate pass. */
|
|
511
|
+
interface McpPlan {
|
|
512
|
+
/** Directory holding the injected discovery endpoints (`.blume/src/blume-mcp`). */
|
|
513
|
+
dir: string;
|
|
514
|
+
/** `.well-known` discovery routes to inject as prerendered pages. */
|
|
515
|
+
discoveryPages: { entrypoint: string; pattern: string }[];
|
|
516
|
+
enabled: boolean;
|
|
517
|
+
route: string;
|
|
518
|
+
srcDir: string;
|
|
519
|
+
warnings: string[];
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Decide whether (and how) to generate the MCP server. Skipped — with a
|
|
524
|
+
* warning — when a content page already occupies its route, so the user's page
|
|
525
|
+
* keeps working.
|
|
526
|
+
*/
|
|
527
|
+
const planMcp = (project: BlumeProject, srcDir: string): McpPlan => {
|
|
528
|
+
const { config } = project;
|
|
529
|
+
const { route } = config.mcp;
|
|
530
|
+
const dir = join(srcDir, "blume-mcp");
|
|
531
|
+
const base: McpPlan = {
|
|
532
|
+
dir,
|
|
533
|
+
discoveryPages: [],
|
|
534
|
+
enabled: false,
|
|
535
|
+
route,
|
|
536
|
+
srcDir,
|
|
537
|
+
warnings: [],
|
|
538
|
+
};
|
|
539
|
+
if (!config.mcp.enabled) {
|
|
540
|
+
return base;
|
|
541
|
+
}
|
|
542
|
+
if (project.graph.pages.some((page) => page.route === route)) {
|
|
543
|
+
return {
|
|
544
|
+
...base,
|
|
545
|
+
warnings: [
|
|
546
|
+
`MCP server route "${route}" is already used by a content page; the MCP server was not generated. Set a different "mcp.route" in blume.config.ts.`,
|
|
547
|
+
],
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
return {
|
|
551
|
+
...base,
|
|
552
|
+
discoveryPages: [
|
|
553
|
+
{
|
|
554
|
+
entrypoint: join(dir, "discovery.ts"),
|
|
555
|
+
pattern: "/.well-known/mcp.json",
|
|
556
|
+
},
|
|
557
|
+
{
|
|
558
|
+
entrypoint: join(dir, "server-card.ts"),
|
|
559
|
+
pattern: "/.well-known/mcp/server-card.json",
|
|
560
|
+
},
|
|
561
|
+
],
|
|
562
|
+
enabled: true,
|
|
563
|
+
};
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
/** Write the MCP data snapshot, server endpoint, and discovery documents. */
|
|
567
|
+
const writeMcpFiles = async (
|
|
568
|
+
project: BlumeProject,
|
|
569
|
+
plan: McpPlan,
|
|
570
|
+
write: (path: string, content: string) => Promise<boolean>
|
|
571
|
+
): Promise<void> => {
|
|
572
|
+
if (!plan.enabled) {
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
const data = await buildMcpData(project);
|
|
576
|
+
const discoveryInput = {
|
|
577
|
+
name: data.name,
|
|
578
|
+
route: plan.route,
|
|
579
|
+
site: data.site,
|
|
580
|
+
version: data.version,
|
|
581
|
+
};
|
|
582
|
+
await Promise.all([
|
|
583
|
+
write(
|
|
584
|
+
join(plan.srcDir, "generated", "mcp-data.json"),
|
|
585
|
+
`${JSON.stringify(data)}\n`
|
|
586
|
+
),
|
|
587
|
+
write(
|
|
588
|
+
join(plan.srcDir, "pages", mcpPageFile(plan.route)),
|
|
589
|
+
mcpEndpointTemplate(plan.route)
|
|
590
|
+
),
|
|
591
|
+
write(
|
|
592
|
+
join(plan.dir, "discovery.ts"),
|
|
593
|
+
staticJsonEndpointTemplate(buildMcpDiscovery(discoveryInput))
|
|
594
|
+
),
|
|
595
|
+
write(
|
|
596
|
+
join(plan.dir, "server-card.ts"),
|
|
597
|
+
staticJsonEndpointTemplate(buildMcpServerCard(discoveryInput))
|
|
598
|
+
),
|
|
599
|
+
]);
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
export interface GenerateResult {
|
|
603
|
+
/** Whether any structural file changed (config/page/content config). */
|
|
604
|
+
structuralChange: boolean;
|
|
605
|
+
/** Non-fatal warnings raised while generating (e.g. a missing API spec). */
|
|
606
|
+
warnings: string[];
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Write (or update) the generated `.blume/` Astro runtime for a project.
|
|
611
|
+
* Only files whose content changed are rewritten so Vite HMR stays fast.
|
|
612
|
+
*/
|
|
613
|
+
export const generateRuntime = async (
|
|
614
|
+
project: BlumeProject
|
|
615
|
+
): Promise<GenerateResult> => {
|
|
616
|
+
const { context, config } = project;
|
|
617
|
+
const out = context.outDir;
|
|
618
|
+
const srcDir = join(out, "src");
|
|
619
|
+
const dataPath = join(srcDir, "generated", "data.json");
|
|
620
|
+
const themePath = join(srcDir, "generated", "app.css");
|
|
621
|
+
const searchClientPath = join(srcDir, "generated", "search-client.ts");
|
|
622
|
+
|
|
623
|
+
// Record every file this pass writes so orphans (from a now-disabled feature)
|
|
624
|
+
// can be pruned afterwards. `write` wraps the atomic writer and tracks paths.
|
|
625
|
+
const written = new Set<string>();
|
|
626
|
+
const write = (path: string, content: string): Promise<boolean> => {
|
|
627
|
+
written.add(normalize(path));
|
|
628
|
+
return writeIfChanged(path, content);
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
await ensureDepsLink(out);
|
|
632
|
+
|
|
633
|
+
const askEnabled = config.ai.ask?.enabled ?? false;
|
|
634
|
+
const exportPdf = config.export.pdf;
|
|
635
|
+
const exportEpub = config.export.epub;
|
|
636
|
+
const [pages, detectedReact, userTheme, islandDiscovery] = await Promise.all([
|
|
637
|
+
context.pagesRoot ? discoverPages(context.pagesRoot) : Promise.resolve([]),
|
|
638
|
+
detectNeedsReact(context.root),
|
|
639
|
+
readOptional(context.themeFile),
|
|
640
|
+
discoverIslands(context.root),
|
|
641
|
+
]);
|
|
642
|
+
// Each island's framework enables its Astro renderer. React also switches on
|
|
643
|
+
// for any project `.tsx`/`.jsx` and for Ask AI; Vue/Svelte are island-driven.
|
|
644
|
+
const islandFrameworks = new Set(
|
|
645
|
+
islandDiscovery.islands.map((island) => island.framework)
|
|
646
|
+
);
|
|
647
|
+
const needsReact =
|
|
648
|
+
detectedReact || askEnabled || islandFrameworks.has("react");
|
|
649
|
+
const needsVue = islandFrameworks.has("vue");
|
|
650
|
+
const needsSvelte = islandFrameworks.has("svelte");
|
|
651
|
+
|
|
652
|
+
// The hosted MCP server. The `.well-known` discovery docs are injected as
|
|
653
|
+
// prerendered routes alongside user pages; the server endpoint itself is a
|
|
654
|
+
// normal (server-rendered) page written by `writeMcpFiles`.
|
|
655
|
+
const mcp = planMcp(project, srcDir);
|
|
656
|
+
pages.push(...mcp.discoveryPages);
|
|
657
|
+
|
|
658
|
+
// Staged (non-filesystem) sources materialize into `.blume/content`; keyed by
|
|
659
|
+
// entryId so i18n duplicates of one entry write a single file.
|
|
660
|
+
const staged = collectStaged(project);
|
|
661
|
+
const hasStaged = staged.size > 0;
|
|
662
|
+
|
|
663
|
+
const structural = await Promise.all([
|
|
664
|
+
write(
|
|
665
|
+
join(out, "astro.config.mjs"),
|
|
666
|
+
astroConfigTemplate({
|
|
667
|
+
config,
|
|
668
|
+
contentRoutes: project.manifest.routes.map((route) => route.path),
|
|
669
|
+
context,
|
|
670
|
+
dataPath,
|
|
671
|
+
needsReact,
|
|
672
|
+
needsSvelte,
|
|
673
|
+
needsVue,
|
|
674
|
+
pages,
|
|
675
|
+
searchClientPath,
|
|
676
|
+
themePath,
|
|
677
|
+
})
|
|
678
|
+
),
|
|
679
|
+
write(
|
|
680
|
+
join(out, "package.json"),
|
|
681
|
+
runtimePackageTemplate(
|
|
682
|
+
runtimeDependencies({ config, needsReact, needsSvelte, needsVue })
|
|
683
|
+
)
|
|
684
|
+
),
|
|
685
|
+
write(join(out, "tsconfig.json"), runtimeTsconfigTemplate()),
|
|
686
|
+
write(join(srcDir, "env.d.ts"), envTemplate()),
|
|
687
|
+
write(
|
|
688
|
+
join(srcDir, "content.config.ts"),
|
|
689
|
+
contentConfigTemplate({ config, context, staged: hasStaged })
|
|
690
|
+
),
|
|
691
|
+
write(
|
|
692
|
+
join(srcDir, "pages", "[...slug].astro"),
|
|
693
|
+
catchAllPageTemplate({
|
|
694
|
+
askEnabled,
|
|
695
|
+
exportEpub,
|
|
696
|
+
exportPdf,
|
|
697
|
+
mathEnabled: config.markdown.math,
|
|
698
|
+
})
|
|
699
|
+
),
|
|
700
|
+
write(
|
|
701
|
+
join(srcDir, "generated", "components.ts"),
|
|
702
|
+
userComponentsTemplate(context.componentsFile)
|
|
703
|
+
),
|
|
704
|
+
write(
|
|
705
|
+
join(srcDir, "generated", "islands.ts"),
|
|
706
|
+
islandMapTemplate(islandDiscovery.islands)
|
|
707
|
+
),
|
|
708
|
+
write(
|
|
709
|
+
themePath,
|
|
710
|
+
tailwindEntryTemplate({
|
|
711
|
+
configTokens: `${buildThemeCss(config.theme)}${buildFontsCss(config.theme.fonts)}`,
|
|
712
|
+
sources: [
|
|
713
|
+
`${BLUME_SRC}/**/*.{astro,ts,tsx}`,
|
|
714
|
+
`${context.root}/**/*.{astro,mdx,ts,tsx}`,
|
|
715
|
+
],
|
|
716
|
+
twoslashCss: twoslashCss(),
|
|
717
|
+
userTheme,
|
|
718
|
+
})
|
|
719
|
+
),
|
|
720
|
+
]);
|
|
721
|
+
|
|
722
|
+
// Per-island hydration wrappers for the `islands/` convention. The map module
|
|
723
|
+
// (written above, always) imports these; orphans from removed islands are
|
|
724
|
+
// pruned at the end of the pass.
|
|
725
|
+
await Promise.all(
|
|
726
|
+
islandDiscovery.islands.map((island) =>
|
|
727
|
+
write(
|
|
728
|
+
join(srcDir, "generated", "islands", `${island.name}.astro`),
|
|
729
|
+
islandWrapperTemplate(island)
|
|
730
|
+
)
|
|
731
|
+
)
|
|
732
|
+
);
|
|
733
|
+
|
|
734
|
+
if (askEnabled) {
|
|
735
|
+
await write(
|
|
736
|
+
join(srcDir, "pages", "api", "ask.ts"),
|
|
737
|
+
askEndpointTemplate(resolveAskBackend(config.ai.ask))
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
await writeMcpFiles(project, mcp, write);
|
|
742
|
+
|
|
743
|
+
if (config.seo.og.enabled) {
|
|
744
|
+
await write(
|
|
745
|
+
join(srcDir, "pages", "og", "[...slug].png.ts"),
|
|
746
|
+
ogEndpointTemplate()
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Changelog index (`/changelog`): a timeline of every `type: changelog` entry,
|
|
751
|
+
// rendered through the Update layout. Skipped when there are no entries, or
|
|
752
|
+
// when a user content page already occupies the `/changelog` route.
|
|
753
|
+
const hasChangelog = project.graph.pages.some(
|
|
754
|
+
(page) =>
|
|
755
|
+
page.contentType === "changelog" &&
|
|
756
|
+
!(page.meta.draft || page.meta.sidebar.hidden)
|
|
757
|
+
);
|
|
758
|
+
const changelogRouteTaken = project.graph.pages.some(
|
|
759
|
+
(page) => page.route === "/changelog"
|
|
760
|
+
);
|
|
761
|
+
if (hasChangelog && !changelogRouteTaken) {
|
|
762
|
+
await write(
|
|
763
|
+
join(srcDir, "pages", "changelog.astro"),
|
|
764
|
+
changelogIndexTemplate({ askEnabled, exportEpub, exportPdf })
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// The provider-specific client loader behind the `blume:search-client` alias
|
|
769
|
+
// is always (re)generated so the alias resolves even when search is disabled.
|
|
770
|
+
await write(searchClientPath, searchClientTemplate(config));
|
|
771
|
+
|
|
772
|
+
// Client-loaded providers (orama, flexsearch) ship a static index + endpoint.
|
|
773
|
+
if (servesStaticIndex(config.search.provider)) {
|
|
774
|
+
const documents = await buildSearchDocuments(project);
|
|
775
|
+
await write(
|
|
776
|
+
join(srcDir, "generated", "search.json"),
|
|
777
|
+
`${JSON.stringify(documents)}\n`
|
|
778
|
+
);
|
|
779
|
+
await write(
|
|
780
|
+
join(srcDir, "pages", "blume-search.json.ts"),
|
|
781
|
+
searchEndpointTemplate()
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// Mixedbread proxies queries through a server endpoint that holds the key.
|
|
786
|
+
if (config.search.provider === "mixedbread") {
|
|
787
|
+
await write(
|
|
788
|
+
join(srcDir, "pages", "api", "search.ts"),
|
|
789
|
+
mixedbreadSearchEndpointTemplate(config.search.mixedbread?.storeId ?? "")
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const rawMarkdown = await buildRawMarkdown(project);
|
|
794
|
+
await Promise.all([
|
|
795
|
+
write(
|
|
796
|
+
join(srcDir, "generated", "raw-markdown.json"),
|
|
797
|
+
`${JSON.stringify(rawMarkdown)}\n`
|
|
798
|
+
),
|
|
799
|
+
write(
|
|
800
|
+
join(srcDir, "pages", "[...slug].md.ts"),
|
|
801
|
+
rawMarkdownEndpointTemplate()
|
|
802
|
+
),
|
|
803
|
+
write(
|
|
804
|
+
join(srcDir, "pages", "[...slug].mdx.ts"),
|
|
805
|
+
rawMarkdownEndpointTemplate()
|
|
806
|
+
),
|
|
807
|
+
]);
|
|
808
|
+
|
|
809
|
+
// Automatic RSS feeds for blog/changelog content types (a no-op when no such
|
|
810
|
+
// pages exist or no deployment.site is configured).
|
|
811
|
+
const feeds = buildRssFeeds(project);
|
|
812
|
+
if (feeds.length > 0) {
|
|
813
|
+
const feedXml = Object.fromEntries(
|
|
814
|
+
feeds.map((feed) => [feed.type, renderRssFeed(feed)])
|
|
815
|
+
);
|
|
816
|
+
await Promise.all([
|
|
817
|
+
write(
|
|
818
|
+
join(srcDir, "generated", "rss.json"),
|
|
819
|
+
`${JSON.stringify(feedXml)}\n`
|
|
820
|
+
),
|
|
821
|
+
write(
|
|
822
|
+
join(srcDir, "pages", "[section]", "rss.xml.ts"),
|
|
823
|
+
rssEndpointTemplate()
|
|
824
|
+
),
|
|
825
|
+
]);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// API/AsyncAPI reference pages (Scalar). One self-contained page per source,
|
|
829
|
+
// mounted on its configured route and regenerated each run.
|
|
830
|
+
const warnings: string[] = [...mcp.warnings, ...islandDiscovery.warnings];
|
|
831
|
+
|
|
832
|
+
// The new provider SDKs are optional peers; warn (rather than fail opaquely in
|
|
833
|
+
// Vite) when the configured provider's package isn't installed.
|
|
834
|
+
for (const dep of searchProviderMeta(config.search.provider).runtimeDeps) {
|
|
835
|
+
if (!canResolveFrom(context.root, dep)) {
|
|
836
|
+
warnings.push(
|
|
837
|
+
`Search provider "${config.search.provider}" needs "${dep}", which isn't installed. Run \`npm install ${dep}\` (or your package manager's equivalent).`
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// React ships with Blume; Vue/Svelte islands need their Astro integration
|
|
843
|
+
// installed by the project. Warn early rather than let Vite fail to resolve it.
|
|
844
|
+
warnings.push(...islandFrameworkWarnings(islandFrameworks, context.root));
|
|
845
|
+
if (hasReferences(config)) {
|
|
846
|
+
const references = await buildReferenceFiles({
|
|
847
|
+
config,
|
|
848
|
+
contentRoutes: new Set(project.graph.pages.map((page) => page.route)),
|
|
849
|
+
root: context.root,
|
|
850
|
+
});
|
|
851
|
+
warnings.push(...references.warnings);
|
|
852
|
+
await Promise.all(
|
|
853
|
+
references.files.map((file) =>
|
|
854
|
+
write(join(srcDir, "pages", file.pagePath), file.content)
|
|
855
|
+
)
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// Data and manifest are not "structural" for Astro; they hot-reload.
|
|
860
|
+
await write(
|
|
861
|
+
join(srcDir, "generated", "data.json"),
|
|
862
|
+
buildRuntimeData(project)
|
|
863
|
+
);
|
|
864
|
+
await write(
|
|
865
|
+
join(out, "blume.manifest.json"),
|
|
866
|
+
`${JSON.stringify(project.manifest, null, 2)}\n`
|
|
867
|
+
);
|
|
868
|
+
|
|
869
|
+
// Write staged source bodies and prune orphans under `.blume/content` (its own
|
|
870
|
+
// tree, outside `.blume/src`), so a removed remote entry doesn't linger.
|
|
871
|
+
await writeStagedContent(out, staged);
|
|
872
|
+
|
|
873
|
+
// Remove anything under `.blume/src` this pass didn't write — e.g. an Ask AI
|
|
874
|
+
// endpoint left behind after the feature was switched off.
|
|
875
|
+
await pruneOrphans(srcDir, written);
|
|
876
|
+
|
|
877
|
+
return { structuralChange: structural.some(Boolean), warnings };
|
|
878
|
+
};
|