@jxsuite/parser 1.8.2 → 2.0.1

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.
@@ -18,9 +18,9 @@
18
18
  */
19
19
 
20
20
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
21
- import { basename, dirname, extname, resolve } from "node:path";
21
+ import { basename, dirname, extname, relative, resolve } from "node:path";
22
22
  import { assetUrlFor } from "@jxsuite/schema/asset-paths";
23
- import { resolveI18n } from "@jxsuite/schema/locale";
23
+ import { localeUrlPrefix, resolveI18n } from "@jxsuite/schema/locale";
24
24
  import type { AssetMount } from "@jxsuite/schema/asset-paths";
25
25
  import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
26
26
  import type { FormatEntry, FormatHostIO, FormatRegistry } from "@jxsuite/schema/format-registry";
@@ -30,8 +30,25 @@ import type {
30
30
  JxMutableNode,
31
31
  ProjectConfig,
32
32
  } from "@jxsuite/schema/types";
33
+ import { normalizeAlerts } from "./alerts.ts";
34
+ import { rewriteLinks } from "./content-links.ts";
35
+ import type { FileOutcome, LinkIndex } from "./content-links.ts";
36
+ import {
37
+ directoryOf,
38
+ isDirectoryIndex,
39
+ parseRouteConfig,
40
+ renderRoute,
41
+ routeHref,
42
+ routeParams,
43
+ traversalReason,
44
+ } from "./content-routes.ts";
45
+ import type { RouteSpec } from "./content-routes.ts";
46
+ import { compileExclude, compileWhere } from "./content-rules.ts";
47
+ import type { ExcludeMatcher, WherePredicate } from "./content-rules.ts";
33
48
  import { coerceEntryDates, isCoercedDate, isDateFormat } from "./dates.ts";
49
+ import type { ContentNodes } from "./inert.ts";
34
50
  import type { ContentLoaderEntry, ContentTypeDef } from "./types.ts";
51
+ import { walkFiles } from "./walk.ts";
35
52
 
36
53
  export type { ContentEntry, ContentLoaderEntry, TocEntry } from "./types.ts";
37
54
 
@@ -63,6 +80,18 @@ export interface ResolvePathsContext {
63
80
  * reads it — it is the discriminator between two translations sharing an id.
64
81
  */
65
82
  locale?: string | null;
83
+ /**
84
+ * The URL pattern of the dynamic page being expanded (`/kb/:category/:slug`, `/kb/*`) and its
85
+ * parameter names in order. A content type with a `route` reads them to turn each entry's route
86
+ * into the parameters THIS page needs, so `$paths` never has to name a parameter.
87
+ */
88
+ urlPattern?: string;
89
+ params?: readonly string[];
90
+ /**
91
+ * The URL patterns of every dynamic page in the project, so a routed content type can report the
92
+ * entries no page at all would generate (a route no page shape produces is a dead link).
93
+ */
94
+ patterns?: readonly string[];
66
95
  }
67
96
 
68
97
  /** The `$paths` value shape routed to this extension by its `contentType` discriminator. */
@@ -101,18 +130,12 @@ function loadJSONEntries(filePath: string): ContentLoaderEntry[] {
101
130
  ];
102
131
  }
103
132
 
104
- /** Discover .json entry files for a source (single file or directory). */
105
- function discoverJSONFiles(resolvedSource: string): string[] {
133
+ /** Discover .json entry files for a source (single file or directory), in sorted order. */
134
+ function discoverJSONFiles(resolvedSource: string, exclude: ExcludeMatcher): string[] {
106
135
  if (extname(resolvedSource)) {
107
136
  return existsSync(resolvedSource) ? [resolvedSource] : [];
108
137
  }
109
- try {
110
- return readdirSync(resolvedSource, { recursive: true })
111
- .filter((f) => String(f).endsWith(".json"))
112
- .map((f) => resolve(resolvedSource, String(f)));
113
- } catch {
114
- return [];
115
- }
138
+ return walkFiles(resolvedSource, (name) => name.toLowerCase().endsWith(".json"), exclude);
116
139
  }
117
140
 
118
141
  // ─── Asset mounts and content-relative references ────────────────────────────
@@ -201,6 +224,44 @@ function isNonRelativeRef(value: string): boolean {
201
224
  );
202
225
  }
203
226
 
227
+ /** Document extensions: an entry of a collection is never an asset of it. */
228
+ const DOCUMENT_EXTENSIONS = new Set([".md", ".markdown", ".mdx"]);
229
+
230
+ /**
231
+ * What a content type's asset mount may serve, or `undefined` for "everything under the directory"
232
+ * (today's behavior, which every type that declares none of the vault options keeps).
233
+ *
234
+ * A type that declares `exclude`, `where` or `route` is publishing a folder of notes, and there the
235
+ * mount is the collection's front door, so it must not be a way around the collection's own rules:
236
+ * a file `exclude` names is not served however a page refers to it, nothing whose path has a dot
237
+ * segment is (`.git`, `.env` and `.obsidian` are what a source set to the repository root also
238
+ * contains), and a document is not (a note `where` left out would otherwise leave as a raw
239
+ * download, frontmatter and all). The paths it is asked about are relative to the mount directory,
240
+ * which is the source directory.
241
+ */
242
+ function mountFilterFor(def: ContentTypeDef | undefined): ((path: string) => boolean) | undefined {
243
+ if (!def || (def.exclude === undefined && def.where === undefined && def.route === undefined)) {
244
+ return undefined;
245
+ }
246
+ let exclude: ExcludeMatcher;
247
+ try {
248
+ exclude = compileExclude(def.exclude);
249
+ } catch {
250
+ // The same list fails the build when the type loads; until then, serve nothing.
251
+ return () => false;
252
+ }
253
+ return (path) => {
254
+ const segments = path.split("/");
255
+ if (segments.some((segment) => segment.startsWith("."))) {
256
+ return false;
257
+ }
258
+ if (DOCUMENT_EXTENSIONS.has(extname(path).toLowerCase())) {
259
+ return false;
260
+ }
261
+ return exclude.excludedBy(path) === undefined;
262
+ };
263
+ }
264
+
204
265
  /**
205
266
  * Asset mounts for a `content` section: every content type whose source is a local **directory**
206
267
  * publishes that directory at `/content/<type>`, so files sitting beside the entries (images,
@@ -218,6 +279,10 @@ export function contentAssetMounts(
218
279
  ): AssetMount[] {
219
280
  const mounts: AssetMount[] = [];
220
281
  const locales = localesForExpansion(projectConfig);
282
+ const filterOf = (def: ContentTypeDef | undefined): { filter?: (path: string) => boolean } => {
283
+ const filter = mountFilterFor(def);
284
+ return filter === undefined ? {} : { filter };
285
+ };
221
286
  for (const [name, contentTypeDef] of Object.entries(section ?? {})) {
222
287
  const source = contentTypeDef?.source;
223
288
  if (!source || source.startsWith("http://") || source.startsWith("https://")) {
@@ -237,7 +302,11 @@ export function contentAssetMounts(
237
302
  .split("\\")
238
303
  .join("/");
239
304
  if (SAFE_TYPE_NAME.test(name) && existsSync(dir) && statSync(dir).isDirectory()) {
240
- mounts.push({ dir, urlPrefix: `/${SECTION_KEY}/${name}/${locale}` });
305
+ mounts.push({
306
+ dir,
307
+ urlPrefix: `/${SECTION_KEY}/${name}/${locale}`,
308
+ ...filterOf(contentTypeDef),
309
+ });
241
310
  }
242
311
  }
243
312
  continue;
@@ -253,22 +322,31 @@ export function contentAssetMounts(
253
322
  if (extname(source) || !existsSync(resolvedSource) || !statSync(resolvedSource).isDirectory()) {
254
323
  continue;
255
324
  }
256
- mounts.push({ dir: resolvedSource, urlPrefix: `/${SECTION_KEY}/${name}` });
325
+ mounts.push({
326
+ dir: resolvedSource,
327
+ urlPrefix: `/${SECTION_KEY}/${name}`,
328
+ ...filterOf(contentTypeDef),
329
+ });
257
330
  }
258
331
  return mounts;
259
332
  }
260
333
 
334
+ /** Why a content-relative reference was left as written. */
335
+ type RefProblem = "missing" | "refused";
336
+
261
337
  /**
262
338
  * Rewrite one content-relative reference to its mounted URL, or null to leave the value untouched.
263
339
  * A value is rewritten only when it is relative, resolves against the entry's own directory to a
264
- * file that exists, and lands inside the mount — so nothing an existing project authored (a
265
- * project-root-relative path, an absolute URL, a bound template) changes meaning.
340
+ * file that exists, and lands inside the mount and where the mount serves — so nothing an existing
341
+ * project authored (a project-root-relative path, an absolute URL, a bound template) changes
342
+ * meaning. A file inside the collection that its mount refuses to serve (excluded, hidden or a
343
+ * document) is reported and left as written, so a published note cannot pull it into the site.
266
344
  */
267
345
  function rewriteRef(
268
346
  value: string,
269
347
  entryDir: string,
270
348
  mount: AssetMount,
271
- onMissing: (value: string) => void,
349
+ onProblem: (value: string, problem: RefProblem) => void,
272
350
  ): string | null {
273
351
  if (isNonRelativeRef(value)) {
274
352
  return null;
@@ -282,10 +360,13 @@ function rewriteRef(
282
360
  }
283
361
  const url = assetUrlFor([mount], target);
284
362
  if (!url) {
285
- return null; // Resolves outside the collection — not ours to publish
363
+ if (relativeTo(mount.dir, target) !== null && existsSync(target)) {
364
+ onProblem(value, "refused");
365
+ }
366
+ return null; // Resolves outside the collection, or is not served — not ours to publish
286
367
  }
287
368
  if (!existsSync(target)) {
288
- onMissing(value);
369
+ onProblem(value, "missing");
289
370
  return null;
290
371
  }
291
372
  const suffix = value.slice(bare.length);
@@ -294,11 +375,17 @@ function rewriteRef(
294
375
 
295
376
  /** Rewrite media references on one node and its children, in place. */
296
377
  function rewriteNodeAssets(
297
- node: JxElement | string | undefined,
378
+ node: JxElement | string | ContentNodes | undefined,
298
379
  entryDir: string,
299
380
  mount: AssetMount,
300
- onMissing: (value: string) => void,
381
+ onProblem: (value: string, problem: RefProblem) => void,
301
382
  ) {
383
+ if (Array.isArray(node)) {
384
+ for (const child of node) {
385
+ rewriteNodeAssets(child, entryDir, mount, onProblem);
386
+ }
387
+ return;
388
+ }
302
389
  if (!node || typeof node !== "object") {
303
390
  return;
304
391
  }
@@ -308,7 +395,7 @@ function rewriteNodeAssets(
308
395
  for (const holder of [record, attributes]) {
309
396
  const value = holder?.[key];
310
397
  if (typeof value === "string") {
311
- const rewritten = rewriteRef(value, entryDir, mount, onMissing);
398
+ const rewritten = rewriteRef(value, entryDir, mount, onProblem);
312
399
  if (rewritten) {
313
400
  holder![key] = rewritten;
314
401
  }
@@ -318,7 +405,7 @@ function rewriteNodeAssets(
318
405
  const { children } = record;
319
406
  if (Array.isArray(children)) {
320
407
  for (const child of children) {
321
- rewriteNodeAssets(child as JxElement | string, entryDir, mount, onMissing);
408
+ rewriteNodeAssets(child as JxElement | string | ContentNodes, entryDir, mount, onProblem);
322
409
  }
323
410
  }
324
411
  }
@@ -351,22 +438,26 @@ export function rewriteEntryAssets(
351
438
  .map(([field]) => field);
352
439
 
353
440
  for (const entry of entries) {
354
- const onMissing = (value: string) =>
441
+ const onProblem = (value: string, problem: RefProblem) =>
355
442
  console.warn(
356
- `Content type "${typeName}": entry "${entry.id}" references missing asset "${value}"`,
443
+ problem === "missing"
444
+ ? `Content type "${typeName}": entry "${entry.id}" references missing asset "${value}"`
445
+ : `Content type "${typeName}": entry "${entry.id}" references "${value}", which the ` +
446
+ `collection does not publish (it is excluded, hidden or a document), so the ` +
447
+ `reference is left as written`,
357
448
  );
358
449
 
359
450
  for (const child of entry.$children ?? []) {
360
- rewriteNodeAssets(child, entryDir, mount, onMissing);
451
+ rewriteNodeAssets(child, entryDir, mount, onProblem);
361
452
  }
362
453
 
363
454
  for (const field of uriFields) {
364
455
  const value = entry.data[field];
365
456
  if (typeof value === "string") {
366
- entry.data[field] = rewriteRef(value, entryDir, mount, onMissing) ?? value;
457
+ entry.data[field] = rewriteRef(value, entryDir, mount, onProblem) ?? value;
367
458
  } else if (Array.isArray(value)) {
368
459
  entry.data[field] = value.map((item) =>
369
- typeof item === "string" ? (rewriteRef(item, entryDir, mount, onMissing) ?? item) : item,
460
+ typeof item === "string" ? (rewriteRef(item, entryDir, mount, onProblem) ?? item) : item,
370
461
  );
371
462
  }
372
463
  }
@@ -425,7 +516,10 @@ export async function loadContentSection(
425
516
  for (const [name, contentTypeDef] of Object.entries(section)) {
426
517
  const source = contentTypeDef?.source;
427
518
  if (typeof source !== "string" || !source.includes(LOCALE_PLACEHOLDER)) {
428
- const entries = await loadContentType(name, contentTypeDef, root, formats, mounts.get(name));
519
+ const entries = await loadContentType(name, contentTypeDef, root, formats, {
520
+ mount: mounts.get(name),
521
+ projectConfig,
522
+ });
429
523
  contentTypes.set(name, entries);
430
524
  continue;
431
525
  }
@@ -445,13 +539,11 @@ export async function loadContentSection(
445
539
  const all: ContentLoaderEntry[] = [];
446
540
  for (const locale of locales) {
447
541
  const localized = { ...contentTypeDef, source: localeSource(root, source, locale) };
448
- const entries = await loadContentType(
449
- name,
450
- localized,
451
- root,
452
- formats,
453
- mounts.get(`${name}/${locale}`),
454
- );
542
+ const entries = await loadContentType(name, localized, root, formats, {
543
+ locale,
544
+ mount: mounts.get(`${name}/${locale}`),
545
+ projectConfig,
546
+ });
455
547
  for (const entry of entries) {
456
548
  entry._meta = { ...entry._meta, locale };
457
549
  }
@@ -481,6 +573,526 @@ export function getContentTypeElements(
481
573
  return def?.$elements;
482
574
  }
483
575
 
576
+ // ─── Per-type rules ──────────────────────────────────────────────────────────
577
+
578
+ /** How a link that has no page is reported (`links`). */
579
+ type LinkMode = "warn" | "error" | "ignore";
580
+
581
+ /** A content type's declarative options, validated once per load. */
582
+ interface TypeRules {
583
+ exclude: ExcludeMatcher;
584
+ where: WherePredicate | null;
585
+ idField: string | undefined;
586
+ route: RouteSpec | null;
587
+ links: LinkMode;
588
+ alerts: unknown;
589
+ }
590
+
591
+ /** Every key a content type's definition means something by. */
592
+ const KNOWN_TYPE_KEYS = [
593
+ "$elements",
594
+ "alerts",
595
+ "exclude",
596
+ "format",
597
+ "idField",
598
+ "indexRoute",
599
+ "links",
600
+ "route",
601
+ "schema",
602
+ "source",
603
+ "where",
604
+ ] as const;
605
+
606
+ /** Edit distance between two short strings, for the "did you mean" of a misspelled option. */
607
+ function editDistance(a: string, b: string): number {
608
+ let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
609
+ for (let i = 1; i <= a.length; i++) {
610
+ const row = [i];
611
+ for (let j = 1; j <= b.length; j++) {
612
+ row[j] = Math.min(
613
+ row[j - 1]! + 1,
614
+ previous[j]! + 1,
615
+ previous[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
616
+ );
617
+ }
618
+ previous = row;
619
+ }
620
+ return previous[b.length]!;
621
+ }
622
+
623
+ /**
624
+ * The option a misspelled key most plausibly meant, or undefined when none is close.
625
+ *
626
+ * The options exist to keep things OUT of a published site, so a typo in one fails open: `excludes`
627
+ * would be ignored and the private folder it was meant to hide would be published. That is why a
628
+ * near miss is a build error and not a note.
629
+ */
630
+ function nearestOption(key: string): string | undefined {
631
+ const lower = key.toLowerCase();
632
+ let best: string | undefined;
633
+ let bestDistance = 3;
634
+ for (const known of KNOWN_TYPE_KEYS) {
635
+ const distance = editDistance(lower, known.toLowerCase());
636
+ if (distance < bestDistance) {
637
+ best = known;
638
+ bestDistance = distance;
639
+ }
640
+ }
641
+ return best;
642
+ }
643
+
644
+ /**
645
+ * Validate and compile a content type's `exclude`, `where`, `idField`, `route`, `links` and
646
+ * `alerts`. A malformed option is a build failure that names the content type, never a silently
647
+ * ignored key: `where: { publish: "yes" }` that quietly matched nothing would publish an empty site
648
+ * and say nothing, and neither would `excludes` (for `exclude`) say it was being ignored.
649
+ */
650
+ function compileTypeRules(name: string, def: ContentTypeDef): TypeRules {
651
+ const at = `Content type "${name}"`;
652
+ try {
653
+ for (const key of Object.keys(def)) {
654
+ if ((KNOWN_TYPE_KEYS as readonly string[]).includes(key) || key === "$comment") {
655
+ continue;
656
+ }
657
+ const suggestion = nearestOption(key);
658
+ if (suggestion !== undefined) {
659
+ throw new TypeError(`unknown option "${key}", did you mean "${suggestion}"?`);
660
+ }
661
+ console.warn(
662
+ `${at}: ignores the unknown option "${key}" (the options are ${KNOWN_TYPE_KEYS.join(", ")})`,
663
+ );
664
+ }
665
+ if (def.idField !== undefined && (typeof def.idField !== "string" || def.idField === "")) {
666
+ throw new TypeError(`"idField" must be a field name`);
667
+ }
668
+ const links = def.links ?? "warn";
669
+ if (links !== "warn" && links !== "error" && links !== "ignore") {
670
+ throw new TypeError(
671
+ `"links" must be "warn", "error" or "ignore", got ${JSON.stringify(links)}`,
672
+ );
673
+ }
674
+ normalizeAlerts(def.alerts, `"alerts"`);
675
+ return {
676
+ alerts: def.alerts,
677
+ exclude: compileExclude(def.exclude),
678
+ idField: def.idField,
679
+ links,
680
+ route: parseRouteConfig(def.route, def.indexRoute, name),
681
+ where: def.where === undefined ? null : compileWhere(def.where),
682
+ };
683
+ } catch (error) {
684
+ const message = errorMessage(error);
685
+ throw new Error(message.startsWith(at) ? message : `${at}: ${message}`, { cause: error });
686
+ }
687
+ }
688
+
689
+ /** The path of a file relative to a source directory, `/`-separated, or null when it is outside. */
690
+ function relativeTo(root: string, file: string): string | null {
691
+ const rel = relative(root, file).split("\\").join("/");
692
+ return rel === "" || rel === ".." || rel.startsWith("../") ? null : rel;
693
+ }
694
+
695
+ /** One source file and the entries it yielded, before the collection-wide rules run. */
696
+ interface LoadedFile {
697
+ filePath: string;
698
+ /** Relative to the source directory; null for a single-file or remote source. */
699
+ rel: string | null;
700
+ entries: ContentLoaderEntry[];
701
+ }
702
+
703
+ /** Everything {@link finishEntries} needs that is not the entries themselves. */
704
+ interface FinishContext {
705
+ name: string;
706
+ schema: ContentTypeSchema | undefined;
707
+ rules: TypeRules;
708
+ mount: AssetMount | undefined;
709
+ projectConfig: ProjectConfig | undefined;
710
+ locale: string | undefined;
711
+ /** Whether the format branch's date-coercion pass applies (it does not for JSON and remote). */
712
+ coerceDates: boolean;
713
+ /** Extensions of the collection's entry files, for link resolution. */
714
+ entryExtensions: Set<string>;
715
+ /** The source directory, when there is one. */
716
+ sourceRoot: string | undefined;
717
+ }
718
+
719
+ /**
720
+ * The collection-wide pass every source kind goes through once its files are loaded.
721
+ *
722
+ * Order matters and is the whole design. `where` runs first, so an entry that is not published is
723
+ * never validated, coerced, given a route or counted as a duplicate. Ids are settled before
724
+ * anything keyed by id (relationships, duplicate checks, routes that read `{id}`). Routes are
725
+ * computed before links, because a link can only be resolved once the page it points at has a URL.
726
+ *
727
+ * @returns {ContentLoaderEntry[]} The kept entries, in load order
728
+ */
729
+ function finishEntries(files: LoadedFile[], ctx: FinishContext): ContentLoaderEntry[] {
730
+ const { name, rules } = ctx;
731
+
732
+ // 1. where: split every file's entries into kept and left out.
733
+ const kept: LoadedFile[] = [];
734
+ const unpublished = new Map<string, string>();
735
+ let loadedCount = 0;
736
+ for (const file of files) {
737
+ const keep: ContentLoaderEntry[] = [];
738
+ for (const entry of file.entries) {
739
+ loadedCount += 1;
740
+ if (rules.where && !rules.where(entry.data)) {
741
+ if (file.rel !== null && file.entries.length === 1) {
742
+ unpublished.set(file.rel, `left out by where.${rules.where.why(entry.data) ?? "filter"}`);
743
+ }
744
+ } else {
745
+ keep.push(entry);
746
+ }
747
+ }
748
+ if (keep.length > 0) {
749
+ kept.push({ ...file, entries: keep });
750
+ }
751
+ }
752
+ /* A filter that removes every document is nearly always a mistake in the filter (`publish: "true"`
753
+ against a YAML boolean), and the result is an empty site that builds without complaint. */
754
+ if (rules.where && loadedCount > 0 && kept.length === 0) {
755
+ const [first] = files.flatMap((file) => file.entries);
756
+ console.warn(
757
+ `Content type "${name}": "where" left out all ${loadedCount} entries, so the collection is ` +
758
+ `empty (it failed on "${first ? (rules.where.why(first.data) ?? "?") : "?"}" for the first one). ` +
759
+ `Check the field names and the value types: YAML \`true\` is a boolean, "true" is text.`,
760
+ );
761
+ }
762
+
763
+ // 2. Per kept entry: where it came from, and content-relative assets onto the mount.
764
+ for (const file of kept) {
765
+ for (const entry of file.entries) {
766
+ if (file.rel !== null) {
767
+ entry._meta = { ...entry._meta, path: file.rel };
768
+ }
769
+ }
770
+ if (ctx.mount) {
771
+ rewriteEntryAssets(file.entries, file.filePath, ctx.mount, name, ctx.schema);
772
+ }
773
+ }
774
+ const entries = kept.flatMap((file) => file.entries);
775
+
776
+ // 3. Ids: a README beside an index, then the frontmatter field the type names, then duplicates.
777
+ settleIndexIds(name, entries);
778
+ if (rules.idField) {
779
+ for (const entry of entries) {
780
+ const value = entry.data[rules.idField];
781
+ if ((typeof value === "string" && value !== "") || typeof value === "number") {
782
+ /* An id becomes a directory name under the output (`$paths` hands it to a page), and a
783
+ frontmatter value is not trusted to stay inside it. */
784
+ const unsafe = traversalReason(String(value));
785
+ if (unsafe === null) {
786
+ entry.id = String(value);
787
+ } else {
788
+ console.warn(
789
+ `Content ids: "${name}/${entry.id}" has a "${rules.idField}" that ${unsafe}, ` +
790
+ `so it keeps its path id`,
791
+ );
792
+ }
793
+ } else {
794
+ console.warn(
795
+ `Content ids: "${name}/${entry.id}" has no usable "${rules.idField}" field, ` +
796
+ `so it keeps its path id`,
797
+ );
798
+ }
799
+ }
800
+ }
801
+ const byId = new Map<string, ContentLoaderEntry>();
802
+ for (const entry of entries) {
803
+ const prior = byId.get(entry.id);
804
+ if (prior) {
805
+ console.warn(
806
+ `Content ids: "${name}" has two entries with the id "${entry.id}" ` +
807
+ `(${prior._meta?.path ?? "an earlier entry"} and ${entry._meta?.path ?? "a later entry"}). ` +
808
+ `A lookup finds only the first; give one of them a distinct ${rules.idField ? `"${rules.idField}"` : "name"}.`,
809
+ );
810
+ } else {
811
+ byId.set(entry.id, entry);
812
+ }
813
+ }
814
+
815
+ // 4. Dates, then validation: kept entries only.
816
+ if (ctx.schema) {
817
+ if (ctx.coerceDates) {
818
+ for (const warning of coerceEntryDates(entries, ctx.schema, name)) {
819
+ console.warn(warning.message);
820
+ }
821
+ }
822
+ validateEntries(entries, ctx.schema, name);
823
+ }
824
+
825
+ // 5. Routes, and links over the finished routes.
826
+ if (rules.route) {
827
+ assignRoutes(entries, unpublished, ctx, rules.route);
828
+ if (ctx.sourceRoot) {
829
+ resolveEntryLinks(kept, unpublished, ctx);
830
+ }
831
+ /* An entry the template could not route, or whose route another entry holds, has no page, and
832
+ a collection is what every consumer (a listing, the search index, a feed) publishes from. Left
833
+ in, each of them would invent a URL for it from its own idea of one and point readers at a
834
+ 404, and the document the build calls unpublished would be in the public search index. The
835
+ link index above already knows why it has no page; the collection does not carry it. */
836
+ return entries.filter((entry) => entry._meta?.route !== undefined);
837
+ }
838
+ return entries;
839
+ }
840
+
841
+ /**
842
+ * Keep a folder's id for its `index` file when its `README` would also claim it.
843
+ *
844
+ * A README maps to its folder (`Frappe/README.md` is `Frappe`), and so has `index.md` since before
845
+ * READMEs did. A folder holding both used to give them two ids and now would give them one, with
846
+ * whichever sorted first (README, by case) silently winning an `index.md` that is older and means
847
+ * the same thing. The index keeps the folder's id, as links and routes already assume, and the
848
+ * README keeps the id it had before the mapping existed.
849
+ */
850
+ function settleIndexIds(name: string, entries: ContentLoaderEntry[]): void {
851
+ const indexFolders = new Set<string>();
852
+ for (const entry of entries) {
853
+ const path = entry._meta?.path;
854
+ if (path !== undefined && isDirectoryIndex(path) && !/^readme\./i.test(basename(path))) {
855
+ indexFolders.add(directoryOf(path));
856
+ }
857
+ }
858
+ for (const entry of entries) {
859
+ const path = entry._meta?.path;
860
+ if (path === undefined || !/^readme\./i.test(basename(path))) {
861
+ continue;
862
+ }
863
+ const folder = directoryOf(path);
864
+ if (folder !== "" && entry.id === folder && indexFolders.has(folder)) {
865
+ entry.id = path.slice(0, path.length - extname(path).length);
866
+ console.warn(
867
+ `Content ids: "${name}" has both "${path}" and an index file in "${folder}"; the index ` +
868
+ `keeps the id "${folder}" and the README is "${entry.id}".`,
869
+ );
870
+ }
871
+ }
872
+ }
873
+
874
+ /** The site's `build.trailingSlash`, defaulting to `"always"` exactly as the build does. */
875
+ function trailingSlashOf(projectConfig: ProjectConfig | undefined): string {
876
+ const value = projectConfig?.build?.trailingSlash;
877
+ return typeof value === "string" ? value : "always";
878
+ }
879
+
880
+ /** The URL prefix a collection's locale puts in front of its routes (`/fr`), or `""`. */
881
+ function localePrefixOf(ctx: FinishContext): string {
882
+ const { i18n } = resolveI18n(ctx.projectConfig ?? {});
883
+ return localeUrlPrefix(ctx.locale, i18n);
884
+ }
885
+
886
+ /**
887
+ * Stamp `_meta.route` and `_meta.url` on every entry whose route template renders.
888
+ *
889
+ * An entry the template cannot render (a missing `category`) and an entry whose route another
890
+ * already holds are both left without a route and reported with the file names involved: the second
891
+ * would otherwise overwrite the first's page, or hand a link to the wrong document. Both are
892
+ * recorded as unpublished so a link to one says why it has no page.
893
+ */
894
+ function assignRoutes(
895
+ entries: ContentLoaderEntry[],
896
+ unpublished: Map<string, string>,
897
+ ctx: FinishContext,
898
+ spec: RouteSpec,
899
+ ): void {
900
+ const trailingSlash = trailingSlashOf(ctx.projectConfig);
901
+ const prefix = localePrefixOf(ctx);
902
+ const owner = new Map<string, string>();
903
+ /* One line per entry says what is wrong with it, until there are enough that one line per entry
904
+ is a wall: a template naming a field no document has (`{slugg}`) fails for every entry with the
905
+ same reason, and naming the reason once with a count and a few examples is the useful report. */
906
+ const unrouted = new Map<string, { id: string; path: string }[]>();
907
+ for (const entry of entries) {
908
+ const path = entry._meta?.path ?? entry.id;
909
+ const rendered = renderRoute(spec, { data: entry.data, id: entry.id, path });
910
+ if ("error" in rendered) {
911
+ unrouted.set(rendered.error, [
912
+ ...(unrouted.get(rendered.error) ?? []),
913
+ { id: entry.id, path },
914
+ ]);
915
+ unpublished.set(path, "it has no route");
916
+ continue;
917
+ }
918
+ const prior = owner.get(rendered.route);
919
+ if (prior !== undefined) {
920
+ console.warn(
921
+ `Content routes: "${ctx.name}" has two entries at ${rendered.route} (${prior} and ${path}). ` +
922
+ `Only ${prior} is routed; make the route unique, for example by adding a field to the template.`,
923
+ );
924
+ unpublished.set(path, `its route ${rendered.route} is already used by ${prior}`);
925
+ continue;
926
+ }
927
+ owner.set(rendered.route, path);
928
+ entry._meta = {
929
+ ...entry._meta,
930
+ route: rendered.route,
931
+ url: routeHref(rendered.route, trailingSlash, prefix),
932
+ };
933
+ }
934
+ for (const [reason, list] of unrouted) {
935
+ if (list.length <= UNROUTED_LISTED) {
936
+ for (const { id, path } of list) {
937
+ console.warn(`Content routes: "${ctx.name}/${id}" (${path}) has no route: ${reason}`);
938
+ }
939
+ } else {
940
+ const shown = list.slice(0, UNROUTED_LISTED).map(({ path }) => path);
941
+ console.warn(
942
+ `Content routes: "${ctx.name}": ${list.length} entries have no route: ${reason} ` +
943
+ `(for example ${shown.join(", ")})`,
944
+ );
945
+ }
946
+ }
947
+ }
948
+
949
+ /** How many entries a "has no route" report lists by name before it summarizes. */
950
+ const UNROUTED_LISTED = 3;
951
+
952
+ /** One broken link, for the error-mode summary. */
953
+ interface BrokenLink {
954
+ from: string;
955
+ href: string;
956
+ reason: string;
957
+ }
958
+
959
+ /**
960
+ * Rewrite the relative links of every kept entry to the routes of the entries they name.
961
+ *
962
+ * Builds the {@link LinkIndex} the resolver needs from what the loader knows (every file it loaded,
963
+ * which of them are published, which directory each README or index stands for), then reports each
964
+ * link that has no page at the severity `links` asks for. In `error` mode all of them are gathered
965
+ * and thrown together, so one build names every broken link instead of one per run.
966
+ */
967
+ function resolveEntryLinks(
968
+ kept: LoadedFile[],
969
+ unpublished: Map<string, string>,
970
+ ctx: FinishContext,
971
+ ): void {
972
+ const { name, rules, sourceRoot } = ctx;
973
+ const files = new Map<string, FileOutcome>();
974
+ const indexes = new Map<string, string>();
975
+ const isReadme = (rel: string) => /^readme\./i.test(rel.slice(rel.lastIndexOf("/") + 1));
976
+ const noteIndex = (rel: string) => {
977
+ const dir = directoryOf(rel);
978
+ const prior = indexes.get(dir);
979
+ // `index` beats `README` when a directory has both, matching how ids are derived.
980
+ if (prior === undefined || (isReadme(prior) && !isReadme(rel))) {
981
+ indexes.set(dir, rel);
982
+ }
983
+ };
984
+ for (const file of kept) {
985
+ const [entry] = file.entries;
986
+ if (file.rel === null || file.entries.length !== 1 || !entry) {
987
+ continue;
988
+ }
989
+ const route = entry._meta?.route;
990
+ files.set(
991
+ file.rel,
992
+ route === undefined
993
+ ? { unpublished: unpublished.get(file.rel) ?? "it has no route" }
994
+ : { route },
995
+ );
996
+ if (isDirectoryIndex(file.rel)) {
997
+ noteIndex(file.rel);
998
+ }
999
+ }
1000
+ for (const [rel, reason] of unpublished) {
1001
+ if (!files.has(rel)) {
1002
+ files.set(rel, { unpublished: reason });
1003
+ if (isDirectoryIndex(rel)) {
1004
+ noteIndex(rel);
1005
+ }
1006
+ }
1007
+ }
1008
+
1009
+ const onDisk = (path: string) => resolve(sourceRoot!, path);
1010
+ const index: LinkIndex = {
1011
+ asset: (path) => {
1012
+ const absolute = onDisk(path);
1013
+ if (!ctx.mount || !existsSync(absolute) || statSync(absolute).isDirectory()) {
1014
+ return;
1015
+ }
1016
+ const pattern = rules.exclude.excludedBy(path);
1017
+ if (pattern !== undefined) {
1018
+ return { unpublished: `excluded by "${pattern}"` };
1019
+ }
1020
+ const url = assetUrlFor([ctx.mount], absolute);
1021
+ return url === null ? { unpublished: "the collection does not serve a file there" } : { url };
1022
+ },
1023
+ entryExtensions: ctx.entryExtensions,
1024
+ excludedBy: (path) => rules.exclude.excludedBy(path),
1025
+ excludesDir: (path) => rules.exclude.excludesDir(path),
1026
+ exists: (path) => existsSync(onDisk(path)),
1027
+ files,
1028
+ indexes,
1029
+ isDirectory: (path) => {
1030
+ try {
1031
+ return statSync(onDisk(path)).isDirectory();
1032
+ } catch {
1033
+ return false;
1034
+ }
1035
+ },
1036
+ localePrefix: localePrefixOf(ctx),
1037
+ trailingSlash: trailingSlashOf(ctx.projectConfig),
1038
+ };
1039
+
1040
+ const broken: BrokenLink[] = [];
1041
+ const seen = new Set<string>();
1042
+ for (const file of kept) {
1043
+ for (const entry of file.entries) {
1044
+ if (!entry.$children || file.rel === null) {
1045
+ continue;
1046
+ }
1047
+ rewriteLinks(entry.$children as ContentNodes, file.rel, index, (problem) => {
1048
+ const key = `${file.rel}\0${problem.href}`;
1049
+ if (!seen.has(key)) {
1050
+ seen.add(key);
1051
+ broken.push({ from: file.rel!, href: problem.href, reason: problem.reason });
1052
+ }
1053
+ });
1054
+ }
1055
+ }
1056
+ if (rules.links === "ignore" || broken.length === 0) {
1057
+ return;
1058
+ }
1059
+ const lines = broken.map(
1060
+ ({ from, href, reason }) => `"${from}" links to "${href}", which ${reason}`,
1061
+ );
1062
+ if (rules.links === "error") {
1063
+ const plural = broken.length === 1 ? "" : "s";
1064
+ const list = lines.map((line) => ` - ${line}`).join("\n");
1065
+ throw new Error(`Content links: "${name}" has ${broken.length} broken link${plural}:\n${list}`);
1066
+ }
1067
+ /* A cause that is not in the links themselves (a template naming a field nobody has leaves every
1068
+ entry unrouted) turns into a line per link, which buries the cause. Past a screenful the rest is
1069
+ counted, and `links: "error"` still lists every one. */
1070
+ for (const line of lines.slice(0, LINK_WARNINGS_LISTED)) {
1071
+ console.warn(`Content links: "${name}": ${line}; it renders as plain text.`);
1072
+ }
1073
+ if (lines.length > LINK_WARNINGS_LISTED) {
1074
+ console.warn(
1075
+ `Content links: "${name}": and ${lines.length - LINK_WARNINGS_LISTED} more broken links ` +
1076
+ `(set "links": "error" to fail the build with every one listed).`,
1077
+ );
1078
+ }
1079
+ }
1080
+
1081
+ /** How many broken links a `links: "warn"` build names before it counts the rest. */
1082
+ const LINK_WARNINGS_LISTED = 25;
1083
+
1084
+ // ─── Content Type Loading ────────────────────────────────────────────────────
1085
+
1086
+ /** Per-load context a caller threads into {@link loadContentType}. */
1087
+ interface LoadOptions {
1088
+ /** The type's asset mount; content-relative references remap onto it. */
1089
+ mount?: AssetMount | undefined;
1090
+ /** The project config: `build.trailingSlash` and the i18n routing rules. */
1091
+ projectConfig?: ProjectConfig | undefined;
1092
+ /** The locale this load is for, when the source carried `{locale}`. */
1093
+ locale?: string | undefined;
1094
+ }
1095
+
484
1096
  /**
485
1097
  * Load a single content type by its definition, dispatching through the format registry.
486
1098
  *
@@ -488,7 +1100,7 @@ export function getContentTypeElements(
488
1100
  * @param {ContentTypeDef} contentTypeDef - Content type definition from the `content` section
489
1101
  * @param {string} projectRoot - Absolute path to project root directory
490
1102
  * @param {FormatRegistry} registry - Format-dispatch view of the extension registry
491
- * @param {AssetMount} [mount] - The type's asset mount; content-relative references remap onto it
1103
+ * @param {LoadOptions} [options] - Asset mount, project config and locale for this load
492
1104
  * @returns {Promise<ContentLoaderEntry[]>} Array of ContentEntry
493
1105
  */
494
1106
  async function loadContentType(
@@ -496,13 +1108,27 @@ async function loadContentType(
496
1108
  contentTypeDef: ContentTypeDef,
497
1109
  projectRoot: string,
498
1110
  registry: FormatRegistry,
499
- mount?: AssetMount,
1111
+ options: LoadOptions = {},
500
1112
  ) {
501
1113
  const { source } = contentTypeDef;
502
1114
  if (!source) {
503
1115
  return [];
504
1116
  }
1117
+ const { mount, projectConfig, locale } = options;
505
1118
  const schema = contentTypeDef.schema as ContentTypeSchema | undefined;
1119
+ const rules = compileTypeRules(name, contentTypeDef);
1120
+ const finishContext = (extra: Partial<FinishContext>): FinishContext => ({
1121
+ coerceDates: false,
1122
+ entryExtensions: new Set<string>(),
1123
+ locale,
1124
+ mount,
1125
+ name,
1126
+ projectConfig,
1127
+ rules,
1128
+ schema,
1129
+ sourceRoot: undefined,
1130
+ ...extra,
1131
+ });
506
1132
 
507
1133
  // Derive directive allowedNames from content type $elements (tag names from npm packages)
508
1134
  const directiveOptions = contentTypeDef.$elements?.length
@@ -542,39 +1168,46 @@ async function loadContentType(
542
1168
  `(its format block lacks "remote": true).`,
543
1169
  );
544
1170
  }
1171
+ let remote: ContentLoaderEntry[];
545
1172
  try {
546
- const entries = (await entry.call("load", source, {
1173
+ remote = (await entry.call("load", source, {
547
1174
  directiveOptions,
548
1175
  schema,
1176
+ ...(rules.idField !== undefined && { idField: rules.idField }),
549
1177
  })) as ContentLoaderEntry[];
550
- if (schema) {
551
- validateEntries(entries, schema, name);
552
- }
553
- return entries;
554
1178
  } catch (error) {
555
1179
  console.warn(`Content type "${name}": ${errorMessage(error)}`);
556
1180
  return [];
557
1181
  }
1182
+ return finishEntries([{ entries: remote, filePath: source, rel: null }], finishContext({}));
558
1183
  }
559
1184
 
560
1185
  const resolvedSource = resolve(projectRoot, source).split("\\").join("/");
561
1186
  const ext = extname(source).toLowerCase();
1187
+ // Directory sources pass their resolved root so formats can derive path-based ids for nested files
1188
+ const sourceRoot = extname(source) ? undefined : resolvedSource;
1189
+ /** Whether a discovered file is left out by `exclude` (a format class may not honor the option). */
1190
+ const isExcluded = (file: string) => {
1191
+ const rel = sourceRoot === undefined ? null : relativeTo(sourceRoot, file);
1192
+ return rel !== null && rules.exclude.excludedBy(rel) !== undefined;
1193
+ };
1194
+ const relOf = (file: string) => (sourceRoot === undefined ? null : relativeTo(sourceRoot, file));
562
1195
 
563
1196
  // JSON — the native built-in
564
1197
  if (formatName === "json" || (!entry && ext === ".json")) {
565
- const files = discoverJSONFiles(resolvedSource);
566
- const entries: ContentLoaderEntry[] = [];
567
- for (const filePath of files) {
568
- const fileEntries = loadJSONEntries(filePath);
569
- if (mount) {
570
- rewriteEntryAssets(fileEntries, filePath, mount, name, schema);
1198
+ const loaded: LoadedFile[] = [];
1199
+ for (const filePath of discoverJSONFiles(resolvedSource, rules.exclude)) {
1200
+ const rel = relOf(filePath);
1201
+ try {
1202
+ loaded.push({ entries: loadJSONEntries(filePath), filePath, rel });
1203
+ } catch (error) {
1204
+ throw unreadableFile(name, rel ?? filePath, error);
571
1205
  }
572
- entries.push(...fileEntries);
573
1206
  }
574
- if (schema) {
575
- validateEntries(entries, schema, name);
576
- }
577
- return entries;
1207
+ return finishEntries(
1208
+ loaded,
1209
+ finishContext({ entryExtensions: new Set([".json"]), sourceRoot }),
1210
+ );
578
1211
  }
579
1212
 
580
1213
  // Derive the format from the source extension when no explicit format is named
@@ -589,25 +1222,43 @@ async function loadContentType(
589
1222
  }
590
1223
 
591
1224
  // Discover entry files via the class, then load each
592
- const files = entry.capabilities.discover
1225
+ const discovered = entry.capabilities.discover
593
1226
  ? ((await entry.call("discover", source, {
594
1227
  baseDir: projectRoot,
1228
+ ...(rules.exclude.empty ? {} : { exclude: contentTypeDef.exclude }),
595
1229
  })) as string[])
596
1230
  : [resolvedSource];
1231
+ const files = discovered.filter((file) => !isExcluded(file));
597
1232
 
598
- // Directory sources pass their resolved root so formats can derive path-based ids for nested files
599
- const sourceRoot = extname(source) ? undefined : resolvedSource;
600
- const entries: ContentLoaderEntry[] = [];
1233
+ const fileExtensions = files.map((file) => extname(file).toLowerCase());
1234
+ const loaded: LoadedFile[] = [];
601
1235
  for (const filePath of files) {
602
- const fileEntries = (await entry.call("load", filePath, {
603
- directiveOptions,
604
- schema,
605
- ...(sourceRoot !== undefined && { sourceRoot }),
606
- })) as ContentLoaderEntry[];
607
- if (mount) {
608
- rewriteEntryAssets(fileEntries, filePath, mount, name, schema);
1236
+ const rel = relOf(filePath);
1237
+ let fileEntries: ContentLoaderEntry[];
1238
+ try {
1239
+ fileEntries = (await entry.call("load", filePath, {
1240
+ directiveOptions,
1241
+ schema,
1242
+ ...(sourceRoot !== undefined && { sourceRoot }),
1243
+ ...(rules.alerts !== undefined && { alerts: rules.alerts }),
1244
+ ...(rules.idField !== undefined && { idField: rules.idField }),
1245
+ onUnknownAlert: (type: string) =>
1246
+ console.warn(
1247
+ `Content callouts: "${name}": "${rel ?? filePath}" has a [!${type}] callout, but ` +
1248
+ `"${type.toLowerCase()}" is not an enabled type, so it stays a blockquote with the ` +
1249
+ `marker showing. Add "alerts": { "${type.toUpperCase()}": true } to render it.`,
1250
+ ),
1251
+ })) as ContentLoaderEntry[];
1252
+ } catch (error) {
1253
+ throw unreadableFile(name, rel ?? filePath, error);
609
1254
  }
610
- entries.push(...fileEntries);
1255
+ loaded.push({ entries: fileEntries, filePath, rel });
1256
+ }
1257
+ if (loaded.length === 0 && !rules.exclude.empty) {
1258
+ console.warn(
1259
+ `Content type "${name}": no file was loaded from "${source}" with "exclude" set; if the ` +
1260
+ `folder is not empty, an exclude pattern is hiding all of it.`,
1261
+ );
611
1262
  }
612
1263
 
613
1264
  /*
@@ -615,14 +1266,26 @@ async function loadContentType(
615
1266
  * both the entries and the schema: `Csv.load` receives a schema and `Markdown.load` does not, so
616
1267
  * doing it per-format would mean doing it twice and missing every third-party format class.
617
1268
  */
618
- if (schema) {
619
- for (const warning of coerceEntryDates(entries, schema, name)) {
620
- console.warn(warning.message);
621
- }
622
- validateEntries(entries, schema, name);
623
- }
1269
+ return finishEntries(
1270
+ loaded,
1271
+ finishContext({
1272
+ coerceDates: true,
1273
+ entryExtensions: new Set([...entry.extensions, ...fileExtensions]),
1274
+ sourceRoot,
1275
+ }),
1276
+ );
1277
+ }
624
1278
 
625
- return entries;
1279
+ /**
1280
+ * The error for one file that would not load, with its path: a parse failure names a line and a
1281
+ * reason but never the file, and a vault is hundreds of them.
1282
+ */
1283
+ function unreadableFile(type: string, path: string, error: unknown): Error {
1284
+ return new Error(
1285
+ `Content type "${type}": cannot read "${path}": ${errorMessage(error)}. Fix the file, or add it ` +
1286
+ `to "exclude" if it is not a document (a templates folder, for example).`,
1287
+ { cause: error },
1288
+ );
626
1289
  }
627
1290
 
628
1291
  /** Error message for an unregistered non-JSON extension, naming the fix. */
@@ -782,6 +1445,102 @@ export function resolveContentTypeRefs(
782
1445
  }
783
1446
  }
784
1447
 
1448
+ /** The page patterns of a project with their locale prefix taken off (`/fr/kb/*` → `/kb/*`). */
1449
+ function withoutLocalePrefix(urlPattern: string, prefix: string): string {
1450
+ return prefix !== "" && (urlPattern === prefix || urlPattern.startsWith(`${prefix}/`))
1451
+ ? urlPattern.slice(prefix.length) || "/"
1452
+ : urlPattern;
1453
+ }
1454
+
1455
+ /** Content types whose unserved routes were already reported, per loaded section (once per build). */
1456
+ const REPORTED_UNSERVED = new WeakMap<object, Set<string>>();
1457
+
1458
+ /**
1459
+ * Report the routed entries that no dynamic page of the project produces.
1460
+ *
1461
+ * Each page expands the routes its own pattern fits, so a project with a `[category]/[slug]` page
1462
+ * and a `[category]` page serves different entries from each, and one page skipping an entry is
1463
+ * normal. An entry NO page serves is different: it has a route, links to it were rewritten to that
1464
+ * route, and nothing generates it. It is reported once per content type, with a few of the routes.
1465
+ */
1466
+ function reportUnservedRoutes(
1467
+ contentType: string,
1468
+ entries: ContentLoaderEntry[],
1469
+ ctx: ResolvePathsContext,
1470
+ prefix: string,
1471
+ ): void {
1472
+ if (ctx.patterns === undefined) {
1473
+ return;
1474
+ }
1475
+ const reported = REPORTED_UNSERVED.get(ctx.data) ?? new Set<string>();
1476
+ if (reported.has(contentType)) {
1477
+ return;
1478
+ }
1479
+ const patterns = ctx.patterns.map((pattern) => withoutLocalePrefix(pattern, prefix));
1480
+ const unserved = entries
1481
+ .map((entry) => entry._meta?.route)
1482
+ .filter(
1483
+ (route): route is string =>
1484
+ route !== undefined &&
1485
+ !patterns.some((pattern) => routeParams(route, pattern, []) !== null),
1486
+ );
1487
+ if (unserved.length === 0) {
1488
+ return;
1489
+ }
1490
+ reported.add(contentType);
1491
+ REPORTED_UNSERVED.set(ctx.data, reported);
1492
+ const shown = unserved.slice(0, 3).join(", ");
1493
+ console.warn(
1494
+ `Warning: $paths for content type "${contentType}": ${unserved.length} routed ` +
1495
+ `${unserved.length === 1 ? "entry has" : "entries have"} a route that no dynamic page ` +
1496
+ `produces (${shown}${unserved.length > 3 ? ", ..." : ""}), so ` +
1497
+ `${unserved.length === 1 ? "it is" : "they are"} not generated and links to ` +
1498
+ `${unserved.length === 1 ? "it" : "them"} lead nowhere. Add a page whose URL pattern fits ` +
1499
+ `them, or change the "route" template.`,
1500
+ );
1501
+ }
1502
+
1503
+ /**
1504
+ * `$paths` for a content type with a `route`: one parameter map per entry the page can serve.
1505
+ *
1506
+ * The page pattern arrives with the locale prefix still on it (`/fr/kb/*`) while an entry's route
1507
+ * is locale-free, so the prefix is taken off first. An entry the pattern cannot produce is skipped,
1508
+ * and a page that serves none of them is reported once with an example route, because the usual
1509
+ * cause is a template and a page that were never meant for each other. Entries that NO page of the
1510
+ * project serves are reported too (see {@link reportUnservedRoutes}).
1511
+ */
1512
+ function routedPaths(
1513
+ contentType: string,
1514
+ entries: ContentLoaderEntry[],
1515
+ ctx: ResolvePathsContext,
1516
+ urlPattern: string,
1517
+ ): Record<string, unknown>[] {
1518
+ const { i18n } = resolveI18n(ctx.projectConfig ?? {});
1519
+ const prefix = localeUrlPrefix(ctx.locale, i18n);
1520
+ const pattern = withoutLocalePrefix(urlPattern, prefix);
1521
+ const paths: Record<string, unknown>[] = [];
1522
+ let example: string | undefined;
1523
+ for (const entry of entries) {
1524
+ const route = entry._meta?.route;
1525
+ if (route === undefined) {
1526
+ continue;
1527
+ }
1528
+ example ??= route;
1529
+ const params = routeParams(route, pattern, ctx.params ?? []);
1530
+ if (params) {
1531
+ paths.push({ ...params, ...(entry._meta === undefined ? {} : { _meta: entry._meta }) });
1532
+ }
1533
+ }
1534
+ if (paths.length === 0 && example !== undefined) {
1535
+ console.warn(
1536
+ `Warning: $paths for content type "${contentType}": no entry route fits the page pattern ` +
1537
+ `"${urlPattern}" (for example ${example}), so this page generates nothing`,
1538
+ );
1539
+ }
1540
+ reportUnservedRoutes(contentType, entries, ctx, prefix);
1541
+ return paths;
1542
+ }
1543
+
785
1544
  // ─── The Content project-section class ───────────────────────────────────────
786
1545
 
787
1546
  /**
@@ -831,8 +1590,18 @@ export class Content {
831
1590
  }
832
1591
 
833
1592
  /**
834
- * Expand a content-type `$paths` source into route-param maps: one `{ [param]: value }` per
835
- * entry, with `param` defaulting to "slug" and `field` to "id" (the entry id).
1593
+ * Expand a content-type `$paths` source into route-param maps.
1594
+ *
1595
+ * **With `param` or `field`** (or on a type with no `route`): one `{ [param]: value }` per entry,
1596
+ * `param` defaulting to "slug" and `field` to "id" (the entry id). Unchanged since before routes
1597
+ * existed, and still the right shape for a flat `[slug]` collection.
1598
+ *
1599
+ * **With neither, on a type that declares a `route`:** the page's own URL pattern decides. Each
1600
+ * entry's route is matched against the pattern the host passes (`/kb/:category/:slug`, `/kb/*`)
1601
+ * and the parameters that pattern needs are derived from it, so the same `$paths: {
1602
+ * "contentType": "kb" }` serves `[category]/[slug].json`, `[...path].json` or a `[category].json`
1603
+ * section page, and the page generated for an entry is always at the URL its links point to. An
1604
+ * entry whose route the pattern cannot produce belongs to another page and is skipped here.
836
1605
  *
837
1606
  * Each map also carries the entry's own `_meta`, under that reserved name. `_meta` is not a route
838
1607
  * parameter and the host strips it before substitution — it is how a fact about the _entry_
@@ -841,7 +1610,7 @@ export class Content {
841
1610
  * timestamp in `<lastmod>`, so the whole archive looks edited whenever the template is.
842
1611
  *
843
1612
  * @param {ContentPathsSource} pathsDef - The `$paths` value ({ contentType, param?, field? })
844
- * @param {ResolvePathsContext} ctx - Host context ({ data, projectConfig, root })
1613
+ * @param {ResolvePathsContext} ctx - Host context ({ data, projectConfig, root, urlPattern })
845
1614
  * @returns {Promise<Record<string, unknown>[]>} Array of route-param objects
846
1615
  */
847
1616
  static async resolvePaths(
@@ -869,12 +1638,31 @@ export class Content {
869
1638
  localized && typeof wanted === "string"
870
1639
  ? entries.filter((entry) => entry._meta?.locale === wanted)
871
1640
  : entries;
1641
+ if (
1642
+ pathsDef.param === undefined &&
1643
+ pathsDef.field === undefined &&
1644
+ ctx.urlPattern !== undefined &&
1645
+ scoped.some((entry) => entry._meta?.route !== undefined)
1646
+ ) {
1647
+ return routedPaths(pathsDef.contentType, scoped, ctx, ctx.urlPattern);
1648
+ }
872
1649
  const paths: Record<string, unknown>[] = [];
873
1650
  for (const entry of scoped) {
874
1651
  const value = field === "id" ? entry.id : (entry.data[field] ?? entry.id);
875
1652
  if (!value) {
876
1653
  continue;
877
1654
  }
1655
+ /* The value becomes a directory under the output. It may be frontmatter (`field`, or an id
1656
+ taken from `idField`), so a value that would climb out of the output directory does not
1657
+ get a page. */
1658
+ const unsafe = traversalReason(String(value));
1659
+ if (unsafe !== null) {
1660
+ console.warn(
1661
+ `Warning: $paths for content type "${pathsDef.contentType}": the ${field === "id" ? "id" : `"${field}"`} ` +
1662
+ `of "${entry.id}" ${unsafe}, so no page is generated for it`,
1663
+ );
1664
+ continue;
1665
+ }
878
1666
  const pathEntry: Record<string, unknown> = { [param]: value };
879
1667
  if (entry._meta !== undefined) {
880
1668
  pathEntry._meta = entry._meta;