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, and functions/_middleware.ts turns away cross-origin callers that aren’t listed in ALLOWED_ORIGINS. 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 (edited in place by src/utils/yamlPreserve.ts, so comments and order survive) 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, leaving out (and remembering) any path the other commit touched. 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 (including the translation source panel’s width), editor mode, preview mode, Vim and line-number toggles, whether the source panel is shown and for which language, whether global search and the review board are open
entries Open entries with their frontmatter (parsed and raw), body, dirty flag and original content for conflict detection; pending entries (isNew) that don’t exist in the repository yet; any remote change found by the pre-save check
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, recent entries per site, 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, minus the runtime-only remote-change flags, since every restored entry is re-checked), recent entries, the source panel setting 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.
  • src/search/ is the index behind Ctrl+K search and the review board: useGlobalSearchIndex.ts builds it from the listings Emet already has and fetches the rest on request, and contentIndex.ts keeps the body text it has seen, keyed by blob SHA, for full-text matches.
  • Entry review is src/utils/review.ts (the frontmatter block, state transitions, content hashes and the newest-wins merge) and reviewGate.ts (the publish gate), with ReviewPanel.tsx and ReviewBoard.tsx in src/components/.
  • Conflict handling lives in src/hooks/useEntry.ts, with src/utils/frontmatterMerge.ts and textDiff.ts behind the dialog and RemoteChangeBanner.tsx for the pre-save warning. Every entry open goes through src/hooks/useOpenEntry.ts, which applies the unsaved-changes guard.
  • Translation support is spread out: path rules in src/utils/translation.ts and language.ts, the derived source panel in translationSource.ts, freshness in src/hooks/useTranslationFreshness.ts, and language detection for generated config in src/setup/languages.ts.
  • Page URLs come from src/utils/permalink.ts, which works out a page’s path from the Hugo config the way Hugo 0.168 does, with Go’s string and date helpers ported in hugo-path.ts. HugoSiteProvider in src/hugo-config/ loads the Hugo config once per repo, branch and environment and shares it; components get URLs through usePageUrls. permalink.golden.test.ts checks the resolver against hugo list all output for the sites in src/utils/__fixtures__/hugo-urls/ (regenerate with scripts/regen-hugo-url-fixture.sh), and permalink.realsite.test.ts does the same against a real site when HUGO_SITE is set.
  • src/editor/rawHtmlPolicy.ts and src/hooks/useRawHtmlPolicy.ts decide whether the visual editor may write HTML, reading HugoSiteProvider’s copy of the Hugo config. src/preview/allowed-origins.ts and external-fonts.ts inline allow-listed fonts into the preview. src/utils/hugoConfigFiles.ts holds Hugo’s config filenames and their precedence, shared by everything that looks for a Hugo config.

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.