@deneb-ui/cli 2.0.23 → 2.0.25

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/bin/index.js CHANGED
@@ -692,6 +692,7 @@ function initProject(targetInput, options = {}) {
692
692
  'validate-and-zip': 'deneb validate-and-zip .',
693
693
  'package:template': 'deneb package .',
694
694
  'update:deneb': 'deneb update',
695
+ 'fonts:install': 'deneb fonts install .',
695
696
  };
696
697
  let addedCount = 0;
697
698
  for (const [key, val] of Object.entries(scriptsToAdd)) {
@@ -763,6 +764,16 @@ function initProject(targetInput, options = {}) {
763
764
  }
764
765
  }
765
766
 
767
+ if (!skipInstall) {
768
+ try {
769
+ const { runFontsInstall } = require('../src/tools/deneb-fonts.cjs');
770
+ console.log(`\n\x1b[36mAa Downloading and configuring DENEB Google Fonts...\x1b[0m`);
771
+ runFontsInstall(targetDir, []);
772
+ } catch (err) {
773
+ console.log(`\n\x1b[33m! Fonts were not installed automatically: ${err.message}. Run \x1b[1mdeneb fonts install .\x1b[0m\x1b[0m`);
774
+ }
775
+ }
776
+
766
777
  console.log(`\n\x1b[32m✔ Project initialization complete!\x1b[0m`);
767
778
  console.log(`\nYou can now run:`);
768
779
  console.log(` \x1b[36mnpm run lab\x1b[0m \x1b[90m# Launch Local Visual Editing Lab\x1b[0m`);
@@ -1074,6 +1085,9 @@ if (command === 'init') {
1074
1085
  runValidateAndZip(commandArgs[0] || '.', commandArgs.slice(1));
1075
1086
  } else if (command === 'update' || command === 'upgrade') {
1076
1087
  updateDependencies(commandArgs);
1088
+ } else if (command === 'fonts') {
1089
+ const { runFontsCommand } = require('../src/tools/deneb-fonts.cjs');
1090
+ process.exit(runFontsCommand(commandArgs));
1077
1091
  } else if (command === 'save-recipe' || command === 'learn') {
1078
1092
  const targetDir = path.resolve(commandArgs[0] || '.');
1079
1093
  const recipeName = commandArgs[1] || path.basename(targetDir);
@@ -1098,6 +1112,7 @@ Core Commands:
1098
1112
  doctor Run comprehensive environment, manifest, visual editing AST & asset diagnostic checks (flags: --fix, --json)
1099
1113
  save-recipe Learn and save calibrated fixes & schemas into reusable recipe bank (e.g. deneb save-recipe . shoes-store)
1100
1114
  learn Alias for save-recipe
1115
+ fonts Google Fonts catalog — list presets or install self-hosted @fontsource packages
1101
1116
  update Update DENEB packages (@deneb-ui/ui, @deneb-ui/cli) and UI components
1102
1117
  validate Validate website configuration and visual editing contracts with Fivora platform
1103
1118
  zip Zip the project without unnecessary folders or files (node_modules, .next, .git, .env)
@@ -1121,6 +1136,9 @@ Examples:
1121
1136
  deneb init --recipe electronics
1122
1137
  deneb init --recipe cosmetics
1123
1138
  deneb init --legacy
1139
+ deneb fonts list
1140
+ deneb fonts install .
1141
+ deneb fonts install . --font inter --font playfair-display
1124
1142
  deneb update
1125
1143
  deneb validate .
1126
1144
  deneb validate --zip
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deneb-ui/cli",
3
- "version": "2.0.23",
3
+ "version": "2.0.25",
4
4
  "description": "Official DENEB CLI — scaffold, convert, validate, and package Fivora-ready storefront templates.",
5
5
  "bin": {
6
6
  "deneb": "bin/index.js",
@@ -48,7 +48,12 @@
48
48
  "license": "MIT",
49
49
  "dependencies": {
50
50
  "@babel/parser": "^7.28.0",
51
+ "@deneb-ui/core": "^2.0.25",
51
52
  "adm-zip": "^0.6.0",
52
53
  "recast": "^0.23.11"
54
+ },
55
+ "devDependencies": {
56
+ "esbuild": "^0.28.2",
57
+ "typescript": "^5.9.3"
53
58
  }
54
59
  }
@@ -13,6 +13,13 @@ const { applyFilePlan } = require('../transformer.cjs');
13
13
  const { buildFieldPath, inferFieldName } = require('../field-paths.cjs');
14
14
  const { runDenebArc } = require('../index.cjs');
15
15
  const { parseSource } = require('../ast.cjs');
16
+ const { loadFingerprintBoost } = require('../learning.cjs');
17
+
18
+ test('unseen fingerprints do not change planner confidence', () => {
19
+ const hint = loadFingerprintBoost(null);
20
+ assert.equal(hint.boost, 0);
21
+ assert.equal(hint.skip, false);
22
+ });
16
23
  const contract = require('../fivora-contract.cjs');
17
24
  const { ensureStaticExportConfig } = require('../next-config.cjs');
18
25
 
@@ -101,12 +108,38 @@ test('AST transformer preserves className and uses nullish fallbacks', () => {
101
108
  assert.match(result.code, /className="hero"/);
102
109
  assert.match(result.code, /data-preview-field-path=/);
103
110
  assert.match(result.code, /\?\?/);
104
- assert.match(result.code, /<span data-preview-field-path="/);
111
+ assert.match(result.code, /<span[\s\S]*data-preview-field-path="/);
105
112
  assert.doesNotMatch(result.code, /'use client'/);
106
113
  assert.match(result.code, /site-data\.json|@\/data\/site-data\.json/);
114
+ assert.match(result.code, /data-preview-style-target=/);
115
+ assert.match(result.code, /data-preview-style-type="text"/);
107
116
  parseSource(result.code, relativeFile);
108
117
  });
109
118
 
119
+ test('planner emits style-bind next to content transforms', () => {
120
+ const relativeFile = 'src/components/Hero.tsx';
121
+ const code = fs.readFileSync(path.join(FIXTURE, relativeFile), 'utf8');
122
+ const analysis = analyzeFile({
123
+ code,
124
+ relativeFile,
125
+ profile: scanProject(FIXTURE),
126
+ graph: { sharedFiles: [] },
127
+ ownerScope: 'home',
128
+ componentMeta: { name: 'Hero', role: 'hero' },
129
+ });
130
+ analysis.relativeFile = relativeFile;
131
+ analysis.code = code;
132
+ const plan = planTransformations({
133
+ profile: scanProject(FIXTURE),
134
+ analyses: [analysis],
135
+ recipe: { actionRules: { splitActionAndLabel: true } },
136
+ });
137
+ const binds = plan.files[0].transformations.filter((t) => t.operation === 'style-bind');
138
+ assert.ok(binds.length >= 1, 'expected style-bind operations');
139
+ assert.ok(binds.every((t) => t.stylePath && t.styleKind));
140
+ assert.ok(plan.stats.styleBinds >= 1);
141
+ });
142
+
110
143
  test('dry-run does not modify source files', () => {
111
144
  const dir = copyFixture();
112
145
  const before = fs.readFileSync(path.join(dir, 'src', 'components', 'Hero.tsx'), 'utf8');
@@ -142,6 +175,11 @@ test('ARC converts a Next.js fixture into Fivora contracts without redesigning',
142
175
  assert.doesNotMatch(promo, /use client';;/);
143
176
  assert.doesNotMatch(JSON.stringify(siteData.content), /VANTA/);
144
177
  assert.ok(siteData.content.home.hero.title);
178
+ assert.ok(siteData.theme?.headingFont);
179
+ assert.ok(siteData.theme?.bodyFont);
180
+ assert.equal(typeof siteData.styles, 'object');
181
+ assert.ok(Object.keys(siteData.styles).length >= 1, 'style-bind seeds site-data.styles');
182
+ assert.match(hero, /data-preview-style-type=/);
145
183
  assert.ok(manifest.editorSchema.sections.length >= 1);
146
184
  assert.ok(manifest.editorSchema.sections.length < 12);
147
185
  assert.equal(manifest.arcVersion, result.arcVersion);
@@ -150,8 +188,8 @@ test('ARC converts a Next.js fixture into Fivora contracts without redesigning',
150
188
  const second = silence(() => runDenebArc(dir, 'arc-fixture', { telemetry: 'off' }));
151
189
  assert.equal(second.outcome, 'success');
152
190
  const hero2 = fs.readFileSync(path.join(dir, 'src', 'components', 'Hero.tsx'), 'utf8');
153
- const spanCount1 = (hero.match(/<span data-preview-field-path=/g) || []).length;
154
- const spanCount2 = (hero2.match(/<span data-preview-field-path=/g) || []).length;
191
+ const spanCount1 = (hero.match(/<span[\s\S]*?data-preview-field-path=/g) || []).length;
192
+ const spanCount2 = (hero2.match(/<span[\s\S]*?data-preview-field-path=/g) || []).length;
155
193
  assert.equal(spanCount2, spanCount1);
156
194
  });
157
195
 
@@ -301,6 +339,8 @@ test('static-array collections become list contracts without changing render log
301
339
  const grid = fs.readFileSync(path.join(dir, 'src', 'components', 'ProductGrid.tsx'), 'utf8');
302
340
 
303
341
  assert.match(grid, /data-preview-list-path="home\.products"/);
342
+ assert.match(grid, /data-preview-style-type="grid"/);
343
+ assert.match(grid, /data-preview-style-type="card"/);
304
344
  assert.match(grid, /data-preview-item-path=\{`home\.products\[\$\{index\}\]`\}/);
305
345
  assert.match(grid, /data-preview-field-path=\{`home\.products\[\$\{index\}\]\.title`\}/);
306
346
  // The array is site-data backed with the developer's literal as fallback.
@@ -332,7 +372,7 @@ test('literal text inside a broad container is wrapped instead of marked illegal
332
372
  const about = fs.readFileSync(path.join(dir, 'src', 'app', 'about', 'page.tsx'), 'utf8');
333
373
 
334
374
  // Fivora rejects data-preview-field-path on <div>, so the text gets a span.
335
- assert.match(about, /<div className="mt-10 text-sm text-slate-500">\s*<span data-preview-field-path=/);
375
+ assert.match(about, /<div[\s\S]*className="mt-10 text-sm text-slate-500"[\s\S]*<span[\s\S]*data-preview-field-path=/);
336
376
  assert.ok(!/<div[^>]*data-preview-field-path/.test(about));
337
377
  });
338
378
 
@@ -343,7 +383,7 @@ test('shadcn Button asChild keeps the action on the link and the label in a span
343
383
 
344
384
  assert.match(hero, /<Button asChild>/);
345
385
  assert.match(hero, /href=\{siteData\?\.content\?\.home\?\.hero\?\.shopCollectionUrl \?\? "\/products"\}/);
346
- assert.match(hero, /<span data-preview-field-path="home\.hero\.shopCollectionLabel">/);
386
+ assert.match(hero, /<span[\s\S]*data-preview-field-path="home\.hero\.shopCollectionLabel"/);
347
387
  // The decorative icon stays static.
348
388
  assert.match(hero, /<ArrowRight className="ml-2 size-4" aria-hidden="true" \/>/);
349
389
  });
@@ -385,7 +425,7 @@ test('Pages Router projects convert and satisfy the strict contract', () => {
385
425
  assert.match(index, /from "\.\.\/data\/site-data\.json"/);
386
426
  // tel: actions are split into url + label.
387
427
  assert.match(index, /href=\{siteData\?\.content\?\.home\?\.contact\?\.phoneUrl \?\? "tel:\+94771234567"\}/);
388
- assert.match(index, /<span data-preview-field-path="home\.contact\.phoneLabel">/);
428
+ assert.match(index, /<span[\s\S]*data-preview-field-path="home\.contact\.phoneLabel"/);
389
429
  // Original class names are untouched.
390
430
  assert.match(index, /className="wrapper"/);
391
431
  assert.match(contact, /className="photo"/);
package/src/arc/ast.cjs CHANGED
@@ -229,7 +229,7 @@ function wrapTextInEditableSpan(fieldPath, fallback, fieldType) {
229
229
  return b.jsxElement(
230
230
  b.jsxOpeningElement(
231
231
  b.jsxIdentifier('span'),
232
- [jsxPreviewAttr(fieldPath)],
232
+ [jsxPreviewAttr(fieldPath), ...jsxStyleAttrs(fieldPath, 'text')],
233
233
  false
234
234
  ),
235
235
  b.jsxClosingElement(b.jsxIdentifier('span')),
@@ -238,6 +238,20 @@ function wrapTextInEditableSpan(fieldPath, fallback, fieldType) {
238
238
  );
239
239
  }
240
240
 
241
+ function jsxStyleAttrs(stylePath, kind) {
242
+ if (!stylePath || !kind) return [];
243
+ return [
244
+ b.jsxAttribute(b.jsxIdentifier('data-preview-style-target'), b.stringLiteral(stylePath)),
245
+ b.jsxAttribute(b.jsxIdentifier('data-preview-style-type'), b.stringLiteral(kind)),
246
+ ];
247
+ }
248
+
249
+ function ensureStyleAttrs(node, stylePath, kind) {
250
+ if (!node || !node.openingElement || !stylePath || !kind) return;
251
+ if (hasJsxAttribute(node, 'data-preview-style-target')) return;
252
+ node.openingElement.attributes.push(...jsxStyleAttrs(stylePath, kind));
253
+ }
254
+
241
255
  function hasDirective(ast, value) {
242
256
  const program = ast.program || ast;
243
257
  const first = program.body && program.body[0];
@@ -310,6 +324,8 @@ module.exports = {
310
324
  siteDataBinding,
311
325
  siteDataListBinding,
312
326
  jsxPreviewAttr,
327
+ jsxStyleAttrs,
328
+ ensureStyleAttrs,
313
329
  jsxStaticAttr,
314
330
  jsxTemplatePathAttr,
315
331
  wrapTextInEditableSpan,
@@ -0,0 +1,65 @@
1
+ 'use strict';
2
+
3
+ function tryCoreFonts() {
4
+ try {
5
+ return require('@deneb-ui/core');
6
+ } catch {
7
+ try {
8
+ return require('../../../../packages/deneb-core/dist/index.js');
9
+ } catch {
10
+ return null;
11
+ }
12
+ }
13
+ }
14
+
15
+ const core = tryCoreFonts();
16
+
17
+ function collectFontIdsFromSiteData(siteData) {
18
+ if (core && typeof core.collectFontIdsFromSiteData === 'function') {
19
+ const ids = core.collectFontIdsFromSiteData(siteData);
20
+ if (ids && ids.length) return ids;
21
+ }
22
+ const defaults = (core && core.DEFAULT_PROJECT_FONT_IDS) || ['inter', 'plus-jakarta-sans', 'playfair-display'];
23
+ return [...defaults];
24
+ }
25
+
26
+ function applyFontTheme(siteData, fontIds) {
27
+ const next = siteData && typeof siteData === 'object' ? siteData : {};
28
+ const theme = { ...(next.theme || {}) };
29
+ const structureTheme = next.template?.structure?.theme;
30
+ if (isPlain(structureTheme)) {
31
+ Object.assign(theme, structureTheme);
32
+ }
33
+ if (!theme.headingFont) {
34
+ theme.headingFont = prettyFont(fontIds[1] || fontIds[0] || 'inter');
35
+ }
36
+ if (!theme.bodyFont) {
37
+ theme.bodyFont = prettyFont(fontIds[0] || 'inter');
38
+ }
39
+ next.theme = theme;
40
+ if (next.template && next.template.structure) {
41
+ next.template.structure.theme = {
42
+ ...(next.template.structure.theme || {}),
43
+ headingFont: theme.headingFont,
44
+ bodyFont: theme.bodyFont,
45
+ };
46
+ }
47
+ return next;
48
+ }
49
+
50
+ function prettyFont(id) {
51
+ if (core && typeof core.lookupFontDefinition === 'function') {
52
+ const def = core.lookupFontDefinition(id);
53
+ if (def?.label) return def.label;
54
+ }
55
+ return String(id || 'Inter');
56
+ }
57
+
58
+ function isPlain(value) {
59
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
60
+ }
61
+
62
+ module.exports = {
63
+ collectFontIdsFromSiteData,
64
+ applyFontTheme,
65
+ };
package/src/arc/index.cjs CHANGED
@@ -477,11 +477,7 @@ function runDenebArc(projectDir, projectName, options = {}) {
477
477
  } else {
478
478
  console.log(' \x1b[32m✓\x1b[0m Fivora strict contract');
479
479
  }
480
- if (fivoraAudit.uncoveredVisibleText.length) {
481
- console.log(
482
- ` \x1b[33m⚠\x1b[0m ${fivoraAudit.uncoveredVisibleText.length} visible text node(s) still uncovered — run \x1b[1mdeneb validate .\x1b[0m before packaging`
483
- );
484
- }
480
+ printer.printUncoveredText(fivoraAudit.uncoveredVisibleText);
485
481
  coverage.actionLinkContracts = contracts.fieldPaths.filter((p) => /Url$/.test(p)).length;
486
482
  coverage.contractCollisions = contracts.actionCollisions;
487
483
  console.log(` Action/link contracts validated: ${coverage.actionLinkContracts}`);
@@ -560,7 +556,10 @@ function runDenebArc(projectDir, projectName, options = {}) {
560
556
  writeJson(path.join(journalDir, 'validation.json'), validation);
561
557
  writeJson(path.join(projectDir, '.deneb', 'report.json'), report);
562
558
 
563
- if (outcome === 'success') printer.printSuccess();
559
+ if (outcome === 'success') {
560
+ printer.printSuccess();
561
+ printer.printDeveloperNextSteps();
562
+ }
564
563
  return result;
565
564
  }
566
565
 
@@ -85,6 +85,29 @@ function recordExperience({ projectDir, profile, plan, validation, outcome, tele
85
85
  return records;
86
86
  }
87
87
 
88
+ function loadFingerprintBoost(fingerprint) {
89
+ if (!fingerprint) {
90
+ return { boost: 0, skip: false, state: null };
91
+ }
92
+ try {
93
+ const store = readJsonSafe(fingerprintStorePath(), { fingerprints: {} }) || { fingerprints: {} };
94
+ const entry = store.fingerprints?.[fingerprint];
95
+ if (!entry) return { boost: 0, skip: false, state: null };
96
+ if (entry.state === 'deprecated') {
97
+ return { boost: 0, skip: true, state: 'deprecated' };
98
+ }
99
+ if (entry.state === 'verified') {
100
+ return { boost: 0.08, skip: false, state: 'verified' };
101
+ }
102
+ if (entry.state === 'candidate') {
103
+ return { boost: 0.03, skip: false, state: 'candidate' };
104
+ }
105
+ return { boost: 0, skip: false, state: entry.state || 'observed' };
106
+ } catch {
107
+ return { boost: 0, skip: false, state: null };
108
+ }
109
+ }
110
+
88
111
  function mapOperation(operation) {
89
112
  switch (operation) {
90
113
  case 'extract-text':
@@ -96,6 +119,8 @@ function mapOperation(operation) {
96
119
  return 'url-extraction';
97
120
  case 'split-action-contract':
98
121
  return 'contract-split';
122
+ case 'style-bind':
123
+ return 'style-bind';
99
124
  case 'collection-conversion':
100
125
  return 'collection-conversion';
101
126
  default:
@@ -176,6 +201,7 @@ function redactSecrets(value) {
176
201
 
177
202
  module.exports = {
178
203
  recordExperience,
204
+ loadFingerprintBoost,
179
205
  registryArchitecture,
180
206
  redactSecrets,
181
207
  promoteState,
@@ -3,6 +3,7 @@
3
3
  const path = require('path');
4
4
  const { readJsonSafe, writeJson, deepMerge, isPlainObject } = require('./fs-utils.cjs');
5
5
  const { ARC_VERSION, SCHEMA_VERSION } = require('./version.cjs');
6
+ const { collectFontIdsFromSiteData, applyFontTheme } = require('./font-plan.cjs');
6
7
  const { humanLabel, classifyFieldType } = require('./field-paths.cjs');
7
8
  const {
8
9
  enumerateContentPaths,
@@ -154,6 +155,18 @@ function collectFieldsFromPlan(plan) {
154
155
  return fields;
155
156
  }
156
157
 
158
+ function collectStylesFromPlan(plan, existingStyles) {
159
+ const styles = isPlainObject(existingStyles) ? { ...existingStyles } : {};
160
+ for (const file of plan.files || []) {
161
+ for (const t of file.transformations || []) {
162
+ if (t.operation !== 'style-bind' || !t.stylePath) continue;
163
+ if (t.stylePath.includes('[*]')) continue;
164
+ if (!styles[t.stylePath]) styles[t.stylePath] = {};
165
+ }
166
+ }
167
+ return styles;
168
+ }
169
+
157
170
  function baseContent(projectName, routes) {
158
171
  const navLabels = {};
159
172
  for (const page of routes) navLabels[page.id] = page.label || page.id;
@@ -339,8 +352,12 @@ function buildSiteDataAndManifest({
339
352
  requiredFeatures: [],
340
353
  },
341
354
  content,
355
+ styles: collectStylesFromPlan(plan, existingSiteData?.styles),
356
+ ...(isPlainObject(existingSiteData?.theme) ? { theme: { ...existingSiteData.theme } } : {}),
342
357
  };
343
358
 
359
+ applyFontTheme(siteData, collectFontIdsFromSiteData(siteData));
360
+
344
361
  if (!siteData.template.structure) siteData.template.structure = {};
345
362
  siteData.template.structure.pages = routes.map((p) => p.id);
346
363
 
@@ -3,6 +3,8 @@
3
3
  const { CONFIDENCE } = require('./version.cjs');
4
4
  const { inferSection, inferFieldName, buildFieldPath, classifyFieldType, uniquePath } = require('./field-paths.cjs');
5
5
  const { classifyHref } = require('./adapters.cjs');
6
+ const { loadFingerprintBoost } = require('./learning.cjs');
7
+ const { appendStyleBindTransforms } = require('./style-candidates.cjs');
6
8
 
7
9
  function recipeBoost(candidate, recipe) {
8
10
  if (!recipe) return 0;
@@ -30,7 +32,21 @@ function planTransformations({ profile, analyses, recipe }) {
30
32
  for (const analysis of analyses) {
31
33
  const transformations = [];
32
34
  for (const candidate of analysis.candidates || []) {
33
- const confidence = Math.min(0.99, (candidate.confidence || 0) + recipeBoost(candidate, recipe));
35
+ const fingerprintHint = loadFingerprintBoost(candidate.fingerprint);
36
+ if (fingerprintHint.skip) {
37
+ skipped.push({
38
+ file: candidate.file,
39
+ loc: candidate.loc,
40
+ reason: 'fingerprint-deprecated',
41
+ confidence: candidate.confidence || 0,
42
+ kind: candidate.kind,
43
+ });
44
+ continue;
45
+ }
46
+ const confidence = Math.min(
47
+ 0.99,
48
+ (candidate.confidence || 0) + recipeBoost(candidate, recipe) + (fingerprintHint.boost || 0),
49
+ );
34
50
  const decision = decideThreshold(confidence, candidate);
35
51
  const section = inferSection({
36
52
  componentName: candidate.componentName,
@@ -75,6 +91,8 @@ function planTransformations({ profile, analyses, recipe }) {
75
91
  why: candidate.reason,
76
92
  recipe: recipe?.name || null,
77
93
  confidence,
94
+ fingerprintBoost: fingerprintHint.boost || 0,
95
+ fingerprintState: fingerprintHint.state || null,
78
96
  },
79
97
  };
80
98
 
@@ -188,6 +206,8 @@ function planTransformations({ profile, analyses, recipe }) {
188
206
  });
189
207
  }
190
208
 
209
+ appendStyleBindTransforms(filePlans);
210
+
191
211
  return {
192
212
  files: filePlans,
193
213
  skipped,
@@ -212,12 +232,17 @@ function summarizePlan(filePlans, skipped) {
212
232
  const auto = filePlans.reduce((n, f) => n + f.transformations.filter((t) => t.decision === 'auto').length, 0);
213
233
  const validate = filePlans.reduce((n, f) => n + f.transformations.filter((t) => t.decision === 'validate').length, 0);
214
234
  const filesAffected = filePlans.filter((f) => f.transformations.length > 0).length;
235
+ const styleBinds = filePlans.reduce(
236
+ (n, f) => n + f.transformations.filter((t) => t.operation === 'style-bind').length,
237
+ 0,
238
+ );
215
239
  return {
216
240
  planned,
217
241
  auto,
218
242
  validate,
219
243
  skipped: skipped.length,
220
244
  filesAffected,
245
+ styleBinds,
221
246
  };
222
247
  }
223
248
 
@@ -67,6 +67,7 @@ function printPlan(plan) {
67
67
  if (plan.stats.validate) info(`${plan.stats.validate} transformations queued with extra validation`);
68
68
  const skippedDynamic = plan.skipped.filter((s) => /dynamic|api/.test(s.reason || '')).length;
69
69
  const skippedLow = plan.skipped.filter((s) => (s.confidence || 1) < 0.6).length;
70
+ if (plan.stats.styleBinds) ok(`${plan.stats.styleBinds} Fivora style-bind contracts`);
70
71
  if (skippedDynamic) ok(`${skippedDynamic} existing dynamic values preserved`);
71
72
  if (skippedLow) warn(`${skippedLow} low-confidence candidates skipped`);
72
73
  }
@@ -96,8 +97,12 @@ function printExplain(plan) {
96
97
  if (!file.transformations.length) continue;
97
98
  console.log(` ${C.bold}${file.file}${C.reset}`);
98
99
  for (const t of file.transformations) {
99
- console.log(` - ${t.operation} ${t.field || t.urlField || ''}`);
100
- console.log(` ${C.dim}why: ${t.reason} confidence: ${t.confidence} recipe: ${t.recipeId || 'none'}${C.reset}`);
100
+ const target = t.stylePath || t.field || t.urlField || t.listField || '';
101
+ console.log(` - ${t.operation} ${target}`);
102
+ const boost = t.explain?.fingerprintBoost
103
+ ? ` fingerprint: ${t.explain.fingerprintState}+${t.explain.fingerprintBoost}`
104
+ : '';
105
+ console.log(` ${C.dim}why: ${t.reason} confidence: ${t.confidence} recipe: ${t.recipeId || 'none'}${boost}${C.reset}`);
101
106
  }
102
107
  }
103
108
  }
@@ -130,6 +135,29 @@ function printSuccess() {
130
135
  console.log(`\n${C.green}${C.bold}Deneb ARC completed successfully.${C.reset}\n`);
131
136
  }
132
137
 
138
+ function printUncoveredText(findings = []) {
139
+ if (!findings.length) return;
140
+ warn(`${findings.length} visible text node(s) still uncovered — run deneb validate . before packaging`);
141
+ for (const item of findings.slice(0, 12)) {
142
+ const loc = item.filePath ? `${item.filePath}${item.line ? `:${item.line}` : ''}` : '';
143
+ const snippet = String(item.text || '').slice(0, 80);
144
+ console.log(` ${C.dim}${loc} <${item.tag}> ${snippet}${C.reset}`);
145
+ }
146
+ if (findings.length > 12) {
147
+ console.log(` ${C.dim}... ${findings.length - 12} more in .deneb/report.json${C.reset}`);
148
+ }
149
+ }
150
+
151
+ function printDeveloperNextSteps() {
152
+ heading('Next steps for Fivora');
153
+ console.log(` ${C.cyan}1.${C.reset} npm run lab ${C.dim}preview visual editing locally${C.reset}`);
154
+ console.log(` ${C.cyan}2.${C.reset} deneb fonts install . ${C.dim}self-host Google Fonts used by this site${C.reset}`);
155
+ console.log(` ${C.cyan}3.${C.reset} npm run validate ${C.dim}same contract Fivora ingest uses${C.reset}`);
156
+ console.log(` ${C.cyan}4.${C.reset} npm run validate-and-zip ${C.dim}upload-ready ZIP${C.reset}`);
157
+ console.log(` ${C.dim}Style edits: Fivora sends FIVORA_PREVIEW_STYLE_PATCH to data-preview-style-target nodes.${C.reset}`);
158
+ console.log(` ${C.dim}Re-run with --explain to see why each field/style was bound.${C.reset}\n`);
159
+ }
160
+
133
161
  function printRollback(reason) {
134
162
  console.log(`\n${C.yellow}Deneb ARC rolled back the conversion.${C.reset}`);
135
163
  console.log(`${C.dim}${reason}${C.reset}\n`);
@@ -146,6 +174,8 @@ module.exports = {
146
174
  printDryRun,
147
175
  printError,
148
176
  printSuccess,
177
+ printUncoveredText,
178
+ printDeveloperNextSteps,
149
179
  printRollback,
150
180
  ok,
151
181
  warn,
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ const TEXT_OPS = new Set(['extract-text', 'wrap-text-span']);
4
+ const BUTTON_TAGS = new Set(['button', 'Button', 'CTAButton']);
5
+
6
+ function inferStyleKind(transform) {
7
+ if (transform.operation === 'collection-conversion') return 'grid';
8
+ if (transform.operation === 'split-action-contract') return 'text';
9
+ if (BUTTON_TAGS.has(transform.tag) && transform.operation === 'extract-text') return 'button';
10
+ if (TEXT_OPS.has(transform.operation)) return 'text';
11
+ return null;
12
+ }
13
+
14
+ function stylePathFor(transform, kind) {
15
+ if (kind === 'grid' && transform.listField) return `${transform.listField}.grid`;
16
+ if (kind === 'card' && transform.listField) return `${transform.listField}[*].card`;
17
+ if (transform.operation === 'split-action-contract') return transform.labelField;
18
+ return transform.field || transform.labelField || null;
19
+ }
20
+
21
+ /**
22
+ * Adds explainable style-bind operations next to content transforms so
23
+ * developers see why Fivora can restyle a node without CSS rewrites.
24
+ */
25
+ function appendStyleBindTransforms(filePlans) {
26
+ for (const file of filePlans || []) {
27
+ const extra = [];
28
+ for (const transform of file.transformations || []) {
29
+ if (transform.operation === 'style-bind') continue;
30
+ if (transform.operation === 'wrap-text-span') continue;
31
+ if (transform.operation === 'collection-conversion' && transform.listField) {
32
+ extra.push(makeStyleBind(transform, `${transform.listField}.grid`, 'grid'));
33
+ extra.push(makeStyleBind(transform, `${transform.listField}[*].card`, 'card'));
34
+ continue;
35
+ }
36
+ const kind = inferStyleKind(transform);
37
+ const stylePath = stylePathFor(transform, kind);
38
+ if (!kind || !stylePath) continue;
39
+ extra.push(makeStyleBind(transform, stylePath, kind));
40
+ }
41
+ file.transformations = [...(file.transformations || []), ...extra];
42
+ }
43
+ }
44
+
45
+ function makeStyleBind(transform, stylePath, kind) {
46
+ return {
47
+ loc: transform.loc,
48
+ operation: 'style-bind',
49
+ tag: transform.tag,
50
+ file: transform.file,
51
+ confidence: Math.min(0.99, (transform.confidence || 0.9) - 0.02),
52
+ decision: transform.decision === 'skip' ? 'skip' : 'auto',
53
+ reason: `Bind Fivora style contract (${kind}) without rewriting CSS`,
54
+ field: transform.field,
55
+ stylePath,
56
+ styleKind: kind,
57
+ fingerprint: transform.fingerprint,
58
+ recipeId: transform.recipeId || null,
59
+ explain: {
60
+ detected: `${transform.tag} style-${kind}`,
61
+ why: 'Fivora visual editor patches --deneb-* CSS variables via FIVORA_PREVIEW_STYLE_PATCH',
62
+ recipe: transform.recipeId || null,
63
+ confidence: transform.confidence,
64
+ },
65
+ };
66
+ }
67
+
68
+ module.exports = {
69
+ appendStyleBindTransforms,
70
+ inferStyleKind,
71
+ stylePathFor,
72
+ };