fix: maintain Services dropdown header from one canonical source
The site header / Services mega-dropdown was duplicated across two render systems (Astro pages via Base.astro->nav.html, and ~80 pre-rendered static public/**/index.html pages each embedding their own copy). They had drifted into 5 different variants (missing 'New Carrier Setup', misplaced Healthcare column, NEW vs FREE badges, em-dash encoding differences), so dev.performancewest.net, the order pages, and the rest of the site disagreed. - Make site/src/partials/nav.html the single source of truth (adopts the most complete variant). - Add scripts/sync_nav.py to rewrite every static page's <nav> block from nav.html (idempotent; --check guards against drift in CI/deploy). - Run the sync automatically in deploy.sh and scripts/deploy-dev.sh. - Deprecate scripts/inject_healthcare_nav.py (now delegates to sync_nav.py). - Neutralize the broken no-op SiteNav.astro component. All 80 headers + the Astro-built order pages now render the identical dropdown.
This commit is contained in:
parent
695ace207c
commit
bd9a70607f
86 changed files with 250 additions and 645 deletions
|
|
@ -10,6 +10,16 @@
|
|||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# Single source of truth for the header: rewrite every static page's <nav>
|
||||
# block from site/src/partials/nav.html so the Services dropdown can never
|
||||
# drift between dev.performancewest.net, the order pages, and the rest of
|
||||
# the site. Idempotent.
|
||||
echo "=== Syncing canonical site header (Services dropdown) ==="
|
||||
python3 "$SCRIPT_DIR/sync_nav.py"
|
||||
|
||||
REMOTE="deploy@207.174.124.71"
|
||||
SSH="ssh -p 22022"
|
||||
SCP="scp -P 22022"
|
||||
|
|
|
|||
|
|
@ -1,82 +1,21 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Inject the Healthcare nav column into the pre-rendered static pages.
|
||||
"""DEPRECATED -- superseded by scripts/sync_nav.py.
|
||||
|
||||
The site is mostly static HTML under site/public/**/index.html, each carrying
|
||||
its own copy of the Services mega-dropdown (desktop + mobile). The Astro
|
||||
Base.astro layout reads src/partials/nav.html, but the static pages do NOT,
|
||||
so adding a sector to nav.html alone does not show up on those pages.
|
||||
Historically this script injected just the Healthcare column into the static
|
||||
pages, which caused the Services dropdown to drift (different sectors / badges /
|
||||
ordering on different pages). The header is now maintained in ONE place
|
||||
(site/src/partials/nav.html) and synced wholesale into every static page by
|
||||
scripts/sync_nav.py.
|
||||
|
||||
This injects the (canonical) Healthcare block from nav.html into every static
|
||||
page that has the dropdown but is missing Healthcare. Idempotent.
|
||||
This shim simply delegates to sync_nav.py so any existing automation that still
|
||||
calls inject_healthcare_nav.py keeps working.
|
||||
"""
|
||||
import re
|
||||
import runpy
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PUBLIC = ROOT / "site" / "public"
|
||||
NAV = ROOT / "site" / "src" / "partials" / "nav.html"
|
||||
|
||||
nav = NAV.read_text()
|
||||
|
||||
m_desk = re.search(
|
||||
r'(<p class="text-\[11px\][^>]*>Healthcare</p>.*?npi-compliance-check.*?</a>)',
|
||||
nav, re.S,
|
||||
)
|
||||
m_mob = re.search(
|
||||
r'(<p class="text-xs font-semibold[^>]*>Healthcare</p>.*?npi-compliance-check.*?</a>)',
|
||||
nav, re.S,
|
||||
)
|
||||
if not (m_desk and m_mob):
|
||||
sys.exit("Could not extract Healthcare blocks from nav.html")
|
||||
|
||||
DESKTOP_BLOCK = m_desk.group(1)
|
||||
MOBILE_BLOCK = m_mob.group(1)
|
||||
|
||||
# Desktop insertion: place the Healthcare block at the end of Column 3, right
|
||||
# before the "Form a Business" CTA that closes that column.
|
||||
DESKTOP_ANCHOR = '<a href="/order/formation" class="mt-3 block py-2 px-3 text-sm font-medium text-white bg-pw-700 hover:bg-pw-800 rounded-lg text-center transition-colors">Form a Business</a>'
|
||||
|
||||
# Mobile insertion: place Healthcare right before the mobile Corporate heading.
|
||||
MOBILE_ANCHOR = '<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider px-2 pt-3">Corporate</p>'
|
||||
|
||||
|
||||
def inject(html: str) -> tuple[str, bool]:
|
||||
if "services-menu" not in html:
|
||||
return html, False # no dropdown on this page
|
||||
if "npi-compliance-check" in html:
|
||||
return html, False # already has Healthcare
|
||||
|
||||
changed = False
|
||||
|
||||
if DESKTOP_ANCHOR in html and DESKTOP_BLOCK not in html:
|
||||
html = html.replace(DESKTOP_ANCHOR, DESKTOP_BLOCK + " " + DESKTOP_ANCHOR, 1)
|
||||
changed = True
|
||||
|
||||
if MOBILE_ANCHOR in html and MOBILE_BLOCK not in html:
|
||||
html = html.replace(MOBILE_ANCHOR, MOBILE_BLOCK + " " + MOBILE_ANCHOR, 1)
|
||||
changed = True
|
||||
|
||||
return html, changed
|
||||
|
||||
|
||||
def main():
|
||||
files = sorted(PUBLIC.rglob("index.html")) + [PUBLIC / "404.html"]
|
||||
touched, skipped, nodrop = 0, 0, 0
|
||||
for f in files:
|
||||
if not f.exists():
|
||||
continue
|
||||
html = f.read_text()
|
||||
new, changed = inject(html)
|
||||
if changed:
|
||||
f.write_text(new)
|
||||
touched += 1
|
||||
elif "services-menu" not in html:
|
||||
nodrop += 1
|
||||
else:
|
||||
skipped += 1
|
||||
print(f"injected: {touched} already-had/partial: {skipped} no-dropdown: {nodrop}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
print("inject_healthcare_nav.py is deprecated; delegating to sync_nav.py", file=sys.stderr)
|
||||
target = Path(__file__).with_name("sync_nav.py")
|
||||
sys.argv = [str(target)]
|
||||
runpy.run_path(str(target), run_name="__main__")
|
||||
|
|
|
|||
127
scripts/sync_nav.py
Normal file
127
scripts/sync_nav.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Single source of truth for the site header / Services mega-dropdown.
|
||||
|
||||
Background
|
||||
----------
|
||||
The marketing site is served from two rendering systems that historically each
|
||||
carried their OWN copy of the header markup, which is why the Services dropdown
|
||||
drifted out of sync (dev.performancewest.net vs the order pages vs everything
|
||||
else):
|
||||
|
||||
1. Astro pages (site/src/pages/**/*.astro) -> layouts/Base.astro reads
|
||||
src/partials/nav.html and injects it.
|
||||
2. Pre-rendered static pages (site/public/**/index.html, 404.html) each
|
||||
embedded a hard-coded <nav>...</nav> block.
|
||||
|
||||
This script makes ``site/src/partials/nav.html`` the ONE canonical source and
|
||||
rewrites the <nav>...</nav> block of every static page to match it byte-for-byte.
|
||||
It is idempotent and safe to run on every build/deploy.
|
||||
|
||||
Canonical block
|
||||
---------------
|
||||
``nav.html`` is a single line that begins with the document <body> tag and the
|
||||
"<!-- Navigation -->" comment, followed by exactly one ``<nav>...</nav>`` block.
|
||||
The <nav>...</nav> portion is what gets synced into the static pages (their own
|
||||
<body ...> attributes are preserved).
|
||||
|
||||
Usage
|
||||
-----
|
||||
python3 scripts/sync_nav.py # rewrite static pages from nav.html
|
||||
python3 scripts/sync_nav.py --check # exit 1 if any page is out of sync
|
||||
|
||||
Run with --check in CI / pre-commit to guarantee the header never drifts again.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PUBLIC = ROOT / "site" / "public"
|
||||
NAV_PARTIAL = ROOT / "site" / "src" / "partials" / "nav.html"
|
||||
|
||||
NAV_RE = re.compile(r"<nav\b.*?</nav>", re.S)
|
||||
|
||||
|
||||
def canonical_nav() -> str:
|
||||
"""Extract the canonical <nav>...</nav> block from the partial."""
|
||||
partial = NAV_PARTIAL.read_text()
|
||||
m = NAV_RE.search(partial)
|
||||
if not m:
|
||||
sys.exit(f"ERROR: no <nav>...</nav> block found in {NAV_PARTIAL}")
|
||||
if "services-menu" not in m.group(0):
|
||||
sys.exit(f"ERROR: canonical nav in {NAV_PARTIAL} is missing the Services dropdown")
|
||||
return m.group(0)
|
||||
|
||||
|
||||
def static_pages() -> list[Path]:
|
||||
files = sorted(PUBLIC.rglob("index.html"))
|
||||
fof = PUBLIC / "404.html"
|
||||
if fof.exists():
|
||||
files.append(fof)
|
||||
return files
|
||||
|
||||
|
||||
def sync(check_only: bool) -> int:
|
||||
canon = canonical_nav()
|
||||
pages = static_pages()
|
||||
|
||||
out_of_sync: list[Path] = []
|
||||
changed = 0
|
||||
skipped_no_nav = 0
|
||||
|
||||
for f in pages:
|
||||
html = f.read_text()
|
||||
if "services-menu" not in html:
|
||||
skipped_no_nav += 1
|
||||
continue
|
||||
|
||||
m = NAV_RE.search(html)
|
||||
if not m:
|
||||
# has services-menu text but no <nav> wrapper -> structural anomaly
|
||||
print(f"WARN: {f.relative_to(ROOT)} has a Services dropdown but no <nav> wrapper; skipping")
|
||||
skipped_no_nav += 1
|
||||
continue
|
||||
|
||||
current = m.group(0)
|
||||
if current == canon:
|
||||
continue # already in sync
|
||||
|
||||
out_of_sync.append(f)
|
||||
if not check_only:
|
||||
new_html = html[: m.start()] + canon + html[m.end():]
|
||||
f.write_text(new_html)
|
||||
changed += 1
|
||||
|
||||
if check_only:
|
||||
if out_of_sync:
|
||||
print(f"OUT OF SYNC: {len(out_of_sync)} static page(s) differ from nav.html:")
|
||||
for f in out_of_sync:
|
||||
print(f" - {f.relative_to(ROOT)}")
|
||||
print("\nRun `python3 scripts/sync_nav.py` to fix.")
|
||||
return 1
|
||||
print(f"OK: all {len(pages) - skipped_no_nav} header(s) match nav.html")
|
||||
return 0
|
||||
|
||||
print(
|
||||
f"synced: {changed} already-in-sync: {len(pages) - skipped_no_nav - changed}"
|
||||
f" no-dropdown: {skipped_no_nav}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="report drift and exit non-zero instead of rewriting",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
return sync(check_only=args.check)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue