@rathnasgala2/template 2.1.0 → 3.0.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 +95 -59
- package/contracts/render-policy.jcs +1 -1
- package/contracts/theme-styling-contract.jcs +1 -1
- package/package.json +2 -2
- package/src/core/index.js +209 -60
- package/src/core/internal/appearance/base-layer.js +711 -137
- package/src/core/internal/appearance/bootstrap-script.js +570 -83
- package/src/core/internal/appearance/contract.js +6 -5
- package/src/core/internal/appearance/controller-markup.js +46 -55
- package/src/core/internal/appearance/minify-script.js +69 -0
- package/src/core/internal/appearance/styling-contract.js +199 -17
- package/src/core/internal/chrome.js +199 -0
- package/src/core/internal/components.js +379 -0
- package/src/core/internal/eleventy-render.js +13 -4
- package/src/core/internal/feeds.js +79 -8
- package/src/core/internal/icons.js +52 -0
- package/src/core/internal/llms.js +252 -0
- package/src/core/internal/media/pipeline.js +32 -17
- package/src/core/internal/messages.js +104 -0
- package/src/core/internal/page-kinds.js +922 -293
- package/src/core/internal/render-policy-content.js +2 -2
- package/src/core/internal/robots.js +78 -0
- package/src/core/internal/route-labels.js +31 -0
- package/src/core/internal/route.js +42 -0
- package/src/core/internal/search-index.js +5 -2
- package/src/core/internal/seo.js +57 -2
- package/src/core/internal/sitemap.js +5 -4
- package/src/core/internal/skeleton.js +73 -132
- package/src/core/internal/structured-data.js +247 -0
- package/src/core/internal/theme-assets.js +21 -2
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* schema.org structured data (JSON-LD) for every generated page. One
|
|
3
|
+
* `<script type="application/ld+json">` carrying one `@graph` per page:
|
|
4
|
+
*
|
|
5
|
+
* - home: `WebSite` + `Blog` (no `SearchAction`: the site search is a
|
|
6
|
+
* client-side dialog over a static index, there is no search-results URL
|
|
7
|
+
* a crawler could call, and an invented one would be a false claim);
|
|
8
|
+
* - article: `BlogPosting` + `BreadcrumbList`;
|
|
9
|
+
* - tag / series / archive / paginated index: `CollectionPage` +
|
|
10
|
+
* `BreadcrumbList`;
|
|
11
|
+
* - author: `ProfilePage` whose `mainEntity` is the `Person`;
|
|
12
|
+
* - authored page / publication profile: `WebPage` (+ `BreadcrumbList`).
|
|
13
|
+
*
|
|
14
|
+
* The block is data, never executed: a browser does not run a script whose
|
|
15
|
+
* `type` is not a JavaScript type, and the page CSP (`script-src 'self'`)
|
|
16
|
+
* does not govern it. The "exactly one executable script per page" rule
|
|
17
|
+
* therefore stays true: the one executable script is the `<script src>`; the
|
|
18
|
+
* data block is distinguished by its `type`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** @type {number} Google's recommended maximum `headline` length. */
|
|
22
|
+
const HEADLINE_MAX = 110;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Serialize a JSON-LD document for safe embedding inside a `<script>`
|
|
26
|
+
* element: `<`, `>`, `&`, U+2028 and U+2029 become `\uXXXX` escapes, so the
|
|
27
|
+
* text can never close the element or start a comment, and stays valid
|
|
28
|
+
* JSON.
|
|
29
|
+
*
|
|
30
|
+
* @param {unknown} document the JSON-LD value
|
|
31
|
+
* @returns {string} compact, embed-safe JSON
|
|
32
|
+
*/
|
|
33
|
+
export function serializeJsonLd(document) {
|
|
34
|
+
return JSON.stringify(document).replace(
|
|
35
|
+
/[<>&\u2028\u2029]/g,
|
|
36
|
+
(character) =>
|
|
37
|
+
`\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {{url: string, width?: number, height?: number}} ImageInfo
|
|
43
|
+
* @typedef {{label: string, route?: string}} CrumbStep
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {ImageInfo | undefined} image
|
|
48
|
+
* @returns {Record<string, unknown> | undefined}
|
|
49
|
+
*/
|
|
50
|
+
function imageObject(image) {
|
|
51
|
+
return image
|
|
52
|
+
? {
|
|
53
|
+
'@type': 'ImageObject',
|
|
54
|
+
url: image.url,
|
|
55
|
+
width: image.width,
|
|
56
|
+
height: image.height,
|
|
57
|
+
}
|
|
58
|
+
: undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {string} text
|
|
63
|
+
* @param {number} max
|
|
64
|
+
* @returns {string}
|
|
65
|
+
*/
|
|
66
|
+
function bound(text, max) {
|
|
67
|
+
const characters = Array.from(text);
|
|
68
|
+
return characters.length > max
|
|
69
|
+
? `${characters
|
|
70
|
+
.slice(0, max - 1)
|
|
71
|
+
.join('')
|
|
72
|
+
.trimEnd()}…`
|
|
73
|
+
: text;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Bind the JSON-LD builders to one build.
|
|
78
|
+
*
|
|
79
|
+
* @param {object} context
|
|
80
|
+
* @param {{title: string, description: string, defaultLanguage: string}} context.publication
|
|
81
|
+
* @param {(route: string) => string} context.absolute the absolute URL of a
|
|
82
|
+
* `basePath`-joined route
|
|
83
|
+
* @param {ImageInfo | undefined} context.brandMark the appearance brand mark
|
|
84
|
+
* @returns {{
|
|
85
|
+
* serialize: (graph: readonly unknown[]) => string,
|
|
86
|
+
* home: (input: {posts: readonly {url: string, headline: string, datePublished: string}[]}) => unknown[],
|
|
87
|
+
* article: (input: ArticleInput) => unknown[],
|
|
88
|
+
* collection: (input: {name: string, description?: string, url: string, language: string, trail: readonly CrumbStep[], image?: ImageInfo}) => unknown[],
|
|
89
|
+
* profile: (input: {name: string, description?: string, url: string, language: string, image?: ImageInfo, sameAs: readonly string[]}) => unknown[],
|
|
90
|
+
* webPage: (input: {name: string, description?: string, url: string, language: string, trail: readonly CrumbStep[], dateModified?: string}) => unknown[],
|
|
91
|
+
* }}
|
|
92
|
+
*/
|
|
93
|
+
export function createStructuredData({ publication, absolute, brandMark }) {
|
|
94
|
+
const origin = absolute('/');
|
|
95
|
+
const organization = {
|
|
96
|
+
'@type': 'Organization',
|
|
97
|
+
'@id': `${origin}#organization`,
|
|
98
|
+
name: publication.title,
|
|
99
|
+
url: origin,
|
|
100
|
+
logo: imageObject(brandMark),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {readonly CrumbStep[]} trail
|
|
105
|
+
* @returns {Record<string, unknown>}
|
|
106
|
+
*/
|
|
107
|
+
const breadcrumbs = (trail) => ({
|
|
108
|
+
'@type': 'BreadcrumbList',
|
|
109
|
+
itemListElement: trail.map((step, index) => ({
|
|
110
|
+
'@type': 'ListItem',
|
|
111
|
+
position: index + 1,
|
|
112
|
+
name: step.label,
|
|
113
|
+
item: step.route ? absolute(step.route) : undefined,
|
|
114
|
+
})),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
serialize: (graph) =>
|
|
119
|
+
serializeJsonLd({ '@context': 'https://schema.org', '@graph': graph }),
|
|
120
|
+
|
|
121
|
+
home: ({ posts }) => [
|
|
122
|
+
{
|
|
123
|
+
'@type': 'WebSite',
|
|
124
|
+
'@id': `${origin}#website`,
|
|
125
|
+
url: origin,
|
|
126
|
+
name: publication.title,
|
|
127
|
+
description: publication.description,
|
|
128
|
+
inLanguage: publication.defaultLanguage,
|
|
129
|
+
publisher: { '@id': organization['@id'] },
|
|
130
|
+
// No SearchAction: this site has no search-results URL (see module
|
|
131
|
+
// documentation).
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
'@type': 'Blog',
|
|
135
|
+
'@id': `${origin}#blog`,
|
|
136
|
+
url: origin,
|
|
137
|
+
name: publication.title,
|
|
138
|
+
description: publication.description,
|
|
139
|
+
inLanguage: publication.defaultLanguage,
|
|
140
|
+
publisher: { '@id': organization['@id'] },
|
|
141
|
+
blogPost: posts.map((post) => ({
|
|
142
|
+
'@type': 'BlogPosting',
|
|
143
|
+
headline: bound(post.headline, HEADLINE_MAX),
|
|
144
|
+
url: post.url,
|
|
145
|
+
datePublished: post.datePublished,
|
|
146
|
+
})),
|
|
147
|
+
},
|
|
148
|
+
organization,
|
|
149
|
+
],
|
|
150
|
+
|
|
151
|
+
article: (input) => {
|
|
152
|
+
const authors = input.authors.map((author) => ({
|
|
153
|
+
'@type': 'Person',
|
|
154
|
+
name: author.name,
|
|
155
|
+
url: author.url,
|
|
156
|
+
sameAs: author.sameAs.length > 0 ? [...author.sameAs] : undefined,
|
|
157
|
+
}));
|
|
158
|
+
return [
|
|
159
|
+
{
|
|
160
|
+
'@type': 'BlogPosting',
|
|
161
|
+
'@id': `${input.url}#article`,
|
|
162
|
+
mainEntityOfPage: { '@type': 'WebPage', '@id': input.url },
|
|
163
|
+
url: input.url,
|
|
164
|
+
headline: bound(input.headline, HEADLINE_MAX),
|
|
165
|
+
description: input.description,
|
|
166
|
+
image: imageObject(input.image),
|
|
167
|
+
datePublished: input.datePublished,
|
|
168
|
+
dateModified: input.dateModified,
|
|
169
|
+
inLanguage: input.language,
|
|
170
|
+
author: authors.length === 1 ? authors[0] : authors,
|
|
171
|
+
publisher: organization,
|
|
172
|
+
keywords: input.tags.length > 0 ? input.tags.join(', ') : undefined,
|
|
173
|
+
isPartOf: input.series
|
|
174
|
+
? {
|
|
175
|
+
'@type': 'CreativeWorkSeries',
|
|
176
|
+
name: input.series.name,
|
|
177
|
+
url: input.series.url,
|
|
178
|
+
}
|
|
179
|
+
: undefined,
|
|
180
|
+
},
|
|
181
|
+
breadcrumbs(input.trail),
|
|
182
|
+
];
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
collection: ({ name, description, url, language, trail, image }) => [
|
|
186
|
+
{
|
|
187
|
+
'@type': 'CollectionPage',
|
|
188
|
+
'@id': `${url}#page`,
|
|
189
|
+
url,
|
|
190
|
+
name,
|
|
191
|
+
description,
|
|
192
|
+
inLanguage: language,
|
|
193
|
+
primaryImageOfPage: imageObject(image),
|
|
194
|
+
isPartOf: { '@id': `${origin}#website` },
|
|
195
|
+
},
|
|
196
|
+
breadcrumbs(trail),
|
|
197
|
+
],
|
|
198
|
+
|
|
199
|
+
profile: ({ name, description, url, language, image, sameAs }) => [
|
|
200
|
+
{
|
|
201
|
+
'@type': 'ProfilePage',
|
|
202
|
+
'@id': `${url}#page`,
|
|
203
|
+
url,
|
|
204
|
+
name,
|
|
205
|
+
inLanguage: language,
|
|
206
|
+
mainEntity: {
|
|
207
|
+
'@type': 'Person',
|
|
208
|
+
'@id': `${url}#person`,
|
|
209
|
+
name,
|
|
210
|
+
url,
|
|
211
|
+
description,
|
|
212
|
+
image: imageObject(image),
|
|
213
|
+
sameAs: sameAs.length > 0 ? [...sameAs] : undefined,
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
|
|
218
|
+
webPage: ({ name, description, url, language, trail, dateModified }) => [
|
|
219
|
+
{
|
|
220
|
+
'@type': 'WebPage',
|
|
221
|
+
'@id': `${url}#page`,
|
|
222
|
+
url,
|
|
223
|
+
name,
|
|
224
|
+
description,
|
|
225
|
+
inLanguage: language,
|
|
226
|
+
dateModified,
|
|
227
|
+
isPartOf: { '@id': `${origin}#website` },
|
|
228
|
+
},
|
|
229
|
+
...(trail.length > 0 ? [breadcrumbs(trail)] : []),
|
|
230
|
+
],
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* @typedef {object} ArticleInput
|
|
236
|
+
* @property {string} url the article's absolute URL
|
|
237
|
+
* @property {string} headline
|
|
238
|
+
* @property {string} [description]
|
|
239
|
+
* @property {ImageInfo} [image]
|
|
240
|
+
* @property {string} datePublished
|
|
241
|
+
* @property {string} dateModified
|
|
242
|
+
* @property {string} language
|
|
243
|
+
* @property {readonly {name: string, url: string, sameAs: readonly string[]}[]} authors
|
|
244
|
+
* @property {readonly string[]} tags
|
|
245
|
+
* @property {{name: string, url: string}} [series]
|
|
246
|
+
* @property {readonly CrumbStep[]} trail
|
|
247
|
+
*/
|
|
@@ -277,6 +277,19 @@ function joinedThemeOutputPath(basePath, themeRelativePath) {
|
|
|
277
277
|
);
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
+
/**
|
|
281
|
+
* The public `<link href>` for one theme file: the same joined path as
|
|
282
|
+
* {@link joinedThemeOutputPath}, but under the public base path.
|
|
283
|
+
*
|
|
284
|
+
* @param {string} publicBasePath the public base path
|
|
285
|
+
* @param {string} themeRelativePath the file's path relative to
|
|
286
|
+
* `THEME_ASSET_DIRECTORY`
|
|
287
|
+
* @returns {string} the root-absolute href
|
|
288
|
+
*/
|
|
289
|
+
function themeHref(publicBasePath, themeRelativePath) {
|
|
290
|
+
return `/${joinedThemeOutputPath(publicBasePath, themeRelativePath)}`;
|
|
291
|
+
}
|
|
292
|
+
|
|
280
293
|
/** @type {{maximumFileBytes: number, maximumTotalBytes: number, maximumFiles: number}} */
|
|
281
294
|
const FALLBACK_BUDGETS = Object.freeze({
|
|
282
295
|
maximumFileBytes: DEFAULT_MAXIMUM_PASSIVE_ASSET_BYTES,
|
|
@@ -471,6 +484,8 @@ function subresourceIntegrityAttribute(bytes) {
|
|
|
471
484
|
* file set `theme.json.stylesheets`/`assets` and `package.json.files`
|
|
472
485
|
* name)
|
|
473
486
|
* @param {string} options.basePath the build input's `basePath`
|
|
487
|
+
* @param {string} [options.publicBasePath] the base path emitted `<link href>`
|
|
488
|
+
* values use (`route.js#derivePublicBasePath`); defaults to `basePath`
|
|
474
489
|
* @returns {Promise<{
|
|
475
490
|
* files: {path: string, bytes: Buffer}[],
|
|
476
491
|
* assets: import('../../../types/index.d.ts').ManifestAssetEntry[],
|
|
@@ -478,7 +493,11 @@ function subresourceIntegrityAttribute(bytes) {
|
|
|
478
493
|
* }>} the files to write (already `basePath`-joined), their manifest rows
|
|
479
494
|
* (same joined paths), and the ready-to-insert `<link>` markup
|
|
480
495
|
*/
|
|
481
|
-
export async function loadThemeAssets({
|
|
496
|
+
export async function loadThemeAssets({
|
|
497
|
+
themeDirectory,
|
|
498
|
+
basePath,
|
|
499
|
+
publicBasePath = basePath,
|
|
500
|
+
}) {
|
|
482
501
|
const { bytes: themeJsonBytes } = await readDeclaredThemeFile(
|
|
483
502
|
themeDirectory,
|
|
484
503
|
'theme.json',
|
|
@@ -533,7 +552,7 @@ export async function loadThemeAssets({ themeDirectory, basePath }) {
|
|
|
533
552
|
// base64, computed fresh from these exact bytes.
|
|
534
553
|
const integrity = subresourceIntegrityAttribute(bytes);
|
|
535
554
|
linkTags.push(
|
|
536
|
-
`<link rel="stylesheet" href="${escapeHtml(
|
|
555
|
+
`<link rel="stylesheet" href="${escapeHtml(themeHref(publicBasePath, relativePath))}" integrity="${integrity}" crossorigin="anonymous"${mediaAttribute}>`,
|
|
537
556
|
);
|
|
538
557
|
}
|
|
539
558
|
|