site-kit 1.0.0__py3-none-any.whl

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.
compliance_pages.py ADDED
@@ -0,0 +1,1541 @@
1
+ #!/usr/bin/env python3
2
+ """compliance-pages — turn one site-profile JSON into the
3
+ privacy, terms and cookie pages a small website needs, plus a
4
+ self-hosted consent banner, generated locally as files you own.
5
+
6
+ Pure Python standard library. Local only: no network, no
7
+ uploads, no accounts. The profile never leaves your machine,
8
+ and the generated pages make no external requests either.
9
+
10
+ Consent platforms rent the same pages back to you PER DOMAIN:
11
+ CookieYes bills $10/$25/$55 per domain per month (an agency
12
+ running ten client sites pays roughly $250 a month, with no
13
+ multi-domain bundle) and iubenda charges $4.99–79.99 per site
14
+ per month plus pageview overages. But the pages are documents,
15
+ and a document is a file, not a subscription. compliance-pages
16
+ is the file: one JSON profile of stated facts in, static
17
+ privacy.html / terms.html / cookies.html plus a dependency-free
18
+ consent banner (banner.html + consent.js) out — generate once
19
+ per site, own the files, no per-domain rent, no view metering.
20
+
21
+ WHAT THIS TOOL IS NOT — read this before the output:
22
+
23
+ * These are TEMPLATES, stamped on every page: "Template —
24
+ have a qualified professional review this page before
25
+ relying on it." They are NOT legal advice and NOT a
26
+ guarantee that any law is satisfied. The tool never uses
27
+ the word "compliant" about its own output, and neither
28
+ should you.
29
+ * The tool NEVER invents a fact. A fact the documents need
30
+ but the profile does not state becomes a loud placeholder
31
+ — [TO FILL: ...] — in the output, listed in the manifest,
32
+ and `make` exits 1 while any remain. Sections appear only
33
+ when the profile's facts call for them: no analytics in
34
+ the profile, no analytics section claiming any.
35
+ * The banner records a visitor's choice in the visitor's
36
+ OWN browser (localStorage). It does NOT log consent on a
37
+ server, does NOT implement IAB TCF, and BLOCKS NOTHING by
38
+ itself: the site owner must still wire their analytics
39
+ and marketing scripts to load only after the stored
40
+ choice allows them (consent.js publishes the choice as
41
+ window.cpConsent and a "cp:consent" event for exactly
42
+ that wiring — see the manifest). Laws differ by
43
+ jurisdiction; the profile drives a modest generic or
44
+ Canada-flavoured variant, nothing more.
45
+
46
+ The profile is a single JSON file. Every field is a STATED
47
+ FACT supplied by the site owner; absent means "not stated",
48
+ never "the tool may guess":
49
+
50
+ business {"name", "legal_form", "country", "province",
51
+ "address", "contact_email", "contact_name"} —
52
+ who operates the site. "country" selects the
53
+ variant: "Canada" (any case) -> Canada-flavoured
54
+ labels; anything else -> generic.
55
+ site {"name", "url"} — the website itself.
56
+ as_of generation / "last updated" date, YYYY-MM-DD.
57
+ May also be given with --as-of (which wins).
58
+ One of the two is REQUIRED: the stamp date is
59
+ never read from the machine clock.
60
+ data_collected list of strings — the personal information
61
+ the site collects, in the owner's words.
62
+ data_use list of strings — what it is used for.
63
+ services {"hosting": str|null, "analytics": str|null,
64
+ "payments": str|null, "embeds": [str]} — the
65
+ third-party services the site uses. A key set
66
+ to null (or omitted) means "not used". If the
67
+ whole block is absent, the profile states
68
+ nothing about services and the privacy page
69
+ carries a placeholder instead of a guess.
70
+ cookies {"essential": bool, "analytics": bool,
71
+ "marketing": bool} — which cookie categories
72
+ the site sets. All three keys are required when
73
+ the block is present. Absent block = not stated
74
+ (placeholder), NOT "no cookies".
75
+ consent_note a string describing how analytics/marketing
76
+ scripts are gated on the visitor's consent
77
+ choice on this site — the owner's words for
78
+ wiring that actually exists.
79
+ retention a string — how long collected information is
80
+ kept, in the owner's words.
81
+ governing_law a string — the governing-law line for the
82
+ terms page, in the owner's words.
83
+ quotes_are_estimates bool, default false — when true, the
84
+ terms page gains a "Quotes and estimates"
85
+ section saying quotes are estimates until
86
+ confirmed in writing.
87
+ pages subset of ["privacy", "terms", "cookies"],
88
+ default all three — which pages to generate.
89
+ (consent.js, banner.html and manifest.txt are
90
+ always generated.)
91
+
92
+ Commands:
93
+ make --profile FILE --out DIR [--as-of DATE] [--json]
94
+ — generate the pages, banner and manifest into
95
+ DIR. Exit 0 when the profile was complete (no
96
+ placeholders, no contradictions); exit 1 when
97
+ the files were written but need attention
98
+ (placeholders remain and/or contradictions were
99
+ flagged); exit 2 on a clean error (nothing
100
+ written).
101
+ check --profile FILE [--json] — report the profile's
102
+ gaps (facts the documents need but the profile
103
+ does not state) and contradictions, WITHOUT
104
+ writing anything. Exit 0 complete, 1 gaps or
105
+ contradictions, 2 error.
106
+
107
+ Exit codes: 0 = ok · 1 = ATTENTION — files written (make) or
108
+ report printed (check), but placeholders/contradictions need
109
+ a human · 2 = ERROR — clean message, never a traceback; with
110
+ --json the error is {"error": ...}. A corrupt profile is an
111
+ error and is NEVER overwritten or silently wiped.
112
+ """
113
+ import argparse
114
+ import html
115
+ import json
116
+ import re
117
+ import sys
118
+ from datetime import date
119
+ from pathlib import Path
120
+ from string import Template
121
+
122
+ __version__ = "0.1.0"
123
+ TOOL = "compliance-pages"
124
+
125
+ PAGES = ("privacy", "terms", "cookies")
126
+ PAGE_FILES = {"privacy": "privacy.html", "terms": "terms.html",
127
+ "cookies": "cookies.html"}
128
+ _DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
129
+ STORAGE_KEY = "cp-consent-v1"
130
+
131
+ # The template notice printed at the top of every generated
132
+ # page. It is part of the honesty contract: it ships WITH the
133
+ # document, not just in this tool's README.
134
+ TEMPLATE_NOTICE = (
135
+ "Template — this page was generated from a site profile "
136
+ "by compliance-pages. It is a starting point, not legal "
137
+ "advice: have a qualified professional review it before "
138
+ "relying on it.")
139
+
140
+ # One canonical loud placeholder per fact the documents need.
141
+ # Placeholders are constants (never built from profile data),
142
+ # so counting "[TO FILL:" occurrences in the generated files
143
+ # is an exact audit of what the profile failed to state.
144
+ PH = {
145
+ "business.name":
146
+ "[TO FILL: business name]",
147
+ "business.legal_form":
148
+ "[TO FILL: legal form of the business — e.g. "
149
+ "corporation, sole proprietorship, partnership]",
150
+ "business.country":
151
+ "[TO FILL: country the business is based in]",
152
+ "business.address":
153
+ "[TO FILL: business mailing address]",
154
+ "business.contact_email":
155
+ "[TO FILL: contact email for privacy and terms "
156
+ "questions]",
157
+ "site.name":
158
+ "[TO FILL: website name]",
159
+ "site.url":
160
+ "[TO FILL: website address (URL)]",
161
+ "data_collected":
162
+ "[TO FILL: the personal information this site "
163
+ "collects]",
164
+ "data_use":
165
+ "[TO FILL: what the collected information is used "
166
+ "for]",
167
+ "services":
168
+ "[TO FILL: third-party services this site uses — "
169
+ "hosting, analytics, payments, embeds — or remove "
170
+ "this section if there are none]",
171
+ "cookies":
172
+ "[TO FILL: which cookies this site sets — essential, "
173
+ "analytics, marketing]",
174
+ "consent_note":
175
+ "[TO FILL: how analytics and marketing scripts are "
176
+ "gated on the visitor's consent choice]",
177
+ "retention":
178
+ "[TO FILL: how long collected information is kept]",
179
+ "governing_law":
180
+ "[TO FILL: the governing law for these terms]",
181
+ }
182
+
183
+
184
+ class ToolError(Exception):
185
+ """A clean, user-facing failure (printed as ERROR, exit 2)."""
186
+
187
+
188
+ # --------------------------------------------------------------------------
189
+ # small helpers
190
+ # --------------------------------------------------------------------------
191
+
192
+ def parse_date(value, label):
193
+ """Strict YYYY-MM-DD -> date, or a clean ToolError naming
194
+ `label`."""
195
+ if not isinstance(value, str) or not _DATE_RE.match(value):
196
+ raise ToolError(f"{label} must be a date as YYYY-MM-DD "
197
+ f"(got {value!r})")
198
+ try:
199
+ return date.fromisoformat(value)
200
+ except ValueError:
201
+ raise ToolError(f"{label} is not a real calendar date: "
202
+ f"{value!r}")
203
+
204
+
205
+ def _reject_json_constant(name):
206
+ raise ValueError(f"invalid constant {name}")
207
+
208
+
209
+ def load_json_file(path, kind):
210
+ """Read + parse a JSON file -> its data, or a clean
211
+ ToolError. The file is only READ here; a corrupt file is
212
+ reported, never repaired in place."""
213
+ if not path.is_file():
214
+ raise ToolError(f"{kind} not found: {path}")
215
+ try:
216
+ text = path.read_text(encoding="utf-8-sig")
217
+ except (OSError, UnicodeDecodeError) as exc:
218
+ raise ToolError(f"could not read {kind} {path.name} "
219
+ f"({exc.__class__.__name__}) — the file "
220
+ f"was left untouched")
221
+ try:
222
+ return json.loads(text, parse_constant=_reject_json_constant)
223
+ except ValueError as exc:
224
+ raise ToolError(f"{kind} {path.name} is not valid JSON: "
225
+ f"{exc} — the file was left untouched")
226
+
227
+
228
+ def _esc(text):
229
+ return html.escape(text, quote=True)
230
+
231
+
232
+ def _fill(value, ph_key):
233
+ """A stated fact, or its loud placeholder. In HTML output
234
+ the placeholder is wrapped in a highlighted span by
235
+ _fill_h; this plain form is for the JS config and any
236
+ non-HTML context."""
237
+ return value if value else PH[ph_key]
238
+
239
+
240
+ def _fill_h(value, ph_key):
241
+ """_fill for HTML: stated facts are escaped text; a
242
+ placeholder ships as a visibly highlighted span whose text
243
+ is still the literal [TO FILL: ...] marker."""
244
+ if value:
245
+ return _esc(value)
246
+ return f'<span class="tofill">{PH[ph_key]}</span>'
247
+
248
+
249
+ # --------------------------------------------------------------------------
250
+ # the profile — load + structural validation
251
+ # (structural problems are collected and raised together, exit 2;
252
+ # missing FACTS are not structural — they become placeholders)
253
+ # --------------------------------------------------------------------------
254
+
255
+ def _opt_str(block, key, label, problems):
256
+ """An optional string field: absent/null/empty -> None
257
+ (not stated); a non-string is a structural problem."""
258
+ raw = block.get(key)
259
+ if raw is None:
260
+ return None
261
+ if not isinstance(raw, str):
262
+ problems.append(f"{label} must be a string "
263
+ f"(got {raw!r})")
264
+ return None
265
+ return raw.strip() or None
266
+
267
+
268
+ def _opt_str_list(block, key, label, problems):
269
+ """An optional list-of-strings field: absent/null -> None;
270
+ an empty list states nothing either, so it also -> None
271
+ (the fact is treated as not stated and will placeholder)."""
272
+ raw = block.get(key)
273
+ if raw is None:
274
+ return None
275
+ if not isinstance(raw, list):
276
+ problems.append(f"{label} must be a list of strings "
277
+ f"(got {raw!r})")
278
+ return None
279
+ out = []
280
+ for i, item in enumerate(raw):
281
+ if not isinstance(item, str) or not item.strip():
282
+ problems.append(f"{label}[{i}] must be a non-empty "
283
+ f"string")
284
+ else:
285
+ out.append(item.strip())
286
+ return out or None
287
+
288
+
289
+ def _party(raw, label, keys, problems):
290
+ """A {"name", ...} block of optional strings -> dict of
291
+ key -> str|None. A block of the wrong type is structural;
292
+ a missing block means every fact in it is unstated."""
293
+ if raw is None:
294
+ return {k: None for k in keys}
295
+ if not isinstance(raw, dict):
296
+ problems.append(f"{label} must be an object of fields "
297
+ f"({', '.join(keys)})")
298
+ return {k: None for k in keys}
299
+ return {k: _opt_str(raw, k, f"{label}.{k}", problems)
300
+ for k in keys}
301
+
302
+
303
+ def validate_profile(data):
304
+ """Validate a parsed profile -> a clean profile dict.
305
+ Structural problems (wrong types, unknown page names, an
306
+ impossible date, a half-given cookies block) are collected
307
+ and raised together in ONE ToolError — nothing is
308
+ generated from a structurally broken profile. Missing
309
+ facts pass through as None and become placeholders."""
310
+ if not isinstance(data, dict):
311
+ raise ToolError("the profile must be a JSON object of "
312
+ "fields (business, site, as_of, "
313
+ "data_collected, services, cookies, "
314
+ "...)")
315
+ problems = []
316
+ business = _party(data.get("business"), "business",
317
+ ("name", "legal_form", "country",
318
+ "province", "address", "contact_email",
319
+ "contact_name"), problems)
320
+ site = _party(data.get("site"), "site", ("name", "url"),
321
+ problems)
322
+ as_of = None
323
+ if data.get("as_of") is not None:
324
+ try:
325
+ as_of = parse_date(data.get("as_of"),
326
+ "'as_of'").isoformat()
327
+ except ToolError as exc:
328
+ problems.append(str(exc))
329
+ data_collected = _opt_str_list(data, "data_collected",
330
+ "data_collected", problems)
331
+ data_use = _opt_str_list(data, "data_use", "data_use",
332
+ problems)
333
+ services = None
334
+ raw_services = data.get("services")
335
+ if raw_services is not None:
336
+ if not isinstance(raw_services, dict):
337
+ problems.append("services must be an object with "
338
+ "hosting, analytics, payments and "
339
+ "embeds")
340
+ else:
341
+ services = {
342
+ "hosting": _opt_str(raw_services, "hosting",
343
+ "services.hosting", problems),
344
+ "analytics": _opt_str(raw_services, "analytics",
345
+ "services.analytics",
346
+ problems),
347
+ "payments": _opt_str(raw_services, "payments",
348
+ "services.payments",
349
+ problems),
350
+ "embeds": _opt_str_list(raw_services, "embeds",
351
+ "services.embeds",
352
+ problems) or [],
353
+ }
354
+ cookies = None
355
+ raw_cookies = data.get("cookies")
356
+ if raw_cookies is not None:
357
+ if not isinstance(raw_cookies, dict):
358
+ problems.append("cookies must be an object with "
359
+ "essential, analytics and marketing")
360
+ else:
361
+ cookies = {}
362
+ for key in ("essential", "analytics", "marketing"):
363
+ val = raw_cookies.get(key)
364
+ if not isinstance(val, bool):
365
+ problems.append(
366
+ f"cookies.{key} is required when the "
367
+ f"cookies block is given, and must be "
368
+ f"true or false (got {val!r})")
369
+ val = False
370
+ cookies[key] = val
371
+ consent_note = _opt_str(data, "consent_note", "consent_note",
372
+ problems)
373
+ retention = _opt_str(data, "retention", "retention",
374
+ problems)
375
+ governing_law = _opt_str(data, "governing_law",
376
+ "governing_law", problems)
377
+ quotes = data.get("quotes_are_estimates", False)
378
+ if not isinstance(quotes, bool):
379
+ problems.append(f"quotes_are_estimates must be true or "
380
+ f"false (got {quotes!r})")
381
+ quotes = False
382
+ pages = list(PAGES)
383
+ raw_pages = data.get("pages")
384
+ if raw_pages is not None:
385
+ if not isinstance(raw_pages, list) or not raw_pages:
386
+ problems.append("pages must be a non-empty list "
387
+ "drawn from: " + ", ".join(PAGES))
388
+ else:
389
+ bad = [p for p in raw_pages if p not in PAGES]
390
+ if bad:
391
+ problems.append(
392
+ f"pages names unknown page(s): "
393
+ f"{', '.join(repr(p) for p in bad)} — "
394
+ f"available pages: {', '.join(PAGES)}")
395
+ else:
396
+ pages = [p for p in PAGES if p in raw_pages]
397
+ if problems:
398
+ listing = "\n".join(f" - {p}" for p in problems)
399
+ raise ToolError(f"the profile has {len(problems)} "
400
+ f"problem(s) — nothing was written:\n"
401
+ f"{listing}")
402
+ country = business["country"]
403
+ variant = ("canada" if country and
404
+ country.casefold() == "canada" else "generic")
405
+ return {
406
+ "business": business,
407
+ "site": site,
408
+ "as_of": as_of,
409
+ "data_collected": data_collected,
410
+ "data_use": data_use,
411
+ "services": services,
412
+ "cookies": cookies,
413
+ "consent_note": consent_note,
414
+ "retention": retention,
415
+ "governing_law": governing_law,
416
+ "quotes_are_estimates": quotes,
417
+ "pages": pages,
418
+ "variant": variant,
419
+ }
420
+
421
+
422
+ # --------------------------------------------------------------------------
423
+ # analysis — gaps (unstated facts) and contradictions
424
+ # --------------------------------------------------------------------------
425
+
426
+ def analyse(profile):
427
+ """-> (gaps, contradictions).
428
+
429
+ gaps: ordered list of {"field", "placeholder"} for every
430
+ fact the documents need that the profile does not state,
431
+ in schema order. contradictions: ordered list of strings
432
+ naming facts that fight each other. Both are derived from
433
+ the profile alone — the same analysis drives `check`,
434
+ `make`'s manifest and the exit codes."""
435
+ b = profile["business"]
436
+ s = profile["site"]
437
+ gaps = []
438
+
439
+ def gap(field):
440
+ gaps.append({"field": field, "placeholder": PH[field]})
441
+
442
+ if not b["name"]:
443
+ gap("business.name")
444
+ if not b["legal_form"]:
445
+ gap("business.legal_form")
446
+ if not b["country"]:
447
+ gap("business.country")
448
+ if not b["address"]:
449
+ gap("business.address")
450
+ if not b["contact_email"]:
451
+ gap("business.contact_email")
452
+ if not s["name"]:
453
+ gap("site.name")
454
+ if not s["url"]:
455
+ gap("site.url")
456
+ if not profile["data_collected"]:
457
+ gap("data_collected")
458
+ if not profile["data_use"]:
459
+ gap("data_use")
460
+ if profile["services"] is None:
461
+ gap("services")
462
+ if profile["cookies"] is None:
463
+ gap("cookies")
464
+ if not profile["retention"]:
465
+ gap("retention")
466
+ if not profile["governing_law"]:
467
+ gap("governing_law")
468
+
469
+ contradictions = []
470
+ cookies = profile["cookies"]
471
+ services = profile["services"]
472
+ if "cookies" in profile["pages"] and cookies is not None \
473
+ and not any(cookies.values()):
474
+ contradictions.append(
475
+ "the profile asks for a cookies page but also "
476
+ "says the site sets no cookies (essential, "
477
+ "analytics and marketing are all false) — the "
478
+ "generated cookies page states that no cookies "
479
+ "are set; drop \"cookies\" from pages or correct "
480
+ "the cookie facts")
481
+ if services is not None and services["analytics"] \
482
+ and cookies is not None and not cookies["analytics"]:
483
+ contradictions.append(
484
+ f"an analytics service is declared "
485
+ f"({services['analytics']}) but analytics cookies "
486
+ f"are marked off — analytics tools usually set "
487
+ f"cookies; reconcile services.analytics with "
488
+ f"cookies.analytics")
489
+ optional_cookies = cookies is not None and \
490
+ (cookies["analytics"] or cookies["marketing"])
491
+ if optional_cookies and not profile["consent_note"]:
492
+ contradictions.append(
493
+ "analytics and/or marketing cookies are declared "
494
+ "but the profile has no consent_note describing "
495
+ "how those scripts are gated on the visitor's "
496
+ "consent choice — the banner records a choice "
497
+ "but blocks nothing by itself; add a consent_note "
498
+ "once that wiring exists (until then the cookies "
499
+ "page carries a placeholder where the note "
500
+ "belongs)")
501
+ return gaps, contradictions
502
+
503
+
504
+ # --------------------------------------------------------------------------
505
+ # rendering — self-contained HTML pages (escaped, deterministic)
506
+ # --------------------------------------------------------------------------
507
+
508
+ _CSS = """
509
+ body { font-family: Georgia, 'Times New Roman', serif; color: #222;
510
+ margin: 0; background: #f7f5f0; }
511
+ .page { max-width: 760px; margin: 0 auto; background: #fff;
512
+ padding: 48px 56px; }
513
+ .kicker { font-size: 12px; letter-spacing: 3px; text-transform:
514
+ uppercase; color: #8a7f5c; margin: 0 0 8px; }
515
+ h1 { font-size: 30px; line-height: 1.2; margin: 0 0 6px; }
516
+ h2 { font-size: 16px; letter-spacing: 1px; text-transform:
517
+ uppercase; color: #5c5340; margin: 32px 0 10px;
518
+ border-bottom: 1px solid #e3ddcd; padding-bottom: 6px; }
519
+ h3 { font-size: 16px; margin: 20px 0 6px; }
520
+ p { line-height: 1.6; margin: 0 0 12px; }
521
+ ul { line-height: 1.6; margin: 0 0 12px; padding-left: 20px; }
522
+ table { width: 100%; border-collapse: collapse; margin: 8px 0 12px; }
523
+ th { text-align: left; font-size: 13px; border-bottom: 2px solid
524
+ #222; padding: 6px 8px; }
525
+ td { padding: 8px; border-bottom: 1px solid #e9e4d6;
526
+ vertical-align: top; }
527
+ .meta { color: #555; font-size: 14px; margin: 14px 0 4px; }
528
+ .notice { border: 2px solid #b3261e; background: #fdf0ef;
529
+ color: #6b1712; padding: 12px 16px; margin: 20px 0;
530
+ font-size: 14px; line-height: 1.5; }
531
+ .tofill { background: #ffe58a; color: #5c4300; padding: 0 3px;
532
+ font-weight: bold; }
533
+ .foot { margin-top: 44px; padding-top: 14px;
534
+ border-top: 1px solid #e3ddcd; color: #777; font-size: 13px;
535
+ line-height: 1.6; }
536
+ """
537
+
538
+ _PAGE = Template("""<!DOCTYPE html>
539
+ <html lang="en">
540
+ <head>
541
+ <meta charset="utf-8">
542
+ <title>$title</title>
543
+ <style>$css</style>
544
+ </head>
545
+ <body>
546
+ <div class="page">
547
+ <p class="kicker">$kicker</p>
548
+ <h1>$heading</h1>
549
+ <p class="meta">Last updated: $as_of</p>
550
+ <div class="notice">$notice</div>
551
+ $body
552
+ <div class="foot">$foot</div>
553
+ </div>
554
+ </body>
555
+ </html>
556
+ """)
557
+
558
+
559
+ def _stamp(profile, as_of):
560
+ """The footer stamp on every page: what generated this,
561
+ when, from whose profile — and the standing warning."""
562
+ who = profile["business"]["name"] or profile["site"]["name"] \
563
+ or "the site profile"
564
+ return (f"Template generated by compliance-pages "
565
+ f"v{__version__} on {_esc(as_of)} from the site "
566
+ f"profile of {_esc(who)}. This page is a "
567
+ f"starting-point template, not legal advice, and "
568
+ f"not a guarantee that any law is satisfied. Laws "
569
+ f"differ by jurisdiction.")
570
+
571
+
572
+ def _page(profile, as_of, page_key, heading, body):
573
+ site_name = profile["site"]["name"]
574
+ title = heading + (f" — {site_name}" if site_name else "")
575
+ return _PAGE.substitute(
576
+ title=_esc(title), css=_CSS, kicker=_esc(
577
+ site_name or "Website"),
578
+ heading=_esc(heading), as_of=_esc(as_of),
579
+ notice=_esc(TEMPLATE_NOTICE), body=body,
580
+ foot=_stamp(profile, as_of))
581
+
582
+
583
+ def _ul(items):
584
+ lis = "\n".join(f"<li>{_esc(i)}</li>" for i in items)
585
+ return f"<ul>\n{lis}\n</ul>"
586
+
587
+
588
+ def _contact_section(profile):
589
+ """The closing contact section, identical on all three
590
+ pages: the profile's contact email, or its placeholder."""
591
+ email = profile["business"]["contact_email"]
592
+ name = profile["business"]["contact_name"]
593
+ line = "Questions about this page: contact "
594
+ line += _fill_h(email, "business.contact_email")
595
+ if name:
596
+ line += f" ({_esc(name)})"
597
+ line += "."
598
+ return ("Contact", f"<p>{line}</p>")
599
+
600
+
601
+ def render_privacy(profile, as_of):
602
+ """-> (html, [section names]). Sections appear only when
603
+ the profile's facts call for them; an unstated fact shows
604
+ as its loud placeholder inside the section that needs it,
605
+ never as an invented sentence."""
606
+ b = profile["business"]
607
+ s = profile["site"]
608
+ sections = []
609
+ parts = []
610
+
611
+ # 1. Who we are
612
+ place = _fill_h(b["country"], "business.country")
613
+ if b["province"]:
614
+ place = f"{_esc(b['province'])}, " + place
615
+ who = (f"<p>This policy covers {_fill_h(s['name'], 'site.name')}"
616
+ f" ({_fill_h(s['url'], 'site.url')}), operated by "
617
+ f"{_fill_h(b['name'], 'business.name')}, a "
618
+ f"{_fill_h(b['legal_form'], 'business.legal_form')} "
619
+ f"based in {place}"
620
+ f", at {_fill_h(b['address'], 'business.address')}."
621
+ f"</p>")
622
+ sections.append("Who we are")
623
+ parts.append(f"<h2>Who we are</h2>\n{who}")
624
+
625
+ # 2. Information we collect
626
+ if profile["data_collected"]:
627
+ body = ("<p>The site collects the following personal "
628
+ "information:</p>\n"
629
+ + _ul(profile["data_collected"]))
630
+ else:
631
+ body = (f"<p>{_fill_h(None, 'data_collected')}</p>")
632
+ sections.append("Information we collect")
633
+ parts.append(f"<h2>Information we collect</h2>\n{body}")
634
+
635
+ # 3. How we use information
636
+ if profile["data_use"]:
637
+ body = ("<p>The information is used as follows:</p>\n"
638
+ + _ul(profile["data_use"]))
639
+ else:
640
+ body = f"<p>{_fill_h(None, 'data_use')}</p>"
641
+ sections.append("How we use information")
642
+ parts.append(f"<h2>How we use information</h2>\n{body}")
643
+
644
+ # 4. Third-party services
645
+ services = profile["services"]
646
+ if services is None:
647
+ body = f"<p>{_fill_h(None, 'services')}</p>"
648
+ else:
649
+ rows = []
650
+ for key, label in (("hosting", "Hosting"),
651
+ ("analytics", "Analytics"),
652
+ ("payments", "Payments")):
653
+ if services[key]:
654
+ rows.append((label, services[key]))
655
+ if not rows and not services["embeds"]:
656
+ body = ("<p>The site profile declares no "
657
+ "third-party services.</p>")
658
+ else:
659
+ chunks = []
660
+ if rows:
661
+ trs = "\n".join(
662
+ f"<tr><td>{_esc(label)}</td>"
663
+ f"<td>{_esc(value)}</td></tr>"
664
+ for label, value in rows)
665
+ chunks.append(
666
+ "<table>\n<tr><th>Service</th>"
667
+ "<th>Provider</th></tr>\n"
668
+ f"{trs}\n</table>")
669
+ if services["embeds"]:
670
+ chunks.append(
671
+ "<p>Embedded content from third parties:"
672
+ "</p>\n" + _ul(services["embeds"]))
673
+ body = "\n".join(chunks)
674
+ sections.append("Third-party services")
675
+ parts.append(f"<h2>Third-party services</h2>\n{body}")
676
+
677
+ # 5. Cookies — a summary pointing at the cookies page
678
+ cookies = profile["cookies"]
679
+ if cookies is None:
680
+ body = f"<p>{_fill_h(None, 'cookies')}</p>"
681
+ else:
682
+ cats = [name for name in ("essential", "analytics",
683
+ "marketing") if cookies[name]]
684
+ if cats:
685
+ body = (f"<p>This site sets {', '.join(cats)} "
686
+ f"cookies. The cookies page describes each "
687
+ f"category and how to control it.</p>")
688
+ else:
689
+ body = ("<p>According to the site profile, this "
690
+ "site sets no cookies.</p>")
691
+ sections.append("Cookies")
692
+ parts.append(f"<h2>Cookies</h2>\n{body}")
693
+
694
+ # 6. How long we keep information
695
+ if profile["retention"]:
696
+ body = f"<p>{_esc(profile['retention'])}</p>"
697
+ else:
698
+ body = f"<p>{_fill_h(None, 'retention')}</p>"
699
+ sections.append("How long we keep information")
700
+ parts.append(
701
+ f"<h2>How long we keep information</h2>\n{body}")
702
+
703
+ # 7. Your choices and how to contact us
704
+ if profile["variant"] == "canada":
705
+ authority = ("If we cannot resolve a concern, you may "
706
+ "contact the Office of the Privacy "
707
+ "Commissioner of Canada, and — where your "
708
+ "province has its own privacy commissioner "
709
+ "for private-sector matters — that office "
710
+ "as well.")
711
+ else:
712
+ authority = ("If we cannot resolve a concern, you may "
713
+ "contact your local data-protection "
714
+ "authority.")
715
+ _, contact_html = _contact_section(profile)
716
+ body = (f"<p>You may ask what personal information we "
717
+ f"hold about you, ask for it to be corrected, and "
718
+ f"ask for it to be deleted where the law allows. "
719
+ f"{_esc(authority)}</p>\n{contact_html}")
720
+ sections.append("Your choices and how to contact us")
721
+ parts.append(
722
+ f"<h2>Your choices and how to contact us</h2>\n{body}")
723
+
724
+ return _page(profile, as_of, "privacy", "Privacy Policy",
725
+ "\n".join(parts)), sections
726
+
727
+
728
+ def render_terms(profile, as_of):
729
+ """-> (html, [section names])."""
730
+ b = profile["business"]
731
+ s = profile["site"]
732
+ sections = []
733
+ parts = []
734
+
735
+ # 1. Who operates this site
736
+ body = (f"<p>{_fill_h(s['name'], 'site.name')} "
737
+ f"({_fill_h(s['url'], 'site.url')}) is a website "
738
+ f"of {_fill_h(b['name'], 'business.name')}, a "
739
+ f"{_fill_h(b['legal_form'], 'business.legal_form')} "
740
+ f"based in "
741
+ f"{_fill_h(b['country'], 'business.country')}.</p>")
742
+ sections.append("Who operates this site")
743
+ parts.append(f"<h2>Who operates this site</h2>\n{body}")
744
+
745
+ # 2. Using this site
746
+ body = ("<p>You may browse this site and contact us "
747
+ "through it for the purposes the site offers. Do "
748
+ "not misuse the site: no attempting to break its "
749
+ "security, no submitting false information through "
750
+ "its forms, and no copying the site wholesale. We "
751
+ "try to keep the information on this site accurate "
752
+ "and current, but we do not warrant that it is "
753
+ "complete or error-free, and details such as prices "
754
+ "and availability may change without notice.</p>")
755
+ sections.append("Using this site")
756
+ parts.append(f"<h2>Using this site</h2>\n{body}")
757
+
758
+ # 3. Quotes and estimates — only when the profile says so
759
+ if profile["quotes_are_estimates"]:
760
+ body = ("<p>Any quote or estimate we give — on this "
761
+ "site, by email or in a generated proposal — "
762
+ "is an estimate based on the information "
763
+ "available at the time. It becomes a firm "
764
+ "price only when we confirm it in writing for "
765
+ "the agreed scope of work.</p>")
766
+ sections.append("Quotes and estimates")
767
+ parts.append(f"<h2>Quotes and estimates</h2>\n{body}")
768
+
769
+ # 4. Site content and intellectual property
770
+ body = (f"<p>The text, images and layout of this site "
771
+ f"belong to {_fill_h(b['name'], 'business.name')} "
772
+ f"or are used with permission. You may print or "
773
+ f"save pages for your own use in dealing with us; "
774
+ f"any other reuse needs our written permission "
775
+ f"first.</p>")
776
+ sections.append("Site content and intellectual property")
777
+ parts.append(
778
+ f"<h2>Site content and intellectual property</h2>\n"
779
+ f"{body}")
780
+
781
+ # 5. Limits of liability
782
+ body = ("<p>To the extent permitted by law, we are not "
783
+ "liable for indirect or consequential losses "
784
+ "arising from use of this site or reliance on its "
785
+ "content. Nothing in these terms limits liability "
786
+ "that cannot be limited under applicable law.</p>")
787
+ sections.append("Limits of liability")
788
+ parts.append(f"<h2>Limits of liability</h2>\n{body}")
789
+
790
+ # 6. Governing law
791
+ if profile["governing_law"]:
792
+ body = (f"<p>These terms are governed by "
793
+ f"{_esc(profile['governing_law'])}.</p>")
794
+ else:
795
+ body = (f"<p>These terms are governed by "
796
+ f"{_fill_h(None, 'governing_law')}.</p>")
797
+ sections.append("Governing law")
798
+ parts.append(f"<h2>Governing law</h2>\n{body}")
799
+
800
+ # 7. Changes to these terms
801
+ body = ("<p>We may update these terms from time to time. "
802
+ "The version on this page, with its \"last "
803
+ "updated\" date, is the current one.</p>")
804
+ sections.append("Changes to these terms")
805
+ parts.append(f"<h2>Changes to these terms</h2>\n{body}")
806
+
807
+ # 8. Contact
808
+ heading, contact_html = _contact_section(profile)
809
+ sections.append(heading)
810
+ parts.append(f"<h2>{heading}</h2>\n{contact_html}")
811
+
812
+ return _page(profile, as_of, "terms", "Terms of Use",
813
+ "\n".join(parts)), sections
814
+
815
+
816
+ def render_cookies(profile, as_of):
817
+ """-> (html, [section names]). Category subsections
818
+ appear only for categories the profile declares."""
819
+ sections = []
820
+ parts = []
821
+
822
+ # 1. What cookies are
823
+ body = ("<p>Cookies are small text files a website asks "
824
+ "your browser to store. Some are needed for the "
825
+ "site to work at all; others remember choices, "
826
+ "measure visits, or support marketing. This page "
827
+ "describes, by category, the cookies the site "
828
+ "profile says this site sets.</p>")
829
+ sections.append("What cookies are")
830
+ parts.append(f"<h2>What cookies are</h2>\n{body}")
831
+
832
+ # 2. Cookies this site sets
833
+ cookies = profile["cookies"]
834
+ chunks = []
835
+ subsections = []
836
+ if cookies is None:
837
+ chunks.append(f"<p>{_fill_h(None, 'cookies')}</p>")
838
+ elif not any(cookies.values()):
839
+ chunks.append("<p>According to the site profile, "
840
+ "this site sets no cookies.</p>")
841
+ else:
842
+ if cookies["essential"]:
843
+ subsections.append("Essential")
844
+ chunks.append(
845
+ "<h3>Essential</h3>\n<p>Cookies needed for "
846
+ "the site to function — security, load "
847
+ "balancing, and remembering the consent "
848
+ "choice described below. These are always on "
849
+ "because the site cannot work without them."
850
+ "</p>")
851
+ if cookies["analytics"]:
852
+ subsections.append("Analytics")
853
+ service = None
854
+ if profile["services"] is not None:
855
+ service = profile["services"]["analytics"]
856
+ text = ("<h3>Analytics</h3>\n<p>Cookies that "
857
+ "measure how visitors use the site, so we "
858
+ "can see which pages help people and which "
859
+ "do not.")
860
+ if service:
861
+ text += (f" Provided by {_esc(service)}.")
862
+ text += (" These load only if you accept them in "
863
+ "the consent banner.</p>")
864
+ chunks.append(text)
865
+ if cookies["marketing"]:
866
+ subsections.append("Marketing")
867
+ chunks.append(
868
+ "<h3>Marketing</h3>\n<p>Cookies used to "
869
+ "measure advertising and to show relevant "
870
+ "ads on other sites. These load only if you "
871
+ "accept them in the consent banner.</p>")
872
+ sections.append("Cookies this site sets")
873
+ parts.append("<h2>Cookies this site sets</h2>\n"
874
+ + "\n".join(chunks))
875
+
876
+ # 3. The consent banner and your stored choice
877
+ chunks = [
878
+ "<p>The consent banner on this site is served from "
879
+ "this site's own files — no third-party consent "
880
+ "service is involved. When you make a choice, the "
881
+ "banner stores it in your own browser's local "
882
+ "storage (under the key \"" + STORAGE_KEY + "\"), "
883
+ "not in a cookie and not on a server: nothing about "
884
+ "your choice is sent anywhere. Clearing your "
885
+ "browser's site data deletes the stored choice, and "
886
+ "the banner will ask again.</p>",
887
+ "<p>The banner itself blocks nothing. It records "
888
+ "your choice, and this site's optional scripts are "
889
+ "responsible for honouring it, as described here:"
890
+ "</p>",
891
+ ]
892
+ optional = cookies is not None and \
893
+ (cookies["analytics"] or cookies["marketing"])
894
+ if profile["consent_note"]:
895
+ chunks.append(f"<p>{_esc(profile['consent_note'])}</p>")
896
+ elif optional:
897
+ chunks.append(
898
+ f"<p>{_fill_h(None, 'consent_note')}</p>")
899
+ else:
900
+ chunks.append(
901
+ "<p>This site sets no optional (analytics or "
902
+ "marketing) cookies, so there is nothing further "
903
+ "to gate.</p>")
904
+ sections.append("The consent banner and your stored choice")
905
+ parts.append(
906
+ "<h2>The consent banner and your stored choice</h2>\n"
907
+ + "\n".join(chunks))
908
+
909
+ # 4. Managing cookies in your browser
910
+ body = ("<p>Every major browser lets you view, block "
911
+ "and delete cookies in its settings, and to "
912
+ "browse privately so cookies are discarded when "
913
+ "you close the window. Blocking essential cookies "
914
+ "may break parts of this site.</p>")
915
+ sections.append("Managing cookies in your browser")
916
+ parts.append(
917
+ f"<h2>Managing cookies in your browser</h2>\n{body}")
918
+
919
+ # 5. Contact
920
+ heading, contact_html = _contact_section(profile)
921
+ sections.append(heading)
922
+ parts.append(f"<h2>{heading}</h2>\n{contact_html}")
923
+
924
+ html_text = _page(profile, as_of, "cookies", "Cookie Policy",
925
+ "\n".join(parts))
926
+ return html_text, sections + \
927
+ [f"category: {name}" for name in subsections]
928
+
929
+
930
+ # --------------------------------------------------------------------------
931
+ # the consent banner — banner.html fragment + consent.js
932
+ # (self-hosted, dependency-free, no network requests of any kind;
933
+ # the choice lives in the visitor's own browser local storage)
934
+ # --------------------------------------------------------------------------
935
+
936
+ _JS = Template("""/* compliance-pages consent wiring — generated file.
937
+ Self-hosted and dependency-free: it makes no network
938
+ requests of any kind. It reads the banner fragment
939
+ (id "cp-banner", from banner.html), and when the visitor
940
+ makes a choice it stores that choice in the visitor's own
941
+ browser local storage under the key below. Nothing is
942
+ sent to any server; there is no consent log.
943
+ The banner records a choice — it blocks nothing by
944
+ itself. Site owners: gate your analytics and marketing
945
+ scripts on the stored choice. The choice is published as
946
+ window.cpConsent.get() and as a "cp:consent" event on
947
+ window, both on page load (when a choice already exists)
948
+ and the moment a new choice is saved.
949
+ window.cpConsent.reset() clears the stored choice and
950
+ shows the banner again — wire it to a "cookie settings"
951
+ link so visitors can change their mind. */
952
+ (function () {
953
+ "use strict";
954
+ var CONFIG = $config;
955
+ var KEY = CONFIG.storageKey;
956
+
957
+ function read() {
958
+ try {
959
+ var raw = window.localStorage.getItem(KEY);
960
+ return raw ? JSON.parse(raw) : null;
961
+ } catch (e) {
962
+ return null;
963
+ }
964
+ }
965
+
966
+ function write(choice) {
967
+ try {
968
+ window.localStorage.setItem(KEY, JSON.stringify(choice));
969
+ } catch (e) {
970
+ /* storage unavailable (private mode, disabled): the
971
+ choice simply does not persist, and the banner will
972
+ ask again on the next page. */
973
+ }
974
+ }
975
+
976
+ var bannerEl = null;
977
+ var current = null;
978
+
979
+ function publish(choice) {
980
+ current = choice;
981
+ window.cpConsent = {
982
+ get: function () { return current; },
983
+ reset: function () {
984
+ try {
985
+ window.localStorage.removeItem(KEY);
986
+ } catch (e) { /* storage unavailable: nothing stored */ }
987
+ current = null;
988
+ if (bannerEl) { bannerEl.hidden = false; }
989
+ }
990
+ };
991
+ try {
992
+ window.dispatchEvent(
993
+ new window.CustomEvent("cp:consent", { detail: choice }));
994
+ } catch (e) { /* older browsers: cpConsent.get() remains */ }
995
+ }
996
+
997
+ function onReady(fn) {
998
+ if (document.readyState !== "loading") {
999
+ fn();
1000
+ } else {
1001
+ document.addEventListener("DOMContentLoaded", fn);
1002
+ }
1003
+ }
1004
+
1005
+ onReady(function () {
1006
+ var banner = document.getElementById("cp-banner");
1007
+ bannerEl = banner;
1008
+ var existing = read();
1009
+ if (existing) {
1010
+ publish(existing);
1011
+ if (banner) { banner.hidden = true; }
1012
+ return;
1013
+ }
1014
+ if (!banner) { return; }
1015
+ banner.hidden = false;
1016
+
1017
+ function save(choice) {
1018
+ choice.essential = true;
1019
+ choice.savedAt = new Date().toISOString();
1020
+ write(choice);
1021
+ publish(choice);
1022
+ banner.hidden = true;
1023
+ }
1024
+
1025
+ var ok = banner.querySelector("[data-cp-ok]");
1026
+ if (ok) {
1027
+ ok.addEventListener("click", function () {
1028
+ save({ analytics: false, marketing: false });
1029
+ });
1030
+ }
1031
+ var acceptAll = banner.querySelector("[data-cp-accept-all]");
1032
+ if (acceptAll) {
1033
+ acceptAll.addEventListener("click", function () {
1034
+ var choice = { analytics: false, marketing: false };
1035
+ if (CONFIG.analytics) { choice.analytics = true; }
1036
+ if (CONFIG.marketing) { choice.marketing = true; }
1037
+ save(choice);
1038
+ });
1039
+ }
1040
+ var saveBtn = banner.querySelector("[data-cp-save]");
1041
+ if (saveBtn) {
1042
+ saveBtn.addEventListener("click", function () {
1043
+ var choice = { analytics: false, marketing: false };
1044
+ var boxes = banner.querySelectorAll("[data-cp-category]");
1045
+ for (var i = 0; i < boxes.length; i++) {
1046
+ var name = boxes[i].getAttribute("data-cp-category");
1047
+ if (name === "analytics" || name === "marketing") {
1048
+ choice[name] = boxes[i].checked === true;
1049
+ }
1050
+ }
1051
+ save(choice);
1052
+ });
1053
+ }
1054
+ });
1055
+ })();
1056
+ """)
1057
+
1058
+
1059
+ def render_consent_js(profile):
1060
+ """The consent wiring. The only profile data embedded is
1061
+ the site name and which optional categories exist, as a
1062
+ JSON config with "<" escaped so no profile string can
1063
+ break out of the script."""
1064
+ cookies = profile["cookies"] or {}
1065
+ config = {
1066
+ "storageKey": STORAGE_KEY,
1067
+ "siteName": profile["site"]["name"] or "",
1068
+ "analytics": bool(cookies.get("analytics")),
1069
+ "marketing": bool(cookies.get("marketing")),
1070
+ }
1071
+ config_json = json.dumps(config, indent=2).replace(
1072
+ "<", "\\u003c")
1073
+ return _JS.substitute(config=config_json)
1074
+
1075
+
1076
+ _BANNER_CSS = """
1077
+ #cp-banner { position: fixed; left: 16px; right: 16px;
1078
+ bottom: 16px; z-index: 9999; background: #1c2430;
1079
+ color: #f2f4f7; padding: 16px 20px; border-radius: 10px;
1080
+ font-family: Helvetica, Arial, sans-serif; font-size: 14px;
1081
+ line-height: 1.5; box-shadow: 0 6px 24px rgba(0,0,0,.35); }
1082
+ #cp-banner p { margin: 0 0 10px; }
1083
+ #cp-banner label { display: inline-block; margin: 0 14px 8px 0; }
1084
+ #cp-banner .cp-actions { margin-top: 6px; }
1085
+ #cp-banner button { font-size: 14px; padding: 8px 16px;
1086
+ margin-right: 8px; border-radius: 6px; border: 1px solid
1087
+ #f2f4f7; background: #f2f4f7; color: #1c2430;
1088
+ cursor: pointer; }
1089
+ #cp-banner button.cp-secondary { background: transparent;
1090
+ color: #f2f4f7; }
1091
+ """
1092
+
1093
+
1094
+ def render_banner(profile):
1095
+ """The banner fragment: paste before </body> and load
1096
+ consent.js after it. Category checkboxes appear only for
1097
+ optional categories the profile declares; with none, the
1098
+ banner is a plain essential-cookies notice."""
1099
+ cookies = profile["cookies"] or {}
1100
+ optional = [name for name in ("analytics", "marketing")
1101
+ if cookies.get(name)]
1102
+ site = _fill_h(profile["site"]["name"], "site.name")
1103
+ lines = [
1104
+ "<!-- compliance-pages consent banner — generated "
1105
+ "fragment.",
1106
+ " Paste this fragment into each page just before "
1107
+ "</body>, and load",
1108
+ " consent.js after it:",
1109
+ ' <script src="consent.js" defer></script>',
1110
+ " Without consent.js the banner does nothing: the "
1111
+ "script stores the",
1112
+ " visitor's choice in their own browser local "
1113
+ "storage and publishes",
1114
+ " it as window.cpConsent / a \"cp:consent\" event "
1115
+ "for your other",
1116
+ " scripts to honour. The banner blocks nothing "
1117
+ "by itself. -->",
1118
+ '<div id="cp-banner" class="cp-banner" hidden>',
1119
+ "<style>" + _BANNER_CSS + "</style>",
1120
+ ]
1121
+ if optional:
1122
+ cats = " and ".join(optional)
1123
+ lines.append(
1124
+ f"<p>{site} uses essential cookies to run, and "
1125
+ f"{_esc(cats)} cookies only with your permission. "
1126
+ f"Choose below; essential cookies are always on "
1127
+ f"because the site cannot work without them.</p>")
1128
+ for name in optional:
1129
+ lines.append(
1130
+ f'<label><input type="checkbox" '
1131
+ f'data-cp-category="{name}"> '
1132
+ f'{name.capitalize()} cookies</label>')
1133
+ lines.append(
1134
+ '<div class="cp-actions">'
1135
+ '<button type="button" data-cp-accept-all>'
1136
+ 'Accept all</button>'
1137
+ '<button type="button" class="cp-secondary" '
1138
+ 'data-cp-save>Save my choices</button></div>')
1139
+ else:
1140
+ lines.append(
1141
+ f"<p>{site} uses only essential cookies — the "
1142
+ f"kind a site needs in order to work, such as "
1143
+ f"remembering this notice. It sets no analytics "
1144
+ f"or marketing cookies.</p>")
1145
+ lines.append(
1146
+ '<div class="cp-actions">'
1147
+ '<button type="button" data-cp-ok>OK</button>'
1148
+ '</div>')
1149
+ if "cookies" in profile["pages"]:
1150
+ lines.append(
1151
+ '<p><a href="cookies.html" style="color:#f2f4f7">'
1152
+ 'Read the cookie policy</a></p>')
1153
+ lines.append("</div>")
1154
+ return "\n".join(lines) + "\n"
1155
+
1156
+
1157
+ # --------------------------------------------------------------------------
1158
+ # placeholder audit + manifest
1159
+ # --------------------------------------------------------------------------
1160
+
1161
+ _PLACEHOLDER_RE = re.compile(r"\[TO FILL: [^\]]*\]")
1162
+
1163
+
1164
+ def audit_placeholders(files):
1165
+ """Count [TO FILL: ...] markers in the generated
1166
+ DOCUMENTS (the manifest lists them, so it is excluded —
1167
+ counting it would double-count). -> (total, items) with
1168
+ items in canonical file order, first-appearance order:
1169
+ {"text", "count", "files"}."""
1170
+ counts = {}
1171
+ order = []
1172
+ total = 0
1173
+ for name, text in files:
1174
+ for match in _PLACEHOLDER_RE.findall(text):
1175
+ total += 1
1176
+ if match not in counts:
1177
+ counts[match] = {"text": match, "count": 0,
1178
+ "files": []}
1179
+ order.append(match)
1180
+ counts[match]["count"] += 1
1181
+ if name not in counts[match]["files"]:
1182
+ counts[match]["files"].append(name)
1183
+ return total, [counts[key] for key in order]
1184
+
1185
+
1186
+ def _shown(value):
1187
+ return value if value else "(not stated)"
1188
+
1189
+
1190
+ def _profile_value_lines(profile, as_of):
1191
+ """The profile values the documents were built from, in
1192
+ fixed schema order — the manifest's audit trail."""
1193
+ b = profile["business"]
1194
+ s = profile["site"]
1195
+ lines = [
1196
+ ("business.name", _shown(b["name"])),
1197
+ ("business.legal_form", _shown(b["legal_form"])),
1198
+ ("business.country", _shown(b["country"])),
1199
+ ("business.province", _shown(b["province"])),
1200
+ ("business.address", _shown(b["address"])),
1201
+ ("business.contact_email", _shown(b["contact_email"])),
1202
+ ("business.contact_name", _shown(b["contact_name"])),
1203
+ ("site.name", _shown(s["name"])),
1204
+ ("site.url", _shown(s["url"])),
1205
+ ("as_of", as_of),
1206
+ ]
1207
+ for key in ("data_collected", "data_use"):
1208
+ value = profile[key]
1209
+ lines.append((key, f"{len(value)} item(s) stated"
1210
+ if value else "(not stated)"))
1211
+ services = profile["services"]
1212
+ if services is None:
1213
+ lines.append(("services", "(not stated)"))
1214
+ else:
1215
+ for key in ("hosting", "analytics", "payments"):
1216
+ lines.append((f"services.{key}",
1217
+ _shown(services[key])))
1218
+ lines.append(("services.embeds",
1219
+ f"{len(services['embeds'])} item(s) "
1220
+ f"stated" if services["embeds"]
1221
+ else "(none)"))
1222
+ cookies = profile["cookies"]
1223
+ if cookies is None:
1224
+ lines.append(("cookies", "(not stated)"))
1225
+ else:
1226
+ for key in ("essential", "analytics", "marketing"):
1227
+ lines.append((f"cookies.{key}",
1228
+ "true" if cookies[key] else "false"))
1229
+ lines.append(("consent_note",
1230
+ _shown(profile["consent_note"])))
1231
+ lines.append(("retention", _shown(profile["retention"])))
1232
+ lines.append(("governing_law",
1233
+ _shown(profile["governing_law"])))
1234
+ lines.append(("quotes_are_estimates",
1235
+ "true" if profile["quotes_are_estimates"]
1236
+ else "false"))
1237
+ lines.append(("pages", ", ".join(profile["pages"])))
1238
+ return lines
1239
+
1240
+
1241
+ def render_manifest(profile, profile_name, as_of, date_source,
1242
+ file_sections, placeholders, ph_items,
1243
+ contradictions):
1244
+ """The plain-text manifest: what was generated, from
1245
+ which stated values, what is still placeholder, and the
1246
+ wiring the owner still owes. Deterministic."""
1247
+ out = [f"{TOOL} v{__version__} — generation manifest",
1248
+ f"Profile: {profile_name}",
1249
+ f"Stamped date: {as_of} (from {date_source})",
1250
+ f"Variant: {profile['variant']}", "",
1251
+ "Files written:"]
1252
+ for name in list(file_sections) + ["manifest.txt"]:
1253
+ out.append(f" {name}")
1254
+ out += ["", "Sections generated:"]
1255
+ for name, sections in file_sections.items():
1256
+ if sections:
1257
+ out.append(f" {name}: {'; '.join(sections)}")
1258
+ out += ["", "Profile values used:"]
1259
+ for key, value in _profile_value_lines(profile, as_of):
1260
+ out.append(f" {key}: {value}")
1261
+ out += ["",
1262
+ f"Placeholders in the generated documents: "
1263
+ f"{placeholders}",
1264
+ " (counted in the pages, banner.html and "
1265
+ "consent.js; this manifest",
1266
+ " lists the markers, so it is not counted "
1267
+ "itself)"]
1268
+ for item in ph_items:
1269
+ out.append(f" {item['text']} x{item['count']} — "
1270
+ f"{', '.join(item['files'])}")
1271
+ out += ["", f"Contradictions flagged: "
1272
+ f"{len(contradictions)}"]
1273
+ for text in contradictions:
1274
+ out.append(f" - {text}")
1275
+ out += ["",
1276
+ "Owner wiring still to do:",
1277
+ " - Paste banner.html into every page before "
1278
+ "</body> and load",
1279
+ " consent.js after it.",
1280
+ " - Gate analytics and marketing scripts on "
1281
+ "the stored choice:",
1282
+ " consent.js publishes window.cpConsent.get() "
1283
+ "and fires a",
1284
+ " \"cp:consent\" event; the banner records a "
1285
+ "choice and blocks",
1286
+ " nothing by itself.",
1287
+ " - Consent is stored only in the visitor's "
1288
+ "browser (local",
1289
+ " storage); there is no server-side consent "
1290
+ "log and no IAB",
1291
+ " TCF support.",
1292
+ " - Have a qualified professional review the "
1293
+ "generated pages",
1294
+ " before relying on them. These are "
1295
+ "templates, not legal",
1296
+ " advice; laws differ by jurisdiction."]
1297
+ return "\n".join(out) + "\n"
1298
+
1299
+
1300
+ # --------------------------------------------------------------------------
1301
+ # CLI
1302
+ # --------------------------------------------------------------------------
1303
+
1304
+ class _JsonAwareParser(argparse.ArgumentParser):
1305
+ """ArgumentParser that reports usage errors as JSON in
1306
+ --json mode ({"error": ...} on stdout, same exit code 2) —
1307
+ an agent driving this tool with --json never gets plain
1308
+ text or a traceback. The flag lives on the class so
1309
+ subparsers share it."""
1310
+
1311
+ json_mode = False
1312
+
1313
+ def error(self, message):
1314
+ if self.json_mode:
1315
+ print(json.dumps({"error": message}))
1316
+ self.exit(2)
1317
+ super().error(message)
1318
+
1319
+
1320
+ def _json_arg(p):
1321
+ p.add_argument("--json", action="store_true",
1322
+ help="print one JSON object instead of text")
1323
+
1324
+
1325
+ def _resolve_as_of(profile, flag_value):
1326
+ """The stamp date: --as-of wins, then the profile's
1327
+ as_of. Never the machine clock — a missing date is a
1328
+ clean error naming both ways to pin one."""
1329
+ if flag_value is not None:
1330
+ return parse_date(flag_value, "--as-of").isoformat(), \
1331
+ "the --as-of flag"
1332
+ if profile["as_of"]:
1333
+ return profile["as_of"], "the profile's as_of"
1334
+ raise ToolError("no generation date: set \"as_of\" in "
1335
+ "the profile or pass --as-of YYYY-MM-DD "
1336
+ "— the stamped date is never read from "
1337
+ "the clock")
1338
+
1339
+
1340
+ def _load_profile(a):
1341
+ path = Path(a.profile)
1342
+ data = load_json_file(path, "profile")
1343
+ return path, validate_profile(data)
1344
+
1345
+
1346
+ def cmd_make(a):
1347
+ profile_path, profile = _load_profile(a)
1348
+ as_of, date_source = _resolve_as_of(profile, a.as_of)
1349
+ gaps, contradictions = analyse(profile)
1350
+ documents = [] # (filename, text), canonical order
1351
+ file_sections = {}
1352
+ builders = {"privacy": render_privacy,
1353
+ "terms": render_terms,
1354
+ "cookies": render_cookies}
1355
+ for page in PAGES:
1356
+ if page in profile["pages"]:
1357
+ text, sections = builders[page](profile, as_of)
1358
+ documents.append((PAGE_FILES[page], text))
1359
+ file_sections[PAGE_FILES[page]] = sections
1360
+ documents.append(("consent.js", render_consent_js(profile)))
1361
+ file_sections["consent.js"] = []
1362
+ documents.append(("banner.html", render_banner(profile)))
1363
+ file_sections["banner.html"] = []
1364
+ total, ph_items = audit_placeholders(documents)
1365
+ manifest = render_manifest(
1366
+ profile, profile_path.name, as_of, date_source,
1367
+ file_sections, total, ph_items, contradictions)
1368
+ documents.append(("manifest.txt", manifest))
1369
+ file_sections["manifest.txt"] = []
1370
+
1371
+ out_dir = Path(a.out)
1372
+ if out_dir.exists() and not out_dir.is_dir():
1373
+ raise ToolError(f"--out {a.out} exists and is not a "
1374
+ f"folder — nothing was written")
1375
+ try:
1376
+ out_dir.mkdir(parents=True, exist_ok=True)
1377
+ for name, text in documents:
1378
+ (out_dir / name).write_text(text, encoding="utf-8")
1379
+ except OSError as exc:
1380
+ raise ToolError(f"could not write into {a.out} "
1381
+ f"({exc.__class__.__name__}: {exc})")
1382
+ attention = bool(total or contradictions)
1383
+ if a.json:
1384
+ print(json.dumps({
1385
+ "tool": TOOL, "version": __version__,
1386
+ "command": "make", "profile": profile_path.name,
1387
+ "as_of": as_of, "variant": profile["variant"],
1388
+ "out_dir": str(out_dir),
1389
+ "files": [name for name, _ in documents],
1390
+ "sections": {name: secs for name, secs
1391
+ in file_sections.items() if secs},
1392
+ "placeholders": {"total": total, "items": ph_items},
1393
+ "gaps": gaps,
1394
+ "contradictions": contradictions,
1395
+ "attention": attention,
1396
+ }, indent=2))
1397
+ else:
1398
+ names = ", ".join(name for name, _ in documents)
1399
+ print(f"Wrote {len(documents)} file(s) into "
1400
+ f"{out_dir}: {names}")
1401
+ if total:
1402
+ print(f" Placeholders remaining: {total} — the "
1403
+ f"profile did not state every fact the "
1404
+ f"documents use (listed in manifest.txt)")
1405
+ else:
1406
+ print(" Placeholders remaining: 0 — the profile "
1407
+ "stated every fact the documents use")
1408
+ if contradictions:
1409
+ print(f" Contradictions flagged: "
1410
+ f"{len(contradictions)} (listed in "
1411
+ f"manifest.txt)")
1412
+ print(" compliance-pages generated templates only — "
1413
+ "not legal advice; have them reviewed before "
1414
+ "relying on them.")
1415
+ return 1 if attention else 0
1416
+
1417
+
1418
+ def cmd_check(a):
1419
+ profile_path, profile = _load_profile(a)
1420
+ gaps, contradictions = analyse(profile)
1421
+ date_set = a.as_of is not None or profile["as_of"] is not None
1422
+ all_gaps = list(gaps)
1423
+ if not date_set:
1424
+ all_gaps.append({"field": "as_of", "placeholder": None})
1425
+ complete = not all_gaps and not contradictions
1426
+ if a.json:
1427
+ print(json.dumps({
1428
+ "tool": TOOL, "version": __version__,
1429
+ "command": "check", "profile": profile_path.name,
1430
+ "variant": profile["variant"],
1431
+ "as_of": (a.as_of or profile["as_of"]),
1432
+ "pages": profile["pages"],
1433
+ "gaps": all_gaps,
1434
+ "contradictions": contradictions,
1435
+ "complete": complete,
1436
+ }, indent=2))
1437
+ else:
1438
+ print(f"{TOOL} v{__version__} — check · profile "
1439
+ f"{profile_path.name}")
1440
+ pages = ", ".join(profile["pages"])
1441
+ print(f"Variant: {profile['variant']} · pages make "
1442
+ f"would generate: {pages} "
1443
+ f"(+ consent.js, banner.html, manifest.txt "
1444
+ f"always)")
1445
+ if date_set:
1446
+ print(f"Generation date: "
1447
+ f"{a.as_of or profile['as_of']}")
1448
+ else:
1449
+ print("Generation date: NOT SET — make will "
1450
+ "refuse until as_of is in the profile or "
1451
+ "--as-of is passed")
1452
+ print("")
1453
+ if all_gaps:
1454
+ print(f"GAPS — facts the documents need that "
1455
+ f"the profile does not state "
1456
+ f"({len(all_gaps)}):")
1457
+ for g in all_gaps:
1458
+ if g["placeholder"]:
1459
+ print(f" - {g['field']} → "
1460
+ f"{g['placeholder']}")
1461
+ else:
1462
+ print(f" - {g['field']} → set a date")
1463
+ else:
1464
+ print("GAPS: none — the profile states every "
1465
+ "fact the documents use.")
1466
+ if contradictions:
1467
+ print(f"CONTRADICTIONS ({len(contradictions)}):")
1468
+ for text in contradictions:
1469
+ print(f" - {text}")
1470
+ print("")
1471
+ if complete:
1472
+ print("Verdict: COMPLETE — make will generate "
1473
+ "with no placeholders.")
1474
+ else:
1475
+ print("Verdict: INCOMPLETE — fill the gaps above "
1476
+ "in the profile; anything left unstated "
1477
+ "ships as a loud [TO FILL: ...] placeholder, "
1478
+ "never as an invented fact.")
1479
+ return 0 if complete else 1
1480
+
1481
+
1482
+ def main(argv=None):
1483
+ argv = sys.argv[1:] if argv is None else list(argv)
1484
+ _JsonAwareParser.json_mode = "--json" in argv
1485
+ ap = _JsonAwareParser(
1486
+ prog=TOOL,
1487
+ description="Generate privacy, terms and cookie "
1488
+ "pages plus a self-hosted consent banner "
1489
+ "from one site-profile JSON (stdlib only, "
1490
+ "local only, templates — not legal "
1491
+ "advice).")
1492
+ sub = ap.add_subparsers(dest="command", required=True,
1493
+ parser_class=_JsonAwareParser)
1494
+
1495
+ p_make = sub.add_parser(
1496
+ "make", help="generate the pages, consent.js, "
1497
+ "banner.html and manifest.txt into a "
1498
+ "folder (exit 1 if placeholders or "
1499
+ "contradictions remain)")
1500
+ p_make.add_argument("--profile", required=True,
1501
+ help="the site-profile JSON file")
1502
+ p_make.add_argument("--out", required=True,
1503
+ help="folder to write the generated "
1504
+ "files into (created if missing)")
1505
+ p_make.add_argument("--as-of", default=None,
1506
+ help="generation date, YYYY-MM-DD "
1507
+ "(overrides the profile's as_of; "
1508
+ "one of the two is required)")
1509
+ _json_arg(p_make)
1510
+ p_make.set_defaults(func=cmd_make)
1511
+
1512
+ p_check = sub.add_parser(
1513
+ "check", help="report the profile's gaps and "
1514
+ "contradictions without writing "
1515
+ "anything")
1516
+ p_check.add_argument("--profile", required=True,
1517
+ help="the site-profile JSON file")
1518
+ p_check.add_argument("--as-of", default=None,
1519
+ help="generation date, YYYY-MM-DD "
1520
+ "(only reported; check writes "
1521
+ "nothing)")
1522
+ _json_arg(p_check)
1523
+ p_check.set_defaults(func=cmd_check)
1524
+
1525
+ a = ap.parse_args(argv)
1526
+
1527
+ def fail(msg):
1528
+ if getattr(a, "json", False):
1529
+ print(json.dumps({"error": msg}))
1530
+ else:
1531
+ print(f"ERROR: {msg}")
1532
+ return 2
1533
+
1534
+ try:
1535
+ return a.func(a)
1536
+ except ToolError as exc:
1537
+ return fail(str(exc))
1538
+
1539
+
1540
+ if __name__ == "__main__":
1541
+ sys.exit(main())