| description | Add a dataset (CSV, TSV, JSON, or GeoJSON) to an existing PortalJS portal. Appends an entry to datasets.json so the catalog and showcase render it automatically; routes the data by source (local file vs remote URL) β R2 via Git LFS by default, remote URLs by passthrough. |
|---|---|
| allowed-tools | Read, Write, Edit, Bash, WebFetch |
Add a dataset to an existing PortalJS (portaljs-catalog) portal. Appends one entry to
datasets.json β the single source of truth for the catalog β and routes the data to
the right place by source first, then size. No per-dataset page is created: the catalog
at /search lists it and the dynamic showcase route pages/[owner]/[slug].tsx renders it
automatically at /@<namespace>/<slug>.
Where the bytes end up depends first on where they come from, then on size/intent. The
manifest's resource.path (or single-file file) is the unifying abstraction:
resourceUrl() in lib/datasets.ts serves a repo-relative path from /public/data and
passes an absolute URL straight through. So every route below just decides what string
to write into path.
| Source | Default | What happens | Manifest path |
|---|---|---|---|
| Local file | R2 via Git LFS | mv into the repo, git lfs track <path>, commit a ~134 B pointer, git lfs push β Giftless β R2 (no GitHub remote needed) |
absolute R2 URL (browser fetches R2 directly) |
| Local file (fenced) | inline | Only bundled SAMPLE data or an OSS self-host with no R2: cp into public/data/ (stays inline per .gitattributes fence) |
bare filename β /data/<file> |
| Remote URL | passthrough | record the URL as-is β no download, no upload, zero duplication | the absolute URL, unchanged |
| Remote URL | adopt (opt-in) | user wants it hosted/versioned under the portal: fetch β route as a local file (R2/LFS) | absolute R2 URL |
Storage decision (epic po-g9y): the default for added data is R2, regardless of size or format. Inline is not a size threshold β it is a fenced exception for bundled sample data and the OSS-no-R2 fallback. Remote URLs default to passthrough (copy nothing); adopting one into R2 is an explicit opt-in.
Remote-URL passthrough caveat: serving and linking always work. But in-browser range / DuckDB queries against a 3rd-party URL need CORS + range support on that host, which we don't control. If the user needs querying (not just preview/download) and the remote lacks CORS/range, recommend the adopt-into-R2 option.
Supported formats for the showcase preview/registration: CSV, TSV, JSON (array),
GeoJSON. (LFS itself is format-agnostic β any binary can be tracked β but the showcase
Table/map only previews these.)
- Source β a local file path (
./data/file.csv) or a public URL (https://β¦/data.csv) - Portal directory β path to the portal project (defaults to current directory)
- Namespace β the dataset's namespace value (the portal's
NAMESPACE_TYPEgroup: a subject for'theme'portals, a publisher for'owner'portals)
If the source is missing, ask for it β never dead-end. The user can say "use defaults" to accept the defaults below.
Extract what's present:
SOURCEβ file path or URLPORTAL_DIRβ portal directory (default:.)DATASET_NAMEβ human-readable name (default: derived from filename)DATASET_SLUGβ URL slug (default: lowercase hyphenated filename without extension)DESCRIPTIONβ optional one-line descriptionNAMESPACEβ namespace value (default: read the existing first entry'snamespacefromdatasets.json, elsereference)ADOPTβ for a remote URL only: whether to adopt the file into R2 (default: no β passthrough). Only ask if the URL route is taken and querying is plausibly needed.
If SOURCE is missing, ask (one focused prompt) and wait:
To add a dataset I need:
1. Source: local file path or public URL (required)
2. Portal directory (Enter for current directory)
3. Dataset name (Enter to use the filename)
4. Namespace value β the group this dataset belongs to
(subject if the portal is "theme" mode, publisher if "owner" mode; Enter to reuse the catalog's existing namespace)
Check the portal's namespace mode if helpful: read NAMESPACE_TYPE from
PORTAL_DIR/lib/datasets.ts so you can phrase the namespace question correctly ("subject"
vs "publisher").
The target must be a portaljs-catalog portal. Confirm PORTAL_DIR/datasets.json,
PORTAL_DIR/package.json, and PORTAL_DIR/pages/[owner]/[slug].tsx exist. If
datasets.json is missing, tell the user this portal isn't the catalog template (it may
be an older single-page template) and ask how to proceed rather than failing silently.
If SOURCE is a URL: fetch headers (or a HEAD) and check the status. If not 200, tell
the user the fetch failed (with the HTTP status) and ask them to confirm the URL is publicly
accessible, then retry. Detect format from Content-Type or the URL extension. (For the
default passthrough route you do not download the body β only enough to detect format.)
If SOURCE is a local file path: check the file exists. If not, tell the user the path wasn't found and ask for a correct path. Detect format from the file extension.
Format detection rules:
.csvortext/csvβ CSV.tsvortext/tab-separated-valuesβ TSV.geojsonorapplication/geo+jsonor (JSON-parseable andparsed.type === "FeatureCollection") β GeoJSON.jsonorapplication/jsonβ JSON array- Vector geo formats β
.zip(zipped Shapefile),.gpkg,.kml/.kmz,.fgb, or a.csvwith a geometry/lat-lon column β hand off to/portaljs-add-geo. That skill auto-ingests geo sources into a dual-tier dataset (PMTiles render + GeoParquet query) on the user's machine. This tabular skill only previews flat files; it does not tile or build GeoParquet. (A small GeoJSON you just want listed with a download link can stay here as thegeojsonformat; for a map/query view, prefer/portaljs-add-geoor/portaljs-add-map.) - Anything else: tell the user the showcase can't preview this format and ask them to convert to CSV, TSV, JSON array, or GeoJSON first.
Pick the branch from the matrix above. Decide on SOURCE first.
Do nothing to the bytes. The manifest path is the absolute URL itself; resourceUrl()
passes it through and the browser fetches it directly. Skip to Step 5 with
MANIFEST_PATH=<the URL>.
Mention the CORS/range caveat if the user may need querying, and offer the adopt route.
Only when the user explicitly wants the file hosted/versioned under the portal. Download it to a temp path, then fall through to 4c treating that temp file as the local source:
curl -fL "$SOURCE" -o "/tmp/$DATASET_SLUG.$EXT"This versions a tiny pointer in git and streams the bytes to Cloudflare R2 through Giftless.
The browser then fetches the bytes straight from R2 (manifest path = absolute R2 URL).
cd PORTAL_DIR
mkdir -p data
# mv (NOT cp) so an external local file isn't tripled on disk:
mv "$SOURCE" "data/$DATASET_SLUG.$EXT" # for 4b, $SOURCE is the temp download
# Ensure the LFS clean/smudge filters are installed for THIS repo before tracking.
# On a fresh machine `git lfs install` may never have run (brew install git-lfs does
# NOT auto-run it), so without this `git add` commits the RAW bytes instead of a
# ~134 B pointer and the later `git lfs push` has nothing to stream. Idempotent.
if ! git config --get filter.lfs.clean >/dev/null 2>&1; then
git lfs install --local
fi
# Per-file, format-agnostic LFS tracking (appends a path entry to .gitattributes,
# NOT an extension glob):
git lfs track "data/$DATASET_SLUG.$EXT"
git add .gitattributes "data/$DATASET_SLUG.$EXT"
git commit -m "data: add $DATASET_SLUG via LFS"mv, not cp. When bringing an external local file into the repo, move it in place β a
cpleaves a 3rd on-disk copy (original + working tree +.git/lfscache).
Authenticate git-lfs (phase-2 gotcha, po-g9y.1). The committed .lfsconfig carries the
Giftless URL but no credentials. Mint a repo-scoped JWT and set the credentialed URL in
local git config only (never commit it). Use the _jwt HTTP Basic piggyback β do not
set a broad http.extraHeader, which git-lfs would replay onto the presigned R2 PUT (R2 β
400) and the verify callback.
Mint the token from the Arc API (default β no private key on your machine). The Arc API
is the token issuer (po-g9y.13): it holds the RS256 signer as a Worker secret and mints a
scoped, short-TTL LFS token for any authenticated Arc user. Reuse the same Arc token
/portaljs-deploy resolves (PORTALJS_TOKEN, else ~/.portaljs/credentials); if you have
neither, run /portaljs-deploy's sign-in step once. The endpoint returns a ready-to-use
credentialed lfs_url:
API="${PORTALJS_ARC_API:-https://api.arc.portaljs.com}"
ARC_TOKEN="${PORTALJS_TOKEN:-$(node -e "try{process.stdout.write(JSON.parse(require('fs').readFileSync(process.env.HOME+'/.portaljs/credentials','utf8')).token||'')}catch{}")}"
# Claim the slug under your Arc account (idempotent; safe to re-run). Minting never
# creates a slug (po-g9y.13), so claim once before the FIRST push on a portal that
# hasn't been deployed yet β this is what makes create β add-data β deploy seamless.
# 403 = the slug belongs to another account.
curl -fsS -X POST "$API/v1/repos/<project-slug>/claim" -H "Authorization: Bearer $ARC_TOKEN" >/dev/null
# Pushing data needs write: request ?actions=read,write,verify (default is read-only,
# pull). ?ttl=<secs> to tune.
LFS_URL=$(curl -fsS -X POST "$API/v1/repos/<project-slug>/lfs-token?actions=read,write,verify" \
-H "Authorization: Bearer $ARC_TOKEN" \
| node -e "process.stdin.on('data',d=>process.stdout.write(JSON.parse(d).lfs_url))")
git config lfs.url "$LFS_URL" # local only β carries the token, never committedOSS self-host fallback. Running your own Giftless without an Arc account? Sign the token locally with the issuer's private key instead:
TOKEN=$(python3 ../../giftless/mint-token.py --org datopian --repo <project-slug> \ --ttl 3600 --algorithm RS256 --key-file ../../giftless/jwt_private_key) git config lfs.url "https://_jwt:$TOKEN@lfs.portaljs.com/datopian/<project-slug>"
(See giftless/README.md β "Authenticating a Git LFS client".)
Push the bytes to R2 (no GitHub remote required), then reclaim local disk. The
bytes route to wherever lfs.url points (set above) β a git remote is not a
prerequisite. git lfs push needs only a remote name to enumerate objects from the
branch; with lfs.url set, that remote's own URL is never contacted for the transfer.
Scaffolded portals are local-only (create-portaljs does git init + an initial
commit, no remote; a bare git push would fail with "No configured push
destination"), so branch on whether a real remote exists:
BRANCH=$(git rev-parse --abbrev-ref HEAD)
if git remote get-url --push origin >/dev/null 2>&1; then
# A GitHub remote exists (collaboration setup): a normal push carries commits +
# LFS pointers to GitHub AND streams the bytes to R2 via the pre-push hook.
git push -u origin "$BRANCH"
else
# Local-only scaffold (the DEFAULT): no GitHub remote. Add a throwaway local
# remote just so git-lfs has an object source, then push the LFS objects β the
# bytes go to Giftless/R2 via lfs.url; GitHub is never contacted. (locksverify
# is off because Giftless serves no locking API β the check is cosmetic noise.)
git remote add r2-lfs . 2>/dev/null || true
git -c lfs.locksverify=false lfs push r2-lfs "$BRANCH"
fi
git lfs prune # working tree + .git/lfs cache β 2Γ locally until prunedA GitHub remote is optional β it's for collaboration, not storage. Data-to-R2 is git-lfs talking to Giftless over HTTP (the LFS batch API at
lfs.url); that URL alone determines the upload destination, independent of any git remote. A GitHub remote only shares commits + LFS pointers with collaborators β orthogonal to where the bytes live. This is what makes create β add-data β deploy work for a non-dev user who has never configured a remote.
Build the absolute R2 URL for the manifest. The browser fetches R2 directly, so
path must be the public R2 URL β resourceUrl() passes absolute URLs through unchanged.
The Giftless object key is lfs/datopian/<project-slug>/<oid>, where <oid> is the SHA-256
in the committed LFS pointer:
OID=$(git cat-file -p :data/$DATASET_SLUG.$EXT | sed -n 's/^oid sha256://p')
# MANIFEST_PATH = $R2_PUBLIC_BASE/lfs/datopian/<project-slug>/$OIDR2_PUBLIC_BASE defaults to https://data.portaljs.com β the public read domain on the
portaljs-giftless R2 bucket (GET/HEAD + range + CORS, edge-cached). So the browser fetches
https://data.portaljs.com/lfs/datopian/<project-slug>/<oid> directly. Override it only for
an OSS self-host with its own bucket/domain (export R2_PUBLIC_BASE=β¦).
Only for bundled SAMPLE data shipped with a template, or an OSS self-host running without
R2 credentials. Copy into public/data/, which the .gitattributes fence keeps inline:
mkdir -p PORTAL_DIR/public/data
cp "$SOURCE" "PORTAL_DIR/public/data/$DATASET_SLUG.$EXT"MANIFEST_PATH is the bare filename ($DATASET_SLUG.$EXT) β served from /data/<file>.
Open PORTAL_DIR/datasets.json (a JSON array) and append one entry, keeping all existing
entries. Match the Dataset shape from lib/providers/types.ts:
{
"slug": "DATASET_SLUG",
"namespace": "NAMESPACE",
"name": "DATASET_NAME",
"description": "DESCRIPTION",
"file": "MANIFEST_PATH",
"format": "csv"
}fileisMANIFEST_PATHfrom Step 4: a bare filename (inline), or an absolute URL (passthrough or R2).resourceUrl()resolves both.formatis one ofcsv | tsv | json | geojson(lowercase), matching the detected format.namespaceis the value gathered in Step 1.(namespace, slug)must be unique across the manifest β if a clash exists, ask the user for a different slug or namespace.- Drop
descriptiononly if there genuinely is none (it's optional in the type).
That is the entire registration. getStaticPaths in pages/[owner]/[slug].tsx picks up the
new (namespace, slug) pair at build time, and the catalog at /search filters over it.
Do not create any page file β there is no pages/datasets/[slug].tsx in this template.
cd PORTAL_DIR
npx next build > /tmp/portaljs-add-dataset-build.log 2>&1
BUILD_EXIT=$?
tail -20 /tmp/portaljs-add-dataset-build.logIf BUILD_EXIT is non-zero, print the log and fix the error (commonly malformed JSON in
datasets.json) before reporting success.
β Dataset added: DATASET_NAME
- Route: <localβR2 LFS | remote URL passthrough | adoptedβR2 | inline sample>
- Data: <data/DATASET_SLUG.EXT (LFS β R2) | the source URL | public/data/DATASET_SLUG.EXT>
- Manifest: datasets.json (entry appended; path = MANIFEST_PATH)
- Showcase: /@NAMESPACE/DATASET_SLUG (rendered by pages/[owner]/[slug].tsx)
- Catalog: appears in /search automatically
For an LFS route, remind the user the bytes are in R2 and git lfs prune has reclaimed local
disk. Then:
Next: run `npm run dev` and visit http://localhost:3000/@NAMESPACE/DATASET_SLUG to verify,
or run /portaljs-add-chart or /portaljs-add-map to add a view to its showcase.
- No per-dataset page. Registration is one JSON entry. The dynamic route
pages/[owner]/[slug].tsxrenders metadata + aTabledata preview + a Download & API section + a Views placeholder for every manifest entry. pathis the seam. Repo-relative β/data/<file>(inline); absolute URL β passed through (remote passthrough or R2). One abstraction (resourceUrl()) serves all routes β never branch on the source at the call site.- CSV/TSV preview via the template's
<Table />, which fetches the resolved URL in the browser and auto-detects the delimiter (papaparse). JSON / GeoJSON entries show a download link in the showcase; use/portaljs-add-mapfor a Leaflet view of GeoJSON. - LFS is format-agnostic.
.gitattributesships minimal (only the inline fence); this skill appends a per-file tracking entry for whatever it routes to R2 β including images and arbitrary binaries β never an extension glob. - Column names with spaces are preserved by papaparse and wrap fine in the table.