@rathnasgala2/template 2.0.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 (43) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +515 -0
  3. package/contracts/render-policy.jcs +1 -0
  4. package/contracts/theme-styling-contract.jcs +1 -0
  5. package/package.json +96 -0
  6. package/src/core/errors.js +136 -0
  7. package/src/core/index.js +758 -0
  8. package/src/core/internal/appearance/bootstrap-script.js +159 -0
  9. package/src/core/internal/appearance/contract.js +193 -0
  10. package/src/core/internal/appearance/controller-markup.js +80 -0
  11. package/src/core/internal/appearance/styling-contract.js +509 -0
  12. package/src/core/internal/canonical-jcs.js +0 -0
  13. package/src/core/internal/content-security.js +0 -0
  14. package/src/core/internal/eleventy-render.js +220 -0
  15. package/src/core/internal/feeds.js +161 -0
  16. package/src/core/internal/fs-walk.js +38 -0
  17. package/src/core/internal/media/avif-probe.js +169 -0
  18. package/src/core/internal/media/font-policy.js +56 -0
  19. package/src/core/internal/media/jpeg-codec.js +0 -0
  20. package/src/core/internal/media/limits.js +129 -0
  21. package/src/core/internal/media/pipeline.js +353 -0
  22. package/src/core/internal/media/png-codec.js +406 -0
  23. package/src/core/internal/media/resize.js +147 -0
  24. package/src/core/internal/media/sniff.js +109 -0
  25. package/src/core/internal/media/webp-probe.js +139 -0
  26. package/src/core/internal/messages.js +78 -0
  27. package/src/core/internal/page-kinds.js +709 -0
  28. package/src/core/internal/provenance.js +49 -0
  29. package/src/core/internal/render-policy-content.js +295 -0
  30. package/src/core/internal/route-labels.js +68 -0
  31. package/src/core/internal/route.js +167 -0
  32. package/src/core/internal/search-index.js +119 -0
  33. package/src/core/internal/seo.js +114 -0
  34. package/src/core/internal/sitemap.js +77 -0
  35. package/src/core/internal/skeleton.js +413 -0
  36. package/src/core/internal/source-inventory.js +145 -0
  37. package/src/core/internal/text-direction.js +59 -0
  38. package/src/core/internal/theme-assets.js +387 -0
  39. package/src/core/internal/uuid.js +51 -0
  40. package/src/core/manifest.js +0 -0
  41. package/types/eleventy-plugin-syntaxhighlight.d.ts +26 -0
  42. package/types/eleventy.d.ts +38 -0
  43. package/types/index.d.ts +347 -0
@@ -0,0 +1,509 @@
1
+ /**
2
+ * `contracts/theme-styling-contract.jcs` builder and structural validator
3
+ * (task packet S2-T12; DEC-097 section 4 "Closed theme-package and
4
+ * stylesheet admission": the `templateStylingContract` closed object).
5
+ *
6
+ * This is the one reviewed source module `scripts/generate-contracts.mjs`
7
+ * reads from to emit the published contract file, and the one module
8
+ * `test/theme-styling-contract.test.js` reads from to drift-check the
9
+ * contract against what this renderer actually renders. Every catalog leaf
10
+ * below is drawn from (or, for the two new S2-T12 hooks documented inline,
11
+ * wired into) the actual rendering modules — `internal/skeleton.js`,
12
+ * `internal/page-kinds.js`, `internal/render-policy-content.js` and
13
+ * `internal/appearance/contract.js` — never a hand-typed literal
14
+ * independent of what those modules emit.
15
+ *
16
+ * Public-hook budget (DEC-097: `publicThemeSlotHooks:[1..64] set`): every
17
+ * catalog leaf below (28 type + 15 class + 2 id + 19 attribute-value) has
18
+ * exactly one corresponding public hook, for exactly 64 entries — this
19
+ * renderer's complete public theming surface for S2. A leaf with no
20
+ * matching hook would be a catalog member no theme could ever validly
21
+ * select (DEC-097: "a used-but-undeclared public slot hook rejects"), so
22
+ * catalogs and hooks are generated from one shared list rather than two
23
+ * independently maintained ones.
24
+ *
25
+ * Deliberately out of the S2 public hook surface (documented scope
26
+ * decisions, not omissions):
27
+ * - fine-grained Prism token-class hooks (`.keyword`, `.string`, ...): only
28
+ * the base `.token` class and one hook per admitted highlight grammar's
29
+ * `.language-<grammar>` class are public, so a theme can style a
30
+ * highlighted code block's overall presentation but not recolor
31
+ * individual token kinds in S2;
32
+ * - `html`/`body` type selectors and interactive pseudo-classes
33
+ * (`:hover`, `:focus`, `:visited`, ...): themes reach the document root
34
+ * only through {@link PUBLICATION_ROOT_SELECTOR}/
35
+ * {@link RESOLVED_PALETTE_SELECTORS}, and no pseudo-class hook is
36
+ * published in S2 (an empty `pseudoClasses` catalog is schema-valid: `[0..64]`).
37
+ *
38
+ * Every tag in `internal/render-policy-content.js`'s `ALLOWED_TAGS` that
39
+ * markdown-it's own CommonMark grammar (with `html:false`) never actually
40
+ * produces — `b`, `i`, `u`, `s`, `del`, `ins`, `sub`, `sup`, `mark`, `small`,
41
+ * `wbr`, `table`, `thead`, `tbody`, `tr`, `th`, `td`, `dl`, `dt`, `dd`,
42
+ * `figure`, `figcaption` — is likewise excluded from `typeSelectors`: this
43
+ * catalog is "every hook the template actually renders" (task packet
44
+ * S2-T12), not the sanitizer's own defence-in-depth superset.
45
+ */
46
+
47
+ import { HIGHLIGHT_GRAMMARS } from '../render-policy-content.js';
48
+ import {
49
+ APPEARANCE_RESOLVED_MODE_ATTRIBUTE,
50
+ APPEARANCE_ROOT_ATTRIBUTE,
51
+ APPEARANCE_SELECT_ID,
52
+ } from './contract.js';
53
+ import { PAGE_KIND_ATTRIBUTE, PAGE_KIND_VALUES } from '../page-kinds.js';
54
+ import { SLOT_NAMES } from '../skeleton.js';
55
+ import { canonicalizeJcs, domainDigest } from '../canonical-jcs.js';
56
+
57
+ /** @type {string} */
58
+ export const TEMPLATE_STYLING_CONTRACT_PACKAGE = '@rathnasgala2/template';
59
+ /** @type {string} */
60
+ export const TEMPLATE_STYLING_CONTRACT_VERSION = '2.0.0';
61
+ /** @type {string} */
62
+ export const TEMPLATE_STYLING_CONTRACT_TEMPLATE_VERSION = '2.0.0';
63
+
64
+ /** @type {string} the `main-content` landmark's fixed `id`. */
65
+ const MAIN_CONTENT_ID = 'main-content';
66
+
67
+ /** @type {readonly string[]} the ordered `@layer` names DEC-097 fixes. */
68
+ export const ORDERED_LAYERS = Object.freeze([
69
+ 'gala-tokens',
70
+ 'gala-components',
71
+ 'gala-utilities',
72
+ 'gala-print',
73
+ ]);
74
+
75
+ /** @type {string} */
76
+ export const PUBLICATION_ROOT_SELECTOR = '[data-gala-publication-root]';
77
+
78
+ /** @type {{light: string, dark: string}} */
79
+ export const RESOLVED_PALETTE_SELECTORS = Object.freeze({
80
+ light: `[${APPEARANCE_ROOT_ATTRIBUTE}][${APPEARANCE_RESOLVED_MODE_ATTRIBUTE}="light"]`,
81
+ dark: `[${APPEARANCE_ROOT_ATTRIBUTE}][${APPEARANCE_RESOLVED_MODE_ATTRIBUTE}="dark"]`,
82
+ });
83
+
84
+ /**
85
+ * @typedef {object} StyleHookDefinition
86
+ * @property {string} hookId
87
+ * @property {string} selectorAtom
88
+ * @property {'type' | 'class' | 'id' | 'attribute'} kind
89
+ */
90
+
91
+ /**
92
+ * Every type-selector hook: one per HTML element this renderer's own
93
+ * `internal/skeleton.js`/`internal/page-kinds.js` chrome, or its
94
+ * `internal/content-security.js` markdown-it pipeline (`html:false`,
95
+ * CommonMark grammar only), ever actually emits.
96
+ *
97
+ * @type {readonly StyleHookDefinition[]}
98
+ */
99
+ const TYPE_HOOKS = Object.freeze(
100
+ [
101
+ ['landmark-header', 'header'],
102
+ ['landmark-navigation', 'nav'],
103
+ ['landmark-main', 'main'],
104
+ ['landmark-footer', 'footer'],
105
+ ['content-article', 'article'],
106
+ ['heading-1', 'h1'],
107
+ ['heading-2', 'h2'],
108
+ ['heading-3', 'h3'],
109
+ ['heading-4', 'h4'],
110
+ ['heading-5', 'h5'],
111
+ ['heading-6', 'h6'],
112
+ ['prose-paragraph', 'p'],
113
+ ['prose-list-unordered', 'ul'],
114
+ ['prose-list-ordered', 'ol'],
115
+ ['prose-list-item', 'li'],
116
+ ['prose-blockquote', 'blockquote'],
117
+ ['code-block', 'pre'],
118
+ ['code-inline', 'code'],
119
+ ['prose-strong', 'strong'],
120
+ ['prose-emphasis', 'em'],
121
+ ['prose-link', 'a'],
122
+ ['media-image', 'img'],
123
+ ['prose-divider', 'hr'],
124
+ ['control-label', 'label'],
125
+ ['control-select', 'select'],
126
+ ['control-option', 'option'],
127
+ ['prose-span', 'span'],
128
+ ['prose-time', 'time'],
129
+ ].map(([hookId, tag]) =>
130
+ Object.freeze({
131
+ hookId,
132
+ selectorAtom: tag,
133
+ kind: /** @type {const} */ ('type'),
134
+ }),
135
+ ),
136
+ );
137
+
138
+ /**
139
+ * Every class-selector hook: the base Prism `.token` class plus one
140
+ * `.language-<grammar>` hook per admitted highlight grammar (S2 scope: no
141
+ * per-token-kind class is public — see module documentation).
142
+ *
143
+ * @type {readonly StyleHookDefinition[]}
144
+ */
145
+ const CLASS_HOOKS = Object.freeze([
146
+ Object.freeze({
147
+ hookId: 'code-token',
148
+ selectorAtom: '.token',
149
+ kind: /** @type {const} */ ('class'),
150
+ }),
151
+ ...HIGHLIGHT_GRAMMARS.map((grammar) =>
152
+ Object.freeze({
153
+ hookId: `code-language-${grammar}`,
154
+ selectorAtom: `.language-${grammar}`,
155
+ kind: /** @type {const} */ ('class'),
156
+ }),
157
+ ),
158
+ ]);
159
+
160
+ /**
161
+ * Every id-selector hook: the `<main>` landmark's fixed `id` and the
162
+ * appearance control's fixed `<select id>`.
163
+ *
164
+ * @type {readonly StyleHookDefinition[]}
165
+ */
166
+ const ID_HOOKS = Object.freeze([
167
+ Object.freeze({
168
+ hookId: 'landmark-main-content',
169
+ selectorAtom: `#${MAIN_CONTENT_ID}`,
170
+ kind: /** @type {const} */ ('id'),
171
+ }),
172
+ Object.freeze({
173
+ hookId: 'control-appearance-select',
174
+ selectorAtom: `#${APPEARANCE_SELECT_ID}`,
175
+ kind: /** @type {const} */ ('id'),
176
+ }),
177
+ ]);
178
+
179
+ /**
180
+ * Every attribute-value hook: one per `data-gala-slot` value
181
+ * ({@link SLOT_NAMES}) and one per `data-gala-page-kind` value
182
+ * ({@link PAGE_KIND_VALUES}).
183
+ *
184
+ * @type {readonly StyleHookDefinition[]}
185
+ */
186
+ const ATTRIBUTE_VALUE_HOOKS = Object.freeze([
187
+ ...SLOT_NAMES.map((slotName) =>
188
+ Object.freeze({
189
+ hookId: `slot-${slotName}`,
190
+ selectorAtom: `[data-gala-slot="${slotName}"]`,
191
+ kind: /** @type {const} */ ('attribute'),
192
+ }),
193
+ ),
194
+ ...PAGE_KIND_VALUES.map((pageKind) =>
195
+ Object.freeze({
196
+ hookId: `page-${pageKind}`,
197
+ selectorAtom: `[${PAGE_KIND_ATTRIBUTE}="${pageKind}"]`,
198
+ kind: /** @type {const} */ ('attribute'),
199
+ }),
200
+ ),
201
+ ]);
202
+
203
+ /** @type {readonly StyleHookDefinition[]} every public hook, unsorted. */
204
+ const ALL_HOOKS = Object.freeze([
205
+ ...TYPE_HOOKS,
206
+ ...CLASS_HOOKS,
207
+ ...ID_HOOKS,
208
+ ...ATTRIBUTE_VALUE_HOOKS,
209
+ ]);
210
+
211
+ /**
212
+ * @param {readonly string[]} values
213
+ * @returns {string[]} a new, UTF-8-byte-sorted, duplicate-free copy
214
+ */
215
+ function sortedUniqueBytes(values) {
216
+ return [...new Set(values)].sort((a, b) => {
217
+ const bufferA = Buffer.from(a, 'utf8');
218
+ const bufferB = Buffer.from(b, 'utf8');
219
+ return Buffer.compare(bufferA, bufferB);
220
+ });
221
+ }
222
+
223
+ /**
224
+ * Build the complete `templateStylingContract` object, including
225
+ * `catalogDigest` (DEC-097 section 8:
226
+ * `SHA256(UTF8("GALA-TEMPLATE-STYLING-CONTRACT-V2\0") || JCS(the catalog
227
+ * with catalogDigest omitted))`).
228
+ *
229
+ * @returns {Record<string, unknown>} the complete, digested contract object
230
+ */
231
+ export function buildTemplateStylingContract() {
232
+ const attributesRows = [
233
+ {
234
+ name: APPEARANCE_ROOT_ATTRIBUTE,
235
+ match: 'presence',
236
+ values: [],
237
+ role: 'root',
238
+ },
239
+ {
240
+ name: APPEARANCE_RESOLVED_MODE_ATTRIBUTE,
241
+ match: 'exact-value',
242
+ values: sortedUniqueBytes(['dark', 'light']),
243
+ role: 'state',
244
+ },
245
+ {
246
+ name: 'data-gala-slot',
247
+ match: 'exact-value',
248
+ values: sortedUniqueBytes(SLOT_NAMES),
249
+ role: 'semantic',
250
+ },
251
+ {
252
+ name: PAGE_KIND_ATTRIBUTE,
253
+ match: 'exact-value',
254
+ values: sortedUniqueBytes(PAGE_KIND_VALUES),
255
+ role: 'semantic',
256
+ },
257
+ ].sort((a, b) => {
258
+ const bufferA = Buffer.from(canonicalizeJcs(a), 'utf8');
259
+ const bufferB = Buffer.from(canonicalizeJcs(b), 'utf8');
260
+ return Buffer.compare(bufferA, bufferB);
261
+ });
262
+
263
+ const publicThemeSlotHooks = ALL_HOOKS.map(({ hookId, selectorAtom }) => ({
264
+ hookId,
265
+ selectorAtom,
266
+ })).sort((a, b) => {
267
+ const bufferA = Buffer.from(a.hookId, 'utf8');
268
+ const bufferB = Buffer.from(b.hookId, 'utf8');
269
+ return Buffer.compare(bufferA, bufferB);
270
+ });
271
+
272
+ /** @type {Record<string, unknown>} */
273
+ const withoutDigest = {
274
+ profile: 'gala-template-styling-contract-v2',
275
+ contractVersion: TEMPLATE_STYLING_CONTRACT_VERSION,
276
+ templatePackage: TEMPLATE_STYLING_CONTRACT_PACKAGE,
277
+ templateVersion: TEMPLATE_STYLING_CONTRACT_TEMPLATE_VERSION,
278
+ orderedLayers: [...ORDERED_LAYERS],
279
+ stylesheetLayers: {
280
+ tokens: 'gala-tokens',
281
+ components: 'gala-components',
282
+ utilities: 'gala-utilities',
283
+ print: 'gala-print',
284
+ },
285
+ publicationRootSelector: PUBLICATION_ROOT_SELECTOR,
286
+ resolvedPaletteSelectors: { ...RESOLVED_PALETTE_SELECTORS },
287
+ typeSelectors: sortedUniqueBytes(
288
+ TYPE_HOOKS.map((hook) => hook.selectorAtom),
289
+ ),
290
+ classSelectors: sortedUniqueBytes(
291
+ CLASS_HOOKS.map((hook) => hook.selectorAtom.replace(/^\./, '')),
292
+ ),
293
+ idSelectors: sortedUniqueBytes(
294
+ ID_HOOKS.map((hook) => hook.selectorAtom.replace(/^#/, '')),
295
+ ),
296
+ attributes: attributesRows,
297
+ pseudoClasses: [],
298
+ pseudoElements: ['after', 'before', 'marker', 'selection'],
299
+ functionalPseudos: ['is', 'where', 'not', 'nth-child', 'nth-last-child'],
300
+ combinators: [' ', ' > ', ' + ', ' ~ '],
301
+ publicThemeSlotHooks,
302
+ composition: {
303
+ rootScope: 'first-compound-required',
304
+ compoundOrder: [
305
+ 'type',
306
+ 'id',
307
+ 'class',
308
+ 'attribute',
309
+ 'pseudo-class',
310
+ 'pseudo-element',
311
+ ],
312
+ functionalSelectorArguments: 'compound-only',
313
+ maximumFunctionalDepth: 1,
314
+ nthExpressionProfile: 'gala-positive-an-plus-b-v2',
315
+ },
316
+ };
317
+
318
+ const catalogDigest = domainDigest(
319
+ 'GALA-TEMPLATE-STYLING-CONTRACT-V2\0',
320
+ withoutDigest,
321
+ );
322
+
323
+ return { ...withoutDigest, catalogDigest };
324
+ }
325
+
326
+ /**
327
+ * Every distinct catalog leaf this contract admits, independent of hooks
328
+ * (used by the drift gate to cross-check rendered HTML).
329
+ *
330
+ * @returns {{types: string[], classes: string[], ids: string[]}}
331
+ */
332
+ export function templateStylingContractLeaves() {
333
+ return {
334
+ types: TYPE_HOOKS.map((hook) => hook.selectorAtom),
335
+ classes: CLASS_HOOKS.map((hook) => hook.selectorAtom.replace(/^\./, '')),
336
+ ids: ID_HOOKS.map((hook) => hook.selectorAtom.replace(/^#/, '')),
337
+ };
338
+ }
339
+
340
+ /** @returns {readonly StyleHookDefinition[]} every public hook definition. */
341
+ export function templateStylingContractHooks() {
342
+ return ALL_HOOKS;
343
+ }
344
+
345
+ /**
346
+ * A thrown error's message prefix for every structural violation
347
+ * {@link assertTemplateStylingContractShape} detects.
348
+ *
349
+ * @type {string}
350
+ */
351
+ export const TEMPLATE_STYLING_CONTRACT_INVALID =
352
+ 'TEMPLATE_STYLING_CONTRACT_INVALID';
353
+
354
+ /**
355
+ * Structural self-validation of a `templateStylingContract` object against
356
+ * DEC-097 section 4's closed shape (the checks this repository can run
357
+ * without a published `urn:gala:schema:template-styling-contract` schema
358
+ * entry — no such schema ID is registered in `@rathnasgala2/schemas@2.8.0`;
359
+ * only `urn:gala:schema:theme-contract:2.0.0` is, which this module's sibling
360
+ * theme fixtures are validated against with the schema package's own
361
+ * exported `validateGalaDocument`, see `test/theme-styling-contract.test.js`).
362
+ * Fails closed with a descriptive `Error` on the first violation found.
363
+ *
364
+ * @param {Record<string, unknown>} contract a candidate contract object
365
+ * @returns {void}
366
+ */
367
+ export function assertTemplateStylingContractShape(contract) {
368
+ /**
369
+ * @param {string} reason a short violation description
370
+ * @returns {never}
371
+ */
372
+ const fail = (reason) => {
373
+ throw new Error(`${TEMPLATE_STYLING_CONTRACT_INVALID}: ${reason}`);
374
+ };
375
+ if (contract.profile !== 'gala-template-styling-contract-v2') {
376
+ fail('profile');
377
+ }
378
+ if (contract.contractVersion !== '2.0.0') fail('contractVersion');
379
+ if (contract.templatePackage !== '@rathnasgala2/template') {
380
+ fail('templatePackage');
381
+ }
382
+ if (typeof contract.templateVersion !== 'string') fail('templateVersion');
383
+ if (
384
+ !Array.isArray(contract.orderedLayers) ||
385
+ JSON.stringify(contract.orderedLayers) !== JSON.stringify(ORDERED_LAYERS)
386
+ ) {
387
+ fail('orderedLayers');
388
+ }
389
+ const layers = /** @type {Record<string, unknown>} */ (
390
+ contract.stylesheetLayers
391
+ );
392
+ if (
393
+ !layers ||
394
+ layers.tokens !== 'gala-tokens' ||
395
+ layers.components !== 'gala-components' ||
396
+ layers.utilities !== 'gala-utilities' ||
397
+ layers.print !== 'gala-print'
398
+ ) {
399
+ fail('stylesheetLayers');
400
+ }
401
+ if (contract.publicationRootSelector !== PUBLICATION_ROOT_SELECTOR) {
402
+ fail('publicationRootSelector');
403
+ }
404
+ const palettes = /** @type {Record<string, unknown>} */ (
405
+ contract.resolvedPaletteSelectors
406
+ );
407
+ if (
408
+ !palettes ||
409
+ palettes.light !== RESOLVED_PALETTE_SELECTORS.light ||
410
+ palettes.dark !== RESOLVED_PALETTE_SELECTORS.dark
411
+ ) {
412
+ fail('resolvedPaletteSelectors');
413
+ }
414
+
415
+ const attributes = /** @type {Record<string, unknown>[]} */ (
416
+ contract.attributes
417
+ );
418
+ if (!Array.isArray(attributes) || attributes.length < 2) {
419
+ fail('attributes');
420
+ }
421
+ const rootRows = attributes.filter(
422
+ (row) =>
423
+ row.name === APPEARANCE_ROOT_ATTRIBUTE &&
424
+ row.match === 'presence' &&
425
+ row.role === 'root' &&
426
+ Array.isArray(row.values) &&
427
+ row.values.length === 0,
428
+ );
429
+ const paletteRows = attributes.filter(
430
+ (row) =>
431
+ row.name === APPEARANCE_RESOLVED_MODE_ATTRIBUTE &&
432
+ row.match === 'exact-value' &&
433
+ row.role === 'state' &&
434
+ Array.isArray(row.values) &&
435
+ JSON.stringify([...row.values].sort()) ===
436
+ JSON.stringify(['dark', 'light']),
437
+ );
438
+ if (rootRows.length !== 1) fail('root attribute row');
439
+ if (paletteRows.length !== 1) fail('palette attribute row');
440
+
441
+ if (
442
+ JSON.stringify(contract.pseudoElements) !==
443
+ JSON.stringify(['after', 'before', 'marker', 'selection'])
444
+ ) {
445
+ fail('pseudoElements');
446
+ }
447
+ if (
448
+ JSON.stringify(contract.functionalPseudos) !==
449
+ JSON.stringify(['is', 'where', 'not', 'nth-child', 'nth-last-child'])
450
+ ) {
451
+ fail('functionalPseudos');
452
+ }
453
+ if (
454
+ JSON.stringify(contract.combinators) !==
455
+ JSON.stringify([' ', ' > ', ' + ', ' ~ '])
456
+ ) {
457
+ fail('combinators');
458
+ }
459
+
460
+ const composition = /** @type {Record<string, unknown>} */ (
461
+ contract.composition
462
+ );
463
+ if (
464
+ !composition ||
465
+ composition.rootScope !== 'first-compound-required' ||
466
+ JSON.stringify(composition.compoundOrder) !==
467
+ JSON.stringify([
468
+ 'type',
469
+ 'id',
470
+ 'class',
471
+ 'attribute',
472
+ 'pseudo-class',
473
+ 'pseudo-element',
474
+ ]) ||
475
+ composition.functionalSelectorArguments !== 'compound-only' ||
476
+ composition.maximumFunctionalDepth !== 1 ||
477
+ composition.nthExpressionProfile !== 'gala-positive-an-plus-b-v2'
478
+ ) {
479
+ fail('composition');
480
+ }
481
+
482
+ const hooks = /** @type {{hookId: string, selectorAtom: string}[]} */ (
483
+ contract.publicThemeSlotHooks
484
+ );
485
+ if (!Array.isArray(hooks) || hooks.length < 1 || hooks.length > 64) {
486
+ fail('publicThemeSlotHooks length');
487
+ }
488
+ const seenAtoms = new Set();
489
+ const seenHookIds = new Set();
490
+ let previousHookId = '';
491
+ for (const hook of hooks) {
492
+ if (seenAtoms.has(hook.selectorAtom)) fail('duplicate hook selectorAtom');
493
+ if (seenHookIds.has(hook.hookId)) fail('duplicate hookId');
494
+ if (previousHookId !== '' && hook.hookId <= previousHookId) {
495
+ fail('publicThemeSlotHooks not sorted by hookId');
496
+ }
497
+ previousHookId = hook.hookId;
498
+ seenAtoms.add(hook.selectorAtom);
499
+ seenHookIds.add(hook.hookId);
500
+ }
501
+
502
+ const withoutDigest = { ...contract };
503
+ delete withoutDigest.catalogDigest;
504
+ const expectedDigest = domainDigest(
505
+ 'GALA-TEMPLATE-STYLING-CONTRACT-V2\0',
506
+ withoutDigest,
507
+ );
508
+ if (contract.catalogDigest !== expectedDigest) fail('catalogDigest');
509
+ }