Files
felhom.eu/scripts/site_gates.py
T
admin 0ef0b106f6
gates / gates (push) Failing after 6m51s
website: the operator's seven notes - a services page, the hero, and 100 dead links
Reported from his own screens. Five defects, two design.

1. THE CATEGORY ANCHORS WERE DEAD - all 100 of them. The sidebar's five app-category
   links pointed at #fajlkezeles-irodai and friends; the ids are #fajlkezeles and
   friends. The sidebar is copied into every page, so one wrong fragment was wrong
   twenty times, and a dead fragment raises no error anywhere: the browser stays put.

   New gate 23 resolves every in-site #fragment against the page it names (360 today).
   It convicted two more on its first run: the home page's four hero tiles pointed at
   /alkalmazasok#immich, which no page had ever carried, and the FAQ's contact button
   pointed at a home-page section that had moved out. Every app card now carries its
   slug as an id - taken from the card's own logo file, which gate 21 already ties to
   the catalog, so the ids cannot drift from it silently.
   Decoy site/dead-fragment puts the operator's own bug back and the gate convicts.

2. SZOLGALTATASOK IS A PAGE: /szolgaltatasok + /en/services, registered in TWINS, URL
   and SHELL. The ten capability items moved VERBATIM out of the home page; the home
   keeps the promise sentence, the three group names as links and a button, and is 345
   Hungarian / 434 English words shorter. The .caps rules stopped being .page-index-
   scoped, because the block now lives on a page with a different body class.

3. THE HOME HEADLINE is back in the hero column beside the picture, two lines with the
   second in --blue-bright at clamp(2.1rem, 4.4vw, 3.2rem). M3 had moved it into
   .page-header - the INNER pages' heading - so it shrank to 2.1rem and the domain
   badge was stranded beside it.

4. THE HERO PICTURE is the real dashboard in a laptop. The frame is the operator's own
   supplied render; the screen is dashboard-start-{hu,en}.webp composited into it, so a
   visitor sees a real box's Launcher rather than a drawing of one - the supplied
   screens are AI-drawn and misspell app names. 57 KB, named dashboard-laptop-<lang>
   so gate 19 holds it to the page's language.

5. .page-lede HAD NO CSS AT ALL on 16 pages: a bare paragraph, full width of the
   content column, hard against the box beneath it. 760px measure, centred over the
   box, air beneath; left-aligned on /szolgaltatasok, which has no centred box.

6. THE SIDEBAR LOGO: the wordmark shipped at 0.67x the mark's width where the real
   lockup (assets/logo.png, measured: mark 492px wide, wordmark 631px) has it at 1.28x.
   Mark 112px, wordmark 25px tall, 28px less vertical padding.

7. SCROLLBARS are the page's own now. The first attempt missed the viewport's: it is
   painted from <html>, and a `body.shell ::-webkit-...` rule only reaches scrollers
   inside the page.

Also: the two Latin font subsets are preloaded. With font-display: swap the page
reflowed after first paint - measured at 1339px on /alkalmazasok#media.

Checked at 390px and 1440px on all 21 pages: no sideways scrolling, footer inside the
content column everywhere, drawer opens with all 13 items. site.css?v=24.
2026-10-12 08:58:42 +02:00

787 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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
23. anchors — every in-site link carrying a #fragment finds that id on the page it names.
The sidebar's five category links pointed at #fajlkezeles-irodai and friends while
the page's ids are #fajlkezeles and friends: 100 dead links across 20 pages, live,
found by the operator clicking one
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
"szolgaltatasok.html": os.path.join("en", "services.html"), # 2026-10-12 — was a section of the home 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",
"szolgaltatasok.html": "/szolgaltatasok", os.path.join("en", "services.html"): "/en/services",
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',
'szolgaltatasok.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", 'services.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)))
# gate 23 (2026-10-12): an in-site #fragment has to exist on the page it points at.
#
# The shell's sidebar is copied into every page, so ONE wrong fragment is wrong twenty times: the five
# app-category links shipped pointing at ids nobody had written (#fajlkezeles-irodai for a section
# whose id is #fajlkezeles). Nothing noticed, because a dead fragment is not an error anywhere - the
# browser simply stays put. Checked for every page the gate already walks; a link to a page outside
# PAGES, or to another site, is not this gate's business.
_ids = {}
for _p in PAGES:
_s = io.open(os.path.join(W, _p), encoding="utf-8-sig").read()
_ids[_p] = set(re.findall(r'\sid="([^"]+)"', _s))
_url_to_page = {}
for _p, _u in URL.items():
_url_to_page[_u] = _p
if _u.endswith("/"): # "/" and "/en/" are index pages
_url_to_page[_u + "index.html"] = _p
_anchor_n, _anchor_bad = 0, 0
for _p in PAGES:
_s = io.open(os.path.join(W, _p), encoding="utf-8-sig").read()
for _href in re.findall(r'href="([^"]*#[^"]+)"', _s):
if _href.startswith(("http://", "https://", "mailto:")):
continue
_page_part, _frag = _href.split("#", 1)
if _page_part == "":
_target = _p # same page
elif _page_part in _url_to_page:
_target = _url_to_page[_page_part]
else:
continue # a URL this gate does not own
_anchor_n += 1
if _frag not in _ids.get(_target, ()):
_anchor_bad += 1
fail("%s: link to %s finds no id=\"%s\" on %s — the browser stays where it is and the link "
"reads as broken" % (_p, _href, _frag, _target))
if not _anchor_bad:
print(" anchors: %d in-site #fragment(s), every one resolves" % _anchor_n)
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, live anchors")