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.
- package/dist/cli/index.js +5955 -5733
- package/dist/cli/index.js.map +37 -37
- package/dist/types/core/config-input.d.ts +1 -11
- package/dist/types/core/schema.d.ts +4 -4
- package/dist/types/core/sources/types.d.ts +6 -0
- package/package.json +1 -1
- package/src/astro/generate.ts +23 -13
- package/src/astro/markdown-negotiation.ts +12 -3
- package/src/astro/templates.ts +28 -7
- package/src/cli/commands/dev.ts +30 -14
- package/src/cli/commands/doctor.ts +35 -7
- package/src/cli/commands/sync.ts +14 -2
- package/src/cli/dev-lock.ts +40 -10
- package/src/cli/env.ts +5 -1
- package/src/components/islands/ask-ai.tsx +3 -1
- package/src/components/islands/hooks.ts +5 -1
- package/src/components/layout/Header.astro +10 -2
- package/src/components/layout/NavSelector.astro +5 -3
- package/src/components/layout/PageLayout.astro +2 -1
- package/src/components/layout/ReferenceLayout.astro +1 -0
- package/src/components/layout/RootLayout.astro +16 -2
- package/src/components/layout/Search.astro +8 -3
- package/src/components/layout/nav-utils.ts +7 -3
- package/src/core/config-input.ts +1 -11
- package/src/core/i18n.ts +6 -5
- package/src/core/links.ts +16 -1
- package/src/core/meta.ts +112 -52
- package/src/core/navigation.ts +15 -5
- package/src/core/project-graph.ts +68 -2
- package/src/core/schema.ts +2 -1
- package/src/core/sources/assets.ts +21 -5
- package/src/core/sources/cache.ts +19 -1
- package/src/core/sources/github-releases.ts +9 -3
- package/src/core/sources/mdx-remote.ts +14 -4
- package/src/core/sources/normalize.ts +13 -1
- package/src/core/sources/notion.ts +43 -7
- package/src/core/sources/resolve.ts +44 -1
- package/src/core/sources/sanity.ts +9 -3
- package/src/core/sources/types.ts +6 -0
- package/src/deploy/rss.ts +3 -1
- package/src/markdown/code-title.ts +11 -4
- package/src/markdown/package-commands.ts +13 -0
- package/src/og/card.ts +3 -1
- package/src/openapi/model.ts +2 -1
- package/src/openapi/parse.ts +9 -1
- package/src/openapi/references.ts +11 -1
- package/src/openapi/render-mdx.ts +30 -3
- package/src/openapi/source.ts +3 -1
- package/src/search/documents.ts +4 -1
- package/src/theme/entry.ts +3 -0
- 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,
|
|
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.
|
|
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.
|
|
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
package/src/astro/generate.ts
CHANGED
|
@@ -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
|
|
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 = (
|
|
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
|
|
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
|
|
886
|
+
* without a token). Skipped when a user content page or a custom `.astro` page
|
|
887
|
+
* already owns `/changelog`.
|
|
882
888
|
*/
|
|
883
|
-
const shouldGenerateChangelog = (
|
|
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
|
-
|
|
893
|
-
(
|
|
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
|
|
76
|
-
|
|
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
|
-
|
|
89
|
+
// Re-encode: the variant URL goes back into the request pipeline.
|
|
90
|
+
return `${prefix}${encodeURI(target)}.md${query}`;
|
|
82
91
|
};
|
package/src/astro/templates.ts
CHANGED
|
@@ -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
|
-
//
|
|
421
|
-
const outDirRel = relative(
|
|
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
|
-
...
|
|
440
|
-
...(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
package/src/cli/commands/dev.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { coalescedRunner } from "../coalesce.ts";
|
|
|
12
12
|
import {
|
|
13
13
|
acquireDevLock,
|
|
14
14
|
describeDevLock,
|
|
15
|
-
|
|
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
|
-
|
|
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.
|
|
61
|
-
//
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
)
|
|
68
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
|
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
|
|
33
|
-
|
|
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 (${
|
|
67
|
+
message: `Node ${process.versions.node} is below the supported minimum (${minNode}).`,
|
|
40
68
|
severity: "warning",
|
|
41
69
|
});
|
|
42
70
|
}
|
package/src/cli/commands/sync.ts
CHANGED
|
@@ -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 {
|
|
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,
|
package/src/cli/dev-lock.ts
CHANGED
|
@@ -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
|
|
112
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
17
|
-
//
|
|
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
|
|
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
|
|