accel2spa 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +161 -2
- package/dist/analyze.d.ts +7 -0
- package/dist/analyze.js +401 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +113 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +39 -0
- package/dist/model.d.ts +179 -0
- package/dist/model.js +2 -0
- package/dist/occ/endpoints.json +15 -0
- package/dist/occ/fields.json +187 -0
- package/dist/occ/reference.d.ts +38 -0
- package/dist/occ/reference.js +104 -0
- package/dist/renderers/spec.d.ts +4 -0
- package/dist/renderers/spec.js +264 -0
- package/dist/routes.d.ts +6 -0
- package/dist/routes.js +19 -0
- package/dist/scanners/cms.d.ts +29 -0
- package/dist/scanners/cms.js +110 -0
- package/dist/scanners/js.d.ts +22 -0
- package/dist/scanners/js.js +213 -0
- package/dist/scanners/jsp.d.ts +87 -0
- package/dist/scanners/jsp.js +394 -0
- package/dist/scanners/properties.d.ts +15 -0
- package/dist/scanners/properties.js +54 -0
- package/dist/util.d.ts +12 -0
- package/dist/util.js +61 -0
- package/package.json +48 -4
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/** Reference of what the OCC API exposes, used to detect data and endpoint gaps. */
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
const readJson = (rel) => JSON.parse(readFileSync(new URL(rel, import.meta.url), 'utf8'));
|
|
4
|
+
export function loadOccFields(overridePath) {
|
|
5
|
+
const bundled = readJson('./fields.json');
|
|
6
|
+
const endpoints = readJson('./endpoints.json').endpoints;
|
|
7
|
+
const fields = { rootVars: { ...bundled.rootVars }, types: structuredClone(bundled.types), endpoints: [...endpoints] };
|
|
8
|
+
if (!overridePath)
|
|
9
|
+
return { fields, source: 'bundled' };
|
|
10
|
+
const override = JSON.parse(readFileSync(overridePath, 'utf8'));
|
|
11
|
+
Object.assign(fields.rootVars, override.rootVars ?? {});
|
|
12
|
+
for (const [name, def] of Object.entries(override.types ?? {})) {
|
|
13
|
+
fields.types[name] = { ...(fields.types[name] ?? {}), ...def };
|
|
14
|
+
}
|
|
15
|
+
if (override.endpoints)
|
|
16
|
+
fields.endpoints.unshift(...override.endpoints);
|
|
17
|
+
return { fields, source: `bundled + ${overridePath}` };
|
|
18
|
+
}
|
|
19
|
+
const PRIMITIVES = new Set(['string', 'number', 'boolean', 'date', 'object', 'any']);
|
|
20
|
+
export class OccReference {
|
|
21
|
+
fields;
|
|
22
|
+
constructor(fields) {
|
|
23
|
+
this.fields = fields;
|
|
24
|
+
}
|
|
25
|
+
lookup(typeName, field) {
|
|
26
|
+
const seen = new Set();
|
|
27
|
+
let t = typeName;
|
|
28
|
+
while (t && !seen.has(t)) {
|
|
29
|
+
seen.add(t);
|
|
30
|
+
const def = this.fields.types[t];
|
|
31
|
+
if (!def)
|
|
32
|
+
return undefined;
|
|
33
|
+
if (field in def && field !== '$extends')
|
|
34
|
+
return def[field];
|
|
35
|
+
t = def.$extends;
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
hasType(name) {
|
|
40
|
+
return name in this.fields.types;
|
|
41
|
+
}
|
|
42
|
+
/** Type of an EL root variable, following `forEach`/`set` aliases and tag attribute types. */
|
|
43
|
+
rootType(root, aliases, overrides, depth = 0) {
|
|
44
|
+
if (overrides[root])
|
|
45
|
+
return overrides[root];
|
|
46
|
+
const a = aliases[root];
|
|
47
|
+
if (a) {
|
|
48
|
+
if (a.ignore)
|
|
49
|
+
return undefined;
|
|
50
|
+
if (a.declaredType) {
|
|
51
|
+
const stripped = a.declaredType.replace(/Data$/, '');
|
|
52
|
+
if (this.hasType(stripped))
|
|
53
|
+
return stripped;
|
|
54
|
+
if (this.hasType(a.declaredType))
|
|
55
|
+
return a.declaredType;
|
|
56
|
+
}
|
|
57
|
+
if (a.from && depth < 6) {
|
|
58
|
+
const r = this.checkPath(a.from, aliases, overrides, depth + 1);
|
|
59
|
+
if (r.status === 'exposed' && r.resultType)
|
|
60
|
+
return a.element ? r.resultType.replace(/\[\]$/, '') : r.resultType;
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return this.fields.rootVars[root];
|
|
65
|
+
}
|
|
66
|
+
checkPath(path, aliases, overrides, depth = 0) {
|
|
67
|
+
const [root, ...segs] = path.split('.');
|
|
68
|
+
const rootType = this.rootType(root, aliases, overrides, depth);
|
|
69
|
+
if (!rootType)
|
|
70
|
+
return { status: 'unchecked' };
|
|
71
|
+
let current = rootType;
|
|
72
|
+
for (const seg of segs) {
|
|
73
|
+
const base = current.replace(/\[\]$/, '');
|
|
74
|
+
if (PRIMITIVES.has(base))
|
|
75
|
+
return { status: 'exposed', occType: rootType, resultType: base };
|
|
76
|
+
if (current.endsWith('[]') && ['size', 'length', 'empty'].includes(seg)) {
|
|
77
|
+
return { status: 'exposed', occType: rootType, resultType: 'number' };
|
|
78
|
+
}
|
|
79
|
+
if (!this.hasType(base))
|
|
80
|
+
return { status: 'unchecked', occType: rootType };
|
|
81
|
+
const next = this.lookup(base, seg);
|
|
82
|
+
if (next === undefined)
|
|
83
|
+
return { status: 'missing', occType: rootType, missingField: seg };
|
|
84
|
+
current = next;
|
|
85
|
+
}
|
|
86
|
+
return { status: 'exposed', occType: rootType, resultType: current };
|
|
87
|
+
}
|
|
88
|
+
/** Which OCC endpoint replaces this storefront URL, if known. */
|
|
89
|
+
matchEndpoint(url, method) {
|
|
90
|
+
for (const e of this.fields.endpoints) {
|
|
91
|
+
if (e.method && method !== 'ANY' && e.method.toUpperCase() !== method.toUpperCase())
|
|
92
|
+
continue;
|
|
93
|
+
const re = new RegExp('^' +
|
|
94
|
+
e.accelerator
|
|
95
|
+
.split('/')
|
|
96
|
+
.map((seg) => (seg === '**' ? '.*' : seg === '{param}' || /^\{.+\}$/.test(seg) ? '[^/]+' : seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
|
|
97
|
+
.join('/') +
|
|
98
|
+
'/?$');
|
|
99
|
+
if (re.test(url))
|
|
100
|
+
return e.occ;
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/** Renders one Markdown spec (YAML frontmatter + 9 sections) per page/component. */
|
|
2
|
+
import { stringify } from 'yaml';
|
|
3
|
+
const ref = (r) => `\`${r.file}:${r.line}\``;
|
|
4
|
+
const cell = (s) => (s ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
|
|
5
|
+
const code = (s) => '`' + s.replace(/`/g, "'") + '`';
|
|
6
|
+
const refList = (refs, max = 2) => refs.slice(0, max).map(ref).join(', ') + (refs.length > max ? ` (+${refs.length - max} more)` : '');
|
|
7
|
+
const uniq = (a) => [...new Set(a)];
|
|
8
|
+
function pascal(url) {
|
|
9
|
+
const words = url
|
|
10
|
+
.split(/[/{}\-_.]+/)
|
|
11
|
+
.filter((w) => w && w !== 'param')
|
|
12
|
+
.map((w) => w[0].toUpperCase() + w.slice(1));
|
|
13
|
+
return words.join('') || 'Endpoint';
|
|
14
|
+
}
|
|
15
|
+
/** Guess a TS type + mock value from a field name (purely heuristic). */
|
|
16
|
+
function guessField(name) {
|
|
17
|
+
if (/^(is|has|can|show|enable)|enabled$|active$|requested$/i.test(name))
|
|
18
|
+
return { ts: 'boolean', mock: 'false' };
|
|
19
|
+
if (/(count|total|points|qty|quantity|amount|balance|price|value|number|days|left|width|height|size)$/i.test(name))
|
|
20
|
+
return { ts: 'number', mock: '0' };
|
|
21
|
+
if (/(date|time|expires|expiry|at|window)$/i.test(name))
|
|
22
|
+
return { ts: 'string', mock: "'2030-01-01T00:00:00Z'" };
|
|
23
|
+
if (/(list|items|entries|links)$/i.test(name))
|
|
24
|
+
return { ts: 'unknown[]', mock: '[]' };
|
|
25
|
+
return { ts: 'string', mock: "''" };
|
|
26
|
+
}
|
|
27
|
+
function renderTree(node, depth = 0) {
|
|
28
|
+
const loc = node.line ? ` (included at ${ref({ file: node.file, line: node.line })})` : '';
|
|
29
|
+
const rep = node.repeated ? ' (already listed above)' : '';
|
|
30
|
+
const out = [`${' '.repeat(depth)}- ${code(node.name)} -> ${code(node.file)}${depth ? loc.replace(/ \(included at .*\)/, '') : ''}${rep}`];
|
|
31
|
+
for (const c of node.children)
|
|
32
|
+
out.push(...renderTree(c, depth + 1));
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
function describeCondition(c) {
|
|
36
|
+
if (c.kind === 'otherwise')
|
|
37
|
+
return 'in every other case (c:otherwise)';
|
|
38
|
+
return `when ${code(c.test ?? '?')}`;
|
|
39
|
+
}
|
|
40
|
+
function endpointInterfaces(e) {
|
|
41
|
+
const base = pascal(e.url);
|
|
42
|
+
const lines = [];
|
|
43
|
+
if (e.requestFields.length) {
|
|
44
|
+
lines.push(`export interface ${base}Request {`);
|
|
45
|
+
for (const f of e.requestFields)
|
|
46
|
+
lines.push(` ${f}: ${guessField(f).ts};`);
|
|
47
|
+
lines.push('}', '');
|
|
48
|
+
}
|
|
49
|
+
lines.push(`export interface ${base}Response {`);
|
|
50
|
+
if (e.responseFields.length)
|
|
51
|
+
for (const f of e.responseFields)
|
|
52
|
+
lines.push(` ${f}: ${guessField(f).ts};`);
|
|
53
|
+
else
|
|
54
|
+
lines.push(' // TODO: no response fields detected in the source; inspect the real payload.');
|
|
55
|
+
lines.push('}', '');
|
|
56
|
+
const reqParam = e.requestFields.length ? `request: ${base}Request` : '';
|
|
57
|
+
lines.push(`export abstract class ${base}Adapter {`, ` abstract call(${reqParam}): Observable<${base}Response>; // ${e.method} ${e.url}`, '}', '', `export class Mock${base}Adapter extends ${base}Adapter {`, ` call(${reqParam}): Observable<${base}Response> {`, ` return of({ ${e.responseFields.map((f) => `${f}: ${guessField(f).mock}`).join(', ')} });`, ' }', '}');
|
|
58
|
+
return lines.join('\n');
|
|
59
|
+
}
|
|
60
|
+
export function renderSpec(unit, ctx) {
|
|
61
|
+
const missingData = unit.data.filter((d) => d.status === 'missing');
|
|
62
|
+
const missingEndpoints = unit.endpoints.filter((e) => e.status === 'missing');
|
|
63
|
+
const langs = ctx.languages.map((l) => l.lang);
|
|
64
|
+
const hasCoverage = langs.length > 0;
|
|
65
|
+
const refLang = ctx.referenceLanguage;
|
|
66
|
+
const frontmatter = stringify({
|
|
67
|
+
id: unit.id,
|
|
68
|
+
kind: unit.kind,
|
|
69
|
+
source: unit.files,
|
|
70
|
+
route: unit.route,
|
|
71
|
+
complexity: unit.complexity.level,
|
|
72
|
+
dependsOn: unit.dependsOn,
|
|
73
|
+
gaps: unit.gaps.map((g) => g.id)
|
|
74
|
+
}).trimEnd();
|
|
75
|
+
const L = [];
|
|
76
|
+
L.push('---', frontmatter, '---', '', `# Migration spec: ${unit.id}`, '');
|
|
77
|
+
// 1. Context
|
|
78
|
+
L.push('## 1. Context', '');
|
|
79
|
+
if (unit.kind === 'page') {
|
|
80
|
+
L.push(`Accelerator page ${code(unit.file)} from the \`${ctx.storefront}\` storefront. It composes ${unit.tagIncludes.length} tag include(s) across ${unit.files.length} source file(s). Target: a Spartacus page at ${code(unit.route)}${unit.routeSource === 'derived' ? ' (route derived from the file name; confirm it)' : ''}.`);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
L.push(`CMS component ${unit.cmsType ? code(unit.cmsType) : ''} rendered by ${code(unit.file)} from the \`${ctx.storefront}\` storefront. Target: a Spartacus CMS component mapped through \`cmsComponents\` config.`);
|
|
84
|
+
}
|
|
85
|
+
L.push('');
|
|
86
|
+
L.push(`Detected: ${unit.conditionals.length} conditional(s), ${unit.i18n.length} i18n key(s), ${missingData.length} OCC data gap(s), ${missingEndpoints.length} endpoint gap(s), ${unit.hardcoded.length} hardcoded string(s).`, `Complexity: **${unit.complexity.level}** (score ${unit.complexity.score}; heuristic: 2 per conditional and data gap, 3 per missing endpoint, 1 per tag file, 0.5 per missing translation or hardcoded string).`);
|
|
87
|
+
if (unit.cmsSlots.length)
|
|
88
|
+
L.push('', `CMS slots rendered: ${unit.cmsSlots.map((s) => `${code(s.position)} (${ref(s)})`).join(', ')}.`);
|
|
89
|
+
L.push('');
|
|
90
|
+
// 2. Pointers
|
|
91
|
+
L.push('## 2. Pointers, not copies', '', 'Read these in the original source; this spec intentionally does not inline it.', '');
|
|
92
|
+
for (const f of unit.files)
|
|
93
|
+
L.push(`- ${code(f)}`);
|
|
94
|
+
if (unit.tagIncludes.length) {
|
|
95
|
+
L.push('', 'Tag includes:');
|
|
96
|
+
for (const t of unit.tagIncludes)
|
|
97
|
+
L.push(`- ${code(t.name)} at ${ref(t)}${t.tagFile ? ` -> ${code(t.tagFile)}` : ' (tag file not found)'}`);
|
|
98
|
+
}
|
|
99
|
+
if (ctx.languages.length) {
|
|
100
|
+
L.push('', 'Message bundles:');
|
|
101
|
+
for (const l of ctx.languages)
|
|
102
|
+
L.push(`- ${code(l.file)} (${l.lang}, ${l.keyCount} keys)`);
|
|
103
|
+
}
|
|
104
|
+
L.push('');
|
|
105
|
+
// 3. Structure & conditional rules
|
|
106
|
+
L.push('## 3. Structure and conditional rules', '', 'Component tree:', '', ...renderTree(unit.tree), '');
|
|
107
|
+
if (unit.conditionals.length) {
|
|
108
|
+
L.push('| # | Where | Controls | Rule |', '|---|-------|----------|------|');
|
|
109
|
+
unit.conditionals.forEach((c, i) => {
|
|
110
|
+
const group = c.chooseGroup ? ` (branch of choose ${c.chooseGroup})` : '';
|
|
111
|
+
L.push(`| ${i + 1} | ${ref(c)}-${c.endLine} | ${c.layout} ${cell(c.label)}${cell(group)} | ${cell(describeCondition(c))} |`);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
L.push('No conditional logic detected.');
|
|
116
|
+
}
|
|
117
|
+
L.push('');
|
|
118
|
+
// 4. i18n
|
|
119
|
+
L.push('## 4. i18n', '');
|
|
120
|
+
if (unit.i18n.length) {
|
|
121
|
+
L.push(`| Key | ${refLang ? `Text (${refLang})` : 'Text'} | Missing in | Used at |`, '|-----|------|------------|---------|');
|
|
122
|
+
for (const u of unit.i18n) {
|
|
123
|
+
const text = (refLang && u.texts[refLang]) || u.defaultText || Object.values(u.texts)[0] || '(not defined)';
|
|
124
|
+
L.push(`| ${code(u.key)} | ${cell(text)} | ${u.missingIn.length ? u.missingIn.join(', ') : '-'} | ${u.refs.map(ref).join(', ')} |`);
|
|
125
|
+
}
|
|
126
|
+
L.push('');
|
|
127
|
+
if (hasCoverage) {
|
|
128
|
+
L.push('Coverage:', '');
|
|
129
|
+
for (const c of unit.i18nCoverage)
|
|
130
|
+
L.push(`- ${c.lang}: ${c.present}/${c.total} keys`);
|
|
131
|
+
L.push('');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
L.push('No i18n keys used.', '');
|
|
136
|
+
}
|
|
137
|
+
if (unit.dynamicI18n.length) {
|
|
138
|
+
L.push('Dynamic keys (cannot be checked statically):', '');
|
|
139
|
+
for (const d of unit.dynamicI18n)
|
|
140
|
+
L.push(`- ${code(d.expression)} at ${ref(d)}`);
|
|
141
|
+
L.push('');
|
|
142
|
+
}
|
|
143
|
+
if (unit.hardcoded.length) {
|
|
144
|
+
L.push('Hardcoded strings (must become translation keys):', '');
|
|
145
|
+
for (const h of unit.hardcoded)
|
|
146
|
+
L.push(`- ${ref(h)}: "${cell(h.text)}"${h.attribute ? ` (attribute ${h.attribute})` : ''}`);
|
|
147
|
+
L.push('');
|
|
148
|
+
}
|
|
149
|
+
// 5. Data & endpoints
|
|
150
|
+
L.push('## 5. Data and endpoints', '');
|
|
151
|
+
L.push(`OCC reference: ${code('src/occ/fields.json')} (an approximation; verify against your OCC API docs).`, '');
|
|
152
|
+
const checked = unit.data.filter((d) => d.status !== 'unchecked');
|
|
153
|
+
if (checked.length) {
|
|
154
|
+
L.push('| Expression | OCC type | OCC exposes it? | Used at |', '|------------|----------|-----------------|---------|');
|
|
155
|
+
for (const d of checked) {
|
|
156
|
+
const status = d.status === 'missing' ? `**NO** (gap: \`${d.missingField}\`)` : 'yes';
|
|
157
|
+
L.push(`| ${code(d.path)} | ${d.occType ?? '-'} | ${status} | ${refList(d.refs)} |`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
L.push('No model data with a known OCC type was detected.');
|
|
162
|
+
}
|
|
163
|
+
L.push('');
|
|
164
|
+
const unchecked = unit.data.filter((d) => d.status === 'unchecked');
|
|
165
|
+
if (unchecked.length)
|
|
166
|
+
L.push(`Not verified (unknown root variable): ${unchecked.map((d) => code(d.path)).join(', ')}`, '');
|
|
167
|
+
if (unit.endpoints.length) {
|
|
168
|
+
L.push('| Call | Status | Used at |', '|------|--------|---------|');
|
|
169
|
+
for (const e of unit.endpoints) {
|
|
170
|
+
const status = e.status === 'missing' ? '**no OCC equivalent**' : e.status === 'mapped' ? `OCC: ${code(e.occEquivalent ?? '')}` : 'external';
|
|
171
|
+
L.push(`| ${code(`${e.method} ${e.url}`)} (${e.kind}) | ${status} | ${e.refs.map(ref).join(', ')} |`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
L.push('No AJAX/fetch/form endpoints detected.');
|
|
176
|
+
}
|
|
177
|
+
L.push('');
|
|
178
|
+
// 6. Acceptance criteria
|
|
179
|
+
L.push('## 6. Verifiable acceptance criteria', '');
|
|
180
|
+
for (const c of unit.conditionals) {
|
|
181
|
+
if (c.kind === 'otherwise') {
|
|
182
|
+
L.push(`- [ ] The ${c.layout} at ${ref(c)} is rendered only when none of the other branches of its choose block matches.`);
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
L.push(`- [ ] The ${c.layout} ${c.label} (${ref(c)}) is rendered only ${describeCondition(c)}, and is absent from the DOM otherwise.`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
for (const lang of langs) {
|
|
189
|
+
const missing = unit.i18n.filter((u) => u.missingIn.includes(lang));
|
|
190
|
+
if (missing.length)
|
|
191
|
+
L.push(`- [ ] Add the ${missing.length} missing key(s) to ${lang}: ${missing.map((m) => code(m.key)).join(', ')}.`);
|
|
192
|
+
}
|
|
193
|
+
if (hasCoverage)
|
|
194
|
+
L.push(`- [ ] All ${unit.i18n.length} keys of this ${unit.kind} exist in every language (${langs.join(', ')}); a build check fails otherwise.`);
|
|
195
|
+
L.push(`- [ ] No literal user-facing strings in the Spartacus template${unit.hardcoded.length ? ` (${unit.hardcoded.length} in the source today)` : ''}.`);
|
|
196
|
+
for (const d of missingData)
|
|
197
|
+
L.push(`- [ ] ${code(d.path)} renders from an agreed OCC field or a documented extension, never empty because the backend omits it.`);
|
|
198
|
+
for (const e of missingEndpoints)
|
|
199
|
+
L.push(`- [ ] ${code(`${e.method} ${e.url}`)} is consumed through a typed adapter; switching from mock to real is a provider change only.`);
|
|
200
|
+
for (const e of unit.endpoints.filter((x) => x.status === 'mapped'))
|
|
201
|
+
L.push(`- [ ] ${code(`${e.method} ${e.url}`)} is replaced by ${code(e.occEquivalent ?? '')}.`);
|
|
202
|
+
L.push('');
|
|
203
|
+
// 7. Visual parity
|
|
204
|
+
L.push('## 7. Visual parity', '');
|
|
205
|
+
if (unit.kind === 'page') {
|
|
206
|
+
L.push(`- Current URL: ${unit.currentUrl ? code(unit.currentUrl) : 'unknown (confirm in the running Accelerator storefront)'}`);
|
|
207
|
+
L.push(`- Spartacus route: ${code(unit.route)}`);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
L.push('- Compare the component inside the same CMS slot on the current and the new storefront.');
|
|
211
|
+
}
|
|
212
|
+
L.push('- States to compare side by side:');
|
|
213
|
+
L.push(' - [ ] Empty (no data / condition false)', ' - [ ] Loading / skeleton', ' - [ ] Error (failed request)', ' - [ ] Filled (every conditional branch above, at least once)', '');
|
|
214
|
+
// 8. Blockers
|
|
215
|
+
L.push('## 8. Blockers', '');
|
|
216
|
+
if (!missingData.length && !missingEndpoints.length) {
|
|
217
|
+
L.push('No backend dependency detected.', '');
|
|
218
|
+
}
|
|
219
|
+
if (missingData.length) {
|
|
220
|
+
L.push('### OCC data gaps (backend work)', '');
|
|
221
|
+
const byType = new Map();
|
|
222
|
+
for (const d of missingData) {
|
|
223
|
+
const t = d.occType ?? 'Unknown';
|
|
224
|
+
byType.set(t, [...(byType.get(t) ?? []), d]);
|
|
225
|
+
}
|
|
226
|
+
for (const [type, list] of byType) {
|
|
227
|
+
const fields = uniq(list.map((d) => d.path.split('.').slice(1).join('.')));
|
|
228
|
+
L.push(`${type}: expose ${fields.map(code).join(', ')} in OCC (DTO + field mapping), or agree on an extension payload.`, '');
|
|
229
|
+
for (const d of list)
|
|
230
|
+
if (d.note)
|
|
231
|
+
L.push(`- ${code(d.path)}: ${d.note}`);
|
|
232
|
+
L.push('', 'Suggested typed shape + mock while the backend is pending (field types are guesses):', '', '```ts');
|
|
233
|
+
L.push(`export interface ${type}Extension {`);
|
|
234
|
+
for (const f of uniq(list.map((d) => d.path.split('.')[1])))
|
|
235
|
+
L.push(` ${f}?: ${guessField(f).ts};`);
|
|
236
|
+
L.push('}', '```', '');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (missingEndpoints.length) {
|
|
240
|
+
L.push('### Missing endpoints', '');
|
|
241
|
+
for (const e of missingEndpoints) {
|
|
242
|
+
L.push(`${code(`${e.method} ${e.url}`)} (${e.refs.map(ref).join(', ')}): no OCC equivalent known. Suggested typed interface and mock adapter (shape inferred from the calling code; verify against the real response):`, '', '```ts', "import { Observable, of } from 'rxjs';", '', endpointInterfaces(e), '```', '');
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// 9. Tasks
|
|
246
|
+
L.push('## 9. Tasks', '');
|
|
247
|
+
L.push(`- [ ] Create the Spartacus ${unit.kind === 'page' ? `page at ${code(unit.route)}` : 'CMS component and register it in the config'}.`);
|
|
248
|
+
L.push(`- [ ] Port the structure from section 3 (${unit.tagIncludes.length} tag include(s) become Angular components or template fragments).`);
|
|
249
|
+
if (unit.conditionals.length)
|
|
250
|
+
L.push(`- [ ] Implement the ${unit.conditionals.length} conditional rule(s) from section 3 and cover each with a test.`);
|
|
251
|
+
L.push(`- [ ] Move the ${unit.i18n.length} translation key(s) to the Spartacus i18n resources in every language${unit.hardcoded.length ? `; replace the ${unit.hardcoded.length} hardcoded string(s)` : ''}.`);
|
|
252
|
+
for (const d of missingData)
|
|
253
|
+
L.push(`- [ ] Backend: expose ${code(d.path)} through OCC (or agree a fallback).`);
|
|
254
|
+
for (const e of missingEndpoints)
|
|
255
|
+
L.push(`- [ ] Backend: provide ${code(`${e.method} ${e.url}`)}; frontend: typed interface + mock adapter first.`);
|
|
256
|
+
L.push('- [ ] Visual parity review for the states in section 7.');
|
|
257
|
+
if (unit.warnings.length) {
|
|
258
|
+
L.push('', '## Analyzer warnings', '');
|
|
259
|
+
for (const w of unit.warnings)
|
|
260
|
+
L.push(`- ${w}`);
|
|
261
|
+
}
|
|
262
|
+
L.push('');
|
|
263
|
+
return L.join('\n');
|
|
264
|
+
}
|
package/dist/routes.d.ts
ADDED
package/dist/routes.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Known mapping from Accelerator page ids to current URL and the suggested Spartacus route. */
|
|
2
|
+
// Order matters: first match wins.
|
|
3
|
+
const RULES = [
|
|
4
|
+
{ match: /order-detail/, current: '/my-account/order/{orderCode}', route: '/my-account/order/:orderCode' },
|
|
5
|
+
{ match: /order-history|^orders?$|account-orders/, current: '/my-account/orders', route: '/my-account/orders' },
|
|
6
|
+
{ match: /^cart$|cart-page/, current: '/cart', route: '/cart' },
|
|
7
|
+
{ match: /checkout/, current: '/checkout', route: '/checkout' },
|
|
8
|
+
{ match: /product-detail/, current: '/{productName}/p/{productCode}', route: '/product/:productCode' },
|
|
9
|
+
{ match: /^search/, current: '/search?text={query}', route: '/search/:query' },
|
|
10
|
+
{ match: /^(home|homepage)$/, current: '/', route: '/' },
|
|
11
|
+
{ match: /^login/, current: '/login', route: '/login' }
|
|
12
|
+
];
|
|
13
|
+
export function suggestRoute(pageId) {
|
|
14
|
+
for (const r of RULES) {
|
|
15
|
+
if (r.match.test(pageId))
|
|
16
|
+
return { currentUrl: r.current, route: r.route, routeSource: 'known' };
|
|
17
|
+
}
|
|
18
|
+
return { route: '/' + pageId, routeSource: 'derived' };
|
|
19
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { CmsComponentType } from '../model.js';
|
|
2
|
+
export interface ImpexRecordSet {
|
|
3
|
+
mode: string;
|
|
4
|
+
type: string;
|
|
5
|
+
columns: string[];
|
|
6
|
+
rows: Array<{
|
|
7
|
+
values: string[];
|
|
8
|
+
line: number;
|
|
9
|
+
}>;
|
|
10
|
+
file: string;
|
|
11
|
+
line: number;
|
|
12
|
+
}
|
|
13
|
+
/** Parse ImpEx headers and rows. Macros, comments and malformed lines are skipped. */
|
|
14
|
+
export declare function parseImpex(text: string, file: string): ImpexRecordSet[];
|
|
15
|
+
export interface ItemTypeDef {
|
|
16
|
+
code: string;
|
|
17
|
+
extends?: string;
|
|
18
|
+
attributes: Array<{
|
|
19
|
+
name: string;
|
|
20
|
+
type?: string;
|
|
21
|
+
line: number;
|
|
22
|
+
}>;
|
|
23
|
+
file: string;
|
|
24
|
+
line: number;
|
|
25
|
+
}
|
|
26
|
+
/** Parse `<itemtype>` definitions from a `*-items.xml` file. */
|
|
27
|
+
export declare function parseItemsXml(text: string, file: string): ItemTypeDef[];
|
|
28
|
+
/** Merge ImpEx and items.xml information into CMS component types. */
|
|
29
|
+
export declare function buildCmsTypes(sets: ImpexRecordSet[], items: ItemTypeDef[]): CmsComponentType[];
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/** CMS scanners: ImpEx files and `*-items.xml` type definitions (regex based). */
|
|
2
|
+
import { LineMap } from '../util.js';
|
|
3
|
+
const STANDARD_COLUMNS = new Set([
|
|
4
|
+
'uid', 'name', 'catalogversion', 'visible', 'restrictions', 'actions', 'onlyonerestrictionmustapply',
|
|
5
|
+
'&componentref', 'cmscomponents', 'slotid', 'position', 'page', 'contentslot', 'template'
|
|
6
|
+
]);
|
|
7
|
+
function columnName(raw) {
|
|
8
|
+
return raw.replace(/\[[^\]]*\]/g, '').replace(/\([^)]*\)/g, '').trim();
|
|
9
|
+
}
|
|
10
|
+
/** Parse ImpEx headers and rows. Macros, comments and malformed lines are skipped. */
|
|
11
|
+
export function parseImpex(text, file) {
|
|
12
|
+
const sets = [];
|
|
13
|
+
let current;
|
|
14
|
+
const lines = text.replace(/^/, '').split(/\r?\n/);
|
|
15
|
+
lines.forEach((raw, idx) => {
|
|
16
|
+
const line = raw.trim();
|
|
17
|
+
if (!line || line.startsWith('#') || line.startsWith('$')) {
|
|
18
|
+
if (!line)
|
|
19
|
+
current = undefined;
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const header = /^(INSERT_UPDATE|INSERT|UPDATE|REMOVE)\s+([A-Za-z_][\w]*)\s*(;.*)?$/.exec(line);
|
|
23
|
+
if (header) {
|
|
24
|
+
const cols = (header[3] ?? '').split(';').slice(1).map((c) => c.trim());
|
|
25
|
+
current = { mode: header[1], type: header[2], columns: cols, rows: [], file, line: idx + 1 };
|
|
26
|
+
sets.push(current);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (current && line.startsWith(';')) {
|
|
30
|
+
current.rows.push({ values: line.split(';').slice(1).map((v) => v.trim()), line: idx + 1 });
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
return sets;
|
|
34
|
+
}
|
|
35
|
+
/** Parse `<itemtype>` definitions from a `*-items.xml` file. */
|
|
36
|
+
export function parseItemsXml(text, file) {
|
|
37
|
+
const out = [];
|
|
38
|
+
const lines = new LineMap(text);
|
|
39
|
+
const clean = text.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, ' '));
|
|
40
|
+
const typeRe = /<itemtype\b([^>]*)>([\s\S]*?)<\/itemtype>/g;
|
|
41
|
+
let m;
|
|
42
|
+
while ((m = typeRe.exec(clean))) {
|
|
43
|
+
const attrs = (s, name) => new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`).exec(s)?.[1];
|
|
44
|
+
const code = attrs(m[1], 'code');
|
|
45
|
+
if (!code)
|
|
46
|
+
continue;
|
|
47
|
+
const bodyOffset = m.index + m[0].indexOf('>') + 1;
|
|
48
|
+
const attributes = [];
|
|
49
|
+
const attrRe = /<attribute\b([^>]*)>/g;
|
|
50
|
+
let am;
|
|
51
|
+
while ((am = attrRe.exec(m[2]))) {
|
|
52
|
+
const qualifier = attrs(am[1], 'qualifier');
|
|
53
|
+
if (qualifier)
|
|
54
|
+
attributes.push({ name: qualifier, type: attrs(am[1], 'type'), line: lines.lineAt(bodyOffset + am.index) });
|
|
55
|
+
}
|
|
56
|
+
out.push({ code, extends: attrs(m[1], 'extends'), attributes, file, line: lines.lineAt(m.index) });
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
const isComponentName = (n) => /Component$/.test(n);
|
|
61
|
+
/** Merge ImpEx and items.xml information into CMS component types. */
|
|
62
|
+
export function buildCmsTypes(sets, items) {
|
|
63
|
+
const byCode = new Map();
|
|
64
|
+
const get = (code) => {
|
|
65
|
+
let t = byCode.get(code);
|
|
66
|
+
if (!t) {
|
|
67
|
+
t = { code, attributes: [], instances: [] };
|
|
68
|
+
byCode.set(code, t);
|
|
69
|
+
}
|
|
70
|
+
return t;
|
|
71
|
+
};
|
|
72
|
+
const itemByCode = new Map(items.map((i) => [i.code, i]));
|
|
73
|
+
const isComponentItem = (code, depth = 0) => {
|
|
74
|
+
if (isComponentName(code))
|
|
75
|
+
return true;
|
|
76
|
+
const it = itemByCode.get(code);
|
|
77
|
+
return !!it?.extends && depth < 10 && isComponentItem(it.extends, depth + 1);
|
|
78
|
+
};
|
|
79
|
+
for (const it of items) {
|
|
80
|
+
if (!isComponentItem(it.code))
|
|
81
|
+
continue;
|
|
82
|
+
const t = get(it.code);
|
|
83
|
+
t.extends = it.extends;
|
|
84
|
+
for (const a of it.attributes) {
|
|
85
|
+
t.attributes.push({ name: a.name, type: a.type, file: it.file, line: a.line, source: 'items.xml' });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
for (const set of sets) {
|
|
89
|
+
if (!isComponentName(set.type))
|
|
90
|
+
continue;
|
|
91
|
+
const t = get(set.type);
|
|
92
|
+
const cols = set.columns.map(columnName);
|
|
93
|
+
cols.forEach((c, i) => {
|
|
94
|
+
if (!c || c.startsWith('$') || STANDARD_COLUMNS.has(c.toLowerCase()))
|
|
95
|
+
return;
|
|
96
|
+
if (!t.attributes.some((a) => a.name === c)) {
|
|
97
|
+
t.attributes.push({ name: c, file: set.file, line: set.line, source: 'impex' });
|
|
98
|
+
}
|
|
99
|
+
void i;
|
|
100
|
+
});
|
|
101
|
+
const uidIdx = cols.findIndex((c) => c.toLowerCase() === 'uid');
|
|
102
|
+
const nameIdx = cols.findIndex((c) => c.toLowerCase() === 'name');
|
|
103
|
+
for (const row of set.rows) {
|
|
104
|
+
const uid = uidIdx >= 0 ? row.values[uidIdx] : undefined;
|
|
105
|
+
if (uid)
|
|
106
|
+
t.instances.push({ uid, name: nameIdx >= 0 ? row.values[nameIdx] : undefined, file: set.file, line: row.line });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return [...byCode.values()].sort((a, b) => a.code.localeCompare(b.code));
|
|
110
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight detection of HTTP calls in JavaScript (also used for inline <script> blocks in JSPs).
|
|
3
|
+
* Regex based: no JS parser.
|
|
4
|
+
*/
|
|
5
|
+
export interface RawEndpoint {
|
|
6
|
+
kind: 'ajax' | 'fetch' | 'xhr' | 'c:url' | 'form';
|
|
7
|
+
method: string;
|
|
8
|
+
url: string;
|
|
9
|
+
line: number;
|
|
10
|
+
requestFields: string[];
|
|
11
|
+
responseFields: string[];
|
|
12
|
+
}
|
|
13
|
+
/** Normalize a URL-ish string: strips context path, query/hash; dynamic parts become `{param}`. */
|
|
14
|
+
export declare function normalizeUrlText(raw: string): string | undefined;
|
|
15
|
+
export declare function isStaticUrl(url: string): boolean;
|
|
16
|
+
/** Evaluate a concatenation like `ctx + '/a/' + id` into `/a/{param}`. */
|
|
17
|
+
export declare function evalUrlExpr(expr: string): string | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* Scan JavaScript source for $.ajax / $.get / $.post / fetch / axios / XHR calls.
|
|
20
|
+
* `lineAt` maps an index in `code` to a line number in the original file.
|
|
21
|
+
*/
|
|
22
|
+
export declare function scanJsEndpoints(code: string, lineAt: (index: number) => number): RawEndpoint[];
|