@yashwant.dharmdas/elementor-mcp 3.15.0 → 3.17.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.
Files changed (2) hide show
  1. package/dist/index.js +272 -1
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1812,7 +1812,7 @@ function createMcpServer(sites) {
1812
1812
  return { content: [{ type: "text", text: `Error setting rotation: ${error.response?.data?.message || error.message}` }], isError: true };
1813
1813
  }
1814
1814
  });
1815
- server.tool("update-data", "Replace the entire Elementor element tree for a page. By default refuses if the new data is dramatically smaller than existing. Use force_replace=true to override.", {
1815
+ server.tool("update-data", "⚠️ HEAVY HAMMER — use ONLY for first-time page scaffold. Replaces the ENTIRE Elementor element tree of a page.\n\nWHEN TO USE: First creation of a brand-new page from scratch (one POST).\n\nWHEN NOT TO USE: Any edit to an existing page. Use surgical tools instead — they're faster, safer, and don't require you to reconstruct the whole tree:\n • Text change → set-text-content\n • Button text + link → set-button\n • Image swap → set-image-widget-src\n • Container layout (flex, justify, align) → set-container-layout\n • Container background → set-container-background\n • Typography → set-element-typography\n • Spacing / padding / margin → set-element-spacing\n • Border / shadow / radius → set-element-border / set-element-shadow\n • Add a widget → add-widget-to-page / insert-element / clone-element\n • Move/reorder element → move-element\n • Delete an element → delete-element\n • Swap widget type → change-widget-type\n • Restructure a container's children → restructure-container\n • Same change on many elements → bulk-set-property\n • Anything else on settings → merge-element-settings\n\nSIZE LIMIT: There is no MCP-enforced size limit. The PHP backend writes via Elementor's $document->save() and accepts whatever your WordPress server's post_max_size allows (typically 64MB+). Do NOT fall back to Python scripts to 'work around a size limit' — there isn't one. If your payload is huge, you're using the wrong tool.\n\nBy default refuses if the new data is dramatically smaller than existing (protects against accidental wipeouts). Use force_replace=true to override.", {
1816
1816
  page_id: z.string().describe("WordPress Page ID"),
1817
1817
  elements_json: z.string().describe("Full elements array as stringified JSON"),
1818
1818
  force_replace: z.boolean().optional().describe("Set true to allow replacing with dramatically fewer top-level elements"),
@@ -3805,6 +3805,277 @@ function createMcpServer(sites) {
3805
3805
  return { content: [{ type: "text", text: `Error listing media library: ${error.response?.data?.message || error.message}` }], isError: true };
3806
3806
  }
3807
3807
  });
3808
+ // ═══════════════════════════════════════════════════════════════════════════
3809
+ // ── v3.16.0 — Coverage gap closers ────────────────────────────────────────
3810
+ // These five tools fill the gaps that previously forced fallback to
3811
+ // update-data (the heavy hammer). After v3.16.0, update-data should ONLY
3812
+ // be used for the very first scaffold of a new page. Every subsequent
3813
+ // change uses one of the surgical tools below or an earlier typed setter.
3814
+ // ═══════════════════════════════════════════════════════════════════════════
3815
+ // ── delete-element ────────────────────────────────────────────────────────
3816
+ server.tool("delete-element", "Remove an Elementor element from a page by ID. Surgical, server-side, no full-page download. Works for any element (widget, container, section). For top-level sections you must pass force=true to confirm — protects against accidental wipeouts. After deleting, the parent container's children array is automatically resaved. Use this INSTEAD of update-data when you want to remove just one element.", {
3817
+ page_id: z.string().describe("WordPress Page ID"),
3818
+ element_id: z.string().describe("Elementor Element ID to remove"),
3819
+ force: z.boolean().optional().describe("Required true to delete a top-level section (safety guard). Children/widgets inside a container delete without force."),
3820
+ site: siteParam,
3821
+ }, async ({ page_id, element_id, force, site }) => {
3822
+ try {
3823
+ const { wpUrl, authHeader } = resolveSite(sites, site);
3824
+ const r = await axios.delete(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/elements/${element_id}`, {
3825
+ headers: { Authorization: authHeader },
3826
+ params: { force_delete: force === true ? "true" : "false" },
3827
+ });
3828
+ return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
3829
+ }
3830
+ catch (error) {
3831
+ return { content: [{ type: "text", text: `Error deleting element: ${error.response?.data?.message || error.message}` }], isError: true };
3832
+ }
3833
+ });
3834
+ // ── change-widget-type ────────────────────────────────────────────────────
3835
+ server.tool("change-widget-type", "Swap a widget's type (e.g. heading → button, text-editor → heading) while keeping its position in the page. Old settings are cleared (they belong to the OLD widget type and wouldn't render under the new one); pass new_settings to seed the new widget. Use this INSTEAD of delete + insert when you want to keep the same element ID and avoid disturbing surrounding flex order. NEVER use update-data for type swaps.", {
3836
+ page_id: z.string().describe("WordPress Page ID"),
3837
+ element_id: z.string().describe("Existing widget ID to convert"),
3838
+ new_widget: z.enum(["heading", "text-editor", "button", "image", "icon", "icon-box", "spacer", "divider", "video", "html", "shortcode"]).describe("Target widgetType"),
3839
+ new_settings: z.record(z.any()).optional().describe("Initial settings for the new widget (e.g. { title: 'Click me', link: { url: '/contact' } }). Empty by default."),
3840
+ site: siteParam,
3841
+ }, async ({ page_id, element_id, new_widget, new_settings, site }) => {
3842
+ try {
3843
+ const { wpUrl, authHeader } = resolveSite(sites, site);
3844
+ // GET current element
3845
+ const getRes = await axios.get(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/elements/${element_id}`, { headers: { Authorization: authHeader } });
3846
+ const current = getRes.data;
3847
+ if (current.elType !== "widget") {
3848
+ return { content: [{ type: "text", text: `Element ${element_id} is elType="${current.elType}", not "widget". change-widget-type only works on widgets. For containers, use restructure-container.` }], isError: true };
3849
+ }
3850
+ // POST with new widgetType + fresh settings (top-level array_merge in PHP will replace)
3851
+ const body = {
3852
+ id: element_id,
3853
+ elType: "widget",
3854
+ widgetType: new_widget,
3855
+ settings: new_settings ?? {},
3856
+ elements: [], // widgets don't have children
3857
+ };
3858
+ const postRes = await axios.post(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/elements/${element_id}`, body, { headers: { Authorization: authHeader, "Content-Type": "application/json" } });
3859
+ return { content: [{ type: "text", text: JSON.stringify({ from: current.widgetType, to: new_widget, ...postRes.data }, null, 2) }] };
3860
+ }
3861
+ catch (error) {
3862
+ return { content: [{ type: "text", text: `Error changing widget type: ${error.response?.data?.message || error.message}` }], isError: true };
3863
+ }
3864
+ });
3865
+ // ── restructure-container ─────────────────────────────────────────────────
3866
+ server.tool("restructure-container", "Replace a container's entire children array — the surgical way to split one column into two, add a column, reorder children, or rebuild a container's internal layout WITHOUT touching anything outside that container. Children are described in the same typed format as create-section's children param (container / heading / text / button / image / spacer). The container's own settings (flex direction, padding, background) are preserved. Use this INSTEAD of update-data for ANY structural change inside a single container.", {
3867
+ page_id: z.string().describe("WordPress Page ID"),
3868
+ container_id: z.string().describe("Container Element ID whose children you want to replace"),
3869
+ children: z.array(z.any()).describe("New children array in typed format. Same schema as create-section's `children` field. Example: [{ type:'container', flex_direction:'column', children:[ {type:'heading', text:'Hi'} ] }]"),
3870
+ site: siteParam,
3871
+ }, async ({ page_id, container_id, children, site }) => {
3872
+ try {
3873
+ const { wpUrl, authHeader } = resolveSite(sites, site);
3874
+ // GET current container
3875
+ const getRes = await axios.get(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/elements/${container_id}`, { headers: { Authorization: authHeader } });
3876
+ const current = getRes.data;
3877
+ if (current.elType !== "container") {
3878
+ return { content: [{ type: "text", text: `Element ${container_id} is elType="${current.elType}", not "container". restructure-container only works on containers.` }], isError: true };
3879
+ }
3880
+ // Build the new children array via the shared buildChild helper
3881
+ const builtChildren = children.map((c) => buildChild(c));
3882
+ // POST back the container with its own id/elType/settings preserved + new children
3883
+ const body = {
3884
+ id: container_id,
3885
+ elType: "container",
3886
+ settings: current.settings || {},
3887
+ elements: builtChildren,
3888
+ };
3889
+ const postRes = await axios.post(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/elements/${container_id}`, body, { headers: { Authorization: authHeader, "Content-Type": "application/json" } });
3890
+ return { content: [{ type: "text", text: JSON.stringify({ container_id, new_child_count: builtChildren.length, ...postRes.data }, null, 2) }] };
3891
+ }
3892
+ catch (error) {
3893
+ return { content: [{ type: "text", text: `Error restructuring container: ${error.response?.data?.message || error.message}` }], isError: true };
3894
+ }
3895
+ });
3896
+ // ── bulk-set-property ─────────────────────────────────────────────────────
3897
+ server.tool("bulk-set-property", "Apply one settings patch to every element on a page matching a filter — e.g. 'set color = primary global on all h2 headings', 'add padding 24px to every button', 'set background = light-bg on every container with css_class=\"card\"'. Runs the filter in-memory on the page tree, then issues a parallel batch of merge-element-settings calls. Use this INSTEAD of looping with update-data when you need the same change in many places.", {
3898
+ page_id: z.string().describe("WordPress Page ID"),
3899
+ match_elType: z.enum(["widget", "container", "section", "column"]).optional().describe("Filter by elType (e.g. 'container' to target only containers)"),
3900
+ match_widget_type: z.string().optional().describe("Filter by widgetType (e.g. 'heading', 'button', 'image')"),
3901
+ match_css_class: z.string().optional().describe("Filter by CSS class substring (matches if class list contains this token)"),
3902
+ match_text_value: z.string().optional().describe("Filter by current text contents (case-insensitive substring match against title/editor/text settings)"),
3903
+ settings: z.record(z.any()).describe("Settings patch to deep-merge into every matched element (same shape as merge-element-settings)"),
3904
+ dry_run: z.boolean().optional().describe("Return matched IDs without applying (default false)"),
3905
+ site: siteParam,
3906
+ }, async ({ page_id, match_elType, match_widget_type, match_css_class, match_text_value, settings, dry_run, site }) => {
3907
+ try {
3908
+ const { wpUrl, authHeader } = resolveSite(sites, site);
3909
+ if (!match_elType && !match_widget_type && !match_css_class && !match_text_value) {
3910
+ return { content: [{ type: "text", text: "Refusing to operate without at least one match_* filter — that would touch every element on the page. Add at least one filter parameter." }], isError: true };
3911
+ }
3912
+ // Pull the full page tree once
3913
+ const pageRes = await axios.get(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/data`, { headers: { Authorization: authHeader } });
3914
+ const elements = pageRes.data?.elements ?? pageRes.data ?? [];
3915
+ const matched = [];
3916
+ const txtNeedle = match_text_value?.toLowerCase();
3917
+ const walk = (els) => {
3918
+ for (const el of els) {
3919
+ const elT = el.elType;
3920
+ const wT = el.widgetType;
3921
+ const cls = (el?.settings?._css_classes ?? "");
3922
+ const textVals = [el?.settings?.title, el?.settings?.editor, el?.settings?.text]
3923
+ .filter((v) => typeof v === "string")
3924
+ .map((v) => v.toLowerCase());
3925
+ let ok = true;
3926
+ if (match_elType && elT !== match_elType)
3927
+ ok = false;
3928
+ if (match_widget_type && wT !== match_widget_type)
3929
+ ok = false;
3930
+ if (match_css_class && !String(cls).split(/\s+/).includes(match_css_class))
3931
+ ok = false;
3932
+ if (txtNeedle && !textVals.some((v) => v.includes(txtNeedle)))
3933
+ ok = false;
3934
+ if (ok && el.id) {
3935
+ matched.push({ id: el.id, elType: elT, widgetType: wT });
3936
+ }
3937
+ if (Array.isArray(el.elements) && el.elements.length > 0)
3938
+ walk(el.elements);
3939
+ }
3940
+ };
3941
+ walk(elements);
3942
+ if (dry_run) {
3943
+ return { content: [{ type: "text", text: JSON.stringify({ dry_run: true, matched_count: matched.length, matched }, null, 2) }] };
3944
+ }
3945
+ // Apply in parallel — each one is its own GET+merge+POST, but they're independent
3946
+ const results = await Promise.allSettled(matched.map((m) => applyElementSettings(wpUrl, authHeader, page_id, m.id, settings)));
3947
+ const okCount = results.filter((r) => r.status === "fulfilled").length;
3948
+ const failures = results
3949
+ .map((r, i) => r.status === "rejected" ? { id: matched[i].id, error: String(r.reason) } : null)
3950
+ .filter(Boolean);
3951
+ return { content: [{ type: "text", text: JSON.stringify({ matched_count: matched.length, applied: okCount, failed: failures.length, failures }, null, 2) }] };
3952
+ }
3953
+ catch (error) {
3954
+ return { content: [{ type: "text", text: `Error in bulk-set-property: ${error.response?.data?.message || error.message}` }], isError: true };
3955
+ }
3956
+ });
3957
+ // ── set-button ────────────────────────────────────────────────────────────
3958
+ server.tool("set-button", "One-call setter for a button widget — sets text + link + (optional) icon + (optional) size/style in one shot. Replaces the 'set-text-content then set-element-link' two-step. Use this for ANY button content change. Idempotent: omit a field to leave it unchanged.", {
3959
+ page_id: z.string().describe("WordPress Page ID"),
3960
+ element_id: z.string().describe("Button widget ID"),
3961
+ text: z.string().optional().describe("Button label text"),
3962
+ url: z.string().optional().describe("Button link URL"),
3963
+ open_in_new_tab: z.boolean().optional().describe("Native Elementor 'Open in new window' toggle"),
3964
+ nofollow: z.boolean().optional().describe("Native Elementor 'Add nofollow' toggle"),
3965
+ icon: z.string().optional().describe("Elementor icon library value, e.g. 'fas fa-arrow-right'. Omit to leave icon alone."),
3966
+ icon_align: z.enum(["left", "right"]).optional().describe("Icon position relative to text"),
3967
+ button_size: z.enum(["xs", "sm", "md", "lg", "xl"]).optional().describe("Elementor preset size"),
3968
+ button_type: z.enum(["default", "info", "success", "warning", "danger"]).optional().describe("Elementor color preset"),
3969
+ site: siteParam,
3970
+ }, async ({ page_id, element_id, text, url, open_in_new_tab, nofollow, icon, icon_align, button_size, button_type, site }) => {
3971
+ try {
3972
+ const { wpUrl, authHeader } = resolveSite(sites, site);
3973
+ // 1) Settings patch — text/icon/size/type via applyElementSettings
3974
+ const s = {};
3975
+ if (text !== undefined)
3976
+ s.text = text;
3977
+ if (icon !== undefined)
3978
+ s.selected_icon = { value: icon, library: "fa-solid" };
3979
+ if (icon_align !== undefined)
3980
+ s.icon_align = icon_align;
3981
+ if (button_size !== undefined)
3982
+ s.size = button_size;
3983
+ if (button_type !== undefined)
3984
+ s.button_type = button_type;
3985
+ let settingsResult = null;
3986
+ if (Object.keys(s).length > 0) {
3987
+ settingsResult = await applyElementSettings(wpUrl, authHeader, page_id, element_id, s);
3988
+ }
3989
+ // 2) Link — via the dedicated /link endpoint (handles is_external, nofollow natively)
3990
+ let linkResult = null;
3991
+ if (url !== undefined || open_in_new_tab !== undefined || nofollow !== undefined) {
3992
+ const body = {};
3993
+ if (url !== undefined)
3994
+ body.url = url;
3995
+ if (open_in_new_tab !== undefined)
3996
+ body.open_in_new_tab = open_in_new_tab;
3997
+ if (nofollow !== undefined)
3998
+ body.nofollow = nofollow;
3999
+ const r = await axios.post(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/elements/${element_id}/link`, body, { headers: { Authorization: authHeader, "Content-Type": "application/json" } });
4000
+ linkResult = r.data;
4001
+ }
4002
+ return { content: [{ type: "text", text: JSON.stringify({ settings: settingsResult, link: linkResult }, null, 2) }] };
4003
+ }
4004
+ catch (error) {
4005
+ return { content: [{ type: "text", text: `Error setting button: ${error.response?.data?.message || error.message}` }], isError: true };
4006
+ }
4007
+ });
4008
+ // ── set-css-filters ───────────────────────────────────────────────────────
4009
+ server.tool("set-css-filters", "Apply CSS filter effects to an image widget (or any widget via the Advanced tab) — blur, brightness, contrast, saturation, hue rotation. Use this when a Figma layer has image adjustments applied (Saturation slider, Contrast, Exposure, etc.).\n\nFIGMA → ELEMENTOR VALUE MAPPING (caller must convert):\n • Figma Saturation -100..+100 → Elementor saturation 0..200 (formula: 100 + figma_value). A fully-desaturated Figma image (-100) → Elementor saturation=0 (B&W).\n • Figma Contrast -100..+100 → Elementor contrast 0..200.\n • Figma Exposure -100..+100 → Elementor brightness 0..200 (closest CSS-filter equivalent).\n • Figma Highlights / Shadows / Temperature / Tint → no clean CSS-filter equivalent. Surface as a TODO; do NOT improvise with brightness/contrast.\n • Figma Blur → Elementor blur in px.\n\nELEMENTOR-NATIVE RANGES (what this tool accepts directly):\n • blur: 0..30 (px, 0 = none)\n • brightness: 0..200 (100 = neutral, <100 darker, >100 brighter)\n • contrast: 0..200 (100 = neutral)\n • saturation: 0..200 (100 = neutral, 0 = B&W, 200 = oversaturated)\n • hue: 0..360 (degrees, 0 = no rotation)\n\nAutomatically sets `css_filters_css_filter='custom'` so the values take effect. Pass `reset=true` to clear all filters (returns the widget to the default 'normal' filter mode). For non-image widgets, set `apply_to='any-widget'` to use the Advanced-tab `_css_filters_*` keys instead of the image-widget Style-tab `css_filters_*` keys.", {
4010
+ page_id: z.string().describe("WordPress Page ID"),
4011
+ element_id: z.string().describe("Element ID (typically an image widget; or any widget when apply_to='any-widget')"),
4012
+ blur: z.number().optional().describe("Blur in px (0..30). 0 = no blur."),
4013
+ brightness: z.number().optional().describe("Brightness 0..200 (100 = neutral)"),
4014
+ contrast: z.number().optional().describe("Contrast 0..200 (100 = neutral)"),
4015
+ saturation: z.number().optional().describe("Saturation 0..200 (100 = neutral, 0 = B&W). For a Figma layer with Saturation=-100, pass 0 here."),
4016
+ hue: z.number().optional().describe("Hue rotation in degrees 0..360"),
4017
+ hover_blur: z.number().optional().describe("Blur on hover (px)"),
4018
+ hover_brightness: z.number().optional().describe("Brightness on hover (0..200)"),
4019
+ hover_contrast: z.number().optional().describe("Contrast on hover (0..200)"),
4020
+ hover_saturation: z.number().optional().describe("Saturation on hover (0..200)"),
4021
+ hover_hue: z.number().optional().describe("Hue on hover (degrees)"),
4022
+ apply_to: z.enum(["image-widget", "any-widget"]).optional().describe("Default 'image-widget' (Style-tab keys, css_filters_*). Use 'any-widget' for filters on a non-image widget via the Advanced tab (_css_filters_*)."),
4023
+ reset: z.boolean().optional().describe("Clear all filters and set css_filter mode back to 'normal'. All numeric params are ignored when reset=true."),
4024
+ site: siteParam,
4025
+ }, async ({ page_id, element_id, blur, brightness, contrast, saturation, hue, hover_blur, hover_brightness, hover_contrast, hover_saturation, hover_hue, apply_to, reset, site }) => {
4026
+ try {
4027
+ const { wpUrl, authHeader } = resolveSite(sites, site);
4028
+ const prefix = (apply_to ?? "image-widget") === "any-widget" ? "_css_filters_" : "css_filters_";
4029
+ const slider = (n, unit = "px") => ({ unit, size: n, sizes: [] });
4030
+ const s = {};
4031
+ if (reset === true) {
4032
+ s[`${prefix}css_filter`] = "normal";
4033
+ // Zero out values explicitly so the editor doesn't keep showing stale settings
4034
+ s[`${prefix}blur`] = slider(0);
4035
+ s[`${prefix}brightness`] = slider(100);
4036
+ s[`${prefix}contrast`] = slider(100);
4037
+ s[`${prefix}saturation`] = slider(100);
4038
+ s[`${prefix}hue`] = slider(0);
4039
+ }
4040
+ else {
4041
+ // Any filter param triggers 'custom' mode
4042
+ const anySet = blur !== undefined || brightness !== undefined || contrast !== undefined ||
4043
+ saturation !== undefined || hue !== undefined ||
4044
+ hover_blur !== undefined || hover_brightness !== undefined || hover_contrast !== undefined ||
4045
+ hover_saturation !== undefined || hover_hue !== undefined;
4046
+ if (!anySet) {
4047
+ return { content: [{ type: "text", text: "No filter parameters provided. Pass at least one of blur/brightness/contrast/saturation/hue (or hover_*) — or pass reset=true." }], isError: true };
4048
+ }
4049
+ s[`${prefix}css_filter`] = "custom";
4050
+ if (blur !== undefined)
4051
+ s[`${prefix}blur`] = slider(blur);
4052
+ if (brightness !== undefined)
4053
+ s[`${prefix}brightness`] = slider(brightness);
4054
+ if (contrast !== undefined)
4055
+ s[`${prefix}contrast`] = slider(contrast);
4056
+ if (saturation !== undefined)
4057
+ s[`${prefix}saturation`] = slider(saturation);
4058
+ if (hue !== undefined)
4059
+ s[`${prefix}hue`] = slider(hue);
4060
+ // Hover variants
4061
+ if (hover_blur !== undefined)
4062
+ s[`${prefix}hover_blur`] = slider(hover_blur);
4063
+ if (hover_brightness !== undefined)
4064
+ s[`${prefix}hover_brightness`] = slider(hover_brightness);
4065
+ if (hover_contrast !== undefined)
4066
+ s[`${prefix}hover_contrast`] = slider(hover_contrast);
4067
+ if (hover_saturation !== undefined)
4068
+ s[`${prefix}hover_saturation`] = slider(hover_saturation);
4069
+ if (hover_hue !== undefined)
4070
+ s[`${prefix}hover_hue`] = slider(hover_hue);
4071
+ }
4072
+ const result = await applyElementSettings(wpUrl, authHeader, page_id, element_id, s);
4073
+ return { content: [{ type: "text", text: JSON.stringify({ applied: s, result }, null, 2) }] };
4074
+ }
4075
+ catch (error) {
4076
+ return { content: [{ type: "text", text: `Error setting CSS filters: ${error.response?.data?.message || error.message}` }], isError: true };
4077
+ }
4078
+ });
3808
4079
  // ── validate-page-structure ───────────────────────────────────────────────
3809
4080
  server.tool("validate-page-structure", "Post-build sanity check on a page. Returns a report of structural / standards violations: header widgets in page body (should be a template), images with external URLs missing WP attachment IDs, hardcoded fonts/colors that should use kit globals, legacy _element_width on flex children, missing image alt text, suspicious min-heights. Call this AFTER building a page from Figma. If errors are found, fix them with the typed tools and re-run. If only warnings remain, surface them to the human for review.", {
3810
4081
  page_id: z.string().describe("WordPress Page ID to validate"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yashwant.dharmdas/elementor-mcp",
3
- "version": "3.15.0",
3
+ "version": "3.17.0",
4
4
  "description": "MCP server for controlling Elementor via Claude — supports multiple WordPress sites",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",