blume 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,131 @@
1
- import type { BlumeConfig, ResolvedConfig } from "./schema.ts";
1
+ import type { BlumeConfig } from "./config-input.ts";
2
+ import type { ResolvedConfig } from "./schema.ts";
2
3
  import type { Diagnostic } from "./types.ts";
3
4
  /**
4
- * Identity helper for authoring `blume.config.ts`. Exists for type inference
5
- * and a stable future home for plugin hooks; it does not transform input.
5
+ * Define a Blume site's configuration with full type-checking and editor
6
+ * autocomplete. Place the call in `blume.config.ts` at your project root and
7
+ * `export default` the result:
8
+ *
9
+ * ```ts
10
+ * import { defineConfig } from "blume";
11
+ *
12
+ * export default defineConfig({
13
+ * title: "Acme Docs",
14
+ * description: "Everything you need to build with Acme.",
15
+ * });
16
+ * ```
17
+ *
18
+ * Every field is optional — an empty `defineConfig({})` produces a working
19
+ * site from the Markdown/MDX in your `docs/` directory. Configure only what you
20
+ * want to change; sensible defaults fill in the rest.
21
+ *
22
+ * This is an identity helper: it returns its input unchanged and exists purely
23
+ * for type inference (and as a stable home for future plugin hooks). The object
24
+ * is validated against the Blume schema when the CLI loads it.
25
+ *
26
+ * ## Top-level fields
27
+ *
28
+ * **Site identity**
29
+ * - `title` — site title, shown in the header, `<title>`, and OG images.
30
+ * Defaults to `"Documentation"`.
31
+ * - `description` — default meta description, used where a page sets none.
32
+ * - `logo` — brand mark. A string is an image path/URL; the object form splits
33
+ * an `image` mark from wordmark `text` and can override the brand `href`.
34
+ * - `banner` — site-wide announcement bar; a string, or `{ content, link,
35
+ * dismissible }`.
36
+ *
37
+ * **Content & navigation**
38
+ * - `content` — where content lives (`root`, defaults to `docs`) and pluggable
39
+ * `sources` (filesystem, remote MDX, GitHub Releases, Sanity, Notion, or a
40
+ * custom `ContentSource`). Omit `sources` and the top-level `root` becomes one
41
+ * implicit filesystem source.
42
+ * - `navigation` — sidebar, header `tabs`, `selectors` (version/language/product
43
+ * switchers), pinned `featured` links, and the `repo` link toggle. Omit
44
+ * `sidebar` to generate it from the content tree.
45
+ * - `redirects` — `{ from, to, status }` rules (301 by default).
46
+ * - `github` — `{ owner, repo, branch, dir }`, powering "Edit this page" links
47
+ * and the header repo link.
48
+ *
49
+ * **Appearance**
50
+ * - `theme` — `accent` color, `fonts` (curated Google Font slugs), `radius`,
51
+ * `mode` (`system`/`light`/`dark`), `background`, and `strict` token mode.
52
+ * - `markdown` — `code` (language icons, inline highlighting, line wrap),
53
+ * `headingAnchors`, `imageZoom`, and opt-in KaTeX `math`.
54
+ * - `toc` — on-page table of contents; `true`/`false` or a heading-level range.
55
+ * - `lastModified` — "Last updated" stamps from `git` history or frontmatter.
56
+ * - `feedback` — the per-page "Was this helpful?" widget (on by default).
57
+ * - `export` — reader-facing PDF/EPUB export actions (off by default).
58
+ *
59
+ * **Reference docs**
60
+ * - `openapi` — native OpenAPI reference: one real page per operation, woven
61
+ * into the sidebar and search. Point `sources`/`spec` at your spec.
62
+ * - `asyncapi` — AsyncAPI reference via the embedded Scalar renderer.
63
+ *
64
+ * **Search & AI**
65
+ * - `search` — search backend `provider` (`orama` by default; `pagefind`,
66
+ * `algolia`, `typesense`, `orama-cloud`, `mixedbread`, or `none`) plus its
67
+ * credential block.
68
+ * - `ai` — `ask` (the Ask AI chat endpoint and its provider/model) and `llmsTxt`
69
+ * (emit `llms.txt`).
70
+ * - `mcp` — expose the docs as an MCP server for connecting agents.
71
+ *
72
+ * **SEO, feeds & analytics**
73
+ * - `seo` — `og` images, `sitemap`, `robots`, `rss` feeds, `structuredData`
74
+ * JSON-LD, `agentReadability`, and robots `contentSignals`.
75
+ * - `analytics` — PostHog, Vercel, or arbitrary `scripts` (Plausible, Fathom,
76
+ * GA, …).
77
+ *
78
+ * **Deployment & i18n**
79
+ * - `deployment` — `site` URL (needed for absolute links, sitemaps, and OG),
80
+ * `adapter` (`vercel`/`node`/`netlify`/`cloudflare`), `output`
81
+ * (`static`/`server`), and `base` path. Auto-detected on Vercel/Netlify/
82
+ * Cloudflare from the platform env.
83
+ * - `i18n` — opt-in multi-locale: `locales`, `defaultLocale`, `parser`
84
+ * (`dir` vs filename `dot` suffix), and per-locale UI overrides.
85
+ *
86
+ * - `examples` — where `<Component path>` previews resolve their source from
87
+ * (defaults to `examples/`; supports a glob for colocated registries).
88
+ *
89
+ * @example Zero-config — just render the Markdown under `docs/`.
90
+ * ```ts
91
+ * export default defineConfig({});
92
+ * ```
93
+ *
94
+ * @example A production docs site with theming, search, and deployment.
95
+ * ```ts
96
+ * export default defineConfig({
97
+ * title: "Acme Docs",
98
+ * description: "Build faster with Acme.",
99
+ * logo: { image: "/logo.svg", text: "Acme" },
100
+ * github: { owner: "acme", repo: "acme" },
101
+ * theme: { accent: "violet", fonts: { body: "inter" }, radius: "lg" },
102
+ * navigation: {
103
+ * tabs: [
104
+ * { label: "Guides", path: "/guides" },
105
+ * { label: "API", path: "/api" },
106
+ * ],
107
+ * },
108
+ * search: { provider: "orama" },
109
+ * deployment: { site: "https://docs.acme.com", adapter: "vercel" },
110
+ * });
111
+ * ```
112
+ *
113
+ * @example An OpenAPI reference with the Ask AI assistant enabled.
114
+ * ```ts
115
+ * export default defineConfig({
116
+ * title: "Acme API",
117
+ * openapi: {
118
+ * enabled: true,
119
+ * route: "/reference",
120
+ * sources: [{ label: "Core", spec: "./openapi.json" }],
121
+ * },
122
+ * ai: { ask: { enabled: true }, llmsTxt: true },
123
+ * });
124
+ * ```
125
+ *
126
+ * @param config - The site configuration. All fields are optional.
127
+ * @returns The same config object, typed for inference.
128
+ * @see https://useblume.dev/docs for the full configuration reference.
6
129
  */
7
130
  export declare const defineConfig: (config: BlumeConfig) => BlumeConfig;
8
131
  /** Result of loading + validating a project config. */
@@ -1708,12 +1708,6 @@ export declare const blumeConfigSchema: z.ZodObject<{
1708
1708
  * …). On by default; recognized languages only.
1709
1709
  */
1710
1710
  icons: z.ZodDefault<z.ZodBoolean>;
1711
- /**
1712
- * Syntax-highlight inline `` `code{:lang}` `` snippets. Off by default — most
1713
- * inline code (flags, file names) reads better plain; opt a snippet in with
1714
- * a trailing `{:lang}` marker.
1715
- */
1716
- inline: z.ZodDefault<z.ZodBoolean>;
1717
1711
  /**
1718
1712
  * Wrap long lines instead of scrolling horizontally. Off by default, so
1719
1713
  * code keeps its original line breaks and overflows into a scroll area.
@@ -1721,11 +1715,9 @@ export declare const blumeConfigSchema: z.ZodObject<{
1721
1715
  wrap: z.ZodDefault<z.ZodBoolean>;
1722
1716
  }, "strict", z.ZodTypeAny, {
1723
1717
  icons: boolean;
1724
- inline: boolean;
1725
1718
  wrap: boolean;
1726
1719
  }, {
1727
1720
  icons?: boolean | undefined;
1728
- inline?: boolean | undefined;
1729
1721
  wrap?: boolean | undefined;
1730
1722
  }>>;
1731
1723
  codeBlocks: z.ZodDefault<z.ZodObject<{
@@ -1761,15 +1753,9 @@ export declare const blumeConfigSchema: z.ZodObject<{
1761
1753
  * opt a single image out with `data-no-zoom`.
1762
1754
  */
1763
1755
  imageZoom: z.ZodDefault<z.ZodBoolean>;
1764
- /**
1765
- * Enable LaTeX math (`$…$` inline, `$$…$$` block) rendered with KaTeX.
1766
- * Off by default since `$` is common in prose, shell, and code. MDX only.
1767
- */
1768
- math: z.ZodDefault<z.ZodBoolean>;
1769
1756
  }, "strict", z.ZodTypeAny, {
1770
1757
  code: {
1771
1758
  icons: boolean;
1772
- inline: boolean;
1773
1759
  wrap: boolean;
1774
1760
  };
1775
1761
  codeBlocks: {
@@ -1780,11 +1766,9 @@ export declare const blumeConfigSchema: z.ZodObject<{
1780
1766
  };
1781
1767
  headingAnchors: boolean;
1782
1768
  imageZoom: boolean;
1783
- math: boolean;
1784
1769
  }, {
1785
1770
  code?: {
1786
1771
  icons?: boolean | undefined;
1787
- inline?: boolean | undefined;
1788
1772
  wrap?: boolean | undefined;
1789
1773
  } | undefined;
1790
1774
  codeBlocks?: {
@@ -1795,7 +1779,6 @@ export declare const blumeConfigSchema: z.ZodObject<{
1795
1779
  } | undefined;
1796
1780
  headingAnchors?: boolean | undefined;
1797
1781
  imageZoom?: boolean | undefined;
1798
- math?: boolean | undefined;
1799
1782
  }>>;
1800
1783
  mcp: z.ZodDefault<z.ZodObject<{
1801
1784
  enabled: z.ZodDefault<z.ZodBoolean>;
@@ -2664,7 +2647,6 @@ export declare const blumeConfigSchema: z.ZodObject<{
2664
2647
  markdown: {
2665
2648
  code: {
2666
2649
  icons: boolean;
2667
- inline: boolean;
2668
2650
  wrap: boolean;
2669
2651
  };
2670
2652
  codeBlocks: {
@@ -2675,7 +2657,6 @@ export declare const blumeConfigSchema: z.ZodObject<{
2675
2657
  };
2676
2658
  headingAnchors: boolean;
2677
2659
  imageZoom: boolean;
2678
- math: boolean;
2679
2660
  };
2680
2661
  mcp: {
2681
2662
  enabled: boolean;
@@ -3022,7 +3003,6 @@ export declare const blumeConfigSchema: z.ZodObject<{
3022
3003
  markdown?: {
3023
3004
  code?: {
3024
3005
  icons?: boolean | undefined;
3025
- inline?: boolean | undefined;
3026
3006
  wrap?: boolean | undefined;
3027
3007
  } | undefined;
3028
3008
  codeBlocks?: {
@@ -3033,7 +3013,6 @@ export declare const blumeConfigSchema: z.ZodObject<{
3033
3013
  } | undefined;
3034
3014
  headingAnchors?: boolean | undefined;
3035
3015
  imageZoom?: boolean | undefined;
3036
- math?: boolean | undefined;
3037
3016
  } | undefined;
3038
3017
  mcp?: {
3039
3018
  name?: string | undefined;
@@ -3102,8 +3081,12 @@ export type ResolvedConfig = z.infer<typeof blumeConfigSchema>;
3102
3081
  export type ResolvedI18nConfig = z.infer<typeof i18nConfigSchema>;
3103
3082
  /** A configured locale with display metadata. */
3104
3083
  export type LocaleConfig = z.infer<typeof localeSchema>;
3105
- /** User-authored config: the shape accepted by `defineConfig`. */
3106
- export type BlumeConfig = z.input<typeof blumeConfigSchema>;
3084
+ /**
3085
+ * User-authored config, straight off the schema. The public, hand-documented
3086
+ * authoring type is `BlumeConfig` in `./config-input.ts`, which a compile-time
3087
+ * guard keeps structurally identical to this.
3088
+ */
3089
+ export type BlumeConfigInput = z.input<typeof blumeConfigSchema>;
3107
3090
  /** A configured search backend. */
3108
3091
  export type SearchProvider = (typeof searchProviders)[number];
3109
3092
  /** Resolved robots.txt `Content-Signal` preferences (`null` when disabled). */
@@ -5,6 +5,7 @@ export type { ComponentOverride, ComponentOverrides, IslandDescriptor, } from ".
5
5
  export { defineMeta } from "./core/define-meta.ts";
6
6
  export type { FolderMetaDefinition, FolderMetaFactory, } from "./core/define-meta.ts";
7
7
  export type { UIStrings } from "./core/i18n-ui.ts";
8
- export type { BlumeConfig, FolderMeta, HydrationMode, ResolvedConfig, } from "./core/schema.ts";
8
+ export type { BlumeConfig } from "./core/config-input.ts";
9
+ export type { FolderMeta, HydrationMode, ResolvedConfig, } from "./core/schema.ts";
9
10
  export type { Diagnostic, Heading, NavNode, Navigation, NavTab, PageRecord, } from "./core/types.ts";
10
11
  export { getBlumeVersion } from "./core/version.ts";
@@ -53,14 +53,22 @@ The optional `changelog` object adds richer metadata for the timeline and feed:
53
53
 
54
54
  ## The timeline page
55
55
 
56
- Once you have at least one `type: changelog` entry, Blume generates a **`/changelog`** page automatically. Each entry renders newest-first with its date, label, and `category` tag in a left rail beside its content:
56
+ Once you have at least one `type: changelog` entry, Blume generates a **`/changelog`** page automatically. It renders as a focused, full-width timeline — no sidebar or table of contents — with each entry newest-first, showing its date, label, and `category` tag in a left rail beside its content:
57
57
 
58
- - The entry **title** becomes its label — or `v{version}` when there's no title.
58
+ - The entry **title** becomes its label — or `v{version}` when there's no title. It links to that entry's own page, so a release is both a line in the timeline and a shareable permalink.
59
59
  - The `category` renders as a tag next to the date.
60
60
  - Drafts and `sidebar.hidden` entries are skipped.
61
61
 
62
62
  The page appears only when nothing already occupies the `/changelog` route. To replace it with your own design, add a [custom page](/docs/advanced/custom-pages) at `pages/changelog.astro` — it takes over and Blume stops generating the default timeline.
63
63
 
64
+ ### Grouped by major version
65
+
66
+ When your versions follow [semver](https://semver.org) and span more than one major, Blume paginates the timeline by major version. Only the newest major line is shown, with a **Show N.x releases** button at the bottom that reveals the next-oldest major one click at a time:
67
+
68
+ - Detection is automatic — no configuration. It kicks in only when every listed release parses as `major.minor.patch` and there is more than one major; otherwise the timeline stays flat.
69
+ - It tolerates the scoped tags monorepos publish, so `pkg@2.0.0` groups under `2.x` and `pkg@1.4.0` under `1.x`.
70
+ - It's progressive enhancement: every release is still in the page's HTML (and its RSS feed and search index), so readers without JavaScript — and crawlers — see the complete history. The button only collapses older majors once the page hydrates.
71
+
64
72
  ## From GitHub Releases
65
73
 
66
74
  Rather than authoring entries by hand, point the built-in [`github-releases` source](/docs/content/sources#github-releases) at a repo and every release becomes a `type: changelog` entry — the same timeline and feed, fed straight from the releases you already publish:
@@ -49,11 +49,9 @@ export default defineConfig({
49
49
  // Markdown features
50
50
  markdown: {
51
51
  imageZoom: true,
52
- math: false,
53
52
  code: {
54
53
  icons: true, // language icon in the code-block header
55
54
  wrap: false, // wrap long lines instead of scrolling
56
- inline: false, // highlight inline `code{:lang}` snippets
57
55
  },
58
56
  },
59
57
 
@@ -139,7 +139,7 @@ export default defineConfig({
139
139
  ```
140
140
  ````
141
141
 
142
- Inline code can be highlighted too: add a `{:lang}` marker inside a backtick span and it's colored like a tiny code block — `useState(){:js}` or `T extends object{:ts}`. Turn it on with `markdown: { code: { inline: true } }`.
142
+ Inline code can be highlighted too: add a `{:lang}` marker inside a backtick span and it's colored like a tiny code block — `useState(){:js}` or `T extends object{:ts}`. It only kicks in when you add the marker, so plain inline code stays untouched — nothing to switch on.
143
143
 
144
144
  ### Line numbers
145
145
 
@@ -372,24 +372,20 @@ The names `caution`, `error`, `important`, and `warn` are accepted as aliases fo
372
372
 
373
373
  ## Math
374
374
 
375
- Render LaTeX with KaTeX for formulas in prose or as centered blocks — useful for math-heavy or scientific docs. Inline math goes in `$…$`; block math in `$$…$$`.
376
-
377
- The Pythagorean theorem is $a^2 + b^2 = c^2$.
375
+ Render LaTeX with KaTeX as centered blocks — useful for math-heavy or scientific docs. Wrap a formula in `$$…$$`:
378
376
 
379
377
  $$
380
378
  \int_0^\infty e^{-x^2}\,dx = \frac{\sqrt{\pi}}{2}
381
379
  $$
382
380
 
383
381
  ```md
384
- The Pythagorean theorem is $a^2 + b^2 = c^2$.
385
-
386
382
  $$
387
- \int_0^\infty e^{-x^2}\,dx = \frac{\sqrt{\pi}}{2}
383
+ a^2 + b^2 = c^2
388
384
  $$
389
385
  ```
390
386
 
391
387
  :::note
392
- Math is opt-in because `$` is common in prose and code. Enable it with `markdown: { math: true }` in `blume.config.ts`.
388
+ Math is block-only and on automatically write `$$…$$` and it renders; write none and KaTeX's stylesheet never ships. There's no inline `$…$` math: a lone `$` (currency, shell variables, code) is always left as literal text, so there's no delimiter to escape and no setting to toggle. Math is an MDX-only feature.
393
389
  :::
394
390
 
395
391
  ## Smart punctuation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blume",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Documentation that's fast, AI-ready, and zero-config.",
5
5
  "keywords": [
6
6
  "astro",
@@ -330,6 +330,26 @@ export const detectNeedsReact = async (root: string): Promise<boolean> => {
330
330
  return matches.length > 0;
331
331
  };
332
332
 
333
+ /**
334
+ * Detect whether the project authors block math (`$$…$$`) in any `.mdx`. Drives
335
+ * whether the generated runtime imports the `<Math>` component and KaTeX's
336
+ * stylesheet, so a math-free site ships no KaTeX CSS. Math parsing itself is
337
+ * always on but block-only, so a literal `$$` in source is a necessary
338
+ * condition — no false negatives. A stray `$$` (e.g. inside a code fence) merely
339
+ * over-includes the idempotent import, which is harmless.
340
+ */
341
+ export const detectUsesMath = async (root: string): Promise<boolean> => {
342
+ const files = await glob(["**/*.mdx"], {
343
+ cwd: root,
344
+ ignore: ["**/node_modules/**", "**/.blume/**", "**/dist/**"],
345
+ onlyFiles: true,
346
+ });
347
+ const contents = await Promise.all(
348
+ files.map((file) => readOptional(join(root, file)))
349
+ );
350
+ return contents.some((content) => content.includes("$$"));
351
+ };
352
+
333
353
  const writeIfChanged = async (
334
354
  path: string,
335
355
  content: string
@@ -933,16 +953,21 @@ export const generateRuntime = async (
933
953
  const askEnabled = config.ai.ask?.enabled ?? false;
934
954
  const exportPdf = config.export.pdf;
935
955
  const exportEpub = config.export.epub;
936
- const [pages, detectedReact, userTheme, islandDiscovery, exampleDiscovery] =
937
- await Promise.all([
938
- context.pagesRoot
939
- ? discoverPages(context.pagesRoot)
940
- : Promise.resolve([]),
941
- detectNeedsReact(context.root),
942
- readOptional(context.themeFile),
943
- discoverIslands(context.root),
944
- discoverExamples(context.root, config.examples),
945
- ]);
956
+ const [
957
+ pages,
958
+ detectedReact,
959
+ usesMath,
960
+ userTheme,
961
+ islandDiscovery,
962
+ exampleDiscovery,
963
+ ] = await Promise.all([
964
+ context.pagesRoot ? discoverPages(context.pagesRoot) : Promise.resolve([]),
965
+ detectNeedsReact(context.root),
966
+ detectUsesMath(context.root),
967
+ readOptional(context.themeFile),
968
+ discoverIslands(context.root),
969
+ discoverExamples(context.root, config.examples),
970
+ ]);
946
971
  // Statically analyze `components.ts` overrides (never executed): drives the
947
972
  // `islands` group, hydration on layout/mdx overrides, string-path resolution,
948
973
  // and the "framework component with no client mode" diagnostic.
@@ -1028,7 +1053,7 @@ export const generateRuntime = async (
1028
1053
  askEnabled,
1029
1054
  exportEpub,
1030
1055
  exportPdf,
1031
- mathEnabled: config.markdown.math,
1056
+ mathEnabled: usesMath,
1032
1057
  needsReact,
1033
1058
  })
1034
1059
  ),
@@ -300,8 +300,6 @@ export const astroConfigTemplate = (options: {
300
300
  const integrations = [
301
301
  `mdx({ processor: blumeMdxProcessor(${JSON.stringify({
302
302
  headingAnchors: config.markdown.headingAnchors,
303
- inline: config.markdown.code.inline,
304
- math: config.markdown.math,
305
303
  })}) })`,
306
304
  ];
307
305
  if (needsReact) {
@@ -335,7 +333,6 @@ export default defineConfig({
335
333
  markdown: {
336
334
  processor: blumeMarkdownProcessor(${JSON.stringify({
337
335
  headingAnchors: config.markdown.headingAnchors,
338
- inline: config.markdown.code.inline,
339
336
  })}),
340
337
  shikiConfig: {
341
338
  themes: {
@@ -1278,6 +1275,20 @@ const slugify = (text) =>
1278
1275
  text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") ||
1279
1276
  "update";
1280
1277
 
1278
+ // The major of a version's embedded semver (\`1.2.3\` -> 1, \`pkg@2.0.0\` -> 2), or
1279
+ // null when there is no full major.minor.patch to key on. Drives the changelog's
1280
+ // group-by-major pagination, so it tolerates the scoped tags monorepos publish.
1281
+ const majorVersion = (version) => {
1282
+ const match = /(\\d+)\\.\\d+\\.\\d+/.exec(String(version ?? ""));
1283
+ return match ? Number(match[1]) : null;
1284
+ };
1285
+
1286
+ // Map each entry to its own generated page so the timeline heading can deep-link
1287
+ // to it. The collection entry id matches the route manifest's \`entryId\`.
1288
+ const routeByEntry = new Map(
1289
+ data.routes.map((route) => [route.entryId, route.path])
1290
+ );
1291
+
1281
1292
  const changelogEntries = [
1282
1293
  ...(await getCollection("docs")),${stagedSpread}
1283
1294
  ]
@@ -1299,8 +1310,10 @@ const items = await Promise.all(
1299
1310
  return {
1300
1311
  Content: (await render(entry)).Content,
1301
1312
  date: formatDate(entryDate(entry)),
1313
+ href: routeByEntry.get(entry.id) ?? null,
1302
1314
  id: slugify(label),
1303
1315
  label,
1316
+ major: majorVersion(entry.data.changelog?.version),
1304
1317
  tags: entry.data.changelog?.category
1305
1318
  ? [entry.data.changelog.category]
1306
1319
  : [],
@@ -1308,6 +1321,19 @@ const items = await Promise.all(
1308
1321
  })
1309
1322
  );
1310
1323
 
1324
+ // A changelog is semver-paginated only when every visible release parses as
1325
+ // semver and they span more than one major line. Older majors then collapse
1326
+ // into groups the reader reveals one at a time; otherwise the timeline is flat.
1327
+ const majors = items.every((item) => item.major !== null)
1328
+ ? [...new Set(items.map((item) => item.major))].toSorted((a, b) => b - a)
1329
+ : [];
1330
+ const paginate = majors.length > 1;
1331
+ const majorGroups = majors.map((major) => ({
1332
+ items: items.filter((item) => item.major === major),
1333
+ label: major + ".x",
1334
+ major,
1335
+ }));
1336
+
1311
1337
  const headings = items.map((item) => ({
1312
1338
  depth: 2,
1313
1339
  slug: item.id,
@@ -1339,6 +1365,7 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1339
1365
  }}
1340
1366
  headings={headings}
1341
1367
  toc={data.config.toc}
1368
+ contentLayout="bare"
1342
1369
  themeMode={data.config.theme.mode}
1343
1370
  fontCssVars={data.fontCssVars}
1344
1371
  searchEnabled={data.config.search.enabled}
@@ -1357,16 +1384,50 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1357
1384
  {
1358
1385
  items.length === 0 ? (
1359
1386
  <p>No changelog entries yet.</p>
1387
+ ) : paginate ? (
1388
+ <blume-changelog class="not-prose mt-8 block">
1389
+ {majorGroups[0].items.map(({ Content, href, id, label, date, tags }) => (
1390
+ <Update description={date} href={href} id={id} label={label} tags={tags}>
1391
+ <Content />
1392
+ </Update>
1393
+ ))}
1394
+ {majorGroups.slice(1).map((group) => (
1395
+ <section
1396
+ aria-label={group.label + " releases"}
1397
+ data-changelog-label={group.label}
1398
+ data-changelog-major={group.major}
1399
+ >
1400
+ {group.items.map(({ Content, href, id, label, date, tags }) => (
1401
+ <Update description={date} href={href} id={id} label={label} tags={tags}>
1402
+ <Content />
1403
+ </Update>
1404
+ ))}
1405
+ </section>
1406
+ ))}
1407
+ <div class="mt-10 flex justify-center">
1408
+ <button
1409
+ class="inline-flex items-center gap-2 rounded-full border border-border bg-background px-4 py-2 font-medium text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground"
1410
+ data-changelog-more
1411
+ hidden
1412
+ type="button"
1413
+ >
1414
+ Show older releases
1415
+ </button>
1416
+ </div>
1417
+ </blume-changelog>
1360
1418
  ) : (
1361
1419
  <div class="not-prose mt-8">
1362
- {items.map(({ Content, id, label, date, tags }) => (
1363
- <Update description={date} id={id} label={label} tags={tags}>
1420
+ {items.map(({ Content, href, id, label, date, tags }) => (
1421
+ <Update description={date} href={href} id={id} label={label} tags={tags}>
1364
1422
  <Content />
1365
1423
  </Update>
1366
1424
  ))}
1367
1425
  </div>
1368
1426
  )
1369
1427
  }
1428
+ <script>
1429
+ import "blume/components/content/changelog-element.ts";
1430
+ </script>
1370
1431
  </LayoutComponent>
1371
1432
  `;
1372
1433
  };
@@ -11,6 +11,10 @@ import { ensureGitignore } from "../../core/gitignore.ts";
11
11
  import type { BlumeProject } from "../../core/project-graph.ts";
12
12
  import type { ResolvedConfig } from "../../core/schema.ts";
13
13
  import { serverFeatures } from "../../core/server-features.ts";
14
+ import {
15
+ deployStaticDir,
16
+ surfaceAdapterOutput,
17
+ } from "../../deploy/adapter-output.ts";
14
18
  import {
15
19
  buildNetlifyRedirects,
16
20
  buildRedirectManifest,
@@ -365,6 +369,27 @@ export const buildCommand = defineCommand({
365
369
  return;
366
370
  }
367
371
 
368
- await publishBuildArtifacts(project, distDir, args);
372
+ // A server adapter (Vercel/Netlify) writes its deploy bundle relative to the
373
+ // Astro root — which Blume points at the hidden `.blume` runtime — so the
374
+ // bundle lands where the deploy platform never looks. Surface it up to the
375
+ // project root before publishing artifacts into the served static dir.
376
+ const surfaced = await surfaceAdapterOutput(
377
+ project.config,
378
+ project.context
379
+ );
380
+ if (surfaced.moved) {
381
+ logger.success(
382
+ `Surfaced ${project.config.deployment.adapter} output to ${surfaced.to}`
383
+ );
384
+ // The surfaced bundle is a build artifact — keep it out of version control
385
+ // (Vercel's own CLI ignores `.vercel/` for the same reason).
386
+ await ensureGitignore(root, [surfaced.ignore]);
387
+ }
388
+
389
+ await publishBuildArtifacts(
390
+ project,
391
+ deployStaticDir(project.config, project.context),
392
+ args
393
+ );
369
394
  },
370
395
  });
@@ -6,6 +6,8 @@ interface RssMetadata {
6
6
 
7
7
  interface Props {
8
8
  description?: string;
9
+ /** Link the heading to a dedicated page; falls back to the in-page anchor. */
10
+ href?: string;
9
11
  id?: string;
10
12
  label?: string;
11
13
  rss?: RssMetadata;
@@ -13,7 +15,15 @@ interface Props {
13
15
  title?: string;
14
16
  }
15
17
 
16
- const { description, id: providedId, label, rss, tags, title } = Astro.props;
18
+ const {
19
+ description,
20
+ href,
21
+ id: providedId,
22
+ label,
23
+ rss,
24
+ tags,
25
+ title,
26
+ } = Astro.props;
17
27
  const slugify = (text: string): string =>
18
28
  text
19
29
  .toLowerCase()
@@ -37,7 +47,7 @@ const tagList = Array.isArray(tags) ? tags : tags ? [tags] : [];
37
47
  <header class="md:border-border md:border-e md:pe-4">
38
48
  <a
39
49
  class="font-semibold text-foreground text-sm no-underline hover:text-accent"
40
- href={`#${id}`}
50
+ href={href ?? `#${id}`}
41
51
  >
42
52
  {updateLabel}
43
53
  </a>
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Client behaviour for the `<blume-changelog>` custom element wrapping the
3
+ * generated changelog timeline when its releases are semver-versioned. The
4
+ * newest major line stays visible; every older major is collapsed into a group
5
+ * revealed one major at a time by the "Show N.x releases" button at the bottom.
6
+ *
7
+ * Pure progressive enhancement: the server renders every release in document
8
+ * order, and this element hides the older-major groups on connect — so a no-JS
9
+ * visitor (or a crawler) still sees the complete history, and the button does
10
+ * nothing until the script upgrades it.
11
+ *
12
+ * Imported for its side effect (registers the element) from the changelog page.
13
+ */
14
+
15
+ class BlumeChangelog extends HTMLElement {
16
+ connectedCallback() {
17
+ const groups = [
18
+ ...this.querySelectorAll<HTMLElement>("[data-changelog-major]"),
19
+ ];
20
+ const button = this.querySelector<HTMLButtonElement>(
21
+ "[data-changelog-more]"
22
+ );
23
+ if (groups.length === 0 || !button) {
24
+ return;
25
+ }
26
+
27
+ for (const group of groups) {
28
+ group.hidden = true;
29
+ // Focusable only programmatically, so revealing a group can move focus to
30
+ // it for keyboard and screen-reader users without adding a tab stop.
31
+ group.tabIndex = -1;
32
+ }
33
+
34
+ let revealed = 0;
35
+ const sync = () => {
36
+ const next = groups[revealed];
37
+ if (next) {
38
+ button.textContent = `Show ${next.dataset.changelogLabel} releases`;
39
+ button.hidden = false;
40
+ } else {
41
+ button.hidden = true;
42
+ }
43
+ };
44
+
45
+ button.addEventListener("click", () => {
46
+ const next = groups[revealed];
47
+ if (!next) {
48
+ return;
49
+ }
50
+ next.hidden = false;
51
+ revealed += 1;
52
+ sync();
53
+ next.focus();
54
+ });
55
+
56
+ sync();
57
+ }
58
+ }
59
+
60
+ if (!customElements.get("blume-changelog")) {
61
+ customElements.define("blume-changelog", BlumeChangelog);
62
+ }