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.
- package/dist/cli/index.js +6070 -5718
- package/dist/cli/index.js.map +41 -40
- package/dist/types/core/config-input.d.ts +749 -0
- package/dist/types/core/config.d.ts +126 -3
- package/dist/types/core/schema.d.ts +10 -27
- package/dist/types/core/sources/types.d.ts +6 -0
- package/dist/types/index.d.ts +2 -1
- package/docs/advanced/changelog.mdx +10 -2
- package/docs/configuration/index.mdx +0 -2
- package/docs/content/syntax.mdx +4 -8
- package/package.json +1 -1
- package/src/astro/generate.ts +59 -24
- package/src/astro/markdown-negotiation.ts +12 -3
- package/src/astro/templates.ts +94 -12
- package/src/cli/commands/build.ts +26 -1
- 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/content/Update.astro +12 -2
- package/src/components/content/changelog-element.ts +62 -0
- 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 +47 -10
- package/src/components/layout/Search.astro +8 -3
- package/src/components/layout/nav-utils.ts +7 -3
- package/src/core/config-input.ts +923 -0
- package/src/core/config.ts +126 -3
- 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 +8 -14
- 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/adapter-output.ts +82 -0
- package/src/deploy/rss.ts +3 -1
- package/src/index.ts +1 -1
- package/src/markdown/code-title.ts +11 -4
- package/src/markdown/index.ts +28 -30
- package/src/markdown/math.ts +3 -2
- 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/registry/eject.ts +21 -14
- package/src/search/documents.ts +4 -1
- package/src/theme/entry.ts +10 -3
- package/src/theme/icons.ts +7 -11
package/src/astro/templates.ts
CHANGED
|
@@ -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
|
-
//
|
|
424
|
-
const outDirRel = relative(
|
|
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
|
-
...
|
|
443
|
-
...(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
});
|
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. */
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
}
|