459 lines
18 KiB
Python
459 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
build_site.py - lê site-manifest.json e escreve os blocos repetidos das páginas.
|
|
|
|
Comandos (rode na pasta do site, ao lado do site-manifest.json):
|
|
scan compara pastas x manifesto: acrescenta páginas novas e preenche status que faltam
|
|
init coloca as marcas <!-- NOME:START --> / <!-- NOME:END --> nas páginas que ainda não têm
|
|
apply reescreve o conteúdo entre as marcas (use --dry-run para só ver o que mudaria)
|
|
check confere manifesto x disco, marcas, blocos desatualizados, links e endereços antigos
|
|
|
|
O script só escreve DENTRO das marcas. Nada fora delas é tocado.
|
|
"""
|
|
import argparse, fnmatch, json, os, re, sys
|
|
from pathlib import Path
|
|
|
|
BLOCKS = ["TRACKING", "HEADER", "NAV", "BREADCRUMB", "FOOTER", "CONTACT"]
|
|
OPTIONAL = {"CONTACT"} # só existe em páginas com o cartão "Contact Us" na sidebar
|
|
IGNORE_DIRS = {".git", "img", "images", "css", "js", "node_modules", "modelo"}
|
|
IGNORE_FILES = re.compile(r"^(google[0-9a-f]+|pinterest-[0-9a-z]+)\.html$")
|
|
HOME = "index.html"
|
|
|
|
|
|
# ---------------------------------------------------------------- manifesto
|
|
def load(root):
|
|
path = root / "site-manifest.json"
|
|
if not path.exists():
|
|
sys.exit(f"Não achei {path}")
|
|
m = json.loads(path.read_text(encoding="utf-8"))
|
|
m["_by"] = {p["file"]: p for p in m["pages"]}
|
|
return m
|
|
|
|
|
|
def save(root, m):
|
|
data = {k: v for k, v in m.items() if not k.startswith("_")}
|
|
head = json.dumps({k: v for k, v in data.items() if k != "pages"}, ensure_ascii=False, indent=2)
|
|
head = head.rstrip("}").rstrip().rstrip(",")
|
|
lines = ",\n".join(" " + json.dumps(p, ensure_ascii=False) for p in data["pages"])
|
|
(root / "site-manifest.json").write_text(head + ',\n "pages": [\n' + lines + "\n ]\n}\n", encoding="utf-8")
|
|
|
|
|
|
def url(m, f):
|
|
if m["options"].get("strip_index"):
|
|
if f == HOME:
|
|
return "/"
|
|
if f.endswith("/index.html"):
|
|
return "/" + f[: -len("index.html")]
|
|
return "/" + f
|
|
|
|
|
|
def pub(p):
|
|
return p.get("status") == "publicada"
|
|
|
|
|
|
def chain(m, f):
|
|
"""arquivo -> pai -> ... -> home (protegido contra ciclo)"""
|
|
out, seen = [], set()
|
|
while f and f not in seen and f in m["_by"]:
|
|
out.append(f)
|
|
seen.add(f)
|
|
f = m["_by"][f].get("parent")
|
|
return out
|
|
|
|
|
|
def esc(t):
|
|
return t.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
|
|
|
|
# ---------------------------------------------------------------- geradores
|
|
def gen_tracking(m, cur):
|
|
t = m["site"]["tracking"]
|
|
return f"""<!-- Google Tag Manager -->
|
|
<script>(function(w,d,s,l,i){{w[l]=w[l]||[];w[l].push({{'gtm.start':
|
|
new Date().getTime(),event:'gtm.js'}});var f=d.getElementsByTagName(s)[0],
|
|
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
|
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
|
}})(window,document,'script','dataLayer','{t['gtm']}');</script>
|
|
<!-- End Google Tag Manager -->
|
|
<!-- Matomo -->
|
|
<script>
|
|
var _paq = window._paq = window._paq || [];
|
|
_paq.push(['trackPageView']);
|
|
_paq.push(['enableLinkTracking']);
|
|
(function() {{
|
|
var u="{t['matomo_url']}";
|
|
_paq.push(['setTrackerUrl', u+'matomo.php']);
|
|
_paq.push(['setSiteId', '{t['matomo_site_id']}']);
|
|
var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
|
|
g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
|
|
}})();
|
|
</script>
|
|
<!-- End Matomo Code -->"""
|
|
|
|
|
|
def gen_header(m, cur):
|
|
s = m["site"]
|
|
return f"""<div class="main-header">
|
|
<div class="container header-container">
|
|
<div class="logo"><a href="{url(m, HOME)}"><img alt="{esc(s['name'])}" src="/img/logo.png"/></a></div>
|
|
<div class="info-contact">
|
|
<ul class="info-contact-list">
|
|
<li><i class="fa fa-phone"></i><p>{esc(s['phone'])}</p><p class="vice">Call Us Free</p></li>
|
|
<li><i class="fa fa-clock-o"></i><p>{esc(s['hours'])}</p><p class="vice">{esc(s['closed_note'])}</p></li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>"""
|
|
|
|
|
|
def gen_nav(m, cur):
|
|
pages = m["pages"]
|
|
ch = set(chain(m, cur))
|
|
li = []
|
|
for top in (p for p in pages if p.get("menu") == "top" and pub(p)):
|
|
active = top["file"] in ch and (top["file"] != HOME or cur == HOME)
|
|
subs = [c for c in pages if c.get("menu") == "sub" and c.get("parent") == top["file"] and pub(c)]
|
|
if not subs:
|
|
cls = ' class="active"' if active else ""
|
|
li.append(f'<li{cls}><a href="{url(m, top["file"])}">{esc(top["label"])}</a></li>')
|
|
continue
|
|
items = []
|
|
for c in subs:
|
|
cls = ' class="active"' if c["file"] in ch else ""
|
|
items.append(f' <li{cls}><a href="{url(m, c["file"])}">{esc(c["label"])}</a></li>')
|
|
if top.get("others"):
|
|
items.append(f' <li><a href="{url(m, top["file"])}">Others</a></li>')
|
|
cls = "menu-item-has-children" + (" active" if active else "")
|
|
li.append(
|
|
f'<li class="{cls}">\n'
|
|
f' <a href="{url(m, top["file"])}" aria-haspopup="true">{esc(top["label"])} <span class="fa fa-angle-down"></span></a>\n'
|
|
f' <ul class="sub-menu">\n' + "\n".join(items) + "\n </ul>\n</li>"
|
|
)
|
|
body = "\n".join(" " + l.replace("\n", "\n ") for l in li)
|
|
return f"""<div class="navBar6">
|
|
<div class="container nav-container">
|
|
<nav class="navigation" id="main-nav" aria-label="Main Navigation">
|
|
<ul class="nav text-uppercase">
|
|
{body}
|
|
</ul>
|
|
</nav>
|
|
<button aria-label="Toggle Navigation" class="mobile-nav-toggle" id="mobile-toggle"><span class="hamburger"></span></button>
|
|
</div>
|
|
</div>"""
|
|
|
|
|
|
def gen_breadcrumb(m, cur):
|
|
trail = list(reversed(chain(m, cur)))
|
|
items = []
|
|
for i, f in enumerate(trail):
|
|
p = m["_by"][f]
|
|
label = esc(p.get("crumb") or p["label"])
|
|
if i == len(trail) - 1 or not pub(p):
|
|
items.append(f" <li>{label}</li>")
|
|
else:
|
|
items.append(f' <li><a href="{url(m, f)}">{label}</a></li>')
|
|
return ('<div class="breadcrumb-container">\n <span>You are here:</span>\n <ul class="breadcrumb">\n'
|
|
+ "\n".join(items) + "\n </ul>\n</div>")
|
|
|
|
|
|
def gen_footer(m, cur):
|
|
s = m["site"]
|
|
social = "\n".join(
|
|
f' <li><a href="{x["url"]}" target="_blank" rel="noopener"><i class="fa {x["icon"]}"></i></a></li>'
|
|
for x in s.get("social", []))
|
|
fm = "\n".join(
|
|
f' <li><a href="{url(m, p["file"])}">{esc(p.get("footer_label") or p["label"])}</a></li>'
|
|
for p in m["pages"] if p.get("footer") and pub(p))
|
|
return f"""<footer class="footer bg-dark" id="footer">
|
|
<div class="foot-widget">
|
|
<div class="container">
|
|
<div class="footer-grid">
|
|
<div class="widget widget_about">
|
|
<div class="logo"><a href="{url(m, HOME)}"><img alt="{esc(s['name'])}" src="/img/logo-w.png"/></a></div>
|
|
<p>{esc(s['about_short'])}</p>
|
|
<ul class="social-list">
|
|
{social}
|
|
</ul>
|
|
</div>
|
|
<div class="widget widget_contact">
|
|
<h3 class="widget-title">Get in touch</h3>
|
|
<ul>
|
|
<li><i class="fa fa-mobile"></i>{esc(s['phone'])}</li>
|
|
<li><i class="fa fa-map-marker"></i>{esc(s['address'])}</li>
|
|
<li><i class="fa fa-paper-plane"></i>{esc(s['email'])}</li>
|
|
<li><i class="fa fa-clock-o"></i>{esc(s['hours'])}</li>
|
|
</ul>
|
|
</div>
|
|
<div class="widget widget_subscribe">
|
|
<h3 class="widget-title">Mobile Contact</h3>
|
|
<p>Quick options to call or email directly from your phone:</p>
|
|
<div class="mobile-actions">
|
|
<a class="btn btn-transparent radius" href="tel:{s['phone_tel']}">Click to Call</a>
|
|
<a class="btn btn-transparent radius" href="mailto:{s['email']}">Click to Email</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="foot-credit">
|
|
<div class="container foot-credit-flex">
|
|
<p>{s['copyright']}</p>
|
|
<ul class="foot-menu">
|
|
{fm}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</footer>"""
|
|
|
|
|
|
def gen_contact(m, cur):
|
|
s = m["site"]
|
|
return f"""<aside class="widget widget_contact">
|
|
<h3 class="widget-title">Contact Us</h3>
|
|
<ul>
|
|
<li><i class="fa fa-mobile"></i>{esc(s['phone'])}</li>
|
|
<li><i class="fa fa-map-marker"></i>{esc(s['address'])}</li>
|
|
<li><i class="fa fa-paper-plane"></i>{esc(s['email'])}</li>
|
|
<li><i class="fa fa-clock-o"></i>{esc(s['hours'])}</li>
|
|
</ul>
|
|
</aside>"""
|
|
|
|
|
|
GEN = {"TRACKING": gen_tracking, "HEADER": gen_header, "NAV": gen_nav,
|
|
"BREADCRUMB": gen_breadcrumb, "FOOTER": gen_footer, "CONTACT": gen_contact}
|
|
|
|
|
|
# ---------------------------------------------------------------- marcas
|
|
def marker_re(name):
|
|
return re.compile(r"([ \t]*)<!-- %s:START -->.*?<!-- %s:END -->" % (name, name), re.S)
|
|
|
|
|
|
def render(text, m, cur):
|
|
"""devolve (novo_texto, faltando) com os blocos regenerados"""
|
|
missing = []
|
|
for name in BLOCKS:
|
|
if name == "BREADCRUMB" and (cur == HOME or m["_by"][cur].get("no_breadcrumb")):
|
|
continue
|
|
rx = marker_re(name)
|
|
mt = rx.search(text)
|
|
if not mt:
|
|
if name not in OPTIONAL:
|
|
missing.append(name)
|
|
continue
|
|
ind = mt.group(1)
|
|
body = GEN[name](m, cur)
|
|
block = ind + f"<!-- {name}:START -->\n" + "\n".join(ind + l if l else l for l in body.split("\n")) \
|
|
+ f"\n{ind}<!-- {name}:END -->"
|
|
text = text[: mt.start()] + block + text[mt.end():]
|
|
return text, missing
|
|
|
|
|
|
def balanced_div(text, start):
|
|
depth = 0
|
|
for mt in re.finditer(r"<div\b|</div>", text[start:]):
|
|
depth += 1 if mt.group(0).startswith("<div") else -1
|
|
if depth == 0:
|
|
return start + mt.end()
|
|
return None
|
|
|
|
|
|
def find_span(text, name):
|
|
if name == "TRACKING":
|
|
a = text.find("<!-- Google Tag Manager -->")
|
|
b = text.find("<!-- End Matomo Code -->")
|
|
return (a, b + len("<!-- End Matomo Code -->")) if a >= 0 and b > a else None
|
|
if name == "CONTACT":
|
|
a = text.find('<aside class="widget widget_contact">')
|
|
b = text.find("</aside>", a)
|
|
return (a, b + len("</aside>")) if a >= 0 and b > a else None
|
|
if name == "FOOTER":
|
|
a = text.find("<footer")
|
|
b = text.find("</footer>", a)
|
|
return (a, b + len("</footer>")) if a >= 0 and b > a else None
|
|
cls = {"HEADER": "main-header", "NAV": "navBar6", "BREADCRUMB": "breadcrumb-container"}[name]
|
|
mt = re.search(r'<div class="%s"' % cls, text)
|
|
if not mt:
|
|
return None
|
|
end = balanced_div(text, mt.start())
|
|
return (mt.start(), end) if end else None
|
|
|
|
|
|
def add_markers(text):
|
|
added = []
|
|
for name in BLOCKS:
|
|
if f"<!-- {name}:START -->" in text:
|
|
continue
|
|
span = find_span(text, name)
|
|
if not span:
|
|
continue
|
|
a, b = span
|
|
line_start = text.rfind("\n", 0, a) + 1
|
|
ind = text[line_start:a] if not text[line_start:a].strip() else ""
|
|
text = (text[:line_start] + f"{ind}<!-- {name}:START -->\n" + text[line_start:b]
|
|
+ f"\n{ind}<!-- {name}:END -->" + text[b:])
|
|
added.append(name)
|
|
return text, added
|
|
|
|
|
|
# ---------------------------------------------------------------- comandos
|
|
def is_ignored(m, f):
|
|
"""padrões do manifesto (options.ignore), estilo curinga: 'files/*', 'resources/old-index.html'"""
|
|
return any(fnmatch.fnmatch(f, pat) for pat in m["options"].get("ignore", []))
|
|
|
|
|
|
def disk_pages(root, m=None):
|
|
for dp, dn, fn in os.walk(root):
|
|
dn[:] = [d for d in dn if d not in IGNORE_DIRS]
|
|
for f in fn:
|
|
if f.endswith(".html") and not IGNORE_FILES.match(f):
|
|
rel = str(Path(dp, f).relative_to(root)).replace(os.sep, "/")
|
|
yield rel
|
|
|
|
|
|
def label_from_h1(root, f):
|
|
try:
|
|
t = (root / f).read_text(encoding="utf-8", errors="ignore")
|
|
except OSError:
|
|
return None
|
|
mt = re.search(r"<h1[^>]*>(.*?)</h1>", t, re.S | re.I)
|
|
if not mt:
|
|
return None
|
|
txt = re.sub(r"<[^>]+>", "", mt.group(1))
|
|
txt = re.sub(r"\s+", " ", txt).strip()
|
|
return txt.replace("&", "&") or None
|
|
|
|
|
|
def has_content(root, f):
|
|
p = root / f
|
|
return p.is_file() and p.stat().st_size > 0
|
|
|
|
|
|
def cmd_scan(root, m):
|
|
changed = False
|
|
for p in m["pages"]:
|
|
if "status" not in p:
|
|
p["status"] = "publicada" if has_content(root, p["file"]) else "planejada"
|
|
print(f"status definido pelo disco: {p['file']} -> {p['status']}")
|
|
changed = True
|
|
known = set(m["_by"])
|
|
ignored = []
|
|
for f in sorted(disk_pages(root)):
|
|
if f in known:
|
|
continue
|
|
if is_ignored(m, f):
|
|
ignored.append(f)
|
|
continue
|
|
folder = Path(f).parent.as_posix()
|
|
parent = (folder + "/index.html") if folder != "." and f.endswith("/index.html") is False else None
|
|
if f.endswith("/index.html"):
|
|
up = Path(folder).parent.as_posix()
|
|
parent = (up + "/index.html") if up != "." else HOME
|
|
if parent and parent not in known and parent != HOME:
|
|
parent = HOME
|
|
label = label_from_h1(root, f) or Path(f).stem.replace("-", " ").title()
|
|
page = {"file": f, "label": label, "parent": parent or HOME,
|
|
"status": "publicada" if has_content(root, f) else "planejada"}
|
|
m["pages"].append(page)
|
|
print(f"NOVA página no disco, acrescentada ao manifesto: {f} ({page['status']}). Ajuste label/parent/menu.")
|
|
changed = True
|
|
for f in ignored:
|
|
print(f"ignorada (options.ignore): {f}")
|
|
for p in m["pages"]:
|
|
if not (root / p["file"]).exists():
|
|
print(f"AVISO: no manifesto mas não existe no disco: {p['file']} ({p.get('status')})")
|
|
if changed:
|
|
save(root, m)
|
|
else:
|
|
print("Nada a acrescentar.")
|
|
|
|
|
|
def cmd_init(root, m):
|
|
for p in m["pages"]:
|
|
if not has_content(root, p["file"]):
|
|
continue
|
|
path = root / p["file"]
|
|
text = path.read_text(encoding="utf-8")
|
|
new, added = add_markers(text)
|
|
if added:
|
|
path.write_text(new, encoding="utf-8")
|
|
print(f"{p['file']}: marcas em {', '.join(added)}")
|
|
print("Pronto. Agora rode: apply --dry-run")
|
|
|
|
|
|
def cmd_apply(root, m, dry):
|
|
n = 0
|
|
for p in m["pages"]:
|
|
if not pub(p):
|
|
continue
|
|
if not has_content(root, p["file"]):
|
|
print(f"ERRO: {p['file']} está 'publicada' no manifesto mas o arquivo falta ou está vazio")
|
|
continue
|
|
path = root / p["file"]
|
|
text = path.read_text(encoding="utf-8")
|
|
new, missing = render(text, m, p["file"])
|
|
if missing:
|
|
print(f"{p['file']}: sem marcas em {', '.join(missing)} (rode init)")
|
|
if new != text:
|
|
n += 1
|
|
print(("mudaria: " if dry else "atualizada: ") + p["file"])
|
|
if not dry:
|
|
path.write_text(new, encoding="utf-8")
|
|
print(f"{n} página(s) {'mudariam' if dry else 'atualizadas'}.")
|
|
|
|
|
|
def cmd_check(root, m):
|
|
errors = warns = 0
|
|
legacy = m["site"].get("old_strings", [])
|
|
for p in m["pages"]:
|
|
f = p["file"]
|
|
if not pub(p):
|
|
continue
|
|
if not has_content(root, f):
|
|
print(f"ERRO {f}: 'publicada' mas o arquivo falta ou está vazio"); errors += 1
|
|
continue
|
|
text = (root / f).read_text(encoding="utf-8")
|
|
new, missing = render(text, m, f)
|
|
if missing:
|
|
print(f"AVISO {f}: sem marcas em {', '.join(missing)}"); warns += 1
|
|
elif new != text:
|
|
print(f"AVISO {f}: blocos desatualizados (rode apply)"); warns += 1
|
|
for s in legacy:
|
|
if s in text:
|
|
print(f"ERRO {f}: contém '{s}'"); errors += 1
|
|
visible = re.sub(r"<!--.*?-->", "", text, flags=re.S) # ignora trechos comentados
|
|
for mt in re.finditer(r'(?:href|src)="([^"#?]+)', visible):
|
|
u = mt.group(1)
|
|
if re.match(r"(https?:|mailto:|tel:|//|data:|javascript:)", u):
|
|
continue
|
|
rel = u.lstrip("/") if u.startswith("/") else os.path.normpath(os.path.join(os.path.dirname(f), u))
|
|
cand = [rel, rel.rstrip("/") + "/index.html"] if not rel.endswith(".html") else [rel]
|
|
if not any((root / c).is_file() and (root / c).stat().st_size > 0 for c in cand):
|
|
if rel.startswith(("img/", "css/", "js/", "images/")) or "." in os.path.basename(rel) and not rel.endswith(".html"):
|
|
if (root / rel).is_file():
|
|
continue
|
|
print(f"ERRO {f}: link quebrado ou para página vazia -> {u}"); errors += 1
|
|
print(f"\n{errors} erro(s), {warns} aviso(s).")
|
|
return 1 if errors else 0
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("cmd", choices=["scan", "init", "apply", "check"])
|
|
ap.add_argument("--root", default=".", help="pasta do site (padrão: pasta atual)")
|
|
ap.add_argument("--dry-run", action="store_true")
|
|
a = ap.parse_args()
|
|
root = Path(a.root).resolve()
|
|
m = load(root)
|
|
if a.cmd == "scan":
|
|
cmd_scan(root, m)
|
|
elif a.cmd == "init":
|
|
cmd_init(root, m)
|
|
elif a.cmd == "apply":
|
|
cmd_apply(root, m, a.dry_run)
|
|
else:
|
|
sys.exit(cmd_check(root, m))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|