@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.
@@ -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({ themeDirectory, basePath }) {
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(`/${outputPath}`)}" integrity="${integrity}" crossorigin="anonymous"${mediaAttribute}>`,
555
+ `<link rel="stylesheet" href="${escapeHtml(themeHref(publicBasePath, relativePath))}" integrity="${integrity}" crossorigin="anonymous"${mediaAttribute}>`,
537
556
  );
538
557
  }
539
558