Sign inSign up

iegork/quillet

By iegork

•Updated 1 day ago

A headless CMS with versioned, localised content and a cached delivery API.

Image
0

492

iegork/quillet repository overview

⁠Quillet

Quillet is a headless CMS: content is authored per locale, versioned on every save, and published explicitly, so what a site serves changes only when someone says so. It has two HTTP surfaces — a management API under /api/v1 for authoring, keys and publishing, and a cached public read API under /d/v1 that serves published content with locale fallback and ETag-based conditional requests.

This repository is the API, its OpenAPI contract and the dashboard (apps/dashboard). The browser SDK and the CLI are separate.

⁠Prerequisites

  • Node 26. The version is pinned in .nvmrc, so nvm use picks it up.
  • Docker, for Mongo, Redis and MinIO.

⁠Getting started

nvm use                      # Node 26
docker compose up -d         # Mongo (a single-node replica set), Redis and MinIO
npm install
cp .env.example .env         # then put a real SESSION_SECRET in it

SESSION_SECRET has to be at least 32 characters; openssl rand -base64 48 produces something suitable. It signs the session cookie, so rotating it invalidates every session at once. npm run dev reads .env for you; the API itself reads only the process environment, so a deployment passes real variables in and needs no file:

npm run dev

The API is then on http://localhost:3000:

PathWhat it is
/The dashboard, when apps/dashboard has been built
/api/v1/*Management API: auth, projects, keys, content, publish
/api/v1/aboutWhich build is running: version and, if known, commit
/d/v1/:project/:envDelivery API: published content, cached
/healthzLiveness. 200 while the process runs, drain included
/readyzReadiness. 503 while draining, or a dependency is down
/api/docsSwagger UI, in development only

Other scripts, all from the repository root:

npm test          # every suite (needs `docker compose up -d`)
npm run lint      # eslint per package, then a repo-wide prettier check
npm run build     # compile every workspace
npm run migrate   # build every index, idempotently (see Deployment notes)
npm run openapi   # regenerate openapi.json from the running route decorators
npm run format    # apply prettier

npm run e2e -w apps/dashboard   # the browser suite (see Browser verification)

npm test boots an in-memory Mongo replica set per Jest worker but uses the real Redis from docker-compose.yml, so the containers have to be up.

The asset store's contract suite is the one exception to "the containers have to be up": it runs the disk adapter always and the s3 adapter only when TEST_S3_ENDPOINT names a bucket, logging one line and skipping that half otherwise, so a checkout without Docker still passes. Prove the S3 adapter with:

TEST_S3_ENDPOINT=http://localhost:9002 npm test -w apps/api -- store.contract

It writes under a random contract-… prefix of the compose bucket and deletes it afterwards. Point it at a real bucket and it would do the same there, so do not.

⁠A note on published ports: Redis 6381, MinIO 9002

docker-compose.yml publishes Redis on 6381, not 6379, and MinIO on 9002 (console 9003), not 9000: the machines this was built on already had unrelated containers on 6379, 6380 and 9000. REDIS_URL and ASSET_S3_ENDPOINT in .env.example match. Tests take the same defaults and can be pointed elsewhere with TEST_REDIS_URL and TEST_S3_ENDPOINT.

Two suite runs can share one Redis. Everything Quillet writes there — sessions, rate-limit counters, the delivery cache, the transform locks — lives under REDIS_KEY_PREFIX, and the test harness gives each run a namespace of its own (q:t<random>w<worker>:) rather than a database. Cleanup between suites deletes that namespace and nothing else, so a second run started beside the first cannot touch it, whatever database either was pointed at.

This used to be the other way round, and the advice here used to be wrong in a way worth knowing about if you are reading an older branch: the harness replaced the database in TEST_REDIS_URL with the Jest worker number and then flushed it, so "give the other run a different database number" did nothing at all — worker 1 of both runs landed on database 1 regardless. It never failed as a visible collision; it failed as spurious 401s and 429s in suites with nothing to do with either run, because a session had been flushed out from under a request or a counter reset by the other one.

TEST_REDIS_URL is still honoured in full, database included, if you would rather keep runs on separate databases or separate servers anyway.

MinIO's images come from quay.io/minio/* rather than Docker Hub, which no longer serves minio/minio anonymously. The credentials are fixed at quillet / quillet-local and are not secrets: the contract suite has to run from a fresh checkout. A one-shot minio-init sidecar creates the quillet-test bucket, the way mongo-init initialises the replica set.

⁠Testing the prototype

A walk through the dashboard in a browser, about half an hour from a fresh checkout. Every address, button and sentence below is the one on screen; each step is one action, followed by what you should see. Two curl steps use the key a step above prints once.

  1. Start the stack. docker compose up -d, then npm run dev — the API answers on http://localhost:3000⁠ (curl http://localhost:3000/readyz prints {"status":"ok","mongo":"up","redis":"up"}) and the dashboard on http://localhost:5173⁠.
  2. Open http://localhost:5173⁠ — you are sent to Sign in (/login).
  3. Press "Create one" under the form — Create account. Fill Name, Email and a Password of at least 12 characters, press Create account — back on Sign in with the notice "Account created — sign in to continue."
  4. Sign in — Projects, "No projects yet", with a Create a project button.
  5. Create the project: Name Handbook, Slug handbook. Locales is a search over the catalogue rather than a text field: type en-US and press Enter — the chip English (United States) en-US appears and Default locale follows the first pick — then type de-DE and press Enter for German (Germany) (typing aims at the first match, and German alone offers six countries). Environments below it already holds one chip, Prod — type staging into the box beside it and press Add for a second, and leave Default environment on Prod (prod). Press Create project — /p/handbook/content. Across the top is the project header: the mark (a link to All projects), the project switcher reading Handbook, the Environment select on Prod with Staging in its list, Search content (Ctrl/Cmd+K), the theme toggle and a round button with your initials, the Account menu, which holds your name and email, Account, Sign out and, at the bottom, which build of Quillet is running (Quillet 1.1.1 · 0b5b73f: the version and the commit, when the image recorded one). Under it are the section tabs Content, Media, Components, Promote and Settings; the left panel holds the Content tree, and the page is Content, with Start here and a New menu. Below 1024 px the panel folds behind a Show Content button at the start of the tabs and opens as a sheet; below 640 px Search content moves into the account menu, and the project's and environment's names truncate to share the row.
  6. New → Document: Key landing/hero, Type Structured, press Create document — the editor opens at /p/handbook/content/landing/hero on {}; the tree opens the landing folder (an Up one level link over its one row, hero), and the page bar across the top of the editor holds, left to right, the breadcrumb "landing / hero", a Draft pill whose tooltip reads "Version 1", the locale switcher on en-US, then History, Preview, Save and Publish. Below 1280 px History and Preview show only their icons, with their names as tooltips.
  7. Replace the text with {"title": "Hello", "cta": "Go"} and press Save (or Ctrl/Cmd+S) — "Saved." above the editor, and the pill's tooltip reads "Version 2" (version 1 is the empty document the dialog created).
  8. Press Publish, then Publish in Publish this document? — the pill reads Published, and a ⋮ button before Publish (Publishing actions) holds Unpublish.
  9. Press Pretty-print, then delete the colon after "title" — a red sentence above the editor, "Line 2, column 11: a colon was expected after the property name.", a marker under the spot in the text, and Save unavailable. Put the colon back and press Save — "Saved.", and the tooltip still reads "Version 2": reformatting is not a change to the content, so no version is added.
  10. A Markdown document: press Up one level, then New → Document: Key guides/intro, Type Markdown, Create document — a Visual editor with a formatting toolbar, and a Source tab beside it. Type # Introduction and press Enter: the # becomes a heading as you type. Press Image to put a picture in from the media library or to upload one into it. Switch to Source to see the Markdown that will be stored — a Preview pane sits beside it (two tabs of the same names on a window 1024 px wide or narrower), and Expand gives either the whole width. Save.
  11. A plain-text document: Up one level, New → Document: Key notes/plain, Type Plain text — a monospaced text area and nothing else; type a line and Save.
  12. Open landing/hero again and press the locale switcher in the page bar (it reads en-US), then choose de-DE (not translated) — "This document has no de-DE translation yet." over the en-US document, read-only.
  13. Press "Start the de-DE translation" — the editor opens on {} and the panel 2 fields inherited from en-US lists title and cta, each with an Inherited from en-US badge.
  14. Press title in that panel — it leaves the list (1 field inherited from en-US) and "title": "Hello" is in the text; change it to "Hallo" and Save — "Saved."
  15. Open guides/intro in de-DE the same way — the same offer, this time with an Inherited from en-US badge over the read-only body: a Markdown or text document falls back as a whole.
  16. Press the Components tab — "No components yet", "A component is a reusable shape of content — a hero, a call to action, a page made of sections. Build one with a form and use it anywhere.", and a New menu. Choose Component: the builder opens at /p/handbook/components/new on an empty field list under the component's own settings, with the Name box enabled and focused. Type cta and Save — nothing was written until then, and the address is now /p/handbook/components/cta, at version 1.
  17. Build the CTA: Title CTA. Add field — Name label, Type Text, tick Required. Add field — Name link, Type Link, tick Required. Preview field label. Save — "Saved.", and the page bar's pill has "Version 2" in its tooltip (version 1 is the empty component the first Save wrote). Publish — Published.
  18. Build the hero: Components → New → Component, Name hero, Save — version 1 at /p/handbook/components/hero. Title Hero. Add field title (Text, Required), cta (Text), blurb (Rich text), image (Image, Accept image/* then Add type), sku (Text, untick Translatable). Preview field title. Save, Publish — Published, at version 2.
  19. Press Versions in the builder's page bar — Versions of Hero over _schemas/hero (en-US), a From / To pair already reading "From v1 to v2": Settings: title "" → "Hero" · preview none → title, Added title (Text), Added cta (Text), Added blurb (Rich text), Added image (Image), Added sku (Text), with Show JSON under them. Each row has the same disclosure — What changed in v2 — and Restore as new draft is at the foot. Close it.
  20. Bind from the Details panel: open landing/hero (en-US). Under Details the Schema select reads None and lists cta and hero; choose hero and Save — the editor gains a Form / JSON toggle and opens on Form, with a title box, a cta box, a rich text area for blurb, an image field reading Choose an image, and a sku box. The version does not move: a binding is not a change to the content.
  21. Fill the shared field: type HB-001 in sku and Save — the pill reads Changed since published, its tooltip "Version 3 · version 2 is published". Publish — Published, at version 3.
  22. Bind from the folder: press landing in the breadcrumb, then New → Document — the Key box is pre-filled with landing/; make it landing/_index, Create document — the editor says "Folder settings. Set defaultSchema to a schema name to validate every JSON document in this folder."; type {"title": "Landing", "defaultSchema": "hero"} and Save — at the root of the tree the folder is now called Landing. Back in landing/hero, the Schema select's first option reads Inherit from folder; choose it and Save — it reads Inherited from folder: hero and the form is still there.
  23. Press Components — the hero row reads 5 fields, 2 instances and Published. Press the count — the builder opens on its Instances tab, "Every document this component is in force for, and whether it still matches.", with a Draft / Published toggle and both rows of landing/hero — en-US labelled Hello by its preview field, de-DE labelled by its key because its body was written before the component existed — each Valid.
  24. Problems in the JSON view: back in landing/hero, press JSON and delete the "title" line — 1 problem under the editor: title "Missing title.", with a marker in the text; press the row and the cursor goes to it. Press Save anyway — the API refuses it and the same sentence stays in the list, with no banner.
  25. Problems in the form view: put the line back and press Form (while the text is not JSON the tab is unavailable and says "Fix the JSON first."); clear the title box and Save — "Missing title." under the box. Type the title back.
  26. Build the page: Components → New → Component, Name page, Save. Title Page. Add field title (Text, Required); Add field hero, Type Reference, Target Hero (the select also offers Any component, and page itself — a reference is a pointer, so it may point at its own kind); Add field sections, Type Block list, Allowed components Hero then CTA, Maximum items 20. Preview field title. Save, Publish — the library lists three components and the page row reads 3 fields, Not used yet and Published.
  27. A page to put them in: the Content tab → New → Document pages/_index (Structured), type {"defaultSchema": "page"}, Save. Up one level, New → Document pages/home — it opens on the Form rather than on {}, because the dialog seeded it from the folder's component: the JSON tab reads {"title": ""}. Type Home in title, press the hero picker and choose landing/hero.
  28. Add two blocks: press Add block — a menu of Hero and CTA. Choose Hero: the block draws title, cta, blurb, image and sku. Type Everything is 20% off and Shop now, write a line in blurb, press Choose an image → Upload, pick a PNG and press Choose, and type SPR-2026 in sku. Press Add block → CTA: label Questions?, then Link to A document in this project and choose landing/hero (the picker lists json documents only — a link's ref has to name one the API can check). Save, Publish — the JSON tab shows each block carrying _component, the image as {"$asset": "…"} and the link as {"ref": "landing/hero"}.
  29. A shared field is not translated: open landing/hero and switch the locale to de-DE — the form draws title, cta, blurb and image, and where sku would be it says Shared across locales. The panel beside it reads 1 field inherited from en-US and lists cta alone: sku is not untranslated, it is shared. Publish — Published, at version 1.
  30. Switch back to en-US — the Used by panel lists pages/home with hero and sections.1.link under it: the slot the page holds it in, and the block whose link points at it. Hover the hero row in the tree and press its ⋮ (Actions for hero) — Rename (F2) and Delete (Del) are unavailable, each carrying the reason "Used by 1 document"; a rename is a copy and a delete, and the API refuses both with a 409 rather than leave a page pointing at nothing.
  31. A delivery key: Settings → Keys → New key: Name Website, Kind Delivery, Environment Prod (prod), press Create key — Your new key shows the Secret once, with Website · qk_… under it. Copy both halves, then press I have copied the secret — the key is in the table with a Revoke button and no secret.
  32. Read it through delivery: curl -s -u 'qk_…:qs_…' http://localhost:3000/d/v1/handbook/prod/content/pages/home — content.hero is the hero's body inlined, {"title":"Hello","cta":"Go","sku":"HB-001"}, and _meta.refs[0].key is landing/hero; each section carries _component, the image is inlined with its url and variants and is listed in _meta.assets as sections.0.image, and the CTA block's link.ref is still the plain key landing/hero — a link wants the target's address, so delivery leaves it alone and keeps it out of _meta.refs. Add ?locale=de-DE — content.hero.title is Hallo and _meta.refs[0].resolvedLocale is de-DE, while the page itself reports "fallbackLocale":"en-US" and "fallbacks":["*"]: it has no de-DE version of its own.
  33. The shared field, and the delete that is refused: curl -s -u 'qk_…:qs_…' 'http://localhost:3000/d/v1/handbook/prod/content/landing/hero?locale=de-DE' — the body is {"title":"Hallo","cta":"Go","sku":"HB-001"} and _meta.fallbacks is ["cta"]: sku came from en-US and is not listed, because it is not untranslated, it is shared. Then press Components and open the hero row's ⋮ — Delete is unavailable, reading "Used by 2 instances".
  34. A conflicting edit: open landing/hero in two tabs, both on JSON. In the first, change cta and Save. In the second, change title and Save — a red banner, "This document moved on — Someone else saved this document — reload to see their version, then reapply your change.", with Reload inside it.
  35. Press Reload — Discard your changes? names landing/hero; press Discard and reload — the first tab's text, and a notice "Your previous edits were discarded." with Restore them beside it; press that — your text is back, unsaved, and Save now lands.
  36. Press History — a sheet lists v5 down to v1, Published on v3, each with who wrote it ("you") and the note it was saved with, if any. Press View on v1 — "Viewing v1 — the current draft is v5" with Back to draft and Restore as new draft; press Restore — refused, "Could not restore this version — title: Missing title.": v1 is the empty document the dialog created, and the folder's schema now requires a title.
  37. Open History again and press View on v2 — "Viewing v2 — the current draft is v5"; press Restore as new draft — back on the draft at v6, whose History entry reads "Restored v2".
  38. Keyboard: on any project page press Tab once — Skip to content appears in the top-left corner; Enter puts focus in the content area. Reload and Tab on instead, past the header and the section tabs into the left panel, until a row of the tree has focus (the folders come first, _schemas among them): ↓ and ↑ move between rows, → opens a folder (focus lands on its first row), ← goes up a level, Enter opens a document (its row stays focused and is marked as the current page), F2 opens Rename this document, Delete opens Delete this document?, and Shift+F10 opens the row's menu — Rename (F2) and Delete (Del); Escape closes each and puts focus back on the row.
  39. A locale the project does not have: change the address to …/content/landing/hero?locale=fr-FR — "This project has no fr-FR locale." with an Open the en-US version link, and nothing fetched behind it.
  40. Roles: register a second account in a private window. As the owner: Settings → Members → Add member, that email, Role viewer, Add member — the person is listed with a viewer badge. Signed in as them: Settings says "Only an owner can change this", there is no Keys in the Settings panel, Members says "You can't see the member list", and editing a document then pressing Save answers with a banner above the editor, "Could not save this document — This needs the editor role."
  41. Locales from the list: as the owner again, Settings → Project. The Locales box holds the two chips the project was created with; type fr- — the list narrows to the sixteen French regions (the bare fr also matches a few names that contain those letters, such as "Suid-Afrika" and "Frysk", because the search reads English name, native name and tag) — choose French (France) and press Save changes — "Saved." above the form, and GET /api/v1/projects/handbook now lists fr-FR. Then press the ✕ on the German (Germany) chip and Save changes again — refused under the box, "Remove the 1 document in de-DE first. Among them: landing/hero." (the translation of step 14), with no banner and nothing changed; choose German (Germany) from the list again to put the chip back (no save needed: the refused save changed nothing, so the form is back to what the API holds).
  42. Delete an environment: Settings → Environments → New environment: Key staging, Name Staging, Create environment — the row is in the list and Staging is in the header's Environment select. Switch the select to Staging, mint a delivery key on it (Settings → Keys → New key, Environment Staging (staging)), then Settings → Environments and press Delete on the Staging row — Delete staging? names Staging · staging and counts what goes, "Its 0 documents and 1 key go with it. This cannot be undone.", with Delete environment held until it can say so. Press it — the row is gone, the address is back on ?env=prod, the select reads Prod, and in Keys the staging key is Revoked, its Environment column reading Environment deleted — it reached one environment that no longer exists, not every one of them.

⁠The first project, end to end

Everything below is a real transcript against a fresh npm run dev. Each request is one curl; the only state carried between them is the session cookie in cookies.txt and the key secrets the API prints once.

1. Register, and sign in. Registration creates the user; login sets the session cookie the management API authenticates with.

curl -s -X POST http://localhost:3000/api/v1/auth/register \
  -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","password":"correct horse battery staple","name":"Ada Lovelace"}'
# {"id":"6aa1ffdd3469a49719765199","email":"[email protected]","name":"Ada Lovelace"}

curl -s -X POST http://localhost:3000/api/v1/auth/login -c cookies.txt \
  -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","password":"correct horse battery staple"}'
# {"id":"6aa1ffdd3469a49719765199","email":"[email protected]","name":"Ada Lovelace"}

2. Create a project. The caller becomes its owner, and its environments are created with it in the same transaction.

curl -s -X POST http://localhost:3000/api/v1/projects -b cookies.txt \
  -H 'Content-Type: application/json' \
  -d '{"slug":"handbook","name":"Handbook","defaultLocale":"en-US","locales":["en-US","de-DE"]}'
# {"id":"...","slug":"handbook","name":"Handbook","default

Tag summary

Content type

Image

Digest

sha256:5bcfd1aa3…

Size

88.6 MB

Last updated

1 day ago

docker pull iegork/quillet