blume 0.6.1 → 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 (51) hide show
  1. package/dist/cli/index.js +5955 -5733
  2. package/dist/cli/index.js.map +37 -37
  3. package/dist/types/core/config-input.d.ts +1 -11
  4. package/dist/types/core/schema.d.ts +4 -4
  5. package/dist/types/core/sources/types.d.ts +6 -0
  6. package/package.json +1 -1
  7. package/src/astro/generate.ts +23 -13
  8. package/src/astro/markdown-negotiation.ts +12 -3
  9. package/src/astro/templates.ts +28 -7
  10. package/src/cli/commands/dev.ts +30 -14
  11. package/src/cli/commands/doctor.ts +35 -7
  12. package/src/cli/commands/sync.ts +14 -2
  13. package/src/cli/dev-lock.ts +40 -10
  14. package/src/cli/env.ts +5 -1
  15. package/src/components/islands/ask-ai.tsx +3 -1
  16. package/src/components/islands/hooks.ts +5 -1
  17. package/src/components/layout/Header.astro +10 -2
  18. package/src/components/layout/NavSelector.astro +5 -3
  19. package/src/components/layout/PageLayout.astro +2 -1
  20. package/src/components/layout/ReferenceLayout.astro +1 -0
  21. package/src/components/layout/RootLayout.astro +16 -2
  22. package/src/components/layout/Search.astro +8 -3
  23. package/src/components/layout/nav-utils.ts +7 -3
  24. package/src/core/config-input.ts +1 -11
  25. package/src/core/i18n.ts +6 -5
  26. package/src/core/links.ts +16 -1
  27. package/src/core/meta.ts +112 -52
  28. package/src/core/navigation.ts +15 -5
  29. package/src/core/project-graph.ts +68 -2
  30. package/src/core/schema.ts +2 -1
  31. package/src/core/sources/assets.ts +21 -5
  32. package/src/core/sources/cache.ts +19 -1
  33. package/src/core/sources/github-releases.ts +9 -3
  34. package/src/core/sources/mdx-remote.ts +14 -4
  35. package/src/core/sources/normalize.ts +13 -1
  36. package/src/core/sources/notion.ts +43 -7
  37. package/src/core/sources/resolve.ts +44 -1
  38. package/src/core/sources/sanity.ts +9 -3
  39. package/src/core/sources/types.ts +6 -0
  40. package/src/deploy/rss.ts +3 -1
  41. package/src/markdown/code-title.ts +11 -4
  42. package/src/markdown/package-commands.ts +13 -0
  43. package/src/og/card.ts +3 -1
  44. package/src/openapi/model.ts +2 -1
  45. package/src/openapi/parse.ts +9 -1
  46. package/src/openapi/references.ts +11 -1
  47. package/src/openapi/render-mdx.ts +30 -3
  48. package/src/openapi/source.ts +3 -1
  49. package/src/search/documents.ts +4 -1
  50. package/src/theme/entry.ts +3 -0
  51. package/src/theme/icons.ts +7 -11
@@ -592,17 +592,12 @@ export interface GithubConfig {
592
592
  export interface CodeConfig {
593
593
  /** Show a brand language icon in the code-block header. Defaults to `true`. */
594
594
  icons?: boolean;
595
- /**
596
- * Syntax-highlight inline `` `code{:lang}` `` snippets. Defaults to `false`;
597
- * opt a snippet in with a trailing `{:lang}` marker.
598
- */
599
- inline?: boolean;
600
595
  /** Wrap long lines instead of scrolling horizontally. Defaults to `false`. */
601
596
  wrap?: boolean;
602
597
  }
603
598
  /** Markdown / MDX rendering behavior. */
604
599
  export interface MarkdownConfig {
605
- /** Code-block rendering: language icons, inline highlighting, line wrap. */
600
+ /** Code-block rendering: language icons, line wrap. */
606
601
  code?: CodeConfig;
607
602
  /** Syntax-highlighting themes for fenced code blocks. */
608
603
  codeBlocks?: {
@@ -621,11 +616,6 @@ export interface MarkdownConfig {
621
616
  headingAnchors?: boolean;
622
617
  /** Make content images click-to-zoom (lightbox). Defaults to `true`. */
623
618
  imageZoom?: boolean;
624
- /**
625
- * Enable LaTeX math (`$…$` inline, `$$…$$` block) via KaTeX. Defaults to
626
- * `false` since `$` is common in prose and shell. MDX only.
627
- */
628
- math?: boolean;
629
619
  }
630
620
  /**
631
621
  * OpenAPI reference. By default (`renderer: "blume"`) Blume renders its own UI:
@@ -112,9 +112,8 @@ declare const pageMetaBaseSchema: z.ZodObject<{
112
112
  }>>;
113
113
  slug: z.ZodOptional<z.ZodString>;
114
114
  title: z.ZodOptional<z.ZodString>;
115
- type: z.ZodDefault<z.ZodString>;
115
+ type: z.ZodOptional<z.ZodString>;
116
116
  }, "strict", z.ZodTypeAny, {
117
- type: string;
118
117
  search: {
119
118
  exclude: boolean;
120
119
  boost?: number | undefined;
@@ -138,6 +137,7 @@ declare const pageMetaBaseSchema: z.ZodObject<{
138
137
  badge?: string | undefined;
139
138
  order?: number | undefined;
140
139
  };
140
+ type?: string | undefined;
141
141
  date?: string | undefined;
142
142
  title?: string | undefined;
143
143
  description?: string | undefined;
@@ -317,9 +317,8 @@ export declare const pageMetaSchema: z.ZodObject<{
317
317
  }>>;
318
318
  slug: z.ZodOptional<z.ZodString>;
319
319
  title: z.ZodOptional<z.ZodString>;
320
- type: z.ZodDefault<z.ZodString>;
320
+ type: z.ZodOptional<z.ZodString>;
321
321
  }, "strict", z.ZodTypeAny, {
322
- type: string;
323
322
  search: {
324
323
  exclude: boolean;
325
324
  boost?: number | undefined;
@@ -343,6 +342,7 @@ export declare const pageMetaSchema: z.ZodObject<{
343
342
  badge?: string | undefined;
344
343
  order?: number | undefined;
345
344
  };
345
+ type?: string | undefined;
346
346
  date?: string | undefined;
347
347
  title?: string | undefined;
348
348
  description?: string | undefined;
@@ -83,6 +83,12 @@ export interface ContentSource {
83
83
  readonly staged: boolean;
84
84
  /** Optional route prefix; the source's routes namespace under `/<prefix>/`. */
85
85
  readonly prefix?: string;
86
+ /**
87
+ * Resolved on-disk root, set by filesystem-backed sources only. Drives
88
+ * folder-meta discovery (scan under this root) and the docs-collection base;
89
+ * omitted by remote/CMS/staged sources that have no local tree.
90
+ */
91
+ readonly contentRoot?: string;
86
92
  /** Pull every entry. Called once per scan. */
87
93
  load: () => Promise<SourceLoadResult>;
88
94
  /** Validate the source is usable; throws a BlumeError when not. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blume",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "Documentation that's fast, AI-ready, and zero-config.",
5
5
  "keywords": [
6
6
  "astro",
@@ -33,6 +33,7 @@ import { validateNavTargets } from "../core/nav-diagnostics.ts";
33
33
  import { packageRoot } from "../core/package-root.ts";
34
34
  import type { BlumeProject } from "../core/project-graph.ts";
35
35
  import type { ResolvedConfig } from "../core/schema.ts";
36
+ import { resolveDocsCollection } from "../core/sources/resolve.ts";
36
37
  import { resolveTsconfigAliases } from "../core/tsconfig-aliases.ts";
37
38
  import type { Navigation } from "../core/types.ts";
38
39
  import { buildRssFeeds, renderRssFeed } from "../deploy/rss.ts";
@@ -742,10 +743,14 @@ interface McpPlan {
742
743
 
743
744
  /**
744
745
  * Decide whether (and how) to generate the MCP server. Skipped — with a
745
- * warning — when a content page already occupies its route, so the user's page
746
- * keeps working.
746
+ * warning — when a content page or a custom `.astro` page already occupies its
747
+ * route, so the user's page keeps working instead of colliding.
747
748
  */
748
- const planMcp = (project: BlumeProject, srcDir: string): McpPlan => {
749
+ const planMcp = (
750
+ project: BlumeProject,
751
+ srcDir: string,
752
+ userPages: { pattern: string }[]
753
+ ): McpPlan => {
749
754
  const { config } = project;
750
755
  const { route } = config.mcp;
751
756
  const dir = join(srcDir, "blume-mcp");
@@ -760,11 +765,11 @@ const planMcp = (project: BlumeProject, srcDir: string): McpPlan => {
760
765
  if (!config.mcp.enabled) {
761
766
  return base;
762
767
  }
763
- if (project.graph.pages.some((page) => page.route === route)) {
768
+ if (routeIsTaken(userPages, project.graph.pages, route)) {
764
769
  return {
765
770
  ...base,
766
771
  warnings: [
767
- `MCP server route "${route}" is already used by a content page; the MCP server was not generated. Set a different "mcp.route" in blume.config.ts.`,
772
+ `MCP server route "${route}" is already used by a content or custom page; the MCP server was not generated. Set a different "mcp.route" in blume.config.ts.`,
768
773
  ],
769
774
  };
770
775
  }
@@ -878,9 +883,13 @@ export interface GenerateResult {
878
883
  * `type: changelog` entries — or when a release-backed changelog source is
879
884
  * configured, so its route (and any nav tab pointing at it) still resolves to an
880
885
  * empty timeline on a build where the source could not be fetched (e.g. CI
881
- * without a token). Skipped when a user content page already owns `/changelog`.
886
+ * without a token). Skipped when a user content page or a custom `.astro` page
887
+ * already owns `/changelog`.
882
888
  */
883
- const shouldGenerateChangelog = (project: BlumeProject): boolean => {
889
+ const shouldGenerateChangelog = (
890
+ project: BlumeProject,
891
+ userPages: { pattern: string }[]
892
+ ): boolean => {
884
893
  const hasChangelog = project.graph.pages.some(
885
894
  (page) =>
886
895
  page.contentType === "changelog" &&
@@ -889,10 +898,10 @@ const shouldGenerateChangelog = (project: BlumeProject): boolean => {
889
898
  const hasChangelogSource = (project.config.content.sources ?? []).some(
890
899
  (source) => source.type === "github-releases"
891
900
  );
892
- const changelogRouteTaken = project.graph.pages.some(
893
- (page) => page.route === "/changelog"
901
+ return (
902
+ (hasChangelog || hasChangelogSource) &&
903
+ !routeIsTaken(userPages, project.graph.pages, "/changelog")
894
904
  );
895
- return (hasChangelog || hasChangelogSource) && !changelogRouteTaken;
896
905
  };
897
906
 
898
907
  /**
@@ -998,7 +1007,7 @@ export const generateRuntime = async (
998
1007
  // The hosted MCP server. The `.well-known` discovery docs are injected as
999
1008
  // prerendered routes alongside user pages; the server endpoint itself is a
1000
1009
  // normal (server-rendered) page written by `writeMcpFiles`.
1001
- const mcp = planMcp(project, srcDir);
1010
+ const mcp = planMcp(project, srcDir, pages);
1002
1011
  pages.push(...mcp.discoveryPages);
1003
1012
 
1004
1013
  // Staged (non-filesystem) sources materialize into `.blume/content`; keyed by
@@ -1041,6 +1050,7 @@ export const generateRuntime = async (
1041
1050
  write(
1042
1051
  join(srcDir, "content.config.ts"),
1043
1052
  contentConfigTemplate({
1053
+ collection: resolveDocsCollection(config, context),
1044
1054
  config,
1045
1055
  context,
1046
1056
  filesystem: hasFilesystemSource,
@@ -1132,7 +1142,7 @@ export const generateRuntime = async (
1132
1142
  }
1133
1143
 
1134
1144
  // Changelog index (`/changelog`), rendered through the Update timeline layout.
1135
- if (shouldGenerateChangelog(project)) {
1145
+ if (shouldGenerateChangelog(project, pages)) {
1136
1146
  await write(
1137
1147
  join(srcDir, "pages", "changelog.astro"),
1138
1148
  changelogIndexTemplate({
@@ -1226,7 +1236,7 @@ export const generateRuntime = async (
1226
1236
  ...pages.map((page) => page.pattern),
1227
1237
  ...referenceTabs(config).map((tab) => tab.path),
1228
1238
  ]);
1229
- if (shouldGenerateChangelog(project)) {
1239
+ if (shouldGenerateChangelog(project, pages)) {
1230
1240
  navTargetRoutes.add("/changelog");
1231
1241
  }
1232
1242
  warnings.push(
@@ -72,11 +72,20 @@ export const markdownVariantUrl = (
72
72
  }
73
73
  }
74
74
 
75
- const pathname =
76
- path !== "/" && path.endsWith("/") ? path.slice(0, -1) : path;
75
+ const trimmed = path !== "/" && path.endsWith("/") ? path.slice(0, -1) : path;
76
+ // Requests arrive percent-encoded while routes are stored decoded, so a
77
+ // non-ASCII route (`/ja/はじめに` requested as `/ja/%E3%81%AF…`) must be
78
+ // decoded before the lookup. Malformed sequences stay verbatim.
79
+ let pathname = trimmed;
80
+ try {
81
+ pathname = decodeURIComponent(trimmed);
82
+ } catch {
83
+ // Keep the raw path; it simply won't match a content route.
84
+ }
77
85
  if (!routes.has(pathname)) {
78
86
  return null;
79
87
  }
80
88
  const target = pathname === "/" ? "/index" : pathname;
81
- return `${prefix}${target}.md${query}`;
89
+ // Re-encode: the variant URL goes back into the request pipeline.
90
+ return `${prefix}${encodeURI(target)}.md${query}`;
82
91
  };
@@ -404,6 +404,13 @@ export const contentConfigTemplate = (options: {
404
404
  staged?: boolean;
405
405
  /** Base dir for the staged collection; defaults to `<outDir>/content`. */
406
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[] };
407
414
  /**
408
415
  * Whether any filesystem (non-staged) source feeds the `docs` collection.
409
416
  * When false (e.g. an all-staged project where every page is materialized by
@@ -413,12 +420,15 @@ export const contentConfigTemplate = (options: {
413
420
  }): string => {
414
421
  const { context, config } = options;
415
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;
416
426
 
417
427
  // Fold the content excludes into the glob as negative patterns so the `docs`
418
428
  // collection doesn't ingest ignored trees (`node_modules`, `snippets`, the
419
429
  // staged bodies under `.blume/content`, …) as entries. This matters when
420
- // `content.root` is the project root (a migrated `.`-rooted project).
421
- 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);
422
432
  const outDirIgnore =
423
433
  outDirRel && !outDirRel.startsWith("..") && !isAbsolute(outDirRel)
424
434
  ? [`!${outDirRel}/**`]
@@ -436,8 +446,8 @@ export const contentConfigTemplate = (options: {
436
446
  const filesystem = options.filesystem ?? true;
437
447
  const docsPattern = filesystem
438
448
  ? [
439
- ...config.content.include,
440
- ...(config.content.exclude ?? []).map((pattern) => `!${pattern}`),
449
+ ...includeGlobs,
450
+ ...(excludeGlobs ?? []).map((pattern) => `!${pattern}`),
441
451
  // Mirror the filesystem scan's baseline ignores (see BLUME_IGNORE_DIRS):
442
452
  // Astro's content layer roots at the project dir, so a `.`-wide content
443
453
  // root would otherwise re-ingest dependency trees and build output —
@@ -472,7 +482,7 @@ import { glob } from "astro/loaders";
472
482
  const docs = defineCollection({
473
483
  loader: glob({
474
484
  pattern: ${JSON.stringify(docsPattern)},
475
- base: ${JSON.stringify(context.contentRoot)},
485
+ base: ${JSON.stringify(collectionBase)},
476
486
  generateId: ({ entry }) => entry,
477
487
  }),
478
488
  });
@@ -1093,7 +1103,10 @@ const ogPath = data.config.og.enabled
1093
1103
  ? \`/og/\${route === "/" ? "index" : route.slice(1)}.png\`
1094
1104
  : null;
1095
1105
  const ogRel = seo.image ?? ogPath;
1096
- 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;
1097
1110
 
1098
1111
  const canonical =
1099
1112
  seo.canonical ?? (base ? \`\${base}\${route === "/" ? "" : route}\` : null);
@@ -1538,8 +1551,16 @@ const exampleDirective = (spec: ExampleSpec): string => {
1538
1551
  };
1539
1552
 
1540
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
+ */
1541
1559
  export const exampleSlug = (path: string): string =>
1542
- 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
+ );
1543
1564
 
1544
1565
  /**
1545
1566
  * Generate `.blume/src/generated/examples/<slug>.astro` — a wrapper that renders
@@ -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. */
@@ -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
  }
@@ -5,6 +5,7 @@
5
5
  // <details>/<summary> like the language switcher.
6
6
  import type { NavSelector } from "../../core/types.ts";
7
7
  import Icon from "../Icon.astro";
8
+ import { isUnderPath } from "./nav-utils.ts";
8
9
 
9
10
  interface Props {
10
11
  selector: NavSelector;
@@ -13,12 +14,13 @@ interface Props {
13
14
 
14
15
  const { selector, route } = Astro.props;
15
16
 
16
- // The active item is the deepest path that prefixes the current route, falling
17
- // back to the first item so the summary always shows something meaningful.
17
+ // The active item is the deepest path the current route sits under (on a path
18
+ // boundary, so `/api` never claims `/api-reference` routes), falling back to
19
+ // the first item so the summary always shows something meaningful.
18
20
  const active =
19
21
  selector.items.find((item) => item.path === route) ??
20
22
  selector.items
21
- .filter((item) => route.startsWith(item.path))
23
+ .filter((item) => isUnderPath(route, item.path))
22
24
  .toSorted((a, b) => b.path.length - a.path.length)[0] ??
23
25
  selector.items[0];
24
26