Skip to content

docs: tags are picked from a curated list - #39

Draft
alukach wants to merge 2 commits into
mainfrom
docs/product-tags-picker
Draft

alukach wants to merge 2 commits into
mainfrom
docs/product-tags-picker

Conversation

@alukach

@alukach alukach commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

The product form now offers tags from a list Source curates instead of taking comma-separated free text (source-cooperative/source.coop#647). This updates the Tags bullet in Create a data product to describe picking them, and drops the "up to 20" limit, which the app never enforced. The "contact hello@source.coop" line for a missing tag is a placeholder until there's a real request path, so correct it if there's a better one. Merge alongside #647.

🤖 Generated with Claude Code

The product form now offers tags from a list Source curates rather than taking free text (source-cooperative/source.coop feat/product-tags), so describe picking them and how to ask for a missing one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
docs-source-coop Ready Ready Preview Oct 8, 2026 11:07pm UTC

Request Review

The tag field now opens a list to tick tags from rather than suggesting them as you type (source-cooperative/source.coop#647).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alukach added a commit to source-cooperative/source.coop that referenced this pull request Oct 26, 2026
…647)

Product owners can now set a product's tags from the product form, in
both create and edit mode, choosing from a curated corpus kept in a new
DynamoDB table.

## What changes

- **New table `sc-{stage}-tags`** (`deploy/lib/database-construct.ts`),
partition key `tag_id`, which is the tag string itself (the same string
stored in `product.metadata.tags`). Each row is just `{ tag_id }`. The
Vercel role gets `grantReadData` only, because the app never writes the
corpus. Curation happens outside the app.
- **`TagsTable.listAll()`** (`src/lib/clients/database/tags.ts`) scans
the table with pagination and returns the tags sorted.
- **`TagPicker`** (`src/components/features/products/TagPicker.tsx`)
shows the chosen tags as removable chips beside an "Edit tags" button.
The button opens a Radix Themes `Popover` that lists every corpus tag as
a checkbox, with a box to filter them, so people can browse the whole
list without guessing what to type. Each chosen tag is submitted as its
own `tags` form value from a hidden input next to its chip. It is wired
into `ProductCreationForm` as a `custom` field under Description, and
both pages pass the corpus in as `tagOptions`.
- **Server enforcement** (`src/lib/operations/products.ts`):
`createProduct` and `updateProduct` in the operations layer take an
optional `tags` array, dedupe it, and reject any tag outside the corpus
with a `tags` field error. A tag the product already carries is accepted
even after it leaves the corpus. This follows the same legacy-drift rule
as visibility: editing the title shouldn't fail on a tag nobody chose
today. Because the API routes share these operations, `POST` and `PATCH`
on products accept `tags` too, documented in the generated reference.
The form actions in `src/lib/actions/products.ts` always pass
`formData.getAll("tags")`; `Object.fromEntries(formData)` alone would
keep only the last tag.
- **Tag cleanup and seeding**: `scripts/clean-tags.ts <stage>` reads
every product's free-text tags, cleans them, and builds the corpus from
the result (see Tag cleanup below). It previews by default; `APPLY=1`
rewrites each product's `metadata.tags` to its cleaned tags and puts the
corpus into `sc-{stage}-tags`. `init-local` creates the table and seeds
it from the fixture products' tags.

## Rollout

1. Deploy the CDK stack, which creates the table and the grant.
2. Run `AWS_REGION=us-west-2 npx tsx scripts/clean-tags.ts prod` and
review the preview: the corpus, the dropped tags, and each product whose
tags change. Production is in us-west-2; the script defaults to
us-east-1 like the others in `scripts/`. Adjust `ALIASES` and `KEEP` in
the script until the result is right.
3. Run it again with `APPLY=1`. This rewrites product tags, so take an
on-demand backup of `sc-prod-products` first. Until then the corpus is
empty, and the popover shows "No tags to choose from yet."

## Tag cleanup

Production's products carry 416 distinct free-text tags. Many are the
same tag in another case or spelling (`PMTiles` and `pmtiles`; `ftw` and
`fieldsoftheworld`), several tags typed as one string (`#overturemaps
#roads #riyadh …`), places below country level (14 Spanish regions,
German state codes from the fiboa datasets), or noise (`test`, `foo`,
`--`, an empty tag, and `geospatial` and `open data`, which are true of
everything on Source).

`scripts/clean-tags.ts` lowercases each tag, strips `#`, splits combined
tags, maps synonyms, typos and places to one canonical tag through
`ALIASES`, and drops noise. A canonical tag joins the corpus when two or
more products carry it, or when it is in `KEEP` (countries, plus
`elevation`, `machine learning`, `time series`, `forests` and
`hydrology`). Each product keeps only its cleaned tags that are in the
corpus.

Run against the production counts (`TABLE=1 npx tsx
scripts/clean-tags.ts --counts counts.txt`): **416 raw tags → 196
cleaned → 136 in corpus**. Counts are products per tag. They are built
from per-tag counts, so a product that had two spellings of one tag
counts twice and some totals run slightly high; the `<stage>` run counts
per product exactly.

<details><summary>The 136 tags in the corpus, by number of
products</summary>

| Tag | Products |
| --- | --: |
| `field boundaries` | 113 |
| `geoparquet` | 101 |
| `pmtiles` | 85 |
| `agriculture` | 75 |
| `vector` | 72 |
| `fiboa` | 59 |
| `fields of the world` | 58 |
| `spain` | 30 |
| `fieldscapes` | 27 |
| `sentinel-2` | 25 |
| `segmentation` | 20 |
| `weather` | 18 |
| `climate` | 17 |
| `germany` | 17 |
| `zarr` | 17 |
| `building footprints` | 14 |
| `energy` | 13 |
| `hydrology` | 13 |
| `sigpac` | 13 |
| `crop type` | 12 |
| `crops` | 12 |
| `land cover` | 12 |
| `raster` | 12 |
| `united states` | 12 |
| `cog` | 11 |
| `satellite imagery` | 11 |
| `overture maps` | 10 |
| `saudi arabia` | 10 |
| `biodiversity` | 9 |
| `forecasting` | 9 |
| `parquet` | 9 |
| `sentinel-1` | 9 |
| `admin boundaries` | 8 |
| `places` | 8 |
| `precipitation` | 8 |
| `conservation` | 7 |
| `elevation` | 7 |
| `forests` | 7 |
| `india` | 7 |
| `labels` | 7 |
| `machine learning` | 7 |
| `carbon` | 6 |
| `cloud native` | 6 |
| `icechunk` | 6 |
| `japan` | 6 |
| `land use` | 6 |
| `solar` | 6 |
| `stac` | 6 |
| `transportation` | 6 |
| `vegetation` | 6 |
| `embeddings` | 5 |
| `global` | 5 |
| `landsat` | 5 |
| `ocean` | 5 |
| `openstreetmap` | 5 |
| `planetscope` | 5 |
| `sar` | 5 |
| `ai` | 4 |
| `carbon dioxide removal` | 4 |
| `flatgeobuf` | 4 |
| `foundation models` | 4 |
| `gapfilled` | 4 |
| `lidar` | 4 |
| `modis` | 4 |
| `natural gas` | 4 |
| `nhd` | 4 |
| `remote sensing` | 4 |
| `tanzania` | 4 |
| `wildfire` | 4 |
| `youthmappers` | 4 |
| `aerosols` | 3 |
| `atmosphere` | 3 |
| `belgium` | 3 |
| `canada` | 3 |
| `census` | 3 |
| `cer` | 3 |
| `clay` | 3 |
| `cloud` | 3 |
| `ecology` | 3 |
| `fisheries` | 3 |
| `image classification` | 3 |
| `imagery` | 3 |
| `netherlands` | 3 |
| `radar` | 3 |
| `reanalysis` | 3 |
| `regression` | 3 |
| `rwanda` | 3 |
| `africa` | 2 |
| `austria` | 2 |
| `bathymetry` | 2 |
| `brazil` | 2 |
| `computer vision` | 2 |
| `duckdb` | 2 |
| `eurocrops` | 2 |
| `exports` | 2 |
| `flood detection` | 2 |
| `geopackage` | 2 |
| `geostationary` | 2 |
| `google` | 2 |
| `h3` | 2 |
| `hls` | 2 |
| `imports` | 2 |
| `invekos` | 2 |
| `latvia` | 2 |
| `marine debris` | 2 |
| `microsoft` | 2 |
| `monitoring` | 2 |
| `naip` | 2 |
| `neb` | 2 |
| `noaa` | 2 |
| `object detection` | 2 |
| `observations` | 2 |
| `slovenia` | 2 |
| `switzerland` | 2 |
| `tiles` | 2 |
| `time series` | 2 |
| `united kingdom` | 2 |
| `usda` | 2 |
| `wetlands` | 2 |
| `wildlife` | 2 |
| `antarctica` | 1 |
| `cambodia` | 1 |
| `croatia` | 1 |
| `czechia` | 1 |
| `denmark` | 1 |
| `estonia` | 1 |
| `europe` | 1 |
| `finland` | 1 |
| `france` | 1 |
| `ireland` | 1 |
| `luxembourg` | 1 |
| `new zealand` | 1 |
| `portugal` | 1 |
| `slovakia` | 1 |
| `sweden` | 1 |
| `vietnam` | 1 |

</details>

<details><summary>Dropped because only one product carries them
(60)</summary>

| Tag | Products |
| --- | --: |
| `american bollworms` | 1 |
| `attribution benchmark` | 1 |
| `bioregionalisation` | 1 |
| `brp` | 1 |
| `catalog` | 1 |
| `copernicus` | 1 |
| `corridors` | 1 |
| `csv` | 1 |
| `digitaltwin` | 1 |
| `disaster risk` | 1 |
| `dwr` | 1 |
| `ee-mrio` | 1 |
| `egms` | 1 |
| `eia` | 1 |
| `environment` | 1 |
| `environmental data` | 1 |
| `epa` | 1 |
| `equity` | 1 |
| `esa` | 1 |
| `exiobase` | 1 |
| `ferc` | 1 |
| `finance` | 1 |
| `foodsecurity` | 1 |
| `foodsystems` | 1 |
| `fude` | 1 |
| `gdal` | 1 |
| `geocoding` | 1 |
| `geos` | 1 |
| `geozarr` | 1 |
| `goes` | 1 |
| `government` | 1 |
| `hex` | 1 |
| `historical` | 1 |
| `human footprint` | 1 |
| `iceberg` | 1 |
| `imputation` | 1 |
| `jpss` | 1 |
| `justice` | 1 |
| `landscape elements` | 1 |
| `meiji era` | 1 |
| `metop` | 1 |
| `nasa` | 1 |
| `neon` | 1 |
| `ngff` | 1 |
| `nsta` | 1 |
| `pink bollworms` | 1 |
| `plodiny` | 1 |
| `png` | 1 |
| `policy` | 1 |
| `population` | 1 |
| `procurement` | 1 |
| `property data` | 1 |
| `protected areas` | 1 |
| `realtime` | 1 |
| `sahara` | 1 |
| `snow` | 1 |
| `supplychain` | 1 |
| `synthetic data` | 1 |
| `terrestrial` | 1 |
| `usgs` | 1 |

Keep any of these by adding it to `KEEP`, or merge it into a corpus tag
in `ALIASES`.

</details>

<details><summary>Every tag that changes (295 rows; unchanged tags
appear only in the corpus list)</summary>

| Count | Before | After | Notes |
| --: | --- | --- | --- |
| 30 | `ftw` | `fields of the world` | Merged |
| 28 | `fieldsoftheworld` | `fields of the world` | Merged |
| 10 | `geospatial` | — | Dropped: noise |
| 5 | `boundaries` | `admin boundaries` | Merged |
| 4 | `satellite` | `satellite imagery` | Merged |
| 4 | `overture` | `overture maps` | Merged |
| 4 | `landsat 8` | `landsat` | Merged |
| 4 | `foundational model` | `foundation models` | Merged |
| 3 | `nhd` | `nhd`, `hydrology` | Split into several tags |
| 3 | `ml` | `machine learning` | Merged |
| 3 | `imagery` | `satellite imagery` | Merged |
| 3 | (empty) | — | Dropped: noise |
| 2 | `usa` | `united states` | Merged |
| 2 | `uk` | `united kingdom` | Merged |
| 2 | `sentinel2` | `sentinel-2` | Merged |
| 2 | `opendata` | — | Dropped: noise |
| 2 | `karnataka` | `india` | Merged |
| 2 | `forecast` | `forecasting` | Merged |
| 2 | `fieldboundaries` | `field boundaries` | Merged |
| 2 | `field boundary` | `field boundaries` | Merged |
| 2 | `field` | — | Dropped: noise |
| 2 | `earth` | — | Dropped: noise |
| 2 | `dem` | `elevation` | Merged |
| 2 | `crop` | `crops` | Merged |
| 2 | `cloud optimised geotiff` | `cog` | Merged |
| 2 | `climate change` | `climate` | Merged |
| 2 | `cdr` | `carbon dioxide removal` | Merged |
| 2 | `california` | `united states` | Merged |
| 2 | `buildings` | `building footprints` | Merged |
| 2 | `building footprint` | `building footprints` | Merged |
| 2 | `admin` | `admin boundaries` | Merged |
| 2 | `Sentinel2` | `sentinel-2` | Merged |
| 2 | `PMTiles` | `pmtiles` | Normalized (case, #) |
| 2 | `Japan` | `japan` | Normalized (case, #) |
| 2 | `COG` | `cog` | Normalized (case, #) |
| 2 | `AI` | `ai` | Normalized (case, #) |
| 2 | `#geoparquet #aois #places #dataset #riyadh #geospatial
#overturemaps #saudiarabia` | `geoparquet`, `places`, `saudi arabia`,
`overture maps` | Split into several tags |
| 1 | `youthmasppers` | `youthmappers` | Merged |
| 1 | `years` | — | Dropped: noise |
| 1 | `xai` | `machine learning` | Merged |
| 1 | `wheat` | `crops` | Merged |
| 1 | `wbd` | `hydrology` | Merged |
| 1 | `waterways` | `hydrology` | Merged |
| 1 | `watershed boundaries` | `hydrology` | Merged |
| 1 | `water` | `hydrology` | Merged |
| 1 | `wadhwaniai` | — | Dropped: noise |
| 1 | `vegetation cover` | `vegetation` | Merged |
| 1 | `valencia` | `spain` | Merged |
| 1 | `usgs` | — | Dropped: on one product only |
| 1 | `us` | `united states` | Merged |
| 1 | `tropical storm` | `weather` | Merged |
| 1 | `trees` | `forests` | Merged |
| 1 | `topography` | `elevation` | Merged |
| 1 | `timeseries` | `time series` | Merged |
| 1 | `tiff` | `raster` | Merged |
| 1 | `ticks` | `biodiversity` | Merged |
| 1 | `th` | `germany` | Merged |
| 1 | `testrepo12` | — | Dropped: noise |
| 1 | `test unidata netcdf` | — | Dropped: noise |
| 1 | `test` | — | Dropped: noise |
| 1 | `terrestrial` | — | Dropped: on one product only |
| 1 | `temperature` | `weather` | Merged |
| 1 | `taxi zones` | `transportation` | Merged |
| 1 | `tamsat` | `precipitation` | Merged |
| 1 | `synthetic data` | — | Dropped: on one product only |
| 1 | `swiss` | `switzerland` | Merged |
| 1 | `supplychain` | — | Dropped: on one product only |
| 1 | `street level imagery` | `imagery` | Merged |
| 1 | `static` | — | Dropped: noise |
| 1 | `state` | — | Dropped: noise |
| 1 | `stac-geoparquet` | `stac`, `geoparquet` | Split into several tags
|
| 1 | `sst` | `ocean` | Merged |
| 1 | `species distribution model` | `biodiversity` | Merged |
| 1 | `southern ocean` | `ocean` | Merged |
| 1 | `south fork eel river` | `united states` | Merged |
| 1 | `source` | — | Dropped: noise |
| 1 | `solar maps` | `solar` | Merged |
| 1 | `snow` | — | Dropped: on one product only |
| 1 | `slovenia fiboa field boundaries krms gerk geoparquet vector
pmtiles` | `slovenia`, `fiboa`, `field boundaries`, `geoparquet`,
`vector`, `pmtiles` | Split into several tags |
| 1 | `sl` | `germany` | Merged |
| 1 | `sixpac` | `sigpac` | Merged |
| 1 | `sh` | `germany` | Merged |
| 1 | `sfer` | — | Dropped: noise |
| 1 | `self-supervised` | `machine learning` | Merged |
| 1 | `sax` | — | Dropped: noise |
| 1 | `saudiarabia` | `saudi arabia` | Merged |
| 1 | `sahara` | — | Dropped: on one product only |
| 1 | `riyadh` | `saudi arabia` | Merged |
| 1 | `remotesensing` | `remote sensing` | Merged |
| 1 | `reanalysis data` | `reanalysis` | Merged |
| 1 | `realtime` | — | Dropped: on one product only |
| 1 | `rainfall radar` | `precipitation`, `radar` | Split into several
tags |
| 1 | `pv` | `solar` | Merged |
| 1 | `public-data` | — | Dropped: noise |
| 1 | `public` | — | Dropped: noise |
| 1 | `protected areas` | — | Dropped: on one product only |
| 1 | `property data` | — | Dropped: on one product only |
| 1 | `procurement` | — | Dropped: on one product only |
| 1 | `population` | — | Dropped: on one product only |
| 1 | `polygon` | — | Dropped: noise |
| 1 | `political boundaries` | `admin boundaries` | Merged |
| 1 | `policy` | — | Dropped: on one product only |
| 1 | `pois` | `places` | Merged |
| 1 | `poi` | `places` | Merged |
| 1 | `png` | — | Dropped: on one product only |
| 1 | `pmtiles gtfs` | `pmtiles`, `transportation` | Split into several
tags |
| 1 | `plodiny` | — | Dropped: on one product only |
| 1 | `planet fusion` | `planetscope` | Merged |
| 1 | `pink bollworms` | — | Dropped: on one product only |
| 1 | `photovoltaic` | `solar` | Merged |
| 1 | `perspective images` | `imagery` | Merged |
| 1 | `pacificspatial` | — | Dropped: noise |
| 1 | `overturemaps` | `overture maps` | Merged |
| 1 | `osm` | `openstreetmap` | Merged |
| 1 | `open data` | — | Dropped: noise |
| 1 | `ome` | — | Dropped: noise |
| 1 | `oil production` | `energy` | Merged |
| 1 | `observation` | `observations` | Merged |
| 1 | `nyc` | `united states` | Merged |
| 1 | `nwp` | `forecasting`, `weather` | Split into several tags |
| 1 | `nsta` | — | Dropped: on one product only |
| 1 | `nrw` | `germany` | Merged |
| 1 | `ngff` | — | Dropped: on one product only |
| 1 | `netherlands fiboa field boundaries aan geoparquet vector pmtiles`
| `netherlands`, `fiboa`, `field boundaries`, `geoparquet`, `vector`,
`pmtiles` | Split into several tags |
| 1 | `net zero` | `carbon` | Merged |
| 1 | `neon` | — | Dropped: on one product only |
| 1 | `nds` | `germany` | Merged |
| 1 | `navarra` | `spain` | Merged |
| 1 | `natural gas production` | `natural gas` | Merged |
| 1 | `national hydrology dataset` | `nhd`, `hydrology` | Split into
several tags |
| 1 | `nasa` | — | Dropped: on one product only |
| 1 | `mv` | `germany` | Merged |
| 1 | `multitemporal` | `time series` | Merged |
| 1 | `mrms` | `precipitation` | Merged |
| 1 | `misc` | — | Dropped: noise |
| 1 | `metop` | — | Dropped: on one product only |
| 1 | `metadata` | — | Dropped: noise |
| 1 | `maize` | `crops` | Merged |
| 1 | `madrid` | `spain` | Merged |
| 1 | `luxembourg fiboa field flik boundaries geoparquet vector pmtiles`
| `luxembourg`, `fiboa`, `field boundaries`, `geoparquet`, `vector`,
`pmtiles` | Split into several tags |
| 1 | `lulc` | `land cover`, `land use` | Split into several tags |
| 1 | `lowcoder` | — | Dropped: noise |
| 1 | `live fuel moisture` | `wildfire` | Merged |
| 1 | `lidar 3dtiles mago` | `lidar` | Merged |
| 1 | `landscape elements` | — | Dropped: on one product only |
| 1 | `landcover` | `land cover` | Merged |
| 1 | `land-use and land-cover change` | `land cover`, `land use` |
Split into several tags |
| 1 | `land` | — | Dropped: noise |
| 1 | `lacuna` | — | Dropped: noise |
| 1 | `ksrsac` | `india` | Merged |
| 1 | `karnatak` | `india` | Merged |
| 1 | `justice` | — | Dropped: on one product only |
| 1 | `jpss` | — | Dropped: on one product only |
| 1 | `ireland fiboa field boundarie geoparquet vector pmtiles` |
`ireland`, `fiboa`, `field boundaries`, `geoparquet`, `vector`,
`pmtiles` | Split into several tags |
| 1 | `insolation` | `solar` | Merged |
| 1 | `imputation` | — | Dropped: on one product only |
| 1 | `imerg` | `precipitation` | Merged |
| 1 | `image` | — | Dropped: noise |
| 1 | `iceberg` | — | Dropped: on one product only |
| 1 | `hydrography` | `hydrology` | Merged |
| 1 | `human footprint` | — | Dropped: on one product only |
| 1 | `historical` | — | Dropped: on one product only |
| 1 | `hex` | — | Dropped: on one product only |
| 1 | `height` | `elevation` | Merged |
| 1 | `hcat` | — | Dropped: noise |
| 1 | `havasu` | `united states` | Merged |
| 1 | `hamburg` | `germany` | Merged |
| 1 | `groundwater quality` | `hydrology` | Merged |
| 1 | `groundtruthed` | `labels` | Merged |
| 1 | `ground-truthed` | `labels` | Merged |
| 1 | `ground-truth` | `labels` | Merged |
| 1 | `gpm` | `precipitation` | Merged |
| 1 | `government` | — | Dropped: on one product only |
| 1 | `goes` | — | Dropped: on one product only |
| 1 | `geozarr` | — | Dropped: on one product only |
| 1 | `geos` | — | Dropped: on one product only |
| 1 | `geocoding` | — | Dropped: on one product only |
| 1 | `geo spatial` | — | Dropped: noise |
| 1 | `gdal` | — | Dropped: on one product only |
| 1 | `galicia` | `spain` | Merged |
| 1 | `fude` | — | Dropped: on one product only |
| 1 | `forest carbon` | `forests`, `carbon` | Split into several tags |
| 1 | `forest` | `forests` | Merged |
| 1 | `forecasts` | `forecasting` | Merged |
| 1 | `foodsystems` | — | Dropped: on one product only |
| 1 | `foodsecurity` | — | Dropped: on one product only |
| 1 | `foo` | — | Dropped: noise |
| 1 | `flowlines` | `hydrology` | Merged |
| 1 | `flanders` | `belgium` | Merged |
| 1 | `fishbase` | `fisheries` | Merged |
| 1 | `fish` | `fisheries` | Merged |
| 1 | `fire` | `wildfire` | Merged |
| 1 | `finance` | — | Dropped: on one product only |
| 1 | `ferc` | — | Dropped: on one product only |
| 1 | `farmland` | `agriculture` | Merged |
| 1 | `extremadura` | `spain` | Merged |
| 1 | `euhvd` | — | Dropped: noise |
| 1 | `esa` | — | Dropped: on one product only |
| 1 | `era5` | `reanalysis`, `climate` | Split into several tags |
| 1 | `equity` | — | Dropped: on one product only |
| 1 | `epa` | — | Dropped: on one product only |
| 1 | `environmental data` | — | Dropped: on one product only |
| 1 | `environment` | — | Dropped: on one product only |
| 1 | `energy use by fuel` | `energy` | Merged |
| 1 | `energy use` | `energy` | Merged |
| 1 | `energy transition` | `energy` | Merged |
| 1 | `energy production` | `energy` | Merged |
| 1 | `energy efficiency` | `energy` | Merged |
| 1 | `energy demand by sector` | `energy` | Merged |
| 1 | `energy demand` | `energy` | Merged |
| 1 | `emissions` | `carbon` | Merged |
| 1 | `embedding` | `embeddings` | Merged |
| 1 | `electricity generation` | `energy` | Merged |
| 1 | `electricity` | `energy` | Merged |
| 1 | `eia` | — | Dropped: on one product only |
| 1 | `eatch monitoring` | `monitoring` | Merged |
| 1 | `dwr` | — | Dropped: on one product only |
| 1 | `dust` | `aerosols` | Merged |
| 1 | `drone` | `imagery` | Merged |
| 1 | `disaster risk` | — | Dropped: on one product only |
| 1 | `digitaltwin` | — | Dropped: on one product only |
| 1 | `deforestation` | `forests` | Merged |
| 1 | `deep learning` | `machine learning` | Merged |
| 1 | `dataset` | — | Dropped: noise |
| 1 | `czech` | `czechia` | Merged |
| 1 | `csv` | — | Dropped: on one product only |
| 1 | `croptype` | `crop type` | Merged |
| 1 | `county` | — | Dropped: noise |
| 1 | `countries` | — | Dropped: noise |
| 1 | `cotton` | `crops` | Merged |
| 1 | `corridors` | — | Dropped: on one product only |
| 1 | `contour` | `elevation` | Merged |
| 1 | `coal` | `energy` | Merged |
| 1 | `cloud-native` | `cloud native` | Merged |
| 1 | `cdl` | `crop type`, `united states` | Split into several tags |
| 1 | `catalonia` | `spain` | Merged |
| 1 | `catalog` | — | Dropped: on one product only |
| 1 | `castillalamancha` | `spain` | Merged |
| 1 | `castile and leon` | `spain` | Merged |
| 1 | `carbon emissions` | `carbon` | Merged |
| 1 | `cantabria` | `spain` | Merged |
| 1 | `canary islands` | `spain` | Merged |
| 1 | `businesses` | `places` | Merged |
| 1 | `building` | `building footprints` | Merged |
| 1 | `brp` | — | Dropped: on one product only |
| 1 | `bremen` | `germany` | Merged |
| 1 | `boundary vector agriculture` | `field boundaries`, `vector`,
`agriculture` | Split into several tags |
| 1 | `blore` | `india` | Merged |
| 1 | `bioregionalisation` | — | Dropped: on one product only |
| 1 | `biomass` | `forests` | Merged |
| 1 | `belgium wallonia fiboa field boundaries geoparquet vector
pmtiles` | `belgium`, `fiboa`, `field boundaries`, `geoparquet`,
`vector`, `pmtiles` | Split into several tags |
| 1 | `beetles` | `biodiversity` | Merged |
| 1 | `bb` | `germany` | Merged |
| 1 | `basque` | `spain` | Merged |
| 1 | `balearic islands` | `spain` | Merged |
| 1 | `attribution benchmark` | — | Dropped: on one product only |
| 1 | `arc` | — | Dropped: noise |
| 1 | `aragon` | `spain` | Merged |
| 1 | `aquatics` | `ocean` | Merged |
| 1 | `aois` | — | Dropped: noise |
| 1 | `andalusia` | `spain` | Merged |
| 1 | `andalucía` | `spain` | Merged |
| 1 | `analysis` | — | Dropped: noise |
| 1 | `amx` | — | Dropped: noise |
| 1 | `american bollworms` | — | Dropped: on one product only |
| 1 | `aerosol` | `aerosols` | Merged |
| 1 | `Trees` | `forests` | Merged |
| 1 | `Tottori` | `japan` | Merged |
| 1 | `Topographic Map` | `elevation` | Merged |
| 1 | `Sentinel2 mosaics cloud-free global cog raster` | `sentinel-2`,
`global`, `cog`, `raster` | Split into several tags |
| 1 | `PMTiles FlatGeobuf` | `pmtiles`, `flatgeobuf` | Split into
several tags |
| 1 | `Meiji era` | — | Dropped: on one product only |
| 1 | `Forest` | `forests` | Merged |
| 1 | `EXIOBASE` | — | Dropped: on one product only |
| 1 | `EU` | `europe` | Merged |
| 1 | `EGMS` | — | Dropped: on one product only |
| 1 | `EE-MRIO` | — | Dropped: on one product only |
| 1 | `DEM` | `elevation` | Merged |
| 1 | `Copernicus` | — | Dropped: on one product only |
| 1 | `Cloud-Native` | `cloud native` | Merged |
| 1 | `3d tiles` | `lidar` | Merged |
| 1 | `--` | — | Dropped: noise |
| 1 | `#waterways` | `hydrology` | Merged |
| 1 | `#vector` | `vector` | Normalized (case, #) |
| 1 | `#tif` | `raster` | Merged |
| 1 | `#stac` | `stac` | Normalized (case, #) |
| 1 | `#sentinel #satellite #imagery #cog #aws #remotesensing
#saudiarabia #tabaqat` | `sentinel-2`, `satellite imagery`, `cog`,
`remote sensing`, `saudi arabia` | Split into several tags |
| 1 | `#riyadh #satellite #rgb #cog #2024` | `saudi arabia`, `satellite
imagery`, `cog` | Split into several tags |
| 1 | `#raster` | `raster` | Normalized (case, #) |
| 1 | `#pmtiles #cng #saudiarabia #sentinel #tabaqat #aws` | `pmtiles`,
`cloud native`, `saudi arabia`, `sentinel-2` | Split into several tags |
| 1 | `#parquet` | `parquet` | Normalized (case, #) |
| 1 | `#overturemaps #roads #riyadh #osm #pmtiles #cng #tabaqat` |
`overture maps`, `transportation`, `saudi arabia`, `openstreetmap`,
`pmtiles`, `cloud native` | Split into several tags |
| 1 | `#overturemaps #roads #pmtiles #cng #osm #tabaqat #saudiarabia` |
`overture maps`, `transportation`, `pmtiles`, `cloud native`,
`openstreetmap`, `saudi arabia` | Split into several tags |
| 1 | `#geospatial #ksa #riyadh #geoparquet #places #pois` | `saudi
arabia`, `geoparquet`, `places` | Split into several tags |
| 1 | `#cbam #climate reanalysis` | `reanalysis`, `climate` | Split into
several tags |

</details>

## Decisions worth a second look

- **Which tags survive is a judgement call.** The `ALIASES` and `KEEP`
lists in `scripts/clean-tags.ts` encode it, and the tables above show
the result. Calls most worth checking: `boundaries` and `admin` become
`admin boundaries`; `satellite` and `imagery` merge into `satellite
imagery`; agencies on one product (`nasa`, `usgs`, `esa`, `epa`) are
dropped while those on two or more (`noaa`, `usda`, `cer`, `neb`) stay.
- **No admin UI for the corpus.** Tags are added in the AWS console or
CLI for now. An admin page is the natural follow-up if curation becomes
frequent.
- **Leaving `tags` out of an update keeps them; an empty list clears
them.** That suits API callers, who shouldn't wipe a product's tags by
editing its title. The form always sends the list, because removing
every chip submits no `tags` field at all, and that has to clear them.
- **No cap on tag count.** The docs used to promise "up to 20". Nothing
enforced that before and nothing does now, so I dropped the claim from
the docs instead of inventing a limit.
- **Popover picker instead of a datalist or a combobox library.** The
first version used a native `<datalist>`, but its dropdown ignored the
theme and hid the list until you typed. Of the alternatives (every tag
as a toggle chip, or a popover like GitHub's label picker), the popover
keeps the field compact and uses only components Radix Themes already
ships. The list renders every tag without virtualisation, which is fine
to a few hundred tags; past that it should be virtualised (marked with a
`ponytail:` comment).

## Stories

On this branch's Storybook deploy:

- TagPicker:
[Empty](https://source-coop-ui-git-feat-product-tags-radiantearth.vercel.app/?path=/story/features-settings-details-tagpicker--empty),
[WithTags](https://source-coop-ui-git-feat-product-tags-radiantearth.vercel.app/?path=/story/features-settings-details-tagpicker--with-tags),
[RetiredTag](https://source-coop-ui-git-feat-product-tags-radiantearth.vercel.app/?path=/story/features-settings-details-tagpicker--retired-tag),
[Filtering](https://source-coop-ui-git-feat-product-tags-radiantearth.vercel.app/?path=/story/features-settings-details-tagpicker--filtering)
(opens the popover and filters it)
- ProductCreationForm:
[Create](https://source-coop-ui-git-feat-product-tags-radiantearth.vercel.app/?path=/story/features-settings-details-productcreationform--create),
[Edit](https://source-coop-ui-git-feat-product-tags-radiantearth.vercel.app/?path=/story/features-settings-details-productcreationform--edit)

The Filtering story, with the popover open and filtered to tags
containing "c":

![TagPicker with the popover
open](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/product-tags/tagpicker-filtering.png)

## Docs

- `docs/using-source/create-a-data-product.md` described tags as
"Comma-separated, up to 20 tags", which is now stale. Updated in
[docs.source.coop#39](source-cooperative/docs.source.coop#39).
- `docs/about-source/core-concepts.md` ("Tags and Discoverability")
still holds: it says what tags are for, not how they are entered.
- `public/llms.txt` doesn't mention tags, and this adds no public
endpoint. Checked the data-proxy ADRs: tags have no bearing on
authorization, credentials, or federation.

## Testing

- `npm run test` for `src/lib/actions/products.test.ts` and
`src/components/features/products/TagPicker.test.tsx` passes locally (30
tests), and so does `npm run type-check`. `npm run lint` reports nothing
in the changed files.
- The action tests cover create saving corpus tags, rejecting unknown
ones, and not reading the tag table when no tags are sent, and update
saving corpus tags, rejecting unknown ones, and keeping a retired tag
the product already had.
- `TagPicker.test.tsx` covers removing a chip, and the popover filtering
case-insensitively and toggling a tag on and off.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Tyler Erickson <92862+tylere@users.noreply.github.com>

This branch was successfully deployed

1 active deployment
Preview — 205e13fd Deployed Oct 8, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant