@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.
- package/dist/content-loader.js +620 -286
- package/dist/content.js +183 -30
- package/dist/markdown.js +641 -382
- package/dist/serialize.js +28852 -28615
- package/package.json +6 -5
- package/src/Content.class.json +2 -0
- 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 +702 -223
- 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 +26 -6
- package/src/html-to-jx.ts +3 -3
- package/src/inert.ts +7 -5
- package/src/markdown.ts +27 -23
- package/src/md-html.ts +3 -3
- package/src/md.ts +133 -64
- package/src/serialize.ts +544 -299
- package/src/source-style.ts +236 -0
- package/src/transpile.ts +182 -152
- package/src/types.ts +43 -11
package/src/content-loader.ts
CHANGED
|
@@ -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 {
|
|
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.
|
|
@@ -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 (
|
|
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
|
-
|
|
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 (!
|
|
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 (
|
|
337
|
+
if (SAFE_CONTENT_TYPE_NAME.test(name) && existsSync(dir) && statSync(dir).isDirectory()) {
|
|
305
338
|
mounts.push({
|
|
306
339
|
dir,
|
|
307
|
-
urlPrefix: `/${
|
|
340
|
+
urlPrefix: `/${CONTENT_SECTION_KEY}/${name}/${locale}`,
|
|
308
341
|
...filterOf(contentTypeDef),
|
|
309
342
|
});
|
|
310
343
|
}
|
|
311
344
|
}
|
|
312
345
|
continue;
|
|
313
346
|
}
|
|
314
|
-
if (!
|
|
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 /${
|
|
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: `/${
|
|
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
|
|
422
|
+
if (!isNonNullObject(node)) {
|
|
390
423
|
return;
|
|
391
424
|
}
|
|
392
|
-
const
|
|
393
|
-
const attributes = record.attributes as Record<string, unknown> | undefined;
|
|
425
|
+
const { attributes } = node;
|
|
394
426
|
for (const key of ASSET_KEYS) {
|
|
395
|
-
|
|
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 (
|
|
430
|
+
if (holder && isString(value)) {
|
|
398
431
|
const rewritten = rewriteRef(value, entryDir, mount, onProblem);
|
|
399
432
|
if (rewritten) {
|
|
400
|
-
holder
|
|
433
|
+
holder[key] = rewritten;
|
|
401
434
|
}
|
|
402
435
|
}
|
|
403
436
|
}
|
|
404
437
|
}
|
|
405
|
-
const { children } =
|
|
438
|
+
const { children } = node;
|
|
406
439
|
if (Array.isArray(children)) {
|
|
407
440
|
for (const child of children) {
|
|
408
|
-
rewriteNodeAssets(child
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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 = (
|
|
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
|
-
}
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
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(`/${
|
|
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
|
-
|
|
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);
|
|
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
|
-
|
|
656
|
+
all.push(...entries);
|
|
553
657
|
}
|
|
554
|
-
return
|
|
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:
|
|
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 ((
|
|
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
|
-
|
|
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
|
|
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 ((
|
|
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
|
-
|
|
829
|
-
|
|
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
|
|
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
|
-
):
|
|
972
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
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 =
|
|
1383
|
+
remote = await entry.call("load", source, {
|
|
1174
1384
|
directiveOptions,
|
|
1175
1385
|
schema,
|
|
1176
1386
|
...(rules.idField !== undefined && { idField: rules.idField }),
|
|
1177
|
-
})
|
|
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
|
-
?
|
|
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 =
|
|
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,
|
|
1505
|
+
function unreadableFile(type: string, path: string, cause: unknown): Error {
|
|
1284
1506
|
return new Error(
|
|
1285
|
-
`Content type "${type}": cannot read "${path}": ${errorMessage(
|
|
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
|
|
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(
|
|
1301
|
-
return
|
|
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) &&
|
|
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" &&
|
|
1581
|
+
} else if (def.type === "string" && !isString(value)) {
|
|
1345
1582
|
console.warn(
|
|
1346
|
-
`Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected string, got ${
|
|
1583
|
+
`Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected string, got ${jsTypeName(value)}`,
|
|
1347
1584
|
);
|
|
1348
|
-
} else if (def.type === "number" &&
|
|
1585
|
+
} else if (def.type === "number" && !isNumber(value)) {
|
|
1349
1586
|
console.warn(
|
|
1350
|
-
`Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected number, got ${
|
|
1587
|
+
`Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected number, got ${jsTypeName(value)}`,
|
|
1351
1588
|
);
|
|
1352
|
-
} else if (def.type === "boolean" &&
|
|
1589
|
+
} else if (def.type === "boolean" && !isBoolean(value)) {
|
|
1353
1590
|
console.warn(
|
|
1354
|
-
`Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected boolean, got ${
|
|
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 ${
|
|
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
|
|
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 &&
|
|
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 (
|
|
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
|
-
|
|
1482
|
-
|
|
1483
|
-
(
|
|
1484
|
-
|
|
1485
|
-
|
|
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
|
-
):
|
|
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:
|
|
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
|
-
|
|
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 {
|
|
1558
|
-
* @param {ProjectDataContext} ctx - Host context ({ projectConfig, root, registry, io })
|
|
1559
|
-
* @returns {Promise<
|
|
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:
|
|
2043
|
+
sectionValue: ContentSection | undefined,
|
|
1563
2044
|
ctx: ProjectDataContext,
|
|
1564
|
-
): Promise<
|
|
1565
|
-
const
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
ctx.root,
|
|
1569
|
-
|
|
1570
|
-
|
|
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 {
|
|
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:
|
|
2065
|
+
sectionValue: ContentSection | undefined,
|
|
1587
2066
|
ctx: { root: string; projectConfig?: ProjectConfig },
|
|
1588
2067
|
): AssetMount[] {
|
|
1589
|
-
return contentAssetMounts(
|
|
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<
|
|
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<
|
|
1620
|
-
const entries = ctx.data
|
|
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 &&
|
|
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:
|
|
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:
|
|
2145
|
+
const pathEntry: ContentPathParams = { [param]: value };
|
|
1667
2146
|
if (entry._meta !== undefined) {
|
|
1668
2147
|
pathEntry._meta = entry._meta;
|
|
1669
2148
|
}
|