@getquick/content 0.0.0-stage → 0.2.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.
Files changed (39) hide show
  1. package/README.md +179 -2
  2. package/consent-migrations/0001_consent_records.sql +34 -0
  3. package/dist/bearer-DMOCoSci.js +16 -0
  4. package/dist/client-config-B3LNSOxl.js +4 -0
  5. package/dist/configuration-C_JGygQI.d.ts +259 -0
  6. package/dist/consent-astro.d.ts +36 -0
  7. package/dist/consent-astro.js +94 -0
  8. package/dist/consent-client.d.ts +37 -0
  9. package/dist/consent-client.js +321 -0
  10. package/dist/consent-migrations.d.ts +8 -0
  11. package/dist/consent-migrations.js +10 -0
  12. package/dist/consent-server.d.ts +77 -0
  13. package/dist/consent-server.js +292 -0
  14. package/dist/consent-testing.d.ts +96 -0
  15. package/dist/consent-testing.js +188 -0
  16. package/dist/consent.d.ts +114 -0
  17. package/dist/consent.js +5 -0
  18. package/dist/embeds-p9SyGNZI.js +344 -0
  19. package/dist/formatting-wOk2jz6J.js +276 -0
  20. package/dist/index.d.ts +75 -0
  21. package/dist/index.js +39 -0
  22. package/dist/legal-C5Xiq59W.d.ts +48 -0
  23. package/dist/migrations.d.ts +4 -0
  24. package/dist/migrations.js +6 -0
  25. package/dist/paths-Cmzzof3v.js +5 -0
  26. package/dist/records-CACA2O0Q.d.ts +56 -0
  27. package/dist/records-Dumrz759.js +74 -0
  28. package/dist/server.d.ts +2 -0
  29. package/dist/server.js +6465 -0
  30. package/dist/state-BYWc5TUB.js +559 -0
  31. package/dist/state-Dzd52F3c.d.ts +77 -0
  32. package/dist/version-DyyNhsuU.js +6 -0
  33. package/dist/wordpress-CUFb6YZZ.d.ts +384 -0
  34. package/migrations/0001_publications.sql +31 -0
  35. package/migrations/0002_entries.sql +14 -0
  36. package/migrations/0003_publication_events.sql +36 -0
  37. package/migrations/0004_withdrawals.sql +30 -0
  38. package/migrations/0005_reconciliation.sql +45 -0
  39. package/package.json +56 -3
package/README.md CHANGED
@@ -1,3 +1,180 @@
1
- # Temporary Holding Version
1
+ # @getquick/content
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The configured content runtime for WordPress HTML and WPGraphQL. This slice
4
+ provides pure helpers, the configured WordPress client, durable delivery and
5
+ installed SQL migrations, and event/refresh HTTP handlers. It is released as a
6
+ pair with the Composer package `getquick/gq-content`, at the same version and tag.
7
+
8
+ ```ts
9
+ import { renderWordPressBlocks, htmlLang } from "@getquick/content";
10
+ import { createContentRuntime } from "@getquick/content/server";
11
+
12
+ const content = createContentRuntime({
13
+ site: "my Site identity",
14
+ graphqlEndpoint: "https://cms.example.test/wp/graphql",
15
+ cmsOrigin: "https://cms.example.test",
16
+ wordpress: {
17
+ locale: "pt_PT_ao90",
18
+ languages: [{ locale: "en_US", slug: "en" }],
19
+ },
20
+ isServer: () => true, // Supply the framework's lazy server-context check.
21
+ bindings: () => ({}), // Supply fresh request bindings, including optional credentials.
22
+ });
23
+ const home = await content.wordpress.getHomeContent(content.defaultLanguage);
24
+ const entry = await content.wordpress.getEntryByUri("/about/");
25
+ const chrome = await content.wordpress.getSiteChrome("https://www.example.test", {
26
+ language: content.routeLanguage("/en/about/"),
27
+ });
28
+ ```
29
+
30
+ The Site supplies all configuration: no manifest, framework environment or
31
+ Cloudflare discovery is imported. Construct per request where practical. A
32
+ long-lived instance calls its binding getter on every delivery operation and CMS request. Keep the
33
+ GraphQL read identity separate from automation identities. Partial credential
34
+ pairs fail before transport. With credentials, only the configured HTTPS CMS
35
+ origin at `/graphql` or `/wp/graphql`, without URL userinfo, is allowed. Reads use
36
+ manual redirects and an eight-second deadline; failed reads return unavailable,
37
+ confirmed absence returns missing, and successful reads return found.
38
+
39
+ The root entry contains pure helpers and public structural types. The server
40
+ entry exports `createContentRuntime`, `ContentConfig`, `ContentExtension`,
41
+ `ExtensionNode`, `ContentBindings`, `ContentRuntime` and `WordPressClient`. Runtime properties contain languages,
42
+ routing functions and the WordPress methods; credentials/configuration are kept
43
+ in closures. Language configuration retains the source's normalization policy,
44
+ and routing returns the same language objects held in that instance's array.
45
+
46
+ The reusable client methods retain their argument order:
47
+
48
+ - `getHomeContent(language?)`
49
+ - `getEntryByUri(uri)`
50
+ - `getDesignPresets()`
51
+ - `getPublishedEntries()`
52
+ - `getPublishedRoutes()`
53
+ - `getSiteChrome(siteOrigin?, { location?, language? }?)`
54
+
55
+ From the repository root, use `vp install` and `vp run validate`. Validation
56
+ builds compiled ESM/declarations through Vite+ Pack, checks formatting/lint/types,
57
+ runs package-boundary suites, then packs and installs in a disposable consumer
58
+ without private registry configuration. `vp run test` rebuilds before tests;
59
+ `vp test frontend/tests/wordpress-access.test.ts` is useful after an explicit
60
+ `vp pack` during a focused implementation cycle. Build output is intentionally
61
+ untracked. The npm tarball allowlist contains only compiled output, the five original SQL migrations, the
62
+ separate consent database migrations and package metadata (npm also includes this README).
63
+
64
+ The public delivery methods are `publishedHome(siteOrigin?, language?)`,
65
+ `publishedEntry(path, siteOrigin?)`, `pageChrome(siteOrigin?)` and
66
+ `designPresets()`. Supply a
67
+ `PUBLICATION_DB` structural binding to serve durable publications. Stored reads
68
+ need no event or refresh secret and retain last-known-good content without an
69
+ age limit. Cold entry lookups read the CMS once, preserving missing versus
70
+ unavailable, canonical routes, language isolation and conditional promotion.
71
+ Production without a store returns unavailable (chrome returns null). Only
72
+ `development: true` enables live reads without a store. A store failure never
73
+ activates that fallback. All public reads guard server context before bindings
74
+ or transport.
75
+
76
+ `designPresets()` returns the stored design presets for a page that isn't a
77
+ publication (live only in explicit development, null otherwise), and
78
+ `internalOrigins(siteOrigin?)` the origins `menuItemHref` treats as this Site's.
79
+ `blockPresetStyles(spacing, colors, scope?)` scopes the preset classes to a
80
+ container (`.prose-wp` by default); `presetRootStyle(spacing, colors)` declares
81
+ the presets on `:root`.
82
+
83
+ ## A Site's own content
84
+
85
+ A Site whose WordPress plugin adds fields to its pages and posts declares them
86
+ as an `extension`. The runtime reads them with each front page, page and post
87
+ (with its blocks), stores the Site's value with the publication and serves it,
88
+ typed, through outages:
89
+
90
+ ```ts
91
+ const content = createContentRuntime<SiteBlocks>({
92
+ // …the configuration above…
93
+ extension: {
94
+ fields: { page: "wpBlocksJson pageBackgroundColor", post: "wpBlocksJson" },
95
+ // The Site's value for a node WordPress returned, or undefined for none.
96
+ read: ({ kind, fields }) => siteBlocks(fields.wpBlocksJson, fields.pageBackgroundColor),
97
+ // The stored value (JSON) read back, or undefined when it can't be served.
98
+ parse: (stored) => storedSiteBlocks.safeParse(stored).data,
99
+ },
100
+ });
101
+ const page = await content.publishedEntry("/about/");
102
+ if (page.kind === "found" && page.content.entry.extension) {
103
+ // Render page.content.entry.extension (SiteBlocks); the front page's is
104
+ // home.page.extension.
105
+ }
106
+ ```
107
+
108
+ Rows stored before the Site declared it serve without it; a refresh stores it.
109
+ A read throwing fails as WordPress's answer not matching the schema, and a read
110
+ without its blocks stores nothing, so the stored page stays served. See ADR 0004.
111
+
112
+ Infrastructure imports the Node-only migration entry separately:
113
+
114
+ ```ts
115
+ import { migrationsDirectory } from "@getquick/content/migrations";
116
+ // Alchemy beta.79: Cloudflare.D1.Database("Publications", {
117
+ // migrations: migrationsDirectory,
118
+ // });
119
+ ```
120
+
121
+ The exported string resolves to the five SQL files inside the installed package.
122
+ Keep this infrastructure entry out of browser and Worker imports. The root/server
123
+ graphs contain no Node migration module. `vp run alchemy` packs/installs with pnpm
124
+ and exercises the pinned Alchemy local D1/Worker lifecycle, including an update
125
+ with unchanged migration identities and publication data. This proof uses local
126
+ workerd, no remote resources or credentials. See the repository's issue #4 evidence.
127
+
128
+ The HTTP methods are `handleEvent(request, { siteOrigin? }?)` and
129
+ `handleRefresh(request, { siteOrigin? }?)`, both returning standard `Response`s.
130
+ Sites wire their POST routes directly to these methods; other methods return
131
+ 405 with `Allow: POST`. Responses use `Cache-Control: no-store`.
132
+
133
+ Events require `PUBLICATION_EVENT_SECRET` and refresh requires
134
+ `FRONTEND_REFRESH_TOKEN`, each at least 32 characters, plus `PUBLICATION_DB`.
135
+ An absent or short credential disables its handler without disabling stored
136
+ public reads. Event HMAC verification and Site/schema checks precede any CMS
137
+ reads or store changes. Refresh uses bearer authorization; `{}` refreshes the
138
+ whole Site and `{"uris":["/about/"]}` targets 1–100 entry paths. Language homes
139
+ require a whole-Site refresh. A signed withdrawal hides every stored route of
140
+ that entry immediately, without reading the CMS. Refreshes cannot lift it;
141
+ only a later publication can.
142
+
143
+ Signed `reconcile` events compare canonical shared rows and the complete
144
+ published-entry list, repair differences through normal publication rules, and
145
+ rotate rereads of unchanged entries. Runs hold a D1 lease, reserve a bounded CMS
146
+ request budget, and persist outcomes and pending counts for continuation.
147
+ Responses retain the source protocol: 200 for `reconciled`, `behind` or `busy`;
148
+ 503 for `failed`, preserving last-known-good content. Incomplete lists remove
149
+ nothing. Every configured language has separate home/chrome rows; design is
150
+ shared.
151
+
152
+ Signed `check` events return `{ status: "checked", site, reconciliation, store }`.
153
+ `store` reports shared row states, entry-state counts, active withdrawals and
154
+ failed events, plus other language row states under `languages`. It is null
155
+ when unavailable. `reconciliation` reports the last run, counts, failure and
156
+ last matched time, or null before any run. These are runtime evidence for the
157
+ Blueprint readiness gate; readiness orchestration, media checks and provisioning
158
+ remain Blueprint-owned. No storage or reconciliation helpers are public exports.
159
+
160
+ `vp run pair` exercises installed WordPress/package transport; `vp run alchemy`
161
+ retains the local installed-package/workerd migration and protocol proof.
162
+ `vp run reconciliation` additionally installs the pair and runs actual
163
+ WordPress/Polylang CLI and scheduler HTTP into the installed workerd consumer,
164
+ using GraphQL answers projected from real CMS state. It verifies convergence,
165
+ partial runs, outages/recovery and complete pending/failed records across Worker
166
+ updates. Full P6 release evidence remains issue #11.
167
+
168
+ ## Consent and legal links
169
+
170
+ CONSENT-1/LEGAL-1 ship as isolated entries: `@getquick/content/consent`
171
+ (neutral core), `/consent/client` (browser runtime), `/consent/server` (the
172
+ `/gq/consent` record and read endpoints and purge), `/consent/migrations`
173
+ (the separate consent database), `/consent/astro` (default UI and legal
174
+ footer as HTML) and `/consent/testing` (acceptance suite). They import no
175
+ content runtime, framework or WordPress client. Passing
176
+ `consent: { services: ["youtube"], cmsSettings: true }` to
177
+ `createContentRuntime` rewrites third-party embeds into consent placeholders
178
+ at delivery and stores gq-config's Legal Contract and GA4 settings in each
179
+ language's chrome; `runtime.consentConfiguration(chrome)` returns the page's
180
+ consent configuration. See the repository's `docs/consent.md`.
@@ -0,0 +1,34 @@
1
+ -- The consent database (CONSENT-1 CN-R7/R8): a dedicated D1 database per Site
2
+ -- and stage, created with jurisdiction "eu", separate from the publication
3
+ -- database. One row per decision; rows are never updated. Rows are deleted
4
+ -- only by the retention purge (CN-R10). No IP address, user agent or account
5
+ -- link is stored.
6
+ CREATE TABLE consent_records (
7
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
8
+ consent_id TEXT NOT NULL,
9
+ site_id TEXT NOT NULL,
10
+ -- Server UTC time, ISO 8601 with milliseconds.
11
+ recorded_at TEXT NOT NULL,
12
+ -- The browser's decision time: with consent_id, a retried post's identity.
13
+ decided_at TEXT NOT NULL,
14
+ -- When the decision expires if nothing supersedes it (6 months).
15
+ expires_at TEXT NOT NULL,
16
+ config_version TEXT NOT NULL,
17
+ legal_revisions TEXT NOT NULL,
18
+ preferences INTEGER NOT NULL CHECK (preferences IN (0, 1)),
19
+ analytics INTEGER NOT NULL CHECK (analytics IN (0, 1)),
20
+ marketing INTEGER NOT NULL CHECK (marketing IN (0, 1)),
21
+ external_media INTEGER NOT NULL CHECK (external_media IN (0, 1)),
22
+ action TEXT NOT NULL CHECK (action IN ('accept_all', 'reject_all', 'custom', 'withdraw', 'renew')),
23
+ language TEXT NOT NULL,
24
+ ui_version TEXT NOT NULL,
25
+ UNIQUE (consent_id, decided_at)
26
+ );
27
+
28
+ CREATE INDEX consent_records_by_time ON consent_records (recorded_at);
29
+
30
+ CREATE TRIGGER consent_records_append_only
31
+ BEFORE UPDATE ON consent_records
32
+ BEGIN
33
+ SELECT RAISE(ABORT, 'consent records are append-only');
34
+ END;
@@ -0,0 +1,16 @@
1
+ //#region frontend/src/bearer.ts
2
+ /**
3
+ * Whether a request carries `Authorization: Bearer <token>`. Both sides are
4
+ * hashed first, so the comparison's time depends on neither value nor length.
5
+ * Shared by the refresh and consent read endpoints; dependency-free.
6
+ */
7
+ async function bearerMatches(request, token) {
8
+ const presented = /^Bearer (.+)$/.exec(request.headers.get("Authorization") ?? "")?.[1] ?? "";
9
+ const digest = async (value) => new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)));
10
+ const [expected, actual] = await Promise.all([digest(token), digest(presented)]);
11
+ let difference = presented ? 0 : 1;
12
+ for (let index = 0; index < expected.length; index += 1) difference |= expected[index] ^ actual[index];
13
+ return difference === 0;
14
+ }
15
+ //#endregion
16
+ export { bearerMatches as t };
@@ -0,0 +1,4 @@
1
+ //#region frontend/src/consent/client-config.ts
2
+ const CONSENT_CONFIG_ID = "gq-consent-config";
3
+ //#endregion
4
+ export { CONSENT_CONFIG_ID as t };
@@ -0,0 +1,259 @@
1
+ //#region frontend/src/consent/catalog.d.ts
2
+ /**
3
+ * CONSENT-1's fixed purpose categories (CN-R2) and the Runtime's closed service
4
+ * catalog (CN-R3). Sites choose services from here; they cannot add categories
5
+ * or services. Adding a service is a Runtime release.
6
+ */
7
+ declare const CONSENT_CATEGORIES: readonly ["necessary", "preferences", "analytics", "marketing", "external_media"];
8
+ type ConsentCategory = (typeof CONSENT_CATEGORIES)[number];
9
+ /** The categories a visitor decides on: every one but `necessary`, which is never a choice. */
10
+ declare const OPTIONAL_CATEGORIES: readonly ["preferences", "analytics", "marketing", "external_media"];
11
+ type OptionalCategory = (typeof OPTIONAL_CATEGORIES)[number];
12
+ /** The languages Runtime-owned copy ships in (CN-C3). */
13
+ declare const COPY_LANGUAGES: readonly ["en", "pt-PT"];
14
+ type CopyLanguage = (typeof COPY_LANGUAGES)[number];
15
+ type Copy = Record<CopyLanguage, string>;
16
+ /** The copy language for a page's `lang` (BCP 47): Portuguese pages get pt-PT, others en. */
17
+ declare function copyLanguage(lang: string | undefined): CopyLanguage;
18
+ interface ConsentStorage {
19
+ name: string;
20
+ kind: "cookie" | "localStorage" | "sessionStorage";
21
+ /** `prefix`: every item whose name starts with `name`. */
22
+ match?: "exact" | "prefix";
23
+ /** First-party storage the Runtime can remove on withdrawal; third-party it cannot. */
24
+ party: "first" | "third";
25
+ duration: Copy;
26
+ }
27
+ interface ConsentService {
28
+ id: string;
29
+ category: ConsentCategory;
30
+ /** The provider's legal name. */
31
+ provider: string;
32
+ name: string;
33
+ purpose: Copy;
34
+ storage: ConsentStorage[];
35
+ /** How long the provider keeps the data it collects. */
36
+ retention: Copy;
37
+ privacyPolicy: string;
38
+ /** Domains the service contacts; a host matches a domain or any subdomain of it. */
39
+ hosts: string[];
40
+ /** The embed URLs that identify this service in HTML (host, optional path prefix). */
41
+ embeds?: Array<{
42
+ host: string;
43
+ path?: string;
44
+ }>;
45
+ }
46
+ declare const SERVICE_CATALOG: {
47
+ readonly consent: {
48
+ readonly id: "consent";
49
+ readonly category: "necessary";
50
+ readonly provider: "This website";
51
+ readonly name: "Consent choices";
52
+ readonly purpose: {
53
+ readonly en: "Remembers your consent choices and the consent ID that identifies them.";
54
+ readonly "pt-PT": "Memoriza as suas escolhas de consentimento e o ID que as identifica.";
55
+ };
56
+ readonly storage: [{
57
+ readonly name: "gq_consent";
58
+ readonly kind: "cookie";
59
+ readonly party: "first";
60
+ readonly duration: Copy;
61
+ }];
62
+ readonly retention: {
63
+ readonly en: "Records: 5 years after the choice stops being in force.";
64
+ readonly "pt-PT": "Registos: 5 anos após a escolha deixar de estar em vigor.";
65
+ };
66
+ readonly privacyPolicy: "";
67
+ readonly hosts: [];
68
+ };
69
+ readonly turnstile: {
70
+ readonly id: "turnstile";
71
+ readonly category: "necessary";
72
+ readonly provider: "Cloudflare, Inc.";
73
+ readonly name: "Cloudflare Turnstile";
74
+ readonly purpose: {
75
+ readonly en: "Protects sign-in, registration and checkout against abuse, only on those actions.";
76
+ readonly "pt-PT": "Protege o início de sessão, o registo e a finalização de compra contra abusos, apenas nessas ações.";
77
+ };
78
+ readonly storage: [];
79
+ readonly retention: {
80
+ readonly en: "Not stored by this website.";
81
+ readonly "pt-PT": "Não guardado por este website.";
82
+ };
83
+ readonly privacyPolicy: "https://www.cloudflare.com/privacypolicy/";
84
+ readonly hosts: ["challenges.cloudflare.com"];
85
+ };
86
+ readonly ga4: {
87
+ readonly id: "ga4";
88
+ readonly category: "analytics";
89
+ readonly provider: "Google Ireland Limited";
90
+ readonly name: "Google Analytics 4";
91
+ readonly purpose: {
92
+ readonly en: "Measures how visitors use the website, in aggregate (Consent Mode v2, basic mode).";
93
+ readonly "pt-PT": "Mede, de forma agregada, como os visitantes usam o website (Consent Mode v2, modo básico).";
94
+ };
95
+ readonly storage: [{
96
+ readonly name: "_ga";
97
+ readonly kind: "cookie";
98
+ readonly match: "prefix";
99
+ readonly party: "first";
100
+ readonly duration: {
101
+ readonly en: "Up to 2 years";
102
+ readonly "pt-PT": "Até 2 anos";
103
+ };
104
+ }];
105
+ readonly retention: {
106
+ readonly en: "14 months at Google (default data retention).";
107
+ readonly "pt-PT": "14 meses na Google (retenção de dados predefinida).";
108
+ };
109
+ readonly privacyPolicy: "https://policies.google.com/privacy";
110
+ readonly hosts: ["googletagmanager.com", "google-analytics.com", "analytics.google.com"];
111
+ };
112
+ readonly youtube: {
113
+ readonly id: "youtube";
114
+ readonly category: "external_media";
115
+ readonly provider: "Google Ireland Limited";
116
+ readonly name: "YouTube";
117
+ readonly purpose: {
118
+ readonly en: "Plays embedded YouTube videos. Loading one sends your IP address to YouTube.";
119
+ readonly "pt-PT": "Reproduz vídeos do YouTube incorporados. Carregar um envia o seu endereço IP ao YouTube.";
120
+ };
121
+ readonly storage: [{
122
+ readonly name: "VISITOR_INFO1_LIVE, YSC";
123
+ readonly kind: "cookie";
124
+ readonly party: "third";
125
+ readonly duration: {
126
+ readonly en: "Session to 6 months";
127
+ readonly "pt-PT": "Sessão a 6 meses";
128
+ };
129
+ }];
130
+ readonly retention: {
131
+ readonly en: "Set by Google; see its privacy policy.";
132
+ readonly "pt-PT": "Definida pela Google; consulte a sua política de privacidade.";
133
+ };
134
+ readonly privacyPolicy: "https://policies.google.com/privacy";
135
+ readonly hosts: ["youtube.com", "youtube-nocookie.com", "ytimg.com", "googlevideo.com", "ggpht.com"];
136
+ readonly embeds: [{
137
+ readonly host: "youtube.com";
138
+ }, {
139
+ readonly host: "youtube-nocookie.com";
140
+ }];
141
+ };
142
+ readonly vimeo: {
143
+ readonly id: "vimeo";
144
+ readonly category: "external_media";
145
+ readonly provider: "Vimeo.com, Inc.";
146
+ readonly name: "Vimeo";
147
+ readonly purpose: {
148
+ readonly en: "Plays embedded Vimeo videos. Loading one sends your IP address to Vimeo.";
149
+ readonly "pt-PT": "Reproduz vídeos do Vimeo incorporados. Carregar um envia o seu endereço IP ao Vimeo.";
150
+ };
151
+ readonly storage: [{
152
+ readonly name: "vuid";
153
+ readonly kind: "cookie";
154
+ readonly party: "third";
155
+ readonly duration: {
156
+ readonly en: "2 years";
157
+ readonly "pt-PT": "2 anos";
158
+ };
159
+ }];
160
+ readonly retention: {
161
+ readonly en: "Set by Vimeo; see its privacy policy.";
162
+ readonly "pt-PT": "Definida pela Vimeo; consulte a sua política de privacidade.";
163
+ };
164
+ readonly privacyPolicy: "https://vimeo.com/privacy";
165
+ readonly hosts: ["vimeo.com", "vimeocdn.com"];
166
+ readonly embeds: [{
167
+ readonly host: "player.vimeo.com";
168
+ }];
169
+ };
170
+ readonly google_maps: {
171
+ readonly id: "google_maps";
172
+ readonly category: "external_media";
173
+ readonly provider: "Google Ireland Limited";
174
+ readonly name: "Google Maps";
175
+ readonly purpose: {
176
+ readonly en: "Shows an embedded map. Loading it sends your IP address to Google.";
177
+ readonly "pt-PT": "Mostra um mapa incorporado. Carregá-lo envia o seu endereço IP à Google.";
178
+ };
179
+ readonly storage: [{
180
+ readonly name: "NID";
181
+ readonly kind: "cookie";
182
+ readonly party: "third";
183
+ readonly duration: {
184
+ readonly en: "6 months";
185
+ readonly "pt-PT": "6 meses";
186
+ };
187
+ }];
188
+ readonly retention: {
189
+ readonly en: "Set by Google; see its privacy policy.";
190
+ readonly "pt-PT": "Definida pela Google; consulte a sua política de privacidade.";
191
+ };
192
+ readonly privacyPolicy: "https://policies.google.com/privacy";
193
+ readonly hosts: ["google.com", "googleapis.com", "gstatic.com"];
194
+ readonly embeds: [{
195
+ readonly host: "www.google.com";
196
+ readonly path: "/maps";
197
+ }, {
198
+ readonly host: "google.com";
199
+ readonly path: "/maps";
200
+ }, {
201
+ readonly host: "maps.google.com";
202
+ }];
203
+ };
204
+ };
205
+ type ServiceId = keyof typeof SERVICE_CATALOG;
206
+ declare const SERVICE_IDS: ServiceId[];
207
+ declare function isServiceId(value: unknown): value is ServiceId;
208
+ declare function catalogService(id: ServiceId): ConsentService;
209
+ /** Whether a host is a domain or one of its subdomains. */
210
+ declare function hostMatches(host: string, domain: string): boolean;
211
+ /** The catalog service an embed URL belongs to, if any. */
212
+ declare function embedService(url: URL): ServiceId | null;
213
+ /** The catalog service whose hosts include a host, if any. */
214
+ declare function hostService(host: string): ServiceId | null;
215
+ //#endregion
216
+ //#region frontend/src/consent/configuration.d.ts
217
+ /**
218
+ * The revision of the Runtime's banner copy that counts as material. Copy edits
219
+ * keep it (CN-R5); only a reviewed change of what the visitor is told about
220
+ * processing bumps it.
221
+ */
222
+ declare const CONSENT_COPY_REVISION = 1;
223
+ /** gq-config's GA4 settings: the toggle and the measurement ID (GraphQL `googleAnalytics`). */
224
+ interface AnalyticsSettings {
225
+ enabled: boolean;
226
+ measurementId: string | null;
227
+ }
228
+ /** A GA4 measurement ID. Anything else leaves GA4 inactive. */
229
+ declare const MEASUREMENT_ID: RegExp;
230
+ interface ConsentConfigurationInput {
231
+ /** The catalog services the Site declares in code. */
232
+ services?: readonly string[];
233
+ /** gq-config's analytics settings: GA4 is active only when its toggle is on (ticket 08). */
234
+ analytics?: AnalyticsSettings | null;
235
+ /** The data controller's legal name (LEGAL-1); changing it is material. */
236
+ controller?: string | null;
237
+ }
238
+ interface ConsentConfiguration {
239
+ schemaVersion: 1;
240
+ /** Every active service, necessary ones included, sorted. */
241
+ services: ServiceId[];
242
+ /** The optional categories at least one active service is in, in category order. */
243
+ categories: OptionalCategory[];
244
+ /** GA4's measurement ID, when GA4 is active. */
245
+ measurementId: string | null;
246
+ /** A short hash of the controller's name, empty when unknown; a change between known ones is material. */
247
+ controller: string;
248
+ /** The consent configuration version: categories, services, controller and copy revision. */
249
+ version: string;
250
+ }
251
+ /**
252
+ * A Site's consent configuration: the union of services it declares in code and
253
+ * those its settings switch on. Unknown services are refused: the set is closed.
254
+ */
255
+ declare function consentConfiguration(input?: ConsentConfigurationInput): ConsentConfiguration;
256
+ /** CN-C1: a Site with no active service outside `necessary` shows no banner and records nothing. */
257
+ declare function needsBanner(configuration: ConsentConfiguration): boolean;
258
+ //#endregion
259
+ export { hostMatches as C, embedService as S, isServiceId as T, SERVICE_CATALOG as _, MEASUREMENT_ID as a, catalogService as b, CONSENT_CATEGORIES as c, ConsentService as d, ConsentStorage as f, OptionalCategory as g, OPTIONAL_CATEGORIES as h, ConsentConfigurationInput as i, COPY_LANGUAGES as l, CopyLanguage as m, CONSENT_COPY_REVISION as n, consentConfiguration as o, Copy as p, ConsentConfiguration as r, needsBanner as s, AnalyticsSettings as t, ConsentCategory as u, SERVICE_IDS as v, hostService as w, copyLanguage as x, ServiceId as y };
@@ -0,0 +1,36 @@
1
+ import { r as ConsentConfiguration } from "./configuration-C_JGygQI.js";
2
+ import { r as LegalLanguage } from "./legal-C5Xiq59W.js";
3
+ //#region frontend/src/consent/astro.d.ts
4
+ export interface ConsentPageInput {
5
+ configuration: ConsentConfiguration;
6
+ /** The page's `lang` (BCP 47): the UI follows it (CN-C3). */
7
+ language: string;
8
+ /** The page language's Legal Contract, from the chrome (LEGAL-1); null when not delivered yet. */
9
+ legal?: LegalLanguage | null;
10
+ /** The request's Cookie header: the banner is rendered open only without a decision in force. */
11
+ cookie?: string | null;
12
+ now?: number;
13
+ /** Include the default stylesheet (Sites restyle through its custom properties). */
14
+ styles?: boolean;
15
+ }
16
+ export interface ConsentPage {
17
+ /** For `<head>`: the configuration the client reads, and the default styles. */
18
+ head: string;
19
+ /** For the start of `<body>`: the banner (first in focus order) and the preferences dialog. */
20
+ body: string;
21
+ /** For the footer: the legal links and the persistent "Cookie settings" control. */
22
+ footer: string;
23
+ /** Whether the banner is rendered open. */
24
+ bannerOpen: boolean;
25
+ }
26
+ /** The legal links and "Cookie settings" control for a footer (LG-R5, CN-R6). */
27
+ export declare function legalFooter(legal: LegalLanguage | null | undefined, language: string): string;
28
+ /** Everything the default UI renders on one page. */
29
+ export declare function consentPage(input: ConsentPageInput): ConsentPage;
30
+ /**
31
+ * The default stylesheet. Sites restyle it through these custom properties or
32
+ * their own CSS (CN-C4); the two first-layer choices share one class so they
33
+ * keep equal prominence.
34
+ */
35
+ export declare const CONSENT_STYLES: string;
36
+ //#endregion
@@ -0,0 +1,94 @@
1
+ import { S as SERVICE_CATALOG, T as copyLanguage, c as grantedCategories, d as promptReason, f as readConsentCookie, x as OPTIONAL_CATEGORIES } from "./state-BYWc5TUB.js";
2
+ import { a as CONSENT_COPY, c as legalRevisions, r as escapeHtml } from "./embeds-p9SyGNZI.js";
3
+ import { t as CONSENT_UI_VERSION } from "./version-DyyNhsuU.js";
4
+ import { t as CONSENT_CONFIG_ID } from "./client-config-B3LNSOxl.js";
5
+ import { t as CONSENT_PATH } from "./paths-Cmzzof3v.js";
6
+ //#region frontend/src/consent/astro.ts
7
+ function storageText(service, language) {
8
+ const copy = CONSENT_COPY[language];
9
+ return service.storage.map((item) => `${item.name}${item.match === "prefix" ? "*" : ""} (${item.kind}, ${item.duration[language]}${item.party === "third" ? `; ${copy.thirdPartyStorage}` : ""})`).join("; ");
10
+ }
11
+ function serviceList(services, language) {
12
+ if (!services.length) return "";
13
+ const copy = CONSENT_COPY[language];
14
+ return `<ul class="gq-consent-services">${services.map((service) => {
15
+ const storage = storageText(service, language);
16
+ return `<li class="gq-consent-service"><strong>${escapeHtml(service.name)}</strong><dl><dt>${escapeHtml(copy.provider)}</dt><dd>${escapeHtml(service.provider)}</dd><dt>${escapeHtml(copy.purpose)}</dt><dd>${escapeHtml(service.purpose[language])}</dd><dt>${escapeHtml(copy.storage)}</dt><dd>${escapeHtml(storage || "—")}</dd><dt>${escapeHtml(copy.retention)}</dt><dd>${escapeHtml(service.retention[language])}</dd></dl></li>`;
17
+ }).join("")}</ul>`;
18
+ }
19
+ function link(href, text, attributes = "") {
20
+ return `<a href="${escapeHtml(href)}"${attributes}>${escapeHtml(text)}</a>`;
21
+ }
22
+ /** The legal links and "Cookie settings" control for a footer (LG-R5, CN-R6). */
23
+ function legalFooter(legal, language) {
24
+ const lang = copyLanguage(language);
25
+ const copy = CONSENT_COPY[lang];
26
+ const items = (legal?.roles ?? []).map((role) => `<li>${link(role.url, role.title ?? copy.legal[role.role] ?? role.role, ` data-gq-legal-role="${escapeHtml(role.role)}"`)}</li>`);
27
+ items.push(`<li><button type="button" class="gq-consent-settings" data-gq-consent-open aria-haspopup="dialog" aria-controls="gq-consent-preferences">${escapeHtml(copy.cookieSettings)}</button></li>`);
28
+ return `<nav class="gq-legal" aria-label="${escapeHtml(copy.legalNavigation)}"><ul>${items.join("")}</ul></nav>`;
29
+ }
30
+ /** Everything the default UI renders on one page. */
31
+ function consentPage(input) {
32
+ const { configuration } = input;
33
+ const lang = copyLanguage(input.language);
34
+ const copy = CONSENT_COPY[lang];
35
+ const state = readConsentCookie(input.cookie);
36
+ const now = input.now ?? Date.now();
37
+ const bannerOpen = promptReason(configuration, state, now) !== null;
38
+ const inForce = grantedCategories(configuration, state, now);
39
+ const cookiePolicy = input.legal?.roles.find((role) => role.role === "cookie_policy");
40
+ const config = {
41
+ configuration,
42
+ language: input.language,
43
+ uiVersion: CONSENT_UI_VERSION,
44
+ legalRevisions: legalRevisions(input.legal),
45
+ endpoint: CONSENT_PATH
46
+ };
47
+ const head = `<script type="application/json" id="${CONSENT_CONFIG_ID}">${JSON.stringify(config).replaceAll("<", "\\u003c")}<\/script>` + (input.styles === false ? "" : `<style data-gq-consent-styles>${CONSENT_STYLES}</style>`);
48
+ const services = configuration.services.map((id) => SERVICE_CATALOG[id]);
49
+ const inCategory = (category) => services.filter((service) => service.category === category);
50
+ const optional = configuration.categories.length > 0;
51
+ const banner = !optional ? "" : `<section id="gq-consent-banner" class="gq-consent-banner" role="dialog" aria-modal="false" aria-labelledby="gq-consent-banner-title" aria-describedby="gq-consent-banner-text" lang="${escapeHtml(input.language)}"${bannerOpen ? "" : " hidden"}><h2 id="gq-consent-banner-title" class="gq-consent-banner__title">${escapeHtml(copy.bannerTitle)}</h2><p id="gq-consent-banner-text" class="gq-consent-banner__text">${escapeHtml(copy.bannerText)}` + (cookiePolicy ? ` ${link(cookiePolicy.url, copy.cookiePolicy, " class=\"gq-consent-banner__policy\"")}` : "") + `</p><div class="gq-consent-banner__actions"><button type="button" class="gq-consent-button gq-consent-button--choice" data-gq-consent-reject-all>${escapeHtml(copy.rejectAll)}</button><button type="button" class="gq-consent-button gq-consent-button--choice" data-gq-consent-accept-all>${escapeHtml(copy.acceptAll)}</button><button type="button" class="gq-consent-button gq-consent-button--secondary" data-gq-consent-customise aria-haspopup="dialog" aria-controls="gq-consent-preferences">${escapeHtml(copy.customise)}</button></div></section>`;
52
+ const necessary = copy.categories.necessary;
53
+ const categories = OPTIONAL_CATEGORIES.filter((category) => configuration.categories.includes(category)).map((category) => {
54
+ const text = copy.categories[category];
55
+ const id = `gq-consent-category-${category}`;
56
+ return `<fieldset class="gq-consent-category"><legend>${escapeHtml(text.name)}</legend><label for="${id}"><input type="checkbox" id="${id}" name="${category}" value="${category}" data-gq-consent-category${inForce.includes(category) ? " checked" : ""}> ${escapeHtml(text.name)}</label><p>${escapeHtml(text.description)}</p>${serviceList(inCategory(category), lang)}</fieldset>`;
57
+ });
58
+ return {
59
+ head,
60
+ body: banner + (`<dialog id="gq-consent-preferences" class="gq-consent-preferences" aria-labelledby="gq-consent-preferences-title" lang="${escapeHtml(input.language)}"><form method="dialog"><h2 id="gq-consent-preferences-title">${escapeHtml(copy.preferencesTitle)}</h2><p>${escapeHtml(copy.preferencesText)}</p>` + (cookiePolicy ? `<p>${link(cookiePolicy.url, copy.cookiePolicy)}</p>` : "") + `<fieldset class="gq-consent-category"><legend>${escapeHtml(necessary.name)}</legend><p>${escapeHtml(copy.alwaysOn)}. ${escapeHtml(necessary.description)}</p>${serviceList(inCategory("necessary"), lang)}</fieldset>` + categories.join("") + (optional ? `<p class="gq-consent-id">${escapeHtml(copy.consentId)}: <code data-gq-consent-id data-gq-consent-none="${escapeHtml(copy.noDecision)}">${escapeHtml(state?.consentId ?? copy.noDecision)}</code></p>` : "") + `<div class="gq-consent-preferences__actions">` + (optional ? `<button type="button" class="gq-consent-button gq-consent-button--choice" data-gq-consent-reject-all>${escapeHtml(copy.rejectAll)}</button><button type="button" class="gq-consent-button gq-consent-button--choice" data-gq-consent-accept-all>${escapeHtml(copy.acceptAll)}</button><button type="button" class="gq-consent-button gq-consent-button--secondary" data-gq-consent-save>${escapeHtml(copy.save)}</button>` : "") + `<button type="button" class="gq-consent-button gq-consent-button--secondary" data-gq-consent-close>${escapeHtml(copy.close)}</button></div></form></dialog>`),
61
+ footer: legalFooter(input.legal, input.language),
62
+ bannerOpen
63
+ };
64
+ }
65
+ /**
66
+ * The default stylesheet. Sites restyle it through these custom properties or
67
+ * their own CSS (CN-C4); the two first-layer choices share one class so they
68
+ * keep equal prominence.
69
+ */
70
+ const CONSENT_STYLES = `
71
+ .gq-consent-banner{position:fixed;inset:auto 1rem 1rem 1rem;z-index:2147483000;max-width:44rem;margin:0 auto;padding:1.25rem;background:var(--gq-consent-background,#fff);color:var(--gq-consent-color,#111);border:1px solid var(--gq-consent-border,#888);border-radius:var(--gq-consent-radius,.5rem);box-shadow:0 .5rem 2rem rgba(0,0,0,.2);font:inherit}
72
+ .gq-consent-banner[hidden]{display:none}
73
+ .gq-consent-banner__title,.gq-consent-preferences h2{margin:0 0 .5rem;font-size:1.125rem}
74
+ .gq-consent-banner__actions,.gq-consent-preferences__actions,.gq-consent-embed__actions{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:1rem}
75
+ .gq-consent-button{font:inherit;cursor:pointer;padding:.6rem 1rem;border-radius:var(--gq-consent-radius,.5rem);border:2px solid var(--gq-consent-accent,#111)}
76
+ .gq-consent-button--choice{flex:1 1 10rem;background:var(--gq-consent-accent,#111);color:var(--gq-consent-accent-contrast,#fff)}
77
+ .gq-consent-button--secondary{background:transparent;color:inherit}
78
+ .gq-consent-button:focus-visible,.gq-consent-settings:focus-visible{outline:3px solid var(--gq-consent-focus,#1a73e8);outline-offset:2px}
79
+ .gq-consent-preferences{max-width:40rem;width:calc(100% - 2rem);max-height:calc(100vh - 2rem);overflow:auto;padding:1.25rem;border:1px solid var(--gq-consent-border,#888);border-radius:var(--gq-consent-radius,.5rem);background:var(--gq-consent-background,#fff);color:var(--gq-consent-color,#111)}
80
+ .gq-consent-category{border:1px solid var(--gq-consent-border,#ccc);border-radius:.375rem;margin:.75rem 0;padding:.75rem}
81
+ .gq-consent-services{margin:.5rem 0 0;padding-left:1rem;font-size:.875rem}
82
+ .gq-consent-services dl{display:grid;grid-template-columns:max-content 1fr;gap:.125rem .5rem;margin:.25rem 0}
83
+ .gq-consent-services dd{margin:0}
84
+ .gq-consent-embed{position:relative;display:flex;align-items:center;justify-content:center;width:100%;min-height:12rem;background:var(--gq-consent-embed-background,#eee);color:var(--gq-consent-color,#111)}
85
+ .gq-consent-embed[data-gq-loaded]{display:block;min-height:0;background:none}
86
+ .gq-consent-embed[data-gq-loaded]>iframe{width:100%;height:100%;border:0}
87
+ .gq-consent-embed--decorative{position:absolute;inset:0;min-height:0;background:none}
88
+ .gq-consent-embed__notice{max-width:32rem;padding:1rem;text-align:center}
89
+ .gq-consent-blocked{padding:1rem;background:var(--gq-consent-embed-background,#eee)}
90
+ .gq-legal ul{display:flex;flex-wrap:wrap;gap:.5rem 1rem;list-style:none;margin:0;padding:0}
91
+ .gq-consent-settings{font:inherit;color:inherit;background:none;border:0;padding:0;text-decoration:underline;cursor:pointer}
92
+ `.trim();
93
+ //#endregion
94
+ export { CONSENT_STYLES, consentPage, legalFooter };