@rathnasgala2/template 2.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.
Files changed (43) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +515 -0
  3. package/contracts/render-policy.jcs +1 -0
  4. package/contracts/theme-styling-contract.jcs +1 -0
  5. package/package.json +96 -0
  6. package/src/core/errors.js +136 -0
  7. package/src/core/index.js +758 -0
  8. package/src/core/internal/appearance/bootstrap-script.js +159 -0
  9. package/src/core/internal/appearance/contract.js +193 -0
  10. package/src/core/internal/appearance/controller-markup.js +80 -0
  11. package/src/core/internal/appearance/styling-contract.js +509 -0
  12. package/src/core/internal/canonical-jcs.js +0 -0
  13. package/src/core/internal/content-security.js +0 -0
  14. package/src/core/internal/eleventy-render.js +220 -0
  15. package/src/core/internal/feeds.js +161 -0
  16. package/src/core/internal/fs-walk.js +38 -0
  17. package/src/core/internal/media/avif-probe.js +169 -0
  18. package/src/core/internal/media/font-policy.js +56 -0
  19. package/src/core/internal/media/jpeg-codec.js +0 -0
  20. package/src/core/internal/media/limits.js +129 -0
  21. package/src/core/internal/media/pipeline.js +353 -0
  22. package/src/core/internal/media/png-codec.js +406 -0
  23. package/src/core/internal/media/resize.js +147 -0
  24. package/src/core/internal/media/sniff.js +109 -0
  25. package/src/core/internal/media/webp-probe.js +139 -0
  26. package/src/core/internal/messages.js +78 -0
  27. package/src/core/internal/page-kinds.js +709 -0
  28. package/src/core/internal/provenance.js +49 -0
  29. package/src/core/internal/render-policy-content.js +295 -0
  30. package/src/core/internal/route-labels.js +68 -0
  31. package/src/core/internal/route.js +167 -0
  32. package/src/core/internal/search-index.js +119 -0
  33. package/src/core/internal/seo.js +114 -0
  34. package/src/core/internal/sitemap.js +77 -0
  35. package/src/core/internal/skeleton.js +413 -0
  36. package/src/core/internal/source-inventory.js +145 -0
  37. package/src/core/internal/text-direction.js +59 -0
  38. package/src/core/internal/theme-assets.js +387 -0
  39. package/src/core/internal/uuid.js +51 -0
  40. package/src/core/manifest.js +0 -0
  41. package/types/eleventy-plugin-syntaxhighlight.d.ts +26 -0
  42. package/types/eleventy.d.ts +38 -0
  43. package/types/index.d.ts +347 -0
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Collect the distinct source records this renderer actually consumed from
3
+ * a validated `build-input:2.0.0` instance, as
4
+ * `manifestIncludedSource[]` rows (DEC-097 section 6: "`includedSources` has
5
+ * set equality with the distinct source records placed in the
6
+ * verified-input carrier ... a file used by multiple records appears once
7
+ * and the role is the earliest value in the enum order").
8
+ */
9
+
10
+ /** @type {readonly string[]} */
11
+ const ROLE_PRIORITY = Object.freeze([
12
+ 'publication',
13
+ 'author',
14
+ 'content',
15
+ 'navigation',
16
+ 'appearance',
17
+ 'asset',
18
+ ]);
19
+
20
+ /**
21
+ * @param {string} role a `manifestIncludedSource.role` value
22
+ * @returns {number} its position in the enum priority order
23
+ */
24
+ function rolePriority(role) {
25
+ const index = ROLE_PRIORITY.indexOf(role);
26
+ return index === -1 ? ROLE_PRIORITY.length : index;
27
+ }
28
+
29
+ /**
30
+ * Collect every distinct `{path, sha256, sourceRevision, role}` row this
31
+ * renderer read out of `buildInput`, deduplicated by path with the
32
+ * earliest-enum role kept on collision.
33
+ *
34
+ * @param {import('../../../types/index.d.ts').NormalizedBuildInput} buildInput
35
+ * the validated build input
36
+ * @returns {import('../../../types/index.d.ts').ManifestIncludedSourceEntry[]}
37
+ * the unique-by-path row set, sorted by UTF-8 path bytes
38
+ */
39
+ export function collectIncludedSources(buildInput) {
40
+ /** @type {Map<string, import('../../../types/index.d.ts').ManifestIncludedSourceEntry>} */
41
+ const byPath = new Map();
42
+
43
+ /**
44
+ * @param {string | undefined} path repository-relative path
45
+ * @param {string | undefined} sha256 tagged digest
46
+ * @param {import('../../../types/index.d.ts').ManifestIncludedSourceEntry['role']} role
47
+ * manifestIncludedSource role
48
+ * @param {string} sourceRevision the record's own source revision, when
49
+ * known; falls back to the root `sourceRevision`
50
+ * @returns {void}
51
+ */
52
+ const record = (path, sha256, role, sourceRevision) => {
53
+ if (!path || !sha256) return;
54
+ const existing = byPath.get(path);
55
+ if (existing && rolePriority(existing.role) <= rolePriority(role)) return;
56
+ byPath.set(path, { path, sha256, sourceRevision, role });
57
+ };
58
+
59
+ const rootRevision = buildInput.sourceRevision;
60
+
61
+ record(
62
+ buildInput.publication.sourcePath,
63
+ buildInput.publication.sourceDigest,
64
+ 'publication',
65
+ rootRevision,
66
+ );
67
+ recordResolvedFile(record, buildInput.publication.defaultImage, rootRevision);
68
+
69
+ for (const author of buildInput.authors) {
70
+ record(author.sourcePath, author.sourceDigest, 'author', rootRevision);
71
+ recordResolvedFile(record, author.avatar, rootRevision);
72
+ }
73
+
74
+ for (const contentRecord of buildInput.content) {
75
+ record(
76
+ contentRecord.sourcePath,
77
+ contentRecord.sourceDigest,
78
+ 'content',
79
+ contentRecord.sourceRevision,
80
+ );
81
+ if (contentRecord.frontmatter.hero) {
82
+ recordResolvedFile(
83
+ record,
84
+ contentRecord.frontmatter.hero.file,
85
+ contentRecord.sourceRevision,
86
+ );
87
+ }
88
+ recordResolvedFile(
89
+ record,
90
+ contentRecord.frontmatter.socialImage,
91
+ contentRecord.sourceRevision,
92
+ );
93
+ }
94
+
95
+ if (buildInput.navigation.source.kind === 'authored') {
96
+ record(
97
+ buildInput.navigation.source.sourcePath,
98
+ buildInput.navigation.source.sourceDigest,
99
+ 'navigation',
100
+ rootRevision,
101
+ );
102
+ }
103
+
104
+ if (buildInput.appearance.source.kind === 'authored') {
105
+ record(
106
+ buildInput.appearance.source.sourcePath,
107
+ buildInput.appearance.source.sourceDigest,
108
+ 'appearance',
109
+ rootRevision,
110
+ );
111
+ }
112
+ recordResolvedFile(record, buildInput.appearance.brandMark, rootRevision);
113
+ recordResolvedFile(record, buildInput.appearance.wordmark, rootRevision);
114
+ for (const fontAsset of buildInput.appearance.fontAssets ?? []) {
115
+ recordResolvedFile(record, fontAsset, rootRevision);
116
+ }
117
+
118
+ return [...byPath.values()].sort((a, b) => compareUtf8Bytes(a.path, b.path));
119
+ }
120
+
121
+ /**
122
+ * @param {(path: string | undefined, sha256: string | undefined, role: import('../../../types/index.d.ts').ManifestIncludedSourceEntry['role'], sourceRevision: string) => void} record
123
+ * the accumulator closure from {@link collectIncludedSources}
124
+ * @param {{path?: string, sourceDigest?: string} | undefined} resolvedFile
125
+ * a `resolvedFile`-shaped reference, if present
126
+ * @param {string} sourceRevision the owning record's source revision
127
+ * @returns {void}
128
+ */
129
+ function recordResolvedFile(record, resolvedFile, sourceRevision) {
130
+ if (!resolvedFile) return;
131
+ record(resolvedFile.path, resolvedFile.sourceDigest, 'asset', sourceRevision);
132
+ }
133
+
134
+ /**
135
+ * Compare two strings by their UTF-8 byte sequence, ascending.
136
+ *
137
+ * @param {string} a first path
138
+ * @param {string} b second path
139
+ * @returns {number} negative, zero or positive per `Array#sort` contract
140
+ */
141
+ export function compareUtf8Bytes(a, b) {
142
+ const bytesA = Buffer.from(a, 'utf8');
143
+ const bytesB = Buffer.from(b, 'utf8');
144
+ return Buffer.compare(bytesA, bytesB);
145
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Deterministic base-direction resolution from a BCP-47 language tag.
3
+ *
4
+ * `build-input:2.0.0` carries a validated `bcp47` language tag on the
5
+ * publication (`defaultLanguage`) and on every content record
6
+ * (`frontmatter.language`) but no separate `dir`/direction field (checked
7
+ * against `node_modules/@rathnasgala2/schemas/schemas/build-input.schema.json`:
8
+ * no `$defs` entry or property named `dir`/`direction` exists anywhere in the
9
+ * schema). The semantic skeleton must still set an accurate `dir` attribute
10
+ * per brief S2 section 3 ("localized content with mixed direction" is part of
11
+ * acceptance test A's golden fixture), so this module derives it from the
12
+ * tag's primary language subtag against the closed set of right-to-left
13
+ * script languages in Unicode CLDR's exemplar set — the same approach HTML's
14
+ * own `Intl.Locale` `textInfo.direction` uses, reimplemented here as a small
15
+ * static table so this renderer adds no new runtime dependency and stays
16
+ * fully deterministic (no ICU data version drift across Node builds).
17
+ *
18
+ * This is a deliberately scoped decision, not a full BCP-47 script/region
19
+ * override resolver (a tag can in principle override direction with an
20
+ * explicit script subtag, e.g. `az-Arab` vs `az-Latn`); the closed language
21
+ * subtag list below is sufficient for every right-to-left language in the S2
22
+ * fixture corpus and is documented here for the same reason `route.js` scopes
23
+ * its own route-projection engine.
24
+ */
25
+
26
+ /**
27
+ * Primary BCP-47 language subtags (lowercase) whose default script is
28
+ * written right-to-left.
29
+ *
30
+ * @type {ReadonlySet<string>}
31
+ */
32
+ const RTL_PRIMARY_LANGUAGE_SUBTAGS = new Set([
33
+ 'ar', // Arabic
34
+ 'arc', // Aramaic
35
+ 'dv', // Divehi
36
+ 'fa', // Persian
37
+ 'ha', // Hausa (Arabic script usage)
38
+ 'he', // Hebrew
39
+ 'khw', // Khowar
40
+ 'ks', // Kashmiri
41
+ 'ku', // Kurdish (Sorani, Arabic script usage)
42
+ 'ps', // Pashto
43
+ 'sd', // Sindhi
44
+ 'ug', // Uyghur
45
+ 'ur', // Urdu
46
+ 'yi', // Yiddish
47
+ ]);
48
+
49
+ /**
50
+ * Resolve the base text direction for a validated `bcp47` language tag.
51
+ *
52
+ * @param {string} bcp47 a schema-validated BCP-47 language tag (structurally
53
+ * already `^[A-Za-z0-9-]+$`)
54
+ * @returns {'ltr' | 'rtl'} the resolved base direction
55
+ */
56
+ export function resolveTextDirection(bcp47) {
57
+ const primarySubtag = bcp47.split('-')[0]?.toLowerCase() ?? '';
58
+ return RTL_PRIMARY_LANGUAGE_SUBTAGS.has(primarySubtag) ? 'rtl' : 'ltr';
59
+ }
@@ -0,0 +1,387 @@
1
+ /**
2
+ * Theme package asset integration (task packet S2-T12): copy the selected
3
+ * theme package's stylesheets (and any declared passive assets) into the
4
+ * candidate output directory's `<basePath>/assets/theme/`, produce their
5
+ * `manifestAsset` rows, and render the ordered `<link>` elements every
6
+ * generated page's `<head>` inserts, in `cssLayers` order.
7
+ *
8
+ * Scope note (documented, not silently assumed, same style as
9
+ * `internal/print-stylesheet.js`'s own module documentation): this module
10
+ * trusts the theme package's own `theme.json` file-set/`cssLayers`/
11
+ * `stylesheets` declarations to the extent needed to *consume* them —
12
+ * closed stylesheet-list shape, `cssLayers` projection matching the
13
+ * published `contracts/theme-styling-contract.jcs`'s `orderedLayers`, and
14
+ * the declared files actually existing. It does **not** re-implement
15
+ * DEC-097's full CSS Syntax Module admission grammar (selector/property/
16
+ * at-rule closed catalogs, byte/token/rule ceilings, etc.) — that is the
17
+ * shared `theme-release.yml` conformance runner's job (S2-T11), run once
18
+ * per theme package release, not per publication build. Authoring real
19
+ * theme CSS is S2-T13's task, forbidden here; this module only moves bytes
20
+ * an already-conformant theme package supplies.
21
+ *
22
+ * The theme package directory this module reads from (`options.themeDirectory`
23
+ * on `renderPublication`) is optional: a caller that does not yet resolve a
24
+ * theme package (e.g. most of this repository's own S2-T02 through S2-T11
25
+ * tests) gets the S2-T08 default — a `<link>` at the fixed conventional
26
+ * `assets/theme/print.css` path, matching `internal/print-stylesheet.js`'s
27
+ * pre-existing convention — so this task's addition is purely additive.
28
+ *
29
+ * Path-containment hardening (independent-review finding B1): every
30
+ * declared `theme.json` path this module reads — a `stylesheets` entry or a
31
+ * non-CSS `assets[].path` entry — is untrusted input from the selected
32
+ * theme package, not a fixed literal. `assertSafeThemeRelativePath` rejects
33
+ * anything that is not a normalized (NFC), forward-slash, `/`-free,
34
+ * `..`-free, backslash-free, non-absolute relative path with no empty or
35
+ * dot segment, then walks every path component under `themeDirectory` with
36
+ * `lstat` and rejects a symlink at any component (so a symlink planted
37
+ * inside the theme directory cannot be used to escape it), then verifies
38
+ * the fully resolved path still starts inside the resolved `themeDirectory`.
39
+ * Only paths this module itself reads off `theme.json`'s `stylesheets`/
40
+ * `assets` arrays are ever opened or copied — a file physically present in
41
+ * `themeDirectory` but never declared there is never enumerated, so it can
42
+ * never be copied into the candidate output directory.
43
+ *
44
+ * basePath fix (independent-review finding B2, LOCAL-7 follow-up): every
45
+ * file this module writes, and every `manifestAsset.path` it produces, is
46
+ * now `basePath`-joined through `internal/route.js`'s
47
+ * `projectFixedAssetPath` — the exact same join `internal/route.js` already
48
+ * uses for the generated `404.html`/feeds/sitemap/search-index paths — so
49
+ * the physical file the local-directory adapter serves at
50
+ * `<basePath>/assets/theme/<name>` matches the `<link href>` this module
51
+ * also emits. `<link href>` and the physical/manifest path are derived from
52
+ * the exact same joined value, so they cannot drift apart.
53
+ */
54
+
55
+ import { lstat, readFile } from 'node:fs/promises';
56
+ import path from 'node:path';
57
+
58
+ import { ThemeAssetError } from '../errors.js';
59
+ import { digestBytes } from './canonical-jcs.js';
60
+ import { ORDERED_LAYERS } from './appearance/styling-contract.js';
61
+ import { projectFixedAssetPath } from './route.js';
62
+ import { escapeHtml } from './skeleton.js';
63
+
64
+ /** @type {string} the fixed, non-content-addressed output directory every
65
+ * theme asset (stylesheet or passive file) is copied under, relative to
66
+ * `basePath`. */
67
+ export const THEME_ASSET_DIRECTORY = 'assets/theme';
68
+
69
+ /** @type {readonly string[]} the stylesheet-list shape without `utilities.css`. */
70
+ const STYLESHEETS_WITHOUT_UTILITIES = Object.freeze([
71
+ 'tokens.css',
72
+ 'components.css',
73
+ 'print.css',
74
+ ]);
75
+
76
+ /** @type {readonly string[]} the stylesheet-list shape with `utilities.css`. */
77
+ const STYLESHEETS_WITH_UTILITIES = Object.freeze([
78
+ 'tokens.css',
79
+ 'components.css',
80
+ 'utilities.css',
81
+ 'print.css',
82
+ ]);
83
+
84
+ /** @type {Readonly<Record<string, string>>} theme stylesheet filename to its
85
+ * fixed `@layer` name (DEC-097 section 4). */
86
+ const LAYER_BY_STYLESHEET = Object.freeze({
87
+ 'tokens.css': 'gala-tokens',
88
+ 'components.css': 'gala-components',
89
+ 'utilities.css': 'gala-utilities',
90
+ 'print.css': 'gala-print',
91
+ });
92
+
93
+ /**
94
+ * @param {readonly unknown[]} candidate
95
+ * @returns {void}
96
+ */
97
+ function assertStylesheetListShape(candidate) {
98
+ const withoutUtilities =
99
+ JSON.stringify(candidate) === JSON.stringify(STYLESHEETS_WITHOUT_UTILITIES);
100
+ const withUtilities =
101
+ JSON.stringify(candidate) === JSON.stringify(STYLESHEETS_WITH_UTILITIES);
102
+ if (!withoutUtilities && !withUtilities) {
103
+ throw new ThemeAssetError(
104
+ 'THEME_STYLESHEETS_INVALID',
105
+ 'theme.json stylesheets must be exactly ["tokens.css","components.css","print.css"] ' +
106
+ 'or the same list with "utilities.css" inserted before "print.css"',
107
+ );
108
+ }
109
+ }
110
+
111
+ /**
112
+ * @param {readonly unknown[]} stylesheets the already-shape-validated
113
+ * stylesheet list
114
+ * @param {unknown} cssLayers the candidate `theme.json.cssLayers`
115
+ * @returns {void}
116
+ */
117
+ function assertCssLayersProjection(stylesheets, cssLayers) {
118
+ const expected = /** @type {readonly string[]} */ (stylesheets).map(
119
+ (filename) => LAYER_BY_STYLESHEET[/** @type {string} */ (filename)],
120
+ );
121
+ if (JSON.stringify(cssLayers) !== JSON.stringify(expected)) {
122
+ throw new ThemeAssetError(
123
+ 'THEME_CSS_LAYERS_INVALID',
124
+ 'theme.json cssLayers must byte-equal the exact ordered layer ' +
125
+ 'projection of its own stylesheets list',
126
+ );
127
+ }
128
+ if (
129
+ !expected.every(
130
+ (layer, index) =>
131
+ layer === ORDERED_LAYERS[index] ||
132
+ (layer === 'gala-print' && index === expected.length - 1),
133
+ )
134
+ ) {
135
+ throw new ThemeAssetError(
136
+ 'THEME_CSS_LAYERS_INVALID',
137
+ 'theme.json cssLayers must be a subsequence of the published ' +
138
+ 'templateStylingContract.orderedLayers, in that exact relative order',
139
+ );
140
+ }
141
+ }
142
+
143
+ /** @type {string} the reason code every path-containment rejection below
144
+ * carries. */
145
+ const PATH_CONTAINMENT_CODE = 'THEME_ASSET_PATH_UNSAFE';
146
+
147
+ /**
148
+ * Reject anything that is not a normalized (NFC), forward-slash-only,
149
+ * absolute-free, `..`-free relative path with no empty or dot segment.
150
+ * Purely syntactic — {@link assertContainedThemePath} does the filesystem
151
+ * containment/symlink walk.
152
+ *
153
+ * @param {unknown} candidate a declared `theme.json` path
154
+ * @returns {string} the same value, once it is known to be a syntactically
155
+ * safe relative path
156
+ */
157
+ function assertSafeThemeRelativePath(candidate) {
158
+ if (typeof candidate !== 'string' || candidate.length === 0) {
159
+ throw new ThemeAssetError(
160
+ PATH_CONTAINMENT_CODE,
161
+ `declared path must be a non-empty string (got ${JSON.stringify(candidate)})`,
162
+ );
163
+ }
164
+ if (candidate.normalize('NFC') !== candidate) {
165
+ throw new ThemeAssetError(
166
+ PATH_CONTAINMENT_CODE,
167
+ `declared path must be NFC-normalized: ${candidate}`,
168
+ );
169
+ }
170
+ if (candidate.includes('\\')) {
171
+ throw new ThemeAssetError(
172
+ PATH_CONTAINMENT_CODE,
173
+ `declared path must not contain a backslash: ${candidate}`,
174
+ );
175
+ }
176
+ if (candidate.startsWith('/') || /^[a-zA-Z]:/.test(candidate)) {
177
+ throw new ThemeAssetError(
178
+ PATH_CONTAINMENT_CODE,
179
+ `declared path must be relative, not absolute: ${candidate}`,
180
+ );
181
+ }
182
+ const segments = candidate.split('/');
183
+ for (const segment of segments) {
184
+ if (segment.length === 0 || segment === '.' || segment === '..') {
185
+ throw new ThemeAssetError(
186
+ PATH_CONTAINMENT_CODE,
187
+ `declared path has an empty, "." or ".." segment: ${candidate}`,
188
+ );
189
+ }
190
+ }
191
+ if (path.posix.normalize(candidate) !== candidate) {
192
+ throw new ThemeAssetError(
193
+ PATH_CONTAINMENT_CODE,
194
+ `declared path is not already in normalized form: ${candidate}`,
195
+ );
196
+ }
197
+ return candidate;
198
+ }
199
+
200
+ /**
201
+ * Walk every path component of an already syntactically-safe relative path
202
+ * under `themeDirectory`, rejecting a symlink at any component, then verify
203
+ * the fully resolved path still starts inside the resolved
204
+ * `themeDirectory` (belt-and-suspenders on top of the syntactic check
205
+ * above).
206
+ *
207
+ * @param {string} themeDirectory the absolute, caller-mounted theme package
208
+ * directory
209
+ * @param {string} relativePath an already syntactically-safe relative path
210
+ * (see {@link assertSafeThemeRelativePath})
211
+ * @returns {Promise<string>} the absolute, verified-contained path
212
+ */
213
+ async function assertContainedThemePath(themeDirectory, relativePath) {
214
+ const resolvedRoot = path.resolve(themeDirectory);
215
+ let current = resolvedRoot;
216
+ for (const segment of relativePath.split('/')) {
217
+ current = path.join(current, segment);
218
+ let stats;
219
+ try {
220
+ stats = await lstat(current);
221
+ } catch {
222
+ throw new ThemeAssetError(
223
+ PATH_CONTAINMENT_CODE,
224
+ `declared theme file does not exist: ${relativePath}`,
225
+ );
226
+ }
227
+ if (stats.isSymbolicLink()) {
228
+ throw new ThemeAssetError(
229
+ PATH_CONTAINMENT_CODE,
230
+ `declared theme file path contains a symlink component: ${relativePath}`,
231
+ );
232
+ }
233
+ }
234
+ const resolved = path.resolve(current);
235
+ if (
236
+ resolved !== resolvedRoot &&
237
+ !resolved.startsWith(resolvedRoot + path.sep)
238
+ ) {
239
+ throw new ThemeAssetError(
240
+ PATH_CONTAINMENT_CODE,
241
+ `declared theme file resolves outside the theme directory: ${relativePath}`,
242
+ );
243
+ }
244
+ return resolved;
245
+ }
246
+
247
+ /**
248
+ * Validate and read one declared theme-package-relative file, failing
249
+ * closed on any traversal, absolute-path, backslash, non-NFC, dot-segment,
250
+ * or symlink-escape attempt.
251
+ *
252
+ * @param {string} themeDirectory the absolute theme package directory
253
+ * @param {unknown} candidatePath the declared `theme.json` path
254
+ * @returns {Promise<{relativePath: string, bytes: Buffer}>} the validated
255
+ * relative path and the file's bytes
256
+ */
257
+ async function readDeclaredThemeFile(themeDirectory, candidatePath) {
258
+ const relativePath = assertSafeThemeRelativePath(candidatePath);
259
+ const absolutePath = await assertContainedThemePath(
260
+ themeDirectory,
261
+ relativePath,
262
+ );
263
+ const bytes = await readFile(absolutePath);
264
+ return { relativePath, bytes };
265
+ }
266
+
267
+ /**
268
+ * The `basePath`-joined output-relative path (no leading slash, safe to
269
+ * both `path.join(outputDirectory, ...)` and a `manifestAsset.path`) for
270
+ * one theme-owned file. `href` is always derived from this exact same
271
+ * value, never computed independently, so the emitted `<link>` and the
272
+ * physical/manifest path can never drift apart.
273
+ *
274
+ * @param {string} basePath the build input's `basePath`
275
+ * @param {string} themeRelativePath the file's path relative to
276
+ * `THEME_ASSET_DIRECTORY` (e.g. `"tokens.css"` or `"assets/mark.svg"`)
277
+ * @returns {string} the joined output-relative path
278
+ */
279
+ function joinedThemeOutputPath(basePath, themeRelativePath) {
280
+ return projectFixedAssetPath(
281
+ basePath,
282
+ `/${THEME_ASSET_DIRECTORY}/${themeRelativePath}`,
283
+ );
284
+ }
285
+
286
+ /**
287
+ * Load and validate a selected theme package's `theme.json`, copy its
288
+ * stylesheets (and any declared non-CSS passive assets) into
289
+ * `<basePath>/assets/theme/`, and build the ordered `<link>` HTML fragment
290
+ * every generated page's `<head>` inserts.
291
+ *
292
+ * @param {object} options load options
293
+ * @param {string} options.themeDirectory an absolute, caller-mounted,
294
+ * read-only directory holding one extracted theme package (the exact
295
+ * file set `theme.json.stylesheets`/`assets` and `package.json.files`
296
+ * name)
297
+ * @param {string} options.basePath the build input's `basePath`
298
+ * @returns {Promise<{
299
+ * files: {path: string, bytes: Buffer}[],
300
+ * assets: import('../../../types/index.d.ts').ManifestAssetEntry[],
301
+ * linkTagsHtml: string,
302
+ * }>} the files to write (already `basePath`-joined), their manifest rows
303
+ * (same joined paths), and the ready-to-insert `<link>` markup
304
+ */
305
+ export async function loadThemeAssets({ themeDirectory, basePath }) {
306
+ const { bytes: themeJsonBytes } = await readDeclaredThemeFile(
307
+ themeDirectory,
308
+ 'theme.json',
309
+ );
310
+ /** @type {{stylesheets: unknown, cssLayers: unknown, assets?: {path: string, mediaType: string}[]}} */
311
+ const themeJson = JSON.parse(themeJsonBytes.toString('utf8'));
312
+
313
+ if (!Array.isArray(themeJson.stylesheets)) {
314
+ throw new ThemeAssetError(
315
+ 'THEME_STYLESHEETS_INVALID',
316
+ 'theme.json.stylesheets must be an array',
317
+ );
318
+ }
319
+ assertStylesheetListShape(themeJson.stylesheets);
320
+ assertCssLayersProjection(themeJson.stylesheets, themeJson.cssLayers);
321
+
322
+ /** @type {{path: string, bytes: Buffer}[]} */
323
+ const files = [];
324
+ /** @type {import('../../../types/index.d.ts').ManifestAssetEntry[]} */
325
+ const assets = [];
326
+ /** @type {string[]} */
327
+ const linkTags = [];
328
+
329
+ for (const filename of /** @type {string[]} */ (themeJson.stylesheets)) {
330
+ const { relativePath, bytes } = await readDeclaredThemeFile(
331
+ themeDirectory,
332
+ filename,
333
+ );
334
+ const outputPath = joinedThemeOutputPath(basePath, relativePath);
335
+ files.push({ path: outputPath, bytes });
336
+ assets.push({
337
+ path: outputPath,
338
+ mediaType: 'text/css; charset=utf-8',
339
+ byteLength: String(bytes.byteLength),
340
+ sha256: digestBytes(bytes),
341
+ immutable: false,
342
+ });
343
+ const mediaAttribute = filename === 'print.css' ? ' media="print"' : '';
344
+ linkTags.push(
345
+ `<link rel="stylesheet" href="${escapeHtml(`/${outputPath}`)}"${mediaAttribute}>`,
346
+ );
347
+ }
348
+
349
+ const passiveAssets = (themeJson.assets ?? []).filter(
350
+ (asset) => asset.mediaType !== 'text/css',
351
+ );
352
+ for (const asset of passiveAssets) {
353
+ const { relativePath, bytes } = await readDeclaredThemeFile(
354
+ themeDirectory,
355
+ asset.path,
356
+ );
357
+ const outputPath = joinedThemeOutputPath(basePath, relativePath);
358
+ files.push({ path: outputPath, bytes });
359
+ assets.push({
360
+ path: outputPath,
361
+ mediaType: asset.mediaType,
362
+ byteLength: String(bytes.byteLength),
363
+ sha256: digestBytes(bytes),
364
+ immutable: false,
365
+ });
366
+ }
367
+
368
+ return { files, assets, linkTagsHtml: linkTags.join('') };
369
+ }
370
+
371
+ /**
372
+ * The S2-T08 default theme stylesheet markup, used only when
373
+ * `renderPublication` is not given a resolved `options.themeDirectory`
374
+ * (this repository's own tests, and any caller that has not yet resolved a
375
+ * theme package): one `media="print"` `<link>` at the fixed conventional
376
+ * `<basePath>/assets/theme/print.css` path, matching
377
+ * `internal/print-stylesheet.js`'s pre-existing convention exactly. No
378
+ * file is written for this fallback path — see the module's own "purely
379
+ * additive" documentation above.
380
+ *
381
+ * @param {string} basePath the build input's `basePath`
382
+ * @returns {string} the `<link>` HTML fragment
383
+ */
384
+ export function defaultThemeStylesheetLinksHtml(basePath) {
385
+ const outputPath = joinedThemeOutputPath(basePath, 'print.css');
386
+ return `<link rel="stylesheet" href="${escapeHtml(`/${outputPath}`)}" media="print">`;
387
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * A minimal RFC 9562 UUIDv7 generator for `artifactId`. DEC-097's
3
+ * `stableId` pattern requires the version nibble `7` and an `8`/`9`/`a`/`b`
4
+ * variant nibble; `node:crypto`'s `randomUUID()` only produces v4. The
5
+ * decision text is explicit that "equal artifact bytes may have distinct
6
+ * artifact IDs" (DEC-097 section 6), so this value is intentionally not
7
+ * required to be deterministic across two otherwise byte-identical builds.
8
+ */
9
+
10
+ import { randomBytes } from 'node:crypto';
11
+
12
+ /**
13
+ * Generate one lowercase canonical UUIDv7 string.
14
+ *
15
+ * @returns {string} a UUIDv7 matching `stableId`'s pattern
16
+ */
17
+ export function generateUuidV7() {
18
+ const timestampMs = BigInt(Date.now());
19
+ const random = randomBytes(10);
20
+
21
+ const bytes = new Uint8Array(16);
22
+ bytes[0] = Number((timestampMs >> 40n) & 0xffn);
23
+ bytes[1] = Number((timestampMs >> 32n) & 0xffn);
24
+ bytes[2] = Number((timestampMs >> 24n) & 0xffn);
25
+ bytes[3] = Number((timestampMs >> 16n) & 0xffn);
26
+ bytes[4] = Number((timestampMs >> 8n) & 0xffn);
27
+ bytes[5] = Number(timestampMs & 0xffn);
28
+ // Version nibble 7 in the high nibble of byte 6.
29
+ bytes[6] = 0x70 | (random[0] & 0x0f);
30
+ bytes[7] = random[1];
31
+ // Variant bits 10 in the top two bits of byte 8.
32
+ bytes[8] = 0x80 | (random[2] & 0x3f);
33
+ bytes[9] = random[3];
34
+ bytes[10] = random[4];
35
+ bytes[11] = random[5];
36
+ bytes[12] = random[6];
37
+ bytes[13] = random[7];
38
+ bytes[14] = random[8];
39
+ bytes[15] = random[9];
40
+
41
+ const hex = Array.from(bytes, (byte) =>
42
+ byte.toString(16).padStart(2, '0'),
43
+ ).join('');
44
+ return [
45
+ hex.slice(0, 8),
46
+ hex.slice(8, 12),
47
+ hex.slice(12, 16),
48
+ hex.slice(16, 20),
49
+ hex.slice(20, 32),
50
+ ].join('-');
51
+ }
Binary file
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Minimal ambient typing for the one internal helper
3
+ * `src/core/internal/content-security.js` calls directly:
4
+ * `@11ty/eleventy-plugin-syntaxhighlight`'s markdown-it `highlight` option
5
+ * factory. The package ships no published type declarations, publishes no
6
+ * `exports` map (so this deep import path is a plain, resolvable file), and
7
+ * this typing is deliberately narrow rather than a full re-authoring of its
8
+ * API.
9
+ */
10
+ declare module '@11ty/eleventy-plugin-syntaxhighlight/src/markdownSyntaxHighlightOptions.js' {
11
+ export interface MarkdownSyntaxHighlightOptions {
12
+ lineSeparator?: string;
13
+ alwaysWrapLineHighlights?: boolean;
14
+ preAttributes?: Record<string, unknown>;
15
+ codeAttributes?: Record<string, unknown>;
16
+ }
17
+
18
+ export type MarkdownItHighlightFn = (
19
+ code: string,
20
+ language: string,
21
+ ) => string;
22
+
23
+ export default function markdownSyntaxHighlightOptions(
24
+ options?: MarkdownSyntaxHighlightOptions,
25
+ ): MarkdownItHighlightFn;
26
+ }