1
0
Fork
You've already forked cms
0

Dynamic node editor and plugin arch #48

Merged
ernstla merged 34 commits from epic/plugins into main 2026年07月03日 11:07:20 +02:00
No description provided.
getField()/getFields() are pure reflection reads with no instance
state. Making them static removes the ad-hoc empty-registry hydrators
in Column and ValidatorFactory and drops the hydrator constructor
params from Node, Serializer and ViewRenderer, which only forwarded it
for reads. Node\Factory now holds the only hydrator instance.
The optional param silently fell back to an empty registry, skipping
all attribute handlers. Types now exposes its registry for callers
that need to construct or share the node schema registry.
Plugin now constructs the field schema registry once and exposes it
together with the node schema registry and a new Field\Services bag
(schemas + types) as container entries, giving external code one
live registration point instead of scattered withDefaults() copies.
Field::init(Services, ?ReflectionProperty) replaces initSchema and
throws instead of silently building a private registry copy. The
hydrator, node Factory and Cms now require Services, so every
hydration path shares the container-bound registries and Types.
Entries no longer builds its own Types instance.
The class is the CMS boot procedure, not a user-facing plugin. This
frees the plugin name for the upcoming runtime plugin interface;
App::plugin() becomes App::bootstrap().
Field\Index registers field types and their string aliases, replacing
the hardcoded alias table in NodeContentNormalizer. Bound in the
container so plugins can register custom field types whose aliases
reach content normalization.
Plugins implement Cosray\Plugin\Plugin (id + register) and are
registered via App::plugin() or the plugins config key. Bootstrap
runs each plugin's register() before renderers, collect() and
database(), so plugin fields, schemas, nodes, collections, services,
migration/sql dirs and frontend routes land in the normal pipeline.
The Registrar is the write-only facade handed to plugins.
Integration test shows a plugin-shipped field type hydrates,
serializes and exposes editor properties with no core changes.
README gains a Plugins section covering the interface, Registrar
surface and migration conventions.
Field\Control is an immutable builder over a fixed named vocabulary
(text, number, option, richtext, group, repeater, element, ...).
Every field emits its descriptor via control(), serialized into
properties() as 'control', so the panel can render fields without
knowing field type classes. The element control is the web-component
escape hatch for plugin-supplied UIs.
Registrar::assets(dir) maps a plugin's prebuilt asset dir to
{panel}/vendor/{pluginId}/..., served path-jailed with a wider
extension whitelist (js modules, fonts, images). This is the
delivery path for element controls shipped by plugins.
The island no longer knows field type classes: Control.svelte
dispatches on the server-provided control descriptor name and
controls.ts is keyed by the fixed vocabulary. Adds Datetime, radio
display for Option, number props (step/min/max), structural Group/
Repeater controls with a primitive SubControl, and the Element
escape hatch that dynamically imports plugin web components from the
vendor asset route. Decimal, DateTime and Radio fields render real
controls for the first time.
docs/controls.md specifies the value shape per control name and the
element (web component) contract. E2E test asserts the node API
payload carries control descriptors end-to-end.
Block\Type classes own their panel descriptor (control + init
payload) and server render; Block\Registry replaces the closed
match in Value\Blocks. Blocks field payloads expose blockTypes for
a data-driven picker, restrictable via allow()/#[Allows]. Plugins
register types through Registrar::blockType(). Heading levels render
legacy content but stay hidden from the picker.
The picker list, initial payloads and per-block component dispatch
now come from field.blockTypes; the hardcoded id maps and add()
branching are gone. BlockElement hosts plugin web components inside
blocks via the element control.
Collection config moves from static props to class attributes
(#[Label], #[Handle], #[Icon], #[Badge], #[Permission], #[Hidden],
#[Order], #[Listing], #[Blueprints]) resolved through a collection
schema registry mirroring the node schemas. Navigation stores
Collection\Refs resolved without instantiation; Wire creation in
the controllers is the only instantiation path. List/row/hierarchy
logic is hoisted into Collection\Listing. Plugins can register
schema handlers via Registrar::collectionSchema().
Open the panel chrome to plugin apps
All checks were successful
ci / ci (push) Successful in 2m55s
ci / build-and-release (push) Has been skipped
ci / release (push) Successful in 0s
15fef6f45c
Plugins can register template namespaces (addressed as pluginId:path;
the cosray dir stays first so un-namespaced layouts keep resolving),
panel pages inside the auth/session/renderer pipeline, NavLink nav
entries for arbitrary URLs, and extra panel stylesheets/scripts.
The fixture plugin now exercises the whole app story end-to-end:
chrome-wrapped page, nav link with active state, injected css and
path-jailed vendor assets.
The embed island entry is referenced nowhere and is not a Vite input.
Field\Control\Registry maps named rich controls to {tag, module};
Control::resolve() serializes registered names as element descriptors
(recursing into group/repeater), so the island will only interpret
primitives, structural controls and elements. Control::named() lets
fields reference custom registered controls. Plugins register via
Registrar::control() with plugin-prefixed module paths; later
registrations win so a plugin can replace a built-in editor. The
default registry is empty, so payloads are unchanged until controls
are converted stage by stage.
The modal moves from a Svelte context (unreachable outside the
component tree) to a $lib/modal store with an openElement() variant
that renders plain DOM — the door for web components and the
upcoming window.Cosray bridge. All eight consumers migrate to module
imports.
The island installs a versioned public runtime for custom-element
controls: system info, media upload (wrapping the CSRF-aware request
helper), DOM-based modals and toasts. Cosray's own elements and
plugin elements use the same API. LocaleTabs and ensureLocales accept
explicit locale lists so they keep working when compiled into element
bundles that cannot reach the island's system store.
ElementHost is the bare loader/property-assigner/event-listener with
no island store imports; Element.svelte becomes the field wrapper
that owns the locale tabs and feeds the host. The contract grows
node, meta and a live locale property (detail becomes {value, meta?})
— fixing the previously dead locale tabs on element fields. Module
values resolve via lib/elements.ts: cosray:{entry} (reserved id,
panel build or dev server), URLs, or plugin vendor paths. Control
threads an onchange callback so container bundles can supply their
own dirty signal later.
First converted control: cosray-richtext wraps RichTextEditor
(compiled as a shadowless custom element via a second Vite pass
emitting build/elements/*), owns the per-locale value map and
dispatches cosray-change. RichTextEditor loses its island store
imports: dirty signal becomes a notify prop, the link modal opens
through the window.Cosray bridge. The control registry resolves
'richtext' to the element, so the island no longer maps it; the
island RichText control is deleted. The bridge accessor is split
from its installer so element bundles stay free of island stores.
cosray-code carries the syntax select and CodeMirror editor,
exercising the meta side of the contract: syntax changes dispatch
cosray-change with {value, meta}. CodeEditor's dirty signal becomes
a notify prop. The registry resolves 'code'; the island Code control
is deleted.
cosray-image/-file/-video share one module wrapping the existing
Upload/MediaList machinery. Upload derives its endpoint from the
bridge (node prop replaces path), uploads and toasts go through
window.Cosray, dialogs and the image edit/preview modals mount via
the bridge modal, and the dirty signal is a notify prop. Island
media field controls and FileLike are deleted; block media controls
pass node + setDirty until Stage C converts them.
Primitive.svelte renders the whole primitive vocabulary (text,
textarea, iframe, number, checkbox, option, date, time, datetime,
hidden) from descriptors — one component instead of ten. Control
dispatches inline; the controls.ts map is gone. Group, Repeater and
SubControl thread the onchange callback instead of importing the
island dirty store, readying them for container element bundles.
node-editor.js drops from 803k to 481k.
Each entry type ships init content built from its fields'
structure(); the editor clones it when adding an entry. This deletes
the client typeMap — the last place the island named Cosray field
classes.
cosray-blocks owns the whole blocks editing surface: the panel and
all built-in block controls move into the element bundle, dispatch
edits through an instance-scoped notify context, and open the add/
remove modals via the bridge. Plugin block types keep loading as
elements through BlockElement inside the bundle. Built-in block
controls stay internal Svelte components rather than becoming
elements themselves — converting their bind-based editing to the
event contract would risk regressions for no isolation gain, since
the island already only sees 'element'. The elements build emits a
single style.css the loader links once; the island bundle is down
to 108k from the original 803k.
cosray-entries hosts the entry list; nested fields render through
the generic dispatcher compiled into the bundle, so nested rich
fields resolve as nested elements. Entry controls and the remove
confirmation use the shared notify channel and the bridge modal.
The island dispatcher now knows only primitives, group/repeater and
element — zero built-in rich controls.
Prune per-control types and finish element docs
All checks were successful
ci / ci (push) Successful in 2m52s
ci / build-and-release (push) Has been skipped
ci / release (push) Successful in 0s
c1dc170cd3
types/fields.ts loses the per-control field interfaces the island no
longer needs; docs describe the final split: primitives interpreted
by the island, named rich controls resolved server-side to cosray
custom elements, plugins registering their own via
Registrar::control().
Only one island survived the element refactor — the node editor's
boot layer — so the islands namespace said nothing anymore. The
directory becomes src/editor/ (main.ts entry, mount.ts runtime,
Editor.svelte orchestrator, editor.css); the lazy chunk is emitted
as main.js. The DOM/SSR contract (data-cosray-node-editor markers,
window.CosrayNodeEditor, mountEditor) is unchanged; remaining
island wording in comments and docs now says editor.
A boosted swap nests a fresh #main inside the previous one, so the stale
outer wrapper keeps the last full page's class and the :has() scroll rules
read the wrong page mode. De-nest to a single #main after each swap so
client-side navigation matches a full page load.
Unmount node editor on navigation to clear dirty guards
All checks were successful
ci / ci (push) Successful in 2m50s
ci / build-and-release (push) Successful in 1m24s
ci / release (push) Successful in 0s
d76ccee8a5
The editor mounts a Svelte component that registers document-level
beforeunload/htmx guards but was never unmounted on a boosted swap, so the
component (and its guards, reading the shared dirty store) leaked. Every
node left behind another guard, prompting 'unsaved changes' once per leak.
Track the mounted instance and unmount it once its host leaves the DOM;
let the panel wrapper hand off on hostless pages too so the release runs
when navigating from a node to a collection.
ernstla deleted branch epic/plugins 2026年07月03日 11:07:20 +02:00
Sign in to join this conversation.
No reviewers
Milestone
Clear milestone
No items
No milestone
Projects
Clear projects
No items
No project
Assignees
Clear assignees
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
cosray/cms!48
Reference in a new issue
cosray/cms
No description provided.
Delete branch "epic/plugins"

Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?