@mannisto/astro-metadata 2.0.0-alpha.4 → 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 (38) 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/schema.ts +5 -2
  24. package/src/lib/site.ts +65 -43
  25. package/src/lib/url.ts +4 -1
  26. package/src/runtime.ts +1 -3
  27. package/src/toolbar/app.ts +1 -15
  28. package/src/toolbar/data.ts +0 -2
  29. package/src/toolbar/highlight.ts +0 -3
  30. package/src/toolbar/ui/alert/alert.css +0 -2
  31. package/src/toolbar/ui/section/section.css +0 -7
  32. package/src/toolbar/ui/table/table.css +0 -2
  33. package/src/toolbar/views/metadata-panel.css +0 -7
  34. package/src/toolbar/views/metadata-panel.ts +1 -5
  35. package/src/toolbar/views/overview-view.css +0 -2
  36. package/src/toolbar/views/overview-view.ts +13 -0
  37. package/src/toolbar/views/previews/previews-view.css +0 -1
  38. package/src/toolbar/views/previews/previews-view.ts +0 -1
@@ -0,0 +1,164 @@
1
+ import { getConfig, localize } from "./config.ts"
2
+ import type { MetadataImage } from "./config.ts"
3
+ import { i18n } from "./i18n.ts"
4
+ import type { I18nPage } from "./i18n.ts"
5
+ import { openGraphAlternates, openGraphLocale } from "./open-graph.ts"
6
+ import type { SchemaAuthor, SchemaBreadcrumb, SchemaData } from "./schema.ts"
7
+ import { pageUrl } from "./url.ts"
8
+
9
+ export type HreflangLink = {
10
+ /** The locale of the version, e.g. "en", "fi" or "en-US", or "x-default". */
11
+ hreflang: string
12
+ /** The path or URL of the page in that locale. */
13
+ href: string
14
+ }
15
+
16
+ /** The props of `<Metadata>`. The small components take the props that they use. */
17
+ export type MetadataProps = {
18
+ /** The page title. The title template of the config adds the site name. */
19
+ title?: string
20
+ /** Defaults to the description of the config. */
21
+ description?: string
22
+ /** The social image. Defaults to the image of the config. */
23
+ image?: MetadataImage
24
+ /**
25
+ * The Open Graph type. Defaults to "website". "article" also adds the `Article`
26
+ * structured data, with the title, the description, the image and the dates.
27
+ */
28
+ type?: string
29
+ /**
30
+ * The author of an article: a name, or `{ name, url, twitter }`. The Twitter (X) handle
31
+ * gives `twitter:creator`.
32
+ */
33
+ author?: SchemaAuthor | SchemaAuthor[]
34
+ /** The publication date of an article. */
35
+ published?: Date | string
36
+ /** The date of the last change of an article. */
37
+ modified?: Date | string
38
+ /**
39
+ * The canonical path or URL. Defaults to the current page. With `@mannisto/astro-i18n`,
40
+ * the default is the public URL of the page.
41
+ */
42
+ canonical?: string
43
+ /** Set to false to keep the page out of search results. Defaults to true. */
44
+ index?: boolean
45
+ /** Set to false to tell search engines not to follow the links. Defaults to true. */
46
+ follow?: boolean
47
+ /**
48
+ * The hreflang links: the page in each locale, and `x-default`. With `@mannisto/astro-i18n`
49
+ * or the `i18n` option of Astro, the links are made for you. `[]` turns them off.
50
+ */
51
+ hreflang?: HreflangLink[]
52
+ /** The path from the home page to this page. It adds the `BreadcrumbList` structured data. */
53
+ breadcrumbs?: SchemaBreadcrumb[]
54
+ /**
55
+ * Structured data items of your own, e.g. a `Product`. They go into the structured data
56
+ * of the page, with the site from the config.
57
+ */
58
+ schema?: SchemaData | SchemaData[]
59
+ /**
60
+ * The locale of the page. Defaults to `Astro.currentLocale`, or the locale of
61
+ * `@mannisto/astro-i18n`.
62
+ */
63
+ locale?: string
64
+ }
65
+
66
+ /**
67
+ * Returns the full title of a page: the title in the title template, or the site name
68
+ * for a page without a title.
69
+ *
70
+ * @example fullTitle("About") // "About | My Site"
71
+ */
72
+ export function fullTitle(title?: string, locale?: string): string | undefined {
73
+ const config = getConfig()
74
+ if (!title) return localize(config.siteName, locale)
75
+
76
+ const template = localize(config.titleTemplate, locale)
77
+ if (!template?.includes("%s")) return title
78
+
79
+ // A replacer function keeps "$&" and other "$" patterns in the title as text
80
+ return template.replace("%s", () => {
81
+ return title
82
+ })
83
+ }
84
+
85
+ /**
86
+ * Returns the locale of the page: the prop, `Astro.currentLocale`, or the locale of the
87
+ * i18n setup of the project.
88
+ */
89
+ export function pageLocale(page: I18nPage, props: MetadataProps): string | undefined {
90
+ return props.locale ?? page.currentLocale ?? i18n?.locale(page)
91
+ }
92
+
93
+ /**
94
+ * Returns the absolute canonical URL of the page: the prop, the public path of the
95
+ * i18n setup, or the current path.
96
+ */
97
+ export function pageCanonical(page: I18nPage, props: MetadataProps): string {
98
+ return pageUrl(props.canonical ?? i18n?.path(page) ?? page.url.pathname)
99
+ }
100
+
101
+ /**
102
+ * Returns the hreflang links of the page: the prop, or the links of the i18n setup.
103
+ */
104
+ export function pageHreflang(page: I18nPage, props: MetadataProps): HreflangLink[] {
105
+ return props.hreflang ?? i18n?.alternates(page) ?? []
106
+ }
107
+
108
+ /**
109
+ * Returns the social image of the page: the prop, or the image of the config with the alt
110
+ * text of the locale.
111
+ */
112
+ export function pageImage(props: MetadataProps, locale?: string): MetadataImage | undefined {
113
+ if (props.image) return props.image
114
+
115
+ const image = getConfig().image
116
+ if (!image) return undefined
117
+ return { ...image, alt: localize(image.alt, locale) }
118
+ }
119
+
120
+ /**
121
+ * Returns the description of the page: the prop, or the description of the config.
122
+ */
123
+ export function pageDescription(props: MetadataProps, locale?: string): string | undefined {
124
+ return props.description ?? localize(getConfig().description, locale)
125
+ }
126
+
127
+ /**
128
+ * Returns the content of the robots tag, or an empty text. Search engines index and follow
129
+ * by default, so the tag is only necessary for other values.
130
+ */
131
+ export function pageRobots(props: MetadataProps): string {
132
+ const robots = getConfig().robots
133
+ const index = props.index ?? robots?.index ?? true
134
+ const follow = props.follow ?? robots?.follow ?? true
135
+
136
+ return [!index && "noindex", !follow && "nofollow", robots?.extra].filter(Boolean).join(", ")
137
+ }
138
+
139
+ /**
140
+ * Returns the Open Graph locale of the page and its other locales: "fi_FI".
141
+ */
142
+ export function pageOpenGraphLocales(
143
+ locale: string | undefined,
144
+ hreflang: HreflangLink[]
145
+ ): { locale?: string; alternates: string[] } {
146
+ const codes = hreflang.map((link) => {
147
+ return link.hreflang
148
+ })
149
+ return { locale: openGraphLocale(locale), alternates: openGraphAlternates(locale, codes) }
150
+ }
151
+
152
+ /**
153
+ * Returns the authors of an article as a list.
154
+ */
155
+ export function pageAuthors(props: MetadataProps): SchemaAuthor[] {
156
+ return props.author === undefined ? [] : [props.author].flat()
157
+ }
158
+
159
+ /**
160
+ * Returns a date as an ISO 8601 text. A text stays unchanged.
161
+ */
162
+ export function isoDate(value: Date | string): string {
163
+ return typeof value === "string" ? value : value.toISOString()
164
+ }
package/src/lib/rules.js CHANGED
@@ -813,7 +813,6 @@ export const SITE_RULES = [
813
813
  field: "hreflang",
814
814
  level: "warning",
815
815
  check(pages) {
816
- // The pages by their canonical URL
817
816
  const byUrl = new Map()
818
817
  for (const page of pages) {
819
818
  const canonical = findLink(page.head, "canonical")
package/src/lib/schema.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Graph, Thing, WithContext } from "schema-dts"
1
+ import type { Graph, Thing } from "schema-dts"
2
2
 
3
3
  import { getConfig, localize } from "./config.ts"
4
4
  import type { Localized } from "./config.ts"
@@ -8,8 +8,11 @@ import { url } from "./url.ts"
8
8
  * A structured data item of your own, e.g. a `Product` or an `Event`. The schema.org types
9
9
  * give the editor completion, and other objects are also accepted. `@context` is not
10
10
  * necessary: the items go into one graph with the context.
11
+ *
12
+ * `@context` is an optional property, not `WithContext<Thing>`: that type makes the union of
13
+ * schema-dts too large for TypeScript when a layout spreads its props into `<Metadata>`.
11
14
  */
12
- export type SchemaData = Thing | WithContext<Thing> | Record<string, unknown>
15
+ export type SchemaData = (Thing & { "@context"?: string }) | Record<string, unknown>
13
16
 
14
17
  /**
15
18
  * The organization or the person behind the site. It is the publisher of the site and of
package/src/lib/site.ts CHANGED
@@ -1,73 +1,95 @@
1
1
  import { getConfig, localize } from "./config.ts"
2
- import type { MetadataConfig } from "./config.ts"
3
- import { pageUrl, url } from "./url.ts"
2
+ import type { MetadataImage, ThemeColor } from "./config.ts"
3
+ import { Favicon } from "./favicon.ts"
4
+ import type { ManifestIcon } from "./favicon.ts"
5
+ import { url } from "./url.ts"
6
+
7
+ /** The options of the `Site` values that can have a text for each locale. */
8
+ export type SiteOptions = {
9
+ /** The locale of the text, e.g. "fi". Defaults to the first text. */
10
+ locale?: string
11
+ }
4
12
 
5
13
  /**
6
- * The site values of the integration, and absolute URLs with the Astro `site` and `base`.
14
+ * The values that you set in the integration. Each method returns `undefined` for an
15
+ * option that is not set.
16
+ *
17
+ * @example Site.description({ locale: "fi" })
7
18
  */
8
19
  export const Site = {
9
20
  /**
10
- * Returns the site values of the integration. Without the integration, all values are undefined.
21
+ * Returns the `siteName` of the locale.
22
+ *
23
+ * @example Site.name() // "Acme Studio"
11
24
  */
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
- schema: config.schema,
25
- }
25
+ name(options: SiteOptions = {}): string | undefined {
26
+ return localize(getConfig().siteName, options.locale)
27
+ },
28
+
29
+ /**
30
+ * Returns the `titleTemplate` of the locale.
31
+ *
32
+ * @example Site.template() // "%s | Acme Studio"
33
+ */
34
+ template(options: SiteOptions = {}): string | undefined {
35
+ return localize(getConfig().titleTemplate, options.locale)
26
36
  },
27
37
 
28
38
  /**
29
- * Returns the name of the site for a locale.
39
+ * Returns the `description` of the locale.
30
40
  *
31
- * @param locale - e.g. "fi". A missing locale uses the first value.
41
+ * @example Site.description({ locale: "fi" })
32
42
  */
33
- name(locale?: string): string | undefined {
34
- return localize(getConfig().siteName, locale)
43
+ description(options: SiteOptions = {}): string | undefined {
44
+ return localize(getConfig().description, options.locale)
35
45
  },
36
46
 
37
47
  /**
38
- * Returns the full title of a page: the title in the title template, or the site name
39
- * for a page without a title.
48
+ * Returns the `image` with an absolute URL, and the alt text of the locale.
40
49
  *
41
- * @example Site.title("About") // "About | My Site"
50
+ * @example Site.image() // { url: "https://example.com/og.jpg", alt: "Acme Studio", … }
42
51
  */
43
- title(title?: string, locale?: string): string | undefined {
44
- const template = localize(getConfig().titleTemplate, locale)
45
- if (!title) return Site.name(locale)
46
- if (!template?.includes("%s")) return title
52
+ image(options: SiteOptions = {}): MetadataImage | undefined {
53
+ const image = getConfig().image
54
+ if (!image) return undefined
47
55
 
48
- // A replacer function keeps "$&" and other "$" patterns in the title as text
49
- return template.replace("%s", () => {
50
- return title
51
- })
56
+ return {
57
+ url: url(image.url),
58
+ alt: localize(image.alt, options.locale),
59
+ width: image.width,
60
+ height: image.height,
61
+ }
52
62
  },
53
63
 
54
64
  /**
55
- * Returns the absolute URL of a path, with the Astro `site` and `base`.
56
- * Absolute URLs stay unchanged.
65
+ * Returns the `twitter` values. The card defaults to "summary_large_image".
57
66
  *
58
- * @example Site.url("/og.jpg") // "https://example.com/og.jpg"
67
+ * @example Site.twitter() // { site: "@acmestudio", card: "summary_large_image" }
68
+ */
69
+ twitter(): { site?: string; card: "summary" | "summary_large_image" } | undefined {
70
+ const twitter = getConfig().twitter
71
+ if (!twitter) return undefined
72
+
73
+ return {
74
+ site: twitter.site,
75
+ card: twitter.card ?? "summary_large_image",
76
+ }
77
+ },
78
+
79
+ /**
80
+ * Returns the `themeColor`: one color, or `{ light, dark }`.
59
81
  */
60
- url(path: string): string {
61
- return url(path)
82
+ themeColor(): ThemeColor | undefined {
83
+ return getConfig().themeColor
62
84
  },
63
85
 
64
86
  /**
65
- * Returns the absolute URL of a page, with or without the slash at the end,
66
- * the same as the Astro `trailingSlash` and `build.format` settings.
87
+ * Returns the icons of the generated favicons, for the `icons` field of your own web app
88
+ * manifest. Without the `favicon` option, the list is empty.
67
89
  *
68
- * @example Site.pageUrl("/blog") // "https://example.com/blog/"
90
+ * @example export const GET = () => Response.json({ name: "Acme Studio", icons: Site.icons() })
69
91
  */
70
- pageUrl(path: string): string {
71
- return pageUrl(path)
92
+ icons(): ManifestIcon[] {
93
+ return Favicon.icons()
72
94
  },
73
95
  }
package/src/lib/url.ts CHANGED
@@ -62,7 +62,10 @@ export function pagePath(path: string): string {
62
62
  if (buildFormat === "preserve") return path
63
63
  slash = buildFormat !== "file"
64
64
  }
65
- const clean = path.replace(/\/+$/, "")
65
+ // A loop and not a regular expression: /\/+$/ is slow for a path with many slashes
66
+ let end = path.length
67
+ while (end > 0 && path[end - 1] === "/") end--
68
+ const clean = path.slice(0, end)
66
69
  return slash ? `${clean}/` : clean || "/"
67
70
  }
68
71
 
package/src/runtime.ts CHANGED
@@ -1,9 +1,7 @@
1
1
  export type { Feed, Localized, MetadataConfig, MetadataImage, ThemeColor } from "./lib/config.ts"
2
2
  export type { FaviconFile, ResolvedFaviconFile } from "./lib/favicon-files.js"
3
3
  export type { FaviconConfig, FaviconLink, ManifestIcon } from "./lib/favicon.ts"
4
- export { Favicon } from "./lib/favicon.ts"
5
4
  export type { ManifestConfig, ManifestPath } from "./lib/manifest.ts"
6
- export { Manifest } from "./lib/manifest.ts"
7
5
  export type {
8
6
  SchemaAuthor,
9
7
  SchemaBreadcrumb,
@@ -12,5 +10,5 @@ export type {
12
10
  SchemaPage,
13
11
  SchemaPublisher,
14
12
  } from "./lib/schema.ts"
15
- export { Schema } from "./lib/schema.ts"
13
+ export type { SiteOptions } from "./lib/site.ts"
16
14
  export { Site } from "./lib/site.ts"
@@ -15,7 +15,6 @@ export default defineToolbarApp({
15
15
  canvas.append(panel)
16
16
  let open = false
17
17
 
18
- // The badge shows the worst problem of the page
19
18
  async function updateBadge() {
20
19
  const { checks } = await checkPage()
21
20
  const { level } = summarize(checks)
@@ -44,18 +43,8 @@ export default defineToolbarApp({
44
43
  setOpen(false)
45
44
  })
46
45
 
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
46
  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
- }
47
+ if (event.key === "Escape" && open) setOpen(false)
59
48
  })
60
49
 
61
50
  // View transitions change the page without a full load
@@ -65,8 +54,5 @@ export default defineToolbarApp({
65
54
  })
66
55
 
67
56
  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
57
  },
72
58
  })
@@ -109,7 +109,6 @@ function jsonLd(): JsonLd[] {
109
109
  const source = script.textContent ?? ""
110
110
  try {
111
111
  const data = JSON.parse(source)
112
- // The items of a list, or of a graph
113
112
  const list = Array.isArray(data) ? data : [data]
114
113
  const items = list.flatMap((item) => {
115
114
  return Array.isArray(item?.["@graph"]) ? item["@graph"] : [item]
@@ -239,7 +238,6 @@ export type PageChecks = {
239
238
  checks: Check[]
240
239
  }
241
240
 
242
- // The check endpoint of the dev server. The integration adds it.
243
241
  const CHECK_PATH = "/__astro-metadata/check"
244
242
 
245
243
  /**
@@ -1,6 +1,3 @@
1
- // A small highlighter for the two formats of the panel: JSON and HTML.
2
- // It wraps each token in a span with a class, and the CSS sets the colors.
3
-
4
1
  export type Language = "json" | "html"
5
2
 
6
3
  /**
@@ -61,7 +61,6 @@
61
61
  font-weight: 400;
62
62
  height: 100%;
63
63
 
64
- /* Inline code in the alert uses the colors of the level */
65
64
  --code-color: var(--text);
66
65
  --code-border: color-mix(in srgb, var(--border) 40%, transparent);
67
66
  --code-background: color-mix(in srgb, var(--border) 20%, transparent);
@@ -73,7 +72,6 @@
73
72
  }
74
73
  }
75
74
 
76
- /* The list of items, each with a dot in the color of its level */
77
75
  .items {
78
76
  display: grid;
79
77
  gap: var(--space-50);
@@ -1,4 +1,3 @@
1
- /* A box around the section, so its content belongs together */
2
1
  :host {
3
2
  display: block;
4
3
  padding: var(--space-300);
@@ -7,7 +6,6 @@
7
6
  background: var(--zinc-900);
8
7
  }
9
8
 
10
- /* A closed or empty section shows only its heading */
11
9
  :host([closed]),
12
10
  :host([empty]) {
13
11
  h2 {
@@ -15,17 +13,14 @@
15
13
  }
16
14
  }
17
15
 
18
- /* An empty section has no caret */
19
16
  :host([empty]) astro-metadata-icon {
20
17
  visibility: hidden;
21
18
  }
22
19
 
23
- /* The caret turns down when the section is open */
24
20
  :host(:not([closed])) astro-metadata-icon {
25
21
  transform: rotate(90deg);
26
22
  }
27
23
 
28
- /* The heading: small, monospace, uppercase, wide letter spacing */
29
24
  h2 {
30
25
  margin: 0 0 var(--space-200);
31
26
  font-family: var(--font-mono);
@@ -36,7 +31,6 @@ h2 {
36
31
  letter-spacing: 0.05em;
37
32
  }
38
33
 
39
- /* The heading is a button that closes or opens the section */
40
34
  button {
41
35
  display: flex;
42
36
  align-items: center;
@@ -56,7 +50,6 @@ button {
56
50
  color: var(--zinc-100);
57
51
  }
58
52
 
59
- /* An empty section: a dim heading and "None" at the right */
60
53
  &:disabled {
61
54
  color: var(--zinc-600);
62
55
  cursor: default;
@@ -1,4 +1,3 @@
1
- /* A framed container, a hairline between the rows, and a hover color */
2
1
  :host {
3
2
  display: block;
4
3
  }
@@ -31,7 +30,6 @@ tr {
31
30
  background: var(--zinc-800);
32
31
  }
33
32
 
34
- /* The child rows are indented under their row */
35
33
  &.child th {
36
34
  padding-left: calc(var(--space-400) + var(--space-400));
37
35
  }
@@ -53,13 +53,6 @@
53
53
  min-width: 0;
54
54
  }
55
55
 
56
- /* The title and the Beta badge */
57
- .title {
58
- display: flex;
59
- align-items: center;
60
- gap: var(--space-200);
61
- }
62
-
63
56
  h1 {
64
57
  margin: 0;
65
58
  font-size: var(--font-size-body);
@@ -6,7 +6,6 @@ import type { PageMetadata } from "../data.ts"
6
6
  import { define } from "../define.ts"
7
7
  import tokens from "../styles/tokens.css?inline"
8
8
 
9
- import "../ui/badge/badge.ts"
10
9
  import "../ui/icon/icon.ts"
11
10
  import "../ui/tab-group/tab-group.ts"
12
11
 
@@ -105,10 +104,7 @@ export class MetadataPanel extends LitElement {
105
104
 
106
105
  <header class="header">
107
106
  <div class="heading">
108
- <div class="title">
109
- <h1>Metadata</h1>
110
- <astro-metadata-badge level="info">Beta</astro-metadata-badge>
111
- </div>
107
+ <h1>Metadata</h1>
112
108
  <p class="path">${location.pathname}</p>
113
109
  </div>
114
110
  <button class="close" @click=${this.close} aria-label="Close the panel">
@@ -1,10 +1,8 @@
1
- /* The alerts of the page: one for each level */
2
1
  .alerts {
3
2
  display: grid;
4
3
  gap: var(--space-200);
5
4
  }
6
5
 
7
- /* The Open Graph image at the full width of the panel */
8
6
  .image {
9
7
  width: 100%;
10
8
  aspect-ratio: 1.91 / 1;
@@ -327,6 +327,13 @@ export class OverviewView extends LitElement {
327
327
  const data = this.data
328
328
  const og = data.openGraph
329
329
  const image = findMeta(og, "og:image")
330
+ const alternates = og
331
+ .filter((tag) => {
332
+ return tag.key === "og:locale:alternate"
333
+ })
334
+ .map((tag) => {
335
+ return tag.value
336
+ })
330
337
  const width = findMeta(og, "og:image:width")
331
338
  const height = findMeta(og, "og:image:height")
332
339
 
@@ -366,6 +373,12 @@ export class OverviewView extends LitElement {
366
373
  tagRow("Description", findMeta(og, "og:description"), "og:description"),
367
374
  tagRow("Type", code(findMeta(og, "og:type")), "og:type"),
368
375
  tagRow("Site name", findMeta(og, "og:site_name"), "og:site_name"),
376
+ tagRow("Locale", code(findMeta(og, "og:locale")), "og:locale"),
377
+ tagRow(
378
+ "Other locales",
379
+ code(alternates.join(", ") || undefined),
380
+ "og:locale:alternate"
381
+ ),
369
382
  tagRow("Image", image, "og:image"),
370
383
  tagRow(
371
384
  "Image size",
@@ -4,7 +4,6 @@
4
4
  * look like the platform, not like the panel.
5
5
  */
6
6
 
7
- /* The preview at the center of a dotted background */
8
7
  .stage {
9
8
  display: flex;
10
9
  justify-content: center;
@@ -17,7 +17,6 @@ import { slack } from "./slack.ts"
17
17
  import { twitter } from "./twitter.ts"
18
18
  import { whatsapp } from "./whatsapp.ts"
19
19
 
20
- // The platforms in the order of the sections
21
20
  const PLATFORMS: Platform[] = [google, twitter, facebook, linkedin, whatsapp, discord, slack]
22
21
 
23
22
  /**