@getvitops/generator 3.0.0 → 4.1.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 +385 -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 +178 -11
- package/dist/index.d.mts +491 -13
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1680 -46
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
package/dist/index.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
6
|
import { transform } from "lightningcss";
|
|
7
7
|
import * as z from "zod/mini";
|
|
8
|
+
import { AD_PLATFORMS, AD_PROVIDER_KEYS } from "@getvitops/utils/ads";
|
|
8
9
|
import { LIGHTNESS_LADDER, contrastLc, generateOklchPalette, hexToOklch, oklchStringToHex, pickOn, tintedEndpoints } from "@getvitops/utils/color";
|
|
9
10
|
import { generateIconInclude, nodesToHtml, resolveIcon, t } from "@getvitops/utils";
|
|
10
11
|
import { getIconData, iconToSVG, replaceIDs } from "@iconify/utils";
|
|
@@ -20,7 +21,7 @@ import { getIconData, iconToSVG, replaceIDs } from "@iconify/utils";
|
|
|
20
21
|
*
|
|
21
22
|
* `data-brx-theme` is Bricks' own attribute — Bricks sets it, so it's what the
|
|
22
23
|
* WordPress target needs. Nothing sets it anywhere else, which meant the dark
|
|
23
|
-
* flip was unreachable outside Bricks: the shipped `<color-scheme-toggle>` web
|
|
24
|
+
* flip was unreachable outside Bricks: the shipped `<wc-color-scheme-toggle>` web
|
|
24
25
|
* component writes `documentElement.dataset.theme` (i.e. `data-theme`), so
|
|
25
26
|
* clicking "Dark" changed an attribute no rule matched. Matching both makes the
|
|
26
27
|
* component work on every target without changing what Bricks already does.
|
|
@@ -38,7 +39,7 @@ const DARK_SEL = ":root[data-brx-theme=\"dark\"], :root[data-theme=\"dark\"]";
|
|
|
38
39
|
*
|
|
39
40
|
* Derived from `DARK_SEL`'s attributes rather than written out a third time, and
|
|
40
41
|
* 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
|
|
42
|
+
* exactly what `<wc-color-scheme-toggle>`'s "System" position produces — it removes
|
|
42
43
|
* `data-theme` entirely — so with this block present System resolves to the OS
|
|
43
44
|
* instead of silently falling through to light, and needs no JS to do it.
|
|
44
45
|
*
|
|
@@ -519,7 +520,7 @@ const DesignSystemBlock = desc(z.strictObject({
|
|
|
519
520
|
"light",
|
|
520
521
|
"dark",
|
|
521
522
|
"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.")
|
|
523
|
+
])), "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
524
|
}), "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
525
|
const LocaleSchema = z.object({
|
|
525
526
|
name: desc(z.string(), "Display name of the locale (e.g. \"English\")."),
|
|
@@ -629,11 +630,23 @@ const DnsDomainSchema = z.object({
|
|
|
629
630
|
nameservers: z.optional(z.array(z.string())),
|
|
630
631
|
records: z.optional(z.array(DnsRecordSchema))
|
|
631
632
|
});
|
|
633
|
+
/**
|
|
634
|
+
* One domain to onboard into Google Search Console via `vitops search setup`.
|
|
635
|
+
*
|
|
636
|
+
* The verification token is fetched live from the Site Verification API at run
|
|
637
|
+
* time and written to DNS — it is never stored here. What the config records is
|
|
638
|
+
* the *intent*: which domains to onboard, and who should co-own them.
|
|
639
|
+
*/
|
|
640
|
+
const SearchConsoleSetupSchema = z.object({
|
|
641
|
+
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."),
|
|
642
|
+
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.")
|
|
643
|
+
});
|
|
632
644
|
const RobotsSchema = z.string();
|
|
633
645
|
const EnvironmentSchema = z.object({
|
|
634
646
|
url: desc(z.url(), "Public origin of this environment."),
|
|
635
647
|
api: desc(z.optional(z.url()), "API origin, when different from `url`."),
|
|
636
648
|
analytics: desc(z.optional(z.boolean()), "Whether analytics fire in this environment."),
|
|
649
|
+
ads: desc(z.optional(z.boolean()), "Whether `site.ads` pixels fire in this environment. Defaults to `analytics`, then true — a preview deployment sending pageviews is survivable, one firing conversion pixels is not, so this can be turned off on its own."),
|
|
637
650
|
robots: desc(z.optional(RobotsSchema), "Robots policy (e.g. \"noindex,nofollow\" for dev)."),
|
|
638
651
|
variant: desc(z.optional(z.string()), "Active `abTesting.variants` key for this environment.")
|
|
639
652
|
});
|
|
@@ -818,7 +831,7 @@ const TwitterSchema = z.object({
|
|
|
818
831
|
image: z.optional(ImageRefSchema)
|
|
819
832
|
});
|
|
820
833
|
/**
|
|
821
|
-
* What `vitops notify` needs to tell search engines a deploy happened.
|
|
834
|
+
* What `vitops search notify` needs to tell search engines a deploy happened.
|
|
822
835
|
*
|
|
823
836
|
* Scoped by what search engines actually accept, which is narrower than it looks:
|
|
824
837
|
* Google exposes **no** "request indexing" API (the Search Console button is not in
|
|
@@ -832,7 +845,7 @@ const TwitterSchema = z.object({
|
|
|
832
845
|
* (a key, a property, which pages matter), never what to say about it.
|
|
833
846
|
*/
|
|
834
847
|
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`."),
|
|
848
|
+
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
849
|
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
850
|
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
851
|
});
|
|
@@ -843,8 +856,49 @@ const SearchConsoleSchema = z.object({
|
|
|
843
856
|
const IndexingSchema = z.object({
|
|
844
857
|
sitemapUrl: desc(z.optional(z.url()), "The sitemap to submit and to diff for changed URLs. Defaults to `<canonical>/sitemap-index.xml`."),
|
|
845
858
|
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.")
|
|
859
|
+
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."),
|
|
860
|
+
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.")
|
|
861
|
+
});
|
|
862
|
+
/**
|
|
863
|
+
* The ad platforms `vitops ads` can link a site to.
|
|
864
|
+
*
|
|
865
|
+
* A closed enum, mirroring `AD_PROVIDER_KEYS` in `@getvitops/utils/ads` — that
|
|
866
|
+
* module owns what each platform *can do* (whether it verifies a domain, what its
|
|
867
|
+
* tag is, which click ID it stamps), this owns what a config may *say*. Mirrored
|
|
868
|
+
* rather than derived because a `z.enum` needs literals to reach the published JSON
|
|
869
|
+
* Schema; `config.test.ts` asserts the two lists are the same set, so a provider
|
|
870
|
+
* added to one and not the other fails the build rather than silently getting no
|
|
871
|
+
* verification step and no tag.
|
|
872
|
+
*/
|
|
873
|
+
const AD_PROVIDERS = [
|
|
874
|
+
"google",
|
|
875
|
+
"meta",
|
|
876
|
+
"linkedin",
|
|
877
|
+
"reddit",
|
|
878
|
+
"tiktok",
|
|
879
|
+
"microsoft",
|
|
880
|
+
"pinterest",
|
|
881
|
+
"snapchat"
|
|
882
|
+
];
|
|
883
|
+
const AdProviderSchema = z.enum(AD_PROVIDERS);
|
|
884
|
+
/**
|
|
885
|
+
* One ad property this site is linked to.
|
|
886
|
+
*
|
|
887
|
+
* Facts, not prose — the same rule the legal block follows. This records *which*
|
|
888
|
+
* account and *which* tag id, and the platform capability table decides what can be
|
|
889
|
+
* done about them. It is also what finally lets the rest of the toolchain see an ad
|
|
890
|
+
* pixel: the generated cookie notice discloses its cookies, `vitops ads lint`
|
|
891
|
+
* checks its click ID is captured, and the consent gate is handed the cookie names
|
|
892
|
+
* to clear on revoke. A pixel pasted into a template is invisible to all three.
|
|
893
|
+
*/
|
|
894
|
+
const AdPropertySchema = z.object({
|
|
895
|
+
accountId: desc(z.optional(z.string()), "The advertising account as the platform shows it — Google Ads customer ID, Meta ad account, LinkedIn partner ID, Reddit advertiser ID. For LinkedIn this is also the Insight Tag id; everywhere else the tag id is `pixelId`."),
|
|
896
|
+
pixelId: desc(z.optional(z.string()), "The tag/pixel ID the browser snippet initialises — Meta pixel, TikTok pixel, Reddit pixel, Microsoft UET tag ID, Pinterest tag ID, Snap pixel, or the Google Ads conversion ID (`AW-…`, which is NOT the customer ID)."),
|
|
897
|
+
conversionLabel: desc(z.optional(z.string()), "Google Ads only: the conversion action label paired with the `AW-…` conversion ID."),
|
|
898
|
+
domain: desc(z.optional(z.string()), "Bare hostname to verify with this platform. Defaults to the host of `domains.canonical` — set it only when the ad account is verified against a different domain."),
|
|
899
|
+
domainVerification: desc(z.optional(z.string()), "The verification token from the platform UI, for the platforms that verify by DNS TXT (Meta, TikTok, Pinterest, Snapchat). `vitops ads setup` prompts for it and writes it here on first run. NOT a secret — it is published in DNS, and the platform fetching it back is the ownership proof, exactly like the IndexNow key. A value containing `=` is used as the whole record, which is the escape hatch if a platform changes its prefix."),
|
|
900
|
+
category: desc(z.optional(z.enum(["marketing", "analytics"])), "Consent category the tag waits on (default `marketing`). An ad pixel is advertising by default; `analytics` is for a site using one purely for its own measurement."),
|
|
901
|
+
enabled: desc(z.optional(z.boolean()), "Set false to keep the property on record without emitting its tag.")
|
|
848
902
|
});
|
|
849
903
|
/**
|
|
850
904
|
* Which template set the generated legal documents are written against. This is
|
|
@@ -856,6 +910,20 @@ const IndexingSchema = z.object({
|
|
|
856
910
|
const JURISDICTIONS = ["ca"];
|
|
857
911
|
const JurisdictionSchema = z.enum(JURISDICTIONS);
|
|
858
912
|
/**
|
|
913
|
+
* The country each template set frames cross-border transfers as *leaving*.
|
|
914
|
+
*
|
|
915
|
+
* A jurisdiction's own country is not a transfer, so this is what filters the
|
|
916
|
+
* disclosure lists — without it, `country: "Canada"` renders the incoherent
|
|
917
|
+
* "outside of Canada, including Canada".
|
|
918
|
+
*
|
|
919
|
+
* It belongs to the **jurisdiction**, not to `organization.address`: a `ca` policy
|
|
920
|
+
* says "outside of Canada" even for a business incorporated elsewhere. (The
|
|
921
|
+
* address drives a different sentence — the terms' governing-law clause, via
|
|
922
|
+
* `governingCountry`.) `satisfies` makes a new jurisdiction unable to forget it,
|
|
923
|
+
* the same guard style as `TEMPLATES`.
|
|
924
|
+
*/
|
|
925
|
+
const JURISDICTION_COUNTRIES = { ca: "Canada" };
|
|
926
|
+
/**
|
|
859
927
|
* A third party that receives personal information. The generator derives the
|
|
860
928
|
* ones it can see (analytics IDs, Turnstile, the deploy platform); this is for
|
|
861
929
|
* the rest, which no other part of the config implies.
|
|
@@ -863,10 +931,16 @@ const JurisdictionSchema = z.enum(JURISDICTIONS);
|
|
|
863
931
|
* Deliberately facts, not prose: the config records *what is true*, the template
|
|
864
932
|
* owns *how it is said*. That keeps a policy correct when the wording changes.
|
|
865
933
|
*/
|
|
934
|
+
const ProcessorStorageSchema = z.object({
|
|
935
|
+
country: desc(z.string(), "A country or bloc where this provider stores or processes the information, as it should read in a sentence (e.g. \"Canada\", \"the United States\", \"the European Union\"). Naming the jurisdiction the policy is written for is meaningful: it says this information does not leave it."),
|
|
936
|
+
scope: desc(z.optional(z.string()), "Which information is held there, as a noun phrase that reads after \"in the case of\" (e.g. \"account and sign-in information\", \"mailbox contents\"). Omit when this location holds everything the provider receives — a scoped entry claims LESS than an unscoped one, so omitting it is the safe default.")
|
|
937
|
+
});
|
|
866
938
|
const ProcessorSchema = z.object({
|
|
867
939
|
name: desc(z.string(), "The provider, as a reader would recognise it (e.g. \"Stripe\")."),
|
|
868
940
|
purpose: desc(z.string(), "Why they receive it, as a noun phrase that reads after \"for\" (e.g. \"payment processing\")."),
|
|
869
|
-
country: desc(z.optional(z.string()), "
|
|
941
|
+
country: desc(z.optional(z.string()), "Shorthand for the common case where one country is both where they store it and whose laws reach it: asserts BOTH `storage: [{ country }]` AND `operatorCountry`. Reads inside a sentence (e.g. \"the United States\"). When the two differ — a Canadian region operated by a US company — state `storage` and `operatorCountry` instead; setting this alongside either is rejected."),
|
|
942
|
+
storage: desc(z.optional(z.array(ProcessorStorageSchema)), "Where the information actually rests. Feeds the \"stored or processed outside of <jurisdiction>\" disclosure and nothing else. Several entries are allowed, each optionally scoped to a category of information — which is what makes a Canadian-region tenant holding identity data in the US expressible."),
|
|
943
|
+
operatorCountry: desc(z.optional(z.string()), "The jurisdiction that can compel this provider to hand the information over — where it is established, or from which it is controlled. Reads after \"the laws of\" (e.g. \"the United States\"); a bloc is acceptable. A SEPARATE fact from `storage`, because privacy law cares about foreign *access*, not only foreign storage: a Canadian-region service run by a US company is `storage: [{ country: \"Canada\" }]` with `operatorCountry: \"the United States\"`."),
|
|
870
944
|
privacyUrl: z.optional(z.url())
|
|
871
945
|
});
|
|
872
946
|
const SiteSectionSchema = z.object({
|
|
@@ -878,6 +952,7 @@ const SiteSectionSchema = z.object({
|
|
|
878
952
|
})), "Canonical domain + redirecting aliases."),
|
|
879
953
|
dns: desc(z.optional(z.record(z.string(), DnsDomainSchema)), "Desired DNS state per domain (provider, nameservers, records) — declarative reference, not applied automatically."),
|
|
880
954
|
cloudflare: desc(z.optional(z.record(z.string(), z.unknown())), "Deliberate escape hatch for provider-specific Cloudflare settings — out of scope to model here."),
|
|
955
|
+
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
956
|
environments: desc(z.record(z.string(), EnvironmentSchema), "Deploy environments (production, dev, …): URL, API origin, analytics toggle, robots policy, active A/B variant."),
|
|
882
957
|
abTesting: desc(z.optional(z.object({
|
|
883
958
|
enabled: z.boolean(),
|
|
@@ -904,7 +979,7 @@ const SiteSectionSchema = z.object({
|
|
|
904
979
|
bingSiteVerification: z.optional(z.string()),
|
|
905
980
|
openGraph: z.optional(OpenGraphSchema),
|
|
906
981
|
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.")
|
|
982
|
+
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
983
|
})), "SEO defaults: title/description templates, robots policy, verification tokens, Open Graph + Twitter cards, and post-deploy indexing notification."),
|
|
909
984
|
analytics: desc(z.optional(z.object({
|
|
910
985
|
googleAnalyticsId: z.optional(z.string()),
|
|
@@ -917,11 +992,20 @@ const SiteSectionSchema = z.object({
|
|
|
917
992
|
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
993
|
})), "Matomo instance.")
|
|
919
994
|
})), "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()
|
|
995
|
+
notifications: desc(z.optional(z.object({ email: desc(z.optional(z.union([z.email(), z.object({
|
|
996
|
+
provider: desc(z.literal("cloudflare"), "Delivery provider. Only Cloudflare Email Sending is implemented; the field is stated so adding another is additive."),
|
|
997
|
+
to: desc(z.optional(z.email()), "Recipient. Defaults to the primary location’s `email`, then the first location that has one."),
|
|
998
|
+
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."),
|
|
999
|
+
fromName: desc(z.optional(z.string()), "Display name on the From header. Defaults to `organization.name`."),
|
|
1000
|
+
replyTo: desc(z.optional(z.email()), "Reply-To header."),
|
|
1001
|
+
binding: desc(z.optional(z.string()), "Workers `send_email` binding name (default `EMAIL`). Set it when using a restricted binding.")
|
|
1002
|
+
})])), "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
1003
|
tracking: desc(z.optional(z.object({
|
|
922
|
-
enabled: z.optional(z.boolean()),
|
|
923
|
-
|
|
924
|
-
|
|
1004
|
+
enabled: desc(z.optional(z.boolean()), "Capture ad click IDs and UTMs from the landing URL into the `_ac` cookie, for attributing conversions."),
|
|
1005
|
+
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."),
|
|
1006
|
+
platforms: desc(z.optional(z.array(z.string())), "Informational list of ad platforms in use. Capture recognises every known click-ID parameter regardless.")
|
|
1007
|
+
})), "Ad-click attribution for conversion tracking."),
|
|
1008
|
+
ads: desc(z.optional(z.partialRecord(AdProviderSchema, AdPropertySchema)), "Ad properties this site is linked to, keyed by platform. Read by `vitops ads setup` (ensure the DNS verification record), `vitops ads tags` (emit the consent-gated pixel), and the generated cookie notice (disclose what each pixel stores). Credentials never live here — the DNS write uses CLOUDFLARE_API_TOKEN from the environment."),
|
|
925
1009
|
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
1010
|
legal: desc(z.optional(z.object({
|
|
927
1011
|
jurisdiction: desc(z.optional(JurisdictionSchema), "Which legal template set the generated documents use. Default `ca`."),
|
|
@@ -1141,6 +1225,27 @@ function validateConfig(input) {
|
|
|
1141
1225
|
bp
|
|
1142
1226
|
], `template "${tpl}" is not in site.templates`));
|
|
1143
1227
|
const privacy = site.legal?.privacyPolicy;
|
|
1228
|
+
/**
|
|
1229
|
+
* `country` is shorthand for BOTH `storage` and `operatorCountry`, so combining it
|
|
1230
|
+
* with either is genuinely ambiguous — is the shorthand narrowing the explicit
|
|
1231
|
+
* fact, or adding to it? Both readings are plausible and they make *contradictory
|
|
1232
|
+
* legal claims*, so this is rejected rather than resolved by a silent rule.
|
|
1233
|
+
*
|
|
1234
|
+
* Checked unconditionally, not gated on `privacy.enabled`: the fact is wrong
|
|
1235
|
+
* either way, and a document turned on later shouldn't surface it for the first
|
|
1236
|
+
* time. Here rather than as a `z.refine` on the schema because parse-time
|
|
1237
|
+
* rejection would make `resolveConfig` throw on the `vitops legal` path, which
|
|
1238
|
+
* has no formatting for a zod issue — `validateConfig` is where every other
|
|
1239
|
+
* cross-field legal requirement already lives, and it prints `path: message`.
|
|
1240
|
+
*/
|
|
1241
|
+
for (const [i, p] of (privacy?.processors ?? []).entries()) if (p.country != null && (p.storage != null || p.operatorCountry != null)) errors.push(issue([
|
|
1242
|
+
"site",
|
|
1243
|
+
"legal",
|
|
1244
|
+
"privacyPolicy",
|
|
1245
|
+
"processors",
|
|
1246
|
+
i,
|
|
1247
|
+
"country"
|
|
1248
|
+
], `processor "${p.name}": \`country\` is shorthand for BOTH \`storage\` and \`operatorCountry\`, so combining it with either makes the narrower claim ambiguous — drop \`country\` and state the two facts, or drop the two and keep the shorthand`));
|
|
1144
1249
|
if (typeof privacy?.privacyOfficer === "string" && !has(org?.locations, privacy.privacyOfficer)) errors.push(issue([
|
|
1145
1250
|
"site",
|
|
1146
1251
|
"legal",
|
|
@@ -1175,6 +1280,18 @@ function validateConfig(input) {
|
|
|
1175
1280
|
"keyLocation"
|
|
1176
1281
|
], "site.seo.indexing.indexNow needs site.domains.canonical to derive the key location — set one, or state keyLocation explicitly"));
|
|
1177
1282
|
}
|
|
1283
|
+
for (const [provider, entry] of Object.entries(site.ads ?? {})) {
|
|
1284
|
+
if (entry == null) continue;
|
|
1285
|
+
const platform = AD_PLATFORMS[provider];
|
|
1286
|
+
const at = [
|
|
1287
|
+
"site",
|
|
1288
|
+
"ads",
|
|
1289
|
+
provider
|
|
1290
|
+
];
|
|
1291
|
+
if (entry.domainVerification != null && platform.verification.method === "none") errors.push(issue([...at, "domainVerification"], `${platform.name} has no domain verification — ${platform.verification.reason ?? "nothing verifies a domain there"}. Remove the token; linking is the tag and the account id.`));
|
|
1292
|
+
if (entry.domainVerification != null && entry.domain == null && site.domains?.canonical == null) errors.push(issue([...at, "domain"], `site.ads.${provider}.domainVerification needs a domain to put the record on — set site.domains.canonical, or state site.ads.${provider}.domain explicitly`));
|
|
1293
|
+
if (entry.accountId == null && entry.pixelId == null) errors.push(issue(at, `site.ads.${provider} identifies nothing — set accountId (the advertising account) and/or pixelId (the tag: ${platform.tag.needs} is what its snippet needs)`));
|
|
1294
|
+
}
|
|
1178
1295
|
if (errors.length) return {
|
|
1179
1296
|
ok: false,
|
|
1180
1297
|
data: void 0,
|
|
@@ -1753,6 +1870,590 @@ function checkContrast(fr, numericOf, surfaceBg) {
|
|
|
1753
1870
|
return failures;
|
|
1754
1871
|
}
|
|
1755
1872
|
|
|
1873
|
+
//#endregion
|
|
1874
|
+
//#region src/tiers.ts
|
|
1875
|
+
const c = (classes, partial, generated = false) => ({
|
|
1876
|
+
...partial ? { partial } : {},
|
|
1877
|
+
classes,
|
|
1878
|
+
generated
|
|
1879
|
+
});
|
|
1880
|
+
/**
|
|
1881
|
+
* Shorthand for the usual `@getvitops/astro/components/<name>.astro` export.
|
|
1882
|
+
*
|
|
1883
|
+
* Not every component lives under `components/` — `CookieConsent.astro` is exported
|
|
1884
|
+
* from the package root — so those are written out in full. The specifier must be
|
|
1885
|
+
* one a consumer can actually import: `tiers.test.ts` checks each against
|
|
1886
|
+
* `packages/astro/package.json`'s `exports`, because a wrong one is a copy-pasteable
|
|
1887
|
+
* import that fails only in the consumer's project.
|
|
1888
|
+
*/
|
|
1889
|
+
const a = (name, wraps) => [{
|
|
1890
|
+
component: `@getvitops/astro/components/${name}.astro`,
|
|
1891
|
+
wraps
|
|
1892
|
+
}];
|
|
1893
|
+
const TIERS = {
|
|
1894
|
+
tree: {
|
|
1895
|
+
css: c([
|
|
1896
|
+
"tree",
|
|
1897
|
+
"tree__item",
|
|
1898
|
+
"tree__content",
|
|
1899
|
+
"tree__toggle",
|
|
1900
|
+
"tree__label"
|
|
1901
|
+
], "patterns/tree.css", true),
|
|
1902
|
+
wc: {
|
|
1903
|
+
tag: "wc-tree",
|
|
1904
|
+
registered: true,
|
|
1905
|
+
adds: "filter, expand/collapse all, hash deep-linking"
|
|
1906
|
+
},
|
|
1907
|
+
astro: a("Tree", "wc"),
|
|
1908
|
+
use: "`<Tree items={…} />` — it emits `<wc-tree>` itself, so do NOT add your own wrapper."
|
|
1909
|
+
},
|
|
1910
|
+
carousel: {
|
|
1911
|
+
css: c(["carousel"], "patterns/carousel.css", true),
|
|
1912
|
+
wc: {
|
|
1913
|
+
tag: "wc-carousel",
|
|
1914
|
+
registered: true,
|
|
1915
|
+
adds: "cloned slides for a seamless loop, autoplay, snap nav"
|
|
1916
|
+
},
|
|
1917
|
+
bricks: "vitops-carousel",
|
|
1918
|
+
use: "`<wc-carousel>` around your slides; each child is a slide. Works unenhanced as a scroll-snap strip."
|
|
1919
|
+
},
|
|
1920
|
+
entries: {
|
|
1921
|
+
css: c([
|
|
1922
|
+
"entries__table",
|
|
1923
|
+
"entries__scroll",
|
|
1924
|
+
"entries__nav"
|
|
1925
|
+
], "patterns/table.css"),
|
|
1926
|
+
wc: {
|
|
1927
|
+
tag: "wc-entries",
|
|
1928
|
+
registered: true,
|
|
1929
|
+
adds: "parses heading + `<dl>` pairs into a table above a breakpoint"
|
|
1930
|
+
},
|
|
1931
|
+
bricks: "vitops-entries",
|
|
1932
|
+
use: "`<wc-entries>` around `<h3>` + `<dl>` pairs. The tier-2 exemplar: semantic pairs with no JS, a table with it."
|
|
1933
|
+
},
|
|
1934
|
+
copy: {
|
|
1935
|
+
css: c([
|
|
1936
|
+
"copy",
|
|
1937
|
+
"copy__button",
|
|
1938
|
+
"copy__icon",
|
|
1939
|
+
"copy__tooltip"
|
|
1940
|
+
], "patterns/copy.css"),
|
|
1941
|
+
wc: {
|
|
1942
|
+
tag: "wc-copy",
|
|
1943
|
+
registered: true,
|
|
1944
|
+
adds: "clipboard write plus success feedback"
|
|
1945
|
+
},
|
|
1946
|
+
bricks: "vitops-copy-button",
|
|
1947
|
+
use: "`<wc-copy>` around the content and a `[data-copy]` button."
|
|
1948
|
+
},
|
|
1949
|
+
dismissable: {
|
|
1950
|
+
css: c(["banner__close", "notification__close"], "patterns/banner.css"),
|
|
1951
|
+
wc: {
|
|
1952
|
+
tag: "wc-dismissable",
|
|
1953
|
+
registered: true,
|
|
1954
|
+
adds: "fade-out + removal, optional auto-dismiss"
|
|
1955
|
+
},
|
|
1956
|
+
bricks: "vitops-dismissable",
|
|
1957
|
+
use: "`<wc-dismissable>` around any banner/notification containing a `[data-dismiss]` control."
|
|
1958
|
+
},
|
|
1959
|
+
"split-panel": {
|
|
1960
|
+
css: c([
|
|
1961
|
+
"splitter",
|
|
1962
|
+
"splitter__panel",
|
|
1963
|
+
"splitter__handle"
|
|
1964
|
+
], "patterns/splitter.css"),
|
|
1965
|
+
wc: {
|
|
1966
|
+
tag: "wc-split-panel",
|
|
1967
|
+
registered: true,
|
|
1968
|
+
adds: "draggable divider between two panels"
|
|
1969
|
+
},
|
|
1970
|
+
bricks: "vitops-split-panel",
|
|
1971
|
+
use: "`<wc-split-panel>` with two children. Unenhanced they simply stack."
|
|
1972
|
+
},
|
|
1973
|
+
"image-compare": {
|
|
1974
|
+
css: c([
|
|
1975
|
+
"image-compare",
|
|
1976
|
+
"image-compare__before",
|
|
1977
|
+
"image-compare__handle"
|
|
1978
|
+
], "patterns/splitter.css"),
|
|
1979
|
+
wc: {
|
|
1980
|
+
tag: "wc-image-compare",
|
|
1981
|
+
registered: true,
|
|
1982
|
+
adds: "drag handle revealing the before/after image"
|
|
1983
|
+
},
|
|
1984
|
+
bricks: "vitops-image-compare",
|
|
1985
|
+
use: "`<wc-image-compare>` with two images. Both are visible without JS."
|
|
1986
|
+
},
|
|
1987
|
+
marquee: {
|
|
1988
|
+
css: c([
|
|
1989
|
+
"marquee",
|
|
1990
|
+
"marquee__content",
|
|
1991
|
+
"marquee__item"
|
|
1992
|
+
], "patterns/marquee.css"),
|
|
1993
|
+
wc: {
|
|
1994
|
+
tag: "wc-marquee",
|
|
1995
|
+
registered: true,
|
|
1996
|
+
adds: "fills the track with enough copies to scroll seamlessly"
|
|
1997
|
+
},
|
|
1998
|
+
use: "`<wc-marquee>` around one `.marquee__content`. Scrolls via CSS alone; JS only removes the seam."
|
|
1999
|
+
},
|
|
2000
|
+
"multi-field": {
|
|
2001
|
+
css: c([
|
|
2002
|
+
"form-group",
|
|
2003
|
+
"input-group",
|
|
2004
|
+
"fieldset"
|
|
2005
|
+
], "patterns/forms.css"),
|
|
2006
|
+
wc: {
|
|
2007
|
+
tag: "wc-multi-field",
|
|
2008
|
+
registered: true,
|
|
2009
|
+
adds: "add/remove repeating field rows"
|
|
2010
|
+
},
|
|
2011
|
+
bricks: "vitops-multi-field",
|
|
2012
|
+
use: "`<wc-multi-field>` around a field template. One row submits fine with no JS."
|
|
2013
|
+
},
|
|
2014
|
+
"color-scheme-toggle": {
|
|
2015
|
+
css: c([], "patterns/icon.css"),
|
|
2016
|
+
wc: {
|
|
2017
|
+
tag: "wc-color-scheme-toggle",
|
|
2018
|
+
registered: true,
|
|
2019
|
+
adds: "segmented light/dark/system control; persists the choice (consent-gated)"
|
|
2020
|
+
},
|
|
2021
|
+
bricks: "vitops-color-scheme-toggle",
|
|
2022
|
+
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."
|
|
2023
|
+
},
|
|
2024
|
+
consent: {
|
|
2025
|
+
css: c([
|
|
2026
|
+
"consent",
|
|
2027
|
+
"consent__body",
|
|
2028
|
+
"consent__options",
|
|
2029
|
+
"consent__actions"
|
|
2030
|
+
], "patterns/consent.css"),
|
|
2031
|
+
wc: {
|
|
2032
|
+
tag: "wc-consent",
|
|
2033
|
+
registered: false,
|
|
2034
|
+
bundle: "@getvitops/core/consent",
|
|
2035
|
+
adds: "the permission gate itself — activates `type=\"text/plain\"` tags on grant"
|
|
2036
|
+
},
|
|
2037
|
+
astro: [{
|
|
2038
|
+
component: "@getvitops/astro/CookieConsent.astro",
|
|
2039
|
+
wraps: "wc"
|
|
2040
|
+
}],
|
|
2041
|
+
use: "`<CookieConsent />`. Its own Lit-free bundle so a site needing consent does not download a rendering framework."
|
|
2042
|
+
},
|
|
2043
|
+
"theme-editor": {
|
|
2044
|
+
css: c([
|
|
2045
|
+
"ed-panel",
|
|
2046
|
+
"ed-launch",
|
|
2047
|
+
"ed-input"
|
|
2048
|
+
], "patterns/theme-editor.css"),
|
|
2049
|
+
wc: {
|
|
2050
|
+
tag: "wc-theme-editor",
|
|
2051
|
+
registered: false,
|
|
2052
|
+
bundle: "@getvitops/core/editor",
|
|
2053
|
+
adds: "live `:root` token overrides, export as CSS or a config patch"
|
|
2054
|
+
},
|
|
2055
|
+
use: "`<wc-theme-editor manifest=\"…\">`. TOOLING, not a page pattern — the one deliberate tier-2 exception, quarantined in its own opt-in bundle."
|
|
2056
|
+
},
|
|
2057
|
+
"color-wheel": {
|
|
2058
|
+
css: c([]),
|
|
2059
|
+
wc: {
|
|
2060
|
+
tag: "wc-color-wheel",
|
|
2061
|
+
registered: false,
|
|
2062
|
+
adds: "hue/chroma wheel input",
|
|
2063
|
+
bundle: "(none — editor-v2 track, not yet bundled)"
|
|
2064
|
+
},
|
|
2065
|
+
use: "Not shipped. Registered but absent from every bundle, so the tag is inert in a consumer project."
|
|
2066
|
+
},
|
|
2067
|
+
"oklch-color-picker": {
|
|
2068
|
+
css: c([]),
|
|
2069
|
+
wc: {
|
|
2070
|
+
tag: "wc-oklch-color-picker",
|
|
2071
|
+
registered: false,
|
|
2072
|
+
adds: "OKLCH L/C/H picker",
|
|
2073
|
+
bundle: "(none — editor-v2 track, not yet bundled)"
|
|
2074
|
+
},
|
|
2075
|
+
use: "Not shipped. See `color-wheel`."
|
|
2076
|
+
},
|
|
2077
|
+
"icon-picker": {
|
|
2078
|
+
css: c([], "patterns/icon.css"),
|
|
2079
|
+
wc: {
|
|
2080
|
+
tag: "wc-icon-picker",
|
|
2081
|
+
registered: false,
|
|
2082
|
+
adds: "browses the semantic icon map for the configured set",
|
|
2083
|
+
bundle: "(none — editor-v2 track, not yet bundled)"
|
|
2084
|
+
},
|
|
2085
|
+
use: "Not shipped. See `color-wheel`."
|
|
2086
|
+
},
|
|
2087
|
+
"typography-config": {
|
|
2088
|
+
css: c([]),
|
|
2089
|
+
wc: {
|
|
2090
|
+
tag: "wc-typography-config",
|
|
2091
|
+
registered: false,
|
|
2092
|
+
adds: "type role editor",
|
|
2093
|
+
bundle: "(none — editor-v2 track, not yet bundled)"
|
|
2094
|
+
},
|
|
2095
|
+
use: "Not shipped. See `color-wheel`."
|
|
2096
|
+
},
|
|
2097
|
+
details: {
|
|
2098
|
+
css: c([
|
|
2099
|
+
"details",
|
|
2100
|
+
"details-trigger",
|
|
2101
|
+
"details-icon"
|
|
2102
|
+
], "patterns/details.css", true),
|
|
2103
|
+
astro: a("Details", "css"),
|
|
2104
|
+
use: "`<Details>` — or hand-write `<details>`. No JS at either tier; `::details-content` animates in CSS."
|
|
2105
|
+
},
|
|
2106
|
+
drawer: {
|
|
2107
|
+
css: c([
|
|
2108
|
+
"drawer",
|
|
2109
|
+
"drawer--start",
|
|
2110
|
+
"drawer--end"
|
|
2111
|
+
], "patterns/drawer.css", true),
|
|
2112
|
+
astro: a("Drawer", "css"),
|
|
2113
|
+
use: "`<Drawer>` — `<dialog class=\"drawer\">` plus an Invoker Commands trigger. No JS."
|
|
2114
|
+
},
|
|
2115
|
+
popover: {
|
|
2116
|
+
css: c([
|
|
2117
|
+
"popover",
|
|
2118
|
+
"popover-trigger",
|
|
2119
|
+
"popover-anchor"
|
|
2120
|
+
], "patterns/popover.css", true),
|
|
2121
|
+
astro: a("Popover", "css"),
|
|
2122
|
+
use: "`<Popover>` — native Popover API + CSS Anchor Positioning. No JS."
|
|
2123
|
+
},
|
|
2124
|
+
navshell: {
|
|
2125
|
+
css: c([
|
|
2126
|
+
"navshell",
|
|
2127
|
+
"navshell__bar",
|
|
2128
|
+
"navshell__panel",
|
|
2129
|
+
"navshell-toggle"
|
|
2130
|
+
], "patterns/navshell.css"),
|
|
2131
|
+
astro: [...a("NavShell", "css"), ...a("NavShellToggle", "css")],
|
|
2132
|
+
use: "`<NavShell>` with a `nav` slot, plus `<NavShellToggle>` if the toggle must live outside the shell."
|
|
2133
|
+
},
|
|
2134
|
+
subgrid: {
|
|
2135
|
+
css: c([
|
|
2136
|
+
"subgrid",
|
|
2137
|
+
"subgrid-card",
|
|
2138
|
+
"pricing-grid"
|
|
2139
|
+
], "patterns/subgrid.css"),
|
|
2140
|
+
astro: a("Subgrid", "css"),
|
|
2141
|
+
use: "`<Subgrid>` — emits `<ul class=\"grid\">` with row-subgrid children so cards align across tracks."
|
|
2142
|
+
},
|
|
2143
|
+
card: {
|
|
2144
|
+
css: c(["card"], void 0, true),
|
|
2145
|
+
astro: a("Cards", "css"),
|
|
2146
|
+
use: "`<Cards>` for a list, or the `.card` class directly. Generated pattern: has roles and states."
|
|
2147
|
+
},
|
|
2148
|
+
icon: {
|
|
2149
|
+
css: c([
|
|
2150
|
+
"icon",
|
|
2151
|
+
"icon-button",
|
|
2152
|
+
"icon-mask"
|
|
2153
|
+
], "patterns/icon.css"),
|
|
2154
|
+
astro: a("Icon", "css"),
|
|
2155
|
+
bricks: "vitops-icon",
|
|
2156
|
+
use: "`<Icon name=\"menu\" />`. Names are semantic and resolve per configured set; a name containing `:` passes through."
|
|
2157
|
+
},
|
|
2158
|
+
split: {
|
|
2159
|
+
css: c([
|
|
2160
|
+
"split",
|
|
2161
|
+
"split-1-2",
|
|
2162
|
+
"split-reverse"
|
|
2163
|
+
], "layout.css"),
|
|
2164
|
+
bricks: "vitops-split",
|
|
2165
|
+
use: "`.split` plus a ratio class. Pure CSS; `md-split-1-2` for the responsive form."
|
|
2166
|
+
},
|
|
2167
|
+
centered: {
|
|
2168
|
+
css: c([
|
|
2169
|
+
"centered",
|
|
2170
|
+
"spotlight",
|
|
2171
|
+
"breakout",
|
|
2172
|
+
"fullbleed"
|
|
2173
|
+
], "layout.css"),
|
|
2174
|
+
bricks: "vitops-centered",
|
|
2175
|
+
use: "`.centered` as a track grid. Children land in `measure` — give it a track child to lay out inside."
|
|
2176
|
+
},
|
|
2177
|
+
sitenav: {
|
|
2178
|
+
css: c([
|
|
2179
|
+
"sitenav",
|
|
2180
|
+
"sitenav__link",
|
|
2181
|
+
"sitenav--drawer-start"
|
|
2182
|
+
], "patterns/sitenav.css"),
|
|
2183
|
+
bricks: "vitops-sitenav",
|
|
2184
|
+
use: "`.sitenav` with `--bp-*` and a drawer direction. `<details>` + Invoker Commands, no JS."
|
|
2185
|
+
},
|
|
2186
|
+
"split-link": {
|
|
2187
|
+
css: c([
|
|
2188
|
+
"split-link",
|
|
2189
|
+
"split-link__toggle",
|
|
2190
|
+
"split-link__panel"
|
|
2191
|
+
], "patterns/split-link.css"),
|
|
2192
|
+
bricks: "vitops-split-link",
|
|
2193
|
+
use: "`.split-link` — a link plus a disclosure toggle. No JS."
|
|
2194
|
+
},
|
|
2195
|
+
btn: {
|
|
2196
|
+
css: c(["btn"], void 0, true),
|
|
2197
|
+
use: "`<button>` gets it with no class; `.btn` carries it to other tags. `fill: false`, so states drive `color`."
|
|
2198
|
+
},
|
|
2199
|
+
cta: {
|
|
2200
|
+
css: c(["cta", "cta-danger"], void 0, true),
|
|
2201
|
+
use: "`.cta` on any element — usually `<a>`, since a CTA navigates. Filled, with role variants."
|
|
2202
|
+
},
|
|
2203
|
+
link: {
|
|
2204
|
+
css: c(["link", "skip-link"], "patterns/anchor-link.css", true),
|
|
2205
|
+
use: "`<a>` gets it at zero specificity; `.link` for other tags."
|
|
2206
|
+
},
|
|
2207
|
+
badge: {
|
|
2208
|
+
css: c(["badge", "badge-indicator"], "patterns/tag.css", true),
|
|
2209
|
+
use: "`.badge` — a STATIC label. Use `tag` if it is dismissable or editable."
|
|
2210
|
+
},
|
|
2211
|
+
tag: {
|
|
2212
|
+
css: c([
|
|
2213
|
+
"tag",
|
|
2214
|
+
"tag__remove",
|
|
2215
|
+
"tag__icon",
|
|
2216
|
+
"tag-list"
|
|
2217
|
+
], "patterns/tag.css", true),
|
|
2218
|
+
use: "`.tag` — an editable/dismissable label. Its group is `label`, not `tag`."
|
|
2219
|
+
},
|
|
2220
|
+
status: {
|
|
2221
|
+
css: c(["status", "status-dot--pulse"], "patterns/tag.css", true),
|
|
2222
|
+
use: "`.status` with a role variant."
|
|
2223
|
+
},
|
|
2224
|
+
tooltip: {
|
|
2225
|
+
css: c(["tooltip-trigger"], "patterns/tooltip.css", true),
|
|
2226
|
+
use: "`.tooltip-trigger` — CSS only, anchor-positioned."
|
|
2227
|
+
},
|
|
2228
|
+
dialog: {
|
|
2229
|
+
css: c([
|
|
2230
|
+
"dialog__header",
|
|
2231
|
+
"dialog__body",
|
|
2232
|
+
"dialog__footer"
|
|
2233
|
+
], "patterns/dialog.css", true),
|
|
2234
|
+
use: "Native `<dialog>` plus the `.dialog__*` parts. No JS beyond `showModal()`."
|
|
2235
|
+
},
|
|
2236
|
+
dropdown: {
|
|
2237
|
+
css: c(["dropdown--show-on-hover", "dropdown-menu"], "patterns/dropdown.css", true),
|
|
2238
|
+
use: "`.dropdown` — Popover API based."
|
|
2239
|
+
},
|
|
2240
|
+
notification: {
|
|
2241
|
+
css: c([
|
|
2242
|
+
"notification",
|
|
2243
|
+
"notification__title",
|
|
2244
|
+
"notification__actions"
|
|
2245
|
+
], "patterns/notification.css", true),
|
|
2246
|
+
use: "`.notification` with a role variant; wrap in `<wc-dismissable>` to make it dismissable."
|
|
2247
|
+
},
|
|
2248
|
+
lightbox: {
|
|
2249
|
+
css: c(["lightbox", "lightbox-dialog__content"], "patterns/lightbox.css", true),
|
|
2250
|
+
use: "`.lightbox` thumbnails plus a `<dialog>`."
|
|
2251
|
+
},
|
|
2252
|
+
comment: {
|
|
2253
|
+
css: c([
|
|
2254
|
+
"comment",
|
|
2255
|
+
"comment__author",
|
|
2256
|
+
"comment-thread"
|
|
2257
|
+
], "patterns/comment.css", true),
|
|
2258
|
+
use: "`.comment` inside `.comment-thread`."
|
|
2259
|
+
},
|
|
2260
|
+
tabs: {
|
|
2261
|
+
css: c(["tabs"], void 0, true),
|
|
2262
|
+
use: "Generated pattern only; no structural partial and no component yet."
|
|
2263
|
+
},
|
|
2264
|
+
nav: {
|
|
2265
|
+
css: c([
|
|
2266
|
+
"menu",
|
|
2267
|
+
"navbar",
|
|
2268
|
+
"nav-collapse",
|
|
2269
|
+
"drawer-nav"
|
|
2270
|
+
], "patterns/nav.css", true),
|
|
2271
|
+
use: "`.menu` / `.navbar`. See `navbar` and `navshell` for the newer shells."
|
|
2272
|
+
},
|
|
2273
|
+
navbar: {
|
|
2274
|
+
css: c([
|
|
2275
|
+
"navbar",
|
|
2276
|
+
"navbar--sticky",
|
|
2277
|
+
"nav-items"
|
|
2278
|
+
], "patterns/navbar.css"),
|
|
2279
|
+
use: "`.navbar` with `--start/--center/--end`."
|
|
2280
|
+
},
|
|
2281
|
+
banner: {
|
|
2282
|
+
css: c([
|
|
2283
|
+
"banner",
|
|
2284
|
+
"banner__content",
|
|
2285
|
+
"banner__action"
|
|
2286
|
+
], "patterns/banner.css", true),
|
|
2287
|
+
use: "`.banner`; wrap in `<wc-dismissable>` for a close button that works."
|
|
2288
|
+
},
|
|
2289
|
+
table: {
|
|
2290
|
+
css: c(["table", "table-wrapper"], "patterns/table.css", true),
|
|
2291
|
+
use: "`.table` inside `.table-wrapper`. See `entries` for the responsive-to-table pattern."
|
|
2292
|
+
},
|
|
2293
|
+
list: {
|
|
2294
|
+
css: c(["facet-list", "filtered-list"], "patterns/list.css", true),
|
|
2295
|
+
use: "`.facet-list` / `.filtered-list`."
|
|
2296
|
+
},
|
|
2297
|
+
"pull-quote": {
|
|
2298
|
+
css: c(["pull-quote", "pull-quote__attribution"], "patterns/pull-quote.css", true),
|
|
2299
|
+
use: "`.pull-quote`. Note the type role `quote` is separate."
|
|
2300
|
+
},
|
|
2301
|
+
combobox: {
|
|
2302
|
+
css: c([
|
|
2303
|
+
"combobox",
|
|
2304
|
+
"combobox__listbox",
|
|
2305
|
+
"combobox__option"
|
|
2306
|
+
], "patterns/combobox.css", true),
|
|
2307
|
+
use: "CSS shell only — the Lit component is a TODO, so it is not interactive yet."
|
|
2308
|
+
},
|
|
2309
|
+
forms: {
|
|
2310
|
+
css: c([
|
|
2311
|
+
"form-group",
|
|
2312
|
+
"input-group",
|
|
2313
|
+
"fieldset",
|
|
2314
|
+
"custom-check"
|
|
2315
|
+
], "patterns/forms.css", true),
|
|
2316
|
+
use: "Wrap text controls in `.form-group` or `.input-group` — a bare `<input>` gets browser defaults."
|
|
2317
|
+
},
|
|
2318
|
+
cluster: {
|
|
2319
|
+
css: c([
|
|
2320
|
+
"cluster",
|
|
2321
|
+
"cluster-between",
|
|
2322
|
+
"cluster-start"
|
|
2323
|
+
], "patterns/cluster.css"),
|
|
2324
|
+
use: "`.cluster` plus an alignment variant."
|
|
2325
|
+
},
|
|
2326
|
+
code: {
|
|
2327
|
+
css: c([], "patterns/code.css"),
|
|
2328
|
+
use: "Element selectors on `pre`/`code`; no class needed."
|
|
2329
|
+
},
|
|
2330
|
+
grouped: {
|
|
2331
|
+
css: c([
|
|
2332
|
+
"group",
|
|
2333
|
+
"group-inline",
|
|
2334
|
+
"bordered"
|
|
2335
|
+
], "patterns/grouped.css"),
|
|
2336
|
+
use: "`.group-inline` / `.group-block` to join adjacent controls."
|
|
2337
|
+
},
|
|
2338
|
+
"horizontal-scroll": {
|
|
2339
|
+
css: c(["horizontal-scroll"], "patterns/horizontal-scroll.css"),
|
|
2340
|
+
use: "`.horizontal-scroll` for a snap strip."
|
|
2341
|
+
},
|
|
2342
|
+
masonry: {
|
|
2343
|
+
css: c(["masonry", "masonry__item"], "patterns/masonry.css"),
|
|
2344
|
+
use: "`.masonry` — CSS columns based."
|
|
2345
|
+
},
|
|
2346
|
+
media: {
|
|
2347
|
+
css: c([
|
|
2348
|
+
"media",
|
|
2349
|
+
"media__figure",
|
|
2350
|
+
"tile"
|
|
2351
|
+
], "patterns/media.css"),
|
|
2352
|
+
use: "`.media` for a figure/body pair; `.tile` for the stacked form."
|
|
2353
|
+
},
|
|
2354
|
+
overlay: {
|
|
2355
|
+
css: c(["overlay"], "patterns/overlay.css"),
|
|
2356
|
+
use: "`.overlay` for a scrim."
|
|
2357
|
+
},
|
|
2358
|
+
reveal: {
|
|
2359
|
+
css: c([
|
|
2360
|
+
"reveal",
|
|
2361
|
+
"reveal-fade",
|
|
2362
|
+
"reveal-h"
|
|
2363
|
+
], "patterns/reveal.css"),
|
|
2364
|
+
use: "`.reveal` — scroll-driven, no JS. Hosts the `interpolate-size` that `<details>` animation needs."
|
|
2365
|
+
},
|
|
2366
|
+
"scroll-based": {
|
|
2367
|
+
css: c([
|
|
2368
|
+
"parallax",
|
|
2369
|
+
"scroll-progress",
|
|
2370
|
+
"scroll-spy"
|
|
2371
|
+
], "patterns/scroll-based.css"),
|
|
2372
|
+
use: "Scroll-timeline driven; no JS."
|
|
2373
|
+
},
|
|
2374
|
+
"scroll-target": {
|
|
2375
|
+
css: c(["scroll-target-group", "toc-sidebar"], "patterns/scroll-target.css"),
|
|
2376
|
+
use: "`.scroll-target-group` for `:target-current` highlighting."
|
|
2377
|
+
},
|
|
2378
|
+
separator: {
|
|
2379
|
+
css: c([
|
|
2380
|
+
"separator",
|
|
2381
|
+
"separator__text",
|
|
2382
|
+
"separator__ornament"
|
|
2383
|
+
], "patterns/separator.css"),
|
|
2384
|
+
use: "`.separator`, optionally with text or an ornament."
|
|
2385
|
+
},
|
|
2386
|
+
stack: {
|
|
2387
|
+
css: c(["stack", "stack__card"], "patterns/stack.css"),
|
|
2388
|
+
use: "`.stack` for overlapping cards."
|
|
2389
|
+
},
|
|
2390
|
+
sticky: {
|
|
2391
|
+
css: c([
|
|
2392
|
+
"sticky",
|
|
2393
|
+
"sticky-header",
|
|
2394
|
+
"sticky-nav"
|
|
2395
|
+
], "patterns/sticky.css"),
|
|
2396
|
+
use: "`.sticky-header` etc. Pure CSS."
|
|
2397
|
+
},
|
|
2398
|
+
svg: {
|
|
2399
|
+
css: c([
|
|
2400
|
+
"svg-container",
|
|
2401
|
+
"svg-shape",
|
|
2402
|
+
"svg-animated"
|
|
2403
|
+
], "patterns/svg.css"),
|
|
2404
|
+
use: "`.svg-*` helpers for inline SVG."
|
|
2405
|
+
},
|
|
2406
|
+
"text-effects": {
|
|
2407
|
+
css: c([
|
|
2408
|
+
"typing",
|
|
2409
|
+
"text-highlight",
|
|
2410
|
+
"text-gradient-animate",
|
|
2411
|
+
"text-stagger"
|
|
2412
|
+
], "patterns/text-effects.css"),
|
|
2413
|
+
use: "`.typing`, `.text-highlight`, … CSS animation only."
|
|
2414
|
+
}
|
|
2415
|
+
};
|
|
2416
|
+
/** Pattern names, sorted — a stable order for docs and tests. */
|
|
2417
|
+
const TIER_NAMES = Object.keys(TIERS).sort();
|
|
2418
|
+
/** Every `wc-*` tag the manifest knows, whether shipped in `elements.js` or not. */
|
|
2419
|
+
function tierTags() {
|
|
2420
|
+
return Object.entries(TIERS).filter(([, e]) => e.wc).map(([pattern, e]) => ({
|
|
2421
|
+
tag: e.wc.tag,
|
|
2422
|
+
registered: e.wc.registered,
|
|
2423
|
+
pattern
|
|
2424
|
+
}));
|
|
2425
|
+
}
|
|
2426
|
+
/**
|
|
2427
|
+
* The patterns one tier provides, in `TIER_NAMES` order.
|
|
2428
|
+
*
|
|
2429
|
+
* Every renderer goes through this rather than filtering `TIERS` itself: the docs
|
|
2430
|
+
* bundle projects all four tiers into one table for agents, the docsite projects
|
|
2431
|
+
* each tier as its own page for humans, and they must agree about which patterns
|
|
2432
|
+
* are in a tier. Five copies of `.filter(e => e.wc)` would agree until one of them
|
|
2433
|
+
* didn't.
|
|
2434
|
+
*
|
|
2435
|
+
* `css` needs a predicate rather than a presence check, which is the non-obvious
|
|
2436
|
+
* part. `TierEntry.css` is required, so *every* entry has one — including the
|
|
2437
|
+
* editor-v2 track, whose entries carry `c([])`: no partial, no classes, not
|
|
2438
|
+
* generated. Treating the field's presence as membership would list four patterns
|
|
2439
|
+
* on the CSS page with nothing to show for them, and `color-wheel` genuinely has
|
|
2440
|
+
* no CSS. The other three tiers are optional fields, so presence is the test.
|
|
2441
|
+
*/
|
|
2442
|
+
function tierPatterns(tier) {
|
|
2443
|
+
const has = (e) => {
|
|
2444
|
+
switch (tier) {
|
|
2445
|
+
case "css": return !!e.css.partial || e.css.classes.length > 0 || e.css.generated;
|
|
2446
|
+
case "wc": return !!e.wc;
|
|
2447
|
+
case "astro": return !!e.astro?.length;
|
|
2448
|
+
case "bricks": return !!e.bricks;
|
|
2449
|
+
}
|
|
2450
|
+
};
|
|
2451
|
+
return TIER_NAMES.filter((name) => has(TIERS[name])).map((name) => ({
|
|
2452
|
+
name,
|
|
2453
|
+
entry: TIERS[name]
|
|
2454
|
+
}));
|
|
2455
|
+
}
|
|
2456
|
+
|
|
1756
2457
|
//#endregion
|
|
1757
2458
|
//#region src/docs.ts
|
|
1758
2459
|
/**
|
|
@@ -1770,13 +2471,13 @@ const ORDER = [
|
|
|
1770
2471
|
"split",
|
|
1771
2472
|
"centered",
|
|
1772
2473
|
"carousel",
|
|
1773
|
-
"color-scheme-toggle",
|
|
2474
|
+
"wc-color-scheme-toggle",
|
|
1774
2475
|
"wc-copy",
|
|
1775
2476
|
"dismissable",
|
|
1776
2477
|
"entries",
|
|
1777
2478
|
"image-compare",
|
|
1778
2479
|
"sitenav",
|
|
1779
|
-
"wc-
|
|
2480
|
+
"wc-multi-field",
|
|
1780
2481
|
"split-link",
|
|
1781
2482
|
"split-panel"
|
|
1782
2483
|
];
|
|
@@ -2515,26 +3216,26 @@ function childEntries(node) {
|
|
|
2515
3216
|
}];
|
|
2516
3217
|
return [];
|
|
2517
3218
|
}
|
|
2518
|
-
function renderSchemaNode(label, node, required, depth, out) {
|
|
3219
|
+
function renderSchemaNode(label, node, required, depth, out, maxDepth = 3) {
|
|
2519
3220
|
const indent = " ".repeat(depth);
|
|
2520
3221
|
const meta = `(${typeLabel(node)}${required ? ", required" : ""})`;
|
|
2521
3222
|
out.push(`${indent}- \`${label}\` ${meta}${node.description ? ` — ${node.description}` : ""}`);
|
|
2522
|
-
if (depth >=
|
|
3223
|
+
if (depth >= maxDepth) return;
|
|
2523
3224
|
if (node.anyOf && !node.anyOf.every(isPrimitive)) {
|
|
2524
3225
|
for (const variant of node.anyOf) {
|
|
2525
3226
|
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);
|
|
3227
|
+
for (const c of childEntries(variant)) renderSchemaNode(c.label, c.node, c.required, depth + 2, out, maxDepth);
|
|
2527
3228
|
}
|
|
2528
3229
|
return;
|
|
2529
3230
|
}
|
|
2530
|
-
for (const c of childEntries(node)) renderSchemaNode(c.label, c.node, c.required, depth + 1, out);
|
|
3231
|
+
for (const c of childEntries(node)) renderSchemaNode(c.label, c.node, c.required, depth + 1, out, maxDepth);
|
|
2531
3232
|
}
|
|
2532
3233
|
/**
|
|
2533
3234
|
* One `## <key>` section per top-level property: its description, then its
|
|
2534
3235
|
* fields as a bullet tree. Shared by both references so the two cannot drift in
|
|
2535
3236
|
* presentation — only in which schema they are pointed at.
|
|
2536
3237
|
*/
|
|
2537
|
-
function schemaSections(schema, heading = "##") {
|
|
3238
|
+
function schemaSections(schema, heading = "##", maxDepth = 3) {
|
|
2538
3239
|
const required = schema.required ?? [];
|
|
2539
3240
|
const sections = [];
|
|
2540
3241
|
for (const [key, node] of Object.entries(schema.properties ?? {})) {
|
|
@@ -2542,12 +3243,102 @@ function schemaSections(schema, heading = "##") {
|
|
|
2542
3243
|
const parts = [`${heading} \`${key}\`${required.includes(key) ? "" : " *(optional)*"}`];
|
|
2543
3244
|
if (node.description) parts.push("", node.description);
|
|
2544
3245
|
const bullets = [];
|
|
2545
|
-
for (const c of childEntries(node)) renderSchemaNode(c.label, c.node, c.required, 0, bullets);
|
|
3246
|
+
for (const c of childEntries(node)) renderSchemaNode(c.label, c.node, c.required, 0, bullets, maxDepth);
|
|
2546
3247
|
if (bullets.length) parts.push("", ...bullets);
|
|
2547
3248
|
sections.push(parts.join("\n"));
|
|
2548
3249
|
}
|
|
2549
3250
|
return sections;
|
|
2550
3251
|
}
|
|
3252
|
+
const escapeHtml = (s) => s.replace(/[&<>"]/g, (c) => ({
|
|
3253
|
+
"&": "&",
|
|
3254
|
+
"<": "<",
|
|
3255
|
+
">": ">",
|
|
3256
|
+
"\"": """
|
|
3257
|
+
})[c]);
|
|
3258
|
+
/** Sentinel for a lifted code span. Cannot occur in authored schema prose. */
|
|
3259
|
+
const CODE_SLOT = "\0";
|
|
3260
|
+
/**
|
|
3261
|
+
* The inline markdown the schema's `desc()` text actually uses, as HTML. Closed
|
|
3262
|
+
* on purpose, exactly like the legal renderer's subset: these strings are
|
|
3263
|
+
* authored in `schema.ts`, so the set is knowable rather than guessed at.
|
|
3264
|
+
*
|
|
3265
|
+
* Escaping happens before any markup is emitted, so a description containing `<`
|
|
3266
|
+
* cannot inject tags — several do (`<canonical>/<key>.txt`). Exported because the
|
|
3267
|
+
* only safe place for this is one tested function; a caller doing its own escaping
|
|
3268
|
+
* is how one of them eventually doesn't.
|
|
3269
|
+
*
|
|
3270
|
+
* **Code spans are lifted out FIRST, and that is load-bearing rather than tidy.**
|
|
3271
|
+
* `colors.utilities` describes the target families as `` `bg-*` ``, `` `text-*` ``,
|
|
3272
|
+
* `` `border-*` `` — literal asterisk WILDCARDS. Run emphasis over the raw string
|
|
3273
|
+
* and the `*` closing `bg-*` pairs with the one closing `text-*`, wrapping the
|
|
3274
|
+
* text between two unrelated utilities in `<em>` and eating both asterisks. The
|
|
3275
|
+
* prose then silently describes families that don't exist. Lifting code spans
|
|
3276
|
+
* first makes emphasis structurally unable to see inside them.
|
|
3277
|
+
*/
|
|
3278
|
+
const renderInlineMarkdown = (s) => {
|
|
3279
|
+
const spans = [];
|
|
3280
|
+
return escapeHtml(s.replace(/`([^`]+)`/g, (_, body) => {
|
|
3281
|
+
spans.push(body);
|
|
3282
|
+
return `${CODE_SLOT}${spans.length - 1}${CODE_SLOT}`;
|
|
3283
|
+
})).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>`);
|
|
3284
|
+
};
|
|
3285
|
+
/**
|
|
3286
|
+
* Walk a JSON Schema into tree DATA.
|
|
3287
|
+
*
|
|
3288
|
+
* The same walk that produces the `authoring.md` / `config.md` bullet lists,
|
|
3289
|
+
* returned as a node model instead of a string. Returning data rather than markup
|
|
3290
|
+
* is deliberate and follows `roleColorUtilities()`: the *shape* of the tree is one
|
|
3291
|
+
* decision, and each medium renders it — markdown for agents, an accessible
|
|
3292
|
+
* `<details>` tree for the docs site. An HTML-emitting version here would be a
|
|
3293
|
+
* second source of the markup contract for `<wc-tree>` to drift from.
|
|
3294
|
+
*
|
|
3295
|
+
* Ids are dotted paths so a field can be linked to directly. Map-value and
|
|
3296
|
+
* array-item pseudo-labels are skipped in the path: `site.dns.records` reads
|
|
3297
|
+
* better than `site.dns.<domain>.records`, and neither `<domain>` nor `[items]`
|
|
3298
|
+
* is a real key.
|
|
3299
|
+
*/
|
|
3300
|
+
function schemaTreeNodes(schema, opts = {}) {
|
|
3301
|
+
const { idPrefix = "", maxDepth = 12, prune = [] } = opts;
|
|
3302
|
+
const pruned = new Set(prune);
|
|
3303
|
+
const isPseudo = (label) => label === "<name>" || label === "[items]";
|
|
3304
|
+
const join = (a, b) => a ? `${a}.${b}` : b;
|
|
3305
|
+
/**
|
|
3306
|
+
* Paths already claimed. An `anyOf` branch has no key of its own, so sibling
|
|
3307
|
+
* branches sharing a field name resolve to the same dotted path
|
|
3308
|
+
* (`site.templates.type` exists in all three template variants). Rather than
|
|
3309
|
+
* invent a branch index that no real config document contains, the first
|
|
3310
|
+
* occurrence keeps the id — which is what `getElementById` would have picked
|
|
3311
|
+
* anyway, now true instead of accidental.
|
|
3312
|
+
*/
|
|
3313
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3314
|
+
const build = (label, n, required, path, depth) => {
|
|
3315
|
+
const addressable = path !== "" && !isPseudo(label) && !seen.has(path);
|
|
3316
|
+
if (addressable) seen.add(path);
|
|
3317
|
+
const variants = n.anyOf && !n.anyOf.every(isPrimitive) ? n.anyOf : [];
|
|
3318
|
+
const children = [];
|
|
3319
|
+
if (depth < maxDepth && !pruned.has(path)) {
|
|
3320
|
+
for (const v of variants) children.push({
|
|
3321
|
+
label: "one of",
|
|
3322
|
+
type: typeLabel(v),
|
|
3323
|
+
required: false,
|
|
3324
|
+
variant: true,
|
|
3325
|
+
...v.description ? { description: v.description } : {},
|
|
3326
|
+
children: childEntries(v).map((c) => build(c.label, c.node, c.required, isPseudo(c.label) ? path : join(path, c.label), depth + 2))
|
|
3327
|
+
});
|
|
3328
|
+
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));
|
|
3329
|
+
}
|
|
3330
|
+
return {
|
|
3331
|
+
label,
|
|
3332
|
+
type: typeLabel(n),
|
|
3333
|
+
required,
|
|
3334
|
+
...n.description ? { description: n.description } : {},
|
|
3335
|
+
...addressable ? { id: path } : {},
|
|
3336
|
+
children
|
|
3337
|
+
};
|
|
3338
|
+
};
|
|
3339
|
+
const required = schema.required ?? [];
|
|
3340
|
+
return Object.entries(schema.properties ?? {}).filter(([key]) => key !== "$schema").map(([key, child]) => build(key, child, required.includes(key), join(idPrefix, key), 0));
|
|
3341
|
+
}
|
|
2551
3342
|
function renderAuthoring() {
|
|
2552
3343
|
const sections = schemaSections(jsonSchema);
|
|
2553
3344
|
return `${frontmatter({
|
|
@@ -2592,7 +3383,7 @@ function renderConfig() {
|
|
|
2592
3383
|
const required = schema.required ?? [];
|
|
2593
3384
|
const sections = Object.entries(props).map(([key, node]) => {
|
|
2594
3385
|
const head = [`## \`${key}\`${required.includes(key) ? "" : " *(optional)*"}`, ...node.description ? ["", node.description] : []].join("\n");
|
|
2595
|
-
const fields = schemaSections(node, "###").join("\n\n");
|
|
3386
|
+
const fields = schemaSections(node, "###", key === "designSystem" ? 0 : 3).join("\n\n");
|
|
2596
3387
|
if (key === "designSystem") return [
|
|
2597
3388
|
head,
|
|
2598
3389
|
"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 +3409,7 @@ function renderConfig() {
|
|
|
2618
3409
|
|
|
2619
3410
|
The document that describes a **whole project**: the token set, the company, and the
|
|
2620
3411
|
published site. It is the input every command that needs more than tokens is anchored
|
|
2621
|
-
to — \`vitops legal\`, \`vitops icons\`, \`vitops
|
|
3412
|
+
to — \`vitops legal\`, \`vitops icons\`, \`vitops search\` — and it can also stand in for
|
|
2622
3413
|
a \`design-system.json\` anywhere the toolchain takes one, since it carries a full
|
|
2623
3414
|
\`designSystem\`.
|
|
2624
3415
|
|
|
@@ -2911,14 +3702,14 @@ Raw hue steps (\`--color-<hue>-<step>\`) are the exception — they are fixed va
|
|
|
2911
3702
|
ignores the appearance.
|
|
2912
3703
|
|
|
2913
3704
|
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
|
|
3705
|
+
WordPress target), \`data-theme\` is what the shipped \`<wc-color-scheme-toggle>\` writes on
|
|
2915
3706
|
\`<html>\`, so the toggle works on every other target. Set either.
|
|
2916
3707
|
|
|
2917
3708
|
The OS preference is a **second, opt-in block**. Set
|
|
2918
3709
|
\`designSystem.defaultColorScheme: "system"\` in your site config and the same delta is emitted again inside
|
|
2919
3710
|
\`@media (prefers-color-scheme: dark)\`, under
|
|
2920
3711
|
\`${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,
|
|
3712
|
+
\`<wc-color-scheme-toggle>\`'s "System" position resolve to the OS (it *removes* the attribute,
|
|
2922
3713
|
so without this block it fell through to light), and it gives a no-JS page the OS
|
|
2923
3714
|
appearance, which the toggle alone never could. An explicit light choice still wins.
|
|
2924
3715
|
|
|
@@ -3128,6 +3919,138 @@ token guaranteed legible over a surface in both appearances. \`default_role\` co
|
|
|
3128
3919
|
bare, unsuffixed pattern. States re-apply per variant with the variant's role.
|
|
3129
3920
|
`;
|
|
3130
3921
|
}
|
|
3922
|
+
/**
|
|
3923
|
+
* All four tiers in ONE doc — the agent-facing projection of `TIERS`.
|
|
3924
|
+
*
|
|
3925
|
+
* The docsite projects each tier as its own page, because a human arrives already
|
|
3926
|
+
* knowing which stack they are in ("show me the Astro components"). An agent
|
|
3927
|
+
* arrives the other way round: it knows the *pattern* it needs and has to be told
|
|
3928
|
+
* which tiers offer it and which call to make. Four docs would mean an agent
|
|
3929
|
+
* fetching `components/astro` learns `<Tree />` exists and never learns it must not
|
|
3930
|
+
* wrap it in `<wc-tree>` — the composition only exists between the tiers, so the
|
|
3931
|
+
* projection that serves agents is the one that keeps them together.
|
|
3932
|
+
*
|
|
3933
|
+
* `ds` is read for one thing: whether each config-authored pattern is actually
|
|
3934
|
+
* declared in THIS consumer's `patterns.items`. `TIERS.css.generated` records that a
|
|
3935
|
+
* pattern is *of that kind* against this repo's reference config; it cannot know
|
|
3936
|
+
* that a given consumer dropped `card`. Reporting a class that this config emits no
|
|
3937
|
+
* CSS for is the failure worth preventing here.
|
|
3938
|
+
*/
|
|
3939
|
+
function renderComponentsConcept(ds) {
|
|
3940
|
+
const declared = new Set(Object.keys(ds.patterns?.items ?? {}));
|
|
3941
|
+
const cell = (s) => s.replace(/\|/g, "\\|");
|
|
3942
|
+
const code = (s) => `\`${s}\``;
|
|
3943
|
+
const list = (xs) => xs.map(code).join(", ");
|
|
3944
|
+
const tiersOf = (name) => [
|
|
3945
|
+
"css",
|
|
3946
|
+
"wc",
|
|
3947
|
+
"astro",
|
|
3948
|
+
"bricks"
|
|
3949
|
+
].filter((t) => tierPatterns(t).some((p) => p.name === name));
|
|
3950
|
+
const overview = TIER_NAMES.map((name) => {
|
|
3951
|
+
const e = TIERS[name];
|
|
3952
|
+
return `| ${code(name)} | ${tiersOf(name).join(" · ")} | ${cell(e.use)} |`;
|
|
3953
|
+
}).join("\n");
|
|
3954
|
+
const wc = tierPatterns("wc").map(({ name, entry }) => {
|
|
3955
|
+
const w = entry.wc;
|
|
3956
|
+
const ships = w.registered ? code("elements.js") : w.bundle ? code(w.bundle) : "nothing";
|
|
3957
|
+
return `| ${code(`<${w.tag}>`)} | ${code(name)} | ${ships} | ${cell(w.adds)} |`;
|
|
3958
|
+
}).join("\n");
|
|
3959
|
+
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");
|
|
3960
|
+
const bricks = tierPatterns("bricks").map(({ name, entry }) => `| ${code(entry.bricks)} | ${code(name)} |`).join("\n");
|
|
3961
|
+
const css = tierPatterns("css").map(({ name, entry }) => {
|
|
3962
|
+
const c = entry.css;
|
|
3963
|
+
const config = !c.generated ? "—" : declared.has(name) ? "declared" : "**absent from this config — no CSS emitted**";
|
|
3964
|
+
return `| ${code(name)} | ${c.partial ? code(c.partial) : "—"} | ${c.classes.length ? list(c.classes) : "—"} | ${config} |`;
|
|
3965
|
+
}).join("\n");
|
|
3966
|
+
const hooks = [...new Set(Object.values(BASE_HOOK))].map((sfx) => code(`--${sfx}-<pattern>`)).join(", ");
|
|
3967
|
+
const unshipped = tierPatterns("wc").filter(({ entry }) => !entry.wc.registered && entry.wc.bundle?.startsWith("(none")).map(({ entry }) => code(`<${entry.wc.tag}>`)).join(", ");
|
|
3968
|
+
return `${frontmatter({
|
|
3969
|
+
type: "Design Concept",
|
|
3970
|
+
title: "Vitops components — which tier provides a pattern, and what to write",
|
|
3971
|
+
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.",
|
|
3972
|
+
resource: DS_PATH,
|
|
3973
|
+
tags: [
|
|
3974
|
+
"components",
|
|
3975
|
+
"web-components",
|
|
3976
|
+
"astro",
|
|
3977
|
+
"bricks",
|
|
3978
|
+
"tiers",
|
|
3979
|
+
"design-system"
|
|
3980
|
+
]
|
|
3981
|
+
})}
|
|
3982
|
+
|
|
3983
|
+
# Components
|
|
3984
|
+
|
|
3985
|
+
A pattern is provided by up to four tiers, and they **compose** rather than compete:
|
|
3986
|
+
|
|
3987
|
+
1. **CSS framework classes** — every pattern expressible in pure HTML/CSS. Reach here first.
|
|
3988
|
+
2. **Web components** (${code("<wc-*>")}, Lit) — only where a pattern genuinely benefits from
|
|
3989
|
+
progressive enhancement. The slotted markup is the fallback and must be usable with no JS;
|
|
3990
|
+
the element parses and augments it in place.
|
|
3991
|
+
3. **Astro components** — authoring conveniences that emit the correct markup. They must not
|
|
3992
|
+
require runtime JS. Where a pattern has a web component, the Astro component emits that tag
|
|
3993
|
+
**with the accessible fallback inside it**.
|
|
3994
|
+
4. **Bricks elements** — the same patterns as WordPress/Bricks Builder elements.
|
|
3995
|
+
|
|
3996
|
+
## Choosing
|
|
3997
|
+
|
|
3998
|
+
**Use the highest-numbered tier available for your stack, and write only its call.** In Astro
|
|
3999
|
+
that is the Astro component; in Bricks the element; anywhere else the classes, plus the
|
|
4000
|
+
${code("<wc-*>")} tag when one exists.
|
|
4001
|
+
|
|
4002
|
+
The trap is composing two tiers by hand. When ${code("wraps")} below says the Astro component
|
|
4003
|
+
emits the tag, that one call is the whole composition —
|
|
4004
|
+
${code("<wc-tree><Tree /></wc-tree>")} nests two elements on one tree. A tier-3 component that
|
|
4005
|
+
would need runtime JS is in the wrong tier: build a web component instead and have the wrapper
|
|
4006
|
+
emit its tag.
|
|
4007
|
+
|
|
4008
|
+
## Every pattern
|
|
4009
|
+
|
|
4010
|
+
| Pattern | Tiers | What to write |
|
|
4011
|
+
| --- | --- | --- |
|
|
4012
|
+
${overview}
|
|
4013
|
+
|
|
4014
|
+
## Web components
|
|
4015
|
+
|
|
4016
|
+
Shipped as feature-detected, deferred ES-module bundles. ${code("elements.js")} carries the
|
|
4017
|
+
registered set; ${code("<wc-consent>")} and ${code("<wc-theme-editor>")} ship separately, the
|
|
4018
|
+
first so a site needing consent does not download a rendering framework, the second because it
|
|
4019
|
+
is tooling and opt-in per consumer.
|
|
4020
|
+
|
|
4021
|
+
| Tag | Pattern | Ships in | What JS adds over the fallback |
|
|
4022
|
+
| --- | --- | --- | --- |
|
|
4023
|
+
${wc}
|
|
4024
|
+
|
|
4025
|
+
${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.` : ""}
|
|
4026
|
+
|
|
4027
|
+
## Astro components
|
|
4028
|
+
|
|
4029
|
+
| Component | Pattern | Emits |
|
|
4030
|
+
| --- | --- | --- |
|
|
4031
|
+
${astro}
|
|
4032
|
+
|
|
4033
|
+
## Bricks elements
|
|
4034
|
+
|
|
4035
|
+
Controls, defaults and seeded children for each are in [the elements reference](../bricks/elements.md).
|
|
4036
|
+
|
|
4037
|
+
| Element | Pattern |
|
|
4038
|
+
| --- | --- |
|
|
4039
|
+
${bricks}
|
|
4040
|
+
|
|
4041
|
+
## CSS
|
|
4042
|
+
|
|
4043
|
+
${code("patterns.items")} patterns get the full token cascade — ${code("base")} declarations,
|
|
4044
|
+
${code("states")}, role variants and override hooks (${hooks}); see
|
|
4045
|
+
[component patterns](patterns.md). A structural partial has none of those. The class lists below
|
|
4046
|
+
are representative, not exhaustive — [the class vocabulary](../css/classes.md) states the naming
|
|
4047
|
+
rules that generate them all.
|
|
4048
|
+
|
|
4049
|
+
| Pattern | Partial | Classes | ${code("patterns.items")} |
|
|
4050
|
+
| --- | --- | --- | --- |
|
|
4051
|
+
${css}
|
|
4052
|
+
`;
|
|
4053
|
+
}
|
|
3131
4054
|
function renderTopIndex() {
|
|
3132
4055
|
return `${INDEX_NOTE}
|
|
3133
4056
|
|
|
@@ -3142,7 +4065,7 @@ variable-driven CSS framework plus progressively-enhanced web components, genera
|
|
|
3142
4065
|
* [Authoring reference](authoring.md) - every design-system.json field, generated from the JSON Schema
|
|
3143
4066
|
* [Config reference](config.md) - every field of the three-section config (designSystem / organization / site), generated from the JSON Schema
|
|
3144
4067
|
* [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,
|
|
4068
|
+
* [Concepts](concepts/) - the colour system, type/space scales, pattern CSS architecture, the four component tiers, icons, and the consent / tracking / search / legal subsystems
|
|
3146
4069
|
* [CSS framework](css/) - the class vocabulary (colour, type, space, layout, animation, component patterns), stated as naming rules
|
|
3147
4070
|
* [Bricks Builder](bricks/) - custom elements and how to style them
|
|
3148
4071
|
`;
|
|
@@ -3204,7 +4127,7 @@ astro-icon is **zero-config on a static build**. Under \`output: 'server'\` it b
|
|
|
3204
4127
|
*every icon in a set* unless given an \`include\` map — which is why projects end up
|
|
3205
4128
|
hand-maintaining one.
|
|
3206
4129
|
|
|
3207
|
-
The \`icons\` option on \`
|
|
4130
|
+
The \`icons\` option on \`vitops()\` derives it by scanning your source, merged with
|
|
3208
4131
|
whatever you declare. On a static build **no include is passed at all**: there is
|
|
3209
4132
|
nothing to trim there, and a list could only drop a glyph the scan couldn't see.
|
|
3210
4133
|
|
|
@@ -3259,6 +4182,491 @@ Note \`.icon-mask\` is a different thing: the CSS-only adornment path used by
|
|
|
3259
4182
|
is no markup to hang an \`<svg>\` on.
|
|
3260
4183
|
`;
|
|
3261
4184
|
}
|
|
4185
|
+
function renderConsentConcept() {
|
|
4186
|
+
return `${frontmatter({
|
|
4187
|
+
type: "Design Concept",
|
|
4188
|
+
title: "Vitops consent — a demand-driven permission gate",
|
|
4189
|
+
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.",
|
|
4190
|
+
resource: CONFIG_PATH,
|
|
4191
|
+
tags: [
|
|
4192
|
+
"consent",
|
|
4193
|
+
"cookies",
|
|
4194
|
+
"privacy",
|
|
4195
|
+
"gdpr",
|
|
4196
|
+
"analytics"
|
|
4197
|
+
]
|
|
4198
|
+
})}
|
|
4199
|
+
|
|
4200
|
+
# The consent gate
|
|
4201
|
+
|
|
4202
|
+
\`@getvitops/core/consent\` is a **general permission gate, not an analytics feature**. Anything
|
|
4203
|
+
marked \`data-consent="<category>"\` waits on one visitor choice — a third-party tag, an A/B
|
|
4204
|
+
assignment, a personalisation cookie, an embedded map. It carries no Lit and is a separate
|
|
4205
|
+
bundle from \`elements.js\`, because consent is a legal requirement: a site that needs it must
|
|
4206
|
+
not be made to download a rendering framework first.
|
|
4207
|
+
|
|
4208
|
+
Categories are \`necessary\`, \`analytics\`, \`marketing\`, \`preferences\`.
|
|
4209
|
+
|
|
4210
|
+
## Gating something
|
|
4211
|
+
|
|
4212
|
+
A gated script renders **inert**, with its URL parked on \`data-src\`:
|
|
4213
|
+
|
|
4214
|
+
\`\`\`html
|
|
4215
|
+
<script
|
|
4216
|
+
type="text/plain"
|
|
4217
|
+
data-vitops-tag
|
|
4218
|
+
data-consent="analytics"
|
|
4219
|
+
data-consent-cookies="_ga,_ga_*"
|
|
4220
|
+
data-src="https://example.com/analytics.js"
|
|
4221
|
+
><\/script>
|
|
4222
|
+
\`\`\`
|
|
4223
|
+
|
|
4224
|
+
⚠️ **\`type="text/plain"\` is what makes the gate real. Never give a gated tag a live \`src\`.**
|
|
4225
|
+
The browser neither parses the body nor fetches the library, so an undecided visitor's page
|
|
4226
|
+
issues **no third-party request at all**. A gate that instead loads a script and politely asks
|
|
4227
|
+
it not to track is a promise, not a gate.
|
|
4228
|
+
|
|
4229
|
+
\`data-consent-cookies\` is written by whoever emitted the tag, because that is who knows what
|
|
4230
|
+
the provider sets. It is what a revoke clears.
|
|
4231
|
+
|
|
4232
|
+
## Demand-driven: two different ideas
|
|
4233
|
+
|
|
4234
|
+
**Offered** categories are a build-time fact — which rows \`<CookieConsent />\` renders. The
|
|
4235
|
+
default is deliberately generous; a hidden row costs nothing.
|
|
4236
|
+
|
|
4237
|
+
**Demanded** categories are a runtime fact — what something has actually asked for and the
|
|
4238
|
+
visitor hasn't answered. The banner appears only when a demand is outstanding, so **a site
|
|
4239
|
+
whose only provider is cookieless never interrupts anyone.**
|
|
4240
|
+
|
|
4241
|
+
Demand is registered by:
|
|
4242
|
+
|
|
4243
|
+
- a gated element **reaching its loading strategy** — so an \`idle\` tag asks after \`load\`, off
|
|
4244
|
+
the LCP path, and an \`interaction\` tag asks only once the visitor acts; or
|
|
4245
|
+
- an explicit \`require()\` / \`request()\`.
|
|
4246
|
+
|
|
4247
|
+
## The runtime API
|
|
4248
|
+
|
|
4249
|
+
\`window.vitopsConsent\`, plus a \`vitops:consent\` \`CustomEvent\` on \`document\`.
|
|
4250
|
+
|
|
4251
|
+
| Call | Does |
|
|
4252
|
+
| --- | --- |
|
|
4253
|
+
| \`granted(cat)\` | Is it granted? **Passive — does not prompt.** |
|
|
4254
|
+
| \`require(cat)\` | Declare a need *now* and report whether it's granted. **This is what raises the banner.** Synchronous. |
|
|
4255
|
+
| \`request(cat)\` | \`require()\`, but resolves once the visitor answers *this* category. |
|
|
4256
|
+
| \`needed()\` | Is a prompt warranted? True only if something demanded an unanswered category. |
|
|
4257
|
+
| \`demanded()\` | What has asked, this page view. |
|
|
4258
|
+
| \`set(patch)\` | Record a decision for exactly these categories. |
|
|
4259
|
+
| \`open()\` | Re-show the banner without discarding the current choice. |
|
|
4260
|
+
| \`reset()\` | Forget the choice and re-prompt. |
|
|
4261
|
+
|
|
4262
|
+
⚠️ If you want a side effect to be *possible*, you must \`require()\` it. Calling \`granted()\`
|
|
4263
|
+
and doing nothing else is a **permanent no-op** on any site where nothing else demands that
|
|
4264
|
+
category: it is never offered, never granted, and your write never happens — silently.
|
|
4265
|
+
|
|
4266
|
+
## An absent gate means "store freely"
|
|
4267
|
+
|
|
4268
|
+
\`consent.js\` and \`elements.js\` are both deferred with no ordering between them, so a component
|
|
4269
|
+
can upgrade and be clicked before the gate exists. \`<Head />\` therefore emits a synchronous
|
|
4270
|
+
inline **stub** that answers \`false\` and queues; the runtime replays the queue on load.
|
|
4271
|
+
|
|
4272
|
+
That makes the absence of \`window.vitopsConsent\` meaningful: it reliably means **this site has
|
|
4273
|
+
no gate**. Read it as *store freely* — never as *denied*.
|
|
4274
|
+
|
|
4275
|
+
Because the stub is not the full API, listen for the \`vitops:consent\` event rather than calling
|
|
4276
|
+
\`subscribe()\`, which the stub does not have.
|
|
4277
|
+
|
|
4278
|
+
## Three-valued, and why it matters
|
|
4279
|
+
|
|
4280
|
+
The cookie (\`vitops_consent\`, v2) records each category as \`true\` / \`false\` / **\`null\`**.
|
|
4281
|
+
"Not asked" is a third value, not a synonym for "declined" — and only \`null\` can be
|
|
4282
|
+
re-prompted. That is what lets a \`preferences\` demand arrive *after* an \`analytics\` prompt was
|
|
4283
|
+
already answered.
|
|
4284
|
+
|
|
4285
|
+
Three consequences:
|
|
4286
|
+
|
|
4287
|
+
- **Nothing is stored until the visitor chooses.** Absence of the cookie is *undecided*, and
|
|
4288
|
+
undecided denies everything but \`necessary\`. If merely showing the banner wrote state, the
|
|
4289
|
+
banner would be the thing it asks permission for.
|
|
4290
|
+
- **A corrupt or wrong-version cookie re-prompts.** It does not read as "decided, all denied" —
|
|
4291
|
+
that safe-looking read strands a visitor who wants to opt in with no way to say so.
|
|
4292
|
+
- **A patch must cover exactly the categories a showing put on screen.** \`<wc-consent>\` builds
|
|
4293
|
+
its own patch rather than calling \`acceptAll()\`, because "Accept" on a preferences-only
|
|
4294
|
+
prompt that also granted analytics would be consent nobody gave. \`acceptAll()\` /
|
|
4295
|
+
\`rejectAll()\` mean *literally every* optional category — widening a patch to "all" is the
|
|
4296
|
+
easy version of this bug.
|
|
4297
|
+
|
|
4298
|
+
## Revoking reloads
|
|
4299
|
+
|
|
4300
|
+
An already-executing tracker cannot be unloaded. Clearing its cookies only stops it identifying
|
|
4301
|
+
the visitor *next* time, while the running instance keeps reporting until the document goes
|
|
4302
|
+
away — so a revoke clears the named cookies and **reloads**. (\`reloadOnRevoke = false\` defers
|
|
4303
|
+
that to the next navigation.)
|
|
4304
|
+
|
|
4305
|
+
## The notice must match what loads
|
|
4306
|
+
|
|
4307
|
+
The same config facts drive the **generated cookie notice** — see
|
|
4308
|
+
[legal.md](legal.md). \`site.analytics.clarityId\` is what makes the notice name Clarity; the
|
|
4309
|
+
same provider in \`vitops({ analytics })\` is what makes the tag load. The Astro integration
|
|
4310
|
+
**warns when the two disagree**, because a site running a tag its own notice omits is a
|
|
4311
|
+
compliance defect, not a documentation gap.
|
|
4312
|
+
|
|
4313
|
+
## Reference consumer
|
|
4314
|
+
|
|
4315
|
+
\`<wc-color-scheme-toggle>\` applies the chosen scheme **immediately** and gates only the
|
|
4316
|
+
\`localStorage\` write. Nothing about honouring a visitor's click needs permission; remembering
|
|
4317
|
+
it does.
|
|
4318
|
+
`;
|
|
4319
|
+
}
|
|
4320
|
+
function renderTrackingConcept() {
|
|
4321
|
+
return `${frontmatter({
|
|
4322
|
+
type: "Design Concept",
|
|
4323
|
+
title: "Vitops conversion tracking — ad attribution and notifications",
|
|
4324
|
+
description: "How an ad click becomes a notified conversion: the `_ac` cookie, consent-demanding capture, the pure notification planner, and the Cloudflare email channel.",
|
|
4325
|
+
resource: CONFIG_PATH,
|
|
4326
|
+
tags: [
|
|
4327
|
+
"tracking",
|
|
4328
|
+
"attribution",
|
|
4329
|
+
"conversions",
|
|
4330
|
+
"notifications",
|
|
4331
|
+
"consent"
|
|
4332
|
+
]
|
|
4333
|
+
})}
|
|
4334
|
+
|
|
4335
|
+
# Conversion tracking
|
|
4336
|
+
|
|
4337
|
+
A visitor arrives on an ad carrying a click ID; \`<Tracking />\` captures it into the
|
|
4338
|
+
first-party **\`_ac\`** cookie; when they later submit a form or tap a \`tel:\` link,
|
|
4339
|
+
\`createConversionRoute()\` reads the cookie back and notifies whoever the config names.
|
|
4340
|
+
|
|
4341
|
+
\`\`\`json
|
|
4342
|
+
{
|
|
4343
|
+
"site": {
|
|
4344
|
+
"tracking": { "enabled": true, "category": "marketing" },
|
|
4345
|
+
"notifications": { "email": "leads@example.com" }
|
|
4346
|
+
}
|
|
4347
|
+
}
|
|
4348
|
+
\`\`\`
|
|
4349
|
+
|
|
4350
|
+
A bare address is shorthand for \`{ provider: "cloudflare", to }\`. The recipient otherwise
|
|
4351
|
+
falls back to the primary location's email, and the sender to \`noreply@<domains.canonical>\`.
|
|
4352
|
+
|
|
4353
|
+
## Where each piece lives
|
|
4354
|
+
|
|
4355
|
+
| Layer | Module | Why there |
|
|
4356
|
+
| --- | --- | --- |
|
|
4357
|
+
| Attribution vocabulary + cookie | \`@getvitops/utils/tracking\` | Needed on **both** sides of the wire |
|
|
4358
|
+
| Plan / render / send | \`@getvitops/utils/notify\` | Pure planner, I/O sender |
|
|
4359
|
+
| Capture script, \`<Tracking />\`, route factory | \`@getvitops/astro\` | Beside the analytics components |
|
|
4360
|
+
|
|
4361
|
+
Both utils entries are **separate subpaths** because they are the only modules that run in a
|
|
4362
|
+
**Worker** rather than at build time. Keeping them off the package index is what stops a
|
|
4363
|
+
conversion endpoint pulling \`sharp\` into its bundle. Neither may use a Node builtin.
|
|
4364
|
+
|
|
4365
|
+
## The capture demands consent
|
|
4366
|
+
|
|
4367
|
+
\`_ac\` is a 90-day identifier tying a visitor to an ad, so it waits on \`marketing\` (override
|
|
4368
|
+
with \`site.tracking.category\`).
|
|
4369
|
+
|
|
4370
|
+
⚠️ The script calls **\`require()\`**, not \`granted()\` — \`require()\` is what *raises the
|
|
4371
|
+
banner*. A passive \`granted()\` here is a permanent no-op: nothing else on a page demands
|
|
4372
|
+
\`marketing\`, so it is never offered, never granted, and \`_ac\` is never written — silently, on
|
|
4373
|
+
every gated site. The integration adds \`marketing\` to the offered categories when tracking is
|
|
4374
|
+
on, so there is a row for the category the script will ask about.
|
|
4375
|
+
|
|
4376
|
+
**Only an arrival that carried something asks.** The demand is guarded on the URL actually
|
|
4377
|
+
holding a click ID or UTM, so an organic visitor — who has nothing to attribute — is never
|
|
4378
|
+
interrupted. That is demand-driven consent applied to attribution.
|
|
4379
|
+
|
|
4380
|
+
**The capture is synchronous; only the write waits.** Reading the query string is not storage
|
|
4381
|
+
and needs no permission; *keeping* it does. The click ID is in the URL only on the landing
|
|
4382
|
+
page, so deferring the read would lose it outright.
|
|
4383
|
+
|
|
4384
|
+
The marker element carries \`data-consent\` but deliberately **not** \`data-vitops-tag\`: the
|
|
4385
|
+
scan never tries to "activate" it (it is ungated by design), while the revoke path — which
|
|
4386
|
+
queries \`[data-consent="…"]\` — still finds it and clears \`_ac\`.
|
|
4387
|
+
|
|
4388
|
+
## The event is the abstraction
|
|
4389
|
+
|
|
4390
|
+
\`ConversionEvent\` is the *fact*; how it reads belongs to the channel. That is what lets an SMS
|
|
4391
|
+
channel render 160 characters from the same event an email renders in full.
|
|
4392
|
+
|
|
4393
|
+
**The plan is pure and says why anything is skipped.** \`planNotifications\` touches no network
|
|
4394
|
+
and no binding, so a misconfigured site can be told exactly why no notification will arrive — a
|
|
4395
|
+
silently unsent conversion notification is indistinguishable from no conversion.
|
|
4396
|
+
|
|
4397
|
+
## The email channel
|
|
4398
|
+
|
|
4399
|
+
Cloudflare Email Sending's **current** binding — structured
|
|
4400
|
+
\`env.EMAIL.send({ to, from, subject, html, text })\`, not the legacy \`EmailMessage\` plus
|
|
4401
|
+
hand-built MIME. The binding is **passed in, never imported**, so utils takes no Cloudflare
|
|
4402
|
+
dependency.
|
|
4403
|
+
|
|
4404
|
+
Only transient codes are retried. \`E_SENDER_NOT_VERIFIED\` and friends are surfaced
|
|
4405
|
+
**verbatim**, because nothing here can check whether the sending domain was onboarded — run:
|
|
4406
|
+
|
|
4407
|
+
\`\`\`sh
|
|
4408
|
+
wrangler email sending enable <domain>
|
|
4409
|
+
\`\`\`
|
|
4410
|
+
|
|
4411
|
+
A generic "send failed" would hide the one thing worth knowing.
|
|
4412
|
+
|
|
4413
|
+
Only \`email\` is implemented. \`sms\` and \`persist\` are a planned seam
|
|
4414
|
+
(\`NotificationsConfig\` plus a sender with \`sendEmail\`'s signature); one channel is not enough
|
|
4415
|
+
to know what the abstraction should be.
|
|
4416
|
+
|
|
4417
|
+
## Disclosure
|
|
4418
|
+
|
|
4419
|
+
\`_ac\` is disclosed by the generated cookie notice as a first-party cookie. It has to be stated
|
|
4420
|
+
explicitly: no provider table would ever name a first-party cookie, so a site running
|
|
4421
|
+
attribution alongside a **cookieless** analytics provider would otherwise be described as
|
|
4422
|
+
setting no cookies at all. See [legal.md](legal.md).
|
|
4423
|
+
`;
|
|
4424
|
+
}
|
|
4425
|
+
function renderSearchConcept() {
|
|
4426
|
+
return `${frontmatter({
|
|
4427
|
+
type: "Design Concept",
|
|
4428
|
+
title: "Vitops search — Search Console onboarding and deploy notification",
|
|
4429
|
+
description: "What search engines actually accept, why the Indexing API is deliberately not wired, and how `vitops search setup` and `vitops search notify` work.",
|
|
4430
|
+
resource: CONFIG_PATH,
|
|
4431
|
+
tags: [
|
|
4432
|
+
"seo",
|
|
4433
|
+
"search-console",
|
|
4434
|
+
"indexnow",
|
|
4435
|
+
"sitemap",
|
|
4436
|
+
"indexing"
|
|
4437
|
+
]
|
|
4438
|
+
})}
|
|
4439
|
+
|
|
4440
|
+
# Search
|
|
4441
|
+
|
|
4442
|
+
Two commands, both anchored to a full config:
|
|
4443
|
+
|
|
4444
|
+
- **\`vitops search notify\`** — tell search engines a deploy happened (from \`site.seo.indexing\`).
|
|
4445
|
+
- **\`vitops search setup\`** — onboard domains into Search Console (from \`site.searchConsole\`).
|
|
4446
|
+
|
|
4447
|
+
## Start from what engines actually accept
|
|
4448
|
+
|
|
4449
|
+
The obvious assumption is wrong, and every design decision here follows from that:
|
|
4450
|
+
|
|
4451
|
+
- **Google exposes no "request indexing" API.** The button in the Search Console UI is not in
|
|
4452
|
+
the Search Console API or anywhere else, and the **URL Inspection API is read-only**.
|
|
4453
|
+
- **The sitemap ping endpoint was removed in June 2023.** \`google.com/ping?sitemap=\` is a no-op.
|
|
4454
|
+
- **The Indexing API is scoped to \`JobPosting\` / \`BroadcastEvent\`.** It accepts other URLs and
|
|
4455
|
+
discards them; general use violates its terms, with *your own* GCP project on the line.
|
|
4456
|
+
⚠️ **Deliberately not wired. Do not add it.**
|
|
4457
|
+
- **Google does not participate in IndexNow.** Bing, Yandex, Naver, Seznam and Yep do.
|
|
4458
|
+
|
|
4459
|
+
So \`search notify\` does every sanctioned thing and then **verifies**: resubmit the sitemap,
|
|
4460
|
+
ping IndexNow, and \`--check\` inspects \`priorityUrls\` and exits non-zero on one Google hasn't
|
|
4461
|
+
indexed. That last part is what actually replaces the manual Search Console visit.
|
|
4462
|
+
|
|
4463
|
+
⚠️ Never describe this as making Google re-index faster. It cannot.
|
|
4464
|
+
|
|
4465
|
+
## \`search notify\`
|
|
4466
|
+
|
|
4467
|
+
- **The pure/I-O split is the point.** The planner decides everything — which URLs, which
|
|
4468
|
+
channels, why each was skipped — and touches no network, no filesystem, no clock. That is what
|
|
4469
|
+
makes \`--dry\` a *complete* account of a run rather than an approximation.
|
|
4470
|
+
- **\`lastmod\` is not a nice-to-have, it is the mechanism.** The changed-URL diff compares each
|
|
4471
|
+
sitemap entry's \`<lastmod>\` against a stored snapshot. With no lastmod the diff can see pages
|
|
4472
|
+
appear and disappear but **never see one change** — so an edited page is never resubmitted, and
|
|
4473
|
+
the command looks healthy while doing less than it appears to. It counts lastmod-less entries
|
|
4474
|
+
and says so every run.
|
|
4475
|
+
|
|
4476
|
+
\`gitLastmod()\` in \`@getvitops/astro\` derives real dates from \`git log\`. It is an exported
|
|
4477
|
+
helper rather than a \`sitemap\` option because it shells out to git and returns **nothing**
|
|
4478
|
+
from a shallow CI clone (\`fetch-depth: 1\`). It leaves an unmatched URL alone rather than
|
|
4479
|
+
stamping the build time: Google weighs lastmod only while it stays consistent with what
|
|
4480
|
+
actually changed, so a site that stamps every page every deploy teaches it to distrust the
|
|
4481
|
+
field site-wide.
|
|
4482
|
+
- **The \`noindex\` gate reads the environment, so the URLs must too.** The run is refused
|
|
4483
|
+
entirely when the resolved environment's \`robots\` contains \`noindex\` — submitting a staging
|
|
4484
|
+
host to IndexNow publishes it to several engines and invites them to crawl it, which a later
|
|
4485
|
+
directive does not undo. Origins therefore derive from \`site.environments[env].url\` **before**
|
|
4486
|
+
\`domains.canonical\`: the canonical is the *production* origin, so deriving from it while
|
|
4487
|
+
notifying staging would submit production URLs the gate would not catch.
|
|
4488
|
+
- **Verify the IndexNow key file before submitting.** A submission whose key file is unreachable
|
|
4489
|
+
returns \`403\`, but one whose key file is *reachable and stale* is accepted with \`202\` and then
|
|
4490
|
+
silently discarded. Only a prior GET distinguishes "submitted" from "submitted and ignored".
|
|
4491
|
+
The key is **not a secret** — the engine fetching it back is the ownership proof — so it lives
|
|
4492
|
+
in the config, and the Astro integration writes \`public/<key>.txt\` from it.
|
|
4493
|
+
- **Write the snapshot last, and only on success.** Writing it eagerly records URLs as notified
|
|
4494
|
+
that never were; because the next run diffs against it, one transient \`503\` would drop those
|
|
4495
|
+
pages from every future run — silently and permanently. A corrupt or absent snapshot reads as
|
|
4496
|
+
"submit everything, and say so", never as "nothing changed".
|
|
4497
|
+
|
|
4498
|
+
## \`search setup\`
|
|
4499
|
+
|
|
4500
|
+
Automates the otherwise-manual DNS-paste / wait / verify / add-property dance.
|
|
4501
|
+
\`site.searchConsole\` is keyed by bare hostname (mirroring \`site.dns\`):
|
|
4502
|
+
|
|
4503
|
+
\`\`\`json
|
|
4504
|
+
{
|
|
4505
|
+
"site": {
|
|
4506
|
+
"searchConsole": {
|
|
4507
|
+
"example.com": {
|
|
4508
|
+
"delegatedOwners": ["dev@example.com"],
|
|
4509
|
+
"fullUserGroup": "seo@example.com"
|
|
4510
|
+
}
|
|
4511
|
+
}
|
|
4512
|
+
}
|
|
4513
|
+
}
|
|
4514
|
+
\`\`\`
|
|
4515
|
+
|
|
4516
|
+
Per domain it ensures the apex verification TXT in Cloudflare, verifies ownership via the Site
|
|
4517
|
+
Verification API (\`DNS_TXT\`), adds the \`sc-domain:\` property, then adds any
|
|
4518
|
+
\`delegatedOwners\`. The verification token is fetched live and written to DNS — never stored in
|
|
4519
|
+
the config.
|
|
4520
|
+
|
|
4521
|
+
- **Idempotency lives in the planner.** A step whose desired state already holds resolves to
|
|
4522
|
+
\`skip\`, so re-running an onboarded domain is a no-op **by construction**, not because the
|
|
4523
|
+
executors check twice. \`--check\` reports drift, exits non-zero and mutates nothing;
|
|
4524
|
+
\`--dry\` prints the plan and stops.
|
|
4525
|
+
- **DNS is only ever created, never edited or deleted.** The command never removes a record, so
|
|
4526
|
+
the Cloudflare executor simply has no update or delete verb.
|
|
4527
|
+
- **Verification retries with backoff, then reports PENDING — not failed.** \`DNS_TXT\`
|
|
4528
|
+
verification fails until the record propagates, which is slow, not broken. A domain still
|
|
4529
|
+
unverified after the last attempt is \`PENDING\`, and its property and owner steps are skipped
|
|
4530
|
+
for that run. Re-run later.
|
|
4531
|
+
- **Search Console has no user/permission API.** Adding a Google Group as a **Full User** is
|
|
4532
|
+
genuinely un-automatable, so \`fullUserGroup\` is surfaced as a **reminder** in the summary,
|
|
4533
|
+
never attempted. That is distinct from \`delegatedOwners\`, which *are* automated — those are
|
|
4534
|
+
Site Verification web-resource owners (an additive union, so an existing owner is never
|
|
4535
|
+
dropped), a different concept from Search Console property users. Don't conflate them.
|
|
4536
|
+
|
|
4537
|
+
## Credentials
|
|
4538
|
+
|
|
4539
|
+
Always from the environment; never in the config. There is **one** token exchange and **two
|
|
4540
|
+
grants**, tracking where each command runs:
|
|
4541
|
+
|
|
4542
|
+
| Grant | Env vars | Used by |
|
|
4543
|
+
| --- | --- | --- |
|
|
4544
|
+
| **Service account** (JWT bearer) | \`VITOPS_GSC_SERVICE_ACCOUNT\` (inline JSON) or \`GOOGLE_APPLICATION_CREDENTIALS\` (path) | \`search notify\` — never expires, right for CI |
|
|
4545
|
+
| **User OAuth** (refresh token) | \`VITOPS_GOOGLE_CLIENT_ID\` / \`_CLIENT_SECRET\` / \`_REFRESH_TOKEN\` | \`search setup\` — **required** |
|
|
4546
|
+
|
|
4547
|
+
Cloudflare uses \`CLOUDFLARE_API_TOKEN\` (a \`Zone:DNS:Edit\` token; the standard "Edit zone DNS"
|
|
4548
|
+
template also carries the \`Zone:Read\` the zone-by-name lookup needs).
|
|
4549
|
+
|
|
4550
|
+
\`search setup\` requires user OAuth because **verifying a site makes the caller an owner** of
|
|
4551
|
+
the property, and that should be a person, not a project robot. Note a refresh token can be
|
|
4552
|
+
revoked, and for an OAuth client still in *Testing* publishing status Google expires it after
|
|
4553
|
+
**7 days** — fine for a one-time human setup, bad for a deploy step.
|
|
4554
|
+
|
|
4555
|
+
**\`search notify\` accepts either**, preferring the service account when both are set. Search
|
|
4556
|
+
Console does not care which identity calls it, and someone who has run \`search setup\` already
|
|
4557
|
+
holds a Google credential.
|
|
4558
|
+
|
|
4559
|
+
⚠️ Do not add \`googleapis\` — an enormous dependency for a handful of REST endpoints in a CLI
|
|
4560
|
+
that installs into every consumer project. The JWT is minted with ~30 lines of \`node:crypto\`.
|
|
4561
|
+
`;
|
|
4562
|
+
}
|
|
4563
|
+
function renderLegalConcept() {
|
|
4564
|
+
return `${frontmatter({
|
|
4565
|
+
type: "Design Concept",
|
|
4566
|
+
title: "Vitops legal documents — generated policy, terms and cookie notice",
|
|
4567
|
+
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.",
|
|
4568
|
+
resource: CONFIG_PATH,
|
|
4569
|
+
tags: [
|
|
4570
|
+
"legal",
|
|
4571
|
+
"privacy",
|
|
4572
|
+
"cookies",
|
|
4573
|
+
"pipeda",
|
|
4574
|
+
"compliance"
|
|
4575
|
+
]
|
|
4576
|
+
})}
|
|
4577
|
+
|
|
4578
|
+
# Legal documents
|
|
4579
|
+
|
|
4580
|
+
A privacy policy, terms of service and cookie notice, rendered from a full config: the company
|
|
4581
|
+
from \`organization\`, what the site actually *does* from \`site\`.
|
|
4582
|
+
|
|
4583
|
+
It is a **sibling of the docs generator, not a \`generate()\` format** — structurally, not
|
|
4584
|
+
stylistically. \`generate()\` is keyed to a design system, so a "legal format" would be a format
|
|
4585
|
+
that ignores its own input.
|
|
4586
|
+
|
|
4587
|
+
## The governing rule
|
|
4588
|
+
|
|
4589
|
+
**The config records facts; the template owns prose.**
|
|
4590
|
+
|
|
4591
|
+
Nothing in the derivation writes a sentence a lawyer would review, and no template invents a
|
|
4592
|
+
fact. That is what lets wording be corrected without touching your config, and your provider
|
|
4593
|
+
change land without touching prose.
|
|
4594
|
+
|
|
4595
|
+
⚠️ It also means **the fix for a wrong policy is a corrected config.** Hand-editing the output
|
|
4596
|
+
is overwritten by the next build.
|
|
4597
|
+
|
|
4598
|
+
## What derives from what
|
|
4599
|
+
|
|
4600
|
+
- **The provider table is what makes derivation possible.** A policy naming Plausible while the
|
|
4601
|
+
site runs GA is a compliance defect, not a typo — so the provider comes from *which analytics
|
|
4602
|
+
ID is set*, whether \`site.security.turnstile.siteKey\` exists, and what
|
|
4603
|
+
\`site.deployment.platform\` says. Never from a hand-maintained string.
|
|
4604
|
+
|
|
4605
|
+
It covers only what the schema can imply. Everything else — payment, CRM, mail — is declared
|
|
4606
|
+
in \`site.legal.privacyPolicy.processors\` and flows through the same pipeline.
|
|
4607
|
+
|
|
4608
|
+
- **\`cookies: []\` is meaningfully different from \`undefined\`.** It *asserts* a provider is
|
|
4609
|
+
cookieless (Plausible), which the cookie notice states **positively** rather than omitting.
|
|
4610
|
+
|
|
4611
|
+
- **"Stored in" is not the same fact as "reachable by".** A processor's \`storage\` is where the
|
|
4612
|
+
information rests; \`operatorCountry\` is the jurisdiction that can compel the provider to hand
|
|
4613
|
+
it over. Privacy law turns on foreign **access**, not merely foreign storage, so the two get
|
|
4614
|
+
separate sentences — an Azure tenant in a Canadian region never moves the data and is still
|
|
4615
|
+
subject to US law. \`country\` is shorthand asserting both, and combining it with either is
|
|
4616
|
+
**rejected** rather than resolved by a silent rule: whether it narrows or adds is a
|
|
4617
|
+
contradiction between two legal claims, not a formatting choice.
|
|
4618
|
+
|
|
4619
|
+
A \`storage\` entry may be \`scope\`d to a category, which is what makes a Canadian tenant
|
|
4620
|
+
holding identity data abroad expressible. A country in the policy's own jurisdiction is not a
|
|
4621
|
+
transfer and is filtered out — that is what stopped \`country: "Canada"\` rendering "outside of
|
|
4622
|
+
Canada, including Canada". Only what the config states is asserted: hosts like Cloudflare
|
|
4623
|
+
declare an \`operatorCountry\` and **no** storage, because anycast means the config cannot know
|
|
4624
|
+
which region served a request, and "we don't know" is a fact.
|
|
4625
|
+
|
|
4626
|
+
- **Form templates are the PII inventory.** \`site.templates\` entries of type \`form\` are the
|
|
4627
|
+
only place the config says what personal information the site actually collects, so the
|
|
4628
|
+
disclosed list derives from their fields. \`hidden\` fields and honeypots are **excluded** —
|
|
4629
|
+
neither is visitor-supplied, and describing them as collected would be untrue.
|
|
4630
|
+
|
|
4631
|
+
- **First-party cookies are declared, not detected.** The attribution cookie \`_ac\` is disclosed
|
|
4632
|
+
this way; see [tracking.md](tracking.md).
|
|
4633
|
+
|
|
4634
|
+
## The markdown subset is closed
|
|
4635
|
+
|
|
4636
|
+
We author every template, so the renderer is exactly as capable as they are: \`#\`/\`##\`/\`###\`,
|
|
4637
|
+
\`- \` bullets, \`> \` quote, \`**strong**\`, \`\\\`code\\\`\`. **An unsupported construct is an error,
|
|
4638
|
+
not a silent degrade** — that is what stops a literal \`| --- |\` reaching a published page.
|
|
4639
|
+
|
|
4640
|
+
Portable Text maps the \`> \` quote to a banner block and **drops the \`# \` heading**, which is
|
|
4641
|
+
EmDash's own title field.
|
|
4642
|
+
|
|
4643
|
+
## Jurisdictions
|
|
4644
|
+
|
|
4645
|
+
Adding one is: author three templates, add one enum member, add one registry key. The two are
|
|
4646
|
+
checked against each other at compile time, so skipping either **fails to compile** rather than
|
|
4647
|
+
rendering against the wrong body of law.
|
|
4648
|
+
|
|
4649
|
+
⚠️ Only **\`ca\` (PIPEDA)** ships. Its prose names the Office of the Privacy Commissioner of
|
|
4650
|
+
Canada and frames transfers as "outside of Canada" — **do not reuse it for another
|
|
4651
|
+
jurisdiction.**
|
|
4652
|
+
|
|
4653
|
+
## Delivery: one renderer, four consumers
|
|
4654
|
+
|
|
4655
|
+
| Consumer | How |
|
|
4656
|
+
| --- | --- |
|
|
4657
|
+
| **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. |
|
|
4658
|
+
| **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. |
|
|
4659
|
+
| **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. |
|
|
4660
|
+
| **EmDash** | \`--format portable-text\`, pasted into the admin. |
|
|
4661
|
+
|
|
4662
|
+
The CLI is the load-bearing one: it is the surface every consumer has regardless of stack.
|
|
4663
|
+
|
|
4664
|
+
## The review banner
|
|
4665
|
+
|
|
4666
|
+
Every document opens with a **non-optional** review banner. These are rendered from a template
|
|
4667
|
+
by a build tool; the one failure mode with real consequences is a consumer publishing one as-is.
|
|
4668
|
+
`;
|
|
4669
|
+
}
|
|
3262
4670
|
function renderConceptsIndex() {
|
|
3263
4671
|
return `${INDEX_NOTE}
|
|
3264
4672
|
|
|
@@ -3269,7 +4677,12 @@ function renderConceptsIndex() {
|
|
|
3269
4677
|
* [Colour system](color.md) - seeded OKLCH scales on a shared lightness ladder, target-prefixed tokens, automatic dark mode
|
|
3270
4678
|
* [Type & space scales](scales.md) - fluid modular scales and the tokens they emit
|
|
3271
4679
|
* [Component patterns](patterns.md) - token cascade, override hooks, states, role variants
|
|
4680
|
+
* [Components](components.md) - which of the four tiers provides each pattern, and the call to make
|
|
3272
4681
|
* [Icons](icons.md) - semantic names across icon sets, bundle derivation, sprite delivery
|
|
4682
|
+
* [Consent gate](consent.md) - inert gated tags, demand-driven prompting, three-valued choices
|
|
4683
|
+
* [Conversion tracking](tracking.md) - ad-click attribution, the \`_ac\` cookie, conversion notifications
|
|
4684
|
+
* [Search](search.md) - what engines accept, Search Console onboarding, deploy notification
|
|
4685
|
+
* [Legal documents](legal.md) - policy/terms/cookie notice derived from config facts
|
|
3273
4686
|
`;
|
|
3274
4687
|
}
|
|
3275
4688
|
function renderCssIndex() {
|
|
@@ -3318,7 +4731,12 @@ function generateDocs(ds, assetsDir) {
|
|
|
3318
4731
|
"concepts/color.md": renderColorConcept(ds),
|
|
3319
4732
|
"concepts/scales.md": renderScalesConcept(ds),
|
|
3320
4733
|
"concepts/patterns.md": renderPatternsConcept(ds),
|
|
4734
|
+
"concepts/components.md": renderComponentsConcept(ds),
|
|
3321
4735
|
"concepts/icons.md": renderIconsConcept(),
|
|
4736
|
+
"concepts/consent.md": renderConsentConcept(),
|
|
4737
|
+
"concepts/tracking.md": renderTrackingConcept(),
|
|
4738
|
+
"concepts/search.md": renderSearchConcept(),
|
|
4739
|
+
"concepts/legal.md": renderLegalConcept(),
|
|
3322
4740
|
"css/index.md": renderCssIndex(),
|
|
3323
4741
|
"css/classes.md": renderCssClasses(ds),
|
|
3324
4742
|
"bricks/index.md": renderBricksIndex(),
|
|
@@ -3757,6 +5175,26 @@ across every pattern. Focus rings are never removed, only restyled.
|
|
|
3757
5175
|
//#endregion
|
|
3758
5176
|
//#region src/legal/providers.ts
|
|
3759
5177
|
/**
|
|
5178
|
+
* What the config already tells us about third parties that receive personal
|
|
5179
|
+
* information.
|
|
5180
|
+
*
|
|
5181
|
+
* A privacy policy's riskiest sentence is the one naming service providers: if
|
|
5182
|
+
* it says "Plausible" while the site runs Google Analytics, the document is a
|
|
5183
|
+
* compliance defect, not a typo. The site config already records which analytics
|
|
5184
|
+
* ID is set, whether Turnstile is deployed and where the site is hosted — so the
|
|
5185
|
+
* sentence derives from those signals rather than from a hand-maintained string
|
|
5186
|
+
* that drifts the moment someone swaps providers.
|
|
5187
|
+
*
|
|
5188
|
+
* Presence of the key is the signal, matching how `analytics` already works
|
|
5189
|
+
* (provider identity is implied by which ID is set, not by an enum).
|
|
5190
|
+
*
|
|
5191
|
+
* This table is deliberately small and covers only what the schema can imply.
|
|
5192
|
+
* Everything else — payment processors, CRMs, mail senders — is declared by the
|
|
5193
|
+
* consumer in `legal.privacyPolicy.processors`, which flows through the same
|
|
5194
|
+
* pipeline. This is not a directory of the web; it's the set of providers the
|
|
5195
|
+
* toolchain itself can vouch for.
|
|
5196
|
+
*/
|
|
5197
|
+
/**
|
|
3760
5198
|
* The providers the config can imply. Keyed for testability — a test can assert
|
|
3761
5199
|
* a given config resolves to a given key without matching on prose.
|
|
3762
5200
|
*/
|
|
@@ -3810,28 +5248,28 @@ const KNOWN_PROCESSORS = {
|
|
|
3810
5248
|
turnstile: {
|
|
3811
5249
|
name: "Cloudflare Turnstile",
|
|
3812
5250
|
purpose: "bot protection on forms",
|
|
3813
|
-
|
|
5251
|
+
operatorCountry: "the United States",
|
|
3814
5252
|
privacyUrl: "https://www.cloudflare.com/privacypolicy/",
|
|
3815
5253
|
cookies: ["cf_clearance"]
|
|
3816
5254
|
},
|
|
3817
5255
|
cloudflare: {
|
|
3818
5256
|
name: "Cloudflare",
|
|
3819
5257
|
purpose: "website hosting and content delivery",
|
|
3820
|
-
|
|
5258
|
+
operatorCountry: "the United States",
|
|
3821
5259
|
privacyUrl: "https://www.cloudflare.com/privacypolicy/",
|
|
3822
5260
|
cookies: []
|
|
3823
5261
|
},
|
|
3824
5262
|
vercel: {
|
|
3825
5263
|
name: "Vercel",
|
|
3826
5264
|
purpose: "website hosting",
|
|
3827
|
-
|
|
5265
|
+
operatorCountry: "the United States",
|
|
3828
5266
|
privacyUrl: "https://vercel.com/legal/privacy-policy",
|
|
3829
5267
|
cookies: []
|
|
3830
5268
|
},
|
|
3831
5269
|
netlify: {
|
|
3832
5270
|
name: "Netlify",
|
|
3833
5271
|
purpose: "website hosting",
|
|
3834
|
-
|
|
5272
|
+
operatorCountry: "the United States",
|
|
3835
5273
|
privacyUrl: "https://www.netlify.com/privacy/",
|
|
3836
5274
|
cookies: []
|
|
3837
5275
|
}
|
|
@@ -3872,9 +5310,81 @@ function detectProcessorKeys(cfg) {
|
|
|
3872
5310
|
* an explicit declaration is a statement of fact we have no grounds to drop.
|
|
3873
5311
|
*/
|
|
3874
5312
|
function resolveProcessors(cfg) {
|
|
3875
|
-
const detected = detectProcessorKeys(cfg).map((k) =>
|
|
5313
|
+
const detected = detectProcessorKeys(cfg).map((k) => matomoLocation(KNOWN_PROCESSORS[k], cfg.site));
|
|
3876
5314
|
const declared = cfg.site.legal?.privacyPolicy?.processors ?? [];
|
|
3877
|
-
return [
|
|
5315
|
+
return [
|
|
5316
|
+
...detected,
|
|
5317
|
+
...adProcessors(cfg),
|
|
5318
|
+
...declared
|
|
5319
|
+
].map(desugarCountry);
|
|
5320
|
+
}
|
|
5321
|
+
/**
|
|
5322
|
+
* The ad platforms `site.ads` links this site to, as processors.
|
|
5323
|
+
*
|
|
5324
|
+
* Derived from `AD_PLATFORMS` rather than written out here, because the cookie
|
|
5325
|
+
* names, the privacy URL and the opt-out are the *same facts* `vitops ads tags`
|
|
5326
|
+
* writes into `data-consent-cookies` — and the one failure that matters is those
|
|
5327
|
+
* two disagreeing. A pixel that sets `_fbp` while the notice omits it is a
|
|
5328
|
+
* compliance defect, and a revoke that clears a cookie the notice never mentioned
|
|
5329
|
+
* is the same defect seen from the other end.
|
|
5330
|
+
*
|
|
5331
|
+
* `enabled: false` keeps a property on record without emitting its tag, so nothing
|
|
5332
|
+
* is set and nothing is disclosed. Everything else with an id is live.
|
|
5333
|
+
*/
|
|
5334
|
+
function adProcessors(cfg) {
|
|
5335
|
+
const ads = cfg.site.ads ?? {};
|
|
5336
|
+
return AD_PROVIDER_KEYS.filter((provider) => {
|
|
5337
|
+
const entry = ads[provider];
|
|
5338
|
+
return entry != null && entry.enabled !== false && (entry.pixelId ?? entry.accountId) != null;
|
|
5339
|
+
}).map((provider) => {
|
|
5340
|
+
const platform = AD_PLATFORMS[provider];
|
|
5341
|
+
return {
|
|
5342
|
+
name: platform.name,
|
|
5343
|
+
purpose: "advertising measurement and retargeting",
|
|
5344
|
+
operatorCountry: platform.operatorCountry,
|
|
5345
|
+
privacyUrl: platform.tag.privacyUrl,
|
|
5346
|
+
cookies: platform.tag.cookies,
|
|
5347
|
+
...platform.tag.optOut ? { optOut: platform.tag.optOut } : {}
|
|
5348
|
+
};
|
|
5349
|
+
});
|
|
5350
|
+
}
|
|
5351
|
+
/**
|
|
5352
|
+
* `country` → both facts it has always asserted.
|
|
5353
|
+
*
|
|
5354
|
+
* The sentence it fed claimed storage *and* legal reach in one breath ("may be
|
|
5355
|
+
* stored or processed in … including X. As a result … access requests from
|
|
5356
|
+
* governments … in those jurisdictions"). So expanding it to storage alone would
|
|
5357
|
+
* silently retract half of a claim consumers have already published. Expanding to
|
|
5358
|
+
* both is also what keeps the rendered output byte-identical for every config that
|
|
5359
|
+
* used the shorthand.
|
|
5360
|
+
*
|
|
5361
|
+
* `country` is left in place — it is public API and `PolicyVars.countries` still
|
|
5362
|
+
* reports it. When the explicit fields are present they win; `validateConfig`
|
|
5363
|
+
* rejects that combination, so it is unreachable through a validated config, but
|
|
5364
|
+
* `resolveProcessors` is exported and needs a defined answer regardless.
|
|
5365
|
+
*/
|
|
5366
|
+
function desugarCountry(p) {
|
|
5367
|
+
if (!p.country) return p;
|
|
5368
|
+
return {
|
|
5369
|
+
...p,
|
|
5370
|
+
storage: p.storage ?? [{ country: p.country }],
|
|
5371
|
+
operatorCountry: p.operatorCountry ?? p.country
|
|
5372
|
+
};
|
|
5373
|
+
}
|
|
5374
|
+
/**
|
|
5375
|
+
* Processors the config discloses but places nowhere.
|
|
5376
|
+
*
|
|
5377
|
+
* Such a processor cannot appear in the transfer disclosure — there is nothing true
|
|
5378
|
+
* to say about it — and today it vanishes from that section in silence, which is the
|
|
5379
|
+
* tidy-looking failure. Names only, and deliberately NOT on `PolicyVars`: that type
|
|
5380
|
+
* is the facts a template renders from, and a document must not editorialise about
|
|
5381
|
+
* its own gaps. The CLI reports these on stderr instead.
|
|
5382
|
+
*
|
|
5383
|
+
* Not a `validateConfig` error either: a bare `{ name, purpose }` validates today,
|
|
5384
|
+
* so rejecting it would make this a breaking change.
|
|
5385
|
+
*/
|
|
5386
|
+
function processorsMissingLocation(cfg) {
|
|
5387
|
+
return resolveProcessors(cfg).filter((p) => !p.operatorCountry && !p.storage?.length).map((p) => p.name);
|
|
3878
5388
|
}
|
|
3879
5389
|
/**
|
|
3880
5390
|
* Matomo is the one provider whose location the config actually knows.
|
|
@@ -3884,13 +5394,17 @@ function resolveProcessors(cfg) {
|
|
|
3884
5394
|
* country. So the cross-border transfer sentence is asserted only for the case we
|
|
3885
5395
|
* can see. Naming a transfer that isn't happening is the same class of error as
|
|
3886
5396
|
* omitting one that is — the config records facts, and "we don't know" is a fact.
|
|
5397
|
+
*
|
|
5398
|
+
* This sets `storage`, not `operatorCountry`: Matomo Cloud genuinely holds the data
|
|
5399
|
+
* in the EU. InnoCraft is New Zealand-based, but nothing in the config sees that, so
|
|
5400
|
+
* no operator jurisdiction is asserted — the same rule, applied to the other fact.
|
|
3887
5401
|
*/
|
|
3888
|
-
function
|
|
5402
|
+
function matomoLocation(processor, site) {
|
|
3889
5403
|
if (processor.name !== "Matomo") return processor;
|
|
3890
5404
|
const url = site.analytics?.matomo?.url ?? "";
|
|
3891
5405
|
return /\.matomo\.cloud/i.test(url) ? {
|
|
3892
5406
|
...processor,
|
|
3893
|
-
country: "the European Union"
|
|
5407
|
+
storage: [{ country: "the European Union" }]
|
|
3894
5408
|
} : processor;
|
|
3895
5409
|
}
|
|
3896
5410
|
|
|
@@ -4041,21 +5555,43 @@ function derivePolicyVars(cfg) {
|
|
|
4041
5555
|
"improve our Site to better meet your needs"
|
|
4042
5556
|
]);
|
|
4043
5557
|
const ab = site.abTesting;
|
|
5558
|
+
/**
|
|
5559
|
+
* A site offering the `preferences` category stores at least one setting the
|
|
5560
|
+
* visitor chose — `<wc-color-scheme-toggle>` writes `vitops-color-scheme` to local
|
|
5561
|
+
* storage, gated on exactly this category. The banner asking about it while the
|
|
5562
|
+
* notice never mentions it is the disclosure gap `providers.ts` exists to
|
|
5563
|
+
* prevent, just on the browser-storage side rather than the third-party one.
|
|
5564
|
+
*/
|
|
5565
|
+
const hasPreferences = consent?.enabled === true && !!consent.categories?.includes("preferences");
|
|
5566
|
+
/**
|
|
5567
|
+
* `site.tracking.enabled` writes the `_ac` cookie: the click ID from the ad a
|
|
5568
|
+
* visitor followed, kept for 90 days so a later enquiry can be attributed to
|
|
5569
|
+
* the campaign that produced it. It is first-party and never leaves the site,
|
|
5570
|
+
* which is exactly why nothing in the provider table would ever mention it —
|
|
5571
|
+
* and why it has to be stated here instead. A notice that lists every
|
|
5572
|
+
* third-party cookie while omitting the one the site sets itself is the same
|
|
5573
|
+
* disclosure defect, pointed inward.
|
|
5574
|
+
*/
|
|
5575
|
+
const hasAdTracking = site.tracking?.enabled === true;
|
|
4044
5576
|
const cookieTracked = uniq([
|
|
4045
5577
|
"your IP address",
|
|
4046
5578
|
"the type of web browser and operating system used",
|
|
4047
5579
|
"the pages of the Site visited",
|
|
5580
|
+
hasPreferences ? "display settings you have chosen, such as light or dark appearance" : void 0,
|
|
5581
|
+
hasAdTracking ? "which advertisement or campaign brought you to our Site" : void 0,
|
|
4048
5582
|
ab?.enabled ? "which version of a page you were shown" : void 0,
|
|
4049
5583
|
hasSessionReplay ? "how you moved through and interacted with a page" : void 0
|
|
4050
5584
|
]);
|
|
4051
5585
|
const cookiePurposes = uniq([
|
|
4052
5586
|
"keep the Site functioning as you move between pages",
|
|
4053
5587
|
hasAnalytics ? "understand how our Site is used, in aggregate" : void 0,
|
|
5588
|
+
hasPreferences ? "remember display settings you choose, between visits" : void 0,
|
|
5589
|
+
hasAdTracking ? "attribute an enquiry to the advertisement that brought you here, for up to 90 days" : void 0,
|
|
4054
5590
|
hasSessionReplay ? "recognise a returning visit as part of the same session" : void 0,
|
|
4055
5591
|
ab?.enabled ? "keep you on a consistent version of the Site between visits" : void 0,
|
|
4056
5592
|
site.security?.turnstile?.siteKey ? "distinguish visitors from automated traffic" : void 0
|
|
4057
5593
|
]);
|
|
4058
|
-
const cookieTool = consent?.enabled ? consent.type === "opt-in" ? "You may also choose which categories of cookies to allow using the consent banner
|
|
5594
|
+
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
5595
|
const usePurposes = uniq([
|
|
4060
5596
|
"respond to your inquiries",
|
|
4061
5597
|
"supply you with requested products or services",
|
|
@@ -4071,7 +5607,38 @@ function derivePolicyVars(cfg) {
|
|
|
4071
5607
|
...processors.filter((p) => p.optOut).map((p) => `You may opt out of ${p.name} directly at ${p.optOut}.`)
|
|
4072
5608
|
]);
|
|
4073
5609
|
const optOutOptions = uniq(["You may unsubscribe from any commercial e-mail we send using the \"unsubscribe\" link in that e-mail.", ...cookieOptOutOptions]);
|
|
4074
|
-
|
|
5610
|
+
/**
|
|
5611
|
+
* The cross-border disclosure, as two facts rather than one flattened list.
|
|
5612
|
+
*
|
|
5613
|
+
* Order matters here — each step narrows what the next may claim:
|
|
5614
|
+
*
|
|
5615
|
+
* 1. Anything in this jurisdiction is not a transfer at all, so it is filtered
|
|
5616
|
+
* out. Without this, `country: "Canada"` rendered "outside of Canada,
|
|
5617
|
+
* including Canada".
|
|
5618
|
+
* 2. An unscoped storage country is the BROAD claim; a scoped one covering the
|
|
5619
|
+
* same country would only narrow it, so it is dropped as redundant.
|
|
5620
|
+
* 3. An operator country already named as storage adds no fact — "US law reaches
|
|
5621
|
+
* information stored in the US" is a sentence, not information.
|
|
5622
|
+
*
|
|
5623
|
+
* Steps 2 and 3 are what make the rendered output byte-identical for every config
|
|
5624
|
+
* that used `country` (which desugars to both facts).
|
|
5625
|
+
*
|
|
5626
|
+
* No geography is inferred — "France" is not resolved into "the European Union".
|
|
5627
|
+
* Only what the config states is asserted, which is the `matomoLocation` rule.
|
|
5628
|
+
*/
|
|
5629
|
+
const jurisdictionCountry = JURISDICTION_COUNTRIES[legal?.jurisdiction ?? "ca"];
|
|
5630
|
+
const norm = (c) => c.trim().toLowerCase().replace(/^the\s+/, "");
|
|
5631
|
+
const foreign = (c) => norm(c) !== norm(jurisdictionCountry);
|
|
5632
|
+
const allStorage = processors.flatMap((p) => p.storage ?? []);
|
|
5633
|
+
const unscoped = uniq(allStorage.filter((s) => !s.scope && foreign(s.country)).map((s) => s.country));
|
|
5634
|
+
const storageCountries = list(unscoped);
|
|
5635
|
+
const named = new Set(unscoped.map(norm));
|
|
5636
|
+
const scopedStorage = [...new Map(allStorage.filter((s) => s.scope && foreign(s.country) && !named.has(norm(s.country))).map((s) => [`${norm(s.country)}|${s.scope}`, {
|
|
5637
|
+
country: s.country,
|
|
5638
|
+
scope: s.scope
|
|
5639
|
+
}])).values()];
|
|
5640
|
+
const operatorCountries = list(uniq(processors.map((p) => p.operatorCountry)).filter((c) => foreign(c) && !named.has(norm(c))));
|
|
5641
|
+
const countries = list(uniq(processors.flatMap((p) => [...(p.storage ?? []).map((s) => s.country), p.operatorCountry])));
|
|
4075
5642
|
const address = contact?.address ?? org?.address;
|
|
4076
5643
|
return {
|
|
4077
5644
|
businessName,
|
|
@@ -4093,6 +5660,15 @@ function derivePolicyVars(cfg) {
|
|
|
4093
5660
|
optOutOptions,
|
|
4094
5661
|
cookieOptOutOptions,
|
|
4095
5662
|
processors,
|
|
5663
|
+
firstPartyCookies: hasAdTracking ? [{
|
|
5664
|
+
name: "_ac",
|
|
5665
|
+
purpose: "records the advertisement or campaign you arrived from, so an enquiry can be attributed to it",
|
|
5666
|
+
retention: "90 days"
|
|
5667
|
+
}] : [],
|
|
5668
|
+
jurisdictionCountry,
|
|
5669
|
+
storageCountries: storageCountries || void 0,
|
|
5670
|
+
scopedStorage,
|
|
5671
|
+
operatorCountries: operatorCountries || void 0,
|
|
4096
5672
|
countries: countries || void 0,
|
|
4097
5673
|
consentModel: consent?.enabled ? consent.type ?? "opt-in" : "none",
|
|
4098
5674
|
cookieCategories: consent?.categories ?? []
|
|
@@ -4383,18 +5959,28 @@ If you have questions about our use of cookies, please contact us at ${v.mailing
|
|
|
4383
5959
|
* "This site sets no cookies" is a real and increasingly common answer — a
|
|
4384
5960
|
* cookieless analytics provider is a selling point, not an omission. Saying it
|
|
4385
5961
|
* plainly beats an empty section that reads as an oversight.
|
|
5962
|
+
*
|
|
5963
|
+
* But it is only true if the site sets none of its **own**, which is why
|
|
5964
|
+
* `firstPartyCookies` is checked alongside the processors: a site running
|
|
5965
|
+
* attribution with a cookieless analytics provider sets `_ac` and no third-party
|
|
5966
|
+
* cookie at all, and the old wording would have declared it cookie-free.
|
|
4386
5967
|
*/
|
|
4387
5968
|
function providersSection$1(v) {
|
|
4388
5969
|
const setting = v.processors.filter((p) => p.cookies?.length);
|
|
4389
5970
|
const cookieless = v.processors.filter((p) => p.cookies?.length === 0);
|
|
4390
|
-
|
|
4391
|
-
|
|
4392
|
-
|
|
4393
|
-
|
|
5971
|
+
const own = v.firstPartyCookies;
|
|
5972
|
+
const ownSection = own.length ? `\n### Cookies We Set Ourselves\n\n${bullets(own.map((c) => `\`${c.name}\` — ${c.purpose}. Kept for ${c.retention}.`))}\n` : "";
|
|
5973
|
+
if (setting.length === 0) {
|
|
5974
|
+
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.` : "";
|
|
5975
|
+
return `## Cookies Set on This Site
|
|
5976
|
+
|
|
5977
|
+
${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}`}
|
|
5978
|
+
${ownSection}`;
|
|
5979
|
+
}
|
|
4394
5980
|
return `## Cookies Set on This Site
|
|
4395
5981
|
|
|
4396
5982
|
${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` : ""}`;
|
|
5983
|
+
${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
5984
|
}
|
|
4399
5985
|
/** Only meaningful when a consent tool actually offers the categories. */
|
|
4400
5986
|
function categoriesSection(v) {
|
|
@@ -4410,6 +5996,23 @@ ${bullets(v.cookieCategories)}
|
|
|
4410
5996
|
|
|
4411
5997
|
//#endregion
|
|
4412
5998
|
//#region src/legal/templates/privacy.ca.ts
|
|
5999
|
+
/**
|
|
6000
|
+
* Privacy policy — Canada (PIPEDA).
|
|
6001
|
+
*
|
|
6002
|
+
* Ported from the pandoc prototype at `packages/core/legal-templates/`. Two
|
|
6003
|
+
* things changed in the port and both are load-bearing:
|
|
6004
|
+
*
|
|
6005
|
+
* 1. `${var}` is now real TypeScript interpolation rather than a pandoc Lua
|
|
6006
|
+
* filter, so an unresolved variable is a compile error. The filter left
|
|
6007
|
+
* unknown keys as the literal text `${key}` — which would ship a live
|
|
6008
|
+
* privacy policy containing `${countries}`.
|
|
6009
|
+
* 2. Lists are built from arrays, not by splicing a string into a fixed bullet.
|
|
6010
|
+
* An empty derived value used to produce a stray `- ; and` bullet.
|
|
6011
|
+
*
|
|
6012
|
+
* The prose is jurisdiction-specific: it names the Office of the Privacy
|
|
6013
|
+
* Commissioner of Canada and frames transfers as "outside of Canada". Do not
|
|
6014
|
+
* reuse it for another jurisdiction — add a sibling template instead.
|
|
6015
|
+
*/
|
|
4413
6016
|
function privacyCa(v) {
|
|
4414
6017
|
return `# Privacy Policy
|
|
4415
6018
|
${updatedLine(v)}
|
|
@@ -4511,15 +6114,46 @@ ${bullets(v.processors.map((p) => `${p.name}, for ${p.purpose}${p.privacyUrl ? `
|
|
|
4511
6114
|
|
|
4512
6115
|
We remain responsible for personal information in the possession of our service providers.`;
|
|
4513
6116
|
}
|
|
4514
|
-
/**
|
|
6117
|
+
/**
|
|
6118
|
+
* Omitted entirely when nothing leaves the country — a false claim otherwise.
|
|
6119
|
+
*
|
|
6120
|
+
* Two clauses, because storage and legal reach are two facts and the OPC's concern
|
|
6121
|
+
* is foreign **access**, not merely foreign storage. A provider can hold data in
|
|
6122
|
+
* Canada and still be compellable abroad (Azure in a Canadian region), and a
|
|
6123
|
+
* provider can hold only one category of data abroad (a Canadian tenant whose
|
|
6124
|
+
* identity data sits in the US). One sentence could state neither.
|
|
6125
|
+
*
|
|
6126
|
+
* The closer is shared: it is true of whichever clauses appear.
|
|
6127
|
+
*/
|
|
4515
6128
|
function transferSection(v) {
|
|
4516
|
-
|
|
6129
|
+
const clauses = [storageClause(v), operatorClause(v)].filter(Boolean);
|
|
6130
|
+
if (clauses.length === 0) return "";
|
|
4517
6131
|
return `
|
|
4518
6132
|
### We May Transfer Personal Information to Other Countries
|
|
4519
6133
|
|
|
4520
|
-
|
|
6134
|
+
${clauses.join("\n\n")}
|
|
6135
|
+
|
|
6136
|
+
As a result, this information may be subject to access requests from governments, courts or law enforcement in those jurisdictions according to laws in those jurisdictions.
|
|
4521
6137
|
`;
|
|
4522
6138
|
}
|
|
6139
|
+
/**
|
|
6140
|
+
* Where the information rests.
|
|
6141
|
+
*
|
|
6142
|
+
* A scoped location gets its own sentence rather than joining the "including …"
|
|
6143
|
+
* list: folded in, a reader would take it to cover everything the provider holds,
|
|
6144
|
+
* which is a broader claim than the config made.
|
|
6145
|
+
*/
|
|
6146
|
+
function storageClause(v) {
|
|
6147
|
+
const scoped = list(v.scopedStorage.map((s) => `${s.country}, in the case of ${s.scope}`));
|
|
6148
|
+
if (v.storageCountries) return `Some or all of the personal information we collect may be stored or processed in jurisdictions outside of Canada, including ${v.storageCountries}.${scoped ? ` Certain categories of personal information are held outside of Canada even where the rest is not: ${scoped}.` : ""}`;
|
|
6149
|
+
if (scoped) return `Certain categories of the personal information we collect are stored or processed outside of Canada: ${scoped}.`;
|
|
6150
|
+
return "";
|
|
6151
|
+
}
|
|
6152
|
+
/** The Azure-in-Canada case: the information never moves, and foreign law still reaches it. */
|
|
6153
|
+
function operatorClause(v) {
|
|
6154
|
+
if (!v.operatorCountries) return "";
|
|
6155
|
+
return `Some of the service providers we use are established in, or controlled from, jurisdictions outside of Canada, including ${v.operatorCountries}. Personal information those providers handle on our behalf may be subject to the laws of those jurisdictions even when it is stored in Canada.`;
|
|
6156
|
+
}
|
|
4523
6157
|
|
|
4524
6158
|
//#endregion
|
|
4525
6159
|
//#region src/legal/templates/terms.ca.ts
|
|
@@ -6385,5 +8019,5 @@ function defaultConfig() {
|
|
|
6385
8019
|
}
|
|
6386
8020
|
|
|
6387
8021
|
//#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 };
|
|
8022
|
+
export { AD_PROVIDERS, BASE_HOOK, CONFIG_SCHEMA_URL, CONTRAST, ConfigSchema, DARK_SEL, DOC_ORDER, DOC_SLUGS, DesignSystemSchema, JURISDICTIONS, JURISDICTION_COUNTRIES, KNOWN_PROCESSORS, NUMERIC_STEPS, REQUIRED_ROLES, ROLE_TOKEN_KEYS, SCHEMA_URL, SiteFontSchema, TEMPLATES, TIERS, TIER_NAMES, TW_CLASH, buildIconSprite, configJsonSchema, deepMerge, defaultConfig, derivePolicyVars, detectProcessorKeys, enabledDocs, expandPalette, functionalRole, generate, generateDocs, generateLegal, isConfig, jsonSchema, loadIconSvg, parseMarkdown, processorsMissingLocation, renderInlineMarkdown, renderMarkdown, renderNodes, resolveConfig, resolveInput, resolvePrivacyContact, resolveProcessors, resolveTheme, roleColorUtilities, roleHue, roleKind, schemaTreeNodes, spriteId, stripNulls, tierPatterns, tierTags, toContentNodes, toHtmlFragment, toPortableText, tokenClass, tokenVar, validate, validateConfig };
|
|
6389
8023
|
//# sourceMappingURL=index.mjs.map
|