@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.
@@ -524,6 +524,7 @@ export const GALA_BASE_COMPONENT_CSS = `
524
524
  .g-article-head h1 { font-size: clamp(2.25rem, 1.2rem + 3.8vw, var(--gala-display-max)); }
525
525
  .g-article-meta { display: flex; align-items: center; justify-content: space-between; gap: var(--gala-space-4); width: 100%; padding-top: 1.1rem; border-top: var(--gala-border-width) solid var(--gala-color-border); flex-wrap: wrap; }
526
526
  .g-share { display: flex; gap: var(--gala-space-1); }
527
+ [data-gala-slot="edition-selector"] { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--gala-space-2); min-width: 0; max-width: 100%; }
527
528
  .g-article-cover { margin-block: 0; }
528
529
  .g-article-cover img { width: 100%; height: auto; aspect-ratio: 21 / 9; object-fit: cover; border-radius: var(--gala-radius-large); border: var(--gala-border-card); box-shadow: var(--gala-shadow-card); filter: var(--gala-media-filter); }
529
530
  .g-article-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, var(--gala-content-measure)) minmax(0, 1fr); gap: clamp(1.5rem, 4vw, 3.5rem); padding-block: clamp(2rem, 5vw, 3.5rem) var(--gala-space-4); }
@@ -654,6 +655,83 @@ export const GALA_BASE_COMPONENT_CSS = `
654
655
  [aria-selected="true"] > .g-search-hit > .g-icon { opacity: 1; color: var(--gala-color-accent); }
655
656
  .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
657
 
658
+ /* reader interactions: server-rendered section and the classes the browser script creates */
659
+ .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); }
660
+ .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; }
661
+ .g-interactions__notice, .g-interactions__status { font-size: .875rem; color: var(--gala-color-text-muted); overflow-wrap: anywhere; }
662
+ .g-interactions__status:empty { display: none; }
663
+ .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; }
664
+ .g-interactions__signout:hover { color: var(--gala-color-text); }
665
+ .g-reactions { display: flex; flex-wrap: wrap; gap: var(--gala-space-2); }
666
+ .g-reaction {
667
+ display: inline-flex; align-items: center; gap: .5em; min-height: 2.75rem; max-width: 100%; padding: .45rem .95rem;
668
+ 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);
669
+ font-family: var(--gala-font-ui); font-weight: var(--gala-weight-ui); font-size: .9rem; line-height: 1.2; cursor: pointer;
670
+ 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);
671
+ }
672
+ .g-reaction:hover:not(:disabled) { border-color: var(--gala-color-text); color: var(--gala-color-text); }
673
+ .g-reaction:active:not(:disabled) { transform: scale(.96); }
674
+ .g-reaction:disabled { opacity: .55; cursor: default; }
675
+ .g-reaction__icon { display: inline-grid; place-items: center; font-size: 1.1rem; line-height: 1; }
676
+ .g-reaction__icon .g-icon { width: 1.15rem; height: 1.15rem; }
677
+ .g-reaction__label { overflow-wrap: anywhere; }
678
+ .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); }
679
+ .g-reaction__count:empty { display: none; }
680
+ .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); }
681
+ .g-reaction[aria-pressed="true"] .g-icon, .g-reaction--active .g-icon { fill: currentColor; color: var(--gala-color-accent); }
682
+ .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); }
683
+ .g-comments__title { font-family: var(--gala-font-display); font-weight: var(--gala-weight-title); font-size: 1.05rem; }
684
+ .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; }
685
+ .g-comments__more {
686
+ 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);
687
+ font-family: var(--gala-font-ui); font-weight: var(--gala-weight-ui); font-size: .9rem; cursor: pointer;
688
+ transition: border-color var(--gala-duration-fast) var(--gala-ease-standard), transform var(--gala-duration-fast) var(--gala-ease-standard);
689
+ }
690
+ .g-comments__more:hover { border-color: var(--gala-color-text); }
691
+ .g-comments__more:active { transform: scale(.97); }
692
+ .g-composer { display: grid; gap: var(--gala-space-3); min-width: 0; }
693
+ .g-composer label { font-size: .875rem; font-weight: var(--gala-weight-ui); color: var(--gala-color-text-muted); }
694
+ .g-composer__input, .g-report select, .g-report textarea {
695
+ 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);
696
+ background: var(--gala-color-canvas); color: var(--gala-color-text); font-family: var(--gala-font-body); font-size: 1rem; line-height: 1.5;
697
+ }
698
+ .g-composer__input, .g-report textarea { min-height: 6rem; resize: vertical; }
699
+ .g-composer__input:hover, .g-report select:hover, .g-report textarea:hover { border-color: var(--gala-color-text-muted); }
700
+ .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); }
701
+ .g-composer__submit, .g-composer__signin, .g-report button[type="submit"] {
702
+ 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);
703
+ background: var(--gala-paint-button); color: var(--gala-color-btn-text); border: var(--gala-border-button); box-shadow: var(--gala-shadow-button);
704
+ font-family: var(--gala-font-ui); font-weight: var(--gala-weight-ui); font-size: .9375rem; cursor: pointer;
705
+ transition: transform var(--gala-duration-fast) var(--gala-ease-standard), filter var(--gala-duration-base) var(--gala-ease-standard);
706
+ }
707
+ .g-composer__submit:hover:not(:disabled), .g-composer__signin:hover, .g-report button[type="submit"]:hover { filter: brightness(1.07) saturate(1.05); }
708
+ .g-composer__submit:active:not(:disabled), .g-composer__signin:active { transform: scale(.97); }
709
+ .g-composer__submit:disabled { opacity: .55; cursor: default; box-shadow: none; }
710
+ .g-composer__cancel, .g-report button[type="button"] {
711
+ 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);
712
+ font-family: var(--gala-font-ui); font-weight: var(--gala-weight-ui); font-size: .9rem; cursor: pointer;
713
+ }
714
+ .g-composer__cancel:hover, .g-report button[type="button"]:hover { border-color: var(--gala-color-text); }
715
+ .g-comment-list { display: grid; gap: var(--gala-space-4); min-width: 0; }
716
+ .g-comment { display: grid; gap: var(--gala-space-2); min-width: 0; }
717
+ .g-comment__head { display: flex; align-items: center; gap: var(--gala-space-3); min-width: 0; }
718
+ .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; }
719
+ .g-comment__author { font-weight: var(--gala-weight-ui); font-size: .9375rem; overflow-wrap: anywhere; min-width: 0; }
720
+ .g-comment__time { font-size: .8125rem; color: var(--gala-color-text-faint); font-variant-numeric: tabular-nums; white-space: nowrap; }
721
+ .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; }
722
+ .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); }
723
+ .g-comment__body a:hover { text-decoration-color: var(--gala-color-link-underline-hover); }
724
+ .g-comment__actions { display: flex; flex-wrap: wrap; gap: var(--gala-space-1) var(--gala-space-3); }
725
+ .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); }
726
+ .g-comment__action:hover { color: var(--gala-color-text); background: var(--gala-color-surface-raised); }
727
+ .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); }
728
+ .g-comment--tombstone > .g-comment__body { font-style: italic; color: var(--gala-color-text-faint); }
729
+ .g-comment--mine > .g-comment__head .g-comment__avatar { box-shadow: 0 0 0 2px var(--gala-color-accent); }
730
+ .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); }
731
+ .g-report::backdrop { background: var(--gala-color-overlay); }
732
+ .g-report form { display: grid; gap: var(--gala-space-3); }
733
+ .g-report menu { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--gala-space-3); margin: 0; padding: 0; }
734
+
657
735
  /* toast */
658
736
  .g-toast {
659
737
  position: fixed; left: 50%; bottom: var(--gala-space-6); z-index: 60; max-width: calc(100% - 2rem); padding: .7rem 1.1rem;
@@ -709,6 +787,10 @@ export const GALA_BASE_COMPONENT_CSS = `
709
787
  .g-article-cover img { aspect-ratio: 16 / 10; }
710
788
  .g-footer-grid { grid-template-columns: minmax(0, 1fr); }
711
789
  .g-author-card { flex-direction: column; }
790
+ .g-interactions { padding: var(--gala-space-4); }
791
+ .g-comment__replies { padding-left: var(--gala-space-3); }
792
+ .g-composer__actions { flex-direction: column; align-items: stretch; }
793
+ .g-composer__submit, .g-composer__signin { width: 100%; }
712
794
  .g-topic-head { align-items: flex-start; }
713
795
  }
714
796
 
@@ -142,6 +142,9 @@ function renderSocialLinks(socialLinks, rssHref, messages) {
142
142
  * @param {string} options.footerProfileHtml the already-rendered footer card
143
143
  * body (may be empty)
144
144
  * @param {string | undefined} options.copyrightText the copyright line
145
+ * @param {boolean} options.showAttribution whether the Galascribe
146
+ * attribution line renders: the build input's
147
+ * `appearance.attribution.showMadeWith`, shown when absent
145
148
  * @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} options.messages
146
149
  * @returns {string} the rendered footer
147
150
  */
@@ -158,6 +161,7 @@ export function renderFooter({
158
161
  authorHtml,
159
162
  footerProfileHtml,
160
163
  copyrightText,
164
+ showAttribution,
161
165
  messages,
162
166
  }) {
163
167
  const label = (/** @type {string} */ key) =>
@@ -190,10 +194,9 @@ export function renderFooter({
190
194
  renderSlot('account-intent') +
191
195
  renderSlot('conversation') +
192
196
  renderSlot('newsletter') +
193
- renderSlot('edition-selector') +
194
197
  `<div class="g-wrap g-footer-base">` +
195
198
  (copyrightText ? `<span>${escapeHtml(copyrightText)}</span>` : '') +
196
- `<span>${label('footerAttributionLabel')}</span>` +
199
+ (showAttribution ? `<span>${label('footerAttributionLabel')}</span>` : '') +
197
200
  `</div></footer>`
198
201
  );
199
202
  }
@@ -14,6 +14,7 @@
14
14
  * published one is never rendered.
15
15
  */
16
16
 
17
+ import { RenderPolicyViolationError } from '../errors.js';
17
18
  import { escapeHtml } from './skeleton.js';
18
19
  import { icon } from './icons.js';
19
20
  import { joinBasePathAndRoute } from './route.js';
@@ -197,18 +198,76 @@ export function renderImage({
197
198
  }
198
199
 
199
200
  /**
200
- * Add `loading="lazy" decoding="async"` to every `<img>` in an
201
- * already-sanitized body. Applied at render time only: the body's own
202
- * digest is over the stored bytes and is never recomputed.
201
+ * The widest slot a content image fills, in CSS pixels: the prose column
202
+ * never exceeds it, so a content image's `src` is its largest output file
203
+ * no wider than this.
204
+ */
205
+ export const CONTENT_IMAGE_SLOT_WIDTH = 960;
206
+
207
+ /** The `sizes` value every content image carries (the slot above). */
208
+ export const CONTENT_IMAGE_SIZES = `(max-width: ${CONTENT_IMAGE_SLOT_WIDTH}px) 100vw, ${CONTENT_IMAGE_SLOT_WIDTH}px`;
209
+
210
+ /**
211
+ * Build the two renderers for a content image (an image a body references,
212
+ * already resolved by `media/content-images.js` and processed by the media
213
+ * pipeline). The page form names every output file of the image in
214
+ * `srcset`; its `src` is the largest output file no wider than
215
+ * {@link CONTENT_IMAGE_SLOT_WIDTH}, or the original when the image has no
216
+ * narrower file (a GIF, WebP or AVIF passes through as its original only).
217
+ * Applied at render time only: the body's own digest is over the stored
218
+ * bytes and is never recomputed.
203
219
  *
204
- * @param {string} bodyHtml sanitized body HTML
205
- * @returns {string} the body with loading hints on its images
220
+ * @param {object} options
221
+ * @param {Readonly<Record<string, readonly {path: string, width: number, height: number}[]>>} options.mediaVariants
222
+ * the media pipeline's output files per source digest, ascending width
223
+ * @param {string} options.publicBasePath the public base path
224
+ * @returns {{page: (reference: {path: string, sourceDigest: string}, altAttribute: string) => string, feed: (reference: {path: string, sourceDigest: string}, altAttribute: string) => string}}
225
+ * the page form (responsive, lazy) and the feed form (one `src`, no
226
+ * loading hints); `altAttribute` is the body's own entity-encoded `alt`
206
227
  */
207
- export function lazyBodyImages(bodyHtml) {
208
- return bodyHtml.replace(
209
- /<img\b(?![^>]*\bloading=)/g,
210
- '<img loading="lazy" decoding="async"',
211
- );
228
+ export function createContentImage({ mediaVariants, publicBasePath }) {
229
+ /**
230
+ * @param {{path: string, sourceDigest: string}} reference
231
+ * @returns {{url: string, width: number, height: number}[]} every output
232
+ * file of the image, ascending width
233
+ */
234
+ const filesOf = (reference) => {
235
+ const variants = mediaVariants[reference.sourceDigest] ?? [];
236
+ if (variants.length === 0) {
237
+ throw new RenderPolicyViolationError(
238
+ `content image ${reference.path} has no media pipeline output; this is a renderer defect`,
239
+ );
240
+ }
241
+ return variants.map((variant) => ({
242
+ url: joinBasePathAndRoute(publicBasePath, `/${variant.path}`),
243
+ width: variant.width,
244
+ height: variant.height,
245
+ }));
246
+ };
247
+ /**
248
+ * @param {{url: string, width: number, height: number}[]} files
249
+ * @returns {{url: string, width: number, height: number}} the `src` file
250
+ */
251
+ const fitting = (files) =>
252
+ files.filter((file) => file.width <= CONTENT_IMAGE_SLOT_WIDTH).at(-1) ??
253
+ files[0];
254
+ return {
255
+ page(reference, altAttribute) {
256
+ const files = filesOf(reference);
257
+ const source = fitting(files);
258
+ const srcset = files
259
+ .map((file) => `${file.url} ${file.width}w`)
260
+ .join(', ');
261
+ return (
262
+ `<img src="${escapeHtml(source.url)}" srcset="${escapeHtml(srcset)}" sizes="${CONTENT_IMAGE_SIZES}"` +
263
+ ` alt="${altAttribute}" width="${source.width}" height="${source.height}" loading="lazy" decoding="async">`
264
+ );
265
+ },
266
+ feed(reference, altAttribute) {
267
+ const source = fitting(filesOf(reference));
268
+ return `<img src="${escapeHtml(source.url)}" alt="${altAttribute}" width="${source.width}" height="${source.height}">`;
269
+ },
270
+ };
212
271
  }
213
272
 
214
273
  /**
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Editions: repository documents of `kind: "edition"` that restate one
3
+ * article at another depth (a quick read, a standard edition, a deep dive).
4
+ *
5
+ * An edition is an ordinary rendered document, not a module: the build input
6
+ * already carries it as a `content[]` record whose front matter names the
7
+ * article it restates (`edition.of`, the article's slug) and its depth
8
+ * (`edition.kind`). Whether the edition is current (generated from the
9
+ * article's present text) is decided upstream, before the build input exists,
10
+ * so an edition that arrives here is rendered as given.
11
+ *
12
+ * What this renderer does with one:
13
+ *
14
+ * - it renders at `<article route>/<segment>` where the segment is
15
+ * `quick-read`, `standard` or `deep-dive`, built from `edition.of`
16
+ * ({@link editionRoute}), so `/post` has `/post/quick-read` beside it;
17
+ * - its `<link rel="canonical">` names the article and its robots directive is
18
+ * `noindex, follow`, so the article stays the indexed reference;
19
+ * - it never appears in a listing, tag or series page, a feed, the sitemap, the
20
+ * search index or the `llms` documents (each of those selects `kind:
21
+ * "article"` / `"page"` records, or skips `noindex` pages);
22
+ * - the article and each of its editions carry the same accessible selector,
23
+ * a `<nav aria-label="Editions">` of links in the core `edition-selector`
24
+ * slot, listing the original and each edition that exists, the current page
25
+ * marked `aria-current="page"`; an edition page also carries a one-line
26
+ * label saying the page is a generated edition and the original is the
27
+ * reference.
28
+ *
29
+ * Styling is the base layer's: the selector uses the existing pill hooks, so
30
+ * the published theme styling contract does not change.
31
+ *
32
+ * An edition that cannot be placed is a malformed build input and fails closed
33
+ * before anything is written ({@link resolveEditions}).
34
+ */
35
+
36
+ import { BuildInputValidationError } from '../errors.js';
37
+ import { icon } from './icons.js';
38
+ import { escapeHtml, renderSlot } from './skeleton.js';
39
+
40
+ /** @typedef {'QUICK_READ' | 'STANDARD' | 'DEEP_DIVE'} EditionKind */
41
+
42
+ /**
43
+ * One edition placed beside its article.
44
+ *
45
+ * @typedef {object} EditionEntry
46
+ * @property {import('../../../types/index.d.ts').ContentBuildRecord} record
47
+ * the edition document
48
+ * @property {import('../../../types/index.d.ts').ContentBuildRecord} original
49
+ * the article it restates
50
+ * @property {EditionKind} kind its depth
51
+ * @property {string} route its un-joined route
52
+ */
53
+
54
+ /**
55
+ * Every edition of a build input.
56
+ *
57
+ * @typedef {object} EditionResolution
58
+ * @property {ReadonlyMap<import('../../../types/index.d.ts').ContentBuildRecord, EditionEntry>} byRecord
59
+ * each edition record's placement
60
+ * @property {ReadonlyMap<import('../../../types/index.d.ts').ContentBuildRecord, readonly EditionEntry[]>} byOriginal
61
+ * each article's editions, in selector order (quick read, standard, deep dive)
62
+ */
63
+
64
+ /**
65
+ * The edition kinds in the order the selector lists them.
66
+ *
67
+ * @type {readonly EditionKind[]}
68
+ */
69
+ export const EDITION_KINDS = Object.freeze([
70
+ 'QUICK_READ',
71
+ 'STANDARD',
72
+ 'DEEP_DIVE',
73
+ ]);
74
+
75
+ /** @type {Readonly<Record<EditionKind, string>>} */
76
+ const EDITION_SEGMENTS = Object.freeze({
77
+ QUICK_READ: 'quick-read',
78
+ STANDARD: 'standard',
79
+ DEEP_DIVE: 'deep-dive',
80
+ });
81
+
82
+ /** @type {Readonly<Record<EditionKind, string>>} */
83
+ const EDITION_LABEL_KEYS = Object.freeze({
84
+ QUICK_READ: 'editionQuickReadLabel',
85
+ STANDARD: 'editionStandardLabel',
86
+ DEEP_DIVE: 'editionDeepDiveLabel',
87
+ });
88
+
89
+ /** Where a reader of an edition diagnostic finds the rule. */
90
+ const DOCUMENTATION_URL = 'https://github.com/rathnasgala2/template#editions';
91
+
92
+ /**
93
+ * @param {{of: string, kind: EditionKind}} edition an edition's front-matter
94
+ * `edition` object
95
+ * @returns {string} the edition's un-joined route, `/<of>/<segment>`
96
+ */
97
+ export function editionRoute(edition) {
98
+ return `/${edition.of}/${EDITION_SEGMENTS[edition.kind]}`;
99
+ }
100
+
101
+ /**
102
+ * @param {EditionKind} kind an edition kind
103
+ * @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} messages
104
+ * the resolved message catalog
105
+ * @returns {string} the kind's plain-text label ("Quick read")
106
+ */
107
+ export function editionKindLabel(kind, messages) {
108
+ return /** @type {string} */ (messages[EDITION_LABEL_KEYS[kind]]);
109
+ }
110
+
111
+ /**
112
+ * @param {string} code the diagnostic code
113
+ * @param {number} index the record's index in `content`
114
+ * @param {string} sourcePath the record's repository source path
115
+ * @param {string} remediation what the author or producer should change
116
+ * @returns {Record<string, string>} one diagnostic, shaped like a schema
117
+ * validation diagnostic
118
+ */
119
+ function diagnostic(code, index, sourcePath, remediation) {
120
+ return {
121
+ code,
122
+ severity: 'ERROR',
123
+ instancePointer: `/content/${index}`,
124
+ actualValueClass: 'object',
125
+ rule: 'template:edition',
126
+ sourcePath,
127
+ remediation,
128
+ documentationUrl: DOCUMENTATION_URL,
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Place every edition beside the article it restates, before anything is
134
+ * written. Fails closed with one diagnostic per edition that cannot be placed:
135
+ * its `edition.of` names no `kind: "article"` document in this build
136
+ * (`EDITION_ORIGINAL_UNRESOLVED`), two editions share an article and a depth
137
+ * (`EDITION_DUPLICATE`), or its route is already another document's route or
138
+ * redirect (`EDITION_ROUTE_CONFLICT`).
139
+ *
140
+ * @param {readonly import('../../../types/index.d.ts').ContentBuildRecord[]} content
141
+ * the build input's `content[]`
142
+ * @param {(frontmatter: import('../../../types/index.d.ts').ContentFrontmatterNormalized) => string} routeOf
143
+ * a document's own un-joined route
144
+ * @returns {EditionResolution} every edition's placement
145
+ * @throws {BuildInputValidationError} when an edition cannot be placed
146
+ */
147
+ export function resolveEditions(content, routeOf) {
148
+ /** @type {Map<string, import('../../../types/index.d.ts').ContentBuildRecord>} */
149
+ const articles = new Map();
150
+ /** @type {Set<string>} */
151
+ const occupied = new Set();
152
+ for (const record of content) {
153
+ const { frontmatter } = record;
154
+ if (frontmatter.kind === 'edition') continue;
155
+ if (frontmatter.kind === 'article') articles.set(frontmatter.slug, record);
156
+ occupied.add(routeOf(frontmatter));
157
+ for (const source of frontmatter.redirects) occupied.add(source);
158
+ }
159
+
160
+ /** @type {Map<import('../../../types/index.d.ts').ContentBuildRecord, EditionEntry>} */
161
+ const byRecord = new Map();
162
+ /** @type {Map<import('../../../types/index.d.ts').ContentBuildRecord, EditionEntry[]>} */
163
+ const byOriginal = new Map();
164
+ /** @type {Set<string>} */
165
+ const claimed = new Set();
166
+ /** @type {Record<string, string>[]} */
167
+ const diagnostics = [];
168
+
169
+ content.forEach((record, index) => {
170
+ const { frontmatter } = record;
171
+ if (frontmatter.kind !== 'edition') return;
172
+ const { edition } = frontmatter;
173
+ const original = edition ? articles.get(edition.of) : undefined;
174
+ if (!edition || !original) {
175
+ diagnostics.push(
176
+ diagnostic(
177
+ 'EDITION_ORIGINAL_UNRESOLVED',
178
+ index,
179
+ record.sourcePath,
180
+ `The edition ${record.sourcePath} restates "${edition?.of ?? ''}", which is not an article in this build. Publish the article with it, or remove the edition.`,
181
+ ),
182
+ );
183
+ return;
184
+ }
185
+ const route = editionRoute(edition);
186
+ if (occupied.has(route)) {
187
+ diagnostics.push(
188
+ diagnostic(
189
+ 'EDITION_ROUTE_CONFLICT',
190
+ index,
191
+ record.sourcePath,
192
+ `The edition ${record.sourcePath} would render at ${route}, which another document already uses. Rename or move that document.`,
193
+ ),
194
+ );
195
+ return;
196
+ }
197
+ if (claimed.has(route)) {
198
+ diagnostics.push(
199
+ diagnostic(
200
+ 'EDITION_DUPLICATE',
201
+ index,
202
+ record.sourcePath,
203
+ `The article "${edition.of}" already has a ${edition.kind} edition. Keep one.`,
204
+ ),
205
+ );
206
+ return;
207
+ }
208
+ claimed.add(route);
209
+ /** @type {EditionEntry} */
210
+ const entry = { record, original, kind: edition.kind, route };
211
+ byRecord.set(record, entry);
212
+ byOriginal.set(original, [...(byOriginal.get(original) ?? []), entry]);
213
+ });
214
+
215
+ if (diagnostics.length > 0) throw new BuildInputValidationError(diagnostics);
216
+
217
+ for (const [original, entries] of byOriginal) {
218
+ byOriginal.set(
219
+ original,
220
+ entries.sort(
221
+ (a, b) => EDITION_KINDS.indexOf(a.kind) - EDITION_KINDS.indexOf(b.kind),
222
+ ),
223
+ );
224
+ }
225
+ return { byRecord, byOriginal };
226
+ }
227
+
228
+ /**
229
+ * The editions a reader is offered next to an article: every published one,
230
+ * plus the one being read. An `unlisted` edition stays reachable by its own
231
+ * address but is not advertised.
232
+ *
233
+ * @param {readonly EditionEntry[] | undefined} entries every edition of the
234
+ * article, in selector order
235
+ * @param {EditionEntry | undefined} current the edition being read, if any
236
+ * @returns {EditionEntry[]} the editions to list
237
+ */
238
+ export function listedEditions(entries, current) {
239
+ return (entries ?? []).filter(
240
+ (entry) =>
241
+ entry === current || entry.record.frontmatter.status === 'published',
242
+ );
243
+ }
244
+
245
+ /**
246
+ * Render the `edition-selector` slot for an article or one of its editions.
247
+ *
248
+ * @param {object} options rendering options
249
+ * @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} options.messages
250
+ * the resolved message catalog
251
+ * @param {string} options.originalHref the article's joined route
252
+ * @param {readonly EditionEntry[]} options.entries the editions to list, in
253
+ * selector order ({@link listedEditions})
254
+ * @param {EditionEntry | undefined} options.current the edition being read;
255
+ * `undefined` on the article itself
256
+ * @param {(route: string) => string} options.site joins a route with the
257
+ * public base path
258
+ * @returns {string} the slot, or an empty string when there is no edition to
259
+ * offer (a slot with nothing to say is not rendered)
260
+ */
261
+ export function renderEditionSelector({
262
+ messages,
263
+ originalHref,
264
+ entries,
265
+ current,
266
+ site,
267
+ }) {
268
+ if (entries.length === 0) return '';
269
+ const text = (/** @type {string} */ key) =>
270
+ escapeHtml(/** @type {string} */ (messages[key]));
271
+ const item = (
272
+ /** @type {string} */ href,
273
+ /** @type {string} */ label,
274
+ /** @type {string} */ iconName,
275
+ /** @type {boolean} */ isCurrent,
276
+ ) =>
277
+ `<li><a class="g-pill" href="${escapeHtml(href)}"${isCurrent ? ' aria-current="page"' : ''}>` +
278
+ `${icon(iconName)}<span>${label}</span></a></li>`;
279
+ const links = [
280
+ item(originalHref, text('editionOriginalLabel'), 'pen', !current),
281
+ ...entries.map((entry) =>
282
+ item(
283
+ site(entry.route),
284
+ escapeHtml(editionKindLabel(entry.kind, messages)),
285
+ 'sparkle',
286
+ entry === current,
287
+ ),
288
+ ),
289
+ ].join('');
290
+ return renderSlot('edition-selector', {
291
+ html:
292
+ `<nav aria-label="${text('editionsNavigationLabel')}"><ul class="g-pills">${links}</ul></nav>` +
293
+ (current
294
+ ? `<p class="g-label">${icon('sparkle')}${text('editionNoticeLabel')}</p>`
295
+ : ''),
296
+ });
297
+ }
@@ -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 %}',
@@ -18,7 +18,7 @@
18
18
  * media pipeline scope decisions in the package README).
19
19
  */
20
20
 
21
- import { escapeHtml } from './skeleton.js';
21
+ import { escapeHtml, unescapeHtml } from './skeleton.js';
22
22
  import { derivePublicBasePath, joinPublicRoute } from './route.js';
23
23
  import { deriveAuthorSlugs } from './route-labels.js';
24
24
  import {
@@ -48,19 +48,6 @@ function toRfc822(rfc3339) {
48
48
  return new Date(rfc3339).toUTCString();
49
49
  }
50
50
 
51
- /**
52
- * @param {string} value an attribute value as it appears in HTML source
53
- * @returns {string} the value with the five predefined entities decoded
54
- */
55
- function decodeAttribute(value) {
56
- return value
57
- .replace(/&quot;/g, '"')
58
- .replace(/&#39;/g, "'")
59
- .replace(/&lt;/g, '<')
60
- .replace(/&gt;/g, '>')
61
- .replace(/&amp;/g, '&');
62
- }
63
-
64
51
  /**
65
52
  * Make every `href`/`src` in an article body absolute against the article's
66
53
  * own URL: a feed reader renders the body away from the site, so a relative
@@ -75,7 +62,7 @@ export function absolutizeBodyLinks(bodyHtml, pageUrl) {
75
62
  /\b(href|src)="([^"]*)"/g,
76
63
  (match, attribute, value) => {
77
64
  try {
78
- return `${attribute}="${escapeHtml(new URL(decodeAttribute(value), pageUrl).toString())}"`;
65
+ return `${attribute}="${escapeHtml(new URL(unescapeHtml(value), pageUrl).toString())}"`;
79
66
  } catch {
80
67
  return match;
81
68
  }
@@ -99,11 +86,14 @@ function cdata(text) {
99
86
  * validated `build-input:2.0.0` instance.
100
87
  *
101
88
  * @param {import('../../../types/index.d.ts').NormalizedBuildInput} validatedInput
89
+ * @param {object} options feed options
90
+ * @param {(body: string) => string} options.feedBody turns an article body
91
+ * into its feed form (every image naming its media derivative)
102
92
  * @returns {{atomXml: string, rssXml: string, atomSelfUrl: string, rssSelfUrl: string}}
103
93
  * both feed documents' exact UTF-8 text, plus each feed's own absolute
104
94
  * self URL (for the shared layout's feed-discovery `<link>` tags)
105
95
  */
106
- export function buildFeeds(validatedInput) {
96
+ export function buildFeeds(validatedInput, { feedBody }) {
107
97
  const { publication, authors, content, basePath, baseUrl } = validatedInput;
108
98
  const authorsById = new Map(authors.map((author) => [author.id, author]));
109
99
  const authorSlugs = deriveAuthorSlugs(authors);
@@ -144,7 +134,7 @@ export function buildFeeds(validatedInput) {
144
134
  const categoriesXml = frontmatter.tags
145
135
  .map((tag) => `<category term="${escapeHtml(tag)}"/>`)
146
136
  .join('');
147
- const contentXml = `<content type="html">${escapeHtml(absolutizeBodyLinks(record.body, entryUrl))}</content>`;
137
+ const contentXml = `<content type="html">${escapeHtml(absolutizeBodyLinks(feedBody(record.body), entryUrl))}</content>`;
148
138
  const summaryXml = frontmatter.description
149
139
  ? `<summary>${escapeHtml(frontmatter.description)}</summary>`
150
140
  : '';
@@ -200,7 +190,7 @@ export function buildFeeds(validatedInput) {
200
190
  creatorsXml +
201
191
  categoriesXml +
202
192
  descriptionXml +
203
- `<content:encoded>${cdata(absolutizeBodyLinks(record.body, itemUrl))}</content:encoded>` +
193
+ `<content:encoded>${cdata(absolutizeBodyLinks(feedBody(record.body), itemUrl))}</content:encoded>` +
204
194
  `</item>`
205
195
  );
206
196
  })
@@ -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