Instantly share code, notes, and snippets.
Last active
August 11, 2026 00:37
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
-
Save AnythingLinux/c87a5a8a255a1397c1ef143ad2ed093c to your computer and use it in GitHub Desktop.
Copy the homepage's <footer> block into other HTML pages
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| sync_footer.py — Copy the homepage's <footer> block into other site HTML | |
| pages, with a mandatory dry-run validation pass before anything is written. | |
| WORKFLOW | |
| 1. Extract the footer block from the source page (homepage), matched by | |
| the "<!-- ===== FOOTER ===== -->" marker + <footer>...</footer>. | |
| 2. For every target page, find its own footer block (marker+<footer> if | |
| present, otherwise a bare <footer>...</footer>) and simulate the swap | |
| in memory only (dry run). | |
| 3. Validate each simulated result: | |
| - HTML must still parse (no unclosed/mismatched tags) | |
| - exactly one <footer> element remains | |
| - no duplicate id="" attributes were introduced | |
| - any inline JSON-LD blocks still parse as valid JSON | |
| 4. Print a pass/fail report for every target file. | |
| 5. Only if ALL targets pass does it write the real files (each original | |
| is backed up to <file>.bak.<timestamp> first). Use --skip-failed to | |
| instead apply only to the pages that passed. | |
| USAGE | |
| # Point it at your site root. It looks for <site_dir>/index.html as the | |
| # source footer, and recursively targets every other *.html file in | |
| # <site_dir>. | |
| python3 sync_footer.py /var/www/html --dry-run # validate only, writes nothing | |
| python3 sync_footer.py /var/www/html # apply (only if the dry run above passed) | |
| python3 sync_footer.py /var/www/html --skip-failed # apply to passing pages even if some fail | |
| # Override the defaults if your layout differs: | |
| python3 sync_footer.py /var/www/html --source /var/www/html/index.html --targets "/var/www/html/**/*.html" | |
| EXIT CODES | |
| 0 success (dry run passed everywhere and files were written, or | |
| --dry-run was requested and validation passed everywhere) | |
| 1 one or more targets failed validation and nothing was written | |
| 2 usage / setup error (bad paths, no footer found in source, etc.) | |
| """ | |
| import argparse | |
| import datetime | |
| import glob | |
| import json | |
| import re | |
| import sys | |
| from html.parser import HTMLParser | |
| from pathlib import Path | |
| FOOTER_MARKER = "<!-- ===== FOOTER ===== -->" | |
| # Tags that never need a closing tag — used by the balance checker so we | |
| # don't flag normal void elements as "unclosed". | |
| VOID_ELEMENTS = { | |
| "area", "base", "br", "col", "embed", "hr", "img", "input", | |
| "link", "meta", "param", "source", "track", "wbr", | |
| } | |
| # -------------------------------------------------------------------------- | |
| # Footer extraction | |
| # -------------------------------------------------------------------------- | |
| class FooterNotFound(Exception): | |
| pass | |
| def find_footer_block(html: str): | |
| """Return (start_idx, end_idx, block_text) for the <footer> element in | |
| html, including the preceding marker comment if it sits immediately | |
| before the opening <footer> tag. Raises FooterNotFound if no <footer> | |
| element exists, or if it's unbalanced (never closed).""" | |
| open_re = re.compile(r"<footer\b[^>]*>", re.IGNORECASE) | |
| close_re = re.compile(r"</footer\s*>", re.IGNORECASE) | |
| m_open = open_re.search(html) | |
| if not m_open: | |
| raise FooterNotFound("no <footer> element found") | |
| start_tag_idx = m_open.start() | |
| # Include the marker comment if it directly precedes <footer> (only | |
| # whitespace/newlines in between). | |
| marker_idx = html.rfind(FOOTER_MARKER, 0, start_tag_idx) | |
| if marker_idx != -1 and html[marker_idx + len(FOOTER_MARKER):start_tag_idx].strip() == "": | |
| block_start = marker_idx | |
| else: | |
| block_start = start_tag_idx | |
| # Walk forward tracking nested <footer> depth (defensive; footers don't | |
| # normally nest, but this keeps the match correct if one ever does). | |
| pos = m_open.end() | |
| depth = 1 | |
| while depth > 0: | |
| next_close = close_re.search(html, pos) | |
| if not next_close: | |
| raise FooterNotFound("<footer> is never closed (unbalanced)") | |
| next_open = open_re.search(html, pos, next_close.start()) | |
| if next_open: | |
| depth += 1 | |
| pos = next_open.end() | |
| else: | |
| depth -= 1 | |
| pos = next_close.end() | |
| block_end = pos | |
| return block_start, block_end, html[block_start:block_end] | |
| # -------------------------------------------------------------------------- | |
| # Validation | |
| # -------------------------------------------------------------------------- | |
| class _TagBalanceChecker(HTMLParser): | |
| """Minimal well-formedness check: flags unclosed and mismatched tags.""" | |
| def __init__(self): | |
| super().__init__(convert_charrefs=True) | |
| self.stack = [] | |
| self.errors = [] | |
| def handle_starttag(self, tag, attrs): | |
| if tag in VOID_ELEMENTS: | |
| return | |
| self.stack.append(tag) | |
| def handle_startendtag(self, tag, attrs): | |
| pass # self-closed tag, e.g. <br/> — nothing to push | |
| def handle_endtag(self, tag): | |
| if tag in VOID_ELEMENTS: | |
| return | |
| if not self.stack: | |
| self.errors.append(f"extra closing tag </{tag}> with nothing open") | |
| return | |
| if self.stack[-1] == tag: | |
| self.stack.pop() | |
| return | |
| if tag in self.stack: | |
| while self.stack and self.stack[-1] != tag: | |
| self.errors.append( | |
| f"tag <{self.stack[-1]}> was never closed before </{tag}>" | |
| ) | |
| self.stack.pop() | |
| if self.stack: | |
| self.stack.pop() | |
| else: | |
| self.errors.append(f"</{tag}> found but <{tag}> was never opened") | |
| def check_tag_balance(html: str): | |
| parser = _TagBalanceChecker() | |
| parser.feed(html) | |
| errors = list(parser.errors) | |
| for tag in parser.stack: | |
| errors.append(f"<{tag}> was never closed") | |
| return errors | |
| def check_single_footer(html: str): | |
| count = len(re.findall(r"<footer\b", html, re.IGNORECASE)) | |
| if count == 0: | |
| return ["no <footer> element present after replacement"] | |
| if count > 1: | |
| return [f"{count} <footer> elements present after replacement (expected 1)"] | |
| return [] | |
| def check_duplicate_ids(html: str): | |
| ids = re.findall(r'\bid\s*=\s*"([^"]+)"', html, re.IGNORECASE) | |
| seen, dupes = set(), set() | |
| for i in ids: | |
| if i in seen: | |
| dupes.add(i) | |
| seen.add(i) | |
| if dupes: | |
| return [f'duplicate id="{d}" found after replacement' for d in sorted(dupes)] | |
| return [] | |
| def check_json_ld(html: str): | |
| errors = [] | |
| for i, m in enumerate( | |
| re.finditer( | |
| r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>', | |
| html, | |
| re.DOTALL | re.IGNORECASE, | |
| ), | |
| start=1, | |
| ): | |
| try: | |
| json.loads(m.group(1)) | |
| except json.JSONDecodeError as e: | |
| errors.append(f"JSON-LD block #{i} is invalid JSON: {e}") | |
| return errors | |
| def validate_result(html: str): | |
| """Run every check against a simulated post-replacement document. | |
| Returns a list of error strings (empty list == passed).""" | |
| errors = [] | |
| errors += check_tag_balance(html) | |
| errors += check_single_footer(html) | |
| errors += check_duplicate_ids(html) | |
| errors += check_json_ld(html) | |
| return errors | |
| # -------------------------------------------------------------------------- | |
| # Core sync logic | |
| # -------------------------------------------------------------------------- | |
| def simulate_replacement(target_html: str, new_footer: str): | |
| """Return the target's HTML with its footer swapped for new_footer. | |
| Raises FooterNotFound if the target has no footer to replace.""" | |
| start, end, _old_block = find_footer_block(target_html) | |
| return target_html[:start] + new_footer + target_html[end:] | |
| def run(source_path: Path, target_paths: list, dry_run_only: bool, skip_failed: bool): | |
| if not source_path.is_file(): | |
| print(f"ERROR: source file not found: {source_path}", file=sys.stderr) | |
| return 2 | |
| source_html = source_path.read_text(encoding="utf-8") | |
| try: | |
| _s, _e, new_footer = find_footer_block(source_html) | |
| except FooterNotFound as e: | |
| print(f"ERROR: could not extract footer from source '{source_path}': {e}", file=sys.stderr) | |
| return 2 | |
| print(f"Source footer extracted from {source_path} ({len(new_footer)} chars)\n") | |
| results = {} # path -> (passed: bool, errors: list[str], new_html: str|None) | |
| for target in target_paths: | |
| target = Path(target) | |
| if target.resolve() == source_path.resolve(): | |
| continue # never touch the source itself | |
| if not target.is_file(): | |
| results[target] = (False, [f"file not found: {target}"], None) | |
| continue | |
| original_html = target.read_text(encoding="utf-8") | |
| try: | |
| new_html = simulate_replacement(original_html, new_footer) | |
| except FooterNotFound as e: | |
| results[target] = (False, [f"could not locate footer to replace: {e}"], None) | |
| continue | |
| errors = validate_result(new_html) | |
| results[target] = (len(errors) == 0, errors, new_html) | |
| # ---- report ---- | |
| print("DRY RUN RESULTS") | |
| print("=" * 60) | |
| all_passed = True | |
| any_target = False | |
| for target, (passed, errors, _new_html) in results.items(): | |
| any_target = True | |
| status = "PASS" if passed else "FAIL" | |
| print(f"[{status}] {target}") | |
| for err in errors: | |
| print(f" - {err}") | |
| if not passed: | |
| all_passed = False | |
| print("=" * 60) | |
| if not any_target: | |
| print("No target files were found — nothing to do.") | |
| return 2 | |
| if dry_run_only: | |
| print("\n--dry-run-only set: no files were written.") | |
| return 0 if all_passed else 1 | |
| if not all_passed and not skip_failed: | |
| print( | |
| "\nOne or more pages failed validation. No files were written.\n" | |
| "Fix the issues above, or re-run with --skip-failed to apply only " | |
| "to the pages that passed." | |
| ) | |
| return 1 | |
| # ---- apply ---- | |
| timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") | |
| written = [] | |
| for target, (passed, _errors, new_html) in results.items(): | |
| if not passed: | |
| continue | |
| backup_path = Path(str(target) + f".bak.{timestamp}") | |
| backup_path.write_text(target.read_text(encoding="utf-8"), encoding="utf-8") | |
| target.write_text(new_html, encoding="utf-8") | |
| written.append((target, backup_path)) | |
| print(f"\nApplied footer to {len(written)} file(s):") | |
| for target, backup_path in written: | |
| print(f" - {target} (backup: {backup_path})") | |
| return 0 if all_passed else 1 | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| description="Sync the homepage footer into other site pages, with dry-run validation." | |
| ) | |
| parser.add_argument( | |
| "site_dir", | |
| nargs="?", | |
| default=None, | |
| help="Site root directory. Defaults to <site_dir>/index.html as the " | |
| "source and every other *.html file under <site_dir> (recursive) " | |
| "as targets. Optional if --source and --targets are both given.", | |
| ) | |
| parser.add_argument("--source", help="Path to the homepage HTML file (source of truth for the footer). Defaults to <site_dir>/index.html.") | |
| parser.add_argument("--targets", help="Glob pattern for target HTML files. Defaults to '<site_dir>/**/*.html'.") | |
| parser.add_argument("--dry-run", "--dry-run-only", dest="dry_run_only", action="store_true", help="Only validate; never write files.") | |
| parser.add_argument("--skip-failed", action="store_true", help="Apply to passing pages even if some pages fail validation.") | |
| args = parser.parse_args() | |
| if not args.site_dir and not (args.source and args.targets): | |
| parser.error("provide a site_dir, or both --source and --targets") | |
| site_dir = Path(args.site_dir) if args.site_dir else None | |
| source_path = Path(args.source) if args.source else site_dir / "index.html" | |
| targets_glob = args.targets if args.targets else str(site_dir / "**" / "*.html") | |
| target_paths = sorted(glob.glob(targets_glob, recursive=True)) | |
| exit_code = run(source_path, target_paths, args.dry_run_only, args.skip_failed) | |
| sys.exit(exit_code) | |
| if __name__ == "__main__": | |
| main() |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
nano sync_footer.py
chmod sync_footer.py
python3 sync_footer.py /var/www/html --dry-run # validate only, writes nothing
python3 sync_footer.py /var/www/html # apply (only if the dry run above passed)
python3 sync_footer.py /var/www/html --skip-failed # apply to passing pages even if some fail