@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/src/locale.ts ADDED
@@ -0,0 +1,429 @@
1
+ /**
2
+ * BCP 47 language tags: well-formedness, canonical case, and writing direction.
3
+ *
4
+ * One implementation for the repo — `i18n.locales`, a document's `$lang`, and anything else that
5
+ * accepts a language tag all come through here, so a tag that builds in one place cannot fail in
6
+ * another.
7
+ *
8
+ * The grammar and the case conventions come from `Intl.Locale`, which is ECMA-402's parser for
9
+ * exactly this and is already in every runtime Jx targets. Note what that does and does not buy:
10
+ * `Intl.Locale` enforces the RFC 5646 **grammar**, so `en_US` and `en--US` are rejected, but it
11
+ * does not consult the IANA registry, so `zz` and `xx-YY` are well-formed tags for languages that
12
+ * do not exist. Registry membership is not checked here either; a typo'd region is the author's to
13
+ * catch.
14
+ *
15
+ * Pure string math and `Intl` — no node imports — so browser hosts can import this module.
16
+ *
17
+ * @docs framework/site/i18n
18
+ */
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";
22
+
23
+ /**
24
+ * Scripts written right to left (ISO 15924, per UAX #9).
25
+ *
26
+ * Direction is decided by **script**, not by language, and not by `Intl.Locale`'s own
27
+ * `getTextInfo()`. That method answers from the language's CLDR entry, which gets two ordinary
28
+ * cases wrong: `dv` (Dhivehi, written in Thaana) reports `ltr` where an ICU build lacks its data,
29
+ * and `az-Arab` — Azerbaijani deliberately written in the Arabic script — reports `ltr` because the
30
+ * language's default script is Latin. Maximizing the tag and reading the script is right in both.
31
+ */
32
+ const RTL_SCRIPTS = new Set([
33
+ "Adlm",
34
+ "Arab",
35
+ "Aran",
36
+ "Armi",
37
+ "Avst",
38
+ "Cprt",
39
+ "Egyd",
40
+ "Egyh",
41
+ "Elym",
42
+ "Gara",
43
+ "Hatr",
44
+ "Hebr",
45
+ "Hluw",
46
+ "Hung",
47
+ "Khar",
48
+ "Lydi",
49
+ "Mand",
50
+ "Mani",
51
+ "Mend",
52
+ "Merc",
53
+ "Mero",
54
+ "Narb",
55
+ "Nbat",
56
+ "Nkoo",
57
+ "Orkh",
58
+ "Palm",
59
+ "Phli",
60
+ "Phlp",
61
+ "Phnx",
62
+ "Prti",
63
+ "Rohg",
64
+ "Samr",
65
+ "Sarb",
66
+ "Sogd",
67
+ "Sogo",
68
+ "Syrc",
69
+ "Thaa",
70
+ "Todr",
71
+ "Yezi",
72
+ ]);
73
+
74
+ /** Writing direction of a language tag's text. */
75
+ export type TextDirection = "ltr" | "rtl";
76
+
77
+ /**
78
+ * The shape of a language tag, as a regular expression a JSON Schema `pattern` can hold.
79
+ *
80
+ * Deliberately **looser** than {@link canonicalizeLocale}, and deliberately not RFC 5646's grammar.
81
+ * That grammar distinguishes subtags by length _and_ position, and admits grandfathered and
82
+ * private-use forms (`i-klingon`, `x-whatever`); a regex encoding all of it would be unreadable and
83
+ * still wrong at the edges. What this catches is the class of mistake an author actually makes — an
84
+ * underscore where a hyphen belongs (`en_US`), an empty subtag (`en--US`, `en-US-`), a one-letter
85
+ * primary language, a subtag past eight characters.
86
+ *
87
+ * The direction of the inequality is the contract: this accepts **every** tag `Intl.Locale`
88
+ * accepts, so a value the build canonicalizes can never fail `jx validate`. The reverse is allowed
89
+ * and expected — `zh-min-nan` matches here and throws there — because the build stays the authority
90
+ * on well-formedness. This only closes the case where author-time accepted a value build-time was
91
+ * certain to reject.
92
+ */
93
+ export const LANGUAGE_TAG_PATTERN = "^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$";
94
+
95
+ /**
96
+ * The canonical spelling of a well-formed language tag, or null when the tag is malformed.
97
+ *
98
+ * Canonical case is BCP 47's: language lowercase, script titlecase, region uppercase. `EN-us`
99
+ * becomes `en-US`, which matters because a tag is compared as a string in a route table and in a
100
+ * `hreflang` attribute.
101
+ *
102
+ * @param {unknown} tag
103
+ * @returns {string | null}
104
+ */
105
+ export function canonicalizeLocale(tag: unknown): string | null {
106
+ if (typeof tag !== "string" || tag.trim() === "") {
107
+ return null;
108
+ }
109
+ try {
110
+ return new Intl.Locale(tag.trim()).toString();
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ /** True when the tag is a well-formed BCP 47 language tag. */
117
+ export function isWellFormedLocale(tag: unknown): boolean {
118
+ return canonicalizeLocale(tag) !== null;
119
+ }
120
+
121
+ /**
122
+ * The writing direction a tag's text is set in.
123
+ *
124
+ * A malformed tag is `"ltr"` rather than an error: this is called while rendering, where the
125
+ * validation has already happened at config load, and guessing left-to-right is what every browser
126
+ * does with an unusable `lang`.
127
+ *
128
+ * @param {unknown} tag
129
+ * @returns {TextDirection}
130
+ */
131
+ export function localeDirection(tag: unknown): TextDirection {
132
+ const canonical = canonicalizeLocale(tag);
133
+ if (canonical === null) {
134
+ return "ltr";
135
+ }
136
+ // No try/catch: `canonicalizeLocale` already constructed this exact tag, so the constructor
137
+ // Cannot throw here and a guard would be an untestable branch pretending otherwise.
138
+ const locale = new Intl.Locale(canonical);
139
+ // An explicit script wins; otherwise CLDR's likely-subtags (UTS #35) supplies one.
140
+ const script = locale.script ?? locale.maximize().script;
141
+ return script !== undefined && RTL_SCRIPTS.has(script) ? "rtl" : "ltr";
142
+ }
143
+
144
+ /**
145
+ * A locale's name **in its own language**: `français` for `fr`, `العربية` for `ar`.
146
+ *
147
+ * The autonym, not the name in the site's language, because that is what a reader looking for their
148
+ * own language scans a switcher for — a menu that says "French" is unreadable to precisely the
149
+ * person it exists for.
150
+ *
151
+ * Resolved here rather than left to `Intl/displayName` in the document, because a switcher is a
152
+ * mapped array and a map template interpolates scope values rather than evaluating expressions:
153
+ * without this the only label available to an author is one they typed themselves, which is the
154
+ * hand-kept table CLDR exists to replace.
155
+ *
156
+ * Falls back to the tag. `Intl.DisplayNames` answers with the code itself for a language it has no
157
+ * name for, which is the same answer and a better one than an empty menu entry.
158
+ *
159
+ * No try/catch, for the reason {@link localeDirection} gives: the tag has already been through
160
+ * `Intl.Locale` here, so neither the constructor nor `of` can reject it, and a guard would be an
161
+ * untestable branch pretending otherwise.
162
+ *
163
+ * @param {unknown} tag
164
+ * @returns {string}
165
+ */
166
+ export function localeLabel(tag: unknown): string {
167
+ const canonical = canonicalizeLocale(tag);
168
+ if (canonical === null) {
169
+ return typeof tag === "string" ? tag : "";
170
+ }
171
+ return new Intl.DisplayNames([canonical], { type: "language" }).of(canonical) ?? canonical;
172
+ }
173
+
174
+ /** How locales appear in URLs. */
175
+ export type LocaleRouting = "prefix-except-default" | "prefix-always";
176
+
177
+ /** A project's `i18n` block after validation, or null when the project declares none. */
178
+ export interface ResolvedI18n {
179
+ defaultLocale: string;
180
+ /** Canonical tags, default first, in declaration order. */
181
+ locales: string[];
182
+ routing: LocaleRouting;
183
+ }
184
+
185
+ const DEFAULT_ROUTING: LocaleRouting = "prefix-except-default";
186
+
187
+ /**
188
+ * Validate and canonicalize a project's `i18n` block.
189
+ *
190
+ * A malformed tag is a **build error**, not a warning. A locale is a URL prefix, an `hreflang`
191
+ * value and an `<html lang>` all at once, so a typo does not degrade — it produces a site whose
192
+ * every page claims a language that does not exist, and nothing downstream can tell.
193
+ *
194
+ * @param {ProjectConfig} projectConfig
195
+ * @returns {{ i18n: ResolvedI18n | null; errors: string[] }}
196
+ */
197
+ export function resolveI18n(projectConfig: ProjectConfig): {
198
+ i18n: ResolvedI18n | null;
199
+ errors: string[];
200
+ } {
201
+ const raw = projectConfig.i18n;
202
+ const errors: string[] = [];
203
+ if (!raw || typeof raw !== "object") {
204
+ return { errors, i18n: null };
205
+ }
206
+
207
+ const declared = Array.isArray(raw.locales) ? raw.locales : [];
208
+ const locales: string[] = [];
209
+ for (const tag of declared) {
210
+ const canonical = canonicalizeLocale(tag);
211
+ if (canonical === null) {
212
+ errors.push(`i18n.locales: "${String(tag)}" is not a well-formed BCP 47 language tag.`);
213
+ continue;
214
+ }
215
+ if (!locales.includes(canonical)) {
216
+ locales.push(canonical);
217
+ }
218
+ }
219
+
220
+ const defaultLocale = canonicalizeLocale(raw.defaultLocale) ?? locales[0] ?? null;
221
+ if (raw.defaultLocale !== undefined && canonicalizeLocale(raw.defaultLocale) === null) {
222
+ errors.push(
223
+ `i18n.defaultLocale: "${String(raw.defaultLocale)}" is not a well-formed BCP 47 language tag.`,
224
+ );
225
+ }
226
+ if (defaultLocale === null) {
227
+ errors.push("i18n is declared with no usable locale — set `defaultLocale` or `locales`.");
228
+ return { errors, i18n: null };
229
+ }
230
+ if (!locales.includes(defaultLocale)) {
231
+ // A default outside the list is a config the author cannot have meant either way round, so it
232
+ // Joins the list rather than being rejected: the pages under it exist regardless.
233
+ locales.unshift(defaultLocale);
234
+ }
235
+
236
+ const routing: LocaleRouting =
237
+ raw.routing === "prefix-always" || raw.routing === "prefix-except-default"
238
+ ? raw.routing
239
+ : DEFAULT_ROUTING;
240
+
241
+ return { errors, i18n: { defaultLocale, locales, routing } };
242
+ }
243
+
244
+ /**
245
+ * The URL prefix a locale's pages carry: `/fr-ca`, or `""` for a locale that is not prefixed.
246
+ *
247
+ * The one rule `routing` expresses, in the one form every URL-shaped consumer needs. Under
248
+ * `prefix-except-default` the default locale owns the unprefixed URL space; under `prefix-always`
249
+ * every locale is named, including the default. An extension that spells this itself is how a
250
+ * search result or a feed entry comes to link at the default locale's URL for a French post.
251
+ *
252
+ * Lowercase, matching the directory the build reads (see {@link translationPathFor}).
253
+ *
254
+ * @param {string | null | undefined} locale
255
+ * @param {ResolvedI18n | null} i18n
256
+ * @returns {string} `""` or `/<tag>`
257
+ */
258
+ export function localeUrlPrefix(
259
+ locale: string | null | undefined,
260
+ i18n: ResolvedI18n | null,
261
+ ): string {
262
+ if (i18n === null) {
263
+ return "";
264
+ }
265
+ const canonical = canonicalizeLocale(locale);
266
+ if (canonical === null || !i18n.locales.includes(canonical)) {
267
+ return "";
268
+ }
269
+ if (canonical === i18n.defaultLocale && i18n.routing === "prefix-except-default") {
270
+ return "";
271
+ }
272
+ return `/${canonical.toLowerCase()}`;
273
+ }
274
+
275
+ /**
276
+ * The locale a project **file** belongs to: the first path segment that canonicalizes to a declared
277
+ * locale, or null when no segment does.
278
+ *
279
+ * One rule for both layouts §13 defines — `pages/fr/about.json` and `content/blog/fr/hello.md` —
280
+ * because the locale is a directory in both and its position is not the same in the two.
281
+ *
282
+ * `null` for `pages/about.json` is the honest answer rather than the default locale: under
283
+ * `prefix-except-default` that file is the default locale's copy, and under `prefix-always` it is a
284
+ * page outside the locale tree. Which of those it is depends on `routing`, which is the caller's
285
+ * question, not this function's.
286
+ *
287
+ * @param {string} path - Project-relative, `/`-separated
288
+ * @param {ResolvedI18n | null} i18n
289
+ * @returns {string | null}
290
+ */
291
+ export function localeOfPath(path: string, i18n: ResolvedI18n | null): string | null {
292
+ const index = localeSegment(pathSegments(path), i18n);
293
+ if (index === -1 || i18n === null) {
294
+ return null;
295
+ }
296
+ return canonicalizeLocale(pathSegments(path)[index]);
297
+ }
298
+
299
+ /**
300
+ * The locale a **page** is served as, which is {@link localeOfPath} plus the one thing routing
301
+ * says.
302
+ *
303
+ * `localeOfPath` answers null for `pages/about.json` because the path alone cannot tell you whether
304
+ * that file is the default locale's copy or a page outside the locale tree — `routing` decides, and
305
+ * that is the caller's question. This is the caller that has it: under `prefix-except-default` the
306
+ * unprefixed page **is** the default locale's, and a surface that could filter to French and Arabic
307
+ * but never to English is a language filter that cannot answer the question it is most often
308
+ * asked.
309
+ *
310
+ * Scoped to `pages/` deliberately. An unlocalized **collection** expands under every locale's route
311
+ * (§13.3), so calling its entries the default language would be a claim about content that is in
312
+ * none — and a layout is shared by every locale rather than belonging to one.
313
+ *
314
+ * @param {string} path - Project-relative
315
+ * @param {ResolvedI18n | null} i18n
316
+ * @returns {string | null}
317
+ */
318
+ export function servedLocaleOfPath(path: string, i18n: ResolvedI18n | null): string | null {
319
+ const own = localeOfPath(path, i18n);
320
+ if (own !== null || i18n === null || i18n.routing !== "prefix-except-default") {
321
+ return own;
322
+ }
323
+ return pathSegments(path)[0] === "pages" ? i18n.defaultLocale : null;
324
+ }
325
+
326
+ /**
327
+ * A file's identity across languages: its path with the locale segment removed.
328
+ *
329
+ * `pages/fr/about.json` and `pages/about.json` share the key `pages/about.json`. The file-shaped
330
+ * sibling of the compiler's route-shaped `translationKey`, and the same idea: what makes two
331
+ * documents the same page is that they agree once the language is taken out.
332
+ *
333
+ * @param {string} path
334
+ * @param {ResolvedI18n | null} i18n
335
+ * @returns {string}
336
+ */
337
+ export function translationKeyOfPath(path: string, i18n: ResolvedI18n | null): string {
338
+ const segments = pathSegments(path);
339
+ const index = localeSegment(segments, i18n);
340
+ return index === -1 ? segments.join("/") : segments.filter((_, i) => i !== index).join("/");
341
+ }
342
+
343
+ /**
344
+ * Where this file's copy in `locale` lives — the inverse of {@link translationKeyOfPath}.
345
+ *
346
+ * **`routing` decides whether the default locale is prefixed**, and that asymmetry is its whole
347
+ * meaning: under `prefix-except-default` the default locale's copy is the unprefixed path, under
348
+ * `prefix-always` it is prefixed like every other. A caller that ignores it creates the file in a
349
+ * place nothing serves.
350
+ *
351
+ * Null when the project declares no locales, when the tag is not one of them, or when the path
352
+ * cannot carry a locale at all — a layout, a component, a bare file at the project root. Inventing
353
+ * a location for those would put a translation somewhere no router looks.
354
+ *
355
+ * @param {string} path
356
+ * @param {string} locale
357
+ * @param {ResolvedI18n | null} i18n
358
+ * @returns {string | null}
359
+ */
360
+ export function translationPathFor(
361
+ path: string,
362
+ locale: string,
363
+ i18n: ResolvedI18n | null,
364
+ ): string | null {
365
+ if (i18n === null) {
366
+ return null;
367
+ }
368
+ const canonical = canonicalizeLocale(locale);
369
+ if (canonical === null || !i18n.locales.includes(canonical)) {
370
+ return null;
371
+ }
372
+ const segments = pathSegments(path);
373
+ const existing = localeSegment(segments, i18n);
374
+ const slot = existing === -1 ? localeSlot(segments) : existing;
375
+ if (slot === -1) {
376
+ return null;
377
+ }
378
+ const bare = existing === -1 ? segments : segments.filter((_, i) => i !== existing);
379
+ if (canonical === i18n.defaultLocale && i18n.routing === "prefix-except-default") {
380
+ return bare.join("/");
381
+ }
382
+ /*
383
+ * Lowercase on disk. The build matches a directory to a locale case-insensitively, so `fr-CA/`
384
+ * would work — but the URL it produces would carry the capitals, and the warning the build
385
+ * already gives an author with a mismatched directory tells them to write the lowercase form.
386
+ * One spelling, and it is the one the site's own URLs use.
387
+ */
388
+ return bare.toSpliced(slot, 0, canonical.toLowerCase()).join("/");
389
+ }
390
+
391
+ /** Project-relative segments, tolerating a leading `./`. */
392
+ function pathSegments(path: string): string[] {
393
+ return path.replace(/^\.\//, "").split("/").filter(Boolean);
394
+ }
395
+
396
+ /** The index of the segment naming a declared locale, or -1. */
397
+ function localeSegment(segments: readonly string[], i18n: ResolvedI18n | null): number {
398
+ if (i18n === null) {
399
+ return -1;
400
+ }
401
+ return segments.findIndex((segment) => {
402
+ const canonical = canonicalizeLocale(segment);
403
+ return canonical !== null && i18n.locales.includes(canonical);
404
+ });
405
+ }
406
+
407
+ /**
408
+ * Where a locale segment would go in a path that has none, or -1 when it could not have one.
409
+ *
410
+ * Immediately below the routing root: `pages/<locale>/…` and `content/<type>/<locale>/…`, which is
411
+ * where §13.1 puts a locale directory and where §13.3's `source: "./blog/{locale}/"` expands to. A
412
+ * file that is neither is not addressable per language — a layout is shared by every locale, and a
413
+ * component has no URL to be a translation of.
414
+ */
415
+ function localeSlot(segments: readonly string[]): number {
416
+ if (segments[0] === "pages" && segments.length > 1) {
417
+ return 1;
418
+ }
419
+ if (segments[0] === "content" && segments.length > 2) {
420
+ return 2;
421
+ }
422
+ return -1;
423
+ }
424
+
425
+ /** The primary language subtag of a tag: `en` for `en-GB`, `zh` for `zh-Hant-TW`. */
426
+ export function primaryLanguage(tag: unknown): string | null {
427
+ const canonical = canonicalizeLocale(tag);
428
+ return canonical === null ? null : (canonical.split("-")[0] ?? null);
429
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Media types (RFC 6838), parsed rather than passed through.
3
+ *
4
+ * A format declares `mediaType` and the string reaches an HTTP header, an editor's file association
5
+ * and a Studio label. Until now nothing checked it, so `text/markdown;variant GFM` — missing an `=`
6
+ * — would have been served verbatim to a browser as a malformed header value.
7
+ *
8
+ * The grammar is `type "/" [tree "."] subtype ["+" suffix] *(";" parameter)`, with both halves
9
+ * limited to 127 characters of a restricted character set. Registration trees (`vnd.`, `prs.`,
10
+ * `x.`) are recognized because they are what distinguishes "this is registered with IANA" from
11
+ * "this is mine" — the check here is grammatical, not a registry lookup.
12
+ *
13
+ * Pure string math — no node imports — so browser hosts can import this module.
14
+ *
15
+ * @docs extending/extensions/formats
16
+ */
17
+
18
+ /** RFC 6838 §4.2: the first character, then up to 126 more from the restricted set. */
19
+ const RESTRICTED_NAME = /^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}$/;
20
+
21
+ /** The standards tree is the absence of a facet; these three are the others RFC 6838 §3 defines. */
22
+ const KNOWN_TREES = new Set(["vnd", "prs", "x"]);
23
+
24
+ export interface MediaType {
25
+ /** `text` in `text/markdown`. Lower-cased: RFC 6838 §4.2 makes both halves case-insensitive. */
26
+ type: string;
27
+ /** `markdown` in `text/markdown` — the tree facet and `+suffix` removed. */
28
+ subtype: string;
29
+ /** `vnd`, `prs`, `x`, or null for the standards tree. */
30
+ tree: string | null;
31
+ /** `json` in `application/feed+json`. */
32
+ suffix: string | null;
33
+ /** Parameter names lower-cased; values kept as written, minus surrounding quotes. */
34
+ parameters: Record<string, string>;
35
+ }
36
+
37
+ /**
38
+ * Parse a media type, or return null when it does not match the grammar.
39
+ *
40
+ * @param {unknown} value
41
+ * @returns {MediaType | null}
42
+ */
43
+ export function parseMediaType(value: unknown): MediaType | null {
44
+ if (typeof value !== "string") {
45
+ return null;
46
+ }
47
+ const [essence, ...paramParts] = value.split(";");
48
+ const slash = (essence ?? "").indexOf("/");
49
+ if (slash === -1) {
50
+ return null;
51
+ }
52
+ const type = (essence ?? "").slice(0, slash).trim().toLowerCase();
53
+ const rawSubtype = (essence ?? "")
54
+ .slice(slash + 1)
55
+ .trim()
56
+ .toLowerCase();
57
+ if (!RESTRICTED_NAME.test(type) || !RESTRICTED_NAME.test(rawSubtype)) {
58
+ return null;
59
+ }
60
+
61
+ /*
62
+ * `+suffix` binds last: `application/vnd.acme.thing+json` is the `vnd` tree, subtype
63
+ * `acme.thing`, suffix `json`. Split the suffix off before the tree so a dotted subtype keeps
64
+ * its dots.
65
+ */
66
+ const plus = rawSubtype.lastIndexOf("+");
67
+ const suffix = plus === -1 ? null : rawSubtype.slice(plus + 1);
68
+ const withoutSuffix = plus === -1 ? rawSubtype : rawSubtype.slice(0, plus);
69
+ if (suffix === "") {
70
+ return null;
71
+ }
72
+ const dot = withoutSuffix.indexOf(".");
73
+ const facet = dot === -1 ? null : withoutSuffix.slice(0, dot);
74
+ const tree = facet !== null && KNOWN_TREES.has(facet) ? facet : null;
75
+ const subtype = tree === null ? withoutSuffix : withoutSuffix.slice(tree.length + 1);
76
+ if (subtype === "") {
77
+ return null;
78
+ }
79
+
80
+ const parameters: Record<string, string> = {};
81
+ for (const part of paramParts) {
82
+ const eq = part.indexOf("=");
83
+ if (eq === -1) {
84
+ return null;
85
+ }
86
+ const name = part.slice(0, eq).trim().toLowerCase();
87
+ let paramValue = part.slice(eq + 1).trim();
88
+ if (name === "") {
89
+ return null;
90
+ }
91
+ if (paramValue.startsWith('"') && paramValue.endsWith('"') && paramValue.length >= 2) {
92
+ paramValue = paramValue.slice(1, -1);
93
+ }
94
+ parameters[name] = paramValue;
95
+ }
96
+
97
+ return { parameters, subtype, suffix, tree, type };
98
+ }
99
+
100
+ /** Render a parsed media type back to its canonical string. */
101
+ export function formatMediaType(media: MediaType): string {
102
+ const facet = media.tree === null ? "" : `${media.tree}.`;
103
+ const suffix = media.suffix === null ? "" : `+${media.suffix}`;
104
+ const params = Object.entries(media.parameters)
105
+ .map(([name, value]) => `; ${name}=${value}`)
106
+ .join("");
107
+ return `${media.type}/${facet}${media.subtype}${suffix}${params}`;
108
+ }
109
+
110
+ /**
111
+ * A human-readable reason the value is not a media type, or null when it is one.
112
+ *
113
+ * Separate from {@link parseMediaType} because a caller validating configuration needs to say what
114
+ * is wrong, and a caller reading a type only needs to know whether it could.
115
+ *
116
+ * @param {unknown} value
117
+ * @returns {string | null}
118
+ */
119
+ export function mediaTypeProblem(value: unknown): string | null {
120
+ if (typeof value !== "string" || value.trim() === "") {
121
+ return "expected a media type string";
122
+ }
123
+ if (!value.includes("/")) {
124
+ return `"${value}" has no "/" — a media type is type/subtype (RFC 6838 §4.2)`;
125
+ }
126
+ if (parseMediaType(value) === null) {
127
+ return `"${value}" is not a well-formed media type (RFC 6838 §4.2)`;
128
+ }
129
+ return null;
130
+ }
131
+
132
+ /**
133
+ * The media type without its parameters: `text/markdown` for `text/markdown; variant=GFM`.
134
+ *
135
+ * Anything that **keys** on a type rather than describing one wants this — a File System Access
136
+ * `accept` map, an editor language id, a registry lookup. Those are the call sites that broke the
137
+ * moment a format declared the `variant` parameter RFC 7763 defines, so the distinction belongs
138
+ * here rather than in each caller's `split`.
139
+ *
140
+ * @param {unknown} value
141
+ * @returns {string | null} Null when the value is not a media type at all
142
+ */
143
+ export function mediaTypeEssence(value: unknown): string | null {
144
+ const media = parseMediaType(value);
145
+ if (media === null) {
146
+ return null;
147
+ }
148
+ const facet = media.tree === null ? "" : `${media.tree}.`;
149
+ const suffix = media.suffix === null ? "" : `+${media.suffix}`;
150
+ return `${media.type}/${facet}${media.subtype}${suffix}`;
151
+ }
152
+
153
+ /** True when the type names an unregistered format under the `x.` or deprecated `x-` convention. */
154
+ export function isUnregisteredMediaType(value: unknown): boolean {
155
+ const media = parseMediaType(value);
156
+ return media !== null && (media.tree === "x" || media.subtype.startsWith("x-"));
157
+ }
158
+
159
+ /**
160
+ * `text/markdown` with the flavour Jx actually parses, per RFC 7763 §2 and the `variant` values RFC
161
+ * 7764 registers. Bare `text/markdown` is a family, not a syntax — the `variant` is the only thing
162
+ * on the wire that says which dialect a `.md` file is written in.
163
+ */
164
+ export const MARKDOWN_MEDIA_TYPE = "text/markdown; variant=GFM";
165
+
166
+ /**
167
+ * YAML's registered media type, per RFC 9512 §4.
168
+ *
169
+ * Deliberately **not** `text/yaml`. RFC 9512 §5 names `text/yaml`, `text/x-yaml` and
170
+ * `application/x-yaml` as the pre-registration spellings and asks new implementations not to use
171
+ * them — but they are still what most platform lookup tables answer, Bun's included, so serving a
172
+ * `.yaml` file without an opinion here means serving a deprecated type.
173
+ */
174
+ export const YAML_MEDIA_TYPE = "application/yaml";
175
+
176
+ /**
177
+ * The media type Jx serves a file extension with, where the platform's own table is absent or
178
+ * disagrees with the registration.
179
+ *
180
+ * This is not a general MIME table and must not grow into one: every host already has one for the
181
+ * ordinary types, and duplicating those would create a second source of truth for `image/png`. What
182
+ * is here is the short list where the platform answer is **wrong**, which is why each entry carries
183
+ * the RFC that makes it wrong:
184
+ *
185
+ * - `.md` — every table answers bare `text/markdown`, which does not say which markdown (RFC 7763,
186
+ * RFC 7764). Jx knows: its parser is GFM, and the format class declares exactly this.
187
+ * - `.yaml`/`.yml` — Bun answers `text/yaml` and Node's tables usually answer nothing at all;
188
+ * `application/yaml` is the registration (RFC 9512).
189
+ *
190
+ * Keys are lower-cased extensions including the dot; values carry `charset` because both types are
191
+ * text and a browser that guesses the encoding of a UTF-8 document guesses wrong for exactly the
192
+ * non-Latin content the rest of this repo works to support.
193
+ */
194
+ export const MEDIA_TYPE_BY_EXTENSION: Readonly<Record<string, string>> = {
195
+ ".markdown": `${MARKDOWN_MEDIA_TYPE}; charset=utf-8`,
196
+ ".md": `${MARKDOWN_MEDIA_TYPE}; charset=utf-8`,
197
+ ".yaml": `${YAML_MEDIA_TYPE}; charset=utf-8`,
198
+ ".yml": `${YAML_MEDIA_TYPE}; charset=utf-8`,
199
+ };
200
+
201
+ /**
202
+ * The media type for a path, or null to leave the host's own answer alone.
203
+ *
204
+ * Null is the common case and the point: a host calls this to _correct_ its table, not to replace
205
+ * it, so anything absent from {@link MEDIA_TYPE_BY_EXTENSION} keeps whatever the host already
206
+ * decided.
207
+ *
208
+ * @param {string} path - A file path or name; only its extension is read
209
+ * @returns {string | null}
210
+ */
211
+ export function mediaTypeForPath(path: string): string | null {
212
+ const dot = path.lastIndexOf(".");
213
+ if (dot <= path.lastIndexOf("/") || dot <= path.lastIndexOf("\\")) {
214
+ return null;
215
+ }
216
+ return MEDIA_TYPE_BY_EXTENSION[path.slice(dot).toLowerCase()] ?? null;
217
+ }