@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/CHANGELOG.md +301 -0
- package/README.md +78 -1
- package/dist/index.d.mts +88 -8
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +196 -22
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -5
- package/src/analytics.ts +1 -1
- package/src/components/Analytics.astro +2 -2
- package/src/components/CookieConsent.astro +2 -2
- package/src/components/Details.astro +1 -1
- package/src/components/Drawer.astro +1 -1
- package/src/components/Head.astro +6 -6
- package/src/components/Icon.astro +3 -3
- package/src/components/NavShell.astro +123 -0
- package/src/components/NavShellToggle.astro +45 -0
- package/src/components/Popover.astro +1 -1
- package/src/components/Seo.astro +2 -2
- package/src/seo.ts +2 -2
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 {
|
|
4
|
+
import { isConfig, resolveConfig } from "@getvitops/generator";
|
|
5
5
|
import { collectIconRefs, faviconLinks, generateFavicons, generateIconInclude, resolveIcon, scanFiles, writeFaviconManifest } from "@getvitops/utils";
|
|
6
|
-
import
|
|
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(`[
|
|
206
|
-
if (NEEDS_OPTIONS.has(decl.provider)) throw new Error(`[
|
|
207
|
-
if (!decl.cssVariable.startsWith("--")) throw new Error(`[
|
|
208
|
-
if (seen.has(decl.cssVariable)) throw new Error(`[
|
|
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 `
|
|
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
|
-
* `
|
|
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
|
|
331
|
+
function readsAsConfig(path) {
|
|
330
332
|
try {
|
|
331
|
-
return
|
|
333
|
+
return isConfig(JSON.parse(readFileSync(path, "utf8")));
|
|
332
334
|
} catch {
|
|
333
335
|
return false;
|
|
334
336
|
}
|
|
335
337
|
}
|
|
336
|
-
function
|
|
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 &&
|
|
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("[
|
|
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
|
|
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("[
|
|
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("[
|
|
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 = [
|
|
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("[
|
|
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,
|
|
889
|
+
export { consentCategories, vitops as default, gitLastmod, resolveAnalytics, resolveSeo, routeFromPage, slugFromContent };
|
|
716
890
|
//# sourceMappingURL=index.mjs.map
|