История коммитов

.
feat(modules): publish the assets of a module
A module may ship styles and scripts of its own. They are shipped built —
there is no Vite on a site that only runs the CMS — and installing the
module copies them out of modules/, which is not reachable by URL, into
public/modules/<alias>/. Switching the module off takes them out again.

Only files a browser loads are copied. Everything under the document root
is served by the web server, so a .php among the assets of a module would
be code the module put where anyone can run it; a module that needs to
answer a request declares a route. A directory left with nothing
publishable is not created at all.

Copying rather than linking: half the shared hosting this CMS runs on
cannot make a symlink, and on the other half it is a way to lose a whole
site. --symlink exists for developing a module and has to be asked for.

Published files alone do nothing, so the layout prints what the modules
declare: module_assets() next to vite() in the base templates, with the
version of the module as the cache buster, since a module has no build
hashes to rely on. A template of the module addresses a single file with
module_asset('blog', 'js/app.js').
.
feat(modules): the lifecycle of a module
Installing, switching on and off, updating and uninstalling — in one
service, which is the only place any of it happens. Files on disk remain
somebody else's business: Composer put them there, or an archive, or a
person over FTP. What happens here is the record the site keeps, the
migrations, the hooks of the module and the caches that would otherwise
still describe the previous set of modules.

Every operation answers with the steps it took rather than a boolean.
Installing is half a dozen things in a row and any of them can be the one
that failed; the console prints that list, and the admin panel will show
it.

What it refuses is as much the point as what it does: a module that is not
there, one already installed, an alias another module holds, a version this
site cannot run, a system module, one another installed module needs — and,
before anything is undone, a module whose migrations never said how to be
rolled back. Undoing half a module leaves a schema nobody can describe.

Uninstalling keeps the tables. --purge is what deletes the data, and it is
a separate word on purpose.

Installer gets install() and update($from, $to) back, and none of its
methods are abstract any more: a module overrides what it needs. Tables are
still created by migrations alone. ModuleInstaller is gone, and step 4 of
the site installer calls the installers of the modules itself.

Two things this turned up:

The registry and the state store cache what they read, and the factory
builds them from instances of its own — not the ones in the container. So
installing wrote the file while the migrations ran against a list of
sources the new module was not in yet: the first run reported "0 applied"
with a live migration on disk. Both grew a forget(), called after every
write.

Migrator is final readonly, so it can be neither replaced nor mocked. The
service now depends on MigrationRunnerInterface — run, rollback,
irreversible — which is what .agents/architecture.md asks for anyway and
the only way to test these operations without a database.
.
feat(modules): compatibility and dependencies
A module may now say what it cannot run without: a version of PHP, a
version of the CMS, and other modules. Constraints are Composer
constraints, read by composer/semver — the language a module author already
writes in composer.json, and not a second one invented here.

The check runs in the registry, in a loop until nothing changes. A module
whose requirement is not met becomes incompatible and is not loaded, and
neither is whatever was built on it: requirements chain, and one pass would
leave a module loaded against a dependency that is not there — which is the
container failing to compile, on a page nobody touched.

Two rules the plan did not have, and the site needs both:

A module a system module requires stays loaded even when the configuration
switches it off, and the listing says who is holding it. Obeying would take
the admin panel down and with it the only way to put the module back. For
the same reason a system module can no longer be switched off at all —
"system" now means that rather than a recommendation.

The dependencies that exist are declared: admin needs forum and
registration, contacts needs consent, profile needs forum, guestbook and
mail, registration needs consent. They were found by reading constructors
and confirmed by switching each module off — without the declaration the
container fails with "Cannot autowire … references interface", with it the
dependent module reports itself incompatible and the site stays up.

The sweep in the previous commit missed half of these: it searched the
output for "references class", and Symfony writes "references interface"
for an interface.

CMS_VERSION becomes 10.0, without which requires.johncms means nothing.
It also moves the cache prefix (jc9_9 -> jc10_0) and asks johncms.com for
the languages of 10.0; both are in the changelog.
.
feat(modules): autoload the classes of a module
A module installed into a site cannot put its namespace in the root
composer.json: that file belongs to the release, and an upgrade overwrites
it — along with the list of what the site installed. So a module says what
to load in its own manifest, and ModuleAutoloader registers it at boot,
before the container is compiled out of those very classes.

The prefixes go into the ClassLoader of Composer rather than an autoloader
of our own: it is already registered, it already resolves PSR-4, and one
loader means one order of resolution. Modules of the release stay in the
root composer.json, where an optimised classmap can cover them, and declare
no autoload at all.

Registration follows enabled(), so a module that is switched off loses its
classes too. Without that its controllers would still be constructible and
"switched off" would only be a word in a listing.

A module may also point at a vendor/autoload.php of its own — the only way
the dependencies it ships get loaded, since Composer of the site knows
nothing about them. One it forgot to ship is a broken package, not a
stopped boot: the modules after it are still registered.

The autoload field is the one deferred in the manifest commit until
something read it. This is that something.
.
refactor(modules): load services, routes and migrations from the registry
The last of the globs over modules/ are gone. Services and routes come from
the modules the registry loads, migration sources from the ones it has
installed, and ModuleContext asks it for the alias and the directory of the
module a route belongs to.

Installed rather than loaded is the rule for migrations, and the difference
matters: switching a module off leaves its tables in the database, so its
source stays in the journal. A module merely dropped into the directory is
not there at all — nothing of it has run.

A switched-off module now answers 404 instead of 500: it has no routes to
reach a controller that is no longer a service.

FileSystemLanguageFilesManager keeps its glob on purpose. Removing a
language has to take the dictionaries of every module lying on disk, the
switched-off and the never-installed included, or the language comes half
back the moment such a module is switched on.

Two things this turned up:

The core depended on a module. CronCleanupForumFilesCommand lived in
system/src/Console/Commands and took a use case of the forum, so switching
the forum off failed to compile the container. It moves into the module as
Forum\Application\Console\CleanupOrphanFilesCommand — same command name,
same schedule — and .agents/architecture.md now says why the core may not
reference a module and where such a command belongs.

Safe mode meant "system modules only", which helped nobody: the admin panel
autowires services of forum, consent, guestbook and registration, so
dropping them takes the panel down as well. It now means "the modules of
the release only", which is the situation it exists for — a third-party
module takes the site down and there is no way in to remove it. Those four
modules still cannot be switched off; that is for the dependency graph to
refuse politely, later.
.
feat(modules): the registry of installed modules
Until now a module was whatever lay in the directory: the container loaded
the services of every one it found, and being listed in the configuration
decided nothing but a Twig namespace. There was no way to say that a module
is installed, and none at all to switch one off.

The registry answers that, from three things that routinely disagree: what
is on disk, what this release ships (bundled, in modules.global.php) and
what the site has done since (the generated modules.local.php). A module of
the release counts as installed until the site says otherwise, so a working
site needs no state file at all; a third-party module counts as installed
only once it is recorded, because files in a directory are not migrations
that have run.

Nothing here raises. A module whose files were deleted and a module whose
alias another one already holds become broken, with a sentence saying why,
and are left out of the loading — a directory must not be able to take a
site down. The installed module keeps the alias against one just dropped
in; refusing that is the business of installing, which comes later.

The factory is static, like PSRContainerFactory and for the same reason:
the list decides whose services.php the container compiles, so it cannot
come out of the container. It reads bundled straight from the file rather
than through config(), so a cached container does not freeze the modules of
the release it was compiled under.

Also here: MODULES_SAFE_MODE, which loads the system modules only;
module:list and module:sync; the state file, written whole through a
temporary file; and the installer, which now records what it put in.
Johncms\Modules\Modules is gone.

Only the template lookup asks the registry so far — routes and services
still come from a glob, so a module switched off by hand answers with its
routes and no templates. That is the next step.
.
feat(modules): describe every module with a manifest
A directory is a module because it carries module.php. The manifest holds
what the system has to know before anything of the module is loaded: the
key (vendor/name, the directory and the package name), the alias (the flat
name a Twig namespace, a gettext domain and a migration source are made of),
what to call it, its version and whether it may be switched off at all.

The loader refuses rather than skips. A key that does not match the
directory it lies in, a key that is not vendor/name, an alias holding a
slash, a field of the wrong type, a file returning something other than an
array — each is answered with the file name and the reason. A module
dropped because of a typo would be routes, tables and permissions that
silently stop existing, and the failure would surface somewhere else.

The repository is the opposite: a directory with no manifest is not a
module and is passed over, so an unpacked archive or a leftover of the
one-level layout cannot stop the boot.

Nothing reads any of this yet — the registry that will is the next step.
The fields it does not need yet are absent on purpose: requirements arrive
with the compatibility check, autoload with the module autoloader, assets
with their publishing. A field nobody reads is dead code repeated in 21
files.
.
refactor(modules): move the bundled modules under a vendor directory
modules/<module>/ becomes modules/johncms/<module>/. A module gets a vendor
of its own, so two authors may publish a module of the same name without
colliding — the ground the installable-module work needs before anything
else can be built on it.

Only paths move. The template namespace (@forum), the translation domain
(d__('forum', …)) and the migration source (--source=forum) are the name
alone and stay what they were: nothing in a template, a dictionary or the
migrations table is rewritten, and a site upgrading from 9.9 keeps the
journal it already has.

What that costs elsewhere:

* the glob patterns of the container, the router, the migration sources,
the language file manager and i18n:translate look two levels deep;
* TemplatePathRegistry is given module keys and takes the namespace as the
basename of one, so a theme still overrides templates/<name>;
* the _module attribute of a route now carries the key (johncms/forum).
ModuleContext resolves the directory from it and the domain from its
basename — the route has to say where the dictionaries lie, and the name
alone no longer does;
* paths in composer.json, phpstan-baseline.neon, translate.xml.dist,
crowdin.yml, phpunit.xml.dist, phpcs.xml.dist, modules.global.php and
ide-twig.json.

CHANGELOG and the documentation submodule carry the upgrade instruction:
the old one-level directories are not picked up, but have to be deleted by
hand after unpacking.
.
ci(tests): build the assets before the functional suite
The suite renders real pages, and every layout asks Vite for the entry point of its theme. A runner has no compiled bundle, so each of them failed on the missing manifest — 43 of 72 tests. The job builds the assets the way the Node one does.
.
test(functional): check a route's permission from an account that holds it
The suite could only drive requests as a guest, so a gate was pinned by the refusal alone; an account now says which permission it holds and the way in is covered too. Runs the functional suite in CI, and writes down how a test is written in .agents/testing.md.