@rathnasgala2/template 3.1.0 → 3.2.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/README.md +136 -43
- package/package.json +2 -2
- package/src/core/index.js +48 -8
- package/src/core/internal/appearance/base-layer.js +1 -0
- package/src/core/internal/chrome.js +5 -2
- package/src/core/internal/components.js +69 -10
- package/src/core/internal/editions.js +297 -0
- package/src/core/internal/feeds.js +8 -18
- package/src/core/internal/media/content-images.js +262 -0
- package/src/core/internal/media/gif-probe.js +162 -0
- package/src/core/internal/media/jpeg-codec.js +0 -0
- package/src/core/internal/media/limits.js +21 -0
- package/src/core/internal/media/pipeline.js +73 -40
- package/src/core/internal/media/sniff.js +15 -2
- package/src/core/internal/messages.js +10 -1
- package/src/core/internal/page-kinds.js +97 -25
- package/src/core/internal/skeleton.js +25 -4
- package/src/core/internal/source-inventory.js +4 -0
- package/src/modules/interactions/browser/gala-interactions.js +298 -13
- package/types/index.d.ts +31 -1
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
|
|
39
|
-
|
|
40
|
-
entropy decode
|
|
41
|
-
`webp-probe.js`/`avif-probe.js`
|
|
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
|
|
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
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
`
|
|
160
|
-
`
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
172
|
-
|
|
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
|
|
444
|
-
`buildInput.baseUrl`, bounded to the most recent 50 entries
|
|
445
|
-
decision — neither the brief nor this renderer's own
|
|
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
|
|
450
|
-
absolute URL, with `<lastmod>` sourced from each page's own
|
|
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
|
|
463
|
-
the generated `404.html`), a canonical link, `og:*`/
|
|
464
|
-
(image resolved to an absolute content-addressed media
|
|
465
|
-
`internal/seo.js`, never the raw source path) and the
|
|
466
|
-
`<link>`s. **hreflang** is a documented scope
|
|
467
|
-
`build-input:2.0.0` has no
|
|
468
|
-
`content[]` records (see
|
|
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
|
|
485
|
-
categories. Author pages live at
|
|
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
|
|
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. `
|
|
584
|
-
|
|
585
|
-
|
|
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.
|
|
590
|
-
registry, declared as `"@rathnasgala2/schemas": "3.
|
|
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.
|
|
3
|
+
"version": "3.2.0",
|
|
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.
|
|
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,
|
|
13
|
-
* {@link
|
|
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 {
|
|
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
|
|
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); }
|
|
@@ -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
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
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 {
|
|
205
|
-
* @
|
|
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
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
/**
|