@phis/contracts 0.2.0 → 0.4.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.
Files changed (51) hide show
  1. package/NOTICE +1 -1
  2. package/README.md +175 -67
  3. package/dist/access.d.ts +63 -0
  4. package/dist/access.js +78 -0
  5. package/dist/addon-index.d.ts +5 -0
  6. package/dist/addon-index.js +5 -0
  7. package/dist/addon.d.ts +181 -57
  8. package/dist/addon.js +2 -2
  9. package/dist/assets.d.ts +165 -0
  10. package/dist/assets.js +1 -0
  11. package/dist/capabilities.d.ts +175 -33
  12. package/dist/capabilities.js +2 -2
  13. package/dist/catalog.d.ts +104 -0
  14. package/dist/catalog.js +116 -0
  15. package/dist/cms.d.ts +120 -0
  16. package/dist/cms.js +270 -0
  17. package/dist/core.d.ts +52 -6
  18. package/dist/core.js +55 -8
  19. package/dist/http.d.ts +33 -0
  20. package/dist/http.js +33 -0
  21. package/dist/locale.d.ts +14 -0
  22. package/dist/locale.js +14 -0
  23. package/dist/logging.d.ts +49 -0
  24. package/dist/logging.js +17 -0
  25. package/dist/media-storage.d.ts +371 -0
  26. package/dist/media-storage.js +84 -0
  27. package/dist/media.d.ts +272 -0
  28. package/dist/media.js +421 -0
  29. package/dist/queries.d.ts +280 -48
  30. package/dist/queries.js +10 -1
  31. package/dist/resource-links.d.ts +94 -0
  32. package/dist/resource-links.js +27 -0
  33. package/dist/schema.d.ts +160 -26
  34. package/dist/schema.js +11 -2
  35. package/dist/server-capabilities.d.ts +37 -0
  36. package/dist/server-capabilities.js +17 -0
  37. package/dist/signals.d.ts +58 -0
  38. package/dist/signals.js +118 -0
  39. package/dist/site-groups.d.ts +53 -0
  40. package/dist/site-groups.js +54 -0
  41. package/dist/support.d.ts +390 -0
  42. package/dist/support.js +148 -0
  43. package/dist/threads.d.ts +363 -0
  44. package/dist/threads.js +146 -0
  45. package/dist/translation.d.ts +125 -0
  46. package/dist/translation.js +15 -0
  47. package/dist/user-state.d.ts +132 -0
  48. package/dist/user-state.js +92 -0
  49. package/package.json +64 -4
  50. package/dist/index.d.ts +0 -6
  51. package/dist/index.js +0 -6
package/dist/media.js ADDED
@@ -0,0 +1,421 @@
1
+ /**
2
+ * How a Media Space names its kind on the wire.
3
+ *
4
+ * Only the name is shared. phis stores the kind as a smallint and that number is its own business --
5
+ * the site UI never sees it, so putting it here would place a storage detail in a contract.
6
+ *
7
+ * What made this worth writing down is that the translation between the two was incomplete: phis knew
8
+ * four kinds and mapped three, falling back to `"site"` for the rest, while the UI declared its own
9
+ * union of the same three. An Add-on Space therefore reached the administration surface calling itself
10
+ * a Site Space, in the one view that shows Add-on Spaces at all. With the names here, the map on the
11
+ * server side is a total function from its own enum onto this one, and a kind that is added and not
12
+ * mapped stops compiling.
13
+ */
14
+ export const PHI_MEDIA_SPACE_KINDS = ["site", "user", "group", "addon"];
15
+ /**
16
+ * The kinds a Module may declare a need for.
17
+ *
18
+ * The Site Space always exists and is governed by the Core Media role matrix, so declaring it could
19
+ * only repeat what is already true. An Add-on Space belongs to its Add-on and comes with installing
20
+ * it, rather than being asked for by a Module.
21
+ */
22
+ export const PHI_DECLARABLE_MEDIA_SPACE_KINDS = ["user", "group"];
23
+ export function isPhiDeclarableMediaSpaceKind(value) {
24
+ return PHI_DECLARABLE_MEDIA_SPACE_KINDS.includes(value);
25
+ }
26
+ /**
27
+ * The display properties an Asset may carry, as bits of `media_assets.presentation_flags`.
28
+ *
29
+ * Shared because the number itself travels: the site UI sends a mask as a list filter and an array of
30
+ * these values when metadata is saved, and phis both answers the filter with `flags & mask <> 0` and
31
+ * refuses a value it does not know. Two sides that must agree on a number is what this package is for,
32
+ * and the copy that lived on each side had already drifted -- phis still carried `Private`, `Archived`
33
+ * and `Restricted` long after lifecycle and delivery had become columns of their own.
34
+ *
35
+ * Display only. Access is `delivery_policy` and `lifecycle_status`, never a bit in here.
36
+ *
37
+ * Bits 1 and 2 are free, and before v1 a bit may be given a new meaning rather than kept vacant.
38
+ */
39
+ export const PhiMediaAssetFlags = {
40
+ /** Offered as a background. What an Asset is for; where it came from is `meta.source`. */
41
+ Background: 1 << 0,
42
+ Featured: 1 << 3,
43
+ Locked: 1 << 4,
44
+ Mask: 1 << 5,
45
+ };
46
+ /**
47
+ * The rest of the Media vocabulary both sides speak.
48
+ *
49
+ * Kind names travel as `kind` in the list contract and are stored on the Asset; Folder flags travel in
50
+ * the Folder payload; a variant key is a number in a delivery URL and the name phis generates the
51
+ * rendition under. None of it is one side's private business, and all of it stood twice -- once here in
52
+ * spirit, once in each repository in fact -- until a bit value drifted and showed what that costs.
53
+ *
54
+ * The two resolvers come along because a vocabulary without its total function is only half a contract:
55
+ * a content type must land on the same kind in the browser and on the server, or an upload is filed as
56
+ * one thing and listed as another.
57
+ */
58
+ export const PhiMediaFolderFlags = {
59
+ Collapsed: 1 << 0,
60
+ Hidden: 1 << 1,
61
+ Locked: 1 << 2,
62
+ };
63
+ export const PhiMediaKind = {
64
+ Image: "image",
65
+ Video: "video",
66
+ Audio: "audio",
67
+ Pdf: "pdf",
68
+ Markdown: "markdown",
69
+ Document: "document",
70
+ Archive: "archive",
71
+ /*
72
+ * A typeface, kept because a Site owns its lettering the way it owns its pictures.
73
+ *
74
+ * Neither `Document` nor `Binary` would do. A font is not read and not handed over as a download: a
75
+ * stylesheet loads it, and the Theme that names it has to be able to find it again years later --
76
+ * including after the Module that once brought it was switched off. A kind of its own is what makes
77
+ * that a query rather than a guess about content types.
78
+ */
79
+ Font: "font",
80
+ /*
81
+ * Arbitrary bytes, offered as a download and nothing else.
82
+ *
83
+ * Separate from `Other` because the two mean opposite things to a declaration: `Binary` is a Module
84
+ * saying it wants executables and disk images, while `Other` is the answer for a type nothing has
85
+ * classified. Without it, a Module that legitimately distributes an installer would have to declare
86
+ * the catch-all to get one.
87
+ */
88
+ Binary: "binary",
89
+ Other: "other",
90
+ };
91
+ export const PhiImageAssetVariantKey = {
92
+ Thumbnail: 0,
93
+ Preview: 1,
94
+ Banner: 2,
95
+ Header: 3,
96
+ Card: 4,
97
+ Hero: 5,
98
+ Avatar: 6,
99
+ Logo: 7,
100
+ Landscape: 8,
101
+ Portrait: 9,
102
+ };
103
+ export const PhiImageAssetVariantKeyName = {
104
+ [PhiImageAssetVariantKey.Thumbnail]: "thumbnail",
105
+ [PhiImageAssetVariantKey.Preview]: "preview",
106
+ [PhiImageAssetVariantKey.Banner]: "banner",
107
+ [PhiImageAssetVariantKey.Header]: "header",
108
+ [PhiImageAssetVariantKey.Card]: "card",
109
+ [PhiImageAssetVariantKey.Hero]: "hero",
110
+ [PhiImageAssetVariantKey.Avatar]: "avatar",
111
+ [PhiImageAssetVariantKey.Logo]: "logo",
112
+ [PhiImageAssetVariantKey.Landscape]: "landscape",
113
+ [PhiImageAssetVariantKey.Portrait]: "portrait",
114
+ };
115
+ export function normalizePhiMediaKind(kind) {
116
+ const normalized = (kind ?? "").trim().toLowerCase();
117
+ if (normalized === PhiMediaKind.Image ||
118
+ normalized === PhiMediaKind.Video ||
119
+ normalized === PhiMediaKind.Audio ||
120
+ normalized === PhiMediaKind.Pdf ||
121
+ normalized === PhiMediaKind.Markdown ||
122
+ normalized === PhiMediaKind.Document ||
123
+ normalized === PhiMediaKind.Archive ||
124
+ normalized === PhiMediaKind.Font ||
125
+ normalized === PhiMediaKind.Binary) {
126
+ return normalized;
127
+ }
128
+ return PhiMediaKind.Other;
129
+ }
130
+ export function resolvePhiMediaKindFromContentType(contentType) {
131
+ const normalized = (contentType ?? "").trim().toLowerCase();
132
+ if (!normalized) {
133
+ return PhiMediaKind.Other;
134
+ }
135
+ if (normalized.startsWith("image/")) {
136
+ return PhiMediaKind.Image;
137
+ }
138
+ if (normalized.startsWith("video/")) {
139
+ return PhiMediaKind.Video;
140
+ }
141
+ if (normalized.startsWith("audio/")) {
142
+ return PhiMediaKind.Audio;
143
+ }
144
+ /*
145
+ * `font/*` is the registered tree (RFC 8081); the `application/...` spellings below it are what older
146
+ * tools still send for the same bytes.
147
+ */
148
+ if (normalized.startsWith("font/") ||
149
+ normalized === "application/font-woff" ||
150
+ normalized === "application/x-font-woff" ||
151
+ normalized === "application/x-font-ttf" ||
152
+ normalized === "application/x-font-otf" ||
153
+ normalized === "application/x-font-truetype" ||
154
+ normalized === "application/x-font-opentype" ||
155
+ normalized === "application/vnd.ms-fontobject") {
156
+ return PhiMediaKind.Font;
157
+ }
158
+ if (normalized === "application/pdf") {
159
+ return PhiMediaKind.Pdf;
160
+ }
161
+ if (normalized === "text/markdown") {
162
+ return PhiMediaKind.Markdown;
163
+ }
164
+ if (normalized === "application/msword" ||
165
+ normalized === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
166
+ normalized === "application/rtf" ||
167
+ normalized === "text/plain" ||
168
+ normalized === "application/vnd.oasis.opendocument.text") {
169
+ return PhiMediaKind.Document;
170
+ }
171
+ if (normalized === "application/zip" ||
172
+ normalized === "application/x-7z-compressed" ||
173
+ normalized === "application/x-rar-compressed" ||
174
+ normalized === "application/x-tar") {
175
+ return PhiMediaKind.Archive;
176
+ }
177
+ /*
178
+ * `application/octet-stream` belongs here rather than with the unclassified: an unlabelled blob is
179
+ * bytes, and a Module declaring `binary` is saying it accepts exactly that.
180
+ */
181
+ if (normalized === "application/octet-stream" ||
182
+ normalized === "application/x-msdownload" ||
183
+ normalized === "application/vnd.microsoft.portable-executable" ||
184
+ normalized === "application/x-executable" ||
185
+ normalized === "application/x-mach-binary" ||
186
+ normalized === "application/x-msi" ||
187
+ normalized === "application/x-apple-diskimage" ||
188
+ normalized === "application/vnd.debian.binary-package" ||
189
+ normalized === "application/x-rpm") {
190
+ return PhiMediaKind.Binary;
191
+ }
192
+ return PhiMediaKind.Other;
193
+ }
194
+ export function normalizePhiImageAssetVariantKey(key) {
195
+ const parsed = typeof key === "number"
196
+ ? key
197
+ : Number.parseInt(typeof key === "string" ? key.trim() : "", 10);
198
+ if (Number.isInteger(parsed) &&
199
+ Object.prototype.hasOwnProperty.call(PhiImageAssetVariantKeyName, parsed)) {
200
+ return parsed;
201
+ }
202
+ return null;
203
+ }
204
+ export function resolvePhiImageAssetVariantKeyName(key) {
205
+ if (typeof key !== "number" || !Number.isInteger(key)) {
206
+ return null;
207
+ }
208
+ return PhiImageAssetVariantKeyName[key] ?? null;
209
+ }
210
+ export function resolvePhiImageAssetVariantSpec(key) {
211
+ switch (key) {
212
+ case PhiImageAssetVariantKey.Thumbnail:
213
+ return { width: 256, height: 256, fit: "cover", quality: 80, format: "webp" };
214
+ case PhiImageAssetVariantKey.Preview:
215
+ return { width: 640, height: 360, fit: "cover", quality: 82, format: "webp" };
216
+ case PhiImageAssetVariantKey.Banner:
217
+ return { width: 1600, height: 89, fit: "cover", quality: 82, format: "webp" };
218
+ case PhiImageAssetVariantKey.Header:
219
+ return { width: 1920, height: 144, fit: "cover", quality: 82, format: "webp" };
220
+ case PhiImageAssetVariantKey.Card:
221
+ return { width: 960, height: 640, fit: "cover", quality: 82, format: "webp" };
222
+ case PhiImageAssetVariantKey.Hero:
223
+ return { width: 1600, height: 900, fit: "cover", quality: 84, format: "webp" };
224
+ case PhiImageAssetVariantKey.Avatar:
225
+ return { width: 100, height: 100, fit: "cover", quality: 80, format: "webp" };
226
+ case PhiImageAssetVariantKey.Logo:
227
+ return { width: 512, height: 256, fit: "contain", quality: 82, format: "webp" };
228
+ case PhiImageAssetVariantKey.Landscape:
229
+ return { width: 640, height: 396, fit: "cover", quality: 82, format: "webp" };
230
+ case PhiImageAssetVariantKey.Portrait:
231
+ return { width: 396, height: 640, fit: "cover", quality: 82, format: "webp" };
232
+ default:
233
+ return null;
234
+ }
235
+ }
236
+ /**
237
+ * The two closed vocabularies that decide whether an Asset may be delivered at all.
238
+ *
239
+ * The numbers themselves reach the site UI: an Asset's detail payload carries `lifecycleStatus` and
240
+ * `deliveryPolicy` as they stand in the row, and the UI decides from them whether `next/image` may be
241
+ * pointed at the original. phis evaluates `delivery_policy` before any physical lookup, so a value that
242
+ * means one thing on one side and another on the other would be a visibility bug, not a display bug.
243
+ *
244
+ * Unlike the Space kind next to them in phis, which stays a storage detail because the wire carries its
245
+ * name instead of its number.
246
+ */
247
+ export const PhiMediaLifecycleStatus = {
248
+ Pending: 1,
249
+ Ready: 2,
250
+ Archived: 3,
251
+ Quarantined: 4,
252
+ Failed: 5,
253
+ };
254
+ export const PhiMediaDeliveryPolicy = {
255
+ Public: 1,
256
+ Authenticated: 2,
257
+ User: 3,
258
+ Group: 4,
259
+ Internal: 5,
260
+ };
261
+ /**
262
+ * The cuts a typeface can be delivered in.
263
+ *
264
+ * The number stands in a delivery URL, so it is shared; the named cuts follow the unicode ranges Google
265
+ * Fonts divides its families along, so a face declared here splits where fonts are commonly split.
266
+ * `Rest` is not a range but everything a typeface has outside all of them -- its own characters, listed
267
+ * per font in `PhiFontCoverage.restRanges`, so a page never loses a glyph and never fetches a cut for a
268
+ * character the font does not have.
269
+ */
270
+ export const PhiFontSubsetKey = {
271
+ Latin: 0,
272
+ LatinExt: 1,
273
+ Cyrillic: 2,
274
+ CyrillicExt: 3,
275
+ Greek: 4,
276
+ GreekExt: 5,
277
+ Vietnamese: 6,
278
+ Math: 7,
279
+ Symbols: 8,
280
+ Rest: 15,
281
+ };
282
+ /**
283
+ * Raised whenever the ranges below or the way a cut is produced changes, so every stored cut is made
284
+ * again rather than served under a rule that no longer describes it.
285
+ */
286
+ export const PHI_FONT_SUBSET_VERSION = 1;
287
+ const range = (text) => text.split(",").map((part) => {
288
+ const [start, end] = part.trim().replace(/^U\+/u, "").split("-");
289
+ const first = Number.parseInt(start, 16);
290
+ return [first, end ? Number.parseInt(end, 16) : first];
291
+ });
292
+ /** The named cuts, as Google Fonts' CSS2 API states them for a Latin, Cyrillic and Greek family. */
293
+ export const PHI_FONT_SUBSET_RANGES = {
294
+ [PhiFontSubsetKey.Latin]: range("U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD"),
295
+ [PhiFontSubsetKey.LatinExt]: range("U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF"),
296
+ [PhiFontSubsetKey.Cyrillic]: range("U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116"),
297
+ [PhiFontSubsetKey.CyrillicExt]: range("U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F"),
298
+ [PhiFontSubsetKey.Greek]: range("U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF"),
299
+ [PhiFontSubsetKey.GreekExt]: range("U+1F00-1FFF"),
300
+ [PhiFontSubsetKey.Vietnamese]: range("U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB"),
301
+ [PhiFontSubsetKey.Math]: range("U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF"),
302
+ [PhiFontSubsetKey.Symbols]: range("U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF"),
303
+ };
304
+ export function isPhiCodepointInRanges(codepoint, ranges) {
305
+ return ranges.some(([start, end]) => codepoint >= start && codepoint <= end);
306
+ }
307
+ /** A `unicode-range` descriptor value. */
308
+ export function formatPhiUnicodeRange(ranges) {
309
+ return ranges
310
+ .map(([start, end]) => {
311
+ const hex = (value) => value.toString(16).toUpperCase().padStart(4, "0");
312
+ return start === end ? `U+${hex(start)}` : `U+${hex(start)}-${hex(end)}`;
313
+ })
314
+ .join(", ");
315
+ }
316
+ /** The ranges a cut covers for one typeface: the shared list for a named cut, the font's own for `Rest`. */
317
+ export function resolvePhiFontSubsetRanges(key, coverage) {
318
+ return key === PhiFontSubsetKey.Rest ? coverage.restRanges : PHI_FONT_SUBSET_RANGES[key];
319
+ }
320
+ export function isPhiFontSubsetKey(value) {
321
+ return typeof value === "number" && Object.values(PhiFontSubsetKey).includes(value);
322
+ }
323
+ /*
324
+ * Which cuts a page in one language needs before it paints, beyond `Latin` -- which every page needs,
325
+ * because spaces, digits and most punctuation live there whatever the script.
326
+ *
327
+ * This decides what is preloaded and nothing else: every cut a font covers is still declared, so a
328
+ * glyph outside the list is fetched when it appears rather than missing. A language not listed here
329
+ * preloads `Latin` alone, which is the right answer for most Latin-script languages and a harmless one
330
+ * for a script this list does not name -- its cut is then discovered by the browser, one round-trip later.
331
+ */
332
+ const PHI_FONT_SUBSET_PRELOAD_BY_LANGUAGE = {
333
+ // Latin script with letters beyond Latin-1: č, ł, ő, ș, ğ, ž and their kin.
334
+ az: [PhiFontSubsetKey.LatinExt], bs: [PhiFontSubsetKey.LatinExt], cs: [PhiFontSubsetKey.LatinExt],
335
+ cy: [PhiFontSubsetKey.LatinExt], et: [PhiFontSubsetKey.LatinExt], hr: [PhiFontSubsetKey.LatinExt],
336
+ hu: [PhiFontSubsetKey.LatinExt], lt: [PhiFontSubsetKey.LatinExt], lv: [PhiFontSubsetKey.LatinExt],
337
+ mt: [PhiFontSubsetKey.LatinExt], pl: [PhiFontSubsetKey.LatinExt], ro: [PhiFontSubsetKey.LatinExt],
338
+ sk: [PhiFontSubsetKey.LatinExt], sl: [PhiFontSubsetKey.LatinExt], tr: [PhiFontSubsetKey.LatinExt],
339
+ vi: [PhiFontSubsetKey.Vietnamese],
340
+ be: [PhiFontSubsetKey.Cyrillic], bg: [PhiFontSubsetKey.Cyrillic], mk: [PhiFontSubsetKey.Cyrillic],
341
+ ru: [PhiFontSubsetKey.Cyrillic], sr: [PhiFontSubsetKey.Cyrillic], uk: [PhiFontSubsetKey.Cyrillic],
342
+ // Cyrillic with letters the basic block lacks: қ, ң, ө, ү, ҳ.
343
+ kk: [PhiFontSubsetKey.Cyrillic, PhiFontSubsetKey.CyrillicExt], ky: [PhiFontSubsetKey.Cyrillic, PhiFontSubsetKey.CyrillicExt],
344
+ mn: [PhiFontSubsetKey.Cyrillic, PhiFontSubsetKey.CyrillicExt], tg: [PhiFontSubsetKey.Cyrillic, PhiFontSubsetKey.CyrillicExt],
345
+ el: [PhiFontSubsetKey.Greek],
346
+ };
347
+ /**
348
+ * The cuts to preload for a page in `locale`, `Latin` first, restricted to what the font covers.
349
+ *
350
+ * `locale` is a BCP 47 tag; its script subtag overrides the language's usual one, so `sr-Latn` preloads
351
+ * `LatinExt` where `sr` preloads `Cyrillic`.
352
+ */
353
+ export function resolvePhiFontPreloadSubsetKeys(locale, coverage) {
354
+ const [language = "", ...subtags] = (locale ?? "").trim().toLowerCase().split(/[-_]/);
355
+ const script = subtags.find((subtag) => subtag.length === 4);
356
+ const extra = script === "latn"
357
+ ? [PhiFontSubsetKey.LatinExt]
358
+ : script === "cyrl"
359
+ ? [PhiFontSubsetKey.Cyrillic]
360
+ : PHI_FONT_SUBSET_PRELOAD_BY_LANGUAGE[language] ?? [];
361
+ return [PhiFontSubsetKey.Latin, ...extra].filter((key) => coverage.subsets.includes(key));
362
+ }
363
+ /**
364
+ * How a body above one part is divided, as a rule rather than as a Provider's private arithmetic.
365
+ *
366
+ * It lives here because two parties have to agree on it and neither can be told by the other in time. The
367
+ * Provider needs the part size to sign one request per part; the Client needs it *before* the plan exists,
368
+ * because a digest can only be signed into a request if it is known when the request is signed -- so the
369
+ * Client has to hash the same divisions the Provider is about to address. A rule both compute from the
370
+ * byte size is the only arrangement where that holds without an extra round trip.
371
+ *
372
+ * **Frozen once objects exist under it.** A `sha256-composite` value is a digest of part digests, so it
373
+ * answers "the same bytes divided the same way". Change the division and the same file yields a different
374
+ * value, which splits duplicate detection into cohorts that can never match across the change. The value
375
+ * carries the part size for exactly this reason: a changed rule produces non-matches, never false matches.
376
+ * Changing it is therefore a decision about the whole corpus, not a tuning knob.
377
+ *
378
+ * `PHIS_MEDIA_UPLOAD_MAX_PART_COUNT` is well under S3's ten thousand on purpose. Every part is signed up
379
+ * front, so the count is also the size of the plan a Client receives and Core holds in memory.
380
+ */
381
+ export const PHIS_MEDIA_UPLOAD_PART_SIZE_BYTES = 16 * 1024 * 1024;
382
+ export const PHIS_MEDIA_UPLOAD_MAX_PART_COUNT = 1000;
383
+ /**
384
+ * The part size for one body, and the same answer wherever it is asked.
385
+ *
386
+ * Sixteen mebibytes until that would need more parts than are allowed, and from there the smallest whole
387
+ * mebibyte that fits the body into the allowance. Whole mebibytes keep the number short where it is
388
+ * recorded and keep the arithmetic exact in a browser, where sizes are doubles.
389
+ */
390
+ export function resolvePhiMediaUploadPartSizeBytes(sizeBytes) {
391
+ const mebibyte = 1024 * 1024;
392
+ if (sizeBytes <= PHIS_MEDIA_UPLOAD_PART_SIZE_BYTES * PHIS_MEDIA_UPLOAD_MAX_PART_COUNT) {
393
+ return PHIS_MEDIA_UPLOAD_PART_SIZE_BYTES;
394
+ }
395
+ return Math.ceil(Math.ceil(sizeBytes / PHIS_MEDIA_UPLOAD_MAX_PART_COUNT) / mebibyte) * mebibyte;
396
+ }
397
+ /** How many parts one body is divided into under `resolvePhiMediaUploadPartSizeBytes`. */
398
+ export function resolvePhiMediaUploadPartCount(sizeBytes) {
399
+ return Math.max(1, Math.ceil(sizeBytes / resolvePhiMediaUploadPartSizeBytes(sizeBytes)));
400
+ }
401
+ /**
402
+ * The recorded form of a `sha256-composite` digest: the digest, the part count, the part size.
403
+ *
404
+ * Duplicate detection compares these values as strings, so everything that decides whether two of them
405
+ * mean the same thing has to be inside the string. The digest alone would not: the same bytes divided
406
+ * differently hash differently, and a value that did not say how it was divided would look like a plain
407
+ * SHA-256 that simply failed to match. Naming the division makes a mismatch legible instead of puzzling.
408
+ */
409
+ export function formatPhiMediaCompositeChecksum(digestHex, partCount, partSizeBytes) {
410
+ return `${digestHex.toLowerCase()}-${partCount}-${partSizeBytes}`;
411
+ }
412
+ export function parsePhiMediaCompositeChecksum(value) {
413
+ const match = /^([0-9a-f]{64})-(\d{1,5})-(\d{1,12})$/u.exec(value.trim().toLowerCase());
414
+ if (!match)
415
+ return null;
416
+ const partCount = Number(match[2]);
417
+ const partSizeBytes = Number(match[3]);
418
+ if (partCount < 1 || partSizeBytes < 1)
419
+ return null;
420
+ return { digest: match[1], partCount, partSizeBytes };
421
+ }