Tiparo Architecture¶
Tiparo has two separate concerns:
- The app UI, which is served as a static Vite build.
- The font content pipeline, which converts local font metadata and preview assets into a generated TypeScript manifest consumed by the app.
At a high level, the flow is:
- You define a font in
content/fonts/<font-id>/family.toml. - You keep the full original upstream source tree in
content/upstreams/<font-id>/. - You sync only the preview font files into
content/fonts/<font-id>/. - A generator script reads every
family.tomlfile and emits a typedfontLibrarymanifest for the app. - The React app imports that manifest and renders the library and detail views.
- Local preview fonts are loaded at runtime by injecting
@font-facerules intodocument.head. - Full upstream archives are packaged and uploaded separately to R2, and the app links to those external archive URLs for download.
Directory Layout¶
The important directories are:
.
├── content
│ ├── archive-config.json
│ ├── fonts
│ │ └── <font-id>
│ │ ├── family.toml
│ │ └── ...preview font files
│ └── upstreams
│ └── <font-id>
│ └── ...full upstream tree, ignored by git
├── public
│ ├── CNAME
│ └── tiparo.svg
├── scripts
│ ├── generate-font-manifest.mjs
│ ├── publish-font-archive.mjs
│ ├── set-archive-version.mjs
│ ├── sync-font-assets.mjs
│ └── lib/font-spec.mjs
└── src
├── App.tsx
├── components
├── data
│ ├── font-library.ts
│ ├── fonts.ts
│ └── generated-font-library.ts
└── lib
└── font-loader.ts
Build And Runtime Model¶
Development and production commands¶
The main scripts are defined in
package.json:
bun run devRunscontent:syncfirst, then starts the Vite dev server.bun run buildRunscontent:sync, TypeScript build checks, and thenvite build.bun run previewRunscontent:sync, then serves the built app withvite preview.bun run content:syncRunsscripts/generate-font-manifest.mjs.bun run fonts:sync -- <font-id>Copies preview assets from ignored full upstreams into tracked preview directories.bun run fonts:publish -- <font-id> --bucket <bucket>Packages the full upstream, uploads it to R2, updatesarchive_version, and regenerates the manifest.bun run archives:set-version -- <font-id> <short-hash>Updatessource.archive_versionmanually in the targetfamily.toml.
Vite base path behavior¶
vite.config.ts
calculates the app base path with resolveBasePath():
- if
VITE_BASE_PATHis set, that value wins - otherwise, local development uses
/ - otherwise, GitHub Actions falls back to a project-site path derived from
GITHUB_REPOSITORY
The GitHub Pages workflow currently forces VITE_BASE_PATH="/", because the
intended public hostname is a custom domain, not /tiparo/.
App Structure¶
Entry points¶
src/main.tsxmounts the React app and imports the global CSS.src/App.tsxis the main application shell.
Routing model¶
Tiparo uses hash routing, not React Router.
The route parsing logic lives directly in
src/App.tsx:
#/means the library view#/fonts/<font-id>means the detail view for one font
App stores the current route in local component state and updates it on
hashchange. This keeps the deployment simple on GitHub Pages, because the
server only has to serve one index.html.
Main screens¶
The app has two top-level states:
- Library view Rendered directly by
src/App.tsxusing either: src/components/font-list-row.tsx-
Font detail view Rendered by
src/components/font-detail-page.tsx
The library view:
- reads
fontLibrary - filters fonts by search query
- toggles between list and grid display
- shares a single specimen text state across all cards/rows
- eagerly loads preview faces for visible fonts
The detail view:
- calls
ensureFontLoaded(font)on mount - shows metadata and external links
- renders large, medium, and small specimen editors
- renders a styles section showing every face in the font family
- exposes the external archive URL as the Download button target
Editable specimen fields¶
src/components/editable-specimen-text.tsx
is the reusable textarea-based specimen control.
It supports two modes:
- multi-line preview fields
- single-line scrolling preview fields with a fading edge mask
This component is used in the library rows/cards and on the detail page. It resizes itself for multiline content and recalculates the fade mask for single-line content.
Data Model¶
Generated runtime data¶
The app imports fontLibrary via
src/data/font-library.ts,
which simply re-exports the generated file.
The generated file:
- imports the preview asset files as Vite asset URLs
- builds a typed
FontEntry[] - includes metadata, display settings, preview faces, and the resolved external archive URL
The runtime type is defined in
src/data/fonts.ts.
The important FontEntry fields are:
idStable internal identifier, usually the folder name.familyDisplay name of the family.designersStructured designer records with optional URLs.sourceIn current practice this istype: "local", with afacesarray describing the preview files.linksPublic URLs for source repo, official site, and download archive.display.styleOptional preferred preview style id/name.upstreamSource repository, download URL, and upstream commit metadata.
family.toml¶
Each font is declared by content/fonts/<font-id>/family.toml.
Current sections:
[details]Human-facing metadata such as family name, designers, foundry, license, and website.[source]Packaging and provenance information.[display]The preferred face for library previews.[style.<id>]One entry per previewable face.
Example shape:
[details]
name = "Inter"
designer = "Rasmus Andersson"
[source]
archive = "tiparo-inter.tar.gz"
archive_version = "abc1234"
[display]
style = "Regular400"
[style.Regular400]
name = "Regular 400"
file = "inter/web/Inter-Regular.woff2"
Important fields in [source]:
archiveUsually a relative tarball filename such astiparo-inter.tar.gz.archive_versionA short hash used as the URL path segment for cache-busting and versioning.repositoryOptional upstream repository URL.commitOptional upstream commit hash or source revision.
Important fields in [style.<id>]:
nameHuman-readable name.fileRelative path to the preview font file inside the full upstream tree.indexOptional font collection index for TTC files.
TOML parser¶
The app itself does not read TOML in the browser. TOML is parsed ahead of time
by build scripts, and the result is written to
src/data/generated-font-library.ts.
Tiparo uses a small custom TOML subset parser in
scripts/lib/font-spec.mjs.
It supports the patterns used by the repository:
- string values
- numeric values
- booleans
- arrays
- nested tables using
[section]and[section.subsection]
This parser is intentionally narrow. It is not a full TOML implementation. If you add more advanced TOML syntax, the scripts may fail.
How Preview Fonts Are Served¶
Tracked preview assets¶
The preview assets live directly in content/fonts/<font-id>/.
These are committed to git and bundled into the Vite build. The app does not fetch preview fonts from R2. They are part of the site itself.
Generated imports¶
During content:sync,
scripts/generate-font-manifest.mjs
does this for every style.*.file entry:
- Resolves the file path relative to
content/fonts/<font-id>/ - Verifies that the file exists
- Adds an import like
...?urlintogenerated-font-library.ts - Emits a face entry with:
pathweightstyleformatcollectionIndex
The end result is that the browser receives Vite-managed URLs to preview font files.
Runtime font loading¶
The runtime font-loading logic is in
src/lib/font-loader.ts.
It has two loading modes:
previewLoads only the default display face used for library previews.fullLoads every local face for the selected family.
The loader keeps a loadedFontModes map keyed by font id. For local fonts it
injects a <style data-tiparo-font="..."> element into document.head.
For each local face, it creates @font-face rules with synthetic family names
like:
The library view uses ensureFontPreviewsLoaded() so only preview faces are
registered initially. The detail page upgrades a font to full mode by calling
ensureFontLoaded(font).
This is the main performance optimization in the current runtime model: the app ships all preview assets in the bundle, but it avoids registering all faces until they are needed.
How Download Archives Are Served¶
Tiparo treats downloadable archives separately from preview assets.
Preview assets:
- live in the repo
- are bundled by Vite
- are used only for in-browser previews
Download archives:
- are built from the full upstream tree in
content/upstreams/<font-id>/ - are uploaded to R2
- are linked from the UI as external URLs
The shared external base URL is configured in
content/archive-config.json:
If source.archive is relative and source.archive_version is set, the
generator resolves the final download URL as:
For example:
If source.archive is already an absolute URL, the generator uses it directly.
Adding A New Font¶
This is the intended workflow for a new family.
1. Add the full upstream locally¶
Create:
This directory is ignored by git. It is the source of truth for packaging and preview extraction.
Include the full upstream tree here:
- source files
- binaries
- docs
- licenses
- any other files you may want in the downloadable archive
2. Create the font definition¶
Create:
At minimum, define:
[details][source]- at least one
[style.<id>]entry
Recommended:
- use
woff2files for preview entries - set
[display].styleso the library uses the intended face - set
source.repositoryandsource.commitfor provenance
3. Sync tracked preview assets¶
Run:
scripts/sync-font-assets.mjs
will:
- Read
family.toml - Collect every
style.*.file - Remove the existing tracked files under
content/fonts/<font-id>/, exceptfamily.toml - Copy only the required preview font files from
content/upstreams/<font-id>/into the tracked font directory
This keeps the git-tracked content light while preserving the full upstream locally.
4. Regenerate the runtime manifest¶
Run:
This updates
src/data/generated-font-library.ts,
which is what the app actually imports.
If you run bun run dev or bun run build, this happens automatically.
5. Verify locally¶
Run:
Then check:
- the font appears in the library
- search works
- the intended default preview face is used
- the detail page loads all styles correctly
- the metadata fields display as expected
Publishing A Font Archive¶
To publish one family’s full upstream archive to R2, run:
For real uploads, Wrangler must already be authenticated locally or have
CLOUDFLARE_API_TOKEN set in the environment.
scripts/publish-font-archive.mjs
does the following:
- Reads
family.toml - Verifies that
source.archiveis present and relative - Runs
fonts:syncfor that font first - Creates a temporary
.tar.gzfromcontent/upstreams/<font-id>/ - Computes a short SHA-256 hash
- Uploads the archive to R2 via Wrangler
- Writes the hash back into
source.archive_version - Regenerates
generated-font-library.ts
The uploaded object key is derived from the configured base path plus the short hash:
The script also sets useful object metadata:
Content-Type: application/gzipContent-Disposition: attachment; filename="..."Cache-Control: public, max-age=31536000, immutable
Dry runs¶
You can inspect the result without uploading:
This prints:
- temporary archive path
- computed short hash
- R2 object key
- final public URL
Manual version updates¶
If you need to set the archive version yourself, use:
scripts/set-archive-version.mjs
updates only the archive_version line in the target family.toml.
Deployment¶
App hosting¶
The app and docs are deployed together via GitHub Pages using the workflow at
.github/workflows/deploy-pages.yml.
The workflow:
- checks out the repo
- installs dependencies with Bun
- syncs the docs environment with
uv - runs
bun run pages:build - uploads
dist/ - deploys to GitHub Pages
pages:build builds the app into dist/, then builds the docs directly into
dist/docs/.
The custom domain is declared in
public/CNAME:
Download hosting¶
Download archives are not served from GitHub Pages. They are served from Cloudflare R2 behind:
That separation is intentional:
Public URLs¶
- app:
https://tiparo.leightonpayne.dev/ - docs:
https://tiparo.leightonpayne.dev/docs/ -
downloads:
https://downloads.leightonpayne.dev/tiparo -
GitHub Pages serves the UI
- R2 serves large downloadable archives
Failure Modes And Gotchas¶
Missing preview asset¶
If a style.*.file path in family.toml does not exist under the tracked font
directory, generate-font-manifest.mjs fails.
Usual fix:
Missing full upstream¶
If content/upstreams/<font-id>/ is missing, fonts:sync and fonts:publish
fail.
Missing archive_version¶
If source.archive is relative and archive_version is missing, the manifest
generator fails, because it cannot build the public download URL.
Custom TOML limits¶
The parser only supports the TOML subset used here. Avoid advanced TOML features unless you also extend the parser.
Generated file is source of truth for the app¶
The browser never reads family.toml directly. If you change family.toml and
do not regenerate the manifest, the app will still reflect the old generated
data.
Practical Maintenance Rules¶
If you want to keep the repo healthy, these are the rules that matter most:
- Keep full upstreams in
content/upstreams/<font-id>/, not in tracked app content. - Keep only preview font files under
content/fonts/<font-id>/. - Use
woff2for preview faces unless there is a strong reason not to. - Regenerate the manifest whenever
family.tomlor tracked preview assets change. - Publish downloadable archives to R2 instead of committing tarballs to the repo.
- Treat
generated-font-library.tsas generated output, not handwritten code.
Short Operational Checklist¶
For day-to-day work:
- Edit
family.toml - Put the full upstream in
content/upstreams/<font-id>/ - Run
bun run fonts:sync -- <font-id> - Run
bun run dev - When ready to publish downloads, run
bun run fonts:publish -- <font-id> --bucket <bucket> - Commit the tracked preview assets, TOML, generated manifest, and app changes