@jxsuite/schema 2.3.0 → 4.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.
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(children?: unknown): boolean {
157
- if (isMappedArray(children)) {
158
- return true;
159
- }
160
- return Array.isArray(children) && children.some((c) => isMappedArray(c));
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) => (typeof p === "string" ? p : p.name));
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: unknown): tagName is { $expression: JxTagExpression } {
272
- if (!isJsonObject(tagName) || !isJsonObject(tagName.$expression)) {
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) => typeof tag === "string") &&
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: unknown): string[] {
300
- if (typeof tagName === "string") {
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: unknown): string {
321
- return typeof tagName === "string" ? tagName : tagNameCandidates(tagName).join("|");
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[next] ?? next;
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: Record<string, string> = {
155
- b: "\b",
156
- f: "\f",
157
- n: "\n",
158
- r: "\r",
159
- t: "\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 {
@@ -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 = (): { raw: string; escaped: Set<number> } => {
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
- /** Parse a JSON file into the document it holds and the layout it was written in. */
197
- export function parseJsonDocument(text: string): {
198
- document: Record<string, unknown>;
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
- const document = JSON.parse(text) as Record<string, unknown>;
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
- type Entry = [key: string, value: unknown];
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: unknown): boolean {
270
- return value === undefined || typeof value === "function" || typeof value === "symbol";
303
+ function omitted(value: SerializableValue): boolean {
304
+ return value === undefined || isFunction(value) || isSymbol(value);
271
305
  }
272
306
 
273
- function entriesOf(value: object): Entry[] {
307
+ function entriesOf(value: SerializableObject): Entry[] {
274
308
  return Object.entries(value).filter(([, v]) => !omitted(v));
275
309
  }
276
310
 
277
- function isObject(value: unknown): value is Record<string, unknown> {
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: unknown[]): boolean {
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: unknown[]): boolean {
303
- return items.length > 1 && items.every((item) => typeof item === "number");
336
+ function isNumberRun(items: SerializableValue[]): boolean {
337
+ return items.length > 1 && items.every((item) => isNumber(item));
304
338
  }
305
339
 
306
- /** A scalar, as `JSON.stringify` spells it — with the code units the source escaped, escaped. */
307
- function scalar(value: unknown, escaped?: Set<number>): string {
308
- const text = JSON.stringify(value) ?? "null";
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: unknown,
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: unknown, path: string, inherited: boolean): string | null => {
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: unknown,
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
- // A scalar is never broken, however long: only a container has a second layout to fall to.
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 as object);
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: unknown[],
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
- /* Type-only, so this module still imports nothing at runtime and a browser host can load it. */
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 {unknown} tag
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: unknown): string | null {
106
- if (typeof tag !== "string" || tag.trim() === "") {
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: unknown): boolean {
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 {unknown} tag
129
+ * @param {JsonValue | undefined} tag - Any configured value; non-strings are not tags
129
130
  * @returns {TextDirection}
130
131
  */
131
- export function localeDirection(tag: unknown): TextDirection {
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 {unknown} tag
164
+ * @param {JsonValue | undefined} tag - Any configured value; non-strings are not tags
164
165
  * @returns {string}
165
166
  */
166
- export function localeLabel(tag: unknown): string {
167
+ export function localeLabel(tag: JsonValue | undefined): string {
167
168
  const canonical = canonicalizeLocale(tag);
168
169
  if (canonical === null) {
169
- return typeof tag === "string" ? tag : "";
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 {{ i18n: ResolvedI18n | null; errors: string[] }}
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
- if (!raw || typeof raw !== "object") {
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: unknown): string | null {
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 {unknown} value
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: unknown): MediaType | null {
44
- if (typeof value !== "string") {
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 {unknown} value
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: unknown): string | null {
120
- if (typeof value !== "string" || value.trim() === "") {
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 {unknown} value
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: unknown): string | null {
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: unknown): boolean {
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: Readonly<Record<string, string>> = {
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
- return MEDIA_TYPE_BY_EXTENSION[path.slice(dot).toLowerCase()] ?? null;
229
+ const extension = path.slice(dot).toLowerCase();
230
+ return isOverriddenExtension(extension) ? MEDIA_TYPE_BY_EXTENSION[extension] : null;
217
231
  }