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
|
|
@ -13,6 +13,14 @@ SERVICES="${@:-site api workers}"
|
|||
echo "=== Pulling latest from git ==="
|
||||
git pull origin main
|
||||
|
||||
# Single source of truth for the site header: rewrite every static page's
|
||||
# <nav> block from site/src/partials/nav.html so the Services dropdown stays
|
||||
# identical across the static site, the Astro order pages, and dev. Idempotent;
|
||||
# does nothing if already in sync. (See scripts/sync_nav.py.)
|
||||
echo ""
|
||||
echo "=== Syncing canonical site header (Services dropdown) ==="
|
||||
python3 scripts/sync_nav.py
|
||||
|
||||
echo ""
|
||||
echo "=== Building: $SERVICES ==="
|
||||
# ERPNext bakes the custom Frappe apps into its image, so they must be staged
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,6 +1,13 @@
|
|||
---
|
||||
// Production site navigation — extracted from the static homepage.
|
||||
// This component is included in Base.astro to give all Astro-built
|
||||
// pages the same nav as the static pages in public/.
|
||||
// DEPRECATED / unused.
|
||||
//
|
||||
// Site navigation is rendered from a single canonical source:
|
||||
// - Astro pages: layouts/Base.astro reads src/partials/nav.html and injects it.
|
||||
// - Static pages (public/**/index.html): kept byte-identical to nav.html by
|
||||
// scripts/sync_nav.py (run on every deploy).
|
||||
//
|
||||
// This component used to attempt an Astro.glob() include but was a no-op
|
||||
// (it always rendered an empty string). It is intentionally left empty so
|
||||
// any stray imports keep compiling; do NOT add nav markup here -- edit
|
||||
// src/partials/nav.html instead.
|
||||
---
|
||||
<Fragment set:html={await Astro.glob('../partials/nav.html').then(() => '').catch(() => '')} />
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue