@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.
@@ -15,6 +15,10 @@
15
15
  * `/authors/<id>`;
16
16
  * - `article` / `page` — one page per `content[]` record, at its own
17
17
  * `contentFrontmatterNormalized` route;
18
+ * - an `edition` record (`kind: "edition"`, `internal/editions.js`) is an
19
+ * article page too, for the page-kind hook: it renders at
20
+ * `<article>/quick-read`, `/standard` or `/deep-dive`, names the article as
21
+ * its canonical URL, is `noindex, follow`, and never enters a listing;
18
22
  * - `index` — a paginated reverse-chronological listing of every
19
23
  * `status: "published"` `kind: "article"` record (an `unlisted` record is
20
24
  * reachable only by its own direct route or an authored navigation entry,
@@ -62,14 +66,24 @@ import {
62
66
  createComponents,
63
67
  createMediaImage,
64
68
  extractH2Headings,
65
- lazyBodyImages,
66
69
  plainText,
67
70
  renderImage,
68
71
  renderNewsletterPanel,
69
72
  } from './components.js';
73
+ import {
74
+ editionKindLabel,
75
+ editionRoute,
76
+ listedEditions,
77
+ renderEditionSelector,
78
+ resolveEditions,
79
+ } from './editions.js';
70
80
  import { truncateAtWord } from './seo.js';
71
81
  import { createStructuredData } from './structured-data.js';
72
82
  import { icon } from './icons.js';
83
+ import {
84
+ interactionsActive,
85
+ renderInteractionsSection,
86
+ } from './interactions.js';
73
87
  import { getMessages } from './messages.js';
74
88
  import { resolveTextDirection } from './text-direction.js';
75
89
  import { deriveAuthorSlugs, routeSegmentForLabel } from './route-labels.js';
@@ -119,9 +133,13 @@ export const PAGE_KIND_VALUES = Object.freeze(
119
133
 
120
134
  /**
121
135
  * @param {import('../../../types/index.d.ts').ContentFrontmatterNormalized} frontmatter
122
- * @returns {string} the record's own canonical route (un-joined)
136
+ * @returns {string} the record's own canonical route (un-joined); an
137
+ * edition's is derived from the article it restates, never authored
123
138
  */
124
139
  export function contentRoute(frontmatter) {
140
+ if (frontmatter.kind === 'edition' && frontmatter.edition) {
141
+ return editionRoute(frontmatter.edition);
142
+ }
125
143
  return frontmatter.route ?? `/${frontmatter.slug}`;
126
144
  }
127
145
 
@@ -149,6 +167,9 @@ function paginate(items, pageSize) {
149
167
  * @property {'profile' | 'author' | 'article' | 'page' | 'index' | 'tag' | 'series' | 'archive'} kind
150
168
  * @property {string} route an un-joined `canonicalRoute` (caller joins with
151
169
  * `basePath`)
170
+ * @property {string} [canonicalRoute] the un-joined route the page's
171
+ * `<link rel="canonical">` names when it is not the page's own (an edition
172
+ * names its article)
152
173
  * @property {string} title the page's `<title>` text
153
174
  * @property {string} language the page's BCP-47 language tag
154
175
  * @property {string} [direction] the page's resolved base text direction (assigned by buildGeneratedPages's own return mapping)
@@ -176,6 +197,8 @@ function paginate(items, pageSize) {
176
197
  * one place that can turn this into an absolute derivative URL, because
177
198
  * only it has run the media pipeline and knows the derivative
178
199
  * output path a given `sourceDigest` produced)
200
+ * @property {boolean} [interactionBearing] whether this page carries the
201
+ * reader-interactions section (article pages when the module is on)
179
202
  * @property {string} [robotsContent] an optional
180
203
  * `<meta name="robots">` content value (e.g. `'noindex, follow'` for
181
204
  * `status: "unlisted"` content); omitted entirely for ordinarily indexable
@@ -345,21 +368,33 @@ export function homeDocumentTitle(name, tagline) {
345
368
  * `build-input:2.0.0` instance.
346
369
  *
347
370
  * @param {import('../../../types/index.d.ts').NormalizedBuildInput} validatedInput
348
- * @param {object} [options] rendering options
371
+ * @param {object} options rendering options
372
+ * @param {(body: string) => string} options.pageBody turns a body into its
373
+ * page form (every image naming its media derivatives, lazily loaded)
374
+ * @param {import('./editions.js').EditionResolution} [options.editions] where
375
+ * each edition sits beside its article; resolved from the build input when
376
+ * the caller has not already done so
349
377
  * @param {readonly {path: string, mediaType: string}[]} [options.mediaAssets]
350
378
  * the media pipeline's finished `assets` list, used to resolve every
351
379
  * image reference (hero, avatar) to its emitted URL; an image with no
352
380
  * processed derivative renders as the placeholder/monogram fallback
353
381
  * @param {Readonly<Record<string, {width: number, height: number}>>} [options.mediaDimensions]
354
382
  * the media pipeline's pixel dimensions per derivative path
383
+ * @param {import('../../../types/index.d.ts').InteractionsModule} [options.interactions]
384
+ * the validated interactions module; when set, every article page carries
385
+ * the interactions section and is interaction-bearing
355
386
  * @returns {GeneratedPage[]} every generated page, in a stable, deterministic
356
387
  * order
357
388
  */
358
- export function buildGeneratedPages(validatedInput, options = {}) {
389
+ export function buildGeneratedPages(validatedInput, options) {
359
390
  const { publication, authors, content, basePath, baseUrl } = validatedInput;
391
+ const interactionsModule = interactionsActive(options.interactions)
392
+ ? options.interactions
393
+ : undefined;
360
394
  const publicBasePath = derivePublicBasePath(baseUrl, basePath);
361
395
  const messages = getMessages(publication.defaultLanguage);
362
396
  const authorsById = new Map(authors.map((author) => [author.id, author]));
397
+ const editions = options.editions ?? resolveEditions(content, contentRoute);
363
398
 
364
399
  /**
365
400
  * @param {string} route an un-joined route
@@ -462,7 +497,11 @@ export function buildGeneratedPages(validatedInput, options = {}) {
462
497
  const publishedArticles = selectPublishedArticles(content);
463
498
  const byTag = groupBy(publishedArticles, (r) => r.frontmatter.tags);
464
499
  const bySeries = groupBy(
465
- content.filter((r) => r.frontmatter.status === 'published'),
500
+ content.filter(
501
+ (r) =>
502
+ r.frontmatter.kind !== 'edition' &&
503
+ r.frontmatter.status === 'published',
504
+ ),
466
505
  (r) => (r.frontmatter.series ? [r.frontmatter.series] : []),
467
506
  );
468
507
  for (const bucket of bySeries.values()) {
@@ -646,7 +685,7 @@ export function buildGeneratedPages(validatedInput, options = {}) {
646
685
  bodyHtml:
647
686
  `<article class="g-article"><div class="g-wrap g-article-head">` +
648
687
  `<h1>${escapeHtml(publication.title)}</h1></div>` +
649
- `<div class="g-wrap g-article-grid"><div class="g-prose">${lazyBodyImages(publication.profile.body.body)}</div></div></article>`,
688
+ `<div class="g-wrap g-article-grid"><div class="g-prose">${options.pageBody(publication.profile.body.body)}</div></div></article>`,
650
689
  });
651
690
  }
652
691
 
@@ -715,7 +754,15 @@ export function buildGeneratedPages(validatedInput, options = {}) {
715
754
  for (const record of content) {
716
755
  const { frontmatter } = record;
717
756
  const isArticle = frontmatter.kind === 'article';
718
- const hero = mediaImage(frontmatter.hero?.file);
757
+ // An edition restates `original`; a document that is not one is its own.
758
+ const edition = editions.byRecord.get(record);
759
+ const isEdition = edition !== undefined;
760
+ const original = edition?.original ?? record;
761
+ // An edition with no cover of its own wears its article's.
762
+ const heroMedia = isEdition
763
+ ? (frontmatter.hero ?? original.frontmatter.hero)
764
+ : frontmatter.hero;
765
+ const hero = mediaImage(heroMedia?.file);
719
766
  const headings = extractH2Headings(record.body);
720
767
  const contents =
721
768
  headings.length >= CONTENTS_MIN_HEADINGS
@@ -726,12 +773,17 @@ export function buildGeneratedPages(validatedInput, options = {}) {
726
773
  )
727
774
  .join('')}</ol>`
728
775
  : '';
729
- const firstTag = frontmatter.tags[0];
776
+ const firstTag = original.frontmatter.tags[0];
730
777
  const trail = [
731
- ...(isArticle && firstTag
778
+ ...((isArticle || isEdition) && firstTag
732
779
  ? [{ label: firstTag, route: tagHref(firstTag) }]
733
780
  : []),
734
- { label: frontmatter.title },
781
+ ...(edition
782
+ ? [
783
+ { label: original.frontmatter.title, route: recordHref(original) },
784
+ { label: editionKindLabel(edition.kind, messages) },
785
+ ]
786
+ : [{ label: frontmatter.title }]),
735
787
  ];
736
788
  const partLabel = partLabelOf(record);
737
789
  const series = frontmatter.series;
@@ -742,6 +794,19 @@ export function buildGeneratedPages(validatedInput, options = {}) {
742
794
  : '') +
743
795
  `${escapeHtml(series)}</a>`
744
796
  : '';
797
+ const share =
798
+ `<div class="g-share" role="group" aria-label="${text('shareLabel')}" hidden>` +
799
+ `<button class="g-icon-btn" type="button" data-action="copy-link" aria-label="${text('copyLinkLabel')}" title="${text('copyLinkLabel')}">${icon('link')}</button>` +
800
+ `<button class="g-icon-btn" type="button" data-action="bookmark" aria-pressed="false" aria-label="${text('saveForLaterLabel')}" title="${text('saveForLaterLabel')}">${icon('bookmark')}</button>` +
801
+ `</div>`;
802
+ // The article and each of its editions offer the same selector.
803
+ const editionSelector = renderEditionSelector({
804
+ messages,
805
+ originalHref: recordHref(original),
806
+ entries: listedEditions(editions.byOriginal.get(original), edition),
807
+ current: edition,
808
+ site,
809
+ });
745
810
  const head =
746
811
  `<div class="g-wrap g-article-head">${renderBreadcrumbs({ trail: [homeStep, ...trail], messages })}` +
747
812
  (isArticle
@@ -751,16 +816,13 @@ export function buildGeneratedPages(validatedInput, options = {}) {
751
816
  (frontmatter.description
752
817
  ? `<p class="g-dek">${escapeHtml(frontmatter.description)}</p>`
753
818
  : '') +
754
- (isArticle
755
- ? `<div class="g-article-meta">${byline(record, true)}` +
756
- `<div class="g-share" role="group" aria-label="${text('shareLabel')}" hidden>` +
757
- `<button class="g-icon-btn" type="button" data-action="copy-link" aria-label="${text('copyLinkLabel')}" title="${text('copyLinkLabel')}">${icon('link')}</button>` +
758
- `<button class="g-icon-btn" type="button" data-action="bookmark" aria-pressed="false" aria-label="${text('saveForLaterLabel')}" title="${text('saveForLaterLabel')}">${icon('bookmark')}</button>` +
759
- `</div></div>`
819
+ (isArticle || isEdition
820
+ ? `<div class="g-article-meta">${byline(record, true)}${isArticle ? share : ''}</div>`
760
821
  : '') +
822
+ editionSelector +
761
823
  `</div>`;
762
824
  const cover = hero
763
- ? `<figure class="g-wrap g-article-cover">${renderImage({ image: hero, alt: frontmatter.hero?.role === 'decorative' ? '' : (frontmatter.hero?.alt ?? ''), priority: true })}</figure>`
825
+ ? `<figure class="g-wrap g-article-cover">${renderImage({ image: hero, alt: heroMedia?.role === 'decorative' ? '' : (heroMedia?.alt ?? ''), priority: true })}</figure>`
764
826
  : '';
765
827
  const tocNav = contents
766
828
  ? `<nav class="g-toc" aria-label="${text('onThisPageLabel')}"><p class="g-label">${icon('list')}${text('onThisPageLabel')}</p>${contents}</nav>`
@@ -780,7 +842,7 @@ export function buildGeneratedPages(validatedInput, options = {}) {
780
842
  const prose =
781
843
  `<div class="g-prose">${tocMobile}` +
782
844
  renderSlot('article-preamble') +
783
- lazyBodyImages(record.body) +
845
+ options.pageBody(record.body) +
784
846
  tags +
785
847
  renderSlot('article-end') +
786
848
  renderSlot('article-footer-ad', { collapsed: true }) +
@@ -788,6 +850,7 @@ export function buildGeneratedPages(validatedInput, options = {}) {
788
850
 
789
851
  let foot = '';
790
852
  let after = '';
853
+ let interactions = '';
791
854
  if (isArticle) {
792
855
  const authorCards = ui
793
856
  .recordAuthors(record)
@@ -835,7 +898,16 @@ export function buildGeneratedPages(validatedInput, options = {}) {
835
898
  newer || older
836
899
  ? `<nav class="g-pager" aria-label="${text('moreArticlesNavigationLabel')}">${newer ? pagerLink(newer, false) : ''}${older ? pagerLink(older, true) : ''}</nav>`
837
900
  : '';
838
- foot = `<div class="g-wrap g-article-foot">${authorCards}${seriesBox}${pager}</div>`;
901
+ interactions = interactionsModule
902
+ ? renderInteractionsSection({
903
+ module: interactionsModule,
904
+ messages,
905
+ publicationId: publication.id,
906
+ contentId: frontmatter.id,
907
+ canonicalUrl: absolute(recordHref(record)),
908
+ })
909
+ : '';
910
+ foot = `<div class="g-wrap g-article-foot">${authorCards}${interactions}${seriesBox}${pager}</div>`;
839
911
  const related = selectRelatedArticles(record, publishedArticles);
840
912
  after =
841
913
  related.length > 0
@@ -852,8 +924,17 @@ export function buildGeneratedPages(validatedInput, options = {}) {
852
924
  const articleTrail = [
853
925
  homeStep,
854
926
  ...trail.slice(0, -1),
855
- { label: frontmatter.title, route: recordHref(record) },
927
+ {
928
+ label: trail.at(-1)?.label ?? frontmatter.title,
929
+ route: recordHref(record),
930
+ },
856
931
  ];
932
+ // An edition with no social image of its own shares its article's.
933
+ const social = socialOfContent(
934
+ isEdition && !frontmatter.socialImage && !frontmatter.hero
935
+ ? original.frontmatter
936
+ : frontmatter,
937
+ );
857
938
  const articleGraph = isArticle
858
939
  ? sd.article({
859
940
  url: ownUrl,
@@ -883,14 +964,24 @@ export function buildGeneratedPages(validatedInput, options = {}) {
883
964
  dateModified: contentLastModified(frontmatter),
884
965
  });
885
966
  pages.push({
886
- kind: frontmatter.kind,
967
+ // An edition is an article page for the page-kind hook.
968
+ kind: frontmatter.kind === 'edition' ? 'article' : frontmatter.kind,
887
969
  route: contentRoute(frontmatter),
970
+ canonicalRoute: edition ? contentRoute(original.frontmatter) : undefined,
888
971
  title: frontmatter.title,
889
- documentTitle: documentTitleOf(frontmatter.title),
972
+ documentTitle: documentTitleOf(
973
+ edition
974
+ ? msg(
975
+ 'editionPageTitle',
976
+ frontmatter.title,
977
+ editionKindLabel(edition.kind, messages),
978
+ )
979
+ : frontmatter.title,
980
+ ),
890
981
  language: frontmatter.language,
891
982
  description: contentDescription(record),
892
- ogType: isArticle ? 'article' : 'website',
893
- ...socialOfContent(frontmatter),
983
+ ogType: isArticle || isEdition ? 'article' : 'website',
984
+ ...social,
894
985
  jsonLd: sd.serialize(articleGraph),
895
986
  articleMeta: isArticle
896
987
  ? {
@@ -902,11 +993,15 @@ export function buildGeneratedPages(validatedInput, options = {}) {
902
993
  tags: [...frontmatter.tags],
903
994
  }
904
995
  : undefined,
996
+ // An edition is never indexed: its article is the reference.
905
997
  robotsContent:
906
- frontmatter.status === 'unlisted' ? 'noindex, follow' : undefined,
998
+ isEdition || frontmatter.status === 'unlisted'
999
+ ? 'noindex, follow'
1000
+ : undefined,
1001
+ interactionBearing: Boolean(interactions),
907
1002
  lastModified: contentLastModified(frontmatter),
908
1003
  bodyHtml:
909
- `<article class="g-article">${isArticle ? '<div class="g-progress" aria-hidden="true"></div>' : ''}${head}${cover}` +
1004
+ `<article class="g-article">${isArticle || isEdition ? '<div class="g-progress" aria-hidden="true"></div>' : ''}${head}${cover}` +
910
1005
  `<div class="g-wrap g-article-grid">${tocNav}${prose}</div>${foot}</article>` +
911
1006
  after +
912
1007
  (isArticle ? newsletterHtml : ''),
@@ -239,6 +239,14 @@ export const ADMITTED_LANGUAGE_CLASSES = Object.freeze(
239
239
  HIGHLIGHT_GRAMMARS.map((grammar) => `language-${grammar}`),
240
240
  );
241
241
 
242
+ /**
243
+ * The production Galascribe API origin. The blog CSP always allows it in
244
+ * `connect-src` (reader interactions design D8), interactions on or off.
245
+ *
246
+ * @type {string}
247
+ */
248
+ export const PRODUCTION_API_ORIGIN = 'https://api.galascribe.com';
249
+
242
250
  /**
243
251
  * The CSP specification explicitly ignores `frame-ancestors`
244
252
  * (and `report-uri`/`sandbox`, neither used here) when a policy is
@@ -262,16 +270,19 @@ export const CSP_HEADER_ONLY_DIRECTIVES = Object.freeze([
262
270
  * `<meta http-equiv="Content-Security-Policy">` tag: {@link CSP_HEADER_ONLY_DIRECTIVES}
263
271
  * is excluded. Directive order is byte-exact, minus
264
272
  * the excluded directive; every directive's value list is already sorted
265
- * bytewise and duplicate-free because this renderer materializes no module
266
- * package, configuration, output or runtime, so this string is
267
- * byte-identical on every route.
273
+ * bytewise and duplicate-free. `connect-src` always admits the production
274
+ * Galascribe API origin (reader interactions, whether or not the module is
275
+ * on), so this string is byte-identical on every route of every production
276
+ * build. Only the local stack's differing interactions `apiOrigin` is
277
+ * appended per build ({@link contentSecurityPolicyMeta}).
268
278
  *
269
279
  * @type {string}
270
280
  */
271
281
  export const CONTENT_SECURITY_POLICY_META_BASELINE =
272
282
  "default-src 'none'; base-uri 'none'; object-src 'none'; " +
273
283
  "form-action 'none'; script-src 'self'; " +
274
- "style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'self'; " +
284
+ "style-src 'self'; img-src 'self'; font-src 'self'; " +
285
+ `connect-src 'self' ${PRODUCTION_API_ORIGIN}; ` +
275
286
  "media-src 'self'; manifest-src 'self'; worker-src 'none'";
276
287
 
277
288
  /**
@@ -287,9 +298,33 @@ export const CONTENT_SECURITY_POLICY_META_BASELINE =
287
298
  export const CONTENT_SECURITY_POLICY_COMPLETE =
288
299
  "default-src 'none'; base-uri 'none'; object-src 'none'; " +
289
300
  "frame-ancestors 'none'; form-action 'none'; script-src 'self'; " +
290
- "style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'self'; " +
301
+ "style-src 'self'; img-src 'self'; font-src 'self'; " +
302
+ `connect-src 'self' ${PRODUCTION_API_ORIGIN}; ` +
291
303
  "media-src 'self'; manifest-src 'self'; worker-src 'none'";
292
304
 
305
+ /**
306
+ * The meta-safe CSP for one build. Identical to
307
+ * {@link CONTENT_SECURITY_POLICY_META_BASELINE} unless the interactions
308
+ * module names a different API origin (local stack only), in which case that
309
+ * origin is added to `connect-src`, sorted bytewise with the other sources
310
+ * and de-duplicated.
311
+ *
312
+ * @param {string | undefined} [apiOrigin] the interactions module's
313
+ * `apiOrigin`, when the module is on
314
+ * @returns {string} the CSP directive string for the `<meta>` tag
315
+ */
316
+ export function contentSecurityPolicyMeta(apiOrigin) {
317
+ if (apiOrigin === undefined || apiOrigin === PRODUCTION_API_ORIGIN) {
318
+ return CONTENT_SECURITY_POLICY_META_BASELINE;
319
+ }
320
+ const sources = [...new Set(["'self'", PRODUCTION_API_ORIGIN, apiOrigin])];
321
+ sources.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
322
+ return CONTENT_SECURITY_POLICY_META_BASELINE.replace(
323
+ `connect-src 'self' ${PRODUCTION_API_ORIGIN};`,
324
+ `connect-src ${sources.join(' ')};`,
325
+ );
326
+ }
327
+
293
328
  /**
294
329
  * Build the exact plain-object document `contracts/render-policy.jcs`
295
330
  * publishes: `name`, `version` byte-equal to the template lock's
@@ -19,10 +19,11 @@
19
19
  * - exactly one `<main id="main-content">`; each page kind places its own
20
20
  * breadcrumb `<nav>` and single `<h1>` inside it;
21
21
  * - exactly one `<footer>` (brand, description, social links, navigation
22
- * columns, author profile, copyright and the template attribution line),
22
+ * columns, author profile, copyright and the template attribution line,
23
+ * which `appearance.attribution.showMadeWith: false` omits),
23
24
  * plus the always-present, currently-empty core slots (`footer-auxiliary`,
24
- * `account-intent`, `conversation`, `newsletter`, `edition-selector`),
25
- * rendered by `internal/chrome.js`.
25
+ * `account-intent`, `conversation`, `newsletter`), rendered by
26
+ * `internal/chrome.js`.
26
27
  *
27
28
  * Core owns nine versioned semantic slots plus the collapsed
28
29
  * `article-footer-ad` slot: `header-actions`, `article-preamble`,
@@ -46,7 +47,11 @@
46
47
  * Light/Dark/System appearance control (the three-mode appearance control
47
48
  * in the header-actions slot) — core content, not a module, so this slot's
48
49
  * earlier "every slot ... renders empty" description does not cover it.
49
- * Every other slot is unaffected.
50
+ *
51
+ * `edition-selector` is not a footer slot: it is rendered by the article page
52
+ * kind (`internal/editions.js`) at the end of the article head, on an article
53
+ * that has a published edition and on each edition page, and is not rendered
54
+ * on a page with no edition to offer. Every other slot is unaffected.
50
55
  *
51
56
  * The exact `selectorAtom`/hook-naming decisions here (`data-gala-slot`
52
57
  * attribute, one slot name per value) are what
@@ -106,6 +111,22 @@ export function escapeHtml(value) {
106
111
  return escaped;
107
112
  }
108
113
 
114
+ /**
115
+ * Decode the five predefined entities {@link escapeHtml} produces, turning an
116
+ * attribute value as it appears in sanitized HTML back into its text.
117
+ *
118
+ * @param {string} value an attribute value as it appears in HTML source
119
+ * @returns {string} the value with the five predefined entities decoded
120
+ */
121
+ export function unescapeHtml(value) {
122
+ return value
123
+ .replace(/&quot;/g, '"')
124
+ .replace(/&#39;/g, "'")
125
+ .replace(/&lt;/g, '<')
126
+ .replace(/&gt;/g, '>')
127
+ .replace(/&amp;/g, '&');
128
+ }
129
+
109
130
  /**
110
131
  * Render one always-present, currently-empty core semantic slot.
111
132
  *
@@ -89,6 +89,10 @@ export function collectIncludedSources(buildInput) {
89
89
  contentRecord.frontmatter.socialImage,
90
90
  contentRecord.sourceRevision,
91
91
  );
92
+ // The document's inventoried media (its body images' sources).
93
+ for (const mediaFile of contentRecord.media ?? []) {
94
+ recordResolvedFile(record, mediaFile, contentRecord.sourceRevision);
95
+ }
92
96
  }
93
97
 
94
98
  if (buildInput.navigation.source.kind === 'authored') {