[Editor] Add ux-editor with CKEditor, EditorJS and GrapesJS bridges - #3687
[Editor] Add ux-editor with CKEditor, EditorJS and GrapesJS bridges#3687makraz wants to merge 39 commits into
Conversation
- Add .symfony.bundle.yaml and .gitattributes - Drop feature-branch composer branch-alias, add author - Fix assets/package.json symfony block (importmap) - Expand README with standard sub-tree-split boilerplate - Support non-controller exports and nested controllers in build - Rebuild dist
📊 Packages dist files size differenceThanks for the PR! Here is the difference in size of the packages dist files between the base branch and the PR. |
- Regenerate pnpm-lock.yaml with editor + bridge dependencies (frozen-lockfile) - Add 'symfony-ux' keywords and fix files: ["dist"] in npm package.json files - Fix DOCtor-RST: use '::' over code-block:: php, 'javascript' over 'js', 'applications' over 'apps', matching title underline - Apply @symfony coding standards (php-cs-fixer)
- Add psr/log ^3 to require (used by AbstractEditorConfig); fixes --prefer-lowest builds that pulled the incompatible psr/log 1.x - Apply oxfmt formatting to TypeScript sources and config files
Bridges require symfony/ux-editor ^0.1|^1.0; the dev-feat/ux-editor-core => 0.1.x-dev alias is what lets the monorepo path package satisfy that constraint under composer (incl. --prefer-lowest). Fixes lowest-deps CI.
… path repos
- Doctrine tests: mock AbstractPlatform instead of instantiating SqlitePlatform
(renamed to SQLitePlatform in doctrine/dbal 4); production types already use
the version-agnostic AbstractPlatform
- Remove per-bridge 'repositories' path config with symlink:false; the monorepo
build-packages.php injects path repos (symlinked), and the forced copy was
mirroring src/Editor/vendor temp files ("Unable to guess file type")
The glob patterns in the core package's exclude-from-classmap caused Composer to walk the recursive ux-editor path symlink while dumping the bridge autoloaders, aborting classmap generation so phpunit's classes were never registered (Class "PHPUnit\TextUI\Application" not found). Matches the symfony/ux-map layout, which uses an empty array.
|
Will review this (great) PR next wednesday! |
|
I've been reading through it in depth and have started writing a detailed response, but I can already share my main impression: there is more than one bundle in here. I'm also pretty convinced that we should not try to build a full CMS editor, at least not right now. We should first focus on providing a solid, focused editor abstraction that we or other packages can build on, block by block (pun intended). That said, this is already impressive work. 👏 If you're willing and want to work on it over the long term, I think it could become a really impressive and useful set of packages for a lot of different people. I'll post a more detailed response, along with some concrete proposals for how we could move forward, this weekend. Again, what a nice piece of work! |
I really appreciate your feedback. @smnandre Agree that building a full CMS editor shouldn't be the initial goal. My motivation was primarily to explore what a modern editing experience could look like in the Symfony UX ecosystem, but I'm completely on board with refocusing it around a solid editor abstraction and letting higher-level features evolve as separate packages. I'm definitely interested in working on this over the long term, so I'd be very happy to split things up and iterate package by package if that's the direction we decide to take. Looking forward to your detailed feedback. Thanks again for the encouraging words, they're really motivating! 🚀 |
|
(it's coming it's coming 😅 ) |
|
Note Full disclosure: I used an LLM to review, correct, and adapt this text so that it communicates my thoughts and intended meaning as clearly as possible. Overall directionFirst, this is a great piece of work. The PR is ambitious, contains many good ideas, and gives us a lot of concrete code to discuss. Thanks again for that! 🤝 It also asks many useful questions about editor integrations, content models, rendering, uploads, page builders, and the boundaries of Symfony UX. This exploration is already very valuable. As I see it, the PR addresses a real need: integrating JavaScript editors with Symfony Forms is still harder than it should be. My main concern is the responsibility of the core package. -- The PR describes This makes the central question important before we stabilize the public API:
In my view, the current code is much closer to the second option. The proposed content abstractionThe core starts with one interface for all edited content: interface EditorContentInterface
{
public function getFormat(): EditorContentFormat;
public function getRaw(): string|array;
public function getMetadata(): array;
public function isEmpty(): bool;
}It then defines three formats: enum EditorContentFormat: string
{
case Html = 'html';
case Blocks = 'blocks';
case Page = 'page';
}I am not convinced that these values form one "useful" domain abstraction. Let me explain 😅...
The differences also appear directly in their methods. Blocks can be filtered by type. Pages can expose assets. HTML can be sanitized. These are separate domain operations, not variations of the same operation. I think the common interface does not remove these differences. It mainly places all three models inside the Editor package. The form type defines the application model
if (!$value instanceof EditorContentInterface) {
throw new TransformationFailedException(
'Expected EditorContentInterface, got '.get_debug_type($value)
);
}This means that using CKEditor for a normal description field requires the application to replace a regular string with In my view, the form integration is therefore not neutral. It asks the application to adopt the bundle's content model, even for the smallest rich-text use case. I think we should keep the default contract as the natural value of the Symfony field. For CKEditor, that is usually In my opinion, the form type should make the editor easier to mount. It should not decide how the domain entity stores its content. The bridge contract mixes UI and content responsibilitiesThe proposed interface BridgeInterface
{
public function getId(): string;
public function getControllerName(): string;
public function getDefaultConfig(): EditorConfigInterface;
public function getCapabilities(): BridgeCapabilities;
public function createTransformer(): EditorContentTransformerInterface;
}In my view, the controller name and configuration belong to the editor integration. I see the transformer as part of the data model. It decides the content class and whether storage is scalar, JSON, or split: enum StorageShape: string
{
case Scalar = 'scalar';
case Json = 'json';
case Split = 'split';
}Coupling these concerns means that selecting a JavaScript editor also selects a PHP content type and a storage shape. I think that coupling is reasonable only if we want UX Editor to own the complete authoring model. It is too strong if we only want to integrate an editor into a form. I think we should keep the provider contract focused on the UI integration: controller, configuration, assets, lifecycle, and events. In my view, content transformation should stay optional and separate. The three format tiers expose three different contractsThe architecture introduces three abstract families:
Each family then has its own config, transformer, capabilities, and JavaScript controller. The JavaScript interfaces show the difference clearly. The WYSIWYG contract returns HTML: interface WysiwygInstance {
getHTML(): string;
}The block contract saves a document asynchronously: interface BlockInstance {
save(): Promise<{
version?: string;
blocks: Array<{ type: string; data: Record<string, unknown> }>;
}>;
}The page contract manages several coordinated resources: interface PageInstance {
getHtml(): string;
getCss(): string;
getComponents(): unknown[];
getAssets(): unknown[];
}In my view, these are not only three editor implementations. They are three different document contracts. The separate abstract families make this explicit, but they are still shipped as layers of one Editor core. I think this is a sign that the common abstraction is being placed one level too high. The "useful" common abstraction is the editor lifecycle(re-using the same term here, but it should be read as my personal opinion and not given more value than that)
It defines:
I think this is a good foundation for a Symfony UX component. However, the current lifecycle is broad in formats and still incomplete in the basic form direction. this.instance = await this.createEditor(this.mountTarget, this.configValue);There is also no common submit hook that waits for asynchronous serialization before the form is submitted. The form theme hides the textarea with I think we should first make this lifecycle complete: initial hydration, change synchronization, submit flushing, reset, failure fallback, Turbo reconnection, and teardown. In my view, this is more valuable as a core abstraction than supporting three document families from the first version. Common options and capabilitiesThe PR adds It then adds This creates a normalization layer between Symfony and every editor API. The current code already shows its limits:
I think provider-specific config objects are useful. I think we should start with these provider configs and a native escape hatch. In my opinion, we should add a common option only after at least two providers prove that it has the same meaning and behavior. The core owns persistence and renderingThe PR adds three Doctrine types: It also adds return match (true) {
$content instanceof HtmlContent => $this->renderHtml($content),
$content instanceof BlockContent => $this->renderBlocks($content),
$content instanceof PageContent => $this->renderPage($content, $options),
default => '',
};In my view, this creates a closed content system inside a package presented as editor-agnostic. Adding another content model requires either changing the core renderer or staying outside The rendering rules are also domain-specific:
I think these are valid features, but they are not editor lifecycle concerns. An editor can edit content without owning its Doctrine mapping or frontend rendering. I do not think we should make persistence and rendering part of the first Editor contract. We can document them as application integrations or provide them through packages that own the related content model. Conversion is not a generic editor operationThe core also defines interface ContentConverterInterface
{
public function getFrom(): string;
public function getTo(): string;
public function convert(EditorContentInterface $content): EditorContentInterface;
}I do not think converting HTML, blocks, and complete pages is a simple technical adapter. It raises questions about lost formatting, unsupported block types, assets, CSS, metadata, and round trips. The current interface does not express whether a conversion is lossy or reversible. In my view, this is another sign that content conversion should not be a basic responsibility of an editor integration package. The concrete bridges confirm the boundaryCKEditor
In my view, this is close to the main value we should expect from UX Editor: make a third-party editor work correctly with a Symfony field. EditorJSThe EditorJS package includes more than a UI adapter. It defines tool configuration, a block transformer, a block schema, and server-side renderers for paragraphs, headers, lists, quotes, and images. I think those features are useful, but they form a block-content system. The editor integration is only one consumer of that system. GrapesJSThe GrapesJS package works with HTML, CSS, components, assets, devices, blocks, and a storage manager. The core also provides page asset extraction and iframe rendering. To me, this is a page-builder model, not simply a richer textarea. In my view, putting all three tools behind Upload and autosave increase the core contractThe core package provides a complete upload path:
It also provides the These features can be used by editors, but I do not think they are required to mount, hydrate, synchronize, or submit an editor field. They also introduce application decisions about storage, authorization, draft persistence, and error handling. The I think we should keep the first package focused on the editor field lifecycle. We can integrate upload and autosave through events and optional adapters without making the core own them. What I think we should keep in this PRI think we should keep and strengthen:
The first complete path could be: This smaller scope would let us validate the abstraction with a complete real use case before defining models for every kind of authoring tool. What I think we should defer from this PRBased on the code currently proposed, I think we should defer:
This does not mean the work has no value. In my view, it means we should discuss these APIs as separate responsibilities before making them part of the base bundle. Possible follow-up componentsNote Full disclosure vol.2: I may be biased here.. I just have UX Upload open as a PR, and will open one for UX Image tomorrow :) I mention this because the code in this PR naturally raises the same responsibility questions. I do not think this PR should depend on those proposals. If we reduce the Editor responsibility, I think the current code suggests some natural follow-up components. UX BlocksIn my view, UX UploadI think the signed upload client, handlers, profiles, progress, storage result, and authorization contract could become an autonomous upload component. We could let editors use it through an optional integration. UX ImageThe EditorJS image renderer and the image-focused default upload handler already raise image-specific questions. In my view, image metadata, variants, transformations, and responsive rendering may deserve their own responsibility rather than being hidden inside an editor upload. UX PageI think we could explore I do not think we should make these components prerequisites for the basic Editor use case. We could later provide an integration package that connects them for applications wanting the complete authoring experience. Personal positionI support the goal of making editor integrations easy in Symfony Forms. I do not think the current common content model is the right foundation for that goal. The code shows that HTML, block documents, and pages need different models, storage rules, renderers, and integrations. I think we should reduce this PR to the field and provider lifecycle, complete that path with one editor, and discuss the other responsibilities separately. The current work is still very, very useful: it identifies most of the problems an authoring ecosystem will need to solve. In my view, we should use it as exploration for smaller public contracts, rather than merge all of those contracts as one |
Questions about CKEditor licensingI may be missing something, and this is not a legal conclusion. Before going further, I think we should ask two questions: are we comfortable providing an official MIT bridge for an editor licensed under GPL 2+ or commercial terms? More importantly, are we legally allowed to do so under the licenses involved? CKEditor dual-licensCKEditor 5 is dual-licensed: GPL 2+ or commercial. Its Free Plan is commercial. For self-hosted use, The PR exposes Symfony and Symfony UX licensingSymfony is MIT, as is the proposed bridge. CKEditor remains separately licensed. Could an official MIT bridge nevertheless make CKEditor appear MIT-compatible? What could we do?
I have no qualifications on these topics. Nor opinions. But I guess we probably want to answer this question before investing further in the integration and its public API. |
|
Could be possible to add a Lexical bridge? |
symfony/ux-editoradds a single Symfony form field (EditorType) on top ofmultiple content-authoring editors — WYSIWYG, block and page-builder — behind
one consistent API.
The core package is editor-agnostic and provides:
EditorContentInterfacewithHtmlContent,BlockContentandPageContent, plus anEditorContentFormatenum;EditorTypeform abstraction with data transformers;editor_html,editor_blocks,editor_page);EditorUploadController,SignedUploadUrlGenerator,EditorUploadHandlerInterface, local handler + registry);ux_editor_renderTwig function (HTML sanitized, blocks via registry, pagerendered in a sandboxed iframe);
LiveEditortrait for debounced autosave with LiveComponent;debug:ux-editorconsole command and a WebProfiler data collector.Concrete editors plug in through
BridgeInterface+BridgeRegistry. This PRalso includes three bridges as separate composer + npm sub-packages:
symfony/ux-editor-ckeditor— CKEditor 5 (WYSIWYG family)symfony/ux-editor-editorjs— EditorJS (block family)symfony/ux-editor-grapesjs— GrapesJS (page-builder family)PHP and JS test suites are included and passing. Documentation lives in
src/Editor/doc/.