@jxsuite/parser 4.1.2 → 4.3.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.
@@ -1,6 +1,8 @@
1
1
  /**
2
- * Content rules — the two declarative filters a content type can carry: `exclude` (which files are
3
- * never read) and `where` (which entries are kept once their frontmatter is known).
2
+ * Content rules — the declarative filters a content type can carry: `exclude` (which files are
3
+ * never read), here, and `where` and `hold` (what is kept once frontmatter is known), which live in
4
+ * `@jxsuite/schema/content-query` because Studio has to answer them too and a core package may not
5
+ * import an extension.
4
6
  *
5
7
  * Both are **data, never code**. A `project.json` is read by Studio, by the dev server and by CI,
6
8
  * so a filter that needed `eval` or a function would make every one of those a place that executes
@@ -16,8 +18,10 @@
16
18
  * @docs framework/site/content-collections
17
19
  */
18
20
 
19
- import { isBoolean, isNumber, isString } from "@jxsuite/schema/guards";
20
- import type { ContentFieldValue, ContentLoaderEntry, JsonValue } from "@jxsuite/schema/types";
21
+ import { isString } from "@jxsuite/schema/guards";
22
+
23
+ export { compileHold, compileWhere, readField } from "@jxsuite/schema/content-query";
24
+ export type { HoldPredicate, HoldRule, WherePredicate } from "@jxsuite/schema/content-query";
21
25
 
22
26
  // ─── exclude: globs relative to the source root ─────────────────────────────
23
27
 
@@ -267,219 +271,3 @@ export function compileExclude(patterns?: readonly string[]): ExcludeMatcher {
267
271
  excludedBy: (path) => files.find((f) => f.re.test(path))?.pattern,
268
272
  };
269
273
  }
270
-
271
- // ─── where: a declarative frontmatter filter ────────────────────────────────
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
-
279
- /** Whether an entry's frontmatter passes a compiled `where`. */
280
- export interface WherePredicate {
281
- (data: FieldData): boolean;
282
- /**
283
- * The first field whose condition `data` fails, for a message that says why an entry was left
284
- * out.
285
- */
286
- why: (data: FieldData) => string | undefined;
287
- }
288
-
289
- /** The query operators. Closed on purpose: see the module comment. */
290
- const OPERATORS = new Set(["$eq", "$ne", "$in", "$nin", "$exists", "$gt", "$gte", "$lt", "$lte"]);
291
-
292
- function isPlainObject(value: FieldValue | FieldData): value is FieldData {
293
- return (
294
- typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date)
295
- );
296
- }
297
-
298
- /** A frontmatter value in the form a filter compares: YAML dates become RFC 3339 text. */
299
- function comparable(value: FieldValue): FieldValue {
300
- if (value instanceof Date) {
301
- if (Number.isNaN(value.getTime())) {
302
- return undefined;
303
- }
304
- const iso = value.toISOString();
305
- return iso.endsWith("T00:00:00.000Z") ? iso.slice(0, 10) : iso.replace(/\.\d{3}Z$/, "Z");
306
- }
307
- return value;
308
- }
309
-
310
- function deepEqual(a: FieldValue, b: FieldValue): boolean {
311
- if (a === b) {
312
- return true;
313
- }
314
- if (Array.isArray(a) && Array.isArray(b)) {
315
- return (
316
- a.length === b.length && a.every((item, i) => deepEqual(comparable(item), comparable(b[i])))
317
- );
318
- }
319
- if (isPlainObject(a) && isPlainObject(b)) {
320
- const keys = Object.keys(a);
321
- return (
322
- keys.length === Object.keys(b).length &&
323
- keys.every((key) => key in b && deepEqual(comparable(a[key]), comparable(b[key])))
324
- );
325
- }
326
- return false;
327
- }
328
-
329
- /**
330
- * Equality, with one convenience that earns its keep: a scalar matches an array field that CONTAINS
331
- * it, so `{"tags": "frappe"}` selects every entry tagged frappe without a second operator. An array
332
- * or object operand compares as a whole value.
333
- */
334
- function sameValue(actual: FieldValue, expected: JsonValue | undefined): boolean {
335
- const a = comparable(actual);
336
- if (Array.isArray(expected) || isPlainObject(expected)) {
337
- return deepEqual(a, expected);
338
- }
339
- if (Array.isArray(a)) {
340
- return a.some((item) => comparable(item) === expected);
341
- }
342
- return a === expected;
343
- }
344
-
345
- /** Read a frontmatter field: a literal key first, then a dotted path into nested data. */
346
- export function readField(data: FieldData, key: string): FieldValue {
347
- // Own properties only: `constructor` and `toString` are not frontmatter, whatever `in` says.
348
- if (Object.hasOwn(data, key)) {
349
- return data[key];
350
- }
351
- if (!key.includes(".")) {
352
- return undefined;
353
- }
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;
361
- }
362
- const value = holder[part];
363
- return rest.length === 0 ? value : readPath(value, rest);
364
- }
365
-
366
- /** Order two comparable values; undefined when they are not the same kind of thing. */
367
- function order(actual: FieldValue, operand: string | number): number | undefined {
368
- const a = comparable(actual);
369
- if (isNumber(a) && isNumber(operand)) {
370
- return a - operand;
371
- }
372
- if (isString(a) && isString(operand)) {
373
- return a < operand ? -1 : a > operand ? 1 : 0;
374
- }
375
- return undefined;
376
- }
377
-
378
- type FieldTest = (actual: FieldValue) => boolean;
379
-
380
- function operatorTest(where: string, op: string, operand: JsonValue | undefined): FieldTest {
381
- switch (op) {
382
- case "$eq": {
383
- return (actual) => sameValue(actual, operand);
384
- }
385
- case "$ne": {
386
- return (actual) => !sameValue(actual, operand);
387
- }
388
- case "$in":
389
- case "$nin": {
390
- if (!Array.isArray(operand)) {
391
- throw new TypeError(`${where}.${op}: expected an array, got ${JSON.stringify(operand)}`);
392
- }
393
- const hit: FieldTest = (actual) => operand.some((candidate) => sameValue(actual, candidate));
394
- return op === "$in" ? hit : (actual) => !hit(actual);
395
- }
396
- case "$exists": {
397
- if (!isBoolean(operand)) {
398
- throw new TypeError(
399
- `${where}.$exists: expected true or false, got ${JSON.stringify(operand)}`,
400
- );
401
- }
402
- // An empty YAML key is null, and "set to nothing" is not a value an author means by "exists".
403
- return (actual) => (actual !== undefined && actual !== null) === operand;
404
- }
405
- default: {
406
- if (!isString(operand) && !isNumber(operand)) {
407
- throw new TypeError(
408
- `${where}.${op}: expected a string or number, got ${JSON.stringify(operand)}`,
409
- );
410
- }
411
- const test = (diff: number) =>
412
- op === "$gt" ? diff > 0 : op === "$gte" ? diff >= 0 : op === "$lt" ? diff < 0 : diff <= 0;
413
- return (actual) => {
414
- const diff = order(actual, operand);
415
- return diff !== undefined && test(diff);
416
- };
417
- }
418
- }
419
- }
420
-
421
- /** A condition object is an operator set when every key is `$`-prefixed. */
422
- function operatorKeys(condition: FieldData): string[] | null {
423
- const keys = Object.keys(condition);
424
- const dollars = keys.filter((key) => key.startsWith("$"));
425
- if (dollars.length === 0) {
426
- return null;
427
- }
428
- return dollars.length === keys.length ? keys : [];
429
- }
430
-
431
- function compileCondition(key: string, condition: JsonValue): FieldTest {
432
- const where = `where.${key}`;
433
- if (!isPlainObject(condition)) {
434
- return (actual) => sameValue(actual, condition);
435
- }
436
- const ops = operatorKeys(condition);
437
- if (ops === null) {
438
- // A plain object with no operators is a literal value to compare whole.
439
- return (actual) => deepEqual(comparable(actual), condition);
440
- }
441
- if (ops.length === 0) {
442
- throw new TypeError(`${where}: cannot mix $operators with plain keys in one condition`);
443
- }
444
- const tests = ops.map((op) => {
445
- if (!OPERATORS.has(op)) {
446
- throw new TypeError(`${where}: unknown operator "${op}" (use ${[...OPERATORS].join(", ")})`);
447
- }
448
- return operatorTest(where, op, condition[op]);
449
- });
450
- return (actual) => tests.every((test) => test(actual));
451
- }
452
-
453
- /**
454
- * Compile a `where` filter into a predicate over an entry's frontmatter.
455
- *
456
- * Every key is a frontmatter field (a dotted path reaches into nested data), keys combine with AND,
457
- * and a value is either a literal to equal or an operator object (`$eq`, `$ne`, `$in`, `$nin`,
458
- * `$exists`, `$gt`, `$gte`, `$lt`, `$lte`; several in one object combine with AND too). There is no
459
- * OR and no evaluation of text: a project that needs "either" splits it into two content types.
460
- *
461
- * @param {JsonValue} [where]
462
- * @returns {WherePredicate}
463
- */
464
- export function compileWhere(where?: JsonValue): WherePredicate {
465
- if (where === undefined) {
466
- return Object.assign(() => true, { why: nothing });
467
- }
468
- if (!isPlainObject(where)) {
469
- throw new TypeError(`"where" must be an object mapping frontmatter fields to conditions`);
470
- }
471
- const tests = Object.entries(where).map(([key, condition]) => {
472
- if (key.startsWith("$")) {
473
- throw new TypeError(
474
- `where.${key}: top-level operators are not supported, conditions on different fields already combine with AND`,
475
- );
476
- }
477
- return { key, test: compileCondition(key, condition) };
478
- });
479
- return Object.assign(
480
- (data: FieldData) => tests.every(({ key, test }) => test(readField(data, key))),
481
- {
482
- why: (data: FieldData) => tests.find(({ key, test }) => !test(readField(data, key)))?.key,
483
- },
484
- );
485
- }
package/src/content.ts CHANGED
@@ -6,7 +6,7 @@
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
+ import { isJsonObject, isNonNullObject, isRef, isString } from "@jxsuite/schema/guards";
10
10
  import { localeUrlPrefix, resolveI18n } from "@jxsuite/schema/locale";
11
11
  import type {
12
12
  ContentFieldValue,
@@ -23,7 +23,7 @@ import type { ContentLoaderEntry } from "./types.ts";
23
23
  export interface FilterRule {
24
24
  field: string;
25
25
  op: string;
26
- value?: ContentFieldValue;
26
+ value?: ContentFieldValue | undefined;
27
27
  }
28
28
 
29
29
  /** One sort key, as `ContentCollection.class.json` declares an item of its `sort` parameter. */
@@ -50,12 +50,27 @@ interface ContentProjectContext {
50
50
  config?: ProjectConfig | null;
51
51
  }
52
52
 
53
+ /** The route and resolved state of the page a class is resolving for. */
54
+ interface DocumentContext {
55
+ route?: {
56
+ _pathParams?: Record<string, string>;
57
+ /** The concrete URL of the page being built (`/kb/frappe/bench-operations`). */
58
+ urlPattern?: string;
59
+ /** The language this route serves, when the project declares any (§13.4). */
60
+ locale?: string | null;
61
+ };
62
+ /** The page's state, its earlier class entries already replaced by what they resolved to. */
63
+ state?: JsonObject;
64
+ }
65
+
53
66
  interface CollectionConfig {
54
- contentType?: string;
67
+ /** One content type, or several whose entries are queried as one list. */
68
+ contentType?: string | string[];
55
69
  filter?: FilterRule[] | JsonObject | null;
56
70
  sort?: SortRule | SortRule[] | null;
57
71
  limit?: number | null;
58
72
  _project?: ContentProjectContext;
73
+ _document?: DocumentContext;
59
74
  }
60
75
 
61
76
  interface EntryConfig {
@@ -64,15 +79,7 @@ interface EntryConfig {
64
79
  id?: JsonValue;
65
80
  field?: string;
66
81
  _project?: ContentProjectContext;
67
- _document?: {
68
- route?: {
69
- _pathParams?: Record<string, string>;
70
- /** The concrete URL of the page being built (`/kb/frappe/bench-operations`). */
71
- urlPattern?: string;
72
- /** The language this route serves, when the project declares any (§13.4). */
73
- locale?: string | null;
74
- };
75
- };
82
+ _document?: DocumentContext;
76
83
  }
77
84
 
78
85
  // ─── Query Functions ────────────────────────────────────────────────────────
@@ -85,6 +92,103 @@ function includesValue(
85
92
  return items.includes(value);
86
93
  }
87
94
 
95
+ /**
96
+ * The value a rule's `field` names on an entry (site-architecture.md §6.4).
97
+ *
98
+ * `id` is the entry's id, and a key of the entry's data is that field, a dotted one included. Any
99
+ * other name with a dot is a path through the data: `terms.category.slug` reads `slug` in each item
100
+ * of `terms.category`. A path that crosses an array collects what it finds in every item, so its
101
+ * value is the list of those values (flattened one level, so a list of lists reads as one list),
102
+ * and `contains` then asks whether any item holds the value. A path that finds nothing is
103
+ * `undefined`.
104
+ *
105
+ * @param {ContentLoaderEntry} entry
106
+ * @param {string} field
107
+ * @returns {ContentFieldValue | undefined}
108
+ */
109
+ export function fieldValue(
110
+ entry: ContentLoaderEntry,
111
+ field: string,
112
+ ): ContentFieldValue | undefined {
113
+ if (field === "id") {
114
+ return entry.id;
115
+ }
116
+ if (!field.includes(".") || Object.hasOwn(entry.data, field)) {
117
+ return entry.data[field];
118
+ }
119
+ /* SAFETY: an entry's data is a record of content values, absent keys aside, and only present
120
+ keys are read from it below. */
121
+ let found: ContentFieldValue[] = [entry.data as Record<string, ContentFieldValue>];
122
+ let crossed = false;
123
+ for (const key of field.split(".")) {
124
+ const next: ContentFieldValue[] = [];
125
+ for (const value of found) {
126
+ const items = Array.isArray(value) ? value : [value];
127
+ crossed ||= Array.isArray(value);
128
+ for (const item of items) {
129
+ if (isNonNullObject(item) && !Array.isArray(item) && Object.hasOwn(item, key)) {
130
+ /* SAFETY: an own key of a content value object is itself a content value. */
131
+ next.push((item as Record<string, ContentFieldValue>)[key] as ContentFieldValue);
132
+ }
133
+ }
134
+ }
135
+ found = next;
136
+ }
137
+ if (!crossed) {
138
+ return found[0];
139
+ }
140
+ return found.flatMap((value) => (Array.isArray(value) ? value : [value]));
141
+ }
142
+
143
+ /**
144
+ * A rule's value with a reference to the page resolved (site-architecture.md §6.4):
145
+ * `#/$params/<name>` is the route's parameter, and `#/state/<pointer>` a JSON Pointer into the
146
+ * page's state as it has resolved so far (an entry declared earlier in `state`). Anything else is
147
+ * the value as written; a reference that finds nothing is `undefined`.
148
+ *
149
+ * @param {ContentFieldValue | undefined} value
150
+ * @param {DocumentContext | undefined} document
151
+ * @returns {ContentFieldValue | undefined}
152
+ */
153
+ export function resolveRuleValue(
154
+ value: ContentFieldValue | undefined,
155
+ document: DocumentContext | undefined,
156
+ ): ContentFieldValue | undefined {
157
+ if (!isRef(value)) {
158
+ return value;
159
+ }
160
+ const ref = value.$ref;
161
+ if (ref.startsWith("#/$params/")) {
162
+ return document?.route?._pathParams?.[ref.slice("#/$params/".length)];
163
+ }
164
+ if (!ref.startsWith("#/state/")) {
165
+ return value;
166
+ }
167
+ let current: JsonValue | undefined = document?.state;
168
+ for (const token of ref.slice("#/state/".length).split("/")) {
169
+ const key = token.replaceAll("~1", "/").replaceAll("~0", "~");
170
+ if (Array.isArray(current) && /^(0|[1-9]\d*)$/.test(key)) {
171
+ current = current[Number(key)];
172
+ } else if (isJsonObject(current) && Object.hasOwn(current, key)) {
173
+ current = current[key];
174
+ } else {
175
+ return undefined;
176
+ }
177
+ }
178
+ return current;
179
+ }
180
+
181
+ /**
182
+ * The rules a `filter` declares: explicit rules as written, or the `{ field: value }` shorthand as
183
+ * one `==` rule per key.
184
+ */
185
+ function filterRules(filter: FilterRule[] | JsonObject): FilterRule[] {
186
+ if (Array.isArray(filter)) {
187
+ return filter;
188
+ }
189
+ return Object.entries(filter).map(([field, value]) => ({ field, op: "==", value }));
190
+ }
191
+
88
192
  /**
89
193
  * Evaluate a single filter rule against an entry.
90
194
  *
@@ -93,7 +197,7 @@ function includesValue(
93
197
  * @returns {boolean}
94
198
  */
95
199
  export function evaluateFilterRule(rule: FilterRule, entry: ContentLoaderEntry) {
96
- const actual = rule.field === "id" ? entry.id : entry.data[rule.field];
200
+ const actual = fieldValue(entry, rule.field);
97
201
  switch (rule.op) {
98
202
  case "==": {
99
203
  return actual === rule.value;
@@ -117,6 +221,10 @@ export function evaluateFilterRule(rule: FilterRule, entry: ContentLoaderEntry)
117
221
  if (Array.isArray(actual)) {
118
222
  return !includesValue(actual, rule.value);
119
223
  }
224
+ // A field with no value contains nothing, so an entry without tags is kept by "not contains".
225
+ if (actual == null) {
226
+ return true;
227
+ }
120
228
  return isString(actual) && !actual.includes(String(rule.value ?? ""));
121
229
  }
122
230
  /*
@@ -158,18 +266,7 @@ export function queryContentType(entries: ContentLoaderEntry[], query: ContentQu
158
266
 
159
267
  // Filter
160
268
  if (query.filter) {
161
- let rules: FilterRule[];
162
- if (Array.isArray(query.filter)) {
163
- rules = query.filter;
164
- } else if (isNonNullObject(query.filter)) {
165
- rules = Object.entries(query.filter).map(([field, value]) => ({
166
- field,
167
- op: "==",
168
- value,
169
- }));
170
- } else {
171
- rules = [];
172
- }
269
+ const rules = isNonNullObject(query.filter) ? filterRules(query.filter) : [];
173
270
  result = result.filter((entry) => rules.every((rule) => evaluateFilterRule(rule, entry)));
174
271
  }
175
272
 
@@ -178,8 +275,8 @@ export function queryContentType(entries: ContentLoaderEntry[], query: ContentQu
178
275
  const sortRules = Array.isArray(query.sort) ? query.sort : [query.sort];
179
276
  result.sort((a, b) => {
180
277
  for (const { field, order = "asc" } of sortRules) {
181
- const aVal = field === "id" ? a.id : (a.data[field] ?? "");
182
- const bVal = field === "id" ? b.id : (b.data[field] ?? "");
278
+ const aVal = fieldValue(a, field) ?? "";
279
+ const bVal = fieldValue(b, field) ?? "";
183
280
  if (aVal < bVal) {
184
281
  return order === "asc" ? -1 : 1;
185
282
  }
@@ -227,14 +324,23 @@ export class ContentCollection {
227
324
  }
228
325
 
229
326
  async resolve() {
230
- const { contentType, filter, sort, limit, _project } = this.config;
231
- const entries = await entriesOf(_project?.content, contentType ?? "");
232
- if (!entries) {
327
+ const { contentType, filter, sort, limit, _project, _document } = this.config;
328
+ /* Several types are one list: each type's entries in turn, then filtered, sorted and cut
329
+ together, so a sort interleaves them. A type that does not exist adds nothing. */
330
+ const entries: ContentLoaderEntry[] = [];
331
+ for (const type of Array.isArray(contentType) ? contentType : [contentType ?? ""]) {
332
+ entries.push(...((await entriesOf(_project?.content, type)) ?? []));
333
+ }
334
+ if (entries.length === 0) {
233
335
  return [];
234
336
  }
235
337
  const query: ContentQuery = {};
236
- if (filter != null) {
237
- query.filter = filter;
338
+ if (isNonNullObject(filter)) {
339
+ query.filter = filterRules(filter).map((rule) => ({
340
+ field: rule.field,
341
+ op: rule.op,
342
+ value: resolveRuleValue(rule.value, _document),
343
+ }));
238
344
  }
239
345
  if (sort != null) {
240
346
  query.sort = sort;
package/src/serialize.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  isNumber,
28
28
  isRef,
29
29
  isString,
30
+ isTemplateString,
30
31
  tagNameCandidates,
31
32
  } from "@jxsuite/schema/guards";
32
33
  import type { TagNameSlot } from "@jxsuite/schema/guards";
@@ -561,6 +562,10 @@ function normalizeAllowlist(allowlist?: ReadonlySet<string> | string[]): Readonl
561
562
 
562
563
  /** Check if a Jx element has properties beyond the mdast-compatible ones. */
563
564
  function hasJxProps(el: JxElement) {
565
+ // A computed-children template has no Markdown spelling, so even a `p` needs a directive to hold it.
566
+ if (isTemplateString(el.children)) {
567
+ return true;
568
+ }
564
569
  for (const key of Object.keys(el)) {
565
570
  if (
566
571
  key === "tagName" ||
@@ -923,7 +928,11 @@ function collectDirectiveAttrs(el: JxElement) {
923
928
  continue;
924
929
  }
925
930
  if (key === "children") {
926
- if (isJsonObject(value)) {
931
+ /* An array is the directive's body. A legacy repeater object and a computed-children template
932
+ (spec.md §8.4) have no body to be, so the attribute is the only place they can survive a
933
+ save. Dropping the template wrote `::div` for `::div{children="${item.$children ?? []}"}`,
934
+ and the element lost what it draws with no error anywhere. */
935
+ if (isJsonObject(value) || isTemplateString(value)) {
927
936
  propsObj.children = value;
928
937
  }
929
938
  continue;
package/src/types.ts CHANGED
@@ -44,6 +44,11 @@ export interface ContentTypeDef {
44
44
  exclude?: string[];
45
45
  /** Declarative frontmatter filter: only entries that satisfy it are kept. */
46
46
  where?: JsonObject;
47
+ /**
48
+ * What keeps an entry out of a production build and shows it to authors (site-architecture.md
49
+ * §7.6). Any rule matching holds the entry, and the first names why.
50
+ */
51
+ hold?: JsonObject[];
47
52
  /** Frontmatter field whose value is the entry id (the path-derived id when absent). */
48
53
  idField?: string;
49
54
  /** Route template giving each entry its URL. */