@mannisto/astro-metadata 1.0.1 → 2.0.0-alpha.1
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 +427 -82
- package/package.json +35 -22
- package/src/assets/icons/LICENSE.md +35 -0
- package/src/assets/icons/binoculars.svg +1 -0
- package/src/assets/icons/caret-right.svg +1 -0
- package/src/assets/icons/check.svg +1 -0
- package/src/assets/icons/copy.svg +1 -0
- package/src/assets/icons/seal-check-duotone.svg +1 -0
- package/src/assets/icons/seal-question-duotone.svg +1 -0
- package/src/assets/icons/warning-circle-duotone.svg +1 -0
- package/src/assets/icons/warning-duotone.svg +1 -0
- package/src/assets/icons/x.svg +1 -0
- package/src/components/Metadata.astro +145 -0
- package/src/components/index.ts +2 -0
- package/src/integration.d.ts +112 -0
- package/src/integration.js +508 -0
- package/src/lib/checks.d.ts +70 -0
- package/src/lib/checks.js +193 -0
- package/src/lib/config.ts +111 -0
- package/src/lib/favicon-files.d.ts +29 -0
- package/src/lib/favicon-files.js +103 -0
- package/src/lib/favicon-image.ts +124 -0
- package/src/lib/favicon.ts +167 -0
- package/src/lib/head.d.ts +25 -0
- package/src/lib/head.js +141 -0
- package/src/lib/manifest.ts +126 -0
- package/src/lib/rules.d.ts +73 -0
- package/src/lib/rules.js +902 -0
- package/src/lib/schema.ts +208 -0
- package/src/lib/site.ts +72 -0
- package/src/lib/url.ts +79 -0
- package/src/routes/favicon.ts +14 -0
- package/src/routes/manifest.ts +18 -0
- package/src/runtime.ts +16 -0
- package/src/toolbar/app.ts +72 -0
- package/src/toolbar/assets.ts +81 -0
- package/src/toolbar/data.ts +282 -0
- package/src/toolbar/define.ts +7 -0
- package/src/toolbar/env.d.ts +10 -0
- package/src/toolbar/highlight.ts +83 -0
- package/src/toolbar/styles/shared.css +18 -0
- package/src/toolbar/styles/tokens.css +182 -0
- package/src/toolbar/toolbar-badge.ts +78 -0
- package/src/toolbar/ui/alert/alert.css +126 -0
- package/src/toolbar/ui/alert/alert.ts +102 -0
- package/src/toolbar/ui/badge/badge.css +41 -0
- package/src/toolbar/ui/badge/badge.ts +24 -0
- package/src/toolbar/ui/code-block/code-block.css +90 -0
- package/src/toolbar/ui/code-block/code-block.ts +68 -0
- package/src/toolbar/ui/color-swatch/color-swatch.css +15 -0
- package/src/toolbar/ui/color-swatch/color-swatch.ts +31 -0
- package/src/toolbar/ui/icon/icon.css +11 -0
- package/src/toolbar/ui/icon/icon.ts +46 -0
- package/src/toolbar/ui/image/image.css +18 -0
- package/src/toolbar/ui/image/image.ts +58 -0
- package/src/toolbar/ui/inline-code/inline-code.css +16 -0
- package/src/toolbar/ui/inline-code/inline-code.ts +41 -0
- package/src/toolbar/ui/section/section.css +88 -0
- package/src/toolbar/ui/section/section.ts +78 -0
- package/src/toolbar/ui/tab/tab.css +7 -0
- package/src/toolbar/ui/tab/tab.ts +47 -0
- package/src/toolbar/ui/tab-group/tab-group.css +42 -0
- package/src/toolbar/ui/tab-group/tab-group.ts +101 -0
- package/src/toolbar/ui/table/table.css +124 -0
- package/src/toolbar/ui/table/table.ts +155 -0
- package/src/toolbar/views/code-blocks.ts +35 -0
- package/src/toolbar/views/metadata-panel.css +101 -0
- package/src/toolbar/views/metadata-panel.ts +125 -0
- package/src/toolbar/views/overview-view.css +27 -0
- package/src/toolbar/views/overview-view.ts +401 -0
- package/src/toolbar/views/previews/discord.css +70 -0
- package/src/toolbar/views/previews/discord.ts +60 -0
- package/src/toolbar/views/previews/facebook.css +65 -0
- package/src/toolbar/views/previews/facebook.ts +36 -0
- package/src/toolbar/views/previews/google.css +75 -0
- package/src/toolbar/views/previews/google.ts +36 -0
- package/src/toolbar/views/previews/linkedin.css +58 -0
- package/src/toolbar/views/previews/linkedin.ts +34 -0
- package/src/toolbar/views/previews/page.ts +129 -0
- package/src/toolbar/views/previews/platform.ts +14 -0
- package/src/toolbar/views/previews/previews-view.css +37 -0
- package/src/toolbar/views/previews/previews-view.ts +52 -0
- package/src/toolbar/views/previews/slack.css +61 -0
- package/src/toolbar/views/previews/slack.ts +47 -0
- package/src/toolbar/views/previews/twitter.css +89 -0
- package/src/toolbar/views/previews/twitter.ts +57 -0
- package/src/toolbar/views/previews/whatsapp.css +88 -0
- package/src/toolbar/views/previews/whatsapp.ts +65 -0
- package/src/toolbar/views/raw-view.ts +41 -0
- package/index.ts +0 -31
- package/src/components/Canonical.astro +0 -11
- package/src/components/Description.astro +0 -9
- package/src/components/Favicon.astro +0 -88
- package/src/components/Head.astro +0 -96
- package/src/components/Keywords.astro +0 -9
- package/src/components/LanguageAlternates.astro +0 -18
- package/src/components/OpenGraph.astro +0 -75
- package/src/components/Robots.astro +0 -25
- package/src/components/Schema.astro +0 -9
- package/src/components/Title.astro +0 -10
- package/src/components/Twitter.astro +0 -25
- 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[]>
|