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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 André Escocard
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,162 @@
1
- # Temporary Holding Version
1
+ <h1>
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="assets/brand/wordmark-dark.svg">
4
+ <img src="assets/brand/wordmark.svg" alt="accel2spa" height="40">
5
+ </picture>
6
+ </h1>
2
7
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
8
+ Reads a SAP Commerce Accelerator storefront and writes one migration spec per page for Spartacus (Composable Storefront). Each spec lists every conditional rule, translation gap, field OCC does not expose and endpoint with no OCC equivalent, with the `file:line` it comes from.
9
+
10
+ ![A terminal runs npx accel2spa analyze ./acme-storefront and reports 3 pages, 1 CMS component, 12 conditionals, 9 OCC data gaps, 3 missing endpoints and 25 missing i18n keys. Beside it, the generated spec for the order detail page lists 8 gaps in its frontmatter and a data table where four order fields are marked NO because OCC does not expose them.](assets/hero.svg)
11
+
12
+ ## Quickstart
13
+
14
+ ```bash
15
+ npx accel2spa analyze <storefront-path> --out out
16
+ ```
17
+
18
+ Requires Node.js 20+. To try it on the bundled synthetic fixture, run it from a clone:
19
+
20
+ ```bash
21
+ npm install && npm run build
22
+ node dist/cli.js analyze fixtures/acme-storefront --out out
23
+ ```
24
+
25
+ Point it at the storefront extension (the folder that contains `web/webroot/WEB-INF`), or at a parent folder that also holds your core extension so its `*-items.xml` and ImpEx are read too. It writes:
26
+
27
+ - `out/<storefront-name>/specs/<page>.md`: one spec per page (`pages/**/*.jsp`) and per CMS component (`cms/*.jsp`).
28
+ - `out/<storefront-name>/model.json`: the intermediate model behind the specs (page, components, tags, conditionals, i18n keys, data used, endpoints, gaps).
29
+
30
+ ## What it finds
31
+
32
+ Four kinds of work that a JSP hides and a Spartacus estimate usually misses.
33
+
34
+ **1. Conditional layout.** Every `<c:if test>` and `<c:choose>/<c:when>/<c:otherwise>`, with nesting and what each one controls (block, row, column, item). Custom tags are followed through their taglib prefixes, so you get the page's real component tree, not just its top file. In the fixture, `orderDetail.tag:15` renders the invoice-number block only `when not empty order.invoiceNumber`.
35
+
36
+ **2. i18n coverage.** Keys used per page (`<spring:theme>`, `<spring:message>`, `<fmt:message>`), checked against every `base_<lang>.properties`, plus literal text that should have been a key. The fixture's order detail page uses 22 keys; French defines 7 of them.
37
+
38
+ **3. Data OCC does not expose.** EL expressions such as `${order.invoiceNumber}` are compared with a reference of OCC fields. A JSP could read any model attribute; Spartacus only gets what OCC returns, so each gap is backend work. Custom CMS attributes from `*-items.xml` and ImpEx are listed too.
39
+
40
+ **4. Endpoints with no OCC equivalent.** `$.ajax`, `$.get/post`, `fetch`, `XMLHttpRequest`, forms and `<c:url>` values in JSPs and referenced scripts. For each unmapped call, the spec proposes a typed interface (from the request and response fields the code touches) and a mock, so the frontend can ship before the backend does.
41
+
42
+ ![Diagram. On the left, fields the Accelerator JSP reads on the order detail page. OCC sits in the middle as a filter. order.code, order.statusDisplay, order.entries, entry.quantity and order.created pass through to Spartacus; order.invoiceNumber, order.estimatedDeliveryWindow, order.giftWrapRequested and entry.giftMessage stop at OCC and are marked as gaps. Below, POST /cart/add maps to an OCC endpoint, while GET /my-account/wishlist/count and POST /promo/banner/{param}/impression have none.](assets/how-it-works.svg)
43
+
44
+ ## What a spec contains
45
+
46
+ Markdown with YAML frontmatter (`id`, `kind`, `source`, `route`, `complexity`, `dependsOn`, `gaps`), written so a developer or an AI agent can build the page without reading the whole legacy codebase. Nine sections:
47
+
48
+ 1. **Context**: source page, target route, counts, complexity score.
49
+ 2. **Pointers, not copies**: source files and tag includes as `file:line`. Legacy code is never inlined.
50
+ 3. **Structure and conditional rules**: component tree and a table of every rule.
51
+ 4. **i18n**: keys with text, missing languages and location; hardcoded strings.
52
+ 5. **Data and endpoints**: each expression, its OCC type, whether OCC exposes it.
53
+ 6. **Acceptance criteria**: one verifiable check per rule, key and gap.
54
+ 7. **Visual parity**: current URL, Spartacus route, states to compare.
55
+ 8. **Blockers**: backend work, with a typed interface and mock to start from.
56
+ 9. **Tasks**: a checklist to work through.
57
+
58
+ An excerpt from `out/specs/account-order-detail.md`, generated from the bundled fixture:
59
+
60
+ ```yaml
61
+ id: account-order-detail
62
+ kind: page
63
+ route: /my-account/order/:orderCode
64
+ complexity: high
65
+ gaps:
66
+ - data:entry.giftMessage
67
+ - data:order.estimatedDeliveryWindow
68
+ - data:order.giftWrapRequested
69
+ - data:order.invoiceNumber
70
+ - i18n:de
71
+ - i18n:en
72
+ - i18n:fr
73
+ - hardcoded-text
74
+ ```
75
+
76
+ ```ts
77
+ // 8. Blockers: suggested shape while the backend is pending (field types are guesses)
78
+ export interface OrderExtension {
79
+ estimatedDeliveryWindow?: string;
80
+ giftWrapRequested?: boolean;
81
+ invoiceNumber?: number;
82
+ }
83
+ ```
84
+
85
+ ## CLI
86
+
87
+ ```
88
+ accel2spa analyze <storefront-path> [--out <dir>] [--occ-fields <file>]
89
+ ```
90
+
91
+ | Option | Effect |
92
+ |---|---|
93
+ | `--out <dir>` | Output root. Default `out`. Writes `<dir>/<storefront-name>/model.json` and `<dir>/<storefront-name>/specs/<page>.md`. |
94
+ | `--occ-fields <file>` | JSON that extends or overrides the bundled OCC reference. |
95
+ | `-h`, `--help` | Show help. |
96
+ | `-v`, `--version` | Show version. |
97
+
98
+ Problems with odd input never stop the run; they are listed under `warnings` in `model.json`.
99
+
100
+ ### Your OCC is not the default OCC
101
+
102
+ The bundled reference (`src/occ/fields.json`, `src/occ/endpoints.json`) was written from the public OCC documentation. Real projects extend OCC, so declare your additions and the gaps that are already closed disappear from the specs:
103
+
104
+ ```json
105
+ {
106
+ "rootVars": { "myOrder": "Order" },
107
+ "types": { "Order": { "invoiceNumber": "string" } },
108
+ "endpoints": [{ "accelerator": "/my/endpoint", "method": "GET", "occ": "GET /users/{userId}/my" }]
109
+ }
110
+ ```
111
+
112
+ ## Programmatic API
113
+
114
+ The CLI is a thin layer over a library, so an MCP server or a CI check can reuse it.
115
+
116
+ ```ts
117
+ import { analyzeStorefront, renderSpec, writeOutputs } from 'accel2spa';
118
+
119
+ const model = analyzeStorefront('./storefront', { occFieldsPath: './my-occ.json' });
120
+ console.log(model.summary.dataGaps, model.summary.missingEndpoints);
121
+
122
+ const orderDetail = model.pages.find((p) => p.id === 'account-order-detail');
123
+ if (orderDetail) console.log(renderSpec(orderDetail, model));
124
+
125
+ writeOutputs(model, 'out'); // model.json + specs/*.md
126
+ ```
127
+
128
+ Also exported: `renderAllSpecs`, `analyzeAndWrite`, the individual scanners (`scanTemplate`, `scanJsEndpoints`, `parseProperties`, `parseImpex`, `parseItemsXml`, `buildCmsTypes`), `loadOccFields`, `OccReference` and the model types.
129
+
130
+ ## Limitations
131
+
132
+ - Regular expressions and a small tokenizer, not a full JSP parser.
133
+ - EL variable types come from `<%@ attribute type="...Data" %>`, `c:forEach`/`c:set` and a table of root variables. Anything else is reported as "not verified".
134
+ - The OCC reference is an approximation; pass `--occ-fields` for your project.
135
+ - CMS slot to component wiring from ImpEx is not resolved yet. CMS components get their own specs.
136
+ - Complexity is a simple weighted score, useful for ordering work, not for estimates.
137
+
138
+ ## Roadmap
139
+
140
+ - A report renderer: one human-readable overview across all pages, for planning and estimates.
141
+ - An MCP wrapper over the library, so coding agents can query the model directly.
142
+ - Three more patterns:
143
+ - Third-party scripts (payment iframes, plugins): loaded browser-only, idempotent and safe under SSR.
144
+ - Session and country state carried by cookies and redirects: mapped to interceptors and state.
145
+ - Several sites in one codebase: mapped to `baseSite` and context configuration.
146
+
147
+ ## Development
148
+
149
+ ```bash
150
+ npm test # vitest: scanner unit tests + integration snapshot on the fixture
151
+ npm run build # tsc + copies the OCC reference JSON to dist/
152
+ ```
153
+
154
+ ## Legal note
155
+
156
+ `fixtures/acme-storefront/` is synthetic: written from scratch for this project around a fictional "Acme" store, using only generic Accelerator patterns (taglib usage, `<c:if>`, `<spring:theme>`, and so on). It contains no code, names, endpoints, URLs or business rules from SAP's licensed `yacceleratorstorefront` templates or from any real client. Please keep it that way in contributions.
157
+
158
+ SAP, SAP Commerce and Spartacus are trademarks of their respective owners. This project is independent and not affiliated with SAP.
159
+
160
+ ## License
161
+
162
+ MIT. The images in `assets/` embed subsets of JetBrains Mono and Schibsted Grotesk (the wordmark as outlines) under the SIL Open Font License 1.1 (see `assets/fonts/`).
@@ -0,0 +1,7 @@
1
+ import type { Model, SourceRef } from './model.js';
2
+ export interface AnalyzeOptions {
3
+ /** JSON file that extends/overrides the bundled OCC field reference. */
4
+ occFieldsPath?: string;
5
+ }
6
+ export declare function analyzeStorefront(storefrontPath: string, options?: AnalyzeOptions): Model;
7
+ export type { SourceRef };
@@ -0,0 +1,401 @@
1
+ /** Core analysis: scan a storefront and build the `Model`. */
2
+ import { existsSync, readFileSync, statSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { buildCmsTypes, parseImpex, parseItemsXml } from './scanners/cms.js';
5
+ import { scanTemplate } from './scanners/jsp.js';
6
+ import { isStaticUrl, scanJsEndpoints } from './scanners/js.js';
7
+ import { bundleLang, parseProperties } from './scanners/properties.js';
8
+ import { loadOccFields, OccReference } from './occ/reference.js';
9
+ import { suggestRoute } from './routes.js';
10
+ import { kebab, LineMap, walkFiles } from './util.js';
11
+ /** EL roots provided by the container, never part of the OCC contract. */
12
+ const INFRA_ROOTS = new Set([
13
+ 'contextPath', 'themeResourcePath', 'siteRootUrl', 'request', 'pageContext', 'param', 'params', 'sessionScope',
14
+ 'requestScope', 'applicationScope', 'cookie', 'header', 'initParam', 'jspContext', 'pageScope', 'fn'
15
+ ]);
16
+ export function analyzeStorefront(storefrontPath, options = {}) {
17
+ const root = path.resolve(storefrontPath);
18
+ if (!existsSync(root) || !statSync(root).isDirectory()) {
19
+ throw new Error(`Storefront path not found or not a directory: ${storefrontPath}`);
20
+ }
21
+ const warnings = [];
22
+ const files = walkFiles(root, (m) => warnings.push(m));
23
+ const { fields, source: occSource } = loadOccFields(options.occFieldsPath);
24
+ const occ = new OccReference(fields);
25
+ const textCache = new Map();
26
+ const readText = (rel) => {
27
+ if (!textCache.has(rel)) {
28
+ try {
29
+ textCache.set(rel, readFileSync(path.join(root, rel), 'utf8'));
30
+ }
31
+ catch (e) {
32
+ warnings.push(`Cannot read ${rel}: ${e.message}`);
33
+ textCache.set(rel, undefined);
34
+ }
35
+ }
36
+ return textCache.get(rel);
37
+ };
38
+ // --- i18n bundles ---
39
+ const bundles = [];
40
+ for (const f of files) {
41
+ const lang = bundleLang(path.posix.basename(f));
42
+ if (!lang)
43
+ continue;
44
+ const text = readText(f);
45
+ if (text !== undefined)
46
+ bundles.push({ lang, file: f, entries: parseProperties(text) });
47
+ }
48
+ const langBundles = bundles.filter((b) => b.lang !== 'default').sort((a, b) => a.lang.localeCompare(b.lang));
49
+ const defaultBundle = bundles.find((b) => b.lang === 'default');
50
+ const referenceLanguage = langBundles.find((b) => b.lang === 'en')?.lang ?? langBundles[0]?.lang;
51
+ if (!langBundles.length)
52
+ warnings.push('No base_<lang>.properties files found: i18n coverage was not checked.');
53
+ // --- CMS ---
54
+ const impexSets = [];
55
+ const itemTypes = [];
56
+ for (const f of files) {
57
+ if (f.endsWith('.impex')) {
58
+ const t = readText(f);
59
+ if (t !== undefined)
60
+ impexSets.push(...parseImpex(t, f));
61
+ }
62
+ else if (/items\.xml$/.test(f)) {
63
+ const t = readText(f);
64
+ if (t !== undefined)
65
+ itemTypes.push(...parseItemsXml(t, f));
66
+ }
67
+ }
68
+ const cmsTypes = buildCmsTypes(impexSets, itemTypes);
69
+ // --- templates ---
70
+ const tagFiles = files.filter((f) => f.endsWith('.tag'));
71
+ const jspFiles = files.filter((f) => f.endsWith('.jsp'));
72
+ const jsFiles = files.filter((f) => f.endsWith('.js'));
73
+ const scanCache = new Map();
74
+ const getScan = (rel) => {
75
+ if (!scanCache.has(rel)) {
76
+ const t = readText(rel);
77
+ scanCache.set(rel, t === undefined ? undefined : scanTemplate(t, rel));
78
+ }
79
+ return scanCache.get(rel);
80
+ };
81
+ const jsEndpointCache = new Map();
82
+ const getJsEndpoints = (rel) => {
83
+ if (!jsEndpointCache.has(rel)) {
84
+ const t = readText(rel);
85
+ let eps = [];
86
+ if (t !== undefined) {
87
+ try {
88
+ const lm = new LineMap(t);
89
+ eps = scanJsEndpoints(t, (i) => lm.lineAt(i));
90
+ }
91
+ catch (e) {
92
+ warnings.push(`${rel}: JS scanner error: ${e.message}`);
93
+ }
94
+ }
95
+ jsEndpointCache.set(rel, eps);
96
+ }
97
+ return jsEndpointCache.get(rel);
98
+ };
99
+ const endsWithPath = (list, suffix) => {
100
+ const s = suffix.replace(/^\/+/, '');
101
+ return list.find((f) => f === s || f.endsWith('/' + s));
102
+ };
103
+ const resolveTagFile = (tagdir, name) => endsWithPath(tagFiles, `${tagdir.replace(/^\/+|\/+$/g, '')}/${name}.tag`);
104
+ const resolveInclude = (target, from) => {
105
+ if (/\$\{/.test(target) || /^https?:/.test(target))
106
+ return undefined;
107
+ const rel = target.startsWith('/') ? target : path.posix.join(path.posix.dirname(from), target);
108
+ return endsWithPath(jspFiles, rel);
109
+ };
110
+ // --- unit builder ---
111
+ const buildUnit = (entry, kind, id, extra = {}) => {
112
+ const unitWarnings = [];
113
+ const unitFiles = [];
114
+ const visited = new Set();
115
+ const tagIncludes = [];
116
+ const conditionals = [];
117
+ const i18nMap = new Map();
118
+ const dynamicI18n = [];
119
+ const hardcoded = [];
120
+ const dataMap = new Map();
121
+ const endpointMap = new Map();
122
+ const cmsSlots = [];
123
+ const overrides = extra.overrides ?? {};
124
+ const addEndpoint = (ep, file) => {
125
+ if (isStaticUrl(ep.url))
126
+ return;
127
+ const key = `${ep.method} ${ep.url}`;
128
+ let e = endpointMap.get(key);
129
+ if (!e) {
130
+ const external = /^(https?:)?\/\//i.test(ep.url);
131
+ const occEquivalent = external ? undefined : occ.matchEndpoint(ep.url, ep.method);
132
+ e = {
133
+ kind: ep.kind,
134
+ method: ep.method,
135
+ url: ep.url,
136
+ status: external ? 'external' : occEquivalent ? 'mapped' : 'missing',
137
+ occEquivalent,
138
+ refs: [],
139
+ requestFields: [],
140
+ responseFields: []
141
+ };
142
+ endpointMap.set(key, e);
143
+ }
144
+ e.refs.push({ file, line: ep.line });
145
+ for (const f of ep.requestFields)
146
+ if (!e.requestFields.includes(f))
147
+ e.requestFields.push(f);
148
+ for (const f of ep.responseFields)
149
+ if (!e.responseFields.includes(f))
150
+ e.responseFields.push(f);
151
+ };
152
+ const visit = (file, name, line) => {
153
+ const node = { name, file, line, children: [] };
154
+ if (visited.has(file)) {
155
+ node.repeated = true;
156
+ return node;
157
+ }
158
+ visited.add(file);
159
+ unitFiles.push(file);
160
+ const scan = getScan(file);
161
+ if (!scan) {
162
+ unitWarnings.push(`${file}: could not be read`);
163
+ return node;
164
+ }
165
+ unitWarnings.push(...scan.warnings);
166
+ for (const c of scan.conditionals)
167
+ conditionals.push({ ...c, file });
168
+ for (const s of scan.cmsSlots)
169
+ cmsSlots.push({ file, ...s });
170
+ for (const r of scan.i18n) {
171
+ if (r.dynamic) {
172
+ dynamicI18n.push({ file, line: r.line, tag: r.tag, expression: r.key });
173
+ continue;
174
+ }
175
+ let u = i18nMap.get(r.key);
176
+ if (!u) {
177
+ u = { key: r.key, refs: [], texts: {}, missingIn: [] };
178
+ i18nMap.set(r.key, u);
179
+ }
180
+ u.refs.push({ file, line: r.line, tag: r.tag });
181
+ if (r.defaultText && !u.defaultText)
182
+ u.defaultText = r.defaultText;
183
+ }
184
+ for (const h of scan.hardcoded)
185
+ hardcoded.push({ ...h, file });
186
+ for (const ref of scan.elRefs) {
187
+ const root0 = ref.path.split('.')[0];
188
+ if (INFRA_ROOTS.has(root0))
189
+ continue;
190
+ let d = dataMap.get(ref.path);
191
+ if (!d) {
192
+ const check = occ.checkPath(ref.path, scan.aliases, overrides);
193
+ d = { path: ref.path, root: root0, occType: check.occType, status: check.status, missingField: check.missingField, refs: [] };
194
+ if (check.status === 'missing') {
195
+ const declared = extra.cmsType?.attributes.find((a) => a.name === check.missingField);
196
+ d.note = declared
197
+ ? `Custom CMS attribute declared at ${declared.file}:${declared.line} (${declared.source}); OCC only returns it if the component type is mapped in the OCC DTO configuration.`
198
+ : `\`${check.missingField}\` is not part of the OCC reference for ${check.occType}.`;
199
+ }
200
+ dataMap.set(ref.path, d);
201
+ }
202
+ d.refs.push({ file, line: ref.line });
203
+ }
204
+ for (const ep of scan.endpoints)
205
+ if (ep.usage === 'ajax')
206
+ addEndpoint(ep, file);
207
+ // JS files referenced by <script src>
208
+ for (const src of scan.scriptRefs) {
209
+ const js = endsWithPath(jsFiles, src.split(/[?#]/)[0].split('/').pop() ?? '');
210
+ if (!js)
211
+ continue;
212
+ if (!unitFiles.includes(js))
213
+ unitFiles.push(js);
214
+ for (const ep of getJsEndpoints(js))
215
+ addEndpoint(ep, js);
216
+ }
217
+ // children: includes and tag usages, in source order
218
+ const children = [];
219
+ for (const inc of scan.includes) {
220
+ children.push({
221
+ line: inc.line,
222
+ build: () => {
223
+ const target = resolveInclude(inc.target, file);
224
+ if (target)
225
+ node.children.push(visit(target, path.posix.basename(target), inc.line));
226
+ else if (!/^https?:/.test(inc.target))
227
+ unitWarnings.push(`${file}:${inc.line}: include target not found in storefront: ${inc.target}`);
228
+ }
229
+ });
230
+ }
231
+ for (const u of scan.tagUsages) {
232
+ children.push({
233
+ line: u.line,
234
+ build: () => {
235
+ const tagFile = resolveTagFile(u.tagdir, u.name);
236
+ const label = `${u.prefix}:${u.name}`;
237
+ tagIncludes.push({ name: label, tagFile, file, line: u.line });
238
+ if (tagFile)
239
+ node.children.push(visit(tagFile, label, u.line));
240
+ else
241
+ unitWarnings.push(`${file}:${u.line}: tag file not found for <${label}> (tagdir ${u.tagdir})`);
242
+ }
243
+ });
244
+ }
245
+ children.sort((a, b) => a.line - b.line);
246
+ children.forEach((c) => c.build());
247
+ return node;
248
+ };
249
+ const tree = visit(entry, path.posix.basename(entry));
250
+ // --- i18n resolution ---
251
+ const i18n = [...i18nMap.values()].sort((a, b) => a.key.localeCompare(b.key));
252
+ for (const u of i18n) {
253
+ for (const b of langBundles) {
254
+ const hit = b.entries.get(u.key) ?? defaultBundle?.entries.get(u.key);
255
+ if (hit)
256
+ u.texts[b.lang] = hit.value;
257
+ else
258
+ u.missingIn.push(b.lang);
259
+ }
260
+ if (!langBundles.length && defaultBundle?.entries.get(u.key))
261
+ u.texts.default = defaultBundle.entries.get(u.key).value;
262
+ }
263
+ const i18nCoverage = langBundles.map((b) => ({
264
+ lang: b.lang,
265
+ file: b.file,
266
+ present: i18n.filter((u) => b.lang in u.texts).length,
267
+ total: i18n.length
268
+ }));
269
+ const data = [...dataMap.values()].sort((a, b) => a.path.localeCompare(b.path));
270
+ const endpoints = [...endpointMap.values()];
271
+ // --- gaps ---
272
+ const gaps = [];
273
+ for (const d of data.filter((x) => x.status === 'missing')) {
274
+ gaps.push({ kind: 'data', id: `data:${d.path}`, summary: `${d.path}: \`${d.missingField}\` is not exposed by OCC on ${d.occType}`, refs: d.refs });
275
+ }
276
+ for (const e of endpoints.filter((x) => x.status === 'missing')) {
277
+ gaps.push({ kind: 'endpoint', id: `endpoint:${e.method} ${e.url}`, summary: `${e.method} ${e.url} has no known OCC equivalent`, refs: e.refs });
278
+ }
279
+ for (const b of langBundles) {
280
+ const missing = i18n.filter((u) => u.missingIn.includes(b.lang));
281
+ if (missing.length) {
282
+ gaps.push({
283
+ kind: 'i18n',
284
+ id: `i18n:${b.lang}`,
285
+ summary: `${missing.length} of ${i18n.length} keys missing in ${b.lang}`,
286
+ refs: missing.flatMap((m) => m.refs.slice(0, 1).map(({ file, line }) => ({ file, line })))
287
+ });
288
+ }
289
+ }
290
+ if (hardcoded.length) {
291
+ gaps.push({
292
+ kind: 'hardcoded-text',
293
+ id: 'hardcoded-text',
294
+ summary: `${hardcoded.length} hardcoded literal string(s) in templates`,
295
+ refs: hardcoded.map(({ file, line }) => ({ file, line }))
296
+ });
297
+ }
298
+ const dataGaps = data.filter((d) => d.status === 'missing').length;
299
+ const epGaps = endpoints.filter((e) => e.status === 'missing').length;
300
+ const missingKeys = i18n.reduce((n, u) => n + u.missingIn.length, 0);
301
+ const score = conditionals.length * 2 + dataGaps * 2 + epGaps * 3 + (visited.size - 1) + missingKeys * 0.5 + hardcoded.length * 0.5;
302
+ const level = score < 10 ? 'low' : score < 25 ? 'medium' : 'high';
303
+ const dependsOn = [
304
+ ...new Set([
305
+ ...tagIncludes.map((t) => t.name),
306
+ ...data.filter((d) => d.status === 'missing' && d.occType).map((d) => `occ-extension:${d.occType}`),
307
+ ...endpoints.filter((e) => e.status === 'missing').map((e) => `endpoint:${e.method} ${e.url}`)
308
+ ])
309
+ ];
310
+ const routeInfo = kind === 'page' ? suggestRoute(id) : { route: `(CMS component, rendered inside a Spartacus page)`, routeSource: 'derived', currentUrl: undefined };
311
+ return {
312
+ id,
313
+ kind,
314
+ file: entry,
315
+ files: unitFiles,
316
+ currentUrl: routeInfo.currentUrl,
317
+ route: routeInfo.route,
318
+ routeSource: routeInfo.routeSource,
319
+ cmsType: extra.cmsType?.code,
320
+ cmsSlots,
321
+ tree,
322
+ tagIncludes,
323
+ conditionals,
324
+ i18n,
325
+ dynamicI18n,
326
+ i18nCoverage,
327
+ hardcoded,
328
+ data,
329
+ endpoints,
330
+ gaps,
331
+ dependsOn,
332
+ complexity: { score, level },
333
+ warnings: [...new Set(unitWarnings)]
334
+ };
335
+ };
336
+ // --- pages ---
337
+ const usedIds = new Set();
338
+ const uniqueId = (id) => {
339
+ let out = id;
340
+ let n = 2;
341
+ while (usedIds.has(out))
342
+ out = `${id}-${n++}`;
343
+ usedIds.add(out);
344
+ return out;
345
+ };
346
+ const pages = [];
347
+ for (const f of jspFiles.filter((x) => /(^|\/)pages\/.+\.jsp$/.test(x))) {
348
+ const rel = f.slice(f.lastIndexOf('pages/') + 'pages/'.length).replace(/\.jsp$/, '');
349
+ const parts = rel.split('/');
350
+ const last = kebab(parts.pop()).replace(/-page$/, '');
351
+ const dirs = parts.map(kebab);
352
+ const id = uniqueId(dirs.length && !last.startsWith(dirs[dirs.length - 1]) ? `${dirs.join('-')}-${last}` : last);
353
+ pages.push(buildUnit(f, 'page', id));
354
+ }
355
+ if (!pages.length)
356
+ warnings.push('No pages found (expected JSPs under a "pages/" directory).');
357
+ // --- CMS components ---
358
+ const components = [];
359
+ for (const f of jspFiles.filter((x) => /(^|\/)cms\/[^/]+\.jsp$/.test(x))) {
360
+ const base = path.posix.basename(f, '.jsp');
361
+ const type = cmsTypes.find((t) => t.code.toLowerCase() === base.toLowerCase());
362
+ if (type)
363
+ type.jsp = f;
364
+ let known = 'CMSComponent';
365
+ let cursor = type?.extends;
366
+ for (let i = 0; cursor && i < 10; i++) {
367
+ if (occ.hasType(cursor)) {
368
+ known = cursor;
369
+ break;
370
+ }
371
+ cursor = cmsTypes.find((t) => t.code === cursor)?.extends ?? itemTypes.find((t) => t.code === cursor)?.extends;
372
+ }
373
+ const stem = (type?.code ?? base).replace(/component$/i, '');
374
+ const id = uniqueId('component-' + kebab(type ? stem : base.replace(/component$/i, '')));
375
+ components.push(buildUnit(f, 'component', id, { cmsType: type, overrides: { component: known } }));
376
+ }
377
+ const all = [...pages, ...components];
378
+ const model = {
379
+ version: 1,
380
+ storefront: path.basename(root),
381
+ occFieldsSource: occSource,
382
+ languages: langBundles.map((b) => ({ lang: b.lang, file: b.file, keyCount: b.entries.size })),
383
+ referenceLanguage,
384
+ cmsComponentTypes: cmsTypes,
385
+ pages,
386
+ components,
387
+ summary: {
388
+ pages: pages.length,
389
+ components: components.length,
390
+ tagFiles: tagFiles.length,
391
+ languages: langBundles.length,
392
+ conditionals: all.reduce((n, u) => n + u.conditionals.length, 0),
393
+ dataGaps: all.reduce((n, u) => n + u.gaps.filter((g) => g.kind === 'data').length, 0),
394
+ missingEndpoints: all.reduce((n, u) => n + u.gaps.filter((g) => g.kind === 'endpoint').length, 0),
395
+ missingI18nKeys: all.reduce((n, u) => n + u.i18n.reduce((m, k) => m + k.missingIn.length, 0), 0),
396
+ hardcodedStrings: all.reduce((n, u) => n + u.hardcoded.length, 0)
397
+ },
398
+ warnings: [...warnings, ...all.flatMap((u) => u.warnings.map((w) => `[${u.id}] ${w}`))]
399
+ };
400
+ return model;
401
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ /** Safe folder name for a storefront path: its last segment, resolved when it is `.`, `..` or a drive root. */
3
+ export declare function storefrontFolderName(target: string): string;
4
+ export declare function main(argv: string[]): number;