@getvitops/astro 0.8.0 → 1.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.
package/package.json CHANGED
@@ -1,11 +1,13 @@
1
1
  {
2
2
  "name": "@getvitops/astro",
3
- "version": "0.8.0",
3
+ "version": "1.0.0",
4
4
  "description": "Astro integration for the Vitops design system — favicons/PWA, web-component scripts, and CSS, via a getvitops() integration + <Head /> component.",
5
5
  "license": "MIT",
6
6
  "files": [
7
7
  "dist",
8
8
  "src/components/Head.astro",
9
+ "src/components/Seo.astro",
10
+ "src/seo.ts",
9
11
  "src/components/Subgrid.astro",
10
12
  "src/components/Cards.astro",
11
13
  "src/components/NodeRenderer.astro",
@@ -25,6 +27,7 @@
25
27
  "import": "./dist/index.mjs"
26
28
  },
27
29
  "./Head.astro": "./src/components/Head.astro",
30
+ "./Seo.astro": "./src/components/Seo.astro",
28
31
  "./components/Subgrid.astro": "./src/components/Subgrid.astro",
29
32
  "./components/Cards.astro": "./src/components/Cards.astro",
30
33
  "./components/NodeRenderer.astro": "./src/components/NodeRenderer.astro",
@@ -39,24 +42,37 @@
39
42
  "access": "public"
40
43
  },
41
44
  "dependencies": {
42
- "@tailwindcss/vite": "^4.3.3",
43
- "tailwindcss": "^4.3.3",
44
- "@getvitops/core": "0.8.0",
45
- "@getvitops/vite": "0.8.0",
46
- "@getvitops/generator": "0.8.0",
47
- "@getvitops/utils": "0.8.0"
45
+ "@getvitops/core": "1.0.0",
46
+ "@getvitops/utils": "1.0.0",
47
+ "@getvitops/generator": "1.0.0",
48
+ "@getvitops/vite": "1.0.0"
48
49
  },
49
50
  "devDependencies": {
51
+ "@astrojs/sitemap": "^3.7.3",
52
+ "@tailwindcss/vite": "^4.3.3",
50
53
  "astro": "^7.1.2",
54
+ "tailwindcss": "^4.3.3",
51
55
  "vitest": "^4.1.9"
52
56
  },
53
57
  "peerDependencies": {
58
+ "@astrojs/sitemap": ">=3.6",
59
+ "@tailwindcss/vite": ">=4",
54
60
  "astro": ">=7",
55
- "astro-icon": ">=1"
61
+ "astro-icon": ">=1",
62
+ "tailwindcss": ">=4"
56
63
  },
57
64
  "peerDependenciesMeta": {
65
+ "@astrojs/sitemap": {
66
+ "optional": true
67
+ },
68
+ "@tailwindcss/vite": {
69
+ "optional": true
70
+ },
58
71
  "astro-icon": {
59
72
  "optional": true
73
+ },
74
+ "tailwindcss": {
75
+ "optional": true
60
76
  }
61
77
  }
62
78
  }
@@ -1,32 +1,34 @@
1
1
  ---
2
+ /**
3
+ * Cards — a `Subgrid` whose items are `card`s. The child's own class and style
4
+ * ride along, so `<article class="bg-surface-muted">` becomes `<li class="card bg-surface-muted">`.
5
+ *
6
+ * The class has to be put on the `<li>` we hand to Subgrid rather than written
7
+ * into the slotted element: `serialize()` returns a node's *inner* HTML, so the
8
+ * element carrying the attribute is discarded on the way through.
9
+ */
2
10
  import type { HTMLAttributes } from 'astro/types';
3
11
  import Subgrid from './Subgrid.astro';
4
- import { parseRenderedSlots, serialize } from '@getvitops/utils';
12
+ import { parseRenderedSlots, getAttribute, serialize } from '@getvitops/utils';
5
13
 
6
14
  export interface Props extends HTMLAttributes<'ul'> {}
7
15
 
8
16
  const { class: className, ...rest } = Astro.props;
9
17
 
10
18
  const nodes = parseRenderedSlots(await Astro.slots.render('default'));
11
- const slots = nodes.filter(node => 'tagName' in node).map(node => {
12
- const cls = node.attrs?.find(attr => attr.name === 'class');
13
- const value = `card${cls ? ' ' + cls.value : ''}`;
14
- if (cls) {
15
- cls.value = value;
16
- } else {
17
- node.attrs.push({ name: 'class', value });
18
- }
19
-
20
- return { html: serialize(node), attrs: node.attrs };
21
- });
19
+ const slots = nodes.filter(node => 'tagName' in node).map(node => ({
20
+ html: serialize(node),
21
+ class: getAttribute(node, 'class'),
22
+ style: getAttribute(node, 'style'),
23
+ }));
22
24
  ---
23
25
 
24
26
  <Subgrid
25
27
  role="list"
26
- class:list={["no-list-markers", className]}
28
+ class={className}
27
29
  {...rest}
28
30
  >
29
31
  {slots.map((slot) => (
30
- <li class="card-wrapper" style={slot.attrs?.find(attr => attr.name === 'style')?.value} set:html={slot.html}></li>
32
+ <li class:list={["card", slot.class]} style={slot.style} set:html={slot.html}></li>
31
33
  ))}
32
34
  </Subgrid>
@@ -49,8 +49,8 @@ const { class: dialogAttrClass, style: dialogAttrStyle, ...dialogRestAttrs } = a
49
49
  aria-label={trigger?.label || 'Open drawer'}
50
50
  {...triggerRestAttrs}
51
51
  >
52
- <span class={trigger?.icon ? 'sr-only' : ''}>{trigger?.label || 'Open'}</span>
53
- {trigger?.icon && <Icon name={iconResolver(trigger.icon)} class="not-sr-only" />}
52
+ <span class:list={[trigger?.icon && 'visually-hidden']}>{trigger?.label || 'Open'}</span>
53
+ {trigger?.icon && <Icon name={iconResolver(trigger.icon)} />}
54
54
  </button>
55
55
  </slot>
56
56
 
@@ -63,3 +63,20 @@ const { class: dialogAttrClass, style: dialogAttrStyle, ...dialogRestAttrs } = a
63
63
  >
64
64
  <slot />
65
65
  </dialog>
66
+
67
+ <style>
68
+ /* Icon-only triggers keep their label for screen readers. Component-scoped
69
+ rather than the framework's `sr-only`: that one is in the generator's
70
+ TW_CLASH list, so the tailwind format ships no rule for it. */
71
+ .visually-hidden {
72
+ position: absolute;
73
+ inline-size: 1px;
74
+ block-size: 1px;
75
+ padding: 0;
76
+ margin: -1px;
77
+ overflow: hidden;
78
+ clip-path: inset(50%);
79
+ white-space: nowrap;
80
+ border: 0;
81
+ }
82
+ </style>
@@ -41,6 +41,9 @@ const THEME_SCRIPT =
41
41
  ))}
42
42
  {favicons && head.themeColor && <meta name="theme-color" content={head.themeColor} />}
43
43
 
44
+ {/* Set by getvitops({ sitemap }); null when no sitemap was registered. */}
45
+ {head.sitemap && <link rel="sitemap" type="application/xml" href={head.sitemap} />}
46
+
44
47
  {webComponents && (
45
48
  <>
46
49
  {/*
@@ -48,8 +48,8 @@ const { class: triggerAttrClass, ...triggerRestAttrs } = trigger?.attrs ?? {};
48
48
  aria-label={trigger?.label || 'Open'}
49
49
  {...triggerRestAttrs}
50
50
  >
51
- <span class={trigger?.icon ? 'sr-only' : ''}>{trigger?.label || 'Open'}</span>
52
- {trigger?.icon && <Icon name={iconResolver(trigger.icon)} class="not-sr-only" />}
51
+ <span class:list={[trigger?.icon && 'visually-hidden']}>{trigger?.label || 'Open'}</span>
52
+ {trigger?.icon && <Icon name={iconResolver(trigger.icon)} />}
53
53
  </button>
54
54
  </slot>
55
55
 
@@ -87,4 +87,19 @@ const { class: triggerAttrClass, ...triggerRestAttrs } = trigger?.attrs ?? {};
87
87
  font-family: inherit;
88
88
  color: inherit;
89
89
  }
90
+
91
+ /* Icon-only triggers keep their label for screen readers. Component-scoped
92
+ rather than the framework's `sr-only`: that one is in the generator's
93
+ TW_CLASH list, so the tailwind format ships no rule for it. */
94
+ .visually-hidden {
95
+ position: absolute;
96
+ inline-size: 1px;
97
+ block-size: 1px;
98
+ padding: 0;
99
+ margin: -1px;
100
+ overflow: hidden;
101
+ clip-path: inset(50%);
102
+ white-space: nowrap;
103
+ border: 0;
104
+ }
90
105
  </style>
@@ -0,0 +1,68 @@
1
+ ---
2
+ /**
3
+ * <Seo /> — the page's SEO <head> tags. Place inside your layout's <head>,
4
+ * alongside <Head /> (which owns favicons, theme-color and the web-component
5
+ * runtime — the two don't overlap).
6
+ *
7
+ * Site-level defaults come from `getvitops({ seo: … })` via the
8
+ * `virtual:getvitops/head` module; the props here override them per page.
9
+ *
10
+ * It owns <title> and <meta name="description">, so **remove them from your
11
+ * layout** when you adopt it. That is not tidiness: this component already
12
+ * computes the resolved title for `og:title`/`twitter:title`, and the previous
13
+ * version of it emitted those while leaving <title> to the layout — so the two
14
+ * drifted. Whoever computes the title emits it.
15
+ *
16
+ * It emits no JSON-LD. The structured-data components in `../schemas/` take
17
+ * entity data; this takes page data. Compose them:
18
+ * <Seo title={title} description={description} />
19
+ * <Organization {...org} />
20
+ *
21
+ * On an EmDash site use <EmDashHead> instead — it covers the same tags from the
22
+ * CMS. Using both double-emits every og:/twitter: tag.
23
+ */
24
+ // @ts-ignore — virtual module provided by the getvitops() integration at build time
25
+ import head from 'virtual:getvitops/head';
26
+ import { resolveSeo, type SeoProps } from '../seo.ts';
27
+
28
+ export type Props = SeoProps;
29
+
30
+ // `Astro.site` is `URL | undefined`; `head.site` is the `config.site` string the
31
+ // integration captured. Same value, different types — the fallback is there so a
32
+ // render context without `Astro.site` still resolves absolute URLs.
33
+ const site: URL | undefined = Astro.site ?? (head.site ? new URL(head.site) : undefined);
34
+
35
+ /*
36
+ * `Astro.url.pathname` already includes `base`, and being an absolute path it
37
+ * replaces any path in `site` — so `site: 'https://x.com'` + `base: '/docs'`
38
+ * yields `https://x.com/docs/page/`. Do not prepend `base` by hand; that
39
+ * double-prefixes.
40
+ *
41
+ * The pathname follows whatever `trailingSlash`/`build.format` produced. Under
42
+ * the default `trailingSlash: 'ignore'` dev and build can disagree, so pin
43
+ * `'always'` or `'never'` if you need a byte-stable canonical. We deliberately
44
+ * don't normalise it here — rewriting the URL Astro actually serves is how you
45
+ * end up with a canonical that redirects.
46
+ */
47
+ const seo = resolveSeo(head.seo ?? {}, Astro.props, {
48
+ site,
49
+ pathname: Astro.url.pathname,
50
+ });
51
+ ---
52
+
53
+ {seo.title && <title set:text={seo.title} />}
54
+ {seo.description && <meta name="description" content={seo.description} />}
55
+ {seo.canonical && <link rel="canonical" href={seo.canonical} />}
56
+ {/* Omitted when it would only restate `index, follow`, which crawlers assume. */}
57
+ {seo.robots && <meta name="robots" content={seo.robots} />}
58
+
59
+ {seo.openGraph.map((t) => <meta property={t.property} content={t.content} />)}
60
+ {seo.twitter.map((t) => <meta name={t.name} content={t.content} />)}
61
+
62
+ {/*
63
+ Explicit only — see SeoProps.alternates. Nothing is inferred from a locale list,
64
+ because only the page knows where its own translations live.
65
+ */}
66
+ {seo.alternates.map((a) => <link rel="alternate" hreflang={a.hreflang} href={a.href} />)}
67
+
68
+ {seo.verification.map((t) => <meta name={t.name} content={t.content} />)}
@@ -1,4 +1,19 @@
1
1
  ---
2
+ /**
3
+ * Subgrid — re-emits slotted children as `<li>` items of a `.subgrid` grid, so
4
+ * the tranches inside each one align on shared row lines. Every declaration
5
+ * lives in the framework's `subgrid` pattern (patterns/subgrid.css), which is
6
+ * emitted in all three output formats; this file is markup only.
7
+ *
8
+ * Nothing here may reach for a Tailwind utility: Tailwind doesn't scan
9
+ * node_modules, so a class only it can emit is silently never generated — and
10
+ * classes in the generator's TW_CLASH list are stripped from the framework's
11
+ * own tailwind bundle by design.
12
+ *
13
+ * Size it from the consumer's own CSS, which keeps media-query overrides
14
+ * working: `--subgrid-cols`, `--subgrid-row-span`, `--subgrid-gap`,
15
+ * `--subgrid-row-gap`. `--num-items` is set here for nth-child-style maths.
16
+ */
2
17
  import type { HTMLAttributes } from 'astro/types';
3
18
  import { parseRenderedSlots, getAttribute, serialize, styleList } from '@getvitops/utils';
4
19
 
@@ -10,23 +25,8 @@ const nodes = parseRenderedSlots(await Astro.slots.render('default'));
10
25
  const slots = nodes.filter(node => 'tagName' in node).map(node => ({ html: serialize(node), attrs: node.attrs }));
11
26
  ---
12
27
 
13
- <style>
14
- ul {
15
- grid-template-columns: repeat(var(--cols, var(--_cols, 1)), minmax(0, 1fr));
16
- }
17
-
18
- li {
19
- --current-row: round(down, calc((sibling-index() - 1) / var(--cols)), 1);
20
-
21
- /* Apply the extra gap ONLY if the row index is > 0.
22
- We use clamp to ensure the multiplier is either 0 or 1.
23
- */
24
- --row-gap-multiplier: clamp(0, var(--current-row), 1);
25
- margin-block-start: calc(var(--row-gap-multiplier) * var(--row-margin));
26
- }
27
- </style>
28
- <ul class:list={["grid", className]} style={styleList({'--num-items': slots.length}, style)}>
28
+ <ul class:list={["subgrid", className]} style={styleList({'--num-items': slots.length}, style)}>
29
29
  {slots.map((slot) => (
30
- <li class:list={["grid grid-rows-subgrid row-span-(--row-span)", getAttribute(slot, 'class')]} style={styleList(getAttribute(slot, 'style'))} set:html={slot.html}></li>
30
+ <li class:list={[getAttribute(slot, 'class')]} style={styleList(getAttribute(slot, 'style'))} set:html={slot.html}></li>
31
31
  ))}
32
- </ul>
32
+ </ul>
package/src/seo.ts ADDED
@@ -0,0 +1,307 @@
1
+ /**
2
+ * The `<Seo />` resolution layer — site-level defaults merged with per-page props
3
+ * into the exact tags to emit.
4
+ *
5
+ * Kept separate from `Seo.astro` deliberately. The component this replaces
6
+ * (`SEO.astro`, deleted in c949cae) fused resolution with markup, which is why it
7
+ * was never unit-tested and why its title handling drifted: it computed a title
8
+ * for `og:title` but left `<title>` to the layout, so the two could disagree.
9
+ * Everything decidable lives here and is asserted in `seo.test.ts`; the component
10
+ * only renders what this returns.
11
+ */
12
+
13
+ /** Twitter card types, per <https://developer.x.com/en/docs/twitter-for-websites/cards>. */
14
+ export type SeoTwitterCard = 'summary' | 'summary_large_image' | 'app' | 'player';
15
+
16
+ /** Open Graph object types this component knows how to describe. */
17
+ export type SeoOgType = 'website' | 'article' | 'profile';
18
+
19
+ /**
20
+ * Site-level SEO defaults, passed to `getvitops({ seo })` and baked into the
21
+ * `virtual:getvitops/head` module. Per-page `<Seo />` props override these.
22
+ *
23
+ * The vocabulary mirrors the site-config schema's `seo` block
24
+ * (`@getvitops/generator`, `src/site.ts`) so a `SiteConfig` → these adapter would
25
+ * be a flat field map — but it is not imported from there. This is the
26
+ * integration's own option surface, and it must stay JSON-serialisable: it is
27
+ * `JSON.stringify`d into the virtual module, so no functions, no `URL`s, no dates.
28
+ */
29
+ export interface GetvitopsSeoOptions {
30
+ /** `og:site_name`, and the `<title>` when a page passes none. */
31
+ siteName?: string;
32
+ /**
33
+ * Page-title pattern; `%s` is the page's own title. e.g. `'%s · Acme'`.
34
+ *
35
+ * Skipped when a page's title already equals `siteName` — a homepage titled
36
+ * "Acme" emits `Acme`, not `Acme · Acme` — and never applied to `defaultTitle`.
37
+ */
38
+ titleTemplate?: string;
39
+ /** `<title>` for pages that pass none. Used verbatim — the template is not applied. */
40
+ defaultTitle?: string;
41
+ /** `<meta name="description">` for pages that pass none. */
42
+ defaultDescription?: string;
43
+ /** Site-wide robots directive, e.g. `'index,follow,max-image-preview:large'`. */
44
+ robots?: string;
45
+ /** Google Search Console verification token. */
46
+ googleSiteVerification?: string;
47
+ /** Bing Webmaster Tools verification token. */
48
+ bingSiteVerification?: string;
49
+ openGraph?: {
50
+ /** Default `og:type` (default `'website'`). */
51
+ type?: SeoOgType;
52
+ /** `og:locale`. Written either way — `en-CA` and `en_CA` both emit `en_CA`. */
53
+ locale?: string;
54
+ /** Additional locales the same content exists in → `og:locale:alternate`. */
55
+ localeAlternates?: string[];
56
+ /** Fallback share image. Relative URLs resolve against `site`. */
57
+ image?: { url: string; alt?: string; width?: number; height?: number; type?: string };
58
+ };
59
+ twitter?: {
60
+ /** Card type when no image resolves (default `'summary'`). */
61
+ card?: SeoTwitterCard;
62
+ /** The site's own handle, `@acme`. */
63
+ site?: string;
64
+ /** Default author handle, `@ada`. */
65
+ creator?: string;
66
+ };
67
+ }
68
+
69
+ /** Per-page `<Seo />` props. Everything is optional; defaults fill the gaps. */
70
+ export interface SeoProps {
71
+ /** Page title, before `titleTemplate` is applied. */
72
+ title?: string;
73
+ description?: string;
74
+ /** Overrides the computed canonical. Relative values resolve against `site`. */
75
+ canonical?: string;
76
+ /** Overrides `og:site_name` and the title fallback. */
77
+ siteName?: string;
78
+ /** Share image for this page. Relative URLs resolve against `site`. */
79
+ image?: string;
80
+ imageAlt?: string;
81
+ imageWidth?: number;
82
+ imageHeight?: number;
83
+ /** MIME type of the share image, e.g. `'image/png'`. */
84
+ imageType?: string;
85
+ noindex?: boolean;
86
+ nofollow?: boolean;
87
+ noarchive?: boolean;
88
+ nocache?: boolean;
89
+ /** Extra robots directives, e.g. `'max-snippet:-1'`. Appended to the composed value. */
90
+ robotsExtras?: string;
91
+ /** Full robots override. Wins over the flags above and the site default. */
92
+ robots?: string;
93
+ /** `og:type` for this page (default: the site default, else `'website'`). */
94
+ type?: SeoOgType;
95
+ /** Emitted only when `type` is `'article'`. */
96
+ article?: {
97
+ publishedTime?: string;
98
+ modifiedTime?: string;
99
+ expirationTime?: string;
100
+ authors?: string[];
101
+ section?: string;
102
+ tags?: string[];
103
+ };
104
+ /** `og:locale` for this page. */
105
+ locale?: string;
106
+ /** Twitter author handle for this page, `@ada`. */
107
+ twitterCreator?: string;
108
+ /**
109
+ * Translations of *this* page, as `<link rel="alternate" hreflang>`.
110
+ *
111
+ * Explicit only, and it must include the current page. Nothing is inferred:
112
+ * the deleted component derived alternates from the site's locale list and
113
+ * pointed every one of them at that locale's homepage, so every page on the
114
+ * site claimed the homepage as its translation. Only the page knows its own
115
+ * translated URLs. Pass `hreflang: 'x-default'` for the fallback entry.
116
+ */
117
+ alternates?: { hreflang: string; href: string }[];
118
+ }
119
+
120
+ /** A `<meta property>` tag (Open Graph uses `property`, not `name`). */
121
+ export interface SeoPropertyTag {
122
+ property: string;
123
+ content: string;
124
+ }
125
+
126
+ /** A `<meta name>` tag. */
127
+ export interface SeoNameTag {
128
+ name: string;
129
+ content: string;
130
+ }
131
+
132
+ /** Everything `<Seo />` renders, already decided. `null` means "emit nothing". */
133
+ export interface ResolvedSeo {
134
+ title: string | null;
135
+ description: string | null;
136
+ canonical: string | null;
137
+ robots: string | null;
138
+ openGraph: SeoPropertyTag[];
139
+ twitter: SeoNameTag[];
140
+ verification: SeoNameTag[];
141
+ alternates: { hreflang: string; href: string }[];
142
+ }
143
+
144
+ /** Render context supplied by the component from `Astro`. */
145
+ export interface SeoContext {
146
+ /**
147
+ * The deployed origin — `Astro.site`, or `config.site` via the virtual module.
148
+ *
149
+ * `undefined` when neither is set, and then every absolute-URL tag is dropped
150
+ * rather than being derived from the request. `astro-seo` falls back to
151
+ * `Astro.url` here; on a static build that is `http://localhost:4321`, which
152
+ * would bake a localhost canonical into production HTML.
153
+ */
154
+ site?: URL | undefined;
155
+ /** `Astro.url.pathname` — already includes `base`. */
156
+ pathname: string;
157
+ /** `Astro.url.search`, if the canonical should keep the query string. */
158
+ search?: string;
159
+ }
160
+
161
+ /** `en-CA` → `en_CA`; Open Graph wants the underscore form. */
162
+ const ogLocale = (locale: string) => locale.replace('-', '_');
163
+
164
+ /**
165
+ * Absolute URLs pass through; relative ones resolve against `site`, or are
166
+ * dropped when there is no `site` to resolve them against. A relative `og:image`
167
+ * or canonical is worse than none — crawlers resolve it against whatever origin
168
+ * served the page.
169
+ */
170
+ function absolute(url: string | undefined, site: URL | undefined): string | null {
171
+ if (!url) return null;
172
+ try {
173
+ return new URL(url).href;
174
+ } catch {
175
+ return site ? new URL(url, site).href : null;
176
+ }
177
+ }
178
+
179
+ /** Composes the robots directive, or null when nothing meaningful was asked for. */
180
+ function resolveRobots(props: SeoProps, defaults: GetvitopsSeoOptions): string | null {
181
+ if (props.robots) return props.robots;
182
+ const { noindex, nofollow, noarchive, nocache, robotsExtras } = props;
183
+ if (noindex || nofollow || noarchive || nocache || robotsExtras) {
184
+ return [
185
+ noindex ? 'noindex' : 'index',
186
+ nofollow ? 'nofollow' : 'follow',
187
+ ...(noarchive ? ['noarchive'] : []),
188
+ ...(nocache ? ['nocache'] : []),
189
+ ...(robotsExtras ? [robotsExtras] : []),
190
+ ].join(', ');
191
+ }
192
+ // No explicit default either: emit nothing. `index, follow` is what crawlers
193
+ // already assume, so stating it adds bytes and no meaning.
194
+ return defaults.robots ?? null;
195
+ }
196
+
197
+ /**
198
+ * Merges site defaults with page props into the final tag set.
199
+ *
200
+ * Pure — no `Astro`, no filesystem, no config lookup. Everything it needs comes
201
+ * through its three arguments, which is what makes it testable and what keeps the
202
+ * package free of the consumer-global coupling that sank the previous site model.
203
+ */
204
+ export function resolveSeo(
205
+ defaults: GetvitopsSeoOptions,
206
+ props: SeoProps,
207
+ ctx: SeoContext,
208
+ ): ResolvedSeo {
209
+ const { site } = ctx;
210
+ const siteName = props.siteName ?? defaults.siteName ?? null;
211
+
212
+ // The template applies to a page's own title only, and only when that title
213
+ // isn't already the site name. Two ways to get "Acme · Acme" otherwise: running
214
+ // it over `defaultTitle`, and a homepage whose own title happens to equal the
215
+ // site name — which is the common case, not an edge case.
216
+ const templated = props.title && defaults.titleTemplate && props.title !== siteName;
217
+ const title = props.title
218
+ ? templated
219
+ ? String(defaults.titleTemplate).replaceAll('%s', props.title)
220
+ : props.title
221
+ : (defaults.defaultTitle ?? siteName);
222
+
223
+ const description = props.description ?? defaults.defaultDescription ?? null;
224
+
225
+ const canonical = props.canonical
226
+ ? absolute(props.canonical, site)
227
+ : site
228
+ ? new URL(ctx.pathname + (ctx.search ?? ''), site).href
229
+ : null;
230
+
231
+ const image = absolute(props.image, site) ?? absolute(defaults.openGraph?.image?.url, site);
232
+ // Only inherit the default image's metadata when the page didn't bring its own
233
+ // image — otherwise a page's image would carry the default's alt text.
234
+ const usingDefaultImage = !props.image;
235
+ const defaultImage = defaults.openGraph?.image;
236
+ const imageAlt = props.imageAlt ?? (usingDefaultImage ? defaultImage?.alt : undefined);
237
+ const imageWidth = props.imageWidth ?? (usingDefaultImage ? defaultImage?.width : undefined);
238
+ const imageHeight = props.imageHeight ?? (usingDefaultImage ? defaultImage?.height : undefined);
239
+ const imageType = props.imageType ?? (usingDefaultImage ? defaultImage?.type : undefined);
240
+
241
+ const type = props.type ?? defaults.openGraph?.type ?? 'website';
242
+ const locale = props.locale ?? defaults.openGraph?.locale;
243
+
244
+ const openGraph: SeoPropertyTag[] = [];
245
+ const og = (property: string, content: string | number | null | undefined) => {
246
+ if (content !== null && content !== undefined && content !== '')
247
+ openGraph.push({ property, content: String(content) });
248
+ };
249
+
250
+ og('og:type', type);
251
+ og('og:title', title);
252
+ og('og:description', description);
253
+ og('og:url', canonical);
254
+ og('og:site_name', siteName);
255
+ og('og:locale', locale ? ogLocale(locale) : null);
256
+ for (const alt of defaults.openGraph?.localeAlternates ?? [])
257
+ og('og:locale:alternate', ogLocale(alt));
258
+ og('og:image', image);
259
+ if (image) {
260
+ og('og:image:alt', imageAlt);
261
+ og('og:image:width', imageWidth);
262
+ og('og:image:height', imageHeight);
263
+ og('og:image:type', imageType);
264
+ }
265
+ if (type === 'article' && props.article) {
266
+ const a = props.article;
267
+ og('article:published_time', a.publishedTime);
268
+ og('article:modified_time', a.modifiedTime);
269
+ og('article:expiration_time', a.expirationTime);
270
+ for (const author of a.authors ?? []) og('article:author', author);
271
+ og('article:section', a.section);
272
+ for (const tag of a.tags ?? []) og('article:tag', tag);
273
+ }
274
+
275
+ const twitter: SeoNameTag[] = [];
276
+ const tw = (name: string, content: string | null | undefined) => {
277
+ if (content) twitter.push({ name, content });
278
+ };
279
+ // A card with a large image is only honoured if there *is* an image.
280
+ tw('twitter:card', image ? 'summary_large_image' : (defaults.twitter?.card ?? 'summary'));
281
+ tw('twitter:site', defaults.twitter?.site);
282
+ tw('twitter:creator', props.twitterCreator ?? defaults.twitter?.creator);
283
+ tw('twitter:title', title);
284
+ tw('twitter:description', description);
285
+ tw('twitter:image', image);
286
+ tw('twitter:image:alt', image ? imageAlt : null);
287
+
288
+ const verification: SeoNameTag[] = [];
289
+ if (defaults.googleSiteVerification)
290
+ verification.push({
291
+ name: 'google-site-verification',
292
+ content: defaults.googleSiteVerification,
293
+ });
294
+ if (defaults.bingSiteVerification)
295
+ verification.push({ name: 'msvalidate.01', content: defaults.bingSiteVerification });
296
+
297
+ return {
298
+ title,
299
+ description,
300
+ canonical,
301
+ robots: resolveRobots(props, defaults),
302
+ openGraph,
303
+ twitter,
304
+ verification,
305
+ alternates: props.alternates ?? [],
306
+ };
307
+ }
package/virtual.d.ts CHANGED
@@ -6,12 +6,22 @@ declare module 'virtual:getvitops/head' {
6
6
  type?: string;
7
7
  sizes?: string;
8
8
  }
9
+ // Keep these keys in step with `HeadData` in src/integration.ts — the two are
10
+ // hand-duplicated (an ambient `declare module` in a shipped .d.ts cannot import
11
+ // from src), and `head.test.ts` asserts the key sets match.
9
12
  interface HeadData {
10
13
  favicons: boolean;
11
14
  faviconLinks: FaviconLink[];
12
15
  themeColor: string | null;
13
16
  webComponents: boolean;
14
17
  wcBase: string;
18
+ editor: boolean;
19
+ sitemap: string | null;
20
+ site: string | null;
21
+ // Structurally `GetvitopsSeoOptions` (src/seo.ts). Left loose here because
22
+ // this file can't import from src, and <Seo /> re-types it on the way into
23
+ // `resolveSeo`, which is where the real contract is enforced.
24
+ seo: Record<string, unknown>;
15
25
  }
16
26
  const data: HeadData;
17
27
  export default data;