@deneb-ui/cli 2.0.46 → 2.0.48
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/package.json +2 -2
- package/src/arc/__tests__/arc.test.cjs +249 -0
- package/src/arc/adapters.cjs +74 -0
- package/src/arc/ast.cjs +8 -1
- package/src/arc/field-paths.cjs +7 -3
- package/src/arc/planner.cjs +4 -1
- package/src/arc/semantic.cjs +17 -8
- package/src/arc/transformer.cjs +99 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deneb-ui/cli",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.48",
|
|
4
4
|
"description": "Official DENEB CLI — scaffold, convert, validate, and package Fivora-ready storefront templates.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"deneb": "bin/index.js",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"license": "MIT",
|
|
50
50
|
"dependencies": {
|
|
51
51
|
"@babel/parser": "^7.28.0",
|
|
52
|
-
"@deneb-ui/core": "^2.0.
|
|
52
|
+
"@deneb-ui/core": "^2.0.48",
|
|
53
53
|
"@octokit/rest": "^22.0.1",
|
|
54
54
|
"adm-zip": "^0.6.0",
|
|
55
55
|
"dotenv": "^17.4.2",
|
|
@@ -15,6 +15,7 @@ const { enrichSchemasFromContent } = require('../manifest.cjs');
|
|
|
15
15
|
const { runDenebArc } = require('../index.cjs');
|
|
16
16
|
const { parseSource } = require('../ast.cjs');
|
|
17
17
|
const { loadFingerprintBoost } = require('../learning.cjs');
|
|
18
|
+
const { classifyActionIntent } = require('../adapters.cjs');
|
|
18
19
|
|
|
19
20
|
test('field-paths recognizes list action CTA keys', () => {
|
|
20
21
|
assert.equal(isListActionCtaKey('preOrderCta'), true);
|
|
@@ -626,3 +627,251 @@ export { useSiteData, contentText };
|
|
|
626
627
|
assert.match(out, /export\s*\{[^}]*useSiteData[^}]*\}\s*from\s*['"]@deneb-ui\/ui['"]/);
|
|
627
628
|
assert.doesNotMatch(out, /import\s*\{[^}]*useSiteData/);
|
|
628
629
|
});
|
|
630
|
+
|
|
631
|
+
test('toCamel and optionalMember safely handle strings starting with numbers without syntax errors', () => {
|
|
632
|
+
const { toCamel } = require('../field-paths.cjs');
|
|
633
|
+
const { optionalMember, parseSource } = require('../ast.cjs');
|
|
634
|
+
const recast = require('recast');
|
|
635
|
+
|
|
636
|
+
// toCamel prefixes identifiers starting with a number
|
|
637
|
+
assert.equal(toCamel(['1800840AURA']), 'item1800840aura');
|
|
638
|
+
assert.equal(toCamel(['25-Min', 'Express']), 'item25MinExpress');
|
|
639
|
+
|
|
640
|
+
// optionalMember safely falls back to computed string literal for non-identifier keys
|
|
641
|
+
const chain = optionalMember(['siteData', 'content', 'home', '100EncryptedLabel']);
|
|
642
|
+
const code = recast.print(chain).code;
|
|
643
|
+
assert.equal(code, 'siteData?.content?.home?.["100EncryptedLabel"]');
|
|
644
|
+
|
|
645
|
+
// Verify it parses as valid JS without syntax errors
|
|
646
|
+
assert.doesNotThrow(() => parseSource(`const x = ${code};`, 'test.tsx'));
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
test('bindArrayDeclaration transforms module-scope array into DEFAULT_ fallback in client components', () => {
|
|
650
|
+
const clientCode = `'use client';
|
|
651
|
+
import React from 'react';
|
|
652
|
+
|
|
653
|
+
const BRANDS = [
|
|
654
|
+
{ name: 'Apple' },
|
|
655
|
+
{ name: 'Samsung' },
|
|
656
|
+
];
|
|
657
|
+
|
|
658
|
+
export function BrandMarquee() {
|
|
659
|
+
return (
|
|
660
|
+
<div>
|
|
661
|
+
{BRANDS.map((b) => (
|
|
662
|
+
<div key={b.name} className="brand-item">
|
|
663
|
+
<span>{b.name}</span>
|
|
664
|
+
</div>
|
|
665
|
+
))}
|
|
666
|
+
</div>
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
`;
|
|
670
|
+
|
|
671
|
+
const profile = {
|
|
672
|
+
root: os.tmpdir(),
|
|
673
|
+
framework: 'nextjs',
|
|
674
|
+
router: 'next-app',
|
|
675
|
+
language: 'typescript',
|
|
676
|
+
cssSystems: ['tailwind'],
|
|
677
|
+
hasSrc: true,
|
|
678
|
+
aliasMap: { '@/*': ['src/*'] },
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
const analysis = analyzeFile({
|
|
682
|
+
code: clientCode,
|
|
683
|
+
relativeFile: 'src/components/BrandMarquee.tsx',
|
|
684
|
+
profile,
|
|
685
|
+
graph: { sharedFiles: [] },
|
|
686
|
+
ownerScope: 'home',
|
|
687
|
+
componentMeta: { name: 'BrandMarquee', role: 'about' },
|
|
688
|
+
});
|
|
689
|
+
analysis.code = clientCode;
|
|
690
|
+
analysis.relativeFile = 'src/components/BrandMarquee.tsx';
|
|
691
|
+
|
|
692
|
+
const plan = planTransformations({ profile, analyses: [analysis] });
|
|
693
|
+
const result = applyFilePlan(plan.files[0], profile);
|
|
694
|
+
|
|
695
|
+
assert.equal(result.changed, true);
|
|
696
|
+
// Module-scope array renamed to DEFAULT_BRANDS with literal array preserved
|
|
697
|
+
assert.match(result.code, /const DEFAULT_BRANDS = \[\s*\{\s*name:\s*['"]Apple['"]\s*\}/);
|
|
698
|
+
// Inside component body: useSiteData hook followed by dynamic BRANDS binding
|
|
699
|
+
assert.match(result.code, /const siteData = useSiteData\(\);/);
|
|
700
|
+
assert.match(result.code, /const BRANDS = siteData\?\.content\?\.home\?\.BRANDS \?\? DEFAULT_BRANDS;/);
|
|
701
|
+
// Verify AST parses cleanly
|
|
702
|
+
assert.doesNotThrow(() => parseSource(result.code, 'BrandMarquee.tsx'));
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
test('classifyActionIntent identifies action keywords and assigns correct fallback URLs', () => {
|
|
706
|
+
const wa = classifyActionIntent('Order on WhatsApp', null);
|
|
707
|
+
assert.equal(wa?.action, 'whatsapp');
|
|
708
|
+
assert.equal(wa?.defaultUrl, 'https://wa.me/1234567890');
|
|
709
|
+
assert.equal(wa?.external, true);
|
|
710
|
+
|
|
711
|
+
const call = classifyActionIntent('Call Us', null);
|
|
712
|
+
assert.equal(call?.action, 'phone');
|
|
713
|
+
assert.equal(call?.defaultUrl, 'tel:+1234567890');
|
|
714
|
+
|
|
715
|
+
const dir = classifyActionIntent('Get Directions', null);
|
|
716
|
+
assert.equal(dir?.action, 'directions');
|
|
717
|
+
assert.equal(dir?.defaultUrl, 'https://maps.google.com/?q=store+location');
|
|
718
|
+
assert.equal(dir?.external, true);
|
|
719
|
+
|
|
720
|
+
const loc = classifyActionIntent('Our Location', null);
|
|
721
|
+
assert.equal(loc?.action, 'location');
|
|
722
|
+
assert.equal(loc?.defaultUrl, 'https://maps.google.com/?q=store+location');
|
|
723
|
+
assert.equal(loc?.external, true);
|
|
724
|
+
|
|
725
|
+
const shop = classifyActionIntent('Shop Now', null);
|
|
726
|
+
assert.equal(shop?.action, 'shop');
|
|
727
|
+
assert.equal(shop?.defaultUrl, '/shop');
|
|
728
|
+
assert.equal(shop?.external, false);
|
|
729
|
+
|
|
730
|
+
const nonAction = classifyActionIntent('Submit Form', null);
|
|
731
|
+
assert.equal(nonAction, null);
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
test('semantic engine recognizes <button> with action text as split-action-contract', () => {
|
|
735
|
+
const code = `
|
|
736
|
+
export function ContactBar() {
|
|
737
|
+
return (
|
|
738
|
+
<div className="contact-bar">
|
|
739
|
+
<button className="btn-primary">Order on WhatsApp</button>
|
|
740
|
+
<button className="btn-secondary">Get Directions</button>
|
|
741
|
+
<button type="submit">Submit Feedback</button>
|
|
742
|
+
</div>
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
`;
|
|
746
|
+
const profile = {
|
|
747
|
+
root: os.tmpdir(),
|
|
748
|
+
framework: 'nextjs',
|
|
749
|
+
router: 'next-app',
|
|
750
|
+
language: 'typescript',
|
|
751
|
+
cssSystems: ['tailwind'],
|
|
752
|
+
hasSrc: true,
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
const analysis = analyzeFile({
|
|
756
|
+
code,
|
|
757
|
+
relativeFile: 'src/components/ContactBar.tsx',
|
|
758
|
+
profile,
|
|
759
|
+
graph: { sharedFiles: [] },
|
|
760
|
+
ownerScope: 'home',
|
|
761
|
+
componentMeta: { name: 'ContactBar', role: 'contact' },
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
const waSplit = analysis.candidates.find((c) => c.operation === 'split-action-contract' && c.label === 'Order on WhatsApp');
|
|
765
|
+
assert.ok(waSplit, 'expected split-action-contract for WhatsApp button');
|
|
766
|
+
assert.equal(waSplit.extra.action, 'whatsapp');
|
|
767
|
+
assert.equal(waSplit.value, 'https://wa.me/1234567890');
|
|
768
|
+
|
|
769
|
+
const dirSplit = analysis.candidates.find((c) => c.operation === 'split-action-contract' && c.label === 'Get Directions');
|
|
770
|
+
assert.ok(dirSplit, 'expected split-action-contract for Directions button');
|
|
771
|
+
assert.equal(dirSplit.extra.action, 'directions');
|
|
772
|
+
|
|
773
|
+
// Submit button should NOT be split into a redirect link
|
|
774
|
+
const submitSplit = analysis.candidates.find((c) => c.label === 'Submit Feedback' && c.operation === 'split-action-contract');
|
|
775
|
+
assert.equal(submitSplit, undefined, 'submit button must not be an action redirect');
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
test('AST transformer converts <button> action to <a> with redirect URL and editable text label', () => {
|
|
779
|
+
const code = `
|
|
780
|
+
export function ActionPanel() {
|
|
781
|
+
return (
|
|
782
|
+
<div className="panel">
|
|
783
|
+
<button className="px-4 py-2 bg-green-600 text-white rounded">Order on WhatsApp</button>
|
|
784
|
+
</div>
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
`;
|
|
788
|
+
const profile = {
|
|
789
|
+
root: os.tmpdir(),
|
|
790
|
+
framework: 'nextjs',
|
|
791
|
+
router: 'next-app',
|
|
792
|
+
language: 'typescript',
|
|
793
|
+
cssSystems: ['tailwind'],
|
|
794
|
+
hasSrc: true,
|
|
795
|
+
aliasMap: { '@/*': ['src/*'] },
|
|
796
|
+
};
|
|
797
|
+
|
|
798
|
+
const analysis = analyzeFile({
|
|
799
|
+
code,
|
|
800
|
+
relativeFile: 'src/components/ActionPanel.tsx',
|
|
801
|
+
profile,
|
|
802
|
+
graph: { sharedFiles: [] },
|
|
803
|
+
ownerScope: 'home',
|
|
804
|
+
componentMeta: { name: 'ActionPanel', role: 'hero' },
|
|
805
|
+
});
|
|
806
|
+
analysis.code = code;
|
|
807
|
+
analysis.relativeFile = 'src/components/ActionPanel.tsx';
|
|
808
|
+
|
|
809
|
+
const plan = planTransformations({ profile, analyses: [analysis] });
|
|
810
|
+
const result = applyFilePlan(plan.files[0], profile);
|
|
811
|
+
|
|
812
|
+
assert.equal(result.changed, true);
|
|
813
|
+
// Converted from <button> to <a ...>
|
|
814
|
+
assert.match(result.code, /<a\s+[^>]*href=\{siteData\?\.content\?\.home\?\.hero\?\.whatsappUrl \?\? "https:\/\/wa\.me\/1234567890"\}/);
|
|
815
|
+
assert.match(result.code, /target="_blank"/);
|
|
816
|
+
assert.match(result.code, /rel="noopener noreferrer"/);
|
|
817
|
+
assert.match(result.code, /data-preview-static="action-link"/);
|
|
818
|
+
// Text label wrapped in editable span
|
|
819
|
+
assert.match(result.code, /<span\s+data-preview-field-path="home\.hero\.whatsappLabel"[^>]*>\{siteData\?\.content\?\.home\?\.hero\?\.whatsappLabel \?\? "Order on WhatsApp"\}<\/span>/);
|
|
820
|
+
// Hidden URL span present for Fivora contract
|
|
821
|
+
assert.match(result.code, /<span hidden aria-hidden="true" data-preview-field-path="home\.hero\.whatsappUrl">/);
|
|
822
|
+
// No parse errors
|
|
823
|
+
assert.doesNotThrow(() => parseSource(result.code, 'ActionPanel.tsx'));
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
test('end-to-end ARC conversion transforms action buttons and passes strict Fivora audit', () => {
|
|
827
|
+
const dir = copyOf(FIXTURE);
|
|
828
|
+
const actionPagePath = path.join(dir, 'src', 'components', 'ActionPage.tsx');
|
|
829
|
+
fs.writeFileSync(
|
|
830
|
+
actionPagePath,
|
|
831
|
+
`
|
|
832
|
+
export function ActionPage() {
|
|
833
|
+
return (
|
|
834
|
+
<div className="action-page p-8">
|
|
835
|
+
<h1>Connect & Visit</h1>
|
|
836
|
+
<button className="btn-wa">Order on WhatsApp</button>
|
|
837
|
+
<button className="btn-call">Call Us</button>
|
|
838
|
+
<button className="btn-dir">Get Directions</button>
|
|
839
|
+
<button className="btn-loc">Our Location</button>
|
|
840
|
+
<button className="btn-shop">Shop Now</button>
|
|
841
|
+
</div>
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
`
|
|
845
|
+
);
|
|
846
|
+
|
|
847
|
+
// Link ActionPage in page.tsx
|
|
848
|
+
const pageFile = path.join(dir, 'src', 'app', 'page.tsx');
|
|
849
|
+
const pageSrc = fs.readFileSync(pageFile, 'utf8');
|
|
850
|
+
fs.writeFileSync(
|
|
851
|
+
pageFile,
|
|
852
|
+
`import { ActionPage } from '../components/ActionPage';\n` +
|
|
853
|
+
pageSrc.replace('</main>', ' <ActionPage />\n </main>')
|
|
854
|
+
);
|
|
855
|
+
|
|
856
|
+
silence(() => runDenebArc(dir, 'actions-fixture', { telemetry: 'off' }));
|
|
857
|
+
|
|
858
|
+
const transformed = fs.readFileSync(actionPagePath, 'utf8');
|
|
859
|
+
// All 5 buttons converted to <a> tags with href
|
|
860
|
+
assert.match(transformed, /<a\s+[^>]*href=\{siteData\?\.content\?\.home\?\.contact\?\.whatsappUrl/);
|
|
861
|
+
assert.match(transformed, /<a\s+[^>]*href=\{siteData\?\.content\?\.home\?\.contact\?\.phoneUrl/);
|
|
862
|
+
assert.match(transformed, /<a\s+[^>]*href=\{siteData\?\.content\?\.home\?\.contact\?\.getDirectionsUrl/);
|
|
863
|
+
assert.match(transformed, /<a\s+[^>]*href=\{siteData\?\.content\?\.home\?\.contact\?\.ourLocationUrl/);
|
|
864
|
+
assert.match(transformed, /<a\s+[^>]*href=\{siteData\?\.content\?\.home\?\.shop\?\.shopNowUrl/);
|
|
865
|
+
|
|
866
|
+
// All 5 buttons have editable labels
|
|
867
|
+
assert.match(transformed, /data-preview-field-path="home\.contact\.whatsappLabel"/);
|
|
868
|
+
assert.match(transformed, /data-preview-field-path="home\.contact\.phoneLabel"/);
|
|
869
|
+
assert.match(transformed, /data-preview-field-path="home\.contact\.getDirectionsLabel"/);
|
|
870
|
+
assert.match(transformed, /data-preview-field-path="home\.contact\.ourLocationLabel"/);
|
|
871
|
+
assert.match(transformed, /data-preview-field-path="home\.shop\.shopNowLabel"/);
|
|
872
|
+
|
|
873
|
+
// Strict Fivora audit passes with 0 errors
|
|
874
|
+
const { errors } = auditFivora(dir);
|
|
875
|
+
assert.deepEqual(errors, []);
|
|
876
|
+
});
|
|
877
|
+
|
package/src/arc/adapters.cjs
CHANGED
|
@@ -188,6 +188,79 @@ function isLikelyCtaClass(className) {
|
|
|
188
188
|
return /\b(btn|button|cta|action|rounded|bg-|hero[-_]?cta)\b/i.test(className || '');
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
function classifyActionIntent(text, href) {
|
|
192
|
+
const t = String(text || '').trim().toLowerCase();
|
|
193
|
+
const h = String(href || '').trim().toLowerCase();
|
|
194
|
+
|
|
195
|
+
// 1. WhatsApp
|
|
196
|
+
if (/wa\.me|whatsapp/i.test(h) || /\b(?:whatsapp|wa\.me)\b/i.test(t) || /order on whatsapp|chat on whatsapp|message on whatsapp/i.test(t)) {
|
|
197
|
+
return {
|
|
198
|
+
action: 'whatsapp',
|
|
199
|
+
defaultUrl: /^https?:\/\//i.test(h) && !h.includes('#') ? href : 'https://wa.me/1234567890',
|
|
200
|
+
external: true,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 2. Phone / Call
|
|
205
|
+
if (/^tel:/i.test(h) || /\b(?:call\s*(?:me|us|now)?|phone\s*(?:me|us|now)?|direct\s*line|ring\s*us)\b/i.test(t)) {
|
|
206
|
+
return {
|
|
207
|
+
action: 'phone',
|
|
208
|
+
defaultUrl: /^tel:/i.test(h) ? href : 'tel:+1234567890',
|
|
209
|
+
external: false,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 3. Directions
|
|
214
|
+
if (/maps\.google|goo\.gl\/maps|map\.apple/i.test(h) || /\b(?:directions?|get\s*directions?|route|navigate)\b/i.test(t)) {
|
|
215
|
+
return {
|
|
216
|
+
action: 'directions',
|
|
217
|
+
defaultUrl: /^https?:\/\//i.test(h) && !h.includes('#') ? href : 'https://maps.google.com/?q=store+location',
|
|
218
|
+
external: true,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 4. Location / Map
|
|
223
|
+
if (/\b(?:locations?|our\s*location|store\s*location|view\s*location|find\s*us|visit\s*us|locate\s*us|map)\b/i.test(t)) {
|
|
224
|
+
return {
|
|
225
|
+
action: 'location',
|
|
226
|
+
defaultUrl: /^https?:\/\//i.test(h) && !h.includes('#') ? href : 'https://maps.google.com/?q=store+location',
|
|
227
|
+
external: true,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 5. Shop / Order / Catalog / Menu
|
|
232
|
+
if (/\b(?:shop(?:\s*now)?|order(?:\s*now)?|buy(?:\s*now)?|explore\s*(?:shop|products|offerings|collection|menu)|browse\s*(?:menu|catalog|shop)|view\s*(?:menu|catalog)|menu)\b/i.test(t) || /^\/(?:shop|products|menu|catalog)/i.test(h)) {
|
|
233
|
+
return {
|
|
234
|
+
action: 'shop',
|
|
235
|
+
defaultUrl: h && !['#', ''].includes(h) ? href : '/shop',
|
|
236
|
+
external: false,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 6. Email
|
|
241
|
+
if (/^mailto:/i.test(h) || /\b(?:email\s*us|send\s*(?:us\s*)?email|contact\s*by\s*email)\b/i.test(t)) {
|
|
242
|
+
return {
|
|
243
|
+
action: 'email',
|
|
244
|
+
defaultUrl: /^mailto:/i.test(h) ? href : 'mailto:info@example.com',
|
|
245
|
+
external: false,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// 7. Check href using existing classifyHref
|
|
250
|
+
if (h && !['#', ''].includes(h)) {
|
|
251
|
+
const fromHref = classifyHref(href);
|
|
252
|
+
if (fromHref && fromHref !== 'link') {
|
|
253
|
+
return {
|
|
254
|
+
action: fromHref,
|
|
255
|
+
defaultUrl: href,
|
|
256
|
+
external: ['instagram', 'facebook', 'tiktok', 'twitter', 'youtube', 'linkedin', 'pinterest'].includes(fromHref),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
191
264
|
module.exports = {
|
|
192
265
|
ADAPTERS,
|
|
193
266
|
activeAdapters,
|
|
@@ -195,6 +268,7 @@ module.exports = {
|
|
|
195
268
|
resolveActionWithAdapters,
|
|
196
269
|
isIconComponent,
|
|
197
270
|
classifyHref,
|
|
271
|
+
classifyActionIntent,
|
|
198
272
|
isLikelyCtaClass,
|
|
199
273
|
DECORATIVE_TAGS,
|
|
200
274
|
SKIP_TAGS,
|
package/src/arc/ast.cjs
CHANGED
|
@@ -158,10 +158,17 @@ function isJsxTextHeavy(node) {
|
|
|
158
158
|
return meaningful.length > 0;
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
const VALID_IDENTIFIER_REGEX = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
|
|
162
|
+
|
|
161
163
|
function optionalMember(parts) {
|
|
162
164
|
let expr = b.identifier(parts[0]);
|
|
163
165
|
for (let i = 1; i < parts.length; i++) {
|
|
164
|
-
|
|
166
|
+
const part = String(parts[i]);
|
|
167
|
+
if (VALID_IDENTIFIER_REGEX.test(part)) {
|
|
168
|
+
expr = b.optionalMemberExpression(expr, b.identifier(part), false, true);
|
|
169
|
+
} else {
|
|
170
|
+
expr = b.optionalMemberExpression(expr, b.stringLiteral(part), true, true);
|
|
171
|
+
}
|
|
165
172
|
}
|
|
166
173
|
return expr;
|
|
167
174
|
}
|
package/src/arc/field-paths.cjs
CHANGED
|
@@ -15,13 +15,17 @@ function toCamel(parts) {
|
|
|
15
15
|
.filter((w, i) => i === 0 || !STOP_WORDS.has(w.toLowerCase()))
|
|
16
16
|
.slice(0, 5);
|
|
17
17
|
if (!cleaned.length) return '';
|
|
18
|
-
|
|
18
|
+
const camel = cleaned
|
|
19
19
|
.map((word, i) => {
|
|
20
20
|
const lower = word.toLowerCase();
|
|
21
21
|
if (i === 0) return lower;
|
|
22
22
|
return lower.charAt(0).toUpperCase() + lower.slice(1);
|
|
23
23
|
})
|
|
24
24
|
.join('');
|
|
25
|
+
if (/^[0-9]/.test(camel)) {
|
|
26
|
+
return 'item' + camel.charAt(0).toUpperCase() + camel.slice(1);
|
|
27
|
+
}
|
|
28
|
+
return camel;
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
function inferSection(context) {
|
|
@@ -67,14 +71,14 @@ function inferFieldName(kind, tag, text, extra = {}) {
|
|
|
67
71
|
if (extra.action === 'email') return 'emailUrl';
|
|
68
72
|
if (extra.cta) return extra.cta === 'primary' ? 'primaryCtaUrl' : `${extra.cta}Url`;
|
|
69
73
|
const fromText = toCamel([text || '', 'url']);
|
|
70
|
-
return fromText || 'ctaUrl';
|
|
74
|
+
return fromText || (extra.action ? `${extra.action}Url` : 'ctaUrl');
|
|
71
75
|
}
|
|
72
76
|
if (kind === 'label' && extra.paired) {
|
|
73
77
|
if (extra.action === 'whatsapp') return 'whatsappLabel';
|
|
74
78
|
if (extra.action === 'phone') return 'phoneLabel';
|
|
75
79
|
if (extra.action === 'email') return 'emailLabel';
|
|
76
80
|
if (extra.cta) return extra.cta === 'primary' ? 'primaryCtaLabel' : `${extra.cta}Label`;
|
|
77
|
-
return toCamel([text || '', 'label']) || 'ctaLabel';
|
|
81
|
+
return toCamel([text || '', 'label']) || (extra.action ? `${extra.action}Label` : 'ctaLabel');
|
|
78
82
|
}
|
|
79
83
|
if (kind === 'image') return extra.alt ? toCamel([extra.alt, 'image']) || 'image' : 'image';
|
|
80
84
|
if (kind === 'alt') return extra.imageField ? extra.imageField.replace(/Image$/, 'ImageAlt').replace(/image$/, 'imageAlt') : 'imageAlt';
|
package/src/arc/planner.cjs
CHANGED
|
@@ -230,9 +230,12 @@ function sectionForAction(scope, section, extra) {
|
|
|
230
230
|
if (scope === 'common' && (extra.social || ['instagram', 'facebook', 'twitter', 'tiktok', 'youtube', 'linkedin'].includes(extra.action))) {
|
|
231
231
|
return 'footer';
|
|
232
232
|
}
|
|
233
|
-
if (
|
|
233
|
+
if (['whatsapp', 'phone', 'email', 'directions', 'location'].includes(extra.action)) {
|
|
234
234
|
return section || 'contact';
|
|
235
235
|
}
|
|
236
|
+
if (extra.action === 'shop') {
|
|
237
|
+
return section || 'shop';
|
|
238
|
+
}
|
|
236
239
|
return section;
|
|
237
240
|
}
|
|
238
241
|
|
package/src/arc/semantic.cjs
CHANGED
|
@@ -16,6 +16,7 @@ const {
|
|
|
16
16
|
resolveActionWithAdapters,
|
|
17
17
|
isIconComponent,
|
|
18
18
|
classifyHref,
|
|
19
|
+
classifyActionIntent,
|
|
19
20
|
isLikelyCtaClass,
|
|
20
21
|
SKIP_TAGS,
|
|
21
22
|
HEADING_TAGS,
|
|
@@ -474,22 +475,30 @@ function analyzeFile({ code, relativeFile, profile, graph, ownerScope, component
|
|
|
474
475
|
}
|
|
475
476
|
|
|
476
477
|
const actionableHref = href || (name === 'Button' ? getJsxAttributeLiteral(node, 'href') : null);
|
|
477
|
-
const
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
478
|
+
const innerText = textInfo.dynamic ? '' : textInfo.text;
|
|
479
|
+
const typeAttr = getJsxAttributeLiteral(node, 'type');
|
|
480
|
+
const isSubmit = typeAttr === 'submit';
|
|
481
|
+
|
|
482
|
+
// Check for action intent via classifyActionIntent (covers WhatsApp, Call/Phone, Directions, Location, Shop, Email)
|
|
483
|
+
const actionIntent = !isSubmit && !apiOwned ? classifyActionIntent(innerText, actionableHref) : null;
|
|
484
|
+
const isAction = (Boolean(actionableHref) || Boolean(actionIntent)) && (ACTION_TAGS.has(name) || isLikelyCtaClass(className));
|
|
485
|
+
|
|
486
|
+
if (isAction && (actionableHref || actionIntent)) {
|
|
487
|
+
const action = actionIntent ? actionIntent.action : classifyHref(actionableHref);
|
|
488
|
+
const resolvedHref = actionableHref || (actionIntent ? actionIntent.defaultUrl : '#');
|
|
489
|
+
const looksCta = isLikelyCtaClass(className) || ['whatsapp', 'phone', 'email', 'directions', 'location', 'shop'].includes(action) || Boolean(innerText);
|
|
482
490
|
if (looksCta && innerText && !textInfo.dynamic && !apiOwned) {
|
|
483
491
|
usedLocs.add(loc);
|
|
484
492
|
candidates.push({
|
|
485
493
|
...baseMeta,
|
|
486
494
|
kind: 'split-action-contract',
|
|
487
495
|
operation: 'split-action-contract',
|
|
488
|
-
value:
|
|
496
|
+
value: resolvedHref,
|
|
489
497
|
label: innerText,
|
|
490
498
|
extra: {
|
|
491
499
|
action,
|
|
492
|
-
|
|
500
|
+
external: actionIntent ? actionIntent.external : false,
|
|
501
|
+
social: !['whatsapp', 'phone', 'email', 'directions', 'location', 'shop', 'link'].includes(action),
|
|
493
502
|
platform: ['instagram', 'facebook', 'tiktok', 'twitter', 'youtube', 'linkedin', 'pinterest'].includes(action) ? action : undefined,
|
|
494
503
|
},
|
|
495
504
|
confidence: confidenceFor('split-action-contract', { action, social: action !== 'link' }),
|
|
@@ -500,7 +509,7 @@ function analyzeFile({ code, relativeFile, profile, graph, ownerScope, component
|
|
|
500
509
|
return;
|
|
501
510
|
}
|
|
502
511
|
|
|
503
|
-
if (action !== 'link' && !innerText) {
|
|
512
|
+
if (action !== 'link' && !innerText && actionableHref) {
|
|
504
513
|
usedLocs.add(loc);
|
|
505
514
|
candidates.push({
|
|
506
515
|
...baseMeta,
|
package/src/arc/transformer.cjs
CHANGED
|
@@ -142,14 +142,48 @@ function wrapHiddenUrlSibling(pathNode, urlField, fallback) {
|
|
|
142
142
|
[b.jsxExpressionContainer(siteDataBinding(urlParts, fallback, 'url'))],
|
|
143
143
|
false
|
|
144
144
|
);
|
|
145
|
-
pathNode.
|
|
145
|
+
if (pathNode.parent && Array.isArray(pathNode.parent.node?.children)) {
|
|
146
|
+
pathNode.insertAfter(hiddenUrl);
|
|
147
|
+
} else if (pathNode.node && Array.isArray(pathNode.node.children)) {
|
|
148
|
+
pathNode.node.children = [hiddenUrl, ...(pathNode.node.children || [])];
|
|
149
|
+
}
|
|
146
150
|
}
|
|
147
151
|
|
|
148
152
|
function applyTransformToElement(pathNode, transform) {
|
|
149
153
|
const node = pathNode.node;
|
|
150
154
|
if (transform.operation === 'split-action-contract') {
|
|
155
|
+
const tagName = getJsxName(node);
|
|
156
|
+
if (tagName === 'button') {
|
|
157
|
+
node.openingElement.name = b.jsxIdentifier('a');
|
|
158
|
+
if (node.closingElement) {
|
|
159
|
+
node.closingElement.name = b.jsxIdentifier('a');
|
|
160
|
+
}
|
|
161
|
+
node.openingElement.attributes = (node.openingElement.attributes || []).filter(
|
|
162
|
+
(attr) => !(attr.type === 'JSXAttribute' && attr.name && attr.name.name === 'type')
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
151
166
|
const urlParts = transform.urlField.split('.');
|
|
152
167
|
replaceAttrValue(node, 'href', siteDataBinding(urlParts, transform.fallback, 'url'));
|
|
168
|
+
|
|
169
|
+
const isExternal =
|
|
170
|
+
transform.extra?.external ||
|
|
171
|
+
['whatsapp', 'directions', 'location'].includes(transform.extra?.action) ||
|
|
172
|
+
/^https?:\/\//i.test(String(transform.fallback || ''));
|
|
173
|
+
|
|
174
|
+
if (isExternal && (tagName === 'button' || tagName === 'a')) {
|
|
175
|
+
if (!hasJsxAttribute(node, 'target')) {
|
|
176
|
+
node.openingElement.attributes.push(
|
|
177
|
+
b.jsxAttribute(b.jsxIdentifier('target'), b.stringLiteral('_blank'))
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
if (!hasJsxAttribute(node, 'rel')) {
|
|
181
|
+
node.openingElement.attributes.push(
|
|
182
|
+
b.jsxAttribute(b.jsxIdentifier('rel'), b.stringLiteral('noopener noreferrer'))
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
153
187
|
if (!hasJsxAttribute(node, 'data-preview-static')) {
|
|
154
188
|
node.openingElement.attributes.push(
|
|
155
189
|
b.jsxAttribute(b.jsxIdentifier('data-preview-static'), b.stringLiteral('action-link'))
|
|
@@ -238,7 +272,7 @@ function wrapLiteralTextChildren(node, fieldPath, fallback) {
|
|
|
238
272
|
* emitted as JSX template literals (`items[${index}].title`) so added and
|
|
239
273
|
* reordered items stay editable, which is what strict mode requires.
|
|
240
274
|
*/
|
|
241
|
-
function applyCollectionTransform(ast, transform) {
|
|
275
|
+
function applyCollectionTransform(ast, transform, isClient = false) {
|
|
242
276
|
const listPath = transform.listField;
|
|
243
277
|
const binding = transform.itemParam;
|
|
244
278
|
if (!listPath || !binding) return false;
|
|
@@ -290,7 +324,7 @@ function applyCollectionTransform(ast, transform) {
|
|
|
290
324
|
);
|
|
291
325
|
}
|
|
292
326
|
|
|
293
|
-
return bindArrayDeclaration(ast, mapCall, listPath);
|
|
327
|
+
return bindArrayDeclaration(ast, mapCall, listPath, isClient);
|
|
294
328
|
}
|
|
295
329
|
|
|
296
330
|
function findMapCall(ast, loc) {
|
|
@@ -376,7 +410,40 @@ function findListContainer(mapCallPath) {
|
|
|
376
410
|
return null;
|
|
377
411
|
}
|
|
378
412
|
|
|
379
|
-
function
|
|
413
|
+
function isModuleLevel(pathNode) {
|
|
414
|
+
let current = pathNode.parentPath || pathNode.parent;
|
|
415
|
+
while (current) {
|
|
416
|
+
const type = current.node?.type;
|
|
417
|
+
if (
|
|
418
|
+
type === 'FunctionDeclaration' ||
|
|
419
|
+
type === 'FunctionExpression' ||
|
|
420
|
+
type === 'ArrowFunctionExpression'
|
|
421
|
+
) {
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
if (type === 'Program') return true;
|
|
425
|
+
current = current.parentPath || current.parent;
|
|
426
|
+
}
|
|
427
|
+
return true;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function findEnclosingFunction(pathNode) {
|
|
431
|
+
let current = pathNode.parentPath || pathNode.parent;
|
|
432
|
+
while (current) {
|
|
433
|
+
const type = current.node?.type;
|
|
434
|
+
if (
|
|
435
|
+
type === 'FunctionDeclaration' ||
|
|
436
|
+
type === 'FunctionExpression' ||
|
|
437
|
+
type === 'ArrowFunctionExpression'
|
|
438
|
+
) {
|
|
439
|
+
return current;
|
|
440
|
+
}
|
|
441
|
+
current = current.parentPath || current.parent;
|
|
442
|
+
}
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function bindArrayDeclaration(ast, mapCallPath, listPath, isClient = false) {
|
|
380
447
|
const arrayName = mapCallPath.node.callee.object?.name;
|
|
381
448
|
if (!arrayName) return false;
|
|
382
449
|
let bound = false;
|
|
@@ -392,6 +459,33 @@ function bindArrayDeclaration(ast, mapCallPath, listPath) {
|
|
|
392
459
|
this.traverse(pathNode);
|
|
393
460
|
return;
|
|
394
461
|
}
|
|
462
|
+
|
|
463
|
+
if (isClient && isModuleLevel(pathNode)) {
|
|
464
|
+
const defaultName = 'DEFAULT_' + arrayName;
|
|
465
|
+
node.id.name = defaultName;
|
|
466
|
+
const fnPath = findEnclosingFunction(mapCallPath);
|
|
467
|
+
if (fnPath && fnPath.node.body?.type === 'BlockStatement') {
|
|
468
|
+
const body = fnPath.node.body.body;
|
|
469
|
+
const already = body.some((stmt) => recast.print(stmt).code.includes(`const ${arrayName} =`));
|
|
470
|
+
if (!already) {
|
|
471
|
+
const localDecl = b.variableDeclaration('const', [
|
|
472
|
+
b.variableDeclarator(
|
|
473
|
+
b.identifier(arrayName),
|
|
474
|
+
siteDataListBinding(listPath.split('.'), b.identifier(defaultName))
|
|
475
|
+
),
|
|
476
|
+
]);
|
|
477
|
+
const hookIdx = body.findIndex((stmt) => recast.print(stmt).code.includes('useSiteData'));
|
|
478
|
+
if (hookIdx >= 0) {
|
|
479
|
+
body.splice(hookIdx + 1, 0, localDecl);
|
|
480
|
+
} else {
|
|
481
|
+
body.unshift(localDecl);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
bound = true;
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
|
|
395
489
|
node.init = siteDataListBinding(listPath.split('.'), node.init);
|
|
396
490
|
bound = true;
|
|
397
491
|
return false;
|
|
@@ -549,7 +643,7 @@ function applyFilePlan(filePlan, profile) {
|
|
|
549
643
|
|
|
550
644
|
if (transform.operation === 'collection-conversion') {
|
|
551
645
|
try {
|
|
552
|
-
if (applyCollectionTransform(ast, transform)) applied++;
|
|
646
|
+
if (applyCollectionTransform(ast, transform, isClient)) applied++;
|
|
553
647
|
else failures.push({ loc: transform.loc, reason: 'collection-not-bindable' });
|
|
554
648
|
} catch (err) {
|
|
555
649
|
failures.push({ loc: transform.loc, reason: err.message });
|