@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,193 @@
|
|
|
1
|
+
// Plain JavaScript: the integration runs in Node, which does not strip types in node_modules.
|
|
2
|
+
// The dev toolbar app uses the same functions, so the build and the toolbar give the same results.
|
|
3
|
+
|
|
4
|
+
import { ASSET_RULES, RULES, SITE_RULES } from "./rules.js"
|
|
5
|
+
|
|
6
|
+
export { findLink, parseHead } from "./head.js"
|
|
7
|
+
export { LIMITS } from "./rules.js"
|
|
8
|
+
|
|
9
|
+
const ORDER = ["error", "warning", "info"]
|
|
10
|
+
|
|
11
|
+
const LEVELS = ["error", "warning", "info"]
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Returns true for a rule that the `rules.ignore` option does not turn off.
|
|
15
|
+
*
|
|
16
|
+
* @param {{ id: string }} rule
|
|
17
|
+
* @param {import("./checks").CheckOptions} options
|
|
18
|
+
* @returns {boolean}
|
|
19
|
+
*/
|
|
20
|
+
function isOn(rule, options) {
|
|
21
|
+
return !options.ignore?.includes(rule.id)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Checks the custom rules of the `rules.custom` option.
|
|
26
|
+
*
|
|
27
|
+
* @param {import("./checks").CustomRule[] | undefined} custom
|
|
28
|
+
* @throws {Error} For a rule without an id, a message, a valid level or a check function,
|
|
29
|
+
* and for an id that another rule already has.
|
|
30
|
+
*/
|
|
31
|
+
export function validateCustomRules(custom) {
|
|
32
|
+
const ids = new Set(
|
|
33
|
+
[...RULES, ...ASSET_RULES, ...SITE_RULES].map((rule) => {
|
|
34
|
+
return rule.id
|
|
35
|
+
})
|
|
36
|
+
)
|
|
37
|
+
for (const rule of custom ?? []) {
|
|
38
|
+
const name = rule?.id ? `The custom rule "${rule.id}"` : "A custom rule"
|
|
39
|
+
if (!rule?.id || typeof rule.id !== "string") throw new Error(`${name} has no \`id\`.`)
|
|
40
|
+
if (ids.has(rule.id)) throw new Error(`${name} has the id of another rule.`)
|
|
41
|
+
if (!LEVELS.includes(rule.level)) {
|
|
42
|
+
throw new Error(`${name} needs a \`level\`: "error", "warning" or "info".`)
|
|
43
|
+
}
|
|
44
|
+
if (typeof rule.message !== "string" && typeof rule.message !== "function") {
|
|
45
|
+
throw new Error(`${name} has no \`message\`.`)
|
|
46
|
+
}
|
|
47
|
+
if (typeof rule.check !== "function") throw new Error(`${name} has no \`check\` function.`)
|
|
48
|
+
ids.add(rule.id)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Changes a custom rule into a rule of the same form as the rules in `rules.js`.
|
|
54
|
+
*
|
|
55
|
+
* @param {import("./checks").CustomRule} rule
|
|
56
|
+
* @returns {import("./rules").Rule}
|
|
57
|
+
*/
|
|
58
|
+
function toRule(rule) {
|
|
59
|
+
return {
|
|
60
|
+
id: rule.id,
|
|
61
|
+
field: rule.field ?? "custom",
|
|
62
|
+
level: rule.level,
|
|
63
|
+
indexed: rule.indexed,
|
|
64
|
+
check(head) {
|
|
65
|
+
try {
|
|
66
|
+
if (!rule.check(head)) return undefined
|
|
67
|
+
return typeof rule.message === "function" ? rule.message(head) : rule.message
|
|
68
|
+
} catch (error) {
|
|
69
|
+
// An error in a custom rule shows as its problem, and the other rules still run
|
|
70
|
+
return `The custom rule \`${rule.id}\` failed: ${error instanceof Error ? error.message : error}`
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Returns the checks with the errors first, then the warnings, then the information.
|
|
78
|
+
*
|
|
79
|
+
* @param {import("./checks").Check[]} checks
|
|
80
|
+
* @returns {import("./checks").Check[]}
|
|
81
|
+
*/
|
|
82
|
+
export function sortChecks(checks) {
|
|
83
|
+
return checks.sort((a, b) => {
|
|
84
|
+
return ORDER.indexOf(a.level) - ORDER.indexOf(b.level)
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Returns true for a page with `noindex`. Such a page gets no search and sharing checks.
|
|
90
|
+
*
|
|
91
|
+
* @param {import("./head").HeadData} head
|
|
92
|
+
* @returns {boolean}
|
|
93
|
+
*/
|
|
94
|
+
export function isNoindex(head) {
|
|
95
|
+
return /noindex/i.test(head.meta.robots ?? "")
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Returns a path without the slash at the end, e.g. "/blog" for "/blog/". The root stays "/".
|
|
100
|
+
*
|
|
101
|
+
* @param {string} path
|
|
102
|
+
* @returns {string}
|
|
103
|
+
*/
|
|
104
|
+
function trimSlash(path) {
|
|
105
|
+
return path.length > 1 ? path.replace(/\/+$/, "") : path
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Returns true for a page path that matches a pattern of the `ignore` option.
|
|
110
|
+
* In a pattern, `*` matches one part of the path and `**` matches any number of parts.
|
|
111
|
+
*
|
|
112
|
+
* @example isIgnored("/drafts/hello/", ["/drafts/**"]) // true
|
|
113
|
+
*
|
|
114
|
+
* @param {string} path - The path of the page without the base, e.g. "/drafts/hello/".
|
|
115
|
+
* @param {string[] | undefined} patterns - e.g. ["/404", "/drafts/**"].
|
|
116
|
+
* @returns {boolean}
|
|
117
|
+
*/
|
|
118
|
+
export function isIgnored(path, patterns) {
|
|
119
|
+
return (patterns ?? []).some((pattern) => {
|
|
120
|
+
const source = trimSlash(pattern)
|
|
121
|
+
.split(/(\*\*|\*)/)
|
|
122
|
+
.map((part) => {
|
|
123
|
+
if (part === "**") return ".*"
|
|
124
|
+
if (part === "*") return "[^/]*"
|
|
125
|
+
return part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")
|
|
126
|
+
})
|
|
127
|
+
.join("")
|
|
128
|
+
return new RegExp(`^${source}$`).test(trimSlash(path))
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Checks the head of a page with the rules in `rules.js`.
|
|
134
|
+
*
|
|
135
|
+
* @param {import("./head").HeadData} head - The metadata from `parseHead`.
|
|
136
|
+
* @param {import("./checks").CheckOptions} [options]
|
|
137
|
+
* @returns {import("./checks").Check[]} The problems, errors first.
|
|
138
|
+
*/
|
|
139
|
+
export function checkHead(head, options = {}) {
|
|
140
|
+
const noindex = isNoindex(head)
|
|
141
|
+
|
|
142
|
+
/** @type {import("./checks").Check[]} */
|
|
143
|
+
const checks = []
|
|
144
|
+
const custom = (options.custom ?? []).map(toRule)
|
|
145
|
+
for (const rule of [...RULES, ...custom]) {
|
|
146
|
+
if ((noindex && rule.indexed) || !isOn(rule, options)) continue
|
|
147
|
+
|
|
148
|
+
const message = rule.check(head)
|
|
149
|
+
if (message) checks.push({ id: rule.id, field: rule.field, level: rule.level, message })
|
|
150
|
+
}
|
|
151
|
+
return sortChecks(checks)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Checks the loaded files of a page: the `og:image` and the favicons.
|
|
156
|
+
*
|
|
157
|
+
* @param {import("./head").HeadData} head
|
|
158
|
+
* @param {import("./rules").PageAssets} assets
|
|
159
|
+
* @param {import("./checks").CheckOptions} [options]
|
|
160
|
+
* @returns {import("./checks").Check[]}
|
|
161
|
+
*/
|
|
162
|
+
export function checkAssets(head, assets, options = {}) {
|
|
163
|
+
/** @type {import("./checks").Check[]} */
|
|
164
|
+
const checks = []
|
|
165
|
+
for (const rule of ASSET_RULES) {
|
|
166
|
+
if (!isOn(rule, options)) continue
|
|
167
|
+
|
|
168
|
+
const message = rule.check(head, assets)
|
|
169
|
+
if (message) checks.push({ id: rule.id, field: rule.field, level: rule.level, message })
|
|
170
|
+
}
|
|
171
|
+
return sortChecks(checks)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Compares the pages of the site, e.g. two pages with the same title.
|
|
176
|
+
*
|
|
177
|
+
* @param {import("./rules").SitePage[]} pages
|
|
178
|
+
* @param {import("./checks").CheckOptions} [options]
|
|
179
|
+
* @returns {Map<string, import("./checks").Check[]>} The problems of each page path.
|
|
180
|
+
*/
|
|
181
|
+
export function checkSite(pages, options = {}) {
|
|
182
|
+
/** @type {Map<string, import("./checks").Check[]>} */
|
|
183
|
+
const result = new Map()
|
|
184
|
+
for (const rule of SITE_RULES) {
|
|
185
|
+
if (!isOn(rule, options)) continue
|
|
186
|
+
|
|
187
|
+
for (const problem of rule.check(pages)) {
|
|
188
|
+
const check = { id: rule.id, field: rule.field, level: rule.level, message: problem.message }
|
|
189
|
+
result.set(problem.path, [...(result.get(problem.path) ?? []), check])
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return result
|
|
193
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { FaviconInfo } from "./favicon.ts"
|
|
2
|
+
import type { ManifestConfig } from "./manifest.ts"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A text value: one string for all locales, or one string for each locale.
|
|
6
|
+
*
|
|
7
|
+
* @example "Acme Studio"
|
|
8
|
+
* @example { en: "Acme Studio", fi: "Acme Studio Suomi" }
|
|
9
|
+
*/
|
|
10
|
+
export type Localized = string | Record<string, string>
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* An image for the social cards. Use an image of 1200 × 630 px.
|
|
14
|
+
*/
|
|
15
|
+
export type MetadataImage = {
|
|
16
|
+
/** The path or URL of the image, e.g. "/og.jpg". */
|
|
17
|
+
url: string
|
|
18
|
+
/** A description of the image. */
|
|
19
|
+
alt?: string
|
|
20
|
+
width?: number
|
|
21
|
+
height?: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The color of the browser interface: one color, or one color for each color mode.
|
|
26
|
+
*/
|
|
27
|
+
export type ThemeColor = string | { light: string; dark: string }
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A feed of the site: RSS, Atom or JSON.
|
|
31
|
+
*/
|
|
32
|
+
export type Feed = {
|
|
33
|
+
/** The path of the feed, e.g. "/rss.xml". */
|
|
34
|
+
href: string
|
|
35
|
+
title?: Localized
|
|
36
|
+
/** Defaults to "rss". */
|
|
37
|
+
type?: "rss" | "atom" | "json"
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The site values in the `metadata()` integration. The props of `<Metadata>` override them.
|
|
42
|
+
*/
|
|
43
|
+
export type MetadataConfig = {
|
|
44
|
+
/** The name of the site: the title of a page without a title, and `og:site_name`. */
|
|
45
|
+
siteName?: Localized
|
|
46
|
+
/** The title template, e.g. "%s | My Site". */
|
|
47
|
+
titleTemplate?: Localized
|
|
48
|
+
/** The description of a page without a description. */
|
|
49
|
+
description?: Localized
|
|
50
|
+
/** The social image of a page without an image. */
|
|
51
|
+
image?: Omit<MetadataImage, "alt"> & { alt?: Localized }
|
|
52
|
+
twitter?: {
|
|
53
|
+
/** The card type for pages with an image. Defaults to "summary_large_image". */
|
|
54
|
+
card?: "summary" | "summary_large_image"
|
|
55
|
+
/** The Twitter (X) handle of the site, e.g. "@mysite". */
|
|
56
|
+
site?: string
|
|
57
|
+
}
|
|
58
|
+
/** The color of the browser interface, e.g. the address bar on Android. */
|
|
59
|
+
themeColor?: ThemeColor
|
|
60
|
+
/** The feeds of the site. */
|
|
61
|
+
feeds?: Feed[]
|
|
62
|
+
robots?: {
|
|
63
|
+
/** Set to false to keep all pages out of search results, e.g. on a staging site. */
|
|
64
|
+
index?: boolean
|
|
65
|
+
/** Set to false to tell search engines not to follow the links. */
|
|
66
|
+
follow?: boolean
|
|
67
|
+
/** Other directives for all pages, e.g. "max-image-preview:large". */
|
|
68
|
+
extra?: string
|
|
69
|
+
}
|
|
70
|
+
/** The web app manifest: an object to generate it, or the path of your own manifest. */
|
|
71
|
+
manifest?: ManifestConfig | string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The data that the integration gives to the components at build time.
|
|
76
|
+
*/
|
|
77
|
+
export type RuntimeConfig = MetadataConfig & {
|
|
78
|
+
/** The generated favicon files. */
|
|
79
|
+
favicon?: FaviconInfo
|
|
80
|
+
/** The `trailingSlash` setting of the Astro config. */
|
|
81
|
+
trailingSlash?: "always" | "never" | "ignore"
|
|
82
|
+
/** The `build.format` setting of the Astro config. */
|
|
83
|
+
buildFormat?: "directory" | "file" | "preserve"
|
|
84
|
+
/** The `base` of the Astro config. */
|
|
85
|
+
base?: string
|
|
86
|
+
/** The `site` of the Astro config. The dev toolbar app loads its files from the dev server. */
|
|
87
|
+
site?: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The integration replaces this name with the config, with Vite's `define`
|
|
91
|
+
declare const __ASTRO_METADATA__: RuntimeConfig | undefined
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Returns the config of the integration, or an empty config without the integration.
|
|
95
|
+
*/
|
|
96
|
+
export function getConfig(): RuntimeConfig {
|
|
97
|
+
return typeof __ASTRO_METADATA__ === "undefined" ? {} : __ASTRO_METADATA__
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Returns the value of a text for a locale. A missing locale uses the first value.
|
|
102
|
+
*
|
|
103
|
+
* @param value - A string or an object with one string for each locale.
|
|
104
|
+
* @param locale - The current locale, e.g. "fi".
|
|
105
|
+
* @returns The text, or undefined without a value.
|
|
106
|
+
*/
|
|
107
|
+
export function localize(value: Localized | undefined, locale?: string): string | undefined {
|
|
108
|
+
if (value === undefined || typeof value === "string") return value
|
|
109
|
+
if (locale && Object.hasOwn(value, locale)) return value[locale]
|
|
110
|
+
return Object.values(value)[0]
|
|
111
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** A favicon file in the `files` option. The format comes from the extension. */
|
|
2
|
+
export type FaviconFile = {
|
|
3
|
+
/** The URL of the file, e.g. "/favicon-96.png". Must end in .ico, .png or .svg. */
|
|
4
|
+
path: string
|
|
5
|
+
/** The size in px. Required for a PNG file. */
|
|
6
|
+
size?: number
|
|
7
|
+
/** The sizes of the images in an ICO file. Defaults to [16, 32]. */
|
|
8
|
+
sizes?: number[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** A favicon file after the checks, with its format and its use. */
|
|
12
|
+
export type ResolvedFaviconFile = {
|
|
13
|
+
path: string
|
|
14
|
+
type: "ico" | "png" | "svg"
|
|
15
|
+
/**
|
|
16
|
+
* "icon": an icon link tag. "apple": the Apple link tag.
|
|
17
|
+
* "manifest": the web app manifest (PNG files of 192 px or larger).
|
|
18
|
+
*/
|
|
19
|
+
use: "icon" | "apple" | "manifest"
|
|
20
|
+
/** The sizes in px, smallest first. Empty for an SVG file. */
|
|
21
|
+
sizes: number[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export declare function defaultFaviconFiles(svg: boolean): FaviconFile[]
|
|
25
|
+
|
|
26
|
+
export declare function resolveFaviconFiles(
|
|
27
|
+
files: FaviconFile[],
|
|
28
|
+
svg: boolean
|
|
29
|
+
): ResolvedFaviconFile[]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Plain JavaScript: the integration runs in Node, which does not strip types in node_modules.
|
|
2
|
+
|
|
3
|
+
// A PNG file of this size or larger goes into the web app manifest. Smaller PNG files get an
|
|
4
|
+
// icon link tag. 192 px is the smallest icon that browsers use to install a web app.
|
|
5
|
+
const MANIFEST_SIZE = 192
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The default files. An SVG source also gives `favicon.svg`.
|
|
9
|
+
*
|
|
10
|
+
* @param {boolean} svg - True for an SVG source.
|
|
11
|
+
* @returns {import("./favicon-files").FaviconFile[]}
|
|
12
|
+
*/
|
|
13
|
+
export function defaultFaviconFiles(svg) {
|
|
14
|
+
return [
|
|
15
|
+
{ path: "/favicon.ico", sizes: [16, 32] },
|
|
16
|
+
...(svg ? [{ path: "/favicon.svg" }] : []),
|
|
17
|
+
{ path: "/favicon-16.png", size: 16 },
|
|
18
|
+
{ path: "/favicon-32.png", size: 32 },
|
|
19
|
+
{ path: "/apple-touch-icon.png", size: 180 },
|
|
20
|
+
{ path: "/favicon-192.png", size: 192 },
|
|
21
|
+
{ path: "/favicon-512.png", size: 512 },
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Returns true for a whole number from 1 to a maximum.
|
|
27
|
+
*
|
|
28
|
+
* @param {unknown} value
|
|
29
|
+
* @param {number} max
|
|
30
|
+
* @returns {boolean}
|
|
31
|
+
*/
|
|
32
|
+
function isSize(value, max) {
|
|
33
|
+
return Number.isInteger(value) && Number(value) >= 1 && Number(value) <= max
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Checks the favicon files and finds the use of each file from its name, format and size:
|
|
38
|
+
* - `.ico` and `.svg` files get an icon link tag.
|
|
39
|
+
* - `apple-touch-icon.png` gets the Apple link tag and the background color.
|
|
40
|
+
* - Other `.png` files smaller than 192 px get an icon link tag.
|
|
41
|
+
* - Other `.png` files of 192 px or larger go into the web app manifest.
|
|
42
|
+
*
|
|
43
|
+
* @param {import("./favicon-files").FaviconFile[]} files - The `files` option, or the defaults.
|
|
44
|
+
* @param {boolean} svg - True for an SVG source.
|
|
45
|
+
* @returns {import("./favicon-files").ResolvedFaviconFile[]}
|
|
46
|
+
* @throws {Error} For a file that the package can not generate.
|
|
47
|
+
*/
|
|
48
|
+
export function resolveFaviconFiles(files, svg) {
|
|
49
|
+
const paths = new Set()
|
|
50
|
+
|
|
51
|
+
return files.map((file) => {
|
|
52
|
+
const { path } = file
|
|
53
|
+
if (typeof path !== "string" || !path.startsWith("/")) {
|
|
54
|
+
throw new Error(`The favicon path "${path}" must start with "/".`)
|
|
55
|
+
}
|
|
56
|
+
if (paths.has(path)) {
|
|
57
|
+
throw new Error(`The favicon path "${path}" is in the list two times.`)
|
|
58
|
+
}
|
|
59
|
+
paths.add(path)
|
|
60
|
+
|
|
61
|
+
const type = path.split(".").pop()?.toLowerCase()
|
|
62
|
+
const name = path.split("/").pop() ?? ""
|
|
63
|
+
|
|
64
|
+
if (type === "svg") {
|
|
65
|
+
if (!svg) throw new Error(`"${path}" needs an SVG source.`)
|
|
66
|
+
if (file.size !== undefined || file.sizes !== undefined) {
|
|
67
|
+
throw new Error(`"${path}" is an SVG file, so it has no size.`)
|
|
68
|
+
}
|
|
69
|
+
return { path, type, use: "icon", sizes: [] }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (type === "ico") {
|
|
73
|
+
if (file.size !== undefined && file.sizes !== undefined) {
|
|
74
|
+
throw new Error(`"${path}" has "size" and "sizes". Use one.`)
|
|
75
|
+
}
|
|
76
|
+
const sizes = file.sizes ?? (file.size !== undefined ? [file.size] : [16, 32])
|
|
77
|
+
const valid = sizes.every((size) => {
|
|
78
|
+
return isSize(size, 256)
|
|
79
|
+
})
|
|
80
|
+
if (sizes.length === 0 || !valid) {
|
|
81
|
+
throw new Error(`The sizes of "${path}" must be whole numbers from 1 to 256.`)
|
|
82
|
+
}
|
|
83
|
+
const sorted = [...sizes].sort((a, b) => {
|
|
84
|
+
return a - b
|
|
85
|
+
})
|
|
86
|
+
return { path, type, use: "icon", sizes: sorted }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (type === "png") {
|
|
90
|
+
if (file.sizes !== undefined) {
|
|
91
|
+
throw new Error(`"${path}" is a PNG file: use "size", not "sizes".`)
|
|
92
|
+
}
|
|
93
|
+
if (!isSize(file.size, 4096)) {
|
|
94
|
+
throw new Error(`"${path}" needs a "size" from 1 to 4096.`)
|
|
95
|
+
}
|
|
96
|
+
let use = file.size >= MANIFEST_SIZE ? "manifest" : "icon"
|
|
97
|
+
if (name.startsWith("apple-touch-icon")) use = "apple"
|
|
98
|
+
return { path, type, use, sizes: [file.size] }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
throw new Error(`"${path}" must be an .ico, .png or .svg file.`)
|
|
102
|
+
})
|
|
103
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import fs from "node:fs/promises"
|
|
2
|
+
import { createRequire } from "node:module"
|
|
3
|
+
import type Sharp from "sharp"
|
|
4
|
+
|
|
5
|
+
import { getConfig } from "./config.ts"
|
|
6
|
+
import type { FaviconInfo } from "./favicon.ts"
|
|
7
|
+
|
|
8
|
+
// The Apple icon shows the logo on the background color, with space around it.
|
|
9
|
+
// The logo fills 80 % of the icon.
|
|
10
|
+
const APPLE_CONTENT = 0.8
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Loads sharp from the path that the integration found. The build writes this code into
|
|
14
|
+
* the dist folder of the project, and with pnpm the project can not see the dependencies
|
|
15
|
+
* of this package from there.
|
|
16
|
+
*/
|
|
17
|
+
function loadSharp(favicon: FaviconInfo): typeof Sharp {
|
|
18
|
+
return createRequire(import.meta.url)(favicon.sharp)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Renders a square PNG of the source with transparent space around it.
|
|
23
|
+
* An SVG source is rasterized at a density that gives a sharp result at the size.
|
|
24
|
+
*/
|
|
25
|
+
async function renderPng(favicon: FaviconInfo, size: number): Promise<Buffer> {
|
|
26
|
+
const sharp = loadSharp(favicon)
|
|
27
|
+
const source = await fs.readFile(favicon.source)
|
|
28
|
+
const meta = await sharp(source).metadata()
|
|
29
|
+
const smallest = Math.min(meta.width ?? size, meta.height ?? size)
|
|
30
|
+
const density = favicon.svg ? Math.min(2400, Math.ceil((72 * size) / smallest)) : undefined
|
|
31
|
+
|
|
32
|
+
return sharp(source, density ? { density } : {})
|
|
33
|
+
.resize(size, size, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
|
34
|
+
.png()
|
|
35
|
+
.toBuffer()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Renders a square icon with a background color and padding around the logo.
|
|
40
|
+
*/
|
|
41
|
+
async function renderPadded(favicon: FaviconInfo, size: number, content: number): Promise<Buffer> {
|
|
42
|
+
const inner = Math.round(size * content)
|
|
43
|
+
const logo = await renderPng(favicon, inner)
|
|
44
|
+
const offset = Math.floor((size - inner) / 2)
|
|
45
|
+
const sharp = loadSharp(favicon)
|
|
46
|
+
|
|
47
|
+
return sharp({
|
|
48
|
+
create: { width: size, height: size, channels: 4, background: favicon.background },
|
|
49
|
+
})
|
|
50
|
+
.composite([{ input: logo, left: offset, top: offset }])
|
|
51
|
+
.png()
|
|
52
|
+
.toBuffer()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Puts PNG images into an ICO container: a 6-byte header, a 16-byte entry
|
|
57
|
+
* for each image, and then the PNG data of each image.
|
|
58
|
+
*/
|
|
59
|
+
function toIco(images: { size: number; png: Buffer }[]): Buffer {
|
|
60
|
+
const header = Buffer.alloc(6)
|
|
61
|
+
header.writeUInt16LE(0, 0)
|
|
62
|
+
header.writeUInt16LE(1, 2)
|
|
63
|
+
header.writeUInt16LE(images.length, 4)
|
|
64
|
+
|
|
65
|
+
let offset = 6 + 16 * images.length
|
|
66
|
+
const entries = images.map((image) => {
|
|
67
|
+
const entry = Buffer.alloc(16)
|
|
68
|
+
entry.writeUInt8(image.size >= 256 ? 0 : image.size, 0)
|
|
69
|
+
entry.writeUInt8(image.size >= 256 ? 0 : image.size, 1)
|
|
70
|
+
entry.writeUInt8(0, 2)
|
|
71
|
+
entry.writeUInt8(0, 3)
|
|
72
|
+
entry.writeUInt16LE(1, 4)
|
|
73
|
+
entry.writeUInt16LE(32, 6)
|
|
74
|
+
entry.writeUInt32LE(image.png.length, 8)
|
|
75
|
+
entry.writeUInt32LE(offset, 12)
|
|
76
|
+
offset += image.png.length
|
|
77
|
+
return entry
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
return Buffer.concat([
|
|
81
|
+
header,
|
|
82
|
+
...entries,
|
|
83
|
+
...images.map((image) => {
|
|
84
|
+
return image.png
|
|
85
|
+
}),
|
|
86
|
+
])
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Renders a generated favicon file as a response.
|
|
91
|
+
*
|
|
92
|
+
* @param path - The path of the file, e.g. "/apple-touch-icon.png".
|
|
93
|
+
* @returns The response with the file, or a 404 for a file that is not in the favicon config.
|
|
94
|
+
*/
|
|
95
|
+
export async function renderFavicon(path: string): Promise<Response> {
|
|
96
|
+
const favicon = getConfig().favicon
|
|
97
|
+
const file = favicon?.files.find((entry) => {
|
|
98
|
+
return entry.path === path
|
|
99
|
+
})
|
|
100
|
+
if (!favicon || !file) return new Response(null, { status: 404 })
|
|
101
|
+
|
|
102
|
+
if (file.type === "svg") {
|
|
103
|
+
const svg = await fs.readFile(favicon.source)
|
|
104
|
+
return new Response(svg, { headers: { "Content-Type": "image/svg+xml" } })
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (file.type === "ico") {
|
|
108
|
+
const images = await Promise.all(
|
|
109
|
+
file.sizes.map(async (size) => {
|
|
110
|
+
return { size, png: await renderPng(favicon, size) }
|
|
111
|
+
})
|
|
112
|
+
)
|
|
113
|
+
return new Response(new Uint8Array(toIco(images)), {
|
|
114
|
+
headers: { "Content-Type": "image/x-icon" },
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const size = file.sizes[0]
|
|
119
|
+
const png =
|
|
120
|
+
file.use === "apple"
|
|
121
|
+
? await renderPadded(favicon, size, APPLE_CONTENT)
|
|
122
|
+
: await renderPng(favicon, size)
|
|
123
|
+
return new Response(new Uint8Array(png), { headers: { "Content-Type": "image/png" } })
|
|
124
|
+
}
|