Aller au contenu

omnibase/blog

Chronicles and visitors' comments for omnibase: a Post is a Thread, a Comment is read by Akismet before it goes online.

Étage
Structure préparée · Omnibase
Version
main
Vérifié en réel
Son README ne le dit pas
Licence
MIT
composer require omnibase/blog:dev-main

omnibase/blog is the chronicles of a site of the omnibase family - a teacher's, a studio's, a wine seller's - and what visitors leave under them or in the visitors' book, with no account needed. Its namespace is Base\Blog\; it is built on omnisense/omnibase and omnibase/admin.

What it adds

  • A Post that is omnisense/omnibase's Thread (a JOINED subclass, table blog_post): title, headline, excerpt, text (EditorJS or HTML), tags, taxa, owners, slug, states and scheduling, revisions, soft delete and translations come from the thread; the post adds a cover, a "featured" flag, open or closed comments and its reading time.
  • Comments that are omnisense/omnibase's Base\Entity\Thread\Comment: under a post, or in the visitors' book when they have no post.
  • Seven routes: the list (by page, by topic, by taxon), a month, a post, the RSS feed, the visitors' book, and the two comment forms. The first segment of the blog's addresses is blog.path (chroniques by default).
  • View models for every page (Base\Blog\Screen\*): the templates read them, and a client asking for JSON gets the same data.
  • The comment flow: the guard, the spam classifier, moderation, the administrators notified, the classifier taught by the moderator's choices.
  • schema.org's BlogPosting on a post, the posts in /sitemap.xml.
  • Twig functions for a host's own pages: the latest posts, the comments, the form, the cards.
  • Two back office screens (posts, comments) open to the site's administrator, and a dashboard tile of the comments waiting.
  • blog:import-wordpress, which moves a WordPress site in through its REST API.
  • English, French and German messages (domain blog).

Pages

Proposer une modification docs/index.md

Installation

Requirements

  • PHP 8.2 or later;
  • doctrine/orm 2.15 or 3;
  • omnisense/omnibase 3.0 or 4.0, omnibridge/symfony 1.x, omnibase/admin;
  • league/flysystem 3 (the importer's pictures);
  • Symfony 6, 7 or 8 (config, console, dependency-injection, form, http-client, http-kernel, security-bundle, string);
  • an App\Entity\User class: a signed-in commenter, and the administrators notified.

Suggested: glitchr/ux-google, for Google reCAPTCHA v3 on the comment form when google.recaptcha.enable is on; omnisense/omnishield, for the captcha of the forms' guard and the spam classifier's reports.

The bundle

composer require omnibase/blog:dev-main
// config/bundles.php
Base\Blog\BlogBundle::class => ['all' => true],

The routes

# config/routes.yaml
blog_controller:
    resource: "@BlogBundle/src/Controller/Client"
    type: attribute
    prefix: /

The paths start with %blog.path%. After changing blog.path, clear the route pool: bin/console cache:pool:clear --all (omnisense/omnibase keeps the routes in a cache pool).

The configuration

Every key is optional (Configuration reference):

# config/packages/blog.yaml
blog:
    path: chroniques
    sitemap: true
    posts_per_page: 12
    comments:
        enabled: true
        guest_allowed: true
        guest_replies: false
        auto_approve: true
        flood_interval: 60
        min_delay: 4
        max_length: 4000

The schema and the assets

bin/console doctrine:migrations:diff
bin/console doctrine:migrations:migrate   # blog_post (the comments are the core's threadComment)
bin/console assets:install                # public/bundles/blog/css/blog.css

A site that had the blog's former comment table moves its rows into threadComment (Comments).

The layout

The templates extend the site's layout1.html.twig and fill title, description, content, stylesheets and javascripts: that is the whole contract. blog.css follows the custom properties --blog-accent, --blog-ink, --blog-soft, --blog-line, --blog-surface, --blog-measure and --blog-measure-post.

Overriding the entity

The bundle maps Base\Blog\Entity\* to App\Entity\Blog\* and Base\Blog\Repository\* to App\Repository\Blog\* (omnisense/omnibase's App-wins aliasing): an App\Entity\Blog\Post extending Base\Blog\Entity\Post takes over.

Proposer une modification docs/installation.md

Posts

A post is a thread

Base\Blog\Entity\Post extends omnisense/omnibase's Base\Entity\Thread (table blog_post, discriminator blog_post). From the thread it has:

  • its texts, translatable: title, headline, excerpt, content (EditorJS JSON or HTML);
  • its slug, its owners (the authors), its tags (the topics) and taxa (what the site classifies with: a level, a subject);
  • its state (ThreadState: draft, published...), publishedAt, its revisions, followers, likes and soft delete.

It adds three fields:

Field Column Meaning
cover text, nullable an image (8 MB at most, image/*), stored by omnisense/omnibase's Uploader
featured boolean the post the first page heads with
commentsOpen boolean, default true whether visitors comment under it

Methods

Method Returns
getCover() the cover's stored public path (in Twig, post.cover is that path, drawn with `
getCoverFile() the cover as a file
hasCover() whether a cover is set, without asking the storage
getCoverUrl() the cover's address on the site (/uploads/...); an absolute URL as it is
getReadingMinutes() minutes to read it at 220 words a minute (WORDS_PER_MINUTE), rounded up, one at least; the words of an EditorJS text are read from its blocks
publish(bool $published = true, ?DateTimeInterface $at = null) sets the state to published (or draft) and the publication date to $at, the date it had, or now

Published

A post is on the site when its state is published and its publication date is empty or past: a post published with a date to come stays off every list, page, feed and sitemap until then (PostRepository::published()).

The repository

Method Returns
findPublishedPage($page, $perPage, $tag, $taxon) a page of the published posts, newest first, of one tag or one taxon (by slug) when given
countPublished($tag, $taxon) how many
findAllPublished() every published post, for the sitemap
findLatest($limit = 3) the newest posts
findHeadline() the featured post, else the newest
findOnePublished($slug) one published post
findNeighbours($post) the older and the newer post
findTopics() the tags of the posts on the site (published, their publication date come), by priority then slug
countByMonth() year, then month, then the count, for the archive
findByMonth($year, $month) the posts of a month

Pages are cut by hand (first result, max results), not with Doctrine's paginator, whose output walker does not work with omnisense/omnibase's SQL walker.

JSON-LD

Base\Blog\Service\JsonLd::article($post, $url, $base) returns schema.org's BlogPosting: headline (the title, cut at 110 characters), url, mainEntityOfPage, description (the excerpt, else the headline, without tags), datePublished, dateModified, image (the cover, $base before a site path), author (a Person per owner), the tags as keywords (joined by commas) and wordCount, the reading time times 220 (a rounded figure, not the exact count). The post's page prints it in a <script type="application/ld+json">.

Proposer une modification docs/concepts.md

The pages

Routes

With blog.path: chroniques:

Name Path Methods Template Query
blog_index /chroniques any @Blog/client/index.html.twig page, sujet (a tag's slug), classe (a taxon's slug)
blog_month /chroniques/{year}/{month} any @Blog/client/index.html.twig
blog_feed /chroniques/feed.xml any @Blog/client/feed.xml.twig
blog_post /chroniques/{slug} any @Blog/client/post.html.twig
blog_post_comment /chroniques/{slug}/commenter POST
blog_book /livre-d-or any @Blog/client/book.html.twig
blog_book_comment /livre-d-or/commenter POST

year is four digits, month one or two, slug [a-z0-9-]+. The visitors' book is always at /livre-d-or, whatever blog.path says. An unknown or unpublished slug is a 404.

What each page shows

  • The list: the published posts, posts_per_page at a time, newest first. On the first page, without a topic or a taxon, the headline (the featured post, else the newest) is drawn large and left out of the cards. The topics (the tags in use), the months with their counts, the previous and next pages, the feed's address.
  • A month: its posts, on one page.
  • A post: its story (title, cover at 1600 pixels, text, tags, taxa, reading time), the older and the newer post, its comments and the form when it takes comments, its BlogPosting.
  • The feed: the twenty newest posts, by their absolute addresses, as RSS (Content-Type: application/rss+xml; charset=UTF-8), its channel titled with the site's title, else the host.
  • The visitors' book: the comments without a post, and the form.

View models

Each action hands omnisense/omnibase's ScreenResponder a view: a template and its model, built by Base\Blog\Screen\BlogScreens.

Page Model Its fields
list, month BlogPage page, pages, found, next, prev, articles (cards), headline, topics, topic, taxon, month, months, indexUrl, feedUrl
post PostPage article (a story), prev, next, discussion, jsonLd
book BookPage discussion
feed FeedPage title, indexUrl, feedUrl, items

A Discussion holds the visible comments, whether the visitor may write (open), whether they may answer (canReply), the sign-in address and the flashes of the blog category. Covers are drawn at 800 pixels on a card, 1200 for the headline, 1600 on a post (BlogScreens::CARD, HEADLINE, POST).

The blog's templates read screen and frame alone. A template a site overrides in templates/bundles/BlogBundle/ also receives the variables the pages had before the models (posts, post, comments, headline...); those are deprecated and go in omnisense/omnibase 4.0.

As JSON

A request whose Accept header asks for application/json (and not text/html) gets the page's model as JSON instead of the HTML, with Vary: Accept:

curl -H 'Accept: application/json' https://example.org/chroniques

The sitemap

/chroniques is in /sitemap.xml through its route (priority 0.8, daily). Base\Blog\EventListener\SitemapListener adds every published post on SitemapEvent::BUILD, dated the day it last changed (its update, else its publication); blog.sitemap: false leaves the posts out.

Proposer une modification docs/pages.md

Comments

The blog's comments are omnisense/omnibase's Base\Entity\Thread\Comment (table threadComment): a post is a thread, so a comment under a post has the post as its thread; a comment with no thread belongs to the visitors' book (/livre-d-or). The bundle no longer has a Comment entity, enum, form, repository or guard of its own - it uses the core's:

Was Is
Base\Blog\Entity\Comment (blog_comment, post) Base\Entity\Thread\Comment (threadComment, thread)
Base\Blog\Enum\CommentState Base\Enum\CommentState
Base\Blog\Form\CommentType, Form\Model\CommentModel Base\Form\Type\CommentType (options placeholders, trap_class), Base\Form\Model\CommentModel
Base\Blog\Repository\CommentRepository Base\Repository\Thread\CommentRepository
Base\Blog\Service\CommentGuard Base\Service\CommentGuard (the blog passes its min_delay and flood_interval)
Post::getComments(), getVisibleComments() blog_comments(post), blog_comments_count(post) in Twig, or the repository

The guard is the core's: CommentType's own trap (url) and time (opened), read by Base\Service\CommentGuard with the blog's min_delay and flood_interval, and the forms' guard of omnisense/omnibase on top - its stamp, the lists, the captcha when the site has omnisense/omnishield (printed above the button). A comment form built by hand in a test posts the guard's signed stamp (FormGuard::stamp()) and, with omnishield's fixed test gateway, omnishield-token=omnishield-fixed-token.

What stays the blog's: the routes (blog_book, blog_book_comment, blog_post_comment), the configuration (blog.comments.*), the moderation screen (CommentCrudController, /admin/comments, filter thread), the pending-comments tile (blog_pending_comments), the notifications and the Akismet reports.

Moving a site

A site that had the blog before: its migration copies blog_comment into threadComment (post_id becomes thread_id, the discriminator comment) and drops blog_comment. Apfelschule's migrations/Version20261004090000.php is the model. In the site's code: Base\Blog\Entity\Comment → Base\Entity\Thread\Comment, Base\Blog\Enum\CommentState → Base\Enum\CommentState.

Who may write

Base\Blog\Service\CommentPolicy reads blog.comments.*:

Question Answer
may one comment here? comments are enabled, the visitor is signed in or guest_allowed is on, and the post keeps its comments open (Post::isCommentsOpen()); the visitors' book has no post to close
may one answer a comment? the visitor is signed in, or guest_replies is on

A post with closed comments refuses a comment sent anyway with a 403; closed comments everywhere (or no account when one is needed) refuse it the same way. A reply sent by a visitor who may not reply is refused on the form (@blog.comment.error.reply_signed_in), and a reply to a comment that is not visible, or not under the same post, is kept as a comment of its own.

What happens to a comment

CommentController (blog_post_comment, blog_book_comment) handles the form in this order:

  1. the guard (Base\Service\CommentGuard, with min_delay and flood_interval): a robot caught in the trap is thanked as if its comment waited (comment.flash.pending) and nothing is kept; a comment sent too fast or too soon after the last one from the same address gets the error @blog.comment.error.<reason>;
  2. the form's validation, with omnisense/omnibase's spam extension: the comment is classified (Akismet with the site's key, base.spam.akismet), a blatant spam refused, a doubtful one kept as pending;
  3. the comment is saved with the visitor's address and browser, online at once when auto_approve is on and the classifier found it clean, pending otherwise;
  4. the administrators are told (Notifications);
  5. the visitor goes back to the page: at #commentaire-<id> with comment.flash.published, or at #commentaires with comment.flash.pending.

An invalid form shows the post (or the book) again with its errors, status 422. The flashes are in the blog category; the page's view model reads them.

Notifications

Base\Blog\Service\CommentNotifier sends every user whose roles reach ROLE_ADMIN a notification in omnisense/omnibase's notification centre (the notify channel, the bell): notify.pending or notify.approved (domain blog, with the visitor's name and the post's title, or the book's), the first 240 characters of the comment, and a link to the comments screen filtered on the comment's state. No e-mail is sent.

Teaching the classifier

Base\Blog\Service\AkismetReporter tells the classifier when a moderator corrects it: approving a comment held as spam, or one with a spam score, reports it as ham; marking as spam a comment that was online, or one with no score, reports it as spam. It sends what the visitor sent (text, author, address, browser, website). Without omnisense/omnishield nothing is sent; a provider's error is logged, not shown to the moderator.

Proposer une modification docs/comments.md

In a host's pages

Twig functions

Function Returns
blog_recent(limit = 3) the newest published posts
blog_comments(post = null) the visible comments of a post, or of the visitors' book
blog_comments_count(post = null) how many
blog_comments_open(post = null) whether the visitor may comment there
blog_can_reply() whether the visitor may answer a comment
blog_comment_form(post = null) the comment form's view, posting to the post or to the book
blog_card(post, width = 800) a post's card, the model _card.html.twig reads
blog_discussion(post = null, comments = null) the discussion model _comments.html.twig reads; the comments given, else the visible ones

The partials

Partial Variables
@Blog/client/_card.html.twig card (a Base\Screen\Card), or a host's own post entity, made a card with blog_card()
@Blog/client/_meta.html.twig the same: the date, the reading time, the taxa and tags
@Blog/client/_comments.html.twig discussion, or a host's post (null: the visitors' book) and comments, made a discussion with blog_discussion(); comment_form, else the blog's form when the discussion is open
@Blog/client/_comment.html.twig one comment

A home page with the three latest posts and the visitors' book:

{% for post in blog_recent(3) %}
    {% include '@Blog/client/_card.html.twig' with {post: post} %}
{% endfor %}

{% include '@Blog/client/_comments.html.twig' with {post: null} %}

Overriding

A site overrides a template in templates/bundles/BlogBundle/ (for instance templates/bundles/BlogBundle/client/post.html.twig). Such a template gets the model (screen) and, until omnisense/omnibase 4.0, the former variables (The pages).

The stylesheet

bundles/blog/css/blog.css is added by every page's stylesheets block. Its custom properties: --blog-accent, --blog-ink, --blog-soft, --blog-line, --blog-surface, --blog-measure (the page's width, 72rem by default) and --blog-measure-post (a post's text, 46rem).

Translations

The domain blog, in English, French and German (ICU format): index, post, book, comment (the form, its errors, the flashes), notify, admin. A site overrides a message in its own translations/blog+intl-icu.<locale>.yaml.

Proposer une modification docs/templates.md

Moving a WordPress site in: blog:import-wordpress

bin/console blog:import-wordpress https://example.org
bin/console blog:import-wordpress https://example.org --map about-me/biography=bio --map contact=skip --update
bin/console blog:import-wordpress https://example.org --map var/wordpress-map.json --author=admin
bin/console blog:import-wordpress https://example.org --targets      # the targets this site has

It reads the site's REST API (wp-json/wp/v2, open on any public WordPress: no key) - every page of posts, pages, media, categories, tags - and for each post and page:

  1. cleans its HTML: the scripts and styles plugins add (share buttons, forms that need their JavaScript), unexpanded shortcodes, empty paragraphs, the theme's classes, inline sizes and srcset;
  2. copies its pictures into the uploads (local.uploads, under --uploads, wordpress/ by default: /uploads/wordpress/2014/05/book-a.jpg) - what is there already is left; --no-media leaves them on the old site;
  3. rewrites its links: a picture to its copy (a thumbnail -300x225 to the full picture), a post or a page to its new address - by permalink, by ?p=123, by ?page_id=123, a dated permalink by its slug -, the old home page to /; a page that was not imported keeps its old address;
  4. sends it to its target.

Targets and the map

Target Of omnibase/blog
post yes an omnibase/blog Post under the same slug: title, excerpt, text, its featured picture as its cover, published at its date (a draft stays one), its categories and tags as tags
skip yes left behind
anything else the site's a class implementing Base\Blog\WordPress\TargetInterface

By default a post goes to post, a page to page when the site declares a target of that name, else to skip. The map says otherwise, for one item - by its slug or its path (about-me/biography) - or for all of a kind (*post, *page): --map slug=target, repeatable, or a JSON file ({"about-me/biography": "bio", "schedule-2016": "agenda:2016", "*page": "page"}). What follows the colon is handed to the target ($context->option($item)).

namespace App\Import;

use Base\Blog\WordPress\{Context, Item, Result, TargetInterface};

final class BiographyTarget implements TargetInterface      // autoconfigured: tag blog.wordpress_target
{
    public function getName(): string { return 'bio'; }

    public function url(Item $item): ?string { return '/biography'; }   // before anything is written: the links follow

    public function import(Item $item, Context $context): string
    {
        $bio = $this->bios->findOneFor(...);
        if ($bio && !$context->update) {
            return Result::KEPT;
        }
        // $item->title, ->content (cleaned, links rewritten), ->text(), ->excerpt, ->date, ->categories...
        $this->em->persist($bio);                                      // persist; the importer flushes

        return $bio->getId() ? Result::UPDATED : Result::CREATED;
    }
}

The cover

A post's featured picture (featured_media) becomes its cover: the importer gives every item the address of that picture's copy ($item->cover, /uploads/wordpress/2014/05/a.jpg; $item->featuredMedia is its id on the old site), and the post target uploads it as Post::$cover - once: a cover set since, by hand or by an earlier run, is kept. With --no-media there is no copy, so no cover. A site's own target reads $item->cover the same way.

A slug already taken

omnibase keeps a thread's slug unique whatever its kind: a post named like a product or a page cannot have that slug. The post target then takes the slug with the post's number on the old site (trophees-publicitaires-2170), the same at every run, so the post is found again and never doubled.

Run it again

Without --update, what an earlier run imported is left alone (kept); with it, brought up to date. Either way nothing is doubled: a target finds its record again (a post by its slug). The summary counts, per target, what was created, updated, kept and skipped, and the pictures copied.

In code

$result = $importer->import('https://example.org', ['contact' => 'skip'], update: true);
$items = $importer->read('https://example.org');   // the posts and pages, cleaned, not imported

Base\Blog\WordPress\Client reads the API alone (all($site, 'pages')).

Proposer une modification docs/wordpress-import.md

The back office

The screens are loaded when omnibase/admin is installed. Both carry omnibase/admin's #[OpenToAdmins]: the site's administrator (ROLE_ADMIN: a teacher, an editor) writes in them, not the super-admin only.

Posts

Base\Blog\Controller\Admin\Crud\PostCrudController creates, edits and deletes a post: title, state, slug (following the title), publication date, featured, comments open, headline, excerpt (with a help text), content (the editor, with collaborative autosave and live editing), tags, taxa and cover.

A post published with a date to come waits for it (Posts).

Comments

Base\Blog\Controller\Admin\Crud\CommentCrudController moderates the comments (omnisense/omnibase's Base\Entity\Thread\Comment, every thread's). There is no "new" action: comments come from the visitors. Fields: state, name, e-mail, website, post (thread), parent, content, date; the address and the spam score on the detail page. Filters: state, post.

Three actions, on the index and on the detail page, open to the administrator (#[OpenToAdmins(actions: ['approve', 'spam', 'trash'])]):

Action Path under the screen Does The classifier is told
approve /{entityId}/approve its state becomes approved: online ham, when it was held as spam or had a spam score
spam /{entityId}/spam its state becomes spam spam, when it was online or had no score
trash /{entityId}/trash its state becomes trash nothing

Each flashes @blog.admin.comment.flash.approved, .spam or .trashed and returns to the index. The notifications of a new comment link to this screen, filtered on the comment's state (Comments).

The dashboard tile

Base\Blog\Admin\Widget\PendingCommentsWidgetType is the widget blog_pending_comments: how many comments wait (countPending()) and the five latest (findPending(5)), rendered by @Blog/admin/widget/pending_comments.html.twig.

yield MenuItem::block('blog_pending_comments', 'Commentaires en attente', 'fa-solid fa-comments')->setSize(2);
yield MenuItem::linkToCrud(\Base\Blog\Entity\Post::class, 'Chroniques', 'fa-solid fa-newspaper');
yield MenuItem::linkToCrud(\Base\Entity\Thread\Comment::class, 'Commentaires', 'fa-solid fa-comments');

Proposer une modification docs/back-office.md

Configuration reference

The blog tree

Dumped with Symfony's YamlReferenceDumper from Base\Blog\DependencyInjection\BlogConfiguration:

blog:

    # The first segment of the blog's addresses: /chroniques, /chroniques/{slug}, /chroniques/feed.xml. No slash.
    path:                 chroniques

    # The published posts in /sitemap.xml.
    sitemap:              true

    # Posts listed per page.
    posts_per_page:       12
    comments:

        # Comments at all.
        enabled:              true

        # Comments without an account (a name and an e-mail).
        guest_allowed:        true

        # Replies without an account. Off: anyone writes a word, answering one takes an account.
        guest_replies:        false

        # A comment Akismet finds clean goes online at once; otherwise every comment waits.
        auto_approve:         true

        # Seconds between two comments from the same address.
        flood_interval:       60

        # Seconds between the form being opened and sent: faster is a robot.
        min_delay:            4
        max_length:           4000
Key Type Default Read by
path string chroniques the routes' first segment; one or more segments, no slash at either end (/blog is refused)
sitemap boolean true SitemapListener
posts_per_page integer, at least 1 12 the list
comments.enabled boolean true CommentPolicy
comments.guest_allowed boolean true CommentPolicy: without it, commenting takes an account
comments.guest_replies boolean false CommentPolicy: with it, answering a comment takes no account
comments.auto_approve boolean true a comment the classifier finds clean goes online at once; off, every comment waits
comments.flood_interval integer, at least 0 60 the guard: seconds between two comments from one address
comments.min_delay integer, at least 0 4 the guard: seconds between the form's opening and its sending
comments.max_length integer, at least 100 4000 the comment form's max_length

Each key is a container parameter: %blog.path%, %blog.sitemap%, %blog.posts_per_page%, %blog.comments.enabled%... %blog.comments.max_length%.

Services

config/services.php autowires and autoconfigures the bundle's classes; the admin screens and the widget are loaded with omnibase/admin.

Service Role
Base\Blog\Repository\PostRepository the post queries (Posts)
Base\Blog\Screen\BlogScreens the pages' view models, card(), discussion()
Base\Blog\Service\CommentPolicy isOpen($post, $signedIn), canReply($signedIn)
Base\Blog\Service\CommentNotifier commentLeft($comment)
Base\Blog\Service\AkismetReporter reportSpam($comment), reportHam($comment)
Base\Blog\Service\JsonLd article($post, $url, $base)
Base\Blog\EventListener\SitemapListener the posts of /sitemap.xml
Base\Blog\Twig\BlogExtension the Twig functions
Base\Blog\Command\ImportWordPressCommand blog:import-wordpress

A service tagged blog.wordpress_target (Base\Blog\WordPress\TargetInterface, autoconfigured) is a target of the WordPress import (Moving a WordPress site in).

Proposer une modification docs/configuration.md

Testing

Running the suite

From the package's checkout, with its dev dependencies:

vendor/bin/phpunit

Inside a host application:

php vendor/bin/phpunit -c vendor/omnibase/blog/phpunit.xml.dist

In omnisense/omnibase-harness, mounting the checkout in place of the installed package:

docker run --rm --network none \
    -v "$PWD":/srv/demo/vendor/omnibase/blog:ro \
    omnisense/omnibase-harness:latest test blog

On this checkout, in the harness:

Tests: 31, Assertions: 177, Failures: 1, Skipped: 1.

The skipped test is the captcha's: the harness asks the captcha only after refused tries (base.guard.captcha_after). The failure is the snapshot test (below).

What the tests check

Test Checks
Controller\Admin\OpenToAdminsTest the administrator writes in both screens; anyone else is refused (needs a host kernel)
Controller\CommentGuardTest a comment written as a person goes through; a filled trap is a robot; a comment sent faster than min_delay is refused; a comment without the captcha's token is refused, when the host has a captcha
Controller\CrudRequiredFieldsTest omnibase/admin's CrudFieldsTestCase on the two screens
DependencyInjection\ConfigurationTest a site that says nothing keeps chroniques; a site's own path; a path with slashes around is refused
Screen\BlogPagesSnapshotTest the pages equal to the recorded HTML; the screens given as data
Service\AkismetReporterTest a spam let through is reported as the visitor sent it; without a classifier nothing is sent
Service\JsonLdTest a post is a BlogPosting; a post without cover nor author keeps to what it knows; a long title is cut; the cover's address
WordPress\ImporterTest on a recorded excerpt of a real site's API: the client follows every page, the HTML is cleaned, the links follow what was imported, a second run doubles nothing, no media means no cover, an unknown target is refused

The snapshots

Screen\BlogPagesSnapshotTest renders the pages through the host's kernel (the list and its variants, a post seen by a visitor and by a member, the feed, the book, a refused comment shown again with status 422, the partials a host page includes) and compares them with tests/Screen/snapshots/*.html. Every date is fixed; the CSRF token, the form's opening time and the guard's stamp are set aside. A missing snapshot is written and the test marked incomplete; BLOG_SNAPSHOT_RECORD=1 writes them all again.

The host's built core assets (/bundles/base/<entry>.<hash>.css|js: the base-async, base-defer, form-defer entry points) are set aside on both sides: they are printed only where the host built them, and their hash changes at each build.

Proposer une modification docs/testing.md