Skip to content

Latest commit

 

History

History
503 lines (414 loc) · 25.7 KB

README.md

File metadata and controls

503 lines (414 loc) · 25.7 KB

functionstore.tools

The FNSTools site: a hand-written landing page plus generated docs. Static files, no framework, deployed on Vercel with root directory = website.

Where the content actually lives

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.

Guides

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

/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.

Free and Patreon

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.json carries real tier ids on six packages, Gateurl defaults to https://gate.functionstore.tools, and an unauthenticated GET of a plus/ artifact returns 401 (probed 2026-09-08). This note used to say the Worker was undeployed and the catalog carried PLACEHOLDER_TIER; both were true until the 2026-08-29 deploy.

About

/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.

The catalog feed

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.

Build

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.

The CMS

npm run cms       # http://127.0.0.1:8787

An 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: true on the package's catalog.json entry (absent when off, so toggling is a one-line diff) and reaches users as the manifest's starter list at the next release; the list shows a REC badge. 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.mjs and 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 sets FNS_SHOW_DRAFTS=1 and FNS_SHOW_PREVIEWS=1, so the local preview also shows community drafts and, on /features/, preview packages, both marked.
  • feature_list is 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 keeps features[] anchors and the real heading slugs from drifting apart.
  • Hotkeys are one per line, KEYS = what it does.

Categories

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.

Adding or changing a tool's docs

  1. npm run cms, edit, Save. (Or edit packaging/docs/<Name>.md by hand — the CMS is a convenience, not a gate.)
  2. Commit the markdown. That is the whole step — the deploy builds the pages from it.
  3. 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.

Frontmatter

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.

Deploying

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.

Release links

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.

Brand assets

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.

Three slug functions must agree

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).

Parameter references

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.