@diviops/mcp-server 1.5.9 → 1.5.11
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 +22 -1
- package/data/verified-attrs-backlog.json +197 -0
- package/data/verified-attrs.json +663 -0
- package/dist/index.js +38 -2
- package/dist/preset-cli/__tests__/button-emitter.test.d.ts +8 -0
- package/dist/preset-cli/__tests__/button-emitter.test.js +188 -0
- package/dist/preset-cli/__tests__/cli.test.d.ts +9 -0
- package/dist/preset-cli/__tests__/cli.test.js +149 -0
- package/dist/preset-cli/__tests__/preset-create-unchanged.test.d.ts +13 -0
- package/dist/preset-cli/__tests__/preset-create-unchanged.test.js +64 -0
- package/dist/preset-cli/__tests__/registry.test.d.ts +5 -0
- package/dist/preset-cli/__tests__/registry.test.js +134 -0
- package/dist/preset-cli/__tests__/write-path.test.d.ts +8 -0
- package/dist/preset-cli/__tests__/write-path.test.js +120 -0
- package/dist/preset-cli/bin.d.ts +8 -0
- package/dist/preset-cli/bin.js +32 -0
- package/dist/preset-cli/button-emitter.d.ts +117 -0
- package/dist/preset-cli/button-emitter.js +218 -0
- package/dist/preset-cli/cli.d.ts +59 -0
- package/dist/preset-cli/cli.js +326 -0
- package/dist/preset-cli/registry.d.ts +107 -0
- package/dist/preset-cli/registry.js +168 -0
- package/dist/preset-cli/variable-token.d.ts +42 -0
- package/dist/preset-cli/variable-token.js +70 -0
- package/dist/preset-cli/write-path.d.ts +59 -0
- package/dist/preset-cli/write-path.js +89 -0
- package/package.json +6 -2
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry-gating coverage: the min() rule, missing-applicability → 0,
|
|
3
|
+
* and threshold refusal with attr + level + source in the error.
|
|
4
|
+
*/
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { loadRegistry, resolveEvidence, gateWriteAttr, EvidenceGateError, writeThresholdNumber, } from "../registry.js";
|
|
8
|
+
/** A minimal synthetic registry exercising every branch of the min() rule. */
|
|
9
|
+
function syntheticRegistry() {
|
|
10
|
+
return {
|
|
11
|
+
evidence_level_ordering: {
|
|
12
|
+
UNVERIFIED: 0,
|
|
13
|
+
SCHEMA_OBSERVED: 1,
|
|
14
|
+
RUNTIME_VERIFIED: 2,
|
|
15
|
+
VB_ROUNDTRIP_VERIFIED: 3,
|
|
16
|
+
VB_PRESET_STORAGE_VERIFIED: 4,
|
|
17
|
+
CROSS_VERSION_STABLE: 5,
|
|
18
|
+
},
|
|
19
|
+
tier2: [
|
|
20
|
+
{
|
|
21
|
+
// pattern 4, cell 4 -> effective 4 (clears threshold)
|
|
22
|
+
pattern_family: "synthetic/clears",
|
|
23
|
+
pattern_evidence_level: "VB_PRESET_STORAGE_VERIFIED",
|
|
24
|
+
pattern_evidence_source: "docs/synthetic/clears.json",
|
|
25
|
+
applicability: {
|
|
26
|
+
"divi/widget": {
|
|
27
|
+
cell_evidence_level: "VB_PRESET_STORAGE_VERIFIED",
|
|
28
|
+
source: "docs/synthetic/clears-cell.json",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
// pattern 4, cell 1 -> effective 1 (min picks the cell, below threshold)
|
|
34
|
+
pattern_family: "synthetic/cell-low",
|
|
35
|
+
pattern_evidence_level: "VB_PRESET_STORAGE_VERIFIED",
|
|
36
|
+
pattern_evidence_source: "docs/synthetic/cell-low-pattern.json",
|
|
37
|
+
applicability: {
|
|
38
|
+
"divi/widget": {
|
|
39
|
+
cell_evidence_level: "SCHEMA_OBSERVED",
|
|
40
|
+
source: "docs/synthetic/cell-low-cell.json",
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
// pattern 1, cell 4 -> effective 1 (min picks the pattern, below threshold)
|
|
46
|
+
pattern_family: "synthetic/pattern-low",
|
|
47
|
+
pattern_evidence_level: "SCHEMA_OBSERVED",
|
|
48
|
+
pattern_evidence_source: "docs/synthetic/pattern-low-pattern.json",
|
|
49
|
+
applicability: {
|
|
50
|
+
"divi/widget": {
|
|
51
|
+
cell_evidence_level: "VB_PRESET_STORAGE_VERIFIED",
|
|
52
|
+
source: "docs/synthetic/pattern-low-cell.json",
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
// pattern 4, NO applicability cell for divi/widget -> effective 0
|
|
58
|
+
pattern_family: "synthetic/no-cell",
|
|
59
|
+
pattern_evidence_level: "VB_PRESET_STORAGE_VERIFIED",
|
|
60
|
+
pattern_evidence_source: "docs/synthetic/no-cell.json",
|
|
61
|
+
applicability: {
|
|
62
|
+
"divi/other": {
|
|
63
|
+
cell_evidence_level: "VB_PRESET_STORAGE_VERIFIED",
|
|
64
|
+
source: "docs/synthetic/no-cell-other.json",
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
test("min() rule picks the smaller of pattern and cell", () => {
|
|
72
|
+
const reg = syntheticRegistry();
|
|
73
|
+
const clears = resolveEvidence(reg, "divi/widget", "synthetic/clears");
|
|
74
|
+
assert.equal(clears.effectiveLevel, 4);
|
|
75
|
+
assert.equal(clears.effectiveLevelName, "VB_PRESET_STORAGE_VERIFIED");
|
|
76
|
+
const cellLow = resolveEvidence(reg, "divi/widget", "synthetic/cell-low");
|
|
77
|
+
assert.equal(cellLow.patternLevel, 4);
|
|
78
|
+
assert.equal(cellLow.cellLevel, 1);
|
|
79
|
+
assert.equal(cellLow.effectiveLevel, 1, "min picks the cell level");
|
|
80
|
+
const patternLow = resolveEvidence(reg, "divi/widget", "synthetic/pattern-low");
|
|
81
|
+
assert.equal(patternLow.patternLevel, 1);
|
|
82
|
+
assert.equal(patternLow.cellLevel, 4);
|
|
83
|
+
assert.equal(patternLow.effectiveLevel, 1, "min picks the pattern level");
|
|
84
|
+
});
|
|
85
|
+
test("missing applicability[<module>] resolves to UNVERIFIED (0)", () => {
|
|
86
|
+
const reg = syntheticRegistry();
|
|
87
|
+
const r = resolveEvidence(reg, "divi/widget", "synthetic/no-cell");
|
|
88
|
+
assert.equal(r.applicabilityMissing, true);
|
|
89
|
+
assert.equal(r.cellLevel, 0, "no invisible inheritance from the pattern");
|
|
90
|
+
assert.equal(r.effectiveLevel, 0);
|
|
91
|
+
assert.equal(r.effectiveLevelName, "UNVERIFIED");
|
|
92
|
+
});
|
|
93
|
+
test("gateWriteAttr passes when effective evidence clears the threshold", () => {
|
|
94
|
+
const reg = syntheticRegistry();
|
|
95
|
+
const r = gateWriteAttr(reg, "divi/widget", "synthetic/clears");
|
|
96
|
+
assert.equal(r.effectiveLevel, 4);
|
|
97
|
+
});
|
|
98
|
+
test("gateWriteAttr refuses below threshold, naming attr + level + source", () => {
|
|
99
|
+
const reg = syntheticRegistry();
|
|
100
|
+
assert.throws(() => gateWriteAttr(reg, "divi/widget", "synthetic/cell-low"), (err) => {
|
|
101
|
+
assert.ok(err instanceof EvidenceGateError);
|
|
102
|
+
assert.match(err.message, /synthetic\/cell-low/, "names the attr family");
|
|
103
|
+
assert.match(err.message, /SCHEMA_OBSERVED/, "names the effective level");
|
|
104
|
+
assert.match(err.message, /docs\/synthetic\/cell-low-cell\.json/, "names the registry source");
|
|
105
|
+
return true;
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
test("gateWriteAttr refusal on missing applicability cites UNVERIFIED + absent cell", () => {
|
|
109
|
+
const reg = syntheticRegistry();
|
|
110
|
+
assert.throws(() => gateWriteAttr(reg, "divi/widget", "synthetic/no-cell"), (err) => {
|
|
111
|
+
assert.ok(err instanceof EvidenceGateError);
|
|
112
|
+
assert.match(err.message, /UNVERIFIED \(0\)/);
|
|
113
|
+
assert.match(err.message, /absent from the registry entry/);
|
|
114
|
+
return true;
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
test("resolveEvidence throws for an entirely-absent pattern family", () => {
|
|
118
|
+
const reg = syntheticRegistry();
|
|
119
|
+
assert.throws(() => resolveEvidence(reg, "divi/widget", "synthetic/does-not-exist"), /absent from verified-attrs\.json/);
|
|
120
|
+
});
|
|
121
|
+
test("real registry: divi/button styling families clear the write threshold", () => {
|
|
122
|
+
const reg = loadRegistry();
|
|
123
|
+
assert.equal(writeThresholdNumber(reg), 4);
|
|
124
|
+
for (const fam of [
|
|
125
|
+
"divi/button.background",
|
|
126
|
+
"divi/button.border",
|
|
127
|
+
"divi/button.font",
|
|
128
|
+
]) {
|
|
129
|
+
const r = resolveEvidence(reg, "divi/button", fam);
|
|
130
|
+
assert.equal(r.effectiveLevel, 4, `${fam} must be at effective level 4 (VB_PRESET_STORAGE_VERIFIED)`);
|
|
131
|
+
// gateWriteAttr must not throw.
|
|
132
|
+
gateWriteAttr(reg, "divi/button", fam);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply-mode coverage — mocked only (no live substrate write, per #725 AC #8).
|
|
3
|
+
*
|
|
4
|
+
* Asserts: the capability gate (storage_multipath_probe_v1 present → proceed,
|
|
5
|
+
* absent → fail fast), the POST route + request body, and credential
|
|
6
|
+
* handling. The HTTP client is a stub; nothing touches the network.
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply-mode coverage — mocked only (no live substrate write, per #725 AC #8).
|
|
3
|
+
*
|
|
4
|
+
* Asserts: the capability gate (storage_multipath_probe_v1 present → proceed,
|
|
5
|
+
* absent → fail fast), the POST route + request body, and credential
|
|
6
|
+
* handling. The HTTP client is a stub; nothing touches the network.
|
|
7
|
+
*/
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { applyButtonPreset, assertStorageCapability, buildClientFromEnv, CapabilityMissingError, CredentialsMissingError, STORAGE_CAPABILITY, PRESET_CREATE_ROUTE, } from "../write-path.js";
|
|
11
|
+
import { emitButtonGroupPreset } from "../button-emitter.js";
|
|
12
|
+
import { loadRegistry } from "../registry.js";
|
|
13
|
+
const registry = loadRegistry();
|
|
14
|
+
function handshake(capabilities) {
|
|
15
|
+
return {
|
|
16
|
+
compatible: true,
|
|
17
|
+
plugin_version: "1.4.9",
|
|
18
|
+
min_server: "1.5.0",
|
|
19
|
+
divi: { active: true, version: "5.5.2" },
|
|
20
|
+
capabilities,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const TEST_SERVER_VERSION = "1.5.9";
|
|
24
|
+
/** A mock client recording the calls it receives. */
|
|
25
|
+
function mockClient(opts) {
|
|
26
|
+
const calls = [];
|
|
27
|
+
const handshakeVersions = [];
|
|
28
|
+
return {
|
|
29
|
+
calls,
|
|
30
|
+
handshakeVersions,
|
|
31
|
+
async handshake(serverVersion) {
|
|
32
|
+
handshakeVersions.push(serverVersion);
|
|
33
|
+
const hs = handshake(opts.capabilities);
|
|
34
|
+
if (opts.pluginVersion)
|
|
35
|
+
hs.plugin_version = opts.pluginVersion;
|
|
36
|
+
return hs;
|
|
37
|
+
},
|
|
38
|
+
async requestEnveloped(endpoint, options) {
|
|
39
|
+
calls.push({ endpoint, options });
|
|
40
|
+
return { ok: true, data: { preset_id: "mocked123" } };
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
test("assertStorageCapability proceeds when the capability is present", async () => {
|
|
45
|
+
const client = mockClient({ capabilities: { [STORAGE_CAPABILITY]: true } });
|
|
46
|
+
const hs = await assertStorageCapability(client, TEST_SERVER_VERSION);
|
|
47
|
+
assert.equal(hs.capabilities[STORAGE_CAPABILITY], true);
|
|
48
|
+
assert.deepEqual(client.handshakeVersions, [TEST_SERVER_VERSION], "the server version reaches handshake()");
|
|
49
|
+
assert.ok(client.handshakeVersions.every((v) => typeof v === "string" && v.length > 0), "handshake() never receives an undefined/empty server version");
|
|
50
|
+
});
|
|
51
|
+
test("assertStorageCapability fails fast when the capability is absent", async () => {
|
|
52
|
+
const client = mockClient({ capabilities: {}, pluginVersion: "1.4.8" });
|
|
53
|
+
await assert.rejects(() => assertStorageCapability(client, TEST_SERVER_VERSION), (err) => {
|
|
54
|
+
assert.ok(err instanceof CapabilityMissingError);
|
|
55
|
+
assert.equal(err.capability, STORAGE_CAPABILITY);
|
|
56
|
+
assert.match(err.message, /1\.4\.8/);
|
|
57
|
+
return true;
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
test("applyButtonPreset gates capability BEFORE issuing the write", async () => {
|
|
61
|
+
const client = mockClient({ capabilities: {} });
|
|
62
|
+
const entry = emitButtonGroupPreset({ name: "Primary", bg_color: "#111" }, registry);
|
|
63
|
+
await assert.rejects(() => applyButtonPreset(client, entry, { serverVersion: TEST_SERVER_VERSION }), CapabilityMissingError);
|
|
64
|
+
assert.equal(client.calls.length, 0, "no write issued when capability is absent");
|
|
65
|
+
});
|
|
66
|
+
test("applyButtonPreset posts to /preset/create with the canonical body", async () => {
|
|
67
|
+
const client = mockClient({ capabilities: { [STORAGE_CAPABILITY]: true } });
|
|
68
|
+
const entry = emitButtonGroupPreset({
|
|
69
|
+
name: "Primary",
|
|
70
|
+
bg_color: "gcid-primary-color",
|
|
71
|
+
bg_color_hover: "gcid-secondary-color",
|
|
72
|
+
radius: { topLeft: "8px", topRight: "8px", bottomLeft: "8px", bottomRight: "8px" },
|
|
73
|
+
font: { family: "Inter", weight: "600", color: "gcid-body-color" },
|
|
74
|
+
}, registry);
|
|
75
|
+
const result = await applyButtonPreset(client, entry, {
|
|
76
|
+
serverVersion: TEST_SERVER_VERSION,
|
|
77
|
+
});
|
|
78
|
+
assert.equal(client.handshakeVersions[0], TEST_SERVER_VERSION, "applyButtonPreset threads a non-empty server version into handshake()");
|
|
79
|
+
assert.ok((client.handshakeVersions[0] ?? "").length > 0, "handshake() server version is never empty in apply mode");
|
|
80
|
+
assert.equal(client.calls.length, 1, "exactly one write");
|
|
81
|
+
const call = client.calls[0];
|
|
82
|
+
assert.equal(call.endpoint, PRESET_CREATE_ROUTE, "posts to /preset/create");
|
|
83
|
+
const options = call.options;
|
|
84
|
+
assert.equal(options.method, "POST");
|
|
85
|
+
assert.equal(options.body.type, "group");
|
|
86
|
+
assert.equal(options.body.module_name, "divi/button");
|
|
87
|
+
assert.equal(options.body.group_name, "divi/button");
|
|
88
|
+
assert.equal(options.body.group_id, "button");
|
|
89
|
+
assert.equal(options.body.name, "Primary");
|
|
90
|
+
assert.deepEqual(options.body.attrs, entry.attrs);
|
|
91
|
+
assert.equal("dry_run" in options.body, false, "apply mode does not set dry_run");
|
|
92
|
+
assert.deepEqual(result, { ok: true, data: { preset_id: "mocked123" } });
|
|
93
|
+
});
|
|
94
|
+
test("applyButtonPreset threads dry_run into the body when requested", async () => {
|
|
95
|
+
const client = mockClient({ capabilities: { [STORAGE_CAPABILITY]: true } });
|
|
96
|
+
const entry = emitButtonGroupPreset({ name: "P", bg_color: "#111" }, registry);
|
|
97
|
+
await applyButtonPreset(client, entry, {
|
|
98
|
+
serverVersion: TEST_SERVER_VERSION,
|
|
99
|
+
dry_run: true,
|
|
100
|
+
});
|
|
101
|
+
const options = client.calls[0].options;
|
|
102
|
+
assert.equal(options.body.dry_run, true);
|
|
103
|
+
});
|
|
104
|
+
test("buildClientFromEnv throws CredentialsMissingError when env vars are absent", () => {
|
|
105
|
+
assert.throws(() => buildClientFromEnv({}), (err) => {
|
|
106
|
+
assert.ok(err instanceof CredentialsMissingError);
|
|
107
|
+
assert.match(err.message, /WP_URL/);
|
|
108
|
+
assert.match(err.message, /WP_USER/);
|
|
109
|
+
assert.match(err.message, /WP_APP_PASSWORD/);
|
|
110
|
+
return true;
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
test("buildClientFromEnv succeeds with the standard env vars", () => {
|
|
114
|
+
const client = buildClientFromEnv({
|
|
115
|
+
WP_URL: "http://divi5-ai.local",
|
|
116
|
+
WP_USER: "admin",
|
|
117
|
+
WP_APP_PASSWORD: "xxxx xxxx xxxx",
|
|
118
|
+
});
|
|
119
|
+
assert.ok(client, "WPClient constructed from WP_URL/WP_USER/WP_APP_PASSWORD");
|
|
120
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `diviops-preset` bin entrypoint.
|
|
4
|
+
*
|
|
5
|
+
* Thin wrapper around `run()` from `cli.ts`: forwards argv, prints output,
|
|
6
|
+
* and translates the structured exit code into `process.exitCode`.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from "fs";
|
|
9
|
+
import { dirname, join } from "path";
|
|
10
|
+
import { fileURLToPath } from "url";
|
|
11
|
+
import { run } from "./cli.js";
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
// Read version from package.json — same single-source-of-truth pattern as
|
|
14
|
+
// `src/index.ts`. Threaded into `run()` so apply-mode supplies it to the
|
|
15
|
+
// plugin handshake (the /handshake route requires `mcp_server_version`).
|
|
16
|
+
const SERVER_VERSION = (() => {
|
|
17
|
+
try {
|
|
18
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, "..", "..", "package.json"), "utf-8"));
|
|
19
|
+
return pkg.version ?? "0.0.0";
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return "0.0.0";
|
|
23
|
+
}
|
|
24
|
+
})();
|
|
25
|
+
run(process.argv.slice(2), undefined, SERVER_VERSION)
|
|
26
|
+
.then((code) => {
|
|
27
|
+
process.exitCode = code;
|
|
28
|
+
})
|
|
29
|
+
.catch((err) => {
|
|
30
|
+
process.stderr.write(`diviops-preset: unexpected error: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`);
|
|
31
|
+
process.exitCode = 4;
|
|
32
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `divi/button` group-preset emitter — Track 4 vertical slice.
|
|
3
|
+
*
|
|
4
|
+
* Emits a byte-canonical Divi 5.5.x `type: "group"` button preset at
|
|
5
|
+
* `attrs.button.decoration.{background, border, font}`, gated by the
|
|
6
|
+
* verified-attrs registry. Shape canon:
|
|
7
|
+
* `docs/verification/canonical-preset-shapes-per-module-type-2026-05-18.md`
|
|
8
|
+
* (`divi/button` section), cross-checked against
|
|
9
|
+
* `docs/verification/evidence/canonical-shape-dumps-2026-05-18/round-3-button-canonical-complete.json`.
|
|
10
|
+
*
|
|
11
|
+
* Shape rules enforced here (brief §3):
|
|
12
|
+
* - Emit-on-specification only — omitted params produce NO keys.
|
|
13
|
+
* - Hover shape is one wrapper shallower: `background.desktop.hover.color`
|
|
14
|
+
* (NO `value` wrapper). Desktop is `background.desktop.value.color`.
|
|
15
|
+
* - Border radius vocabulary `{topLeft, topRight, bottomLeft, bottomRight,
|
|
16
|
+
* sync}`.
|
|
17
|
+
* - Font double-nesting: `font.font.desktop.value.{family, weight, color,
|
|
18
|
+
* size}`.
|
|
19
|
+
* - `$variable()` color tokens use the `{name, settings}` object shape
|
|
20
|
+
* with the trailing `)$`.
|
|
21
|
+
* - Do NOT emit `attrs.font`/`attrs.spacing` top-level, `renderAttrs`, or
|
|
22
|
+
* `button.decoration.button.desktop.value.*` — unless
|
|
23
|
+
* `bypass_hover_padding_gate: true`, which adds only
|
|
24
|
+
* `button.decoration.button.desktop.value.padding.top: "0px"`.
|
|
25
|
+
*/
|
|
26
|
+
import { type VerifiedAttrsRegistry } from "./registry.js";
|
|
27
|
+
export declare const BUTTON_MODULE = "divi/button";
|
|
28
|
+
export declare const BUTTON_GROUP_NAME = "divi/button";
|
|
29
|
+
export declare const BUTTON_GROUP_ID = "button";
|
|
30
|
+
/** Pattern families the button emitter touches in the registry. */
|
|
31
|
+
export declare const BUTTON_PATTERN_FAMILIES: {
|
|
32
|
+
readonly background: "divi/button.background";
|
|
33
|
+
readonly border: "divi/button.border";
|
|
34
|
+
readonly font: "divi/button.font";
|
|
35
|
+
};
|
|
36
|
+
export interface ButtonRadiusInput {
|
|
37
|
+
topLeft?: string;
|
|
38
|
+
topRight?: string;
|
|
39
|
+
bottomLeft?: string;
|
|
40
|
+
bottomRight?: string;
|
|
41
|
+
/** Explicit override; otherwise auto-derived (see deriveRadiusSync). */
|
|
42
|
+
sync?: "on" | "off";
|
|
43
|
+
}
|
|
44
|
+
export interface ButtonBorderStylesInput {
|
|
45
|
+
width?: string;
|
|
46
|
+
style?: string;
|
|
47
|
+
color?: string;
|
|
48
|
+
}
|
|
49
|
+
export interface ButtonFontInput {
|
|
50
|
+
family?: string;
|
|
51
|
+
weight?: string;
|
|
52
|
+
color?: string;
|
|
53
|
+
size?: string;
|
|
54
|
+
}
|
|
55
|
+
export interface ButtonEmitterInput {
|
|
56
|
+
/** Required display name for the preset. */
|
|
57
|
+
name: string;
|
|
58
|
+
/** Desktop background color — literal hex or bare/formed variable token. */
|
|
59
|
+
bg_color?: string;
|
|
60
|
+
/** Hover background color — literal hex or bare/formed variable token. */
|
|
61
|
+
bg_color_hover?: string;
|
|
62
|
+
/** Border radius composable widget. */
|
|
63
|
+
radius?: ButtonRadiusInput;
|
|
64
|
+
/** Outline-button border styles (`styles.all`). */
|
|
65
|
+
border?: ButtonBorderStylesInput;
|
|
66
|
+
/** Button font. */
|
|
67
|
+
font?: ButtonFontInput;
|
|
68
|
+
/** Opt-in hover-padding-gate bypass corner (default false). */
|
|
69
|
+
bypass_hover_padding_gate?: boolean;
|
|
70
|
+
}
|
|
71
|
+
/** The composed canonical preset entry shape sent to `/preset/create`. */
|
|
72
|
+
export interface ButtonPresetEntry {
|
|
73
|
+
type: "group";
|
|
74
|
+
module_name: string;
|
|
75
|
+
group_name: string;
|
|
76
|
+
group_id: string;
|
|
77
|
+
name: string;
|
|
78
|
+
attrs: Record<string, unknown>;
|
|
79
|
+
}
|
|
80
|
+
/** Deep clone via structuredClone with a JSON fallback. */
|
|
81
|
+
export declare function deepClone<T>(value: T): T;
|
|
82
|
+
/**
|
|
83
|
+
* Compose the canonical `attrs.button.decoration.*` bag from the input.
|
|
84
|
+
* Emit-on-specification: only specified sub-fields produce keys.
|
|
85
|
+
*/
|
|
86
|
+
export declare function composeButtonAttrs(input: ButtonEmitterInput): Record<string, unknown>;
|
|
87
|
+
/**
|
|
88
|
+
* Gate every pattern family the composed attrs touch against the registry.
|
|
89
|
+
* Throws `EvidenceGateError` if any touched family is below the
|
|
90
|
+
* `VB_PRESET_STORAGE_VERIFIED` threshold. The `divi/button` decoration
|
|
91
|
+
* slot (`button.decoration.button.*`) used by the bypass corner is part
|
|
92
|
+
* of the `divi/button.border`/baseline write surface; it is not separately
|
|
93
|
+
* gated because the bypass corner is an explicit opt-in workaround, not a
|
|
94
|
+
* styling attr — but the three styling families ARE gated.
|
|
95
|
+
*/
|
|
96
|
+
export declare function gateButtonAttrs(attrs: Record<string, unknown>, registry: VerifiedAttrsRegistry): void;
|
|
97
|
+
/**
|
|
98
|
+
* Emit a canonical `divi/button` group preset.
|
|
99
|
+
*
|
|
100
|
+
* 1. Compose `attrs.button.decoration.*` (emit-on-specification).
|
|
101
|
+
* 2. Gate every touched styling family against the verified-attrs
|
|
102
|
+
* registry — throws if any is below `VB_PRESET_STORAGE_VERIFIED`.
|
|
103
|
+
* 3. Return the canonical preset entry. `styleAttrs` and `renderAttrs` are
|
|
104
|
+
* intentionally NOT part of this entry — the plugin's `/preset/create`
|
|
105
|
+
* route mirrors the single `attrs` bag into all three buckets, writing
|
|
106
|
+
* `attrs == styleAttrs == renderAttrs` to match VB save semantics
|
|
107
|
+
* (see `trait-preset.php` `preset_create`).
|
|
108
|
+
*/
|
|
109
|
+
export declare function emitButtonGroupPreset(input: ButtonEmitterInput, registry?: VerifiedAttrsRegistry): ButtonPresetEntry;
|
|
110
|
+
/**
|
|
111
|
+
* Build the `POST /diviops/v1/preset/create` request body from a preset
|
|
112
|
+
* entry. Matches the body shape the `diviops_preset_create` MCP tool
|
|
113
|
+
* posts — the CLI reuses the existing route, it does not add one.
|
|
114
|
+
*/
|
|
115
|
+
export declare function buildPresetCreateBody(entry: ButtonPresetEntry, opts?: {
|
|
116
|
+
dry_run?: boolean;
|
|
117
|
+
}): Record<string, unknown>;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `divi/button` group-preset emitter — Track 4 vertical slice.
|
|
3
|
+
*
|
|
4
|
+
* Emits a byte-canonical Divi 5.5.x `type: "group"` button preset at
|
|
5
|
+
* `attrs.button.decoration.{background, border, font}`, gated by the
|
|
6
|
+
* verified-attrs registry. Shape canon:
|
|
7
|
+
* `docs/verification/canonical-preset-shapes-per-module-type-2026-05-18.md`
|
|
8
|
+
* (`divi/button` section), cross-checked against
|
|
9
|
+
* `docs/verification/evidence/canonical-shape-dumps-2026-05-18/round-3-button-canonical-complete.json`.
|
|
10
|
+
*
|
|
11
|
+
* Shape rules enforced here (brief §3):
|
|
12
|
+
* - Emit-on-specification only — omitted params produce NO keys.
|
|
13
|
+
* - Hover shape is one wrapper shallower: `background.desktop.hover.color`
|
|
14
|
+
* (NO `value` wrapper). Desktop is `background.desktop.value.color`.
|
|
15
|
+
* - Border radius vocabulary `{topLeft, topRight, bottomLeft, bottomRight,
|
|
16
|
+
* sync}`.
|
|
17
|
+
* - Font double-nesting: `font.font.desktop.value.{family, weight, color,
|
|
18
|
+
* size}`.
|
|
19
|
+
* - `$variable()` color tokens use the `{name, settings}` object shape
|
|
20
|
+
* with the trailing `)$`.
|
|
21
|
+
* - Do NOT emit `attrs.font`/`attrs.spacing` top-level, `renderAttrs`, or
|
|
22
|
+
* `button.decoration.button.desktop.value.*` — unless
|
|
23
|
+
* `bypass_hover_padding_gate: true`, which adds only
|
|
24
|
+
* `button.decoration.button.desktop.value.padding.top: "0px"`.
|
|
25
|
+
*/
|
|
26
|
+
import { loadRegistry, gateWriteAttr, } from "./registry.js";
|
|
27
|
+
import { normalizeColorValue } from "./variable-token.js";
|
|
28
|
+
export const BUTTON_MODULE = "divi/button";
|
|
29
|
+
export const BUTTON_GROUP_NAME = "divi/button";
|
|
30
|
+
export const BUTTON_GROUP_ID = "button";
|
|
31
|
+
/** Pattern families the button emitter touches in the registry. */
|
|
32
|
+
export const BUTTON_PATTERN_FAMILIES = {
|
|
33
|
+
background: "divi/button.background",
|
|
34
|
+
border: "divi/button.border",
|
|
35
|
+
font: "divi/button.font",
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Derive the radius `sync` flag when the caller did not pass it explicitly.
|
|
39
|
+
*
|
|
40
|
+
* Per Rule 1a in `docs/verification/canonical-preset-shapes-per-module-type-2026-05-18.md`:
|
|
41
|
+
* composable-widget sync flags default to `"off"` when any sub-field is
|
|
42
|
+
* touched. Auto-deriving `"on"` is only correct when ALL FOUR corners are
|
|
43
|
+
* specified AND identical — a partial corner set (or a single corner) must
|
|
44
|
+
* derive `"off"`, never `"on"` (a 1-element set is trivially "all equal").
|
|
45
|
+
* An explicitly-passed `radius.sync` always wins.
|
|
46
|
+
*/
|
|
47
|
+
function deriveRadiusSync(radius) {
|
|
48
|
+
if (radius.sync === "on" || radius.sync === "off")
|
|
49
|
+
return radius.sync;
|
|
50
|
+
const corners = [
|
|
51
|
+
radius.topLeft,
|
|
52
|
+
radius.topRight,
|
|
53
|
+
radius.bottomLeft,
|
|
54
|
+
radius.bottomRight,
|
|
55
|
+
].filter((v) => typeof v === "string");
|
|
56
|
+
if (corners.length === 0)
|
|
57
|
+
return "off";
|
|
58
|
+
return corners.length === 4 && corners.every((v) => v === corners[0])
|
|
59
|
+
? "on"
|
|
60
|
+
: "off";
|
|
61
|
+
}
|
|
62
|
+
function isPlainObject(v) {
|
|
63
|
+
return (!!v && typeof v === "object" && !Array.isArray(v));
|
|
64
|
+
}
|
|
65
|
+
/** Deep clone via structuredClone with a JSON fallback. */
|
|
66
|
+
export function deepClone(value) {
|
|
67
|
+
if (typeof structuredClone === "function")
|
|
68
|
+
return structuredClone(value);
|
|
69
|
+
return JSON.parse(JSON.stringify(value));
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Compose the canonical `attrs.button.decoration.*` bag from the input.
|
|
73
|
+
* Emit-on-specification: only specified sub-fields produce keys.
|
|
74
|
+
*/
|
|
75
|
+
export function composeButtonAttrs(input) {
|
|
76
|
+
const decoration = {};
|
|
77
|
+
// --- background -----------------------------------------------------
|
|
78
|
+
if (input.bg_color !== undefined || input.bg_color_hover !== undefined) {
|
|
79
|
+
const desktop = {};
|
|
80
|
+
if (input.bg_color !== undefined) {
|
|
81
|
+
desktop.value = { color: normalizeColorValue(input.bg_color) };
|
|
82
|
+
}
|
|
83
|
+
if (input.bg_color_hover !== undefined) {
|
|
84
|
+
// Hover is one wrapper shallower — NO `value` between hover and color.
|
|
85
|
+
desktop.hover = { color: normalizeColorValue(input.bg_color_hover) };
|
|
86
|
+
}
|
|
87
|
+
decoration.background = { desktop };
|
|
88
|
+
}
|
|
89
|
+
// --- border ---------------------------------------------------------
|
|
90
|
+
const borderValue = {};
|
|
91
|
+
if (input.radius) {
|
|
92
|
+
const r = input.radius;
|
|
93
|
+
const radius = {};
|
|
94
|
+
if (r.topLeft !== undefined)
|
|
95
|
+
radius.topLeft = r.topLeft;
|
|
96
|
+
if (r.topRight !== undefined)
|
|
97
|
+
radius.topRight = r.topRight;
|
|
98
|
+
if (r.bottomLeft !== undefined)
|
|
99
|
+
radius.bottomLeft = r.bottomLeft;
|
|
100
|
+
if (r.bottomRight !== undefined)
|
|
101
|
+
radius.bottomRight = r.bottomRight;
|
|
102
|
+
if (Object.keys(radius).length > 0) {
|
|
103
|
+
// Composable widget: `sync` emits alongside any corner.
|
|
104
|
+
radius.sync = deriveRadiusSync(r);
|
|
105
|
+
borderValue.radius = radius;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (input.border) {
|
|
109
|
+
const b = input.border;
|
|
110
|
+
const all = {};
|
|
111
|
+
if (b.width !== undefined)
|
|
112
|
+
all.width = b.width;
|
|
113
|
+
if (b.style !== undefined)
|
|
114
|
+
all.style = b.style;
|
|
115
|
+
if (b.color !== undefined)
|
|
116
|
+
all.color = normalizeColorValue(b.color);
|
|
117
|
+
if (Object.keys(all).length > 0) {
|
|
118
|
+
borderValue.styles = { all };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (Object.keys(borderValue).length > 0) {
|
|
122
|
+
decoration.border = { desktop: { value: borderValue } };
|
|
123
|
+
}
|
|
124
|
+
// --- font (double-nested font.font) --------------------------------
|
|
125
|
+
if (input.font) {
|
|
126
|
+
const f = input.font;
|
|
127
|
+
const value = {};
|
|
128
|
+
if (f.family !== undefined)
|
|
129
|
+
value.family = f.family;
|
|
130
|
+
if (f.weight !== undefined)
|
|
131
|
+
value.weight = f.weight;
|
|
132
|
+
if (f.color !== undefined)
|
|
133
|
+
value.color = normalizeColorValue(f.color);
|
|
134
|
+
if (f.size !== undefined)
|
|
135
|
+
value.size = f.size;
|
|
136
|
+
if (Object.keys(value).length > 0) {
|
|
137
|
+
decoration.font = { font: { desktop: { value } } };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// --- hover-padding-gate bypass (opt-in only) -----------------------
|
|
141
|
+
if (input.bypass_hover_padding_gate === true) {
|
|
142
|
+
decoration.button = { desktop: { value: { padding: { top: "0px" } } } };
|
|
143
|
+
}
|
|
144
|
+
return { button: { decoration } };
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Gate every pattern family the composed attrs touch against the registry.
|
|
148
|
+
* Throws `EvidenceGateError` if any touched family is below the
|
|
149
|
+
* `VB_PRESET_STORAGE_VERIFIED` threshold. The `divi/button` decoration
|
|
150
|
+
* slot (`button.decoration.button.*`) used by the bypass corner is part
|
|
151
|
+
* of the `divi/button.border`/baseline write surface; it is not separately
|
|
152
|
+
* gated because the bypass corner is an explicit opt-in workaround, not a
|
|
153
|
+
* styling attr — but the three styling families ARE gated.
|
|
154
|
+
*/
|
|
155
|
+
export function gateButtonAttrs(attrs, registry) {
|
|
156
|
+
const decoration = (isPlainObject(attrs.button) ? attrs.button.decoration : undefined);
|
|
157
|
+
if (!isPlainObject(decoration))
|
|
158
|
+
return;
|
|
159
|
+
if (decoration.background !== undefined) {
|
|
160
|
+
gateWriteAttr(registry, BUTTON_MODULE, BUTTON_PATTERN_FAMILIES.background);
|
|
161
|
+
}
|
|
162
|
+
if (decoration.border !== undefined) {
|
|
163
|
+
gateWriteAttr(registry, BUTTON_MODULE, BUTTON_PATTERN_FAMILIES.border);
|
|
164
|
+
}
|
|
165
|
+
if (decoration.font !== undefined) {
|
|
166
|
+
gateWriteAttr(registry, BUTTON_MODULE, BUTTON_PATTERN_FAMILIES.font);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Emit a canonical `divi/button` group preset.
|
|
171
|
+
*
|
|
172
|
+
* 1. Compose `attrs.button.decoration.*` (emit-on-specification).
|
|
173
|
+
* 2. Gate every touched styling family against the verified-attrs
|
|
174
|
+
* registry — throws if any is below `VB_PRESET_STORAGE_VERIFIED`.
|
|
175
|
+
* 3. Return the canonical preset entry. `styleAttrs` and `renderAttrs` are
|
|
176
|
+
* intentionally NOT part of this entry — the plugin's `/preset/create`
|
|
177
|
+
* route mirrors the single `attrs` bag into all three buckets, writing
|
|
178
|
+
* `attrs == styleAttrs == renderAttrs` to match VB save semantics
|
|
179
|
+
* (see `trait-preset.php` `preset_create`).
|
|
180
|
+
*/
|
|
181
|
+
export function emitButtonGroupPreset(input, registry = loadRegistry()) {
|
|
182
|
+
if (!input.name || typeof input.name !== "string") {
|
|
183
|
+
throw new Error("Button emitter requires a non-empty `name`.");
|
|
184
|
+
}
|
|
185
|
+
const attrs = composeButtonAttrs(input);
|
|
186
|
+
const decoration = attrs.button.decoration;
|
|
187
|
+
if (!decoration || Object.keys(decoration).length === 0) {
|
|
188
|
+
throw new Error("Button emitter produced an empty preset — pass at least one of " +
|
|
189
|
+
"bg_color, bg_color_hover, radius, border, or font.");
|
|
190
|
+
}
|
|
191
|
+
gateButtonAttrs(attrs, registry);
|
|
192
|
+
return {
|
|
193
|
+
type: "group",
|
|
194
|
+
module_name: BUTTON_MODULE,
|
|
195
|
+
group_name: BUTTON_GROUP_NAME,
|
|
196
|
+
group_id: BUTTON_GROUP_ID,
|
|
197
|
+
name: input.name,
|
|
198
|
+
attrs,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Build the `POST /diviops/v1/preset/create` request body from a preset
|
|
203
|
+
* entry. Matches the body shape the `diviops_preset_create` MCP tool
|
|
204
|
+
* posts — the CLI reuses the existing route, it does not add one.
|
|
205
|
+
*/
|
|
206
|
+
export function buildPresetCreateBody(entry, opts = {}) {
|
|
207
|
+
const body = {
|
|
208
|
+
module_name: entry.module_name,
|
|
209
|
+
name: entry.name,
|
|
210
|
+
attrs: entry.attrs,
|
|
211
|
+
type: entry.type,
|
|
212
|
+
group_name: entry.group_name,
|
|
213
|
+
group_id: entry.group_id,
|
|
214
|
+
};
|
|
215
|
+
if (opts.dry_run)
|
|
216
|
+
body.dry_run = true;
|
|
217
|
+
return body;
|
|
218
|
+
}
|