@rathnasgala2/template 3.0.0 → 3.1.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
@@ -486,14 +486,14 @@ brief sits on top of the page kinds above:
486
486
  collide).
487
487
  - **`robots.txt`, `llms.txt`, `llms-full.txt`** (`internal/robots.js`,
488
488
  `internal/llms.js`). `robots.txt` allows all crawlers and names the absolute
489
- sitemap; `publication.crawlers.ai: "block"` (read defensively, absent means
490
- allow) adds `Disallow: /` groups for the 15 known AI crawlers. It is always
491
- written at the output root: **only a robots.txt at the host root is honoured
492
- by crawlers**, so a site served from a path (for example a GitHub project
493
- site) must have the same file at its host root. `llms.txt` follows llmstxt.org
494
- (`# title`, `> description`, `## Articles`, `## Series`, `## Optional`);
495
- `llms-full.txt` is every published article as plain text. Unlisted content is
496
- excluded from both.
489
+ sitemap; `publication.crawlers.ai: "block"` (validated by the build-input
490
+ schema, absent means allow) adds `Disallow: /` groups for the 15 known AI
491
+ crawlers. It is always written at the output root: **only a robots.txt at the
492
+ host root is honoured by crawlers**, so a site served from a path (for example
493
+ a GitHub project site) must have the same file at its host root. `llms.txt`
494
+ follows llmstxt.org (`# title`, `> description`, `## Articles`, `## Series`,
495
+ `## Optional`); `llms-full.txt` is every published article as plain text.
496
+ Unlisted content is excluded from both.
497
497
  - **`404.html`**: the `error` page kind's pure `renderErrorPageBody`, now
498
498
  actually wired into `renderPublication` at the fixed `errorDocumentKey`
499
499
  (`internal/route.js#errorDocumentPath`) — `404.html` at the root, or
@@ -551,7 +551,7 @@ npm run format # apply Prettier
551
551
  npm run format:check # CI check
552
552
  npm run lint # ESLint flat config
553
553
  npm run typecheck # tsc --checkJs --noEmit against types/index.d.ts
554
- npm run architecture # dependency-cruiser: no src/modules/, no circular deps
554
+ npm run architecture # dependency-cruiser: core never imports src/modules, no circular deps
555
555
  npm run duplication # jscpd, 3% / 50 tokens
556
556
  npm run contracts:generate # (re)write contracts/*.jcs from their reviewed source modules
557
557
  npm run contracts:check # fail if committed contracts drifted
@@ -569,23 +569,25 @@ npm run audit # npm audit --audit-level=high
569
569
  ## Package layout
570
570
 
571
571
  ```text
572
- src/core/ # the only source root
572
+ src/core/ # the renderer
573
+ src/modules/interactions/browser/ # the reader-interactions browser script (emitted as data)
573
574
  contracts/render-policy.jcs # normalized-body policy identity (real content)
574
575
  contracts/theme-styling-contract.jcs # published styling catalog (real and closed)
575
576
  ```
576
577
 
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.
578
+ `src/core/` and `src/modules/interactions/` are the only source roots, and
579
+ `src/core` never imports from `src/modules`: the interactions browser script is
580
+ read from disk and emitted as `assets/gala-interactions-v1.js` when
581
+ `build-input.modules.interactions` is present.
582
+ `test/module-tree-absence.test.js`, `.dependency-cruiser.cjs` and the ESLint
583
+ `no-restricted-imports` rule enforce this. `whitelabel`, `newsletter` and
584
+ `prism` have no module directory, package, configuration schema, stub, output or
585
+ runtime code in this MVP.
584
586
 
585
587
  ## Consuming `@rathnasgala2/schemas`
586
588
 
587
- This repository consumes `@rathnasgala2/schemas@2.11.0` from the public npm
588
- registry, declared as `"@rathnasgala2/schemas": "2.11.0"` (exact pin, no range)
589
+ This repository consumes `@rathnasgala2/schemas@3.1.0` from the public npm
590
+ registry, declared as `"@rathnasgala2/schemas": "3.1.0"` (exact pin, no range)
589
591
  so `package-lock.json` records the resolved version and integrity hash. The
590
592
  LOCAL-1/LOCAL-39 local-tarball convention
591
593
  (`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.0",
3
+ "version": "3.1.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": "2.11.0",
75
+ "@rathnasgala2/schemas": "3.1.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
@@ -83,8 +83,16 @@ import {
83
83
  assertPolicyConformantHtml,
84
84
  assertRenderPolicyIdentity,
85
85
  computeRenderPolicyIdentity,
86
+ contentSecurityPolicyMetaTag,
86
87
  } from './internal/content-security.js';
87
88
  import { renderPagesWithEleventy } from './internal/eleventy-render.js';
89
+ import {
90
+ INTERACTIONS_SCRIPT_MEDIA_TYPE,
91
+ INTERACTIONS_SCRIPT_PATH,
92
+ interactionsActive,
93
+ loadInteractionsScript,
94
+ validateInteractionsModule,
95
+ } from './internal/interactions.js';
88
96
  import {
89
97
  ATOM_FEED_ROUTE,
90
98
  RSS_FEED_ROUTE,
@@ -241,8 +249,9 @@ function assertOptions(options) {
241
249
  * Verify every `renderableBody.renderPolicy` in the publication profile,
242
250
  * footer card and every content record byte-equals the currently published
243
251
  * 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
252
+ * through (belt-and-suspenders on top of the `build-input` schema's module
253
+ * closure: only a valid `interactions` module is admitted, `placements` stays
254
+ * empty). Fails closed before any
246
255
  * directory is created or Eleventy ever runs: an absent policy file, wrong
247
256
  * path/name/version/digest, mixed identity between records, or body
248
257
  * produced under a different policy rejects before build.
@@ -252,14 +261,20 @@ function assertOptions(options) {
252
261
  * @returns {Promise<void>} resolves once every reference is verified
253
262
  */
254
263
  async function assertRenderPolicyCompliance(validatedInput) {
255
- if (
256
- Object.keys(validatedInput.modules).length !== 0 ||
257
- validatedInput.placements.length !== 0
258
- ) {
264
+ const moduleKeys = Object.keys(validatedInput.modules);
265
+ if (moduleKeys.some((key) => key !== 'interactions')) {
259
266
  throw new RenderPolicyViolationError(
260
- 'build-input.modules and build-input.placements must both be empty in S2',
267
+ 'build-input.modules may only carry interactions',
261
268
  );
262
269
  }
270
+ if (validatedInput.placements.length !== 0) {
271
+ throw new RenderPolicyViolationError(
272
+ 'build-input.placements must be empty',
273
+ );
274
+ }
275
+ if (validatedInput.modules.interactions !== undefined) {
276
+ validateInteractionsModule(validatedInput.modules.interactions);
277
+ }
263
278
 
264
279
  const identity = await computeRenderPolicyIdentity();
265
280
 
@@ -343,6 +358,17 @@ export async function renderPublication(buildInput, options) {
343
358
  validatedInput.baseUrl,
344
359
  validatedInput.basePath,
345
360
  );
361
+ const interactionsModule = validatedInput.modules.interactions;
362
+ const interactionsOn = interactionsActive(interactionsModule);
363
+ // One CSP per build: the production policy, plus (local stack only) the
364
+ // interactions module's differing API origin in `connect-src`.
365
+ const cspMetaTag = contentSecurityPolicyMetaTag(
366
+ interactionsModule?.apiOrigin,
367
+ );
368
+ const interactionsScriptHref = joinBasePathAndRoute(
369
+ publicBasePath,
370
+ `/${INTERACTIONS_SCRIPT_PATH}`,
371
+ );
346
372
  const urlPrefix = derivePublicBasePath(validatedInput.baseUrl, '/');
347
373
  const homeRoute = joinPublicRoute(publicBasePath, '/');
348
374
 
@@ -500,7 +526,10 @@ export async function renderPublication(buildInput, options) {
500
526
  const generatedPages = buildGeneratedPages(validatedInput, {
501
527
  mediaAssets,
502
528
  mediaDimensions,
529
+ interactions: interactionsOn ? interactionsModule : undefined,
503
530
  });
531
+ /** @type {Set<string>} output paths of the interaction-bearing routes */
532
+ const interactionBearingPaths = new Set();
504
533
  generatedPages.forEach((page, index) => {
505
534
  const joinedRoute = joinBasePathAndRoute(
506
535
  validatedInput.basePath,
@@ -520,10 +549,12 @@ export async function renderPublication(buildInput, options) {
520
549
  basePath: publicBasePath,
521
550
  });
522
551
  const ogImage = ogImageInfo?.url;
552
+ const pagePermalink = projectRouteToFilePath(joinedRoute, routeProfile);
553
+ if (page.interactionBearing) interactionBearingPaths.add(pagePermalink);
523
554
  contentPages.push({
524
555
  virtualPath: `pages/${page.kind}-${index}.html`,
525
556
  content: bodyHtml,
526
- permalink: projectRouteToFilePath(joinedRoute, routeProfile),
557
+ permalink: pagePermalink,
527
558
  layout: 'skeleton.njk',
528
559
  data: {
529
560
  title: page.documentTitle ?? page.title,
@@ -552,6 +583,10 @@ export async function renderPublication(buildInput, options) {
552
583
  atomFeedUrl: feeds.atomSelfUrl,
553
584
  rssFeedUrl: feeds.rssSelfUrl,
554
585
  appearanceScriptHref: appearanceScriptHrefValue,
586
+ interactionsScriptHref: page.interactionBearing
587
+ ? interactionsScriptHref
588
+ : undefined,
589
+ cspMetaTag,
555
590
  baseStylesheetHref: baseStylesheetHrefValue,
556
591
  themeStylesheetLinksHtml,
557
592
  },
@@ -623,6 +658,7 @@ export async function renderPublication(buildInput, options) {
623
658
  atomFeedUrl: feeds.atomSelfUrl,
624
659
  rssFeedUrl: feeds.rssSelfUrl,
625
660
  appearanceScriptHref: appearanceScriptHrefValue,
661
+ cspMetaTag,
626
662
  baseStylesheetHref: baseStylesheetHrefValue,
627
663
  themeStylesheetLinksHtml,
628
664
  },
@@ -767,7 +803,7 @@ export async function renderPublication(buildInput, options) {
767
803
  byteLength: String(bytes.byteLength),
768
804
  sha256: digestBytes(bytes),
769
805
  routeClass: special?.routeClass ?? 'html',
770
- interactionBearing: false,
806
+ interactionBearing: interactionBearingPaths.has(filePath),
771
807
  });
772
808
  }
773
809
  routes.sort((a, b) => compareUtf8Bytes(a.path, b.path));
@@ -897,10 +933,39 @@ export async function renderPublication(buildInput, options) {
897
933
  immutable: false,
898
934
  };
899
935
 
936
+ // The reader-interactions browser script, only when the module is on. Its
937
+ // bytes are the template package's own file, so the asset row is a pure
938
+ // function of the template version.
939
+ /** @type {import('../../types/index.d.ts').ManifestAssetEntry[]} */
940
+ const interactionsAssets = [];
941
+ if (interactionsOn) {
942
+ const interactionsScriptBytes = await loadInteractionsScript();
943
+ const joinedInteractionsScriptPath = projectFixedAssetPath(
944
+ validatedInput.basePath,
945
+ `/${INTERACTIONS_SCRIPT_PATH}`,
946
+ );
947
+ const interactionsScriptDestination = path.join(
948
+ outputDirectory,
949
+ joinedInteractionsScriptPath,
950
+ );
951
+ await mkdir(path.dirname(interactionsScriptDestination), {
952
+ recursive: true,
953
+ });
954
+ await writeFile(interactionsScriptDestination, interactionsScriptBytes);
955
+ interactionsAssets.push({
956
+ path: joinedInteractionsScriptPath,
957
+ mediaType: INTERACTIONS_SCRIPT_MEDIA_TYPE,
958
+ byteLength: String(interactionsScriptBytes.byteLength),
959
+ sha256: digestBytes(interactionsScriptBytes),
960
+ immutable: false,
961
+ });
962
+ }
963
+
900
964
  const assets = [
901
965
  ...joinedMediaAssets,
902
966
  appearanceScriptAsset,
903
967
  baseStylesheetAsset,
968
+ ...interactionsAssets,
904
969
  ...(themeAssetsResult ? themeAssetsResult.assets : []),
905
970
  ].sort((a, b) => compareUtf8Bytes(a.path, b.path));
906
971
 
@@ -49,7 +49,7 @@ export const GALA_BASE_STYLESHEET_MEDIA_TYPE = 'text/css; charset=utf-8';
49
49
  *
50
50
  * @type {Readonly<Record<string, string | string[]>>}
51
51
  */
52
- const DEFAULT_TOKENS = Object.freeze({
52
+ export const GALA_BASE_DEFAULT_TOKENS = Object.freeze({
53
53
  'border-button': 'none',
54
54
  'border-card': ['1px solid #e2e6ec', '1px solid #232a34'],
55
55
  'border-chip': 'none',
@@ -195,7 +195,7 @@ const DEFAULT_TOKENS = Object.freeze({
195
195
 
196
196
  /** @type {readonly string[]} every token key this layer defaults, sorted. */
197
197
  export const GALA_BASE_DEFAULT_TOKEN_KEYS = Object.freeze(
198
- Object.keys(DEFAULT_TOKENS),
198
+ Object.keys(GALA_BASE_DEFAULT_TOKENS),
199
199
  );
200
200
 
201
201
  /**
@@ -203,7 +203,7 @@ export const GALA_BASE_DEFAULT_TOKEN_KEYS = Object.freeze(
203
203
  * @returns {string} the declaration list for one palette
204
204
  */
205
205
  function defaultDeclarations(index) {
206
- return Object.entries(DEFAULT_TOKENS)
206
+ return Object.entries(GALA_BASE_DEFAULT_TOKENS)
207
207
  .map(([key, value]) => {
208
208
  const resolved = typeof value === 'string' ? value : (value[index] ?? '');
209
209
  return `--gala-${key}:${resolved};`;
@@ -654,6 +654,83 @@ export const GALA_BASE_COMPONENT_CSS = `
654
654
  [aria-selected="true"] > .g-search-hit > .g-icon { opacity: 1; color: var(--gala-color-accent); }
655
655
  .g-search-empty { padding: var(--gala-space-6) var(--gala-space-4); color: var(--gala-color-text-muted); font-family: var(--gala-font-ui); text-align: center; }
656
656
 
657
+ /* reader interactions: server-rendered section and the classes the browser script creates */
658
+ .g-interactions { display: grid; gap: var(--gala-space-4); min-width: 0; padding: var(--gala-space-6); border-radius: var(--gala-radius-large); background: var(--gala-color-surface); border: var(--gala-border-card); font-family: var(--gala-font-ui); }
659
+ .g-interactions__title { font-family: var(--gala-font-display); font-weight: var(--gala-weight-title); font-size: 1.25rem; letter-spacing: var(--gala-tracking-title); text-transform: var(--gala-title-transform); line-height: 1.2; }
660
+ .g-interactions__notice, .g-interactions__status { font-size: .875rem; color: var(--gala-color-text-muted); overflow-wrap: anywhere; }
661
+ .g-interactions__status:empty { display: none; }
662
+ .g-interactions__signout { display: inline; padding: 0; border: 0; background: none; font: inherit; color: var(--gala-color-link); text-decoration: underline; text-underline-offset: var(--gala-link-offset); cursor: pointer; }
663
+ .g-interactions__signout:hover { color: var(--gala-color-text); }
664
+ .g-reactions { display: flex; flex-wrap: wrap; gap: var(--gala-space-2); }
665
+ .g-reaction {
666
+ display: inline-flex; align-items: center; gap: .5em; min-height: 2.75rem; max-width: 100%; padding: .45rem .95rem;
667
+ border-radius: var(--gala-radius-pill); border: var(--gala-border-width) solid var(--gala-color-border); background: var(--gala-color-canvas); color: var(--gala-color-text-muted);
668
+ font-family: var(--gala-font-ui); font-weight: var(--gala-weight-ui); font-size: .9rem; line-height: 1.2; cursor: pointer;
669
+ transition: transform var(--gala-duration-fast) var(--gala-ease-standard), border-color var(--gala-duration-fast) var(--gala-ease-standard), color var(--gala-duration-fast) var(--gala-ease-standard), background-color var(--gala-duration-fast) var(--gala-ease-standard);
670
+ }
671
+ .g-reaction:hover:not(:disabled) { border-color: var(--gala-color-text); color: var(--gala-color-text); }
672
+ .g-reaction:active:not(:disabled) { transform: scale(.96); }
673
+ .g-reaction:disabled { opacity: .55; cursor: default; }
674
+ .g-reaction__icon { display: inline-grid; place-items: center; font-size: 1.1rem; line-height: 1; }
675
+ .g-reaction__icon .g-icon { width: 1.15rem; height: 1.15rem; }
676
+ .g-reaction__label { overflow-wrap: anywhere; }
677
+ .g-reaction__count { font-variant-numeric: tabular-nums; font-size: .8rem; color: var(--gala-color-text-faint); padding-left: var(--gala-space-2); border-left: var(--gala-border-width) solid var(--gala-color-border); }
678
+ .g-reaction__count:empty { display: none; }
679
+ .g-reaction[aria-pressed="true"], .g-reaction--active { border-color: var(--gala-color-accent); background: var(--gala-paint-chip); color: var(--gala-color-chip-text); }
680
+ .g-reaction[aria-pressed="true"] .g-icon, .g-reaction--active .g-icon { fill: currentColor; color: var(--gala-color-accent); }
681
+ .g-comments { display: grid; gap: var(--gala-space-4); min-width: 0; padding-top: var(--gala-space-4); border-top: var(--gala-border-width) solid var(--gala-color-border); }
682
+ .g-comments__title { font-family: var(--gala-font-display); font-weight: var(--gala-weight-title); font-size: 1.05rem; }
683
+ .g-comments__count { font-family: var(--gala-font-ui); font-weight: var(--gala-weight-normal); font-size: .875rem; color: var(--gala-color-text-faint); font-variant-numeric: tabular-nums; }
684
+ .g-comments__more {
685
+ justify-self: start; min-height: 2.75rem; padding: .5rem 1.1rem; border-radius: var(--gala-radius-pill); border: var(--gala-border-width) solid var(--gala-color-border); background: transparent; color: var(--gala-color-text);
686
+ font-family: var(--gala-font-ui); font-weight: var(--gala-weight-ui); font-size: .9rem; cursor: pointer;
687
+ transition: border-color var(--gala-duration-fast) var(--gala-ease-standard), transform var(--gala-duration-fast) var(--gala-ease-standard);
688
+ }
689
+ .g-comments__more:hover { border-color: var(--gala-color-text); }
690
+ .g-comments__more:active { transform: scale(.97); }
691
+ .g-composer { display: grid; gap: var(--gala-space-3); min-width: 0; }
692
+ .g-composer label { font-size: .875rem; font-weight: var(--gala-weight-ui); color: var(--gala-color-text-muted); }
693
+ .g-composer__input, .g-report select, .g-report textarea {
694
+ width: 100%; max-width: 100%; min-height: 2.75rem; padding: .7rem .9rem; border-radius: var(--gala-radius-medium); border: var(--gala-border-width) solid var(--gala-color-border);
695
+ background: var(--gala-color-canvas); color: var(--gala-color-text); font-family: var(--gala-font-body); font-size: 1rem; line-height: 1.5;
696
+ }
697
+ .g-composer__input, .g-report textarea { min-height: 6rem; resize: vertical; }
698
+ .g-composer__input:hover, .g-report select:hover, .g-report textarea:hover { border-color: var(--gala-color-text-muted); }
699
+ .g-composer__actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--gala-space-3); justify-content: space-between; font-size: .875rem; color: var(--gala-color-text-muted); }
700
+ .g-composer__submit, .g-composer__signin, .g-report button[type="submit"] {
701
+ display: inline-flex; align-items: center; justify-content: center; gap: .55em; min-height: 2.75rem; padding: .6rem 1.2rem; border-radius: var(--gala-radius-pill);
702
+ background: var(--gala-paint-button); color: var(--gala-color-btn-text); border: var(--gala-border-button); box-shadow: var(--gala-shadow-button);
703
+ font-family: var(--gala-font-ui); font-weight: var(--gala-weight-ui); font-size: .9375rem; cursor: pointer;
704
+ transition: transform var(--gala-duration-fast) var(--gala-ease-standard), filter var(--gala-duration-base) var(--gala-ease-standard);
705
+ }
706
+ .g-composer__submit:hover:not(:disabled), .g-composer__signin:hover, .g-report button[type="submit"]:hover { filter: brightness(1.07) saturate(1.05); }
707
+ .g-composer__submit:active:not(:disabled), .g-composer__signin:active { transform: scale(.97); }
708
+ .g-composer__submit:disabled { opacity: .55; cursor: default; box-shadow: none; }
709
+ .g-composer__cancel, .g-report button[type="button"] {
710
+ min-height: 2.75rem; padding: .6rem 1rem; border-radius: var(--gala-radius-pill); border: var(--gala-border-width) solid var(--gala-color-border); background: transparent; color: var(--gala-color-text);
711
+ font-family: var(--gala-font-ui); font-weight: var(--gala-weight-ui); font-size: .9rem; cursor: pointer;
712
+ }
713
+ .g-composer__cancel:hover, .g-report button[type="button"]:hover { border-color: var(--gala-color-text); }
714
+ .g-comment-list { display: grid; gap: var(--gala-space-4); min-width: 0; }
715
+ .g-comment { display: grid; gap: var(--gala-space-2); min-width: 0; }
716
+ .g-comment__head { display: flex; align-items: center; gap: var(--gala-space-3); min-width: 0; }
717
+ .g-comment__avatar { display: inline-grid; place-items: center; width: 2.25rem; height: 2.25rem; flex: none; border-radius: var(--gala-radius-avatar); background: var(--gala-paint-button); color: var(--gala-color-btn-text); box-shadow: var(--gala-shadow-avatar-ring); font-family: var(--gala-font-display); font-weight: var(--gala-weight-display); font-size: .8rem; text-transform: uppercase; }
718
+ .g-comment__author { font-weight: var(--gala-weight-ui); font-size: .9375rem; overflow-wrap: anywhere; min-width: 0; }
719
+ .g-comment__time { font-size: .8125rem; color: var(--gala-color-text-faint); font-variant-numeric: tabular-nums; white-space: nowrap; }
720
+ .g-comment__body { margin: 0; font-family: var(--gala-font-body); font-size: 1rem; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
721
+ .g-comment__body a { color: var(--gala-color-link); text-decoration: underline; text-decoration-color: var(--gala-color-link-underline); text-underline-offset: var(--gala-link-offset); }
722
+ .g-comment__body a:hover { text-decoration-color: var(--gala-color-link-underline-hover); }
723
+ .g-comment__actions { display: flex; flex-wrap: wrap; gap: var(--gala-space-1) var(--gala-space-3); }
724
+ .g-comment__action { min-height: 2.5rem; padding: 0 .5rem; margin-left: -.5rem; border: 0; border-radius: var(--gala-radius-small); background: transparent; color: var(--gala-color-text-muted); font-family: var(--gala-font-ui); font-weight: var(--gala-weight-ui); font-size: .8125rem; cursor: pointer; transition: color var(--gala-duration-fast) var(--gala-ease-standard), background-color var(--gala-duration-fast) var(--gala-ease-standard); }
725
+ .g-comment__action:hover { color: var(--gala-color-text); background: var(--gala-color-surface-raised); }
726
+ .g-comment__replies { display: grid; gap: var(--gala-space-4); min-width: 0; margin-top: var(--gala-space-2); padding-left: var(--gala-space-4); border-left: var(--gala-border-width) solid var(--gala-color-border); }
727
+ .g-comment--tombstone > .g-comment__body { font-style: italic; color: var(--gala-color-text-faint); }
728
+ .g-comment--mine > .g-comment__head .g-comment__avatar { box-shadow: 0 0 0 2px var(--gala-color-accent); }
729
+ .g-report { width: min(28rem, calc(100% - 2rem)); max-width: 100%; padding: var(--gala-space-6); border-radius: var(--gala-radius-large); border: var(--gala-border-card); background: var(--gala-color-surface); color: var(--gala-color-text); box-shadow: var(--gala-shadow-dialog); font-family: var(--gala-font-ui); }
730
+ .g-report::backdrop { background: var(--gala-color-overlay); }
731
+ .g-report form { display: grid; gap: var(--gala-space-3); }
732
+ .g-report menu { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--gala-space-3); margin: 0; padding: 0; }
733
+
657
734
  /* toast */
658
735
  .g-toast {
659
736
  position: fixed; left: 50%; bottom: var(--gala-space-6); z-index: 60; max-width: calc(100% - 2rem); padding: .7rem 1.1rem;
@@ -709,6 +786,10 @@ export const GALA_BASE_COMPONENT_CSS = `
709
786
  .g-article-cover img { aspect-ratio: 16 / 10; }
710
787
  .g-footer-grid { grid-template-columns: minmax(0, 1fr); }
711
788
  .g-author-card { flex-direction: column; }
789
+ .g-interactions { padding: var(--gala-space-4); }
790
+ .g-comment__replies { padding-left: var(--gala-space-3); }
791
+ .g-composer__actions { flex-direction: column; align-items: stretch; }
792
+ .g-composer__submit, .g-composer__signin { width: 100%; }
712
793
  .g-topic-head { align-items: flex-start; }
713
794
  }
714
795
 
@@ -49,7 +49,7 @@
49
49
  * or a `background-image`.
50
50
  *
51
51
  * Contract 2.1.0 (TPL-H2/TPL-H3/TPL-M7 fix): S2.0's empty `pseudoClasses`
52
- * catalog made every interaction state unstylable while the closed 35-token
52
+ * catalog made every interaction state unstylable while the closed 116-token
53
53
  * catalog already required `color-focus` and `color-link-visited`, tokens
54
54
  * only a pseudo-class can ever apply. {@link PSEUDO_CLASSES} publishes a
55
55
  * small closed catalog instead of widening indefinitely; the module
@@ -628,7 +628,7 @@ export const TEMPLATE_STYLING_CONTRACT_INVALID =
628
628
  * Structural self-validation of a `templateStylingContract` object against
629
629
  * its closed shape (the checks this repository can run
630
630
  * without a published `urn:gala:schema:template-styling-contract` schema
631
- * entry — no such schema ID is registered in `@rathnasgala2/schemas@2.11.0`;
631
+ * entry — no such schema ID is registered in `@rathnasgala2/schemas@3.0.0`;
632
632
  * only `urn:gala:schema:theme-contract:2.0.0` is, which this module's sibling
633
633
  * theme fixtures are validated against with the schema package's own
634
634
  * exported `validateGalaDocument`, see `test/theme-styling-contract.test.js`).
@@ -81,28 +81,20 @@ export function monogram(name) {
81
81
 
82
82
  /**
83
83
  * Render the optional newsletter panel: a call-to-action link to the
84
- * sign-up page the author configured in publication settings, never a form.
85
- * Reads `publication.newsletter` defensively: absent, or without an
86
- * `https:` `url`, renders nothing.
84
+ * sign-up page configured in `publication.newsletter` (validated by the
85
+ * build-input schema: an `https:` URL, a title and a short text), never a
86
+ * form. Renders nothing when the publication has no newsletter.
87
87
  *
88
88
  * @param {object} options rendering options
89
- * @param {unknown} options.newsletter `publication.newsletter` (may be
90
- * undefined on a build input that predates the field)
89
+ * @param {{url: string, title: string, text: string} | undefined} options.newsletter
90
+ * `publication.newsletter`
91
91
  * @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} options.messages
92
92
  * the resolved message catalog
93
93
  * @returns {string} the panel markup, or an empty string
94
94
  */
95
95
  export function renderNewsletterPanel({ newsletter, messages }) {
96
- const value = /** @type {Record<string, unknown> | undefined} */ (
97
- newsletter && typeof newsletter === 'object' ? newsletter : undefined
98
- );
99
- if (!value || typeof value.url !== 'string' || !/^https:\/\//.test(value.url))
100
- return '';
101
- const title =
102
- typeof value.title === 'string' && value.title !== ''
103
- ? value.title
104
- : /** @type {string} */ (messages.newsletterLabel);
105
- const text = typeof value.text === 'string' ? value.text : '';
96
+ if (!newsletter) return '';
97
+ const { url, title, text } = newsletter;
106
98
  return (
107
99
  `<section class="g-wrap g-newsletter" aria-labelledby="newsletter-title">` +
108
100
  `<div class="g-panel">` +
@@ -111,7 +103,7 @@ export function renderNewsletterPanel({ newsletter, messages }) {
111
103
  `<h2 id="newsletter-title">${escapeHtml(title)}</h2>` +
112
104
  (text ? `<p>${escapeHtml(text)}</p>` : '') +
113
105
  `</div>` +
114
- `<a class="g-btn" href="${escapeHtml(value.url)}" rel="noopener">${escapeHtml(/** @type {string} */ (messages.newsletterActionLabel))}${icon('arrow')}</a>` +
106
+ `<a class="g-btn" href="${escapeHtml(url)}" rel="noopener">${escapeHtml(/** @type {string} */ (messages.newsletterActionLabel))}${icon('arrow')}</a>` +
115
107
  `</div></section>`
116
108
  );
117
109
  }
@@ -38,7 +38,6 @@ import {
38
38
  APPEARANCE_SERVER_DEFAULT_RESOLVED_MODE,
39
39
  COLOR_SCHEME_META_CONTENT,
40
40
  } from './appearance/contract.js';
41
- import { contentSecurityPolicyMetaTag } from './content-security.js';
42
41
  import { PAGE_KIND_ATTRIBUTE } from './page-kinds.js';
43
42
 
44
43
  /**
@@ -133,9 +132,12 @@ const SKELETON_LAYOUT_SOURCE = [
133
132
  '<head>',
134
133
  '<meta charset="utf-8">',
135
134
  '<meta name="viewport" content="width=device-width, initial-scale=1">',
136
- contentSecurityPolicyMetaTag(),
135
+ '{{ cspMetaTag | safe }}',
137
136
  `<meta name="color-scheme" content="${COLOR_SCHEME_META_CONTENT}">`,
138
- '<script src="{{ appearanceScriptHref }}"></script>',
137
+ // The interactions script (article pages, module on) is deferred and sits
138
+ // on the appearance script's line, so a page without it is byte-identical
139
+ // to before.
140
+ '<script src="{{ appearanceScriptHref }}"></script>{% if interactionsScriptHref %}<script src="{{ interactionsScriptHref }}" defer></script>{% endif %}',
139
141
  '<title>{{ title }}</title>',
140
142
  '{% if description %}<meta name="description" content="{{ description }}">{% endif %}',
141
143
  '{% if robotsContent %}<meta name="robots" content="{{ robotsContent }}">{% endif %}',
@@ -35,6 +35,8 @@ export const ICONS = Object.freeze({
35
35
  '<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/><path d="M19 16l.7 2 2 .7-2 .7-.7 2-.7-2-2-.7 2-.7z"/>',
36
36
  list: '<path d="M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01"/>',
37
37
  layers: '<path d="m12 3 9 5-9 5-9-5z"/><path d="m3 13 9 5 9-5"/>',
38
+ heart:
39
+ '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7z"/>',
38
40
  pen: '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13 7 4 4"/>',
39
41
  });
40
42