@rathnasgala2/template 2.2.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
+ */