#!/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 / 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"""
"""
def gen_header(m, cur):
s = m["site"]
return f"""
{esc(s['phone'])}
Call Us Free
{esc(s['hours'])}
{esc(s['closed_note'])}
"""
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'
"
)
body = "\n".join(" " + l.replace("\n", "\n ") for l in li)
return f"""
"""
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"
'
for p in m["pages"] if p.get("footer") and pub(p))
return f""""""
def gen_contact(m, cur):
s = m["site"]
return f""""""
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]*).*?" % (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"\n" + "\n".join(ind + l if l else l for l in body.split("\n")) \
+ f"\n{ind}"
text = text[: mt.start()] + block + text[mt.end():]
return text, missing
def balanced_div(text, start):
depth = 0
for mt in re.finditer(r"
", text[start:]):
depth += 1 if mt.group(0).startswith("
")
b = text.find("")
return (a, b + len("")) if a >= 0 and b > a else None
if name == "CONTACT":
a = text.find('", a)
return (a, b + len("")) if a >= 0 and b > a else None
if name == "FOOTER":
a = text.find("")) if a >= 0 and b > a else None
cls = {"HEADER": "main-header", "NAV": "navBar6", "BREADCRUMB": "breadcrumb-container"}[name]
mt = re.search(r'
" 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}\n" + text[line_start:b]
+ f"\n{ind}" + 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"
]*>(.*?)
", 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()