Index
# Pages A site built on `mmo.client` does not register its pages in code. It puts them in a folder, drops an `index.json` next to them, and `MMO\client\Theme` turns the whole tree into the menu, the breadcrumbs and the routes that `MMO\client\controllers\PageController` serves. ## Where the pages live The theme is told which folders to read: ```json "MMO\\client\\Theme": { "pages": ["{basepath}/contents/pages"] } ``` Every `index.json` **anywhere under** one of those folders is loaded, and its own folder — relative to the pages root — is the url it describes. `contents/pages/guides/index.json` describes `/guides`; the files beside it are its pages. ## index.json ```json { "label": "Application", "icon": "desktop", "group": "Framework", "order": 1, "default": "admin", "files": { "admin": { "label": "Admin", "title": "Admin shell", "icon": "table-cells-large", "description": "The application shell on real content." }, "landing": {"label": "Landing", "title": "Landing page", "icon": "bullhorn"} } } ``` That is `contents/pages/app/index.json`, and it produces `/app/admin` and `/app/landing`, with `/app` itself serving `admin`. ### Keys of a folder | key | | |---|---| | `path` | the url segment. Defaults to the folder's own name, and setting it overrides the whole path | | `label` | what the menu shows. Falls back to the capitalised `path` | | `title` | the long name, used for the page's `` and its heading | | `description` | goes into the page's meta description | | `image` | the image for the Open Graph tag | | `icon` | an icon name, or a utf-8 character | | `group` | the menu section this folder is filed under | | `order` | sorts among siblings. Equal orders keep declaration order | | `default` | the child served when the folder itself is requested | | `query` | query parameters the content needs | | `inMenu` | `false` keeps it out of the menu but still reachable | | `visible` | `false` hides it entirely | | `css` | the class of the `<mmo-app>` shell on its pages (`admin`, `landing`, `blog`, ...), instead of the theme's. Its pages inherit it | | `files` | the pages of this folder | ### Keys of a file An entry of `files` takes every key above, plus: | key | | |---|---| | `file` | the template to render. **Defaults to** `<key>.phtml`, resolved next to the `index.json` | | `data` | anything the page itself wants to read | So `"admin": {...}` renders `admin.phtml` at `/app/admin` unless `file` says otherwise. A key is a url segment, not a filename: give `file` explicitly when the two differ. ## The landing page The root (`/`) serves whatever `PageController` is configured to serve — a folder name, or a `folder/file` pair: ```json "MMO\\client\\controllers\\PageController": { "homepage": "pitch" } ``` ## A folder without a page A folder requested without a `default` is a category: `PageController` renders it with `category.phtml`, imported once with the folder as `$_VARS['content']`. It looks the template up on the document's import path, so a `category.phtml` at the root of one of the theme's `pages` folders replaces it; with none, the package's own `src/templates/category.phtml` is used — the folder's sub-folders as topic cards, its own pages below them, and a filter bar over the whole tree (styles in `styles/parts/topics.scs`). A topic card lists 6 children and folds the rest; set `limit` in the vars to change it: ```php <?php $this->import(\MMO\filename('{MMO\client}/templates/category.phtml'), ['content' => $_VARS['content'], 'limit' => 10]); ?> ``` The template name itself is the controller's `categoryTpl` setting. ## What a page file is A page is a `.phtml` rendered into the document the theme has already set up: the `<mmo-app>` shell, the stylesheet, the compiled javascript and the icon sprite are in place before it runs. What it echoes becomes the content of `<mmo-app>`. ```php <article> <h1>My page</h1> <p>Ordinary html, plus any custom element the client provides.</p> </article> ``` `PageController` picks what to do from the extension of the entry's `file`: | extension | what the page becomes | |---|---| | `.phtml` | `$document->import($file, $data)` -- php, run once per request | | `.md` | the file's text inside a `<mmo-markdown>`, rendered in the browser | | anything else | a link to the file | ## The page file (.phtml) ### How it runs The file is `require`d by `MMO\core\documents\Document::import()`, inside that method. So a page is plain php, and two things are in scope: - `$this` -- the `HtmlDocument` being built (an `MMO\core\documents\HtmlDocument`), not a controller. - `$_VARS` -- an array of variables. They are **not** extracted: write `$_VARS['route']`, never `$route`. Whatever the file echoes is captured and added to the document; an exception thrown inside it is logged and printed in the page's place rather than taking the whole document down. ### `$_VARS` `$_VARS` is built from two sources, and the first wins: 1. the entry's **`data`** in `index.json` -- the variables given to this `import()`; 2. the variables of the document, which every `import()` gets: | key | what it is | set by | |---|---|---| | `document` | the `HtmlDocument` itself -- the same object as `$this` | `Document::__construct` | | `app` | the `MMO\core\Application` -- **not** the `<mmo-app>` element | `Response::__construct` | | `route` | the current `MMO\core\server\Route` | `Response::__construct` | | `controller` | the route's controller -- here the `PageController` | `Response::__construct` | ```json "files": { "pricing": {"label": "Pricing", "data": {"plans": ["solo", "team"], "currency": "€"}} } ``` ```php <?php foreach ($_VARS['plans'] as $plan): ?> <section class="card"><?= htmlspecialchars($plan) ?> · <?= $_VARS['currency'] ?></section> <?php endforeach; ?> ``` A `data` key with one of those four names **shadows** it: `"data": {"route": 1}` leaves the page without its route. Keep `data` to names of your own. ### `$_VARS['document']`: reaching the shell The document is an array over its nodes, and the theme names two of them. After `MMO\client\Theme` has configured the document, `$_VARS['document']['app']` -- and `$_VARS['document']['body']`, which the theme repoints at the same node -- is the `<mmo-app>` element itself, an `MMO\core\helpers\XmlNode`. A key that starts with `@` reads or writes one of its attributes. The shell's template **is** its class (`admin`, `landing` or `blog` -- see `<mmo-app>`), so one line in a page changes the whole layout of that page: ```php <?php $_VARS['document']['app']['@class'] = 'blog'; ?> <article class="prose"> <h1>Release notes</h1> ... </article> ``` The site is `admin` everywhere (the theme's `rootAttributes`), and this page alone renders with the editorial shell: no rail, a sticky header, one content column and the footer. `landing` gives the marketing shell the same way. The class is written in the html the server sends, so there is no flash of the admin layout before the switch. The same node takes any other attribute or class: ```php <?php $app = $_VARS['document']['app']; //or $this['app'] $app['@class'] = 'landing'; //replaces the whole class: the template $app->addCSSClass('menu-collapsed'); //adds one, keeps the rest: the rail starts reduced to icons unset($app['@id']); //removes an attribute (don't: the app is found by its id) ?> ``` To change the layout of a whole site rather than of one page, set it once in the configuration: ```json "MMO\\client\\Theme": { "rootAttributes": {"id": "app", "class": "landing"} } ``` `$_VARS['document']['head']` is the `<head>` node the same way. The shell only exists in a document the theme configured: in a raw document (an email body) `['app']` is `null`. ### Filling the shell's regions The page's html lands in the light dom of `<mmo-app>`, so a **top-level** element with a `slot` attribute goes to that region of the shell, and a region no slot fills removes itself from the layout: ```php <?php $_VARS['document']['app']['@class'] = 'blog'; ?> <section slot="hero" class="blog-hero"><h1>The journal</h1></section> <article class="prose">...</article> <aside slot="aside">On this page ...</aside> ``` `hero` turns on the hero band (`has-hero`), `aside` the sticky rail of the `blog` template (`has-aside`), `page-title`/`page-actions` the page header of `admin` (`has-page-head`). A `slot` only counts on a direct child of `<mmo-app>`: inside the page's own `<article>`, it does nothing. ### What the page can change, and what the controller overwrites `$this` is the document, so the page can use its whole api: ```php <?php $this->addStylesheet(\MMO\url('css/charts.css', '{assets}')); $this->addScript(\MMO\url('js/charts.js', '{assets}')); $this->setMeta('og:image', \MMO\url('og/pricing.png', '{assets}'), 'property'); $this->addImportPath(__DIR__ . '/parts', true); //partials of this folder first ?> ``` `PageController` runs the page **before** it sets the title and the description, so these two are written over: `setTitle()` in a page is lost (the title is `<app title> · <entry title>`), and `setMeta('description')` survives only for an entry without a `description`. Give both in `index.json` instead. ### The page entry and its tree The controller knows which entry it is serving, with its parent and children, so a page can build its own table of contents: ```php <?php $content = $_VARS['controller']->getMainContent(); ?> <h1><?= htmlspecialchars($content->getTitle()) ?></h1> <ul> <?php foreach ($content->getParent()->getChildren() as $sibling): ?> <li><a href="<?= $sibling->url() ?>"><?= htmlspecialchars($sibling->getLabel()) ?></a></li> <?php endforeach; ?> </ul> ``` ### Partials `import()` looks relative names up on the document's import path -- every `pages` folder of the theme, then its `imports` -- so partials live next to the pages. Its third argument says where the output goes: ```php <?php $this->import('parts/header.phtml'); //appended to the body, where it is echo $this->import('parts/card.phtml', ['id' => 3], false); //returned instead, and echoed here ?> ``` In a page, use `false` and `echo`. With the default (`'body'`), the partial is added to the body as a node of its own, **after** the capture of the page that called it -- so it lands below the whole page, not where the call is. The partial gets its own `$_VARS`: what you pass, plus the document's variables -- not the caller's `data`. Pass `$_VARS` on to hand it all down: ```php <?= $this->import('parts/status.phtml', $_VARS, false) ?> ``` A relative name is resolved against the import path, **not** against the calling file: `'../parts/x.phtml'` or `'solutions/menu.phtml'` mean the same from every page. And the name is literal -- `'card.phtml'`, never `'card'`. A partial reads its optional arguments with `??`, and can let the url win over them -- one list template then serves a dozen one-line pages, and the address bar can still override it: ```php <?php //entities.phtml $fields = $_VARS['route']->get('fields', $_VARS['fields'] ?? null); $class = $_VARS['class'] ?? ''; ?> ``` ```php <?php //rh/employees.phtml -- the whole page ?> <?= $this->import('entities.phtml', ['collection' => 'employees', 'fields' => 'name,firstname', 'class' => 'fullscreen'], false) ?> ``` ### One template, several pages The `file` and `data` of an entry make a `.phtml` reusable without a partial: two entries, one file, and what differs is in `data`. `file` is relative to the `index.json`, so it can reach a template higher up: ```json "files": { "rates": {"label": "Rates", "file": "rates.phtml", "data": {"collection": "rates"}}, "imputed": {"label": "Imputed", "file": "rates.phtml", "data": {"collection": "imputedRates"}}, "documents": {"label": "Documents", "file": "../../documents.phtml", "query": "id={id}", "data": {"collection": "accidents"}} } ``` An entry whose `query` holds a placeholder (`id={id}`) needs a parameter to make sense, so it stays out of the menu on its own. ### Reading the request ```php <?php $route = $_VARS['route']; $page = (int)$route->get('page', 1); //?page=2, with a default $zoom = $route->get('zoom', 'week'); ?> ``` **Throwing does not answer an error.** `import()` catches whatever the page throws, logs it and prints it where the page was: an `e404` thrown in a page is a 200 with an error message in it. Act on the route's response instead -- `redirect()` replaces it, and `withStatus()` keeps the page but changes its code: ```php <?php $route = $_VARS['route']; if (!$post) {$route->setResponse($route->getResponse()->withStatus(404)); ?> <p>This post does not exist.</p> <?php return; } if ($post->isDraft()) {$route->redirect(\MMO\url('blog')); return;} ?> ``` Neither `redirect()` nor `stop()` throws: the rest of the file still runs, so `return` after them. A top-level `return` simply ends the page -- what was echoed so far is kept. `stop()` also lets a page answer something other than html -- an export of the table it shows, a form it handles itself: ```php <?php use MMO\core\server\Response; $route = $_VARS['route']; if ($route->get('export') === 'xlsx') {$route->stop(Response::Xlsx(buildSheet($stats))); return;} if (($route->isPost()) && ($post = $route->getRequestBody())) { //save $post, then show the page again } ?> ``` A missing *page* is a 404 already: `PageController` throws it, before any page runs, for a path no entry matches -- and for an entry marked `"visible": false`, a 401. ### Escaping A page is php: `<?= $x ?>` prints raw. Use `htmlspecialchars()` for text, and `\MMO\core\printers\JsonPrinter::Encode()` to hand a value to javascript: ```php <script> const plans = <?= \MMO\core\printers\JsonPrinter::Encode($_VARS['plans']) ?>; MMO.domReady(() => document.getElementById('tree').value = <?= json_encode($selected) ?>); </script> <mmo-filter value="<?= htmlspecialchars(json_encode($filter)) ?>"></mmo-filter> ``` JSON is a javascript literal: write it bare, never inside quotes. `JSON.parse('<?= json_encode($x) ?>')` and `'<?= $name ?>'` break on the first apostrophe, and are an injection when the value comes from a user. In an attribute, the JSON goes through `htmlspecialchars()` like any other text. ### Pieces every page shares What belongs to every page -- the user's name in the top bar, the logo, a global `<style>` -- is not repeated in each file. A listener on `DocumentInit` imports it into every html document before the page runs, and since it writes into `<mmo-app>` too, its top-level `slot`s fill the shell: ```php //bootstrap.php of the project \MMO\core\events\DocumentInit::AddListener(function (\MMO\core\events\DocumentInit $event) { if (($event->document instanceof \MMO\core\documents\HtmlDocument) && (!$event->document->isRaw())) { $event->document->import('common.phtml'); } }, 100); ``` The order (`100`) puts it after the theme's own listener: before that, there is no `<mmo-app>` to write into and no `pages` folder on the import path. ```php <?php //common.phtml ?> <a slot="logo" href="<?= \MMO\url('') ?>"><img src="<?= \MMO\url('logo.svg', '{assets}') ?>" alt="" height="24"/></a> <div slot="user-info"><?= htmlspecialchars(\MMO\oauth\user()?->getUsername() ?? '') ?></div> ``` Such a file is also the place for the php functions the pages share: it is `require`d once per request, before them. ### Special templates A few names are looked up on the import path, so the first `pages` folder that has one overrides it: | template | rendered for | its `$_VARS` | |---|---|---| | `category.phtml` | a folder without `default` (see above) | `content`, `limit` | | `entity.phtml` | `PageController::routeEntity` (`entity?uid=…`) -- no default, the project writes it | `entity` | | `error.phtml` | any exception of a route, via `Theme::createErrorResponse` -- without one, the application's plain error page | `status` (the http code), `error` (the exception) | ```php <?php //error.phtml $status = $_VARS['status']; if ($status === 503) {$this->import('maintenance.phtml'); return;} ?> <div class="panel error"> <h1><?= \MMO\core\server\statusMessage($status) ?></h1> </div> ``` ### Templates outside the page tree: emails `MMO\core\controllers\MailController` renders its templates with the same `import()`, in a **raw** `HtmlDocument`: the theme leaves it alone, so there is no `<mmo-app>` (`$this['body']` is the real `<body>`), no stylesheet, and no `app`/`route`/`controller` in `$_VARS` -- only `document` and the data given to the mail. A page can still show such a template inside the application, to preview it. `$_VARS` only has to be an `ArrayAccess`, so an object that answers `{name}` for every missing key prints the template with its holes visible: ```php <?php $vars = new class implements \ArrayAccess { protected array $data = []; //only what was really set exists, so import() still adds `document` and the others public function offsetExists(mixed $k): bool {return array_key_exists($k, $this->data);} public function offsetGet(mixed $k): mixed {return $this->data[$k] ?? '{' . $k . '}';} public function offsetSet(mixed $k, mixed $v): void {$this->data[$k] = $v;} public function offsetUnset(mixed $k): void {unset($this->data[$k]);} }; $this->addImportPath(dirname(__DIR__) . '/emails', true); ?> <?= $this->import('welcome.phtml', $vars, false) ?> ``` ## References ### See also - [The JHS format](JHS.md) - [Styles](Styles.md) ## Tags <#page>, <#theme>, <#menu> </mmo-markdown></mmo-app></body></html>