@jxsuite/parser 4.1.1 → 4.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/parser",
3
- "version": "4.1.1",
3
+ "version": "4.2.0",
4
4
  "description": "Jx markdown parser and external class integration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -48,11 +48,11 @@
48
48
  "upgrade": "bun update --latest && bun install"
49
49
  },
50
50
  "dependencies": {
51
- "@jxsuite/markup": "^1.0.1",
52
- "@jxsuite/schema": "^4.1.0",
51
+ "@jxsuite/markup": "^1.0.3",
52
+ "@jxsuite/schema": "^5.1.0",
53
53
  "@shikijs/langs": "^4.5.0",
54
54
  "@shikijs/themes": "^4.5.0",
55
- "glob": "^13.0.6",
55
+ "glob": "^14.0.3",
56
56
  "mdast-util-to-string": "^4.0.0",
57
57
  "remark-directive": "^4.0.0",
58
58
  "remark-frontmatter": "^5.0.0",
@@ -67,7 +67,7 @@
67
67
  },
68
68
  "devDependencies": {
69
69
  "@happy-dom/global-registrator": "^21.0.4",
70
- "@jxsuite/runtime": "^6.0.0",
70
+ "@jxsuite/runtime": "^6.0.2",
71
71
  "@jxsuite/test-support": "^0.0.0",
72
72
  "@types/mdast": "^4.0.4",
73
73
  "ajv": "^8.20.0"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "title": "ContentCollection",
4
- "description": "Query a project content type with filter, sort, and limit",
4
+ "description": "Query one or more project content types with filter, sort, and limit",
5
5
  "$prototype": "Class",
6
6
  "extends": "Object",
7
7
  "$implementation": "../dist/content.js",
@@ -11,7 +11,7 @@
11
11
  "contentType": {
12
12
  "identifier": "contentType",
13
13
  "type": { "type": "string", "enum": { "$ref": "#/$context/content" } },
14
- "description": "Content type to query"
14
+ "description": "Content type to query. A list of types (written in JSON) is queried as one list, filtered, sorted and cut together"
15
15
  },
16
16
  "filter": {
17
17
  "identifier": "filter",
@@ -40,12 +40,14 @@
40
40
  "not empty"
41
41
  ]
42
42
  },
43
- "value": {}
43
+ "value": {
44
+ "description": "The value to compare with, or a reference resolved for the page: { \"$ref\": \"#/$params/<name>\" } is a route parameter and { \"$ref\": \"#/state/<pointer>\" } a value in an entry declared earlier in state"
45
+ }
44
46
  },
45
47
  "required": ["field", "op"]
46
48
  }
47
49
  },
48
- "description": "Filter rules"
50
+ "description": "Filter rules. A field may be a dotted path through the entry's data (terms.category.slug); a path that crosses an array collects every item's value"
49
51
  },
50
52
  "sort": {
51
53
  "identifier": "sort",
@@ -64,7 +66,7 @@
64
66
  "required": ["field"]
65
67
  }
66
68
  },
67
- "description": "Sort rules"
69
+ "description": "Sort rules. A field may be a dotted path, as in a filter rule"
68
70
  },
69
71
  "limit": {
70
72
  "identifier": "limit",
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;