dev/github-sync/
.slate
pages/
Home.chext
That Is the Whole Interface
Four lines of directory listing. A folder containing a file called .slate is a slate; the .chext files under pages/ are its pages; anything in assets/ next to them is an image. Push, and about a minute later the page you are reading has changed. There is no editor open anywhere, no export step, no publish button.
This Page Is the Example
The file you are looking at lives in a Git repository as pages/Home.chext, and everything the platform knows about this slate beyond its content comes from one small file sitting next to it. Here is that file, in full, unedited.
name: GitHub Sync: Slates as Code
type: ARTICLE
visibility: public
description: A folder with a .slate file in it becomes a published page, and every \
push re-syncs it. How folders are discovered, what change detection skips, why one \
broken folder does not take the rest down with it, and the places where a file is \
dropped without telling you.
Two details in there worth pointing at. The parser splits each line at the first colon only, which is why a title containing a colon survives intact. And a trailing backslash continues a line, which is how a description spans four of them.
Not YAML
It looks like YAML and it is not. It is a hand-written line scanner: split on newline, find the first colon, lowercase the key, trim the value. Seven keys are recognised and everything else is ignored in silence. Description is the one multi-line key, and it keeps consuming lines until it hits another key it knows. A real YAML parser was never worth the dependency for seven keys, and a line scanner fails in ways you can explain to someone.
What Makes a Folder a Slate
The presence of a .slate file. That is the entire test. The repository tree arrives as one flat list of blobs, gets indexed by folder, and any folder with metadata in it is a slate. Nesting depth does not matter. A .slate at the repository root gives you a slate whose folder path is the empty string.
The Two Conventions
A file is a page if its path is pages/name.chext, directly, with no subdirectory. A file is an asset if it sits directly in assets/ and its extension is one of jpg, jpeg, png, gif, webp or svg. Both checks reject nesting outright. Page names come from the filename: getting-started.chext becomes a page titled Getting Started, with dashes and underscores turned into spaces and each word capitalised.
Sync threads, platform wide
1
Sync queue capacity
200
Pause after each new asset
500ms
Log rows kept per repository
200
Slate name truncated at
50 chars
The Single Thread
Every GitHub sync for every user runs on one thread with a queue two hundred deep. That is not an oversight, it is a decision made after a batch push spiked production CPU badly enough to matter. One thread means syncs are slow under load and never concurrent, which is the correct trade when the alternative is taking the site down. The 500 millisecond pause after each newly downloaded image is the same instinct applied to GitHub's rate limits.
What Change Detection Actually Skips
Each folder gets a SHA-256 hash over the paths and blob hashes of its .slate, its pages and its assets, sorted and joined. If the hash matches what was stored, the folder is skipped without a single file being fetched. Below that there is a second layer: a map of page name to blob hash, so only the pages that changed are downloaded. A performance test pins this at a thousand folders: one call to fetch the tree, and zero calls to fetch any file content.
A Push Does Not Even Fetch the Tree
Webhook syncs are narrower still. The push payload lists changed files, those paths are mapped back to their folders, and only those folder subtrees are requested. A push that touches nothing under a slate folder makes no API calls at all. The test for that case asserts no interactions with the GitHub client, the persistence layer or storage.
Failure Is Per Folder
Each folder is synced inside its own try/catch. A folder that throws records an error message against its mapping, writes a FOLDER_ERROR row to the sync log, and returns an ERROR outcome. The loop then moves to the next folder. Four outcomes exist: SKIPPED, CREATED, UPDATED, ERROR.
The Part Worth Being Precise About
CREATED and UPDATED can both carry an error message. A folder with one broken page is still published, with the working pages intact and the failure attached to the record rather than thrown away. When that happens the stored content hash is deliberately set to null, so the folder is not skipped next time and gets another attempt on the following push. Partial success is a real state and it is modelled as one.
Seven Sync Log Event Types
SLATE_CREATED
SLATE_UPDATED
SLATE_RESTORED
SLATE_ARCHIVED
FOLDER_ERROR
SYNC_COMPLETED
SYNC_FAILED
A skipped folder writes no log row at all. If every folder is unchanged, a sync produces one SYNC_COMPLETED line and nothing else, which is the correct amount of noise for a push that changed a README.
The Rough Edges, Named
An SVG in assets/ is indexed, counted in the folder hash, and then silently discarded at download time because the magic-byte check that follows does not accept SVG. No error, no warning, no log line. If your .slate points a thumbnail at it you get a confusing not-found message instead. GitHub flags a recursive tree as truncated past roughly a hundred thousand entries and that flag is never read, so a large enough monorepo quietly loses folders. The contents API returns nothing for a blob over a megabyte, and that comes back as an empty page rather than an error. All three fail quietly, which is the wrong way to fail.
Only Push Events
The webhook handler accepts push and returns two hundred to everything else without looking at it. Signatures are verified with a constant-time HMAC comparison against a per-repository secret, and the branch is matched by exact string equality against the watched branch. No glob, no default-branch resolution. If you watch main and push to a branch, nothing happens, which is usually what you wanted.
"The page and the source of the page are the same file. There is no export step because there is nothing to export from.
"

