Skip to content

feat(content): ✨ Content Containers (Stage 2) - #875

Merged
aXenDeveloper merged 1 commit into
canaryfrom
claude/content-containers
Oct 13, 2026
Merged

aXenDeveloper merged 1 commit into
canaryfrom
claude/content-containers

Conversation

@aXenDeveloper

Copy link
Copy Markdown
Owner

Improving Documentation

  • Formatting and lint were run on every changed file (oxfmt, oxlint --type-aware).
  • New docs pages: content-engine/containers.mdx, container-hierarchy.mdx and container-frontend.mdx, in a new "Organize content" group. These pages were also updated: fields.mdx, reference.mdx, public-api.mdx, public-write.mdx, public-write-api.mdx, events.mdx, index.mdx, plugin-files.mdx and dev/events/built-in-events.mdx.

Stacked PR. This branch starts from claude/html-page-cache (#873) and merges claude/content-public-write (#874, Stage 1 publicWrite), because containers extend the Stage 1 write pipeline. Until both merge, this diff also shows their commits. The Stage 2 work is the five commits after the merge commit c03f8888.

Description

What?

Content Containers organize content into hierarchical collections (forum boards, blog sections, knowledge-base categories), so you don't need one content type per category. They are built into the Content Engine and are not tied to forums.

Developer API

export const boardContainers = defineContentContainer({
  id: "example.boards",
  fields: {
    description: field.textarea({ maxLength: 500, nullable: true }),
    color: field.text({ maxLength: 7, nullable: true }),
    locked: field.boolean({ defaultValue: false }),
  },
  hierarchy: { maxDepth: 3 },
  ordering: { mode: "manual" }, // or "alphabetical"
  publicApi: { path: "boards", fields: ["description", "color"] },
  admin: { path: "example/boards" },
});

// in a content type
board: field.container({ definition: boardContainers, required: true }),

// registration, beside the content types
buildContentAdminModule({ pluginId, contentTypes, containers: [boardContainers] });
buildContentPublicModule({ pluginId, contentTypes, containers: [boardContainers] });

Architecture

  • Storage: one core table, core_content_containers. It is an adjacency list (parentId, RESTRICT) with a sibling position, a slug unique per definition, and metadata jsonb that is checked against the definition's own fields on every write. A definition is code, not schema, so adding one needs no migration.
  • Tree service (createContentContainerService): create, update, move, reorder, delete, list, children, ancestors and descendants.
    • Every write takes a per-definition advisory lock, reads the tree, and plans the whole change before touching a row. A refused write therefore changes nothing, and concurrent writes are serialized.
    • The service refuses: self-parenting, cycles, maxDepth (it counts the height of the subtree being moved), parents from another definition, out-of-range positions, duplicate slugs, and deleting a container that has children or content.
    • Each refusal carries a code the AdminCP translates.
  • Linking content: field.container() is a new field kind, so it reuses the column, schema, form, filter and public-projection pipeline.
    • It is at most one per content type, an indexed RESTRICT FK, indexed as (container, publishedAt) when the type uses publication.
    • Every admin, editorial and public write checks that the id exists and belongs to the declared collection.
  • publicWrite (Stage 1): the container may appear in create.fields and is refused in update.fields at definition time, so authors can't move content. It is resolved on the server inside the write transaction and passed to resolveContentPublicationPolicy as container. That is the hook for future per-container publishing modes.
  • Public reads:
    • content/<path> GET / returns the collection in tree order (?parent=root|<id>). Nest it with contentContainerTree.
    • GET /{idOrSlug} returns the container, its ancestors and its children.
    • Only publicApi.fields metadata is exposed.
    • Content lists accept ?<field>=<id>&includeDescendants=true. An unknown container returns 404.
    • Whole trees load in one query (no N+1), capped at 5,000 containers per definition.
  • AdminCP:
    • /admin/containers/* is a dnd-kit tree editor (reorder, re-parent, invalid drops blocked, optimistic update with rollback).
    • Keyboard and screen readers get announcements plus a row menu: Move up / Move down / Move to top level / Move inside….
    • Create and edit happen in a sheet generated from the definition. Delete explains why a container can't be removed.
    • Content forms get a container picker, and list cells show the container name.
    • Each collection has its own staff permission module (can_view/create/edit/delete).
  • Boot validation and the rest: cross-plugin checks cover ids, admin paths, namespace clashes with content types, and content types that reference unregistered collections. Container writes emit content.container.created|updated|moved|deleted and expire the page cache.
  • Example: the example plugin's discussions (the Stage 1 reference) now live in boards. It adds /boards (tree), /boards/:slug (discussions including sub-boards), and a board picker on "New discussion".
  • Migration: 20261013103054_add_content_containers is hand-adjusted. It adds board as nullable, files any existing discussions under a seeded "General" board, then sets NOT NULL.

Why?

Stage 2 of the Content Engine roadmap. Plugins (forum next) need one content type (forum.topic) spread across many admin-managed, nested containers, with server-side checks on where content may be filed. The schema-driven metadata and the container context in the write pipeline leave room for Stage 3 (per-container publishing modes, approval, permissions, visibility) without changing the data model.

Verification

  • vitest unit + isolated + dom: 588 files, 9,261 tests passed. --project types: 429 passed, no type errors. tsc --noEmit is clean for core, apps/web and plugins/example. The integration project (real builds/child processes) was not run.
  • Against a real Postgres 16:
    • All migrations apply, and the backfill path was checked with pre-existing discussions.
    • Over HTTP: admin CRUD, moves and refusals; the public tree and detail; public create inside a board; spoofed and missing board refused; includeDescendants; PATCH can't move content; deleting a board with content refused.
    • 12 concurrent creates plus 8 concurrent moves left sibling positions contiguous with no duplicates.
  • The AdminCP screen was not exercised in a browser.

Known limitations / follow-ups

  • Content-list filter params (including includeDescendants) aren't part of the fetcher's inferred query type, because publicFilters is an untyped ZodObject. This was already true for every filterable field.
  • Container names aren't localized yet. There is one container per record, with no multi-container membership.
  • Editors need the collection's can_view to use the container picker in content forms.
  • Blog categories are unchanged. They are a localized content type with a many-to-many relation, so migrating them isn't safe or needed here.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JvepU3TcQSG6hHpguYPsHd


Generated by Claude Code

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@github-actions github-actions Bot added the 💡 Feature A new feature label Oct 13, 2026
@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

React Doctor found no new issues. 🎉

Reviewed by React Doctor for commit 665682a.

Containers organize content into hierarchical collections - forum boards,
blog sections, knowledge-base categories - without a content type per
category.

- `defineContentContainer({ id, fields, hierarchy, ordering, publicApi, admin })`
  with registration-time validation and typed metadata inferred from fields.
- One core table, `core_content_containers` (adjacency list + sibling
  position + schema-validated `metadata`), so a definition adds no migration.
- Tree service: create, update, move, reorder, delete, tree, ancestors,
  children and descendants. Every write takes a per-definition advisory lock
  and validates the whole plan first, so refused or concurrent writes cannot
  corrupt the tree. Deletion refuses containers with children or content.
- `field.container({ definition, required })`: one container per record, an
  indexed RESTRICT foreign key, validated against the collection on every
  admin, editorial and public write.
- publicWrite: the container may be chosen on create, never on update; it is
  resolved server-side and handed to the publication policy hook.
- Public reads: container list/detail routes, and `?<field>=<id>` with
  `includeDescendants=true` on content lists. AdminCP CRUD + move routes per
  definition, gated by its own staff permission module.
- Container admin bodies the schema refuses answer
  `{ code: "invalidMetadata" | "invalidInput", message }` through an optional
  `buildRoute` validation `hook`; namespace checks key owners by kind and id.
- Boot validation across plugins, page-cache tags and
  `content.container.*` events.
- AdminCP: a tree editor per collection at `/admin/containers/{admin.path}`
  with drag-and-drop reorder/move (same planner as the server, optimistic
  with rollback), keyboard and screen-reader support, a create/edit sheet
  generated from the definition's fields, and a container picker in
  generated content forms.
- Example plugin: `example.boards` with `/boards` and `/boards/:slug`, a
  required `board` field on discussions, and a migration that files existing
  discussions under a seeded "General" board.
- Docs for containers, hierarchy and frontend pages, plus field, public API,
  public write and event reference updates.
- Tests for definitions, tree planners, the service, admin and public routes,
  assignment on writes and the container filter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@aXenDeveloper
aXenDeveloper force-pushed the claude/content-containers branch from 26469e5 to 665682a Compare October 13, 2026 14:44
@aXenDeveloper
aXenDeveloper merged commit 95428da into canary Oct 13, 2026
4 checks passed
@aXenDeveloper
aXenDeveloper deleted the claude/content-containers branch October 13, 2026 14:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

💡 Feature A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant