blume 0.5.4 → 0.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/dist/cli/index.js +759 -406
- package/dist/cli/index.js.map +27 -25
- package/dist/types/core/config-input.d.ts +759 -0
- package/dist/types/core/config.d.ts +126 -3
- package/dist/types/core/data.d.ts +4 -0
- package/dist/types/core/i18n-ui.d.ts +50 -0
- package/dist/types/core/schema.d.ts +334 -62
- package/dist/types/core/types.d.ts +8 -0
- package/dist/types/index.d.ts +2 -1
- package/docs/advanced/changelog.mdx +10 -2
- package/docs/configuration/ai.mdx +56 -0
- package/docs/configuration/index.mdx +0 -2
- package/docs/configuration/seo.mdx +59 -1
- package/docs/configuration/theming.mdx +14 -9
- package/docs/content/meta.mdx +3 -17
- package/docs/content/navigation.mdx +41 -4
- package/docs/content/syntax.mdx +4 -8
- package/package.json +3 -1
- package/src/ai/agent-readability.ts +97 -0
- package/src/ai/ask-context.ts +131 -8
- package/src/ai/ask-data.ts +4 -1
- package/src/astro/generate.ts +40 -11
- package/src/astro/templates.ts +90 -10
- package/src/cli/commands/build.ts +41 -1
- package/src/cli/commands/dev.ts +31 -14
- package/src/cli/dev-lock.ts +94 -21
- package/src/components/content/GithubInfo.astro +11 -10
- package/src/components/content/TypeTable.astro +8 -3
- package/src/components/content/Update.astro +12 -2
- package/src/components/content/changelog-element.ts +62 -0
- package/src/components/islands/AskAI.astro +66 -2
- package/src/components/islands/ask-ai.tsx +289 -53
- package/src/components/layout/Header.astro +1 -1
- package/src/components/layout/NavTree.astro +1 -1
- package/src/components/layout/PageActions.astro +73 -30
- package/src/components/layout/RootLayout.astro +79 -10
- package/src/core/config-input.ts +933 -0
- package/src/core/config.ts +126 -3
- package/src/core/data.ts +4 -0
- package/src/core/graph.ts +7 -2
- package/src/core/i18n-ui.ts +5 -0
- package/src/core/nav-diagnostics.ts +7 -0
- package/src/core/navigation.ts +38 -12
- package/src/core/schema.ts +130 -22
- package/src/core/sources/filesystem.ts +5 -1
- package/src/core/sources/watch.ts +43 -12
- package/src/core/types.ts +9 -0
- package/src/deploy/adapter-output.ts +82 -0
- package/src/deploy/robots.ts +37 -4
- package/src/index.ts +1 -1
- package/src/markdown/index.ts +28 -30
- package/src/markdown/math.ts +3 -2
- package/src/openapi/scalar.ts +1 -1
- package/src/registry/eject.ts +21 -14
- package/src/search/documents.ts +9 -2
- package/src/theme/entry.ts +7 -3
- package/src/theme/palette.ts +21 -14
package/src/ai/ask-context.ts
CHANGED
|
@@ -26,16 +26,77 @@ export interface AskData {
|
|
|
26
26
|
/** Documents retrieved per question and injected into the system prompt. */
|
|
27
27
|
const MAX_RESULTS = 6;
|
|
28
28
|
/** Characters kept per injected excerpt. */
|
|
29
|
-
const EXCERPT_CHARS =
|
|
29
|
+
const EXCERPT_CHARS = 2000;
|
|
30
30
|
/** Overall cap on injected documentation characters. */
|
|
31
|
-
const CONTEXT_BUDGET =
|
|
31
|
+
const CONTEXT_BUDGET = 10_000;
|
|
32
|
+
/** Chars of lead-in kept before the matched region, for heading/sentence context. */
|
|
33
|
+
const EXCERPT_LEAD = 160;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Common words dropped from the retrieval query before locating the relevant
|
|
37
|
+
* excerpt region, so short filler ("how does…", "what is…") doesn't drag the
|
|
38
|
+
* window toward incidental matches instead of the meaningful terms.
|
|
39
|
+
*/
|
|
40
|
+
const STOPWORDS = new Set([
|
|
41
|
+
"about",
|
|
42
|
+
"and",
|
|
43
|
+
"are",
|
|
44
|
+
"as",
|
|
45
|
+
"at",
|
|
46
|
+
"be",
|
|
47
|
+
"but",
|
|
48
|
+
"by",
|
|
49
|
+
"can",
|
|
50
|
+
"do",
|
|
51
|
+
"does",
|
|
52
|
+
"for",
|
|
53
|
+
"from",
|
|
54
|
+
"how",
|
|
55
|
+
"in",
|
|
56
|
+
"into",
|
|
57
|
+
"is",
|
|
58
|
+
"it",
|
|
59
|
+
"its",
|
|
60
|
+
"my",
|
|
61
|
+
"of",
|
|
62
|
+
"on",
|
|
63
|
+
"or",
|
|
64
|
+
"our",
|
|
65
|
+
"that",
|
|
66
|
+
"the",
|
|
67
|
+
"these",
|
|
68
|
+
"this",
|
|
69
|
+
"those",
|
|
70
|
+
"to",
|
|
71
|
+
"use",
|
|
72
|
+
"used",
|
|
73
|
+
"using",
|
|
74
|
+
"was",
|
|
75
|
+
"were",
|
|
76
|
+
"what",
|
|
77
|
+
"when",
|
|
78
|
+
"where",
|
|
79
|
+
"which",
|
|
80
|
+
"who",
|
|
81
|
+
"why",
|
|
82
|
+
"with",
|
|
83
|
+
"you",
|
|
84
|
+
"your",
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
/** Distinct, meaningful lowercase terms from a query (drops stopwords). */
|
|
88
|
+
const queryTerms = (query: string): string[] =>
|
|
89
|
+
[...new Set(query.toLowerCase().match(/[a-z0-9]+/gu))].filter(
|
|
90
|
+
(term) => term.length >= 2 && !STOPWORDS.has(term)
|
|
91
|
+
);
|
|
32
92
|
|
|
33
93
|
/**
|
|
34
94
|
* The grounding preamble. The model is told to answer strictly from the injected
|
|
35
|
-
* excerpts and to cite the pages it used
|
|
95
|
+
* excerpts and to cite the pages it used as Markdown links (each excerpt is
|
|
96
|
+
* headed by `## Title (/route)`), so citations render as real links in the panel.
|
|
36
97
|
*/
|
|
37
98
|
const BASE_INSTRUCTION =
|
|
38
|
-
"You are a helpful documentation assistant for this project. Answer the user's question using ONLY the documentation excerpts below. If the answer is not covered by
|
|
99
|
+
"You are a helpful documentation assistant for this project. Answer the user's question using ONLY the documentation excerpts below. Each excerpt is headed by its page as `## Page Title (/route)`. If the answer is not covered by the excerpts, say you don't know and suggest where in the docs to look — do not invent details. Always cite the pages you drew from, and write every citation as a Markdown link to that page using its route, e.g. [Page Title](/route).";
|
|
39
100
|
|
|
40
101
|
/** Normalize a page path to a document `route` (`/`, `/a/b`, no trailing slash). */
|
|
41
102
|
const normalizeRoute = (input: string): string => {
|
|
@@ -55,10 +116,68 @@ const lastUserMessage = (messages: AskMessage[]): string => {
|
|
|
55
116
|
return "";
|
|
56
117
|
};
|
|
57
118
|
|
|
58
|
-
/**
|
|
59
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Excerpt the region of `content` most relevant to `query`, not just its head.
|
|
121
|
+
*
|
|
122
|
+
* Pages are indexed whole (one document each), so a naive head slice of a long
|
|
123
|
+
* page returns its intro and misses sections below the fold — the exact failure
|
|
124
|
+
* where "How does Ask AI work?" retrieves the right page but only sees its
|
|
125
|
+
* opening paragraph. This centers the window on the densest cluster of query
|
|
126
|
+
* terms so the injected text is the part that actually answers the question.
|
|
127
|
+
*/
|
|
128
|
+
const relevantExcerpt = (
|
|
129
|
+
content: string,
|
|
130
|
+
query: string,
|
|
131
|
+
max: number
|
|
132
|
+
): string => {
|
|
60
133
|
const trimmed = content.trim();
|
|
61
|
-
|
|
134
|
+
if (trimmed.length <= max) {
|
|
135
|
+
return trimmed;
|
|
136
|
+
}
|
|
137
|
+
const withEllipsis = (start: number): string => {
|
|
138
|
+
const slice = trimmed.slice(start, start + max).trim();
|
|
139
|
+
const prefix = start > 0 ? "…" : "";
|
|
140
|
+
const suffix = start + max < trimmed.length ? "…" : "";
|
|
141
|
+
return `${prefix}${slice}${suffix}`;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const lower = trimmed.toLowerCase();
|
|
145
|
+
const positions: number[] = [];
|
|
146
|
+
for (const term of queryTerms(query)) {
|
|
147
|
+
let idx = lower.indexOf(term);
|
|
148
|
+
while (idx !== -1) {
|
|
149
|
+
positions.push(idx);
|
|
150
|
+
idx = lower.indexOf(term, idx + term.length);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// No query terms hit this doc — nothing to center on, so keep the head.
|
|
154
|
+
if (positions.length === 0) {
|
|
155
|
+
return withEllipsis(0);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Pick the term hit whose following `max`-char window covers the most hits.
|
|
159
|
+
// `positions` is non-empty here, so the first window (count ≥ 1) always wins
|
|
160
|
+
// over the initial 0 and assigns a real offset to `best`.
|
|
161
|
+
positions.sort((a, b) => a - b);
|
|
162
|
+
let best = 0;
|
|
163
|
+
let bestCount = 0;
|
|
164
|
+
for (const start of positions) {
|
|
165
|
+
const end = start + max;
|
|
166
|
+
let count = 0;
|
|
167
|
+
for (const pos of positions) {
|
|
168
|
+
if (pos >= end) {
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
if (pos >= start) {
|
|
172
|
+
count += 1;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (count > bestCount) {
|
|
176
|
+
bestCount = count;
|
|
177
|
+
best = start;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return withEllipsis(Math.max(0, best - EXCERPT_LEAD));
|
|
62
181
|
};
|
|
63
182
|
|
|
64
183
|
/**
|
|
@@ -111,7 +230,11 @@ export const createAskContext = (
|
|
|
111
230
|
return;
|
|
112
231
|
}
|
|
113
232
|
seen.add(doc.route);
|
|
114
|
-
const body =
|
|
233
|
+
const body = relevantExcerpt(
|
|
234
|
+
doc.content,
|
|
235
|
+
query,
|
|
236
|
+
Math.min(EXCERPT_CHARS, budget)
|
|
237
|
+
);
|
|
115
238
|
budget -= body.length;
|
|
116
239
|
sections.push(`## ${doc.title} (${doc.route})${label}\n${body}`);
|
|
117
240
|
};
|
package/src/ai/ask-data.ts
CHANGED
|
@@ -6,10 +6,13 @@ import type { AskData } from "./ask-context.ts";
|
|
|
6
6
|
* Build the grounding snapshot the Ask AI endpoint serves. Like the MCP server,
|
|
7
7
|
* Ask AI is independent of on-page search, so documents are indexed even when the
|
|
8
8
|
* search provider is `none` (`includeWhenDisabled`). `locale` is kept (unlike the
|
|
9
|
-
* MCP snapshot) so retrieval can be filtered to the current page's language
|
|
9
|
+
* MCP snapshot) so retrieval can be filtered to the current page's language, and
|
|
10
|
+
* content is kept as Markdown so grounding sees fenced code examples — the model
|
|
11
|
+
* answers "what does the config look like?" from the docs instead of declining.
|
|
10
12
|
*/
|
|
11
13
|
export const buildAskData = async (project: BlumeProject): Promise<AskData> => {
|
|
12
14
|
const documents = await buildSearchDocuments(project, {
|
|
15
|
+
content: "markdown",
|
|
13
16
|
includeWhenDisabled: true,
|
|
14
17
|
});
|
|
15
18
|
return {
|
package/src/astro/generate.ts
CHANGED
|
@@ -330,6 +330,26 @@ export const detectNeedsReact = async (root: string): Promise<boolean> => {
|
|
|
330
330
|
return matches.length > 0;
|
|
331
331
|
};
|
|
332
332
|
|
|
333
|
+
/**
|
|
334
|
+
* Detect whether the project authors block math (`$$…$$`) in any `.mdx`. Drives
|
|
335
|
+
* whether the generated runtime imports the `<Math>` component and KaTeX's
|
|
336
|
+
* stylesheet, so a math-free site ships no KaTeX CSS. Math parsing itself is
|
|
337
|
+
* always on but block-only, so a literal `$$` in source is a necessary
|
|
338
|
+
* condition — no false negatives. A stray `$$` (e.g. inside a code fence) merely
|
|
339
|
+
* over-includes the idempotent import, which is harmless.
|
|
340
|
+
*/
|
|
341
|
+
export const detectUsesMath = async (root: string): Promise<boolean> => {
|
|
342
|
+
const files = await glob(["**/*.mdx"], {
|
|
343
|
+
cwd: root,
|
|
344
|
+
ignore: ["**/node_modules/**", "**/.blume/**", "**/dist/**"],
|
|
345
|
+
onlyFiles: true,
|
|
346
|
+
});
|
|
347
|
+
const contents = await Promise.all(
|
|
348
|
+
files.map((file) => readOptional(join(root, file)))
|
|
349
|
+
);
|
|
350
|
+
return contents.some((content) => content.includes("$$"));
|
|
351
|
+
};
|
|
352
|
+
|
|
333
353
|
const writeIfChanged = async (
|
|
334
354
|
path: string,
|
|
335
355
|
content: string
|
|
@@ -620,6 +640,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
|
|
|
620
640
|
code,
|
|
621
641
|
withReferenceTabs(
|
|
622
642
|
graph.navigationByLocale[code] ?? {
|
|
643
|
+
featured: [],
|
|
623
644
|
selectors: [],
|
|
624
645
|
sidebar: [],
|
|
625
646
|
tabs: [],
|
|
@@ -633,6 +654,9 @@ export const buildRuntimeData = (project: BlumeProject): string => {
|
|
|
633
654
|
config: {
|
|
634
655
|
analytics: config.analytics ?? null,
|
|
635
656
|
appleIcon: resolveAppleIcon(project),
|
|
657
|
+
ask: config.ai.ask?.enabled
|
|
658
|
+
? { suggestions: config.ai.ask.suggestions }
|
|
659
|
+
: null,
|
|
636
660
|
banner: resolveBanner(config),
|
|
637
661
|
codeWrap: config.markdown.code.wrap,
|
|
638
662
|
description: config.description,
|
|
@@ -929,16 +953,21 @@ export const generateRuntime = async (
|
|
|
929
953
|
const askEnabled = config.ai.ask?.enabled ?? false;
|
|
930
954
|
const exportPdf = config.export.pdf;
|
|
931
955
|
const exportEpub = config.export.epub;
|
|
932
|
-
const [
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
956
|
+
const [
|
|
957
|
+
pages,
|
|
958
|
+
detectedReact,
|
|
959
|
+
usesMath,
|
|
960
|
+
userTheme,
|
|
961
|
+
islandDiscovery,
|
|
962
|
+
exampleDiscovery,
|
|
963
|
+
] = await Promise.all([
|
|
964
|
+
context.pagesRoot ? discoverPages(context.pagesRoot) : Promise.resolve([]),
|
|
965
|
+
detectNeedsReact(context.root),
|
|
966
|
+
detectUsesMath(context.root),
|
|
967
|
+
readOptional(context.themeFile),
|
|
968
|
+
discoverIslands(context.root),
|
|
969
|
+
discoverExamples(context.root, config.examples),
|
|
970
|
+
]);
|
|
942
971
|
// Statically analyze `components.ts` overrides (never executed): drives the
|
|
943
972
|
// `islands` group, hydration on layout/mdx overrides, string-path resolution,
|
|
944
973
|
// and the "framework component with no client mode" diagnostic.
|
|
@@ -1024,7 +1053,7 @@ export const generateRuntime = async (
|
|
|
1024
1053
|
askEnabled,
|
|
1025
1054
|
exportEpub,
|
|
1026
1055
|
exportPdf,
|
|
1027
|
-
mathEnabled:
|
|
1056
|
+
mathEnabled: usesMath,
|
|
1028
1057
|
needsReact,
|
|
1029
1058
|
})
|
|
1030
1059
|
),
|
package/src/astro/templates.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { dirname, isAbsolute, join, relative } from "pathe";
|
|
|
5
5
|
import { askBackendRuntimeDep } from "../ai/ask.ts";
|
|
6
6
|
import type { AskBackend } from "../ai/ask.ts";
|
|
7
7
|
import type { ResolvedConfig } from "../core/schema.ts";
|
|
8
|
+
import { BLUME_IGNORE_DIRS } from "../core/sources/watch.ts";
|
|
8
9
|
import type { ProjectContext } from "../core/types.ts";
|
|
9
10
|
import { hasScalarReferences } from "../openapi/references.ts";
|
|
10
11
|
import { searchProviderMeta } from "../search/providers.ts";
|
|
@@ -299,8 +300,6 @@ export const astroConfigTemplate = (options: {
|
|
|
299
300
|
const integrations = [
|
|
300
301
|
`mdx({ processor: blumeMdxProcessor(${JSON.stringify({
|
|
301
302
|
headingAnchors: config.markdown.headingAnchors,
|
|
302
|
-
inline: config.markdown.code.inline,
|
|
303
|
-
math: config.markdown.math,
|
|
304
303
|
})}) })`,
|
|
305
304
|
];
|
|
306
305
|
if (needsReact) {
|
|
@@ -334,7 +333,6 @@ export default defineConfig({
|
|
|
334
333
|
markdown: {
|
|
335
334
|
processor: blumeMarkdownProcessor(${JSON.stringify({
|
|
336
335
|
headingAnchors: config.markdown.headingAnchors,
|
|
337
|
-
inline: config.markdown.code.inline,
|
|
338
336
|
})}),
|
|
339
337
|
shikiConfig: {
|
|
340
338
|
themes: {
|
|
@@ -354,9 +352,17 @@ export default defineConfig({
|
|
|
354
352
|
// native bindings resolve at runtime and isolated linkers don't bundle
|
|
355
353
|
// symlinked store copies (which would surface their children as unresolvable
|
|
356
354
|
// imports). See RENDER_EXTERNAL_DEPS / prerenderDepsPlugin.
|
|
355
|
+
//
|
|
356
|
+
// The SSR externals go through the legacy \`ssr.external\` key rather than
|
|
357
|
+
// \`environments.ssr\`: defining a user-owned \`environments.ssr\` block
|
|
358
|
+
// collides with the internal environment Astro 7 builds the server under and
|
|
359
|
+
// detaches the adapter's server entrypoint from the rolldown input, so the
|
|
360
|
+
// SSR entry is emitted as \`index.mjs\` instead of the \`entry.mjs\` the
|
|
361
|
+
// Vercel adapter's \`astro:build:done\` hook then fails to find. \`prerender\`
|
|
362
|
+
// is Astro-only and has no legacy equivalent, so it stays under \`environments\`.
|
|
363
|
+
ssr: { external: ${JSON.stringify(RENDER_EXTERNAL_DEPS)} },
|
|
357
364
|
environments: {
|
|
358
365
|
prerender: { resolve: { external: ${JSON.stringify(RENDER_EXTERNAL_DEPS)} } },
|
|
359
|
-
ssr: { resolve: { external: ${JSON.stringify(RENDER_EXTERNAL_DEPS)} } },
|
|
360
366
|
},
|
|
361
367
|
resolve: {
|
|
362
368
|
alias: {
|
|
@@ -432,7 +438,15 @@ export const contentConfigTemplate = (options: {
|
|
|
432
438
|
? [
|
|
433
439
|
...config.content.include,
|
|
434
440
|
...(config.content.exclude ?? []).map((pattern) => `!${pattern}`),
|
|
435
|
-
|
|
441
|
+
// Mirror the filesystem scan's baseline ignores (see BLUME_IGNORE_DIRS):
|
|
442
|
+
// Astro's content layer roots at the project dir, so a `.`-wide content
|
|
443
|
+
// root would otherwise re-ingest dependency trees and build output —
|
|
444
|
+
// e.g. a prior `dist/*.mdx` render — and crash the content-module graph.
|
|
445
|
+
// The runtime dir (`.blume`, or a custom distDir) is excluded precisely
|
|
446
|
+
// by `outDirIgnore` instead, so it's left out of this baseline.
|
|
447
|
+
...BLUME_IGNORE_DIRS.filter((dir) => dir !== ".blume").map(
|
|
448
|
+
(dir) => `!**/${dir}/**`
|
|
449
|
+
),
|
|
436
450
|
...outDirIgnore,
|
|
437
451
|
]
|
|
438
452
|
: [];
|
|
@@ -866,7 +880,7 @@ const siteHost = (() => {
|
|
|
866
880
|
|
|
867
881
|
export async function GET({ props }) {
|
|
868
882
|
const png = await renderOgImage({
|
|
869
|
-
accent: data.config.theme.accent,
|
|
883
|
+
accent: data.config.theme.accent.light,
|
|
870
884
|
brand: data.config.title,
|
|
871
885
|
description: data.config.description,
|
|
872
886
|
logo: data.config.logo?.svg,
|
|
@@ -939,7 +953,7 @@ export const catchAllPageTemplate = (options: {
|
|
|
939
953
|
? 'import AskAI from "blume/components/islands/AskAI.astro";\n'
|
|
940
954
|
: "";
|
|
941
955
|
const askSlot = options.askEnabled
|
|
942
|
-
? '\n <AskAI slot="ask" strings={ui.ask} />'
|
|
956
|
+
? '\n <AskAI slot="ask" strings={ui.ask} suggestions={data.config.ask?.suggestions ?? []} />'
|
|
943
957
|
: "";
|
|
944
958
|
const mathImport = options.mathEnabled
|
|
945
959
|
? 'import Math from "blume/components/content/Math.astro";\n'
|
|
@@ -1210,7 +1224,9 @@ export const changelogIndexTemplate = (options: {
|
|
|
1210
1224
|
const askImport = options.askEnabled
|
|
1211
1225
|
? 'import AskAI from "blume/components/islands/AskAI.astro";\n'
|
|
1212
1226
|
: "";
|
|
1213
|
-
const askSlot = options.askEnabled
|
|
1227
|
+
const askSlot = options.askEnabled
|
|
1228
|
+
? '\n <AskAI slot="ask" strings={data.ui.ask} suggestions={data.config.ask?.suggestions ?? []} />'
|
|
1229
|
+
: "";
|
|
1214
1230
|
const clientData = options.needsReact
|
|
1215
1231
|
? '\n clientData={{ config: data.config, navigation: data.navigation, page: { route: "/changelog", title: data.config.title + " changelog" } }}'
|
|
1216
1232
|
: "";
|
|
@@ -1259,6 +1275,20 @@ const slugify = (text) =>
|
|
|
1259
1275
|
text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") ||
|
|
1260
1276
|
"update";
|
|
1261
1277
|
|
|
1278
|
+
// The major of a version's embedded semver (\`1.2.3\` -> 1, \`pkg@2.0.0\` -> 2), or
|
|
1279
|
+
// null when there is no full major.minor.patch to key on. Drives the changelog's
|
|
1280
|
+
// group-by-major pagination, so it tolerates the scoped tags monorepos publish.
|
|
1281
|
+
const majorVersion = (version) => {
|
|
1282
|
+
const match = /(\\d+)\\.\\d+\\.\\d+/.exec(String(version ?? ""));
|
|
1283
|
+
return match ? Number(match[1]) : null;
|
|
1284
|
+
};
|
|
1285
|
+
|
|
1286
|
+
// Map each entry to its own generated page so the timeline heading can deep-link
|
|
1287
|
+
// to it. The collection entry id matches the route manifest's \`entryId\`.
|
|
1288
|
+
const routeByEntry = new Map(
|
|
1289
|
+
data.routes.map((route) => [route.entryId, route.path])
|
|
1290
|
+
);
|
|
1291
|
+
|
|
1262
1292
|
const changelogEntries = [
|
|
1263
1293
|
...(await getCollection("docs")),${stagedSpread}
|
|
1264
1294
|
]
|
|
@@ -1280,8 +1310,10 @@ const items = await Promise.all(
|
|
|
1280
1310
|
return {
|
|
1281
1311
|
Content: (await render(entry)).Content,
|
|
1282
1312
|
date: formatDate(entryDate(entry)),
|
|
1313
|
+
href: routeByEntry.get(entry.id) ?? null,
|
|
1283
1314
|
id: slugify(label),
|
|
1284
1315
|
label,
|
|
1316
|
+
major: majorVersion(entry.data.changelog?.version),
|
|
1285
1317
|
tags: entry.data.changelog?.category
|
|
1286
1318
|
? [entry.data.changelog.category]
|
|
1287
1319
|
: [],
|
|
@@ -1289,6 +1321,19 @@ const items = await Promise.all(
|
|
|
1289
1321
|
})
|
|
1290
1322
|
);
|
|
1291
1323
|
|
|
1324
|
+
// A changelog is semver-paginated only when every visible release parses as
|
|
1325
|
+
// semver and they span more than one major line. Older majors then collapse
|
|
1326
|
+
// into groups the reader reveals one at a time; otherwise the timeline is flat.
|
|
1327
|
+
const majors = items.every((item) => item.major !== null)
|
|
1328
|
+
? [...new Set(items.map((item) => item.major))].toSorted((a, b) => b - a)
|
|
1329
|
+
: [];
|
|
1330
|
+
const paginate = majors.length > 1;
|
|
1331
|
+
const majorGroups = majors.map((major) => ({
|
|
1332
|
+
items: items.filter((item) => item.major === major),
|
|
1333
|
+
label: major + ".x",
|
|
1334
|
+
major,
|
|
1335
|
+
}));
|
|
1336
|
+
|
|
1292
1337
|
const headings = items.map((item) => ({
|
|
1293
1338
|
depth: 2,
|
|
1294
1339
|
slug: item.id,
|
|
@@ -1320,6 +1365,7 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
|
|
|
1320
1365
|
}}
|
|
1321
1366
|
headings={headings}
|
|
1322
1367
|
toc={data.config.toc}
|
|
1368
|
+
contentLayout="bare"
|
|
1323
1369
|
themeMode={data.config.theme.mode}
|
|
1324
1370
|
fontCssVars={data.fontCssVars}
|
|
1325
1371
|
searchEnabled={data.config.search.enabled}
|
|
@@ -1338,16 +1384,50 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
|
|
|
1338
1384
|
{
|
|
1339
1385
|
items.length === 0 ? (
|
|
1340
1386
|
<p>No changelog entries yet.</p>
|
|
1387
|
+
) : paginate ? (
|
|
1388
|
+
<blume-changelog class="not-prose mt-8 block">
|
|
1389
|
+
{majorGroups[0].items.map(({ Content, href, id, label, date, tags }) => (
|
|
1390
|
+
<Update description={date} href={href} id={id} label={label} tags={tags}>
|
|
1391
|
+
<Content />
|
|
1392
|
+
</Update>
|
|
1393
|
+
))}
|
|
1394
|
+
{majorGroups.slice(1).map((group) => (
|
|
1395
|
+
<section
|
|
1396
|
+
aria-label={group.label + " releases"}
|
|
1397
|
+
data-changelog-label={group.label}
|
|
1398
|
+
data-changelog-major={group.major}
|
|
1399
|
+
>
|
|
1400
|
+
{group.items.map(({ Content, href, id, label, date, tags }) => (
|
|
1401
|
+
<Update description={date} href={href} id={id} label={label} tags={tags}>
|
|
1402
|
+
<Content />
|
|
1403
|
+
</Update>
|
|
1404
|
+
))}
|
|
1405
|
+
</section>
|
|
1406
|
+
))}
|
|
1407
|
+
<div class="mt-10 flex justify-center">
|
|
1408
|
+
<button
|
|
1409
|
+
class="inline-flex items-center gap-2 rounded-full border border-border bg-background px-4 py-2 font-medium text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground"
|
|
1410
|
+
data-changelog-more
|
|
1411
|
+
hidden
|
|
1412
|
+
type="button"
|
|
1413
|
+
>
|
|
1414
|
+
Show older releases
|
|
1415
|
+
</button>
|
|
1416
|
+
</div>
|
|
1417
|
+
</blume-changelog>
|
|
1341
1418
|
) : (
|
|
1342
1419
|
<div class="not-prose mt-8">
|
|
1343
|
-
{items.map(({ Content, id, label, date, tags }) => (
|
|
1344
|
-
<Update description={date} id={id} label={label} tags={tags}>
|
|
1420
|
+
{items.map(({ Content, href, id, label, date, tags }) => (
|
|
1421
|
+
<Update description={date} href={href} id={id} label={label} tags={tags}>
|
|
1345
1422
|
<Content />
|
|
1346
1423
|
</Update>
|
|
1347
1424
|
))}
|
|
1348
1425
|
</div>
|
|
1349
1426
|
)
|
|
1350
1427
|
}
|
|
1428
|
+
<script>
|
|
1429
|
+
import "blume/components/content/changelog-element.ts";
|
|
1430
|
+
</script>
|
|
1351
1431
|
</LayoutComponent>
|
|
1352
1432
|
`;
|
|
1353
1433
|
};
|
|
@@ -5,11 +5,16 @@ import { build } from "astro";
|
|
|
5
5
|
import { defineCommand } from "citty";
|
|
6
6
|
import { join } from "pathe";
|
|
7
7
|
|
|
8
|
+
import { buildAgentReadability } from "../../ai/agent-readability.ts";
|
|
8
9
|
import { buildLlmsFiles } from "../../ai/llms.ts";
|
|
9
10
|
import { ensureGitignore } from "../../core/gitignore.ts";
|
|
10
11
|
import type { BlumeProject } from "../../core/project-graph.ts";
|
|
11
12
|
import type { ResolvedConfig } from "../../core/schema.ts";
|
|
12
13
|
import { serverFeatures } from "../../core/server-features.ts";
|
|
14
|
+
import {
|
|
15
|
+
deployStaticDir,
|
|
16
|
+
surfaceAdapterOutput,
|
|
17
|
+
} from "../../deploy/adapter-output.ts";
|
|
13
18
|
import {
|
|
14
19
|
buildNetlifyRedirects,
|
|
15
20
|
buildRedirectManifest,
|
|
@@ -218,6 +223,19 @@ const publishBuildArtifacts = async (
|
|
|
218
223
|
logger.success("Generated robots.txt");
|
|
219
224
|
}
|
|
220
225
|
|
|
226
|
+
const agentReadability = buildAgentReadability(project);
|
|
227
|
+
if (
|
|
228
|
+
agentReadability &&
|
|
229
|
+
!existsSync(join(distDir, "agent-readability.json"))
|
|
230
|
+
) {
|
|
231
|
+
await writeFile(
|
|
232
|
+
join(distDir, "agent-readability.json"),
|
|
233
|
+
`${JSON.stringify(agentReadability, null, 2)}\n`,
|
|
234
|
+
"utf-8"
|
|
235
|
+
);
|
|
236
|
+
logger.success("Generated agent-readability.json");
|
|
237
|
+
}
|
|
238
|
+
|
|
221
239
|
await emitRedirectFiles(project.config, distDir);
|
|
222
240
|
|
|
223
241
|
const { config } = project;
|
|
@@ -231,6 +249,7 @@ const publishBuildArtifacts = async (
|
|
|
231
249
|
`Redirects ${config.redirects.length}`,
|
|
232
250
|
`Sitemap ${sitemap ? "yes" : "no (set deployment.site)"}`,
|
|
233
251
|
`Robots ${robots ? "yes" : "no"}`,
|
|
252
|
+
`Agent JSON ${agentReadability ? "yes" : "no"}`,
|
|
234
253
|
`LLM files ${config.ai.llmsTxt ? "yes" : "no"}`,
|
|
235
254
|
`Server features ${features.length > 0 ? features.join(", ") : "none"}`,
|
|
236
255
|
].join("\n")
|
|
@@ -350,6 +369,27 @@ export const buildCommand = defineCommand({
|
|
|
350
369
|
return;
|
|
351
370
|
}
|
|
352
371
|
|
|
353
|
-
|
|
372
|
+
// A server adapter (Vercel/Netlify) writes its deploy bundle relative to the
|
|
373
|
+
// Astro root — which Blume points at the hidden `.blume` runtime — so the
|
|
374
|
+
// bundle lands where the deploy platform never looks. Surface it up to the
|
|
375
|
+
// project root before publishing artifacts into the served static dir.
|
|
376
|
+
const surfaced = await surfaceAdapterOutput(
|
|
377
|
+
project.config,
|
|
378
|
+
project.context
|
|
379
|
+
);
|
|
380
|
+
if (surfaced.moved) {
|
|
381
|
+
logger.success(
|
|
382
|
+
`Surfaced ${project.config.deployment.adapter} output to ${surfaced.to}`
|
|
383
|
+
);
|
|
384
|
+
// The surfaced bundle is a build artifact — keep it out of version control
|
|
385
|
+
// (Vercel's own CLI ignores `.vercel/` for the same reason).
|
|
386
|
+
await ensureGitignore(root, [surfaced.ignore]);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
await publishBuildArtifacts(
|
|
390
|
+
project,
|
|
391
|
+
deployStaticDir(project.config, project.context),
|
|
392
|
+
args
|
|
393
|
+
);
|
|
354
394
|
},
|
|
355
395
|
});
|
package/src/cli/commands/dev.ts
CHANGED
|
@@ -6,9 +6,15 @@ import { defineCommand } from "citty";
|
|
|
6
6
|
import { generateRuntime } from "../../astro/generate.ts";
|
|
7
7
|
import { showBlumeErrorOverlay } from "../../astro/integration.ts";
|
|
8
8
|
import { scanProject } from "../../core/project-graph.ts";
|
|
9
|
+
import { resolveRuntimeDir } from "../../core/project.ts";
|
|
9
10
|
import { parsePort } from "../args.ts";
|
|
10
11
|
import { coalescedRunner } from "../coalesce.ts";
|
|
11
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
acquireDevLock,
|
|
14
|
+
describeDevLock,
|
|
15
|
+
readDevLock,
|
|
16
|
+
updateDevLockPort,
|
|
17
|
+
} from "../dev-lock.ts";
|
|
12
18
|
import { logger } from "../log.ts";
|
|
13
19
|
import { prepareProject } from "../prepare.ts";
|
|
14
20
|
|
|
@@ -47,6 +53,23 @@ export const devCommand = defineCommand({
|
|
|
47
53
|
const explicitPort = parsePort(args.port);
|
|
48
54
|
const port = explicitPort ?? 4321;
|
|
49
55
|
const devServerUrl = `http://localhost:${port}`;
|
|
56
|
+
|
|
57
|
+
// Claim the shared `.blume` dir BEFORE preparing: `prepareProject`
|
|
58
|
+
// regenerates the runtime, so even a refused second dev server would
|
|
59
|
+
// otherwise clobber the running one's generated tree (with this
|
|
60
|
+
// invocation's port baked in) on its way out. Dev never relocates the
|
|
61
|
+
// runtime dir, so the lock always lives at `<root>/.blume`.
|
|
62
|
+
const outDir = resolveRuntimeDir(root);
|
|
63
|
+
const running = readDevLock(outDir);
|
|
64
|
+
if (running) {
|
|
65
|
+
logger.error(
|
|
66
|
+
`A \`blume dev\` server is already running${describeDevLock(running)} in this project. Reuse that server instead of starting a second one — two dev servers would corrupt the shared .blume dir. If it crashed, delete .blume/dev.lock.`
|
|
67
|
+
);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
const releaseLock = acquireDevLock(outDir, port);
|
|
71
|
+
process.on("exit", releaseLock);
|
|
72
|
+
|
|
50
73
|
const project = await prepareProject({
|
|
51
74
|
devServerUrl,
|
|
52
75
|
mode: "dev",
|
|
@@ -56,19 +79,6 @@ export const devCommand = defineCommand({
|
|
|
56
79
|
strict: args.strict,
|
|
57
80
|
});
|
|
58
81
|
|
|
59
|
-
// Claim the shared `.blume` dir so a concurrent build/eject/sync refuses
|
|
60
|
-
// rather than regenerating or deleting it out from under this server. A
|
|
61
|
-
// second dev server would fight over the same generated tree the same
|
|
62
|
-
// way, so it must refuse too instead of silently clobbering the lock.
|
|
63
|
-
if (isDevLocked(project.context.outDir)) {
|
|
64
|
-
logger.error(
|
|
65
|
-
"Another `blume dev` is already running in this project; two dev servers would corrupt the shared .blume dir. Stop the other one first (or delete .blume/dev.lock if it crashed)."
|
|
66
|
-
);
|
|
67
|
-
process.exit(1);
|
|
68
|
-
}
|
|
69
|
-
const releaseLock = acquireDevLock(project.context.outDir);
|
|
70
|
-
process.on("exit", releaseLock);
|
|
71
|
-
|
|
72
82
|
const server = await dev({
|
|
73
83
|
logLevel: args.debug ? "debug" : "info",
|
|
74
84
|
root: project.context.outDir,
|
|
@@ -79,6 +89,13 @@ export const devCommand = defineCommand({
|
|
|
79
89
|
},
|
|
80
90
|
});
|
|
81
91
|
|
|
92
|
+
// Vite bumps to the next free port when the default is taken, so record
|
|
93
|
+
// the port the server actually bound — the lock's URL is what a refused
|
|
94
|
+
// second invocation tells its caller to reuse.
|
|
95
|
+
if (server.address.port !== port) {
|
|
96
|
+
updateDevLockPort(outDir, server.address.port);
|
|
97
|
+
}
|
|
98
|
+
|
|
82
99
|
// Mirror any initial diagnostics into the browser overlay now the server
|
|
83
100
|
// (and its HMR channel) is up.
|
|
84
101
|
showBlumeErrorOverlay(project.diagnostics);
|