Development and deployment

Prerequisites

That is the entire toolchain for onnne.link itself. deno.json declares every dependency and deno.lock pins it; nodeModulesDir is set to "manual", so npm packages are materialised into node_modules/ by Deno when needed.

The apps under mikro/ are the exception — each brings its own toolchain, and building them needs Node.js and pnpm. You only need those if you are rebuilding a hosted app; see MIKRO.md.

Tasks

Defined in deno.json:

Task Command Purpose
deno task dev vite Dev server with HMR, default port 5173
deno task build vite build Production build into _fresh/
deno task start deno serve -A _fresh/server.js Serve the build
deno task test deno test -A Run the test suite
deno task check deno fmt --check . && deno lint . && deno check Format, lint, and typecheck — run before every commit
deno task update deno run -A -r jsr:@fresh/update . Upgrade the Fresh framework in place

Plus one task per hosted app, and a fan-out that runs them all:

Task Purpose
deno task mikro:build Build every app under mikro/
deno task mikro:build:<app> Build one app, with its own --base=/m/…

These shell out to pnpm inside the app directory, so they need Node.js. Deno Deploy runs mikro:build before build via the deploy block in deno.json.

Development

deno task dev

Vite serves on http://localhost:5173 with hot module reloading. CSS changes hot-reload because client.ts imports the stylesheet.

Adding or editing a file in data/links/ does not trigger HMR — the JSON is read at request time by Deno.readFile, outside Vite's module graph. Just reload the page; the change is picked up because nothing is cached.

Checks

deno task check

Runs three things in sequence, stopping at the first failure:

  1. deno fmt --check . — formatting. Covers .ts, .tsx, .json and .md, so the files in docs/ are checked too. Fix with deno fmt ..
  2. deno lint . — with the fresh and recommended rule tags enabled.
  3. deno check — TypeScript across the project.

_fresh/ is excluded from all three via the top-level exclude in deno.json.

deno task test

Eight layers, none of which need a build or a free port:

  • Registry (tests/demos_test.ts) — asserts every link type has a demo, that each resolves and is served, that the home page links to it and previews it in an inert iframe, that the Alpha demo's rawHtml block survives sanitisation, and that no demo carries a real contact detail.
  • Docs (tests/docs_test.ts) — that every file in docs/ is reachable at /docs without being registered anywhere, that a slug cannot escape the directory, that every cross-doc link and #fragment resolves, and — since these files are now public — that none of them publishes a customer identifier.
  • Island dependencies (tests/islands_test.ts) — runs deno info over each island's runtime module graph and fails if it reaches an npm package outside a browser-safe allowlist. This exists because it was violated: sanitize-html dragged node:fs/node:path/node:url into both island chunks and no island hydrated at all, while every other test stayed green. See ALPHA.md.
  • Unit (tests/{validate,data,vcard,phone,icons}_test.ts) — validation, vCard escaping, phone normalisation, icon fallbacks, the link cache, plus a fixture test asserting every file in data/links/ validates and that each uuid field matches its filename.
  • Rendering snapshots (tests/render_test.ts) — every page type rendered to HTML and compared against tests/__snapshots__/render_test.ts.snap. Fixtures are synthetic and cover branches no live link reaches, so adding a real link does not churn them.
  • HTTP (tests/app_test.ts) — the real route modules and middleware mounted on a bare App, asserting status codes, content types, cache headers, the path-traversal guard, and the builder preview route's two representations.
  • Builder (tests/builder_test.ts) — the path helpers behind the draft state (including that they never mutate their input), uuid generation, that every starter template passes parseLink, that the vocabulary served by /builder/schema matches the node registry field for field, and that a freshly inserted node of every type validates.
  • Boundaries (tests/builder_boundary_test.ts, tests/islands_test.ts) — what may import what. The builder must reach the app only over HTTP, and no island may reach a Node-only dependency. Both walk the runtime module graph with deno info, shared through tests/module_graph.ts. The islands one exists because it was violated: a node:fs import reached both island bundles and neither hydrated, while every other test stayed green.

One check does need the build, so it runs in CI after deno task build rather than in the suite: no file in _fresh/client/assets/ may import a node: specifier. tests/islands_test.ts guards the module graph; that step guards the output Vite actually emits.

A changed snapshot means the markup changed. Review the diff, and only then regenerate:

deno task test:update

Production build

deno task build
deno task start

vite build emits a self-contained server bundle plus hashed client assets into _fresh/. deno serve -A _fresh/server.js then serves it.

Runtime requirements

Working directory matters. Both getLinkByUuid and the asset middleware build relative paths:

`./data/links/${uuid}.json`     // lib/data.ts:86
`./assets/logos/${…}`           // lib/assets.ts

The process must therefore be started from the repository root. Running deno serve from anywhere else yields a site where every link 404s and no logo loads.

These directories must ship with the build:

Directory Why
data/links/ Read at request time — not bundled into _fresh/
docs/ Rendered at request time by /docs — not bundled
assets/logos/ Read at request time by the middleware — not bundled
static/ Served by staticFiles()
mikro/*/dist/ Read at request time by serveMikro — not bundled
_fresh/ The build output itself

This is the single most common deployment mistake: shipping only _fresh/ produces a server that starts cleanly and 404s on every link.

-A (allow-all) is required, or at minimum:

Permission Needed for
--allow-read=. Deno.readTextFile / Deno.readFile
--allow-net Binding the HTTP listener
--allow-env Deno/Fresh runtime configuration

Tightening -A to an explicit set is a reasonable hardening step; nothing in the app needs write access.

Port. deno serve defaults to 8000 and honours --port. Set it explicitly behind a reverse proxy.

Environment variables.

Variable Default Effect
SITE_ORIGIN https://onnne.link Origin used for QR content, og:url and og:image

SITE_ORIGIN is the only one. It defaults to production, so an unconfigured deployment renders exactly what production renders. Set it on staging or locally if you need QR codes that point back at that deployment:

SITE_ORIGIN=http://localhost:8000 deno task start

Outbound dependency at runtime: every page loads https://unpkg.com/@lottiefiles/lottie-player@2.0.4/dist/lottie-player.js from the client. If unpkg is unreachable the pages still work — only the error-page animation is lost.

Example: minimal container

FROM denoland/deno:2.9.6
WORKDIR /app
COPY . .
RUN deno task build
EXPOSE 8000
CMD ["deno", "serve", "-A", "--port", "8000", "--host", "0.0.0.0", "_fresh/server.js"]

COPY . . before the build is deliberate — it keeps data/links/ and assets/logos/ in the image, and WORKDIR /app guarantees the relative paths resolve. Add a .dockerignore for node_modules/, _fresh/ and .git/.

Publishing a content change

Because links live in git, publishing is a deploy:

deno fmt .
deno task check
git add data/links/ assets/logos/
git commit -m "feat(links): add …"
git push
# then rebuild + restart the server

See OPERATIONS.md for the authoring steps, or BUILDER.md to assemble the record at /builder and export it.

Ignored paths

From .gitignore:

.env, .env.*.local     # not currently used by any code
_fresh/                # build output
node_modules/          # npm deps materialised by Deno
vendor/                # vendored deps
.claude/.docs-dirty    # docs-staleness sentinel (see CLAUDE.md)

Never edit _fresh/ — it is regenerated by every build.

Editor setup

.vscode/extensions.json recommends:

  • denoland.vscode-deno — LSP, formatting, lint
  • bradlc.vscode-tailwindcss — class autocomplete

.vscode/settings.json enables deno.enable and deno.lint, sets the Deno extension as the default formatter for TS/TSX/JS/JSX, and maps *.css to the tailwindcss language mode so IntelliSense understands @import "tailwindcss".

If TypeScript errors appear across the project in VS Code, the built-in TS server is probably competing with the Deno LSP — confirm deno.enable: true is active for the workspace and reload the window.

CI

.github/workflows/ci.yml runs deno task check, deno task test and deno task build on every push to main and every pull request.

deno task check runs deno fmt --check, deno lint and deno check in an && chain, so a lint failure short-circuits before the typecheck. If you are debugging, run the three separately.

The suite includes a fixture test asserting that every file in data/links/ validates and that each uuid field matches its filename — that is what stops a malformed link reaching production.