mcp-magento-cms 1.0.3 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +173 -82
- package/package.json +4 -3
- package/src/assignStores.js +150 -0
- package/src/index.js +840 -89
- package/src/magentoClient.js +95 -4
- package/src/pageBuilder.js +66 -68
- package/src/pageBuilderElements.js +789 -0
- package/src/pageBuilderRepair.js +266 -0
- package/src/pageBuilderRules.js +127 -0
- package/src/pageBuilderValidate.js +164 -0
- package/src/projectRules.js +95 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repair Magento Page Builder HTML so admin stage can open.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { validatePageBuilderContent, WIDTH_FOR_COUNT } from './pageBuilderValidate.js';
|
|
6
|
+
|
|
7
|
+
function pbStyleId(length = 7) {
|
|
8
|
+
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
9
|
+
let out = '';
|
|
10
|
+
for (let i = 0; i < length; i++) {
|
|
11
|
+
out += alphabet[Math.floor(Math.random() * alphabet.length)];
|
|
12
|
+
}
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function extractStyle(html) {
|
|
17
|
+
const m = html.match(/<style>([\s\S]*?)<\/style>/i);
|
|
18
|
+
return {
|
|
19
|
+
style: m ? m[1] : '',
|
|
20
|
+
body: html.replace(/<style>[\s\S]*?<\/style>/gi, ''),
|
|
21
|
+
hasStyle: Boolean(m),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function wrapWithStyle(style, body) {
|
|
26
|
+
return `<style>${style}</style>${body}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Remove CSS rules whose [data-pb-style=ID] is not present on any element.
|
|
31
|
+
* Orphans crash Magento stage-builder convertToInlineStyles (null setAttribute).
|
|
32
|
+
*/
|
|
33
|
+
function removeOrphanStyles(style, usedIds) {
|
|
34
|
+
// Split roughly on rule boundaries while keeping media queries intact enough for our use.
|
|
35
|
+
// Strategy: rewrite selectors that only reference orphan IDs out of comma-groups;
|
|
36
|
+
// drop rules that become empty.
|
|
37
|
+
return style.replace(
|
|
38
|
+
/#html-body[^{]*\{[^}]*\}/g,
|
|
39
|
+
(rule) => {
|
|
40
|
+
const head = rule.slice(0, rule.indexOf('{'));
|
|
41
|
+
const body = rule.slice(rule.indexOf('{'));
|
|
42
|
+
const selectors = head.split(',').map((s) => s.trim()).filter(Boolean);
|
|
43
|
+
const kept = selectors.filter((sel) => {
|
|
44
|
+
const id = (sel.match(/\[data-pb-style=([A-Z0-9]+)\]/i) || [])[1];
|
|
45
|
+
if (!id) return true;
|
|
46
|
+
return usedIds.has(id);
|
|
47
|
+
});
|
|
48
|
+
if (!kept.length) return '';
|
|
49
|
+
return `${kept.join(',')} ${body}`;
|
|
50
|
+
}
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Ensure each column style id has a width rule; group by column-line siblings.
|
|
56
|
+
*/
|
|
57
|
+
function ensureColumnWidths(style, body) {
|
|
58
|
+
const injections = [];
|
|
59
|
+
// Split body by column-group and count columns per group
|
|
60
|
+
const parts = body.split(/(?=<div[^>]*data-content-type="column-group")/i);
|
|
61
|
+
const colBase =
|
|
62
|
+
'justify-content:flex-start;display:flex;flex-direction:column;' +
|
|
63
|
+
'background-position:left top;background-size:cover;background-repeat:no-repeat;' +
|
|
64
|
+
'background-attachment:scroll;align-self:stretch';
|
|
65
|
+
|
|
66
|
+
for (const part of parts.slice(1)) {
|
|
67
|
+
const cols = [
|
|
68
|
+
...part.matchAll(
|
|
69
|
+
/data-content-type="column"[^>]*data-pb-style="([A-Z0-9]+)"/gi
|
|
70
|
+
),
|
|
71
|
+
].map((m) => m[1]);
|
|
72
|
+
if (!cols.length) continue;
|
|
73
|
+
const n = cols.length;
|
|
74
|
+
const width = WIDTH_FOR_COUNT[n] || `${(100 / n).toFixed(4)}%`;
|
|
75
|
+
|
|
76
|
+
const need = cols.filter((sid) => {
|
|
77
|
+
const rules = [
|
|
78
|
+
...style.matchAll(
|
|
79
|
+
new RegExp(
|
|
80
|
+
`#html-body[^{]*\\[data-pb-style=${sid}\\][^{]*\\{([^}]*)\\}`,
|
|
81
|
+
'gi'
|
|
82
|
+
)
|
|
83
|
+
),
|
|
84
|
+
].map((m) => m[1]);
|
|
85
|
+
return !rules.some((r) => /width\s*:/.test(r));
|
|
86
|
+
});
|
|
87
|
+
if (!need.length) continue;
|
|
88
|
+
const selectors = need.map((sid) => `#html-body [data-pb-style=${sid}]`).join(',');
|
|
89
|
+
injections.push(`${selectors}{${colBase};width:${width}}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return style + injections.join('');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Convert full-bleed rows → full-width and ensure inner wrapper exists.
|
|
97
|
+
*/
|
|
98
|
+
function fixRowAppearances(body, styleInjections) {
|
|
99
|
+
let out = body;
|
|
100
|
+
|
|
101
|
+
// full-bleed → full-width on rows
|
|
102
|
+
out = out.replace(
|
|
103
|
+
/(data-content-type="row"[^>]*data-appearance=")full-bleed(")/gi,
|
|
104
|
+
'$1full-width$2'
|
|
105
|
+
);
|
|
106
|
+
out = out.replace(
|
|
107
|
+
/(data-appearance=")full-bleed("[^>]*data-content-type="row")/gi,
|
|
108
|
+
'$1full-width$2'
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// For each full-width row missing inner, wrap children
|
|
112
|
+
const rowRe =
|
|
113
|
+
/<div\b([^>]*\bdata-content-type="row"[^>]*)>/gi;
|
|
114
|
+
const matches = [...out.matchAll(rowRe)];
|
|
115
|
+
// Process from end so indexes stay valid
|
|
116
|
+
for (let i = matches.length - 1; i >= 0; i--) {
|
|
117
|
+
const m = matches[i];
|
|
118
|
+
const attrs = m[1];
|
|
119
|
+
const appearance =
|
|
120
|
+
(attrs.match(/data-appearance="([^"]+)"/i) || [])[1] || '';
|
|
121
|
+
if (appearance !== 'full-width' && appearance !== 'contained') continue;
|
|
122
|
+
|
|
123
|
+
const openEnd = m.index + m[0].length;
|
|
124
|
+
const peek = out.slice(openEnd, openEnd + 220);
|
|
125
|
+
if (/data-element="inner"/.test(peek) || /row-full-width-inner/.test(peek)) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Find matching close for this row
|
|
130
|
+
let depth = 1;
|
|
131
|
+
let pos = openEnd;
|
|
132
|
+
let closeAt = -1;
|
|
133
|
+
while (pos < out.length && depth > 0) {
|
|
134
|
+
const nextOpen = out.indexOf('<div', pos);
|
|
135
|
+
const nextClose = out.indexOf('</div>', pos);
|
|
136
|
+
if (nextClose === -1) break;
|
|
137
|
+
if (nextOpen !== -1 && nextOpen < nextClose) {
|
|
138
|
+
depth += 1;
|
|
139
|
+
pos = nextOpen + 4;
|
|
140
|
+
} else {
|
|
141
|
+
depth -= 1;
|
|
142
|
+
if (depth === 0) {
|
|
143
|
+
closeAt = nextClose;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
pos = nextClose + 6;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (closeAt === -1) continue;
|
|
150
|
+
|
|
151
|
+
const innerId = pbStyleId();
|
|
152
|
+
styleInjections.push(
|
|
153
|
+
`#html-body [data-pb-style=${innerId}]{background-position:left top;background-size:cover;background-repeat:no-repeat;background-attachment:scroll}`
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
let innerOpen;
|
|
157
|
+
if (appearance === 'full-width') {
|
|
158
|
+
innerOpen =
|
|
159
|
+
`<div class="row-full-width-inner" data-enable-parallax="0" data-parallax-speed="0.5" ` +
|
|
160
|
+
`data-background-images="{}" data-background-type="image" data-video-loop="true" ` +
|
|
161
|
+
`data-video-play-only-visible="true" data-video-lazy-load="true" data-video-fallback-src="" ` +
|
|
162
|
+
`data-element="inner" data-pb-style="${innerId}">`;
|
|
163
|
+
} else {
|
|
164
|
+
innerOpen =
|
|
165
|
+
`<div data-enable-parallax="0" data-parallax-speed="0.5" data-background-images="{}" ` +
|
|
166
|
+
`data-background-type="image" data-video-loop="true" data-video-play-only-visible="true" ` +
|
|
167
|
+
`data-video-lazy-load="true" data-video-fallback-src="" data-element="inner" ` +
|
|
168
|
+
`data-pb-style="${innerId}">`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const inner = out.slice(openEnd, closeAt);
|
|
172
|
+
out =
|
|
173
|
+
out.slice(0, openEnd) +
|
|
174
|
+
innerOpen +
|
|
175
|
+
inner +
|
|
176
|
+
'</div>' +
|
|
177
|
+
out.slice(closeAt);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* @param {string} html
|
|
185
|
+
* @param {{ stripSvg?: boolean, fixFullBleed?: boolean }} [options]
|
|
186
|
+
*/
|
|
187
|
+
export function repairPageBuilderContent(html, options = {}) {
|
|
188
|
+
const opts = {
|
|
189
|
+
stripSvg: options.stripSvg !== false,
|
|
190
|
+
fixFullBleed: options.fixFullBleed !== false,
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
let content = String(html || '');
|
|
194
|
+
const actions = [];
|
|
195
|
+
const before = validatePageBuilderContent(content);
|
|
196
|
+
|
|
197
|
+
let { style, body } = extractStyle(content);
|
|
198
|
+
const styleInjections = [];
|
|
199
|
+
|
|
200
|
+
if (opts.stripSvg) {
|
|
201
|
+
const next = body.replace(/<svg\b[^>]*>[\s\S]*?<\/svg>/gi, '');
|
|
202
|
+
if (next !== body) {
|
|
203
|
+
actions.push('Removed inline <svg> from content');
|
|
204
|
+
body = next;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Escape bare & in title/alt attributes
|
|
209
|
+
const ampFixed = body.replace(
|
|
210
|
+
/(title|alt)="([^"]*)&(?!amp;|lt;|gt;|quot;|apos;|#)([^"]*)"/gi,
|
|
211
|
+
(_, attr, a, b) => `${attr}="${a}&${b}"`
|
|
212
|
+
);
|
|
213
|
+
if (ampFixed !== body) {
|
|
214
|
+
actions.push('Escaped bare & in title/alt attributes');
|
|
215
|
+
body = ampFixed;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (opts.fixFullBleed) {
|
|
219
|
+
const beforeBleed = body;
|
|
220
|
+
body = fixRowAppearances(body, styleInjections);
|
|
221
|
+
if (body !== beforeBleed || styleInjections.length) {
|
|
222
|
+
actions.push(
|
|
223
|
+
'Normalized rows to full-width/contained with required inner wrappers (no full-bleed)'
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Rebuild used ids after structural changes
|
|
229
|
+
const usedIds = new Set(
|
|
230
|
+
[...`${style}${body}`.matchAll(/data-pb-style="([A-Z0-9]+)"/gi)].map(
|
|
231
|
+
(m) => m[1]
|
|
232
|
+
)
|
|
233
|
+
);
|
|
234
|
+
// Also ids only in style that might still be referenced after edits — recompute from body only for orphans
|
|
235
|
+
const usedInBody = new Set(
|
|
236
|
+
[...body.matchAll(/data-pb-style="([A-Z0-9]+)"/gi)].map((m) => m[1])
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
const styleBeforeOrphans = style;
|
|
240
|
+
style = removeOrphanStyles(style, usedInBody);
|
|
241
|
+
if (style !== styleBeforeOrphans) {
|
|
242
|
+
actions.push('Removed orphan [data-pb-style] rules that crash admin stage');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const styleBeforeCols = style;
|
|
246
|
+
style = ensureColumnWidths(style, body);
|
|
247
|
+
if (style !== styleBeforeCols) {
|
|
248
|
+
actions.push('Added missing column width:% rules');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (styleInjections.length) {
|
|
252
|
+
style += styleInjections.join('');
|
|
253
|
+
actions.push('Added style rules for new row inner wrappers');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
content = wrapWithStyle(style, body);
|
|
257
|
+
const after = validatePageBuilderContent(content);
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
content,
|
|
261
|
+
actions,
|
|
262
|
+
before,
|
|
263
|
+
after,
|
|
264
|
+
changed: content !== html,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Magento Page Builder + CMS/theme hard rules (shared across all Adobe Commerce projects).
|
|
3
|
+
* Prefer native content types; keep HTML stage-safe for Admin Page Builder.
|
|
4
|
+
* Store-specific hex values, class prefixes, and folder catalogs belong in
|
|
5
|
+
* MAGENTO_CMS_PROJECT_RULES — not here.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const PAGEBUILDER_RULES = {
|
|
9
|
+
/**
|
|
10
|
+
* Row appearance:
|
|
11
|
+
* - Prefer "full-width" for content sections (bands, cards, heroes with gutters).
|
|
12
|
+
* - "contained" is OK for narrow text columns.
|
|
13
|
+
* - NEVER use "full-bleed" unless the user explicitly asks (edge-to-edge hero).
|
|
14
|
+
*
|
|
15
|
+
* full-width REQUIRES an inner wrapper:
|
|
16
|
+
* <div class="row-full-width-inner" data-element="inner" ...>
|
|
17
|
+
* contained REQUIRES:
|
|
18
|
+
* <div data-element="inner" ...>
|
|
19
|
+
* full-bleed has NO inner wrapper (children hang directly under row).
|
|
20
|
+
* Converting full-bleed → full-width without adding inner breaks Page Builder stage.
|
|
21
|
+
*/
|
|
22
|
+
rowAppearance: {
|
|
23
|
+
preferred: 'full-width',
|
|
24
|
+
allowed: ['full-width', 'contained'],
|
|
25
|
+
forbiddenUnlessExplicit: ['full-bleed'],
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Magento stage-builder.js convertToInlineStyles does:
|
|
30
|
+
* element = document.querySelector(selector); element.setAttribute(...)
|
|
31
|
+
* If a <style> rule references [data-pb-style=ID] with no matching element,
|
|
32
|
+
* querySelector returns null → TypeError → "Page Builder Error" / stage won't open.
|
|
33
|
+
*/
|
|
34
|
+
orphanStylesCrashStage: true,
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Columns need width:% in the style block (25/33.3333/50/etc by column-line count).
|
|
38
|
+
*/
|
|
39
|
+
columnsNeedWidths: true,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Stage-safe Page Builder rules (all projects). */
|
|
43
|
+
export const PAGEBUILDER_RULES_SUMMARY = [
|
|
44
|
+
'CMS workflow (mandatory): use magento-cms MCP tools ONLY for creating/updating CMS pages and blocks (compose_pagebuilder, create_cms_page/block, update_cms_page/block, set_pagebuilder_html, repair_pagebuilder). Do NOT use project PHP compose/deploy scripts, bin/magento cms:*, or direct DB writes for CMS content.',
|
|
45
|
+
'Before any CMS create/update: call pagebuilder_rules, then list_section_types when composing. Prefer sections[] → compose_pagebuilder / create_cms_page; use set_pagebuilder_html only for intentional raw master HTML. Always validate (or repair_first) before save; flush/verify storefront after save.',
|
|
46
|
+
'Store views: create_cms_page/block defaults to All Store Views (store_id 0). Pass store_ids only when the user explicitly names particular store view(s).',
|
|
47
|
+
'Page layout: create_cms_page defaults to cms-full-width (Page — Full Width). Only pass page_layout when the user asks for a different layout.',
|
|
48
|
+
'When MCP is missing a capability or fails incorrectly: fix/improve the MCP package (mcp-magento-cms) — do not work around it with PHP scripts. Document the fix for the whole team.',
|
|
49
|
+
'Rows: use Full Width (or Contained). NEVER Full Bleed unless user explicitly asks.',
|
|
50
|
+
'When full-bleed IS intentional (edge-to-edge hero): pass allow_full_bleed=true and repair_first with fix_full_bleed=false (or repair_first=false).',
|
|
51
|
+
'full-width rows MUST include .row-full-width-inner[data-element=inner].',
|
|
52
|
+
'contained rows MUST include [data-element=inner].',
|
|
53
|
+
'Do not convert full-bleed→full-width without adding the inner wrapper.',
|
|
54
|
+
'Every [data-pb-style=ID] in <style> must match an element (orphans crash admin stage).',
|
|
55
|
+
'Every column needs a width:% rule in the style block.',
|
|
56
|
+
'Avoid raw <svg> inside Text elements (often crashes stage); use Image content type or theme CSS for icons.',
|
|
57
|
+
'Banner CTAs: use Banner Show Button=Always + Link URL (native .pagebuilder-banner-button) — editable in Admin Banner form.',
|
|
58
|
+
'Prefer native content types (Banner, Heading, Text, Image, Buttons, Columns, HTML) so merchants can edit without raw HTML.',
|
|
59
|
+
'Storefront links: prefer {{store url=path}} with an unquoted path; do not HTML-encode quotes as " in href attributes.',
|
|
60
|
+
'Theme hooks: put project CSS on rows/banners via css_classes / data-css-classes — the theme owns class names; this MCP stays project-agnostic.',
|
|
61
|
+
'Landing composition: typically hero row (Banner) + body row(s) (Heading/Text/Columns/Image/Buttons); CMS body = page-specific content only (layout owns global chrome).',
|
|
62
|
+
'Card grids: Column → Image + Text (+ optional Buttons). Equal widths in the style block.',
|
|
63
|
+
'Compose complex pages with compose_pagebuilder({ sections }) covering all native Magento_PageBuilder types — not only Row→Text.',
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Cross-project CMS media + theme conventions (all Magento stores using this MCP).
|
|
68
|
+
* Brand hex / Vendor/theme names stay in MAGENTO_CMS_PROJECT_RULES.
|
|
69
|
+
*/
|
|
70
|
+
export const CMS_THEME_CONVENTIONS_SUMMARY = [
|
|
71
|
+
'Media: one flat directory per CMS page under pub/media/wysiwyg/ — do NOT create cards/, icons/, products/, related/, or similar subfolders.',
|
|
72
|
+
'Media folder name MUST match the CMS page identifier (URL key), e.g. pub/media/wysiwyg/<section>/<page-url-key>/file.jpg (example: markets/market-industrial/hero.jpg).',
|
|
73
|
+
'Put all assets for that page (hero, cards, icons, products, related) directly in that single page folder.',
|
|
74
|
+
'When renaming a media folder, update all {{media url=…}} / path references in cms_page and cms_block content, then flush Magento cache.',
|
|
75
|
+
'Similar marketing/CMS pages MUST share the same CSS classes and one Magento_Cms LESS partial — do not duplicate a nearly identical LESS file per page in theme web/css/source/_sources.less.',
|
|
76
|
+
'Page-specific CMS LESS lives in the theme Magento_Cms/web/css/source/ override and is imported from Magento_Cms _module.less only (not theme _sources for page-only styles).',
|
|
77
|
+
'Define brand primary/secondary (and related tokens) once in the theme _theme.less (@theme__color__primary / @theme__color__secondary). Prefer those variables over hardcoded hex in page/module LESS.',
|
|
78
|
+
'Do not re-alias theme variables in module LESS (e.g. @local-primary: @theme__color__primary) — use the theme variable directly everywhere.',
|
|
79
|
+
'Do not redeclare font-family on page wrappers when body already uses the theme base font (@font-family-name__base / @font-family__base). Set font-family only when a different font is required.',
|
|
80
|
+
'Scope page styles with a stable hook (layout body class and/or content marker like :has(.page-hero)) so renaming the CMS URL key does not break styles when possible.',
|
|
81
|
+
'Avoid CSS !important in theme/CMS LESS. Prefer higher specificity: #html-body.<page-body-class> …, element+class (p.eyebrow, h2.page-h2), and deeper nesting under .column.main. Use !important only when a true inline style or third-party rule cannot be beaten otherwise — and leave a short comment why.',
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
/** Combined list returned by pagebuilder_rules (before optional project rules). */
|
|
85
|
+
export const SHARED_RULES_SUMMARY = [
|
|
86
|
+
...PAGEBUILDER_RULES_SUMMARY,
|
|
87
|
+
...CMS_THEME_CONVENTIONS_SUMMARY,
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
export const SHARED_RULES_DETAIL = {
|
|
91
|
+
cms_workflow:
|
|
92
|
+
'Mandatory: magento-cms MCP only for CMS page/block content. Tools: pagebuilder_rules → compose_pagebuilder / create_* / set_pagebuilder_html / validate / repair. Forbidden: project PHP compose/deploy, CLI cms hacks, DB SQL for content. Improve the MCP when gaps appear — do not bypass it.',
|
|
93
|
+
row: 'Use Full Width or Contained. Never Full Bleed unless user explicitly asks.',
|
|
94
|
+
allow_full_bleed:
|
|
95
|
+
'When user asks for edge-to-edge hero: set_pagebuilder_html({ allow_full_bleed: true }) so repair keeps bleed and validate does not fail.',
|
|
96
|
+
full_width_inner:
|
|
97
|
+
'full-width rows MUST include <div class="row-full-width-inner" data-element="inner">…</div>',
|
|
98
|
+
contained_inner:
|
|
99
|
+
'contained rows MUST include <div data-element="inner">…</div>',
|
|
100
|
+
orphan_styles:
|
|
101
|
+
'Every [data-pb-style=ID] in <style> must exist on an element or admin stage crashes',
|
|
102
|
+
columns: 'Every column needs width:% in the style block',
|
|
103
|
+
banner_button:
|
|
104
|
+
'Hero CTAs: Banner appearance=poster, data-show-button=always, Link URL on banner, button text in .pagebuilder-banner-button — merchants edit via Banner form (Show Button / Button Text / Link).',
|
|
105
|
+
store_urls:
|
|
106
|
+
'Prefer {{store url=path}} with an unquoted path in href/Link URL. Avoid HTML-encoded quotes (") which break storefront links after cache.',
|
|
107
|
+
page_layout:
|
|
108
|
+
'create_cms_page defaults to cms-full-width (Page — Full Width). Only pass page_layout when the user asks for a different layout.',
|
|
109
|
+
theme_css:
|
|
110
|
+
'Attach project-specific styles via css_classes / data-css-classes on rows and banners. Theme LESS owns the class names; do not bake a store\'s prefixes into this MCP.',
|
|
111
|
+
composition:
|
|
112
|
+
'Typical marketing landing: hero row (Banner poster) + body row(s) with native Heading/Text/Columns/Image/Buttons. Keep global header/footer/widgets in layout XML, not duplicated in CMS HTML.',
|
|
113
|
+
cards:
|
|
114
|
+
'Product/feature cards: Column → Image + Text (+ optional Buttons). Prefer Image over SVG-in-Text for icons.',
|
|
115
|
+
media_layout:
|
|
116
|
+
'One flat folder per CMS page: pub/media/wysiwyg/<optional-section>/<cms-page-identifier>/ — folder name = page URL key; no nested cards/icons/products/related.',
|
|
117
|
+
less_location:
|
|
118
|
+
'Page-only styles → theme Magento_Cms/web/css/source/ + _module.less. Shared chrome may stay in theme web/css/source/_sources.less. Sibling pages share one LESS partial + shared classes.',
|
|
119
|
+
brand_tokens:
|
|
120
|
+
'Define @theme__color__primary / @theme__color__secondary (and related tokens) once in _theme.less. Use those variables directly in module LESS — no re-aliases, prefer vars over hex.',
|
|
121
|
+
fonts:
|
|
122
|
+
'If body already sets the theme base font, do not repeat font-family on page wrappers unless overriding to a different font.',
|
|
123
|
+
page_scope:
|
|
124
|
+
'Prefer layout body class and/or content :has() markers for page CSS scope so URL-key renames are safer.',
|
|
125
|
+
css_specificity:
|
|
126
|
+
'Avoid !important in theme/CMS LESS. Beat Magento/PB with #html-body.<page-class>, element.class, and .column.main nesting. Reserve !important for true inline/third-party cases only (comment why).',
|
|
127
|
+
};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate Magento Page Builder master-format HTML for admin stage safety.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { PAGEBUILDER_RULES, PAGEBUILDER_RULES_SUMMARY } from './pageBuilderRules.js';
|
|
6
|
+
|
|
7
|
+
const WIDTH_FOR_COUNT = {
|
|
8
|
+
1: '100%',
|
|
9
|
+
2: '50%',
|
|
10
|
+
3: '33.3333%',
|
|
11
|
+
4: '25%',
|
|
12
|
+
5: '20%',
|
|
13
|
+
6: '16.6667%',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {string} html
|
|
18
|
+
* @param {{ allowFullBleed?: boolean }} [options]
|
|
19
|
+
* @returns {{
|
|
20
|
+
* ok: boolean,
|
|
21
|
+
* errors: string[],
|
|
22
|
+
* warnings: string[],
|
|
23
|
+
* stats: Record<string, number|string>,
|
|
24
|
+
* orphans: string[],
|
|
25
|
+
* missingStyles: string[],
|
|
26
|
+
* columnsMissingWidth: string[],
|
|
27
|
+
* rows: Array<{appearance:string, hasInner:boolean, className:string}>,
|
|
28
|
+
* rules: string[],
|
|
29
|
+
* }}
|
|
30
|
+
*/
|
|
31
|
+
export function validatePageBuilderContent(html, options = {}) {
|
|
32
|
+
const errors = [];
|
|
33
|
+
const warnings = [];
|
|
34
|
+
const content = String(html || '');
|
|
35
|
+
const allowFullBleed = options.allowFullBleed === true;
|
|
36
|
+
|
|
37
|
+
if (!content.includes('data-content-type="row"')) {
|
|
38
|
+
errors.push('No Page Builder row found (data-content-type="row").');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const styleMatch = content.match(/<style>([\s\S]*?)<\/style>/i);
|
|
42
|
+
const style = styleMatch ? styleMatch[1] : '';
|
|
43
|
+
const body = content.replace(/<style>[\s\S]*?<\/style>/gi, '');
|
|
44
|
+
|
|
45
|
+
const idsDefined = new Set(
|
|
46
|
+
[...style.matchAll(/\[data-pb-style=([A-Z0-9]+)\]/gi)].map((m) => m[1])
|
|
47
|
+
);
|
|
48
|
+
const idsUsed = new Set(
|
|
49
|
+
[...content.matchAll(/data-pb-style="([A-Z0-9]+)"/gi)].map((m) => m[1])
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const orphans = [...idsDefined].filter((id) => !idsUsed.has(id)).sort();
|
|
53
|
+
const missingStyles = [...idsUsed].filter((id) => !idsDefined.has(id)).sort();
|
|
54
|
+
|
|
55
|
+
if (orphans.length) {
|
|
56
|
+
errors.push(
|
|
57
|
+
`Orphan style IDs crash Page Builder stage (querySelector null): ${orphans.join(', ')}`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
if (missingStyles.length) {
|
|
61
|
+
warnings.push(`Elements reference missing style IDs: ${missingStyles.join(', ')}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const fullBleed = [
|
|
65
|
+
...body.matchAll(
|
|
66
|
+
/data-content-type="row"[^>]*data-appearance="full-bleed"|data-appearance="full-bleed"[^>]*data-content-type="row"/gi
|
|
67
|
+
),
|
|
68
|
+
];
|
|
69
|
+
if (fullBleed.length) {
|
|
70
|
+
const msg = `Found ${fullBleed.length} full-bleed row(s). Prefer full-width (with inner) or contained — full-bleed only when explicitly requested (edge-to-edge hero).`;
|
|
71
|
+
if (allowFullBleed) {
|
|
72
|
+
warnings.push(msg + ' (allowed via allow_full_bleed)');
|
|
73
|
+
} else {
|
|
74
|
+
errors.push(
|
|
75
|
+
msg + ' Pass allow_full_bleed=true on validate/set_pagebuilder_html when intentional.'
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const rows = [];
|
|
81
|
+
const rowTagRe =
|
|
82
|
+
/<div\b([^>]*\bdata-content-type="row"[^>]*)>/gi;
|
|
83
|
+
let rm;
|
|
84
|
+
while ((rm = rowTagRe.exec(body))) {
|
|
85
|
+
const attrs = rm[1];
|
|
86
|
+
const appearance =
|
|
87
|
+
(attrs.match(/data-appearance="([^"]+)"/i) || [])[1] || 'unknown';
|
|
88
|
+
const className = (attrs.match(/\bclass="([^"]*)"/i) || [])[1] || '';
|
|
89
|
+
const peek = body.slice(rm.index + rm[0].length, rm.index + rm[0].length + 280);
|
|
90
|
+
const hasInner =
|
|
91
|
+
/data-element="inner"/.test(peek) || /row-full-width-inner/.test(peek);
|
|
92
|
+
rows.push({ appearance, hasInner, className });
|
|
93
|
+
|
|
94
|
+
if (appearance === 'full-width' && !hasInner) {
|
|
95
|
+
errors.push(
|
|
96
|
+
`full-width row missing .row-full-width-inner[data-element=inner] (class=${className || '—'}). Stage cannot open.`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
if (appearance === 'contained' && !hasInner) {
|
|
100
|
+
errors.push(
|
|
101
|
+
`contained row missing [data-element=inner] (class=${className || '—'}).`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const columnIds = [
|
|
107
|
+
...body.matchAll(/data-content-type="column"[^>]*data-pb-style="([A-Z0-9]+)"/gi),
|
|
108
|
+
].map((m) => m[1]);
|
|
109
|
+
|
|
110
|
+
const columnsMissingWidth = [];
|
|
111
|
+
for (const sid of columnIds) {
|
|
112
|
+
const rules = [
|
|
113
|
+
...style.matchAll(
|
|
114
|
+
new RegExp(
|
|
115
|
+
`#html-body[^{]*\\[data-pb-style=${sid}\\][^{]*\\{([^}]*)\\}`,
|
|
116
|
+
'gi'
|
|
117
|
+
)
|
|
118
|
+
),
|
|
119
|
+
].map((m) => m[1]);
|
|
120
|
+
if (!rules.some((r) => /width\s*:/.test(r))) {
|
|
121
|
+
columnsMissingWidth.push(sid);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (columnsMissingWidth.length) {
|
|
125
|
+
errors.push(
|
|
126
|
+
`${columnsMissingWidth.length} column(s) missing width:% in <style> (e.g. ${columnsMissingWidth.slice(0, 5).join(', ')}).`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const svgCount = (content.match(/<svg\b/gi) || []).length;
|
|
131
|
+
if (svgCount) {
|
|
132
|
+
warnings.push(
|
|
133
|
+
`${svgCount} <svg> tag(s) in content — often crash Text content-type in admin stage.`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const ctypeCounts = {};
|
|
138
|
+
for (const m of content.matchAll(/data-content-type="([^"]+)"/g)) {
|
|
139
|
+
ctypeCounts[m[1]] = (ctypeCounts[m[1]] || 0) + 1;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
ok: errors.length === 0,
|
|
144
|
+
errors,
|
|
145
|
+
warnings,
|
|
146
|
+
stats: {
|
|
147
|
+
length: content.length,
|
|
148
|
+
rows: rows.length,
|
|
149
|
+
columns: columnIds.length,
|
|
150
|
+
styleIdsDefined: idsDefined.size,
|
|
151
|
+
styleIdsUsed: idsUsed.size,
|
|
152
|
+
svg: svgCount,
|
|
153
|
+
contentTypes: ctypeCounts,
|
|
154
|
+
},
|
|
155
|
+
orphans,
|
|
156
|
+
missingStyles,
|
|
157
|
+
columnsMissingWidth,
|
|
158
|
+
rows,
|
|
159
|
+
rules: PAGEBUILDER_RULES_SUMMARY,
|
|
160
|
+
ruleConfig: PAGEBUILDER_RULES.rowAppearance,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export { WIDTH_FOR_COUNT };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional per-store conventions for each Magento codebase.
|
|
3
|
+
* Keeps this MCP package project-agnostic.
|
|
4
|
+
*
|
|
5
|
+
* Resolution order:
|
|
6
|
+
* 1. MAGENTO_CMS_PROJECT_RULES (explicit path) — optional override
|
|
7
|
+
* 2. ${MAGENTO_ROOT}/.cursor/magento-cms-project-rules.json
|
|
8
|
+
* 3. ${MAGENTO_ROOT}/magento-cms-project-rules.json
|
|
9
|
+
*
|
|
10
|
+
* Prefer (2): commit `.cursor/magento-cms-project-rules.json` in the Magento
|
|
11
|
+
* project and set only MAGENTO_ROOT in that project's `.cursor/mcp.json`.
|
|
12
|
+
*
|
|
13
|
+
* File formats:
|
|
14
|
+
* - .json → { "rules": string[], "detail"?: object }
|
|
15
|
+
* - .md / .txt → one rule per non-empty, non-# line
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
|
|
21
|
+
function candidatePaths() {
|
|
22
|
+
const out = [];
|
|
23
|
+
const configured = String(process.env.MAGENTO_CMS_PROJECT_RULES || '').trim();
|
|
24
|
+
if (configured) out.push(path.resolve(configured));
|
|
25
|
+
|
|
26
|
+
const root = String(process.env.MAGENTO_ROOT || '').trim();
|
|
27
|
+
if (root) {
|
|
28
|
+
const r = path.resolve(root);
|
|
29
|
+
out.push(path.join(r, '.cursor', 'magento-cms-project-rules.json'));
|
|
30
|
+
out.push(path.join(r, 'magento-cms-project-rules.json'));
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function loadFromFile(filePath) {
|
|
36
|
+
if (!fs.existsSync(filePath)) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
41
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
42
|
+
|
|
43
|
+
if (ext === '.json') {
|
|
44
|
+
try {
|
|
45
|
+
const data = JSON.parse(raw);
|
|
46
|
+
const rules = Array.isArray(data.rules)
|
|
47
|
+
? data.rules.map(String).filter(Boolean)
|
|
48
|
+
: [];
|
|
49
|
+
const detail =
|
|
50
|
+
data.detail && typeof data.detail === 'object' && !Array.isArray(data.detail)
|
|
51
|
+
? Object.fromEntries(
|
|
52
|
+
Object.entries(data.detail).map(([k, v]) => [k, String(v)]),
|
|
53
|
+
)
|
|
54
|
+
: {};
|
|
55
|
+
return { rules, detail, source: filePath };
|
|
56
|
+
} catch (e) {
|
|
57
|
+
return {
|
|
58
|
+
rules: [`Invalid project rules JSON (${filePath}): ${e.message}`],
|
|
59
|
+
detail: {},
|
|
60
|
+
source: filePath,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const rules = raw
|
|
66
|
+
.split(/\r?\n/)
|
|
67
|
+
.map((line) => line.trim())
|
|
68
|
+
.filter((line) => line && !line.startsWith('#') && !line.startsWith('//'));
|
|
69
|
+
|
|
70
|
+
return { rules, detail: {}, source: filePath };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @returns {{ rules: string[], detail: Record<string, string>, source: string|null }}
|
|
75
|
+
*/
|
|
76
|
+
export function loadProjectRules() {
|
|
77
|
+
const candidates = candidatePaths();
|
|
78
|
+
for (const filePath of candidates) {
|
|
79
|
+
const loaded = loadFromFile(filePath);
|
|
80
|
+
if (loaded) return loaded;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Explicit env pointed at a missing file → warn once
|
|
84
|
+
const configured = String(process.env.MAGENTO_CMS_PROJECT_RULES || '').trim();
|
|
85
|
+
if (configured) {
|
|
86
|
+
const filePath = path.resolve(configured);
|
|
87
|
+
return {
|
|
88
|
+
rules: [`MAGENTO_CMS_PROJECT_RULES points to missing file: ${filePath}`],
|
|
89
|
+
detail: {},
|
|
90
|
+
source: filePath,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { rules: [], detail: {}, source: null };
|
|
95
|
+
}
|