Architecture

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.ts uses GitHub’s Contents API for individual reads and writes, and the Git Trees API when several files need to land in one commit.
  • local.ts talks to the Vite dev server, which serves a small JSON API under /api/local/* from vite-plugin-local-cms.ts and reads and writes the Hugo site on disk.
  • electron.ts goes through window.electronAPI, the IPC bridge the desktop app’s preload script exposes.
  • mock.ts is an in-memory site with a couple of demo posts, for working on the UI with no Hugo site to hand.
  • indexeddb.ts isn’t a backend you’d use directly; it’s the cache that CachingBackend (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:

  1. Build-time config. vite.config.ts reads VITE_GITHUB_CLIENT_ID, VITE_GITHUB_APP_SLUG, VITE_GOOGLE_CLIENT_ID and VITE_API_BASE_URL from the environment and bakes them into the bundle as __APP_CONFIG__. getAppConfig() in src/config/app-config.ts returns that, or null if there’s no client ID, which is how the app knows it’s an unconfigured build.
  2. Where the site is. After sign-in the repository picker produces a ConfigContext: repository, branch and an optional base_path for 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.
  3. The site’s own config. admin/site.yaml (sections, fields, shortcodes, media, languages, branches) and the optional admin/cms.yaml (editor, AI, SEO, preview). src/config/loader.ts fetches both through whichever backend is active and mergeConfigs in src/config/parser.ts folds them, plus the context, into a single CmsConfig that the rest of the app consumes. Defaults are applied in src/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). usePublication in src/hooks/ builds the publication workflow on top of pulls.ts.
  • src/preview/ renders the CSS-based preview: resolve-preview.ts picks styles per section and renderer.ts does best-effort interpolation of shortcode preview templates. HugoPreview.tsx is the iframe used when a real Hugo server is available.
  • src/ai/ has the provider implementations under providers/, prompt templates, the chat tools under tools/ and the panels and menus under components/.
  • src/seo/ is the rule engine; src/gsc/ is Google Search Console; src/hugo-config/ is the Hugo config editor.
  • src/main/, src/preload/ and src/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.