@diviops/mcp-server 1.5.11 → 1.5.12
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 +15 -2
- package/dist/preset-cli/__tests__/cli.test.js +181 -0
- package/dist/preset-cli/__tests__/heading-font-emitter.test.d.ts +12 -0
- package/dist/preset-cli/__tests__/heading-font-emitter.test.js +249 -0
- package/dist/preset-cli/__tests__/registry.test.js +15 -0
- package/dist/preset-cli/__tests__/write-path.test.js +55 -1
- package/dist/preset-cli/cli.d.ts +3 -0
- package/dist/preset-cli/cli.js +114 -11
- package/dist/preset-cli/heading-font-emitter.d.ts +128 -0
- package/dist/preset-cli/heading-font-emitter.js +166 -0
- package/dist/preset-cli/registry.d.ts +23 -9
- package/dist/preset-cli/registry.js +37 -13
- package/dist/preset-cli/write-path.d.ts +15 -0
- package/dist/preset-cli/write-path.js +21 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -85,18 +85,31 @@ See [server-reference.md](../docs/server-reference.md) for per-tool descriptions
|
|
|
85
85
|
The package also ships a standalone command-line preset emitter, `diviops-preset`,
|
|
86
86
|
that produces byte-canonical Divi 5.5.x preset JSON gated by the verified-attrs
|
|
87
87
|
registry (`data/verified-attrs.json`). It is independent of the MCP stdio server —
|
|
88
|
-
run it directly:
|
|
88
|
+
run it directly. Current commands:
|
|
89
|
+
|
|
90
|
+
| Command | Emits |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `diviops-preset button [options]` | `divi/button` group preset |
|
|
93
|
+
| `diviops-preset heading-font [options]` | `divi/font` group preset for `divi/heading` (Pattern A — Google Fonts — or Pattern B — local-hosted) |
|
|
89
94
|
|
|
90
95
|
```bash
|
|
91
96
|
diviops-preset button --name "Primary" --bg-color gcid-primary-color \
|
|
92
97
|
--bg-color-hover gcid-secondary-color --radius 8px \
|
|
93
98
|
--font-family Inter --font-weight 600 --font-color gcid-body-color
|
|
99
|
+
|
|
100
|
+
diviops-preset heading-font --name "Heading H1" --pattern google \
|
|
101
|
+
--font-family Inter --font-weight 700 \
|
|
102
|
+
--font-color gcid-heading-color --font-size 48px
|
|
94
103
|
```
|
|
95
104
|
|
|
96
105
|
`--dry-run` (the default) composes and prints the canonical JSON with no
|
|
97
106
|
credentials and no network. `--apply` posts to the existing `/preset/create`
|
|
98
107
|
REST route, reusing the same `WP_URL` / `WP_USER` / `WP_APP_PASSWORD` env vars.
|
|
99
|
-
|
|
108
|
+
|
|
109
|
+
The CLI's coverage is intentionally narrow: only the (module, group, variant)
|
|
110
|
+
combinations whose canonical shape is VB-verified in the registry are
|
|
111
|
+
emittable. It is **not** an all-module or all-font-family emitter — each
|
|
112
|
+
additional vertical slice lands with its own verified evidence. See the
|
|
100
113
|
[preset-cli reference](https://github.com/oaris-dev/diviops/blob/main/diviops-server/src/preset-cli/README.md)
|
|
101
114
|
for the full command reference (the `src/` tree is not part of the published
|
|
102
115
|
npm package — this link resolves on the repository).
|
|
@@ -39,6 +39,14 @@ test("unknown command exits 1 (invalid input)", async () => {
|
|
|
39
39
|
assert.equal(code, EXIT.INVALID_INPUT);
|
|
40
40
|
assert.match(io.stderr.join("\n"), /Unknown command/);
|
|
41
41
|
});
|
|
42
|
+
test("--help advertises the heading-font command", async () => {
|
|
43
|
+
const io = capture();
|
|
44
|
+
const code = await run(["--help"], io);
|
|
45
|
+
assert.equal(code, EXIT.OK);
|
|
46
|
+
const help = io.stdout.join("\n");
|
|
47
|
+
assert.match(help, /heading-font/);
|
|
48
|
+
assert.match(help, /--pattern <google\|local>/);
|
|
49
|
+
});
|
|
42
50
|
test("unknown flag exits 1 (invalid input)", async () => {
|
|
43
51
|
const io = capture();
|
|
44
52
|
const code = await run(["button", "--name", "X", "--bogus", "y"], io);
|
|
@@ -138,6 +146,179 @@ test("parseArgs: --radius-sync rejects values outside on|off", () => {
|
|
|
138
146
|
const parsed = parseArgs(["button", "--name", "P", "--radius-sync", "maybe"]);
|
|
139
147
|
assert.throws(() => buildButtonInput(parsed), /radius-sync must be/);
|
|
140
148
|
});
|
|
149
|
+
// ------------------------------------------------------------------
|
|
150
|
+
// heading-font command — CLI integration (parse → emit → dry-run JSON)
|
|
151
|
+
// ------------------------------------------------------------------
|
|
152
|
+
test("heading-font dry-run (Pattern A) emits canonical JSON, exit 0", async () => {
|
|
153
|
+
const io = capture();
|
|
154
|
+
const code = await run([
|
|
155
|
+
"heading-font",
|
|
156
|
+
"--name",
|
|
157
|
+
"H1",
|
|
158
|
+
"--pattern",
|
|
159
|
+
"google",
|
|
160
|
+
"--font-family",
|
|
161
|
+
"Inter",
|
|
162
|
+
"--font-weight",
|
|
163
|
+
"700",
|
|
164
|
+
"--font-color",
|
|
165
|
+
"gcid-heading-color",
|
|
166
|
+
"--font-size",
|
|
167
|
+
"48px",
|
|
168
|
+
], io);
|
|
169
|
+
assert.equal(code, EXIT.OK);
|
|
170
|
+
const parsed = JSON.parse(io.stdout.join("\n"));
|
|
171
|
+
assert.equal(parsed.type, "group");
|
|
172
|
+
assert.equal(parsed.dry_run, true);
|
|
173
|
+
assert.equal(parsed.module_name, "divi/heading");
|
|
174
|
+
assert.equal(parsed.group_name, "divi/font");
|
|
175
|
+
assert.equal(parsed.group_id, "designTitleText");
|
|
176
|
+
const value = parsed.attrs.title.decoration.font.font.desktop.value;
|
|
177
|
+
assert.equal(value.family, "Inter");
|
|
178
|
+
assert.equal(value.weight, "700");
|
|
179
|
+
assert.equal(value.size, "48px");
|
|
180
|
+
assert.equal(value.color, '$variable({"type":"color","value":{"name":"gcid-heading-color","settings":{}}})$');
|
|
181
|
+
});
|
|
182
|
+
test("heading-font dry-run (Pattern B) emits no `weight` key", async () => {
|
|
183
|
+
const io = capture();
|
|
184
|
+
const code = await run([
|
|
185
|
+
"heading-font",
|
|
186
|
+
"--name",
|
|
187
|
+
"H1-local",
|
|
188
|
+
"--pattern",
|
|
189
|
+
"local",
|
|
190
|
+
"--font-family",
|
|
191
|
+
"Sora 700",
|
|
192
|
+
"--font-color",
|
|
193
|
+
"gcid-heading-color",
|
|
194
|
+
"--font-size",
|
|
195
|
+
"48px",
|
|
196
|
+
], io);
|
|
197
|
+
assert.equal(code, EXIT.OK);
|
|
198
|
+
const parsed = JSON.parse(io.stdout.join("\n"));
|
|
199
|
+
const value = parsed.attrs.title.decoration.font.font.desktop.value;
|
|
200
|
+
assert.equal(value.family, "Sora 700");
|
|
201
|
+
assert.equal("weight" in value, false, "Pattern B emits no weight key");
|
|
202
|
+
});
|
|
203
|
+
test("heading-font without --pattern exits 1 (invalid input)", async () => {
|
|
204
|
+
const io = capture();
|
|
205
|
+
const code = await run([
|
|
206
|
+
"heading-font",
|
|
207
|
+
"--name",
|
|
208
|
+
"H1",
|
|
209
|
+
"--font-family",
|
|
210
|
+
"Inter",
|
|
211
|
+
"--font-weight",
|
|
212
|
+
"700",
|
|
213
|
+
], io);
|
|
214
|
+
assert.equal(code, EXIT.INVALID_INPUT);
|
|
215
|
+
assert.match(io.stderr.join("\n"), /--pattern/);
|
|
216
|
+
assert.match(io.stderr.join("\n"), /google\|local/);
|
|
217
|
+
});
|
|
218
|
+
test("heading-font without --name exits 1", async () => {
|
|
219
|
+
const io = capture();
|
|
220
|
+
const code = await run(["heading-font", "--pattern", "google", "--font-family", "Inter"], io);
|
|
221
|
+
assert.equal(code, EXIT.INVALID_INPUT);
|
|
222
|
+
assert.match(io.stderr.join("\n"), /requires --name/);
|
|
223
|
+
});
|
|
224
|
+
test("heading-font --pattern local + --font-weight is refused (exit 1)", async () => {
|
|
225
|
+
const io = capture();
|
|
226
|
+
const code = await run([
|
|
227
|
+
"heading-font",
|
|
228
|
+
"--name",
|
|
229
|
+
"H1-bad",
|
|
230
|
+
"--pattern",
|
|
231
|
+
"local",
|
|
232
|
+
"--font-family",
|
|
233
|
+
"Sora 700",
|
|
234
|
+
"--font-weight",
|
|
235
|
+
"700",
|
|
236
|
+
], io);
|
|
237
|
+
assert.equal(code, EXIT.INVALID_INPUT);
|
|
238
|
+
assert.match(io.stderr.join("\n"), /Pattern B/);
|
|
239
|
+
});
|
|
240
|
+
test("heading-font --pattern with an invalid value exits 1", async () => {
|
|
241
|
+
const io = capture();
|
|
242
|
+
const code = await run([
|
|
243
|
+
"heading-font",
|
|
244
|
+
"--name",
|
|
245
|
+
"H1",
|
|
246
|
+
"--pattern",
|
|
247
|
+
"auto",
|
|
248
|
+
"--font-family",
|
|
249
|
+
"Inter",
|
|
250
|
+
], io);
|
|
251
|
+
assert.equal(code, EXIT.INVALID_INPUT);
|
|
252
|
+
assert.match(io.stderr.join("\n"), /--pattern must be/);
|
|
253
|
+
});
|
|
254
|
+
test("heading-font dry-run requires no credentials and no network", async () => {
|
|
255
|
+
// Mirrors the dry-run-no-creds button assertion: a heading-font dry-run
|
|
256
|
+
// must not throw a CredentialsMissingError. Exercises AC: --apply is
|
|
257
|
+
// the only path that touches credentials/handshake/network.
|
|
258
|
+
const saved = {
|
|
259
|
+
WP_URL: process.env.WP_URL,
|
|
260
|
+
WP_USER: process.env.WP_USER,
|
|
261
|
+
WP_APP_PASSWORD: process.env.WP_APP_PASSWORD,
|
|
262
|
+
};
|
|
263
|
+
delete process.env.WP_URL;
|
|
264
|
+
delete process.env.WP_USER;
|
|
265
|
+
delete process.env.WP_APP_PASSWORD;
|
|
266
|
+
try {
|
|
267
|
+
const io = capture();
|
|
268
|
+
const code = await run([
|
|
269
|
+
"heading-font",
|
|
270
|
+
"--name",
|
|
271
|
+
"H1",
|
|
272
|
+
"--pattern",
|
|
273
|
+
"google",
|
|
274
|
+
"--font-family",
|
|
275
|
+
"Inter",
|
|
276
|
+
], io);
|
|
277
|
+
assert.equal(code, EXIT.OK);
|
|
278
|
+
assert.equal(io.stderr.length, 0, "no error output on credential-free dry-run");
|
|
279
|
+
}
|
|
280
|
+
finally {
|
|
281
|
+
if (saved.WP_URL !== undefined)
|
|
282
|
+
process.env.WP_URL = saved.WP_URL;
|
|
283
|
+
if (saved.WP_USER !== undefined)
|
|
284
|
+
process.env.WP_USER = saved.WP_USER;
|
|
285
|
+
if (saved.WP_APP_PASSWORD !== undefined)
|
|
286
|
+
process.env.WP_APP_PASSWORD = saved.WP_APP_PASSWORD;
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
test("heading-font --apply without credentials exits 1 with a credentials hint", async () => {
|
|
290
|
+
const saved = {
|
|
291
|
+
WP_URL: process.env.WP_URL,
|
|
292
|
+
WP_USER: process.env.WP_USER,
|
|
293
|
+
WP_APP_PASSWORD: process.env.WP_APP_PASSWORD,
|
|
294
|
+
};
|
|
295
|
+
delete process.env.WP_URL;
|
|
296
|
+
delete process.env.WP_USER;
|
|
297
|
+
delete process.env.WP_APP_PASSWORD;
|
|
298
|
+
try {
|
|
299
|
+
const io = capture();
|
|
300
|
+
const code = await run([
|
|
301
|
+
"heading-font",
|
|
302
|
+
"--name",
|
|
303
|
+
"H1",
|
|
304
|
+
"--pattern",
|
|
305
|
+
"google",
|
|
306
|
+
"--font-family",
|
|
307
|
+
"Inter",
|
|
308
|
+
"--apply",
|
|
309
|
+
], io);
|
|
310
|
+
assert.equal(code, EXIT.INVALID_INPUT);
|
|
311
|
+
assert.match(io.stderr.join("\n"), /requires WordPress credentials/);
|
|
312
|
+
}
|
|
313
|
+
finally {
|
|
314
|
+
if (saved.WP_URL !== undefined)
|
|
315
|
+
process.env.WP_URL = saved.WP_URL;
|
|
316
|
+
if (saved.WP_USER !== undefined)
|
|
317
|
+
process.env.WP_USER = saved.WP_USER;
|
|
318
|
+
if (saved.WP_APP_PASSWORD !== undefined)
|
|
319
|
+
process.env.WP_APP_PASSWORD = saved.WP_APP_PASSWORD;
|
|
320
|
+
}
|
|
321
|
+
});
|
|
141
322
|
test("dry-run output includes the bypass corner when requested", async () => {
|
|
142
323
|
const io = capture();
|
|
143
324
|
const code = await run(["button", "--name", "P", "--bg-color", "#111", "--bypass-hover-padding-gate"], io);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `divi/font` heading emitter shape + gating coverage:
|
|
3
|
+
* - fixture-based shape assertion against round-1a (Pattern A) and
|
|
4
|
+
* round-1b (Pattern B) canonical captures;
|
|
5
|
+
* - Pattern B no-weight discriminator: omitted `weight` produces no key;
|
|
6
|
+
* - Pattern B + explicit weight refused as unverified/out-of-scope;
|
|
7
|
+
* - variant-aware registry gating: missing applicability or under-
|
|
8
|
+
* verified evidence on the chosen variant throws with attr / pattern /
|
|
9
|
+
* effective-level / source in the message; Pattern A evidence does NOT
|
|
10
|
+
* vouch for Pattern B and vice versa.
|
|
11
|
+
*/
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `divi/font` heading emitter shape + gating coverage:
|
|
3
|
+
* - fixture-based shape assertion against round-1a (Pattern A) and
|
|
4
|
+
* round-1b (Pattern B) canonical captures;
|
|
5
|
+
* - Pattern B no-weight discriminator: omitted `weight` produces no key;
|
|
6
|
+
* - Pattern B + explicit weight refused as unverified/out-of-scope;
|
|
7
|
+
* - variant-aware registry gating: missing applicability or under-
|
|
8
|
+
* verified evidence on the chosen variant throws with attr / pattern /
|
|
9
|
+
* effective-level / source in the message; Pattern A evidence does NOT
|
|
10
|
+
* vouch for Pattern B and vice versa.
|
|
11
|
+
*/
|
|
12
|
+
import { test } from "node:test";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { emitHeadingFontGroupPreset, composeHeadingFontAttrs, buildHeadingFontPresetCreateBody, UnsupportedVariantCombinationError, HEADING_FONT_MODULE, HEADING_FONT_GROUP_NAME, HEADING_FONT_GROUP_ID, HEADING_FONT_PATTERN_VARIANTS, } from "../heading-font-emitter.js";
|
|
18
|
+
import { loadRegistry, EvidenceGateError, } from "../registry.js";
|
|
19
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const REPO_ROOT = join(__dirname, "..", "..", "..", "..");
|
|
21
|
+
const FIXTURE_DIR = join(REPO_ROOT, "docs/verification/evidence/canonical-shape-dumps-2026-05-18");
|
|
22
|
+
const FIXTURE_1A = join(FIXTURE_DIR, "round-1a-heading-h1-pattern-a-google.json");
|
|
23
|
+
const FIXTURE_1B = join(FIXTURE_DIR, "round-1b-heading-h1-pattern-b-local.json");
|
|
24
|
+
const registry = loadRegistry();
|
|
25
|
+
test("emitter byte-matches round-1a (Pattern A — Google Fonts) fixture attrs", () => {
|
|
26
|
+
const fixture = JSON.parse(readFileSync(FIXTURE_1A, "utf8"));
|
|
27
|
+
const canonicalAttrs = fixture.preset_entry.attrs;
|
|
28
|
+
const canonicalStyleAttrs = fixture.preset_entry.styleAttrs;
|
|
29
|
+
const entry = emitHeadingFontGroupPreset({
|
|
30
|
+
name: "canonical-heading-h1-vb-google-2026-05-18",
|
|
31
|
+
pattern: "google",
|
|
32
|
+
family: "Inter",
|
|
33
|
+
weight: "700",
|
|
34
|
+
color: '$variable({"type":"color","value":{"name":"gcid-heading-color","settings":{}}})$',
|
|
35
|
+
size: "48px",
|
|
36
|
+
}, registry);
|
|
37
|
+
assert.deepEqual(entry.attrs, canonicalAttrs, "emitted attrs must byte-match the Pattern A canonical capture");
|
|
38
|
+
// Round 1a observation: attrs and styleAttrs are byte-identical. The
|
|
39
|
+
// emitter only emits `attrs`; the plugin's /preset/create route mirrors
|
|
40
|
+
// into styleAttrs. So `attrs` must equal the fixture's styleAttrs too.
|
|
41
|
+
assert.deepEqual(entry.attrs, canonicalStyleAttrs, "attrs must equal styleAttrs (mirror at /preset/create layer)");
|
|
42
|
+
assert.equal(entry.type, "group");
|
|
43
|
+
assert.equal(entry.group_name, HEADING_FONT_GROUP_NAME);
|
|
44
|
+
assert.equal(entry.group_id, HEADING_FONT_GROUP_ID);
|
|
45
|
+
assert.equal(entry.module_name, HEADING_FONT_MODULE);
|
|
46
|
+
assert.equal(entry.pattern_variant, HEADING_FONT_PATTERN_VARIANTS.google);
|
|
47
|
+
});
|
|
48
|
+
test("emitter byte-matches round-1b (Pattern B — local-hosted) fixture attrs", () => {
|
|
49
|
+
const fixture = JSON.parse(readFileSync(FIXTURE_1B, "utf8"));
|
|
50
|
+
const canonicalAttrs = fixture.preset_entry.attrs;
|
|
51
|
+
const canonicalStyleAttrs = fixture.preset_entry.styleAttrs;
|
|
52
|
+
const entry = emitHeadingFontGroupPreset({
|
|
53
|
+
name: "canonical-heading-h1-vb-local-2026-05-18",
|
|
54
|
+
pattern: "local",
|
|
55
|
+
family: "Sora 700",
|
|
56
|
+
// weight intentionally absent — Pattern B encodes the weight in the
|
|
57
|
+
// family string itself.
|
|
58
|
+
color: '$variable({"type":"color","value":{"name":"gcid-heading-color","settings":{}}})$',
|
|
59
|
+
size: "48px",
|
|
60
|
+
}, registry);
|
|
61
|
+
assert.deepEqual(entry.attrs, canonicalAttrs, "emitted attrs must byte-match the Pattern B canonical capture");
|
|
62
|
+
assert.deepEqual(entry.attrs, canonicalStyleAttrs, "attrs must equal styleAttrs (mirror at /preset/create layer)");
|
|
63
|
+
assert.equal(entry.pattern_variant, HEADING_FONT_PATTERN_VARIANTS.local);
|
|
64
|
+
});
|
|
65
|
+
test("Pattern B discriminator: omitted weight produces NO weight key", () => {
|
|
66
|
+
const attrs = composeHeadingFontAttrs({
|
|
67
|
+
name: "B",
|
|
68
|
+
pattern: "local",
|
|
69
|
+
family: "Sora 700",
|
|
70
|
+
color: "#666666",
|
|
71
|
+
size: "48px",
|
|
72
|
+
});
|
|
73
|
+
const value = attrs.title.decoration.font.font.desktop.value;
|
|
74
|
+
assert.equal("weight" in value, false, "Pattern B canonical shape has no `weight` key in attrs.title.decoration.font.font.desktop.value");
|
|
75
|
+
// The exact set of structural keys, no more, no less. Order is not
|
|
76
|
+
// semantically meaningful (deep-equal is order-insensitive on objects)
|
|
77
|
+
// — sort here for a stable assertion.
|
|
78
|
+
assert.deepEqual(Object.keys(value).sort(), ["color", "family", "size"], "Pattern B emits ONLY the keys the user specified — no defaulted weight");
|
|
79
|
+
});
|
|
80
|
+
test("Pattern A discriminator: weight emitted when specified, absent when not", () => {
|
|
81
|
+
const withWeight = composeHeadingFontAttrs({
|
|
82
|
+
name: "A+w",
|
|
83
|
+
pattern: "google",
|
|
84
|
+
family: "Inter",
|
|
85
|
+
weight: "700",
|
|
86
|
+
});
|
|
87
|
+
const withoutWeight = composeHeadingFontAttrs({
|
|
88
|
+
name: "A-w",
|
|
89
|
+
pattern: "google",
|
|
90
|
+
family: "Inter",
|
|
91
|
+
});
|
|
92
|
+
const wv = withWeight.title.decoration.font.font.desktop.value;
|
|
93
|
+
const wov = withoutWeight.title.decoration.font.font.desktop.value;
|
|
94
|
+
assert.equal(wv.weight, "700");
|
|
95
|
+
assert.equal("weight" in wov, false, "weight absent when not specified");
|
|
96
|
+
});
|
|
97
|
+
test("Pattern B + explicit --font-weight is refused as out-of-scope", () => {
|
|
98
|
+
assert.throws(() => emitHeadingFontGroupPreset({
|
|
99
|
+
name: "B-with-weight",
|
|
100
|
+
pattern: "local",
|
|
101
|
+
family: "Sora 700",
|
|
102
|
+
weight: "700",
|
|
103
|
+
}, registry), (err) => {
|
|
104
|
+
assert.ok(err instanceof UnsupportedVariantCombinationError);
|
|
105
|
+
assert.match(err.message, /Pattern B/);
|
|
106
|
+
assert.match(err.message, /weight/);
|
|
107
|
+
return true;
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
test("emit-on-specification: only specified keys produce keys (Pattern A)", () => {
|
|
111
|
+
const attrs = composeHeadingFontAttrs({
|
|
112
|
+
name: "minimal",
|
|
113
|
+
pattern: "google",
|
|
114
|
+
family: "Inter",
|
|
115
|
+
});
|
|
116
|
+
const value = attrs.title.decoration.font.font.desktop.value;
|
|
117
|
+
assert.deepEqual(Object.keys(value), ["family"], "ONLY the touched key emitted — no defaulted weight/color/size/lineHeight");
|
|
118
|
+
});
|
|
119
|
+
test("variable color tokens get the {name,settings} object shape + trailing )$", () => {
|
|
120
|
+
const attrs = composeHeadingFontAttrs({
|
|
121
|
+
name: "v",
|
|
122
|
+
pattern: "google",
|
|
123
|
+
color: "gcid-heading-color",
|
|
124
|
+
});
|
|
125
|
+
const color = attrs.title.decoration.font.font.desktop.value.color;
|
|
126
|
+
assert.ok(color.endsWith(")$"));
|
|
127
|
+
assert.match(color, /^\$variable\(/);
|
|
128
|
+
const payload = JSON.parse(color.slice("$variable(".length, -2));
|
|
129
|
+
assert.deepEqual(payload, {
|
|
130
|
+
type: "color",
|
|
131
|
+
value: { name: "gcid-heading-color", settings: {} },
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
test("literal hex color is emitted verbatim, not wrapped", () => {
|
|
135
|
+
const attrs = composeHeadingFontAttrs({
|
|
136
|
+
name: "h",
|
|
137
|
+
pattern: "google",
|
|
138
|
+
color: "#666666",
|
|
139
|
+
});
|
|
140
|
+
assert.equal(attrs.title.decoration.font.font.desktop.value.color, "#666666");
|
|
141
|
+
});
|
|
142
|
+
test("lineHeight is emitted only when specified", () => {
|
|
143
|
+
const withLH = composeHeadingFontAttrs({
|
|
144
|
+
name: "lh+",
|
|
145
|
+
pattern: "google",
|
|
146
|
+
family: "Inter",
|
|
147
|
+
lineHeight: "1.1",
|
|
148
|
+
});
|
|
149
|
+
assert.equal(withLH.title.decoration.font.font.desktop.value.lineHeight, "1.1");
|
|
150
|
+
const without = composeHeadingFontAttrs({
|
|
151
|
+
name: "lh-",
|
|
152
|
+
pattern: "google",
|
|
153
|
+
family: "Inter",
|
|
154
|
+
});
|
|
155
|
+
assert.equal("lineHeight" in without.title.decoration.font.font.desktop.value, false);
|
|
156
|
+
});
|
|
157
|
+
test("emitter rejects an invalid `pattern` value", () => {
|
|
158
|
+
assert.throws(() => emitHeadingFontGroupPreset(
|
|
159
|
+
// @ts-expect-error — exercising runtime validation
|
|
160
|
+
{ name: "x", pattern: "auto", family: "Inter" }, registry), /pattern.*google.*local/i);
|
|
161
|
+
});
|
|
162
|
+
test("emitter rejects an empty preset (no styling specified)", () => {
|
|
163
|
+
assert.throws(() => emitHeadingFontGroupPreset({ name: "empty", pattern: "google" }, registry), /empty preset/);
|
|
164
|
+
});
|
|
165
|
+
test("emitter rejects a missing name", () => {
|
|
166
|
+
assert.throws(() => emitHeadingFontGroupPreset({ name: "", pattern: "google", family: "Inter" }, registry), /requires a non-empty `name`/);
|
|
167
|
+
});
|
|
168
|
+
test("variant-aware gate: missing applicability throws with attr+variant+source", () => {
|
|
169
|
+
// Build a synthetic registry where the Pattern A entry's applicability
|
|
170
|
+
// for divi/heading is REMOVED — Pattern B still verified. Confirms a
|
|
171
|
+
// missing applicability cell on the chosen variant is caught at the
|
|
172
|
+
// gate (not silently inherited from the pattern level), and that the
|
|
173
|
+
// error message names the variant and the registry source.
|
|
174
|
+
const real = loadRegistry();
|
|
175
|
+
const synthetic = JSON.parse(JSON.stringify(real));
|
|
176
|
+
const t2 = synthetic.tier2 ?? [];
|
|
177
|
+
const a = t2.find((e) => e.pattern_family === "divi/font" &&
|
|
178
|
+
e.pattern_variant === HEADING_FONT_PATTERN_VARIANTS.google);
|
|
179
|
+
assert.ok(a, "test prereq: Pattern A entry must exist in the real registry");
|
|
180
|
+
// Strip the divi/heading applicability cell from the Pattern A variant.
|
|
181
|
+
delete a.applicability["divi/heading"];
|
|
182
|
+
assert.throws(() => emitHeadingFontGroupPreset({
|
|
183
|
+
name: "missing-applic",
|
|
184
|
+
pattern: "google",
|
|
185
|
+
family: "Inter",
|
|
186
|
+
weight: "700",
|
|
187
|
+
}, synthetic), (err) => {
|
|
188
|
+
assert.ok(err instanceof EvidenceGateError);
|
|
189
|
+
assert.match(err.message, /divi\/font/);
|
|
190
|
+
assert.match(err.message, /google_fonts_pattern_a/);
|
|
191
|
+
assert.match(err.message, /UNVERIFIED \(0\)/);
|
|
192
|
+
assert.match(err.message, /absent from the registry entry/);
|
|
193
|
+
return true;
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
test("variant isolation: Pattern A evidence does NOT vouch for Pattern B (and vice versa)", () => {
|
|
197
|
+
// Build a synthetic registry containing ONLY the Pattern A divi/font
|
|
198
|
+
// entry (Pattern B variant removed). A Pattern-B caller must be
|
|
199
|
+
// refused — its variant is entirely absent — even though a Pattern A
|
|
200
|
+
// entry under the same `pattern_family` is fully verified.
|
|
201
|
+
const real = loadRegistry();
|
|
202
|
+
const synthetic = JSON.parse(JSON.stringify(real));
|
|
203
|
+
synthetic.tier2 = (synthetic.tier2 ?? []).filter((e) => !(e.pattern_family === "divi/font" &&
|
|
204
|
+
e.pattern_variant === HEADING_FONT_PATTERN_VARIANTS.local));
|
|
205
|
+
assert.throws(() => emitHeadingFontGroupPreset({
|
|
206
|
+
name: "B-without-its-entry",
|
|
207
|
+
pattern: "local",
|
|
208
|
+
family: "Sora 700",
|
|
209
|
+
}, synthetic), (err) => {
|
|
210
|
+
// Variant absent entirely → resolveEvidence throws the
|
|
211
|
+
// "absent from verified-attrs.json" error (not EvidenceGateError).
|
|
212
|
+
assert.match(err.message, /absent from verified-attrs\.json/, "missing variant must NOT silently fall back to the other variant's evidence");
|
|
213
|
+
assert.match(err.message, /local_hosted_pattern_b/);
|
|
214
|
+
return true;
|
|
215
|
+
});
|
|
216
|
+
// The reverse: stripping Pattern A and asking for Pattern A.
|
|
217
|
+
const synthetic2 = JSON.parse(JSON.stringify(real));
|
|
218
|
+
synthetic2.tier2 = (synthetic2.tier2 ?? []).filter((e) => !(e.pattern_family === "divi/font" &&
|
|
219
|
+
e.pattern_variant === HEADING_FONT_PATTERN_VARIANTS.google));
|
|
220
|
+
assert.throws(() => emitHeadingFontGroupPreset({ name: "A-without-its-entry", pattern: "google", family: "Inter" }, synthetic2), /google_fonts_pattern_a/);
|
|
221
|
+
});
|
|
222
|
+
test("buildHeadingFontPresetCreateBody mirrors the diviops_preset_create body shape", () => {
|
|
223
|
+
const entry = emitHeadingFontGroupPreset({ name: "H1", pattern: "google", family: "Inter", weight: "700" }, registry);
|
|
224
|
+
const body = buildHeadingFontPresetCreateBody(entry, { dry_run: true });
|
|
225
|
+
assert.deepEqual(body, {
|
|
226
|
+
module_name: HEADING_FONT_MODULE,
|
|
227
|
+
name: "H1",
|
|
228
|
+
attrs: entry.attrs,
|
|
229
|
+
type: "group",
|
|
230
|
+
group_name: HEADING_FONT_GROUP_NAME,
|
|
231
|
+
group_id: HEADING_FONT_GROUP_ID,
|
|
232
|
+
dry_run: true,
|
|
233
|
+
});
|
|
234
|
+
// `pattern_variant` is in-memory metadata; it must NOT leak into the wire body.
|
|
235
|
+
assert.equal("pattern_variant" in body, false, "pattern_variant is client-side gating metadata; it must not be sent over the wire");
|
|
236
|
+
const noDry = buildHeadingFontPresetCreateBody(entry);
|
|
237
|
+
assert.equal("dry_run" in noDry, false);
|
|
238
|
+
});
|
|
239
|
+
test("real registry: both divi/font variants on divi/heading clear the write threshold", () => {
|
|
240
|
+
// The verified-attrs.json shipped in-repo MUST keep both Pattern A and
|
|
241
|
+
// Pattern B at effective-evidence VB_PRESET_STORAGE_VERIFIED (4) for
|
|
242
|
+
// divi/heading. If a registry edit ever drops one below 4, this test
|
|
243
|
+
// is the canary.
|
|
244
|
+
const reg = loadRegistry();
|
|
245
|
+
const a = emitHeadingFontGroupPreset({ name: "A", pattern: "google", family: "Inter", weight: "700" }, reg);
|
|
246
|
+
assert.equal(a.pattern_variant, HEADING_FONT_PATTERN_VARIANTS.google);
|
|
247
|
+
const b = emitHeadingFontGroupPreset({ name: "B", pattern: "local", family: "Sora 700" }, reg);
|
|
248
|
+
assert.equal(b.pattern_variant, HEADING_FONT_PATTERN_VARIANTS.local);
|
|
249
|
+
});
|
|
@@ -132,3 +132,18 @@ test("real registry: divi/button styling families clear the write threshold", ()
|
|
|
132
132
|
gateWriteAttr(reg, "divi/button", fam);
|
|
133
133
|
}
|
|
134
134
|
});
|
|
135
|
+
test("variant-aware lookup selects the correct entry by pattern_variant", () => {
|
|
136
|
+
// The real registry carries TWO `divi/font` entries that share a
|
|
137
|
+
// pattern_family but differ on pattern_variant. resolveEvidence must
|
|
138
|
+
// honor the variant — a Pattern A query must NOT match the Pattern B
|
|
139
|
+
// entry (and vice versa), even though both share the `divi/font` family.
|
|
140
|
+
const reg = loadRegistry();
|
|
141
|
+
const a = resolveEvidence(reg, "divi/heading", "divi/font", "google_fonts_pattern_a");
|
|
142
|
+
assert.equal(a.patternVariant, "google_fonts_pattern_a");
|
|
143
|
+
assert.equal(a.effectiveLevel, 4, "Pattern A on divi/heading must clear VB_PRESET_STORAGE_VERIFIED in the shipped registry");
|
|
144
|
+
const b = resolveEvidence(reg, "divi/heading", "divi/font", "local_hosted_pattern_b");
|
|
145
|
+
assert.equal(b.patternVariant, "local_hosted_pattern_b");
|
|
146
|
+
assert.equal(b.effectiveLevel, 4, "Pattern B on divi/heading must clear VB_PRESET_STORAGE_VERIFIED in the shipped registry");
|
|
147
|
+
// A bogus variant under a known family throws — no silent fallback.
|
|
148
|
+
assert.throws(() => resolveEvidence(reg, "divi/heading", "divi/font", "no_such_variant"), /absent from verified-attrs\.json/);
|
|
149
|
+
});
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { test } from "node:test";
|
|
9
9
|
import assert from "node:assert/strict";
|
|
10
|
-
import { applyButtonPreset, assertStorageCapability, buildClientFromEnv, CapabilityMissingError, CredentialsMissingError, STORAGE_CAPABILITY, PRESET_CREATE_ROUTE, } from "../write-path.js";
|
|
10
|
+
import { applyButtonPreset, applyHeadingFontPreset, assertStorageCapability, buildClientFromEnv, CapabilityMissingError, CredentialsMissingError, STORAGE_CAPABILITY, PRESET_CREATE_ROUTE, } from "../write-path.js";
|
|
11
11
|
import { emitButtonGroupPreset } from "../button-emitter.js";
|
|
12
|
+
import { emitHeadingFontGroupPreset } from "../heading-font-emitter.js";
|
|
12
13
|
import { loadRegistry } from "../registry.js";
|
|
13
14
|
const registry = loadRegistry();
|
|
14
15
|
function handshake(capabilities) {
|
|
@@ -101,6 +102,59 @@ test("applyButtonPreset threads dry_run into the body when requested", async ()
|
|
|
101
102
|
const options = client.calls[0].options;
|
|
102
103
|
assert.equal(options.body.dry_run, true);
|
|
103
104
|
});
|
|
105
|
+
// ------------------------------------------------------------------
|
|
106
|
+
// heading-font apply-mode — mocked only.
|
|
107
|
+
// Mirrors the button apply-mode coverage: capability gate first, then a
|
|
108
|
+
// single POST to /preset/create with the canonical body. pattern_variant
|
|
109
|
+
// is in-memory only and must NOT appear in the wire body.
|
|
110
|
+
// ------------------------------------------------------------------
|
|
111
|
+
test("applyHeadingFontPreset gates capability BEFORE issuing the write", async () => {
|
|
112
|
+
const client = mockClient({ capabilities: {} });
|
|
113
|
+
const entry = emitHeadingFontGroupPreset({ name: "H1", pattern: "google", family: "Inter", weight: "700" }, registry);
|
|
114
|
+
await assert.rejects(() => applyHeadingFontPreset(client, entry, {
|
|
115
|
+
serverVersion: TEST_SERVER_VERSION,
|
|
116
|
+
}), CapabilityMissingError);
|
|
117
|
+
assert.equal(client.calls.length, 0, "no write issued when capability is absent");
|
|
118
|
+
});
|
|
119
|
+
test("applyHeadingFontPreset posts to /preset/create with the canonical body", async () => {
|
|
120
|
+
const client = mockClient({ capabilities: { [STORAGE_CAPABILITY]: true } });
|
|
121
|
+
const entry = emitHeadingFontGroupPreset({
|
|
122
|
+
name: "H1",
|
|
123
|
+
pattern: "google",
|
|
124
|
+
family: "Inter",
|
|
125
|
+
weight: "700",
|
|
126
|
+
color: "gcid-heading-color",
|
|
127
|
+
size: "48px",
|
|
128
|
+
}, registry);
|
|
129
|
+
const result = await applyHeadingFontPreset(client, entry, {
|
|
130
|
+
serverVersion: TEST_SERVER_VERSION,
|
|
131
|
+
});
|
|
132
|
+
assert.equal(client.handshakeVersions[0], TEST_SERVER_VERSION);
|
|
133
|
+
assert.equal(client.calls.length, 1);
|
|
134
|
+
const call = client.calls[0];
|
|
135
|
+
assert.equal(call.endpoint, PRESET_CREATE_ROUTE);
|
|
136
|
+
const options = call.options;
|
|
137
|
+
assert.equal(options.method, "POST");
|
|
138
|
+
assert.equal(options.body.type, "group");
|
|
139
|
+
assert.equal(options.body.module_name, "divi/heading");
|
|
140
|
+
assert.equal(options.body.group_name, "divi/font");
|
|
141
|
+
assert.equal(options.body.group_id, "designTitleText");
|
|
142
|
+
assert.equal(options.body.name, "H1");
|
|
143
|
+
assert.deepEqual(options.body.attrs, entry.attrs);
|
|
144
|
+
assert.equal("pattern_variant" in options.body, false, "pattern_variant is client-side gating metadata; it must not be on the wire");
|
|
145
|
+
assert.equal("dry_run" in options.body, false);
|
|
146
|
+
assert.deepEqual(result, { ok: true, data: { preset_id: "mocked123" } });
|
|
147
|
+
});
|
|
148
|
+
test("applyHeadingFontPreset threads dry_run into the body when requested", async () => {
|
|
149
|
+
const client = mockClient({ capabilities: { [STORAGE_CAPABILITY]: true } });
|
|
150
|
+
const entry = emitHeadingFontGroupPreset({ name: "H1", pattern: "local", family: "Sora 700" }, registry);
|
|
151
|
+
await applyHeadingFontPreset(client, entry, {
|
|
152
|
+
serverVersion: TEST_SERVER_VERSION,
|
|
153
|
+
dry_run: true,
|
|
154
|
+
});
|
|
155
|
+
const options = client.calls[0].options;
|
|
156
|
+
assert.equal(options.body.dry_run, true);
|
|
157
|
+
});
|
|
104
158
|
test("buildClientFromEnv throws CredentialsMissingError when env vars are absent", () => {
|
|
105
159
|
assert.throws(() => buildClientFromEnv({}), (err) => {
|
|
106
160
|
assert.ok(err instanceof CredentialsMissingError);
|
package/dist/preset-cli/cli.d.ts
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* 4 write / network error
|
|
24
24
|
*/
|
|
25
25
|
import { type ButtonEmitterInput } from "./button-emitter.js";
|
|
26
|
+
import { type HeadingFontEmitterInput } from "./heading-font-emitter.js";
|
|
26
27
|
export declare const EXIT: {
|
|
27
28
|
readonly OK: 0;
|
|
28
29
|
readonly INVALID_INPUT: 1;
|
|
@@ -50,6 +51,8 @@ export declare class UsageError extends Error {
|
|
|
50
51
|
}
|
|
51
52
|
/** Map parsed `button` options into the emitter input shape. */
|
|
52
53
|
export declare function buildButtonInput(parsed: ParsedArgs): ButtonEmitterInput;
|
|
54
|
+
/** Map parsed `heading-font` options into the heading-font emitter input shape. */
|
|
55
|
+
export declare function buildHeadingFontInput(parsed: ParsedArgs): HeadingFontEmitterInput;
|
|
53
56
|
/**
|
|
54
57
|
* Run the CLI. Returns the structured exit code (does NOT call
|
|
55
58
|
* `process.exit` — the thin bin wrapper does). `io` is injectable so
|