@rathnasgala2/template 3.1.0 → 3.2.1

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/README.md CHANGED
@@ -35,10 +35,11 @@ mistaken for an HTML route:
35
35
 
36
36
  - **Bounded decode.** JPEG (via the pinned pure-JS `jpeg-js` decoder, no native
37
37
  addon or WebAssembly) and PNG (hand-written, using Node's built-in `zlib` with
38
- a decompression-bomb-bounded inflate) are fully decoded to RGBA8; WebP and
39
- AVIF get bounded container/declared-dimension validation without a full
40
- entropy decode (a documented scope decision — see
41
- `webp-probe.js`/`avif-probe.js`). Every format shares one set of resource
38
+ a decompression-bomb-bounded inflate) are fully decoded to RGBA8; WebP, AVIF
39
+ and GIF get bounded container/declared-dimension validation without a full
40
+ entropy decode, and their original bytes pass through unmodified (a documented
41
+ scope decision — see `webp-probe.js`/`avif-probe.js`/ `gif-probe.js`; an
42
+ animated GIF keeps its animation). Every format shares one set of resource
42
43
  ceilings in `limits.js` (raster dimension, pixel count, decoded-buffer and
43
44
  source-byte caps reused from the theme package's own closed binary-asset
44
45
  decoder bounds, plus this renderer's own documented per-publication ceilings).
@@ -61,13 +62,86 @@ mistaken for an HTML route:
61
62
  structurally.
62
63
  - **Fail-closed errors.** Every rejection — oversize, decompression bomb, digest
63
64
  mismatch, SVG, unrecognized format, invalid font — is a `MediaPipelineError`
64
- with a stable `reasonCode`, thrown before the manifest is assembled.
65
+ with a stable `reasonCode`, thrown before anything is written.
65
66
 
66
67
  `options.sourceDirectory` is the caller-mounted, read-only repository source
67
68
  tree `build-input`'s `resolvedFile` references point into; every referenced
68
69
  file's bytes are read from there and verified against `build-input`'s declared
69
70
  `sourceDigest` before being decoded.
70
71
 
72
+ ### Content images
73
+
74
+ Every `<img>` in a rendered body must name an image file `build-input`
75
+ inventories for that document in `content[].media[]` (an entry whose `mediaType`
76
+ is `image/png`, `image/jpeg`, `image/webp`, `image/avif` or `image/gif`). The
77
+ `src` is resolved by one rule (`src/core/internal/media/content-images.js`): it
78
+ is entity-decoded; a scheme, a protocol-relative `//`, a query or a fragment
79
+ never resolves; one leading `/` or `./` is dropped (paths are
80
+ repository-root-relative); the rest is percent-decoded and NFC-normalized; an
81
+ empty, `.` or `..` segment never resolves; the result must equal an inventoried
82
+ `path`. The publication profile and the footer card (checked only when enabled,
83
+ since only then is it rendered) have no inventory, so an image there never
84
+ resolves.
85
+
86
+ An image that resolves to nothing (a remote image included: the render policy
87
+ already strips its `src`) fails the build with `BuildInputValidationError`, one
88
+ `MEDIA_REFERENCE_UNRESOLVED` diagnostic per image (`instancePointer` names the
89
+ body, `reference` the `src`), before anything is written. An inventoried file
90
+ the media pipeline rejects fails with that `MediaPipelineError` reason code,
91
+ also before anything is written.
92
+
93
+ A resolved image runs through the media pipeline and renders as
94
+ `<img src srcset sizes="(max-width: 960px) 100vw, 960px" alt width height loading="lazy" decoding="async">`:
95
+ `srcset` names every output file in ascending width (PNG/JPEG: each fixed
96
+ derivative width below the source plus the re-encoded original; GIF, WebP and
97
+ AVIF: the original only), and `src` is the widest of them no wider than 960
98
+ pixels, or the original when none is. `alt` is the authored text. The feeds
99
+ carry the same `src` as an absolute URL, without `srcset`. Only pipeline output
100
+ is written: the source file is never copied, and identical bytes under two paths
101
+ are processed and written once. Images use the base layer's existing `img`
102
+ rules; no class is added. The front-matter `hero` renders as the article's cover
103
+ `<figure>` above the body (`role: "decorative"` renders `alt=""`).
104
+
105
+ ### Editions
106
+
107
+ A document of `kind: "edition"` restates one article at another depth. Its front
108
+ matter's `edition` object names the article (`of`, the article's slug) and the
109
+ depth (`kind`: `QUICK_READ`, `STANDARD` or `DEEP_DIVE`), and `build-input`
110
+ carries it as an ordinary `content[]` record. Whether an edition is current is
111
+ decided before the build input exists, so an edition that arrives is rendered as
112
+ given. An edition is a content kind, not a module: the renderer runs no
113
+ generation and has no Prism runtime.
114
+
115
+ - **Routes.** `/<slug>/quick-read`, `/<slug>/standard` and `/<slug>/deep-dive`,
116
+ where `<slug>` is `edition.of` (written as `…/index.html` like every route).
117
+ The page's `data-gala-page-kind` is `article`, so the published page-kind
118
+ hooks do not change. Reactions and comments stay on the article; an edition
119
+ page carries none.
120
+ - **The article stays the reference.** `<link rel="canonical">` and `og:url`
121
+ name the article, the robots directive is `noindex, follow`, and the
122
+ structured data is a `WebPage` with a breadcrumb back to the article.
123
+ - **Never listed.** An edition is in no index, tag, series, archive or author
124
+ listing, feed, `sitemap.xml`, `search-index.json`, `llms.txt` or
125
+ `llms-full.txt`, and it does not move the footer's copyright year.
126
+ - **Selector.** The article and each of its editions carry one
127
+ `<nav aria-label="Editions">` of links, in the `edition-selector` slot at the
128
+ end of the article head: Original, Quick read, Standard, Deep dive in that
129
+ order, only the kinds that exist, the page being read marked
130
+ `aria-current="page"`. An `unlisted` edition is not listed, except on its own
131
+ page. An edition page adds the line "Generated edition, reviewed by the
132
+ author; the original is the reference". The links are plain anchors; nothing
133
+ needs JavaScript. The strings come from the message catalog, and the markup
134
+ reuses the base layer's `g-pills`, `g-pill` and `g-label` hooks, so the
135
+ published theme styling contract does not change.
136
+ - **Cover.** An edition with no `hero` or social image of its own uses its
137
+ article's.
138
+ - **Fails closed.** Before anything is written, `BuildInputValidationError`
139
+ carries one diagnostic per edition that cannot be placed:
140
+ `EDITION_ORIGINAL_UNRESOLVED` (`edition.of` names no article of the build),
141
+ `EDITION_DUPLICATE` (two editions of one article at one depth) and
142
+ `EDITION_ROUTE_CONFLICT` (the route is already another document's route or
143
+ redirect).
144
+
71
145
  The complete output-security pipeline (`src/core/internal/content-security.js`)
72
146
  sits on top of that adapter. **Division of labour** (normalization replaces an
73
147
  authored body path with a `renderableBody` carrying "the deterministic policy
@@ -153,23 +227,29 @@ output-security pipeline every generated page still passes through:
153
227
  `header-actions` slot), one primary `<nav>` from `navigationNormalized.items`,
154
228
  one `<main id="main-content">` (an optional breadcrumb `<nav>` before the
155
229
  page's own single `<h1>`), and one `<footer>` (publication name, footer
156
- navigation, the `footer-profile` slot, the remaining core slots, and the
157
- template attribution line). Core owns nine versioned semantic slots
158
- (`header-actions`, `article-preamble`, `article-end`, `footer-profile`,
159
- `footer-auxiliary`, `account-intent`, `conversation`, `newsletter`,
160
- `edition-selector`) plus the collapsed `article-footer-ad` slot: every slot is
161
- one `data-gala-slot="<name>"` attribute on an otherwise empty, non-landmark
162
- `<div>`, and the collapsed slot additionally carries `hidden`. This renderer
163
- has no module system, so every slot except `footer-profile` (which always
164
- wraps the publication's own footer card) and `header-actions` (the appearance
165
- control, core content rather than a module) renders empty. This module also
166
- renders breadcrumbs and deterministic pagination controls
167
- (`rel="prev"`/`rel="next"`, an `aria-current="page"` status).
230
+ navigation, the `footer-profile` slot, the remaining footer slots
231
+ (`footer-auxiliary`, `account-intent`, `conversation`, `newsletter`), and the
232
+ "Made with Galascribe" attribution line, which
233
+ `appearance.attribution.showMadeWith: false` omits). Core owns nine versioned
234
+ semantic slots (`header-actions`, `article-preamble`, `article-end`,
235
+ `footer-profile`, `footer-auxiliary`, `account-intent`, `conversation`,
236
+ `newsletter`, `edition-selector`) plus the collapsed `article-footer-ad` slot:
237
+ every slot is one `data-gala-slot="<name>"` attribute on an otherwise empty,
238
+ non-landmark `<div>`, and the collapsed slot additionally carries `hidden`.
239
+ This renderer has no module system, so every slot except `footer-profile`
240
+ (which always wraps the publication's own footer card), `header-actions` (the
241
+ appearance control, core content rather than a module) and `edition-selector`
242
+ (see Editions) renders empty. `edition-selector` is rendered by the article
243
+ page kind at the end of the article head, only where there is an edition to
244
+ offer, not in the footer. This module also renders breadcrumbs and
245
+ deterministic pagination controls (`rel="prev"`/`rel="next"`, an
246
+ `aria-current="page"` status).
168
247
  - **`src/core/internal/page-kinds.js`** builds every generated page kind from a
169
248
  validated `build-input:2.0.0`: `profile` (the optional publication profile),
170
249
  `author` (one page per `build-input.authors` entry, at `/authors/<id>`),
171
- `article`/`page` (one page per `content[]` record, at its own route), `index`
172
- (a paginated reverse-chronological listing of every `status: "published"`,
250
+ `article`/`page` (one page per `content[]` record, at its own route; a
251
+ `kind: "edition"` record renders beside its article, see Editions), `index` (a
252
+ paginated reverse-chronological listing of every `status: "published"`,
173
253
  `kind: "article"` record — an `unlisted` record is reachable only by its own
174
254
  direct route, never through a generated listing), `tag`/`series` (one listing
175
255
  page per distinct authored value, plus a `/tags`/`/series` root page linking
@@ -424,6 +504,10 @@ numbers:
424
504
  - decode/resource ceilings reuse the theme binary-asset profile's numbers
425
505
  (dimension, pixel-count, decoded-buffer, source-byte caps), since the
426
506
  decode-bomb risk they defend against is identical regardless of asset class;
507
+ the JPEG decoder's own memory knob counts its whole working set (coefficient
508
+ and line buffers as well as the RGBA output), so it is sized for an image at
509
+ the pixel ceiling at 32 bytes per pixel (512 MiB) rather than to the RGBA
510
+ output alone, which rejected every JPEG above about three to five megapixels;
427
511
  - responsive derivative widths (`320/640/960/1280/1920`), JPEG derivative
428
512
  quality (`82`), and the per-publication image/font count and aggregate byte
429
513
  ceilings are this renderer's own conservative choices;
@@ -431,7 +515,12 @@ numbers:
431
515
  VP8/VP8L/AV1 entropy decode or derivative generation — hand-authoring an
432
516
  independently verifiable decoder for either format was judged out of
433
517
  proportion to this task, and is flagged as a follow-up rather than attempted
434
- partially.
518
+ partially;
519
+ - GIF gets a bounded block-structure walk (signature, logical screen and every
520
+ frame within the dimension ceilings, every sub-block chain inside the file, at
521
+ least one image, the trailer as the last byte) and passes through unmodified:
522
+ this renderer carries no GIF encoder, so an animation is never resized, and
523
+ any comment or application extension is served as authored.
435
524
 
436
525
  Every remaining "Required generated output" from this renderer's own governing
437
526
  brief sits on top of the page kinds above:
@@ -440,15 +529,15 @@ brief sits on top of the page kinds above:
440
529
  (`/feed/atom.xml`) and RSS 2.0 (`/feed/rss.xml`) documents over the exact
441
530
  reverse-chronological `status: "published"`, `kind: "article"` selection
442
531
  `internal/page-kinds.js`'s `index` page kind paginates (the shared
443
- `selectPublishedArticles` export), every item URL absolute against
444
- `buildInput.baseUrl`, bounded to the most recent 50 entries (documented scoped
445
- decision — neither the brief nor this renderer's own styling decision fixes a
446
- feed item count).
532
+ `selectPublishedArticles` export; editions are never in it), every item URL
533
+ absolute against `buildInput.baseUrl`, bounded to the most recent 50 entries
534
+ (documented scoped decision — neither the brief nor this renderer's own
535
+ styling decision fixes a feed item count).
447
536
  - **Sitemap** (`src/core/internal/sitemap.js`): `/sitemap.xml` over every page
448
537
  `internal/page-kinds.js` generated that this renderer did not itself mark
449
- `noindex` (unlisted content), deterministically sorted by each entry's own
450
- absolute URL, with `<lastmod>` sourced from each page's own authored
451
- `publishedAt`/`updatedAt` — never build time.
538
+ `noindex` (unlisted content and editions), deterministically sorted by each
539
+ entry's own absolute URL, with `<lastmod>` sourced from each page's own
540
+ authored `publishedAt`/`updatedAt` — never build time.
452
541
  - **Static search index** (`src/core/internal/search-index.js`):
453
542
  `/search-index.json`, a deterministic JSON document indexing every published
454
543
  `article`/`page` record's title, description, a bounded plain-text excerpt,
@@ -459,13 +548,14 @@ brief sits on top of the page kinds above:
459
548
  C).
460
549
  - **SEO/Open-Graph/Twitter/localization metadata**: every generated page's
461
550
  `<head>` now carries a viewport meta, an optional description, an optional
462
- `robots` directive (`noindex, follow` for `status: "unlisted"` content and for
463
- the generated `404.html`), a canonical link, `og:*`/ `twitter:*` meta tags
464
- (image resolved to an absolute content-addressed media derivative URL via
465
- `internal/seo.js`, never the raw source path) and the Atom/RSS feed-discovery
466
- `<link>`s. **hreflang** is a documented scope decision, not a silent omission:
467
- `build-input:2.0.0` has no translation/locale-variant linkage between distinct
468
- `content[]` records (see `internal/seo.js`'s module documentation), so no
551
+ `robots` directive (`noindex, follow` for `status: "unlisted"` content, for
552
+ editions and for the generated `404.html`), a canonical link, `og:*`/
553
+ `twitter:*` meta tags (image resolved to an absolute content-addressed media
554
+ derivative URL via `internal/seo.js`, never the raw source path) and the
555
+ Atom/RSS feed-discovery `<link>`s. **hreflang** is a documented scope
556
+ decision, not a silent omission: `build-input:2.0.0` has no
557
+ translation/locale-variant linkage between distinct `content[]` records (see
558
+ `internal/seo.js`'s module documentation), so no
469
559
  `<link rel="alternate" hreflang="...">` is ever emitted by this renderer.
470
560
  - **Search and assistant discovery** (3.0.0, unpublished). Every page carries
471
561
  one `<script type="application/ld+json">` data block
@@ -481,9 +571,9 @@ brief sits on top of the page kinds above:
481
571
  `width` and `height` from the media pipeline; the article cover and the home
482
572
  hero are `fetchpriority="high"`, all other images
483
573
  `loading="lazy" decoding="async"`. Feeds carry the full sanitized body
484
- (`content:encoded` in RSS, `<content type="html">` in Atom), authors and
485
- categories. Author pages live at `/authors/<name-slug>` (the id when two names
486
- collide).
574
+ (`content:encoded` in RSS, `<content type="html">` in Atom; each image as its
575
+ absolute media-derivative URL), authors and categories. Author pages live at
576
+ `/authors/<name-slug>` (the id when two names collide).
487
577
  - **`robots.txt`, `llms.txt`, `llms-full.txt`** (`internal/robots.js`,
488
578
  `internal/llms.js`). `robots.txt` allows all crawlers and names the absolute
489
579
  sitemap; `publication.crawlers.ai: "block"` (validated by the build-input
@@ -493,7 +583,7 @@ brief sits on top of the page kinds above:
493
583
  a GitHub project site) must have the same file at its host root. `llms.txt`
494
584
  follows llmstxt.org (`# title`, `> description`, `## Articles`, `## Series`,
495
585
  `## Optional`); `llms-full.txt` is every published article as plain text.
496
- Unlisted content is excluded from both.
586
+ Unlisted content and editions are excluded from both.
497
587
  - **`404.html`**: the `error` page kind's pure `renderErrorPageBody`, now
498
588
  actually wired into `renderPublication` at the fixed `errorDocumentKey`
499
589
  (`internal/route.js#errorDocumentPath`) — `404.html` at the root, or
@@ -580,14 +670,17 @@ contracts/theme-styling-contract.jcs # published styling catalog (real and c
580
670
  read from disk and emitted as `assets/gala-interactions-v1.js` when
581
671
  `build-input.modules.interactions` is present.
582
672
  `test/module-tree-absence.test.js`, `.dependency-cruiser.cjs` and the ESLint
583
- `no-restricted-imports` rule enforce this. `whitelabel`, `newsletter` and
584
- `prism` have no module directory, package, configuration schema, stub, output or
585
- runtime code in this MVP.
673
+ `no-restricted-imports` rule enforce this. `newsletter` and `prism` have no
674
+ module directory, package, configuration schema, stub, output or runtime code in
675
+ this MVP. Whitelabel is not a module either: the footer's "Made with Galascribe"
676
+ line is a core appearance switch, `build-input.appearance.attribution`
677
+ (`showMadeWith: false` omits the line; absent or `true` shows it). Nor are
678
+ editions: they are a rendered content kind (see Editions).
586
679
 
587
680
  ## Consuming `@rathnasgala2/schemas`
588
681
 
589
- This repository consumes `@rathnasgala2/schemas@3.1.0` from the public npm
590
- registry, declared as `"@rathnasgala2/schemas": "3.1.0"` (exact pin, no range)
682
+ This repository consumes `@rathnasgala2/schemas@3.3.0` from the public npm
683
+ registry, declared as `"@rathnasgala2/schemas": "3.3.0"` (exact pin, no range)
591
684
  so `package-lock.json` records the resolved version and integrity hash. The
592
685
  LOCAL-1/LOCAL-39 local-tarball convention
593
686
  (`file:../../local-packages/rathnasgala2-schemas-*.tgz`) is retired for this
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rathnasgala2/template",
3
- "version": "3.1.0",
3
+ "version": "3.2.1",
4
4
  "description": "Deterministic Eleventy-backed static publication renderer for Galascribe portable repositories.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -72,7 +72,7 @@
72
72
  "dependencies": {
73
73
  "@11ty/eleventy": "3.1.6",
74
74
  "@11ty/eleventy-plugin-syntaxhighlight": "5.0.2",
75
- "@rathnasgala2/schemas": "3.1.0",
75
+ "@rathnasgala2/schemas": "3.3.0",
76
76
  "jpeg-js": "0.4.4",
77
77
  "markdown-it": "14.3.1",
78
78
  "sanitize-html": "2.17.7"
package/src/core/index.js CHANGED
@@ -9,8 +9,13 @@
9
9
  * fails closed: an invalid `build-input` is rejected with
10
10
  * {@link BuildInputValidationError} before anything is written, malformed
11
11
  * adapter options are rejected with {@link RenderOptionsError} before
12
- * Eleventy ever runs, and a rejected image or font is rejected with
13
- * {@link MediaPipelineError} before the manifest is assembled.
12
+ * Eleventy ever runs, a body image that names no inventoried image asset is
13
+ * rejected with {@link BuildInputValidationError}
14
+ * (`MEDIA_REFERENCE_UNRESOLVED`) before anything is written, an edition that
15
+ * names no article in the build, repeats another edition or takes another
16
+ * document's route is rejected the same way (`EDITION_ORIGINAL_UNRESOLVED`,
17
+ * `EDITION_DUPLICATE`, `EDITION_ROUTE_CONFLICT`), and a rejected image or font
18
+ * is rejected with {@link MediaPipelineError} before anything is written.
14
19
  *
15
20
  * `normalizeAuthoredMarkdown` (re-exported here from
16
21
  * `internal/content-security.js`) is this package's second public entry
@@ -85,6 +90,7 @@ import {
85
90
  computeRenderPolicyIdentity,
86
91
  contentSecurityPolicyMetaTag,
87
92
  } from './internal/content-security.js';
93
+ import { resolveEditions } from './internal/editions.js';
88
94
  import { renderPagesWithEleventy } from './internal/eleventy-render.js';
89
95
  import {
90
96
  INTERACTIONS_SCRIPT_MEDIA_TYPE,
@@ -100,6 +106,10 @@ import {
100
106
  } from './internal/feeds.js';
101
107
  import { listFilesSortedByUtf8Bytes } from './internal/fs-walk.js';
102
108
  import { getMessages } from './internal/messages.js';
109
+ import {
110
+ resolveContentImages,
111
+ rewriteBodyImages,
112
+ } from './internal/media/content-images.js';
103
113
  import { processMedia } from './internal/media/pipeline.js';
104
114
  import {
105
115
  buildGeneratedPages,
@@ -136,7 +146,11 @@ import {
136
146
  renderPrimaryNavigation,
137
147
  } from './internal/skeleton.js';
138
148
  import { renderFooter, renderHeader } from './internal/chrome.js';
139
- import { createComponents, createMediaImage } from './internal/components.js';
149
+ import {
150
+ createComponents,
151
+ createContentImage,
152
+ createMediaImage,
153
+ } from './internal/components.js';
140
154
  import {
141
155
  collectIncludedSources,
142
156
  compareUtf8Bytes,
@@ -336,13 +350,18 @@ export async function renderPublication(buildInput, options) {
336
350
  );
337
351
 
338
352
  await assertRenderPolicyCompliance(validatedInput);
353
+ // Every body image must name an image asset the build input inventories
354
+ // for its document; resolved here, from the verified bodies alone, so an
355
+ // unresolved one fails closed before anything is written.
356
+ const contentImages = resolveContentImages(validatedInput);
357
+ // Every edition must sit beside an article of this build; placed here for the
358
+ // same reason.
359
+ const editions = resolveEditions(validatedInput.content, contentRoute);
339
360
  assertOptions(options);
340
361
  const routeProfile = options.routeNormalizationProfile ?? 'directory-index';
341
362
 
342
363
  const outputDirectory = path.resolve(options.outputDirectory);
343
364
  const workDirectory = path.resolve(options.workDirectory);
344
- await mkdir(outputDirectory, { recursive: true });
345
- await mkdir(workDirectory, { recursive: true });
346
365
 
347
366
  // The site-wide message catalog for core chrome (skip link, navigation,
348
367
  // breadcrumb, pagination and footer labels). Every page's own <html lang>
@@ -388,9 +407,22 @@ export async function renderPublication(buildInput, options) {
388
407
  assets: mediaAssets,
389
408
  files: mediaFiles,
390
409
  dimensions: mediaDimensions,
410
+ variants: mediaVariants,
391
411
  } = await processMedia(validatedInput, {
392
412
  sourceDirectory: path.resolve(options.sourceDirectory),
413
+ contentImages: [...contentImages.values()],
393
414
  });
415
+ // Created only now, after every input and media check has passed, so a
416
+ // rejected build input or media reference leaves nothing behind.
417
+ await mkdir(outputDirectory, { recursive: true });
418
+ await mkdir(workDirectory, { recursive: true });
419
+ const contentImage = createContentImage({ mediaVariants, publicBasePath });
420
+ /** @type {(body: string) => string} a body's page form */
421
+ const pageBody = (body) =>
422
+ rewriteBodyImages(body, contentImages, contentImage.page);
423
+ /** @type {(body: string) => string} a body's feed form */
424
+ const feedBody = (body) =>
425
+ rewriteBodyImages(body, contentImages, contentImage.feed);
394
426
  const mediaImage = createMediaImage({
395
427
  mediaAssets,
396
428
  publicBasePath,
@@ -407,7 +439,9 @@ export async function renderPublication(buildInput, options) {
407
439
  // Already verified render-policy-conformant HTML by
408
440
  // assertRenderPolicyCompliance's assertPolicyConformantHtml call above —
409
441
  // inserted as-is, never re-parsed as Markdown.
410
- const footerProfileHtml = footerCard?.enabled ? footerCard.body.body : '';
442
+ const footerProfileHtml = footerCard?.enabled
443
+ ? pageBody(footerCard.body.body)
444
+ : '';
411
445
  const authorsById = new Map(
412
446
  validatedInput.authors.map((author) => [author.id, author]),
413
447
  );
@@ -440,6 +474,7 @@ export async function renderPublication(buildInput, options) {
440
474
  `</div></div>`
441
475
  : '';
442
476
  const latestYear = validatedInput.content
477
+ .filter((record) => record.frontmatter.kind !== 'edition')
443
478
  .map((record) => record.frontmatter.publishedAt.slice(0, 4))
444
479
  .sort()
445
480
  .at(-1);
@@ -456,6 +491,9 @@ export async function renderPublication(buildInput, options) {
456
491
  authorHtml: footerAuthorHtml,
457
492
  footerProfileHtml,
458
493
  copyrightText: `${siteMessages.footerCopyrightPrefix} ${latestYear ? `${latestYear} ` : ''}${publication.title}`,
494
+ // The whitelabel switch: a core appearance setting, not a module.
495
+ showAttribution:
496
+ validatedInput.appearance.attribution?.showMadeWith !== false,
459
497
  messages: siteMessages,
460
498
  });
461
499
 
@@ -489,7 +527,7 @@ export async function renderPublication(buildInput, options) {
489
527
  // The site-wide feeds, resolved once so every page's feed-discovery
490
528
  // `<link>` tags and the feed documents themselves agree on the exact same
491
529
  // self URLs.
492
- const feeds = buildFeeds(validatedInput);
530
+ const feeds = buildFeeds(validatedInput, { feedBody });
493
531
  const siteName = validatedInput.publication.title;
494
532
  const appearanceScriptHrefValue =
495
533
  appearanceBootstrapScriptHref(publicBasePath);
@@ -524,6 +562,8 @@ export async function renderPublication(buildInput, options) {
524
562
  const redirectProjections = [];
525
563
 
526
564
  const generatedPages = buildGeneratedPages(validatedInput, {
565
+ pageBody,
566
+ editions,
527
567
  mediaAssets,
528
568
  mediaDimensions,
529
569
  interactions: interactionsOn ? interactionsModule : undefined,
@@ -565,7 +605,7 @@ export async function renderPublication(buildInput, options) {
565
605
  description: page.description,
566
606
  robotsContent: page.robotsContent,
567
607
  canonicalUrl: new URL(
568
- joinPublicRoute(publicBasePath, page.route),
608
+ joinPublicRoute(publicBasePath, page.canonicalRoute ?? page.route),
569
609
  validatedInput.baseUrl,
570
610
  ).toString(),
571
611
  siteName,
@@ -524,6 +524,7 @@ export const GALA_BASE_COMPONENT_CSS = `
524
524
  .g-article-head h1 { font-size: clamp(2.25rem, 1.2rem + 3.8vw, var(--gala-display-max)); }
525
525
  .g-article-meta { display: flex; align-items: center; justify-content: space-between; gap: var(--gala-space-4); width: 100%; padding-top: 1.1rem; border-top: var(--gala-border-width) solid var(--gala-color-border); flex-wrap: wrap; }
526
526
  .g-share { display: flex; gap: var(--gala-space-1); }
527
+ [data-gala-slot="edition-selector"] { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--gala-space-2); min-width: 0; max-width: 100%; }
527
528
  .g-article-cover { margin-block: 0; }
528
529
  .g-article-cover img { width: 100%; height: auto; aspect-ratio: 21 / 9; object-fit: cover; border-radius: var(--gala-radius-large); border: var(--gala-border-card); box-shadow: var(--gala-shadow-card); filter: var(--gala-media-filter); }
529
530
  .g-article-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, var(--gala-content-measure)) minmax(0, 1fr); gap: clamp(1.5rem, 4vw, 3.5rem); padding-block: clamp(2rem, 5vw, 3.5rem) var(--gala-space-4); }
@@ -670,7 +671,6 @@ export const GALA_BASE_COMPONENT_CSS = `
670
671
  }
671
672
  .g-reaction:hover:not(:disabled) { border-color: var(--gala-color-text); color: var(--gala-color-text); }
672
673
  .g-reaction:active:not(:disabled) { transform: scale(.96); }
673
- .g-reaction:disabled { opacity: .55; cursor: default; }
674
674
  .g-reaction__icon { display: inline-grid; place-items: center; font-size: 1.1rem; line-height: 1; }
675
675
  .g-reaction__icon .g-icon { width: 1.15rem; height: 1.15rem; }
676
676
  .g-reaction__label { overflow-wrap: anywhere; }
@@ -705,12 +705,41 @@ export const GALA_BASE_COMPONENT_CSS = `
705
705
  }
706
706
  .g-composer__submit:hover:not(:disabled), .g-composer__signin:hover, .g-report button[type="submit"]:hover { filter: brightness(1.07) saturate(1.05); }
707
707
  .g-composer__submit:active:not(:disabled), .g-composer__signin:active { transform: scale(.97); }
708
- .g-composer__submit:disabled { opacity: .55; cursor: default; box-shadow: none; }
709
708
  .g-composer__cancel, .g-report button[type="button"] {
710
709
  min-height: 2.75rem; padding: .6rem 1rem; border-radius: var(--gala-radius-pill); border: var(--gala-border-width) solid var(--gala-color-border); background: transparent; color: var(--gala-color-text);
711
710
  font-family: var(--gala-font-ui); font-weight: var(--gala-weight-ui); font-size: .9rem; cursor: pointer;
712
711
  }
713
712
  .g-composer__cancel:hover, .g-report button[type="button"]:hover { border-color: var(--gala-color-text); }
713
+ /* Interaction-button states. Enabled: full-strength tokens and a pointer.
714
+ Disabled (native or aria-disabled): muted surface and border tokens,
715
+ faint text, reduced opacity, a dashed border and not-allowed. Working
716
+ (aria-busy): enabled colours, a progress cursor and a spinner, never the
717
+ muted look. Themes may tint these tokens, never remove the rule. */
718
+ .g-reaction:is(:disabled, [aria-disabled="true"]):not([aria-busy="true"]),
719
+ .g-comments__more:is(:disabled, [aria-disabled="true"]):not([aria-busy="true"]),
720
+ .g-composer__cancel:is(:disabled, [aria-disabled="true"]):not([aria-busy="true"]),
721
+ .g-report button[type="button"]:is(:disabled, [aria-disabled="true"]):not([aria-busy="true"]),
722
+ .g-comment__action:is(:disabled, [aria-disabled="true"]):not([aria-busy="true"]),
723
+ .g-composer__submit:is(:disabled, [aria-disabled="true"]):not([aria-busy="true"]),
724
+ .g-composer__signin:is(:disabled, [aria-disabled="true"]):not([aria-busy="true"]),
725
+ .g-report button[type="submit"]:is(:disabled, [aria-disabled="true"]):not([aria-busy="true"]) {
726
+ background: var(--gala-color-surface-raised); background-image: none; color: var(--gala-color-text-faint);
727
+ border: var(--gala-border-width) dashed var(--gala-color-border); box-shadow: none; filter: none; transform: none;
728
+ opacity: .6; cursor: not-allowed;
729
+ }
730
+ .g-interactions__signout:is(:disabled, [aria-disabled="true"]):not([aria-busy="true"]) { color: var(--gala-color-text-faint); text-decoration-style: dashed; opacity: .6; cursor: not-allowed; }
731
+ .g-reaction[aria-busy="true"], .g-comments__more[aria-busy="true"], .g-composer__cancel[aria-busy="true"], .g-comment__action[aria-busy="true"],
732
+ .g-composer__submit[aria-busy="true"], .g-composer__signin[aria-busy="true"], .g-report button[aria-busy="true"] { cursor: progress; }
733
+ .g-reaction[aria-busy="true"]::after, .g-comments__more[aria-busy="true"]::after, .g-comment__action[aria-busy="true"]::after,
734
+ .g-composer__submit[aria-busy="true"]::after, .g-report button[aria-busy="true"]::after {
735
+ content: ""; display: inline-block; flex: none; width: .9em; height: .9em; margin-left: .5em; border-radius: 50%;
736
+ border: 2px solid currentColor; border-right-color: transparent; vertical-align: -.1em; animation: g-busy-spin .8s linear infinite;
737
+ }
738
+ @keyframes g-busy-spin { to { transform: rotate(360deg); } }
739
+ @media (prefers-reduced-motion: reduce) {
740
+ .g-reaction[aria-busy="true"]::after, .g-comments__more[aria-busy="true"]::after, .g-comment__action[aria-busy="true"]::after,
741
+ .g-composer__submit[aria-busy="true"]::after, .g-report button[aria-busy="true"]::after { animation: none; border-right-color: currentColor; border-style: dotted; }
742
+ }
714
743
  .g-comment-list { display: grid; gap: var(--gala-space-4); min-width: 0; }
715
744
  .g-comment { display: grid; gap: var(--gala-space-2); min-width: 0; }
716
745
  .g-comment__head { display: flex; align-items: center; gap: var(--gala-space-3); min-width: 0; }
@@ -142,6 +142,9 @@ function renderSocialLinks(socialLinks, rssHref, messages) {
142
142
  * @param {string} options.footerProfileHtml the already-rendered footer card
143
143
  * body (may be empty)
144
144
  * @param {string | undefined} options.copyrightText the copyright line
145
+ * @param {boolean} options.showAttribution whether the Galascribe
146
+ * attribution line renders: the build input's
147
+ * `appearance.attribution.showMadeWith`, shown when absent
145
148
  * @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} options.messages
146
149
  * @returns {string} the rendered footer
147
150
  */
@@ -158,6 +161,7 @@ export function renderFooter({
158
161
  authorHtml,
159
162
  footerProfileHtml,
160
163
  copyrightText,
164
+ showAttribution,
161
165
  messages,
162
166
  }) {
163
167
  const label = (/** @type {string} */ key) =>
@@ -190,10 +194,9 @@ export function renderFooter({
190
194
  renderSlot('account-intent') +
191
195
  renderSlot('conversation') +
192
196
  renderSlot('newsletter') +
193
- renderSlot('edition-selector') +
194
197
  `<div class="g-wrap g-footer-base">` +
195
198
  (copyrightText ? `<span>${escapeHtml(copyrightText)}</span>` : '') +
196
- `<span>${label('footerAttributionLabel')}</span>` +
199
+ (showAttribution ? `<span>${label('footerAttributionLabel')}</span>` : '') +
197
200
  `</div></footer>`
198
201
  );
199
202
  }
@@ -14,6 +14,7 @@
14
14
  * published one is never rendered.
15
15
  */
16
16
 
17
+ import { RenderPolicyViolationError } from '../errors.js';
17
18
  import { escapeHtml } from './skeleton.js';
18
19
  import { icon } from './icons.js';
19
20
  import { joinBasePathAndRoute } from './route.js';
@@ -197,18 +198,76 @@ export function renderImage({
197
198
  }
198
199
 
199
200
  /**
200
- * Add `loading="lazy" decoding="async"` to every `<img>` in an
201
- * already-sanitized body. Applied at render time only: the body's own
202
- * digest is over the stored bytes and is never recomputed.
201
+ * The widest slot a content image fills, in CSS pixels: the prose column
202
+ * never exceeds it, so a content image's `src` is its largest output file
203
+ * no wider than this.
204
+ */
205
+ export const CONTENT_IMAGE_SLOT_WIDTH = 960;
206
+
207
+ /** The `sizes` value every content image carries (the slot above). */
208
+ export const CONTENT_IMAGE_SIZES = `(max-width: ${CONTENT_IMAGE_SLOT_WIDTH}px) 100vw, ${CONTENT_IMAGE_SLOT_WIDTH}px`;
209
+
210
+ /**
211
+ * Build the two renderers for a content image (an image a body references,
212
+ * already resolved by `media/content-images.js` and processed by the media
213
+ * pipeline). The page form names every output file of the image in
214
+ * `srcset`; its `src` is the largest output file no wider than
215
+ * {@link CONTENT_IMAGE_SLOT_WIDTH}, or the original when the image has no
216
+ * narrower file (a GIF, WebP or AVIF passes through as its original only).
217
+ * Applied at render time only: the body's own digest is over the stored
218
+ * bytes and is never recomputed.
203
219
  *
204
- * @param {string} bodyHtml sanitized body HTML
205
- * @returns {string} the body with loading hints on its images
220
+ * @param {object} options
221
+ * @param {Readonly<Record<string, readonly {path: string, width: number, height: number}[]>>} options.mediaVariants
222
+ * the media pipeline's output files per source digest, ascending width
223
+ * @param {string} options.publicBasePath the public base path
224
+ * @returns {{page: (reference: {path: string, sourceDigest: string}, altAttribute: string) => string, feed: (reference: {path: string, sourceDigest: string}, altAttribute: string) => string}}
225
+ * the page form (responsive, lazy) and the feed form (one `src`, no
226
+ * loading hints); `altAttribute` is the body's own entity-encoded `alt`
206
227
  */
207
- export function lazyBodyImages(bodyHtml) {
208
- return bodyHtml.replace(
209
- /<img\b(?![^>]*\bloading=)/g,
210
- '<img loading="lazy" decoding="async"',
211
- );
228
+ export function createContentImage({ mediaVariants, publicBasePath }) {
229
+ /**
230
+ * @param {{path: string, sourceDigest: string}} reference
231
+ * @returns {{url: string, width: number, height: number}[]} every output
232
+ * file of the image, ascending width
233
+ */
234
+ const filesOf = (reference) => {
235
+ const variants = mediaVariants[reference.sourceDigest] ?? [];
236
+ if (variants.length === 0) {
237
+ throw new RenderPolicyViolationError(
238
+ `content image ${reference.path} has no media pipeline output; this is a renderer defect`,
239
+ );
240
+ }
241
+ return variants.map((variant) => ({
242
+ url: joinBasePathAndRoute(publicBasePath, `/${variant.path}`),
243
+ width: variant.width,
244
+ height: variant.height,
245
+ }));
246
+ };
247
+ /**
248
+ * @param {{url: string, width: number, height: number}[]} files
249
+ * @returns {{url: string, width: number, height: number}} the `src` file
250
+ */
251
+ const fitting = (files) =>
252
+ files.filter((file) => file.width <= CONTENT_IMAGE_SLOT_WIDTH).at(-1) ??
253
+ files[0];
254
+ return {
255
+ page(reference, altAttribute) {
256
+ const files = filesOf(reference);
257
+ const source = fitting(files);
258
+ const srcset = files
259
+ .map((file) => `${file.url} ${file.width}w`)
260
+ .join(', ');
261
+ return (
262
+ `<img src="${escapeHtml(source.url)}" srcset="${escapeHtml(srcset)}" sizes="${CONTENT_IMAGE_SIZES}"` +
263
+ ` alt="${altAttribute}" width="${source.width}" height="${source.height}" loading="lazy" decoding="async">`
264
+ );
265
+ },
266
+ feed(reference, altAttribute) {
267
+ const source = fitting(filesOf(reference));
268
+ return `<img src="${escapeHtml(source.url)}" alt="${altAttribute}" width="${source.width}" height="${source.height}">`;
269
+ },
270
+ };
212
271
  }
213
272
 
214
273
  /**