sitezen-mcp 1.0.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 +107 -0
- package/dist/conversion-log.js +67 -0
- package/dist/conversion-rules.md +1361 -0
- package/dist/errors.js +37 -0
- package/dist/figma.js +1369 -0
- package/dist/index.js +37 -0
- package/dist/license.js +121 -0
- package/dist/normalize.js +692 -0
- package/dist/state.js +81 -0
- package/dist/tools-session.js +131 -0
- package/dist/tools.js +1378 -0
- package/dist/validate.js +114 -0
- package/dist/wp-client.js +130 -0
- package/package.json +35 -0
package/dist/tools.js
ADDED
|
@@ -0,0 +1,1378 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { wpRequest, getConfig, ok, err } from "./wp-client.js";
|
|
6
|
+
import { validateHtml } from "./validate.js";
|
|
7
|
+
import { normalizeHtml, enforceSectionBackground, enforceFigmaTextStyles, enforceNoHorizontalOverflow, extractPendingAssets, detectBakedBackgroundViolations, detectDuplicateContent, detectNavInHeroViolation, optimiseImagesForPageSpeed, minifyInlineCss, findMissingTexts, autoConvertCardGrids, } from "./normalize.js";
|
|
8
|
+
import { parseFigmaUrl, fetchFigmaFile, fetchRenderedImages, extractAllTextNodes, extractAllColors, extractAllFonts, extractAllImageNodes, extractAllVectorNodes, extractOverlapHints, extractAllGradients, extractAllEffects, extractAllCornerRadii, extractAllStrokes, extractAllOpacities, extractAllResponsiveHints, fetchFigmaSubtreeAsFile, extractAllLayoutValues, extractAllImageScaleHints, listRenderableSections, } from "./figma.js";
|
|
9
|
+
/* Shared shape for the Figma-derived data Claude passes back to push tools.
|
|
10
|
+
* Identical to what the platform's extractDesignData() produced — the model
|
|
11
|
+
* extracts these from its figma MCP fetches and hands them to the MCP so
|
|
12
|
+
* the post-processors can enforce REAL Figma values on the emitted HTML. */
|
|
13
|
+
const FIGMA_DATA_SCHEMA = z.object({
|
|
14
|
+
section_bg: z.string().optional().describe("Hex color of the section's background fill from Figma (e.g. '#0E426C'). " +
|
|
15
|
+
"Applied to the outermost <section> as inline background so it can't be lost."),
|
|
16
|
+
text_nodes: z.array(z.object({
|
|
17
|
+
text: z.string(),
|
|
18
|
+
fontSize: z.number().optional(),
|
|
19
|
+
fontWeight: z.number().optional(),
|
|
20
|
+
color: z.string().optional(),
|
|
21
|
+
fontFamily: z.string().optional(),
|
|
22
|
+
})).optional().describe("Array of text nodes extracted from Figma. Each entry: the verbatim text + " +
|
|
23
|
+
"its exact fontSize (px), fontWeight (100-900), color (hex), fontFamily. The " +
|
|
24
|
+
"MCP matches these to <h*>/<p>/<span> tags in your HTML by text content and " +
|
|
25
|
+
"forces the Figma values as !important inline styles. Use this for every text " +
|
|
26
|
+
"node in the design so the rendered page matches Figma typography exactly."),
|
|
27
|
+
image_asset_urls: z.array(z.string()).optional().describe("URLs of the LEGITIMATE content images returned by prepare_section.image_assets. " +
|
|
28
|
+
"These can be used in BOTH <img src> AND background-image without triggering the " +
|
|
29
|
+
"baked-bg validator. Pass the .url values from prepare_section.image_assets here " +
|
|
30
|
+
"so the validator knows which Figma URLs are real photos vs which are the " +
|
|
31
|
+
"section render (which is always blocked as bg)."),
|
|
32
|
+
section_render_url: z.string().optional().describe("The section_render_url returned by prepare_section. NEVER use this as a " +
|
|
33
|
+
"background-image or <img src> — it is a high-res reference render of the " +
|
|
34
|
+
"whole section (text, nav, buttons, photos baked in). Pass it here so the " +
|
|
35
|
+
"validator can hard-block it specifically even if a similar URL appears " +
|
|
36
|
+
"legitimately in image_asset_urls."),
|
|
37
|
+
}).optional().describe("Optional Figma-derived design data. When provided, the MCP runs enforce* " +
|
|
38
|
+
"post-processors that apply EXACT Figma values (bg color, font size, weight, " +
|
|
39
|
+
"color, family) onto Claude's HTML — overriding any drift. Highly recommended " +
|
|
40
|
+
"for production-quality output.");
|
|
41
|
+
/**
|
|
42
|
+
* Apply all 5 post-processors in the same order the platform used. Pure
|
|
43
|
+
* function: takes HTML + optional Figma data, returns processed HTML + a
|
|
44
|
+
* list of any Figma text nodes that didn't survive Claude's output.
|
|
45
|
+
*/
|
|
46
|
+
function postProcess(html, figmaData) {
|
|
47
|
+
let out = normalizeHtml(html);
|
|
48
|
+
out = autoConvertCardGrids(out);
|
|
49
|
+
if (figmaData?.section_bg) {
|
|
50
|
+
out = enforceSectionBackground(out, figmaData.section_bg);
|
|
51
|
+
}
|
|
52
|
+
if (figmaData?.text_nodes && figmaData.text_nodes.length > 0) {
|
|
53
|
+
out = enforceFigmaTextStyles(out, figmaData.text_nodes);
|
|
54
|
+
}
|
|
55
|
+
out = enforceNoHorizontalOverflow(out);
|
|
56
|
+
// PageSpeed: lazy-load offscreen images, explicit dimensions to prevent
|
|
57
|
+
// layout shift, fetchpriority+eager on the LCP image. Pure HTML
|
|
58
|
+
// transform, zero runtime cost, 5-15 Lighthouse points on image pages.
|
|
59
|
+
out = optimiseImagesForPageSpeed(out);
|
|
60
|
+
// PageSpeed: shrink inline CSS bytes (strip comments + whitespace).
|
|
61
|
+
out = minifyInlineCss(out);
|
|
62
|
+
const missing_texts = figmaData?.text_nodes
|
|
63
|
+
? findMissingTexts(out, figmaData.text_nodes)
|
|
64
|
+
: [];
|
|
65
|
+
return { html: out, missing_texts };
|
|
66
|
+
}
|
|
67
|
+
// Resolve the bundled conversion-rules.md relative to this file's location
|
|
68
|
+
// (works from both src/ during tsx dev and dist/ at runtime, because we copy
|
|
69
|
+
// the .md into dist/ via the build script).
|
|
70
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
71
|
+
const __dirname = dirname(__filename);
|
|
72
|
+
const CONVERSION_RULES_PATH = join(__dirname, "conversion-rules.md");
|
|
73
|
+
/* ─── Tool 1: check_config ─────────────────────────────────────────────────
|
|
74
|
+
* Verifies the MCP server can talk to the configured WP site. Always the
|
|
75
|
+
* first tool to call in any new session — surfaces auth/network problems
|
|
76
|
+
* immediately. */
|
|
77
|
+
function registerCheckConfig(server) {
|
|
78
|
+
server.tool("check_config", "Verify SiteZen MCP can reach the configured WordPress site and report which env credentials are present (Figma token, license key, site URL, connection key). Returns the site URL, plugin settings, and a credentials checklist on success. Call this first if anything seems wrong.", {}, async () => {
|
|
79
|
+
try {
|
|
80
|
+
const config = getConfig();
|
|
81
|
+
const settings = await wpRequest("/settings");
|
|
82
|
+
// Mask credentials when reporting — never leak the actual values
|
|
83
|
+
// in tool output (they appear in chat transcripts and logs).
|
|
84
|
+
const mask = (v) => !v ? "MISSING"
|
|
85
|
+
: v.length <= 8 ? "present (short)"
|
|
86
|
+
: v.slice(0, 4) + "…" + v.slice(-3);
|
|
87
|
+
return ok({
|
|
88
|
+
connected: true,
|
|
89
|
+
siteUrl: config.siteUrl,
|
|
90
|
+
settings,
|
|
91
|
+
credentials_visible_to_mcp: {
|
|
92
|
+
FIGMA_TOKEN: mask(process.env.FIGMA_TOKEN),
|
|
93
|
+
SITEZEN_LICENSE_KEY: mask(process.env.SITEZEN_LICENSE_KEY),
|
|
94
|
+
SITEZEN_SITE_URL: process.env.SITEZEN_SITE_URL ? "present" : "MISSING",
|
|
95
|
+
SITEZEN_CONNECTION_KEY: mask(process.env.SITEZEN_CONNECTION_KEY),
|
|
96
|
+
},
|
|
97
|
+
note_to_claude: "If FIGMA_TOKEN shows MISSING but the user says they added it, Claude Desktop hasn't reloaded the new config — tell the user to FULLY QUIT Claude Desktop (not just close the window — quit from the system tray/menu bar) and reopen.",
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/* ─── Tool 2: list_pages ───────────────────────────────────────────────────
|
|
106
|
+
* Lists all pages on the WP site. Use this to find a page to push a new
|
|
107
|
+
* section to, or to verify a freshly-created page exists. */
|
|
108
|
+
function registerListPages(server) {
|
|
109
|
+
server.tool("list_pages", "List all WordPress pages on the connected site, with id, title, slug, URL, and publish status. Use this to find a target page for push_section_to_page, or to verify a create_page call succeeded.", {}, async () => {
|
|
110
|
+
try {
|
|
111
|
+
const pages = await wpRequest("/pages");
|
|
112
|
+
return ok({ pages, count: Array.isArray(pages) ? pages.length : 0 });
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/* ─── Tool 3: list_templates ───────────────────────────────────────────────
|
|
120
|
+
* Lists SiteZen header/footer/global templates registered on the site. */
|
|
121
|
+
function registerListTemplates(server) {
|
|
122
|
+
server.tool("list_templates", "List SiteZen templates (headers, footers, global sections) on the connected site. Use before creating a new template to check what already exists, or to find a template id for reuse.", {}, async () => {
|
|
123
|
+
try {
|
|
124
|
+
const templates = await wpRequest("/templates");
|
|
125
|
+
return ok({ templates });
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
/* ─── Tool 4: create_page ──────────────────────────────────────────────────
|
|
133
|
+
* Creates a brand-new WordPress page wrapping the provided HTML as a
|
|
134
|
+
* SiteZen Section block. This is the most common tool — the typical flow
|
|
135
|
+
* is: model generates HTML → calls create_page with it → user opens the
|
|
136
|
+
* resulting URL to see / edit. */
|
|
137
|
+
function registerCreatePage(server) {
|
|
138
|
+
server.tool("create_page", "Create a new WordPress page wrapping the provided HTML as a SiteZen Section block (full editor v2 panels available). Returns the page id and URL on success. The HTML you provide should be a single <section>...</section> (or any wrapper element) with inline styles and semantic markup. The plugin will base64-encode it and store it in the block attribute; the editor will let users tweak everything via the Style Studio panels.", {
|
|
139
|
+
title: z.string().min(1).describe("WP page title, also used as the section name in the block editor."),
|
|
140
|
+
html: z.string().min(1).describe("Full HTML for the section. Use inline styles; avoid linking external stylesheets that may not exist on the target site. Real text content from the design source (not placeholders) is strongly preferred."),
|
|
141
|
+
figma_data: FIGMA_DATA_SCHEMA,
|
|
142
|
+
slug: z.string().optional().describe("Optional URL slug. If omitted, WP derives one from the title."),
|
|
143
|
+
draft: z.boolean().optional().describe("If true, create the page as a draft instead of publishing immediately."),
|
|
144
|
+
}, async ({ title, html, figma_data, slug, draft }) => {
|
|
145
|
+
try {
|
|
146
|
+
// 1. Structural validation — refuse if Claude's HTML is malformed
|
|
147
|
+
// (no <section>, no scoped id, markdown fences). These can't
|
|
148
|
+
// be auto-fixed; the model must regenerate.
|
|
149
|
+
const v = validateHtml(html);
|
|
150
|
+
if (!v.ok) {
|
|
151
|
+
return err("HTML failed SiteZen structural validation — refusing to push. Fix the issues and retry.", {
|
|
152
|
+
violations: v.errors,
|
|
153
|
+
instruction: "Re-read get_conversion_rules(section_type=...) and your Figma data, then regenerate the HTML.",
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
// 1b. Baked-background guard — refuse if Claude used the full
|
|
157
|
+
// section render or screenshot as a background image.
|
|
158
|
+
const bakedViolations = detectBakedBackgroundViolations(html, {
|
|
159
|
+
legitimate_urls: figma_data?.image_asset_urls,
|
|
160
|
+
section_render_url: figma_data?.section_render_url,
|
|
161
|
+
});
|
|
162
|
+
if (bakedViolations.length > 0) {
|
|
163
|
+
return err("BAKED_BACKGROUND_VIOLATION — refusing to push.", {
|
|
164
|
+
error_code: "BAKED_BACKGROUND_VIOLATION",
|
|
165
|
+
violations: bakedViolations,
|
|
166
|
+
instruction: "Per ABSOLUTE_BACKGROUND_RULES: never use section_render_url or any signed render URL as a background-image. Reconstruct from colors_used / gradients[] / vector_svgs[]; render text + nav + buttons as HTML.",
|
|
167
|
+
user_message: "I tried to use a baked render as the background. Let me regenerate with proper HTML elements and a CSS-built background.",
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
// 1c. Nav-in-hero guard — refuse if the page section markup
|
|
171
|
+
// contains <nav>/<header>. Per §0.3.K this must be split
|
|
172
|
+
// into a header template push + a page push with sz-nav-spacer.
|
|
173
|
+
const navViolations = detectNavInHeroViolation(html);
|
|
174
|
+
if (navViolations.length > 0) {
|
|
175
|
+
return err("NAV_IN_HERO_VIOLATION — refusing to push.", {
|
|
176
|
+
error_code: "NAV_IN_HERO_VIOLATION",
|
|
177
|
+
violations: navViolations,
|
|
178
|
+
instruction: "Split the design into TWO pushes per §0.3.K: (1) create_header_footer(template_type='header', html=<the nav wrapped in <header>>) for the site-wide header. (2) create_page(title, html=<page content WITHOUT any <nav>, starting with <div class='sz-nav-spacer' style='height:clamp(40px,5vw,60px)' aria-hidden='true'></div>>). Otherwise users see DUPLICATE headers (global template + nav baked into the section).",
|
|
179
|
+
user_message: "I bundled the header into the page section by mistake — that causes duplicate headers. Let me push the header as a template first, then the page without the nav.",
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
// 1d. Duplicate-content guard — refuse if the same heading/
|
|
183
|
+
// paragraph text appears more than once. Usually means
|
|
184
|
+
// Claude wrote the section twice or baked text into bg
|
|
185
|
+
// AND also as HTML.
|
|
186
|
+
const dupViolations = detectDuplicateContent(html);
|
|
187
|
+
if (dupViolations.length > 0) {
|
|
188
|
+
return err("DUPLICATE_CONTENT_VIOLATION — refusing to push.", {
|
|
189
|
+
error_code: "DUPLICATE_CONTENT_VIOLATION",
|
|
190
|
+
violations: dupViolations,
|
|
191
|
+
instruction: "Each headline / paragraph from text_nodes should appear exactly ONCE in the HTML. Find the duplicate text and remove the redundant copy. If you see a text in both the HTML and a bg image, you have a baked-bg violation in disguise — fix the bg per ABSOLUTE_BACKGROUND_RULES.",
|
|
192
|
+
user_message: "I duplicated some text on the page. Let me clean it up so each element appears once.",
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
// 2. Post-process — same pipeline as the platform engine:
|
|
196
|
+
// normalizeHtml → autoConvertCardGrids → enforceSectionBackground
|
|
197
|
+
// → enforceFigmaTextStyles. The enforce* steps APPLY Figma
|
|
198
|
+
// values onto Claude's HTML if figma_data was provided.
|
|
199
|
+
const { html: processed, missing_texts } = postProcess(html, figma_data);
|
|
200
|
+
const body = {
|
|
201
|
+
page_title: title,
|
|
202
|
+
html: processed,
|
|
203
|
+
pushTarget: "page",
|
|
204
|
+
};
|
|
205
|
+
if (slug)
|
|
206
|
+
body.slug = slug;
|
|
207
|
+
if (draft)
|
|
208
|
+
body.draft = true;
|
|
209
|
+
const result = await wpRequest("/push-html", { method: "POST", body });
|
|
210
|
+
// Per CONVERSION_RULES.md §0.3.N: the detection block is the source
|
|
211
|
+
// of truth for how the section was routed. If is_header/is_footer
|
|
212
|
+
// came back unexpectedly, the section got promoted to a site-wide
|
|
213
|
+
// template and the page body is empty — that's a quality issue worth
|
|
214
|
+
// surfacing prominently so the model can re-split per §0.3.K.
|
|
215
|
+
const det = result.detection;
|
|
216
|
+
let detection_warning;
|
|
217
|
+
if (det?.is_header) {
|
|
218
|
+
detection_warning =
|
|
219
|
+
"is_header:true returned by the plugin's auto-detector. Even though pushTarget=page was set, the markup looked header-shaped to the detector and the section may have been promoted to a site-wide header template. If you intended a regular page section: re-split per §0.3.K — emit the <nav> as a separate create_header_footer call, and the hero body without any <nav> via create_page with a <div class='sz-nav-spacer'> at top.";
|
|
220
|
+
}
|
|
221
|
+
else if (det?.is_footer) {
|
|
222
|
+
detection_warning =
|
|
223
|
+
"is_footer:true returned by the plugin's auto-detector. The section may have been promoted to a site-wide footer template. Re-split per §0.3.K — emit the footer markup as create_header_footer(template_type='footer'), and the page section without <footer> via create_page.";
|
|
224
|
+
}
|
|
225
|
+
// Log the successful conversion (maximum data per product
|
|
226
|
+
// decision: timestamp, license fingerprint, site, Figma URL,
|
|
227
|
+
// page name + id, HTML, detection). Append-only JSONL on
|
|
228
|
+
// disk; also POSTed to the dashboard when configured.
|
|
229
|
+
logConversion({
|
|
230
|
+
site_url: getConfig().siteUrl,
|
|
231
|
+
page_name: title,
|
|
232
|
+
page_id: result.page_id,
|
|
233
|
+
success: true,
|
|
234
|
+
html: processed,
|
|
235
|
+
detection: result.detection,
|
|
236
|
+
});
|
|
237
|
+
// Surface graceful-degradation placeholders so Claude can give
|
|
238
|
+
// the user a clear "X spots need your input" checklist. Each
|
|
239
|
+
// entry has type + label + what_to_do — Claude shows them as
|
|
240
|
+
// a numbered list at the end of the conversion message.
|
|
241
|
+
const pending_assets = extractPendingAssets(processed);
|
|
242
|
+
return ok({
|
|
243
|
+
created: true,
|
|
244
|
+
page_id: result.page_id,
|
|
245
|
+
page_url: result.page_url,
|
|
246
|
+
edit_url: result.page_id
|
|
247
|
+
? `${getConfig().siteUrl}/wp-admin/post.php?post=${result.page_id}&action=edit`
|
|
248
|
+
: undefined,
|
|
249
|
+
status: result.status,
|
|
250
|
+
detection: result.detection,
|
|
251
|
+
detection_warning,
|
|
252
|
+
figma_text_missing_from_html: missing_texts.length > 0
|
|
253
|
+
? missing_texts
|
|
254
|
+
: undefined,
|
|
255
|
+
pending_assets: pending_assets.length > 0 ? pending_assets : undefined,
|
|
256
|
+
pending_assets_instruction: pending_assets.length > 0
|
|
257
|
+
? "Show the user a numbered list of the items above (label + what_to_do). The page is otherwise complete — only these specific spots need attention."
|
|
258
|
+
: undefined,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
catch (e) {
|
|
262
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
263
|
+
logConversion({
|
|
264
|
+
site_url: (() => { try {
|
|
265
|
+
return getConfig().siteUrl;
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return "";
|
|
269
|
+
} })(),
|
|
270
|
+
page_name: title,
|
|
271
|
+
success: false,
|
|
272
|
+
error_code: msg.slice(0, 200),
|
|
273
|
+
});
|
|
274
|
+
return err(msg);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
/* ─── Tool 5: push_section_to_page ─────────────────────────────────────────
|
|
279
|
+
* Appends a new section (or replaces an existing one by index) on an
|
|
280
|
+
* already-existing page. Use this to build multi-section pages turn by
|
|
281
|
+
* turn — create_page for the first section, then push_section_to_page
|
|
282
|
+
* for each additional section. */
|
|
283
|
+
function registerPushSectionToPage(server) {
|
|
284
|
+
server.tool("push_section_to_page", "Add a SiteZen Section block (or replace one) on an existing WordPress page. Set replace_block_index to overwrite a specific block by position, otherwise the new section appends to the end. Use list_pages first to find the target page_id.", {
|
|
285
|
+
page_id: z.number().int().positive().describe("Target WP page id (from list_pages.pages[].id)."),
|
|
286
|
+
html: z.string().min(1).describe("HTML for the new section."),
|
|
287
|
+
figma_data: FIGMA_DATA_SCHEMA,
|
|
288
|
+
replace_block_index: z.number().int().nonnegative().optional().describe("If set, replaces the block at this 0-based index instead of appending. Use with caution — wrong index can wipe a section."),
|
|
289
|
+
}, async ({ page_id, html, figma_data, replace_block_index }) => {
|
|
290
|
+
try {
|
|
291
|
+
const v = validateHtml(html);
|
|
292
|
+
if (!v.ok) {
|
|
293
|
+
return err("HTML failed SiteZen structural validation — refusing to push. Fix the issues and retry.", {
|
|
294
|
+
violations: v.errors,
|
|
295
|
+
instruction: "Re-read get_conversion_rules(section_type=...) and your Figma data, then regenerate.",
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
const bakedViolations = detectBakedBackgroundViolations(html, {
|
|
299
|
+
legitimate_urls: figma_data?.image_asset_urls,
|
|
300
|
+
section_render_url: figma_data?.section_render_url,
|
|
301
|
+
});
|
|
302
|
+
if (bakedViolations.length > 0) {
|
|
303
|
+
return err("BAKED_BACKGROUND_VIOLATION — refusing to push.", {
|
|
304
|
+
error_code: "BAKED_BACKGROUND_VIOLATION",
|
|
305
|
+
violations: bakedViolations,
|
|
306
|
+
instruction: "Per ABSOLUTE_BACKGROUND_RULES: never use section_render_url or any signed render URL as a background-image. Reconstruct from colors_used / gradients / vector_svgs; render text + UI as HTML.",
|
|
307
|
+
user_message: "I tried to use a baked render as the background. Let me regenerate.",
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
const navViolations = detectNavInHeroViolation(html);
|
|
311
|
+
if (navViolations.length > 0) {
|
|
312
|
+
return err("NAV_IN_HERO_VIOLATION — refusing to push.", {
|
|
313
|
+
error_code: "NAV_IN_HERO_VIOLATION",
|
|
314
|
+
violations: navViolations,
|
|
315
|
+
instruction: "Split per §0.3.K: header via create_header_footer, page section without <nav> with sz-nav-spacer at top.",
|
|
316
|
+
user_message: "I bundled the header into the section — causes duplicate headers. Let me split it.",
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
const dupViolations = detectDuplicateContent(html);
|
|
320
|
+
if (dupViolations.length > 0) {
|
|
321
|
+
return err("DUPLICATE_CONTENT_VIOLATION — refusing to push.", {
|
|
322
|
+
error_code: "DUPLICATE_CONTENT_VIOLATION",
|
|
323
|
+
violations: dupViolations,
|
|
324
|
+
instruction: "Each text from text_nodes should appear exactly once. Remove duplicates.",
|
|
325
|
+
user_message: "I duplicated some text. Let me clean up.",
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
const { html: processed, missing_texts } = postProcess(html, figma_data);
|
|
329
|
+
const body = {
|
|
330
|
+
page_id,
|
|
331
|
+
html: processed,
|
|
332
|
+
append: replace_block_index === undefined,
|
|
333
|
+
};
|
|
334
|
+
if (replace_block_index !== undefined) {
|
|
335
|
+
body.replace_block_index = replace_block_index;
|
|
336
|
+
}
|
|
337
|
+
const result = await wpRequest("/push-html", { method: "POST", body });
|
|
338
|
+
logConversion({
|
|
339
|
+
site_url: getConfig().siteUrl,
|
|
340
|
+
page_id,
|
|
341
|
+
success: true,
|
|
342
|
+
html: processed,
|
|
343
|
+
});
|
|
344
|
+
const pending_assets = extractPendingAssets(processed);
|
|
345
|
+
return ok({
|
|
346
|
+
pushed: true,
|
|
347
|
+
page_id: result.page_id,
|
|
348
|
+
page_url: result.page_url,
|
|
349
|
+
block_index: result.block_index,
|
|
350
|
+
edit_url: `${getConfig().siteUrl}/wp-admin/post.php?post=${page_id}&action=edit`,
|
|
351
|
+
figma_text_missing_from_html: missing_texts.length > 0
|
|
352
|
+
? missing_texts
|
|
353
|
+
: undefined,
|
|
354
|
+
pending_assets: pending_assets.length > 0 ? pending_assets : undefined,
|
|
355
|
+
pending_assets_instruction: pending_assets.length > 0
|
|
356
|
+
? "Show the user a numbered list of the items above (label + what_to_do). The section is otherwise complete — only these specific spots need attention."
|
|
357
|
+
: undefined,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
catch (e) {
|
|
361
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
362
|
+
logConversion({
|
|
363
|
+
site_url: (() => { try {
|
|
364
|
+
return getConfig().siteUrl;
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
return "";
|
|
368
|
+
} })(),
|
|
369
|
+
page_id,
|
|
370
|
+
success: false,
|
|
371
|
+
error_code: msg.slice(0, 200),
|
|
372
|
+
});
|
|
373
|
+
return err(msg);
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
/* ─── Tool 6: get_page_html ────────────────────────────────────────────────
|
|
378
|
+
* Returns the raw HTML of an existing SiteZen page (one section per
|
|
379
|
+
* block). Use this before update_section so the model can read what's
|
|
380
|
+
* there, modify it surgically, and push the modified version back. */
|
|
381
|
+
function registerGetPageHtml(server) {
|
|
382
|
+
server.tool("get_page_html", "Fetch the current HTML of an existing WordPress page (split per SiteZen Section block). Use this to read what's already on a page before editing — the model can diff its proposed change against the current HTML and only push deltas.", {
|
|
383
|
+
page_id: z.number().int().positive().describe("WP page id."),
|
|
384
|
+
}, async ({ page_id }) => {
|
|
385
|
+
try {
|
|
386
|
+
const result = await wpRequest(`/debug-page/${page_id}`);
|
|
387
|
+
return ok({ page_id, content: result });
|
|
388
|
+
}
|
|
389
|
+
catch (e) {
|
|
390
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
/* ─── Tool 7: create_header_footer ─────────────────────────────────────────
|
|
395
|
+
* Creates a SiteZen header or footer template. These are site-wide and
|
|
396
|
+
* apply to every page. Once created, the plugin auto-activates the
|
|
397
|
+
* template for the relevant role. */
|
|
398
|
+
function registerCreateHeaderFooter(server) {
|
|
399
|
+
server.tool("create_header_footer", "Create a site-wide header or footer template from HTML. Once created, the plugin automatically activates it for every page on the site. Use template_type='header' for a nav bar with logo + menu, 'footer' for the footer block. Make sure to include semantic <header> or <footer> tag in the HTML.", {
|
|
400
|
+
template_type: z.enum(["header", "footer"]).describe("Which template slot this fills."),
|
|
401
|
+
title: z.string().min(1).describe("Template title shown in WP admin (e.g. 'Main Header')."),
|
|
402
|
+
html: z.string().min(1).describe("Full HTML for the template, wrapped in a semantic <header> or <footer> element."),
|
|
403
|
+
}, async ({ template_type, title, html }) => {
|
|
404
|
+
try {
|
|
405
|
+
const result = await wpRequest("/push-html", {
|
|
406
|
+
method: "POST",
|
|
407
|
+
body: {
|
|
408
|
+
page_title: title,
|
|
409
|
+
html,
|
|
410
|
+
// Force the detector — without these the engine guesses from markup,
|
|
411
|
+
// which works most of the time but can mis-route for ambiguous HTML.
|
|
412
|
+
pushTarget: "template",
|
|
413
|
+
templateType: template_type,
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
return ok({
|
|
417
|
+
created: true,
|
|
418
|
+
template_id: result.template_id,
|
|
419
|
+
template_type: result.template_type || template_type,
|
|
420
|
+
message: result.message,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
catch (e) {
|
|
424
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
/* ─── Tool 8: set_site_branding ────────────────────────────────────────────
|
|
429
|
+
* Sets site-wide brand defaults the plugin uses across new conversions:
|
|
430
|
+
* primary color, logo URL, body font. These are read by the editor and
|
|
431
|
+
* applied as defaults when generating new sections. */
|
|
432
|
+
function registerSetSiteBranding(server) {
|
|
433
|
+
server.tool("set_site_branding", "Update site-wide brand defaults: primary color (hex), accent color (hex), heading font, body font, logo URL. These are stored as plugin settings and used as defaults when generating new sections. Pass only the fields you want to change — omitted fields are left as-is.", {
|
|
434
|
+
primary_color: z.string().regex(/^#?[0-9A-Fa-f]{3,8}$/).optional().describe("Hex color (with or without #), used for primary buttons + accents."),
|
|
435
|
+
accent_color: z.string().regex(/^#?[0-9A-Fa-f]{3,8}$/).optional().describe("Hex color for secondary accents."),
|
|
436
|
+
heading_font: z.string().optional().describe("Font family for headings (e.g. 'Inter', 'Plus Jakarta Sans')."),
|
|
437
|
+
body_font: z.string().optional().describe("Font family for body text."),
|
|
438
|
+
logo_url: z.string().url().optional().describe("Absolute URL to the site logo image."),
|
|
439
|
+
}, async (args) => {
|
|
440
|
+
try {
|
|
441
|
+
// The plugin's /settings endpoint accepts PUT/POST with the same shape.
|
|
442
|
+
const result = await wpRequest("/settings", {
|
|
443
|
+
method: "POST",
|
|
444
|
+
body: args,
|
|
445
|
+
});
|
|
446
|
+
return ok({ updated: true, settings: result });
|
|
447
|
+
}
|
|
448
|
+
catch (e) {
|
|
449
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
/* ─── Tool 9: get_site_globals ─────────────────────────────────────────────
|
|
454
|
+
* Returns the current brand defaults (colors, fonts, logo). The model
|
|
455
|
+
* should call this at the start of any design conversation so it can
|
|
456
|
+
* generate HTML that matches the user's existing brand. */
|
|
457
|
+
function registerGetSiteGlobals(server) {
|
|
458
|
+
server.tool("get_site_globals", "Fetch the site's brand defaults (colors, fonts, logo URL) and any other plugin settings. Call this at the start of a design conversation so the model can match the site's existing brand when generating new sections.", {}, async () => {
|
|
459
|
+
try {
|
|
460
|
+
const settings = await wpRequest("/settings");
|
|
461
|
+
return ok({ globals: settings });
|
|
462
|
+
}
|
|
463
|
+
catch (e) {
|
|
464
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
/* ─── Tool 9c: set_wc_single_product_template ─────────────────────────────
|
|
469
|
+
* Phase 3 of the WooCommerce build. After the user makes the 3-option
|
|
470
|
+
* choice for their single product page (default / convert later /
|
|
471
|
+
* auto-generate / use this converted template), this tool persists their
|
|
472
|
+
* choice on the WordPress site so /product/<slug>/ URLs render the right
|
|
473
|
+
* template for EVERY product. */
|
|
474
|
+
function registerWcSingleTemplate(server) {
|
|
475
|
+
server.tool("get_wc_single_template", "Read the user's current single-product template choice on the connected WC site. Returns mode ('default'|'pending_design'|'custom'|'auto_generated') and the active template post id (when custom/auto_generated). Use this to know whether to ask the 3-option question on first product conversion.", {}, async () => {
|
|
476
|
+
try {
|
|
477
|
+
const r = await wpRequest("/wc-single-template");
|
|
478
|
+
return ok(r);
|
|
479
|
+
}
|
|
480
|
+
catch (e) {
|
|
481
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
server.tool("set_wc_single_product_template", "Persist the user's single-product template choice. After the user picks Option A/B/C, call this. Mode values: 'default' (use plugin's polished PHP template — Option A), 'pending_design' (use default while user prepares custom Figma — Option B interim), 'custom' (use the converted sz_template — Option B final or Option C), 'auto_generated' (same as custom — just signals it came from brand-matching). template_id is REQUIRED for 'custom' and 'auto_generated' (the sz_template post id returned by create_template).", {
|
|
485
|
+
mode: z.enum(["default", "pending_design", "custom", "auto_generated"]).describe("default: use plugin's polished PHP single product template. pending_design: also uses default but signals user wants to convert their own design later. custom: use a converted Figma single-product design. auto_generated: same as custom, signals MCP generated it from brand samples."),
|
|
486
|
+
template_id: z.number().int().positive().optional().describe("The sz_template post id for custom/auto_generated modes. Get this from create_template's response when you pushed the converted single-product HTML."),
|
|
487
|
+
}, async ({ mode, template_id }) => {
|
|
488
|
+
try {
|
|
489
|
+
const r = await wpRequest("/wc-single-template", {
|
|
490
|
+
method: "POST",
|
|
491
|
+
body: { mode, template_id: template_id || 0 },
|
|
492
|
+
});
|
|
493
|
+
return ok(r);
|
|
494
|
+
}
|
|
495
|
+
catch (e) {
|
|
496
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
/* ─── Tool: set_page_role ──────────────────────────────────────────────────
|
|
501
|
+
* Assigns a freshly-created page to its site role so conversion needs zero
|
|
502
|
+
* manual Settings work (Home → front page, Shop/Cart/Checkout/Account → the WC
|
|
503
|
+
* page options, Blog → posts page). */
|
|
504
|
+
function registerSetPageRole(server) {
|
|
505
|
+
server.tool("set_page_role", "Assign a converted page to its site role so it goes live with NO manual WordPress/WooCommerce settings. Call this right after create_page when the design clearly IS a home/blog/shop/cart/checkout/account page (ask the user if unsure). Roles: 'home' (sets it as the site's front page), 'blog' (posts page — must differ from home), 'shop' (WooCommerce shop), 'cart', 'checkout', 'myaccount'. For cart/checkout/myaccount the plugin also injects the required WooCommerce shortcode ([woocommerce_cart] / [woocommerce_checkout] / [woocommerce_my_account]) into the page so the functional widget renders inside the custom design — that's how a custom-designed-but-fully-functional store page works (the surrounding design is pixel-perfect; the cart/checkout/account widget is WooCommerce's, styled to match). WooCommerce roles require WooCommerce active.", {
|
|
506
|
+
page_id: z.number().int().positive().describe("WP page id from create_page / list_pages."),
|
|
507
|
+
role: z.enum(["home", "blog", "shop", "cart", "checkout", "myaccount"]).describe("home: site front page. blog: posts page (must differ from home). shop/cart/checkout/myaccount: the matching WooCommerce page (WooCommerce must be active; cart/checkout/myaccount also get the WC shortcode injected)."),
|
|
508
|
+
}, async ({ page_id, role }) => {
|
|
509
|
+
try {
|
|
510
|
+
const r = await wpRequest("/set-page-role", {
|
|
511
|
+
method: "POST",
|
|
512
|
+
body: { page_id, role },
|
|
513
|
+
});
|
|
514
|
+
return ok(r);
|
|
515
|
+
}
|
|
516
|
+
catch (e) {
|
|
517
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
/* ─── Tool 9b: get_site_widths ────────────────────────────────────────────
|
|
522
|
+
* Returns the active theme's content + wide widths so generated sections
|
|
523
|
+
* fit the user's actual theme container — no more "Figma's 1920px width
|
|
524
|
+
* gets baked into the section and causes horizontal page scroll". */
|
|
525
|
+
function registerGetSiteWidths(server) {
|
|
526
|
+
server.tool("get_site_widths", "Returns the active WordPress theme's content_size_px and wide_size_px (in pixels). Generated section HTML MUST use these as max-widths instead of hardcoded Figma pixel widths — otherwise sections bleed past the theme container and cause horizontal page scroll. Call this once per conversation; the values rarely change.", {}, async () => {
|
|
527
|
+
try {
|
|
528
|
+
const widths = await wpRequest("/site-widths");
|
|
529
|
+
return ok({
|
|
530
|
+
...widths,
|
|
531
|
+
instruction: [
|
|
532
|
+
`Use wide_size_px (${widths.wide_size_px}px) as the max-width for any section's outer content container.`,
|
|
533
|
+
`Use content_size_px (${widths.content_size_px}px) as the max-width for paragraph/text containers within sections.`,
|
|
534
|
+
"Section roots themselves stay width:100% (full-bleed bg colors / gradients / images). The MAX-WIDTH constraint only applies to INNER content containers — typically a div with margin-inline:auto centred inside the full-bleed section.",
|
|
535
|
+
"NEVER use pixel widths from Figma absoluteBoundingBox on the section root or any container — those are Figma canvas widths (often 1920+) and will cause horizontal scroll on smaller theme containers. Use these values instead.",
|
|
536
|
+
`Detection source: ${widths.source}. Theme: ${widths.theme.name || "unknown"}.`,
|
|
537
|
+
].join("\n"),
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
catch (e) {
|
|
541
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
/* ─── Tool 10: detect_section_kind ─────────────────────────────────────────
|
|
546
|
+
* Runs the plugin's detector against arbitrary HTML and returns what
|
|
547
|
+
* structural blocks it found (slider, accordion, pricing, stats, etc.).
|
|
548
|
+
* Useful for the model to verify its emitted HTML will be recognised by
|
|
549
|
+
* the editor's specialized panels before pushing. */
|
|
550
|
+
function registerDetectSectionKind(server) {
|
|
551
|
+
server.tool("detect_section_kind", "Dry-run the SiteZen detector against HTML to see what structural blocks it identifies (slider, accordion, pricing, stats, tabs, etc.). Use this before pushing complex sections to verify the editor will recognize them and offer the right specialized panels.", {
|
|
552
|
+
html: z.string().min(1).describe("HTML to analyse."),
|
|
553
|
+
}, async ({ html }) => {
|
|
554
|
+
try {
|
|
555
|
+
const result = await wpRequest("/detect", {
|
|
556
|
+
method: "POST",
|
|
557
|
+
body: { html },
|
|
558
|
+
});
|
|
559
|
+
return ok({ detection: result });
|
|
560
|
+
}
|
|
561
|
+
catch (e) {
|
|
562
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
/* ─── Tool 11: editor_v2_capabilities ──────────────────────────────────────
|
|
567
|
+
* Read-only manifest of what the WP editor can do per element. The model
|
|
568
|
+
* uses this to choose HTML structure that makes the most of editor v2
|
|
569
|
+
* (e.g. wrapping a text in <span> so the Tag panel can swap it to <h2>;
|
|
570
|
+
* adding data-sz-id="hero-cta" so the Layers panel labels it cleanly). */
|
|
571
|
+
function registerEditorV2Capabilities(server) {
|
|
572
|
+
server.tool("editor_v2_capabilities", "Returns a manifest of the SiteZen editor v2 Style Studio panels and the CSS properties / attributes each one edits. Use this to decide what HTML structure to emit so the user gets maximum editing power post-push — e.g. wrap text in <span> for tag-swap support, add data-sz-id attributes for layer labels.", {}, async () => {
|
|
573
|
+
return ok({
|
|
574
|
+
version: "editor-v2 / Style Studio",
|
|
575
|
+
panels: [
|
|
576
|
+
{ name: "Layers", edits: "Walks all elements with data-sz-id, click to select. Recommend: add data-sz-id=\"some-label\" to major elements so the tree is readable." },
|
|
577
|
+
{ name: "Tag", edits: "Swap element tag (h1↔h2↔h3↔p↔span↔div↔a↔button). Affects the same element, preserves children + attributes." },
|
|
578
|
+
{ name: "Image", edits: "src, alt, object-fit. Only for <img> tags." },
|
|
579
|
+
{ name: "Spacing", edits: "padding-top/right/bottom/left, margin-top/right/bottom/left, gap." },
|
|
580
|
+
{ name: "Border", edits: "border-width, border-style, border-color, per-side overrides." },
|
|
581
|
+
{ name: "Border Radius", edits: "border-radius + per-corner (top-left, top-right, bottom-left, bottom-right)." },
|
|
582
|
+
{ name: "Background", edits: "background-color, background-image (gradient palette + custom URL), background-size, background-position, background-repeat." },
|
|
583
|
+
{ name: "Effects", edits: "box-shadow (6 presets + custom), opacity (slider), filter, cursor." },
|
|
584
|
+
{ name: "Layout", edits: "display, position, top/right/bottom/left, z-index, overflow." },
|
|
585
|
+
{ name: "Size", edits: "width, height, min/max-width, min/max-height, box-sizing." },
|
|
586
|
+
{ name: "Flex", edits: "Parent: flex-direction, flex-wrap, justify-content, align-items. Child: flex-grow, flex-shrink, flex-basis, order, align-self." },
|
|
587
|
+
{ name: "Transform", edits: "transform, transform-origin, transition (with 6 quick-preset buttons: Lift, Zoom, Tilt, Shrink, Flip X, Reset)." },
|
|
588
|
+
{ name: "Hover State", edits: "Writes :hover rules to a <style data-sz-rules> block keyed by data-sz-id. Covers background-color, color, border-color, transform, box-shadow, opacity. Includes quick-preset buttons (Lift, Zoom, Shadow, Fade, Clear all)." },
|
|
589
|
+
{ name: "Alignment & Text Style", edits: "text-align (L/C/R/J button row), text-transform (uppercase/capitalize/lowercase), text-decoration (underline/strikethrough/overline), font-style (italic), letter-spacing, word-spacing, white-space, justify-self, align-self, vertical-align." },
|
|
590
|
+
{ name: "Classes", edits: "Free-form class attribute, id attribute, inline style attribute editor." },
|
|
591
|
+
],
|
|
592
|
+
authoring_tips: [
|
|
593
|
+
"Wrap headlines in semantic h1/h2/h3 (not <div>) so the Tag panel offers SEO-friendly swaps.",
|
|
594
|
+
"Use inline styles — they survive the section's base64 round-trip cleanly and are what the per-element panels edit directly.",
|
|
595
|
+
"Add data-sz-id=\"hero-cta\" / \"feature-icon\" etc. to important elements — the Layers panel uses these as labels.",
|
|
596
|
+
"For images, use real URLs (or, if you must use placeholders, mark them so the user knows to swap via the Image panel).",
|
|
597
|
+
"For hover effects, leave the static styles to the model and let the user opt-in to hover via the Hover State panel's presets.",
|
|
598
|
+
],
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
/* ─── Conversion rules section index ──────────────────────────────────────
|
|
603
|
+
* The CONVERSION_RULES.md file has 23 numbered sections (§0 universal +
|
|
604
|
+
* §1–§22 section types). When Claude is converting a specific section
|
|
605
|
+
* (hero, slider, etc.) it doesn't need the full 100KB file — it needs
|
|
606
|
+
* §0 universal + the one section-specific contract. This index lets us
|
|
607
|
+
* slice the file cheaply at runtime.
|
|
608
|
+
*
|
|
609
|
+
* SECTION_TYPES is the public contract: a Zod enum so the tool only
|
|
610
|
+
* accepts valid types. SECTION_MARKERS maps each type to its `## §N`
|
|
611
|
+
* heading in the markdown file. */
|
|
612
|
+
const SECTION_TYPES = [
|
|
613
|
+
"universal", "hero", "slider", "testimonial", "tabs", "accordion",
|
|
614
|
+
"post_listing", "cta_banner", "features", "stats", "pricing",
|
|
615
|
+
"team", "steps", "logo_strip", "form", "video", "map",
|
|
616
|
+
"header", "footer", "sticky_cta", "cookie_banner", "animations",
|
|
617
|
+
"decoration",
|
|
618
|
+
];
|
|
619
|
+
const SECTION_MARKERS = {
|
|
620
|
+
universal: { heading: /^## §0 /m, nextHeading: /^## §1 /m },
|
|
621
|
+
hero: { heading: /^## §1 /m, nextHeading: /^## §2 /m },
|
|
622
|
+
slider: { heading: /^## §2 /m, nextHeading: /^## §3 /m },
|
|
623
|
+
testimonial: { heading: /^## §3 /m, nextHeading: /^## §4 /m },
|
|
624
|
+
tabs: { heading: /^## §4 /m, nextHeading: /^## §5 /m },
|
|
625
|
+
accordion: { heading: /^## §5 /m, nextHeading: /^## §6 /m },
|
|
626
|
+
post_listing: { heading: /^## §6 /m, nextHeading: /^## §7 /m },
|
|
627
|
+
cta_banner: { heading: /^## §7 /m, nextHeading: /^## §8 /m },
|
|
628
|
+
features: { heading: /^## §8 /m, nextHeading: /^## §9 /m },
|
|
629
|
+
stats: { heading: /^## §9 /m, nextHeading: /^## §10 /m },
|
|
630
|
+
pricing: { heading: /^## §10 /m, nextHeading: /^## §11 /m },
|
|
631
|
+
team: { heading: /^## §11 /m, nextHeading: /^## §12 /m },
|
|
632
|
+
steps: { heading: /^## §12 /m, nextHeading: /^## §13 /m },
|
|
633
|
+
logo_strip: { heading: /^## §13 /m, nextHeading: /^## §14 /m },
|
|
634
|
+
form: { heading: /^## §14 /m, nextHeading: /^## §15 /m },
|
|
635
|
+
video: { heading: /^## §15 /m, nextHeading: /^## §16 /m },
|
|
636
|
+
map: { heading: /^## §16 /m, nextHeading: /^## §17 /m },
|
|
637
|
+
header: { heading: /^## §17 /m, nextHeading: /^## §18 /m },
|
|
638
|
+
footer: { heading: /^## §18 /m, nextHeading: /^## §19 /m },
|
|
639
|
+
sticky_cta: { heading: /^## §19 /m, nextHeading: /^## §20 /m },
|
|
640
|
+
cookie_banner: { heading: /^## §20 /m, nextHeading: /^## §21 /m },
|
|
641
|
+
animations: { heading: /^## §21 /m, nextHeading: /^## §22 /m },
|
|
642
|
+
decoration: { heading: /^## §22 /m, nextHeading: /^## Workflow/m },
|
|
643
|
+
};
|
|
644
|
+
/** Slice one section from the rules markdown. Returns the full markdown
|
|
645
|
+
* text from that section's `## §N` heading up to (but not including) the
|
|
646
|
+
* next major heading. Returns empty string if section not found. */
|
|
647
|
+
function sliceRulesSection(rules, type) {
|
|
648
|
+
const { heading, nextHeading } = SECTION_MARKERS[type];
|
|
649
|
+
const startMatch = heading.exec(rules);
|
|
650
|
+
if (!startMatch)
|
|
651
|
+
return "";
|
|
652
|
+
const startIdx = startMatch.index;
|
|
653
|
+
const endIdx = nextHeading
|
|
654
|
+
? (nextHeading.exec(rules)?.index ?? rules.length)
|
|
655
|
+
: rules.length;
|
|
656
|
+
return rules.slice(startIdx, endIdx).trim();
|
|
657
|
+
}
|
|
658
|
+
/* ─── Tool 13: get_conversion_rules ───────────────────────────────────────
|
|
659
|
+
* Returns SiteZen conversion rules. Section-aware: pass section_type to
|
|
660
|
+
* get §0 universal + that specific section's contract (typically 5–15KB,
|
|
661
|
+
* Claude-friendly token footprint). Pass nothing to get the full ~100KB
|
|
662
|
+
* file (rare — only when classifying which type a design contains).
|
|
663
|
+
*
|
|
664
|
+
* The platform engine had these rules baked into every Claude API call as
|
|
665
|
+
* a system prompt. The MCP makes Claude opt in by calling this tool, so
|
|
666
|
+
* the model must call this BEFORE writing any HTML for a section. The
|
|
667
|
+
* tool description below makes that explicit so any Claude reading the
|
|
668
|
+
* tool list knows to call it. */
|
|
669
|
+
function registerGetConversionRules(server) {
|
|
670
|
+
server.tool("get_conversion_rules", "Returns SiteZen conversion rules — the structural HTML contracts the WordPress plugin recognizes (class names, data-* attributes, markup shapes, responsive requirements). MUST be called BEFORE generating any HTML for a section. Pass section_type to get only the relevant section's rules + universal rules (5–15KB, recommended). Omit section_type to get the full rules file (~100KB, only use when you don't yet know what type of section you're converting and need to read the full index to classify it).", {
|
|
671
|
+
section_type: z.enum(SECTION_TYPES).optional().describe("The type of section being converted. Pass this to get only the relevant section's contract + universal rules — much smaller and easier to follow. Valid values: " + SECTION_TYPES.join(", ") + ". Omit to get the full rules file."),
|
|
672
|
+
}, async ({ section_type }) => {
|
|
673
|
+
try {
|
|
674
|
+
const rules = readFileSync(CONVERSION_RULES_PATH, "utf-8");
|
|
675
|
+
if (!section_type) {
|
|
676
|
+
return ok({
|
|
677
|
+
full_rules: rules,
|
|
678
|
+
section_types_available: SECTION_TYPES,
|
|
679
|
+
note: "Full rules file returned (~100KB). For future calls, pass section_type to get a focused slice (universal + that section only).",
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
const universal = sliceRulesSection(rules, "universal");
|
|
683
|
+
const sectionRules = section_type === "universal"
|
|
684
|
+
? ""
|
|
685
|
+
: sliceRulesSection(rules, section_type);
|
|
686
|
+
if (section_type !== "universal" && !sectionRules) {
|
|
687
|
+
return err(`No rules found for section_type "${section_type}". Valid types: ${SECTION_TYPES.join(", ")}.`);
|
|
688
|
+
}
|
|
689
|
+
return ok({
|
|
690
|
+
section_type,
|
|
691
|
+
universal_rules: universal,
|
|
692
|
+
section_specific_rules: sectionRules,
|
|
693
|
+
instruction: "These rules are authoritative. Match the class names, data-* attributes, and markup shapes exactly — the editor's specialized panels and frontend JS find your elements by these contracts. Generate HTML AFTER reading both blocks above.",
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
catch (e) {
|
|
697
|
+
return err(`Could not read conversion-rules.md from MCP package: ${e instanceof Error ? e.message : String(e)}. ` +
|
|
698
|
+
"If you installed the MCP from source, make sure the build step copied conversion-rules.md into dist/.");
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
/* ─── Tool 14: start_conversion ───────────────────────────────────────────
|
|
703
|
+
* The "front door" for any new Figma→WP conversion. Returns the workflow
|
|
704
|
+
* Claude MUST follow, the list of valid section types, and a prompt that
|
|
705
|
+
* reminds the model of the universal rules. Without this, Claude has to
|
|
706
|
+
* infer the workflow from scattered tool descriptions; with it, the
|
|
707
|
+
* workflow is one tool call away.
|
|
708
|
+
*
|
|
709
|
+
* Designed so the user's prompt can be as simple as:
|
|
710
|
+
* "Use sitezen MCP to convert this Figma: <URL>. Push as a new page
|
|
711
|
+
* titled <T>."
|
|
712
|
+
* and Claude correctly:
|
|
713
|
+
* 1. Calls start_conversion → reads the workflow
|
|
714
|
+
* 2. Calls get_conversion_rules(section_type) → reads the specific rules
|
|
715
|
+
* 3. Calls figma MCP → reads the design
|
|
716
|
+
* 4. Generates HTML obeying the rules + matching the design
|
|
717
|
+
* 5. Calls create_page → pushes the result */
|
|
718
|
+
function registerStartConversion(server) {
|
|
719
|
+
server.tool("start_conversion", "ALWAYS CALL THIS FIRST when starting any Figma-to-WordPress conversion. Returns the section-wise workflow Claude must follow, the list of valid section types, and a checklist of universal rules. Without calling this, conversion quality degrades because Claude misses the section-specific contracts and the responsive/semantic requirements. Pure read-only — no side effects.", {}, async () => {
|
|
720
|
+
return ok({
|
|
721
|
+
DEFAULT_MODE: "SINGLE_SECTION. Always assume the user wants ONE section converted per request. Only switch to FULL_DESIGN if the user EXPLICITLY says 'convert the full design' / 'convert the whole homepage' / 'convert every section'. If in doubt → SINGLE_SECTION.",
|
|
722
|
+
ABSOLUTE_BACKGROUND_RULES: [
|
|
723
|
+
"NEVER EVER use the user's screenshot as a background-image, src, or any visual asset. The screenshot is for YOU to look at when deciding layout — it is NEVER inserted into the HTML in any form. Period.",
|
|
724
|
+
"NEVER EVER use prepare_section.section_render_url as a background-image, src, or any visual asset. It is YOUR REFERENCE for visual inspection ONLY. It contains the FULL section including text, buttons, navbars, photos — using it as a bg would bake ALL of that into a flat image, which is the worst possible result. If you find yourself reaching for section_render_url as a URL value in CSS or src — STOP. You're about to ship a flat baked image.",
|
|
725
|
+
"A 'background' in our model is only the empty canvas BEHIND content: solid color, gradient, decorative shapes, and (rarely) a content-free photo. It is NEVER something that contains text, headings, paragraphs, buttons, navbars, footers, cards, icons, or any UI chrome. If you see any of those in the screenshot, they are HTML — not background.",
|
|
726
|
+
"Background image URLs in your output HTML must come from ONE of these sources only: (a) a specific entry in prepare_section.image_assets[].url whose name/dimensions match a content-free background photo, OR (b) a data: URI built from a vector_svgs entry, OR (c) a CSS gradient/color string — no URL at all. Never invent URLs. Never use the section render. Never use the screenshot.",
|
|
727
|
+
"Text content discipline: every entry in prepare_section.text_nodes must appear as actual HTML text in your output (<h*>, <p>, <span>, <a>, <button>). If a text node is visible in the screenshot but missing from your HTML, you've either dropped it (bug) or baked it into an image (worse bug). The MCP audits this in findMissingTexts — missing text triggers a warning on every push, so you can self-verify before believing the page is done.",
|
|
728
|
+
"Nav-in-hero discipline (§0.3.K): if the screenshot's TOP shows a navbar (logo + menu items + optional CTA) above hero content, this is a TWO-PUSH design. Push 1: create_header_footer(template_type='header', html=<the navbar as <header>>). Push 2: create_page(title, html=<the hero WITHOUT any <nav>, with a <div class='sz-nav-spacer' style='height:clamp(40px,5vw,60px)' aria-hidden='true'></div> at the very top>). If you push the nav AS PART of the hero section, the plugin auto-detects is_header:true and your page body becomes empty — that's the detection_warning case. Same applies to footers.",
|
|
729
|
+
],
|
|
730
|
+
PREFLIGHT_CHECKLIST: [
|
|
731
|
+
"BEFORE ANYTHING ELSE, verify all 5 inputs are present. NEVER proceed with guesses or partial data. If any is missing, STOP and ask the user the specific question for that input — one tool call at a time, no batch questions.",
|
|
732
|
+
"1. SECTION SCREENSHOT — must be attached as an image in the chat. Not described, not linked, ATTACHED. If absent → ask: 'Please attach a screenshot of the exact section you want me to convert.' Stop and wait.",
|
|
733
|
+
"2. FIGMA URL — must be a https://www.figma.com/file/... or /design/... URL in the chat. If absent → ask: 'Please share the Figma URL of this design.' Stop.",
|
|
734
|
+
"3. PAGE NAME (or TARGET PAGE) — what the WordPress page should be titled, OR which existing page to push into. If absent → ask: 'What should I name the page in WordPress (new) — or which existing page should I add this section to?' Stop.",
|
|
735
|
+
"4. CONNECTED SITE — call list_connected_sites first. If one is saved → use it automatically (don't ask). If none → ask the user for site URL + connection key, call connect_site, save it. If many and the user hasn't picked → ask: 'You have these sites connected — which one?' showing the labels.",
|
|
736
|
+
"5. LICENSE QUOTA — list_connected_sites returns conversions_remaining. If 0 → STOP and show the upgrade_url. Do NOT start a conversion you can't finish.",
|
|
737
|
+
"Only when ALL FIVE are confirmed → proceed to single_section_workflow step 2 (read_rules) onward.",
|
|
738
|
+
],
|
|
739
|
+
INPUT_MEMORY: "Once the user gives you Figma URL / connected site / page name in this conversation, REMEMBER them for subsequent sections in the same chat. Do NOT re-ask for the Figma URL when the user says 'now convert the next section' — only ask for the new screenshot and (if a new page) the new title.",
|
|
740
|
+
ERROR_HANDLING: {
|
|
741
|
+
rule: "Any MCP tool can return {ok:false, error_code, user_message, what_to_do}. When you get one, show the user_message VERBATIM to the user, do the what_to_do step if there is one, and STOP. Do NOT retry with guessed values. Do NOT silently try a fallback. Do NOT continue converting with partial data.",
|
|
742
|
+
why: "Bad results from guessing cost the user real money (Claude Max tokens) and trust. A clear error costs nothing. Always prefer the clear error.",
|
|
743
|
+
error_catalog: {
|
|
744
|
+
NO_SCREENSHOT: "Ask the user to attach a screenshot.",
|
|
745
|
+
NO_FIGMA_URL: "Ask the user to paste the Figma URL.",
|
|
746
|
+
NO_PAGE_NAME: "Ask the user for the page title.",
|
|
747
|
+
NO_SITE_URL: "Ask the user which WordPress site to push to.",
|
|
748
|
+
NO_CONNECTION_KEY: "Ask the user for the connection key for that site.",
|
|
749
|
+
NO_LICENSE_KEY: "Tell the user to set SITEZEN_LICENSE_KEY in their Claude Desktop config and restart.",
|
|
750
|
+
INVALID_LICENSE: "Tell the user their license key isn't recognised — show the upgrade URL.",
|
|
751
|
+
LIMIT_REACHED_CONVERSIONS: "Show the upgrade URL — do NOT start the conversion.",
|
|
752
|
+
LIMIT_REACHED_SITES: "Tell the user they're at their site limit — offer to disconnect_site or upgrade.",
|
|
753
|
+
NO_FIGMA_TOKEN: "Tell the user to set FIGMA_TOKEN in their config and restart.",
|
|
754
|
+
SITE_UNREACHABLE: "Tell the user the URL didn't respond — ask them to check the site is online.",
|
|
755
|
+
INVALID_CONNECTION_KEY: "Ask for a fresh connection key from WordPress → SiteZen → Connection.",
|
|
756
|
+
FIGMA_RATE_LIMIT: "Tell the user Figma is throttling — wait ~5 minutes.",
|
|
757
|
+
FIGMA_TOKEN_INVALID: "Tell the user their Figma token was rejected — they need a new one.",
|
|
758
|
+
FIGMA_NOT_FOUND: "Tell the user the file isn't accessible — check the URL or token sharing.",
|
|
759
|
+
SECTION_MATCH_UNCLEAR: "Ask for a clearer single-section screenshot.",
|
|
760
|
+
FIGMA_FILE_FLATTENED: "The Figma file (or selected section) has no editable text/font layers — it's a flattened image. Show the user the user_message verbatim and STOP. Do NOT silently proceed by fabricating text/fonts/colors from the screenshot — that's how we ship wrong results. Only proceed if the user explicitly types something like 'convert from screenshot only', and even then prefix the response with a clear note that the result is approximate, not pixel-perfect.",
|
|
761
|
+
NO_SITES_CONNECTED: "Ask the user for site URL + connection key, then call connect_site.",
|
|
762
|
+
SITE_NOT_FOUND: "Ask the user to connect the site first.",
|
|
763
|
+
},
|
|
764
|
+
},
|
|
765
|
+
ABSOLUTE_RULES_BEFORE_YOU_START: [
|
|
766
|
+
"NEVER ask the user to pick a section from a list. NEVER show them node IDs, frame names, or a multiple-choice question like 'which section did you mean'. The user has ALREADY told you which section by attaching a screenshot — your job is to visually match, silently.",
|
|
767
|
+
"NEVER list out all sections in the file to the user. The section inventory (list_section_renders) is for YOUR EYES ONLY for visual matching.",
|
|
768
|
+
"If the user pasted a Figma URL whose node-id is the WHOLE homepage (a tall frame containing many stacked sections) but attached a screenshot of just ONE section → ignore the node-id, use list_section_renders to find the section that matches the screenshot, and proceed. Do NOT ask. Do NOT offer choices.",
|
|
769
|
+
"If the user attached NO screenshot AND did NOT explicitly ask for full-design conversion → ask ONCE: 'Please attach a screenshot of the specific section you want me to convert.' Then stop and wait. Never proceed by guessing or by listing options.",
|
|
770
|
+
"ONE SECTION PER REQUEST. Convert exactly the one section the screenshot shows. Do not also convert neighbours. Do not preview other sections. Do not ask 'should I also do X'.",
|
|
771
|
+
"IGNORE FIGMA SELECTION CHROME in screenshots. If the user's screenshot shows a thin solid bright-blue (or purple/pink) outline around a frame, blue corner-resize handles, blue rotation handles, or a small blue tag with a frame name floating above — those are Figma's editor UI showing the selected layer, NOT part of the design. Do NOT render that outline, do NOT add a blue border to the section, do NOT include the frame-name tag. Treat the selected frame as if it were just visible content with no chrome.",
|
|
772
|
+
"IGNORE INTER-SECTION GAPS that come from Figma canvas spacing. White space between sections on the Figma canvas is canvas padding, not design margin. Do NOT add margin-top / margin-bottom to <section>. Sections on the same WordPress page must sit flush — vertical rhythm comes from each section's own padding-top / padding-bottom, never from outside margins.",
|
|
773
|
+
"WHEN A PRODUCT LISTING IS PUSHED, YOU MUST ASK THE SINGLE-PRODUCT-TEMPLATE QUESTION (mandatory, not a soft suggestion): IMMEDIATELY after any successful push that created WC products (the response shows newly-created products OR data-sz-post-type='product' was in the HTML), call get_wc_single_template. If mode is unset OR 'default' AND no template_id, you MUST end your message to the user with: 'Your products are now live! How should the single product page (the page customers see when they click a product) look? (A) Use our polished default — works out of the box, professional layout, recommended. (B) I'll convert my own Figma single-product design later — we keep the default running until you share it. (C) Generate one matching my brand — I sample colors and fonts from your products and create a matching layout.' Then STOP and wait for the user's answer. Call set_wc_single_product_template once they pick. Do NOT skip this question — it's how Option B and Option C get unlocked. Do NOT batch it with other questions or hide it inside a long summary; it must be its own clear ask.",
|
|
774
|
+
],
|
|
775
|
+
two_modes: {
|
|
776
|
+
SINGLE_SECTION: "DEFAULT. User shares one screenshot of one section. Use the SINGLE-SECTION workflow below.",
|
|
777
|
+
FULL_DESIGN: "ONLY when the user EXPLICITLY asks for the whole design / whole homepage / every section. Use the FULL-DESIGN workflow below.",
|
|
778
|
+
},
|
|
779
|
+
single_section_workflow: [
|
|
780
|
+
"1. require_screenshot — The user MUST share a screenshot of the section. THE SCREENSHOT IS THE SECTION (per §0.3.AL).",
|
|
781
|
+
"2. read_rules — Call get_conversion_rules(). Pass section_type when the section matches a known pattern (slider/accordion/pricing/form). Otherwise call with no section_type for universal rules. No requirement to categorise.",
|
|
782
|
+
"3. list_section_renders — Call list_section_renders(figma_url, figma_token). Returns PNG previews of every candidate section. VISUALLY compare each render to the user's screenshot YOURSELF and pick the one that matches. DO NOT show this list to the user. DO NOT ask the user to pick. DO NOT mention 'I found N sections' — just silently match and continue. If no render matches the screenshot with reasonable confidence, ask the user ONCE for a clearer screenshot — never offer them a menu.",
|
|
783
|
+
"4. prepare_section_SCOPED — Call prepare_section(figma_url, figma_token, section_bbox=<bounding_box from step 3>). Returns values scoped ONLY to that section's visual region — no values from other sections appear in the response. Pure per-section data.",
|
|
784
|
+
"5. match_values — Match step 4's scoped values against what's visible in the user's screenshot. Easier than before because the response is small and focused.",
|
|
785
|
+
"6. generate_html — LAYOUT from screenshot, VALUES from step 5, MARKUP from step 2 (specialized contracts where they apply, semantic HTML for everything else).",
|
|
786
|
+
"7. push — create_page(title, html, figma_data: {section_bg, text_nodes from step 5}).",
|
|
787
|
+
"8. check_detection — Read response's detection + detection_warning. If detection_warning is set (is_header / is_footer unexpectedly), re-split per §0.3.K and re-push.",
|
|
788
|
+
"9. handle_warnings — If figma_text_missing_from_html is non-empty, dropped some text; call push_section_to_page with replace_block_index=0 to fix in place.",
|
|
789
|
+
"10. report — Return page_url + edit_url + offer start_fix_loop for tweaks.",
|
|
790
|
+
],
|
|
791
|
+
full_design_workflow: [
|
|
792
|
+
"A. read_universal_rules — Call get_conversion_rules() with no section_type → loads §0 universal rules. Apply to every section.",
|
|
793
|
+
"B. list_section_renders — Call list_section_renders(figma_url, figma_token) ONCE. Returns PNG previews of every candidate section in the file. Use these to identify which section in the file corresponds to each visual section in the user's screenshot.",
|
|
794
|
+
"C. identify_sections_in_screenshot — Visually scan the user's page screenshot from TOP to BOTTOM. List the distinct VISUAL sections in order. Don't try to box every section into one of 22 predefined types — just describe what you see: 'header with logo+nav', 'hero with headline+CTAs+bg image', 'a row of 3 cards with icons and text', 'a custom split section with image on left and form on right', 'footer with columns'. Whatever the user designed is what you convert — your job is to render it faithfully, NOT to force it into a category. Tell the user the list before pushing: 'I see 7 sections: 1) Header, 2) Hero, 3) <whatever section 3 is>, 4) <whatever section 4 is>, 5) <…>, 6) <…>, 7) Footer. Pushing now.'",
|
|
795
|
+
"D. push_in_order — Push each identified section in screenshot order. Every section becomes a SiteZen Section block (the SAME block type) — no per-section restrictions on what HTML you can write. Routing rules:",
|
|
796
|
+
" • HEADER (logo+nav at the very top of the page): call create_header_footer(template_type='header', title='<Site> Header', html=<header markup with <header> tag>). Plugin stores site-wide.",
|
|
797
|
+
" • FIRST PAGE SECTION (the one right below the header): call create_page(title=<user's title>, html=<this section's HTML, with <div class='sz-nav-spacer'> at top to reserve nav space>, figma_data). Capture the returned page_id.",
|
|
798
|
+
" • EVERY SUBSEQUENT PAGE SECTION (whatever they are — hero, features, custom split layouts, CTAs, stats, testimonials, pricing, anything else the design contains): call push_section_to_page(page_id=<captured>, html=<that section's HTML>, figma_data). Each becomes its own SiteZen Section block on the page, in order. The plugin auto-detects what's inside (slider markers, accordion, forms, listings, etc.) and wires up frontend JS automatically.",
|
|
799
|
+
" • FOOTER (logo+columns+copyright at the very bottom): call create_header_footer(template_type='footer', title='<Site> Footer', html=<footer markup with <footer> tag>). Plugin stores site-wide.",
|
|
800
|
+
"E. per_section_quality — For EACH section identified in step C: (a) visually match the section to one of step B's renders to get its section_bbox; (b) call prepare_section(figma_url, figma_token, section_bbox=<that bbox>) to get scoped values for ONLY that section; (c) generate HTML using that section's screenshot region + the scoped values + universal rules. Scoped values eliminate cross-section pollution. If the section happens to be a known pattern with specific plugin contracts (slider/pricing/stats/accordion/form), use those contracts so frontend JS attaches. For custom sections, just write semantic HTML matching the design.",
|
|
801
|
+
"F. check_each_detection — Each push response includes detection + detection_warning. The detection block tells you what the plugin auto-recognised in your HTML (has_slider, has_accordion, has_form, etc. — these activate the corresponding frontend JS). If detection_warning is set (is_header/is_footer came back unexpectedly), re-split per §0.3.K and re-push that ONE section.",
|
|
802
|
+
"G. report_full_page — When all sections are pushed, return: { page_url, edit_url, header_template_id, footer_template_id, sections_pushed: [{visual_description, block_index, detection}], any warnings }. Use visual_description (e.g. 'split layout: form on right, copy on left') rather than forcing a section_type label.",
|
|
803
|
+
],
|
|
804
|
+
critical_rules_for_both_modes: "See critical_rules below — they apply to single AND full conversions. The full-design mode adds ONE more rule: push sections in TOP-TO-BOTTOM order so block_index matches visual order, and ALWAYS route headers/footers to create_header_footer (never create_page) so the plugin auto-activates them site-wide.",
|
|
805
|
+
critical_rules: [
|
|
806
|
+
"USER'S SCREENSHOT WINS for layout (composition, where things go, what hierarchy). Per universal rule §0.3.AL.",
|
|
807
|
+
"FIGMA JSON WINS for exact values (text content, hex codes, font names, image URLs). Per universal rule §0.3.A.",
|
|
808
|
+
"PLUGIN CONTRACTS WIN for markup (sz-* classes, data-sz-* attributes). Per CONVERSION_RULES.md line 713.",
|
|
809
|
+
"NEVER depend on the Figma layer panel structure. Don't read layer names. Don't 'pick a frame'. The screenshot is the section.",
|
|
810
|
+
"Match prepare_section's values to what's visible in the screenshot — don't invent text, colors, fonts, image URLs, padding, gap, or layout shape. ALL of these come from prepare_section as values (no structure dependency).",
|
|
811
|
+
"LAYOUT DISCIPLINE: prepare_section.layout_values contains the EXACT padding/gap/dimensions/alignment from autolayout frames. Match the screenshot's spacing to entries in this list (sorted largest-frame-first). Use the values directly: padding-top: <paddingTop>px; gap: <itemSpacing>px; flex-direction: row if layoutMode=HORIZONTAL else column. NEVER guess spacing when these values exist. NEVER use multiples of 8 'because that's a common scale' — use the EXACT Figma values.",
|
|
812
|
+
"IMAGE SCALE DISCIPLINE: prepare_section.image_scale_hints tells you how each image was meant to render. FILL/CROP → object-fit:cover. FIT → object-fit:contain. TILE → background-repeat. background-fill kind means it's a full-bleed background; content-image kind means it sits inside a card or content slot.",
|
|
813
|
+
"IMAGE DISCIPLINE: <img src=…> values MUST come from prepare_section.image_assets[].url ONLY. NEVER use Pexels/Unsplash/iStock/external CDN URLs — those carry watermarks and break licensing. If a needed image isn't in image_assets, ask the user for it; don't substitute.",
|
|
814
|
+
"VECTOR DISCIPLINE: For decorative shapes (waves, arrows, scroll indicators, logos, icons, brand marks), use prepare_section.vector_svgs[].svg DIRECTLY — paste the SVG markup into the HTML. NEVER write <svg> from scratch; the real Figma vector is in the response.",
|
|
815
|
+
"EDGE TRANSITION SHAPES (universal): Many designs use a wave/curve/blob at the very top or bottom of a section that visually flows into the next section. These show up in vector_svgs even when they sit slightly outside the section's strict bbox (the extractor uses an expanded bbox to catch them). When the screenshot shows a wave/curve at the section edge, find the matching vector_svgs entry (look at its dimensions — edge waves are typically >70% of section width and short) and place the inline SVG at the bottom (or top) of the section root with position:absolute; left:0; right:0; bottom:0 (or top:0); width:100%; height:auto; pointer-events:none. Never replace these with a flat coloured rectangle.",
|
|
816
|
+
"OVERLAP DISCIPLINE (universal): When prepare_section.overlap_hints is non-empty, the design has elements that visually bleed past the section's edges (cards overlapping into the next section, hero photos hanging off the bottom, decorations breaching the boundary). For each hint entry, locate the matching element in your HTML and apply the corresponding negative margin (overflow_bottom_px:N → margin-bottom:-Npx; overflow_top_px:N → margin-top:-Npx). Also set position:relative; z-index:2 on the overflowing element so it sits ABOVE the next section. Without this, every section is a hard rectangle and the design loses its layered feel.",
|
|
817
|
+
"SLIDER / CAROUSEL (universal — MANDATORY when slider chrome is visible, powered by SiteZen's built-in carousel engine): If the screenshot shows ANY of these — left/right arrow buttons beside repeating content, dots/bullets under a row of cards, one card with a peek of the next, a centered larger card with smaller side cards (center mode), a 'Testimonials / Reviews' heading above a quote with arrows, a team/logo/product carousel — render it as a SiteZen slider. The plugin's own dependency-free carousel (no external library, so it never conflicts with the theme or other plugins) provides smooth scroll-snap sliding, drag/swipe, responsive columns, autoplay, and seamless loop. Required markup: <div class='sz-slider' data-sz-slider [config attrs]><div class='sz-slides'><div class='sz-slide'>…card 1…</div><div class='sz-slide'>…card 2…</div>…</div></div>. CONFIG ATTRIBUTES (set on the .sz-slider root, read what the design shows): data-sz-slider-per-view='N' (how many full cards are visible across desktop — count them in the screenshot; testimonials often 1, product/team often 3-4), data-sz-slider-per-view-tablet='2', data-sz-slider-per-view-mobile='1', data-sz-slider-center='1' (ONLY when the design shows a centered active card with peeking neighbours), data-sz-slider-loop='1' (infinite/seamless wrap), data-sz-slider-autoplay='1' (auto-advance) — SET BOTH loop='1' AND autoplay='1' when the design shows a continuously-scrolling carousel that never stops or jumps (testimonial walls, logo strips, review marquees like premium addon carousels); that combination produces a smooth seamless marquee. Use autoplay='1' alone (loop='0') for a step carousel that snaps back at the end. Optional data-sz-slider-marquee-speed='0.6' tunes the continuous scroll speed (px/frame), data-sz-slider-gap='<px>' (gap between cards — use the EXACT value from the design / prepare_section layout_values, NOT a guess; omit it to let the design's own CSS gap apply), data-sz-slider-arrows='1|0', data-sz-slider-dots='1|0'. DON'T hand-write arrows/dots — the engine injects working, styled ones from the data-sz-slider-arrows/dots flags. Each card goes in a <div class='sz-slide'>. For PRODUCT slider listings, ALSO set data-sz-post-layout='slider' on the listing wrapper. This same markup works for testimonials, team members, logos, post listings, and any generic carousel — one contract for all.",
|
|
818
|
+
"GRACEFUL DEGRADATION — NEVER ABANDON (universal): The bar stays PIXEL-PERFECT. For the 99.9999% of inputs everything must be auto-correct: text exact, font exact, colors exact, spacing exact, images embedded, layout exact. Graceful degradation is the SAFETY NET for the rare cases where a specific upstream operation actually fails. It is NEVER an excuse for sloppy output. When the MCP tool itself reports a real failure (an entry in prepare_section.failed_image_assets, a video block where no src exists, a custom font that isn't standard, a form/map/embed block where the user hasn't provided a source) — generate the correct block markup with a PLACEHOLDER, never skip the spot and never invent a value. Required attributes on the placeholder element: class='sz-asset-needed', data-sz-asset-type='image|video|map|form|font|audio|embed|download|icon|lottie', data-sz-spot='<short human label>', data-sz-original='<original font/asset name if relevant>'. KEEP THE FULL VISUAL STYLING INTACT (size, position, surrounding markup) — only the missing piece is the placeholder. Example for a failed image: <img class='sz-asset-needed' data-sz-asset-type='image' data-sz-spot='hero photo' alt='Hero photo' style='width:100%;aspect-ratio:1920/1080;background:linear-gradient(135deg,#eee,#ddd);object-fit:cover'>. Example for a video block: <div class='sz-asset-needed sz-video-block' data-sz-asset-type='video' data-sz-spot='demo video' style='position:relative;aspect-ratio:16/9'><img src='<cover image from image_assets>' style='width:100%;height:100%;object-fit:cover'><button class='sz-play-btn' aria-label='Play'>▶</button></div>. The push response will return pending_assets[] — read it and show the user a numbered list at the end ('3 spots need your input: 1) hero photo — open editor, click yellow image, upload or paste URL; 2) ...'). Universal rule: never claim 'done' if pending_assets is non-empty — always surface the list verbatim.",
|
|
819
|
+
"FONT FALLBACK (universal): When fonts_used contains a custom typeface that isn't a common Google Font (i.e. not Inter, Roboto, Open Sans, Poppins, Montserrat, Lato, Raleway, Nunito, Source Sans, etc.) AND not a system font — use the closest matching Google Font as the actual CSS font-family (e.g. 'Acme Display' → 'Bebas Neue' or 'Oswald' depending on shape), but ALSO add class='sz-asset-needed' + data-sz-asset-type='font' + data-sz-original='<the real Figma font name>' to the heading/paragraph so the editor highlights it and the push response surfaces it in pending_assets. Tell the user at the end: '<font name> isn't in our library — upload it via SiteZen → Custom Fonts to switch over automatically.'",
|
|
820
|
+
"BACKGROUND POLICY (universal, strict order — DO NOT skip levels): For every section's background, walk this decision tree in order and pick the FIRST level that's faithful to the design. NEVER invent values, NEVER use the user's screenshot, NEVER use section_render_url as a URL. Order: (1) SOLID COLOR — if the bg is a single flat color, use that hex from colors_used. (2) CSS GRADIENT — if prepare_section.gradients has an entry whose bbox matches the section's bbox, paste its .css value verbatim as background:<css>. Never construct a gradient by hand when gradients[] has the real one. (3) INLINE SVG DECORATIONS — for organic shapes (waves, blobs, dashed circles, arches, custom patterns), use prepare_section.vector_svgs entries — paste the .svg markup as an absolutely-positioned layer (position:absolute; pointer-events:none) so it overlays the bg. Stack multiple SVGs as needed. (4) CONTENT-FREE PHOTO from image_assets — only when the bg is genuinely a photographic scene with NO TEXT, NO BUTTONS, NO UI in it (e.g. a hero photo of a beach behind a separate text overlay). Use the SPECIFIC image_assets entry whose dimensions match the bg area. NEVER use section_render_url because it contains all the section's content baked in — using it would flatten the page into a static image. (5) Visual verification — view section_render_url (REFERENCE only, never as src/url) to confirm your CSS+SVG+content layers match the design.",
|
|
821
|
+
"VISUAL VERIFICATION (universal): Before finalising the HTML for any section with a complex background, decorative shapes, or unusual composition — view prepare_section.section_render_url yourself. The user's screenshot is the LAYOUT source of truth (§0.3.AL), but section_render_url is the HIGH-RES visual source of truth for fidelity decisions. If a shape, gradient, or background looks different in section_render_url than in your draft HTML's mental model, fix the HTML before pushing.",
|
|
822
|
+
"GSAP SCROLL ANIMATIONS (universal — offer after a push, ONLY where it adds value): Figma is static so animations are NEVER auto-detected — they come from the user's choice. AFTER a successful push, JUDGE whether the section can meaningfully take scroll animation. ASK only when YES. Sections where YES: hero (text reveal / fade-up entrance), stats or number counters (count-up), feature/card grids (stagger fade), galleries or large images (parallax / scale-in), CTA banners (fade-up), steps/process (sequential reveal), about sections with rich content. SKIP SILENTLY (do not ask) for: plain footers, nav bars, a single line of text, tiny utility sections, and sections that already animate (sliders/marquees). When YES, ask: 'Want me to add scroll animations to this section?' If the user says yes, present a dropdown (use the AskUserQuestion tool) of animation options SPECIFIC TO THIS SECTION — e.g. hero: [Text reveal, Fade up, Zoom in, Parallax background, None]; stats: [Count up numbers, Fade up, None]; card grid: [Stagger fade-up, Scale in, Slide in, None]; gallery: [Parallax, Scale on scroll, Fade up, None]. ALWAYS include 'None' so they can back out. Then ask a second dropdown for intensity: [Subtle, Medium, Strong]. Then RE-GENERATE the same section HTML with the animation attributes added and REPLACE the existing block in place via push_section_to_page(replace_block_index=<that block's index>) — never create a duplicate or a new page.",
|
|
823
|
+
"GSAP MARKUP (universal — how to add the chosen animation): Add data-sz-gsap='<preset>' to the element(s) that should animate, plus data-sz-gsap-intensity='subtle|medium|strong' (from the user's pick). FULL PRESET CATALOG (the plugin ships all of these — pick the right one per element): ENTRANCES (one-shot on scroll-in): 'fade-up', 'fade-down', 'fade-in', 'slide-left' (enters from right), 'slide-right' (enters from left), 'slide-up', 'slide-down', 'zoom-in', 'zoom-out', 'scale-in' (back-ease pop), 'flip-x' (card flip on X axis), 'flip-y' (flip on Y axis), 'rotate-in' (spin+scale), 'blur-in' (un-blurs into focus), 'skew-in' (editorial de-skew), 'bounce-in'. GROUPS (put on a CONTAINER — its direct children animate one-by-one, e.g. a card grid / list wrapper): 'stagger-fade', 'stagger-scale', 'stagger-left'. TEXT (put on a heading/paragraph): 'text-reveal' (word-by-word), 'text-chars' (letter-by-letter), 'text-lines' (line-by-line mask), 'text-typewriter' (types out). SCROLL-LINKED (scrubbed to scroll position): 'parallax' / 'parallax-strong' (on an image/bg layer), 'reveal-mask' (clip-path wipe), 'draw-line' (grows an underline/left-border — put on the thin line element), 'progress-fill' (fills a bar's width 0→100, supports data-sz-gsap-to='80' for 80%), 'rotate-scroll', 'scale-scroll', 'horizontal-scroll' (section is the pinned viewport; its FIRST CHILD is the wide track that scrolls sideways — use for panel galleries), 'pin' (pins while section scrolls), 'pin-reveal' (pins, then reveals its children in sequence while pinned). SPECIAL: 'counter' (number counts up; supports data-sz-gsap-prefix / data-sz-gsap-suffix / data-sz-gsap-to), 'float' / 'pulse' / 'spin' (gentle INFINITE ambient loops — use sparingly on badges/icons/accents), 'motion-path' (animates element along an SVG path; set data-sz-gsap-path='#svgPathId'), 'flip-layout' (advanced Flip transition; set data-sz-flip-class='<class applied on scroll-in>'). Optional shared attrs: data-sz-gsap-delay='0.2' (stagger entrances), data-sz-gsap-duration='0.8', data-sz-gsap-start='top 85%', data-sz-gsap-stagger='0.1'. Apply the preset to the RIGHT element: hero headline → text-reveal/text-chars; sub-text → fade-up with a small delay; hero image → zoom-in or parallax; a stats row's numbers → counter (one per number); a card grid's wrapper → stagger-fade. The plugin loads GSAP + ScrollTrigger + Flip + MotionPath automatically when any data-sz-gsap is present, respects prefers-reduced-motion, and skips infinite loops for reduced-motion users. Don't hand-write GSAP JS — just add the attributes. NOTE: the dropdown you offer the user should list only a curated FEW options that fit THAT section (plus 'None') — the full catalog is for YOUR selection, not a menu to dump on the user.",
|
|
824
|
+
"STICKY HEADER EFFECTS (universal — offer after converting a HEADER/nav section, never auto-apply): Figma is static so sticky behaviour is the user's choice. AFTER pushing a header/nav, ASK: 'Want the header to stick to the top when scrolling?' If yes, present a dropdown (AskUserQuestion) of behaviours: [Shrink & restyle on scroll, Slide down when stuck, Fade in when stuck, Hide on scroll-down / show on scroll-up, None]. Optionally offer extras as a multi-select: [Add shadow when stuck, Translucent + blur background, Swap logo image when stuck]. Then RE-GENERATE the header HTML with the attributes and REPLACE the block in place via push_section_to_page(replace_block_index=<header block index>). MARKUP: put data-sz-sticky on the header root element, plus data-sz-sticky-effect='shrink|slide-down|fade|hide'. Optional: data-sz-sticky-offset='<px>' (scroll distance before it engages — default the header's own height), data-sz-sticky-hide-on-down='1' (combine smart-hide with any effect), data-sz-sticky-shadow='1', data-sz-sticky-blur='1'. Set the stuck background colour by adding inline style='--sz-stuck-bg:<hex>' on the header (use the design's header colour). For the SHRINK effect, add class='sz-sticky-shrinkable' to the logo so it scales down when stuck. LOGO SWAP: add data-sz-logo-swap='<url of alt logo>' to the logo <img> — it swaps while stuck (use only if the user provides/the design shows a second logo; else surface it as a pending asset). HEADER REPLACE ON SCROLL: wrap the default header markup in <div class='sz-header-default'> and the alternate (stuck) markup in <div class='sz-header-stuck'> — the plugin shows the stuck one only when stuck. ABOVE-HEADER BAR: if the design has an announcement/contact strip above the nav that should scroll away while the nav sticks, put data-sz-above-header on that strip and data-sz-sticky on the nav below it. The plugin auto-loads its script only when these markers are present and respects prefers-reduced-motion. Don't hand-write scroll JS — just add attributes.",
|
|
825
|
+
"PAGE ROLES (universal — make converted pages go live with zero manual setup): When the design clearly represents a SPECIAL page, after create_page call set_page_role(page_id, role) so the plugin wires the WordPress/WooCommerce setting automatically — the user should NEVER have to touch Settings → Reading or WooCommerce → Advanced. Detect role from the design + the page name/intent (ask the user only if genuinely ambiguous): a hero/landing 'Home' design → role 'home' (becomes the site front page); a posts/blog/magazine listing → 'blog'; a products archive / 'Shop' → 'shop'; a cart design → 'cart'; a checkout design → 'checkout'; a login/account/dashboard design → 'myaccount'. For cart/checkout/myaccount the plugin injects the WooCommerce shortcode into the page so the FUNCTIONAL widget renders inside the custom design — so build the page's surrounding design (header area, banners, trust badges, layout, brand styling) pixel-perfect and leave a clear content area where the WC cart/checkout/account widget will sit. HONESTY: the cart/checkout/account FUNCTIONAL widget is WooCommerce's own (real totals, payment, login) styled to match — surrounding design is pixel-perfect, the widget is brand-styled (near-perfect for conventional designs, not a 1:1 rebuild of exotic checkout structures). Tell the user this plainly; never promise a pixel-perfect custom checkout structure. Only set a role when confident; a normal content page needs no role.",
|
|
826
|
+
"HEADER ENGINE (universal — MANDATORY for ANY header/nav conversion): A converted header must be RESPONSIVE and FUNCTIONAL, not a static pixel-frozen strip. Follow ALL of these: (1) LAYOUT — build the header row with flexbox (display:flex; align-items:center; gap; flex-wrap:wrap), NEVER with Figma's absolute canvas pixel widths/positions. The header root is width:100%; the inner row is max-width:<wide_size_px from get_site_widths>; margin-inline:auto; padding:0 24px. Logo / search / actions are flex children that shrink gracefully. This is what fixes 'desktop spacing bad' and 'responsive too bad'. (2) NAV MENU — output the menu as a real list: <nav data-sz-nav data-sz-nav-root><ul><li><a href='/'>Home</a></li><li class='sz-has-sub'><a href='/shop/'>Product</a><ul><li><a href='…'>Sub</a></li>…</ul></li>…</ul></nav>. Put data-sz-nav on the <ul> (or <nav>) wrapper and data-sz-nav-root on the row that should host the hamburger. The plugin AUTO-INJECTS a hamburger below 1024px, turns the menu into a mobile dropdown, gives nested <ul> submenus tap-to-expand, and shows hover dropdowns on desktop — you only need to emit the nested list markup. Use the design's nav labels/links; point them at real pages when known (/, /shop/, /about/, /contact/). NEVER emit nav as plain text spans — always a <ul><li> list, or the mobile toggle + dropdowns can't work. (3) SEARCH BAR — emit a REAL WooCommerce product search form, never a contact form: <form role='search' method='get' action='<site home url>'><input type='search' name='s' placeholder='Search Products'><input type='hidden' name='post_type' value='product'><button type='submit'>…</button></form>. Mark it data-sz-search so the plugin does NOT hijack it into a [sitezen_form]. (4) HEADER CART — <a class='sz-header-cart' href='/cart/'><svg…cart icon…><span class='sz-header-cart__count' data-sz-cart-count>0</span></a>; the plugin fills the live count + fragments. (5) HEADER WISHLIST COUNT — the contract DOES exist: <a class='sz-header-wishlist' href='/wishlist/'><svg…heart…><span class='sz-header-wishlist__count' data-sz-wishlist-count>0</span></a>; the plugin paints the live liked-count and opens the wishlist drawer. (6) STICKY — if the user wants it, add data-sz-sticky per the STICKY HEADER EFFECTS rule. (7) ICONS — use the design's inline SVG for cart/heart/contact/headphone icons; if an icon is only available as a raster image asset that can't embed in a header push, inline a matching generic SVG and surface it in pending_assets so the user can swap it. (8) HONESTLY-UNSUPPORTED bits — a LANGUAGE switcher (ENG▾) and a CURRENCY switcher (USD▾) are NOT SiteZen features; they require WPML/Polylang (multilingual) and a multi-currency plugin. Render them as styled static controls that match the design, but TELL the user at the end: 'The language and currency switchers are visual only — they need WPML/Polylang + a multi-currency plugin to actually function; SiteZen can't wire those.' A full mega-panel (a big categories flyout) renders as styled markup but only becomes a live category menu if bound to a real WP menu. Always end a header conversion by listing which parts are live vs visual-only, in one short block.",
|
|
827
|
+
"DYNAMIC MENUS & SEARCH (universal — use whenever a header/section shows a categories dropdown, a 'All Categories' panel, a nav that should reflect the real site menu, or a search bar): The plugin fills EMPTY marked containers with LIVE data at render time, so the menu/categories auto-update when the site changes — never hand-list categories statically. CONTRACT — emit the container EMPTY (this is required; the plugin injects the items): (1) CATEGORIES / MENU LIST — <ul data-sz-nav-source='categories'></ul>. source values: 'categories' or 'auto' (product categories if WooCommerce is active, else blog post categories — this is how the SAME design works on a store OR a blog), 'wc-categories' (force product cats), 'post-categories' (force blog cats), 'wp-menu:Primary' (a real WP menu by name or theme-location). Options on the same element: data-sz-source-depth='1' (top level only) or omit for full nesting, data-sz-source-parent='<term id/slug>', data-sz-source-limit='20', data-sz-source-orderby='name|count'. For the orange 'All Categories' panel: put this empty <ul data-sz-nav-source='categories'> as the nested submenu inside the panel's <li class='sz-has-sub'> within a data-sz-nav nav — then it's BOTH live AND opens via the nav engine (hover on desktop, tap on mobile). (2) UNIVERSAL SEARCH — <form data-sz-search data-sz-search-type='auto'><input type='search' name='s' placeholder='Search…'><select data-sz-search-cats='auto'></select><button type='submit'>…</button></form>. data-sz-search-type: 'auto' (product search if WooCommerce active, else post search — covers BOTH store and blog designs with one contract), 'product', 'post', or 'any' (search everything). The <input> MUST have name='s'. The optional EMPTY <select data-sz-search-cats='auto'></select> is auto-filled with the matching taxonomy terms and given the correct query-var name (product_cat / category_name) so a chosen category scopes the search. The plugin forces method=get, a real action URL, and the right post_type — so it actually searches, and it is NOT hijacked into a contact form (data-sz-search exempts it). SCENARIO NOTES so you pick correctly: a store header with 'All Categories' + product grid → use 'categories'/'product' (resolves to product_cat). A blog/magazine header → 'post-categories'/'post'. A site with NO WooCommerce → 'auto' safely falls back to blog categories, so you can always use 'auto' when unsure. If the taxonomy is empty (new site, no categories yet) the dropdown still renders with just 'All Categories' and fills in as the user adds categories — no breakage. Never emit a hardcoded category list; always use the empty live container so 'add a new category → it appears' works.",
|
|
828
|
+
"TICKET SIDEBAR (universal — only when the user explicitly asks for a floating support/feedback/help tab, or the design shows an edge-pinned tab): An edge-pinned tab that stays fixed while scrolling and slides open an accordion panel on click. NEVER auto-add it — build it only on request or when the design clearly contains it. MARKUP: <aside class='sz-ticket' data-sz-ticket data-sz-ticket-side='right' data-sz-ticket-pos='middle'><button class='sz-ticket-tab' type='button'>Support</button><div class='sz-ticket-body'><div class='sz-ticket-head'><span class='sz-ticket-title'>How can we help?</span><button class='sz-ticket-close' type='button' aria-label='Close'>×</button></div><div class='sz-ticket-item'><button class='sz-ticket-q' type='button'>Question?</button><div class='sz-ticket-a'><p>Answer…</p></div></div><!-- more items --></div></aside>. ATTRS: data-sz-ticket-side='right|left' (which edge), data-sz-ticket-pos='middle|top|bottom' (vertical position), data-sz-ticket-open='1' (start expanded). Theme it by adding inline style='--sz-ticket-accent:<hex>;--sz-ticket-bg:<hex>' on the .sz-ticket root (pull the colours from the design). The plugin handles open/close, Escape-to-close, outside-click-close, and the inner one-at-a-time accordion. The script loads only when data-sz-ticket is present. ASK the user for: which side (right/left), the tab label, and the items (Q/A pairs) if they aren't already in the design.",
|
|
829
|
+
"SPACING FINE-TUNE OFFER (universal — after EVERY push): Card/grid/section internal spacing is derived from the design's extracted values (auto-layout padding + itemSpacing) and the screenshot. When the design uses absolute positioning (no auto-layout), some spacing is computed from coordinates and can be slightly off — this is the one place a first pass may not be exact. So ALWAYS end a conversion message with a short, friendly offer: 'I matched the spacing from your design as closely as possible. If any spacing or position looks off, just tell me the exact value and I'll set it precisely — e.g. \"card padding 24px\", \"gap between title and price 8px\", \"image height 220px\".' Keep it ONE line, not a wall of text. THIS IS NOT OPTIONAL AND APPLIES TO EVERY PUSH WITHOUT EXCEPTION — including WooCommerce product grids, product sliders, related-products listings, and re-conversions of the same section. Do not skip it just because the section is a WC listing or a repeat run; the card spacing/image-height/gap offer is exactly what users fine-tune on product cards.",
|
|
830
|
+
"SPACING FIX LOOP (universal — when the user reports a spacing/position problem): NEVER guess again. If the user says spacing is off but gives no number, ASK for the specific value(s) with a tight, numbered question listing ONLY the spots in question — e.g. 'Quick numbers so I can match it exactly: (1) card padding? (2) gap between image and title? (3) gap between title and price? (4) space below the button?' The user replies with just the numbers. Then apply them surgically via start_fix_loop — edit ONLY those spacing values in the pushed HTML, never regenerate the whole section. If the user volunteers a value directly ('the gap should be 16px'), apply it immediately via start_fix_loop. This turns vague 'design is off' into a precise, one-shot correction.",
|
|
831
|
+
"USE EVERY EXTRACTED VALUE (universal, strict): prepare_section returns 17 categories of values: text_nodes (with letterSpacing/lineHeight/textDecoration/textCase/textAlign/opacity), colors_used, fonts_used, gradients (CSS), effects (shadows/blurs as CSS), corner_radii (per-corner), strokes (border CSS), opacities (node-level), image_assets, vector_svgs, layout_values, image_scale_hints, overlap_hints, responsive_hints, failed_image_assets, section_render_url. EVERY non-empty entry MUST be applied to the matching element in the HTML — they were extracted at real cost from Figma and represent the designer's actual values. Silently dropping a shadow, a letter-spacing, a per-corner radius, or a dashed border is exactly the 'feels different' bug we are fighting. Audit your draft HTML against the response before pushing: every effect → on an element; every corner_radii entry → on the matching element; every text_node with letterSpacing → on the matching text; every gradient → as the matching bg. No silent drops.",
|
|
832
|
+
"RESPONSIVE FROM REAL INTENT (universal): Use prepare_section.responsive_hints to write the @media (max-width:1024px) and @media (max-width:768px) blocks. Each entry has the designer's actual constraint (stretch, scale, center, anchored). Translate it: 'stretches with parent' → width:100% / flex:1; 'scales with parent width' → percentage width; 'horizontally centered' → margin-inline:auto. NEVER guess responsive behaviour as 'stack everything on mobile' when responsive_hints has real data — that's the lazy default and it's wrong half the time.",
|
|
833
|
+
"DEFAULT RESPONSIVE LOGIC (universal — market-standard behaviour per element type, ALWAYS apply even when responsive_hints is thin): responsive_hints refines, but EVERY section must still ship the standard responsive behaviour the rest of the market uses by default — never output a desktop-only layout. Breakpoints: tablet @media (max-width:1024px), mobile @media (max-width:767px). Per element type, the default rules: CARD/PRODUCT GRID → desktop columns as designed (e.g. 4), tablet 2, mobile 1 (grid-template-columns or flex-basis). HERO / 2-COLUMN (text+image) → side-by-side on desktop, STACK to one column on mobile (flex-direction:column / grid 1fr), image below or above text per design order, reduce hero font-size ~25-35% on mobile. NAV/HEADER → collapses to the data-sz-nav hamburger ≤1024 (already handled by the engine). FEATURE ROW (3-4 items) → 3-4 across desktop, 2 tablet, 1 mobile. STATS/COUNTERS → row on desktop, 2x2 or stacked on mobile. TESTIMONIAL/LOGO SLIDER → reduce per-view (e.g. 3→1) on mobile via the slider's data-sz-slider-per-view-mobile. FOOTER columns → multi-column desktop, 2 tablet, 1 mobile, centered. TYPOGRAPHY → scale down large display/heading sizes on mobile (h1 clamp or a mobile font-size override); body stays readable (min ~15-16px). PADDING/SECTION SPACING → reduce large vertical paddings (e.g. 96px → ~48px) and side padding (→ 16-20px) on mobile. BUTTONS that sit inline → may go full-width on mobile when the design implies it. IMAGES → max-width:100%; height:auto always. These defaults are non-negotiable baseline; refine each with responsive_hints when present. The user can further fine-tune any element per-device in the editor's 📱 Responsive panel (tablet/mobile overrides), but the CONVERSION must already be properly responsive — don't rely on the user to fix it.",
|
|
834
|
+
"FIT THE THEME CONTAINER (universal — kills horizontal scroll at the root): Call get_site_widths ONCE per conversation. It returns content_size_px and wide_size_px — the active theme's actual content widths. Every section root must be width:100% (so background colors / gradients / decorations go edge-to-edge), but the INNER content container must be max-width:<wide_size_px>px; margin-inline:auto. Paragraph/text wrappers use max-width:<content_size_px>px. Pattern: <section style='width:100%; ...'><div style='max-width:1200px; margin-inline:auto; padding:0 24px'><!-- content --></div></section>. NEVER bake Figma's canvas pixel width (typically 1920) onto the section or any container — that always causes horizontal scroll on smaller theme containers. The width values from get_site_widths are the user's theme's actual layout — using them makes converted sections look native to the site.",
|
|
835
|
+
"WOOCOMMERCE AWARENESS (universal — when WC is detected on the target site): If the converted section shows a grid/list/slider/masonry of product-shaped cards (image + heading + price-shaped element like '$29' or '₹2,499' + button labelled 'Add to Cart'/'Buy Now'/'Shop Now'), the listing markup MUST use data-sz-post-type='product' (not 'sz_news' or anything generic). The plugin's WooCommerce bridge will then create REAL WC products from each card's data — title, image, price, sale price, SKU — so the listing immediately becomes a working storefront on the user's site. Required attributes on the listing root: data-sz-post-listing data-sz-post-type='product' data-sz-post-layout='grid|list|slider|masonry' data-sz-post-paginate='off|numbered|loadmore|infinite' data-sz-post-filter='off|category|attribute|price' data-sz-post-sort='off|price-asc|price-desc|newest|popularity|rating' data-sz-post-search='1|0'. Required per-card attributes: data-sz-card (on the card root), and INSIDE the card: data-sz-card-field='title' on the title element, data-sz-card-field='excerpt' on description, data-sz-card-field='image' on the image, data-sz-card-field='price' on the price element (use the displayed price, e.g. '$29.99' or '₹2,499' — the plugin parses any currency format), data-sz-card-field='sale_price' on a struck-through original price if present, data-sz-card-field='sku' on the SKU if visible. The Add-to-Cart button MUST use class='sz-add-to-cart' (plugin auto-wires it to WC cart). Wishlist heart icons use class='sz-wishlist-toggle' (plugin auto-detects YITH/TI Wishlist and emits their shortcode). Compare buttons use class='sz-compare-toggle'. Star ratings render as a 5-star scale with class='sz-product-rating' (plugin auto-fills from WC's average_rating). Style the button/badges however you want — WC only cares about the data attributes, design is yours.",
|
|
836
|
+
"WC LISTING PAGINATION + FILTERS + SORT + SEARCH (universal): The listing root accepts these data attributes — Claude picks the right values based on what the design SHOWS. (1) data-sz-post-paginate='off|numbered|loadmore|infinite' — when the design shows page-number buttons use 'numbered'; a 'Load more' button use 'loadmore'; nothing visible but a long list use 'infinite' (best UX on mobile). (2) data-sz-post-sort='off|default' — set to 'default' if the design shows a sort dropdown anywhere near the listing; plugin emits a polished <select> with all standard WC sorts. (3) data-sz-post-search='1' — set to '1' if the design shows a search box for filtering products. (4) data-sz-post-filter='off|category|tag' — for category/tag filter pills. (5) data-sz-post-price-filter='1' — set when the design shows a min/max price range filter. (6) data-sz-post-attribute-filter='color,size,brand' — comma-separated list of WC attribute slugs to show as filter pills (use the slugs the user has actually configured in WC → Attributes; common ones are color, size, material, brand). All controls render automatically with sensible defaults; the user's design CSS still wraps them.",
|
|
837
|
+
"WC PRICE TOKENS (universal — avoid the struck-price duplication bug): A product card often shows TWO prices: the CURRENT price prominently (e.g. ₹800.22) and the ORIGINAL struck-through (e.g. ₹1000.66). Map them like this: put {{current_price}} on the main/current price element, and {{original_price}} on the struck-through element (it auto-wraps in <del> and renders EMPTY when the product isn't on sale). DO NOT use {{price}} on the main element when there's ALSO a separate struck element — {{price}} renders WC's full del+ins block, so combined with a separate struck element you get the original shown twice. Rule: SINGLE price element in the design → use {{price}} (renders struck+current together automatically). TWO separate price elements → use {{current_price}} for the main and {{original_price}} for the struck one. For EXTRACTION (so the WC product gets the right regular/sale), still mark the source prices with data-sz-card-field='price' (current) and data-sz-card-field='sale_price' (the struck original) — the plugin maps higher=regular, lower=sale automatically.",
|
|
838
|
+
"WC CARD — UNIVERSAL ENGINE (write the EXACT design, plugin enhances in place): The product card is YOUR design, rendered exactly as written — same wrapper divs, classes, layout order, button styling, badge colors, quick-view pill, everything. The plugin does NOT impose any structure. It only (a) creates a real WC product from the card's data and (b) wires functionality INTO your existing elements without changing them. Follow these rules: (1) Write the FULL card markup exactly as the design shows — keep your wrapper divs (e.g. <div class='rp-box'>, <div class='rp-meta'>), your exact layout order (if the button sits under the image, write it under the image), your card background, your per-card badge with its exact text+color (<span class='badge' style='background:#7F7CF6;color:#fff'>2 Years Warranty</span>), your wishlist heart styling, your Add-to-Cart button styling (icon, border, colors, label), your Quick View pill. (2) The plugin AUTO-DETECTS and WIRES these in place: any <a>/<button> whose text is 'Add to cart'/'Buy now' gets the real WC cart behavior injected (href + product id) while keeping your exact look; any element whose text is 'Quick View' gets the modal trigger injected; the whole card becomes clickable to the product. You do NOT need special classes for these — just write the button/pill with its normal label and design. (3) USE DATA TOKENS ONLY for the values that change per product: {{title}} (product name), {{current_price}} (the price shown; or {{price}} if a single price element), {{original_price}} (struck-through original, empty if not on sale), {{image}} (in the <img src>), {{rating_stars}}. (4) Per-card unique badges = static markup (your exact text+color), NEVER the {{badge_*}} auto-tokens unless the design literally shows generic WC-state badges identical on every card. Full example preserving design: <div data-sz-card class='rp-box' style='background:#F5F5F5;border-radius:14px;padding:18px;position:relative'><span class='rp-badge' style='position:absolute;top:14px;left:14px;background:#7F7CF6;color:#fff;padding:4px 12px;border-radius:20px'>2 Years Warranty</span><button class='rp-wish' style='...'>♡</button><div class='rp-imgbox' style='background:#fff;border-radius:10px'><img src='{{image}}' alt='{{title}}'><button class='rp-cart' style='border:1px solid #ddd;border-radius:8px;padding:10px'>🛒 Add to cart</button></div><div class='rp-meta'><h3 style='...'>{{title}}</h3><div class='rp-price'>{{current_price}} {{original_price}}</div></div></div>. The button keeps its exact style and position; the plugin makes it functional.",
|
|
839
|
+
"WC SHOP + ARCHIVE PAGES (universal): The plugin auto-installs polished default templates for /shop/ and product category/tag archives. They use the same design language as the polished default single-product template. No conversion needed for these — they just work out of the box when WC is active. To customise: convert a Figma 'shop page' design via create_template(template_type='wc_archive_product') and the plugin will swap it in. For category-specific archives, use template_type='wc_archive_<category_slug>'.",
|
|
840
|
+
"WC HEADER CART + WISHLIST (universal): When the design's header shows a CART icon, emit the EXACT icon from the design (use the design's own SVG via vector_svgs, its colors, size, position — NEVER substitute a generic icon) wrapped with class='sz-header-cart', plus a count badge span class='sz-header-cart__count' styled to match the design's badge. The plugin auto-fills the LIVE item count (updates without page reload on every add-to-cart) and opens the cart on click. Same for a WISHLIST/heart icon in the header: class='sz-header-wishlist' + span class='sz-header-wishlist__count', using the design's heart icon. Pattern: <a href='/cart' class='sz-header-cart' data-sz-cart-open='drawer-right'>{design's cart SVG}<span class='sz-header-cart__count'>0</span></a>.",
|
|
841
|
+
"WC CART/WISHLIST OPEN BEHAVIOR — ASK THE USER (universal): The design shows a cart/heart icon but CANNOT convey how it should open — that's the user's choice, not something to guess. So when you convert a design that has a header cart and/or wishlist icon (and the user hasn't already said), ASK ONCE: 'How should the cart open when clicked? (1) Slide-in drawer from the right [most common], (2) Slide-in drawer from the left, (3) Centered popup, (4) Just go to the cart page. And the wishlist?' Map the answer to the data attribute on the icon: data-sz-cart-open / data-sz-wishlist-open = 'drawer-right' | 'drawer-left' | 'popup' | 'page'. Default to 'drawer-right' if the user has no preference. The user can change it later ('make the cart open as a popup') — apply via start_fix_loop by editing just that attribute. The drawer/popup itself is rendered + styled by the plugin; you only set the icon + the open-mode attribute.",
|
|
842
|
+
"WC LISTING LAYOUT VARIANTS (universal — write the right markup for what the design shows): The plugin defaults product listings to FLAT render mode so your wrapper's CSS governs layout. Use these EXACT patterns per layout: (1) GRID: wrap cards in `<div data-sz-post-listing data-sz-post-type='product' data-sz-post-layout='grid' style='display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:24px'>` — set the column min/gap from the design's measurements. (2) LIST: `<div ... data-sz-post-layout='list' style='display:flex;flex-direction:column;gap:16px'>` and each card uses `display:grid;grid-template-columns:200px 1fr;gap:24px` so image goes left, content right. (3) SLIDER: `<div data-sz-post-listing ... data-sz-post-layout='slider' data-sz-slider data-sz-slider-arrows='1' data-sz-slider-dots='1' data-sz-slider-autoplay='0'><div class='sz-slides' style='display:flex;gap:24px;overflow-x:auto;scroll-snap-type:x mandatory;scroll-padding:24px'>` — each card is `<div data-sz-card class='sz-slide' style='flex:0 0 auto;width:280px;scroll-snap-align:start'>`. Plugin auto-attaches scroll JS + creates the prev/next arrows + dot indicators. (4) MASONRY: `<div ... data-sz-post-layout='masonry' style='column-count:3;column-gap:24px'>` and each card uses `break-inside:avoid;margin-bottom:24px`. Mobile @media adjustments are your call — use the responsive_hints from prepare_section to translate the designer's intent. The whole-card-clickable link works the same way in every layout — the plugin injects it at render time.",
|
|
843
|
+
"WC SINGLE PRODUCT PAGE FLOW (when a product listing was just created): After pushing a section with data-sz-post-type='product', call get_wc_single_template to check whether the user has already made their choice. If mode is 'default' or already 'custom'/'auto_generated' with a template_id → say nothing, just continue. If mode is unset OR 'pending_design' AND no template_id → ask the user ONCE per store: 'Your products are live! How should the single product page look? (A) Use our polished default template — works out of the box, recommended (B) I'll convert my own Figma single-product design later — we'll keep the default running in the meantime (C) Generate one matching my brand — we sample colors/fonts from your products and create a matching layout for you.' Map their answer → call set_wc_single_product_template with mode='default'|'pending_design'|'auto_generated'. For C, generate a single-product HTML matching the brand (see WC_SINGLE_PRODUCT_TEMPLATE_TOKENS rule below) and push via create_template(template_type='wc_single_product', html=...), then call set_wc_single_product_template with mode='auto_generated' and the returned template_id. For Option B (Figma later), do nothing more now — the user will come back when they have their Figma design ready, you convert that with template_type='wc_single_product', then call set_wc_single_product_template with mode='custom' and the new template_id.",
|
|
844
|
+
"WC SINGLE PRODUCT TEMPLATE TOKENS (universal — when converting a single-product Figma design OR generating one for Option C): The plugin's runtime template renders the converted HTML for every /product/<slug>/ URL with per-product token substitution. Use these tokens INSTEAD of static text wherever the design shows product-specific content. Token reference: {{title}} (product name), {{price}} (WC formatted price including sale), {{regular_price}}, {{sale_price}}, {{is_on_sale}} ('1'/'' for conditional styling), {{short_description}}, {{description}} (full body), {{sku}}, {{stock_status}}, {{stock_quantity}}, {{rating}} (star rating HTML), {{rating_value}} (number), {{review_count}}, {{image}} (main URL), {{image_alt}}, {{main_image}} (full <img> with class), {{gallery}} (clickable thumbnail grid that swaps the main image), {{add_to_cart}} (working WC form — handles simple AND variable products including variation dropdowns), {{quantity}} (qty input only), {{variations}} (variations form only, for variable products), {{categories}}, {{tags}}, {{breadcrumb}}, {{tabs}} (Description/Additional Info/Reviews with all WC plugins' tab additions), {{related_products}}, {{up_sells}}, {{cross_sells}}, {{wishlist}} (YITH/TI button when installed, decorative icon otherwise), {{compare}} (YITH compare button), {{product_url}}, {{product_id}}. Example pattern for the right-column info block: <div><h1>{{title}}</h1>{{rating}}<div class='price'>{{price}}</div><div class='desc'>{{short_description}}</div>{{add_to_cart}}<div class='actions'>{{wishlist}}{{compare}}</div><div class='meta'>SKU: {{sku}} • Categories: {{categories}}</div></div>. Use {{tabs}} for the description tabs block — it includes all WC plugin tab content automatically. Use {{related_products}} for the related grid at the bottom.",
|
|
845
|
+
"NAV-IN-HERO SPLIT (CONVERSION_RULES.md §0.3.K): If the user's screenshot shows a navigation bar at the top of the hero (logo + menu items + optional contact button), the design MUST be split into TWO pushes: (a) call create_header_footer(template_type='header', html=<the navbar wrapped in <header>>) — plugin stores it as a site-wide template; (b) call create_page(title, html=<just the hero content, NO <nav>>) — and reserve top space with <div class='sz-nav-spacer' style='height:clamp(40px,5vw,60px)' aria-hidden='true'></div> at the very top of the hero so visual alignment stays correct. Same split applies to footers.",
|
|
846
|
+
"AFTER-PUSH VERIFICATION (CONVERSION_RULES.md §0.3.N): The create_page / push_section_to_page response includes a `detection` object with is_header / is_footer / has_tabs / has_slider / etc. ALWAYS check it. If is_header:true came back unexpectedly, your section got promoted to a site-wide template — the page body is empty. Re-split per the nav-in-hero rule above and re-push.",
|
|
847
|
+
"FIX LOOP: If the user complains after pushing, call start_fix_loop — surgical fix on what they pointed at, never regenerate from scratch.",
|
|
848
|
+
],
|
|
849
|
+
section_types_note: "The section_types below are HINTS — they identify known patterns where the SiteZen plugin has specific markup contracts that activate frontend behaviour (slider JS, accordion JS, form auto-conversion, etc.). They are NOT required categories. If a section in the user's design doesn't match any of these (custom split layouts, unique CTA blocks, decorative banners, anything else), just convert it as semantic HTML matching the visuals — every section becomes a SiteZen Section block regardless. The plugin auto-detects what's inside and surfaces it via the detection block on the push response.",
|
|
850
|
+
section_types: {
|
|
851
|
+
hero: "Top-of-page banner with headline + subtitle + CTAs + bg image/gradient.",
|
|
852
|
+
slider: "Horizontal carousel of cards / images / testimonials with arrows or dots.",
|
|
853
|
+
testimonial: "Quote cards with author name + avatar + role.",
|
|
854
|
+
tabs: "Tabbed content with a strip of tab buttons + matching panels.",
|
|
855
|
+
accordion: "Expandable Q&A or FAQ items with show/hide controls.",
|
|
856
|
+
post_listing: "Dynamic feed of news / blog / services / projects / team posts.",
|
|
857
|
+
cta_banner: "Standalone call-to-action with a headline + button, inside the page flow.",
|
|
858
|
+
features: "Static grid of feature cards (icon + heading + body).",
|
|
859
|
+
stats: "Numbers / counters that animate on scroll.",
|
|
860
|
+
pricing: "Pricing plan cards, often with a 'popular' highlight.",
|
|
861
|
+
team: "Team member cards (avatar + name + role + social).",
|
|
862
|
+
steps: "Process / how-it-works timeline (1 → 2 → 3).",
|
|
863
|
+
logo_strip: "Client logo bar (static row or auto-scrolling marquee).",
|
|
864
|
+
form: "Contact form / lead capture / newsletter signup with input fields.",
|
|
865
|
+
video: "Embedded video (inline player or modal trigger).",
|
|
866
|
+
map: "Embedded map (Google / Mapbox / Leaflet).",
|
|
867
|
+
header: "Site header / nav bar (logo + menu + cta).",
|
|
868
|
+
footer: "Site footer (links + columns + copyright).",
|
|
869
|
+
sticky_cta: "Page-level floating action button.",
|
|
870
|
+
cookie_banner: "Page-level cookie consent banner.",
|
|
871
|
+
animations: "Cross-cutting — adds data-sz-anim attributes to any element for scroll-triggered animations.",
|
|
872
|
+
decoration: "Cross-cutting — handling for decorative shapes, illustrations, vectors, gradient overlays.",
|
|
873
|
+
},
|
|
874
|
+
universal_rules_quick_reference: [
|
|
875
|
+
"Section root: <section id=\"sz-X\" class=\"sz-fullwidth\"> with ALL css scoped under #sz-X.",
|
|
876
|
+
"Real text from Figma (characters field). Never invent text.",
|
|
877
|
+
"Real hex colors from Figma fills. Never guess colors.",
|
|
878
|
+
"Real font family/size/weight from Figma text styles. Use clamp(MIN, Xvw, FIGMA_PX) for fonts >18px and padding >32px.",
|
|
879
|
+
"Semantic tags: h1/h2/h3 for headings, p for body, a for links, button for actions. Never wrap everything in div.",
|
|
880
|
+
"Mobile + tablet: include @media (max-width: 1024px) and @media (max-width: 768px) blocks. Stack horizontal flex on mobile, shrink fonts to readable sizes.",
|
|
881
|
+
"Never render text/buttons/interactives as images. Photos and decorative shapes are images; everything else stays as HTML.",
|
|
882
|
+
"Decorations separate from content: absolute-positioned shapes get their own layer with z-index BELOW content. Content has position: relative with z-index 1+.",
|
|
883
|
+
"Animations: attach data-sz-anim=\"fade-up\" (or fade-in, fade-left, slide-up, zoom-in, etc.) to elements that should animate on scroll. Stack delays with data-sz-anim-delay=\"100\" / \"200\" / \"300\".",
|
|
884
|
+
"Add data-sz-id=\"meaningful-name\" to important elements — the editor's Layers panel uses these as readable labels.",
|
|
885
|
+
"Output: HTML only. No markdown code fences. No preamble. First character is <.",
|
|
886
|
+
],
|
|
887
|
+
next_step: "Pick a section_type from the list above, then call get_conversion_rules with that type to load the specific contract.",
|
|
888
|
+
});
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
/* ─── Tool 12: create_template ─────────────────────────────────────────────
|
|
892
|
+
* Lower-level template creation for non-header/footer templates (e.g.
|
|
893
|
+
* archive layouts, single-post templates). Most use cases want
|
|
894
|
+
* create_header_footer; this is the escape hatch. */
|
|
895
|
+
function registerCreateTemplate(server) {
|
|
896
|
+
server.tool("create_template", "Create a SiteZen template of any type (general escape hatch beyond create_header_footer). Used for archive layouts, single-post templates, dynamic post listings, etc. For headers and footers prefer create_header_footer — it's more ergonomic.", {
|
|
897
|
+
title: z.string().min(1).describe("Template title."),
|
|
898
|
+
template_type: z.string().min(1).describe("Template kind — 'header', 'footer', 'archive', 'single', 'listing', 'wc_single_product' (the editable single-product template used by the WC bridge — pair with set_wc_single_product_template after creation), etc."),
|
|
899
|
+
html: z.string().min(1).describe("Full HTML for the template."),
|
|
900
|
+
}, async ({ title, template_type, html }) => {
|
|
901
|
+
try {
|
|
902
|
+
const result = await wpRequest("/create-template", {
|
|
903
|
+
method: "POST",
|
|
904
|
+
body: { title, template_type, html },
|
|
905
|
+
});
|
|
906
|
+
return ok({ created: true, template: result });
|
|
907
|
+
}
|
|
908
|
+
catch (e) {
|
|
909
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
910
|
+
}
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
/* ─── Tool 15a: list_section_renders ─────────────────────────────────────
|
|
914
|
+
*
|
|
915
|
+
* Option C step 1 of 2: Returns rendered PNG URLs of candidate sections
|
|
916
|
+
* in the Figma file. NO LAYER NAMES are returned — only IDs, bounding
|
|
917
|
+
* boxes, and rendered preview URLs. Claude in Desktop visually compares
|
|
918
|
+
* the user's screenshot to these renders, picks the one that matches,
|
|
919
|
+
* and then calls prepare_section with that section's bounding_box for
|
|
920
|
+
* scoped value extraction.
|
|
921
|
+
*
|
|
922
|
+
* Why this exists: returning whole-file values from prepare_section was
|
|
923
|
+
* causing cross-section pollution (the Mission section's circular dashed
|
|
924
|
+
* pattern bleeding into the Purpose section). Per-section value scoping
|
|
925
|
+
* fixes that. But we have a hard rule: NO LAYER NAME DEPENDENCY. So this
|
|
926
|
+
* tool returns ONLY visual renders + coordinate bounding boxes —
|
|
927
|
+
* deliberately omits names. Claude makes the decision visually. */
|
|
928
|
+
function registerListSectionRenders(server) {
|
|
929
|
+
server.tool("list_section_renders", "INTERNAL TOOL — use this for YOUR OWN visual matching. Returns rendered PNG previews of every candidate section in the Figma file (no layer names — just IDs, rendered preview URLs, and bounding boxes). HOW TO USE: (1) View each render_url image yourself; (2) Visually compare each render to the user's screenshot; (3) Pick the ONE that matches; (4) Call prepare_section with that section's bounding_box. ABSOLUTELY DO NOT show this list to the user, do NOT ask the user 'which section did you mean', do NOT present multiple-choice questions to the user. The user has already told you which section via their screenshot — you do the matching, the user doesn't pick from a menu. If you genuinely cannot tell which section matches the user's screenshot, ask the user to share a clearer screenshot — never ask them to pick from a list.", {
|
|
930
|
+
figma_url: z.string().url().describe("Figma file URL."),
|
|
931
|
+
figma_token: z.string().optional().describe("Figma personal access token. OPTIONAL — when omitted, the MCP uses the FIGMA_TOKEN env var (set by the plugin's MCP config generator). Only pass this parameter if you have a fresh token to override the configured one."),
|
|
932
|
+
}, async ({ figma_url, figma_token }) => {
|
|
933
|
+
try {
|
|
934
|
+
const parsed = parseFigmaUrl(figma_url);
|
|
935
|
+
if (!parsed)
|
|
936
|
+
return err("Could not parse the Figma URL.");
|
|
937
|
+
// Fall back to env var so users don't have to paste the token
|
|
938
|
+
// every time — it's already in claude_desktop_config.json.
|
|
939
|
+
const token = figma_token || process.env.FIGMA_TOKEN || "";
|
|
940
|
+
if (!token) {
|
|
941
|
+
return err("NO_FIGMA_TOKEN", {
|
|
942
|
+
error_code: "NO_FIGMA_TOKEN",
|
|
943
|
+
user_message: "No Figma access token is set. Visit your WordPress admin → SiteZen → Connection → Setup Claude Desktop, paste your Figma token, copy the generated config, and update claude_desktop_config.json. Then restart Claude Desktop.",
|
|
944
|
+
what_to_do: "Get a Figma token via the plugin's Setup Claude Desktop form, update the config, restart Claude.",
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
figma_token = token;
|
|
948
|
+
// Shallow fetch — we only need top-level sections + their bboxes
|
|
949
|
+
// here. prepare_section will do a full deep fetch later.
|
|
950
|
+
// LARGE-FILE SAFETY: when the URL has a node-id, scope the fetch
|
|
951
|
+
// to just that node's subtree. This is what makes huge multi-
|
|
952
|
+
// design files work — instead of pulling/enumerating the entire
|
|
953
|
+
// file (which times out), we only enumerate sections within the
|
|
954
|
+
// frame the user's URL points at. The screenshot still picks
|
|
955
|
+
// the exact section among those candidates (no structure
|
|
956
|
+
// dependency — the node-id only narrows the search space).
|
|
957
|
+
const file = await fetchFigmaFile(parsed.fileKey, figma_token, "shallow", {
|
|
958
|
+
scopeNodeId: parsed.nodeId,
|
|
959
|
+
});
|
|
960
|
+
const candidates = listRenderableSections(file).slice(0, 30); // cap
|
|
961
|
+
// Render each candidate as a small PNG so Claude can compare visually.
|
|
962
|
+
// Scale 1 (smallest) is enough for visual matching — full-res renders
|
|
963
|
+
// come later via prepare_section's image_assets when needed.
|
|
964
|
+
// fetchRenderedImages now batches + retries + scale-falls-back, so
|
|
965
|
+
// a few slow renders won't block the rest. But if MORE than a third
|
|
966
|
+
// of candidates failed even with retries, the file is too heavy for
|
|
967
|
+
// safe visual matching and we error out — never picking the wrong
|
|
968
|
+
// section silently.
|
|
969
|
+
const ids = candidates.map((c) => c.id);
|
|
970
|
+
const renderMap = ids.length
|
|
971
|
+
? await fetchRenderedImages(parsed.fileKey, ids, figma_token, "png", 1)
|
|
972
|
+
: {};
|
|
973
|
+
const failedIds = ids.filter((id) => !renderMap[id]);
|
|
974
|
+
if (candidates.length > 0 && failedIds.length / candidates.length > 0.33) {
|
|
975
|
+
return err("RENDER_API_TIMEOUT — Figma's image rendering API failed for too many sections in this file to safely visual-match. Don't proceed by guessing.", {
|
|
976
|
+
error_code: "RENDER_API_TIMEOUT",
|
|
977
|
+
user_message: `I couldn't get previews for ${failedIds.length} of ${candidates.length} sections in this Figma file (the file is large and Figma's rendering service is overloaded). Please wait ~2 minutes and try the same prompt again — I won't pick a wrong section by guessing.`,
|
|
978
|
+
what_to_do: "Wait 2 minutes and retry the exact same prompt.",
|
|
979
|
+
failed_count: failedIds.length,
|
|
980
|
+
total_count: candidates.length,
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
const sections = candidates
|
|
984
|
+
.filter((c) => renderMap[c.id])
|
|
985
|
+
.map((c) => ({
|
|
986
|
+
// Note: no name field. Claude picks visually, not by name.
|
|
987
|
+
section_id: c.id,
|
|
988
|
+
bounding_box: c.bbox,
|
|
989
|
+
render_url: renderMap[c.id],
|
|
990
|
+
width: Math.round(c.bbox.width),
|
|
991
|
+
height: Math.round(c.bbox.height),
|
|
992
|
+
}));
|
|
993
|
+
return ok({
|
|
994
|
+
sections,
|
|
995
|
+
instruction: [
|
|
996
|
+
"Each entry above has a render_url — fetch/view the PNG (or describe it from the URL) and compare to the user's screenshot.",
|
|
997
|
+
"Pick the section_id whose render matches the user's screenshot visually.",
|
|
998
|
+
"Then call prepare_section(figma_url, figma_token, section_id=<that section_id>, section_bbox=<the bounding_box from that entry>).",
|
|
999
|
+
"ALWAYS pass section_id — it tells prepare_section to fetch ONLY that section's subtree (fast, complete, no timeout). Without section_id the MCP has to pull the whole file which can time out on large designs.",
|
|
1000
|
+
"prepare_section will return values (text, colors, fonts, images, vectors, layouts) scoped ONLY to that section's region. Cross-section pollution eliminated.",
|
|
1001
|
+
"",
|
|
1002
|
+
"If the user's screenshot matches the WHOLE file (single-section design), call prepare_section without section_id/section_bbox — it will return whole-file values (current behaviour, backward-compatible).",
|
|
1003
|
+
"",
|
|
1004
|
+
"IMPORTANT — NEVER fall back to using the URL's node-id when you cannot visually match. The screenshot is the source of truth (§0.3.AL). If matching fails, return the error to the user — do NOT silently pick a section based on the URL.",
|
|
1005
|
+
].join("\n"),
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
catch (e) {
|
|
1009
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
1010
|
+
}
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
/* ─── Tool 15b: prepare_section ───────────────────────────────────────────
|
|
1014
|
+
*
|
|
1015
|
+
* Returns ALL exact values from the Figma file (text content, hex colors,
|
|
1016
|
+
* font names+weights, image URLs, vector SVGs) as flat collections — with
|
|
1017
|
+
* NO dependency on the Figma layer panel structure.
|
|
1018
|
+
*
|
|
1019
|
+
* Why no node-id, no section picking, no frame matching:
|
|
1020
|
+
* Per existing universal rules in CONVERSION_RULES.md:
|
|
1021
|
+
* §0.3.AL — "User's screenshot WINS over my API render for visual
|
|
1022
|
+
* decisions" — the user's screenshot is the ground truth
|
|
1023
|
+
* for which section to convert and how it should look.
|
|
1024
|
+
* §0.3.A — "Node JSON is for VALUES, the rendered image is for
|
|
1025
|
+
* LAYOUT" — JSON delivers exact text/colors/fonts;
|
|
1026
|
+
* visuals deliver layout.
|
|
1027
|
+
* Line 711-713 — User screenshot wins for layout; Figma JSON wins
|
|
1028
|
+
* for exact values; plugin contracts win for sz-* markup.
|
|
1029
|
+
*
|
|
1030
|
+
* And from real beta-user experience: layer panel structures are
|
|
1031
|
+
* often messy ("Frame 32", "Group 12", weird nesting). The
|
|
1032
|
+
* conversion MUST work regardless of how layers are organized.
|
|
1033
|
+
*
|
|
1034
|
+
* What the model does with this response:
|
|
1035
|
+
* 1. Looks at the user's SCREENSHOT (their pasted section image)
|
|
1036
|
+
* → that's the section to convert and its layout
|
|
1037
|
+
* 2. Calls this tool to get the file's exact VALUES
|
|
1038
|
+
* 3. Matches text/colors/fonts visible in the screenshot to entries
|
|
1039
|
+
* in text_nodes / colors_used / fonts_used
|
|
1040
|
+
* 4. Generates HTML where layout matches the screenshot and values
|
|
1041
|
+
* come from this response
|
|
1042
|
+
* 5. Calls create_page with the html + figma_data — the MCP runs the
|
|
1043
|
+
* enforce* post-processors that apply these Figma values onto
|
|
1044
|
+
* the HTML as a safety net
|
|
1045
|
+
*/
|
|
1046
|
+
function registerPrepareSection(server) {
|
|
1047
|
+
server.tool("prepare_section", "Reads the user's Figma file and returns exact values for HTML generation: text_nodes, colors_used, fonts_used, image_assets (rendered PNG URLs), vector_svgs (inline SVG markup), layout_values (padding/gap/layoutMode), image_scale_hints. Pass section_bbox to scope values to ONE section's visual region (recommended — prevents cross-section pollution where other sections' text/vectors bleed into your output). Omit section_bbox for single-section files or to get whole-file values. Get section_bbox from list_section_renders first by visually matching the user's screenshot.", {
|
|
1048
|
+
figma_url: z.string().url().describe("Figma file URL."),
|
|
1049
|
+
figma_token: z.string().optional().describe("Figma personal access token. OPTIONAL — when omitted, the MCP uses the FIGMA_TOKEN env var (set by the plugin's MCP config generator). Only pass this parameter if you have a fresh token to override the configured one."),
|
|
1050
|
+
section_id: z.string().optional().describe("STRONGLY RECOMMENDED — the section_id from list_section_renders for the section you visually matched. When provided, the MCP fetches ONLY that section's subtree (fast + complete, no timeout risk) instead of pulling the whole file. Use this whenever you've already called list_section_renders."),
|
|
1051
|
+
section_bbox: z.object({
|
|
1052
|
+
x: z.number(),
|
|
1053
|
+
y: z.number(),
|
|
1054
|
+
width: z.number(),
|
|
1055
|
+
height: z.number(),
|
|
1056
|
+
}).optional().describe("Optional bounding box (from list_section_renders) to scope all returned values to one visual section. Used together with section_id, or alone for very small files. Prefer section_id when both are available."),
|
|
1057
|
+
}, async ({ figma_url, figma_token, section_id, section_bbox }) => {
|
|
1058
|
+
try {
|
|
1059
|
+
const parsed = parseFigmaUrl(figma_url);
|
|
1060
|
+
if (!parsed)
|
|
1061
|
+
return err("Could not parse the Figma URL. Expected format: https://www.figma.com/design/<fileKey>/<name>");
|
|
1062
|
+
// Fall back to env var (set by plugin's MCP config generator)
|
|
1063
|
+
// so users don't have to paste the token on every call.
|
|
1064
|
+
const token = figma_token || process.env.FIGMA_TOKEN || "";
|
|
1065
|
+
if (!token) {
|
|
1066
|
+
return err("NO_FIGMA_TOKEN", {
|
|
1067
|
+
error_code: "NO_FIGMA_TOKEN",
|
|
1068
|
+
user_message: "No Figma access token is set. Visit your WordPress admin → SiteZen → Connection → Setup Claude Desktop, paste your Figma token, copy the generated config, and update claude_desktop_config.json. Then restart Claude Desktop.",
|
|
1069
|
+
what_to_do: "Get a Figma token via the plugin's Setup Claude Desktop form, update the config, restart Claude.",
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
figma_token = token;
|
|
1073
|
+
// 1. Get the layer tree to extract from.
|
|
1074
|
+
//
|
|
1075
|
+
// FAST PATH (section_id provided): fetch ONLY that section's
|
|
1076
|
+
// subtree via /v1/files/{key}/nodes — same depth, fraction of
|
|
1077
|
+
// the bytes, no timeout risk on large files. This is what
|
|
1078
|
+
// list_section_renders returns the section_id for.
|
|
1079
|
+
//
|
|
1080
|
+
// SLOW PATH (no section_id): pull the whole file with full
|
|
1081
|
+
// depth. Real designs nest text 4-8 levels deep so this is
|
|
1082
|
+
// the only correct fetch when we don't know which section
|
|
1083
|
+
// yet. May time out on very large files — that's why the
|
|
1084
|
+
// workflow tells Claude to always pass section_id when
|
|
1085
|
+
// available.
|
|
1086
|
+
const useSubtreeFetch = !!section_id;
|
|
1087
|
+
const file = useSubtreeFetch
|
|
1088
|
+
? await fetchFigmaSubtreeAsFile(parsed.fileKey, section_id, figma_token)
|
|
1089
|
+
: await fetchFigmaFile(parsed.fileKey, figma_token, "full");
|
|
1090
|
+
if (!file) {
|
|
1091
|
+
return err(`SECTION_NOT_FOUND — node ${section_id} is not in this Figma file (it may have been deleted or you may be on a different file).`, {
|
|
1092
|
+
error_code: "SECTION_NOT_FOUND",
|
|
1093
|
+
user_message: `I couldn't find that section in the Figma file. The node id (${section_id}) doesn't exist — it may have been deleted or you're on a different file. Please re-share the Figma URL.`,
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
// 2. Extract all VALUES from the whole file in parallel-safe pure functions.
|
|
1097
|
+
// Layout values (padding, gap, layoutMode, alignment) and image
|
|
1098
|
+
// scale hints are properties of nodes, same as text content or
|
|
1099
|
+
// color hex — they're values, not structure. Returned flat with
|
|
1100
|
+
// dedupe so the model matches them against the screenshot the
|
|
1101
|
+
// same way it matches a hex color or a font name.
|
|
1102
|
+
// Pass section_bbox to all extractors — when provided, each
|
|
1103
|
+
// extractor filters values to only nodes whose absoluteBoundingBox
|
|
1104
|
+
// overlaps with the section region. This is Option C: visual region
|
|
1105
|
+
// matching, no layer name dependency.
|
|
1106
|
+
const text_nodes = extractAllTextNodes(file, section_bbox);
|
|
1107
|
+
const colors_used = extractAllColors(file, section_bbox);
|
|
1108
|
+
const fonts_used = extractAllFonts(file, section_bbox);
|
|
1109
|
+
const image_nodes = extractAllImageNodes(file, section_bbox).slice(0, 15);
|
|
1110
|
+
const vector_nodes = extractAllVectorNodes(file, section_bbox).slice(0, 12);
|
|
1111
|
+
const layout_values = extractAllLayoutValues(file, section_bbox).slice(0, 30);
|
|
1112
|
+
// FLATTENED-FILE GUARD — if there are NO text nodes AND NO fonts
|
|
1113
|
+
// in this scope, the file (or section) is a flattened/exported
|
|
1114
|
+
// image rather than an editable Figma design. We have nothing
|
|
1115
|
+
// real to extract — proceeding would force Claude to fabricate
|
|
1116
|
+
// text/fonts/colors from the screenshot, violating the "no
|
|
1117
|
+
// guessing" contract. Hard error out instead, so the user
|
|
1118
|
+
// knows exactly what's wrong and can give us a real layered
|
|
1119
|
+
// Figma file (or explicitly opt into screenshot-only mode).
|
|
1120
|
+
if (text_nodes.length === 0 && fonts_used.length === 0) {
|
|
1121
|
+
const wholeFileTextCheck = section_bbox
|
|
1122
|
+
? extractAllTextNodes(file, undefined).length
|
|
1123
|
+
: 0;
|
|
1124
|
+
const scopeWord = section_bbox ? "section" : "file";
|
|
1125
|
+
return err("FIGMA_FILE_FLATTENED — no editable text or font data in this " + scopeWord + ".", {
|
|
1126
|
+
error_code: "FIGMA_FILE_FLATTENED",
|
|
1127
|
+
user_message: section_bbox && wholeFileTextCheck > 0
|
|
1128
|
+
? "I can read text from other parts of this Figma file, but the specific section you selected has no editable text or font layers — it looks like a flattened/exported image inside the file. Please either: (1) share a screenshot of a section that has REAL text layers, or (2) confirm you want me to convert from just the screenshot, which will be approximate — not pixel-perfect — because there are no exact text/font/color values to pull."
|
|
1129
|
+
: "This Figma file has no editable text or font layers — it's a flattened/exported image rather than a real editable design. To get a pixel-perfect conversion, please share a Figma file where the text, shapes and colors are real editable layers (not a baked PNG/JPG). If you want me to proceed from just the screenshot anyway, reply 'convert from screenshot only — I understand it won't be pixel-perfect' and I'll do my best from visual inspection.",
|
|
1130
|
+
what_to_do: "Share a Figma file with real editable text/shape layers, OR explicitly opt into screenshot-only conversion.",
|
|
1131
|
+
extracted_signals: {
|
|
1132
|
+
text_nodes: 0,
|
|
1133
|
+
fonts: 0,
|
|
1134
|
+
colors: colors_used.length,
|
|
1135
|
+
images: image_nodes.length,
|
|
1136
|
+
vectors: vector_nodes.length,
|
|
1137
|
+
layouts: layout_values.length,
|
|
1138
|
+
},
|
|
1139
|
+
note_to_claude: "DO NOT silently proceed by guessing values from the screenshot. Show user_message to the user verbatim and STOP. Only proceed if the user explicitly opts in to screenshot-only mode — and even then, prefix the output with a clear note that the result is approximate.",
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
const image_scale_hints = extractAllImageScaleHints(file, section_bbox);
|
|
1143
|
+
const overlap_hints = extractOverlapHints(file, section_bbox).slice(0, 20);
|
|
1144
|
+
// Gradients with FULL CSS definitions (type + angle + stops +
|
|
1145
|
+
// position). Without these, complex gradient backgrounds
|
|
1146
|
+
// were lost — Claude had only the stop colors and had to
|
|
1147
|
+
// invent the direction, which was nearly always wrong.
|
|
1148
|
+
const gradients = extractAllGradients(file, section_bbox).slice(0, 12);
|
|
1149
|
+
// Extended-fidelity extractors — close the silent-defaults gap.
|
|
1150
|
+
// All return ready-to-paste CSS so Claude never has to invent
|
|
1151
|
+
// shadows / radii / borders / opacity / responsive intent.
|
|
1152
|
+
const effects = extractAllEffects(file, section_bbox).slice(0, 30);
|
|
1153
|
+
const corner_radii = extractAllCornerRadii(file, section_bbox).slice(0, 30);
|
|
1154
|
+
const strokes = extractAllStrokes(file, section_bbox).slice(0, 30);
|
|
1155
|
+
const opacities = extractAllOpacities(file, section_bbox).slice(0, 30);
|
|
1156
|
+
const responsive_hints = extractAllResponsiveHints(file, section_bbox).slice(0, 30);
|
|
1157
|
+
// 3. Fetch rendered PNG URLs for image-fill nodes + SVG markup for vectors,
|
|
1158
|
+
// in parallel. Figma's /v1/images returns signed CDN URLs.
|
|
1159
|
+
// Also fetch a HIGH-RES render of the section itself when we
|
|
1160
|
+
// have a section_id — gives Claude a clean visual reference
|
|
1161
|
+
// for complex backgrounds that can't be cleanly reproduced
|
|
1162
|
+
// via CSS / SVG and need an image fill instead.
|
|
1163
|
+
const imageIds = image_nodes.map((n) => n.id);
|
|
1164
|
+
const vectorIds = vector_nodes.map((n) => n.id);
|
|
1165
|
+
// High-res reference render of the section itself. Scale 2 hits
|
|
1166
|
+
// the sweet spot — sharp enough for pixel-perfect bg-image use
|
|
1167
|
+
// and visual inspection by Claude, but still under Figma's
|
|
1168
|
+
// 8000px output cap for typical section sizes.
|
|
1169
|
+
const sectionRenderIds = section_id ? [section_id] : [];
|
|
1170
|
+
const [pngMap, svgMap, sectionRenderMap] = await Promise.all([
|
|
1171
|
+
// scale=2: 4× fewer bytes than scale=3, still sharp on
|
|
1172
|
+
// retina; pageSpeed wins outweigh the marginal sharpness
|
|
1173
|
+
// delta. The plugin sideloads + resizes anyway, so the
|
|
1174
|
+
// final served size is determined by the editor / theme.
|
|
1175
|
+
imageIds.length ? fetchRenderedImages(parsed.fileKey, imageIds, figma_token, "png", 2) : Promise.resolve({}),
|
|
1176
|
+
vectorIds.length ? fetchRenderedImages(parsed.fileKey, vectorIds, figma_token, "svg", 1) : Promise.resolve({}),
|
|
1177
|
+
sectionRenderIds.length ? fetchRenderedImages(parsed.fileKey, sectionRenderIds, figma_token, "png", 2) : Promise.resolve({}),
|
|
1178
|
+
]);
|
|
1179
|
+
const section_render_url = section_id ? (sectionRenderMap[section_id] || null) : null;
|
|
1180
|
+
// 4. For vectors, fetch the actual SVG markup (signed URLs expire in ~30 min;
|
|
1181
|
+
// inline-ing them in the HTML makes them permanent).
|
|
1182
|
+
const vector_svgs = [];
|
|
1183
|
+
await Promise.all(vector_nodes.map(async (v) => {
|
|
1184
|
+
const url = svgMap[v.id];
|
|
1185
|
+
if (!url)
|
|
1186
|
+
return;
|
|
1187
|
+
try {
|
|
1188
|
+
const r = await fetch(url);
|
|
1189
|
+
if (!r.ok)
|
|
1190
|
+
return;
|
|
1191
|
+
const svgText = await r.text();
|
|
1192
|
+
vector_svgs.push({
|
|
1193
|
+
name: v.name,
|
|
1194
|
+
width: v.width,
|
|
1195
|
+
height: v.height,
|
|
1196
|
+
svg: svgText,
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
catch { /* skip individual fetch errors */ }
|
|
1200
|
+
}));
|
|
1201
|
+
// 5. Compose image_nodes response with their rendered URLs.
|
|
1202
|
+
// Images whose render genuinely failed (after batch + retry +
|
|
1203
|
+
// scale fallback) end up in failed_image_assets with the
|
|
1204
|
+
// name + dimensions so Claude can still emit a correctly-
|
|
1205
|
+
// sized placeholder in the HTML (graceful-degradation pattern,
|
|
1206
|
+
// never abandon — see workflow rules in start_conversion).
|
|
1207
|
+
const image_assets = image_nodes
|
|
1208
|
+
.filter((n) => pngMap[n.id])
|
|
1209
|
+
.map((n) => ({
|
|
1210
|
+
name: n.name,
|
|
1211
|
+
width: n.width,
|
|
1212
|
+
height: n.height,
|
|
1213
|
+
url: pngMap[n.id],
|
|
1214
|
+
}));
|
|
1215
|
+
const failed_image_assets = image_nodes
|
|
1216
|
+
.filter((n) => !pngMap[n.id])
|
|
1217
|
+
.map((n) => ({
|
|
1218
|
+
name: n.name,
|
|
1219
|
+
width: n.width,
|
|
1220
|
+
height: n.height,
|
|
1221
|
+
reason: "render_api_unavailable",
|
|
1222
|
+
}));
|
|
1223
|
+
return ok({
|
|
1224
|
+
file_name: file?.name,
|
|
1225
|
+
counts: {
|
|
1226
|
+
text_nodes: text_nodes.length,
|
|
1227
|
+
colors: colors_used.length,
|
|
1228
|
+
fonts: fonts_used.length,
|
|
1229
|
+
images: image_assets.length,
|
|
1230
|
+
failed_images: failed_image_assets.length,
|
|
1231
|
+
vectors: vector_svgs.length,
|
|
1232
|
+
layouts: layout_values.length,
|
|
1233
|
+
image_scale_hints: image_scale_hints.length,
|
|
1234
|
+
overlap_hints: overlap_hints.length,
|
|
1235
|
+
gradients: gradients.length,
|
|
1236
|
+
effects: effects.length,
|
|
1237
|
+
corner_radii: corner_radii.length,
|
|
1238
|
+
strokes: strokes.length,
|
|
1239
|
+
opacities: opacities.length,
|
|
1240
|
+
responsive_hints: responsive_hints.length,
|
|
1241
|
+
},
|
|
1242
|
+
section_render_url,
|
|
1243
|
+
text_nodes,
|
|
1244
|
+
colors_used,
|
|
1245
|
+
fonts_used,
|
|
1246
|
+
gradients,
|
|
1247
|
+
effects,
|
|
1248
|
+
corner_radii,
|
|
1249
|
+
strokes,
|
|
1250
|
+
opacities,
|
|
1251
|
+
responsive_hints,
|
|
1252
|
+
image_assets,
|
|
1253
|
+
failed_image_assets,
|
|
1254
|
+
vector_svgs,
|
|
1255
|
+
layout_values,
|
|
1256
|
+
image_scale_hints,
|
|
1257
|
+
overlap_hints,
|
|
1258
|
+
instruction: [
|
|
1259
|
+
"These are ALL the exact VALUES from the Figma file. Match them against what you SEE in the user's screenshot:",
|
|
1260
|
+
" • text_nodes — every text with its exact fontSize/fontWeight/color/fontFamily. Use entries whose text appears in the screenshot, verbatim. Never invent text.",
|
|
1261
|
+
" • colors_used — every hex color present in fills/gradients across the file. The colors visible in the screenshot are in this list.",
|
|
1262
|
+
" • fonts_used — typeface families + weights. Use the ones whose visual style matches the screenshot.",
|
|
1263
|
+
" • image_assets — content photos with rendered PNG URLs. Pick whichever image matches what's in the screenshot for hero/photo positions.",
|
|
1264
|
+
" • vector_svgs — decorative shapes, icons, logos as inline SVG markup. Paste the .svg field directly into the HTML where the design shows that shape.",
|
|
1265
|
+
" • layout_values — padding/gap/layoutMode/alignment from autolayout frames across the file. Each entry is a complete layout shape (width, height, padding-top/right/bottom/left, itemSpacing=gap, layoutMode=HORIZONTAL|VERTICAL, alignment). Match these against the screenshot's spacing — entries are sorted largest-frame-first so page/hero-sized values come up first. Use the values directly in your CSS (padding: <top>px <right>px <bottom>px <left>px; gap: <itemSpacing>px; flex-direction: row if HORIZONTAL else column). NEVER guess spacing when these values exist.",
|
|
1266
|
+
" • image_scale_hints — for each image fill, the scaleMode (FILL/FIT/CROP/TILE) and whether the frame is background-fill (>=800px wide) or content-image. Use object-fit:cover for FILL/CROP, object-fit:contain for FIT, background-repeat for TILE.",
|
|
1267
|
+
" • overlap_hints — nodes whose bbox extends PAST the section's edges. Each entry tells you which direction and how many pixels. Translate each one into CSS on the matching element: overflow_bottom_px:80 → margin-bottom:-80px (the element bleeds into the next section); overflow_top_px:40 → margin-top:-40px (bleeds up into the previous section). This is how cards-that-overlap-the-section-boundary and waves-that-hang-past-the-edge get preserved. Without applying these, every section renders as a hard rectangle and overlaps are lost.",
|
|
1268
|
+
" • failed_image_assets — images whose Figma render couldn't be fetched (api outage, file too large, etc.) AFTER the MCP's batching + retry + scale fallback. Don't skip these positions — generate the graceful-degradation placeholder per the workflow rules (class='sz-asset-needed', data-sz-asset-type='image', data-sz-spot='<label>', correct dimensions and aspect-ratio baked in). The push response will surface them in pending_assets so the user gets a clear checklist.",
|
|
1269
|
+
" • gradients — every gradient in the section as a READY-TO-PASTE CSS string (linear-gradient(135deg, #ff0080 0%, #7928ca 100%), radial-gradient(...), conic-gradient(...)). Each entry has the CSS, its type, and the bbox so you can match it to where it appears. NEVER invent gradient angles or stops when the gradients[] list has an entry that matches what you see in the screenshot — paste the .css value verbatim.",
|
|
1270
|
+
" • section_render_url — a 2× high-res PNG render of the entire section straight from Figma. This is YOUR PRIMARY VISUAL REFERENCE for the section — use it to see what you're converting at high quality, much sharper than the user's screenshot which may have been compressed or rescaled. Specifically use it when: the background is a complex gradient or decorative scene that's hard to read from the user's screenshot; you're deciding whether a background should be CSS or an image; you need to verify exact shapes / positions / proportions before generating HTML.",
|
|
1271
|
+
" • effects — drop/inner shadows + layer/background blurs as ready-to-paste box-shadow / filter / backdrop-filter CSS strings. Each entry has the bbox so you can match to the right element. NEVER skip a shadow — every entry must end up on the matching element's CSS. Without this, cards/heroes go flat (the single biggest source of 'feels off' complaints).",
|
|
1272
|
+
" • corner_radii — per-corner border-radius. Each entry is `border-radius:Apx Bpx Cpx Dpx` (TL TR BR BL Figma order). Apply verbatim — asymmetric corners (top-only, pill shapes) used to drift to symmetric default.",
|
|
1273
|
+
" • strokes — borders as `border:Wpx solid|dashed|dotted #...` CSS strings. Paste verbatim. Dashed/dotted borders used to silently collapse to solid.",
|
|
1274
|
+
" • opacities — node-level opacity (< 1) on containers. Apply as `opacity:X` on the matching element. Without this, semi-transparent overlays went fully opaque.",
|
|
1275
|
+
" • responsive_hints — designer's intent for how each element behaves at smaller widths (LEFT_RIGHT stretch, SCALE %, CENTER auto, etc.) based on Figma constraints. Translate to actual responsive CSS: stretch → width:100%/flex:1, scale → percentage width, center → margin-inline:auto, etc. Use these for the mobile + tablet @media blocks — don't guess responsive behaviour when designer intent is here.",
|
|
1276
|
+
" • text_nodes (extended) — now includes letterSpacing, lineHeight + unit, textDecoration, textCase (upper/lower/title), textAlign, opacity. Apply ALL of these on each matching <h*>/<p>/<span> — typography drift was a major source of 'feels different' complaints. NEVER drop letterSpacing or lineHeight when they're set; never default textCase when the design specifies upper/lower.",
|
|
1277
|
+
"",
|
|
1278
|
+
"Authority chain (per universal rules):",
|
|
1279
|
+
" 1. User's screenshot = composition truth (where things go, visual hierarchy, what's stacked vs side-by-side)",
|
|
1280
|
+
" 2. This response = value truth (exact text content, hex codes, font names, image URLs, AND exact padding/gap/dimensions)",
|
|
1281
|
+
" 3. Plugin contracts = markup truth (sz-* classes, data-sz-* attributes — see get_conversion_rules)",
|
|
1282
|
+
"",
|
|
1283
|
+
"Important: layout_values and image_scale_hints are properties of nodes (just like fontSize and color) — they're VALUES, not structure. They don't depend on the layer panel being clean or layers being named well. They're just numbers Figma exposes that you should use directly instead of guessing.",
|
|
1284
|
+
"",
|
|
1285
|
+
"After generating HTML, call create_page with html + figma_data: { text_nodes, section_bg }. The MCP runs the enforce* post-processors that apply Figma values onto your HTML as a safety net.",
|
|
1286
|
+
].join("\n"),
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
catch (e) {
|
|
1290
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
1291
|
+
}
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
/* ─── Tool 16: start_fix_loop ─────────────────────────────────────────────
|
|
1295
|
+
* Equivalent of the platform's beta "Fix Issue" workflow. When the user
|
|
1296
|
+
* looks at a pushed section and says "this is wrong because X", the model
|
|
1297
|
+
* calls this tool with the user's feedback. The tool returns:
|
|
1298
|
+
* • The current HTML of that section (verbatim from the WP page)
|
|
1299
|
+
* • The relevant conversion rules
|
|
1300
|
+
* • Surgical-fix instructions the model must follow
|
|
1301
|
+
*
|
|
1302
|
+
* The model then writes a fixed HTML (changing ONLY what the user
|
|
1303
|
+
* complained about, keeping everything else identical) and pushes via
|
|
1304
|
+
* push_section_to_page with replace_block_index. This restores the Fix
|
|
1305
|
+
* Issue loop the platform showcased in the beta demo. */
|
|
1306
|
+
function registerStartFixLoop(server) {
|
|
1307
|
+
server.tool("start_fix_loop", "Start a surgical fix loop on an existing section. Call this when the user looks at a pushed page and says 'the headline is too big', 'change the photo', 'colors are wrong on the buttons', etc. Returns the current HTML, relevant rules, and strict surgical-fix instructions. Generate a FIXED version of the HTML (change ONLY what the user complained about, keep everything else byte-for-byte identical) and push back via push_section_to_page with replace_block_index pointing at the section.", {
|
|
1308
|
+
page_id: z.number().int().positive().describe("WP page id of the page being fixed."),
|
|
1309
|
+
block_index: z.number().int().nonnegative().describe("0-based index of the SiteZen Section block on that page (0 = first/only section)."),
|
|
1310
|
+
section_type: z.enum(SECTION_TYPES).optional().describe("Section type (hero/slider/pricing/etc.) so we return the right rules. If omitted, returns only universal rules."),
|
|
1311
|
+
user_feedback: z.string().min(1).describe("Exactly what the user said is wrong, in their words. Don't paraphrase. The fix model uses this as the authoritative complaint to address."),
|
|
1312
|
+
}, async ({ page_id, block_index: _block_index, section_type, user_feedback }) => {
|
|
1313
|
+
try {
|
|
1314
|
+
// Fetch the current page content via the WP REST debug endpoint
|
|
1315
|
+
const page = await wpRequest(`/debug-page/${page_id}`);
|
|
1316
|
+
if (!page || typeof page.raw_content !== "string") {
|
|
1317
|
+
return err(`Could not read page ${page_id}. Use list_pages to confirm the page exists.`);
|
|
1318
|
+
}
|
|
1319
|
+
// Load conversion rules for the section type (or universal-only)
|
|
1320
|
+
const rulesText = readFileSync(CONVERSION_RULES_PATH, "utf-8");
|
|
1321
|
+
const universal = sliceRulesSection(rulesText, "universal");
|
|
1322
|
+
const sectionRules = section_type && section_type !== "universal"
|
|
1323
|
+
? sliceRulesSection(rulesText, section_type)
|
|
1324
|
+
: "";
|
|
1325
|
+
return ok({
|
|
1326
|
+
page_id,
|
|
1327
|
+
current_page_content: page.raw_content,
|
|
1328
|
+
user_feedback,
|
|
1329
|
+
universal_rules: universal,
|
|
1330
|
+
section_rules: sectionRules,
|
|
1331
|
+
surgical_fix_instructions: [
|
|
1332
|
+
"1. Parse the current_page_content above to find the SiteZen Section block(s). Each is wrapped in `<!-- wp:sitezen/section {\"sectionHtmlB64\":\"...\"} /-->`.",
|
|
1333
|
+
"2. Decode the base64 in sectionHtmlB64 to recover the section's HTML.",
|
|
1334
|
+
"3. Apply the user's feedback as a SURGICAL FIX:",
|
|
1335
|
+
" • Keep EVERY element the user did NOT mention BYTE-FOR-BYTE identical (same tag, classes, inline styles, data-* attributes, text, image src, child structure)",
|
|
1336
|
+
" • Change ONLY the parts the user complained about",
|
|
1337
|
+
" • The user's request OVERRIDES any rule. If they say 'make headline 80px' you write 80px even if Figma data says 56px",
|
|
1338
|
+
" • Do NOT 'improve' anything they didn't ask about",
|
|
1339
|
+
" • Do NOT regenerate from scratch",
|
|
1340
|
+
"4. Call push_section_to_page with: page_id (same), html: <your fixed HTML>, replace_block_index: <the same block_index>. This REPLACES the section in place — does not create a new page or append.",
|
|
1341
|
+
"5. Report the page_url back to the user so they verify the fix.",
|
|
1342
|
+
],
|
|
1343
|
+
important: "This is the platform's beta 'Fix Issue' workflow. Surgical means surgical — touching anything beyond the user's complaint creates new regressions.",
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
catch (e) {
|
|
1347
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
1348
|
+
}
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
/* ─── Register all ─────────────────────────────────────────────────────────
|
|
1352
|
+
* Called once from index.ts. New tools: add the file (or function) here
|
|
1353
|
+
* and add a register call below. Order doesn't matter to MCP. */
|
|
1354
|
+
import { registerSessionTools } from "./tools-session.js";
|
|
1355
|
+
import { logConversion } from "./conversion-log.js";
|
|
1356
|
+
export function registerAllTools(server) {
|
|
1357
|
+
registerSessionTools(server);
|
|
1358
|
+
registerCheckConfig(server);
|
|
1359
|
+
registerListPages(server);
|
|
1360
|
+
registerListTemplates(server);
|
|
1361
|
+
registerCreatePage(server);
|
|
1362
|
+
registerPushSectionToPage(server);
|
|
1363
|
+
registerGetPageHtml(server);
|
|
1364
|
+
registerCreateHeaderFooter(server);
|
|
1365
|
+
registerSetSiteBranding(server);
|
|
1366
|
+
registerGetSiteGlobals(server);
|
|
1367
|
+
registerGetSiteWidths(server);
|
|
1368
|
+
registerWcSingleTemplate(server);
|
|
1369
|
+
registerSetPageRole(server);
|
|
1370
|
+
registerDetectSectionKind(server);
|
|
1371
|
+
registerEditorV2Capabilities(server);
|
|
1372
|
+
registerCreateTemplate(server);
|
|
1373
|
+
registerGetConversionRules(server);
|
|
1374
|
+
registerStartConversion(server);
|
|
1375
|
+
registerListSectionRenders(server);
|
|
1376
|
+
registerPrepareSection(server);
|
|
1377
|
+
registerStartFixLoop(server);
|
|
1378
|
+
}
|