@getvitops/astro 2.0.0 → 3.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,10 +1,12 @@
1
- import { cpSync, existsSync, readFileSync } from "node:fs";
1
+ import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join, relative, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- import { isSiteConfig, resolveSiteConfig } from "@getvitops/generator";
4
+ import { isConfig, resolveConfig } from "@getvitops/generator";
5
5
  import { collectIconRefs, faviconLinks, generateFavicons, generateIconInclude, resolveIcon, scanFiles, writeFaviconManifest } from "@getvitops/utils";
6
- import vitops from "@getvitops/vite";
6
+ import vitopsPlugin from "@getvitops/vite";
7
7
  import { fontProviders } from "astro/config";
8
+ import { execFile } from "node:child_process";
9
+ import { promisify } from "node:util";
8
10
 
9
11
  export * from "@getvitops/utils"
10
12
 
@@ -202,10 +204,10 @@ function resolveFonts(decls) {
202
204
  const head = [];
203
205
  const seen = /* @__PURE__ */ new Set();
204
206
  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.`);
207
+ if (!PROVIDERS.has(decl.provider)) throw new Error(`[vitops] fonts: unknown provider "${decl.provider}" for "${decl.name}". Astro provides: ${[...PROVIDERS].sort().join(", ")}.`);
208
+ if (NEEDS_OPTIONS.has(decl.provider)) throw new Error(`[vitops] 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.`);
209
+ if (!decl.cssVariable.startsWith("--")) throw new Error(`[vitops] fonts: cssVariable "${decl.cssVariable}" (${decl.name}) must start with "--".`);
210
+ if (seen.has(decl.cssVariable)) throw new Error(`[vitops] 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
211
  seen.add(decl.cssVariable);
210
212
  const { provider, preload, ...rest } = decl;
211
213
  fonts.push({
@@ -235,11 +237,11 @@ function resolveFonts(decls) {
235
237
  * config from the `virtual:getvitops/head` module this integration provides.
236
238
  */
237
239
  /**
238
- * Which providers `getvitops({ analytics })` configures that the site config's
240
+ * Which providers `vitops({ analytics })` configures that the site config's
239
241
  * own `analytics` block does not.
240
242
  *
241
243
  * 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
244
+ * `Config` — but they describe the same site, and a disagreement between them
243
245
  * is a compliance defect rather than a style issue: `vitops legal` derives the
244
246
  * cookie notice from the site config, so a provider missing there is a tag the
245
247
  * site runs and its own notice never mentions. That is precisely what the
@@ -257,7 +259,7 @@ const SITE_CONFIG_KEYS = {
257
259
  function undisclosedProviders(configPath, analytics) {
258
260
  let declared;
259
261
  try {
260
- declared = JSON.parse(readFileSync(configPath, "utf8")).analytics ?? {};
262
+ declared = JSON.parse(readFileSync(configPath, "utf8")).site?.analytics ?? {};
261
263
  } catch {
262
264
  return [];
263
265
  }
@@ -326,14 +328,14 @@ async function probeIconPackage(name) {
326
328
  * the build here would turn "your JSON has a trailing comma" into an error about
327
329
  * an option the consumer never set.
328
330
  */
329
- function readsAsSiteConfig(path) {
331
+ function readsAsConfig(path) {
330
332
  try {
331
- return isSiteConfig(JSON.parse(readFileSync(path, "utf8")));
333
+ return isConfig(JSON.parse(readFileSync(path, "utf8")));
332
334
  } catch {
333
335
  return false;
334
336
  }
335
337
  }
336
- function getvitops(opts = {}) {
338
+ function vitops(opts = {}) {
337
339
  return {
338
340
  name: "@getvitops/astro",
339
341
  hooks: { "astro:config:setup": async ({ config, updateConfig, injectScript, logger }) => {
@@ -343,11 +345,12 @@ function getvitops(opts = {}) {
343
345
  const editor = opts.editor === true;
344
346
  if (editor && !opts.css) logger.warn("editor: true needs a `css` config — it reads design-manifest.json.");
345
347
  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`.");
348
+ if (opts.media && !opts.css) logger.warn("media: needs a `css` config — the encoding runs in the Vite plugin that `css` registers. Run `npx vitops media` instead if you do not use `css`.");
346
349
  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);
350
+ const siteInput = opts.site?.input ?? (cssInput != null && readsAsConfig(resolve(root, cssInput)) ? cssInput : void 0);
348
351
  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
352
  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.");
353
+ if (opts.legal && !legalInput) throw new Error("[vitops] legal: needs `legal.input` (a site config path), the top-level `site` option, or a `css.input` that is itself a site config.");
351
354
  if (opts.seo) {
352
355
  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
356
  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.");
@@ -392,8 +395,8 @@ function getvitops(opts = {}) {
392
395
  };
393
396
  const declared = [];
394
397
  if (o.input) {
395
- const site = resolveSiteConfig(JSON.parse(readFileSync(resolve(root, o.input), "utf8")), o.siteEnv ?? "production");
396
- declared.push(...site.fonts ?? []);
398
+ const cfg = resolveConfig(JSON.parse(readFileSync(resolve(root, o.input), "utf8")), o.siteEnv ?? "production");
399
+ declared.push(...cfg.site.fonts ?? []);
397
400
  }
398
401
  declared.push(...o.families ?? []);
399
402
  if (declared.length === 0) logger.warn("fonts: nothing declared — no webfonts will load. Add families, or drop the option.");
@@ -438,7 +441,7 @@ function getvitops(opts = {}) {
438
441
  try {
439
442
  ({default: sitemap} = await import("@astrojs/sitemap"));
440
443
  } catch {
441
- throw new Error("[getvitops] sitemap requires `@astrojs/sitemap` in your devDependencies — install it, or drop the `sitemap` option.");
444
+ throw new Error("[vitops] sitemap requires `@astrojs/sitemap` in your devDependencies — install it, or drop the `sitemap` option.");
442
445
  }
443
446
  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
447
  const sitemapOpts = opts.sitemap === true ? void 0 : opts.sitemap;
@@ -447,6 +450,16 @@ function getvitops(opts = {}) {
447
450
  logger.info(`sitemap registered → ${sitemapHref.slice(1)}`);
448
451
  }
449
452
  }
453
+ if (siteInput) try {
454
+ const key = resolveConfig(JSON.parse(readFileSync(resolve(root, siteInput), "utf8")), opts.site?.siteEnv ?? "production").site.seo?.indexing?.indexNow?.key;
455
+ if (key) {
456
+ mkdirSync(publicDir, { recursive: true });
457
+ writeFileSync(join(publicDir, `${key}.txt`), `${key}\n`);
458
+ logger.info(`IndexNow key file → public/${key}.txt`);
459
+ }
460
+ } catch (err) {
461
+ logger.warn(`IndexNow key file skipped: ${err.message}`);
462
+ }
450
463
  let iconsData = null;
451
464
  if (opts.icons) {
452
465
  const o = opts.icons === true ? {} : opts.icons;
@@ -549,9 +562,9 @@ function getvitops(opts = {}) {
549
562
  })] } });
550
563
  if (opts.css) {
551
564
  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.");
565
+ if (format === "design") throw new Error("[vitops] 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.");
553
566
  const out = opts.css.out ?? "src/styles";
554
- const plugins = [vitops({
567
+ const plugins = [vitopsPlugin({
555
568
  input: opts.css.input ?? "design-system.json",
556
569
  ...opts.css.theme != null ? { theme: opts.css.theme } : {},
557
570
  format,
@@ -562,6 +575,7 @@ function getvitops(opts = {}) {
562
575
  ...opts.legal,
563
576
  input: legalInput
564
577
  } } : {},
578
+ ...opts.media ? { media: opts.media } : {},
565
579
  ...opts.site ? { site: opts.site } : {},
566
580
  ...opts.css.systemColorScheme != null ? { systemColorScheme: opts.css.systemColorScheme } : {}
567
581
  })];
@@ -573,7 +587,7 @@ function getvitops(opts = {}) {
573
587
  TAILWIND_VITE
574
588
  ));
575
589
  } catch {
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.");
590
+ throw new Error("[vitops] css.format: 'tailwind' requires `tailwindcss` and `@tailwindcss/vite` in your devDependencies — install them, or switch to css.format: 'css' for the standalone bundle.");
577
591
  }
578
592
  plugins.push(...tailwindcss());
579
593
  }
@@ -589,6 +603,166 @@ function getvitops(opts = {}) {
589
603
  };
590
604
  }
591
605
 
606
+ //#endregion
607
+ //#region src/lastmod.ts
608
+ /**
609
+ * Real per-page `<lastmod>` for the sitemap, derived from git.
610
+ *
611
+ * This is the one lever that reliably moves Google. It is also load-bearing for
612
+ * `vitops indexing`, which decides what to submit by diffing lastmod against the
613
+ * previous build — with no lastmod, that diff can see pages appear and disappear
614
+ * but never see one *change*, so an edited page is never resubmitted.
615
+ *
616
+ * A plain exported helper rather than magic inside the `sitemap` option, because
617
+ * it carries a caveat the caller needs to see: it shells out to `git`, so it
618
+ * returns nothing useful from a shallow CI clone (`fetch-depth: 1` — the default
619
+ * in actions/checkout) or from a tarball with no history. In that case it warns
620
+ * and emits no dates at all.
621
+ *
622
+ * import vitops, { gitLastmod } from '@getvitops/astro';
623
+ * export default defineConfig({
624
+ * site: 'https://acme.ca',
625
+ * integrations: [vitops({ sitemap: { serialize: await gitLastmod() } })],
626
+ * });
627
+ *
628
+ * **No date beats a wrong date.** An unmatched URL is left alone rather than
629
+ * stamped with the build time: Google weighs lastmod only while it stays
630
+ * consistent with what actually changed, and a site that stamps every page on
631
+ * every deploy teaches it to stop believing the field — site-wide, not just for
632
+ * the pages that were wrong.
633
+ */
634
+ const exec = promisify(execFile);
635
+ /** Route-bearing sources: file-based pages, and the collections they render. */
636
+ const DEFAULT_DIRS = [
637
+ "src/pages",
638
+ "src/content",
639
+ "src/data"
640
+ ];
641
+ const PAGE_EXT = /\.(astro|md|mdx|markdown|html)$/;
642
+ const CONTENT_EXT = /\.(md|mdx|markdown|json|yaml|yml)$/;
643
+ /**
644
+ * `src/pages/blog/index.astro` → `/blog`; `src/pages/index.astro` → `/`.
645
+ *
646
+ * Dynamic routes return `undefined`: one source file backs many URLs, and its
647
+ * commit date describes the template rather than any of them. Stamping every
648
+ * generated page with the template's date is exactly the "wrong date" case above.
649
+ */
650
+ function routeFromPage(file) {
651
+ const m = /(?:^|\/)src\/pages\/(.+)$/.exec(file);
652
+ if (!m) return void 0;
653
+ let rest = m[1];
654
+ if (!PAGE_EXT.test(rest)) return void 0;
655
+ if (rest.includes("[")) return void 0;
656
+ rest = rest.replace(PAGE_EXT, "");
657
+ if (rest === "index") return "/";
658
+ rest = rest.replace(/\/index$/, "");
659
+ return `/${rest}`;
660
+ }
661
+ /** The slug a content entry is most likely published under. */
662
+ function slugFromContent(file) {
663
+ const m = /(?:^|\/)src\/(?:content|data)\/(.+)$/.exec(file);
664
+ if (!m) return void 0;
665
+ const rest = m[1];
666
+ if (!CONTENT_EXT.test(rest)) return void 0;
667
+ const base = rest.split("/").pop().replace(CONTENT_EXT, "");
668
+ return base === "index" ? void 0 : base;
669
+ }
670
+ const normalise = (pathname) => {
671
+ const p = pathname.replace(/\/+$/, "");
672
+ return p === "" ? "/" : p;
673
+ };
674
+ /**
675
+ * Most recent commit date per file, from one `git log` pass.
676
+ *
677
+ * One process rather than one per file: a `git log -1` per page is fine for
678
+ * twenty pages and unusable for two thousand. `--name-only` interleaves each
679
+ * commit's date with its files, and because `git log` is newest-first the first
680
+ * time a path appears is its latest change.
681
+ */
682
+ async function commitDates(dirs, cwd) {
683
+ const dates = /* @__PURE__ */ new Map();
684
+ const { stdout } = await exec("git", [
685
+ "log",
686
+ "--date-order",
687
+ "--pretty=format:%x00%cI",
688
+ "--name-only",
689
+ "--",
690
+ ...dirs
691
+ ], {
692
+ cwd,
693
+ maxBuffer: 64 * 1024 * 1024
694
+ });
695
+ let current;
696
+ for (const line of stdout.split("\n")) {
697
+ if (line.startsWith("\0")) {
698
+ current = line.slice(1).trim();
699
+ continue;
700
+ }
701
+ const file = line.trim();
702
+ if (!file || !current) continue;
703
+ if (!dates.has(file)) dates.set(file, current);
704
+ }
705
+ return dates;
706
+ }
707
+ /**
708
+ * Build a `serialize` function that stamps `lastmod` from git history.
709
+ *
710
+ * Resolution order per URL: an exact file-based route first, then a unique
711
+ * content slug. "Unique" is doing real work — a slug appearing in two collections
712
+ * is dropped rather than guessed at, since picking either one would be a coin
713
+ * flip presented as a fact.
714
+ */
715
+ async function gitLastmod(options = {}) {
716
+ const cwd = options.cwd ?? process.cwd();
717
+ const warn = options.onWarn ?? ((m) => console.warn(`[vitops] gitLastmod: ${m}`));
718
+ const dirs = (options.dirs ?? DEFAULT_DIRS).filter((d) => existsSync(`${cwd}/${d}`));
719
+ const identity = (item) => item;
720
+ if (dirs.length === 0) {
721
+ warn(`none of ${(options.dirs ?? DEFAULT_DIRS).join(", ")} exist — no dates emitted`);
722
+ return identity;
723
+ }
724
+ let dates;
725
+ try {
726
+ dates = await commitDates(dirs, cwd);
727
+ } catch (err) {
728
+ warn(`git log failed (${err.message}) — no dates emitted`);
729
+ return identity;
730
+ }
731
+ if (dates.size === 0) {
732
+ warn("git history has no commits touching your source — a shallow clone? Set fetch-depth: 0 in your checkout step. No dates emitted.");
733
+ return identity;
734
+ }
735
+ const byRoute = /* @__PURE__ */ new Map();
736
+ const bySlug = /* @__PURE__ */ new Map();
737
+ const ambiguous = /* @__PURE__ */ new Set();
738
+ for (const [file, date] of dates) {
739
+ const route = routeFromPage(file);
740
+ if (route) {
741
+ byRoute.set(route, date);
742
+ continue;
743
+ }
744
+ const slug = slugFromContent(file);
745
+ if (!slug) continue;
746
+ if (bySlug.has(slug)) ambiguous.add(slug);
747
+ else bySlug.set(slug, date);
748
+ }
749
+ for (const slug of ambiguous) bySlug.delete(slug);
750
+ return (item) => {
751
+ if (item.lastmod) return item;
752
+ let pathname;
753
+ try {
754
+ pathname = normalise(new URL(item.url).pathname);
755
+ } catch {
756
+ return item;
757
+ }
758
+ const date = byRoute.get(pathname) ?? bySlug.get(pathname.split("/").pop() ?? "");
759
+ return date ? {
760
+ ...item,
761
+ lastmod: date
762
+ } : item;
763
+ };
764
+ }
765
+
592
766
  //#endregion
593
767
  //#region src/seo.ts
594
768
  /** `en-CA` → `en_CA`; Open Graph wants the underscore form. */
@@ -712,5 +886,5 @@ function resolveSeo(defaults, props, ctx) {
712
886
  }
713
887
 
714
888
  //#endregion
715
- export { consentCategories, getvitops as default, resolveAnalytics, resolveSeo };
889
+ export { consentCategories, vitops as default, gitLastmod, resolveAnalytics, resolveSeo, routeFromPage, slugFromContent };
716
890
  //# sourceMappingURL=index.mjs.map