@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.
Files changed (102) hide show
  1. package/README.md +427 -82
  2. package/package.json +35 -22
  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 -88
  94. package/src/components/Head.astro +0 -96
  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 -9
  100. package/src/components/Title.astro +0 -10
  101. package/src/components/Twitter.astro +0 -25
  102. package/src/lib/metadata.ts +0 -77
@@ -0,0 +1,208 @@
1
+ import type {
2
+ Article,
3
+ BreadcrumbList,
4
+ Graph,
5
+ Organization,
6
+ Person,
7
+ Thing,
8
+ WebSite,
9
+ WithContext,
10
+ } from "schema-dts"
11
+
12
+ import { getConfig, localize } from "./config.ts"
13
+ import { url } from "./url.ts"
14
+
15
+ /**
16
+ * The data of a JSON-LD script. The schema.org types give the editor completion,
17
+ * and other objects are also accepted.
18
+ */
19
+ export type SchemaData = WithContext<Thing> | Graph | Record<string, unknown>
20
+
21
+ export type SchemaWebsite = {
22
+ /** Defaults to `siteName` in the config. */
23
+ name?: string
24
+ /** Defaults to `description` in the config. */
25
+ description?: string
26
+ /** Defaults to the site root. */
27
+ url?: string
28
+ /** The locale of the localized config values, e.g. `Astro.currentLocale`. */
29
+ locale?: string
30
+ }
31
+
32
+ export type SchemaOrganization = {
33
+ /** Defaults to `siteName` in the config. */
34
+ name?: string
35
+ /** Defaults to the site root. */
36
+ url?: string
37
+ /** The path or URL of the logo, e.g. "/logo.png". */
38
+ logo?: string
39
+ /** The profiles of the organization on other sites. */
40
+ sameAs?: string[]
41
+ /** The locale of the localized config values, e.g. `Astro.currentLocale`. */
42
+ locale?: string
43
+ }
44
+
45
+ export type SchemaAuthor = string | { name: string; url?: string }
46
+
47
+ export type SchemaArticle = {
48
+ /** Defaults to "Article". */
49
+ type?: "Article" | "BlogPosting" | "NewsArticle"
50
+ title: string
51
+ description?: string
52
+ /** The paths or URLs of the images. */
53
+ image?: string | string[]
54
+ published?: Date | string
55
+ modified?: Date | string
56
+ author?: SchemaAuthor | SchemaAuthor[]
57
+ /** The URL of the article page, e.g. `Astro.url.pathname`. */
58
+ url?: string
59
+ /** The locale of the article. The publisher name uses it too. */
60
+ locale?: string
61
+ }
62
+
63
+ export type SchemaBreadcrumb = {
64
+ name: string
65
+ /** The path or URL of the page, e.g. "/exhibitions". */
66
+ url: string
67
+ }
68
+
69
+ /**
70
+ * Returns a date as an ISO 8601 text. A text stays unchanged.
71
+ */
72
+ function isoDate(value: Date | string | undefined): string | undefined {
73
+ if (value === undefined || typeof value === "string") return value
74
+ return value.toISOString()
75
+ }
76
+
77
+ /**
78
+ * Returns an author as a schema.org person.
79
+ */
80
+ function person(author: SchemaAuthor): Person {
81
+ if (typeof author === "string") {
82
+ return { "@type": "Person", name: author }
83
+ }
84
+ return {
85
+ "@type": "Person",
86
+ name: author.name,
87
+ url: author.url ? url(author.url) : undefined,
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Returns the `WebSite` schema, with the name and the URL of the site.
93
+ *
94
+ * @example Schema.website({ locale: Astro.currentLocale })
95
+ */
96
+ function website(options: SchemaWebsite = {}): WithContext<WebSite> {
97
+ const config = getConfig()
98
+ return {
99
+ "@context": "https://schema.org",
100
+ "@type": "WebSite",
101
+ name: options.name ?? localize(config.siteName, options.locale),
102
+ description: options.description ?? localize(config.description, options.locale),
103
+ url: url(options.url ?? "/"),
104
+ inLanguage: options.locale,
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Returns the `Organization` schema of the site owner.
110
+ *
111
+ * @example Schema.organization({ logo: "/logo.png" })
112
+ */
113
+ function organization(options: SchemaOrganization = {}): WithContext<Organization> {
114
+ const config = getConfig()
115
+ return {
116
+ "@context": "https://schema.org",
117
+ "@type": "Organization",
118
+ name: options.name ?? localize(config.siteName, options.locale),
119
+ url: url(options.url ?? "/"),
120
+ logo: options.logo ? url(options.logo) : undefined,
121
+ sameAs: options.sameAs,
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Returns the `Article` schema of a page. The publisher is the site from the config.
127
+ *
128
+ * @example Schema.article({ title, published: post.date, author: "Acme Writer" })
129
+ */
130
+ function article(options: SchemaArticle): WithContext<Article> {
131
+ const config = getConfig()
132
+ const siteName = localize(config.siteName, options.locale)
133
+ const images = typeof options.image === "string" ? [options.image] : options.image
134
+ const authors = Array.isArray(options.author) ? options.author : [options.author]
135
+
136
+ return {
137
+ "@context": "https://schema.org",
138
+ "@type": options.type ?? "Article",
139
+ headline: options.title,
140
+ description: options.description,
141
+ image: images?.map((image) => {
142
+ return url(image)
143
+ }),
144
+ datePublished: isoDate(options.published),
145
+ dateModified: isoDate(options.modified),
146
+ author: options.author
147
+ ? authors.flatMap((author) => {
148
+ return author ? [person(author)] : []
149
+ })
150
+ : undefined,
151
+ publisher: siteName
152
+ ? {
153
+ "@type": "Organization",
154
+ name: siteName,
155
+ url: url("/"),
156
+ }
157
+ : undefined,
158
+ mainEntityOfPage: options.url ? url(options.url) : undefined,
159
+ inLanguage: options.locale,
160
+ } as WithContext<Article>
161
+ }
162
+
163
+ /**
164
+ * Returns the `BreadcrumbList` schema: the path from the home page to the current page.
165
+ *
166
+ * @example Schema.breadcrumbs([{ name: "Home", url: "/" }, { name: "Blog", url: "/blog" }])
167
+ */
168
+ function breadcrumbs(items: SchemaBreadcrumb[]): WithContext<BreadcrumbList> {
169
+ return {
170
+ "@context": "https://schema.org",
171
+ "@type": "BreadcrumbList",
172
+ itemListElement: items.map((item, index) => {
173
+ return {
174
+ "@type": "ListItem",
175
+ position: index + 1,
176
+ name: item.name,
177
+ item: url(item.url),
178
+ }
179
+ }),
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Builders for common JSON-LD schemas. They fill in values from the config of the
185
+ * integration and make all URLs absolute. Give the result to `<Metadata schema>`.
186
+ */
187
+ export const Schema = {
188
+ website,
189
+ organization,
190
+ article,
191
+ breadcrumbs,
192
+
193
+ /**
194
+ * Returns the data as JSON that is safe inside a script tag.
195
+ *
196
+ * @example <script type="application/ld+json" set:html={Schema.stringify(data)} />
197
+ */
198
+ stringify(data: SchemaData | SchemaData[]): string {
199
+ // Escape the characters that can end the script tag or break the JavaScript parser.
200
+ // A value such as "</script>" in the schema then stays inside the JSON.
201
+ return JSON.stringify(data)
202
+ .replace(/</g, "\\u003c")
203
+ .replace(/>/g, "\\u003e")
204
+ .replace(/&/g, "\\u0026")
205
+ .replace(/\u2028/g, "\\u2028")
206
+ .replace(/\u2029/g, "\\u2029")
207
+ },
208
+ }
@@ -0,0 +1,72 @@
1
+ import { getConfig, localize } from "./config.ts"
2
+ import type { MetadataConfig } from "./config.ts"
3
+ import { pageUrl, url } from "./url.ts"
4
+
5
+ /**
6
+ * The site values of the integration, and absolute URLs with the Astro `site` and `base`.
7
+ */
8
+ export const Site = {
9
+ /**
10
+ * Returns the site values of the integration. Without the integration, all values are undefined.
11
+ */
12
+ get config(): MetadataConfig {
13
+ const config = getConfig()
14
+ return {
15
+ siteName: config.siteName,
16
+ titleTemplate: config.titleTemplate,
17
+ description: config.description,
18
+ image: config.image,
19
+ twitter: config.twitter,
20
+ themeColor: config.themeColor,
21
+ feeds: config.feeds,
22
+ robots: config.robots,
23
+ manifest: config.manifest,
24
+ }
25
+ },
26
+
27
+ /**
28
+ * Returns the name of the site for a locale.
29
+ *
30
+ * @param locale - e.g. "fi". A missing locale uses the first value.
31
+ */
32
+ name(locale?: string): string | undefined {
33
+ return localize(getConfig().siteName, locale)
34
+ },
35
+
36
+ /**
37
+ * Returns the full title of a page: the title in the title template, or the site name
38
+ * for a page without a title.
39
+ *
40
+ * @example Site.title("About") // "About | My Site"
41
+ */
42
+ title(title?: string, locale?: string): string | undefined {
43
+ const template = localize(getConfig().titleTemplate, locale)
44
+ if (!title) return Site.name(locale)
45
+ if (!template?.includes("%s")) return title
46
+
47
+ // A replacer function keeps "$&" and other "$" patterns in the title as text
48
+ return template.replace("%s", () => {
49
+ return title
50
+ })
51
+ },
52
+
53
+ /**
54
+ * Returns the absolute URL of a path, with the Astro `site` and `base`.
55
+ * Absolute URLs stay unchanged.
56
+ *
57
+ * @example Site.url("/og.jpg") // "https://example.com/og.jpg"
58
+ */
59
+ url(path: string): string {
60
+ return url(path)
61
+ },
62
+
63
+ /**
64
+ * Returns the absolute URL of a page, with or without the slash at the end,
65
+ * the same as the Astro `trailingSlash` and `build.format` settings.
66
+ *
67
+ * @example Site.pageUrl("/blog") // "https://example.com/blog/"
68
+ */
69
+ pageUrl(path: string): string {
70
+ return pageUrl(path)
71
+ },
72
+ }
package/src/lib/url.ts ADDED
@@ -0,0 +1,79 @@
1
+ import { getConfig } from "./config.ts"
2
+
3
+ /**
4
+ * Returns true for a URL with a scheme ("https:", "data:") or a protocol-relative URL ("//").
5
+ */
6
+ function isAbsolute(value: string): boolean {
7
+ return /^[a-z][a-z\d+.-]*:/i.test(value) || value.startsWith("//")
8
+ }
9
+
10
+ /**
11
+ * Adds the Astro `base` to a path: "/og.jpg" becomes "/docs/og.jpg".
12
+ * A path that already starts with the base stays unchanged.
13
+ *
14
+ * @param path - A path in the site, e.g. "/og.jpg".
15
+ * @returns The path with the base.
16
+ */
17
+ export function withBase(path: string): string {
18
+ if (isAbsolute(path)) return path
19
+
20
+ const base = (import.meta.env.BASE_URL ?? "/").replace(/\/$/, "")
21
+ const clean = path.startsWith("/") ? path : `/${path}`
22
+ if (!base || clean === base || clean.startsWith(`${base}/`)) return clean
23
+ return `${base}${clean}`
24
+ }
25
+
26
+ /**
27
+ * Returns the absolute URL of a path in the site, with the Astro `site` and `base`:
28
+ * "/og.jpg" becomes "https://example.com/docs/og.jpg". Absolute URLs stay unchanged.
29
+ * Without `site` in the Astro config, the result is the path with the base.
30
+ *
31
+ * @param path - A path in the site, e.g. "/og.jpg".
32
+ * @returns The absolute URL.
33
+ */
34
+ export function url(path: string): string {
35
+ if (isAbsolute(path)) return path
36
+
37
+ const site = import.meta.env.SITE
38
+ const pathname = withBase(path)
39
+ return site ? new URL(pathname, site).href : pathname
40
+ }
41
+
42
+ /**
43
+ * Adds or removes the slash at the end of a page path, the same as Astro:
44
+ * - `trailingSlash: "always"` adds the slash, and `"never"` removes it.
45
+ * - With `"ignore"`, `build.format` decides: "directory" adds the slash, and "file" removes it.
46
+ * With "preserve", the path stays unchanged.
47
+ * Paths with a file extension, a query or a hash stay unchanged.
48
+ * Without the integration, the path stays unchanged.
49
+ *
50
+ * @param path - A page path, e.g. "/blog".
51
+ * @returns The path with or without the slash at the end.
52
+ */
53
+ export function pagePath(path: string): string {
54
+ const { trailingSlash, buildFormat } = getConfig()
55
+ if (!trailingSlash || path === "/" || /[?#]/.test(path)) return path
56
+
57
+ const last = path.split("/").pop() ?? ""
58
+ if (last.includes(".")) return path
59
+
60
+ let slash = trailingSlash === "always"
61
+ if (trailingSlash === "ignore") {
62
+ if (buildFormat === "preserve") return path
63
+ slash = buildFormat !== "file"
64
+ }
65
+ const clean = path.replace(/\/+$/, "")
66
+ return slash ? `${clean}/` : clean || "/"
67
+ }
68
+
69
+ /**
70
+ * Returns the absolute URL of a page, with the slash at the end that Astro uses.
71
+ * Absolute URLs stay unchanged.
72
+ *
73
+ * @param path - A page path, e.g. "/blog".
74
+ * @returns The absolute URL, e.g. "https://example.com/blog/".
75
+ */
76
+ export function pageUrl(path: string): string {
77
+ if (isAbsolute(path)) return path
78
+ return url(pagePath(path))
79
+ }
@@ -0,0 +1,14 @@
1
+ import type { APIRoute } from "astro"
2
+
3
+ import { Favicon } from "../lib/favicon.ts"
4
+
5
+ export const prerender = true
6
+
7
+ // The integration adds this route one time for each favicon file.
8
+ // The path without the base tells which file to generate.
9
+ export const GET: APIRoute = ({ url }) => {
10
+ const base = import.meta.env.BASE_URL.replace(/\/$/, "")
11
+ const path =
12
+ base && url.pathname.startsWith(`${base}/`) ? url.pathname.slice(base.length) : url.pathname
13
+ return Favicon.generate(path)
14
+ }
@@ -0,0 +1,18 @@
1
+ import type { APIRoute, GetStaticPaths } from "astro"
2
+
3
+ import { Manifest } from "../lib/manifest.ts"
4
+
5
+ export const prerender = true
6
+
7
+ export const getStaticPaths: GetStaticPaths = () => {
8
+ return Manifest.paths().map((path) => {
9
+ return { params: { lang: path.lang }, props: { locale: path.locale } }
10
+ })
11
+ }
12
+
13
+ export const GET: APIRoute = ({ props }) => {
14
+ const manifest = Manifest.build(props.locale)
15
+ return new Response(JSON.stringify(manifest, null, 2), {
16
+ headers: { "Content-Type": "application/manifest+json" },
17
+ })
18
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,16 @@
1
+ export type { Feed, Localized, MetadataConfig, MetadataImage, ThemeColor } from "./lib/config.ts"
2
+ export type { FaviconFile, ResolvedFaviconFile } from "./lib/favicon-files.js"
3
+ export type { FaviconConfig, FaviconLink, ManifestIcon } from "./lib/favicon.ts"
4
+ export { Favicon } from "./lib/favicon.ts"
5
+ export type { ManifestConfig, ManifestPath } from "./lib/manifest.ts"
6
+ export { Manifest } from "./lib/manifest.ts"
7
+ export type {
8
+ SchemaArticle,
9
+ SchemaAuthor,
10
+ SchemaBreadcrumb,
11
+ SchemaData,
12
+ SchemaOrganization,
13
+ SchemaWebsite,
14
+ } from "./lib/schema.ts"
15
+ export { Schema } from "./lib/schema.ts"
16
+ export { Site } from "./lib/site.ts"
@@ -0,0 +1,72 @@
1
+ import { defineToolbarApp } from "astro/toolbar"
2
+
3
+ import { styleBadge } from "./toolbar-badge.ts"
4
+
5
+ import "./views/metadata-panel.ts"
6
+
7
+ import { checkPage, collect, summarize } from "./data.ts"
8
+ import type { MetadataPanel } from "./views/metadata-panel.ts"
9
+
10
+ export default defineToolbarApp({
11
+ init(canvas, app) {
12
+ styleBadge()
13
+
14
+ const panel = document.createElement("astro-metadata-panel") as MetadataPanel
15
+ canvas.append(panel)
16
+ let open = false
17
+
18
+ // The badge shows the worst problem of the page
19
+ async function updateBadge() {
20
+ const { checks } = await checkPage()
21
+ const { level } = summarize(checks)
22
+ if (level) {
23
+ app.toggleNotification({ state: true, level })
24
+ } else {
25
+ app.toggleNotification({ state: false })
26
+ }
27
+ }
28
+
29
+ async function load() {
30
+ panel.data = undefined
31
+ panel.data = await collect()
32
+ }
33
+
34
+ function setOpen(state: boolean) {
35
+ app.toggleState({ state })
36
+ }
37
+
38
+ app.onToggled(({ state }) => {
39
+ open = state
40
+ if (state) load()
41
+ })
42
+
43
+ panel.addEventListener("close", () => {
44
+ setOpen(false)
45
+ })
46
+
47
+ // Alt+M (Option+M on a Mac) opens and closes the panel. Escape closes it.
48
+ // `code` is the key, not the character: Option+M types "µ" on a Mac.
49
+ document.addEventListener("keydown", (event) => {
50
+ const target = event.target as HTMLElement | null
51
+ if (target?.closest("input, textarea, select, [contenteditable]")) return
52
+
53
+ if (event.altKey && event.code === "KeyM") {
54
+ event.preventDefault()
55
+ setOpen(!open)
56
+ } else if (event.key === "Escape" && open) {
57
+ setOpen(false)
58
+ }
59
+ })
60
+
61
+ // View transitions change the page without a full load
62
+ document.addEventListener("astro:after-swap", () => {
63
+ updateBadge()
64
+ if (open) load()
65
+ })
66
+
67
+ updateBadge()
68
+
69
+ // ?metadata in the URL opens the panel when the page loads
70
+ if (new URLSearchParams(location.search).has("metadata")) setOpen(true)
71
+ },
72
+ })
@@ -0,0 +1,81 @@
1
+ import type { Asset, HeadData, PageAssets } from "../lib/checks.js"
2
+ import { getConfig } from "../lib/config.ts"
3
+
4
+ /**
5
+ * Returns the URL to load a file from, or undefined for a file on another site.
6
+ * A file of the site loads from the dev server, because the `site` URL is the
7
+ * production site, where a new file does not exist yet.
8
+ */
9
+ function devUrl(url: string): URL | undefined {
10
+ let parsed: URL
11
+ try {
12
+ parsed = new URL(url, location.href)
13
+ } catch {
14
+ return undefined
15
+ }
16
+ if (parsed.origin === location.origin) return parsed
17
+
18
+ const site = getConfig().site
19
+ if (!site || parsed.origin !== new URL(site).origin) return undefined
20
+ return new URL(`${parsed.pathname}${parsed.search}`, location.origin)
21
+ }
22
+
23
+ /**
24
+ * Returns the real size of an image, or undefined for a format that the browser
25
+ * can not measure, e.g. SVG.
26
+ */
27
+ async function imageSize(blob: Blob): Promise<{ width: number; height: number } | undefined> {
28
+ try {
29
+ const bitmap = await createImageBitmap(blob)
30
+ const size = { width: bitmap.width, height: bitmap.height }
31
+ bitmap.close()
32
+ return size
33
+ } catch {
34
+ return undefined
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Loads a file of the page: whether it loads, its type, its size in bytes,
40
+ * and the size of an image.
41
+ */
42
+ async function loadAsset(url: string, measure: boolean): Promise<Asset | undefined> {
43
+ const source = devUrl(url)
44
+ if (!source) return undefined
45
+
46
+ try {
47
+ const response = await fetch(source)
48
+ if (!response.ok) return { url, ok: false, status: response.status }
49
+
50
+ const blob = await response.blob()
51
+ const type = blob.type || response.headers.get("content-type")?.split(";")[0]
52
+ const size = measure ? await imageSize(blob) : undefined
53
+ return { url, ok: true, type, bytes: blob.size, ...size }
54
+ } catch {
55
+ return { url, ok: false }
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Loads the `og:image` and the favicons of the page from the dev server.
61
+ * The build reads the same files from the output folder.
62
+ */
63
+ export async function loadAssets(head: HeadData): Promise<PageAssets> {
64
+ const imageUrl = head.meta["og:image"]
65
+ const links = head.links.filter((link) => {
66
+ return /(^|\s)(icon|apple-touch-icon)(\s|$)/i.test(link.rel ?? "") && Boolean(link.href)
67
+ })
68
+
69
+ const [image, ...icons] = await Promise.all([
70
+ imageUrl ? loadAsset(imageUrl, true) : Promise.resolve(undefined),
71
+ ...links.map((link) => {
72
+ return loadAsset(link.href, false)
73
+ }),
74
+ ])
75
+ return {
76
+ image,
77
+ icons: icons.filter((icon): icon is Asset => {
78
+ return icon !== undefined
79
+ }),
80
+ }
81
+ }