The FNSTools site: a hand-written landing page plus generated docs.
Static files, no framework, deployed on Vercel with root directory =
website.
packaging/catalog.json category + description + access per package (curated)
packaging/docs/<Name>.md prose + frontmatter per package (curated)
website/index.html landing page (hand-written)
website/content/patreon.html /patreon/ prose, a fragment (hand-written)
website/content/about.html /about/ prose, a fragment (hand-written)
website/content/family.json the other Function Store products (curated)
website/content/guides/*.md guides and reference pages, one per file (hand-written)
website/docs/ GENERATED — gitignored, built on every deploy
website/get/ GENERATED — gitignored, the online configurator
website/patreon/ GENERATED — gitignored, from content/patreon.html
website/features/ GENERATED — gitignored, from feature_list in packaging/docs/
website/catalog.json GENERATED — gitignored, the tool feed functionstore.xyz reads
/get/ is emitted from packaging/configurator/index.html with the
manifest baked in (the bucket now sends CORS, so the page also refreshes
that manifest at runtime) and window.FNS_SITE set, which promotes the
Copy install script button — the paste rail described in
packaging/README.md. Edit the configurator, not the emitted page.
That same file is also read verbatim into a Text DAT and served from
inside TouchDesigner, so it carries its own copy of the palette and every
style it needs; /docs.css and /site-nav.js do not exist over there.
The site flavor is assembled here instead: this build injects the fonts,
site-nav.css and docs.css, and replaces the <!-- FNS:HEADER --> and
<!-- FNS:FOOTER --> markers with the real site header and footer. It
refuses to build if either marker is gone, because the failure mode
otherwise is a live page that silently loses its navigation.
The tokens are declared twice on purpose — once inline in the
configurator, once in docs.css — with the same names and the same
values, so the two cannot disagree while the page is dressed in both. The
picker's light theme is opt-in and its toggle is hidden on the site,
whose header and footer are dark-only.
packaging/docs/ is the source of truth for documentation. It is not
generated from the GitHub wiki — the wiki seeded it once
(packaging/docs_seed_from_wiki.py) and is now frozen. Edit the markdown.
One file per shipping package, filename matching the catalog.json key
exactly. That is what lets the same markdown be embedded into each tool
later, and what makes help_url in manifest.json point at a page that
exists.
catalog.json keeps owning category and description; frontmatter must
not duplicate them. The build joins the two on package name and refuses
to build if they disagree — a .md with no catalog entry, a catalog
package with no .md, a frontmatter package that does not match its
filename, or an internal /docs/ link pointing at a page or heading that
does not exist.
A guide is a page about the toolkit as a whole, as opposed to one package:
website/content/guides/<slug>.md, with a frontmatter of title, summary
(the lede and the meta description) and the optional section and order.
The filename is the URL: getting-started.md is served at
/docs/guides/getting-started/, so it must be lowercase letters, digits and
hyphens, and it should match the title so a guessed URL lands where the
reader expects. The build renders a guide through the same markdown
pipeline as a package page, gives it the docs chrome (sidebar, crumbs, an "On this page" list read off its ##
headings), and Pagefind indexes it with the rest of the docs. Internal links
to /docs/... pages and headings are validated both ways, the dash note
covers its prose, and a missing title or summary fails the build. No
parameter tables, badges or registry sections are generated for it.
section decides where it sits. guides (the default) is the short kind:
it leads the sidebar under Guides and leads the docs index. reference
is the long-form kind: it closes the sidebar's Reference group beside
the common-parameters page and closes the index, so a reader meets the
short pages first and the deep one only on purpose. order (default 50,
low first) sets the reading order inside a section, with the title breaking
ties. Make it explicit: without it the order is the filename's alphabet,
and a guide named advanced-... would silently outrank Getting started.
Three ship today, and they are a ladder:
| Guide | Section | For |
|---|---|---|
getting-started.md |
guides, 10 | install, pick, update, settings, Plus, help |
architecture.md |
guides, 20 | the shape of the toolkit in one page, around 700 words |
how-fnstools-is-built.md |
reference, 10 | the full account: bootstrap, versioning and updates, dependencies, the release rail, the launcher ecosystem, the gate |
Each links to the next one down and the deep page links back up, so a
reader can stop at any rung. The internal record behind the deep page is
docs/ArchitectureResearch.md. Guides are not in the CMS; edit the
markdown.
/features/ lists what every package does, one line per feature, each
linking to the heading on the tool's page that explains it. It is built
from the feature_list in each doc's frontmatter (see Frontmatter below),
so a tool's features are edited in the same file as its page. A search box,
a Free/Patreon switch and a tool-author toggle filter it in place; without
script the whole list is still there.
It is a page of its own, not a guide under /docs/. It answers the
landing catalogue's question one level down ("does the toolkit do X?"), it
wants the full width a docs sidebar would take, and outside /docs/ it
stays out of Pagefind, where a page repeating every tool page's words would
come first for almost any search. The way in is the landing page: the
"What's in the toolkit" intro links it, and every category fold ends with
"All N features →" pointing at that category's section. The docs
sidebar's Reference group and the docs index link it too.
The tool line is the catalog description, the same one the landing page and the docs index show. Tools are grouped and ordered as in the docs (Core last, free first), with the same Patreon and trial markers.
Previews are left out unless the build runs with FNS_SHOW_PREVIEWS=1,
which shows them marked, unlinked (their pages are not built) and noindexed.
The CMS's Build site sets it for the local preview, the same way it sets
FNS_SHOW_DRAFTS=1; the public build never does, so a forgotten flag hides
them. A preview's feature_list lives in its own doc, which the public mirror
already withholds, and when the preview is released its features join the
page with nothing else to do.
The build refuses an entry whose anchor is not a heading on that tool's
page, an unknown key, or an entry without name and does, and notes any
released package with no feature_list. python tests/test_feature_list.py
fails until every released package has one.
The site has two audiences at once: someone who has never heard of any of
this, and someone deciding whether to pay for part of it. The shape that
serves both is progressive disclosure — the landing page answers the
common question in the open and keeps the precise answer one <details>
away. <details> rather than script: it works with JS off, it is focusable
and announced, and Chrome's Ctrl+F finds closed content.
That is why the landing page has one install section instead of the previous two (a "paths" row and a quickstart saying the same thing). Its three cards are tagged Either / Or / Then, not 1 / 2 / 3 (owner, 2026-09-30): download-and-drop and pick-online-and-paste are alternative ways in, and only the updater card follows both. Numbering them read as a sequence and hid the paste path inside step 2. That is also why the tool catalogue is one fold per category rather than 49 rows in a column. Nothing was deleted; the caveats moved into the fold under the step they belong to.
A package is gated when catalog.json gives it an access that is not the
literal free. access NAMES A TIER (docs/GatedDeliveryResearch.md
§9.3), the ENTRY tier of the ladder. The marker says Patreon; it was
"Plus" until 2026-09-13, and the CSS classes (.plus-mark, .tier--plus)
and the PLUSPKGS marker kept the old name (docs/PatreonNaming.md). The
tier's NAME (Base, Pro, Coaching) comes from the repo manifest's
toolkit.tiers ladder, and "or a Gumroad licence key" from its per-package
key_available; build_manifest derives both, so the site never keeps a copy
of the tier→packages map, which is the failure LOPs shipped. Absent access
means free, so a catalog written before gating existed reads correctly.
Gated packages surface in five places, all generated from that one field:
| Surface | What it shows |
|---|---|
| landing catalogue | a Patreon marker on the row, and · N Patreon in the category count |
/patreon/ |
the generated list of gated packages with each one's minimum tier, and the whole story around it (/plus/ redirects here, vercel.json) |
| docs page | a ◆ Patreon badge and an unlock callout naming the tier above the prose |
| docs index + sidebar | the same marker beside the name |
/get/ picker |
a Patreon chip (tier in its tooltip), a tinted card, and a line in the summary |
Gated packages are visible and tickable everywhere, never hidden. That is the decision from GatedDeliveryResearch §9.2 — a picker that hides them lies about what the toolkit is — and the picker cannot refuse the tick anyway: entitlement is the server's answer and no page here has asked it. The real lock is R2 refusing to serve the object.
content/family.json holds the other Function Store products. It is
site-only content — packaging/ never reads it — injected into the FAMILY
markers on both the landing page and /patreon/, because two hand-kept
copies of the same two cards drift.
/patreon/ is generated the way /get/ is: content/patreon.html is a fragment
of prose with PLUSPKGS and FAMILY markers, and the build wraps it in the
shared head, header and footer. Edit the fragment, not website/patreon/. The
build refuses to run if either marker pair is gone — the failure mode
otherwise is a Patreon page that quietly lists nothing.
Status (corrected 2026-09-08): the page's claims are true. The gate went live on 2026-08-29,
catalog.jsoncarries real tier ids on six packages,Gateurldefaults tohttps://gate.functionstore.tools, and an unauthenticated GET of aplus/artifact returns 401 (probed 2026-09-08). This note used to say the Worker was undeployed and the catalog carriedPLACEHOLDER_TIER; both were true until the 2026-08-29 deploy.
/about/ is built from website/content/about.html the way /privacy/ and
/terms/ are: a hand-written fragment wrapped in the site chrome. It says
who makes the toolkit and points at the portfolio (functionstore.xyz), which
keeps the long bio and the work; keep the facts in step when they move.
The Mission section is left out until it is written. A <h2> followed
only by comments is dropped from the built page (the same rule the community
posts use), so the fragment carries the heading and the note to fill it in
without the site publishing an empty section. Write one or more paragraphs
under the heading and the next build publishes them; the build prints a note
while a section is still unwritten.
The landing page carries a short "Who makes this" strip that links to
/about/, and About is in the header (hand-written in index.html and
navLinks in the build, change both) and the footer.
https://functionstore.tools/catalog.json is the published catalogue as one
JSON document, for other sites to read. The Function Store portfolio
(myPortfolioWebsite, functionstore.xyz) renders its Tools section from it:
its build takes a snapshot into data/tools.js, and the page refreshes from
the live URL in the visitor's browser. That is what replaced the Notion
tools database over there. This repo is the list of tools; nothing is typed
twice.
The build writes it last, after every refusal gate, from the same pages,
categories, family.json and guides the HTML is built from. So a feed that
exists describes a site that built: previews are absent, titles are the
public names, each category carries its glyph, pitch and group, each tool its
docs URL, access (free or patreon), tier name, unlock route, surfaces,
author and pricing. vercel.json serves it with
Access-Control-Allow-Origin: * and a five-minute cache, because a
third-party page fetches it from the browser.
Fields are only ever added under schema: 1. A consumer this repo does
not deploy reads them by name, so a rename or a removal bumps schema and
gets the consumer changed first. The reasoning and the field list:
docs/PortfolioCatalogFeed.md; python tests/test_catalog_feed.py pins the
wiring.
cd website
npm install
npm run build # generate pages, then index them for search
npm run serve # http://localhost:3000| script | does |
|---|---|
npm run build |
pages + search index (use this one) |
npm run pages |
pages only — leaves the existing search index alone |
npm run search |
re-index only |
Do not commit website/docs/, website/get/, website/patreon/ or
website/features/ — all of them are gitignored.
Vercel runs this same build on every deploy, so the generated tree exists
only on your machine (for preview) and on the deploy. Build output in git
is what used to let the site drift from its sources, and it needed a CI
job to police it; now there is nothing to drift.
npm run cms # http://127.0.0.1:8787An internal editor for the two curated sources. Left: every package,
grouped by category, with STUB and TODO badges and a filter. Middle:
the metadata fields and the markdown. Right: live preview, the built page,
and build output.
It writes straight to packaging/catalog.json and
packaging/docs/*.md — there is no database and no staging step. Review
with git diff, undo with git checkout -- packaging/.
- Recommended (checkbox under Description) puts the package in the
installer picker's Recommended preset — the starting point the
first-run welcome offers. It is stored as
recommended: trueon the package'scatalog.jsonentry (absent when off, so toggling is a one-line diff) and reaches users as the manifest'sstarterlist at the next release; the list shows aRECbadge. Tools only — core is never part of the offer. - Save is
⌘S. The window warns before you navigate away dirty. - Build site runs
build-site.mjsand shows its output, including the validation failures — so a link to a heading that does not exist surfaces here rather than at deploy time. It setsFNS_SHOW_DRAFTS=1andFNS_SHOW_PREVIEWS=1, so the local preview also shows community drafts and, on/features/, preview packages, both marked. feature_listis not edited here yet. The editor keeps it on save (it sends the whole frontmatter back), but you change it in the markdown file.- Features are derived, not typed. The list re-syncs from the
##headings in the body as you write, keeping the icon and hotkeys attached to each surviving anchor. That is what keepsfeatures[]anchors and the real heading slugs from drifting apart. - Hotkeys are one per line,
KEYS = what it does.
Categories in the top bar edits the category list itself — add, rename, reorder, delete, and set each one's glyph and one-line pitch.
Order is meaningful: it is the order sections appear on the landing page, in the docs sidebar, and in the installer picker. Move rows with ▲▼.
Renaming moves every package in that category with it, in a single write. That is deliberate — the site build refuses to run on a package whose category is not in the list, so a half-applied rename would leave the repo unbuildable. Deleting is only allowed once a category is empty; the button stays disabled while anything is still in it.
The glyph and pitch live in catalog.json under category_meta, beside
the category list. It used to be hardcoded in build-site.mjs, which
meant renaming a category silently dropped its glyph and pitch.
They reach four surfaces now: the landing page sections, the docs sidebar
and index, the picker preview — and the configurator itself, which heads
its categories the same way. build_manifest.py copies category_meta
onto manifest.json for that last one, because the picker served from
inside TouchDesigner has no site to fetch it from; packaging itself still
reads only categories and packages, so the key is inert there. The
site build also bakes a copy into /get/, so the online picker keeps its
glyphs against a release published before the manifest carried the key.
Bound to 127.0.0.1 on purpose: this process writes to the repo, so it
must not be reachable from the network. There is no auth because there is
no remote access.
Saving a file rewrites its frontmatter through the same YAML serializer,
so quoting is normalised. Every file has been run through it once already,
which means a git diff after editing shows only what you actually
changed. Prose is never touched — including trailing double-spaces, which
are markdown hard line breaks.
If a file changed on disk after the editor loaded it, saving returns a conflict instead of clobbering it. Reload and redo the edit.
npm run cms, edit, Save. (Or editpackaging/docs/<Name>.mdby hand — the CMS is a convenience, not a gate.)- Commit the markdown. That is the whole step — the deploy builds the pages from it.
- Optionally Build site in the CMS (or
npm run build) first, to see the result locally and to catch a bad link before the deploy does.
A package must exist in catalog.json before it can have docs — that file
is derived from the TouchDesigner project, so new packages appear by
adding the tool the normal way and re-running build_manifest.py. The CMS
edits packages; it does not invent them.
The landing page's tool catalogue is generated too, between the
<!-- TOOLS:START --> / <!-- TOOLS:END --> markers in index.html, as
is the picker preview in the configurator band, between
<!-- CONFIGURATOR:START --> / <!-- CONFIGURATOR:END -->, and the product
cards between <!-- FAMILY:START --> / <!-- FAMILY:END -->. Everything
outside those markers is hand-written — edit it freely. The build exits on a
missing marker pair rather than silently dropping the block.
The nav is duplicated: hand-written in index.html, and navLinks in
build-site.mjs for every generated page. Change both, or a visitor sees
the links move when they open a docs page.
The preview is a depiction of /get/ built from the same catalogue the
picker itself lists, so it cannot end up advertising a tool that no longer
ships. It shows the first two categories after Core, two packages
each — reorder categories in the CMS and the preview follows. It is
deliberately static markup inside one link: a second copy of the picker's
logic on the landing page would be a second thing to keep true.
Only package is required. Everything else is optional.
---
package: CustomParTools # must equal the filename and a catalog key
summary: One line, used if the catalog description is empty.
features: # drives the "On this page" list
- name: QuickExt
anchor: quickext # must match the generated heading slug
icon: CustomParTools.png # from repo icons/, copied to /docs/assets/
hotkeys:
- {keys: "Ctrl+Alt+Drag", does: "Promote as iop"}
platforms: [windows] # omit when it runs everywhere
video: "https://youtu.be/j43gZ0MB2xo"
feature_list: # what the tool does, for /features/
- name: Create an extension with QuickExt
anchor: quickext # a heading on this page; omit to link the page
does: Adds a Python extension to the parent with simplified default code.
- name: Declare parameters in code
anchor: declare-parameters
does: Helpers such as `Float` and `Menu` create custom parameters from an extension.
authors: true # for people building tools on the toolkit
platform: Windows only # only when the feature is limited to one
---features and feature_list are different lists. features is the page's
sections, derived from its ## headings, and drives "On this page".
feature_list is what the tool can do, written by hand, one sentence each
in does (backticks become code), and drives /features/. Several entries
can point at the same heading.
Author credit is not frontmatter: it lives in catalog.json as
author {name, url} (edited in the CMS package editor), and the build
refuses a doc that still carries a credit block.
Vercel project fnstools, root directory website. Vercel builds
the site on every deploy: vercel.json sets installCommand to npm ci
and buildCommand to npm run build, and serves the result from
outputDirectory: ".". (The file cannot carry comments — Vercel's schema
rejects unknown keys, // included.)
That is the whole pipeline. Push the markdown; the deploy generates the pages. Nothing generated is committed, so nothing can go stale, and there is no CI job policing the gap — there is no gap.
A build failure is the safety net: a broken internal link, a docs file with
no catalog entry, a frontmatter/filename mismatch all fail the build, so
the deploy fails and production keeps serving the last good build. Run
npm run build locally first if you would rather see it before Vercel does.
.vercelignore must not exclude anything the build needs — package.json,
package-lock.json and tools/ all have to reach the builder. It keeps out
node_modules/ (~60 MB of pagefind binaries), the generated docs/ and
get/, and .env.local, which the CLI writes on link with a
VERCEL_OIDC_TOKEN in it.
Set the production branch to whichever branch you deploy from. If it is not set, a push only produces a Preview and production stays where it is until someone promotes a deployment by hand — which is exactly how the live site once sat 30 hours behind the repo.
Also turn on Web Analytics in the project — the page already ships the
stub and the /_vercel/insights/script.js tag the platform serves once it is
enabled. It is cookieless, which is why there is no consent banner.
Download buttons point at the mutable latest/ aliases
(latest/FNSTools.tox, latest/FNS_Installer.tox) that upload.py
publishes beside each release. That is deliberate: a release cannot leave
the buttons serving bytes that are no longer current, so a version bump
needs no site rebuild. latest/ is for humans only — installs still
resolve pinned URLs from the manifest, because reproducible installs are
what make bug reports correlatable (publish.py, "Never publish a mutable
latest/<Package>.tox").
The landing page carries no version number at all — the buttons say "Get FNSTools", not "Get FNSTools v3.0.1". Nothing release-shaped is stamped into it, so there is no number that can go stale and no reason to rebuild the site when a release ships.
/get/ bakes the published manifest (it carries the rails hashes), but
that is display data: the paste script it hands you re-fetches the rolling
manifest at paste time from inside TouchDesigner via requests, and
resolves every artifact and hash from that. A /get/ page built months ago
still installs the current release; only the picker's package list can lag.
The page also refreshes hrefs at runtime, and that fetch now works: the
bucket sends Access-Control-Allow-Origin for functionstore.tools.
The browser upgrades hrefs from latest/ to the pinned release, so
visitors get reproducible URLs, and /get/'s picker list stays current
between deploys. If the fetch ever fails the static latest/ hrefs still
resolve.
Everything is served from the custom domain storage.functionstore.tools, not
the pub-*.r2.dev development endpoint — the latter is rate-limited, not
intended for production, and can be switched off in the bucket settings.
The bootstrap artifact is FNSTools.tox, renamed from
FunctionStore_tools_2025.tox in the v3.0.0 redesign. The filename is a
constant in index.html; do not assume it tracks the brand.
favicon.png and og-image.png are placeholders generated by
tools/make-brand-assets.py. There is no real square FNSTools mark in the
repo. Replace both and delete the script.
packageSlug() here, package_slug() in docs_seed_from_wiki.py, and
_docsSlug() in build_manifest.py all lowercase the name and turn _
into -. If one changes, help_url starts pointing at 404s. The heading
slugifier is duplicated the same way (slugify in this build script and
in the seeder).
Each tool page has one parameter reference. The default is the generated table
from packaging/parameters.json; keep the Markdown for setup, examples and
behavior that needs explanation across several controls.
When a handwritten reference contains descriptions missing from the parameter
export, set parameter_reference: authored in its frontmatter and keep its
## Parameters section. This suppresses the generated table and its duplicate
contents link. Remove that setting and the handwritten list once the exported
parameter help covers it. The build rejects pages that would show both lists.