@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,220 @@
1
+ /**
2
+ * The one module in this repository permitted to import `@11ty/eleventy`.
3
+ * Every other module reaches rendered output only through
4
+ * {@link renderPagesWithEleventy}'s plain-data return value: no Eleventy
5
+ * instance, plug-in API, config object or Nunjucks environment crosses back
6
+ * out of this module (brief S2 section 3: "No Eleventy object, plug-in API,
7
+ * config object or Nunjucks environment may cross the adapter boundary in
8
+ * either direction").
9
+ *
10
+ * Confinement enforced here:
11
+ * - `configPath: false` on the `Eleventy` constructor disables discovery of
12
+ * any `.eleventy.js`/`eleventy.config.*` file entirely (`TemplateConfig`
13
+ * treats an explicit falsy `configPath` as "skip config files" rather
14
+ * than searching `process.cwd()`), so nothing from an author repository
15
+ * or an ambient host directory is ever read as configuration.
16
+ * - The only filesystem paths this module touches are the caller-supplied
17
+ * `inputDirectory` and `outputDirectory`, both required to be absolute
18
+ * paths under the caller's declared work/output roots.
19
+ * - `eleventyConfig.setUseGitIgnore(false)` stops Eleventy from walking
20
+ * parent directories looking for a `.gitignore` to exclude files by.
21
+ * - Every page is supplied as an in-memory virtual template
22
+ * (`eleventyConfig.addTemplate`) with `templateEngineOverride: false`, so
23
+ * author-authority body content is written verbatim and is never
24
+ * interpreted as Nunjucks/Liquid template syntax.
25
+ * - `elev.write()` runs exactly one full build; this module never calls
26
+ * `.watch()` or constructs an incremental build, and never sets
27
+ * `runMode: "watch"` (brief S2 section 3: "Incremental and watch mode
28
+ * ... cannot produce a releasable artifact").
29
+ * - No plugin that performs network access is registered, and Eleventy's
30
+ * own default plugins (`HtmlBasePlugin`, `BundlePlugin`) perform no
31
+ * network access.
32
+ */
33
+
34
+ import Eleventy from '@11ty/eleventy';
35
+
36
+ import {
37
+ APPEARANCE_ROOT_ATTRIBUTE,
38
+ COLOR_SCHEME_META_CONTENT,
39
+ } from './appearance/contract.js';
40
+ import { contentSecurityPolicyMetaTag } from './content-security.js';
41
+ import { PAGE_KIND_ATTRIBUTE } from './page-kinds.js';
42
+
43
+ /**
44
+ * @typedef {object} EleventyPage
45
+ * @property {string} virtualPath a unique virtual input path (arbitrary,
46
+ * never read from any real filesystem location)
47
+ * @property {string} content the exact output bytes for this page (raw
48
+ * HTML, or a fragment inserted into `layout` when one is supplied)
49
+ * @property {string} permalink the output-directory-relative file path,
50
+ * already normalized (no leading slash)
51
+ * @property {string} [layout] a layout file name relative to the layouts
52
+ * directory this module writes; omitted pages are written byte-for-byte
53
+ * with no wrapping
54
+ * @property {Record<string, unknown>} [data] extra front-matter data made
55
+ * available to the layout (e.g. `title`, `lang`)
56
+ */
57
+
58
+ /**
59
+ * The one skeleton layout this repository ships. It is core-owned static
60
+ * markup, not discovered from or influenced by the author repository. As of
61
+ * S2-T06, `{{ content | safe }}` is the page's *complete* body content
62
+ * (skip link, header, primary navigation, main, footer — see
63
+ * `internal/skeleton.js`'s `renderPageBody`), so this layout only supplies
64
+ * the outer document shell, the per-page `lang`/`dir` attributes, the CSP
65
+ * baseline, the S2-T07 appearance-controller wiring and the S2-T08
66
+ * SEO/localization `<head>` metadata below; it does not itself wrap
67
+ * anything in `<main>`.
68
+ *
69
+ * The one S2-T04 addition on top of S2-T03's minimal layout is the
70
+ * byte-exact per-artifact CSP `<meta>` baseline (brief S2 section 3): S2
71
+ * materializes no module package, configuration, output or runtime, so the
72
+ * same constant tag is correct on every route.
73
+ *
74
+ * S2-T07 additions, all fixed literals from `internal/appearance/contract.js`
75
+ * (never re-typed here), each byte-identical on every route exactly like the
76
+ * CSP tag above:
77
+ *
78
+ * - the `data-gala-publication-root` presence attribute on `<html>`, always
79
+ * rendered independent of JavaScript, so a theme's own no-JS
80
+ * `prefers-color-scheme` fallback CSS has a stable root scope to key off;
81
+ * - `<meta name="color-scheme" content="light dark">`, so user-agent styling
82
+ * (form controls, scrollbars) and the initial paint already follow
83
+ * `prefers-color-scheme` before any script runs;
84
+ * - the one `<script src>` this renderer ever emits (brief S2 section 3:
85
+ * "the appearance controller is the only browser bootstrap"): a plain,
86
+ * same-origin, non-`defer`/non-`async`/non-`module` classic script, placed
87
+ * after the CSP `<meta>` so it is governed by the policy it declares.
88
+ * Being a blocking `<script src>` in `<head>`, it always finishes running
89
+ * — synchronously resolving and applying the appearance state — before
90
+ * `<body>` (and so any themed paint) is even parsed, satisfying the
91
+ * pre-paint requirement with no visibility-hiding trick of any kind (see
92
+ * `internal/appearance/bootstrap-script.js`'s own module documentation).
93
+ * Its `href` (S2-T12 basePath fix) is supplied per-page as
94
+ * `data.appearanceScriptHref`, already `basePath`-joined by the caller
95
+ * (`internal/appearance/contract.js`'s `appearanceBootstrapScriptHref`),
96
+ * never a literal embedded in this fixed layout source.
97
+ *
98
+ * S2-T08 adds every other `<head>` element: a viewport meta, an optional
99
+ * plain-text description, an optional `robots` directive
100
+ * (`internal/page-kinds.js`'s per-page-kind `robotsContent`), an optional
101
+ * canonical link, Open Graph and Twitter Card meta tags and the Atom/RSS
102
+ * feed discovery links (present on every page, unconditionally — the feeds
103
+ * themselves are site-wide, not per-page). S2-T12 adds the theme stylesheet
104
+ * `<link>` elements (`internal/theme-assets.js`, in `cssLayers` order,
105
+ * including the print stylesheet hookup that used to be a fixed literal
106
+ * line here) as one already-rendered `data.themeStylesheetLinksHtml`
107
+ * fragment, and the `data-gala-page-kind` attribute on `<body>`
108
+ * (`internal/page-kinds.js`'s `PAGE_KIND_ATTRIBUTE`). Every interpolated
109
+ * `{{ variable }}`
110
+ * below relies on Nunjucks' own default `autoescape: true` (verified: no
111
+ * `nunjucksEnvironmentOptions.autoescape` override exists anywhere in this
112
+ * repository or in `@11ty/eleventy`'s own source, so Nunjucks' own
113
+ * documented default stands) for HTML-entity escaping — the same implicit
114
+ * contract `{{ title }}` already relied on before S2-T08; only
115
+ * `{{ content | safe }}` is deliberately exempted, because that value is
116
+ * already sanitized HTML markup, not plain text.
117
+ *
118
+ * @type {string}
119
+ */
120
+ const SKELETON_LAYOUT_SOURCE = [
121
+ '<!doctype html>',
122
+ `<html lang="{{ lang }}" dir="{{ dir }}" ${APPEARANCE_ROOT_ATTRIBUTE}>`,
123
+ '<head>',
124
+ '<meta charset="utf-8">',
125
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
126
+ contentSecurityPolicyMetaTag(),
127
+ `<meta name="color-scheme" content="${COLOR_SCHEME_META_CONTENT}">`,
128
+ '<script src="{{ appearanceScriptHref }}"></script>',
129
+ '<title>{{ title }}</title>',
130
+ '{% if description %}<meta name="description" content="{{ description }}">{% endif %}',
131
+ '{% if robotsContent %}<meta name="robots" content="{{ robotsContent }}">{% endif %}',
132
+ '{% if canonicalUrl %}<link rel="canonical" href="{{ canonicalUrl }}">{% endif %}',
133
+ '<meta property="og:site_name" content="{{ siteName }}">',
134
+ '{% if canonicalUrl %}<meta property="og:url" content="{{ canonicalUrl }}">{% endif %}',
135
+ '<meta property="og:type" content="{{ ogType }}">',
136
+ '<meta property="og:title" content="{{ title }}">',
137
+ '{% if description %}<meta property="og:description" content="{{ description }}">{% endif %}',
138
+ '{% if ogImage %}<meta property="og:image" content="{{ ogImage }}">{% endif %}',
139
+ '<meta name="twitter:card" content="{{ twitterCard }}">',
140
+ '<meta name="twitter:title" content="{{ title }}">',
141
+ '{% if description %}<meta name="twitter:description" content="{{ description }}">{% endif %}',
142
+ '{% if ogImage %}<meta name="twitter:image" content="{{ ogImage }}">{% endif %}',
143
+ '<link rel="alternate" type="application/atom+xml" href="{{ atomFeedUrl }}" title="{{ siteName }}">',
144
+ '<link rel="alternate" type="application/rss+xml" href="{{ rssFeedUrl }}" title="{{ siteName }}">',
145
+ '{{ themeStylesheetLinksHtml | safe }}',
146
+ '</head>',
147
+ `<body ${PAGE_KIND_ATTRIBUTE}="{{ pageKind }}">`,
148
+ '{{ content | safe }}',
149
+ '</body>',
150
+ '</html>',
151
+ '',
152
+ ].join('\n');
153
+
154
+ const LAYOUT_FILE_NAME = 'skeleton.njk';
155
+
156
+ /**
157
+ * Render every supplied page through a fully confined, single-pass,
158
+ * programmatic Eleventy 3.1.6 build.
159
+ *
160
+ * @param {object} options render options
161
+ * @param {readonly EleventyPage[]} options.pages pages to emit, already
162
+ * carrying their final output-relative permalink
163
+ * @param {string} options.inputDirectory an absolute, empty-or-caller-owned
164
+ * directory Eleventy may treat as its input root (only
165
+ * `${inputDirectory}/_includes/skeleton.njk` is written into it)
166
+ * @param {string} options.outputDirectory an absolute directory Eleventy
167
+ * writes the candidate publication into
168
+ * @returns {Promise<void>} resolves once the single build pass completes
169
+ */
170
+ export async function renderPagesWithEleventy({
171
+ pages,
172
+ inputDirectory,
173
+ outputDirectory,
174
+ }) {
175
+ const { mkdir, writeFile } = await import('node:fs/promises');
176
+ const path = await import('node:path');
177
+
178
+ const includesDirectory = path.join(inputDirectory, '_includes');
179
+ await mkdir(includesDirectory, { recursive: true });
180
+ await writeFile(
181
+ path.join(includesDirectory, LAYOUT_FILE_NAME),
182
+ SKELETON_LAYOUT_SOURCE,
183
+ 'utf8',
184
+ );
185
+
186
+ const eleventy = new Eleventy(inputDirectory, outputDirectory, {
187
+ source: 'script',
188
+ runMode: 'build',
189
+ dryRun: false,
190
+ quietMode: true,
191
+ // Explicit falsy configPath: skip .eleventy.js/eleventy.config.*
192
+ // discovery entirely rather than searching process.cwd().
193
+ configPath: false,
194
+ config(eleventyConfig) {
195
+ eleventyConfig.setUseGitIgnore(false);
196
+ eleventyConfig.setLayoutsDirectory('_includes');
197
+ eleventyConfig.setQuietMode(true);
198
+ eleventyConfig.setDataDeepMerge(false);
199
+
200
+ for (const page of pages) {
201
+ eleventyConfig.addTemplate(page.virtualPath, page.content, {
202
+ permalink: page.permalink,
203
+ layout: page.layout,
204
+ templateEngineOverride: false,
205
+ ...page.data,
206
+ });
207
+ }
208
+ },
209
+ });
210
+
211
+ // Eleventy's "Wrote N files" summary is logged with `force: true`
212
+ // regardless of quiet mode (its own `logFinished()` caller intentionally
213
+ // bypasses verbosity). `disableLogger()` is Eleventy's own documented
214
+ // escape hatch for that: it marks the console logger disabled so this
215
+ // library call produces no stdout/stderr output of its own; the caller
216
+ // decides what, if anything, to report.
217
+ eleventy.disableLogger();
218
+
219
+ await eleventy.write();
220
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * RSS 2.0 and Atom 1.0 feed generation (task packet S2-T08; brief S2
3
+ * section 3 "Required generated outputs": "RSS and Atom feeds").
4
+ *
5
+ * Both feeds are built from the exact same {@link selectPublishedArticles}
6
+ * selection and ordering `internal/page-kinds.js`'s `index` page kind
7
+ * paginates (most recently published first, `publishedAt` descending, tied
8
+ * broken by ascending `id`), so a feed's item order can never drift from the
9
+ * site's own index listing. Every URL is an absolute `https://` URL resolved
10
+ * against `buildInput.baseUrl` (brief S2 section 3: "absolute URLs from the
11
+ * build input's public origin"), never a relative path — a feed reader has
12
+ * no notion of the publication's own base path to resolve a relative URL
13
+ * against.
14
+ *
15
+ * Item count (documented scoped decision, not fixed by the brief or
16
+ * DEC-097): neither source specifies a maximum feed item count. This module
17
+ * bounds it at {@link FEED_ITEM_LIMIT} — a conservative, deterministic
18
+ * default with no author-facing configuration surface in S2, matching this
19
+ * renderer's own established style for an unspecified numeric bound (see
20
+ * `internal/page-kinds.js`'s `LISTING_PAGE_SIZE` and the media pipeline
21
+ * scope decisions in the package README).
22
+ */
23
+
24
+ import { escapeHtml } from './skeleton.js';
25
+ import { joinBasePathAndRoute } from './route.js';
26
+ import {
27
+ contentLastModified,
28
+ contentRoute,
29
+ selectPublishedArticles,
30
+ } from './page-kinds.js';
31
+
32
+ /** @type {number} */
33
+ export const FEED_ITEM_LIMIT = 50;
34
+
35
+ /** @type {string} the un-joined route the Atom feed is published at. */
36
+ export const ATOM_FEED_ROUTE = '/feed/atom.xml';
37
+ /** @type {string} the un-joined route the RSS feed is published at. */
38
+ export const RSS_FEED_ROUTE = '/feed/rss.xml';
39
+
40
+ /**
41
+ * Format an `rfc3339` (`YYYY-MM-DDTHH:mm:ss.000Z`) timestamp as RFC 822/1123
42
+ * text, the pubDate grammar RSS 2.0 requires. `Date#toUTCString()` always
43
+ * emits the fixed-width, zero-padded, `GMT`-suffixed form this needs,
44
+ * deterministically, from a deterministic input.
45
+ *
46
+ * @param {string} rfc3339 a validated `rfc3339` timestamp
47
+ * @returns {string} an RFC 822/1123 date-time string
48
+ */
49
+ function toRfc822(rfc3339) {
50
+ return new Date(rfc3339).toUTCString();
51
+ }
52
+
53
+ /**
54
+ * Build the deterministic Atom 1.0 and RSS 2.0 feed documents for one
55
+ * validated `build-input:2.0.0` instance.
56
+ *
57
+ * @param {import('../../../types/index.d.ts').NormalizedBuildInput} validatedInput
58
+ * @returns {{atomXml: string, rssXml: string, atomSelfUrl: string, rssSelfUrl: string}}
59
+ * both feed documents' exact UTF-8 text, plus each feed's own absolute
60
+ * self URL (for the shared layout's feed-discovery `<link>` tags)
61
+ */
62
+ export function buildFeeds(validatedInput) {
63
+ const { publication, authors, content, basePath, baseUrl } = validatedInput;
64
+ const authorsById = new Map(authors.map((author) => [author.id, author]));
65
+ const articles = selectPublishedArticles(content).slice(0, FEED_ITEM_LIMIT);
66
+
67
+ /**
68
+ * @param {string} route an un-joined route
69
+ * @returns {string} the absolute `https://` URL
70
+ */
71
+ const absoluteUrl = (route) =>
72
+ new URL(joinBasePathAndRoute(basePath, route), baseUrl).toString();
73
+
74
+ const siteUrl = absoluteUrl('/');
75
+ const atomSelfUrl = absoluteUrl(ATOM_FEED_ROUTE);
76
+ const rssSelfUrl = absoluteUrl(RSS_FEED_ROUTE);
77
+
78
+ const feedUpdated =
79
+ articles.length > 0
80
+ ? contentLastModified(articles[0].frontmatter)
81
+ : validatedInput.buildEpoch;
82
+
83
+ const atomEntries = articles
84
+ .map((record) => {
85
+ const { frontmatter } = record;
86
+ const entryUrl = absoluteUrl(contentRoute(frontmatter));
87
+ const authorNames = frontmatter.authorIds.map(
88
+ (id) => authorsById.get(id)?.displayName ?? '',
89
+ );
90
+ const authorsXml = authorNames
91
+ .map((name) => `<author><name>${escapeHtml(name)}</name></author>`)
92
+ .join('');
93
+ const summaryXml = frontmatter.description
94
+ ? `<summary>${escapeHtml(frontmatter.description)}</summary>`
95
+ : '';
96
+ return (
97
+ `<entry>` +
98
+ `<id>${escapeHtml(entryUrl)}</id>` +
99
+ `<title>${escapeHtml(frontmatter.title)}</title>` +
100
+ `<updated>${escapeHtml(contentLastModified(frontmatter))}</updated>` +
101
+ `<published>${escapeHtml(frontmatter.publishedAt)}</published>` +
102
+ `<link href="${escapeHtml(entryUrl)}" rel="alternate"/>` +
103
+ authorsXml +
104
+ summaryXml +
105
+ `</entry>`
106
+ );
107
+ })
108
+ .join('');
109
+
110
+ const atomXml =
111
+ '<?xml version="1.0" encoding="UTF-8"?>\n' +
112
+ '<feed xmlns="http://www.w3.org/2005/Atom">' +
113
+ `<id>${escapeHtml(siteUrl)}</id>` +
114
+ `<title>${escapeHtml(publication.title)}</title>` +
115
+ `<subtitle>${escapeHtml(publication.description)}</subtitle>` +
116
+ `<updated>${escapeHtml(feedUpdated)}</updated>` +
117
+ `<link href="${escapeHtml(atomSelfUrl)}" rel="self"/>` +
118
+ `<link href="${escapeHtml(siteUrl)}" rel="alternate"/>` +
119
+ atomEntries +
120
+ '</feed>\n';
121
+
122
+ const rssItems = articles
123
+ .map((record) => {
124
+ const { frontmatter } = record;
125
+ const itemUrl = absoluteUrl(contentRoute(frontmatter));
126
+ const descriptionXml = frontmatter.description
127
+ ? `<description>${escapeHtml(frontmatter.description)}</description>`
128
+ : '';
129
+ return (
130
+ `<item>` +
131
+ `<title>${escapeHtml(frontmatter.title)}</title>` +
132
+ `<link>${escapeHtml(itemUrl)}</link>` +
133
+ `<guid isPermaLink="true">${escapeHtml(itemUrl)}</guid>` +
134
+ `<pubDate>${escapeHtml(toRfc822(frontmatter.publishedAt))}</pubDate>` +
135
+ descriptionXml +
136
+ `</item>`
137
+ );
138
+ })
139
+ .join('');
140
+
141
+ // `atom:link rel="self"` is RSS 2.0's own de-facto self-discovery
142
+ // convention (no equivalent element exists in the bare RSS 2.0
143
+ // vocabulary itself), requiring the `atom` namespace declaration on the
144
+ // root `<rss>` element — the same self-discovery guarantee the Atom feed
145
+ // above already gets from its native `<link rel="self">` (independent
146
+ // review request).
147
+ const rssXml =
148
+ '<?xml version="1.0" encoding="UTF-8"?>\n' +
149
+ '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">' +
150
+ '<channel>' +
151
+ `<title>${escapeHtml(publication.title)}</title>` +
152
+ `<link>${escapeHtml(siteUrl)}</link>` +
153
+ `<description>${escapeHtml(publication.description)}</description>` +
154
+ `<lastBuildDate>${escapeHtml(toRfc822(feedUpdated))}</lastBuildDate>` +
155
+ `<atom:link href="${escapeHtml(rssSelfUrl)}" rel="self" type="application/rss+xml"/>` +
156
+ rssItems +
157
+ '</channel>' +
158
+ '</rss>\n';
159
+
160
+ return { atomXml, rssXml, rssSelfUrl, atomSelfUrl };
161
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Deterministic recursive directory listing.
3
+ *
4
+ * Filesystem readdir order is not portable, so every caller that needs a
5
+ * reproducible route/asset ordering must sort explicitly; this module never
6
+ * returns raw enumeration order (brief S2 section 3: "no
7
+ * filesystem-enumeration-order dependence").
8
+ */
9
+
10
+ import { readdir } from 'node:fs/promises';
11
+ import path from 'node:path';
12
+
13
+ /**
14
+ * List every regular file under `root`, recursively, as POSIX-style
15
+ * (forward-slash) paths relative to `root`, sorted by UTF-8 byte sequence.
16
+ *
17
+ * @param {string} root an absolute directory path
18
+ * @returns {Promise<string[]>} sorted, root-relative, forward-slash paths
19
+ */
20
+ export async function listFilesSortedByUtf8Bytes(root) {
21
+ const entries = await readdir(root, { withFileTypes: true });
22
+ /** @type {string[]} */
23
+ const files = [];
24
+ for (const entry of entries) {
25
+ const absolute = path.join(root, entry.name);
26
+ if (entry.isDirectory()) {
27
+ const nested = await listFilesSortedByUtf8Bytes(absolute);
28
+ for (const relative of nested) {
29
+ files.push(`${entry.name}/${relative}`);
30
+ }
31
+ } else if (entry.isFile()) {
32
+ files.push(entry.name);
33
+ }
34
+ }
35
+ return files.sort((a, b) =>
36
+ Buffer.compare(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8')),
37
+ );
38
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Bounded AVIF (ISOBMFF) container validation and declared-dimension
3
+ * extraction.
4
+ *
5
+ * Scope decision (documented, not silently assumed, and identical in spirit
6
+ * to `webp-probe.js`): this module walks the ISOBMFF box structure closely
7
+ * enough to confirm an `ftyp` box with major brand `avif`, locate the
8
+ * `meta` box, and read the primary item's `ispe` (image spatial extents)
9
+ * property for declared width/height, but it does not run a full AV1
10
+ * bitstream decode. DEC-097's closed `gala-theme-binary-assets-v2` profile
11
+ * requires a complete single-frame AV1 decode for *passive theme assets*;
12
+ * an equivalent from-scratch AV1 decoder for *author media* is out of
13
+ * proportion to this task's scope, flagged to the orchestrator as a
14
+ * follow-up. AVIF is therefore admitted as a *source* reference
15
+ * (recorded, digest-verified, dimension-bounded) with no generated
16
+ * responsive derivative; its original bytes pass through as the sole asset
17
+ * entry, exactly like WebP.
18
+ *
19
+ * The box walk is bounded: at most 4,096 boxes are visited (matching
20
+ * DEC-097's own "4,096 container structures" isolation bound for the binary
21
+ * decoder family) and every box's declared size is checked against the
22
+ * remaining buffer length before it is used to slice, so a malformed or
23
+ * adversarial box table cannot cause an out-of-bounds read or an unbounded
24
+ * walk.
25
+ */
26
+
27
+ import { MediaPipelineError } from '../../errors.js';
28
+ import { assertDimensionsWithinCeiling } from './limits.js';
29
+
30
+ const MAX_BOXES_VISITED = 4096;
31
+
32
+ /**
33
+ * Walk one ISOBMFF box list, bounded, yielding `{type, start, end}` for each
34
+ * top-level box.
35
+ *
36
+ * @param {Buffer} bytes the buffer to walk
37
+ * @param {number} start the offset to begin walking at
38
+ * @param {number} end the exclusive offset to stop walking at
39
+ * @param {{count: number}} budget a shared box-count budget, mutated
40
+ * @param {string} referencePath the source path, for error messages
41
+ * @returns {{type: string, start: number, end: number}[]} the boxes found
42
+ */
43
+ function walkBoxes(bytes, start, end, budget, referencePath) {
44
+ /** @type {{type: string, start: number, end: number}[]} */
45
+ const boxes = [];
46
+ let offset = start;
47
+ while (offset < end) {
48
+ if (offset + 8 > end) {
49
+ throw new MediaPipelineError(
50
+ 'MEDIA_FORMAT_INVALID',
51
+ 'truncated ISOBMFF box header',
52
+ referencePath,
53
+ );
54
+ }
55
+ budget.count += 1;
56
+ if (budget.count > MAX_BOXES_VISITED) {
57
+ throw new MediaPipelineError(
58
+ 'MEDIA_RESOURCE_EXCEEDED',
59
+ 'AVIF container exceeds the admitted box-count ceiling',
60
+ referencePath,
61
+ );
62
+ }
63
+ const size = bytes.readUInt32BE(offset);
64
+ const type = bytes.subarray(offset + 4, offset + 8).toString('ascii');
65
+ if (size < 8 || offset + size > end) {
66
+ throw new MediaPipelineError(
67
+ 'MEDIA_FORMAT_INVALID',
68
+ `ISOBMFF box "${type}" has an invalid or overflowing size`,
69
+ referencePath,
70
+ );
71
+ }
72
+ boxes.push({ type, start: offset, end: offset + size });
73
+ offset += size;
74
+ }
75
+ return boxes;
76
+ }
77
+
78
+ /**
79
+ * Validate an AVIF container and return its declared primary-item
80
+ * dimensions.
81
+ *
82
+ * @param {Buffer} bytes complete AVIF file bytes
83
+ * @param {string} referencePath the source path, for error messages
84
+ * @returns {{width: number, height: number}} the declared dimensions
85
+ */
86
+ export function probeAvif(bytes, referencePath) {
87
+ const budget = { count: 0 };
88
+ const topLevel = walkBoxes(bytes, 0, bytes.length, budget, referencePath);
89
+ const ftyp = topLevel.find((box) => box.type === 'ftyp');
90
+ if (!ftyp) {
91
+ throw new MediaPipelineError(
92
+ 'MEDIA_FORMAT_INVALID',
93
+ 'missing ftyp box',
94
+ referencePath,
95
+ );
96
+ }
97
+ const majorBrand = bytes
98
+ .subarray(ftyp.start + 8, ftyp.start + 12)
99
+ .toString('ascii');
100
+ if (majorBrand !== 'avif') {
101
+ throw new MediaPipelineError(
102
+ 'MEDIA_FORMAT_INVALID',
103
+ `unsupported ftyp major brand "${majorBrand}"; only "avif" is admitted`,
104
+ referencePath,
105
+ );
106
+ }
107
+
108
+ const meta = topLevel.find((box) => box.type === 'meta');
109
+ if (!meta) {
110
+ throw new MediaPipelineError(
111
+ 'MEDIA_FORMAT_INVALID',
112
+ 'missing meta box',
113
+ referencePath,
114
+ );
115
+ }
116
+ // `meta` is a full box: 4 bytes of version/flags precede its child boxes.
117
+ const metaChildren = walkBoxes(
118
+ bytes,
119
+ meta.start + 12,
120
+ meta.end,
121
+ budget,
122
+ referencePath,
123
+ );
124
+ const iprp = metaChildren.find((box) => box.type === 'iprp');
125
+ if (!iprp) {
126
+ throw new MediaPipelineError(
127
+ 'MEDIA_FORMAT_INVALID',
128
+ 'missing iprp box',
129
+ referencePath,
130
+ );
131
+ }
132
+ const iprpChildren = walkBoxes(
133
+ bytes,
134
+ iprp.start + 8,
135
+ iprp.end,
136
+ budget,
137
+ referencePath,
138
+ );
139
+ const ipco = iprpChildren.find((box) => box.type === 'ipco');
140
+ if (!ipco) {
141
+ throw new MediaPipelineError(
142
+ 'MEDIA_FORMAT_INVALID',
143
+ 'missing ipco box',
144
+ referencePath,
145
+ );
146
+ }
147
+ const ipcoChildren = walkBoxes(
148
+ bytes,
149
+ ipco.start + 8,
150
+ ipco.end,
151
+ budget,
152
+ referencePath,
153
+ );
154
+ const ispe = ipcoChildren.find((box) => box.type === 'ispe');
155
+ if (!ispe || ispe.end - ispe.start < 20) {
156
+ throw new MediaPipelineError(
157
+ 'MEDIA_FORMAT_INVALID',
158
+ 'missing or malformed ispe property',
159
+ referencePath,
160
+ );
161
+ }
162
+ // ispe is a full box: 4 bytes header + 4 bytes version/flags, then two
163
+ // 32-bit big-endian dimensions.
164
+ const width = bytes.readUInt32BE(ispe.start + 12);
165
+ const height = bytes.readUInt32BE(ispe.start + 16);
166
+
167
+ assertDimensionsWithinCeiling(width, height, referencePath, 'AVIF');
168
+ return { width, height };
169
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Font policy for `appearance.fontAssets` (brief S2 section 3: "Custom fonts
3
+ * require licensing, glyph and language coverage plus fallback evidence, and
4
+ * no remote font is ever retrieved").
5
+ *
6
+ * `build-input:2.0.0`'s `resolvedFile` shape (the only shape
7
+ * `appearance.fontAssets` entries use) is `{path, sourceDigest}` — a
8
+ * repository-relative path plus a digest, nothing else. There is no
9
+ * schema-carried field anywhere in `build-input` for a font's license text,
10
+ * glyph set, or language-coverage claim (`grep`-verified against the
11
+ * published `@rathnasgala2/schemas` package); a custom font's licensing,
12
+ * glyph-coverage and fallback *evidence* is therefore necessarily an
13
+ * authoring-process and documentation concern outside this schema's data
14
+ * model, not a field this renderer can validate — this repository's README
15
+ * states that requirement in those terms rather than fabricating a
16
+ * validation step against a field that does not exist. What this module
17
+ * *does* enforce, structurally, for every font this pipeline processes:
18
+ *
19
+ * - the file is bounded WOFF2 (exact `wOF2` signature, size at most
20
+ * `MAX_FONT_SOURCE_BYTES`) — anything else is `MEDIA_FONT_FORMAT_INVALID`;
21
+ * - `resolvedFile.path` is always a `repoRelativePath` (schema-enforced
22
+ * before this renderer ever runs: no scheme, no host, no `://`), so a
23
+ * remote font reference cannot enter `build-input` in the first place, and
24
+ * this pipeline never performs a network fetch to resolve one — "no
25
+ * remote font is ever retrieved" holds by construction, not by a runtime
26
+ * check with something to catch.
27
+ */
28
+
29
+ import { MediaPipelineError } from '../../errors.js';
30
+ import { MAX_FONT_SOURCE_BYTES } from './limits.js';
31
+
32
+ const WOFF2_SIGNATURE = Buffer.from([0x77, 0x4f, 0x46, 0x32]); // "wOF2"
33
+
34
+ /**
35
+ * Validate one font asset's bytes are bounded WOFF2.
36
+ *
37
+ * @param {Buffer} bytes complete font file bytes
38
+ * @param {string} referencePath the source path, for error messages
39
+ * @returns {void}
40
+ */
41
+ export function assertFontPolicy(bytes, referencePath) {
42
+ if (bytes.length > MAX_FONT_SOURCE_BYTES) {
43
+ throw new MediaPipelineError(
44
+ 'MEDIA_RESOURCE_EXCEEDED',
45
+ `font source is ${bytes.length} bytes, exceeding the ${MAX_FONT_SOURCE_BYTES}-byte ceiling`,
46
+ referencePath,
47
+ );
48
+ }
49
+ if (bytes.length < 4 || !bytes.subarray(0, 4).equals(WOFF2_SIGNATURE)) {
50
+ throw new MediaPipelineError(
51
+ 'MEDIA_FONT_FORMAT_INVALID',
52
+ 'font is not a WOFF2 file (missing "wOF2" signature); no other font format is admitted as author media',
53
+ referencePath,
54
+ );
55
+ }
56
+ }