# Pages built from sections — the backoffice of mmo.editor
`MMO\editor\cms` edits the pages of a site — a landing page, an offer, a legal notice — as **JSON** kept by mmo.core
entities, and renders them with **PHTML** templates. A page is a list of typed sections (a hero, features, prices,
text, a call to action...): each type is a template and a JSON schema, the schema says what the backoffice asks for,
and the html fields are written with ``.
The backoffice is `` (`admin/pages` serves it alone): the pages, a form made from the sections'
schemas, a live preview, the publication and the history. What it shows follows the display level of mmo.client
(`MMO\client\Modes`, `MMO.modes`): `simple` the main fields, `standard` every field and the history, `expert` also
the editing **in the page itself**, in the preview.
## The pieces
| file | |
|---|---|
| `src/cms/Page.php` | a page (`Entity`, `iGuarded`): `slug`, `title`, `layout`, `seo`, `sections`, `languages`, `translations` — its draft — and `published`, the version visitors see (null: never published) |
| `src/cms/Translations.php` | a page's languages: what a translation translates, keeps, and the version of a page in one language |
| `src/cms/Section.php` | a section (`EmbeddedEntity` of `Page::$sections`): `id`, `type`, `data` |
| `src/cms/PageRevision.php` | a version as it was published: `page`, `number`, `version`, `author` |
| `src/cms/Pages.php` | the module (`Pages::Shared()`): rights, drafts, publishing, revisions, rollback, rendering |
| `src/cms/SectionTypes.php`, `SectionType.php` | the types of section found in the template folders, their schema, cleaning and validation |
| `src/cms/PageRenderer.php`, `SectionFields.php` | the rendering: a version in its layout, each section by its template; `$f`, what a template prints with |
| `src/cms/Sanitizer.php` | the server's copy of `MMO.editor.sanitize()`: every html field goes through it when it is saved |
| `src/controllers/PagesController.php` | the api, under `admin/` |
| `src/controllers/PublicPageController.php` | a published page at its slug |
| `src/js/pages/PageEditor.js` | `MMO.editor.PageEditor`, the backoffice in the browser (any container) |
| `src/customElements/mmo-page-editor.jhs` | ``, its element |
| `templates/backoffice.phtml` | the page of `admin/pages` (an application's own in its `templates` wins) |
| `src/cms/SectionTypeStore.php`, `src/controllers/SectionTypesController.php` | the section-type editor's files and api |
| `src/js/pages/SectionEditor.js`, `src/customElements/mmo-section-editor.jhs`, `templates/section-types.phtml` | the section-type editor in the browser, and its page |
| `templates/layouts/landing.phtml`, `templates/sections/*` | the default layout (mmo-app's `landing` shell) and five types: `hero`, `features`, `prices`, `text`, `cta` |
## Wiring it into an app
```json
"MMO\\core\\Application": {
"routes": {
"MMO\\oauth\\controllers\\AccessController": {},
"MMO\\editor\\controllers\\PagesController": {},
"MMO\\editor\\controllers\\PublicPageController": {},
"MMO\\client\\controllers\\PageController": {}
}
},
"MMO\\editor\\cms\\Pages": {
"repositories": {"main": {"$": "#repository"}},
"templates": ["{root}/templates/cms"],
"revisions": 20
},
"MMO\\editor\\controllers\\PublicPageController": {"homepage": "home"},
"repository": {
"$": "MMO\\core\\entities\\JsonRepository",
"filenames": {"pages": "{db}/pages.json", "pageRevisions": "{db}/pageRevisions.json"},
"collections": {"pages": "MMO\\editor\\cms\\Page", "pageRevisions": "MMO\\editor\\cms\\PageRevision"}
}
```
- `PublicPageController` only claims the url of a **published** page (its `slug`; `/` is `homepage`'s): any other url
goes on to the controllers after it, so list it before mmo.client's `PageController`. Its route still runs for a
request it did not claim (a controller's routes do): it then returns nothing, and steps aside.
- `templates`: the application's folders, before mmo.editor's own `templates/` (`Pages::TEMPLATES`). A folder holds
`layouts/{name}.phtml` and `sections/{name}.phtml` + `sections/{name}.schema.json`; a type of the same name in an
earlier folder replaces mmo.editor's.
- `demo/www/index.php` is a complete example (with no login: the `demo` user is set on every request).
## Rights
mmo.oauth scopes: `editor.pages#edit` changes the drafts (and makes and deletes pages — deleting also needs
`#publish`), `editor.pages#publish` publishes, takes offline, rolls back; `editor.pages` is both. A page's own scope
narrows them (`editor.pages.{id}#edit: false`). `Pages::can($client, Pages::EDIT, $page)`, `check()` throws
`eForbidden` (403). `Page` and `PageRevision` are `iGuarded`: mmo.oauth asks no `data.*` scope to save what `Pages`
does — but in the generic data api (`RepositoryController`), which refuses them to whoever does not hold those
scopes: do not grant them to editors. `Pages` lists its scopes in `ScopesList` (once it was built: `Pages::Shared()`).
Not `pages.*`: in mmo.oauth that is mmo.client's `Content` tree.
## The model
- **The draft** — `title`, `layout`, `seo` (`{title, description, image}`), `sections` — is what the backoffice
changes (`Pages::saveDraft($client, $page, $input)`: only the fields given, and only if all are valid).
- **Publishing** (`publish()`) copies the draft into `published` (with `revision`, `date`, `author`) and keeps it as a
`PageRevision`; the last `revisions` are kept. `unpublish()` takes it offline (draft and revisions stay).
`rollback($client, $page, $number)` copies a revision back into the **draft**: live once published again.
- A section's `id` is made when the section is (`Section::NewId()`, `s` + 8 hex) and kept when the sections are
reordered; an id given twice is made again. It is the `data-section` of the preview and the `data-path` of the
expert level.
- What a client sends is cleaned before it is checked (`SectionType::clean()`): html fields sanitized (`Sanitizer`,
the same rules as the editor's), urls of an unsafe scheme emptied, numbers and booleans converted. Then validated
(`SectionType::validate()`, core's `Schema`), and a field nobody declared is refused. What is wrong is an
`eInvalidPage` (422) whose `errors` are keyed by path: `slug`, `sections/2/data/title`, `seo/image`...
## Languages
A page may be in several languages: `languages` (RFC 5646 tags, lowercase: `en`, `fr`, `pt-br`…, checked by mmo.core's `I18n::splitLanguage()`, `Translations::Code()`), the **default**
one first — the draft itself is written in it —, and `translations`, its texts in each other one:
`{lang: {title, seo: {title, description}, sections: {sectionId: data}}}`.
- **What is translated**: the texts only — the page's `title`, its SEO title and description, and in a section every
string of format `text` (the default) or `html` (`Translations::Kind()`). A choice (`enum`), an address (`url`,
`uri`), a code (a `pattern`: an anchor, an icon), a number, a boolean, an image's `src` are the same in every
language: so are the sections, their order and their lists' items. A field's `"translate": true|false` says otherwise.
- **What is kept**: only what is translated — a section's data is a part of its own (a list: an item by index, `null`
for one not translated), sanitized and checked against its type as it reads in the language (`Pages::saveDraft`,
errors as `translations/{lang}/sections/{i}/data/{key}`). A section that goes takes its translations with it, a
language that goes its translation.
- **What is not translated reads in the default language** (`Translations::Version($version, $lang, $types)`, used by
`Pages::render(..., $language)`): a new language shows the page as it is until it is translated.
- **Published**: the default language at the page's address, each other at `{lang}/{slug}` (`en/offers/coworking`,
`en` for the homepage), `` and `og:locale` set, the request speaking that language (`I18n::setLanguage()`: the
layout's and templates' own `I18n::_()` texts and the dates are in it — the preview too), `` to the others
(`PublicPageController::PageUrl($page, $lang)`, `PageUrls()`); the api gives `urls`.
In the page editor, the bar's « Languages » opens a drawer: the default language (what the texts are written in —
changing it translates nothing), the others — each its share of texts translated, the list of those that are not
(a link opens the section and its field, in that language), a « Translate » button, ✕ to remove it (undoable) — and
« Add the language » (any code; a new one is shown at once, in the default language's texts). With two languages or
more the bar has a language picker: in another language than the default one the form and the preview are in it (a
notice says so), its texts are what changes, the rest is shown « same in every language » and cannot be changed, the
sections cannot be moved, added or removed; a text still in the default language is marked « not translated ». The
translation is what differs from the default language (`PageEditor#draft()`, `#extractShown()`): a text left as it is
is not translated. The preview is `POST …/preview?language=`.
## A type of section
`sections/{name}.schema.json` is a JSON Schema object, with keys of the page editor's own:
```json
{"label": "Hero", "icon": "star", "order": 1, "description": "The opening of the page",
"properties": {
"title": {"type": "string", "format": "html", "editor": "inline", "label": "Title", "minLength": 1, "default": "..."},
"text": {"type": "string", "format": "html", "tools": "bold,italic,link|ul", "label": "Text"},
"cta": {"type": "object", "label": "Main button", "properties": {
"label": {"type": "string", "label": "Text"}, "url": {"type": "string", "format": "url", "label": "Link"}}},
"image": {"type": "object", "format": "image", "properties": {"src": {"type": "string"}, "alt": {"type": "string", "level": "standard"}}},
"items": {"type": "array", "items": {"type": "object", "properties": {"title": {"type": "string"}}}},
"anchor": {"type": "string", "level": "standard"}}}
```
- `format`: `text` (the default), `html` (``; `"editor": "inline"` keeps text formatting only, `tools` is
its toolbar), `url`, `image` (`{src, alt}`); an `array` of objects is a list of items.
- `level`: the display level from which a field's input is shown (none: every level; `standard`; `expert`) — the
page editor reads it, with `MMO.modes`.
- `default`: what a new section starts with (`Pages::newSection($type)`, the page editor's « Add a section »): an
example written in English and shown in the user's language (`SectionType::TranslateExample()`, `\MMO\core\I18n`;
the application's `i18n` or mmo.editor's `src/i18n/fr.php`) — but a choice (`enum`) and an address (`url`, `uri`),
kept as they are.
- Every object refuses the fields it does not declare (`additionalProperties: false` unless the schema says otherwise).
`sections/{name}.phtml` prints it with `$_VARS['f']`, a `SectionFields` (also `section` and `page`):
```php
root('cms-section my-hero') ?>>
```
`text()` escapes, `html()` prints the rich text sanitized again, `url()` gives `#` for an unsafe address, `image()` an
`` (nothing without an address), `get()`/`has()` test a value, `each()` loops a list. `root()`
gives the section its anchor (`anchor` field) and, out of the public page, `data-section`/`data-type`; `edit()` is
empty but at the expert level (`PageRenderer::EDIT`: `mmo-editor`, `data-path`). Write them on every section and
every field: the same template serves the public page, the preview and the expert level.
A layout (`layouts/{name}.phtml`, its name the file's: lowercase letters, digits, `-`, `_`) gets `page`, `content`
(the sections' html with their styles: print it whole, once, it is what the expert level wraps in
``), `sections` (each `{id, type, html}`) and `renderer` (`getStyles()`, for a layout placing the
sections itself); as soon as the file is in a `layouts/` of `templates`, the page editor offers it. The sections'
styles are every folder's `sections/sections.css` (mmo.editor's `templates/sections/sections.css` first, an
application's after it), added to every page whatever its layout: a layout styles its own frame only. The demo's
guide « Create a layout » (`http://localhost/MMO/master/libs/editor/layouts`, `demo/www/contents/pages/layouts/`) walks a
beginner through writing one, with the demo's own `article` (`demo/templates/cms/layouts/article.phtml`); its guide
« Create a section » (`.../sections`, `demo/www/contents/pages/sections/`) does the same for a type of section, with the
demo's own `faq` (`demo/templates/cms/sections/faq.{schema.json,phtml}`, `sections.css`) and its French
(`demo/js/fr.demo.js`, `demo/i18n/fr.php`). mmo.editor's
`landing` puts mmo-app in its `landing` shell (`$this['body']->setAttribute('class', 'landing')`) and styles the
sections with the theme's tokens (`--mmo-space-*`, `--mmo-color-*`...).
## The api (`PagesController`)
| route | |
|---|---|
| `GET admin/pages.json` | every page, without its sections |
| `POST admin/pages.json` | a new page `{slug, title, layout, seo, sections}`: 201, or 422 `{errors}` |
| `GET`, `PUT`, `DELETE admin/pages/{id}.json` | a page (draft with its `languages` and `translations`, `published` without its sections, `url`, `urls` by language, `rights`); `PUT` changes its draft; `DELETE` it and its revisions |
| `POST admin/pages/{id}/publish.json`, `/unpublish.json` | |
| `GET admin/pages/{id}/revisions.json?full=1` | `{number, date, author, title}`, the version with `full` |
| `POST admin/pages/{id}/rollback.json` `{number}` | |
| `POST admin/pages/{id}/preview?mode=edit&language=` | the html of a version that is not saved (the body: `{title, layout, seo, sections, languages, translations}`), in a language, `data-section` on each section; nothing saved |
| `GET admin/sections.json` | `{layouts, sections}`: the types with their schemas |
| `GET admin/pages/token.json` | the session's CSRF token |
| `GET admin/pages?page={id}` | the backoffice, an html page holding `` |
Someone logged in (401), with the scopes (403). A request changing something sends `X-CSRF-Token`
(`PagesController::Token()`, in the pages as `MMO.editor.config.token`, with `MMO.editor.config.pages` and
`.sections`, the api's urls), unless an `Authorization` header authenticates it. An error says why in
`meta.error.message`.
## The page editor (``, `MMO.editor.PageEditor`)
- It reads the api from `MMO.editor.config` (`pages`, `sections`, `token`), which the server writes only for a user
who may edit pages (`Pages::can($user, Pages::EDIT)`): a visitor's pages have nothing of the backoffice.
- **The level**: `MMO.modes`' (an application offering `simple`, `standard`, `expert` in `MMO\client\Modes::$levels`);
its switch calls `MMO.modes.set()`, which saves it and reloads the page (the draft saved first). Without those
levels, or with `level="…"`, the switch is the editor's own. A field's or a type's `level` (schema) says from which
level it is shown.
- **The layout**: one bar (the page picker and "New page", the page's state, its actions — page settings,
history, open, save, publish —, the level), then two panes: the sections' form and the preview, which fills the
height. What is occasional opens in a drawer on the right: the page's settings (address, title, layout, SEO, take
offline, delete), a new page, the history, a section's settings. The drawer open, the form steps aside: never more
than two columns. At the **expert** level the editor covers the whole window: the bar, and the page alone.
- **The form**: one input per field of the schema (text, `` for html with its `tools`, a select for an
`enum`, a checkbox, a number, an image — its address, or a file sent to mmo.edms —, a list of items); the sections
move, copy, go, and are added. A 422's errors show next to their fields.
- **The preview** is `POST …/preview` of the unsaved draft, a moment after each change, in an iframe (`srcdoc`, a
`` of the page): a click on a section opens its form. Its document is hooked once parsed, not on `load`,
which a request of the page staying open may hold back.
- **The expert level** renders the preview with `?mode=edit`: the sections are in ``, a double
click edits a field in place, and its `change` (the region stops being edited) writes into the draft by
`data-path` — the html of a rich field, the text of a plain one — without rendering the preview again. A toolbar
follows the pointer on each section: move, duplicate, remove, ⚙ its settings (images, links, options, lists: in
the drawer), add a section after it. A click selects a section; a double click edits a text.
- **Undo, redo**: the bar's ↶ ↷, Mod+Z and Mod+Shift+Z (Ctrl+Y): the draft's history, every change of the form,
the drawer, the preview's toolbar and the edits in place (changes made without a 600 ms pause are one step, 100
steps kept). A field keeps its own undo for its typing (``'s, an input's). Saving or publishing keeps
the history, a rollback is a step that can be undone, another page starts a new one; back to the draft as it was
saved, nothing is left to save.
- **Compare a revision with the draft**: when the page has mmo.git's `` (`MMO.git.DiffView`; mmo.editor does
not depend on mmo.git), each revision of the history has a « Compare » button: a dialog shows the revision and the
draft side by side, as indented JSON (`{title, layout, seo, sections}`), the changed words marked
(`PageEditor::compare()`, `PageEditor.CanCompare()`). Without mmo.git, the button is not there.
- Events: `open`, `saved`, `api` (each call: `{method, url, status, reason, errors}`).
## The section-type editor (``, `SectionTypesController`, `SectionTypeStore`)
The site's types of section made and changed in the browser: `admin/section-types` (an html page), and the demo's
« Section editor » (`http://localhost/MMO/master/libs/editor/section-editor`).
- **What it writes**: `sections/{name}.schema.json`, `{name}.phtml`, `{name}.css` in the folder `Pages::$typesFolder`
(the first of `templates` by default), which the web server must be able to write; it is read before the others.
Every other type (mmo.editor's, another folder's) is read only: « Copy to my site » copies it there, where it
replaces the other (deleting the copy brings the other back). The files a save replaces are kept in
`sections/.history/{name}/` (the last 10 saves).
- **The tabs**: « Fields » (the schema as a form: a kind per field — text, rich text, one-line rich text, link,
address, image, choice, yes/no, number, list of items with its own fields —, its label, help, level, example,
required, longest, toolbar, choices), « Schema » (the same json by hand, both kept in step), « Template » (the PHP;
« Generate from the fields » writes one to start from: `SectionTypeStore::generate()`), « Styles » (the type's
css, added after `sections.css`). A live preview renders a page holding one section of the type as it is, not saved.
- **What is checked before a save** (a 422, `errors` by `name`, `schema`, `template`, `css`): a name of its own, a
schema that is a json object whose examples (`default`) pass its own rules, a template that PHP parses
(`PhpToken::tokenize(..., TOKEN_PARSE)`), 200 KB at most a file.
- **⚠ A template is PHP that the server runs.** Whoever may use the editor runs code on the server, in production
too: its right is its own, `editor.sections#edit` (`Pages::$typesScope`, `Pages::checkTypes()`), not the pages',
and is for developers only. Every request changing something — a preview runs the template it is sent — needs the
session's CSRF token.
- **The api**: `GET admin/section-types.json` (the types, their `origin` — `site`, `library`, `other` —, `writable`),
`GET`/`PUT`/`DELETE admin/section-types/{name}.json` (`{schema, template, css}`, and `history`), `POST
.../{name}/copy.json` `{as}`, `POST admin/section-types/generate.json` `{name, schema}`, `POST
admin/section-types/preview` `{name, schema, template, css, data}`. `MMO.editor.config.sectionTypes` is its url
(written for whoever holds the right).
## Guided tours
`` gives mmo.client's `MMO.guide` (its `docs/mcp/Guide.md`) the tour of the level it shows, one per
level: `mmo-page-editor-simple` (the page picker, the sections, their texts, adding one, the preview, save, publish,
the other levels), `mmo-page-editor-standard` (every field, ordering the sections, the page's settings, the history),
`mmo-page-editor-expert` (editing in place, a section's toolbar and its ⚙, adding a section, the settings). It starts
by itself the first time the user meets that level, and the `?` of the editor's bar runs it again (the expert level
covers the top bar's). Its texts are English keys (`src/js/i18n/fr.pages.js`); its targets are the editor's own
nodes (`[data-pe="…"]`): change both together (`MMO.editor.PageEditor#tour()`).
## Images
`` sends an image to mmo.edms as it is: `POST edms/files.json` (a file in one request,
multipart: mmo.edms' route, its CHANGELOG's *Unreleased*). On a page that has edms (`MMO.edms.config`), no `upload-token` is needed:
```php
">
```
The page editor's image fields send theirs with `MMO.edms.Api#sendFile()` under `upload-labels` (`"#pages"`, the
user's own tag, by default; `MMO.editor.PageEditor.EdmsUploadUrl()` for its rich texts). An image of a published page
must be readable by its visitors: give it a label that grants that — choosing one is still an open point.
## Verifying
`php tools/mmo.php verify --lib=editor` (from the hub): lint, phpstan and `tests/` (`cms/TestPages`, `TestSanitizer`,
`TestRenderer`, `TestTranslations`, `controllers/TestPagesApi`; each class on a repository of its own, in a temporary folder).
The demo (`demo/`, an application of its own: `demo/mmo.json` adds mmo.edms) publishes a landing page at
`http://localhost/MMO/master/libs/editor/landing`, and its backoffice, `.../backoffice`, exercises everything above:
the pages, a form made from the schemas (with the display level), the live preview (a click on a section opens its
form; the expert level marks the fields edited in place), saving with the errors next to their fields, publishing,
taking offline, the history and its rollback, deleting, images to mmo.edms (in a rich text and in an image field), and
a panel of checks (a forged token, an invalid address, hostile html, an unknown field, a draft's public address, a
stale edms token). Its editor is the library's ``, with the checks and the api's log around it. The
demo's data is in apache's temporary folder.