@jxsuite/parser 2.0.0 → 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.
- package/dist/content-loader.js +1482 -671
- package/dist/content.js +183 -30
- package/dist/markdown.js +1271 -742
- package/dist/serialize.js +1974 -1457
- package/package.json +5 -5
- package/src/alerts.ts +22 -7
- package/src/content-data.ts +40 -0
- package/src/content-links.ts +15 -7
- package/src/content-loader.ts +681 -221
- package/src/content-routes.ts +23 -10
- package/src/content-rules.ts +40 -29
- package/src/content.ts +108 -70
- package/src/csv.ts +18 -14
- package/src/dates.ts +18 -13
- package/src/highlight.ts +6 -5
- package/src/inert.ts +7 -5
- package/src/markdown.ts +27 -23
- package/src/md.ts +133 -64
- package/src/serialize.ts +367 -266
- package/src/transpile.ts +182 -152
- package/src/types.ts +43 -11
package/src/content-loader.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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 (
|
|
318
|
+
if (SAFE_CONTENT_TYPE_NAME.test(name) && existsSync(dir) && statSync(dir).isDirectory()) {
|
|
305
319
|
mounts.push({
|
|
306
320
|
dir,
|
|
307
|
-
urlPrefix: `/${
|
|
321
|
+
urlPrefix: `/${CONTENT_SECTION_KEY}/${name}/${locale}`,
|
|
308
322
|
...filterOf(contentTypeDef),
|
|
309
323
|
});
|
|
310
324
|
}
|
|
311
325
|
}
|
|
312
326
|
continue;
|
|
313
327
|
}
|
|
314
|
-
if (!
|
|
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 /${
|
|
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: `/${
|
|
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
|
|
403
|
+
if (!isNonNullObject(node)) {
|
|
390
404
|
return;
|
|
391
405
|
}
|
|
392
|
-
const
|
|
393
|
-
const attributes = record.attributes as Record<string, unknown> | undefined;
|
|
406
|
+
const { attributes } = node;
|
|
394
407
|
for (const key of ASSET_KEYS) {
|
|
395
|
-
|
|
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 (
|
|
411
|
+
if (holder && isString(value)) {
|
|
398
412
|
const rewritten = rewriteRef(value, entryDir, mount, onProblem);
|
|
399
413
|
if (rewritten) {
|
|
400
|
-
holder
|
|
414
|
+
holder[key] = rewritten;
|
|
401
415
|
}
|
|
402
416
|
}
|
|
403
417
|
}
|
|
404
418
|
}
|
|
405
|
-
const { children } =
|
|
419
|
+
const { children } = node;
|
|
406
420
|
if (Array.isArray(children)) {
|
|
407
421
|
for (const child of children) {
|
|
408
|
-
rewriteNodeAssets(child
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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 = (
|
|
442
|
-
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
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(`/${
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
637
|
+
all.push(...entries);
|
|
553
638
|
}
|
|
554
|
-
return
|
|
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:
|
|
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 ((
|
|
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
|
-
|
|
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
|
|
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 ((
|
|
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
|
-
|
|
829
|
-
|
|
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
|
|
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
|
-
):
|
|
972
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
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 =
|
|
1364
|
+
remote = await entry.call("load", source, {
|
|
1174
1365
|
directiveOptions,
|
|
1175
1366
|
schema,
|
|
1176
1367
|
...(rules.idField !== undefined && { idField: rules.idField }),
|
|
1177
|
-
})
|
|
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
|
-
?
|
|
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 =
|
|
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,
|
|
1486
|
+
function unreadableFile(type: string, path: string, cause: unknown): Error {
|
|
1284
1487
|
return new Error(
|
|
1285
|
-
`Content type "${type}": cannot read "${path}": ${errorMessage(
|
|
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
|
|
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(
|
|
1301
|
-
return
|
|
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) &&
|
|
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" &&
|
|
1562
|
+
} else if (def.type === "string" && !isString(value)) {
|
|
1345
1563
|
console.warn(
|
|
1346
|
-
`Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected string, got ${
|
|
1564
|
+
`Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected string, got ${jsTypeName(value)}`,
|
|
1347
1565
|
);
|
|
1348
|
-
} else if (def.type === "number" &&
|
|
1566
|
+
} else if (def.type === "number" && !isNumber(value)) {
|
|
1349
1567
|
console.warn(
|
|
1350
|
-
`Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected number, got ${
|
|
1568
|
+
`Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected number, got ${jsTypeName(value)}`,
|
|
1351
1569
|
);
|
|
1352
|
-
} else if (def.type === "boolean" &&
|
|
1570
|
+
} else if (def.type === "boolean" && !isBoolean(value)) {
|
|
1353
1571
|
console.warn(
|
|
1354
|
-
`Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected boolean, got ${
|
|
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 ${
|
|
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
|
|
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 &&
|
|
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 (
|
|
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
|
-
|
|
1482
|
-
|
|
1483
|
-
(
|
|
1484
|
-
|
|
1485
|
-
|
|
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
|
-
):
|
|
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:
|
|
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
|
-
|
|
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 {
|
|
1558
|
-
* @param {ProjectDataContext} ctx - Host context ({ projectConfig, root, registry, io })
|
|
1559
|
-
* @returns {Promise<
|
|
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:
|
|
2024
|
+
sectionValue: ContentSection | undefined,
|
|
1563
2025
|
ctx: ProjectDataContext,
|
|
1564
|
-
): Promise<
|
|
1565
|
-
const
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
ctx.root,
|
|
1569
|
-
|
|
1570
|
-
|
|
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 {
|
|
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:
|
|
2046
|
+
sectionValue: ContentSection | undefined,
|
|
1587
2047
|
ctx: { root: string; projectConfig?: ProjectConfig },
|
|
1588
2048
|
): AssetMount[] {
|
|
1589
|
-
return contentAssetMounts(
|
|
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<
|
|
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<
|
|
1620
|
-
const entries = ctx.data
|
|
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 &&
|
|
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:
|
|
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:
|
|
2126
|
+
const pathEntry: ContentPathParams = { [param]: value };
|
|
1667
2127
|
if (entry._meta !== undefined) {
|
|
1668
2128
|
pathEntry._meta = entry._meta;
|
|
1669
2129
|
}
|