@rathnasgala2/template 2.0.0 → 2.2.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 +211 -137
- package/contracts/render-policy.jcs +1 -1
- package/contracts/theme-styling-contract.jcs +1 -1
- package/package.json +5 -6
- package/src/core/errors.js +13 -13
- package/src/core/index.js +130 -75
- package/src/core/internal/appearance/base-layer.js +180 -0
- package/src/core/internal/appearance/bootstrap-script.js +11 -11
- package/src/core/internal/appearance/contract.js +59 -44
- package/src/core/internal/appearance/controller-markup.js +7 -8
- package/src/core/internal/appearance/styling-contract.js +132 -29
- package/src/core/internal/canonical-jcs.js +0 -0
- package/src/core/internal/content-security.js +0 -0
- package/src/core/internal/eleventy-render.js +52 -35
- package/src/core/internal/feeds.js +15 -15
- package/src/core/internal/fs-walk.js +2 -2
- package/src/core/internal/media/avif-probe.js +4 -4
- package/src/core/internal/media/font-policy.js +2 -2
- package/src/core/internal/media/jpeg-codec.js +0 -0
- package/src/core/internal/media/limits.js +32 -34
- package/src/core/internal/media/pipeline.js +4 -4
- package/src/core/internal/media/png-codec.js +7 -7
- package/src/core/internal/media/resize.js +2 -2
- package/src/core/internal/media/sniff.js +4 -4
- package/src/core/internal/media/theme-svg-sanitizer.js +278 -0
- package/src/core/internal/media/webp-probe.js +3 -3
- package/src/core/internal/messages.js +8 -8
- package/src/core/internal/page-kinds.js +50 -51
- package/src/core/internal/render-policy-content.js +57 -21
- package/src/core/internal/route-labels.js +5 -6
- package/src/core/internal/route.js +58 -20
- package/src/core/internal/search-index.js +17 -16
- package/src/core/internal/semver-range.js +84 -0
- package/src/core/internal/seo.js +24 -24
- package/src/core/internal/sitemap.js +11 -12
- package/src/core/internal/skeleton.js +65 -46
- package/src/core/internal/source-inventory.js +4 -5
- package/src/core/internal/text-direction.js +1 -2
- package/src/core/internal/theme-assets.js +314 -68
- package/src/core/internal/uuid.js +4 -5
- package/src/core/manifest.js +0 -0
- package/types/index.d.ts +22 -8
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
* (grayscale+alpha) and 6 (truecolor+alpha), bit depth 8 only, no
|
|
7
7
|
* interlacing. Any other bit depth, interlace method, or PNG structure this
|
|
8
8
|
* module does not recognize is a `MEDIA_FORMAT_INVALID` rejection — never a
|
|
9
|
-
* best-effort guess — matching the fail-closed posture
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* best-effort guess — matching the fail-closed posture required throughout
|
|
10
|
+
* this pipeline. This is a deliberately smaller surface than this
|
|
11
|
+
* renderer's own closed passive-theme-asset PNG grammar (which the theme
|
|
12
|
+
* packages use for passive presentation assets); author media only needs a
|
|
13
|
+
* decode path sufficient to bound, re-derive
|
|
14
14
|
* orientation-free pixels from, and re-encode ordinary photographic/graphic
|
|
15
15
|
* uploads.
|
|
16
16
|
*
|
|
@@ -371,8 +371,8 @@ function crc32(buffer) {
|
|
|
371
371
|
* Deterministically encode an RGBA8 raster as a minimal truecolor+alpha PNG:
|
|
372
372
|
* one `IHDR`, one "filter type 0 (none)" `IDAT` stream at a fixed zlib
|
|
373
373
|
* level/strategy, one `IEND`. No ancillary chunk (no metadata) is ever
|
|
374
|
-
* emitted,
|
|
375
|
-
*
|
|
374
|
+
* emitted, stripping nonessential metadata
|
|
375
|
+
* from every output this renderer produces.
|
|
376
376
|
*
|
|
377
377
|
* @param {{width: number, height: number, rgba: Buffer}} raster the raster
|
|
378
378
|
* to encode
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
*
|
|
5
5
|
* IEEE 754 double-precision arithmetic is fully deterministic — the same
|
|
6
6
|
* inputs always produce the same bit pattern on any conforming engine — so
|
|
7
|
-
* this box filter (unlike a lossy codec's own pixel decode, which
|
|
8
|
-
*
|
|
7
|
+
* this box filter (unlike a lossy codec's own pixel decode, which is
|
|
8
|
+
* implementation-dependent) is safe to use directly for the
|
|
9
9
|
* two-clean-build byte-equality requirement: the same source raster and the
|
|
10
10
|
* same target width always produce the same resized raster, and therefore,
|
|
11
11
|
* fed through this pipeline's own deterministic encoders, the same
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Media-type sniffing from decoded bytes, never from a file extension or a
|
|
3
|
-
* caller-declared media type
|
|
4
|
-
* decoded bytes, not extension
|
|
3
|
+
* caller-declared media type: media type is established from
|
|
4
|
+
* decoded bytes, not extension.
|
|
5
5
|
*
|
|
6
|
-
* SVG is deliberately excluded from this sniffer's admitted output set:
|
|
7
|
-
* is never processed as author media
|
|
6
|
+
* SVG is deliberately excluded from this sniffer's admitted output set: SVG
|
|
7
|
+
* is never processed as author media. Any byte
|
|
8
8
|
* sequence recognizable as SVG/XML is classified `'svg'` here specifically
|
|
9
9
|
* so the caller can reject it with a distinct, explicit
|
|
10
10
|
* `MEDIA_SVG_REJECTED` reason rather than falling through to a generic
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The closed admission grammar a theme-declared passive SVG asset must pass
|
|
3
|
+
* before it is copied into a published artifact (TPL-C2, prerequisite for
|
|
4
|
+
* TPL-H6's iconography decision).
|
|
5
|
+
*
|
|
6
|
+
* Author-supplied SVG is unconditionally rejected by the media pipeline
|
|
7
|
+
* (`internal/media/sniff.js`'s `'svg'` classification, `MEDIA_SVG_REJECTED`
|
|
8
|
+
* in `internal/media/pipeline.js`) — SVG is active content (it admits
|
|
9
|
+
* `<script>`, event-handler attributes and external references) and the
|
|
10
|
+
* render-policy's own residual-active-content scan explicitly forbids it in
|
|
11
|
+
* markdown-derived HTML. A theme package is a lower-trust supply-chain input
|
|
12
|
+
* than the repository owner's own authored content, so it cannot be held to
|
|
13
|
+
* a looser standard: this module is the one path an SVG byte sequence may
|
|
14
|
+
* take to reach a published artifact, and it applies its own closed
|
|
15
|
+
* tag/attribute/scheme allowlist — independent of, and strictly narrower
|
|
16
|
+
* than, `sanitize-html`'s author-content configuration in
|
|
17
|
+
* `internal/content-security.js` — followed by the same class of fail-closed
|
|
18
|
+
* residual-scan backstop that pipeline uses.
|
|
19
|
+
*
|
|
20
|
+
* Scope: this grammar targets exactly one use case — a small, static,
|
|
21
|
+
* decorative icon or mark referenced from theme CSS (`::before`/`::after`
|
|
22
|
+
* `content: url(...)`, `background-image`, or an `<img>`/`<use>` a future
|
|
23
|
+
* theme ships). It is deliberately conservative: no `<script>`, no `<style>`
|
|
24
|
+
* (inline CSS can itself carry `url()` external references), no
|
|
25
|
+
* `<foreignObject>` (arbitrary embedded HTML), no `<image>` (a raster/SVG
|
|
26
|
+
* reference of its own, which would need this same admission recursively),
|
|
27
|
+
* no event-handler attribute of any kind, and no attribute whose value is a
|
|
28
|
+
* URL reference other than a same-document `#fragment` (so `<use href="#id">`
|
|
29
|
+
* referencing a `<symbol>` defined earlier in the same file works; anything
|
|
30
|
+
* that could reach off-document content does not).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import sanitizeHtml from 'sanitize-html';
|
|
34
|
+
|
|
35
|
+
import { ThemeAssetError } from '../../errors.js';
|
|
36
|
+
|
|
37
|
+
/** @type {string} the reason code every rejection in this module carries. */
|
|
38
|
+
export const THEME_SVG_REJECTED_CODE = 'THEME_ASSET_SVG_REJECTED';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The closed SVG element allowlist: structural/grouping and shape elements,
|
|
42
|
+
* gradients/clipping used for flat icon artwork, and `<title>`/`<desc>` for
|
|
43
|
+
* accessible names. No scripting, styling, foreign-content or
|
|
44
|
+
* external-media element is admitted.
|
|
45
|
+
*
|
|
46
|
+
* @type {readonly string[]}
|
|
47
|
+
*/
|
|
48
|
+
const ALLOWED_SVG_TAGS = Object.freeze([
|
|
49
|
+
'svg',
|
|
50
|
+
'g',
|
|
51
|
+
'defs',
|
|
52
|
+
'symbol',
|
|
53
|
+
'use',
|
|
54
|
+
'title',
|
|
55
|
+
'desc',
|
|
56
|
+
'path',
|
|
57
|
+
'rect',
|
|
58
|
+
'circle',
|
|
59
|
+
'ellipse',
|
|
60
|
+
'line',
|
|
61
|
+
'polyline',
|
|
62
|
+
'polygon',
|
|
63
|
+
'clipPath',
|
|
64
|
+
'mask',
|
|
65
|
+
'linearGradient',
|
|
66
|
+
'radialGradient',
|
|
67
|
+
'stop',
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
/** @type {readonly string[]} presentation/geometry attributes admitted on
|
|
71
|
+
* every allowed tag (a closed union, not per-tag — {@link assertNoResidualActiveSvgContent}
|
|
72
|
+
* is the fail-closed backstop for anything this allowlist under-restricts). */
|
|
73
|
+
const COMMON_SVG_ATTRIBUTES = Object.freeze([
|
|
74
|
+
'id',
|
|
75
|
+
'class',
|
|
76
|
+
'role',
|
|
77
|
+
'aria-hidden',
|
|
78
|
+
'aria-label',
|
|
79
|
+
'focusable',
|
|
80
|
+
'viewBox',
|
|
81
|
+
'width',
|
|
82
|
+
'height',
|
|
83
|
+
'x',
|
|
84
|
+
'y',
|
|
85
|
+
'x1',
|
|
86
|
+
'y1',
|
|
87
|
+
'x2',
|
|
88
|
+
'y2',
|
|
89
|
+
'cx',
|
|
90
|
+
'cy',
|
|
91
|
+
'r',
|
|
92
|
+
'rx',
|
|
93
|
+
'ry',
|
|
94
|
+
'd',
|
|
95
|
+
'points',
|
|
96
|
+
'transform',
|
|
97
|
+
'fill',
|
|
98
|
+
'fill-rule',
|
|
99
|
+
'fill-opacity',
|
|
100
|
+
'stroke',
|
|
101
|
+
'stroke-width',
|
|
102
|
+
'stroke-linecap',
|
|
103
|
+
'stroke-linejoin',
|
|
104
|
+
'stroke-dasharray',
|
|
105
|
+
'stroke-opacity',
|
|
106
|
+
'opacity',
|
|
107
|
+
'clip-path',
|
|
108
|
+
'clip-rule',
|
|
109
|
+
'mask',
|
|
110
|
+
'offset',
|
|
111
|
+
'stop-color',
|
|
112
|
+
'stop-opacity',
|
|
113
|
+
'gradientUnits',
|
|
114
|
+
'gradientTransform',
|
|
115
|
+
'preserveAspectRatio',
|
|
116
|
+
'xmlns',
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
/** @type {import('sanitize-html').IOptions} */
|
|
120
|
+
const SVG_SANITIZE_OPTIONS = {
|
|
121
|
+
allowedTags: [...ALLOWED_SVG_TAGS],
|
|
122
|
+
allowedAttributes: Object.fromEntries(
|
|
123
|
+
ALLOWED_SVG_TAGS.map((tag) => [
|
|
124
|
+
tag,
|
|
125
|
+
tag === 'use'
|
|
126
|
+
? [...COMMON_SVG_ATTRIBUTES, 'href', 'xlink:href']
|
|
127
|
+
: [...COMMON_SVG_ATTRIBUTES],
|
|
128
|
+
]),
|
|
129
|
+
),
|
|
130
|
+
// `href`/`xlink:href` are only ever admitted on `use` above, and only a
|
|
131
|
+
// same-document fragment reference is a legal value (enforced below by
|
|
132
|
+
// {@link assertNoResidualActiveSvgContent}, since sanitize-html's own
|
|
133
|
+
// scheme allowlist does not distinguish "no scheme" from "fragment-only").
|
|
134
|
+
allowedSchemesByTag: { use: [] },
|
|
135
|
+
allowProtocolRelative: false,
|
|
136
|
+
nonTextTags: [
|
|
137
|
+
'script',
|
|
138
|
+
'style',
|
|
139
|
+
'foreignObject',
|
|
140
|
+
'image',
|
|
141
|
+
'animate',
|
|
142
|
+
'animateTransform',
|
|
143
|
+
'animateMotion',
|
|
144
|
+
'set',
|
|
145
|
+
'iframe',
|
|
146
|
+
],
|
|
147
|
+
disallowedTagsMode: 'discard',
|
|
148
|
+
parser: { xmlMode: true, decodeEntities: true },
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/** @type {readonly RegExp[]} fail-closed backstop patterns; a hit here is
|
|
152
|
+
* this module's own configuration defect, never expected of admitted
|
|
153
|
+
* content — mirrors `content-security.js`'s
|
|
154
|
+
* `RESIDUAL_ACTIVE_CONTENT_PATTERNS` backstop pattern. */
|
|
155
|
+
const RESIDUAL_ACTIVE_SVG_PATTERNS = Object.freeze([
|
|
156
|
+
/<script[\s>/]/i,
|
|
157
|
+
/<style[\s>/]/i,
|
|
158
|
+
/<foreignobject[\s>/]/i,
|
|
159
|
+
/<image[\s>/]/i,
|
|
160
|
+
/<iframe[\s>/]/i,
|
|
161
|
+
/\son[a-z]+\s*=/i,
|
|
162
|
+
/url\(\s*['"]?(?:https?:)?\/\//i,
|
|
163
|
+
/url\(\s*['"]?data:/i,
|
|
164
|
+
]);
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* @param {string} sanitized the sanitize-html output
|
|
168
|
+
* @returns {void}
|
|
169
|
+
*/
|
|
170
|
+
function assertNoResidualActiveSvgContent(sanitized) {
|
|
171
|
+
for (const pattern of RESIDUAL_ACTIVE_SVG_PATTERNS) {
|
|
172
|
+
if (pattern.test(sanitized)) {
|
|
173
|
+
throw new ThemeAssetError(
|
|
174
|
+
THEME_SVG_REJECTED_CODE,
|
|
175
|
+
`sanitized theme SVG still matches a forbidden pattern (${pattern})`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Every href/xlink:href that survived sanitization must be a same-document
|
|
180
|
+
// fragment reference; sanitize-html's own scheme allowlist admits a
|
|
181
|
+
// schemeless value (including a bare fragment) without distinguishing it
|
|
182
|
+
// from, say, a schemeless relative path, so this is checked explicitly.
|
|
183
|
+
for (const match of sanitized.matchAll(
|
|
184
|
+
/(?:href|xlink:href)\s*=\s*"([^"]*)"/gi,
|
|
185
|
+
)) {
|
|
186
|
+
if (!match[1].startsWith('#')) {
|
|
187
|
+
throw new ThemeAssetError(
|
|
188
|
+
THEME_SVG_REJECTED_CODE,
|
|
189
|
+
`theme SVG href must be a same-document fragment reference, got ${JSON.stringify(match[1])}`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Reject, rather than silently strip, any of the specific constructs the
|
|
197
|
+
* fix requires an SVG sanitizer to police (no script, no event handlers, no
|
|
198
|
+
* external references): a theme package is untrusted supply-chain input, so
|
|
199
|
+
* a file that *contains* one of these is treated as a rejected asset, not
|
|
200
|
+
* quietly laundered into a smaller, safe file the theme author never
|
|
201
|
+
* reviewed. The allowlist pass below is defense-in-depth on top of this,
|
|
202
|
+
* narrowing an already-clean file to the admitted tag/attribute vocabulary.
|
|
203
|
+
*
|
|
204
|
+
* @param {string} source the raw SVG text
|
|
205
|
+
* @returns {void}
|
|
206
|
+
*/
|
|
207
|
+
function assertNoForbiddenSvgConstructs(source) {
|
|
208
|
+
if (/<!doctype/i.test(source) || /<!entity/i.test(source)) {
|
|
209
|
+
throw new ThemeAssetError(
|
|
210
|
+
THEME_SVG_REJECTED_CODE,
|
|
211
|
+
'theme SVG must not declare a DOCTYPE or an XML entity',
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
if (
|
|
215
|
+
/<\s*(script|style|foreignobject|image|iframe|animate\w*|set)[\s>/]/i.test(
|
|
216
|
+
source,
|
|
217
|
+
)
|
|
218
|
+
) {
|
|
219
|
+
throw new ThemeAssetError(
|
|
220
|
+
THEME_SVG_REJECTED_CODE,
|
|
221
|
+
'theme SVG must not contain a <script>, <style>, <foreignObject>, ' +
|
|
222
|
+
'<image>, <iframe>, animation or <set> element',
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
if (/\son[a-z]+\s*=/i.test(source)) {
|
|
226
|
+
throw new ThemeAssetError(
|
|
227
|
+
THEME_SVG_REJECTED_CODE,
|
|
228
|
+
'theme SVG must not carry an event-handler attribute',
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
for (const match of source.matchAll(
|
|
232
|
+
/(?:href|xlink:href)\s*=\s*(['"])(.*?)\1/gis,
|
|
233
|
+
)) {
|
|
234
|
+
if (!match[2].startsWith('#')) {
|
|
235
|
+
throw new ThemeAssetError(
|
|
236
|
+
THEME_SVG_REJECTED_CODE,
|
|
237
|
+
`theme SVG href must be a same-document fragment reference, got ${JSON.stringify(match[2])}`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (/url\(\s*['"]?(?:https?:)?\/\//i.test(source)) {
|
|
242
|
+
throw new ThemeAssetError(
|
|
243
|
+
THEME_SVG_REJECTED_CODE,
|
|
244
|
+
'theme SVG must not reference an external url()',
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
if (/url\(\s*['"]?data:/i.test(source)) {
|
|
248
|
+
throw new ThemeAssetError(
|
|
249
|
+
THEME_SVG_REJECTED_CODE,
|
|
250
|
+
'theme SVG must not embed a data: url()',
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Sanitize one theme-declared SVG passive asset's exact bytes against the
|
|
257
|
+
* closed grammar above, failing closed on anything it does not admit.
|
|
258
|
+
*
|
|
259
|
+
* @param {Buffer} bytes the file's raw bytes (already sniffed as `'svg'` by
|
|
260
|
+
* {@link ../media/sniff.js})
|
|
261
|
+
* @returns {Buffer} the sanitized UTF-8 SVG bytes, safe to publish
|
|
262
|
+
*/
|
|
263
|
+
export function sanitizeThemeSvg(bytes) {
|
|
264
|
+
const source = bytes.toString('utf8');
|
|
265
|
+
assertNoForbiddenSvgConstructs(source);
|
|
266
|
+
const sanitized = sanitizeHtml(source, SVG_SANITIZE_OPTIONS);
|
|
267
|
+
if (sanitized.trim().length === 0 || !/<svg[\s>]/i.test(sanitized)) {
|
|
268
|
+
throw new ThemeAssetError(
|
|
269
|
+
THEME_SVG_REJECTED_CODE,
|
|
270
|
+
'theme SVG did not survive sanitization as a root <svg> element',
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
// Fail-closed backstop over the allowlist pass above — a hit here is this
|
|
274
|
+
// module's own configuration defect, never expected of admitted content
|
|
275
|
+
// (the explicit rejection checks above should already have caught it).
|
|
276
|
+
assertNoResidualActiveSvgContent(sanitized);
|
|
277
|
+
return Buffer.from(sanitized, 'utf8');
|
|
278
|
+
}
|
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
* the `RIFF`/`WEBP` container framing and the `VP8 `/`VP8L` chunk header
|
|
6
6
|
* closely enough to reject a malformed or oversized file and to read the
|
|
7
7
|
* format's own declared width/height, but it does not run a full VP8/VP8L
|
|
8
|
-
* entropy decoder —
|
|
9
|
-
*
|
|
8
|
+
* entropy decoder — this renderer's closed passive-theme-asset decoder
|
|
9
|
+
* requires a complete bounded VP8/VP8L bitstream decode for
|
|
10
10
|
* *passive theme assets*; hand-authoring an equivalent, independently
|
|
11
11
|
* verifiable decoder for *author media* was judged out of proportion to
|
|
12
|
-
* this
|
|
12
|
+
* this module's scope and is flagged to the orchestrator as a follow-up
|
|
13
13
|
* rather than attempted partially. What this module still enforces, fully:
|
|
14
14
|
* exact container framing, a single recognized image chunk (any `VP8X`,
|
|
15
15
|
* animation, or a second image chunk rejects), and the shared
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Core-owned chrome message catalog
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* localization can find them").
|
|
2
|
+
* Core-owned chrome message catalog: every user-visible chrome string comes
|
|
3
|
+
* from a message catalog, not an inline literal, so localization can find
|
|
4
|
+
* them.
|
|
6
5
|
*
|
|
7
6
|
* Every user-visible string this package's own generated chrome (skip link,
|
|
8
7
|
* navigation labels, breadcrumb labels, pagination controls, generated
|
|
9
|
-
* listing-page headings, the error page kind, the
|
|
8
|
+
* listing-page headings, the error page kind, the Light/Dark/System
|
|
10
9
|
* appearance control's label and its three option labels) emits is looked
|
|
11
10
|
* up here by key
|
|
12
|
-
* rather than written inline at its call site.
|
|
13
|
-
* catalog, `en` (the only language the golden fixture's own
|
|
14
|
-
* required to exercise); per-content localization of
|
|
11
|
+
* rather than written inline at its call site. This renderer authors
|
|
12
|
+
* exactly one catalog, `en` (the only language the golden fixture's own
|
|
13
|
+
* core chrome is required to exercise); per-content localization of
|
|
14
|
+
* *authored* content is
|
|
15
15
|
* `publication`/`content` data, not core chrome, and full chrome
|
|
16
16
|
* localization (additional catalogs, a locale-selection policy) is future
|
|
17
17
|
* work this module is shaped to admit without changing any call site: every
|
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Page-kind assembly
|
|
2
|
+
* Page-kind assembly: builds every generated page's
|
|
3
3
|
* `<main>` content, breadcrumb trail and route from a validated
|
|
4
4
|
* `build-input:2.0.0` instance, and the shared site chrome (header, primary
|
|
5
5
|
* navigation, footer) every page kind is composed with through
|
|
6
6
|
* `internal/skeleton.js`'s {@link import('./skeleton.js').renderPageBody}.
|
|
7
7
|
*
|
|
8
|
-
* Page kinds this module produces
|
|
9
|
-
* generated outputs" and task packet S2-T06's page-kind list:
|
|
8
|
+
* Page kinds this module produces:
|
|
10
9
|
*
|
|
11
10
|
* - `profile` — the publication's own optional profile page
|
|
12
11
|
* (`publication.profile`);
|
|
13
|
-
* - `author` — one page per entry in `build-input.authors` (
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* - `author` — one page per entry in `build-input.authors` (this array is
|
|
13
|
+
* already exactly the set of authors referenced by the selected content,
|
|
14
|
+
* publication contact or footer card — never an unreferenced author), at
|
|
15
|
+
* `/authors/<id>`;
|
|
17
16
|
* - `article` / `page` — one page per `content[]` record, at its own
|
|
18
17
|
* `contentFrontmatterNormalized` route;
|
|
19
18
|
* - `index` — a paginated reverse-chronological listing of every
|
|
@@ -27,10 +26,10 @@
|
|
|
27
26
|
* plus one `/archive` root page linking to each year.
|
|
28
27
|
*
|
|
29
28
|
* The `error` page kind ({@link renderErrorPageBody}) is exported as a pure
|
|
30
|
-
* function, not wired into any generated route here:
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
29
|
+
* function, not wired into any generated route here: the one generated
|
|
30
|
+
* `404.html` artifact is assembled elsewhere, which this module's own scope
|
|
31
|
+
* notes forbid touching. That module composes its `404.html` from this same
|
|
32
|
+
* function.
|
|
34
33
|
*
|
|
35
34
|
* Every generated page's own `route` field (see {@link GeneratedPage}) is an
|
|
36
35
|
* un-joined `canonicalRoute`, in the same convention `internal/index.js`
|
|
@@ -39,16 +38,18 @@
|
|
|
39
38
|
* *link href* this module writes into a page's own body (breadcrumbs, tag and
|
|
40
39
|
* series listings, pagination controls) is, by contrast, already joined with
|
|
41
40
|
* `basePath` here, because it is read back only as literal HTML text, never
|
|
42
|
-
* re-joined by a caller. This mirrors
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
41
|
+
* re-joined by a caller. This mirrors the navigation normalization rule: an
|
|
42
|
+
* *authored* navigation item's route is used exactly as authored, never
|
|
43
|
+
* synthesized or rewritten (so `internal/skeleton.js`'s navigation renderer
|
|
44
|
+
* never joins it), while every route this renderer itself derives (every
|
|
45
|
+
* page kind here) is basePath-joined at the point it becomes link text,
|
|
46
|
+
* exactly like the redirect/content route projection elsewhere in this
|
|
47
|
+
* renderer.
|
|
48
48
|
*
|
|
49
49
|
* Every listing here is a deliberately scoped decision this module documents
|
|
50
50
|
* rather than assumes: page size, sort tie-break and the tag/series/archive
|
|
51
|
-
* root pages
|
|
51
|
+
* root pages have no externally fixed specification this renderer conforms
|
|
52
|
+
* to verbatim.
|
|
52
53
|
*/
|
|
53
54
|
|
|
54
55
|
import {
|
|
@@ -60,25 +61,23 @@ import {
|
|
|
60
61
|
import { getMessages } from './messages.js';
|
|
61
62
|
import { resolveTextDirection } from './text-direction.js';
|
|
62
63
|
import { routeSegmentForLabel } from './route-labels.js';
|
|
63
|
-
import {
|
|
64
|
+
import { derivePublicBasePath, joinPublicRoute } from './route.js';
|
|
64
65
|
|
|
65
66
|
/** Listing page size (index and per-year archive pages). Scoped decision:
|
|
66
|
-
*
|
|
67
|
-
* default with no author-facing configuration surface
|
|
67
|
+
* this renderer has no externally fixed page size; 10 is a deterministic,
|
|
68
|
+
* documented default with no author-facing configuration surface.
|
|
68
69
|
* @type {number} */
|
|
69
70
|
export const LISTING_PAGE_SIZE = 10;
|
|
70
71
|
|
|
71
72
|
/**
|
|
72
|
-
* The
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* private/incidental selector
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* page-kind-targeting hook the brief's "every page-kind ... class/attribute"
|
|
81
|
-
* language calls for.
|
|
73
|
+
* The semantic template-owned attribute this renderer sets on every
|
|
74
|
+
* generated page's `<body>`: `contracts/theme-styling-contract.jcs` exposes
|
|
75
|
+
* this as a public theme hook, one per {@link PAGE_KIND_VALUES} entry, so a
|
|
76
|
+
* theme can vary presentation by page kind — e.g. a different article vs.
|
|
77
|
+
* listing background — without the template ever promoting a
|
|
78
|
+
* private/incidental selector. This attribute is a deliberate addition,
|
|
79
|
+
* introduced specifically because the published styling contract needs a
|
|
80
|
+
* page-kind-targeting hook.
|
|
82
81
|
*
|
|
83
82
|
* @type {string}
|
|
84
83
|
*/
|
|
@@ -87,8 +86,8 @@ export const PAGE_KIND_ATTRIBUTE = 'data-gala-page-kind';
|
|
|
87
86
|
/**
|
|
88
87
|
* Every value {@link PAGE_KIND_ATTRIBUTE} may carry: every {@link GeneratedPage}
|
|
89
88
|
* `kind` plus the synthetic `error` kind (`internal/index.js`'s always-present
|
|
90
|
-
* generated `404.html`), sorted by UTF-8 bytes (
|
|
91
|
-
* sort convention).
|
|
89
|
+
* generated `404.html`), sorted by UTF-8 bytes (this renderer's own
|
|
90
|
+
* attribute-value sort convention throughout).
|
|
92
91
|
*
|
|
93
92
|
* @type {readonly string[]}
|
|
94
93
|
*/
|
|
@@ -135,8 +134,8 @@ function paginate(items, pageSize) {
|
|
|
135
134
|
|
|
136
135
|
/**
|
|
137
136
|
* Resolve an author's display name by ID, failing closed on a dangling
|
|
138
|
-
* reference (an adapter defect: `resolvedAuthorIds` is schema
|
|
139
|
-
*
|
|
137
|
+
* reference (an adapter defect: `resolvedAuthorIds` is schema-guaranteed to
|
|
138
|
+
* resolve).
|
|
140
139
|
*
|
|
141
140
|
* @param {ReadonlyMap<string, import('../../../types/index.d.ts').AuthorNormalized>} authorsById
|
|
142
141
|
* @param {string} authorId
|
|
@@ -161,30 +160,29 @@ function requireAuthor(authorsById, authorId) {
|
|
|
161
160
|
* @property {string} bodyHtml the page's complete `<main>` inner HTML,
|
|
162
161
|
* starting with exactly one `<h1>`
|
|
163
162
|
* @property {string} [breadcrumbHtml] an optional already-rendered breadcrumb
|
|
164
|
-
* @property {string} [description]
|
|
163
|
+
* @property {string} [description] an optional plain-text
|
|
165
164
|
* description this page's SEO/Open-Graph/Twitter `<meta>` tags are built
|
|
166
165
|
* from (never HTML, never the sanitized body — a distinct authored or
|
|
167
166
|
* publication-level plain-text field)
|
|
168
|
-
* @property {'website' | 'article' | 'profile'} [ogType]
|
|
167
|
+
* @property {'website' | 'article' | 'profile'} [ogType]
|
|
169
168
|
* the Open Graph `og:type` this page kind maps to; defaults to `'website'`
|
|
170
169
|
* when omitted
|
|
171
170
|
* @property {{path: string, sourceDigest: string} | undefined} [socialImageRef]
|
|
172
|
-
*
|
|
171
|
+
* an optional `resolvedFile` reference this page's
|
|
173
172
|
* social image is resolved from (the caller, `src/core/index.js`, is the
|
|
174
173
|
* one place that can turn this into an absolute derivative URL, because
|
|
175
|
-
* only it has run the
|
|
174
|
+
* only it has run the media pipeline and knows the derivative
|
|
176
175
|
* output path a given `sourceDigest` produced)
|
|
177
|
-
* @property {string} [robotsContent]
|
|
176
|
+
* @property {string} [robotsContent] an optional
|
|
178
177
|
* `<meta name="robots">` content value (e.g. `'noindex, follow'` for
|
|
179
178
|
* `status: "unlisted"` content); omitted entirely for ordinarily indexable
|
|
180
179
|
* pages, matching this renderer's "documented, not silently assumed" style
|
|
181
180
|
* elsewhere — every page kind's robots decision is made once, here, rather
|
|
182
181
|
* than re-derived by every later consumer (sitemap, search index)
|
|
183
|
-
* @property {string} [lastModified]
|
|
182
|
+
* @property {string} [lastModified] an optional
|
|
184
183
|
* `rfc3339` timestamp the generated sitemap's `<lastmod>` for this page is
|
|
185
184
|
* drawn from — always derived from authored content timestamps
|
|
186
|
-
* (`publishedAt`/`updatedAt`), never build time
|
|
187
|
-
* "sitemap ... `lastmod` from content timestamps not build time"). Omitted
|
|
185
|
+
* (`publishedAt`/`updatedAt`), never build time. Omitted
|
|
188
186
|
* for a page kind with no natural underlying timestamp (`profile`,
|
|
189
187
|
* `author`), which the generated sitemap then emits with no `<lastmod>`
|
|
190
188
|
* element at all (a sitemap's own `<lastmod>` is optional per the Sitemaps
|
|
@@ -194,7 +192,7 @@ function requireAuthor(authorsById, authorId) {
|
|
|
194
192
|
/**
|
|
195
193
|
* The reverse-chronological, deterministically tie-broken set of every
|
|
196
194
|
* `status: "published"`, `kind: "article"` record — the exact selection and
|
|
197
|
-
* ordering the `index` page kind paginates, and
|
|
195
|
+
* ordering the `index` page kind paginates, and the
|
|
198
196
|
* same selection the generated feeds draw from, so a feed's item order can
|
|
199
197
|
* never drift from the index listing's own order. Extracted to its own
|
|
200
198
|
* export rather than duplicated, since `internal/fs-walk.js`'s duplication
|
|
@@ -224,7 +222,7 @@ export function selectPublishedArticles(content) {
|
|
|
224
222
|
/**
|
|
225
223
|
* The most recent of a content record's own `updatedAt` (when authored) or
|
|
226
224
|
* `publishedAt` — this renderer's one definition of "a content record's own
|
|
227
|
-
* last-modified instant" (
|
|
225
|
+
* last-modified instant" (the sitemap's `<lastmod>` and the
|
|
228
226
|
* static search index both read this, rather than each re-deriving it).
|
|
229
227
|
*
|
|
230
228
|
* @param {import('../../../types/index.d.ts').ContentFrontmatterNormalized} frontmatter
|
|
@@ -237,7 +235,7 @@ export function contentLastModified(frontmatter) {
|
|
|
237
235
|
/**
|
|
238
236
|
* The latest {@link contentLastModified} across a non-empty set of records —
|
|
239
237
|
* this renderer's definition of "a generated listing page's own
|
|
240
|
-
* `<lastmod>`"
|
|
238
|
+
* `<lastmod>`": the most recent authored timestamp
|
|
241
239
|
* among the content actually shown on that page, never build time.
|
|
242
240
|
*
|
|
243
241
|
* @param {readonly import('../../../types/index.d.ts').ContentBuildRecord[]} records
|
|
@@ -266,7 +264,8 @@ function latestContentTimestamp(records) {
|
|
|
266
264
|
* order
|
|
267
265
|
*/
|
|
268
266
|
export function buildGeneratedPages(validatedInput) {
|
|
269
|
-
const { publication, authors, content, basePath } = validatedInput;
|
|
267
|
+
const { publication, authors, content, basePath, baseUrl } = validatedInput;
|
|
268
|
+
const publicBasePath = derivePublicBasePath(baseUrl, basePath);
|
|
270
269
|
const messages = getMessages(publication.defaultLanguage);
|
|
271
270
|
const authorsById = new Map(authors.map((author) => [author.id, author]));
|
|
272
271
|
|
|
@@ -274,7 +273,7 @@ export function buildGeneratedPages(validatedInput) {
|
|
|
274
273
|
* @param {string} route an un-joined route
|
|
275
274
|
* @returns {string} the `basePath`-joined absolute route
|
|
276
275
|
*/
|
|
277
|
-
const site = (route) =>
|
|
276
|
+
const site = (route) => joinPublicRoute(publicBasePath, route);
|
|
278
277
|
|
|
279
278
|
/**
|
|
280
279
|
* @param {import('../../../types/index.d.ts').ContentBuildRecord} record
|
|
@@ -682,10 +681,10 @@ export function buildGeneratedPages(validatedInput) {
|
|
|
682
681
|
}
|
|
683
682
|
|
|
684
683
|
/**
|
|
685
|
-
* Render the `error` page kind's `<main>` body
|
|
684
|
+
* Render the `error` page kind's `<main>` body. This is
|
|
686
685
|
* a pure function with no route/build-input dependency, exported for a
|
|
687
|
-
*
|
|
688
|
-
*
|
|
686
|
+
* caller elsewhere (the generated `404.html`, assembled outside this module)
|
|
687
|
+
* and for this repository's own structural/golden tests.
|
|
689
688
|
*
|
|
690
689
|
* @param {object} options rendering options
|
|
691
690
|
* @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} options.messages
|