@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
|
@@ -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 = '
|
|
87
|
+
export const TEMPLATE_STYLING_CONTRACT_VERSION = '4.0.0';
|
|
84
88
|
/** @type {string} */
|
|
85
|
-
export const TEMPLATE_STYLING_CONTRACT_TEMPLATE_VERSION = '
|
|
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 !== '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
* -
|
|
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}="${
|
|
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
|
-
|
|
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';
|