@mannisto/astro-metadata 2.0.0-alpha.2 → 2.0.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -68,6 +68,7 @@ Set `site` in the Astro config. The canonical URL and the social images must be
68
68
  | `robots` | — | `index: false` or `follow: false` for all pages, for example on a staging site. `extra`: other directives, for example `"max-image-preview:large"`. |
69
69
  | `favicon` | — | Generates the favicons from one image. See [Favicons](#favicons). |
70
70
  | `manifest` | — | Generates the web app manifest, or links your own. See [Web app manifest](#web-app-manifest). |
71
+ | `schema` | — | The structured data of the site: `publisher` is the organization or the person behind the site. `false` turns it off. See [Structured data](#structured-data). |
71
72
  | `debug` | `true` | The Metadata app in the dev toolbar (`client`) and the checks in the build log (`build`). Set `false` to turn off both, or `{ client: false }` or `{ build: false }` to turn off one. See [Checks](#checks). |
72
73
  | `rules` | — | `ignore`: the ids of the check rules to turn off. `custom`: your own rules. See [Checks](#checks). |
73
74
  | `pages` | — | `ignore`: the pages without checks, as path patterns, for example `["/404", "/drafts/**"]`. See [Checks](#checks). |
@@ -83,7 +84,16 @@ metadata({
83
84
  })
84
85
  ```
85
86
 
86
- `<Metadata>` uses the text of `Astro.currentLocale`, or of its `locale` prop. A locale without a text uses the first text.
87
+ `<Metadata>` uses the text of the locale of the page, or of its `locale` prop. A locale without a text uses the first text.
88
+
89
+ ### Locales
90
+
91
+ `<Metadata>` reads the locales from your i18n setup. You do not need extra code:
92
+
93
+ - **[`@mannisto/astro-i18n`](https://github.com/eremannisto/astro-i18n):** the locale, the public URL of the page (`/about` and not `/en/about`) and the hreflang links.
94
+ - **The `i18n` option of Astro:** the locale and the hreflang links.
95
+
96
+ The hreflang links contain the page in each locale, and `x-default` for the default locale. To give your own links, use the `hreflang` prop. `hreflang={[]}` turns the links off, for example on a 404 page.
87
97
 
88
98
  ## Metadata component
89
99
 
@@ -143,13 +153,16 @@ The page values override the site values. For example, `title="About"` gives:
143
153
  | `description` | `description` | The page description. |
144
154
  | `image` | `image` | The social image: `url`, `alt`, `width` and `height`. |
145
155
  | `type` | `"website"` | The Open Graph type, for example `"article"`. |
146
- | `author` | — | The Twitter (X) handle of the author, for example `"@acmewriter"`. |
147
- | `canonical` | The current page | The canonical path or URL. |
156
+ | `author` | — | The author of an article: a name, or `{ name, url, twitter }`. `twitter` gives `twitter:creator`. |
157
+ | `published` | — | The publication date of an article. |
158
+ | `modified` | — | The date of the last change of an article. |
159
+ | `canonical` | The current page | The canonical path or URL. With `@mannisto/astro-i18n`, the default is the public URL of the page. |
148
160
  | `index` | `true` | `false` keeps the page out of search results. |
149
161
  | `follow` | `true` | `false` tells search engines not to follow the links. |
150
- | `hreflang` | — | The hreflang links: the page in each locale, and `x-default`: `{ hreflang, href }[]`. |
151
- | `schema` | — | Structured data. See [Structured data](#structured-data). |
152
- | `locale` | `Astro.currentLocale` | The locale of the translated site values. |
162
+ | `hreflang` | From the i18n setup | The hreflang links: the page in each locale, and `x-default`: `{ hreflang, href }[]`. See [Locales](#locales). |
163
+ | `breadcrumbs` | — | The path from the home page to the page: `{ name, url }[]`. See [Structured data](#structured-data). |
164
+ | `schema` | — | Structured data items of your own, for example a `Product`. See [Structured data](#structured-data). |
165
+ | `locale` | The locale of the page | The locale of the translated site values. See [Locales](#locales). |
153
166
 
154
167
  A tag is rendered only when it has a value. For example, the robots tag is necessary only with `index` or `follow` set to `false`.
155
168
 
@@ -240,33 +253,53 @@ Your own manifest endpoint can use the generated icons with `Favicon.icons()`.
240
253
 
241
254
  ## Structured data
242
255
 
243
- The `Schema` builders make the most common JSON-LD types. They fill in the site name and make all URLs absolute.
256
+ `<Metadata>` renders the structured data (JSON-LD) of each page by itself. You only give the values of the page.
244
257
 
245
- ```astro
246
- ---
247
- import { Metadata } from "@mannisto/astro-metadata/components"
248
- import { Schema } from "@mannisto/astro-metadata/runtime"
249
- ---
258
+ **The site.** Each page gets the `WebSite` and its publisher: by default, an organization with the site name. Give the logo and the profiles in the config:
259
+
260
+ ```typescript
261
+ metadata({
262
+ siteName: "Acme Studio",
263
+ schema: {
264
+ publisher: {
265
+ logo: "/logo.png",
266
+ sameAs: ["https://github.com/acme-studio"],
267
+ },
268
+ },
269
+ })
270
+ ```
271
+
272
+ For a personal site, use `publisher: { type: "Person" }`. Set `schema: false` to turn off the site data.
273
+
274
+ **An article.** Set `type="article"`. The article uses the title, the description and the image of the page:
250
275
 
276
+ ```astro
251
277
  <Metadata
252
278
  title="Hello"
253
279
  type="article"
254
- schema={[
255
- Schema.article({
256
- title: "Hello",
257
- published: new Date("2026-09-01"),
258
- author: { name: "Acme Writer", url: "/about" },
259
- url: "/blog/hello",
260
- }),
261
- Schema.breadcrumbs([
262
- { name: "Home", url: "/" },
263
- { name: "Blog", url: "/blog" },
264
- ]),
280
+ published={post.date}
281
+ author={{ name: "Acme Writer", url: "/about", twitter: "@acmewriter" }}
282
+ />
283
+ ```
284
+
285
+ **Breadcrumbs.** Give the path from the home page to the page:
286
+
287
+ ```astro
288
+ <Metadata
289
+ breadcrumbs={[
290
+ { name: "Home", url: "/" },
291
+ { name: "Blog", url: "/blog" },
265
292
  ]}
266
293
  />
267
294
  ```
268
295
 
269
- The `schema` prop also takes your own objects. The types come from [`schema-dts`](https://github.com/google/schema-dts), so your editor completes the schema.org fields.
296
+ **Your own items.** Give other types, for example a `Product` or an `Event`, in `schema`. The types come from [`schema-dts`](https://github.com/google/schema-dts), so your editor completes the schema.org fields:
297
+
298
+ ```astro
299
+ <Metadata schema={{ "@type": "Event", name: "Opening night", startDate: "2026-11-01" }} />
300
+ ```
301
+
302
+ All items go into one graph. The items link to each other with `@id`, and all URLs are absolute.
270
303
 
271
304
  ## Checks
272
305
 
@@ -384,10 +417,7 @@ import { Favicon, Manifest, Schema, Site } from "@mannisto/astro-metadata/runtim
384
417
  | `Manifest.url("fi")` | `string` | The URL of the manifest of a locale |
385
418
  | `Manifest.build("fi")` | `object` | The manifest of a locale |
386
419
  | `Manifest.paths()` | `ManifestPath[]` | The generated manifest files |
387
- | `Schema.website()` | `WebSite` | The website, with the site name and URL |
388
- | `Schema.organization()` | `Organization` | The organization: `name`, `url`, `logo`, `sameAs` |
389
- | `Schema.article()` | `Article` | An article: `title`, `description`, `image`, `published`, `modified`, `author`, `url`, `type` |
390
- | `Schema.breadcrumbs()` | `BreadcrumbList` | The path to the current page: `{ name, url }[]` |
420
+ | `Schema.graph(page)` | `Graph` | The structured data that `<Metadata>` renders for a page |
391
421
  | `Schema.stringify(data)` | `string` | JSON that is safe inside a script tag |
392
422
 
393
423
  | Component | Props |
@@ -402,18 +432,6 @@ Import the types from `@mannisto/astro-metadata/runtime`, for example `import ty
402
432
  - **Trailing slash.** The canonical URL and the `hreflang` links follow the `trailingSlash` setting of Astro. With `"ignore"`, `build.format` decides.
403
433
  - **`sharp`.** The favicons use the `sharp` package that Astro installs. If you install Astro without its optional dependencies, install `sharp` yourself.
404
434
  - **Files in `public/`.** A file in `public/` with the same path as a generated favicon replaces the generated file. The integration shows a warning.
405
- - **With @mannisto/astro-i18n.** Give the locale and the hreflang links to `<Metadata>`:
406
-
407
- ```astro
408
- ---
409
- import { Locale } from "@mannisto/astro-i18n/runtime"
410
- import { Metadata } from "@mannisto/astro-metadata/components"
411
-
412
- const { code } = Locale.use(Astro)
413
- ---
414
-
415
- <Metadata locale={code} hreflang={Locale.hreflang(Astro.url, Astro.site!)} />
416
- ```
417
435
 
418
436
  ## Migrate from v1
419
437
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mannisto/astro-metadata",
3
- "version": "2.0.0-alpha.2",
3
+ "version": "2.0.0-alpha.3",
4
4
  "description": "Metadata for Astro: page tags, social cards, favicons, the web app manifest and structured data.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -2,9 +2,10 @@
2
2
  import { localize } from "../lib/config.ts"
3
3
  import type { MetadataImage } from "../lib/config.ts"
4
4
  import { Favicon } from "../lib/favicon.ts"
5
+ import { i18n } from "../lib/i18n.ts"
5
6
  import { Manifest } from "../lib/manifest.ts"
6
7
  import { Schema } from "../lib/schema.ts"
7
- import type { SchemaData } from "../lib/schema.ts"
8
+ import type { SchemaAuthor, SchemaBreadcrumb, SchemaData } from "../lib/schema.ts"
8
9
  import { Site } from "../lib/site.ts"
9
10
  import { withBase } from "../lib/url.ts"
10
11
 
@@ -22,30 +23,51 @@ export type Props = {
22
23
  description?: string
23
24
  /** The social image. Defaults to the image of the config. */
24
25
  image?: MetadataImage
25
- /** The Open Graph type, e.g. "article". Defaults to "website". */
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
+ */
26
30
  type?: string
27
- /** The Twitter (X) handle of the author, e.g. "@acmewriter". */
28
- author?: string
29
- /** The canonical path or URL. Defaults to the current page. */
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
+ */
30
44
  canonical?: string
31
45
  /** Set to false to keep the page out of search results. Defaults to true. */
32
46
  index?: boolean
33
47
  /** Set to false to tell search engines not to follow the links. Defaults to true. */
34
48
  follow?: boolean
35
49
  /**
36
- * The hreflang links: the page in each locale, and `x-default`.
37
- * The output of `Locale.hreflang()` of @mannisto/astro-i18n fits here.
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.
38
52
  */
39
53
  hreflang?: HreflangLink[]
40
- /** JSON-LD structured data, e.g. from the `Schema` builders. */
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
+ */
41
60
  schema?: SchemaData | SchemaData[]
42
- /** The locale of the localized config values. Defaults to `Astro.currentLocale`. */
61
+ /**
62
+ * The locale of the page. Defaults to `Astro.currentLocale`, or the locale of
63
+ * `@mannisto/astro-i18n`.
64
+ */
43
65
  locale?: string
44
66
  }
45
67
 
46
68
  const props = Astro.props
47
69
  const config = Site.config
48
- const locale = props.locale ?? Astro.currentLocale
70
+ const locale = props.locale ?? Astro.currentLocale ?? i18n?.locale(Astro)
49
71
 
50
72
  const siteName = Site.name(locale)
51
73
  const title = Site.title(props.title, locale)
@@ -57,7 +79,10 @@ const image =
57
79
  // Social cards show the page title without the template: og:site_name has the site name
58
80
  const socialTitle = props.title ?? siteName
59
81
 
60
- const canonical = Site.pageUrl(props.canonical ?? Astro.url.pathname)
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) ?? []
61
86
 
62
87
  // Search engines index and follow by default, so the tag is only necessary for other values
63
88
  const index = props.index ?? config.robots?.index ?? true
@@ -70,9 +95,39 @@ const robots = [!index && "noindex", !follow && "nofollow", config.robots?.extra
70
95
  // are specific to Twitter (X) are necessary
71
96
  const card = image ? (config.twitter?.card ?? "summary_large_image") : "summary"
72
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
+
73
121
  const themeColor = config.themeColor
74
122
  const manifest = Manifest.url(locale)
75
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
+
76
131
  const FEED_TYPES = {
77
132
  rss: "application/rss+xml",
78
133
  atom: "application/atom+xml",
@@ -111,9 +166,19 @@ const FEED_TYPES = {
111
166
  </>
112
167
  )
113
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
+ }
114
179
  <meta name="twitter:card" content={card} />
115
180
  {config.twitter?.site && <meta name="twitter:site" content={config.twitter.site} />}
116
- {props.author && <meta name="twitter:creator" content={props.author} />}
181
+ {creator && <meta name="twitter:creator" content={creator} />}
117
182
 
118
183
  {
119
184
  Favicon.links().map((link) => {
@@ -134,12 +199,8 @@ const FEED_TYPES = {
134
199
  })
135
200
  }
136
201
  {
137
- props.hreflang?.map((link) => {
202
+ hreflang.map((link) => {
138
203
  return <link rel="alternate" hreflang={link.hreflang} href={Site.pageUrl(link.href)} />
139
204
  })
140
205
  }
141
- {
142
- props.schema && (
143
- <script is:inline type="application/ld+json" set:html={Schema.stringify(props.schema)} />
144
- )
145
- }
206
+ {schema && <script is:inline type="application/ld+json" set:html={Schema.stringify(schema)} />}
@@ -19,6 +19,7 @@ const SITE_KEYS = [
19
19
  "feeds",
20
20
  "robots",
21
21
  "manifest",
22
+ "schema",
22
23
  ]
23
24
 
24
25
  // The Lit modules of the dev toolbar app
@@ -54,6 +55,129 @@ function findSharp(root) {
54
55
  }
55
56
  }
56
57
 
58
+ // The module that `<Metadata>` reads the locale values from. Without an i18n setup,
59
+ // it has no adapter.
60
+ const I18N_FILE = fileURLToPath(new URL("./lib/i18n.ts", import.meta.url))
61
+
62
+ /**
63
+ * Returns the adapter module for `@mannisto/astro-i18n`. The module imports the runtime of
64
+ * the project by its path, because with pnpm this package can not see it by its name.
65
+ *
66
+ * @param {string} runtime - The absolute path of `@mannisto/astro-i18n/runtime`.
67
+ * @returns {string}
68
+ */
69
+ function astroI18nAdapter(runtime) {
70
+ return `import { Locale } from ${JSON.stringify(runtime)}
71
+
72
+ export const i18n = {
73
+ locale(page) {
74
+ return Locale.fromURL(page.url)
75
+ },
76
+ path(page) {
77
+ return Locale.url(Locale.fromURL(page.url), page.url.pathname)
78
+ },
79
+ alternates(page) {
80
+ const path = page.url.pathname
81
+ const links = Locale.supported.map((code) => {
82
+ return { hreflang: code, href: Locale.url(code, path) }
83
+ })
84
+ return [...links, { hreflang: "x-default", href: Locale.url(Locale.defaultLocale, path) }]
85
+ },
86
+ }
87
+ `
88
+ }
89
+
90
+ /**
91
+ * Returns the adapter module for the `i18n` option of Astro.
92
+ *
93
+ * @param {NonNullable<import("astro").AstroConfig["i18n"]>} i18n
94
+ * @param {string} base - The `base` of the Astro config.
95
+ * @returns {string}
96
+ */
97
+ function nativeI18nAdapter(i18n, base) {
98
+ // A locale is a code, or a path with codes, e.g. { path: "spanish", codes: ["es"] }
99
+ const locales = i18n.locales.map((entry) => {
100
+ return typeof entry === "string"
101
+ ? { path: entry, code: entry }
102
+ : { path: entry.path, code: entry.codes[0] }
103
+ })
104
+
105
+ return `import { getRelativeLocaleUrl } from "astro:i18n"
106
+
107
+ const LOCALES = ${JSON.stringify(locales)}
108
+ const DEFAULT_LOCALE = ${JSON.stringify(i18n.defaultLocale)}
109
+ const BASE = ${JSON.stringify(base.replace(/\/$/, ""))}
110
+
111
+ // Returns the path of the page without the base and the locale prefix, e.g. "about/"
112
+ function pagePath(page) {
113
+ let path = page.url.pathname
114
+ if (BASE && path.startsWith(BASE)) path = path.slice(BASE.length) || "/"
115
+ const parts = path.split("/")
116
+ const prefixed = LOCALES.some((locale) => {
117
+ return locale.path === parts[1]
118
+ })
119
+ return (prefixed ? parts.slice(2) : parts.slice(1)).join("/")
120
+ }
121
+
122
+ export const i18n = {
123
+ locale(page) {
124
+ return page.currentLocale
125
+ },
126
+ path(page) {
127
+ return page.url.pathname
128
+ },
129
+ alternates(page) {
130
+ const path = pagePath(page)
131
+ const links = LOCALES.map((locale) => {
132
+ return { hreflang: locale.code, href: getRelativeLocaleUrl(locale.code, path) }
133
+ })
134
+ return [...links, { hreflang: "x-default", href: getRelativeLocaleUrl(DEFAULT_LOCALE, path) }]
135
+ },
136
+ }
137
+ `
138
+ }
139
+
140
+ /**
141
+ * Returns the adapter module for the i18n setup of the project, or undefined without one.
142
+ *
143
+ * @param {import("astro").AstroConfig} config
144
+ * @param {import("astro").AstroIntegrationLogger} logger
145
+ * @returns {string | undefined}
146
+ */
147
+ function i18nAdapter(config, logger) {
148
+ const astroI18n = config.integrations.some((integration) => {
149
+ return integration.name === "@mannisto/astro-i18n"
150
+ })
151
+ if (astroI18n) {
152
+ try {
153
+ const fromProject = createRequire(fileURLToPath(new URL("./package.json", config.root)))
154
+ return astroI18nAdapter(fromProject.resolve("@mannisto/astro-i18n/runtime"))
155
+ } catch {
156
+ logger.warn("@mannisto/astro-i18n is in the config, but its runtime was not found.")
157
+ return undefined
158
+ }
159
+ }
160
+ if (config.i18n) return nativeI18nAdapter(config.i18n, config.base)
161
+ return undefined
162
+ }
163
+
164
+ /**
165
+ * Returns a Vite plugin that replaces the i18n module of `<Metadata>` with an adapter.
166
+ *
167
+ * @param {string} adapter - The code of the adapter module.
168
+ * @returns {import("vite").Plugin}
169
+ */
170
+ function i18nPlugin(adapter) {
171
+ return {
172
+ name: "astro-metadata:i18n",
173
+ enforce: "pre",
174
+ load(id) {
175
+ if (id.split("?")[0] === I18N_FILE) return adapter
176
+ return undefined
177
+ },
178
+ }
179
+ }
180
+
57
181
  /**
58
182
  * Loads sharp for the image sizes of the build checks, or undefined without sharp.
59
183
  *
@@ -428,9 +552,13 @@ export default function metadata(options = {}) {
428
552
  if (options[key] !== undefined) runtime[key] = options[key]
429
553
  }
430
554
 
555
+ // <Metadata> reads the locale, the page URL and the hreflang links from the i18n setup
556
+ const adapter = i18nAdapter(config, logger)
557
+
431
558
  // The config is plain data, so it goes to the components as a build-time constant
432
559
  updateConfig({
433
560
  vite: {
561
+ plugins: adapter ? [i18nPlugin(adapter)] : [],
434
562
  define: { __ASTRO_METADATA__: JSON.stringify(runtime) },
435
563
  // The dev toolbar app uses Lit. Without this list, Vite finds Lit only when the
436
564
  // app loads, and the page reloads one time.
package/src/lib/config.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { FaviconInfo } from "./favicon.ts"
2
2
  import type { ManifestConfig } from "./manifest.ts"
3
+ import type { SchemaConfig } from "./schema.ts"
3
4
 
4
5
  /**
5
6
  * A text value: one string for all locales, or one string for each locale.
@@ -69,6 +70,11 @@ export type MetadataConfig = {
69
70
  }
70
71
  /** The web app manifest: an object to generate it, or the path of your own manifest. */
71
72
  manifest?: ManifestConfig | string
73
+ /**
74
+ * The structured data of the site. Each page gets the `WebSite` and its publisher:
75
+ * by default an organization with the site name. Set to false to turn it off.
76
+ */
77
+ schema?: SchemaConfig
72
78
  }
73
79
 
74
80
  /**
@@ -0,0 +1,22 @@
1
+ /** The values of the Astro page that the i18n adapter reads. */
2
+ export type I18nPage = {
3
+ url: URL
4
+ currentLocale?: string
5
+ }
6
+
7
+ /**
8
+ * Reads the locale values of a page from the i18n setup of the project:
9
+ * `@mannisto/astro-i18n`, or the `i18n` option of Astro.
10
+ */
11
+ export type I18nAdapter = {
12
+ /** The locale of the page, e.g. "fi". */
13
+ locale(page: I18nPage): string | undefined
14
+ /** The public path of the page, e.g. "/about" for the default locale without a prefix. */
15
+ path(page: I18nPage): string
16
+ /** The path of the page in each locale, and `x-default`. */
17
+ alternates(page: I18nPage): { hreflang: string; href: string }[]
18
+ }
19
+
20
+ // Without an i18n setup, there is no adapter. The integration replaces this module with
21
+ // an adapter for `@mannisto/astro-i18n` or for the `i18n` option of Astro.
22
+ export const i18n: I18nAdapter | undefined = undefined
package/src/lib/schema.ts CHANGED
@@ -1,69 +1,74 @@
1
- import type {
2
- Article,
3
- BreadcrumbList,
4
- Graph,
5
- Organization,
6
- Person,
7
- Thing,
8
- WebSite,
9
- WithContext,
10
- } from "schema-dts"
1
+ import type { Graph, Thing, WithContext } from "schema-dts"
11
2
 
12
3
  import { getConfig, localize } from "./config.ts"
4
+ import type { Localized } from "./config.ts"
13
5
  import { url } from "./url.ts"
14
6
 
15
7
  /**
16
- * The data of a JSON-LD script. The schema.org types give the editor completion,
17
- * and other objects are also accepted.
8
+ * A structured data item of your own, e.g. a `Product` or an `Event`. The schema.org types
9
+ * give the editor completion, and other objects are also accepted. `@context` is not
10
+ * necessary: the items go into one graph with the context.
18
11
  */
19
- export type SchemaData = WithContext<Thing> | Graph | Record<string, unknown>
12
+ export type SchemaData = Thing | WithContext<Thing> | Record<string, unknown>
20
13
 
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 = {
14
+ /**
15
+ * The organization or the person behind the site. It is the publisher of the site and of
16
+ * the articles.
17
+ */
18
+ export type SchemaPublisher = {
19
+ /** Defaults to "Organization". */
20
+ type?: "Organization" | "Person"
33
21
  /** 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". */
22
+ name?: Localized
23
+ /** The path or URL of the logo of an organization, e.g. "/logo.png". */
38
24
  logo?: string
39
- /** The profiles of the organization on other sites. */
25
+ /** The profiles on other sites, e.g. the GitHub or LinkedIn page. */
40
26
  sameAs?: string[]
41
- /** The locale of the localized config values, e.g. `Astro.currentLocale`. */
42
- locale?: string
43
27
  }
44
28
 
45
- export type SchemaAuthor = string | { name: string; url?: string }
29
+ /** The `schema` option: the structured data of the site. Set to false to turn it off. */
30
+ export type SchemaConfig =
31
+ | false
32
+ | {
33
+ publisher?: SchemaPublisher
34
+ }
35
+
36
+ /** The author of an article: a name, or a name with a profile URL and a Twitter (X) handle. */
37
+ export type SchemaAuthor =
38
+ | string
39
+ | {
40
+ name: string
41
+ /** The path or URL of the profile page of the author, e.g. "/about". */
42
+ url?: string
43
+ /** The Twitter (X) handle of the author, e.g. "@acmewriter". */
44
+ twitter?: string
45
+ }
46
+
47
+ /** A step in the breadcrumbs: from the home page to the current page. */
48
+ export type SchemaBreadcrumb = {
49
+ name: string
50
+ /** The path or URL of the page, e.g. "/exhibitions". */
51
+ url: string
52
+ }
46
53
 
47
- export type SchemaArticle = {
48
- /** Defaults to "Article". */
49
- type?: "Article" | "BlogPosting" | "NewsArticle"
50
- title: string
54
+ /** The values of a page that the structured data uses. `<Metadata>` gives them. */
55
+ export type SchemaPage = {
56
+ /** The absolute URL of the page. */
57
+ url: string
58
+ /** The title of the page without the title template. */
59
+ title?: string
51
60
  description?: string
52
- /** The paths or URLs of the images. */
53
- image?: string | string[]
61
+ /** The absolute URL of the social image. */
62
+ image?: string
63
+ locale?: string
64
+ /** The Open Graph type: "article" adds an `Article`. */
65
+ type?: string
54
66
  published?: Date | string
55
67
  modified?: Date | string
56
68
  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
69
+ breadcrumbs?: SchemaBreadcrumb[]
70
+ /** Your own items. */
71
+ items?: SchemaData[]
67
72
  }
68
73
 
69
74
  /**
@@ -74,13 +79,19 @@ function isoDate(value: Date | string | undefined): string | undefined {
74
79
  return value.toISOString()
75
80
  }
76
81
 
82
+ /**
83
+ * Returns the authors as a list.
84
+ */
85
+ function authorList(author: SchemaPage["author"]): SchemaAuthor[] {
86
+ if (author === undefined) return []
87
+ return Array.isArray(author) ? author : [author]
88
+ }
89
+
77
90
  /**
78
91
  * Returns an author as a schema.org person.
79
92
  */
80
- function person(author: SchemaAuthor): Person {
81
- if (typeof author === "string") {
82
- return { "@type": "Person", name: author }
83
- }
93
+ function person(author: SchemaAuthor): Record<string, unknown> {
94
+ if (typeof author === "string") return { "@type": "Person", name: author }
84
95
  return {
85
96
  "@type": "Person",
86
97
  name: author.name,
@@ -89,113 +100,111 @@ function person(author: SchemaAuthor): Person {
89
100
  }
90
101
 
91
102
  /**
92
- * Returns the `WebSite` schema, with the name and the URL of the site.
93
- *
94
- * @example Schema.website({ locale: Astro.currentLocale })
103
+ * Returns the publisher of the site, or undefined without a name.
95
104
  */
96
- function website(options: SchemaWebsite = {}): WithContext<WebSite> {
105
+ function publisher(page: SchemaPage, id: string): Record<string, unknown> | undefined {
97
106
  const config = getConfig()
107
+ const options = (config.schema || undefined)?.publisher ?? {}
108
+ const name = localize(options.name, page.locale) ?? localize(config.siteName, page.locale)
109
+ if (!name) return undefined
110
+
111
+ const type = options.type ?? "Organization"
98
112
  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,
113
+ "@type": type,
114
+ "@id": id,
115
+ name,
116
+ url: url("/"),
117
+ logo: type === "Organization" && options.logo ? url(options.logo) : undefined,
118
+ sameAs: options.sameAs,
105
119
  }
106
120
  }
107
121
 
108
122
  /**
109
- * Returns the `Organization` schema of the site owner.
110
- *
111
- * @example Schema.organization({ logo: "/logo.png" })
123
+ * Returns an item without `@context`: the graph has the context.
112
124
  */
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
- }
125
+ function withoutContext(item: SchemaData): Record<string, unknown> {
126
+ const copy = { ...(item as Record<string, unknown>) }
127
+ delete copy["@context"]
128
+ return copy
123
129
  }
124
130
 
125
131
  /**
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" })
132
+ * Returns the structured data of a page as one graph:
133
+ * - the `WebSite` and its publisher, from the config,
134
+ * - an `Article` for a page with the type "article",
135
+ * - a `BreadcrumbList` for a page with breadcrumbs,
136
+ * - and your own items.
137
+ * The items link to each other with `@id`. Returns undefined without items.
129
138
  */
130
- function article(options: SchemaArticle): WithContext<Article> {
139
+ function graph(page: SchemaPage): Graph | undefined {
131
140
  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]
141
+ const site = url("/")
142
+ const items: Record<string, unknown>[] = []
143
+
144
+ const owner = config.schema === false ? undefined : publisher(page, `${site}#publisher`)
145
+ const ownerLink = owner ? { "@id": owner["@id"] } : undefined
146
+
147
+ if (config.schema !== false && owner) {
148
+ items.push({
149
+ "@type": "WebSite",
150
+ "@id": `${site}#website`,
151
+ name: owner.name,
152
+ description: localize(config.description, page.locale),
153
+ url: site,
154
+ inLanguage: page.locale,
155
+ publisher: ownerLink,
156
+ })
157
+ items.push(owner)
158
+ }
135
159
 
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
- }
160
+ if (page.type === "article") {
161
+ const authors = authorList(page.author)
162
+ items.push({
163
+ "@type": "Article",
164
+ "@id": `${page.url}#article`,
165
+ headline: page.title,
166
+ description: page.description,
167
+ image: page.image ? [page.image] : undefined,
168
+ datePublished: isoDate(page.published),
169
+ dateModified: isoDate(page.modified),
170
+ author: authors.length > 0 ? authors.map(person) : undefined,
171
+ publisher: ownerLink,
172
+ mainEntityOfPage: page.url,
173
+ inLanguage: page.locale,
174
+ })
175
+ }
162
176
 
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
- }),
177
+ if (page.breadcrumbs && page.breadcrumbs.length > 0) {
178
+ items.push({
179
+ "@type": "BreadcrumbList",
180
+ "@id": `${page.url}#breadcrumbs`,
181
+ itemListElement: page.breadcrumbs.map((step, index) => {
182
+ return { "@type": "ListItem", position: index + 1, name: step.name, item: url(step.url) }
183
+ }),
184
+ })
180
185
  }
186
+
187
+ for (const item of page.items ?? []) {
188
+ items.push(withoutContext(item))
189
+ }
190
+
191
+ if (items.length === 0) return undefined
192
+ return { "@context": "https://schema.org", "@graph": items } as unknown as Graph
181
193
  }
182
194
 
183
195
  /**
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>`.
196
+ * The structured data of the pages. `<Metadata>` renders it: the site from the config,
197
+ * and the article, the breadcrumbs and your own items of a page.
186
198
  */
187
199
  export const Schema = {
188
- website,
189
- organization,
190
- article,
191
- breadcrumbs,
200
+ graph,
192
201
 
193
202
  /**
194
- * Returns the data as JSON that is safe inside a script tag.
203
+ * Returns the data as JSON that is safe inside a script tag. Empty values are removed.
195
204
  *
196
205
  * @example <script type="application/ld+json" set:html={Schema.stringify(data)} />
197
206
  */
198
- stringify(data: SchemaData | SchemaData[]): string {
207
+ stringify(data: unknown): string {
199
208
  // Escape the characters that can end the script tag or break the JavaScript parser.
200
209
  // A value such as "</script>" in the schema then stays inside the JSON.
201
210
  return JSON.stringify(data)
package/src/lib/site.ts CHANGED
@@ -21,6 +21,7 @@ export const Site = {
21
21
  feeds: config.feeds,
22
22
  robots: config.robots,
23
23
  manifest: config.manifest,
24
+ schema: config.schema,
24
25
  }
25
26
  },
26
27
 
package/src/runtime.ts CHANGED
@@ -5,12 +5,12 @@ export { Favicon } from "./lib/favicon.ts"
5
5
  export type { ManifestConfig, ManifestPath } from "./lib/manifest.ts"
6
6
  export { Manifest } from "./lib/manifest.ts"
7
7
  export type {
8
- SchemaArticle,
9
8
  SchemaAuthor,
10
9
  SchemaBreadcrumb,
10
+ SchemaConfig,
11
11
  SchemaData,
12
- SchemaOrganization,
13
- SchemaWebsite,
12
+ SchemaPage,
13
+ SchemaPublisher,
14
14
  } from "./lib/schema.ts"
15
15
  export { Schema } from "./lib/schema.ts"
16
16
  export { Site } from "./lib/site.ts"
@@ -109,7 +109,11 @@ function jsonLd(): JsonLd[] {
109
109
  const source = script.textContent ?? ""
110
110
  try {
111
111
  const data = JSON.parse(source)
112
- const items = Array.isArray(data) ? data : [data]
112
+ // The items of a list, or of a graph
113
+ const list = Array.isArray(data) ? data : [data]
114
+ const items = list.flatMap((item) => {
115
+ return Array.isArray(item?.["@graph"]) ? item["@graph"] : [item]
116
+ })
113
117
  const types = items.map((item) => {
114
118
  return String(item?.["@type"] ?? "?")
115
119
  })
@@ -31,11 +31,6 @@ tr {
31
31
  background: var(--zinc-800);
32
32
  }
33
33
 
34
- /* The row of a problem after a click on the problem */
35
- &.highlight :is(th, td) {
36
- animation: highlight 1.6s ease-out;
37
- }
38
-
39
34
  /* The child rows are indented under their row */
40
35
  &.child th {
41
36
  padding-left: calc(var(--space-400) + var(--space-400));
@@ -116,9 +111,3 @@ td {
116
111
  transform: rotate(90deg);
117
112
  }
118
113
  }
119
-
120
- @keyframes highlight {
121
- from {
122
- background: var(--blue-900);
123
- }
124
- }
@@ -34,42 +34,42 @@ export class Table extends LitElement {
34
34
  static properties = {
35
35
  rows: { attribute: false },
36
36
  opened: { state: true },
37
- highlighted: { state: true },
38
37
  }
39
38
  static styles = unsafeCSS(styles)
40
39
 
41
40
  declare rows: TableRow[]
42
41
  /** The indexes of the open rows. */
43
42
  declare opened: Set<number>
44
- /** The key of the marked row. */
45
- declare highlighted: string | undefined
46
43
 
47
44
  constructor() {
48
45
  super()
49
46
  this.rows = []
50
47
  this.opened = new Set()
51
- this.highlighted = undefined
52
48
  }
53
49
 
54
50
  /**
55
- * Scrolls to the row with a key and marks it for a moment.
51
+ * Scrolls to the row with a key and marks it for a moment. Each call starts the mark again,
52
+ * also while the mark of an earlier call still shows.
56
53
  *
57
54
  * @returns False when the table has no row with the key.
58
55
  */
59
56
  async highlight(key: string): Promise<boolean> {
60
- const found = this.rows.some((row) => {
61
- return row.key === key
62
- })
63
- if (!found) return false
64
-
65
- // A new value restarts the animation of a row that is already marked
66
- this.highlighted = undefined
67
- await this.updateComplete
68
- this.highlighted = key
69
57
  await this.updateComplete
70
- this.renderRoot
71
- .querySelector("tr.highlight")
72
- ?.scrollIntoView({ block: "center", behavior: "smooth" })
58
+ const row = [...this.renderRoot.querySelectorAll<HTMLElement>("tr[data-key]")].find(
59
+ (element) => {
60
+ return element.dataset.key === key
61
+ }
62
+ )
63
+ if (!row) return false
64
+
65
+ row.scrollIntoView({ block: "center", behavior: "smooth" })
66
+ const color = getComputedStyle(this).getPropertyValue("--blue-900")
67
+ for (const cell of row.querySelectorAll("th, td")) {
68
+ cell.animate([{ background: color }, { background: "transparent" }], {
69
+ duration: 1600,
70
+ easing: "ease-out",
71
+ })
72
+ }
73
73
  return true
74
74
  }
75
75
 
@@ -108,9 +108,7 @@ export class Table extends LitElement {
108
108
 
109
109
  renderRow(row: TableRow, label: unknown, notes: boolean, child: boolean) {
110
110
  return html`
111
- <tr
112
- class="${child ? "child" : ""} ${row.key && row.key === this.highlighted ? "highlight" : ""}"
113
- >
111
+ <tr class=${child ? "child" : ""} data-key=${row.key ?? nothing}>
114
112
  <th>${label}</th>
115
113
  <td>${this.renderValue(row)}</td>
116
114
  ${