Five Viewers demo

Hi there!

Welcome to Five Viewers demo! This web application is an example of how you can build a simple website containing 5 viewer modules and 1 poller module.

What do these terms mean?

Viewer is a special module corresponding to a URL. The goal of viewers is to generate static content associated with a specific type of URL. This could be an HTML page, an XML document, a plain text document, and so on.

Poller is similar to a viewer module. It is also a special module, and it also matches a specific URL. But the purpose of pollers is to generate dynamic content, such as JSON for example.

Let's get started

Imagine you want to create a website with:

  • a home page,
  • a list of channels
    • plus their individual pages,
  • an Error404 page,
  • a robots.txt document,
  • a sitemap XML document,
  • and a checkpoint which is an API interface, that is, dynamically requests a random message from random channel via Javascript, for example once every 10 seconds.

As a result, 5 types of web pages and 1 periodically polled checkpoint were identified. Let's call each type by some conventional name. It will become the name of the corresponding viewer or poller module.

  1. Home
  2. Channels
  3. Error404
  4. RobotsTxt
  5. Sitemap
  6. Api

In accordance with these page types, 6 types of URLs were also allocated.



Please note that the last URL already has a segment polls linking it to a poller module. The module name Api written after that segment.

Also note that the second URL contains a segment channels linking it to a viewer module called Channels.

The fourth URL contains a segment robots.txt linking it to a viewer module called RobotsTxt.

And finally, the fifth URL contains a segment sitemap linking it to a viewer module called Sitemap.

Other types of URLs are invalid and automatically link to a viewer module called Error404.

Okay

If you are a developer, you should now go through all the steps of web app creation.

In fact, these steps have already been completed, and their result has been added to the MiMiMi framework installation package. You can download its latest version and look at the five.viewers folder there.

But for learning purposes, imagine that such steps have yet to be taken. This approach will allow you to look at the process of creating a web application from the developer's point of view.

So let's look at each step separately.

Photo by Andrea De Santis on Unsplash

The development will be done below in PHP 7.3+ using a MySQL database. And you should begin by writing the main module of your application.

Step 1 create an application

Please download the MiMiMi framework from the official site and extract it to your computer.

Let me remind that the five.viewers folder already exists there, so either delete it first or pretend it doesn't exist.

So you will see a folder structure like this.

├─> [+] five.viewers
├─> [+] media
├─> [+] mimimi.core
├─> [+] mimimi.install
├─> [+] mimimi.modules
├─> [+] newspaper
├─> [+] tiny.news.feed
├─> .htaccess
├─> favicon.ico
└─> index.php

What is each folder for?

  • five.viewers is a folder containing a demo application called Five Viewers.
  • media is a folder for storing media files used on the pages of your site. The installation package contains in this folder various demo images used in demo applications.
  • mimimi.core is a folder containing a small set of basic classes and routines that you can use to develop modules for your site.
  • mimimi.install is a folder containing a web application to install framework components.
  • mimimi.modules is a folder containing a small set of ready-made modules that can be immediately used on your site.
  • newspaper is a folder containing a demo application called Newspaper.
  • tiny.news.feed is a folder containing a demo application called Tiny News Feed.

Let's begin

Create a folder five.viewers there, and in it a file Application.php with the following contents.

<?php
/** ------------------------------------------------------------------------
 * The main module for receiving requests. It is always called using run()
 * methow implemented in the mimimi.core/Website.php file. The initiator
 * of that call is the root index.php file of your website.
 * ---------------------------------------------------------------------- */

    mimimiInclude ( 'Website.php' );
    class MyMimimiApplication extends MimimiWebsite {

        /** ----------------------------------------------------------------
         * Define initial options.
         * -------------------------------------------------------------- */

        protected $viewerCollector = '';
        protected $pollerCollector = '';
        protected $systemCollector = '';
        protected $allowedPollers  = 'api';
        protected $allowedViewers  = 'home, channels, error404, robots_txt, sitemap';

        /** ----------------------------------------------------------------
         * Reset namespace simulator.
         * -------------------------------------------------------------- */

        protected $myNodeFile = __FILE__;
    };

How does this work?

  1. In the first line, we load a file mimimi.core/Website.php containing a base class MimimiWebsite. It is designed for a web-based application and is a simple router that recognizes the requested viewer module by first segment of URL.
  2. Then we extend the base class to the MyMimimiApplication class, which our application will become, and specify the necessary parameters in its properties.

What do the properties mean?

The $allowedPollers and $allowedViewers properties define comma-separated names of poller modules or visual modules that can be referenced by URL.

This is a kind of security setting that prevents a third-party module from being intentionally launched, even if it was accidentally left in the application folder.

The $viewerCollector, $pollerCollector, and $systemCollector properties define module names that will collect all related modules in a collector's folder.

Best practices recommend moving modules of the same type into special folder to better organize your app's file structure.

We are ignoring this recommendation because it is a demo app.

What does your build look like now?

├─> [-] five.viewers
│       └─> Application.php├─<─ property $viewerCollector├─<─ property $pollerCollector├─<─ property $systemCollector├─<─ property $allowedPollers├─<─ property $allowedViewers└─<─ property $myNodeFile
├─> [+] media
├─> [+] mimimi.core
├─> [+] mimimi.install
├─> [+] mimimi.modules
├─> [+] newspaper
├─> [+] tiny.news.feed
├─> .htaccess
├─> favicon.ico
└─> index.php

A blinking green dot at the end of the file name indicates recently created folders or files. Green strings indicates functions or methods added to the file.

Okay, let's move on

The application module implemented in step 1 above is the central component of your site. Its task is to receive an incoming browser request, parse the URL to identify the associated viewer or poller, and pass control to that module.

Therefore, the remaining steps will be devoted to creating viewer modules. According to the initial task, we planned to develop the following set of modules:

  • Home is a viewer module for displaying the contents of the home page.
  • Error404 is a viewer module for displaying an error page.
  • RobotsTxt is a viewer module for generating a robots.txt document.
  • Sitemap is a viewer module for generating a sitemap XML document.
  • Api is a poller module for requesting a message.
  • Channels is a viewer module for displaying a list of channels or a channel page.

In MVC terminology, each viewer module is a thin Controller that accepts URL parameters via method run($params) and translates them into directives for a Model and View.

The Model can, in principle, be represented either by a module working with a database table, or simply by a method of the Controller itself.

The View can be represented by a template file located in your application's themes folder. It is the five.viewers/Themes/default folder.

Photo by Birmingham Museums Trust on Unsplash

Let's start with the first viewer module. Its name is Home.

Step 2 create a home page

The whole process consists of four substeps.

  • The first is creating a Controller, that is, writing a program logic that describes how to serve an incoming request from the user's browser.
  • The second substep is creating a Model, that is, writing a logic that describes how to query data from the database if it is needed for this page.
  • The third substep is creating a View, that is, marking up a content template that describes how to send a response to the browser.
  • The fourth substep is also templates called snippets, which describe how to generate some blocks that are repeated on each page.

Well, shall we get started? Okay, let's go.

Substep 2.1: Controller

Please create a subfolder Home, and in it a file Home.php with the following contents.

<?php
/** ------------------------------------------------------------------------
 * The viewer module for displaying a home page. It is always called using
 * run() method implemented below. The initiator of that call is the main
 * module of your application.
 * ---------------------------------------------------------------------- */

    mimimiInclude ( 'Module.php' );
    class MyMimimiHome extends MimimiModule {

        /** ----------------------------------------------------------------
         * Displays the contents of home page.
         * Only two forms of URL are processed by this method:
         *     https://your.site/
         *     https://your.site/home
         * Another forms are incorrect:
         *     https://your.site/home/UNDEFINED-OPTIONS
         *                            └──────┬────────┘
         *                                   └─> it is the incoming $params
         * @param  mixed $params The rest of page's URL if detected by the app's routing method.
         * @return bool          TRUE  if the page was rendered successfully.
         *                       FALSE if the page URL is incorrect and an Error404 page needs to be displayed.
         * -------------------------------------------------------------- */

        public function run ( $params = '' ) {
            return $params == ''
                && $this->app->renderTemplate ( 'home.tpl',
                                                'Oops, this site does not have a home page template!' );
        }
    };

How does this work?

  1. The first line of code loads a file mimimi.core/Module.php containing a base class MimimiModule. It is designed as a miniature basis for any module and declares just two public properties ($app, $owner) and an empty method run().
  2. Then we extend the base class to the MyMimimiHome class, which will become the home page viewer.
  3. Next, we write a method algorithm that will generate HTML content for this page, keeping in mind that the method accepts a parameter $params as input, which contains the rest of the requested URL.

Please note that due to the specifics of the app's router, the run() method is called for any root URLs or for any URLs starting with "home".

However, only two forms of URL is processed by this method:

Another forms of the locator are incorrect and are therefore ignored, causing the app's router to display them later using the visual module Error404. This is what those incorrect forms look like:

Ignoring is done using a directive $params == '' above.

Substep 2.2: Model

The home page does not require you to query any database entries for it. Therefore, we do nothing at this substep.

Substep 2.3: View

Please markup a template file for rendering HTML content of the home page. Create a subfolder Themes/default, and in it a file home.tpl with the following contents.

<?php
    /** --------------------------------------------------------------------
     * Send headers to the user's browser.
     * ------------------------------------------------------------------ */

    sendHeaderHTML ( );
    sendStatus200  ( );
    stopIfHead     ( );

    /** --------------------------------------------------------------------
     * Generate page content.
     * ------------------------------------------------------------------ */

?><!DOCTYPE html>
<html lang="en-US" class="home-page">
    <head>
        <base href="<?php printSiteUrl ( ) ?>">
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <meta name="robots"   content="index, follow">
        <title>
            Welcome to Five Viewers demo!
        </title>
        <meta name="description"  content="This web app is an example of how you can build a simple website.">
        <link rel="canonical"     href="<?php printSiteUrl  ( ) ?>">
        <link rel="stylesheet"    href="<?php printThemeUrl ( ) ?>css/styles.css">
        <link rel="shortcut icon" href="<?php printThemeUrl ( ) ?>images/favicon.ico" type="image/ico">
    </head>
    <body>
        <div class="page">
            <?php mimimiModule ( 'snippets/header.tpl' ) ?>
            <?php mimimiModule ( 'snippets/menu.tpl'   ) ?>

            <main class="content">
                ... an HTML ...
            </main>

            <aside class="hint">
                ... an HTML ...
            </aside>

            <?php mimimiModule ( 'snippets/footer.tpl' ) ?>
        </div>
        <script src="<?php printThemeUrl ( ) ?>js/scripts.js"></script>
    </body>
</html>

How does this work?

  1. The first lines of this template pass the necessary headers to user's browser. To do this, routines such as sendHeaderHTML(), sendStatus200(), and stopIfHead() are used. They are implemented in a system module Helper. It is located in a file mimimi.modules/Helper/Helper.php
  2. The next lines generate a page content. Two routines used here are printSiteUrl() and printThemeUrl(). They are also implemented in the Helper module.
  3. There is another routine called mimimiModule(). It is located in a file mimimi.core/Routines.php and is used to include one template file within another.

Substep 2.4: snippets

What are snippets?

They are HTML fragments that repeat on different pages and have the same logic and markup.

So you should create a subfolder Themes/default/snippets, and in it the following three files.

The first file, named header.tpl, is used to generate the topmost block of the page.

<header class="header">
    Five Viewers demo
</header>

The second file, named footer.tpl, is used to generate the bottommost block.

<footer class="footer">
    <div class="copyright">
        &copy; 2024 The Demo Site
    </div>
    <div class="credits">
        Powered by <a href="https://mimimi.software/" rel="nofollow" tabindex="-1">
                       MiMiMi
                   </a>
    </div>
</footer>

The third file, named menu.tpl, is used to generate the left sidebar.

<nav class="menu">
    <a class="home-link"     href="<?php printSiteUrl ( ) ?>"        > Home     </a>
    <a class="channels-link" href="<?php printSiteUrl ( ) ?>channels"> Channels </a>

    <a class="error404-link"
       href="<?php printSiteUrl ( ) ?>abracadabra"
       rel="nofollow"> Error 404 </a>

    <a class="servo"
       href="<?php printSiteUrl ( ) ?>sitemap"
       rel="nofollow"
       target="_blank"> Sitemap </a>

    <a class="servo"
       href="<?php printSiteUrl ( ) ?>robots.txt"
       rel="nofollow"
       target="_blank"> Robots.txt </a>

    <a class="servo no-gap"
       href="<?php printSiteUrl ( ) ?>polls/api/v1/random"
       rel="nofollow"
       target="_blank"
       title="Random message from all database"> Checkpoint All </a>

    <a class="servo"
       href="<?php printSiteUrl ( ) ?>polls/api/v1/random/from/1"
       rel="nofollow"
       target="_blank"
       title="Random message from first channel only"> Checkpoint One </a>

    <div>You can click a checkpoint above to see it as JSON. The <b>Checkpoint All</b> will also be updated every 10 seconds below to demonstrate how a polling works.</div>

    <div class="idle"
         data-poller="api"
         data-version="v1"
         data-command="random"
         data-options=""
         data-timer="10"
         data-success="displayRandom"></div>
</nav>

On a production site, you can remove any DIVs and anchors that contain a class error404-link or servo, as they are used for demostration purposes only.

The data-command="random" is an attribute for marking the container that will receive a response after each request to the API with the random command.

The data-options is an attribute for passing some parameters to that command via URL.

For example, if data-command="random" and data-options="from/2", the API request will be made via the locator.

In addition to these server side files, there are 3 more client side files:

  • favicon.ico to assign an icon to your application's shortcut.
  • styles.css for defining styles for your app's design.
  • scripts.js for declaring scripts for the client side of your website.

You can view any of these files right now.

What does your build look like now?

├─> [-] five.viewers
│       ├─> Home
│       │   └─> Home.php
│       │       └─<─ run()
│       ├─> Themes
│       │   └─> default
│       │       ├─> css
│       │       │   └─> styles.css
│       │       ├─> images
│       │       │   └─> favicon.ico
│       │       ├─> js
│       │       │   └─> scripts.js
│       │       │       ├─<─ findTimes()
│       │       │       ├─<─ extractTime()
│       │       │       ├─<─ fixTimes()
│       │       │       ├─<─ displayRandom()
│       │       │       └─<─ renderMessage()
│       │       ├─> snippets
│       │       │   ├─> footer.tpl
│       │       │   ├─> header.tpl
│       │       │   └─> menu.tpl
│       │       └─> home.tpl
│       └─> Application.php├─<─ property $viewerCollector├─<─ property $pollerCollector├─<─ property $systemCollector├─<─ property $allowedPollers├─<─ property $allowedViewers└─<─ property $myNodeFile
├─> [+] media
├─> [+] mimimi.core
├─> [+] mimimi.install
├─> [+] mimimi.modules
├─> [+] newspaper
├─> [+] tiny.news.feed
├─> .htaccess
├─> favicon.ico
└─> index.php

A blinking green dot at the end of the file name indicates recently created folders or files. Green strings indicates functions or methods added to the file.

Great, let's continue

Now you should handle any cases where a user visits a page at an incorrect URL.

Such a case is called Error 404, which is slang for the situation where a web server should return the HTTP status code "404 Not Found" when a user visits a page that has been moved or deleted.

According to Wikipedia, the term "404 Not Found" was coined by Berners-Lee himself, who explained in a 1998 interview that he wanted to make the error message "slightly apologetic".

The first documented case of a 404 error appearing on a web page was in 1993, when a user tried to access a page about the Mosaic web browser on the NCSA website.

Since then, 404 errors have become one of the most common and recognizable errors on the Web. Many websites have customized their 404 pages with creative designs, messages, or features to entertain or assist their visitors. For example, Google's 404 page features a broken robot and a link to its homepage, while GitHub's 404 page shows a random image of a parallax star field and a link to its status page.

Photo by Georgi Kalaydzhiev on Unsplash

So, let's continue with the second viewer module called Error404.

Step 3 create an Error404 page

Here you will also need to go through 3 substeps.

Substep 3.1: Controller

You should go back to the root folder of your app and create a subfolder Error404 there, and in it a file Error404.php with the following contents.

<?php
/** ------------------------------------------------------------------------
 * The viewer module for displaying a page at an incorrect URL. It is always
 * called using run() method implemented below. The initiator of that call
 * is the main module of your application.
 * ---------------------------------------------------------------------- */

    mimimiInclude ( 'Module.php' );
    class MyMimimiError404 extends MimimiModule {

        /** ----------------------------------------------------------------
         * Displays the contents of error page.
         * This method handles the following URLs:
         *     https://your.site/UNDEFINED-URL
         *                       └────┬──────┘
         *                            └─> it is the incoming $params
         * @param  mixed $params The relative page URL as discovered by the app's routing method.
         * @return bool          TRUE if the page was rendered successfully.
         * -------------------------------------------------------------- */

        public function run ( $params = '' ) {
            return $this->app->renderTemplate ( 'error-404.tpl',
                                                'Oops, this site does not have a template for Error 404 case!' );
        }
    };

How does this work?

  1. Here, similar to handling the home page, the file with a base class MimimiModule is also loaded.
  2. Then it is extended to the required class MyMimimiError404.
  3. And then the desired algorithm for generating page content is written in its method run().

Please note that this method always returns TRUE if a template file error-404.tpl was rendered successfully.

Substep 3.2: Model

Similar to handling the home page, the Error404 page also does not require you to query any database entries for it. Therefore, we do nothing at this substep.

Substep 3.3: View

Markup a template file for rendering HTML content of the Error404 page. Go to the subfolder Themes/default and create a file error-404.tpl inside with the following contents.

<?php
    /** --------------------------------------------------------------------
     * Send headers to the user's browser.
     * ------------------------------------------------------------------ */

    sendHeaderHTML    (   );
    sendStatus404     (   );
    sendHeaderExpires ( 0 );
    stopIfHead        (   );

    /** --------------------------------------------------------------------
     * Generate page content.
     * ------------------------------------------------------------------ */

?><!DOCTYPE html>
<html lang="en-US" class="error404-page">
    <head>
        <base href="<?php printSiteUrl ( ) ?>">
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <meta name="robots"   content="noindex">
        <title>
            Page not found / Five Viewers demo
        </title>
        <link rel="stylesheet"    href="<?php printThemeUrl ( ) ?>css/styles.css">
        <link rel="shortcut icon" href="<?php printThemeUrl ( ) ?>images/favicon.ico" type="image/ico">
    </head>
    <body>
        <div class="page">
            <?php mimimiModule ( 'snippets/header.tpl' ) ?>
            <?php mimimiModule ( 'snippets/menu.tpl'   ) ?>

            <main class="content">
                <section class="message modal">
                    <h1>Page is not found!</h1>
                    <p>Oops, something went wrong! There is no such page on this site.</p>
                    <nav class="buttons">
                        <a class="btn" href="<?php printSiteUrl ( ) ?>">
                            Close
                        </a>
                    </nav>
                </section>
            </main>

            <aside class="hint">
                ... an HTML ...
            </aside>

            <?php mimimiModule ( 'snippets/footer.tpl' ) ?>
        </div>
        <script src="<?php printThemeUrl ( ) ?>js/scripts.js"></script>
    </body>
</html>

What does your build look like now?

├─> [-] five.viewers
│       ├─> Error404
│       │   └─> Error404.php
│       │       └─<─ run()
│       ├─> Home
│       │   └─> Home.php
│       │       └─<─ run()
│       ├─> Themes
│       │   └─> default
│       │       ├─> css
│       │       │   └─> styles.css
│       │       ├─> images
│       │       │   └─> favicon.ico
│       │       ├─> js
│       │       │   └─> scripts.js
│       │       │       ├─<─ findTimes()
│       │       │       ├─<─ extractTime()
│       │       │       ├─<─ fixTimes()
│       │       │       ├─<─ displayRandom()
│       │       │       └─<─ renderMessage()
│       │       ├─> snippets
│       │       │   ├─> footer.tpl
│       │       │   ├─> header.tpl
│       │       │   └─> menu.tpl
│       │       ├─> error-404.tpl
│       │       └─> home.tpl
│       └─> Application.php├─<─ property $viewerCollector├─<─ property $pollerCollector├─<─ property $systemCollector├─<─ property $allowedPollers├─<─ property $allowedViewers└─<─ property $myNodeFile
├─> [+] media
├─> [+] mimimi.core
├─> [+] mimimi.install
├─> [+] mimimi.modules
├─> [+] newspaper
├─> [+] tiny.news.feed
├─> .htaccess
├─> favicon.ico
└─> index.php

A blinking green dot at the end of the file name indicates recently created folders or files. Green strings indicates functions or methods added to the file.

Well, that's good work

Let's think about the rules for crawling your site by search robots. To list such rules, there is a special file that is located in the site root. It is called robots.txt

This is a regular text document, its content is static. However, you may need to dynamically generate a content. For example, to insert the actual hostname, the sitemap URL, and so on.

In this case, you will have to perform step 4 described below.

Photo by Joshua Alan Davis on Unsplash

In general, if you decide to use the robots.txt file on your site, it is recommended to consult with a SEO specialist or research the specification yourself, as an incorrectly assembled file can lead to negative consequences. In addition, search engines have their own requirements for assembling such a file.

However, the presence or absence of robots.txt on the site is not an error. For example, sites with private access, web messengers, chats, forums, gaming platforms, CRM and other services do not have such a file at all. So if your site doesn't need robots.txt file, you can safely skip step 4.

Step 4 create a robots.txt document

Here you will also need to go through 3 substeps.

Substep 4.1: Controller

Please go back to your app root. Create a subfolder RobotsTxt there. Create a file RobotsTxt.php, write the following in it.

<?php
/** ------------------------------------------------------------------------
 * The viewer module for generating a robots.txt document. It is always
 * called using run() method implemented below. The initiator of that call
 * is the main module of your application.
 * ---------------------------------------------------------------------- */

    mimimiInclude ( 'Module.php' );
    class MyMimimiRobotsTxt extends MimimiModule {

        /** ----------------------------------------------------------------
         * Generates the contents of ROBOTS document.
         * Only a single form of URL is processed by this method:
         *     https://your.site/robots.txt
         * Another forms are incorrect:
         *     https://your.site/robots.txt/UNDEFINED-OPTIONS
         *                                  └──────┬────────┘
         *                                         └─> it is the incoming $params
         * @param  mixed $params The rest of page's URL if detected by the app's routing method.
         * @return bool          TRUE  if the document was rendered successfully.
         *                       FALSE if the page URL is incorrect and an Error404 page needs to be displayed.
         * -------------------------------------------------------------- */

        public function run ( $params = '' ) {
            return $params == ''
                && $this->app->renderTemplate ( 'robots-txt.tpl',
                                                'Oops, this site does not have a "robots.txt" template!' );
        }
    };

Please note that due to the specifics of the app's router, the run() method of the viewer module RobotsTxt is called for any root URLs or for any URLs starting with "robots.txt".

However, only a single form of URL is processed by this method:

Another forms of the locator are incorrect and are therefore ignored, causing the app's router to display them later using the visual module Error404. This is what those incorrect forms look like:

Ignoring is done using a directive $params == '' above.

Substep 4.2: Model

Similar to handling the home page and Error 404 page, the RobotsTxt viewer module also does not require you to query any database entries for it. Therefore, we do nothing at this substep.

Substep 4.3: View

Markup a template file for rendering a content of the RobotsTxt document. Go to the subfolder Themes/default and create a file robots-txt.tpl inside with the following contents.

<?php
    sendHeaderTEXT ( );
    sendStatus200  ( );
    stopIfHead     ( );

?>User-agent: *
Sitemap: <?php printSiteUrl   ( ) ?>sitemap
Host:    <?php printDomainUrl ( ) ?>

What does your build look like now?

├─> [-] five.viewers
│       ├─> Error404
│       │   └─> Error404.php
│       │       └─<─ run()
│       ├─> Home
│       │   └─> Home.php
│       │       └─<─ run()
│       ├─> RobotsTxt
│       │   └─> RobotsTxt.php
│       │       └─<─ run()
│       ├─> Themes
│       │   └─> default
│       │       ├─> css
│       │       │   └─> styles.css
│       │       ├─> images
│       │       │   └─> favicon.ico
│       │       ├─> js
│       │       │   └─> scripts.js
│       │       │       ├─<─ findTimes()
│       │       │       ├─<─ extractTime()
│       │       │       ├─<─ fixTimes()
│       │       │       ├─<─ displayRandom()
│       │       │       └─<─ renderMessage()
│       │       ├─> snippets
│       │       │   ├─> footer.tpl
│       │       │   ├─> header.tpl
│       │       │   └─> menu.tpl
│       │       ├─> error-404.tpl
│       │       ├─> home.tpl
│       │       └─> robots-txt.tpl
│       └─> Application.php├─<─ property $viewerCollector├─<─ property $pollerCollector├─<─ property $systemCollector├─<─ property $allowedPollers├─<─ property $allowedViewers└─<─ property $myNodeFile
├─> [+] media
├─> [+] mimimi.core
├─> [+] mimimi.install
├─> [+] mimimi.modules
├─> [+] newspaper
├─> [+] tiny.news.feed
├─> .htaccess
├─> favicon.ico
└─> index.php

A blinking green dot at the end of the file name indicates recently created folders or files. Green strings indicates functions or methods added to the file.

What about the sitemap?

You may have noticed that the template file robots-txt.tpl had a reference to a sitemap. So you will need to create a viewer module with the same name.

Photo by Marjan Blan on Unsplash

Sitemap is a general name for XML documents containing information about web pages that are subject to indexing.

The information provided in these documents may have varying degrees of completeness. In the simplest case, it contains only the URLs of pages to be indexed, as shown in the following example.

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
    <url>
        <loc> https://your.site/channels/demo-channel-3 </loc>
    </url>
    <url>
        <loc> https://your.site/channels/demo-channel-4 </loc>
    </url>
    <url>
        <loc> https://your.site/channels/demo-channel-2 </loc>
    </url>
    <url>
        <loc> https://your.site/channels/demo-channel-1 </loc>
    </url>
</urlset>

Using a sitemap does not guarantee that web pages will be indexed by search engines. This XML document only tells crawlers which URLs should be crawled more thoroughly.

Step 5 create a sitemap XML document

Substep 5.1: Controller

You need to go back to your app root again. Create a subfolder Sitemap there. Create a file Sitemap.php, write the following in it.

<?php
/** ------------------------------------------------------------------------
 * The viewer module for generating a sitemap XML document. It is always
 * called using run() method implemented below. The initiator of that call
 * is the main module of your application.
 * ---------------------------------------------------------------------- */

    mimimiInclude ( 'Module.php' );
    class MyMimimiSitemap extends MimimiModule {

        /** ----------------------------------------------------------------
         * Generates the contents of SITEMAP document.
         * Only a single form of URL is processed by this method:
         *     https://your.site/sitemap
         * Another forms are incorrect:
         *     https://your.site/sitemap/UNDEFINED-OPTIONS
         *                               └──────┬────────┘
         *                                      └─> it is the incoming $params
         * @param  mixed $params The rest of page's URL if detected by the app's routing method.
         * @return bool          TRUE  if the document was rendered successfully.
         *                       FALSE if the page URL is incorrect and an Error404 page needs to be displayed.
         * -------------------------------------------------------------- */

        public function run ( $params = '' ) {
            return   $params == ''
                && ( $data = $this->app->channels->getSitemap ( ) )
                &&           $this->app->renderTemplate       ( 'sitemap.tpl',
                                                                'Oops, this site does not have a sitemap template!',
                                                                $data );
        }
    };

Please note that due to the specifics of the app's router, the run() method of the viewer module Sitemap is called for any root URLs or for any URLs starting with "sitemap".

However, only a single form of URL is processed by this method:

Another forms of the locator are incorrect and are therefore ignored, causing the app's router to display them later using the visual module Error404. This is what those incorrect forms look like:

Ignoring is done using a directive $params == '' above.

Substep 5.2: Model

Please note that this viewer module requires you to query the database for entries. They are sitemap URLs and are represented as follows.

Array (
    [0] => Array (
            [ 'url'    ] => 'demo-channel-3',
            [ 'viewer' ] => 'channels/'
        ),
    [1] => Array (
            [ 'url'    ] => 'demo-channel-4',
            [ 'viewer' ] => 'channels/'
        ),
    [2] => Array (
            [ 'url'    ] => 'demo-channel-2',
            [ 'viewer' ] => 'channels/'
        ),
    [3] => Array (
            [ 'url'    ] => 'demo-channel-1',
            [ 'viewer' ] => 'channels/'
        )
)

To query these entries we use a directive $this->app->channels->getSitemap() above and the following method which will be written later when creating the Channels viewer module (please see substep 7.1).

<?php
    ...
        /** ----------------------------------------------------------------
         * Retrieves all channels entries for a sitemap.
         * These entries are sorted by their creation date. Please note that
         * t1 below is an alias for the primary database table channels.
         * @return array|bool ARRAY on success. Each element is an array that contains the url and viewer columns only.
         *                    FALSE on failure. This means no entries were found.
         * -------------------------------------------------------------- */

        public function getSitemap ( ) {
            $filter = [
                'select'  => [ 't1.url'      => TRUE     ,
                               '"channels/"' => 'viewer' ],
                /* where */    't1.enabled'  => TRUE,
                'orderby' => [ 't1.created'  => 'desc' ]
            ];
            return $this->select ( $filter, 0, 1000000000 );
        }
    ...

The filter used above will actually result in the following MySQL query:

SELECT   t1.url,
         "channels/" AS viewer
FROM     channels AS t1
WHERE    t1.enabled = TRUE
ORDER BY t1.created DESC
LIMIT    1000000000

Note that the channels is a database table associated with the Channels viewer module.

Substep 5.3: View

Markup a template file for rendering a content of the Sitemap document. Go to the subfolder Themes/default and create a file sitemap.tpl inside with the following contents.

<?php
    sendHeaderXML ( );
    sendStatus200 ( );
    stopIfHead    ( );

?><?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
    <?php
        if ( $params ) {
            foreach ( $params as $entry ) {
                ?>
                <url>
                    <loc>
                        <?php
                            printSiteUrl (                    );
                            printValue   ( $entry[ 'viewer' ] );
                            printValue   ( $entry[ 'url'    ] );
                        ?>
                    </loc>
                </url>
                <?php
            }
        }
    ?>
</urlset>

Note that each template has an incoming variable $params through which the associated module or other caller can pass data collected specifically for that template.

What does your build look like now?

├─> [-] five.viewers
│       ├─> Error404
│       │   └─> Error404.php
│       │       └─<─ run()
│       ├─> Home
│       │   └─> Home.php
│       │       └─<─ run()
│       ├─> RobotsTxt
│       │   └─> RobotsTxt.php
│       │       └─<─ run()
│       ├─> Sitemap
│       │   └─> Sitemap.php
│       │       └─<─ run()
│       ├─> Themes
│       │   └─> default
│       │       ├─> css
│       │       │   └─> styles.css
│       │       ├─> images
│       │       │   └─> favicon.ico
│       │       ├─> js
│       │       │   └─> scripts.js
│       │       │       ├─<─ findTimes()
│       │       │       ├─<─ extractTime()
│       │       │       ├─<─ fixTimes()
│       │       │       ├─<─ displayRandom()
│       │       │       └─<─ renderMessage()
│       │       ├─> snippets
│       │       │   ├─> footer.tpl
│       │       │   ├─> header.tpl
│       │       │   └─> menu.tpl
│       │       ├─> error-404.tpl
│       │       ├─> home.tpl
│       │       ├─> robots-txt.tpl
│       │       └─> sitemap.tpl
│       └─> Application.php├─<─ property $viewerCollector├─<─ property $pollerCollector├─<─ property $systemCollector├─<─ property $allowedPollers├─<─ property $allowedViewers└─<─ property $myNodeFile
├─> [+] media
├─> [+] mimimi.core
├─> [+] mimimi.install
├─> [+] mimimi.modules
├─> [+] newspaper
├─> [+] tiny.news.feed
├─> .htaccess
├─> favicon.ico
└─> index.php

A blinking green dot at the end of the file name indicates recently created folders or files. Green strings indicates functions or methods added to the file.

It's time to talk about API

According to the task described at the beginning of this guide, one page of your site should serve as a kind of API.

Currently this API will only handle one function named random. And periodic calling of that function will be organized using polling.

What is a polling?

Web polling is the process where a site waits for its backend to dynamically read the requested data or report its status. It can be implemented in four ways: regular polling, long polling, polling by WebSocket, and polling by Server Sent Events.

Regular polling

It is a short connection to request the required data and a long pause to wait for the next time.

This method is effective with low-cost hosting when a small number of processor minutes are allocated. Typically it is implemented in JavaScript as follows:

let busyNow   = false,
    url       = '',
    urlParams = { },
    longPause = 20000;

async function regularPolling ( ) {
    if ( ! busyNow ) {
        if ( url ) {
            busyNow = true;
            const params   = buildParams ( ),
                  response = await fetch ( url, params );
            switch ( response.status ) {
                case 200:
                     const data = await response.json ( );
                     handlePackage ( data );
                     break;
                default:
                     /* console.log ( 'Error happens: ' + response.statusText ); */
            }
            busyNow = false;
        }
    }
};

setInterval ( regularPolling, longPause );

function buildParams ( ) {
    const data = new FormData ( );
          for ( const key in urlParams ) {
              data.append ( key, urlParams[ key ] );
          }
    return {
        method: 'POST',
        body:   data,
        mode:   'cors',
        cache:  'no-cache'
    };
};

function handlePackage ( data ) {
    stopPolling ( );
    /* you need to parse the package content here   */
    /* and set the appropriate value of the url and */
    /* urlParams variable if you need to perform    */
    /* another request                              */
};

function stopPolling ( ) {
    url       = '';
    urlParams = { };
};

Automatic launch occurs as follows within the next longPause microseconds:

url       =   'some-checkpoint-url';
urlParams = { 'some': 'parameters',
              'for' : 'that checkpoint' };

Long polling

It is a long connection to wait for the required data and a short pause to reestablish that connection.

This method is similar to regular polling, but consumes more server resources and CPU minutes due to the continuous flow of requests. Therefore, it is not recommended to use it on low-cost hosting.

Typically it is implemented in JavaScript as follows:

let url        = '',
    urlParams  = { },
    shortPause = 50;

async function longPolling ( ) {
    if ( url ) {
        const params   = = buildParams ( ),
              response = await fetch ( url, params );
        switch ( response.status ) {
            case 200:
                 const data = await response.json ( );
                 handlePackage ( data );
                 break;
            case 502:
                 /* console.log ( 'Connection timeout happens' ); */
                 break;
            default:
                 /* console.log ( 'Another error happens: ' + response.statusText ); */
        }
    }
    await new Promise (
              ( resolve ) => setTimeout ( resolve, shortPause )
          );
    longPolling ( );
};

longPolling ( );

function buildParams ( ) {
    const data = new FormData ( );
          for ( const key in urlParams ) {
              data.append ( key, urlParams[ key ] );
          }
    return {
        method: 'POST',
        body:   data,
        mode:   'cors',
        cache:  'no-cache'
    };
};

function handlePackage ( data ) {
    stopPolling ( );
    /* you need to parse the package content here   */
    /* and set the appropriate value of the url and */
    /* urlParams variable if you need to perform    */
    /* another request                              */
};

function stopPolling ( ) {
    url       = '';
    urlParams = { };
};

Automatic launch occurs as follows within the next shortPause microseconds:

url       =   'some-checkpoint-url';
urlParams = { 'some': 'parameters',
              'for' : 'that checkpoint' };

WebSocket polling

It is a persistent TCP connection for requesting required data over a full-duplex communication channel in real time.

However, this method requires that your hosting supports WebSocket protocol. Typically it is implemented in JavaScript as follows:

let socket = null;

function websocketPolling ( url, params = null ) {
    socket = new WebSocket ( 'wss://your.site/' + url );
    socket.onopen = function ( event ) {
        socket.onmessage = function ( event ) {
            handlePackage ( event.data );
        };
        if ( params ) socket.send ( params );
    };
    socket.onerror = function ( event ) {
        stopPolling ( );
    };
};

websocketPolling ( 'some-checkpoint-url' );

function handlePackage ( data ) {
    /* you need to parse the package content here */
};

function stopPolling ( ) {
    if ( socket ) socket.close ( );
};

Server Sent Events polling

It is a pseudo persistent HTTP connection for reading required data over an unidirectional communication channel in real time.

Typically it is implemented in JavaScript as follows:

let source = null;

function ssePolling ( url ) {
    if ( typeof EventSource !== 'undefined' ) {
        source = new EventSource ( url );
        socket.onopen = function ( event ) {
            source.onmessage = function ( event ) {
                handlePackage ( event.data );
            };
        };
        source.onerror = function ( event ) {
            if ( this.readyState == EventSource.CONNECTING ) {
                ssePolling ( url );
            } else {
                stopPolling ( );
            }
        };
    }
};

ssePolling ( 'some-checkpoint-url' );

function handlePackage ( data ) {
    /* you need to parse the package content here */
};

function stopPolling ( ) {
    if ( source ) source.close ( );
};

Okay

So let's now create a viewer module called Api. It will even support different versions.

By the way, its URL format will look like this:

This format will allow API requests to be accurately routed according to the requested API version, the desired command to execute and its execution parameters.

Photo by Burkhard Kaufhold on Unsplash

And for now, the only version will be v1. And the only command will be random with an optional parameter from.

Step 6 create a checkpoint

Substep 6.1: Controller

Please go back to your app root. Create a subfolder Api there. Create a file Api.php, write the following in it.

<?php
/** ------------------------------------------------------------------------
 * The poller module for requesting data via the API. It is always called
 * using run() method implemented below. The initiator of that call is the
 * main module of your application.
 * ---------------------------------------------------------------------- */

    mimimiInclude ( 'Module.php' );
    class MyMimimiApi extends MimimiModule {

        /** ----------------------------------------------------------------
         * Sends a result of the current request.
         * Currently only two forms of URL are processed by this method:
         *     https://your.site/polls/api/v1/random
         *     https://your.site/polls/api/v1/random/from/CHANNEL-ID
         *                                 └──────────┬────────────┘
         *                                            └─> it is the incoming $params
         * @param  string $params The rest of page's URL if detected by the app's routing method.
         * @return bool           TRUE  if the result was sent successfully.
         *                        FALSE if the page URL is incorrect and an Error404 page needs to be displayed.
         * -------------------------------------------------------------- */

        public function run ( $params = '' ) {
            $version = $this->app->cutUrlSegment ( $params );
            switch ( $version ) {
                case 'v1': return $this->routeVersion1 ( $params );
            }
            return FALSE;
        }

        /** ----------------------------------------------------------------
         * Processes a request for API version 1.
         * This method parses the URL for a matching command, and if it
         * finds one, sends the rest of the URL to the appropriate handler.
         * In this example, the following URLs correspond to these commands:
         *     https://your.site/polls/api/v1/random
         *     https://your.site/polls/api/v1/random/REST-OF-URL
         *                                    └───────┬────────┘
         *                                            └─> it is the incoming $url
         * @param  string $url The rest of page's URL containing a command and its options.
         * @return bool        TRUE  if the result was sent successfully.
         *                     FALSE if the command is incorrect and an Error404 page needs to be displayed.
         * -------------------------------------------------------------- */

        protected function routeVersion1 ( $url ) {
            $command = $this->app->cutUrlSegment ( $url );
            switch ( $command ) {
                case 'random': return $this->doRandomVersion1 ( $url );
            }
            return FALSE;
        }

        /** ----------------------------------------------------------------
         * Executes the "random" command for API version 1.
         * This method handles the following URLs:
         *     https://your.site/polls/api/v1/random
         *     https://your.site/polls/api/v1/random/from/CHANNEL-ID
         *                                           └──────┬──────┘
         *                                                  └─> it is the incoming $options
         * @param  string $options The rest of page's URL containing the command options.
         * @return bool            TRUE  if the result was sent successfully.
         *                         FALSE if the options are incorrect and an Error404 page needs to be displayed.
         * -------------------------------------------------------------- */

        protected function doRandomVersion1 ( $options ) {
            $target = $this->app->cutUrlSegment ( $options );
            switch ( $target ) {
                case 'from':
                     if ( $options == '' ) break;
                case '':
                     $this->sendJsonHeaders (        );
                              $this->app->channels->install ( );
                     $entry = $this->app->channels->messages->getRandom ( $options );
                     $this->sendJsonData    ( $entry );
                     return TRUE;
            }
            return FALSE;
        }

        /** ----------------------------------------------------------------
         * Sends JSON headers.
         * -------------------------------------------------------------- */

        protected function sendJsonHeaders ( ) {
            $this->app->runHelper (   );
            sendHeaderJSON        (   );
            sendStatus200         (   );
            sendHeaderExpires     ( 0 );
            stopIfHead            (   );
        }

        /** ----------------------------------------------------------------
         * Sends a JSON response.
         * @param array $data Items to be sent to the browser.
         * -------------------------------------------------------------- */

        protected function sendJsonData ( $data ) {
            echo $data
                 ? json_encode ( $data )
                 : '{}';
        }
    };

Strikethrough indicates lines that are written for demonstration purposes, they may be removed on a production site.

Substep 6.2: Model

This viewer module requires you to query the database for an entry. It is represented as follows.

Array (
    [ 'name'    ] => 'Julius Caesar',
    [ 'title'   ] => 'Quote, Act III, Scene II',
    [ 'image'   ] => '',
    [ 'text'    ] => '<p>As he was valiant, I honour him; but, as he was ambitious, I slew him</p>',
    [ 'created' ] => '2024-03-14 11:35:45'
)

To query this entry we use a directive $this->app->channels->messages->getRandom($options); above and the following method which will be written later when creating the Messages submodule (please see substep 7.3).

<?php
    ...
        /** ----------------------------------------------------------------
         * Retrieves a random entry with visible status.
         * Please note that t1 below is an alias for the secondary database
         * table channels_messages. And t2 is an alias for the primary
         * table channels, which is referenced through $this->owner.
         * @param  int        $id The channel identifier to search in.
         * @return array|bool     ARRAY on success. It contains an entry obtained from a database table.
         *                        FALSE on failure. This means no entries were found.
         * -------------------------------------------------------------- */

        public function getRandom ( $id = 0 ) {
            $count = $this->getCount ( $id );
            if ( $count ) {
                $filter = [
                    'select' => [ 't2.name'    => TRUE ,
                                  't1.title'   => TRUE ,
                                  't1.image'   => TRUE ,
                                  't1.text'    => TRUE ,
                                  't1.created' => TRUE ],
                    'join'   => [ $this->owner->table => [
                                                't1.channel_id' => 't2.id' ] ],
                    /* where */   't1.visible' => TRUE,
                                  't2.enabled' => TRUE
                ];
                if ( $id ) {
                    /* where */   $filter[ 't1.channel_id' ] = $id;
                }
                $offset = $count > 1
                                 ? mt_rand ( 0, $count - 1 )
                                 : 0;
                return $this->select ( $filter, $offset );
            }
            return FALSE;
        }
    ...

The filter used above will actually result in the following MySQL query:

SELECT    t2.name,
          t1.title,
          t1.image,
          t1.text,
          t1.created
FROM      channels_messages AS t1
LEFT JOIN channels          AS t2
                               ON t1.channel_id = t2.id
WHERE     t1.visible    = TRUE AND
          t2.enabled    = TRUE AND
          t1.channel_id = $id
LIMIT     1
OFFSET    $offset

Note that the channels is a database table associated with the Channels viewer module, and its submodule Messages has an associated table channels_messages.

Substep 6.3: View

This viewer module does not have a specific template file for generating the response. Instead, it uses the sendJsonHeaders() and sendJsonData() methods implemented above.

Or you can see below how a similar response is displayed every 15 seconds. Here, a regular polling on Javascript was used to display a random quote from William Shakespeare's play Julius Caesar (it is entry 2 in the demo database).

What does your build look like now?

├─> [-] five.viewers
│       ├─> Api
│       │   └─> Api.php
│       │       ├─<─ run()
│       │       ├─<─ routeVersion1()
│       │       ├─<─ doRandomVersion1()
│       │       ├─<─ sendJsonHeaders()
│       │       └─<─ sendJsonData()
│       ├─> Error404
│       │   └─> Error404.php
│       │       └─<─ run()
│       ├─> Home
│       │   └─> Home.php
│       │       └─<─ run()
│       ├─> RobotsTxt
│       │   └─> RobotsTxt.php
│       │       └─<─ run()
│       ├─> Sitemap
│       │   └─> Sitemap.php
│       │       └─<─ run()
│       ├─> Themes
│       │   └─> default
│       │       ├─> css
│       │       │   └─> styles.css
│       │       ├─> images
│       │       │   └─> favicon.ico
│       │       ├─> js
│       │       │   └─> scripts.js
│       │       │       ├─<─ findTimes()
│       │       │       ├─<─ extractTime()
│       │       │       ├─<─ fixTimes()
│       │       │       ├─<─ displayRandom()
│       │       │       ├─<─ renderMessage()
│       │       │       ├─<─ findPollers()
│       │       │       ├─<─ startPollers()
│       │       │       ├─<─ pollingFor()
│       │       │       ├─<─ getPollerUrl()
│       │       │       ├─<─ getPollerOptions()
│       │       │       ├─<─ getPollerTime()
│       │       │       └─<─ loadDocument()
│       │       ├─> snippets
│       │       │   ├─> footer.tpl
│       │       │   ├─> header.tpl
│       │       │   └─> menu.tpl
│       │       ├─> error-404.tpl
│       │       ├─> home.tpl
│       │       ├─> robots-txt.tpl
│       │       └─> sitemap.tpl
│       └─> Application.php├─<─ property $viewerCollector├─<─ property $pollerCollector├─<─ property $systemCollector├─<─ property $allowedPollers├─<─ property $allowedViewers└─<─ property $myNodeFile
├─> [+] media
├─> [+] mimimi.core
├─> [+] mimimi.install
├─> [+] mimimi.modules
├─> [+] newspaper
├─> [+] tiny.news.feed
├─> .htaccess
├─> favicon.ico
└─> index.php

A blinking green dot at the end of the file name indicates recently created folders or files. Green strings indicates functions or methods added to the file.

Photo by Maxim Hopman on Unsplash

Step 7 create a channel list and own page

Since this viewer module serves two types of pages, you will need to go through 5 substeps here:

  1. Controller.
  2. Model 1 to work with the primary database table.
  3. Subordinate Model 2 to work with the secondary table.
  4. View 1 to display a channel list.
  5. View 2 to display the channel's own page.

Substep 7.1: Controller

You should go back to the root folder of your app and create a subfolder Channels there, and in it a file Channels.php with the following contents.

<?php
/** ------------------------------------------------------------------------
 * The viewer module for displaying a channel list or channel page. It is
 * always called using run() method implemented below. The initiator of that
 * call is the main module of your application.
 *
 * We can say that it is a Controller. However, this module also acts as
 * a Model to read certain data from the database. So it contains the
 * corresponding methods: getItem(), getItems(), and getSitemap().
 * ---------------------------------------------------------------------- */

    mimimiInclude ( 'NodeWithTable.php' );
    class MyMimimiChannels extends MimimiNodeModuleWithTable {

        /** ----------------------------------------------------------------
         * Reset namespace simulator.
         * -------------------------------------------------------------- */

        protected $myNodeFile = __FILE__;

        /** ----------------------------------------------------------------
         * Specify a name of the database table to store channels.
         * -------------------------------------------------------------- */

        public $table = 'channels';

        /** ----------------------------------------------------------------
         * Define a database table structure.
         * Please note that the default value of enabled column is FALSE
         * to avoid displaying entries added with an incomplete number of
         * columns. What is meant here is that if such an incomplete entry
         * was somehow added to the table, it was either a software error
         * or a deliberate act of an attacker.
         * -------------------------------------------------------------- */

        protected $tableFields = [
                      '`id`       BIGINT(20)     NOT NULL  AUTO_INCREMENT                 COMMENT "channel system identifier"',
                      '`url`      VARCHAR(255)   NOT NULL                                 COMMENT "page URL relative to the channel list URL"',
                      '`name`     VARCHAR(80)    NOT NULL                                 COMMENT "channel name"',
                      '`meta`     VARCHAR(512)   NOT NULL                                 COMMENT "text for meta description tag"',
                      '`avatar`   VARCHAR(255)   NOT NULL                                 COMMENT "avatar URL relative to the site URL"',
                      '`info`     VARCHAR(2048)  NOT NULL                                 COMMENT "channel description"',
                      '`enabled`  BOOLEAN        NOT NULL  DEFAULT FALSE                  COMMENT "1 if the channel is visible"',
                      '`viewed`   TIMESTAMP      NOT NULL  DEFAULT "2000-01-01 00:00:01"  COMMENT "date up to which the channel was viewed"',
                      '`created`  TIMESTAMP      NOT NULL  DEFAULT CURRENT_TIMESTAMP      COMMENT "creation time"'
                  ];

        /** ----------------------------------------------------------------
         * Define a list of table keys to speed up the database operations
         * related to channels.
         * -------------------------------------------------------------- */

        protected $tableKeys = [
                      'PRIMARY KEY ( `id`      )',
                      'UNIQUE KEY  ( `url`     )',
                      'KEY         ( `name`    )',
                      'KEY         ( `enabled` )',
                      'KEY         ( `created` )'
                  ];

        /** ----------------------------------------------------------------
         * Displays the contents of channel list page.
         * This method handles the following URLs:
         *     https://your.site/channels
         *     https://your.site/channels/A-CHANNEL-URL-PATH
         *                                └───────┬────────┘
         *                                        └─> it is the incoming $params
         * @param  mixed $params The rest of page's URL if detected by the app's routing method.
         * @return bool          TRUE  if the page was rendered successfully.
         *                       FALSE if requested channel is not found and an Error404 page needs to be displayed.
         * -------------------------------------------------------------- */

        public function run ( $params = '' ) {
            switch ( $params ) {
                case '':
                     $file = 'channels-list.tpl';
                     $data = $this->getItems (         );
                     break;
                default:
                     $file = 'channels-page.tpl';
                     $data = $this->getItem  ( $params );
            }
            return $data
                && $this->app->renderTemplate ( $file,
                                                'Oops, this site does not have a template for Channels page!',
                                                $data );
        }

        /** ----------------------------------------------------------------
         * Retrieves a channel entry by its URL.
         * Please note that t1 below is an alias for the primary database
         * table channels, and t2 is an alias for the secondary table
         * channels_messages.
         * Note also that the filter used below will actually result in the
         * following MySQL query:
         *     SELECT    t1.*,
         *               t2.*
         *     FROM      channels          AS t1
         *     LEFT JOIN channels_messages AS t2
         *                                    ON t2.channel_id = t1.id AND
         *                                       t2.visible    = TRUE
         *     WHERE     t1.url     = $url AND
         *               t1.enabled = TRUE
         *     ORDER BY  t2.created DESC
         *     LIMIT     1000000000
         * @param  string     $url The relative URL for the channel you are looking for.
         * @return array|bool      ARRAY on success. It contains a list of rows obtained from a database table.
         *                         FALSE on failure. This means no entries were found.
         * -------------------------------------------------------------- */

        public function getItem ( $url ) {
            $filter = [
                'select'   => [ 't1.*' => TRUE ,
                                't2.*' => TRUE ],
                'join'     => [ $this->messages->table => [ 't2.channel_id' => 't1.id' ,
                                                            't2.visible'    => TRUE    ] ],
                /* where */     't1.url'     => $url,
                                't1.enabled' => TRUE,
                'orderby'  => [ 't2.created' => 'desc' ]
            ];
            return $this->select ( $filter, 0, 1000000000 );
        }

        /** ----------------------------------------------------------------
         * Retrieves all channels entries for displaying.
         * These entries are sorted by creation date of their messages. We
         * also add an abstract column to the result entries. This column
         * is called viewer. And it always contains the string channels/
         * to print later as the first segment of the channel URL.
         * Please note that t1 below is an alias for the primary database
         * table channels, and t2 is an alias for the secondary table
         * channels_messages.
         * Note also that the filter used below will actually result in the
         * following MySQL query:
         *     SELECT    t1.*,
         *               "channels/"  AS      viewer,
         *               t2.created   AS      message_date,
         *               COUNT(t2.id) AS      unreaded
         *     FROM      channels          AS t1
         *     LEFT JOIN channels_messages AS t2
         *                                    ON t2.channel_id = t1.id     AND
         *                                       t2.visible    = TRUE      AND
         *                                       t2.created    > t1.viewed
         *     WHERE     t1.enabled = TRUE
         *     GROUP BY  t1.id        ASC
         *     ORDER BY  message_date DESC,
         *               t1.created   DESC
         *     LIMIT     1000000000
         * @return array|bool ARRAY on success. Each element is an array, like a row obtained from a database table.
         *                    FALSE on failure. This means no entries were found.
         * -------------------------------------------------------------- */

        public function getItems ( ) {
            $filter = [
                'select'     => [ 't1.*'             => TRUE           ,
                                  '"channels/"'      => 'viewer'       ,
                                  't2.created'       => 'message_date' ,
                                  'COUNT(`t2`.`id`)' => 'unreaded'     ],

                'join'       => [ $this->messages->table => [ 't2.channel_id' => 't1.id'     ,
                                                              't2.visible'    => TRUE        ,
                                                              '> t2.created'  => 't1.viewed' ] ],
                /* where */       't1.enabled' => TRUE,
                'groupby'    => [ 't1.id' => 'asc' ],
                'orderby'    => [ 'message_date' => 'desc' ,
                                  't1.created'   => 'desc' ]
            ];
            return $this->select ( $filter, 0, 1000000000 );
        }

        /** ----------------------------------------------------------------
         * Retrieves all channels entries for a sitemap.
         * These entries are sorted by their creation date. Please note that
         * t1 below is an alias for the primary database table channels.
         * Note also that the filter used below will actually result in the
         * following MySQL query:
         *     SELECT   t1.url,
         *              "channels/"  AS viewer,
         *     FROM     channels AS t1
         *     WHERE    t1.enabled = TRUE
         *     ORDER BY t1.created DESC
         *     LIMIT    1000000000
         * @return array|bool ARRAY on success. Each element is an array that contains the url and viewer columns only.
         *                    FALSE on failure. This means no entries were found.
         * -------------------------------------------------------------- */

        public function getSitemap ( ) {
            $filter = [
                'select'  => [ 't1.url'      => TRUE     ,
                               '"channels/"' => 'viewer' ],
                /* where */    't1.enabled'  => TRUE,
                'orderby' => [ 't1.created'  => 'desc' ]
            ];
            return $this->select ( $filter, 0, 1000000000 );
        }

        /** ----------------------------------------------------------------
         * Specify demo rows that will be used as default channel entries
         * if the database does not have a table named channels. In this
         * case, all demo rows will be automatically added to the newly
         * created primary table.
         * -------------------------------------------------------------- */

        protected $demoRows = [ .., SOME, DEMO, ENTRIES, ... ];

        /** ----------------------------------------------------------------
         * Installs the demo table entries.
         * The need to overwrite this method is due to the presence in your
         * web app of a node module with a primary and secondary table.
         * @param  mixed $params Some parameters if you need.
         * @return bool          TRUE  if at least one new entry has been added.
         *                       FALSE if the table has not changed.
         * -------------------------------------------------------------- */

        public function install ( $params = NULL ) {
            $this->messages->install (         );
            return parent::install   ( $params );
        }
    };

Strikethrough indicates lines that are written for demonstration purposes, they may be removed on a production site.

As you can see, the run() method routes the incoming URL to one of the template files that represent the two Views.

As you can also see, the getItems() method declares a column unreaded to count new messages that the current user has not yet seen. And a JOIN codition '> t2.created' => 't1.viewed' that filters new messages for counting. However, this demo application still does not implement an API command viewed to dynamically pass information about each viewed message to the backend.

Substep 7.2: Model 1

You should skip this substep because the Api viewer module already contains two methods, getItem() and getItems(), which are Model's methods.

It also contains method getSitemap() which is Model's method for the Sitemap viewer module created in step 5 above.

Substep 7.3: subordinate Model 2

Let me remind that you are currently in the Channels folder. Since Model 2 is a subordinate and represents a submodule for working with a secondary database table, this submodule should be placed in the folder of the primary Model 1.

So you need to create a subfolder Messages there, and in it a file Messages.php with the following contents.

<?php
/** ------------------------------------------------------------------------
 * Submodule for working with a secondary table of the Channels module.
 * ---------------------------------------------------------------------- */

    mimimiInclude ( 'ModuleWithTable.php' );
    class MyMimimiChannelsMessages extends MimimiModuleWithTable {

        /** ----------------------------------------------------------------
         * Specify a name of the database table to store channel messages.
         * -------------------------------------------------------------- */

        public $table = 'channels_messages';

        /** ----------------------------------------------------------------
         * Define a database table structure.
         * Please note that the default value of visible column is FALSE
         * to avoid displaying entries added with an incomplete number of
         * columns. What is meant here is that if such an incomplete entry
         * was somehow added to the table, it was either a software error
         * or a deliberate act of an attacker.
         * -------------------------------------------------------------- */

        protected $tableFields = [
                      '`id`             BIGINT(20)     NOT NULL  AUTO_INCREMENT             COMMENT "message system identifier"',
                      '`channel_id`     BIGINT(20)     NOT NULL                             COMMENT "channel identifier"',
                      '`title`          VARCHAR(80)    NOT NULL                             COMMENT "message title"',
                      '`image`          VARCHAR(255)   NOT NULL                             COMMENT "image URL relative to the site URL"',
                      '`text`           VARCHAR(2048)  NOT NULL                             COMMENT "message HTML body"',
                      '`credits`        VARCHAR(255)   NOT NULL                             COMMENT "credits URL if you need"',
                      '`credits_label`  VARCHAR(40)    NOT NULL                             COMMENT "label for the credits URL"',
                      '`visible`        BOOLEAN        NOT NULL  DEFAULT FALSE              COMMENT "1 if the message is visible"',
                      '`created`        TIMESTAMP      NOT NULL  DEFAULT CURRENT_TIMESTAMP  COMMENT "creation time"'
                  ];

        /** ----------------------------------------------------------------
         * Define a list of table keys to speed up the database operations
         * related to channel messages.
         * -------------------------------------------------------------- */

        protected $tableKeys = [
                      'PRIMARY KEY ( `id`         )',
                      'KEY         ( `channel_id` )',
                      'KEY         ( `title`      )',
                      'KEY         ( `visible`    )',
                      'KEY         ( `created`    )'
                  ];

        /** ----------------------------------------------------------------
         * Counts the number of entries with visible status.
         * Please note that t1 below is an alias for the secondary database
         * table channels_messages. And t2 is an alias for the primary
         * table channels, which is referenced through $this->owner.
         * Note also that the filter used below will actually result in the
         * following MySQL query:
         *     SELECT    COUNT(*)          AS total
         *     FROM      channels_messages AS t1
         *     LEFT JOIN channels          AS t2
         *                                    ON t1.channel_id = t2.id
         *     WHERE     t1.visible    = TRUE AND
         *               t2.enabled    = TRUE AND
         *               t1.channel_id = $id
         *     LIMIT     1
         * @param  int $id The channel identifier for counting.
         * @return int
         * -------------------------------------------------------------- */

        public function getCount ( $id = 0 ) {
            $filter = [
                'select' => [ 'COUNT(*)' => 'total' ],
                'join'   => [ $this->owner->table => [ 't1.channel_id' => 't2.id' ] ],
                /* where */   't1.visible' => TRUE,
                              't2.enabled' => TRUE
            ];
            if ( $id ) {
                /* where */   $filter[ 't1.channel_id' ] = $id;
            }
            $row = $this->select ( $filter );
            return empty ( $row[ 'total' ] )
                         ? 0
                         : $row[ 'total' ];
        }

        /** ----------------------------------------------------------------
         * Retrieves a random entry with visible status.
         * Please note that t1 below is an alias for the secondary database
         * table channels_messages. And t2 is an alias for the primary
         * table channels, which is referenced through $this->owner.
         * Note also that the filter used below will actually result in the
         * following MySQL query:
         *     SELECT    t2.name,
         *               t1.title,
         *               t1.image,
         *               t1.text,
         *               t1.created
         *     FROM      channels_messages AS t1
         *     LEFT JOIN channels          AS t2
         *                                    ON t1.channel_id = t2.id
         *     WHERE     t1.visible    = TRUE AND
         *               t2.enabled    = TRUE AND
         *               t1.channel_id = $id
         *     LIMIT     1
         *     OFFSET    $offset
         * @param  int        $id The channel identifier to search in.
         * @return array|bool     ARRAY on success. It contains an entry obtained from a database table.
         *                        FALSE on failure. This means no entries were found.
         * -------------------------------------------------------------- */

        public function getRandom ( $id = 0 ) {
            $count = $this->getCount ( $id );
            if ( $count ) {
                $filter = [
                    'select' => [ 't2.name'    => TRUE ,
                                  't1.title'   => TRUE ,
                                  't1.image'   => TRUE ,
                                  't1.text'    => TRUE ,
                                  't1.created' => TRUE ],
                    'join'   => [ $this->owner->table => [
                                                't1.channel_id' => 't2.id' ] ],
                    /* where */   't1.visible' => TRUE,
                                  't2.enabled' => TRUE
                ];
                if ( $id ) {
                    /* where */   $filter[ 't1.channel_id' ] = $id;
                }
                $offset = $count > 1
                                 ? mt_rand ( 0, $count - 1 )
                                 : 0;
                return $this->select ( $filter, $offset );
            }
            return FALSE;
        }

        /** ----------------------------------------------------------------
         * Specify demo rows that will be used as default message entries
         * if the database does not have a table named channels_messages.
         * In this case, all demo rows will be automatically added to the
         * newly created secondary table.
         * -------------------------------------------------------------- */

        protected $demoRows = [ .., SOME, DEMO, ENTRIES, ... ];
    };

Strikethrough indicates lines that are written for demonstration purposes, they may be removed on a production site.

Substep 7.4: View 1

Markup a template file for rendering HTML content of the primary Channels page. Go to the subfolder Themes/default and create a file channels-list.tpl inside with the following contents.

<?php
    /** --------------------------------------------------------------------
     * Send headers to the user's browser.
     * ------------------------------------------------------------------ */

    sendHeaderHTML (   );
    sendStatus200  (   );
    stopIfHead     (   );

    /** --------------------------------------------------------------------
     * Generate page content.
     * ------------------------------------------------------------------ */

?><!DOCTYPE html>
<html lang="en-US" class="channels-page">
    <head>
        <base href="<?php printSiteUrl ( ) ?>">
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <meta name="robots"   content="index, follow">
        <title>
            Our channels / Five Viewers demo
        </title>
        <meta name="description"  content="This web page is an example of a channel listing.">
        <link rel="canonical"     href="<?php printSiteUrl  ( ) ?>channels">
        <link rel="stylesheet"    href="<?php printThemeUrl ( ) ?>css/styles.css">
        <link rel="shortcut icon" href="<?php printThemeUrl ( ) ?>images/favicon.ico" type="image/ico">
    </head>

    <body>
        <div class="page">
            <?php mimimiModule ( 'snippets/header.tpl' ) ?>
            <?php mimimiModule ( 'snippets/menu.tpl'   ) ?>

            <main class="content">
                <?php
                    if ( $params ) {
                        foreach ( $params as $entry ) {
                            ?>
                            <a class="message" href="<?php printValue ( $entry[ 'viewer'   ] );
                                                           printValue ( $entry[ 'url'      ] ) ?>">

                                <figure class="avatar">
                                    <img alt="" src="<?php printValue ( $entry[ 'avatar'   ] ) ?>" loading="lazy" decoding="async">
                                </figure>

                                <div data-count="<?php     printValue ( $entry[ 'unreaded' ] ) ?>"
                                     class="title"><?php   printValue ( $entry[ 'name'     ] ) ?></div>

                                <div data-date="<?php      printValue ( $entry[ 'created'  ] ) ?>"
                                     class="info"><?php    echo         $entry[ 'info'     ]   ?></div>
                            </a>
                            <?php
                        }
                    }
                ?>
            </main>

            <aside class="hint">
                ... an HTML ...
            </aside>

            <?php mimimiModule ( 'snippets/footer.tpl' ) ?>
        </div>
        <script src="<?php printThemeUrl ( ) ?>js/scripts.js"></script>
    </body>
</html>

Note that this template has an incoming parameter $params, which is an array and it contains a list of non-disabled channels.

That array was sent via the $data variable in the run() method (see substep 7.1 above) and looks something like this:

Array (
    [0] => Array (
            [ 'id'           ] => 3,
            [ 'url'          ] => 'demo-channel-3',
            [ 'name'         ] => 'Hamlet',
            [ 'meta'         ] => 'The most famous quotes from William Shakespeare's play Hamlet.',
            [ 'avatar'       ] => 'media/demo-posts/five.viewers/avatar-2.png',
            [ 'info'         ] => '<p>This demo channel contains the most famous quotes from William Shakespeare\'s play Hamlet.</p>',
            [ 'enabled'      ] => 1,
            [ 'viewed'       ] => '2000-01-01 00:00:01',
            [ 'created'      ] => '2024-04-16 09:10:11',
            [ 'viewer'       ] => 'channels/',
            [ 'message_date' ] => '2024-04-16 09:11:21',
            [ 'unreaded'     ] => 19
        ),
    [1] => Array (
            [ 'id'           ] => 4,
            [ 'url'          ] => 'demo-channel-4',
            [ 'name'         ] => 'The Merchant of Venice',
            [ 'meta'         ] => 'Quotes from William Shakespeare\'s play The Merchant of Venice.',
            [ 'avatar'       ] => '',
            [ 'info'         ] => '<p>These quotes listed at this demo channel are included the words of Keats, Milton, Mark Twain, Ralph Waldo Emerson and Oscar Wilde.</p>',
            [ 'enabled'      ] => 1,
            [ 'viewed'       ] => '2000-01-01 00:00:01',
            [ 'created'      ] => '2024-03-28 08:11:14',
            [ 'viewer'       ] => 'channels/',
            [ 'message_date' ] => '2024-03-31 09:23:02',
            [ 'unreaded'     ] => '12
        ),
    [2] => Array (
            [ 'id'           ] => 2,
            [ 'url'          ] => 'demo-channel-2',
            [ 'name'         ] => 'Julius Caesar',
            [ 'meta'         ] => 'A few famous quotes from William Shakespeare\'s play Julius Caesar.',
            [ 'avatar'       ] => 'media/demo-posts/five.viewers/avatar-1.png',
            [ 'info'         ] => '<p>For demonstration purposes, let us list in this channel a few famous quotes from William Shakespeare\'s play Julius Caesar.</p>',
            [ 'enabled'      ] => 1,
            [ 'viewed'       ] => '2000-01-01 00:00:01',
            [ 'created'      ] => '2024-01-19 11:21:32',
            [ 'viewer'       ] => 'channels/',
            [ 'message_date' ] => '2024-02-02 11:23:43',
            [ 'unreaded'     ] => 14
        ),
    [3] => Array (
            [ 'id'           ] => 1,
            [ 'url'          ] => 'demo-channel-1',
            [ 'name'         ] => 'How to install this app?',
            [ 'meta'         ] => 'It is a short instruction that demonstrates how to install this package on a site.',
            [ 'avatar'       ] => '',
            [ 'info'         ] => '<p>Maybe you want to install such an application on your website. Okay, let\'s put a few messages in this channel demonstrating the installation process.</p>',
            [ 'enabled'      ] => 1,
            [ 'viewed'       ] => '2000-01-01 00:00:01',
            [ 'created'      ] => '2024-01-02 17:06:55',
            [ 'viewer'       ] => 'channels/',
            [ 'message_date' ] => '2024-01-02 17:07:56',
            [ 'unreaded'     ] => 4
        )
)

By the way, if you need to dump some variable in a template to see its contents, you can use the following directive. For example, dumpVar($params)

Substep 7.5: View 2

Markup a template file for rendering HTML content of the secondary Channels page. Go to the subfolder Themes/default and create a file channels-page.tpl inside with the following contents.

<?php
    /** --------------------------------------------------------------------
     * Send headers to the user's browser.
     * ------------------------------------------------------------------ */

    sendHeaderHTML (   );
    sendStatus200  (   );
    stopIfHead     (   );

    /** --------------------------------------------------------------------
    * Get the first message (to display some info about this channel later).
     * ------------------------------------------------------------------ */

    $channel = reset ( $params );

    /** --------------------------------------------------------------------
     * Generate page content.
     * ------------------------------------------------------------------ */

?><!DOCTYPE html>
<html lang="en-US" class="channel-page">
    <head>
        <base href="<?php printSiteUrl ( ) ?>">
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <meta name="robots"   content="index, follow">
        <title>
            <?php printValue ( $channel[ 'name' ] ) ?>
        </title>
        <meta name="description"  content="<?php printValue ( $channel[ 'meta' ] ) ?>">
        <link rel="canonical"     href="<?php printPageUrl  ( ) ?>">
        <link rel="stylesheet"    href="<?php printThemeUrl ( ) ?>css/styles.css">
        <link rel="shortcut icon" href="<?php printThemeUrl ( ) ?>images/favicon.ico" type="image/ico">
    </head>

    <body>
        <div class="page">
            <?php mimimiModule ( 'snippets/header.tpl' ) ?>
            <?php mimimiModule ( 'snippets/menu.tpl'   ) ?>

            <main class="content">
                <?php
                    if ( $params ) {
                        $params = array_reverse ( $params );
                        foreach ( $params as $entry ) {
                            ?>
                            <section class="message">
                                <?php
                                    if ( $entry[ 'image' ] ) {
                                        ?>
                                        <figure class="splash">
                                            <img src="<?php  printValue ( $entry[ 'image' ] ) ?>" alt="" loading="lazy" decoding="async">
                                        </figure>
                                        <?php
                                    }
                                ?>

                                <div class="info"
                                     data-title="<?php printValue ( $entry[ 'title'   ] ) ?>"
                                     data-time="<?php  printValue ( $entry[ 'created' ] ) ?>"
                                     ><?php

                                         if ( $entry[ 'text'          ]
                                         ||   $entry[ 'credits'       ]
                                         ||   $entry[ 'credits_label' ] ) {

                                             echo $entry[ 'text' ];
                                             ?>

                                             <a class="btn"
                                                href="<?php printValue ( $entry[ 'credits' ] ) ?>"
                                                rel="nofollow noopener noreferrer"
                                                tabindex="-1"
                                                target="_blank"><?php printValue ( $entry[ 'credits_label' ] ) ?></a>
                                             <?php
                                         }
                                     ?></div>
                            </section>
                            <?php
                        }
                    }
                ?>
            </main>

            <aside class="hint">
                <figure class="avatar">
                    <img alt="" src="<?php printValue ( $channel[ 'avatar' ] ) ?>" loading="lazy" decoding="async">
                </figure>
                <h6><?php printValue ( $channel[ 'name' ] ) ?></h6>
                <?php     echo         $channel[ 'info' ]   ?>

                ... an HTML ...
            </aside>

            <?php mimimiModule ( 'snippets/footer.tpl' ) ?>
        </div>
        <script src="<?php printThemeUrl ( ) ?>js/scripts.js"></script>
    </body>
</html>

This template also has an incoming parameter $params, which is an array. It contains a list of non-disabled messages for the currently browsed channel.

Please note that we reverse the incoming array $params to display messages in ascending date order because getItem() method read them in descending order.

What does your build look like now?

├─> [-] five.viewers
│       ├─> Api
│       │   └─> Api.php
│       │       ├─<─ run()
│       │       ├─<─ routeVersion1()
│       │       ├─<─ doRandomVersion1()
│       │       ├─<─ sendJsonHeaders()
│       │       └─<─ sendJsonData()
│       ├─> Channels
│       │   ├─> Messages
│       │   │   └─> Messages.php
│       │   │       ├─<─ property $table
│       │   │       ├─<─ property $tableFields
│       │   │       ├─<─ property $tableKeys
│       │   │       ├─<─ getCount()
│       │   │       ├─<─ getRandom()
│       │   │       └─<─ property $demoRows
│       │   └─> Channels.php
│       │       ├─<─ property $myNodeFile
│       │       ├─<─ property $table
│       │       ├─<─ property $tableFields
│       │       ├─<─ property $tableKeys
│       │       ├─<─ run()
│       │       ├─<─ getItem()
│       │       ├─<─ getItems()
│       │       ├─<─ getSitemap()
│       │       ├─<─ property $demoRows
│       │       └─<─ install()
│       ├─> Error404
│       │   └─> Error404.php
│       │       └─<─ run()
│       ├─> Home
│       │   └─> Home.php
│       │       └─<─ run()
│       ├─> RobotsTxt
│       │   └─> RobotsTxt.php
│       │       └─<─ run()
│       ├─> Sitemap
│       │   └─> Sitemap.php
│       │       └─<─ run()
│       ├─> Themes
│       │   └─> default
│       │       ├─> css
│       │       │   └─> styles.css
│       │       ├─> images
│       │       │   ├─> favicon.ico
│       │       │   └─> no-avatar.png
│       │       ├─> js
│       │       │   └─> scripts.js
│       │       │       ├─<─ findTimes()
│       │       │       ├─<─ extractTime()
│       │       │       ├─<─ fixTimes()
│       │       │       ├─<─ displayRandom()
│       │       │       ├─<─ renderMessage()
│       │       │       ├─<─ findDates()
│       │       │       ├─<─ extractDate()
│       │       │       ├─<─ fixDates()
│       │       │       ├─<─ findPollers()
│       │       │       ├─<─ startPollers()
│       │       │       ├─<─ pollingFor()
│       │       │       ├─<─ getPollerUrl()
│       │       │       ├─<─ getPollerOptions()
│       │       │       ├─<─ getPollerTime()
│       │       │       ├─<─ loadDocument()
│       │       │       ├─<─ constant NO_AVATAR_IMAGE
│       │       │       ├─<─ findAvatars()
│       │       │       └─<─ fixAvatars()
│       │       ├─> snippets
│       │       │   ├─> footer.tpl
│       │       │   ├─> header.tpl
│       │       │   └─> menu.tpl
│       │       ├─> channels-list.tpl
│       │       ├─> channels-page.tpl
│       │       ├─> error-404.tpl
│       │       ├─> home.tpl
│       │       ├─> robots-txt.tpl
│       │       └─> sitemap.tpl
│       └─> Application.php├─<─ property $viewerCollector├─<─ property $pollerCollector├─<─ property $systemCollector├─<─ property $allowedPollers├─<─ property $allowedViewers└─<─ property $myNodeFile
├─> [+] media
├─> [+] mimimi.core
├─> [+] mimimi.install
├─> [+] mimimi.modules
├─> [+] newspaper
├─> [+] tiny.news.feed
├─> .htaccess
├─> favicon.ico
└─> index.php

A blinking green dot at the end of the file name indicates recently created folders or files. Green strings indicates functions or methods added to the file.

That's all, guys!

Now you have the knowledge to quickly create a simple website similar to channels in social messengers.

Of course, it is very primitive and does not even contain functions for editing the channel list or messages in channels. These functions were simulated using demo entries placed in the property $demoRows of the viewer module Channels and its submodule Messages.

Our goal was not to write a full-fledged application for you. We only showed an example, and you can use it as a skeleton for your goals.

Photo by Yanapi Senaud on Unsplash

And there remains one last question that also needs to be answered.