Open-source project
yousifamanuel/terraink avatar
yousifamanuel/terraink

Terraink ships thirteen fonts, claims nine, and splits its license by date

Terraink: The Cartographic Poster Engine that creates unique and customizable map posters

4,169 stars427 forksTypeScriptNOASSERTION

At a glance

What is it?
Terraink turns OpenStreetMap data into printable map posters with MapLibre, React and Bun, and self-hosts as a static nginx bundle. The manifest, the release tags and the project's own README disagree about fonts, versions and licensing.
Who is it for?
Read Terraink as a code sample rather than a finished deployment. The map pipeline, the theme model and the browser-side export are all legible in a small TypeScript tree, and every mismatch above is something you inherit the moment you self-host it.
Can I use it commercially?
Check first. The repository uses a licence we do not classify automatically, so read its LICENSE file before any commercial use.
Is it still maintained?
Yes. The repository last received commits 14 days ago.
What is it written in?
Mainly TypeScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 8, 2026, and from our analysis. They are not legal advice.

Editorial analysis

Nine typefaces in the features list, thirteen font packages in the manifest

The features list offers city and country display labels in one of nine bundled, self-hosted typefaces. The devDependencies block carries thirteen @fontsource packages: bebas-neue, ibm-plex-mono, instrument-sans, lato, merriweather, montserrat, noto-sans-jp, oswald, playfair-display, raleway, source-sans-pro, space-grotesk and spline-sans-mono. Nothing in the repository entries says which four go unused, or which face the poster falls back to when a requested one is missing.

They sit in devDependencies rather than dependencies, which is the right home for fonts you want inside your own bundle. Vite pulls them in during the build, so no request leaves the visitor's browser for a font CDN at runtime, and a self-hoster cannot trim the set without rebuilding the image.

The same block holds a version mismatch. The runtime pins react and react-dom at ^18.3.1 while @types/react and @types/react-dom sit at ^19.2.14 and ^19.2.3. The type definitions describe a major version the app never runs, so `tsc --noEmit` checks components against React 19 shapes while the components themselves are React 18. The `typecheck` script exists in the manifest and is mentioned nowhere in the README.

The manifest stops at 0.4.2 while the newest tag reads v0.4.9

The version field in package.json reads 0.4.2. The newest tag is v0.4.9, dated 2026-07-17, and the two before it are v0.4.0 from 2026-03-23 and v0.3.0 from 2026-03-07. Since v0.4.0 shipped in March the two lines moved on their own schedule, and nothing reconciles them.

The tag subjects are poster designs rather than version notes: v0.4.9 is Amsterdam Carrara, v0.4.0 is Amsterdam Copper Patina, v0.3.0 is Halifax Forest. Two of the three name the same city with a different surface, so the tag list reads as a gallery index instead of a changelog. There is no CHANGELOG file among the repository entries. ROADMAP.md is present but the README never links it, and the release names are the only description of what changed between tags.

Because the manifest sets `private: true`, no registry consumes that version string, so the mismatch carries no install-time consequence. It is still the number a deployer checks first to confirm which build is answering, and here it points behind the newest tag.

One variable in the compose file, twenty-two in the example environment

docker-compose.yml resolves a single value, `${APP_PORT:-7200}:80`, and declares no environment block and no build arguments. .env.example lists twenty-two VITE_ variables on top of APP_PORT: a Google Search Console verification token, a Google Analytics measurement ID, two AdSense identifiers, a sidebar ad switch and its slot, five social handles, a Ko-fi link, repository and repository API URLs, three raw markdown URLs for the in-app legal modal, and the canvas credit URL.

The README says those variables are optional for most local work and should not be set during testing unless a specific case requires them. The six measurement and advertising entries ship empty, and the sidebar slot has an explicit off state in the example file, so the default path stays off. Editing .env after the image exists changes nothing either way, because Vite inlines VITE_ values into the bundle during the build. The Dockerfile runs `COPY . .` before that build, so whether a local .env reaches it at all depends on .dockerignore, whose rules are not visible here.

Only the host port is meant to vary per deployment, and the README covers that in two dialects:

bash
APP_PORT=80 docker compose up -d --build
powershell
$env:APP_PORT=80
docker compose up -d --build

Everything else in the example file still holds placeholders: `USER/REPO`, `USER/META-REPO`, `[email protected]`, `example.com/@your-profile`, `YOUR_HANDLE`. An unmodified self-hosted copy renders its Help Us Grow panel and its legal modal against those values, and `VITE_APP_CREDIT_URL` is pre-filled with terraink.app, so the poster credits the hosted service unless someone edits one line.

The self-hosted image is one build ending in a static nginx directory

The Dockerfile has a build stage on oven/bun:1-alpine that copies package.json and bun.lock, installs with `bun install --frozen-lockfile`, copies the rest of the context, then runs `bun run build`. The runtime stage is nginx:1.29-alpine with `apk upgrade --no-cache`, nginx.conf dropped into the conf.d directory, and the build stage's dist output copied over /usr/share/nginx/html. It exposes port 80 and stops there.

Read end to end, a self-hosted Terraink has no server side of its own. Tile fetches, the Nominatim geocoding call and poster composition all happen in the visitor's browser against OpenFreeMap, the OpenMapTiles schema and MapLibre GL JS. No variable in the example environment points that copy at a different tile host or geocoder, so a deployer who wants their own tiles has no configuration surface for it.

Two details keep the image from being reproducible. The Bun tag is 1 rather than a point release, so the version running the frozen install is not fixed by the repository, and the runtime stage upgrades its Alpine packages on every build. The Dockerfile also sets no USER directive, so the container keeps whatever the base image runs as.

bash
docker compose up -d --build
bash
docker compose down
bash
docker build -t terraink:latest .
docker run -d --name terraink -p 7200:80 --restart unless-stopped terraink:latest

That last block is the README's path without Compose, and it hard-codes 7200 on the host side. The APP_PORT override documented above does not apply to it.

The dev server binds every interface and the README does not say so

The Run section is two lines:

bash
bun install
bun run dev

The dev script is `bunx --bun vite --host`, and the flag that matters is the trailing one, which makes Vite listen on every interface instead of loopback only. The README states a URL for the Compose path at `http://localhost:7200` and gives none for the dev path, so a first local run hands you a LAN address rather than the localhost address the Docker section just taught you to expect. The preview script, `bunx --bun vite preview --host`, binds the same way, and no documented command checks a production build locally before you containerize it.

Both scripts also reach the Vite binary through Bun's package runner rather than a path inside node_modules. The container pins its install against bun.lock with the frozen flag; the local commands above do not.

The build command is equally short:

bash
bun run build

One more manifest script, `node scripts/combine-showcase-grid.mjs`, is the only entry that calls Node instead of Bun and the only apparent consumer of the sharp dependency. Its output has nothing to work from: the Featured Examples block under the gallery heading in the README is a single empty paragraph tag.

Pull requests go to a branch a fresh clone never checks out

The default branch is main. CONTRIBUTING.md says to branch from `dev` and target `dev` only, and not to open pull requests against `main`. The Run, Build and Deploy sections never mention `dev`, so the first thing a new contributor reads points them at a branch their clone does not have.

The repository entries also carry `CLAUDE.md` and `agent.md` at the root next to `CLA.md`, which puts an agent instruction file and a contributor license agreement in the same PR that the branch rule already constrains. On generated code the contributing text is permissive with conditions: AI-assisted coding is allowed, but submissions must be reviewed, refined, and intentionally engineered before review. That is a review standard rather than a prohibition, and it is the one policy here a contributor can check a draft against before opening anything.

The last push on record is 2026-09-24 and the repository is not archived. The newest release tag, v0.4.9, is two months older than that, so the source is moving ahead of the tags.

MIT before April 3rd 2026, AGPL after it, one flat value in the manifest

The license section splits the grant by date: as of April 3rd 2026 all new changes are AGPL-3.0, and code released before that date stays under MIT. Both LICENSE and LICENSE-OLD sit at the repository root, and nothing in the top-level layout marks which files fall on which side of the boundary. A deployer reading the tree has to treat the commit date as the deciding fact.

The manifest does not express that split. package.json declares a single `AGPL-3.0-only` value, and GitHub shows no license for this repository at all. Three sources give three different answers: a date-based split in prose, one flat copyleft value in JSON, nothing on the repository page.

The same section keeps the hosted service and the open-source build apart. The hosted version includes attribution and branding in the interface, extra features sit behind Terraink Business at [email protected], and anyone deploying the open-source version is told they are responsible for complying with AGPL-3.0, including preserving license and copyright notices. Sitting on top of the copyleft grant, TRADEMARK.md records a DPMA application for the Terraink name with Paris Convention priority, and the logo and visual identity are copyrighted separately from the code. Forking the code does not grant the name, and the README says unauthorized use of Terraink in connection with similar software or map services may be restricted.

Three export formats promised without an encoder in the dependency list

The features list promises a print-ready poster as PNG, PDF, or layered SVG at any defined dimension. The manifest's non-UI dependencies are maplibre-gl, react, react-dom, react-colorful, react-icons, react-markdown, rehype-sanitize, remark-breaks and rollup. There is no PDF library and no SVG library in either block, and sharp sits in devDependencies for the gallery grid script rather than for export. A PNG can come off a browser canvas; PDF and layered SVG have to be assembled by hand or by code outside the manifest.

The markdown toolchain in that list is not for the poster. It matches the variables commented as raw markdown URLs for the in-app legal modal, including the one built as `/privacy-app`, and rehype-sanitize is the piece that filters those fetched documents before rendering. All three URLs point at raw.githubusercontent.com under a repository named USER/META-REPO, so a self-hosted copy fetches its legal text from a path the deployer has to supply, and the privacy-app document is built into a route rather than fetched from one.

Two README headings are also hollow. The User Interface heading is followed immediately by the next heading with nothing between them, and the featured examples block contains only an empty paragraph tag. Between those gaps and thirteen font packages against a claimed nine, the written description reaches past what the repository actually shows.

Editorial conclusion

Read Terraink as a code sample rather than a finished deployment. The map pipeline, the theme model and the browser-side export are all legible in a small TypeScript tree, and every mismatch above is something you inherit the moment you self-host it. Before building on it, pin Bun and the tile host yourself, decide which grant your copy actually needs, fill in the placeholder URLs so the legal modal and the canvas credit point somewhere real, and expect to fix the version field that nothing currently reads.

Frequently asked questions

What is the Terraink app used for?

It turns OpenStreetMap data into map posters for any location, letting you search a city or region by name or enter coordinates manually, style roads, water bodies, parks and building footprints per layer, set city and country labels in one of nine bundled typefaces, and export the result as PNG, PDF or layered SVG at any defined dimension.

Is Terraink safe to use?

The browser talks to third-party endpoints: OpenFreeMap for tiles, the OpenMapTiles schema for the tile format, and Nominatim for geocoding, so those requests leave the visitor's machine. The analytics, AdSense and site-verification variables ship empty, the sidebar ad slot is off by default, and the self-hosted path runs nginx in front of a static dist directory with no application server behind it.

Does a self-hosted Terraink need an API key for map data?

No key appears in the example environment file, and the README says its variables are optional for most local work. The only value the compose file reads is APP_PORT, which defaults to 7200, and nothing in that file points the build at a different tile host or geocoder.

Which branch should a Terraink pull request target?

CONTRIBUTING.md says to branch from `dev` and target `dev` only, and not to open pull requests against `main`, which is the default branch. The README's Run, Build and Deploy sections never mention `dev`, so a fresh clone has to fetch it first.

What license covers Terraink code contributed after April 3rd 2026?

The license section states that all new changes from April 3rd 2026 are licensed under AGPL-3.0, while code released before that date remains under the MIT license. The manifest declares a single `AGPL-3.0-only` value, and both LICENSE and LICENSE-OLD sit at the repository root.

Official sources

  1. Issues
  2. Project website
  3. README
  4. Releases
  5. yousifamanuel/terraink on GitHub
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/yousifamanuel-terraink.svg)](https://hysenlabs.com/projects/yousifamanuel-terraink)