Skip to content

Headless Page Builder - #3414

Draft
adriendupuis wants to merge 23 commits into
6.0from
headless-page-6.0
Draft

adriendupuis wants to merge 23 commits into
6.0from
headless-page-6.0

Conversation

@adriendupuis

@adriendupuis adriendupuis commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor
Question Answer
JIRA Ticket IBX-11985
Versions 6.0 on-prem
Edition Experience, Commerce

Document how to preview a landing page into the Page Builder when going headless.

Related PRs:

  • ibexa/page-builder#550
  • TODO: SaaS version with how to set the base_url

Checklist

  • Text renders correctly
  • Text has been checked with vale
  • Description metadata is up to date
  • Redirects cover removed/moved pages
  • Code samples are working
  • PHP code samples have been fixed with PHP CS fixer
  • Added link to this PR in relevant JIRA ticket or code PR

@adriendupuis
adriendupuis changed the base branch from 5.0 to 6.0 September 17, 2026 11:17
@github-actions

github-actions Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

@github-actions

github-actions Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

Summary

Status Count
🔍 Total 753441
🔗 Unique 15120
✅ Successful 6287
⏳ Timeouts 0
🔀 Redirected 26
👻 Excluded 747115
❓ Unknown 0
🚫 Errors 39
⛔ Unsupported 0

Errors per input

Errors in site/commerce/transactional_emails/transactional_emails/index.html

Errors in site/content_management/field_types/field_type_reference/urlfield/index.html

Errors in site/getting_started/requirements/index.html

Errors in site/ibexa_cloud/ibexa_cloud_guide/index.html

Errors in site/ibexa_products/ibexa_commerce/index.html

Errors in site/ibexa_products/ibexa_experience/index.html

Errors in site/ibexa_products/ibexa_headless/index.html

Errors in site/infrastructure_and_maintenance/cache/http_cache/reverse_proxy/index.html

Errors in site/infrastructure_and_maintenance/security/reporting_issues/index.html

Errors in site/multisite/translations_management/configure_translations_management/index.html

  • [ERROR] file:///home/runner/work/documentation-developer/documentation-developer/repositories/userdoc-6.0/site/content_management/translate_content/index.html#add-new-translation (at 14826:25) | Cannot find fragment

Errors in site/multisite/translations_management/translate_with_cli/index.html

  • [ERROR] file:///home/runner/work/documentation-developer/documentation-developer/repositories/userdoc-6.0/site/content_management/translate_content/index.html#add-new-translation (at 14325:112) | Cannot find fragment

Errors in site/product_catalog/quable/install_quable/index.html

Errors in site/product_catalog/quable/quable_api/index.html

Errors in site/raptor_cdp/raptor_cdp_activation/raptor_cdp_data_export/index.html

Errors in site/recommendations/raptor_integration/connector_installation_configuration/index.html

Errors in site/recommendations/raptor_integration/raptor_connector/index.html

Errors in site/recommendations/raptor_integration/raptor_connector_guide/index.html

Errors in site/release_notes/cohesivo_v6.0_deprecations/index.html

Errors in site/release_notes/ez_platform_v1.10.0/index.html

Errors in site/release_notes/ez_platform_v1.11.0/index.html

Errors in site/release_notes/ez_platform_v1.12.0/index.html

Errors in site/release_notes/ez_platform_v1.13.0_lts/index.html

Errors in site/release_notes/ez_platform_v1.7.0_lts/index.html

Errors in site/release_notes/ez_platform_v1.8.0/index.html

Errors in site/release_notes/ez_platform_v1.9.0/index.html

Errors in site/release_notes/ibexa_dxp_v4.0/index.html

Errors in site/release_notes/ibexa_dxp_v4.4/index.html

Errors in site/release_notes/ibexa_dxp_v4.6/index.html

Errors in site/release_notes/ibexa_dxp_v5.0/index.html

Errors in site/resources/new_in_doc/index.html

Redirects per input

Redirects in site/content_management/field_types/field_type_reference/urlfield/index.html

Redirects in site/getting_started/requirements/index.html

Redirects in site/ibexa_cloud/ibexa_cloud_guide/index.html

Redirects in site/ibexa_products/ibexa_commerce/index.html

Redirects in site/ibexa_products/ibexa_experience/index.html

Redirects in site/ibexa_products/ibexa_headless/index.html

Redirects in site/infrastructure_and_maintenance/cache/http_cache/reverse_proxy/index.html

Redirects in site/infrastructure_and_maintenance/security/reporting_issues/index.html

Redirects in site/product_catalog/quable/quable_api/index.html

Redirects in site/recommendations/raptor_integration/raptor_connector_guide/index.html

Redirects in site/release_notes/cohesivo_v6.0_deprecations/index.html

Redirects in site/release_notes/ez_platform_v1.13.0_lts/index.html

Redirects in site/release_notes/ez_platform_v1.7.0_lts/index.html

Redirects in site/release_notes/ibexa_dxp_v4.0/index.html

Redirects in site/release_notes/ibexa_dxp_v4.4/index.html

Redirects in site/release_notes/ibexa_dxp_v5.0/index.html

Redirects in site/resources/new_in_doc/index.html

Full Github Actions output

@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown

code_samples/ change report

Before (on target branch)After (in current PR)

code_samples/page/headless/config/packages/ibexa_page_builder.yaml


code_samples/page/headless/config/packages/ibexa_page_builder.yaml

docs/content_management/pages/headless_page_builder.md@612:``` yaml
docs/content_management/pages/headless_page_builder.md@613:[[= include_code('code_samples/page/headless/config/packages/ibexa_page_builder.yaml', 10, 16) =]]
docs/content_management/pages/headless_page_builder.md@614:```

001⫶ibexa:
002⫶ system:
003⫶ admin_group:
004⫶ headless:
005⫶ enabled: true
006⫶ page_builder:
007⫶ preview_url: 'http://localhost:8081/page.html'

docs/content_management/pages/headless_page_builder.md@618:``` yaml
docs/content_management/pages/headless_page_builder.md@619:[[= include_code('code_samples/page/headless/config/packages/ibexa_page_builder.yaml', 18, 37) =]]
docs/content_management/pages/headless_page_builder.md@620:```

001⫶ibexa_fieldtype_page:
002⫶ layouts:
003⫶ 2_columns:
004⫶ identifier: '2-columns'
005⫶ name: '2 Columns'
006⫶ description: 'Two columns layout'
007⫶ thumbnail: '/bundles/ibexafieldtypepage/images/layouts/default.svg'
008⫶ template: '@ibexadesign/layouts/2_columns.html.twig'
009⫶ zones:
010⫶ left:
011⫶ name: Left
012⫶ right:
013⫶ name: Right
014⫶ blocks:
015⫶ tag:
016⫶ views:
017⫶ source_code:
018⫶ template: '@ibexadesign/blocks/tag/source_code.html.twig'
019⫶ name: 'See source code'
020⫶ priority: -256


code_samples/page/headless/config/services.yaml


code_samples/page/headless/config/services.yaml

docs/content_management/pages/headless_page_builder.md@634:``` yaml hl_lines="5"
docs/content_management/pages/headless_page_builder.md@635:[[= include_code('code_samples/page/headless/config/services.yaml') =]]
docs/content_management/pages/headless_page_builder.md@636:```

001⫶services:
002⫶# …
003⫶ App\Controller\RichTextController:
004⫶ arguments:
005❇️ $richTextOutputConverter: '@ibexa.richtext.converter.output.xhtml5'


code_samples/page/headless/page.html


code_samples/page/headless/page.html

docs/content_management/pages/headless_page_builder.md@582:    ``` php hl_lines="175 527 778"
docs/content_management/pages/headless_page_builder.md@583: [[= include_code('code_samples/page/headless/page.html', indent_level=1) =]]
docs/content_management/pages/headless_page_builder.md@584: ```

001⫶ <!doctype html>
002⫶ <html lang="en-US">
003⫶
004⫶ <head>
005⫶ <meta charset="utf-8"/>
006⫶ <title>Headless Page Builder test</title>
007⫶ <style>
008⫶ /* Front-end style */
009⫶
010⫶ body {
011⫶ background: oklch(47.52% 0.0999 241.4deg);
012⫶ }
013⫶
014⫶ header, footer {
015⫶ color: white;
016⫶ background: oklch(62.57% 0.0038 247.89deg);
017⫶ border-radius: 5px;
018⫶ padding: 10px;
019⫶ text-align: center;
020⫶ font-family: sans-serif;
021⫶ }
022⫶
023⫶ .layout {
024⫶ margin: 10px 0;
025⫶ }
026⫶
027⫶ #layout-2-columns {
028⫶ display: grid;
029⫶ grid-auto-flow: column;
030⫶ gap: 3px;
031⫶ }
032⫶
033⫶ .landing-page__zone {
034⫶ background: oklch(86.53% 0.0233 241.92deg);
035⫶ border: 1px solid oklch(47.52% 0.0999 241.4deg);
036⫶ border-radius: 5px;
037⫶ padding: 5px;
038⫶ }
039⫶
040⫶ .landing-page__block {
041⫶ border: 1px solid black;
042⫶ border-radius: 10px;
043⫶ background: oklch(95.81% 0 0deg);
044⫶ margin: 5px;
045⫶ padding: 10px;
046⫶ }
047⫶
048⫶ .landing-page__block .loader {
049⫶ margin: 0;
050⫶ padding: 0;
051⫶ }
052⫶
053⫶ .block-contentlist ul {
054⫶ margin: 0;
055⫶ padding: 0;
056⫶ list-style-position: inside;
057⫶ }
058⫶ </style>
059⫶ <style>
060⫶ /* Page Builder embedded style */
061⫶
062⫶ .m-page-builder__zone::after {
063⫶ display: block;
064⫶ content: attr(data-drag-drop-blocks-here);
065⫶ text-align: center;
066⫶ opacity: 67%;
067⫶ }
068⫶
069⫶ .m-page-builder__zone--empty {
070⫶ border: 1px dashed oklch(47.52% 0.0999 241.4deg);
071⫶ }
072⫶
073⫶ .m-page-builder__zone--dragover {
074⫶ border: 1px dashed white;
075⫶ }
076⫶
077⫶ .droppable-placeholder {
078⫶ opacity: 90%;
079⫶ border: 1px dashed black;
080⫶ border-radius: 10px;
081⫶ background: oklch(95.81% 0 0deg);
082⫶ margin: 5px;
083⫶ padding: 10px;
084⫶ content: ' ';
085⫶ }
086⫶
087⫶ .c-pb-block-preview--is-dragging-out {
088⫶ opacity: 75%;
089⫶ border-style: dashed;
090⫶ }
091⫶
092⫶ .c-pb-block-preview.ibexa-mark-invisible {
093⫶ opacity: 50%;
094⫶ border-style: dotted;
095⫶ }
096⫶
097⫶ .c-pb-block-preview--highlighted {
098⫶ animation-duration: 3s;
099⫶ animation-name: c-pb-block-preview--highlighted;
100⫶ }
101⫶ @keyframes c-pb-block-preview--highlighted {
102⫶ from {
103⫶ opacity: 0;
104⫶ }
105⫶ to {
106⫶ opacity: 100%;
107⫶ }
108⫶ }
109⫶
110⫶ .c-pb-block-preview .c-pb-block-preview__inner {
111⫶ margin: 0;
112⫶ padding: 0;
113⫶ }
114⫶
115⫶ .landing-page__block:has(.c-pb-block-preview__inner--invalid) {
116⫶ cursor: help;
117⫶ background: orange;
118⫶ }
119⫶ .landing-page__block .c-pb-block-preview__inner--invalid::before {
120⫶ content: '⚠';
121⫶ display: block;
122⫶ height: 10px;
123⫶ line-height: 10px;
124⫶ }
125⫶
126⫶ .c-pb-block-preview--unavailable {
127⫶ cursor: not-allowed;
128⫶ background: oklch(51.12% 0.1876 26.3deg);
129⫶ }
130⫶ .c-pb-block-preview--unavailable:hover:after {
131⫶ position: absolute;
132⫶ z-index: 1;
133⫶ content: attr(data-block-no-availability);
134⫶ white-space: pre-wrap;
135⫶ font-family: sans-serif;
136⫶ font-size: smaller;
137⫶ color: oklch(51.12% 0.1876 26.3deg);
138⫶ background: white;
139⫶ border: 3px solid oklch(51.12% 0.1876 26.3deg);
140⫶ border-radius: 3px;
141⫶ padding: 1px 3px;
142⫶ }
143⫶
144⫶ .c-pb-block-preview--is-removing {
145⫶ animation-duration: 1s;
146⫶ animation-name: c-pb-block-preview--is-removing;
147⫶ }
148⫶ @keyframes c-pb-block-preview--is-removing {
149⫶ to {
150⫶ opacity: 0;
151⫶ height: 0;
152⫶ }
153⫶ }
154⫶ </style>
155⫶ </head>
156⫶ <body>
157⫶
158⫶ <header>Header</header>
159⫶
160⫶ <main>
161⫶ <div class="layout" id="layout-default" style="display: none;">
162⫶ <div class="zone-0 zone-default landing-page__zone" data-ibexa-zone-id=""></div>
163⫶ </div>
164⫶ <div class="layout" id="layout-2-columns" style="display: none;">
165⫶ <div class="zone-0 zone-left landing-page__zone"></div>
166⫶ <div class="zone-1 zone-right landing-page__zone"></div>
167⫶ </div>
168⫶ </main>
169⫶
170⫶ <footer>Footer</footer>
171⫶
172⫶ </body>
173⫶
174⫶ <script>
175❇️ const apiBaseUrl = 'https://www.example.com';
176⫶
177⫶ /**
178⫶ * @param {string} layout - Layout identifier
179⫶ */
180⫶ const setLayout = (layout) => {
181⫶ for (const layoutElement of document.getElementsByClassName('layout')) {
182⫶ layoutElement.setAttribute('style', 'display: none;');
183⫶ }
184⫶ const layoutElement = document.getElementById('layout-' + layout);
185⫶ if (layoutElement) {
186⫶ layoutElement.removeAttribute('style');
187⫶ } else {
188⫶ console.error('No layout found for layout "' + layout + '".');
189⫶ }
190⫶ };
191⫶
192⫶ /**
193⫶ * @typedef PageBuilderConfig
194⫶ * @type {object}
195⫶ * @property {string} pbOrigin - Page Builder origin, the full URL to the back office root with scheme, domain, and port.
196⫶ * @property {Array.<object>} blocksConfig - the array of block configurations.
197⫶ * @property {blockClickedEventHandler} blockClickedEventHandler - the function handling the ID of a clicked block.
198⫶ * @property {unavailableBlockClickedEventHandler} unavailableBlockClickedEventHandler - the function handling the ID of a clicked unavailable block.
199⫶ * @property {Array.<string>} highlightedBlockIds - the array of highlighted block IDs.
200⫶ * @property {boolean} init - if the Page Builder is initializing and its config passed for the first time.
201⫶ */
202⫶ /**
203⫶ * @callback blockClickedEventHandler
204⫶ * @param {number} blockId - the ID of the clicked block
205⫶ */
206⫶ /**
207⫶ * @callback unavailableBlockClickedEventHandler
208⫶ * @param {number} blockId - the ID of the clicked unavailable block
209⫶ */
210⫶ /**
211⫶ * @function renderZones
212⫶ * @param {Array.<object>} zones
213⫶ * @param {PageBuilderConfig} pageBuilderConfig
214⫶ */
215⫶ const renderZones = (zones, pageBuilderConfig = null) => {
216⫶ for (const zone of zones) {
217⫶ //console.log('zone', zone);
218⫶ const zoneElement = document.getElementsByClassName('zone-' + zone.name.replace(/\s+/g, '-').toLowerCase())[0]; // Not conventional
219⫶ if (!zoneElement) {
220⫶ console.error('No zone element found for zone "' + zone.name + '".', zone);
221⫶ continue;
222⫶ }
223⫶ if (!pageBuilderConfig || pageBuilderConfig.init) {
224⫶ zoneElement.dataset.ibexaZoneId = zone.id;
225⫶ zoneElement.classList.add(`landing-page__zone--${zone.id}`);
226⫶ if (pageBuilderConfig) {
227⫶ zoneElement.classList.add('m-page-builder__zone');
228⫶ zoneElement.dataset.dragDropBlocksHere = window.translations['drag.drop.blocks.here'];
229⫶ }
230⫶ } else {
231⫶ zoneElement.innerHTML = '';
232⫶ }
233⫶ if (zone.blocks.length) {
234⫶ zoneElement.classList.remove('m-page-builder__zone--empty');
235⫶ for (const block of zone.blocks) {
236⫶ //console.log('block', block);
237⫶ if (block.visible || pageBuilderConfig) {
238⫶ const blockElement = document.createElement('div');
239⫶ blockElement.dataset.ibexaBlockId = block.id;
240⫶ blockElement.setAttribute('title', block.name + (pageBuilderConfig ? ' (' + block.id + ') [' + block.type + ']' : ''));
241⫶ const innerHTML = getBlockInnerHTML(block, null !== pageBuilderConfig);
242⫶ if (!innerHTML) {
243⫶ console.error('Unable to render', block);
244⫶ continue;
245⫶ }
246⫶ blockElement.innerHTML = innerHTML;
247⫶ blockElement.classList.add('landing-page__block');
248⫶ blockElement.classList.add('block-' + block.type);
249⫶ blockElement.dataset.type = block.type;
250⫶ blockElement.dataset.name = block.name;
251⫶ if (block.class) {
252⫶ blockElement.classList.add(...block.class.split(' '));
253⫶ }
254⫶ if (block.style) {
255⫶ blockElement.setAttribute('style', block.style);
256⫶ }
257⫶ if (pageBuilderConfig) {
258⫶ blockElement.classList.add('c-pb-block-preview');
259⫶ if (null !== block.visible && false === block.visible) {
260⫶ blockElement.classList.add('ibexa-mark-invisible');
261⫶ }
262⫶ if (pageBuilderConfig.blockClickedEventHandler) {
263⫶ blockElement.addEventListener('click', (clickEvent) => {
264⫶ pageBuilderConfig.blockClickedEventHandler(block.id);
265⫶ });
266⫶ }
267⫶ if (pageBuilderConfig.highlightedBlockIds && pageBuilderConfig.highlightedBlockIds.includes(block.id)) {
268⫶ console.log('Start highlighting block', block.id);
269⫶ blockElement.addEventListener('animationend', () => {
270⫶ console.log('End highlighting block', block.id);
271⫶ blockElement.classList.remove('c-pb-block-preview--highlighted');
272⫶ //positionsUpdate();
273⫶ });
274⫶ blockElement.classList.add('c-pb-block-preview--highlighted');
275⫶ }
276⫶ }
277⫶ zoneElement.appendChild(blockElement);
278⫶ }
279⫶ }
280⫶ } else {
281⫶ zoneElement.classList.add('m-page-builder__zone--empty');
282⫶ }
283⫶ if (pageBuilderConfig && pageBuilderConfig.blocksConfig && pageBuilderConfig.blocksConfig.length) {
284⫶ const setBlockAsUnavailable = (blockElement) => {
285⫶ blockElement.classList.add('c-pb-block-preview--unavailable');
286⫶ if (pageBuilderConfig.unavailableBlockClickedEventHandler) {
287⫶ blockElement.dataset.blockNoAvailability = '🚫 ' + window.translations['block.no_availability.title'] + '. ' + window.translations['block.no_availability.content'] + '.\n\r 🗑 ' + window.translations['block.no_availability.delete'];
288⫶ blockElement.addEventListener('click', (clickEvent) => {
289⫶ pageBuilderConfig.unavailableBlockClickedEventHandler(blockElement.dataset.ibexaBlockId);
290⫶ });
291⫶ }
292⫶ };
293⫶ let existingBlockTypes = [];
294⫶ for (const blockConfig of pageBuilderConfig.blocksConfig) {
295⫶ existingBlockTypes.push(blockConfig.type);
296⫶ if (!blockConfig.visible) {
297⫶ for (const unavailableBlockElement of document.getElementsByClassName('block-' + blockConfig.type)) {
298⫶ setBlockAsUnavailable(unavailableBlockElement);
299⫶ }
300⫶ }
301⫶ }
302⫶ for (const blockElement of document.querySelectorAll('.landing-page__block:not(.c-pb-block-preview--unavailable)')) {
303⫶ if (!existingBlockTypes.includes(blockElement.dataset.type)) {
304⫶ console.error(window.translations['block.unknown.type'].replace('%type%', blockElement.dataset.type).replace('%name%', blockElement.dataset.name));
305⫶ setBlockAsUnavailable(blockElement);
306⫶ }
307⫶ }
308⫶ }
309⫶ }
310⫶ };
311⫶
312⫶ /**
313⫶ * @param {object} block
314⫶ * @param {string} attributeName
315⫶ * @returns {*|null}
316⫶ */
317⫶ const getBlockAttributeValue = (block, attributeName) => {
318⫶ for (const attribute of block.attributes) {
319⫶ if (attribute.name === attributeName) {
320⫶ return attribute.value;
321⫶ }
322⫶ }
323⫶ console.error('No attribute "' + attributeName + '" found for block "' + block.name + '" (' + block.id + ') [' + block.type + '].');
324⫶ return null;
325⫶ };
326⫶
327⫶ /**
328⫶ * @param {object} block
329⫶ * @param {boolean} pb - if the block is rendered inside the Page Builder
330⫶ * @returns {string|null}
331⫶ */
332⫶ const getBlockInnerHTML = (block, pb = false) => {
333⫶ switch (block.type) {
334⫶ case 'tag': // </> Code
335⫶ switch (block.view) {
336⫶ case 'source_code':
337⫶ return '<pre' + (pb ? ' class="c-pb-block-preview__inner"' : '') + '><code>' + getBlockAttributeValue(block, 'content').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') + '</code></pre>';
338⫶ default:
339⫶ console.warn('Unhandled view "' + block.view + '" for block type "' + block.type + '". Using "default" view.');
340⫶ case 'default':
341⫶ let content = getBlockAttributeValue(block, 'content');
342⫶ if (!content) {
343⫶ if (pb) {
344⫶ content = ' ';
345⫶ } else {
346⫶ return null;
347⫶ }
348⫶ }
349⫶ return '<div' + (pb ? ' class="c-pb-block-preview__inner"' : '') + '>' + content + '</div>';
350⫶ }
351⫶ case 'contentlist': // • Content List
352⫶ let content = '';
353⫶ let isInvalid = false;
354⫶ const contentId = getBlockAttributeValue(block, 'contentId');
355⫶ if (!contentId) {
356⫶ isInvalid = true;
357⫶ console.error('contentId is not set for block "' + block.name + '" (' + block.id + ').');
358⫶ if (pb) {
359⫶ if (window.translations && window.translations['block.attribute.invalid']) {
360⫶ content += (content.length ? '<br/>' : '') + window.translations['block.attribute.invalid'].replace('%name%', '<code>contentId</code>');
361⫶ }
362⫶ } else {
363⫶ return null;
364⫶ }
365⫶ }
366⫶ const contentTypes = getBlockAttributeValue(block, 'contentType')?.split(',') ?? [];
367⫶ if (!contentTypes || !contentTypes.length) {
368⫶ isInvalid = true;
369⫶ console.error('No contentType selected for block "' + block.name + '" (' + block.id + ').');
370⫶ if (pb) {
371⫶ if (window.translations && window.translations['block.attribute.invalid']) {
372⫶ content += (content.length ? '<br/>' : '') + window.translations['block.attribute.invalid'].replace('%name%', '<code>contentType</code>');
373⫶ }
374⫶ } else {
375⫶ return null;
376⫶ }
377⫶ }
378⫶ const limit = getBlockAttributeValue(block, 'limit');
379⫶ if (!limit || isNaN(limit) || limit < 0) {
380⫶ isInvalid = true;
381⫶ console.error('Invalid limit "' + limit + '" for block "' + block.name + '" (' + block.id + ').');
382⫶ if (pb) {
383⫶ if (window.translations && window.translations['block.attribute.invalid']) {
384⫶ content += (content.length ? '<br/>' : '') + window.translations['block.attribute.invalid'].replace('%name%', '<code>limit</code>');
385⫶ }
386⫶ } else {
387⫶ return null;
388⫶ }
389⫶ }
390⫶ if (isInvalid) {
391⫶ return '<p class="c-pb-block-preview__inner c-pb-block-preview__inner--invalid">' + content + '</p>';
392⫶ }
393⫶ getContentInfoList(contentId, contentTypes, limit)
394⫶ .then((contentInfoList) => {
395⫶ let innerHTML = '<div' + (pb ? ' class="c-pb-block-preview__inner"' : '') + '>';
396⫶ if (contentInfoList.length > 0) {
397⫶ innerHTML += '<ul>';
398⫶ for (const contentInfo of contentInfoList) {
399⫶ innerHTML += '<li>' + contentInfo.Content.Name + '</li>';
400⫶ }
401⫶ innerHTML += '</ul>';
402⫶ }
403⫶ innerHTML += '</div>';
404⫶ const blockElement = document.querySelector(`[data-ibexa-block-id="${block.id}"]`);
405⫶ if (blockElement) {
406⫶ blockElement.innerHTML = innerHTML;
407⫶ }
408⫶ })
409⫶ .catch((error) => {
410⫶ console.error('Error fetching content list:', error);
411⫶ });
412⫶ return '<p class="' + (pb ? 'c-pb-block-preview__inner ' : '') + 'loader">Loading content list…</p>';
413⫶ case 'richtext': // ¶ Text
414⫶ richTextToHtml5(getBlockAttributeValue(block, 'content'))
415⫶ .then((html5) => {
416⫶ const blockElement = document.querySelector(`[data-ibexa-block-id="${block.id}"]`);
417⫶ if (blockElement) {
418⫶ blockElement.innerHTML = '<div' + (pb ? ' class="c-pb-block-preview__inner"' : '') + '>' + html5 + '</div>';
419⫶ }
420⫶ })
421⫶ .catch((error) => {
422⫶ console.error('Error converting richtext to HTML5:', error);
423⫶ });
424⫶ return '<p class="' + (pb ? 'c-pb-block-preview__inner ' : '') + 'loader">Converting XML to HTML…</p>';
425⫶ break;
426⫶ default:
427⫶ console.error('No rendering for block type "' + block.type + '".');
428⫶ if (pb) {
429⫶ return '<p' + (pb ? ' class="c-pb-block-preview__inner"' : '') + '>Block “' + block.name + '” (' + block.id + ') [' + block.type + ']</p>';
430⫶ }
431⫶ return null;
432⫶ }
433⫶ };
434⫶
435⫶ /**
436⫶ * @param {number} contentId - the parent content ID to fetch children from
437⫶ * @param {Array.<string>} contentTypes - list of content type identifiers to filter by
438⫶ * @param {number} limit - maximum number of content items to return
439⫶ * @returns {Promise<Array.<object>>} - a promise that resolves to an array of content info objects
440⫶ */
441⫶ const getContentInfoList = async (contentId, contentTypes, limit) => {
442⫶ let contentInfoList = [];
443⫶ if (0 === limit) {
444⫶ return contentInfoList;
445⫶ }
446⫶
447⫶ let response;
448⫶ let contentTypeHrefIdentifierMap = {};
449⫶
450⫶ // contentId -> mainLocationId
451⫶ response = await fetch(`${apiBaseUrl}/api/ibexa/v2/content/objects/${contentId}`, {
452⫶ method: 'GET',
453⫶ headers: {
454⫶ 'Accept': 'application/vnd.ibexa.api.ContentInfo+json',
455⫶ },
456⫶ });
457⫶
458⫶ const parent = await response.json();
459⫶
460⫶ // mainLocationId -> children
461⫶ response = await fetch(`${apiBaseUrl}${parent.Content.MainLocation._href}/children`, {
462⫶ method: 'GET',
463⫶ headers: {
464⫶ 'Accept': 'application/vnd.ibexa.api.LocationList+json',
465⫶ },
466⫶ });
467⫶
468⫶ const children = await response.json();
469⫶
470⫶ // child -> contentId + ContentType
471⫶ for (const child of children.LocationList.Location) {
472⫶ response = await fetch(`${apiBaseUrl}${child._href}`, {
473⫶ method: 'GET',
474⫶ headers: {
475⫶ 'Accept': child['_media-type'],
476⫶ },
477⫶ });
478⫶ const location = await response.json();
479⫶ // ContentType._href -> ContentType.identifier
480⫶ const contentTypeHref = location.Location.ContentInfo.Content.ContentType._href;
481⫶ let contentTypeIdentifier;
482⫶ if (undefined === contentTypeHrefIdentifierMap[contentTypeHref]) {
483⫶ response = await fetch(`${apiBaseUrl}${contentTypeHref}`, {
484⫶ method: 'GET',
485⫶ headers: {
486⫶ 'Accept': child['_media-type'],
487⫶ },
488⫶ });
489⫶ const contentType = await response.json();
490⫶ contentTypeIdentifier = contentType.ContentType.identifier;
491⫶ contentTypeHrefIdentifierMap[contentTypeHref] = contentTypeIdentifier;
492⫶ } else {
493⫶ contentTypeIdentifier = contentTypeHrefIdentifierMap[contentTypeHref];
494⫶ }
495⫶ // Filter by content type identifier
496⫶ if (contentTypes.includes(contentTypeIdentifier)) {
497⫶ response = await fetch(`${apiBaseUrl}${contentTypeHref}`, {
498⫶ method: 'GET',
499⫶ headers: {
500⫶ 'Accept': child['_media-type'],
501⫶ },
502⫶ });
503⫶ contentInfoList.push(location.Location.ContentInfo);
504⫶ }
505⫶ // Loop until limit or no more children
506⫶ if (contentInfoList.length >= limit) {
507⫶ break;
508⫶ }
509⫶ }
510⫶
511⫶ return contentInfoList;
512⫶ };
513⫶
514⫶ /**
515⫶ * @param {string} docBook - the RichText DocBook XML string to convert to HTML5
516⫶ * @returns {Promise<string>} - a promise that resolves to the converted HTML5 string
517⫶ */
518⫶ const richTextToHtml5 = async (docBook) => {
519⫶ const response = await fetch(`${apiBaseUrl}/richtext-to-html5`, {
520⫶ method: 'POST',
521⫶ body: docBook,
522⫶ });
523⫶ return await response.text();
524⫶ };
525⫶
526⫶ if (window.parent !== window) { // Inside the Page Builder iframe
527❇️ const pbOrigin = 'https://admin.example.com';
528⫶ const capabilities = [
529⫶ 'blocks.dnd', // 100%
530⫶ 'blocks.geometry', // 100%
531⫶ 'blocks.remove', // 100%
532⫶ 'blocks.reveal', // 100%
533⫶ 'blocks.select', // 100%
534⫶ 'pointer.tracking', // 100%
535⫶ 'preview.params', // 0%
536⫶ ];
537⫶ const initInterval = 1500; // ms
538⫶ const initRetries = -1; // -1 = infinite, 0 = no retry, 1 = 1 retry, etc.
539⫶ const idleLogDelay = 500; // ms
540⫶
541⫶ const postMessageToPB = (type, data = {}) => {
542⫶ window.parent.postMessage({
543⫶ type: type,
544⫶ data: data,
545⫶ }, pbOrigin);
546⫶ logMessage(type, data);
547⫶ };
548⫶ const logMessage = (type, data) => {
549⫶ if (['APP:MOUSE_POSITION', 'PB:DRAG_OVER'].includes(type)) {
550⫶ window.clearTimeout(window.logIdleTimer);
551⫶ window.logIdleTimer = window.setTimeout(() => {
552⫶ console.log(type + ' (idle ' + idleLogDelay + 'ms)', data);
553⫶ }, idleLogDelay);
554⫶ } else {
555⫶ console.log(type, data);
556⫶ }
557⫶ }
558⫶
559⫶ window.addEventListener('message', (messageEvent) => {
560⫶ logMessage(messageEvent.data.type, messageEvent.data.data);
561⫶ switch (messageEvent.data.type) {
562⫶ case 'PB:INIT_MODE':
563⫶ window.clearInterval(window.initIntervalId);
564⫶ window.blocksConfig = messageEvent.data.data.blocksConfig;
565⫶ window.translations = messageEvent.data.data.translations;
566⫶
567⫶ document.body.classList.add('c-pb-iframe__preview-body');
568⫶ setLayout(messageEvent.data.data.fieldValue.layout);
569⫶ renderZones(messageEvent.data.data.fieldValue.zones, {
570⫶ pbOrigin: pbOrigin,
571⫶ blocksConfig: window.blocksConfig,
572⫶ blockClickedEventHandler: blockClicked,
573⫶ unavailableBlockClickedEventHandler: blockRemoveRequest,
574⫶ highlightedBlockIds: [],
575⫶ init: true,
576⫶ });
577⫶
578⫶ window.addEventListener('mousemove', (mouseEvent) => {
579⫶ window.mouseX = mouseEvent.clientX;
580⫶ window.mouseY = mouseEvent.clientY;
581⫶ postMessageToPB('APP:MOUSE_POSITION', {
582⫶ x: window.mouseX,
583⫶ y: window.mouseY,
584⫶ });
585⫶ });
586⫶ window.addEventListener('scrollend', (event) => {
587⫶ postMessageToPB('APP:SCROLL_END');
588⫶ positionsUpdate();
589⫶ });
590⫶ window.addEventListener('resize', (event) => {
591⫶ positionsUpdate();
592⫶ });
593⫶
594⫶ positionsUpdate();
595⫶ break;
596⫶ case 'PB:DRAG_START_PREVIEW':
597⫶ document.querySelector(`[data-ibexa-block-id="${messageEvent.data.data.blockId}"]`).classList.add('c-pb-block-preview--is-dragging-out');
598⫶ break;
599⫶ case 'PB:DRAG_END_PREVIEW':
600⫶ document.querySelector(`[data-ibexa-block-id="${messageEvent.data.data.blockId}"]`).classList.remove('c-pb-block-preview--is-dragging-out');
601⫶ break;
602⫶ case 'PB:DRAG_OVER':
603⫶ window.mouseX = messageEvent.data.data.x;
604⫶ window.mouseY = messageEvent.data.data.y;
605⫶ const hoveredElement = document.elementFromPoint(window.mouseX, window.mouseY);
606⫶ const hoveredZoneElement = hoveredElement.closest('.landing-page__zone');
607⫶ if (hoveredZoneElement) {
608⫶ hoveredZoneElement.classList.add('m-page-builder__zone--dragover');
609⫶ }
610⫶ for (const zoneElement of document.getElementsByClassName('m-page-builder__zone--dragover')) {
611⫶ if (zoneElement !== hoveredZoneElement) {
612⫶ zoneElement.classList.remove('m-page-builder__zone--dragover');
613⫶ }
614⫶ }
615⫶ let hoveredPlaceHolderElement = hoveredElement.closest('.droppable-placeholder');
616⫶ for (const placeHolderElement of document.getElementsByClassName('droppable-placeholder')) {
617⫶ if (placeHolderElement !== hoveredPlaceHolderElement) {
618⫶ placeHolderElement.remove();
619⫶ }
620⫶ }
621⫶ if (hoveredZoneElement && !hoveredPlaceHolderElement) {
622⫶ let hoveredBlockElement = hoveredElement.closest('.landing-page__block');
623⫶ if (!hoveredBlockElement) {
624⫶ hoveredBlockElement = document.elementFromPoint(window.mouseX, window.mouseY - 5).closest('.landing-page__block');
625⫶ }
626⫶ let newPlaceHolderElement = document.createElement('div');
627⫶ newPlaceHolderElement.classList.add('droppable-placeholder');
628⫶ if (hoveredBlockElement) {
629⫶ hoveredBlockElement.before(newPlaceHolderElement);
630⫶ } else {
631⫶ hoveredZoneElement.appendChild(newPlaceHolderElement);
632⫶ }
633⫶ }
634⫶ break;
635⫶ case 'PB:DROP':
636⫶ for (const zoneElement of document.getElementsByClassName('m-page-builder__zone--dragover')) {
637⫶ zoneElement.classList.remove('m-page-builder__zone--dragover');
638⫶ }
639⫶ for (const placeHolderElement of document.getElementsByClassName('droppable-placeholder')) {
640⫶ placeHolderElement.remove();
641⫶ }
642⫶ const dropTargetElement = document.elementFromPoint(window.mouseX, window.mouseY);
643⫶ if (!dropTargetElement) {
644⫶ console.error('No drop target element found.', 'notification.headless_unresponsive_preview');
645⫶ return;
646⫶ }
647⫶ const dropTargetOffsetBlockElement = dropTargetElement.closest('.landing-page__block');
648⫶ let nextBlockId;
649⫶ if (dropTargetOffsetBlockElement) {
650⫶ nextBlockId = dropTargetOffsetBlockElement.dataset.ibexaBlockId;
651⫶ }
652⫶ const zoneElement = dropTargetElement.closest('.landing-page__zone');
653⫶ if (!zoneElement) {
654⫶ console.error('No zone element found. Drop target element: ', dropTargetElement, 'notification.headless_unresponsive_preview');
655⫶ return;
656⫶ }
657⫶ postMessageToPB('APP:DROP_RESPONSE', {
658⫶ zoneId: zoneElement.dataset.ibexaZoneId,
659⫶ nextBlockId: nextBlockId,
660⫶ });
661⫶ break;
662⫶ case 'PB:SCROLL_BY':
663⫶ window.scrollBy(messageEvent.data.data);
664⫶ break;
665⫶ case 'PB:SCROLL_INTO_BLOCK':
666⫶ const blockElementToScrollInto = document.querySelector(`[data-ibexa-block-id="${messageEvent.data.data.blockId}"]`);
667⫶ if (blockElementToScrollInto) {
668⫶ blockElementToScrollInto.scrollIntoView({behavior: 'smooth', block: 'center'});
669⫶ } else {
670⫶ console.error('No block element found for block ID "' + messageEvent.data.data.blockId + '".');
671⫶ }
672⫶ break;
673⫶ case 'PB:UPDATE_FIELD_DATA':
674⫶ renderZones(messageEvent.data.data.fieldValue.zones, {
675⫶ pbOrigin: pbOrigin,
676⫶ blocksConfig: window.blocksConfig,
677⫶ blockClickedEventHandler: blockClicked,
678⫶ unavailableBlockClickedEventHandler: blockRemoveRequest,
679⫶ highlightedBlockIds: messageEvent.data.data.highlightedBlockIds,
680⫶ init: false,
681⫶ });
682⫶ positionsUpdate();
683⫶ break;
684⫶ case 'PB:DISPATCH_EVENT':
685⫶ window.dispatchEvent(new CustomEvent(messageEvent.data.data.eventName, {detail: messageEvent.data.data.eventData}));
686⫶ break;
687⫶ case 'PB:BLOCK_REMOVE':
688⫶ const blockId = messageEvent.data.data.blockId;
689⫶ const blockElementToRemove = document.querySelector(`[data-ibexa-block-id="${blockId}"]`);
690⫶ if (blockElementToRemove) {
691⫶ blockElementToRemove.addEventListener('animationend', () => {
692⫶ blockElementToRemove.remove();
693⫶ postMessageToPB('APP:BLOCK_REMOVE_RESPONSE', {
694⫶ blockId: blockId,
695⫶ });
696⫶ });
697⫶ blockElementToRemove.classList.add('c-pb-block-preview--is-removing');
698⫶ } else {
699⫶ console.error('No block element found for block ID "' + messageEvent.data.data.blockId + '".', 'notification.headless_unresponsive_preview');
700⫶ }
701⫶ break;
702⫶ case 'PB:UPDATE_PREVIEW_PARAMS':
703⫶ //TODO?
704⫶ break;
705⫶ default:
706⫶ // As you can't receive an optional message from undeclared capabilities, to receive an unsupported one has to be an error.
707⫶ // The unsupported received message reaching this is a mandatory message or one from a capability declared as supported.
708⫶ console.error('Unsupported message type "' + messageEvent.data.type + '".');
709⫶ console.log(messageEvent);
710⫶ }
711⫶ });
712⫶
713⫶ window.addEventListener('ibexa-active-block-clicked', (customEvent) => {
714⫶ console.log(customEvent);
715⫶ });
716⫶ window.addEventListener('ibexa-post-update-blocks-preview', (customEvent) => {
717⫶ console.log(customEvent);
718⫶ setLayout(customEvent.detail.fieldValue.layout);
719⫶ renderZones(customEvent.detail.fieldValue.zones, {
720⫶ pbOrigin: pbOrigin,
721⫶ blocksConfig: window.blocksConfig,
722⫶ blockClickedEventHandler: blockClicked,
723⫶ unavailableBlockClickedEventHandler: blockRemoveRequest,
724⫶ highlightedBlockIds: [],
725⫶ init: false,
726⫶ });
727⫶ });
728⫶
729⫶ let initRetry = 0;
730⫶ window.initIntervalId = window.setInterval(() => {
731⫶ postMessageToPB('APP:INITIALIZED', {
732⫶ protocol: {supported: [1]},
733⫶ capabilities: capabilities,
734⫶ });
735⫶ if (initRetries >= 0 && initRetry >= initRetries) {
736⫶ window.clearInterval(window.initIntervalId);
737⫶ }
738⫶ initRetry++;
739⫶ }, initInterval);
740⫶
741⫶ const positionsUpdate = () => {
742⫶ let blocks = [];
743⫶ for (const blockElement of document.getElementsByClassName('landing-page__block')) {
744⫶ const blockId = blockElement.dataset.ibexaBlockId;
745⫶ const blockRect = blockElement.getBoundingClientRect();
746⫶ blocks.push({
747⫶ id: blockId,
748⫶ top: blockRect.top,
749⫶ left: blockRect.left,
750⫶ right: blockRect.right,
751⫶ bottom: blockRect.bottom,
752⫶ width: blockRect.width,
753⫶ height: blockRect.height,
754⫶ });
755⫶ }
756⫶ postMessageToPB('APP:POSITIONS_UPDATE', {
757⫶ blocks: blocks,
758⫶ });
759⫶ };
760⫶ window.positionsUpdate = positionsUpdate; // TMP
761⫶ const blockClicked = (blockId) => {
762⫶ postMessageToPB('APP:BLOCK_CLICKED', {
763⫶ blockId: blockId,
764⫶ });
765⫶ };
766⫶ const blockRemoveRequest = (blockId) => {
767⫶ postMessageToPB('APP:BLOCK_REMOVE_REQUEST', {
768⫶ blockId: blockId,
769⫶ });
770⫶ };
771⫶
772⫶ } else { // Stand-alone front page
773⫶ // ⚠ This is done purely on client side for demo purposes only.
774⫶ // In real project, content rendering, API requests, as much as possible should be done on front-end server's side.
775⫶
776⫶ // For example: /page.html?contentId=52
777⫶
778❇️ const pageContentTypeIds = [42/*landing_page*/];
779⫶ const searchParams = new URLSearchParams(window.location.search);
780⫶
781⫶ const renderContent = async (contentId) => {
782⫶ try {
783⫶ const response = await fetch(`${apiBaseUrl}/api/ibexa/v2/content/objects/${contentId}`, {
784⫶ method: 'GET',
785⫶ headers: {
786⫶ 'Accept': 'application/vnd.ibexa.api.Content+json',
787⫶ },
788⫶ });
789⫶
790⫶ if (!response.ok) {
791⫶ throw new Error('Response status "' + response.status + '" is not OK.');
792⫶ }
793⫶
794⫶ const result = await response.json();
795⫶ const contentTypeId = parseInt(result.Content.ContentType._href.replace('/api/ibexa/v2/content/types/', ''));
796⫶ if (pageContentTypeIds && !pageContentTypeIds.includes(contentTypeId)) {
797⫶ throw new Error('Wrong content type ' + contentTypeId + '.');
798⫶ }
799⫶ let fieldIsFound = false;
800⫶ for (field of result.Content.CurrentVersion.Version.Fields.field) {
801⫶ if (field.fieldDefinitionIdentifier === 'page') {
802⫶ fieldIsFound = true;
803⫶ setLayout(field.fieldValue.layout);
804⫶ renderZones(field.fieldValue.zones);
805⫶ break;
806⫶ }
807⫶ }
808⫶ if (!fieldIsFound) {
809⫶ throw new Error('No field "page" found in content with ID ' + contentId + '.');
810⫶ }
811⫶ } catch (error) {
812⫶ console.error(error.message);
813⫶ }
814⫶ }
815⫶
816⫶ renderContent(searchParams.get('contentId'));
817⫶ }
818⫶ </script>
819⫶
820⫶ </html>


code_samples/page/headless/src/Controller/RichTextController.php


code_samples/page/headless/src/Controller/RichTextController.php

docs/content_management/pages/headless_page_builder.md@628:    ```php
docs/content_management/pages/headless_page_builder.md@629: [[= include_code('code_samples/page/headless/src/Controller/RichTextController.php', indent_level=1) =]]
docs/content_management/pages/headless_page_builder.md@630: ```

001⫶ <?php
002⫶
003⫶ namespace App\Controller;
004⫶
005⫶ use Ibexa\Contracts\FieldTypeRichText\RichText\Converter as RichTextConverterInterface;
006⫶ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
007⫶ use Symfony\Component\HttpFoundation\Request;
008⫶ use Symfony\Component\HttpFoundation\Response;
009⫶ use Symfony\Component\Routing\Attribute\Route;
010⫶
011⫶ class RichTextController extends AbstractController
012⫶ {
013⫶ public function __construct(private readonly RichTextConverterInterface $richTextOutputConverter)
014⫶ {
015⫶ }
016⫶
017⫶ /**
018⫶ * Convert RichText DocBook XML into HTML 5
019⫶ *
020⫶ * @param Request $request
021⫶ * @return Response
022⫶ */
023⫶ #[Route('/richtext-to-html5')]
024⫶ public function richTextToHtml5(Request $request): Response
025⫶ {
026⫶ $xml = new \DOMDocument();
027⫶ $xml->loadXML($request->getContent());
028⫶ return new Response($this->richTextOutputConverter->convert($xml)->saveHTML());
029⫶ }
030⫶ }

Download colorized diff

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant