feat: extend module platform with UI slots, runtime components, CLI tooling and {{userId}} search support
Completes the generic module platform that enables modules to contribute
UI elements, runtime JS components, and search resources without any
core hardcoding.
New generic UI slot types:
- topbar.right_item: module-contributed topbar buttons
- layout.body_end_template: module-contributed dialog/overlay templates
- layout.head_style: module-contributed global CSS
- runtime.component: declarative JS component registration with phase ordering
New infrastructure:
- ModuleAutoloader: PSR-4 autoloading for module-local PHP classes
- ModuleRuntimePageBuilder: symlinks module pages into runtime directory
- ModuleRuntimeAssetPublisher: publishes module CSS/JS to web/modules/
- ModulePermissionSynchronizer: syncs module permissions to DB
- CLI scripts: module-runtime-sync, module-build, module-migrate,
module-permissions-sync, module-assets-sync
- {{userId}} placeholder in SearchDataService for user-scoped search queries
- Component runtime with phased initialization (early/default/late)
- AppContainer.protectExistingBindings() to prevent module→core overwrites
- Architecture tests: ModuleStructureContractTest, CoreTemplateIsolationTest,
FrontendComponentRuntimeContractTest, AppContainerIsolationContractTest
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,192 +1,250 @@
|
||||
/**
|
||||
* Editor.js integration — initializes block editor for CMS page content.
|
||||
* Editor.js integration — lifecycle-managed page editor module.
|
||||
*/
|
||||
import EditorJS from '../../vendor/editorjs/editorjs.mjs';
|
||||
import { warnOnce } from '../core/app-dom.js';
|
||||
import { warnOnce, resolveHost } from '../core/app-dom.js';
|
||||
|
||||
const form = document.querySelector('[data-page-editor]');
|
||||
if (form) {
|
||||
const holder = form.querySelector('[data-editor-holder]');
|
||||
if (!holder) {
|
||||
warnOnce('UI_EL_MISSING', 'Missing editor holder', { module: 'page-editor' });
|
||||
} else {
|
||||
const contentField = form.querySelector('#page-content');
|
||||
if (!contentField) {
|
||||
warnOnce('UI_EL_MISSING', 'Missing #page-content input', { module: 'page-editor' });
|
||||
const parseData = (value) => {
|
||||
if (!value) {
|
||||
return { blocks: [] };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
if (!Array.isArray(parsed.blocks)) {
|
||||
parsed.blocks = [];
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
const toggleButton = document.querySelector('[data-editor-toggle]');
|
||||
const saveButton = document.querySelector('[data-editor-save]');
|
||||
const canEdit = form.dataset.canEdit === '1';
|
||||
} catch {
|
||||
// ignore invalid JSON
|
||||
}
|
||||
return { blocks: [] };
|
||||
};
|
||||
|
||||
const parseData = (value) => {
|
||||
if (!value) {
|
||||
return { blocks: [] };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
if (!Array.isArray(parsed.blocks)) {
|
||||
parsed.blocks = [];
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore invalid JSON
|
||||
}
|
||||
return { blocks: [] };
|
||||
const resolveTools = (form) => {
|
||||
const HeaderTool = window.Header || window.EditorJSHeader || null;
|
||||
const ListTool = window.EditorjsList || window.List || null;
|
||||
const ChecklistTool = window.Checklist || window.EditorJSChecklist || null;
|
||||
const TableTool = window.Table || window.EditorJSTable || null;
|
||||
const ColumnsTool = window.editorjsColumns || window.Columns || null;
|
||||
const MarkerTool = window.Marker || null;
|
||||
|
||||
const tools = {};
|
||||
if (HeaderTool) {
|
||||
tools.header = {
|
||||
class: HeaderTool,
|
||||
inlineToolbar: true,
|
||||
config: {
|
||||
placeholder: form.dataset.headerPlaceholder || 'Heading',
|
||||
levels: [1, 2, 3, 4],
|
||||
defaultLevel: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const setToggleLabel = (mode) => {
|
||||
if (!toggleButton) {
|
||||
return;
|
||||
}
|
||||
const viewLabel = toggleButton.dataset.viewLabel || '';
|
||||
const editLabel = toggleButton.dataset.editLabel || '';
|
||||
const text = mode === 'edit' ? viewLabel : editLabel;
|
||||
const icon = toggleButton.querySelector('[data-editor-icon]');
|
||||
|
||||
if (icon) {
|
||||
icon.className = mode === 'edit' ? 'bi bi-eye-fill' : 'bi bi-pencil-square';
|
||||
}
|
||||
toggleButton.setAttribute('aria-label', text);
|
||||
toggleButton.dataset.tooltip = text;
|
||||
}
|
||||
if (ListTool) {
|
||||
tools.list = {
|
||||
class: ListTool,
|
||||
inlineToolbar: true,
|
||||
config: {
|
||||
defaultStyle: 'unordered',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (ChecklistTool) {
|
||||
tools.checklist = {
|
||||
class: ChecklistTool,
|
||||
inlineToolbar: true,
|
||||
};
|
||||
}
|
||||
if (TableTool) {
|
||||
tools.table = {
|
||||
class: TableTool,
|
||||
inlineToolbar: true,
|
||||
config: {
|
||||
rows: 2,
|
||||
cols: 2,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (ColumnsTool) {
|
||||
const innerTools = { ...tools };
|
||||
tools.columns = {
|
||||
class: ColumnsTool,
|
||||
config: {
|
||||
EditorJsLibrary: EditorJS,
|
||||
tools: innerTools,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (MarkerTool) {
|
||||
tools.marker = {
|
||||
class: MarkerTool,
|
||||
shortcut: 'CMD+SHIFT+M',
|
||||
};
|
||||
}
|
||||
|
||||
let mode = form.dataset.editMode === 'edit' && canEdit ? 'edit' : 'view';
|
||||
return {
|
||||
tools,
|
||||
inlineTools: MarkerTool ? ['marker', 'bold', 'italic', 'link'] : ['bold', 'italic', 'link'],
|
||||
};
|
||||
};
|
||||
|
||||
export function initPageEditor(root = document, config = {}) {
|
||||
const host = resolveHost(root);
|
||||
const formSelector = String(config.selector || '[data-page-editor]').trim() || '[data-page-editor]';
|
||||
const form = host.matches?.(formSelector) ? host : host.querySelector(formSelector);
|
||||
if (!(form instanceof HTMLFormElement)) {
|
||||
return { destroy: () => {} };
|
||||
}
|
||||
if (form.dataset.pageEditorBound === '1' && form._pageEditorApi) {
|
||||
return form._pageEditorApi;
|
||||
}
|
||||
|
||||
const holderSelector = String(config.holderSelector || '[data-editor-holder]').trim() || '[data-editor-holder]';
|
||||
const contentFieldSelector = String(config.contentFieldSelector || '#page-content').trim() || '#page-content';
|
||||
const controlsRootSelector = String(config.controlsRootSelector || '').trim();
|
||||
const toggleSelector = String(config.toggleSelector || '[data-editor-toggle]').trim() || '[data-editor-toggle]';
|
||||
const saveSelector = String(config.saveSelector || '[data-editor-save]').trim() || '[data-editor-save]';
|
||||
|
||||
const holder = form.querySelector(holderSelector);
|
||||
if (!(holder instanceof HTMLElement)) {
|
||||
warnOnce('UI_EL_MISSING', 'Missing editor holder', { module: 'page-editor' });
|
||||
return { destroy: () => {} };
|
||||
}
|
||||
|
||||
const contentField = form.querySelector(contentFieldSelector);
|
||||
if (!(contentField instanceof HTMLTextAreaElement)) {
|
||||
warnOnce('UI_EL_MISSING', `Missing page editor content field: ${contentFieldSelector}`, { module: 'page-editor' });
|
||||
return { destroy: () => {} };
|
||||
}
|
||||
|
||||
const controlsRoot = controlsRootSelector !== ''
|
||||
? document.querySelector(controlsRootSelector)
|
||||
: document;
|
||||
const controlsHost = controlsRoot && typeof controlsRoot.querySelector === 'function'
|
||||
? controlsRoot
|
||||
: document;
|
||||
const toggleButton = controlsHost.querySelector(toggleSelector);
|
||||
const saveButton = controlsHost.querySelector(saveSelector);
|
||||
|
||||
const canEdit = form.dataset.canEdit === '1';
|
||||
let mode = form.dataset.editMode === 'edit' && canEdit ? 'edit' : 'view';
|
||||
form.dataset.editMode = mode;
|
||||
form.dataset.pageEditorBound = '1';
|
||||
|
||||
const setToggleLabel = (nextMode) => {
|
||||
if (!(toggleButton instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const viewLabel = toggleButton.dataset.viewLabel || '';
|
||||
const editLabel = toggleButton.dataset.editLabel || '';
|
||||
const text = nextMode === 'edit' ? viewLabel : editLabel;
|
||||
const icon = toggleButton.querySelector('[data-editor-icon]');
|
||||
|
||||
if (icon instanceof HTMLElement) {
|
||||
icon.className = nextMode === 'edit' ? 'bi bi-eye-fill' : 'bi bi-pencil-square';
|
||||
}
|
||||
toggleButton.setAttribute('aria-label', text);
|
||||
toggleButton.dataset.tooltip = text;
|
||||
};
|
||||
|
||||
setToggleLabel(mode);
|
||||
if (saveButton instanceof HTMLButtonElement) {
|
||||
saveButton.disabled = mode !== 'edit';
|
||||
}
|
||||
|
||||
const toolConfig = resolveTools(form);
|
||||
const editor = new EditorJS({
|
||||
holder,
|
||||
data: parseData(contentField.value),
|
||||
readOnly: mode !== 'edit',
|
||||
autofocus: mode === 'edit',
|
||||
placeholder: form.dataset.placeholder || undefined,
|
||||
tools: toolConfig.tools,
|
||||
inlineToolbar: toolConfig.inlineTools,
|
||||
});
|
||||
|
||||
const toggleMode = async (nextMode) => {
|
||||
if (!canEdit || mode === nextMode) {
|
||||
return;
|
||||
}
|
||||
mode = nextMode;
|
||||
form.dataset.editMode = mode;
|
||||
setToggleLabel(mode);
|
||||
if (editor.readOnly && typeof editor.readOnly.toggle === 'function') {
|
||||
await editor.readOnly.toggle(mode !== 'edit');
|
||||
}
|
||||
if (saveButton instanceof HTMLButtonElement) {
|
||||
saveButton.disabled = mode !== 'edit';
|
||||
}
|
||||
};
|
||||
|
||||
const HeaderTool = window.Header || window.EditorJSHeader || null;
|
||||
const ListTool = window.EditorjsList || window.List || null;
|
||||
const ChecklistTool = window.Checklist || window.EditorJSChecklist || null;
|
||||
const TableTool = window.Table || window.EditorJSTable || null;
|
||||
const ColumnsTool = window.editorjsColumns || window.Columns || null;
|
||||
const MarkerTool = window.Marker || null;
|
||||
const onToggleClick = () => {
|
||||
void toggleMode(mode === 'edit' ? 'view' : 'edit');
|
||||
};
|
||||
|
||||
const tools = {};
|
||||
if (HeaderTool) {
|
||||
tools.header = {
|
||||
class: HeaderTool,
|
||||
inlineToolbar: true,
|
||||
config: {
|
||||
placeholder: form.dataset.headerPlaceholder || 'Heading',
|
||||
levels: [1, 2, 3, 4],
|
||||
defaultLevel: 1,
|
||||
const onSubmit = async (event) => {
|
||||
if (form.dataset.submitting === '1') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const output = await editor.save();
|
||||
contentField.value = JSON.stringify(output);
|
||||
form.dataset.submitting = '1';
|
||||
|
||||
try {
|
||||
const formData = new FormData(form);
|
||||
const response = await fetch(form.action || window.location.pathname, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (ListTool) {
|
||||
tools.list = {
|
||||
class: ListTool,
|
||||
inlineToolbar: true,
|
||||
config: {
|
||||
defaultStyle: 'unordered',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (ChecklistTool) {
|
||||
tools.checklist = {
|
||||
class: ChecklistTool,
|
||||
inlineToolbar: true,
|
||||
};
|
||||
}
|
||||
if (TableTool) {
|
||||
tools.table = {
|
||||
class: TableTool,
|
||||
inlineToolbar: true,
|
||||
config: {
|
||||
rows: 2,
|
||||
cols: 2,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (ColumnsTool) {
|
||||
const innerTools = { ...tools };
|
||||
tools.columns = {
|
||||
class: ColumnsTool,
|
||||
config: {
|
||||
EditorJsLibrary: EditorJS,
|
||||
tools: innerTools,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (MarkerTool) {
|
||||
tools.marker = {
|
||||
class: MarkerTool,
|
||||
shortcut: 'CMD+SHIFT+M',
|
||||
};
|
||||
}
|
||||
|
||||
const inlineTools = ['bold', 'italic', 'link'];
|
||||
if (MarkerTool) {
|
||||
inlineTools.unshift('marker');
|
||||
}
|
||||
|
||||
const editor = new EditorJS({
|
||||
holder,
|
||||
data: parseData(contentField ? contentField.value : ''),
|
||||
readOnly: mode !== 'edit',
|
||||
autofocus: mode === 'edit',
|
||||
placeholder: form.dataset.placeholder || undefined,
|
||||
tools,
|
||||
inlineToolbar: inlineTools,
|
||||
});
|
||||
|
||||
const toggleMode = async (nextMode) => {
|
||||
if (!canEdit || mode === nextMode) {
|
||||
body: formData,
|
||||
});
|
||||
if (response.redirected) {
|
||||
window.location.href = response.url;
|
||||
return;
|
||||
}
|
||||
mode = nextMode;
|
||||
form.dataset.editMode = mode;
|
||||
setToggleLabel(mode);
|
||||
if (editor.readOnly && editor.readOnly.toggle) {
|
||||
await editor.readOnly.toggle(mode !== 'edit');
|
||||
const data = await response.json().catch(() => null);
|
||||
if (data && data.redirect) {
|
||||
window.location.href = data.redirect;
|
||||
return;
|
||||
}
|
||||
if (saveButton) {
|
||||
saveButton.disabled = mode !== 'edit';
|
||||
}
|
||||
};
|
||||
|
||||
if (toggleButton && canEdit) {
|
||||
toggleButton.addEventListener('click', () => {
|
||||
toggleMode(mode === 'edit' ? 'view' : 'edit');
|
||||
});
|
||||
window.location.reload();
|
||||
} catch {
|
||||
form.submit();
|
||||
}
|
||||
};
|
||||
|
||||
if (form && contentField && canEdit) {
|
||||
form.addEventListener('submit', async (event) => {
|
||||
if (form.dataset.submitting === '1') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const output = await editor.save();
|
||||
contentField.value = JSON.stringify(output);
|
||||
form.dataset.submitting = '1';
|
||||
|
||||
try {
|
||||
const formData = new FormData(form);
|
||||
const response = await fetch(form.action || window.location.pathname, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
if (response.redirected) {
|
||||
window.location.href = response.url;
|
||||
return;
|
||||
}
|
||||
const data = await response.json().catch(() => null);
|
||||
if (data && data.redirect) {
|
||||
window.location.href = data.redirect;
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (toggleButton instanceof HTMLElement && canEdit) {
|
||||
toggleButton.addEventListener('click', onToggleClick);
|
||||
}
|
||||
if (canEdit) {
|
||||
form.addEventListener('submit', onSubmit);
|
||||
}
|
||||
|
||||
let destroyed = false;
|
||||
const destroy = () => {
|
||||
if (destroyed) {
|
||||
return;
|
||||
}
|
||||
destroyed = true;
|
||||
if (toggleButton instanceof HTMLElement && canEdit) {
|
||||
toggleButton.removeEventListener('click', onToggleClick);
|
||||
}
|
||||
if (canEdit) {
|
||||
form.removeEventListener('submit', onSubmit);
|
||||
}
|
||||
if (typeof editor.destroy === 'function') {
|
||||
editor.destroy();
|
||||
}
|
||||
delete form.dataset.pageEditorBound;
|
||||
delete form._pageEditorApi;
|
||||
};
|
||||
|
||||
const api = { destroy };
|
||||
form._pageEditorApi = api;
|
||||
return api;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user