@jxsuite/schema 5.5.0 → 6.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.
@@ -0,0 +1,570 @@
1
+ /**
2
+ * The structured dynamic tag: the small, closed shape of template expression an editor can show as
3
+ * a named chip and edit through fields, instead of as code (`studio.md` §6.9).
4
+ *
5
+ * A template string (`spec.md` §8.4) holds JavaScript in each `${…}`. Most bindings a site carries
6
+ * are far simpler than JavaScript: a value read from a path, perhaps passed through a named
7
+ * formula, perhaps with a fallback. This module reads exactly that shape and nothing else:
8
+ *
9
+ * ```text
10
+ * tag := "!"{0,2} pipe ( ws ("??" | "||") ws literal )?
11
+ * pipe := "state." ident "(" ws pipe ( ws "," ws literal )* ws ")" | path
12
+ * path := root ( "." ident | "?." ident | "[" key "]" | "?.[" key "]" )*
13
+ * root := "state" | "item" | "index" | "$map" | "$page" | "$site"
14
+ * key := string | number
15
+ * literal := a JSON value | a single-quoted string
16
+ * ident := [A-Za-z_$] [A-Za-z0-9_$]*
17
+ * ws := white space, optional
18
+ * ```
19
+ *
20
+ * It takes the text INSIDE one `${…}`. Three rules hold it together:
21
+ *
22
+ * - **A strict subset of JavaScript.** Every tag is an expression the template engine already
23
+ * evaluates, with the meaning JavaScript gives it: `!` binds the pipe, and the fallback applies
24
+ * to the result. JSON is a subset of JavaScript expressions, and a single-quoted string is
25
+ * admitted only with the escapes JSON also has (plus `\'`), so a literal's value is the same in
26
+ * both. The one JSON value refused is an object with a `__proto__` key, which JavaScript reads as
27
+ * the object's prototype and not as a key.
28
+ * - **Reading never guesses.** Anything outside the grammar (a lambda, a ternary, arithmetic, a
29
+ * parenthesised operand, a chain of two fallbacks, a call on anything but `state.<name>`, a path
30
+ * step after a call) is `null`, which a caller shows as a custom expression and keeps verbatim.
31
+ * - **Reading never normalises.** {@link printDynamicTag} of a parsed tag is the input byte for byte
32
+ * when the input is canonical, and a literal is always printed as it was written (`raw`), so `''`
33
+ * stays `''` and `{"dateStyle":"long"}` keeps its spacing when another part of the tag is
34
+ * edited.
35
+ *
36
+ * **White space.** The canonical form has none around the tag, exactly one space on each side of a
37
+ * fallback operator, `, ` between arguments, and none inside the parentheses. The parser also
38
+ * accepts white space in those four places (and only those: none inside a path, after `!`, or
39
+ * before `(`), and the printer writes the canonical form. So a tag whose spacing is not canonical
40
+ * parses, and printing it would respace it. That is why a writer must keep the source of a tag it
41
+ * did not edit: {@link templateSegments} hands every tag back with its verbatim `source`, and a tag
42
+ * is printed only when the user changed it.
43
+ *
44
+ * Browser-safe: no DOM, no Node.
45
+ *
46
+ * @docs framework/concepts/expressions
47
+ */
48
+
49
+ import { isJsonObject, isNumber, isString, templateExpressions } from "./guards.ts";
50
+
51
+ import type { JsonValue } from "../types.ts";
52
+
53
+ /** What a path may start from: page state, the row of a repeater, and the injected context. */
54
+ export type DynamicTagRoot = "state" | "item" | "index" | "$map" | "$page" | "$site";
55
+
56
+ /** Every {@link DynamicTagRoot}, in the order the grammar lists them. */
57
+ export const DYNAMIC_TAG_ROOTS: readonly DynamicTagRoot[] = [
58
+ "state",
59
+ "item",
60
+ "index",
61
+ "$map",
62
+ "$page",
63
+ "$site",
64
+ ];
65
+
66
+ /**
67
+ * A literal: its value, and the text it was written as. `raw` is what prints, so build one with
68
+ * {@link dynamicTagLiteral} rather than by hand, or the two can disagree.
69
+ */
70
+ export interface DynamicTagLiteral {
71
+ readonly value: JsonValue;
72
+ readonly raw: string;
73
+ }
74
+
75
+ /** One step of a path: `.name`, `?.name`, `[key]` or `?.[key]`. */
76
+ export type DynamicTagStep =
77
+ | { readonly type: "member"; readonly name: string; readonly optional: boolean }
78
+ | { readonly type: "index"; readonly key: DynamicTagLiteral; readonly optional: boolean };
79
+
80
+ /** A value read from a root through zero or more steps. */
81
+ export interface DynamicTagPath {
82
+ readonly type: "path";
83
+ readonly root: DynamicTagRoot;
84
+ readonly steps: readonly DynamicTagStep[];
85
+ }
86
+
87
+ /** A named formula applied to a pipe: `state.<name>(input, ...args)`. */
88
+ export interface DynamicTagCall {
89
+ readonly type: "call";
90
+ readonly name: string;
91
+ readonly input: DynamicTagPipe;
92
+ readonly args: readonly DynamicTagLiteral[];
93
+ }
94
+
95
+ export type DynamicTagPipe = DynamicTagPath | DynamicTagCall;
96
+
97
+ /** The value a tag yields when its pipe is nullish (`??`) or falsy (`||`). */
98
+ export interface DynamicTagFallback {
99
+ readonly operator: "??" | "||";
100
+ readonly value: DynamicTagLiteral;
101
+ }
102
+
103
+ /** One structured tag. */
104
+ export interface DynamicTag {
105
+ /** How many `!` precede the pipe: `!x` is "is empty", `!!x` is "is set". */
106
+ readonly negations: 0 | 1 | 2;
107
+ readonly pipe: DynamicTagPipe;
108
+ readonly fallback?: DynamicTagFallback;
109
+ }
110
+
111
+ /** A literal for a value an editor produced, written the way `JSON.stringify` writes it. */
112
+ export function dynamicTagLiteral(value: JsonValue): DynamicTagLiteral {
113
+ return { raw: JSON.stringify(value), value };
114
+ }
115
+
116
+ // ─── Reading ─────────────────────────────────────────────────────────────────
117
+
118
+ /** Where the reader stands in the expression. */
119
+ interface Cursor {
120
+ readonly text: string;
121
+ at: number;
122
+ }
123
+
124
+ const IDENT = /[A-Za-z_$][\w$]*/y;
125
+ const NUMBER = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
126
+ const KEYWORD = /(?:true|false|null)(?![\w$])/y;
127
+ const SPACE = /\s*/y;
128
+ /** The escapes a single-quoted string may use: JSON's own, and the quote itself. */
129
+ const SINGLE_QUOTED_ESCAPES = String.raw`'"\/bfnrtu`;
130
+
131
+ /** The text a sticky pattern matches at the cursor, which it then passes; `null` for no match. */
132
+ function take(cursor: Cursor, pattern: RegExp): string | null {
133
+ pattern.lastIndex = cursor.at;
134
+ const found = pattern.exec(cursor.text);
135
+ if (found === null) {
136
+ return null;
137
+ }
138
+ cursor.at += found[0].length;
139
+ return found[0];
140
+ }
141
+
142
+ /** Pass `token` when it is next, and say whether it was. */
143
+ function eat(cursor: Cursor, token: string): boolean {
144
+ if (!cursor.text.startsWith(token, cursor.at)) {
145
+ return false;
146
+ }
147
+ cursor.at += token.length;
148
+ return true;
149
+ }
150
+
151
+ /**
152
+ * Whether a parsed JSON value is one a tag may not carry, at any depth:
153
+ *
154
+ * - An object with a `__proto__` key. It is the one place JSON and a JavaScript object literal part
155
+ * ways: JSON makes it an ordinary own key, and the literal sets the object's prototype with it
156
+ * (ECMA-262 Annex B.3.1) and is a SyntaxError when it is written twice.
157
+ * - A number too large to hold (`1e999`), which both languages read as `Infinity`, a value JSON
158
+ * cannot write back.
159
+ */
160
+ function leavesJson(value: JsonValue): boolean {
161
+ if (isNumber(value)) {
162
+ return !Number.isFinite(value);
163
+ }
164
+ if (Array.isArray(value)) {
165
+ return value.some((item) => leavesJson(item));
166
+ }
167
+ if (!isJsonObject(value)) {
168
+ return false;
169
+ }
170
+ return Object.hasOwn(value, "__proto__") || Object.values(value).some((item) => leavesJson(item));
171
+ }
172
+
173
+ /**
174
+ * `JSON.parse`, with `undefined` for text that is not JSON, and for JSON a tag may not carry
175
+ * ({@link leavesJson}).
176
+ */
177
+ function jsonOrUndefined(text: string): JsonValue | undefined {
178
+ try {
179
+ /* SAFETY: `JSON.parse` returns only what JSON can spell, which is `JsonValue`. */
180
+ const value = JSON.parse(text) as JsonValue;
181
+ return leavesJson(value) ? undefined : value;
182
+ } catch {
183
+ return undefined;
184
+ }
185
+ }
186
+
187
+ /** One past the quote that closes the string opening at `start`, or -1 when nothing closes it. */
188
+ function stringEnd(text: string, start: number): number {
189
+ const quote = text.charAt(start);
190
+ for (let at = start + 1; at < text.length; at += 1) {
191
+ const char = text.charAt(at);
192
+ if (char === "\\") {
193
+ at += 1;
194
+ } else if (char === quote) {
195
+ return at + 1;
196
+ }
197
+ }
198
+ return -1;
199
+ }
200
+
201
+ /**
202
+ * One past the bracket that closes the array or object opening at `start`, or -1. Only finds the
203
+ * extent: whether the text is JSON is `JSON.parse`'s verdict.
204
+ */
205
+ function compositeEnd(text: string, start: number): number {
206
+ let depth = 0;
207
+ let at = start;
208
+ while (at < text.length) {
209
+ const char = text.charAt(at);
210
+ if (char === '"') {
211
+ const end = stringEnd(text, at);
212
+ if (end === -1) {
213
+ return -1;
214
+ }
215
+ at = end;
216
+ } else {
217
+ if (char === "[" || char === "{") {
218
+ depth += 1;
219
+ } else if (char === "]" || char === "}") {
220
+ depth -= 1;
221
+ if (depth === 0) {
222
+ return at + 1;
223
+ }
224
+ }
225
+ at += 1;
226
+ }
227
+ }
228
+ return -1;
229
+ }
230
+
231
+ /**
232
+ * The value of a single-quoted string, or `undefined` when it uses an escape JSON has no spelling
233
+ * for (`\x41`, `\0`, a line continuation), which keeps the literal's value identical in both
234
+ * languages.
235
+ */
236
+ function singleQuotedValue(raw: string): JsonValue | undefined {
237
+ const body = raw.slice(1, -1);
238
+ let json = "";
239
+ for (let at = 0; at < body.length; at += 1) {
240
+ const char = body.charAt(at);
241
+ if (char === "\\") {
242
+ const escaped = body.charAt(at + 1);
243
+ if (!SINGLE_QUOTED_ESCAPES.includes(escaped)) {
244
+ return undefined;
245
+ }
246
+ json += escaped === "'" ? "'" : `\\${escaped}`;
247
+ at += 1;
248
+ } else {
249
+ json += char === '"' ? String.raw`\"` : char;
250
+ }
251
+ }
252
+ return jsonOrUndefined(`"${json}"`);
253
+ }
254
+
255
+ /** Where the literal starting at `start` ends, by its first character; -1 when it does not. */
256
+ function literalEnd(cursor: Cursor): number {
257
+ const start = cursor.at;
258
+ const first = cursor.text.charAt(start);
259
+ if (first === '"' || first === "'") {
260
+ return stringEnd(cursor.text, start);
261
+ }
262
+ if (first === "[" || first === "{") {
263
+ return compositeEnd(cursor.text, start);
264
+ }
265
+ const scalar = take(cursor, NUMBER) ?? take(cursor, KEYWORD);
266
+ cursor.at = start;
267
+ return scalar === null ? -1 : start + scalar.length;
268
+ }
269
+
270
+ function readLiteral(cursor: Cursor): DynamicTagLiteral | null {
271
+ const end = literalEnd(cursor);
272
+ if (end === -1) {
273
+ return null;
274
+ }
275
+ const raw = cursor.text.slice(cursor.at, end);
276
+ const value = raw.startsWith("'") ? singleQuotedValue(raw) : jsonOrUndefined(raw);
277
+ if (value === undefined) {
278
+ return null;
279
+ }
280
+ cursor.at = end;
281
+ return { raw, value };
282
+ }
283
+
284
+ /** The `[key]` after an opening bracket already passed: a string or a number, then `]`. */
285
+ function readIndex(cursor: Cursor, optional: boolean): DynamicTagStep | null {
286
+ const key = readLiteral(cursor);
287
+ if (key === null || !(isString(key.value) || isNumber(key.value))) {
288
+ return null;
289
+ }
290
+ return eat(cursor, "]") ? { key, optional, type: "index" } : null;
291
+ }
292
+
293
+ /** The step at the cursor; `undefined` when the path ends here, `null` when it is malformed. */
294
+ function readStep(cursor: Cursor): DynamicTagStep | null | undefined {
295
+ const optional = eat(cursor, "?.");
296
+ if (eat(cursor, "[")) {
297
+ return readIndex(cursor, optional);
298
+ }
299
+ if (!optional && !eat(cursor, ".")) {
300
+ return undefined;
301
+ }
302
+ const name = take(cursor, IDENT);
303
+ return name === null ? null : { name, optional, type: "member" };
304
+ }
305
+
306
+ function readPath(cursor: Cursor): DynamicTagPath | null {
307
+ const name = take(cursor, IDENT);
308
+ const root = DYNAMIC_TAG_ROOTS.find((candidate) => candidate === name);
309
+ if (root === undefined) {
310
+ return null;
311
+ }
312
+ const steps: DynamicTagStep[] = [];
313
+ for (;;) {
314
+ const step = readStep(cursor);
315
+ if (step === null) {
316
+ return null;
317
+ }
318
+ if (step === undefined) {
319
+ return { root, steps, type: "path" };
320
+ }
321
+ steps.push(step);
322
+ }
323
+ }
324
+
325
+ /** The rest of a call whose `state.<name>(` is already passed. */
326
+ function readCall(cursor: Cursor, name: string): DynamicTagCall | null {
327
+ take(cursor, SPACE);
328
+ const input = readPipe(cursor);
329
+ if (input === null) {
330
+ return null;
331
+ }
332
+ const args: DynamicTagLiteral[] = [];
333
+ for (;;) {
334
+ take(cursor, SPACE);
335
+ if (eat(cursor, ")")) {
336
+ return { args, input, name, type: "call" };
337
+ }
338
+ if (!eat(cursor, ",")) {
339
+ return null;
340
+ }
341
+ take(cursor, SPACE);
342
+ const arg = readLiteral(cursor);
343
+ if (arg === null) {
344
+ return null;
345
+ }
346
+ args.push(arg);
347
+ }
348
+ }
349
+
350
+ function readPipe(cursor: Cursor): DynamicTagPipe | null {
351
+ const start = cursor.at;
352
+ if (eat(cursor, "state.")) {
353
+ const name = take(cursor, IDENT);
354
+ if (name !== null && eat(cursor, "(")) {
355
+ return readCall(cursor, name);
356
+ }
357
+ cursor.at = start;
358
+ }
359
+ return readPath(cursor);
360
+ }
361
+
362
+ function readFallback(cursor: Cursor): DynamicTagFallback | null {
363
+ let operator: DynamicTagFallback["operator"] | null = null;
364
+ if (eat(cursor, "??")) {
365
+ operator = "??";
366
+ } else if (eat(cursor, "||")) {
367
+ operator = "||";
368
+ }
369
+ if (operator === null) {
370
+ return null;
371
+ }
372
+ take(cursor, SPACE);
373
+ const value = readLiteral(cursor);
374
+ return value === null ? null : { operator, value };
375
+ }
376
+
377
+ /**
378
+ * Read the expression inside one `${…}` as a structured tag, or `null` when it is anything else.
379
+ *
380
+ * `null` is not an error: it says the expression is custom code, to be shown as such and stored as
381
+ * it stands. See the module comment for the grammar and for how white space is treated.
382
+ *
383
+ * @param expr The text between the braces, without them.
384
+ */
385
+ export function parseDynamicTag(expr: string): DynamicTag | null {
386
+ const cursor: Cursor = { at: 0, text: expr };
387
+ take(cursor, SPACE);
388
+ let negations: DynamicTag["negations"] = 0;
389
+ if (eat(cursor, "!")) {
390
+ negations = eat(cursor, "!") ? 2 : 1;
391
+ }
392
+ const pipe = readPipe(cursor);
393
+ if (pipe === null) {
394
+ return null;
395
+ }
396
+ take(cursor, SPACE);
397
+ if (cursor.at === expr.length) {
398
+ return { negations, pipe };
399
+ }
400
+ const fallback = readFallback(cursor);
401
+ take(cursor, SPACE);
402
+ if (fallback === null || cursor.at !== expr.length) {
403
+ return null;
404
+ }
405
+ return { fallback, negations, pipe };
406
+ }
407
+
408
+ // ─── Writing ─────────────────────────────────────────────────────────────────
409
+
410
+ function printStep(step: DynamicTagStep): string {
411
+ const optional = step.optional ? "?." : "";
412
+ if (step.type === "index") {
413
+ return `${optional}[${step.key.raw}]`;
414
+ }
415
+ return `${optional || "."}${step.name}`;
416
+ }
417
+
418
+ function printPipe(pipe: DynamicTagPipe): string {
419
+ if (pipe.type === "path") {
420
+ return pipe.root + pipe.steps.map((step) => printStep(step)).join("");
421
+ }
422
+ const args = [printPipe(pipe.input), ...pipe.args.map((arg) => arg.raw)];
423
+ return `state.${pipe.name}(${args.join(", ")})`;
424
+ }
425
+
426
+ /**
427
+ * Write a tag as the expression that goes inside `${…}`, in the canonical form: for a tag read from
428
+ * canonical text and not changed, the text it was read from.
429
+ */
430
+ export function printDynamicTag(tag: DynamicTag): string {
431
+ const fallback = tag.fallback ? ` ${tag.fallback.operator} ${tag.fallback.value.raw}` : "";
432
+ return "!".repeat(tag.negations) + printPipe(tag.pipe) + fallback;
433
+ }
434
+
435
+ // ─── Describing ──────────────────────────────────────────────────────────────
436
+
437
+ /** The path a tag reads: its pipe with every formula call peeled off. */
438
+ export function dynamicTagPath(tag: DynamicTag): DynamicTagPath {
439
+ let { pipe } = tag;
440
+ while (pipe.type === "call") {
441
+ pipe = pipe.input;
442
+ }
443
+ return pipe;
444
+ }
445
+
446
+ /** `field` for a tag that prints a value as stored, `format` for one that runs it through a formula. */
447
+ export function dynamicTagKind(tag: DynamicTag): "field" | "format" {
448
+ return tag.pipe.type === "call" ? "format" : "field";
449
+ }
450
+
451
+ /** A row and a page are named for what they are, since their root words say little to an author. */
452
+ const ROOT_LABELS: Readonly<Record<DynamicTagRoot, string>> = {
453
+ $map: "Item",
454
+ $page: "Page",
455
+ $site: "Site",
456
+ index: "Row number",
457
+ item: "Item",
458
+ state: "State",
459
+ };
460
+
461
+ /**
462
+ * Step names that say where a value is kept, not what it is: `entry.data.title` is the title, and
463
+ * `entry.data` is the entry.
464
+ */
465
+ const CONTAINER_NAMES = new Set(["data"]);
466
+
467
+ /** The name a step contributes to a label, or `null` for a numeric index or a container word. */
468
+ function stepName(step: DynamicTagStep): string | null {
469
+ const name = step.type === "member" ? step.name : step.key.value;
470
+ if (!isString(name) || CONTAINER_NAMES.has(name)) {
471
+ return null;
472
+ }
473
+ return name;
474
+ }
475
+
476
+ /**
477
+ * A name as words: `featuredImage`, `featured_image`, `featured-image` and `$featuredImage` are all
478
+ * "Featured image". A run of capitals is kept as written (`seoURL` is "Seo URL").
479
+ */
480
+ export function humanizeIdentifier(name: string): string {
481
+ const words = name
482
+ .replaceAll(/([a-z\d])([A-Z])/g, "$1 $2")
483
+ .replaceAll(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
484
+ .split(/[^A-Za-z\d]+/)
485
+ .filter((word) => word !== "")
486
+ .map((word) => (/^[A-Z\d]{2,}$/.test(word) ? word : word.toLowerCase()));
487
+ const text = words.join(" ");
488
+ return text.charAt(0).toUpperCase() + text.slice(1);
489
+ }
490
+
491
+ /**
492
+ * A short human name for a tag: its last meaningful path segment as words. `$map.item.data.date` is
493
+ * "Date", `state.entry.data` is "Entry", and a path with nothing to name (`$map`, `state[0]`) is
494
+ * named for its root. A caller that knows the content schema's own title for the field prefers
495
+ * that; this is the answer when there is none.
496
+ */
497
+ export function dynamicTagLabel(tag: DynamicTag): string {
498
+ const path = dynamicTagPath(tag);
499
+ for (const step of path.steps.toReversed()) {
500
+ const name = stepName(step);
501
+ const label = name === null ? "" : humanizeIdentifier(name);
502
+ if (label !== "") {
503
+ return label;
504
+ }
505
+ }
506
+ return ROOT_LABELS[path.root];
507
+ }
508
+
509
+ // ─── Templates ───────────────────────────────────────────────────────────────
510
+
511
+ /**
512
+ * One run of a template string. `start` and `end` are offsets into the template, and the runs of a
513
+ * template cover it exactly: joining each `text` or `source` in order gives the template back.
514
+ *
515
+ * - `literal`: text between expressions, as written (a `\${` escape is still two characters).
516
+ * - `tag`: a `${…}` whose expression is a structured tag. `source` is the whole `${…}` verbatim.
517
+ * - `custom`: a `${…}` holding any other expression.
518
+ */
519
+ export type TemplateSegment =
520
+ | {
521
+ readonly type: "literal";
522
+ readonly start: number;
523
+ readonly end: number;
524
+ readonly text: string;
525
+ }
526
+ | {
527
+ readonly type: "tag";
528
+ readonly start: number;
529
+ readonly end: number;
530
+ readonly source: string;
531
+ readonly expr: string;
532
+ readonly tag: DynamicTag;
533
+ }
534
+ | {
535
+ readonly type: "custom";
536
+ readonly start: number;
537
+ readonly end: number;
538
+ readonly source: string;
539
+ readonly expr: string;
540
+ };
541
+
542
+ /**
543
+ * A template string as literal text, structured tags and custom expressions, in order. Where each
544
+ * `${…}` ends is {@link templateExpressions}' answer, so this agrees with the engine that evaluates
545
+ * the template. An empty template has no segments.
546
+ *
547
+ * @param template The template string, without the backticks of a literal.
548
+ */
549
+ export function templateSegments(template: string): TemplateSegment[] {
550
+ const segments: TemplateSegment[] = [];
551
+ let at = 0;
552
+ const literalUpTo = (end: number): void => {
553
+ if (end > at) {
554
+ segments.push({ end, start: at, text: template.slice(at, end), type: "literal" });
555
+ }
556
+ };
557
+ for (const { start, end, expr } of templateExpressions(template)) {
558
+ literalUpTo(start);
559
+ const source = template.slice(start, end);
560
+ const tag = parseDynamicTag(expr);
561
+ segments.push(
562
+ tag === null
563
+ ? { end, expr, source, start, type: "custom" }
564
+ : { end, expr, source, start, tag, type: "tag" },
565
+ );
566
+ at = end;
567
+ }
568
+ literalUpTo(template.length);
569
+ return segments;
570
+ }