@getvitops/astro 0.9.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/CHANGELOG.md +218 -0
- package/README.md +96 -2
- package/dist/index.d.mts +284 -4
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +152 -3
- package/dist/index.mjs.map +1 -1
- package/package.json +13 -5
- package/src/components/Cards.astro +1 -1
- package/src/components/Head.astro +3 -0
- package/src/components/Seo.astro +68 -0
- package/src/seo.ts +307 -0
- package/virtual.d.ts +10 -0
package/dist/index.mjs
CHANGED
|
@@ -38,6 +38,11 @@ function getvitops(opts = {}) {
|
|
|
38
38
|
const webComponents = opts.webComponents !== false;
|
|
39
39
|
const editor = opts.editor === true;
|
|
40
40
|
if (editor && !opts.css) logger.warn("editor: true needs a `css` config — it reads design-manifest.json.");
|
|
41
|
+
if (opts.legal && !opts.css) logger.warn("legal: needs a `css` config — the generation runs in the Vite plugin that `css` registers. Run `npx vitops legal` instead if you do not use `css`.");
|
|
42
|
+
if (opts.seo) {
|
|
43
|
+
if (!config.site) logger.warn("seo: needs the `site` astro.config option (your deployed URL) — without it <Seo /> omits canonical, og:url and any relative og:image rather than guessing an origin.");
|
|
44
|
+
if (config.integrations.some((i) => i.name === "emdash")) logger.warn("seo: emdash() is registered, and its <EmDashHead> emits the same title/description/canonical/og tags from the CMS. Use one or the other — rendering both duplicates every tag.");
|
|
45
|
+
}
|
|
41
46
|
const hasManifest = !!(opts.favicon?.name && opts.favicon?.themeColor);
|
|
42
47
|
let hasSvg = false;
|
|
43
48
|
if (opts.favicon) {
|
|
@@ -73,6 +78,26 @@ function getvitops(opts = {}) {
|
|
|
73
78
|
logger.info("copied web-component bundles → public/vitops/");
|
|
74
79
|
} else logger.warn(`@getvitops/core dist not found (${coreDist}); run build:core. Skipping WC copy.`);
|
|
75
80
|
}
|
|
81
|
+
let sitemapHref = null;
|
|
82
|
+
if (opts.sitemap) {
|
|
83
|
+
const registered = config.integrations.map((i) => i.name);
|
|
84
|
+
if (registered.includes("@astrojs/sitemap")) logger.info("sitemap: @astrojs/sitemap is already in your integrations — leaving yours in charge.");
|
|
85
|
+
else if (registered.includes("emdash")) logger.warn("sitemap: emdash() already serves /sitemap.xml from the database, which also covers on-demand pages a static sitemap cannot. Skipping — drop the `sitemap` option, or add @astrojs/sitemap to `integrations` yourself to run both.");
|
|
86
|
+
else if (!config.site) logger.warn("sitemap: needs the `site` astro.config option (your deployed URL) — a sitemap lists absolute URLs. Skipping.");
|
|
87
|
+
else {
|
|
88
|
+
let sitemap;
|
|
89
|
+
try {
|
|
90
|
+
({default: sitemap} = await import("@astrojs/sitemap"));
|
|
91
|
+
} catch {
|
|
92
|
+
throw new Error("[getvitops] sitemap requires `@astrojs/sitemap` in your devDependencies — install it, or drop the `sitemap` option.");
|
|
93
|
+
}
|
|
94
|
+
if (config.output === "server") logger.warn("sitemap: output: 'server' — @astrojs/sitemap lists prerendered routes only, so on-demand pages are left out. Add `export const prerender = true` to the pages you want indexed, or list them in `sitemap.customPages`.");
|
|
95
|
+
const sitemapOpts = opts.sitemap === true ? void 0 : opts.sitemap;
|
|
96
|
+
updateConfig({ integrations: [sitemap(sitemapOpts)] });
|
|
97
|
+
sitemapHref = `/${sitemapOpts?.filenameBase ?? "sitemap"}-index.xml`;
|
|
98
|
+
logger.info(`sitemap registered → ${sitemapHref.slice(1)}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
76
101
|
updateConfig({ vite: { plugins: [virtualHeadPlugin({
|
|
77
102
|
favicons: !!opts.favicon,
|
|
78
103
|
faviconLinks: opts.favicon ? faviconLinks({
|
|
@@ -82,16 +107,21 @@ function getvitops(opts = {}) {
|
|
|
82
107
|
themeColor: opts.favicon?.themeColor ?? null,
|
|
83
108
|
webComponents,
|
|
84
109
|
wcBase: "/vitops",
|
|
85
|
-
editor
|
|
110
|
+
editor,
|
|
111
|
+
sitemap: sitemapHref,
|
|
112
|
+
site: config.site ?? null,
|
|
113
|
+
seo: opts.seo ?? {}
|
|
86
114
|
})] } });
|
|
87
115
|
if (opts.css) {
|
|
88
116
|
const format = opts.css.format ?? "tailwind";
|
|
117
|
+
if (format === "design") throw new Error("[getvitops] css.format: 'design' emits only DESIGN.md, not a stylesheet. Run `npx vitops generate --format design --out .` for that file, and set css.format to 'tailwind' | 'css' | 'bricks' here.");
|
|
89
118
|
const out = opts.css.out ?? "src/styles";
|
|
90
119
|
const plugins = [vitops({
|
|
91
120
|
input: opts.css.input ?? "design-system.json",
|
|
92
121
|
format,
|
|
93
122
|
out,
|
|
94
|
-
...editor ? { editorManifestDir: publicDir } : {}
|
|
123
|
+
...editor ? { editorManifestDir: publicDir } : {},
|
|
124
|
+
...opts.legal ? { legal: opts.legal } : {}
|
|
95
125
|
})];
|
|
96
126
|
if (format === "tailwind") {
|
|
97
127
|
let tailwindcss;
|
|
@@ -115,5 +145,124 @@ function getvitops(opts = {}) {
|
|
|
115
145
|
}
|
|
116
146
|
|
|
117
147
|
//#endregion
|
|
118
|
-
|
|
148
|
+
//#region src/seo.ts
|
|
149
|
+
/** `en-CA` → `en_CA`; Open Graph wants the underscore form. */
|
|
150
|
+
const ogLocale = (locale) => locale.replace("-", "_");
|
|
151
|
+
/**
|
|
152
|
+
* Absolute URLs pass through; relative ones resolve against `site`, or are
|
|
153
|
+
* dropped when there is no `site` to resolve them against. A relative `og:image`
|
|
154
|
+
* or canonical is worse than none — crawlers resolve it against whatever origin
|
|
155
|
+
* served the page.
|
|
156
|
+
*/
|
|
157
|
+
function absolute(url, site) {
|
|
158
|
+
if (!url) return null;
|
|
159
|
+
try {
|
|
160
|
+
return new URL(url).href;
|
|
161
|
+
} catch {
|
|
162
|
+
return site ? new URL(url, site).href : null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** Composes the robots directive, or null when nothing meaningful was asked for. */
|
|
166
|
+
function resolveRobots(props, defaults) {
|
|
167
|
+
if (props.robots) return props.robots;
|
|
168
|
+
const { noindex, nofollow, noarchive, nocache, robotsExtras } = props;
|
|
169
|
+
if (noindex || nofollow || noarchive || nocache || robotsExtras) return [
|
|
170
|
+
noindex ? "noindex" : "index",
|
|
171
|
+
nofollow ? "nofollow" : "follow",
|
|
172
|
+
...noarchive ? ["noarchive"] : [],
|
|
173
|
+
...nocache ? ["nocache"] : [],
|
|
174
|
+
...robotsExtras ? [robotsExtras] : []
|
|
175
|
+
].join(", ");
|
|
176
|
+
return defaults.robots ?? null;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Merges site defaults with page props into the final tag set.
|
|
180
|
+
*
|
|
181
|
+
* Pure — no `Astro`, no filesystem, no config lookup. Everything it needs comes
|
|
182
|
+
* through its three arguments, which is what makes it testable and what keeps the
|
|
183
|
+
* package free of the consumer-global coupling that sank the previous site model.
|
|
184
|
+
*/
|
|
185
|
+
function resolveSeo(defaults, props, ctx) {
|
|
186
|
+
const { site } = ctx;
|
|
187
|
+
const siteName = props.siteName ?? defaults.siteName ?? null;
|
|
188
|
+
const templated = props.title && defaults.titleTemplate && props.title !== siteName;
|
|
189
|
+
const title = props.title ? templated ? String(defaults.titleTemplate).replaceAll("%s", props.title) : props.title : defaults.defaultTitle ?? siteName;
|
|
190
|
+
const description = props.description ?? defaults.defaultDescription ?? null;
|
|
191
|
+
const canonical = props.canonical ? absolute(props.canonical, site) : site ? new URL(ctx.pathname + (ctx.search ?? ""), site).href : null;
|
|
192
|
+
const image = absolute(props.image, site) ?? absolute(defaults.openGraph?.image?.url, site);
|
|
193
|
+
const usingDefaultImage = !props.image;
|
|
194
|
+
const defaultImage = defaults.openGraph?.image;
|
|
195
|
+
const imageAlt = props.imageAlt ?? (usingDefaultImage ? defaultImage?.alt : void 0);
|
|
196
|
+
const imageWidth = props.imageWidth ?? (usingDefaultImage ? defaultImage?.width : void 0);
|
|
197
|
+
const imageHeight = props.imageHeight ?? (usingDefaultImage ? defaultImage?.height : void 0);
|
|
198
|
+
const imageType = props.imageType ?? (usingDefaultImage ? defaultImage?.type : void 0);
|
|
199
|
+
const type = props.type ?? defaults.openGraph?.type ?? "website";
|
|
200
|
+
const locale = props.locale ?? defaults.openGraph?.locale;
|
|
201
|
+
const openGraph = [];
|
|
202
|
+
const og = (property, content) => {
|
|
203
|
+
if (content !== null && content !== void 0 && content !== "") openGraph.push({
|
|
204
|
+
property,
|
|
205
|
+
content: String(content)
|
|
206
|
+
});
|
|
207
|
+
};
|
|
208
|
+
og("og:type", type);
|
|
209
|
+
og("og:title", title);
|
|
210
|
+
og("og:description", description);
|
|
211
|
+
og("og:url", canonical);
|
|
212
|
+
og("og:site_name", siteName);
|
|
213
|
+
og("og:locale", locale ? ogLocale(locale) : null);
|
|
214
|
+
for (const alt of defaults.openGraph?.localeAlternates ?? []) og("og:locale:alternate", ogLocale(alt));
|
|
215
|
+
og("og:image", image);
|
|
216
|
+
if (image) {
|
|
217
|
+
og("og:image:alt", imageAlt);
|
|
218
|
+
og("og:image:width", imageWidth);
|
|
219
|
+
og("og:image:height", imageHeight);
|
|
220
|
+
og("og:image:type", imageType);
|
|
221
|
+
}
|
|
222
|
+
if (type === "article" && props.article) {
|
|
223
|
+
const a = props.article;
|
|
224
|
+
og("article:published_time", a.publishedTime);
|
|
225
|
+
og("article:modified_time", a.modifiedTime);
|
|
226
|
+
og("article:expiration_time", a.expirationTime);
|
|
227
|
+
for (const author of a.authors ?? []) og("article:author", author);
|
|
228
|
+
og("article:section", a.section);
|
|
229
|
+
for (const tag of a.tags ?? []) og("article:tag", tag);
|
|
230
|
+
}
|
|
231
|
+
const twitter = [];
|
|
232
|
+
const tw = (name, content) => {
|
|
233
|
+
if (content) twitter.push({
|
|
234
|
+
name,
|
|
235
|
+
content
|
|
236
|
+
});
|
|
237
|
+
};
|
|
238
|
+
tw("twitter:card", image ? "summary_large_image" : defaults.twitter?.card ?? "summary");
|
|
239
|
+
tw("twitter:site", defaults.twitter?.site);
|
|
240
|
+
tw("twitter:creator", props.twitterCreator ?? defaults.twitter?.creator);
|
|
241
|
+
tw("twitter:title", title);
|
|
242
|
+
tw("twitter:description", description);
|
|
243
|
+
tw("twitter:image", image);
|
|
244
|
+
tw("twitter:image:alt", image ? imageAlt : null);
|
|
245
|
+
const verification = [];
|
|
246
|
+
if (defaults.googleSiteVerification) verification.push({
|
|
247
|
+
name: "google-site-verification",
|
|
248
|
+
content: defaults.googleSiteVerification
|
|
249
|
+
});
|
|
250
|
+
if (defaults.bingSiteVerification) verification.push({
|
|
251
|
+
name: "msvalidate.01",
|
|
252
|
+
content: defaults.bingSiteVerification
|
|
253
|
+
});
|
|
254
|
+
return {
|
|
255
|
+
title,
|
|
256
|
+
description,
|
|
257
|
+
canonical,
|
|
258
|
+
robots: resolveRobots(props, defaults),
|
|
259
|
+
openGraph,
|
|
260
|
+
twitter,
|
|
261
|
+
verification,
|
|
262
|
+
alternates: props.alternates ?? []
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
//#endregion
|
|
267
|
+
export { getvitops as default, resolveSeo };
|
|
119
268
|
//# sourceMappingURL=index.mjs.map
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/integration.ts"],"sourcesContent":["/**\n * @getvitops/astro — the Astro integration. One entry wires the design system's\n * `<head>` contributions into a consumer site:\n * - favicons + PWA manifest (generated into public/),\n * - the web-component runtime bundles (copied into public/vitops/),\n * - (opt-in) the design-system CSS, generated + auto-injected (no manual import).\n * The `<Head />` component (shipped alongside) renders the tags, reading resolved\n * config from the `virtual:getvitops/head` module this integration provides.\n */\nimport { cpSync, existsSync } from 'node:fs';\nimport { dirname, join, resolve } from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport type { Format } from '@getvitops/generator';\nimport {\n type FaviconLink,\n faviconLinks,\n generateFavicons,\n writeFaviconManifest,\n} from '@getvitops/utils';\nimport vitops from '@getvitops/vite';\nimport type { AstroIntegration } from 'astro';\n\nexport interface GetvitopsFaviconOptions {\n /** Source SVG or PNG. */\n source: string;\n lowResSource?: string;\n /** App name → enables `site.webmanifest`. */\n name?: string;\n /** PWA theme color (also `<meta name=\"theme-color\">`). */\n themeColor?: string;\n backgroundColor?: string;\n}\n\nexport interface GetvitopsCssOptions {\n /** Path to design-system.json (default 'design-system.json'). */\n input?: string;\n /** Output format (default 'tailwind'). */\n format?: Format;\n /** Directory the generated CSS is written to (default 'src/styles'). */\n out?: string;\n /**\n * Auto-inject the generated stylesheet into every SSR page (default true).\n * Set false when other integrations add routes that must not inherit the\n * design system (e.g. EmDash's `/_emdash/admin`) — then import the generated\n * file (`<out>/tailwind.css` or `<out>/styles.css`) from your site layout so\n * only your own pages (and previews rendered through them) are styled.\n */\n inject?: boolean;\n}\n\nexport interface GetvitopsOptions {\n favicon?: GetvitopsFaviconOptions;\n /** Copy + link the web-component bundles (default true). */\n webComponents?: boolean;\n /** Generate + auto-inject the design-system CSS. Off unless provided. */\n css?: GetvitopsCssOptions;\n /**\n * Ship the live theme editor (default false).\n *\n * Copies `editor.js` into `public/vitops/`, loads it from `<Head />`, and mirrors\n * the generated `design-manifest.json` next to it so `<wc-theme-editor>` resolves\n * its default `/vitops/design-manifest.json`. Requires `css` (the manifest is a\n * `css`-format output) — place the `<wc-theme-editor>` tag in your layout yourself.\n *\n * Editing is live on any deploy; **saving back** to `design-system.json` needs the\n * dev server, so on a static build that button simply isn't rendered.\n */\n editor?: boolean;\n}\n\ninterface HeadData {\n favicons: boolean;\n faviconLinks: FaviconLink[];\n themeColor: string | null;\n webComponents: boolean;\n wcBase: string;\n editor: boolean;\n}\n\nconst VIRTUAL_ID = 'virtual:getvitops/head';\nconst RESOLVED_ID = `\\0${VIRTUAL_ID}`;\n\nfunction virtualHeadPlugin(data: HeadData) {\n return {\n name: '@getvitops/astro:virtual-head',\n resolveId(id: string) {\n return id === VIRTUAL_ID ? RESOLVED_ID : null;\n },\n load(id: string) {\n return id === RESOLVED_ID ? `export default ${JSON.stringify(data)};` : null;\n },\n };\n}\n\nexport default function getvitops(opts: GetvitopsOptions = {}): AstroIntegration {\n return {\n name: '@getvitops/astro',\n hooks: {\n 'astro:config:setup': async ({ config, updateConfig, injectScript, logger }) => {\n const root = fileURLToPath(config.root);\n const publicDir = fileURLToPath(config.publicDir);\n const webComponents = opts.webComponents !== false;\n const editor = opts.editor === true;\n if (editor && !opts.css)\n logger.warn('editor: true needs a `css` config — it reads design-manifest.json.');\n const hasManifest = !!(opts.favicon?.name && opts.favicon?.themeColor);\n let hasSvg = false;\n\n // 1. Favicons + manifest → public/\n if (opts.favicon) {\n const source = resolve(root, opts.favicon.source);\n hasSvg = source.endsWith('.svg');\n await generateFavicons({\n source,\n outputDir: publicDir,\n ...(opts.favicon.lowResSource\n ? { lowResSource: resolve(root, opts.favicon.lowResSource) }\n : {}),\n });\n if (opts.favicon.name && opts.favicon.themeColor) {\n await writeFaviconManifest(publicDir, {\n name: opts.favicon.name,\n themeColor: opts.favicon.themeColor,\n ...(opts.favicon.backgroundColor\n ? { backgroundColor: opts.favicon.backgroundColor }\n : {}),\n });\n }\n logger.info('generated favicons + manifest → public/');\n }\n\n // 2. Web-component bundles → public/vitops/\n if (webComponents) {\n const corePkg = fileURLToPath(import.meta.resolve('@getvitops/core/package.json'));\n const coreDist = join(dirname(corePkg), 'dist');\n const dest = join(publicDir, 'vitops');\n if (existsSync(coreDist)) {\n const bundles = ['polyfills.js', 'deferred.js', 'elements.js', 'polyfills'];\n if (editor) bundles.push('editor.js');\n for (const f of bundles) {\n const src = join(coreDist, f);\n if (existsSync(src)) cpSync(src, join(dest, f), { recursive: true });\n }\n logger.info('copied web-component bundles → public/vitops/');\n } else {\n logger.warn(\n `@getvitops/core dist not found (${coreDist}); run build:core. Skipping WC copy.`,\n );\n }\n }\n\n // 3. Virtual head module for <Head/>\n updateConfig({\n vite: {\n plugins: [\n virtualHeadPlugin({\n favicons: !!opts.favicon,\n faviconLinks: opts.favicon ? faviconLinks({ hasSvg, manifest: hasManifest }) : [],\n themeColor: opts.favicon?.themeColor ?? null,\n webComponents,\n wcBase: '/vitops',\n editor,\n }),\n ],\n },\n });\n\n // 4. CSS — generate + compile + auto-inject (consumer imports nothing).\n if (opts.css) {\n const format: Format = opts.css.format ?? 'tailwind';\n const out = opts.css.out ?? 'src/styles';\n const plugins = [\n vitops({\n input: opts.css.input ?? 'design-system.json',\n format,\n out,\n // Mirrored inside the generate pass so the served copy can never be\n // missing on a cold start or stale after a config edit.\n ...(editor ? { editorManifestDir: publicDir } : {}),\n }),\n ];\n // Tailwind is an optional peer — only the `tailwind` format needs it,\n // so it's loaded here rather than imported at module scope. (The\n // plugin is typed against a newer vite than @getvitops/vite's peer;\n // the Plugin shapes are structurally compatible at runtime — Astro\n // forwards them verbatim — so bridge the two type identities.)\n if (format === 'tailwind') {\n let tailwindcss: () => unknown[];\n try {\n ({ default: tailwindcss } = await import('@tailwindcss/vite'));\n } catch {\n throw new Error(\n \"[getvitops] css.format: 'tailwind' requires `tailwindcss` and `@tailwindcss/vite` \" +\n 'in your devDependencies — install them, or switch to css.format: ' +\n \"'css' for the standalone bundle.\",\n );\n }\n plugins.push(...(tailwindcss() as unknown as typeof plugins));\n }\n updateConfig({ vite: { plugins } });\n const cssFile = format === 'tailwind' ? 'tailwind.css' : 'styles.css';\n if (opts.css.inject !== false) {\n const cssPath = resolve(root, out, cssFile);\n injectScript('page-ssr', `import ${JSON.stringify(cssPath)};`);\n logger.info(`design-system CSS (${format}) auto-injected from ${out}/${cssFile}`);\n } else {\n logger.info(\n `design-system CSS (${format}) generated at ${out}/${cssFile} (inject: false — import it from your layout)`,\n );\n }\n }\n },\n },\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/integration.ts","../src/seo.ts"],"sourcesContent":["/**\n * @getvitops/astro — the Astro integration. One entry wires the design system's\n * `<head>` contributions into a consumer site:\n * - favicons + PWA manifest (generated into public/),\n * - the web-component runtime bundles (copied into public/vitops/),\n * - (opt-in) the design-system CSS, generated + auto-injected (no manual import).\n * The `<Head />` component (shipped alongside) renders the tags, reading resolved\n * config from the `virtual:getvitops/head` module this integration provides.\n */\nimport { cpSync, existsSync } from 'node:fs';\nimport { dirname, join, resolve } from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport type { LegalOutput, StylesheetFormat } from '@getvitops/generator';\nimport {\n type FaviconLink,\n faviconLinks,\n generateFavicons,\n writeFaviconManifest,\n} from '@getvitops/utils';\nimport vitops from '@getvitops/vite';\nimport type { AstroIntegration } from 'astro';\nimport type { GetvitopsSeoOptions } from './seo.ts';\n\nexport interface GetvitopsFaviconOptions {\n /** Source SVG or PNG. */\n source: string;\n lowResSource?: string;\n /** App name → enables `site.webmanifest`. */\n name?: string;\n /** PWA theme color (also `<meta name=\"theme-color\">`). */\n themeColor?: string;\n backgroundColor?: string;\n}\n\nexport interface GetvitopsCssOptions {\n /** Path to design-system.json (default 'design-system.json'). */\n input?: string;\n /**\n * Output format (default 'tailwind'). Narrower than the generator's `Format`\n * on purpose: this option exists to produce a stylesheet to inject, and the\n * `design` format emits only `DESIGN.md`. Run that one via the CLI\n * (`vitops generate --format design --out .`).\n */\n format?: StylesheetFormat;\n /** Directory the generated CSS is written to (default 'src/styles'). */\n out?: string;\n /**\n * Auto-inject the generated stylesheet into every SSR page (default true).\n * Set false when other integrations add routes that must not inherit the\n * design system (e.g. EmDash's `/_emdash/admin`) — then import the generated\n * file (`<out>/tailwind.css` or `<out>/styles.css`) from your site layout so\n * only your own pages (and previews rendered through them) are styled.\n */\n inject?: boolean;\n}\n\nexport interface GetvitopsLegalOptions {\n /** Path to the site config (JSON) the documents are rendered from. */\n input: string;\n /**\n * Where to write them (default 'src/content/legal'). The default is a content\n * collection: the documents are markdown, and a collection is what lets a page\n * render one through your own layout rather than the integration injecting a\n * route (which nothing in this package does).\n */\n out?: string;\n /** Output format (default 'md'). */\n format?: LegalOutput;\n /** Environment whose A/B variant applies (default 'production'). */\n siteEnv?: string;\n}\n\n/** `changefreq`, mirroring @astrojs/sitemap's `ChangeFreq`. */\nexport type GetvitopsSitemapChangeFreq =\n | 'always'\n | 'hourly'\n | 'daily'\n | 'weekly'\n | 'monthly'\n | 'yearly'\n | 'never';\n\n/** One sitemap entry, mirroring @astrojs/sitemap's `SitemapItem`. */\nexport interface GetvitopsSitemapEntry {\n url: string;\n lastmod?: string;\n changefreq?: GetvitopsSitemapChangeFreq;\n priority?: number;\n /** Alternate-language variants. `lang` is required, as upstream requires it. */\n links?: { lang: string; hreflang?: string; url: string }[];\n}\n\n/**\n * Forwarded verbatim to `@astrojs/sitemap`. Deliberately a hand-written subset of\n * its `SitemapOptions` rather than a re-export: aliasing would put an\n * `import … from '@astrojs/sitemap'` — and, transitively via `SitemapItem`, from\n * `sitemap` — into this package's published .d.mts, which cannot resolve for the\n * consumers who don't install the optional peer. Worse, `skipLibCheck` (on by\n * default via astro's base tsconfig) *suppresses* that error, silently degrading\n * the option to `any` instead of failing loudly.\n *\n * Need something not listed here (e.g. `chunks`)? Add `sitemap()` to your own\n * `integrations` array — getvitops detects it and leaves yours in charge.\n */\nexport interface GetvitopsSitemapOptions {\n /** Keep a page out of the sitemap. Receives the page's full absolute URL. */\n filter?: (page: string) => boolean;\n /** Absolute URLs of pages Astro doesn't build but the site serves. */\n customPages?: string[];\n /** Absolute URLs of externally-generated sitemaps to list in the index. */\n customSitemaps?: string[];\n /** Max entries per file before it splits (default 45000). */\n entryLimit?: number;\n /** Filename prefix (default 'sitemap' → `sitemap-index.xml`). */\n filenameBase?: string;\n changefreq?: GetvitopsSitemapChangeFreq;\n lastmod?: Date;\n priority?: number;\n /** Per-entry rewrite; return `undefined` to drop the entry. */\n serialize?: (\n item: GetvitopsSitemapEntry,\n ) => GetvitopsSitemapEntry | Promise<GetvitopsSitemapEntry | undefined> | undefined;\n /** Emit `<xhtml:link rel=\"alternate\">` for localised routes. */\n i18n?: { defaultLocale: string; locales: Record<string, string> };\n /** XSL stylesheet to prettify the XML (path relative to `site`, or absolute URL). */\n xslURL?: string;\n /** XML namespaces to exclude (all included by default). */\n namespaces?: { news?: boolean; xhtml?: boolean; image?: boolean; video?: boolean };\n}\n\nexport interface GetvitopsOptions {\n favicon?: GetvitopsFaviconOptions;\n /** Copy + link the web-component bundles (default true). */\n webComponents?: boolean;\n /** Generate + auto-inject the design-system CSS. Off unless provided. */\n css?: GetvitopsCssOptions;\n /**\n * Ship the live theme editor (default false).\n *\n * Copies `editor.js` into `public/vitops/`, loads it from `<Head />`, and mirrors\n * the generated `design-manifest.json` next to it so `<wc-theme-editor>` resolves\n * its default `/vitops/design-manifest.json`. Requires `css` (the manifest is a\n * `css`-format output) — place the `<wc-theme-editor>` tag in your layout yourself.\n *\n * Editing is live on any deploy; **saving back** to `design-system.json` needs the\n * dev server, so on a static build that button simply isn't rendered.\n */\n editor?: boolean;\n /**\n * Render the site's legal documents (privacy policy, terms, cookie notice)\n * from a site config, and re-render when it changes. Off unless provided.\n *\n * Requires `css`, which is what registers the Vite plugin that runs the\n * generation — without it the documents would be written once and never\n * refresh. Not using `css`? Run `vitops legal` from the CLI instead; it is the\n * same renderer and works in any stack.\n */\n legal?: GetvitopsLegalOptions;\n /**\n * Emit `sitemap-index.xml` + `sitemap-0.xml` via `@astrojs/sitemap`, and link it\n * from `<Head />`. Off unless provided; `true` uses its defaults.\n *\n * Needs `@astrojs/sitemap` (an optional peer — install it yourself) and the\n * `site` astro.config option, since a sitemap lists absolute URLs.\n *\n * Skipped with a warning when `emdash()` is registered — EmDash serves its own\n * database-driven `/sitemap.xml` — and deferred to when you already list\n * `@astrojs/sitemap` yourself. Note it enumerates **prerendered** routes only,\n * so on-demand pages of an `output: 'server'` site are not listed.\n */\n sitemap?: boolean | GetvitopsSitemapOptions;\n /**\n * Site-level defaults for `<Seo />` (`@getvitops/astro/Seo.astro`) — site name,\n * title template, Open Graph + Twitter defaults, verification tokens. Off unless\n * provided; per-page props override every field.\n *\n * Needs the `site` astro.config option for canonical and `og:url`; without it\n * `<Seo />` omits every absolute-URL tag rather than deriving one from the\n * request, and this warns at build time.\n *\n * On an EmDash site prefer `<EmDashHead>`, which covers the same tags from the\n * CMS — using both double-emits them.\n */\n seo?: GetvitopsSeoOptions;\n}\n\n/**\n * Serialised into the virtual module with `JSON.stringify`, so every field here\n * must be JSON-representable — no functions. (That is why the sitemap's *href*\n * lives here and its options, which carry `filter`/`serialize`, do not.)\n */\ninterface HeadData {\n favicons: boolean;\n faviconLinks: FaviconLink[];\n themeColor: string | null;\n webComponents: boolean;\n wcBase: string;\n editor: boolean;\n /** `<link rel=\"sitemap\">` target, or null when no sitemap was registered. */\n sitemap: string | null;\n /** `config.site`, so `<Seo />` has a canonical base even without `Astro.site`. */\n site: string | null;\n /** Site-level `<Seo />` defaults. Function-free, hence serialisable. */\n seo: GetvitopsSeoOptions;\n}\n\nconst VIRTUAL_ID = 'virtual:getvitops/head';\nconst RESOLVED_ID = `\\0${VIRTUAL_ID}`;\n\nfunction virtualHeadPlugin(data: HeadData) {\n return {\n name: '@getvitops/astro:virtual-head',\n resolveId(id: string) {\n return id === VIRTUAL_ID ? RESOLVED_ID : null;\n },\n load(id: string) {\n return id === RESOLVED_ID ? `export default ${JSON.stringify(data)};` : null;\n },\n };\n}\n\nexport default function getvitops(opts: GetvitopsOptions = {}): AstroIntegration {\n return {\n name: '@getvitops/astro',\n hooks: {\n 'astro:config:setup': async ({ config, updateConfig, injectScript, logger }) => {\n const root = fileURLToPath(config.root);\n const publicDir = fileURLToPath(config.publicDir);\n const webComponents = opts.webComponents !== false;\n const editor = opts.editor === true;\n if (editor && !opts.css)\n logger.warn('editor: true needs a `css` config — it reads design-manifest.json.');\n if (opts.legal && !opts.css)\n logger.warn(\n 'legal: needs a `css` config — the generation runs in the Vite plugin that `css` ' +\n 'registers. Run `npx vitops legal` instead if you do not use `css`.',\n );\n if (opts.seo) {\n // Warn once here rather than letting every page silently drop its\n // canonical — a missing absolute URL is invisible in the output.\n if (!config.site)\n logger.warn(\n 'seo: needs the `site` astro.config option (your deployed URL) — without it <Seo /> ' +\n 'omits canonical, og:url and any relative og:image rather than guessing an origin.',\n );\n if (config.integrations.some((i) => i.name === 'emdash'))\n logger.warn(\n 'seo: emdash() is registered, and its <EmDashHead> emits the same title/description/' +\n 'canonical/og tags from the CMS. Use one or the other — rendering both duplicates ' +\n 'every tag.',\n );\n }\n const hasManifest = !!(opts.favicon?.name && opts.favicon?.themeColor);\n let hasSvg = false;\n\n // 1. Favicons + manifest → public/\n if (opts.favicon) {\n const source = resolve(root, opts.favicon.source);\n hasSvg = source.endsWith('.svg');\n await generateFavicons({\n source,\n outputDir: publicDir,\n ...(opts.favicon.lowResSource\n ? { lowResSource: resolve(root, opts.favicon.lowResSource) }\n : {}),\n });\n if (opts.favicon.name && opts.favicon.themeColor) {\n await writeFaviconManifest(publicDir, {\n name: opts.favicon.name,\n themeColor: opts.favicon.themeColor,\n ...(opts.favicon.backgroundColor\n ? { backgroundColor: opts.favicon.backgroundColor }\n : {}),\n });\n }\n logger.info('generated favicons + manifest → public/');\n }\n\n // 2. Web-component bundles → public/vitops/\n if (webComponents) {\n const corePkg = fileURLToPath(import.meta.resolve('@getvitops/core/package.json'));\n const coreDist = join(dirname(corePkg), 'dist');\n const dest = join(publicDir, 'vitops');\n if (existsSync(coreDist)) {\n const bundles = ['polyfills.js', 'deferred.js', 'elements.js', 'polyfills'];\n if (editor) bundles.push('editor.js');\n for (const f of bundles) {\n const src = join(coreDist, f);\n if (existsSync(src)) cpSync(src, join(dest, f), { recursive: true });\n }\n logger.info('copied web-component bundles → public/vitops/');\n } else {\n logger.warn(\n `@getvitops/core dist not found (${coreDist}); run build:core. Skipping WC copy.`,\n );\n }\n }\n\n // 3. Sitemap — opt-in, delegated to the official @astrojs/sitemap.\n //\n // Registered through updateConfig rather than imported at module scope,\n // because the package is an optional peer. Astro core has no\n // addIntegration() (that is Starlight's plugin API), but appending here is\n // supported: runHookConfigSetup re-reads `integrations.length` each\n // iteration and assigns the merged array back onto the settings, and\n // @astrojs/sitemap declares no `astro:config:setup` hook at all — only\n // config:done / routes:resolved / build:done, which all run afterwards.\n //\n // Runs before the virtual head module so <Head /> can link the result.\n let sitemapHref: string | null = null;\n if (opts.sitemap) {\n const registered = config.integrations.map((i) => i.name);\n if (registered.includes('@astrojs/sitemap')) {\n logger.info(\n 'sitemap: @astrojs/sitemap is already in your integrations — leaving yours in charge.',\n );\n } else if (registered.includes('emdash')) {\n logger.warn(\n 'sitemap: emdash() already serves /sitemap.xml from the database, which also covers ' +\n 'on-demand pages a static sitemap cannot. Skipping — drop the `sitemap` option, or ' +\n 'add @astrojs/sitemap to `integrations` yourself to run both.',\n );\n } else if (!config.site) {\n logger.warn(\n 'sitemap: needs the `site` astro.config option (your deployed URL) — a sitemap lists ' +\n 'absolute URLs. Skipping.',\n );\n } else {\n // Optional peer, loaded the same way as @tailwindcss/vite below.\n //\n // The import is cast because the option surface here is this package's\n // narrowed mirror, not @astrojs/sitemap's own `SitemapOptions`: the two\n // disagree on `serialize`'s entry type (upstream's `changefreq` is the\n // `EnumChangefreq` enum, ours the string union a consumer actually\n // wants to write). The shapes are identical at runtime — the value is\n // forwarded verbatim. Cast the module rather than widening the local to\n // `(o?: unknown)`, which is the wrong variance: a target accepting\n // `unknown` promises to accept *any* argument, which upstream doesn't.\n type SitemapFactory = (o?: GetvitopsSitemapOptions) => AstroIntegration;\n let sitemap: SitemapFactory;\n try {\n ({ default: sitemap } = (await import('@astrojs/sitemap')) as unknown as {\n default: SitemapFactory;\n });\n } catch {\n throw new Error(\n '[getvitops] sitemap requires `@astrojs/sitemap` in your devDependencies — ' +\n 'install it, or drop the `sitemap` option.',\n );\n }\n if (config.output === 'server')\n logger.warn(\n \"sitemap: output: 'server' — @astrojs/sitemap lists prerendered routes only, so \" +\n 'on-demand pages are left out. Add `export const prerender = true` to the pages ' +\n 'you want indexed, or list them in `sitemap.customPages`.',\n );\n const sitemapOpts = opts.sitemap === true ? undefined : opts.sitemap;\n updateConfig({ integrations: [sitemap(sitemapOpts)] });\n sitemapHref = `/${sitemapOpts?.filenameBase ?? 'sitemap'}-index.xml`;\n logger.info(`sitemap registered → ${sitemapHref.slice(1)}`);\n }\n }\n\n // 4. Virtual head module for <Head/>\n updateConfig({\n vite: {\n plugins: [\n virtualHeadPlugin({\n favicons: !!opts.favicon,\n faviconLinks: opts.favicon ? faviconLinks({ hasSvg, manifest: hasManifest }) : [],\n themeColor: opts.favicon?.themeColor ?? null,\n webComponents,\n wcBase: '/vitops',\n editor,\n sitemap: sitemapHref,\n site: config.site ?? null,\n seo: opts.seo ?? {},\n }),\n ],\n },\n });\n\n // 5. CSS — generate + compile + auto-inject (consumer imports nothing).\n if (opts.css) {\n const format: StylesheetFormat = opts.css.format ?? 'tailwind';\n // Typed out above, but a plain-JS config reaches here unchecked and\n // would otherwise fail late with an unresolvable `styles.css` import.\n if ((format as string) === 'design')\n throw new Error(\n \"[getvitops] css.format: 'design' emits only DESIGN.md, not a stylesheet. \" +\n 'Run `npx vitops generate --format design --out .` for that file, and set ' +\n \"css.format to 'tailwind' | 'css' | 'bricks' here.\",\n );\n const out = opts.css.out ?? 'src/styles';\n const plugins = [\n vitops({\n input: opts.css.input ?? 'design-system.json',\n format,\n out,\n // Mirrored inside the generate pass so the served copy can never be\n // missing on a cold start or stale after a config edit.\n ...(editor ? { editorManifestDir: publicDir } : {}),\n // Same reasoning: inside the pass, so the documents track the site\n // config in dev instead of going stale after the first build.\n ...(opts.legal ? { legal: opts.legal } : {}),\n }),\n ];\n // Tailwind is an optional peer — only the `tailwind` format needs it,\n // so it's loaded here rather than imported at module scope. (The\n // plugin is typed against a newer vite than @getvitops/vite's peer;\n // the Plugin shapes are structurally compatible at runtime — Astro\n // forwards them verbatim — so bridge the two type identities.)\n if (format === 'tailwind') {\n let tailwindcss: () => unknown[];\n try {\n ({ default: tailwindcss } = await import('@tailwindcss/vite'));\n } catch {\n throw new Error(\n \"[getvitops] css.format: 'tailwind' requires `tailwindcss` and `@tailwindcss/vite` \" +\n 'in your devDependencies — install them, or switch to css.format: ' +\n \"'css' for the standalone bundle.\",\n );\n }\n plugins.push(...(tailwindcss() as unknown as typeof plugins));\n }\n updateConfig({ vite: { plugins } });\n const cssFile = format === 'tailwind' ? 'tailwind.css' : 'styles.css';\n if (opts.css.inject !== false) {\n const cssPath = resolve(root, out, cssFile);\n injectScript('page-ssr', `import ${JSON.stringify(cssPath)};`);\n logger.info(`design-system CSS (${format}) auto-injected from ${out}/${cssFile}`);\n } else {\n logger.info(\n `design-system CSS (${format}) generated at ${out}/${cssFile} (inject: false — import it from your layout)`,\n );\n }\n }\n },\n },\n };\n}\n","/**\n * The `<Seo />` resolution layer — site-level defaults merged with per-page props\n * into the exact tags to emit.\n *\n * Kept separate from `Seo.astro` deliberately. The component this replaces\n * (`SEO.astro`, deleted in c949cae) fused resolution with markup, which is why it\n * was never unit-tested and why its title handling drifted: it computed a title\n * for `og:title` but left `<title>` to the layout, so the two could disagree.\n * Everything decidable lives here and is asserted in `seo.test.ts`; the component\n * only renders what this returns.\n */\n\n/** Twitter card types, per <https://developer.x.com/en/docs/twitter-for-websites/cards>. */\nexport type SeoTwitterCard = 'summary' | 'summary_large_image' | 'app' | 'player';\n\n/** Open Graph object types this component knows how to describe. */\nexport type SeoOgType = 'website' | 'article' | 'profile';\n\n/**\n * Site-level SEO defaults, passed to `getvitops({ seo })` and baked into the\n * `virtual:getvitops/head` module. Per-page `<Seo />` props override these.\n *\n * The vocabulary mirrors the site-config schema's `seo` block\n * (`@getvitops/generator`, `src/site.ts`) so a `SiteConfig` → these adapter would\n * be a flat field map — but it is not imported from there. This is the\n * integration's own option surface, and it must stay JSON-serialisable: it is\n * `JSON.stringify`d into the virtual module, so no functions, no `URL`s, no dates.\n */\nexport interface GetvitopsSeoOptions {\n /** `og:site_name`, and the `<title>` when a page passes none. */\n siteName?: string;\n /**\n * Page-title pattern; `%s` is the page's own title. e.g. `'%s · Acme'`.\n *\n * Skipped when a page's title already equals `siteName` — a homepage titled\n * \"Acme\" emits `Acme`, not `Acme · Acme` — and never applied to `defaultTitle`.\n */\n titleTemplate?: string;\n /** `<title>` for pages that pass none. Used verbatim — the template is not applied. */\n defaultTitle?: string;\n /** `<meta name=\"description\">` for pages that pass none. */\n defaultDescription?: string;\n /** Site-wide robots directive, e.g. `'index,follow,max-image-preview:large'`. */\n robots?: string;\n /** Google Search Console verification token. */\n googleSiteVerification?: string;\n /** Bing Webmaster Tools verification token. */\n bingSiteVerification?: string;\n openGraph?: {\n /** Default `og:type` (default `'website'`). */\n type?: SeoOgType;\n /** `og:locale`. Written either way — `en-CA` and `en_CA` both emit `en_CA`. */\n locale?: string;\n /** Additional locales the same content exists in → `og:locale:alternate`. */\n localeAlternates?: string[];\n /** Fallback share image. Relative URLs resolve against `site`. */\n image?: { url: string; alt?: string; width?: number; height?: number; type?: string };\n };\n twitter?: {\n /** Card type when no image resolves (default `'summary'`). */\n card?: SeoTwitterCard;\n /** The site's own handle, `@acme`. */\n site?: string;\n /** Default author handle, `@ada`. */\n creator?: string;\n };\n}\n\n/** Per-page `<Seo />` props. Everything is optional; defaults fill the gaps. */\nexport interface SeoProps {\n /** Page title, before `titleTemplate` is applied. */\n title?: string;\n description?: string;\n /** Overrides the computed canonical. Relative values resolve against `site`. */\n canonical?: string;\n /** Overrides `og:site_name` and the title fallback. */\n siteName?: string;\n /** Share image for this page. Relative URLs resolve against `site`. */\n image?: string;\n imageAlt?: string;\n imageWidth?: number;\n imageHeight?: number;\n /** MIME type of the share image, e.g. `'image/png'`. */\n imageType?: string;\n noindex?: boolean;\n nofollow?: boolean;\n noarchive?: boolean;\n nocache?: boolean;\n /** Extra robots directives, e.g. `'max-snippet:-1'`. Appended to the composed value. */\n robotsExtras?: string;\n /** Full robots override. Wins over the flags above and the site default. */\n robots?: string;\n /** `og:type` for this page (default: the site default, else `'website'`). */\n type?: SeoOgType;\n /** Emitted only when `type` is `'article'`. */\n article?: {\n publishedTime?: string;\n modifiedTime?: string;\n expirationTime?: string;\n authors?: string[];\n section?: string;\n tags?: string[];\n };\n /** `og:locale` for this page. */\n locale?: string;\n /** Twitter author handle for this page, `@ada`. */\n twitterCreator?: string;\n /**\n * Translations of *this* page, as `<link rel=\"alternate\" hreflang>`.\n *\n * Explicit only, and it must include the current page. Nothing is inferred:\n * the deleted component derived alternates from the site's locale list and\n * pointed every one of them at that locale's homepage, so every page on the\n * site claimed the homepage as its translation. Only the page knows its own\n * translated URLs. Pass `hreflang: 'x-default'` for the fallback entry.\n */\n alternates?: { hreflang: string; href: string }[];\n}\n\n/** A `<meta property>` tag (Open Graph uses `property`, not `name`). */\nexport interface SeoPropertyTag {\n property: string;\n content: string;\n}\n\n/** A `<meta name>` tag. */\nexport interface SeoNameTag {\n name: string;\n content: string;\n}\n\n/** Everything `<Seo />` renders, already decided. `null` means \"emit nothing\". */\nexport interface ResolvedSeo {\n title: string | null;\n description: string | null;\n canonical: string | null;\n robots: string | null;\n openGraph: SeoPropertyTag[];\n twitter: SeoNameTag[];\n verification: SeoNameTag[];\n alternates: { hreflang: string; href: string }[];\n}\n\n/** Render context supplied by the component from `Astro`. */\nexport interface SeoContext {\n /**\n * The deployed origin — `Astro.site`, or `config.site` via the virtual module.\n *\n * `undefined` when neither is set, and then every absolute-URL tag is dropped\n * rather than being derived from the request. `astro-seo` falls back to\n * `Astro.url` here; on a static build that is `http://localhost:4321`, which\n * would bake a localhost canonical into production HTML.\n */\n site?: URL | undefined;\n /** `Astro.url.pathname` — already includes `base`. */\n pathname: string;\n /** `Astro.url.search`, if the canonical should keep the query string. */\n search?: string;\n}\n\n/** `en-CA` → `en_CA`; Open Graph wants the underscore form. */\nconst ogLocale = (locale: string) => locale.replace('-', '_');\n\n/**\n * Absolute URLs pass through; relative ones resolve against `site`, or are\n * dropped when there is no `site` to resolve them against. A relative `og:image`\n * or canonical is worse than none — crawlers resolve it against whatever origin\n * served the page.\n */\nfunction absolute(url: string | undefined, site: URL | undefined): string | null {\n if (!url) return null;\n try {\n return new URL(url).href;\n } catch {\n return site ? new URL(url, site).href : null;\n }\n}\n\n/** Composes the robots directive, or null when nothing meaningful was asked for. */\nfunction resolveRobots(props: SeoProps, defaults: GetvitopsSeoOptions): string | null {\n if (props.robots) return props.robots;\n const { noindex, nofollow, noarchive, nocache, robotsExtras } = props;\n if (noindex || nofollow || noarchive || nocache || robotsExtras) {\n return [\n noindex ? 'noindex' : 'index',\n nofollow ? 'nofollow' : 'follow',\n ...(noarchive ? ['noarchive'] : []),\n ...(nocache ? ['nocache'] : []),\n ...(robotsExtras ? [robotsExtras] : []),\n ].join(', ');\n }\n // No explicit default either: emit nothing. `index, follow` is what crawlers\n // already assume, so stating it adds bytes and no meaning.\n return defaults.robots ?? null;\n}\n\n/**\n * Merges site defaults with page props into the final tag set.\n *\n * Pure — no `Astro`, no filesystem, no config lookup. Everything it needs comes\n * through its three arguments, which is what makes it testable and what keeps the\n * package free of the consumer-global coupling that sank the previous site model.\n */\nexport function resolveSeo(\n defaults: GetvitopsSeoOptions,\n props: SeoProps,\n ctx: SeoContext,\n): ResolvedSeo {\n const { site } = ctx;\n const siteName = props.siteName ?? defaults.siteName ?? null;\n\n // The template applies to a page's own title only, and only when that title\n // isn't already the site name. Two ways to get \"Acme · Acme\" otherwise: running\n // it over `defaultTitle`, and a homepage whose own title happens to equal the\n // site name — which is the common case, not an edge case.\n const templated = props.title && defaults.titleTemplate && props.title !== siteName;\n const title = props.title\n ? templated\n ? String(defaults.titleTemplate).replaceAll('%s', props.title)\n : props.title\n : (defaults.defaultTitle ?? siteName);\n\n const description = props.description ?? defaults.defaultDescription ?? null;\n\n const canonical = props.canonical\n ? absolute(props.canonical, site)\n : site\n ? new URL(ctx.pathname + (ctx.search ?? ''), site).href\n : null;\n\n const image = absolute(props.image, site) ?? absolute(defaults.openGraph?.image?.url, site);\n // Only inherit the default image's metadata when the page didn't bring its own\n // image — otherwise a page's image would carry the default's alt text.\n const usingDefaultImage = !props.image;\n const defaultImage = defaults.openGraph?.image;\n const imageAlt = props.imageAlt ?? (usingDefaultImage ? defaultImage?.alt : undefined);\n const imageWidth = props.imageWidth ?? (usingDefaultImage ? defaultImage?.width : undefined);\n const imageHeight = props.imageHeight ?? (usingDefaultImage ? defaultImage?.height : undefined);\n const imageType = props.imageType ?? (usingDefaultImage ? defaultImage?.type : undefined);\n\n const type = props.type ?? defaults.openGraph?.type ?? 'website';\n const locale = props.locale ?? defaults.openGraph?.locale;\n\n const openGraph: SeoPropertyTag[] = [];\n const og = (property: string, content: string | number | null | undefined) => {\n if (content !== null && content !== undefined && content !== '')\n openGraph.push({ property, content: String(content) });\n };\n\n og('og:type', type);\n og('og:title', title);\n og('og:description', description);\n og('og:url', canonical);\n og('og:site_name', siteName);\n og('og:locale', locale ? ogLocale(locale) : null);\n for (const alt of defaults.openGraph?.localeAlternates ?? [])\n og('og:locale:alternate', ogLocale(alt));\n og('og:image', image);\n if (image) {\n og('og:image:alt', imageAlt);\n og('og:image:width', imageWidth);\n og('og:image:height', imageHeight);\n og('og:image:type', imageType);\n }\n if (type === 'article' && props.article) {\n const a = props.article;\n og('article:published_time', a.publishedTime);\n og('article:modified_time', a.modifiedTime);\n og('article:expiration_time', a.expirationTime);\n for (const author of a.authors ?? []) og('article:author', author);\n og('article:section', a.section);\n for (const tag of a.tags ?? []) og('article:tag', tag);\n }\n\n const twitter: SeoNameTag[] = [];\n const tw = (name: string, content: string | null | undefined) => {\n if (content) twitter.push({ name, content });\n };\n // A card with a large image is only honoured if there *is* an image.\n tw('twitter:card', image ? 'summary_large_image' : (defaults.twitter?.card ?? 'summary'));\n tw('twitter:site', defaults.twitter?.site);\n tw('twitter:creator', props.twitterCreator ?? defaults.twitter?.creator);\n tw('twitter:title', title);\n tw('twitter:description', description);\n tw('twitter:image', image);\n tw('twitter:image:alt', image ? imageAlt : null);\n\n const verification: SeoNameTag[] = [];\n if (defaults.googleSiteVerification)\n verification.push({\n name: 'google-site-verification',\n content: defaults.googleSiteVerification,\n });\n if (defaults.bingSiteVerification)\n verification.push({ name: 'msvalidate.01', content: defaults.bingSiteVerification });\n\n return {\n title,\n description,\n canonical,\n robots: resolveRobots(props, defaults),\n openGraph,\n twitter,\n verification,\n alternates: props.alternates ?? [],\n };\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getvitops/astro",
|
|
3
|
-
"version": "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,29 @@
|
|
|
39
42
|
"access": "public"
|
|
40
43
|
},
|
|
41
44
|
"dependencies": {
|
|
42
|
-
"@getvitops/core": "0.
|
|
43
|
-
"@getvitops/
|
|
44
|
-
"@getvitops/generator": "0.
|
|
45
|
-
"@getvitops/
|
|
45
|
+
"@getvitops/core": "1.0.0",
|
|
46
|
+
"@getvitops/utils": "1.0.0",
|
|
47
|
+
"@getvitops/generator": "1.0.0",
|
|
48
|
+
"@getvitops/vite": "1.0.0"
|
|
46
49
|
},
|
|
47
50
|
"devDependencies": {
|
|
51
|
+
"@astrojs/sitemap": "^3.7.3",
|
|
48
52
|
"@tailwindcss/vite": "^4.3.3",
|
|
49
53
|
"astro": "^7.1.2",
|
|
50
54
|
"tailwindcss": "^4.3.3",
|
|
51
55
|
"vitest": "^4.1.9"
|
|
52
56
|
},
|
|
53
57
|
"peerDependencies": {
|
|
58
|
+
"@astrojs/sitemap": ">=3.6",
|
|
54
59
|
"@tailwindcss/vite": ">=4",
|
|
55
60
|
"astro": ">=7",
|
|
56
61
|
"astro-icon": ">=1",
|
|
57
62
|
"tailwindcss": ">=4"
|
|
58
63
|
},
|
|
59
64
|
"peerDependenciesMeta": {
|
|
65
|
+
"@astrojs/sitemap": {
|
|
66
|
+
"optional": true
|
|
67
|
+
},
|
|
60
68
|
"@tailwindcss/vite": {
|
|
61
69
|
"optional": true
|
|
62
70
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
3
|
* Cards — a `Subgrid` whose items are `card`s. The child's own class and style
|
|
4
|
-
* ride along, so `<article class="bg-surface">` becomes `<li class="card bg-surface">`.
|
|
4
|
+
* ride along, so `<article class="bg-surface-muted">` becomes `<li class="card bg-surface-muted">`.
|
|
5
5
|
*
|
|
6
6
|
* The class has to be put on the `<li>` we hand to Subgrid rather than written
|
|
7
7
|
* into the slotted element: `serialize()` returns a node's *inner* HTML, so the
|
|
@@ -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
|
{/*
|
|
@@ -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} />)}
|