@deneb-ui/cli 2.0.49 → 2.0.51
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 +15 -5
- package/package.json +2 -2
- package/src/arc/__tests__/arc.test.cjs +465 -1
- package/src/arc/adapters.cjs +24 -1
- package/src/arc/ai-agent.cjs +6 -0
- package/src/arc/ai-evaluator.cjs +450 -0
- package/src/arc/ai-prompts.cjs +1 -0
- package/src/arc/ast.cjs +11 -3
- package/src/arc/field-paths.cjs +4 -0
- package/src/arc/index.cjs +30 -3
- package/src/arc/learning.cjs +31 -0
- package/src/arc/planner.cjs +18 -7
- package/src/arc/printer.cjs +20 -0
- package/src/arc/semantic.cjs +168 -37
- package/src/arc/style-candidates.cjs +2 -1
- package/src/arc/transformer.cjs +29 -1
package/bin/index.js
CHANGED
|
@@ -233,8 +233,15 @@ function detectPages(projectDir) {
|
|
|
233
233
|
}
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
-
const
|
|
237
|
-
|
|
236
|
+
const contactExistsOnDisk = candidateDirs.some((cDir) =>
|
|
237
|
+
fs.existsSync(path.join(cDir, 'contact.tsx')) ||
|
|
238
|
+
fs.existsSync(path.join(cDir, 'contact.jsx')) ||
|
|
239
|
+
fs.existsSync(path.join(cDir, 'contact.js')) ||
|
|
240
|
+
fs.existsSync(path.join(cDir, 'contact', 'page.tsx')) ||
|
|
241
|
+
fs.existsSync(path.join(cDir, 'contact', 'page.jsx')) ||
|
|
242
|
+
fs.existsSync(path.join(cDir, 'contact', 'page.js'))
|
|
243
|
+
);
|
|
244
|
+
if (contactExistsOnDisk && !pages.some((p) => p.id === 'contact' || p.route === '/contact')) {
|
|
238
245
|
pages.push({ id: 'contact', label: 'Contact', route: '/contact', required: true });
|
|
239
246
|
}
|
|
240
247
|
|
|
@@ -597,7 +604,7 @@ function getComponentRegistry(importPkg) {
|
|
|
597
604
|
};
|
|
598
605
|
}
|
|
599
606
|
|
|
600
|
-
function initProject(targetInput, options = {}) {
|
|
607
|
+
async function initProject(targetInput, options = {}) {
|
|
601
608
|
const targetDir = path.resolve(process.cwd(), targetInput || '.');
|
|
602
609
|
const pkgPath = path.join(targetDir, 'package.json');
|
|
603
610
|
|
|
@@ -645,7 +652,7 @@ function initProject(targetInput, options = {}) {
|
|
|
645
652
|
conversionRes = runUniversalTemplateConversion(targetDir, projectName, detectedPages, options);
|
|
646
653
|
} else {
|
|
647
654
|
const { runDenebArc } = require('../src/arc/index.cjs');
|
|
648
|
-
conversionRes = runDenebArc(targetDir, projectName, {
|
|
655
|
+
conversionRes = await runDenebArc(targetDir, projectName, {
|
|
649
656
|
...options,
|
|
650
657
|
detectedPages,
|
|
651
658
|
});
|
|
@@ -1048,7 +1055,10 @@ if (command === 'init') {
|
|
|
1048
1055
|
targetInput = arg;
|
|
1049
1056
|
}
|
|
1050
1057
|
}
|
|
1051
|
-
initProject(targetInput, { recipeName, dryRun, explain, legacy, telemetry, aiEnabled, aiDryRun })
|
|
1058
|
+
initProject(targetInput, { recipeName, dryRun, explain, legacy, telemetry, aiEnabled, aiDryRun }).catch((err) => {
|
|
1059
|
+
console.error(`\x1b[31mError:\x1b[0m ${err.message}`);
|
|
1060
|
+
process.exit(1);
|
|
1061
|
+
});
|
|
1052
1062
|
} else if (command === 'create') {
|
|
1053
1063
|
createTemplate(commandArgs[0]);
|
|
1054
1064
|
} else if (command === 'add') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deneb-ui/cli",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.51",
|
|
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.51",
|
|
53
53
|
"@octokit/rest": "^22.0.1",
|
|
54
54
|
"adm-zip": "^0.6.0",
|
|
55
55
|
"dotenv": "^17.4.2",
|
|
@@ -16,6 +16,8 @@ const { runDenebArc } = require('../index.cjs');
|
|
|
16
16
|
const { parseSource } = require('../ast.cjs');
|
|
17
17
|
const { loadFingerprintBoost } = require('../learning.cjs');
|
|
18
18
|
const { classifyActionIntent } = require('../adapters.cjs');
|
|
19
|
+
const { validateGeneratedCode } = require('../ai-agent.cjs');
|
|
20
|
+
const { auditRuntimeIntegrity, healRuntimeIntegrity, runAiEvaluatorPipeline } = require('../ai-evaluator.cjs');
|
|
19
21
|
|
|
20
22
|
test('field-paths recognizes list action CTA keys', () => {
|
|
21
23
|
assert.equal(isListActionCtaKey('preOrderCta'), true);
|
|
@@ -727,7 +729,12 @@ test('classifyActionIntent identifies action keywords and assigns correct fallba
|
|
|
727
729
|
assert.equal(shop?.defaultUrl, '/shop');
|
|
728
730
|
assert.equal(shop?.external, false);
|
|
729
731
|
|
|
730
|
-
const
|
|
732
|
+
const formSubmit = classifyActionIntent('Submit Form', null);
|
|
733
|
+
assert.equal(formSubmit?.action, 'form-submit');
|
|
734
|
+
assert.equal(formSubmit?.defaultUrl, 'https://wa.me/1234567890');
|
|
735
|
+
assert.equal(formSubmit?.external, true);
|
|
736
|
+
|
|
737
|
+
const nonAction = classifyActionIntent('Random Content Text', null);
|
|
731
738
|
assert.equal(nonAction, null);
|
|
732
739
|
});
|
|
733
740
|
|
|
@@ -875,3 +882,460 @@ export function ActionPage() {
|
|
|
875
882
|
assert.deepEqual(errors, []);
|
|
876
883
|
});
|
|
877
884
|
|
|
885
|
+
test('parseSource parses TypeScript interface declarations without experimental syntax errors', () => {
|
|
886
|
+
const tsCode = `
|
|
887
|
+
import React from 'react';
|
|
888
|
+
import { EditableText } from './EditableText';
|
|
889
|
+
|
|
890
|
+
export interface EditableTradeinSectionProps {
|
|
891
|
+
itemPath: string;
|
|
892
|
+
title: string;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
export function EditableTradeinSection({ itemPath, title }: EditableTradeinSectionProps) {
|
|
896
|
+
return (
|
|
897
|
+
<div data-preview-item-path={itemPath}>
|
|
898
|
+
<EditableText as="h2" id={\`\${itemPath}.title\`} data-preview-field-path={\`\${itemPath}.title\`} defaultValue={title} />
|
|
899
|
+
</div>
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
`;
|
|
903
|
+
assert.doesNotThrow(() => {
|
|
904
|
+
parseSource(tsCode, 'EditableTradeinSection.tsx');
|
|
905
|
+
});
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
test('validateGeneratedCode catches data-preview-field-path placed on broad <div> containers', () => {
|
|
909
|
+
const invalidCode = `
|
|
910
|
+
import React from 'react';
|
|
911
|
+
import { EditableText } from './EditableText';
|
|
912
|
+
|
|
913
|
+
export interface EditableCardProps {
|
|
914
|
+
itemPath: string;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
export function EditableCard({ itemPath }: EditableCardProps) {
|
|
918
|
+
return (
|
|
919
|
+
<div data-preview-item-path={itemPath}>
|
|
920
|
+
<div data-preview-field-path={\`\${itemPath}.content\`}>Some broad content</div>
|
|
921
|
+
</div>
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
`;
|
|
925
|
+
const res = validateGeneratedCode(invalidCode, 'EditableCard.tsx');
|
|
926
|
+
assert.equal(res.passed, false);
|
|
927
|
+
assert.ok(res.errors.some((e) => e.includes('cannot be placed on broad <div> content containers')));
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
test('AST transformer wraps literal text inside <div> with <span> instead of placing field path on <div>', () => {
|
|
931
|
+
const code = `
|
|
932
|
+
export function Card() {
|
|
933
|
+
return (
|
|
934
|
+
<div className="card">
|
|
935
|
+
<div className="title-row">In-Store VIP Lab</div>
|
|
936
|
+
</div>
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
`;
|
|
940
|
+
const profile = {
|
|
941
|
+
root: os.tmpdir(),
|
|
942
|
+
framework: 'nextjs',
|
|
943
|
+
router: 'next-app',
|
|
944
|
+
language: 'typescript',
|
|
945
|
+
cssSystems: ['tailwind'],
|
|
946
|
+
hasSrc: true,
|
|
947
|
+
aliasMap: { '@/*': ['src/*'] },
|
|
948
|
+
};
|
|
949
|
+
|
|
950
|
+
const analysis = analyzeFile({
|
|
951
|
+
code,
|
|
952
|
+
relativeFile: 'src/components/Card.tsx',
|
|
953
|
+
profile,
|
|
954
|
+
graph: { sharedFiles: [] },
|
|
955
|
+
ownerScope: 'home',
|
|
956
|
+
componentMeta: { name: 'Card', role: 'card' },
|
|
957
|
+
});
|
|
958
|
+
analysis.code = code;
|
|
959
|
+
analysis.relativeFile = 'src/components/Card.tsx';
|
|
960
|
+
|
|
961
|
+
const plan = planTransformations({ profile, analyses: [analysis] });
|
|
962
|
+
const res = applyFilePlan(plan.files[0], profile);
|
|
963
|
+
assert.equal(res.changed, true);
|
|
964
|
+
// The outer <div> must NOT have data-preview-field-path
|
|
965
|
+
assert.doesNotMatch(res.code, /<div[^>]*data-preview-field-path/);
|
|
966
|
+
// The inner text must be wrapped in a <span> with data-preview-field-path
|
|
967
|
+
assert.match(res.code, /<span[^>]*data-preview-field-path=/);
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
test('AI Evaluator audits and heals RSC duplicate SiteDataProvider in layout.tsx', async () => {
|
|
971
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'deneb-eval-'));
|
|
972
|
+
const appDir = path.join(tmp, 'src', 'app');
|
|
973
|
+
fs.mkdirSync(appDir, { recursive: true });
|
|
974
|
+
|
|
975
|
+
const brokenLayout = `
|
|
976
|
+
import { SiteDataProvider } from '@deneb-ui/ui';
|
|
977
|
+
import { Providers } from '@/components/providers';
|
|
978
|
+
import initialSiteData from '@/data/site-data.json';
|
|
979
|
+
|
|
980
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
981
|
+
return (
|
|
982
|
+
<html lang="en">
|
|
983
|
+
<body>
|
|
984
|
+
<Providers>
|
|
985
|
+
<SiteDataProvider initialSiteData={initialSiteData}>
|
|
986
|
+
{children}
|
|
987
|
+
</SiteDataProvider>
|
|
988
|
+
</Providers>
|
|
989
|
+
</body>
|
|
990
|
+
</html>
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
`;
|
|
994
|
+
fs.writeFileSync(path.join(appDir, 'layout.tsx'), brokenLayout, 'utf8');
|
|
995
|
+
|
|
996
|
+
const profile = {
|
|
997
|
+
root: tmp,
|
|
998
|
+
framework: 'nextjs',
|
|
999
|
+
router: 'next-app',
|
|
1000
|
+
appDir: 'src/app',
|
|
1001
|
+
language: 'typescript',
|
|
1002
|
+
jsxFiles: ['src/app/layout.tsx'],
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
const issues = auditRuntimeIntegrity(tmp, profile);
|
|
1006
|
+
assert.equal(issues.length, 1);
|
|
1007
|
+
assert.equal(issues[0].type, 'rsc-duplicate-provider');
|
|
1008
|
+
|
|
1009
|
+
const healRes = await healRuntimeIntegrity(tmp, profile, issues);
|
|
1010
|
+
assert.equal(healRes.healedCount, 1);
|
|
1011
|
+
assert.equal(healRes.remainingCount, 0);
|
|
1012
|
+
|
|
1013
|
+
const fixed = fs.readFileSync(path.join(appDir, 'layout.tsx'), 'utf8');
|
|
1014
|
+
assert.doesNotMatch(fixed, /<SiteDataProvider/);
|
|
1015
|
+
assert.match(fixed, /<Providers>\s*\{children\}\s*<\/Providers>/);
|
|
1016
|
+
|
|
1017
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
test('AI Evaluator detects missing component exports in page.tsx imports', () => {
|
|
1021
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'deneb-eval-exp-'));
|
|
1022
|
+
const appDir = path.join(tmp, 'src', 'app');
|
|
1023
|
+
const compDir = path.join(tmp, 'src', 'components');
|
|
1024
|
+
fs.mkdirSync(appDir, { recursive: true });
|
|
1025
|
+
fs.mkdirSync(compDir, { recursive: true });
|
|
1026
|
+
|
|
1027
|
+
fs.writeFileSync(
|
|
1028
|
+
path.join(appDir, 'page.tsx'),
|
|
1029
|
+
`import { ProductList, MissingHero } from '@/components/Widgets';\nexport default function Page() { return <div><ProductList /></div>; }`,
|
|
1030
|
+
'utf8'
|
|
1031
|
+
);
|
|
1032
|
+
|
|
1033
|
+
fs.writeFileSync(
|
|
1034
|
+
path.join(compDir, 'Widgets.tsx'),
|
|
1035
|
+
`export function ProductList() { return <div>Products</div>; }`,
|
|
1036
|
+
'utf8'
|
|
1037
|
+
);
|
|
1038
|
+
|
|
1039
|
+
const profile = {
|
|
1040
|
+
root: tmp,
|
|
1041
|
+
framework: 'nextjs',
|
|
1042
|
+
router: 'next-app',
|
|
1043
|
+
appDir: 'src/app',
|
|
1044
|
+
language: 'typescript',
|
|
1045
|
+
jsxFiles: ['src/app/page.tsx', 'src/components/Widgets.tsx'],
|
|
1046
|
+
};
|
|
1047
|
+
|
|
1048
|
+
const issues = auditRuntimeIntegrity(tmp, profile);
|
|
1049
|
+
assert.equal(issues.length, 1);
|
|
1050
|
+
assert.equal(issues[0].type, 'missing-named-export');
|
|
1051
|
+
assert.equal(issues[0].meta?.componentName, 'MissingHero');
|
|
1052
|
+
|
|
1053
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
test('AI Evaluator runAiEvaluatorPipeline passes on clean valid project', async () => {
|
|
1057
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'deneb-eval-clean-'));
|
|
1058
|
+
const appDir = path.join(tmp, 'src', 'app');
|
|
1059
|
+
fs.mkdirSync(appDir, { recursive: true });
|
|
1060
|
+
|
|
1061
|
+
fs.writeFileSync(
|
|
1062
|
+
path.join(appDir, 'layout.tsx'),
|
|
1063
|
+
`import { Providers } from '@/components/providers';\nexport default function RootLayout({ children }: { children: React.ReactNode }) { return <html><body><Providers>{children}</Providers></body></html>; }`,
|
|
1064
|
+
'utf8'
|
|
1065
|
+
);
|
|
1066
|
+
|
|
1067
|
+
const profile = {
|
|
1068
|
+
root: tmp,
|
|
1069
|
+
framework: 'nextjs',
|
|
1070
|
+
router: 'next-app',
|
|
1071
|
+
appDir: 'src/app',
|
|
1072
|
+
language: 'typescript',
|
|
1073
|
+
jsxFiles: ['src/app/layout.tsx'],
|
|
1074
|
+
};
|
|
1075
|
+
|
|
1076
|
+
const res = await runAiEvaluatorPipeline(tmp, profile);
|
|
1077
|
+
assert.equal(res.passed, true);
|
|
1078
|
+
assert.equal(res.issuesFound, 0);
|
|
1079
|
+
|
|
1080
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
test('AI Evaluator audits and heals missing global CSS stylesheet import in layout.tsx', async () => {
|
|
1084
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'deneb-eval-css-'));
|
|
1085
|
+
const appDir = path.join(tmp, 'src', 'app');
|
|
1086
|
+
fs.mkdirSync(appDir, { recursive: true });
|
|
1087
|
+
fs.writeFileSync(path.join(appDir, 'globals.css'), '@import "tailwindcss";', 'utf8');
|
|
1088
|
+
|
|
1089
|
+
const unstyledLayout = `
|
|
1090
|
+
import { Providers } from '@/components/providers';
|
|
1091
|
+
|
|
1092
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
1093
|
+
return <html><body><Providers>{children}</Providers></body></html>;
|
|
1094
|
+
}
|
|
1095
|
+
`;
|
|
1096
|
+
fs.writeFileSync(path.join(appDir, 'layout.tsx'), unstyledLayout, 'utf8');
|
|
1097
|
+
|
|
1098
|
+
const profile = {
|
|
1099
|
+
root: tmp,
|
|
1100
|
+
framework: 'nextjs',
|
|
1101
|
+
router: 'next-app',
|
|
1102
|
+
appDir: 'src/app',
|
|
1103
|
+
language: 'typescript',
|
|
1104
|
+
jsxFiles: ['src/app/layout.tsx'],
|
|
1105
|
+
};
|
|
1106
|
+
|
|
1107
|
+
const issues = auditRuntimeIntegrity(tmp, profile);
|
|
1108
|
+
assert.equal(issues.length, 1);
|
|
1109
|
+
assert.equal(issues[0].type, 'missing-global-css-import');
|
|
1110
|
+
|
|
1111
|
+
const healRes = await healRuntimeIntegrity(tmp, profile, issues);
|
|
1112
|
+
assert.equal(healRes.healedCount, 1);
|
|
1113
|
+
|
|
1114
|
+
const fixed = fs.readFileSync(path.join(appDir, 'layout.tsx'), 'utf8');
|
|
1115
|
+
assert.match(fixed, /import\s+['"]\.\/globals\.css['"]/);
|
|
1116
|
+
|
|
1117
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
test('collections with nested arrays, objects, and TS as const convert to editable list contracts', () => {
|
|
1121
|
+
const code = `
|
|
1122
|
+
const phones = [
|
|
1123
|
+
{
|
|
1124
|
+
name: 'iPhone 16 Pro Max',
|
|
1125
|
+
brand: 'Apple',
|
|
1126
|
+
subtitle: 'Grade 5 Titanium' as const,
|
|
1127
|
+
price: 1199,
|
|
1128
|
+
storageOptions: ['256GB', '512GB', '1TB'],
|
|
1129
|
+
colors: [{ name: 'Desert Titanium', hex: '#bba795' }],
|
|
1130
|
+
},
|
|
1131
|
+
{
|
|
1132
|
+
name: 'Galaxy S25 Ultra',
|
|
1133
|
+
brand: 'Samsung',
|
|
1134
|
+
subtitle: 'Armor Titanium' as const,
|
|
1135
|
+
price: 1299,
|
|
1136
|
+
storageOptions: ['256GB', '512GB'],
|
|
1137
|
+
colors: [{ name: 'Titanium Gray', hex: '#5e6166' }],
|
|
1138
|
+
},
|
|
1139
|
+
];
|
|
1140
|
+
|
|
1141
|
+
export function FeaturedPhones() {
|
|
1142
|
+
return (
|
|
1143
|
+
<div className="phones-grid">
|
|
1144
|
+
{phones.map((phone, idx) => (
|
|
1145
|
+
<div key={phone.name} className="phone-card">
|
|
1146
|
+
<h4>{phone.brand}</h4>
|
|
1147
|
+
<h3>{phone.name}</h3>
|
|
1148
|
+
<p>{phone.subtitle}</p>
|
|
1149
|
+
<span>Rs {phone.price}</span>
|
|
1150
|
+
</div>
|
|
1151
|
+
))}
|
|
1152
|
+
</div>
|
|
1153
|
+
);
|
|
1154
|
+
}
|
|
1155
|
+
`;
|
|
1156
|
+
|
|
1157
|
+
const profile = { framework: 'nextjs', router: 'next-app', appDir: 'src/app', language: 'typescript' };
|
|
1158
|
+
const analysis = analyzeFile({
|
|
1159
|
+
code,
|
|
1160
|
+
relativeFile: 'src/components/FeaturedPhones.tsx',
|
|
1161
|
+
profile,
|
|
1162
|
+
graph: { sharedFiles: [] },
|
|
1163
|
+
ownerScope: 'home',
|
|
1164
|
+
componentMeta: { name: 'FeaturedPhones', role: 'shop' },
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
const collectionCandidate = analysis.candidates.find((c) => c.kind === 'collection');
|
|
1168
|
+
assert.ok(collectionCandidate, 'collection with nested arrays and TS as const must be detected');
|
|
1169
|
+
assert.equal(collectionCandidate.extra.objectItems, true);
|
|
1170
|
+
assert.ok(collectionCandidate.confidence >= 0.8, 'collection must have high confidence');
|
|
1171
|
+
|
|
1172
|
+
const plan = planTransformations({
|
|
1173
|
+
profile,
|
|
1174
|
+
analyses: [analysis],
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
assert.equal(plan.files.length, 1);
|
|
1178
|
+
const collectionTransform = plan.files[0].transformations.find((t) => t.fieldType === 'list');
|
|
1179
|
+
assert.ok(collectionTransform, 'collection must be planned as list field');
|
|
1180
|
+
assert.equal(collectionTransform.listField, 'home.phones');
|
|
1181
|
+
assert.ok(collectionTransform.itemFields.some((f) => f.key === 'name'));
|
|
1182
|
+
assert.ok(collectionTransform.itemFields.some((f) => f.key === 'price'));
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
test('proceed to order button is recognized as WhatsApp order split-action contract', () => {
|
|
1186
|
+
const code = `
|
|
1187
|
+
export function CartDrawer() {
|
|
1188
|
+
return (
|
|
1189
|
+
<div className="cart-footer">
|
|
1190
|
+
<button type="button" className="btn-primary w-full py-4 font-bold">
|
|
1191
|
+
Proceed to Order · RS 1299
|
|
1192
|
+
</button>
|
|
1193
|
+
</div>
|
|
1194
|
+
);
|
|
1195
|
+
}
|
|
1196
|
+
`;
|
|
1197
|
+
|
|
1198
|
+
const profile = { framework: 'nextjs', router: 'next-app', appDir: 'src/app', language: 'typescript' };
|
|
1199
|
+
const analysis = analyzeFile({
|
|
1200
|
+
code,
|
|
1201
|
+
relativeFile: 'src/components/CartDrawer.tsx',
|
|
1202
|
+
profile,
|
|
1203
|
+
graph: { sharedFiles: [] },
|
|
1204
|
+
ownerScope: 'home',
|
|
1205
|
+
componentMeta: { name: 'CartDrawer', role: 'cart' },
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
const actionCandidate = analysis.candidates.find((c) => c.kind === 'split-action-contract');
|
|
1209
|
+
assert.ok(actionCandidate, 'proceed to order must be recognized as split-action-contract');
|
|
1210
|
+
assert.equal(actionCandidate.extra.action, 'whatsapp');
|
|
1211
|
+
|
|
1212
|
+
const plan = planTransformations({
|
|
1213
|
+
profile,
|
|
1214
|
+
analyses: [analysis],
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
const filePlan = plan.files[0];
|
|
1218
|
+
const splitAction = filePlan.transformations.find((t) => t.operation === 'split-action-contract');
|
|
1219
|
+
assert.ok(splitAction, 'split action must be planned');
|
|
1220
|
+
assert.match(splitAction.urlField, /whatsapp/i);
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
test('classifyActionIntent recognizes form submit action keywords', () => {
|
|
1224
|
+
const submit = classifyActionIntent('Submit', null);
|
|
1225
|
+
assert.equal(submit?.action, 'form-submit');
|
|
1226
|
+
assert.equal(submit?.defaultUrl, 'https://wa.me/1234567890');
|
|
1227
|
+
|
|
1228
|
+
const sendMessage = classifyActionIntent('Send Message', null);
|
|
1229
|
+
assert.equal(sendMessage?.action, 'form-submit');
|
|
1230
|
+
|
|
1231
|
+
const confirmBooking = classifyActionIntent('Confirm Booking', null);
|
|
1232
|
+
assert.equal(confirmBooking?.action, 'form-submit');
|
|
1233
|
+
|
|
1234
|
+
const getQuote = classifyActionIntent('Get Quote', null);
|
|
1235
|
+
assert.equal(getQuote?.action, 'form-submit');
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
test('semantic engine recognizes form inputs, labels, placeholders, and form-submit-action buttons', () => {
|
|
1239
|
+
const code = `
|
|
1240
|
+
export function BookingForm() {
|
|
1241
|
+
return (
|
|
1242
|
+
<form className="booking-form">
|
|
1243
|
+
<h2>Book Appointment</h2>
|
|
1244
|
+
<label>Full Name</label>
|
|
1245
|
+
<input type="text" placeholder="Enter your full name" />
|
|
1246
|
+
<label>Email Address</label>
|
|
1247
|
+
<input type="email" placeholder="you@example.com" />
|
|
1248
|
+
<label>Special Instructions</label>
|
|
1249
|
+
<textarea placeholder="Describe your request..." />
|
|
1250
|
+
<button type="submit">Confirm Booking</button>
|
|
1251
|
+
</form>
|
|
1252
|
+
);
|
|
1253
|
+
}
|
|
1254
|
+
`;
|
|
1255
|
+
const profile = {
|
|
1256
|
+
root: os.tmpdir(),
|
|
1257
|
+
framework: 'nextjs',
|
|
1258
|
+
router: 'next-app',
|
|
1259
|
+
language: 'typescript',
|
|
1260
|
+
cssSystems: ['tailwind'],
|
|
1261
|
+
hasSrc: true,
|
|
1262
|
+
};
|
|
1263
|
+
|
|
1264
|
+
const analysis = analyzeFile({
|
|
1265
|
+
code,
|
|
1266
|
+
relativeFile: 'src/components/BookingForm.tsx',
|
|
1267
|
+
profile,
|
|
1268
|
+
graph: { sharedFiles: [] },
|
|
1269
|
+
ownerScope: 'home',
|
|
1270
|
+
componentMeta: { name: 'BookingForm', role: 'form' },
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
const placeholders = analysis.candidates.filter((c) => c.kind === 'placeholder');
|
|
1274
|
+
assert.equal(placeholders.length, 3, 'expected 3 placeholder candidates');
|
|
1275
|
+
|
|
1276
|
+
const labels = analysis.candidates.filter((c) => c.kind === 'text' && c.tag === 'label');
|
|
1277
|
+
assert.equal(labels.length, 3, 'expected 3 label candidates');
|
|
1278
|
+
|
|
1279
|
+
const submitAction = analysis.candidates.find((c) => c.kind === 'form-submit-action');
|
|
1280
|
+
assert.ok(submitAction, 'expected form-submit-action candidate');
|
|
1281
|
+
assert.equal(submitAction.label, 'Confirm Booking');
|
|
1282
|
+
assert.equal(submitAction.extra.action, 'form-submit');
|
|
1283
|
+
assert.equal(submitAction.extra.insideForm, true);
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
test('planner and transformer make entire form editable with submit button and WhatsApp URL binding', () => {
|
|
1287
|
+
const code = `
|
|
1288
|
+
import React from 'react';
|
|
1289
|
+
|
|
1290
|
+
export function ContactSection() {
|
|
1291
|
+
return (
|
|
1292
|
+
<form className="contact-form">
|
|
1293
|
+
<label>Your Name</label>
|
|
1294
|
+
<input type="text" placeholder="John Doe" />
|
|
1295
|
+
<button type="submit">Send Message</button>
|
|
1296
|
+
</form>
|
|
1297
|
+
);
|
|
1298
|
+
}
|
|
1299
|
+
`;
|
|
1300
|
+
const profile = {
|
|
1301
|
+
root: os.tmpdir(),
|
|
1302
|
+
framework: 'nextjs',
|
|
1303
|
+
router: 'next-app',
|
|
1304
|
+
language: 'typescript',
|
|
1305
|
+
cssSystems: ['tailwind'],
|
|
1306
|
+
hasSrc: true,
|
|
1307
|
+
aliasMap: { '@/*': ['src/*'] },
|
|
1308
|
+
};
|
|
1309
|
+
|
|
1310
|
+
const analysis = analyzeFile({
|
|
1311
|
+
code,
|
|
1312
|
+
relativeFile: 'src/components/ContactSection.tsx',
|
|
1313
|
+
profile,
|
|
1314
|
+
graph: { sharedFiles: [] },
|
|
1315
|
+
ownerScope: 'home',
|
|
1316
|
+
componentMeta: { name: 'ContactSection', role: 'contact' },
|
|
1317
|
+
});
|
|
1318
|
+
analysis.code = code;
|
|
1319
|
+
analysis.relativeFile = 'src/components/ContactSection.tsx';
|
|
1320
|
+
|
|
1321
|
+
const plan = planTransformations({
|
|
1322
|
+
profile,
|
|
1323
|
+
analyses: [analysis],
|
|
1324
|
+
});
|
|
1325
|
+
|
|
1326
|
+
const filePlan = plan.files[0];
|
|
1327
|
+
const formSubmit = filePlan.transformations.find((t) => t.operation === 'form-submit-action');
|
|
1328
|
+
assert.ok(formSubmit, 'form-submit-action must be planned');
|
|
1329
|
+
assert.match(formSubmit.urlField, /formWhatsappUrl/i);
|
|
1330
|
+
assert.match(formSubmit.labelField, /formSubmitLabel/i);
|
|
1331
|
+
|
|
1332
|
+
const transformed = applyFilePlan(filePlan, profile);
|
|
1333
|
+
assert.ok(transformed.changed, 'file must be changed');
|
|
1334
|
+
assert.match(transformed.code, /data-preview-field-path/);
|
|
1335
|
+
assert.match(transformed.code, /formSubmitLabel/);
|
|
1336
|
+
assert.match(transformed.code, /formWhatsappUrl/);
|
|
1337
|
+
assert.match(transformed.code, /type="submit"/);
|
|
1338
|
+
assert.match(transformed.code, /hidden/);
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
|
package/src/arc/adapters.cjs
CHANGED
|
@@ -23,6 +23,8 @@ const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'Heading', 'Ti
|
|
|
23
23
|
const TEXT_TAGS = new Set(['p', 'span', 'li', 'blockquote', 'figcaption', 'label', 'CardDescription', 'Description', 'Subtitle', 'Typography', 'Text', 'Badge', 'badge']);
|
|
24
24
|
const ACTION_TAGS = new Set(['a', 'Link', 'NavLink', 'Button', 'button', 'IconButton', 'NavbarBrand']);
|
|
25
25
|
const IMAGE_TAGS = new Set(['img', 'Image', 'Img', 'BackgroundImage']);
|
|
26
|
+
const FORM_INPUT_TAGS = new Set(['input', 'Input', 'textarea', 'Textarea', 'select', 'Select']);
|
|
27
|
+
const FORM_CONTAINER_TAGS = new Set(['form', 'Form']);
|
|
26
28
|
|
|
27
29
|
const shadcn = createAdapter('shadcn', {
|
|
28
30
|
detect: (project) => detectFromProfile(project, 'shadcn') || project.shadcn,
|
|
@@ -106,6 +108,9 @@ const deneb = createAdapter('deneb', {
|
|
|
106
108
|
Boolean(project.dependencies && project.dependencies['@deneb-ui/ui']),
|
|
107
109
|
recognizeNode(node) {
|
|
108
110
|
const name = getJsxName(node);
|
|
111
|
+
if (['EditableContactForm', 'ContactForm'].includes(name)) {
|
|
112
|
+
return { library: 'deneb', kind: 'form', tag: name };
|
|
113
|
+
}
|
|
109
114
|
if (['EditableGoogleFeedback', 'EditableCustomerReviews', 'CustomerReviews', 'GoogleFeedback'].includes(name)) {
|
|
110
115
|
return { library: 'deneb', kind: 'feedback', tag: name };
|
|
111
116
|
}
|
|
@@ -192,8 +197,24 @@ function classifyActionIntent(text, href) {
|
|
|
192
197
|
const t = String(text || '').trim().toLowerCase();
|
|
193
198
|
const h = String(href || '').trim().toLowerCase();
|
|
194
199
|
|
|
200
|
+
// 0. Form Submit / WhatsApp Form Action
|
|
201
|
+
if (
|
|
202
|
+
/^(submit|send\s*message|send\s*inquiry|confirm\s*booking|place\s*order|book\s*now|confirm|get\s*quote|request\s*quote|send\s*request|schedule|register|sign\s*up|subscribe|apply|enroll)$/i.test(t) ||
|
|
203
|
+
/\b(?:send\s*message|confirm\s*booking|submit\s*form|submit\s*inquiry|send\s*inquiry|request\s*quote|get\s*quote|send\s*request)\b/i.test(t)
|
|
204
|
+
) {
|
|
205
|
+
return {
|
|
206
|
+
action: 'form-submit',
|
|
207
|
+
defaultUrl: /^https?:\/\//i.test(h) && !h.includes('#') ? href : 'https://wa.me/1234567890',
|
|
208
|
+
external: true,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
195
212
|
// 1. WhatsApp
|
|
196
|
-
if (
|
|
213
|
+
if (
|
|
214
|
+
/wa\.me|whatsapp/i.test(h) ||
|
|
215
|
+
/\b(?:whatsapp|wa\.me)\b/i.test(t) ||
|
|
216
|
+
/order on whatsapp|chat on whatsapp|message on whatsapp|whatsapp order|order via whatsapp|proceed to order|proceed to checkout|complete order|confirm priority repair|book repair|schedule repair|confirm repair/i.test(t)
|
|
217
|
+
) {
|
|
197
218
|
return {
|
|
198
219
|
action: 'whatsapp',
|
|
199
220
|
defaultUrl: /^https?:\/\//i.test(h) && !h.includes('#') ? href : 'https://wa.me/1234567890',
|
|
@@ -276,6 +297,8 @@ module.exports = {
|
|
|
276
297
|
TEXT_TAGS,
|
|
277
298
|
ACTION_TAGS,
|
|
278
299
|
IMAGE_TAGS,
|
|
300
|
+
FORM_INPUT_TAGS,
|
|
301
|
+
FORM_CONTAINER_TAGS,
|
|
279
302
|
collectJsxText,
|
|
280
303
|
getJsxAttributeLiteral,
|
|
281
304
|
};
|
package/src/arc/ai-agent.cjs
CHANGED
|
@@ -167,6 +167,12 @@ function validateGeneratedCode(code, filename) {
|
|
|
167
167
|
errors.push('Missing data-preview-field-path markers — the component has no editable fields.');
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
// 2b. data-preview-field-path cannot be placed on broad containers
|
|
171
|
+
const broadMatch = code.match(/<(div|section|article|aside|header|footer|nav|main|form|ul|ol|table|thead|tbody|tr)\b[^>]*\bdata-preview-field-path\s*=/i);
|
|
172
|
+
if (broadMatch) {
|
|
173
|
+
errors.push(`data-preview-field-path cannot be placed on broad <${broadMatch[1]}> content containers. Put the marker on the exact visible text, media, link, or control element (like <span>, <p>, <h1>, <a>, <button>, or <EditableText>).`);
|
|
174
|
+
}
|
|
175
|
+
|
|
170
176
|
// 3. Must have data-preview-item-path on the root wrapper
|
|
171
177
|
if (!code.includes('data-preview-item-path')) {
|
|
172
178
|
errors.push('Missing data-preview-item-path on root wrapper element.');
|