@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.
- package/README.md +136 -43
- package/package.json +2 -2
- package/src/core/index.js +48 -8
- package/src/core/internal/appearance/base-layer.js +1 -0
- package/src/core/internal/chrome.js +5 -2
- package/src/core/internal/components.js +69 -10
- package/src/core/internal/editions.js +297 -0
- package/src/core/internal/feeds.js +8 -18
- package/src/core/internal/media/content-images.js +262 -0
- package/src/core/internal/media/gif-probe.js +162 -0
- package/src/core/internal/media/jpeg-codec.js +0 -0
- package/src/core/internal/media/limits.js +21 -0
- package/src/core/internal/media/pipeline.js +73 -40
- package/src/core/internal/media/sniff.js +15 -2
- package/src/core/internal/messages.js +10 -1
- package/src/core/internal/page-kinds.js +97 -25
- package/src/core/internal/skeleton.js +25 -4
- package/src/core/internal/source-inventory.js +4 -0
- package/src/modules/interactions/browser/gala-interactions.js +298 -13
- package/types/index.d.ts +31 -1
|
@@ -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}
|
|
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(
|
|
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">${
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
|
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(
|
|
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
|
-
...
|
|
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'
|
|
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
|
|
25
|
-
*
|
|
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
|
-
*
|
|
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(/"/g, '"')
|
|
124
|
+
.replace(/'/g, "'")
|
|
125
|
+
.replace(/</g, '<')
|
|
126
|
+
.replace(/>/g, '>')
|
|
127
|
+
.replace(/&/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') {
|