@diviops/mcp-server 1.5.11 → 1.5.13

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.
@@ -23,8 +23,10 @@
23
23
  * 4 write / network error
24
24
  */
25
25
  import { emitButtonGroupPreset, buildPresetCreateBody, } from "./button-emitter.js";
26
+ import { emitHeadingFontGroupPreset, buildHeadingFontPresetCreateBody, UnsupportedVariantCombinationError, } from "./heading-font-emitter.js";
27
+ import { emitTextBodyFontGroupPreset, buildTextBodyFontPresetCreateBody, } from "./text-body-font-emitter.js";
26
28
  import { EvidenceGateError } from "./registry.js";
27
- import { applyButtonPreset, buildClientFromEnv, CredentialsMissingError, CapabilityMissingError, } from "./write-path.js";
29
+ import { applyButtonPreset, applyHeadingFontPreset, applyTextBodyFontPreset, buildClientFromEnv, CredentialsMissingError, CapabilityMissingError, } from "./write-path.js";
28
30
  export const EXIT = {
29
31
  OK: 0,
30
32
  INVALID_INPUT: 1,
@@ -39,8 +41,12 @@ const realIO = {
39
41
  const HELP = `diviops-preset — Divi 5.5.x canonical preset-emitter CLI
40
42
 
41
43
  USAGE
42
- diviops-preset button [options] Emit a divi/button group preset
43
- diviops-preset --help Show this help
44
+ diviops-preset button [options] Emit a divi/button group preset
45
+ diviops-preset heading-font [options] Emit a divi/font group preset for
46
+ divi/heading
47
+ diviops-preset text-body-font [options] Emit a divi/font-body group preset
48
+ for divi/text (Pattern A only)
49
+ diviops-preset --help Show this help
44
50
 
45
51
  MODE
46
52
  --dry-run Compose + print canonical JSON only (DEFAULT).
@@ -72,6 +78,51 @@ button OPTIONS (all styling fields optional; emit-on-specification only)
72
78
  .value.padding.top="0px" (hover-padding-gate
73
79
  workaround). Off by default.
74
80
 
81
+ heading-font OPTIONS (all styling fields optional; emit-on-specification only)
82
+ --name <string> Preset display name (required).
83
+ --pattern <google|local> Required. Selects the verified variant:
84
+ google — Pattern A: plain family + explicit
85
+ numeric weight (e.g. family "Inter",
86
+ weight "700"). Verified against
87
+ round-1a fixture.
88
+ local — Pattern B: weight-encoded family
89
+ string (e.g. family "Sora 700") with
90
+ NO --font-weight. Used for local-
91
+ hosted/EU-GDPR font flows. Verified
92
+ against round-1b fixture.
93
+ Pattern A and Pattern B are distinct registry
94
+ variants — there is NO default; omitting
95
+ --pattern is invalid input.
96
+ --font-family <string> Font family. Pattern A: plain name ("Inter").
97
+ Pattern B: weight-encoded ("Sora 700").
98
+ --font-weight <string> Font weight (e.g. "700"). Pattern A only —
99
+ passing it with --pattern local is refused.
100
+ --font-color <value> Font color. Hex literal, bare gcid-*/gvid-*
101
+ token, or already-formed $variable(...)$ token.
102
+ --font-size <v> Font size (e.g. "48px").
103
+ --font-line-height <v> Font line-height (e.g. "1.1").
104
+
105
+ text-body-font OPTIONS (all styling fields optional; emit-on-specification only)
106
+ --name <string> Preset display name (required).
107
+ --pattern <google|local> Required. Example: --pattern google.
108
+ Pattern A is the only supported variant for now:
109
+ google — Pattern A: plain family + optional
110
+ numeric weight (e.g. family "Inter",
111
+ weight "400"). Verified against
112
+ round-2 body-text fixture.
113
+ local — Pattern B for divi/font-body is NOT
114
+ registered (no canonical-shape
115
+ capture exists yet). Selecting it
116
+ lands on a registry-absence refusal.
117
+ There is NO default; omitting --pattern is
118
+ invalid input.
119
+ --font-family <string> Font family (plain name, e.g. "Inter").
120
+ --font-weight <string> Font weight (e.g. "400").
121
+ --font-color <value> Font color. Hex literal, bare gcid-*/gvid-*
122
+ token, or already-formed $variable(...)$ token.
123
+ --font-size <v> Font size (e.g. "16px").
124
+ --font-line-height <v> Font line-height (e.g. "1.5").
125
+
75
126
  EXIT CODES
76
127
  0 success 1 invalid input 2 evidence-gate refusal
77
128
  3 capability missing 4 write error
@@ -82,6 +133,18 @@ EXAMPLES
82
133
  --font-family Inter --font-weight 600 --font-color gcid-body-color
83
134
 
84
135
  diviops-preset button --name "Primary" --bg-color "#2563eb" --apply
136
+
137
+ diviops-preset heading-font --name "Heading H1" --pattern google \\
138
+ --font-family Inter --font-weight 700 \\
139
+ --font-color gcid-heading-color --font-size 48px
140
+
141
+ diviops-preset heading-font --name "Heading H1 (local)" --pattern local \\
142
+ --font-family "Sora 700" \\
143
+ --font-color gcid-heading-color --font-size 48px
144
+
145
+ diviops-preset text-body-font --name "Body Text" --pattern google \\
146
+ --font-family Inter --font-weight 400 \\
147
+ --font-color gcid-body-color --font-size 16px
85
148
  `;
86
149
  const VALUE_FLAGS = new Set([
87
150
  "--name",
@@ -100,6 +163,8 @@ const VALUE_FLAGS = new Set([
100
163
  "--font-weight",
101
164
  "--font-color",
102
165
  "--font-size",
166
+ "--font-line-height",
167
+ "--pattern",
103
168
  ]);
104
169
  /** Parse argv (after `node script`) into a structured shape. Throws on unknown flags. */
105
170
  export function parseArgs(argv) {
@@ -163,6 +228,8 @@ export class UsageError extends Error {
163
228
  this.name = "UsageError";
164
229
  }
165
230
  }
231
+ /** Known CLI commands. The CLI rejects anything else with EXIT.INVALID_INPUT. */
232
+ const KNOWN_COMMANDS = new Set(["button", "heading-font", "text-body-font"]);
166
233
  /** Map parsed `button` options into the emitter input shape. */
167
234
  export function buildButtonInput(parsed) {
168
235
  const opt = (k) => {
@@ -244,6 +311,89 @@ export function buildButtonInput(parsed) {
244
311
  }
245
312
  return input;
246
313
  }
314
+ /** Map parsed `heading-font` options into the heading-font emitter input shape. */
315
+ export function buildHeadingFontInput(parsed) {
316
+ const opt = (k) => {
317
+ const v = parsed.options.get(k);
318
+ return typeof v === "string" ? v : undefined;
319
+ };
320
+ const name = opt("--name");
321
+ if (!name) {
322
+ throw new UsageError("heading-font command requires --name <string>.");
323
+ }
324
+ // --pattern is REQUIRED — Pattern A vs Pattern B are distinct registry/
325
+ // evidence variants and there is no safe default. An omitted --pattern
326
+ // is invalid input; the CLI fails BEFORE emission (no guessing).
327
+ const patternRaw = opt("--pattern");
328
+ if (patternRaw === undefined) {
329
+ throw new UsageError("heading-font command requires --pattern <google|local>. " +
330
+ "There is no default — Pattern A (google) and Pattern B (local) are distinct " +
331
+ "registry variants and must be selected intentionally.");
332
+ }
333
+ if (patternRaw !== "google" && patternRaw !== "local") {
334
+ throw new UsageError(`--pattern must be "google" or "local"; got ${JSON.stringify(patternRaw)}.`);
335
+ }
336
+ const pattern = patternRaw;
337
+ const input = { name, pattern };
338
+ const family = opt("--font-family");
339
+ if (family !== undefined)
340
+ input.family = family;
341
+ const weight = opt("--font-weight");
342
+ if (weight !== undefined)
343
+ input.weight = weight;
344
+ const color = opt("--font-color");
345
+ if (color !== undefined)
346
+ input.color = color;
347
+ const size = opt("--font-size");
348
+ if (size !== undefined)
349
+ input.size = size;
350
+ const lineHeight = opt("--font-line-height");
351
+ if (lineHeight !== undefined)
352
+ input.lineHeight = lineHeight;
353
+ return input;
354
+ }
355
+ /** Map parsed `text-body-font` options into the text-body-font emitter input shape. */
356
+ export function buildTextBodyFontInput(parsed) {
357
+ const opt = (k) => {
358
+ const v = parsed.options.get(k);
359
+ return typeof v === "string" ? v : undefined;
360
+ };
361
+ const name = opt("--name");
362
+ if (!name) {
363
+ throw new UsageError("text-body-font command requires --name <string>.");
364
+ }
365
+ // --pattern is REQUIRED — there is no safe default. Pattern A only;
366
+ // passing "local" lands on the registry-absence refusal downstream
367
+ // (no Pattern B entry exists for `divi/font-body`).
368
+ const patternRaw = opt("--pattern");
369
+ if (patternRaw === undefined) {
370
+ throw new UsageError("text-body-font command requires --pattern <google|local>. " +
371
+ "Example: --pattern google. Pattern A (google) is the only " +
372
+ "supported variant for now; Pattern B (local) has no registry " +
373
+ "entry for `divi/font-body` and will be refused.");
374
+ }
375
+ if (patternRaw !== "google" && patternRaw !== "local") {
376
+ throw new UsageError(`--pattern must be "google" or "local"; got ${JSON.stringify(patternRaw)}.`);
377
+ }
378
+ const pattern = patternRaw;
379
+ const input = { name, pattern };
380
+ const family = opt("--font-family");
381
+ if (family !== undefined)
382
+ input.family = family;
383
+ const weight = opt("--font-weight");
384
+ if (weight !== undefined)
385
+ input.weight = weight;
386
+ const color = opt("--font-color");
387
+ if (color !== undefined)
388
+ input.color = color;
389
+ const size = opt("--font-size");
390
+ if (size !== undefined)
391
+ input.size = size;
392
+ const lineHeight = opt("--font-line-height");
393
+ if (lineHeight !== undefined)
394
+ input.lineHeight = lineHeight;
395
+ return input;
396
+ }
247
397
  /**
248
398
  * Run the CLI. Returns the structured exit code (does NOT call
249
399
  * `process.exit` — the thin bin wrapper does). `io` is injectable so
@@ -264,17 +414,48 @@ export async function run(argv, io = realIO, serverVersion) {
264
414
  io.out(HELP);
265
415
  return EXIT.OK;
266
416
  }
267
- if (parsed.command !== "button") {
417
+ if (parsed.command === null || !KNOWN_COMMANDS.has(parsed.command)) {
268
418
  io.err(`Unknown command: ${parsed.command ?? "(none)"}. ` +
269
- `Track 4 ships one command: "button".`);
419
+ `Known commands: ${[...KNOWN_COMMANDS].sort().join(", ")}.`);
270
420
  io.err("Run `diviops-preset --help` for usage.");
271
421
  return EXIT.INVALID_INPUT;
272
422
  }
273
423
  // --- compose + gate -------------------------------------------------
274
- let entry;
424
+ // Per-command branch produces:
425
+ // - `dryRunBody`: the canonical JSON to print in --dry-run mode.
426
+ // - `applyFn`: a closure that issues the apply-mode write when the
427
+ // capability check passes. Both branches reuse the existing write-
428
+ // path plumbing (`assertStorageCapability` → `/preset/create`).
429
+ let dryRunBody;
430
+ let applyFn;
275
431
  try {
276
- const input = buildButtonInput(parsed);
277
- entry = emitButtonGroupPreset(input);
432
+ if (parsed.command === "button") {
433
+ const input = buildButtonInput(parsed);
434
+ const entry = emitButtonGroupPreset(input);
435
+ dryRunBody = buildPresetCreateBody(entry, { dry_run: true });
436
+ applyFn = (client, sv) => applyButtonPreset(client, entry, { serverVersion: sv });
437
+ }
438
+ else if (parsed.command === "heading-font") {
439
+ const input = buildHeadingFontInput(parsed);
440
+ const entry = emitHeadingFontGroupPreset(input);
441
+ dryRunBody = buildHeadingFontPresetCreateBody(entry, { dry_run: true });
442
+ applyFn = (client, sv) => applyHeadingFontPreset(client, entry, { serverVersion: sv });
443
+ }
444
+ else if (parsed.command === "text-body-font") {
445
+ // text-body-font (Pattern A only; Pattern B lands on the
446
+ // registry-absence refusal in emitTextBodyFontGroupPreset).
447
+ const input = buildTextBodyFontInput(parsed);
448
+ const entry = emitTextBodyFontGroupPreset(input);
449
+ dryRunBody = buildTextBodyFontPresetCreateBody(entry, { dry_run: true });
450
+ applyFn = (client, sv) => applyTextBodyFontPreset(client, entry, { serverVersion: sv });
451
+ }
452
+ else {
453
+ // Defensive: a new entry in KNOWN_COMMANDS without a dispatch
454
+ // branch here would silently break dry-run/apply. parseArgs
455
+ // already gates on KNOWN_COMMANDS upstream, so reaching this is a
456
+ // programmer error in the dispatch wiring.
457
+ throw new UsageError(`Unhandled command: ${parsed.command}`);
458
+ }
278
459
  }
279
460
  catch (err) {
280
461
  if (err instanceof EvidenceGateError) {
@@ -286,13 +467,19 @@ export async function run(argv, io = realIO, serverVersion) {
286
467
  io.err("Run `diviops-preset --help` for usage.");
287
468
  return EXIT.INVALID_INPUT;
288
469
  }
470
+ if (err instanceof UnsupportedVariantCombinationError) {
471
+ // Distinct from EvidenceGateError: the registry IS complete for the
472
+ // verified variants, but the caller asked for a combination outside
473
+ // any verified variant. Surfaces as invalid input (exit 1).
474
+ io.err(err.message);
475
+ return EXIT.INVALID_INPUT;
476
+ }
289
477
  io.err(err instanceof Error ? err.message : String(err));
290
478
  return EXIT.INVALID_INPUT;
291
479
  }
292
480
  // --- dry-run --------------------------------------------------------
293
481
  if (parsed.dryRun) {
294
- const body = buildPresetCreateBody(entry, { dry_run: true });
295
- io.out(JSON.stringify(body, null, 2));
482
+ io.out(JSON.stringify(dryRunBody, null, 2));
296
483
  return EXIT.OK;
297
484
  }
298
485
  // --- apply ----------------------------------------------------------
@@ -307,7 +494,7 @@ export async function run(argv, io = realIO, serverVersion) {
307
494
  "This is an internal error — invoke via the `diviops-preset` bin.");
308
495
  return EXIT.WRITE_ERROR;
309
496
  }
310
- const result = await applyButtonPreset(client, entry, { serverVersion });
497
+ const result = await applyFn(client, serverVersion);
311
498
  io.out(JSON.stringify(result, null, 2));
312
499
  return EXIT.OK;
313
500
  }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * `divi/font` heading group-preset emitter — Track 5 vertical slice.
3
+ *
4
+ * Emits a byte-canonical Divi 5.5.x `type: "group"` `divi/font` preset
5
+ * targeting `divi/heading` at `attrs.title.decoration.font.font.desktop.value.*`,
6
+ * gated by the verified-attrs registry. Canonical shape:
7
+ * `docs/verification/canonical-preset-shapes-per-module-type-2026-05-18.md`
8
+ * (`divi/font` section), cross-checked against
9
+ * `docs/verification/evidence/canonical-shape-dumps-2026-05-18/round-1a-heading-h1-pattern-a-google.json`
10
+ * and `…/round-1b-heading-h1-pattern-b-local.json`.
11
+ *
12
+ * Two verified Pattern variants live in the registry — they are NOT
13
+ * interchangeable evidence. The caller MUST select one explicitly:
14
+ *
15
+ * - Pattern A (Google Fonts CDN): plain family + explicit numeric weight.
16
+ * Registry entry: `divi/font` with `pattern_variant: "google_fonts_pattern_a"`.
17
+ * Verified evidence: round-1a fixture.
18
+ * - Pattern B (local-hosted, EU-GDPR): weight encoded into the family
19
+ * string ("Sora 700") and NO `weight` key at all in the preset.
20
+ * Registry entry: `divi/font` with `pattern_variant: "local_hosted_pattern_b"`.
21
+ * Verified evidence: round-1b fixture.
22
+ *
23
+ * Shape rules enforced here:
24
+ * - Emit-on-specification only — omitted params produce NO keys.
25
+ * - `attrs == styleAttrs == renderAttrs` at the route layer (the plugin's
26
+ * `/preset/create` route mirrors `attrs` into all three buckets to match
27
+ * VB save semantics); the CLI request body only carries `attrs`.
28
+ * - Double-font nesting: `title.decoration.font.font.desktop.value.*` —
29
+ * outer `font` is the decoration category, inner `font` is the attr
30
+ * name within that category.
31
+ * - Pattern B with an explicit `weight` is REFUSED — the verified Pattern
32
+ * B capture has no `weight` key and there is no registry entry
33
+ * authorizing local-hosted-with-explicit-weight.
34
+ */
35
+ import { type VerifiedAttrsRegistry } from "./registry.js";
36
+ export declare const HEADING_FONT_MODULE = "divi/heading";
37
+ export declare const HEADING_FONT_GROUP_NAME = "divi/font";
38
+ export declare const HEADING_FONT_GROUP_ID = "designTitleText";
39
+ export declare const HEADING_FONT_PATTERN_FAMILY = "divi/font";
40
+ /** The two verified pattern variants for `divi/font` on `divi/heading`. */
41
+ export declare const HEADING_FONT_PATTERN_VARIANTS: {
42
+ readonly google: "google_fonts_pattern_a";
43
+ readonly local: "local_hosted_pattern_b";
44
+ };
45
+ export type HeadingFontPattern = "google" | "local";
46
+ export interface HeadingFontEmitterInput {
47
+ /** Required display name for the preset. */
48
+ name: string;
49
+ /**
50
+ * Required pattern selector. There is no default — Pattern A and
51
+ * Pattern B are distinct registry/evidence variants and the caller
52
+ * must select intentionally.
53
+ */
54
+ pattern: HeadingFontPattern;
55
+ /**
56
+ * Font family.
57
+ * - Pattern A (google): plain family name, e.g. `"Inter"`.
58
+ * - Pattern B (local): weight-encoded family name, e.g. `"Sora 700"`.
59
+ */
60
+ family?: string;
61
+ /**
62
+ * Font weight (numeric-string, e.g. `"700"`).
63
+ * - Pattern A: optional; emitted when specified.
64
+ * - Pattern B: PROHIBITED — passing a weight in `local` mode is a
65
+ * usage error (the verified Pattern B shape has no `weight` key,
66
+ * and there is no registry entry vouching for that combination).
67
+ */
68
+ weight?: string;
69
+ /** Font color — literal hex or bare/formed variable token. */
70
+ color?: string;
71
+ /** Font size — literal (e.g. `"48px"`) or already-formed `$variable(...)$`. */
72
+ size?: string;
73
+ /** Line height — optional, emit-on-specification only. */
74
+ lineHeight?: string;
75
+ }
76
+ /** The composed canonical preset entry shape sent to `/preset/create`. */
77
+ export interface HeadingFontPresetEntry {
78
+ type: "group";
79
+ module_name: string;
80
+ group_name: string;
81
+ group_id: string;
82
+ pattern_variant: string;
83
+ name: string;
84
+ attrs: Record<string, unknown>;
85
+ }
86
+ /**
87
+ * Raised when the caller passes a combination the registry does not
88
+ * authorize for the current Track — distinct from `EvidenceGateError`
89
+ * (registry-evidence shortfall) and `UsageError` (CLI arg parse).
90
+ */
91
+ export declare class UnsupportedVariantCombinationError extends Error {
92
+ constructor(message: string);
93
+ }
94
+ /**
95
+ * Compose the canonical `attrs.title.decoration.font.font.desktop.value`
96
+ * bag from the input. Emit-on-specification: only specified sub-fields
97
+ * produce keys.
98
+ */
99
+ export declare function composeHeadingFontAttrs(input: HeadingFontEmitterInput): Record<string, unknown>;
100
+ /**
101
+ * Emit a canonical `divi/font` heading group preset.
102
+ *
103
+ * 1. Validate input shape (name, pattern, Pattern B + weight refusal).
104
+ * 2. Compose `attrs.title.decoration.font.font.desktop.value.*`
105
+ * (emit-on-specification).
106
+ * 3. Gate the chosen `(divi/font, pattern_variant)` cell on `divi/heading`
107
+ * against the verified-attrs registry — throws `EvidenceGateError`
108
+ * when effective evidence is below `VB_PRESET_STORAGE_VERIFIED`.
109
+ *
110
+ * `styleAttrs` and `renderAttrs` are intentionally NOT part of this
111
+ * entry: the plugin's `/preset/create` route mirrors the single `attrs`
112
+ * bag into all three buckets, writing `attrs == styleAttrs == renderAttrs`
113
+ * to match VB save semantics — see `trait-preset.php` `preset_create`.
114
+ * The Round 1a/1b fixtures capture the post-write storage shape.
115
+ */
116
+ export declare function emitHeadingFontGroupPreset(input: HeadingFontEmitterInput, registry?: VerifiedAttrsRegistry): HeadingFontPresetEntry;
117
+ /**
118
+ * Build the `POST /diviops/v1/preset/create` request body from a heading
119
+ * font preset entry. Matches the body shape the `diviops_preset_create`
120
+ * MCP tool posts — the CLI reuses the existing route, it does not add one.
121
+ *
122
+ * `pattern_variant` is informational metadata on the in-memory entry and
123
+ * is NOT sent over the wire — the registry-gate decision happens
124
+ * client-side before the write.
125
+ */
126
+ export declare function buildHeadingFontPresetCreateBody(entry: HeadingFontPresetEntry, opts?: {
127
+ dry_run?: boolean;
128
+ }): Record<string, unknown>;
@@ -0,0 +1,166 @@
1
+ /**
2
+ * `divi/font` heading group-preset emitter — Track 5 vertical slice.
3
+ *
4
+ * Emits a byte-canonical Divi 5.5.x `type: "group"` `divi/font` preset
5
+ * targeting `divi/heading` at `attrs.title.decoration.font.font.desktop.value.*`,
6
+ * gated by the verified-attrs registry. Canonical shape:
7
+ * `docs/verification/canonical-preset-shapes-per-module-type-2026-05-18.md`
8
+ * (`divi/font` section), cross-checked against
9
+ * `docs/verification/evidence/canonical-shape-dumps-2026-05-18/round-1a-heading-h1-pattern-a-google.json`
10
+ * and `…/round-1b-heading-h1-pattern-b-local.json`.
11
+ *
12
+ * Two verified Pattern variants live in the registry — they are NOT
13
+ * interchangeable evidence. The caller MUST select one explicitly:
14
+ *
15
+ * - Pattern A (Google Fonts CDN): plain family + explicit numeric weight.
16
+ * Registry entry: `divi/font` with `pattern_variant: "google_fonts_pattern_a"`.
17
+ * Verified evidence: round-1a fixture.
18
+ * - Pattern B (local-hosted, EU-GDPR): weight encoded into the family
19
+ * string ("Sora 700") and NO `weight` key at all in the preset.
20
+ * Registry entry: `divi/font` with `pattern_variant: "local_hosted_pattern_b"`.
21
+ * Verified evidence: round-1b fixture.
22
+ *
23
+ * Shape rules enforced here:
24
+ * - Emit-on-specification only — omitted params produce NO keys.
25
+ * - `attrs == styleAttrs == renderAttrs` at the route layer (the plugin's
26
+ * `/preset/create` route mirrors `attrs` into all three buckets to match
27
+ * VB save semantics); the CLI request body only carries `attrs`.
28
+ * - Double-font nesting: `title.decoration.font.font.desktop.value.*` —
29
+ * outer `font` is the decoration category, inner `font` is the attr
30
+ * name within that category.
31
+ * - Pattern B with an explicit `weight` is REFUSED — the verified Pattern
32
+ * B capture has no `weight` key and there is no registry entry
33
+ * authorizing local-hosted-with-explicit-weight.
34
+ */
35
+ import { loadRegistry, gateWriteAttr, } from "./registry.js";
36
+ import { normalizeColorValue } from "./variable-token.js";
37
+ export const HEADING_FONT_MODULE = "divi/heading";
38
+ export const HEADING_FONT_GROUP_NAME = "divi/font";
39
+ export const HEADING_FONT_GROUP_ID = "designTitleText";
40
+ export const HEADING_FONT_PATTERN_FAMILY = "divi/font";
41
+ /** The two verified pattern variants for `divi/font` on `divi/heading`. */
42
+ export const HEADING_FONT_PATTERN_VARIANTS = {
43
+ google: "google_fonts_pattern_a",
44
+ local: "local_hosted_pattern_b",
45
+ };
46
+ /**
47
+ * Raised when the caller passes a combination the registry does not
48
+ * authorize for the current Track — distinct from `EvidenceGateError`
49
+ * (registry-evidence shortfall) and `UsageError` (CLI arg parse).
50
+ */
51
+ export class UnsupportedVariantCombinationError extends Error {
52
+ constructor(message) {
53
+ super(message);
54
+ this.name = "UnsupportedVariantCombinationError";
55
+ }
56
+ }
57
+ /**
58
+ * Compose the canonical `attrs.title.decoration.font.font.desktop.value`
59
+ * bag from the input. Emit-on-specification: only specified sub-fields
60
+ * produce keys.
61
+ */
62
+ export function composeHeadingFontAttrs(input) {
63
+ const value = {};
64
+ if (input.family !== undefined)
65
+ value.family = input.family;
66
+ if (input.weight !== undefined)
67
+ value.weight = input.weight;
68
+ if (input.color !== undefined)
69
+ value.color = normalizeColorValue(input.color);
70
+ if (input.size !== undefined)
71
+ value.size = input.size;
72
+ if (input.lineHeight !== undefined)
73
+ value.lineHeight = input.lineHeight;
74
+ return {
75
+ title: {
76
+ decoration: {
77
+ font: {
78
+ font: {
79
+ desktop: {
80
+ value,
81
+ },
82
+ },
83
+ },
84
+ },
85
+ },
86
+ };
87
+ }
88
+ /**
89
+ * Emit a canonical `divi/font` heading group preset.
90
+ *
91
+ * 1. Validate input shape (name, pattern, Pattern B + weight refusal).
92
+ * 2. Compose `attrs.title.decoration.font.font.desktop.value.*`
93
+ * (emit-on-specification).
94
+ * 3. Gate the chosen `(divi/font, pattern_variant)` cell on `divi/heading`
95
+ * against the verified-attrs registry — throws `EvidenceGateError`
96
+ * when effective evidence is below `VB_PRESET_STORAGE_VERIFIED`.
97
+ *
98
+ * `styleAttrs` and `renderAttrs` are intentionally NOT part of this
99
+ * entry: the plugin's `/preset/create` route mirrors the single `attrs`
100
+ * bag into all three buckets, writing `attrs == styleAttrs == renderAttrs`
101
+ * to match VB save semantics — see `trait-preset.php` `preset_create`.
102
+ * The Round 1a/1b fixtures capture the post-write storage shape.
103
+ */
104
+ export function emitHeadingFontGroupPreset(input, registry = loadRegistry()) {
105
+ if (!input.name || typeof input.name !== "string") {
106
+ throw new Error("Heading-font emitter requires a non-empty `name`.");
107
+ }
108
+ if (input.pattern !== "google" && input.pattern !== "local") {
109
+ throw new Error(`Heading-font emitter requires \`pattern\` to be "google" or "local"; got ${JSON.stringify(input.pattern)}. ` +
110
+ `There is no default — Pattern A (google) and Pattern B (local) are distinct registry/evidence variants.`);
111
+ }
112
+ // Pattern B + explicit weight is out-of-scope for Track 5. The verified
113
+ // Pattern B capture proves `weight` is absent; no registry entry
114
+ // currently authorizes a local-hosted-with-explicit-weight shape.
115
+ if (input.pattern === "local" && input.weight !== undefined) {
116
+ throw new UnsupportedVariantCombinationError(`Pattern B (local-hosted) does not support an explicit \`weight\` — the verified ` +
117
+ `Pattern B shape has no \`weight\` key, and there is no registry variant ` +
118
+ `authorizing local-hosted-with-explicit-weight. Encode the weight into the ` +
119
+ `family string instead (e.g. \`family: "Sora 700"\`). A future registry entry ` +
120
+ `can authorize that shape if VB evidence supports it.`);
121
+ }
122
+ const attrs = composeHeadingFontAttrs(input);
123
+ // Sanity check: at least one styling field was specified. An empty
124
+ // value bag is a usage error — there is nothing to write.
125
+ const value = attrs.title.decoration.font.font.desktop.value;
126
+ if (!value || Object.keys(value).length === 0) {
127
+ throw new Error("Heading-font emitter produced an empty preset — pass at least one of " +
128
+ "family, weight, color, size, or lineHeight.");
129
+ }
130
+ // Registry gate: variant-aware. Pattern A evidence must NOT vouch for
131
+ // Pattern B and vice versa — gateWriteAttr resolves by both family AND
132
+ // variant, so a missing/under-verified variant entry throws here.
133
+ const patternVariant = HEADING_FONT_PATTERN_VARIANTS[input.pattern];
134
+ gateWriteAttr(registry, HEADING_FONT_MODULE, HEADING_FONT_PATTERN_FAMILY, patternVariant);
135
+ return {
136
+ type: "group",
137
+ module_name: HEADING_FONT_MODULE,
138
+ group_name: HEADING_FONT_GROUP_NAME,
139
+ group_id: HEADING_FONT_GROUP_ID,
140
+ pattern_variant: patternVariant,
141
+ name: input.name,
142
+ attrs,
143
+ };
144
+ }
145
+ /**
146
+ * Build the `POST /diviops/v1/preset/create` request body from a heading
147
+ * font preset entry. Matches the body shape the `diviops_preset_create`
148
+ * MCP tool posts — the CLI reuses the existing route, it does not add one.
149
+ *
150
+ * `pattern_variant` is informational metadata on the in-memory entry and
151
+ * is NOT sent over the wire — the registry-gate decision happens
152
+ * client-side before the write.
153
+ */
154
+ export function buildHeadingFontPresetCreateBody(entry, opts = {}) {
155
+ const body = {
156
+ module_name: entry.module_name,
157
+ name: entry.name,
158
+ attrs: entry.attrs,
159
+ type: entry.type,
160
+ group_name: entry.group_name,
161
+ group_id: entry.group_id,
162
+ };
163
+ if (opts.dry_run)
164
+ body.dry_run = true;
165
+ return body;
166
+ }
@@ -48,9 +48,11 @@ export interface VerifiedAttrsRegistry {
48
48
  tier2?: Tier12Entry[];
49
49
  tier3?: unknown[];
50
50
  }
51
- /** Resolution of a single `(module, pattern-family)` cell against the registry. */
51
+ /** Resolution of a single `(module, pattern-family[, pattern-variant])` cell against the registry. */
52
52
  export interface EvidenceResolution {
53
53
  patternFamily: string;
54
+ /** The matched entry's `pattern_variant`, if any. */
55
+ patternVariant?: string;
54
56
  module: string;
55
57
  /** Numeric pattern-level evidence (0–5). */
56
58
  patternLevel: number;
@@ -78,17 +80,29 @@ export declare function loadRegistry(explicitPath?: string): VerifiedAttrsRegist
78
80
  /** Reset the module-level cache. Used by tests. */
79
81
  export declare function resetRegistryCache(): void;
80
82
  /**
81
- * Find a Tier 1 / Tier 2 entry by `pattern_family`. Tier 2 is searched
82
- * first (the button families live there), then Tier 1.
83
+ * Find a Tier 1 / Tier 2 entry by `pattern_family` and (optionally)
84
+ * `pattern_variant`. Tier 2 is searched first, then Tier 1.
85
+ *
86
+ * Some pattern families (e.g. `divi/font`) carry multiple entries that
87
+ * differ ONLY by `pattern_variant` (`google_fonts_pattern_a` vs
88
+ * `local_hosted_pattern_b`). When `patternVariant` is supplied, ONLY an
89
+ * entry whose `pattern_variant` matches exactly is returned — a Pattern-A
90
+ * entry must not vouch for a Pattern-B caller, and vice versa.
91
+ *
92
+ * When `patternVariant` is omitted, the legacy behavior holds: the first
93
+ * matching `pattern_family` is returned regardless of variant — only safe
94
+ * for families with a single entry. New variant-aware callers should pass
95
+ * the variant explicitly.
83
96
  */
84
- export declare function findPatternEntry(registry: VerifiedAttrsRegistry, patternFamily: string): Tier12Entry | undefined;
97
+ export declare function findPatternEntry(registry: VerifiedAttrsRegistry, patternFamily: string, patternVariant?: string): Tier12Entry | undefined;
85
98
  /**
86
- * Resolve effective evidence for a `(module, pattern-family)` cell.
99
+ * Resolve effective evidence for a `(module, pattern-family[, pattern-variant])` cell.
87
100
  *
88
- * Throws if the pattern family is entirely absent from the registry
89
- * that is an unrecoverable gap (the CLI must fail, not guess).
101
+ * Throws if the pattern family (or, when supplied, the specific variant)
102
+ * is entirely absent from the registry that is an unrecoverable gap
103
+ * (the CLI must fail, not guess).
90
104
  */
91
- export declare function resolveEvidence(registry: VerifiedAttrsRegistry, module: string, patternFamily: string): EvidenceResolution;
105
+ export declare function resolveEvidence(registry: VerifiedAttrsRegistry, module: string, patternFamily: string, patternVariant?: string): EvidenceResolution;
92
106
  /** Numeric ordering of the write-emitter threshold against a registry. */
93
107
  export declare function writeThresholdNumber(registry: VerifiedAttrsRegistry): number;
94
108
  /**
@@ -104,4 +118,4 @@ export declare class EvidenceGateError extends Error {
104
118
  readonly thresholdName: string;
105
119
  constructor(patternFamily: string, module: string, resolution: EvidenceResolution, thresholdNumber: number, thresholdName: string);
106
120
  }
107
- export declare function gateWriteAttr(registry: VerifiedAttrsRegistry, module: string, patternFamily: string): EvidenceResolution;
121
+ export declare function gateWriteAttr(registry: VerifiedAttrsRegistry, module: string, patternFamily: string, patternVariant?: string): EvidenceResolution;