a7480172ab
gates / gates (push) Failing after 12m29s
The two defects the operator found on his own phone are fixed and live since
5aeb36b5; this is what pins them so they cannot come back silently.
site_gates.py gate 22 parses site.css and shell.css into
(media context, selector, declarations) and fails on any property site.css
declares on a selector shell.css also styles that nothing overrides - neither
shell.css itself nor a `.shell ...`-scoped reset. site.css loads first, so
"later wins" is per PROPERTY, not per rule. Today: 689 site rules vs 119 shell
rules, 9 shared selectors, 0 leaks. .hamburger-box / .hamburger-line are an
explicit allow-list with the reason beside them.
It is media-aware precisely because the check it replaces was not. The STOP-1
leak diff excluded @media blocks and reported "exactly six" leaking
declarations; the real number is forty, and the worst of them
(@media (max-width: 900px) .nav-links { position: fixed; right: -100% })
parked the drawer's menu off-canvas and shipped.
Two decoys in test_gate_decoys.py, both the exact shape that went live, both
seen to convict:
site/shell-css-leak-in-media the body:not(.shell) scope taken back off that
one rule - the gate names the media query, the
selector and `right: -280px`
site/footer-outside-main <main> closed early on gyik.html - gate 3b's line
They plant and restore BYTES, with the anchor translated to the file's own line
ending: these files are CRLF in a Windows worktree and LF in a Linux one, and
replace_in's text-mode round trip would have rewritten a whole file on one side.
Also: CHANGELOG entries for both areas, the M3 report corrected where it quoted
its own wrong "exactly six", and one plain-language STATUS line.
742 lines
39 KiB
Python
742 lines
39 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""TASK-D3 site gates — mechanical checks against the static site's known failure mode:
|
||
silent per-page drift. Run from the repo root: python scripts/site_gates.py
|
||
|
||
Gates (all must pass; non-zero exit on any failure):
|
||
1. BOM — every website/*.html begins with EF BB BF (byte-checked)
|
||
2. emoji — zero emoji/pictographs in website/*.html (codepoint ranges; NEVER grep —
|
||
Windows grep false-negatives multibyte emoji, proven in D0)
|
||
3. nav — the <nav>…</nav> and <footer>…</footer> blocks are identical PER LANGUAGE SET
|
||
(Hungarian pages against index.html, English pages against en/index.html) after
|
||
stripping the active-link marker and the language globe (gate 13 checks it)
|
||
4. analytics — the umami snippet is present on every public page (nonpublic draft exempt)
|
||
5. no-CDN — zero fonts.googleapis.com / fonts.gstatic.com references
|
||
6. banned — zero legacy hexes / 999px radius / box-shadow in pages + site.css
|
||
7. style — zero embedded <style> blocks in pages (everything lives in site.css)
|
||
8. cachebust — every site.css / icons.svg reference carries ?v=<N>
|
||
12. download — the two download pages name the same installer file and checksum (R-559)
|
||
13. twins — every public Hungarian page has its English twin and back; each pair carries the same
|
||
hu/en/x-default alternates; each page's language GLOBE (one per page, every page) lists LANGS in
|
||
order, marks only the page's own language current, and points the other items at the twins;
|
||
no leftover text switch
|
||
14. lang — <html lang="hu"> on the Hungarian set, lang="en" on the English set
|
||
15. same apps — the app logos on /alkalmazasok and /en/apps are the same set
|
||
16. no HU — no Hungarian in the English pages: accent scan PLUS an ASCII-folded, word-bounded stem
|
||
scan (an accent-only scan passes ASCII-only Hungarian — 10-localisation.md §7), over
|
||
visible text, attributes, JSON-LD and the inline scripts' string literals. Positive and
|
||
negative controls are printed on every run. Also: no "please"/"kindly", and no English
|
||
retrieval promise beyond what the Hungarian twin makes
|
||
17. faq-ld — each FAQ page's FAQPage JSON-LD carries exactly its visible questions and answers
|
||
18. tail — nothing after </html> (a stray file-dump summary sat visible below technologiak.html)
|
||
19. dash-lang — a page shows dashboard pictures only in its own language (`dashboard-*-hu.webp` / `-en.webp`)
|
||
20. viewer — data-gallery openers are <a href> to existing assets; gallery.js (with ?v=) exactly where openers are;
|
||
no inline viewer copy
|
||
22. shell leak — MEDIA-AWARE: no declaration in site.css reaches a shell page on a selector that
|
||
shell.css also styles. site.css loads FIRST, so a property shell.css never sets
|
||
survives — „later wins” is per PROPERTY, not per rule. Earned live 2026-10-11:
|
||
@media (max-width: 900px) .nav-links { position: fixed; right: -100% } parked the
|
||
drawer's items off-canvas and the phone menu was empty. The check that missed it
|
||
EXCLUDED @media blocks and reported six leaks where the media-aware number was 40
|
||
21. app logos — every catalog app (`app-catalog-felhom.eu/templates/*/.felhom.yml`, by its `slug:`) has
|
||
website/assets/<slug>-logo.svg or .png — the dashboard asks for exactly that name and shows a grey
|
||
placeholder otherwise (R-912: five apps had theirs under other names). The sibling catalog clone
|
||
absent → said out loud, not checked
|
||
"""
|
||
import glob, html as _html, io, json, os, re, sys, unicodedata
|
||
|
||
# A gate's own console must not decide its verdict. These scripts quote back Hungarian copy, file
|
||
# paths and arrow characters; a Windows console here is cp1250, and printing one of them raised
|
||
# UnicodeEncodeError *before the gate had decided anything* — reuse_refs_check.py died while printing
|
||
# a NOTE, which the runner then reported as a failure (2026-10-10). Same trap class as
|
||
# poster_facts_gate.py's, which was found by its own red-proof.
|
||
for _stream in (sys.stdout, sys.stderr):
|
||
try:
|
||
_stream.reconfigure(encoding="utf-8", errors="replace")
|
||
except (AttributeError, ValueError, OSError): # pragma: no cover - old Python, or a pipe
|
||
pass
|
||
|
||
|
||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
W = os.path.join(ROOT, "website")
|
||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||
from customer_copy_vocab import RETRIEVAL_STEMS as _RET_HU, RETRIEVAL_STEMS_EN as _RET_EN
|
||
|
||
# The public pairs: Hungarian page -> its English twin (2026-10-08, operator ruling 9 in 10-localisation.md §11:
|
||
# the whole public site gets an English twin; the Hungarian stays x-default). A new public page needs its twin
|
||
# in the same commit — gate 13 refuses one without.
|
||
TWINS = {
|
||
"index.html": os.path.join("en", "index.html"),
|
||
"alkalmazasok.html": os.path.join("en", "apps.html"),
|
||
"technologiak.html": os.path.join("en", "technology.html"),
|
||
"biztonsagimentes.html": os.path.join("en", "backups.html"),
|
||
"gyik.html": os.path.join("en", "faq.html"),
|
||
"kapcsolat.html": os.path.join("en", "contact.html"),
|
||
"letoltes.html": os.path.join("en", "download.html"), # R-559 (2026-09-18) — the first twin
|
||
"teszteles.html": os.path.join("en", "testing.html"), # 2026-10-10 — the tester recruitment page
|
||
}
|
||
URL = {"index.html": "/", "alkalmazasok.html": "/alkalmazasok", "technologiak.html": "/technologiak",
|
||
"biztonsagimentes.html": "/biztonsagimentes", "gyik.html": "/gyik", "kapcsolat.html": "/kapcsolat",
|
||
"letoltes.html": "/letoltes", "teszteles.html": "/teszteles",
|
||
os.path.join("en", "testing.html"): "/en/testing", os.path.join("en", "index.html"): "/en/", os.path.join("en", "apps.html"): "/en/apps",
|
||
os.path.join("en", "technology.html"): "/en/technology", os.path.join("en", "backups.html"): "/en/backups",
|
||
os.path.join("en", "faq.html"): "/en/faq", os.path.join("en", "contact.html"): "/en/contact",
|
||
os.path.join("en", "download.html"): "/en/download"}
|
||
# Pages with no twin, BY NAME: the hidden prices page stays Hungarian only (brief 2026-10-08), and the 404 page
|
||
# is served at whatever URL was wrong — it carries one English line instead of a twin.
|
||
# adatkezeles/feltetelek have NO English twin ON PURPOSE (2026-10-09, R-813): the legal texts are
|
||
# Hungarian only, and every English page's footer says so and links the Hungarian page. A twin would
|
||
# be an English legal text nobody has reviewed — worse than an honest pointer. If an English version
|
||
# is ever written and reviewed, move them into TWINS and add both URLs above.
|
||
# preview/index.html (2026-10-10) is the redesigned home page, staged at /preview/ for the operator
|
||
# to compare before it replaces /. It is noindex + Disallow:/preview/, and it goes in NO_TWIN rather
|
||
# than TWINS because the two gates contradict each other for a preview URL: gate 3 requires its <nav>
|
||
# to be IDENTICAL to index.html's (whose globe points at / and /en/), while the twin check requires a
|
||
# TWINS pair's globe to point at the pair's own two URLs. Everything that matters still runs on it
|
||
# here - BOM, emoji, nav/footer, globe structure, analytics, CDN, tokens, cache-busting, dash-lang,
|
||
# viewer, tail. BOTH this entry and the file are removed in the same commit as the switch, and the
|
||
# English twin is written then, as a twin.
|
||
NO_TWIN = {"szolgaltatasok-nonpublic.html", "404.html", "adatkezeles.html", "feltetelek.html"}
|
||
|
||
MOCKUPS = set() # the design studies were deleted with website/preview/ at the M3 switch
|
||
|
||
# SHELL (2026-10-11, M3) — every public page now wears the controller's frame instead of the
|
||
# website's <nav>/<footer>. Gate 3 therefore checks the SIDEBAR and the FOOTER across this set
|
||
# instead of the old nav+footer: the site is static with no build step, so the shell is
|
||
# duplicated into each page, and this is what keeps the duplication honest.
|
||
SHELL = {
|
||
'szolgaltatasok-nonpublic.html',
|
||
'index.html',
|
||
'alkalmazasok.html',
|
||
'technologiak.html',
|
||
'biztonsagimentes.html',
|
||
'gyik.html',
|
||
'teszteles.html',
|
||
'kapcsolat.html',
|
||
'letoltes.html',
|
||
'adatkezeles.html',
|
||
'feltetelek.html',
|
||
'404.html',
|
||
os.path.join("en", 'index.html'),
|
||
os.path.join("en", 'apps.html'),
|
||
os.path.join("en", 'technology.html'),
|
||
os.path.join("en", 'backups.html'),
|
||
os.path.join("en", 'faq.html'),
|
||
os.path.join("en", 'testing.html'),
|
||
os.path.join("en", 'contact.html'),
|
||
os.path.join("en", 'download.html'),
|
||
}
|
||
|
||
PAGES = list(TWINS) + list(TWINS.values()) + sorted(NO_TWIN) + sorted(MOCKUPS) + sorted(SHELL)
|
||
EN_PAGES = set(TWINS.values())
|
||
HU_PAGES = set(PAGES) - EN_PAGES - MOCKUPS - SHELL
|
||
ANALYTICS_EXEMPT = {"szolgaltatasok-nonpublic.html"} | MOCKUPS
|
||
|
||
|
||
def is_en(p):
|
||
"""A page is English if it is a TWINS value OR it lives under an `en/` directory. The second
|
||
half matters for the shell pages staged at preview/m3/en/*, which are not in TWINS yet: without
|
||
it the dash-language, <html lang> and no-Hungarian gates all judged them as Hungarian."""
|
||
return p in EN_PAGES or p.split(os.sep)[0] == "en" or (os.sep + "en" + os.sep) in p
|
||
|
||
|
||
def lang_of(p):
|
||
return "en" if is_en(p) else "hu"
|
||
ANALYTICS_MARK = "https://stats.felhom.eu/script.js"
|
||
SITE = "https://felhom.eu"
|
||
|
||
BANNED = ["fonts.googleapis.com", "fonts.gstatic.com",
|
||
"#0d1117", "#161b22", "#1c2128", "#30363d", "#238636", "#da3633", "#d29922",
|
||
"border-radius: 999px", "box-shadow"]
|
||
|
||
fails = []
|
||
|
||
|
||
def fail(msg):
|
||
fails.append(msg)
|
||
print("FAIL:", msg)
|
||
|
||
|
||
def is_emoji(ch):
|
||
o = ord(ch)
|
||
return any(a <= o <= b for a, b in [
|
||
(0x1F000, 0x1FAFF), # pictographs, emoticons, transport, symbols-ext, cards
|
||
(0x2600, 0x27BF), # misc symbols + dingbats (incl. ✓ ✗ ★ ⚠ — sprite icons instead)
|
||
(0x2300, 0x23FF), # misc technical (⏱ ⏳ ⌛ …)
|
||
(0x2B00, 0x2BFF), # ⭐ etc.
|
||
(0xFE00, 0xFE0F), # variation selectors
|
||
(0x1F1E6, 0x1F1FF), # regional indicators
|
||
])
|
||
|
||
|
||
# The language globe (2026-10-08 ruling, 10-localisation.md §10.7): one <details> per page, its list built from THIS
|
||
# list — a third language is one more entry here plus its pages, and nothing else in the switch.
|
||
LANGS = [("hu", "Magyar"), ("en", "English")]
|
||
GLOBE_LABEL = {"hu": "Nyelv", "en": "Language"}
|
||
GLOBE_RE = re.compile(r'<details class="lang-globe">.*?</details>', re.S)
|
||
GLOBE_ITEM_RE = re.compile(r'<a href="([^"]*)" lang="([^"]*)" hreflang="([^"]*)" class="lang-globe-item( current)?"'
|
||
r'( aria-current="true")?>([^<]*)</a>')
|
||
|
||
|
||
def _drop_state_classes(m):
|
||
"""Remove the markers that legitimately differ per page from a class list: `active` on the
|
||
current item, and `is-open` on the nav group containing it (the shell renders that group open,
|
||
exactly as the controller does). Everything else in the class must match."""
|
||
keep = [c for c in m.group(1).split() if c not in ("active", "is-open")]
|
||
return 'class="%s"' % " ".join(keep) if keep else ""
|
||
|
||
|
||
def norm_block(b):
|
||
b = b.replace(' class="active"', "")
|
||
b = re.sub(r'\s*aria-current="[^"]*"', "", b)
|
||
b = re.sub(r'\s*aria-expanded="[^"]*"', "", b) # open/closed state of the same group
|
||
b = re.sub(r'class="([^"]*)"', _drop_state_classes, b)
|
||
# the globe is the one nav item whose content differs per page (its targets, its current mark, its label) —
|
||
# gate 13 checks all of that; here only its presence must match
|
||
b = GLOBE_RE.sub('<details class="lang-globe"/>', b)
|
||
return b
|
||
|
||
|
||
# R-423 (2026-10-05): PAGES is still the list the checks run on, but it may no longer be SHORTER than the site. Every
|
||
# *.html under website/ (a walk, any depth) must be in it — a page added without being added here used to go
|
||
# unscanned, which is how a gate checks seven files of nine and prints OK.
|
||
_on_disk = set()
|
||
for _dp, _dns, _fns in os.walk(W):
|
||
_dns[:] = [d for d in _dns if not d.startswith(".")]
|
||
for _f in _fns:
|
||
if _f.endswith(".html"):
|
||
_on_disk.add(os.path.relpath(os.path.join(_dp, _f), W))
|
||
for _missing in sorted(_on_disk - set(PAGES)):
|
||
fail("%s: an HTML page the site gates do not scan — add it to TWINS (with its twin) or NO_TWIN in "
|
||
"scripts/site_gates.py (R-423)" % _missing)
|
||
|
||
pages = {}
|
||
for p in PAGES:
|
||
path = os.path.join(W, p)
|
||
if not os.path.exists(path):
|
||
fail("%s: listed in the site gates but missing from website/" % p)
|
||
continue
|
||
raw = io.open(path, "rb").read()
|
||
# gate 1: BOM
|
||
if raw[:3] != b"\xef\xbb\xbf":
|
||
fail("%s: missing UTF-8 BOM (first bytes: %s)" % (p, raw[:3].hex()))
|
||
pages[p] = raw.decode("utf-8-sig")
|
||
|
||
# gate 2: emoji (pages + the shared stylesheet)
|
||
total_emoji = 0
|
||
emoji_targets = dict(pages)
|
||
_css = os.path.join(W, "assets", "site.css")
|
||
if os.path.exists(_css):
|
||
emoji_targets["assets/site.css"] = io.open(_css, encoding="utf-8").read()
|
||
for p, s in emoji_targets.items():
|
||
hits = [(i, ch) for i, ch in enumerate(s) if is_emoji(ch)]
|
||
if hits:
|
||
total_emoji += len(hits)
|
||
sample = " ".join(ch for _, ch in hits[:10])
|
||
fail("%s: %d emoji (%s ...)" % (p, len(hits), sample))
|
||
if total_emoji:
|
||
print(" emoji total: %d" % total_emoji)
|
||
|
||
# gate 3: nav + footer consistency, per language set
|
||
# Pages still wearing the website's own <nav>/<footer>. After the M3 switch that is nothing,
|
||
# and the loop is a no-op; it stays because a page added later without the shell must still be
|
||
# checked against its language's reference rather than silently skipped.
|
||
_OLD_CHROME_HU = sorted(HU_PAGES - SHELL)
|
||
_OLD_CHROME_EN = sorted(EN_PAGES - SHELL)
|
||
for lang, members, ref in (("hu", _OLD_CHROME_HU, "index.html"),
|
||
("en", _OLD_CHROME_EN, os.path.join("en", "index.html"))):
|
||
if ref not in pages or not members:
|
||
continue
|
||
blocks = {}
|
||
for p in sorted(members, key=lambda x: (x != ref, x)):
|
||
s = pages.get(p)
|
||
if s is None:
|
||
continue
|
||
nm = re.search(r"<nav>.*?</nav>", s, re.DOTALL)
|
||
fm = re.search(r"<footer>.*?</footer>", s, re.DOTALL)
|
||
if not nm or not fm:
|
||
fail("%s: missing <nav> or <footer>" % p)
|
||
continue
|
||
blocks[p] = (norm_block(nm.group(0)), norm_block(fm.group(0)))
|
||
rnav, rfoot = blocks.get(ref, (None, None))
|
||
for p, (nav, foot) in blocks.items():
|
||
if nav != rnav:
|
||
fail("%s: <nav> differs from %s (after active-marker normalization)" % (p, ref))
|
||
if foot != rfoot:
|
||
fail("%s: <footer> differs from %s" % (p, ref))
|
||
# gate 3b (2026-10-11, M3): the SHELL set carries a sidebar instead of the nav+footer, and that
|
||
# sidebar is duplicated into every page because the site is static with no build step. The thing
|
||
# that keeps the duplication honest is this check: every shell page's <nav class="sidebar"> block
|
||
# must be identical once the active marker and the globe's current item are normalised away —
|
||
# exactly the normalisation norm_block already does for the old chrome.
|
||
_shell_by_lang = {}
|
||
for p in sorted(SHELL):
|
||
s = pages.get(p)
|
||
if s is None:
|
||
continue
|
||
m = re.search(r'<nav class="sidebar" id="sidebar">.*?</nav>', s, re.DOTALL)
|
||
if not m:
|
||
fail("%s: no <nav class=\"sidebar\"> block — a shell page carries the shell" % p)
|
||
continue
|
||
# The footer stays part of the identity check. The shell has no footer of its own, so the
|
||
# website's is carried INSIDE the content column - it is the only route to the legal pages and
|
||
# the only place the operator's name and e-mail appear. A shell page without one is a defect.
|
||
f = re.search(r"<footer>.*?</footer>", s, re.DOTALL)
|
||
if not f:
|
||
fail("%s: no <footer> — the legal pages and the operator's e-mail are reachable only from it" % p)
|
||
continue
|
||
# ...and it must sit INSIDE the content column. body is a flex row on a shell page, so a footer
|
||
# left outside <main> becomes a SECOND COLUMN beside the content (seen live on 16 pages,
|
||
# 2026-10-11: the footer rendered at the top right of the page).
|
||
_main = re.search(r'<main class="content">.*?</main>', s, re.DOTALL)
|
||
if not _main:
|
||
fail("%s: no <main class=\"content\"> — a shell page puts its content in one" % p)
|
||
continue
|
||
if not (_main.start() < f.start() < _main.end()):
|
||
fail("%s: <footer> is outside <main class=\"content\"> — on a shell page body is a flex row, "
|
||
"so it renders as a second column beside the content" % p)
|
||
continue
|
||
_shell_by_lang.setdefault(lang_of(p), []).append((p, norm_block(m.group(0)) + norm_block(f.group(0))))
|
||
for _lang, _members in sorted(_shell_by_lang.items()):
|
||
_ref_p, _ref = _members[0]
|
||
_bad = 0
|
||
for _p, _blk in _members[1:]:
|
||
if _blk != _ref:
|
||
_bad += 1
|
||
fail("%s: <nav class=\"sidebar\"> differs from %s (after active-marker normalization) — "
|
||
"the shell is duplicated into every page, so it must be identical" % (_p, _ref_p))
|
||
# say "identical" ONLY when they are. A reassuring count printed next to a conviction is the
|
||
# same defect this repo files rows about.
|
||
if _bad:
|
||
print(" shell sidebar: %d of %d %s page(s) DIFFER from %s"
|
||
% (_bad, len(_members) - 1, _lang, _ref_p))
|
||
else:
|
||
print(" shell sidebar: %d %s page(s), all identical to %s" % (len(_members), _lang, _ref_p))
|
||
|
||
if os.path.join("en", "index.html") in pages:
|
||
_f = re.search(r"<footer>.*?</footer>", pages[os.path.join("en", "index.html")], re.DOTALL)
|
||
if not _f or "households in Hungary" not in _f.group(0):
|
||
fail("en/index.html: the English footer no longer says that Felhom serves households in Hungary "
|
||
"(the brief's rule: every English page says it once — the shared footer is where it lives)")
|
||
|
||
# gate 4: analytics presence
|
||
for p, s in pages.items():
|
||
if p in ANALYTICS_EXEMPT:
|
||
continue
|
||
if ANALYTICS_MARK not in s:
|
||
fail("%s: analytics snippet missing (%s)" % (p, ANALYTICS_MARK))
|
||
|
||
# gate 5+6: banned strings in pages + site.css
|
||
targets = dict(pages)
|
||
css_path = os.path.join(W, "assets", "site.css")
|
||
if os.path.exists(css_path):
|
||
targets["assets/site.css"] = io.open(css_path, encoding="utf-8").read()
|
||
for name, s in targets.items():
|
||
low = s.lower()
|
||
for pat in BANNED:
|
||
c = low.count(pat.lower())
|
||
if c:
|
||
fail("%s: banned %r ×%d" % (name, pat, c))
|
||
|
||
# gate 7: no embedded style blocks
|
||
for p, s in pages.items():
|
||
c = len(re.findall(r"<style[\s>]", s))
|
||
if c:
|
||
fail("%s: %d embedded <style> block(s)" % (p, c))
|
||
|
||
# gate 12 (R-559): the two download pages name the SAME installer file and the SAME checksum.
|
||
#
|
||
# THE FAILURE THIS EXISTS FOR IS A HALF-DONE RELEASE. Publishing a new ISO means editing a filename,
|
||
# a size and a 64-character hash on TWO pages now. Update one and forget the other and an English
|
||
# reader downloads yesterday's image, or — worse — checks today's image against yesterday's hash,
|
||
# fails the comparison, and is told by the page itself not to use the file.
|
||
#
|
||
# It compares what the pages SAY, not what is published: a gate cannot reach the bucket, and a
|
||
# published-file check belongs to the ISO release gate (G11), which does exactly that.
|
||
_HU_DL, _EN_DL = "letoltes.html", os.path.join("en", "download.html")
|
||
if _HU_DL in pages and _EN_DL in pages:
|
||
_iso_re = re.compile(r"felhom-installer-[0-9.]+-pve[0-9.\-]+\.iso")
|
||
_sha_re = re.compile(r"\b[0-9a-f]{64}\b")
|
||
hu_iso = sorted(set(_iso_re.findall(pages[_HU_DL])))
|
||
en_iso = sorted(set(_iso_re.findall(pages[_EN_DL])))
|
||
hu_sha = sorted(set(_sha_re.findall(pages[_HU_DL])))
|
||
en_sha = sorted(set(_sha_re.findall(pages[_EN_DL])))
|
||
if not hu_iso:
|
||
fail("%s: names no installer file at all" % _HU_DL)
|
||
if not hu_sha:
|
||
fail("%s: names no SHA-256 at all" % _HU_DL)
|
||
if hu_iso != en_iso:
|
||
fail("the download pages name DIFFERENT installer files: hu=%s en=%s" % (hu_iso, en_iso))
|
||
if hu_sha != en_sha:
|
||
fail("the download pages name DIFFERENT checksums: hu=%s en=%s" % (hu_sha, en_sha))
|
||
else:
|
||
print(" download pages agree: %s, sha %s…" % (", ".join(hu_iso), (hu_sha or ["-"])[0][:12]))
|
||
|
||
# gate 8: cache-busting on shared assets
|
||
for p, s in pages.items():
|
||
for m in re.finditer(r"/assets/(site\.css|icons\.svg)([^\"'#\s>]*)", s):
|
||
if not m.group(2).startswith("?v="):
|
||
fail("%s: %s referenced without ?v= (got %r)" % (p, m.group(1), m.group(0)))
|
||
|
||
# gate 13: twins — both ways, matching alternates, the globe pointing at the twins
|
||
|
||
|
||
def check_globe(p, lang, urls=None):
|
||
"""Exactly one globe; its label in the page's language; one item per LANGS entry, in order, lang/hreflang equal to
|
||
the entry; the page's own language is the only current/aria-current item; with `urls`, each item's target."""
|
||
s = pages[p]
|
||
g = GLOBE_RE.findall(s)
|
||
if len(g) != 1:
|
||
fail("%s: %d language globes in the page — exactly one, in the nav" % (p, len(g)))
|
||
return
|
||
lab = re.search(r'<summary class="lang-globe-btn" aria-label="([^"]*)" title="([^"]*)">', g[0])
|
||
if not lab or lab.group(1) != GLOBE_LABEL[lang] or lab.group(2) != GLOBE_LABEL[lang]:
|
||
fail("%s: the globe's label is %r — on a %s page it is %r" % (p, lab and lab.groups(), lang, GLOBE_LABEL[lang]))
|
||
items = GLOBE_ITEM_RE.findall(g[0])
|
||
if len(re.findall(r"<a ", g[0])) != len(items):
|
||
fail("%s: the globe holds a link the gate cannot read — every item is <a href lang hreflang class=lang-globe-item>" % p)
|
||
got = [(it[1], it[5]) for it in items]
|
||
if got != LANGS:
|
||
fail("%s: the globe lists %r — every page lists exactly %r, in that order" % (p, got, LANGS))
|
||
return
|
||
for href, code, hreflang, cur, aria, name in items:
|
||
if hreflang != code:
|
||
fail("%s: the globe's %s item has hreflang=%r" % (p, code, hreflang))
|
||
is_cur = code == lang
|
||
if bool(cur) != is_cur or bool(aria) != is_cur:
|
||
fail("%s: the globe marks %r as current (class current=%s, aria-current=%s) — only the page's own language %r is"
|
||
% (p, code, bool(cur), bool(aria), lang))
|
||
if urls is not None and href != urls[code]:
|
||
fail("%s: the globe's %s item points at %r — %s" % (p, code, href,
|
||
"the page itself is %r" % urls[code] if is_cur else "its twin is %r" % urls[code]))
|
||
|
||
|
||
ALT_RE = re.compile(r'<link rel="alternate" hreflang="([^"]+)" href="([^"]+)">')
|
||
for hu, en in TWINS.items():
|
||
if hu not in pages or en not in pages:
|
||
fail("twin pair %s <-> %s: a page of the pair is missing" % (hu, en))
|
||
continue
|
||
want = {"hu": SITE + URL[hu], "en": SITE + URL[en], "x-default": SITE + URL[hu]}
|
||
for p, other, lang in ((hu, en, "en"), (en, hu, "hu")):
|
||
got = dict(ALT_RE.findall(pages[p]))
|
||
if got != want:
|
||
fail("%s: hreflang alternates %s — the pair %s <-> %s needs exactly %s" % (p, got, hu, en, want))
|
||
check_globe(p, lang_of(p), {"hu": URL[hu], "en": URL[en]})
|
||
canon = re.search(r'<link rel="canonical" href="([^"]+)">', pages[p])
|
||
if not canon or canon.group(1) != SITE + URL[p]:
|
||
fail("%s: canonical %r — it must point at the page itself (%s)" % (p, canon and canon.group(1), SITE + URL[p]))
|
||
print(" twins: %d pairs checked both ways (hreflang, globe, canonical)" % len(TWINS))
|
||
for p in sorted(NO_TWIN): # no twin: the globe's structure, current mark and label still hold
|
||
if p in pages:
|
||
check_globe(p, lang_of(p))
|
||
for p, s in pages.items():
|
||
if 'class="lang-switch"' in s:
|
||
fail("%s: a leftover text language switch (class=\"lang-switch\") — the globe replaced it" % p)
|
||
|
||
# gate 14: the lang attribute
|
||
for p, s in pages.items():
|
||
m = re.search(r'<html lang="([^"]+)"', s)
|
||
want = "en" if is_en(p) else "hu"
|
||
if not m or m.group(1) != want:
|
||
fail("%s: <html lang=%r> — the %s set needs lang=%r" % (p, m and m.group(1), "English" if want == "en" else "Hungarian", want))
|
||
|
||
|
||
# gate 15: the same apps both ways
|
||
def app_logos(s):
|
||
return set(re.findall(r'<img src="/assets/([^"]+)" alt="[^"]*" class="app-logo"', s))
|
||
|
||
|
||
_A_HU, _A_EN = "alkalmazasok.html", os.path.join("en", "apps.html")
|
||
if _A_HU in pages and _A_EN in pages:
|
||
lh, le = app_logos(pages[_A_HU]), app_logos(pages[_A_EN])
|
||
cards_hu = len(re.findall(r'<div class="app-card">', pages[_A_HU]))
|
||
cards_en = len(re.findall(r'<div class="app-card">', pages[_A_EN]))
|
||
if lh != le or cards_hu != cards_en:
|
||
fail("the apps pages show DIFFERENT apps: only hu %s, only en %s, cards hu=%d en=%d"
|
||
% (sorted(lh - le), sorted(le - lh), cards_hu, cards_en))
|
||
else:
|
||
print(" apps pages agree: %d cards, %d logos" % (cards_hu, len(lh)))
|
||
|
||
|
||
# gate 16: no Hungarian on the English pages
|
||
def fold(s):
|
||
s = unicodedata.normalize("NFKD", s)
|
||
return "".join(c for c in s if not unicodedata.combining(c)).lower()
|
||
|
||
|
||
HU_LETTER = re.compile(u"[áéíóöőúüűÁÉÍÓÖŐÚÜŰ]")
|
||
# ASCII-folded stems. Stems of five letters or more match the START of a word ("jelentkez" convicts
|
||
# "Jelentkezz"); short ones, and the two language names, match WHOLE words only — "angol" must not convict
|
||
# "Angola", nor "az" convict "Amazon". Reused approach: app-catalog-felhom.eu/scripts/check-copy-i18n.py.
|
||
HU_STEMS = ["aldomain", "jelentkez", "oszd meg", "kattints", "valaszd", "magyar", "angol", "felhasznalo", "jelszo",
|
||
"nyelv", "beallitas", "alkalmazas", "szerver", "fajl", "mappa", "megosztas", "mentes", "doboz",
|
||
"vezerlopult", "kapcsolat", "otthon", "szolgaltatas", "biztonsag", "telepit", "haztartas", "kerdes",
|
||
"valasz", "letoltes", "tovabb", "ingyenes", "nyilt", "forraskod", "tarhely", "frissites", "uzemeltet",
|
||
"technologia", "hamarosan", "kerlek", "koszon", "uzenet", "targy",
|
||
"es", "egy", "vagy", "nem", "igen", "az", "hogy", "mint", "csak", "itt", "ahol", "ezt", "ami", "mert",
|
||
"nev", "gyik"]
|
||
_WHOLE = {"angol", "magyar"}
|
||
HU_RX = [(st, re.compile(r"(?<![a-z0-9])%s" % re.escape(st) + (r"(?![a-z0-9])" if len(st) <= 4 or st in _WHOLE else "")))
|
||
for st in HU_STEMS]
|
||
BEG = re.compile(r"\b(please|kindly)\b", re.I)
|
||
|
||
|
||
def visible_strings(s):
|
||
"""Everything a reader can meet: text, attribute values, JSON-LD, and the string literals of inline scripts
|
||
(status messages are shown to the visitor). Not scanned: comments, URLs, and anything marked lang="hu"
|
||
(the switch back to Magyar)."""
|
||
js = " ".join(re.findall(r"<script(?![^>]*ld\+json)[^>]*>(.*?)</script>", s, flags=re.S))
|
||
js = re.sub(r"(?m)^\s*//.*$", " ", js)
|
||
lits = [a or b or c for a, b, c in re.findall(r"'([^'\n]*)'|`([^`]*)`|\"([^\"\n]*)\"", js)]
|
||
s = re.sub(r"<script(?![^>]*ld\+json)[^>]*>.*?</script>", " ", s, flags=re.S)
|
||
s = re.sub(r"<!--.*?-->", " ", s, flags=re.S)
|
||
s = re.sub(r'<(?!html\b)([a-z0-9]+)\b[^>]*\blang="hu"[^>]*>.*?</\1>', " ", s, flags=re.S)
|
||
attrs = re.findall(r'\b(?:alt|title|aria-label|placeholder|content)="([^"]*)"', s)
|
||
s = re.sub(r'(https?://|/)[^\s"<>]*', " ", s)
|
||
text = re.sub(r"<[^>]+>", "\n", s)
|
||
return [_html.unescape(t).strip() for t in text.split("\n") + attrs + lits if t.strip()]
|
||
|
||
|
||
def hungarian_hits(s):
|
||
out = []
|
||
for t in visible_strings(s):
|
||
t2 = re.sub(r"\b[\w.-]+\.hu\b", " ", t) # Hungarian site names (mindmegette.hu …) are names
|
||
if HU_LETTER.search(t2):
|
||
out.append(("an accented Hungarian letter", t))
|
||
continue
|
||
f = fold(t2)
|
||
for st, rx in HU_RX:
|
||
if rx.search(f):
|
||
out.append(("the Hungarian stem %r" % st, t))
|
||
break
|
||
return out
|
||
|
||
|
||
# controls, printed on every run (workspace rule: a zero from a Hungarian search is suspect without them)
|
||
_pos = hungarian_hits(u"<p>Jelentkezz be: admin</p>")
|
||
_pos2 = hungarian_hits(u"<p>Kapcsolat</p>")
|
||
_neg = hungarian_hits(u"<p>The third best option is in Angola. These documents are kept on the server. "
|
||
u"Amazon is not an apps page.</p>")
|
||
if not _pos or not _pos2 or _neg:
|
||
fail("gate 16 INCONCLUSIVE: matcher controls failed (positive %r/%r must convict, negative %r must not)"
|
||
% (_pos, _pos2, _neg))
|
||
else:
|
||
print(" no-HU matcher controls OK (positive 'Jelentkezz be'/'Kapcsolat' convict, negative 'Angola…' does not)")
|
||
|
||
for p in sorted(x for x in pages if is_en(x)):
|
||
s = pages.get(p)
|
||
if s is None:
|
||
continue
|
||
for why, t in hungarian_hits(s)[:5]:
|
||
fail("%s: Hungarian on an English page (%s): %r" % (p, why, t[:120]))
|
||
for t in visible_strings(s):
|
||
if BEG.search(t):
|
||
fail("%s: the site does not beg — no \"please\"/\"kindly\": %r" % (p, t[:120]))
|
||
# the retrieval promise: the English page may make it no more often than its Hungarian twin does
|
||
hu = next((h for h, e in TWINS.items() if e == p), None)
|
||
if hu and hu in pages:
|
||
en_n = sum(len(re.findall(rx, t, re.I)) for t in visible_strings(s) for rx in _RET_EN)
|
||
hu_n = sum(fold(t).count(fold(st)) for t in visible_strings(pages[hu]) for st in _RET_HU)
|
||
if en_n > hu_n:
|
||
fail("%s: %d English retrieval promise(s) ('can be restored', 'recoverable' …) against %d in %s — "
|
||
"the English promises nothing the Hungarian does not" % (p, en_n, hu_n, hu))
|
||
|
||
|
||
# gate 17: FAQ JSON-LD == visible questions and answers
|
||
def faq_pairs(s):
|
||
out = []
|
||
for q, a in re.findall(r'<button class="faq-question">\s*(.*?)\s*<svg class="faq-chevron".*?'
|
||
r'<div class="faq-answer-inner">(.*?)</div></div>', s, re.S):
|
||
parts = re.findall(r"<(?:p|li)[^>]*>(.*?)</(?:p|li)>", a, re.S)
|
||
out.append((_html.unescape(q.strip()),
|
||
" ".join(re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", "", x))).strip() for x in parts)))
|
||
return out
|
||
|
||
|
||
for p in ("gyik.html", os.path.join("en", "faq.html")):
|
||
s = pages.get(p)
|
||
if s is None:
|
||
continue
|
||
m = re.search(r'<script type="application/ld\+json">(.*?)</script>', s, re.S)
|
||
try:
|
||
ld = json.loads(m.group(1)) if m else {}
|
||
except ValueError as e:
|
||
fail("%s: the FAQPage JSON-LD does not parse: %s" % (p, e))
|
||
continue
|
||
got = [(e.get("name"), (e.get("acceptedAnswer") or {}).get("text")) for e in ld.get("mainEntity", [])]
|
||
vis = faq_pairs(s)
|
||
if not vis:
|
||
fail("%s: found no visible FAQ items — the gate's pattern no longer matches the page" % p)
|
||
elif got != vis:
|
||
diff = [(i, a, b) for i, (a, b) in enumerate(zip(got, vis)) if a != b][:2]
|
||
fail("%s: the FAQPage JSON-LD (%d items) differs from the visible questions/answers (%d): first differences %r"
|
||
% (p, len(got), len(vis), diff))
|
||
else:
|
||
print(" %s: JSON-LD equals the %d visible questions and answers" % (p, len(vis)))
|
||
|
||
# gate 18: nothing after </html>
|
||
for p, s in pages.items():
|
||
i = s.rfind("</html>")
|
||
if i < 0 or s[i + len("</html>"):].strip():
|
||
fail("%s: text after </html> (%r) — it renders on the page" % (p, s[i + 7:i + 87] if i >= 0 else "no </html>"))
|
||
|
||
# gate 19: dashboard pictures in the page's own language (2026-10-08). The dashboard speaks both languages and the
|
||
# site shows it in each: an English page may not carry a `dashboard-*-hu.webp`, a Hungarian page a `-en.webp`.
|
||
_DASH_RE = re.compile(r"/assets/dashboard-[a-z0-9-]+?-(hu|en)\.webp")
|
||
_dash_n = 0
|
||
for p, s in pages.items():
|
||
want = lang_of(p)
|
||
for m in _DASH_RE.finditer(s):
|
||
_dash_n += 1
|
||
if m.group(1) != want:
|
||
fail("%s: a %s dashboard picture (%s) on a %s page — use the page's own language" % (p, m.group(1), m.group(0), want))
|
||
print(" dashboard pictures: %d references, each in its page's language" % _dash_n)
|
||
|
||
# gate 20: the one picture viewer (2026-10-08). A set is declared in the page: every opener is an <a href> to a file
|
||
# that exists under website/assets/, carrying data-gallery. A page with openers loads assets/gallery.js with a ?v=; a
|
||
# page that loads it has openers; the old inline viewer script appears on no page.
|
||
_OPENER_RE = re.compile(r"<(\w+)\b[^>]*\bdata-gallery=\"[^\"]*\"[^>]*>")
|
||
_GJS_RE = re.compile(r'<script defer src="/assets/gallery\.js(\?v=[0-9]+)?"></script>')
|
||
_open_n = 0
|
||
for p, s in pages.items():
|
||
openers = [m.group(0) for m in _OPENER_RE.finditer(s)]
|
||
_open_n += len(openers)
|
||
js = _GJS_RE.findall(s)
|
||
if openers and not js:
|
||
fail("%s: %d picture opener(s) (data-gallery) but no <script defer src=\"/assets/gallery.js?v=N\">" % (p, len(openers)))
|
||
if js and not js[0]:
|
||
fail("%s: assets/gallery.js loaded without ?v=" % p)
|
||
if js and not openers:
|
||
fail("%s: loads assets/gallery.js but declares no data-gallery picture" % p)
|
||
for o in openers:
|
||
m = re.match(r'<a\b[^>]*\bhref="(/assets/[^"]+)"', o)
|
||
if not m:
|
||
fail("%s: a data-gallery opener that is not an <a href=\"/assets/…\">: %s" % (p, o[:120]))
|
||
continue
|
||
if not os.path.exists(os.path.join(W, m.group(1).lstrip("/"))):
|
||
fail("%s: a data-gallery opener points at a missing file: %s" % (p, m.group(1)))
|
||
if "Screenshot Gallery / Lightbox" in s:
|
||
fail("%s: an inline copy of the picture viewer — it lives only in assets/gallery.js" % p)
|
||
print(" picture viewer: %d openers, every one a link to an existing file" % _open_n)
|
||
|
||
# gate 21: every catalog app has its logo under its slug (R-912)
|
||
_CAT = os.path.join(os.path.dirname(ROOT), "app-catalog-felhom.eu", "templates")
|
||
if not os.path.isdir(_CAT):
|
||
print(" app logos: NOT CHECKED — no catalog clone at %s" % _CAT)
|
||
else:
|
||
_slugs = []
|
||
for _f in sorted(glob.glob(os.path.join(_CAT, "*", ".felhom.yml"))):
|
||
_m = re.search(r'^slug:\s*["\']?([A-Za-z0-9._-]+)', io.open(_f, encoding="utf-8-sig").read(), re.M)
|
||
_slugs.append(_m.group(1) if _m else os.path.basename(os.path.dirname(_f)))
|
||
if not _slugs:
|
||
fail("app logos: the catalog clone at %s holds no templates/*/.felhom.yml — the gate checked nothing" % _CAT)
|
||
_missing = [x for x in _slugs if not any(os.path.isfile(os.path.join(W, "assets", "%s-logo.%s" % (x, e)))
|
||
for e in ("svg", "png"))]
|
||
if _missing:
|
||
fail("app logos: no website/assets/<slug>-logo.svg or .png for %s — the dashboard shows a grey placeholder"
|
||
% ", ".join(_missing))
|
||
else:
|
||
print(" app logos: %d catalog apps, each with its <slug>-logo file" % len(_slugs))
|
||
|
||
# gate 22 (2026-10-11): no CSS leak from site.css onto a shell page — MEDIA-AWARE.
|
||
#
|
||
# site.css loads BEFORE shell.css, so for a selector both files style, shell.css wins only on the
|
||
# properties it actually declares; everything else survives from site.css. That is how the phone menu
|
||
# shipped empty: site.css's old mobile nav still said, under @media (max-width: 900px),
|
||
# `.nav-links { position: fixed; right: -100% }`, which parked the drawer's items off-canvas. It was
|
||
# invisible at desktop width AND invisible to the check that was supposed to find it — that diff
|
||
# skipped @media blocks and reported six leaking declarations where the media-aware number was forty.
|
||
#
|
||
# A leak is: site.css declares property P on selector S, shell.css also styles S, and nothing
|
||
# overrides P for S — neither shell.css itself nor a `.shell …`-scoped reset in site.css. Scoping a
|
||
# rule `body:not(.shell)` (the fix) takes its selector out of the comparison, which is the point.
|
||
def css_rules(css):
|
||
"""-> [(media_context, selector, [(prop, value), ...])].
|
||
|
||
@media / @supports / @layer nest and their context is CARRIED, so a declaration that applies only
|
||
on a phone is still seen. @keyframes and @font-face hold no page selectors and are skipped.
|
||
"""
|
||
css = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
|
||
out = []
|
||
|
||
def walk(text, media):
|
||
i, n = 0, len(text)
|
||
while i < n:
|
||
j = text.find("{", i)
|
||
if j < 0:
|
||
break
|
||
prelude = text[i:j].strip()
|
||
depth, k = 1, j + 1
|
||
while k < n and depth:
|
||
if text[k] == "{":
|
||
depth += 1
|
||
elif text[k] == "}":
|
||
depth -= 1
|
||
k += 1
|
||
body = text[j + 1:k - 1]
|
||
if prelude.startswith("@"):
|
||
if prelude.split()[0].lower() in ("@media", "@supports", "@layer"):
|
||
walk(body, (media + " AND " + prelude) if media else prelude)
|
||
else:
|
||
decls = []
|
||
for d in body.split(";"):
|
||
if ":" in d and "{" not in d:
|
||
prop, val = d.split(":", 1)
|
||
if prop.strip():
|
||
decls.append((prop.strip().lower(), val.strip()))
|
||
for sel in prelude.split(","):
|
||
sel = re.sub(r"\s+", " ", sel).strip()
|
||
if sel:
|
||
out.append((media, sel, decls))
|
||
i = k
|
||
|
||
walk(css, "")
|
||
return out
|
||
|
||
|
||
# Shared ON PURPOSE: the shell's hamburger is the website's own button markup, so site.css styles the
|
||
# same two class names the controller does. Another name here needs the same kind of sentence.
|
||
_LEAK_ALLOW = {".hamburger-box", ".hamburger-line"}
|
||
_site_css_p = os.path.join(W, "assets", "site.css")
|
||
_shell_css_p = os.path.join(W, "assets", "shell.css")
|
||
if not (os.path.isfile(_site_css_p) and os.path.isfile(_shell_css_p)):
|
||
fail("shell leak: site.css or shell.css is missing — the gate checked nothing")
|
||
else:
|
||
_site_r = css_rules(io.open(_site_css_p, encoding="utf-8-sig").read())
|
||
_shell_r = css_rules(io.open(_shell_css_p, encoding="utf-8-sig").read())
|
||
if len(_site_r) < 200 or len(_shell_r) < 50:
|
||
fail("shell leak: parsed %d site rules and %d shell rules — too few to be the real files, so "
|
||
"nothing was compared" % (len(_site_r), len(_shell_r)))
|
||
_shell_sel = set(sel for _, sel, _ in _shell_r)
|
||
_overridden = set((sel, prop) for _, sel, decls in _shell_r for prop, _ in decls)
|
||
for _, _sel, _decls in _site_r: # a `.shell X { … }` reset in site.css overrides X too
|
||
if ".shell" in _sel:
|
||
for _prop, _ in _decls:
|
||
_overridden.add((_sel.split(" ")[-1], _prop))
|
||
_leaks = []
|
||
for _media, _sel, _decls in _site_r:
|
||
if _sel in _LEAK_ALLOW or ".shell" in _sel or "body:not(.shell)" in _sel:
|
||
continue
|
||
if _sel not in _shell_sel:
|
||
continue
|
||
for _prop, _val in _decls:
|
||
if (_sel, _prop) not in _overridden:
|
||
_leaks.append((_media, _sel, _prop, _val))
|
||
if _leaks:
|
||
for _media, _sel, _prop, _val in _leaks[:12]:
|
||
fail("shell leak: site.css %s`%s { %s: %s }` reaches a shell page — shell.css styles %s but "
|
||
"never sets %s, so this one survives. Scope the rule body:not(.shell), or reset the "
|
||
"property under .shell" % ((_media + " ") if _media else "", _sel, _prop, _val[:40],
|
||
_sel, _prop))
|
||
if len(_leaks) > 12:
|
||
fail("shell leak: and %d more leaking declaration(s), not listed" % (len(_leaks) - 12))
|
||
else:
|
||
print(" shell leak: %d site rules vs %d shell rules, %d shared selector(s), 0 leaking "
|
||
"declarations (media-aware)" % (len(_site_r), len(_shell_r),
|
||
len(set(s for _, s, _ in _site_r) & _shell_sel)))
|
||
|
||
if fails:
|
||
print("\nSITE GATES FAILED: %d problem(s)" % len(fails))
|
||
sys.exit(1)
|
||
print("site gates OK — BOM, emoji=0, nav/footer per language, analytics, no CDN, no legacy tokens, no <style>, "
|
||
"cache-busted assets, twins + hreflang + globe, lang, same apps, no Hungarian in English, FAQ JSON-LD, clean tail, dashboard pictures per language, one picture viewer, no shell CSS leak")
|