@diviops/mcp-server 1.5.13 → 1.5.14

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.
@@ -25,8 +25,9 @@
25
25
  import { emitButtonGroupPreset, buildPresetCreateBody, } from "./button-emitter.js";
26
26
  import { emitHeadingFontGroupPreset, buildHeadingFontPresetCreateBody, UnsupportedVariantCombinationError, } from "./heading-font-emitter.js";
27
27
  import { emitTextBodyFontGroupPreset, buildTextBodyFontPresetCreateBody, } from "./text-body-font-emitter.js";
28
+ import { emitSpacingGroupPreset, buildSpacingPresetCreateBody, } from "./spacing-emitter.js";
28
29
  import { EvidenceGateError } from "./registry.js";
29
- import { applyButtonPreset, applyHeadingFontPreset, applyTextBodyFontPreset, buildClientFromEnv, CredentialsMissingError, CapabilityMissingError, } from "./write-path.js";
30
+ import { applyButtonPreset, applyHeadingFontPreset, applyTextBodyFontPreset, applySpacingPreset, buildClientFromEnv, CredentialsMissingError, CapabilityMissingError, } from "./write-path.js";
30
31
  export const EXIT = {
31
32
  OK: 0,
32
33
  INVALID_INPUT: 1,
@@ -46,6 +47,8 @@ USAGE
46
47
  divi/heading
47
48
  diviops-preset text-body-font [options] Emit a divi/font-body group preset
48
49
  for divi/text (Pattern A only)
50
+ diviops-preset spacing [options] Emit a divi/spacing group preset
51
+ (currently divi/section only)
49
52
  diviops-preset --help Show this help
50
53
 
51
54
  MODE
@@ -123,6 +126,38 @@ text-body-font OPTIONS (all styling fields optional; emit-on-specification only)
123
126
  --font-size <v> Font size (e.g. "16px").
124
127
  --font-line-height <v> Font line-height (e.g. "1.5").
125
128
 
129
+ spacing OPTIONS (sparse-emit per axis; paired sync flags per axis)
130
+ --name <string> Preset display name (required).
131
+ --module <name> Required. Currently only divi/section is wired
132
+ (Track 7a verified only the divi/section cell).
133
+ Other modules (divi/heading, divi/text,
134
+ divi/button, etc.) resolve to the registry gate
135
+ and are refused with EvidenceGateError —
136
+ promoting them requires a Track-7a-style
137
+ canonical capture PR plus a follow-up
138
+ implementation/docs PR (NOT a free dispatch-
139
+ clear via the gate alone).
140
+ --padding-top <v> Desktop padding corners. Pass any subset; only
141
+ --padding-right <v> passed corners emit (sparse-emit per axis). v1
142
+ --padding-bottom <v> accepts literal CSS lengths only (px / rem /
143
+ --padding-left <v> em / % / vw / vh) — $variable(...) / gvid-*
144
+ tokens are refused (deferred until canonical
145
+ variable-token capture lands).
146
+ --margin-top <v> Desktop margin corners. Same shape rules as
147
+ --margin-right <v> padding; padding and margin are independent —
148
+ --margin-bottom <v> passing only padding flags omits the margin bag
149
+ --margin-left <v> from the output, and vice versa.
150
+ --padding-sync-vertical <on|off>
151
+ Explicit padding sync flag (default "off").
152
+ Both syncVertical AND syncHorizontal always
153
+ --padding-sync-horizontal <on|off>
154
+ emit as paired siblings when the padding axis
155
+ has any touched corner.
156
+ --margin-sync-vertical <on|off>
157
+ Explicit margin sync flag (default "off").
158
+ --margin-sync-horizontal <on|off>
159
+ Same paired-siblings rule as padding.
160
+
126
161
  EXIT CODES
127
162
  0 success 1 invalid input 2 evidence-gate refusal
128
163
  3 capability missing 4 write error
@@ -145,6 +180,9 @@ EXAMPLES
145
180
  diviops-preset text-body-font --name "Body Text" --pattern google \\
146
181
  --font-family Inter --font-weight 400 \\
147
182
  --font-color gcid-body-color --font-size 16px
183
+
184
+ diviops-preset spacing --name "Section Rhythm" --module divi/section \\
185
+ --padding-top 80px --padding-bottom 80px --margin-bottom 40px
148
186
  `;
149
187
  const VALUE_FLAGS = new Set([
150
188
  "--name",
@@ -165,6 +203,19 @@ const VALUE_FLAGS = new Set([
165
203
  "--font-size",
166
204
  "--font-line-height",
167
205
  "--pattern",
206
+ "--module",
207
+ "--padding-top",
208
+ "--padding-right",
209
+ "--padding-bottom",
210
+ "--padding-left",
211
+ "--margin-top",
212
+ "--margin-right",
213
+ "--margin-bottom",
214
+ "--margin-left",
215
+ "--padding-sync-vertical",
216
+ "--padding-sync-horizontal",
217
+ "--margin-sync-vertical",
218
+ "--margin-sync-horizontal",
168
219
  ]);
169
220
  /** Parse argv (after `node script`) into a structured shape. Throws on unknown flags. */
170
221
  export function parseArgs(argv) {
@@ -229,7 +280,12 @@ export class UsageError extends Error {
229
280
  }
230
281
  }
231
282
  /** Known CLI commands. The CLI rejects anything else with EXIT.INVALID_INPUT. */
232
- const KNOWN_COMMANDS = new Set(["button", "heading-font", "text-body-font"]);
283
+ const KNOWN_COMMANDS = new Set([
284
+ "button",
285
+ "heading-font",
286
+ "text-body-font",
287
+ "spacing",
288
+ ]);
233
289
  /** Map parsed `button` options into the emitter input shape. */
234
290
  export function buildButtonInput(parsed) {
235
291
  const opt = (k) => {
@@ -394,6 +450,67 @@ export function buildTextBodyFontInput(parsed) {
394
450
  input.lineHeight = lineHeight;
395
451
  return input;
396
452
  }
453
+ /** Parse an optional `on|off` flag value; throw a usage error otherwise. */
454
+ function parseOnOff(raw, flag) {
455
+ if (raw === undefined)
456
+ return undefined;
457
+ if (raw !== "on" && raw !== "off") {
458
+ throw new UsageError(`${flag} must be "on" or "off"; got ${JSON.stringify(raw)}.`);
459
+ }
460
+ return raw;
461
+ }
462
+ /** Map parsed `spacing` options into the spacing emitter input shape. */
463
+ export function buildSpacingInput(parsed) {
464
+ const opt = (k) => {
465
+ const v = parsed.options.get(k);
466
+ return typeof v === "string" ? v : undefined;
467
+ };
468
+ const name = opt("--name");
469
+ if (!name) {
470
+ throw new UsageError("spacing command requires --name <string>.");
471
+ }
472
+ const module = opt("--module");
473
+ if (!module) {
474
+ throw new UsageError("spacing command requires --module <name>. Currently only " +
475
+ "divi/section is wired; other modules are refused by the registry " +
476
+ "gate (heading/text/button cells are SCHEMA_OBSERVED).");
477
+ }
478
+ const input = { name, module };
479
+ // Sparse-emit at parse time too: only attach an axis bag when at least
480
+ // one corner OR a sync flag was passed. (Sync-flag-only input lands on
481
+ // the emitter's per-axis sync-without-corner refusal.) Padding and
482
+ // margin follow the identical shape rule, so the per-axis collection
483
+ // is hoisted into a single helper.
484
+ const buildAxis = (prefix) => {
485
+ const bag = {};
486
+ const t = opt(`--${prefix}-top`);
487
+ if (t !== undefined)
488
+ bag.top = t;
489
+ const r = opt(`--${prefix}-right`);
490
+ if (r !== undefined)
491
+ bag.right = r;
492
+ const b = opt(`--${prefix}-bottom`);
493
+ if (b !== undefined)
494
+ bag.bottom = b;
495
+ const l = opt(`--${prefix}-left`);
496
+ if (l !== undefined)
497
+ bag.left = l;
498
+ const sv = parseOnOff(opt(`--${prefix}-sync-vertical`), `--${prefix}-sync-vertical`);
499
+ if (sv !== undefined)
500
+ bag.syncVertical = sv;
501
+ const sh = parseOnOff(opt(`--${prefix}-sync-horizontal`), `--${prefix}-sync-horizontal`);
502
+ if (sh !== undefined)
503
+ bag.syncHorizontal = sh;
504
+ return Object.keys(bag).length > 0 ? bag : undefined;
505
+ };
506
+ const padding = buildAxis("padding");
507
+ if (padding)
508
+ input.padding = padding;
509
+ const margin = buildAxis("margin");
510
+ if (margin)
511
+ input.margin = margin;
512
+ return input;
513
+ }
397
514
  /**
398
515
  * Run the CLI. Returns the structured exit code (does NOT call
399
516
  * `process.exit` — the thin bin wrapper does). `io` is injectable so
@@ -449,6 +566,14 @@ export async function run(argv, io = realIO, serverVersion) {
449
566
  dryRunBody = buildTextBodyFontPresetCreateBody(entry, { dry_run: true });
450
567
  applyFn = (client, sv) => applyTextBodyFontPreset(client, entry, { serverVersion: sv });
451
568
  }
569
+ else if (parsed.command === "spacing") {
570
+ // spacing (divi/section only — other modules land on the registry
571
+ // gate in emitSpacingGroupPreset and surface as EvidenceGateError).
572
+ const input = buildSpacingInput(parsed);
573
+ const entry = emitSpacingGroupPreset(input);
574
+ dryRunBody = buildSpacingPresetCreateBody(entry, { dry_run: true });
575
+ applyFn = (client, sv) => applySpacingPreset(client, entry, { serverVersion: sv });
576
+ }
452
577
  else {
453
578
  // Defensive: a new entry in KNOWN_COMMANDS without a dispatch
454
579
  // branch here would silently break dry-run/apply. parseArgs
@@ -0,0 +1,132 @@
1
+ /**
2
+ * `divi/spacing` section group-preset emitter — Track 7b vertical slice.
3
+ *
4
+ * Emits a byte-canonical Divi 5.6.0 `type: "group"` `divi/spacing` preset
5
+ * targeting `divi/section` at
6
+ * `attrs.module.decoration.spacing.desktop.value.{padding|margin}.*`,
7
+ * gated by the verified-attrs registry. Canonical shape:
8
+ * `docs/verification/evidence/canonical-shape-dumps-2026-05-23/round-5-spacing-section.json`.
9
+ *
10
+ * Scope: `divi/section` ONLY. Heading / text / button spacing cells remain
11
+ * `SCHEMA_OBSERVED` and resolve to `EvidenceGateError` here per
12
+ * `feedback_preset_map_per_module` (no cross-module shape carry-over).
13
+ *
14
+ * Shape rules enforced here (brief §4):
15
+ * - Sparse-emit per axis — only user-touched corners appear under
16
+ * `padding`/`margin`. Untouched corners are absent (not present-as-empty,
17
+ * not present-as-null).
18
+ * - Paired sync flags per axis — any spacing touch on an axis emits BOTH
19
+ * `syncVertical` AND `syncHorizontal` as siblings. Default `"off"` on
20
+ * both unless the caller explicitly toggled them.
21
+ * - Padding and margin are INDEPENDENT bags — passing only padding flags
22
+ * omits the margin bag entirely, and vice versa.
23
+ * - `attrs == styleAttrs == renderAttrs` at the route layer (the plugin's
24
+ * `/preset/create` route mirrors `attrs` into all three buckets to match
25
+ * VB save semantics); the CLI request body only carries `attrs`. The
26
+ * Track 7a fixture captures the post-write storage shape — do NOT add
27
+ * `styleAttrs` / `renderAttrs` keys to the emitter output.
28
+ * - `groupId: "designSpacing"` — the Composable Settings panel id, NOT a
29
+ * dotted attr path (the prior schema-inferred `module.decoration.spacing`
30
+ * note was a misread, corrected in PR #751).
31
+ * - `primaryAttrName: "module"` for section spacing.
32
+ * - Variable tokens (`$variable(...)` / bare `gvid-*`) in length flags are
33
+ * REFUSED — Track 7a capture exercised literal CSS lengths only;
34
+ * variable-token shape needs its own capture before this emitter writes
35
+ * it.
36
+ */
37
+ import { type VerifiedAttrsRegistry } from "./registry.js";
38
+ export declare const SPACING_GROUP_NAME = "divi/spacing";
39
+ export declare const SPACING_GROUP_ID = "designSpacing";
40
+ export declare const SPACING_PRIMARY_ATTR_NAME = "module";
41
+ export declare const SPACING_PATTERN_FAMILY = "divi/spacing";
42
+ /**
43
+ * The single currently-supported module cell. The `--module` flag accepts
44
+ * any string and routes through the registry gate (so the surface stays
45
+ * stable when heading/text/button cells eventually promote), but only
46
+ * `divi/section` is wired with fixtures + tests + canonical shape today.
47
+ *
48
+ * Promoting another module is NOT a free dispatch-clear via the registry
49
+ * gate — each new cell needs a Track-7a-style canonical capture PR landing
50
+ * first AND a follow-up implementation/docs PR.
51
+ */
52
+ export declare const SPACING_SUPPORTED_MODULES: readonly ["divi/section"];
53
+ export type SpacingCornerInput = {
54
+ top?: string;
55
+ right?: string;
56
+ bottom?: string;
57
+ left?: string;
58
+ syncVertical?: "on" | "off";
59
+ syncHorizontal?: "on" | "off";
60
+ };
61
+ export interface SpacingEmitterInput {
62
+ /** Required display name for the preset. */
63
+ name: string;
64
+ /**
65
+ * Required module selector. Forward-compat-shaped; only `divi/section` is
66
+ * wired today, everything else lands on `EvidenceGateError` via the
67
+ * registry gate (or a SCHEMA_OBSERVED cell evidence-gate refusal).
68
+ */
69
+ module: string;
70
+ /** Desktop padding corner-and-sync bag. */
71
+ padding?: SpacingCornerInput;
72
+ /** Desktop margin corner-and-sync bag. */
73
+ margin?: SpacingCornerInput;
74
+ }
75
+ /** The composed canonical preset entry shape sent to `/preset/create`. */
76
+ export interface SpacingPresetEntry {
77
+ type: "group";
78
+ module_name: string;
79
+ group_name: string;
80
+ group_id: string;
81
+ primary_attr_name: string;
82
+ name: string;
83
+ attrs: Record<string, unknown>;
84
+ }
85
+ /**
86
+ * Compose the canonical
87
+ * `attrs.module.decoration.spacing.desktop.value.{padding|margin}` bag.
88
+ * Sparse-emit per axis; padding and margin are independent.
89
+ *
90
+ * Returns BOTH the full `attrs` tree (what the emitter ships) AND the
91
+ * inner `value` bag — the caller does empty-preset validation against
92
+ * `value` without re-walking the nested attrs tree (no `as any` deep
93
+ * casts).
94
+ */
95
+ export declare function composeSpacingAttrs(input: SpacingEmitterInput): {
96
+ attrs: Record<string, unknown>;
97
+ value: Record<string, unknown>;
98
+ };
99
+ /**
100
+ * Emit a canonical `divi/spacing` section group preset.
101
+ *
102
+ * 1. Validate input shape (name, module, at least one corner across either
103
+ * axis).
104
+ * 2. Reject variable-token values in any length flag (deferred until a
105
+ * canonical capture lands).
106
+ * 3. Compose sparse-emit `attrs.module.decoration.spacing.desktop.value.*`
107
+ * with paired sync flags.
108
+ * 4. Gate `(divi/spacing, <module>)` against the verified-attrs registry —
109
+ * throws `EvidenceGateError` when effective evidence is below
110
+ * `VB_PRESET_STORAGE_VERIFIED`.
111
+ *
112
+ * `styleAttrs` and `renderAttrs` are intentionally NOT part of this entry:
113
+ * the plugin's `/preset/create` route mirrors the single `attrs` bag into
114
+ * all three buckets at write time. The Track 7a fixture captures the
115
+ * post-write storage shape (which is why both `attrs` and `styleAttrs`
116
+ * appear there byte-identical); the CLI emits the request shape only.
117
+ */
118
+ export declare function emitSpacingGroupPreset(input: SpacingEmitterInput, registry?: VerifiedAttrsRegistry): SpacingPresetEntry;
119
+ /**
120
+ * Build the `POST /diviops/v1/preset/create` request body from a spacing
121
+ * preset entry. Matches the body shape the `diviops_preset_create` MCP
122
+ * tool posts — the CLI reuses the existing route, it does not add one.
123
+ *
124
+ * `primary_attr_name` IS sent on the wire (the plugin's `/preset/create`
125
+ * route accepts it as an optional snake_case param and stores it as
126
+ * `primaryAttrName` in the preset). Tracks 4/5/6 emitters omit it because
127
+ * their preset types do not carry it; the divi/spacing cell does, per the
128
+ * Track 7a capture's load-bearing finding #2.
129
+ */
130
+ export declare function buildSpacingPresetCreateBody(entry: SpacingPresetEntry, opts?: {
131
+ dry_run?: boolean;
132
+ }): Record<string, unknown>;
@@ -0,0 +1,276 @@
1
+ /**
2
+ * `divi/spacing` section group-preset emitter — Track 7b vertical slice.
3
+ *
4
+ * Emits a byte-canonical Divi 5.6.0 `type: "group"` `divi/spacing` preset
5
+ * targeting `divi/section` at
6
+ * `attrs.module.decoration.spacing.desktop.value.{padding|margin}.*`,
7
+ * gated by the verified-attrs registry. Canonical shape:
8
+ * `docs/verification/evidence/canonical-shape-dumps-2026-05-23/round-5-spacing-section.json`.
9
+ *
10
+ * Scope: `divi/section` ONLY. Heading / text / button spacing cells remain
11
+ * `SCHEMA_OBSERVED` and resolve to `EvidenceGateError` here per
12
+ * `feedback_preset_map_per_module` (no cross-module shape carry-over).
13
+ *
14
+ * Shape rules enforced here (brief §4):
15
+ * - Sparse-emit per axis — only user-touched corners appear under
16
+ * `padding`/`margin`. Untouched corners are absent (not present-as-empty,
17
+ * not present-as-null).
18
+ * - Paired sync flags per axis — any spacing touch on an axis emits BOTH
19
+ * `syncVertical` AND `syncHorizontal` as siblings. Default `"off"` on
20
+ * both unless the caller explicitly toggled them.
21
+ * - Padding and margin are INDEPENDENT bags — passing only padding flags
22
+ * omits the margin bag entirely, and vice versa.
23
+ * - `attrs == styleAttrs == renderAttrs` at the route layer (the plugin's
24
+ * `/preset/create` route mirrors `attrs` into all three buckets to match
25
+ * VB save semantics); the CLI request body only carries `attrs`. The
26
+ * Track 7a fixture captures the post-write storage shape — do NOT add
27
+ * `styleAttrs` / `renderAttrs` keys to the emitter output.
28
+ * - `groupId: "designSpacing"` — the Composable Settings panel id, NOT a
29
+ * dotted attr path (the prior schema-inferred `module.decoration.spacing`
30
+ * note was a misread, corrected in PR #751).
31
+ * - `primaryAttrName: "module"` for section spacing.
32
+ * - Variable tokens (`$variable(...)` / bare `gvid-*`) in length flags are
33
+ * REFUSED — Track 7a capture exercised literal CSS lengths only;
34
+ * variable-token shape needs its own capture before this emitter writes
35
+ * it.
36
+ */
37
+ import { loadRegistry, gateWriteAttr, } from "./registry.js";
38
+ export const SPACING_GROUP_NAME = "divi/spacing";
39
+ export const SPACING_GROUP_ID = "designSpacing";
40
+ export const SPACING_PRIMARY_ATTR_NAME = "module";
41
+ export const SPACING_PATTERN_FAMILY = "divi/spacing";
42
+ /**
43
+ * The single currently-supported module cell. The `--module` flag accepts
44
+ * any string and routes through the registry gate (so the surface stays
45
+ * stable when heading/text/button cells eventually promote), but only
46
+ * `divi/section` is wired with fixtures + tests + canonical shape today.
47
+ *
48
+ * Promoting another module is NOT a free dispatch-clear via the registry
49
+ * gate — each new cell needs a Track-7a-style canonical capture PR landing
50
+ * first AND a follow-up implementation/docs PR.
51
+ */
52
+ export const SPACING_SUPPORTED_MODULES = ["divi/section"];
53
+ /** Detect bare `gvid-*` / `gcid-*` variable token names. */
54
+ const BARE_VARIABLE_TOKEN_RE = /^(gvid|gcid)-/;
55
+ /**
56
+ * Positive validator for CSS length values. Accepts only the v1 unit set
57
+ * from the brief: integer or decimal numbers (including a leading minus
58
+ * sign and bare `0`) followed by `px`, `rem`, `em`, `%`, `vw`, or `vh`.
59
+ * Track 7a capture exercised literal values only — variable tokens,
60
+ * `var(...)`, `calc(...)`, and free-form strings like `banana` are all
61
+ * refused before emission. Adding any new unit requires a brief update
62
+ * (canonical-capture re-test if the unit changes server-side rendering).
63
+ */
64
+ const LITERAL_CSS_LENGTH_RE = /^-?(?:\d+\.?\d*|\.\d+)(?:px|rem|em|%|vw|vh)$/;
65
+ /**
66
+ * Validate that `value` is a literal CSS length and not a deferred-shape
67
+ * token. The check runs on EVERY length flag before the emission path.
68
+ *
69
+ * Three refusal paths:
70
+ * 1. `$variable(...)` token — deferred shape, needs its own capture.
71
+ * 2. Bare `gvid-*` / `gcid-*` token name — same deferred shape.
72
+ * 3. Anything else that does NOT match the v1 literal CSS length grammar
73
+ * (`px`, `rem`, `em`, `%`, `vw`, `vh`). Catches `banana`, `var(--x)`,
74
+ * `calc(8px + 2vw)`, etc. — none of which Track 7a verified.
75
+ *
76
+ * The variable-token branches fire BEFORE the generic literal check so
77
+ * the operator gets the precise "variable-token support deferred" hint
78
+ * rather than the generic "not a literal CSS length" message.
79
+ */
80
+ function assertLiteralLength(value, flagLabel) {
81
+ // Trim before pattern-matching so leading / trailing whitespace can't
82
+ // bypass any prefix check (e.g. " gvid-space-1"). The raw value
83
+ // appears in the error message so the operator sees exactly what was
84
+ // passed.
85
+ const trimmed = value.trim();
86
+ if (trimmed.startsWith("$variable(")) {
87
+ throw new Error(`${flagLabel} value ${JSON.stringify(value)} is a $variable() token. ` +
88
+ `Variable-token support deferred — pending canonical capture. ` +
89
+ `Track 7a exercised literal CSS length values only (px / rem / em / % / vw / vh).`);
90
+ }
91
+ if (BARE_VARIABLE_TOKEN_RE.test(trimmed)) {
92
+ throw new Error(`${flagLabel} value ${JSON.stringify(value)} is a bare ${trimmed.split("-")[0]}-* variable token. ` +
93
+ `Variable-token support deferred — pending canonical capture. ` +
94
+ `Track 7a exercised literal CSS length values only (px / rem / em / % / vw / vh).`);
95
+ }
96
+ if (!LITERAL_CSS_LENGTH_RE.test(trimmed)) {
97
+ throw new Error(`${flagLabel} value ${JSON.stringify(value)} is not a literal CSS length. ` +
98
+ `v1 accepts only \`<number><unit>\` where unit is one of px / rem / em / % / vw / vh ` +
99
+ `(e.g. "40px", "1.5rem", "100%"). Track 7a canonical capture exercised literal ` +
100
+ `values only; broader value grammars (var(...), calc(...), etc.) need their own capture.`);
101
+ }
102
+ }
103
+ /**
104
+ * Compose a single axis bag (padding or margin) from a corner input.
105
+ *
106
+ * Returns `undefined` when the axis input is entirely absent (the axis is
107
+ * absent from the emitted attrs — sparse-emit at axis level). When ANY
108
+ * corner is passed, both sync flags are emitted as paired siblings
109
+ * (default `"off"` on each side unless the caller explicitly toggled
110
+ * them).
111
+ *
112
+ * Sync-flag-only input on an axis with no touched corner is REFUSED with
113
+ * an explicit error — silently no-op'ing a passed sync flag would be a
114
+ * surprising footgun. If the caller wants to toggle sync, they must also
115
+ * pass at least one corner on the same axis to anchor it.
116
+ */
117
+ function composeAxis(input, flagPrefix) {
118
+ if (!input)
119
+ return undefined;
120
+ const corners = ["top", "right", "bottom", "left"].filter((c) => input[c] !== undefined);
121
+ if (corners.length === 0) {
122
+ // Sync flag passed without any corner on the same axis → explicit
123
+ // refusal. The paired sync flags only have meaning anchored to at
124
+ // least one corner; emitting them alone would silently drop them.
125
+ if (input.syncVertical !== undefined ||
126
+ input.syncHorizontal !== undefined) {
127
+ throw new Error(`--${flagPrefix}-sync-vertical / --${flagPrefix}-sync-horizontal require at ` +
128
+ `least one --${flagPrefix}-{top,right,bottom,left} corner on the same axis. ` +
129
+ `Sync flags are paired-sibling metadata anchored to corners; emitting them ` +
130
+ `alone would silently no-op.`);
131
+ }
132
+ return undefined;
133
+ }
134
+ const bag = {};
135
+ for (const c of corners) {
136
+ const v = input[c];
137
+ if (typeof v !== "string" || v.length === 0) {
138
+ throw new Error(`--${flagPrefix}-${c} requires a non-empty CSS length value.`);
139
+ }
140
+ assertLiteralLength(v, `--${flagPrefix}-${c}`);
141
+ bag[c] = v;
142
+ }
143
+ bag.syncVertical = input.syncVertical ?? "off";
144
+ bag.syncHorizontal = input.syncHorizontal ?? "off";
145
+ return bag;
146
+ }
147
+ /**
148
+ * Compose the canonical
149
+ * `attrs.module.decoration.spacing.desktop.value.{padding|margin}` bag.
150
+ * Sparse-emit per axis; padding and margin are independent.
151
+ *
152
+ * Returns BOTH the full `attrs` tree (what the emitter ships) AND the
153
+ * inner `value` bag — the caller does empty-preset validation against
154
+ * `value` without re-walking the nested attrs tree (no `as any` deep
155
+ * casts).
156
+ */
157
+ export function composeSpacingAttrs(input) {
158
+ const padding = composeAxis(input.padding, "padding");
159
+ const margin = composeAxis(input.margin, "margin");
160
+ const value = {};
161
+ if (padding)
162
+ value.padding = padding;
163
+ if (margin)
164
+ value.margin = margin;
165
+ const attrs = {
166
+ [SPACING_PRIMARY_ATTR_NAME]: {
167
+ decoration: {
168
+ spacing: {
169
+ desktop: {
170
+ value,
171
+ },
172
+ },
173
+ },
174
+ },
175
+ };
176
+ return { attrs, value };
177
+ }
178
+ /**
179
+ * Emit a canonical `divi/spacing` section group preset.
180
+ *
181
+ * 1. Validate input shape (name, module, at least one corner across either
182
+ * axis).
183
+ * 2. Reject variable-token values in any length flag (deferred until a
184
+ * canonical capture lands).
185
+ * 3. Compose sparse-emit `attrs.module.decoration.spacing.desktop.value.*`
186
+ * with paired sync flags.
187
+ * 4. Gate `(divi/spacing, <module>)` against the verified-attrs registry —
188
+ * throws `EvidenceGateError` when effective evidence is below
189
+ * `VB_PRESET_STORAGE_VERIFIED`.
190
+ *
191
+ * `styleAttrs` and `renderAttrs` are intentionally NOT part of this entry:
192
+ * the plugin's `/preset/create` route mirrors the single `attrs` bag into
193
+ * all three buckets at write time. The Track 7a fixture captures the
194
+ * post-write storage shape (which is why both `attrs` and `styleAttrs`
195
+ * appear there byte-identical); the CLI emits the request shape only.
196
+ */
197
+ export function emitSpacingGroupPreset(input, registry = loadRegistry()) {
198
+ if (!input.name || typeof input.name !== "string") {
199
+ throw new Error("spacing emitter requires a non-empty `name`.");
200
+ }
201
+ if (!input.module || typeof input.module !== "string") {
202
+ throw new Error("spacing emitter requires `module` — currently only `divi/section` is " +
203
+ "wired (other module cells are SCHEMA_OBSERVED in the registry; " +
204
+ "promoting them requires a Track-7a-style canonical capture PR + a " +
205
+ "follow-up implementation/docs PR).");
206
+ }
207
+ const { attrs, value } = composeSpacingAttrs(input);
208
+ // Empty-input rejection: at least one corner across either axis MUST be
209
+ // specified. An empty value bag is a usage error — there is nothing to
210
+ // write, and the resulting preset would be the empty-shell VB authoring
211
+ // footgun documented in the Track 7a capture.
212
+ if (Object.keys(value).length === 0) {
213
+ throw new Error("spacing emitter produced an empty preset — pass at least one " +
214
+ "padding or margin corner (--padding-top, --margin-bottom, etc.).");
215
+ }
216
+ // Registry gate: dispatches off `(divi/spacing, <module>)`. Cells below
217
+ // VB_PRESET_STORAGE_VERIFIED (currently divi/heading, divi/text,
218
+ // divi/button — all SCHEMA_OBSERVED) throw EvidenceGateError natively
219
+ // here. Unknown modules with no applicability cell resolve to
220
+ // UNVERIFIED (0) and also throw.
221
+ gateWriteAttr(registry, input.module, SPACING_PATTERN_FAMILY);
222
+ // Implementation-supported-modules guard: even if a future registry-only
223
+ // PR promotes another cell to VB_PRESET_STORAGE_VERIFIED, this emitter
224
+ // must NOT silently start emitting it — heading/text/button cells need
225
+ // their own per-module wrapper (the section cell uses `module.*`, but a
226
+ // heading-spacing capture might surface `title.*` or a different
227
+ // primaryAttrName). Per `feedback_preset_map_per_module`, never assume
228
+ // cross-module shape carry-over. Promoting another module requires
229
+ // adding it to `SPACING_SUPPORTED_MODULES` alongside the fixture +
230
+ // tests + README updates that prove the per-module shape.
231
+ if (!SPACING_SUPPORTED_MODULES.includes(input.module)) {
232
+ throw new Error(`spacing emitter does not yet implement module "${input.module}". ` +
233
+ `Registry evidence cleared the cell, but the per-module wrapper + ` +
234
+ `attr shape have not been verified against a canonical capture ` +
235
+ `(this emitter hard-codes the divi/section wrapper "module" and ` +
236
+ `primaryAttrName "module"). Supported modules: ` +
237
+ `${SPACING_SUPPORTED_MODULES.join(", ")}. Adding a new module ` +
238
+ `requires a Track-7a-style canonical capture PR landing first AND ` +
239
+ `a follow-up implementation PR extending this constant alongside ` +
240
+ `fixtures + tests + README updates.`);
241
+ }
242
+ return {
243
+ type: "group",
244
+ module_name: input.module,
245
+ group_name: SPACING_GROUP_NAME,
246
+ group_id: SPACING_GROUP_ID,
247
+ primary_attr_name: SPACING_PRIMARY_ATTR_NAME,
248
+ name: input.name,
249
+ attrs,
250
+ };
251
+ }
252
+ /**
253
+ * Build the `POST /diviops/v1/preset/create` request body from a spacing
254
+ * preset entry. Matches the body shape the `diviops_preset_create` MCP
255
+ * tool posts — the CLI reuses the existing route, it does not add one.
256
+ *
257
+ * `primary_attr_name` IS sent on the wire (the plugin's `/preset/create`
258
+ * route accepts it as an optional snake_case param and stores it as
259
+ * `primaryAttrName` in the preset). Tracks 4/5/6 emitters omit it because
260
+ * their preset types do not carry it; the divi/spacing cell does, per the
261
+ * Track 7a capture's load-bearing finding #2.
262
+ */
263
+ export function buildSpacingPresetCreateBody(entry, opts = {}) {
264
+ const body = {
265
+ module_name: entry.module_name,
266
+ name: entry.name,
267
+ attrs: entry.attrs,
268
+ type: entry.type,
269
+ group_name: entry.group_name,
270
+ group_id: entry.group_id,
271
+ primary_attr_name: entry.primary_attr_name,
272
+ };
273
+ if (opts.dry_run)
274
+ body.dry_run = true;
275
+ return body;
276
+ }
@@ -20,6 +20,7 @@ import type { DiviopsResponse } from "../envelope.js";
20
20
  import type { ButtonPresetEntry } from "./button-emitter.js";
21
21
  import type { HeadingFontPresetEntry } from "./heading-font-emitter.js";
22
22
  import type { TextBodyFontPresetEntry } from "./text-body-font-emitter.js";
23
+ import type { SpacingPresetEntry } from "./spacing-emitter.js";
23
24
  /** The plugin capability the storage-path capability contract ships. */
24
25
  export declare const STORAGE_CAPABILITY = "storage_multipath_probe_v1";
25
26
  /** The REST route the CLI posts to — same route `diviops_preset_create` uses. */
@@ -88,3 +89,18 @@ export declare function applyTextBodyFontPreset(client: PresetWriteClient, entry
88
89
  serverVersion: string;
89
90
  dry_run?: boolean;
90
91
  }): Promise<DiviopsResponse<unknown>>;
92
+ /**
93
+ * Apply a `divi/spacing` section group preset: capability-gate, then POST
94
+ * to `/preset/create`. Mirrors `applyTextBodyFontPreset`'s sequence — the
95
+ * capability check runs BEFORE the write, and the write reuses the
96
+ * existing storage-routed route (no plugin route is added).
97
+ *
98
+ * Unlike the font emitters, the spacing entry carries `primary_attr_name`
99
+ * (`"module"` for the section cell per Track 7a), which IS sent on the
100
+ * wire — the `/preset/create` route accepts it as an optional snake_case
101
+ * param and stores it as `primaryAttrName` in the preset.
102
+ */
103
+ export declare function applySpacingPreset(client: PresetWriteClient, entry: SpacingPresetEntry, opts: {
104
+ serverVersion: string;
105
+ dry_run?: boolean;
106
+ }): Promise<DiviopsResponse<unknown>>;
@@ -18,6 +18,7 @@ import { WPClient } from "../wp-client.js";
18
18
  import { buildPresetCreateBody } from "./button-emitter.js";
19
19
  import { buildHeadingFontPresetCreateBody } from "./heading-font-emitter.js";
20
20
  import { buildTextBodyFontPresetCreateBody } from "./text-body-font-emitter.js";
21
+ import { buildSpacingPresetCreateBody } from "./spacing-emitter.js";
21
22
  /** The plugin capability the storage-path capability contract ships. */
22
23
  export const STORAGE_CAPABILITY = "storage_multipath_probe_v1";
23
24
  /** The REST route the CLI posts to — same route `diviops_preset_create` uses. */
@@ -130,3 +131,24 @@ export async function applyTextBodyFontPreset(client, entry, opts) {
130
131
  body,
131
132
  });
132
133
  }
134
+ /**
135
+ * Apply a `divi/spacing` section group preset: capability-gate, then POST
136
+ * to `/preset/create`. Mirrors `applyTextBodyFontPreset`'s sequence — the
137
+ * capability check runs BEFORE the write, and the write reuses the
138
+ * existing storage-routed route (no plugin route is added).
139
+ *
140
+ * Unlike the font emitters, the spacing entry carries `primary_attr_name`
141
+ * (`"module"` for the section cell per Track 7a), which IS sent on the
142
+ * wire — the `/preset/create` route accepts it as an optional snake_case
143
+ * param and stores it as `primaryAttrName` in the preset.
144
+ */
145
+ export async function applySpacingPreset(client, entry, opts) {
146
+ await assertStorageCapability(client, opts.serverVersion);
147
+ const body = buildSpacingPresetCreateBody(entry, {
148
+ dry_run: opts.dry_run,
149
+ });
150
+ return client.requestEnveloped(PRESET_CREATE_ROUTE, {
151
+ method: "POST",
152
+ body,
153
+ });
154
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diviops/mcp-server",
3
- "version": "1.5.13",
3
+ "version": "1.5.14",
4
4
  "description": "MCP server exposing Divi 5 Visual Builder as tools for Claude",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",