diff --git a/code_samples/back_office/limitation/src/Security/Form/FormSubmissionServiceDecorator.php b/code_samples/back_office/limitation/src/Security/Form/FormSubmissionServiceDecorator.php
index 2f0328b8765..b231a327a5b 100644
--- a/code_samples/back_office/limitation/src/Security/Form/FormSubmissionServiceDecorator.php
+++ b/code_samples/back_office/limitation/src/Security/Form/FormSubmissionServiceDecorator.php
@@ -16,10 +16,10 @@
class FormSubmissionServiceDecorator implements FormSubmissionServiceInterface
{
public function __construct(
- readonly FormSubmissionServiceInterface $innerService,
- readonly PermissionResolver $permissionResolver,
- readonly ContentService $contentService,
- readonly FormSubmissionGateway $gateway,
+ public readonly FormSubmissionServiceInterface $innerService,
+ public readonly PermissionResolver $permissionResolver,
+ public readonly ContentService $contentService,
+ public readonly FormSubmissionGateway $gateway,
) {
}
diff --git a/code_samples/page/headless/config/packages/ibexa_page_builder.yaml b/code_samples/page/headless/config/packages/ibexa_page_builder.yaml
new file mode 100644
index 00000000000..ba8856f561c
--- /dev/null
+++ b/code_samples/page/headless/config/packages/ibexa_page_builder.yaml
@@ -0,0 +1,37 @@
+jms_translation:
+ configs:
+ page_builder:
+ dirs:
+ - '%kernel.project_dir%/vendor/ibexa/page-builder/src'
+ output_dir: '%kernel.project_dir%/vendor/ibexa/page-builder/src/bundle/Resources/translations/'
+ excluded_dirs: [Behat, Tests]
+ output_format: "xlf"
+
+ibexa:
+ system:
+ admin_group:
+ headless:
+ enabled: true
+ page_builder:
+ preview_url: 'http://localhost:8081/page.html'
+
+ibexa_fieldtype_page:
+ layouts:
+ 2_columns:
+ identifier: '2-columns'
+ name: '2 Columns'
+ description: 'Two columns layout'
+ thumbnail: '/bundles/ibexafieldtypepage/images/layouts/default.svg'
+ template: '@ibexadesign/layouts/2_columns.html.twig'
+ zones:
+ left:
+ name: Left
+ right:
+ name: Right
+ blocks:
+ tag:
+ views:
+ source_code:
+ template: '@ibexadesign/blocks/tag/source_code.html.twig'
+ name: 'See source code'
+ priority: -256
diff --git a/code_samples/page/headless/config/services.yaml b/code_samples/page/headless/config/services.yaml
new file mode 100644
index 00000000000..2da349bc7bf
--- /dev/null
+++ b/code_samples/page/headless/config/services.yaml
@@ -0,0 +1,5 @@
+services:
+# …
+ App\Controller\RichTextController:
+ arguments:
+ $richTextOutputConverter: '@ibexa.richtext.converter.output.xhtml5'
diff --git a/code_samples/page/headless/page.html b/code_samples/page/headless/page.html
new file mode 100644
index 00000000000..a69187f2585
--- /dev/null
+++ b/code_samples/page/headless/page.html
@@ -0,0 +1,820 @@
+
+
+
+
+
+ Headless Page Builder test
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code_samples/page/headless/src/Controller/RichTextController.php b/code_samples/page/headless/src/Controller/RichTextController.php
new file mode 100644
index 00000000000..b09602d353c
--- /dev/null
+++ b/code_samples/page/headless/src/Controller/RichTextController.php
@@ -0,0 +1,32 @@
+loadXML($request->getContent());
+
+ return new Response($this->richTextOutputConverter->convert($xml)->saveHTML());
+ }
+}
diff --git a/docs/content_management/field_types/field_type_reference/pagefield.md b/docs/content_management/field_types/field_type_reference/pagefield.md
index b8497dc0d2b..c90a4b046cb 100644
--- a/docs/content_management/field_types/field_type_reference/pagefield.md
+++ b/docs/content_management/field_types/field_type_reference/pagefield.md
@@ -72,3 +72,9 @@ As a whole a sample layout could look as follows:
``` html+twig
[[= include_file('code_samples/page/pagefield_layout.html.twig') =]]
```
+
+### Headless rendering
+
+When used headless, the front-end have to fetch the page data and render the zones and blocks by itself.
+It's also possible for the front-end to provide a URL that can communicate with the Page Builder and display a preview in it.
+See [Headless front-end preview in Page Builder](headless_page_builder.md) for more information.
diff --git a/docs/content_management/img/headless-page-field-edit.png b/docs/content_management/img/headless-page-field-edit.png
new file mode 100644
index 00000000000..377d3be0995
Binary files /dev/null and b/docs/content_management/img/headless-page-field-edit.png differ
diff --git a/docs/content_management/img/headless-saas-siteaccess-config.png b/docs/content_management/img/headless-saas-siteaccess-config.png
new file mode 100644
index 00000000000..8ed5009f4c1
Binary files /dev/null and b/docs/content_management/img/headless-saas-siteaccess-config.png differ
diff --git a/docs/content_management/pages/headless_page_builder.md b/docs/content_management/pages/headless_page_builder.md
new file mode 100644
index 00000000000..1bd58c0bafd
--- /dev/null
+++ b/docs/content_management/pages/headless_page_builder.md
@@ -0,0 +1,644 @@
+---
+description: The Page Builder can preview pages outside the DXP.
+edition: experience
+month_change: true
+---
+
+# Headless front-end preview in Page Builder
+
+The Page Builder can preview pages hosted outside the DXP.
+
+You provide a single URL for the front-end page and let it communicate with the Page Builder using the JavaScript message API.
+
+The usage of this front-end page instead of the DXP one is set per content type. TODO: This is on-prem only, SaaS don't have the choice
+
+## Configuration (on-prem)
+
+TODO: on-premise only, remove from SaaS documentation
+
+First, set up the feature, for example, in `config/packages/ibexa_page_builder.yaml`:
+
+```yaml
+ibexa:
+ system:
+ admin_group:
+ headless:
+ enabled: true
+ page_builder:
+ preview_url: 'https://frontend.example.com/page-builder-preview' # The front-end URL loaded by the Page Builder's iframe
+```
+
+Then, edit the content types with Landing page field type that are used headless,
+edit that field, and check the option "Edit in the headless Page Builder".
+
+
+
+## Configuration (SaaS)
+
+TODO: Saas only, remove from on-premise documentation
+
+Navigate to **Administration** > **SiteAccess Configuration** > **Headless** (`/siteaccess-config/default/headless`)
+
+TODO: Should `admin` SiteAccess be picked instead of staying on `default`? (`/siteaccess-config/admin/headless`)
+
+Below the description, enable the **Headless mode**, fill in the **Page Builder preview URL**, and **Save** the configuration.
+
+
+
+## Communication protocol
+
+The front-end resource targeted by `base_url` is loaded by the Page Builder when editing a content having a Landing page field "Edit in the headless Page Builder" enabled.
+This resource must follow a protocol to communicate with the Page Builder from the iframe is loaded in.
+This protocol is based on the JavaScript message API.
+
+The Page Builder sends messages to the framed front-end resource.
+They can be received by listening the [message event](https://developer.mozilla.org/en-US/docs/Web/API/EventSource/message_event).
+
+The front-end resource sends back messages to the Page Builder.
+They can be sent using the [`postMessage()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage) method.
+The target origin of those messages must be the Page Builder's origin.
+
+```js
+const pbOrigin = 'https://admin.example.com/';
+window.parent.postMessage(message, pbOrigin);
+```
+
+Those messages are JS objects with the following structure:
+
+```js
+let message = {
+ type: 'PREFIX:MESSAGE_TYPE',
+ data: {}
+};
+```
+
+The `PREFIX` sort message types by their sender.
+`PB:` for messages sent by the Page Builder, `APP:` for messages sent by the front-end resource.
+
+The data depends on the message type.
+
+Message types are then sorted by capabilities.
+So the front-end can declare which capabilities it supports and the Page Builder can refrain from sending or expecting unsupported messages.
+
+### Message types and capabilities
+
+The handshake and core messages are mandatory and not related to an optional capability.
+
+| Capability | Message type | Description |
+|--------------------|---------------------------------------------------------------------------------------------|--------------------------------------|
+| (handshake) | [`APP:INITIALIZED`](#communication-initialization) | Establish protocol and capabilities. |
+| (handshake) | [`PB:INIT_MODE`](#communication-initialization) | Confirm protocol and draft info. |
+| (core) | [`PB:UPDATE_FIELD_DATA`](#on-field-update) | Send updated field data. |
+| (core) | [`PB:DISPATCH_EVENT`](#re-dispatching-events) | Re-dispatch a front-end event. |
+| `blocks.dnd` | [`PB:DRAG_START_PREVIEW`](#drag-and-drop-blocksdnd) | Existing block drag started. |
+| `blocks.dnd` | [`PB:DRAG_OVER`](#drag-and-drop-blocksdnd) | Mouse position during drag. |
+| `blocks.dnd` | [`PB:DRAG_END_PREVIEW`](#drag-and-drop-blocksdnd) | Existing block drag ended. |
+| `blocks.dnd` | [`PB:DROP`](#drag-and-drop-blocksdnd) | Drop notification. |
+| `blocks.dnd` | [`APP:DROP_RESPONSE`](#drag-and-drop-blocksdnd) | Report where the block was dropped. |
+| `blocks.dnd` | [`PB:SCROLL_BY`](#drag-and-drop-blocksdnd) | Scroll the preview. |
+| `blocks.geometry` | [`APP:POSITIONS_UPDATE`](#geometry-and-pointer-tracking-blocksgeometry-and-pointertracking) | Report block positions. |
+| `blocks.geometry` | [`APP:SCROLL_END`](#geometry-and-pointer-tracking-blocksgeometry-and-pointertracking) | Scroll ended. |
+| `blocks.remove` | [`PB:BLOCK_REMOVE`](#block-removal-blocksremove) | Remove a block. |
+| `blocks.remove` | [`APP:BLOCK_REMOVE_RESPONSE`](#block-removal-blocksremove) | Confirm removal. |
+| `blocks.remove` | [`APP:BLOCK_REMOVE_REQUEST`](#block-removal-blocksremove) | Request block removal. |
+| `blocks.reveal` | [`PB:SCROLL_INTO_BLOCK`](#block-reveal-blocksreveal) | Scroll a block into view. |
+| `blocks.select` | `APP:BLOCK_CLICKED` | TODO: Block clicked. |
+| `pointer.tracking` | [`APP:MOUSE_POSITION`](#geometry-and-pointer-tracking-blocksgeometry-and-pointertracking) | Report mouse position. |
+| `preview.params` | [`PB:UPDATE_PREVIEW_PARAMS`](#preview-parameters-update-previewparams) | TODO: Update preview params. |
+
+### Communication initialization
+
+First, the front-end send an initialization message to the Page Builder, indicating which version of the protocol and which capabilities are supported:
+
+```js
+const initializedMessage = {
+ type: 'APP:INITIALIZED',
+ data: {
+ protocol: {
+ supported: [1]
+ },
+ capabilities: [
+ 'blocks.dnd',
+ 'blocks.geometry',
+ 'pointer.tracking',
+ // …
+ ],
+ }
+};
+```
+
+The Page Builder replies with a confirmation message.
+The initialization message might be sent several times until the Page Builder replies to it.
+
+This confirmation `data` contains:
+
+- the actual version of the protocol used (`protocol.version`) and the other supported versions (`protocol.supported`)
+- a list of all available capabilities (`capabilities`)
+- a list of the existing block types, their attributes, and their configuration (`blocksConfig`) - It contains all the block types but mark as not visible the blocks not available for this field.
+- information about the actually edited content draft (`intentParameters`)
+- the current value of the Landing page field being edited (`fieldValue`) including the layout, zones, and blocks.
+- a block-ID-to-name mapping (`blocksIdMap`)
+- a list of translations for the front-end to use (`translations`)
+
+```json
+{
+ "type": "PB:INIT_MODE",
+ "data": {
+ "protocol": {
+ "version": 1,
+ "supported": [
+ 1
+ ]
+ },
+ "capabilities": [
+ "blocks.dnd",
+ "blocks.geometry",
+ "blocks.remove",
+ "blocks.reveal",
+ "blocks.select",
+ "pointer.tracking",
+ "preview.params"
+ ],
+ "blocksConfig": [
+ {
+ "type": "block_type",
+ "name": "Block type name",
+ "category": "Block category",
+ "thumbnail": "path/to/block/thumbnail.file",
+ "visible": true,
+ "views": {
+ "default": {
+ "name": "Default"
+ }
+ },
+ "attributes": [
+ {
+ "id": "attribute_id",
+ "name": "Attribute name",
+ "type": "attribute_type",
+ "value": null,
+ "constraints": {
+ "not_blank": {
+ "message": "Please select…"
+ }
+ }
+ }
+ ]
+ }
+ ],
+ "intentParameters": {
+ "locationId": "2",
+ "contentId": 52,
+ "versionNo": 6,
+ "languageCode": "eng-GB"
+ },
+ "fieldValue": {
+ "layout": "ibexa_fieldtype_page.layouts..identifier",
+ "zones": [
+ {
+ "id": "123",
+ "name": "ibexa_fieldtype_page.layouts..zones..name",
+ "blocks": [
+ {
+ "visible": true,
+ "id": "456",
+ "type": "block_type",
+ "name": "Block name",
+ "view": "default",
+ "class": null,
+ "style": null,
+ "compiled": "",
+ "since": null,
+ "till": null,
+ "attributes": [
+ {
+ "id": "789",
+ "name": "attribute_name",
+ "value": "…"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ "blocksIdMap": {
+ "456": "Block name"
+ },
+ "translations": {
+ "block.attribute.invalid": "%name% is invalid",
+ "block.no_availability.content": "You have to delete it to publish",
+ "block.no_availability.delete": "Delete",
+ "block.no_availability.title": "This element is not available in this page",
+ "block.unknown.type": "Unknown block type: %type% (block name: %name%)",
+ "drag.drop.blocks.here": "Drag and drop blocks here",
+ "structure.drop.zone": "Drop zone %number%"
+ }
+ }
+}
+```
+
+### On field update
+
+The `PB:UPDATE_FIELD_DATA` message is sent from the Page Builder to the front-end resource when the Landing page field value is updated.
+
+Its data contains the new value of the field with the following structure:
+
+- Always, the current value of the Landing page field being edited (`fieldValue`) including the layout, zones, and blocks.
+
For example, only `fieldValue` is sent when manipulating the [timeline]([[= user_doc =]]/content_management/schedule_publishing/#timeline){style="display:inline;width:27px;vertical-align:middle;"}.
+- Optionally, a list of the existing block types, their attributes, and their configuration (`blocksConfig`)
+- Optionally, a block-ID-to-name mapping (`blocksIdMap`)
+- Optionally, a list of IDs from the new blocks that have been added (`highlightedBlockIds`)
+
+```json
+{
+ "type": "PB:UPDATE_FIELD_DATA",
+ "data": {
+ "fieldValue": {
+ "layout": "…",
+ "zones": []
+ },
+ "blocksConfig": [],
+ "blocksIdMap": {},
+ "highlightedBlockIds": []
+ }
+}
+```
+
+### Re-dispatching events
+
+The `PB:DISPATCH_EVENT` message is sent from the Page Builder to the front-end resource for being re-dispatched there as a custom event.
+Its data contains the name of the event to dispatch (`eventName`) and the data to pass to the event detail (`eventData`).
+
+```js
+window.addEventListener('message', (messageEvent) => {
+ switch (messageEvent.data.type) {
+ case 'PB:DISPATCH_EVENT':
+ window.dispatchEvent(new CustomEvent(messageEvent.data.data.eventName, { detail: messageEvent.data.data.eventData }));
+ break;
+ }
+});
+```
+
+#### Available events
+
+- `ibexa-active-block-clicked`: It confirms `APP:BLOCK_CLICKED` have been received. It has no data.
+- `ibexa-post-update-blocks-preview`: It's sent when the timeline is used.
+ - `fieldValue`: The same as in [`PB:UPDATE_FIELD_DATA`](#on-field-update) TODO: Why when timeline move to a revelation time, the block is still invisible here while made visible in PB:UPDATE_FIELD_DATA?
+ - `blockIds`: A list of all the block IDs
+ - `blocksMaps`: A map of block config per block ID
+
+```js
+window.addEventListener('ibexa-post-update-blocks-preview', (customEvent) => {
+ setLayout(customEvent.details.fieldValue.layout);
+ renderZones(customEvent.details.fieldValue.zones);
+});
+```
+
+### Geometry and pointer tracking (`blocks.geometry` and `pointer.tracking`)
+
+The pointer tracking and the geometry helps the Page Builder to position the block editing menus above the front-end preview.
+Such menu is a `.c-pb-headless-preview-menu` element positioned by the Page Builder from its DOM above the preview `iframe`.
+
+`APP:MOUSE_POSITION` message is sent from the front-end preview to the Page Builder to declare the actual position of the mouse.
+
+```js
+window.addEventListener('mousemove', (mouseEvent) => {
+ window.parent.postMessage({
+ type: 'APP:MOUSE_POSITION',
+ data: {
+ x: mouseEvent.clientX,
+ y: mouseEvent.clientY,
+ },
+ }, pbOrigin);
+});
+```
+
+`APP:POSITIONS_UPDATE` message is sent from the front-end preview to the Page Builder to declare the actual position of the blocks.
+Its data contains a list of objects with block IDs, their positions, and dimensions in the front-end preview.
+This format is close to [`getBoundingClientRect()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect) method.
+
+```js
+const positionsUpdate = () => {
+ let blocks = [];
+ for (const blockElement of document.getElementsByClassName('landing-page__block')) {
+ const blockId = blockElement.dataset.ibexaBlockId;
+ const blockRect = blockElement.getBoundingClientRect();
+ blocks.push({
+ id: blockId,
+ top: blockRect.top,
+ left: blockRect.left,
+ right: blockRect.right,
+ bottom: blockRect.bottom,
+ width: blockRect.width,
+ height: blockRect.height,
+ });
+ }
+ window.parent.postMessage({
+ type: 'APP:POSITIONS_UPDATE',
+ data: {
+ blocks: blocks,
+ },
+ }, pbOrigin);
+};
+```
+
+This message should be sent each time the positions of the blocks change.
+It should be sent after updating the blocks, like in response to [`PB:UPDATE_FIELD_DATA`](#on-field-update).
+It should be sent after scrolling or resizing.
+
+`APP:SCROLL_END` message is sent from the front-end preview to the Page Builder to notify that a scroll operation has ended.
+It has no data.
+
+```js
+window.addEventListener('scrollend', (event) => {
+ window.parent.postMessage({
+ type: 'APP:SCROLL_END',
+ }, pbOrigin);
+ positionsUpdate();
+});
+window.addEventListener('resize', (event) => {
+ positionsUpdate();
+});
+window.addEventListener('message', (messageEvent) => {
+ switch (messageEvent.data.type) {
+ case 'PB:UPDATE_FIELD_DATA':
+ setLayout(messageEvent.data.data.fieldValue.layout);
+ renderZones(messageEvent.data.data.fieldValue.zones);
+ positionsUpdate();
+ break;
+ }
+});
+```
+
+TODO: Is there other events that should trigger a positions update?
+
+### Drag and drop (`blocks.dnd`)
+
+`PB:DRAG_OVER` message is sent from the Page Builder to the front-end preview to give the position of the mouse while a (new or existing) block is dragged.
+
+!!! tip "Mouse tracking"
+
+ - `APP:MOUSE_POSITION` helps the Page Builder to know where the mouse is when moved over the preview. It's associated to `APP:POSITIONS_UPDATE` to know if a preview block is hovered.
+ - `PB:DRAG_OVER` helps the front-end to know where the mouse is when a block is dragged over the preview.
+
+`PB:DRAG_START_PREVIEW` and `PB:DRAG_END_PREVIEW` are sent at the beginning and at the end of a drag operation on an existing block in the front-end preview.
+Its data contains the ID of the block being dragged (`blockId`).
+
+```js
+window.addEventListener('message', (messageEvent) => {
+ switch (messageEvent.data.type) {
+ case 'PB:DRAG_START_PREVIEW':
+ document.querySelector(`[data-ibexa-block-id="${messageEvent.data.data.blockId}"]`).classList.add('c-pb-block-preview--is-dragging-out');
+ break;
+ case 'PB:DRAG_END_PREVIEW':
+ document.querySelector(`[data-ibexa-block-id="${messageEvent.data.data.blockId}"]`).classList.remove('c-pb-block-preview--is-dragging-out');
+ break;
+ }
+});
+```
+
+`PB:DROP` message is sent from the Page Builder to the front-end preview to notify that a block has been dropped.
+It has no data. Combined with the last `PB:DRAG_OVER` message, the front-end can determine where the block has been dropped.
+
+`APP:DROP_RESPONSE` message is sent from the front-end preview to the Page Builder to tell where the block has been dropped.
+
+Its data contains:
+
+- the ID of the zone where the block has been dropped (`zoneId`)
+- the ID of a block that is now below the dropped block (`nextBlockId`) if the dropped block isn't the last one of the zone.
+
+In the following example, `targetBlockId` value is the ID of a block the dropped block was dropped on or just before, so the dragged block takes its place and move it below, or `null` when dropped at the bottom of the zone.
+
+```js
+const dropResponseMessage = {
+ type: 'APP:DROP_RESPONSE',
+ data: {
+ zoneId: zoneId,
+ nextBlockId: targetBlockId,
+ }
+};
+```
+
+After a drop `APP:DROP_RESPONSE` from the front-end, the next `PB:UPDATE_FIELD_DATA` message from the Page Builder contains the dropped block ID in its `highlightedBlockIds` array (`messageEvent.data.data.highlightedBlockIds`).
+
+`PB:SCROLL_BY` message is sent from the Page Builder when a block is dragged near a border of the preview which needs to be scrolled.
+Its data contains the `top` or `left` amount to scroll by.
+
+```js
+window.addEventListener('message', (messageEvent) => {
+ switch (messageEvent.data.type) {
+ case 'PB:SCROLL_BY':
+ window.scrollBy(messageEvent.data.data);
+ break;
+ }
+});
+```
+
+See [Geometry (`blocks.geometry`)](#geometry-and-pointer-tracking-blocksgeometry-and-pointertracking)'s `APP:SCROLL_END` message to declare the end of the scroll operation
+and `APP:POSITIONS_UPDATE` message to update the blocks positions.
+
+### Block reveal (`blocks.reveal`)
+
+`PB:SCROLL_INTO_BLOCK` is send by the Page Builder to the front-end preview to request that a block is scrolled into view.
+Its data contains the ID of the block to scroll into view (`blockId`).
+It can be used with the [`scrollIntoView()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView) method.
+
+```js
+window.addEventListener('message', (messageEvent) => {
+ switch (messageEvent.data.type) {
+ case 'PB:SCROLL_INTO_BLOCK':
+ const blockElementToScrollInto = document.querySelector(`[data-ibexa-block-id="${messageEvent.data.data.blockId}"]`);;
+ blockElementToScrollInto.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ break;
+ }
+});
+```
+
+### Block removal (`blocks.remove`)
+
+`PB:BLOCK_REMOVE` message is sent from the Page Builder to the front-end preview to notify that a block should be removed, for example, from the Structure view.
+Its data contains the ID of the block to remove (`blockId`).
+
+`APP:BLOCK_REMOVE_RESPONSE` message is sent from the front-end preview to the Page Builder to confirm that the block has been removed as requested by `PB:BLOCK_REMOVE`.
+Its data contains the ID of the removed block (`blockId`).
+It can be sent immediately or after removal animation.
+
+```js
+window.addEventListener('message', (messageEvent) => {
+ switch (messageEvent.data.type) {
+ case 'PB:BLOCK_REMOVE':
+ const blockId = messageEvent.data.data.blockId;
+ const blockElementToRemove = document.querySelector(`[data-ibexa-block-id="${blockId}"]`);;
+ if (blockElementToRemove) {
+ blockElementToRemove.addEventListener('animationend', () => {
+ blockElementToRemove.remove();
+ window.parent.postMessage({
+ type: 'APP:BLOCK_REMOVE_RESPONSE',
+ data: {
+ blockId: blockId,
+ },
+ });
+ });
+ blockElementToRemove.classList.add('c-pb-block-preview--is-removing');
+ } else {
+ console.error('No block element found for block ID ' + messageEvent.data.data.blockId, 'notification.headless_unresponsive_preview');
+ }
+ break;
+ }
+});
+```
+```css
+.c-pb-block-preview--is-removing {
+ animation-duration: 1s;
+ animation-name: c-pb-block-preview--is-removing;
+}
+@keyframes c-pb-block-preview--is-removing {
+ to {
+ opacity: 0;
+ height: 0;
+ }
+}
+```
+
+`APP:BLOCK_REMOVE_REQUEST` message is sent from the front-end preview to the Page Builder to request the removal of a block.
+Its data contains the ID of the block to remove (`blockId`).
+The Page Builder responses with a `PB:UPDATE_FIELD_DATA`.
+
+### Preview parameters update (`preview.params`)
+
+`PB:UPDATE_PREVIEW_PARAMS` message is sent from the Page Builder to the front-end preview when TODO: it's sent on several occasions without data. It seems also (if not mainly) used by segmentation.
+
+## Guidelines for front-end implementation
+
+The protocol documentation is illustrated with Vanilla JS examples.
+You should use a framework to implement the front-end, like React or Next.js.
+
+TODO: A React front-end kit should be available through npm package in the future. What about Next.js?
+
+Each block type view should be implemented as a component so you can easily add new block types and new views.
+
+CSS classes can be named however you wish, but it may be advisable to follow certain conventions to help the reuse of existing style sheets.
+
+### CSS classes and data attribute conventions
+
+Some class names are, by convention, only used when the front-end is used in the Page Builder preview, some are always used.
+
+For example, the convention is that when the front-end is used in the Page Builder preview, the `c-pb-iframe__preview-body` class is added to the document body.
+
+#### Zones
+
+The always present `data-ibexa-zone-id` attribute (`zoneElement.dataset.ibexaZoneId`) contains the zone ID.
+
+`data-ibexa-zone-id`
+
+| Class name | PB only | Description |
+|----------------------------------|---------|-----------------------------------------------------|
+| `landing-page__zone` | No | Every zone container |
+| `landing-page__zone--${zone.id}` | No | Each zone container with its own ID |
+| `m-page-builder__zone` | Yes | Every zone container when previewed in Page Builder |
+| `m-page-builder__zone--dragover` | Yes | When a block is dragged over the zone |
+| `m-page-builder__zone--empty` | Yes | When the zone has no block |
+
+### Blocks
+
+The always present `data-ibexa-block-id` attribute (`blockElement.dataset.ibexaBlockId`) contains the block ID.
+
+| Class name | PB only | Description |
+|---------------------------------------|---------|-------------------------------------------------------------------------------------------------------|
+| `landing-page__block` | No | Every block container |
+| `c-pb-block-preview` | Yes | Every block container when previewed in Page Builder |
+| `c-pb-block-preview--is-dragging-out` | Yes | When a block is being dragged |
+| `c-pb-block-preview--is-removing` | Yes | When a block is being removed (see [Block removal (`blocks.remove`)](#block-removal-blocksremove)) |
+| `ibexa-mark-invisible` | Yes | When a scheduled block is marked as invisible |
+| `c-pb-block-preview--unavailable` | Yes | When a block is unavailable for this field |
+| `c-pb-block-preview__inner` | Yes | The inner container of a block |
+| `c-pb-block-preview__inner--invalid` | Yes | The inner container of a block with invalid attribute value |
+| `droppable-placeholder` | Yes | The placeholder element shown when a block is being dragged over a zone to indicate the drop position |
+| `c-pb-block-preview--highlighted` | Yes | When a block is highlighted as when newly dropped |
+
+## Implementation helpers
+
+### Frontend kit(s)
+
+TODO: keep up-to-date, incoming npm packages, their installation process, maybe usage examples and integration guidelines
+
+TODO: On ibexa/frontend-kit, packages for several frameworks: [React](https://react.dev/), [Angular](https://angular.dev/), and [Vue](https://vuejs.org/).
+
+### Static example
+
+The following example is just a demo in vanilla JS provided as-is to illustrate the Page Builder protocol usage.
+It can be used to observe the messages exchanged between the Page Builder and a front-end preview in the browser JS console.
+It doesn't support all the block types or views.
+
+- `page.html` is a static HTML page with some JS to handle the Page Builder protocol messages and basic CSS to show how conventional classes are for.
+ It works both as a standalone page and as a Page Builder preview.
+- `RichTextController.php` contains a controller that converts RichText to HTML.
+
+This example needs some setup on a development installation:
+
+- RichText to HTML conversion controller service TODO: on SaaS, how to do this?
+- Declaration of DXP URLs in `page.html` itself
+- Declaration of content types having a Landing Page `ibexa_landing_page` type field
+- Being served by a web server
+- Headless Page Builder setup in the DXP
+- Optionally, an additional layout `2-columns`
+- Optionally, an additional `source_code` block type view for Code (`tag`) block type
+
+??? note "`page.html`"
+
+ ``` php hl_lines="175 527 778"
+ [[= include_code('code_samples/page/headless/page.html', indent_level=1) =]]
+ ```
+
+Edit `page.html`:
+
+- Change the `apiBaseUrl` constant to declare the origin on which the REST API and the conversion controller are called.
+- Change the `pbOrigin` constant to declare the origin of the Page Builder.
+- Change the `pageContentTypeIds` to list content type IDs that have a Landing Page field. You can set this to `false` to skip the content type test.
+
+`page.html` supports two layouts:
+
+- "Default layout for Landing Page" (`default`)
+- Custom "Two columns layout" (`2-columns`)
+
+It supports the following block types and views:
+
+- Text (`richtext`) block type with `default` view
+- Code (`tag`) block type with `default` and `source_code` views
+- Content List (`contentlist`) block type with `default` view
+
+For local test, it can simply be served by [PHP built-in server](https://www.php.net/manual/en/features.commandline.webserver.php).
+For example, by running the following command in the directory where `page.html` is located and a free port:
+
+```bash
+php -S localhost:8081
+```
+
+Then, the Page Builder can be configured to use the corresponding `page.html` URL, for example in `config/packages/ibexa_page_builder.yaml`:
+
+``` yaml
+[[= include_code('code_samples/page/headless/config/packages/ibexa_page_builder.yaml', 10, 16) =]]
+```
+
+Optionally, declare additional layout `2-columns`, and a `source_code` view for `tag`:
+
+``` yaml
+[[= include_code('code_samples/page/headless/config/packages/ibexa_page_builder.yaml', 18, 37) =]]
+```
+
+Optionally, create two template files, even empty, to avoid errors when reaching a landing page through DXP front site. TODO: won't happen on SaaS.
+
+`page.html`'s `richTextToHtml5(docBook)` function use `RichTextController.php`. You can modify this function if you don't want to use this controller.
+
+??? note "RichTextController.php"
+
+ ```php
+ [[= include_code('code_samples/page/headless/src/Controller/RichTextController.php', indent_level=1) =]]
+ ```
+
+Inject the RichText to HTML converter service in the controller:
+
+``` yaml hl_lines="5"
+[[= include_code('code_samples/page/headless/config/services.yaml') =]]
+```
diff --git a/docs/content_management/pages/pages.md b/docs/content_management/pages/pages.md
index f5fb0af210f..71da6027254 100644
--- a/docs/content_management/pages/pages.md
+++ b/docs/content_management/pages/pages.md
@@ -14,4 +14,5 @@ Pages are block-based special types of content that editors can create and modif
"content_management/pages/page_block_attributes",
"content_management/pages/page_block_validators",
"content_management/pages/create_custom_page_block",
+ "content_management/pages/headless_page_builder",
], columns=3) =]]
diff --git a/mkdocs.yml b/mkdocs.yml
index 1b8db944909..ea1bdc80524 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -214,6 +214,7 @@ nav:
- Create custom Page block: content_management/pages/create_custom_page_block.md
- React App page block: content_management/pages/react_app_block.md
- Ibexa Connect scenario block: content_management/pages/ibexa_connect_scenario_block.md
+ - Headless Page Builder: content_management/pages/headless_page_builder.md
- Forms:
- Forms: content_management/forms/forms.md
- Form Builder guide: content_management/forms/form_builder_guide.md