@mannisto/astro-metadata 2.0.0-alpha.5 → 2.0.0

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 (37) hide show
  1. package/README.md +247 -108
  2. package/package.json +2 -2
  3. package/src/components/Canonical.astro +10 -0
  4. package/src/components/Description.astro +10 -0
  5. package/src/components/Favicon.astro +9 -0
  6. package/src/components/Feeds.astro +29 -0
  7. package/src/components/LanguageAlternates.astro +13 -0
  8. package/src/components/Manifest.astro +11 -0
  9. package/src/components/Metadata.astro +27 -199
  10. package/src/components/OpenGraph.astro +70 -0
  11. package/src/components/Robots.astro +10 -0
  12. package/src/components/Schema.astro +42 -0
  13. package/src/components/ThemeColor.astro +15 -0
  14. package/src/components/Title.astro +10 -0
  15. package/src/components/Twitter.astro +21 -0
  16. package/src/components/index.ts +13 -1
  17. package/src/integration.js +0 -6
  18. package/src/lib/checks.js +6 -1
  19. package/src/lib/manifest.ts +0 -1
  20. package/src/lib/open-graph.ts +54 -0
  21. package/src/lib/page.ts +164 -0
  22. package/src/lib/rules.js +0 -1
  23. package/src/lib/site.ts +65 -43
  24. package/src/lib/url.ts +4 -1
  25. package/src/runtime.ts +1 -3
  26. package/src/toolbar/app.ts +1 -15
  27. package/src/toolbar/data.ts +0 -2
  28. package/src/toolbar/highlight.ts +0 -3
  29. package/src/toolbar/ui/alert/alert.css +0 -2
  30. package/src/toolbar/ui/section/section.css +0 -7
  31. package/src/toolbar/ui/table/table.css +0 -2
  32. package/src/toolbar/views/metadata-panel.css +0 -7
  33. package/src/toolbar/views/metadata-panel.ts +1 -5
  34. package/src/toolbar/views/overview-view.css +0 -2
  35. package/src/toolbar/views/overview-view.ts +13 -0
  36. package/src/toolbar/views/previews/previews-view.css +0 -1
  37. package/src/toolbar/views/previews/previews-view.ts +0 -1
@@ -1,206 +1,34 @@
1
1
  ---
2
- import { localize } from "../lib/config.ts"
3
- import type { MetadataImage } from "../lib/config.ts"
4
- import { Favicon } from "../lib/favicon.ts"
5
- import { i18n } from "../lib/i18n.ts"
6
- import { Manifest } from "../lib/manifest.ts"
7
- import { Schema } from "../lib/schema.ts"
8
- import type { SchemaAuthor, SchemaBreadcrumb, SchemaData } from "../lib/schema.ts"
9
- import { Site } from "../lib/site.ts"
10
- import { withBase } from "../lib/url.ts"
11
-
12
- export type HreflangLink = {
13
- /** The locale of the version, e.g. "en", "fi" or "en-US", or "x-default". */
14
- hreflang: string
15
- /** The path or URL of the page in that locale. */
16
- href: string
17
- }
18
-
19
- export type Props = {
20
- /** The page title. The title template of the config adds the site name. */
21
- title?: string
22
- /** Defaults to the description of the config. */
23
- description?: string
24
- /** The social image. Defaults to the image of the config. */
25
- image?: MetadataImage
26
- /**
27
- * The Open Graph type. Defaults to "website". "article" also adds the `Article`
28
- * structured data, with the title, the description, the image and the dates.
29
- */
30
- type?: string
31
- /**
32
- * The author of an article: a name, or `{ name, url, twitter }`. The Twitter (X) handle
33
- * gives `twitter:creator`.
34
- */
35
- author?: SchemaAuthor | SchemaAuthor[]
36
- /** The publication date of an article. */
37
- published?: Date | string
38
- /** The date of the last change of an article. */
39
- modified?: Date | string
40
- /**
41
- * The canonical path or URL. Defaults to the current page. With `@mannisto/astro-i18n`,
42
- * the default is the public URL of the page.
43
- */
44
- canonical?: string
45
- /** Set to false to keep the page out of search results. Defaults to true. */
46
- index?: boolean
47
- /** Set to false to tell search engines not to follow the links. Defaults to true. */
48
- follow?: boolean
49
- /**
50
- * The hreflang links: the page in each locale, and `x-default`. With `@mannisto/astro-i18n`
51
- * or the `i18n` option of Astro, the links are made for you. `[]` turns them off.
52
- */
53
- hreflang?: HreflangLink[]
54
- /** The path from the home page to this page. It adds the `BreadcrumbList` structured data. */
55
- breadcrumbs?: SchemaBreadcrumb[]
56
- /**
57
- * Structured data items of your own, e.g. a `Product`. They go into the structured data
58
- * of the page, with the site from the config.
59
- */
60
- schema?: SchemaData | SchemaData[]
61
- /**
62
- * The locale of the page. Defaults to `Astro.currentLocale`, or the locale of
63
- * `@mannisto/astro-i18n`.
64
- */
65
- locale?: string
66
- }
2
+ import type { MetadataProps } from "../lib/page.ts"
3
+ import Canonical from "./Canonical.astro"
4
+ import Description from "./Description.astro"
5
+ import Favicon from "./Favicon.astro"
6
+ import Feeds from "./Feeds.astro"
7
+ import LanguageAlternates from "./LanguageAlternates.astro"
8
+ import Manifest from "./Manifest.astro"
9
+ import OpenGraph from "./OpenGraph.astro"
10
+ import Robots from "./Robots.astro"
11
+ import Schema from "./Schema.astro"
12
+ import ThemeColor from "./ThemeColor.astro"
13
+ import Title from "./Title.astro"
14
+ import Twitter from "./Twitter.astro"
15
+
16
+ export type Props = MetadataProps
67
17
 
68
18
  const props = Astro.props
69
- const config = Site.config
70
- const locale = props.locale ?? Astro.currentLocale ?? i18n?.locale(Astro)
71
-
72
- const siteName = Site.name(locale)
73
- const title = Site.title(props.title, locale)
74
- const description = props.description ?? localize(config.description, locale)
75
- const image =
76
- props.image ??
77
- (config.image ? { ...config.image, alt: localize(config.image.alt, locale) } : undefined)
78
-
79
- // Social cards show the page title without the template: og:site_name has the site name
80
- const socialTitle = props.title ?? siteName
81
-
82
- const canonical = Site.pageUrl(props.canonical ?? i18n?.path(Astro) ?? Astro.url.pathname)
83
-
84
- // The page in each locale: from the props, or from the i18n setup of the project
85
- const hreflang = props.hreflang ?? i18n?.alternates(Astro) ?? []
86
-
87
- // Search engines index and follow by default, so the tag is only necessary for other values
88
- const index = props.index ?? config.robots?.index ?? true
89
- const follow = props.follow ?? config.robots?.follow ?? true
90
- const robots = [!index && "noindex", !follow && "nofollow", config.robots?.extra]
91
- .filter(Boolean)
92
- .join(", ")
93
-
94
- // Twitter (X) uses the Open Graph title, description and image, so only the tags that
95
- // are specific to Twitter (X) are necessary
96
- const card = image ? (config.twitter?.card ?? "summary_large_image") : "summary"
97
-
98
- // The authors as a list, for the Twitter (X) handles and the article tags
99
- const authors = props.author === undefined ? [] : [props.author].flat()
100
- const creator = authors
101
- .map((author) => {
102
- return typeof author === "string" ? undefined : author.twitter
103
- })
104
- .find(Boolean)
105
- const article = props.type === "article"
106
-
107
- const schema = Schema.graph({
108
- url: canonical,
109
- title: props.title ?? siteName,
110
- description,
111
- image: image ? Site.url(image.url) : undefined,
112
- locale,
113
- type: props.type,
114
- published: props.published,
115
- modified: props.modified,
116
- author: props.author,
117
- breadcrumbs: props.breadcrumbs,
118
- items: props.schema === undefined ? undefined : [props.schema].flat(),
119
- })
120
-
121
- const themeColor = config.themeColor
122
- const manifest = Manifest.url(locale)
123
-
124
- /**
125
- * Returns a date as an ISO 8601 text. A text stays unchanged.
126
- */
127
- function isoDate(value: Date | string): string {
128
- return typeof value === "string" ? value : value.toISOString()
129
- }
130
-
131
- const FEED_TYPES = {
132
- rss: "application/rss+xml",
133
- atom: "application/atom+xml",
134
- json: "application/feed+json",
135
- }
136
19
  ---
137
20
 
138
21
  <meta charset="UTF-8" />
139
22
  <meta name="viewport" content="width=device-width, initial-scale=1" />
140
- {title && <title>{title}</title>}
141
- {description && <meta name="description" content={description} />}
142
- <link rel="canonical" href={canonical} />
143
- {robots && <meta name="robots" content={robots} />}
144
- {typeof themeColor === "string" && <meta name="theme-color" content={themeColor} />}
145
- {
146
- typeof themeColor === "object" && (
147
- <>
148
- <meta name="theme-color" content={themeColor.light} media="(prefers-color-scheme: light)" />
149
- <meta name="theme-color" content={themeColor.dark} media="(prefers-color-scheme: dark)" />
150
- </>
151
- )
152
- }
153
-
154
- {socialTitle && <meta property="og:title" content={socialTitle} />}
155
- {description && <meta property="og:description" content={description} />}
156
- <meta property="og:url" content={canonical} />
157
- <meta property="og:type" content={props.type ?? "website"} />
158
- {siteName && <meta property="og:site_name" content={siteName} />}
159
- {
160
- image && (
161
- <>
162
- <meta property="og:image" content={Site.url(image.url)} />
163
- {image.alt && <meta property="og:image:alt" content={image.alt} />}
164
- {image.width && <meta property="og:image:width" content={String(image.width)} />}
165
- {image.height && <meta property="og:image:height" content={String(image.height)} />}
166
- </>
167
- )
168
- }
169
- {
170
- article && props.published && (
171
- <meta property="article:published_time" content={isoDate(props.published)} />
172
- )
173
- }
174
- {
175
- article && props.modified && (
176
- <meta property="article:modified_time" content={isoDate(props.modified)} />
177
- )
178
- }
179
- <meta name="twitter:card" content={card} />
180
- {config.twitter?.site && <meta name="twitter:site" content={config.twitter.site} />}
181
- {creator && <meta name="twitter:creator" content={creator} />}
182
-
183
- {
184
- Favicon.links().map((link) => {
185
- return <link rel={link.rel} type={link.type} href={link.href} sizes={link.sizes} />
186
- })
187
- }
188
- {manifest && <link rel="manifest" href={manifest} />}
189
- {
190
- config.feeds?.map((feed) => {
191
- return (
192
- <link
193
- rel="alternate"
194
- type={FEED_TYPES[feed.type ?? "rss"]}
195
- title={localize(feed.title, locale)}
196
- href={withBase(feed.href)}
197
- />
198
- )
199
- })
200
- }
201
- {
202
- hreflang.map((link) => {
203
- return <link rel="alternate" hreflang={link.hreflang} href={Site.pageUrl(link.href)} />
204
- })
205
- }
206
- {schema && <script is:inline type="application/ld+json" set:html={Schema.stringify(schema)} />}
23
+ <Title {...props} />
24
+ <Description {...props} />
25
+ <Canonical {...props} />
26
+ <Robots {...props} />
27
+ <ThemeColor />
28
+ <OpenGraph {...props} />
29
+ <Twitter {...props} />
30
+ <Favicon />
31
+ <Manifest {...props} />
32
+ <Feeds {...props} />
33
+ <LanguageAlternates {...props} />
34
+ <Schema {...props} />
@@ -0,0 +1,70 @@
1
+ ---
2
+ import { getConfig, localize } from "../lib/config.ts"
3
+ import {
4
+ isoDate,
5
+ pageCanonical,
6
+ pageDescription,
7
+ pageHreflang,
8
+ pageImage,
9
+ pageLocale,
10
+ pageOpenGraphLocales,
11
+ } from "../lib/page.ts"
12
+ import type { MetadataProps } from "../lib/page.ts"
13
+ import { url } from "../lib/url.ts"
14
+
15
+ type Keys =
16
+ | "title"
17
+ | "description"
18
+ | "image"
19
+ | "type"
20
+ | "published"
21
+ | "modified"
22
+ | "canonical"
23
+ | "hreflang"
24
+ | "locale"
25
+
26
+ export type Props = Pick<MetadataProps, Keys>
27
+
28
+ const props = Astro.props
29
+ const locale = pageLocale(Astro, props)
30
+ const siteName = localize(getConfig().siteName, locale)
31
+ const description = pageDescription(props, locale)
32
+ const image = pageImage(props, locale)
33
+ const locales = pageOpenGraphLocales(locale, pageHreflang(Astro, props))
34
+ const article = props.type === "article"
35
+
36
+ // Social cards show the page title without the template: og:site_name has the site name
37
+ const title = props.title ?? siteName
38
+ ---
39
+
40
+ {title && <meta property="og:title" content={title} />}
41
+ {description && <meta property="og:description" content={description} />}
42
+ <meta property="og:url" content={pageCanonical(Astro, props)} />
43
+ <meta property="og:type" content={props.type ?? "website"} />
44
+ {siteName && <meta property="og:site_name" content={siteName} />}
45
+ {locales.locale && <meta property="og:locale" content={locales.locale} />}
46
+ {
47
+ locales.alternates.map((alternate) => {
48
+ return <meta property="og:locale:alternate" content={alternate} />
49
+ })
50
+ }
51
+ {
52
+ image && (
53
+ <>
54
+ <meta property="og:image" content={url(image.url)} />
55
+ {image.alt && <meta property="og:image:alt" content={image.alt} />}
56
+ {image.width && <meta property="og:image:width" content={String(image.width)} />}
57
+ {image.height && <meta property="og:image:height" content={String(image.height)} />}
58
+ </>
59
+ )
60
+ }
61
+ {
62
+ article && props.published && (
63
+ <meta property="article:published_time" content={isoDate(props.published)} />
64
+ )
65
+ }
66
+ {
67
+ article && props.modified && (
68
+ <meta property="article:modified_time" content={isoDate(props.modified)} />
69
+ )
70
+ }
@@ -0,0 +1,10 @@
1
+ ---
2
+ import { pageRobots } from "../lib/page.ts"
3
+ import type { MetadataProps } from "../lib/page.ts"
4
+
5
+ export type Props = Pick<MetadataProps, "index" | "follow">
6
+
7
+ const robots = pageRobots(Astro.props)
8
+ ---
9
+
10
+ {robots && <meta name="robots" content={robots} />}
@@ -0,0 +1,42 @@
1
+ ---
2
+ import { getConfig, localize } from "../lib/config.ts"
3
+ import { pageCanonical, pageDescription, pageImage, pageLocale } from "../lib/page.ts"
4
+ import type { MetadataProps } from "../lib/page.ts"
5
+ import { Schema } from "../lib/schema.ts"
6
+ import { url } from "../lib/url.ts"
7
+
8
+ type Keys =
9
+ | "title"
10
+ | "description"
11
+ | "image"
12
+ | "type"
13
+ | "published"
14
+ | "modified"
15
+ | "author"
16
+ | "canonical"
17
+ | "breadcrumbs"
18
+ | "schema"
19
+ | "locale"
20
+
21
+ export type Props = Pick<MetadataProps, Keys>
22
+
23
+ const props = Astro.props
24
+ const locale = pageLocale(Astro, props)
25
+ const image = pageImage(props, locale)
26
+
27
+ const schema = Schema.graph({
28
+ url: pageCanonical(Astro, props),
29
+ title: props.title ?? localize(getConfig().siteName, locale),
30
+ description: pageDescription(props, locale),
31
+ image: image ? url(image.url) : undefined,
32
+ locale,
33
+ type: props.type,
34
+ published: props.published,
35
+ modified: props.modified,
36
+ author: props.author,
37
+ breadcrumbs: props.breadcrumbs,
38
+ items: props.schema === undefined ? undefined : [props.schema].flat(),
39
+ })
40
+ ---
41
+
42
+ {schema && <script is:inline type="application/ld+json" set:html={Schema.stringify(schema)} />}
@@ -0,0 +1,15 @@
1
+ ---
2
+ import { getConfig } from "../lib/config.ts"
3
+
4
+ const themeColor = getConfig().themeColor
5
+ ---
6
+
7
+ {typeof themeColor === "string" && <meta name="theme-color" content={themeColor} />}
8
+ {
9
+ typeof themeColor === "object" && (
10
+ <>
11
+ <meta name="theme-color" content={themeColor.light} media="(prefers-color-scheme: light)" />
12
+ <meta name="theme-color" content={themeColor.dark} media="(prefers-color-scheme: dark)" />
13
+ </>
14
+ )
15
+ }
@@ -0,0 +1,10 @@
1
+ ---
2
+ import { fullTitle, pageLocale } from "../lib/page.ts"
3
+ import type { MetadataProps } from "../lib/page.ts"
4
+
5
+ export type Props = Pick<MetadataProps, "title" | "locale">
6
+
7
+ const title = fullTitle(Astro.props.title, pageLocale(Astro, Astro.props))
8
+ ---
9
+
10
+ {title && <title>{title}</title>}
@@ -0,0 +1,21 @@
1
+ ---
2
+ import { getConfig } from "../lib/config.ts"
3
+ import { pageAuthors, pageImage } from "../lib/page.ts"
4
+ import type { MetadataProps } from "../lib/page.ts"
5
+
6
+ export type Props = Pick<MetadataProps, "image" | "author">
7
+
8
+ // Twitter (X) uses the Open Graph title, description and image, so only the tags that
9
+ // are specific to Twitter (X) are necessary
10
+ const twitter = getConfig().twitter
11
+ const card = pageImage(Astro.props) ? (twitter?.card ?? "summary_large_image") : "summary"
12
+ const creator = pageAuthors(Astro.props)
13
+ .map((author) => {
14
+ return typeof author === "string" ? undefined : author.twitter
15
+ })
16
+ .find(Boolean)
17
+ ---
18
+
19
+ <meta name="twitter:card" content={card} />
20
+ {twitter?.site && <meta name="twitter:site" content={twitter.site} />}
21
+ {creator && <meta name="twitter:creator" content={creator} />}
@@ -1,2 +1,14 @@
1
- export type { HreflangLink, Props as MetadataProps } from "./Metadata.astro"
1
+ export type { HreflangLink, MetadataProps } from "../lib/page.ts"
2
+ export { default as Canonical } from "./Canonical.astro"
3
+ export { default as Description } from "./Description.astro"
4
+ export { default as Favicon } from "./Favicon.astro"
5
+ export { default as Feeds } from "./Feeds.astro"
6
+ export { default as LanguageAlternates } from "./LanguageAlternates.astro"
7
+ export { default as Manifest } from "./Manifest.astro"
2
8
  export { default as Metadata } from "./Metadata.astro"
9
+ export { default as OpenGraph } from "./OpenGraph.astro"
10
+ export { default as Robots } from "./Robots.astro"
11
+ export { default as Schema } from "./Schema.astro"
12
+ export { default as ThemeColor } from "./ThemeColor.astro"
13
+ export { default as Title } from "./Title.astro"
14
+ export { default as Twitter } from "./Twitter.astro"
@@ -8,7 +8,6 @@ import { defaultFaviconFiles, resolveFaviconFiles } from "./lib/favicon-files.js
8
8
 
9
9
  const NAME = "@mannisto/astro-metadata"
10
10
 
11
- // The site values of the options. The components read them at build time.
12
11
  const SITE_KEYS = [
13
12
  "siteName",
14
13
  "titleTemplate",
@@ -22,7 +21,6 @@ const SITE_KEYS = [
22
21
  "schema",
23
22
  ]
24
23
 
25
- // The Lit modules of the dev toolbar app
26
24
  const LIT_MODULES = [
27
25
  "lit",
28
26
  "lit/directives/style-map.js",
@@ -285,7 +283,6 @@ function findHtmlFiles(dir) {
285
283
  .sort()
286
284
  }
287
285
 
288
- // The MIME types of the image files, by their extension
289
286
  const MIME_TYPES = {
290
287
  ".avif": "image/avif",
291
288
  ".gif": "image/gif",
@@ -464,7 +461,6 @@ async function checkPages(context, logger) {
464
461
  }
465
462
  }
466
463
 
467
- // The path of the check endpoint of the dev server
468
464
  const CHECK_PATH = "/__astro-metadata/check"
469
465
 
470
466
  /**
@@ -546,7 +542,6 @@ export default function metadata(options = {}) {
546
542
  })
547
543
  }
548
544
 
549
- // The dev toolbar app runs only in dev, and the `debug` option can turn it off
550
545
  const toolbar = command === "dev" && debug.client
551
546
  context.root = config.root
552
547
  context.site = config.site
@@ -597,7 +592,6 @@ export default function metadata(options = {}) {
597
592
  })
598
593
  }
599
594
 
600
- // One route for each favicon file, all with the same endpoint
601
595
  if (favicon) {
602
596
  for (const file of favicon.files) {
603
597
  injectRoute({
package/src/lib/checks.js CHANGED
@@ -102,7 +102,12 @@ export function isNoindex(head) {
102
102
  * @returns {string}
103
103
  */
104
104
  function trimSlash(path) {
105
- return path.length > 1 ? path.replace(/\/+$/, "") : path
105
+ if (path.length <= 1) return path
106
+
107
+ // A loop and not a regular expression: /\/+$/ is slow for a path with many slashes
108
+ let end = path.length
109
+ while (end > 0 && path[end - 1] === "/") end--
110
+ return path.slice(0, end)
106
111
  }
107
112
 
108
113
  /**
@@ -117,7 +117,6 @@ export const Manifest = {
117
117
  ...manifest.extra,
118
118
  }
119
119
 
120
- // Remove empty fields, so the file has only the values that the config gives
121
120
  for (const key of Object.keys(json)) {
122
121
  if (json[key] === undefined) delete json[key]
123
122
  }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Returns the most likely ISO 639-1 language and ISO 3166-1 alpha-2 country of a locale,
3
+ * or undefined. A numeric region, e.g. "419" in "es-419", is not a country.
4
+ */
5
+ function languageAndCountry(locale: string): { language: string; country?: string } | undefined {
6
+ try {
7
+ const parsed = new Intl.Locale(locale)
8
+ // "und" is the undefined language: maximize() changes it to "en"
9
+ if (!parsed.language || parsed.language === "und") return undefined
10
+
11
+ const { language, region } = parsed.maximize()
12
+ if (!/^[a-z]{2}$/.test(language)) return undefined
13
+ return { language, country: region && /^[A-Z]{2}$/.test(region) ? region : undefined }
14
+ } catch {
15
+ return undefined
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Returns a locale in the format of Open Graph: an ISO 639-1 language and an ISO 3166-1
21
+ * alpha-2 country, e.g. "fi_FI". A locale without a country gets the most likely country
22
+ * of its language: "fi" becomes "fi_FI", "en" becomes "en_US" and "es-419" becomes "es_ES".
23
+ * Returns undefined for a locale without this format, e.g. "x-default" or "fil".
24
+ *
25
+ * @param locale - A locale, e.g. "fi", "en-GB" or "en_GB".
26
+ */
27
+ export function openGraphLocale(locale: string | undefined): string | undefined {
28
+ if (!locale) return undefined
29
+
30
+ const value = languageAndCountry(locale.replace("_", "-"))
31
+ if (!value) return undefined
32
+
33
+ const country = value.country ?? languageAndCountry(value.language)?.country
34
+ return country ? `${value.language}_${country}` : undefined
35
+ }
36
+
37
+ /**
38
+ * Returns the other locales of the page in the format of Open Graph, for the
39
+ * `og:locale:alternate` tags. The list has no duplicates and not the locale of the page.
40
+ *
41
+ * @param locale - The locale of the page, e.g. "fi".
42
+ * @param hreflang - The locales of the hreflang links, e.g. ["en", "fi", "x-default"].
43
+ */
44
+ export function openGraphAlternates(locale: string | undefined, hreflang: string[]): string[] {
45
+ const current = openGraphLocale(locale)
46
+ const alternates = hreflang
47
+ .map((value) => {
48
+ return openGraphLocale(value)
49
+ })
50
+ .filter((value): value is string => {
51
+ return value !== undefined && value !== current
52
+ })
53
+ return [...new Set(alternates)]
54
+ }