Websites built from every folder's docs

Change a website, add a page to it, or translate it. Local Link has two websites built from repository pages and a shared navigation outline, so each page says the same on GitHub and on the website.

cd web
npm install
# both, www.local.link at http://localhost:3000 and docs.local.link at 3001
npm run dev

The two websites

Each is a Next.js app on Vercel, and both are one package, web/packages/site: the same reader, layout and look, and each app's routes only re-export it.

web/apps/home: the home page, the mission, sponsors, Hire Us, the waitlist and the legal pages, from that folder's own docs/.

web/apps/docs: the documentation, from the docs/ of every folder scripts/documentation-index.json lists.

What they run on

They use Next.js 16 with the App Router, React 19, TypeScript, Tailwind CSS 4 and ESLint, and need Node 20.9 or newer. They don't run on the relay droplet, and the relay knows nothing about them.

One set of docs

These rules keep the docs one set, and cargo test in the core checks them: every folder's links and anchors, that no link names a page by its address on the website, and that no README.md outside a docs/ folder, nor a page in .github/, holds more than a hub.

Every fact is in a folder's docs/en/

Every file there is a page of one of the websites. Nothing is said only in the repository or only on the website.

A folder's pages are in that folder

They change in the pull request that changes its code. link-public/integrations/agents/harnesses/openclaw/docs/en/agents/harnesses/openclaw/ is OpenClaw's page, at /agents/harnesses/openclaw, and the core's link-public/plugins/agents/agent/docs/en/agents/ is Agents, at /agents.

A page's path is its URL

A page's path in its folder's docs/en/ is its URL on its website, whichever folder holds it, and the build fails if two folders of one website hold one path. The shared navigation outline determines where the page appears in the reading tree, independently of its URL.

A README.md outside docs/ is a map of its folder

A paragraph says what the folder holds: its long description. Its ## Docs lists its pages and child folders' README hubs in shared reading order, by their titles and short descriptions, said once where it lives (a package's Cargo.toml description, else the first sentence of the README or page), so no parent restates it to drift.

A package's ## Code

It links its entry points, three or so, only code, each in a few words. Any other section comes after. How anything works is said in docs/en/, and what a file does at its top.

A map where the website has none

GitHub shows a folder's README.md where the website has no page. So do .github/'s SECURITY.md, SUPPORT.md and CODE_OF_CONDUCT.md, which point at their pages.

It links its own docs, and the README.md of each folder directly in it. The workspace's links its members (harnesses/, connections/, ...), and harnesses/'s each harness. Regeneration updates the hubs and shared outline after a folder is moved or added.

As in ../agent/README.md, never https://docs.local.link/..., so a reader following links stays in the repository. The website turns each path into the page's URL, in the reader's language, and a path to the other website's page into its address there. Only a website itself, https://docs.local.link/, is linked by its address.

Develop

npm run dev serves both, and npm run lint && npm test && npm run build checks them.

The other website

In development a link to the other website goes to its address on this machine, the index's local, so the two link to each other as they do when deployed.

Show it to someone

To show it to someone else, expose it through Link itself with lnk tunnel open 3000 --name site --public.

Before pushing

Run the commands below. CI runs them in .github/workflows/website.yml on release pull requests that change any folder's docs/ or link-public/web/.

npm run lint && npm test && npm run build

Its cache

Development checks the docs' file versions once per server render and reuses unchanged Markdown, page data and navigation indexes. Edits, additions and deletions refresh the cache, including changes to navigation.md and the folders the index lists.

What a page renders

Each page supplies its own headings to the sidebar; opening one never renders the other pages to collect theirs. The Geist fonts are bundled locally, so development and production builds download no fonts.

Add a page

Add a Markdown file to the docs/en/ of the folder it's about, and its path there is its URL. Run npm run dev and refresh to see a change to the docs.

# docs.local.link/pricing
$EDITOR ../core/docs/en/pricing.md
# or as a folder: the same URL
$EDITOR ../core/docs/en/pricing/README.md

Where it goes

A page of www.local.link's own goes in apps/home/docs/en/, and a new folder of docs is a line in the documentation index.

Its title and description

Its first # heading is its title ("Local Link — Pricing"), and its first paragraph is its description in search results and link previews.

Its place in the tree

Each website's docs/en/navigation.md holds its shared reading order under ## Pages. The website renders that outline, and the README writer follows it. Run the documentation writer after adding or moving a page; it inserts missing entries while keeping curated order. Move links in the outline to change their placement.

- [Agents](../../../../../plugins/agents/agent/docs/en/agents/README.md)
  - [Link Harness](../../../../../plugins/agents/link/docs/en/agents/link/README.md)
  - [Harnesses](../../../../../plugins/agents/harnesses/docs/en/agents/harnesses/README.md)
    - [OpenClaw](../../../../../integrations/agents/harnesses/openclaw/docs/en/agents/harnesses/openclaw/README.md)

A section

A page with pages under it is a section. Its line names the section (OpenClaw), and its own page, its folder's README.md, is the first page under it, called Get Started whatever its title, which is a pitch of its own. The writer inserts a folder's other pages under its README unless the outline already places them elsewhere.

The order and the words of its lines

The outline owns sibling order; use it to put the reader's most useful pages first. Labels follow page titles, and generated README indexes inherit this order. Unranked repository entries remain alphabetical.

The other website's pages

A line linking the other website's page is a tab or a page there, opened in a new tab with an arrow: www.local.link's Documentation is docs.local.link, and docs.local.link links the legal pages, which live once, on www.local.link.

A folder a release holds back

A folder scripts/documentation-index.json marks "released": false is a plugin's a release holds back, and its pages are no page of either website. A link to one goes to its file in the repository, and the tree drops its line with every line under it, so a page placed only there is held with it.

A link in a page is to a page, a file, or an https://, http:// or mailto: address; any other scheme fails the build.

Interface text

Shared interface labels live in packages/site/locales/<language>.json, with English fallback. Navigation files contain structure, and page content supplies descriptions and share metadata.

Headings

No heading has more than ten headings under it, so group a longer list under headings one level deeper.

How a page is laid out

The tabs are pills across the top, and on a wide screen each drops down its pages. The tabs and the column are centred on any screen, the home page laid out as every other, and there is no footer: every page is in the tabs.

The tree

Beside every page is the whole site's tree, the home page's too, the same from tab to tab, with the page you're on dark, the sections above it in bold, its ## headings under it and the other sections folded. Until the screen is wide, the tree folds into a bar under the tabs that says where you are and opens in place.

Folding it away

On a wide screen a pill before the tabs folds the tree away, leaving the page alone in the middle, and back: www.local.link starts folded, with few pages, docs.local.link open (the index's tree), and a browser keeps how its reader last left it.

Around a page

Above its title, a page says the sections it's in, and below its cards come the pages before and after it in its tab, each a card on one line, as wide as its words.

What the Markdown means there

The docs are plain Markdown, so they read the same on GitHub. A few things mean something more on the website:

In the MarkdownOn the website
First # headingIts title, or its tab's name if a tab
Each ## headingA line under the page in the tree
First paragraphIts description
---A break between cards
Lone link titled "button"The big pill button
Link titled "button" in a card's wordsA pill in the card
Lone link titled "sponsors", between ---The sponsors (Sponsors and prices)
Lone link titled "offers", between ---The offers, read in the browser
Link titled "price" in a lineIts words, then that list's price
List of links alone between two ---A row of pills
sh code blockCommands with a copy button
Any other code blockCode
<!-- ... -->Hidden in both
Relative link to a .md fileA link to its page, anchor kept
Link to its own website's addressThat page, in the reader's language
Relative link to the other website's pageA link to its address there
Relative link outside the docsThe file on GitHub, marked as leaving
Link to another siteA link with an arrow after it

Languages and builds

English is served without a prefix. /agents is app/[lang]/[[...path]] with lang = en, and /en/agents redirects to /agents (packages/site/src/config.ts). Every page is built once, at npm run build. Moved documentation explains redirects for relocated pages.

Translate the docs

You write the docs in English, in each folder's docs/en/. A model writes the other languages beside them, in docs/<language>/, and never by hand. Each page names the English it was made from, and the website marks a page whose English has changed since.

# from the app of the website to translate, apps/home or apps/docs
cd web/apps/docs
# what is missing or behind in docs/es/ of each folder
node ../../packages/site/scripts/translate.mjs plan
# send it as one batch, see its progress, and once it is done write docs/es/
export OPENAI_API_KEY=...
node ../../packages/site/scripts/translate.mjs submit
node ../../packages/site/scripts/translate.mjs status
node ../../packages/site/scripts/translate.mjs collect

A language

A language is one this app's docs/ has a navigation.md for, whose ## URLs lists the folder and file names in that language, such as /es/agentes. The language pill in the tabs goes to the same page in another language.

What collect writes

collect validates page headings and code blocks, navigation links and nesting, and UI catalog keys and English-source hashes before writing. Invalid or stale results leave existing files intact. UI catalogs are shared across both apps. LNK_TRANSLATE_MODEL picks the model.

Who runs it

No workflow runs this: whoever changes a folder's docs/en/ runs it, and commits what collect wrote with that change. The Terms of Service, the Privacy Policy, the License and Hire Us stay in English, which binds.

Deploy (Vercel)

The websites go live with a release. A merge into main releases lnk when it bumps the version (release.yml). The release's last step, once it is published, calls each website's Vercel deploy hook, so the sites show what the release does.

Only the release's commit

The hook builds main's head, so it's called only while that is still the release's commit: a release that main moved past deploys nothing, and the next one does. A release that fails deploys nothing, and a change to the docs alone waits for the next release.

Previews

vercel.json turns off Vercel's own deploys of main, and branches and pull requests still get preview URLs.

Its headers

Every page is served with headers that keep it out of another site's frame (frame-ancestors 'none', X-Frame-Options), stop a browser guessing a file's type (nosniff), send other sites only the site's address (Referrer-Policy), keep it on HTTPS (Strict-Transport-Security) and ask for no camera, microphone or location (packages/site/src/config.ts).

Set up a website

Once for each website, in Vercel: Add New → Project, import the repository, then set its Root Directory and its deploy hook, as below.

Its Root Directory

Set Root Directory to its app, link-public/web/apps/home or link-public/web/apps/docs in the union checkout, web/apps/... after extracting the public workspace. vercel.json pins the Next.js framework preset, so the default build settings are right as they are.

Without the Root Directory

A project imported from the repo root is detected as "Other" and fails with No Output Directory named "public" found. The build reads every folder's docs/, outside the Root Directory, so keep "Include files outside the root directory in the Build Step" on, as it is by default.

Its deploy hook

In Settings → Git → Deploy Hooks, make a hook for main, and save its URL as a secret of the repository's release environment: VERCEL_DEPLOY_HOOK for www.local.link, VERCEL_DOCS_DEPLOY_HOOK for docs.local.link. Without one, a release says it deployed nothing there.

Domain

The bare domain local.link points at the relay droplet, which owns /_link/* (agent connections, login, TLS checks) and /install.sh, and tunnels live on *.local.link. So the site can't simply take over local.link DNS. It lives at www.local.link instead, and the docs at docs.local.link.

Its DNS and redirect

A CNAME for each points at its Vercel project, and the relay redirects browsers on the bare domain to www.local.link, through LINK_WEBSITE, which bootstrap's --website sets. The relay's own paths stay on the relay.

What they keep

The websites have no settings. The waitlist page links to cloud.local.link, where the pre-sale is bought, and to the free waitlist's Tally form, which keeps what people enter. The site stores nothing. The photos and the sponsors are read from a public bucket (The bucket).

The bucket

What changes without a release is in one public Google Cloud Storage bucket, local-link-web (packages/site/src/bucket.ts), read in the browser by every website: backgrounds/, sponsors/ and hire-us/. Anyone may read it, from any site; only its owners write.

Backgrounds

Every page shows the day's photo, the same on every website: the list's day-th in turn, counting days in UTC. Each photo is a folder, backgrounds/<name>/, of full.jpg, blur.jpg, a tiny copy shown blurred until the photo arrives, and share.jpg, which a link's share card is made from (the list's first, at build time).

Add a photo

backgrounds/backgrounds.json lists them, with where each is centred on a narrow screen and a wide one. A new photo is a new name; the script makes the three images and puts them and the list up:

cd web/packages/site
node scripts/backgrounds.mjs list
node scripts/backgrounds.mjs add dunes ~/dunes.jpg --position "60% center" --wide "left center"
node scripts/backgrounds.mjs remove dunes

Sponsors and prices

The lists a page reads in the browser are linked from it, alone between two ---, titled which list it is. A browser keeps a list for up to five minutes.

{"price": "$100 a month",
 "sponsors": [
  {"name": "Acme", "url": "https://acme.example", "logo": "logos/acme.png",
   "line": "GPUs by the hour, near your users", "category": "Cloud",
   "since": "2026-10-12", "until": "2026-12-11"}
]}

Sponsors

sponsors/sponsors.json, the logos beside it, is read by Sponsors's link titled "sponsors". A sponsor shows from its since through its until, in the order they joined, grouped by category once there are two. Its price, where a sponsor's price starts, is shown in Become a Sponsor's text after the words of its link titled "price", wherever a page puts one.

A sponsor's entry

A sponsor's name is at most 30 characters and its line 80. A logo is square: an SVG, or a PNG from 512 to 1024 pixels wide, shown at 128 on a white tile. until is left out until a sponsor stops, then set to 30 days after their last payment, or to the day they asked to come down.

Offers

hire-us/offers.json is read by Hire Us's link titled "offers": each a name, what it includes and its price, as a table. Sponsors' words, and the offers', are shown as given, never translated.

{"offers": [
  {"name": "Your own agent, set up", "what": "on your Mac and in your cloud", "price": "$500"}
]}

Validate the examples

The website's catalog-schemas.ts owns the runtime shapes of sponsors, offers and prices. Documentation JSON binds to those same constants, and website tests read the published examples through the runtime validator. Change the schema, examples and owning readers together.

Change the lists

Copy the lists down, edit them, check them, and put them back up; the check reads every name, line, link, date, logo and price first.

cd web/apps/home
# a copy of the lists, as they are, without the photos
gcloud storage rsync --recursive --exclude="backgrounds/.*" gs://local-link-web bucket
$EDITOR bucket/sponsors/sponsors.json bucket/hire-us/offers.json
# names, lines, links, dates, logos and prices, before anyone sees them
node scripts/bucket.mjs check bucket
gcloud storage rsync --recursive --exclude="backgrounds/.*" --cache-control="public, max-age=300" bucket gs://local-link-web

Layout

Page prose stays in each member's docs/; shared catalogs hold interface labels. packages/site/src/docs.ts reads those pages through the index's sites and renders each app's navigation.md outline. Decisions explains the ownership and rendering choices.

No Rust here

web/ holds no Rust, and the core no TypeScript. npm test checks the docs cache against edits, additions, deletions, source-folder changes and translations.

The Turbo cache

The Turbo cache includes the documentation index, component pages, shared website pages and the repository's documentation guides. A page edited in any of those roots invalidates the websites' builds, even when an app's source is unchanged.

Docs

  • Decisions: Why www.local.link and docs.local.link are built the way they are
  • Moved Documentation: Permanent redirects preserve existing documentation links when a page moves to a new address, including its child pages and language-prefixed addresses