@jxsuite/schema 1.5.0 → 1.8.1
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/class-schema.json +1 -0
- package/defs/children-value.schema.ts +10 -1
- package/defs/class-def.schema.ts +1 -0
- package/defs/element-def.schema.ts +1 -1
- package/defs/expression-node.schema.ts +4 -1
- package/defs/external-class-def.schema.ts +20 -2
- package/defs/head-entry.schema.ts +6 -1
- package/defs/image-config.schema.ts +7 -0
- package/defs/index.ts +2 -1
- package/defs/project-config.schema.ts +299 -7
- package/defs/tag-expression.schema.ts +98 -0
- package/defs/tag-name.schema.ts +13 -1
- package/package.json +8 -3
- package/project-schema.json +441 -5
- package/schema.json +186 -11
- package/schemas/project.core.schema.json +401 -5
- package/src/asset-paths.ts +21 -0
- package/src/component-meta.ts +143 -0
- package/src/extension-registry.ts +12 -0
- package/src/format-registry.ts +32 -2
- package/src/guards.ts +62 -1
- package/src/ijson.ts +205 -0
- package/src/intl.ts +155 -0
- package/src/locale.ts +429 -0
- package/src/media-type.ts +217 -0
- package/src/parse.ts +78 -1
- package/src/schema.ts +56 -3
- package/types.ts +191 -4
package/src/format-registry.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* (fetch + dynamic import) hosts.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { mediaTypeEssence, mediaTypeProblem } from "./media-type";
|
|
14
|
+
|
|
13
15
|
export type FormatCapability = "parse" | "serialize" | "discover" | "load";
|
|
14
16
|
|
|
15
17
|
export const FORMAT_CAPABILITIES: readonly FormatCapability[] = [
|
|
@@ -22,8 +24,9 @@ export const FORMAT_CAPABILITIES: readonly FormatCapability[] = [
|
|
|
22
24
|
/**
|
|
23
25
|
* All well-known static capability roles across admission blocks (specs/extensions.md §8): format
|
|
24
26
|
* roles plus project-section (`projectData`, `resolvePaths`), compile-away (`lower`), build-time
|
|
25
|
-
* asset emission (`emit`),
|
|
26
|
-
* (`dialect`, `deploySchema`, `bindings`, `testConnection`)
|
|
27
|
+
* asset emission (`emit`), `<head>` contribution (`head`), static asset mounts (`assets`),
|
|
28
|
+
* server-mount (`mount`), and connector (`dialect`, `deploySchema`, `bindings`, `testConnection`)
|
|
29
|
+
* roles.
|
|
27
30
|
*/
|
|
28
31
|
export type ExtensionCapability =
|
|
29
32
|
| FormatCapability
|
|
@@ -31,6 +34,7 @@ export type ExtensionCapability =
|
|
|
31
34
|
| "resolvePaths"
|
|
32
35
|
| "lower"
|
|
33
36
|
| "emit"
|
|
37
|
+
| "head"
|
|
34
38
|
| "assets"
|
|
35
39
|
| "mount"
|
|
36
40
|
| "dialect"
|
|
@@ -44,6 +48,7 @@ export const EXTENSION_CAPABILITIES: readonly ExtensionCapability[] = [
|
|
|
44
48
|
"resolvePaths",
|
|
45
49
|
"lower",
|
|
46
50
|
"emit",
|
|
51
|
+
"head",
|
|
47
52
|
"assets",
|
|
48
53
|
"mount",
|
|
49
54
|
"dialect",
|
|
@@ -160,6 +165,15 @@ export class FormatEntry {
|
|
|
160
165
|
readonly classDef: ClassDefLike;
|
|
161
166
|
readonly extensions: string[];
|
|
162
167
|
readonly mediaType: string | null;
|
|
168
|
+
/**
|
|
169
|
+
* The media type without its parameters — `text/markdown` for `text/markdown; variant=GFM`.
|
|
170
|
+
*
|
|
171
|
+
* Anything that keys on a type rather than describing one wants this: a File System Access
|
|
172
|
+
* `accept` map, an editor language id. Those broke the moment a format declared the `variant`
|
|
173
|
+
* parameter RFC 7763 defines, which is a good reason for the distinction to exist in the type
|
|
174
|
+
* rather than in each caller's split.
|
|
175
|
+
*/
|
|
176
|
+
readonly mediaTypeEssence: string | null;
|
|
163
177
|
readonly documentKinds: FormatDocumentKind[];
|
|
164
178
|
readonly exportTarget: boolean;
|
|
165
179
|
readonly remote: boolean;
|
|
@@ -183,6 +197,7 @@ export class FormatEntry {
|
|
|
183
197
|
const { format } = classDef;
|
|
184
198
|
this.extensions = (format?.extensions ?? []).map((e) => e.toLowerCase());
|
|
185
199
|
this.mediaType = format?.mediaType ?? null;
|
|
200
|
+
this.mediaTypeEssence = mediaTypeEssence(this.mediaType);
|
|
186
201
|
this.documentKinds = format?.documentKinds ?? [];
|
|
187
202
|
this.exportTarget = format?.exportTarget === true;
|
|
188
203
|
this.remote = format?.remote === true;
|
|
@@ -234,6 +249,21 @@ export class FormatRegistry {
|
|
|
234
249
|
constructor(entries: FormatEntry[]) {
|
|
235
250
|
this.#entries = entries;
|
|
236
251
|
|
|
252
|
+
/*
|
|
253
|
+
* A declared media type reaches an HTTP header, an editor's file association and a Studio
|
|
254
|
+
* label. Nothing checked it before, so `text/markdown;variant GFM` — one missing `=` — would
|
|
255
|
+
* have been served to a browser verbatim as a malformed header value (RFC 6838 §4.2).
|
|
256
|
+
*/
|
|
257
|
+
for (const entry of entries) {
|
|
258
|
+
if (entry.mediaType === null) {
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const problem = mediaTypeProblem(entry.mediaType);
|
|
262
|
+
if (problem !== null) {
|
|
263
|
+
throw new Error(`Format "${entry.name}" declares an invalid mediaType: ${problem}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
237
267
|
// Ambiguous (extension, capability) claims are a configuration error.
|
|
238
268
|
for (const cap of FORMAT_CAPABILITIES) {
|
|
239
269
|
const seen = new Map<string, FormatEntry>();
|
package/src/guards.ts
CHANGED
|
@@ -19,9 +19,10 @@ import type {
|
|
|
19
19
|
JxPrototypeDef,
|
|
20
20
|
JxRef,
|
|
21
21
|
JxServerFnDef,
|
|
22
|
-
JxStatement,
|
|
23
22
|
JxStateObject,
|
|
23
|
+
JxStatement,
|
|
24
24
|
JxStyle,
|
|
25
|
+
JxTagExpression,
|
|
25
26
|
} from "../types";
|
|
26
27
|
|
|
27
28
|
// ─── JSON shape guards ──────────────────────────────────────────────────────────
|
|
@@ -241,3 +242,63 @@ export function paramNames(parameters: JxFunctionDef["parameters"]): string[] {
|
|
|
241
242
|
}
|
|
242
243
|
return parameters.map((p) => (typeof p === "string" ? p : p.name));
|
|
243
244
|
}
|
|
245
|
+
|
|
246
|
+
/** Whether an element's `tagName` is a choice rather than a name. */
|
|
247
|
+
export function isTagExpression(tagName: unknown): tagName is { $expression: JxTagExpression } {
|
|
248
|
+
return isJsonObject(tagName) && isJsonObject(tagName.$expression);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Every tag an element could be, without evaluating anything.
|
|
253
|
+
*
|
|
254
|
+
* ONE implementation, shared by the runtime, the three compiler targets and the studio, because
|
|
255
|
+
* four surfaces each writing their own enumeration is four chances to disagree about what an
|
|
256
|
+
* element can be — and disagreeing about that is how the `${…}` tagName shipped a page whose SSR
|
|
257
|
+
* markup and client render used different elements.
|
|
258
|
+
*
|
|
259
|
+
* A plain name yields itself, so every caller can treat the two forms alike.
|
|
260
|
+
*/
|
|
261
|
+
export function tagNameCandidates(tagName: unknown): string[] {
|
|
262
|
+
if (typeof tagName === "string") {
|
|
263
|
+
return [tagName];
|
|
264
|
+
}
|
|
265
|
+
if (!isTagExpression(tagName)) {
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
const expression = tagName.$expression;
|
|
269
|
+
if (expression.operator === "?:") {
|
|
270
|
+
return [...new Set([expression.value, expression.initial])];
|
|
271
|
+
}
|
|
272
|
+
return [...new Set([...Object.values(expression.cases), expression.default])];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The tag to show a human when the element could be several.
|
|
277
|
+
*
|
|
278
|
+
* The Outline draws one row per node and needs a label that is stable, short and honest: `a|div`
|
|
279
|
+
* says "this is one element whose tag is chosen" without pretending to know which. Rendering the
|
|
280
|
+
* first candidate would have been a quieter lie.
|
|
281
|
+
*/
|
|
282
|
+
export function displayTagName(tagName: unknown): string {
|
|
283
|
+
return typeof tagName === "string" ? tagName : tagNameCandidates(tagName).join("|");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Whether a `state` key is private (`spec.md` §5.6).
|
|
288
|
+
*
|
|
289
|
+
* A `#`-prefixed entry is ordinary state in every respect but one: it is not part of the
|
|
290
|
+
* component's interface. It never reaches the studio property panel, never appears in an exported
|
|
291
|
+
* CEM manifest, and is never settable through `$props` — which is the clause that needs a predicate
|
|
292
|
+
* rather than a convention, because the props paths are spread across two tiers.
|
|
293
|
+
*
|
|
294
|
+
* The rule lived inline at three call sites before it lived here, spelled `key.startsWith("#")`
|
|
295
|
+
* each time. That is survivable for two and a drift risk at four, and the interesting direction of
|
|
296
|
+
* drift is silent: a path that forgets the check does not fail, it just quietly makes a private
|
|
297
|
+
* entry writable from outside.
|
|
298
|
+
*
|
|
299
|
+
* @param {string} key - A `state` key
|
|
300
|
+
* @returns {boolean}
|
|
301
|
+
*/
|
|
302
|
+
export function isPrivateStateKey(key: string): boolean {
|
|
303
|
+
return key.startsWith("#");
|
|
304
|
+
}
|
package/src/ijson.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* I-JSON (RFC 7493): the two ways a JSON document can parse cleanly and still mean something other
|
|
3
|
+
* than what it says.
|
|
4
|
+
*
|
|
5
|
+
* Both matter here more than they would elsewhere, because a Jx document does not stay JSON. It
|
|
6
|
+
* round-trips through markdown frontmatter and through a Yjs CRDT, and each crossing rebuilds the
|
|
7
|
+
* object from the parsed value — so anything `JSON.parse` quietly discarded is discarded for good,
|
|
8
|
+
* and the file the author reopens is not the file they wrote.
|
|
9
|
+
*
|
|
10
|
+
* - **Duplicate names** (§2.3). `JSON.parse` keeps the last and says nothing. In a hand-edited
|
|
11
|
+
* document — two `state` keys after a bad merge, say — the first one's contents simply vanish.
|
|
12
|
+
* - **Numbers outside the IEEE 754 double range** (§2.2). `9007199254740993` parses to
|
|
13
|
+
* `9007199254740992`, and the next serialization writes the wrong number back to disk.
|
|
14
|
+
*
|
|
15
|
+
* Implemented as a scan of the source text rather than a `JSON.parse` reviver, because by the time
|
|
16
|
+
* a reviver runs the duplicate is already gone. The scanner is deliberately small: it only needs to
|
|
17
|
+
* know where strings and object keys are, so it does not build a tree or validate the grammar —
|
|
18
|
+
* `JSON.parse` has already done that by the time this is called.
|
|
19
|
+
*
|
|
20
|
+
* @docs framework/agents/authoring-rules
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** One I-JSON violation, located by the path of the object that contains it. */
|
|
24
|
+
export interface IJsonProblem {
|
|
25
|
+
kind: "duplicate-key" | "unsafe-number";
|
|
26
|
+
/** Dotted path to the containing object, `""` at the root. */
|
|
27
|
+
path: string;
|
|
28
|
+
/** The duplicated key, or the number literal as written. */
|
|
29
|
+
detail: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const WHITESPACE = new Set([" ", "\t", "\n", "\r"]);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Find I-JSON violations in already-valid JSON text.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} text - JSON source that `JSON.parse` accepts
|
|
38
|
+
* @returns {IJsonProblem[]} Empty when the document is I-JSON
|
|
39
|
+
*/
|
|
40
|
+
export function findIJsonProblems(text: string): IJsonProblem[] {
|
|
41
|
+
const problems: IJsonProblem[] = [];
|
|
42
|
+
/**
|
|
43
|
+
* One frame per open `{` or `[`. Arrays carry a path too, so an object inside one is reported
|
|
44
|
+
* under the key that names the array rather than under whatever enclosed it.
|
|
45
|
+
*/
|
|
46
|
+
const stack: { kind: "object" | "array"; path: string; keys?: Set<string> }[] = [];
|
|
47
|
+
/** The key most recently read, naming the value that follows it. */
|
|
48
|
+
let pendingKey: string | null = null;
|
|
49
|
+
let index = 0;
|
|
50
|
+
|
|
51
|
+
const currentPath = () => stack.at(-1)?.path ?? "";
|
|
52
|
+
const childPath = () =>
|
|
53
|
+
pendingKey === null ? currentPath() : joinPath(currentPath(), pendingKey);
|
|
54
|
+
|
|
55
|
+
const readString = (): string => {
|
|
56
|
+
// Assumes text[index] === '"'; returns the decoded value and leaves index past the closer.
|
|
57
|
+
index += 1;
|
|
58
|
+
let out = "";
|
|
59
|
+
while (index < text.length) {
|
|
60
|
+
const char = text[index]!;
|
|
61
|
+
if (char === "\\") {
|
|
62
|
+
const next = text[index + 1] ?? "";
|
|
63
|
+
// Only `\uXXXX` needs decoding to compare keys correctly; the rest map one-to-one.
|
|
64
|
+
if (next === "u") {
|
|
65
|
+
out += String.fromCodePoint(Number.parseInt(text.slice(index + 2, index + 6), 16));
|
|
66
|
+
index += 6;
|
|
67
|
+
} else {
|
|
68
|
+
out += ESCAPES[next] ?? next;
|
|
69
|
+
index += 2;
|
|
70
|
+
}
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (char === '"') {
|
|
74
|
+
index += 1;
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
out += char;
|
|
78
|
+
index += 1;
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
while (index < text.length) {
|
|
84
|
+
const char = text[index]!;
|
|
85
|
+
|
|
86
|
+
if (WHITESPACE.has(char) || char === "," || char === ":") {
|
|
87
|
+
index += 1;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (char === "{" || char === "[") {
|
|
92
|
+
stack.push(
|
|
93
|
+
char === "{"
|
|
94
|
+
? { keys: new Set(), kind: "object", path: childPath() }
|
|
95
|
+
: { kind: "array", path: childPath() },
|
|
96
|
+
);
|
|
97
|
+
pendingKey = null;
|
|
98
|
+
index += 1;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (char === "}" || char === "]") {
|
|
102
|
+
stack.pop();
|
|
103
|
+
pendingKey = null;
|
|
104
|
+
index += 1;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (char === '"') {
|
|
109
|
+
const value = readString();
|
|
110
|
+
/*
|
|
111
|
+
* A string is a key when it sits directly inside an object and a `:` follows. Inside an
|
|
112
|
+
* array it is an element, and the same text on the right of a `:` is a value.
|
|
113
|
+
*/
|
|
114
|
+
const frame = stack.at(-1);
|
|
115
|
+
if (frame?.kind === "object" && nextMeaningful(text, index) === ":") {
|
|
116
|
+
if (frame.keys!.has(value)) {
|
|
117
|
+
problems.push({ detail: value, kind: "duplicate-key", path: frame.path });
|
|
118
|
+
}
|
|
119
|
+
frame.keys!.add(value);
|
|
120
|
+
pendingKey = value;
|
|
121
|
+
} else {
|
|
122
|
+
pendingKey = null;
|
|
123
|
+
}
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (char === "-" || (char >= "0" && char <= "9")) {
|
|
128
|
+
const start = index;
|
|
129
|
+
while (index < text.length && /[\d+.eE-]/.test(text[index]!)) {
|
|
130
|
+
index += 1;
|
|
131
|
+
}
|
|
132
|
+
const literal = text.slice(start, index);
|
|
133
|
+
if (!isSafeJsonNumber(literal)) {
|
|
134
|
+
problems.push({ detail: literal, kind: "unsafe-number", path: childPath() });
|
|
135
|
+
}
|
|
136
|
+
pendingKey = null;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// `true`, `false`, `null`. The grammar is already known good, so a run of letters is enough.
|
|
141
|
+
const before = index;
|
|
142
|
+
while (index < text.length && /[A-Za-z]/.test(text[index]!)) {
|
|
143
|
+
index += 1;
|
|
144
|
+
}
|
|
145
|
+
if (index === before) {
|
|
146
|
+
index += 1;
|
|
147
|
+
}
|
|
148
|
+
pendingKey = null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return problems;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const ESCAPES: Record<string, string> = {
|
|
155
|
+
b: "\b",
|
|
156
|
+
f: "\f",
|
|
157
|
+
n: "\n",
|
|
158
|
+
r: "\r",
|
|
159
|
+
t: "\t",
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/** The next non-whitespace character at or after `from`, or "" at the end. */
|
|
163
|
+
function nextMeaningful(text: string, from: number): string {
|
|
164
|
+
let index = from;
|
|
165
|
+
while (index < text.length && WHITESPACE.has(text[index]!)) {
|
|
166
|
+
index += 1;
|
|
167
|
+
}
|
|
168
|
+
return text[index] ?? "";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function joinPath(parent: string, key: string): string {
|
|
172
|
+
return parent === "" ? key : `${parent}.${key}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* True when a number literal survives a round trip through a double.
|
|
177
|
+
*
|
|
178
|
+
* Only integers are judged. A fractional literal is _always_ approximate in binary floating point —
|
|
179
|
+
* `0.1` is not representable either — so flagging those would flag most real documents and say
|
|
180
|
+
* nothing about whether the author's value survived.
|
|
181
|
+
*
|
|
182
|
+
* @param {string} literal
|
|
183
|
+
* @returns {boolean}
|
|
184
|
+
*/
|
|
185
|
+
export function isSafeJsonNumber(literal: string): boolean {
|
|
186
|
+
if (/[.eE]/.test(literal)) {
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
const value = Number(literal);
|
|
190
|
+
if (!Number.isFinite(value)) {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
// The literal is an integer, so `String(Number(x))` reproduces it exactly when nothing was lost.
|
|
194
|
+
return String(value) === literal.replace(/^\+/, "");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** A one-line description of a problem, for a build error or a warning. */
|
|
198
|
+
export function describeIJsonProblem(problem: IJsonProblem): string {
|
|
199
|
+
const where = problem.path === "" ? "the document root" : `"${problem.path}"`;
|
|
200
|
+
return problem.kind === "duplicate-key"
|
|
201
|
+
? `duplicate key "${problem.detail}" in ${where} — JSON.parse keeps the last one and ` +
|
|
202
|
+
"silently discards the first (RFC 7493 §2.3)"
|
|
203
|
+
: `number ${problem.detail} in ${where} cannot be represented exactly and will change when ` +
|
|
204
|
+
"the document is written back (RFC 7493 §2.2)";
|
|
205
|
+
}
|
package/src/intl.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The blessed `Intl` helpers: one list, read by everything that needs to know them.
|
|
3
|
+
*
|
|
4
|
+
* **The list used to exist in four places** — the runtime's allow-set, the runtime's dispatch
|
|
5
|
+
* table, the compiler's emitter, and a prose JSON-Schema description enumerating them by name. The
|
|
6
|
+
* description was the dangerous one: nothing checked it, so a helper could ship and the schema go
|
|
7
|
+
* on telling authors it did not exist. The names live here; the runtime and the schema description
|
|
8
|
+
* both read them, and a test asserts the two agree.
|
|
9
|
+
*
|
|
10
|
+
* **Why helpers and not the constructors.** ECMA-402's formatters are constructors, and a formula
|
|
11
|
+
* is a pure call — `new` is not in the expression grammar and should not be. Each helper wraps
|
|
12
|
+
* construct-then-format, which is the shape a document author wants anyway.
|
|
13
|
+
*
|
|
14
|
+
* **Every helper takes an explicit locale.** `new Intl.NumberFormat(undefined)` reads the _host's_
|
|
15
|
+
* locale, so the same document compiled on two machines emits different text. A call that names no
|
|
16
|
+
* locale gets the **page's** — which is a function of the route, not of the build machine — and
|
|
17
|
+
* failing that the fixed default below; see `site-architecture.md` §13.7.
|
|
18
|
+
*
|
|
19
|
+
* Pure data and no imports, so a browser bundle, the compiler and the schema generator can all read
|
|
20
|
+
* it without pulling anything else in.
|
|
21
|
+
*
|
|
22
|
+
* @docs studio/logic/formulas
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The locale a helper uses when a formula names none **and there is no page to ask**.
|
|
27
|
+
*
|
|
28
|
+
* **Not the host's.** `new Intl.NumberFormat(undefined)` reads the machine's locale, so the same
|
|
29
|
+
* document renders `1,234.5` on one build machine and `1.234,5` on another, and a site's output
|
|
30
|
+
* stops being a function of its input.
|
|
31
|
+
*
|
|
32
|
+
* A page's own locale is the better answer where there is one — a page under `/fr/` that formats a
|
|
33
|
+
* date means French, and it already said so in `<html lang>` — so `$page.locale` is consulted first
|
|
34
|
+
* (see `INTL_LOCALE_PARAM`). That preserves the property this constant exists for, because
|
|
35
|
+
* `$page.locale` is a function of the route and the document rather than of the machine. This value
|
|
36
|
+
* is what remains for a scope with no page: a component's own state, or the runtime evaluated
|
|
37
|
+
* standalone.
|
|
38
|
+
*/
|
|
39
|
+
export const DEFAULT_FORMAT_LOCALE = "en-US";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The time zone `Intl/formatDate` uses when the formula's options name none.
|
|
43
|
+
*
|
|
44
|
+
* The same determinism argument as the locale, and worse in effect: a locale changes how a date
|
|
45
|
+
* reads, a time zone can change **which day it is**. A post timestamped `2026-08-16T02:00Z` renders
|
|
46
|
+
* as the 16th in UTC and the 15th in New York, so a build machine's zone would silently move dates
|
|
47
|
+
* in published HTML. UTC is the one zone that is the same everywhere.
|
|
48
|
+
*/
|
|
49
|
+
export const DEFAULT_TIME_ZONE = "UTC";
|
|
50
|
+
|
|
51
|
+
/** One blessed helper: its callee path (after `window#/`) and what it wraps. */
|
|
52
|
+
export interface IntlHelper {
|
|
53
|
+
/** The callee path an expression names, e.g. `Intl/formatNumber`. */
|
|
54
|
+
path: string;
|
|
55
|
+
/** The ECMA-402 constructor it wraps. */
|
|
56
|
+
api: string;
|
|
57
|
+
/** Parameter names, in order, for documentation and for the compiled call. */
|
|
58
|
+
params: string[];
|
|
59
|
+
/** One line, for the formula catalogue and the schema description. */
|
|
60
|
+
summary: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Every blessed `Intl` helper, in the order they are documented.
|
|
65
|
+
*
|
|
66
|
+
* **`DurationFormat` is deliberately absent.** Its baseline support is not universal, and a blessed
|
|
67
|
+
* global that throws on a browser Jx claims to support is worse than one that does not exist: the
|
|
68
|
+
* author writes a formula that works on their machine and fails on a visitor's.
|
|
69
|
+
*/
|
|
70
|
+
export const INTL_HELPERS: readonly IntlHelper[] = [
|
|
71
|
+
{
|
|
72
|
+
api: "Intl.NumberFormat",
|
|
73
|
+
params: ["value", "locale", "options"],
|
|
74
|
+
path: "Intl/formatNumber",
|
|
75
|
+
summary: "Format a number for a locale — grouping, decimals, currency, percent, units.",
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
api: "Intl.DateTimeFormat",
|
|
79
|
+
params: ["value", "locale", "options"],
|
|
80
|
+
path: "Intl/formatDate",
|
|
81
|
+
summary: "Format a date or timestamp for a locale. Pass `timeZone` in options.",
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
api: "Intl.RelativeTimeFormat",
|
|
85
|
+
params: ["value", "unit", "locale", "options"],
|
|
86
|
+
path: "Intl/formatRelativeTime",
|
|
87
|
+
summary: 'Format a relative time — `-3, "day"` becomes "3 days ago".',
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
api: "Intl.ListFormat",
|
|
91
|
+
params: ["values", "locale", "options"],
|
|
92
|
+
path: "Intl/formatList",
|
|
93
|
+
summary: 'Join a list the way a locale does — "a, b, and c", not a hand-written comma join.',
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
api: "Intl.PluralRules",
|
|
97
|
+
params: ["value", "locale", "options"],
|
|
98
|
+
path: "Intl/plural",
|
|
99
|
+
summary:
|
|
100
|
+
'Which plural category a number falls in ("one", "few", "other") — many languages have ' +
|
|
101
|
+
"more than two, so a ternary on `n === 1` is wrong outside English.",
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
api: "Intl.Collator",
|
|
105
|
+
params: ["a", "b", "locale", "options"],
|
|
106
|
+
path: "Intl/compare",
|
|
107
|
+
summary:
|
|
108
|
+
"Compare two strings for sorting. `<` and `sort()` order by UTF-16 code unit, which puts " +
|
|
109
|
+
'"Zebra" before "apple" and sorts every accented word into the wrong place.',
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
api: "Intl.DisplayNames",
|
|
113
|
+
params: ["code", "type", "locale", "options"],
|
|
114
|
+
path: "Intl/displayName",
|
|
115
|
+
summary:
|
|
116
|
+
'The name of a language, region, script or currency in a locale — "de" as "German" or ' +
|
|
117
|
+
'"Deutsch", from CLDR rather than a hand-kept table.',
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
api: "Intl.Segmenter",
|
|
121
|
+
params: ["value", "granularity", "locale"],
|
|
122
|
+
path: "Intl/segment",
|
|
123
|
+
summary:
|
|
124
|
+
"Split text into graphemes, words or sentences (UAX #29). `[...str]` splits code points, " +
|
|
125
|
+
"so it cuts an emoji with a skin-tone modifier in half.",
|
|
126
|
+
},
|
|
127
|
+
] as const;
|
|
128
|
+
|
|
129
|
+
/** Just the callee paths, for the runtime's allow-set. */
|
|
130
|
+
export const INTL_HELPER_PATHS: readonly string[] = INTL_HELPERS.map((helper) => helper.path);
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Where each helper's `locale` argument sits, so a call that omits it can be given the page's.
|
|
134
|
+
*
|
|
135
|
+
* Derived from `params` rather than written out, for the reason this module exists at all: a
|
|
136
|
+
* hand-kept second list is what let the schema description drift from the implementation. The
|
|
137
|
+
* position differs per helper — `formatNumber` takes it second, `formatRelativeTime` third — and a
|
|
138
|
+
* table that disagreed with the signature would pass the wrong argument silently, which reads as a
|
|
139
|
+
* formatting bug rather than a wiring one.
|
|
140
|
+
*/
|
|
141
|
+
export const INTL_LOCALE_PARAM: Readonly<Record<string, number>> = Object.fromEntries(
|
|
142
|
+
INTL_HELPERS.map((helper) => [helper.path, helper.params.indexOf("locale")]),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The helper names as one clause, for the `call` operator's JSON-Schema description.
|
|
147
|
+
*
|
|
148
|
+
* Generated rather than typed out, because a hand-written enumeration is what let the schema drift
|
|
149
|
+
* from the implementation in the first place.
|
|
150
|
+
*
|
|
151
|
+
* @returns {string}
|
|
152
|
+
*/
|
|
153
|
+
export function intlHelpersClause(): string {
|
|
154
|
+
return `the Intl helpers ${INTL_HELPER_PATHS.join(", ")}`;
|
|
155
|
+
}
|