blume 1.6.0 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/cli/index.js +434 -150
  3. package/dist/cli/index.js.map +59 -56
  4. package/dist/types/core/data.d.ts +10 -1
  5. package/dist/types/core/i18n-ui.d.ts +2 -0
  6. package/dist/types/core/schema.d.ts +5 -0
  7. package/dist/types/core/types.d.ts +6 -0
  8. package/dist/types/openapi/references.d.ts +5 -0
  9. package/docs/07-faq.mdx +9 -9
  10. package/docs/advanced/api-reference.mdx +10 -1
  11. package/docs/advanced/custom-pages.mdx +3 -1
  12. package/docs/advanced/graphql.mdx +1 -1
  13. package/docs/configuration/ai.mdx +4 -0
  14. package/docs/configuration/seo.mdx +3 -3
  15. package/docs/configuration/theming.mdx +6 -0
  16. package/docs/content/components.mdx +8 -1
  17. package/package.json +53 -53
  18. package/src/astro/examples.ts +29 -2
  19. package/src/astro/generate.ts +99 -61
  20. package/src/astro/index.ts +7 -0
  21. package/src/astro/markdown-negotiation.ts +1 -1
  22. package/src/astro/runtime-modules.ts +196 -0
  23. package/src/astro/templates.ts +241 -38
  24. package/src/cli/commands/build.ts +7 -1
  25. package/src/cli/commands/dev.ts +6 -3
  26. package/src/cli/host-args.ts +18 -0
  27. package/src/cli/index.ts +2 -1
  28. package/src/components/copy-feedback.ts +93 -9
  29. package/src/components/islands/ask-ai.tsx +4 -1
  30. package/src/components/islands/hooks.ts +3 -1
  31. package/src/components/layout/PageActions.astro +25 -14
  32. package/src/core/data.ts +10 -1
  33. package/src/core/define-components.ts +2 -0
  34. package/src/core/i18n-ui.ts +1 -0
  35. package/src/core/includes.ts +2 -1
  36. package/src/core/manifest.ts +10 -0
  37. package/src/core/schema.ts +13 -5
  38. package/src/core/types.ts +6 -0
  39. package/src/core/ui-packs/ar.ts +1 -0
  40. package/src/core/ui-packs/bg.ts +1 -0
  41. package/src/core/ui-packs/bn.ts +1 -0
  42. package/src/core/ui-packs/ca.ts +1 -0
  43. package/src/core/ui-packs/cs.ts +1 -0
  44. package/src/core/ui-packs/da.ts +1 -0
  45. package/src/core/ui-packs/de.ts +1 -0
  46. package/src/core/ui-packs/el.ts +1 -0
  47. package/src/core/ui-packs/es.ts +1 -0
  48. package/src/core/ui-packs/fa.ts +1 -0
  49. package/src/core/ui-packs/fi.ts +1 -0
  50. package/src/core/ui-packs/fr.ts +1 -0
  51. package/src/core/ui-packs/he.ts +1 -0
  52. package/src/core/ui-packs/hi.ts +1 -0
  53. package/src/core/ui-packs/hr.ts +1 -0
  54. package/src/core/ui-packs/hu.ts +1 -0
  55. package/src/core/ui-packs/id.ts +1 -0
  56. package/src/core/ui-packs/it.ts +1 -0
  57. package/src/core/ui-packs/ja.ts +1 -0
  58. package/src/core/ui-packs/ko.ts +1 -0
  59. package/src/core/ui-packs/nl.ts +1 -0
  60. package/src/core/ui-packs/no.ts +1 -0
  61. package/src/core/ui-packs/pl.ts +1 -0
  62. package/src/core/ui-packs/pt-br.ts +1 -0
  63. package/src/core/ui-packs/pt.ts +1 -0
  64. package/src/core/ui-packs/ro.ts +1 -0
  65. package/src/core/ui-packs/ru.ts +1 -0
  66. package/src/core/ui-packs/sk.ts +1 -0
  67. package/src/core/ui-packs/sr.ts +1 -0
  68. package/src/core/ui-packs/sv.ts +1 -0
  69. package/src/core/ui-packs/th.ts +1 -0
  70. package/src/core/ui-packs/tr.ts +1 -0
  71. package/src/core/ui-packs/uk.ts +1 -0
  72. package/src/core/ui-packs/vi.ts +1 -0
  73. package/src/core/ui-packs/zh-tw.ts +1 -0
  74. package/src/core/ui-packs/zh.ts +1 -0
  75. package/src/core/version-cut.ts +5 -3
  76. package/src/deploy/vercel-negotiation.ts +49 -6
  77. package/src/og/card.ts +1 -1
  78. package/src/openapi/references.ts +8 -0
  79. package/src/openapi/render-mdx.ts +18 -4
  80. package/src/openapi/scalar.ts +0 -4
  81. package/src/registry/eject.ts +36 -17
  82. package/src/theme/entry.ts +2 -2
  83. package/src/theme/sources.ts +49 -0
@@ -22,6 +22,7 @@ import type { ExampleSpec } from "./examples.ts";
22
22
  import type { BlumePageRoute } from "./integration.ts";
23
23
  import type { IslandSpec } from "./islands.ts";
24
24
  import type { OgCustomRoute } from "./pages.ts";
25
+ import { RUNTIME_MODULE_FILES } from "./runtime-modules.ts";
25
26
 
26
27
  const WORKSPACE_MARKERS = [
27
28
  ".git",
@@ -290,9 +291,38 @@ const adapterRoot = (context: ProjectContext): string =>
290
291
  * re-optimization. A blanket `/node_modules/` exclude would instead switch the
291
292
  * React Compiler off for Blume's own components in published installs (they
292
293
  * resolve under `node_modules/blume/src`, and exclude beats include in the
293
- * plugin's filter), so only the pre-bundle cache is excluded.
294
+ * plugin's filter), so only the pre-bundle cache is excluded. The hidden runtime
295
+ * relocates that cache to `<runtime>/.cache/vite` (see `cacheOptions`), so both
296
+ * the default and the relocated path are excluded.
294
297
  */
295
- const REACT_EXCLUDE = String.raw`exclude: [/\/node_modules\/\.vite\//]`;
298
+ const REACT_EXCLUDE = String.raw`exclude: [/\/node_modules\/\.vite\//, /\/\.cache\/vite\//]`;
299
+
300
+ /**
301
+ * The `cacheDir` entries for the generated config's top level and its `vite`
302
+ * block. The hidden runtime's `node_modules` is a junction into Blume's own
303
+ * package directory, and Astro (`node_modules/.astro`: the content data store,
304
+ * the fonts cache) and Vite (`node_modules/.vite`: pre-bundled deps) both
305
+ * default their caches under the project's `node_modules`. Two Blume projects
306
+ * that resolve the same package (a monorepo building docs and a sandbox in
307
+ * parallel) would therefore share one data store, and each build would serve
308
+ * the other's content — or 404 on entries the other cleared. Keep every cache
309
+ * inside the runtime dir instead. An ejected project (`generatedModulesDir`
310
+ * set) has real `node_modules`, so it keeps the defaults.
311
+ */
312
+ const runtimeCacheOptions = (
313
+ context: ProjectContext,
314
+ generatedModulesDir: string | undefined
315
+ ) => {
316
+ if (generatedModulesDir !== undefined) {
317
+ return { astro: "", vite: "" };
318
+ }
319
+ return {
320
+ astro: `
321
+ cacheDir: ${JSON.stringify(`${context.outDir}/.cache/astro`)},`,
322
+ vite: `
323
+ cacheDir: ${JSON.stringify(`${context.outDir}/.cache/vite`)},`,
324
+ };
325
+ };
296
326
 
297
327
  /**
298
328
  * The `react()` integration call. When `compilerPath` is set (the resolved
@@ -394,6 +424,40 @@ const resolveOptimizeDeps = (options: {
394
424
  return { optimizeDepsEntries, optimizeDepsInclude };
395
425
  };
396
426
 
427
+ /**
428
+ * How the generated config reaches the runtime data modules (`blume:data`,
429
+ * the search index, …): served from memory by `runtimeModulesPlugin` in the
430
+ * hidden runtime, or aliased to JSON files under `generatedModulesDir` for an
431
+ * ejected project, which has no CLI to publish them (see `runtime-modules.ts`).
432
+ */
433
+ interface RuntimeModuleWiring {
434
+ /** `resolve.alias` entries (one per module), empty in the in-memory form. */
435
+ aliasLines: string;
436
+ /** Extra `blume/astro` imports the wiring needs. */
437
+ imports: string[];
438
+ /** Leading `vite.plugins` entry, empty in the file-alias form. */
439
+ pluginEntry: string;
440
+ }
441
+
442
+ const renderRuntimeModuleWiring = (
443
+ generatedModulesDir: string | undefined
444
+ ): RuntimeModuleWiring => {
445
+ if (generatedModulesDir === undefined) {
446
+ return {
447
+ aliasLines: "",
448
+ imports: ["runtimeModulesPlugin"],
449
+ pluginEntry: "runtimeModulesPlugin(), ",
450
+ };
451
+ }
452
+ const aliasLines = [...RUNTIME_MODULE_FILES]
453
+ .map(
454
+ ([id, file]) =>
455
+ `\n ${JSON.stringify(id)}: ${JSON.stringify(`${generatedModulesDir}/${file}`)},`
456
+ )
457
+ .join("");
458
+ return { aliasLines, imports: [], pluginEntry: "" };
459
+ };
460
+
397
461
  export const astroConfigTemplate = (options: {
398
462
  context: ProjectContext;
399
463
  config: ResolvedConfig;
@@ -404,13 +468,18 @@ export const astroConfigTemplate = (options: {
404
468
  contentRoutes: string[];
405
469
  /** The generated Ask trigger (`blume:ask`); renders nothing when Ask is off. */
406
470
  askPath: string;
407
- dataPath: string;
408
471
  examplesPath: string;
409
472
  /** The example-preview Tailwind entry (`blume:examples-theme`). */
410
473
  examplesThemePath: string;
411
474
  themePath: string;
412
475
  searchClientPath: string;
413
- openapiPath: string;
476
+ /**
477
+ * Where the runtime data modules (`blume:data`, the search index, …) live as
478
+ * JSON files, for a project with no CLI to publish them in memory (eject):
479
+ * each id is aliased to its file under this directory. Absent, the modules
480
+ * are served from memory by `runtimeModulesPlugin` — the hidden runtime.
481
+ */
482
+ generatedModulesDir?: string;
414
483
  /**
415
484
  * Absolute path to `babel-plugin-react-compiler` when the React Compiler is
416
485
  * enabled (resolved from Blume's package root by the caller); null/absent
@@ -427,17 +496,27 @@ export const astroConfigTemplate = (options: {
427
496
  /** Bridge used to load configured integrations without serializing them. */
428
497
  integrationBridge?: IntegrationBridgeOptions;
429
498
  }): string => {
430
- const { context, config, needsReact, pages, dataPath, themePath } = options;
499
+ const { context, config, needsReact, pages, themePath } = options;
500
+
501
+ const { astro: cacheOptions, vite: viteCacheOption } = runtimeCacheOptions(
502
+ context,
503
+ options.generatedModulesDir
504
+ );
431
505
  const {
432
506
  askPath,
433
507
  contentRoutes,
434
508
  examplesPath,
435
509
  examplesThemePath,
510
+ generatedModulesDir,
436
511
  needsSvelte,
437
512
  needsVue,
438
- openapiPath,
439
513
  searchClientPath,
440
514
  } = options;
515
+ const {
516
+ aliasLines: runtimeModuleAliasLines,
517
+ imports: runtimeModuleImports,
518
+ pluginEntry: runtimeModulesPluginEntry,
519
+ } = renderRuntimeModuleWiring(generatedModulesDir);
441
520
  const { deployment } = config;
442
521
  const userAliasLines = renderUserAliases(options.aliases);
443
522
  const server = deployment.output === "server";
@@ -588,6 +667,7 @@ export const astroConfigTemplate = (options: {
588
667
  "blumeIntegration",
589
668
  "includeHmrPlugin",
590
669
  "prerenderDepsPlugin",
670
+ ...runtimeModuleImports,
591
671
  ...(adapterOption.includes("withAdapterRoot") ? ["withAdapterRoot"] : []),
592
672
  ];
593
673
  const blumeImport = `import { ${blumeImports.join(", ")} } from "blume/astro";\n`;
@@ -655,7 +735,7 @@ ${userConfigSetup}export default defineConfig({
655
735
  root: ${JSON.stringify(context.outDir)},
656
736
  srcDir: ${JSON.stringify(`${context.outDir}/src`)},
657
737
  outDir: ${JSON.stringify(astroOutDir(context))},
658
- publicDir: ${JSON.stringify(`${context.root}/public`)},
738
+ publicDir: ${JSON.stringify(`${context.root}/public`)},${cacheOptions}
659
739
  output: ${JSON.stringify(deployment.output)},${adapterOption}${sessionOption}${siteOption}${baseOption}${imageOption}${redirectsOption}${i18nOption}${fontsOption}
660
740
  integrations: [${integrations.join(", ")}${userIntegrationSpread}],
661
741
  markdown: {
@@ -683,8 +763,8 @@ ${userConfigSetup}export default defineConfig({
683
763
  // request latency behind the user's intent, so most navigations swap
684
764
  // instantly.
685
765
  prefetch: { prefetchAll: true },
686
- vite: {
687
- plugins: [tailwindcss(), includeHmrPlugin(${JSON.stringify(
766
+ vite: {${viteCacheOption}
767
+ plugins: [${runtimeModulesPluginEntry}tailwindcss(), includeHmrPlugin(${JSON.stringify(
688
768
  `${context.outDir}/src/generated/includes.json`
689
769
  )}), prerenderDepsPlugin()],
690
770
  // Everything hydration can reach must be part of the dev dep optimizer's
@@ -733,12 +813,10 @@ ${userConfigSetup}export default defineConfig({
733
813
  resolve: {
734
814
  alias: {
735
815
  "blume:ask": ${JSON.stringify(askPath)},
736
- "blume:data": ${JSON.stringify(dataPath)},
737
816
  "blume:examples": ${JSON.stringify(examplesPath)},
738
817
  "blume:examples-theme": ${JSON.stringify(examplesThemePath)},
739
- "blume:openapi": ${JSON.stringify(openapiPath)},
740
818
  "blume:search-client": ${JSON.stringify(searchClientPath)},
741
- "blume:theme": ${JSON.stringify(themePath)},${userAliasLines}
819
+ "blume:theme": ${JSON.stringify(themePath)},${runtimeModuleAliasLines}${userAliasLines}
742
820
  },
743
821
  },
744
822
  server: {
@@ -932,7 +1010,7 @@ export const askEndpointTemplate = (
932
1010
  if (grounded) {
933
1011
  imports.push(
934
1012
  'import { createAskContext } from "blume/ai/ask-context.ts";',
935
- 'import askData from "../../generated/ask-data.json";'
1013
+ 'import askData from "blume:ask-data";'
936
1014
  );
937
1015
  const groundFields: string[] = [];
938
1016
  if (instructions) {
@@ -1080,7 +1158,7 @@ const { strings } = Astro.props;
1080
1158
  /** Generate the static search index endpoint (`/blume-search.json`). */
1081
1159
  export const searchEndpointTemplate = (): string =>
1082
1160
  `// Generated by Blume. Do not edit.
1083
- import documents from "../generated/search.json";
1161
+ import documents from "blume:search-index";
1084
1162
 
1085
1163
  export const prerender = true;
1086
1164
 
@@ -1258,7 +1336,7 @@ export const POST: APIRoute = async ({ request }) => {
1258
1336
  */
1259
1337
  export const rawMarkdownEndpointTemplate = (kind: "md" | "mdx"): string =>
1260
1338
  `// Generated by Blume. Do not edit.
1261
- import raw from "../generated/raw-markdown.json";
1339
+ import raw from "blume:raw-markdown";
1262
1340
 
1263
1341
  export const prerender = true;
1264
1342
 
@@ -1308,7 +1386,7 @@ import { existsSync } from "node:fs";
1308
1386
  import { readdir, readFile } from "node:fs/promises";
1309
1387
  import { isAbsolute, join, relative, resolve } from "node:path";
1310
1388
  import type { APIRoute } from "astro";
1311
- import assets from "../../generated/content-assets.json";
1389
+ import assets from "blume:content-assets";
1312
1390
 
1313
1391
  export const prerender = true;
1314
1392
 
@@ -1400,22 +1478,18 @@ export const mcpPageFile = (route: string): string =>
1400
1478
  * generated data snapshot. Runs server-side (no prerender) so agents can query
1401
1479
  * the docs over Streamable HTTP.
1402
1480
  */
1403
- export const mcpEndpointTemplate = (route: string): string => {
1404
- const clean = trimChar(route, "/");
1405
- const up = "../".repeat(clean.split("/").length);
1406
- return `// Generated by Blume. Do not edit.
1481
+ export const mcpEndpointTemplate = (): string =>
1482
+ `// Generated by Blume. Do not edit.
1407
1483
  import type { APIRoute } from "astro";
1408
1484
  import { createMcpFetchHandler } from "blume/ai/mcp/server.ts";
1409
- import type { McpData } from "blume/ai/mcp/data.ts";
1410
- import data from "${up}generated/mcp-data.json";
1485
+ import data from "blume:mcp-data";
1411
1486
 
1412
1487
  export const prerender = false;
1413
1488
 
1414
- const handler = createMcpFetchHandler(data as McpData);
1489
+ const handler = createMcpFetchHandler(data);
1415
1490
 
1416
1491
  export const ALL: APIRoute = ({ request }) => handler(request);
1417
1492
  `;
1418
- };
1419
1493
 
1420
1494
  /**
1421
1495
  * Generate the playground's CORS proxy endpoint
@@ -1464,7 +1538,7 @@ export function GET() {
1464
1538
  */
1465
1539
  export const rssEndpointTemplate = (): string =>
1466
1540
  `// Generated by Blume. Do not edit.
1467
- import feeds from "../../generated/rss.json";
1541
+ import feeds from "blume:rss";
1468
1542
 
1469
1543
  export const prerender = true;
1470
1544
 
@@ -1486,7 +1560,16 @@ export function GET({ props }: { props: { section: string } }) {
1486
1560
  /** Generate the OG image endpoint (`.blume/src/pages/og/[...slug].png.ts`). */
1487
1561
  export const ogEndpointTemplate = (
1488
1562
  customRoutes: OgCustomRoute[] = [],
1489
- og: { families?: OgFontFamilies; fonts?: OgFont[] } = {},
1563
+ og: {
1564
+ families?: OgFontFamilies;
1565
+ fonts?: OgFont[];
1566
+ /**
1567
+ * Whether a page's own description may replace the site-wide subtitle.
1568
+ * `false` when `seo.og.description` is `false`, which hides the subtitle
1569
+ * on every card, page text included. Defaults to `true`.
1570
+ */
1571
+ pageDescriptions?: boolean;
1572
+ } = {},
1490
1573
  includeChangelog = false
1491
1574
  ): string =>
1492
1575
  `// Generated by Blume. Do not edit.
@@ -1510,28 +1593,52 @@ const families: OgFontFamilies | undefined = ${
1510
1593
  og.families ? JSON.stringify(og.families) : "undefined"
1511
1594
  };
1512
1595
 
1596
+ // A page's own description (its \`seo.description\`, else \`description\`) is
1597
+ // the card subtitle, so the image says what the page's og:description says.
1598
+ // Pages without one fall back to the site-wide subtitle at render time.
1599
+ // \`seo.og.description: false\` hides the subtitle on every card, page text
1600
+ // included, which is what switches this off.
1601
+ const pageDescriptions = ${og.pageDescriptions !== false};
1602
+
1603
+ interface CardProps {
1604
+ title: string;
1605
+ description: string | null;
1606
+ }
1607
+
1513
1608
  export function getStaticPaths() {
1514
1609
  const seen = new Set<string>();
1515
- const paths: { params: { slug: string }; props: { title: string } }[] = [];
1516
- const add = (slug: string, title: string) => {
1610
+ const paths: { params: { slug: string }; props: CardProps }[] = [];
1611
+ const add = (slug: string, title: string, description: string | null) => {
1517
1612
  if (seen.has(slug)) {
1518
1613
  return;
1519
1614
  }
1520
1615
  seen.add(slug);
1521
- paths.push({ params: { slug }, props: { title } });
1616
+ paths.push({
1617
+ params: { slug },
1618
+ props: { title, description: pageDescriptions ? description : null },
1619
+ });
1522
1620
  };
1523
1621
  // A custom page wins over a content route sharing its path, so add it first.
1622
+ // Its description is unknown at generate time, so it takes the site subtitle.
1524
1623
  for (const route of customRoutes) {
1525
- add(route.slug, route.title);
1624
+ add(route.slug, route.title, null);
1526
1625
  }
1527
1626
  for (const route of data.routes) {
1528
- add(route.path === "/" ? "index" : route.path.slice(1), route.title);
1627
+ add(
1628
+ route.path === "/" ? "index" : route.path.slice(1),
1629
+ route.title,
1630
+ route.description
1631
+ );
1529
1632
  }${
1530
1633
  includeChangelog
1531
1634
  ? `
1532
1635
  // The generated changelog index is not a content route, so it needs its own
1533
1636
  // card. Added last: a custom page or content route owning /changelog wins.
1534
- add("changelog", data.ui.changelog?.title ?? "Changelog");`
1637
+ add(
1638
+ "changelog",
1639
+ data.ui.changelog?.title ?? "Changelog",
1640
+ data.ui.changelog?.description ?? null
1641
+ );`
1535
1642
  : ""
1536
1643
  }
1537
1644
  return paths;
@@ -1544,11 +1651,11 @@ const repoSlug = data.config.github
1544
1651
  ? \`\${data.config.github.owner}/\${data.config.github.repo}\`
1545
1652
  : undefined;
1546
1653
 
1547
- export async function GET({ props }: { props: { title: string } }) {
1654
+ export async function GET({ props }: { props: CardProps }) {
1548
1655
  const png = await renderOgImage({
1549
1656
  accent: data.config.og.palette?.accent ?? data.config.theme.accent.light,
1550
1657
  brand: data.config.title,
1551
- description: data.config.og.description,
1658
+ description: props.description ?? data.config.og.description,
1552
1659
  families,
1553
1660
  fonts,
1554
1661
  logo: data.config.og.logo,
@@ -1572,13 +1679,11 @@ export async function GET({ props }: { props: { title: string } }) {
1572
1679
  * but mounted inside Blume's {@link ReferenceLayout} so the page keeps Blume's
1573
1680
  * navbar on top. `renderMode: "client"` mounts the reference into a container
1574
1681
  * element (rather than emitting a full HTML document), which is what lets it
1575
- * live inside our shell. `dataImport` is the route-depth-aware relative path to
1576
- * the generated data module the layout reads.
1682
+ * live inside our shell.
1577
1683
  */
1578
1684
  export const scalarReferenceTemplate = <Configuration extends object>(options: {
1579
1685
  /** Scalar options forwarded verbatim (spec/theme config plus the author's `scalar` escape hatch). */
1580
1686
  configuration: Configuration;
1581
- dataImport: string;
1582
1687
  noindex?: boolean;
1583
1688
  route: string;
1584
1689
  title: string;
@@ -1587,7 +1692,7 @@ export const scalarReferenceTemplate = <Configuration extends object>(options: {
1587
1692
  // Generated by Blume. Do not edit.
1588
1693
  import { ScalarComponent } from "@scalar/astro";
1589
1694
  import ReferenceLayout from "blume/components/layout/ReferenceLayout.astro";
1590
- import data from ${JSON.stringify(options.dataImport)};
1695
+ import data from "blume:data";
1591
1696
 
1592
1697
  export const prerender = true;
1593
1698
 
@@ -2411,6 +2516,74 @@ const suggestions = [
2411
2516
  </PageLayout>
2412
2517
  `;
2413
2518
 
2519
+ /**
2520
+ * Generate `.blume/src/pages/404.md.ts`: the Markdown twin of the default 404
2521
+ * page, prerendered to `dist/404.md`. An agent that asked for a missing page
2522
+ * with `Accept: text/markdown` — or fetched a `.md` URL no page backs — gets
2523
+ * this body with the 404 status instead of the HTML shell; Vercel server
2524
+ * builds wire that into the routing config (`deploy/vercel-negotiation.ts`).
2525
+ * Same recovery links as the HTML page, absolute when the site URL is known:
2526
+ * the body is read out of context, so a relative link would leave the reader
2527
+ * guessing the host. Written alongside `404.astro` and skipped under the same
2528
+ * rule, so a project that owns `/404` owns both variants.
2529
+ */
2530
+ export const notFoundMarkdownTemplate =
2531
+ (): string => `// Generated by Blume. Do not edit. Override by adding \`pages/404.astro\`.
2532
+ import { withBase } from "blume/components/islands/base-path.ts";
2533
+ import { absoluteUrl } from "blume/core/site-url.ts";
2534
+ import data from "blume:data";
2535
+
2536
+ export const prerender = true;
2537
+
2538
+ const nf = data.ui.notFound;
2539
+
2540
+ // Absolute for internal routes when the site is known; an external tab href
2541
+ // passes through untouched.
2542
+ const href = (path: string): string => {
2543
+ const based = withBase(path);
2544
+ return data.config.site && based.startsWith("/") && !based.startsWith("//")
2545
+ ? absoluteUrl(data.config.site, based)
2546
+ : based;
2547
+ };
2548
+
2549
+ // The recovery set of 404.astro: home, every top-level section (a tab links to
2550
+ // its resolved target), then the machine-readable indexes that exist.
2551
+ const links = [
2552
+ { href: href("/"), label: nf.home },
2553
+ ...data.navigation.tabs.map((tab) => ({
2554
+ href: href(tab.href ?? tab.path),
2555
+ label: tab.label,
2556
+ })),
2557
+ ...(data.config.discovery.sitemap
2558
+ ? [{ href: href("/sitemap.xml"), label: nf.sitemap }]
2559
+ : []),
2560
+ ...(data.config.discovery.llmsTxt
2561
+ ? [{ href: href("/llms.txt"), label: nf.llms }]
2562
+ : []),
2563
+ ];
2564
+
2565
+ const body = [
2566
+ "# " + nf.title,
2567
+ "",
2568
+ nf.description,
2569
+ "",
2570
+ "## " + nf.suggestions,
2571
+ "",
2572
+ ...links.map((link) => "- [" + link.label + "](" + link.href + ")"),
2573
+ "",
2574
+ ].join("\\n");
2575
+
2576
+ export function GET() {
2577
+ return new Response(body, {
2578
+ headers: {
2579
+ "Content-Type": "text/markdown; charset=utf-8",
2580
+ // ~4 characters per token; keep in sync with markdownTokenCount.
2581
+ "x-markdown-tokens": String(Math.ceil(body.length / 4)),
2582
+ },
2583
+ });
2584
+ }
2585
+ `;
2586
+
2414
2587
  /** The literal Astro hydration directive for an island's client mode. */
2415
2588
  const islandDirective = (spec: IslandSpec): string =>
2416
2589
  spec.client === "only"
@@ -2692,6 +2865,36 @@ declare module "blume:data" {
2692
2865
  export default data;
2693
2866
  }
2694
2867
 
2868
+ declare module "blume:ask-data" {
2869
+ const askData: import("blume/ai/ask-context.ts").AskData;
2870
+ export default askData;
2871
+ }
2872
+
2873
+ declare module "blume:content-assets" {
2874
+ const assets: Record<string, string>;
2875
+ export default assets;
2876
+ }
2877
+
2878
+ declare module "blume:mcp-data" {
2879
+ const data: import("blume/ai/mcp/data.ts").McpData;
2880
+ export default data;
2881
+ }
2882
+
2883
+ declare module "blume:raw-markdown" {
2884
+ const raw: Record<string, import("blume/ai/markdown.ts").RawMarkdownEntry>;
2885
+ export default raw;
2886
+ }
2887
+
2888
+ declare module "blume:rss" {
2889
+ const feeds: Record<string, string>;
2890
+ export default feeds;
2891
+ }
2892
+
2893
+ declare module "blume:search-index" {
2894
+ const documents: import("blume/search/documents.ts").SearchDocument[];
2895
+ export default documents;
2896
+ }
2897
+
2695
2898
  declare module "blume:examples" {
2696
2899
  type Examples = typeof import("./generated/examples.ts").examples;
2697
2900
  export const examples: Record<string, Examples[keyof Examples]>;
@@ -334,12 +334,18 @@ const emitVercelNegotiation = async (
334
334
  // endpoint stamps it on dev/server-rendered responses itself.
335
335
  const rawMarkdown = await buildRawMarkdown(project);
336
336
  const home = rawMarkdown["/"];
337
+ // The Markdown 404 routes point at the prerendered `404.md`; only wire them
338
+ // when the build actually emitted it (a project that owns `/404` gets none).
339
+ const notFoundMarkdown = existsSync(
340
+ join(root, ".vercel", "output", "static", "404.md")
341
+ );
337
342
  const injected = injectNegotiationRoutes(
338
343
  await readFile(configPath, "utf-8"),
339
344
  routePaths,
340
345
  buildHomeLinkHeader(config, routePaths),
341
346
  overrides,
342
- home ? markdownTokenCount(agentMarkdown(home)) : undefined
347
+ home ? markdownTokenCount(agentMarkdown(home)) : undefined,
348
+ notFoundMarkdown
343
349
  );
344
350
  if (injected === null) {
345
351
  logger.warn(
@@ -19,8 +19,9 @@ import { prepareProject } from "../prepare.ts";
19
19
 
20
20
  /**
21
21
  * Resolve a `--host` flag value into what Astro/Vite's `server.host` expects.
22
- * citty (0.1) has no mixed string/boolean arg type, so `host` is declared as a
23
- * string and a bare `--host` parses as `""` — Node would bind all interfaces
22
+ * citty has no mixed string/boolean arg type, so `host` is declared as a
23
+ * string and a bare `--host` parses as `""` (the CLI entry rewrites it to
24
+ * `--host=` first; see `host-args.ts`) — Node would bind all interfaces
24
25
  * for `""`, but Vite's `resolveHostname` treats it as a literal hostname and
25
26
  * prints malformed URLs like `http://:4321/`. Match Astro's own `--host`
26
27
  * semantics instead: bare flag → `true` (bind all interfaces), `--host
@@ -219,7 +220,9 @@ export const devCommand = defineCommand({
219
220
  }).on("all", regenerate);
220
221
  const disposers = [
221
222
  ...project.sources.map((source) => source.watch?.(regenerate)),
222
- () => void projectWatcher.close(),
223
+ () => {
224
+ void projectWatcher.close();
225
+ },
223
226
  ].filter((dispose) => dispose !== undefined);
224
227
 
225
228
  const shutdown = async () => {
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Rewrite a bare `--host` in the raw argv to `--host=` before citty parses it.
3
+ *
4
+ * `host` is a string arg (citty has no mixed string/boolean type), and citty
5
+ * 0.2 parses with `node:util.parseArgs`, where a string option consumes the
6
+ * next token as its value even when that token is another flag: `blume dev
7
+ * --host --open` would bind the literal hostname "--open" and drop `--open`.
8
+ * The `--host=` spelling parses as `""` without touching its neighbor, which
9
+ * `normalizeHost` then maps to Astro's "bind all interfaces".
10
+ */
11
+ export const normalizeHostArgs = (rawArgs: readonly string[]): string[] =>
12
+ rawArgs.map((arg, index) => {
13
+ if (arg !== "--host") {
14
+ return arg;
15
+ }
16
+ const next = rawArgs[index + 1];
17
+ return next === undefined || next.startsWith("-") ? "--host=" : arg;
18
+ });
package/src/cli/index.ts CHANGED
@@ -17,6 +17,7 @@ import { translateCommand } from "./commands/translate.ts";
17
17
  import { validateCommand } from "./commands/validate.ts";
18
18
  import { versionCommand } from "./commands/version.ts";
19
19
  import { loadEnvFiles } from "./env.ts";
20
+ import { normalizeHostArgs } from "./host-args.ts";
20
21
  import { reportInternalError } from "./internal-error.ts";
21
22
 
22
23
  const main = defineCommand({
@@ -60,4 +61,4 @@ process.on("unhandledRejection", (error) => {
60
61
  process.exit(1);
61
62
  });
62
63
 
63
- runMain(main);
64
+ runMain(main, { rawArgs: normalizeHostArgs(process.argv.slice(2)) });
@@ -3,13 +3,14 @@
3
3
  * blocks, page actions, color swatches, prompts, API panels, Ask AI). One
4
4
  * implementation owns the invariants each site used to hand-roll:
5
5
  *
6
- * - the clipboard write is guarded, and nothing flashes on failure — a
7
- * confirmation must never lie;
6
+ * - the clipboard write is guarded and a confirmation must never lie: a
7
+ * site either shows nothing on failure or (the page actions) shows a
8
+ * failure label, but never a false "Copied";
8
9
  * - repeat copies restart the hold instead of stacking timers, so the copied
9
10
  * state never reverts early after a double-click;
10
- * - every successful copy is announced to a shared polite live region, so the
11
- * confirmation is audible, not just visual (previously only the code-block
12
- * button announced).
11
+ * - every flash is announced to a shared polite live region, so the
12
+ * confirmation — or the failure — is audible, not just visual (previously
13
+ * only the code-block button announced).
13
14
  */
14
15
 
15
16
  /** How long the copied confirmation holds before reverting. */
@@ -35,17 +36,100 @@ export const announceCopied = (message: string): void => {
35
36
  };
36
37
 
37
38
  /**
38
- * Copy `text` to the clipboard. Returns whether the write succeeded; failures
39
- * (insecure context, permissions) are swallowed so callers can simply skip
40
- * their confirmation.
39
+ * The legacy copy path: select `text` in an off-screen textarea and run the
40
+ * `copy` editing command. It needs no clipboard permission, only the user
41
+ * activation the click already provides, so it covers the places the async
42
+ * Clipboard API doesn't reach — in-app browsers and WebViews that ship no
43
+ * `navigator.clipboard`, insecure origins, and a denied permission prompt.
44
+ */
45
+ const copyViaCommand = (text: string): boolean => {
46
+ const previous = document.activeElement;
47
+ const textarea = document.createElement("textarea");
48
+ textarea.value = text;
49
+ textarea.setAttribute("readonly", "");
50
+ textarea.setAttribute("aria-hidden", "true");
51
+ // Off-screen rather than `display: none`: hidden controls can't be selected.
52
+ textarea.style.position = "fixed";
53
+ textarea.style.top = "0";
54
+ textarea.style.left = "-9999px";
55
+ textarea.style.opacity = "0";
56
+ // Beside the focused control, not on `<body>`: `select()` moves focus to the
57
+ // textarea, and a copy button inside a light-dismissed `<details>` menu (the
58
+ // MCP actions) would otherwise see focus leave the panel — closing the menu
59
+ // mid-click and hiding the label the outcome is about to flash on.
60
+ const host =
61
+ previous instanceof HTMLElement && previous.parentElement
62
+ ? previous.parentElement
63
+ : document.body;
64
+ host.append(textarea);
65
+ textarea.select();
66
+ // iOS WebKit has been known to ignore `select()` on a readonly textarea;
67
+ // an explicit range covers it (the same belt-and-braces clipboard.js uses).
68
+ textarea.setSelectionRange(0, text.length);
69
+ let copied = false;
70
+ try {
71
+ copied = document.execCommand("copy");
72
+ } catch {
73
+ // Some engines throw instead of returning false; either way it failed.
74
+ }
75
+ textarea.remove();
76
+ // `select()` moved focus to the textarea; put it back on the button so a
77
+ // keyboard user isn't dropped at the top of the document.
78
+ if (previous instanceof HTMLElement) {
79
+ previous.focus();
80
+ }
81
+ return copied;
82
+ };
83
+
84
+ /**
85
+ * Copy `text` to the clipboard. Returns whether the write succeeded. The async
86
+ * Clipboard API is tried first; when it is missing (in-app browsers, insecure
87
+ * contexts) or rejects (a denied permission), the legacy `copy` command is
88
+ * tried before giving up, so callers only see `false` when nothing worked and
89
+ * can show a failure instead of silently doing nothing.
41
90
  */
42
91
  export const copyText = async (text: string): Promise<boolean> => {
43
92
  try {
44
93
  await navigator.clipboard.writeText(text);
45
94
  return true;
46
95
  } catch {
47
- return false;
96
+ return copyViaCommand(text);
97
+ }
98
+ };
99
+
100
+ /**
101
+ * Copy text that still has to be loaded — the page's Markdown mirror, fetched
102
+ * on click. Safari and Firefox only honor a clipboard write issued inside the
103
+ * click's own task: awaiting the load first lands the write outside the user
104
+ * activation, so `writeText` rejects and the legacy command returns `false`
105
+ * even though the clipboard is perfectly available. `ClipboardItem` accepts a
106
+ * promise for its payload, so the write is issued synchronously with the
107
+ * load still in flight and the activation intact. Engines without it (or a
108
+ * write that rejects for any reason, a failed load included) fall back to the
109
+ * awaited {@link copyText}, which is what Chrome's longer activation window
110
+ * already tolerated. A load failure still throws, so the caller can report
111
+ * it rather than a clipboard problem.
112
+ */
113
+ export const copyDeferredText = async (
114
+ load: () => Promise<string>
115
+ ): Promise<boolean> => {
116
+ const text = load();
117
+ if ("ClipboardItem" in globalThis) {
118
+ try {
119
+ await navigator.clipboard.write([
120
+ new ClipboardItem({
121
+ "text/plain": text.then(
122
+ (value) => new Blob([value], { type: "text/plain" })
123
+ ),
124
+ }),
125
+ ]);
126
+ return true;
127
+ } catch {
128
+ // Fall through to the awaited write; if the load itself failed, the
129
+ // `await` below rethrows that error for the caller.
130
+ }
48
131
  }
132
+ return copyText(await text);
49
133
  };
50
134
 
51
135
  /**