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.
Files changed (65) hide show
  1. package/dist/cli/index.js +6070 -5718
  2. package/dist/cli/index.js.map +41 -40
  3. package/dist/types/core/config-input.d.ts +749 -0
  4. package/dist/types/core/config.d.ts +126 -3
  5. package/dist/types/core/schema.d.ts +10 -27
  6. package/dist/types/core/sources/types.d.ts +6 -0
  7. package/dist/types/index.d.ts +2 -1
  8. package/docs/advanced/changelog.mdx +10 -2
  9. package/docs/configuration/index.mdx +0 -2
  10. package/docs/content/syntax.mdx +4 -8
  11. package/package.json +1 -1
  12. package/src/astro/generate.ts +59 -24
  13. package/src/astro/markdown-negotiation.ts +12 -3
  14. package/src/astro/templates.ts +94 -12
  15. package/src/cli/commands/build.ts +26 -1
  16. package/src/cli/commands/dev.ts +30 -14
  17. package/src/cli/commands/doctor.ts +35 -7
  18. package/src/cli/commands/sync.ts +14 -2
  19. package/src/cli/dev-lock.ts +40 -10
  20. package/src/cli/env.ts +5 -1
  21. package/src/components/content/Update.astro +12 -2
  22. package/src/components/content/changelog-element.ts +62 -0
  23. package/src/components/islands/ask-ai.tsx +3 -1
  24. package/src/components/islands/hooks.ts +5 -1
  25. package/src/components/layout/Header.astro +10 -2
  26. package/src/components/layout/NavSelector.astro +5 -3
  27. package/src/components/layout/PageLayout.astro +2 -1
  28. package/src/components/layout/ReferenceLayout.astro +1 -0
  29. package/src/components/layout/RootLayout.astro +47 -10
  30. package/src/components/layout/Search.astro +8 -3
  31. package/src/components/layout/nav-utils.ts +7 -3
  32. package/src/core/config-input.ts +923 -0
  33. package/src/core/config.ts +126 -3
  34. package/src/core/i18n.ts +6 -5
  35. package/src/core/links.ts +16 -1
  36. package/src/core/meta.ts +112 -52
  37. package/src/core/navigation.ts +15 -5
  38. package/src/core/project-graph.ts +68 -2
  39. package/src/core/schema.ts +8 -14
  40. package/src/core/sources/assets.ts +21 -5
  41. package/src/core/sources/cache.ts +19 -1
  42. package/src/core/sources/github-releases.ts +9 -3
  43. package/src/core/sources/mdx-remote.ts +14 -4
  44. package/src/core/sources/normalize.ts +13 -1
  45. package/src/core/sources/notion.ts +43 -7
  46. package/src/core/sources/resolve.ts +44 -1
  47. package/src/core/sources/sanity.ts +9 -3
  48. package/src/core/sources/types.ts +6 -0
  49. package/src/deploy/adapter-output.ts +82 -0
  50. package/src/deploy/rss.ts +3 -1
  51. package/src/index.ts +1 -1
  52. package/src/markdown/code-title.ts +11 -4
  53. package/src/markdown/index.ts +28 -30
  54. package/src/markdown/math.ts +3 -2
  55. package/src/markdown/package-commands.ts +13 -0
  56. package/src/og/card.ts +3 -1
  57. package/src/openapi/model.ts +2 -1
  58. package/src/openapi/parse.ts +9 -1
  59. package/src/openapi/references.ts +11 -1
  60. package/src/openapi/render-mdx.ts +30 -3
  61. package/src/openapi/source.ts +3 -1
  62. package/src/registry/eject.ts +21 -14
  63. package/src/search/documents.ts +4 -1
  64. package/src/theme/entry.ts +10 -3
  65. package/src/theme/icons.ts +7 -11
@@ -1,16 +1,139 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
 
3
+ import type { BlumeConfig } from "./config-input.ts";
3
4
  import { applyDeploymentEnv } from "./deployment-env.ts";
4
5
  import { BlumeError, diagnosticsFromZod } from "./diagnostics.ts";
5
6
  import { createModuleLoader } from "./load-module.ts";
6
7
  import { findConfigFile } from "./project.ts";
7
8
  import { blumeConfigSchema } from "./schema.ts";
8
- import type { BlumeConfig, ResolvedConfig } from "./schema.ts";
9
+ import type { ResolvedConfig } from "./schema.ts";
9
10
  import type { Diagnostic } from "./types.ts";
10
11
 
11
12
  /**
12
- * Identity helper for authoring `blume.config.ts`. Exists for type inference
13
- * and a stable future home for plugin hooks; it does not transform input.
13
+ * Define a Blume site's configuration with full type-checking and editor
14
+ * autocomplete. Place the call in `blume.config.ts` at your project root and
15
+ * `export default` the result:
16
+ *
17
+ * ```ts
18
+ * import { defineConfig } from "blume";
19
+ *
20
+ * export default defineConfig({
21
+ * title: "Acme Docs",
22
+ * description: "Everything you need to build with Acme.",
23
+ * });
24
+ * ```
25
+ *
26
+ * Every field is optional — an empty `defineConfig({})` produces a working
27
+ * site from the Markdown/MDX in your `docs/` directory. Configure only what you
28
+ * want to change; sensible defaults fill in the rest.
29
+ *
30
+ * This is an identity helper: it returns its input unchanged and exists purely
31
+ * for type inference (and as a stable home for future plugin hooks). The object
32
+ * is validated against the Blume schema when the CLI loads it.
33
+ *
34
+ * ## Top-level fields
35
+ *
36
+ * **Site identity**
37
+ * - `title` — site title, shown in the header, `<title>`, and OG images.
38
+ * Defaults to `"Documentation"`.
39
+ * - `description` — default meta description, used where a page sets none.
40
+ * - `logo` — brand mark. A string is an image path/URL; the object form splits
41
+ * an `image` mark from wordmark `text` and can override the brand `href`.
42
+ * - `banner` — site-wide announcement bar; a string, or `{ content, link,
43
+ * dismissible }`.
44
+ *
45
+ * **Content & navigation**
46
+ * - `content` — where content lives (`root`, defaults to `docs`) and pluggable
47
+ * `sources` (filesystem, remote MDX, GitHub Releases, Sanity, Notion, or a
48
+ * custom `ContentSource`). Omit `sources` and the top-level `root` becomes one
49
+ * implicit filesystem source.
50
+ * - `navigation` — sidebar, header `tabs`, `selectors` (version/language/product
51
+ * switchers), pinned `featured` links, and the `repo` link toggle. Omit
52
+ * `sidebar` to generate it from the content tree.
53
+ * - `redirects` — `{ from, to, status }` rules (301 by default).
54
+ * - `github` — `{ owner, repo, branch, dir }`, powering "Edit this page" links
55
+ * and the header repo link.
56
+ *
57
+ * **Appearance**
58
+ * - `theme` — `accent` color, `fonts` (curated Google Font slugs), `radius`,
59
+ * `mode` (`system`/`light`/`dark`), `background`, and `strict` token mode.
60
+ * - `markdown` — `code` (language icons, inline highlighting, line wrap),
61
+ * `headingAnchors`, `imageZoom`, and opt-in KaTeX `math`.
62
+ * - `toc` — on-page table of contents; `true`/`false` or a heading-level range.
63
+ * - `lastModified` — "Last updated" stamps from `git` history or frontmatter.
64
+ * - `feedback` — the per-page "Was this helpful?" widget (on by default).
65
+ * - `export` — reader-facing PDF/EPUB export actions (off by default).
66
+ *
67
+ * **Reference docs**
68
+ * - `openapi` — native OpenAPI reference: one real page per operation, woven
69
+ * into the sidebar and search. Point `sources`/`spec` at your spec.
70
+ * - `asyncapi` — AsyncAPI reference via the embedded Scalar renderer.
71
+ *
72
+ * **Search & AI**
73
+ * - `search` — search backend `provider` (`orama` by default; `pagefind`,
74
+ * `algolia`, `typesense`, `orama-cloud`, `mixedbread`, or `none`) plus its
75
+ * credential block.
76
+ * - `ai` — `ask` (the Ask AI chat endpoint and its provider/model) and `llmsTxt`
77
+ * (emit `llms.txt`).
78
+ * - `mcp` — expose the docs as an MCP server for connecting agents.
79
+ *
80
+ * **SEO, feeds & analytics**
81
+ * - `seo` — `og` images, `sitemap`, `robots`, `rss` feeds, `structuredData`
82
+ * JSON-LD, `agentReadability`, and robots `contentSignals`.
83
+ * - `analytics` — PostHog, Vercel, or arbitrary `scripts` (Plausible, Fathom,
84
+ * GA, …).
85
+ *
86
+ * **Deployment & i18n**
87
+ * - `deployment` — `site` URL (needed for absolute links, sitemaps, and OG),
88
+ * `adapter` (`vercel`/`node`/`netlify`/`cloudflare`), `output`
89
+ * (`static`/`server`), and `base` path. Auto-detected on Vercel/Netlify/
90
+ * Cloudflare from the platform env.
91
+ * - `i18n` — opt-in multi-locale: `locales`, `defaultLocale`, `parser`
92
+ * (`dir` vs filename `dot` suffix), and per-locale UI overrides.
93
+ *
94
+ * - `examples` — where `<Component path>` previews resolve their source from
95
+ * (defaults to `examples/`; supports a glob for colocated registries).
96
+ *
97
+ * @example Zero-config — just render the Markdown under `docs/`.
98
+ * ```ts
99
+ * export default defineConfig({});
100
+ * ```
101
+ *
102
+ * @example A production docs site with theming, search, and deployment.
103
+ * ```ts
104
+ * export default defineConfig({
105
+ * title: "Acme Docs",
106
+ * description: "Build faster with Acme.",
107
+ * logo: { image: "/logo.svg", text: "Acme" },
108
+ * github: { owner: "acme", repo: "acme" },
109
+ * theme: { accent: "violet", fonts: { body: "inter" }, radius: "lg" },
110
+ * navigation: {
111
+ * tabs: [
112
+ * { label: "Guides", path: "/guides" },
113
+ * { label: "API", path: "/api" },
114
+ * ],
115
+ * },
116
+ * search: { provider: "orama" },
117
+ * deployment: { site: "https://docs.acme.com", adapter: "vercel" },
118
+ * });
119
+ * ```
120
+ *
121
+ * @example An OpenAPI reference with the Ask AI assistant enabled.
122
+ * ```ts
123
+ * export default defineConfig({
124
+ * title: "Acme API",
125
+ * openapi: {
126
+ * enabled: true,
127
+ * route: "/reference",
128
+ * sources: [{ label: "Core", spec: "./openapi.json" }],
129
+ * },
130
+ * ai: { ask: { enabled: true }, llmsTxt: true },
131
+ * });
132
+ * ```
133
+ *
134
+ * @param config - The site configuration. All fields are optional.
135
+ * @returns The same config object, typed for inference.
136
+ * @see https://useblume.dev/docs for the full configuration reference.
14
137
  */
15
138
  export const defineConfig = (config: BlumeConfig): BlumeConfig => config;
16
139
 
package/src/core/i18n.ts CHANGED
@@ -111,13 +111,14 @@ export const localePlacement = (
111
111
 
112
112
  if (i18n.parser === "dot") {
113
113
  const lastDot = base.lastIndexOf(".");
114
- // Only a dot inside the filename (not a directory) is a locale suffix.
114
+ // Only a dot inside the filename (not a directory) is a locale suffix. Any
115
+ // configured locale counts — including the default, so the symmetric
116
+ // authoring `intro.en.mdx` + `intro.fr.mdx` shares one translation key
117
+ // instead of routing the default file to a literal `/intro.en`.
115
118
  if (lastDot > base.lastIndexOf("/")) {
116
119
  const suffix = base.slice(lastDot + 1);
117
- const isNonDefault = i18n.locales.some(
118
- (locale) => locale.code !== i18n.defaultLocale && locale.code === suffix
119
- );
120
- if (isNonDefault) {
120
+ const matched = i18n.locales.some((locale) => locale.code === suffix);
121
+ if (matched) {
121
122
  return {
122
123
  locales: [suffix],
123
124
  navPath: `${base.slice(0, lastDot)}${ext}`,
package/src/core/links.ts CHANGED
@@ -12,6 +12,15 @@ import type {
12
12
  const HTTP = /^https?:\/\//iu;
13
13
  const PROTOCOL_RELATIVE = /^\/\//u;
14
14
  const SCHEME = /^[a-z][a-z0-9+.-]*:/iu;
15
+
16
+ /** Percent-decode a link piece; malformed sequences stay verbatim. */
17
+ const decodePercent = (value: string): string => {
18
+ try {
19
+ return decodeURIComponent(value);
20
+ } catch {
21
+ return value;
22
+ }
23
+ };
15
24
  const DOC_EXT = /\.(?:md|mdx)$/iu;
16
25
  const FILE_EXT = /\.[a-z0-9]+$/iu;
17
26
 
@@ -309,12 +318,18 @@ const classifyLink = (
309
318
  }
310
319
 
311
320
  const hashIndex = target.indexOf("#");
312
- const fragment = hashIndex === -1 ? "" : target.slice(hashIndex + 1);
321
+ // Browser-copied links arrive percent-encoded (`/caf%C3%A9`, `#caf%C3%A9`)
322
+ // while routes and anchor slugs are stored decoded — decode before comparing
323
+ // so valid links aren't reported broken.
324
+ const fragment = decodePercent(
325
+ hashIndex === -1 ? "" : target.slice(hashIndex + 1)
326
+ );
313
327
  let rawPath = hashIndex === -1 ? target : target.slice(0, hashIndex);
314
328
  const queryIndex = rawPath.indexOf("?");
315
329
  if (queryIndex !== -1) {
316
330
  rawPath = rawPath.slice(0, queryIndex);
317
331
  }
332
+ rawPath = decodePercent(rawPath);
318
333
 
319
334
  if (rawPath === "") {
320
335
  return fragment ? checkAnchor(page.route, fragment, site, ctx) : null;
package/src/core/meta.ts CHANGED
@@ -21,75 +21,135 @@ const META_FILES = [
21
21
  const resolveMeta = async (mod: unknown): Promise<unknown> =>
22
22
  typeof mod === "function" ? await (mod as () => unknown)() : mod;
23
23
 
24
+ /** A filesystem content source to scan for folder meta: its on-disk root and
25
+ * optional route prefix. The prefix is folded into every key so meta lines up
26
+ * with the sidebar group path, which carries the same prefix. */
27
+ export interface FolderMetaSource {
28
+ root: string;
29
+ prefix?: string;
30
+ }
31
+
24
32
  /**
25
- * Discover `meta.{ts,js,mjs}` files under the content root. Keys are the
26
- * directory path relative to the content root (`""` for the root directory).
27
- * `meta.$.*` files are returned in `shared` — folder meta that applies to that
28
- * directory in every locale (a locale-specific `meta.*` overrides it). Each file
29
- * default-exports an object or a (sync/async) function returning one.
33
+ * The folder-meta key for a directory. Mirrors the sidebar group path: the
34
+ * source's route prefix (`docs`) followed by the directory relative to the
35
+ * source root (`provider`)so `docs/provider/meta.ts` under a `prefix: "docs"`
36
+ * source keys to `docs/provider`, exactly the group path navigation builds.
37
+ */
38
+ const metaKeyFor = (prefix: string | undefined, dir: string): string => {
39
+ const clean = prefix ? prefix.replaceAll(/^\/+|\/+$/gu, "") : "";
40
+ if (!clean) {
41
+ return dir;
42
+ }
43
+ return dir ? `${clean}/${dir}` : clean;
44
+ };
45
+
46
+ /**
47
+ * Discover `meta.{ts,js,mjs}` files across the given filesystem sources. Keys are
48
+ * the source's route prefix joined with the directory relative to that source's
49
+ * root (`""`/the prefix itself for a source's root directory), so meta matches
50
+ * the prefixed sidebar group path. A bare string is shorthand for a single,
51
+ * unprefixed source (the default project layout). `meta.$.*` files are returned
52
+ * in `shared` — folder meta that applies to that directory in every locale (a
53
+ * locale-specific `meta.*` overrides it). Each file default-exports an object or
54
+ * a (sync/async) function returning one.
55
+ *
56
+ * `localeDirs` names the top-level locale directories of a `dir`-parser i18n
57
+ * project. Navigation looks locale meta up as `locale/<group path>` where the
58
+ * group path starts with the source prefix, so a locale directory found at a
59
+ * source root is hoisted in front of the prefix (`docs/fr/guides/meta.ts` keys
60
+ * to `fr/docs/guides`, not `docs/fr/guides`).
30
61
  */
31
62
  export const discoverFolderMeta = async (
32
- contentRoot: string
63
+ sources: string | FolderMetaSource[],
64
+ options: { localeDirs?: readonly string[] } = {}
33
65
  ): Promise<{
34
66
  meta: Map<string, FolderMeta>;
35
67
  shared: Map<string, FolderMeta>;
36
68
  diagnostics: Diagnostic[];
37
69
  }> => {
38
- const files = await glob(META_FILES, {
39
- absolute: true,
40
- cwd: contentRoot,
41
- // Never descend into dependencies or build output — relevant when the
42
- // content root is the project root (e.g. a `.`-rooted or all-staged project).
43
- ignore: ["**/node_modules/**", "**/.blume/**", "**/dist/**"],
44
- onlyFiles: true,
45
- });
70
+ const list: FolderMetaSource[] =
71
+ typeof sources === "string" ? [{ root: sources }] : sources;
72
+ const localeDirs = new Set(options.localeDirs);
46
73
 
47
74
  const load = createModuleLoader();
48
- const loaded = await Promise.all(
49
- files.map(
50
- async (
51
- file
52
- ): Promise<
53
- | { ok: true; file: string; value: unknown }
54
- | { ok: false; file: string; error: Error }
55
- > => {
56
- try {
57
- return { file, ok: true, value: await resolveMeta(await load(file)) };
58
- } catch (error) {
59
- return { error: error as Error, file, ok: false };
60
- }
61
- }
62
- )
63
- );
64
-
65
75
  const meta = new Map<string, FolderMeta>();
66
76
  const shared = new Map<string, FolderMeta>();
67
77
  const diagnostics: Diagnostic[] = [];
68
78
 
69
- for (const entry of loaded) {
70
- const dir = relative(contentRoot, dirname(entry.file));
71
-
72
- if (!entry.ok) {
73
- diagnostics.push({
74
- code: "BLUME_META_LOAD_FAILED",
75
- file: entry.file,
76
- message: `Could not load meta file: ${entry.error.message}`,
77
- severity: "error",
79
+ // Scan every source under its own root so a source rooted outside
80
+ // `content.root` still contributes its folder meta.
81
+ const perSource = await Promise.all(
82
+ list.map(async (source) => {
83
+ const files = await glob(META_FILES, {
84
+ absolute: true,
85
+ cwd: source.root,
86
+ // Never descend into dependencies or build output — relevant when the
87
+ // root is the project root (e.g. a `.`-rooted or all-staged project).
88
+ ignore: ["**/node_modules/**", "**/.blume/**", "**/dist/**"],
89
+ onlyFiles: true,
78
90
  });
79
- continue;
80
- }
91
+ const loaded = await Promise.all(
92
+ files.map(
93
+ async (
94
+ file
95
+ ): Promise<
96
+ | { ok: true; file: string; value: unknown }
97
+ | { ok: false; file: string; error: Error }
98
+ > => {
99
+ try {
100
+ return {
101
+ file,
102
+ ok: true,
103
+ value: await resolveMeta(await load(file)),
104
+ };
105
+ } catch (error) {
106
+ return { error: error as Error, file, ok: false };
107
+ }
108
+ }
109
+ )
110
+ );
111
+ return { loaded, source };
112
+ })
113
+ );
114
+
115
+ for (const { loaded, source } of perSource) {
116
+ for (const entry of loaded) {
117
+ const dir = relative(source.root, dirname(entry.file));
118
+ const [head, ...tail] = dir.split("/");
119
+ // A locale directory sits between the source root and the folder, but the
120
+ // lookup key carries the locale in front of the (prefixed) group path.
121
+ const key =
122
+ head && localeDirs.has(head)
123
+ ? `${head}/${metaKeyFor(source.prefix, tail.join("/"))}`.replace(
124
+ /\/$/u,
125
+ ""
126
+ )
127
+ : metaKeyFor(source.prefix, dir);
81
128
 
82
- const result = folderMetaSchema.safeParse(entry.value);
83
- if (result.success) {
84
- const target = basename(entry.file).startsWith("meta.$.") ? shared : meta;
85
- target.set(dir, result.data);
86
- } else {
87
- diagnostics.push(
88
- ...diagnosticsFromZod(result.error, {
89
- code: "BLUME_META_INVALID",
129
+ if (!entry.ok) {
130
+ diagnostics.push({
131
+ code: "BLUME_META_LOAD_FAILED",
90
132
  file: entry.file,
91
- })
92
- );
133
+ message: `Could not load meta file: ${entry.error.message}`,
134
+ severity: "error",
135
+ });
136
+ continue;
137
+ }
138
+
139
+ const result = folderMetaSchema.safeParse(entry.value);
140
+ if (result.success) {
141
+ const target = basename(entry.file).startsWith("meta.$.")
142
+ ? shared
143
+ : meta;
144
+ target.set(key, result.data);
145
+ } else {
146
+ diagnostics.push(
147
+ ...diagnosticsFromZod(result.error, {
148
+ code: "BLUME_META_INVALID",
149
+ file: entry.file,
150
+ })
151
+ );
152
+ }
93
153
  }
94
154
  }
95
155
 
@@ -228,6 +228,7 @@ const buildFileSystemSidebar = (
228
228
  // Group by the locale-stripped path so the locale dir is not a nav group.
229
229
  const parts = page.navPath.split("/");
230
230
  const filename = parts.at(-1) ?? page.navPath;
231
+ const stem = filename.replace(extname(filename), "");
231
232
  const dirs = parts.slice(0, -1);
232
233
 
233
234
  // Each group's URL path is the matching prefix of the page's route. navPath
@@ -235,13 +236,22 @@ const buildFileSystemSidebar = (
235
236
  // align the folder segments from the right (the extra leading segments are
236
237
  // that prefix). Under such a prefix the path won't match a logical tab path,
237
238
  // so tab-scoping simply no-ops — same as the header's active-tab logic.
238
- const folderParts = page.route.split("/").filter(Boolean).slice(0, -1);
239
- const offset = Math.max(0, folderParts.length - dirs.length);
239
+ // An index page's route IS its folder's route (no page segment to drop),
240
+ // and `(group)` folders contribute no route segment at all.
241
+ const routeSegments = page.route.split("/").filter(Boolean);
242
+ const folderParts =
243
+ stem === "index" ? routeSegments : routeSegments.slice(0, -1);
244
+ const routeDirCount = dirs.filter((dir) => !GROUP_FOLDER.test(dir)).length;
245
+ const offset = Math.max(0, folderParts.length - routeDirCount);
240
246
 
241
247
  let parent = root;
242
- for (const [index, dir] of dirs.entries()) {
248
+ let consumed = offset;
249
+ for (const dir of dirs) {
243
250
  parent = ensureGroup(parent, dir);
244
- parent.routePath ??= `/${folderParts.slice(0, offset + index + 1).join("/")}`;
251
+ if (!GROUP_FOLDER.test(dir)) {
252
+ consumed += 1;
253
+ }
254
+ parent.routePath ??= `/${folderParts.slice(0, consumed).join("/")}`;
245
255
  }
246
256
 
247
257
  parent.children.push({
@@ -249,7 +259,7 @@ const buildFileSystemSidebar = (
249
259
  deprecated: page.meta.deprecated || undefined,
250
260
  description: page.description,
251
261
  icon: page.meta.sidebar.icon,
252
- key: segmentKey(filename.replace(extname(filename), "")),
262
+ key: segmentKey(stem),
253
263
  kind: "page",
254
264
  label: page.meta.sidebar.label ?? page.title,
255
265
  order: pageOrder(page, filename),
@@ -1,3 +1,5 @@
1
+ import { relative } from "pathe";
2
+
1
3
  import { loadConfig } from "./config.ts";
2
4
  import { buildContentGraph } from "./graph.ts";
3
5
  import { i18nDiagnostics } from "./i18n.ts";
@@ -7,10 +9,11 @@ import {
7
9
  } from "./last-modified.ts";
8
10
  import { buildManifest } from "./manifest.ts";
9
11
  import { discoverFolderMeta } from "./meta.ts";
12
+ import type { FolderMetaSource } from "./meta.ts";
10
13
  import { resolveProjectContext } from "./project.ts";
11
14
  import type { ResolvedConfig } from "./schema.ts";
12
15
  import { normalizeEntry } from "./sources/normalize.ts";
13
- import { resolveSources } from "./sources/resolve.ts";
16
+ import { resolveDocsCollection, resolveSources } from "./sources/resolve.ts";
14
17
  import type { ContentSource } from "./sources/types.ts";
15
18
  import type {
16
19
  BlumeManifest,
@@ -70,6 +73,48 @@ export interface BlumeProject {
70
73
  sources: ContentSource[];
71
74
  }
72
75
 
76
+ /**
77
+ * Guard the invariant that ties a filesystem page to the `docs` collection:
78
+ * Astro ids each collection entry by its path relative to the collection base,
79
+ * so `getEntry("docs", entryId)` only resolves when that entry id equals
80
+ * `relative(base, file)`. A filesystem source ids entries relative to its own
81
+ * root; when that root can't be the collection base (e.g. a second filesystem
82
+ * source rooted elsewhere), the ids diverge and every one of that source's pages
83
+ * would 404 in dev (a static build silently masks it). Emit a hard error naming
84
+ * the mismatch so it can't ship, instead of a silent runtime failure. One
85
+ * diagnostic per file (locale duplicates share a source path).
86
+ */
87
+ const entryIdDiagnostics = (
88
+ pages: PageRecord[],
89
+ collectionBase: string
90
+ ): Diagnostic[] => {
91
+ const diagnostics: Diagnostic[] = [];
92
+ const seen = new Set<string>();
93
+ for (const page of pages) {
94
+ // Only filesystem entries render through the base-rooted `docs` collection;
95
+ // staged sources carry their own id and collection.
96
+ if (page.collection || !page.sourcePath || seen.has(page.sourcePath)) {
97
+ continue;
98
+ }
99
+ seen.add(page.sourcePath);
100
+ const expected = relative(collectionBase, page.sourcePath)
101
+ .split("\\")
102
+ .join("/");
103
+ const entryId = page.entryId ?? page.source.ref;
104
+ if (expected !== entryId) {
105
+ diagnostics.push({
106
+ code: "BLUME_ENTRY_ID_MISMATCH",
107
+ file: page.sourcePath,
108
+ message: `Content source "${page.source.name}" is rooted outside the docs collection base, so ${page.route} resolves entry id "${entryId}" but the collection would generate "${expected}" — the page would 404 at runtime.`,
109
+ severity: "error",
110
+ suggestion:
111
+ "Give each filesystem source a root under content.root, or use a single filesystem source so the collection can root at it.",
112
+ });
113
+ }
114
+ }
115
+ return diagnostics;
116
+ };
117
+
73
118
  /**
74
119
  * Run the full core pipeline for a project root: load config, resolve paths,
75
120
  * discover content and folder meta, build the graph, and assemble the manifest.
@@ -110,13 +155,33 @@ export const scanProject = async (
110
155
  source.validate?.();
111
156
  }
112
157
 
158
+ // Folder meta is discovered per filesystem source, under each source's own
159
+ // root and keyed by its route prefix, so a prefixed/root-differing source's
160
+ // `meta.ts` still lines up with its (prefixed) sidebar group path.
161
+ const metaSources: FolderMetaSource[] = sources
162
+ .filter((source) => !source.staged && source.contentRoot)
163
+ .map((source) => ({
164
+ prefix: source.prefix,
165
+ root: source.contentRoot ?? "",
166
+ }));
167
+
113
168
  // Run every source's `load()` in parallel, then funnel each entry through the
114
169
  // shared `normalizeEntry` so route mapping is identical regardless of origin.
170
+ // Under the `dir` parser, non-default locales are top-level directories whose
171
+ // meta keys must carry the locale in front of the source prefix (see
172
+ // `discoverFolderMeta`).
173
+ const localeDirs =
174
+ config.i18n && config.i18n.parser === "dir"
175
+ ? config.i18n.locales
176
+ .map((locale) => locale.code)
177
+ .filter((code) => code !== config.i18n?.defaultLocale)
178
+ : undefined;
179
+
115
180
  const [loaded, folderMeta] = await Promise.all([
116
181
  Promise.all(
117
182
  sources.map(async (source) => ({ source, ...(await source.load()) }))
118
183
  ),
119
- discoverFolderMeta(context.contentRoot),
184
+ discoverFolderMeta(metaSources, { localeDirs }),
120
185
  ]);
121
186
 
122
187
  const allPages: PageRecord[] = [];
@@ -180,6 +245,7 @@ export const scanProject = async (
180
245
  diagnostics: [
181
246
  ...contentDiagnostics,
182
247
  ...folderMeta.diagnostics,
248
+ ...entryIdDiagnostics(pages, resolveDocsCollection(config, context).base),
183
249
  ...graph.diagnostics,
184
250
  ...i18nWarnings,
185
251
  ],
@@ -109,7 +109,8 @@ const pageMetaBaseSchema = z
109
109
  sidebar: sidebarMetaSchema.default({}),
110
110
  slug: z.string().optional(),
111
111
  title: z.string().optional(),
112
- type: z.string().default("doc"),
112
+ // No default: an absent `type` must fall through to `content.defaultType`.
113
+ type: z.string().optional(),
113
114
  })
114
115
  .strict();
115
116
 
@@ -925,12 +926,6 @@ const codeConfigSchema = z
925
926
  * …). On by default; recognized languages only.
926
927
  */
927
928
  icons: z.boolean().default(true),
928
- /**
929
- * Syntax-highlight inline `` `code{:lang}` `` snippets. Off by default — most
930
- * inline code (flags, file names) reads better plain; opt a snippet in with
931
- * a trailing `{:lang}` marker.
932
- */
933
- inline: z.boolean().default(false),
934
929
  /**
935
930
  * Wrap long lines instead of scrolling horizontally. Off by default, so
936
931
  * code keeps its original line breaks and overflows into a scroll area.
@@ -955,11 +950,6 @@ const markdownConfigSchema = z
955
950
  * opt a single image out with `data-no-zoom`.
956
951
  */
957
952
  imageZoom: z.boolean().default(true),
958
- /**
959
- * Enable LaTeX math (`$…$` inline, `$$…$$` block) rendered with KaTeX.
960
- * Off by default since `$` is common in prose, shell, and code. MDX only.
961
- */
962
- math: z.boolean().default(false),
963
953
  })
964
954
  .strict();
965
955
 
@@ -1097,8 +1087,12 @@ export type ResolvedConfig = z.infer<typeof blumeConfigSchema>;
1097
1087
  export type ResolvedI18nConfig = z.infer<typeof i18nConfigSchema>;
1098
1088
  /** A configured locale with display metadata. */
1099
1089
  export type LocaleConfig = z.infer<typeof localeSchema>;
1100
- /** User-authored config: the shape accepted by `defineConfig`. */
1101
- export type BlumeConfig = z.input<typeof blumeConfigSchema>;
1090
+ /**
1091
+ * User-authored config, straight off the schema. The public, hand-documented
1092
+ * authoring type is `BlumeConfig` in `./config-input.ts`, which a compile-time
1093
+ * guard keeps structurally identical to this.
1094
+ */
1095
+ export type BlumeConfigInput = z.input<typeof blumeConfigSchema>;
1102
1096
  /** A configured search backend. */
1103
1097
  export type SearchProvider = (typeof searchProviders)[number];
1104
1098
  /** Resolved robots.txt `Content-Signal` preferences (`null` when disabled). */
@@ -8,6 +8,11 @@ import { hashText } from "./cache.ts";
8
8
  const MD_IMAGE = /!\[(?<alt>[^\]]*)\]\((?<url>[^)\s]+)\)/gu;
9
9
  const REMOTE = /^https?:\/\//u;
10
10
  const SAFE_EXT = /^\.[a-z0-9]+$/iu;
11
+ const CODE_FENCE_BLOCK =
12
+ /^(?<fence>`{3,}|~{3,})[^\n]*\n[\s\S]*?^\k<fence>[^\n]*(?=\n|$)/gmu;
13
+ // NUL delimiters cannot appear in authored markdown, so tokens never collide.
14
+ // oxlint-disable-next-line no-control-regex -- the NUL is the collision guard.
15
+ const FENCE_TOKEN = /\u0000blume-fence-(?<index>\d+)\u0000/gu;
11
16
 
12
17
  /** Where to write downloaded assets and how to reference them publicly. */
13
18
  export interface AssetContext {
@@ -37,8 +42,17 @@ export const materializeAssets = async (
37
42
  const doFetch = ctx.fetchImpl ?? globalThis.fetch;
38
43
  const diagnostics: Diagnostic[] = [];
39
44
 
45
+ // Mask fenced code blocks so an image URL inside a code sample is neither
46
+ // downloaded nor rewritten — the sample must keep showing what the author
47
+ // wrote.
48
+ const fences: string[] = [];
49
+ const masked = markdown.replace(CODE_FENCE_BLOCK, (block) => {
50
+ fences.push(block);
51
+ return `\u0000blume-fence-${fences.length - 1}\u0000`;
52
+ });
53
+
40
54
  const urls = new Set<string>();
41
- for (const match of markdown.matchAll(MD_IMAGE)) {
55
+ for (const match of masked.matchAll(MD_IMAGE)) {
42
56
  const url = match.groups?.url;
43
57
  if (url && REMOTE.test(url)) {
44
58
  urls.add(url);
@@ -68,10 +82,12 @@ export const materializeAssets = async (
68
82
  })
69
83
  );
70
84
 
71
- const rewritten = markdown.replaceAll(MD_IMAGE, (match, alt, url) => {
72
- const local = rewrites.get(url);
73
- return local ? `![${alt}](${local})` : match;
74
- });
85
+ const rewritten = masked
86
+ .replaceAll(MD_IMAGE, (match, alt, url) => {
87
+ const local = rewrites.get(url);
88
+ return local ? `![${alt}](${local})` : match;
89
+ })
90
+ .replaceAll(FENCE_TOKEN, (token, index) => fences[Number(index)] ?? token);
75
91
 
76
92
  return { diagnostics, markdown: rewritten };
77
93
  };