1. Purpose, status, and scope
This specification turns content-platform-architecture.md into an executable implementation contract for a clean-slate replacement CMS.
The replacement has no requirement to read, retain, migrate, or remain API-compatible with the current CMS persistence model. Existing services may remain online until cutover, but implementation decisions must optimise the replacement model.
The first implementation includes:
a generic CMS folder and content-node tree;
articles, common strings, and fixed-layout single-page content;
US-English source content and Swedish, French, and German translations;
shared drafts, immutable published revisions, leaf publishing, and prompted folder publishing;
plain, paragraph, formatted, and rich profiles;
CMS-admin tree and flat-table browsing;
a portal-mounted Vue authoring application with inline editing and a right-docked properties pane;
public, published-only content reads for Vue applications.
The first implementation excludes:
migration from current CMS records;
new permission subjects/actions or translator/publisher roles;
image-bank implementation;
stale key cleanup;
Nuxt-specific routing, deployment, revalidation, and cache configuration. The public-read contract must support Nuxt later, but Nuxt infrastructure is deliberately not specified here.
2. Normative language and source of truth
The words must, must not, should, and may are normative.
CMS owns editable values, shared drafts, revision history, review state, folder placement, folder display mode, and publishing.
Application code owns content lookup keys, default/source values, profile declarations, maximum lengths, Vue layout, CSS, and rendering semantics.
The immutable content key is the only application lookup identity.
A CMS name, CMS folder location, and code-derived mapping path are not application lookup identities.
3. Initial language enum
Use one shared enum in CMS API types, persistence validation, frontend types, and tests.
export const CONTENT_LOCALES = [
'en-US',
'sv-SE',
'fr-FR',
'de-DE',
] as const;
export type ContentLocale = (typeof CONTENT_LOCALES)[number];
export const SOURCE_CONTENT_LOCALE: ContentLocale = 'en-US';
No content may be saved for a locale outside this enum in the first implementation. The active portal locale is mapped to one of these exact values by the consuming application.
4. Content profiles
export const CONTENT_FORMATS = ['plain', 'paragraph', 'formatted', 'rich'] as const;
export type ContentFormat = (typeof CONTENT_FORMATS)[number];
Format | Stored form | Allowed content |
|---|---|---|
plain | string | One visible line. No \r or \n. |
paragraph | ProseMirror JSON | Paragraphs, hard breaks, bullet lists, ordered lists, list items, and text. |
formatted | ProseMirror JSON | paragraph plus bold, italic, and underline marks. |
rich | ProseMirror JSON | The complete current CMS TipTap schema described below. |
4.1 Rich compatibility requirement
rich means the current CMS editor capability. The rewrite must preserve its supported rich-content behaviour; it must not simplify or reinterpret it while introducing the new node model.
The existing rich extension set is currently assembled in cms/src/vue/CmsApp.vue through createEditorExtensions() and includes:
StarterKit, with built-in code block and link disabled in favour of custom/link extensions;
CmsCodeBlock / lowlight code blocks;
Link;
CmsImage, including CMS asset image attributes, resize metadata, alignment, and draw.io embed attributes;
CmsTable, TableRow, TableHeader, and TableCell;
TextAlign for paragraph, heading, blockquote, and image alignment;
existing CMS column-layout nodes/serialization;
current article editor behaviour for headings, blockquotes, lists, tables, images, links, code, slash insertion, and draw.io.
The rewrite must extract this into one createRichExtensions() factory. Both CMS admin and portal authoring use that factory so they cannot drift. Existing specialized UI controls may be split into components, but their document schema and stored node attributes must remain accepted by rich validation.
4.2 Profile validation
The server must validate every ProseMirror JSON value against the declared profile. Frontend toolbar visibility is never sufficient validation.
The validator must:
reject unknown node and mark types;
reject node/mark types prohibited by the profile;
reject malformed JSON or a top-level value other than a ProseMirror document;
reject plain values containing hard or soft returns;
enforce optional maximum visible-text length;
return field-level validation errors suitable for CMS admin and portal author mode.
Visible-text length is the concatenated human-visible text of a string or ProseMirror document. It excludes JSON, attributes, markup, image URLs, and formatting syntax. Rich images or diagrams do not count as text characters.
5. $c and CmsContent application contracts
$c is CMS content, parallel to—not a replacement for—i18next $t.
type CmsCopyOptions = {
d: string | CmsPluralForms; // required US-English source/default value
n?: string; // optional initial CMS display name
f?: ContentFormat; // defaults to plain
l?: number; // optional maximum visible-text length
[interpolationName: string]: unknown;
};
$c(key: string, options: CmsCopyOptions): string;
Examples:
<button>
{{ $c('common.btn.submit', { d: 'Submit', n: 'Submit button', l: 32 }) }}
</button>
<p>
{{ $c('public.account.welcome', {
d: 'Welcome, {{name}}',
name: accountName,
l: 80,
}) }}
</p>
$c must be used only for plain content. It returns an interpolated string. The registration call includes d, n, f, and l; interpolation values are all remaining options.
Structured profiles use a Vue component:
<CmsContent
content-key="public.opc4.hero.summary"
:options="{
d: 'A clear product overview.',
n: 'Hero summary',
f: 'formatted',
l: 600,
}"
/>
CmsContent renders the relevant published ProseMirror value safely using the profile renderer. It must not render stored HTML through v-html.
5.1 Missing content behaviour
d is a registration/source seed; it is not a public fallback that bypasses CMS publishing.
In normal non-author mode, a node with no published CMS value renders no content. For $c, return an empty string. For CmsContent, render no wrapper/content.
In author mode, a node with no published or draft value renders the explicit visible placeholder Missing and remains selectable for authoring.
A first authenticated author-mode encounter registers/reconciles the key and seeds the shared en-US draft from d if the node does not yet exist.
Anonymous/public rendering must never create, update, or reconcile CMS content.
This makes missing CMS content safe in the public portal while clearly actionable to an authorised editor.
5.2 Interpolation
The first implementation supports {{name}}-style interpolation with options passed to $c. The registration metadata stores the set of interpolation variable names from d.
Saving a locale value must preserve exactly the same variable-name set. A translated value may move variables grammatically, but it must not add or omit them. The CMS editor shows immutable variable chips to help translators.
5.3 Plural strings and integer injection
The first implementation supports i18next-style integer interpolation through the reserved count option. Any text value may include {{count}}:
{{ $c('common.files.selected', {
d: '{{count}} files selected',
count: selectedFiles.length,
n: 'Selected file count',
}) }}
When grammar changes with the number, the node must be declared as a plural node by providing plural forms in d:
{{ $c('common.confirm.deleteFiles', {
d: {
one: 'Do you want to delete {{count}} file?',
other: 'Do you want to delete {{count}} files?',
},
count: files.length,
n: 'Delete files confirmation',
f: 'plain',
l: 80,
}) }}
type PluralCategory = Intl.LDMLPluralRule;
type CmsPluralForms = Partial<Record<PluralCategory, string>>;
The resolver uses new Intl.PluralRules(activeLocale).select(count) and renders the matching stored form. If a locale has no explicit matching form, it falls back to that locale's required other form. This makes runtime plural resolution standards-based without forcing editors to write rare categories that are not relevant to ordinary UI counts.
For the initial locales, CMS displays these expected categories:
Locale | Categories to edit |
|---|---|
en-US | one, other |
sv-SE | one, other |
fr-FR | one, other |
de-DE | one, other |
The system must obtain the active category from Intl.PluralRules rather than hard-coding language-specific conditionals. The CMS may expose additional locale categories as optional forms. If a future locale requires more than one/other for normal product copy, that locale's content configuration must explicitly make those categories mandatory before it is enabled.
Plural forms belong to one node, one shared draft, and one published revision. A Swedish singular/plural pair is never represented as two separate keys or separately published leaves. CMS admin shows a form editor for every required category, while flat tables show an abbreviated plural indicator and the currently selected/resolved form.
Plural support is initially limited to plain nodes. paragraph, formatted, and rich values remain singular in the first delivery. If structured plural content is needed later, extend the same CmsPluralForms shape so each form contains a profile-valid ProseMirror document; do not create a separate plural/revision model.
Plural registration/save validation must:
require count on the $c call that renders a plural node;
require {{count}} in every supplied source/translated form;
require the same interpolation variable set across all forms and locales;
validate every form against l independently;
reject a change between singular and plural node shape for an existing key with 409.
i18next context variants, namespaces, object returns, and post-processors are not part of the first implementation. They may be designed later as explicit CMS node features, not implicit options.
6. Folder and key identity model
CMS starts empty. It has no hard-coded root folders.
6.1 Folder records
type FolderDisplayMode = 'tree' | 'flat';
type CmsFolder = {
_id: ObjectId;
accountId: string;
parentFolderId?: ObjectId;
name: string;
sortOrder: number;
displayMode: FolderDisplayMode;
mappingPath?: string; // immutable only when initially code-derived
publicCollection?: {
kind: 'knowledge-base';
enabled: boolean;
};
createdBy: string;
updatedBy: string;
createdAt: Date;
updatedAt: Date;
};
Indexes:
{ accountId: 1, parentFolderId: 1, sortOrder: 1 }
{ accountId: 1, mappingPath: 1 } unique, sparse
Folders created by $c/CmsContent registration use the dotted key prefixes. For common.btn.submit, registration creates/reuses folders with mapping paths common and common.btn, then creates/reconciles the leaf key.
mappingPath is immutable after creation. It remains unchanged when a user renames or moves the folder. It supports discovery/reconciliation; it never determines the current visible CMS path or frontend lookup.
User-created article folders have no mappingPath unless they are intentionally created by future code registration.
6.2 Content node records
type ContentNodeType = 'article' | 'content';
type SingularContentValue = string | ProseMirrorJsonDocument;
type PluralContentValue = Partial<Record<Intl.LDMLPluralRule, string>>;
type ContentValue = SingularContentValue | PluralContentValue;
type LocaleReviewState = 'current' | 'needs-review' | 'missing';
type CmsContentNode = {
_id: ObjectId;
accountId: string;
parentFolderId?: ObjectId;
key: string; // immutable and unique per account
name: string; // editable CMS display name
nodeType: ContentNodeType;
valueKind: 'singular' | 'plural';
format: ContentFormat;
maxLength?: number;
source: {
defaultValue: ContentValue;
sourceApplication?: string;
sourceLocation?: string;
lastSeenAt?: Date;
};
draft: {
version: number;
values: Partial<Record<ContentLocale, ContentValue>>;
review: Partial<Record<Exclude<ContentLocale, 'en-US'>, {
state: LocaleReviewState;
reviewedAgainstEnglishDigest?: string;
}>>;
updatedBy: string;
updatedAt: Date;
};
publishedRevisionId?: ObjectId;
createdBy: string;
updatedBy: string;
createdAt: Date;
updatedAt: Date;
};
Indexes:
{ accountId: 1, key: 1 } unique
{ accountId: 1, parentFolderId: 1, name: 1 }
{ accountId: 1, 'draft.review.state': 1 }
All articles require a key too. CMS admin creates an article by choosing its key and initial folder placement; folder prefixes may be created on demand. The article key makes articles addressable by the future public knowledge-base projection without relying on a mutable title or path.
nodeType: 'article' must use format: 'rich' and valueKind: 'singular'. nodeType: 'content' supports every profile; valueKind: 'plural' is initially permitted only with format: 'plain'.
6.3 Published revisions
type CmsContentRevision = {
_id: ObjectId;
accountId: string;
nodeId: ObjectId;
revisionNumber: number;
values: Partial<Record<ContentLocale, ContentValue>>;
review: CmsContentNode['draft']['review'];
publishedBy: string;
publishedAt: Date;
};
Index { nodeId: 1, revisionNumber: -1 } must be unique.
Every published revision snapshots all locale values and review metadata together. No per-locale revision, publication, or rollback record exists.
6.4 Moves and names
CMS users may move or rename a code-created folder/node. The system must preserve:
the node key;
folder mappingPath;
all child placement under a moved folder;
locale values, shared draft, revisions, and published revision pointer.
Future registration looks up leaves by immutable key and auto-created folders by immutable mappingPath. It must not rebuild the original key-prefix path if editors have moved or renamed it.
7. Draft, review, and publishing operations
7.1 Shared drafts
There is one shared draft per node, embedded in the node record. Saving requires the caller to send expectedDraftVersion.
If expectedDraftVersion equals node.draft.version:
validate and save values
increment draft.version
Else:
return 409 draft-version-conflict with latest draft payload
For plain, portal and CMS clients resolve a conflict by showing the latest value and asking the editor to merge/retry. For ProseMirror fields, the first implementation uses the same optimistic version protocol. Realtime/Yjs collaboration can be introduced later without changing published-revision semantics.
7.2 English-source changes
When en-US draft content changes through CMS admin, portal author mode, or code reconciliation:
update the English draft value;
calculate its visible-content digest;
mark every populated non-English locale needs-review unless its stored reviewed digest equals the new digest;
retain every translated value and history.
Changing Swedish, French, or German must not modify English or flag other locales.
7.3 Leaf publishing
Publishing a leaf validates the full draft and writes a new immutable CmsContentRevision. It then updates publishedRevisionId on the node.
Published public reads use only the revision referenced by publishedRevisionId, never draft.
7.4 Folder publishing
Folders have no own content revision. Selecting Publish on a folder gathers descendant content nodes and presents a mandatory choice when descendants exist:
Publish direct leaves only Publish recursively
Direct leaves only publishes only content nodes directly contained by the selected folder.
Recursively publishes every content node in the selected folder and all descendant folders.
The confirmation dialog must show the exact node count and keys that will publish. The operation is all-or-nothing: validate every selected node before writing any revision; if validation fails, publish none and report failing nodes.
Folder moves, folder names, order, display mode, and public-collection configuration are saved live and are never versioned or published.
7.5 Revert
Revert copies the selected immutable revision into the shared draft, increments draft.version, and requires a subsequent leaf or folder publish. Revert must never silently replace the public revision pointer.
8. Public and author API contract
Route names below are target contracts; the final router placement must remain inside cms.
8.1 Public, published-only APIs
GET /cms/api/public/content?keys=common.btn.submit,public.home.hero.title&locale=sv-SE
GET /cms/api/public/knowledge-base/:collectionFolderId/tree?locale=sv-SE
GET /cms/api/public/knowledge-base/:collectionFolderId/articles/:key?locale=sv-SE
Public response rules:
authenticate nothing and return no draft/revision/history metadata;
resolve only the selected published revision;
return null/absent values for missing locale content; the frontend follows normal/author mode rendering rules;
return live folder tree structure for configured knowledge-base roots, filtered to descendants with published revisions;
allow normal HTTP caching; cache policy details for Nuxt are deferred.
8.2 Protected author APIs
POST /cms/api/author/registrations
GET /cms/api/author/nodes/by-key/:key
PUT /cms/api/author/nodes/:nodeId/draft
POST /cms/api/author/nodes/:nodeId/publish
POST /cms/api/author/nodes/:nodeId/revert/:revisionId
POST /cms/api/author/folders/:folderId/publish
POST /cms/api/author/folders
PUT /cms/api/author/folders/:folderId
POST /cms/api/author/nodes
PUT /cms/api/author/nodes/:nodeId
POST /cms/api/author/nodes/:nodeId/move
POST /cms/api/author/folders/:folderId/move
All author endpoints require the current CMS manage permission. Every author/preview response must send Cache-Control: private, no-store.
Registration request
type RegisterContentRequest = {
key: string;
name?: string;
format: ContentFormat;
maxLength?: number;
defaultValue: ContentValue;
nodeType: 'content';
sourceApplication?: string;
sourceLocation?: string;
};
Registration is idempotent. It creates missing folders/nodes, seeds the en-US draft from defaultValue, and returns the author node payload. For an existing key it validates that profile/type/max-length are compatible, updates source metadata, and applies the English-source-change rules when the supplied default changed.
Incompatible profile changes must return 409 rather than silently converting stored content.
Draft save request
type SaveDraftRequest = {
expectedDraftVersion: number;
values: Partial<Record<ContentLocale, ContentValue>>;
};
The save response returns the complete current author node payload and its next draft version.
8.3 Author node response
type AuthorNodeResponse = {
node: CmsContentNode;
published?: CmsContentRevision;
revisions: Array<Pick<CmsContentRevision,
'_id' | 'revisionNumber' | 'publishedAt' | 'publishedBy'>>;
placement: Array<{ folderId: string; name: string; mappingPath?: string }>;
};
The full revision body is fetched only when the editor opens a revision comparison/restore action.
9. CMS admin application
9.1 Tree behaviour
CMS opens on a generic folder tree. It must not render hard-coded roots such as Common, Public, Operated, or Knowledge Base Articles.
Folders auto-created from key prefixes appear when the first matching field is registered in an authorised author-mode session. Article folders appear when a CMS user creates them.
The folder context menu provides:
New folder
New article
Rename
Move
Display mode: Tree | Flat
Configure/unconfigure public Knowledge Base collection
Publish…
9.2 Display modes
tree is the normal nested representation. It displays folders and individual article/content leaves.
Selecting a flat folder must show an Excel-style table instead of expanding the ordinary child tree. Descendant folders are collapsed in this interaction. The folder icon must visually distinguish a flat/table folder from an ordinary expandable tree folder.
Flat table requirements:
rows are content-node descendants of the selected folder;
columns include CMS name, immutable key, US English, Swedish, French, German, profile, and review status;
filter by locale state (missing, needs-review, current), profile, key, and visible name;
inline table-cell editing is allowed for plain; other profiles open their editor/properties pane;
selecting a row opens the full node properties surface;
the table always displays the immutable key, even after a CMS move or rename.
Plural nodes remain one row. A plural cell must not pretend that its currently resolved form is the whole value. It shows a plural indicator plus a compact preview, for example:
Key | English (US) | Swedish |
|---|---|---|
common.confirm.deleteFiles | 1: …1 file? / 5: …5 files? | 1: …1 fil? / 5: …5 filer? |
Clicking the plural row/cell opens the plural form editor described below.
9.3 Node properties surface
The node properties surface includes:
Content editor
Languages and review state
Published versus shared draft comparison
Version history and restore-to-draft
Leaf publish
Containing-folder publish
Immutable key
Editable CMS name and current CMS location
Registered format and max length (read-only for code-registered nodes)
For article creation, key, name, parent folder, and rich profile are entered before opening the full article editor. Articles use the complete rich TipTap surface.
9.4 Plural form editor
A plural node has one language section per locale and one input for each required form within that language. For the initial locales, this is normally one and other.
Delete files confirmation [ Swedish ]
one
[ Vill du radera {{count}} fil? ]
other
[ Vill du radera {{count}} filer? ]
Preview count [ 1 ] [ 5 ] [ custom: __ ]
Preview: Vill du radera 5 filer?
{{count}} is displayed as a required variable chip/marker. Authors may move it within a sentence but cannot remove or rename it. The preview selector changes only the editor preview; it does not change stored data or the count supplied by the running application.
Saving a plural node saves all currently edited forms into the same shared draft update. Publishing publishes the complete plural node with every locale/form in one revision.
10. Portal authoring application
10.1 Mount and activation
The portal authoring application is a Vue application mounted once into the main portal index.html only when:
the user is authenticated;
server-provided portal bootstrap confirms existing CMS manage access; and
the user explicitly activates author mode.
The mounted app must work over both server-rendered Nuxt sections and dynamic Vue applications. It does not own the visible page router or layout.
Portal index.html
├─ normal content mount(s): Nuxt section or Vue application
└─ #content-authoring-root
└─ mounted only in enabled author mode
Authoring activation state is a user preference or explicit shell toggle. It is not activated by a public query parameter.
Server/client responsibility split
The server must never render a live <input>, TipTap instance, draft value, or authoring toolbar into public page HTML.
Concern | Owner |
|---|---|
Authenticate the user and decide whether CMS manage access is available | Server/shared auth API |
Render normal published SSR content | Nuxt/server-rendered section, where applicable |
Return published catalogue/content to ordinary Vue apps | Public CMS read API |
Supply a minimal portal bootstrap capability (canAuthorContent) after authentication | Main portal shell/auth bootstrap |
Mount the authoring Vue application after user activation | Browser |
Add target outlines, register fields, and create/remove temporary editors | Browser authoring application |
Read drafts, save drafts, publish, revert, and validate permissions | Protected CMS author API |
The main portal index.html should contain an empty, stable mount point. It may be present for every visitor because an empty element has no authoring behaviour:
<main id="portal-app"></main>
<div id="content-authoring-root"></div>
The browser lazy-loads and mounts the authoring bundle only after both canAuthorContent and the editor's explicit author-mode preference are true. The CMS author API still enforces permissions on every request; the bootstrap flag is only a UX gate.
10.2 Field registry
Every CmsEditable/CmsContent registers a field only while author mode is active. Fields must not be inferred from arbitrary DOM text.
type PortalContentField = {
key: string;
element: HTMLElement;
options: CmsCopyOptions;
format: ContentFormat;
maxLength?: number;
registrationState: 'unknown' | 'registering' | 'ready' | 'error';
};
The authoring app owns a registry keyed by content key, the selected field, draft cache, active inline editor, and docked-pane state. It receives element references from wrappers and uses ResizeObserver, scroll listeners, and viewport changes to position outlines/toolbars.
10.3 Automatic CmsText targets and optional v-cms-editable
For an ordinary $c template interpolation, the Vue content transform automatically emits a CmsText target around the resolved text. Developers do not need a directive:
<button>
{{ $c('common.btn.submit', { d: 'Submit', n: 'Submit button', l: 32 }) }}
</button>
Conceptually, it renders:
<button>
<span data-cms-key="common.btn.submit">Submit</span>
</button>
The neutral inline span has no intended visual styling. In author mode it registers with the portal authoring application and receives temporary selection decoration. No input, TipTap editor, or toolbar is created until the editor selects it.
CmsText must render one ordinary inline <span> around a $c value. It must inherit the surrounding element's typography, colour, text transform, and layout naturally; it must not set display, font, spacing, dimensions, or a default border/background in normal mode. This makes it safe inside headings, paragraphs, buttons, labels, and links while providing a precise element reference for author mode. The authoring overlay reads this span's computed style, so inherited H1/body/button styling is available when it creates the temporary input.
The span is a registration/selection boundary, not an editor or layout component. In normal mode it contains only resolved published text (or no text for a missing value). In author mode it may receive an author-target class/data attributes and a hover/selected outline; those styles must be non-layout-affecting.
v-cms-editable is optional. It is used when the entire existing element, rather than a string-sized span, must become the editable target:
<button
v-cms-editable="{
key: 'common.btn.submit',
options: { d: 'Submit', n: 'Submit button', f: 'plain', l: 32 },
}"
>
{{ $c('common.btn.submit', { d: 'Submit', l: 32 }) }}
</button>
An explicit directive suppresses the automatic CmsText span for its direct $c field. The transform verifies that the directive and $c use the same key and compatible d/f/l options. A mismatch must fail the Vue build with a source-location error. The first implementation supports one directly associated $c field per directive target.
The directive's client mounted hook registers the actual button element with the portal authoring registry only when author mode is active. It adds/removes the lightweight data-cms-* metadata and event handlers as author mode changes. It does not create an input.
CmsEditable remains a convenience component where a directive cannot be attached directly, but it must forward the registration to one concrete child/root element rather than add an arbitrary wrapper around interactive controls. CmsContent owns its own render root and registers that root for structured ProseMirror content.
<CmsEditable
content-key="common.btn.submit"
:options="{ d: 'Submit', n: 'Submit button', f: 'plain', l: 32 }"
>
<button>{{ $c('common.btn.submit', { d: 'Submit', l: 32 }) }}</button>
</CmsEditable>
In author mode it adds a selection outline and a deliberate edit affordance. Opening an inline edit must prevent the wrapped button/link/default interaction from firing. Cancelling or saving restores ordinary interaction.
Inactive decoration and on-demand editor DOM
The inactive author-mode target is the normal rendered element, registered by reference. It must not contain a hidden <input>, a hidden TipTap editor, or a pre-mounted editor toolbar.
For example, normal mode renders the application’s ordinary output:
<button class="button button--primary">Submit</button>
In author mode, an explicit directive target is minimally decorated/registered:
<button
class="button button--primary cms-author-target"
data-cms-key="common.btn.submit"
data-cms-format="plain"
aria-label="Editable CMS content: Submit"
>
Submit
</button>
The selection outline is rendered with CSS/pseudo-elements or a lightweight portal-wide selection rectangle. It appears only on author-mode hover/selection. No content editor is created at this stage.
On an intentional edit action, the authoring app creates a single temporary editor overlay through Teleport at #content-authoring-root:
<div id="content-authoring-root">
<div class="cms-author-inline-editor" style="position: fixed; left: …; top: …; width: …">
<input value="Submit" maxlength="32">
<button type="button">Save</button>
<button type="button">Cancel</button>
</div>
</div>
The overlay is positioned from the selected target's getBoundingClientRect(). It must copy the target's resolved computed presentation so it appears to be edited in place. At minimum copy font-family, font-size, font-weight, font-style, letter-spacing, line-height, text-transform, text-align, color, relevant padding, border radius, and box-sizing; use the target rectangle for left, top, width, and height. The target may be a CmsText span inheriting its surrounding H1/paragraph/button typography, so getComputedStyle(target) is the authoritative source.
For example, a CMS span inside an H1 receives an overlay input with the actual rendered H1 font size, line height, weight, letter spacing, and colour. The input must use browser-style resets such as appearance: none, transparent background, neutral border, and an explicit author-mode focus outline so native input styling does not break the page language.
The original target remains as the layout anchor but is visually muted/hidden and has its normal pointer/click behaviour disabled while editing. This avoids invalid interactive nesting such as placing an <input> inside a <button>, avoids page reflow, and works equally over SSR and client-rendered page regions. The overlay must have a minimum width based on the original target so an empty string remains editable, and must reposition on scroll, viewport resize, and target resize while open.
Save/cancel, outside-confirmation, route change, or selection change unmounts the temporary overlay entirely and restores the original target. At most one inline editor exists in the portal at a time.
10.4 Adoption impact on Vue and portal infrastructure
This feature must not modify every existing Vue component or make arbitrary page text editable. A component/page opts in only where it declares a $c/CmsContent field:
plain values: use $c normally; the transform adds a CmsText span target automatically. Add v-cms-editable only when the whole existing element must be the target;
structured values: use CmsContent, which renders and registers its own root;
non-editable application copy continues to use $t and receives no authoring decoration.
The shared portal infrastructure gains only:
a published-content resolver used by $c and CmsContent;
the automatic $c/CmsText template transform, optional v-cms-editable directive, and field-registry client contract;
a lazy-loaded authoring Vue application mounted at #content-authoring-root;
authenticated capability bootstrap for the author-mode toggle.
It does not require route-specific server templates to inject editor inputs or toolbar HTML. SSR Nuxt sections emit normal published HTML; after hydration, their Vue directive/component instances register targets in exactly the same way as a dynamic Vue app. Details of the eventual Nuxt plugin/module remain deferred until Nuxt infrastructure exists.
10.5 Profile interaction rules
Format | First selection action | Editing surface |
|---|---|---|
plain | Enter inline edit | Styled single-line input over/in the normal rendered position; save and cancel controls. |
paragraph | Enter inline edit | Restricted TipTap editor in the same content region with paragraph/list controls. |
formatted | Enter inline edit | Restricted TipTap editor plus contextual formatting toolbar. |
rich | Select and open properties pane | Full rich TipTap editor in the docked pane by default. |
Every profile may be opened in the right-docked properties pane. The pane and inline editor bind to the same loaded node/draft store; they must never maintain competing unsaved local drafts.
Plural plain nodes are an intentional exception to direct one-line replacement. The visible portal text is only the form resolved for the application-provided count; editing that text alone would risk leaving another form untranslated. Selecting a plural field therefore opens an anchored plural mini-editor instead of replacing the visible text with one input.
Visible portal copy: Vill du radera 5 filer? [edit]
Anchored plural mini-editor
Count preview: [1] [5]
one: [Vill du radera {{count}} fil?]
other: [Vill du radera {{count}} filer?]
[Save draft] [Open properties]
The form selected by the live application count is highlighted first, but all required forms remain visible and editable. The count value itself is read-only in author mode because it belongs to the running application state. A custom preview count may be entered solely to inspect the locale's plural resolution. Open properties opens the same full plural form editor in the right dock.
10.6 Inline editors and toolbars
plain uses a single-line overlay <input> with maxlength when l exists. It is not a bare contenteditable span. Computed styles are copied from the selected rendered target so in-place edits retain the original heading/body/button typography. Client validation is assistive only; CMS API validation is authoritative.
paragraph and formatted use TipTap initialized from the current shared draft (or published value if no draft value exists). They use profile extension factories shared with server validation. Their controls are deliberately restricted to their profile.
Context toolbars are rendered by the authoring app through Vue Teleport to the document body. Toolbar positioning is calculated from the selected field/editor rectangle. It must reposition on scroll, resize, and editor selection updates, and close on Escape/outside interaction.
Inline saves call the same draft endpoint used by CMS admin. On 409, the UI preserves the local text, shows that the shared draft changed, retrieves the latest content, and requires explicit retry/merge; it must not overwrite silently.
10.7 Right-docked properties pane
The authoring application renders one docked pane through Teleport. Selecting a field loads the protected author node payload and shows:
Editor
Languages: en-US, sv-SE, fr-FR, de-DE
Translation review/source comparison
Shared draft versus published revision
Version history and restore to draft
Publish leaf
Publish containing folder
Immutable key, format, max length, and CMS placement
The pane is the integration point for later image-bank and asset editing. Asset features are not part of this delivery.
Relationship between inline editing and the docked pane
Inline editing and the docked pane are two views of the same selected content node and the same shared draft. They are not separate editor modes, do not create separate revisions, and must not keep independent unsaved copies.
Select CMS field
├─ fast inline surface: edit the value in its page context
└─ docked pane: inspect/edit the complete node and its lifecycle
Both surfaces
└─ one portal authoring draft store
└─ one CMS shared-draft version / save endpoint
The inline surface is intentionally narrow:
plain: current locale's one-line value, with save/cancel;
paragraph / formatted: current locale's allowed ProseMirror value and small contextual toolbar;
plural plain: required forms together in the anchored plural mini-editor;
rich: no default large inline editor; open the docked pane.
The docked pane is the complete node-level workspace. It always shows all locales, source/review state, shared draft versus published content, revision history, restore-to-draft, leaf publish, and containing-folder publish. It may edit the active locale's value, but it must also make clear that publishing snapshots every locale/form in the node together.
When an inline editor opens, the pane may remain closed. Its header/selection state is still updated in the background. If the user chooses Open properties, the inline editor first validates and transfers its current local buffer into the shared portal draft store; the pane then displays that same unsaved draft buffer. The reverse also applies: saving or changing a value in the pane updates the selected field's inline preview immediately.
Before a new field is selected, the pane is closed, or navigation occurs, the authoring app checks the single portal draft store. It offers Save draft, Discard local changes, or Stay. It must never silently discard an inline buffer or overwrite it with a pane value.
10.8 Navigation and safety
Prompt before closing a field, selecting another field, or navigating away with unsaved local inline changes.
Do not put author content in browser history URLs.
Author data, drafts, and revision responses are private/no-store.
The client permission flag enables UX only. CMS author APIs enforce the permission again.
Normal users and users outside author mode receive no authoring app, overlays, registration writes, or draft values.
11. Knowledge Base public projection
A CMS user may mark any existing folder as publicCollection.kind = 'knowledge-base'. There is no reserved folder name.
The public tree endpoint builds a hierarchy from the folder’s current live descendants. It includes a content node only when it has a published revision. Folder moves, names, order, archive/hide state if introduced, and collection configuration affect the visible tree immediately; they are not revisioned.
The article endpoint loads the selected article by immutable key and returns only the requested locale’s value from its selected published revision. Missing locale values return no content. Fallback policy is intentionally not included in this specification.
12. Delivery phases and required tests
Phase 1 — Domain core and plain nodes
Implement folders, content nodes, revisions, registration, plain validation, shared drafts, leaf publish/revert, and public published reads.
Required tests:
creates folders from common.btn.submit only when authorised registration is called;
preserves key and mapping path after rename/move;
rejects newline in plain;
resolves count: 1 and count: 5 to the correct en-US plural forms;
resolves the stored Swedish one and other forms through Intl.PluralRules('sv-SE');
rejects a plural registration with missing count, missing {{count}}, or missing required locale forms;
rejects an attempted singular-to-plural shape change for an existing key;
rejects stale expectedDraftVersion with 409;
public endpoint never returns draft text;
changing English marks Swedish/French/German review state;
changing Swedish does not dirty English/French/German;
leaf publish snapshots all locale values together.
Phase 2 — CMS tree and flat table
Implement generic tree, folder context menu, display modes, table filtering, node properties, and direct/recursive publish confirmation.
Required tests:
folder display mode persists and is shared between users;
flat view does not alter node placement or keys;
direct folder publish excludes nested-folder leaves;
recursive publish includes every descendant leaf;
any invalid selected draft aborts the entire folder publishing operation.
Phase 3 — ProseMirror profiles and articles
Extract rich extensions, implement profile validators/renderers, restricted profile editors, article creation, and Knowledge Base public projections.
Required tests:
paragraph rejects bold/underline/image/table;
formatted accepts bold/italic/underline and rejects image/table;
rich accepts documents produced by the current CMS rich editor schema;
max length measures visible text;
knowledge-base tree exposes live folder placement and published articles only.
Phase 4 — Portal author mode
Implement field registry, registration on authorised first use, plain inline editing, shared draft handling, and properties pane.
Required tests:
normal mode produces no registration request and no author UI;
author mode registers a missing field and shows Missing before content is saved/published;
inline button editing prevents its normal click action;
save affects shared draft but not public read until publish;
docked pane and inline editor show the same shared draft;
author API response is private/no-store.
Phase 5 — Portal ProseMirror authoring
Add inline paragraph/formatted editors, contextual toolbar, and rich docked editor. Nuxt-specific SSR/cache/revalidation work is a later phase after Nuxt infrastructure exists.
13. Cutover criteria
The replacement may take ownership of a public Vue/app/Knowledge Base surface only after:
its required profile and locale behaviour pass the relevant tests;
public reads return published-only values;
author mode is private and permission-enforced;
folder publishing semantics are verified;
the consuming frontend has no runtime dependency on current CMS APIs or collections.
No legacy-data migration is required for cutover. Any optional later import is a separate scoped project.
Downloads & Attachments
Workspace_Onboarding_Guide.pdf
2.4 MBAccess_Policy_Matrix_v2.pdf
1.8 MBSecurity_Audit_Checklist.pdf
840 KBRelated Articles
Understanding Custom Role Permissions
Updated 2 days ago
Top 10 Security Protocols for Teams
Updated 1 week ago
Integrating LDAP with Directory Sync
Updated 3 weeks ago