@mannisto/astro-metadata 1.0.2 → 2.0.0-alpha.2

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.
Files changed (102) hide show
  1. package/README.md +427 -82
  2. package/package.json +25 -14
  3. package/src/assets/icons/LICENSE.md +35 -0
  4. package/src/assets/icons/binoculars.svg +1 -0
  5. package/src/assets/icons/caret-right.svg +1 -0
  6. package/src/assets/icons/check.svg +1 -0
  7. package/src/assets/icons/copy.svg +1 -0
  8. package/src/assets/icons/seal-check-duotone.svg +1 -0
  9. package/src/assets/icons/seal-question-duotone.svg +1 -0
  10. package/src/assets/icons/warning-circle-duotone.svg +1 -0
  11. package/src/assets/icons/warning-duotone.svg +1 -0
  12. package/src/assets/icons/x.svg +1 -0
  13. package/src/components/Metadata.astro +145 -0
  14. package/src/components/index.ts +2 -0
  15. package/src/integration.d.ts +112 -0
  16. package/src/integration.js +508 -0
  17. package/src/lib/checks.d.ts +70 -0
  18. package/src/lib/checks.js +193 -0
  19. package/src/lib/config.ts +111 -0
  20. package/src/lib/favicon-files.d.ts +29 -0
  21. package/src/lib/favicon-files.js +103 -0
  22. package/src/lib/favicon-image.ts +124 -0
  23. package/src/lib/favicon.ts +167 -0
  24. package/src/lib/head.d.ts +25 -0
  25. package/src/lib/head.js +141 -0
  26. package/src/lib/manifest.ts +126 -0
  27. package/src/lib/rules.d.ts +73 -0
  28. package/src/lib/rules.js +929 -0
  29. package/src/lib/schema.ts +208 -0
  30. package/src/lib/site.ts +72 -0
  31. package/src/lib/url.ts +79 -0
  32. package/src/routes/favicon.ts +14 -0
  33. package/src/routes/manifest.ts +18 -0
  34. package/src/runtime.ts +16 -0
  35. package/src/toolbar/app.ts +72 -0
  36. package/src/toolbar/assets.ts +81 -0
  37. package/src/toolbar/data.ts +282 -0
  38. package/src/toolbar/define.ts +7 -0
  39. package/src/toolbar/env.d.ts +10 -0
  40. package/src/toolbar/highlight.ts +83 -0
  41. package/src/toolbar/styles/shared.css +18 -0
  42. package/src/toolbar/styles/tokens.css +182 -0
  43. package/src/toolbar/toolbar-badge.ts +78 -0
  44. package/src/toolbar/ui/alert/alert.css +126 -0
  45. package/src/toolbar/ui/alert/alert.ts +102 -0
  46. package/src/toolbar/ui/badge/badge.css +41 -0
  47. package/src/toolbar/ui/badge/badge.ts +24 -0
  48. package/src/toolbar/ui/code-block/code-block.css +90 -0
  49. package/src/toolbar/ui/code-block/code-block.ts +68 -0
  50. package/src/toolbar/ui/color-swatch/color-swatch.css +15 -0
  51. package/src/toolbar/ui/color-swatch/color-swatch.ts +31 -0
  52. package/src/toolbar/ui/icon/icon.css +11 -0
  53. package/src/toolbar/ui/icon/icon.ts +46 -0
  54. package/src/toolbar/ui/image/image.css +18 -0
  55. package/src/toolbar/ui/image/image.ts +58 -0
  56. package/src/toolbar/ui/inline-code/inline-code.css +16 -0
  57. package/src/toolbar/ui/inline-code/inline-code.ts +41 -0
  58. package/src/toolbar/ui/section/section.css +88 -0
  59. package/src/toolbar/ui/section/section.ts +78 -0
  60. package/src/toolbar/ui/tab/tab.css +7 -0
  61. package/src/toolbar/ui/tab/tab.ts +47 -0
  62. package/src/toolbar/ui/tab-group/tab-group.css +42 -0
  63. package/src/toolbar/ui/tab-group/tab-group.ts +101 -0
  64. package/src/toolbar/ui/table/table.css +124 -0
  65. package/src/toolbar/ui/table/table.ts +155 -0
  66. package/src/toolbar/views/code-blocks.ts +35 -0
  67. package/src/toolbar/views/metadata-panel.css +105 -0
  68. package/src/toolbar/views/metadata-panel.ts +125 -0
  69. package/src/toolbar/views/overview-view.css +27 -0
  70. package/src/toolbar/views/overview-view.ts +401 -0
  71. package/src/toolbar/views/previews/discord.css +70 -0
  72. package/src/toolbar/views/previews/discord.ts +60 -0
  73. package/src/toolbar/views/previews/facebook.css +65 -0
  74. package/src/toolbar/views/previews/facebook.ts +36 -0
  75. package/src/toolbar/views/previews/google.css +75 -0
  76. package/src/toolbar/views/previews/google.ts +36 -0
  77. package/src/toolbar/views/previews/linkedin.css +58 -0
  78. package/src/toolbar/views/previews/linkedin.ts +34 -0
  79. package/src/toolbar/views/previews/page.ts +129 -0
  80. package/src/toolbar/views/previews/platform.ts +14 -0
  81. package/src/toolbar/views/previews/previews-view.css +37 -0
  82. package/src/toolbar/views/previews/previews-view.ts +52 -0
  83. package/src/toolbar/views/previews/slack.css +61 -0
  84. package/src/toolbar/views/previews/slack.ts +47 -0
  85. package/src/toolbar/views/previews/twitter.css +89 -0
  86. package/src/toolbar/views/previews/twitter.ts +57 -0
  87. package/src/toolbar/views/previews/whatsapp.css +88 -0
  88. package/src/toolbar/views/previews/whatsapp.ts +65 -0
  89. package/src/toolbar/views/raw-view.ts +41 -0
  90. package/index.ts +0 -31
  91. package/src/components/Canonical.astro +0 -11
  92. package/src/components/Description.astro +0 -9
  93. package/src/components/Favicon.astro +0 -96
  94. package/src/components/Head.astro +0 -94
  95. package/src/components/Keywords.astro +0 -9
  96. package/src/components/LanguageAlternates.astro +0 -18
  97. package/src/components/OpenGraph.astro +0 -75
  98. package/src/components/Robots.astro +0 -25
  99. package/src/components/Schema.astro +0 -18
  100. package/src/components/Title.astro +0 -13
  101. package/src/components/Twitter.astro +0 -27
  102. package/src/lib/metadata.ts +0 -77
@@ -0,0 +1,929 @@
1
+ // Plain JavaScript: the integration runs in Node, which does not strip types in node_modules.
2
+ //
3
+ // The rules of the metadata checks. The build and the dev toolbar app use the same rules.
4
+ // To add a rule, add an object to the list below:
5
+ //
6
+ // {
7
+ // id: "og-title-missing", // a short, unique name: users turn it off with `rules`
8
+ // field: "og:title", // the tag of the problem: the toolbar app shows its row
9
+ // level: "warning", // "error", "warning" or "info"
10
+ // indexed: true, // optional: skip the rule on a noindex page
11
+ // check(head) { // returns the message of a problem, or undefined
12
+ // if (!head.meta["og:title"]) return "The page has no `og:title`."
13
+ // },
14
+ // }
15
+ //
16
+ // Text between backticks shows as code in the dev toolbar app.
17
+
18
+ import { findLink, findLinks, isAbsolute } from "./head.js"
19
+
20
+ /** The approximate lengths that search results show. */
21
+ export const LIMITS = {
22
+ title: 60,
23
+ description: 160,
24
+ /** A shorter description gives search engines little to show. */
25
+ shortDescription: 50,
26
+ }
27
+
28
+ // The image sizes of the social sites, in px
29
+ const IMAGE = {
30
+ /** All sites show a large image from this size. */
31
+ large: { width: 1200, height: 630 },
32
+ /** Facebook shows a small image under this size. */
33
+ small: { width: 600, height: 315 },
34
+ /** Facebook shows no image under this size. */
35
+ minimum: 200,
36
+ /** The ratios that the social sites show without a large crop. */
37
+ ratio: { min: 1.6, max: 2.1 },
38
+ }
39
+
40
+ const TWITTER_CARDS = ["summary", "summary_large_image", "app", "player"]
41
+
42
+ /**
43
+ * Returns "1 title tag" or "2 title tags".
44
+ *
45
+ * @param {number} count
46
+ * @param {string} word
47
+ * @returns {string}
48
+ */
49
+ function count(count, word) {
50
+ return `${count} ${word}${count === 1 ? "" : "s"}`
51
+ }
52
+
53
+ /**
54
+ * Returns the JSON of each valid JSON-LD script, and the number of invalid scripts.
55
+ *
56
+ * @param {import("./head").HeadData} head
57
+ * @returns {{ valid: unknown[], invalid: number }}
58
+ */
59
+ function parseSchemas(head) {
60
+ const valid = []
61
+ let invalid = 0
62
+ for (const schema of head.schemas) {
63
+ try {
64
+ valid.push(JSON.parse(schema))
65
+ } catch {
66
+ invalid++
67
+ }
68
+ }
69
+ return { valid, invalid }
70
+ }
71
+
72
+ /**
73
+ * Returns the `og:image` size, or undefined without the width and the height.
74
+ *
75
+ * @param {import("./head").HeadData} head
76
+ * @returns {{ width: number, height: number } | undefined}
77
+ */
78
+ function imageSize(head) {
79
+ const width = Number.parseInt(head.meta["og:image:width"] ?? "", 10)
80
+ const height = Number.parseInt(head.meta["og:image:height"] ?? "", 10)
81
+ return Number.isNaN(width) || Number.isNaN(height) ? undefined : { width, height }
82
+ }
83
+
84
+ /**
85
+ * Returns the language of a locale, e.g. "en" for "en-US" or "en_US".
86
+ *
87
+ * @param {string} locale
88
+ * @returns {string}
89
+ */
90
+ function language(locale) {
91
+ return locale.split(/[-_]/)[0].toLowerCase()
92
+ }
93
+
94
+ /**
95
+ * Returns the hreflang links of the page.
96
+ *
97
+ * @param {import("./head").HeadData} head
98
+ * @returns {Record<string, string>[]}
99
+ */
100
+ function hreflangLinks(head) {
101
+ return findLinks(head, "alternate").filter((link) => {
102
+ return Boolean(link.hreflang)
103
+ })
104
+ }
105
+
106
+ // Text that a template leaves when a value is missing, e.g. "undefined | My Site"
107
+ const PLACEHOLDER = /\[object Object\]|%s|\{\{|\}\}/
108
+ const EMPTY_VALUES = ["undefined", "null", "nan"]
109
+
110
+ /**
111
+ * Returns the first value that contains placeholder text, or undefined.
112
+ *
113
+ * @param {(string | undefined)[]} values
114
+ * @returns {string | undefined}
115
+ */
116
+ function findPlaceholder(values) {
117
+ return values.find((value) => {
118
+ if (!value) return false
119
+ if (PLACEHOLDER.test(value)) return true
120
+ // A part of a title between separators, e.g. "undefined" in "undefined | My Site"
121
+ return value.split(/\s[|\-–—:·]\s/).some((part) => {
122
+ return EMPTY_VALUES.includes(part.trim().toLowerCase())
123
+ })
124
+ })
125
+ }
126
+
127
+ // The fields that Google needs for a rich result. Without them, the type gives no rich result.
128
+ const REQUIRED_FIELDS = {
129
+ BreadcrumbList: ["itemListElement"],
130
+ Event: ["name", "startDate", "location"],
131
+ FAQPage: ["mainEntity"],
132
+ JobPosting: ["title", "datePosted", "description", "hiringOrganization"],
133
+ Product: ["name"],
134
+ Recipe: ["name", "image"],
135
+ Review: ["itemReviewed", "author"],
136
+ }
137
+
138
+ // The fields that Google recommends for an article
139
+ const ARTICLE_TYPES = ["Article", "NewsArticle", "BlogPosting"]
140
+ const ARTICLE_FIELDS = ["headline", "image", "datePublished", "author"]
141
+
142
+ /**
143
+ * Returns all items of the valid JSON-LD scripts: the items of arrays and of `@graph`.
144
+ *
145
+ * @param {import("./head").HeadData} head
146
+ * @returns {Record<string, unknown>[]}
147
+ */
148
+ function schemaItems(head) {
149
+ return parseSchemas(head).valid.flatMap((schema) => {
150
+ const items = Array.isArray(schema) ? schema : [schema]
151
+ return items.flatMap((item) => {
152
+ if (!item || typeof item !== "object") return []
153
+ return Array.isArray(item["@graph"]) ? [item, ...item["@graph"]] : [item]
154
+ })
155
+ })
156
+ }
157
+
158
+ /**
159
+ * Returns the types of a structured data item, e.g. ["Article"].
160
+ *
161
+ * @param {Record<string, unknown>} item
162
+ * @returns {string[]}
163
+ */
164
+ function typesOf(item) {
165
+ const type = item["@type"]
166
+ return (Array.isArray(type) ? type : [type]).filter((value) => {
167
+ return typeof value === "string"
168
+ })
169
+ }
170
+
171
+ /**
172
+ * Returns "`a`" or "`a` and `b`" or "`a`, `b` and `c`".
173
+ *
174
+ * @param {string[]} names
175
+ * @returns {string}
176
+ */
177
+ function codeList(names) {
178
+ const codes = names.map((name) => {
179
+ return `\`${name}\``
180
+ })
181
+ if (codes.length === 1) return codes[0]
182
+ return `${codes.slice(0, -1).join(", ")} and ${codes[codes.length - 1]}`
183
+ }
184
+
185
+ /** @type {import("./rules").Rule[]} */
186
+ export const RULES = [
187
+ // The document
188
+
189
+ {
190
+ id: "title-missing",
191
+ field: "title",
192
+ level: "error",
193
+ check(head) {
194
+ if (!head.titles[0]) return "The page has no title."
195
+ },
196
+ },
197
+ {
198
+ id: "title-duplicate",
199
+ field: "title",
200
+ level: "error",
201
+ check(head) {
202
+ if (head.titles.length > 1) {
203
+ return `The page has ${count(head.titles.length, "title tag")}. Keep only one.`
204
+ }
205
+ },
206
+ },
207
+ {
208
+ id: "title-long",
209
+ field: "title",
210
+ level: "warning",
211
+ check(head) {
212
+ const title = head.titles[0] ?? ""
213
+ if (title.length > LIMITS.title) {
214
+ return `The title has ${title.length} characters. Search results show about ${LIMITS.title}.`
215
+ }
216
+ },
217
+ },
218
+ {
219
+ id: "charset-missing",
220
+ field: "charset",
221
+ level: "warning",
222
+ check(head) {
223
+ if (!head.charset)
224
+ return "The page has no `<meta charset>`. Browsers can show wrong characters."
225
+ },
226
+ },
227
+ {
228
+ id: "viewport-missing",
229
+ field: "viewport",
230
+ level: "warning",
231
+ check(head) {
232
+ if (!head.meta.viewport) {
233
+ return "The page has no `viewport` meta tag. Phones show the page zoomed out."
234
+ }
235
+ },
236
+ },
237
+ {
238
+ id: "lang-missing",
239
+ field: "lang",
240
+ level: "warning",
241
+ check(head) {
242
+ if (!head.lang) return "The `<html>` tag has no `lang` attribute."
243
+ },
244
+ },
245
+ {
246
+ id: "lang-invalid",
247
+ field: "lang",
248
+ level: "warning",
249
+ check(head) {
250
+ if (head.lang && !/^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i.test(head.lang)) {
251
+ return `The \`lang\` value \`${head.lang}\` is not valid. Use a code like \`en\` or \`en-US\`.`
252
+ }
253
+ },
254
+ },
255
+ {
256
+ id: "placeholder-title",
257
+ field: "title",
258
+ level: "error",
259
+ check(head) {
260
+ const value = findPlaceholder([
261
+ head.titles[0],
262
+ head.meta["og:title"],
263
+ head.meta["twitter:title"],
264
+ ])
265
+ if (value) return `The title contains placeholder text: "${value}".`
266
+ },
267
+ },
268
+ {
269
+ id: "placeholder-description",
270
+ field: "description",
271
+ level: "error",
272
+ check(head) {
273
+ const value = findPlaceholder([
274
+ head.meta.description,
275
+ head.meta["og:description"],
276
+ head.meta["twitter:description"],
277
+ ])
278
+ if (value) return `The description contains placeholder text: "${value}".`
279
+ },
280
+ },
281
+ {
282
+ id: "favicon-missing",
283
+ field: "favicon",
284
+ level: "warning",
285
+ check(head) {
286
+ if (!findLink(head, "icon")) return "The page has no favicon link."
287
+ },
288
+ },
289
+ {
290
+ id: "apple-touch-icon-missing",
291
+ field: "favicon",
292
+ level: "info",
293
+ check(head) {
294
+ if (findLink(head, "icon") && !findLink(head, "apple-touch-icon")) {
295
+ return "The page has no `apple-touch-icon`. iOS shows a screenshot of the page on the home screen."
296
+ }
297
+ },
298
+ },
299
+
300
+ // Structured data
301
+
302
+ {
303
+ id: "json-ld-invalid",
304
+ field: "json-ld",
305
+ level: "error",
306
+ check(head) {
307
+ const { invalid } = parseSchemas(head)
308
+ if (invalid === 1) return "A JSON-LD script does not contain valid JSON."
309
+ if (invalid > 1) return `${invalid} JSON-LD scripts do not contain valid JSON.`
310
+ },
311
+ },
312
+ {
313
+ id: "json-ld-context",
314
+ field: "json-ld",
315
+ level: "warning",
316
+ check(head) {
317
+ const missing = parseSchemas(head).valid.some((schema) => {
318
+ const items = Array.isArray(schema) ? schema : [schema]
319
+ return items.some((item) => {
320
+ return item && typeof item === "object" && !("@context" in item)
321
+ })
322
+ })
323
+ if (missing) return "A JSON-LD item has no `@context`. Set it to `https://schema.org`."
324
+ },
325
+ },
326
+ {
327
+ id: "json-ld-required",
328
+ field: "json-ld",
329
+ level: "error",
330
+ check(head) {
331
+ for (const item of schemaItems(head)) {
332
+ for (const type of typesOf(item)) {
333
+ const missing = (REQUIRED_FIELDS[type] ?? []).filter((field) => {
334
+ return item[field] === undefined
335
+ })
336
+ if (missing.length > 0) {
337
+ return `The \`${type}\` structured data has no ${codeList(missing)}. Google shows no rich result.`
338
+ }
339
+ }
340
+ }
341
+ },
342
+ },
343
+ {
344
+ id: "json-ld-breadcrumb",
345
+ field: "json-ld",
346
+ level: "error",
347
+ check(head) {
348
+ const lists = schemaItems(head).filter((item) => {
349
+ return typesOf(item).includes("BreadcrumbList")
350
+ })
351
+ for (const list of lists) {
352
+ const items = Array.isArray(list.itemListElement) ? list.itemListElement : []
353
+ const invalid = items.some((item) => {
354
+ return !item || item.position === undefined || item.name === undefined
355
+ })
356
+ if (invalid) {
357
+ return "An item of the `BreadcrumbList` has no `position` or `name`."
358
+ }
359
+ }
360
+ },
361
+ },
362
+ {
363
+ id: "json-ld-article",
364
+ field: "json-ld",
365
+ level: "warning",
366
+ indexed: true,
367
+ check(head) {
368
+ for (const item of schemaItems(head)) {
369
+ const type = typesOf(item).find((value) => {
370
+ return ARTICLE_TYPES.includes(value)
371
+ })
372
+ if (!type) continue
373
+
374
+ const missing = ARTICLE_FIELDS.filter((field) => {
375
+ return item[field] === undefined
376
+ })
377
+ if (missing.length > 0) {
378
+ return `The \`${type}\` structured data has no ${codeList(missing)}. Google recommends them.`
379
+ }
380
+ }
381
+ },
382
+ },
383
+
384
+ // Search engines
385
+
386
+ {
387
+ id: "noindex",
388
+ field: "robots",
389
+ level: "info",
390
+ check(head) {
391
+ if (/noindex/i.test(head.meta.robots ?? "")) {
392
+ return "The robots tag contains `noindex`: search engines do not show the page."
393
+ }
394
+ },
395
+ },
396
+ {
397
+ id: "description-missing",
398
+ field: "description",
399
+ level: "warning",
400
+ indexed: true,
401
+ check(head) {
402
+ if (!head.meta.description) return "The page has no description."
403
+ },
404
+ },
405
+ {
406
+ id: "description-long",
407
+ field: "description",
408
+ level: "warning",
409
+ indexed: true,
410
+ check(head) {
411
+ const length = head.meta.description?.length ?? 0
412
+ if (length > LIMITS.description) {
413
+ return `The description has ${length} characters. Search results show about ${LIMITS.description}.`
414
+ }
415
+ },
416
+ },
417
+ {
418
+ id: "description-short",
419
+ field: "description",
420
+ level: "info",
421
+ indexed: true,
422
+ check(head) {
423
+ const length = head.meta.description?.length ?? 0
424
+ if (length > 0 && length < LIMITS.shortDescription) {
425
+ return `The description has ${length} characters. Use ${LIMITS.shortDescription} to ${LIMITS.description} characters.`
426
+ }
427
+ },
428
+ },
429
+ {
430
+ id: "description-duplicate",
431
+ field: "description",
432
+ level: "warning",
433
+ indexed: true,
434
+ check(head) {
435
+ const tags = head.metaCount.description ?? 0
436
+ if (tags > 1) return `The page has ${tags} description tags. Search engines use only one.`
437
+ },
438
+ },
439
+ {
440
+ id: "canonical-missing",
441
+ field: "canonical",
442
+ level: "warning",
443
+ indexed: true,
444
+ check(head) {
445
+ if (!findLink(head, "canonical")) return "The page has no canonical URL."
446
+ },
447
+ },
448
+ {
449
+ id: "canonical-duplicate",
450
+ field: "canonical",
451
+ level: "error",
452
+ indexed: true,
453
+ check(head) {
454
+ const links = findLinks(head, "canonical").length
455
+ if (links > 1) return `The page has ${links} canonical links. Keep only one.`
456
+ },
457
+ },
458
+ {
459
+ id: "canonical-relative",
460
+ field: "canonical",
461
+ level: "warning",
462
+ indexed: true,
463
+ check(head) {
464
+ const canonical = findLink(head, "canonical")
465
+ if (canonical && !isAbsolute(canonical)) {
466
+ return "The canonical URL is not absolute. Set `site` in the Astro config."
467
+ }
468
+ },
469
+ },
470
+
471
+ // Social sites
472
+
473
+ {
474
+ id: "og-title-missing",
475
+ field: "og:title",
476
+ level: "warning",
477
+ indexed: true,
478
+ check(head) {
479
+ if (!head.meta["og:title"])
480
+ return "The page has no `og:title`. Social sites use the `<title>`."
481
+ },
482
+ },
483
+ {
484
+ id: "og-description-missing",
485
+ field: "og:description",
486
+ level: "warning",
487
+ indexed: true,
488
+ check(head) {
489
+ if (!head.meta["og:description"]) {
490
+ return "The page has no `og:description`. Some social cards show no description."
491
+ }
492
+ },
493
+ },
494
+ {
495
+ id: "og-url-mismatch",
496
+ field: "og:url",
497
+ level: "warning",
498
+ indexed: true,
499
+ check(head) {
500
+ const url = head.meta["og:url"]
501
+ const canonical = findLink(head, "canonical")
502
+ if (url && canonical && url !== canonical) {
503
+ return "The `og:url` is not the same as the canonical URL. Social sites can count the shares two times."
504
+ }
505
+ },
506
+ },
507
+ {
508
+ id: "og-locale-mismatch",
509
+ field: "og:locale",
510
+ level: "warning",
511
+ indexed: true,
512
+ check(head) {
513
+ const locale = head.meta["og:locale"]
514
+ if (!locale || !head.lang) return
515
+ if (language(locale) !== language(head.lang)) {
516
+ return `The \`og:locale\` \`${locale}\` is not the language of the \`lang\` attribute \`${head.lang}\`.`
517
+ }
518
+ },
519
+ },
520
+ {
521
+ id: "og-image-missing",
522
+ field: "og:image",
523
+ level: "warning",
524
+ indexed: true,
525
+ check(head) {
526
+ if (!head.meta["og:image"]) return "The page has no `og:image`. Social cards show no image."
527
+ },
528
+ },
529
+ {
530
+ id: "og-image-relative",
531
+ field: "og:image",
532
+ level: "warning",
533
+ indexed: true,
534
+ check(head) {
535
+ const image = head.meta["og:image"]
536
+ if (image && !isAbsolute(image)) {
537
+ return "The `og:image` URL is not absolute. Social sites can not load it."
538
+ }
539
+ },
540
+ },
541
+ {
542
+ id: "og-image-http",
543
+ field: "og:image",
544
+ level: "warning",
545
+ indexed: true,
546
+ check(head) {
547
+ if (/^http:\/\//i.test(head.meta["og:image"] ?? "")) {
548
+ return "The `og:image` URL uses `http`. Some sites, e.g. WhatsApp, load only `https` images."
549
+ }
550
+ },
551
+ },
552
+ {
553
+ id: "og-image-size-missing",
554
+ field: "og:image",
555
+ level: "warning",
556
+ indexed: true,
557
+ check(head) {
558
+ if (head.meta["og:image"] && !imageSize(head)) {
559
+ return "The `og:image` has no width and height. The first share can show no image."
560
+ }
561
+ },
562
+ },
563
+ {
564
+ id: "og-image-too-small",
565
+ field: "og:image",
566
+ level: "error",
567
+ indexed: true,
568
+ check(head) {
569
+ const size = imageSize(head)
570
+ if (size && (size.width < IMAGE.minimum || size.height < IMAGE.minimum)) {
571
+ return `The \`og:image\` is ${size.width} × ${size.height} px. Facebook does not show images smaller than ${IMAGE.minimum} × ${IMAGE.minimum} px.`
572
+ }
573
+ },
574
+ },
575
+ {
576
+ id: "og-image-small",
577
+ field: "og:image",
578
+ level: "warning",
579
+ indexed: true,
580
+ check(head) {
581
+ const size = imageSize(head)
582
+ if (!size || size.width < IMAGE.minimum || size.height < IMAGE.minimum) return
583
+ if (size.width < IMAGE.small.width || size.height < IMAGE.small.height) {
584
+ return `The \`og:image\` is ${size.width} × ${size.height} px. Social sites show it small. Use ${IMAGE.large.width} × ${IMAGE.large.height} px.`
585
+ }
586
+ },
587
+ },
588
+ {
589
+ id: "og-image-ratio",
590
+ field: "og:image",
591
+ level: "warning",
592
+ indexed: true,
593
+ check(head) {
594
+ const size = imageSize(head)
595
+ // A small card can use a square image, and a too small image has its own error
596
+ if (!size || head.meta["twitter:card"] === "summary") return
597
+ if (size.width < IMAGE.minimum || size.height < IMAGE.minimum) return
598
+
599
+ const ratio = size.width / size.height
600
+ if (ratio < IMAGE.ratio.min || ratio > IMAGE.ratio.max) {
601
+ return `The \`og:image\` ratio is ${ratio.toFixed(2)}:1. Social sites crop it to about 1.91:1.`
602
+ }
603
+ },
604
+ },
605
+ {
606
+ id: "og-image-alt-missing",
607
+ field: "og:image:alt",
608
+ level: "info",
609
+ indexed: true,
610
+ check(head) {
611
+ if (head.meta["og:image"] && !head.meta["og:image:alt"]) {
612
+ return "The `og:image` has no `og:image:alt`. Screen readers can not describe the image."
613
+ }
614
+ },
615
+ },
616
+ {
617
+ id: "twitter-card-missing",
618
+ field: "twitter:card",
619
+ level: "warning",
620
+ indexed: true,
621
+ check(head) {
622
+ if (!head.meta["twitter:card"])
623
+ return "The page has no `twitter:card`. Twitter (X) shows no card."
624
+ },
625
+ },
626
+ {
627
+ id: "twitter-card-invalid",
628
+ field: "twitter:card",
629
+ level: "error",
630
+ indexed: true,
631
+ check(head) {
632
+ const card = head.meta["twitter:card"]
633
+ if (card && !TWITTER_CARDS.includes(card)) {
634
+ return `The \`twitter:card\` value \`${card}\` is not valid. Use \`summary\` or \`summary_large_image\`.`
635
+ }
636
+ },
637
+ },
638
+
639
+ // Locales
640
+
641
+ {
642
+ id: "hreflang-x-default",
643
+ field: "hreflang",
644
+ level: "warning",
645
+ indexed: true,
646
+ check(head) {
647
+ const links = hreflangLinks(head)
648
+ const fallback = links.some((link) => {
649
+ return link.hreflang.toLowerCase() === "x-default"
650
+ })
651
+ if (links.length > 0 && !fallback) {
652
+ return "The hreflang links have no `x-default`. Add it for the users of other locales."
653
+ }
654
+ },
655
+ },
656
+ {
657
+ id: "hreflang-self",
658
+ field: "hreflang",
659
+ level: "warning",
660
+ indexed: true,
661
+ check(head) {
662
+ const links = hreflangLinks(head)
663
+ const canonical = findLink(head, "canonical")
664
+ const self = links.some((link) => {
665
+ return link.href === canonical
666
+ })
667
+ if (links.length > 0 && canonical && !self) {
668
+ return "The hreflang links do not include this page. Each locale version must also link to itself."
669
+ }
670
+ },
671
+ },
672
+ {
673
+ id: "hreflang-relative",
674
+ field: "hreflang",
675
+ level: "warning",
676
+ indexed: true,
677
+ check(head) {
678
+ const relative = hreflangLinks(head).some((link) => {
679
+ return !isAbsolute(link.href ?? "")
680
+ })
681
+ if (relative) return "An hreflang URL is not absolute. Search engines need absolute URLs."
682
+ },
683
+ },
684
+ {
685
+ id: "hreflang-duplicate",
686
+ field: "hreflang",
687
+ level: "warning",
688
+ indexed: true,
689
+ check(head) {
690
+ const locales = hreflangLinks(head).map((link) => {
691
+ return link.hreflang.toLowerCase()
692
+ })
693
+ const duplicate = locales.find((locale, index) => {
694
+ return locales.indexOf(locale) !== index
695
+ })
696
+ if (duplicate) {
697
+ return `The hreflang \`${duplicate}\` is on more than one link. Keep one link for each locale.`
698
+ }
699
+ },
700
+ },
701
+ ]
702
+
703
+ /**
704
+ * Returns the pages that search engines index: the pages without `noindex`.
705
+ *
706
+ * @param {import("./rules").SitePage[]} pages
707
+ * @returns {import("./rules").SitePage[]}
708
+ */
709
+ function indexedPages(pages) {
710
+ return pages.filter((page) => {
711
+ return !/noindex/i.test(page.head.meta.robots ?? "")
712
+ })
713
+ }
714
+
715
+ /**
716
+ * Returns true for a page with an hreflang link to the canonical URL of another page.
717
+ *
718
+ * @param {import("./rules").SitePage} page
719
+ * @param {import("./rules").SitePage} other
720
+ * @returns {boolean}
721
+ */
722
+ function linksTo(page, other) {
723
+ const url = findLink(other.head, "canonical")
724
+ if (!url) return false
725
+ return hreflangLinks(page.head).some((link) => {
726
+ return link.href === url
727
+ })
728
+ }
729
+
730
+ /**
731
+ * Returns true for two pages that are versions of one page in other locales:
732
+ * one of the pages has an hreflang link to the other.
733
+ *
734
+ * @param {import("./rules").SitePage} a
735
+ * @param {import("./rules").SitePage} b
736
+ * @returns {boolean}
737
+ */
738
+ function isLocaleVersion(a, b) {
739
+ return linksTo(a, b) || linksTo(b, a)
740
+ }
741
+
742
+ /**
743
+ * Returns a problem for each page with a value that other pages also have.
744
+ * The versions of a page in other locales can have the same value, e.g. the name of
745
+ * an artwork in its title, so they do not count.
746
+ *
747
+ * @param {import("./rules").SitePage[]} pages
748
+ * @param {(page: import("./rules").SitePage) => string | undefined} valueOf
749
+ * @param {string} name - The name of the value in the message, e.g. "title".
750
+ * @returns {import("./rules").SiteProblem[]}
751
+ */
752
+ function duplicates(pages, valueOf, name) {
753
+ /** @type {Map<string, import("./rules").SitePage[]>} */
754
+ const groups = new Map()
755
+ for (const page of indexedPages(pages)) {
756
+ const value = valueOf(page)
757
+ if (value) groups.set(value, [...(groups.get(value) ?? []), page])
758
+ }
759
+
760
+ return [...groups.values()].flatMap((group) => {
761
+ return group.flatMap((page) => {
762
+ const others = group.filter((other) => {
763
+ return other !== page && !isLocaleVersion(page, other)
764
+ })
765
+ if (others.length === 0) return []
766
+
767
+ const list =
768
+ others.length === 1
769
+ ? others[0].path
770
+ : `${others[0].path} and ${count(others.length - 1, "other page")}`
771
+ return [{ path: page.path, message: `The ${name} is the same as on ${list}.` }]
772
+ })
773
+ })
774
+ }
775
+
776
+ /**
777
+ * The rules that compare the pages of the site. Only the build runs them, because the
778
+ * dev toolbar app sees one page at a time. A check returns the problems of the pages.
779
+ *
780
+ * @type {import("./rules").SiteRule[]}
781
+ */
782
+ export const SITE_RULES = [
783
+ {
784
+ id: "title-not-unique",
785
+ field: "title",
786
+ level: "warning",
787
+ check(pages) {
788
+ return duplicates(
789
+ pages,
790
+ (page) => {
791
+ return page.head.titles[0]
792
+ },
793
+ "title"
794
+ )
795
+ },
796
+ },
797
+ {
798
+ id: "description-not-unique",
799
+ field: "description",
800
+ level: "warning",
801
+ check(pages) {
802
+ return duplicates(
803
+ pages,
804
+ (page) => {
805
+ return page.head.meta.description
806
+ },
807
+ "description"
808
+ )
809
+ },
810
+ },
811
+ {
812
+ id: "hreflang-no-return",
813
+ field: "hreflang",
814
+ level: "warning",
815
+ check(pages) {
816
+ // The pages by their canonical URL
817
+ const byUrl = new Map()
818
+ for (const page of pages) {
819
+ const canonical = findLink(page.head, "canonical")
820
+ if (canonical) byUrl.set(canonical, page)
821
+ }
822
+
823
+ return pages.flatMap((page) => {
824
+ const canonical = findLink(page.head, "canonical")
825
+ if (!canonical) return []
826
+
827
+ return hreflangLinks(page.head).flatMap((link) => {
828
+ const other = byUrl.get(link.href)
829
+ if (!other || other === page || link.hreflang.toLowerCase() === "x-default") return []
830
+
831
+ const back = hreflangLinks(other.head).some((otherLink) => {
832
+ return otherLink.href === canonical
833
+ })
834
+ if (back) return []
835
+ return [
836
+ {
837
+ path: page.path,
838
+ message: `The hreflang page ${other.path} does not link back to this page. Search engines ignore one-way hreflang links.`,
839
+ },
840
+ ]
841
+ })
842
+ })
843
+ },
844
+ },
845
+ ]
846
+
847
+ // The image formats that all social sites show
848
+ const IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp"]
849
+
850
+ /** WhatsApp shows no image over this file size, in bytes. */
851
+ const MAX_IMAGE_BYTES = 600 * 1024
852
+
853
+ /**
854
+ * The rules that load the files of the page: the `og:image` and the favicons.
855
+ * The build reads the files from the output folder, and the dev toolbar app loads them
856
+ * from the dev server. A check gets the head and the loaded files.
857
+ *
858
+ * @type {import("./rules").AssetRule[]}
859
+ */
860
+ export const ASSET_RULES = [
861
+ {
862
+ id: "og-image-not-found",
863
+ field: "og:image",
864
+ level: "error",
865
+ check(head, assets) {
866
+ const image = assets.image
867
+ if (image && !image.ok) {
868
+ const status = image.status ? `: the server answered ${image.status}` : ""
869
+ return `The \`og:image\` does not load${status}.`
870
+ }
871
+ },
872
+ },
873
+ {
874
+ id: "og-image-svg",
875
+ field: "og:image",
876
+ level: "error",
877
+ check(head, assets) {
878
+ if (assets.image?.ok && assets.image.type === "image/svg+xml") {
879
+ return "The `og:image` is an SVG file. Social sites do not show SVG images."
880
+ }
881
+ },
882
+ },
883
+ {
884
+ id: "og-image-format",
885
+ field: "og:image",
886
+ level: "warning",
887
+ check(head, assets) {
888
+ const type = assets.image?.ok ? assets.image.type : undefined
889
+ if (type && type !== "image/svg+xml" && !IMAGE_TYPES.includes(type)) {
890
+ return `The \`og:image\` is a \`${type}\` file. Use JPEG, PNG or WebP: some sites show no other formats.`
891
+ }
892
+ },
893
+ },
894
+ {
895
+ id: "og-image-file-large",
896
+ field: "og:image",
897
+ level: "warning",
898
+ check(head, assets) {
899
+ const bytes = assets.image?.ok ? assets.image.bytes : undefined
900
+ if (bytes && bytes > MAX_IMAGE_BYTES) {
901
+ return `The \`og:image\` file is ${Math.round(bytes / 1024)} KB. WhatsApp shows no image over 600 KB.`
902
+ }
903
+ },
904
+ },
905
+ {
906
+ id: "og-image-size-mismatch",
907
+ field: "og:image",
908
+ level: "warning",
909
+ check(head, assets) {
910
+ const image = assets.image
911
+ const size = imageSize(head)
912
+ if (!image?.ok || !image.width || !image.height || !size) return
913
+ if (image.width !== size.width || image.height !== size.height) {
914
+ return `The \`og:image\` is ${image.width} × ${image.height} px, but \`og:image:width\` and \`og:image:height\` give ${size.width} × ${size.height}.`
915
+ }
916
+ },
917
+ },
918
+ {
919
+ id: "favicon-not-found",
920
+ field: "favicon",
921
+ level: "error",
922
+ check(head, assets) {
923
+ const missing = assets.icons.find((icon) => {
924
+ return !icon.ok
925
+ })
926
+ if (missing) return `The favicon \`${missing.url}\` does not load.`
927
+ },
928
+ },
929
+ ]