@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.
@@ -0,0 +1,217 @@
1
+ /**
2
+ * The publication palette stylesheet: every build that supplies a theme
3
+ * package derives the theme's palette-driven tokens from the author's
4
+ * `appearance.tokens` (`{}` or absent selects the theme's own default
5
+ * palette), checks them, and writes them as one content-addressed
6
+ * `assets/gala-palette-<12 hex>.css` file linked after the theme's own
7
+ * stylesheets.
8
+ *
9
+ * All colour mathematics lives in `@rathnasgala2/schemas/palette`
10
+ * (`derivePalette`, `checkPalette`); this module never re-implements any of
11
+ * it. It only
12
+ *
13
+ * 1. derives the final token values,
14
+ * 2. re-checks every derived value against the theme-contract token grammar
15
+ * (the same closed schema every theme token already passes),
16
+ * 3. checks every contrast pair in both modes, and
17
+ * 4. serialises the bound tokens into four blocks inside
18
+ * `@layer gala-tokens`: the bare root (light), the
19
+ * `prefers-color-scheme: dark` root, and the two resolved-mode selectors,
20
+ * exactly the four blocks a theme's own `tokens.css` uses. The sheet is an
21
+ * external same-origin file, so the CSP stays free of inline styles.
22
+ *
23
+ * The bytes are a pure function of the theme's palette-relevant data and the
24
+ * author's picks, so sites on the same theme default share identical bytes.
25
+ */
26
+
27
+ import { createHash } from 'node:crypto';
28
+
29
+ import { validateGalaDocument } from '@rathnasgala2/schemas';
30
+ import {
31
+ checkPalette,
32
+ derivePalette,
33
+ PaletteError,
34
+ } from '@rathnasgala2/schemas/palette';
35
+
36
+ import { ThemeAssetError } from '../../errors.js';
37
+ import {
38
+ APPEARANCE_RESOLVED_MODE_ATTRIBUTE,
39
+ APPEARANCE_ROOT_ATTRIBUTE,
40
+ } from './contract.js';
41
+
42
+ /** @type {string} */
43
+ export const PALETTE_STYLESHEET_DIRECTORY = 'assets';
44
+ /** @type {string} */
45
+ export const PALETTE_STYLESHEET_PREFIX = 'gala-palette-';
46
+ /** @type {string} */
47
+ export const PALETTE_STYLESHEET_MEDIA_TYPE = 'text/css; charset=utf-8';
48
+ /** @type {number} the palette sheet's size ceiling (design D4). */
49
+ export const PALETTE_STYLESHEET_MAXIMUM_BYTES = 16384;
50
+
51
+ const THEME_CONTRACT_SCHEMA_ID = 'urn:gala:schema:theme-contract:2.0.0';
52
+
53
+ /**
54
+ * @param {Record<string, string>} declarations `--gala-<key>` -> value
55
+ * @param {string} indent line indent
56
+ * @returns {string} the declaration lines
57
+ */
58
+ function declarationLines(declarations, indent) {
59
+ return Object.entries(declarations)
60
+ .map(([key, value]) => `${indent}--gala-${key}: ${value};\n`)
61
+ .join('');
62
+ }
63
+
64
+ /**
65
+ * Serialise the bound tokens into the four-block sheet.
66
+ *
67
+ * @param {Record<string, {light: string, dark: string}>} tokens derived tokens
68
+ * @param {readonly string[]} bound the bound keys, in the theme's order
69
+ * @returns {string} the stylesheet text
70
+ */
71
+ function serialisePaletteSheet(tokens, bound) {
72
+ const keys = [...bound].sort((a, b) =>
73
+ Buffer.compare(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8')),
74
+ );
75
+ /** @type {Record<string, string>} */
76
+ const light = {};
77
+ /** @type {Record<string, string>} */
78
+ const dark = {};
79
+ for (const key of keys) {
80
+ light[key] = /** @type {{light: string, dark: string}} */ (
81
+ tokens[key]
82
+ ).light;
83
+ dark[key] = /** @type {{light: string, dark: string}} */ (tokens[key]).dark;
84
+ }
85
+ const root = `[${APPEARANCE_ROOT_ATTRIBUTE}]`;
86
+ const resolved = (/** @type {string} */ mode) =>
87
+ `[${APPEARANCE_ROOT_ATTRIBUTE}][${APPEARANCE_RESOLVED_MODE_ATTRIBUTE}="${mode}"]`;
88
+ return (
89
+ `@layer gala-tokens {\n` +
90
+ ` ${root} {\n${declarationLines(light, ' ')} }\n` +
91
+ ` @media (prefers-color-scheme: dark) {\n` +
92
+ ` ${root} {\n${declarationLines(dark, ' ')} }\n` +
93
+ ` }\n` +
94
+ ` ${resolved('light')} {\n${declarationLines(light, ' ')} }\n` +
95
+ ` ${resolved('dark')} {\n${declarationLines(dark, ' ')} }\n` +
96
+ `}\n`
97
+ );
98
+ }
99
+
100
+ /**
101
+ * Map a schemas `PaletteError` to the template's refusal shape.
102
+ *
103
+ * @param {unknown} error the thrown value
104
+ * @returns {never} always throws
105
+ */
106
+ function rethrowPaletteError(error) {
107
+ if (error instanceof PaletteError) {
108
+ throw new ThemeAssetError(error.code, error.detail);
109
+ }
110
+ throw error;
111
+ }
112
+
113
+ /**
114
+ * Admit a theme's `palette` block: the theme must declare one and it must be
115
+ * well formed. There is no fallback for a theme without it.
116
+ *
117
+ * @param {Record<string, unknown>} themeJson the parsed `theme.json`
118
+ * @returns {void}
119
+ * @throws {ThemeAssetError} `THEME_PALETTE_MISSING` or `THEME_PALETTE_INVALID`
120
+ */
121
+ export function assertThemePalette(themeJson) {
122
+ try {
123
+ derivePalette(
124
+ /** @type {Parameters<typeof derivePalette>[0]} */ (themeJson),
125
+ {},
126
+ );
127
+ } catch (error) {
128
+ rethrowPaletteError(error);
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Derive, check and serialise the palette sheet for one build.
134
+ *
135
+ * @param {Record<string, unknown>} themeJson the admitted `theme.json`
136
+ * @param {unknown} appearanceTokens `appearance.tokens` (`{}` or absent for
137
+ * the theme's default palette)
138
+ * @returns {{
139
+ * text: string,
140
+ * bytes: Buffer,
141
+ * fileName: string,
142
+ * }} the sheet and its content-addressed file name
143
+ * @throws {ThemeAssetError} `APPEARANCE_PALETTE_INVALID` (malformed picks or
144
+ * a derived value outside the token grammar), `APPEARANCE_PALETTE_CONTRAST`
145
+ * (a pair below its minimum; the message names the pair, mode and ratio)
146
+ */
147
+ export function buildPaletteStylesheet(themeJson, appearanceTokens) {
148
+ const theme = /** @type {Parameters<typeof derivePalette>[0]} */ (themeJson);
149
+ /** @type {ReturnType<typeof derivePalette>} */
150
+ let derived;
151
+ try {
152
+ derived = derivePalette(
153
+ theme,
154
+ /** @type {Parameters<typeof derivePalette>[1]} */ (
155
+ appearanceTokens ?? {}
156
+ ),
157
+ );
158
+ } catch (error) {
159
+ return rethrowPaletteError(error);
160
+ }
161
+
162
+ // Every derived value passes the closed token grammar: the theme-contract
163
+ // schema validates each token's value against its type, so substitute the
164
+ // derived values into the theme's own token rows and validate.
165
+ const finalTokens =
166
+ /** @type {{key: string, light: string, dark: string}[]} */ (
167
+ themeJson.tokens
168
+ ).map((row) => ({
169
+ ...row,
170
+ light: derived.tokens[row.key]?.light ?? row.light,
171
+ dark: derived.tokens[row.key]?.dark ?? row.dark,
172
+ }));
173
+ const validation = validateGalaDocument(THEME_CONTRACT_SCHEMA_ID, {
174
+ ...themeJson,
175
+ tokens: finalTokens,
176
+ });
177
+ if (!validation.valid) {
178
+ throw new ThemeAssetError(
179
+ 'APPEARANCE_PALETTE_INVALID',
180
+ `a derived palette value is outside the token grammar ` +
181
+ `(${validation.diagnostics.length} diagnostic(s)): ` +
182
+ JSON.stringify(validation.diagnostics),
183
+ );
184
+ }
185
+
186
+ for (const mode of /** @type {const} */ (['light', 'dark'])) {
187
+ const check = checkPalette(derived, mode);
188
+ const failure = check.failures[0];
189
+ if (failure) {
190
+ const ratio =
191
+ failure.ratio === null
192
+ ? (failure.reason ?? 'unmeasurable')
193
+ : `${failure.ratio.toFixed(2)}:1`;
194
+ throw new ThemeAssetError(
195
+ 'APPEARANCE_PALETTE_CONTRAST',
196
+ `${failure.pair} in ${mode} mode is ${ratio}, below ${failure.minimum}:1 ` +
197
+ `(${check.failures.length} failing pair(s) in ${mode} mode)`,
198
+ { pair: failure.pair, mode, failures: check.failures },
199
+ );
200
+ }
201
+ }
202
+
203
+ const text = serialisePaletteSheet(derived.tokens, derived.bound);
204
+ const bytes = Buffer.from(text, 'utf8');
205
+ if (bytes.byteLength > PALETTE_STYLESHEET_MAXIMUM_BYTES) {
206
+ throw new ThemeAssetError(
207
+ 'APPEARANCE_PALETTE_INVALID',
208
+ `the palette stylesheet is ${bytes.byteLength} bytes, over ${PALETTE_STYLESHEET_MAXIMUM_BYTES}`,
209
+ );
210
+ }
211
+ const hash = createHash('sha256').update(bytes).digest('hex').slice(0, 12);
212
+ return {
213
+ text,
214
+ bytes,
215
+ fileName: `${PALETTE_STYLESHEET_PREFIX}${hash}.css`,
216
+ };
217
+ }
@@ -74,15 +74,19 @@ import {
74
74
  APPEARANCE_TOGGLE_ID,
75
75
  } from './contract.js';
76
76
  import { PAGE_KIND_ATTRIBUTE, PAGE_KIND_VALUES } from '../page-kinds.js';
77
+ import {
78
+ COMPOSITION_ATTRIBUTES,
79
+ COMPOSITION_ATTRIBUTE_CATALOG,
80
+ } from '../template-composition.js';
77
81
  import { SLOT_NAMES } from '../skeleton.js';
78
82
  import { canonicalizeJcs, domainDigest } from '../canonical-jcs.js';
79
83
 
80
84
  /** @type {string} */
81
85
  export const TEMPLATE_STYLING_CONTRACT_PACKAGE = '@rathnasgala2/template';
82
86
  /** @type {string} */
83
- export const TEMPLATE_STYLING_CONTRACT_VERSION = '3.0.0';
87
+ export const TEMPLATE_STYLING_CONTRACT_VERSION = '4.0.0';
84
88
  /** @type {string} */
85
- export const TEMPLATE_STYLING_CONTRACT_TEMPLATE_VERSION = '3.0.0';
89
+ export const TEMPLATE_STYLING_CONTRACT_TEMPLATE_VERSION = '4.0.0';
86
90
 
87
91
  /** @type {string} the `main-content` landmark's fixed `id`. */
88
92
  const MAIN_CONTENT_ID = 'main-content';
@@ -278,6 +282,9 @@ export const COMPONENT_CLASS_GROUPS = Object.freeze({
278
282
  'g-section-head',
279
283
  'g-grid',
280
284
  'g-list',
285
+ 'g-dated-list',
286
+ 'g-dated-row',
287
+ 'g-date-stamp',
281
288
  'g-pills',
282
289
  'g-pill',
283
290
  'g-pill-count',
@@ -343,6 +350,7 @@ export const COMPONENT_CLASS_GROUPS = Object.freeze({
343
350
  'g-article-meta',
344
351
  'g-share',
345
352
  'g-article-cover',
353
+ 'g-article-cover-full-bleed',
346
354
  'g-article-grid',
347
355
  'g-toc',
348
356
  'g-toc-list',
@@ -468,6 +476,13 @@ const ATTRIBUTE_VALUE_HOOKS = Object.freeze([
468
476
  kind: /** @type {const} */ ('attribute'),
469
477
  }),
470
478
  ),
479
+ ...COMPOSITION_ATTRIBUTES.map(({ field, name, value }) =>
480
+ Object.freeze({
481
+ hookId: `composition-${field.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-${value}`,
482
+ selectorAtom: `[${name}="${value}"]`,
483
+ kind: /** @type {const} */ ('attribute'),
484
+ }),
485
+ ),
471
486
  ]);
472
487
 
473
488
  /** @type {readonly StyleHookDefinition[]} every public hook, unsorted. */
@@ -524,6 +539,12 @@ export function buildTemplateStylingContract() {
524
539
  values: sortedUniqueBytes(PAGE_KIND_VALUES),
525
540
  role: 'semantic',
526
541
  },
542
+ ...COMPOSITION_ATTRIBUTE_CATALOG.map(({ name, values }) => ({
543
+ name,
544
+ match: 'exact-value',
545
+ values: sortedUniqueBytes(values),
546
+ role: 'semantic',
547
+ })),
527
548
  ].sort((a, b) => {
528
549
  const bufferA = Buffer.from(canonicalizeJcs(a), 'utf8');
529
550
  const bufferB = Buffer.from(canonicalizeJcs(b), 'utf8');
@@ -648,7 +669,7 @@ export function assertTemplateStylingContractShape(contract) {
648
669
  if (contract.profile !== 'gala-template-styling-contract-v2') {
649
670
  fail('profile');
650
671
  }
651
- if (contract.contractVersion !== '3.0.0') fail('contractVersion');
672
+ if (contract.contractVersion !== '4.0.0') fail('contractVersion');
652
673
  if (contract.templatePackage !== '@rathnasgala2/template') {
653
674
  fail('templatePackage');
654
675
  }
@@ -52,6 +52,8 @@ function renderMark(name, mark) {
52
52
  * @param {string} [options.appearanceControlHtml] the already-rendered
53
53
  * appearance control, inserted into the `header-actions` slot
54
54
  * @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} options.messages
55
+ * @param {string} options.profile the admitted composition profile
56
+ * @param {string} options.headerLayout the admitted composition's header layout
55
57
  * @returns {string} the rendered header
56
58
  */
57
59
  export function renderHeader({
@@ -66,6 +68,8 @@ export function renderHeader({
66
68
  searchIndexHref,
67
69
  appearanceControlHtml,
68
70
  messages,
71
+ profile,
72
+ headerLayout,
69
73
  }) {
70
74
  const label = (/** @type {string} */ key) =>
71
75
  escapeHtml(/** @type {string} */ (messages[key]));
@@ -74,19 +78,28 @@ export function renderHeader({
74
78
  ? `<details class="g-menu"><summary>${icon('menu')}<span class="g-sr">${label('menuSummaryLabel')}</span></summary>` +
75
79
  `<ul>${navItems.map((item) => renderNavItem(item, currentRoute, urlPrefix)).join('')}</ul></details>`
76
80
  : '';
77
- return (
78
- `<header class="g-header"><div class="g-wrap g-header-row">` +
81
+ const brand =
79
82
  `<a class="g-brand" href="${escapeHtml(homeRoute)}">${renderMark(publicationName, brandMark)}` +
80
83
  `<span class="g-brand-text"><span class="g-brand-name">${escapeHtml(publicationName)}</span>` +
81
- `<span class="g-brand-tag">${escapeHtml(tagline)}</span></span></a>` +
82
- navHtml +
84
+ `<span class="g-brand-tag">${escapeHtml(tagline)}</span></span></a>`;
85
+ const actions =
83
86
  `<div class="g-actions">` +
84
87
  `<button class="g-search-btn" type="button" hidden data-action="search" data-search-index="${escapeHtml(searchIndexHref)}" aria-label="${label('searchButtonAriaLabel')}">` +
85
88
  `${icon('search')}<span>${label('searchButtonLabel')}</span></button>` +
86
89
  renderSlot('header-actions', { html: appearanceControlHtml }) +
87
90
  menu +
88
- `</div></div></header>`
89
- );
91
+ `</div>`;
92
+ const content =
93
+ profile === 'newsletter'
94
+ ? actions + brand + navHtml
95
+ : profile === 'visual'
96
+ ? brand + actions + navHtml
97
+ : profile === 'personal'
98
+ ? navHtml + brand + actions
99
+ : profile === 'developer'
100
+ ? navHtml + actions + brand
101
+ : brand + navHtml + actions;
102
+ return `<header class="g-header" data-gala-header-layout="${escapeHtml(headerLayout)}"><div class="g-wrap g-header-row">${content}</div></header>`;
90
103
  }
91
104
 
92
105
  /**
@@ -146,6 +159,8 @@ function renderSocialLinks(socialLinks, rssHref, messages) {
146
159
  * attribution line renders: the build input's
147
160
  * `appearance.attribution.showMadeWith`, shown when absent
148
161
  * @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} options.messages
162
+ * @param {string} options.profile the admitted composition profile
163
+ * @param {string} options.footerLayout the admitted composition's footer layout
149
164
  * @returns {string} the rendered footer
150
165
  */
151
166
  export function renderFooter({
@@ -163,6 +178,8 @@ export function renderFooter({
163
178
  copyrightText,
164
179
  showAttribution,
165
180
  messages,
181
+ profile,
182
+ footerLayout,
166
183
  }) {
167
184
  const label = (/** @type {string} */ key) =>
168
185
  escapeHtml(/** @type {string} */ (messages[key]));
@@ -176,20 +193,31 @@ export function renderFooter({
176
193
  ? ''
177
194
  : `<nav class="g-footer-col" aria-label="${label(labelKey)}"><p class="g-label">${label(labelKey)}</p>` +
178
195
  `<ul>${items.map((item) => renderNavItem(item, undefined, urlPrefix)).join('')}</ul></nav>`;
179
- return (
180
- `<footer class="g-footer"><div class="g-wrap g-footer-grid">` +
196
+ const brand =
181
197
  `<div class="g-footer-brand">` +
182
198
  `<a class="g-brand" href="${escapeHtml(homeRoute)}">${renderMark(publicationName, brandMark)}` +
183
199
  `<span class="g-brand-name">${escapeHtml(publicationName)}</span></a>` +
184
200
  `<p class="g-footer-about">${escapeHtml(description)}</p>` +
185
201
  renderSocialLinks(socialLinks, rssHref, messages) +
186
- `</div>` +
187
- column('exploreColumnLabel', primaryItems) +
188
- column('footerNavigationLabel', footerItems) +
202
+ `</div>`;
203
+ const primary = column('exploreColumnLabel', primaryItems);
204
+ const secondary = column('footerNavigationLabel', footerItems);
205
+ const profileCard =
189
206
  `<div class="g-footer-col">` +
190
207
  renderSlot('footer-profile', { html: authorHtml + footerProfileHtml }) +
191
- `</div>` +
192
- `</div>` +
208
+ `</div>`;
209
+ const grid =
210
+ profile === 'newsletter'
211
+ ? brand + profileCard + primary + secondary
212
+ : profile === 'visual'
213
+ ? brand + primary + profileCard + secondary
214
+ : profile === 'personal'
215
+ ? profileCard + brand + primary + secondary
216
+ : profile === 'developer'
217
+ ? primary + secondary + brand + profileCard
218
+ : brand + primary + secondary + profileCard;
219
+ return (
220
+ `<footer class="g-footer" data-gala-footer-layout="${escapeHtml(footerLayout)}"><div class="g-wrap g-footer-grid">${grid}</div>` +
193
221
  renderSlot('footer-auxiliary') +
194
222
  renderSlot('account-intent') +
195
223
  renderSlot('conversation') +
@@ -33,10 +33,10 @@
33
33
  import Eleventy from '@11ty/eleventy';
34
34
 
35
35
  import {
36
+ APPEARANCE_ALLOWED_ATTRIBUTE,
37
+ APPEARANCE_DEFAULT_ATTRIBUTE,
36
38
  APPEARANCE_RESOLVED_MODE_ATTRIBUTE,
37
39
  APPEARANCE_ROOT_ATTRIBUTE,
38
- APPEARANCE_SERVER_DEFAULT_RESOLVED_MODE,
39
- COLOR_SCHEME_META_CONTENT,
40
40
  } from './appearance/contract.js';
41
41
  import { PAGE_KIND_ATTRIBUTE } from './page-kinds.js';
42
42
 
@@ -78,7 +78,8 @@ import { PAGE_KIND_ATTRIBUTE } from './page-kinds.js';
78
78
  * rendered independent of JavaScript, so a theme's own no-JS
79
79
  * `prefers-color-scheme` fallback CSS has a stable root scope to key off;
80
80
  * - (TPL-C1 fix) `data-gala-resolved-color-mode`, server-rendered as
81
- * {@link APPEARANCE_SERVER_DEFAULT_RESOLVED_MODE} (`"light"`) on the same
81
+ * `data.resolvedColorMode` (the author's `light`/`dark` default, else
82
+ * `"light"` for a `system` default) on the same
82
83
  * `<html>` element, so `RESOLVED_PALETTE_SELECTORS.light` already matches
83
84
  * before any script runs and a page is never unstyled UA-default HTML for
84
85
  * a no-JS reader, a text-mode/archival crawler, or a load where the
@@ -87,7 +88,12 @@ import { PAGE_KIND_ATTRIBUTE } from './page-kinds.js';
87
88
  * this attribute with the resolved value for the reader's actual stored
88
89
  * selection/system preference the instant it runs, so a scripted reader's
89
90
  * experience is unchanged;
90
- * - `<meta name="color-scheme" content="light dark">`, so user-agent styling
91
+ * - `data-gala-color-mode-default` and `data-gala-color-mode-allowed`, the
92
+ * author's "Visitors see" default and allowed modes
93
+ * (`appearance.colorMode`), which the bootstrap script prefers over
94
+ * `system` when the reader has no stored choice;
95
+ * - `<meta name="color-scheme">`, `light dark` unless the author allows a
96
+ * single `light` or `dark` mode, so user-agent styling
91
97
  * (form controls, scrollbars) and the initial paint already follow
92
98
  * `prefers-color-scheme` before any script runs;
93
99
  * - the one `<script src>` this renderer ever emits (the appearance
@@ -128,12 +134,12 @@ import { PAGE_KIND_ATTRIBUTE } from './page-kinds.js';
128
134
  */
129
135
  const SKELETON_LAYOUT_SOURCE = [
130
136
  '<!doctype html>',
131
- `<html lang="{{ lang }}" dir="{{ dir }}" ${APPEARANCE_ROOT_ATTRIBUTE} ${APPEARANCE_RESOLVED_MODE_ATTRIBUTE}="${APPEARANCE_SERVER_DEFAULT_RESOLVED_MODE}">`,
137
+ `<html lang="{{ lang }}" dir="{{ dir }}" ${APPEARANCE_ROOT_ATTRIBUTE} ${APPEARANCE_RESOLVED_MODE_ATTRIBUTE}="{{ resolvedColorMode }}" ${APPEARANCE_DEFAULT_ATTRIBUTE}="{{ colorModeDefault }}" ${APPEARANCE_ALLOWED_ATTRIBUTE}="{{ colorModeAllowed }}">`,
132
138
  '<head>',
133
139
  '<meta charset="utf-8">',
134
140
  '<meta name="viewport" content="width=device-width, initial-scale=1">',
135
141
  '{{ cspMetaTag | safe }}',
136
- `<meta name="color-scheme" content="${COLOR_SCHEME_META_CONTENT}">`,
142
+ '<meta name="color-scheme" content="{{ colorScheme }}">',
137
143
  // The interactions script (article pages, module on) is deferred and sits
138
144
  // on the appearance script's line, so a page without it is byte-identical
139
145
  // to before.
@@ -172,7 +178,7 @@ const SKELETON_LAYOUT_SOURCE = [
172
178
  '<link rel="stylesheet" href="{{ baseStylesheetHref }}">',
173
179
  '{{ themeStylesheetLinksHtml | safe }}',
174
180
  '</head>',
175
- `<body ${PAGE_KIND_ATTRIBUTE}="{{ pageKind }}">`,
181
+ `<body ${PAGE_KIND_ATTRIBUTE}="{{ pageKind }}" data-gala-profile="{{ composition.profile }}" data-gala-home-layout="{{ composition.homeLayout }}" data-gala-article-layout="{{ composition.articleLayout }}" data-gala-author-layout="{{ composition.authorLayout }}" data-gala-listing-layout="{{ composition.listingLayout }}" data-gala-header-layout="{{ composition.headerLayout }}" data-gala-footer-layout="{{ composition.footerLayout }}" data-gala-media-ratio="{{ composition.mediaRatio }}" data-gala-density="{{ composition.density }}">`,
176
182
  '{{ content | safe }}',
177
183
  '</body>',
178
184
  '</html>',
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The renderer's own published version, the single constant both the public
3
+ * `TEMPLATE_PACKAGE_VERSION` export and theme `templateRange` admission read.
4
+ * It is deliberately separate from the styling contract's
5
+ * `templateVersion` (`appearance/styling-contract.js`), which records the
6
+ * version the contract was published at and is part of the contract digest:
7
+ * a renderer release that does not change the contract keeps that digest.
8
+ *
9
+ * @type {string}
10
+ */
11
+ export const TEMPLATE_RENDERER_VERSION = '4.1.0';