Emet is a React and TypeScript single-page app, built with Vite, that treats a Git repository as its database. This page is a tour of how that works: the backends that read and write content, how a running copy decides which one it’s using, where configuration comes from, how state is kept and what the build produces. It’s meant to be read before you go poking around in src/.
Why there’s no backend#
The design decision everything else follows from is that Emet has no application server. The site repository already holds the content, Git already keeps the history, and GitHub already has an API for reading and writing files, opening pull requests and checking CI. Putting a server and a database in front of that would mean a second copy of the content, a sync problem between the two and another thing to host and keep patched. I didn’t want any of those, so the browser talks to GitHub directly and the repository stays the single source of truth.
The one thing a browser can’t do is finish an OAuth login, because that step needs the GitHub App’s client secret. So there’s a single Cloudflare Pages Function, functions/api/github/token.ts, that adds the secret and forwards the request to GitHub. Three more under functions/api/gsc/ do the same for Google Search Console. That directory is the whole server side of the project; everything else is static files. Deployment has the consequences of that for hosting.
The stack#
| Layer | What’s used |
|---|---|
| UI | React 18, TypeScript, Tailwind CSS |
| Visual editor | Tiptap (a ProseMirror wrapper), with custom nodes for shortcodes |
| Markdown editor | CodeMirror 6, with @replit/codemirror-vim for Vim mode |
| Markdown conversion | unified, remark and rehype, plus turndown for HTML to Markdown |
| Frontmatter | yaml and smol-toml |
| State | Zustand, persisted to localStorage |
| Build | Vite; electron-vite for the desktop app |
| Tests | Vitest with jsdom and Testing Library |
| AI | OpenAI, Anthropic, Gemini and any OpenAI-compatible endpoint (custom) |
Content backends#
All content access goes through one interface, ContentBackend in src/content/types.ts: list a directory, read, write, delete and rename a file, check existence, create and delete a directory. There are five implementations in src/content/backends/:
github.tsuses GitHub’s Contents API for individual reads and writes, and the Git Trees API when several files need to land in one commit.local.tstalks to the Vite dev server, which serves a small JSON API under/api/local/*fromvite-plugin-local-cms.tsand reads and writes the Hugo site on disk.electron.tsgoes throughwindow.electronAPI, the IPC bridge the desktop app’s preload script exposes.mock.tsis an in-memory site with a couple of demo posts, for working on the UI with no Hugo site to hand.indexeddb.tsisn’t a backend you’d use directly; it’s the cache thatCachingBackend(src/content/caching-backend.ts) layers over the GitHub backend.
Components never pick one of these themselves. ContentProvider (src/content/ContentProvider.tsx) is a singleton context that works out which backend to use on startup and hands it out through the useContent and useGitHub hooks. The order it tries is: Electron if window.electronAPI exists; otherwise GitHub if this is a production build (or VITE_FORCE_AUTH is set) and there’s an access token; otherwise, in dev, local if /api/local/check says there’s a Hugo site, and mock if it doesn’t. The resolved mode is exposed as dataSource, and a handful of components branch on it, mostly to hide GitHub-only UI like the branch switcher and the publication button.
In GitHub mode, ContentProvider also creates a SyncService (src/content/sync.ts). Most writes commit directly through the Contents API; the sync service’s job is to push whatever gets queued instead (single deletes, mainly) as one Git Trees commit every 30 seconds, poll the branch every 60 seconds for commits made elsewhere and retry once on a conflict. Sync describes the user-facing side of this.
Configuration#
There are three layers, and it helps to keep them apart:
- Build-time config.
vite.config.tsreadsVITE_GITHUB_CLIENT_ID,VITE_GITHUB_APP_SLUG,VITE_GOOGLE_CLIENT_IDandVITE_API_BASE_URLfrom the environment and bakes them into the bundle as__APP_CONFIG__.getAppConfig()insrc/config/app-config.tsreturns that, ornullif there’s no client ID, which is how the app knows it’s an unconfigured build. - Where the site is. After sign-in the repository picker produces a
ConfigContext: repository, branch and an optionalbase_pathfor sites that live in a subdirectory. This is the bit the old per-site bootstrap file used to hold, and moving it to runtime is what lets one deployment edit any repository. - The site’s own config.
admin/site.yaml(sections, fields, shortcodes, media, languages, branches) and the optionaladmin/cms.yaml(editor, AI, SEO, preview).src/config/loader.tsfetches both through whichever backend is active andmergeConfigsinsrc/config/parser.tsfolds them, plus the context, into a singleCmsConfigthat the rest of the app consumes. Defaults are applied insrc/config/defaults.ts.
In GitHub mode a repository with no site.yaml triggers the setup wizard, which scans the site and commits both files. The local dev server does the equivalent on disk through /api/local/generate-config. The scanner behind both is src/setup/scanner.ts; the parts of it that are safe to run in a browser are split out into src/setup/parseTemplate.ts and src/setup/web-scanner.ts.
State#
The Zustand store in src/store/index.ts is assembled from slices in src/store/slices/:
| Slice | Holds |
|---|---|
auth |
Access and refresh tokens, the signed-in user, session expiry |
ui |
Sidebar and panel state, editor mode, preview mode, Vim and line-number toggles, dark mode |
entries |
Open entries with their frontmatter, body, dirty flag and original content for conflict detection |
data |
Open data files and the current data section |
archetypes |
Open archetype templates |
ai |
API keys, provider and model selection, cached model lists, chat history per entry, pending tool confirmations |
repo |
Selected repository, branch and base path, recent repositories, recent desktop sites, Hugo config editor state |
review |
Review threads for the current pull request |
A partialize function picks what gets persisted; the list is in index.ts and includes tokens, UI preferences, open entries (so unsaved work survives a refresh) and AI keys and history. Storage is localStorage, under emet-cms-storage before sign-in and emet-cms-<username> after, with writes debounced by two seconds and flushed on unload. Dark mode is deliberately left out and kept under its own key so a stale persisted value can’t fight the OS setting.
The editor#
src/editor/ holds the Tiptap setup (extensions.ts, WysiwygEditor.tsx) and the CodeMirror setup (codemirror/). Markdown goes to HTML through the unified pipeline in markdown/toHtml.ts on the way into Tiptap, and back to Markdown through turndown in WysiwygEditor.tsx on the way out. Shortcodes are the complicated part: shortcodes/preprocessor.ts turns {{< name >}} blocks into placeholder nodes before Tiptap sees the document, ShortcodeNodeView.tsx renders them as editable cards and serializeShortcode.ts writes them back out. Inline shortcodes get the same treatment with a definition-and-usage model; see Shortcodes.
Frontmatter fields are the components in src/fields/, one per type, each taking a FieldConfig and rendering itself. Field.tsx dispatches on type.
Other modules worth knowing about#
src/github/is the raw GitHub API client (client.ts), the OAuth and PKCE flow (oauth.ts) and pull request operations (pulls.ts).usePublicationinsrc/hooks/builds the publication workflow on top ofpulls.ts.src/preview/renders the CSS-based preview:resolve-preview.tspicks styles per section andrenderer.tsdoes best-effort interpolation of shortcode preview templates.HugoPreview.tsxis the iframe used when a real Hugo server is available.src/ai/has the provider implementations underproviders/, prompt templates, the chat tools undertools/and the panels and menus undercomponents/.src/seo/is the rule engine;src/gsc/is Google Search Console;src/hugo-config/is the Hugo config editor.src/main/,src/preload/andsrc/shared/are the desktop app, covered in Desktop app.
The build#
npm run build runs tsc and then vite build. tsconfig.json excludes tests and the Electron main and preload code, so npm run typecheck doesn’t cover src/main/ or src/preload/; electron-vite bundles those without type-checking them, which is worth knowing if you change the IPC types. The output in dist/ uses relative asset paths (base: "./"), so it can be served from a subdirectory, and gets a Content Security Policy injected into index.html by vite-plugin-csp.ts; the dev server skips the CSP because hot reloading needs inline scripts, unless you start it with EMET_DEV_CSP=1, which injects the production policy plus the inline-script and websocket relaxations hot reloading needs.
The bundle is split into named chunks in vite.config.ts so the heavy editor code doesn’t load until it’s needed:
| Chunk | Contents |
|---|---|
vendor-ui |
lucide-react icons, date-fns |
vendor-parsers |
yaml, smol-toml, turndown |
editor |
all of @tiptap/* |
codemirror |
all of @codemirror/* |
codemirror-vim |
@replit/codemirror-vim |
markdown |
unified, remark, rehype, KaTeX |
Setting DEPLOY_TARGET=cloudflare-pages adds a _routes.json to the output so Cloudflare runs Functions only for /api/*. The desktop renderer is built separately by electron.vite.config.ts with a simpler chunk map and no CSP.