blume 0.6.0 → 0.6.2

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 (65) hide show
  1. package/dist/cli/index.js +6070 -5718
  2. package/dist/cli/index.js.map +41 -40
  3. package/dist/types/core/config-input.d.ts +749 -0
  4. package/dist/types/core/config.d.ts +126 -3
  5. package/dist/types/core/schema.d.ts +10 -27
  6. package/dist/types/core/sources/types.d.ts +6 -0
  7. package/dist/types/index.d.ts +2 -1
  8. package/docs/advanced/changelog.mdx +10 -2
  9. package/docs/configuration/index.mdx +0 -2
  10. package/docs/content/syntax.mdx +4 -8
  11. package/package.json +1 -1
  12. package/src/astro/generate.ts +59 -24
  13. package/src/astro/markdown-negotiation.ts +12 -3
  14. package/src/astro/templates.ts +94 -12
  15. package/src/cli/commands/build.ts +26 -1
  16. package/src/cli/commands/dev.ts +30 -14
  17. package/src/cli/commands/doctor.ts +35 -7
  18. package/src/cli/commands/sync.ts +14 -2
  19. package/src/cli/dev-lock.ts +40 -10
  20. package/src/cli/env.ts +5 -1
  21. package/src/components/content/Update.astro +12 -2
  22. package/src/components/content/changelog-element.ts +62 -0
  23. package/src/components/islands/ask-ai.tsx +3 -1
  24. package/src/components/islands/hooks.ts +5 -1
  25. package/src/components/layout/Header.astro +10 -2
  26. package/src/components/layout/NavSelector.astro +5 -3
  27. package/src/components/layout/PageLayout.astro +2 -1
  28. package/src/components/layout/ReferenceLayout.astro +1 -0
  29. package/src/components/layout/RootLayout.astro +47 -10
  30. package/src/components/layout/Search.astro +8 -3
  31. package/src/components/layout/nav-utils.ts +7 -3
  32. package/src/core/config-input.ts +923 -0
  33. package/src/core/config.ts +126 -3
  34. package/src/core/i18n.ts +6 -5
  35. package/src/core/links.ts +16 -1
  36. package/src/core/meta.ts +112 -52
  37. package/src/core/navigation.ts +15 -5
  38. package/src/core/project-graph.ts +68 -2
  39. package/src/core/schema.ts +8 -14
  40. package/src/core/sources/assets.ts +21 -5
  41. package/src/core/sources/cache.ts +19 -1
  42. package/src/core/sources/github-releases.ts +9 -3
  43. package/src/core/sources/mdx-remote.ts +14 -4
  44. package/src/core/sources/normalize.ts +13 -1
  45. package/src/core/sources/notion.ts +43 -7
  46. package/src/core/sources/resolve.ts +44 -1
  47. package/src/core/sources/sanity.ts +9 -3
  48. package/src/core/sources/types.ts +6 -0
  49. package/src/deploy/adapter-output.ts +82 -0
  50. package/src/deploy/rss.ts +3 -1
  51. package/src/index.ts +1 -1
  52. package/src/markdown/code-title.ts +11 -4
  53. package/src/markdown/index.ts +28 -30
  54. package/src/markdown/math.ts +3 -2
  55. package/src/markdown/package-commands.ts +13 -0
  56. package/src/og/card.ts +3 -1
  57. package/src/openapi/model.ts +2 -1
  58. package/src/openapi/parse.ts +9 -1
  59. package/src/openapi/references.ts +11 -1
  60. package/src/openapi/render-mdx.ts +30 -3
  61. package/src/openapi/source.ts +3 -1
  62. package/src/registry/eject.ts +21 -14
  63. package/src/search/documents.ts +4 -1
  64. package/src/theme/entry.ts +10 -3
  65. package/src/theme/icons.ts +7 -11
@@ -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: {
@@ -407,6 +404,13 @@ export const contentConfigTemplate = (options: {
407
404
  staged?: boolean;
408
405
  /** Base dir for the staged collection; defaults to `<outDir>/content`. */
409
406
  stagedBase?: string;
407
+ /**
408
+ * The `docs` collection's base + include/exclude globs. Defaults to
409
+ * `content.root` and the top-level content globs; a single filesystem source
410
+ * roots the collection at *its* root so entry ids resolve (see
411
+ * `resolveDocsCollection`).
412
+ */
413
+ collection?: { base: string; include: string[]; exclude: string[] };
410
414
  /**
411
415
  * Whether any filesystem (non-staged) source feeds the `docs` collection.
412
416
  * When false (e.g. an all-staged project where every page is materialized by
@@ -416,12 +420,15 @@ export const contentConfigTemplate = (options: {
416
420
  }): string => {
417
421
  const { context, config } = options;
418
422
  const stagedBase = options.stagedBase ?? stagedContentDir(context.outDir);
423
+ const collectionBase = options.collection?.base ?? context.contentRoot;
424
+ const includeGlobs = options.collection?.include ?? config.content.include;
425
+ const excludeGlobs = options.collection?.exclude ?? config.content.exclude;
419
426
 
420
427
  // Fold the content excludes into the glob as negative patterns so the `docs`
421
428
  // collection doesn't ingest ignored trees (`node_modules`, `snippets`, the
422
429
  // staged bodies under `.blume/content`, …) as entries. This matters when
423
- // `content.root` is the project root (a migrated `.`-rooted project).
424
- const outDirRel = relative(context.contentRoot, context.outDir);
430
+ // the collection base is the project root (a migrated `.`-rooted project).
431
+ const outDirRel = relative(collectionBase, context.outDir);
425
432
  const outDirIgnore =
426
433
  outDirRel && !outDirRel.startsWith("..") && !isAbsolute(outDirRel)
427
434
  ? [`!${outDirRel}/**`]
@@ -439,8 +446,8 @@ export const contentConfigTemplate = (options: {
439
446
  const filesystem = options.filesystem ?? true;
440
447
  const docsPattern = filesystem
441
448
  ? [
442
- ...config.content.include,
443
- ...(config.content.exclude ?? []).map((pattern) => `!${pattern}`),
449
+ ...includeGlobs,
450
+ ...(excludeGlobs ?? []).map((pattern) => `!${pattern}`),
444
451
  // Mirror the filesystem scan's baseline ignores (see BLUME_IGNORE_DIRS):
445
452
  // Astro's content layer roots at the project dir, so a `.`-wide content
446
453
  // root would otherwise re-ingest dependency trees and build output —
@@ -475,7 +482,7 @@ import { glob } from "astro/loaders";
475
482
  const docs = defineCollection({
476
483
  loader: glob({
477
484
  pattern: ${JSON.stringify(docsPattern)},
478
- base: ${JSON.stringify(context.contentRoot)},
485
+ base: ${JSON.stringify(collectionBase)},
479
486
  generateId: ({ entry }) => entry,
480
487
  }),
481
488
  });
@@ -1096,7 +1103,10 @@ const ogPath = data.config.og.enabled
1096
1103
  ? \`/og/\${route === "/" ? "index" : route.slice(1)}.png\`
1097
1104
  : null;
1098
1105
  const ogRel = seo.image ?? ogPath;
1099
- const ogImage = ogRel && base ? \`\${base}\${ogRel}\` : ogRel;
1106
+ // Only absolutize root-relative paths: \`seo.image\` may be an external URL,
1107
+ // which must pass through verbatim (mirrors PageLayout's absolutizeOgImage).
1108
+ const ogImage =
1109
+ ogRel && base && ogRel.startsWith("/") ? \`\${base}\${ogRel}\` : ogRel;
1100
1110
 
1101
1111
  const canonical =
1102
1112
  seo.canonical ?? (base ? \`\${base}\${route === "/" ? "" : route}\` : null);
@@ -1278,6 +1288,20 @@ const slugify = (text) =>
1278
1288
  text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") ||
1279
1289
  "update";
1280
1290
 
1291
+ // The major of a version's embedded semver (\`1.2.3\` -> 1, \`pkg@2.0.0\` -> 2), or
1292
+ // null when there is no full major.minor.patch to key on. Drives the changelog's
1293
+ // group-by-major pagination, so it tolerates the scoped tags monorepos publish.
1294
+ const majorVersion = (version) => {
1295
+ const match = /(\\d+)\\.\\d+\\.\\d+/.exec(String(version ?? ""));
1296
+ return match ? Number(match[1]) : null;
1297
+ };
1298
+
1299
+ // Map each entry to its own generated page so the timeline heading can deep-link
1300
+ // to it. The collection entry id matches the route manifest's \`entryId\`.
1301
+ const routeByEntry = new Map(
1302
+ data.routes.map((route) => [route.entryId, route.path])
1303
+ );
1304
+
1281
1305
  const changelogEntries = [
1282
1306
  ...(await getCollection("docs")),${stagedSpread}
1283
1307
  ]
@@ -1299,8 +1323,10 @@ const items = await Promise.all(
1299
1323
  return {
1300
1324
  Content: (await render(entry)).Content,
1301
1325
  date: formatDate(entryDate(entry)),
1326
+ href: routeByEntry.get(entry.id) ?? null,
1302
1327
  id: slugify(label),
1303
1328
  label,
1329
+ major: majorVersion(entry.data.changelog?.version),
1304
1330
  tags: entry.data.changelog?.category
1305
1331
  ? [entry.data.changelog.category]
1306
1332
  : [],
@@ -1308,6 +1334,19 @@ const items = await Promise.all(
1308
1334
  })
1309
1335
  );
1310
1336
 
1337
+ // A changelog is semver-paginated only when every visible release parses as
1338
+ // semver and they span more than one major line. Older majors then collapse
1339
+ // into groups the reader reveals one at a time; otherwise the timeline is flat.
1340
+ const majors = items.every((item) => item.major !== null)
1341
+ ? [...new Set(items.map((item) => item.major))].toSorted((a, b) => b - a)
1342
+ : [];
1343
+ const paginate = majors.length > 1;
1344
+ const majorGroups = majors.map((major) => ({
1345
+ items: items.filter((item) => item.major === major),
1346
+ label: major + ".x",
1347
+ major,
1348
+ }));
1349
+
1311
1350
  const headings = items.map((item) => ({
1312
1351
  depth: 2,
1313
1352
  slug: item.id,
@@ -1339,6 +1378,7 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1339
1378
  }}
1340
1379
  headings={headings}
1341
1380
  toc={data.config.toc}
1381
+ contentLayout="bare"
1342
1382
  themeMode={data.config.theme.mode}
1343
1383
  fontCssVars={data.fontCssVars}
1344
1384
  searchEnabled={data.config.search.enabled}
@@ -1357,16 +1397,50 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1357
1397
  {
1358
1398
  items.length === 0 ? (
1359
1399
  <p>No changelog entries yet.</p>
1400
+ ) : paginate ? (
1401
+ <blume-changelog class="not-prose mt-8 block">
1402
+ {majorGroups[0].items.map(({ Content, href, id, label, date, tags }) => (
1403
+ <Update description={date} href={href} id={id} label={label} tags={tags}>
1404
+ <Content />
1405
+ </Update>
1406
+ ))}
1407
+ {majorGroups.slice(1).map((group) => (
1408
+ <section
1409
+ aria-label={group.label + " releases"}
1410
+ data-changelog-label={group.label}
1411
+ data-changelog-major={group.major}
1412
+ >
1413
+ {group.items.map(({ Content, href, id, label, date, tags }) => (
1414
+ <Update description={date} href={href} id={id} label={label} tags={tags}>
1415
+ <Content />
1416
+ </Update>
1417
+ ))}
1418
+ </section>
1419
+ ))}
1420
+ <div class="mt-10 flex justify-center">
1421
+ <button
1422
+ 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"
1423
+ data-changelog-more
1424
+ hidden
1425
+ type="button"
1426
+ >
1427
+ Show older releases
1428
+ </button>
1429
+ </div>
1430
+ </blume-changelog>
1360
1431
  ) : (
1361
1432
  <div class="not-prose mt-8">
1362
- {items.map(({ Content, id, label, date, tags }) => (
1363
- <Update description={date} id={id} label={label} tags={tags}>
1433
+ {items.map(({ Content, href, id, label, date, tags }) => (
1434
+ <Update description={date} href={href} id={id} label={label} tags={tags}>
1364
1435
  <Content />
1365
1436
  </Update>
1366
1437
  ))}
1367
1438
  </div>
1368
1439
  )
1369
1440
  }
1441
+ <script>
1442
+ import "blume/components/content/changelog-element.ts";
1443
+ </script>
1370
1444
  </LayoutComponent>
1371
1445
  `;
1372
1446
  };
@@ -1477,8 +1551,16 @@ const exampleDirective = (spec: ExampleSpec): string => {
1477
1551
  };
1478
1552
 
1479
1553
  /** Filesystem-safe slug for an example's generated wrapper file. */
1554
+ /**
1555
+ * A filesystem-safe, injective token for an example path. Distinct paths must
1556
+ * never share a wrapper file (`button.demo` vs `button-demo` used to collide),
1557
+ * so every non-alphanumeric character is hex-escaped rather than collapsed.
1558
+ */
1480
1559
  export const exampleSlug = (path: string): string =>
1481
- path.replaceAll("/", "__").replaceAll(/[^a-zA-Z0-9_]+/gu, "-");
1560
+ path.replaceAll(
1561
+ /[^a-zA-Z0-9]/gu,
1562
+ (char) => `_${(char.codePointAt(0) ?? 0).toString(16)}_`
1563
+ );
1482
1564
 
1483
1565
  /**
1484
1566
  * Generate `.blume/src/generated/examples/<slug>.astro` — a wrapper that renders
@@ -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
  });
@@ -12,7 +12,7 @@ import { coalescedRunner } from "../coalesce.ts";
12
12
  import {
13
13
  acquireDevLock,
14
14
  describeDevLock,
15
- readDevLock,
15
+ DevLockHeldError,
16
16
  updateDevLockPort,
17
17
  } from "../dev-lock.ts";
18
18
  import { logger } from "../log.ts";
@@ -52,22 +52,27 @@ export const devCommand = defineCommand({
52
52
  // (OG images, canonicals, sitemap) work locally without configuring a site.
53
53
  const explicitPort = parsePort(args.port);
54
54
  const port = explicitPort ?? 4321;
55
- const devServerUrl = `http://localhost:${port}`;
55
+ let devServerUrl = `http://localhost:${port}`;
56
56
 
57
57
  // Claim the shared `.blume` dir BEFORE preparing: `prepareProject`
58
58
  // regenerates the runtime, so even a refused second dev server would
59
59
  // otherwise clobber the running one's generated tree (with this
60
- // invocation's port baked in) on its way out. Dev never relocates the
61
- // runtime dir, so the lock always lives at `<root>/.blume`.
60
+ // invocation's port baked in) on its way out. The claim is atomic, so two
61
+ // simultaneous starts can't both win. Dev never relocates the runtime dir,
62
+ // so the lock always lives at `<root>/.blume`.
62
63
  const outDir = resolveRuntimeDir(root);
63
- const running = readDevLock(outDir);
64
- if (running) {
65
- logger.error(
66
- `A \`blume dev\` server is already running${describeDevLock(running)} in this project. Reuse that server instead of starting a second one — two dev servers would corrupt the shared .blume dir. If it crashed, delete .blume/dev.lock.`
67
- );
68
- process.exit(1);
64
+ let releaseLock: () => void;
65
+ try {
66
+ releaseLock = acquireDevLock(outDir, port);
67
+ } catch (error) {
68
+ if (error instanceof DevLockHeldError) {
69
+ logger.error(
70
+ `A \`blume dev\` server is already running${describeDevLock(error.lock)} in this project. Reuse that server instead of starting a second one — two dev servers would corrupt the shared .blume dir. If it crashed, delete .blume/dev.lock.`
71
+ );
72
+ process.exit(1);
73
+ }
74
+ throw error;
69
75
  }
70
- const releaseLock = acquireDevLock(outDir, port);
71
76
  process.on("exit", releaseLock);
72
77
 
73
78
  const project = await prepareProject({
@@ -91,9 +96,13 @@ export const devCommand = defineCommand({
91
96
 
92
97
  // Vite bumps to the next free port when the default is taken, so record
93
98
  // the port the server actually bound — the lock's URL is what a refused
94
- // second invocation tells its caller to reuse.
95
- if (server.address.port !== port) {
96
- updateDevLockPort(outDir, server.address.port);
99
+ // second invocation tells its caller to reuse. The site fallback baked
100
+ // into the runtime also carries the port, so it must follow suit (below,
101
+ // once the regeneration closure exists).
102
+ const boundPort = server.address.port;
103
+ if (boundPort !== port) {
104
+ updateDevLockPort(outDir, boundPort);
105
+ devServerUrl = `http://localhost:${boundPort}`;
97
106
  }
98
107
 
99
108
  // Mirror any initial diagnostics into the browser overlay now the server
@@ -129,6 +138,13 @@ export const devCommand = defineCommand({
129
138
  timer = setTimeout(runRegenerate, 80);
130
139
  };
131
140
 
141
+ // The runtime prepared above baked the *requested* port into the site
142
+ // fallback; if Vite bumped it, regenerate so OG images, canonicals, and
143
+ // other site-gated URLs point at the port actually serving.
144
+ if (boundPort !== port) {
145
+ void runRegenerate();
146
+ }
147
+
132
148
  // Content is watched per source (filesystem uses fs.watch; remote sources
133
149
  // are frozen for the session). The remaining project inputs — user pages,
134
150
  // config, theme, and component overrides — are watched directly.
@@ -1,6 +1,10 @@
1
+ import { readFileSync } from "node:fs";
2
+
1
3
  import { defineCommand } from "citty";
4
+ import { join } from "pathe";
2
5
 
3
6
  import { BlumeError } from "../../core/diagnostics.ts";
7
+ import { packageRoot } from "../../core/package-root.ts";
4
8
  import { scanProject } from "../../core/project-graph.ts";
5
9
  import { serverFeatures } from "../../core/server-features.ts";
6
10
  import type { Diagnostic } from "../../core/types.ts";
@@ -12,7 +16,34 @@ import {
12
16
  reportDiagnosticsJson,
13
17
  } from "../log.ts";
14
18
 
15
- const MIN_NODE_MAJOR = 20;
19
+ const FALLBACK_MIN_NODE = "22.12.0";
20
+ const LEADING_RANGE = /^[^\d]*/u;
21
+
22
+ /** The minimum Node version, read from the package's own `engines` field so
23
+ * doctor can never drift from what the package actually declares. */
24
+ const minSupportedNode = (): string => {
25
+ try {
26
+ const pkg = JSON.parse(
27
+ readFileSync(join(packageRoot(), "package.json"), "utf-8")
28
+ ) as { engines?: { node?: string } };
29
+ const range = pkg.engines?.node ?? "";
30
+ return range.replace(LEADING_RANGE, "") || FALLBACK_MIN_NODE;
31
+ } catch {
32
+ return FALLBACK_MIN_NODE;
33
+ }
34
+ };
35
+
36
+ const versionBelow = (current: string, minimum: string): boolean => {
37
+ const a = current.split(".").map((part) => Number.parseInt(part, 10));
38
+ const b = minimum.split(".").map((part) => Number.parseInt(part, 10));
39
+ for (let i = 0; i < 3; i += 1) {
40
+ const delta = (a[i] ?? 0) - (b[i] ?? 0);
41
+ if (delta !== 0) {
42
+ return delta < 0;
43
+ }
44
+ }
45
+ return false;
46
+ };
16
47
 
17
48
  export const doctorCommand = defineCommand({
18
49
  args: {
@@ -29,14 +60,11 @@ export const doctorCommand = defineCommand({
29
60
  const root = process.cwd();
30
61
  const diagnostics: Diagnostic[] = [];
31
62
 
32
- const nodeMajor = Number.parseInt(
33
- process.versions.node.split(".")[0] ?? "0",
34
- 10
35
- );
36
- if (nodeMajor < MIN_NODE_MAJOR) {
63
+ const minNode = minSupportedNode();
64
+ if (versionBelow(process.versions.node, minNode)) {
37
65
  diagnostics.push({
38
66
  code: "BLUME_NODE_VERSION",
39
- message: `Node ${process.versions.node} is below the supported minimum (${MIN_NODE_MAJOR}).`,
67
+ message: `Node ${process.versions.node} is below the supported minimum (${minNode}).`,
40
68
  severity: "warning",
41
69
  });
42
70
  }
@@ -4,7 +4,11 @@ import { defineCommand } from "citty";
4
4
  import { join } from "pathe";
5
5
 
6
6
  import { loadConfig } from "../../core/config.ts";
7
- import { resolveProjectContext } from "../../core/project.ts";
7
+ import {
8
+ resolveProjectContext,
9
+ resolveRuntimeDir,
10
+ } from "../../core/project.ts";
11
+ import { readDevLock } from "../dev-lock.ts";
8
12
  import { logger } from "../log.ts";
9
13
  import { prepareProject } from "../prepare.ts";
10
14
 
@@ -38,8 +42,16 @@ export const syncCommand = defineCommand({
38
42
 
39
43
  // Dev mode keeps drafts and skips the static-build gate; `refresh` forces
40
44
  // remote sources to re-fetch rather than serve their cached snapshot. A
41
- // running dev server hot-reloads from the regenerated runtime.
45
+ // running dev server hot-reloads from the regenerated runtime — so when one
46
+ // is live, regenerate with the same site fallback (its URL) it used, else
47
+ // its astro.config loses `site` (dropping OG and canonicals) and Astro
48
+ // restarts cold.
49
+ const lock = readDevLock(resolveRuntimeDir(root));
50
+ const devServerUrl = lock?.port
51
+ ? `http://localhost:${lock.port}`
52
+ : undefined;
42
53
  await prepareProject({
54
+ devServerUrl,
43
55
  mode: "dev",
44
56
  preview: args.preview,
45
57
  refresh: true,
@@ -84,16 +84,27 @@ export const readDevLock = (outDir: string): DevLockInfo | null => {
84
84
  export const isDevLocked = (outDir: string): boolean =>
85
85
  readDevLock(outDir) !== null;
86
86
 
87
+ const lockPayload = (port?: number): string =>
88
+ JSON.stringify({
89
+ pid: process.pid,
90
+ ...(port === undefined ? {} : { port }),
91
+ });
92
+
87
93
  const writeLock = (outDir: string, port?: number): void => {
88
- writeFileSync(
89
- lockPath(outDir),
90
- JSON.stringify({
91
- pid: process.pid,
92
- ...(port === undefined ? {} : { port }),
93
- })
94
- );
94
+ writeFileSync(lockPath(outDir), lockPayload(port));
95
95
  };
96
96
 
97
+ /** Thrown when another live `blume dev` already holds the lock. */
98
+ export class DevLockHeldError extends Error {
99
+ readonly lock: DevLockInfo;
100
+
101
+ constructor(lock: DevLockInfo) {
102
+ super(`A blume dev server (pid ${lock.pid}) already holds the lock.`);
103
+ this.name = "DevLockHeldError";
104
+ this.lock = lock;
105
+ }
106
+ }
107
+
97
108
  const ownsLock = (outDir: string): boolean => {
98
109
  const path = lockPath(outDir);
99
110
  if (!existsSync(path)) {
@@ -108,12 +119,31 @@ const ownsLock = (outDir: string): boolean => {
108
119
 
109
120
  /**
110
121
  * Write the current process's dev lock into `outDir` and return a release
111
- * function. The release only removes the file if it's still ours, so a newer
112
- * dev server's lock is never clobbered.
122
+ * function. The claim is atomic (`wx`): two `blume dev` processes racing the
123
+ * same dir can't both pass a check-then-write — the loser gets a
124
+ * {@link DevLockHeldError} naming the live holder. A stale lock (dead pid) or
125
+ * this process's own leftover is cleared and re-claimed. The release only
126
+ * removes the file if it's still ours, so a newer dev server's lock is never
127
+ * clobbered.
113
128
  */
114
129
  export const acquireDevLock = (outDir: string, port?: number): (() => void) => {
115
130
  mkdirSync(outDir, { recursive: true });
116
- writeLock(outDir, port);
131
+ for (;;) {
132
+ try {
133
+ writeFileSync(lockPath(outDir), lockPayload(port), { flag: "wx" });
134
+ break;
135
+ } catch (error) {
136
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
137
+ throw error;
138
+ }
139
+ const existing = readDevLock(outDir);
140
+ if (existing && existing.pid !== process.pid) {
141
+ throw new DevLockHeldError(existing);
142
+ }
143
+ // Stale or our own leftover: clear it and race for the claim again.
144
+ rmSync(lockPath(outDir), { force: true });
145
+ }
146
+ }
117
147
  let released = false;
118
148
  return () => {
119
149
  if (released) {
package/src/cli/env.ts CHANGED
@@ -27,7 +27,11 @@ const unquote = (raw: string): string => {
27
27
  if (single !== undefined) {
28
28
  return single;
29
29
  }
30
- return raw;
30
+ // dotenv/Vite treat an unquoted `#` as the start of an inline comment (a
31
+ // value containing `#` must be quoted) — keeping the comment would hand
32
+ // consumers a silently corrupted value.
33
+ const hash = raw.indexOf("#");
34
+ return (hash === -1 ? raw : raw.slice(0, hash)).trim();
31
35
  };
32
36
 
33
37
  /** Parse `.env` text into key/value pairs, skipping blanks and `#` comments. */
@@ -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
+ }
@@ -217,7 +217,9 @@ const AskAI = ({
217
217
  const chunk = await reader.read();
218
218
  ({ done } = chunk);
219
219
  if (chunk.value) {
220
- assistant.content += decoder.decode(chunk.value);
220
+ // Streaming mode: a multi-byte UTF-8 sequence split across chunks
221
+ // must not flush as U+FFFD garbage.
222
+ assistant.content += decoder.decode(chunk.value, { stream: true });
221
223
  setMessages((current) => [...current.slice(0, -1), { ...assistant }]);
222
224
  }
223
225
  }
@@ -171,7 +171,11 @@ export const useAskAI = (): UseAskAI => {
171
171
  const chunk = await reader.read();
172
172
  ({ done } = chunk);
173
173
  if (chunk.value) {
174
- assistant.content += decoder.decode(chunk.value);
174
+ // Streaming mode: a multi-byte UTF-8 sequence split across
175
+ // chunks must not flush as U+FFFD garbage.
176
+ assistant.content += decoder.decode(chunk.value, {
177
+ stream: true,
178
+ });
175
179
  setMessages((current) => [
176
180
  ...current.slice(0, -1),
177
181
  { ...assistant },
@@ -6,6 +6,7 @@ import { GITHUB_MARK } from "../github-mark.ts";
6
6
  import Icon from "../Icon.astro";
7
7
  import LanguageSwitcher from "./LanguageSwitcher.astro";
8
8
  import Logo from "./Logo.astro";
9
+ import { isUnderPath } from "./nav-utils.ts";
9
10
  import NavSelector from "./NavSelector.astro";
10
11
  import { resolveSlot } from "./overrides.ts";
11
12
  import Search from "./Search.astro";
@@ -27,6 +28,12 @@ interface Props {
27
28
  // The mobile menu button toggles the docs sidebar drawer; custom pages
28
29
  // without a sidebar (e.g. a landing page) pass `false` to hide it.
29
30
  hasSidebar?: boolean;
31
+ /**
32
+ * Whether the layout renders a drawer for the nav toggle to open. A shell
33
+ * with no drawer at all (the Scalar reference layout) passes `false`, else
34
+ * the hamburger would lock page scroll with nothing appearing.
35
+ */
36
+ hasDrawer?: boolean;
30
37
  searchStrings?: UIStrings["search"];
31
38
  switcherStrings?: UIStrings["languageSwitcher"];
32
39
  localeSwitch?: LocaleSwitchOption[];
@@ -48,6 +55,7 @@ const {
48
55
  searchEnabled,
49
56
  askEnabled = false,
50
57
  hasSidebar = true,
58
+ hasDrawer = true,
51
59
  searchStrings,
52
60
  switcherStrings,
53
61
  localeSwitch,
@@ -62,7 +70,7 @@ const SearchSlot = resolveSlot(layout.Search, Search);
62
70
  // is the sidebar tree (`hasSidebar`); on chrome-only pages (a landing page via
63
71
  // PageLayout) it's a tabs-only drawer the layout renders — so the button is also
64
72
  // needed whenever there are tabs to reveal.
65
- const showNavToggle = hasSidebar || navigation.tabs.length > 0;
73
+ const showNavToggle = hasDrawer && (hasSidebar || navigation.tabs.length > 0);
66
74
  // Where the header's inline tab bar appears. With a sidebar it shares the `md`
67
75
  // breakpoint with the docs drawer; without one, the tabs-only drawer is the sole
68
76
  // mobile nav below `lg`, so the inline tabs wait until `lg` to avoid duplicating
@@ -126,7 +134,7 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
126
134
  <a
127
135
  aria-current={
128
136
  route === tab.path ||
129
- (tab.path !== "/" && route.startsWith(tab.path))
137
+ (tab.path !== "/" && isUnderPath(route, tab.path))
130
138
  ? "page"
131
139
  : undefined
132
140
  }