apps/admin/src/editor/ is the React post and page editor: the screen, the
Koenig surface it wraps, the settings sidebar beside it, and the publish and
preview flows it opens, plus the restore screen that turns a local copy of a
lost draft back into a post. api.ts is the domain's public surface — the shell
mounts both screens lazily through it and everything else here is internal.
Once a signed-in admin is idle, the shell also fetches the editor screen and
Koenig through api.ts, so opening the first post doesn't wait on either
download.
The editor opens once it has the site's settings, config and site record and the current user, which Koenig's cards are configured from. If one of them fails to load with no copy cached, the editor shows its load error, and Retry reads it again.
| Module | What it is |
|---|---|
engine/ |
The pure, React-free save engine, change tracker and slug machine. No React, no network, every effect through a port |
session/ |
The editing session that composes those three and is the editor's only writer |
settings/ |
The settings panel beside the editor and the sections that edit a post's non-body fields |
publish/ |
The publish options machine and the publish, update and email-failure flow |
preview/ |
The modal that shows a post as the site renders it or as the newsletter it would be sent as |
editor-screen.tsx |
The route: loads the post, builds the session, and lays out the header, the surface and the sidebar |
post-editor.tsx, koenig-post-editor.tsx |
The title, excerpt and feature image around the Koenig instances, and the Koenig integration itself |
editor-header-actions.tsx, editor-status.tsx |
The header's publish and preview controls, and the line saying where the post stands or why a save failed |
use-save-feedback.tsx, save-toast.ts |
The toast and button progress that report an explicit save, and the pure copy they show |
card-config.ts, use-post-card-config.ts |
What Koenig's cards are told about the site and the post they are being edited in |
local-revisions.ts |
Browser-local copies of drafts holding unsaved work: how they are stored, trimmed and read back |
email-size.ts, use-email-size.ts |
The estimate of how large a post's newsletter would be, and when the editor makes it |
restore/ |
The /restore screen: lists this browser's local copies and creates a new draft from any of them |
Two small modules are shared across all of the above. request-options.ts
carries the editor's opt-out from the transport's session-expiry redirect, which
every request the editor makes passes: leaving the page would take unsaved
content with it, so the editor surfaces an expired session itself. Until a post
has opened nothing is unsaved, so a first read refused because the session has
expired reloads the page instead: the signed-out admin asks the writer to sign
in and then reopens the post. The opt-out belongs to whichever component starts
a fetch rather than to the cache entry, so a component reading a query key it
shares with a screen outside the editor opts out too. layering.ts carries the z-index a confirmation dialog opened from
inside another editor surface needs in order to paint above it.
The title is held to 255 characters, counted as the server counts a title: trimmed, with an emoji and its presentation selector as one character. Past that the title says so beneath itself and nothing is saved, and a save the writer asks for is refused with the same message. The inline excerpt is held to 300 characters, counted by code point, and its divider turns red while it is past them.
A draft opens the publish flow from three places: the header's Publish button,
its keyboard shortcut and the preview's Publish. A published or sent post whose
newsletter failed opens it from the status line instead, which offers "Retry
now" on an email-only send and "View details" on a published post; the flow
then starts at its email-failure step. Whether a post qualifies is decided by
the flow's own initialEmailError(), and the button is offered only to roles
Core lets retry an email, so an Author sees the failure without it.
While the post breaks a rule a save checks, the header's Preview and Publish, their shortcuts, the preview's Publish, Unpublish and Unschedule open nothing. The rules are the save's own: an over-long title, excerpt, email subject, meta, social card or code injection field, an invalid canonical URL, specific-tier access with no tier, an empty author list, and a publish date staged for a time that has not passed. Each opener is refused as a save the writer asks for is: the status line names the rule in red in place of the status, nothing is sent, and the writer is taken to the field. The title and the excerpt under it take focus; a field in the settings panel opens the panel, and the field's pane when it has one, and takes focus there. The email subject is edited in the preview, so the preview opens on its email tab, its own save refused beside the subject. The preview's Publish refuses an over-long subject in place: the preview stays open and turns to its email tab, where the rule is named beside the subject. A refusal of any other field from the preview's Publish closes the preview first. Update is refused by its save and takes the writer to the field the same way.
Every opener stays unavailable until the publish inputs have loaded, and so does the update flow behind Unpublish, Unschedule and a sent post's "Sent" in the status line. When they fail to load, the header shows the error with a Retry beside Publish, Unpublish or Unschedule, and for a post whose status line offers "Sent" or the retry. A read that found the session gone asks for the password in the editor's sign-in dialog first and reads again once the writer is back; cancelling it leaves the error, a later background read that fails the same way does not ask again, and Retry asks again. A published post's status line reads "Published and sending to N members" while its email is on its way and "Published and sent to N members" once it is submitted; an email-only send reads "Sent to N members" throughout. Neither a publish that emails nor a retry waits for the send, so the status line shows it as the save or retry left it.
After successful completion, the editor follows the publish flow's celebration
handoff to the destination screen. Pages return to /pages; scheduled posts
and posts without email return to /posts. Immediately published posts with
email, including email-only sends and posts that were emailed previously, open
/posts/analytics/:id. Failed saves and rejected retries keep the flow open
so the writer can try again. A publish that emails, or a retry of a failed
send, opens analytics once Core accepts it, but no sooner than 1.5 seconds
after the writer confirms; the send's progress, and any failure after that, is
reported there rather than in the flow.
The header's back link returns a post to the posts list and a page to the pages
list, with the filters and sort order the list was left with. A post opened
from its analytics screen or from the analytics overview instead reads
"Analytics" and returns to the screen it was opened from, path and query string
included. Those screens pass editorReturnState() from api.ts as router
state, and the editor accepts only a path under /posts/analytics/ or
/analytics. The return survives a reload, since it lives in the history
entry, but a new post never gets it. While the link leads to analytics, the
status line is hidden unless the post's newsletter failed to send or a save
failed.
The feature image and the X and Facebook card images share one field: a file picker and drop target, Unsplash while the site has it on, a preview, and Remove. When the site has Pintura configured, a set image also offers Edit, which opens it in Pintura; Save and close uploads the result and writes it as an upload would. While Pintura is open or the edited image is uploading, Edit and Remove are disabled and the preview shows the upload in progress. An edited image that fails to upload is reported, leaves the original in place, and is not counted as a saved edit.
The publish date in the settings panel and the schedule in the publish flow
share one pair of fields, edited in the site's timezone. The date is typed as
YYYY-MM-DD or picked from a calendar. The button at the start of the field
opens the calendar with focus on the chosen day; the arrow keys move between
days, Enter picks one, and focus returns to the button. A click on the field
also opens the calendar but leaves the caret in the field: typing closes it,
and clicking back into the field keeps it open.
A typed date is taken on blur or Enter, and an emptied field puts back the date
already held. In the settings panel Escape puts it back too; in the publish
flow Escape closes the flow instead. With the calendar open, the first Escape
only closes the calendar. Text in another format is refused with
Invalid date format, must be YYYY-MM-DD, and a day the calendar does not
have, such as 30 February, with Invalid date. A refused date stays in the
field, marked invalid and described by the message, until it is corrected or
discarded; Cmd/Ctrl+S from the field saves nothing meanwhile.
A new date keeps the time of day already held, and where that time does not exist on the new day because the clocks go forward, it moves forward with them. Typing reaches days the calendar does not offer: a publish date still to come is refused as a chosen time would be, and a schedule earlier than the flow allows moves up to the earliest it does.
The header's Update button, a contributor's Save button and Cmd/Ctrl+S report the save they asked for. Each clears the previous save toast when it starts and, once the server acknowledges the save, shows one describing the post's new status against the status it had before: "Post updated" with a "View on site" link for a published post, "Post updated" for a sent one and "Post saved" for a draft. A scheduled post's toast reads "Post scheduled" and says when it will be published or sent in the site's timezone, with the newsletter's audience when one goes out, and links to the post's preview. A save that fails, is dropped or is superseded shows nothing, as does one that lands with a different status because a publish or revert ran ahead of it, and autosaves and the saves the preview and publish flows make first are never reported.
The Update button reads "Updating..." while its save runs, "Updated" for two and a half seconds once it lands and "Retry" after a failure; a contributor's Save reads "Saving" and "Saved". Cmd/Ctrl+S leaves both buttons alone. Reverting a post to a draft from the update flow shows "Post reverted to a draft." ("Page" for a page).
A post that has never been saved has no status line. The save that creates it reads "Saving…" there, and a first save that fails says why.
The status line is where a failed or refused save is reported: its reason, in red, replaces the status until a later save lands, and stays shown when the header otherwise hides the status line. A host limit's reason links its "please upgrade" phrase to the host's upgrade screen. Retry beside the reason repeats a failed save. There is none for a save the editor refused before sending, since only fixing the field ends it, nor for a failed publish, schedule or unpublish: a retry there would save the post without changing its status, so the publish or update flow is where those are retried. While a collision blocks the next save, its banner offers the ways out instead.
What the status line cannot hold has a banner above the header, which moves down with the settings toggle beneath it: a collision, a deleted post, lost access and a crashed editor, each with Reload or Copy content, and a newer version saved elsewhere. Changes held back by a field's rule have no banner; the field names its rule, and the status line does once a save the writer asks for is refused over it.
The header row's actions line up with the settings toggle. A status that wraps keeps its first line level with the back link's label and grows downward.
Below Tailwind's sm breakpoint the status stays on the header row beside the
back link, and the header's actions move to the bottom bar: the word count sits
in the bottom-left corner, the actions in the bottom right, and the help link is
hidden. The bar's rightmost action, Publish, Update or Save, is a primary
button there, and the bar names no shortcuts,
which still work. editor-header-actions.tsx builds one list of controls and,
below the breakpoint, portals them into a slot post-editor.tsx keeps in its
footer, so the header and the bar share order, disabled states, refusals and
flows. Only the controls move; the flows they open stay mounted, so crossing
the breakpoint keeps an open flow. A failed publish inputs load shows its
message and Retry there too. While the on-screen keyboard is open the bar's
actions are hidden with the word count.
The link toolbar loads content search indexes on first use and shares them with global search in the React Query cache. Staff edits update the saved name, slug and URL in both searches; deletion removes the staff entry. Preference-only saves reuse the loaded lists without another index request. Resource invalidations make the next link search wait for fresh data. The shared parser retains URLs, visibility and published dates for the toolbar's link decorations.
Saving the post being edited never reads the lists again, since each one is every post, page or tag on the site. Each save writes the post as the server answered it into the loaded lists instead: its entry moves to the front of the posts or pages list, and any of its tags the tags list lacks, such as one the save created, is added to the front of that one. This happens as each save is answered, including one that lands after the writer has left the editor. A list whose read is still in flight when a save lands, its first included, gets the save once that read lands, since the read may predate it; the read itself is neither cancelled nor repeated.
use-post-snippets.tsx gives Koenig's card menu every snippet on the site,
sorted by name. Owners, Administrators, Editors and Super Editors can also save a selection as
a snippet, overwrite one by saving under its name and delete one, each after a
confirmation; Authors and Contributors can only insert them.
Koenig inserts a snippet from its lexical clipboard JSON. Older snippets don't
always store that: some hold it encoded twice, and some only have mobiledoc.
toSnippetValue() in snippet-value.ts unwraps the first and converts the
second when the list is read, without writing anything back, so a snippet the
editor cannot convert to any content is left out of the menu.
Inboxes clip an email of 100kB or more behind a "View entire message" link, so the editor estimates the size of the newsletter a post would be sent as and warns once it reaches that size: as an icon beside the word count that gives the size when clicked or tapped, in the publish flow's options while it will send an email, and above the email preview.
The estimate starts from the email preview endpoint, read without a newsletter
or audience, and measures its HTML in bytes. Every link between the post content
markers that starts with http://, https:// or / is then counted at the
length the send rewrites it to for click tracking, the site URL plus 50
characters; replacement tokens and # are left alone. estimateEmailSize() is
that calculation.
Only a saved post that could still go out as a newsletter is checked: not a page, not a published post, not one with an email record or marked email-only, and not when the site has newsletters turned off in its default recipients. There is no role gate. The version the editor opens with is estimated at once, and each later saved version about 500ms after it loads; every version is estimated once, in a cache entry of its own that the three places share. A failed estimate shows no warning.
- Add the section's id to
SETTINGS_SECTION_ORDERinsettings/sections.ts, in the position it should hold in the panel. - Write the section component in
settings/. It takes the narrow port fromsettings/editor-settings-port.ts, not the whole editing handle; a section that opens a pane rather than rendering fields in the list usesSettingsSubview. - Add an entry for the id to the
sectionsmap insettings/post-settings-sidebar.tsx. The map is also where the section's role gate goes: a role that cannot write the section gets no entry. - If the section writes a post field the session does not carry yet, add its
key to
SETTINGS_FIELD_KEYSinsession/settings-fields.ts, together with the identity it is written as and any rule the save-time validator should apply to it. - Add the section's selector constants to the shared registry at
packages/testing/test-data/src/selectors/editor.ts, so the acceptance specs and the e2e page objects break together. - Add the section's locators and gestures to
editor.screen.ts, consuming those constants. - Add an acceptance spec beside the others, named
editor-settings-<section>.acceptance.test.tsx. - Describe the section in
settings/README.md, in the placeSETTINGS_SECTION_ORDERgives it.
Editor acceptance specs are *.acceptance.test.tsx files in this directory, and
they follow the conventions in
the acceptance tier README. What is
specific to the editor lives in apps/admin/test-utils/acceptance/editor.ts:
-
fakeEditorChrome()serves the supporting reads the header, the card configuration and the email size check make, so a spec never mentions them. -
fakeEmailPreview(bytes)serves every post's email at a given size; a later call replaces the earlier one, which is how a spec changes the size between saves. -
fakeEditorPost(overrides, normalize)serves one post: later reads return what earlier saves sent, each save advances the collision token, and the capture it returns is what a spec asserts writes against.normalizestands in for a server that rewrites what it was sent. -
submittedPost(capture, index)reads the fields of a captured save, the most recent one by default. -
editorReadLanded(queryClient, version)resolves once a read ofversionat itsupdated_athas landed in the query cache and the editor has handled it, which is the only sign of a read the editor refuses. The copy a save writes into the cache does not count while the read after that save is in flight.renderAdminAppresolves with thequeryClient. -
fakeUnsplashPhotos()serves one photo in the shape the picker lays out and inserts, andUNSPLASH_PICKEDis the rendition the picker asks for. -
fakePintura()stands in for an already loaded Pintura script and stylesheet, records the images the editor is opened on and ends an edit withsave(file); it removes itself when the test finishes.
Four presets adjust the boot for a spec whose subject needs it. withoutAutosave()
boots with a debounce no run reaches, so a debounced autosave cannot be what an
assertion sees; withFastAutosave() is its opposite, for specs whose subject is
autosave itself; withoutUnsplash() boots a site with the integration off; and
withPintura() boots a site with Pintura configured, for use with
fakePintura().
Assert writes with await expect(saveApi).toHaveSavedFields({slug: "new-slug"}),
which waits for the write and compares the named fields of the latest payload;
the other matchers the tier provides are listed in its README. Gesture through
editorScreen in editor.screen.ts rather than reaching for locators directly.