@jxsuite/parser 4.2.0 → 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.
@@ -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
  }
@@ -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 ?? {},
@@ -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/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. */