- TypeScript 54.4%
- HTML 45.6%
|
|
||
|---|---|---|
| .claude | ||
| .forgejo/workflows | ||
| celilo | ||
| docs | ||
| openspec | ||
| scripts | ||
| site | ||
| tests | ||
| .gitignore | ||
| .mise.toml | ||
| bun.lock | ||
| csv-to-json.test.ts | ||
| csv-to-json.ts | ||
| exclude.txt | ||
| freeya-logo.png | ||
| geocode-cache.json | ||
| manifest.test.ts | ||
| manifest.yml | ||
| package.json | ||
| pipeline.integration.test.ts | ||
| README.md | ||
| squarespace-snippet.html | ||
| squarespace-snippet.template.html | ||
| tsconfig.json | ||
| yard-sale-info.json | ||
BNA Yard Sale Map
The Boise Neighborhood Association runs a neighborhood-wide yard sale on the last Saturday in September. Neighbors sign up through a Google Form. This repo turns those signups into a map that goes on the BNA's Squarespace site.
The map is a celilo module. It serves as a static site at
bna-yard-sale.org, and the Squarespace page links to it.
The data is still hand-run: you export the form responses, run the pipeline,
build the site, and deploy. Automating that is add-data-refresh, and it is
not built yet.
The process, end to end
- Neighbors fill out the Google Form. One response per household.
- You export the responses as CSV and save it here as
yard-sale-info.csv. - You run
bun run convert-csv. It geocodes each address against OpenStreetMap, drops the junk, and writes the data three places:yard-sale-info.json,site/data/sales.json(what the site serves), andsquarespace-snippet.html(the contingency, below). - You run
bun run build:site. It renderssite/dist, and refuses to build a page that would render seller text as HTML. - You deploy the module.
celilo module deploy bna-yard-sale. - You repeat whenever enough new signups come in. The geocode cache means a re-run only hits the network for addresses it has not seen.
Verify by the artifact, not the exit code:
curl -sSI https://bna-yard-sale.org | head -3
curl -s https://bna-yard-sale.org/sales.json | head -5
If the fleet is down on the morning of the sale
squarespace-snippet.html is still generated every run, and it is a
self-contained map that needs nothing but Squarespace. Paste it into a Code
block on the BNA page and the neighborhood has a map again. Frozen at whatever
the last pipeline run produced, which on the day is fine.
Where things live
| Thing | Where |
|---|---|
| Google Form (public link) | https://docs.google.com/forms/d/e/1FAIpQLSfJ-B7u6SO5qSzqdUaNpSJv4V5_TyDZUmZ_LOelnJoZR0QPCg/viewform |
| Google Form (edit) | In Google Drive under peter.banka@gmail.com, search "Boise Neighborhood Yard Sale" |
| Response spreadsheet | Linked from the form's Responses tab, "View in Sheets" |
| The web page | The BNA Squarespace site, yard sale page |
The form and the spreadsheet both live in the peter.banka@gmail.com Google
account. That is a single point of failure. See "Open questions" below.
Running it
bun install
bun run convert-csv
yard-sale-info.csv is deliberately not in git. The form export pairs each
neighbor's name and email with their home address, and only the address and the
goods list were ever meant to be public. Those two fields are what
yard-sale-info.json carries, so the generated data is safe to commit and the
raw export is not.
To get the CSV: open the form, Responses tab, the three-dot menu, "Download
responses (.csv)". Unzip it if Google hands you a zip. Save the CSV over
yard-sale-info.csv.
The script prints every sale it kept and every row it dropped, with a reason for each drop. Read that output. It is the only review step in the process.
When it asks you for an address
If OpenStreetMap cannot place an address, or places it outside the neighborhood, the script stops and asks you to retype it. Look the address up, type a version that Nominatim will understand, and press Enter. Press Enter on an empty line to skip that seller entirely.
Most of the time this happens because someone typed an apartment number, a cross street, or "the alley behind X". Strip it down to a street number and a street name.
What gets dropped automatically
- A row with no address.
- A row where nobody listed anything for sale.
- A second response from an address that already appeared. The later response wins, because a household submitting twice is correcting itself.
- Anything you have listed in
exclude.txt. - An address that could not be placed on the map after you were asked to fix it.
Every drop is printed with a reason. If something real got dropped, fix the address in the spreadsheet and re-export.
What gets flagged but kept
Sales that geocode to the same point are listed at the end of the run and
never dropped. A shared point means one house typed two ways, but it just
as often means a duplex, an apartment building, or a house and the ADU behind
it, where the neighbors really are selling separately. Only you can tell those
apart, so the script asks instead of deciding. Add one to exclude.txt if it
is a repeat, or leave both if they are not.
The 2025 data has one: 3720 N Michigan Ave and 3720 N Michigan Ave / Alley Garage. Two listings, one address, and genuinely ambiguous.
Dropping a row by hand
Some responses are neither empty nor duplicated, but still are not yard sales.
In 2025 one person wrote "Nothing I'm trying to find the address or a map" in
the goods field. A computer cannot tell that apart from a real listing, so put
the address in exclude.txt, one per line. Comments start with #.
The files
| File | What it is |
|---|---|
csv-to-json.ts |
The whole pipeline. Read this if anything surprises you. |
csv-to-json.test.ts |
Covers the address matching, sanitizing, and column lookup. |
yard-sale-info.csv |
The Google Form export. Not in git, see above. You overwrite this each time. |
yard-sale-info.json |
Generated. The clean, geocoded list. |
squarespace-snippet.template.html |
The page source. Edit this one. |
squarespace-snippet.html |
Generated. The thing you paste. Never edit it. |
geocode-cache.json |
Address to coordinates, so re-runs stay fast and quiet. |
exclude.txt |
Addresses you have decided are not real sales. |
freeya-logo.png |
Sponsor logo, kept as a local backup of the hosted one. |
manifest.yml |
The celilo module: what it needs, what it builds, what it runs. |
scripts/on_install.ts |
Deploy hook. Publishes site/dist through public_web. |
scripts/register-forge.ts |
Optional. Mints the repo's scoped CI publish token. |
scripts/health_check.ts |
Checks the page answers and that sales.json holds drawable sales. |
site/index.template.html |
The page. Edit this one. |
site/build.ts |
Renders site/dist, and refuses to build an unsafe page. |
site/data/sales.json |
Generated. What the page fetches at load. |
site/dist/ |
Generated. What celilo publishes. Not in git. |
Design notes worth knowing
Every geocode query carries the city. Nominatim guesses wildly when you hand it a bare street address, and for the first two years this pipeline did exactly that. That is why the 2025 run kept stopping to ask for corrections, and why one address landed a mile south of the neighborhood.
Results are checked against a bounding box. A geocode that lands outside
roughly the Boise neighborhood is treated as a bad match, not a distant
neighbor, and you get asked to fix it. The box is NEIGHBORHOOD_BOUNDS in
csv-to-json.ts. If the sale ever grows past those streets, widen it there.
The map builds popups out of text nodes, never HTML strings. The goods
description is whatever a stranger on the internet typed into a public form,
and it ends up on the neighborhood association's website. Building the popup
with textContent means a <script> tag in that field renders as the literal
characters instead of running. Do not "simplify" this back to string
concatenation with innerHTML.
The snippet is generated, so it cannot drift. In 2025 the repo had two copies of the page with two different sets of sales, six days apart, and no way to tell which one was live. Now there is one template and one generated file.
The column lookup matches question text, not column position. The old code
read the address out of column 4 and required exactly six columns, so adding a
question to the form would have silently skipped every row. Now it finds the
address column by matching ^address against the headers, and fails loudly
with the actual header list if it cannot.
Geocoding is rate-limited to one request every 1.1 seconds. Nominatim is a free service run on donated hardware, and their usage policy caps anonymous callers at one request per second. Going faster gets your IP blocked, which on an unattended run would show up as an empty map with no obvious cause. A full cold-cache run of forty addresses therefore takes about a minute. That is fine.
The page fetches its data instead of embedding it. sales.json sits beside
index.html and is fetched from the same origin at load. That keeps a data
change to one file, which is what makes the phase-two refresh reviewable. The
page renders a usable header, date and message when the fetch fails, and says
plainly that the list could not be loaded rather than claiming nobody signed up.
The date is computed, never typed. The sale is the last Saturday in September, so the page derives it, and rolls to next year the day after this year's. Nothing to update in September 2027.
The build refuses to ship an unsafe page. site/build.ts fails if the page
uses innerHTML, if it reaches a geocoder at runtime, or if the seller
description stops going through the textContent helper. Those are the ways
this page could hurt somebody, so they stop the build rather than get noticed
later.
The geocode cache stores coordinates only, never descriptions. Descriptions change every year. Coordinates do not. The cache carries over between years, so a repeat seller costs no network call.
Checks
bun run check # everything below, in order, stopping at the first failure
That is five gates. build:site is one of them, and it is a real gate rather
than a build step: it refuses to write a page that reaches for innerHTML,
geocodes in the browser, stops routing seller text through the textContent
helper, or has no title.
The hooks are a separate package with their own package.json, exactly as
celilo modules are on the fleet, so run bun install inside scripts/ once.
All of it must be clean before you deploy, or before you paste the contingency snippet into the live site.
The deploy suite
bun run test:e2e # cele2e run .
A real deploy of this module inside cele2e's simulated internet: real Caddy,
real ACME, real DNS through a fake registrar, on a host celilo provisions. It
asserts the served page renders seller text as text and that sales.json holds
sales inside the neighborhood, because a page that answers 200 with nothing
behind it is the failure worth catching.
Three things to know before the first run.
Run cele2e build-infra once on the machine. It downloads the standard
modules the suite deploys into the harness's cache. Skip it and the run fails
four stages in with Module 'iptables' not found in registry, which reads as a
broken registry rather than an empty cache.
The rig is one machine-global resource behind a run lock. cele2e status
says whether it is free. This is why the suite does not run in CI.
Bumping @celilo/e2e un-stages the standard modules. build-infra writes
the fetched .netapps inside node_modules/@celilo/e2e, so reinstalling that
package throws them away. The next run then fails at stage one with Standard modules are not staged, which names the symptom rather than the cause. Re-run
cele2e build-infra after any @celilo/e2e version change.
First-time setup
If you are setting this up rather than running it, see
docs/one-time-setup.md: the Google Form, publishing
the responses as CSV without leaking names and emails, the domain, and the forge.
Open questions
These are decisions nobody has made yet, written down so they do not get lost.
- The form and spreadsheet live in one personal Google account. When somebody else runs the sale, they cannot get at them. Moving the form to a BNA-owned account, or adding a co-owner, would fix that.