@rathnasgala2/template 3.0.1 → 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 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
@@ -551,7 +641,7 @@ npm run format # apply Prettier
551
641
  npm run format:check # CI check
552
642
  npm run lint # ESLint flat config
553
643
  npm run typecheck # tsc --checkJs --noEmit against types/index.d.ts
554
- npm run architecture # dependency-cruiser: no src/modules/, no circular deps
644
+ npm run architecture # dependency-cruiser: core never imports src/modules, no circular deps
555
645
  npm run duplication # jscpd, 3% / 50 tokens
556
646
  npm run contracts:generate # (re)write contracts/*.jcs from their reviewed source modules
557
647
  npm run contracts:check # fail if committed contracts drifted
@@ -569,23 +659,28 @@ npm run audit # npm audit --audit-level=high
569
659
  ## Package layout
570
660
 
571
661
  ```text
572
- src/core/ # the only source root
662
+ src/core/ # the renderer
663
+ src/modules/interactions/browser/ # the reader-interactions browser script (emitted as data)
573
664
  contracts/render-policy.jcs # normalized-body policy identity (real content)
574
665
  contracts/theme-styling-contract.jcs # published styling catalog (real and closed)
575
666
  ```
576
667
 
577
- No `src/modules/` tree, module import edge, registration stub, module
578
- configuration, module output or module runtime is ever admitted beside
579
- `src/core/`. `test/module-tree-absence.test.js` and `.dependency-cruiser.cjs`'s
580
- `only-src-core-is-a-source-root` rule both enforce this; `interactions`,
581
- `whitelabel`, `newsletter` and `prism` have no module directory, package,
582
- configuration schema, stub, output, runtime code, network target or
583
- compatibility promise in this MVP.
668
+ `src/core/` and `src/modules/interactions/` are the only source roots, and
669
+ `src/core` never imports from `src/modules`: the interactions browser script is
670
+ read from disk and emitted as `assets/gala-interactions-v1.js` when
671
+ `build-input.modules.interactions` is present.
672
+ `test/module-tree-absence.test.js`, `.dependency-cruiser.cjs` and the ESLint
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).
584
679
 
585
680
  ## Consuming `@rathnasgala2/schemas`
586
681
 
587
- This repository consumes `@rathnasgala2/schemas@3.0.0` from the public npm
588
- registry, declared as `"@rathnasgala2/schemas": "3.0.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)
589
684
  so `package-lock.json` records the resolved version and integrity hash. The
590
685
  LOCAL-1/LOCAL-39 local-tarball convention
591
686
  (`file:../../local-packages/rathnasgala2-schemas-*.tgz`) is retired for this
@@ -1 +1 @@
1
- {"contentSecurityPolicy":"default-src 'none'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'none'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'self'; media-src 'self'; manifest-src 'self'; worker-src 'none'","contentSecurityPolicyHeaderOnlyDirectives":["frame-ancestors 'none'"],"contentSecurityPolicyMeta":"default-src 'none'; base-uri 'none'; object-src 'none'; form-action 'none'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'self'; media-src 'self'; manifest-src 'self'; worker-src 'none'","highlighter":{"grammars":["bash","css","diff","java","javascript","json","jsx","markdown","markup","python","sql","tsx","typescript","yaml"],"package":"@11ty/eleventy-plugin-syntaxhighlight","tokenClasses":["atrule","attr-equals","attr-name","attr-value","bold","boolean","builtin","cdata","char","class-name","comment","constant","content","coord","deleted","deleted-sign","doctype","entity","for-or-select","function","function-variable","important","inserted","inserted-sign","interpolation","interpolation-punctuation","italic","key","keyword","namespace","null","number","operator","prefix","prolog","property","punctuation","regex","rule","selector","shebang","string","symbol","tag","template-punctuation","template-string","title","token","url","variable"],"version":"5.0.2"},"markdownIt":{"options":{"html":false,"linkify":false,"typographer":false},"package":"markdown-it","version":"14.3.1"},"name":"gala-render-policy","sanitizer":{"allowedAttributes":{"a":["href","rel"],"code":["class"],"h1":["id"],"h2":["id"],"h3":["id"],"h4":["id"],"h5":["id"],"h6":["id"],"img":["src","alt"],"ol":["start"],"pre":["class"],"span":["class"]},"allowedTags":["p","br","hr","h1","h2","h3","h4","h5","h6","ul","ol","li","blockquote","pre","code","strong","em","b","i","u","s","del","ins","sub","sup","mark","small","wbr","a","img","table","thead","tbody","tr","th","td","dl","dt","dd","figure","figcaption","span"],"allowedUrlSchemes":["https","http","mailto"],"package":"sanitize-html","version":"2.17.7"},"version":"2.0.0"}
1
+ {"contentSecurityPolicy":"default-src 'none'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'none'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'self' https://api.galascribe.com; media-src 'self'; manifest-src 'self'; worker-src 'none'","contentSecurityPolicyHeaderOnlyDirectives":["frame-ancestors 'none'"],"contentSecurityPolicyMeta":"default-src 'none'; base-uri 'none'; object-src 'none'; form-action 'none'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'self' https://api.galascribe.com; media-src 'self'; manifest-src 'self'; worker-src 'none'","highlighter":{"grammars":["bash","css","diff","java","javascript","json","jsx","markdown","markup","python","sql","tsx","typescript","yaml"],"package":"@11ty/eleventy-plugin-syntaxhighlight","tokenClasses":["atrule","attr-equals","attr-name","attr-value","bold","boolean","builtin","cdata","char","class-name","comment","constant","content","coord","deleted","deleted-sign","doctype","entity","for-or-select","function","function-variable","important","inserted","inserted-sign","interpolation","interpolation-punctuation","italic","key","keyword","namespace","null","number","operator","prefix","prolog","property","punctuation","regex","rule","selector","shebang","string","symbol","tag","template-punctuation","template-string","title","token","url","variable"],"version":"5.0.2"},"markdownIt":{"options":{"html":false,"linkify":false,"typographer":false},"package":"markdown-it","version":"14.3.1"},"name":"gala-render-policy","sanitizer":{"allowedAttributes":{"a":["href","rel"],"code":["class"],"h1":["id"],"h2":["id"],"h3":["id"],"h4":["id"],"h5":["id"],"h6":["id"],"img":["src","alt"],"ol":["start"],"pre":["class"],"span":["class"]},"allowedTags":["p","br","hr","h1","h2","h3","h4","h5","h6","ul","ol","li","blockquote","pre","code","strong","em","b","i","u","s","del","ins","sub","sup","mark","small","wbr","a","img","table","thead","tbody","tr","th","td","dl","dt","dd","figure","figcaption","span"],"allowedUrlSchemes":["https","http","mailto"],"package":"sanitize-html","version":"2.17.7"},"version":"2.0.0"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rathnasgala2/template",
3
- "version": "3.0.1",
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",
@@ -22,6 +22,7 @@
22
22
  "types": "./types/index.d.ts",
23
23
  "files": [
24
24
  "src/core/",
25
+ "src/modules/",
25
26
  "contracts/",
26
27
  "types/",
27
28
  "LICENSE",
@@ -52,7 +53,7 @@
52
53
  "format": "prettier --write .",
53
54
  "format:check": "prettier --check .",
54
55
  "lint": "eslint .",
55
- "typecheck": "tsc --project tsconfig.json",
56
+ "typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.modules.json",
56
57
  "architecture": "depcruise --validate .dependency-cruiser.cjs src contracts eslint.config.js",
57
58
  "duplication": "jscpd src scripts test eslint.config.js",
58
59
  "contracts:generate": "node scripts/generate-contracts.mjs",
@@ -71,7 +72,7 @@
71
72
  "dependencies": {
72
73
  "@11ty/eleventy": "3.1.6",
73
74
  "@11ty/eleventy-plugin-syntaxhighlight": "5.0.2",
74
- "@rathnasgala2/schemas": "3.0.0",
75
+ "@rathnasgala2/schemas": "3.3.0",
75
76
  "jpeg-js": "0.4.4",
76
77
  "markdown-it": "14.3.1",
77
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
@@ -83,8 +88,17 @@ import {
83
88
  assertPolicyConformantHtml,
84
89
  assertRenderPolicyIdentity,
85
90
  computeRenderPolicyIdentity,
91
+ contentSecurityPolicyMetaTag,
86
92
  } from './internal/content-security.js';
93
+ import { resolveEditions } from './internal/editions.js';
87
94
  import { renderPagesWithEleventy } from './internal/eleventy-render.js';
95
+ import {
96
+ INTERACTIONS_SCRIPT_MEDIA_TYPE,
97
+ INTERACTIONS_SCRIPT_PATH,
98
+ interactionsActive,
99
+ loadInteractionsScript,
100
+ validateInteractionsModule,
101
+ } from './internal/interactions.js';
88
102
  import {
89
103
  ATOM_FEED_ROUTE,
90
104
  RSS_FEED_ROUTE,
@@ -92,6 +106,10 @@ import {
92
106
  } from './internal/feeds.js';
93
107
  import { listFilesSortedByUtf8Bytes } from './internal/fs-walk.js';
94
108
  import { getMessages } from './internal/messages.js';
109
+ import {
110
+ resolveContentImages,
111
+ rewriteBodyImages,
112
+ } from './internal/media/content-images.js';
95
113
  import { processMedia } from './internal/media/pipeline.js';
96
114
  import {
97
115
  buildGeneratedPages,
@@ -128,7 +146,11 @@ import {
128
146
  renderPrimaryNavigation,
129
147
  } from './internal/skeleton.js';
130
148
  import { renderFooter, renderHeader } from './internal/chrome.js';
131
- import { createComponents, createMediaImage } from './internal/components.js';
149
+ import {
150
+ createComponents,
151
+ createContentImage,
152
+ createMediaImage,
153
+ } from './internal/components.js';
132
154
  import {
133
155
  collectIncludedSources,
134
156
  compareUtf8Bytes,
@@ -241,8 +263,9 @@ function assertOptions(options) {
241
263
  * Verify every `renderableBody.renderPolicy` in the publication profile,
242
264
  * footer card and every content record byte-equals the currently published
243
265
  * render-policy identity, and that no module or placement value leaked
244
- * through (belt-and-suspenders on top of the `build-input:2.0.0` schema's
245
- * own `modules: {}` / `placements: []` closure). Fails closed before any
266
+ * through (belt-and-suspenders on top of the `build-input` schema's module
267
+ * closure: only a valid `interactions` module is admitted, `placements` stays
268
+ * empty). Fails closed before any
246
269
  * directory is created or Eleventy ever runs: an absent policy file, wrong
247
270
  * path/name/version/digest, mixed identity between records, or body
248
271
  * produced under a different policy rejects before build.
@@ -252,14 +275,20 @@ function assertOptions(options) {
252
275
  * @returns {Promise<void>} resolves once every reference is verified
253
276
  */
254
277
  async function assertRenderPolicyCompliance(validatedInput) {
255
- if (
256
- Object.keys(validatedInput.modules).length !== 0 ||
257
- validatedInput.placements.length !== 0
258
- ) {
278
+ const moduleKeys = Object.keys(validatedInput.modules);
279
+ if (moduleKeys.some((key) => key !== 'interactions')) {
280
+ throw new RenderPolicyViolationError(
281
+ 'build-input.modules may only carry interactions',
282
+ );
283
+ }
284
+ if (validatedInput.placements.length !== 0) {
259
285
  throw new RenderPolicyViolationError(
260
- 'build-input.modules and build-input.placements must both be empty in S2',
286
+ 'build-input.placements must be empty',
261
287
  );
262
288
  }
289
+ if (validatedInput.modules.interactions !== undefined) {
290
+ validateInteractionsModule(validatedInput.modules.interactions);
291
+ }
263
292
 
264
293
  const identity = await computeRenderPolicyIdentity();
265
294
 
@@ -321,13 +350,18 @@ export async function renderPublication(buildInput, options) {
321
350
  );
322
351
 
323
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);
324
360
  assertOptions(options);
325
361
  const routeProfile = options.routeNormalizationProfile ?? 'directory-index';
326
362
 
327
363
  const outputDirectory = path.resolve(options.outputDirectory);
328
364
  const workDirectory = path.resolve(options.workDirectory);
329
- await mkdir(outputDirectory, { recursive: true });
330
- await mkdir(workDirectory, { recursive: true });
331
365
 
332
366
  // The site-wide message catalog for core chrome (skip link, navigation,
333
367
  // breadcrumb, pagination and footer labels). Every page's own <html lang>
@@ -343,6 +377,17 @@ export async function renderPublication(buildInput, options) {
343
377
  validatedInput.baseUrl,
344
378
  validatedInput.basePath,
345
379
  );
380
+ const interactionsModule = validatedInput.modules.interactions;
381
+ const interactionsOn = interactionsActive(interactionsModule);
382
+ // One CSP per build: the production policy, plus (local stack only) the
383
+ // interactions module's differing API origin in `connect-src`.
384
+ const cspMetaTag = contentSecurityPolicyMetaTag(
385
+ interactionsModule?.apiOrigin,
386
+ );
387
+ const interactionsScriptHref = joinBasePathAndRoute(
388
+ publicBasePath,
389
+ `/${INTERACTIONS_SCRIPT_PATH}`,
390
+ );
346
391
  const urlPrefix = derivePublicBasePath(validatedInput.baseUrl, '/');
347
392
  const homeRoute = joinPublicRoute(publicBasePath, '/');
348
393
 
@@ -362,9 +407,22 @@ export async function renderPublication(buildInput, options) {
362
407
  assets: mediaAssets,
363
408
  files: mediaFiles,
364
409
  dimensions: mediaDimensions,
410
+ variants: mediaVariants,
365
411
  } = await processMedia(validatedInput, {
366
412
  sourceDirectory: path.resolve(options.sourceDirectory),
413
+ contentImages: [...contentImages.values()],
367
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);
368
426
  const mediaImage = createMediaImage({
369
427
  mediaAssets,
370
428
  publicBasePath,
@@ -381,7 +439,9 @@ export async function renderPublication(buildInput, options) {
381
439
  // Already verified render-policy-conformant HTML by
382
440
  // assertRenderPolicyCompliance's assertPolicyConformantHtml call above —
383
441
  // inserted as-is, never re-parsed as Markdown.
384
- const footerProfileHtml = footerCard?.enabled ? footerCard.body.body : '';
442
+ const footerProfileHtml = footerCard?.enabled
443
+ ? pageBody(footerCard.body.body)
444
+ : '';
385
445
  const authorsById = new Map(
386
446
  validatedInput.authors.map((author) => [author.id, author]),
387
447
  );
@@ -414,6 +474,7 @@ export async function renderPublication(buildInput, options) {
414
474
  `</div></div>`
415
475
  : '';
416
476
  const latestYear = validatedInput.content
477
+ .filter((record) => record.frontmatter.kind !== 'edition')
417
478
  .map((record) => record.frontmatter.publishedAt.slice(0, 4))
418
479
  .sort()
419
480
  .at(-1);
@@ -430,6 +491,9 @@ export async function renderPublication(buildInput, options) {
430
491
  authorHtml: footerAuthorHtml,
431
492
  footerProfileHtml,
432
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,
433
497
  messages: siteMessages,
434
498
  });
435
499
 
@@ -463,7 +527,7 @@ export async function renderPublication(buildInput, options) {
463
527
  // The site-wide feeds, resolved once so every page's feed-discovery
464
528
  // `<link>` tags and the feed documents themselves agree on the exact same
465
529
  // self URLs.
466
- const feeds = buildFeeds(validatedInput);
530
+ const feeds = buildFeeds(validatedInput, { feedBody });
467
531
  const siteName = validatedInput.publication.title;
468
532
  const appearanceScriptHrefValue =
469
533
  appearanceBootstrapScriptHref(publicBasePath);
@@ -498,9 +562,14 @@ export async function renderPublication(buildInput, options) {
498
562
  const redirectProjections = [];
499
563
 
500
564
  const generatedPages = buildGeneratedPages(validatedInput, {
565
+ pageBody,
566
+ editions,
501
567
  mediaAssets,
502
568
  mediaDimensions,
569
+ interactions: interactionsOn ? interactionsModule : undefined,
503
570
  });
571
+ /** @type {Set<string>} output paths of the interaction-bearing routes */
572
+ const interactionBearingPaths = new Set();
504
573
  generatedPages.forEach((page, index) => {
505
574
  const joinedRoute = joinBasePathAndRoute(
506
575
  validatedInput.basePath,
@@ -520,10 +589,12 @@ export async function renderPublication(buildInput, options) {
520
589
  basePath: publicBasePath,
521
590
  });
522
591
  const ogImage = ogImageInfo?.url;
592
+ const pagePermalink = projectRouteToFilePath(joinedRoute, routeProfile);
593
+ if (page.interactionBearing) interactionBearingPaths.add(pagePermalink);
523
594
  contentPages.push({
524
595
  virtualPath: `pages/${page.kind}-${index}.html`,
525
596
  content: bodyHtml,
526
- permalink: projectRouteToFilePath(joinedRoute, routeProfile),
597
+ permalink: pagePermalink,
527
598
  layout: 'skeleton.njk',
528
599
  data: {
529
600
  title: page.documentTitle ?? page.title,
@@ -534,7 +605,7 @@ export async function renderPublication(buildInput, options) {
534
605
  description: page.description,
535
606
  robotsContent: page.robotsContent,
536
607
  canonicalUrl: new URL(
537
- joinPublicRoute(publicBasePath, page.route),
608
+ joinPublicRoute(publicBasePath, page.canonicalRoute ?? page.route),
538
609
  validatedInput.baseUrl,
539
610
  ).toString(),
540
611
  siteName,
@@ -552,6 +623,10 @@ export async function renderPublication(buildInput, options) {
552
623
  atomFeedUrl: feeds.atomSelfUrl,
553
624
  rssFeedUrl: feeds.rssSelfUrl,
554
625
  appearanceScriptHref: appearanceScriptHrefValue,
626
+ interactionsScriptHref: page.interactionBearing
627
+ ? interactionsScriptHref
628
+ : undefined,
629
+ cspMetaTag,
555
630
  baseStylesheetHref: baseStylesheetHrefValue,
556
631
  themeStylesheetLinksHtml,
557
632
  },
@@ -623,6 +698,7 @@ export async function renderPublication(buildInput, options) {
623
698
  atomFeedUrl: feeds.atomSelfUrl,
624
699
  rssFeedUrl: feeds.rssSelfUrl,
625
700
  appearanceScriptHref: appearanceScriptHrefValue,
701
+ cspMetaTag,
626
702
  baseStylesheetHref: baseStylesheetHrefValue,
627
703
  themeStylesheetLinksHtml,
628
704
  },
@@ -767,7 +843,7 @@ export async function renderPublication(buildInput, options) {
767
843
  byteLength: String(bytes.byteLength),
768
844
  sha256: digestBytes(bytes),
769
845
  routeClass: special?.routeClass ?? 'html',
770
- interactionBearing: false,
846
+ interactionBearing: interactionBearingPaths.has(filePath),
771
847
  });
772
848
  }
773
849
  routes.sort((a, b) => compareUtf8Bytes(a.path, b.path));
@@ -897,10 +973,39 @@ export async function renderPublication(buildInput, options) {
897
973
  immutable: false,
898
974
  };
899
975
 
976
+ // The reader-interactions browser script, only when the module is on. Its
977
+ // bytes are the template package's own file, so the asset row is a pure
978
+ // function of the template version.
979
+ /** @type {import('../../types/index.d.ts').ManifestAssetEntry[]} */
980
+ const interactionsAssets = [];
981
+ if (interactionsOn) {
982
+ const interactionsScriptBytes = await loadInteractionsScript();
983
+ const joinedInteractionsScriptPath = projectFixedAssetPath(
984
+ validatedInput.basePath,
985
+ `/${INTERACTIONS_SCRIPT_PATH}`,
986
+ );
987
+ const interactionsScriptDestination = path.join(
988
+ outputDirectory,
989
+ joinedInteractionsScriptPath,
990
+ );
991
+ await mkdir(path.dirname(interactionsScriptDestination), {
992
+ recursive: true,
993
+ });
994
+ await writeFile(interactionsScriptDestination, interactionsScriptBytes);
995
+ interactionsAssets.push({
996
+ path: joinedInteractionsScriptPath,
997
+ mediaType: INTERACTIONS_SCRIPT_MEDIA_TYPE,
998
+ byteLength: String(interactionsScriptBytes.byteLength),
999
+ sha256: digestBytes(interactionsScriptBytes),
1000
+ immutable: false,
1001
+ });
1002
+ }
1003
+
900
1004
  const assets = [
901
1005
  ...joinedMediaAssets,
902
1006
  appearanceScriptAsset,
903
1007
  baseStylesheetAsset,
1008
+ ...interactionsAssets,
904
1009
  ...(themeAssetsResult ? themeAssetsResult.assets : []),
905
1010
  ].sort((a, b) => compareUtf8Bytes(a.path, b.path));
906
1011