π§ Work in progress. The explorer is usable today, but the corpus underneath it is still being refined β text quality, metadata, and coverage are all in motion upstream, and data shapes may change with them. Nothing here should yet be treated as a complete or authoritative record. Issues and contributions welcome.
A fully static explorer for the NYC mayoral executive orders corpus β search, filter, and browse the full corpus in the browser, with no server and no API.
Queries run in the browser: DuckDB-WASM executes real SQL over Parquet files served as static assets.
Vibe coded with Claude by BetaNYC.
Deployed to GitHub Pages from main by
.github/workflows/deploy.yml: the corpus repo is
checked out alongside, the Parquet and picture crops are rebuilt from it, and the
whole thing is published as a static directory. Everything the page needs is served
from Pages except the scanned source PDFs, which are 1.1 GB of git-LFS files and are
read cross-origin instead β see "The source PDFs, side by side" below.
- Node β₯ 20
- The data repo checked out as a sibling directory:
<parent>/
nyc-executive-orders/ <- the corpus
nyc-eo-explorer/ <- this repo
That layout is assumed by the data build. Override it with EO_CORPUS_DIR=/path/to/corpus.
npm install
npm run dev # http://localhost:5275predev builds the Parquet artifacts automatically if they are missing or if the
corpus changed upstream, so those two commands are the whole setup. No Python, no
uv, no DuckDB install β the data build uses the @duckdb/node-api npm package.
| Command | What it does |
|---|---|
npm run dev |
Dev server (rebuilds data first if stale) |
npm run data |
Force a data rebuild |
npm run build / npm run preview |
Production build / serve it |
npm test |
Vitest β pure functions and real-SQL integration |
npm run lint / npm run format |
ESLint / Prettier |
The corpus repo publishes executive orders as Markdown and JSON. That's the right format for reading, diffing, and archiving, and the wrong one for searching 2,300 documents in a browser. This app translates it into something queryable, then does the querying client-side.
1. Build. npm run data reads the sibling corpus repo and rewrites it as
Parquet β a compact columnar format DuckDB can read
efficiently β in public/data/. Those files are derived artifacts: gitignored, and
rebuilt whenever the corpus changes upstream.
| File | Size | Contents |
|---|---|---|
eo_meta.parquet |
~80 KB | One row per order: dates, mayor, title, flags |
eo_text.parquet |
~1.7 MB | The full text of every order (~9 MB, compressed) |
supersession_edges.parquet |
~4 KB | Which orders revoked or amended which |
pictures.json |
~150 KB | Seals and signatures clipped out of the old scans |
manifest.json |
~1 KB | Counts, year range, checksums |
Metadata and text are split because most of the app never needs the text. Browsing, filtering, and the charts run off the 80 KB file; the megabyte of text is fetched the first time you search or open an order.
2. Serve. Everything is a static file. There is no server, no API, and no
database to run β npm run build produces a directory, and GitHub Pages hosts it.
3. Query. DuckDB-WASM runs in the browser and executes real SQL against those Parquet files, pulling them over HTTP a chunk at a time rather than downloading everything up front. Every search, facet count, and chart in the app is a SQL query. The engine is ~7 MB compressed, which is the largest single cost in the design and the reason it loads once and is shared across the whole app.
The one thing not served from Pages is the scanned source PDFs β 1.1 GB of git-LFS
files, too large to host there. Those are read from media.githubusercontent.com,
pinned to the corpus commit the site was built from.
The corpus is heavily OCR'd β about a fifth of orders are flagged as noisy, and 67
have no recoverable text at all β so a word index is the wrong default:
Adminstration will never match administration. Three modes are offered as equals
instead.
| Mode | What it does | Notes |
|---|---|---|
| Exact (default) | Plain substring match, case-insensitive | Always available |
| Regex | Full regex β e.g. admin[il1]strat[il1]on |
For working around OCR |
| Relevance | BM25 ranking via DuckDB's full-text search | Opt-in; downloads ~480 KB |
Relevance needs an extension that isn't built into duckdb-wasm, so selecting it fetches one at runtime. If that fails, search falls back to the other two modes rather than breaking.
The detail view shows the transcribed text next to the scan it came from. The text is
Markdown-ish β the OCR emits headings, emphasis, and HTML tables β so it renders
through marked and is sanitized with DOMPurify, with a Rendered / Raw toggle
because anyone checking a transcription against the scan needs the characters as
stored.
The scan is displayed with pdf.js's own viewer component, which brings continuous scroll, text selection, and fit-to-width along with it. Pre-1974 orders are a few pages inside a scanned bound volume, so the viewer opens on the order's first page (page 49 of 334, not page 1) and the toolbar lists the specific pages that order occupies β they aren't always contiguous, since the volumes have blank versos.
Those old volumes are also where pictures.json comes from: the OCR tags regions of
each page as pictures β city seals, mayoral signatures β and upstream clips them out
of the scans, since none of it survives in the text. The build matches each clip to
whichever order was printed on that page. The Pictures tab turns that around and
searches by text to show images: look up robert moses and you get the seals and
signatures from the three orders that name him.
- Column types are written explicitly in the build script. EO numbers are
strings β
"001","23","1.37"β and left to guess, DuckDB turns"001"into1and quietly loses data. - The pre-1974 corpus is optional. Upstream is still OCR'ing those volumes; the
build picks them up when they appear. Don't assume an
eo_idisEOorEEOβ that era also has1951-EM-001,1972-AM-007, and others. - Cross-origin range requests need help. CORS hides
Accept-Rangesfrom pdf.js, so without the custom transport inPdfViewerit downloads entire 49 MB volumes before showing a page.
npm test- Unit tests cover the pure logic: the SQL builder, the query parser, hash-state round-tripping, and snippet extraction. These are the modules where a bug is silent β a wrong result set looks like a plausible result set.
test/sql-integration.test.jsruns every SQL shape the app can emit against a real DuckDB and the real Parquet. Counts are derived from the manifest, never hardcoded β the corpus grows as Phase E volumes land β so the assertions are invariants (the parts sum to the whole, a facet partitions the corpus). A unit test on a string builder can only prove it produced the string you expected β not that the string compiles. It skips itself ifpublic/datahas not been built.
Not covered: the browser worker, ?url asset resolution, and the FTS extension
load. Those need a real browser; if they earn it, add Playwright as a separate
test:e2e.
Deliberately mirrors qa-ui/ in the data repo so the two read as one project:
React 18.3 + Vite 6, plain .jsx (no TypeScript), one hand-written styles.css
with a CSS-variable design system, no UI library, hooks only, and no router β
deep links ride the URL hash.
One deliberate divergence: qa-ui serves its data through dev-server middleware,
which means its npm run build produces a non-functional bundle. This app reads
plain files from public/, so dev and production are the same code path. The one
asset that is not a plain file β the scans, which are too large to be one β is the
only place the two diverge, and src/paths.js is the single seam where that
happens.
Chart colors were validated for colorblind separation and contrast rather than
chosen by eye. text_quality uses a sequential ramp because it is an ordered
scale, not a set of identities.
This is early-stage, openly built civic infrastructure β contributions to the app, its tests, or the corpus it reads are all welcome.
git clone https://github.com/BetaNYC/nyc-eo-explorer.git && cd nyc-eo-explorer
npm install && npm run dev # builds the Parquet artifacts first if they're stale
npm test # unit + real-SQL integrationYou need the corpus repo checked out as a sibling directory (see Requirements) β that is where the data comes from. Work on the data itself β OCR text, titles, metadata, coverage gaps β belongs upstream in that repo, which has its own review queue and open issues.
The one hard rule carries over from upstream: never invent or guess at content. This app displays a public record; an empty or flagged field is correct, a plausible substitute is a bug. Nothing in the UI may present derived or guessed data as if it came from the source document.
Questions, ideas, or a bug you've spotted: open an issue or hello@beta.nyc.
This explorer is one of a family of free, open civic data assets BetaNYC builds and stewards:
nyc-executive-ordersβ the corpus this app reads: an open, machine-readable archive of NYC mayoral executive orders, 1974βpresent.ny-gov-web-registryβ which NY government entities exist, and where they live on the web.ny-gov-web-archiverβ the throttled Wayback Machine harvester behind the corpus's historical backfills.nyc-boundariesβ NYC administrative boundaries, mapped and queryable.- Seven MCP servers giving AI agents direct access to NYC/NYS civic data: Council legislation, City Record, Checkbook spending, 311, Charter/Code/Rules, NYS legislation, and the Council budget.
BetaNYC uses AI tools openly and with human accountability. This repository was built by AI agents (Anthropic's Claude) working under the direction and review of BetaNYC staff β the app code was written by AI, and the scope and design decisions are human-made.
One commitment about the content: the executive orders this app displays are not AI-generated. Order text is served verbatim from the corpus, which retrieves it from official City sources and the Internet Archive β never summarized, paraphrased, or invented. The app renders and searches that text; it does not rewrite it. Where the corpus is incomplete or its OCR is imperfect, the UI says so rather than smoothing it over.
Questions about our approach: hello@beta.nyc.
- Code (this repository): MIT Β© 2026 BetaNYC β use and modify freely with attribution preserved.
- The data it displays β the corpus, the source scans, and the picture crops β lives in
nyc-executive-ordersunder Creative Commons Attribution-ShareAlike 4.0: reuse and adapt with credit to BetaNYC, and share adaptations under the same license.
The underlying executive orders are public records of the City of New York; these licenses cover this tooling and the compilation it reads.
Documents shown here are reproductions of public records, retrieved from official City sources and the Internet Archive under its access guidelines. Text of the oldest orders is OCR output β faithful to the scan, not perfected β and is not legal advice; verify against the source PDF, which every record links to.