@pie-players/pie-players-shared 1.0.0-beta.6 → 1.0.0-beta.8

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.
Files changed (37) hide show
  1. package/README.md +3 -1
  2. package/dist/i18n/catalogs.d.ts +20 -13
  3. package/dist/i18n/catalogs.js +22 -23
  4. package/dist/i18n/content-language.d.ts +46 -4
  5. package/dist/i18n/content-language.js +83 -5
  6. package/dist/i18n/index.d.ts +3 -2
  7. package/dist/i18n/index.js +2 -2
  8. package/dist/i18n/language-tags.d.ts +73 -4
  9. package/dist/i18n/language-tags.js +176 -6
  10. package/dist/i18n/messages/en-US.d.ts +23 -0
  11. package/dist/i18n/messages/en-US.js +25 -0
  12. package/dist/i18n/provider.d.ts +3 -6
  13. package/dist/i18n/provider.js +5 -15
  14. package/dist/i18n/types.d.ts +6 -1
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.js +1 -0
  17. package/dist/loaders/ElementLoader.d.ts +1 -1
  18. package/dist/loaders/element-loader.d.ts +4 -1
  19. package/dist/loaders/element-loader.js +74 -21
  20. package/dist/loaders/iife-adapter.d.ts +7 -0
  21. package/dist/loaders/iife-adapter.js +1 -1
  22. package/dist/security/private-hosts.d.ts +27 -0
  23. package/dist/security/private-hosts.js +85 -0
  24. package/dist/security/validate-style-url.js +2 -2
  25. package/dist/tools/term-lookup.d.ts +15 -0
  26. package/dist/tools/term-lookup.js +16 -0
  27. package/dist/types/index.d.ts +47 -27
  28. package/dist/types/interchange.d.ts +116 -0
  29. package/dist/types/interchange.js +1 -0
  30. package/dist/ui/focus-trap.js +3 -1
  31. package/dist/ui/fontawesome.d.ts +51 -0
  32. package/dist/ui/fontawesome.js +122 -0
  33. package/dist/ui/ime.d.ts +6 -0
  34. package/dist/ui/ime.js +10 -0
  35. package/package.json +15 -6
  36. package/dist/i18n/messages/nl-NL.d.ts +0 -636
  37. package/dist/i18n/messages/nl-NL.js +0 -636
package/README.md CHANGED
@@ -20,6 +20,7 @@ The subpaths below are the package's `exports`; nothing else is importable.
20
20
  | `@pie-players/pie-players-shared` | The root: the loader, PIE, security and object helpers, loader config, instrumentation providers, the item-player strategy helpers, the default bundle host URL (`BUILDER_BUNDLE_URL`), a subset of the shared types, and the UI helpers (attribute coercion, content styles, pointer gestures, overlay placement, focus trap, safe storage, scoped CSS) |
21
21
  | `/loader-config` | `LoaderConfig` and its defaults: instrumentation (`trackPageActions`, `instrumentationProvider`), resource retry, and IIFE bundle retry |
22
22
  | `/security` | The item-markup sanitizer (`sanitizeItemMarkup`, `createDefaultItemMarkupSanitizer`, `buildAuthoringAllowList`), external-stylesheet URL validation, the SVG icon and `style` attribute sanitizers, and the wrappers that put overwide images and tables in a scrollable region |
23
+ | `/security/private-hosts` | `isPrivateHostname` and `isCloudMetadataHostname`, the host classification for a URL a server fetches on someone else's behalf. Node-safe: it loads none of the markup sanitizers |
23
24
  | `/object` | `mergeObjectsIgnoringNullUndefined`, `cloneDeep`, `isPlainRecord` |
24
25
  | `/types` | The shared types: item, passage, section and session entities, PIE models and controllers, accessibility catalogs, and the [media vocabulary](#media-validation) |
25
26
  | `/formative` | [Formative delivery](#formative-delivery) |
@@ -33,9 +34,10 @@ The subpaths below are the package's `exports`; nothing else is importable.
33
34
  | `/i18n/provider` | `SimpleI18n`, `getDefaultI18n` and `resolveInterfaceI18n`, with the English catalog only |
34
35
  | `/i18n/catalogs` | `BUNDLED_LOCALES` and lazy loaders for the bundled locales other than English |
35
36
  | `/i18n/language-tags` | BCP 47 language tag normalization and matching |
36
- | `/nds-icon-button` | `<nds-icon-button>`, an icon button vendored as a prebuilt bundle with Lit inlined; importing it registers the tag. The assessment toolkit and `@pie-players/pie-tool-tts-inline` render it |
37
+ | `/nds-icon-button` | `<nds-icon-button>`, an icon button vendored as a prebuilt bundle with Lit inlined; importing it registers the tag. The assessment toolkit, the section player and `@pie-players/pie-tool-tts-inline` render it |
37
38
  | `/ui/attribute-coercion` | Boolean attribute coercion for custom elements |
38
39
  | `/ui/content-styles` | `installContentStyles`, `auditContentStyles` and the opt-out checks behind the item player's content styles |
40
+ | `/ui/fontawesome` | The FontAwesome `<nds-icon-button>` glyphs need: the one FA Free pin, Pro detection, the `<head>` and shadow-root installs, and the glyph weight. The assessment toolkit, the section player and `@pie-players/pie-tool-tts-inline` use it |
39
41
  | `/tools/term-lookup` | Term normalization and the lookup session the dictionary tools share; the host supplies the endpoint or a resolver |
40
42
 
41
43
  ## Formative Delivery
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Lazy catalog loaders for every locale this repository ships.
2
+ * The bundled catalog loader: `en-US`, the one statically shipped catalog.
3
3
  *
4
4
  * Imported by players, never by tools. A tool resolves its provider from the
5
5
  * toolkit runtime context and needs only the interface plus the English
@@ -7,25 +7,32 @@
7
7
  * into a bundle that never calls `setLocale`, because every tool
8
8
  * `vite.config.ts` sets `external: []`.
9
9
  *
10
- * An explicit static map rather than a bundler macro: `tsc` emits these
11
- * `import()` calls verbatim, so `players-shared/dist` evaluates anywhere ESM
12
- * does — webpack, esbuild, Rollup, Node, or a browser loading the files
13
- * directly. `pie-qti` used `import.meta.glob` and shipped a `dist` that throws
14
- * under every one of those. Each bundler still sees the specifiers statically,
15
- * so each locale stays its own chunk. Adding a locale means adding one line here
16
- * alongside the file.
10
+ * `en-US` is the source of truth — the fallback for every missing key in every
11
+ * other locale — so the provider imports it statically and it is always
12
+ * resident. It is the only catalog this package ships: every other locale this
13
+ * repository ships lives in the optional generated tier
14
+ * (`@pie-players/pie-i18n-generated`), whose loaders a host composes after
15
+ * this one. The composed shape is that package's README's wiring example:
17
16
  *
18
- * `en-US` is absent: it is the fallback for every missing key in every other
19
- * locale, so the provider imports it statically and it is always resident.
17
+ * ```ts
18
+ * loadCatalog: (locale) =>
19
+ * loadBundledCatalog(locale).catch(() => loadGeneratedCatalog(locale));
20
+ * ```
21
+ *
22
+ * A request this function cannot serve rejects, and the provider treats that
23
+ * as "resolve through the fallback chain" rather than an error, so a host
24
+ * locale nothing ships still renders English.
20
25
  *
21
26
  * @module @pie-players/pie-players-shared/i18n/catalogs
22
27
  */
23
28
  import type { BundledLocaleCode, MessageCatalog } from "./types.js";
24
29
  /**
25
- * Every locale tag this repository ships a catalog for, English included.
30
+ * Every locale tag this repository bundles a catalog for. `en-US` alone: the
31
+ * statically resident source of truth.
26
32
  *
27
33
  * The provider resolves a request against this list through RFC 4647 lookup and
28
- * primary-subtag widening, so `nl`, `nl_NL` and `nl-BE` all reach `nl-NL`.
34
+ * primary-subtag widening, so `en`, `en_US` and `en-GB`-without-a-catalog all
35
+ * land on `en-US` unless a composed loader serves the region.
29
36
  */
30
37
  export declare const BUNDLED_LOCALES: readonly BundledLocaleCode[];
31
38
  /**
@@ -36,7 +43,7 @@ export declare const BUNDLED_LOCALES: readonly BundledLocaleCode[];
36
43
  * or a pseudo-localization of the English catalog — asks for it by name like
37
44
  * any other locale.
38
45
  *
39
- * Throws for a locale with no shipped catalog; the provider treats that as
46
+ * Rejects for a locale with no bundled catalog; the provider treats that as
40
47
  * "resolve through the fallback chain" rather than an error, so a host locale we
41
48
  * do not ship still renders.
42
49
  */
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Lazy catalog loaders for every locale this repository ships.
2
+ * The bundled catalog loader: `en-US`, the one statically shipped catalog.
3
3
  *
4
4
  * Imported by players, never by tools. A tool resolves its provider from the
5
5
  * toolkit runtime context and needs only the interface plus the English
@@ -7,30 +7,34 @@
7
7
  * into a bundle that never calls `setLocale`, because every tool
8
8
  * `vite.config.ts` sets `external: []`.
9
9
  *
10
- * An explicit static map rather than a bundler macro: `tsc` emits these
11
- * `import()` calls verbatim, so `players-shared/dist` evaluates anywhere ESM
12
- * does — webpack, esbuild, Rollup, Node, or a browser loading the files
13
- * directly. `pie-qti` used `import.meta.glob` and shipped a `dist` that throws
14
- * under every one of those. Each bundler still sees the specifiers statically,
15
- * so each locale stays its own chunk. Adding a locale means adding one line here
16
- * alongside the file.
10
+ * `en-US` is the source of truth — the fallback for every missing key in every
11
+ * other locale — so the provider imports it statically and it is always
12
+ * resident. It is the only catalog this package ships: every other locale this
13
+ * repository ships lives in the optional generated tier
14
+ * (`@pie-players/pie-i18n-generated`), whose loaders a host composes after
15
+ * this one. The composed shape is that package's README's wiring example:
17
16
  *
18
- * `en-US` is absent: it is the fallback for every missing key in every other
19
- * locale, so the provider imports it statically and it is always resident.
17
+ * ```ts
18
+ * loadCatalog: (locale) =>
19
+ * loadBundledCatalog(locale).catch(() => loadGeneratedCatalog(locale));
20
+ * ```
21
+ *
22
+ * A request this function cannot serve rejects, and the provider treats that
23
+ * as "resolve through the fallback chain" rather than an error, so a host
24
+ * locale nothing ships still renders English.
20
25
  *
21
26
  * @module @pie-players/pie-players-shared/i18n/catalogs
22
27
  */
23
28
  import enUS from "./messages/en-US.js";
24
- const CATALOG_LOADERS = {
25
- "nl-NL": () => import("./messages/nl-NL.js"),
26
- };
27
29
  /**
28
- * Every locale tag this repository ships a catalog for, English included.
30
+ * Every locale tag this repository bundles a catalog for. `en-US` alone: the
31
+ * statically resident source of truth.
29
32
  *
30
33
  * The provider resolves a request against this list through RFC 4647 lookup and
31
- * primary-subtag widening, so `nl`, `nl_NL` and `nl-BE` all reach `nl-NL`.
34
+ * primary-subtag widening, so `en`, `en_US` and `en-GB`-without-a-catalog all
35
+ * land on `en-US` unless a composed loader serves the region.
32
36
  */
33
- export const BUNDLED_LOCALES = ["en-US", "nl-NL"];
37
+ export const BUNDLED_LOCALES = ["en-US"];
34
38
  /**
35
39
  * Load one shipped catalog.
36
40
  *
@@ -39,7 +43,7 @@ export const BUNDLED_LOCALES = ["en-US", "nl-NL"];
39
43
  * or a pseudo-localization of the English catalog — asks for it by name like
40
44
  * any other locale.
41
45
  *
42
- * Throws for a locale with no shipped catalog; the provider treats that as
46
+ * Rejects for a locale with no bundled catalog; the provider treats that as
43
47
  * "resolve through the fallback chain" rather than an error, so a host locale we
44
48
  * do not ship still renders.
45
49
  */
@@ -47,10 +51,5 @@ export async function loadBundledCatalog(locale) {
47
51
  if (locale === "en-US") {
48
52
  return enUS;
49
53
  }
50
- const loader = CATALOG_LOADERS[locale];
51
- if (!loader) {
52
- throw new Error(`No bundled i18n catalog for locale: ${locale}`);
53
- }
54
- const module = await loader();
55
- return module.default;
54
+ throw new Error(`No bundled i18n catalog for locale: ${locale}`);
56
55
  }
@@ -12,8 +12,32 @@
12
12
  * The player reflects the resolved language onto its content subtree's root as
13
13
  * `lang` and `dir`, so a screen reader takes the content's voice rules and RTL
14
14
  * content lays out right to left, independent of the chrome's locale.
15
+ *
16
+ * Content that declares nothing falls back to a default the container names:
17
+ * a section's `runtime.contentLanguage`, or {@link UNDECLARED_CONTENT_LANGUAGE}
18
+ * once the host sets an interface locale, which stamps `lang=""` and
19
+ * `dir="auto"` so the content takes neither the interface's language nor its
20
+ * direction. With neither, undeclared content inherits its context, which keeps
21
+ * hosts that set no interface locale rendering as they always have.
15
22
  */
16
23
  import type { TextDirection } from "./types.js";
24
+ /**
25
+ * The content-language default for content that declares no language under a
26
+ * host-set interface locale: `lang=""` names the language unknown and
27
+ * `dir="auto"` takes direction from the content's first strong character.
28
+ */
29
+ export declare const UNDECLARED_CONTENT_LANGUAGE = "auto";
30
+ /**
31
+ * The default a player stamps on content whose `env` declares no language: the
32
+ * host's content language, else {@link UNDECLARED_CONTENT_LANGUAGE} when the
33
+ * host set an interface locale, else `undefined`, which leaves undeclared
34
+ * content inheriting its context. Inputs are read defensively, since both
35
+ * arrive from attributes.
36
+ */
37
+ export declare function defaultContentLanguage(options: {
38
+ contentLanguage?: unknown;
39
+ interfaceLocale?: unknown;
40
+ }): string | undefined;
17
41
  /** `lang` and `dir` for declared content, or `null` when nothing is declared. */
18
42
  export interface ContentLanguageAttributes {
19
43
  lang: string;
@@ -28,8 +52,26 @@ export interface ContentLanguageAttributes {
28
52
  */
29
53
  export declare function contentLanguageAttributes(env: unknown): ContentLanguageAttributes | null;
30
54
  /**
31
- * Stamps `contentLanguageAttributes(env)` onto `target`, removing both
32
- * attributes when the env declares no language — a model set can drop the
33
- * language a previous one declared. No-op without a target.
55
+ * Stamps the content language onto `target`: `contentLanguageAttributes(env)`,
56
+ * else `fallback` — a tag, or {@link UNDECLARED_CONTENT_LANGUAGE} for
57
+ * `lang=""` and `dir="auto"` — else neither attribute, so a model set can drop
58
+ * the language a previous one declared. A fallback that is not a usable tag
59
+ * counts as absent. No-op without a target.
60
+ */
61
+ export declare function stampContentLanguage(target: Element | null | undefined, env: unknown, fallback?: string | null): void;
62
+ /**
63
+ * The direction of the first letter in `root`'s text, skipping `script`,
64
+ * `style` and `template`, or `null` when it has none. Letters only, which is
65
+ * where the bidi algorithm's strong characters overwhelmingly sit.
66
+ */
67
+ export declare function firstStrongDirection(root: Node): TextDirection | null;
68
+ /**
69
+ * The mismatch between undeclared content's script and the direction a
70
+ * fallback stamped on `root`, or `null` when there is none to report: the env
71
+ * declares a language, `root` carries no stamped direction or `dir="auto"`, or
72
+ * the content has no letters.
34
73
  */
35
- export declare function stampContentLanguage(target: Element | null | undefined, env: unknown): void;
74
+ export declare function undeclaredDirectionMismatch(root: Element, env: unknown): {
75
+ stamped: TextDirection;
76
+ script: TextDirection;
77
+ } | null;
@@ -12,9 +12,40 @@
12
12
  * The player reflects the resolved language onto its content subtree's root as
13
13
  * `lang` and `dir`, so a screen reader takes the content's voice rules and RTL
14
14
  * content lays out right to left, independent of the chrome's locale.
15
+ *
16
+ * Content that declares nothing falls back to a default the container names:
17
+ * a section's `runtime.contentLanguage`, or {@link UNDECLARED_CONTENT_LANGUAGE}
18
+ * once the host sets an interface locale, which stamps `lang=""` and
19
+ * `dir="auto"` so the content takes neither the interface's language nor its
20
+ * direction. With neither, undeclared content inherits its context, which keeps
21
+ * hosts that set no interface locale rendering as they always have.
15
22
  */
16
23
  import { canonicalLanguageTag } from "./language-tags.js";
17
24
  import { localeDirection } from "./provider.js";
25
+ /**
26
+ * The content-language default for content that declares no language under a
27
+ * host-set interface locale: `lang=""` names the language unknown and
28
+ * `dir="auto"` takes direction from the content's first strong character.
29
+ */
30
+ export const UNDECLARED_CONTENT_LANGUAGE = "auto";
31
+ /**
32
+ * The default a player stamps on content whose `env` declares no language: the
33
+ * host's content language, else {@link UNDECLARED_CONTENT_LANGUAGE} when the
34
+ * host set an interface locale, else `undefined`, which leaves undeclared
35
+ * content inheriting its context. Inputs are read defensively, since both
36
+ * arrive from attributes.
37
+ */
38
+ export function defaultContentLanguage(options) {
39
+ const named = typeof options.contentLanguage === "string"
40
+ ? options.contentLanguage.trim()
41
+ : "";
42
+ if (named)
43
+ return named;
44
+ const requested = typeof options.interfaceLocale === "string"
45
+ ? options.interfaceLocale.trim()
46
+ : "";
47
+ return requested ? UNDECLARED_CONTENT_LANGUAGE : undefined;
48
+ }
18
49
  /**
19
50
  * `lang` and `dir` for the content an `env` describes, or `null` when it
20
51
  * declares no usable language. Reads `env.locale` defensively — `env` is
@@ -32,14 +63,19 @@ export function contentLanguageAttributes(env) {
32
63
  return { lang, dir: localeDirection(lang) };
33
64
  }
34
65
  /**
35
- * Stamps `contentLanguageAttributes(env)` onto `target`, removing both
36
- * attributes when the env declares no language — a model set can drop the
37
- * language a previous one declared. No-op without a target.
66
+ * Stamps the content language onto `target`: `contentLanguageAttributes(env)`,
67
+ * else `fallback` — a tag, or {@link UNDECLARED_CONTENT_LANGUAGE} for
68
+ * `lang=""` and `dir="auto"` — else neither attribute, so a model set can drop
69
+ * the language a previous one declared. A fallback that is not a usable tag
70
+ * counts as absent. No-op without a target.
38
71
  */
39
- export function stampContentLanguage(target, env) {
72
+ export function stampContentLanguage(target, env, fallback) {
40
73
  if (!target)
41
74
  return;
42
- const attributes = contentLanguageAttributes(env);
75
+ const attributes = contentLanguageAttributes(env) ??
76
+ (fallback === UNDECLARED_CONTENT_LANGUAGE
77
+ ? { lang: "", dir: "auto" }
78
+ : contentLanguageAttributes({ locale: fallback }));
43
79
  if (attributes) {
44
80
  target.setAttribute("lang", attributes.lang);
45
81
  target.setAttribute("dir", attributes.dir);
@@ -49,3 +85,45 @@ export function stampContentLanguage(target, env) {
49
85
  target.removeAttribute("dir");
50
86
  }
51
87
  }
88
+ /** Scripts written right to left, for {@link firstStrongDirection}. */
89
+ const RTL_LETTER = /[\p{Script=Arabic}\p{Script=Hebrew}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}\p{Script=Adlam}\p{Script=Hanifi_Rohingya}\p{Script=Mandaic}\p{Script=Samaritan}]/u;
90
+ const LETTER = /\p{L}/u;
91
+ /**
92
+ * The direction of the first letter in `root`'s text, skipping `script`,
93
+ * `style` and `template`, or `null` when it has none. Letters only, which is
94
+ * where the bidi algorithm's strong characters overwhelmingly sit.
95
+ */
96
+ export function firstStrongDirection(root) {
97
+ const doc = root.ownerDocument ?? root;
98
+ const walker = doc.createTreeWalker(root, 4 /* NodeFilter.SHOW_TEXT */, {
99
+ acceptNode: (node) => node.parentElement?.closest("script, style, template")
100
+ ? 2 /* NodeFilter.FILTER_REJECT */
101
+ : 1 /* NodeFilter.FILTER_ACCEPT */,
102
+ });
103
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
104
+ for (const char of node.nodeValue ?? "") {
105
+ if (RTL_LETTER.test(char))
106
+ return "rtl";
107
+ if (LETTER.test(char))
108
+ return "ltr";
109
+ }
110
+ }
111
+ return null;
112
+ }
113
+ /**
114
+ * The mismatch between undeclared content's script and the direction a
115
+ * fallback stamped on `root`, or `null` when there is none to report: the env
116
+ * declares a language, `root` carries no stamped direction or `dir="auto"`, or
117
+ * the content has no letters.
118
+ */
119
+ export function undeclaredDirectionMismatch(root, env) {
120
+ if (contentLanguageAttributes(env))
121
+ return null;
122
+ const stamped = root.getAttribute("dir");
123
+ if (stamped !== "ltr" && stamped !== "rtl")
124
+ return null;
125
+ const script = firstStrongDirection(root);
126
+ if (!script)
127
+ return null;
128
+ return script === stamped ? null : { stamped, script };
129
+ }
@@ -28,9 +28,10 @@ import type { I18nConfig, I18nServiceApi } from "./types.js";
28
28
  */
29
29
  export declare function createPieI18n(config?: I18nConfig): I18nServiceApi;
30
30
  export { BUNDLED_LOCALES, loadBundledCatalog } from "./catalogs.js";
31
- export { canonicalLanguageTag, findBestLanguageMatch, languageTagLookupSequence, languageTagsEqual, normalizeLanguageTag, toLanguageTag, } from "./language-tags.js";
31
+ export { canonicalLanguageTag, findBestLanguageMatch, findClosestLanguageMatch, languageTagLookupSequence, languageTagScriptLookupSequence, languageTagsEqual, negotiateContentLanguage, normalizeLanguageTag, toLanguageTag, } from "./language-tags.js";
32
+ export type { ContentLanguageMatch } from "./language-tags.js";
32
33
  export { DEFAULT_LOCALE, detectBrowserLocale, dynamicMessageKey, getDefaultI18n, localeDirection, resolveInterfaceI18n, SimpleI18n, stampInterfaceLocale, } from "./provider.js";
33
- export { type ContentLanguageAttributes, contentLanguageAttributes, stampContentLanguage, } from "./content-language.js";
34
+ export { type ContentLanguageAttributes, contentLanguageAttributes, defaultContentLanguage, firstStrongDirection, stampContentLanguage, UNDECLARED_CONTENT_LANGUAGE, undeclaredDirectionMismatch, } from "./content-language.js";
34
35
  export { type InterfaceLocaleLoadErrorModel, reportInterfaceLocaleLoadError, } from "./locale-load-error.js";
35
36
  export { PSEUDO_LOCALE_EXPANDED, PSEUDO_LOCALE_RTL, pseudoLocalize, } from "./pseudo.js";
36
37
  export type { PseudoLocalizeOptions } from "./pseudo.js";
@@ -35,8 +35,8 @@ export function createPieI18n(config = {}) {
35
35
  });
36
36
  }
37
37
  export { BUNDLED_LOCALES, loadBundledCatalog } from "./catalogs.js";
38
- export { canonicalLanguageTag, findBestLanguageMatch, languageTagLookupSequence, languageTagsEqual, normalizeLanguageTag, toLanguageTag, } from "./language-tags.js";
38
+ export { canonicalLanguageTag, findBestLanguageMatch, findClosestLanguageMatch, languageTagLookupSequence, languageTagScriptLookupSequence, languageTagsEqual, negotiateContentLanguage, normalizeLanguageTag, toLanguageTag, } from "./language-tags.js";
39
39
  export { DEFAULT_LOCALE, detectBrowserLocale, dynamicMessageKey, getDefaultI18n, localeDirection, resolveInterfaceI18n, SimpleI18n, stampInterfaceLocale, } from "./provider.js";
40
- export { contentLanguageAttributes, stampContentLanguage, } from "./content-language.js";
40
+ export { contentLanguageAttributes, defaultContentLanguage, firstStrongDirection, stampContentLanguage, UNDECLARED_CONTENT_LANGUAGE, undeclaredDirectionMismatch, } from "./content-language.js";
41
41
  export { reportInterfaceLocaleLoadError, } from "./locale-load-error.js";
42
42
  export { PSEUDO_LOCALE_EXPANDED, PSEUDO_LOCALE_RTL, pseudoLocalize, } from "./pseudo.js";
@@ -51,12 +51,81 @@ export declare function languageTagsEqual(a: string | null | undefined, b: strin
51
51
  */
52
52
  export declare function languageTagLookupSequence(tag: string | null | undefined): string[];
53
53
  /**
54
- * Best available tag for a request, by RFC 4647 lookup, or `undefined` when
55
- * nothing matches. Ranking is by the request's specificity rather than the
56
- * candidates' order, so `es-MX` prefers an `es-MX` candidate over an `es` one
57
- * however they were authored.
54
+ * The script a tag is written in, lowercased: explicit (`sr-Latn`), or the
55
+ * CLDR likely script (`zh-TW` maximizes to Hant, `sr` to Cyrl). `undefined`
56
+ * when the tag has no script data or the runtime has no `Intl.Locale`.
57
+ */
58
+ export declare function languageTagScript(tag: string | null | undefined): string | undefined;
59
+ /**
60
+ * Whether a catalog reached by widening serves a request in the same script.
61
+ * A tag without script data passes, so a runtime without `Intl.Locale`
62
+ * resolves as plain RFC 4647 lookup.
63
+ */
64
+ export declare function languageTagScriptsMatch(requested: string | null | undefined, candidate: string | null | undefined): boolean;
65
+ /**
66
+ * The script-aware lookup sequence: the RFC 4647 sequence with the language
67
+ * and likely script inserted before the bare language, so `zh-TW` yields
68
+ * `["zh-tw", "zh-hant", "zh"]`. A bare-language step serves the request only
69
+ * in the same script, which `findBestLanguageMatch` checks.
70
+ */
71
+ export declare function languageTagScriptLookupSequence(tag: string | null | undefined): string[];
72
+ /**
73
+ * Best available tag for a request, or `undefined` when nothing matches.
74
+ * Ranking is by the request's specificity rather than the candidates' order,
75
+ * so `es-MX` prefers an `es-MX` candidate over an `es` one however they were
76
+ * authored, and `zh-TW` reaches `zh-Hant`. A catalog shorter than the request
77
+ * serves it only in the same script: a bare `zh` catalog is Simplified, so
78
+ * `zh-TW` passes it by.
58
79
  */
59
80
  export declare function findBestLanguageMatch(requested: string | null | undefined, available: readonly (string | null | undefined)[]): string | undefined;
81
+ /**
82
+ * A tag without its variant, extension and private-use subtags, casing kept:
83
+ * `en-GB-x-gulf` yields `en-GB`. The form for a speech engine, whose voices
84
+ * are keyed by language and region.
85
+ */
86
+ export declare function languageTagWithoutVariants(tag: string | null | undefined): string;
87
+ /**
88
+ * `findBestLanguageMatch`, then primary-subtag widening, so `nl-BE` and a bare
89
+ * `nl` reach a regional `nl-NL` and `es-MX` reaches `es-ES`. Widening stays
90
+ * within the request's script and keeps its variant subtags: `zh` never
91
+ * reaches `zh-TW`, `sr-Latn` may reach `sr-ME`, `en-GB` never reaches
92
+ * `en-GB-x-gulf`, and `en-US-x-gulf` may reach `en-GB-x-gulf`. Among several
93
+ * siblings the first in `available` wins. The rule that picks an interface
94
+ * catalog and a content variant alike.
95
+ */
96
+ export declare function findClosestLanguageMatch(requested: string | null | undefined, available: readonly (string | null | undefined)[]): string | undefined;
97
+ /** The content language a backend serves, and whether it is a substitute. */
98
+ export interface ContentLanguageMatch {
99
+ /** The `available` entry served, as authored; `null` for content that declares no language. */
100
+ language: string | null;
101
+ /**
102
+ * `"fallback"` when the served content stands in for the request;
103
+ * `"variety"` when it is another variety of the requested language.
104
+ */
105
+ substitution?: "fallback" | "variety";
106
+ }
107
+ /**
108
+ * The language to serve content in: the closest `available` match for the
109
+ * first `requested` tag that has one, under `findClosestLanguageMatch`, or with
110
+ * `matching: "exact"` only an equal tag, so `es-MX` then passes an `es`
111
+ * variant by. With no match and `fallback` other than `"none"`, the fallback
112
+ * chain serves the closest match for `defaultLanguage` (`en-US` reaches `en`),
113
+ * then content that declares no language (a `null` entry), then `source`;
114
+ * `matching` does not narrow it. `undefined` means nothing can be served. No
115
+ * requested languages run the chain without a substitution. A closest match
116
+ * in another variety, `es-MX` served `es-ES`, records `"variety"`; a host
117
+ * that must not serve one sets `matching: "exact"` and lists the varieties it
118
+ * accepts. Candidates rank
119
+ * `source` first, then by tag, so the order of `available` never decides
120
+ * between two siblings.
121
+ */
122
+ export declare function negotiateContentLanguage(requested: readonly string[], available: readonly (string | null)[], options?: {
123
+ defaultLanguage?: string;
124
+ fallback?: "default" | "none";
125
+ matching?: "closest" | "exact";
126
+ /** The family source's `available` entry. */
127
+ source?: string | null;
128
+ }): ContentLanguageMatch | undefined;
60
129
  /**
61
130
  * The canonical form of a usable language tag, or `undefined` for anything a
62
131
  * language in use would not be: `es_MX` normalizes to `es-MX`, `NL-nl` to
@@ -78,20 +78,190 @@ export function languageTagLookupSequence(tag) {
78
78
  }
79
79
  return sequence;
80
80
  }
81
+ /** `maximize()` runs on every resolution, for each candidate catalog. */
82
+ const scripts = new Map();
81
83
  /**
82
- * Best available tag for a request, by RFC 4647 lookup, or `undefined` when
83
- * nothing matches. Ranking is by the request's specificity rather than the
84
- * candidates' order, so `es-MX` prefers an `es-MX` candidate over an `es` one
85
- * however they were authored.
84
+ * The script a tag is written in, lowercased: explicit (`sr-Latn`), or the
85
+ * CLDR likely script (`zh-TW` maximizes to Hant, `sr` to Cyrl). `undefined`
86
+ * when the tag has no script data or the runtime has no `Intl.Locale`.
87
+ */
88
+ export function languageTagScript(tag) {
89
+ const normalized = normalizeLanguageTag(tag);
90
+ if (!normalized ||
91
+ typeof Intl === "undefined" ||
92
+ typeof Intl.Locale !== "function") {
93
+ return undefined;
94
+ }
95
+ if (scripts.has(normalized))
96
+ return scripts.get(normalized);
97
+ let script;
98
+ try {
99
+ script = new Intl.Locale(normalized).maximize().script?.toLowerCase();
100
+ }
101
+ catch {
102
+ // Not a tag `Intl.Locale` parses: no script data.
103
+ }
104
+ scripts.set(normalized, script);
105
+ return script;
106
+ }
107
+ /**
108
+ * Whether a catalog reached by widening serves a request in the same script.
109
+ * A tag without script data passes, so a runtime without `Intl.Locale`
110
+ * resolves as plain RFC 4647 lookup.
111
+ */
112
+ export function languageTagScriptsMatch(requested, candidate) {
113
+ const requestedScript = languageTagScript(requested);
114
+ const candidateScript = languageTagScript(candidate);
115
+ return (!requestedScript || !candidateScript || requestedScript === candidateScript);
116
+ }
117
+ /**
118
+ * The script-aware lookup sequence: the RFC 4647 sequence with the language
119
+ * and likely script inserted before the bare language, so `zh-TW` yields
120
+ * `["zh-tw", "zh-hant", "zh"]`. A bare-language step serves the request only
121
+ * in the same script, which `findBestLanguageMatch` checks.
122
+ */
123
+ export function languageTagScriptLookupSequence(tag) {
124
+ const sequence = languageTagLookupSequence(tag);
125
+ const script = languageTagScript(tag);
126
+ if (!script || sequence.length < 2)
127
+ return sequence;
128
+ const language = sequence[sequence.length - 1];
129
+ const withScript = `${language}-${script}`;
130
+ if (sequence.includes(withScript))
131
+ return sequence;
132
+ return [...sequence.slice(0, -1), withScript, language];
133
+ }
134
+ /**
135
+ * Best available tag for a request, or `undefined` when nothing matches.
136
+ * Ranking is by the request's specificity rather than the candidates' order,
137
+ * so `es-MX` prefers an `es-MX` candidate over an `es` one however they were
138
+ * authored, and `zh-TW` reaches `zh-Hant`. A catalog shorter than the request
139
+ * serves it only in the same script: a bare `zh` catalog is Simplified, so
140
+ * `zh-TW` passes it by.
86
141
  */
87
142
  export function findBestLanguageMatch(requested, available) {
88
- for (const step of languageTagLookupSequence(requested)) {
89
- const hit = available.find((candidate) => normalizeLanguageTag(candidate) === step);
143
+ const exact = normalizeLanguageTag(requested);
144
+ for (const step of languageTagScriptLookupSequence(requested)) {
145
+ const hit = available.find((candidate) => normalizeLanguageTag(candidate) === step &&
146
+ (step === exact || languageTagScriptsMatch(requested, candidate)));
90
147
  if (hit != null)
91
148
  return hit;
92
149
  }
93
150
  return undefined;
94
151
  }
152
+ /**
153
+ * A tag's subtags split at the end of its language, script and region:
154
+ * `en-GB-x-gulf` yields `[["en", "GB"], ["x", "gulf"]]`. The second part holds
155
+ * variants, extensions and private use, as authored.
156
+ */
157
+ function splitVariantSubtags(tag) {
158
+ const subtags = toLanguageTag(tag).split(/[.@]/)[0].split("-");
159
+ let length = 1;
160
+ if (/^[a-z]{4}$/i.test(subtags[length] ?? ""))
161
+ length++;
162
+ if (/^([a-z]{2}|\d{3})$/i.test(subtags[length] ?? ""))
163
+ length++;
164
+ return [subtags.slice(0, length), subtags.slice(length)];
165
+ }
166
+ /** The variant, extension and private-use subtags as a comparison key. */
167
+ function variantSubtagsKey(tag) {
168
+ return splitVariantSubtags(tag)[1].join("-").toLowerCase();
169
+ }
170
+ /**
171
+ * Whether `served` is another variety of `requested`: it names a different
172
+ * region, or different variant or private-use subtags. `es-MX` served `es-ES`
173
+ * or `en-GB-x-gulf` served `en-GB` is; `es-MX` served `es`, or `zh-TW` served
174
+ * `zh-Hant`, is not.
175
+ */
176
+ function isOtherLanguageVariety(requested, served) {
177
+ const region = (tag) => {
178
+ const [base] = splitVariantSubtags(tag);
179
+ const last = base.length > 1 ? base[base.length - 1] : "";
180
+ return /^([a-z]{2}|\d{3})$/i.test(last) ? last.toLowerCase() : undefined;
181
+ };
182
+ const requestedRegion = region(requested);
183
+ const servedRegion = region(served);
184
+ return ((requestedRegion !== undefined &&
185
+ servedRegion !== undefined &&
186
+ requestedRegion !== servedRegion) ||
187
+ variantSubtagsKey(requested) !== variantSubtagsKey(served));
188
+ }
189
+ /**
190
+ * A tag without its variant, extension and private-use subtags, casing kept:
191
+ * `en-GB-x-gulf` yields `en-GB`. The form for a speech engine, whose voices
192
+ * are keyed by language and region.
193
+ */
194
+ export function languageTagWithoutVariants(tag) {
195
+ return splitVariantSubtags(tag)[0].join("-");
196
+ }
197
+ /**
198
+ * `findBestLanguageMatch`, then primary-subtag widening, so `nl-BE` and a bare
199
+ * `nl` reach a regional `nl-NL` and `es-MX` reaches `es-ES`. Widening stays
200
+ * within the request's script and keeps its variant subtags: `zh` never
201
+ * reaches `zh-TW`, `sr-Latn` may reach `sr-ME`, `en-GB` never reaches
202
+ * `en-GB-x-gulf`, and `en-US-x-gulf` may reach `en-GB-x-gulf`. Among several
203
+ * siblings the first in `available` wins. The rule that picks an interface
204
+ * catalog and a content variant alike.
205
+ */
206
+ export function findClosestLanguageMatch(requested, available) {
207
+ const best = findBestLanguageMatch(requested, available);
208
+ if (best !== undefined)
209
+ return best;
210
+ const language = normalizeLanguageTag(requested).split("-")[0];
211
+ if (!language)
212
+ return undefined;
213
+ const variants = variantSubtagsKey(requested);
214
+ const sibling = available.find((candidate) => normalizeLanguageTag(candidate).split("-")[0] === language &&
215
+ variantSubtagsKey(candidate) === variants &&
216
+ languageTagScriptsMatch(requested, candidate));
217
+ return sibling ?? undefined;
218
+ }
219
+ /**
220
+ * The language to serve content in: the closest `available` match for the
221
+ * first `requested` tag that has one, under `findClosestLanguageMatch`, or with
222
+ * `matching: "exact"` only an equal tag, so `es-MX` then passes an `es`
223
+ * variant by. With no match and `fallback` other than `"none"`, the fallback
224
+ * chain serves the closest match for `defaultLanguage` (`en-US` reaches `en`),
225
+ * then content that declares no language (a `null` entry), then `source`;
226
+ * `matching` does not narrow it. `undefined` means nothing can be served. No
227
+ * requested languages run the chain without a substitution. A closest match
228
+ * in another variety, `es-MX` served `es-ES`, records `"variety"`; a host
229
+ * that must not serve one sets `matching: "exact"` and lists the varieties it
230
+ * accepts. Candidates rank
231
+ * `source` first, then by tag, so the order of `available` never decides
232
+ * between two siblings.
233
+ */
234
+ export function negotiateContentLanguage(requested, available, options = {}) {
235
+ const { defaultLanguage, fallback = "default", matching = "closest", source } = options;
236
+ const candidates = [...available].sort((a, b) => {
237
+ if ((a === source) !== (b === source))
238
+ return a === source ? -1 : 1;
239
+ const left = normalizeLanguageTag(a);
240
+ const right = normalizeLanguageTag(b);
241
+ return left < right ? -1 : left > right ? 1 : 0;
242
+ });
243
+ for (const tag of requested) {
244
+ const language = matching === "exact"
245
+ ? candidates.find((candidate) => languageTagsEqual(candidate, tag))
246
+ : findClosestLanguageMatch(tag, candidates);
247
+ if (language != null) {
248
+ return isOtherLanguageVariety(tag, language)
249
+ ? { language, substitution: "variety" }
250
+ : { language };
251
+ }
252
+ }
253
+ if (requested.length > 0 && fallback === "none")
254
+ return undefined;
255
+ let language = findClosestLanguageMatch(defaultLanguage, candidates);
256
+ if (language === undefined && available.includes(null))
257
+ language = null;
258
+ if (language === undefined && source !== undefined && available.includes(source)) {
259
+ language = source;
260
+ }
261
+ if (language === undefined)
262
+ return undefined;
263
+ return requested.length > 0 ? { language, substitution: "fallback" } : { language };
264
+ }
95
265
  /**
96
266
  * The canonical form of a usable language tag, or `undefined` for anything a
97
267
  * language in use would not be: `es_MX` normalizes to `es-MX`, `NL-nl` to