@getvitops/astro 0.9.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +694 -0
- package/README.md +239 -3
- package/dist/index.d.mts +622 -5
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +609 -12
- package/dist/index.mjs.map +1 -1
- package/package.json +20 -5
- package/src/analytics.ts +325 -0
- package/src/components/Analytics.astro +59 -0
- package/src/components/Cards.astro +1 -1
- package/src/components/CookieConsent.astro +106 -0
- package/src/components/Details.astro +10 -4
- package/src/components/Drawer.astro +9 -3
- package/src/components/Head.astro +49 -1
- package/src/components/Icon.astro +94 -0
- package/src/components/Popover.astro +9 -3
- package/src/components/Seo.astro +68 -0
- package/src/seo.ts +337 -0
- package/virtual.d.ts +59 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* One icon, resolved through the site's semantic map.
|
|
4
|
+
*
|
|
5
|
+
* `name` is a semantic name ('menu', 'chevron-down') that resolves per the
|
|
6
|
+
* configured set, OR a fully-qualified `prefix:name` which passes straight
|
|
7
|
+
* through — that is the escape hatch for a glyph the map doesn't cover.
|
|
8
|
+
*
|
|
9
|
+
* **Why this doesn't use astro-icon.** It can't. astro-icon's `./components`
|
|
10
|
+
* entry is a TypeScript file that Vite has to transform, so it is only reachable
|
|
11
|
+
* through a statically-analysable import — and a *static* import is precisely
|
|
12
|
+
* what made the optional peer mandatory for anyone rendering Popover, Details or
|
|
13
|
+
* Drawer, which is the bug this component exists to fix. A dynamic
|
|
14
|
+
* `import(/* @vite-ignore *\/ …)` compiles nothing and hands Node raw TS, which
|
|
15
|
+
* fails silently and renders an empty box. So the glyph is read straight out of
|
|
16
|
+
* the `@iconify-json/*` collection instead — the same loader the sprite emitter
|
|
17
|
+
* uses, so both delivery paths draw the identical icon and neither needs a peer.
|
|
18
|
+
*
|
|
19
|
+
* Two other things worth knowing:
|
|
20
|
+
*
|
|
21
|
+
* - **An unresolvable name warns, it doesn't throw.** A missing glyph must never
|
|
22
|
+
* 500 an SSR page; the box renders empty and the build log says which name.
|
|
23
|
+
* `vitops icons` is the place that fails hard.
|
|
24
|
+
* - **`.icon` already does the layout.** It's an inline-flex box sized in `em`
|
|
25
|
+
* (`--icon-size`) that stretches its `<svg>` child and sets `fill:
|
|
26
|
+
* currentColor`. So a start/end adornment on `.cta`/`.btn` — both already
|
|
27
|
+
* `inline-flex` with a `gap` — is a matter of child order, not a new class.
|
|
28
|
+
* Don't confuse this with `.icon-mask`, the CSS-only adornment path (see
|
|
29
|
+
* `.link--icon::before` in anchor-link.css).
|
|
30
|
+
*/
|
|
31
|
+
import { loadIconSvg, spriteId } from '@getvitops/generator';
|
|
32
|
+
import { resolveIcon } from '@getvitops/utils';
|
|
33
|
+
import icons from 'virtual:getvitops/icons';
|
|
34
|
+
|
|
35
|
+
interface Props {
|
|
36
|
+
/** Semantic name ('menu') or fully-qualified ('ph:list'). */
|
|
37
|
+
name: string;
|
|
38
|
+
/** Resolve against the brand set rather than the UI set. */
|
|
39
|
+
brand?: boolean;
|
|
40
|
+
/** Any CSS length; sets `--icon-size`. Defaults to the framework's 1.25em. */
|
|
41
|
+
size?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Accessible name. Omit for decorative icons — the default — which are then
|
|
44
|
+
* `aria-hidden`. An icon that carries meaning on its own needs this.
|
|
45
|
+
*/
|
|
46
|
+
label?: string;
|
|
47
|
+
class?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const { name, brand = false, size, label, class: className } = Astro.props;
|
|
51
|
+
|
|
52
|
+
const set = brand ? icons.brand : icons.ui;
|
|
53
|
+
const aliased = icons.overrides[name] ?? name;
|
|
54
|
+
|
|
55
|
+
let qualified: string | null = null;
|
|
56
|
+
try {
|
|
57
|
+
qualified = resolveIcon(aliased, set, { weight: icons.weight ?? undefined });
|
|
58
|
+
} catch {
|
|
59
|
+
// A bare name that isn't in the map is legitimate — a sprite id, or a local
|
|
60
|
+
// SVG — so fall through with what we were given rather than giving up.
|
|
61
|
+
qualified = aliased.includes(':') ? aliased : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let svg: { viewBox: string; body: string } | null = null;
|
|
65
|
+
if (icons.engine === 'inline' && qualified) {
|
|
66
|
+
// resolveFrom is the project root, not process.cwd() — see IconsData.root.
|
|
67
|
+
svg = await loadIconSvg(qualified, { resolveFrom: icons.root });
|
|
68
|
+
if (!svg)
|
|
69
|
+
console.warn(
|
|
70
|
+
`[getvitops] <Icon name="${name}"> — "${qualified}" not found. Is ` +
|
|
71
|
+
`@iconify-json/${qualified.slice(0, qualified.indexOf(':'))} installed?`,
|
|
72
|
+
);
|
|
73
|
+
} else if (icons.engine === 'none') {
|
|
74
|
+
console.warn(
|
|
75
|
+
`[getvitops] <Icon name="${name}"> rendered but icons are not configured. ` +
|
|
76
|
+
'Add `icons: true` to getvitops() in astro.config.',
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const a11y = label ? { role: 'img', 'aria-label': label } : { 'aria-hidden': 'true' };
|
|
81
|
+
const style = size ? `--icon-size:${size}` : undefined;
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
<span class:list={['icon', className]} style={style} {...a11y}>
|
|
85
|
+
{
|
|
86
|
+
svg ? (
|
|
87
|
+
<svg viewBox={svg.viewBox} set:html={svg.body} />
|
|
88
|
+
) : icons.engine === 'sprite' && icons.sprite && qualified ? (
|
|
89
|
+
<svg>
|
|
90
|
+
<use href={`${icons.sprite}#${spriteId(qualified)}`} />
|
|
91
|
+
</svg>
|
|
92
|
+
) : null
|
|
93
|
+
}
|
|
94
|
+
</span>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
import
|
|
2
|
+
import Icon from './Icon.astro';
|
|
3
3
|
|
|
4
4
|
interface Props {
|
|
5
5
|
id: string;
|
|
@@ -14,8 +14,14 @@ interface Props {
|
|
|
14
14
|
class?: string;
|
|
15
15
|
anchorName?: string;
|
|
16
16
|
positionArea?: string;
|
|
17
|
-
/**
|
|
18
|
-
*
|
|
17
|
+
/**
|
|
18
|
+
* Map a semantic name onto a fully-qualified one before rendering.
|
|
19
|
+
*
|
|
20
|
+
* @deprecated The `icons` option on `getvitops()` now does this centrally, so
|
|
21
|
+
* the site's set is configured once instead of threaded through every call
|
|
22
|
+
* site. Left in place because removing it would break consumers passing one;
|
|
23
|
+
* when omitted, `<Icon />` resolves through the site config.
|
|
24
|
+
*/
|
|
19
25
|
iconResolver?: (name: string) => string;
|
|
20
26
|
}
|
|
21
27
|
|
|
@@ -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} />)}
|
package/src/seo.ts
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
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
|
+
* Applies to `<title>` only. `og:title`/`twitter:title` get the untemplated
|
|
36
|
+
* title: a social card already shows `og:site_name` and the domain, so the
|
|
37
|
+
* suffix would sit directly above the brand it repeats. Override per page with
|
|
38
|
+
* `ogTitle`.
|
|
39
|
+
*
|
|
40
|
+
* Skipped when a page's title already equals `siteName` — a homepage titled
|
|
41
|
+
* "Acme" emits `Acme`, not `Acme · Acme` — and never applied to `defaultTitle`.
|
|
42
|
+
*/
|
|
43
|
+
titleTemplate?: string;
|
|
44
|
+
/** `<title>` for pages that pass none. Used verbatim — the template is not applied. */
|
|
45
|
+
defaultTitle?: string;
|
|
46
|
+
/** `<meta name="description">` for pages that pass none. */
|
|
47
|
+
defaultDescription?: string;
|
|
48
|
+
/** Site-wide robots directive, e.g. `'index,follow,max-image-preview:large'`. */
|
|
49
|
+
robots?: string;
|
|
50
|
+
/** Google Search Console verification token. */
|
|
51
|
+
googleSiteVerification?: string;
|
|
52
|
+
/** Bing Webmaster Tools verification token. */
|
|
53
|
+
bingSiteVerification?: string;
|
|
54
|
+
openGraph?: {
|
|
55
|
+
/** Default `og:type` (default `'website'`). */
|
|
56
|
+
type?: SeoOgType;
|
|
57
|
+
/** `og:locale`. Written either way — `en-CA` and `en_CA` both emit `en_CA`. */
|
|
58
|
+
locale?: string;
|
|
59
|
+
/** Additional locales the same content exists in → `og:locale:alternate`. */
|
|
60
|
+
localeAlternates?: string[];
|
|
61
|
+
/** Fallback share image. Relative URLs resolve against `site`. */
|
|
62
|
+
image?: { url: string; alt?: string; width?: number; height?: number; type?: string };
|
|
63
|
+
};
|
|
64
|
+
twitter?: {
|
|
65
|
+
/** Card type when no image resolves (default `'summary'`). */
|
|
66
|
+
card?: SeoTwitterCard;
|
|
67
|
+
/** The site's own handle, `@acme`. */
|
|
68
|
+
site?: string;
|
|
69
|
+
/** Default author handle, `@ada`. */
|
|
70
|
+
creator?: string;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Per-page `<Seo />` props. Everything is optional; defaults fill the gaps. */
|
|
75
|
+
export interface SeoProps {
|
|
76
|
+
/** Page title, before `titleTemplate` is applied. */
|
|
77
|
+
title?: string;
|
|
78
|
+
/**
|
|
79
|
+
* `og:title`/`twitter:title`, when the social headline should differ from
|
|
80
|
+
* `title`. Defaults to `title` **without** `titleTemplate` — see the note on
|
|
81
|
+
* `titleTemplate` for why the suffix is dropped there.
|
|
82
|
+
*/
|
|
83
|
+
ogTitle?: string;
|
|
84
|
+
description?: string;
|
|
85
|
+
/** Overrides the computed canonical. Relative values resolve against `site`. */
|
|
86
|
+
canonical?: string;
|
|
87
|
+
/** Overrides `og:site_name` and the title fallback. */
|
|
88
|
+
siteName?: string;
|
|
89
|
+
/** Share image for this page. Relative URLs resolve against `site`. */
|
|
90
|
+
image?: string;
|
|
91
|
+
imageAlt?: string;
|
|
92
|
+
imageWidth?: number;
|
|
93
|
+
imageHeight?: number;
|
|
94
|
+
/** MIME type of the share image, e.g. `'image/png'`. */
|
|
95
|
+
imageType?: string;
|
|
96
|
+
noindex?: boolean;
|
|
97
|
+
nofollow?: boolean;
|
|
98
|
+
noarchive?: boolean;
|
|
99
|
+
nocache?: boolean;
|
|
100
|
+
/** Extra robots directives, e.g. `'max-snippet:-1'`. Appended to the composed value. */
|
|
101
|
+
robotsExtras?: string;
|
|
102
|
+
/** Full robots override. Wins over the flags above and the site default. */
|
|
103
|
+
robots?: string;
|
|
104
|
+
/** `og:type` for this page (default: the site default, else `'website'`). */
|
|
105
|
+
type?: SeoOgType;
|
|
106
|
+
/** Emitted only when `type` is `'article'`. */
|
|
107
|
+
article?: {
|
|
108
|
+
publishedTime?: string;
|
|
109
|
+
modifiedTime?: string;
|
|
110
|
+
expirationTime?: string;
|
|
111
|
+
authors?: string[];
|
|
112
|
+
section?: string;
|
|
113
|
+
tags?: string[];
|
|
114
|
+
};
|
|
115
|
+
/** `og:locale` for this page. */
|
|
116
|
+
locale?: string;
|
|
117
|
+
/** Twitter author handle for this page, `@ada`. */
|
|
118
|
+
twitterCreator?: string;
|
|
119
|
+
/**
|
|
120
|
+
* Translations of *this* page, as `<link rel="alternate" hreflang>`.
|
|
121
|
+
*
|
|
122
|
+
* Explicit only, and it must include the current page. Nothing is inferred:
|
|
123
|
+
* the deleted component derived alternates from the site's locale list and
|
|
124
|
+
* pointed every one of them at that locale's homepage, so every page on the
|
|
125
|
+
* site claimed the homepage as its translation. Only the page knows its own
|
|
126
|
+
* translated URLs. Pass `hreflang: 'x-default'` for the fallback entry.
|
|
127
|
+
*/
|
|
128
|
+
alternates?: { hreflang: string; href: string }[];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A `<meta property>` tag (Open Graph uses `property`, not `name`). */
|
|
132
|
+
export interface SeoPropertyTag {
|
|
133
|
+
property: string;
|
|
134
|
+
content: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A `<meta name>` tag. */
|
|
138
|
+
export interface SeoNameTag {
|
|
139
|
+
name: string;
|
|
140
|
+
content: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Everything `<Seo />` renders, already decided. `null` means "emit nothing". */
|
|
144
|
+
export interface ResolvedSeo {
|
|
145
|
+
title: string | null;
|
|
146
|
+
/** `og:title`/`twitter:title` — the page title without `titleTemplate`. */
|
|
147
|
+
socialTitle: string | null;
|
|
148
|
+
description: string | null;
|
|
149
|
+
canonical: string | null;
|
|
150
|
+
robots: string | null;
|
|
151
|
+
openGraph: SeoPropertyTag[];
|
|
152
|
+
twitter: SeoNameTag[];
|
|
153
|
+
verification: SeoNameTag[];
|
|
154
|
+
alternates: { hreflang: string; href: string }[];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Render context supplied by the component from `Astro`. */
|
|
158
|
+
export interface SeoContext {
|
|
159
|
+
/**
|
|
160
|
+
* The deployed origin — `Astro.site`, or `config.site` via the virtual module.
|
|
161
|
+
*
|
|
162
|
+
* `undefined` when neither is set, and then every absolute-URL tag is dropped
|
|
163
|
+
* rather than being derived from the request. `astro-seo` falls back to
|
|
164
|
+
* `Astro.url` here; on a static build that is `http://localhost:4321`, which
|
|
165
|
+
* would bake a localhost canonical into production HTML.
|
|
166
|
+
*/
|
|
167
|
+
site?: URL | undefined;
|
|
168
|
+
/** `Astro.url.pathname` — already includes `base`. */
|
|
169
|
+
pathname: string;
|
|
170
|
+
/** `Astro.url.search`, if the canonical should keep the query string. */
|
|
171
|
+
search?: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** `en-CA` → `en_CA`; Open Graph wants the underscore form. */
|
|
175
|
+
const ogLocale = (locale: string) => locale.replace('-', '_');
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Absolute URLs pass through; relative ones resolve against `site`, or are
|
|
179
|
+
* dropped when there is no `site` to resolve them against. A relative `og:image`
|
|
180
|
+
* or canonical is worse than none — crawlers resolve it against whatever origin
|
|
181
|
+
* served the page.
|
|
182
|
+
*/
|
|
183
|
+
function absolute(url: string | undefined, site: URL | undefined): string | null {
|
|
184
|
+
if (!url) return null;
|
|
185
|
+
try {
|
|
186
|
+
return new URL(url).href;
|
|
187
|
+
} catch {
|
|
188
|
+
return site ? new URL(url, site).href : null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Composes the robots directive, or null when nothing meaningful was asked for. */
|
|
193
|
+
function resolveRobots(props: SeoProps, defaults: GetvitopsSeoOptions): string | null {
|
|
194
|
+
if (props.robots) return props.robots;
|
|
195
|
+
const { noindex, nofollow, noarchive, nocache, robotsExtras } = props;
|
|
196
|
+
if (noindex || nofollow || noarchive || nocache || robotsExtras) {
|
|
197
|
+
return [
|
|
198
|
+
noindex ? 'noindex' : 'index',
|
|
199
|
+
nofollow ? 'nofollow' : 'follow',
|
|
200
|
+
...(noarchive ? ['noarchive'] : []),
|
|
201
|
+
...(nocache ? ['nocache'] : []),
|
|
202
|
+
...(robotsExtras ? [robotsExtras] : []),
|
|
203
|
+
].join(', ');
|
|
204
|
+
}
|
|
205
|
+
// No explicit default either: emit nothing. `index, follow` is what crawlers
|
|
206
|
+
// already assume, so stating it adds bytes and no meaning.
|
|
207
|
+
return defaults.robots ?? null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Merges site defaults with page props into the final tag set.
|
|
212
|
+
*
|
|
213
|
+
* Pure — no `Astro`, no filesystem, no config lookup. Everything it needs comes
|
|
214
|
+
* through its three arguments, which is what makes it testable and what keeps the
|
|
215
|
+
* package free of the consumer-global coupling that sank the previous site model.
|
|
216
|
+
*/
|
|
217
|
+
export function resolveSeo(
|
|
218
|
+
defaults: GetvitopsSeoOptions,
|
|
219
|
+
props: SeoProps,
|
|
220
|
+
ctx: SeoContext,
|
|
221
|
+
): ResolvedSeo {
|
|
222
|
+
const { site } = ctx;
|
|
223
|
+
const siteName = props.siteName ?? defaults.siteName ?? null;
|
|
224
|
+
|
|
225
|
+
// The template applies to a page's own title only, and only when that title
|
|
226
|
+
// isn't already the site name. Two ways to get "Acme · Acme" otherwise: running
|
|
227
|
+
// it over `defaultTitle`, and a homepage whose own title happens to equal the
|
|
228
|
+
// site name — which is the common case, not an edge case.
|
|
229
|
+
const templated = props.title && defaults.titleTemplate && props.title !== siteName;
|
|
230
|
+
const fallbackTitle = defaults.defaultTitle ?? siteName;
|
|
231
|
+
const title = props.title
|
|
232
|
+
? templated
|
|
233
|
+
? String(defaults.titleTemplate).replaceAll('%s', props.title)
|
|
234
|
+
: props.title
|
|
235
|
+
: fallbackTitle;
|
|
236
|
+
|
|
237
|
+
/*
|
|
238
|
+
* `og:title`/`twitter:title` take the page's title *without* the template.
|
|
239
|
+
*
|
|
240
|
+
* The two are read in different places. `<title>` is navigational — a browser
|
|
241
|
+
* tab or a search result, competing with a dozen others, so it needs the brand
|
|
242
|
+
* suffix to disambiguate. A social card is self-contained and already shows
|
|
243
|
+
* `og:site_name` and the domain, so repeating the brand there is furniture:
|
|
244
|
+
* "Installation · Acme" sitting directly above "Acme".
|
|
245
|
+
*
|
|
246
|
+
* They still derive from one value, which is the part that matters — the
|
|
247
|
+
* component this replaces computed them in separate places and they drifted.
|
|
248
|
+
* Pass `ogTitle` when a page wants a genuinely different social headline.
|
|
249
|
+
*/
|
|
250
|
+
const socialTitle = props.ogTitle ?? props.title ?? fallbackTitle;
|
|
251
|
+
|
|
252
|
+
const description = props.description ?? defaults.defaultDescription ?? null;
|
|
253
|
+
|
|
254
|
+
const canonical = props.canonical
|
|
255
|
+
? absolute(props.canonical, site)
|
|
256
|
+
: site
|
|
257
|
+
? new URL(ctx.pathname + (ctx.search ?? ''), site).href
|
|
258
|
+
: null;
|
|
259
|
+
|
|
260
|
+
const image = absolute(props.image, site) ?? absolute(defaults.openGraph?.image?.url, site);
|
|
261
|
+
// Only inherit the default image's metadata when the page didn't bring its own
|
|
262
|
+
// image — otherwise a page's image would carry the default's alt text.
|
|
263
|
+
const usingDefaultImage = !props.image;
|
|
264
|
+
const defaultImage = defaults.openGraph?.image;
|
|
265
|
+
const imageAlt = props.imageAlt ?? (usingDefaultImage ? defaultImage?.alt : undefined);
|
|
266
|
+
const imageWidth = props.imageWidth ?? (usingDefaultImage ? defaultImage?.width : undefined);
|
|
267
|
+
const imageHeight = props.imageHeight ?? (usingDefaultImage ? defaultImage?.height : undefined);
|
|
268
|
+
const imageType = props.imageType ?? (usingDefaultImage ? defaultImage?.type : undefined);
|
|
269
|
+
|
|
270
|
+
const type = props.type ?? defaults.openGraph?.type ?? 'website';
|
|
271
|
+
const locale = props.locale ?? defaults.openGraph?.locale;
|
|
272
|
+
|
|
273
|
+
const openGraph: SeoPropertyTag[] = [];
|
|
274
|
+
const og = (property: string, content: string | number | null | undefined) => {
|
|
275
|
+
if (content !== null && content !== undefined && content !== '')
|
|
276
|
+
openGraph.push({ property, content: String(content) });
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
og('og:type', type);
|
|
280
|
+
og('og:title', socialTitle);
|
|
281
|
+
og('og:description', description);
|
|
282
|
+
og('og:url', canonical);
|
|
283
|
+
og('og:site_name', siteName);
|
|
284
|
+
og('og:locale', locale ? ogLocale(locale) : null);
|
|
285
|
+
for (const alt of defaults.openGraph?.localeAlternates ?? [])
|
|
286
|
+
og('og:locale:alternate', ogLocale(alt));
|
|
287
|
+
og('og:image', image);
|
|
288
|
+
if (image) {
|
|
289
|
+
og('og:image:alt', imageAlt);
|
|
290
|
+
og('og:image:width', imageWidth);
|
|
291
|
+
og('og:image:height', imageHeight);
|
|
292
|
+
og('og:image:type', imageType);
|
|
293
|
+
}
|
|
294
|
+
if (type === 'article' && props.article) {
|
|
295
|
+
const a = props.article;
|
|
296
|
+
og('article:published_time', a.publishedTime);
|
|
297
|
+
og('article:modified_time', a.modifiedTime);
|
|
298
|
+
og('article:expiration_time', a.expirationTime);
|
|
299
|
+
for (const author of a.authors ?? []) og('article:author', author);
|
|
300
|
+
og('article:section', a.section);
|
|
301
|
+
for (const tag of a.tags ?? []) og('article:tag', tag);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const twitter: SeoNameTag[] = [];
|
|
305
|
+
const tw = (name: string, content: string | null | undefined) => {
|
|
306
|
+
if (content) twitter.push({ name, content });
|
|
307
|
+
};
|
|
308
|
+
// A card with a large image is only honoured if there *is* an image.
|
|
309
|
+
tw('twitter:card', image ? 'summary_large_image' : (defaults.twitter?.card ?? 'summary'));
|
|
310
|
+
tw('twitter:site', defaults.twitter?.site);
|
|
311
|
+
tw('twitter:creator', props.twitterCreator ?? defaults.twitter?.creator);
|
|
312
|
+
tw('twitter:title', socialTitle);
|
|
313
|
+
tw('twitter:description', description);
|
|
314
|
+
tw('twitter:image', image);
|
|
315
|
+
tw('twitter:image:alt', image ? imageAlt : null);
|
|
316
|
+
|
|
317
|
+
const verification: SeoNameTag[] = [];
|
|
318
|
+
if (defaults.googleSiteVerification)
|
|
319
|
+
verification.push({
|
|
320
|
+
name: 'google-site-verification',
|
|
321
|
+
content: defaults.googleSiteVerification,
|
|
322
|
+
});
|
|
323
|
+
if (defaults.bingSiteVerification)
|
|
324
|
+
verification.push({ name: 'msvalidate.01', content: defaults.bingSiteVerification });
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
title,
|
|
328
|
+
socialTitle,
|
|
329
|
+
description,
|
|
330
|
+
canonical,
|
|
331
|
+
robots: resolveRobots(props, defaults),
|
|
332
|
+
openGraph,
|
|
333
|
+
twitter,
|
|
334
|
+
verification,
|
|
335
|
+
alternates: props.alternates ?? [],
|
|
336
|
+
};
|
|
337
|
+
}
|
package/virtual.d.ts
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Astro generates the real types for `astro:env/*` into a consumer's
|
|
3
|
+
* `.astro/` on `astro sync`, which never runs for this package — so the
|
|
4
|
+
* import typechecks nowhere but a consumer project. Declared here at the
|
|
5
|
+
* shape `plan-features.ts` actually uses; the runtime value comes from
|
|
6
|
+
* Astro's env schema, and this file cannot import it.
|
|
7
|
+
*/
|
|
8
|
+
declare module 'astro:env/server' {
|
|
9
|
+
export const SITE_PLAN: string | undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
1
12
|
/** Types for the virtual module the getvitops() integration provides to <Head />. */
|
|
2
13
|
declare module 'virtual:getvitops/head' {
|
|
3
14
|
interface FaviconLink {
|
|
@@ -6,13 +17,61 @@ declare module 'virtual:getvitops/head' {
|
|
|
6
17
|
type?: string;
|
|
7
18
|
sizes?: string;
|
|
8
19
|
}
|
|
20
|
+
/** Mirrors `HeadFont` in src/fonts.ts. `preload` is Astro's `PreloadFilter`. */
|
|
21
|
+
interface HeadFont {
|
|
22
|
+
cssVariable: string;
|
|
23
|
+
preload: boolean | { weight?: string | number; style?: string }[];
|
|
24
|
+
}
|
|
25
|
+
// Keep these keys in step with `HeadData` in src/integration.ts — the two are
|
|
26
|
+
// hand-duplicated (an ambient `declare module` in a shipped .d.ts cannot import
|
|
27
|
+
// from src), and `head.test.ts` asserts the key sets match.
|
|
9
28
|
interface HeadData {
|
|
10
29
|
favicons: boolean;
|
|
11
30
|
faviconLinks: FaviconLink[];
|
|
12
31
|
themeColor: string | null;
|
|
13
32
|
webComponents: boolean;
|
|
14
33
|
wcBase: string;
|
|
34
|
+
editor: boolean;
|
|
35
|
+
sitemap: string | null;
|
|
36
|
+
/** Families registered by `getvitops({ fonts })`, for `<Font />`. */
|
|
37
|
+
fonts: HeadFont[];
|
|
38
|
+
site: string | null;
|
|
39
|
+
// Structurally `GetvitopsSeoOptions` (src/seo.ts). Left loose here because
|
|
40
|
+
// this file can't import from src, and <Seo /> re-types it on the way into
|
|
41
|
+
// `resolveSeo`, which is where the real contract is enforced.
|
|
42
|
+
seo: Record<string, unknown>;
|
|
43
|
+
// Likewise `GetvitopsAnalyticsOptions` (src/analytics.ts) — re-typed by
|
|
44
|
+
// <Analytics /> on the way into `resolveAnalytics`.
|
|
45
|
+
analytics: Record<string, unknown>;
|
|
46
|
+
consent: boolean;
|
|
47
|
+
consentCategories: string[];
|
|
48
|
+
consentPolicyUrl: string | null;
|
|
49
|
+
consentRuntime: boolean;
|
|
15
50
|
}
|
|
16
51
|
const data: HeadData;
|
|
17
52
|
export default data;
|
|
18
53
|
}
|
|
54
|
+
|
|
55
|
+
/** Types for the virtual module the getvitops() integration provides to <Icon />. */
|
|
56
|
+
declare module 'virtual:getvitops/icons' {
|
|
57
|
+
// Keep these keys in step with `IconsData` in src/integration.ts, for the same
|
|
58
|
+
// reason as HeadData above; `icons.test.ts` asserts the key sets match.
|
|
59
|
+
//
|
|
60
|
+
// Note what is NOT here: a resolver function. The module is JSON.stringify'd,
|
|
61
|
+
// so only data crosses. <Icon /> imports the pure `resolveIcon` from
|
|
62
|
+
// @getvitops/utils and feeds it these values.
|
|
63
|
+
interface IconsData {
|
|
64
|
+
engine: 'inline' | 'sprite' | 'none';
|
|
65
|
+
/** Absolute project root — where `@iconify-json/*` is resolved from. */
|
|
66
|
+
root: string;
|
|
67
|
+
ui: string;
|
|
68
|
+
brand: string;
|
|
69
|
+
weight: string | null;
|
|
70
|
+
/** Consumer aliases applied before the semantic map (legacy names, content values). */
|
|
71
|
+
overrides: Record<string, string>;
|
|
72
|
+
/** Public href of the sprite, or null when none is emitted. */
|
|
73
|
+
sprite: string | null;
|
|
74
|
+
}
|
|
75
|
+
const data: IconsData;
|
|
76
|
+
export default data;
|
|
77
|
+
}
|