@rathnasgala2/template 3.1.0 → 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,11 +66,17 @@ 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';
@@ -123,9 +133,13 @@ export const PAGE_KIND_VALUES = Object.freeze(
123
133
 
124
134
  /**
125
135
  * @param {import('../../../types/index.d.ts').ContentFrontmatterNormalized} frontmatter
126
- * @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
127
138
  */
128
139
  export function contentRoute(frontmatter) {
140
+ if (frontmatter.kind === 'edition' && frontmatter.edition) {
141
+ return editionRoute(frontmatter.edition);
142
+ }
129
143
  return frontmatter.route ?? `/${frontmatter.slug}`;
130
144
  }
131
145
 
@@ -153,6 +167,9 @@ function paginate(items, pageSize) {
153
167
  * @property {'profile' | 'author' | 'article' | 'page' | 'index' | 'tag' | 'series' | 'archive'} kind
154
168
  * @property {string} route an un-joined `canonicalRoute` (caller joins with
155
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)
156
173
  * @property {string} title the page's `<title>` text
157
174
  * @property {string} language the page's BCP-47 language tag
158
175
  * @property {string} [direction] the page's resolved base text direction (assigned by buildGeneratedPages's own return mapping)
@@ -351,7 +368,12 @@ export function homeDocumentTitle(name, tagline) {
351
368
  * `build-input:2.0.0` instance.
352
369
  *
353
370
  * @param {import('../../../types/index.d.ts').NormalizedBuildInput} validatedInput
354
- * @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
355
377
  * @param {readonly {path: string, mediaType: string}[]} [options.mediaAssets]
356
378
  * the media pipeline's finished `assets` list, used to resolve every
357
379
  * image reference (hero, avatar) to its emitted URL; an image with no
@@ -364,7 +386,7 @@ export function homeDocumentTitle(name, tagline) {
364
386
  * @returns {GeneratedPage[]} every generated page, in a stable, deterministic
365
387
  * order
366
388
  */
367
- export function buildGeneratedPages(validatedInput, options = {}) {
389
+ export function buildGeneratedPages(validatedInput, options) {
368
390
  const { publication, authors, content, basePath, baseUrl } = validatedInput;
369
391
  const interactionsModule = interactionsActive(options.interactions)
370
392
  ? options.interactions
@@ -372,6 +394,7 @@ export function buildGeneratedPages(validatedInput, options = {}) {
372
394
  const publicBasePath = derivePublicBasePath(baseUrl, basePath);
373
395
  const messages = getMessages(publication.defaultLanguage);
374
396
  const authorsById = new Map(authors.map((author) => [author.id, author]));
397
+ const editions = options.editions ?? resolveEditions(content, contentRoute);
375
398
 
376
399
  /**
377
400
  * @param {string} route an un-joined route
@@ -474,7 +497,11 @@ export function buildGeneratedPages(validatedInput, options = {}) {
474
497
  const publishedArticles = selectPublishedArticles(content);
475
498
  const byTag = groupBy(publishedArticles, (r) => r.frontmatter.tags);
476
499
  const bySeries = groupBy(
477
- content.filter((r) => r.frontmatter.status === 'published'),
500
+ content.filter(
501
+ (r) =>
502
+ r.frontmatter.kind !== 'edition' &&
503
+ r.frontmatter.status === 'published',
504
+ ),
478
505
  (r) => (r.frontmatter.series ? [r.frontmatter.series] : []),
479
506
  );
480
507
  for (const bucket of bySeries.values()) {
@@ -658,7 +685,7 @@ export function buildGeneratedPages(validatedInput, options = {}) {
658
685
  bodyHtml:
659
686
  `<article class="g-article"><div class="g-wrap g-article-head">` +
660
687
  `<h1>${escapeHtml(publication.title)}</h1></div>` +
661
- `<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>`,
662
689
  });
663
690
  }
664
691
 
@@ -727,7 +754,15 @@ export function buildGeneratedPages(validatedInput, options = {}) {
727
754
  for (const record of content) {
728
755
  const { frontmatter } = record;
729
756
  const isArticle = frontmatter.kind === 'article';
730
- 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);
731
766
  const headings = extractH2Headings(record.body);
732
767
  const contents =
733
768
  headings.length >= CONTENTS_MIN_HEADINGS
@@ -738,12 +773,17 @@ export function buildGeneratedPages(validatedInput, options = {}) {
738
773
  )
739
774
  .join('')}</ol>`
740
775
  : '';
741
- const firstTag = frontmatter.tags[0];
776
+ const firstTag = original.frontmatter.tags[0];
742
777
  const trail = [
743
- ...(isArticle && firstTag
778
+ ...((isArticle || isEdition) && firstTag
744
779
  ? [{ label: firstTag, route: tagHref(firstTag) }]
745
780
  : []),
746
- { 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 }]),
747
787
  ];
748
788
  const partLabel = partLabelOf(record);
749
789
  const series = frontmatter.series;
@@ -754,6 +794,19 @@ export function buildGeneratedPages(validatedInput, options = {}) {
754
794
  : '') +
755
795
  `${escapeHtml(series)}</a>`
756
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
+ });
757
810
  const head =
758
811
  `<div class="g-wrap g-article-head">${renderBreadcrumbs({ trail: [homeStep, ...trail], messages })}` +
759
812
  (isArticle
@@ -763,16 +816,13 @@ export function buildGeneratedPages(validatedInput, options = {}) {
763
816
  (frontmatter.description
764
817
  ? `<p class="g-dek">${escapeHtml(frontmatter.description)}</p>`
765
818
  : '') +
766
- (isArticle
767
- ? `<div class="g-article-meta">${byline(record, true)}` +
768
- `<div class="g-share" role="group" aria-label="${text('shareLabel')}" hidden>` +
769
- `<button class="g-icon-btn" type="button" data-action="copy-link" aria-label="${text('copyLinkLabel')}" title="${text('copyLinkLabel')}">${icon('link')}</button>` +
770
- `<button class="g-icon-btn" type="button" data-action="bookmark" aria-pressed="false" aria-label="${text('saveForLaterLabel')}" title="${text('saveForLaterLabel')}">${icon('bookmark')}</button>` +
771
- `</div></div>`
819
+ (isArticle || isEdition
820
+ ? `<div class="g-article-meta">${byline(record, true)}${isArticle ? share : ''}</div>`
772
821
  : '') +
822
+ editionSelector +
773
823
  `</div>`;
774
824
  const cover = hero
775
- ? `<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>`
776
826
  : '';
777
827
  const tocNav = contents
778
828
  ? `<nav class="g-toc" aria-label="${text('onThisPageLabel')}"><p class="g-label">${icon('list')}${text('onThisPageLabel')}</p>${contents}</nav>`
@@ -792,7 +842,7 @@ export function buildGeneratedPages(validatedInput, options = {}) {
792
842
  const prose =
793
843
  `<div class="g-prose">${tocMobile}` +
794
844
  renderSlot('article-preamble') +
795
- lazyBodyImages(record.body) +
845
+ options.pageBody(record.body) +
796
846
  tags +
797
847
  renderSlot('article-end') +
798
848
  renderSlot('article-footer-ad', { collapsed: true }) +
@@ -874,8 +924,17 @@ export function buildGeneratedPages(validatedInput, options = {}) {
874
924
  const articleTrail = [
875
925
  homeStep,
876
926
  ...trail.slice(0, -1),
877
- { label: frontmatter.title, route: recordHref(record) },
927
+ {
928
+ label: trail.at(-1)?.label ?? frontmatter.title,
929
+ route: recordHref(record),
930
+ },
878
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
+ );
879
938
  const articleGraph = isArticle
880
939
  ? sd.article({
881
940
  url: ownUrl,
@@ -905,14 +964,24 @@ export function buildGeneratedPages(validatedInput, options = {}) {
905
964
  dateModified: contentLastModified(frontmatter),
906
965
  });
907
966
  pages.push({
908
- kind: frontmatter.kind,
967
+ // An edition is an article page for the page-kind hook.
968
+ kind: frontmatter.kind === 'edition' ? 'article' : frontmatter.kind,
909
969
  route: contentRoute(frontmatter),
970
+ canonicalRoute: edition ? contentRoute(original.frontmatter) : undefined,
910
971
  title: frontmatter.title,
911
- 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
+ ),
912
981
  language: frontmatter.language,
913
982
  description: contentDescription(record),
914
- ogType: isArticle ? 'article' : 'website',
915
- ...socialOfContent(frontmatter),
983
+ ogType: isArticle || isEdition ? 'article' : 'website',
984
+ ...social,
916
985
  jsonLd: sd.serialize(articleGraph),
917
986
  articleMeta: isArticle
918
987
  ? {
@@ -924,12 +993,15 @@ export function buildGeneratedPages(validatedInput, options = {}) {
924
993
  tags: [...frontmatter.tags],
925
994
  }
926
995
  : undefined,
996
+ // An edition is never indexed: its article is the reference.
927
997
  robotsContent:
928
- frontmatter.status === 'unlisted' ? 'noindex, follow' : undefined,
998
+ isEdition || frontmatter.status === 'unlisted'
999
+ ? 'noindex, follow'
1000
+ : undefined,
929
1001
  interactionBearing: Boolean(interactions),
930
1002
  lastModified: contentLastModified(frontmatter),
931
1003
  bodyHtml:
932
- `<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}` +
933
1005
  `<div class="g-wrap g-article-grid">${tocNav}${prose}</div>${foot}</article>` +
934
1006
  after +
935
1007
  (isArticle ? newsletterHtml : ''),
@@ -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') {