blume 1.0.2 → 1.0.4

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 (63) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/cli/index.js +496 -295
  3. package/dist/cli/index.js.map +18 -17
  4. package/dist/types/core/config-input.d.ts +44 -22
  5. package/dist/types/core/config.d.ts +3 -3
  6. package/dist/types/core/data.d.ts +12 -0
  7. package/dist/types/core/i18n-ui.d.ts +136 -136
  8. package/dist/types/core/schema.d.ts +502 -384
  9. package/dist/types/core/types.d.ts +10 -0
  10. package/dist/types/openapi/references.d.ts +12 -7
  11. package/docs/advanced/api-reference.mdx +11 -3
  12. package/docs/advanced/custom-pages.mdx +2 -0
  13. package/docs/configuration/ai.mdx +6 -4
  14. package/docs/configuration/index.mdx +6 -8
  15. package/docs/configuration/seo.mdx +20 -1
  16. package/docs/content/components.mdx +26 -5
  17. package/docs/content/navigation.mdx +10 -0
  18. package/docs/content/syntax.mdx +116 -4
  19. package/package.json +1 -1
  20. package/skills/blume-migrate/SKILL.md +170 -0
  21. package/skills/blume-migrate/assets/oxfmt@0.55.0.patch +20 -0
  22. package/skills/blume-migrate/references/docusaurus.md +95 -0
  23. package/skills/blume-migrate/references/fumadocs.md +95 -0
  24. package/skills/blume-migrate/references/mintlify.md +155 -0
  25. package/skills/blume-migrate/references/monorepo.md +224 -0
  26. package/skills/blume-migrate/references/nextra.md +76 -0
  27. package/skills/blume-migrate/references/starlight.md +116 -0
  28. package/skills/blume-migrate/scripts/mintlify-codemod.mjs +466 -0
  29. package/src/ai/agent-readability.ts +3 -3
  30. package/src/ai/mcp/data.ts +2 -2
  31. package/src/astro/component-slots.ts +3 -2
  32. package/src/astro/generate.ts +97 -30
  33. package/src/astro/templates.ts +140 -53
  34. package/src/blume-modules.d.ts +6 -0
  35. package/src/components/content/Callout.astro +8 -2
  36. package/src/components/content/Prompt.astro +25 -13
  37. package/src/components/layout/Header.astro +19 -10
  38. package/src/components/layout/Logo.astro +13 -1
  39. package/src/components/layout/PageFeedback.astro +1 -1
  40. package/src/components/layout/PageLayout.astro +11 -11
  41. package/src/components/layout/Pagination.astro +6 -6
  42. package/src/components/layout/ReferenceLayout.astro +1 -0
  43. package/src/components/layout/RootLayout.astro +10 -11
  44. package/src/components/layout/Search.astro +1 -1
  45. package/src/components/layout/nav-utils.ts +9 -7
  46. package/src/core/config-input.ts +47 -27
  47. package/src/core/config.ts +3 -3
  48. package/src/core/data.ts +9 -1
  49. package/src/core/navigation.ts +55 -13
  50. package/src/core/schema.ts +32 -15
  51. package/src/core/server-features.ts +1 -1
  52. package/src/core/sources/watch.ts +5 -0
  53. package/src/core/types.ts +10 -0
  54. package/src/deploy/adapter-output.ts +11 -1
  55. package/src/markdown/index.ts +2 -0
  56. package/src/markdown/language-icon.ts +2 -1
  57. package/src/markdown/table-wrap.ts +43 -0
  58. package/src/og/card.ts +39 -12
  59. package/src/og/index.ts +1 -1
  60. package/src/og/logo.ts +21 -0
  61. package/src/openapi/references.ts +19 -16
  62. package/src/registry/eject.ts +11 -5
  63. package/src/theme/entry.ts +50 -5
@@ -80,6 +80,30 @@ const ADAPTER_OPTIONS: Record<string, string> = {
80
80
  node: '{ mode: "standalone" }',
81
81
  };
82
82
 
83
+ const WRANGLER_CONFIG_FILES = [
84
+ "wrangler.jsonc",
85
+ "wrangler.json",
86
+ "wrangler.toml",
87
+ ];
88
+
89
+ const resolveCloudflareAdapterArgs = (context: ProjectContext): string => {
90
+ const args: string[] = ['prerenderEnvironment: "node"'];
91
+ const wranglerPath = WRANGLER_CONFIG_FILES.map((file) =>
92
+ join(context.root, file)
93
+ ).find((file) => existsSync(file));
94
+ if (wranglerPath) {
95
+ let configPath = relative(context.outDir, wranglerPath);
96
+ // The wrangler config always lives at the project root, above the `.blume`
97
+ // runtime, so `relative` yields a `../…` path; normalize the theoretical
98
+ // sibling case to an explicit `./` so it reads as a relative import.
99
+ if (!configPath.startsWith(".") && !configPath.startsWith("/")) {
100
+ configPath = `./${configPath}`;
101
+ }
102
+ args.push(`configPath: ${JSON.stringify(configPath)}`);
103
+ }
104
+ return `{ ${args.join(", ")} }`;
105
+ };
106
+
83
107
  /**
84
108
  * Integration packages the generated runtime imports. Declaring them in
85
109
  * `.blume/package.json` lets Astro's framework-package crawl discover and bundle
@@ -222,6 +246,8 @@ export const astroConfigTemplate = (options: {
222
246
  needsSvelte?: boolean;
223
247
  pages: BlumePageRoute[];
224
248
  contentRoutes: string[];
249
+ /** The generated Ask trigger (`blume:ask`); renders nothing when Ask is off. */
250
+ askPath: string;
225
251
  dataPath: string;
226
252
  examplesPath: string;
227
253
  /** The example-preview Tailwind entry (`blume:examples-theme`). */
@@ -240,6 +266,7 @@ export const astroConfigTemplate = (options: {
240
266
  }): string => {
241
267
  const { context, config, needsReact, pages, dataPath, themePath } = options;
242
268
  const {
269
+ askPath,
243
270
  contentRoutes,
244
271
  examplesPath,
245
272
  examplesThemePath,
@@ -260,10 +287,15 @@ export const astroConfigTemplate = (options: {
260
287
  server && deployment.adapter
261
288
  ? `import adapter from "${ADAPTER_IMPORTS[deployment.adapter]}";\n`
262
289
  : "";
263
- const adapterArgs =
264
- server && deployment.adapter
265
- ? (ADAPTER_OPTIONS[deployment.adapter] ?? "")
266
- : "";
290
+ const adapterArgs = (() => {
291
+ if (!server || !deployment.adapter) {
292
+ return "";
293
+ }
294
+ if (deployment.adapter === "cloudflare") {
295
+ return resolveCloudflareAdapterArgs(context);
296
+ }
297
+ return ADAPTER_OPTIONS[deployment.adapter] ?? "";
298
+ })();
267
299
  const adapterOption =
268
300
  server && deployment.adapter ? `\n adapter: adapter(${adapterArgs}),` : "";
269
301
 
@@ -407,6 +439,18 @@ export default defineConfig({
407
439
  devToolbar: { enabled: false },
408
440
  vite: {
409
441
  plugins: [tailwindcss(), prerenderDepsPlugin(), serverAppResolvePlugin()],
442
+ // Mermaid (lazy-loaded client-side for diagrams) statically imports dayjs as
443
+ // CJS (\`dayjs/dayjs.min.js\`). In dev, an un-pre-bundled dependency is served
444
+ // as raw ESM, and that UMD file exposes no \`default\` export, so mermaid
445
+ // throws on load and diagrams render blank. Forcing mermaid through the dep
446
+ // optimizer bundles dayjs with correct CJS interop. In a standalone install
447
+ // Blume's dynamic \`import("mermaid")\` lives inside \`node_modules/blume\`,
448
+ // which Vite's optimizer scan doesn't crawl, so mermaid is never discovered
449
+ // on its own — hence the explicit include. mermaid resolves through the
450
+ // \`blume\` package (it isn't a direct dep of the generated project), so the
451
+ // nested \`blume > mermaid\` form is required. Production (Rollup) already
452
+ // handles the interop, so this only affects dev.
453
+ optimizeDeps: { include: ["blume > mermaid"] },
410
454
  // Blume's render-time deps are forced external on both build environments so
411
455
  // native bindings resolve at runtime and isolated linkers don't bundle
412
456
  // symlinked store copies (which would surface their children as unresolvable
@@ -425,6 +469,7 @@ export default defineConfig({
425
469
  },
426
470
  resolve: {
427
471
  alias: {
472
+ "blume:ask": ${JSON.stringify(askPath)},
428
473
  "blume:data": ${JSON.stringify(dataPath)},
429
474
  "blume:examples": ${JSON.stringify(examplesPath)},
430
475
  "blume:examples-theme": ${JSON.stringify(examplesThemePath)},
@@ -694,6 +739,42 @@ ${handler}
694
739
  `;
695
740
  };
696
741
 
742
+ /**
743
+ * Generate `.blume/src/generated/Ask.astro` — the component behind the
744
+ * `blume:ask` alias that the shared header renders in place of a per-page slot.
745
+ *
746
+ * The header can't import the Ask AI island directly: it's a React component, so
747
+ * the import alone would drag the JSX renderer into the module graph of every
748
+ * project — including the ones that never enable Ask AI and therefore have no
749
+ * React integration wired into their generated Astro config (see `needsReact`).
750
+ * Routing the import through a generated component keeps that dependency behind
751
+ * the config switch: enabled projects get the island, disabled ones get a
752
+ * component that renders nothing and imports no React.
753
+ *
754
+ * `strings` comes from the header (the active locale's dictionary); the empty-
755
+ * state suggestions are read straight from the data snapshot, which is why no
756
+ * page has to pass them.
757
+ */
758
+ export const askComponentTemplate = (askEnabled: boolean): string =>
759
+ askEnabled
760
+ ? `---
761
+ // Generated by Blume. Do not edit.
762
+ import AskAI from "blume/components/islands/AskAI.astro";
763
+ import data from "blume:data";
764
+
765
+ const { strings } = Astro.props;
766
+ ---
767
+
768
+ <AskAI strings={strings ?? data.ui.ask} suggestions={data.config.ask?.suggestions ?? []} />
769
+ `
770
+ : `---
771
+ // Generated by Blume. Do not edit.
772
+ // Ask AI is off (\`ai.ask.enabled\`), so the header's Ask trigger renders nothing.
773
+ // Deliberately imports no React island, keeping the JSX renderer out of projects
774
+ // that don't need it.
775
+ ---
776
+ `;
777
+
697
778
  /** Generate the static search index endpoint (`/blume-search.json`). */
698
779
  export const searchEndpointTemplate = (): string =>
699
780
  `// Generated by Blume. Do not edit.
@@ -869,10 +950,11 @@ export function getStaticPaths() {
869
950
  }));
870
951
  }
871
952
 
872
- export function GET({ props }) {
873
- const entry = raw[props.route];
953
+ export function GET({ props }: { props: { route: string } }) {
954
+ const entries = raw as Record<string, { md?: string; mdx?: string }>;
955
+ const entry = entries[props.route];
874
956
  return new Response(entry ? ${
875
- kind === "md" ? "(entry.md ?? entry.mdx)" : "entry.mdx"
957
+ kind === "md" ? '(entry.md ?? entry.mdx ?? "")' : '(entry.mdx ?? "")'
876
958
  } : "", {
877
959
  headers: { "Content-Type": "text/markdown; charset=utf-8" },
878
960
  });
@@ -936,8 +1018,9 @@ export function getStaticPaths() {
936
1018
  }));
937
1019
  }
938
1020
 
939
- export function GET({ props }) {
940
- return new Response(feeds[props.section] ?? "", {
1021
+ export function GET({ props }: { props: { section: string } }) {
1022
+ const bySection = feeds as Record<string, string>;
1023
+ return new Response(bySection[props.section] ?? "", {
941
1024
  headers: { "Content-Type": "application/rss+xml; charset=utf-8" },
942
1025
  });
943
1026
  }
@@ -949,7 +1032,7 @@ export const ogEndpointTemplate = (
949
1032
  ): string =>
950
1033
  `// Generated by Blume. Do not edit.
951
1034
  import { renderOgImage } from "blume/og";
952
- import data from "../../generated/data.json";
1035
+ import data from "blume:data";
953
1036
 
954
1037
  export const prerender = true;
955
1038
 
@@ -957,9 +1040,9 @@ export const prerender = true;
957
1040
  const customRoutes = ${JSON.stringify(customRoutes)};
958
1041
 
959
1042
  export function getStaticPaths() {
960
- const seen = new Set();
961
- const paths = [];
962
- const add = (slug, title) => {
1043
+ const seen = new Set<string>();
1044
+ const paths: { params: { slug: string }; props: { title: string } }[] = [];
1045
+ const add = (slug: string, title: string) => {
963
1046
  if (seen.has(slug)) {
964
1047
  return;
965
1048
  }
@@ -992,17 +1075,18 @@ const siteHost = (() => {
992
1075
  }
993
1076
  })();
994
1077
 
995
- export async function GET({ props }) {
1078
+ export async function GET({ props }: { props: { title: string } }) {
996
1079
  const png = await renderOgImage({
997
- accent: data.config.theme.accent.light,
1080
+ accent: data.config.og.palette?.accent ?? data.config.theme.accent.light,
998
1081
  brand: data.config.title,
999
1082
  description: data.config.description,
1000
- logo: data.config.logo?.svg,
1083
+ logo: data.config.og.logo,
1084
+ palette: data.config.og.palette,
1001
1085
  repo: repoSlug,
1002
1086
  site: siteHost,
1003
1087
  title: props.title,
1004
1088
  });
1005
- return new Response(png, {
1089
+ return new Response(new Uint8Array(png), {
1006
1090
  headers: {
1007
1091
  "Cache-Control": "public, max-age=31536000, immutable",
1008
1092
  "Content-Type": "image/png",
@@ -1069,19 +1153,12 @@ const htmlLang = i18n ? i18n.defaultLocale : "en";
1069
1153
  `;
1070
1154
 
1071
1155
  export const catchAllPageTemplate = (options: {
1072
- askEnabled: boolean;
1073
1156
  exportEpub: boolean;
1074
1157
  exportPdf: boolean;
1075
1158
  mathEnabled: boolean;
1076
1159
  /** Serialize the island-hooks snapshot; only needed when React is enabled. */
1077
1160
  needsReact: boolean;
1078
1161
  }): string => {
1079
- const askImport = options.askEnabled
1080
- ? 'import AskAI from "blume/components/islands/AskAI.astro";\n'
1081
- : "";
1082
- const askSlot = options.askEnabled
1083
- ? '\n <AskAI slot="ask" strings={ui.ask} suggestions={data.config.ask?.suggestions ?? []} />'
1084
- : "";
1085
1162
  const mathImport = options.mathEnabled
1086
1163
  ? 'import Math from "blume/components/content/Math.astro";\n'
1087
1164
  : "";
@@ -1094,10 +1171,10 @@ export const catchAllPageTemplate = (options: {
1094
1171
  return `---
1095
1172
  // Generated by Blume. Do not edit.
1096
1173
  import { getEntry, render } from "astro:content";
1174
+ import type { CollectionKey } from "astro:content";
1097
1175
  import RootLayout from "blume/components/layout/RootLayout.astro";
1098
1176
  import { withBase } from "blume/components/islands/base-path.ts";
1099
1177
  import { resolveSlot } from "blume/components/layout/overrides.ts";
1100
- ${askImport}
1101
1178
  import Accordion from "blume/components/content/Accordion.astro";
1102
1179
  import AccordionItem from "blume/components/content/AccordionItem.astro";
1103
1180
  import AutoTypeTable from "blume/components/content/AutoTypeTable.astro";
@@ -1138,7 +1215,7 @@ import ApiTagOperations from "blume/components/openapi/ApiTagOperations.astro";
1138
1215
  import Operation from "blume/components/openapi/Operation.astro";
1139
1216
  ${mathImport}import { mdxComponents as userMdx, layoutOverrides } from "../generated/components.ts";
1140
1217
  import { islandComponents } from "../generated/islands.ts";
1141
- import data from "../generated/data.json";
1218
+ import data from "blume:data";
1142
1219
 
1143
1220
  const Color = Object.assign(ColorRoot, { Item: ColorItem, Row: ColorRow });
1144
1221
  const Tree = Object.assign(TreeRoot, { File: TreeFile, Folder: TreeFolder });
@@ -1207,7 +1284,7 @@ export function getStaticPaths() {
1207
1284
  }
1208
1285
 
1209
1286
  const { entryId, collection, route, title, indexable, editUrl, lastModified, locale, alternates, fallback } = Astro.props;
1210
- const entry = await getEntry(collection, entryId);
1287
+ const entry = await getEntry(collection as CollectionKey, entryId);
1211
1288
  if (!entry) {
1212
1289
  return new Response(null, { status: 404 });
1213
1290
  }
@@ -1242,18 +1319,18 @@ const canonical =
1242
1319
  // Locale resolution. With i18n on, pick the active locale's nav + dictionary,
1243
1320
  // build hreflang alternates, and derive the language-switcher targets.
1244
1321
  const i18n = data.config.i18n;
1245
- const localePrefix = (codeArg) =>
1322
+ const localePrefix = (codeArg: string) =>
1246
1323
  i18n && codeArg === i18n.defaultLocale && i18n.hideDefaultLocalePrefix
1247
1324
  ? ""
1248
1325
  : \`/\${codeArg}\`;
1249
- const localizeRoute = (logical, codeArg) => {
1326
+ const localizeRoute = (logical: string, codeArg: string) => {
1250
1327
  const prefix = localePrefix(codeArg);
1251
1328
  if (!prefix) {
1252
1329
  return logical;
1253
1330
  }
1254
1331
  return logical === "/" ? prefix : \`\${prefix}\${logical}\`;
1255
1332
  };
1256
- const stripLocale = (path, codeArg) => {
1333
+ const stripLocale = (path: string, codeArg: string) => {
1257
1334
  const prefix = localePrefix(codeArg);
1258
1335
  return prefix && path.startsWith(prefix) ? path.slice(prefix.length) || "/" : path;
1259
1336
  };
@@ -1270,7 +1347,7 @@ const contentLocale =
1270
1347
  const contentDir = i18n
1271
1348
  ? (i18n.locales.find((l) => l.code === contentLocale)?.dir ?? "ltr")
1272
1349
  : "ltr";
1273
- const absolute = (path) => {
1350
+ const absolute = (path: string) => {
1274
1351
  const p = withBase(path);
1275
1352
  return base + (p === "/" ? "" : p);
1276
1353
  };
@@ -1334,7 +1411,6 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1334
1411
  canonical={canonical}
1335
1412
  editUrl={editUrl}
1336
1413
  feedback={data.config.feedback}
1337
- askEnabled={${options.askEnabled}}
1338
1414
  exportPdf={${options.exportPdf}}
1339
1415
  exportEpub={${options.exportEpub}}
1340
1416
  feeds={data.feeds}
@@ -1344,7 +1420,7 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1344
1420
  lastModified={lastModified}
1345
1421
  noindex={seo.noindex}
1346
1422
  structuredDataEnabled={data.config.structuredData}
1347
- >${askSlot}
1423
+ >
1348
1424
  <h1>{title}</h1>
1349
1425
  {frontmatter.description && <p class="text-lg text-muted-foreground">{frontmatter.description}</p>}
1350
1426
  <Content components={components} />
@@ -1359,7 +1435,6 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1359
1435
  * by {@link generateAstroProject} when changelog entries exist.
1360
1436
  */
1361
1437
  export const changelogIndexTemplate = (options: {
1362
- askEnabled: boolean;
1363
1438
  exportEpub: boolean;
1364
1439
  exportPdf: boolean;
1365
1440
  /** Serialize the island-hooks snapshot; only needed when React is enabled. */
@@ -1367,12 +1442,6 @@ export const changelogIndexTemplate = (options: {
1367
1442
  /** Whether a `staged` collection exists (non-filesystem changelog sources). */
1368
1443
  staged: boolean;
1369
1444
  }): string => {
1370
- const askImport = options.askEnabled
1371
- ? 'import AskAI from "blume/components/islands/AskAI.astro";\n'
1372
- : "";
1373
- const askSlot = options.askEnabled
1374
- ? '\n <AskAI slot="ask" strings={data.ui.ask} suggestions={data.config.ask?.suggestions ?? []} />'
1375
- : "";
1376
1445
  const clientData = options.needsReact
1377
1446
  ? '\n clientData={{ config: data.config, navigation: data.navigation, page: { route: "/changelog", title: pageTitle } }}'
1378
1447
  : "";
@@ -1390,14 +1459,15 @@ import Update from "blume/components/content/Update.astro";
1390
1459
  import { withBase } from "blume/components/islands/base-path.ts";
1391
1460
  import { resolveSlot } from "blume/components/layout/overrides.ts";
1392
1461
  import { layoutOverrides } from "../generated/components.ts";
1393
- ${askImport}import data from "../generated/data.json";
1462
+ import data from "blume:data";
1394
1463
 
1395
1464
  export const prerender = true;
1396
1465
 
1397
- const entryDate = (entry) =>
1398
- entry.data.date ?? entry.data.changelog?.date ?? null;
1466
+ const entryDate = (entry: {
1467
+ data: { date?: string | null; changelog?: { date?: string | null } | null };
1468
+ }) => entry.data.date ?? entry.data.changelog?.date ?? null;
1399
1469
 
1400
- const toTime = (value) => {
1470
+ const toTime = (value: string | null | undefined) => {
1401
1471
  if (!value) {
1402
1472
  return 0;
1403
1473
  }
@@ -1405,7 +1475,7 @@ const toTime = (value) => {
1405
1475
  return Number.isNaN(date.getTime()) ? 0 : date.getTime();
1406
1476
  };
1407
1477
 
1408
- const formatDate = (value) => {
1478
+ const formatDate = (value: string | null | undefined) => {
1409
1479
  if (!value) {
1410
1480
  return;
1411
1481
  }
@@ -1418,14 +1488,14 @@ const formatDate = (value) => {
1418
1488
  }).format(date);
1419
1489
  };
1420
1490
 
1421
- const slugify = (text) =>
1491
+ const slugify = (text: string) =>
1422
1492
  text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") ||
1423
1493
  "update";
1424
1494
 
1425
1495
  // The major of a version's embedded semver (\`1.2.3\` -> 1, \`pkg@2.0.0\` -> 2), or
1426
1496
  // null when there is no full major.minor.patch to key on. Drives the changelog's
1427
1497
  // group-by-major pagination, so it tolerates the scoped tags monorepos publish.
1428
- const majorVersion = (version) => {
1498
+ const majorVersion = (version: string | null | undefined) => {
1429
1499
  const match = /(\\d+)\\.\\d+\\.\\d+/.exec(String(version ?? ""));
1430
1500
  return match ? Number(match[1]) : null;
1431
1501
  };
@@ -1457,7 +1527,7 @@ const items = await Promise.all(
1457
1527
  return {
1458
1528
  Content: (await render(entry)).Content,
1459
1529
  date: formatDate(entryDate(entry)),
1460
- href: routeByEntry.get(entry.id) ?? null,
1530
+ href: routeByEntry.get(entry.id) ?? undefined,
1461
1531
  id: slugify(label),
1462
1532
  label,
1463
1533
  major: majorVersion(entry.data.changelog?.version),
@@ -1486,7 +1556,9 @@ for (const item of items) {
1486
1556
  // semver and they span more than one major line. Older majors then collapse
1487
1557
  // into groups the reader reveals one at a time; otherwise the timeline is flat.
1488
1558
  const majors = items.every((item) => item.major !== null)
1489
- ? [...new Set(items.map((item) => item.major))].toSorted((a, b) => b - a)
1559
+ ? [...new Set(items.map((item) => item.major))]
1560
+ .filter((major): major is number => major !== null)
1561
+ .toSorted((a, b) => b - a)
1490
1562
  : [];
1491
1563
  const paginate = majors.length > 1;
1492
1564
  const majorGroups = majors.map((major) => ({
@@ -1559,14 +1631,13 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1559
1631
  ogImage={null}
1560
1632
  x={data.config.x}
1561
1633
  canonical={canonical}
1562
- askEnabled={${options.askEnabled}}
1563
1634
  exportPdf={${options.exportPdf}}
1564
1635
  exportEpub={${options.exportEpub}}
1565
1636
  feeds={data.feeds}
1566
1637
  siteUrl={data.config.site}
1567
1638
  noindex={false}
1568
1639
  structuredDataEnabled={data.config.structuredData}
1569
- >${askSlot}
1640
+ >
1570
1641
  <h1>{changelogTitle}</h1>
1571
1642
  {
1572
1643
  items.length === 0 ? (
@@ -1635,7 +1706,7 @@ export const notFoundPageTemplate = (): string => `---
1635
1706
  // Generated by Blume. Do not edit. Override by adding \`pages/404.astro\`.
1636
1707
  import PageLayout from "blume/components/layout/PageLayout.astro";
1637
1708
  import { withBase } from "blume/components/islands/base-path.ts";
1638
- import data from "../generated/data.json";
1709
+ import data from "blume:data";
1639
1710
 
1640
1711
  export const prerender = true;
1641
1712
 
@@ -1844,7 +1915,10 @@ export const getStaticPaths = () =>
1844
1915
  Object.keys(examples).map((path) => ({ params: { path } }));
1845
1916
 
1846
1917
  const { path } = Astro.params;
1847
- const entry = examples[path];
1918
+ const entry = path ? examples[path] : undefined;
1919
+ if (!entry) {
1920
+ return new Response(null, { status: 404 });
1921
+ }
1848
1922
  const Example = entry.Component;
1849
1923
  ---
1850
1924
 
@@ -1891,11 +1965,24 @@ export const envTemplate =
1891
1965
  (): string => `/// <reference path="../.astro/types.d.ts" />
1892
1966
  /// <reference types="astro/client" />
1893
1967
 
1968
+ declare module "blume:ask" {
1969
+ const Ask: typeof import("blume/components/islands/AskAI.astro").default;
1970
+ export default Ask;
1971
+ }
1972
+
1894
1973
  declare module "blume:data" {
1895
1974
  const data: import("blume").BlumeData;
1896
1975
  export default data;
1897
1976
  }
1898
1977
 
1978
+ declare module "blume:examples" {
1979
+ type Examples = typeof import("./generated/examples.ts").examples;
1980
+ export const examples: Record<string, Examples[keyof Examples]>;
1981
+ export const examplesBase: string;
1982
+ }
1983
+
1984
+ declare module "blume:examples-theme";
1985
+
1899
1986
  declare module "blume:openapi" {
1900
1987
  const specs: import("blume/openapi/model.ts").OpenApiData;
1901
1988
  export default specs;
@@ -15,6 +15,12 @@ declare module "blume:search-client" {
15
15
  export const createSearch: () => Fn | Promise<Fn>;
16
16
  }
17
17
 
18
+ declare module "blume:ask" {
19
+ /** The generated Ask trigger (see `askComponentTemplate`); empty when Ask is off. */
20
+ const Ask: (props: Record<string, unknown>) => unknown;
21
+ export default Ask;
22
+ }
23
+
18
24
  declare module "blume:data" {
19
25
  /** The generated per-project data snapshot (see `core/data.ts`). */
20
26
  // biome-ignore lint/style/useImportType: ambient module must stay a global script
@@ -60,8 +60,14 @@ const iconClass: Record<CalloutType, string> = {
60
60
  <span class:list={["mt-0.5 shrink-0", color ? "" : iconClass[type]]}>
61
61
  <Icon color={color} icon={icon ?? iconByType[type]} size={16} />
62
62
  </span>
63
- <div class="flex-1 [&>:first-child]:mt-0! [&>:last-child]:mb-0!">
64
- {title && <p class="mb-1 font-semibold text-foreground">{title}</p>}
63
+ {/* The global prose rule leaks a 1rem margin onto these paragraphs/lists even
64
+ though the callout is not-prose; with a title the body isn't the first
65
+ child, so that margin stacks under the title's own gap and reads as too
66
+ much space. Override it here for a uniform, compact gap (important beats
67
+ the unlayered prose rule): every child a small top margin, none on the
68
+ first, and no trailing bottom margin. */}
69
+ <div class="flex-1 [&>*]:mt-2! [&>*]:mb-0! [&>:first-child]:mt-0!">
70
+ {title && <p class="font-semibold text-foreground">{title}</p>}
65
71
  <slot />
66
72
  </div>
67
73
  </aside>
@@ -59,7 +59,15 @@ const secondaryButton =
59
59
  data-blume-prompt-copy
60
60
  type="button"
61
61
  >
62
- <span data-blume-prompt-copy-label>Copy prompt</span>
62
+ {/* Both labels share one grid cell so the button is always sized
63
+ to the wider ("Copy prompt") — swapping to "Copied" on copy
64
+ can't resize it and shift the description beside it. */}
65
+ <span class="grid *:col-start-1 *:row-start-1 *:text-center">
66
+ <span data-blume-prompt-copy-idle>Copy prompt</span>
67
+ <span aria-hidden="true" class="invisible" data-blume-prompt-copy-done>
68
+ Copied
69
+ </span>
70
+ </span>
63
71
  </button>
64
72
  )}
65
73
  {enabledActions.has("cursor") && (
@@ -99,6 +107,13 @@ const secondaryButton =
99
107
  const copy = this.querySelector<HTMLButtonElement>(
100
108
  "[data-blume-prompt-copy]"
101
109
  );
110
+ const idle = copy?.querySelector<HTMLElement>(
111
+ "[data-blume-prompt-copy-idle]"
112
+ );
113
+ const done = copy?.querySelector<HTMLElement>(
114
+ "[data-blume-prompt-copy-done]"
115
+ );
116
+ let resetTimer: ReturnType<typeof setTimeout> | undefined;
102
117
  copy?.addEventListener("click", async () => {
103
118
  const text = promptText();
104
119
  if (!text) {
@@ -110,18 +125,15 @@ const secondaryButton =
110
125
  return;
111
126
  }
112
127
 
113
- const label = copy.querySelector<HTMLElement>(
114
- "[data-blume-prompt-copy-label]"
115
- );
116
- if (!label) {
117
- return;
118
- }
119
- // Remember the real label once — capturing at click time would
120
- // capture "Copied" on a double-click and stick until reload.
121
- label.dataset.blumeLabel ??= label.textContent ?? "Copy prompt";
122
- label.textContent = "Copied";
123
- setTimeout(() => {
124
- label.textContent = label.dataset.blumeLabel ?? "Copy prompt";
128
+ // Toggle visibility rather than swapping text: both labels occupy the
129
+ // same grid cell, so the button width never changes. Clearing the timer
130
+ // keeps the "Copied" state 1.5s past the last of several rapid clicks.
131
+ clearTimeout(resetTimer);
132
+ idle?.classList.add("invisible");
133
+ done?.classList.remove("invisible");
134
+ resetTimer = setTimeout(() => {
135
+ idle?.classList.remove("invisible");
136
+ done?.classList.add("invisible");
125
137
  }, 1500);
126
138
  });
127
139
  }
@@ -1,4 +1,6 @@
1
1
  ---
2
+ import Ask from "blume:ask";
3
+ import data from "blume:data";
2
4
  import { withBase } from "../islands/base-path.ts";
3
5
  import type { ComponentOverride } from "../../core/define-components.ts";
4
6
  import { EN_UI } from "../../core/i18n-ui.ts";
@@ -8,7 +10,7 @@ import { GITHUB_MARK } from "../github-mark.ts";
8
10
  import Icon from "../Icon.astro";
9
11
  import LanguageSwitcher from "./LanguageSwitcher.astro";
10
12
  import Logo from "./Logo.astro";
11
- import { isUnderPath } from "./nav-utils.ts";
13
+ import { activeTabForRoute } from "./nav-utils.ts";
12
14
  import NavSelector from "./NavSelector.astro";
13
15
  import { resolveSlot } from "./overrides.ts";
14
16
  import Search from "./Search.astro";
@@ -26,7 +28,14 @@ interface Props {
26
28
  navigation: Navigation;
27
29
  route: string;
28
30
  searchEnabled: boolean;
31
+ /**
32
+ * Whether the search modal offers an "Ask AI" hand-off. Defaults to whether
33
+ * Ask AI is configured, so no page has to pass it; a layout can still opt a
34
+ * shell out explicitly.
35
+ */
29
36
  askEnabled?: boolean;
37
+ /** Localized Ask AI strings for the active locale. */
38
+ askStrings?: UIStrings["ask"];
30
39
  // The mobile menu button toggles the docs sidebar drawer; custom pages
31
40
  // without a sidebar (e.g. a landing page) pass `false` to hide it.
32
41
  hasSidebar?: boolean;
@@ -57,7 +66,11 @@ const {
57
66
  navigation,
58
67
  route,
59
68
  searchEnabled,
60
- askEnabled = false,
69
+ // `config.ask` is null whenever Ask AI is off, so the header is the one place
70
+ // that has to know — the Ask trigger below and the search modal's hand-off to
71
+ // it both switch on this, and every page gets both for free.
72
+ askEnabled = Boolean(data.config.ask),
73
+ askStrings,
61
74
  hasSidebar = true,
62
75
  hasDrawer = true,
63
76
  searchStrings,
@@ -81,6 +94,7 @@ const SearchSlot = resolveSlot(layout.Search, Search);
81
94
  // PageLayout) it's a tabs-only drawer the layout renders — so the button is also
82
95
  // needed whenever there are tabs to reveal.
83
96
  const showNavToggle = hasDrawer && (hasSidebar || navigation.tabs.length > 0);
97
+ const activeTab = activeTabForRoute(navigation.tabs, route);
84
98
  // Where the header's inline tab bar appears. With a sidebar it shares the `md`
85
99
  // breakpoint with the docs drawer; without one, the tabs-only drawer is the sole
86
100
  // mobile nav below `lg`, so the inline tabs wait until `lg` to avoid duplicating
@@ -142,14 +156,9 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
142
156
  <nav aria-label={n.sections} class={tabsNavClass}>
143
157
  {navigation.tabs.map((tab) => (
144
158
  <a
145
- aria-current={
146
- route === tab.path ||
147
- (tab.path !== "/" && isUnderPath(route, tab.path))
148
- ? "page"
149
- : undefined
150
- }
159
+ aria-current={tab === activeTab ? "page" : undefined}
151
160
  class="rounded-full px-3 py-1.5 font-medium text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground aria-[current=page]:text-foreground"
152
- href={withBase(tab.path)}
161
+ href={withBase(tab.href ?? tab.path)}
153
162
  >
154
163
  {tab.label}
155
164
  </a>
@@ -208,7 +217,7 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
208
217
  <span class="inline-flex dark:hidden"><Icon name="sun" size={18} /></span>
209
218
  <span class="hidden dark:inline-flex"><Icon name="moon" size={18} /></span>
210
219
  </button>
211
- <slot name="ask" />
220
+ {askEnabled && <Ask strings={askStrings} />}
212
221
  </div>
213
222
  </header>
214
223
  <script is:inline set:html={clickScript} />
@@ -18,6 +18,8 @@ const { site, logo } = Astro.props;
18
18
  const logoSvg = logo?.svg;
19
19
  const logoLight = logo?.light;
20
20
  const logoDark = logo?.dark ?? logo?.light;
21
+ const logoLightDimensions = logo?.dimensions?.light;
22
+ const logoDarkDimensions = logo?.dimensions?.dark ?? logo?.dimensions?.light;
21
23
  const logoAlt = logo?.alt ?? "";
22
24
  const brandHref = logo?.href ?? "/";
23
25
  // Wordmark beside the mark: the configured `logo.text`, or the site title when
@@ -43,18 +45,28 @@ const brandText = logo?.text ?? site.title;
43
45
  !logoSvg &&
44
46
  logoLight &&
45
47
  (logoLight === logoDark ? (
46
- <img alt={logoAlt} class="h-5 w-auto" src={withBase(logoLight)} />
48
+ <img
49
+ alt={logoAlt}
50
+ class="h-5 w-auto"
51
+ height={logoLightDimensions?.height}
52
+ src={withBase(logoLight)}
53
+ width={logoLightDimensions?.width}
54
+ />
47
55
  ) : (
48
56
  <>
49
57
  <img
50
58
  alt={logoAlt}
51
59
  class="h-5 w-auto dark:hidden"
60
+ height={logoLightDimensions?.height}
52
61
  src={withBase(logoLight)}
62
+ width={logoLightDimensions?.width}
53
63
  />
54
64
  <img
55
65
  alt={logoAlt}
56
66
  class="hidden h-5 w-auto dark:block"
67
+ height={logoDarkDimensions?.height}
57
68
  src={withBase(logoDark)}
69
+ width={logoDarkDimensions?.width}
58
70
  />
59
71
  </>
60
72
  ))
@@ -15,7 +15,7 @@ const { strings } = Astro.props;
15
15
  const f = { ...EN_UI.feedback, ...strings };
16
16
 
17
17
  const buttonClass =
18
- "inline-flex items-center gap-2 rounded-full border border-border px-4 py-2 text-foreground text-sm transition-colors hover:border-foreground";
18
+ "inline-flex items-center gap-2 rounded-blume border border-border px-4 py-2 text-foreground text-sm transition-colors hover:border-foreground";
19
19
  ---
20
20
 
21
21
  <section