瀏覽代碼

Describe the architecture in design.md

Readme.md covers installation and a feature tour but never says what tCMS is
*for* or how it hangs together, so the thing a contractor most needs -- "where
does my change go" -- takes an afternoon of reading source to answer.

design.md states the goal plainly: disintermediation from aggregators, which in
practice means making a new content type cheap enough that building the client
the thing they actually asked for beats talking them out of it.  Everything else
follows from that and is documented as following from it, rather than as a list
of modules.

It covers the one idea the system is built on -- everything is a post, so the
routing table is data and a content type is two files -- then the request
lifecycle, post types and their sidecars, series and ACLs, the data model and
datasource contracts, rendering, components, themes, relations, and operations.

Two sections carry most of the practical weight.  "Where does my change go" is a
table from what you want to what you touch, sorted by how much Perl it costs,
because most of the useful answers cost none.  "Things that will bite you"
collects the invariants that are not obvious from the code containing them: that
tags carry visibility and so anything filtering on them is a security boundary,
that get(id => ...) skips the ACL filter on purpose, that FlatFile caches its tag
list in a way that deletes unknown tags from a query, that a component which
merely dies vanishes from the page silently, and the load cycle that is why
components load lazily.

Every path, route and default named in it was checked against the source rather
than recalled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6Q1gRJ2Rr85S8FQ47EjnQ
George S. Baugh 1 月之前
父節點
當前提交
d0c6043046
共有 2 個文件被更改,包括 436 次插入 和 0 次删除
  1. 14 0
      Readme.md
  2. 422 0
      design.md

+ 14 - 0
Readme.md

@@ -4,6 +4,17 @@ tCMS
 A flexible perl CMS which supports multiple data models and content types.
 Should be readily portable/hostable between any other system that runs tCMS due to being largely self-contained.
 
+tCMS exists for **disintermediation from aggregators**: your content, at your
+domain, in your database, under your rules.  It is built so that standing up a
+site hosting content of any imaginable type is a day's work rather than a
+project -- which is to say, it is built for people who get paid to deliver the
+thing a client actually asked for.
+
+**See [design.md](design.md) for the architecture**: what a post is, why the
+routing table is data, how to add a content type without writing Perl, and where
+any given change belongs.  Start there if you are going to work on tCMS or build
+something on top of it.
+
 tCMS is built to be run by tPSGI.
 
 Simple deployment is currently:
@@ -44,6 +55,9 @@ Many of the advanced features of tCMS won't work quite right without the configu
 
 Content Types
 =============
+See [design.md](design.md#post-types) for how post types work and what the
+sidecar can declare.  In brief:
+
 Content templates are modular.
 Add in a template to www/templates/html/components/forms which describe the content *and* how to edit it.
 Our post data storage being JSON allows us the flexibility to have any kind of meta associated with posts, so go hog wild.

+ 422 - 0
design.md

@@ -0,0 +1,422 @@
+tCMS Design
+===========
+
+This document describes how tCMS is put together and, more importantly, *why* --
+so that you can tell at a glance where your change belongs.
+
+Read the [Readme](Readme.md) first for installation and the feature-by-feature
+tour. This is the architecture behind it.
+
+What tCMS is for
+================
+
+**Disintermediation from aggregators.**
+
+Publishing something today usually means publishing it on somebody else's
+property. The aggregator owns the URL, the audience, the ranking, the terms, and
+the right to change any of them without telling you. Your content is the product
+being sold, and you are renting access to people who already agreed to hear from
+you.
+
+tCMS exists so that the thing you publish lives at *your* domain, in *your*
+database, under *your* rules, and so that standing that up is a day's work rather
+than a project.
+
+The obstacle has never been "can I host a blog". It is that real sites are not
+blogs. A client wants invoices, or a photo gallery with EXIF, or a list of
+virtual machines with a start button, or a recipe index with cook times, or a
+paywalled newsletter, or all of those on one site. Every CMS handles the first
+one, and every CMS makes the second one a plugin project.
+
+So the design goal is narrower and more useful than "a CMS":
+
+> **Make a new content type cheap enough that building the client the thing they
+> actually asked for is faster than talking them out of it.**
+
+That is the bet the whole architecture is placed on. If you are a Perl contractor
+holding a statement of work, the question you should be able to answer in minutes
+is "what does a `Widget` post look like, and where does the Widget page come
+from" -- and the answer should be a template, a small JSON file, and nothing
+else.
+
+The one idea
+============
+
+**Everything is a post.**
+
+Not "everything is a post, plus pages, plus users, plus categories, plus
+settings". Everything:
+
+| The thing | Is a post |
+|---|---|
+| A blog entry | tagged with its series, `form` is `blog.tx` |
+| A page | a post whose `local_href` is where you want it |
+| A user profile | a post tagged `about`; the user *is* their profile page |
+| A category / section | a "series" post, which owns an ACL and names its children's type |
+| A virtual machine | a post, built on demand by a datasource, never stored |
+
+A post is a JSON object. It has a handful of fields every post has -- `id`,
+`title`, `data`, `tags`, `created`, `version`, `visibility`, `local_href`,
+`callback` -- and then whatever else its type says it has.
+
+Two consequences fall out of that, and most of the system is downstream of them.
+
+**The routing table is data, not code.** Every post carries a `local_href` and a
+`callback`. At startup, `TCMS::_routes` asks the data model for every post and
+builds the routing table out of them, then merges the static routes from
+`Trog::Routes::HTML`, `Trog::Routes::JSON` and the active theme over the top.
+Publishing a post creates a URL because the URL *is* a field on the post. There
+is no separate router config to keep in sync, and no rewrite rules.
+
+**Adding a content type does not require touching Perl.** A post type is a
+template that says how to display and edit it, plus a JSON sidecar that says what
+fields it has. Both are files in a directory. See [Post types](#post-types).
+
+The request lifecycle
+=====================
+
+tCMS is a routing module, not a server. It is run by **tPSGI**, which owns the
+socket, the process model, the access log and the static file cache. tPSGI hands
+each request to the callback tCMS registered for that route, and hands the
+callback a `tpsgi` object for the things only the server can do -- redirect,
+forbid, invalidate cached renders, run work after the response is closed.
+
+```
+  tPSGI
+    │  route lookup against the table TCMS built at startup
+    ▼
+  TCMS::build_routes' wrapper          ← identity, ACLs, logging context
+    │
+    ▼
+  Trog::Routes::HTML::<callback>       ← e.g. series() → posts() → index()
+    │
+    ├──▶ Trog::Data->new(config)       ← the data model: get() the posts
+    │      └──▶ Trog::DataSource       ← unless this type's posts are built, not stored
+    │
+    ├──▶ _enrich_post                  ← resolve declared relations, run enrich subs
+    │
+    ▼
+  Trog::Renderer->render(...)          ← picks a renderer by content type
+    │      └──▶ Text::Xslate           ← templates, with component() and render_it()
+    ▼
+  [ code, headers, body ]              ← a PSGI triplet
+    │
+    └──▶ tpsgi->save_render(...)       ← if this page is cacheable, write a static
+```
+
+The wrapper installed by `TCMS::build_routes` is where a request stops being
+anonymous bytes and becomes a request by somebody:
+
+1. Initialise logging into tPSGI's log directory.
+2. If `config/setup` does not exist, this is a fresh install -- everything goes
+   to the setup route.
+3. Pull the `tcmslogin` cookie, resolve it to a user, load that user's ACLs into
+   `$query->{user_acls}`.
+4. Strip an `admin` ACL from anything the *request* asked for unless the user
+   actually holds it.
+5. Refuse the request if the route requires auth and nobody is logged in.
+
+Everything downstream reads `$query->{user_acls}` and never asks who the user is
+again.
+
+The static render cache
+-----------------------
+
+`Trog::Renderer::render` hands finished pages back to tPSGI to save as static
+files, but only when caching one is safe. It skips when the render is a
+component, when the route says `nocache`, when there is no route to key on, when
+the request had a query string, **when a user is logged in**, when the status is
+not 200, or when running at debug level.
+
+That last-but-two is the important one: a logged-in reader sees `/secure/...`
+variants and editing chrome, and caching those would serve one user's view to
+another. Anonymous readers get files off disk; editors get live renders. Saving a
+post invalidates the cache.
+
+Post types
+==========
+
+This is the extension point that matters, so it gets the most detail.
+
+A post type is two files in `www/templates/html/components/forms/`:
+
+    blog.tx      the template: how a post of this type displays, and how it edits
+    blog.json    the sidecar: an OpenAPIv3 object schema for its fields
+
+`Trog::DataModule::schema_for` merges the sidecar with the base post schema and
+uses the result to validate every save. The base schema is merged **last**, on
+purpose: a sidecar can add fields but can never redefine a core one. A sidecar
+retyping `callback` as a plain string would defeat the check that the sub it
+names actually exists, which is a privilege problem rather than a cosmetic one.
+
+The sidecar carries the wizard's own UI metadata on `x-` extension keys, so there
+is one file to keep in sync rather than two:
+
+| Key | Means |
+|---|---|
+| `x-tcms-label`, `x-tcms-placeholder` | what the editor shows for this field |
+| `x-tcms-input` | which widget to render |
+| `x-tcms-private` | editors only -- the value is dropped before rendering, so no template mistake can leak it |
+| `x-tcms-indexed` | give this field its own indexed column, where the data model can |
+| `x-tcms-relation-form` | this field holds another post's UUID |
+| `x-tcms-relations` | what to resolve onto the post at render time |
+| `x-tcms-datasource` | these posts are built by a module, not stored |
+| `x-tcms-post-type` | the type's own name and description |
+
+You do not have to write either file by hand. **The Post Type Wizard**
+(`/admin/wyzzerdd`) generates both from a form: name the fields, pick their
+types, tick whether each is required, editors-only, or indexed, and write the
+display snippet. It writes the template and the sidecar into whichever forms
+directory is in use -- the theme's if it has one, the stock one otherwise -- and
+the type is live on the next request. No restart: `themed_templates_in_dir`
+re-reads the directory every call and Xslate resolves includes lazily.
+
+The generated template is ordinary Kolon, so hand-editing it afterwards is
+expected and fine. Regenerating the type overwrites it, which is why the wizard
+says so at the top of what it writes.
+
+Series, and how content is organised
+------------------------------------
+
+A **series** is a post of type `series.tx` that does two jobs:
+
+- It **owns an ACL**. Its `aclname` is the tag its children carry, so "who may
+  read this section" is one field on one post.
+- It **names its children's type** via `child_form`. A series of `invoice.tx`
+  lists invoices; the same site's series of `blog.tx` lists blog entries.
+
+Tag a series `topbar` and it appears in the site navigation. That is the whole
+of "site structure" -- there is no menu editor, because the menu is a query.
+
+Visibility and ACLs
+-------------------
+
+`visibility` is one of `public`, `unlisted`, `private`. `Trog::DataModule::_process`
+folds it into the post's `tags`, along with the post's ACLs when it is private,
+and then deletes the separate `acls` field. **Tags are the whole truth about who
+may see a post**, which is why `get()` answers the `acls` filter by looking at
+tags and nothing else.
+
+Know this before you touch anything that filters. A change that makes tags and
+ACLs diverge is a disclosure bug, not a refactor.
+
+Storage: data models
+====================
+
+A data model is where posts live. `Trog::Data` is a factory; the implementation
+is chosen by `general.data_model` in the config.
+
+`Trog::DataModule` is the base class and provides the query semantics -- `get()`,
+`filter()`, `paginate()`, version rollup, validation, and the derived-field
+plumbing in `_process`. A subclass must implement `read`, `write`, `count`,
+`tags`, `lang` and `help`, and may override `get` if it can answer a query more
+directly than by filtering in Perl.
+
+| Model | What it is | Use it when |
+|---|---|---|
+| `DUMMY` | one JSON blob | never in production; it is not race-safe. It exists as the smallest possible worked example of the interface |
+| `FlatFile` | one file per post, SQLite alongside as a tag index | you want the posts to be greppable files on disk, and the site is small |
+| `SQLite` | posts in SQLite, blob and all | anything else |
+
+Posts are **versioned**. A save appends a version rather than overwriting, and
+`get()` hands back the newest one, carrying `version_max` plus the original
+`created` and `author` from the first version. Asking for a specific `version`
+gets that one.
+
+The SQLite model is worth understanding as a pattern, because it is the shape a
+new model should copy: it stores the same JSON blob the flat file model would
+have written and projects everything worth querying back out of it as
+`GENERATED ALWAYS ... VIRTUAL` columns. The blob stays the single source of
+truth, the projections cost no storage and cannot drift from it, and adding a
+queryable field is an `ALTER TABLE` plus a `CREATE INDEX` with no migration --
+because the data never moves. Tag membership needs one row per tag, so triggers
+unpack `json_each` into an index table; search is FTS5 with the trigram
+tokenizer, which is the one tokenizer that answers the substring queries the
+search box has always meant.
+
+Foreign authority: data sources
+===============================
+
+Sometimes the authoritative copy of something is not yours and never will be. A
+hypervisor knows what guests exist; a payment processor knows what was paid; an
+LDAP directory knows who works here. Copying that into the datastore means it is
+wrong the moment you copy it.
+
+A **datasource** lets a post type say "my posts are built, not stored". The
+series is still an ordinary post; only its children are synthesized, and they are
+rebuilt on every view.
+
+    "x-tcms-datasource": "Trog::DataSource::Virt"
+
+The module provides `posts($series, $query)` and may provide `filter`, `order`,
+`lang`, `help` and `EDITABLE`. `Trog::DataSource` documents the whole contract
+and supplies the defaults.
+
+Two things about the contract are worth knowing before you write one, because
+both are places the obvious implementation is wrong:
+
+- **Your posts have never been near the datastore**, so nothing has filtered
+  them. The reader's search was answered against the datastore and thrown away
+  along with the posts it filtered. `filter()` is how the search reaches your
+  posts, and you should implement it if your posts carry anything worth
+  searching beyond a title.
+- **Your posts probably all share a timestamp**, because you stamped them with
+  the time you built them. Stored posts paginate by a `created` cursor, which on
+  such a page cannot move -- every cursor is the same instant. Datasource pages
+  therefore paginate by page number, which is why `order()` has to be total: page
+  2 of a shuffled list is a lottery.
+
+Rendering
+=========
+
+`Trog::Renderer->render(%options)` dispatches on content type to a renderer under
+`Trog::Renderer::`, all of which subclass `Trog::Renderer::Base`. The known types
+are in `Trog::Vars::%content_types`: text, html, json, blob, xml, xsl, css, rss,
+email. (`Trog::Renderer::javascript` exists but is not registered in the
+dispatcher.)
+
+Base sets up the Xslate instance, resolves templates against the theme's
+directory and then the stock one, and injects two functions every template can
+call:
+
+- `render_it($string)` -- render a template string that came out of a post, which
+  is what lets a post body contain `<: embed(12345, 'embed') :>`.
+- `component('Name', {...})` -- render a `Trog::Component`.
+
+Components
+----------
+
+A component is a module in `Trog::Component` with a `render(%args)` returning a
+string. Templates ask for what they want by name rather than having a route
+render it and thread the string through:
+
+    <: component('EmojiPicker') :>
+    <: component('Gallery', { album => $post.id, cols => 3 }) :>
+
+The page furniture is built this way -- `Header`, `Footer`, `HtmlTitle`,
+`MidTitle`, `TopBar`, `LeftBar`, `RightBar`, `FootBar`, `CategoryBar` are what
+`index.tx` is made of. Each module owns the name of the template it renders, so
+adding a piece of furniture does not mean editing a route.
+
+One caution if you write one: Xslate catches an exception thrown out of a
+template function, warns, and renders on with an empty string where the call was.
+A component that merely dies would vanish from the page with a line on stderr to
+show for it, so `Trog::Component` also records the failure for `Renderer::Base`
+to turn into a 500.
+
+Themes
+------
+
+A theme is a directory under `www/themes/` mirroring the structure of `www/`.
+Templates resolve against the theme's directory first and the stock one second,
+so a theme overrides individual templates without forking the ones next to them.
+Stylesheets are the exception and are loaded both, theme last, so a theme can
+override a handful of rules without restating the sheet. A theme may ship a
+`routes.pm` to add routes of its own.
+
+Composition: relations and enrichment
+=====================================
+
+Types need to refer to each other -- an invoice has a payee and a payor, which
+are `entities` posts. A type declares that in its sidecar and
+`Trog::Routes::HTML::_enrich_post` resolves it at render time:
+
+- an entry naming a `from` field resolves the UUID in that field into the post it
+  refers to;
+- an entry without one pulls in *every* post of the target type, which is what an
+  editor's picker or a "show me all of these" display wants.
+
+Resolution is memoised per request, or a page of twenty-five invoices rescans
+every entities post twenty-five times.
+
+When a type needs something computed rather than merely fetched, it gets an
+enrich sub -- see `Trog::Enrich::Invoice`, which sums a multi-page invoice's line
+items into a total.
+
+Identity, configuration, operations
+===================================
+
+**Auth** (`Trog::Auth`) is an SQLite database at `config/auth.db` holding hashed
+and salted passwords, ACLs and sessions. A session is the `tcmslogin` cookie.
+TOTP two-factor is supported. The first user to register becomes the
+administrator and registration then closes; further users are made by an admin
+through the `about` post type -- because a user *is* their profile page.
+
+**Config** (`Trog::Config`) is a thin wrapper over `Config::Simple` reading
+`config/main.cfg` and falling back to the shipped `config/default.cfg`. It is
+memoised, so a config change signals the workers to restart.
+
+**Logging** (`Trog::Log`) goes to a rotating file, to the screen at error level
+and above -- which is what reaches the webserver's log -- and to SQLite via
+`Trog::Log::DBI`, which is what `Trog::Log::Metrics` later reads time series out
+of. Log lines start with an ISO8601 date because fail2ban parses them; do not
+change that. UTM parameters are captured per request, so the analytics are yours
+too.
+
+**Deployment** is tPSGI behind nginx. See the Readme for development, and the
+`trog-provisioner` recipes (`tcms`, `tpsgi`, `nginxproxy`) for production --
+several features want the configuration encoded there.
+
+Where does my change go?
+========================
+
+The contractor's cheat-sheet.
+
+| I need... | Do this | Perl required |
+|---|---|---|
+| A new kind of content | Post Type Wizard, or write `foo.tx` + `foo.json` by hand | none |
+| A field on an existing type | edit its sidecar; the editor and validator follow | none |
+| That field to be searchable/filterable fast | tick "Index this field" in the wizard | none |
+| A field only editors may see | `x-tcms-private` in the sidecar | none |
+| One type to refer to another | `x-tcms-relations` in the sidecar | none |
+| A new section of the site | make a series post; tag it `topbar` for the nav | none |
+| To restrict a section | give the series an ACL; grant it to users | none |
+| A different look | a theme directory; override the templates you care about | none |
+| A reusable chunk of UI | `Trog::Component::Whatever` with `render(%args)` | a little |
+| Content from an external system | `Trog::DataSource::Whatever` with `posts($series,$query)` | some |
+| A computed field on a type | an enrich sub, as `Trog::Enrich::Invoice` does | some |
+| A route that is not a post | add to `%Trog::Routes::HTML::routes`, or a theme's `routes.pm` | some |
+| A different place to keep posts | subclass `Trog::DataModule` | most |
+| A new output format | a `Trog::Renderer::` subclass, registered in `Trog::Renderer` | most |
+
+Things that will bite you
+=========================
+
+Hard-won, and none of them obvious from reading the code that contains them.
+
+- **Tags carry visibility and ACLs.** `_process` folds them in and deletes the
+  separate field. Anything that filters on tags is a security boundary.
+- **`get(id => ...)` deliberately skips the ACL filter.** `filter()` returns early
+  for an `id`, `title` or `aclname` query, because that is how `add()` asks
+  whether a post already exists. Do not "fix" it without reading every caller.
+- **Validation runs on what was submitted; `_process` then builds what gets
+  stored.** Both are checked now (`validate` and `validate_built`), and they
+  check different objects. A field that only exists after `_process` will not be
+  in the first one.
+- **The FlatFile model caches its tag list at load time** and filters each query's
+  tags against it *in place*. A tag created later is not merely unindexed, it is
+  deleted from the query. Production survives this because a save re-execs the
+  workers; anything long-lived doing its own reads has to refresh it by hand.
+- **Sidecar schemas are cached on the forms directory's mtime.** Writing a
+  sidecar and reading it back in the same process is fine; the resolution is
+  sub-second, but the cache is real.
+- **A component that fails must be recorded, not merely thrown.** See above.
+- **Never `use` `Trog::Renderer` from `Trog::Component`.** `Renderer::Base` uses
+  `Trog::Component`; the cycle is why components load lazily.
+
+Testing
+=======
+
+`prove -l t/`. The suite is `Test::More` with `Test::MockModule` and
+`Test::Fatal`.
+
+The one to know about is `t/Trog-Routes-HTML-series-lifecycle.t`, which stands up
+a genuinely fresh tCMS in a temp directory and walks what a user actually does:
+register an admin, invent a post type in the wizard, build a series of every
+content type, add children, and confirm each renders. It asserts the repository
+itself was not touched, which is the only real proof of isolation.
+
+It is parameterised by `TCMS_TEST_DATA_MODEL` and runs against every data model,
+because **a data model is only finished when the application cannot tell which
+one it is talking to.** If you add a model, that is the test that says whether
+you are done.