@rathnasgala2/template 3.2.1 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -13
- package/contracts/theme-styling-contract.jcs +1 -1
- package/package.json +4 -2
- package/src/core/errors.js +5 -1
- package/src/core/index.js +43 -17
- package/src/core/internal/appearance/base-layer.js +16 -2
- package/src/core/internal/appearance/bootstrap-script.js +27 -4
- package/src/core/internal/appearance/contract.js +76 -0
- package/src/core/internal/appearance/controller-markup.js +6 -2
- package/src/core/internal/appearance/palette-sheet.js +217 -0
- package/src/core/internal/appearance/styling-contract.js +24 -3
- package/src/core/internal/chrome.js +41 -13
- package/src/core/internal/eleventy-render.js +13 -7
- package/src/core/internal/package-version.js +11 -0
- package/src/core/internal/page-kinds.js +131 -25
- package/src/core/internal/template-composition.js +185 -0
- package/src/core/internal/theme-assets.js +70 -8
|
@@ -88,6 +88,10 @@ import { getMessages } from './messages.js';
|
|
|
88
88
|
import { resolveTextDirection } from './text-direction.js';
|
|
89
89
|
import { deriveAuthorSlugs, routeSegmentForLabel } from './route-labels.js';
|
|
90
90
|
import { derivePublicBasePath, joinPublicRoute } from './route.js';
|
|
91
|
+
import {
|
|
92
|
+
assertSupportedComposition,
|
|
93
|
+
EDITORIAL_COMPOSITION,
|
|
94
|
+
} from './template-composition.js';
|
|
91
95
|
|
|
92
96
|
/** Listing page size (index and per-year archive pages). Scoped decision:
|
|
93
97
|
* this renderer has no externally fixed page size; 10 is a deterministic,
|
|
@@ -383,11 +387,46 @@ export function homeDocumentTitle(name, tagline) {
|
|
|
383
387
|
* @param {import('../../../types/index.d.ts').InteractionsModule} [options.interactions]
|
|
384
388
|
* the validated interactions module; when set, every article page carries
|
|
385
389
|
* the interactions section and is interaction-bearing
|
|
390
|
+
* @param {unknown} [options.composition] the schema-validated structural
|
|
391
|
+
* composition selected by the theme; defaults to Editorial only for
|
|
392
|
+
* theme-less renderer use
|
|
386
393
|
* @returns {GeneratedPage[]} every generated page, in a stable, deterministic
|
|
387
394
|
* order
|
|
388
395
|
*/
|
|
389
396
|
export function buildGeneratedPages(validatedInput, options) {
|
|
390
397
|
const { publication, authors, content, basePath, baseUrl } = validatedInput;
|
|
398
|
+
const composition = assertSupportedComposition(
|
|
399
|
+
options.composition ?? EDITORIAL_COMPOSITION,
|
|
400
|
+
);
|
|
401
|
+
/**
|
|
402
|
+
* Preserve every content region while giving each article direction its
|
|
403
|
+
* own reading sequence and semantic container.
|
|
404
|
+
*
|
|
405
|
+
* @param {{head: string, cover: string, toc: string, prose: string, foot: string}} parts
|
|
406
|
+
* @returns {string}
|
|
407
|
+
*/
|
|
408
|
+
const articleStructure = ({ head, cover, toc, prose, foot }) => {
|
|
409
|
+
/**
|
|
410
|
+
* @param {string} contents rendered article regions
|
|
411
|
+
* @param {string} [element] semantic wrapper element
|
|
412
|
+
* @returns {string}
|
|
413
|
+
*/
|
|
414
|
+
const grid = (contents, element = 'div') =>
|
|
415
|
+
`<${element} class="g-wrap g-article-grid">${contents}</${element}>`;
|
|
416
|
+
if (composition.profile === 'newsletter') {
|
|
417
|
+
return `<section>${head}${cover}${grid(prose + toc, 'section')}${foot}</section>`;
|
|
418
|
+
}
|
|
419
|
+
if (composition.profile === 'visual') {
|
|
420
|
+
return `<div>${cover}${head}${grid(prose + toc)}</div>${foot}`;
|
|
421
|
+
}
|
|
422
|
+
if (composition.profile === 'personal') {
|
|
423
|
+
return `<section>${head}${cover}${grid(prose)}${toc ? grid(toc, 'section') : ''}${foot}</section>`;
|
|
424
|
+
}
|
|
425
|
+
if (composition.profile === 'developer') {
|
|
426
|
+
return `<div>${head}${cover}${grid(toc + prose, 'section')}${foot}</div>`;
|
|
427
|
+
}
|
|
428
|
+
return head + cover + grid(toc + prose) + foot;
|
|
429
|
+
};
|
|
391
430
|
const interactionsModule = interactionsActive(options.interactions)
|
|
392
431
|
? options.interactions
|
|
393
432
|
: undefined;
|
|
@@ -612,13 +651,32 @@ export function buildGeneratedPages(validatedInput, options) {
|
|
|
612
651
|
* @param {string} [afterHtml] markup after the list (pagination)
|
|
613
652
|
* @returns {string}
|
|
614
653
|
*/
|
|
615
|
-
const rowList = (items, afterHtml = '') =>
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
654
|
+
const rowList = (items, afterHtml = '') => {
|
|
655
|
+
const grid = composition.listingLayout === 'image-grid';
|
|
656
|
+
const issueCards = composition.listingLayout === 'issues';
|
|
657
|
+
const datedRows = composition.listingLayout === 'dated-rows';
|
|
658
|
+
const outer =
|
|
659
|
+
composition.profile === 'personal' || composition.profile === 'developer'
|
|
660
|
+
? 'div'
|
|
661
|
+
: 'section';
|
|
662
|
+
const contents =
|
|
663
|
+
items.length > 0
|
|
664
|
+
? datedRows
|
|
665
|
+
? `<div class="g-list g-dated-list">${items
|
|
666
|
+
.map(
|
|
667
|
+
(record) =>
|
|
668
|
+
`<div class="g-dated-row"><time class="g-date-stamp" datetime="${escapeHtml(record.frontmatter.publishedAt)}">` +
|
|
669
|
+
`${call('formatDate', record.frontmatter.publishedAt)}</time>${cardOf(record, 'row', 2)}</div>`,
|
|
670
|
+
)
|
|
671
|
+
.join('')}</div>`
|
|
672
|
+
: `<div class="${grid ? 'g-grid' : 'g-list'}">${items
|
|
673
|
+
.map((record) =>
|
|
674
|
+
cardOf(record, grid || issueCards ? 'grid' : 'row', 2),
|
|
675
|
+
)
|
|
676
|
+
.join('')}</div>`
|
|
677
|
+
: `<p>${text('emptyListingLabel')}</p>`;
|
|
678
|
+
return `<${outer} class="g-wrap g-section" data-gala-listing-layout="${composition.listingLayout}">${contents}${afterHtml}</${outer}>`;
|
|
679
|
+
};
|
|
622
680
|
|
|
623
681
|
/**
|
|
624
682
|
* One listing page's metadata: bare title, `<title>`, description,
|
|
@@ -710,6 +768,28 @@ export function buildGeneratedPages(validatedInput, options) {
|
|
|
710
768
|
? `<p class="g-dek">${escapeHtml(author.biography)}</p>`
|
|
711
769
|
: '') +
|
|
712
770
|
links;
|
|
771
|
+
const authorHead = listingHead({
|
|
772
|
+
breadcrumbHtml: crumbs(
|
|
773
|
+
[{ label: /** @type {string} */ (messages.authorsSectionLabel) }],
|
|
774
|
+
[{ label: author.displayName }],
|
|
775
|
+
),
|
|
776
|
+
kicker: /** @type {string} */ (messages.authorKickerLabel),
|
|
777
|
+
count: authored.length,
|
|
778
|
+
title: author.displayName,
|
|
779
|
+
descriptionHtml: intro,
|
|
780
|
+
markHtml: avatar(author),
|
|
781
|
+
});
|
|
782
|
+
const authoredList = rowList(authored);
|
|
783
|
+
const authorContents =
|
|
784
|
+
composition.profile === 'newsletter'
|
|
785
|
+
? `<section>${authorHead}${authoredList}</section>`
|
|
786
|
+
: composition.profile === 'visual'
|
|
787
|
+
? `<div>${authorHead}<section>${authoredList}</section></div>`
|
|
788
|
+
: composition.profile === 'personal'
|
|
789
|
+
? `<section>${authorHead}<div>${authoredList}</div></section>`
|
|
790
|
+
: composition.profile === 'developer'
|
|
791
|
+
? `<div>${authorHead}<div>${authoredList}</div></div>`
|
|
792
|
+
: authorHead + authoredList;
|
|
713
793
|
pages.push({
|
|
714
794
|
kind: 'author',
|
|
715
795
|
route: authorRoute(author),
|
|
@@ -737,17 +817,9 @@ export function buildGeneratedPages(validatedInput, options) {
|
|
|
737
817
|
}),
|
|
738
818
|
),
|
|
739
819
|
bodyHtml:
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
[{ label: author.displayName }],
|
|
744
|
-
),
|
|
745
|
-
kicker: /** @type {string} */ (messages.authorKickerLabel),
|
|
746
|
-
count: authored.length,
|
|
747
|
-
title: author.displayName,
|
|
748
|
-
descriptionHtml: intro,
|
|
749
|
-
markHtml: avatar(author),
|
|
750
|
-
}) + rowList(authored),
|
|
820
|
+
`<div data-gala-author-layout="${composition.authorLayout}">` +
|
|
821
|
+
authorContents +
|
|
822
|
+
`</div>`,
|
|
751
823
|
});
|
|
752
824
|
}
|
|
753
825
|
|
|
@@ -822,7 +894,7 @@ export function buildGeneratedPages(validatedInput, options) {
|
|
|
822
894
|
editionSelector +
|
|
823
895
|
`</div>`;
|
|
824
896
|
const cover = hero
|
|
825
|
-
? `<figure class="g-wrap g-article-cover">${renderImage({ image: hero, alt: heroMedia?.role === 'decorative' ? '' : (heroMedia?.alt ?? ''), priority: true })}</figure>`
|
|
897
|
+
? `<figure class="${composition.articleLayout === 'full-bleed' ? 'g-article-cover g-article-cover-full-bleed' : 'g-wrap g-article-cover'}">${renderImage({ image: hero, alt: heroMedia?.role === 'decorative' ? '' : (heroMedia?.alt ?? ''), priority: true })}</figure>`
|
|
826
898
|
: '';
|
|
827
899
|
const tocNav = contents
|
|
828
900
|
? `<nav class="g-toc" aria-label="${text('onThisPageLabel')}"><p class="g-label">${icon('list')}${text('onThisPageLabel')}</p>${contents}</nav>`
|
|
@@ -1001,8 +1073,8 @@ export function buildGeneratedPages(validatedInput, options) {
|
|
|
1001
1073
|
interactionBearing: Boolean(interactions),
|
|
1002
1074
|
lastModified: contentLastModified(frontmatter),
|
|
1003
1075
|
bodyHtml:
|
|
1004
|
-
`<article class="g-article">${isArticle || isEdition ? '<div class="g-progress" aria-hidden="true"></div>' : ''}
|
|
1005
|
-
|
|
1076
|
+
`<article class="g-article" data-gala-article-layout="${composition.articleLayout}">${isArticle || isEdition ? '<div class="g-progress" aria-hidden="true"></div>' : ''}` +
|
|
1077
|
+
`${articleStructure({ head, cover, toc: tocNav, prose, foot })}</article>` +
|
|
1006
1078
|
after +
|
|
1007
1079
|
(isArticle ? newsletterHtml : ''),
|
|
1008
1080
|
});
|
|
@@ -1093,15 +1165,49 @@ export function buildGeneratedPages(validatedInput, options) {
|
|
|
1093
1165
|
? `<section class="g-wrap g-section"><div class="g-section-head"><h2>${text('moreArticlesHeading')}</h2></div>` +
|
|
1094
1166
|
`<div class="g-list">${items.map((r) => cardOf(r, 'row')).join('')}</div>${pagination}</section>`
|
|
1095
1167
|
: '';
|
|
1096
|
-
const
|
|
1097
|
-
|
|
1168
|
+
const homeParts =
|
|
1169
|
+
composition.profile === 'newsletter'
|
|
1098
1170
|
? homeHeading +
|
|
1099
|
-
homeFeatured +
|
|
1100
1171
|
homeTags +
|
|
1172
|
+
homeFeatured +
|
|
1101
1173
|
homeLatest +
|
|
1102
|
-
homeSeries +
|
|
1103
1174
|
moreList +
|
|
1175
|
+
homeSeries +
|
|
1104
1176
|
newsletterHtml
|
|
1177
|
+
: composition.profile === 'visual'
|
|
1178
|
+
? homeHeading +
|
|
1179
|
+
homeFeatured +
|
|
1180
|
+
homeLatest +
|
|
1181
|
+
homeSeries +
|
|
1182
|
+
homeTags +
|
|
1183
|
+
moreList +
|
|
1184
|
+
newsletterHtml
|
|
1185
|
+
: composition.profile === 'personal'
|
|
1186
|
+
? homeHeading +
|
|
1187
|
+
homeLatest +
|
|
1188
|
+
homeFeatured +
|
|
1189
|
+
moreList +
|
|
1190
|
+
homeTags +
|
|
1191
|
+
homeSeries +
|
|
1192
|
+
newsletterHtml
|
|
1193
|
+
: composition.profile === 'developer'
|
|
1194
|
+
? homeHeading +
|
|
1195
|
+
homeTags +
|
|
1196
|
+
homeLatest +
|
|
1197
|
+
homeSeries +
|
|
1198
|
+
homeFeatured +
|
|
1199
|
+
moreList +
|
|
1200
|
+
newsletterHtml
|
|
1201
|
+
: homeHeading +
|
|
1202
|
+
homeFeatured +
|
|
1203
|
+
homeTags +
|
|
1204
|
+
homeLatest +
|
|
1205
|
+
homeSeries +
|
|
1206
|
+
moreList +
|
|
1207
|
+
newsletterHtml;
|
|
1208
|
+
const bodyHtml =
|
|
1209
|
+
pageNumber === 1
|
|
1210
|
+
? `<div data-gala-home-layout="${composition.homeLayout}">${homeParts}</div>`
|
|
1105
1211
|
: listingHead({
|
|
1106
1212
|
breadcrumbHtml: crumbs([
|
|
1107
1213
|
{ label: /** @type {string} */ (messages.indexHeading) },
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generation-4 template composition admission.
|
|
3
|
+
*
|
|
4
|
+
* A composition is one complete, immutable product direction. Admission is
|
|
5
|
+
* deliberately by exact object equality so fields from different directions
|
|
6
|
+
* cannot be combined into renderer states that were never designed or tested.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The immutable generation-4 composition schema identity. */
|
|
10
|
+
export const TEMPLATE_COMPOSITION_SCHEMA_ID =
|
|
11
|
+
'urn:gala:schema:template-composition:2.0.0';
|
|
12
|
+
|
|
13
|
+
const SCHEMA_FIELDS = Object.freeze({
|
|
14
|
+
schemaId: TEMPLATE_COMPOSITION_SCHEMA_ID,
|
|
15
|
+
schemaVersion: '2.0.0',
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/** The complete Editorial composition. */
|
|
19
|
+
export const EDITORIAL_COMPOSITION = Object.freeze({
|
|
20
|
+
...SCHEMA_FIELDS,
|
|
21
|
+
profile: 'editorial',
|
|
22
|
+
homeLayout: 'magazine',
|
|
23
|
+
articleLayout: 'editorial',
|
|
24
|
+
authorLayout: 'portrait',
|
|
25
|
+
listingLayout: 'rows',
|
|
26
|
+
headerLayout: 'masthead',
|
|
27
|
+
footerLayout: 'expanded',
|
|
28
|
+
mediaRatio: 'landscape',
|
|
29
|
+
density: 'comfortable',
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** The complete Newsletter composition. */
|
|
33
|
+
export const NEWSLETTER_COMPOSITION = Object.freeze({
|
|
34
|
+
...SCHEMA_FIELDS,
|
|
35
|
+
profile: 'newsletter',
|
|
36
|
+
homeLayout: 'newspaper',
|
|
37
|
+
articleLayout: 'issue',
|
|
38
|
+
authorLayout: 'profile',
|
|
39
|
+
listingLayout: 'issues',
|
|
40
|
+
headerLayout: 'publication',
|
|
41
|
+
footerLayout: 'expanded',
|
|
42
|
+
mediaRatio: 'landscape',
|
|
43
|
+
density: 'comfortable',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** The complete Visual composition. */
|
|
47
|
+
export const VISUAL_COMPOSITION = Object.freeze({
|
|
48
|
+
...SCHEMA_FIELDS,
|
|
49
|
+
profile: 'visual',
|
|
50
|
+
homeLayout: 'gallery',
|
|
51
|
+
articleLayout: 'full-bleed',
|
|
52
|
+
authorLayout: 'portfolio',
|
|
53
|
+
listingLayout: 'image-grid',
|
|
54
|
+
headerLayout: 'minimal',
|
|
55
|
+
footerLayout: 'compact',
|
|
56
|
+
mediaRatio: 'mixed',
|
|
57
|
+
density: 'comfortable',
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/** The complete Personal composition. */
|
|
61
|
+
export const PERSONAL_COMPOSITION = Object.freeze({
|
|
62
|
+
...SCHEMA_FIELDS,
|
|
63
|
+
profile: 'personal',
|
|
64
|
+
homeLayout: 'chronological',
|
|
65
|
+
articleLayout: 'prose',
|
|
66
|
+
authorLayout: 'biography',
|
|
67
|
+
listingLayout: 'dated-rows',
|
|
68
|
+
headerLayout: 'compact',
|
|
69
|
+
footerLayout: 'compact',
|
|
70
|
+
mediaRatio: 'landscape',
|
|
71
|
+
density: 'spacious',
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/** The complete Developer composition. */
|
|
75
|
+
export const DEVELOPER_COMPOSITION = Object.freeze({
|
|
76
|
+
...SCHEMA_FIELDS,
|
|
77
|
+
profile: 'developer',
|
|
78
|
+
homeLayout: 'topic-index',
|
|
79
|
+
articleLayout: 'technical',
|
|
80
|
+
authorLayout: 'biography',
|
|
81
|
+
listingLayout: 'rows',
|
|
82
|
+
headerLayout: 'utility',
|
|
83
|
+
footerLayout: 'compact',
|
|
84
|
+
mediaRatio: 'landscape',
|
|
85
|
+
density: 'compact',
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/** Every complete composition implemented by generation 4. */
|
|
89
|
+
export const SUPPORTED_COMPOSITIONS = Object.freeze([
|
|
90
|
+
EDITORIAL_COMPOSITION,
|
|
91
|
+
NEWSLETTER_COMPOSITION,
|
|
92
|
+
VISUAL_COMPOSITION,
|
|
93
|
+
PERSONAL_COMPOSITION,
|
|
94
|
+
DEVELOPER_COMPOSITION,
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
/** @type {Readonly<Record<string, (typeof SUPPORTED_COMPOSITIONS)[number]>>} */
|
|
98
|
+
const COMPOSITION_BY_PROFILE = Object.freeze(
|
|
99
|
+
Object.fromEntries(
|
|
100
|
+
SUPPORTED_COMPOSITIONS.map((composition) => [
|
|
101
|
+
composition.profile,
|
|
102
|
+
composition,
|
|
103
|
+
]),
|
|
104
|
+
),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const COMPOSITION_ATTRIBUTE_NAMES = Object.freeze({
|
|
108
|
+
profile: 'data-gala-profile',
|
|
109
|
+
homeLayout: 'data-gala-home-layout',
|
|
110
|
+
articleLayout: 'data-gala-article-layout',
|
|
111
|
+
authorLayout: 'data-gala-author-layout',
|
|
112
|
+
listingLayout: 'data-gala-listing-layout',
|
|
113
|
+
headerLayout: 'data-gala-header-layout',
|
|
114
|
+
footerLayout: 'data-gala-footer-layout',
|
|
115
|
+
mediaRatio: 'data-gala-media-ratio',
|
|
116
|
+
density: 'data-gala-density',
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
/** @typedef {(typeof SUPPORTED_COMPOSITIONS)[number]} SupportedComposition */
|
|
120
|
+
/** @typedef {Exclude<keyof SupportedComposition, 'schemaId' | 'schemaVersion'>} CompositionField */
|
|
121
|
+
|
|
122
|
+
/** Public semantic attribute catalogs corresponding to composition fields. */
|
|
123
|
+
export const COMPOSITION_ATTRIBUTE_CATALOG = Object.freeze(
|
|
124
|
+
/** @type {readonly [CompositionField, string][]} */ (
|
|
125
|
+
Object.entries(COMPOSITION_ATTRIBUTE_NAMES)
|
|
126
|
+
).map(([field, name]) =>
|
|
127
|
+
Object.freeze({
|
|
128
|
+
field,
|
|
129
|
+
name,
|
|
130
|
+
values: Object.freeze([
|
|
131
|
+
...new Set(
|
|
132
|
+
SUPPORTED_COMPOSITIONS.map((composition) => composition[field]),
|
|
133
|
+
),
|
|
134
|
+
]),
|
|
135
|
+
}),
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
/** Every public semantic attribute/value pair themes may select. */
|
|
140
|
+
export const COMPOSITION_ATTRIBUTES = Object.freeze(
|
|
141
|
+
COMPOSITION_ATTRIBUTE_CATALOG.flatMap(({ field, name, values }) =>
|
|
142
|
+
values.map((value) => Object.freeze({ field, name, value })),
|
|
143
|
+
),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Admit exactly one complete composition implemented by this renderer.
|
|
148
|
+
*
|
|
149
|
+
* @param {unknown} candidate a complete template-composition document
|
|
150
|
+
* @returns {(typeof SUPPORTED_COMPOSITIONS)[number]} canonical composition
|
|
151
|
+
*/
|
|
152
|
+
export function assertSupportedComposition(candidate) {
|
|
153
|
+
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`composition must be a complete ${TEMPLATE_COMPOSITION_SCHEMA_ID} document`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
const composition = /** @type {Record<string, unknown>} */ (candidate);
|
|
159
|
+
const selected =
|
|
160
|
+
typeof composition.profile === 'string' &&
|
|
161
|
+
Object.hasOwn(COMPOSITION_BY_PROFILE, composition.profile)
|
|
162
|
+
? COMPOSITION_BY_PROFILE[composition.profile]
|
|
163
|
+
: undefined;
|
|
164
|
+
if (!selected) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`composition.profile (${JSON.stringify(composition.profile)}) is not implemented by this renderer`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
const expectedKeys = Object.keys(selected).sort();
|
|
170
|
+
const actualKeys = Object.keys(composition).sort();
|
|
171
|
+
if (JSON.stringify(actualKeys) !== JSON.stringify(expectedKeys)) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`composition must contain exactly ${expectedKeys.join(', ')}`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
for (const [key, expected] of Object.entries(selected)) {
|
|
177
|
+
if (composition[key] !== expected) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`composition.${key} (${JSON.stringify(composition[key])}) is not ` +
|
|
180
|
+
`implemented for ${selected.profile}; expected ${JSON.stringify(expected)}`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return selected;
|
|
185
|
+
}
|
|
@@ -77,16 +77,21 @@ import { validateGalaDocument } from '@rathnasgala2/schemas';
|
|
|
77
77
|
import { ThemeAssetError } from '../errors.js';
|
|
78
78
|
import { digestBytes } from './canonical-jcs.js';
|
|
79
79
|
import {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
assertThemePalette,
|
|
81
|
+
buildPaletteStylesheet,
|
|
82
|
+
PALETTE_STYLESHEET_DIRECTORY,
|
|
83
|
+
PALETTE_STYLESHEET_MEDIA_TYPE,
|
|
84
|
+
} from './appearance/palette-sheet.js';
|
|
85
|
+
import { buildTemplateStylingContract } from './appearance/styling-contract.js';
|
|
86
|
+
import { TEMPLATE_RENDERER_VERSION } from './package-version.js';
|
|
83
87
|
import { MEDIA_TYPE_BY_FORMAT, sniffMediaFormat } from './media/sniff.js';
|
|
84
88
|
import { sanitizeThemeSvg } from './media/theme-svg-sanitizer.js';
|
|
85
89
|
import { projectFixedAssetPath } from './route.js';
|
|
86
90
|
import { satisfiesTemplateRange } from './semver-range.js';
|
|
87
91
|
import { escapeHtml } from './skeleton.js';
|
|
92
|
+
import { assertSupportedComposition } from './template-composition.js';
|
|
88
93
|
|
|
89
|
-
/** @type {string} the `urn:gala:schema:theme-contract:2.0.0` schema id
|
|
94
|
+
/** @type {string} the immutable `urn:gala:schema:theme-contract:2.0.0` schema id
|
|
90
95
|
* every consumed `theme.json` is validated against (TPL-H1). */
|
|
91
96
|
const THEME_CONTRACT_SCHEMA_ID = 'urn:gala:schema:theme-contract:2.0.0';
|
|
92
97
|
|
|
@@ -413,9 +418,24 @@ function admitPassiveAssetBytes(relativePath, bytes) {
|
|
|
413
418
|
function assertThemeContractIntegrity(themeJson) {
|
|
414
419
|
const validation = validateGalaDocument(THEME_CONTRACT_SCHEMA_ID, themeJson);
|
|
415
420
|
if (!validation.valid) {
|
|
421
|
+
// A palette block the closed schema itself refuses (unknown or
|
|
422
|
+
// non-drivable key, bad profile, bad role) is a palette refusal, not a
|
|
423
|
+
// generic contract refusal, so the author sees one code for it.
|
|
424
|
+
if (
|
|
425
|
+
validation.diagnostics.every((diagnostic) =>
|
|
426
|
+
String(diagnostic.instancePointer ?? '').startsWith('/palette'),
|
|
427
|
+
)
|
|
428
|
+
) {
|
|
429
|
+
throw new ThemeAssetError(
|
|
430
|
+
'THEME_PALETTE_INVALID',
|
|
431
|
+
`theme.json palette failed ${THEME_CONTRACT_SCHEMA_ID} validation ` +
|
|
432
|
+
`(${validation.diagnostics.length} diagnostic(s)): ` +
|
|
433
|
+
JSON.stringify(validation.diagnostics),
|
|
434
|
+
);
|
|
435
|
+
}
|
|
416
436
|
throw new ThemeAssetError(
|
|
417
437
|
'THEME_CONTRACT_SCHEMA_INVALID',
|
|
418
|
-
`theme.json failed
|
|
438
|
+
`theme.json failed ${THEME_CONTRACT_SCHEMA_ID} validation ` +
|
|
419
439
|
`(${validation.diagnostics.length} diagnostic(s)): ` +
|
|
420
440
|
JSON.stringify(validation.diagnostics),
|
|
421
441
|
);
|
|
@@ -441,7 +461,7 @@ function assertThemeContractIntegrity(themeJson) {
|
|
|
441
461
|
let templateRangeSatisfied;
|
|
442
462
|
try {
|
|
443
463
|
templateRangeSatisfied = satisfiesTemplateRange(
|
|
444
|
-
|
|
464
|
+
TEMPLATE_RENDERER_VERSION,
|
|
445
465
|
/** @type {string} */ (themeJson.templateRange),
|
|
446
466
|
);
|
|
447
467
|
} catch (error) {
|
|
@@ -455,9 +475,20 @@ function assertThemeContractIntegrity(themeJson) {
|
|
|
455
475
|
'THEME_CONTRACT_VERSION_MISMATCH',
|
|
456
476
|
`theme.json.templateRange (${themeJson.templateRange}) does not admit ` +
|
|
457
477
|
`this renderer's own published version ` +
|
|
458
|
-
`(${
|
|
478
|
+
`(${TEMPLATE_RENDERER_VERSION})`,
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
try {
|
|
482
|
+
assertSupportedComposition(themeJson.composition);
|
|
483
|
+
} catch (error) {
|
|
484
|
+
throw new ThemeAssetError(
|
|
485
|
+
'THEME_COMPOSITION_UNSUPPORTED',
|
|
486
|
+
/** @type {Error} */ (error).message,
|
|
459
487
|
);
|
|
460
488
|
}
|
|
489
|
+
// Template 4.1.0: every theme declares its palette bindings; a theme
|
|
490
|
+
// without them (any 4.0.0 package) is refused, with no fallback path.
|
|
491
|
+
assertThemePalette(themeJson);
|
|
461
492
|
}
|
|
462
493
|
|
|
463
494
|
/**
|
|
@@ -486,10 +517,14 @@ function subresourceIntegrityAttribute(bytes) {
|
|
|
486
517
|
* @param {string} options.basePath the build input's `basePath`
|
|
487
518
|
* @param {string} [options.publicBasePath] the base path emitted `<link href>`
|
|
488
519
|
* values use (`route.js#derivePublicBasePath`); defaults to `basePath`
|
|
520
|
+
* @param {unknown} [options.appearanceTokens] `appearance.tokens` (`{}` or
|
|
521
|
+
* absent selects the theme's default palette); the derived palette sheet
|
|
522
|
+
* is written and linked after the theme's own stylesheets
|
|
489
523
|
* @returns {Promise<{
|
|
490
524
|
* files: {path: string, bytes: Buffer}[],
|
|
491
525
|
* assets: import('../../../types/index.d.ts').ManifestAssetEntry[],
|
|
492
526
|
* linkTagsHtml: string,
|
|
527
|
+
* composition: (typeof import('./template-composition.js').SUPPORTED_COMPOSITIONS)[number],
|
|
493
528
|
* }>} the files to write (already `basePath`-joined), their manifest rows
|
|
494
529
|
* (same joined paths), and the ready-to-insert `<link>` markup
|
|
495
530
|
*/
|
|
@@ -497,6 +532,7 @@ export async function loadThemeAssets({
|
|
|
497
532
|
themeDirectory,
|
|
498
533
|
basePath,
|
|
499
534
|
publicBasePath = basePath,
|
|
535
|
+
appearanceTokens = {},
|
|
500
536
|
}) {
|
|
501
537
|
const { bytes: themeJsonBytes } = await readDeclaredThemeFile(
|
|
502
538
|
themeDirectory,
|
|
@@ -556,6 +592,27 @@ export async function loadThemeAssets({
|
|
|
556
592
|
);
|
|
557
593
|
}
|
|
558
594
|
|
|
595
|
+
// Template 4.1.0: the publication palette sheet, derived on every build
|
|
596
|
+
// (`{}` selects the theme's default palette), content-addressed, linked
|
|
597
|
+
// last with SRI and manifested like every theme stylesheet.
|
|
598
|
+
const paletteSheet = buildPaletteStylesheet(themeJson, appearanceTokens);
|
|
599
|
+
const paletteRelativePath = `${PALETTE_STYLESHEET_DIRECTORY}/${paletteSheet.fileName}`;
|
|
600
|
+
const paletteOutputPath = projectFixedAssetPath(
|
|
601
|
+
basePath,
|
|
602
|
+
`/${paletteRelativePath}`,
|
|
603
|
+
);
|
|
604
|
+
files.push({ path: paletteOutputPath, bytes: paletteSheet.bytes });
|
|
605
|
+
assets.push({
|
|
606
|
+
path: paletteOutputPath,
|
|
607
|
+
mediaType: PALETTE_STYLESHEET_MEDIA_TYPE,
|
|
608
|
+
byteLength: String(paletteSheet.bytes.byteLength),
|
|
609
|
+
sha256: digestBytes(paletteSheet.bytes),
|
|
610
|
+
immutable: false,
|
|
611
|
+
});
|
|
612
|
+
linkTags.push(
|
|
613
|
+
`<link rel="stylesheet" href="${escapeHtml(`/${projectFixedAssetPath(publicBasePath, `/${paletteRelativePath}`)}`)}" integrity="${subresourceIntegrityAttribute(paletteSheet.bytes)}" crossorigin="anonymous">`,
|
|
614
|
+
);
|
|
615
|
+
|
|
559
616
|
// TPL-M2 fix: partition by the already-shape-validated `stylesheets` list
|
|
560
617
|
// itself, not by comparing `asset.mediaType` against the literal string
|
|
561
618
|
// `'text/css'` — a theme declaring its own stylesheet rows with the same
|
|
@@ -611,7 +668,12 @@ export async function loadThemeAssets({
|
|
|
611
668
|
});
|
|
612
669
|
}
|
|
613
670
|
|
|
614
|
-
return {
|
|
671
|
+
return {
|
|
672
|
+
files,
|
|
673
|
+
assets,
|
|
674
|
+
linkTagsHtml: linkTags.join(''),
|
|
675
|
+
composition: assertSupportedComposition(themeJson.composition),
|
|
676
|
+
};
|
|
615
677
|
}
|
|
616
678
|
|
|
617
679
|
/**
|