@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 +144 -49
- package/contracts/render-policy.jcs +1 -1
- package/package.json +4 -3
- package/src/core/index.js +122 -17
- package/src/core/internal/appearance/base-layer.js +82 -0
- package/src/core/internal/chrome.js +5 -2
- package/src/core/internal/components.js +69 -10
- package/src/core/internal/content-security.js +0 -0
- package/src/core/internal/editions.js +297 -0
- package/src/core/internal/eleventy-render.js +5 -3
- package/src/core/internal/feeds.js +8 -18
- package/src/core/internal/icons.js +2 -0
- package/src/core/internal/interactions.js +349 -0
- 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 +16 -1
- package/src/core/internal/page-kinds.js +121 -26
- package/src/core/internal/render-policy-content.js +40 -5
- 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 +2176 -0
- package/types/index.d.ts +61 -3
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
|
|
@@ -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:
|
|
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
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
`
|
|
581
|
-
`
|
|
582
|
-
|
|
583
|
-
|
|
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.
|
|
588
|
-
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)
|
|
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
|
|
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.
|
|
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,
|
|
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
|
|
@@ -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 {
|
|
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
|
|
245
|
-
*
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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.
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|