@pie-players/pie-players-shared 1.0.0-beta.7 → 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.
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
  }
@@ -78,19 +78,31 @@ export declare function languageTagScriptLookupSequence(tag: string | null | und
78
78
  * `zh-TW` passes it by.
79
79
  */
80
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;
81
87
  /**
82
88
  * `findBestLanguageMatch`, then primary-subtag widening, so `nl-BE` and a bare
83
- * `nl` reach a regional `nl-NL` and `es-MX` reaches `es-ES`. Both stay within
84
- * the request's script: `zh` never reaches `zh-TW`, and `sr-Latn` may reach
85
- * `sr-ME`. The rule that picks an interface catalog and a content variant alike.
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.
86
95
  */
87
96
  export declare function findClosestLanguageMatch(requested: string | null | undefined, available: readonly (string | null | undefined)[]): string | undefined;
88
97
  /** The content language a backend serves, and whether it is a substitute. */
89
98
  export interface ContentLanguageMatch {
90
99
  /** The `available` entry served, as authored; `null` for content that declares no language. */
91
100
  language: string | null;
92
- /** `"fallback"` when the served content stands in for the request. */
93
- substitution?: "fallback";
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";
94
106
  }
95
107
  /**
96
108
  * The language to serve content in: the closest `available` match for the
@@ -100,7 +112,12 @@ export interface ContentLanguageMatch {
100
112
  * chain serves the closest match for `defaultLanguage` (`en-US` reaches `en`),
101
113
  * then content that declares no language (a `null` entry), then `source`;
102
114
  * `matching` does not narrow it. `undefined` means nothing can be served. No
103
- * requested languages run the chain without a substitution.
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.
104
121
  */
105
122
  export declare function negotiateContentLanguage(requested: readonly string[], available: readonly (string | null)[], options?: {
106
123
  defaultLanguage?: string;
@@ -149,11 +149,59 @@ export function findBestLanguageMatch(requested, available) {
149
149
  }
150
150
  return undefined;
151
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
+ }
152
197
  /**
153
198
  * `findBestLanguageMatch`, then primary-subtag widening, so `nl-BE` and a bare
154
- * `nl` reach a regional `nl-NL` and `es-MX` reaches `es-ES`. Both stay within
155
- * the request's script: `zh` never reaches `zh-TW`, and `sr-Latn` may reach
156
- * `sr-ME`. The rule that picks an interface catalog and a content variant alike.
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.
157
205
  */
158
206
  export function findClosestLanguageMatch(requested, available) {
159
207
  const best = findBestLanguageMatch(requested, available);
@@ -162,7 +210,9 @@ export function findClosestLanguageMatch(requested, available) {
162
210
  const language = normalizeLanguageTag(requested).split("-")[0];
163
211
  if (!language)
164
212
  return undefined;
213
+ const variants = variantSubtagsKey(requested);
165
214
  const sibling = available.find((candidate) => normalizeLanguageTag(candidate).split("-")[0] === language &&
215
+ variantSubtagsKey(candidate) === variants &&
166
216
  languageTagScriptsMatch(requested, candidate));
167
217
  return sibling ?? undefined;
168
218
  }
@@ -174,20 +224,35 @@ export function findClosestLanguageMatch(requested, available) {
174
224
  * chain serves the closest match for `defaultLanguage` (`en-US` reaches `en`),
175
225
  * then content that declares no language (a `null` entry), then `source`;
176
226
  * `matching` does not narrow it. `undefined` means nothing can be served. No
177
- * requested languages run the chain without a substitution.
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.
178
233
  */
179
234
  export function negotiateContentLanguage(requested, available, options = {}) {
180
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
+ });
181
243
  for (const tag of requested) {
182
244
  const language = matching === "exact"
183
- ? available.find((candidate) => languageTagsEqual(candidate, tag))
184
- : findClosestLanguageMatch(tag, available);
185
- if (language != null)
186
- return { language };
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
+ }
187
252
  }
188
253
  if (requested.length > 0 && fallback === "none")
189
254
  return undefined;
190
- let language = findClosestLanguageMatch(defaultLanguage, available);
255
+ let language = findClosestLanguageMatch(defaultLanguage, candidates);
191
256
  if (language === undefined && available.includes(null))
192
257
  language = null;
193
258
  if (language === undefined && source !== undefined && available.includes(source)) {
@@ -301,6 +301,12 @@ declare const enUS: {
301
301
  lanthanide: string;
302
302
  actinide: string;
303
303
  };
304
+ /** Standard-state phase, keyed by the data file's lowercased `phase` value. */
305
+ phaseValue: {
306
+ solid: string;
307
+ liquid: string;
308
+ gas: string;
309
+ };
304
310
  };
305
311
  ruler: {
306
312
  name: string;
@@ -462,6 +468,17 @@ declare const enUS: {
462
468
  highlightPinkA11y: string;
463
469
  highlightBlueA11y: string;
464
470
  highlightGreenA11y: string;
471
+ selectionAvailableA11y: string;
472
+ highlightedYellowA11y: string;
473
+ highlightedPinkA11y: string;
474
+ highlightedBlueA11y: string;
475
+ highlightedGreenA11y: string;
476
+ underlinedA11y: string;
477
+ removedA11y: string;
478
+ clearedA11y: {
479
+ one: string;
480
+ other: string;
481
+ };
465
482
  };
466
483
  theme: {
467
484
  name: string;
@@ -306,6 +306,12 @@ const enUS = {
306
306
  lanthanide: "Lanthanide",
307
307
  actinide: "Actinide",
308
308
  },
309
+ /** Standard-state phase, keyed by the data file's lowercased `phase` value. */
310
+ phaseValue: {
311
+ solid: "Solid",
312
+ liquid: "Liquid",
313
+ gas: "Gas",
314
+ },
309
315
  },
310
316
  ruler: {
311
317
  name: "Ruler",
@@ -482,6 +488,19 @@ const enUS = {
482
488
  highlightPinkA11y: "Pink highlight",
483
489
  highlightBlueA11y: "Blue highlight",
484
490
  highlightGreenA11y: "Green highlight",
491
+ // Announced in the strip's live region. A colour is authored per phrase:
492
+ // a colour adjective agrees with its noun in some languages.
493
+ selectionAvailableA11y: "Annotation toolbar available for “{text}”. Press Shift+F10 for annotation tools.",
494
+ highlightedYellowA11y: "“{text}” highlighted in yellow",
495
+ highlightedPinkA11y: "“{text}” highlighted in pink",
496
+ highlightedBlueA11y: "“{text}” highlighted in blue",
497
+ highlightedGreenA11y: "“{text}” highlighted in green",
498
+ underlinedA11y: "“{text}” underlined",
499
+ removedA11y: "Removed annotation from “{text}”",
500
+ clearedA11y: {
501
+ one: "{count} annotation cleared",
502
+ other: "{count} annotations cleared",
503
+ },
485
504
  },
486
505
  theme: {
487
506
  name: "Theme",
@@ -16,8 +16,13 @@ import type enUS from "./messages/en-US.js";
16
16
  * syntax — the Learnosity transform emits POSIX `nl_NL`, QTI catalog cards carry
17
17
  * `xml:lang` — and the resolver normalizes both onto these, so this list is the
18
18
  * set of catalogs, not the set of accepted inputs.
19
+ *
20
+ * `en-US` is the only bundled catalog: the statically resident source of truth
21
+ * every other locale falls back through. Every other locale this repository
22
+ * ships lives in the optional generated tier
23
+ * (`@pie-players/pie-i18n-generated`), which a host composes into its loader.
19
24
  */
20
- export type BundledLocaleCode = "en-US" | "nl-NL";
25
+ export type BundledLocaleCode = "en-US";
21
26
  /**
22
27
  * Any locale tag. A host may supply a locale we ship no catalog for and provide
23
28
  * its own messages through {@link I18nConfig.customMessages}, so this is
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Hostname classification for a URL a server fetches on someone else's behalf,
3
+ * such as an SSML `<audio src>` a TTS provider will request or an asset URL an
4
+ * upstream service returns. Every such check reads this module, so no two
5
+ * copies of it can drift apart.
6
+ *
7
+ * IP literals are classified by `ipaddr.js`, whose ranges follow the IANA IPv4
8
+ * and IPv6 Special-Purpose Address Registries (RFC 6890). A host is private
9
+ * unless it is a public unicast address or a name: loopback, private, link-local,
10
+ * unique-local, carrier-grade NAT, multicast, broadcast, documentation and
11
+ * reserved ranges, and the IPv6 forms that embed an IPv4 address (NAT64, 6to4,
12
+ * Teredo), all count. `ipaddr.js` also reads the numeric IPv4 forms (`2130706433`,
13
+ * `0x7f000001`, `0177.0.0.1`) and unmaps IPv4-mapped IPv6.
14
+ *
15
+ * Only the literal is checked. A public name that resolves to a private address
16
+ * passes; guarding that takes a check of the resolved address at connect time.
17
+ *
18
+ * @module
19
+ */
20
+ /** Whether `hostname` is a cloud instance-metadata endpoint. */
21
+ export declare function isCloudMetadataHostname(hostname: string): boolean;
22
+ /**
23
+ * Whether `hostname` is anything other than a public unicast address or a
24
+ * public name: `localhost` and its subdomains (RFC 6761), or an IP literal in
25
+ * any special-purpose range.
26
+ */
27
+ export declare function isPrivateHostname(hostname: string): boolean;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Hostname classification for a URL a server fetches on someone else's behalf,
3
+ * such as an SSML `<audio src>` a TTS provider will request or an asset URL an
4
+ * upstream service returns. Every such check reads this module, so no two
5
+ * copies of it can drift apart.
6
+ *
7
+ * IP literals are classified by `ipaddr.js`, whose ranges follow the IANA IPv4
8
+ * and IPv6 Special-Purpose Address Registries (RFC 6890). A host is private
9
+ * unless it is a public unicast address or a name: loopback, private, link-local,
10
+ * unique-local, carrier-grade NAT, multicast, broadcast, documentation and
11
+ * reserved ranges, and the IPv6 forms that embed an IPv4 address (NAT64, 6to4,
12
+ * Teredo), all count. `ipaddr.js` also reads the numeric IPv4 forms (`2130706433`,
13
+ * `0x7f000001`, `0177.0.0.1`) and unmaps IPv4-mapped IPv6.
14
+ *
15
+ * Only the literal is checked. A public name that resolves to a private address
16
+ * passes; guarding that takes a check of the resolved address at connect time.
17
+ *
18
+ * @module
19
+ */
20
+ import ipaddr from "ipaddr.js";
21
+ /**
22
+ * Cloud instance-metadata hostnames. They hand out credentials. No registry
23
+ * lists them; each is the provider's documented name.
24
+ */
25
+ const CLOUD_METADATA_HOSTNAMES = new Set([
26
+ "metadata.google.internal",
27
+ "metadata.azure.internal",
28
+ "metadata.packet.net",
29
+ "metadata.oci.oraclecloud.com",
30
+ ]);
31
+ /** Cloud instance-metadata addresses: the link-local one most clouds use, and AWS's IPv6 one. */
32
+ const CLOUD_METADATA_ADDRESSES = ["169.254.169.254", "fd00:ec2::254"].map((address) => ipaddr.parse(address));
33
+ /** Lower-cased, without brackets or a trailing dot. */
34
+ function normalizeHostname(hostname) {
35
+ return hostname
36
+ .replace(/^\[|\]$/g, "")
37
+ .replace(/\.$/, "")
38
+ .toLowerCase();
39
+ }
40
+ /**
41
+ * The address `host` is a literal for, IPv4-mapped (`::ffff:a.b.c.d`) and
42
+ * IPv4-compatible (`::a.b.c.d`) IPv6 reduced to IPv4, or `null` for a name.
43
+ */
44
+ function parseAddress(host) {
45
+ if (!ipaddr.isValid(host))
46
+ return null;
47
+ const address = ipaddr.process(host);
48
+ if (address.kind() !== "ipv6")
49
+ return address;
50
+ // `ipaddr.js` classifies deprecated IPv4-compatible addresses as unicast;
51
+ // read the IPv4 address in their low 32 bits instead.
52
+ const { parts } = address;
53
+ const isIpv4Compatible = parts.slice(0, 6).every((part) => part === 0) &&
54
+ (parts[6] !== 0 || parts[7] > 1);
55
+ return isIpv4Compatible
56
+ ? new ipaddr.IPv4([
57
+ parts[6] >> 8,
58
+ parts[6] & 0xff,
59
+ parts[7] >> 8,
60
+ parts[7] & 0xff,
61
+ ])
62
+ : address;
63
+ }
64
+ /** Whether `hostname` is a cloud instance-metadata endpoint. */
65
+ export function isCloudMetadataHostname(hostname) {
66
+ const host = normalizeHostname(hostname);
67
+ if (CLOUD_METADATA_HOSTNAMES.has(host))
68
+ return true;
69
+ const address = parseAddress(host);
70
+ return (address !== null &&
71
+ CLOUD_METADATA_ADDRESSES.some((metadata) => metadata.kind() === address.kind() &&
72
+ metadata.toNormalizedString() === address.toNormalizedString()));
73
+ }
74
+ /**
75
+ * Whether `hostname` is anything other than a public unicast address or a
76
+ * public name: `localhost` and its subdomains (RFC 6761), or an IP literal in
77
+ * any special-purpose range.
78
+ */
79
+ export function isPrivateHostname(hostname) {
80
+ const host = normalizeHostname(hostname);
81
+ if (host === "localhost" || host.endsWith(".localhost"))
82
+ return true;
83
+ const address = parseAddress(host);
84
+ return address !== null && address.range() !== "unicast";
85
+ }
@@ -13,6 +13,7 @@
13
13
  * corpus behind one is licensed per programme, so a host supplies either an endpoint
14
14
  * or a resolver of its own.
15
15
  */
16
+ import type { TextDirection } from "../i18n/types.js";
16
17
  /** What a host's term service is asked for. */
17
18
  export interface TermLookupRequest {
18
19
  /** The term, already normalised by {@link normalizeTerm}. */
@@ -187,3 +188,17 @@ export declare function termPanelStatusMessage<TItem>(state: TermPanelState<TIte
187
188
  countLabel: (count: number) => string;
188
189
  emptyLabel: (term: string) => string;
189
190
  }): string;
191
+ /**
192
+ * `lang` and `dir` for a lookup's results: the language the lookup asked in.
193
+ *
194
+ * Results are in the corpus's language, which is neither the panel's interface
195
+ * locale nor necessarily the content the term came from: a Spanish gloss for an
196
+ * English passage. Stamped on the results, an Arabic definition in an English panel
197
+ * reads and lays out as Arabic, and the panel's language never reaches the content.
198
+ * With no language named the corpus's is unknown, so the stamp is `lang=""` and
199
+ * `dir="auto"`, the one undeclared content gets.
200
+ */
201
+ export declare function termResultLanguage(language: string | undefined): {
202
+ lang: string;
203
+ dir: TextDirection | "auto";
204
+ };
@@ -13,6 +13,8 @@
13
13
  * corpus behind one is licensed per programme, so a host supplies either an endpoint
14
14
  * or a resolver of its own.
15
15
  */
16
+ import { toLanguageTag } from "../i18n/language-tags.js";
17
+ import { localeDirection } from "../i18n/provider.js";
16
18
  /** Longest term worth sending. Past this it is prose, not a headword. */
17
19
  export const MAX_LOOKUP_TERM_LENGTH = 80;
18
20
  /** Words a term may span. Generous enough for `carbon dioxide` and `mother in law`. */
@@ -282,3 +284,17 @@ export function termPanelStatusMessage(state, labels) {
282
284
  return labels.emptyLabel(state.term);
283
285
  return "";
284
286
  }
287
+ /**
288
+ * `lang` and `dir` for a lookup's results: the language the lookup asked in.
289
+ *
290
+ * Results are in the corpus's language, which is neither the panel's interface
291
+ * locale nor necessarily the content the term came from: a Spanish gloss for an
292
+ * English passage. Stamped on the results, an Arabic definition in an English panel
293
+ * reads and lays out as Arabic, and the panel's language never reaches the content.
294
+ * With no language named the corpus's is unknown, so the stamp is `lang=""` and
295
+ * `dir="auto"`, the one undeclared content gets.
296
+ */
297
+ export function termResultLanguage(language) {
298
+ const tag = toLanguageTag(language);
299
+ return tag ? { lang: tag, dir: localeDirection(tag) } : { lang: "", dir: "auto" };
300
+ }
@@ -10,10 +10,10 @@
10
10
  */
11
11
  import type { FormativeDeliveryPolicy, FormativeItemPolicy, FormativeSectionSlice } from "../formative/types.js";
12
12
  import type { TimedMediaSectionData, TimedMediaSectionSessionSlice } from "../timed-media/types.js";
13
- import type { AdaptationLink, LanguagePolicy } from "./interchange.js";
13
+ import type { AdaptationLink, CurriculumStandard, LanguagePolicy, SkillLevel } from "./interchange.js";
14
14
  export type { FormativeDeliveryPolicy, FormativeItemPolicy };
15
15
  export type { TimedMediaSectionData };
16
- export type { AdaptationLink, ContentSubstitution, LanguagePolicy, ServedContentIdentity, } from "./interchange.js";
16
+ export type { AdaptationLink, ContentSubstitution, CurriculumStandard, LanguagePolicy, ServedContentIdentity, SkillLevel, } from "./interchange.js";
17
17
  export interface BaseEntity {
18
18
  id?: string;
19
19
  createdAt?: Date;
@@ -76,6 +76,13 @@ export interface PassageEntity extends VersionEntity, ConfigContainerEntity {
76
76
  isAdaptationOf?: AdaptationLink;
77
77
  /** QTI/APIP-style accessibility catalogs owned by this passage content. */
78
78
  accessibilityCatalogs?: AccessibilityCatalog[];
79
+ /**
80
+ * Standards and skills the content is aligned to. Absent means no data; `[]`
81
+ * means the writer states that none applies. No player reads it.
82
+ */
83
+ curriculumStandards?: CurriculumStandard[];
84
+ /** DOK and Bloom's classifications, on the same terms. No player reads it. */
85
+ skillLevels?: SkillLevel[];
79
86
  retired?: boolean;
80
87
  published?: boolean;
81
88
  }
@@ -96,6 +103,13 @@ export interface ItemEntity extends VersionEntity, ConfigContainerEntity {
96
103
  passage?: string | PassageEntity | null;
97
104
  /** QTI/APIP-style accessibility catalogs owned by the item root. */
98
105
  accessibilityCatalogs?: AccessibilityCatalog[];
106
+ /**
107
+ * Standards and skills the content is aligned to. Absent means no data; `[]`
108
+ * means the writer states that none applies. No player reads it.
109
+ */
110
+ curriculumStandards?: CurriculumStandard[];
111
+ /** DOK and Bloom's classifications, on the same terms. No player reads it. */
112
+ skillLevels?: SkillLevel[];
99
113
  retired?: boolean;
100
114
  published?: boolean;
101
115
  configuration?: any;