@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.
@@ -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
- const TRANSFORMS: Record<string, (value: string) => string> = {
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: unknown, where: string): RouteTemplate {
89
- if (typeof source !== "string" || !source.startsWith("/")) {
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.index ?? 0;
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: unknown,
132
- indexRoute: unknown,
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: Record<string, unknown>;
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: unknown): string | null {
182
- if (typeof value === "string") {
194
+ function textOf(value: ContentFieldValue | undefined): string | null {
195
+ if (isString(value)) {
183
196
  return value.trim() === "" ? null : value;
184
197
  }
185
- if (typeof value === "number" || typeof value === "boolean") {
198
+ if (isNumber(value) || isBoolean(value)) {
186
199
  return String(value);
187
200
  }
188
201
  if (value instanceof Date && !Number.isNaN(value.getTime())) {
@@ -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
- if (typeof pattern !== "string" || pattern.trim() === "") {
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: Record<string, unknown>): boolean;
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: Record<string, unknown>) => string | undefined;
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: unknown): value is Record<string, unknown> {
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: unknown): unknown {
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: unknown, b: unknown): boolean {
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: unknown, expected: unknown): boolean {
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: Record<string, unknown>, key: string): unknown {
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
- let current: unknown = data;
345
- for (const part of key.split(".")) {
346
- if (!isPlainObject(current) || !Object.hasOwn(current, part)) {
347
- return undefined;
348
- }
349
- current = current[part];
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
- return current;
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: unknown, operand: string | number): number | undefined {
367
+ function order(actual: FieldValue, operand: string | number): number | undefined {
356
368
  const a = comparable(actual);
357
- if (typeof a === "number" && typeof operand === "number") {
369
+ if (isNumber(a) && isNumber(operand)) {
358
370
  return a - operand;
359
371
  }
360
- if (typeof a === "string" && typeof operand === "string") {
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: unknown) => boolean;
378
+ type FieldTest = (actual: FieldValue) => boolean;
367
379
 
368
- function operatorTest(where: string, op: string, operand: unknown): FieldTest {
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 (typeof operand !== "boolean") {
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 (typeof operand !== "string" && typeof operand !== "number") {
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: Record<string, unknown>): string[] | null {
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: unknown): FieldTest {
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 {unknown} where
461
+ * @param {JsonValue} [where]
450
462
  * @returns {WherePredicate}
451
463
  */
452
- export function compileWhere(where?: unknown): WherePredicate {
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: Record<string, unknown>) => tests.every(({ key, test }) => test(readField(data, key))),
480
+ (data: FieldData) => tests.every(({ key, test }) => test(readField(data, key))),
469
481
  {
470
- why: (data: Record<string, unknown>) =>
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 { ProjectConfig } from "@jxsuite/schema/types";
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?: unknown;
17
- sort?: unknown;
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?: unknown;
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 {{ field: string; op: string; value?: unknown }} rule
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.includes(rule.value);
112
+ return includesValue(actual, rule.value);
81
113
  }
82
- return typeof actual === "string" && actual.includes(String(rule.value ?? ""));
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.includes(rule.value);
118
+ return !includesValue(actual, rule.value);
87
119
  }
88
- return typeof actual === "string" && !actual.includes(String(rule.value ?? ""));
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: { field: string; op: string; value?: unknown }[];
161
+ let rules: FilterRule[];
141
162
  if (Array.isArray(query.filter)) {
142
163
  rules = query.filter;
143
- } else if (typeof query.filter === "object") {
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 : ((a.data[field] ?? "") as string | number);
161
- const bVal = field === "id" ? b.id : ((b.data[field] ?? "") as string | number);
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?.get(contentType ?? "");
231
+ const entries = await entriesOf(_project?.content, contentType ?? "");
204
232
  if (!entries) {
205
233
  return [];
206
234
  }
207
- return queryContentType(entries, {
208
- ...(filter != null && {
209
- filter: filter as
210
- | Record<string, unknown>
211
- | { field: string; op: string; value?: unknown }[],
212
- }),
213
- ...(sort != null && {
214
- sort: sort as { field: string; order?: string } | { field: string; order?: string }[],
215
- }),
216
- ...(limit != null && { limit }),
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 (typeof path !== "string") {
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?.get(contentType ?? "");
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 && typeof wanted === "string"
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
- return findEntry(scoped, resolvedId as string);
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
- * format-extension class. `parse` and `rewrite` are pure and browser-safe; `discover`, `load`, and
6
- * instance `resolve` touch the filesystem (or fetch remote URLs) and dynamically
7
- * import node modules so the module itself stays importable in the browser.
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
- /** One authored value to replace, wherever a data cell holds exactly it. */
101
- export interface CsvEdit {
102
- /** The cell value as `parse` produced it — unquoted and trimmed. */
103
- from: string;
104
- /** What to write in its place. */
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 CsvEdit[]): string {
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: Record<string, unknown> = { ...row };
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 CsvEdit[] = []): string {
349
+ static rewrite(source: string, edits: readonly ValueEdit[] = []): string {
346
350
  return rewriteCSV(source, edits);
347
351
  }
348
352