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.
Files changed (57) hide show
  1. package/dist/cli/index.js +759 -406
  2. package/dist/cli/index.js.map +27 -25
  3. package/dist/types/core/config-input.d.ts +759 -0
  4. package/dist/types/core/config.d.ts +126 -3
  5. package/dist/types/core/data.d.ts +4 -0
  6. package/dist/types/core/i18n-ui.d.ts +50 -0
  7. package/dist/types/core/schema.d.ts +334 -62
  8. package/dist/types/core/types.d.ts +8 -0
  9. package/dist/types/index.d.ts +2 -1
  10. package/docs/advanced/changelog.mdx +10 -2
  11. package/docs/configuration/ai.mdx +56 -0
  12. package/docs/configuration/index.mdx +0 -2
  13. package/docs/configuration/seo.mdx +59 -1
  14. package/docs/configuration/theming.mdx +14 -9
  15. package/docs/content/meta.mdx +3 -17
  16. package/docs/content/navigation.mdx +41 -4
  17. package/docs/content/syntax.mdx +4 -8
  18. package/package.json +3 -1
  19. package/src/ai/agent-readability.ts +97 -0
  20. package/src/ai/ask-context.ts +131 -8
  21. package/src/ai/ask-data.ts +4 -1
  22. package/src/astro/generate.ts +40 -11
  23. package/src/astro/templates.ts +90 -10
  24. package/src/cli/commands/build.ts +41 -1
  25. package/src/cli/commands/dev.ts +31 -14
  26. package/src/cli/dev-lock.ts +94 -21
  27. package/src/components/content/GithubInfo.astro +11 -10
  28. package/src/components/content/TypeTable.astro +8 -3
  29. package/src/components/content/Update.astro +12 -2
  30. package/src/components/content/changelog-element.ts +62 -0
  31. package/src/components/islands/AskAI.astro +66 -2
  32. package/src/components/islands/ask-ai.tsx +289 -53
  33. package/src/components/layout/Header.astro +1 -1
  34. package/src/components/layout/NavTree.astro +1 -1
  35. package/src/components/layout/PageActions.astro +73 -30
  36. package/src/components/layout/RootLayout.astro +79 -10
  37. package/src/core/config-input.ts +933 -0
  38. package/src/core/config.ts +126 -3
  39. package/src/core/data.ts +4 -0
  40. package/src/core/graph.ts +7 -2
  41. package/src/core/i18n-ui.ts +5 -0
  42. package/src/core/nav-diagnostics.ts +7 -0
  43. package/src/core/navigation.ts +38 -12
  44. package/src/core/schema.ts +130 -22
  45. package/src/core/sources/filesystem.ts +5 -1
  46. package/src/core/sources/watch.ts +43 -12
  47. package/src/core/types.ts +9 -0
  48. package/src/deploy/adapter-output.ts +82 -0
  49. package/src/deploy/robots.ts +37 -4
  50. package/src/index.ts +1 -1
  51. package/src/markdown/index.ts +28 -30
  52. package/src/markdown/math.ts +3 -2
  53. package/src/openapi/scalar.ts +1 -1
  54. package/src/registry/eject.ts +21 -14
  55. package/src/search/documents.ts +9 -2
  56. package/src/theme/entry.ts +7 -3
  57. package/src/theme/palette.ts +21 -14
@@ -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 = 1500;
29
+ const EXCERPT_CHARS = 2000;
30
30
  /** Overall cap on injected documentation characters. */
31
- const CONTEXT_BUDGET = 8000;
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, so answers stay tied to the docs.
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 them, say you don't know and suggest where in the docs to look — do not invent details. Cite the page titles you drew from.";
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
- /** Trim a document body to `max` characters, marking truncation with an ellipsis. */
59
- const excerpt = (content: string, max: number): string => {
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
- return trimmed.length > max ? `${trimmed.slice(0, max)}…` : trimmed;
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 = excerpt(doc.content, Math.min(EXCERPT_CHARS, budget));
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
  };
@@ -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 {
@@ -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 [pages, detectedReact, userTheme, islandDiscovery, exampleDiscovery] =
933
- await Promise.all([
934
- context.pagesRoot
935
- ? discoverPages(context.pagesRoot)
936
- : Promise.resolve([]),
937
- detectNeedsReact(context.root),
938
- readOptional(context.themeFile),
939
- discoverIslands(context.root),
940
- discoverExamples(context.root, config.examples),
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: config.markdown.math,
1056
+ mathEnabled: usesMath,
1028
1057
  needsReact,
1029
1058
  })
1030
1059
  ),
@@ -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
- "!**/node_modules/**",
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 ? '\n <AskAI slot="ask" />' : "";
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
- await publishBuildArtifacts(project, distDir, args);
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
  });
@@ -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 { acquireDevLock, isDevLocked } from "../dev-lock.ts";
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);