@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/dist/index.mjs CHANGED
@@ -1,11 +1,229 @@
1
- import { cpSync, existsSync } from "node:fs";
2
- import { dirname, join, resolve } from "node:path";
1
+ import { cpSync, existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join, relative, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- import { faviconLinks, generateFavicons, writeFaviconManifest } from "@getvitops/utils";
4
+ import { isSiteConfig, resolveSiteConfig } from "@getvitops/generator";
5
+ import { collectIconRefs, faviconLinks, generateFavicons, generateIconInclude, resolveIcon, scanFiles, writeFaviconManifest } from "@getvitops/utils";
5
6
  import vitops from "@getvitops/vite";
7
+ import { fontProviders } from "astro/config";
6
8
 
7
9
  export * from "@getvitops/utils"
8
10
 
11
+ //#region src/analytics.ts
12
+ /**
13
+ * IDs land inside an inline `<script>`, so they are checked rather than trusted.
14
+ * A `<\/script>` in a measurement ID is not a plausible accident, but the cost of
15
+ * the guard is one regex and the cost of not having it is arbitrary script
16
+ * injection from a config file.
17
+ */
18
+ const SAFE_ID = /^[\w.-]+$/;
19
+ function bad(value) {
20
+ return !SAFE_ID.test(value);
21
+ }
22
+ /** JSON is a subset of JS object-literal syntax; `<` can't appear unescaped. */
23
+ function literal(value) {
24
+ return JSON.stringify(value).replaceAll("<", "\\u003c");
25
+ }
26
+ function googleAnalytics(input) {
27
+ const opts = typeof input === "string" ? { id: input } : input;
28
+ if (bad(opts.id)) return `googleAnalytics: "${opts.id}" is not a valid measurement ID. Skipped.`;
29
+ const config = opts.config ? `,${literal(opts.config)}` : "";
30
+ return {
31
+ key: "googleAnalytics",
32
+ provider: "Google Analytics",
33
+ category: opts.category ?? "analytics",
34
+ setsCookies: true,
35
+ cookies: [
36
+ "_ga",
37
+ "_ga_*",
38
+ "_gid",
39
+ "_gat",
40
+ "_gat_*"
41
+ ],
42
+ strategy: "idle",
43
+ gated: true,
44
+ inline: `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag('js',new Date());gtag('config','${opts.id}'${config});`,
45
+ src: `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(opts.id)}`,
46
+ attrs: {}
47
+ };
48
+ }
49
+ function clarity(input) {
50
+ const opts = typeof input === "string" ? { id: input } : input;
51
+ if (bad(opts.id)) return `clarity: "${opts.id}" is not a valid project ID. Skipped.`;
52
+ const category = opts.category ?? "analytics";
53
+ return {
54
+ key: "clarity",
55
+ provider: "Microsoft Clarity",
56
+ category,
57
+ setsCookies: true,
58
+ cookies: [
59
+ "_clck",
60
+ "_clsk",
61
+ "MUID",
62
+ "CLID",
63
+ "ANONCHK",
64
+ "SM"
65
+ ],
66
+ strategy: "idle",
67
+ gated: true,
68
+ inline: `(function(c,l,a,r,i,t,y){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y)})(window,document,"clarity","script","${opts.id}");window.clarity('consentv2',{ad_Storage:'${category === "marketing" ? "granted" : "denied"}',analytics_Storage:'granted'});`,
69
+ src: null,
70
+ attrs: {}
71
+ };
72
+ }
73
+ function matomo(opts) {
74
+ const siteId = String(opts.siteId);
75
+ if (bad(siteId)) return `matomo: "${siteId}" is not a valid site ID. Skipped.`;
76
+ let base;
77
+ try {
78
+ base = new URL(opts.url);
79
+ } catch {
80
+ return `matomo: "${opts.url}" is not a valid URL. Skipped.`;
81
+ }
82
+ const url = `${base.origin}${base.pathname.replace(/\/*$/, "")}/`;
83
+ const cookies = opts.cookies === true;
84
+ return {
85
+ key: "matomo",
86
+ provider: "Matomo",
87
+ category: cookies ? "analytics" : "necessary",
88
+ setsCookies: cookies,
89
+ cookies: cookies ? [
90
+ "_pk_id*",
91
+ "_pk_ses*",
92
+ "_pk_ref*",
93
+ "_pk_cvar*",
94
+ "_pk_hsr*"
95
+ ] : [],
96
+ strategy: "idle",
97
+ gated: true,
98
+ inline: `var _paq=window._paq=window._paq||[];` + (cookies ? "" : `_paq.push(['disableCookies']);`) + `_paq.push(['trackPageView']);_paq.push(['enableLinkTracking']);(function(){var u="${url}";_paq.push(['setTrackerUrl',u+'matomo.php']);_paq.push(['setSiteId','${siteId}']);var d=document,g=d.createElement('script'),s=d.getElementsByTagName('script')[0];g.async=true;g.src=u+'matomo.js';s.parentNode.insertBefore(g,s)})();`,
99
+ src: null,
100
+ attrs: {}
101
+ };
102
+ }
103
+ function plausible(input) {
104
+ const opts = typeof input === "string" ? { domain: input } : input;
105
+ if (bad(opts.domain)) return `plausible: "${opts.domain}" is not a valid domain. Skipped.`;
106
+ return {
107
+ key: "plausible",
108
+ provider: "Plausible Analytics",
109
+ category: "necessary",
110
+ setsCookies: false,
111
+ cookies: [],
112
+ strategy: "idle",
113
+ gated: true,
114
+ inline: "",
115
+ src: opts.src ?? "https://plausible.io/js/script.js",
116
+ attrs: { "data-domain": opts.domain }
117
+ };
118
+ }
119
+ /**
120
+ * Resolve the configured providers into the tags to emit.
121
+ *
122
+ * An invalid provider is dropped with a warning rather than throwing: one
123
+ * malformed ID should not take down a build, and a missing tag is visible in the
124
+ * output while a failed build is just a red wall.
125
+ */
126
+ function resolveAnalytics(opts, ctx = {}) {
127
+ const warnings = [];
128
+ const tags = [];
129
+ if (!opts) return {
130
+ tags,
131
+ needsRuntime: false,
132
+ warnings
133
+ };
134
+ const strategy = opts.strategy ?? "idle";
135
+ const results = [
136
+ opts.googleAnalytics === void 0 ? null : googleAnalytics(opts.googleAnalytics),
137
+ opts.clarity === void 0 ? null : clarity(opts.clarity),
138
+ opts.matomo === void 0 ? null : matomo(opts.matomo),
139
+ opts.plausible === void 0 ? null : plausible(opts.plausible)
140
+ ];
141
+ for (const result of results) {
142
+ if (result === null) continue;
143
+ if (typeof result === "string") {
144
+ warnings.push(result);
145
+ continue;
146
+ }
147
+ const gated = result.category !== "necessary" || strategy !== "async";
148
+ tags.push({
149
+ ...result,
150
+ strategy,
151
+ gated
152
+ });
153
+ }
154
+ const setting = tags.filter((t) => t.setsCookies);
155
+ if (setting.length && !ctx.consent) warnings.push(`${setting.map((t) => t.provider).join(" and ")} ${setting.length === 1 ? "sets" : "set"} cookies (${setting.flatMap((t) => t.cookies).join(", ")}) and \`consent\` is off, so ${setting.length === 1 ? "it loads" : "they load"} for every visitor with no way to decline. Set \`consent: true\`, or switch to a cookieless provider (Plausible, or Matomo with its default \`cookies: false\`).`);
156
+ return {
157
+ tags,
158
+ needsRuntime: tags.some((t) => t.gated),
159
+ warnings
160
+ };
161
+ }
162
+ /** Which optional categories a set of tags actually needs a visitor to decide. */
163
+ function consentCategories(tags) {
164
+ const used = /* @__PURE__ */ new Set();
165
+ for (const tag of tags) if (tag.category !== "necessary") used.add(tag.category);
166
+ return [
167
+ "analytics",
168
+ "marketing",
169
+ "preferences"
170
+ ].filter((c) => used.has(c));
171
+ }
172
+
173
+ //#endregion
174
+ //#region src/fonts.ts
175
+ /**
176
+ * Provider names, matching `fontProviders`' keys 1:1 (verified against astro@7.1.2).
177
+ * The generator's `SiteFontSchema` enumerates the same set, so a mismatch is a
178
+ * compile-time-invisible drift — `resolveFonts` throws on an unknown name rather than
179
+ * quietly dropping the family and leaving the token resolving to a fallback stack.
180
+ */
181
+ const PROVIDERS = new Set(Object.keys(fontProviders));
182
+ /**
183
+ * Providers a declaration can construct with no arguments.
184
+ *
185
+ * `adobe` is the one that can't: `fontProviders.adobe({ id })` takes a project API id,
186
+ * and the point of that id being an argument is that it comes from the environment. A
187
+ * JSON declaration has nowhere to put `process.env.ADOBE_ID` without either inlining the
188
+ * key into a committed file or inventing an interpolation syntax, so Adobe families are
189
+ * declared in `astro.config` directly — see the error thrown below.
190
+ */
191
+ const NEEDS_OPTIONS = new Set(["adobe"]);
192
+ /**
193
+ * Map declarations onto Astro's `fonts:` entries.
194
+ *
195
+ * `preload` is dropped from the Astro entry on purpose — it is not part of the config
196
+ * schema there, it is a prop on the `<Font />` component. It rides along in `head`
197
+ * instead, which is what makes preload a property of the declaration rather than
198
+ * something the consumer hand-picks in their layout.
199
+ */
200
+ function resolveFonts(decls) {
201
+ const fonts = [];
202
+ const head = [];
203
+ const seen = /* @__PURE__ */ new Set();
204
+ for (const decl of decls) {
205
+ if (!PROVIDERS.has(decl.provider)) throw new Error(`[getvitops] fonts: unknown provider "${decl.provider}" for "${decl.name}". Astro provides: ${[...PROVIDERS].sort().join(", ")}.`);
206
+ if (NEEDS_OPTIONS.has(decl.provider)) throw new Error(`[getvitops] fonts: provider "${decl.provider}" needs an API id from your environment ("${decl.name}"), and a JSON declaration has nowhere to hold one. Declare this family in astro.config instead — fonts: [{ provider: fontProviders.${decl.provider}({ id: process.env.ADOBE_ID }), name: '${decl.name}', cssVariable: '${decl.cssVariable}' }] — and render its <Font /> yourself; the design-system token can still point at it.`);
207
+ if (!decl.cssVariable.startsWith("--")) throw new Error(`[getvitops] fonts: cssVariable "${decl.cssVariable}" (${decl.name}) must start with "--".`);
208
+ if (seen.has(decl.cssVariable)) throw new Error(`[getvitops] fonts: two families both declare ${decl.cssVariable} (the second is "${decl.name}"). Astro resolves one family per variable — give them separate variables, or drop one.`);
209
+ seen.add(decl.cssVariable);
210
+ const { provider, preload, ...rest } = decl;
211
+ fonts.push({
212
+ ...rest,
213
+ provider: fontProviders[provider]()
214
+ });
215
+ head.push({
216
+ cssVariable: decl.cssVariable,
217
+ preload: preload ?? false
218
+ });
219
+ }
220
+ return {
221
+ fonts,
222
+ head
223
+ };
224
+ }
225
+
226
+ //#endregion
9
227
  //#region src/integration.ts
10
228
  /**
11
229
  * @getvitops/astro — the Astro integration. One entry wires the design system's
@@ -16,6 +234,35 @@ export * from "@getvitops/utils"
16
234
  * The `<Head />` component (shipped alongside) renders the tags, reading resolved
17
235
  * config from the `virtual:getvitops/head` module this integration provides.
18
236
  */
237
+ /**
238
+ * Which providers `getvitops({ analytics })` configures that the site config's
239
+ * own `analytics` block does not.
240
+ *
241
+ * The two are separate surfaces on purpose — the integration must not import
242
+ * `SiteConfig` — but they describe the same site, and a disagreement between them
243
+ * is a compliance defect rather than a style issue: `vitops legal` derives the
244
+ * cookie notice from the site config, so a provider missing there is a tag the
245
+ * site runs and its own notice never mentions. That is precisely what the
246
+ * generator's processor table exists to prevent.
247
+ *
248
+ * Read is best-effort — the `legal` step already reports an unreadable config,
249
+ * and a second copy of that error helps nobody.
250
+ */
251
+ const SITE_CONFIG_KEYS = {
252
+ googleAnalytics: "googleAnalyticsId",
253
+ clarity: "clarityId",
254
+ matomo: "matomo",
255
+ plausible: "plausibleDomain"
256
+ };
257
+ function undisclosedProviders(configPath, analytics) {
258
+ let declared;
259
+ try {
260
+ declared = JSON.parse(readFileSync(configPath, "utf8")).analytics ?? {};
261
+ } catch {
262
+ return [];
263
+ }
264
+ return Object.keys(SITE_CONFIG_KEYS).filter((key) => analytics[key] !== void 0 && !declared[SITE_CONFIG_KEYS[key]]);
265
+ }
19
266
  const VIRTUAL_ID = "virtual:getvitops/head";
20
267
  const RESOLVED_ID = `\0${VIRTUAL_ID}`;
21
268
  function virtualHeadPlugin(data) {
@@ -29,6 +276,63 @@ function virtualHeadPlugin(data) {
29
276
  }
30
277
  };
31
278
  }
279
+ const ICONS_ID = "virtual:getvitops/icons";
280
+ const RESOLVED_ICONS_ID = `\0${ICONS_ID}`;
281
+ function virtualIconsPlugin(data) {
282
+ return {
283
+ name: "@getvitops/astro:virtual-icons",
284
+ resolveId(id) {
285
+ return id === ICONS_ID ? RESOLVED_ICONS_ID : null;
286
+ },
287
+ load(id) {
288
+ return id === RESOLVED_ICONS_ID ? `export default ${JSON.stringify(data)};` : null;
289
+ }
290
+ };
291
+ }
292
+ /** Defaults live here so the component and the integration can't drift. */
293
+ const ICON_DEFAULTS = {
294
+ ui: "fa7-solid",
295
+ brand: "simple-icons",
296
+ sprite: "/vitops/icons.svg",
297
+ scan: ["src"]
298
+ };
299
+ /**
300
+ * Probe an optional icon integration so it can be registered for the consumer's
301
+ * OWN `<Icon>` usage. Nothing here decides how our `<Icon />` renders — that is
302
+ * `engine`, and it never needs a peer.
303
+ */
304
+ /**
305
+ * Indirection so the bundler can't resolve the specifier statically. See the
306
+ * import site in the `tailwind` branch for why that matters.
307
+ */
308
+ const TAILWIND_VITE = "@tailwindcss/vite";
309
+ async function probeIconPackage(name) {
310
+ try {
311
+ await import(
312
+ /* @vite-ignore */
313
+ name
314
+ );
315
+ return true;
316
+ } catch {
317
+ return false;
318
+ }
319
+ }
320
+ /**
321
+ * Does the file at `path` hold a site config rather than a bare design system?
322
+ *
323
+ * Swallows a missing or unparseable file and answers `false`: this only decides
324
+ * whether the other options may default their paths to this one, and the real
325
+ * read happens in the Vite plugin, which reports a parse error properly. Failing
326
+ * the build here would turn "your JSON has a trailing comma" into an error about
327
+ * an option the consumer never set.
328
+ */
329
+ function readsAsSiteConfig(path) {
330
+ try {
331
+ return isSiteConfig(JSON.parse(readFileSync(path, "utf8")));
332
+ } catch {
333
+ return false;
334
+ }
335
+ }
32
336
  function getvitops(opts = {}) {
33
337
  return {
34
338
  name: "@getvitops/astro",
@@ -38,6 +342,26 @@ function getvitops(opts = {}) {
38
342
  const webComponents = opts.webComponents !== false;
39
343
  const editor = opts.editor === true;
40
344
  if (editor && !opts.css) logger.warn("editor: true needs a `css` config — it reads design-manifest.json.");
345
+ 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`.");
346
+ const cssInput = opts.css ? opts.css.input ?? "design-system.json" : void 0;
347
+ const siteInput = opts.site?.input ?? (cssInput != null && readsAsSiteConfig(resolve(root, cssInput)) ? cssInput : void 0);
348
+ if (opts.site && !opts.css) logger.warn("site: needs a `css` config — it is read during generation, which runs in the Vite plugin that `css` registers. Without it the site config changes nothing here.");
349
+ const legalInput = opts.legal?.input ?? siteInput;
350
+ if (opts.legal && !legalInput) throw new Error("[getvitops] legal: needs `legal.input` (a site config path), the top-level `site` option, or a `css.input` that is itself a site config.");
351
+ if (opts.seo) {
352
+ 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.");
353
+ 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.");
354
+ }
355
+ const consentEnabled = !!opts.consent;
356
+ const consentOpts = typeof opts.consent === "object" ? opts.consent : {};
357
+ const analytics = resolveAnalytics(opts.analytics, { consent: consentEnabled });
358
+ const detectedCategories = consentCategories(analytics.tags);
359
+ const offeredCategories = consentOpts.categories ?? (detectedCategories.length ? detectedCategories : ["analytics"]);
360
+ for (const warning of analytics.warnings) logger.warn(warning);
361
+ if (opts.analytics && legalInput) {
362
+ const undisclosed = undisclosedProviders(resolve(root, legalInput), opts.analytics);
363
+ if (undisclosed.length) logger.warn(`analytics: ${undisclosed.join(", ")} ${undisclosed.length === 1 ? "is" : "are"} configured here but absent from your site config's \`analytics\` block, so the cookie notice \`legal\` generates will not disclose ${undisclosed.length === 1 ? "it" : "them"}. Add ${undisclosed.map((k) => SITE_CONFIG_KEYS[k]).join(", ")} to ${legalInput}.`);
364
+ }
41
365
  const hasManifest = !!(opts.favicon?.name && opts.favicon?.themeColor);
42
366
  let hasSvg = false;
43
367
  if (opts.favicon) {
@@ -46,7 +370,8 @@ function getvitops(opts = {}) {
46
370
  await generateFavicons({
47
371
  source,
48
372
  outputDir: publicDir,
49
- ...opts.favicon.lowResSource ? { lowResSource: resolve(root, opts.favicon.lowResSource) } : {}
373
+ ...opts.favicon.lowResSource ? { lowResSource: resolve(root, opts.favicon.lowResSource) } : {},
374
+ ...opts.favicon.backgroundColor ? { backgroundColor: opts.favicon.backgroundColor } : {}
50
375
  });
51
376
  if (opts.favicon.name && opts.favicon.themeColor) await writeFaviconManifest(publicDir, {
52
377
  name: opts.favicon.name,
@@ -55,16 +380,45 @@ function getvitops(opts = {}) {
55
380
  });
56
381
  logger.info("generated favicons + manifest → public/");
57
382
  }
58
- if (webComponents) {
383
+ let headFonts = [];
384
+ if (opts.fonts) {
385
+ const given = Array.isArray(opts.fonts) ? { families: opts.fonts } : typeof opts.fonts === "string" ? { input: opts.fonts } : opts.fonts === true ? {} : opts.fonts;
386
+ const o = {
387
+ ...given,
388
+ ...given.input == null && given.families == null && siteInput ? {
389
+ input: siteInput,
390
+ ...opts.site?.siteEnv ? { siteEnv: opts.site.siteEnv } : {}
391
+ } : {}
392
+ };
393
+ const declared = [];
394
+ if (o.input) {
395
+ const site = resolveSiteConfig(JSON.parse(readFileSync(resolve(root, o.input), "utf8")), o.siteEnv ?? "production");
396
+ declared.push(...site.fonts ?? []);
397
+ }
398
+ declared.push(...o.families ?? []);
399
+ if (declared.length === 0) logger.warn("fonts: nothing declared — no webfonts will load. Add families, or drop the option.");
400
+ else {
401
+ const existing = new Set((config.fonts ?? []).map((f) => f.cssVariable));
402
+ const { fonts, head } = resolveFonts(declared);
403
+ for (const f of head) if (existing.has(f.cssVariable)) logger.warn(`fonts: ${f.cssVariable} is already declared in your astro.config \`fonts\` array. Astro resolves one family per variable, so one of the two will be dropped — remove it from one place.`);
404
+ updateConfig({ fonts });
405
+ headFonts = head;
406
+ const preloaded = head.filter((f) => f.preload !== false).length;
407
+ logger.info(`fonts: ${fonts.length} famil${fonts.length === 1 ? "y" : "ies"} registered via Astro's Fonts API (${preloaded} preloaded) — <Head /> emits <Font /> for each`);
408
+ }
409
+ }
410
+ const consentRuntime = consentEnabled || analytics.needsRuntime;
411
+ if (webComponents || consentRuntime) {
59
412
  const coreDist = join(dirname(fileURLToPath(import.meta.resolve("@getvitops/core/package.json"))), "dist");
60
413
  const dest = join(publicDir, "vitops");
61
414
  if (existsSync(coreDist)) {
62
- const bundles = [
415
+ const bundles = webComponents ? [
63
416
  "polyfills.js",
64
417
  "deferred.js",
65
418
  "elements.js",
66
419
  "polyfills"
67
- ];
420
+ ] : [];
421
+ if (consentRuntime) bundles.push("consent.js");
68
422
  if (editor) bundles.push("editor.js");
69
423
  for (const f of bundles) {
70
424
  const src = join(coreDist, f);
@@ -73,7 +427,107 @@ function getvitops(opts = {}) {
73
427
  logger.info("copied web-component bundles → public/vitops/");
74
428
  } else logger.warn(`@getvitops/core dist not found (${coreDist}); run build:core. Skipping WC copy.`);
75
429
  }
76
- updateConfig({ vite: { plugins: [virtualHeadPlugin({
430
+ let sitemapHref = null;
431
+ if (opts.sitemap) {
432
+ const registered = config.integrations.map((i) => i.name);
433
+ if (registered.includes("@astrojs/sitemap")) logger.info("sitemap: @astrojs/sitemap is already in your integrations — leaving yours in charge.");
434
+ 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.");
435
+ else if (!config.site) logger.warn("sitemap: needs the `site` astro.config option (your deployed URL) — a sitemap lists absolute URLs. Skipping.");
436
+ else {
437
+ let sitemap;
438
+ try {
439
+ ({default: sitemap} = await import("@astrojs/sitemap"));
440
+ } catch {
441
+ throw new Error("[getvitops] sitemap requires `@astrojs/sitemap` in your devDependencies — install it, or drop the `sitemap` option.");
442
+ }
443
+ 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`.");
444
+ const sitemapOpts = opts.sitemap === true ? void 0 : opts.sitemap;
445
+ updateConfig({ integrations: [sitemap(sitemapOpts)] });
446
+ sitemapHref = `/${sitemapOpts?.filenameBase ?? "sitemap"}-index.xml`;
447
+ logger.info(`sitemap registered → ${sitemapHref.slice(1)}`);
448
+ }
449
+ }
450
+ let iconsData = null;
451
+ if (opts.icons) {
452
+ const o = opts.icons === true ? {} : opts.icons;
453
+ const ui = o.ui ?? ICON_DEFAULTS.ui;
454
+ const brand = o.brand ?? ICON_DEFAULTS.brand;
455
+ const spriteHref = o.sprite ?? ICON_DEFAULTS.sprite;
456
+ const weightOpt = o.weight ? { weight: o.weight } : {};
457
+ const include = o.include ? generateIconInclude({
458
+ ui,
459
+ brand,
460
+ ...weightOpt,
461
+ ...o.include
462
+ }) : {};
463
+ const add = (prefix, name) => {
464
+ const list = include[prefix] ??= [];
465
+ if (!list.includes(name)) list.push(name);
466
+ };
467
+ const scanDirs = o.scan === false ? [] : o.scan ?? [...ICON_DEFAULTS.scan];
468
+ const unresolved = [];
469
+ const dynamic = [];
470
+ for (const dir of scanDirs) {
471
+ const abs = resolve(root, dir);
472
+ if (!existsSync(abs)) continue;
473
+ const found = collectIconRefs(scanFiles(abs));
474
+ dynamic.push(...found.dynamic.map((d) => ({
475
+ file: relative(root, d.file),
476
+ line: d.line,
477
+ expr: d.expr
478
+ })));
479
+ for (const name of found.names) {
480
+ const colon = name.indexOf(":");
481
+ if (colon > 0) {
482
+ add(name.slice(0, colon), name.slice(colon + 1));
483
+ continue;
484
+ }
485
+ try {
486
+ const q = resolveIcon(name, ui, weightOpt);
487
+ add(q.slice(0, q.indexOf(":")), q.slice(q.indexOf(":") + 1));
488
+ } catch {
489
+ unresolved.push(name);
490
+ }
491
+ }
492
+ }
493
+ const wantsInclude = config.output !== "static";
494
+ if (dynamic.length && wantsInclude) logger.warn(`icons: ${dynamic.length} icon name(s) are computed at runtime and cannot be bundled automatically:\n` + dynamic.map((d) => ` ${d.file}:${d.line} ${d.expr}`).join("\n") + `\n Declare them in \`icons.include\` (or set \`icons.scan: false\` to silence this).`);
495
+ if (unresolved.length) logger.warn(`icons: ${unresolved.length} name(s) are not in the semantic map for '${ui}': ${unresolved.join(", ")}. Left as-is — fine for a local src/icons/*.svg or a sprite id, a typo otherwise.`);
496
+ const engine = o.engine ?? "inline";
497
+ if (o.sprite && !opts.css) logger.warn("icons: the sprite is written by the same pass that generates the CSS, so `sprite` without `css` produces no file. Add `css`, or run `vitops icons --sprite`.");
498
+ const already = config.integrations.map((i) => i.name).find((n) => n === "astro-icon" || n === "astro-iconset");
499
+ if (o.register === false) logger.info("icons: register: false — resolving names only, not registering anything.");
500
+ else if (already) logger.info(`icons: ${already} is already in your integrations — leaving yours in charge.`);
501
+ else {
502
+ const pkg = await probeIconPackage("astro-icon") ? "astro-icon" : await probeIconPackage("astro-iconset") ? "astro-iconset" : null;
503
+ if (pkg) {
504
+ const { default: icon } = await import(
505
+ /* @vite-ignore */
506
+ pkg
507
+ );
508
+ updateConfig({ integrations: [icon(wantsInclude ? { include } : {})] });
509
+ logger.info(wantsInclude ? `icons: ${pkg} registered — bundling ${Object.values(include).flat().length} icon(s)` : `icons: ${pkg} registered (output: 'static' — no include needed)`);
510
+ }
511
+ }
512
+ iconsData = {
513
+ engine,
514
+ root,
515
+ ui,
516
+ brand,
517
+ weight: o.weight ?? null,
518
+ overrides: o.overrides ?? {},
519
+ sprite: engine === "sprite" || o.sprite ? spriteHref : null
520
+ };
521
+ }
522
+ updateConfig({ vite: { plugins: [virtualIconsPlugin(iconsData ?? {
523
+ engine: "none",
524
+ root,
525
+ ui: ICON_DEFAULTS.ui,
526
+ brand: ICON_DEFAULTS.brand,
527
+ weight: null,
528
+ overrides: {},
529
+ sprite: null
530
+ }), virtualHeadPlugin({
77
531
  favicons: !!opts.favicon,
78
532
  faviconLinks: opts.favicon ? faviconLinks({
79
533
  hasSvg,
@@ -82,21 +536,42 @@ function getvitops(opts = {}) {
82
536
  themeColor: opts.favicon?.themeColor ?? null,
83
537
  webComponents,
84
538
  wcBase: "/vitops",
85
- editor
539
+ editor,
540
+ sitemap: sitemapHref,
541
+ fonts: headFonts,
542
+ site: config.site ?? null,
543
+ seo: opts.seo ?? {},
544
+ analytics: opts.analytics ?? {},
545
+ consent: consentEnabled,
546
+ consentCategories: offeredCategories,
547
+ consentPolicyUrl: consentOpts.policyUrl ?? null,
548
+ consentRuntime
86
549
  })] } });
87
550
  if (opts.css) {
88
551
  const format = opts.css.format ?? "tailwind";
552
+ 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
553
  const out = opts.css.out ?? "src/styles";
90
554
  const plugins = [vitops({
91
555
  input: opts.css.input ?? "design-system.json",
556
+ ...opts.css.theme != null ? { theme: opts.css.theme } : {},
92
557
  format,
93
558
  out,
94
- ...editor ? { editorManifestDir: publicDir } : {}
559
+ ...editor ? { editorManifestDir: publicDir } : {},
560
+ ...iconsData?.sprite ? { spriteDir: publicDir } : {},
561
+ ...opts.legal ? { legal: {
562
+ ...opts.legal,
563
+ input: legalInput
564
+ } } : {},
565
+ ...opts.site ? { site: opts.site } : {},
566
+ ...opts.css.systemColorScheme != null ? { systemColorScheme: opts.css.systemColorScheme } : {}
95
567
  })];
96
568
  if (format === "tailwind") {
97
569
  let tailwindcss;
98
570
  try {
99
- ({default: tailwindcss} = await import("@tailwindcss/vite"));
571
+ ({default: tailwindcss} = await import(
572
+ /* @vite-ignore */
573
+ TAILWIND_VITE
574
+ ));
100
575
  } catch {
101
576
  throw new Error("[getvitops] css.format: 'tailwind' requires `tailwindcss` and `@tailwindcss/vite` in your devDependencies — install them, or switch to css.format: 'css' for the standalone bundle.");
102
577
  }
@@ -115,5 +590,127 @@ function getvitops(opts = {}) {
115
590
  }
116
591
 
117
592
  //#endregion
118
- export { getvitops as default };
593
+ //#region src/seo.ts
594
+ /** `en-CA` → `en_CA`; Open Graph wants the underscore form. */
595
+ const ogLocale = (locale) => locale.replace("-", "_");
596
+ /**
597
+ * Absolute URLs pass through; relative ones resolve against `site`, or are
598
+ * dropped when there is no `site` to resolve them against. A relative `og:image`
599
+ * or canonical is worse than none — crawlers resolve it against whatever origin
600
+ * served the page.
601
+ */
602
+ function absolute(url, site) {
603
+ if (!url) return null;
604
+ try {
605
+ return new URL(url).href;
606
+ } catch {
607
+ return site ? new URL(url, site).href : null;
608
+ }
609
+ }
610
+ /** Composes the robots directive, or null when nothing meaningful was asked for. */
611
+ function resolveRobots(props, defaults) {
612
+ if (props.robots) return props.robots;
613
+ const { noindex, nofollow, noarchive, nocache, robotsExtras } = props;
614
+ if (noindex || nofollow || noarchive || nocache || robotsExtras) return [
615
+ noindex ? "noindex" : "index",
616
+ nofollow ? "nofollow" : "follow",
617
+ ...noarchive ? ["noarchive"] : [],
618
+ ...nocache ? ["nocache"] : [],
619
+ ...robotsExtras ? [robotsExtras] : []
620
+ ].join(", ");
621
+ return defaults.robots ?? null;
622
+ }
623
+ /**
624
+ * Merges site defaults with page props into the final tag set.
625
+ *
626
+ * Pure — no `Astro`, no filesystem, no config lookup. Everything it needs comes
627
+ * through its three arguments, which is what makes it testable and what keeps the
628
+ * package free of the consumer-global coupling that sank the previous site model.
629
+ */
630
+ function resolveSeo(defaults, props, ctx) {
631
+ const { site } = ctx;
632
+ const siteName = props.siteName ?? defaults.siteName ?? null;
633
+ const templated = props.title && defaults.titleTemplate && props.title !== siteName;
634
+ const fallbackTitle = defaults.defaultTitle ?? siteName;
635
+ const title = props.title ? templated ? String(defaults.titleTemplate).replaceAll("%s", props.title) : props.title : fallbackTitle;
636
+ const socialTitle = props.ogTitle ?? props.title ?? fallbackTitle;
637
+ const description = props.description ?? defaults.defaultDescription ?? null;
638
+ const canonical = props.canonical ? absolute(props.canonical, site) : site ? new URL(ctx.pathname + (ctx.search ?? ""), site).href : null;
639
+ const image = absolute(props.image, site) ?? absolute(defaults.openGraph?.image?.url, site);
640
+ const usingDefaultImage = !props.image;
641
+ const defaultImage = defaults.openGraph?.image;
642
+ const imageAlt = props.imageAlt ?? (usingDefaultImage ? defaultImage?.alt : void 0);
643
+ const imageWidth = props.imageWidth ?? (usingDefaultImage ? defaultImage?.width : void 0);
644
+ const imageHeight = props.imageHeight ?? (usingDefaultImage ? defaultImage?.height : void 0);
645
+ const imageType = props.imageType ?? (usingDefaultImage ? defaultImage?.type : void 0);
646
+ const type = props.type ?? defaults.openGraph?.type ?? "website";
647
+ const locale = props.locale ?? defaults.openGraph?.locale;
648
+ const openGraph = [];
649
+ const og = (property, content) => {
650
+ if (content !== null && content !== void 0 && content !== "") openGraph.push({
651
+ property,
652
+ content: String(content)
653
+ });
654
+ };
655
+ og("og:type", type);
656
+ og("og:title", socialTitle);
657
+ og("og:description", description);
658
+ og("og:url", canonical);
659
+ og("og:site_name", siteName);
660
+ og("og:locale", locale ? ogLocale(locale) : null);
661
+ for (const alt of defaults.openGraph?.localeAlternates ?? []) og("og:locale:alternate", ogLocale(alt));
662
+ og("og:image", image);
663
+ if (image) {
664
+ og("og:image:alt", imageAlt);
665
+ og("og:image:width", imageWidth);
666
+ og("og:image:height", imageHeight);
667
+ og("og:image:type", imageType);
668
+ }
669
+ if (type === "article" && props.article) {
670
+ const a = props.article;
671
+ og("article:published_time", a.publishedTime);
672
+ og("article:modified_time", a.modifiedTime);
673
+ og("article:expiration_time", a.expirationTime);
674
+ for (const author of a.authors ?? []) og("article:author", author);
675
+ og("article:section", a.section);
676
+ for (const tag of a.tags ?? []) og("article:tag", tag);
677
+ }
678
+ const twitter = [];
679
+ const tw = (name, content) => {
680
+ if (content) twitter.push({
681
+ name,
682
+ content
683
+ });
684
+ };
685
+ tw("twitter:card", image ? "summary_large_image" : defaults.twitter?.card ?? "summary");
686
+ tw("twitter:site", defaults.twitter?.site);
687
+ tw("twitter:creator", props.twitterCreator ?? defaults.twitter?.creator);
688
+ tw("twitter:title", socialTitle);
689
+ tw("twitter:description", description);
690
+ tw("twitter:image", image);
691
+ tw("twitter:image:alt", image ? imageAlt : null);
692
+ const verification = [];
693
+ if (defaults.googleSiteVerification) verification.push({
694
+ name: "google-site-verification",
695
+ content: defaults.googleSiteVerification
696
+ });
697
+ if (defaults.bingSiteVerification) verification.push({
698
+ name: "msvalidate.01",
699
+ content: defaults.bingSiteVerification
700
+ });
701
+ return {
702
+ title,
703
+ socialTitle,
704
+ description,
705
+ canonical,
706
+ robots: resolveRobots(props, defaults),
707
+ openGraph,
708
+ twitter,
709
+ verification,
710
+ alternates: props.alternates ?? []
711
+ };
712
+ }
713
+
714
+ //#endregion
715
+ export { consentCategories, getvitops as default, resolveAnalytics, resolveSeo };
119
716
  //# sourceMappingURL=index.mjs.map