@jxsuite/schema 1.5.0 → 1.8.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/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 +7 -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/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/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
|
+
}
|