@getvitops/generator 6.0.0 → 8.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
@@ -9,6 +9,7 @@ import { LIGHTNESS_LADDER, contrastLc, generateOklchPalette, hexToOklch, oklchSt
9
9
  import { AD_PLATFORMS, AD_PROVIDER_KEYS } from "@getvitops/utils/ads";
10
10
  import { generateIconInclude, nodesToHtml, resolveIcon, t } from "@getvitops/utils";
11
11
  import { getIconData, iconToSVG, replaceIDs } from "@iconify/utils";
12
+ import { resolveSitemapUrl } from "@getvitops/utils/indexing";
12
13
 
13
14
  //#region src/shared.ts
14
15
  /**
@@ -187,7 +188,9 @@ const TW_CLASH = new Set([
187
188
  "text-wrap",
188
189
  "text-nowrap",
189
190
  "text-balance",
190
- "text-pretty"
191
+ "text-pretty",
192
+ "rounded-none",
193
+ "rounded-full"
191
194
  ]);
192
195
 
193
196
  //#endregion
@@ -326,7 +329,7 @@ const LADDER_TOLERANCE = .03;
326
329
  function ladderWarnings(palette) {
327
330
  const warnings = [];
328
331
  for (const [hue, expanded] of Object.entries(palette)) for (const [step, deviation] of Object.entries(expanded.deviations ?? {})) {
329
- if (Math.abs(deviation) <= .03) continue;
332
+ if (Math.abs(deviation) <= LADDER_TOLERANCE) continue;
330
333
  const dir = deviation > 0 ? "lighter" : "darker";
331
334
  warnings.push(`colors.palette.${hue}: the colour pinned at step ${step} is ${Math.abs(deviation).toFixed(3)} ${dir} than the shared lightness ladder — this hue will read ${dir} than others at ${step}. Legal, but move it to a neighbouring step if that wasn't intended.`);
332
335
  }
@@ -1257,6 +1260,18 @@ const OpeningHoursSchema = z.object({
1257
1260
  opens: z.string().check(z.regex(/^\d{2}:\d{2}$/)),
1258
1261
  closes: z.string().check(z.regex(/^\d{2}:\d{2}$/))
1259
1262
  });
1263
+ /**
1264
+ * A dated deviation from the recurring `hours` — holiday hours, a one-off
1265
+ * closure, a temporary schedule. Same schema.org shape
1266
+ * (`OpeningHoursSpecification`), distinguished by carrying `validFrom`. A
1267
+ * full closure omits `opens`/`closes` rather than encoding an open interval.
1268
+ */
1269
+ const SpecialHoursSchema = z.object({
1270
+ validFrom: IsoDate,
1271
+ validThrough: z.optional(IsoDate),
1272
+ opens: z.optional(z.string().check(z.regex(/^\d{2}:\d{2}$/))),
1273
+ closes: z.optional(z.string().check(z.regex(/^\d{2}:\d{2}$/)))
1274
+ });
1260
1275
  const LocalBusinessType = z.enum([
1261
1276
  "LocalBusiness",
1262
1277
  "Store",
@@ -1272,6 +1287,11 @@ const LocalBusinessType = z.enum([
1272
1287
  "RealEstateAgent",
1273
1288
  "Organization"
1274
1289
  ]);
1290
+ const ListingPlatform = z.enum([
1291
+ "google",
1292
+ "bing",
1293
+ "apple"
1294
+ ]);
1275
1295
  const LocationSchema = z.object({
1276
1296
  slug: z.optional(LocalizableSchema),
1277
1297
  name: LocalizableSchema,
@@ -1280,13 +1300,17 @@ const LocationSchema = z.object({
1280
1300
  address: z.optional(PostalAddressSchema),
1281
1301
  geo: z.optional(GeoSchema),
1282
1302
  hours: z.optional(z.array(OpeningHoursSchema)),
1303
+ hoursSpecial: desc(z.optional(z.array(SpecialHoursSchema)), "Holiday/temporary hours, as dated deviations from the recurring `hours` — distinct from it because these carry a date range rather than a weekday."),
1283
1304
  type: z.optional(LocalBusinessType),
1284
1305
  description: z.optional(LocalizableSchema),
1285
1306
  areaServed: z.optional(z.array(z.string())),
1286
1307
  priceRange: z.optional(z.string()),
1287
1308
  paymentAccepted: z.optional(z.array(z.string())),
1288
1309
  currenciesAccepted: z.optional(Iso4217),
1289
- knowsLanguage: z.optional(z.array(z.string()))
1310
+ knowsLanguage: z.optional(z.array(z.string())),
1311
+ photos: desc(z.optional(z.array(ImageRefSchema)), "Location photos (logo, storefront, interior, …) → JSON-LD `image`. Every listing platform (GBP, Bing Places, Apple Business Connect) wants these; none can derive them."),
1312
+ sameAs: desc(z.optional(z.array(z.url())), "This location's own profile URLs (its Google Business Profile, Bing Places listing, Apple Business Connect location, Facebook page, …) → JSON-LD `sameAs`. Distinct from `organization.sameAs`, which is the company overall — a multi-location org's locations do not share one GBP listing."),
1313
+ listings: desc(z.optional(z.partialRecord(ListingPlatform, z.string())), "External listing IDs for this location, keyed by platform — the Google Business Profile location id, the Bing Places business id, the Apple Business Connect location id. Not consumed by the generator today; recorded here so a future sync has a stable id to match against instead of risking a duplicate listing.")
1290
1314
  });
1291
1315
  const OrganizationSchema = z.object({
1292
1316
  name: desc(z.optional(LocalizableSchema), "Trading name, as a reader would recognise it (e.g. \"Acme\"). Localisable."),
@@ -1595,6 +1619,27 @@ const AdPropertySchema = z.object({
1595
1619
  enabled: desc(z.optional(z.boolean()), "Set false to keep the property on record without emitting its tag.")
1596
1620
  });
1597
1621
  /**
1622
+ * How one location's Google reviews are selected and ordered for display.
1623
+ *
1624
+ * Keyed by `organization.locations` key, mirroring `site.ads`/`site.searchConsole`
1625
+ * — `vitops reviews` needs to know which physical location a fetched review set
1626
+ * belongs to, and that record already exists.
1627
+ */
1628
+ const ReviewLocationSchema = z.object({
1629
+ accountId: desc(z.optional(z.string()), "GBP account resource id (`accounts/…`), overriding `site.reviews.accountId` for this location."),
1630
+ locationId: desc(z.string(), "GBP location resource id (`locations/…`) to fetch reviews for."),
1631
+ featured: desc(z.optional(z.array(z.string())), "Review ids to always include, in this order, ahead of everything else automatically selected."),
1632
+ minRating: desc(z.optional(z.number()), "Drop reviews below this star rating (default 4) from the automatic (non-featured) selection."),
1633
+ limit: desc(z.optional(z.number()), "Keep at most this many reviews after filtering."),
1634
+ sort: desc(z.optional(z.enum(["recent", "rating"])), "Ordering for the non-featured remainder (default `recent`).")
1635
+ });
1636
+ const ReviewsSchema = z.object({
1637
+ accountId: desc(z.optional(z.string()), "Default GBP account resource id (`accounts/…`) for locations that do not override it."),
1638
+ locations: desc(z.record(z.string(), ReviewLocationSchema), "Which `organization.locations` to fetch Google reviews for, and how each one’s reviews are filtered/ordered. `vitops reviews` writes the result to `.vitops/reviews.json`."),
1639
+ schema: desc(z.optional(z.boolean()), "Emit `Review` JSON-LD for these reviews (default false). Off by default: Google’s structured-data policy treats a business marking up reviews of itself as ineligible for review rich results, and risks a manual action."),
1640
+ avatars: desc(z.optional(z.boolean()), "Render the reviewer’s Google-hosted avatar photo (default false). Off by default: every visitor would otherwise issue a third-party request to googleusercontent.com, and the URLs are not guaranteed to stay live.")
1641
+ });
1642
+ /**
1598
1643
  * Which template set the generated legal documents are written against. This is
1599
1644
  * a closed enum rather than a free string because an unrecognised value would
1600
1645
  * otherwise silently fall back to the wrong body of law; `validateConfig` rejects
@@ -1646,7 +1691,8 @@ const SiteSectionSchema = z.object({
1646
1691
  })), "Canonical domain + redirecting aliases."),
1647
1692
  dns: desc(z.optional(z.record(z.string(), DnsDomainSchema)), "Desired DNS state per domain (provider, nameservers, records) — declarative reference, not applied automatically."),
1648
1693
  cloudflare: desc(z.optional(z.record(z.string(), z.unknown())), "Deliberate escape hatch for provider-specific Cloudflare settings — out of scope to model here."),
1649
- searchConsole: desc(z.optional(z.record(z.string(), SearchConsoleSetupSchema)), "Domains to onboard as Google Search Console *domain properties* via `vitops search setup`, keyed by bare hostname (mirrors `dns`). Credentials come from the environment (CLOUDFLARE_API_TOKEN + the Google OAuth vars) — never in this file."),
1694
+ google: desc(z.optional(z.object({ project: desc(z.string(), "Google Cloud project id that API usage is attributed to (quota and billing). One project per site keeps usage separate — Search Console today, Maps and anything else later. Sent as `x-goog-user-project`, which is required when the credential is a *user* one (an ADC login from `gcloud auth application-default login`, or `VITOPS_GOOGLE_*`): those use a shared OAuth client that carries no project of its own. A service-account credential belongs to a project already, so this is not needed for it. The identity must hold `serviceusage.services.use` on the project, and the APIs being called must be enabled there.") })), "This site's Google Cloud project. Not a secret — the credentials stay in the environment."),
1695
+ searchConsole: desc(z.optional(z.record(z.string(), SearchConsoleSetupSchema)), "Domains to onboard as Google Search Console *domain properties* via `vitops search setup`, keyed by bare hostname (mirrors `dns`). Credentials come from the environment (CLOUDFLARE_API_TOKEN + the Google OAuth vars) — never in this file. Set `google.project` when using a user credential."),
1650
1696
  environments: desc(z.record(z.string(), EnvironmentSchema), "Deploy environments (production, dev, …): URL, API origin, analytics toggle, robots policy, active A/B variant."),
1651
1697
  abTesting: desc(z.optional(z.object({
1652
1698
  enabled: z.boolean(),
@@ -1700,6 +1746,7 @@ const SiteSectionSchema = z.object({
1700
1746
  platforms: desc(z.optional(z.array(z.string())), "Informational list of ad platforms in use. Capture recognises every known click-ID parameter regardless.")
1701
1747
  })), "Ad-click attribution for conversion tracking."),
1702
1748
  ads: desc(z.optional(z.partialRecord(AdProviderSchema, AdPropertySchema)), "Ad properties this site is linked to, keyed by platform. Read by `vitops ads setup` (ensure the DNS verification record), `vitops ads tags` (emit the consent-gated pixel), and the generated cookie notice (disclose what each pixel stores). Credentials never live here — the DNS write uses CLOUDFLARE_API_TOKEN from the environment."),
1749
+ reviews: desc(z.optional(ReviewsSchema), "Google Business Profile reviews to cache and display, keyed by `organization.locations`. Read by `vitops reviews`. Credentials never live here — a Google OAuth credential comes from the environment, same as `site.searchConsole`."),
1703
1750
  security: desc(z.optional(z.object({ turnstile: z.optional(z.object({ siteKey: desc(z.optional(z.string()), "Cloudflare Turnstile site key (public).") })) })), "Security integrations (bot protection)."),
1704
1751
  legal: desc(z.optional(z.object({
1705
1752
  jurisdiction: desc(z.optional(JurisdictionSchema), "Which legal template set the generated documents use. Default `ca`."),
@@ -1986,6 +2033,17 @@ function validateConfig(input) {
1986
2033
  if (entry.domainVerification != null && entry.domain == null && site.domains?.canonical == null) errors.push(issue([...at, "domain"], `site.ads.${provider}.domainVerification needs a domain to put the record on — set site.domains.canonical, or state site.ads.${provider}.domain explicitly`));
1987
2034
  if (entry.accountId == null && entry.pixelId == null) errors.push(issue(at, `site.ads.${provider} identifies nothing — set accountId (the advertising account) and/or pixelId (the tag: ${platform.tag.needs} is what its snippet needs)`));
1988
2035
  }
2036
+ for (const [key, entry] of Object.entries(site.reviews?.locations ?? {})) {
2037
+ const at = [
2038
+ "site",
2039
+ "reviews",
2040
+ "locations",
2041
+ key
2042
+ ];
2043
+ if (!has(org?.locations, key)) errors.push(issue(at, `"${key}" is not an organization.locations key`));
2044
+ else if (org?.locations?.[key]?.listings?.google == null) errors.push(issue(at, `organization.locations.${key} has no listings.google id — record the GBP location id there first`));
2045
+ if (entry.accountId == null && site.reviews?.accountId == null) errors.push(issue([...at, "accountId"], `no accountId — set site.reviews.accountId or site.reviews.locations.${key}.accountId`));
2046
+ }
1989
2047
  if (errors.length) return {
1990
2048
  ok: false,
1991
2049
  data: void 0,
@@ -2212,13 +2270,14 @@ const TIERS = {
2212
2270
  "tree",
2213
2271
  "tree__item",
2214
2272
  "tree__content",
2273
+ "tree__summary",
2215
2274
  "tree__toggle",
2216
2275
  "tree__label"
2217
2276
  ], "patterns/tree.css", true),
2218
2277
  wc: {
2219
2278
  tag: "wc-tree",
2220
2279
  registered: true,
2221
- adds: "filter, expand/collapse all, hash deep-linking"
2280
+ adds: "WAI-ARIA tree semantics, roving-tabindex keyboard navigation, filter, expand/collapse all, hash deep-linking"
2222
2281
  },
2223
2282
  astro: a("Tree", "wc"),
2224
2283
  use: "`<Tree items={…} />` — it emits `<wc-tree>` itself, so do NOT add your own wrapper."
@@ -2240,6 +2299,21 @@ const TIERS = {
2240
2299
  bricks: "vitops-carousel",
2241
2300
  use: "`<Carousel slides={…} label=\"…\" />` — it emits `<wc-carousel>` itself, so do NOT add your own wrapper. `.carousel` is the shell and `.carousel__track` the scroller; add `loop` for the cloned infinite strip (`autoplay` implies it)."
2242
2301
  },
2302
+ gallery: {
2303
+ css: c([
2304
+ "gallery",
2305
+ "gallery__item",
2306
+ "gallery__trigger",
2307
+ "gallery__thumb"
2308
+ ], "patterns/gallery.css", true),
2309
+ wc: {
2310
+ tag: "wc-gallery",
2311
+ registered: true,
2312
+ adds: "a real `showModal()` open, neighbour preload and the thumbnail→full-image view-transition morph"
2313
+ },
2314
+ astro: a("Gallery", "wc"),
2315
+ use: "`<Gallery images={…} label=\"…\" />` — it emits `<wc-gallery>` wrapping a `<Carousel />` (which emits its own `<wc-carousel>`) itself, so do NOT add your own wrapper around either. Each thumbnail is a plain `<a href=\"#slide-id\">` into the carousel's own `<li id=\"slide-id\">` inside a single `<dialog class=\"lightbox-dialog\">` (see `lightbox`), so it is a working modal lightbox — WITH prev/next — before any script runs. Stepping between images is the carousel's job, not the gallery's."
2316
+ },
2243
2317
  entries: {
2244
2318
  css: c([
2245
2319
  "entries__table",
@@ -2318,7 +2392,22 @@ const TIERS = {
2318
2392
  registered: true,
2319
2393
  adds: "fills the track with enough copies to scroll seamlessly"
2320
2394
  },
2321
- use: "`<wc-marquee>` around one `.marquee__content`. Scrolls via CSS alone; JS only removes the seam."
2395
+ astro: a("Marquee", "wc"),
2396
+ use: "`<Marquee>` around one `.marquee__content`, or `<wc-marquee>` by hand. Scrolls via CSS alone; JS only removes the seam."
2397
+ },
2398
+ counter: {
2399
+ css: c([
2400
+ "counter",
2401
+ "counter__value",
2402
+ "counter__presentation"
2403
+ ], "patterns/counter.css"),
2404
+ wc: {
2405
+ tag: "wc-counter",
2406
+ registered: true,
2407
+ adds: "animates from a start value to the value already in the fallback text, on intersection"
2408
+ },
2409
+ astro: a("Counter", "wc"),
2410
+ use: "`<Counter value=\"94%\" />` — it emits `<wc-counter>` itself, so do NOT add your own wrapper. The final value is parsed from the fallback text, never passed separately."
2322
2411
  },
2323
2412
  "multi-field": {
2324
2413
  css: c([
@@ -2579,8 +2668,12 @@ const TIERS = {
2579
2668
  use: "`.notification` with a role variant; wrap in `<wc-dismissable>` to make it dismissable."
2580
2669
  },
2581
2670
  lightbox: {
2582
- css: c(["lightbox", "lightbox-dialog__content"], "patterns/lightbox.css", true),
2583
- use: "`.lightbox` thumbnails plus a `<dialog>`."
2671
+ css: c([
2672
+ "lightbox-dialog",
2673
+ "lightbox-dialog__track",
2674
+ "lightbox-dialog__close"
2675
+ ], "patterns/lightbox.css", true),
2676
+ use: "The single `<dialog class=\"lightbox-dialog\">` a gallery opens — it wraps ONE `.carousel` (`lightbox-dialog__track`) of every image, not one dialog per image. Use `gallery` for the thumbnail grid that drives it — `<Gallery />` emits both, and the carousel inside already owns prev/next/dots/keys/swipe."
2584
2677
  },
2585
2678
  comment: {
2586
2679
  css: c([
@@ -2590,6 +2683,17 @@ const TIERS = {
2590
2683
  ], "patterns/comment.css", true),
2591
2684
  use: "`.comment` inside `.comment-thread`."
2592
2685
  },
2686
+ review: {
2687
+ css: c([
2688
+ "review",
2689
+ "review__stars",
2690
+ "review__body",
2691
+ "review__reply",
2692
+ "review__source"
2693
+ ], "patterns/review.css", true),
2694
+ astro: a("ReviewCard", "css"),
2695
+ use: "`<ReviewCard review={…} />` — one card, from `vitops reviews`' `.vitops/reviews.json` cache. Put several inside `<Carousel>` slides or `<Marquee>` items; there is no group component and no `wc-review`."
2696
+ },
2593
2697
  tabs: {
2594
2698
  css: c(["tabs"], void 0, true),
2595
2699
  use: "Generated pattern only; no structural partial and no component yet."
@@ -3451,6 +3555,9 @@ and a heading and a footer, \`subgrid\` is the one that makes them line up. Neve
3451
3555
  - **Display** — \`block\`, \`inline\`, \`inline-block\`, \`flex\`, \`grid\`, \`hidden\`
3452
3556
  (breakpoint-prefixable, e.g. \`md-hidden\`).
3453
3557
  - **Accessibility** — \`sr-only\` / \`not-sr-only\` (breakpoint-prefixable).
3558
+ - **Shape** — \`rounded-none\`, \`rounded-full\` (the two radii with no design-token meaning of
3559
+ their own; every other radius comes from the pattern token cascade, not a utility). Tailwind
3560
+ ships its own \`rounded-*\` scale, so these are css/bricks only.
3454
3561
  - **State hooks** — \`is-active\`, \`is-open\` (styling flags toggled by JS / native state).
3455
3562
 
3456
3563
  ## Spacing
@@ -4007,11 +4114,12 @@ split in two rather than shelved whole. \`layout.css\` (patterns) and \`layout-u
4007
4114
  format reaches the same arrangement by its own route — patterns in \`@layer components\`,
4008
4115
  utilities as \`@utility\`.
4009
4116
 
4010
- Known gap: the \`typography.headings\` bare-element bindings (\`h1\`, \`h2\`, \`body\`) are
4011
- emitted alongside \`.font-<role>\` and so sit in \`vitops.utilities\`, where a tag rule
4012
- outranks every pattern — \`<h2 class="pull-quote">\` keeps the heading's font-size in
4013
- \`css\`/\`bricks\` and the pattern's in \`tailwind\`, which puts the bindings in \`@layer base\`.
4014
- Give the element a \`.font-<role>\` class to pin it either way.
4117
+ The \`typography.headings\` bare-element bindings (\`h1\`, \`h2\`, \`body\`) are a DEFAULT for an
4118
+ unstyled element, not a utility, so they emit into their own chunk in \`vitops.base\` — below
4119
+ every pattern — rather than alongside \`.font-<role>\` in \`vitops.utilities\`. That is what lets
4120
+ \`<h2 class="pull-quote">\` keep the pattern's font-size in every format: the bare \`h2\` binding
4121
+ never outranks \`.pull-quote\`, regardless of specificity, because a layer decides before
4122
+ specificity is ever asked.
4015
4123
 
4016
4124
  ## \`bricks\` — WordPress / Bricks Builder payload
4017
4125
 
@@ -5009,6 +5117,7 @@ grants**, tracking where each command runs:
5009
5117
  | --- | --- | --- |
5010
5118
  | **Service account** (JWT bearer) | \`VITOPS_GSC_SERVICE_ACCOUNT\` (inline JSON) or \`GOOGLE_APPLICATION_CREDENTIALS\` (path) | \`search notify\` — never expires, right for CI |
5011
5119
  | **User OAuth** (refresh token) | \`VITOPS_GOOGLE_CLIENT_ID\` / \`_CLIENT_SECRET\` / \`_REFRESH_TOKEN\` | \`search setup\` — **required** |
5120
+ | **User OAuth via ADC** (fallback) | none — a \`gcloud auth application-default login\` credential | either, when no \`VITOPS_GOOGLE_*\` is set |
5012
5121
 
5013
5122
  Cloudflare uses \`CLOUDFLARE_API_TOKEN\` (a \`Zone:DNS:Edit\` token; the standard "Edit zone DNS"
5014
5123
  template also carries the \`Zone:Read\` the zone-by-name lookup needs).
@@ -5022,6 +5131,33 @@ revoked, and for an OAuth client still in *Testing* publishing status Google exp
5022
5131
  Console does not care which identity calls it, and someone who has run \`search setup\` already
5023
5132
  holds a Google credential.
5024
5133
 
5134
+ **Being logged in to gcloud is enough**, which also sidesteps the 7-day expiry above — the Cloud
5135
+ SDK client is published, so its refresh token doesn't rot. The Search Console scopes are not in
5136
+ the default ADC set, so ask for them:
5137
+
5138
+ \`\`\`
5139
+ gcloud auth application-default login \\
5140
+ --scopes=openid,https://www.googleapis.com/auth/siteverification,\\
5141
+ https://www.googleapis.com/auth/webmasters,https://www.googleapis.com/auth/cloud-platform
5142
+ \`\`\`
5143
+
5144
+ Explicit \`VITOPS_GOOGLE_*\` vars win over it: a stale local login silently overriding a
5145
+ deliberate CI secret is the worst available precedence.
5146
+
5147
+ Two things follow from ADC using a **shared** OAuth client, and both fail as a bare \`403\`:
5148
+
5149
+ - **It owns no project, so \`site.google.project\` is required** — sent as \`x-goog-user-project\`.
5150
+ Without it: *"requires a quota project, which is not set by default"*. A service account
5151
+ belongs to a project already, so the header is **not** sent for one; attaching a different
5152
+ project would newly require \`serviceusage.services.use\` there and break a working credential.
5153
+ - **That project needs the APIs enabled** (\`searchconsole.googleapis.com\`,
5154
+ \`siteverification.googleapis.com\`) and \`serviceusage.services.use\` for the identity. One
5155
+ project per site keeps usage and billing separate — one grant per site, one admin identity.
5156
+
5157
+ \`GOOGLE_APPLICATION_CREDENTIALS\` carries **either kind**, since it is both Google's convention
5158
+ and where gcloud writes ADC. It is discriminated on the JSON's \`type\` field, never on which
5159
+ fields are present.
5160
+
5025
5161
  ⚠️ Do not add \`googleapis\` — an enormous dependency for a handful of REST endpoints in a CLI
5026
5162
  that installs into every consumer project. The JWT is minted with ~30 lines of \`node:crypto\`.
5027
5163
  `;
@@ -5322,7 +5458,7 @@ function emitDesignMd(ctx) {
5322
5458
  if (pDefaults.br) pushRadius("DEFAULT", pDefaults.br);
5323
5459
  for (const [n, v] of Object.entries(pRadii)) pushRadius(n, v);
5324
5460
  const roundedNames = new Set(rounded.map(([k]) => k));
5325
- /** `var(--br-default)` → `{rounded.DEFAULT}`, `var(--br-pill, 999px)` → `{rounded.pill}`. */
5461
+ /** `var(--br-default)` → `{rounded.DEFAULT}`, `var(--br-circle, 50%)` → `{rounded.circle}`. */
5326
5462
  const roundedRef = (value) => {
5327
5463
  const v = varName(value);
5328
5464
  const bare = v?.startsWith("br-") ? v.slice(3) : void 0;
@@ -7281,8 +7417,10 @@ function build(ds, format, assetsDir, buildOpts = {}) {
7281
7417
  const headings = typography?.headings ?? {};
7282
7418
  let typo = `/* GENERATED typography roles — do not edit by hand. */\n`;
7283
7419
  for (const [role, spec] of Object.entries(roles)) typo += `.font-${role} {\n${decls(roleDecls(role, spec))}\n}\n`;
7284
- for (const [tag, role] of Object.entries(headings)) if (roles[role]) typo += `${tag} {\n${decls(roleDecls(role, roles[role]))}\n}\n`;
7285
7420
  generated["typography.css"] = typo;
7421
+ let typoHeadings = `/* GENERATED typography.headings bare-element bindings — do not edit by hand. */\n`;
7422
+ for (const [tag, role] of Object.entries(headings)) if (roles[role]) typoHeadings += `${tag} {\n${decls(roleDecls(role, roles[role]))}\n}\n`;
7423
+ generated["typography-headings.css"] = typoHeadings;
7286
7424
  }
7287
7425
  const stateRules = (sel, role, states, colorProp = "background-color") => {
7288
7426
  let out = "";
@@ -8038,6 +8176,7 @@ const CHUNK_LAYER = {
8038
8176
  "global.css": "vitops.base",
8039
8177
  "generated/type-tokens.css": "vitops.base",
8040
8178
  "generated/tokens.css": "vitops.base",
8179
+ "generated/typography-headings.css": "vitops.base",
8041
8180
  "generated/color.css": "vitops.utilities",
8042
8181
  "generated/shadows.css": "vitops.utilities",
8043
8182
  "generated/typography.css": "vitops.utilities",
@@ -8207,6 +8346,68 @@ function loadConfigFile(input, siteEnv) {
8207
8346
  return resolveConfig(typeof input === "string" ? JSON.parse(readFileSync(input, "utf8")) : input, siteEnv);
8208
8347
  }
8209
8348
 
8349
+ //#endregion
8350
+ //#region src/robots.ts
8351
+ /**
8352
+ * `robots.txt`, generated from the same fact the indexing gate and the
8353
+ * `<meta name="robots">` tag already read.
8354
+ *
8355
+ * Before this, nothing in the toolchain emitted one at all — a staging
8356
+ * environment with `robots: "noindex,nofollow"` got a `<meta>` tag and a
8357
+ * blocked `search notify` run, but a crawler reading `/robots.txt` directly
8358
+ * saw nothing and was free to index it anyway. `plan()`
8359
+ * (`@getvitops/utils/indexing`) already treats `noindex` in the resolved
8360
+ * environment's directive as a hard stop for notifying search engines; this is
8361
+ * the same fact, read the same way, applied to the file crawlers actually ask
8362
+ * for first.
8363
+ *
8364
+ * A sibling of `generateLegal`, not a `generate()` format: this renders from a
8365
+ * **`Config`** (a `site.environments` entry), not a `DesignSystem`, so bolting
8366
+ * it onto `generate()` would be a format that ignores its own input.
8367
+ *
8368
+ * Pure: no filesystem, no clock, no shared state. The CLI writes it to stdout
8369
+ * or `--out`; the Astro integration writes `public/robots.txt` directly,
8370
+ * mirroring how it already writes the IndexNow key file.
8371
+ */
8372
+ /**
8373
+ * A directive string (`site.environments[env].robots` or `site.seo.robots`)
8374
+ * blocks indexing if it contains `noindex` — matching `plan()`'s own check in
8375
+ * `@getvitops/utils/indexing`, so the two can't read the same fact two ways.
8376
+ */
8377
+ function isBlocked(directive) {
8378
+ return /noindex/i.test(directive ?? "");
8379
+ }
8380
+ /**
8381
+ * Render `robots.txt` for one environment.
8382
+ *
8383
+ * A blocked environment gets `Disallow: /` for every user agent and no sitemap
8384
+ * line — publishing the sitemap of a site asking not to be indexed would be
8385
+ * the more damaging way to get this wrong, handing every listed URL to a
8386
+ * crawler that then finds itself blocked from all of them, but has already
8387
+ * seen where they are. An allowed environment gets a permissive robots.txt
8388
+ * plus a `Sitemap:` line, using the exact URL `vitops search notify` would
8389
+ * submit — `site.seo.indexing.sitemapUrl`, or `<canonical>/sitemap-index.xml`,
8390
+ * the same default `@astrojs/sitemap` produces.
8391
+ */
8392
+ function renderRobotsTxt(cfg, options = {}) {
8393
+ const envName = options.env ?? "production";
8394
+ const site = cfg.site;
8395
+ const env = site.environments?.[envName];
8396
+ const directive = env?.robots ?? site.seo?.robots;
8397
+ const lines = ["User-agent: *"];
8398
+ if (isBlocked(directive)) {
8399
+ lines.push("Disallow: /");
8400
+ return lines.join("\n") + "\n";
8401
+ }
8402
+ lines.push("Allow: /");
8403
+ const sitemapUrl = resolveSitemapUrl({
8404
+ canonical: env?.url ?? site.domains?.canonical,
8405
+ sitemapUrl: site.seo?.indexing?.sitemapUrl
8406
+ });
8407
+ if (sitemapUrl) lines.push("", `Sitemap: ${sitemapUrl}`);
8408
+ return lines.join("\n") + "\n";
8409
+ }
8410
+
8210
8411
  //#endregion
8211
8412
  //#region src/index.ts
8212
8413
  /**
@@ -8308,10 +8509,7 @@ function defaultConfig() {
8308
8509
  p: "1rem",
8309
8510
  fs: "1rem"
8310
8511
  },
8311
- radii: {
8312
- pill: "999px",
8313
- circle: "50%"
8314
- },
8512
+ radii: { circle: "50%" },
8315
8513
  groups: {
8316
8514
  control: {
8317
8515
  ds: "var(--ds-default)",
@@ -8486,5 +8684,5 @@ function defaultConfig() {
8486
8684
  }
8487
8685
 
8488
8686
  //#endregion
8489
- export { AD_PROVIDERS, BASE_HOOK, CONFIG_SCHEMA_URL, CONTRAST, ConfigSchema, DARK_SEL, DOC_ORDER, DOC_SLUGS, DesignSystemSchema, JURISDICTIONS, JURISDICTION_COUNTRIES, KNOWN_PROCESSORS, NUMERIC_STEPS, REQUIRED_ROLES, ROLE_TOKEN_KEYS, SCHEMA_LOCAL_PATH, SCHEMA_URL, SiteFontSchema, TEMPLATES, TIERS, TIER_NAMES, TW_CLASH, buildIconSprite, configJsonSchema, deepMerge, defaultConfig, derivePolicyVars, detectProcessorKeys, enabledDocs, expandPalette, functionalRole, generate, generateDocs, generateLegal, isConfig, jsonSchema, loadIconSvg, movedTokens, parseMarkdown, processorsMissingLocation, renderInlineMarkdown, renderMarkdown, renderNodes, resolveConfig, resolveInput, resolvePrivacyContact, resolveProcessors, resolveTheme, roleColorUtilities, roleHue, roleKind, schemaTreeNodes, spriteId, stripNulls, tierPatterns, tierTags, toContentNodes, toHtmlFragment, toPortableText, tokenClass, tokenVar, validate, validateConfig };
8687
+ export { AD_PROVIDERS, BASE_HOOK, CONFIG_SCHEMA_URL, CONTRAST, ConfigSchema, DARK_SEL, DOC_ORDER, DOC_SLUGS, DesignSystemSchema, JURISDICTIONS, JURISDICTION_COUNTRIES, KNOWN_PROCESSORS, NUMERIC_STEPS, REQUIRED_ROLES, ROLE_TOKEN_KEYS, SCHEMA_LOCAL_PATH, SCHEMA_URL, SiteFontSchema, TEMPLATES, TIERS, TIER_NAMES, TW_CLASH, buildIconSprite, configJsonSchema, deepMerge, defaultConfig, derivePolicyVars, detectProcessorKeys, enabledDocs, expandPalette, functionalRole, generate, generateDocs, generateLegal, isConfig, jsonSchema, loadIconSvg, movedTokens, parseMarkdown, processorsMissingLocation, renderInlineMarkdown, renderMarkdown, renderNodes, renderRobotsTxt, resolveConfig, resolveInput, resolvePrivacyContact, resolveProcessors, resolveTheme, roleColorUtilities, roleHue, roleKind, schemaTreeNodes, spriteId, stripNulls, tierPatterns, tierTags, toContentNodes, toHtmlFragment, toPortableText, tokenClass, tokenVar, validate, validateConfig };
8490
8688
  //# sourceMappingURL=index.mjs.map