@jxsuite/parser 2.0.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/content-loader.js +608 -284
- package/dist/content.js +183 -30
- package/dist/markdown.js +394 -352
- package/dist/serialize.js +262 -232
- 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-routes.ts
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* @docs framework/site/content-collections
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import { isBoolean, isNumber, isString } from "@jxsuite/schema/guards";
|
|
20
|
+
import type { ContentFieldValue, ContentLoaderEntry, JsonValue } from "@jxsuite/schema/types";
|
|
19
21
|
import { readField } from "./content-rules.ts";
|
|
20
22
|
|
|
21
23
|
// ─── slugs ──────────────────────────────────────────────────────────────────
|
|
@@ -56,7 +58,18 @@ export function slugifyPath(text: string): string {
|
|
|
56
58
|
|
|
57
59
|
// ─── templates ──────────────────────────────────────────────────────────────
|
|
58
60
|
|
|
59
|
-
|
|
61
|
+
/**
|
|
62
|
+
* The transforms a placeholder may name, looked up by the name a template spells. The four built-in
|
|
63
|
+
* transforms are always present; any other name is checked with `in` before it is applied.
|
|
64
|
+
*/
|
|
65
|
+
interface RouteTransforms extends Readonly<Record<string, (value: string) => string>> {
|
|
66
|
+
readonly lower: (value: string) => string;
|
|
67
|
+
readonly raw: (value: string) => string;
|
|
68
|
+
readonly slug: (value: string) => string;
|
|
69
|
+
readonly upper: (value: string) => string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const TRANSFORMS: RouteTransforms = {
|
|
60
73
|
lower: (value) => value.toLowerCase(),
|
|
61
74
|
raw: (value) => value,
|
|
62
75
|
slug: slugifyPath,
|
|
@@ -85,8 +98,8 @@ export interface RouteSpec {
|
|
|
85
98
|
* `project.json` fails the build on the first entry rather than producing routes with the
|
|
86
99
|
* placeholder text in them.
|
|
87
100
|
*/
|
|
88
|
-
export function parseRouteTemplate(source:
|
|
89
|
-
if (
|
|
101
|
+
export function parseRouteTemplate(source: JsonValue, where: string): RouteTemplate {
|
|
102
|
+
if (!isString(source) || !source.startsWith("/")) {
|
|
90
103
|
throw new TypeError(
|
|
91
104
|
`${where}: a route template is a site-absolute path starting with "/", got ${JSON.stringify(source)}`,
|
|
92
105
|
);
|
|
@@ -94,7 +107,7 @@ export function parseRouteTemplate(source: unknown, where: string): RouteTemplat
|
|
|
94
107
|
const tokens: Token[] = [];
|
|
95
108
|
let last = 0;
|
|
96
109
|
for (const match of source.matchAll(/\{([^{}]*)\}/g)) {
|
|
97
|
-
const index = match
|
|
110
|
+
const { index } = match;
|
|
98
111
|
if (index > last) {
|
|
99
112
|
tokens.push({ literal: source.slice(last, index) });
|
|
100
113
|
}
|
|
@@ -128,8 +141,8 @@ export function parseRouteTemplate(source: unknown, where: string): RouteTemplat
|
|
|
128
141
|
* entry template would route the READMEs and leave every other document without a URL.
|
|
129
142
|
*/
|
|
130
143
|
export function parseRouteConfig(
|
|
131
|
-
route:
|
|
132
|
-
indexRoute:
|
|
144
|
+
route: JsonValue | undefined,
|
|
145
|
+
indexRoute: JsonValue | undefined,
|
|
133
146
|
typeName: string,
|
|
134
147
|
): RouteSpec | null {
|
|
135
148
|
const where = `Content type "${typeName}"`;
|
|
@@ -152,7 +165,7 @@ export interface RouteSubject {
|
|
|
152
165
|
/** The entry id (after `idField`). */
|
|
153
166
|
id: string;
|
|
154
167
|
/** Frontmatter / entry data. */
|
|
155
|
-
data:
|
|
168
|
+
data: ContentLoaderEntry["data"];
|
|
156
169
|
/** The source file, relative to the source root, `/`-separated, with its extension. */
|
|
157
170
|
path: string;
|
|
158
171
|
}
|
|
@@ -178,11 +191,11 @@ export function directoryOf(path: string): string {
|
|
|
178
191
|
}
|
|
179
192
|
|
|
180
193
|
/** A field value as route text, or null when it cannot name a path segment. */
|
|
181
|
-
function textOf(value:
|
|
182
|
-
if (
|
|
194
|
+
function textOf(value: ContentFieldValue | undefined): string | null {
|
|
195
|
+
if (isString(value)) {
|
|
183
196
|
return value.trim() === "" ? null : value;
|
|
184
197
|
}
|
|
185
|
-
if (
|
|
198
|
+
if (isNumber(value) || isBoolean(value)) {
|
|
186
199
|
return String(value);
|
|
187
200
|
}
|
|
188
201
|
if (value instanceof Date && !Number.isNaN(value.getTime())) {
|
package/src/content-rules.ts
CHANGED
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
* @docs framework/site/content-collections
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import { isBoolean, isNumber, isString } from "@jxsuite/schema/guards";
|
|
20
|
+
import type { ContentFieldValue, ContentLoaderEntry, JsonValue } from "@jxsuite/schema/types";
|
|
21
|
+
|
|
19
22
|
// ─── exclude: globs relative to the source root ─────────────────────────────
|
|
20
23
|
|
|
21
24
|
/**
|
|
@@ -225,7 +228,8 @@ export function compileExclude(patterns?: readonly string[]): ExcludeMatcher {
|
|
|
225
228
|
const dirs: RegExp[] = [];
|
|
226
229
|
let everything = false;
|
|
227
230
|
for (const pattern of patterns) {
|
|
228
|
-
|
|
231
|
+
// The list is project.json data, so an entry is checked rather than trusted to be text.
|
|
232
|
+
if (!isString(pattern) || pattern.trim() === "") {
|
|
229
233
|
throw new TypeError(
|
|
230
234
|
`"exclude" entries must be non-empty strings, got ${JSON.stringify(pattern)}`,
|
|
231
235
|
);
|
|
@@ -266,27 +270,33 @@ export function compileExclude(patterns?: readonly string[]): ExcludeMatcher {
|
|
|
266
270
|
|
|
267
271
|
// ─── where: a declarative frontmatter filter ────────────────────────────────
|
|
268
272
|
|
|
273
|
+
/** An entry's frontmatter as a filter reads it: field name → value, absent when it is not set. */
|
|
274
|
+
type FieldData = ContentLoaderEntry["data"];
|
|
275
|
+
|
|
276
|
+
/** A field value, or an operand, in the form a filter compares; `undefined` is a missing field. */
|
|
277
|
+
type FieldValue = ContentFieldValue | undefined;
|
|
278
|
+
|
|
269
279
|
/** Whether an entry's frontmatter passes a compiled `where`. */
|
|
270
280
|
export interface WherePredicate {
|
|
271
|
-
(data:
|
|
281
|
+
(data: FieldData): boolean;
|
|
272
282
|
/**
|
|
273
283
|
* The first field whose condition `data` fails, for a message that says why an entry was left
|
|
274
284
|
* out.
|
|
275
285
|
*/
|
|
276
|
-
why: (data:
|
|
286
|
+
why: (data: FieldData) => string | undefined;
|
|
277
287
|
}
|
|
278
288
|
|
|
279
289
|
/** The query operators. Closed on purpose: see the module comment. */
|
|
280
290
|
const OPERATORS = new Set(["$eq", "$ne", "$in", "$nin", "$exists", "$gt", "$gte", "$lt", "$lte"]);
|
|
281
291
|
|
|
282
|
-
function isPlainObject(value:
|
|
292
|
+
function isPlainObject(value: FieldValue | FieldData): value is FieldData {
|
|
283
293
|
return (
|
|
284
294
|
typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date)
|
|
285
295
|
);
|
|
286
296
|
}
|
|
287
297
|
|
|
288
298
|
/** A frontmatter value in the form a filter compares: YAML dates become RFC 3339 text. */
|
|
289
|
-
function comparable(value:
|
|
299
|
+
function comparable(value: FieldValue): FieldValue {
|
|
290
300
|
if (value instanceof Date) {
|
|
291
301
|
if (Number.isNaN(value.getTime())) {
|
|
292
302
|
return undefined;
|
|
@@ -297,7 +307,7 @@ function comparable(value: unknown): unknown {
|
|
|
297
307
|
return value;
|
|
298
308
|
}
|
|
299
309
|
|
|
300
|
-
function deepEqual(a:
|
|
310
|
+
function deepEqual(a: FieldValue, b: FieldValue): boolean {
|
|
301
311
|
if (a === b) {
|
|
302
312
|
return true;
|
|
303
313
|
}
|
|
@@ -321,7 +331,7 @@ function deepEqual(a: unknown, b: unknown): boolean {
|
|
|
321
331
|
* it, so `{"tags": "frappe"}` selects every entry tagged frappe without a second operator. An array
|
|
322
332
|
* or object operand compares as a whole value.
|
|
323
333
|
*/
|
|
324
|
-
function sameValue(actual:
|
|
334
|
+
function sameValue(actual: FieldValue, expected: JsonValue | undefined): boolean {
|
|
325
335
|
const a = comparable(actual);
|
|
326
336
|
if (Array.isArray(expected) || isPlainObject(expected)) {
|
|
327
337
|
return deepEqual(a, expected);
|
|
@@ -333,7 +343,7 @@ function sameValue(actual: unknown, expected: unknown): boolean {
|
|
|
333
343
|
}
|
|
334
344
|
|
|
335
345
|
/** Read a frontmatter field: a literal key first, then a dotted path into nested data. */
|
|
336
|
-
export function readField(data:
|
|
346
|
+
export function readField(data: FieldData, key: string): FieldValue {
|
|
337
347
|
// Own properties only: `constructor` and `toString` are not frontmatter, whatever `in` says.
|
|
338
348
|
if (Object.hasOwn(data, key)) {
|
|
339
349
|
return data[key];
|
|
@@ -341,31 +351,33 @@ export function readField(data: Record<string, unknown>, key: string): unknown {
|
|
|
341
351
|
if (!key.includes(".")) {
|
|
342
352
|
return undefined;
|
|
343
353
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
354
|
+
return readPath(data, key.split("."));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Follow a dotted path's parts through nested objects, own properties only like {@link readField}. */
|
|
358
|
+
function readPath(holder: FieldValue | FieldData, [part, ...rest]: readonly string[]): FieldValue {
|
|
359
|
+
if (part === undefined || !isPlainObject(holder) || !Object.hasOwn(holder, part)) {
|
|
360
|
+
return undefined;
|
|
350
361
|
}
|
|
351
|
-
|
|
362
|
+
const value = holder[part];
|
|
363
|
+
return rest.length === 0 ? value : readPath(value, rest);
|
|
352
364
|
}
|
|
353
365
|
|
|
354
366
|
/** Order two comparable values; undefined when they are not the same kind of thing. */
|
|
355
|
-
function order(actual:
|
|
367
|
+
function order(actual: FieldValue, operand: string | number): number | undefined {
|
|
356
368
|
const a = comparable(actual);
|
|
357
|
-
if (
|
|
369
|
+
if (isNumber(a) && isNumber(operand)) {
|
|
358
370
|
return a - operand;
|
|
359
371
|
}
|
|
360
|
-
if (
|
|
372
|
+
if (isString(a) && isString(operand)) {
|
|
361
373
|
return a < operand ? -1 : a > operand ? 1 : 0;
|
|
362
374
|
}
|
|
363
375
|
return undefined;
|
|
364
376
|
}
|
|
365
377
|
|
|
366
|
-
type FieldTest = (actual:
|
|
378
|
+
type FieldTest = (actual: FieldValue) => boolean;
|
|
367
379
|
|
|
368
|
-
function operatorTest(where: string, op: string, operand:
|
|
380
|
+
function operatorTest(where: string, op: string, operand: JsonValue | undefined): FieldTest {
|
|
369
381
|
switch (op) {
|
|
370
382
|
case "$eq": {
|
|
371
383
|
return (actual) => sameValue(actual, operand);
|
|
@@ -382,7 +394,7 @@ function operatorTest(where: string, op: string, operand: unknown): FieldTest {
|
|
|
382
394
|
return op === "$in" ? hit : (actual) => !hit(actual);
|
|
383
395
|
}
|
|
384
396
|
case "$exists": {
|
|
385
|
-
if (
|
|
397
|
+
if (!isBoolean(operand)) {
|
|
386
398
|
throw new TypeError(
|
|
387
399
|
`${where}.$exists: expected true or false, got ${JSON.stringify(operand)}`,
|
|
388
400
|
);
|
|
@@ -391,7 +403,7 @@ function operatorTest(where: string, op: string, operand: unknown): FieldTest {
|
|
|
391
403
|
return (actual) => (actual !== undefined && actual !== null) === operand;
|
|
392
404
|
}
|
|
393
405
|
default: {
|
|
394
|
-
if (
|
|
406
|
+
if (!isString(operand) && !isNumber(operand)) {
|
|
395
407
|
throw new TypeError(
|
|
396
408
|
`${where}.${op}: expected a string or number, got ${JSON.stringify(operand)}`,
|
|
397
409
|
);
|
|
@@ -407,7 +419,7 @@ function operatorTest(where: string, op: string, operand: unknown): FieldTest {
|
|
|
407
419
|
}
|
|
408
420
|
|
|
409
421
|
/** A condition object is an operator set when every key is `$`-prefixed. */
|
|
410
|
-
function operatorKeys(condition:
|
|
422
|
+
function operatorKeys(condition: FieldData): string[] | null {
|
|
411
423
|
const keys = Object.keys(condition);
|
|
412
424
|
const dollars = keys.filter((key) => key.startsWith("$"));
|
|
413
425
|
if (dollars.length === 0) {
|
|
@@ -416,7 +428,7 @@ function operatorKeys(condition: Record<string, unknown>): string[] | null {
|
|
|
416
428
|
return dollars.length === keys.length ? keys : [];
|
|
417
429
|
}
|
|
418
430
|
|
|
419
|
-
function compileCondition(key: string, condition:
|
|
431
|
+
function compileCondition(key: string, condition: JsonValue): FieldTest {
|
|
420
432
|
const where = `where.${key}`;
|
|
421
433
|
if (!isPlainObject(condition)) {
|
|
422
434
|
return (actual) => sameValue(actual, condition);
|
|
@@ -446,10 +458,10 @@ function compileCondition(key: string, condition: unknown): FieldTest {
|
|
|
446
458
|
* `$exists`, `$gt`, `$gte`, `$lt`, `$lte`; several in one object combine with AND too). There is no
|
|
447
459
|
* OR and no evaluation of text: a project that needs "either" splits it into two content types.
|
|
448
460
|
*
|
|
449
|
-
* @param {
|
|
461
|
+
* @param {JsonValue} [where]
|
|
450
462
|
* @returns {WherePredicate}
|
|
451
463
|
*/
|
|
452
|
-
export function compileWhere(where?:
|
|
464
|
+
export function compileWhere(where?: JsonValue): WherePredicate {
|
|
453
465
|
if (where === undefined) {
|
|
454
466
|
return Object.assign(() => true, { why: nothing });
|
|
455
467
|
}
|
|
@@ -465,10 +477,9 @@ export function compileWhere(where?: unknown): WherePredicate {
|
|
|
465
477
|
return { key, test: compileCondition(key, condition) };
|
|
466
478
|
});
|
|
467
479
|
return Object.assign(
|
|
468
|
-
(data:
|
|
480
|
+
(data: FieldData) => tests.every(({ key, test }) => test(readField(data, key))),
|
|
469
481
|
{
|
|
470
|
-
why: (data:
|
|
471
|
-
tests.find(({ key, test }) => !test(readField(data, key)))?.key,
|
|
482
|
+
why: (data: FieldData) => tests.find(({ key, test }) => !test(readField(data, key)))?.key,
|
|
472
483
|
},
|
|
473
484
|
);
|
|
474
485
|
}
|
package/src/content.ts
CHANGED
|
@@ -6,35 +6,64 @@
|
|
|
6
6
|
* queryContentType, findEntry) used by both the classes and the server endpoint.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { isNonNullObject, isRef, isString } from "@jxsuite/schema/guards";
|
|
9
10
|
import { localeUrlPrefix, resolveI18n } from "@jxsuite/schema/locale";
|
|
10
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
ContentFieldValue,
|
|
13
|
+
JsonObject,
|
|
14
|
+
JsonValue,
|
|
15
|
+
ProjectConfig,
|
|
16
|
+
} from "@jxsuite/schema/types";
|
|
17
|
+
import { entriesOf } from "./content-data.ts";
|
|
18
|
+
import type { ContentSectionData } from "./content-data.ts";
|
|
11
19
|
import { parseComparable } from "./dates.ts";
|
|
12
20
|
import type { ContentLoaderEntry } from "./types.ts";
|
|
13
21
|
|
|
22
|
+
/** One filter rule, as `ContentCollection.class.json` declares an item of its `filter` parameter. */
|
|
23
|
+
export interface FilterRule {
|
|
24
|
+
field: string;
|
|
25
|
+
op: string;
|
|
26
|
+
value?: ContentFieldValue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One sort key, as `ContentCollection.class.json` declares an item of its `sort` parameter. */
|
|
30
|
+
export interface SortRule {
|
|
31
|
+
field: string;
|
|
32
|
+
order?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** What {@link queryContentType} narrows a content type by. */
|
|
36
|
+
export interface ContentQuery {
|
|
37
|
+
/** Explicit rules, or the `{ field: value }` shorthand that means `==` on every key. */
|
|
38
|
+
filter?: FilterRule[] | JsonObject;
|
|
39
|
+
sort?: SortRule | SortRule[];
|
|
40
|
+
limit?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The loaded project the runtime hands a content class: the `content` section (the
|
|
45
|
+
* Content.projectData result) under its section key, and the project config.
|
|
46
|
+
*/
|
|
47
|
+
interface ContentProjectContext {
|
|
48
|
+
content?: ContentSectionData;
|
|
49
|
+
/** The project config, for the i18n routing rules a locale prefix comes from. */
|
|
50
|
+
config?: ProjectConfig | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
14
53
|
interface CollectionConfig {
|
|
15
54
|
contentType?: string;
|
|
16
|
-
filter?:
|
|
17
|
-
sort?:
|
|
18
|
-
limit?: number;
|
|
19
|
-
_project?:
|
|
20
|
-
/** The loaded `content` section (Content.projectData result) under its section key. */
|
|
21
|
-
content?: Map<string, ContentLoaderEntry[]>;
|
|
22
|
-
[k: string]: unknown;
|
|
23
|
-
};
|
|
24
|
-
[k: string]: unknown;
|
|
55
|
+
filter?: FilterRule[] | JsonObject | null;
|
|
56
|
+
sort?: SortRule | SortRule[] | null;
|
|
57
|
+
limit?: number | null;
|
|
58
|
+
_project?: ContentProjectContext;
|
|
25
59
|
}
|
|
26
60
|
|
|
27
61
|
interface EntryConfig {
|
|
28
62
|
contentType?: string;
|
|
29
|
-
id
|
|
63
|
+
/** An entry id, or a `{ $ref: "#/$params/<name>" }` binding to one of the route's path params. */
|
|
64
|
+
id?: JsonValue;
|
|
30
65
|
field?: string;
|
|
31
|
-
_project?:
|
|
32
|
-
/** The loaded `content` section (Content.projectData result) under its section key. */
|
|
33
|
-
content?: Map<string, ContentLoaderEntry[]>;
|
|
34
|
-
/** The project config, for the i18n routing rules a locale prefix comes from. */
|
|
35
|
-
config?: ProjectConfig | null;
|
|
36
|
-
[k: string]: unknown;
|
|
37
|
-
};
|
|
66
|
+
_project?: ContentProjectContext;
|
|
38
67
|
_document?: {
|
|
39
68
|
route?: {
|
|
40
69
|
_pathParams?: Record<string, string>;
|
|
@@ -43,24 +72,27 @@ interface EntryConfig {
|
|
|
43
72
|
/** The language this route serves, when the project declares any (§13.4). */
|
|
44
73
|
locale?: string | null;
|
|
45
74
|
};
|
|
46
|
-
[k: string]: unknown;
|
|
47
75
|
};
|
|
48
|
-
[k: string]: unknown;
|
|
49
76
|
}
|
|
50
77
|
|
|
51
78
|
// ─── Query Functions ────────────────────────────────────────────────────────
|
|
52
79
|
|
|
80
|
+
/** `Array.prototype.includes`, with a rule that names no value looking for `undefined`. */
|
|
81
|
+
function includesValue(
|
|
82
|
+
items: readonly (ContentFieldValue | undefined)[],
|
|
83
|
+
value: ContentFieldValue | undefined,
|
|
84
|
+
): boolean {
|
|
85
|
+
return items.includes(value);
|
|
86
|
+
}
|
|
87
|
+
|
|
53
88
|
/**
|
|
54
89
|
* Evaluate a single filter rule against an entry.
|
|
55
90
|
*
|
|
56
|
-
* @param {
|
|
91
|
+
* @param {FilterRule} rule
|
|
57
92
|
* @param {ContentLoaderEntry} entry
|
|
58
93
|
* @returns {boolean}
|
|
59
94
|
*/
|
|
60
|
-
export function evaluateFilterRule(
|
|
61
|
-
rule: { field: string; op: string; value?: unknown },
|
|
62
|
-
entry: ContentLoaderEntry,
|
|
63
|
-
) {
|
|
95
|
+
export function evaluateFilterRule(rule: FilterRule, entry: ContentLoaderEntry) {
|
|
64
96
|
const actual = rule.field === "id" ? entry.id : entry.data[rule.field];
|
|
65
97
|
switch (rule.op) {
|
|
66
98
|
case "==": {
|
|
@@ -77,15 +109,15 @@ export function evaluateFilterRule(
|
|
|
77
109
|
}
|
|
78
110
|
case "contains": {
|
|
79
111
|
if (Array.isArray(actual)) {
|
|
80
|
-
return actual
|
|
112
|
+
return includesValue(actual, rule.value);
|
|
81
113
|
}
|
|
82
|
-
return
|
|
114
|
+
return isString(actual) && actual.includes(String(rule.value ?? ""));
|
|
83
115
|
}
|
|
84
116
|
case "not contains": {
|
|
85
117
|
if (Array.isArray(actual)) {
|
|
86
|
-
return !actual
|
|
118
|
+
return !includesValue(actual, rule.value);
|
|
87
119
|
}
|
|
88
|
-
return
|
|
120
|
+
return isString(actual) && !actual.includes(String(rule.value ?? ""));
|
|
89
121
|
}
|
|
90
122
|
/*
|
|
91
123
|
* `Number()` on both sides made every date comparison false, since NaN compares false against
|
|
@@ -118,29 +150,18 @@ export function evaluateFilterRule(
|
|
|
118
150
|
* Query a loaded content type with filter, sort, and limit.
|
|
119
151
|
*
|
|
120
152
|
* @param {ContentLoaderEntry[]} entries - Full content type entries
|
|
121
|
-
* @param {
|
|
122
|
-
* filter?: Record<string, unknown> | Array<{ field: string; op: string; value?: unknown }>;
|
|
123
|
-
* sort?: { field: string; order?: string } | Array<{ field: string; order?: string }>;
|
|
124
|
-
* limit?: number;
|
|
125
|
-
* }} [query]
|
|
153
|
+
* @param {ContentQuery} [query]
|
|
126
154
|
* @returns {ContentLoaderEntry[]} Filtered, sorted, limited entries
|
|
127
155
|
*/
|
|
128
|
-
export function queryContentType(
|
|
129
|
-
entries: ContentLoaderEntry[],
|
|
130
|
-
query: {
|
|
131
|
-
filter?: Record<string, unknown> | { field: string; op: string; value?: unknown }[];
|
|
132
|
-
sort?: { field: string; order?: string } | { field: string; order?: string }[];
|
|
133
|
-
limit?: number;
|
|
134
|
-
} = {},
|
|
135
|
-
) {
|
|
156
|
+
export function queryContentType(entries: ContentLoaderEntry[], query: ContentQuery = {}) {
|
|
136
157
|
let result = [...entries];
|
|
137
158
|
|
|
138
159
|
// Filter
|
|
139
160
|
if (query.filter) {
|
|
140
|
-
let rules:
|
|
161
|
+
let rules: FilterRule[];
|
|
141
162
|
if (Array.isArray(query.filter)) {
|
|
142
163
|
rules = query.filter;
|
|
143
|
-
} else if (
|
|
164
|
+
} else if (isNonNullObject(query.filter)) {
|
|
144
165
|
rules = Object.entries(query.filter).map(([field, value]) => ({
|
|
145
166
|
field,
|
|
146
167
|
op: "==",
|
|
@@ -157,8 +178,8 @@ export function queryContentType(
|
|
|
157
178
|
const sortRules = Array.isArray(query.sort) ? query.sort : [query.sort];
|
|
158
179
|
result.sort((a, b) => {
|
|
159
180
|
for (const { field, order = "asc" } of sortRules) {
|
|
160
|
-
const aVal = field === "id" ? a.id : (
|
|
161
|
-
const bVal = field === "id" ? b.id : (
|
|
181
|
+
const aVal = field === "id" ? a.id : (a.data[field] ?? "");
|
|
182
|
+
const bVal = field === "id" ? b.id : (b.data[field] ?? "");
|
|
162
183
|
if (aVal < bVal) {
|
|
163
184
|
return order === "asc" ? -1 : 1;
|
|
164
185
|
}
|
|
@@ -192,33 +213,46 @@ export function findEntry(entries: ContentLoaderEntry[], id: string) {
|
|
|
192
213
|
// ─── Class Implementations ──────────────────────────────────────────────────
|
|
193
214
|
|
|
194
215
|
export class ContentCollection {
|
|
216
|
+
/**
|
|
217
|
+
* Reads `_project.content` through `load` and `materialize`, so a host that loads sections on
|
|
218
|
+
* demand may hand it the section as it is, rather than completing it first (specs/extensions.md
|
|
219
|
+
* §8). A class without this flag gets the whole section, as it always has.
|
|
220
|
+
*/
|
|
221
|
+
static projectOnDemand = true;
|
|
222
|
+
|
|
195
223
|
config: CollectionConfig;
|
|
196
224
|
/** @param {CollectionConfig} config */
|
|
197
225
|
constructor(config: CollectionConfig) {
|
|
198
226
|
this.config = config;
|
|
199
227
|
}
|
|
200
228
|
|
|
201
|
-
resolve() {
|
|
229
|
+
async resolve() {
|
|
202
230
|
const { contentType, filter, sort, limit, _project } = this.config;
|
|
203
|
-
const entries = _project?.content
|
|
231
|
+
const entries = await entriesOf(_project?.content, contentType ?? "");
|
|
204
232
|
if (!entries) {
|
|
205
233
|
return [];
|
|
206
234
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
235
|
+
const query: ContentQuery = {};
|
|
236
|
+
if (filter != null) {
|
|
237
|
+
query.filter = filter;
|
|
238
|
+
}
|
|
239
|
+
if (sort != null) {
|
|
240
|
+
query.sort = sort;
|
|
241
|
+
}
|
|
242
|
+
if (limit != null) {
|
|
243
|
+
query.limit = limit;
|
|
244
|
+
}
|
|
245
|
+
const result = queryContentType(entries, query);
|
|
246
|
+
// Only what is returned needs a body; the query above read frontmatter alone.
|
|
247
|
+
await _project?.content?.materialize?.(result);
|
|
248
|
+
return result;
|
|
218
249
|
}
|
|
219
250
|
}
|
|
220
251
|
|
|
221
252
|
export class ContentEntry {
|
|
253
|
+
/** As {@link ContentCollection.projectOnDemand}: reads the section through `load`. */
|
|
254
|
+
static projectOnDemand = true;
|
|
255
|
+
|
|
222
256
|
config: EntryConfig;
|
|
223
257
|
/** @param {EntryConfig} config */
|
|
224
258
|
constructor(config: EntryConfig) {
|
|
@@ -232,7 +266,7 @@ export class ContentEntry {
|
|
|
232
266
|
#pageRoute(): string | undefined {
|
|
233
267
|
const { _project, _document } = this.config;
|
|
234
268
|
let path = _document?.route?.urlPattern;
|
|
235
|
-
if (
|
|
269
|
+
if (!isString(path)) {
|
|
236
270
|
return undefined;
|
|
237
271
|
}
|
|
238
272
|
const { i18n } = resolveI18n(_project?.config ?? {});
|
|
@@ -243,9 +277,16 @@ export class ContentEntry {
|
|
|
243
277
|
return path.replace(/\/+$/, "") || "/";
|
|
244
278
|
}
|
|
245
279
|
|
|
246
|
-
resolve() {
|
|
280
|
+
async resolve() {
|
|
281
|
+
const found = await this.#find();
|
|
282
|
+
await this.config._project?.content?.materialize?.([found]);
|
|
283
|
+
return found;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** The entry this one names, found by frontmatter, route and id alone (its body may be pending). */
|
|
287
|
+
async #find(): Promise<ContentLoaderEntry | null> {
|
|
247
288
|
const { contentType, id, field, _project, _document } = this.config;
|
|
248
|
-
const entries = _project?.content
|
|
289
|
+
const entries = await entriesOf(_project?.content, contentType ?? "");
|
|
249
290
|
if (!entries) {
|
|
250
291
|
return null;
|
|
251
292
|
}
|
|
@@ -263,7 +304,7 @@ export class ContentEntry {
|
|
|
263
304
|
const wanted = _document?.route?.locale;
|
|
264
305
|
const localized = entries.some((e: ContentLoaderEntry) => e._meta?.locale !== undefined);
|
|
265
306
|
const scoped =
|
|
266
|
-
localized &&
|
|
307
|
+
localized && isString(wanted)
|
|
267
308
|
? entries.filter((e: ContentLoaderEntry) => e._meta?.locale === wanted)
|
|
268
309
|
: entries;
|
|
269
310
|
|
|
@@ -280,12 +321,8 @@ export class ContentEntry {
|
|
|
280
321
|
}
|
|
281
322
|
|
|
282
323
|
let resolvedId = id;
|
|
283
|
-
if (
|
|
284
|
-
resolvedId
|
|
285
|
-
typeof resolvedId === "object" &&
|
|
286
|
-
(resolvedId as { $ref?: string }).$ref?.startsWith("#/$params/")
|
|
287
|
-
) {
|
|
288
|
-
const paramName = (resolvedId as { $ref: string }).$ref.replace("#/$params/", "");
|
|
324
|
+
if (isRef(resolvedId) && resolvedId.$ref.startsWith("#/$params/")) {
|
|
325
|
+
const paramName = resolvedId.$ref.replace("#/$params/", "");
|
|
289
326
|
resolvedId = _document?.route?._pathParams?.[paramName];
|
|
290
327
|
}
|
|
291
328
|
if (!resolvedId) {
|
|
@@ -295,6 +332,7 @@ export class ContentEntry {
|
|
|
295
332
|
if (field && field !== "id") {
|
|
296
333
|
return scoped.find((e: ContentLoaderEntry) => e.data[field] === resolvedId) ?? null;
|
|
297
334
|
}
|
|
298
|
-
|
|
335
|
+
// Entry ids are strings, so an id of any other type matches no entry.
|
|
336
|
+
return isString(resolvedId) ? findEntry(scoped, resolvedId) : null;
|
|
299
337
|
}
|
|
300
338
|
}
|
package/src/csv.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Csv — CSV content format for Jx
|
|
3
3
|
*
|
|
4
|
-
* RFC 4180-style CSV parsing with schema-driven type coercion, packaged as a Jx
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* RFC 4180-style CSV parsing with schema-driven type coercion, packaged as a Jx format-extension
|
|
5
|
+
* class. `parse` and `rewrite` are pure and browser-safe; `discover`, `load`, and instance
|
|
6
|
+
* `resolve` touch the filesystem (or fetch remote URLs) and dynamically import node modules so the
|
|
7
|
+
* module itself stays importable in the browser.
|
|
8
8
|
*
|
|
9
9
|
* There is no `serialize`, and there will not be one: a collection is a data source entries are
|
|
10
10
|
* loaded FROM, and re-emitting one would mean choosing a quoting style, a line ending and a column
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* @license MIT
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import type { ValueEdit } from "@jxsuite/schema/format-registry";
|
|
18
19
|
import type { ContentTypeSchema } from "@jxsuite/schema/types";
|
|
19
20
|
import type { ContentLoaderEntry } from "./types.ts";
|
|
20
21
|
|
|
@@ -97,13 +98,11 @@ export function parseCSV(csv: string): Record<string, string>[] {
|
|
|
97
98
|
|
|
98
99
|
// ─── Cell-level rewriting ────────────────────────────────────────────────────
|
|
99
100
|
|
|
100
|
-
/**
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
to: string;
|
|
106
|
-
}
|
|
101
|
+
/**
|
|
102
|
+
* One authored value to replace, wherever a data cell holds exactly it. `from` is the cell value as
|
|
103
|
+
* `parse` produced it: unquoted and trimmed. Kept under its own name for importers of this module.
|
|
104
|
+
*/
|
|
105
|
+
export type { ValueEdit as CsvEdit } from "@jxsuite/schema/format-registry";
|
|
107
106
|
|
|
108
107
|
/** One field of a CSV source: where its bytes are, and what {@link parseCSV} made of it. */
|
|
109
108
|
interface CsvField {
|
|
@@ -226,7 +225,7 @@ function encodeField(raw: string, value: string): string {
|
|
|
226
225
|
* @param edits — authored values to replace
|
|
227
226
|
* @returns The source with exactly those cells rewritten
|
|
228
227
|
*/
|
|
229
|
-
export function rewriteCSV(source: string, edits: readonly
|
|
228
|
+
export function rewriteCSV(source: string, edits: readonly ValueEdit[]): string {
|
|
230
229
|
const replacements = new Map<string, string>();
|
|
231
230
|
for (const edit of edits) {
|
|
232
231
|
if (edit.from !== edit.to) {
|
|
@@ -273,7 +272,7 @@ export function coerceCSVRows(
|
|
|
273
272
|
idField?: string,
|
|
274
273
|
): ContentLoaderEntry[] {
|
|
275
274
|
return rows.map((row, i) => {
|
|
276
|
-
const data:
|
|
275
|
+
const data: ContentLoaderEntry["data"] = { ...row };
|
|
277
276
|
if (schema?.properties) {
|
|
278
277
|
for (const [key, def] of Object.entries(schema.properties)) {
|
|
279
278
|
if (!(key in data)) {
|
|
@@ -302,6 +301,11 @@ export function coerceCSVRows(
|
|
|
302
301
|
}
|
|
303
302
|
}
|
|
304
303
|
}
|
|
304
|
+
/*
|
|
305
|
+
* SAFETY: each candidate is a cell copied from `row`, so a string, unless the schema typed that
|
|
306
|
+
* column as number, boolean or array and the loop above coerced it. An id column typed that way
|
|
307
|
+
* would put its coerced value here as the entry id (see the coercion rules in the doc comment).
|
|
308
|
+
*/
|
|
305
309
|
const id =
|
|
306
310
|
(idField ? (data[idField] as string | undefined) : undefined) ??
|
|
307
311
|
(data.id as string | undefined) ??
|
|
@@ -342,7 +346,7 @@ export class Csv {
|
|
|
342
346
|
* changed without inventing an opinion about quoting, line endings or column order. See
|
|
343
347
|
* {@link rewriteCSV}.
|
|
344
348
|
*/
|
|
345
|
-
static rewrite(source: string, edits: readonly
|
|
349
|
+
static rewrite(source: string, edits: readonly ValueEdit[] = []): string {
|
|
346
350
|
return rewriteCSV(source, edits);
|
|
347
351
|
}
|
|
348
352
|
|