@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,758 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@rathnasgala2/template` public entry point.
|
|
3
|
+
*
|
|
4
|
+
* This is S2-T03's Eleventy-confined renderer adapter, extended by S2-T05's
|
|
5
|
+
* media pipeline: it consumes one validated
|
|
6
|
+
* `urn:gala:schema:build-input:2.0.0` instance and emits a candidate output
|
|
7
|
+
* directory plus one `urn:gala:schema:artifact-manifest:2.0.0` instance
|
|
8
|
+
* (brief S2 section 3).
|
|
9
|
+
*
|
|
10
|
+
* `renderPublication` is the only documented *rendering* entry point. It
|
|
11
|
+
* fails closed: an invalid `build-input` is rejected with
|
|
12
|
+
* {@link BuildInputValidationError} before anything is written, malformed
|
|
13
|
+
* adapter options are rejected with {@link RenderOptionsError} before
|
|
14
|
+
* Eleventy ever runs, and a rejected image or font is rejected with
|
|
15
|
+
* {@link MediaPipelineError} before the manifest is assembled.
|
|
16
|
+
*
|
|
17
|
+
* `normalizeAuthoredMarkdown` (re-exported here from S2-T04's
|
|
18
|
+
* `internal/content-security.js`) is this package's second public entry
|
|
19
|
+
* point: the upstream normalization step a `build-input:2.0.0` producer
|
|
20
|
+
* (`publish-kernel`/`publish-action`) calls once per authored Markdown
|
|
21
|
+
* source to produce the exact `renderableBody.body`/`bodyDigest` pair this
|
|
22
|
+
* renderer expects to receive. `renderPublication` never calls it itself —
|
|
23
|
+
* see `internal/content-security.js`'s module documentation for why a
|
|
24
|
+
* `renderableBody.body` is already policy-conformant HTML by the time it
|
|
25
|
+
* reaches this renderer, and how `renderPublication` verifies that instead
|
|
26
|
+
* of re-deriving it.
|
|
27
|
+
*
|
|
28
|
+
* S2-T05 adds `options.sourceDirectory` (the caller-mounted, read-only
|
|
29
|
+
* repository source tree `build-input`'s `resolvedFile` references point
|
|
30
|
+
* into) and runs `processMedia` after the Eleventy route listing is already
|
|
31
|
+
* computed, so generated derivative files under `assets/media/` are never
|
|
32
|
+
* mistaken for an HTML route.
|
|
33
|
+
*
|
|
34
|
+
* Output-security (markdown/sanitizer/highlighter/CSP; S2-T04) and the media
|
|
35
|
+
* pipeline (S2-T05) are layered on the same adapter. S2-T06 adds the core
|
|
36
|
+
* semantic skeleton and navigation renderer (`internal/skeleton.js`) and
|
|
37
|
+
* every generated page kind (`internal/page-kinds.js`): publication profile,
|
|
38
|
+
* author, article/page, index, tag, series and archive pages, each composed
|
|
39
|
+
* with one shared header/primary-navigation/footer chrome and, where the
|
|
40
|
+
* brief requires it, a breadcrumb trail. Feeds/search/sitemap remain
|
|
41
|
+
* S2-T08. This adapter also emits every authored static redirect, shaped so
|
|
42
|
+
* that later task extends the page-body pipeline without touching the
|
|
43
|
+
* confinement, route-projection or manifest-assembly mechanics here.
|
|
44
|
+
*
|
|
45
|
+
* S2-T07 adds the Light/Dark/System appearance controller
|
|
46
|
+
* (`internal/appearance/`): the server-rendered control in the header's
|
|
47
|
+
* `header-actions` slot (`controller-markup.js`), the one deterministic
|
|
48
|
+
* pre-paint bootstrap script this renderer ever emits, written into the
|
|
49
|
+
* candidate output directory and entered into the manifest as an ordinary
|
|
50
|
+
* `manifestAsset` row exactly like an S2-T05 media derivative
|
|
51
|
+
* (`bootstrap-script.js`), and the fixed `data-` attribute names, mode
|
|
52
|
+
* values and storage key every one of those pieces shares
|
|
53
|
+
* (`contract.js`) — the contract S2-T12's published theme styling catalog
|
|
54
|
+
* targets.
|
|
55
|
+
*
|
|
56
|
+
* `src/core/` is the only admitted source root in this repository. No
|
|
57
|
+
* `src/modules/` tree, module import edge, registration stub, module
|
|
58
|
+
* configuration, module output or module runtime is ever added beside it
|
|
59
|
+
* (brief S2 section 3; DEC-097 sections 2 and 5).
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
63
|
+
import path from 'node:path';
|
|
64
|
+
|
|
65
|
+
import { validateGalaDocument } from '@rathnasgala2/schemas';
|
|
66
|
+
|
|
67
|
+
import {
|
|
68
|
+
APPEARANCE_BOOTSTRAP_SCRIPT_MEDIA_TYPE,
|
|
69
|
+
APPEARANCE_BOOTSTRAP_SCRIPT_PATH,
|
|
70
|
+
appearanceBootstrapScriptHref,
|
|
71
|
+
} from './internal/appearance/contract.js';
|
|
72
|
+
import { APPEARANCE_BOOTSTRAP_SCRIPT_SOURCE } from './internal/appearance/bootstrap-script.js';
|
|
73
|
+
import { renderAppearanceControl } from './internal/appearance/controller-markup.js';
|
|
74
|
+
import {
|
|
75
|
+
ArtifactManifestValidationError,
|
|
76
|
+
BuildInputValidationError,
|
|
77
|
+
RenderOptionsError,
|
|
78
|
+
RenderPolicyViolationError,
|
|
79
|
+
} from './errors.js';
|
|
80
|
+
import { digestBytes } from './internal/canonical-jcs.js';
|
|
81
|
+
import {
|
|
82
|
+
assertPolicyConformantHtml,
|
|
83
|
+
assertRenderPolicyIdentity,
|
|
84
|
+
computeRenderPolicyIdentity,
|
|
85
|
+
} from './internal/content-security.js';
|
|
86
|
+
import { renderPagesWithEleventy } from './internal/eleventy-render.js';
|
|
87
|
+
import {
|
|
88
|
+
ATOM_FEED_ROUTE,
|
|
89
|
+
RSS_FEED_ROUTE,
|
|
90
|
+
buildFeeds,
|
|
91
|
+
} from './internal/feeds.js';
|
|
92
|
+
import { listFilesSortedByUtf8Bytes } from './internal/fs-walk.js';
|
|
93
|
+
import { getMessages } from './internal/messages.js';
|
|
94
|
+
import { processMedia } from './internal/media/pipeline.js';
|
|
95
|
+
import {
|
|
96
|
+
buildGeneratedPages,
|
|
97
|
+
contentRoute,
|
|
98
|
+
renderErrorPageBody,
|
|
99
|
+
} from './internal/page-kinds.js';
|
|
100
|
+
import { assertProvenance } from './internal/provenance.js';
|
|
101
|
+
import {
|
|
102
|
+
errorDocumentPath,
|
|
103
|
+
joinBasePathAndRoute,
|
|
104
|
+
projectFixedAssetPath,
|
|
105
|
+
projectRouteToFilePath,
|
|
106
|
+
renderRedirectDocument,
|
|
107
|
+
} from './internal/route.js';
|
|
108
|
+
import { buildSearchIndex } from './internal/search-index.js';
|
|
109
|
+
import { resolveSocialImageUrl, twitterCardType } from './internal/seo.js';
|
|
110
|
+
import { buildSitemap } from './internal/sitemap.js';
|
|
111
|
+
import {
|
|
112
|
+
defaultThemeStylesheetLinksHtml,
|
|
113
|
+
loadThemeAssets,
|
|
114
|
+
} from './internal/theme-assets.js';
|
|
115
|
+
import { resolveTextDirection } from './internal/text-direction.js';
|
|
116
|
+
import {
|
|
117
|
+
renderFooter,
|
|
118
|
+
renderFooterNavigation,
|
|
119
|
+
renderHeader,
|
|
120
|
+
renderPageBody,
|
|
121
|
+
renderPrimaryNavigation,
|
|
122
|
+
} from './internal/skeleton.js';
|
|
123
|
+
import {
|
|
124
|
+
collectIncludedSources,
|
|
125
|
+
compareUtf8Bytes,
|
|
126
|
+
} from './internal/source-inventory.js';
|
|
127
|
+
import { generateUuidV7 } from './internal/uuid.js';
|
|
128
|
+
import { buildArtifactManifest } from './manifest.js';
|
|
129
|
+
|
|
130
|
+
/** @type {string} the un-joined route `sitemap.xml` is published at. */
|
|
131
|
+
const SITEMAP_ROUTE = '/sitemap.xml';
|
|
132
|
+
/** @type {string} the un-joined route the static search index is published
|
|
133
|
+
* at. */
|
|
134
|
+
const SEARCH_INDEX_ROUTE = '/search-index.json';
|
|
135
|
+
|
|
136
|
+
/** @type {string} */
|
|
137
|
+
const BUILD_INPUT_SCHEMA_ID = 'urn:gala:schema:build-input:2.0.0';
|
|
138
|
+
/** @type {string} */
|
|
139
|
+
const ARTIFACT_MANIFEST_SCHEMA_ID = 'urn:gala:schema:artifact-manifest:2.0.0';
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The published npm package name for this renderer.
|
|
143
|
+
*
|
|
144
|
+
* @type {string}
|
|
145
|
+
*/
|
|
146
|
+
export const TEMPLATE_PACKAGE_NAME = '@rathnasgala2/template';
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The renderer's published major.minor.patch version, kept in sync with
|
|
150
|
+
* `package.json` by the packaging test in `test/pinned-versions.test.js`.
|
|
151
|
+
*
|
|
152
|
+
* @type {string}
|
|
153
|
+
*/
|
|
154
|
+
export const TEMPLATE_PACKAGE_VERSION = '2.0.0';
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Validate the caller-supplied render options, independent of build-input
|
|
158
|
+
* validity. Directories must be supplied as absolute paths and must be
|
|
159
|
+
* distinct, so this renderer never has to guess where "outside the
|
|
160
|
+
* declared input/output/work directories" begins.
|
|
161
|
+
*
|
|
162
|
+
* @param {import('../../types/index.d.ts').RenderOptions} options caller
|
|
163
|
+
* options
|
|
164
|
+
* @returns {void}
|
|
165
|
+
*/
|
|
166
|
+
function assertOptions(options) {
|
|
167
|
+
if (!options || typeof options !== 'object') {
|
|
168
|
+
throw new RenderOptionsError('render options are required');
|
|
169
|
+
}
|
|
170
|
+
for (const key of /** @type {const} */ ([
|
|
171
|
+
'outputDirectory',
|
|
172
|
+
'workDirectory',
|
|
173
|
+
'sourceDirectory',
|
|
174
|
+
])) {
|
|
175
|
+
const value = options[key];
|
|
176
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
177
|
+
throw new RenderOptionsError(`options.${key} must be a non-empty string`);
|
|
178
|
+
}
|
|
179
|
+
if (!path.isAbsolute(value)) {
|
|
180
|
+
throw new RenderOptionsError(`options.${key} must be an absolute path`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// S2-T12: an optional caller-mounted, read-only extracted theme package
|
|
184
|
+
// directory (`internal/theme-assets.js`). Absent entirely by default
|
|
185
|
+
// (every pre-S2-T12 caller, including this repository's own test suite)
|
|
186
|
+
// so this addition is purely additive.
|
|
187
|
+
if (options.themeDirectory !== undefined) {
|
|
188
|
+
if (
|
|
189
|
+
typeof options.themeDirectory !== 'string' ||
|
|
190
|
+
options.themeDirectory.length === 0 ||
|
|
191
|
+
!path.isAbsolute(options.themeDirectory)
|
|
192
|
+
) {
|
|
193
|
+
throw new RenderOptionsError(
|
|
194
|
+
'options.themeDirectory, when supplied, must be a non-empty absolute path',
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const resolvedDirectories = [
|
|
199
|
+
path.resolve(options.outputDirectory),
|
|
200
|
+
path.resolve(options.workDirectory),
|
|
201
|
+
path.resolve(options.sourceDirectory),
|
|
202
|
+
...(options.themeDirectory ? [path.resolve(options.themeDirectory)] : []),
|
|
203
|
+
];
|
|
204
|
+
if (new Set(resolvedDirectories).size !== resolvedDirectories.length) {
|
|
205
|
+
throw new RenderOptionsError(
|
|
206
|
+
'options.outputDirectory, options.workDirectory, options.sourceDirectory ' +
|
|
207
|
+
'and (when supplied) options.themeDirectory must all be distinct',
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
if (
|
|
211
|
+
options.routeNormalizationProfile !== undefined &&
|
|
212
|
+
options.routeNormalizationProfile !== 'directory-index' &&
|
|
213
|
+
options.routeNormalizationProfile !== 'explicit-file'
|
|
214
|
+
) {
|
|
215
|
+
throw new RenderOptionsError(
|
|
216
|
+
"options.routeNormalizationProfile must be 'directory-index' or 'explicit-file'",
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
// Checked in full here, before any directory is created or Eleventy ever
|
|
220
|
+
// runs, so an incomplete provenance bundle fails closed exactly like an
|
|
221
|
+
// invalid build-input does: nothing is written.
|
|
222
|
+
assertProvenance(options.provenance);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Verify every `renderableBody.renderPolicy` in the publication profile,
|
|
227
|
+
* footer card and every content record byte-equals the currently published
|
|
228
|
+
* render-policy identity, and that no module or placement value leaked
|
|
229
|
+
* through (belt-and-suspenders on top of the `build-input:2.0.0` schema's
|
|
230
|
+
* own `modules: {}` / `placements: []` closure). Fails closed before any
|
|
231
|
+
* directory is created or Eleventy ever runs (DEC-097 section 5: "An absent
|
|
232
|
+
* policy file, wrong path/name/version/digest, mixed identity between
|
|
233
|
+
* records or body produced under a different policy rejects before build").
|
|
234
|
+
*
|
|
235
|
+
* @param {import('../../types/index.d.ts').NormalizedBuildInput} validatedInput
|
|
236
|
+
* the schema-validated build input
|
|
237
|
+
* @returns {Promise<void>} resolves once every reference is verified
|
|
238
|
+
*/
|
|
239
|
+
async function assertRenderPolicyCompliance(validatedInput) {
|
|
240
|
+
if (
|
|
241
|
+
Object.keys(validatedInput.modules).length !== 0 ||
|
|
242
|
+
validatedInput.placements.length !== 0
|
|
243
|
+
) {
|
|
244
|
+
throw new RenderPolicyViolationError(
|
|
245
|
+
'build-input.modules and build-input.placements must both be empty in S2',
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const identity = await computeRenderPolicyIdentity();
|
|
250
|
+
|
|
251
|
+
if (validatedInput.publication.profile) {
|
|
252
|
+
const { body } = validatedInput.publication.profile;
|
|
253
|
+
assertRenderPolicyIdentity(
|
|
254
|
+
body.renderPolicy,
|
|
255
|
+
identity,
|
|
256
|
+
'publication.profile.body',
|
|
257
|
+
);
|
|
258
|
+
assertPolicyConformantHtml(
|
|
259
|
+
body.body,
|
|
260
|
+
body.bodyDigest,
|
|
261
|
+
'publication.profile.body',
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
if (validatedInput.publication.footerCard) {
|
|
265
|
+
const { body } = validatedInput.publication.footerCard;
|
|
266
|
+
assertRenderPolicyIdentity(
|
|
267
|
+
body.renderPolicy,
|
|
268
|
+
identity,
|
|
269
|
+
'publication.footerCard.body',
|
|
270
|
+
);
|
|
271
|
+
assertPolicyConformantHtml(
|
|
272
|
+
body.body,
|
|
273
|
+
body.bodyDigest,
|
|
274
|
+
'publication.footerCard.body',
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
for (const record of validatedInput.content) {
|
|
278
|
+
const location = `content[${record.frontmatter.id}]`;
|
|
279
|
+
assertRenderPolicyIdentity(record.renderPolicy, identity, location);
|
|
280
|
+
assertPolicyConformantHtml(record.body, record.bodyDigest, location);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Render one validated `build-input:2.0.0` instance into a candidate output
|
|
286
|
+
* directory plus a closed `artifact-manifest:2.0.0` instance.
|
|
287
|
+
*
|
|
288
|
+
* @param {unknown} buildInput a candidate `build-input:2.0.0` document
|
|
289
|
+
* @param {import('../../types/index.d.ts').RenderOptions} options adapter
|
|
290
|
+
* options: output/work directories, route normalization profile and the
|
|
291
|
+
* provenance facts this renderer cannot derive from `build-input` alone
|
|
292
|
+
* @returns {Promise<{outputDirectory: string, manifest: Record<string, unknown>}>}
|
|
293
|
+
* the absolute candidate output directory and the assembled manifest
|
|
294
|
+
*/
|
|
295
|
+
export async function renderPublication(buildInput, options) {
|
|
296
|
+
const inputValidation = validateGalaDocument(
|
|
297
|
+
BUILD_INPUT_SCHEMA_ID,
|
|
298
|
+
buildInput,
|
|
299
|
+
);
|
|
300
|
+
if (!inputValidation.valid) {
|
|
301
|
+
throw new BuildInputValidationError(inputValidation.diagnostics);
|
|
302
|
+
}
|
|
303
|
+
const validatedInput =
|
|
304
|
+
/** @type {import('../../types/index.d.ts').NormalizedBuildInput} */ (
|
|
305
|
+
buildInput
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
await assertRenderPolicyCompliance(validatedInput);
|
|
309
|
+
assertOptions(options);
|
|
310
|
+
const routeProfile = options.routeNormalizationProfile ?? 'directory-index';
|
|
311
|
+
|
|
312
|
+
const outputDirectory = path.resolve(options.outputDirectory);
|
|
313
|
+
const workDirectory = path.resolve(options.workDirectory);
|
|
314
|
+
await mkdir(outputDirectory, { recursive: true });
|
|
315
|
+
await mkdir(workDirectory, { recursive: true });
|
|
316
|
+
|
|
317
|
+
// The site-wide message catalog for core chrome (skip link, navigation,
|
|
318
|
+
// breadcrumb, pagination and footer labels). Every page's own <html lang>
|
|
319
|
+
// attribute still reflects that individual page's own language (see
|
|
320
|
+
// `page.language` below); only the *chrome strings surrounding it* are
|
|
321
|
+
// rendered in one consistent language, matching a reader's expectation
|
|
322
|
+
// that navigation/footer text does not flip language from page to page
|
|
323
|
+
// (task packet S2-T06 scoped decision; see `internal/messages.js`).
|
|
324
|
+
const siteMessages = getMessages(validatedInput.publication.defaultLanguage);
|
|
325
|
+
const homeRoute = joinBasePathAndRoute(validatedInput.basePath, '/');
|
|
326
|
+
|
|
327
|
+
const appearanceControlHtml = renderAppearanceControl({
|
|
328
|
+
messages: siteMessages,
|
|
329
|
+
});
|
|
330
|
+
const headerHtml = renderHeader({
|
|
331
|
+
homeRoute,
|
|
332
|
+
publicationName: validatedInput.publication.title,
|
|
333
|
+
logo: undefined,
|
|
334
|
+
appearanceControlHtml,
|
|
335
|
+
});
|
|
336
|
+
const footerNavHtml = renderFooterNavigation({
|
|
337
|
+
items: validatedInput.navigation.footerItems,
|
|
338
|
+
messages: siteMessages,
|
|
339
|
+
});
|
|
340
|
+
const footerCard = validatedInput.publication.footerCard;
|
|
341
|
+
// Already verified render-policy-conformant HTML by
|
|
342
|
+
// assertRenderPolicyCompliance's assertPolicyConformantHtml call above —
|
|
343
|
+
// inserted as-is, never re-parsed as Markdown.
|
|
344
|
+
const footerProfileHtml = footerCard?.enabled ? footerCard.body.body : '';
|
|
345
|
+
const footerHtml = renderFooter({
|
|
346
|
+
publicationName: validatedInput.publication.title,
|
|
347
|
+
footerNavHtml,
|
|
348
|
+
footerProfileHtml,
|
|
349
|
+
copyrightText: undefined,
|
|
350
|
+
messages: siteMessages,
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// Run before page assembly, not after (task packet S2-T08): resolving
|
|
354
|
+
// each page's own `og:image`/`twitter:image` needs the S2-T05 media
|
|
355
|
+
// pipeline's own finished `assets` list (only it knows which derivative
|
|
356
|
+
// file extension a given source image's `sourceDigest` produced — see
|
|
357
|
+
// `internal/seo.js`). This only moves the in-memory computation earlier;
|
|
358
|
+
// `mediaFiles`' actual bytes are still written to `outputDirectory` only
|
|
359
|
+
// after the route listing below (unchanged from S2-T05), so
|
|
360
|
+
// `outputDirectory/assets/media/` still can never be mistaken for an
|
|
361
|
+
// Eleventy-rendered HTML route.
|
|
362
|
+
const { assets: mediaAssets, files: mediaFiles } = await processMedia(
|
|
363
|
+
validatedInput,
|
|
364
|
+
{ sourceDirectory: path.resolve(options.sourceDirectory) },
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
// S2-T08: the site-wide feeds, resolved once so every page's feed-
|
|
368
|
+
// discovery `<link>` tags and the feed documents themselves agree on the
|
|
369
|
+
// exact same self URLs.
|
|
370
|
+
const feeds = buildFeeds(validatedInput);
|
|
371
|
+
const siteName = validatedInput.publication.title;
|
|
372
|
+
const appearanceScriptHrefValue = appearanceBootstrapScriptHref(
|
|
373
|
+
validatedInput.basePath,
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
// S2-T12: the selected theme package's copied stylesheets/passive assets
|
|
377
|
+
// and the ordered `<link>` markup every generated page's `<head>` inserts.
|
|
378
|
+
// Run before page assembly (same reasoning as the media pipeline above):
|
|
379
|
+
// every page's `data.themeStylesheetLinksHtml` needs the same one
|
|
380
|
+
// already-rendered fragment.
|
|
381
|
+
const themeAssetsResult = options.themeDirectory
|
|
382
|
+
? await loadThemeAssets({
|
|
383
|
+
themeDirectory: path.resolve(options.themeDirectory),
|
|
384
|
+
basePath: validatedInput.basePath,
|
|
385
|
+
})
|
|
386
|
+
: undefined;
|
|
387
|
+
const themeStylesheetLinksHtml = themeAssetsResult
|
|
388
|
+
? themeAssetsResult.linkTagsHtml
|
|
389
|
+
: defaultThemeStylesheetLinksHtml(validatedInput.basePath);
|
|
390
|
+
|
|
391
|
+
/** @type {{virtualPath: string, content: string, permalink: string, layout?: string, data?: Record<string, unknown>}[]} */
|
|
392
|
+
const contentPages = [];
|
|
393
|
+
/** @type {{joinedSource: string, joinedTarget: string, filePath: string}[]} */
|
|
394
|
+
const redirectProjections = [];
|
|
395
|
+
|
|
396
|
+
const generatedPages = buildGeneratedPages(validatedInput);
|
|
397
|
+
generatedPages.forEach((page, index) => {
|
|
398
|
+
const joinedRoute = joinBasePathAndRoute(
|
|
399
|
+
validatedInput.basePath,
|
|
400
|
+
page.route,
|
|
401
|
+
);
|
|
402
|
+
const navHtml = renderPrimaryNavigation({
|
|
403
|
+
items: validatedInput.navigation.items,
|
|
404
|
+
currentRoute: joinedRoute,
|
|
405
|
+
messages: siteMessages,
|
|
406
|
+
});
|
|
407
|
+
const bodyHtml = renderPageBody({
|
|
408
|
+
messages: siteMessages,
|
|
409
|
+
headerHtml,
|
|
410
|
+
navHtml,
|
|
411
|
+
footerHtml,
|
|
412
|
+
breadcrumbHtml: page.breadcrumbHtml,
|
|
413
|
+
mainHtml: page.bodyHtml,
|
|
414
|
+
});
|
|
415
|
+
const ogImage = resolveSocialImageUrl({
|
|
416
|
+
mediaAssets,
|
|
417
|
+
reference: page.socialImageRef,
|
|
418
|
+
baseUrl: validatedInput.baseUrl,
|
|
419
|
+
basePath: validatedInput.basePath,
|
|
420
|
+
});
|
|
421
|
+
contentPages.push({
|
|
422
|
+
virtualPath: `pages/${page.kind}-${index}.html`,
|
|
423
|
+
content: bodyHtml,
|
|
424
|
+
permalink: projectRouteToFilePath(joinedRoute, routeProfile),
|
|
425
|
+
layout: 'skeleton.njk',
|
|
426
|
+
data: {
|
|
427
|
+
title: page.title,
|
|
428
|
+
lang: page.language,
|
|
429
|
+
dir: page.direction,
|
|
430
|
+
pageKind: page.kind,
|
|
431
|
+
description: page.description,
|
|
432
|
+
robotsContent: page.robotsContent,
|
|
433
|
+
canonicalUrl: new URL(joinedRoute, validatedInput.baseUrl).toString(),
|
|
434
|
+
siteName,
|
|
435
|
+
ogType: page.ogType ?? 'website',
|
|
436
|
+
ogImage,
|
|
437
|
+
twitterCard: twitterCardType(Boolean(ogImage)),
|
|
438
|
+
atomFeedUrl: feeds.atomSelfUrl,
|
|
439
|
+
rssFeedUrl: feeds.rssSelfUrl,
|
|
440
|
+
appearanceScriptHref: appearanceScriptHrefValue,
|
|
441
|
+
themeStylesheetLinksHtml,
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
for (const record of validatedInput.content) {
|
|
447
|
+
const joined = joinBasePathAndRoute(
|
|
448
|
+
validatedInput.basePath,
|
|
449
|
+
contentRoute(record.frontmatter),
|
|
450
|
+
);
|
|
451
|
+
for (const sourceRoute of record.frontmatter.redirects ?? []) {
|
|
452
|
+
const joinedSource = joinBasePathAndRoute(
|
|
453
|
+
validatedInput.basePath,
|
|
454
|
+
sourceRoute,
|
|
455
|
+
);
|
|
456
|
+
redirectProjections.push({
|
|
457
|
+
joinedSource,
|
|
458
|
+
joinedTarget: joined,
|
|
459
|
+
filePath: projectRouteToFilePath(joinedSource, routeProfile),
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const redirectPages = redirectProjections.map((projection, index) => ({
|
|
465
|
+
virtualPath: `redirects/${index}.html`,
|
|
466
|
+
content: renderRedirectDocument(projection.joinedTarget),
|
|
467
|
+
permalink: projection.filePath,
|
|
468
|
+
}));
|
|
469
|
+
|
|
470
|
+
if (contentPages.length === 0 && redirectPages.length === 0) {
|
|
471
|
+
throw new RenderOptionsError(
|
|
472
|
+
'build input selects no publication profile and no content; there is nothing to render',
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// S2-T08: the one ordinary generated `404.html` error page, using the
|
|
477
|
+
// S2-T06 `error` page kind's own pure body function. Pushed after the
|
|
478
|
+
// guard above so the guard's own "is there anything authored to render"
|
|
479
|
+
// check is unaffected by this always-present synthetic page.
|
|
480
|
+
const errorNavHtml = renderPrimaryNavigation({
|
|
481
|
+
items: validatedInput.navigation.items,
|
|
482
|
+
currentRoute: undefined,
|
|
483
|
+
messages: siteMessages,
|
|
484
|
+
});
|
|
485
|
+
const errorBodyHtml = renderPageBody({
|
|
486
|
+
messages: siteMessages,
|
|
487
|
+
headerHtml,
|
|
488
|
+
navHtml: errorNavHtml,
|
|
489
|
+
footerHtml,
|
|
490
|
+
mainHtml: renderErrorPageBody({ messages: siteMessages, homeRoute }),
|
|
491
|
+
});
|
|
492
|
+
const errorFilePath = errorDocumentPath(validatedInput.basePath);
|
|
493
|
+
contentPages.push({
|
|
494
|
+
virtualPath: 'pages/error-404.html',
|
|
495
|
+
content: errorBodyHtml,
|
|
496
|
+
permalink: errorFilePath,
|
|
497
|
+
layout: 'skeleton.njk',
|
|
498
|
+
data: {
|
|
499
|
+
title: /** @type {string} */ (siteMessages.errorPageHeading),
|
|
500
|
+
lang: validatedInput.publication.defaultLanguage,
|
|
501
|
+
dir: resolveTextDirection(validatedInput.publication.defaultLanguage),
|
|
502
|
+
pageKind: 'error',
|
|
503
|
+
// No `canonicalUrl`/`ogImage`: an error page has no real resource to
|
|
504
|
+
// canonicalize or illustrate.
|
|
505
|
+
robotsContent: 'noindex, follow',
|
|
506
|
+
siteName,
|
|
507
|
+
ogType: 'website',
|
|
508
|
+
twitterCard: twitterCardType(false),
|
|
509
|
+
atomFeedUrl: feeds.atomSelfUrl,
|
|
510
|
+
rssFeedUrl: feeds.rssSelfUrl,
|
|
511
|
+
appearanceScriptHref: appearanceScriptHrefValue,
|
|
512
|
+
themeStylesheetLinksHtml,
|
|
513
|
+
},
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// S2-T08: feeds, sitemap and the static search index. Each is a fixed,
|
|
517
|
+
// literal-extension file — never route-normalized like an ordinary
|
|
518
|
+
// content route (`internal/route.js#projectFixedAssetPath`) — and each
|
|
519
|
+
// bypasses the shared HTML skeleton layout entirely (no `layout` field,
|
|
520
|
+
// exactly like a generated redirect document above): these are raw
|
|
521
|
+
// XML/JSON bytes, not HTML pages.
|
|
522
|
+
const atomFilePath = projectFixedAssetPath(
|
|
523
|
+
validatedInput.basePath,
|
|
524
|
+
ATOM_FEED_ROUTE,
|
|
525
|
+
);
|
|
526
|
+
const rssFilePath = projectFixedAssetPath(
|
|
527
|
+
validatedInput.basePath,
|
|
528
|
+
RSS_FEED_ROUTE,
|
|
529
|
+
);
|
|
530
|
+
const sitemapFilePath = projectFixedAssetPath(
|
|
531
|
+
validatedInput.basePath,
|
|
532
|
+
SITEMAP_ROUTE,
|
|
533
|
+
);
|
|
534
|
+
const searchIndexFilePath = projectFixedAssetPath(
|
|
535
|
+
validatedInput.basePath,
|
|
536
|
+
SEARCH_INDEX_ROUTE,
|
|
537
|
+
);
|
|
538
|
+
const sitemapXml = buildSitemap({
|
|
539
|
+
generatedPages,
|
|
540
|
+
basePath: validatedInput.basePath,
|
|
541
|
+
baseUrl: validatedInput.baseUrl,
|
|
542
|
+
});
|
|
543
|
+
const searchIndexJson = buildSearchIndex(validatedInput);
|
|
544
|
+
const assetPages = [
|
|
545
|
+
{
|
|
546
|
+
virtualPath: 'feeds/atom.html',
|
|
547
|
+
content: feeds.atomXml,
|
|
548
|
+
permalink: atomFilePath,
|
|
549
|
+
},
|
|
550
|
+
{
|
|
551
|
+
virtualPath: 'feeds/rss.html',
|
|
552
|
+
content: feeds.rssXml,
|
|
553
|
+
permalink: rssFilePath,
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
virtualPath: 'sitemap.html',
|
|
557
|
+
content: sitemapXml,
|
|
558
|
+
permalink: sitemapFilePath,
|
|
559
|
+
},
|
|
560
|
+
{
|
|
561
|
+
virtualPath: 'search-index.html',
|
|
562
|
+
content: searchIndexJson,
|
|
563
|
+
permalink: searchIndexFilePath,
|
|
564
|
+
},
|
|
565
|
+
];
|
|
566
|
+
|
|
567
|
+
await renderPagesWithEleventy({
|
|
568
|
+
pages: [...contentPages, ...redirectPages, ...assetPages],
|
|
569
|
+
inputDirectory: path.join(workDirectory, 'input'),
|
|
570
|
+
outputDirectory,
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
// S2-T08: the exact `routeClass`/`mediaType` this renderer's own generated
|
|
574
|
+
// non-HTML routes carry, keyed by their fixed output path. Every other
|
|
575
|
+
// route defaults to `'html'`/`text/html; charset=utf-8`, unchanged from
|
|
576
|
+
// S2-T03.
|
|
577
|
+
/** @type {Map<string, {routeClass: import('../../types/index.d.ts').ManifestRouteEntry['routeClass'], mediaType: string}>} */
|
|
578
|
+
const specialRoutes = new Map([
|
|
579
|
+
[
|
|
580
|
+
errorFilePath,
|
|
581
|
+
{ routeClass: 'error', mediaType: 'text/html; charset=utf-8' },
|
|
582
|
+
],
|
|
583
|
+
[
|
|
584
|
+
atomFilePath,
|
|
585
|
+
{ routeClass: 'feed', mediaType: 'application/atom+xml; charset=utf-8' },
|
|
586
|
+
],
|
|
587
|
+
[
|
|
588
|
+
rssFilePath,
|
|
589
|
+
{ routeClass: 'feed', mediaType: 'application/rss+xml; charset=utf-8' },
|
|
590
|
+
],
|
|
591
|
+
[
|
|
592
|
+
sitemapFilePath,
|
|
593
|
+
{ routeClass: 'sitemap', mediaType: 'application/xml; charset=utf-8' },
|
|
594
|
+
],
|
|
595
|
+
[
|
|
596
|
+
searchIndexFilePath,
|
|
597
|
+
{ routeClass: 'asset', mediaType: 'application/json; charset=utf-8' },
|
|
598
|
+
],
|
|
599
|
+
]);
|
|
600
|
+
|
|
601
|
+
const outputFilePaths = await listFilesSortedByUtf8Bytes(outputDirectory);
|
|
602
|
+
/** @type {import('../../types/index.d.ts').ManifestRouteEntry[]} */
|
|
603
|
+
const routes = [];
|
|
604
|
+
for (const filePath of outputFilePaths) {
|
|
605
|
+
const bytes = await readFile(path.join(outputDirectory, filePath));
|
|
606
|
+
const special = specialRoutes.get(filePath);
|
|
607
|
+
routes.push({
|
|
608
|
+
path: filePath,
|
|
609
|
+
mediaType: special?.mediaType ?? 'text/html; charset=utf-8',
|
|
610
|
+
byteLength: String(bytes.byteLength),
|
|
611
|
+
sha256: digestBytes(bytes),
|
|
612
|
+
routeClass: special?.routeClass ?? 'html',
|
|
613
|
+
interactionBearing: false,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
routes.sort((a, b) => compareUtf8Bytes(a.path, b.path));
|
|
617
|
+
|
|
618
|
+
const routeByPath = new Map(routes.map((route) => [route.path, route]));
|
|
619
|
+
const redirects = redirectProjections
|
|
620
|
+
.map((projection) => {
|
|
621
|
+
const backingRoute = routeByPath.get(projection.filePath);
|
|
622
|
+
if (!backingRoute) {
|
|
623
|
+
throw new RenderOptionsError(
|
|
624
|
+
`internal: no rendered file at redirect backing path ${projection.filePath}`,
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
return /** @type {import('../../types/index.d.ts').ManifestRedirectEntry} */ ({
|
|
628
|
+
sourceRoute: projection.joinedSource,
|
|
629
|
+
targetRoute: projection.joinedTarget,
|
|
630
|
+
status: /** @type {200} */ (200),
|
|
631
|
+
backingPath: projection.filePath,
|
|
632
|
+
sha256: backingRoute.sha256,
|
|
633
|
+
});
|
|
634
|
+
})
|
|
635
|
+
.sort((a, b) => compareUtf8Bytes(a.sourceRoute, b.sourceRoute));
|
|
636
|
+
|
|
637
|
+
// The media pipeline itself ran earlier (see `mediaAssets` above, needed
|
|
638
|
+
// for SEO image resolution, which matches against these same *unprefixed*
|
|
639
|
+
// paths); only its actual output *bytes* are written here, after the
|
|
640
|
+
// routes listing above and never before, so
|
|
641
|
+
// `outputDirectory/<basePath>/assets/media/` still can never be mistaken
|
|
642
|
+
// for an Eleventy-rendered HTML route.
|
|
643
|
+
//
|
|
644
|
+
// basePath fix (independent-review finding B2, LOCAL-7 follow-up): the
|
|
645
|
+
// physical file and its manifest `path` are now joined with `basePath`
|
|
646
|
+
// through the same `projectFixedAssetPath` every other fixed-name
|
|
647
|
+
// generated asset (404, feeds, sitemap, search index) already uses, so
|
|
648
|
+
// the local-directory adapter — which serves a manifest `path` verbatim —
|
|
649
|
+
// finds this file at exactly the `<basePath>`-prefixed location
|
|
650
|
+
// `internal/seo.js`'s already-`basePath`-joined `og:image`/`twitter:image`
|
|
651
|
+
// href points at.
|
|
652
|
+
const joinedMediaAssets = mediaAssets.map((asset) => ({
|
|
653
|
+
...asset,
|
|
654
|
+
path: projectFixedAssetPath(validatedInput.basePath, `/${asset.path}`),
|
|
655
|
+
}));
|
|
656
|
+
for (const file of mediaFiles) {
|
|
657
|
+
const joinedPath = projectFixedAssetPath(
|
|
658
|
+
validatedInput.basePath,
|
|
659
|
+
`/${file.path}`,
|
|
660
|
+
);
|
|
661
|
+
const destination = path.join(outputDirectory, joinedPath);
|
|
662
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
663
|
+
await writeFile(destination, file.bytes);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// S2-T12: the selected theme package's stylesheet/passive-asset bytes,
|
|
667
|
+
// written for the same reason and at the same point as the media pipeline
|
|
668
|
+
// output immediately above (`themeAssetsResult` is `undefined` when no
|
|
669
|
+
// `options.themeDirectory` was supplied, in which case there is nothing to
|
|
670
|
+
// write — the fallback `defaultThemeStylesheetLinksHtml` link above points
|
|
671
|
+
// at a file this renderer does not itself produce).
|
|
672
|
+
if (themeAssetsResult) {
|
|
673
|
+
for (const file of themeAssetsResult.files) {
|
|
674
|
+
const destination = path.join(outputDirectory, file.path);
|
|
675
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
676
|
+
await writeFile(destination, file.bytes);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// S2-T07's one deterministic pre-paint appearance-controller bootstrap
|
|
681
|
+
// script (brief S2 section 3: "the appearance controller is the only
|
|
682
|
+
// browser bootstrap"). Written after the routes listing above for the same
|
|
683
|
+
// reason as the media pipeline output immediately above: it must never be
|
|
684
|
+
// mistaken for an Eleventy-rendered HTML route. Its bytes are a pure
|
|
685
|
+
// function of `internal/appearance/contract.js`'s own fixed constants, so
|
|
686
|
+
// this asset row is identical on every build regardless of build input.
|
|
687
|
+
const appearanceScriptBytes = Buffer.from(
|
|
688
|
+
APPEARANCE_BOOTSTRAP_SCRIPT_SOURCE,
|
|
689
|
+
'utf8',
|
|
690
|
+
);
|
|
691
|
+
// basePath fix (independent-review finding B2): joined the exact same way
|
|
692
|
+
// `appearanceScriptHrefValue` above was, so the `<script src>` this
|
|
693
|
+
// renderer emits and the physical file/manifest path it writes can never
|
|
694
|
+
// drift apart.
|
|
695
|
+
const joinedAppearanceScriptPath = projectFixedAssetPath(
|
|
696
|
+
validatedInput.basePath,
|
|
697
|
+
`/${APPEARANCE_BOOTSTRAP_SCRIPT_PATH}`,
|
|
698
|
+
);
|
|
699
|
+
const appearanceScriptDestination = path.join(
|
|
700
|
+
outputDirectory,
|
|
701
|
+
joinedAppearanceScriptPath,
|
|
702
|
+
);
|
|
703
|
+
await mkdir(path.dirname(appearanceScriptDestination), { recursive: true });
|
|
704
|
+
await writeFile(appearanceScriptDestination, appearanceScriptBytes);
|
|
705
|
+
/** @type {import('../../types/index.d.ts').ManifestAssetEntry} */
|
|
706
|
+
const appearanceScriptAsset = {
|
|
707
|
+
path: joinedAppearanceScriptPath,
|
|
708
|
+
mediaType: APPEARANCE_BOOTSTRAP_SCRIPT_MEDIA_TYPE,
|
|
709
|
+
byteLength: String(appearanceScriptBytes.byteLength),
|
|
710
|
+
sha256: digestBytes(appearanceScriptBytes),
|
|
711
|
+
// Not content-addressed (a fixed, non-digest-bearing path), unlike the
|
|
712
|
+
// media pipeline's derivative paths above: `immutable` here documents
|
|
713
|
+
// cacheability, not path-collision safety.
|
|
714
|
+
immutable: false,
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
const assets = [
|
|
718
|
+
...joinedMediaAssets,
|
|
719
|
+
appearanceScriptAsset,
|
|
720
|
+
...(themeAssetsResult ? themeAssetsResult.assets : []),
|
|
721
|
+
].sort((a, b) => compareUtf8Bytes(a.path, b.path));
|
|
722
|
+
|
|
723
|
+
const includedSources = collectIncludedSources(validatedInput);
|
|
724
|
+
|
|
725
|
+
const manifest = buildArtifactManifest({
|
|
726
|
+
buildInput: validatedInput,
|
|
727
|
+
routes,
|
|
728
|
+
assets,
|
|
729
|
+
redirects,
|
|
730
|
+
includedSources,
|
|
731
|
+
excludedInputs: options.provenance.additionalExcludedInputs ?? [],
|
|
732
|
+
provenance: options.provenance,
|
|
733
|
+
generateArtifactId: generateUuidV7,
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
const manifestValidation = validateGalaDocument(
|
|
737
|
+
ARTIFACT_MANIFEST_SCHEMA_ID,
|
|
738
|
+
manifest,
|
|
739
|
+
);
|
|
740
|
+
if (!manifestValidation.valid) {
|
|
741
|
+
throw new ArtifactManifestValidationError(manifestValidation.diagnostics);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
return { outputDirectory, manifest };
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
export {
|
|
748
|
+
BuildInputValidationError,
|
|
749
|
+
RenderOptionsError,
|
|
750
|
+
ArtifactManifestValidationError,
|
|
751
|
+
MediaPipelineError,
|
|
752
|
+
RenderPolicyViolationError,
|
|
753
|
+
} from './errors.js';
|
|
754
|
+
|
|
755
|
+
export {
|
|
756
|
+
computeBodyDigest,
|
|
757
|
+
normalizeAuthoredMarkdown,
|
|
758
|
+
} from './internal/content-security.js';
|