From 93458a0d81f4069f8ba2d55e0644dd7856c11451 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 05:07:16 +0000 Subject: [PATCH] Serve a static copy of the portfolio to clients without JavaScript The Claude Design export keeps all of its content in a bundled template, so crawlers, link previewers, recruiter tools and agents fetching aabiro.github.io saw only an "Unpacking..." loader. The build now reads the page's own copy and component data out of that template and writes it into index.html as plain HTML. Browsers with JavaScript still get the designed page unchanged; the loader replaces the document as before. CI fails the build if the static copy is missing or the bare loader text is still served. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N4PmHXHU33xFbkndjeQEhH --- .github/workflows/github-pages.yml | 2 + README.md | 10 + scripts/build_site.sh | 13 +- scripts/inject_static_profile.py | 373 +++++++++++++++++++++++++++++ 4 files changed, 393 insertions(+), 5 deletions(-) create mode 100644 scripts/inject_static_profile.py diff --git a/.github/workflows/github-pages.yml b/.github/workflows/github-pages.yml index 7ac36a0..a1c0f5c 100644 --- a/.github/workflows/github-pages.yml +++ b/.github/workflows/github-pages.yml @@ -38,6 +38,8 @@ jobs: test -f site/favicon.png grep -q 'application/ld+json' site/index.html grep -q 'og:image' site/index.html + grep -q 'id="static-profile"' site/index.html + if grep -q 'Unpacking\.\.\.' site/index.html; then echo "index.html still serves the bare loader"; exit 1; fi - uses: actions/upload-pages-artifact@v3 if: github.event_name != 'pull_request' with: diff --git a/README.md b/README.md index 0ae50da..c71774b 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,18 @@ Dual-audience portfolio for humans and LLM agents (see `pxl-registry` S5 L6): | `llms.txt` | repo root → `site/llms.txt` | https://aabiro.github.io/llms.txt | | Agent manifest | `.well-known/agent.json` | https://aabiro.github.io/.well-known/agent.json | | JSON-LD | `scripts/inject_seo.py` | Person + CreativeWork `@graph` | +| No-JavaScript copy | `scripts/inject_static_profile.py` | `
` in `index.html` | | Resume source | `RESUME.md` | built to `assets/Aaryn_Biro_Resume.pdf` in CI | +The Claude Design export renders only with JavaScript, so on its own a crawler, +link previewer, recruiter tool or agent fetching the page sees an empty loader. +`scripts/inject_static_profile.py` reads the page's own copy and component data +(experience, work, capabilities, stack, contact) out of the bundled template and +writes it into `index.html` as plain HTML. Browsers with JavaScript still swap in +the designed page as before. Re-exporting `page/page.html` keeps the static copy +in sync automatically; the build fails if it can no longer find the experience +or work data. + `scripts/build_site.sh` copies agent files into `site/` alongside the HTML page. `scripts/build_resume_pdf.sh` regenerates the PDF from `RESUME.md` when pandoc is available. diff --git a/scripts/build_site.sh b/scripts/build_site.sh index f103140..4a72547 100755 --- a/scripts/build_site.sh +++ b/scripts/build_site.sh @@ -2,10 +2,10 @@ # Assemble the static deploy directory (site/) that GitHub Pages serves. # # The page itself is the Claude Design export (page/page.html); this injects the -# portfolio's SEO / agent into it and gathers the supporting assets -# (favicon, icons, manifest, robots, sitemap, OG image, llms.txt, -# .well-known/agent.json, resume PDF) alongside it. The resume is expected to -# already be built by scripts/build_resume_pdf.sh. +# portfolio's SEO / agent and a static copy of its content into it, and +# gathers the supporting assets (favicon, icons, manifest, robots, sitemap, OG +# image, llms.txt, .well-known/agent.json, resume PDF) alongside it. The resume +# is expected to already be built by scripts/build_resume_pdf.sh. set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" SITE="$ROOT/site" @@ -13,8 +13,11 @@ SITE="$ROOT/site" rm -rf "$SITE" mkdir -p "$SITE" "$SITE/.well-known" "$SITE/assets" -# 1. The page, with SEO / agent metadata injected into its . +# 1. The page, with SEO / agent metadata injected into its , plus a static +# copy of its content for crawlers, link previewers and agents that do not +# run JavaScript. python3 "$ROOT/scripts/inject_seo.py" "$ROOT/page/page.html" "$SITE/index.html" +python3 "$ROOT/scripts/inject_static_profile.py" "$SITE/index.html" # 2. Favicon + PWA icons + manifest. cp "$ROOT/web/favicon.png" "$SITE/favicon.png" diff --git a/scripts/inject_static_profile.py b/scripts/inject_static_profile.py new file mode 100644 index 0000000..1697fab --- /dev/null +++ b/scripts/inject_static_profile.py @@ -0,0 +1,373 @@ +#!/usr/bin/env python3 +"""Give the built page a static, readable copy of the portfolio. + +The Claude Design export keeps all of its content inside a bundled template and +a component script, so the HTML that crawlers, link previewers, recruiter tools +and LLM agents fetch holds nothing but an "Unpacking..." loader. This reads the +copy and the component data out of that template and writes them into the page +as plain, semantic HTML. + +Browsers with JavaScript still get the designed page exactly as before: the +loader keeps its thumbnail over the static copy while it unpacks, then replaces +the whole document. Everything that does not run JavaScript reads the static +copy instead. Runs after inject_seo.py in scripts/build_site.sh; safe to re-run. +""" +import html +import json +import re +import sys + +MARKER = 'id="static-profile"' +RESUME_HREF = "assets/Aaryn_Biro_Resume.pdf" + + +# --- JS literal parsing ------------------------------------------------------ +# The component declares its content as class fields holding plain JS literals +# (single-quoted strings, bare keys, trailing commas). This reads that subset. + +class _JSLiteral: + def __init__(self, src: str, pos: int): + self.src = src + self.pos = pos + + def _ws(self) -> None: + while self.pos < len(self.src): + if self.src[self.pos].isspace(): + self.pos += 1 + elif self.src.startswith("//", self.pos): + end = self.src.find("\n", self.pos) + self.pos = len(self.src) if end == -1 else end + elif self.src.startswith("/*", self.pos): + self.pos = self.src.index("*/", self.pos) + 2 + else: + return + + def value(self): + self._ws() + ch = self.src[self.pos] + if ch == "[": + return self._array() + if ch == "{": + return self._object() + if ch in "'\"`": + return self._string() + m = re.compile(r"-?\d+(\.\d+)?|true|false|null").match(self.src, self.pos) + if not m: + raise ValueError(f"unsupported JS literal at {self.src[self.pos:self.pos + 40]!r}") + self.pos = m.end() + return json.loads(m.group(0)) + + def _string(self) -> str: + quote = self.src[self.pos] + self.pos += 1 + out = [] + while self.src[self.pos] != quote: + ch = self.src[self.pos] + if ch == "\\": + self.pos += 1 + ch = {"n": "\n", "t": "\t"}.get(self.src[self.pos], self.src[self.pos]) + out.append(ch) + self.pos += 1 + self.pos += 1 + return "".join(out) + + def _array(self) -> list: + self.pos += 1 + items = [] + while True: + self._ws() + if self.src[self.pos] == "]": + self.pos += 1 + return items + items.append(self.value()) + self._ws() + if self.src[self.pos] == ",": + self.pos += 1 + + def _object(self) -> dict: + self.pos += 1 + obj = {} + while True: + self._ws() + if self.src[self.pos] == "}": + self.pos += 1 + return obj + if self.src[self.pos] in "'\"": + key = self._string() + else: + m = re.compile(r"[A-Za-z_$][\w$]*").match(self.src, self.pos) + if not m: + raise ValueError(f"unsupported object key at {self.src[self.pos:self.pos + 40]!r}") + key = m.group(0) + self.pos = m.end() + self._ws() + if self.src[self.pos] != ":": + raise ValueError(f"expected ':' after key {key!r}") + self.pos += 1 + obj[key] = self.value() + self._ws() + if self.src[self.pos] == ",": + self.pos += 1 + + +def _field(script: str, name: str): + """Value of a `name = ;` class field, or None if it is absent.""" + m = re.search(r"^\s*" + re.escape(name) + r"\s*=\s*(?=[\[{'\"])", script, re.M) + if not m: + return None + try: + return _JSLiteral(script, m.end()).value() + except (ValueError, IndexError) as err: + sys.exit(f"inject_static_profile: could not read {name!r}: {err}") + + +# --- Template copy ----------------------------------------------------------- + +def _text(fragment: str) -> str: + """Visible text of an HTML fragment, whitespace-collapsed.""" + fragment = re.sub(r"<[^>]+>", " ", fragment) + return re.sub(r"\s+", " ", html.unescape(fragment)).strip() + + +def _without_bindings(fragment: str) -> str: + """Drop / blocks, innermost first: their copy is data-driven or conditional.""" + block = re.compile(r"]*>(?:(?!", re.S) + while True: + fragment, n = block.subn("", fragment) + if not n: + return fragment + + +def _static_texts(fragment: str, tag: str) -> list: + """Text of every in the fragment that is not a template binding.""" + fragment = _without_bindings(fragment) + out = [] + for m in re.finditer(rf"<{tag}\b[^>]*>(.*?)", fragment, re.S): + if "{{" not in m.group(1): + text = _text(m.group(1)) + if text: + out.append(text) + return out + + +def _sections(markup: str) -> list: + """(id, heading, lead texts) for each
in page order.""" + out = [] + for m in re.finditer(r'
]*>(.*?)
', markup, re.S): + body = m.group(2) + h2 = _static_texts(body, "h2") + out.append((m.group(1), h2[0] if h2 else "", _static_texts(body, "p"))) + return out + + +# --- Rendering --------------------------------------------------------------- + +def _e(value) -> str: + return html.escape(str(value), quote=True) + + +def _tags(tags) -> str: + if not tags: + return "" + return f'

{_e(", ".join(tags))}

' + + +def _render_work(data: dict) -> str: + parts = [] + for s in data.get("caseStudies") or []: + outcomes = "".join(f"
  • {_e(o)}
  • " for o in s.get("outcomes", [])) + parts.append( + "
    " + f'

    {_e(s.get("title", ""))}

    ' + f'

    {_e(s.get("kicker", ""))}

    ' + f'

    {_e(s.get("summary", ""))}

    ' + + (f"
      {outcomes}
    " if outcomes else "") + + _tags(s.get("tags")) + + "
    " + ) + return "".join(parts) + + +def _render_github(links: dict) -> str: + return f'

    View contribution history on GitHub (@aabiro)

    ' + + +def _render_systems(data: dict) -> str: + parts = [] + for cap in data.get("capabilities") or []: + items = "".join( + f'
  • {_e(i.get("title", ""))}. {_e(i.get("body", ""))}
  • ' + for i in cap.get("items", []) + ) + parts.append( + "
    " + f'

    {_e(cap.get("name", ""))}

    ' + f'

    {_e(cap.get("summary", ""))}

    ' + + (f"
      {items}
    " if items else "") + + "
    " + ) + return "".join(parts) + + +def _render_experience(data: dict) -> str: + parts = [] + for job in data.get("experience") or []: + role, company = job.get("role", ""), job.get("company", "") + meta = " | ".join(v for v in (job.get("period"), job.get("place")) if v) + parts.append( + "
    " + f"

    {_e(role)}, {_e(company)}

    " + f'

    {_e(meta)}

    ' + f'

    {_e(job.get("summary", ""))}

    ' + + _tags(job.get("tags")) + + "
    " + ) + parts.append(f'

    Download the full resume (PDF)

    ') + return "".join(parts) + + +def _render_stack(data: dict) -> str: + rows = "".join( + f'
  • {_e(col.get("title", ""))}: {_e(", ".join(col.get("skills", [])))}
  • ' + for col in data.get("skillColumns") or [] + ) + return f"
      {rows}
    " if rows else "" + + +def _render_contact(links: dict) -> str: + return ( + "" + ) + + +def _links(template: str) -> dict: + def first(pattern: str, default: str) -> str: + m = re.search(pattern, template) + return m.group(0) if m else default + + email = first(r"[\w.+-]+@[\w-]+\.[\w.]+", "aaryn.alexander@gmail.com") + return { + "email": email, + "linkedin": first(r"https://www\.linkedin\.com/in/[\w-]+/?", "https://www.linkedin.com/in/aabiro/"), + "github": first(r"https://github\.com/[\w-]+(?=['\"])", "https://github.com/aabiro"), + } + + +def render(template: str) -> str: + m = re.search(r'', template, re.S) + script = m.group(1) if m else "" + data = { + name: _field(script, name) + for name in ("metrics", "caseStudies", "capabilities", "experience", "skillColumns", "heroTags") + } + links = _links(template) + + hero = re.search(r'data-screen-label="Hero".*?
    ', template, re.S) + hero_markup = hero.group(0) if hero else "" + name = (_static_texts(hero_markup, "h1") or ["Aaryn Biro"])[0] + hero_paras = _static_texts(hero_markup, "p") + + out = ['
    ', "
    ", f"

    {_e(name)}

    "] + out += [f"

    {_e(p)}

    " for p in hero_paras[:2]] + out.append( + '" + ) + out.append(_tags(data["heroTags"])) + out.append("
    ") + + if data["metrics"]: + items = "".join( + f'
  • {_e(m.get("value", ""))} {_e(m.get("label", ""))}: {_e(m.get("detail", ""))}
  • ' + for m in data["metrics"] + ) + out.append(f'

    Highlights

      {items}
    ') + + renderers = { + "Work": lambda: _render_work(data), + "GitHub": lambda: _render_github(links), + "Systems": lambda: _render_systems(data), + "Experience": lambda: _render_experience(data), + "Stack": lambda: _render_stack(data), + "Contact": lambda: _render_contact(links), + } + for sec_id, heading, paras in _sections(template): + out.append(f'

    {_e(sec_id)}

    ') + if heading: + out.append(f'

    {_e(heading)}

    ') + out += [f"

    {_e(p)}

    " for p in paras] + out.append(renderers.get(sec_id, lambda: "")()) + out.append("
    ") + + out.append("
    ") + fallback = "\n".join(part for part in out if part) + + if not (data["experience"] or data["caseStudies"]): + sys.exit("inject_static_profile: found no experience or work entries in the template") + return fallback + + +STYLE = """""" + +NOSCRIPT = """""" + + +def main(path: str) -> None: + with open(path, "r", encoding="utf-8") as fh: + page = fh.read() + + if MARKER in page: + print("inject_static_profile: static profile already present, leaving as is") + return + + m = re.search(r'', page, re.S) + if not m: + sys.exit("inject_static_profile: bundled template not found") + fallback = render(json.loads(m.group(1))) + + page, n = re.subn(r"", NOSCRIPT, page, count=1, flags=re.S) + if n != 1: + sys.exit("inject_static_profile: expected loader