#!/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'{esc(top["label"])}') continue items = [] for c in subs: cls = ' class="active"' if c["file"] in ch else "" items.append(f' {esc(c["label"])}') if top.get("others"): items.append(f'
  • Others
  • ') cls = "menu-item-has-children" + (" active" if active else "") li.append( f'
  • \n' f' {esc(top["label"])} \n' f' \n
  • " ) 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"
  • {label}
  • ") else: items.append(f'
  • {label}
  • ') return ('") def gen_footer(m, cur): s = m["site"] social = "\n".join( f'
  • ' for x in s.get("social", [])) fm = "\n".join( f'
  • {esc(p.get("footer_label") or p["label"])}
  • ' for p in m["pages"] if p.get("footer") and pub(p)) return f"""

    {s['copyright']}

      {fm}
    """ 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("", a) return (a, b + len("")) 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()