@deneb-ui/cli 2.0.112 → 2.0.114

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.
@@ -17,6 +17,8 @@ const KNOWN_EDITABLE_COMPONENTS = new Set([
17
17
  'EditableQuote',
18
18
  'EditableButton',
19
19
  'EditableImage',
20
+ 'EditableIcon',
21
+ 'DynamicIcon',
20
22
  'EditableMap',
21
23
  'EditableList',
22
24
  'EditableBox',
@@ -26,6 +28,10 @@ const KNOWN_EDITABLE_COMPONENTS = new Set([
26
28
 
27
29
  // ─── Product & Commerce ────────────────────────────────────────
28
30
  'EditableProductCard',
31
+ 'EditableUniversalCard',
32
+ 'EditableVehicleCard',
33
+ 'EditableListingCard',
34
+ 'EditableEntityCard',
29
35
  'EditableProductGrid',
30
36
  'EditableProductDetail',
31
37
  'EditableCartDrawer',
@@ -78,12 +84,18 @@ const ALIAS_MAP = {
78
84
  'Badge': 'EditableBadge',
79
85
  'Quote': 'EditableQuote',
80
86
  'Image': 'EditableImage',
87
+ 'Icon': 'EditableIcon',
88
+ 'DynamicIcon': 'EditableIcon',
81
89
  'Map': 'EditableMap',
82
90
  'Grid': 'EditableGrid',
83
91
  'Section': 'EditableSection',
84
92
  'Box': 'EditableBox',
85
93
  'List': 'EditableList',
86
94
  'ProductCard': 'EditableProductCard',
95
+ 'UniversalCard': 'EditableUniversalCard',
96
+ 'ListingCard': 'EditableUniversalCard',
97
+ 'EntityCard': 'EditableUniversalCard',
98
+ 'VehicleCard': 'EditableVehicleCard',
87
99
  'ProductGrid': 'EditableProductGrid',
88
100
  'ProductDetail': 'EditableProductDetail',
89
101
  'CustomerReviews': 'EditableCustomerReviews',
@@ -91,7 +91,10 @@ function inferFieldName(kind, tag, text, extra = {}) {
91
91
  if (kind === 'image') return 'logoUrl';
92
92
  return 'logoText';
93
93
  }
94
- if (kind === 'image') return extra.alt ? toCamel([extra.alt, 'image']) || 'image' : 'image';
94
+ if (kind === 'image') {
95
+ if (extra.fieldHint) return extra.fieldHint;
96
+ return extra.alt ? toCamel([extra.alt, 'image']) || 'image' : 'image';
97
+ }
95
98
  if (kind === 'alt') return extra.imageField ? extra.imageField.replace(/Image$/, 'ImageAlt').replace(/image$/, 'imageAlt') : 'imageAlt';
96
99
  if (kind === 'placeholder') return toCamel([text || '', 'placeholder']) || 'placeholder';
97
100
 
@@ -36,7 +36,7 @@ const TYPE_COERCION_MAP = {
36
36
  float: 'number',
37
37
  bool: 'boolean',
38
38
  img: 'image',
39
- icon: 'image',
39
+ icon: 'text',
40
40
  };
41
41
 
42
42
  /**
@@ -49,6 +49,11 @@ function normalizeFieldType(rawType, fieldKey = '') {
49
49
  if (!rawType || typeof rawType !== 'string') return 'text';
50
50
  const lower = rawType.toLowerCase().trim();
51
51
 
52
+ // Explicit icon or emoji fields always coerce to text
53
+ if (lower === 'icon' || lower === 'emoji') {
54
+ return 'text';
55
+ }
56
+
52
57
  if (PLATFORM_ALLOWED_TYPES.has(lower)) {
53
58
  return lower;
54
59
  }
@@ -59,6 +64,9 @@ function normalizeFieldType(rawType, fieldKey = '') {
59
64
 
60
65
  // Heuristic from fieldKey
61
66
  const keyLower = fieldKey.toLowerCase();
67
+ if (keyLower.endsWith('icon') || keyLower.startsWith('icon') || keyLower.includes('emoji')) {
68
+ return 'text';
69
+ }
62
70
  if (keyLower.endsWith('url') || keyLower.endsWith('link') || keyLower.endsWith('href')) {
63
71
  return 'url';
64
72
  }
package/src/arc/index.cjs CHANGED
@@ -69,6 +69,7 @@ const {
69
69
  testMutationResilience,
70
70
  runFuzzHarness,
71
71
  } = require('./fuzz-engine.cjs');
72
+ const { runAutoEditabilityFix } = require('./auto-editability-fixer.cjs');
72
73
  const {
73
74
  analyzeBlockedProject,
74
75
  formatBlockedExplanationTerminal,
@@ -1306,6 +1307,27 @@ function runTransactionalPipeline(targetDirInput = '.', options = {}) {
1306
1307
  }
1307
1308
  }
1308
1309
 
1310
+ /**
1311
+ * Standalone entry point for `deneb fix-editability`.
1312
+ * Scans the project for missing field paths and auto-patches everything.
1313
+ *
1314
+ * @param {string} projectDir
1315
+ * @param {{ dryRun?: boolean, verbose?: boolean }} options
1316
+ */
1317
+ function runFixEditability(projectDir, options) {
1318
+ const verbose = !!(options && options.verbose);
1319
+ const dryRun = !!(options && options.dryRun);
1320
+ if (verbose) console.log('[deneb] Running auto-editability fix on ' + projectDir);
1321
+ const report = runAutoEditabilityFix(projectDir, { dryRun, verbose: true });
1322
+ if (report.fixed.length === 0) {
1323
+ console.log('[deneb] ✅ All fields already present — nothing to fix.');
1324
+ } else {
1325
+ console.log('[deneb] ✅ Fixed ' + report.fixed.length + ' file(s):');
1326
+ for (const f of report.fixed) console.log(' • ' + f);
1327
+ }
1328
+ return report;
1329
+ }
1330
+
1309
1331
  module.exports = {
1310
1332
  runDenebArc,
1311
1333
  runTransactionalPipeline,
@@ -1332,4 +1354,6 @@ module.exports = {
1332
1354
  printBlockedExplanation,
1333
1355
  ENGINE_ID,
1334
1356
  ARC_VERSION,
1357
+ runAutoEditabilityFix,
1358
+ runFixEditability,
1335
1359
  };
@@ -163,6 +163,7 @@ function planTransformations({ profile, analyses, recipe, ir }) {
163
163
  used: usedPaths,
164
164
  });
165
165
  transform.fieldType = 'image';
166
+ transform.dynamicSrc = Boolean(extra.dynamicSrc);
166
167
  } else if (candidate.operation === 'extract-alt') {
167
168
  transform.field = buildFieldPath({
168
169
  scope,
@@ -233,6 +234,18 @@ function planTransformations({ profile, analyses, recipe, ir }) {
233
234
  transform.targetComponent = extra.componentName;
234
235
  transform.targetFile = extra.componentFile;
235
236
  transform.previewPath = prefix;
237
+ } else if (candidate.operation === 'feature-icon') {
238
+ const iconBase = extra.iconName || 'icon';
239
+ const fieldName = inferFieldName(iconBase + 'Icon', candidate.tag, candidate.value, extra);
240
+ transform.field = buildFieldPath({
241
+ scope,
242
+ section,
243
+ field: fieldName,
244
+ used: usedPaths,
245
+ });
246
+ transform.fieldType = 'text';
247
+ transform.control = 'icon-picker';
248
+ transform.fallback = candidate.value || iconBase;
236
249
  } else if (candidate.operation === 'extract-tailwind-bg') {
237
250
  transform.field = buildFieldPath({
238
251
  scope,
@@ -31,6 +31,8 @@ const {
31
31
  IMAGE_TAGS,
32
32
  DECORATIVE_TAGS,
33
33
  isIconComponent,
34
+ isUtilityIcon,
35
+ isFeatureIcon,
34
36
  } = require('./adapters.cjs');
35
37
  const { BROAD_CONTENT_CONTAINERS } = require('./fivora-contract.cjs');
36
38
  const { inferSection, inferFieldName, buildFieldPath, classifyFieldType } = require('./field-paths.cjs');
@@ -118,7 +120,7 @@ function isDecorativeCopy(text, tag, className) {
118
120
  function classifyResidual(node, text, tag) {
119
121
  const className = attrLiteral(node, 'className') || attrLiteral(node, 'class');
120
122
  const ariaHidden = attrLiteral(node, 'aria-hidden') === 'true' || hasJsxAttribute(node, 'hidden') || /(^|\s)hidden(\s|$)/.test(className);
121
- if (ariaHidden || DECORATIVE_TAGS.has(tag) || isIconComponent(tag, '')) {
123
+ if (ariaHidden || DECORATIVE_TAGS.has(tag) || isUtilityIcon(tag)) {
122
124
  return { bind: false, reason: 'decorative-icon' };
123
125
  }
124
126
  if (isDecorativeCopy(text, tag, className)) {
@@ -224,7 +226,7 @@ function applyResidualPass({ code, file, ownerScope, usedPaths, componentName, r
224
226
  return;
225
227
  }
226
228
 
227
- if (DECORATIVE_TAGS.has(tag) || isIconComponent(tag, '')) {
229
+ if (DECORATIVE_TAGS.has(tag) || isUtilityIcon(tag)) {
228
230
  if (ensureStaticOnLeaf(node, 'decorative-icon')) applied++;
229
231
  this.traverse(pathNode);
230
232
  return;
@@ -10,12 +10,16 @@ const {
10
10
  collectJsxText,
11
11
  findJsxAttribute,
12
12
  unwrapExpr,
13
+ extractItemMemberName,
13
14
  } = require('./ast.cjs');
14
15
  const {
15
16
  activeAdapters,
16
17
  recognizeWithAdapters,
17
18
  resolveActionWithAdapters,
18
19
  isIconComponent,
20
+ isUtilityIcon,
21
+ isFeatureIcon,
22
+ extractIconBaseName,
19
23
  classifyHref,
20
24
  classifyActionIntent,
21
25
  isLikelyCtaClass,
@@ -498,6 +502,141 @@ function classNameOf(node) {
498
502
  return getJsxAttributeLiteral(node, 'className') || getJsxAttributeLiteral(node, 'class') || '';
499
503
  }
500
504
 
505
+ function collectPatternNames(param, out) {
506
+ if (!param) return;
507
+ if (param.type === 'Identifier') out.push(param.name);
508
+ else if (param.type === 'AssignmentPattern') collectPatternNames(param.left, out);
509
+ else if (param.type === 'RestElement') collectPatternNames(param.argument, out);
510
+ else if (param.type === 'ObjectPattern') {
511
+ for (const prop of param.properties || []) {
512
+ if (prop.type === 'RestElement') collectPatternNames(prop.argument, out);
513
+ else collectPatternNames(prop.value || prop.argument, out);
514
+ }
515
+ } else if (param.type === 'ArrayPattern') {
516
+ for (const el of param.elements || []) collectPatternNames(el, out);
517
+ }
518
+ }
519
+
520
+ function enclosingComponentParams(pathNode) {
521
+ let current = pathNode.parent;
522
+ while (current) {
523
+ const node = current.node || current.value;
524
+ if (
525
+ node &&
526
+ (node.type === 'FunctionDeclaration' ||
527
+ node.type === 'FunctionExpression' ||
528
+ node.type === 'ArrowFunctionExpression')
529
+ ) {
530
+ const parent = current.parent?.node || current.parent?.value;
531
+ const isMapCallback =
532
+ parent?.type === 'CallExpression' &&
533
+ parent.callee?.type === 'MemberExpression' &&
534
+ parent.callee.property?.name === 'map';
535
+ if (!isMapCallback) {
536
+ const names = [];
537
+ for (const param of node.params || []) collectPatternNames(param, names);
538
+ return { fn: node, names };
539
+ }
540
+ }
541
+ current = current.parentPath || current.parent;
542
+ }
543
+ return { fn: null, names: [] };
544
+ }
545
+
546
+ function identifiersIn(expr, out) {
547
+ const node = unwrapExpr(expr);
548
+ if (!node) return;
549
+ if (node.type === 'Identifier') {
550
+ out.add(node.name);
551
+ return;
552
+ }
553
+ if (node.type === 'MemberExpression' || node.type === 'OptionalMemberExpression') {
554
+ identifiersIn(node.object, out);
555
+ if (node.computed) identifiersIn(node.property, out);
556
+ return;
557
+ }
558
+ if (node.type === 'ChainExpression') {
559
+ identifiersIn(node.expression, out);
560
+ return;
561
+ }
562
+ if (node.type === 'LogicalExpression' || node.type === 'BinaryExpression') {
563
+ identifiersIn(node.left, out);
564
+ identifiersIn(node.right, out);
565
+ return;
566
+ }
567
+ if (node.type === 'ConditionalExpression') {
568
+ identifiersIn(node.test, out);
569
+ identifiersIn(node.consequent, out);
570
+ identifiersIn(node.alternate, out);
571
+ }
572
+ }
573
+
574
+ function namesTiedToParams(fn, paramNames) {
575
+ const blocked = new Set(paramNames);
576
+ if (!fn?.body) return blocked;
577
+ let grew = true;
578
+ while (grew) {
579
+ grew = false;
580
+ recast.types.visit(fn.body, {
581
+ visitVariableDeclarator(pathNode) {
582
+ const node = pathNode.node;
583
+ if (node.id?.type === 'Identifier' && node.init && !blocked.has(node.id.name)) {
584
+ const used = new Set();
585
+ identifiersIn(node.init, used);
586
+ for (const name of used) {
587
+ if (blocked.has(name)) {
588
+ blocked.add(node.id.name);
589
+ grew = true;
590
+ }
591
+ }
592
+ }
593
+ this.traverse(pathNode);
594
+ },
595
+ });
596
+ }
597
+ return blocked;
598
+ }
599
+
600
+ function imageExpression(node) {
601
+ if (!node) return null;
602
+ const attr = findJsxAttribute(node, 'src');
603
+ if (!attr?.value || attr.value.type !== 'JSXExpressionContainer') return null;
604
+ const expr = unwrapExpr(attr.value.expression);
605
+ if (!expr || expr.type === 'JSXEmptyExpression') return null;
606
+ if (expr.type === 'StringLiteral' || expr.type === 'Literal' || expr.type === 'TemplateLiteral') return null;
607
+ if (expr.type === 'Identifier' && expr.name === 'siteData') return null;
608
+ if (
609
+ expr.type === 'Identifier' ||
610
+ expr.type === 'MemberExpression' ||
611
+ expr.type === 'OptionalMemberExpression' ||
612
+ expr.type === 'ChainExpression' ||
613
+ expr.type === 'LogicalExpression' ||
614
+ expr.type === 'ConditionalExpression'
615
+ ) {
616
+ return expr;
617
+ }
618
+ return null;
619
+ }
620
+
621
+ function imageFieldHint(expr) {
622
+ const node = unwrapExpr(expr);
623
+ if (!node) return 'photoImage';
624
+ if (node.type === 'Identifier') {
625
+ return /image|photo|picture|banner|logo/i.test(node.name) ? node.name : `${node.name}Image`;
626
+ }
627
+ if ((node.type === 'MemberExpression' || node.type === 'OptionalMemberExpression') && !node.computed) {
628
+ const prop = node.property?.name || '';
629
+ const objectName = node.object?.type === 'Identifier' ? node.object.name : '';
630
+ const base = /^(src|url|href)$/i.test(prop) ? objectName || prop : prop || objectName;
631
+ if (!base) return 'photoImage';
632
+ return /image|photo|picture|banner|logo/i.test(base) ? base : `${base}Image`;
633
+ }
634
+ if (node.type === 'ChainExpression') return imageFieldHint(node.expression);
635
+ if (node.type === 'LogicalExpression') return imageFieldHint(node.left) || imageFieldHint(node.right);
636
+ if (node.type === 'ConditionalExpression') return imageFieldHint(node.consequent) || imageFieldHint(node.alternate);
637
+ return 'photoImage';
638
+ }
639
+
501
640
  function confidenceFor(kind, extras = {}) {
502
641
  if (extras.already) return 1;
503
642
  if (extras.dynamic) return 0.15;
@@ -641,7 +780,7 @@ function analyzeFile(optionsOrFile, codeArg, profileArg) {
641
780
  if (!binding || binding.kind !== 'array') apiOwned = true;
642
781
  }
643
782
 
644
- if (DECORATIVE_TAGS.has(name) || isIconComponent(name, importSource)) {
783
+ if (DECORATIVE_TAGS.has(name) || isUtilityIcon(name)) {
645
784
  candidates.push({
646
785
  loc,
647
786
  tag: name,
@@ -657,6 +796,29 @@ function analyzeFile(optionsOrFile, codeArg, profileArg) {
657
796
  return;
658
797
  }
659
798
 
799
+ if (isFeatureIcon(name, importSource)) {
800
+ const iconBase = extractIconBaseName(name);
801
+ candidates.push({
802
+ loc,
803
+ tag: name,
804
+ kind: 'feature-icon',
805
+ operation: 'feature-icon',
806
+ confidence: 0.95,
807
+ skip: false,
808
+ value: iconBase,
809
+ file: relativeFile,
810
+ ownerScope,
811
+ fingerprint: fingerprintCandidate({ tag: name, kind: 'icon', importSource }),
812
+ extra: {
813
+ iconName: iconBase,
814
+ originalComponent: name,
815
+ importSource,
816
+ },
817
+ });
818
+ this.traverse(pathNode);
819
+ return;
820
+ }
821
+
660
822
  const parents = parentNames(pathNode);
661
823
 
662
824
  if (name === 'source' && (parents[0] === 'picture' || recognition?.kind === 'responsive-image-source')) {
@@ -818,6 +980,43 @@ function analyzeFile(optionsOrFile, codeArg, profileArg) {
818
980
  return;
819
981
  }
820
982
 
983
+ const dynamicImageNode = name === 'picture'
984
+ ? (node.children || []).find((c) => c && c.type === 'JSXElement' && (getJsxName(c) === 'img' || getJsxName(c) === 'Image'))
985
+ : node;
986
+ const dynamicExpr = !src ? imageExpression(dynamicImageNode) : null;
987
+ if ((IMAGE_TAGS.has(name) || recognition?.kind === 'image') && dynamicExpr && !hasJsxAttribute(dynamicImageNode, 'data-preview-field-path')) {
988
+ const itemBound = mapInfo?.itemParam && extractItemMemberName(dynamicExpr, mapInfo.itemParam);
989
+ const componentParams = enclosingComponentParams(pathNode);
990
+ const blocked = namesTiedToParams(componentParams.fn, componentParams.names);
991
+ const usedNames = new Set();
992
+ identifiersIn(dynamicExpr, usedNames);
993
+ const fromProps = [...usedNames].some((usedName) => blocked.has(usedName));
994
+ // A photo that lives on one page (the home hero, for example) gets its
995
+ // own image field even when the src is a prop. A shared image widget
996
+ // must not, or every call site would show the same photo.
997
+ const pageOwned = Boolean(ownerScope) && ownerScope !== 'common';
998
+ if (!itemBound && (!fromProps || pageOwned)) {
999
+ usedLocs.add(loc);
1000
+ candidates.push({
1001
+ ...baseMeta,
1002
+ kind: 'image',
1003
+ operation: 'extract-image',
1004
+ value: '',
1005
+ extra: {
1006
+ alt,
1007
+ dynamicSrc: true,
1008
+ fieldHint: isLogoContext ? undefined : imageFieldHint(dynamicExpr),
1009
+ isBrandLogo: Boolean(isLogoContext),
1010
+ },
1011
+ confidence: 0.9,
1012
+ reason: 'dynamic-image-source',
1013
+ fingerprint: fingerprintCandidate({ tag: name, kind: 'image', dynamic: true, hint: imageFieldHint(dynamicExpr) }),
1014
+ });
1015
+ this.traverse(pathNode);
1016
+ return;
1017
+ }
1018
+ }
1019
+
821
1020
  if (placeholder && !isStaticSkipText(placeholder)) {
822
1021
  usedLocs.add(loc);
823
1022
  candidates.push({
@@ -5,6 +5,7 @@ const {
5
5
  findJsxAttribute,
6
6
  hasJsxAttribute,
7
7
  siteDataBinding,
8
+ optionalMember,
8
9
  ensureStyleAttrs,
9
10
  b,
10
11
  } = require('../ast.cjs');
@@ -20,6 +21,22 @@ const { splitActionChildren } = require('./primitives/action.cjs');
20
21
  const { bindButtonWithIcon, bindHighlightedHeading } = require('./components/compound-content.cjs');
21
22
  const { extractTailwindBg } = require('./assets/tailwind-bg.cjs');
22
23
 
24
+ function bindDynamicImageSource(node, field) {
25
+ const tagName = getJsxName(node);
26
+ const target = tagName === 'picture'
27
+ ? (node.children || []).find((child) => child && child.type === 'JSXElement' && (getJsxName(child) === 'img' || getJsxName(child) === 'Image'))
28
+ : node;
29
+ if (!target) return;
30
+ const srcAttr = findJsxAttribute(target, 'src');
31
+ if (srcAttr?.value?.type === 'JSXExpressionContainer' && srcAttr.value.expression) {
32
+ const original = srcAttr.value.expression;
33
+ const edited = optionalMember(['siteData', 'content', ...String(field).split('.')]);
34
+ srcAttr.value = b.jsxExpressionContainer(b.logicalExpression('||', edited, original));
35
+ }
36
+ ensurePreviewPath(target, field);
37
+ if (target !== node) ensurePreviewPath(node, field);
38
+ }
39
+
23
40
  function applyTransformToElement(pathNode, transform) {
24
41
  const node = pathNode.node;
25
42
  if (transform.operation === 'form-submit-action') {
@@ -96,6 +113,10 @@ function applyTransformToElement(pathNode, transform) {
96
113
  return;
97
114
  }
98
115
  if (transform.operation === 'extract-image' || transform.operation === 'extract-picture') {
116
+ if (transform.dynamicSrc) {
117
+ bindDynamicImageSource(node, transform.field);
118
+ return;
119
+ }
99
120
  const tagName = getJsxName(node);
100
121
  if (tagName === 'picture') {
101
122
  const children = node.children || [];
@@ -185,6 +206,20 @@ function applyTransformToElement(pathNode, transform) {
185
206
  extractTailwindBg(node, transform);
186
207
  return;
187
208
  }
209
+ if (transform.operation === 'feature-icon') {
210
+ if (hasJsxAttribute(node, 'data-preview-static')) {
211
+ node.openingElement.attributes = node.openingElement.attributes.filter(
212
+ (attr) => !(attr.type === 'JSXAttribute' && attr.name && attr.name.name === 'data-preview-static')
213
+ );
214
+ }
215
+ replaceAttrValue(node, 'data-preview-field-path', b.stringLiteral(transform.field));
216
+ if (!hasJsxAttribute(node, 'data-preview-control')) {
217
+ node.openingElement.attributes.push(
218
+ b.jsxAttribute(b.jsxIdentifier('data-preview-control'), b.stringLiteral('icon-picker'))
219
+ );
220
+ }
221
+ return;
222
+ }
188
223
  }
189
224
 
190
225
  module.exports = {
@@ -77,6 +77,7 @@ function applyFilePlan(filePlan, profile) {
77
77
  'bind-highlighted-heading',
78
78
  'prop-flow-callsite',
79
79
  'instrument-reusable-component',
80
+ 'feature-icon',
80
81
  ]);
81
82
 
82
83
  const isTypeScript = Boolean(filePlan.file && /\.(tsx|ts)$/.test(filePlan.file));
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ // Helper to ensure nested object path exists
7
+ function setDeep(obj, pathArr, value) {
8
+ let cur = obj;
9
+ for (let i = 0; i < pathArr.length - 1; i++) {
10
+ const k = pathArr[i];
11
+ if (!cur[k] || typeof cur[k] !== 'object') cur[k] = {};
12
+ cur = cur[k];
13
+ }
14
+ if (cur[pathArr[pathArr.length - 1]] === undefined) {
15
+ cur[pathArr[pathArr.length - 1]] = value;
16
+ }
17
+ }
18
+
19
+ function patchUniversalIcons(projectDir, dryRun = false, log = () => {}) {
20
+ if (!projectDir || typeof projectDir !== 'string' || !fs.existsSync(projectDir)) {
21
+ return;
22
+ }
23
+
24
+ const siteDataPath = path.join(projectDir, 'data', 'site-data.json');
25
+ if (!fs.existsSync(siteDataPath)) {
26
+ return;
27
+ }
28
+
29
+ let siteData;
30
+ try {
31
+ siteData = JSON.parse(fs.readFileSync(siteDataPath, 'utf8'));
32
+ } catch {
33
+ return;
34
+ }
35
+
36
+ if (!siteData.content) siteData.content = {};
37
+ if (!siteData.content.common) siteData.content.common = {};
38
+
39
+ setDeep(siteData.content, ['common', 'whatsapp', 'copyIcon'], 'copy');
40
+ setDeep(siteData.content, ['common', 'whatsapp', 'icon'], 'message-circle');
41
+ setDeep(siteData.content, ['common', 'search', 'searchIcon'], 'search');
42
+
43
+ if (!dryRun) {
44
+ try {
45
+ fs.writeFileSync(siteDataPath, JSON.stringify(siteData, null, 2), 'utf8');
46
+ log('[universal-icons] Registered common icon keys in site-data.json');
47
+ } catch (err) {
48
+ log('[universal-icons] Warning: failed to save site-data.json: ' + err.message);
49
+ }
50
+ }
51
+ }
52
+
53
+ module.exports = {
54
+ patchUniversalIcons,
55
+ };
@@ -519,10 +519,32 @@ export function findLinksToUnselectedPages(input: {
519
519
  return findings;
520
520
  }
521
521
 
522
+ /** Detect links that contain the configured base path but omit its leading
523
+ * slash. Browsers resolve these relative to the current page and duplicate the
524
+ * generated-site prefix after navigation. */
525
+ export function findMalformedBasePathLinks(input: {
526
+ html: string;
527
+ basePath: string;
528
+ }) {
529
+ const prefix = input.basePath.replace(/^\/+|\/+$/g, '');
530
+ if (!prefix) return [];
531
+ const findings = new Set<string>();
532
+ for (const match of input.html.matchAll(
533
+ /<[a-z][^>]*\b(?:href|formaction|data-href|data-route|data-url)\s*=\s*(?:"([^"]*)"|'([^']*)')[^>]*>/gi,
534
+ )) {
535
+ const value = (match[1] ?? match[2] ?? '').trim();
536
+ const path = value.split(/[?#]/, 1)[0].replace(/^\.\//, '');
537
+ if (path === prefix || path.startsWith(`${prefix}/`)) {
538
+ findings.add(value);
539
+ }
540
+ }
541
+ return [...findings];
542
+ }
543
+
522
544
  /**
523
545
  * Next's client router applies next.config `basePath` automatically. Wrapping
524
- * a router destination in a helper that also prepends the base path duplicates
525
- * the generated-site prefix at runtime.
546
+ * a router destination in a package helper that also prepends the base path
547
+ * duplicates the generated-site prefix at runtime.
526
548
  */
527
549
  export function findDoubleBasePathNextRouterCalls(source: string) {
528
550
  const findings: Array<{