@rathnasgala2/template 3.2.0 → 4.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.
@@ -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
- `<section class="g-wrap g-section">` +
617
- (items.length > 0
618
- ? `<div class="g-list">${items.map((record) => cardOf(record, 'row', 2)).join('')}</div>`
619
- : `<p>${text('emptyListingLabel')}</p>`) +
620
- afterHtml +
621
- `</section>`;
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
- listingHead({
741
- breadcrumbHtml: crumbs(
742
- [{ label: /** @type {string} */ (messages.authorsSectionLabel) }],
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>' : ''}${head}${cover}` +
1005
- `<div class="g-wrap g-article-grid">${tocNav}${prose}</div>${foot}</article>` +
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 bodyHtml =
1097
- pageNumber === 1
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
+ }
@@ -85,8 +85,9 @@ import { sanitizeThemeSvg } from './media/theme-svg-sanitizer.js';
85
85
  import { projectFixedAssetPath } from './route.js';
86
86
  import { satisfiesTemplateRange } from './semver-range.js';
87
87
  import { escapeHtml } from './skeleton.js';
88
+ import { assertSupportedComposition } from './template-composition.js';
88
89
 
89
- /** @type {string} the `urn:gala:schema:theme-contract:2.0.0` schema id
90
+ /** @type {string} the immutable `urn:gala:schema:theme-contract:2.0.0` schema id
90
91
  * every consumed `theme.json` is validated against (TPL-H1). */
91
92
  const THEME_CONTRACT_SCHEMA_ID = 'urn:gala:schema:theme-contract:2.0.0';
92
93
 
@@ -415,7 +416,7 @@ function assertThemeContractIntegrity(themeJson) {
415
416
  if (!validation.valid) {
416
417
  throw new ThemeAssetError(
417
418
  'THEME_CONTRACT_SCHEMA_INVALID',
418
- `theme.json failed urn:gala:schema:theme-contract:2.0.0 validation ` +
419
+ `theme.json failed ${THEME_CONTRACT_SCHEMA_ID} validation ` +
419
420
  `(${validation.diagnostics.length} diagnostic(s)): ` +
420
421
  JSON.stringify(validation.diagnostics),
421
422
  );
@@ -458,6 +459,14 @@ function assertThemeContractIntegrity(themeJson) {
458
459
  `(${TEMPLATE_STYLING_CONTRACT_TEMPLATE_VERSION})`,
459
460
  );
460
461
  }
462
+ try {
463
+ assertSupportedComposition(themeJson.composition);
464
+ } catch (error) {
465
+ throw new ThemeAssetError(
466
+ 'THEME_COMPOSITION_UNSUPPORTED',
467
+ /** @type {Error} */ (error).message,
468
+ );
469
+ }
461
470
  }
462
471
 
463
472
  /**
@@ -490,6 +499,7 @@ function subresourceIntegrityAttribute(bytes) {
490
499
  * files: {path: string, bytes: Buffer}[],
491
500
  * assets: import('../../../types/index.d.ts').ManifestAssetEntry[],
492
501
  * linkTagsHtml: string,
502
+ * composition: (typeof import('./template-composition.js').SUPPORTED_COMPOSITIONS)[number],
493
503
  * }>} the files to write (already `basePath`-joined), their manifest rows
494
504
  * (same joined paths), and the ready-to-insert `<link>` markup
495
505
  */
@@ -611,7 +621,12 @@ export async function loadThemeAssets({
611
621
  });
612
622
  }
613
623
 
614
- return { files, assets, linkTagsHtml: linkTags.join('') };
624
+ return {
625
+ files,
626
+ assets,
627
+ linkTagsHtml: linkTags.join(''),
628
+ composition: assertSupportedComposition(themeJson.composition),
629
+ };
615
630
  }
616
631
 
617
632
  /**