Static site. No database, no build server, no dependencies. Plain HTML, one stylesheet, a handful of small scripts. Everything runs inside what GitHub Pages can serve.
index.html home
404.html dead links, carries the normal header and footer
about/ library/ advisory/ speaking/ contact/
contact/thanks/ form landing page, only reached when JavaScript is off
assets/css/site.css the whole stylesheet, tokens at the top
assets/js/nav.js mobile menu
assets/js/library.js library filtering, progressive enhancement only
assets/js/external-links.js opens outbound links and PDFs in a new tab
assets/js/contact.js submits the contact form without leaving the page
data/publications.json every library item, single source of truth
files/ PDFs, clean paths
wp-content/uploads/ the same PDFs at their original WordPress paths,
so links published between 2010 and 2026 keep working
tools/build.py regenerates library and homepage from the JSON
tools/make-favicon.py regenerates the IH monogram in every size
tools/mirror-assets.sh one-time download from the old WordPress site
Live and delivering. The site is static and cannot send email itself, so the form
posts to Web3Forms, which relays to the address registered against the access key
in contact/index.html.
The destination address never appears anywhere public. Not in the HTML, not in
this repository, not in the network request the browser makes. Only the access key
travels, and a key cannot be resolved back to an address. That is the entire reason
the form exists rather than a mailto: link, which scrapers harvest within days of
publication.
The access key itself is not a secret and is fine sitting in public HTML. It is a routing identifier. The one thing someone could do with a copied key is post to the form from elsewhere, which produces junk mail rather than any disclosure. If that ever starts, generate a replacement key at https://web3forms.com against the same address and swap it in.
To point the form at a different inbox, create a key for that address and replace
the access_key value. Nothing else changes.
The form also carries a honeypot field. Bots fill in every input they can find; people never see this one, and Web3Forms rejects anything that arrives with it set.
Worth doing once after any change: send yourself a test message from the live site and confirm it lands, including in spam.
Edit data/publications.json, then:
python3 tools/build.py
git add . && git commit -m "Add <title>" && git push
build.py validates the JSON first and refuses to write anything if an entry is
malformed. It only rewrites text between <!-- BUILD:NAME:START --> and
<!-- BUILD:NAME:END --> markers. Everything outside those is hand-edited and safe.
Set "external": true on anything hosted elsewhere. That is what stamps
target="_blank" onto the link at build time.
topic is normally a single string, one of ospo, compliance, ai,
government, ma, strategy. Something that genuinely belongs under two takes
a list instead:
"topic": "strategy" one topic, what most entries use
"topic": ["strategy", "ai"] two, the one it belongs to most first
It then shows up under both filters, and the row carries a badge for each. The first topic leads, so order the list deliberately.
Do not add a second entry for the same piece to file it under another topic. It would appear twice under All and add one to the item count.
A plain string behaves exactly as it always has, so nothing needs revisiting.
Under the hood build.py writes the topics into data-topic separated by
spaces, and library.js matches against that list rather than comparing the
whole attribute. build.py rejects an unknown topic, an empty list, and the
same topic listed twice.
The Year row filters by period rather than by individual year. Twenty years are represented and four of them, 2004, 2009, 2014 and 2015, hold nothing, so a chip per year would be a wall of buttons with dead ends in it.
The periods are plain HTML in library/index.html, inside the third
filters__row. build.py does not generate or touch that row, so edit it by
hand:
<button class="chip" data-filter="year" data-value="2019-2022"
data-from="2019" data-to="2022" aria-pressed="false">2019–2022</button>
data-from and data-to are inclusive bounds and either can be left off for an
open end. The newest chip carries only data-from, so it keeps working as years
pass; the oldest carries only data-to. data-value is what lands in the URL,
so keep it short and stable once a link is out in the world.
The periods currently hold 38, 19, 9 and 21 items. Rebalance them when the newest one grows top-heavy, which it will: roughly half the library is from the last four years.
The iD appears in three places: the footer "Elsewhere" list, the intro on
/library/, and the sameAs array in the schema.org block. All three live on
every page except the library display, which is on /library/ alone.
sameAs is the one that earns its keep. It is invisible to readers and is how
search engines tie this site, ORCID and Google Scholar together as one person.
Add any future profile there as well as to the footer.
The icon is ORCID's official SVG, self-hosted at assets/img/orcid.svg.
Their display guidelines ask for the green mark beside the full
https://orcid.org/… URI rather than the bare digits, which is why the library
line reads the way it does.
Do not replace it with the snippet ORCID hands out. That one hotlinks the icon from their servers, which puts a third-party request on every page view and breaks silently if they move the file, and it also opens a named window instead of a new tab and carries a CSS class this site does not define.
Run every new logo through the normaliser before committing it.
pip3 install pillow
python3 tools/normalize-logo.py ~/Downloads/acme.png assets/img/collaborators/acme.jpg
The rows set img { height: 30px; width: auto }, which means the file's
canvas decides how big a logo looks, not the mark itself. Supplied logo files
arrive on wildly different canvases, so dropping one in untouched gets you either
a 5px smudge or something twice the size of its neighbours. Both have happened
here.
The script trims to the ink and rebuilds it on the 500x250 canvas every logo in this repo uses, scaled so the mark's bounding box covers the same area as every other logo. Aspect ratio is kept, so a wide wordmark ends up long and short and a roundel narrow and tall, but the two take up the same amount of the row. Equal area is what stops any one logo reading as dominant. All 27 logos are through it, so every one renders at 60x30 with ink area within 1%.
It prints the resulting area; if a new logo reports CAPPED, its aspect ratio is
extreme enough to hit a guard and it will come out slightly smaller than its
neighbours. That is usually a sign the source art has odd padding.
Then add the <img> to the row by hand. Collaborators on /advisory/ are sorted
alphabetically, ignoring a leading "The" so the Linux Foundation files under L.
The homepage row is deliberately not alphabetical: it runs in employment
order, most recent first.
assets/img/og-card.png is what LinkedIn, X and Slack render when anyone posts a
link to the site. Rebuild it if the claim on the homepage changes, or the portrait
does.
pip3 install pillow
python3 tools/make-og-card.py
Then re-scrape the URL at https://www.linkedin.com/post-inspector/ so LinkedIn drops its cached copy. It will otherwise keep serving the old card for weeks.
Only needed if the accent colour or the heading typeface changes.
pip3 install fonttools pillow
python3 tools/make-favicon.py
It downloads Newsreader, converts the I and H to outlines, and writes
favicon.ico, assets/img/favicon.svg, and the three PNG sizes. The letterforms
are baked in as paths, so the icon needs no webfont to render.
python3 -m http.server 8000
Then open http://localhost:8000
.nojekyll stops GitHub Pages running the site through Jekyll. Do not delete it.
CNAME holds the custom domain. GitHub rewrites this if you change the domain in
Settings, so leave it alone unless you are moving hosts.
The library page renders every item as real HTML. JavaScript only hides and shows them. With scripting disabled the page still lists everything.
Two counts are deliberately different and should not be reconciled. The homepage stat strip counts distinct works, excluding translations and second editions. The library lists every entry, including them. Separately, the library is a selection: it holds what can be given away freely, while the full record of 150+ publications lives on Google Scholar. The library page says so.
Every link leaving the site opens in a new tab. Most of that is baked into the HTML,
by hand in the header and footer and by build.py for publications.
external-links.js is the safety net for anything added later that was missed, so
turning JavaScript off degrades the behaviour rather than breaking it.
Two structured-data notes. Every page carries a schema.org @graph with a Person
and a WebSite node, both keyed by @id so crawlers understand it is one person
across six pages. 404.html and contact/thanks/ deliberately carry none, because
both are noindex.
There is one trap in site.css. Section 6 RESPONSIVE sits above the ADDITIONS
section at the bottom of the file, so its media queries cannot override anything
declared below them. Components declared in ADDITIONS get their breakpoints in the
block at the very end of the file. The comment there explains it.