@mannisto/astro-metadata 2.0.0-alpha.1 → 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 +58 -40
- package/package.json +1 -1
- package/src/components/Metadata.astro +79 -18
- package/src/integration.js +128 -0
- package/src/lib/config.ts +6 -0
- package/src/lib/i18n.ts +22 -0
- package/src/lib/rules.js +43 -16
- package/src/lib/schema.ts +143 -134
- package/src/lib/site.ts +1 -0
- package/src/runtime.ts +3 -3
- package/src/toolbar/data.ts +5 -1
- package/src/toolbar/ui/table/table.css +0 -11
- package/src/toolbar/ui/table/table.ts +18 -20
- package/src/toolbar/views/metadata-panel.css +5 -1
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
|
|
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
|
|
147
|
-
| `
|
|
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` |
|
|
151
|
-
| `
|
|
152
|
-
| `
|
|
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
|
-
|
|
256
|
+
`<Metadata>` renders the structured data (JSON-LD) of each page by itself. You only give the values of the page.
|
|
244
257
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
{
|
|
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
|
-
|
|
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)} />}
|
package/src/integration.js
CHANGED
|
@@ -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
|
/**
|
package/src/lib/i18n.ts
ADDED
|
@@ -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/rules.js
CHANGED
|
@@ -712,8 +712,37 @@ function indexedPages(pages) {
|
|
|
712
712
|
})
|
|
713
713
|
}
|
|
714
714
|
|
|
715
|
+
/**
|
|
716
|
+
* Returns true for a page with an hreflang link to the canonical URL of another page.
|
|
717
|
+
*
|
|
718
|
+
* @param {import("./rules").SitePage} page
|
|
719
|
+
* @param {import("./rules").SitePage} other
|
|
720
|
+
* @returns {boolean}
|
|
721
|
+
*/
|
|
722
|
+
function linksTo(page, other) {
|
|
723
|
+
const url = findLink(other.head, "canonical")
|
|
724
|
+
if (!url) return false
|
|
725
|
+
return hreflangLinks(page.head).some((link) => {
|
|
726
|
+
return link.href === url
|
|
727
|
+
})
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Returns true for two pages that are versions of one page in other locales:
|
|
732
|
+
* one of the pages has an hreflang link to the other.
|
|
733
|
+
*
|
|
734
|
+
* @param {import("./rules").SitePage} a
|
|
735
|
+
* @param {import("./rules").SitePage} b
|
|
736
|
+
* @returns {boolean}
|
|
737
|
+
*/
|
|
738
|
+
function isLocaleVersion(a, b) {
|
|
739
|
+
return linksTo(a, b) || linksTo(b, a)
|
|
740
|
+
}
|
|
741
|
+
|
|
715
742
|
/**
|
|
716
743
|
* Returns a problem for each page with a value that other pages also have.
|
|
744
|
+
* The versions of a page in other locales can have the same value, e.g. the name of
|
|
745
|
+
* an artwork in its title, so they do not count.
|
|
717
746
|
*
|
|
718
747
|
* @param {import("./rules").SitePage[]} pages
|
|
719
748
|
* @param {(page: import("./rules").SitePage) => string | undefined} valueOf
|
|
@@ -721,29 +750,27 @@ function indexedPages(pages) {
|
|
|
721
750
|
* @returns {import("./rules").SiteProblem[]}
|
|
722
751
|
*/
|
|
723
752
|
function duplicates(pages, valueOf, name) {
|
|
724
|
-
/** @type {Map<string,
|
|
753
|
+
/** @type {Map<string, import("./rules").SitePage[]>} */
|
|
725
754
|
const groups = new Map()
|
|
726
755
|
for (const page of indexedPages(pages)) {
|
|
727
756
|
const value = valueOf(page)
|
|
728
|
-
if (value) groups.set(value, [...(groups.get(value) ?? []), page
|
|
757
|
+
if (value) groups.set(value, [...(groups.get(value) ?? []), page])
|
|
729
758
|
}
|
|
730
759
|
|
|
731
|
-
return [...groups.values()]
|
|
732
|
-
.
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
.flatMap((paths) => {
|
|
736
|
-
return paths.map((path) => {
|
|
737
|
-
const others = paths.filter((other) => {
|
|
738
|
-
return other !== path
|
|
739
|
-
})
|
|
740
|
-
const list =
|
|
741
|
-
others.length === 1
|
|
742
|
-
? others[0]
|
|
743
|
-
: `${others[0]} and ${count(others.length - 1, "other page")}`
|
|
744
|
-
return { path, message: `The ${name} is the same as on ${list}.` }
|
|
760
|
+
return [...groups.values()].flatMap((group) => {
|
|
761
|
+
return group.flatMap((page) => {
|
|
762
|
+
const others = group.filter((other) => {
|
|
763
|
+
return other !== page && !isLocaleVersion(page, other)
|
|
745
764
|
})
|
|
765
|
+
if (others.length === 0) return []
|
|
766
|
+
|
|
767
|
+
const list =
|
|
768
|
+
others.length === 1
|
|
769
|
+
? others[0].path
|
|
770
|
+
: `${others[0].path} and ${count(others.length - 1, "other page")}`
|
|
771
|
+
return [{ path: page.path, message: `The ${name} is the same as on ${list}.` }]
|
|
746
772
|
})
|
|
773
|
+
})
|
|
747
774
|
}
|
|
748
775
|
|
|
749
776
|
/**
|
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
|
-
*
|
|
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> |
|
|
12
|
+
export type SchemaData = Thing | WithContext<Thing> | Record<string, unknown>
|
|
20
13
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
/** Defaults to
|
|
27
|
-
|
|
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?:
|
|
35
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
53
|
-
image?: 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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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):
|
|
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
|
|
93
|
-
*
|
|
94
|
-
* @example Schema.website({ locale: Astro.currentLocale })
|
|
103
|
+
* Returns the publisher of the site, or undefined without a name.
|
|
95
104
|
*/
|
|
96
|
-
function
|
|
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
|
-
"@
|
|
100
|
-
"@
|
|
101
|
-
name
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
110
|
-
*
|
|
111
|
-
* @example Schema.organization({ logo: "/logo.png" })
|
|
123
|
+
* Returns an item without `@context`: the graph has the context.
|
|
112
124
|
*/
|
|
113
|
-
function
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
127
|
-
*
|
|
128
|
-
*
|
|
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
|
|
139
|
+
function graph(page: SchemaPage): Graph | undefined {
|
|
131
140
|
const config = getConfig()
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
*
|
|
185
|
-
*
|
|
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
|
-
|
|
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:
|
|
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
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
|
-
|
|
13
|
-
|
|
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"
|
package/src/toolbar/data.ts
CHANGED
|
@@ -109,7 +109,11 @@ function jsonLd(): JsonLd[] {
|
|
|
109
109
|
const source = script.textContent ?? ""
|
|
110
110
|
try {
|
|
111
111
|
const data = JSON.parse(source)
|
|
112
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
${
|
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
box-sizing: border-box;
|
|
3
3
|
}
|
|
4
4
|
|
|
5
|
+
/*
|
|
6
|
+
* The z-index of the Audit app window of Astro: above the site, e.g. a fixed navigation,
|
|
7
|
+
* and under the toolbar bar (2000000010). The toolbar element has no stacking context.
|
|
8
|
+
*/
|
|
5
9
|
.panel {
|
|
6
10
|
position: fixed;
|
|
7
11
|
inset: 0 0 0 auto;
|
|
8
|
-
z-index:
|
|
12
|
+
z-index: 2000000009;
|
|
9
13
|
display: flex;
|
|
10
14
|
flex-direction: column;
|
|
11
15
|
width: var(--panel-width, 480px);
|