@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/parser",
3
- "version": "4.1.2",
3
+ "version": "4.3.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.2",
52
- "@jxsuite/schema": "^5.0.0",
51
+ "@jxsuite/markup": "^1.0.4",
52
+ "@jxsuite/schema": "^5.2.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.1",
70
+ "@jxsuite/runtime": "^6.1.0",
71
71
  "@jxsuite/test-support": "^0.0.0",
72
72
  "@types/mdast": "^4.0.4",
73
73
  "ajv": "^8.20.0"
@@ -100,16 +100,76 @@
100
100
  "type": "boolean"
101
101
  },
102
102
  "$gt": {
103
- "type": ["string", "number"]
103
+ "anyOf": [
104
+ {
105
+ "type": ["string", "number"]
106
+ },
107
+ {
108
+ "type": "object",
109
+ "description": "The moment the rule is evaluated: the build machine's real clock.",
110
+ "properties": {
111
+ "$now": {
112
+ "const": true
113
+ }
114
+ },
115
+ "required": ["$now"],
116
+ "additionalProperties": false
117
+ }
118
+ ]
104
119
  },
105
120
  "$gte": {
106
- "type": ["string", "number"]
121
+ "anyOf": [
122
+ {
123
+ "type": ["string", "number"]
124
+ },
125
+ {
126
+ "type": "object",
127
+ "description": "The moment the rule is evaluated: the build machine's real clock.",
128
+ "properties": {
129
+ "$now": {
130
+ "const": true
131
+ }
132
+ },
133
+ "required": ["$now"],
134
+ "additionalProperties": false
135
+ }
136
+ ]
107
137
  },
108
138
  "$lt": {
109
- "type": ["string", "number"]
139
+ "anyOf": [
140
+ {
141
+ "type": ["string", "number"]
142
+ },
143
+ {
144
+ "type": "object",
145
+ "description": "The moment the rule is evaluated: the build machine's real clock.",
146
+ "properties": {
147
+ "$now": {
148
+ "const": true
149
+ }
150
+ },
151
+ "required": ["$now"],
152
+ "additionalProperties": false
153
+ }
154
+ ]
110
155
  },
111
156
  "$lte": {
112
- "type": ["string", "number"]
157
+ "anyOf": [
158
+ {
159
+ "type": ["string", "number"]
160
+ },
161
+ {
162
+ "type": "object",
163
+ "description": "The moment the rule is evaluated: the build machine's real clock.",
164
+ "properties": {
165
+ "$now": {
166
+ "const": true
167
+ }
168
+ },
169
+ "required": ["$now"],
170
+ "additionalProperties": false
171
+ }
172
+ ]
113
173
  }
114
174
  }
115
175
  },
@@ -133,6 +193,165 @@
133
193
  }
134
194
  ]
135
195
  },
196
+ "hold": {
197
+ "description": "What keeps an entry out of a production build while authors still see it. Any rule matching holds the entry, and the first names why. `jx build` leaves held entries out, `jx build --include-held`, `jx dev` and Studio keep them. Jx gives no field a meaning of its own: a project names its fields and says here what they imply.",
198
+ "type": "array",
199
+ "items": {
200
+ "type": "object",
201
+ "additionalProperties": false,
202
+ "required": ["label", "when"],
203
+ "properties": {
204
+ "label": {
205
+ "description": "The words every surface shows for an entry this rule holds.",
206
+ "type": "string",
207
+ "minLength": 1
208
+ },
209
+ "when": {
210
+ "type": "object",
211
+ "propertyNames": {
212
+ "pattern": "^[^$]"
213
+ },
214
+ "additionalProperties": {
215
+ "anyOf": [
216
+ {
217
+ "type": "object",
218
+ "propertyNames": {
219
+ "enum": [
220
+ "$eq",
221
+ "$ne",
222
+ "$in",
223
+ "$nin",
224
+ "$exists",
225
+ "$gt",
226
+ "$gte",
227
+ "$lt",
228
+ "$lte"
229
+ ]
230
+ },
231
+ "minProperties": 1,
232
+ "properties": {
233
+ "$eq": {},
234
+ "$ne": {},
235
+ "$in": {
236
+ "type": "array"
237
+ },
238
+ "$nin": {
239
+ "type": "array"
240
+ },
241
+ "$exists": {
242
+ "type": "boolean"
243
+ },
244
+ "$gt": {
245
+ "anyOf": [
246
+ {
247
+ "type": ["string", "number"]
248
+ },
249
+ {
250
+ "type": "object",
251
+ "description": "The moment the rule is evaluated: the build machine's real clock.",
252
+ "properties": {
253
+ "$now": {
254
+ "const": true
255
+ }
256
+ },
257
+ "required": ["$now"],
258
+ "additionalProperties": false
259
+ }
260
+ ]
261
+ },
262
+ "$gte": {
263
+ "anyOf": [
264
+ {
265
+ "type": ["string", "number"]
266
+ },
267
+ {
268
+ "type": "object",
269
+ "description": "The moment the rule is evaluated: the build machine's real clock.",
270
+ "properties": {
271
+ "$now": {
272
+ "const": true
273
+ }
274
+ },
275
+ "required": ["$now"],
276
+ "additionalProperties": false
277
+ }
278
+ ]
279
+ },
280
+ "$lt": {
281
+ "anyOf": [
282
+ {
283
+ "type": ["string", "number"]
284
+ },
285
+ {
286
+ "type": "object",
287
+ "description": "The moment the rule is evaluated: the build machine's real clock.",
288
+ "properties": {
289
+ "$now": {
290
+ "const": true
291
+ }
292
+ },
293
+ "required": ["$now"],
294
+ "additionalProperties": false
295
+ }
296
+ ]
297
+ },
298
+ "$lte": {
299
+ "anyOf": [
300
+ {
301
+ "type": ["string", "number"]
302
+ },
303
+ {
304
+ "type": "object",
305
+ "description": "The moment the rule is evaluated: the build machine's real clock.",
306
+ "properties": {
307
+ "$now": {
308
+ "const": true
309
+ }
310
+ },
311
+ "required": ["$now"],
312
+ "additionalProperties": false
313
+ }
314
+ ]
315
+ }
316
+ }
317
+ },
318
+ {
319
+ "type": ["string", "number", "boolean", "null", "array"]
320
+ },
321
+ {
322
+ "type": "object",
323
+ "propertyNames": {
324
+ "pattern": "^[^$]"
325
+ }
326
+ }
327
+ ]
328
+ },
329
+ "description": "The frontmatter that matches this rule, with the grammar of `where`.",
330
+ "minProperties": 1
331
+ }
332
+ }
333
+ },
334
+ "examples": [
335
+ [
336
+ {
337
+ "label": "Draft",
338
+ "when": {
339
+ "draft": true
340
+ }
341
+ },
342
+ {
343
+ "label": "Scheduled",
344
+ "when": {
345
+ "publishDate": {
346
+ "$gt": {
347
+ "$now": true
348
+ }
349
+ }
350
+ }
351
+ }
352
+ ]
353
+ ]
354
+ },
136
355
  "idField": {
137
356
  "description": "Frontmatter field whose value is the entry id, instead of the path-derived id. An entry without the field keeps its path id and is reported.",
138
357
  "type": "string",
@@ -78,6 +78,10 @@
78
78
  "properties": {
79
79
  "projectConfig": { "type": "object" },
80
80
  "root": { "type": "string" },
81
+ "includeHeld": {
82
+ "type": "boolean",
83
+ "description": "Keep entries a type's `hold` rules match, labelled, rather than leaving them out"
84
+ },
81
85
  "registry": { "type": "object" },
82
86
  "io": { "type": "object" }
83
87
  }
@@ -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",
@@ -55,8 +55,8 @@ import {
55
55
  traversalReason,
56
56
  } from "./content-routes.ts";
57
57
  import type { RouteSpec } from "./content-routes.ts";
58
- import { compileExclude, compileWhere } from "./content-rules.ts";
59
- import type { ExcludeMatcher, WherePredicate } from "./content-rules.ts";
58
+ import { compileExclude, compileHold, compileWhere } from "./content-rules.ts";
59
+ import type { ExcludeMatcher, HoldPredicate, WherePredicate } from "./content-rules.ts";
60
60
  import { coerceEntryDates, isCoercedDate, isDateFormat } from "./dates.ts";
61
61
  import type { ContentNodes } from "./inert.ts";
62
62
  import type { ContentLoaderEntry, ContentTypeDef } from "./types.ts";
@@ -260,16 +260,22 @@ const DOCUMENT_EXTENSIONS = new Set([".md", ".markdown", ".mdx"]);
260
260
  * What a content type's asset mount may serve, or `undefined` for "everything under the directory"
261
261
  * (today's behavior, which every type that declares none of the vault options keeps).
262
262
  *
263
- * A type that declares `exclude`, `where` or `route` is publishing a folder of notes, and there the
264
- * mount is the collection's front door, so it must not be a way around the collection's own rules:
265
- * a file `exclude` names is not served however a page refers to it, nothing whose path has a dot
266
- * segment is (`.git`, `.env` and `.obsidian` are what a source set to the repository root also
267
- * contains), and a document is not (a note `where` left out would otherwise leave as a raw
263
+ * A type that declares `exclude`, `where`, `hold` or `route` is publishing a folder of notes, and
264
+ * there the mount is the collection's front door, so it must not be a way around the collection's
265
+ * own rules: a file `exclude` names is not served however a page refers to it, nothing whose path
266
+ * has a dot segment is (`.git`, `.env` and `.obsidian` are what a source set to the repository root
267
+ * also contains), and a document is not (a note `where` left out would otherwise leave as a raw
268
268
  * download, frontmatter and all). The paths it is asked about are relative to the mount directory,
269
269
  * which is the source directory.
270
270
  */
271
271
  function mountFilterFor(def: ContentTypeDef | undefined): ((path: string) => boolean) | undefined {
272
- if (!def || (def.exclude === undefined && def.where === undefined && def.route === undefined)) {
272
+ if (
273
+ !def ||
274
+ (def.exclude === undefined &&
275
+ def.where === undefined &&
276
+ def.hold === undefined &&
277
+ def.route === undefined)
278
+ ) {
273
279
  return undefined;
274
280
  }
275
281
  let exclude: ExcludeMatcher;
@@ -551,6 +557,8 @@ export function loadContentConfig(projectRoot: string, projectConfig?: ProjectCo
551
557
  * @param {ContentSection} section - The project.json `content` section value
552
558
  * @param {string} root - Project root directory
553
559
  * @param {FormatRegistry} formats - Format-dispatch view of the extension registry
560
+ * @param {ProjectConfig} [projectConfig]
561
+ * @param {boolean} [includeHeld] - Keep entries the `hold` rules match, labelled
554
562
  * @returns {Promise<Map<string, ContentLoaderEntry[]>>} Map of content type name → entries
555
563
  */
556
564
  export async function loadContentSection(
@@ -558,13 +566,14 @@ export async function loadContentSection(
558
566
  root: string,
559
567
  formats: FormatRegistry,
560
568
  projectConfig?: ProjectConfig,
569
+ includeHeld?: boolean,
561
570
  ): Promise<Map<string, ContentLoaderEntry[]>> {
562
571
  const contentTypes = new Map<string, ContentLoaderEntry[]>();
563
572
  const mounts = sectionMounts(section, root, projectConfig);
564
573
  for (const name of Object.keys(section)) {
565
574
  contentTypes.set(
566
575
  name,
567
- await loadSectionType(name, { formats, mounts, projectConfig, root, section }),
576
+ await loadSectionType(name, { formats, includeHeld, mounts, projectConfig, root, section }),
568
577
  );
569
578
  }
570
579
  return contentTypes;
@@ -601,6 +610,8 @@ export interface SectionTypeSource {
601
610
  mounts: Map<string, AssetMount>;
602
611
  /** Leave entry bodies for later, reporting each deferred entry here. */
603
612
  onDeferred?: DeferredBodyHandler | undefined;
613
+ /** Keep entries the type's `hold` rules match, labelled, rather than leaving them out. */
614
+ includeHeld?: boolean | undefined;
604
615
  }
605
616
 
606
617
  /**
@@ -614,7 +625,7 @@ export async function loadSectionType(
614
625
  name: string,
615
626
  source: SectionTypeSource,
616
627
  ): Promise<ContentLoaderEntry[]> {
617
- const { section, root, formats, projectConfig, mounts, onDeferred } = source;
628
+ const { section, root, formats, projectConfig, mounts, onDeferred, includeHeld } = source;
618
629
  const contentTypeDef = section[name];
619
630
  if (!contentTypeDef) {
620
631
  return [];
@@ -623,6 +634,7 @@ export async function loadSectionType(
623
634
  const typeSource = contentTypeDef.source;
624
635
  if (!isString(typeSource) || !typeSource.includes(LOCALE_PLACEHOLDER)) {
625
636
  return await loadContentType(name, contentTypeDef, root, formats, {
637
+ includeHeld,
626
638
  mount: mounts.get(name),
627
639
  projectConfig,
628
640
  ...deferral,
@@ -645,6 +657,7 @@ export async function loadSectionType(
645
657
  for (const locale of locales) {
646
658
  const localized = { ...contentTypeDef, source: localeSource(root, typeSource, locale) };
647
659
  const entries = await loadContentType(name, localized, root, formats, {
660
+ includeHeld,
648
661
  locale,
649
662
  mount: mounts.get(`${name}/${locale}`),
650
663
  projectConfig,
@@ -686,6 +699,7 @@ type LinkMode = "warn" | "error" | "ignore";
686
699
  interface TypeRules {
687
700
  exclude: ExcludeMatcher;
688
701
  where: WherePredicate | null;
702
+ hold: HoldPredicate | null;
689
703
  idField: string | undefined;
690
704
  route: RouteSpec | null;
691
705
  links: LinkMode;
@@ -706,6 +720,7 @@ const KNOWN_TYPE_KEYS = [
706
720
  "alerts",
707
721
  "exclude",
708
722
  "format",
723
+ "hold",
709
724
  "idField",
710
725
  "indexRoute",
711
726
  "links",
@@ -792,6 +807,7 @@ function compileTypeRules(name: string, def: ContentTypeDef): TypeRules {
792
807
  links,
793
808
  route: parseRouteConfig(def.route, def.indexRoute, name),
794
809
  where: def.where === undefined ? null : compileWhere(def.where),
810
+ hold: def.hold === undefined ? null : compileHold(def.hold),
795
811
  };
796
812
  } catch (error) {
797
813
  const message = errorMessage(error);
@@ -831,6 +847,8 @@ interface FinishContext {
831
847
  reload?: ((filePath: string) => Promise<ContentLoaderEntry[]>) | undefined;
832
848
  /** Told of each entry whose body was deferred, with what completes it. */
833
849
  onDeferred?: DeferredBodyHandler | undefined;
850
+ /** Keep entries `hold` matches, labelled, rather than leaving them out. */
851
+ includeHeld?: boolean | undefined;
834
852
  }
835
853
 
836
854
  /**
@@ -845,10 +863,11 @@ export type DeferredBodyHandler = (
845
863
  /**
846
864
  * The collection-wide pass every source kind goes through once its files are loaded.
847
865
  *
848
- * Order matters and is the whole design. `where` runs first, so an entry that is not published is
849
- * never validated, coerced, given a route or counted as a duplicate. Ids are settled before
850
- * anything keyed by id (relationships, duplicate checks, routes that read `{id}`). Routes are
851
- * computed before links, because a link can only be resolved once the page it points at has a URL.
866
+ * Order matters and is the whole design. `where` and `hold` run first, so an entry that is not
867
+ * published is never validated, coerced, given a route or counted as a duplicate. Ids are settled
868
+ * before anything keyed by id (relationships, duplicate checks, routes that read `{id}`). Routes
869
+ * are computed before links, because a link can only be resolved once the page it points at has a
870
+ * URL.
852
871
  *
853
872
  * @returns {ContentLoaderEntry[]} The kept entries, in load order
854
873
  */
@@ -858,6 +877,7 @@ function finishEntries(files: LoadedFile[], ctx: FinishContext): ContentLoaderEn
858
877
  // 1. where: split every file's entries into kept and left out.
859
878
  const kept: LoadedFile[] = [];
860
879
  const unpublished = new Map<string, string>();
880
+ const held: string[] = [];
861
881
  let loadedCount = 0;
862
882
  for (const file of files) {
863
883
  const keep: ContentLoaderEntry[] = [];
@@ -867,8 +887,19 @@ function finishEntries(files: LoadedFile[], ctx: FinishContext): ContentLoaderEn
867
887
  if (file.rel !== null && file.entries.length === 1) {
868
888
  unpublished.set(file.rel, `left out by where.${rules.where.why(entry.data) ?? "filter"}`);
869
889
  }
870
- } else {
890
+ continue;
891
+ }
892
+ const hold = rules.hold?.(entry.data);
893
+ if (hold === undefined) {
871
894
  keep.push(entry);
895
+ } else if (ctx.includeHeld === true) {
896
+ entry._meta = { ...entry._meta, hold };
897
+ keep.push(entry);
898
+ } else {
899
+ held.push(`${file.rel ?? entry.id} (${hold})`);
900
+ if (file.rel !== null && file.entries.length === 1) {
901
+ unpublished.set(file.rel, `held: ${hold}`);
902
+ }
872
903
  }
873
904
  }
874
905
  if (keep.length > 0) {
@@ -886,6 +917,14 @@ function finishEntries(files: LoadedFile[], ctx: FinishContext): ContentLoaderEn
886
917
  );
887
918
  }
888
919
 
920
+ /* Said, not warned: holding an entry is the project's own rule working. It is still said, because
921
+ an entry that disappears from a site with no message reads as a bug. */
922
+ if (held.length > 0) {
923
+ console.info(
924
+ `Content type "${name}": holding ${held.length} from this build: ${held.join(", ")}`,
925
+ );
926
+ }
927
+
889
928
  // 2. Per kept entry: where it came from, and content-relative assets onto the mount.
890
929
  for (const file of kept) {
891
930
  for (const entry of file.entries) {
@@ -1302,6 +1341,8 @@ interface LoadOptions {
1302
1341
  */
1303
1342
  deferBody?: boolean | undefined;
1304
1343
  onDeferred?: DeferredBodyHandler | undefined;
1344
+ /** Keep entries the type's `hold` rules match, labelled, rather than leaving them out. */
1345
+ includeHeld?: boolean | undefined;
1305
1346
  }
1306
1347
 
1307
1348
  /**
@@ -1325,12 +1366,13 @@ async function loadContentType(
1325
1366
  if (!source) {
1326
1367
  return [];
1327
1368
  }
1328
- const { mount, projectConfig, locale, deferBody, onDeferred } = options;
1369
+ const { mount, projectConfig, locale, deferBody, onDeferred, includeHeld } = options;
1329
1370
  const { schema } = contentTypeDef;
1330
1371
  const rules = compileTypeRules(name, contentTypeDef);
1331
1372
  const finishContext = (extra: Partial<FinishContext>): FinishContext => ({
1332
1373
  coerceDates: false,
1333
1374
  entryExtensions: new Set<string>(),
1375
+ includeHeld,
1334
1376
  locale,
1335
1377
  mount,
1336
1378
  name,
@@ -1822,6 +1864,8 @@ export interface ContentStoreSource {
1822
1864
  root: string;
1823
1865
  formats: FormatRegistry;
1824
1866
  projectConfig: ProjectConfig | undefined;
1867
+ /** Keep entries the type's `hold` rules match, labelled, rather than leaving them out. */
1868
+ includeHeld?: boolean | undefined;
1825
1869
  }
1826
1870
 
1827
1871
  export class ContentStore extends Map<string, ContentLoaderEntry[]> implements ContentSectionData {
@@ -1852,6 +1896,7 @@ export class ContentStore extends Map<string, ContentLoaderEntry[]> implements C
1852
1896
  source.root,
1853
1897
  source.formats,
1854
1898
  source.projectConfig,
1899
+ source.includeHeld,
1855
1900
  );
1856
1901
  for (const [name, entries] of loaded) {
1857
1902
  store.#adopt(name, entries);
@@ -1964,8 +2009,9 @@ export class ContentStore extends Map<string, ContentLoaderEntry[]> implements C
1964
2009
  #loadType(name: string): Promise<void> {
1965
2010
  let load = this.#loads.get(name);
1966
2011
  if (!load) {
1967
- const { section, root, formats, projectConfig } = this.#source;
2012
+ const { section, root, formats, projectConfig, includeHeld } = this.#source;
1968
2013
  load = loadSectionType(name, {
2014
+ includeHeld,
1969
2015
  formats,
1970
2016
  mounts: this.#mounts,
1971
2017
  onDeferred: (entry, completeBody) => {
@@ -2045,6 +2091,7 @@ export class Content {
2045
2091
  ): Promise<ContentStore> {
2046
2092
  const source = {
2047
2093
  formats: ctx.registry.formats,
2094
+ includeHeld: ctx.includeHeld,
2048
2095
  projectConfig: ctx.projectConfig,
2049
2096
  root: ctx.root,
2050
2097
  section: sectionValue ?? {},