@mannisto/astro-metadata 1.0.2 → 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.
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 +902 -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 +101 -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,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
+ }