@getvitops/generator 3.0.0 → 4.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 +237 -0
- package/assets/bricks/elements/color-scheme-toggle.php +3 -3
- package/assets/bricks/elements/multi-field.php +3 -3
- package/assets/css/patterns/consent.css +14 -3
- package/assets/css/patterns/sitenav.css +145 -79
- package/assets/css/patterns/tree.css +118 -8
- package/assets/js/consent.js +1 -1
- package/assets/js/elements.js +25 -25
- package/config.schema.json +93 -10
- package/dist/index.d.mts +268 -7
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1376 -33
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
package/dist/index.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { getIconData, iconToSVG, replaceIDs } from "@iconify/utils";
|
|
|
20
20
|
*
|
|
21
21
|
* `data-brx-theme` is Bricks' own attribute — Bricks sets it, so it's what the
|
|
22
22
|
* WordPress target needs. Nothing sets it anywhere else, which meant the dark
|
|
23
|
-
* flip was unreachable outside Bricks: the shipped `<color-scheme-toggle>` web
|
|
23
|
+
* flip was unreachable outside Bricks: the shipped `<wc-color-scheme-toggle>` web
|
|
24
24
|
* component writes `documentElement.dataset.theme` (i.e. `data-theme`), so
|
|
25
25
|
* clicking "Dark" changed an attribute no rule matched. Matching both makes the
|
|
26
26
|
* component work on every target without changing what Bricks already does.
|
|
@@ -38,7 +38,7 @@ const DARK_SEL = ":root[data-brx-theme=\"dark\"], :root[data-theme=\"dark\"]";
|
|
|
38
38
|
*
|
|
39
39
|
* Derived from `DARK_SEL`'s attributes rather than written out a third time, and
|
|
40
40
|
* inverted: this must apply when NO explicit choice has been made. That is
|
|
41
|
-
* exactly what `<color-scheme-toggle>`'s "System" position produces — it removes
|
|
41
|
+
* exactly what `<wc-color-scheme-toggle>`'s "System" position produces — it removes
|
|
42
42
|
* `data-theme` entirely — so with this block present System resolves to the OS
|
|
43
43
|
* instead of silently falling through to light, and needs no JS to do it.
|
|
44
44
|
*
|
|
@@ -519,7 +519,7 @@ const DesignSystemBlock = desc(z.strictObject({
|
|
|
519
519
|
"light",
|
|
520
520
|
"dark",
|
|
521
521
|
"system"
|
|
522
|
-
])), "Initial appearance. `\"system\"` follows the OS via `prefers-color-scheme` and is what makes `<color-scheme-toggle>`'s \"System\" position resolve to anything — it removes the theme attribute, so without this the page falls through to light. It also gives a no-JS page the OS appearance. Defaults to `\"light\"`, because switching an existing site to `\"system\"` visibly flips it dark for dark-OS visitors.")
|
|
522
|
+
])), "Initial appearance. `\"system\"` follows the OS via `prefers-color-scheme` and is what makes `<wc-color-scheme-toggle>`'s \"System\" position resolve to anything — it removes the theme attribute, so without this the page falls through to light. It also gives a no-JS page the OS appearance. Defaults to `\"light\"`, because switching an existing site to `\"system\"` visibly flips it dark for dark-OS visitors.")
|
|
523
523
|
}), "The design system: named themes plus the system-wide facts (which theme, which appearance). A bare design system, or a bare theme map, is accepted as shorthand for `{ themes: … }`.");
|
|
524
524
|
const LocaleSchema = z.object({
|
|
525
525
|
name: desc(z.string(), "Display name of the locale (e.g. \"English\")."),
|
|
@@ -629,6 +629,17 @@ const DnsDomainSchema = z.object({
|
|
|
629
629
|
nameservers: z.optional(z.array(z.string())),
|
|
630
630
|
records: z.optional(z.array(DnsRecordSchema))
|
|
631
631
|
});
|
|
632
|
+
/**
|
|
633
|
+
* One domain to onboard into Google Search Console via `vitops search setup`.
|
|
634
|
+
*
|
|
635
|
+
* The verification token is fetched live from the Site Verification API at run
|
|
636
|
+
* time and written to DNS — it is never stored here. What the config records is
|
|
637
|
+
* the *intent*: which domains to onboard, and who should co-own them.
|
|
638
|
+
*/
|
|
639
|
+
const SearchConsoleSetupSchema = z.object({
|
|
640
|
+
delegatedOwners: desc(z.optional(z.array(z.email())), "Emails added as verification owners on the Site Verification web resource. Automated via the API — additive and idempotent, never removes an existing owner."),
|
|
641
|
+
fullUserGroup: desc(z.optional(z.email()), "A Google Group to grant Full-User access in Search Console. SURFACED AS A REMINDER only: Search Console exposes no user/permission API, so this step stays manual by design.")
|
|
642
|
+
});
|
|
632
643
|
const RobotsSchema = z.string();
|
|
633
644
|
const EnvironmentSchema = z.object({
|
|
634
645
|
url: desc(z.url(), "Public origin of this environment."),
|
|
@@ -818,7 +829,7 @@ const TwitterSchema = z.object({
|
|
|
818
829
|
image: z.optional(ImageRefSchema)
|
|
819
830
|
});
|
|
820
831
|
/**
|
|
821
|
-
* What `vitops notify` needs to tell search engines a deploy happened.
|
|
832
|
+
* What `vitops search notify` needs to tell search engines a deploy happened.
|
|
822
833
|
*
|
|
823
834
|
* Scoped by what search engines actually accept, which is narrower than it looks:
|
|
824
835
|
* Google exposes **no** "request indexing" API (the Search Console button is not in
|
|
@@ -832,7 +843,7 @@ const TwitterSchema = z.object({
|
|
|
832
843
|
* (a key, a property, which pages matter), never what to say about it.
|
|
833
844
|
*/
|
|
834
845
|
const IndexNowSchema = z.object({
|
|
835
|
-
key: desc(z.string().check(z.regex(/^[a-zA-Z0-9-]{8,128}$/)), "IndexNow key (8–128 chars, hex is conventional). NOT a secret — it is served publicly at `keyLocation` so the engine can verify you own the host. Generate one with `vitops notify --new-key`."),
|
|
846
|
+
key: desc(z.string().check(z.regex(/^[a-zA-Z0-9-]{8,128}$/)), "IndexNow key (8–128 chars, hex is conventional). NOT a secret — it is served publicly at `keyLocation` so the engine can verify you own the host. Generate one with `vitops search notify --new-key`."),
|
|
836
847
|
keyLocation: desc(z.optional(z.url()), "Absolute URL of the key file. Defaults to `<canonical>/<key>.txt`; set it only when the file lives elsewhere."),
|
|
837
848
|
endpoint: desc(z.optional(z.url()), "IndexNow endpoint (default `https://api.indexnow.org/indexnow`). Any participating engine shares submissions with the rest, so one is normally enough.")
|
|
838
849
|
});
|
|
@@ -843,8 +854,8 @@ const SearchConsoleSchema = z.object({
|
|
|
843
854
|
const IndexingSchema = z.object({
|
|
844
855
|
sitemapUrl: desc(z.optional(z.url()), "The sitemap to submit and to diff for changed URLs. Defaults to `<canonical>/sitemap-index.xml`."),
|
|
845
856
|
indexNow: desc(z.optional(IndexNowSchema), "IndexNow submission (Bing, Yandex, Naver, Seznam, Yep — not Google). Omit to skip the channel."),
|
|
846
|
-
searchConsole: desc(z.optional(SearchConsoleSchema), "Google Search Console property. Needs a service
|
|
847
|
-
priorityUrls: desc(z.optional(z.array(z.url())), "The pages whose indexing actually matters. `vitops notify --check` inspects these and exits non-zero if Google has not indexed one. Kept explicit because URL Inspection is quota-bound (2000/day), so checking every page is neither affordable nor informative.")
|
|
857
|
+
searchConsole: desc(z.optional(SearchConsoleSchema), "Google Search Console property. Needs a credential at run time — either a service account in `VITOPS_GSC_SERVICE_ACCOUNT` / `GOOGLE_APPLICATION_CREDENTIALS`, or the user OAuth credential `vitops search setup` uses (`VITOPS_GOOGLE_CLIENT_ID` / `_CLIENT_SECRET` / `_REFRESH_TOKEN`). The service account wins when both are set. Never put a key in this file."),
|
|
858
|
+
priorityUrls: desc(z.optional(z.array(z.url())), "The pages whose indexing actually matters. `vitops search notify --check` inspects these and exits non-zero if Google has not indexed one. Kept explicit because URL Inspection is quota-bound (2000/day), so checking every page is neither affordable nor informative.")
|
|
848
859
|
});
|
|
849
860
|
/**
|
|
850
861
|
* Which template set the generated legal documents are written against. This is
|
|
@@ -878,6 +889,7 @@ const SiteSectionSchema = z.object({
|
|
|
878
889
|
})), "Canonical domain + redirecting aliases."),
|
|
879
890
|
dns: desc(z.optional(z.record(z.string(), DnsDomainSchema)), "Desired DNS state per domain (provider, nameservers, records) — declarative reference, not applied automatically."),
|
|
880
891
|
cloudflare: desc(z.optional(z.record(z.string(), z.unknown())), "Deliberate escape hatch for provider-specific Cloudflare settings — out of scope to model here."),
|
|
892
|
+
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."),
|
|
881
893
|
environments: desc(z.record(z.string(), EnvironmentSchema), "Deploy environments (production, dev, …): URL, API origin, analytics toggle, robots policy, active A/B variant."),
|
|
882
894
|
abTesting: desc(z.optional(z.object({
|
|
883
895
|
enabled: z.boolean(),
|
|
@@ -904,7 +916,7 @@ const SiteSectionSchema = z.object({
|
|
|
904
916
|
bingSiteVerification: z.optional(z.string()),
|
|
905
917
|
openGraph: z.optional(OpenGraphSchema),
|
|
906
918
|
twitter: z.optional(TwitterSchema),
|
|
907
|
-
indexing: desc(z.optional(IndexingSchema), "How `vitops notify` tells search engines about a deploy: which sitemap, IndexNow key, Search Console property, and which pages to verify afterwards.")
|
|
919
|
+
indexing: desc(z.optional(IndexingSchema), "How `vitops search notify` tells search engines about a deploy: which sitemap, IndexNow key, Search Console property, and which pages to verify afterwards.")
|
|
908
920
|
})), "SEO defaults: title/description templates, robots policy, verification tokens, Open Graph + Twitter cards, and post-deploy indexing notification."),
|
|
909
921
|
analytics: desc(z.optional(z.object({
|
|
910
922
|
googleAnalyticsId: z.optional(z.string()),
|
|
@@ -917,11 +929,19 @@ const SiteSectionSchema = z.object({
|
|
|
917
929
|
cookies: desc(z.optional(z.boolean()), "Default false — Matomo runs cookieless (`disableCookies`), which is what lets the cookie notice state positively that it sets none.")
|
|
918
930
|
})), "Matomo instance.")
|
|
919
931
|
})), "Analytics provider IDs (gated per environment via `environments.<env>.analytics`). Which provider is set is also what the generated privacy policy and cookie notice disclose — see `legal`."),
|
|
920
|
-
notifications: desc(z.optional(z.object({ email: z.optional(z.email()
|
|
932
|
+
notifications: desc(z.optional(z.object({ email: desc(z.optional(z.union([z.email(), z.object({
|
|
933
|
+
provider: desc(z.literal("cloudflare"), "Delivery provider. Only Cloudflare Email Sending is implemented; the field is stated so adding another is additive."),
|
|
934
|
+
to: desc(z.optional(z.email()), "Recipient. Defaults to the primary location’s `email`, then the first location that has one."),
|
|
935
|
+
from: desc(z.optional(z.email()), "Sender. Defaults to `noreply@<domains.canonical>`. Its domain must be onboarded (`wrangler email sending enable <domain>`) or every send fails."),
|
|
936
|
+
fromName: desc(z.optional(z.string()), "Display name on the From header. Defaults to `organization.name`."),
|
|
937
|
+
replyTo: desc(z.optional(z.email()), "Reply-To header."),
|
|
938
|
+
binding: desc(z.optional(z.string()), "Workers `send_email` binding name (default `EMAIL`). Set it when using a restricted binding.")
|
|
939
|
+
})])), "Where conversion notifications are e-mailed. A bare address is shorthand for `{ provider: \"cloudflare\", to }`.") })), "Where site notifications (form submissions, tracked calls) are sent. TODO: `sms` and `persist` channels — only `email` is implemented."),
|
|
921
940
|
tracking: desc(z.optional(z.object({
|
|
922
|
-
enabled: z.optional(z.boolean()),
|
|
923
|
-
|
|
924
|
-
|
|
941
|
+
enabled: desc(z.optional(z.boolean()), "Capture ad click IDs and UTMs from the landing URL into the `_ac` cookie, for attributing conversions."),
|
|
942
|
+
category: desc(z.optional(z.enum(["marketing", "analytics"])), "Consent category the `_ac` cookie waits on (default `marketing`). It is a 90-day identifier tying a visitor to the ad that brought them, so it is only written once this category is granted — and asking for it is what raises the banner."),
|
|
943
|
+
platforms: desc(z.optional(z.array(z.string())), "Informational list of ad platforms in use. Capture recognises every known click-ID parameter regardless.")
|
|
944
|
+
})), "Ad-click attribution for conversion tracking."),
|
|
925
945
|
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)."),
|
|
926
946
|
legal: desc(z.optional(z.object({
|
|
927
947
|
jurisdiction: desc(z.optional(JurisdictionSchema), "Which legal template set the generated documents use. Default `ca`."),
|
|
@@ -1753,6 +1773,590 @@ function checkContrast(fr, numericOf, surfaceBg) {
|
|
|
1753
1773
|
return failures;
|
|
1754
1774
|
}
|
|
1755
1775
|
|
|
1776
|
+
//#endregion
|
|
1777
|
+
//#region src/tiers.ts
|
|
1778
|
+
const c = (classes, partial, generated = false) => ({
|
|
1779
|
+
...partial ? { partial } : {},
|
|
1780
|
+
classes,
|
|
1781
|
+
generated
|
|
1782
|
+
});
|
|
1783
|
+
/**
|
|
1784
|
+
* Shorthand for the usual `@getvitops/astro/components/<name>.astro` export.
|
|
1785
|
+
*
|
|
1786
|
+
* Not every component lives under `components/` — `CookieConsent.astro` is exported
|
|
1787
|
+
* from the package root — so those are written out in full. The specifier must be
|
|
1788
|
+
* one a consumer can actually import: `tiers.test.ts` checks each against
|
|
1789
|
+
* `packages/astro/package.json`'s `exports`, because a wrong one is a copy-pasteable
|
|
1790
|
+
* import that fails only in the consumer's project.
|
|
1791
|
+
*/
|
|
1792
|
+
const a = (name, wraps) => [{
|
|
1793
|
+
component: `@getvitops/astro/components/${name}.astro`,
|
|
1794
|
+
wraps
|
|
1795
|
+
}];
|
|
1796
|
+
const TIERS = {
|
|
1797
|
+
tree: {
|
|
1798
|
+
css: c([
|
|
1799
|
+
"tree",
|
|
1800
|
+
"tree__item",
|
|
1801
|
+
"tree__content",
|
|
1802
|
+
"tree__toggle",
|
|
1803
|
+
"tree__label"
|
|
1804
|
+
], "patterns/tree.css", true),
|
|
1805
|
+
wc: {
|
|
1806
|
+
tag: "wc-tree",
|
|
1807
|
+
registered: true,
|
|
1808
|
+
adds: "filter, expand/collapse all, hash deep-linking"
|
|
1809
|
+
},
|
|
1810
|
+
astro: a("Tree", "wc"),
|
|
1811
|
+
use: "`<Tree items={…} />` — it emits `<wc-tree>` itself, so do NOT add your own wrapper."
|
|
1812
|
+
},
|
|
1813
|
+
carousel: {
|
|
1814
|
+
css: c(["carousel"], "patterns/carousel.css", true),
|
|
1815
|
+
wc: {
|
|
1816
|
+
tag: "wc-carousel",
|
|
1817
|
+
registered: true,
|
|
1818
|
+
adds: "cloned slides for a seamless loop, autoplay, snap nav"
|
|
1819
|
+
},
|
|
1820
|
+
bricks: "vitops-carousel",
|
|
1821
|
+
use: "`<wc-carousel>` around your slides; each child is a slide. Works unenhanced as a scroll-snap strip."
|
|
1822
|
+
},
|
|
1823
|
+
entries: {
|
|
1824
|
+
css: c([
|
|
1825
|
+
"entries__table",
|
|
1826
|
+
"entries__scroll",
|
|
1827
|
+
"entries__nav"
|
|
1828
|
+
], "patterns/table.css"),
|
|
1829
|
+
wc: {
|
|
1830
|
+
tag: "wc-entries",
|
|
1831
|
+
registered: true,
|
|
1832
|
+
adds: "parses heading + `<dl>` pairs into a table above a breakpoint"
|
|
1833
|
+
},
|
|
1834
|
+
bricks: "vitops-entries",
|
|
1835
|
+
use: "`<wc-entries>` around `<h3>` + `<dl>` pairs. The tier-2 exemplar: semantic pairs with no JS, a table with it."
|
|
1836
|
+
},
|
|
1837
|
+
copy: {
|
|
1838
|
+
css: c([
|
|
1839
|
+
"copy",
|
|
1840
|
+
"copy__button",
|
|
1841
|
+
"copy__icon",
|
|
1842
|
+
"copy__tooltip"
|
|
1843
|
+
], "patterns/copy.css"),
|
|
1844
|
+
wc: {
|
|
1845
|
+
tag: "wc-copy",
|
|
1846
|
+
registered: true,
|
|
1847
|
+
adds: "clipboard write plus success feedback"
|
|
1848
|
+
},
|
|
1849
|
+
bricks: "vitops-copy-button",
|
|
1850
|
+
use: "`<wc-copy>` around the content and a `[data-copy]` button."
|
|
1851
|
+
},
|
|
1852
|
+
dismissable: {
|
|
1853
|
+
css: c(["banner__close", "notification__close"], "patterns/banner.css"),
|
|
1854
|
+
wc: {
|
|
1855
|
+
tag: "wc-dismissable",
|
|
1856
|
+
registered: true,
|
|
1857
|
+
adds: "fade-out + removal, optional auto-dismiss"
|
|
1858
|
+
},
|
|
1859
|
+
bricks: "vitops-dismissable",
|
|
1860
|
+
use: "`<wc-dismissable>` around any banner/notification containing a `[data-dismiss]` control."
|
|
1861
|
+
},
|
|
1862
|
+
"split-panel": {
|
|
1863
|
+
css: c([
|
|
1864
|
+
"splitter",
|
|
1865
|
+
"splitter__panel",
|
|
1866
|
+
"splitter__handle"
|
|
1867
|
+
], "patterns/splitter.css"),
|
|
1868
|
+
wc: {
|
|
1869
|
+
tag: "wc-split-panel",
|
|
1870
|
+
registered: true,
|
|
1871
|
+
adds: "draggable divider between two panels"
|
|
1872
|
+
},
|
|
1873
|
+
bricks: "vitops-split-panel",
|
|
1874
|
+
use: "`<wc-split-panel>` with two children. Unenhanced they simply stack."
|
|
1875
|
+
},
|
|
1876
|
+
"image-compare": {
|
|
1877
|
+
css: c([
|
|
1878
|
+
"image-compare",
|
|
1879
|
+
"image-compare__before",
|
|
1880
|
+
"image-compare__handle"
|
|
1881
|
+
], "patterns/splitter.css"),
|
|
1882
|
+
wc: {
|
|
1883
|
+
tag: "wc-image-compare",
|
|
1884
|
+
registered: true,
|
|
1885
|
+
adds: "drag handle revealing the before/after image"
|
|
1886
|
+
},
|
|
1887
|
+
bricks: "vitops-image-compare",
|
|
1888
|
+
use: "`<wc-image-compare>` with two images. Both are visible without JS."
|
|
1889
|
+
},
|
|
1890
|
+
marquee: {
|
|
1891
|
+
css: c([
|
|
1892
|
+
"marquee",
|
|
1893
|
+
"marquee__content",
|
|
1894
|
+
"marquee__item"
|
|
1895
|
+
], "patterns/marquee.css"),
|
|
1896
|
+
wc: {
|
|
1897
|
+
tag: "wc-marquee",
|
|
1898
|
+
registered: true,
|
|
1899
|
+
adds: "fills the track with enough copies to scroll seamlessly"
|
|
1900
|
+
},
|
|
1901
|
+
use: "`<wc-marquee>` around one `.marquee__content`. Scrolls via CSS alone; JS only removes the seam."
|
|
1902
|
+
},
|
|
1903
|
+
"multi-field": {
|
|
1904
|
+
css: c([
|
|
1905
|
+
"form-group",
|
|
1906
|
+
"input-group",
|
|
1907
|
+
"fieldset"
|
|
1908
|
+
], "patterns/forms.css"),
|
|
1909
|
+
wc: {
|
|
1910
|
+
tag: "wc-multi-field",
|
|
1911
|
+
registered: true,
|
|
1912
|
+
adds: "add/remove repeating field rows"
|
|
1913
|
+
},
|
|
1914
|
+
bricks: "vitops-multi-field",
|
|
1915
|
+
use: "`<wc-multi-field>` around a field template. One row submits fine with no JS."
|
|
1916
|
+
},
|
|
1917
|
+
"color-scheme-toggle": {
|
|
1918
|
+
css: c([], "patterns/icon.css"),
|
|
1919
|
+
wc: {
|
|
1920
|
+
tag: "wc-color-scheme-toggle",
|
|
1921
|
+
registered: true,
|
|
1922
|
+
adds: "segmented light/dark/system control; persists the choice (consent-gated)"
|
|
1923
|
+
},
|
|
1924
|
+
bricks: "vitops-color-scheme-toggle",
|
|
1925
|
+
use: "`<wc-color-scheme-toggle></wc-color-scheme-toggle>`. Renamed from `<color-scheme-toggle>` in 4.0. Shadow DOM, so it is absent rather than broken without JS."
|
|
1926
|
+
},
|
|
1927
|
+
consent: {
|
|
1928
|
+
css: c([
|
|
1929
|
+
"consent",
|
|
1930
|
+
"consent__body",
|
|
1931
|
+
"consent__options",
|
|
1932
|
+
"consent__actions"
|
|
1933
|
+
], "patterns/consent.css"),
|
|
1934
|
+
wc: {
|
|
1935
|
+
tag: "wc-consent",
|
|
1936
|
+
registered: false,
|
|
1937
|
+
bundle: "@getvitops/core/consent",
|
|
1938
|
+
adds: "the permission gate itself — activates `type=\"text/plain\"` tags on grant"
|
|
1939
|
+
},
|
|
1940
|
+
astro: [{
|
|
1941
|
+
component: "@getvitops/astro/CookieConsent.astro",
|
|
1942
|
+
wraps: "wc"
|
|
1943
|
+
}],
|
|
1944
|
+
use: "`<CookieConsent />`. Its own Lit-free bundle so a site needing consent does not download a rendering framework."
|
|
1945
|
+
},
|
|
1946
|
+
"theme-editor": {
|
|
1947
|
+
css: c([
|
|
1948
|
+
"ed-panel",
|
|
1949
|
+
"ed-launch",
|
|
1950
|
+
"ed-input"
|
|
1951
|
+
], "patterns/theme-editor.css"),
|
|
1952
|
+
wc: {
|
|
1953
|
+
tag: "wc-theme-editor",
|
|
1954
|
+
registered: false,
|
|
1955
|
+
bundle: "@getvitops/core/editor",
|
|
1956
|
+
adds: "live `:root` token overrides, export as CSS or a config patch"
|
|
1957
|
+
},
|
|
1958
|
+
use: "`<wc-theme-editor manifest=\"…\">`. TOOLING, not a page pattern — the one deliberate tier-2 exception, quarantined in its own opt-in bundle."
|
|
1959
|
+
},
|
|
1960
|
+
"color-wheel": {
|
|
1961
|
+
css: c([]),
|
|
1962
|
+
wc: {
|
|
1963
|
+
tag: "wc-color-wheel",
|
|
1964
|
+
registered: false,
|
|
1965
|
+
adds: "hue/chroma wheel input",
|
|
1966
|
+
bundle: "(none — editor-v2 track, not yet bundled)"
|
|
1967
|
+
},
|
|
1968
|
+
use: "Not shipped. Registered but absent from every bundle, so the tag is inert in a consumer project."
|
|
1969
|
+
},
|
|
1970
|
+
"oklch-color-picker": {
|
|
1971
|
+
css: c([]),
|
|
1972
|
+
wc: {
|
|
1973
|
+
tag: "wc-oklch-color-picker",
|
|
1974
|
+
registered: false,
|
|
1975
|
+
adds: "OKLCH L/C/H picker",
|
|
1976
|
+
bundle: "(none — editor-v2 track, not yet bundled)"
|
|
1977
|
+
},
|
|
1978
|
+
use: "Not shipped. See `color-wheel`."
|
|
1979
|
+
},
|
|
1980
|
+
"icon-picker": {
|
|
1981
|
+
css: c([], "patterns/icon.css"),
|
|
1982
|
+
wc: {
|
|
1983
|
+
tag: "wc-icon-picker",
|
|
1984
|
+
registered: false,
|
|
1985
|
+
adds: "browses the semantic icon map for the configured set",
|
|
1986
|
+
bundle: "(none — editor-v2 track, not yet bundled)"
|
|
1987
|
+
},
|
|
1988
|
+
use: "Not shipped. See `color-wheel`."
|
|
1989
|
+
},
|
|
1990
|
+
"typography-config": {
|
|
1991
|
+
css: c([]),
|
|
1992
|
+
wc: {
|
|
1993
|
+
tag: "wc-typography-config",
|
|
1994
|
+
registered: false,
|
|
1995
|
+
adds: "type role editor",
|
|
1996
|
+
bundle: "(none — editor-v2 track, not yet bundled)"
|
|
1997
|
+
},
|
|
1998
|
+
use: "Not shipped. See `color-wheel`."
|
|
1999
|
+
},
|
|
2000
|
+
details: {
|
|
2001
|
+
css: c([
|
|
2002
|
+
"details",
|
|
2003
|
+
"details-trigger",
|
|
2004
|
+
"details-icon"
|
|
2005
|
+
], "patterns/details.css", true),
|
|
2006
|
+
astro: a("Details", "css"),
|
|
2007
|
+
use: "`<Details>` — or hand-write `<details>`. No JS at either tier; `::details-content` animates in CSS."
|
|
2008
|
+
},
|
|
2009
|
+
drawer: {
|
|
2010
|
+
css: c([
|
|
2011
|
+
"drawer",
|
|
2012
|
+
"drawer--start",
|
|
2013
|
+
"drawer--end"
|
|
2014
|
+
], "patterns/drawer.css", true),
|
|
2015
|
+
astro: a("Drawer", "css"),
|
|
2016
|
+
use: "`<Drawer>` — `<dialog class=\"drawer\">` plus an Invoker Commands trigger. No JS."
|
|
2017
|
+
},
|
|
2018
|
+
popover: {
|
|
2019
|
+
css: c([
|
|
2020
|
+
"popover",
|
|
2021
|
+
"popover-trigger",
|
|
2022
|
+
"popover-anchor"
|
|
2023
|
+
], "patterns/popover.css", true),
|
|
2024
|
+
astro: a("Popover", "css"),
|
|
2025
|
+
use: "`<Popover>` — native Popover API + CSS Anchor Positioning. No JS."
|
|
2026
|
+
},
|
|
2027
|
+
navshell: {
|
|
2028
|
+
css: c([
|
|
2029
|
+
"navshell",
|
|
2030
|
+
"navshell__bar",
|
|
2031
|
+
"navshell__panel",
|
|
2032
|
+
"navshell-toggle"
|
|
2033
|
+
], "patterns/navshell.css"),
|
|
2034
|
+
astro: [...a("NavShell", "css"), ...a("NavShellToggle", "css")],
|
|
2035
|
+
use: "`<NavShell>` with a `nav` slot, plus `<NavShellToggle>` if the toggle must live outside the shell."
|
|
2036
|
+
},
|
|
2037
|
+
subgrid: {
|
|
2038
|
+
css: c([
|
|
2039
|
+
"subgrid",
|
|
2040
|
+
"subgrid-card",
|
|
2041
|
+
"pricing-grid"
|
|
2042
|
+
], "patterns/subgrid.css"),
|
|
2043
|
+
astro: a("Subgrid", "css"),
|
|
2044
|
+
use: "`<Subgrid>` — emits `<ul class=\"grid\">` with row-subgrid children so cards align across tracks."
|
|
2045
|
+
},
|
|
2046
|
+
card: {
|
|
2047
|
+
css: c(["card"], void 0, true),
|
|
2048
|
+
astro: a("Cards", "css"),
|
|
2049
|
+
use: "`<Cards>` for a list, or the `.card` class directly. Generated pattern: has roles and states."
|
|
2050
|
+
},
|
|
2051
|
+
icon: {
|
|
2052
|
+
css: c([
|
|
2053
|
+
"icon",
|
|
2054
|
+
"icon-button",
|
|
2055
|
+
"icon-mask"
|
|
2056
|
+
], "patterns/icon.css"),
|
|
2057
|
+
astro: a("Icon", "css"),
|
|
2058
|
+
bricks: "vitops-icon",
|
|
2059
|
+
use: "`<Icon name=\"menu\" />`. Names are semantic and resolve per configured set; a name containing `:` passes through."
|
|
2060
|
+
},
|
|
2061
|
+
split: {
|
|
2062
|
+
css: c([
|
|
2063
|
+
"split",
|
|
2064
|
+
"split-1-2",
|
|
2065
|
+
"split-reverse"
|
|
2066
|
+
], "layout.css"),
|
|
2067
|
+
bricks: "vitops-split",
|
|
2068
|
+
use: "`.split` plus a ratio class. Pure CSS; `md-split-1-2` for the responsive form."
|
|
2069
|
+
},
|
|
2070
|
+
centered: {
|
|
2071
|
+
css: c([
|
|
2072
|
+
"centered",
|
|
2073
|
+
"spotlight",
|
|
2074
|
+
"breakout",
|
|
2075
|
+
"fullbleed"
|
|
2076
|
+
], "layout.css"),
|
|
2077
|
+
bricks: "vitops-centered",
|
|
2078
|
+
use: "`.centered` as a track grid. Children land in `measure` — give it a track child to lay out inside."
|
|
2079
|
+
},
|
|
2080
|
+
sitenav: {
|
|
2081
|
+
css: c([
|
|
2082
|
+
"sitenav",
|
|
2083
|
+
"sitenav__link",
|
|
2084
|
+
"sitenav--drawer-start"
|
|
2085
|
+
], "patterns/sitenav.css"),
|
|
2086
|
+
bricks: "vitops-sitenav",
|
|
2087
|
+
use: "`.sitenav` with `--bp-*` and a drawer direction. `<details>` + Invoker Commands, no JS."
|
|
2088
|
+
},
|
|
2089
|
+
"split-link": {
|
|
2090
|
+
css: c([
|
|
2091
|
+
"split-link",
|
|
2092
|
+
"split-link__toggle",
|
|
2093
|
+
"split-link__panel"
|
|
2094
|
+
], "patterns/split-link.css"),
|
|
2095
|
+
bricks: "vitops-split-link",
|
|
2096
|
+
use: "`.split-link` — a link plus a disclosure toggle. No JS."
|
|
2097
|
+
},
|
|
2098
|
+
btn: {
|
|
2099
|
+
css: c(["btn"], void 0, true),
|
|
2100
|
+
use: "`<button>` gets it with no class; `.btn` carries it to other tags. `fill: false`, so states drive `color`."
|
|
2101
|
+
},
|
|
2102
|
+
cta: {
|
|
2103
|
+
css: c(["cta", "cta-danger"], void 0, true),
|
|
2104
|
+
use: "`.cta` on any element — usually `<a>`, since a CTA navigates. Filled, with role variants."
|
|
2105
|
+
},
|
|
2106
|
+
link: {
|
|
2107
|
+
css: c(["link", "skip-link"], "patterns/anchor-link.css", true),
|
|
2108
|
+
use: "`<a>` gets it at zero specificity; `.link` for other tags."
|
|
2109
|
+
},
|
|
2110
|
+
badge: {
|
|
2111
|
+
css: c(["badge", "badge-indicator"], "patterns/tag.css", true),
|
|
2112
|
+
use: "`.badge` — a STATIC label. Use `tag` if it is dismissable or editable."
|
|
2113
|
+
},
|
|
2114
|
+
tag: {
|
|
2115
|
+
css: c([
|
|
2116
|
+
"tag",
|
|
2117
|
+
"tag__remove",
|
|
2118
|
+
"tag__icon",
|
|
2119
|
+
"tag-list"
|
|
2120
|
+
], "patterns/tag.css", true),
|
|
2121
|
+
use: "`.tag` — an editable/dismissable label. Its group is `label`, not `tag`."
|
|
2122
|
+
},
|
|
2123
|
+
status: {
|
|
2124
|
+
css: c(["status", "status-dot--pulse"], "patterns/tag.css", true),
|
|
2125
|
+
use: "`.status` with a role variant."
|
|
2126
|
+
},
|
|
2127
|
+
tooltip: {
|
|
2128
|
+
css: c(["tooltip-trigger"], "patterns/tooltip.css", true),
|
|
2129
|
+
use: "`.tooltip-trigger` — CSS only, anchor-positioned."
|
|
2130
|
+
},
|
|
2131
|
+
dialog: {
|
|
2132
|
+
css: c([
|
|
2133
|
+
"dialog__header",
|
|
2134
|
+
"dialog__body",
|
|
2135
|
+
"dialog__footer"
|
|
2136
|
+
], "patterns/dialog.css", true),
|
|
2137
|
+
use: "Native `<dialog>` plus the `.dialog__*` parts. No JS beyond `showModal()`."
|
|
2138
|
+
},
|
|
2139
|
+
dropdown: {
|
|
2140
|
+
css: c(["dropdown--show-on-hover", "dropdown-menu"], "patterns/dropdown.css", true),
|
|
2141
|
+
use: "`.dropdown` — Popover API based."
|
|
2142
|
+
},
|
|
2143
|
+
notification: {
|
|
2144
|
+
css: c([
|
|
2145
|
+
"notification",
|
|
2146
|
+
"notification__title",
|
|
2147
|
+
"notification__actions"
|
|
2148
|
+
], "patterns/notification.css", true),
|
|
2149
|
+
use: "`.notification` with a role variant; wrap in `<wc-dismissable>` to make it dismissable."
|
|
2150
|
+
},
|
|
2151
|
+
lightbox: {
|
|
2152
|
+
css: c(["lightbox", "lightbox-dialog__content"], "patterns/lightbox.css", true),
|
|
2153
|
+
use: "`.lightbox` thumbnails plus a `<dialog>`."
|
|
2154
|
+
},
|
|
2155
|
+
comment: {
|
|
2156
|
+
css: c([
|
|
2157
|
+
"comment",
|
|
2158
|
+
"comment__author",
|
|
2159
|
+
"comment-thread"
|
|
2160
|
+
], "patterns/comment.css", true),
|
|
2161
|
+
use: "`.comment` inside `.comment-thread`."
|
|
2162
|
+
},
|
|
2163
|
+
tabs: {
|
|
2164
|
+
css: c(["tabs"], void 0, true),
|
|
2165
|
+
use: "Generated pattern only; no structural partial and no component yet."
|
|
2166
|
+
},
|
|
2167
|
+
nav: {
|
|
2168
|
+
css: c([
|
|
2169
|
+
"menu",
|
|
2170
|
+
"navbar",
|
|
2171
|
+
"nav-collapse",
|
|
2172
|
+
"drawer-nav"
|
|
2173
|
+
], "patterns/nav.css", true),
|
|
2174
|
+
use: "`.menu` / `.navbar`. See `navbar` and `navshell` for the newer shells."
|
|
2175
|
+
},
|
|
2176
|
+
navbar: {
|
|
2177
|
+
css: c([
|
|
2178
|
+
"navbar",
|
|
2179
|
+
"navbar--sticky",
|
|
2180
|
+
"nav-items"
|
|
2181
|
+
], "patterns/navbar.css"),
|
|
2182
|
+
use: "`.navbar` with `--start/--center/--end`."
|
|
2183
|
+
},
|
|
2184
|
+
banner: {
|
|
2185
|
+
css: c([
|
|
2186
|
+
"banner",
|
|
2187
|
+
"banner__content",
|
|
2188
|
+
"banner__action"
|
|
2189
|
+
], "patterns/banner.css", true),
|
|
2190
|
+
use: "`.banner`; wrap in `<wc-dismissable>` for a close button that works."
|
|
2191
|
+
},
|
|
2192
|
+
table: {
|
|
2193
|
+
css: c(["table", "table-wrapper"], "patterns/table.css", true),
|
|
2194
|
+
use: "`.table` inside `.table-wrapper`. See `entries` for the responsive-to-table pattern."
|
|
2195
|
+
},
|
|
2196
|
+
list: {
|
|
2197
|
+
css: c(["facet-list", "filtered-list"], "patterns/list.css", true),
|
|
2198
|
+
use: "`.facet-list` / `.filtered-list`."
|
|
2199
|
+
},
|
|
2200
|
+
"pull-quote": {
|
|
2201
|
+
css: c(["pull-quote", "pull-quote__attribution"], "patterns/pull-quote.css", true),
|
|
2202
|
+
use: "`.pull-quote`. Note the type role `quote` is separate."
|
|
2203
|
+
},
|
|
2204
|
+
combobox: {
|
|
2205
|
+
css: c([
|
|
2206
|
+
"combobox",
|
|
2207
|
+
"combobox__listbox",
|
|
2208
|
+
"combobox__option"
|
|
2209
|
+
], "patterns/combobox.css", true),
|
|
2210
|
+
use: "CSS shell only — the Lit component is a TODO, so it is not interactive yet."
|
|
2211
|
+
},
|
|
2212
|
+
forms: {
|
|
2213
|
+
css: c([
|
|
2214
|
+
"form-group",
|
|
2215
|
+
"input-group",
|
|
2216
|
+
"fieldset",
|
|
2217
|
+
"custom-check"
|
|
2218
|
+
], "patterns/forms.css", true),
|
|
2219
|
+
use: "Wrap text controls in `.form-group` or `.input-group` — a bare `<input>` gets browser defaults."
|
|
2220
|
+
},
|
|
2221
|
+
cluster: {
|
|
2222
|
+
css: c([
|
|
2223
|
+
"cluster",
|
|
2224
|
+
"cluster-between",
|
|
2225
|
+
"cluster-start"
|
|
2226
|
+
], "patterns/cluster.css"),
|
|
2227
|
+
use: "`.cluster` plus an alignment variant."
|
|
2228
|
+
},
|
|
2229
|
+
code: {
|
|
2230
|
+
css: c([], "patterns/code.css"),
|
|
2231
|
+
use: "Element selectors on `pre`/`code`; no class needed."
|
|
2232
|
+
},
|
|
2233
|
+
grouped: {
|
|
2234
|
+
css: c([
|
|
2235
|
+
"group",
|
|
2236
|
+
"group-inline",
|
|
2237
|
+
"bordered"
|
|
2238
|
+
], "patterns/grouped.css"),
|
|
2239
|
+
use: "`.group-inline` / `.group-block` to join adjacent controls."
|
|
2240
|
+
},
|
|
2241
|
+
"horizontal-scroll": {
|
|
2242
|
+
css: c(["horizontal-scroll"], "patterns/horizontal-scroll.css"),
|
|
2243
|
+
use: "`.horizontal-scroll` for a snap strip."
|
|
2244
|
+
},
|
|
2245
|
+
masonry: {
|
|
2246
|
+
css: c(["masonry", "masonry__item"], "patterns/masonry.css"),
|
|
2247
|
+
use: "`.masonry` — CSS columns based."
|
|
2248
|
+
},
|
|
2249
|
+
media: {
|
|
2250
|
+
css: c([
|
|
2251
|
+
"media",
|
|
2252
|
+
"media__figure",
|
|
2253
|
+
"tile"
|
|
2254
|
+
], "patterns/media.css"),
|
|
2255
|
+
use: "`.media` for a figure/body pair; `.tile` for the stacked form."
|
|
2256
|
+
},
|
|
2257
|
+
overlay: {
|
|
2258
|
+
css: c(["overlay"], "patterns/overlay.css"),
|
|
2259
|
+
use: "`.overlay` for a scrim."
|
|
2260
|
+
},
|
|
2261
|
+
reveal: {
|
|
2262
|
+
css: c([
|
|
2263
|
+
"reveal",
|
|
2264
|
+
"reveal-fade",
|
|
2265
|
+
"reveal-h"
|
|
2266
|
+
], "patterns/reveal.css"),
|
|
2267
|
+
use: "`.reveal` — scroll-driven, no JS. Hosts the `interpolate-size` that `<details>` animation needs."
|
|
2268
|
+
},
|
|
2269
|
+
"scroll-based": {
|
|
2270
|
+
css: c([
|
|
2271
|
+
"parallax",
|
|
2272
|
+
"scroll-progress",
|
|
2273
|
+
"scroll-spy"
|
|
2274
|
+
], "patterns/scroll-based.css"),
|
|
2275
|
+
use: "Scroll-timeline driven; no JS."
|
|
2276
|
+
},
|
|
2277
|
+
"scroll-target": {
|
|
2278
|
+
css: c(["scroll-target-group", "toc-sidebar"], "patterns/scroll-target.css"),
|
|
2279
|
+
use: "`.scroll-target-group` for `:target-current` highlighting."
|
|
2280
|
+
},
|
|
2281
|
+
separator: {
|
|
2282
|
+
css: c([
|
|
2283
|
+
"separator",
|
|
2284
|
+
"separator__text",
|
|
2285
|
+
"separator__ornament"
|
|
2286
|
+
], "patterns/separator.css"),
|
|
2287
|
+
use: "`.separator`, optionally with text or an ornament."
|
|
2288
|
+
},
|
|
2289
|
+
stack: {
|
|
2290
|
+
css: c(["stack", "stack__card"], "patterns/stack.css"),
|
|
2291
|
+
use: "`.stack` for overlapping cards."
|
|
2292
|
+
},
|
|
2293
|
+
sticky: {
|
|
2294
|
+
css: c([
|
|
2295
|
+
"sticky",
|
|
2296
|
+
"sticky-header",
|
|
2297
|
+
"sticky-nav"
|
|
2298
|
+
], "patterns/sticky.css"),
|
|
2299
|
+
use: "`.sticky-header` etc. Pure CSS."
|
|
2300
|
+
},
|
|
2301
|
+
svg: {
|
|
2302
|
+
css: c([
|
|
2303
|
+
"svg-container",
|
|
2304
|
+
"svg-shape",
|
|
2305
|
+
"svg-animated"
|
|
2306
|
+
], "patterns/svg.css"),
|
|
2307
|
+
use: "`.svg-*` helpers for inline SVG."
|
|
2308
|
+
},
|
|
2309
|
+
"text-effects": {
|
|
2310
|
+
css: c([
|
|
2311
|
+
"typing",
|
|
2312
|
+
"text-highlight",
|
|
2313
|
+
"text-gradient-animate",
|
|
2314
|
+
"text-stagger"
|
|
2315
|
+
], "patterns/text-effects.css"),
|
|
2316
|
+
use: "`.typing`, `.text-highlight`, … CSS animation only."
|
|
2317
|
+
}
|
|
2318
|
+
};
|
|
2319
|
+
/** Pattern names, sorted — a stable order for docs and tests. */
|
|
2320
|
+
const TIER_NAMES = Object.keys(TIERS).sort();
|
|
2321
|
+
/** Every `wc-*` tag the manifest knows, whether shipped in `elements.js` or not. */
|
|
2322
|
+
function tierTags() {
|
|
2323
|
+
return Object.entries(TIERS).filter(([, e]) => e.wc).map(([pattern, e]) => ({
|
|
2324
|
+
tag: e.wc.tag,
|
|
2325
|
+
registered: e.wc.registered,
|
|
2326
|
+
pattern
|
|
2327
|
+
}));
|
|
2328
|
+
}
|
|
2329
|
+
/**
|
|
2330
|
+
* The patterns one tier provides, in `TIER_NAMES` order.
|
|
2331
|
+
*
|
|
2332
|
+
* Every renderer goes through this rather than filtering `TIERS` itself: the docs
|
|
2333
|
+
* bundle projects all four tiers into one table for agents, the docsite projects
|
|
2334
|
+
* each tier as its own page for humans, and they must agree about which patterns
|
|
2335
|
+
* are in a tier. Five copies of `.filter(e => e.wc)` would agree until one of them
|
|
2336
|
+
* didn't.
|
|
2337
|
+
*
|
|
2338
|
+
* `css` needs a predicate rather than a presence check, which is the non-obvious
|
|
2339
|
+
* part. `TierEntry.css` is required, so *every* entry has one — including the
|
|
2340
|
+
* editor-v2 track, whose entries carry `c([])`: no partial, no classes, not
|
|
2341
|
+
* generated. Treating the field's presence as membership would list four patterns
|
|
2342
|
+
* on the CSS page with nothing to show for them, and `color-wheel` genuinely has
|
|
2343
|
+
* no CSS. The other three tiers are optional fields, so presence is the test.
|
|
2344
|
+
*/
|
|
2345
|
+
function tierPatterns(tier) {
|
|
2346
|
+
const has = (e) => {
|
|
2347
|
+
switch (tier) {
|
|
2348
|
+
case "css": return !!e.css.partial || e.css.classes.length > 0 || e.css.generated;
|
|
2349
|
+
case "wc": return !!e.wc;
|
|
2350
|
+
case "astro": return !!e.astro?.length;
|
|
2351
|
+
case "bricks": return !!e.bricks;
|
|
2352
|
+
}
|
|
2353
|
+
};
|
|
2354
|
+
return TIER_NAMES.filter((name) => has(TIERS[name])).map((name) => ({
|
|
2355
|
+
name,
|
|
2356
|
+
entry: TIERS[name]
|
|
2357
|
+
}));
|
|
2358
|
+
}
|
|
2359
|
+
|
|
1756
2360
|
//#endregion
|
|
1757
2361
|
//#region src/docs.ts
|
|
1758
2362
|
/**
|
|
@@ -1770,13 +2374,13 @@ const ORDER = [
|
|
|
1770
2374
|
"split",
|
|
1771
2375
|
"centered",
|
|
1772
2376
|
"carousel",
|
|
1773
|
-
"color-scheme-toggle",
|
|
2377
|
+
"wc-color-scheme-toggle",
|
|
1774
2378
|
"wc-copy",
|
|
1775
2379
|
"dismissable",
|
|
1776
2380
|
"entries",
|
|
1777
2381
|
"image-compare",
|
|
1778
2382
|
"sitenav",
|
|
1779
|
-
"wc-
|
|
2383
|
+
"wc-multi-field",
|
|
1780
2384
|
"split-link",
|
|
1781
2385
|
"split-panel"
|
|
1782
2386
|
];
|
|
@@ -2515,26 +3119,26 @@ function childEntries(node) {
|
|
|
2515
3119
|
}];
|
|
2516
3120
|
return [];
|
|
2517
3121
|
}
|
|
2518
|
-
function renderSchemaNode(label, node, required, depth, out) {
|
|
3122
|
+
function renderSchemaNode(label, node, required, depth, out, maxDepth = 3) {
|
|
2519
3123
|
const indent = " ".repeat(depth);
|
|
2520
3124
|
const meta = `(${typeLabel(node)}${required ? ", required" : ""})`;
|
|
2521
3125
|
out.push(`${indent}- \`${label}\` ${meta}${node.description ? ` — ${node.description}` : ""}`);
|
|
2522
|
-
if (depth >=
|
|
3126
|
+
if (depth >= maxDepth) return;
|
|
2523
3127
|
if (node.anyOf && !node.anyOf.every(isPrimitive)) {
|
|
2524
3128
|
for (const variant of node.anyOf) {
|
|
2525
3129
|
out.push(`${indent} - *one of*${variant.description ? ` — ${variant.description}` : ""}`);
|
|
2526
|
-
for (const c of childEntries(variant)) renderSchemaNode(c.label, c.node, c.required, depth + 2, out);
|
|
3130
|
+
for (const c of childEntries(variant)) renderSchemaNode(c.label, c.node, c.required, depth + 2, out, maxDepth);
|
|
2527
3131
|
}
|
|
2528
3132
|
return;
|
|
2529
3133
|
}
|
|
2530
|
-
for (const c of childEntries(node)) renderSchemaNode(c.label, c.node, c.required, depth + 1, out);
|
|
3134
|
+
for (const c of childEntries(node)) renderSchemaNode(c.label, c.node, c.required, depth + 1, out, maxDepth);
|
|
2531
3135
|
}
|
|
2532
3136
|
/**
|
|
2533
3137
|
* One `## <key>` section per top-level property: its description, then its
|
|
2534
3138
|
* fields as a bullet tree. Shared by both references so the two cannot drift in
|
|
2535
3139
|
* presentation — only in which schema they are pointed at.
|
|
2536
3140
|
*/
|
|
2537
|
-
function schemaSections(schema, heading = "##") {
|
|
3141
|
+
function schemaSections(schema, heading = "##", maxDepth = 3) {
|
|
2538
3142
|
const required = schema.required ?? [];
|
|
2539
3143
|
const sections = [];
|
|
2540
3144
|
for (const [key, node] of Object.entries(schema.properties ?? {})) {
|
|
@@ -2542,12 +3146,102 @@ function schemaSections(schema, heading = "##") {
|
|
|
2542
3146
|
const parts = [`${heading} \`${key}\`${required.includes(key) ? "" : " *(optional)*"}`];
|
|
2543
3147
|
if (node.description) parts.push("", node.description);
|
|
2544
3148
|
const bullets = [];
|
|
2545
|
-
for (const c of childEntries(node)) renderSchemaNode(c.label, c.node, c.required, 0, bullets);
|
|
3149
|
+
for (const c of childEntries(node)) renderSchemaNode(c.label, c.node, c.required, 0, bullets, maxDepth);
|
|
2546
3150
|
if (bullets.length) parts.push("", ...bullets);
|
|
2547
3151
|
sections.push(parts.join("\n"));
|
|
2548
3152
|
}
|
|
2549
3153
|
return sections;
|
|
2550
3154
|
}
|
|
3155
|
+
const escapeHtml = (s) => s.replace(/[&<>"]/g, (c) => ({
|
|
3156
|
+
"&": "&",
|
|
3157
|
+
"<": "<",
|
|
3158
|
+
">": ">",
|
|
3159
|
+
"\"": """
|
|
3160
|
+
})[c]);
|
|
3161
|
+
/** Sentinel for a lifted code span. Cannot occur in authored schema prose. */
|
|
3162
|
+
const CODE_SLOT = "\0";
|
|
3163
|
+
/**
|
|
3164
|
+
* The inline markdown the schema's `desc()` text actually uses, as HTML. Closed
|
|
3165
|
+
* on purpose, exactly like the legal renderer's subset: these strings are
|
|
3166
|
+
* authored in `schema.ts`, so the set is knowable rather than guessed at.
|
|
3167
|
+
*
|
|
3168
|
+
* Escaping happens before any markup is emitted, so a description containing `<`
|
|
3169
|
+
* cannot inject tags — several do (`<canonical>/<key>.txt`). Exported because the
|
|
3170
|
+
* only safe place for this is one tested function; a caller doing its own escaping
|
|
3171
|
+
* is how one of them eventually doesn't.
|
|
3172
|
+
*
|
|
3173
|
+
* **Code spans are lifted out FIRST, and that is load-bearing rather than tidy.**
|
|
3174
|
+
* `colors.utilities` describes the target families as `` `bg-*` ``, `` `text-*` ``,
|
|
3175
|
+
* `` `border-*` `` — literal asterisk WILDCARDS. Run emphasis over the raw string
|
|
3176
|
+
* and the `*` closing `bg-*` pairs with the one closing `text-*`, wrapping the
|
|
3177
|
+
* text between two unrelated utilities in `<em>` and eating both asterisks. The
|
|
3178
|
+
* prose then silently describes families that don't exist. Lifting code spans
|
|
3179
|
+
* first makes emphasis structurally unable to see inside them.
|
|
3180
|
+
*/
|
|
3181
|
+
const renderInlineMarkdown = (s) => {
|
|
3182
|
+
const spans = [];
|
|
3183
|
+
return escapeHtml(s.replace(/`([^`]+)`/g, (_, body) => {
|
|
3184
|
+
spans.push(body);
|
|
3185
|
+
return `${CODE_SLOT}${spans.length - 1}${CODE_SLOT}`;
|
|
3186
|
+
})).replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*\n]+)\*/g, "<em>$1</em>").replace(new RegExp(`${CODE_SLOT}(\\d+)${CODE_SLOT}`, "g"), (_, i) => `<code>${escapeHtml(spans[Number(i)] ?? "")}</code>`);
|
|
3187
|
+
};
|
|
3188
|
+
/**
|
|
3189
|
+
* Walk a JSON Schema into tree DATA.
|
|
3190
|
+
*
|
|
3191
|
+
* The same walk that produces the `authoring.md` / `config.md` bullet lists,
|
|
3192
|
+
* returned as a node model instead of a string. Returning data rather than markup
|
|
3193
|
+
* is deliberate and follows `roleColorUtilities()`: the *shape* of the tree is one
|
|
3194
|
+
* decision, and each medium renders it — markdown for agents, an accessible
|
|
3195
|
+
* `<details>` tree for the docs site. An HTML-emitting version here would be a
|
|
3196
|
+
* second source of the markup contract for `<wc-tree>` to drift from.
|
|
3197
|
+
*
|
|
3198
|
+
* Ids are dotted paths so a field can be linked to directly. Map-value and
|
|
3199
|
+
* array-item pseudo-labels are skipped in the path: `site.dns.records` reads
|
|
3200
|
+
* better than `site.dns.<domain>.records`, and neither `<domain>` nor `[items]`
|
|
3201
|
+
* is a real key.
|
|
3202
|
+
*/
|
|
3203
|
+
function schemaTreeNodes(schema, opts = {}) {
|
|
3204
|
+
const { idPrefix = "", maxDepth = 12, prune = [] } = opts;
|
|
3205
|
+
const pruned = new Set(prune);
|
|
3206
|
+
const isPseudo = (label) => label === "<name>" || label === "[items]";
|
|
3207
|
+
const join = (a, b) => a ? `${a}.${b}` : b;
|
|
3208
|
+
/**
|
|
3209
|
+
* Paths already claimed. An `anyOf` branch has no key of its own, so sibling
|
|
3210
|
+
* branches sharing a field name resolve to the same dotted path
|
|
3211
|
+
* (`site.templates.type` exists in all three template variants). Rather than
|
|
3212
|
+
* invent a branch index that no real config document contains, the first
|
|
3213
|
+
* occurrence keeps the id — which is what `getElementById` would have picked
|
|
3214
|
+
* anyway, now true instead of accidental.
|
|
3215
|
+
*/
|
|
3216
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3217
|
+
const build = (label, n, required, path, depth) => {
|
|
3218
|
+
const addressable = path !== "" && !isPseudo(label) && !seen.has(path);
|
|
3219
|
+
if (addressable) seen.add(path);
|
|
3220
|
+
const variants = n.anyOf && !n.anyOf.every(isPrimitive) ? n.anyOf : [];
|
|
3221
|
+
const children = [];
|
|
3222
|
+
if (depth < maxDepth && !pruned.has(path)) {
|
|
3223
|
+
for (const v of variants) children.push({
|
|
3224
|
+
label: "one of",
|
|
3225
|
+
type: typeLabel(v),
|
|
3226
|
+
required: false,
|
|
3227
|
+
variant: true,
|
|
3228
|
+
...v.description ? { description: v.description } : {},
|
|
3229
|
+
children: childEntries(v).map((c) => build(c.label, c.node, c.required, isPseudo(c.label) ? path : join(path, c.label), depth + 2))
|
|
3230
|
+
});
|
|
3231
|
+
if (!variants.length) for (const c of childEntries(n)) children.push(build(c.label, c.node, c.required, isPseudo(c.label) ? path : join(path, c.label), depth + 1));
|
|
3232
|
+
}
|
|
3233
|
+
return {
|
|
3234
|
+
label,
|
|
3235
|
+
type: typeLabel(n),
|
|
3236
|
+
required,
|
|
3237
|
+
...n.description ? { description: n.description } : {},
|
|
3238
|
+
...addressable ? { id: path } : {},
|
|
3239
|
+
children
|
|
3240
|
+
};
|
|
3241
|
+
};
|
|
3242
|
+
const required = schema.required ?? [];
|
|
3243
|
+
return Object.entries(schema.properties ?? {}).filter(([key]) => key !== "$schema").map(([key, child]) => build(key, child, required.includes(key), join(idPrefix, key), 0));
|
|
3244
|
+
}
|
|
2551
3245
|
function renderAuthoring() {
|
|
2552
3246
|
const sections = schemaSections(jsonSchema);
|
|
2553
3247
|
return `${frontmatter({
|
|
@@ -2592,7 +3286,7 @@ function renderConfig() {
|
|
|
2592
3286
|
const required = schema.required ?? [];
|
|
2593
3287
|
const sections = Object.entries(props).map(([key, node]) => {
|
|
2594
3288
|
const head = [`## \`${key}\`${required.includes(key) ? "" : " *(optional)*"}`, ...node.description ? ["", node.description] : []].join("\n");
|
|
2595
|
-
const fields = schemaSections(node, "###").join("\n\n");
|
|
3289
|
+
const fields = schemaSections(node, "###", key === "designSystem" ? 0 : 3).join("\n\n");
|
|
2596
3290
|
if (key === "designSystem") return [
|
|
2597
3291
|
head,
|
|
2598
3292
|
"The token set. Its `themes.<name>` entries are full design systems — every field of\none is in [authoring.md](authoring.md). Only the wrapper is listed here.",
|
|
@@ -2618,7 +3312,7 @@ function renderConfig() {
|
|
|
2618
3312
|
|
|
2619
3313
|
The document that describes a **whole project**: the token set, the company, and the
|
|
2620
3314
|
published site. It is the input every command that needs more than tokens is anchored
|
|
2621
|
-
to — \`vitops legal\`, \`vitops icons\`, \`vitops
|
|
3315
|
+
to — \`vitops legal\`, \`vitops icons\`, \`vitops search\` — and it can also stand in for
|
|
2622
3316
|
a \`design-system.json\` anywhere the toolchain takes one, since it carries a full
|
|
2623
3317
|
\`designSystem\`.
|
|
2624
3318
|
|
|
@@ -2911,14 +3605,14 @@ Raw hue steps (\`--color-<hue>-<step>\`) are the exception — they are fixed va
|
|
|
2911
3605
|
ignores the appearance.
|
|
2912
3606
|
|
|
2913
3607
|
Two attributes, one flip: \`data-brx-theme\` is Bricks' own (Bricks sets it on the
|
|
2914
|
-
WordPress target), \`data-theme\` is what the shipped \`<color-scheme-toggle>\` writes on
|
|
3608
|
+
WordPress target), \`data-theme\` is what the shipped \`<wc-color-scheme-toggle>\` writes on
|
|
2915
3609
|
\`<html>\`, so the toggle works on every other target. Set either.
|
|
2916
3610
|
|
|
2917
3611
|
The OS preference is a **second, opt-in block**. Set
|
|
2918
3612
|
\`designSystem.defaultColorScheme: "system"\` in your site config and the same delta is emitted again inside
|
|
2919
3613
|
\`@media (prefers-color-scheme: dark)\`, under
|
|
2920
3614
|
\`${SYSTEM_DARK_SEL}\` — i.e. whenever no explicit choice has been made. That is what makes
|
|
2921
|
-
\`<color-scheme-toggle>\`'s "System" position resolve to the OS (it *removes* the attribute,
|
|
3615
|
+
\`<wc-color-scheme-toggle>\`'s "System" position resolve to the OS (it *removes* the attribute,
|
|
2922
3616
|
so without this block it fell through to light), and it gives a no-JS page the OS
|
|
2923
3617
|
appearance, which the toggle alone never could. An explicit light choice still wins.
|
|
2924
3618
|
|
|
@@ -3128,6 +3822,138 @@ token guaranteed legible over a surface in both appearances. \`default_role\` co
|
|
|
3128
3822
|
bare, unsuffixed pattern. States re-apply per variant with the variant's role.
|
|
3129
3823
|
`;
|
|
3130
3824
|
}
|
|
3825
|
+
/**
|
|
3826
|
+
* All four tiers in ONE doc — the agent-facing projection of `TIERS`.
|
|
3827
|
+
*
|
|
3828
|
+
* The docsite projects each tier as its own page, because a human arrives already
|
|
3829
|
+
* knowing which stack they are in ("show me the Astro components"). An agent
|
|
3830
|
+
* arrives the other way round: it knows the *pattern* it needs and has to be told
|
|
3831
|
+
* which tiers offer it and which call to make. Four docs would mean an agent
|
|
3832
|
+
* fetching `components/astro` learns `<Tree />` exists and never learns it must not
|
|
3833
|
+
* wrap it in `<wc-tree>` — the composition only exists between the tiers, so the
|
|
3834
|
+
* projection that serves agents is the one that keeps them together.
|
|
3835
|
+
*
|
|
3836
|
+
* `ds` is read for one thing: whether each config-authored pattern is actually
|
|
3837
|
+
* declared in THIS consumer's `patterns.items`. `TIERS.css.generated` records that a
|
|
3838
|
+
* pattern is *of that kind* against this repo's reference config; it cannot know
|
|
3839
|
+
* that a given consumer dropped `card`. Reporting a class that this config emits no
|
|
3840
|
+
* CSS for is the failure worth preventing here.
|
|
3841
|
+
*/
|
|
3842
|
+
function renderComponentsConcept(ds) {
|
|
3843
|
+
const declared = new Set(Object.keys(ds.patterns?.items ?? {}));
|
|
3844
|
+
const cell = (s) => s.replace(/\|/g, "\\|");
|
|
3845
|
+
const code = (s) => `\`${s}\``;
|
|
3846
|
+
const list = (xs) => xs.map(code).join(", ");
|
|
3847
|
+
const tiersOf = (name) => [
|
|
3848
|
+
"css",
|
|
3849
|
+
"wc",
|
|
3850
|
+
"astro",
|
|
3851
|
+
"bricks"
|
|
3852
|
+
].filter((t) => tierPatterns(t).some((p) => p.name === name));
|
|
3853
|
+
const overview = TIER_NAMES.map((name) => {
|
|
3854
|
+
const e = TIERS[name];
|
|
3855
|
+
return `| ${code(name)} | ${tiersOf(name).join(" · ")} | ${cell(e.use)} |`;
|
|
3856
|
+
}).join("\n");
|
|
3857
|
+
const wc = tierPatterns("wc").map(({ name, entry }) => {
|
|
3858
|
+
const w = entry.wc;
|
|
3859
|
+
const ships = w.registered ? code("elements.js") : w.bundle ? code(w.bundle) : "nothing";
|
|
3860
|
+
return `| ${code(`<${w.tag}>`)} | ${code(name)} | ${ships} | ${cell(w.adds)} |`;
|
|
3861
|
+
}).join("\n");
|
|
3862
|
+
const astro = tierPatterns("astro").flatMap(({ name, entry }) => entry.astro.map((a) => `| ${code(a.component)} | ${code(name)} | ${a.wraps === "wc" ? `the ${code(`<${entry.wc.tag}>`)} tag, fallback inside` : "tier-1 markup — no web component"} |`)).join("\n");
|
|
3863
|
+
const bricks = tierPatterns("bricks").map(({ name, entry }) => `| ${code(entry.bricks)} | ${code(name)} |`).join("\n");
|
|
3864
|
+
const css = tierPatterns("css").map(({ name, entry }) => {
|
|
3865
|
+
const c = entry.css;
|
|
3866
|
+
const config = !c.generated ? "—" : declared.has(name) ? "declared" : "**absent from this config — no CSS emitted**";
|
|
3867
|
+
return `| ${code(name)} | ${c.partial ? code(c.partial) : "—"} | ${c.classes.length ? list(c.classes) : "—"} | ${config} |`;
|
|
3868
|
+
}).join("\n");
|
|
3869
|
+
const hooks = [...new Set(Object.values(BASE_HOOK))].map((sfx) => code(`--${sfx}-<pattern>`)).join(", ");
|
|
3870
|
+
const unshipped = tierPatterns("wc").filter(({ entry }) => !entry.wc.registered && entry.wc.bundle?.startsWith("(none")).map(({ entry }) => code(`<${entry.wc.tag}>`)).join(", ");
|
|
3871
|
+
return `${frontmatter({
|
|
3872
|
+
type: "Design Concept",
|
|
3873
|
+
title: "Vitops components — which tier provides a pattern, and what to write",
|
|
3874
|
+
description: "Every UI pattern across the four tiers (CSS classes, wc-* web components, Astro components, Bricks elements): which tiers provide it, which call to make, and how the tiers compose.",
|
|
3875
|
+
resource: DS_PATH,
|
|
3876
|
+
tags: [
|
|
3877
|
+
"components",
|
|
3878
|
+
"web-components",
|
|
3879
|
+
"astro",
|
|
3880
|
+
"bricks",
|
|
3881
|
+
"tiers",
|
|
3882
|
+
"design-system"
|
|
3883
|
+
]
|
|
3884
|
+
})}
|
|
3885
|
+
|
|
3886
|
+
# Components
|
|
3887
|
+
|
|
3888
|
+
A pattern is provided by up to four tiers, and they **compose** rather than compete:
|
|
3889
|
+
|
|
3890
|
+
1. **CSS framework classes** — every pattern expressible in pure HTML/CSS. Reach here first.
|
|
3891
|
+
2. **Web components** (${code("<wc-*>")}, Lit) — only where a pattern genuinely benefits from
|
|
3892
|
+
progressive enhancement. The slotted markup is the fallback and must be usable with no JS;
|
|
3893
|
+
the element parses and augments it in place.
|
|
3894
|
+
3. **Astro components** — authoring conveniences that emit the correct markup. They must not
|
|
3895
|
+
require runtime JS. Where a pattern has a web component, the Astro component emits that tag
|
|
3896
|
+
**with the accessible fallback inside it**.
|
|
3897
|
+
4. **Bricks elements** — the same patterns as WordPress/Bricks Builder elements.
|
|
3898
|
+
|
|
3899
|
+
## Choosing
|
|
3900
|
+
|
|
3901
|
+
**Use the highest-numbered tier available for your stack, and write only its call.** In Astro
|
|
3902
|
+
that is the Astro component; in Bricks the element; anywhere else the classes, plus the
|
|
3903
|
+
${code("<wc-*>")} tag when one exists.
|
|
3904
|
+
|
|
3905
|
+
The trap is composing two tiers by hand. When ${code("wraps")} below says the Astro component
|
|
3906
|
+
emits the tag, that one call is the whole composition —
|
|
3907
|
+
${code("<wc-tree><Tree /></wc-tree>")} nests two elements on one tree. A tier-3 component that
|
|
3908
|
+
would need runtime JS is in the wrong tier: build a web component instead and have the wrapper
|
|
3909
|
+
emit its tag.
|
|
3910
|
+
|
|
3911
|
+
## Every pattern
|
|
3912
|
+
|
|
3913
|
+
| Pattern | Tiers | What to write |
|
|
3914
|
+
| --- | --- | --- |
|
|
3915
|
+
${overview}
|
|
3916
|
+
|
|
3917
|
+
## Web components
|
|
3918
|
+
|
|
3919
|
+
Shipped as feature-detected, deferred ES-module bundles. ${code("elements.js")} carries the
|
|
3920
|
+
registered set; ${code("<wc-consent>")} and ${code("<wc-theme-editor>")} ship separately, the
|
|
3921
|
+
first so a site needing consent does not download a rendering framework, the second because it
|
|
3922
|
+
is tooling and opt-in per consumer.
|
|
3923
|
+
|
|
3924
|
+
| Tag | Pattern | Ships in | What JS adds over the fallback |
|
|
3925
|
+
| --- | --- | --- | --- |
|
|
3926
|
+
${wc}
|
|
3927
|
+
|
|
3928
|
+
${unshipped ? `${unshipped} are **registered but in no bundle** (the editor-v2 track). The tags are inert in a consumer project — they are listed so that "the tag does nothing" is documented rather than discovered.` : ""}
|
|
3929
|
+
|
|
3930
|
+
## Astro components
|
|
3931
|
+
|
|
3932
|
+
| Component | Pattern | Emits |
|
|
3933
|
+
| --- | --- | --- |
|
|
3934
|
+
${astro}
|
|
3935
|
+
|
|
3936
|
+
## Bricks elements
|
|
3937
|
+
|
|
3938
|
+
Controls, defaults and seeded children for each are in [the elements reference](../bricks/elements.md).
|
|
3939
|
+
|
|
3940
|
+
| Element | Pattern |
|
|
3941
|
+
| --- | --- |
|
|
3942
|
+
${bricks}
|
|
3943
|
+
|
|
3944
|
+
## CSS
|
|
3945
|
+
|
|
3946
|
+
${code("patterns.items")} patterns get the full token cascade — ${code("base")} declarations,
|
|
3947
|
+
${code("states")}, role variants and override hooks (${hooks}); see
|
|
3948
|
+
[component patterns](patterns.md). A structural partial has none of those. The class lists below
|
|
3949
|
+
are representative, not exhaustive — [the class vocabulary](../css/classes.md) states the naming
|
|
3950
|
+
rules that generate them all.
|
|
3951
|
+
|
|
3952
|
+
| Pattern | Partial | Classes | ${code("patterns.items")} |
|
|
3953
|
+
| --- | --- | --- | --- |
|
|
3954
|
+
${css}
|
|
3955
|
+
`;
|
|
3956
|
+
}
|
|
3131
3957
|
function renderTopIndex() {
|
|
3132
3958
|
return `${INDEX_NOTE}
|
|
3133
3959
|
|
|
@@ -3142,7 +3968,7 @@ variable-driven CSS framework plus progressively-enhanced web components, genera
|
|
|
3142
3968
|
* [Authoring reference](authoring.md) - every design-system.json field, generated from the JSON Schema
|
|
3143
3969
|
* [Config reference](config.md) - every field of the three-section config (designSystem / organization / site), generated from the JSON Schema
|
|
3144
3970
|
* [Output formats](formats.md) - tailwind vs css vs bricks vs design: what's emitted, what the platform provides, which utilities Tailwind owns
|
|
3145
|
-
* [Concepts](concepts/) - the colour system, type/space scales,
|
|
3971
|
+
* [Concepts](concepts/) - the colour system, type/space scales, pattern CSS architecture, the four component tiers, icons, and the consent / tracking / search / legal subsystems
|
|
3146
3972
|
* [CSS framework](css/) - the class vocabulary (colour, type, space, layout, animation, component patterns), stated as naming rules
|
|
3147
3973
|
* [Bricks Builder](bricks/) - custom elements and how to style them
|
|
3148
3974
|
`;
|
|
@@ -3204,7 +4030,7 @@ astro-icon is **zero-config on a static build**. Under \`output: 'server'\` it b
|
|
|
3204
4030
|
*every icon in a set* unless given an \`include\` map — which is why projects end up
|
|
3205
4031
|
hand-maintaining one.
|
|
3206
4032
|
|
|
3207
|
-
The \`icons\` option on \`
|
|
4033
|
+
The \`icons\` option on \`vitops()\` derives it by scanning your source, merged with
|
|
3208
4034
|
whatever you declare. On a static build **no include is passed at all**: there is
|
|
3209
4035
|
nothing to trim there, and a list could only drop a glyph the scan couldn't see.
|
|
3210
4036
|
|
|
@@ -3259,6 +4085,476 @@ Note \`.icon-mask\` is a different thing: the CSS-only adornment path used by
|
|
|
3259
4085
|
is no markup to hang an \`<svg>\` on.
|
|
3260
4086
|
`;
|
|
3261
4087
|
}
|
|
4088
|
+
function renderConsentConcept() {
|
|
4089
|
+
return `${frontmatter({
|
|
4090
|
+
type: "Design Concept",
|
|
4091
|
+
title: "Vitops consent — a demand-driven permission gate",
|
|
4092
|
+
description: "How the consent gate works: inert `type=\"text/plain\"` tags, demand-driven prompting, three-valued choices, and the invariants that make the gate real rather than a promise.",
|
|
4093
|
+
resource: CONFIG_PATH,
|
|
4094
|
+
tags: [
|
|
4095
|
+
"consent",
|
|
4096
|
+
"cookies",
|
|
4097
|
+
"privacy",
|
|
4098
|
+
"gdpr",
|
|
4099
|
+
"analytics"
|
|
4100
|
+
]
|
|
4101
|
+
})}
|
|
4102
|
+
|
|
4103
|
+
# The consent gate
|
|
4104
|
+
|
|
4105
|
+
\`@getvitops/core/consent\` is a **general permission gate, not an analytics feature**. Anything
|
|
4106
|
+
marked \`data-consent="<category>"\` waits on one visitor choice — a third-party tag, an A/B
|
|
4107
|
+
assignment, a personalisation cookie, an embedded map. It carries no Lit and is a separate
|
|
4108
|
+
bundle from \`elements.js\`, because consent is a legal requirement: a site that needs it must
|
|
4109
|
+
not be made to download a rendering framework first.
|
|
4110
|
+
|
|
4111
|
+
Categories are \`necessary\`, \`analytics\`, \`marketing\`, \`preferences\`.
|
|
4112
|
+
|
|
4113
|
+
## Gating something
|
|
4114
|
+
|
|
4115
|
+
A gated script renders **inert**, with its URL parked on \`data-src\`:
|
|
4116
|
+
|
|
4117
|
+
\`\`\`html
|
|
4118
|
+
<script
|
|
4119
|
+
type="text/plain"
|
|
4120
|
+
data-vitops-tag
|
|
4121
|
+
data-consent="analytics"
|
|
4122
|
+
data-consent-cookies="_ga,_ga_*"
|
|
4123
|
+
data-src="https://example.com/analytics.js"
|
|
4124
|
+
><\/script>
|
|
4125
|
+
\`\`\`
|
|
4126
|
+
|
|
4127
|
+
⚠️ **\`type="text/plain"\` is what makes the gate real. Never give a gated tag a live \`src\`.**
|
|
4128
|
+
The browser neither parses the body nor fetches the library, so an undecided visitor's page
|
|
4129
|
+
issues **no third-party request at all**. A gate that instead loads a script and politely asks
|
|
4130
|
+
it not to track is a promise, not a gate.
|
|
4131
|
+
|
|
4132
|
+
\`data-consent-cookies\` is written by whoever emitted the tag, because that is who knows what
|
|
4133
|
+
the provider sets. It is what a revoke clears.
|
|
4134
|
+
|
|
4135
|
+
## Demand-driven: two different ideas
|
|
4136
|
+
|
|
4137
|
+
**Offered** categories are a build-time fact — which rows \`<CookieConsent />\` renders. The
|
|
4138
|
+
default is deliberately generous; a hidden row costs nothing.
|
|
4139
|
+
|
|
4140
|
+
**Demanded** categories are a runtime fact — what something has actually asked for and the
|
|
4141
|
+
visitor hasn't answered. The banner appears only when a demand is outstanding, so **a site
|
|
4142
|
+
whose only provider is cookieless never interrupts anyone.**
|
|
4143
|
+
|
|
4144
|
+
Demand is registered by:
|
|
4145
|
+
|
|
4146
|
+
- a gated element **reaching its loading strategy** — so an \`idle\` tag asks after \`load\`, off
|
|
4147
|
+
the LCP path, and an \`interaction\` tag asks only once the visitor acts; or
|
|
4148
|
+
- an explicit \`require()\` / \`request()\`.
|
|
4149
|
+
|
|
4150
|
+
## The runtime API
|
|
4151
|
+
|
|
4152
|
+
\`window.vitopsConsent\`, plus a \`vitops:consent\` \`CustomEvent\` on \`document\`.
|
|
4153
|
+
|
|
4154
|
+
| Call | Does |
|
|
4155
|
+
| --- | --- |
|
|
4156
|
+
| \`granted(cat)\` | Is it granted? **Passive — does not prompt.** |
|
|
4157
|
+
| \`require(cat)\` | Declare a need *now* and report whether it's granted. **This is what raises the banner.** Synchronous. |
|
|
4158
|
+
| \`request(cat)\` | \`require()\`, but resolves once the visitor answers *this* category. |
|
|
4159
|
+
| \`needed()\` | Is a prompt warranted? True only if something demanded an unanswered category. |
|
|
4160
|
+
| \`demanded()\` | What has asked, this page view. |
|
|
4161
|
+
| \`set(patch)\` | Record a decision for exactly these categories. |
|
|
4162
|
+
| \`open()\` | Re-show the banner without discarding the current choice. |
|
|
4163
|
+
| \`reset()\` | Forget the choice and re-prompt. |
|
|
4164
|
+
|
|
4165
|
+
⚠️ If you want a side effect to be *possible*, you must \`require()\` it. Calling \`granted()\`
|
|
4166
|
+
and doing nothing else is a **permanent no-op** on any site where nothing else demands that
|
|
4167
|
+
category: it is never offered, never granted, and your write never happens — silently.
|
|
4168
|
+
|
|
4169
|
+
## An absent gate means "store freely"
|
|
4170
|
+
|
|
4171
|
+
\`consent.js\` and \`elements.js\` are both deferred with no ordering between them, so a component
|
|
4172
|
+
can upgrade and be clicked before the gate exists. \`<Head />\` therefore emits a synchronous
|
|
4173
|
+
inline **stub** that answers \`false\` and queues; the runtime replays the queue on load.
|
|
4174
|
+
|
|
4175
|
+
That makes the absence of \`window.vitopsConsent\` meaningful: it reliably means **this site has
|
|
4176
|
+
no gate**. Read it as *store freely* — never as *denied*.
|
|
4177
|
+
|
|
4178
|
+
Because the stub is not the full API, listen for the \`vitops:consent\` event rather than calling
|
|
4179
|
+
\`subscribe()\`, which the stub does not have.
|
|
4180
|
+
|
|
4181
|
+
## Three-valued, and why it matters
|
|
4182
|
+
|
|
4183
|
+
The cookie (\`vitops_consent\`, v2) records each category as \`true\` / \`false\` / **\`null\`**.
|
|
4184
|
+
"Not asked" is a third value, not a synonym for "declined" — and only \`null\` can be
|
|
4185
|
+
re-prompted. That is what lets a \`preferences\` demand arrive *after* an \`analytics\` prompt was
|
|
4186
|
+
already answered.
|
|
4187
|
+
|
|
4188
|
+
Three consequences:
|
|
4189
|
+
|
|
4190
|
+
- **Nothing is stored until the visitor chooses.** Absence of the cookie is *undecided*, and
|
|
4191
|
+
undecided denies everything but \`necessary\`. If merely showing the banner wrote state, the
|
|
4192
|
+
banner would be the thing it asks permission for.
|
|
4193
|
+
- **A corrupt or wrong-version cookie re-prompts.** It does not read as "decided, all denied" —
|
|
4194
|
+
that safe-looking read strands a visitor who wants to opt in with no way to say so.
|
|
4195
|
+
- **A patch must cover exactly the categories a showing put on screen.** \`<wc-consent>\` builds
|
|
4196
|
+
its own patch rather than calling \`acceptAll()\`, because "Accept" on a preferences-only
|
|
4197
|
+
prompt that also granted analytics would be consent nobody gave. \`acceptAll()\` /
|
|
4198
|
+
\`rejectAll()\` mean *literally every* optional category — widening a patch to "all" is the
|
|
4199
|
+
easy version of this bug.
|
|
4200
|
+
|
|
4201
|
+
## Revoking reloads
|
|
4202
|
+
|
|
4203
|
+
An already-executing tracker cannot be unloaded. Clearing its cookies only stops it identifying
|
|
4204
|
+
the visitor *next* time, while the running instance keeps reporting until the document goes
|
|
4205
|
+
away — so a revoke clears the named cookies and **reloads**. (\`reloadOnRevoke = false\` defers
|
|
4206
|
+
that to the next navigation.)
|
|
4207
|
+
|
|
4208
|
+
## The notice must match what loads
|
|
4209
|
+
|
|
4210
|
+
The same config facts drive the **generated cookie notice** — see
|
|
4211
|
+
[legal.md](legal.md). \`site.analytics.clarityId\` is what makes the notice name Clarity; the
|
|
4212
|
+
same provider in \`vitops({ analytics })\` is what makes the tag load. The Astro integration
|
|
4213
|
+
**warns when the two disagree**, because a site running a tag its own notice omits is a
|
|
4214
|
+
compliance defect, not a documentation gap.
|
|
4215
|
+
|
|
4216
|
+
## Reference consumer
|
|
4217
|
+
|
|
4218
|
+
\`<wc-color-scheme-toggle>\` applies the chosen scheme **immediately** and gates only the
|
|
4219
|
+
\`localStorage\` write. Nothing about honouring a visitor's click needs permission; remembering
|
|
4220
|
+
it does.
|
|
4221
|
+
`;
|
|
4222
|
+
}
|
|
4223
|
+
function renderTrackingConcept() {
|
|
4224
|
+
return `${frontmatter({
|
|
4225
|
+
type: "Design Concept",
|
|
4226
|
+
title: "Vitops conversion tracking — ad attribution and notifications",
|
|
4227
|
+
description: "How an ad click becomes a notified conversion: the `_ac` cookie, consent-demanding capture, the pure notification planner, and the Cloudflare email channel.",
|
|
4228
|
+
resource: CONFIG_PATH,
|
|
4229
|
+
tags: [
|
|
4230
|
+
"tracking",
|
|
4231
|
+
"attribution",
|
|
4232
|
+
"conversions",
|
|
4233
|
+
"notifications",
|
|
4234
|
+
"consent"
|
|
4235
|
+
]
|
|
4236
|
+
})}
|
|
4237
|
+
|
|
4238
|
+
# Conversion tracking
|
|
4239
|
+
|
|
4240
|
+
A visitor arrives on an ad carrying a click ID; \`<Tracking />\` captures it into the
|
|
4241
|
+
first-party **\`_ac\`** cookie; when they later submit a form or tap a \`tel:\` link,
|
|
4242
|
+
\`createConversionRoute()\` reads the cookie back and notifies whoever the config names.
|
|
4243
|
+
|
|
4244
|
+
\`\`\`json
|
|
4245
|
+
{
|
|
4246
|
+
"site": {
|
|
4247
|
+
"tracking": { "enabled": true, "category": "marketing" },
|
|
4248
|
+
"notifications": { "email": "leads@example.com" }
|
|
4249
|
+
}
|
|
4250
|
+
}
|
|
4251
|
+
\`\`\`
|
|
4252
|
+
|
|
4253
|
+
A bare address is shorthand for \`{ provider: "cloudflare", to }\`. The recipient otherwise
|
|
4254
|
+
falls back to the primary location's email, and the sender to \`noreply@<domains.canonical>\`.
|
|
4255
|
+
|
|
4256
|
+
## Where each piece lives
|
|
4257
|
+
|
|
4258
|
+
| Layer | Module | Why there |
|
|
4259
|
+
| --- | --- | --- |
|
|
4260
|
+
| Attribution vocabulary + cookie | \`@getvitops/utils/tracking\` | Needed on **both** sides of the wire |
|
|
4261
|
+
| Plan / render / send | \`@getvitops/utils/notify\` | Pure planner, I/O sender |
|
|
4262
|
+
| Capture script, \`<Tracking />\`, route factory | \`@getvitops/astro\` | Beside the analytics components |
|
|
4263
|
+
|
|
4264
|
+
Both utils entries are **separate subpaths** because they are the only modules that run in a
|
|
4265
|
+
**Worker** rather than at build time. Keeping them off the package index is what stops a
|
|
4266
|
+
conversion endpoint pulling \`sharp\` into its bundle. Neither may use a Node builtin.
|
|
4267
|
+
|
|
4268
|
+
## The capture demands consent
|
|
4269
|
+
|
|
4270
|
+
\`_ac\` is a 90-day identifier tying a visitor to an ad, so it waits on \`marketing\` (override
|
|
4271
|
+
with \`site.tracking.category\`).
|
|
4272
|
+
|
|
4273
|
+
⚠️ The script calls **\`require()\`**, not \`granted()\` — \`require()\` is what *raises the
|
|
4274
|
+
banner*. A passive \`granted()\` here is a permanent no-op: nothing else on a page demands
|
|
4275
|
+
\`marketing\`, so it is never offered, never granted, and \`_ac\` is never written — silently, on
|
|
4276
|
+
every gated site. The integration adds \`marketing\` to the offered categories when tracking is
|
|
4277
|
+
on, so there is a row for the category the script will ask about.
|
|
4278
|
+
|
|
4279
|
+
**Only an arrival that carried something asks.** The demand is guarded on the URL actually
|
|
4280
|
+
holding a click ID or UTM, so an organic visitor — who has nothing to attribute — is never
|
|
4281
|
+
interrupted. That is demand-driven consent applied to attribution.
|
|
4282
|
+
|
|
4283
|
+
**The capture is synchronous; only the write waits.** Reading the query string is not storage
|
|
4284
|
+
and needs no permission; *keeping* it does. The click ID is in the URL only on the landing
|
|
4285
|
+
page, so deferring the read would lose it outright.
|
|
4286
|
+
|
|
4287
|
+
The marker element carries \`data-consent\` but deliberately **not** \`data-vitops-tag\`: the
|
|
4288
|
+
scan never tries to "activate" it (it is ungated by design), while the revoke path — which
|
|
4289
|
+
queries \`[data-consent="…"]\` — still finds it and clears \`_ac\`.
|
|
4290
|
+
|
|
4291
|
+
## The event is the abstraction
|
|
4292
|
+
|
|
4293
|
+
\`ConversionEvent\` is the *fact*; how it reads belongs to the channel. That is what lets an SMS
|
|
4294
|
+
channel render 160 characters from the same event an email renders in full.
|
|
4295
|
+
|
|
4296
|
+
**The plan is pure and says why anything is skipped.** \`planNotifications\` touches no network
|
|
4297
|
+
and no binding, so a misconfigured site can be told exactly why no notification will arrive — a
|
|
4298
|
+
silently unsent conversion notification is indistinguishable from no conversion.
|
|
4299
|
+
|
|
4300
|
+
## The email channel
|
|
4301
|
+
|
|
4302
|
+
Cloudflare Email Sending's **current** binding — structured
|
|
4303
|
+
\`env.EMAIL.send({ to, from, subject, html, text })\`, not the legacy \`EmailMessage\` plus
|
|
4304
|
+
hand-built MIME. The binding is **passed in, never imported**, so utils takes no Cloudflare
|
|
4305
|
+
dependency.
|
|
4306
|
+
|
|
4307
|
+
Only transient codes are retried. \`E_SENDER_NOT_VERIFIED\` and friends are surfaced
|
|
4308
|
+
**verbatim**, because nothing here can check whether the sending domain was onboarded — run:
|
|
4309
|
+
|
|
4310
|
+
\`\`\`sh
|
|
4311
|
+
wrangler email sending enable <domain>
|
|
4312
|
+
\`\`\`
|
|
4313
|
+
|
|
4314
|
+
A generic "send failed" would hide the one thing worth knowing.
|
|
4315
|
+
|
|
4316
|
+
Only \`email\` is implemented. \`sms\` and \`persist\` are a planned seam
|
|
4317
|
+
(\`NotificationsConfig\` plus a sender with \`sendEmail\`'s signature); one channel is not enough
|
|
4318
|
+
to know what the abstraction should be.
|
|
4319
|
+
|
|
4320
|
+
## Disclosure
|
|
4321
|
+
|
|
4322
|
+
\`_ac\` is disclosed by the generated cookie notice as a first-party cookie. It has to be stated
|
|
4323
|
+
explicitly: no provider table would ever name a first-party cookie, so a site running
|
|
4324
|
+
attribution alongside a **cookieless** analytics provider would otherwise be described as
|
|
4325
|
+
setting no cookies at all. See [legal.md](legal.md).
|
|
4326
|
+
`;
|
|
4327
|
+
}
|
|
4328
|
+
function renderSearchConcept() {
|
|
4329
|
+
return `${frontmatter({
|
|
4330
|
+
type: "Design Concept",
|
|
4331
|
+
title: "Vitops search — Search Console onboarding and deploy notification",
|
|
4332
|
+
description: "What search engines actually accept, why the Indexing API is deliberately not wired, and how `vitops search setup` and `vitops search notify` work.",
|
|
4333
|
+
resource: CONFIG_PATH,
|
|
4334
|
+
tags: [
|
|
4335
|
+
"seo",
|
|
4336
|
+
"search-console",
|
|
4337
|
+
"indexnow",
|
|
4338
|
+
"sitemap",
|
|
4339
|
+
"indexing"
|
|
4340
|
+
]
|
|
4341
|
+
})}
|
|
4342
|
+
|
|
4343
|
+
# Search
|
|
4344
|
+
|
|
4345
|
+
Two commands, both anchored to a full config:
|
|
4346
|
+
|
|
4347
|
+
- **\`vitops search notify\`** — tell search engines a deploy happened (from \`site.seo.indexing\`).
|
|
4348
|
+
- **\`vitops search setup\`** — onboard domains into Search Console (from \`site.searchConsole\`).
|
|
4349
|
+
|
|
4350
|
+
## Start from what engines actually accept
|
|
4351
|
+
|
|
4352
|
+
The obvious assumption is wrong, and every design decision here follows from that:
|
|
4353
|
+
|
|
4354
|
+
- **Google exposes no "request indexing" API.** The button in the Search Console UI is not in
|
|
4355
|
+
the Search Console API or anywhere else, and the **URL Inspection API is read-only**.
|
|
4356
|
+
- **The sitemap ping endpoint was removed in June 2023.** \`google.com/ping?sitemap=\` is a no-op.
|
|
4357
|
+
- **The Indexing API is scoped to \`JobPosting\` / \`BroadcastEvent\`.** It accepts other URLs and
|
|
4358
|
+
discards them; general use violates its terms, with *your own* GCP project on the line.
|
|
4359
|
+
⚠️ **Deliberately not wired. Do not add it.**
|
|
4360
|
+
- **Google does not participate in IndexNow.** Bing, Yandex, Naver, Seznam and Yep do.
|
|
4361
|
+
|
|
4362
|
+
So \`search notify\` does every sanctioned thing and then **verifies**: resubmit the sitemap,
|
|
4363
|
+
ping IndexNow, and \`--check\` inspects \`priorityUrls\` and exits non-zero on one Google hasn't
|
|
4364
|
+
indexed. That last part is what actually replaces the manual Search Console visit.
|
|
4365
|
+
|
|
4366
|
+
⚠️ Never describe this as making Google re-index faster. It cannot.
|
|
4367
|
+
|
|
4368
|
+
## \`search notify\`
|
|
4369
|
+
|
|
4370
|
+
- **The pure/I-O split is the point.** The planner decides everything — which URLs, which
|
|
4371
|
+
channels, why each was skipped — and touches no network, no filesystem, no clock. That is what
|
|
4372
|
+
makes \`--dry\` a *complete* account of a run rather than an approximation.
|
|
4373
|
+
- **\`lastmod\` is not a nice-to-have, it is the mechanism.** The changed-URL diff compares each
|
|
4374
|
+
sitemap entry's \`<lastmod>\` against a stored snapshot. With no lastmod the diff can see pages
|
|
4375
|
+
appear and disappear but **never see one change** — so an edited page is never resubmitted, and
|
|
4376
|
+
the command looks healthy while doing less than it appears to. It counts lastmod-less entries
|
|
4377
|
+
and says so every run.
|
|
4378
|
+
|
|
4379
|
+
\`gitLastmod()\` in \`@getvitops/astro\` derives real dates from \`git log\`. It is an exported
|
|
4380
|
+
helper rather than a \`sitemap\` option because it shells out to git and returns **nothing**
|
|
4381
|
+
from a shallow CI clone (\`fetch-depth: 1\`). It leaves an unmatched URL alone rather than
|
|
4382
|
+
stamping the build time: Google weighs lastmod only while it stays consistent with what
|
|
4383
|
+
actually changed, so a site that stamps every page every deploy teaches it to distrust the
|
|
4384
|
+
field site-wide.
|
|
4385
|
+
- **The \`noindex\` gate reads the environment, so the URLs must too.** The run is refused
|
|
4386
|
+
entirely when the resolved environment's \`robots\` contains \`noindex\` — submitting a staging
|
|
4387
|
+
host to IndexNow publishes it to several engines and invites them to crawl it, which a later
|
|
4388
|
+
directive does not undo. Origins therefore derive from \`site.environments[env].url\` **before**
|
|
4389
|
+
\`domains.canonical\`: the canonical is the *production* origin, so deriving from it while
|
|
4390
|
+
notifying staging would submit production URLs the gate would not catch.
|
|
4391
|
+
- **Verify the IndexNow key file before submitting.** A submission whose key file is unreachable
|
|
4392
|
+
returns \`403\`, but one whose key file is *reachable and stale* is accepted with \`202\` and then
|
|
4393
|
+
silently discarded. Only a prior GET distinguishes "submitted" from "submitted and ignored".
|
|
4394
|
+
The key is **not a secret** — the engine fetching it back is the ownership proof — so it lives
|
|
4395
|
+
in the config, and the Astro integration writes \`public/<key>.txt\` from it.
|
|
4396
|
+
- **Write the snapshot last, and only on success.** Writing it eagerly records URLs as notified
|
|
4397
|
+
that never were; because the next run diffs against it, one transient \`503\` would drop those
|
|
4398
|
+
pages from every future run — silently and permanently. A corrupt or absent snapshot reads as
|
|
4399
|
+
"submit everything, and say so", never as "nothing changed".
|
|
4400
|
+
|
|
4401
|
+
## \`search setup\`
|
|
4402
|
+
|
|
4403
|
+
Automates the otherwise-manual DNS-paste / wait / verify / add-property dance.
|
|
4404
|
+
\`site.searchConsole\` is keyed by bare hostname (mirroring \`site.dns\`):
|
|
4405
|
+
|
|
4406
|
+
\`\`\`json
|
|
4407
|
+
{
|
|
4408
|
+
"site": {
|
|
4409
|
+
"searchConsole": {
|
|
4410
|
+
"example.com": {
|
|
4411
|
+
"delegatedOwners": ["dev@example.com"],
|
|
4412
|
+
"fullUserGroup": "seo@example.com"
|
|
4413
|
+
}
|
|
4414
|
+
}
|
|
4415
|
+
}
|
|
4416
|
+
}
|
|
4417
|
+
\`\`\`
|
|
4418
|
+
|
|
4419
|
+
Per domain it ensures the apex verification TXT in Cloudflare, verifies ownership via the Site
|
|
4420
|
+
Verification API (\`DNS_TXT\`), adds the \`sc-domain:\` property, then adds any
|
|
4421
|
+
\`delegatedOwners\`. The verification token is fetched live and written to DNS — never stored in
|
|
4422
|
+
the config.
|
|
4423
|
+
|
|
4424
|
+
- **Idempotency lives in the planner.** A step whose desired state already holds resolves to
|
|
4425
|
+
\`skip\`, so re-running an onboarded domain is a no-op **by construction**, not because the
|
|
4426
|
+
executors check twice. \`--check\` reports drift, exits non-zero and mutates nothing;
|
|
4427
|
+
\`--dry\` prints the plan and stops.
|
|
4428
|
+
- **DNS is only ever created, never edited or deleted.** The command never removes a record, so
|
|
4429
|
+
the Cloudflare executor simply has no update or delete verb.
|
|
4430
|
+
- **Verification retries with backoff, then reports PENDING — not failed.** \`DNS_TXT\`
|
|
4431
|
+
verification fails until the record propagates, which is slow, not broken. A domain still
|
|
4432
|
+
unverified after the last attempt is \`PENDING\`, and its property and owner steps are skipped
|
|
4433
|
+
for that run. Re-run later.
|
|
4434
|
+
- **Search Console has no user/permission API.** Adding a Google Group as a **Full User** is
|
|
4435
|
+
genuinely un-automatable, so \`fullUserGroup\` is surfaced as a **reminder** in the summary,
|
|
4436
|
+
never attempted. That is distinct from \`delegatedOwners\`, which *are* automated — those are
|
|
4437
|
+
Site Verification web-resource owners (an additive union, so an existing owner is never
|
|
4438
|
+
dropped), a different concept from Search Console property users. Don't conflate them.
|
|
4439
|
+
|
|
4440
|
+
## Credentials
|
|
4441
|
+
|
|
4442
|
+
Always from the environment; never in the config. There is **one** token exchange and **two
|
|
4443
|
+
grants**, tracking where each command runs:
|
|
4444
|
+
|
|
4445
|
+
| Grant | Env vars | Used by |
|
|
4446
|
+
| --- | --- | --- |
|
|
4447
|
+
| **Service account** (JWT bearer) | \`VITOPS_GSC_SERVICE_ACCOUNT\` (inline JSON) or \`GOOGLE_APPLICATION_CREDENTIALS\` (path) | \`search notify\` — never expires, right for CI |
|
|
4448
|
+
| **User OAuth** (refresh token) | \`VITOPS_GOOGLE_CLIENT_ID\` / \`_CLIENT_SECRET\` / \`_REFRESH_TOKEN\` | \`search setup\` — **required** |
|
|
4449
|
+
|
|
4450
|
+
Cloudflare uses \`CLOUDFLARE_API_TOKEN\` (a \`Zone:DNS:Edit\` token; the standard "Edit zone DNS"
|
|
4451
|
+
template also carries the \`Zone:Read\` the zone-by-name lookup needs).
|
|
4452
|
+
|
|
4453
|
+
\`search setup\` requires user OAuth because **verifying a site makes the caller an owner** of
|
|
4454
|
+
the property, and that should be a person, not a project robot. Note a refresh token can be
|
|
4455
|
+
revoked, and for an OAuth client still in *Testing* publishing status Google expires it after
|
|
4456
|
+
**7 days** — fine for a one-time human setup, bad for a deploy step.
|
|
4457
|
+
|
|
4458
|
+
**\`search notify\` accepts either**, preferring the service account when both are set. Search
|
|
4459
|
+
Console does not care which identity calls it, and someone who has run \`search setup\` already
|
|
4460
|
+
holds a Google credential.
|
|
4461
|
+
|
|
4462
|
+
⚠️ Do not add \`googleapis\` — an enormous dependency for a handful of REST endpoints in a CLI
|
|
4463
|
+
that installs into every consumer project. The JWT is minted with ~30 lines of \`node:crypto\`.
|
|
4464
|
+
`;
|
|
4465
|
+
}
|
|
4466
|
+
function renderLegalConcept() {
|
|
4467
|
+
return `${frontmatter({
|
|
4468
|
+
type: "Design Concept",
|
|
4469
|
+
title: "Vitops legal documents — generated policy, terms and cookie notice",
|
|
4470
|
+
description: "How the privacy policy, terms of service and cookie notice are derived from config facts, why the provider table exists, and the four delivery paths.",
|
|
4471
|
+
resource: CONFIG_PATH,
|
|
4472
|
+
tags: [
|
|
4473
|
+
"legal",
|
|
4474
|
+
"privacy",
|
|
4475
|
+
"cookies",
|
|
4476
|
+
"pipeda",
|
|
4477
|
+
"compliance"
|
|
4478
|
+
]
|
|
4479
|
+
})}
|
|
4480
|
+
|
|
4481
|
+
# Legal documents
|
|
4482
|
+
|
|
4483
|
+
A privacy policy, terms of service and cookie notice, rendered from a full config: the company
|
|
4484
|
+
from \`organization\`, what the site actually *does* from \`site\`.
|
|
4485
|
+
|
|
4486
|
+
It is a **sibling of the docs generator, not a \`generate()\` format** — structurally, not
|
|
4487
|
+
stylistically. \`generate()\` is keyed to a design system, so a "legal format" would be a format
|
|
4488
|
+
that ignores its own input.
|
|
4489
|
+
|
|
4490
|
+
## The governing rule
|
|
4491
|
+
|
|
4492
|
+
**The config records facts; the template owns prose.**
|
|
4493
|
+
|
|
4494
|
+
Nothing in the derivation writes a sentence a lawyer would review, and no template invents a
|
|
4495
|
+
fact. That is what lets wording be corrected without touching your config, and your provider
|
|
4496
|
+
change land without touching prose.
|
|
4497
|
+
|
|
4498
|
+
⚠️ It also means **the fix for a wrong policy is a corrected config.** Hand-editing the output
|
|
4499
|
+
is overwritten by the next build.
|
|
4500
|
+
|
|
4501
|
+
## What derives from what
|
|
4502
|
+
|
|
4503
|
+
- **The provider table is what makes derivation possible.** A policy naming Plausible while the
|
|
4504
|
+
site runs GA is a compliance defect, not a typo — so the provider comes from *which analytics
|
|
4505
|
+
ID is set*, whether \`site.security.turnstile.siteKey\` exists, and what
|
|
4506
|
+
\`site.deployment.platform\` says. Never from a hand-maintained string.
|
|
4507
|
+
|
|
4508
|
+
It covers only what the schema can imply. Everything else — payment, CRM, mail — is declared
|
|
4509
|
+
in \`site.legal.privacyPolicy.processors\` and flows through the same pipeline.
|
|
4510
|
+
|
|
4511
|
+
- **\`cookies: []\` is meaningfully different from \`undefined\`.** It *asserts* a provider is
|
|
4512
|
+
cookieless (Plausible), which the cookie notice states **positively** rather than omitting.
|
|
4513
|
+
|
|
4514
|
+
- **Form templates are the PII inventory.** \`site.templates\` entries of type \`form\` are the
|
|
4515
|
+
only place the config says what personal information the site actually collects, so the
|
|
4516
|
+
disclosed list derives from their fields. \`hidden\` fields and honeypots are **excluded** —
|
|
4517
|
+
neither is visitor-supplied, and describing them as collected would be untrue.
|
|
4518
|
+
|
|
4519
|
+
- **First-party cookies are declared, not detected.** The attribution cookie \`_ac\` is disclosed
|
|
4520
|
+
this way; see [tracking.md](tracking.md).
|
|
4521
|
+
|
|
4522
|
+
## The markdown subset is closed
|
|
4523
|
+
|
|
4524
|
+
We author every template, so the renderer is exactly as capable as they are: \`#\`/\`##\`/\`###\`,
|
|
4525
|
+
\`- \` bullets, \`> \` quote, \`**strong**\`, \`\\\`code\\\`\`. **An unsupported construct is an error,
|
|
4526
|
+
not a silent degrade** — that is what stops a literal \`| --- |\` reaching a published page.
|
|
4527
|
+
|
|
4528
|
+
Portable Text maps the \`> \` quote to a banner block and **drops the \`# \` heading**, which is
|
|
4529
|
+
EmDash's own title field.
|
|
4530
|
+
|
|
4531
|
+
## Jurisdictions
|
|
4532
|
+
|
|
4533
|
+
Adding one is: author three templates, add one enum member, add one registry key. The two are
|
|
4534
|
+
checked against each other at compile time, so skipping either **fails to compile** rather than
|
|
4535
|
+
rendering against the wrong body of law.
|
|
4536
|
+
|
|
4537
|
+
⚠️ Only **\`ca\` (PIPEDA)** ships. Its prose names the Office of the Privacy Commissioner of
|
|
4538
|
+
Canada and frames transfers as "outside of Canada" — **do not reuse it for another
|
|
4539
|
+
jurisdiction.**
|
|
4540
|
+
|
|
4541
|
+
## Delivery: one renderer, four consumers
|
|
4542
|
+
|
|
4543
|
+
| Consumer | How |
|
|
4544
|
+
| --- | --- |
|
|
4545
|
+
| **any stack** | \`vitops legal [--doc <name>] [--format md\\|html\\|portable-text] [--out <dir>]\` — stdout without \`--out\`. Hugo, Eleventy or a hand-built WordPress theme need no integration code. |
|
|
4546
|
+
| **WordPress** | \`generate({ site })\` also emits \`dist/legal/*.html\`; \`[vitops_legal doc="privacy"]\` renders one. \`doc\` is matched against a fixed allowlist, because it lands in a filesystem read. |
|
|
4547
|
+
| **Astro** | \`vitops({ legal: { input, out } })\` — a sibling of \`css\`, not a widening of it. Regenerates on config change; writes markdown to a content collection. No route injection. |
|
|
4548
|
+
| **EmDash** | \`--format portable-text\`, pasted into the admin. |
|
|
4549
|
+
|
|
4550
|
+
The CLI is the load-bearing one: it is the surface every consumer has regardless of stack.
|
|
4551
|
+
|
|
4552
|
+
## The review banner
|
|
4553
|
+
|
|
4554
|
+
Every document opens with a **non-optional** review banner. These are rendered from a template
|
|
4555
|
+
by a build tool; the one failure mode with real consequences is a consumer publishing one as-is.
|
|
4556
|
+
`;
|
|
4557
|
+
}
|
|
3262
4558
|
function renderConceptsIndex() {
|
|
3263
4559
|
return `${INDEX_NOTE}
|
|
3264
4560
|
|
|
@@ -3269,7 +4565,12 @@ function renderConceptsIndex() {
|
|
|
3269
4565
|
* [Colour system](color.md) - seeded OKLCH scales on a shared lightness ladder, target-prefixed tokens, automatic dark mode
|
|
3270
4566
|
* [Type & space scales](scales.md) - fluid modular scales and the tokens they emit
|
|
3271
4567
|
* [Component patterns](patterns.md) - token cascade, override hooks, states, role variants
|
|
4568
|
+
* [Components](components.md) - which of the four tiers provides each pattern, and the call to make
|
|
3272
4569
|
* [Icons](icons.md) - semantic names across icon sets, bundle derivation, sprite delivery
|
|
4570
|
+
* [Consent gate](consent.md) - inert gated tags, demand-driven prompting, three-valued choices
|
|
4571
|
+
* [Conversion tracking](tracking.md) - ad-click attribution, the \`_ac\` cookie, conversion notifications
|
|
4572
|
+
* [Search](search.md) - what engines accept, Search Console onboarding, deploy notification
|
|
4573
|
+
* [Legal documents](legal.md) - policy/terms/cookie notice derived from config facts
|
|
3273
4574
|
`;
|
|
3274
4575
|
}
|
|
3275
4576
|
function renderCssIndex() {
|
|
@@ -3318,7 +4619,12 @@ function generateDocs(ds, assetsDir) {
|
|
|
3318
4619
|
"concepts/color.md": renderColorConcept(ds),
|
|
3319
4620
|
"concepts/scales.md": renderScalesConcept(ds),
|
|
3320
4621
|
"concepts/patterns.md": renderPatternsConcept(ds),
|
|
4622
|
+
"concepts/components.md": renderComponentsConcept(ds),
|
|
3321
4623
|
"concepts/icons.md": renderIconsConcept(),
|
|
4624
|
+
"concepts/consent.md": renderConsentConcept(),
|
|
4625
|
+
"concepts/tracking.md": renderTrackingConcept(),
|
|
4626
|
+
"concepts/search.md": renderSearchConcept(),
|
|
4627
|
+
"concepts/legal.md": renderLegalConcept(),
|
|
3322
4628
|
"css/index.md": renderCssIndex(),
|
|
3323
4629
|
"css/classes.md": renderCssClasses(ds),
|
|
3324
4630
|
"bricks/index.md": renderBricksIndex(),
|
|
@@ -4041,21 +5347,43 @@ function derivePolicyVars(cfg) {
|
|
|
4041
5347
|
"improve our Site to better meet your needs"
|
|
4042
5348
|
]);
|
|
4043
5349
|
const ab = site.abTesting;
|
|
5350
|
+
/**
|
|
5351
|
+
* A site offering the `preferences` category stores at least one setting the
|
|
5352
|
+
* visitor chose — `<wc-color-scheme-toggle>` writes `vitops-color-scheme` to local
|
|
5353
|
+
* storage, gated on exactly this category. The banner asking about it while the
|
|
5354
|
+
* notice never mentions it is the disclosure gap `providers.ts` exists to
|
|
5355
|
+
* prevent, just on the browser-storage side rather than the third-party one.
|
|
5356
|
+
*/
|
|
5357
|
+
const hasPreferences = consent?.enabled === true && !!consent.categories?.includes("preferences");
|
|
5358
|
+
/**
|
|
5359
|
+
* `site.tracking.enabled` writes the `_ac` cookie: the click ID from the ad a
|
|
5360
|
+
* visitor followed, kept for 90 days so a later enquiry can be attributed to
|
|
5361
|
+
* the campaign that produced it. It is first-party and never leaves the site,
|
|
5362
|
+
* which is exactly why nothing in the provider table would ever mention it —
|
|
5363
|
+
* and why it has to be stated here instead. A notice that lists every
|
|
5364
|
+
* third-party cookie while omitting the one the site sets itself is the same
|
|
5365
|
+
* disclosure defect, pointed inward.
|
|
5366
|
+
*/
|
|
5367
|
+
const hasAdTracking = site.tracking?.enabled === true;
|
|
4044
5368
|
const cookieTracked = uniq([
|
|
4045
5369
|
"your IP address",
|
|
4046
5370
|
"the type of web browser and operating system used",
|
|
4047
5371
|
"the pages of the Site visited",
|
|
5372
|
+
hasPreferences ? "display settings you have chosen, such as light or dark appearance" : void 0,
|
|
5373
|
+
hasAdTracking ? "which advertisement or campaign brought you to our Site" : void 0,
|
|
4048
5374
|
ab?.enabled ? "which version of a page you were shown" : void 0,
|
|
4049
5375
|
hasSessionReplay ? "how you moved through and interacted with a page" : void 0
|
|
4050
5376
|
]);
|
|
4051
5377
|
const cookiePurposes = uniq([
|
|
4052
5378
|
"keep the Site functioning as you move between pages",
|
|
4053
5379
|
hasAnalytics ? "understand how our Site is used, in aggregate" : void 0,
|
|
5380
|
+
hasPreferences ? "remember display settings you choose, between visits" : void 0,
|
|
5381
|
+
hasAdTracking ? "attribute an enquiry to the advertisement that brought you here, for up to 90 days" : void 0,
|
|
4054
5382
|
hasSessionReplay ? "recognise a returning visit as part of the same session" : void 0,
|
|
4055
5383
|
ab?.enabled ? "keep you on a consistent version of the Site between visits" : void 0,
|
|
4056
5384
|
site.security?.turnstile?.siteKey ? "distinguish visitors from automated traffic" : void 0
|
|
4057
5385
|
]);
|
|
4058
|
-
const cookieTool = consent?.enabled ? consent.type === "opt-in" ? "You may also choose which categories of cookies to allow using the consent banner
|
|
5386
|
+
const cookieTool = consent?.enabled ? consent.type === "opt-in" ? "You may also choose which categories of cookies to allow using the consent banner, which appears when something on the Site first needs your permission, and you may change that choice at any time." : "You may also withdraw your consent to non-essential cookies at any time using the consent controls on our Site." : void 0;
|
|
4059
5387
|
const usePurposes = uniq([
|
|
4060
5388
|
"respond to your inquiries",
|
|
4061
5389
|
"supply you with requested products or services",
|
|
@@ -4093,6 +5421,11 @@ function derivePolicyVars(cfg) {
|
|
|
4093
5421
|
optOutOptions,
|
|
4094
5422
|
cookieOptOutOptions,
|
|
4095
5423
|
processors,
|
|
5424
|
+
firstPartyCookies: hasAdTracking ? [{
|
|
5425
|
+
name: "_ac",
|
|
5426
|
+
purpose: "records the advertisement or campaign you arrived from, so an enquiry can be attributed to it",
|
|
5427
|
+
retention: "90 days"
|
|
5428
|
+
}] : [],
|
|
4096
5429
|
countries: countries || void 0,
|
|
4097
5430
|
consentModel: consent?.enabled ? consent.type ?? "opt-in" : "none",
|
|
4098
5431
|
cookieCategories: consent?.categories ?? []
|
|
@@ -4383,18 +5716,28 @@ If you have questions about our use of cookies, please contact us at ${v.mailing
|
|
|
4383
5716
|
* "This site sets no cookies" is a real and increasingly common answer — a
|
|
4384
5717
|
* cookieless analytics provider is a selling point, not an omission. Saying it
|
|
4385
5718
|
* plainly beats an empty section that reads as an oversight.
|
|
5719
|
+
*
|
|
5720
|
+
* But it is only true if the site sets none of its **own**, which is why
|
|
5721
|
+
* `firstPartyCookies` is checked alongside the processors: a site running
|
|
5722
|
+
* attribution with a cookieless analytics provider sets `_ac` and no third-party
|
|
5723
|
+
* cookie at all, and the old wording would have declared it cookie-free.
|
|
4386
5724
|
*/
|
|
4387
5725
|
function providersSection$1(v) {
|
|
4388
5726
|
const setting = v.processors.filter((p) => p.cookies?.length);
|
|
4389
5727
|
const cookieless = v.processors.filter((p) => p.cookies?.length === 0);
|
|
4390
|
-
|
|
4391
|
-
|
|
4392
|
-
|
|
4393
|
-
|
|
5728
|
+
const own = v.firstPartyCookies;
|
|
5729
|
+
const ownSection = own.length ? `\n### Cookies We Set Ourselves\n\n${bullets(own.map((c) => `\`${c.name}\` — ${c.purpose}. Kept for ${c.retention}.`))}\n` : "";
|
|
5730
|
+
if (setting.length === 0) {
|
|
5731
|
+
const named = cookieless.length ? ` The third party ${cookieless.length === 1 ? "service" : "services"} we use on this Site — ${list(cookieless.map((p) => p.name))} — ${cookieless.length === 1 ? "does" : "do"} not set cookies of ${cookieless.length === 1 ? "its" : "their"} own.` : "";
|
|
5732
|
+
return `## Cookies Set on This Site
|
|
5733
|
+
|
|
5734
|
+
${own.length ? `Beyond the cookies strictly necessary to serve and secure the Site, no third party sets cookies through this Site.${named}` : `Beyond the cookies strictly necessary to serve and secure the Site, we do not set cookies.${named}`}
|
|
5735
|
+
${ownSection}`;
|
|
5736
|
+
}
|
|
4394
5737
|
return `## Cookies Set on This Site
|
|
4395
5738
|
|
|
4396
5739
|
${bullets(setting.map((p) => `**${p.name}** (${p.purpose}) — ${(p.cookies ?? []).map((c) => `\`${c}\``).join(", ")}${p.privacyUrl ? `. Privacy policy: ${p.privacyUrl}` : ""}`))}
|
|
4397
|
-
${cookieless.length ? `\nWe also use ${list(cookieless.map((p) => p.name))}, which ${cookieless.length === 1 ? "does" : "do"} not set cookies of ${cookieless.length === 1 ? "its" : "their"} own.\n` : ""}`;
|
|
5740
|
+
${cookieless.length ? `\nWe also use ${list(cookieless.map((p) => p.name))}, which ${cookieless.length === 1 ? "does" : "do"} not set cookies of ${cookieless.length === 1 ? "its" : "their"} own.\n` : ""}${ownSection}`;
|
|
4398
5741
|
}
|
|
4399
5742
|
/** Only meaningful when a consent tool actually offers the categories. */
|
|
4400
5743
|
function categoriesSection(v) {
|
|
@@ -6385,5 +7728,5 @@ function defaultConfig() {
|
|
|
6385
7728
|
}
|
|
6386
7729
|
|
|
6387
7730
|
//#endregion
|
|
6388
|
-
export { BASE_HOOK, CONFIG_SCHEMA_URL, CONTRAST, ConfigSchema, DARK_SEL, DOC_ORDER, DOC_SLUGS, DesignSystemSchema, JURISDICTIONS, KNOWN_PROCESSORS, NUMERIC_STEPS, REQUIRED_ROLES, ROLE_TOKEN_KEYS, SCHEMA_URL, SiteFontSchema, TEMPLATES, TW_CLASH, buildIconSprite, configJsonSchema, deepMerge, defaultConfig, derivePolicyVars, detectProcessorKeys, enabledDocs, expandPalette, functionalRole, generate, generateDocs, generateLegal, isConfig, jsonSchema, loadIconSvg, parseMarkdown, renderMarkdown, renderNodes, resolveConfig, resolveInput, resolvePrivacyContact, resolveProcessors, resolveTheme, roleColorUtilities, roleHue, roleKind, spriteId, stripNulls, toContentNodes, toHtmlFragment, toPortableText, tokenClass, tokenVar, validate, validateConfig };
|
|
7731
|
+
export { BASE_HOOK, CONFIG_SCHEMA_URL, CONTRAST, ConfigSchema, DARK_SEL, DOC_ORDER, DOC_SLUGS, DesignSystemSchema, JURISDICTIONS, KNOWN_PROCESSORS, NUMERIC_STEPS, REQUIRED_ROLES, ROLE_TOKEN_KEYS, SCHEMA_URL, SiteFontSchema, TEMPLATES, TIERS, TIER_NAMES, TW_CLASH, buildIconSprite, configJsonSchema, deepMerge, defaultConfig, derivePolicyVars, detectProcessorKeys, enabledDocs, expandPalette, functionalRole, generate, generateDocs, generateLegal, isConfig, jsonSchema, loadIconSvg, parseMarkdown, renderInlineMarkdown, renderMarkdown, renderNodes, resolveConfig, resolveInput, resolvePrivacyContact, resolveProcessors, resolveTheme, roleColorUtilities, roleHue, roleKind, schemaTreeNodes, spriteId, stripNulls, tierPatterns, tierTags, toContentNodes, toHtmlFragment, toPortableText, tokenClass, tokenVar, validate, validateConfig };
|
|
6389
7732
|
//# sourceMappingURL=index.mjs.map
|