blume 0.6.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/cli/index.js +5955 -5733
  2. package/dist/cli/index.js.map +37 -37
  3. package/dist/types/core/config-input.d.ts +1 -11
  4. package/dist/types/core/schema.d.ts +4 -4
  5. package/dist/types/core/sources/types.d.ts +6 -0
  6. package/package.json +1 -1
  7. package/src/astro/generate.ts +23 -13
  8. package/src/astro/markdown-negotiation.ts +12 -3
  9. package/src/astro/templates.ts +28 -7
  10. package/src/cli/commands/dev.ts +30 -14
  11. package/src/cli/commands/doctor.ts +35 -7
  12. package/src/cli/commands/sync.ts +14 -2
  13. package/src/cli/dev-lock.ts +40 -10
  14. package/src/cli/env.ts +5 -1
  15. package/src/components/islands/ask-ai.tsx +3 -1
  16. package/src/components/islands/hooks.ts +5 -1
  17. package/src/components/layout/Header.astro +10 -2
  18. package/src/components/layout/NavSelector.astro +5 -3
  19. package/src/components/layout/PageLayout.astro +2 -1
  20. package/src/components/layout/ReferenceLayout.astro +1 -0
  21. package/src/components/layout/RootLayout.astro +16 -2
  22. package/src/components/layout/Search.astro +8 -3
  23. package/src/components/layout/nav-utils.ts +7 -3
  24. package/src/core/config-input.ts +1 -11
  25. package/src/core/i18n.ts +6 -5
  26. package/src/core/links.ts +16 -1
  27. package/src/core/meta.ts +112 -52
  28. package/src/core/navigation.ts +15 -5
  29. package/src/core/project-graph.ts +68 -2
  30. package/src/core/schema.ts +2 -1
  31. package/src/core/sources/assets.ts +21 -5
  32. package/src/core/sources/cache.ts +19 -1
  33. package/src/core/sources/github-releases.ts +9 -3
  34. package/src/core/sources/mdx-remote.ts +14 -4
  35. package/src/core/sources/normalize.ts +13 -1
  36. package/src/core/sources/notion.ts +43 -7
  37. package/src/core/sources/resolve.ts +44 -1
  38. package/src/core/sources/sanity.ts +9 -3
  39. package/src/core/sources/types.ts +6 -0
  40. package/src/deploy/rss.ts +3 -1
  41. package/src/markdown/code-title.ts +11 -4
  42. package/src/markdown/package-commands.ts +13 -0
  43. package/src/og/card.ts +3 -1
  44. package/src/openapi/model.ts +2 -1
  45. package/src/openapi/parse.ts +9 -1
  46. package/src/openapi/references.ts +11 -1
  47. package/src/openapi/render-mdx.ts +30 -3
  48. package/src/openapi/source.ts +3 -1
  49. package/src/search/documents.ts +4 -1
  50. package/src/theme/entry.ts +3 -0
  51. package/src/theme/icons.ts +7 -11
@@ -32,6 +32,7 @@ import Favicon from "./Favicon.astro";
32
32
  import Fonts from "./Fonts.astro";
33
33
  import { bannerInitScript, themeInitScript } from "./head-scripts.ts";
34
34
  import Header from "./Header.astro";
35
+ import { isUnderPath } from "./nav-utils.ts";
35
36
 
36
37
  interface Props {
37
38
  site: { title: string; description?: string };
@@ -209,7 +210,7 @@ const bannerScript = banner?.dismissible
209
210
  <a
210
211
  aria-current={
211
212
  route === tab.path ||
212
- (tab.path !== "/" && route.startsWith(tab.path))
213
+ (tab.path !== "/" && isUnderPath(route, tab.path))
213
214
  ? "page"
214
215
  : undefined
215
216
  }
@@ -92,6 +92,7 @@ const bannerScript = banner?.dismissible
92
92
  <body class="bg-background font-sans text-foreground antialiased">
93
93
  <Banner banner={banner} />
94
94
  <Header
95
+ hasDrawer={false}
95
96
  hasSidebar={false}
96
97
  logo={logo}
97
98
  navigation={navigation}
@@ -23,6 +23,7 @@ import {
23
23
  findBreadcrumbs,
24
24
  flattenPages,
25
25
  getPagination,
26
+ isUnderPath,
26
27
  sidebarForRoute,
27
28
  } from "./nav-utils.ts";
28
29
  import NavTree from "./NavTree.astro";
@@ -427,7 +428,7 @@ const bannerScript = banner?.dismissible
427
428
  <a
428
429
  aria-current={
429
430
  page.route === tab.path ||
430
- (tab.path !== "/" && page.route.startsWith(tab.path))
431
+ (tab.path !== "/" && isUnderPath(page.route, tab.path))
431
432
  ? "page"
432
433
  : undefined
433
434
  }
@@ -601,7 +602,20 @@ const bannerScript = banner?.dismissible
601
602
  button.setAttribute("aria-label", "Copy code");
602
603
  button.innerHTML = svg("copy");
603
604
  button.addEventListener("click", async () => {
604
- const text = pre.querySelector("code")?.textContent ?? "";
605
+ const code = pre.querySelector("code");
606
+ let text = code?.textContent ?? "";
607
+ // Twoslash nests each hover popup's type signature and docs inside
608
+ // the <code>; copying textContent verbatim would interleave them
609
+ // with the source. Strip the popups from a clone first.
610
+ if (code?.querySelector(".twoslash-popup-container")) {
611
+ const clone = code.cloneNode(true) as HTMLElement;
612
+ for (const popup of clone.querySelectorAll(
613
+ ".twoslash-popup-container"
614
+ )) {
615
+ popup.remove();
616
+ }
617
+ text = clone.textContent ?? "";
618
+ }
605
619
  try {
606
620
  await navigator.clipboard.writeText(text);
607
621
  } catch {
@@ -133,7 +133,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
133
133
  <script
134
134
  data-blume-search-popular
135
135
  is:inline
136
- set:html={JSON.stringify(popular)}
136
+ set:html={JSON.stringify(popular).replaceAll("<", "\\u003c")}
137
137
  type="application/json"
138
138
  />
139
139
 
@@ -190,6 +190,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
190
190
  selectables: Selectable[] = [];
191
191
  selectedIndex = -1;
192
192
  activeSection: string | null = null;
193
+ renderGeneration = 0;
193
194
  previewOn = true;
194
195
  devOnlyMsg = "Search is available in the production build.";
195
196
  noResultsMsg = "No results found.";
@@ -339,6 +340,8 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
339
340
 
340
341
  async render() {
341
342
  const query = this.input.value.trim();
343
+ this.renderGeneration += 1;
344
+ const generation = this.renderGeneration;
342
345
  this.selectables = [];
343
346
  this.selectedIndex = -1;
344
347
  this.results.replaceChildren();
@@ -364,8 +367,10 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
364
367
  locale: localeFilter,
365
368
  section: this.activeSection ?? undefined,
366
369
  });
367
- // A later keystroke may have superseded this query mid-await.
368
- if (this.input.value.trim() !== query) {
370
+ // Any newer render — a keystroke, a section pill, a locale toggle —
371
+ // supersedes this one mid-await, even for the same query text;
372
+ // appending the stale hits would duplicate rows and desync selection.
373
+ if (generation !== this.renderGeneration) {
369
374
  return;
370
375
  }
371
376
 
@@ -71,9 +71,13 @@ export const findBreadcrumbs = (nodes: NavNode[], route: string): Crumb[] => {
71
71
  return search(nodes, []) ?? [];
72
72
  };
73
73
 
74
- /** Whether `route` is the section root `base` or nested beneath it. */
75
- const isUnderPath = (route: string, base: string): boolean =>
76
- route === base || route.startsWith(`${base}/`);
74
+ /**
75
+ * Whether `route` is the section root `base` or nested beneath it. Requires a
76
+ * path boundary, so `/api-reference` is not under `/api`. The root `/` spans
77
+ * every route.
78
+ */
79
+ export const isUnderPath = (route: string, base: string): boolean =>
80
+ base === "/" || route === base || route.startsWith(`${base}/`);
77
81
 
78
82
  /**
79
83
  * The tab whose `path` is the longest prefix of `route`, mirroring the header's
@@ -714,18 +714,13 @@ export interface GithubConfig {
714
714
  export interface CodeConfig {
715
715
  /** Show a brand language icon in the code-block header. Defaults to `true`. */
716
716
  icons?: boolean;
717
- /**
718
- * Syntax-highlight inline `` `code{:lang}` `` snippets. Defaults to `false`;
719
- * opt a snippet in with a trailing `{:lang}` marker.
720
- */
721
- inline?: boolean;
722
717
  /** Wrap long lines instead of scrolling horizontally. Defaults to `false`. */
723
718
  wrap?: boolean;
724
719
  }
725
720
 
726
721
  /** Markdown / MDX rendering behavior. */
727
722
  export interface MarkdownConfig {
728
- /** Code-block rendering: language icons, inline highlighting, line wrap. */
723
+ /** Code-block rendering: language icons, line wrap. */
729
724
  code?: CodeConfig;
730
725
  /** Syntax-highlighting themes for fenced code blocks. */
731
726
  codeBlocks?: {
@@ -744,11 +739,6 @@ export interface MarkdownConfig {
744
739
  headingAnchors?: boolean;
745
740
  /** Make content images click-to-zoom (lightbox). Defaults to `true`. */
746
741
  imageZoom?: boolean;
747
- /**
748
- * Enable LaTeX math (`$…$` inline, `$$…$$` block) via KaTeX. Defaults to
749
- * `false` since `$` is common in prose and shell. MDX only.
750
- */
751
- math?: boolean;
752
742
  }
753
743
 
754
744
  // ---------------------------------------------------------------------------
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
 
@@ -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
  };
@@ -27,14 +27,32 @@ export const entriesDigest = (entries: SourceEntry[]): string =>
27
27
  * Build an opt-in polling watcher for a remote source: re-`load()` on an
28
28
  * interval and fire `onChange` only when the entry digest changes, so a remote
29
29
  * source can hot-reload in dev without refetching the world on every keystroke.
30
+ *
31
+ * `load` must fetch fresh (bypassing the cache-first dev path) — polling the
32
+ * cache-first loader would serve the identical snapshot on every tick and
33
+ * never observe a remote change. `seed` (the source's regular, cache-first
34
+ * loader) establishes the baseline digest from what dev actually served, so a
35
+ * remote change landing before the first tick still fires.
30
36
  */
31
37
  export const pollingWatch =
32
38
  (
33
39
  load: () => Promise<SourceLoadResult>,
34
- intervalSeconds: number
40
+ intervalSeconds: number,
41
+ seed?: () => Promise<SourceLoadResult>
35
42
  ): ((onChange: () => void) => () => void) =>
36
43
  (onChange) => {
37
44
  let last = "";
45
+ if (seed) {
46
+ const seedBaseline = async (): Promise<void> => {
47
+ try {
48
+ const { entries } = await seed();
49
+ last ||= entriesDigest(entries);
50
+ } catch {
51
+ // Fall back to first-tick seeding.
52
+ }
53
+ };
54
+ void seedBaseline();
55
+ }
38
56
  const tick = async (): Promise<void> => {
39
57
  try {
40
58
  const { entries } = await load();