MMOframework
Pourquoi l'IA Playground Bibliothèques Documentation Rechercher Agence
FR EN
Index
# 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') ?>> edit('title') ?>>html('title') ?> text('cta.label') ?> image('image', ['sizes' => '50vw']) ?> each('items') as $item) { ?>

text('title') ?>

``` `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.