@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.
- package/LICENSE +201 -0
- package/README.md +515 -0
- package/contracts/render-policy.jcs +1 -0
- package/contracts/theme-styling-contract.jcs +1 -0
- package/package.json +96 -0
- package/src/core/errors.js +136 -0
- package/src/core/index.js +758 -0
- package/src/core/internal/appearance/bootstrap-script.js +159 -0
- package/src/core/internal/appearance/contract.js +193 -0
- package/src/core/internal/appearance/controller-markup.js +80 -0
- package/src/core/internal/appearance/styling-contract.js +509 -0
- 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 +220 -0
- package/src/core/internal/feeds.js +161 -0
- package/src/core/internal/fs-walk.js +38 -0
- package/src/core/internal/media/avif-probe.js +169 -0
- package/src/core/internal/media/font-policy.js +56 -0
- package/src/core/internal/media/jpeg-codec.js +0 -0
- package/src/core/internal/media/limits.js +129 -0
- package/src/core/internal/media/pipeline.js +353 -0
- package/src/core/internal/media/png-codec.js +406 -0
- package/src/core/internal/media/resize.js +147 -0
- package/src/core/internal/media/sniff.js +109 -0
- package/src/core/internal/media/webp-probe.js +139 -0
- package/src/core/internal/messages.js +78 -0
- package/src/core/internal/page-kinds.js +709 -0
- package/src/core/internal/provenance.js +49 -0
- package/src/core/internal/render-policy-content.js +295 -0
- package/src/core/internal/route-labels.js +68 -0
- package/src/core/internal/route.js +167 -0
- package/src/core/internal/search-index.js +119 -0
- package/src/core/internal/seo.js +114 -0
- package/src/core/internal/sitemap.js +77 -0
- package/src/core/internal/skeleton.js +413 -0
- package/src/core/internal/source-inventory.js +145 -0
- package/src/core/internal/text-direction.js +59 -0
- package/src/core/internal/theme-assets.js +387 -0
- package/src/core/internal/uuid.js +51 -0
- package/src/core/manifest.js +0 -0
- package/types/eleventy-plugin-syntaxhighlight.d.ts +26 -0
- package/types/eleventy.d.ts +38 -0
- 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
|
+
}
|