blume 0.6.7 → 0.7.0
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 +1179 -738
- package/dist/cli/index.js.map +52 -51
- package/dist/types/core/base-path.d.ts +38 -0
- package/dist/types/core/config-input.d.ts +74 -10
- package/dist/types/core/config.d.ts +3 -2
- package/dist/types/core/data.d.ts +2 -0
- package/dist/types/core/i18n-ui.d.ts +1 -3
- package/dist/types/core/schema.d.ts +95 -52
- package/dist/types/core/sources/types.d.ts +2 -0
- package/dist/types/core/types.d.ts +6 -1
- package/docs/02-deployment.mdx +16 -1
- package/docs/03-faq.mdx +8 -8
- package/docs/configuration/index.mdx +6 -0
- package/docs/content/components.mdx +29 -2
- package/docs/content/islands.mdx +8 -0
- package/docs/content/syntax.mdx +13 -0
- package/package.json +2 -1
- package/src/ai/agent-readability.ts +7 -2
- package/src/ai/ask.ts +12 -7
- package/src/ai/llms.ts +15 -4
- package/src/ai/mcp/data.ts +8 -4
- package/src/ai/mcp/server.ts +3 -0
- package/src/astro/component-slots.ts +5 -3
- package/src/astro/examples.ts +12 -7
- package/src/astro/generate.ts +317 -144
- package/src/astro/index.ts +5 -1
- package/src/astro/integration.ts +8 -4
- package/src/astro/islands.ts +11 -5
- package/src/astro/markdown-negotiation.ts +1 -1
- package/src/astro/pages.ts +8 -3
- package/src/astro/templates.ts +166 -19
- package/src/cli/commands/build.ts +32 -19
- package/src/cli/commands/dev.ts +48 -15
- package/src/cli/commands/doctor.ts +2 -2
- package/src/cli/commands/validate.ts +1 -0
- package/src/cli/dev-lock.ts +26 -15
- package/src/cli/required-secrets.ts +2 -1
- package/src/components/content/CodeBlock.astro +3 -0
- package/src/components/content/Component.astro +30 -16
- package/src/components/content/Diff.astro +3 -1
- package/src/components/content/auto-type-table.ts +18 -8
- package/src/components/content/diff.ts +12 -6
- package/src/components/content/mermaid-element.ts +3 -0
- package/src/components/index.ts +23 -1
- package/src/components/islands/ask-ai.tsx +12 -6
- package/src/components/islands/base-path.ts +28 -0
- package/src/components/islands/hooks.ts +16 -1
- package/src/components/layout/Banner.astro +2 -1
- package/src/components/layout/Breadcrumbs.astro +2 -1
- package/src/components/layout/Favicon.astro +3 -2
- package/src/components/layout/Header.astro +2 -1
- package/src/components/layout/LanguageSwitcher.astro +2 -1
- package/src/components/layout/Logo.astro +2 -1
- package/src/components/layout/NavSelector.astro +2 -1
- package/src/components/layout/NavTree.astro +5 -4
- package/src/components/layout/PageFeedback.astro +4 -1
- package/src/components/layout/PageLayout.astro +9 -4
- package/src/components/layout/Pagination.astro +3 -2
- package/src/components/layout/RootLayout.astro +7 -4
- package/src/components/layout/Search.astro +13 -5
- package/src/components/layout/nav-utils.ts +18 -10
- package/src/components/layout/search/pagefind.ts +3 -0
- package/src/components/layout/toc-element.ts +7 -1
- package/src/components/openapi/RequestPanel.astro +7 -1
- package/src/components/openapi/snippets.ts +25 -11
- package/src/core/base-path.ts +70 -0
- package/src/core/component-overrides.ts +103 -74
- package/src/core/config-input.ts +81 -15
- package/src/core/config.ts +5 -3
- package/src/core/content.ts +2 -0
- package/src/core/data.ts +2 -0
- package/src/core/diagnostics.ts +54 -34
- package/src/core/gitignore.ts +4 -1
- package/src/core/graph.ts +156 -88
- package/src/core/i18n-ui.ts +18 -3
- package/src/core/last-modified.ts +2 -0
- package/src/core/links.ts +38 -18
- package/src/core/manifest.ts +62 -45
- package/src/core/nav-diagnostics.ts +1 -1
- package/src/core/navigation.ts +116 -55
- package/src/core/project-graph.ts +10 -9
- package/src/core/schema.ts +572 -621
- package/src/core/sources/github-releases.ts +2 -1
- package/src/core/sources/mdx-remote.ts +58 -54
- package/src/core/sources/normalize.ts +116 -73
- package/src/core/sources/notion.ts +19 -10
- package/src/core/sources/types.ts +2 -0
- package/src/core/tsconfig-aliases.ts +59 -30
- package/src/core/types.ts +6 -1
- package/src/deploy/redirects.ts +18 -0
- package/src/deploy/robots.ts +6 -1
- package/src/deploy/rss.ts +10 -3
- package/src/deploy/sitemap.ts +14 -10
- package/src/markdown/base-links.ts +58 -0
- package/src/markdown/code-title.ts +11 -14
- package/src/markdown/index.ts +34 -9
- package/src/markdown/inline-code.ts +7 -2
- package/src/markdown/themes.ts +24 -0
- package/src/openapi/model.ts +3 -1
- package/src/openapi/references.ts +41 -17
- package/src/openapi/render-mdx.ts +11 -6
- package/src/openapi/scalar.ts +32 -16
- package/src/registry/eject.ts +64 -8
- package/src/search/build.ts +3 -0
- package/src/search/documents.ts +2 -2
- package/src/search/sync/typesense.ts +6 -4
- package/src/seo/jsonld.ts +16 -6
- package/src/theme/entry.ts +85 -20
|
@@ -18,7 +18,7 @@ const parseAccept = (accept: string): AcceptEntry[] =>
|
|
|
18
18
|
.slice(1)
|
|
19
19
|
.map((segment) => segment.trim())
|
|
20
20
|
.find((segment) => segment.startsWith("q="));
|
|
21
|
-
const q = qSegment ? Number
|
|
21
|
+
const q = qSegment ? Number(qSegment.slice(2)) : 1;
|
|
22
22
|
return { q: Number.isNaN(q) ? 1 : q, type };
|
|
23
23
|
});
|
|
24
24
|
|
package/src/astro/pages.ts
CHANGED
|
@@ -80,20 +80,25 @@ export const customOgRoutes = (
|
|
|
80
80
|
): OgCustomRoute[] => {
|
|
81
81
|
const seen = new Set<string>();
|
|
82
82
|
const routes: OgCustomRoute[] = [];
|
|
83
|
-
|
|
83
|
+
// Extracted so the skip paths become early `return`s (one `continue` budget
|
|
84
|
+
// per loop under the lint rule) instead of `continue` statements.
|
|
85
|
+
const collectRoute = (pattern: string): void => {
|
|
84
86
|
const segments = pattern.split("/").filter(Boolean);
|
|
85
87
|
if (
|
|
86
88
|
segments.some((part) => PRIVATE_SEGMENT.test(part) || part.includes("["))
|
|
87
89
|
) {
|
|
88
|
-
|
|
90
|
+
return;
|
|
89
91
|
}
|
|
90
92
|
const slug = segments.length === 0 ? "index" : segments.join("/");
|
|
91
93
|
if (seen.has(slug)) {
|
|
92
|
-
|
|
94
|
+
return;
|
|
93
95
|
}
|
|
94
96
|
seen.add(slug);
|
|
95
97
|
const last = segments.at(-1);
|
|
96
98
|
routes.push({ slug, title: last ? humanizeSegment(last) : siteTitle });
|
|
99
|
+
};
|
|
100
|
+
for (const { pattern } of pages) {
|
|
101
|
+
collectRoute(pattern);
|
|
97
102
|
}
|
|
98
103
|
return routes;
|
|
99
104
|
};
|
package/src/astro/templates.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
2
3
|
|
|
3
4
|
import { dirname, isAbsolute, join, relative } from "pathe";
|
|
4
5
|
|
|
5
6
|
import { askBackendRuntimeDep } from "../ai/ask.ts";
|
|
6
7
|
import type { AskBackend } from "../ai/ask.ts";
|
|
8
|
+
import { normalizeBasePath } from "../core/base-path.ts";
|
|
7
9
|
import type { ResolvedConfig } from "../core/schema.ts";
|
|
8
10
|
import { BLUME_IGNORE_DIRS } from "../core/sources/watch.ts";
|
|
9
11
|
import type { ProjectContext } from "../core/types.ts";
|
|
12
|
+
import { applyBaseToRedirects } from "../deploy/redirects.ts";
|
|
10
13
|
import { hasScalarReferences } from "../openapi/references.ts";
|
|
11
14
|
import { searchProviderMeta } from "../search/providers.ts";
|
|
12
15
|
import { buildFontEntries } from "../theme/fonts.ts";
|
|
@@ -184,6 +187,19 @@ const renderUserAliases = (
|
|
|
184
187
|
const astroOutDir = (context: ProjectContext): string =>
|
|
185
188
|
context.distDir ?? `${context.root}/dist`;
|
|
186
189
|
|
|
190
|
+
/**
|
|
191
|
+
* The `react()` integration call. When `compilerPath` is set (the resolved
|
|
192
|
+
* absolute path to `babel-plugin-react-compiler`), react() carries the compiler
|
|
193
|
+
* as the first babel plugin — an absolute path, because @vitejs/plugin-react
|
|
194
|
+
* resolves babel plugins from the *project* root, not `.blume/`, so a bare
|
|
195
|
+
* specifier wouldn't resolve in a user project. `target: "19"` matches Blume's
|
|
196
|
+
* React pin. `null`/`undefined` (compiler off or unresolvable) emits bare react().
|
|
197
|
+
*/
|
|
198
|
+
const reactIntegration = (compilerPath: string | null | undefined): string =>
|
|
199
|
+
compilerPath
|
|
200
|
+
? `react({ babel: { plugins: [[${JSON.stringify(compilerPath)}, { target: "19" }]] } })`
|
|
201
|
+
: "react()";
|
|
202
|
+
|
|
187
203
|
export const astroConfigTemplate = (options: {
|
|
188
204
|
context: ProjectContext;
|
|
189
205
|
config: ResolvedConfig;
|
|
@@ -194,9 +210,17 @@ export const astroConfigTemplate = (options: {
|
|
|
194
210
|
contentRoutes: string[];
|
|
195
211
|
dataPath: string;
|
|
196
212
|
examplesPath: string;
|
|
213
|
+
/** The example-preview Tailwind entry (`blume:examples-theme`). */
|
|
214
|
+
examplesThemePath: string;
|
|
197
215
|
themePath: string;
|
|
198
216
|
searchClientPath: string;
|
|
199
217
|
openapiPath: string;
|
|
218
|
+
/**
|
|
219
|
+
* Absolute path to `babel-plugin-react-compiler` when the React Compiler is
|
|
220
|
+
* enabled (resolved from Blume's package root by the caller); null/absent
|
|
221
|
+
* disables the compiler and emits a bare `react()`.
|
|
222
|
+
*/
|
|
223
|
+
reactCompilerPath?: string | null;
|
|
200
224
|
/** Project tsconfig path aliases (`find` -> absolute dir), e.g. `@` -> src. */
|
|
201
225
|
aliases?: Record<string, string>;
|
|
202
226
|
}): string => {
|
|
@@ -204,6 +228,7 @@ export const astroConfigTemplate = (options: {
|
|
|
204
228
|
const {
|
|
205
229
|
contentRoutes,
|
|
206
230
|
examplesPath,
|
|
231
|
+
examplesThemePath,
|
|
207
232
|
needsSvelte,
|
|
208
233
|
needsVue,
|
|
209
234
|
openapiPath,
|
|
@@ -248,11 +273,17 @@ export const astroConfigTemplate = (options: {
|
|
|
248
273
|
})},`
|
|
249
274
|
: "";
|
|
250
275
|
|
|
276
|
+
// Base the redirect paths the same way routes are based, so a redirect lands
|
|
277
|
+
// under `basePath` too. Astro layers its own `base` (deployment.base) on top.
|
|
278
|
+
const basedRedirects = applyBaseToRedirects(
|
|
279
|
+
config.redirects,
|
|
280
|
+
config.basePath
|
|
281
|
+
);
|
|
251
282
|
const redirectsOption =
|
|
252
|
-
|
|
283
|
+
basedRedirects.length > 0
|
|
253
284
|
? `\n redirects: ${JSON.stringify(
|
|
254
285
|
Object.fromEntries(
|
|
255
|
-
|
|
286
|
+
basedRedirects.map((redirect) => [
|
|
256
287
|
redirect.from,
|
|
257
288
|
{ destination: redirect.to, status: redirect.status },
|
|
258
289
|
])
|
|
@@ -288,7 +319,7 @@ export const astroConfigTemplate = (options: {
|
|
|
288
319
|
const svelteImport = needsSvelte
|
|
289
320
|
? `import svelte from "@astrojs/svelte";\n`
|
|
290
321
|
: "";
|
|
291
|
-
const blumeImport = `import { blumeIntegration, prerenderDepsPlugin } from "blume/astro";\n`;
|
|
322
|
+
const blumeImport = `import { blumeIntegration, prerenderDepsPlugin, serverAppResolvePlugin } from "blume/astro";\n`;
|
|
292
323
|
|
|
293
324
|
// Twoslash runs first, before the always-on transformers, but only on fences
|
|
294
325
|
// with the `twoslash` meta (explicitTrigger) — so it's opt-in per block with
|
|
@@ -297,13 +328,21 @@ export const astroConfigTemplate = (options: {
|
|
|
297
328
|
const twoslashTransformer =
|
|
298
329
|
"transformerTwoslash({ explicitTrigger: true }), ";
|
|
299
330
|
|
|
331
|
+
// Content links are rewritten to their real served URL: the `deployment.base`
|
|
332
|
+
// subdirectory (Astro doesn't rewrite `<a href>`) plus the site-wide
|
|
333
|
+
// `basePath` baked into routes. The link checker validates the base-less
|
|
334
|
+
// authored path against `basePath` routes separately.
|
|
335
|
+
const contentLinkBase = normalizeBasePath(deployment.base) + config.basePath;
|
|
336
|
+
|
|
300
337
|
const integrations = [
|
|
301
338
|
`mdx({ processor: blumeMdxProcessor(${JSON.stringify({
|
|
339
|
+
basePath: contentLinkBase,
|
|
340
|
+
codeThemes: config.markdown.codeBlocks.theme,
|
|
302
341
|
headingAnchors: config.markdown.headingAnchors,
|
|
303
342
|
})}) })`,
|
|
304
343
|
];
|
|
305
344
|
if (needsReact) {
|
|
306
|
-
integrations.push(
|
|
345
|
+
integrations.push(reactIntegration(options.reactCompilerPath));
|
|
307
346
|
}
|
|
308
347
|
if (needsVue) {
|
|
309
348
|
integrations.push("vue()");
|
|
@@ -332,12 +371,14 @@ export default defineConfig({
|
|
|
332
371
|
integrations: [${integrations.join(", ")}],
|
|
333
372
|
markdown: {
|
|
334
373
|
processor: blumeMarkdownProcessor(${JSON.stringify({
|
|
374
|
+
basePath: contentLinkBase,
|
|
375
|
+
codeThemes: config.markdown.codeBlocks.theme,
|
|
335
376
|
headingAnchors: config.markdown.headingAnchors,
|
|
336
377
|
})}),
|
|
337
378
|
shikiConfig: {
|
|
338
379
|
themes: {
|
|
339
|
-
light:
|
|
340
|
-
dark:
|
|
380
|
+
light: ${JSON.stringify(config.markdown.codeBlocks.theme.light)},
|
|
381
|
+
dark: ${JSON.stringify(config.markdown.codeBlocks.theme.dark)},
|
|
341
382
|
},
|
|
342
383
|
defaultColor: false,
|
|
343
384
|
transformers: [${twoslashTransformer}...blumeShikiTransformers(${JSON.stringify(
|
|
@@ -347,7 +388,7 @@ export default defineConfig({
|
|
|
347
388
|
},
|
|
348
389
|
devToolbar: { enabled: false },
|
|
349
390
|
vite: {
|
|
350
|
-
plugins: [tailwindcss(), prerenderDepsPlugin()],
|
|
391
|
+
plugins: [tailwindcss(), prerenderDepsPlugin(), serverAppResolvePlugin()],
|
|
351
392
|
// Blume's render-time deps are forced external on both build environments so
|
|
352
393
|
// native bindings resolve at runtime and isolated linkers don't bundle
|
|
353
394
|
// symlinked store copies (which would surface their children as unresolvable
|
|
@@ -368,6 +409,7 @@ export default defineConfig({
|
|
|
368
409
|
alias: {
|
|
369
410
|
"blume:data": ${JSON.stringify(dataPath)},
|
|
370
411
|
"blume:examples": ${JSON.stringify(examplesPath)},
|
|
412
|
+
"blume:examples-theme": ${JSON.stringify(examplesThemePath)},
|
|
371
413
|
"blume:openapi": ${JSON.stringify(openapiPath)},
|
|
372
414
|
"blume:search-client": ${JSON.stringify(searchClientPath)},
|
|
373
415
|
"blume:theme": ${JSON.stringify(themePath)},${userAliasLines}
|
|
@@ -396,6 +438,17 @@ export default defineConfig({
|
|
|
396
438
|
export const stagedContentDir = (outDir: string): string =>
|
|
397
439
|
join(outDir, "content");
|
|
398
440
|
|
|
441
|
+
/**
|
|
442
|
+
* Astro's glob loader resolves `base` with `new URL(base, config.root)`. On
|
|
443
|
+
* Windows an absolute path like `C:\\docs\\content` makes `new URL` parse the
|
|
444
|
+
* drive letter as a URL scheme, so the result isn't a `file:` URL and Astro's
|
|
445
|
+
* subsequent `fileURLToPath` throws "The URL must be of scheme file". Emit an
|
|
446
|
+
* absolute base as a proper `file://` URL so the drive letter can't be mistaken
|
|
447
|
+
* for a scheme; relative bases resolve against `config.root` unchanged.
|
|
448
|
+
*/
|
|
449
|
+
const astroGlobBase = (base: string): string =>
|
|
450
|
+
isAbsolute(base) ? pathToFileURL(base).href : base;
|
|
451
|
+
|
|
399
452
|
/** Generate `.blume/src/content.config.ts`. */
|
|
400
453
|
export const contentConfigTemplate = (options: {
|
|
401
454
|
context: ProjectContext;
|
|
@@ -454,8 +507,8 @@ export const contentConfigTemplate = (options: {
|
|
|
454
507
|
// e.g. a prior `dist/*.mdx` render — and crash the content-module graph.
|
|
455
508
|
// The runtime dir (`.blume`, or a custom distDir) is excluded precisely
|
|
456
509
|
// by `outDirIgnore` instead, so it's left out of this baseline.
|
|
457
|
-
...BLUME_IGNORE_DIRS.
|
|
458
|
-
|
|
510
|
+
...BLUME_IGNORE_DIRS.flatMap((dir) =>
|
|
511
|
+
dir === ".blume" ? [] : [`!**/${dir}/**`]
|
|
459
512
|
),
|
|
460
513
|
...outDirIgnore,
|
|
461
514
|
]
|
|
@@ -468,7 +521,7 @@ export const contentConfigTemplate = (options: {
|
|
|
468
521
|
const staged = defineCollection({
|
|
469
522
|
loader: glob({
|
|
470
523
|
pattern: ["**/*.{md,mdx}"],
|
|
471
|
-
base: ${JSON.stringify(stagedBase)},
|
|
524
|
+
base: ${JSON.stringify(astroGlobBase(stagedBase))},
|
|
472
525
|
generateId: ({ entry }) => entry,
|
|
473
526
|
}),
|
|
474
527
|
});
|
|
@@ -482,7 +535,7 @@ import { glob } from "astro/loaders";
|
|
|
482
535
|
const docs = defineCollection({
|
|
483
536
|
loader: glob({
|
|
484
537
|
pattern: ${JSON.stringify(docsPattern)},
|
|
485
|
-
base: ${JSON.stringify(collectionBase)},
|
|
538
|
+
base: ${JSON.stringify(astroGlobBase(collectionBase))},
|
|
486
539
|
generateId: ({ entry }) => entry,
|
|
487
540
|
}),
|
|
488
541
|
});
|
|
@@ -978,6 +1031,7 @@ export const catchAllPageTemplate = (options: {
|
|
|
978
1031
|
// Generated by Blume. Do not edit.
|
|
979
1032
|
import { getEntry, render } from "astro:content";
|
|
980
1033
|
import RootLayout from "blume/components/layout/RootLayout.astro";
|
|
1034
|
+
import { withBase } from "blume/components/islands/base-path.ts";
|
|
981
1035
|
import { resolveSlot } from "blume/components/layout/overrides.ts";
|
|
982
1036
|
${askImport}
|
|
983
1037
|
import Accordion from "blume/components/content/Accordion.astro";
|
|
@@ -1103,13 +1157,16 @@ const ogPath = data.config.og.enabled
|
|
|
1103
1157
|
? \`/og/\${route === "/" ? "index" : route.slice(1)}.png\`
|
|
1104
1158
|
: null;
|
|
1105
1159
|
const ogRel = seo.image ?? ogPath;
|
|
1106
|
-
//
|
|
1107
|
-
//
|
|
1160
|
+
// Absolute URLs also carry the deployment base (the page is served under it):
|
|
1161
|
+
// \`site + base + path\`. Only absolutize root-relative paths: \`seo.image\` may be
|
|
1162
|
+
// an external URL, which passes through verbatim (mirrors PageLayout).
|
|
1108
1163
|
const ogImage =
|
|
1109
|
-
ogRel && base && ogRel.startsWith("/") ? \`\${base}\${ogRel}\` : ogRel;
|
|
1164
|
+
ogRel && base && ogRel.startsWith("/") ? \`\${base}\${withBase(ogRel)}\` : ogRel;
|
|
1110
1165
|
|
|
1166
|
+
const basedRoute = withBase(route);
|
|
1111
1167
|
const canonical =
|
|
1112
|
-
seo.canonical ??
|
|
1168
|
+
seo.canonical ??
|
|
1169
|
+
(base ? \`\${base}\${basedRoute === "/" ? "" : basedRoute}\` : null);
|
|
1113
1170
|
|
|
1114
1171
|
// Locale resolution. With i18n on, pick the active locale's nav + dictionary,
|
|
1115
1172
|
// build hreflang alternates, and derive the language-switcher targets.
|
|
@@ -1142,7 +1199,10 @@ const contentLocale =
|
|
|
1142
1199
|
const contentDir = i18n
|
|
1143
1200
|
? (i18n.locales.find((l) => l.code === contentLocale)?.dir ?? "ltr")
|
|
1144
1201
|
: "ltr";
|
|
1145
|
-
const absolute = (path) =>
|
|
1202
|
+
const absolute = (path) => {
|
|
1203
|
+
const p = withBase(path);
|
|
1204
|
+
return base + (p === "/" ? "" : p);
|
|
1205
|
+
};
|
|
1146
1206
|
|
|
1147
1207
|
const localeAlternates =
|
|
1148
1208
|
i18n && base
|
|
@@ -1575,15 +1635,31 @@ import Example from ${JSON.stringify(spec.file)};
|
|
|
1575
1635
|
<Example ${exampleDirective(spec)}{...Astro.props}><slot /></Example>
|
|
1576
1636
|
`;
|
|
1577
1637
|
|
|
1638
|
+
/**
|
|
1639
|
+
* The route prefix `<Component />` preview frames are served under:
|
|
1640
|
+
* `{basePath}/blume-examples/<example path>`. `deployment.base` is layered on
|
|
1641
|
+
* top by Astro (components apply it with `withBase`).
|
|
1642
|
+
*/
|
|
1643
|
+
export const examplesRouteBase = (basePath: string): string =>
|
|
1644
|
+
`${basePath}/blume-examples`;
|
|
1645
|
+
|
|
1578
1646
|
/**
|
|
1579
1647
|
* Generate `.blume/src/generated/examples.ts` — a map of example path to its live
|
|
1580
|
-
* wrapper component plus raw source and language for the code tab
|
|
1581
|
-
* shipped `Component.astro`
|
|
1648
|
+
* wrapper component plus raw source and language for the code tab, and the route
|
|
1649
|
+
* base preview iframes point at. Reached by the shipped `Component.astro` and the
|
|
1650
|
+
* generated preview page via the `blume:examples` alias. Always written (an
|
|
1582
1651
|
* empty object when there are no examples) so the alias resolves.
|
|
1583
1652
|
*/
|
|
1584
|
-
export const exampleMapTemplate = (
|
|
1653
|
+
export const exampleMapTemplate = (
|
|
1654
|
+
specs: ExampleSpec[],
|
|
1655
|
+
basePath: string
|
|
1656
|
+
): string => {
|
|
1657
|
+
const base = `export const examplesBase = ${JSON.stringify(
|
|
1658
|
+
examplesRouteBase(basePath)
|
|
1659
|
+
)};`;
|
|
1585
1660
|
if (specs.length === 0) {
|
|
1586
1661
|
return `// Generated by Blume. Do not edit.
|
|
1662
|
+
${base}
|
|
1587
1663
|
export const examples = {};
|
|
1588
1664
|
`;
|
|
1589
1665
|
}
|
|
@@ -1603,12 +1679,83 @@ export const examples = {};
|
|
|
1603
1679
|
.join("\n");
|
|
1604
1680
|
return `// Generated by Blume. Do not edit.
|
|
1605
1681
|
${imports}
|
|
1682
|
+
${base}
|
|
1606
1683
|
export const examples = {
|
|
1607
1684
|
${entries}
|
|
1608
1685
|
};
|
|
1609
1686
|
`;
|
|
1610
1687
|
};
|
|
1611
1688
|
|
|
1689
|
+
/**
|
|
1690
|
+
* Generate the `<Component />` preview page — one prerendered route per
|
|
1691
|
+
* example under `{basePath}/blume-examples/`, rendered as a bare document
|
|
1692
|
+
* (no layout) that an iframe in the docs page embeds. The iframe boundary is
|
|
1693
|
+
* what isolates examples from the docs CSS: the only stylesheet here is the
|
|
1694
|
+
* example entry (`blume:examples-theme` — Tailwind, the Blume tokens, and the
|
|
1695
|
+
* user's configured examples css), so users can preview components styled by
|
|
1696
|
+
* their own design system (e.g. shadcn) with no prose styles bleeding in.
|
|
1697
|
+
*
|
|
1698
|
+
* The inline script mirrors the docs theme before first paint — same-document
|
|
1699
|
+
* reads of the parent's `data-theme` (same origin) with a MutationObserver for
|
|
1700
|
+
* live toggles — and sets both `data-theme` and a `dark` class so either
|
|
1701
|
+
* dark-mode convention works in user CSS. When the page is opened directly
|
|
1702
|
+
* (no parent), it falls back to the stored preference, then the OS setting.
|
|
1703
|
+
*/
|
|
1704
|
+
export const examplesPageTemplate = (): string =>
|
|
1705
|
+
`---
|
|
1706
|
+
// Generated by Blume. Do not edit.
|
|
1707
|
+
import { examples } from "blume:examples";
|
|
1708
|
+
import "blume:examples-theme";
|
|
1709
|
+
|
|
1710
|
+
// Prerendered even in server output, like docs content.
|
|
1711
|
+
export const prerender = true;
|
|
1712
|
+
|
|
1713
|
+
export const getStaticPaths = () =>
|
|
1714
|
+
Object.keys(examples).map((path) => ({ params: { path } }));
|
|
1715
|
+
|
|
1716
|
+
const { path } = Astro.params;
|
|
1717
|
+
const entry = examples[path];
|
|
1718
|
+
const Example = entry.Component;
|
|
1719
|
+
---
|
|
1720
|
+
|
|
1721
|
+
<html lang="en">
|
|
1722
|
+
<head>
|
|
1723
|
+
<meta charset="utf-8" />
|
|
1724
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1725
|
+
<meta name="robots" content="noindex" />
|
|
1726
|
+
<title>{path}</title>
|
|
1727
|
+
<script is:inline>
|
|
1728
|
+
(() => {
|
|
1729
|
+
const root = document.documentElement;
|
|
1730
|
+
const apply = (theme) => {
|
|
1731
|
+
root.dataset.theme = theme;
|
|
1732
|
+
root.classList.toggle("dark", theme === "dark");
|
|
1733
|
+
};
|
|
1734
|
+
const stored = () =>
|
|
1735
|
+
localStorage.getItem("blume-theme") ??
|
|
1736
|
+
(matchMedia("(prefers-color-scheme: dark)").matches
|
|
1737
|
+
? "dark"
|
|
1738
|
+
: "light");
|
|
1739
|
+
try {
|
|
1740
|
+
const host = window.parent.document.documentElement;
|
|
1741
|
+
apply(host.dataset.theme ?? stored());
|
|
1742
|
+
new MutationObserver(() => {
|
|
1743
|
+
apply(host.dataset.theme ?? stored());
|
|
1744
|
+
}).observe(host, { attributeFilter: ["data-theme"] });
|
|
1745
|
+
} catch {
|
|
1746
|
+
apply(stored());
|
|
1747
|
+
}
|
|
1748
|
+
})();
|
|
1749
|
+
</script>
|
|
1750
|
+
</head>
|
|
1751
|
+
<!-- Flex + margin:auto centers the example and, unlike place-items, keeps
|
|
1752
|
+
the top edge reachable when the example outgrows the frame. -->
|
|
1753
|
+
<body style="display:flex;min-height:100svh;padding:1.5rem">
|
|
1754
|
+
<div style="margin:auto"><Example /></div>
|
|
1755
|
+
</body>
|
|
1756
|
+
</html>
|
|
1757
|
+
`;
|
|
1758
|
+
|
|
1612
1759
|
/** Generate `.blume/src/env.d.ts`. */
|
|
1613
1760
|
export const envTemplate =
|
|
1614
1761
|
(): string => `/// <reference path="../.astro/types.d.ts" />
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
surfaceAdapterOutput,
|
|
17
17
|
} from "../../deploy/adapter-output.ts";
|
|
18
18
|
import {
|
|
19
|
+
applyBaseToRedirects,
|
|
19
20
|
buildNetlifyRedirects,
|
|
20
21
|
buildRedirectManifest,
|
|
21
22
|
buildVercelConfig,
|
|
@@ -30,18 +31,27 @@ import { prepareProject } from "../prepare.ts";
|
|
|
30
31
|
|
|
31
32
|
const ADAPTERS = ["vercel", "node", "netlify", "cloudflare"] as const;
|
|
32
33
|
|
|
34
|
+
const BUDGET_JS = "budget-js";
|
|
35
|
+
const BUDGET_CSS = "budget-css";
|
|
36
|
+
|
|
37
|
+
interface BudgetArgs {
|
|
38
|
+
"budget-css"?: string;
|
|
39
|
+
"budget-js"?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
33
42
|
/**
|
|
34
43
|
* Reject a non-numeric performance budget. `Number("250kb")` is `NaN` and
|
|
35
44
|
* `total > NaN` is always false, so a typo'd flag would silently pass the gate;
|
|
36
45
|
* fail up front instead.
|
|
37
46
|
*/
|
|
38
|
-
const validateBudgetFlags = (args: {
|
|
39
|
-
|
|
40
|
-
"budget-js"?: string;
|
|
41
|
-
}): void => {
|
|
42
|
-
for (const flag of ["budget-js", "budget-css"] as const) {
|
|
47
|
+
const validateBudgetFlags = (args: BudgetArgs): void => {
|
|
48
|
+
for (const flag of [BUDGET_JS, BUDGET_CSS] as const) {
|
|
43
49
|
const value = args[flag];
|
|
44
|
-
|
|
50
|
+
const parsed = Number(value);
|
|
51
|
+
// Equivalent to `!(parsed > 0)` but without the inverted check: this must
|
|
52
|
+
// also reject `NaN` (a typo'd flag like "250kb"), which `parsed <= 0` alone
|
|
53
|
+
// would let through since `NaN <= 0` is false.
|
|
54
|
+
if (value !== undefined && (Number.isNaN(parsed) || parsed <= 0)) {
|
|
45
55
|
logger.error(
|
|
46
56
|
`Invalid --${flag} "${value}" (expected a positive number of kB).`
|
|
47
57
|
);
|
|
@@ -59,7 +69,7 @@ const emitRedirectFiles = async (
|
|
|
59
69
|
config: ResolvedConfig,
|
|
60
70
|
distDir: string
|
|
61
71
|
): Promise<void> => {
|
|
62
|
-
const
|
|
72
|
+
const redirects = applyBaseToRedirects(config.redirects, config.basePath);
|
|
63
73
|
if (redirects.length === 0 || config.deployment.output !== "static") {
|
|
64
74
|
return;
|
|
65
75
|
}
|
|
@@ -82,10 +92,13 @@ const emitRedirectFiles = async (
|
|
|
82
92
|
logger.success(`Emitted redirect files for ${redirects.length} redirect(s)`);
|
|
83
93
|
};
|
|
84
94
|
|
|
85
|
-
const formatBytes = (bytes: number): string =>
|
|
86
|
-
bytes < 1024
|
|
87
|
-
|
|
88
|
-
|
|
95
|
+
const formatBytes = (bytes: number): string => {
|
|
96
|
+
if (bytes < 1024) {
|
|
97
|
+
return `${bytes} B`;
|
|
98
|
+
}
|
|
99
|
+
const digits = bytes < 1024 * 100 ? 1 : 0;
|
|
100
|
+
return `${(bytes / 1024).toFixed(digits)} kB`;
|
|
101
|
+
};
|
|
89
102
|
|
|
90
103
|
/** Sizes of `dist/_astro/*.<ext>`, largest first (empty when none exist). */
|
|
91
104
|
const astroAssets = async (
|
|
@@ -144,14 +157,14 @@ const reportBundleSizes = async (distDir: string): Promise<void> => {
|
|
|
144
157
|
*/
|
|
145
158
|
const enforceBudget = async (
|
|
146
159
|
distDir: string,
|
|
147
|
-
args:
|
|
160
|
+
args: BudgetArgs
|
|
148
161
|
): Promise<"fail" | "pass" | "skip"> => {
|
|
149
162
|
const checks: { ext: string; limitKb: number; name: string }[] = [
|
|
150
|
-
...(args[
|
|
151
|
-
? [{ ext: "js", limitKb: Number(args[
|
|
163
|
+
...(args[BUDGET_JS]
|
|
164
|
+
? [{ ext: "js", limitKb: Number(args[BUDGET_JS]), name: "JavaScript" }]
|
|
152
165
|
: []),
|
|
153
|
-
...(args[
|
|
154
|
-
? [{ ext: "css", limitKb: Number(args[
|
|
166
|
+
...(args[BUDGET_CSS]
|
|
167
|
+
? [{ ext: "css", limitKb: Number(args[BUDGET_CSS]), name: "CSS" }]
|
|
155
168
|
: []),
|
|
156
169
|
];
|
|
157
170
|
if (checks.length === 0) {
|
|
@@ -185,7 +198,7 @@ const enforceBudget = async (
|
|
|
185
198
|
const publishBuildArtifacts = async (
|
|
186
199
|
project: BlumeProject,
|
|
187
200
|
distDir: string,
|
|
188
|
-
args: { analyze?: boolean
|
|
201
|
+
args: { analyze?: boolean } & BudgetArgs
|
|
189
202
|
): Promise<void> => {
|
|
190
203
|
if (project.config.search.provider === "pagefind") {
|
|
191
204
|
logger.start("Building search index");
|
|
@@ -280,11 +293,11 @@ export const buildCommand = defineCommand({
|
|
|
280
293
|
description: "Base path the site is served under (e.g. /docs).",
|
|
281
294
|
type: "string",
|
|
282
295
|
},
|
|
283
|
-
|
|
296
|
+
[BUDGET_CSS]: {
|
|
284
297
|
description: "Fail if total client CSS exceeds this many kB.",
|
|
285
298
|
type: "string",
|
|
286
299
|
},
|
|
287
|
-
|
|
300
|
+
[BUDGET_JS]: {
|
|
288
301
|
description: "Fail if total client JavaScript exceeds this many kB.",
|
|
289
302
|
type: "string",
|
|
290
303
|
},
|
package/src/cli/commands/dev.ts
CHANGED
|
@@ -18,6 +18,20 @@ import {
|
|
|
18
18
|
import { logger } from "../log.ts";
|
|
19
19
|
import { prepareProject } from "../prepare.ts";
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* A fingerprint of the route set: the sorted `path entryId` pairs. It changes
|
|
23
|
+
* when a page is added, removed, or renamed (a folder rename shifts many at
|
|
24
|
+
* once) but stays equal across pure body edits — so the dev loop can tell a
|
|
25
|
+
* "structural" change (needs a cold restart) from a hot-reloadable one.
|
|
26
|
+
*/
|
|
27
|
+
const routeSignature = (
|
|
28
|
+
routes: readonly { entryId: string; path: string }[]
|
|
29
|
+
): string =>
|
|
30
|
+
routes
|
|
31
|
+
.map((route) => `${route.path} ${route.entryId}`)
|
|
32
|
+
.toSorted()
|
|
33
|
+
.join("\n");
|
|
34
|
+
|
|
21
35
|
export const devCommand = defineCommand({
|
|
22
36
|
args: {
|
|
23
37
|
"content-dir": {
|
|
@@ -84,15 +98,18 @@ export const devCommand = defineCommand({
|
|
|
84
98
|
strict: args.strict,
|
|
85
99
|
});
|
|
86
100
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
101
|
+
// A factory so `runRegenerate` can recreate the server on a structural
|
|
102
|
+
// (route-set) change: only a cold container re-globs Astro's content store,
|
|
103
|
+
// which its in-place config restart doesn't. `open` is honoured on first
|
|
104
|
+
// boot only — a restart must not reopen the browser.
|
|
105
|
+
const createServer = (listenPort: number | undefined, open: boolean) =>
|
|
106
|
+
dev({
|
|
107
|
+
logLevel: args.debug ? "debug" : "info",
|
|
108
|
+
root: project.context.outDir,
|
|
109
|
+
server: { host: args.host ?? false, open, port: listenPort },
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
let server = await createServer(explicitPort, args.open ?? false);
|
|
96
113
|
|
|
97
114
|
// Vite bumps to the next free port when the default is taken, so record
|
|
98
115
|
// the port the server actually bound — the lock's URL is what a refused
|
|
@@ -109,11 +126,18 @@ export const devCommand = defineCommand({
|
|
|
109
126
|
// (and its HMR channel) is up.
|
|
110
127
|
showBlumeErrorOverlay(project.diagnostics);
|
|
111
128
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
129
|
+
let lastSignature = routeSignature(project.manifest.routes);
|
|
130
|
+
|
|
131
|
+
// Watch user inputs and regenerate the runtime data on change. A body edit
|
|
132
|
+
// hot-reloads via Vite (fast path). A route-set change instead forces a cold
|
|
133
|
+
// server restart: Astro's in-place content sync never re-globs on a Blume
|
|
134
|
+
// route change (it strips `integrations` from its cache digest) and its glob
|
|
135
|
+
// watcher misses directory renames, so a renamed page 404s (`getEntry` reads
|
|
136
|
+
// a stale in-memory store) until the server is restarted. We restart it
|
|
137
|
+
// ourselves — stop, regenerate while down (no watcher races), then bring up
|
|
138
|
+
// a fresh container whose cold sync re-globs everything. `coalescedRunner`
|
|
139
|
+
// single-flights the scan so a burst of watch events can never stack
|
|
140
|
+
// overlapping regenerations (piled-up scans exhaust the heap).
|
|
117
141
|
const runRegenerate = coalescedRunner(async () => {
|
|
118
142
|
try {
|
|
119
143
|
const next = await scanProject(root, {
|
|
@@ -122,7 +146,16 @@ export const devCommand = defineCommand({
|
|
|
122
146
|
overrides,
|
|
123
147
|
preview,
|
|
124
148
|
});
|
|
125
|
-
|
|
149
|
+
const nextSignature = routeSignature(next.manifest.routes);
|
|
150
|
+
const structural = nextSignature !== lastSignature;
|
|
151
|
+
lastSignature = nextSignature;
|
|
152
|
+
if (structural) {
|
|
153
|
+
await server.stop();
|
|
154
|
+
await generateRuntime(next);
|
|
155
|
+
server = await createServer(boundPort, false);
|
|
156
|
+
} else {
|
|
157
|
+
await generateRuntime(next);
|
|
158
|
+
}
|
|
126
159
|
// Surface any content/config errors in the browser overlay too.
|
|
127
160
|
showBlumeErrorOverlay(next.diagnostics);
|
|
128
161
|
} catch (error) {
|
|
@@ -34,8 +34,8 @@ const minSupportedNode = (): string => {
|
|
|
34
34
|
};
|
|
35
35
|
|
|
36
36
|
const versionBelow = (current: string, minimum: string): boolean => {
|
|
37
|
-
const a = current.split(".").map((part) =>
|
|
38
|
-
const b = minimum.split(".").map((part) =>
|
|
37
|
+
const a = current.split(".").map((part) => Math.trunc(Number(part)));
|
|
38
|
+
const b = minimum.split(".").map((part) => Math.trunc(Number(part)));
|
|
39
39
|
for (let i = 0; i < 3; i += 1) {
|
|
40
40
|
const delta = (a[i] ?? 0) - (b[i] ?? 0);
|
|
41
41
|
if (delta !== 0) {
|
|
@@ -47,6 +47,7 @@ export const validateCommand = defineCommand({
|
|
|
47
47
|
const publicDir = join(root, "public");
|
|
48
48
|
diagnostics.push(
|
|
49
49
|
...(await validateLinks(project.graph, {
|
|
50
|
+
basePath: project.config.basePath,
|
|
50
51
|
checkExternal: Boolean(args.external),
|
|
51
52
|
publicDir: existsSync(publicDir) ? publicDir : null,
|
|
52
53
|
redirects: project.config.redirects,
|
package/src/cli/dev-lock.ts
CHANGED
|
@@ -117,6 +117,30 @@ const ownsLock = (outDir: string): boolean => {
|
|
|
117
117
|
}
|
|
118
118
|
};
|
|
119
119
|
|
|
120
|
+
/**
|
|
121
|
+
* One atomic claim attempt. Returns `true` when this process wins the `wx`
|
|
122
|
+
* write, `false` when the dir held a stale or our-own leftover lock (now
|
|
123
|
+
* cleared, so the caller should retry). Throws {@link DevLockHeldError} when a
|
|
124
|
+
* live foreign holder owns the dir.
|
|
125
|
+
*/
|
|
126
|
+
const tryClaimLock = (outDir: string, port?: number): boolean => {
|
|
127
|
+
try {
|
|
128
|
+
writeFileSync(lockPath(outDir), lockPayload(port), { flag: "wx" });
|
|
129
|
+
return true;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
const existing = readDevLock(outDir);
|
|
135
|
+
if (existing && existing.pid !== process.pid) {
|
|
136
|
+
throw new DevLockHeldError(existing);
|
|
137
|
+
}
|
|
138
|
+
// Stale or our own leftover: clear it and race for the claim again.
|
|
139
|
+
rmSync(lockPath(outDir), { force: true });
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
120
144
|
/**
|
|
121
145
|
* Write the current process's dev lock into `outDir` and return a release
|
|
122
146
|
* function. The claim is atomic (`wx`): two `blume dev` processes racing the
|
|
@@ -128,21 +152,8 @@ const ownsLock = (outDir: string): boolean => {
|
|
|
128
152
|
*/
|
|
129
153
|
export const acquireDevLock = (outDir: string, port?: number): (() => void) => {
|
|
130
154
|
mkdirSync(outDir, { recursive: true });
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
}
|
|
155
|
+
while (!tryClaimLock(outDir, port)) {
|
|
156
|
+
// Retry until we win the claim or a live holder makes us throw.
|
|
146
157
|
}
|
|
147
158
|
let released = false;
|
|
148
159
|
return () => {
|