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