@jxsuite/schema 3.0.0 → 4.1.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/defs/children-value.schema.ts +2 -2
- package/defs/class-def.schema.ts +1 -1
- package/defs/expression-node.schema.ts +2 -2
- package/defs/ref-object.schema.ts +3 -3
- package/package.json +4 -4
- package/schema.json +52 -26
- package/src/a11y.ts +91 -95
- package/src/asset-paths.ts +23 -1
- package/src/component-meta.ts +123 -55
- package/src/dialogs.ts +20 -19
- package/src/doc-ops.ts +291 -40
- package/src/extension-registry.ts +15 -8
- package/src/format-registry.ts +504 -31
- package/src/guards.ts +89 -14
- package/src/ijson.ts +8 -8
- package/src/json-layout.ts +64 -25
- package/src/locale.ts +23 -18
- package/src/media-type.ts +26 -12
- package/src/overlays.ts +50 -45
- package/src/parse.ts +42 -24
- package/src/project-schemas.ts +94 -66
- package/src/schema.ts +50 -38
- package/src/validate-project.ts +34 -11
- package/types.ts +116 -47
package/src/guards.ts
CHANGED
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
|
|
9
9
|
import type {
|
|
10
10
|
JsonObject,
|
|
11
|
+
JsonPrimitive,
|
|
11
12
|
JsonValue,
|
|
12
13
|
JxClassDef,
|
|
13
14
|
JxElement,
|
|
15
|
+
JxElementTagName,
|
|
14
16
|
JxEventBinding,
|
|
15
17
|
JxExpressionDef,
|
|
16
18
|
JxFunctionDef,
|
|
@@ -25,6 +27,58 @@ import type {
|
|
|
25
27
|
JxTagExpression,
|
|
26
28
|
} from "../types";
|
|
27
29
|
|
|
30
|
+
// ─── Primitive guards ───────────────────────────────────────────────────────────
|
|
31
|
+
/*
|
|
32
|
+
* The one sanctioned home of `typeof`. Lint allows the operator only inside a type predicate
|
|
33
|
+
* (`deslop/no-runtime-typeof`), so code that has to tell a string from a number calls one of these
|
|
34
|
+
* and gets a narrowed type back instead of a bare boolean. Prefer a domain guard below when the
|
|
35
|
+
* value is a Jx shape; these are for the primitives a decoder bottoms out on.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** A string primitive. */
|
|
39
|
+
export function isString(value: unknown): value is string {
|
|
40
|
+
return typeof value === "string";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A number primitive, `NaN` and the infinities included (use `Number.isFinite` to refuse them). */
|
|
44
|
+
export function isNumber(value: unknown): value is number {
|
|
45
|
+
return typeof value === "number";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A boolean primitive. */
|
|
49
|
+
export function isBoolean(value: unknown): value is boolean {
|
|
50
|
+
return typeof value === "boolean";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A bigint primitive. */
|
|
54
|
+
export function isBigInt(value: unknown): value is bigint {
|
|
55
|
+
return typeof value === "bigint";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A symbol primitive. */
|
|
59
|
+
export function isSymbol(value: unknown): value is symbol {
|
|
60
|
+
return typeof value === "symbol";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Anything callable. The predicate admits no arguments (`never[]`), so a value that was `unknown`
|
|
65
|
+
* still needs its real signature established before it is called; narrowing a declared union
|
|
66
|
+
* (`string | (() => void)`) yields the union's own function member.
|
|
67
|
+
*/
|
|
68
|
+
export function isFunction(value: unknown): value is (...args: never[]) => void {
|
|
69
|
+
return typeof value === "function";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Any non-null object, arrays included. {@link isJsonObject} is the narrower plain-object check. */
|
|
73
|
+
export function isNonNullObject(value: unknown): value is object {
|
|
74
|
+
return typeof value === "object" && value !== null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A JSON primitive: string, number, boolean or `null`. */
|
|
78
|
+
export function isJsonPrimitive(value: unknown): value is JsonPrimitive {
|
|
79
|
+
return value === null || isString(value) || isNumber(value) || isBoolean(value);
|
|
80
|
+
}
|
|
81
|
+
|
|
28
82
|
// ─── JSON shape guards ──────────────────────────────────────────────────────────
|
|
29
83
|
|
|
30
84
|
/** A plain object: not null, not an array. The base check for all object-shaped defs. */
|
|
@@ -153,11 +207,12 @@ export function isMappedArray(value: unknown): value is JxMappedArray {
|
|
|
153
207
|
* slot (legacy) or as a member of the children array. Used by renderers and the compiler to decide
|
|
154
208
|
* if a children list needs inline repeater expansion.
|
|
155
209
|
*/
|
|
156
|
-
export function childrenContainArray(
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
210
|
+
export function childrenContainArray(
|
|
211
|
+
children?: JxElement["children"] | JxMutableNode["children"] | JsonValue,
|
|
212
|
+
): boolean {
|
|
213
|
+
// The legacy bare slot is a one-member list, so both forms take the same test.
|
|
214
|
+
const members = Array.isArray(children) ? children : [children];
|
|
215
|
+
return members.some((member) => isMappedArray(member));
|
|
161
216
|
}
|
|
162
217
|
|
|
163
218
|
/** A .class.json schema-defined class document — `{ $prototype: "Class" }`. */
|
|
@@ -200,7 +255,7 @@ export function bodyReturnsValue(body: string): boolean {
|
|
|
200
255
|
* array to look properties up on, which finds nothing and reports nothing.
|
|
201
256
|
*/
|
|
202
257
|
export function isNestedStyle(
|
|
203
|
-
value: string | number | JxStyle | JxStyle[] | undefined,
|
|
258
|
+
value: string | number | JxStyle | JxStyle[] | null | undefined,
|
|
204
259
|
): value is JxStyle {
|
|
205
260
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
206
261
|
}
|
|
@@ -248,7 +303,22 @@ export function paramNames(parameters: JxFunctionDef["parameters"]): string[] {
|
|
|
248
303
|
if (!parameters) {
|
|
249
304
|
return [];
|
|
250
305
|
}
|
|
251
|
-
return parameters.map((p) => (
|
|
306
|
+
return parameters.map((p) => (isString(p) ? p : p.name));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* What an element's `tagName` slot holds: one of the two declared forms, nothing, or whatever other
|
|
311
|
+
* JSON a hand-edited document put there. The readers below take the slot as found, because refusing
|
|
312
|
+
* a malformed one gracefully is part of their contract.
|
|
313
|
+
*/
|
|
314
|
+
export type TagNameSlot = JxElementTagName | JsonValue | undefined;
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Whether an element's `tagName` slot holds a plain name: the other form {@link isTagExpression}
|
|
318
|
+
* admits.
|
|
319
|
+
*/
|
|
320
|
+
function isPlainTagName(tagName: TagNameSlot): tagName is string {
|
|
321
|
+
return typeof tagName === "string";
|
|
252
322
|
}
|
|
253
323
|
|
|
254
324
|
/**
|
|
@@ -268,8 +338,13 @@ export function paramNames(parameters: JxFunctionDef["parameters"]): string[] {
|
|
|
268
338
|
* A `$expression` this refuses is not a tag choice, so it has no candidates and no static tag; the
|
|
269
339
|
* readers fall back to their own "nothing declared" answer (`[]`, `"div"`) instead of throwing.
|
|
270
340
|
*/
|
|
271
|
-
export function isTagExpression(tagName:
|
|
272
|
-
if (
|
|
341
|
+
export function isTagExpression(tagName: TagNameSlot): tagName is { $expression: JxTagExpression } {
|
|
342
|
+
if (
|
|
343
|
+
typeof tagName !== "object" ||
|
|
344
|
+
tagName === null ||
|
|
345
|
+
Array.isArray(tagName) ||
|
|
346
|
+
!isJsonObject(tagName.$expression)
|
|
347
|
+
) {
|
|
273
348
|
return false;
|
|
274
349
|
}
|
|
275
350
|
const expression = tagName.$expression;
|
|
@@ -279,7 +354,7 @@ export function isTagExpression(tagName: unknown): tagName is { $expression: JxT
|
|
|
279
354
|
if (expression.operator === "switch") {
|
|
280
355
|
return (
|
|
281
356
|
isJsonObject(expression.cases) &&
|
|
282
|
-
Object.values(expression.cases).every((tag) =>
|
|
357
|
+
Object.values(expression.cases).every((tag) => isString(tag)) &&
|
|
283
358
|
typeof expression.default === "string"
|
|
284
359
|
);
|
|
285
360
|
}
|
|
@@ -296,8 +371,8 @@ export function isTagExpression(tagName: unknown): tagName is { $expression: JxT
|
|
|
296
371
|
*
|
|
297
372
|
* A plain name yields itself, so every caller can treat the two forms alike.
|
|
298
373
|
*/
|
|
299
|
-
export function tagNameCandidates(tagName:
|
|
300
|
-
if (
|
|
374
|
+
export function tagNameCandidates(tagName: TagNameSlot): string[] {
|
|
375
|
+
if (isPlainTagName(tagName)) {
|
|
301
376
|
return [tagName];
|
|
302
377
|
}
|
|
303
378
|
if (!isTagExpression(tagName)) {
|
|
@@ -317,8 +392,8 @@ export function tagNameCandidates(tagName: unknown): string[] {
|
|
|
317
392
|
* says "this is one element whose tag is chosen" without pretending to know which. Rendering the
|
|
318
393
|
* first candidate would have been a quieter lie.
|
|
319
394
|
*/
|
|
320
|
-
export function displayTagName(tagName:
|
|
321
|
-
return
|
|
395
|
+
export function displayTagName(tagName: TagNameSlot): string {
|
|
396
|
+
return isPlainTagName(tagName) ? tagName : tagNameCandidates(tagName).join("|");
|
|
322
397
|
}
|
|
323
398
|
|
|
324
399
|
/**
|
package/src/ijson.ts
CHANGED
|
@@ -65,7 +65,7 @@ export function findIJsonProblems(text: string): IJsonProblem[] {
|
|
|
65
65
|
out += String.fromCodePoint(Number.parseInt(text.slice(index + 2, index + 6), 16));
|
|
66
66
|
index += 6;
|
|
67
67
|
} else {
|
|
68
|
-
out += ESCAPES
|
|
68
|
+
out += ESCAPES.get(next) ?? next;
|
|
69
69
|
index += 2;
|
|
70
70
|
}
|
|
71
71
|
continue;
|
|
@@ -151,13 +151,13 @@ export function findIJsonProblems(text: string): IJsonProblem[] {
|
|
|
151
151
|
return problems;
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
const ESCAPES
|
|
155
|
-
b
|
|
156
|
-
f
|
|
157
|
-
n
|
|
158
|
-
r
|
|
159
|
-
t
|
|
160
|
-
|
|
154
|
+
const ESCAPES = new Map([
|
|
155
|
+
["b", "\b"],
|
|
156
|
+
["f", "\f"],
|
|
157
|
+
["n", "\n"],
|
|
158
|
+
["r", "\r"],
|
|
159
|
+
["t", "\t"],
|
|
160
|
+
]);
|
|
161
161
|
|
|
162
162
|
/** The next non-whitespace character at or after `from`, or "" at the end. */
|
|
163
163
|
function nextMeaningful(text: string, from: number): string {
|
package/src/json-layout.ts
CHANGED
|
@@ -49,6 +49,9 @@
|
|
|
49
49
|
* @docs studio/interface/tabs
|
|
50
50
|
*/
|
|
51
51
|
|
|
52
|
+
import { isFunction, isNumber, isSymbol } from "./guards";
|
|
53
|
+
import type { JsonObject, JxDocumentValue } from "../types";
|
|
54
|
+
|
|
52
55
|
/** A JSON Pointer (RFC 6901) segment: `~` and `/` are the two characters that need escaping. */
|
|
53
56
|
function segment(key: string): string {
|
|
54
57
|
return key.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
@@ -69,6 +72,12 @@ export interface JsonLayout {
|
|
|
69
72
|
escaped: Map<string, Set<number>>;
|
|
70
73
|
}
|
|
71
74
|
|
|
75
|
+
/** A JSON string token the scanner consumed: its raw source text and the code units it escaped. */
|
|
76
|
+
interface ScannedString {
|
|
77
|
+
raw: string;
|
|
78
|
+
escaped: Set<number>;
|
|
79
|
+
}
|
|
80
|
+
|
|
72
81
|
/** The print width the repository formats to — `.oxfmtrc.json` leaves oxfmt's default in place. */
|
|
73
82
|
export const JSON_PRINT_WIDTH = 100;
|
|
74
83
|
|
|
@@ -100,7 +109,7 @@ export function deriveJsonLayout(text: string): JsonLayout {
|
|
|
100
109
|
};
|
|
101
110
|
|
|
102
111
|
/** Consume the string starting at `i`; return its raw text and the code units it escaped. */
|
|
103
|
-
const string = ():
|
|
112
|
+
const string = (): ScannedString => {
|
|
104
113
|
const start = i;
|
|
105
114
|
const escaped = new Set<number>();
|
|
106
115
|
i += 1;
|
|
@@ -153,6 +162,7 @@ export function deriveJsonLayout(text: string): JsonLayout {
|
|
|
153
162
|
}
|
|
154
163
|
layout.inline.set(path, !broken);
|
|
155
164
|
while (i < text.length) {
|
|
165
|
+
// SAFETY: `string()` consumed one whole JSON string token, so its raw text parses to a string.
|
|
156
166
|
const key = JSON.parse(string().raw) as string;
|
|
157
167
|
space();
|
|
158
168
|
i += 1; // ":"
|
|
@@ -193,12 +203,18 @@ export function deriveJsonLayout(text: string): JsonLayout {
|
|
|
193
203
|
return layout;
|
|
194
204
|
}
|
|
195
205
|
|
|
196
|
-
/**
|
|
197
|
-
export
|
|
198
|
-
document:
|
|
206
|
+
/** A JSON file read for editing: the document it holds and the layout it was written in. */
|
|
207
|
+
export interface ParsedJsonDocument {
|
|
208
|
+
document: JsonObject;
|
|
199
209
|
layout: JsonLayout;
|
|
200
|
-
}
|
|
201
|
-
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Parse a JSON file into the document it holds and the layout it was written in. */
|
|
213
|
+
export function parseJsonDocument(text: string): ParsedJsonDocument {
|
|
214
|
+
/* SAFETY: `JSON.parse` without a reviver yields a JSON value, and every file read through here (a
|
|
215
|
+
Jx document, `project.json`, a surface) is one whose schema requires a top-level object. That
|
|
216
|
+
contract is what the cast states, as the `Record` it replaced did; the parse does not check it. */
|
|
217
|
+
const document = JSON.parse(text) as JsonObject;
|
|
202
218
|
return { document, layout: deriveJsonLayout(text) };
|
|
203
219
|
}
|
|
204
220
|
|
|
@@ -263,18 +279,36 @@ export function displayWidth(text: string): number {
|
|
|
263
279
|
return width;
|
|
264
280
|
}
|
|
265
281
|
|
|
266
|
-
|
|
282
|
+
/**
|
|
283
|
+
* What {@link serializeJson} writes: a Jx document value, or a container holding one, where a member
|
|
284
|
+
* may also be something `JSON.stringify` drops from an object (`undefined`, a function, a symbol),
|
|
285
|
+
* as a live editor document can briefly hold.
|
|
286
|
+
*/
|
|
287
|
+
export type SerializableValue =
|
|
288
|
+
| JxDocumentValue
|
|
289
|
+
| undefined
|
|
290
|
+
| symbol
|
|
291
|
+
| ((...args: never[]) => void)
|
|
292
|
+
| SerializableValue[]
|
|
293
|
+
| SerializableObject;
|
|
294
|
+
|
|
295
|
+
/** An object {@link serializeJson} writes member by member. */
|
|
296
|
+
export interface SerializableObject {
|
|
297
|
+
[key: string]: SerializableValue;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
type Entry = [key: string, value: SerializableValue];
|
|
267
301
|
|
|
268
302
|
/** Whether `JSON.stringify` would drop this value from an object. */
|
|
269
|
-
function omitted(value:
|
|
270
|
-
return value === undefined ||
|
|
303
|
+
function omitted(value: SerializableValue): boolean {
|
|
304
|
+
return value === undefined || isFunction(value) || isSymbol(value);
|
|
271
305
|
}
|
|
272
306
|
|
|
273
|
-
function entriesOf(value:
|
|
307
|
+
function entriesOf(value: SerializableObject): Entry[] {
|
|
274
308
|
return Object.entries(value).filter(([, v]) => !omitted(v));
|
|
275
309
|
}
|
|
276
310
|
|
|
277
|
-
function isObject(value:
|
|
311
|
+
function isObject(value: SerializableValue): value is SerializableObject {
|
|
278
312
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
279
313
|
}
|
|
280
314
|
|
|
@@ -283,7 +317,7 @@ function isObject(value: unknown): value is Record<string, unknown> {
|
|
|
283
317
|
* one key or all arrays with more than one element (Prettier's "concisely printed" rule,
|
|
284
318
|
* inverted).
|
|
285
319
|
*/
|
|
286
|
-
function forcedOpen(items:
|
|
320
|
+
function forcedOpen(items: SerializableValue[]): boolean {
|
|
287
321
|
if (items.length < 2) {
|
|
288
322
|
return false;
|
|
289
323
|
}
|
|
@@ -299,13 +333,16 @@ function forcedOpen(items: unknown[]): boolean {
|
|
|
299
333
|
}
|
|
300
334
|
|
|
301
335
|
/** Two or more numbers: the formatter fills lines with them rather than stacking one per line. */
|
|
302
|
-
function isNumberRun(items:
|
|
303
|
-
return items.length > 1 && items.every((item) =>
|
|
336
|
+
function isNumberRun(items: SerializableValue[]): boolean {
|
|
337
|
+
return items.length > 1 && items.every((item) => isNumber(item));
|
|
304
338
|
}
|
|
305
339
|
|
|
306
|
-
/**
|
|
307
|
-
|
|
308
|
-
|
|
340
|
+
/**
|
|
341
|
+
* A scalar, as `JSON.stringify` spells it — with the code units the source escaped, escaped. What
|
|
342
|
+
* `JSON.stringify` cannot spell (it answers `undefined`) is written `null`, as it is in an array.
|
|
343
|
+
*/
|
|
344
|
+
function scalar(value: SerializableValue, escaped?: Set<number>): string {
|
|
345
|
+
const text = omitted(value) ? "null" : JSON.stringify(value);
|
|
309
346
|
return escaped === undefined
|
|
310
347
|
? text
|
|
311
348
|
: text.replaceAll(/[\u0080-\uFFFF]/g, (c) => {
|
|
@@ -323,7 +360,7 @@ function scalar(value: unknown, escaped?: Set<number>): string {
|
|
|
323
360
|
* result ends with exactly one newline, as every formatted file does.
|
|
324
361
|
*/
|
|
325
362
|
export function serializeJson(
|
|
326
|
-
value:
|
|
363
|
+
value: SerializableValue,
|
|
327
364
|
layout: JsonLayout | null,
|
|
328
365
|
printWidth = JSON_PRINT_WIDTH,
|
|
329
366
|
): string {
|
|
@@ -342,7 +379,7 @@ export function serializeJson(
|
|
|
342
379
|
* blank line, or an array the formatter always breaks — so that the break propagates outward the
|
|
343
380
|
* way the formatter's does.
|
|
344
381
|
*/
|
|
345
|
-
const flat = (v:
|
|
382
|
+
const flat = (v: SerializableValue, path: string, inherited: boolean): string | null => {
|
|
346
383
|
if (Array.isArray(v)) {
|
|
347
384
|
if (v.length === 0) {
|
|
348
385
|
return "[]";
|
|
@@ -396,17 +433,19 @@ export function serializeJson(
|
|
|
396
433
|
* parent adds, which counts toward the width the formatter measures).
|
|
397
434
|
*/
|
|
398
435
|
const render = (
|
|
399
|
-
v:
|
|
436
|
+
v: SerializableValue,
|
|
400
437
|
path: string,
|
|
401
438
|
indent: number,
|
|
402
439
|
column: number,
|
|
403
440
|
inherited: boolean,
|
|
404
441
|
trailing: number,
|
|
405
442
|
): string => {
|
|
443
|
+
if (!Array.isArray(v) && !isObject(v)) {
|
|
444
|
+
// A scalar is never broken, however long: only a container has a second layout to fall to.
|
|
445
|
+
return scalar(v, escaped.get(path));
|
|
446
|
+
}
|
|
406
447
|
const line = flat(v, path, inherited);
|
|
407
|
-
|
|
408
|
-
const container = Array.isArray(v) || isObject(v);
|
|
409
|
-
if (line !== null && (!container || column + displayWidth(line) + trailing <= printWidth)) {
|
|
448
|
+
if (line !== null && column + displayWidth(line) + trailing <= printWidth) {
|
|
410
449
|
return line;
|
|
411
450
|
}
|
|
412
451
|
const pad = " ".repeat(indent + 2);
|
|
@@ -424,7 +463,7 @@ export function serializeJson(
|
|
|
424
463
|
});
|
|
425
464
|
return `[\n${stack(items)}${close}]`;
|
|
426
465
|
}
|
|
427
|
-
const entries = entriesOf(v
|
|
466
|
+
const entries = entriesOf(v);
|
|
428
467
|
const own = soft(path, inherited);
|
|
429
468
|
const members = entries.map(([key, item], index) => {
|
|
430
469
|
const member = `${path}/${segment(key)}`;
|
|
@@ -447,7 +486,7 @@ export function serializeJson(
|
|
|
447
486
|
* {@link displayWidth}: a line of numbers and spaces is ASCII, so the two agree.
|
|
448
487
|
*/
|
|
449
488
|
function fill(
|
|
450
|
-
numbers:
|
|
489
|
+
numbers: SerializableValue[],
|
|
451
490
|
pad: string,
|
|
452
491
|
printWidth: number,
|
|
453
492
|
blank: (index: number) => boolean,
|
package/src/locale.ts
CHANGED
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
* @docs framework/site/i18n
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
/*
|
|
21
|
-
import type { ProjectConfig } from "../types.ts";
|
|
20
|
+
/* The guards module is pure predicates with no node imports, so a browser host can still load this. */
|
|
21
|
+
import type { JsonValue, ProjectConfig } from "../types.ts";
|
|
22
|
+
import { isNonNullObject, isString } from "./guards";
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* Scripts written right to left (ISO 15924, per UAX #9).
|
|
@@ -99,11 +100,11 @@ export const LANGUAGE_TAG_PATTERN = "^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$";
|
|
|
99
100
|
* becomes `en-US`, which matters because a tag is compared as a string in a route table and in a
|
|
100
101
|
* `hreflang` attribute.
|
|
101
102
|
*
|
|
102
|
-
* @param {
|
|
103
|
+
* @param {JsonValue | undefined} tag - Any configured value; non-strings are not tags
|
|
103
104
|
* @returns {string | null}
|
|
104
105
|
*/
|
|
105
|
-
export function canonicalizeLocale(tag:
|
|
106
|
-
if (
|
|
106
|
+
export function canonicalizeLocale(tag: JsonValue | undefined): string | null {
|
|
107
|
+
if (!isString(tag) || tag.trim() === "") {
|
|
107
108
|
return null;
|
|
108
109
|
}
|
|
109
110
|
try {
|
|
@@ -114,7 +115,7 @@ export function canonicalizeLocale(tag: unknown): string | null {
|
|
|
114
115
|
}
|
|
115
116
|
|
|
116
117
|
/** True when the tag is a well-formed BCP 47 language tag. */
|
|
117
|
-
export function isWellFormedLocale(tag:
|
|
118
|
+
export function isWellFormedLocale(tag: JsonValue | undefined): boolean {
|
|
118
119
|
return canonicalizeLocale(tag) !== null;
|
|
119
120
|
}
|
|
120
121
|
|
|
@@ -125,10 +126,10 @@ export function isWellFormedLocale(tag: unknown): boolean {
|
|
|
125
126
|
* validation has already happened at config load, and guessing left-to-right is what every browser
|
|
126
127
|
* does with an unusable `lang`.
|
|
127
128
|
*
|
|
128
|
-
* @param {
|
|
129
|
+
* @param {JsonValue | undefined} tag - Any configured value; non-strings are not tags
|
|
129
130
|
* @returns {TextDirection}
|
|
130
131
|
*/
|
|
131
|
-
export function localeDirection(tag:
|
|
132
|
+
export function localeDirection(tag: JsonValue | undefined): TextDirection {
|
|
132
133
|
const canonical = canonicalizeLocale(tag);
|
|
133
134
|
if (canonical === null) {
|
|
134
135
|
return "ltr";
|
|
@@ -160,13 +161,13 @@ export function localeDirection(tag: unknown): TextDirection {
|
|
|
160
161
|
* `Intl.Locale` here, so neither the constructor nor `of` can reject it, and a guard would be an
|
|
161
162
|
* untestable branch pretending otherwise.
|
|
162
163
|
*
|
|
163
|
-
* @param {
|
|
164
|
+
* @param {JsonValue | undefined} tag - Any configured value; non-strings are not tags
|
|
164
165
|
* @returns {string}
|
|
165
166
|
*/
|
|
166
|
-
export function localeLabel(tag:
|
|
167
|
+
export function localeLabel(tag: JsonValue | undefined): string {
|
|
167
168
|
const canonical = canonicalizeLocale(tag);
|
|
168
169
|
if (canonical === null) {
|
|
169
|
-
return
|
|
170
|
+
return isString(tag) ? tag : "";
|
|
170
171
|
}
|
|
171
172
|
return new Intl.DisplayNames([canonical], { type: "language" }).of(canonical) ?? canonical;
|
|
172
173
|
}
|
|
@@ -182,6 +183,12 @@ export interface ResolvedI18n {
|
|
|
182
183
|
routing: LocaleRouting;
|
|
183
184
|
}
|
|
184
185
|
|
|
186
|
+
/** What {@link resolveI18n} answers: the resolved block, and every problem found on the way. */
|
|
187
|
+
export interface I18nResolution {
|
|
188
|
+
i18n: ResolvedI18n | null;
|
|
189
|
+
errors: string[];
|
|
190
|
+
}
|
|
191
|
+
|
|
185
192
|
const DEFAULT_ROUTING: LocaleRouting = "prefix-except-default";
|
|
186
193
|
|
|
187
194
|
/**
|
|
@@ -192,15 +199,13 @@ const DEFAULT_ROUTING: LocaleRouting = "prefix-except-default";
|
|
|
192
199
|
* every page claims a language that does not exist, and nothing downstream can tell.
|
|
193
200
|
*
|
|
194
201
|
* @param {ProjectConfig} projectConfig
|
|
195
|
-
* @returns {
|
|
202
|
+
* @returns {I18nResolution}
|
|
196
203
|
*/
|
|
197
|
-
export function resolveI18n(projectConfig: ProjectConfig): {
|
|
198
|
-
i18n: ResolvedI18n | null;
|
|
199
|
-
errors: string[];
|
|
200
|
-
} {
|
|
204
|
+
export function resolveI18n(projectConfig: ProjectConfig): I18nResolution {
|
|
201
205
|
const raw = projectConfig.i18n;
|
|
202
206
|
const errors: string[] = [];
|
|
203
|
-
|
|
207
|
+
// Checked rather than trusted: the block comes straight from project.json.
|
|
208
|
+
if (!isNonNullObject(raw)) {
|
|
204
209
|
return { errors, i18n: null };
|
|
205
210
|
}
|
|
206
211
|
|
|
@@ -423,7 +428,7 @@ function localeSlot(segments: readonly string[]): number {
|
|
|
423
428
|
}
|
|
424
429
|
|
|
425
430
|
/** The primary language subtag of a tag: `en` for `en-GB`, `zh` for `zh-Hant-TW`. */
|
|
426
|
-
export function primaryLanguage(tag:
|
|
431
|
+
export function primaryLanguage(tag: JsonValue | undefined): string | null {
|
|
427
432
|
const canonical = canonicalizeLocale(tag);
|
|
428
433
|
return canonical === null ? null : (canonical.split("-")[0] ?? null);
|
|
429
434
|
}
|
package/src/media-type.ts
CHANGED
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
* @docs extending/extensions/formats
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import type { JsonValue } from "../types";
|
|
19
|
+
import { isString } from "./guards";
|
|
20
|
+
|
|
18
21
|
/** RFC 6838 §4.2: the first character, then up to 126 more from the restricted set. */
|
|
19
22
|
const RESTRICTED_NAME = /^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}$/;
|
|
20
23
|
|
|
@@ -37,11 +40,11 @@ export interface MediaType {
|
|
|
37
40
|
/**
|
|
38
41
|
* Parse a media type, or return null when it does not match the grammar.
|
|
39
42
|
*
|
|
40
|
-
* @param {
|
|
43
|
+
* @param {JsonValue | undefined} value - Any configured value; non-strings are not media types
|
|
41
44
|
* @returns {MediaType | null}
|
|
42
45
|
*/
|
|
43
|
-
export function parseMediaType(value:
|
|
44
|
-
if (
|
|
46
|
+
export function parseMediaType(value: JsonValue | undefined): MediaType | null {
|
|
47
|
+
if (!isString(value)) {
|
|
45
48
|
return null;
|
|
46
49
|
}
|
|
47
50
|
const [essence, ...paramParts] = value.split(";");
|
|
@@ -113,11 +116,11 @@ export function formatMediaType(media: MediaType): string {
|
|
|
113
116
|
* Separate from {@link parseMediaType} because a caller validating configuration needs to say what
|
|
114
117
|
* is wrong, and a caller reading a type only needs to know whether it could.
|
|
115
118
|
*
|
|
116
|
-
* @param {
|
|
119
|
+
* @param {JsonValue | undefined} value - Any configured value; non-strings are not media types
|
|
117
120
|
* @returns {string | null}
|
|
118
121
|
*/
|
|
119
|
-
export function mediaTypeProblem(value:
|
|
120
|
-
if (
|
|
122
|
+
export function mediaTypeProblem(value: JsonValue | undefined): string | null {
|
|
123
|
+
if (!isString(value) || value.trim() === "") {
|
|
121
124
|
return "expected a media type string";
|
|
122
125
|
}
|
|
123
126
|
if (!value.includes("/")) {
|
|
@@ -137,10 +140,10 @@ export function mediaTypeProblem(value: unknown): string | null {
|
|
|
137
140
|
* moment a format declared the `variant` parameter RFC 7763 defines, so the distinction belongs
|
|
138
141
|
* here rather than in each caller's `split`.
|
|
139
142
|
*
|
|
140
|
-
* @param {
|
|
143
|
+
* @param {JsonValue | undefined} value - Any configured value; non-strings are not media types
|
|
141
144
|
* @returns {string | null} Null when the value is not a media type at all
|
|
142
145
|
*/
|
|
143
|
-
export function mediaTypeEssence(value:
|
|
146
|
+
export function mediaTypeEssence(value: JsonValue | undefined): string | null {
|
|
144
147
|
const media = parseMediaType(value);
|
|
145
148
|
if (media === null) {
|
|
146
149
|
return null;
|
|
@@ -151,7 +154,7 @@ export function mediaTypeEssence(value: unknown): string | null {
|
|
|
151
154
|
}
|
|
152
155
|
|
|
153
156
|
/** True when the type names an unregistered format under the `x.` or deprecated `x-` convention. */
|
|
154
|
-
export function isUnregisteredMediaType(value:
|
|
157
|
+
export function isUnregisteredMediaType(value: JsonValue | undefined): boolean {
|
|
155
158
|
const media = parseMediaType(value);
|
|
156
159
|
return media !== null && (media.tree === "x" || media.subtype.startsWith("x-"));
|
|
157
160
|
}
|
|
@@ -191,12 +194,22 @@ export const YAML_MEDIA_TYPE = "application/yaml";
|
|
|
191
194
|
* text and a browser that guesses the encoding of a UTF-8 document guesses wrong for exactly the
|
|
192
195
|
* non-Latin content the rest of this repo works to support.
|
|
193
196
|
*/
|
|
194
|
-
export const MEDIA_TYPE_BY_EXTENSION
|
|
197
|
+
export const MEDIA_TYPE_BY_EXTENSION = {
|
|
195
198
|
".markdown": `${MARKDOWN_MEDIA_TYPE}; charset=utf-8`,
|
|
196
199
|
".md": `${MARKDOWN_MEDIA_TYPE}; charset=utf-8`,
|
|
197
200
|
".yaml": `${YAML_MEDIA_TYPE}; charset=utf-8`,
|
|
198
201
|
".yml": `${YAML_MEDIA_TYPE}; charset=utf-8`,
|
|
199
|
-
}
|
|
202
|
+
} as const satisfies Readonly<Record<string, string>>;
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* True when {@link MEDIA_TYPE_BY_EXTENSION} corrects this extension. Every key starts with a dot,
|
|
206
|
+
* so no `Object.prototype` member can answer for one.
|
|
207
|
+
*/
|
|
208
|
+
function isOverriddenExtension(
|
|
209
|
+
extension: string,
|
|
210
|
+
): extension is keyof typeof MEDIA_TYPE_BY_EXTENSION {
|
|
211
|
+
return extension in MEDIA_TYPE_BY_EXTENSION;
|
|
212
|
+
}
|
|
200
213
|
|
|
201
214
|
/**
|
|
202
215
|
* The media type for a path, or null to leave the host's own answer alone.
|
|
@@ -213,5 +226,6 @@ export function mediaTypeForPath(path: string): string | null {
|
|
|
213
226
|
if (dot <= path.lastIndexOf("/") || dot <= path.lastIndexOf("\\")) {
|
|
214
227
|
return null;
|
|
215
228
|
}
|
|
216
|
-
|
|
229
|
+
const extension = path.slice(dot).toLowerCase();
|
|
230
|
+
return isOverriddenExtension(extension) ? MEDIA_TYPE_BY_EXTENSION[extension] : null;
|
|
217
231
|
}
|