@jxsuite/parser 2.0.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,15 +19,27 @@
19
19
 
20
20
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
21
21
  import { basename, dirname, extname, relative, resolve } from "node:path";
22
- import { assetUrlFor } from "@jxsuite/schema/asset-paths";
22
+ import {
23
+ assetUrlFor,
24
+ CONTENT_SECTION_KEY,
25
+ LOCALE_PLACEHOLDER,
26
+ SAFE_CONTENT_TYPE_NAME,
27
+ } from "@jxsuite/schema/asset-paths";
23
28
  import { localeUrlPrefix, resolveI18n } from "@jxsuite/schema/locale";
24
29
  import type { AssetMount } from "@jxsuite/schema/asset-paths";
25
- import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
26
- import type { FormatEntry, FormatHostIO, FormatRegistry } from "@jxsuite/schema/format-registry";
27
30
  import type {
31
+ FormatDiscoverOptions,
32
+ FormatEntry,
33
+ FormatRegistry,
34
+ ProjectDataContext,
35
+ } from "@jxsuite/schema/format-registry";
36
+ import { isBoolean, isNonNullObject, isNumber, isString } from "@jxsuite/schema/guards";
37
+ import type {
38
+ ContentFieldValue,
28
39
  ContentTypeSchema,
40
+ JsonObject,
41
+ JsonValue,
29
42
  JxElement,
30
- JxMutableNode,
31
43
  ProjectConfig,
32
44
  } from "@jxsuite/schema/types";
33
45
  import { normalizeAlerts } from "./alerts.ts";
@@ -49,29 +61,26 @@ import { coerceEntryDates, isCoercedDate, isDateFormat } from "./dates.ts";
49
61
  import type { ContentNodes } from "./inert.ts";
50
62
  import type { ContentLoaderEntry, ContentTypeDef } from "./types.ts";
51
63
  import { walkFiles } from "./walk.ts";
64
+ import { entriesOf } from "./content-data.ts";
65
+ import type { ContentSectionData } from "./content-data.ts";
52
66
 
53
67
  export type { ContentEntry, ContentLoaderEntry, TocEntry } from "./types.ts";
68
+ export type { ContentSectionData } from "./content-data.ts";
54
69
 
55
70
  // ─── Context and section shapes ──────────────────────────────────────────────
56
71
 
57
72
  /** The value of the project.json `content` section: content type name → definition. */
58
73
  export type ContentSection = Record<string, ContentTypeDef>;
59
74
 
60
- /** Host context passed to `Content.projectData` (specs/extensions.md §8). */
61
- export interface ProjectDataContext {
62
- projectConfig?: ProjectConfig;
63
- /** Absolute project root directory. */
64
- root: string;
65
- /** The project's extension registry; format dispatch goes through its `formats` view. */
66
- registry: ExtensionRegistry;
67
- /** Injected host I/O (unused here — this loader is node-only by its declared timing). */
68
- io?: FormatHostIO;
69
- }
75
+ /* The host context passed to `Content.projectData` is the capability contract's own
76
+ (specs/extensions.md §8); its injected `io` goes unused here, as this loader is node-only by its
77
+ declared timing. */
78
+ export type { ProjectDataContext } from "@jxsuite/schema/format-registry";
70
79
 
71
80
  /** Host context passed to `Content.resolvePaths` (specs/extensions.md §8). */
72
81
  export interface ResolvePathsContext {
73
82
  /** The loaded section data, as returned by `Content.projectData`. */
74
- data: Map<string, ContentLoaderEntry[]>;
83
+ data: ContentSectionData;
75
84
  projectConfig?: ProjectConfig;
76
85
  /** Absolute project root directory. */
77
86
  root: string;
@@ -94,6 +103,18 @@ export interface ResolvePathsContext {
94
103
  patterns?: readonly string[];
95
104
  }
96
105
 
106
+ /** An entry's `_meta`: where it came from, and the route and URL the loader gave it. */
107
+ type ContentEntryMeta = NonNullable<ContentLoaderEntry["_meta"]>;
108
+
109
+ /**
110
+ * One `$paths` result: route parameter → value, plus the entry's own `_meta` under that reserved
111
+ * name, which the host strips before substitution.
112
+ */
113
+ export interface ContentPathParams {
114
+ _meta?: ContentEntryMeta;
115
+ [param: string]: ContentFieldValue | ContentEntryMeta | undefined;
116
+ }
117
+
97
118
  /** The `$paths` value shape routed to this extension by its `contentType` discriminator. */
98
119
  export interface ContentPathsSource {
99
120
  contentType: string;
@@ -111,23 +132,24 @@ export interface ContentPathsSource {
111
132
  * @returns {ContentLoaderEntry[]} Array of ContentEntry shapes
112
133
  */
113
134
  function loadJSONEntries(filePath: string): ContentLoaderEntry[] {
114
- const raw = JSON.parse(readFileSync(filePath, "utf8")) as unknown;
135
+ // SAFETY: JSON.parse only ever yields JSON values; its `any` return type says nothing more.
136
+ const raw = JSON.parse(readFileSync(filePath, "utf8")) as JsonValue;
115
137
  if (Array.isArray(raw)) {
116
- return (raw as Record<string, unknown>[]).map((item: Record<string, unknown>, i: number) => ({
117
- body: null,
118
- data: item,
119
- id: (item.id as string) ?? `${basename(filePath, ".json")}-${i}`,
120
- }));
138
+ return raw.map((item, i) => jsonEntry(item, `${basename(filePath, ".json")}-${i}`));
121
139
  }
122
140
  // Single object file — filename is the id
123
- const rawObj = raw as Record<string, unknown>;
124
- return [
125
- {
126
- body: null,
127
- data: rawObj,
128
- id: (rawObj.id as string) ?? basename(filePath, ".json"),
129
- },
130
- ];
141
+ return [jsonEntry(raw, basename(filePath, ".json"))];
142
+ }
143
+
144
+ /** One entry of a JSON source: the object is its data, and its own `id` wins over `fallbackId`. */
145
+ function jsonEntry(item: JsonValue, fallbackId: string): ContentLoaderEntry {
146
+ /* SAFETY: a JSON content file is documented (content-collections docs, "JSON files") as one
147
+ entry object or an array of entry objects, each optionally naming its `id`; the loader takes
148
+ the file at its word for that shape, as it always has, rather than checking each item. */
149
+ const data = item as JsonObject;
150
+ // SAFETY: by the same documented shape, an entry's own `id` is its text id; absent, the path names it.
151
+ const id = (data.id ?? fallbackId) as string;
152
+ return { body: null, data, id };
131
153
  }
132
154
 
133
155
  /** Discover .json entry files for a source (single file or directory), in sorted order. */
@@ -140,15 +162,9 @@ function discoverJSONFiles(resolvedSource: string, exclude: ExcludeMatcher): str
140
162
 
141
163
  // ─── Asset mounts and content-relative references ────────────────────────────
142
164
 
143
- /**
144
- * The placeholder a content type's `source` may carry to say "one directory per locale".
145
- *
146
- * `./blog/{locale}/` is one content type spread over N directories, not N content types. That
147
- * distinction is what keeps a translated post the same post: it keeps one schema, one set of
148
- * relationship targets, and one name in `$paths`, and differs only in which directory it was read
149
- * from — which is exactly the model `site-architecture.md` §13 uses for routes.
150
- */
151
- export const LOCALE_PLACEHOLDER = "{locale}";
165
+ /* `LOCALE_PLACEHOLDER` lives in `@jxsuite/schema/asset-paths`, beside the mount math, so Studio's
166
+ browser-side canvas reads the same token; this module's public name for it stays available. */
167
+ export { LOCALE_PLACEHOLDER } from "@jxsuite/schema/asset-paths";
152
168
 
153
169
  /**
154
170
  * The locales a `{locale}` source expands over, or `[]` when the project declares none.
@@ -204,12 +220,6 @@ export function localeSource(root: string, source: string, locale: string): stri
204
220
  return source.split(LOCALE_PLACEHOLDER).join(locale);
205
221
  }
206
222
 
207
- /** The project.json section key this class owns; also the URL namespace for its asset mounts. */
208
- const SECTION_KEY = "content";
209
-
210
- /** Content type names safe to place in a URL path segment unescaped. */
211
- const SAFE_TYPE_NAME = /^[\w.~-]+$/;
212
-
213
223
  /** Node keys whose value is a media reference the rewrite may remap. */
214
224
  const ASSET_KEYS = ["src", "poster"] as const;
215
225
 
@@ -273,7 +283,7 @@ function mountFilterFor(def: ContentTypeDef | undefined): ((path: string) => boo
273
283
  * @returns {AssetMount[]} One mount per eligible content type, in declaration order
274
284
  */
275
285
  export function contentAssetMounts(
276
- section: ContentSection,
286
+ section: ContentSection | undefined,
277
287
  root: string,
278
288
  projectConfig?: ProjectConfig,
279
289
  ): AssetMount[] {
@@ -284,7 +294,11 @@ export function contentAssetMounts(
284
294
  return filter === undefined ? {} : { filter };
285
295
  };
286
296
  for (const [name, contentTypeDef] of Object.entries(section ?? {})) {
287
- const source = contentTypeDef?.source;
297
+ // A definition is project.json data, and one still being written may not be an object yet.
298
+ if (!isNonNullObject(contentTypeDef)) {
299
+ continue;
300
+ }
301
+ const { source } = contentTypeDef;
288
302
  if (!source || source.startsWith("http://") || source.startsWith("https://")) {
289
303
  continue;
290
304
  }
@@ -295,26 +309,26 @@ export function contentAssetMounts(
295
309
  */
296
310
  if (source.includes(LOCALE_PLACEHOLDER)) {
297
311
  for (const locale of locales) {
298
- if (!SAFE_TYPE_NAME.test(locale)) {
312
+ if (!SAFE_CONTENT_TYPE_NAME.test(locale)) {
299
313
  continue;
300
314
  }
301
315
  const dir = resolve(root, localeSource(root, source, locale))
302
316
  .split("\\")
303
317
  .join("/");
304
- if (SAFE_TYPE_NAME.test(name) && existsSync(dir) && statSync(dir).isDirectory()) {
318
+ if (SAFE_CONTENT_TYPE_NAME.test(name) && existsSync(dir) && statSync(dir).isDirectory()) {
305
319
  mounts.push({
306
320
  dir,
307
- urlPrefix: `/${SECTION_KEY}/${name}/${locale}`,
321
+ urlPrefix: `/${CONTENT_SECTION_KEY}/${name}/${locale}`,
308
322
  ...filterOf(contentTypeDef),
309
323
  });
310
324
  }
311
325
  }
312
326
  continue;
313
327
  }
314
- if (!SAFE_TYPE_NAME.test(name)) {
328
+ if (!SAFE_CONTENT_TYPE_NAME.test(name)) {
315
329
  console.warn(
316
330
  `Content type "${name}": name is not URL-safe — its source directory is not published ` +
317
- `at /${SECTION_KEY}/${name}, so content-relative asset references stay unresolved.`,
331
+ `at /${CONTENT_SECTION_KEY}/${name}, so content-relative asset references stay unresolved.`,
318
332
  );
319
333
  continue;
320
334
  }
@@ -324,7 +338,7 @@ export function contentAssetMounts(
324
338
  }
325
339
  mounts.push({
326
340
  dir: resolvedSource,
327
- urlPrefix: `/${SECTION_KEY}/${name}`,
341
+ urlPrefix: `/${CONTENT_SECTION_KEY}/${name}`,
328
342
  ...filterOf(contentTypeDef),
329
343
  });
330
344
  }
@@ -386,26 +400,26 @@ function rewriteNodeAssets(
386
400
  }
387
401
  return;
388
402
  }
389
- if (!node || typeof node !== "object") {
403
+ if (!isNonNullObject(node)) {
390
404
  return;
391
405
  }
392
- const record = node as unknown as Record<string, unknown>;
393
- const attributes = record.attributes as Record<string, unknown> | undefined;
406
+ const { attributes } = node;
394
407
  for (const key of ASSET_KEYS) {
395
- for (const holder of [record, attributes]) {
408
+ // The node's own property first, then its attribute of the same name.
409
+ for (const holder of [node, attributes]) {
396
410
  const value = holder?.[key];
397
- if (typeof value === "string") {
411
+ if (holder && isString(value)) {
398
412
  const rewritten = rewriteRef(value, entryDir, mount, onProblem);
399
413
  if (rewritten) {
400
- holder![key] = rewritten;
414
+ holder[key] = rewritten;
401
415
  }
402
416
  }
403
417
  }
404
418
  }
405
- const { children } = record;
419
+ const { children } = node;
406
420
  if (Array.isArray(children)) {
407
421
  for (const child of children) {
408
- rewriteNodeAssets(child as JxElement | string | ContentNodes, entryDir, mount, onProblem);
422
+ rewriteNodeAssets(child, entryDir, mount, onProblem);
409
423
  }
410
424
  }
411
425
  }
@@ -433,37 +447,62 @@ export function rewriteEntryAssets(
433
447
  schema?: ContentTypeSchema,
434
448
  ) {
435
449
  const entryDir = dirname(filePath);
436
- const uriFields = Object.entries(schema?.properties ?? {})
437
- .filter(([, def]) => def?.format === "uri-reference" || def?.items?.format === "uri-reference")
438
- .map(([field]) => field);
450
+ /* A field's schema is project.json data, so one that is not an object (a `null` left by an edit)
451
+ declares nothing rather than failing the load. */
452
+ const uriFields = Object.entries(schema?.properties ?? {}).flatMap(([field, def]) =>
453
+ isNonNullObject(def) &&
454
+ (def.format === "uri-reference" || def.items?.format === "uri-reference")
455
+ ? [field]
456
+ : [],
457
+ );
439
458
 
440
459
  for (const entry of entries) {
441
- const onProblem = (value: string, problem: RefProblem) =>
442
- console.warn(
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`,
448
- );
449
-
450
- for (const child of entry.$children ?? []) {
451
- rewriteNodeAssets(child, entryDir, mount, onProblem);
452
- }
460
+ const onProblem = assetProblemReporter(typeName, entry);
461
+ rewriteBodyAssets(entry, filePath, mount, typeName);
453
462
 
454
463
  for (const field of uriFields) {
455
464
  const value = entry.data[field];
456
- if (typeof value === "string") {
465
+ if (isString(value)) {
457
466
  entry.data[field] = rewriteRef(value, entryDir, mount, onProblem) ?? value;
458
467
  } else if (Array.isArray(value)) {
459
468
  entry.data[field] = value.map((item) =>
460
- typeof item === "string" ? (rewriteRef(item, entryDir, mount, onProblem) ?? item) : item,
469
+ isString(item) ? (rewriteRef(item, entryDir, mount, onProblem) ?? item) : item,
461
470
  );
462
471
  }
463
472
  }
464
473
  }
465
474
  }
466
475
 
476
+ /** The warning for one asset reference an entry makes that its collection will not publish. */
477
+ function assetProblemReporter(typeName: string, entry: ContentLoaderEntry) {
478
+ return (value: string, problem: RefProblem) =>
479
+ console.warn(
480
+ problem === "missing"
481
+ ? `Content type "${typeName}": entry "${entry.id}" references missing asset "${value}"`
482
+ : `Content type "${typeName}": entry "${entry.id}" references "${value}", which the ` +
483
+ `collection does not publish (it is excluded, hidden or a document), so the ` +
484
+ `reference is left as written`,
485
+ );
486
+ }
487
+
488
+ /**
489
+ * The body half of {@link rewriteEntryAssets}: remap the media references in an entry's `$children`
490
+ * onto the mount. Separate because a body loaded later (a lazily loaded collection's deferred
491
+ * entry) gets this pass on its own, after its frontmatter had the other half at load.
492
+ */
493
+ function rewriteBodyAssets(
494
+ entry: ContentLoaderEntry,
495
+ filePath: string,
496
+ mount: AssetMount,
497
+ typeName: string,
498
+ ) {
499
+ const entryDir = dirname(filePath);
500
+ const onProblem = assetProblemReporter(typeName, entry);
501
+ for (const child of entry.$children ?? []) {
502
+ rewriteNodeAssets(child, entryDir, mount, onProblem);
503
+ }
504
+ }
505
+
467
506
  // ─── Content Config ───────────────────────────────────────────────────────────
468
507
 
469
508
  /**
@@ -477,6 +516,9 @@ export function rewriteEntryAssets(
477
516
  export function loadContentConfig(projectRoot: string, projectConfig?: ProjectConfig) {
478
517
  const contentDir = resolve(projectRoot, "content");
479
518
 
519
+ /* SAFETY: `content` is the section this extension owns, and its project fragment schema defines
520
+ it as content type name → definition, which is what ContentSection spells; the options of a
521
+ definition are validated again by compileTypeRules when that type loads. */
480
522
  const config = { content: (projectConfig?.content as ContentSection | undefined) ?? {} };
481
523
 
482
524
  return { config, contentDir };
@@ -499,7 +541,23 @@ export async function loadContentSection(
499
541
  projectConfig?: ProjectConfig,
500
542
  ): Promise<Map<string, ContentLoaderEntry[]>> {
501
543
  const contentTypes = new Map<string, ContentLoaderEntry[]>();
502
- const mounts = new Map(
544
+ const mounts = sectionMounts(section, root, projectConfig);
545
+ for (const name of Object.keys(section)) {
546
+ contentTypes.set(
547
+ name,
548
+ await loadSectionType(name, { formats, mounts, projectConfig, root, section }),
549
+ );
550
+ }
551
+ return contentTypes;
552
+ }
553
+
554
+ /** A section's asset mounts, keyed by the path below `/content/` a lookup builds. */
555
+ export function sectionMounts(
556
+ section: ContentSection,
557
+ root: string,
558
+ projectConfig: ProjectConfig | undefined,
559
+ ): Map<string, AssetMount> {
560
+ return new Map(
503
561
  contentAssetMounts(section, root, projectConfig).map((mount) => [
504
562
  /*
505
563
  * The path below `/content/` — `<type>` for a plain source, `<type>/<locale>` for a
@@ -508,50 +566,77 @@ export async function loadContentSection(
508
566
  * translated entry's `./hero.png` was left unrewritten; and it collided any two types that
509
567
  * shared a locale.
510
568
  */
511
- mount.urlPrefix.slice(`/${SECTION_KEY}/`.length),
569
+ mount.urlPrefix.slice(`/${CONTENT_SECTION_KEY}/`.length),
512
570
  mount,
513
571
  ]),
514
572
  );
573
+ }
574
+
575
+ /** What {@link loadSectionType} loads one content type of a section with. */
576
+ export interface SectionTypeSource {
577
+ section: ContentSection;
578
+ root: string;
579
+ formats: FormatRegistry;
580
+ projectConfig: ProjectConfig | undefined;
581
+ /** The section's mounts, from {@link sectionMounts}. */
582
+ mounts: Map<string, AssetMount>;
583
+ /** Leave entry bodies for later, reporting each deferred entry here. */
584
+ onDeferred?: DeferredBodyHandler | undefined;
585
+ }
586
+
587
+ /**
588
+ * Load one content type of a section, expanding a `{locale}` source over the project's locales.
589
+ *
590
+ * @param {string} name - The content type's name in the section
591
+ * @param {SectionTypeSource} source - The section and what it is loaded with
592
+ * @returns {Promise<ContentLoaderEntry[]>} The type's entries
593
+ */
594
+ export async function loadSectionType(
595
+ name: string,
596
+ source: SectionTypeSource,
597
+ ): Promise<ContentLoaderEntry[]> {
598
+ const { section, root, formats, projectConfig, mounts, onDeferred } = source;
599
+ const contentTypeDef = section[name];
600
+ if (!contentTypeDef) {
601
+ return [];
602
+ }
603
+ const deferral = onDeferred ? { deferBody: true, onDeferred } : {};
604
+ const typeSource = contentTypeDef.source;
605
+ if (!isString(typeSource) || !typeSource.includes(LOCALE_PLACEHOLDER)) {
606
+ return await loadContentType(name, contentTypeDef, root, formats, {
607
+ mount: mounts.get(name),
608
+ projectConfig,
609
+ ...deferral,
610
+ });
611
+ }
612
+ /*
613
+ * One content type, N directories. Each pass loads a real path and stamps the locale it came
614
+ * from onto every entry, which is the only thing distinguishing two translations that share an
615
+ * id — `resolvePaths` reads it back to expand the right ones under a locale-prefixed route.
616
+ */
515
617
  const locales = localesForExpansion(projectConfig);
516
- for (const [name, contentTypeDef] of Object.entries(section)) {
517
- const source = contentTypeDef?.source;
518
- if (typeof source !== "string" || !source.includes(LOCALE_PLACEHOLDER)) {
519
- const entries = await loadContentType(name, contentTypeDef, root, formats, {
520
- mount: mounts.get(name),
521
- projectConfig,
522
- });
523
- contentTypes.set(name, entries);
524
- continue;
525
- }
526
- /*
527
- * One content type, N directories. Each pass loads a real path and stamps the locale it came
528
- * from onto every entry, which is the only thing distinguishing two translations that share an
529
- * id — `resolvePaths` reads it back to expand the right ones under a locale-prefixed route.
530
- */
531
- if (locales.length === 0) {
532
- console.warn(
533
- `Content type "${name}": source "${source}" uses ${LOCALE_PLACEHOLDER}, but the project ` +
534
- `declares no i18n locales — nothing to expand it over, so no entries were loaded.`,
535
- );
536
- contentTypes.set(name, []);
537
- continue;
538
- }
539
- const all: ContentLoaderEntry[] = [];
540
- for (const locale of locales) {
541
- const localized = { ...contentTypeDef, source: localeSource(root, source, locale) };
542
- const entries = await loadContentType(name, localized, root, formats, {
543
- locale,
544
- mount: mounts.get(`${name}/${locale}`),
545
- projectConfig,
546
- });
547
- for (const entry of entries) {
548
- entry._meta = { ...entry._meta, locale };
549
- }
550
- all.push(...entries);
618
+ if (locales.length === 0) {
619
+ console.warn(
620
+ `Content type "${name}": source "${typeSource}" uses ${LOCALE_PLACEHOLDER}, but the project ` +
621
+ `declares no i18n locales — nothing to expand it over, so no entries were loaded.`,
622
+ );
623
+ return [];
624
+ }
625
+ const all: ContentLoaderEntry[] = [];
626
+ for (const locale of locales) {
627
+ const localized = { ...contentTypeDef, source: localeSource(root, typeSource, locale) };
628
+ const entries = await loadContentType(name, localized, root, formats, {
629
+ locale,
630
+ mount: mounts.get(`${name}/${locale}`),
631
+ projectConfig,
632
+ ...deferral,
633
+ });
634
+ for (const entry of entries) {
635
+ entry._meta = { ...entry._meta, locale };
551
636
  }
552
- contentTypes.set(name, all);
637
+ all.push(...entries);
553
638
  }
554
- return contentTypes;
639
+ return all;
555
640
  }
556
641
 
557
642
  /**
@@ -585,7 +670,15 @@ interface TypeRules {
585
670
  idField: string | undefined;
586
671
  route: RouteSpec | null;
587
672
  links: LinkMode;
588
- alerts: unknown;
673
+ alerts: ContentTypeDef["alerts"];
674
+ }
675
+
676
+ /** The values `links` accepts. */
677
+ const LINK_MODES = ["warn", "error", "ignore"] as const;
678
+
679
+ /** Whether a `links` option names one of the {@link LINK_MODES}; it is project.json data. */
680
+ function isLinkMode(value: JsonValue): value is LinkMode {
681
+ return LINK_MODES.some((mode) => mode === value);
589
682
  }
590
683
 
591
684
  /** Every key a content type's definition means something by. */
@@ -651,7 +744,7 @@ function compileTypeRules(name: string, def: ContentTypeDef): TypeRules {
651
744
  const at = `Content type "${name}"`;
652
745
  try {
653
746
  for (const key of Object.keys(def)) {
654
- if ((KNOWN_TYPE_KEYS as readonly string[]).includes(key) || key === "$comment") {
747
+ if (KNOWN_TYPE_KEYS.some((known) => known === key) || key === "$comment") {
655
748
  continue;
656
749
  }
657
750
  const suggestion = nearestOption(key);
@@ -662,11 +755,12 @@ function compileTypeRules(name: string, def: ContentTypeDef): TypeRules {
662
755
  `${at}: ignores the unknown option "${key}" (the options are ${KNOWN_TYPE_KEYS.join(", ")})`,
663
756
  );
664
757
  }
665
- if (def.idField !== undefined && (typeof def.idField !== "string" || def.idField === "")) {
758
+ // `idField` and `links` are project.json data: checked here, whatever their declared types say.
759
+ if (def.idField !== undefined && (!isString(def.idField) || def.idField === "")) {
666
760
  throw new TypeError(`"idField" must be a field name`);
667
761
  }
668
762
  const links = def.links ?? "warn";
669
- if (links !== "warn" && links !== "error" && links !== "ignore") {
763
+ if (!isLinkMode(links)) {
670
764
  throw new TypeError(
671
765
  `"links" must be "warn", "error" or "ignore", got ${JSON.stringify(links)}`,
672
766
  );
@@ -714,8 +808,21 @@ interface FinishContext {
714
808
  entryExtensions: Set<string>;
715
809
  /** The source directory, when there is one. */
716
810
  sourceRoot: string | undefined;
811
+ /** Set when the files were loaded without their bodies: loads one file again, body and all. */
812
+ reload?: ((filePath: string) => Promise<ContentLoaderEntry[]>) | undefined;
813
+ /** Told of each entry whose body was deferred, with what completes it. */
814
+ onDeferred?: DeferredBodyHandler | undefined;
717
815
  }
718
816
 
817
+ /**
818
+ * Told of one entry loaded without its body, and handed what completes it: load the body, then give
819
+ * it the asset and link passes the rest of the collection's bodies got at load.
820
+ */
821
+ export type DeferredBodyHandler = (
822
+ entry: ContentLoaderEntry,
823
+ completeBody: () => Promise<void>,
824
+ ) => void;
825
+
719
826
  /**
720
827
  * The collection-wide pass every source kind goes through once its files are loaded.
721
828
  *
@@ -778,7 +885,7 @@ function finishEntries(files: LoadedFile[], ctx: FinishContext): ContentLoaderEn
778
885
  if (rules.idField) {
779
886
  for (const entry of entries) {
780
887
  const value = entry.data[rules.idField];
781
- if ((typeof value === "string" && value !== "") || typeof value === "number") {
888
+ if ((isString(value) && value !== "") || isNumber(value)) {
782
889
  /* An id becomes a directory name under the output (`$paths` hands it to a page), and a
783
890
  frontmatter value is not trusted to stay inside it. */
784
891
  const unsafe = traversalReason(String(value));
@@ -825,9 +932,8 @@ function finishEntries(files: LoadedFile[], ctx: FinishContext): ContentLoaderEn
825
932
  // 5. Routes, and links over the finished routes.
826
933
  if (rules.route) {
827
934
  assignRoutes(entries, unpublished, ctx, rules.route);
828
- if (ctx.sourceRoot) {
829
- resolveEntryLinks(kept, unpublished, ctx);
830
- }
935
+ const links = ctx.sourceRoot ? resolveEntryLinks(kept, unpublished, ctx) : undefined;
936
+ deferBodies(kept, ctx, links);
831
937
  /* An entry the template could not route, or whose route another entry holds, has no page, and
832
938
  a collection is what every consumer (a listing, the search index, a feed) publishes from. Left
833
939
  in, each of them would invent a URL for it from its own idea of one and point readers at a
@@ -835,9 +941,59 @@ function finishEntries(files: LoadedFile[], ctx: FinishContext): ContentLoaderEn
835
941
  link index above already knows why it has no page; the collection does not carry it. */
836
942
  return entries.filter((entry) => entry._meta?.route !== undefined);
837
943
  }
944
+ deferBodies(kept, ctx);
838
945
  return entries;
839
946
  }
840
947
 
948
+ /**
949
+ * Hand each entry that was loaded without its body to `onDeferred`, with what completes it.
950
+ *
951
+ * Completing one does for that entry exactly what {@link finishEntries} did for every body it was
952
+ * given: the full load supplies `$children` and the `_meta` fields read from the body, then the
953
+ * body's assets move onto the mount and its links resolve against the collection's own index. The
954
+ * frontmatter half is untouched; it was finished with the rest of the collection. Only a file that
955
+ * yielded one entry is deferred, since a reload answers per file.
956
+ */
957
+ function deferBodies(kept: LoadedFile[], ctx: FinishContext, links?: LinkIndex): void {
958
+ const { reload, onDeferred } = ctx;
959
+ if (!reload || !onDeferred) {
960
+ return;
961
+ }
962
+ for (const file of kept) {
963
+ const [entry] = file.entries;
964
+ if (file.entries.length !== 1 || !entry || entry.$children !== undefined) {
965
+ continue;
966
+ }
967
+ onDeferred(entry, async () => {
968
+ const [full] = await reload(file.filePath);
969
+ if (!full) {
970
+ return;
971
+ }
972
+ /* Rebuilt in place, in the full load's key order: the entry is already referenced (by the
973
+ collection, and by any entry relating to it), and an entry whose body arrived late should
974
+ serialize exactly as one loaded whole. Everything the collection pass decided about it
975
+ (its id, data, route) is kept; the full load contributes only what came from the body. */
976
+ const completed: ContentLoaderEntry = {
977
+ ...full,
978
+ ...entry,
979
+ _meta: { ...full._meta, ...entry._meta },
980
+ };
981
+ for (const key of Object.keys(entry)) {
982
+ Reflect.deleteProperty(entry, key);
983
+ }
984
+ Object.assign(entry, completed);
985
+ if (ctx.mount) {
986
+ rewriteBodyAssets(entry, file.filePath, ctx.mount, ctx.name);
987
+ }
988
+ if (links && file.rel !== null) {
989
+ const broken: BrokenLink[] = [];
990
+ collectBrokenLinks(entry, file.rel, links, broken, new Set());
991
+ reportBrokenLinks(broken, ctx.name, ctx.rules.links);
992
+ }
993
+ });
994
+ }
995
+ }
996
+
841
997
  /**
842
998
  * Keep a folder's id for its `index` file when its `README` would also claim it.
843
999
  *
@@ -874,7 +1030,7 @@ function settleIndexIds(name: string, entries: ContentLoaderEntry[]): void {
874
1030
  /** The site's `build.trailingSlash`, defaulting to `"always"` exactly as the build does. */
875
1031
  function trailingSlashOf(projectConfig: ProjectConfig | undefined): string {
876
1032
  const value = projectConfig?.build?.trailingSlash;
877
- return typeof value === "string" ? value : "always";
1033
+ return isString(value) ? value : "always";
878
1034
  }
879
1035
 
880
1036
  /** The URL prefix a collection's locale puts in front of its routes (`/fr`), or `""`. */
@@ -963,13 +1119,38 @@ interface BrokenLink {
963
1119
  * which of them are published, which directory each README or index stands for), then reports each
964
1120
  * link that has no page at the severity `links` asks for. In `error` mode all of them are gathered
965
1121
  * and thrown together, so one build names every broken link instead of one per run.
1122
+ *
1123
+ * An entry whose body has not been loaded yet (a lazily loaded collection) has no links to rewrite
1124
+ * here; the index is returned so its body is resolved against the same one when it loads.
1125
+ *
1126
+ * @returns {LinkIndex} The collection's link index
966
1127
  */
967
1128
  function resolveEntryLinks(
968
1129
  kept: LoadedFile[],
969
1130
  unpublished: Map<string, string>,
970
1131
  ctx: FinishContext,
971
- ): void {
972
- const { name, rules, sourceRoot } = ctx;
1132
+ ): LinkIndex {
1133
+ const index = buildLinkIndex(kept, unpublished, ctx);
1134
+ const broken: BrokenLink[] = [];
1135
+ const seen = new Set<string>();
1136
+ for (const file of kept) {
1137
+ for (const entry of file.entries) {
1138
+ if (entry.$children && file.rel !== null) {
1139
+ collectBrokenLinks(entry, file.rel, index, broken, seen);
1140
+ }
1141
+ }
1142
+ }
1143
+ reportBrokenLinks(broken, ctx.name, ctx.rules.links);
1144
+ return index;
1145
+ }
1146
+
1147
+ /** The {@link LinkIndex} for one collection: every file it loaded and what became of each. */
1148
+ function buildLinkIndex(
1149
+ kept: LoadedFile[],
1150
+ unpublished: Map<string, string>,
1151
+ ctx: FinishContext,
1152
+ ): LinkIndex {
1153
+ const { rules, sourceRoot } = ctx;
973
1154
  const files = new Map<string, FileOutcome>();
974
1155
  const indexes = new Map<string, string>();
975
1156
  const isReadme = (rel: string) => /^readme\./i.test(rel.slice(rel.lastIndexOf("/") + 1));
@@ -1007,7 +1188,7 @@ function resolveEntryLinks(
1007
1188
  }
1008
1189
 
1009
1190
  const onDisk = (path: string) => resolve(sourceRoot!, path);
1010
- const index: LinkIndex = {
1191
+ return {
1011
1192
  asset: (path) => {
1012
1193
  const absolute = onDisk(path);
1013
1194
  if (!ctx.mount || !existsSync(absolute) || statSync(absolute).isDirectory()) {
@@ -1036,30 +1217,34 @@ function resolveEntryLinks(
1036
1217
  localePrefix: localePrefixOf(ctx),
1037
1218
  trailingSlash: trailingSlashOf(ctx.projectConfig),
1038
1219
  };
1220
+ }
1039
1221
 
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
- });
1222
+ /** Rewrite one entry's links against the index, adding each link with no page to `broken`. */
1223
+ function collectBrokenLinks(
1224
+ entry: ContentLoaderEntry,
1225
+ rel: string,
1226
+ index: LinkIndex,
1227
+ broken: BrokenLink[],
1228
+ seen: Set<string>,
1229
+ ): void {
1230
+ rewriteLinks(entry.$children ?? [], rel, index, (problem) => {
1231
+ const key = `${rel}\0${problem.href}`;
1232
+ if (!seen.has(key)) {
1233
+ seen.add(key);
1234
+ broken.push({ from: rel, href: problem.href, reason: problem.reason });
1054
1235
  }
1055
- }
1056
- if (rules.links === "ignore" || broken.length === 0) {
1236
+ });
1237
+ }
1238
+
1239
+ /** Report broken links at the severity the collection's `links` option asks for. */
1240
+ function reportBrokenLinks(broken: BrokenLink[], name: string, mode: LinkMode): void {
1241
+ if (mode === "ignore" || broken.length === 0) {
1057
1242
  return;
1058
1243
  }
1059
1244
  const lines = broken.map(
1060
1245
  ({ from, href, reason }) => `"${from}" links to "${href}", which ${reason}`,
1061
1246
  );
1062
- if (rules.links === "error") {
1247
+ if (mode === "error") {
1063
1248
  const plural = broken.length === 1 ? "" : "s";
1064
1249
  const list = lines.map((line) => ` - ${line}`).join("\n");
1065
1250
  throw new Error(`Content links: "${name}" has ${broken.length} broken link${plural}:\n${list}`);
@@ -1091,6 +1276,13 @@ interface LoadOptions {
1091
1276
  projectConfig?: ProjectConfig | undefined;
1092
1277
  /** The locale this load is for, when the source carried `{locale}`. */
1093
1278
  locale?: string | undefined;
1279
+ /**
1280
+ * Ask the format to leave entry bodies unparsed (`deferBody`), and report each deferred entry to
1281
+ * `onDeferred`. A format that does not honor the option returns whole entries, and nothing is
1282
+ * deferred.
1283
+ */
1284
+ deferBody?: boolean | undefined;
1285
+ onDeferred?: DeferredBodyHandler | undefined;
1094
1286
  }
1095
1287
 
1096
1288
  /**
@@ -1114,8 +1306,8 @@ async function loadContentType(
1114
1306
  if (!source) {
1115
1307
  return [];
1116
1308
  }
1117
- const { mount, projectConfig, locale } = options;
1118
- const schema = contentTypeDef.schema as ContentTypeSchema | undefined;
1309
+ const { mount, projectConfig, locale, deferBody, onDeferred } = options;
1310
+ const { schema } = contentTypeDef;
1119
1311
  const rules = compileTypeRules(name, contentTypeDef);
1120
1312
  const finishContext = (extra: Partial<FinishContext>): FinishContext => ({
1121
1313
  coerceDates: false,
@@ -1133,11 +1325,10 @@ async function loadContentType(
1133
1325
  // Derive directive allowedNames from content type $elements (tag names from npm packages)
1134
1326
  const directiveOptions = contentTypeDef.$elements?.length
1135
1327
  ? {
1136
- allowedNames: contentTypeDef.$elements
1137
- .filter(
1138
- (e) => typeof e === "string" || (typeof e === "object" && (e as JxMutableNode)?.$ref),
1139
- )
1140
- .map((e) => (typeof e === "string" ? e : (e as JxMutableNode).$ref)),
1328
+ // A tag name as written, or the `$ref` of an element reference; anything else names nothing.
1329
+ allowedNames: contentTypeDef.$elements.flatMap((e) =>
1330
+ isString(e) ? [e] : isNonNullObject(e) && e.$ref ? [e.$ref] : [],
1331
+ ),
1141
1332
  }
1142
1333
  : undefined;
1143
1334
 
@@ -1170,11 +1361,11 @@ async function loadContentType(
1170
1361
  }
1171
1362
  let remote: ContentLoaderEntry[];
1172
1363
  try {
1173
- remote = (await entry.call("load", source, {
1364
+ remote = await entry.call("load", source, {
1174
1365
  directiveOptions,
1175
1366
  schema,
1176
1367
  ...(rules.idField !== undefined && { idField: rules.idField }),
1177
- })) as ContentLoaderEntry[];
1368
+ });
1178
1369
  } catch (error) {
1179
1370
  console.warn(`Content type "${name}": ${errorMessage(error)}`);
1180
1371
  return [];
@@ -1222,33 +1413,41 @@ async function loadContentType(
1222
1413
  }
1223
1414
 
1224
1415
  // Discover entry files via the class, then load each
1416
+ const discoverOptions: FormatDiscoverOptions = { baseDir: projectRoot };
1417
+ if (!rules.exclude.empty) {
1418
+ discoverOptions.exclude = contentTypeDef.exclude;
1419
+ }
1225
1420
  const discovered = entry.capabilities.discover
1226
- ? ((await entry.call("discover", source, {
1227
- baseDir: projectRoot,
1228
- ...(rules.exclude.empty ? {} : { exclude: contentTypeDef.exclude }),
1229
- })) as string[])
1421
+ ? await entry.call("discover", source, discoverOptions)
1230
1422
  : [resolvedSource];
1231
1423
  const files = discovered.filter((file) => !isExcluded(file));
1232
1424
 
1233
1425
  const fileExtensions = files.map((file) => extname(file).toLowerCase());
1426
+ const format = entry;
1427
+ /** One file's load, with or without its body. */
1428
+ const loadFile = (filePath: string, withoutBody: boolean) => {
1429
+ const rel = relOf(filePath);
1430
+ return format.call("load", filePath, {
1431
+ directiveOptions,
1432
+ schema,
1433
+ ...(sourceRoot !== undefined && { sourceRoot }),
1434
+ ...(rules.alerts !== undefined && { alerts: rules.alerts }),
1435
+ ...(rules.idField !== undefined && { idField: rules.idField }),
1436
+ ...(withoutBody && { deferBody: true }),
1437
+ onUnknownAlert: (type: string) =>
1438
+ console.warn(
1439
+ `Content callouts: "${name}": "${rel ?? filePath}" has a [!${type}] callout, but ` +
1440
+ `"${type.toLowerCase()}" is not an enabled type, so it stays a blockquote with the ` +
1441
+ `marker showing. Add "alerts": { "${type.toUpperCase()}": true } to render it.`,
1442
+ ),
1443
+ });
1444
+ };
1234
1445
  const loaded: LoadedFile[] = [];
1235
1446
  for (const filePath of files) {
1236
1447
  const rel = relOf(filePath);
1237
1448
  let fileEntries: ContentLoaderEntry[];
1238
1449
  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[];
1450
+ fileEntries = await loadFile(filePath, deferBody === true);
1252
1451
  } catch (error) {
1253
1452
  throw unreadableFile(name, rel ?? filePath, error);
1254
1453
  }
@@ -1272,6 +1471,10 @@ async function loadContentType(
1272
1471
  coerceDates: true,
1273
1472
  entryExtensions: new Set([...entry.extensions, ...fileExtensions]),
1274
1473
  sourceRoot,
1474
+ ...(deferBody === true && {
1475
+ onDeferred,
1476
+ reload: async (filePath: string) => await loadFile(filePath, false),
1477
+ }),
1275
1478
  }),
1276
1479
  );
1277
1480
  }
@@ -1280,11 +1483,11 @@ async function loadContentType(
1280
1483
  * The error for one file that would not load, with its path: a parse failure names a line and a
1281
1484
  * reason but never the file, and a vault is hundreds of them.
1282
1485
  */
1283
- function unreadableFile(type: string, path: string, error: unknown): Error {
1486
+ function unreadableFile(type: string, path: string, cause: unknown): Error {
1284
1487
  return new Error(
1285
- `Content type "${type}": cannot read "${path}": ${errorMessage(error)}. Fix the file, or add it ` +
1488
+ `Content type "${type}": cannot read "${path}": ${errorMessage(cause)}. Fix the file, or add it ` +
1286
1489
  `to "exclude" if it is not a document (a templates folder, for example).`,
1287
- { cause: error },
1490
+ { cause },
1288
1491
  );
1289
1492
  }
1290
1493
 
@@ -1297,8 +1500,23 @@ function unknownFormatError(path: string, ext: string): Error {
1297
1500
  );
1298
1501
  }
1299
1502
 
1300
- function errorMessage(error: unknown): string {
1301
- return error instanceof Error ? error.message : String(error);
1503
+ function errorMessage(cause: unknown): string {
1504
+ return cause instanceof Error ? cause.message : String(cause);
1505
+ }
1506
+
1507
+ /**
1508
+ * The JavaScript type name of a field value, as `typeof` spells it, for a validation message. A
1509
+ * date, a list, a nested object, a resolved entry and `null` are all `"object"`, as they are
1510
+ * there.
1511
+ */
1512
+ function jsTypeName(value: ContentFieldValue): string {
1513
+ if (isString(value)) {
1514
+ return "string";
1515
+ }
1516
+ if (isNumber(value)) {
1517
+ return "number";
1518
+ }
1519
+ return isBoolean(value) ? "boolean" : "object";
1302
1520
  }
1303
1521
 
1304
1522
  // ─── Schema Validation ────────────────────────────────────────────────────────
@@ -1336,26 +1554,26 @@ function validateEntries(
1336
1554
  continue;
1337
1555
  }
1338
1556
 
1339
- if (isDateFormat(def.format) && typeof value === "string" && !isCoercedDate(value)) {
1557
+ if (isDateFormat(def.format) && isString(value) && !isCoercedDate(value)) {
1340
1558
  console.warn(
1341
1559
  `Content validation: "${contentTypeName}/${entry.id}" field "${field}" is declared ` +
1342
1560
  `${def.format} but "${value}" was not coerced — it will sort and filter as plain text.`,
1343
1561
  );
1344
- } else if (def.type === "string" && typeof value !== "string") {
1562
+ } else if (def.type === "string" && !isString(value)) {
1345
1563
  console.warn(
1346
- `Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected string, got ${typeof value}`,
1564
+ `Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected string, got ${jsTypeName(value)}`,
1347
1565
  );
1348
- } else if (def.type === "number" && typeof value !== "number") {
1566
+ } else if (def.type === "number" && !isNumber(value)) {
1349
1567
  console.warn(
1350
- `Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected number, got ${typeof value}`,
1568
+ `Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected number, got ${jsTypeName(value)}`,
1351
1569
  );
1352
- } else if (def.type === "boolean" && typeof value !== "boolean") {
1570
+ } else if (def.type === "boolean" && !isBoolean(value)) {
1353
1571
  console.warn(
1354
- `Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected boolean, got ${typeof value}`,
1572
+ `Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected boolean, got ${jsTypeName(value)}`,
1355
1573
  );
1356
1574
  } else if (def.type === "array" && !Array.isArray(value)) {
1357
1575
  console.warn(
1358
- `Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected array, got ${typeof value}`,
1576
+ `Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected array, got ${jsTypeName(value)}`,
1359
1577
  );
1360
1578
  }
1361
1579
  }
@@ -1375,6 +1593,25 @@ function refTargetName(ref: string | undefined): string | undefined {
1375
1593
  return ref.slice(CONTENT_REF_PREFIX.length);
1376
1594
  }
1377
1595
 
1596
+ /**
1597
+ * The content types a content type's schema points into: each field whose `$ref` (to-one) or
1598
+ * `items.$ref` (to-many) is a `#/content/<name>` pointer. What {@link resolveContentTypeRefs} will
1599
+ * look up for this type, so a type can be loaded together with exactly what it relates to.
1600
+ *
1601
+ * @param {ContentTypeDef | undefined} def - The content type's definition
1602
+ * @returns {string[]} The referenced type names, each once
1603
+ */
1604
+ export function referencedContentTypes(def: ContentTypeDef | undefined): string[] {
1605
+ const targets = new Set<string>();
1606
+ for (const field of Object.values(def?.schema?.properties ?? {})) {
1607
+ const target = refTargetName(field.$ref) ?? refTargetName(field.items?.$ref);
1608
+ if (target !== undefined) {
1609
+ targets.add(target);
1610
+ }
1611
+ }
1612
+ return [...targets];
1613
+ }
1614
+
1378
1615
  /**
1379
1616
  * Resolve cross-content-type relationship references in entry data (specs/relationships.md). A bare
1380
1617
  * field `$ref` is to-one: a blog post's `author: "jane-doe"` with a schema `$ref` of
@@ -1390,7 +1627,7 @@ export function resolveContentTypeRefs(
1390
1627
  section: ContentSection,
1391
1628
  ) {
1392
1629
  for (const [name, contentTypeDef] of Object.entries(section)) {
1393
- const schema = contentTypeDef.schema as ContentTypeSchema | undefined;
1630
+ const { schema } = contentTypeDef;
1394
1631
  if (!schema?.properties) {
1395
1632
  continue;
1396
1633
  }
@@ -1417,7 +1654,7 @@ export function resolveContentTypeRefs(
1417
1654
 
1418
1655
  for (const entry of entries) {
1419
1656
  const value = entry.data[field];
1420
- if (toOne && typeof value === "string") {
1657
+ if (toOne && isString(value)) {
1421
1658
  const resolved = refEntries.find((e) => e.id === value);
1422
1659
  if (resolved) {
1423
1660
  entry.data[field] = resolved;
@@ -1428,7 +1665,7 @@ export function resolveContentTypeRefs(
1428
1665
  }
1429
1666
  } else if (toMany && Array.isArray(value)) {
1430
1667
  entry.data[field] = value.map((id) => {
1431
- if (typeof id !== "string") {
1668
+ if (!isString(id)) {
1432
1669
  return id;
1433
1670
  }
1434
1671
  const resolved = refEntries.find((e) => e.id === id);
@@ -1477,13 +1714,13 @@ function reportUnservedRoutes(
1477
1714
  return;
1478
1715
  }
1479
1716
  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
- );
1717
+ const unserved = entries.flatMap((entry) => {
1718
+ const route = entry._meta?.route;
1719
+ return route !== undefined &&
1720
+ !patterns.some((pattern) => routeParams(route, pattern, []) !== null)
1721
+ ? [route]
1722
+ : [];
1723
+ });
1487
1724
  if (unserved.length === 0) {
1488
1725
  return;
1489
1726
  }
@@ -1514,11 +1751,11 @@ function routedPaths(
1514
1751
  entries: ContentLoaderEntry[],
1515
1752
  ctx: ResolvePathsContext,
1516
1753
  urlPattern: string,
1517
- ): Record<string, unknown>[] {
1754
+ ): ContentPathParams[] {
1518
1755
  const { i18n } = resolveI18n(ctx.projectConfig ?? {});
1519
1756
  const prefix = localeUrlPrefix(ctx.locale, i18n);
1520
1757
  const pattern = withoutLocalePrefix(urlPattern, prefix);
1521
- const paths: Record<string, unknown>[] = [];
1758
+ const paths: ContentPathParams[] = [];
1522
1759
  let example: string | undefined;
1523
1760
  for (const entry of entries) {
1524
1761
  const route = entry._meta?.route;
@@ -1528,7 +1765,11 @@ function routedPaths(
1528
1765
  example ??= route;
1529
1766
  const params = routeParams(route, pattern, ctx.params ?? []);
1530
1767
  if (params) {
1531
- paths.push({ ...params, ...(entry._meta === undefined ? {} : { _meta: entry._meta }) });
1768
+ const pathEntry: ContentPathParams = { ...params };
1769
+ if (entry._meta !== undefined) {
1770
+ pathEntry._meta = entry._meta;
1771
+ }
1772
+ paths.push(pathEntry);
1532
1773
  }
1533
1774
  }
1534
1775
  if (paths.length === 0 && example !== undefined) {
@@ -1541,6 +1782,225 @@ function routedPaths(
1541
1782
  return paths;
1542
1783
  }
1543
1784
 
1785
+ // ─── The section's loaded data ───────────────────────────────────────────────
1786
+
1787
+ /*
1788
+ * What `Content.projectData` answers, and what classes read as `_project.content`: a map of content
1789
+ * type name → entries. A build reads every entry of every type, so it gets them all at once, as it
1790
+ * always has. A host that serves one page at a time (the dev server, Studio) asks for the section
1791
+ * lazily (`ProjectDataContext.lazy`, specs/extensions.md §8), and then a type loads the first
1792
+ * time a class asks for it, with only its frontmatter, and an entry's body is parsed only once that
1793
+ * entry is actually returned.
1794
+ *
1795
+ * Both are the same class, so a consumer reads either through `load` and `materialize`, which are
1796
+ * no-ops on a store that is already whole; a consumer that reads the map directly (`get`) is a
1797
+ * build-time one and sees exactly what it always did.
1798
+ */
1799
+
1800
+ /** What a store loads its section with. */
1801
+ export interface ContentStoreSource {
1802
+ section: ContentSection;
1803
+ root: string;
1804
+ formats: FormatRegistry;
1805
+ projectConfig: ProjectConfig | undefined;
1806
+ }
1807
+
1808
+ export class ContentStore extends Map<string, ContentLoaderEntry[]> implements ContentSectionData {
1809
+ readonly #source: ContentStoreSource;
1810
+ readonly #mounts: Map<string, AssetMount>;
1811
+ /** Each type's load in flight or done, so concurrent asks share one. */
1812
+ readonly #loads = new Map<string, Promise<void>>();
1813
+ /** Types whose relationship fields have been resolved (it mutates entries, so exactly once). */
1814
+ readonly #linked = new Set<string>();
1815
+ /** Every entry this store loaded, to tell a related entry inside `data` from ordinary data. */
1816
+ readonly #entries = new WeakSet<object>();
1817
+ /** Entries loaded without their body, and what completes each. */
1818
+ readonly #deferred = new WeakMap<ContentLoaderEntry, () => Promise<void>>();
1819
+ /** Bodies being completed, so two requests for one entry share the work. */
1820
+ readonly #completing = new WeakMap<ContentLoaderEntry, Promise<void>>();
1821
+
1822
+ private constructor(source: ContentStoreSource) {
1823
+ super();
1824
+ this.#source = source;
1825
+ this.#mounts = sectionMounts(source.section, source.root, source.projectConfig);
1826
+ }
1827
+
1828
+ /** A store holding every type, bodies and relationships included: what a build reads. */
1829
+ static async whole(source: ContentStoreSource): Promise<ContentStore> {
1830
+ const store = new ContentStore(source);
1831
+ const loaded = await loadContentSection(
1832
+ source.section,
1833
+ source.root,
1834
+ source.formats,
1835
+ source.projectConfig,
1836
+ );
1837
+ for (const [name, entries] of loaded) {
1838
+ store.#adopt(name, entries);
1839
+ store.#loads.set(name, Promise.resolve());
1840
+ }
1841
+ resolveContentTypeRefs(store, source.section);
1842
+ for (const name of loaded.keys()) {
1843
+ store.#linked.add(name);
1844
+ }
1845
+ return store;
1846
+ }
1847
+
1848
+ /** An empty store that loads each type when it is first asked for. */
1849
+ static lazy(source: ContentStoreSource): ContentStore {
1850
+ return new ContentStore(source);
1851
+ }
1852
+
1853
+ /**
1854
+ * One type's entries, loading it (and every type its schema relates it to) on first use.
1855
+ *
1856
+ * @param {string} name - Content type name
1857
+ * @returns {Promise<ContentLoaderEntry[] | undefined>} The entries, or undefined when the section
1858
+ * declares no such type
1859
+ */
1860
+ async load(name: string): Promise<ContentLoaderEntry[] | undefined> {
1861
+ if (!Object.hasOwn(this.#source.section, name)) {
1862
+ return undefined;
1863
+ }
1864
+ const related = this.#relatedTypes(name);
1865
+ await Promise.all(related.map(async (type) => await this.#loadType(type)));
1866
+ /* Resolved only once everything a type points into is loaded, and synchronously, so two loads
1867
+ finishing together cannot both resolve one type: its fields would be looked up twice, the
1868
+ second time with entries where the ids were. */
1869
+ const unlinked = related.filter((type) => !this.#linked.has(type));
1870
+ for (const type of unlinked) {
1871
+ this.#linked.add(type);
1872
+ }
1873
+ if (unlinked.length > 0) {
1874
+ const { section } = this.#source;
1875
+ resolveContentTypeRefs(
1876
+ this,
1877
+ Object.fromEntries(
1878
+ unlinked.flatMap((type) => (section[type] ? [[type, section[type]]] : [])),
1879
+ ),
1880
+ );
1881
+ }
1882
+ return this.get(name);
1883
+ }
1884
+
1885
+ /**
1886
+ * Give every entry in `entries` its body, and every entry they relate to through a relationship
1887
+ * field, before they are handed out. An entry that already has one is left as it is.
1888
+ *
1889
+ * @param {readonly (ContentLoaderEntry | null | undefined)[]} entries - Entries about to be
1890
+ * returned
1891
+ */
1892
+ async materialize(entries: readonly (ContentLoaderEntry | null | undefined)[]): Promise<void> {
1893
+ const seen = new Set<ContentLoaderEntry>();
1894
+ const pending: Promise<void>[] = [];
1895
+ const visit = (entry: ContentLoaderEntry) => {
1896
+ if (seen.has(entry)) {
1897
+ return;
1898
+ }
1899
+ seen.add(entry);
1900
+ pending.push(this.#complete(entry));
1901
+ for (const value of Object.values(entry.data)) {
1902
+ for (const item of Array.isArray(value) ? value : [value]) {
1903
+ if (this.#isEntry(item)) {
1904
+ visit(item);
1905
+ }
1906
+ }
1907
+ }
1908
+ };
1909
+ for (const entry of entries) {
1910
+ if (entry) {
1911
+ visit(entry);
1912
+ }
1913
+ }
1914
+ await Promise.all(pending);
1915
+ }
1916
+
1917
+ /**
1918
+ * Load every type and give every entry its body: the store becomes exactly what
1919
+ * {@link ContentStore.whole} builds. A host does this before handing `_project` to a class that
1920
+ * reads it synchronously (one that does not declare `projectOnDemand`, specs/extensions.md §8).
1921
+ */
1922
+ async complete(): Promise<void> {
1923
+ const lists = await Promise.all(
1924
+ Object.keys(this.#source.section).map(async (name) => (await this.load(name)) ?? []),
1925
+ );
1926
+ await this.materialize(lists.flat());
1927
+ }
1928
+
1929
+ /** The type and every type reachable through its schema's relationship fields. */
1930
+ #relatedTypes(name: string): string[] {
1931
+ const { section } = this.#source;
1932
+ const found = new Set<string>([name]);
1933
+ const queue = [name];
1934
+ for (let type = queue.shift(); type !== undefined; type = queue.shift()) {
1935
+ for (const target of referencedContentTypes(section[type])) {
1936
+ if (Object.hasOwn(section, target) && !found.has(target)) {
1937
+ found.add(target);
1938
+ queue.push(target);
1939
+ }
1940
+ }
1941
+ }
1942
+ return [...found];
1943
+ }
1944
+
1945
+ #loadType(name: string): Promise<void> {
1946
+ let load = this.#loads.get(name);
1947
+ if (!load) {
1948
+ const { section, root, formats, projectConfig } = this.#source;
1949
+ load = loadSectionType(name, {
1950
+ formats,
1951
+ mounts: this.#mounts,
1952
+ onDeferred: (entry, completeBody) => {
1953
+ this.#deferred.set(entry, completeBody);
1954
+ },
1955
+ projectConfig,
1956
+ root,
1957
+ section,
1958
+ }).then((entries) => {
1959
+ this.#adopt(name, entries);
1960
+ });
1961
+ /* A failed load is not kept: the error reaches this ask, and the next one tries again (the
1962
+ file may have been fixed in between). */
1963
+ load.catch(() => {
1964
+ this.#loads.delete(name);
1965
+ });
1966
+ this.#loads.set(name, load);
1967
+ }
1968
+ return load;
1969
+ }
1970
+
1971
+ #adopt(name: string, entries: ContentLoaderEntry[]): void {
1972
+ for (const entry of entries) {
1973
+ this.#entries.add(entry);
1974
+ }
1975
+ this.set(name, entries);
1976
+ }
1977
+
1978
+ /* A related entry is placed into `data` by `resolveContentTypeRefs`, so membership in the set of
1979
+ entries this store loaded is exactly the test, and it never mistakes an object field for one. */
1980
+ #isEntry(value: ContentLoaderEntry["data"][string]): value is ContentLoaderEntry {
1981
+ return isNonNullObject(value) && this.#entries.has(value);
1982
+ }
1983
+
1984
+ #complete(entry: ContentLoaderEntry): Promise<void> {
1985
+ let completing = this.#completing.get(entry);
1986
+ if (!completing) {
1987
+ const completeBody = this.#deferred.get(entry);
1988
+ if (!completeBody) {
1989
+ return Promise.resolve();
1990
+ }
1991
+ completing = completeBody().then(() => {
1992
+ this.#deferred.delete(entry);
1993
+ });
1994
+ // A failed body is retried by the next ask, as a failed type load is.
1995
+ completing.catch(() => {
1996
+ this.#completing.delete(entry);
1997
+ });
1998
+ this.#completing.set(entry, completing);
1999
+ }
2000
+ return completing;
2001
+ }
2002
+ }
2003
+
1544
2004
  // ─── The Content project-section class ───────────────────────────────────────
1545
2005
 
1546
2006
  /**
@@ -1552,25 +2012,25 @@ function routedPaths(
1552
2012
  export class Content {
1553
2013
  /**
1554
2014
  * Load the `content` section: every content type's entries, with relationship references already
1555
- * resolved.
2015
+ * resolved. A host that asks for it `lazy` gets a {@link ContentStore} that loads each type the
2016
+ * first time a class asks for it and each entry's body only once that entry is returned
2017
+ * (specs/extensions.md §8); either way the answer is a map of type name to entries.
1556
2018
  *
1557
- * @param {unknown} sectionValue - The project.json `content` section value
1558
- * @param {ProjectDataContext} ctx - Host context ({ projectConfig, root, registry, io })
1559
- * @returns {Promise<Map<string, ContentLoaderEntry[]>>} Content type name → entries
2019
+ * @param {ContentSection | undefined} sectionValue - The project.json `content` section value
2020
+ * @param {ProjectDataContext} ctx - Host context ({ projectConfig, root, registry, io, lazy })
2021
+ * @returns {Promise<ContentStore>} Content type name → entries
1560
2022
  */
1561
2023
  static async projectData(
1562
- sectionValue: unknown,
2024
+ sectionValue: ContentSection | undefined,
1563
2025
  ctx: ProjectDataContext,
1564
- ): Promise<Map<string, ContentLoaderEntry[]>> {
1565
- const section = (sectionValue ?? {}) as ContentSection;
1566
- const data = await loadContentSection(
1567
- section,
1568
- ctx.root,
1569
- ctx.registry.formats,
1570
- ctx.projectConfig,
1571
- );
1572
- resolveContentTypeRefs(data, section);
1573
- return data;
2026
+ ): Promise<ContentStore> {
2027
+ const source = {
2028
+ formats: ctx.registry.formats,
2029
+ projectConfig: ctx.projectConfig,
2030
+ root: ctx.root,
2031
+ section: sectionValue ?? {},
2032
+ };
2033
+ return ctx.lazy === true ? ContentStore.lazy(source) : await ContentStore.whole(source);
1574
2034
  }
1575
2035
 
1576
2036
  /**
@@ -1578,15 +2038,15 @@ export class Content {
1578
2038
  * Hosts serve, resolve, and copy files through the returned mounts, which is what lets an entry
1579
2039
  * reference `../images/hero.png` and still resolve in a built site, the dev server, and Studio.
1580
2040
  *
1581
- * @param {unknown} sectionValue - The project.json `content` section value
2041
+ * @param {ContentSection | undefined} sectionValue - The project.json `content` section value
1582
2042
  * @param {{ projectConfig?: ProjectConfig; root: string }} ctx - Host context
1583
2043
  * @returns {AssetMount[]} One mount per content type with a local directory source
1584
2044
  */
1585
2045
  static assets(
1586
- sectionValue: unknown,
2046
+ sectionValue: ContentSection | undefined,
1587
2047
  ctx: { root: string; projectConfig?: ProjectConfig },
1588
2048
  ): AssetMount[] {
1589
- return contentAssetMounts((sectionValue ?? {}) as ContentSection, ctx.root, ctx.projectConfig);
2049
+ return contentAssetMounts(sectionValue ?? {}, ctx.root, ctx.projectConfig);
1590
2050
  }
1591
2051
 
1592
2052
  /**
@@ -1611,13 +2071,13 @@ export class Content {
1611
2071
  *
1612
2072
  * @param {ContentPathsSource} pathsDef - The `$paths` value ({ contentType, param?, field? })
1613
2073
  * @param {ResolvePathsContext} ctx - Host context ({ data, projectConfig, root, urlPattern })
1614
- * @returns {Promise<Record<string, unknown>[]>} Array of route-param objects
2074
+ * @returns {Promise<ContentPathParams[]>} Array of route-param objects
1615
2075
  */
1616
2076
  static async resolvePaths(
1617
2077
  pathsDef: ContentPathsSource,
1618
2078
  ctx: ResolvePathsContext,
1619
- ): Promise<Record<string, unknown>[]> {
1620
- const entries = ctx.data.get(pathsDef.contentType);
2079
+ ): Promise<ContentPathParams[]> {
2080
+ const entries = await entriesOf(ctx.data, pathsDef.contentType);
1621
2081
  if (!entries || entries.length === 0) {
1622
2082
  console.warn(
1623
2083
  `Warning: $paths references content type "${pathsDef.contentType}" but it has no entries`,
@@ -1635,7 +2095,7 @@ export class Content {
1635
2095
  const localized = entries.some((entry) => entry._meta?.locale !== undefined);
1636
2096
  const wanted = ctx.locale;
1637
2097
  const scoped =
1638
- localized && typeof wanted === "string"
2098
+ localized && isString(wanted)
1639
2099
  ? entries.filter((entry) => entry._meta?.locale === wanted)
1640
2100
  : entries;
1641
2101
  if (
@@ -1646,7 +2106,7 @@ export class Content {
1646
2106
  ) {
1647
2107
  return routedPaths(pathsDef.contentType, scoped, ctx, ctx.urlPattern);
1648
2108
  }
1649
- const paths: Record<string, unknown>[] = [];
2109
+ const paths: ContentPathParams[] = [];
1650
2110
  for (const entry of scoped) {
1651
2111
  const value = field === "id" ? entry.id : (entry.data[field] ?? entry.id);
1652
2112
  if (!value) {
@@ -1663,7 +2123,7 @@ export class Content {
1663
2123
  );
1664
2124
  continue;
1665
2125
  }
1666
- const pathEntry: Record<string, unknown> = { [param]: value };
2126
+ const pathEntry: ContentPathParams = { [param]: value };
1667
2127
  if (entry._meta !== undefined) {
1668
2128
  pathEntry._meta = entry._meta;
1669
2129
  }