blume 0.1.1 → 0.1.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.
@@ -1457,6 +1457,14 @@ export declare const blumeConfigSchema: z.ZodObject<{
1457
1457
  site?: string | undefined;
1458
1458
  }>>;
1459
1459
  description: z.ZodOptional<z.ZodString>;
1460
+ /**
1461
+ * Directory (relative to the project root) that `<Component path>` resolves
1462
+ * live previews and their source against. Defaults to `examples`; point it
1463
+ * elsewhere when examples live outside a top-level `examples/` — e.g. a
1464
+ * registry layout like `registry/<pkg>`, where a `<Component path>` key is
1465
+ * then relative to that directory.
1466
+ */
1467
+ examples: z.ZodDefault<z.ZodString>;
1460
1468
  export: z.ZodDefault<z.ZodEffects<z.ZodUnion<[z.ZodBoolean, z.ZodObject<{
1461
1469
  epub: z.ZodDefault<z.ZodBoolean>;
1462
1470
  pdf: z.ZodDefault<z.ZodBoolean>;
@@ -2898,6 +2906,7 @@ export declare const blumeConfigSchema: z.ZodObject<{
2898
2906
  base?: string | undefined;
2899
2907
  site?: string | undefined;
2900
2908
  };
2909
+ examples: string;
2901
2910
  export: {
2902
2911
  epub: boolean;
2903
2912
  pdf: boolean;
@@ -3299,6 +3308,7 @@ export declare const blumeConfigSchema: z.ZodObject<{
3299
3308
  output?: "static" | "server" | undefined;
3300
3309
  site?: string | undefined;
3301
3310
  } | undefined;
3311
+ examples?: string | undefined;
3302
3312
  export?: boolean | {
3303
3313
  epub?: boolean | undefined;
3304
3314
  pdf?: boolean | undefined;
@@ -573,6 +573,22 @@ a live preview alongside its highlighted source, in tabs. Point it at a file wit
573
573
  examples are all supported; framework examples hydrate, Astro ones render
574
574
  statically. It keeps the preview and the code in sync from a single file.
575
575
 
576
+ The directory is configurable — set `examples` in `blume.config.ts` when your
577
+ examples live elsewhere (e.g. a registry layout). `path` is always relative to
578
+ it:
579
+
580
+ ```ts
581
+ // blume.config.ts
582
+ export default defineConfig({
583
+ examples: "registry/files-sdk",
584
+ });
585
+ ```
586
+
587
+ ```astro
588
+ <!-- registry/files-sdk/file-list/basic.tsx -->
589
+ <Component path="file-list/basic" />
590
+ ```
591
+
576
592
  <Component path="counter" />
577
593
 
578
594
  ```astro
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blume",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Documentation that's fast, AI-ready, and zero-config.",
5
5
  "keywords": [
6
6
  "astro",
@@ -44,18 +44,20 @@ const FRAMEWORK_BY_EXT: Record<string, ExampleFramework> = {
44
44
  const EXAMPLE_FILE = /\.(?<ext>astro|jsx|svelte|tsx|vue)$/u;
45
45
 
46
46
  /**
47
- * Discover preview examples under `<root>/examples`. Every `.astro`/`.tsx`/
48
- * `.jsx`/`.vue`/`.svelte` file becomes addressable by `<Component path="...">`,
49
- * where the path is the file's location under `examples/` without its extension
50
- * (e.g. `forms/login.tsx` → `forms/login`). Discovery is path-based (a glob),
51
- * so no example code is executed. Framework examples carry a hydration mode
52
- * (default `client:visible`, overridable via `export const client`); `.astro`
53
- * examples render statically with no client directive.
47
+ * Discover preview examples under `<root>/<subdir>` (the `examples` config,
48
+ * default `examples`). Every `.astro`/`.tsx`/`.jsx`/`.vue`/`.svelte` file
49
+ * becomes addressable by `<Component path="...">`, where the path is the file's
50
+ * location under that directory without its extension (e.g. `forms/login.tsx` →
51
+ * `forms/login`). Discovery is path-based (a glob), so no example code is
52
+ * executed. Framework examples carry a hydration mode (default `client:visible`,
53
+ * overridable via `export const client`); `.astro` examples render statically
54
+ * with no client directive.
54
55
  */
55
56
  export const discoverExamples = async (
56
- root: string
57
+ root: string,
58
+ subdir = "examples"
57
59
  ): Promise<ExampleDiscovery> => {
58
- const dir = join(root, "examples");
60
+ const dir = join(root, subdir);
59
61
  const matches = await glob(["**/*.{astro,jsx,svelte,tsx,vue}"], {
60
62
  absolute: true,
61
63
  cwd: dir,
@@ -11,7 +11,7 @@ import {
11
11
  import { createRequire } from "node:module";
12
12
  import { pathToFileURL } from "node:url";
13
13
 
14
- import { dirname, join, normalize, relative } from "pathe";
14
+ import { basename, dirname, join, normalize, relative } from "pathe";
15
15
  import { glob } from "tinyglobby";
16
16
 
17
17
  import { resolveAskBackend } from "../ai/ask.ts";
@@ -128,6 +128,61 @@ export const blumeDepsDir = (pkgDir: string = packageRoot()): string | null => {
128
128
  return candidates.find((dir) => existsSync(join(dir, "astro"))) ?? null;
129
129
  };
130
130
 
131
+ /**
132
+ * Point `link` at Blume's dependency directory via a `node_modules` junction,
133
+ * replacing a stale junction we own and leaving a real directory untouched.
134
+ *
135
+ * `lstat`, not `existsSync`, so a broken junction (target since moved) is still
136
+ * detected — `existsSync` follows the link and reports a dangling one as absent.
137
+ */
138
+ const linkDepsJunction = async (
139
+ link: string,
140
+ depsDir: string
141
+ ): Promise<void> => {
142
+ const existing = await lstat(link).catch(() => null);
143
+ if (existing) {
144
+ if (!existing.isSymbolicLink()) {
145
+ return;
146
+ }
147
+ await rm(link, { force: true });
148
+ }
149
+ await mkdir(dirname(link), { recursive: true });
150
+ await symlink(depsDir, link, "junction");
151
+ };
152
+
153
+ /** Read the `version` field of a `package.json`, or null when unreadable. */
154
+ const readPkgVersion = (pkgJsonPath: string | null): string | null => {
155
+ if (!pkgJsonPath) {
156
+ return null;
157
+ }
158
+ try {
159
+ return JSON.parse(readFileSync(pkgJsonPath, "utf-8")).version ?? null;
160
+ } catch {
161
+ return null;
162
+ }
163
+ };
164
+
165
+ /**
166
+ * Build the diagnostic for a split-layout Astro conflict that a symlink can't
167
+ * repair: a different Astro is hoisted to the project root, shadowing Blume's,
168
+ * and `@astrojs/mdx` binds to the wrong copy. `blumeAstroPkg`/`shadowAstroPkg`
169
+ * are the resolved `astro/package.json` paths for Blume's set and the one the
170
+ * runtime actually resolves.
171
+ */
172
+ const astroConflictWarning = (
173
+ blumeAstroPkg: string | null,
174
+ shadowAstroPkg: string | null
175
+ ): string => {
176
+ const blume = readPkgVersion(blumeAstroPkg);
177
+ const shadow = readPkgVersion(shadowAstroPkg);
178
+ const versions =
179
+ blume && shadow
180
+ ? `astro@${shadow} shadowing Blume's astro@${blume}`
181
+ : "a second copy of Astro shadowing Blume's";
182
+ const pin = blume ?? "<Blume's astro version>";
183
+ return `Astro version conflict: another dependency hoisted ${versions} to the project root, so @astrojs/mdx binds to the wrong copy and the build fails on a missing export (e.g. "chunkToString"). A single symlink can't reconcile a split install — pin Blume's Astro by adding a package.json "overrides" (npm/bun/pnpm) or "resolutions" (yarn) entry { "astro": "${pin}" }, then reinstall. Run \`npm ls astro\` to find the dependency pulling the older copy.`;
184
+ };
185
+
131
186
  /**
132
187
  * Make the generated runtime resolve Astro and its integrations against Blume's
133
188
  * own dependency set. Two failure modes this repairs:
@@ -146,40 +201,81 @@ export const blumeDepsDir = (pkgDir: string = packageRoot()): string | null => {
146
201
  * are a *co-located, consistent* set (astro beside the `@astrojs/mdx` that binds
147
202
  * to it). A split layout — an integration hoisted away from a conflicting astro
148
203
  * — can't be made consistent by a single symlink and needs a root `overrides`/
149
- * `resolutions` pin instead, so we leave it untouched rather than half-fix it.
204
+ * `resolutions` pin instead. We can't fix that from `.blume/`, so we return a
205
+ * diagnostic naming the conflict rather than silently shipping a runtime that
206
+ * crashes downstream. Returns the warning, or null when nothing needs saying.
150
207
  */
151
208
  export const ensureDepsLink = async (
152
209
  outDir: string,
153
210
  pkgDir: string = packageRoot()
154
- ): Promise<void> => {
211
+ ): Promise<string | null> => {
155
212
  const depsDir = blumeDepsDir(pkgDir);
156
- if (!depsDir || !existsSync(join(depsDir, "@astrojs", "mdx"))) {
157
- return;
213
+ if (!depsDir) {
214
+ return null;
158
215
  }
159
216
  // Already correct when `.blume/` resolves the very same astro Blume's deps
160
- // provide — the clean hoisted case. Otherwise (unreachable, or a different
161
- // astro shadowing Blume's) link Blume's deps in.
217
+ // provide — the clean hoisted case, nothing to do.
162
218
  const blumeAstro = resolvedAstroPath(depsDir);
163
- if (blumeAstro && resolvedAstroPath(outDir) === blumeAstro) {
164
- return;
219
+ const outDirAstro = resolvedAstroPath(outDir);
220
+ if (blumeAstro && outDirAstro === blumeAstro) {
221
+ return null;
165
222
  }
166
- const link = join(outDir, "node_modules");
167
- // `lstat`, not `existsSync`, so a broken junction (target since moved) is
168
- // still detected `existsSync` follows the link and reports a dangling one
169
- // as absent. Reaching here means `outDir` doesn't resolve Blume's astro, so
170
- // any existing link is stale: replace a link we own (a junction/symlink) and
171
- // leave a real directory untouched.
172
- const existing = await lstat(link).catch(() => null);
173
- if (existing) {
174
- if (!existing.isSymbolicLink()) {
175
- return;
176
- }
177
- await rm(link, { force: true });
223
+ // A co-located, consistent set (astro beside the @astrojs/mdx that binds to
224
+ // it) can be linked in wholesale; this repairs the unreachable and the
225
+ // repairable-conflict cases. Any existing link here is stale and gets
226
+ // replaced.
227
+ if (existsSync(join(depsDir, "@astrojs", "mdx"))) {
228
+ await linkDepsJunction(join(outDir, "node_modules"), depsDir);
229
+ return null;
178
230
  }
179
- await mkdir(outDir, { recursive: true });
180
- await symlink(depsDir, link, "junction");
231
+ // Split layout: Blume's astro is nested (a conflicting astro took the root
232
+ // spot) but @astrojs/mdx hoisted away from it, binding to the shadow. Only a
233
+ // root pin fixes this — surface it.
234
+ return astroConflictWarning(blumeAstro, outDirAstro);
181
235
  };
182
236
 
237
+ /**
238
+ * Vite plugin that makes Blume's externalized runtime deps (zod, shiki, sharp,
239
+ * `@takumi-rs/core`, …) resolvable when Astro executes the static prerender
240
+ * bundle under an isolated linker (Bun's `isolated` mode, pnpm).
241
+ *
242
+ * Astro's static build emits a self-contained SSR bundle to
243
+ * `<outDir>/.prerender/` and `import()`s it in-process to generate the HTML.
244
+ * That bundle externalizes Blume's render-time deps, so Node resolves them at
245
+ * prerender time by walking up from `.prerender/chunks/*.mjs`. {@link
246
+ * ensureDepsLink} only repairs resolution rooted at `.blume/`; `.prerender/`
247
+ * lives under `dist/`, a separate tree an isolated linker never hoists Blume's
248
+ * deps into — so the import dies with `Cannot find package 'zod'`. We drop the
249
+ * same `node_modules` junction into the prerender root, mirroring
250
+ * `.blume/node_modules`, so every externalized specifier — native bindings
251
+ * included, which can't be bundled — resolves. Astro deletes `.prerender/` once
252
+ * generation finishes (and the junction with it: `fs.rm` unlinks symlinks, it
253
+ * never follows them), so nothing leaks into the published `dist/`.
254
+ *
255
+ * Keyed off the output dir's basename (`.prerender`) — the name Astro 7 gives
256
+ * the prerender build for both static (`<outDir>/.prerender/`) and server
257
+ * (`<build.server>/.prerender/`) output — so it fires for exactly that build.
258
+ * Inert in dev, where there is no build and `writeBundle` never runs.
259
+ */
260
+ export const prerenderDepsPlugin = (
261
+ pkgDir: string = packageRoot()
262
+ ): {
263
+ name: string;
264
+ writeBundle: (options: { dir?: string }) => Promise<void>;
265
+ } => ({
266
+ name: "blume:prerender-deps",
267
+ writeBundle: async (options) => {
268
+ if (!options.dir || basename(options.dir) !== ".prerender") {
269
+ return;
270
+ }
271
+ const depsDir = blumeDepsDir(pkgDir);
272
+ if (!depsDir) {
273
+ return;
274
+ }
275
+ await linkDepsJunction(join(options.dir, "node_modules"), depsDir);
276
+ },
277
+ });
278
+
183
279
  /** Astro integration package each non-React island framework needs installed. */
184
280
  const ISLAND_FRAMEWORK_DEPS: Record<string, string> = {
185
281
  svelte: "@astrojs/svelte",
@@ -720,7 +816,7 @@ export const generateRuntime = async (
720
816
  return writeIfChanged(path, content);
721
817
  };
722
818
 
723
- await ensureDepsLink(out);
819
+ const depsLinkWarning = await ensureDepsLink(out);
724
820
 
725
821
  const askEnabled = config.ai.ask?.enabled ?? false;
726
822
  const exportPdf = config.export.pdf;
@@ -733,7 +829,7 @@ export const generateRuntime = async (
733
829
  detectNeedsReact(context.root),
734
830
  readOptional(context.themeFile),
735
831
  discoverIslands(context.root),
736
- discoverExamples(context.root),
832
+ discoverExamples(context.root, config.examples),
737
833
  ]);
738
834
  // Each island/example framework enables its Astro renderer. React also
739
835
  // switches on for any project `.tsx`/`.jsx` and for Ask AI; Vue/Svelte are
@@ -952,6 +1048,7 @@ export const generateRuntime = async (
952
1048
  // API/AsyncAPI reference pages (Scalar). One self-contained page per source,
953
1049
  // mounted on its configured route and regenerated each run.
954
1050
  const warnings: string[] = [
1051
+ ...(depsLinkWarning ? [depsLinkWarning] : []),
955
1052
  ...mcp.warnings,
956
1053
  ...islandDiscovery.warnings,
957
1054
  ...exampleDiscovery.warnings,
@@ -1,4 +1,4 @@
1
- export { generateRuntime } from "./generate.ts";
1
+ export { generateRuntime, prerenderDepsPlugin } from "./generate.ts";
2
2
  export type { GenerateResult } from "./generate.ts";
3
3
  export { blumeIntegration } from "./integration.ts";
4
4
  export type { BlumeIntegrationOptions, BlumePageRoute } from "./integration.ts";
@@ -130,6 +130,42 @@ export const runtimeDependencies = (options: {
130
130
  * before a broader one (`@`); these follow Blume's `blume:*` aliases, which
131
131
  * never overlap with a project's.
132
132
  */
133
+ /**
134
+ * Blume's render-time dependencies, forced external on the build's SSR and
135
+ * static-prerender Vite environments.
136
+ *
137
+ * Two reasons a dep lands here:
138
+ * - `@takumi-rs/core` (OG image rendering) is a native NAPI addon that loads a
139
+ * platform-specific `.node` binding via `createRequire(import.meta.url)`.
140
+ * Bundling it relocates `import.meta.url` and breaks the binding lookup
141
+ * ("Cannot find native binding") on other platforms (e.g. the Linux CI
142
+ * runner), so it must resolve from `node_modules` at runtime instead.
143
+ * - The rest are pure-JS packages kept external so an isolated linker (Bun's
144
+ * `isolated` mode, pnpm) doesn't bundle their symlinked store copies. When
145
+ * Vite bundles such a package but leaves its own `node_modules` child
146
+ * external, that child surfaces as an unresolvable bare import in the
147
+ * prerender chunk (e.g. `batchwork` via `@astrojs/markdown-satteri`). Kept
148
+ * external, each package's transitive imports resolve relative to its real
149
+ * store location — reachable through the `node_modules` junction {@link
150
+ * prerenderDepsPlugin} drops beside the prerender bundle.
151
+ *
152
+ * Astro 7 configures externalization per Vite environment, so this must be
153
+ * applied to both `prerender` (static) and `ssr` (server) — a top-level
154
+ * `ssr.external` only reaches the latter.
155
+ */
156
+ const RENDER_EXTERNAL_DEPS = [
157
+ "@astrojs/markdown-satteri",
158
+ "@pierre/diffs",
159
+ "@shikijs/transformers",
160
+ "@takumi-rs/core",
161
+ "@takumi-rs/helpers",
162
+ "github-slugger",
163
+ "katex",
164
+ "shiki",
165
+ "simple-icons",
166
+ "zod",
167
+ ];
168
+
133
169
  const renderUserAliases = (
134
170
  aliases: Record<string, string> | undefined
135
171
  ): string =>
@@ -243,7 +279,7 @@ export const astroConfigTemplate = (options: {
243
279
  const svelteImport = needsSvelte
244
280
  ? `import svelte from "@astrojs/svelte";\n`
245
281
  : "";
246
- const blumeImport = `import { blumeIntegration } from "blume/astro";\n`;
282
+ const blumeImport = `import { blumeIntegration, prerenderDepsPlugin } from "blume/astro";\n`;
247
283
 
248
284
  // Twoslash runs first, before the always-on transformers, but only on fences
249
285
  // with the `twoslash` meta (explicitTrigger) — so it's opt-in per block with
@@ -305,19 +341,14 @@ export default defineConfig({
305
341
  },
306
342
  devToolbar: { enabled: false },
307
343
  vite: {
308
- plugins: [tailwindcss()],
309
- // @takumi-rs/core (OG image rendering) is a native NAPI addon that loads a
310
- // platform-specific .node binding via createRequire(import.meta.url). Astro's
311
- // build bundles it into the per-environment output by default, which
312
- // relocates import.meta.url and breaks the binding lookup ("Cannot find
313
- // native binding") on other platforms (e.g. the Linux CI runner). Astro 7
314
- // configures externalization per Vite environment, so it must be forced
315
- // external on the prerender (static) and ssr (server) environments -- a
316
- // top-level ssr.external only reaches the latter -- so the binding resolves
317
- // from node_modules at runtime instead.
344
+ plugins: [tailwindcss(), prerenderDepsPlugin()],
345
+ // Blume's render-time deps are forced external on both build environments so
346
+ // native bindings resolve at runtime and isolated linkers don't bundle
347
+ // symlinked store copies (which would surface their children as unresolvable
348
+ // imports). See RENDER_EXTERNAL_DEPS / prerenderDepsPlugin.
318
349
  environments: {
319
- prerender: { resolve: { external: ["@takumi-rs/core"] } },
320
- ssr: { resolve: { external: ["@takumi-rs/core"] } },
350
+ prerender: { resolve: { external: ${JSON.stringify(RENDER_EXTERNAL_DEPS)} } },
351
+ ssr: { resolve: { external: ${JSON.stringify(RENDER_EXTERNAL_DEPS)} } },
321
352
  },
322
353
  resolve: {
323
354
  alias: {
@@ -1021,6 +1021,14 @@ export const blumeConfigSchema = z
1021
1021
  contextual: contextualConfigSchema.default({}),
1022
1022
  deployment: deploymentConfigSchema.default({}),
1023
1023
  description: z.string().optional(),
1024
+ /**
1025
+ * Directory (relative to the project root) that `<Component path>` resolves
1026
+ * live previews and their source against. Defaults to `examples`; point it
1027
+ * elsewhere when examples live outside a top-level `examples/` — e.g. a
1028
+ * registry layout like `registry/<pkg>`, where a `<Component path>` key is
1029
+ * then relative to that directory.
1030
+ */
1031
+ examples: z.string().default("examples"),
1024
1032
  export: exportConfigSchema.default(false),
1025
1033
  favicon: faviconConfigSchema.optional(),
1026
1034
  feedback: z.boolean().default(true),