@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.
- package/README.md +144 -49
- package/contracts/render-policy.jcs +1 -1
- package/package.json +4 -3
- package/src/core/index.js +122 -17
- package/src/core/internal/appearance/base-layer.js +82 -0
- package/src/core/internal/chrome.js +5 -2
- package/src/core/internal/components.js +69 -10
- package/src/core/internal/content-security.js +0 -0
- package/src/core/internal/editions.js +297 -0
- package/src/core/internal/eleventy-render.js +5 -3
- package/src/core/internal/feeds.js +8 -18
- package/src/core/internal/icons.js +2 -0
- package/src/core/internal/interactions.js +349 -0
- 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 +16 -1
- package/src/core/internal/page-kinds.js +121 -26
- package/src/core/internal/render-policy-content.js +40 -5
- 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 +2176 -0
- package/types/index.d.ts +61 -3
|
@@ -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}
|
|
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(
|
|
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">${
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
|
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(
|
|
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
|
-
...
|
|
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'
|
|
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
|
|
266
|
-
*
|
|
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';
|
|
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';
|
|
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
|
|
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') {
|