@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,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
|
+
}
|
|
Binary file
|