blume 1.0.3 → 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 (51) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/cli/index.js +441 -259
  3. package/dist/cli/index.js.map +12 -11
  4. package/dist/types/core/config-input.d.ts +27 -8
  5. package/dist/types/core/data.d.ts +12 -0
  6. package/dist/types/core/i18n-ui.d.ts +136 -136
  7. package/dist/types/core/schema.d.ts +420 -350
  8. package/dist/types/core/types.d.ts +10 -0
  9. package/dist/types/openapi/references.d.ts +12 -7
  10. package/docs/advanced/api-reference.mdx +11 -3
  11. package/docs/configuration/seo.mdx +20 -1
  12. package/docs/content/components.mdx +1 -2
  13. package/docs/content/navigation.mdx +10 -0
  14. package/docs/content/syntax.mdx +116 -4
  15. package/package.json +1 -1
  16. package/skills/blume-migrate/SKILL.md +170 -0
  17. package/skills/blume-migrate/assets/oxfmt@0.55.0.patch +20 -0
  18. package/skills/blume-migrate/references/docusaurus.md +95 -0
  19. package/skills/blume-migrate/references/fumadocs.md +95 -0
  20. package/skills/blume-migrate/references/mintlify.md +155 -0
  21. package/skills/blume-migrate/references/monorepo.md +224 -0
  22. package/skills/blume-migrate/references/nextra.md +76 -0
  23. package/skills/blume-migrate/references/starlight.md +116 -0
  24. package/skills/blume-migrate/scripts/mintlify-codemod.mjs +466 -0
  25. package/src/astro/component-slots.ts +3 -2
  26. package/src/astro/generate.ts +82 -23
  27. package/src/astro/templates.ts +93 -34
  28. package/src/components/content/Callout.astro +8 -2
  29. package/src/components/content/Prompt.astro +25 -13
  30. package/src/components/layout/Header.astro +4 -8
  31. package/src/components/layout/Logo.astro +13 -1
  32. package/src/components/layout/PageFeedback.astro +1 -1
  33. package/src/components/layout/PageLayout.astro +4 -8
  34. package/src/components/layout/Pagination.astro +6 -6
  35. package/src/components/layout/RootLayout.astro +4 -8
  36. package/src/components/layout/Search.astro +1 -1
  37. package/src/components/layout/nav-utils.ts +9 -7
  38. package/src/core/config-input.ts +29 -8
  39. package/src/core/data.ts +9 -1
  40. package/src/core/navigation.ts +55 -13
  41. package/src/core/schema.ts +17 -1
  42. package/src/core/sources/watch.ts +5 -0
  43. package/src/core/types.ts +10 -0
  44. package/src/markdown/index.ts +2 -0
  45. package/src/markdown/language-icon.ts +2 -1
  46. package/src/markdown/table-wrap.ts +43 -0
  47. package/src/og/card.ts +39 -12
  48. package/src/og/index.ts +1 -1
  49. package/src/og/logo.ts +21 -0
  50. package/src/openapi/references.ts +19 -16
  51. package/src/theme/entry.ts +50 -5
@@ -37,7 +37,8 @@ import { resolveDocsCollection } from "../core/sources/resolve.ts";
37
37
  import { resolveTsconfigAliases } from "../core/tsconfig-aliases.ts";
38
38
  import type { Navigation } from "../core/types.ts";
39
39
  import { buildRssFeeds, renderRssFeed } from "../deploy/rss.ts";
40
- import { hasScalarReferences, referenceTabs } from "../openapi/references.ts";
40
+ import { resolveOgLogo } from "../og/logo.ts";
41
+ import { hasScalarReferences, referenceRoutes } from "../openapi/references.ts";
41
42
  import { buildReferenceFiles } from "../openapi/scalar.ts";
42
43
  import { isOpenApiSource } from "../openapi/source.ts";
43
44
  import { registry } from "../registry/registry.ts";
@@ -626,7 +627,56 @@ const writeStagedContent = async (
626
627
  }
627
628
  };
628
629
 
629
- /** The logo shape the runtime consumes: an inline SVG or image URL(s). */
630
+ interface LogoDimensions {
631
+ height: number;
632
+ width: number;
633
+ }
634
+
635
+ const SVG_ROOT = /<svg\b(?<attributes>[^>]*)>/u;
636
+ const SVG_WIDTH = /\bwidth\s*=\s*["'](?<value>[^"']+)["']/u;
637
+ const SVG_HEIGHT = /\bheight\s*=\s*["'](?<value>[^"']+)["']/u;
638
+ const SVG_LENGTH = /^\s*(?<value>[\d.]+)(?:px)?\s*$/u;
639
+ const SVG_VIEW_BOX =
640
+ /\bviewBox\s*=\s*["'][\d.-]+[\s,]+[\d.-]+[\s,]+(?<width>[\d.]+)[\s,]+(?<height>[\d.]+)["']/u;
641
+
642
+ const parseSvgLength = (value: string | undefined): number | undefined => {
643
+ const length = Number(value?.match(SVG_LENGTH)?.groups?.value);
644
+ return length > 0 ? length : undefined;
645
+ };
646
+
647
+ /** Read dimensions from an SVG's explicit size or its view box. */
648
+ const svgDimensions = (svg: string | undefined): LogoDimensions | undefined => {
649
+ const attributes = svg?.match(SVG_ROOT)?.groups?.attributes;
650
+ const width = parseSvgLength(attributes?.match(SVG_WIDTH)?.groups?.value);
651
+ const height = parseSvgLength(attributes?.match(SVG_HEIGHT)?.groups?.value);
652
+ if (width && height) {
653
+ return { height, width };
654
+ }
655
+
656
+ const viewBox = attributes?.match(SVG_VIEW_BOX);
657
+ const viewBoxWidth = Number(viewBox?.groups?.width);
658
+ const viewBoxHeight = Number(viewBox?.groups?.height);
659
+ return viewBoxWidth > 0 && viewBoxHeight > 0
660
+ ? { height: viewBoxHeight, width: viewBoxWidth }
661
+ : undefined;
662
+ };
663
+
664
+ /** Read a local SVG logo from the project root or public directory. */
665
+ const readLogoSvg = (
666
+ project: BlumeProject,
667
+ source: string | undefined
668
+ ): string | undefined => {
669
+ if (!source?.toLowerCase().endsWith(".svg")) {
670
+ return;
671
+ }
672
+ const rel = source.replace(/^\//u, "");
673
+ const file = [
674
+ join(project.context.root, "public", rel),
675
+ join(project.context.root, rel),
676
+ ].find((path) => existsSync(path));
677
+ return file ? readFileSync(file, "utf-8") : undefined;
678
+ };
679
+
630
680
  /**
631
681
  * Resolve the configured logo. A single SVG is read and inlined so a
632
682
  * `currentColor` logo follows the theme; other images keep their URL for an
@@ -647,18 +697,20 @@ const resolveLogo = (project: BlumeProject): BlumeLogo | null => {
647
697
  const dark = image?.dark ?? image?.light;
648
698
  const alt = image?.alt ?? "";
649
699
  const brandHref = href ?? "/";
700
+ const lightSvg = readLogoSvg(project, light);
701
+ const darkSvg = dark === light ? lightSvg : readLogoSvg(project, dark);
650
702
 
651
- if (light && light === dark && light.toLowerCase().endsWith(".svg")) {
652
- const rel = light.replace(/^\//u, "");
653
- const file = [
654
- join(project.context.root, "public", rel),
655
- join(project.context.root, rel),
656
- ].find((path) => existsSync(path));
657
- if (file) {
658
- return { alt, href: brandHref, svg: readFileSync(file, "utf-8"), text };
659
- }
703
+ if (light && light === dark && lightSvg) {
704
+ return { alt, href: brandHref, svg: lightSvg, text };
660
705
  }
661
- return { alt, dark, href: brandHref, light, text };
706
+
707
+ const lightDimensions = svgDimensions(lightSvg);
708
+ const darkDimensions = svgDimensions(darkSvg);
709
+ const dimensions =
710
+ lightDimensions || darkDimensions
711
+ ? { dark: darkDimensions, light: lightDimensions }
712
+ : undefined;
713
+ return { alt, dark, dimensions, href: brandHref, light, text };
662
714
  };
663
715
 
664
716
  /**
@@ -779,6 +831,10 @@ export const buildRuntimeData = (project: BlumeProject): string => {
779
831
  ? `https://github.com/${github.owner}/${github.repo}`
780
832
  : null;
781
833
  const editBase = github ? `${repoUrl}/edit/${github.branch}` : null;
834
+ const logo = resolveLogo(project);
835
+ const ogLogo = config.seo.og.logo
836
+ ? resolveOgLogo(project, config.seo.og.logo)
837
+ : logo?.svg;
782
838
 
783
839
  const editUrlFor = (sourcePath?: string): string | null => {
784
840
  if (!(editBase && sourcePath)) {
@@ -791,13 +847,12 @@ export const buildRuntimeData = (project: BlumeProject): string => {
791
847
 
792
848
  const { i18n } = config;
793
849
 
794
- // API reference routes surface as header tabs alongside the content-derived
795
- // ones (Blume-rendered references also own a tab-scoped sidebar of operations),
796
- // so the reference stays discoverable in every locale.
797
- const withReferenceTabs = (nav: Navigation): Navigation => ({
850
+ // Resolve the header repo link per locale. API references no longer add a tab
851
+ // automatically authors point a `navigation.tabs` entry at the reference
852
+ // route to surface it (see `referenceRoutes`).
853
+ const withRepoUrl = (nav: Navigation): Navigation => ({
798
854
  ...nav,
799
855
  repoUrl: config.navigation.repo && repoUrl ? repoUrl : null,
800
- tabs: [...nav.tabs, ...referenceTabs(config)],
801
856
  });
802
857
 
803
858
  // Resolved UI dictionaries: one per locale under i18n, English baseline
@@ -824,7 +879,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
824
879
  ? Object.fromEntries(
825
880
  i18n.locales.map(({ code }) => [
826
881
  code,
827
- withReferenceTabs(
882
+ withRepoUrl(
828
883
  graph.navigationByLocale[code] ?? {
829
884
  featured: [],
830
885
  selectors: [],
@@ -865,7 +920,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
865
920
  }
866
921
  : null,
867
922
  imageZoom: config.markdown.imageZoom,
868
- logo: resolveLogo(project),
923
+ logo,
869
924
  mcp: config.ai.mcp.enabled
870
925
  ? {
871
926
  name: config.ai.mcp.name ?? config.title,
@@ -874,7 +929,11 @@ export const buildRuntimeData = (project: BlumeProject): string => {
874
929
  : null,
875
930
  // `og.enabled` is resolved to a definite boolean in `loadConfig`; coerce
876
931
  // the optional schema type so the serialized shape stays `boolean`.
877
- og: { enabled: config.seo.og.enabled ?? false },
932
+ og: {
933
+ enabled: config.seo.og.enabled ?? false,
934
+ logo: ogLogo,
935
+ palette: config.seo.og.palette,
936
+ },
878
937
  repoUrl,
879
938
  search: {
880
939
  enabled: config.search.provider !== "none",
@@ -894,7 +953,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
894
953
  // CSS variables for Astro's <Font> component; matches the astro.config
895
954
  // `fonts:` entries derived from the same theme.fonts config.
896
955
  fontCssVars: configuredCssVars(config.theme.fonts),
897
- navigation: withReferenceTabs(graph.navigation),
956
+ navigation: withRepoUrl(graph.navigation),
898
957
  // Per-locale navigation; the catch-all selects the active locale's tree.
899
958
  navigationByLocale,
900
959
  routes: manifest.routes.map((route) => ({
@@ -1441,11 +1500,11 @@ export const generateRuntime = async (
1441
1500
 
1442
1501
  // Missing-navigation-target check, now that every servable route is known:
1443
1502
  // content routes, custom `.astro` pages, the generated changelog, and any
1444
- // OpenAPI reference tabs.
1503
+ // OpenAPI reference routes (so a tab an author points at one still validates).
1445
1504
  const navTargetRoutes = new Set<string>([
1446
1505
  ...project.graph.routes.keys(),
1447
1506
  ...pages.map((page) => page.pattern),
1448
- ...referenceTabs(config).map((tab) => tab.path),
1507
+ ...referenceRoutes(config),
1449
1508
  ]);
1450
1509
  if (hasGeneratedChangelog(project, pages)) {
1451
1510
  navTargetRoutes.add("/changelog");
@@ -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
@@ -263,10 +287,15 @@ export const astroConfigTemplate = (options: {
263
287
  server && deployment.adapter
264
288
  ? `import adapter from "${ADAPTER_IMPORTS[deployment.adapter]}";\n`
265
289
  : "";
266
- const adapterArgs =
267
- server && deployment.adapter
268
- ? (ADAPTER_OPTIONS[deployment.adapter] ?? "")
269
- : "";
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
+ })();
270
299
  const adapterOption =
271
300
  server && deployment.adapter ? `\n adapter: adapter(${adapterArgs}),` : "";
272
301
 
@@ -410,6 +439,18 @@ export default defineConfig({
410
439
  devToolbar: { enabled: false },
411
440
  vite: {
412
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"] },
413
454
  // Blume's render-time deps are forced external on both build environments so
414
455
  // native bindings resolve at runtime and isolated linkers don't bundle
415
456
  // symlinked store copies (which would surface their children as unresolvable
@@ -909,10 +950,11 @@ export function getStaticPaths() {
909
950
  }));
910
951
  }
911
952
 
912
- export function GET({ props }) {
913
- 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];
914
956
  return new Response(entry ? ${
915
- kind === "md" ? "(entry.md ?? entry.mdx)" : "entry.mdx"
957
+ kind === "md" ? '(entry.md ?? entry.mdx ?? "")' : '(entry.mdx ?? "")'
916
958
  } : "", {
917
959
  headers: { "Content-Type": "text/markdown; charset=utf-8" },
918
960
  });
@@ -976,8 +1018,9 @@ export function getStaticPaths() {
976
1018
  }));
977
1019
  }
978
1020
 
979
- export function GET({ props }) {
980
- 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] ?? "", {
981
1024
  headers: { "Content-Type": "application/rss+xml; charset=utf-8" },
982
1025
  });
983
1026
  }
@@ -989,7 +1032,7 @@ export const ogEndpointTemplate = (
989
1032
  ): string =>
990
1033
  `// Generated by Blume. Do not edit.
991
1034
  import { renderOgImage } from "blume/og";
992
- import data from "../../generated/data.json";
1035
+ import data from "blume:data";
993
1036
 
994
1037
  export const prerender = true;
995
1038
 
@@ -997,9 +1040,9 @@ export const prerender = true;
997
1040
  const customRoutes = ${JSON.stringify(customRoutes)};
998
1041
 
999
1042
  export function getStaticPaths() {
1000
- const seen = new Set();
1001
- const paths = [];
1002
- 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) => {
1003
1046
  if (seen.has(slug)) {
1004
1047
  return;
1005
1048
  }
@@ -1032,17 +1075,18 @@ const siteHost = (() => {
1032
1075
  }
1033
1076
  })();
1034
1077
 
1035
- export async function GET({ props }) {
1078
+ export async function GET({ props }: { props: { title: string } }) {
1036
1079
  const png = await renderOgImage({
1037
- accent: data.config.theme.accent.light,
1080
+ accent: data.config.og.palette?.accent ?? data.config.theme.accent.light,
1038
1081
  brand: data.config.title,
1039
1082
  description: data.config.description,
1040
- logo: data.config.logo?.svg,
1083
+ logo: data.config.og.logo,
1084
+ palette: data.config.og.palette,
1041
1085
  repo: repoSlug,
1042
1086
  site: siteHost,
1043
1087
  title: props.title,
1044
1088
  });
1045
- return new Response(png, {
1089
+ return new Response(new Uint8Array(png), {
1046
1090
  headers: {
1047
1091
  "Cache-Control": "public, max-age=31536000, immutable",
1048
1092
  "Content-Type": "image/png",
@@ -1127,6 +1171,7 @@ export const catchAllPageTemplate = (options: {
1127
1171
  return `---
1128
1172
  // Generated by Blume. Do not edit.
1129
1173
  import { getEntry, render } from "astro:content";
1174
+ import type { CollectionKey } from "astro:content";
1130
1175
  import RootLayout from "blume/components/layout/RootLayout.astro";
1131
1176
  import { withBase } from "blume/components/islands/base-path.ts";
1132
1177
  import { resolveSlot } from "blume/components/layout/overrides.ts";
@@ -1170,7 +1215,7 @@ import ApiTagOperations from "blume/components/openapi/ApiTagOperations.astro";
1170
1215
  import Operation from "blume/components/openapi/Operation.astro";
1171
1216
  ${mathImport}import { mdxComponents as userMdx, layoutOverrides } from "../generated/components.ts";
1172
1217
  import { islandComponents } from "../generated/islands.ts";
1173
- import data from "../generated/data.json";
1218
+ import data from "blume:data";
1174
1219
 
1175
1220
  const Color = Object.assign(ColorRoot, { Item: ColorItem, Row: ColorRow });
1176
1221
  const Tree = Object.assign(TreeRoot, { File: TreeFile, Folder: TreeFolder });
@@ -1239,7 +1284,7 @@ export function getStaticPaths() {
1239
1284
  }
1240
1285
 
1241
1286
  const { entryId, collection, route, title, indexable, editUrl, lastModified, locale, alternates, fallback } = Astro.props;
1242
- const entry = await getEntry(collection, entryId);
1287
+ const entry = await getEntry(collection as CollectionKey, entryId);
1243
1288
  if (!entry) {
1244
1289
  return new Response(null, { status: 404 });
1245
1290
  }
@@ -1274,18 +1319,18 @@ const canonical =
1274
1319
  // Locale resolution. With i18n on, pick the active locale's nav + dictionary,
1275
1320
  // build hreflang alternates, and derive the language-switcher targets.
1276
1321
  const i18n = data.config.i18n;
1277
- const localePrefix = (codeArg) =>
1322
+ const localePrefix = (codeArg: string) =>
1278
1323
  i18n && codeArg === i18n.defaultLocale && i18n.hideDefaultLocalePrefix
1279
1324
  ? ""
1280
1325
  : \`/\${codeArg}\`;
1281
- const localizeRoute = (logical, codeArg) => {
1326
+ const localizeRoute = (logical: string, codeArg: string) => {
1282
1327
  const prefix = localePrefix(codeArg);
1283
1328
  if (!prefix) {
1284
1329
  return logical;
1285
1330
  }
1286
1331
  return logical === "/" ? prefix : \`\${prefix}\${logical}\`;
1287
1332
  };
1288
- const stripLocale = (path, codeArg) => {
1333
+ const stripLocale = (path: string, codeArg: string) => {
1289
1334
  const prefix = localePrefix(codeArg);
1290
1335
  return prefix && path.startsWith(prefix) ? path.slice(prefix.length) || "/" : path;
1291
1336
  };
@@ -1302,7 +1347,7 @@ const contentLocale =
1302
1347
  const contentDir = i18n
1303
1348
  ? (i18n.locales.find((l) => l.code === contentLocale)?.dir ?? "ltr")
1304
1349
  : "ltr";
1305
- const absolute = (path) => {
1350
+ const absolute = (path: string) => {
1306
1351
  const p = withBase(path);
1307
1352
  return base + (p === "/" ? "" : p);
1308
1353
  };
@@ -1414,14 +1459,15 @@ import Update from "blume/components/content/Update.astro";
1414
1459
  import { withBase } from "blume/components/islands/base-path.ts";
1415
1460
  import { resolveSlot } from "blume/components/layout/overrides.ts";
1416
1461
  import { layoutOverrides } from "../generated/components.ts";
1417
- import data from "../generated/data.json";
1462
+ import data from "blume:data";
1418
1463
 
1419
1464
  export const prerender = true;
1420
1465
 
1421
- const entryDate = (entry) =>
1422
- 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;
1423
1469
 
1424
- const toTime = (value) => {
1470
+ const toTime = (value: string | null | undefined) => {
1425
1471
  if (!value) {
1426
1472
  return 0;
1427
1473
  }
@@ -1429,7 +1475,7 @@ const toTime = (value) => {
1429
1475
  return Number.isNaN(date.getTime()) ? 0 : date.getTime();
1430
1476
  };
1431
1477
 
1432
- const formatDate = (value) => {
1478
+ const formatDate = (value: string | null | undefined) => {
1433
1479
  if (!value) {
1434
1480
  return;
1435
1481
  }
@@ -1442,14 +1488,14 @@ const formatDate = (value) => {
1442
1488
  }).format(date);
1443
1489
  };
1444
1490
 
1445
- const slugify = (text) =>
1491
+ const slugify = (text: string) =>
1446
1492
  text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") ||
1447
1493
  "update";
1448
1494
 
1449
1495
  // The major of a version's embedded semver (\`1.2.3\` -> 1, \`pkg@2.0.0\` -> 2), or
1450
1496
  // null when there is no full major.minor.patch to key on. Drives the changelog's
1451
1497
  // group-by-major pagination, so it tolerates the scoped tags monorepos publish.
1452
- const majorVersion = (version) => {
1498
+ const majorVersion = (version: string | null | undefined) => {
1453
1499
  const match = /(\\d+)\\.\\d+\\.\\d+/.exec(String(version ?? ""));
1454
1500
  return match ? Number(match[1]) : null;
1455
1501
  };
@@ -1481,7 +1527,7 @@ const items = await Promise.all(
1481
1527
  return {
1482
1528
  Content: (await render(entry)).Content,
1483
1529
  date: formatDate(entryDate(entry)),
1484
- href: routeByEntry.get(entry.id) ?? null,
1530
+ href: routeByEntry.get(entry.id) ?? undefined,
1485
1531
  id: slugify(label),
1486
1532
  label,
1487
1533
  major: majorVersion(entry.data.changelog?.version),
@@ -1510,7 +1556,9 @@ for (const item of items) {
1510
1556
  // semver and they span more than one major line. Older majors then collapse
1511
1557
  // into groups the reader reveals one at a time; otherwise the timeline is flat.
1512
1558
  const majors = items.every((item) => item.major !== null)
1513
- ? [...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)
1514
1562
  : [];
1515
1563
  const paginate = majors.length > 1;
1516
1564
  const majorGroups = majors.map((major) => ({
@@ -1658,7 +1706,7 @@ export const notFoundPageTemplate = (): string => `---
1658
1706
  // Generated by Blume. Do not edit. Override by adding \`pages/404.astro\`.
1659
1707
  import PageLayout from "blume/components/layout/PageLayout.astro";
1660
1708
  import { withBase } from "blume/components/islands/base-path.ts";
1661
- import data from "../generated/data.json";
1709
+ import data from "blume:data";
1662
1710
 
1663
1711
  export const prerender = true;
1664
1712
 
@@ -1867,7 +1915,10 @@ export const getStaticPaths = () =>
1867
1915
  Object.keys(examples).map((path) => ({ params: { path } }));
1868
1916
 
1869
1917
  const { path } = Astro.params;
1870
- const entry = examples[path];
1918
+ const entry = path ? examples[path] : undefined;
1919
+ if (!entry) {
1920
+ return new Response(null, { status: 404 });
1921
+ }
1871
1922
  const Example = entry.Component;
1872
1923
  ---
1873
1924
 
@@ -1924,6 +1975,14 @@ declare module "blume:data" {
1924
1975
  export default data;
1925
1976
  }
1926
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
+
1927
1986
  declare module "blume:openapi" {
1928
1987
  const specs: import("blume/openapi/model.ts").OpenApiData;
1929
1988
  export default specs;
@@ -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
  }
@@ -10,7 +10,7 @@ import { GITHUB_MARK } from "../github-mark.ts";
10
10
  import Icon from "../Icon.astro";
11
11
  import LanguageSwitcher from "./LanguageSwitcher.astro";
12
12
  import Logo from "./Logo.astro";
13
- import { isUnderPath } from "./nav-utils.ts";
13
+ import { activeTabForRoute } from "./nav-utils.ts";
14
14
  import NavSelector from "./NavSelector.astro";
15
15
  import { resolveSlot } from "./overrides.ts";
16
16
  import Search from "./Search.astro";
@@ -94,6 +94,7 @@ const SearchSlot = resolveSlot(layout.Search, Search);
94
94
  // PageLayout) it's a tabs-only drawer the layout renders — so the button is also
95
95
  // needed whenever there are tabs to reveal.
96
96
  const showNavToggle = hasDrawer && (hasSidebar || navigation.tabs.length > 0);
97
+ const activeTab = activeTabForRoute(navigation.tabs, route);
97
98
  // Where the header's inline tab bar appears. With a sidebar it shares the `md`
98
99
  // breakpoint with the docs drawer; without one, the tabs-only drawer is the sole
99
100
  // mobile nav below `lg`, so the inline tabs wait until `lg` to avoid duplicating
@@ -155,14 +156,9 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
155
156
  <nav aria-label={n.sections} class={tabsNavClass}>
156
157
  {navigation.tabs.map((tab) => (
157
158
  <a
158
- aria-current={
159
- route === tab.path ||
160
- (tab.path !== "/" && isUnderPath(route, tab.path))
161
- ? "page"
162
- : undefined
163
- }
159
+ aria-current={tab === activeTab ? "page" : undefined}
164
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"
165
- href={withBase(tab.path)}
161
+ href={withBase(tab.href ?? tab.path)}
166
162
  >
167
163
  {tab.label}
168
164
  </a>
@@ -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
@@ -39,7 +39,7 @@ import Favicon from "./Favicon.astro";
39
39
  import Fonts from "./Fonts.astro";
40
40
  import { BANNER_INIT_SCRIPT, THEME_INIT_SCRIPT } from "./head-scripts.ts";
41
41
  import Header from "./Header.astro";
42
- import { isUnderPath } from "./nav-utils.ts";
42
+ import { activeTabForRoute } from "./nav-utils.ts";
43
43
 
44
44
  interface Props {
45
45
  site: { title: string; description?: string };
@@ -137,6 +137,7 @@ const searchLocale =
137
137
  const pageTitle = page?.title ?? site.title;
138
138
  const description = page?.description ?? site.description;
139
139
  const route = page?.route ?? "/";
140
+ const activeTab = activeTabForRoute(navigation.tabs, route);
140
141
 
141
142
  // Derive canonical + og:image from the site URL the same way the catch-all does
142
143
  // for content pages, so a custom page gets both for free. The matching OG card
@@ -267,14 +268,9 @@ const bannerKey = banner?.dismissible ? banner.key : null;
267
268
  {navigation.tabs.map((tab) => (
268
269
  <li>
269
270
  <a
270
- aria-current={
271
- route === tab.path ||
272
- (tab.path !== "/" && isUnderPath(route, tab.path))
273
- ? "page"
274
- : undefined
275
- }
271
+ aria-current={tab === activeTab ? "page" : undefined}
276
272
  class="block rounded-[0.65rem] px-2.5 py-1.5 font-medium text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground aria-[current=page]:bg-muted aria-[current=page]:text-foreground"
277
- href={withBase(tab.path)}
273
+ href={withBase(tab.href ?? tab.path)}
278
274
  >
279
275
  {tab.label}
280
276
  </a>