@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,508 @@
1
+ import crypto from "node:crypto"
2
+ import fs from "node:fs"
3
+ import { createRequire } from "node:module"
4
+ import path from "node:path"
5
+ import { fileURLToPath, URL } from "node:url"
6
+
7
+ import { defaultFaviconFiles, resolveFaviconFiles } from "./lib/favicon-files.js"
8
+
9
+ const NAME = "@mannisto/astro-metadata"
10
+
11
+ // The site values of the options. The components read them at build time.
12
+ const SITE_KEYS = [
13
+ "siteName",
14
+ "titleTemplate",
15
+ "description",
16
+ "image",
17
+ "twitter",
18
+ "themeColor",
19
+ "feeds",
20
+ "robots",
21
+ "manifest",
22
+ ]
23
+
24
+ // The Lit modules of the dev toolbar app
25
+ const LIT_MODULES = [
26
+ "lit",
27
+ "lit/directives/style-map.js",
28
+ "lit/directives/unsafe-html.js",
29
+ "lit/directives/unsafe-svg.js",
30
+ ]
31
+
32
+ /**
33
+ * Finds sharp: first the copy that Astro installs as an optional dependency,
34
+ * then a copy in the project.
35
+ *
36
+ * @param {URL} root - The root of the Astro project.
37
+ * @returns {string} The absolute path of the sharp package.
38
+ * @throws {Error} When sharp is not installed.
39
+ */
40
+ function findSharp(root) {
41
+ const fromProject = createRequire(fileURLToPath(new URL("./package.json", root)))
42
+ try {
43
+ const astro = fromProject.resolve("astro/package.json")
44
+ return createRequire(astro).resolve("sharp")
45
+ } catch {
46
+ // Astro was installed without its optional dependencies
47
+ }
48
+ try {
49
+ return fromProject.resolve("sharp")
50
+ } catch {
51
+ throw new Error(
52
+ `${NAME} The favicon option needs sharp. Install it in your project: npm install sharp`
53
+ )
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Loads sharp for the image sizes of the build checks, or undefined without sharp.
59
+ *
60
+ * @param {URL | undefined} root - The root of the Astro project.
61
+ * @returns {import("./integration").BuildContext["sharp"]}
62
+ */
63
+ function loadSharp(root) {
64
+ if (!root) return undefined
65
+ try {
66
+ return createRequire(import.meta.url)(findSharp(root))
67
+ } catch {
68
+ return undefined
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Checks the favicon source and returns the data for the generated files.
74
+ * Warns about a source that gives blurred or cut icons.
75
+ *
76
+ * @param {URL} root - The root of the Astro project.
77
+ * @param {URL} publicDir - The public directory of the Astro project.
78
+ * @param {import("./integration").FaviconOptions} options - The `favicon` option.
79
+ * @param {import("astro").AstroIntegrationLogger} logger - The integration logger.
80
+ * @returns {Promise<import("./lib/favicon").FaviconInfo>} The favicon data.
81
+ */
82
+ async function resolveFavicon(root, publicDir, options, logger) {
83
+ const source = fileURLToPath(new URL(options.source, root))
84
+ if (!fs.existsSync(source)) throw new Error(`${NAME} Favicon source not found: ${options.source}`)
85
+
86
+ const content = fs.readFileSync(source)
87
+ const svg = path.extname(source).toLowerCase() === ".svg"
88
+ const hash = crypto.createHash("sha256").update(content).digest("hex").slice(0, 8)
89
+
90
+ // The endpoints load sharp from the same path: see `loadSharp` in lib/favicon-image.ts
91
+ const sharpPath = findSharp(root)
92
+ const sharp = createRequire(import.meta.url)(sharpPath)
93
+ const { width = 0, height = 0 } = await sharp(content).metadata()
94
+ if (width !== height) {
95
+ logger.warn(`The favicon source is not square (${width}x${height}). The icons get empty space.`)
96
+ }
97
+ if (!svg && Math.min(width, height) < 512) {
98
+ logger.warn(`The favicon source is smaller than 512 px. The large icons become blurred.`)
99
+ }
100
+ if (svg && !/viewBox=/i.test(content.toString("utf-8"))) {
101
+ logger.warn("The favicon SVG has no viewBox. It can not scale correctly.")
102
+ }
103
+
104
+ let files
105
+ try {
106
+ files = resolveFaviconFiles(options.files ?? defaultFaviconFiles(svg), svg)
107
+ } catch (error) {
108
+ throw new Error(`${NAME} ${error instanceof Error ? error.message : error}`, { cause: error })
109
+ }
110
+
111
+ // A file in public/ with the same path replaces the generated file in the build
112
+ for (const file of files) {
113
+ if (fs.existsSync(fileURLToPath(new URL(`.${file.path}`, publicDir)))) {
114
+ logger.warn(
115
+ `public${file.path} replaces the generated ${file.path}. Remove it from public/ to use the generated file.`
116
+ )
117
+ }
118
+ }
119
+
120
+ return {
121
+ source,
122
+ background: options.background ?? "#ffffff",
123
+ svg,
124
+ hash,
125
+ sharp: sharpPath,
126
+ files,
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Returns the paths of all HTML files in a directory and its subdirectories.
132
+ *
133
+ * @param {string} dir
134
+ * @returns {string[]}
135
+ */
136
+ function findHtmlFiles(dir) {
137
+ return fs
138
+ .readdirSync(dir, { recursive: true, encoding: "utf-8" })
139
+ .filter((file) => {
140
+ return file.endsWith(".html")
141
+ })
142
+ .map((file) => {
143
+ return path.join(dir, file)
144
+ })
145
+ .sort()
146
+ }
147
+
148
+ // The MIME types of the image files, by their extension
149
+ const MIME_TYPES = {
150
+ ".avif": "image/avif",
151
+ ".gif": "image/gif",
152
+ ".ico": "image/x-icon",
153
+ ".jpeg": "image/jpeg",
154
+ ".jpg": "image/jpeg",
155
+ ".png": "image/png",
156
+ ".svg": "image/svg+xml",
157
+ ".webp": "image/webp",
158
+ }
159
+
160
+ /**
161
+ * Returns the debug features: the dev toolbar app (`client`) and the build checks (`build`).
162
+ *
163
+ * @param {import("./integration").MetadataOptions["debug"]} debug
164
+ * @returns {{ client: boolean, build: boolean }}
165
+ */
166
+ function debugOptions(debug) {
167
+ if (typeof debug === "boolean") return { client: debug, build: debug }
168
+ return { client: debug?.client ?? true, build: debug?.build ?? true }
169
+ }
170
+
171
+ /**
172
+ * Returns the path of a file of the site in the output directory, or undefined for
173
+ * a file on another site.
174
+ *
175
+ * @param {string} url - The URL or path of the file.
176
+ * @param {import("./integration").BuildContext} context
177
+ * @returns {string | undefined}
178
+ */
179
+ function outputFile(url, context) {
180
+ const site = context.site ? new URL(context.site) : undefined
181
+ let parsed
182
+ try {
183
+ parsed = new URL(url, site ?? "http://localhost/")
184
+ } catch {
185
+ return undefined
186
+ }
187
+ if (site ? parsed.origin !== site.origin : /^https?:\/\//i.test(url)) return undefined
188
+
189
+ // The output directory has no folder for the base
190
+ const base = context.base.replace(/\/$/, "")
191
+ let pathname = decodeURIComponent(parsed.pathname)
192
+ if (base && pathname.startsWith(`${base}/`)) pathname = pathname.slice(base.length)
193
+ return path.join(context.dir, pathname)
194
+ }
195
+
196
+ /**
197
+ * Reads a file of the page from the output directory: whether it exists, its type,
198
+ * its size in bytes, and the size of an image.
199
+ *
200
+ * @param {string} url
201
+ * @param {import("./integration").BuildContext} context
202
+ * @returns {Promise<import("./lib/checks").Asset | undefined>} Undefined for a file on another site.
203
+ */
204
+ async function readAsset(url, context) {
205
+ const file = outputFile(url, context)
206
+ if (!file) return undefined
207
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) return { url, ok: false }
208
+
209
+ /** @type {import("./lib/checks").Asset} */
210
+ const asset = {
211
+ url,
212
+ ok: true,
213
+ type: MIME_TYPES[path.extname(file).toLowerCase()],
214
+ bytes: fs.statSync(file).size,
215
+ }
216
+ if (context.sharp && asset.type !== "image/x-icon") {
217
+ try {
218
+ const { width, height } = await context.sharp(file).metadata()
219
+ asset.width = width
220
+ asset.height = height
221
+ } catch {
222
+ // sharp can not read the file: the checks skip the image size
223
+ }
224
+ }
225
+ return asset
226
+ }
227
+
228
+ /**
229
+ * Reads the `og:image` and the favicons of a page from the output directory.
230
+ *
231
+ * @param {import("./lib/checks").HeadData} head
232
+ * @param {import("./integration").BuildContext} context
233
+ * @param {Map<string, Promise<import("./lib/checks").Asset | undefined>>} cache - The files that other pages already read.
234
+ * @returns {Promise<import("./lib/checks").PageAssets>}
235
+ */
236
+ async function readAssets(head, context, cache) {
237
+ const read = (url) => {
238
+ if (!cache.has(url)) cache.set(url, readAsset(url, context))
239
+ return cache.get(url)
240
+ }
241
+
242
+ const image = head.meta["og:image"] ? await read(head.meta["og:image"]) : undefined
243
+ const links = head.links.filter((link) => {
244
+ return /(^|\s)(icon|apple-touch-icon)(\s|$)/i.test(link.rel ?? "") && link.href
245
+ })
246
+ const icons = await Promise.all(
247
+ links.map((link) => {
248
+ // The query, e.g. "?v=3f2a1c9e", is not part of the file name
249
+ return read(link.href.split("?")[0])
250
+ })
251
+ )
252
+ return {
253
+ image,
254
+ icons: icons.filter((icon) => {
255
+ return icon !== undefined
256
+ }),
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Returns the URL path of a built HTML file: "/blog/index.html" becomes "/blog/",
262
+ * and "/about.html" becomes "/about".
263
+ *
264
+ * @param {string} file - The path of the file in the output directory.
265
+ * @returns {string}
266
+ */
267
+ function pagePath(file) {
268
+ if (file.endsWith("/index.html")) return file.slice(0, -"index.html".length)
269
+ return file.replace(/\.html$/, "")
270
+ }
271
+
272
+ /**
273
+ * Checks the metadata of the built pages and logs the errors and the warnings:
274
+ * the head of each page, its files, and the pages of the site together.
275
+ *
276
+ * @param {import("./integration").BuildContext} context
277
+ * @param {import("astro").AstroIntegrationLogger} logger - The integration logger.
278
+ */
279
+ async function checkPages(context, logger) {
280
+ const { checkAssets, checkHead, checkSite, isIgnored, parseHead, sortChecks } =
281
+ await import("./lib/checks.js")
282
+ const options = context.checks
283
+
284
+ const pages = []
285
+ for (const file of findHtmlFiles(context.dir)) {
286
+ const html = fs.readFileSync(file, "utf-8")
287
+ // Astro writes redirects as HTML pages without metadata
288
+ if (/<meta[^>]+http-equiv=["']?refresh/i.test(html)) continue
289
+
290
+ const page = `/${path.relative(context.dir, file).split(path.sep).join("/")}`
291
+ if (isIgnored(pagePath(page), context.pages)) continue
292
+
293
+ pages.push({ path: page, head: parseHead(html) })
294
+ }
295
+
296
+ const siteChecks = checkSite(pages, options)
297
+ const cache = new Map()
298
+ let count = 0
299
+
300
+ for (const page of pages) {
301
+ const assets = await readAssets(page.head, context, cache)
302
+ const checks = sortChecks([
303
+ ...checkHead(page.head, options),
304
+ ...checkAssets(page.head, assets, options),
305
+ ...(siteChecks.get(page.path) ?? []),
306
+ ])
307
+ const problems = checks.filter((check) => {
308
+ return check.level !== "info"
309
+ })
310
+ if (problems.length === 0) continue
311
+
312
+ count++
313
+ const lines = problems.map((check) => {
314
+ return ` - ${check.message}`
315
+ })
316
+ logger.warn(`${page.path}\n${lines.join("\n")}`)
317
+ }
318
+
319
+ if (count > 0) {
320
+ const label = count === 1 ? "1 page has" : `${count} pages have`
321
+ logger.warn(
322
+ `${label} metadata problems. Turn off a rule with \`rules.ignore\`, or all build checks with \`debug: { build: false }\`.`
323
+ )
324
+ }
325
+ }
326
+
327
+ // The path of the check endpoint of the dev server
328
+ const CHECK_PATH = "/__astro-metadata/check"
329
+
330
+ /**
331
+ * Reads the body of a request as text.
332
+ *
333
+ * @param {import("node:http").IncomingMessage} request
334
+ * @returns {Promise<string>}
335
+ */
336
+ function readBody(request) {
337
+ return new Promise((resolve, reject) => {
338
+ let body = ""
339
+ request.setEncoding("utf-8")
340
+ request.on("data", (chunk) => {
341
+ body += chunk
342
+ })
343
+ request.on("end", () => {
344
+ resolve(body)
345
+ })
346
+ request.on("error", reject)
347
+ })
348
+ }
349
+
350
+ /**
351
+ * Checks a page for the dev toolbar app: its head, and the files that the app loaded.
352
+ *
353
+ * @param {{ html: string, path: string, assets: import("./lib/checks").PageAssets }} page
354
+ * @param {import("./integration").BuildContext} context
355
+ * @returns {Promise<{ ignored: boolean, checks: import("./lib/checks").Check[] }>}
356
+ */
357
+ async function checkDevPage(page, context) {
358
+ const { checkAssets, checkHead, isIgnored, parseHead, sortChecks } =
359
+ await import("./lib/checks.js")
360
+ if (isIgnored(page.path, context.pages)) return { ignored: true, checks: [] }
361
+
362
+ const head = parseHead(page.html)
363
+ const checks = sortChecks([
364
+ ...checkHead(head, context.checks),
365
+ ...checkAssets(head, page.assets, context.checks),
366
+ ])
367
+ return { ignored: false, checks }
368
+ }
369
+
370
+ /**
371
+ * The Astro integration of `@mannisto/astro-metadata`. It gives the site values to the
372
+ * `<Metadata>` component, generates the favicons and the web app manifest, and checks
373
+ * the metadata of the built pages.
374
+ *
375
+ * @param {import("./integration").MetadataOptions} [options]
376
+ * @returns {import("astro").AstroIntegration}
377
+ */
378
+ export default function metadata(options = {}) {
379
+ const debug = debugOptions(options.debug)
380
+
381
+ /** @type {import("./integration").BuildContext} */
382
+ const context = {
383
+ dir: "",
384
+ base: "/",
385
+ pages: options.pages?.ignore,
386
+ checks: { ignore: options.rules?.ignore, custom: options.rules?.custom },
387
+ }
388
+
389
+ return {
390
+ name: NAME,
391
+ hooks: {
392
+ "astro:config:setup": async ({
393
+ config,
394
+ command,
395
+ updateConfig,
396
+ injectRoute,
397
+ addDevToolbarApp,
398
+ logger,
399
+ }) => {
400
+ const { validateCustomRules } = await import("./lib/checks.js")
401
+ try {
402
+ validateCustomRules(options.rules?.custom)
403
+ } catch (error) {
404
+ throw new Error(`${NAME} ${error instanceof Error ? error.message : error}`, {
405
+ cause: error,
406
+ })
407
+ }
408
+
409
+ // The dev toolbar app runs only in dev, and the `debug` option can turn it off
410
+ const toolbar = command === "dev" && debug.client
411
+ context.root = config.root
412
+ context.site = config.site
413
+ context.base = config.base
414
+
415
+ const favicon = options.favicon
416
+ ? await resolveFavicon(config.root, config.publicDir, options.favicon, logger)
417
+ : undefined
418
+
419
+ /** @type {import("./lib/config").RuntimeConfig} */
420
+ const runtime = {
421
+ favicon,
422
+ site: config.site,
423
+ base: config.base,
424
+ trailingSlash: config.trailingSlash,
425
+ buildFormat: config.build.format,
426
+ }
427
+ for (const key of SITE_KEYS) {
428
+ if (options[key] !== undefined) runtime[key] = options[key]
429
+ }
430
+
431
+ // The config is plain data, so it goes to the components as a build-time constant
432
+ updateConfig({
433
+ vite: {
434
+ define: { __ASTRO_METADATA__: JSON.stringify(runtime) },
435
+ // The dev toolbar app uses Lit. Without this list, Vite finds Lit only when the
436
+ // app loads, and the page reloads one time.
437
+ optimizeDeps: toolbar
438
+ ? {
439
+ include: LIT_MODULES.map((module) => {
440
+ return `${NAME} > ${module}`
441
+ }),
442
+ }
443
+ : undefined,
444
+ },
445
+ })
446
+
447
+ // A manifest object: one manifest file for each locale of its texts
448
+ if (typeof options.manifest === "object") {
449
+ injectRoute({
450
+ pattern: "/[...lang]/manifest.webmanifest",
451
+ entrypoint: new URL("./routes/manifest.ts", import.meta.url),
452
+ prerender: true,
453
+ })
454
+ }
455
+
456
+ // One route for each favicon file, all with the same endpoint
457
+ if (favicon) {
458
+ for (const file of favicon.files) {
459
+ injectRoute({
460
+ pattern: file.path,
461
+ entrypoint: new URL("./routes/favicon.ts", import.meta.url),
462
+ prerender: true,
463
+ })
464
+ }
465
+ }
466
+
467
+ if (toolbar) {
468
+ addDevToolbarApp({
469
+ id: "mannisto-astro-metadata",
470
+ name: "Metadata",
471
+ icon: fs.readFileSync(
472
+ new URL("./assets/icons/binoculars.svg", import.meta.url),
473
+ "utf-8"
474
+ ),
475
+ entrypoint: new URL("./toolbar/app.ts", import.meta.url),
476
+ })
477
+ }
478
+ },
479
+ // The dev toolbar app sends the page to this endpoint, and the server runs the
480
+ // checks. The custom rules are functions in the Astro config, so only the server has them.
481
+ "astro:server:setup": ({ server }) => {
482
+ if (!debug.client) return
483
+
484
+ server.middlewares.use(async (request, response, next) => {
485
+ const url = request.url?.split("?")[0] ?? ""
486
+ if (request.method !== "POST" || !url.endsWith(CHECK_PATH)) return next()
487
+
488
+ try {
489
+ const body = JSON.parse(await readBody(request))
490
+ const result = await checkDevPage(body, context)
491
+ response.setHeader("Content-Type", "application/json")
492
+ response.end(JSON.stringify(result))
493
+ } catch (error) {
494
+ response.statusCode = 500
495
+ response.end(JSON.stringify({ error: String(error) }))
496
+ }
497
+ })
498
+ },
499
+ "astro:build:done": async ({ dir, logger }) => {
500
+ if (!debug.build) return
501
+
502
+ context.dir = fileURLToPath(dir)
503
+ context.sharp = loadSharp(context.root)
504
+ await checkPages(context, logger)
505
+ },
506
+ },
507
+ }
508
+ }
@@ -0,0 +1,70 @@
1
+ import type { HeadData } from "./head"
2
+ import type { PageAssets, SitePage } from "./rules"
3
+
4
+ export type { HeadData } from "./head"
5
+ export { findLink, parseHead } from "./head"
6
+ export type { Asset, PageAssets, SitePage } from "./rules"
7
+ export { LIMITS } from "./rules"
8
+
9
+ /** A problem in the metadata of a page. */
10
+ export type Check = {
11
+ /** The id of the rule, e.g. "og-title-missing". */
12
+ id: string
13
+ /** The tag of the problem, e.g. "og:title". */
14
+ field: string
15
+ level: "error" | "warning" | "info"
16
+ message: string
17
+ }
18
+
19
+ /**
20
+ * A rule of your own. It runs in the build and in the dev toolbar app, after the
21
+ * rules of the package.
22
+ *
23
+ * @example
24
+ * {
25
+ * id: "title-brand",
26
+ * level: "warning",
27
+ * message: "The title must contain the brand name.",
28
+ * check(head) {
29
+ * return !head.titles[0]?.includes("Acme Studio")
30
+ * },
31
+ * }
32
+ */
33
+ export type CustomRule = {
34
+ /** A short, unique name. `rules.ignore` turns the rule off with this id. */
35
+ id: string
36
+ level: "error" | "warning" | "info"
37
+ /** The message of the problem. Text between backticks shows as code in the dev toolbar app. */
38
+ message: string | ((head: HeadData) => string)
39
+ /** Returns true when the page has the problem. */
40
+ check(head: HeadData): boolean | undefined
41
+ /** The tag of the problem, e.g. "og:title". The dev toolbar app opens its row. */
42
+ field?: string
43
+ /** True for a search or sharing rule: it does not run on a noindex page. */
44
+ indexed?: boolean
45
+ }
46
+
47
+ export type CheckOptions = {
48
+ /** The ids of the rules to turn off, e.g. ["description-short"]. */
49
+ ignore?: string[]
50
+ /** Your own rules. */
51
+ custom?: CustomRule[]
52
+ }
53
+
54
+ export declare function validateCustomRules(custom: CustomRule[] | undefined): void
55
+
56
+ export declare function isIgnored(path: string, patterns: string[] | undefined): boolean
57
+
58
+ export declare function sortChecks(checks: Check[]): Check[]
59
+
60
+ export declare function isNoindex(head: HeadData): boolean
61
+
62
+ export declare function checkHead(head: HeadData, options?: CheckOptions): Check[]
63
+
64
+ export declare function checkAssets(
65
+ head: HeadData,
66
+ assets: PageAssets,
67
+ options?: CheckOptions
68
+ ): Check[]
69
+
70
+ export declare function checkSite(pages: SitePage[], options?: CheckOptions): Map<string, Check[]>