@jxsuite/parser 2.0.1 → 4.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.
@@ -17,17 +17,29 @@
17
17
  * @module @jxsuite/parser/content-loader
18
18
  */
19
19
 
20
- import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
20
+ import { existsSync, readFileSync, readdirSync, realpathSync, 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.
@@ -166,6 +182,25 @@ export function localesForExpansion(projectConfig: ProjectConfig | undefined): s
166
182
  return resolveI18n(projectConfig ?? {}).i18n?.locales ?? [];
167
183
  }
168
184
 
185
+ /**
186
+ * Whether `path` (relative to `root`) exists with exactly this spelling.
187
+ *
188
+ * `existsSync` alone answers "is there a directory called `fr-ca`" with yes when only `fr-CA`
189
+ * exists, on a case-insensitive filesystem (macOS and Windows by default), so the spelling that
190
+ * wins would depend on the machine. The filesystem's own name for the entry, from `realpath`, is
191
+ * compared segment for segment instead, which is the same answer everywhere.
192
+ */
193
+ function existsWithSpelling(root: string, path: string): boolean {
194
+ const full = resolve(root, path);
195
+ if (!existsSync(full)) {
196
+ return false;
197
+ }
198
+ const wanted = full.split(/[\\/]/).filter(Boolean);
199
+ const actual = realpathSync.native(full).split(/[\\/]/).filter(Boolean);
200
+ const tail = actual.slice(-wanted.length);
201
+ return wanted.every((segment, index) => segment === tail[index]);
202
+ }
203
+
169
204
  /**
170
205
  * The directory a locale's entries live in, out of the spellings that can legitimately be on disk.
171
206
  *
@@ -188,7 +223,7 @@ export function localeSource(root: string, source: string, locale: string): stri
188
223
  const candidates = [locale, locale.toLowerCase()];
189
224
  for (const spelling of candidates) {
190
225
  const path = source.split(LOCALE_PLACEHOLDER).join(spelling);
191
- if (existsSync(resolve(root, path))) {
226
+ if (existsWithSpelling(root, path)) {
192
227
  return path;
193
228
  }
194
229
  }
@@ -204,12 +239,6 @@ export function localeSource(root: string, source: string, locale: string): stri
204
239
  return source.split(LOCALE_PLACEHOLDER).join(locale);
205
240
  }
206
241
 
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
242
  /** Node keys whose value is a media reference the rewrite may remap. */
214
243
  const ASSET_KEYS = ["src", "poster"] as const;
215
244
 
@@ -273,7 +302,7 @@ function mountFilterFor(def: ContentTypeDef | undefined): ((path: string) => boo
273
302
  * @returns {AssetMount[]} One mount per eligible content type, in declaration order
274
303
  */
275
304
  export function contentAssetMounts(
276
- section: ContentSection,
305
+ section: ContentSection | undefined,
277
306
  root: string,
278
307
  projectConfig?: ProjectConfig,
279
308
  ): AssetMount[] {
@@ -284,7 +313,11 @@ export function contentAssetMounts(
284
313
  return filter === undefined ? {} : { filter };
285
314
  };
286
315
  for (const [name, contentTypeDef] of Object.entries(section ?? {})) {
287
- const source = contentTypeDef?.source;
316
+ // A definition is project.json data, and one still being written may not be an object yet.
317
+ if (!isNonNullObject(contentTypeDef)) {
318
+ continue;
319
+ }
320
+ const { source } = contentTypeDef;
288
321
  if (!source || source.startsWith("http://") || source.startsWith("https://")) {
289
322
  continue;
290
323
  }
@@ -295,26 +328,26 @@ export function contentAssetMounts(
295
328
  */
296
329
  if (source.includes(LOCALE_PLACEHOLDER)) {
297
330
  for (const locale of locales) {
298
- if (!SAFE_TYPE_NAME.test(locale)) {
331
+ if (!SAFE_CONTENT_TYPE_NAME.test(locale)) {
299
332
  continue;
300
333
  }
301
334
  const dir = resolve(root, localeSource(root, source, locale))
302
335
  .split("\\")
303
336
  .join("/");
304
- if (SAFE_TYPE_NAME.test(name) && existsSync(dir) && statSync(dir).isDirectory()) {
337
+ if (SAFE_CONTENT_TYPE_NAME.test(name) && existsSync(dir) && statSync(dir).isDirectory()) {
305
338
  mounts.push({
306
339
  dir,
307
- urlPrefix: `/${SECTION_KEY}/${name}/${locale}`,
340
+ urlPrefix: `/${CONTENT_SECTION_KEY}/${name}/${locale}`,
308
341
  ...filterOf(contentTypeDef),
309
342
  });
310
343
  }
311
344
  }
312
345
  continue;
313
346
  }
314
- if (!SAFE_TYPE_NAME.test(name)) {
347
+ if (!SAFE_CONTENT_TYPE_NAME.test(name)) {
315
348
  console.warn(
316
349
  `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.`,
350
+ `at /${CONTENT_SECTION_KEY}/${name}, so content-relative asset references stay unresolved.`,
318
351
  );
319
352
  continue;
320
353
  }
@@ -324,7 +357,7 @@ export function contentAssetMounts(
324
357
  }
325
358
  mounts.push({
326
359
  dir: resolvedSource,
327
- urlPrefix: `/${SECTION_KEY}/${name}`,
360
+ urlPrefix: `/${CONTENT_SECTION_KEY}/${name}`,
328
361
  ...filterOf(contentTypeDef),
329
362
  });
330
363
  }
@@ -386,26 +419,26 @@ function rewriteNodeAssets(
386
419
  }
387
420
  return;
388
421
  }
389
- if (!node || typeof node !== "object") {
422
+ if (!isNonNullObject(node)) {
390
423
  return;
391
424
  }
392
- const record = node as unknown as Record<string, unknown>;
393
- const attributes = record.attributes as Record<string, unknown> | undefined;
425
+ const { attributes } = node;
394
426
  for (const key of ASSET_KEYS) {
395
- for (const holder of [record, attributes]) {
427
+ // The node's own property first, then its attribute of the same name.
428
+ for (const holder of [node, attributes]) {
396
429
  const value = holder?.[key];
397
- if (typeof value === "string") {
430
+ if (holder && isString(value)) {
398
431
  const rewritten = rewriteRef(value, entryDir, mount, onProblem);
399
432
  if (rewritten) {
400
- holder![key] = rewritten;
433
+ holder[key] = rewritten;
401
434
  }
402
435
  }
403
436
  }
404
437
  }
405
- const { children } = record;
438
+ const { children } = node;
406
439
  if (Array.isArray(children)) {
407
440
  for (const child of children) {
408
- rewriteNodeAssets(child as JxElement | string | ContentNodes, entryDir, mount, onProblem);
441
+ rewriteNodeAssets(child, entryDir, mount, onProblem);
409
442
  }
410
443
  }
411
444
  }
@@ -433,37 +466,62 @@ export function rewriteEntryAssets(
433
466
  schema?: ContentTypeSchema,
434
467
  ) {
435
468
  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);
469
+ /* A field's schema is project.json data, so one that is not an object (a `null` left by an edit)
470
+ declares nothing rather than failing the load. */
471
+ const uriFields = Object.entries(schema?.properties ?? {}).flatMap(([field, def]) =>
472
+ isNonNullObject(def) &&
473
+ (def.format === "uri-reference" || def.items?.format === "uri-reference")
474
+ ? [field]
475
+ : [],
476
+ );
439
477
 
440
478
  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
- }
479
+ const onProblem = assetProblemReporter(typeName, entry);
480
+ rewriteBodyAssets(entry, filePath, mount, typeName);
453
481
 
454
482
  for (const field of uriFields) {
455
483
  const value = entry.data[field];
456
- if (typeof value === "string") {
484
+ if (isString(value)) {
457
485
  entry.data[field] = rewriteRef(value, entryDir, mount, onProblem) ?? value;
458
486
  } else if (Array.isArray(value)) {
459
487
  entry.data[field] = value.map((item) =>
460
- typeof item === "string" ? (rewriteRef(item, entryDir, mount, onProblem) ?? item) : item,
488
+ isString(item) ? (rewriteRef(item, entryDir, mount, onProblem) ?? item) : item,
461
489
  );
462
490
  }
463
491
  }
464
492
  }
465
493
  }
466
494
 
495
+ /** The warning for one asset reference an entry makes that its collection will not publish. */
496
+ function assetProblemReporter(typeName: string, entry: ContentLoaderEntry) {
497
+ return (value: string, problem: RefProblem) =>
498
+ console.warn(
499
+ problem === "missing"
500
+ ? `Content type "${typeName}": entry "${entry.id}" references missing asset "${value}"`
501
+ : `Content type "${typeName}": entry "${entry.id}" references "${value}", which the ` +
502
+ `collection does not publish (it is excluded, hidden or a document), so the ` +
503
+ `reference is left as written`,
504
+ );
505
+ }
506
+
507
+ /**
508
+ * The body half of {@link rewriteEntryAssets}: remap the media references in an entry's `$children`
509
+ * onto the mount. Separate because a body loaded later (a lazily loaded collection's deferred
510
+ * entry) gets this pass on its own, after its frontmatter had the other half at load.
511
+ */
512
+ function rewriteBodyAssets(
513
+ entry: ContentLoaderEntry,
514
+ filePath: string,
515
+ mount: AssetMount,
516
+ typeName: string,
517
+ ) {
518
+ const entryDir = dirname(filePath);
519
+ const onProblem = assetProblemReporter(typeName, entry);
520
+ for (const child of entry.$children ?? []) {
521
+ rewriteNodeAssets(child, entryDir, mount, onProblem);
522
+ }
523
+ }
524
+
467
525
  // ─── Content Config ───────────────────────────────────────────────────────────
468
526
 
469
527
  /**
@@ -477,6 +535,9 @@ export function rewriteEntryAssets(
477
535
  export function loadContentConfig(projectRoot: string, projectConfig?: ProjectConfig) {
478
536
  const contentDir = resolve(projectRoot, "content");
479
537
 
538
+ /* SAFETY: `content` is the section this extension owns, and its project fragment schema defines
539
+ it as content type name → definition, which is what ContentSection spells; the options of a
540
+ definition are validated again by compileTypeRules when that type loads. */
480
541
  const config = { content: (projectConfig?.content as ContentSection | undefined) ?? {} };
481
542
 
482
543
  return { config, contentDir };
@@ -499,7 +560,23 @@ export async function loadContentSection(
499
560
  projectConfig?: ProjectConfig,
500
561
  ): Promise<Map<string, ContentLoaderEntry[]>> {
501
562
  const contentTypes = new Map<string, ContentLoaderEntry[]>();
502
- const mounts = new Map(
563
+ const mounts = sectionMounts(section, root, projectConfig);
564
+ for (const name of Object.keys(section)) {
565
+ contentTypes.set(
566
+ name,
567
+ await loadSectionType(name, { formats, mounts, projectConfig, root, section }),
568
+ );
569
+ }
570
+ return contentTypes;
571
+ }
572
+
573
+ /** A section's asset mounts, keyed by the path below `/content/` a lookup builds. */
574
+ export function sectionMounts(
575
+ section: ContentSection,
576
+ root: string,
577
+ projectConfig: ProjectConfig | undefined,
578
+ ): Map<string, AssetMount> {
579
+ return new Map(
503
580
  contentAssetMounts(section, root, projectConfig).map((mount) => [
504
581
  /*
505
582
  * The path below `/content/` — `<type>` for a plain source, `<type>/<locale>` for a
@@ -508,50 +585,77 @@ export async function loadContentSection(
508
585
  * translated entry's `./hero.png` was left unrewritten; and it collided any two types that
509
586
  * shared a locale.
510
587
  */
511
- mount.urlPrefix.slice(`/${SECTION_KEY}/`.length),
588
+ mount.urlPrefix.slice(`/${CONTENT_SECTION_KEY}/`.length),
512
589
  mount,
513
590
  ]),
514
591
  );
592
+ }
593
+
594
+ /** What {@link loadSectionType} loads one content type of a section with. */
595
+ export interface SectionTypeSource {
596
+ section: ContentSection;
597
+ root: string;
598
+ formats: FormatRegistry;
599
+ projectConfig: ProjectConfig | undefined;
600
+ /** The section's mounts, from {@link sectionMounts}. */
601
+ mounts: Map<string, AssetMount>;
602
+ /** Leave entry bodies for later, reporting each deferred entry here. */
603
+ onDeferred?: DeferredBodyHandler | undefined;
604
+ }
605
+
606
+ /**
607
+ * Load one content type of a section, expanding a `{locale}` source over the project's locales.
608
+ *
609
+ * @param {string} name - The content type's name in the section
610
+ * @param {SectionTypeSource} source - The section and what it is loaded with
611
+ * @returns {Promise<ContentLoaderEntry[]>} The type's entries
612
+ */
613
+ export async function loadSectionType(
614
+ name: string,
615
+ source: SectionTypeSource,
616
+ ): Promise<ContentLoaderEntry[]> {
617
+ const { section, root, formats, projectConfig, mounts, onDeferred } = source;
618
+ const contentTypeDef = section[name];
619
+ if (!contentTypeDef) {
620
+ return [];
621
+ }
622
+ const deferral = onDeferred ? { deferBody: true, onDeferred } : {};
623
+ const typeSource = contentTypeDef.source;
624
+ if (!isString(typeSource) || !typeSource.includes(LOCALE_PLACEHOLDER)) {
625
+ return await loadContentType(name, contentTypeDef, root, formats, {
626
+ mount: mounts.get(name),
627
+ projectConfig,
628
+ ...deferral,
629
+ });
630
+ }
631
+ /*
632
+ * One content type, N directories. Each pass loads a real path and stamps the locale it came
633
+ * from onto every entry, which is the only thing distinguishing two translations that share an
634
+ * id — `resolvePaths` reads it back to expand the right ones under a locale-prefixed route.
635
+ */
515
636
  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);
637
+ if (locales.length === 0) {
638
+ console.warn(
639
+ `Content type "${name}": source "${typeSource}" uses ${LOCALE_PLACEHOLDER}, but the project ` +
640
+ `declares no i18n locales — nothing to expand it over, so no entries were loaded.`,
641
+ );
642
+ return [];
643
+ }
644
+ const all: ContentLoaderEntry[] = [];
645
+ for (const locale of locales) {
646
+ const localized = { ...contentTypeDef, source: localeSource(root, typeSource, locale) };
647
+ const entries = await loadContentType(name, localized, root, formats, {
648
+ locale,
649
+ mount: mounts.get(`${name}/${locale}`),
650
+ projectConfig,
651
+ ...deferral,
652
+ });
653
+ for (const entry of entries) {
654
+ entry._meta = { ...entry._meta, locale };
551
655
  }
552
- contentTypes.set(name, all);
656
+ all.push(...entries);
553
657
  }
554
- return contentTypes;
658
+ return all;
555
659
  }
556
660
 
557
661
  /**
@@ -585,7 +689,15 @@ interface TypeRules {
585
689
  idField: string | undefined;
586
690
  route: RouteSpec | null;
587
691
  links: LinkMode;
588
- alerts: unknown;
692
+ alerts: ContentTypeDef["alerts"];
693
+ }
694
+
695
+ /** The values `links` accepts. */
696
+ const LINK_MODES = ["warn", "error", "ignore"] as const;
697
+
698
+ /** Whether a `links` option names one of the {@link LINK_MODES}; it is project.json data. */
699
+ function isLinkMode(value: JsonValue): value is LinkMode {
700
+ return LINK_MODES.some((mode) => mode === value);
589
701
  }
590
702
 
591
703
  /** Every key a content type's definition means something by. */
@@ -651,7 +763,7 @@ function compileTypeRules(name: string, def: ContentTypeDef): TypeRules {
651
763
  const at = `Content type "${name}"`;
652
764
  try {
653
765
  for (const key of Object.keys(def)) {
654
- if ((KNOWN_TYPE_KEYS as readonly string[]).includes(key) || key === "$comment") {
766
+ if (KNOWN_TYPE_KEYS.some((known) => known === key) || key === "$comment") {
655
767
  continue;
656
768
  }
657
769
  const suggestion = nearestOption(key);
@@ -662,11 +774,12 @@ function compileTypeRules(name: string, def: ContentTypeDef): TypeRules {
662
774
  `${at}: ignores the unknown option "${key}" (the options are ${KNOWN_TYPE_KEYS.join(", ")})`,
663
775
  );
664
776
  }
665
- if (def.idField !== undefined && (typeof def.idField !== "string" || def.idField === "")) {
777
+ // `idField` and `links` are project.json data: checked here, whatever their declared types say.
778
+ if (def.idField !== undefined && (!isString(def.idField) || def.idField === "")) {
666
779
  throw new TypeError(`"idField" must be a field name`);
667
780
  }
668
781
  const links = def.links ?? "warn";
669
- if (links !== "warn" && links !== "error" && links !== "ignore") {
782
+ if (!isLinkMode(links)) {
670
783
  throw new TypeError(
671
784
  `"links" must be "warn", "error" or "ignore", got ${JSON.stringify(links)}`,
672
785
  );
@@ -714,8 +827,21 @@ interface FinishContext {
714
827
  entryExtensions: Set<string>;
715
828
  /** The source directory, when there is one. */
716
829
  sourceRoot: string | undefined;
830
+ /** Set when the files were loaded without their bodies: loads one file again, body and all. */
831
+ reload?: ((filePath: string) => Promise<ContentLoaderEntry[]>) | undefined;
832
+ /** Told of each entry whose body was deferred, with what completes it. */
833
+ onDeferred?: DeferredBodyHandler | undefined;
717
834
  }
718
835
 
836
+ /**
837
+ * Told of one entry loaded without its body, and handed what completes it: load the body, then give
838
+ * it the asset and link passes the rest of the collection's bodies got at load.
839
+ */
840
+ export type DeferredBodyHandler = (
841
+ entry: ContentLoaderEntry,
842
+ completeBody: () => Promise<void>,
843
+ ) => void;
844
+
719
845
  /**
720
846
  * The collection-wide pass every source kind goes through once its files are loaded.
721
847
  *
@@ -778,7 +904,7 @@ function finishEntries(files: LoadedFile[], ctx: FinishContext): ContentLoaderEn
778
904
  if (rules.idField) {
779
905
  for (const entry of entries) {
780
906
  const value = entry.data[rules.idField];
781
- if ((typeof value === "string" && value !== "") || typeof value === "number") {
907
+ if ((isString(value) && value !== "") || isNumber(value)) {
782
908
  /* An id becomes a directory name under the output (`$paths` hands it to a page), and a
783
909
  frontmatter value is not trusted to stay inside it. */
784
910
  const unsafe = traversalReason(String(value));
@@ -825,9 +951,8 @@ function finishEntries(files: LoadedFile[], ctx: FinishContext): ContentLoaderEn
825
951
  // 5. Routes, and links over the finished routes.
826
952
  if (rules.route) {
827
953
  assignRoutes(entries, unpublished, ctx, rules.route);
828
- if (ctx.sourceRoot) {
829
- resolveEntryLinks(kept, unpublished, ctx);
830
- }
954
+ const links = ctx.sourceRoot ? resolveEntryLinks(kept, unpublished, ctx) : undefined;
955
+ deferBodies(kept, ctx, links);
831
956
  /* An entry the template could not route, or whose route another entry holds, has no page, and
832
957
  a collection is what every consumer (a listing, the search index, a feed) publishes from. Left
833
958
  in, each of them would invent a URL for it from its own idea of one and point readers at a
@@ -835,9 +960,59 @@ function finishEntries(files: LoadedFile[], ctx: FinishContext): ContentLoaderEn
835
960
  link index above already knows why it has no page; the collection does not carry it. */
836
961
  return entries.filter((entry) => entry._meta?.route !== undefined);
837
962
  }
963
+ deferBodies(kept, ctx);
838
964
  return entries;
839
965
  }
840
966
 
967
+ /**
968
+ * Hand each entry that was loaded without its body to `onDeferred`, with what completes it.
969
+ *
970
+ * Completing one does for that entry exactly what {@link finishEntries} did for every body it was
971
+ * given: the full load supplies `$children` and the `_meta` fields read from the body, then the
972
+ * body's assets move onto the mount and its links resolve against the collection's own index. The
973
+ * frontmatter half is untouched; it was finished with the rest of the collection. Only a file that
974
+ * yielded one entry is deferred, since a reload answers per file.
975
+ */
976
+ function deferBodies(kept: LoadedFile[], ctx: FinishContext, links?: LinkIndex): void {
977
+ const { reload, onDeferred } = ctx;
978
+ if (!reload || !onDeferred) {
979
+ return;
980
+ }
981
+ for (const file of kept) {
982
+ const [entry] = file.entries;
983
+ if (file.entries.length !== 1 || !entry || entry.$children !== undefined) {
984
+ continue;
985
+ }
986
+ onDeferred(entry, async () => {
987
+ const [full] = await reload(file.filePath);
988
+ if (!full) {
989
+ return;
990
+ }
991
+ /* Rebuilt in place, in the full load's key order: the entry is already referenced (by the
992
+ collection, and by any entry relating to it), and an entry whose body arrived late should
993
+ serialize exactly as one loaded whole. Everything the collection pass decided about it
994
+ (its id, data, route) is kept; the full load contributes only what came from the body. */
995
+ const completed: ContentLoaderEntry = {
996
+ ...full,
997
+ ...entry,
998
+ _meta: { ...full._meta, ...entry._meta },
999
+ };
1000
+ for (const key of Object.keys(entry)) {
1001
+ Reflect.deleteProperty(entry, key);
1002
+ }
1003
+ Object.assign(entry, completed);
1004
+ if (ctx.mount) {
1005
+ rewriteBodyAssets(entry, file.filePath, ctx.mount, ctx.name);
1006
+ }
1007
+ if (links && file.rel !== null) {
1008
+ const broken: BrokenLink[] = [];
1009
+ collectBrokenLinks(entry, file.rel, links, broken, new Set());
1010
+ reportBrokenLinks(broken, ctx.name, ctx.rules.links);
1011
+ }
1012
+ });
1013
+ }
1014
+ }
1015
+
841
1016
  /**
842
1017
  * Keep a folder's id for its `index` file when its `README` would also claim it.
843
1018
  *
@@ -874,7 +1049,7 @@ function settleIndexIds(name: string, entries: ContentLoaderEntry[]): void {
874
1049
  /** The site's `build.trailingSlash`, defaulting to `"always"` exactly as the build does. */
875
1050
  function trailingSlashOf(projectConfig: ProjectConfig | undefined): string {
876
1051
  const value = projectConfig?.build?.trailingSlash;
877
- return typeof value === "string" ? value : "always";
1052
+ return isString(value) ? value : "always";
878
1053
  }
879
1054
 
880
1055
  /** The URL prefix a collection's locale puts in front of its routes (`/fr`), or `""`. */
@@ -963,13 +1138,38 @@ interface BrokenLink {
963
1138
  * which of them are published, which directory each README or index stands for), then reports each
964
1139
  * link that has no page at the severity `links` asks for. In `error` mode all of them are gathered
965
1140
  * and thrown together, so one build names every broken link instead of one per run.
1141
+ *
1142
+ * An entry whose body has not been loaded yet (a lazily loaded collection) has no links to rewrite
1143
+ * here; the index is returned so its body is resolved against the same one when it loads.
1144
+ *
1145
+ * @returns {LinkIndex} The collection's link index
966
1146
  */
967
1147
  function resolveEntryLinks(
968
1148
  kept: LoadedFile[],
969
1149
  unpublished: Map<string, string>,
970
1150
  ctx: FinishContext,
971
- ): void {
972
- const { name, rules, sourceRoot } = ctx;
1151
+ ): LinkIndex {
1152
+ const index = buildLinkIndex(kept, unpublished, ctx);
1153
+ const broken: BrokenLink[] = [];
1154
+ const seen = new Set<string>();
1155
+ for (const file of kept) {
1156
+ for (const entry of file.entries) {
1157
+ if (entry.$children && file.rel !== null) {
1158
+ collectBrokenLinks(entry, file.rel, index, broken, seen);
1159
+ }
1160
+ }
1161
+ }
1162
+ reportBrokenLinks(broken, ctx.name, ctx.rules.links);
1163
+ return index;
1164
+ }
1165
+
1166
+ /** The {@link LinkIndex} for one collection: every file it loaded and what became of each. */
1167
+ function buildLinkIndex(
1168
+ kept: LoadedFile[],
1169
+ unpublished: Map<string, string>,
1170
+ ctx: FinishContext,
1171
+ ): LinkIndex {
1172
+ const { rules, sourceRoot } = ctx;
973
1173
  const files = new Map<string, FileOutcome>();
974
1174
  const indexes = new Map<string, string>();
975
1175
  const isReadme = (rel: string) => /^readme\./i.test(rel.slice(rel.lastIndexOf("/") + 1));
@@ -1007,7 +1207,7 @@ function resolveEntryLinks(
1007
1207
  }
1008
1208
 
1009
1209
  const onDisk = (path: string) => resolve(sourceRoot!, path);
1010
- const index: LinkIndex = {
1210
+ return {
1011
1211
  asset: (path) => {
1012
1212
  const absolute = onDisk(path);
1013
1213
  if (!ctx.mount || !existsSync(absolute) || statSync(absolute).isDirectory()) {
@@ -1036,30 +1236,34 @@ function resolveEntryLinks(
1036
1236
  localePrefix: localePrefixOf(ctx),
1037
1237
  trailingSlash: trailingSlashOf(ctx.projectConfig),
1038
1238
  };
1239
+ }
1039
1240
 
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
- });
1241
+ /** Rewrite one entry's links against the index, adding each link with no page to `broken`. */
1242
+ function collectBrokenLinks(
1243
+ entry: ContentLoaderEntry,
1244
+ rel: string,
1245
+ index: LinkIndex,
1246
+ broken: BrokenLink[],
1247
+ seen: Set<string>,
1248
+ ): void {
1249
+ rewriteLinks(entry.$children ?? [], rel, index, (problem) => {
1250
+ const key = `${rel}\0${problem.href}`;
1251
+ if (!seen.has(key)) {
1252
+ seen.add(key);
1253
+ broken.push({ from: rel, href: problem.href, reason: problem.reason });
1054
1254
  }
1055
- }
1056
- if (rules.links === "ignore" || broken.length === 0) {
1255
+ });
1256
+ }
1257
+
1258
+ /** Report broken links at the severity the collection's `links` option asks for. */
1259
+ function reportBrokenLinks(broken: BrokenLink[], name: string, mode: LinkMode): void {
1260
+ if (mode === "ignore" || broken.length === 0) {
1057
1261
  return;
1058
1262
  }
1059
1263
  const lines = broken.map(
1060
1264
  ({ from, href, reason }) => `"${from}" links to "${href}", which ${reason}`,
1061
1265
  );
1062
- if (rules.links === "error") {
1266
+ if (mode === "error") {
1063
1267
  const plural = broken.length === 1 ? "" : "s";
1064
1268
  const list = lines.map((line) => ` - ${line}`).join("\n");
1065
1269
  throw new Error(`Content links: "${name}" has ${broken.length} broken link${plural}:\n${list}`);
@@ -1091,6 +1295,13 @@ interface LoadOptions {
1091
1295
  projectConfig?: ProjectConfig | undefined;
1092
1296
  /** The locale this load is for, when the source carried `{locale}`. */
1093
1297
  locale?: string | undefined;
1298
+ /**
1299
+ * Ask the format to leave entry bodies unparsed (`deferBody`), and report each deferred entry to
1300
+ * `onDeferred`. A format that does not honor the option returns whole entries, and nothing is
1301
+ * deferred.
1302
+ */
1303
+ deferBody?: boolean | undefined;
1304
+ onDeferred?: DeferredBodyHandler | undefined;
1094
1305
  }
1095
1306
 
1096
1307
  /**
@@ -1114,8 +1325,8 @@ async function loadContentType(
1114
1325
  if (!source) {
1115
1326
  return [];
1116
1327
  }
1117
- const { mount, projectConfig, locale } = options;
1118
- const schema = contentTypeDef.schema as ContentTypeSchema | undefined;
1328
+ const { mount, projectConfig, locale, deferBody, onDeferred } = options;
1329
+ const { schema } = contentTypeDef;
1119
1330
  const rules = compileTypeRules(name, contentTypeDef);
1120
1331
  const finishContext = (extra: Partial<FinishContext>): FinishContext => ({
1121
1332
  coerceDates: false,
@@ -1133,11 +1344,10 @@ async function loadContentType(
1133
1344
  // Derive directive allowedNames from content type $elements (tag names from npm packages)
1134
1345
  const directiveOptions = contentTypeDef.$elements?.length
1135
1346
  ? {
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)),
1347
+ // A tag name as written, or the `$ref` of an element reference; anything else names nothing.
1348
+ allowedNames: contentTypeDef.$elements.flatMap((e) =>
1349
+ isString(e) ? [e] : isNonNullObject(e) && e.$ref ? [e.$ref] : [],
1350
+ ),
1141
1351
  }
1142
1352
  : undefined;
1143
1353
 
@@ -1170,11 +1380,11 @@ async function loadContentType(
1170
1380
  }
1171
1381
  let remote: ContentLoaderEntry[];
1172
1382
  try {
1173
- remote = (await entry.call("load", source, {
1383
+ remote = await entry.call("load", source, {
1174
1384
  directiveOptions,
1175
1385
  schema,
1176
1386
  ...(rules.idField !== undefined && { idField: rules.idField }),
1177
- })) as ContentLoaderEntry[];
1387
+ });
1178
1388
  } catch (error) {
1179
1389
  console.warn(`Content type "${name}": ${errorMessage(error)}`);
1180
1390
  return [];
@@ -1222,33 +1432,41 @@ async function loadContentType(
1222
1432
  }
1223
1433
 
1224
1434
  // Discover entry files via the class, then load each
1435
+ const discoverOptions: FormatDiscoverOptions = { baseDir: projectRoot };
1436
+ if (!rules.exclude.empty) {
1437
+ discoverOptions.exclude = contentTypeDef.exclude;
1438
+ }
1225
1439
  const discovered = entry.capabilities.discover
1226
- ? ((await entry.call("discover", source, {
1227
- baseDir: projectRoot,
1228
- ...(rules.exclude.empty ? {} : { exclude: contentTypeDef.exclude }),
1229
- })) as string[])
1440
+ ? await entry.call("discover", source, discoverOptions)
1230
1441
  : [resolvedSource];
1231
1442
  const files = discovered.filter((file) => !isExcluded(file));
1232
1443
 
1233
1444
  const fileExtensions = files.map((file) => extname(file).toLowerCase());
1445
+ const format = entry;
1446
+ /** One file's load, with or without its body. */
1447
+ const loadFile = (filePath: string, withoutBody: boolean) => {
1448
+ const rel = relOf(filePath);
1449
+ return format.call("load", filePath, {
1450
+ directiveOptions,
1451
+ schema,
1452
+ ...(sourceRoot !== undefined && { sourceRoot }),
1453
+ ...(rules.alerts !== undefined && { alerts: rules.alerts }),
1454
+ ...(rules.idField !== undefined && { idField: rules.idField }),
1455
+ ...(withoutBody && { deferBody: true }),
1456
+ onUnknownAlert: (type: string) =>
1457
+ console.warn(
1458
+ `Content callouts: "${name}": "${rel ?? filePath}" has a [!${type}] callout, but ` +
1459
+ `"${type.toLowerCase()}" is not an enabled type, so it stays a blockquote with the ` +
1460
+ `marker showing. Add "alerts": { "${type.toUpperCase()}": true } to render it.`,
1461
+ ),
1462
+ });
1463
+ };
1234
1464
  const loaded: LoadedFile[] = [];
1235
1465
  for (const filePath of files) {
1236
1466
  const rel = relOf(filePath);
1237
1467
  let fileEntries: ContentLoaderEntry[];
1238
1468
  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[];
1469
+ fileEntries = await loadFile(filePath, deferBody === true);
1252
1470
  } catch (error) {
1253
1471
  throw unreadableFile(name, rel ?? filePath, error);
1254
1472
  }
@@ -1272,6 +1490,10 @@ async function loadContentType(
1272
1490
  coerceDates: true,
1273
1491
  entryExtensions: new Set([...entry.extensions, ...fileExtensions]),
1274
1492
  sourceRoot,
1493
+ ...(deferBody === true && {
1494
+ onDeferred,
1495
+ reload: async (filePath: string) => await loadFile(filePath, false),
1496
+ }),
1275
1497
  }),
1276
1498
  );
1277
1499
  }
@@ -1280,11 +1502,11 @@ async function loadContentType(
1280
1502
  * The error for one file that would not load, with its path: a parse failure names a line and a
1281
1503
  * reason but never the file, and a vault is hundreds of them.
1282
1504
  */
1283
- function unreadableFile(type: string, path: string, error: unknown): Error {
1505
+ function unreadableFile(type: string, path: string, cause: unknown): Error {
1284
1506
  return new Error(
1285
- `Content type "${type}": cannot read "${path}": ${errorMessage(error)}. Fix the file, or add it ` +
1507
+ `Content type "${type}": cannot read "${path}": ${errorMessage(cause)}. Fix the file, or add it ` +
1286
1508
  `to "exclude" if it is not a document (a templates folder, for example).`,
1287
- { cause: error },
1509
+ { cause },
1288
1510
  );
1289
1511
  }
1290
1512
 
@@ -1297,8 +1519,23 @@ function unknownFormatError(path: string, ext: string): Error {
1297
1519
  );
1298
1520
  }
1299
1521
 
1300
- function errorMessage(error: unknown): string {
1301
- return error instanceof Error ? error.message : String(error);
1522
+ function errorMessage(cause: unknown): string {
1523
+ return cause instanceof Error ? cause.message : String(cause);
1524
+ }
1525
+
1526
+ /**
1527
+ * The JavaScript type name of a field value, as `typeof` spells it, for a validation message. A
1528
+ * date, a list, a nested object, a resolved entry and `null` are all `"object"`, as they are
1529
+ * there.
1530
+ */
1531
+ function jsTypeName(value: ContentFieldValue): string {
1532
+ if (isString(value)) {
1533
+ return "string";
1534
+ }
1535
+ if (isNumber(value)) {
1536
+ return "number";
1537
+ }
1538
+ return isBoolean(value) ? "boolean" : "object";
1302
1539
  }
1303
1540
 
1304
1541
  // ─── Schema Validation ────────────────────────────────────────────────────────
@@ -1336,26 +1573,26 @@ function validateEntries(
1336
1573
  continue;
1337
1574
  }
1338
1575
 
1339
- if (isDateFormat(def.format) && typeof value === "string" && !isCoercedDate(value)) {
1576
+ if (isDateFormat(def.format) && isString(value) && !isCoercedDate(value)) {
1340
1577
  console.warn(
1341
1578
  `Content validation: "${contentTypeName}/${entry.id}" field "${field}" is declared ` +
1342
1579
  `${def.format} but "${value}" was not coerced — it will sort and filter as plain text.`,
1343
1580
  );
1344
- } else if (def.type === "string" && typeof value !== "string") {
1581
+ } else if (def.type === "string" && !isString(value)) {
1345
1582
  console.warn(
1346
- `Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected string, got ${typeof value}`,
1583
+ `Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected string, got ${jsTypeName(value)}`,
1347
1584
  );
1348
- } else if (def.type === "number" && typeof value !== "number") {
1585
+ } else if (def.type === "number" && !isNumber(value)) {
1349
1586
  console.warn(
1350
- `Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected number, got ${typeof value}`,
1587
+ `Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected number, got ${jsTypeName(value)}`,
1351
1588
  );
1352
- } else if (def.type === "boolean" && typeof value !== "boolean") {
1589
+ } else if (def.type === "boolean" && !isBoolean(value)) {
1353
1590
  console.warn(
1354
- `Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected boolean, got ${typeof value}`,
1591
+ `Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected boolean, got ${jsTypeName(value)}`,
1355
1592
  );
1356
1593
  } else if (def.type === "array" && !Array.isArray(value)) {
1357
1594
  console.warn(
1358
- `Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected array, got ${typeof value}`,
1595
+ `Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected array, got ${jsTypeName(value)}`,
1359
1596
  );
1360
1597
  }
1361
1598
  }
@@ -1375,6 +1612,25 @@ function refTargetName(ref: string | undefined): string | undefined {
1375
1612
  return ref.slice(CONTENT_REF_PREFIX.length);
1376
1613
  }
1377
1614
 
1615
+ /**
1616
+ * The content types a content type's schema points into: each field whose `$ref` (to-one) or
1617
+ * `items.$ref` (to-many) is a `#/content/<name>` pointer. What {@link resolveContentTypeRefs} will
1618
+ * look up for this type, so a type can be loaded together with exactly what it relates to.
1619
+ *
1620
+ * @param {ContentTypeDef | undefined} def - The content type's definition
1621
+ * @returns {string[]} The referenced type names, each once
1622
+ */
1623
+ export function referencedContentTypes(def: ContentTypeDef | undefined): string[] {
1624
+ const targets = new Set<string>();
1625
+ for (const field of Object.values(def?.schema?.properties ?? {})) {
1626
+ const target = refTargetName(field.$ref) ?? refTargetName(field.items?.$ref);
1627
+ if (target !== undefined) {
1628
+ targets.add(target);
1629
+ }
1630
+ }
1631
+ return [...targets];
1632
+ }
1633
+
1378
1634
  /**
1379
1635
  * Resolve cross-content-type relationship references in entry data (specs/relationships.md). A bare
1380
1636
  * field `$ref` is to-one: a blog post's `author: "jane-doe"` with a schema `$ref` of
@@ -1390,7 +1646,7 @@ export function resolveContentTypeRefs(
1390
1646
  section: ContentSection,
1391
1647
  ) {
1392
1648
  for (const [name, contentTypeDef] of Object.entries(section)) {
1393
- const schema = contentTypeDef.schema as ContentTypeSchema | undefined;
1649
+ const { schema } = contentTypeDef;
1394
1650
  if (!schema?.properties) {
1395
1651
  continue;
1396
1652
  }
@@ -1417,7 +1673,7 @@ export function resolveContentTypeRefs(
1417
1673
 
1418
1674
  for (const entry of entries) {
1419
1675
  const value = entry.data[field];
1420
- if (toOne && typeof value === "string") {
1676
+ if (toOne && isString(value)) {
1421
1677
  const resolved = refEntries.find((e) => e.id === value);
1422
1678
  if (resolved) {
1423
1679
  entry.data[field] = resolved;
@@ -1428,7 +1684,7 @@ export function resolveContentTypeRefs(
1428
1684
  }
1429
1685
  } else if (toMany && Array.isArray(value)) {
1430
1686
  entry.data[field] = value.map((id) => {
1431
- if (typeof id !== "string") {
1687
+ if (!isString(id)) {
1432
1688
  return id;
1433
1689
  }
1434
1690
  const resolved = refEntries.find((e) => e.id === id);
@@ -1477,13 +1733,13 @@ function reportUnservedRoutes(
1477
1733
  return;
1478
1734
  }
1479
1735
  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
- );
1736
+ const unserved = entries.flatMap((entry) => {
1737
+ const route = entry._meta?.route;
1738
+ return route !== undefined &&
1739
+ !patterns.some((pattern) => routeParams(route, pattern, []) !== null)
1740
+ ? [route]
1741
+ : [];
1742
+ });
1487
1743
  if (unserved.length === 0) {
1488
1744
  return;
1489
1745
  }
@@ -1514,11 +1770,11 @@ function routedPaths(
1514
1770
  entries: ContentLoaderEntry[],
1515
1771
  ctx: ResolvePathsContext,
1516
1772
  urlPattern: string,
1517
- ): Record<string, unknown>[] {
1773
+ ): ContentPathParams[] {
1518
1774
  const { i18n } = resolveI18n(ctx.projectConfig ?? {});
1519
1775
  const prefix = localeUrlPrefix(ctx.locale, i18n);
1520
1776
  const pattern = withoutLocalePrefix(urlPattern, prefix);
1521
- const paths: Record<string, unknown>[] = [];
1777
+ const paths: ContentPathParams[] = [];
1522
1778
  let example: string | undefined;
1523
1779
  for (const entry of entries) {
1524
1780
  const route = entry._meta?.route;
@@ -1528,7 +1784,11 @@ function routedPaths(
1528
1784
  example ??= route;
1529
1785
  const params = routeParams(route, pattern, ctx.params ?? []);
1530
1786
  if (params) {
1531
- paths.push({ ...params, ...(entry._meta === undefined ? {} : { _meta: entry._meta }) });
1787
+ const pathEntry: ContentPathParams = { ...params };
1788
+ if (entry._meta !== undefined) {
1789
+ pathEntry._meta = entry._meta;
1790
+ }
1791
+ paths.push(pathEntry);
1532
1792
  }
1533
1793
  }
1534
1794
  if (paths.length === 0 && example !== undefined) {
@@ -1541,6 +1801,225 @@ function routedPaths(
1541
1801
  return paths;
1542
1802
  }
1543
1803
 
1804
+ // ─── The section's loaded data ───────────────────────────────────────────────
1805
+
1806
+ /*
1807
+ * What `Content.projectData` answers, and what classes read as `_project.content`: a map of content
1808
+ * type name → entries. A build reads every entry of every type, so it gets them all at once, as it
1809
+ * always has. A host that serves one page at a time (the dev server, Studio) asks for the section
1810
+ * lazily (`ProjectDataContext.lazy`, specs/extensions.md §8), and then a type loads the first
1811
+ * time a class asks for it, with only its frontmatter, and an entry's body is parsed only once that
1812
+ * entry is actually returned.
1813
+ *
1814
+ * Both are the same class, so a consumer reads either through `load` and `materialize`, which are
1815
+ * no-ops on a store that is already whole; a consumer that reads the map directly (`get`) is a
1816
+ * build-time one and sees exactly what it always did.
1817
+ */
1818
+
1819
+ /** What a store loads its section with. */
1820
+ export interface ContentStoreSource {
1821
+ section: ContentSection;
1822
+ root: string;
1823
+ formats: FormatRegistry;
1824
+ projectConfig: ProjectConfig | undefined;
1825
+ }
1826
+
1827
+ export class ContentStore extends Map<string, ContentLoaderEntry[]> implements ContentSectionData {
1828
+ readonly #source: ContentStoreSource;
1829
+ readonly #mounts: Map<string, AssetMount>;
1830
+ /** Each type's load in flight or done, so concurrent asks share one. */
1831
+ readonly #loads = new Map<string, Promise<void>>();
1832
+ /** Types whose relationship fields have been resolved (it mutates entries, so exactly once). */
1833
+ readonly #linked = new Set<string>();
1834
+ /** Every entry this store loaded, to tell a related entry inside `data` from ordinary data. */
1835
+ readonly #entries = new WeakSet<object>();
1836
+ /** Entries loaded without their body, and what completes each. */
1837
+ readonly #deferred = new WeakMap<ContentLoaderEntry, () => Promise<void>>();
1838
+ /** Bodies being completed, so two requests for one entry share the work. */
1839
+ readonly #completing = new WeakMap<ContentLoaderEntry, Promise<void>>();
1840
+
1841
+ private constructor(source: ContentStoreSource) {
1842
+ super();
1843
+ this.#source = source;
1844
+ this.#mounts = sectionMounts(source.section, source.root, source.projectConfig);
1845
+ }
1846
+
1847
+ /** A store holding every type, bodies and relationships included: what a build reads. */
1848
+ static async whole(source: ContentStoreSource): Promise<ContentStore> {
1849
+ const store = new ContentStore(source);
1850
+ const loaded = await loadContentSection(
1851
+ source.section,
1852
+ source.root,
1853
+ source.formats,
1854
+ source.projectConfig,
1855
+ );
1856
+ for (const [name, entries] of loaded) {
1857
+ store.#adopt(name, entries);
1858
+ store.#loads.set(name, Promise.resolve());
1859
+ }
1860
+ resolveContentTypeRefs(store, source.section);
1861
+ for (const name of loaded.keys()) {
1862
+ store.#linked.add(name);
1863
+ }
1864
+ return store;
1865
+ }
1866
+
1867
+ /** An empty store that loads each type when it is first asked for. */
1868
+ static lazy(source: ContentStoreSource): ContentStore {
1869
+ return new ContentStore(source);
1870
+ }
1871
+
1872
+ /**
1873
+ * One type's entries, loading it (and every type its schema relates it to) on first use.
1874
+ *
1875
+ * @param {string} name - Content type name
1876
+ * @returns {Promise<ContentLoaderEntry[] | undefined>} The entries, or undefined when the section
1877
+ * declares no such type
1878
+ */
1879
+ async load(name: string): Promise<ContentLoaderEntry[] | undefined> {
1880
+ if (!Object.hasOwn(this.#source.section, name)) {
1881
+ return undefined;
1882
+ }
1883
+ const related = this.#relatedTypes(name);
1884
+ await Promise.all(related.map(async (type) => await this.#loadType(type)));
1885
+ /* Resolved only once everything a type points into is loaded, and synchronously, so two loads
1886
+ finishing together cannot both resolve one type: its fields would be looked up twice, the
1887
+ second time with entries where the ids were. */
1888
+ const unlinked = related.filter((type) => !this.#linked.has(type));
1889
+ for (const type of unlinked) {
1890
+ this.#linked.add(type);
1891
+ }
1892
+ if (unlinked.length > 0) {
1893
+ const { section } = this.#source;
1894
+ resolveContentTypeRefs(
1895
+ this,
1896
+ Object.fromEntries(
1897
+ unlinked.flatMap((type) => (section[type] ? [[type, section[type]]] : [])),
1898
+ ),
1899
+ );
1900
+ }
1901
+ return this.get(name);
1902
+ }
1903
+
1904
+ /**
1905
+ * Give every entry in `entries` its body, and every entry they relate to through a relationship
1906
+ * field, before they are handed out. An entry that already has one is left as it is.
1907
+ *
1908
+ * @param {readonly (ContentLoaderEntry | null | undefined)[]} entries - Entries about to be
1909
+ * returned
1910
+ */
1911
+ async materialize(entries: readonly (ContentLoaderEntry | null | undefined)[]): Promise<void> {
1912
+ const seen = new Set<ContentLoaderEntry>();
1913
+ const pending: Promise<void>[] = [];
1914
+ const visit = (entry: ContentLoaderEntry) => {
1915
+ if (seen.has(entry)) {
1916
+ return;
1917
+ }
1918
+ seen.add(entry);
1919
+ pending.push(this.#complete(entry));
1920
+ for (const value of Object.values(entry.data)) {
1921
+ for (const item of Array.isArray(value) ? value : [value]) {
1922
+ if (this.#isEntry(item)) {
1923
+ visit(item);
1924
+ }
1925
+ }
1926
+ }
1927
+ };
1928
+ for (const entry of entries) {
1929
+ if (entry) {
1930
+ visit(entry);
1931
+ }
1932
+ }
1933
+ await Promise.all(pending);
1934
+ }
1935
+
1936
+ /**
1937
+ * Load every type and give every entry its body: the store becomes exactly what
1938
+ * {@link ContentStore.whole} builds. A host does this before handing `_project` to a class that
1939
+ * reads it synchronously (one that does not declare `projectOnDemand`, specs/extensions.md §8).
1940
+ */
1941
+ async complete(): Promise<void> {
1942
+ const lists = await Promise.all(
1943
+ Object.keys(this.#source.section).map(async (name) => (await this.load(name)) ?? []),
1944
+ );
1945
+ await this.materialize(lists.flat());
1946
+ }
1947
+
1948
+ /** The type and every type reachable through its schema's relationship fields. */
1949
+ #relatedTypes(name: string): string[] {
1950
+ const { section } = this.#source;
1951
+ const found = new Set<string>([name]);
1952
+ const queue = [name];
1953
+ for (let type = queue.shift(); type !== undefined; type = queue.shift()) {
1954
+ for (const target of referencedContentTypes(section[type])) {
1955
+ if (Object.hasOwn(section, target) && !found.has(target)) {
1956
+ found.add(target);
1957
+ queue.push(target);
1958
+ }
1959
+ }
1960
+ }
1961
+ return [...found];
1962
+ }
1963
+
1964
+ #loadType(name: string): Promise<void> {
1965
+ let load = this.#loads.get(name);
1966
+ if (!load) {
1967
+ const { section, root, formats, projectConfig } = this.#source;
1968
+ load = loadSectionType(name, {
1969
+ formats,
1970
+ mounts: this.#mounts,
1971
+ onDeferred: (entry, completeBody) => {
1972
+ this.#deferred.set(entry, completeBody);
1973
+ },
1974
+ projectConfig,
1975
+ root,
1976
+ section,
1977
+ }).then((entries) => {
1978
+ this.#adopt(name, entries);
1979
+ });
1980
+ /* A failed load is not kept: the error reaches this ask, and the next one tries again (the
1981
+ file may have been fixed in between). */
1982
+ load.catch(() => {
1983
+ this.#loads.delete(name);
1984
+ });
1985
+ this.#loads.set(name, load);
1986
+ }
1987
+ return load;
1988
+ }
1989
+
1990
+ #adopt(name: string, entries: ContentLoaderEntry[]): void {
1991
+ for (const entry of entries) {
1992
+ this.#entries.add(entry);
1993
+ }
1994
+ this.set(name, entries);
1995
+ }
1996
+
1997
+ /* A related entry is placed into `data` by `resolveContentTypeRefs`, so membership in the set of
1998
+ entries this store loaded is exactly the test, and it never mistakes an object field for one. */
1999
+ #isEntry(value: ContentLoaderEntry["data"][string]): value is ContentLoaderEntry {
2000
+ return isNonNullObject(value) && this.#entries.has(value);
2001
+ }
2002
+
2003
+ #complete(entry: ContentLoaderEntry): Promise<void> {
2004
+ let completing = this.#completing.get(entry);
2005
+ if (!completing) {
2006
+ const completeBody = this.#deferred.get(entry);
2007
+ if (!completeBody) {
2008
+ return Promise.resolve();
2009
+ }
2010
+ completing = completeBody().then(() => {
2011
+ this.#deferred.delete(entry);
2012
+ });
2013
+ // A failed body is retried by the next ask, as a failed type load is.
2014
+ completing.catch(() => {
2015
+ this.#completing.delete(entry);
2016
+ });
2017
+ this.#completing.set(entry, completing);
2018
+ }
2019
+ return completing;
2020
+ }
2021
+ }
2022
+
1544
2023
  // ─── The Content project-section class ───────────────────────────────────────
1545
2024
 
1546
2025
  /**
@@ -1552,25 +2031,25 @@ function routedPaths(
1552
2031
  export class Content {
1553
2032
  /**
1554
2033
  * Load the `content` section: every content type's entries, with relationship references already
1555
- * resolved.
2034
+ * resolved. A host that asks for it `lazy` gets a {@link ContentStore} that loads each type the
2035
+ * first time a class asks for it and each entry's body only once that entry is returned
2036
+ * (specs/extensions.md §8); either way the answer is a map of type name to entries.
1556
2037
  *
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
2038
+ * @param {ContentSection | undefined} sectionValue - The project.json `content` section value
2039
+ * @param {ProjectDataContext} ctx - Host context ({ projectConfig, root, registry, io, lazy })
2040
+ * @returns {Promise<ContentStore>} Content type name → entries
1560
2041
  */
1561
2042
  static async projectData(
1562
- sectionValue: unknown,
2043
+ sectionValue: ContentSection | undefined,
1563
2044
  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;
2045
+ ): Promise<ContentStore> {
2046
+ const source = {
2047
+ formats: ctx.registry.formats,
2048
+ projectConfig: ctx.projectConfig,
2049
+ root: ctx.root,
2050
+ section: sectionValue ?? {},
2051
+ };
2052
+ return ctx.lazy === true ? ContentStore.lazy(source) : await ContentStore.whole(source);
1574
2053
  }
1575
2054
 
1576
2055
  /**
@@ -1578,15 +2057,15 @@ export class Content {
1578
2057
  * Hosts serve, resolve, and copy files through the returned mounts, which is what lets an entry
1579
2058
  * reference `../images/hero.png` and still resolve in a built site, the dev server, and Studio.
1580
2059
  *
1581
- * @param {unknown} sectionValue - The project.json `content` section value
2060
+ * @param {ContentSection | undefined} sectionValue - The project.json `content` section value
1582
2061
  * @param {{ projectConfig?: ProjectConfig; root: string }} ctx - Host context
1583
2062
  * @returns {AssetMount[]} One mount per content type with a local directory source
1584
2063
  */
1585
2064
  static assets(
1586
- sectionValue: unknown,
2065
+ sectionValue: ContentSection | undefined,
1587
2066
  ctx: { root: string; projectConfig?: ProjectConfig },
1588
2067
  ): AssetMount[] {
1589
- return contentAssetMounts((sectionValue ?? {}) as ContentSection, ctx.root, ctx.projectConfig);
2068
+ return contentAssetMounts(sectionValue ?? {}, ctx.root, ctx.projectConfig);
1590
2069
  }
1591
2070
 
1592
2071
  /**
@@ -1611,13 +2090,13 @@ export class Content {
1611
2090
  *
1612
2091
  * @param {ContentPathsSource} pathsDef - The `$paths` value ({ contentType, param?, field? })
1613
2092
  * @param {ResolvePathsContext} ctx - Host context ({ data, projectConfig, root, urlPattern })
1614
- * @returns {Promise<Record<string, unknown>[]>} Array of route-param objects
2093
+ * @returns {Promise<ContentPathParams[]>} Array of route-param objects
1615
2094
  */
1616
2095
  static async resolvePaths(
1617
2096
  pathsDef: ContentPathsSource,
1618
2097
  ctx: ResolvePathsContext,
1619
- ): Promise<Record<string, unknown>[]> {
1620
- const entries = ctx.data.get(pathsDef.contentType);
2098
+ ): Promise<ContentPathParams[]> {
2099
+ const entries = await entriesOf(ctx.data, pathsDef.contentType);
1621
2100
  if (!entries || entries.length === 0) {
1622
2101
  console.warn(
1623
2102
  `Warning: $paths references content type "${pathsDef.contentType}" but it has no entries`,
@@ -1635,7 +2114,7 @@ export class Content {
1635
2114
  const localized = entries.some((entry) => entry._meta?.locale !== undefined);
1636
2115
  const wanted = ctx.locale;
1637
2116
  const scoped =
1638
- localized && typeof wanted === "string"
2117
+ localized && isString(wanted)
1639
2118
  ? entries.filter((entry) => entry._meta?.locale === wanted)
1640
2119
  : entries;
1641
2120
  if (
@@ -1646,7 +2125,7 @@ export class Content {
1646
2125
  ) {
1647
2126
  return routedPaths(pathsDef.contentType, scoped, ctx, ctx.urlPattern);
1648
2127
  }
1649
- const paths: Record<string, unknown>[] = [];
2128
+ const paths: ContentPathParams[] = [];
1650
2129
  for (const entry of scoped) {
1651
2130
  const value = field === "id" ? entry.id : (entry.data[field] ?? entry.id);
1652
2131
  if (!value) {
@@ -1663,7 +2142,7 @@ export class Content {
1663
2142
  );
1664
2143
  continue;
1665
2144
  }
1666
- const pathEntry: Record<string, unknown> = { [param]: value };
2145
+ const pathEntry: ContentPathParams = { [param]: value };
1667
2146
  if (entry._meta !== undefined) {
1668
2147
  pathEntry._meta = entry._meta;
1669
2148
  }