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.
@@ -0,0 +1,213 @@
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
+ const CONTEXT_PATH_RE = /context\s*path/i;
6
+ /** Normalize a URL-ish string: strips context path, query/hash; dynamic parts become `{param}`. */
7
+ export function normalizeUrlText(raw) {
8
+ let s = raw.replace(/\$\{([^}]*)\}/g, (_m, e) => (CONTEXT_PATH_RE.test(e) ? '' : '{param}'));
9
+ s = s.split(/[?#]/)[0].trim();
10
+ s = s.replace(/(\{param\})+/g, '{param}').replace(/^\{param\}(?=\/)/, '');
11
+ if (!s || s === '{param}')
12
+ return undefined;
13
+ if (/^https?:\/\//i.test(s) || s.startsWith('//'))
14
+ return s;
15
+ if (!s.startsWith('/'))
16
+ s = '/' + s;
17
+ return s;
18
+ }
19
+ export function isStaticUrl(url) {
20
+ return /\.(css|js|png|jpe?g|gif|svg|ico|woff2?|ttf|eot|map|html?)$/i.test(url) || /^\/(_ui|medias|webjars)\b/.test(url);
21
+ }
22
+ function skipString(s, i) {
23
+ const q = s[i];
24
+ let j = i + 1;
25
+ while (j < s.length && s[j] !== q) {
26
+ if (s[j] === '\\')
27
+ j++;
28
+ j++;
29
+ }
30
+ return j; // index of closing quote (or s.length)
31
+ }
32
+ /** Text between the parenthesis at `openIdx` and its matching close. Tolerant of unbalanced input. */
33
+ function callArgs(code, openIdx) {
34
+ let depth = 0;
35
+ const limit = Math.min(code.length, openIdx + 4000);
36
+ for (let i = openIdx; i < limit; i++) {
37
+ const ch = code[i];
38
+ if (ch === '"' || ch === "'" || ch === '`') {
39
+ i = skipString(code, i);
40
+ continue;
41
+ }
42
+ if (ch === '(')
43
+ depth++;
44
+ else if (ch === ')') {
45
+ depth--;
46
+ if (depth === 0)
47
+ return { text: code.slice(openIdx + 1, i), end: i };
48
+ }
49
+ }
50
+ return { text: code.slice(openIdx + 1, limit), end: limit };
51
+ }
52
+ /** Split on top-level commas; stops at an unmatched closing bracket. */
53
+ function splitTopLevel(s) {
54
+ const parts = [];
55
+ let depth = 0;
56
+ let start = 0;
57
+ for (let i = 0; i < s.length; i++) {
58
+ const ch = s[i];
59
+ if (ch === '"' || ch === "'" || ch === '`') {
60
+ i = skipString(s, i);
61
+ continue;
62
+ }
63
+ if (ch === '(' || ch === '{' || ch === '[')
64
+ depth++;
65
+ else if (ch === ')' || ch === '}' || ch === ']') {
66
+ depth--;
67
+ if (depth < 0) {
68
+ parts.push(s.slice(start, i));
69
+ return parts;
70
+ }
71
+ }
72
+ else if (ch === ',' && depth === 0) {
73
+ parts.push(s.slice(start, i));
74
+ start = i + 1;
75
+ }
76
+ }
77
+ parts.push(s.slice(start));
78
+ return parts;
79
+ }
80
+ /** Evaluate a concatenation like `ctx + '/a/' + id` into `/a/{param}`. */
81
+ export function evalUrlExpr(expr) {
82
+ const seg = splitTopLevel(expr)[0].trim();
83
+ let out = '';
84
+ let any = false;
85
+ let i = 0;
86
+ while (i < seg.length) {
87
+ const ch = seg[i];
88
+ if (/\s|\+/.test(ch)) {
89
+ i++;
90
+ continue;
91
+ }
92
+ if (ch === '"' || ch === "'" || ch === '`') {
93
+ const j = skipString(seg, i);
94
+ out += seg.slice(i + 1, j);
95
+ i = j + 1;
96
+ any = true;
97
+ continue;
98
+ }
99
+ const idm = /^[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*|\[[^\]]*\])*(?:\([^()]*\))?/.exec(seg.slice(i));
100
+ if (idm) {
101
+ out += CONTEXT_PATH_RE.test(idm[0]) ? '' : '${dynamic}';
102
+ i += idm[0].length;
103
+ any = true;
104
+ continue;
105
+ }
106
+ if (ch === '(' || ch === ')') {
107
+ i++;
108
+ continue;
109
+ }
110
+ break;
111
+ }
112
+ return any ? normalizeUrlText(out) : undefined;
113
+ }
114
+ const CALL_RE = /(\$\.ajax|jQuery\.ajax|\$\.getJSON|\$\.get|\$\.post|jQuery\.get|jQuery\.post|\bfetch|\baxios(?:\.(?:get|post|put|patch|delete))?|\.open)\s*\(/g;
115
+ const RESPONSE_IGNORE = new Set([
116
+ 'length', 'then', 'catch', 'finally', 'json', 'text', 'ok', 'status', 'statusText', 'forEach', 'map', 'filter',
117
+ 'done', 'fail', 'always', 'success', 'error', 'headers', 'push', 'toString', 'hasOwnProperty'
118
+ ]);
119
+ function objectKeys(body) {
120
+ const keys = [];
121
+ const re = /(?:^|[,{\s])(?:([A-Za-z_$][\w$]*)|'([^']+)'|"([^"]+)")\s*:/g;
122
+ let m;
123
+ while ((m = re.exec(body)))
124
+ keys.push(m[1] ?? m[2] ?? m[3]);
125
+ return keys;
126
+ }
127
+ /**
128
+ * Scan JavaScript source for $.ajax / $.get / $.post / fetch / axios / XHR calls.
129
+ * `lineAt` maps an index in `code` to a line number in the original file.
130
+ */
131
+ export function scanJsEndpoints(code, lineAt) {
132
+ const result = [];
133
+ const matches = [];
134
+ CALL_RE.lastIndex = 0;
135
+ let m;
136
+ while ((m = CALL_RE.exec(code))) {
137
+ matches.push({ index: m.index, name: m[1], openIdx: m.index + m[0].length - 1 });
138
+ }
139
+ matches.forEach((call, idx) => {
140
+ const { text: args, end } = callArgs(code, call.openIdx);
141
+ const name = call.name.replace(/^\\b/, '');
142
+ let method = 'GET';
143
+ let kind = 'ajax';
144
+ let urlExpr;
145
+ if (name === '.open') {
146
+ const first = /^\s*['"]([A-Za-z]+)['"]\s*,/.exec(args);
147
+ if (!first)
148
+ return;
149
+ method = first[1].toUpperCase();
150
+ kind = 'xhr';
151
+ urlExpr = args.slice(first[0].length);
152
+ }
153
+ else {
154
+ if (/fetch$/.test(name))
155
+ kind = 'fetch';
156
+ if (/\.post$/.test(name))
157
+ method = 'POST';
158
+ else if (/\.put$/.test(name))
159
+ method = 'PUT';
160
+ else if (/\.patch$/.test(name))
161
+ method = 'PATCH';
162
+ else if (/\.delete$/.test(name))
163
+ method = 'DELETE';
164
+ if (/(ajax|fetch|axios)$/.test(name)) {
165
+ const mm = /\b(?:type|method)\s*:\s*['"`]([A-Za-z]+)['"`]/.exec(args);
166
+ if (mm)
167
+ method = mm[1].toUpperCase();
168
+ }
169
+ if (args.trimStart().startsWith('{')) {
170
+ const um = /\burl\s*:\s*/.exec(args);
171
+ if (um)
172
+ urlExpr = args.slice(um.index + um[0].length);
173
+ }
174
+ else {
175
+ urlExpr = args;
176
+ }
177
+ }
178
+ if (!urlExpr)
179
+ return;
180
+ const url = evalUrlExpr(urlExpr);
181
+ if (!url)
182
+ return;
183
+ // Request payload keys.
184
+ const requestFields = new Set();
185
+ const dataRe = /\b(?:data|body|params)\s*:\s*(?:JSON\.stringify\(\s*)?\{([^{}]*)\}/g;
186
+ let dm;
187
+ while ((dm = dataRe.exec(args)))
188
+ objectKeys(dm[1]).forEach((k) => requestFields.add(k));
189
+ if (/\$\.(post|get)|jQuery\.(post|get)/.test(name)) {
190
+ const second = splitTopLevel(args)[1]?.trim();
191
+ if (second?.startsWith('{'))
192
+ objectKeys(second.slice(1)).forEach((k) => requestFields.add(k));
193
+ }
194
+ // Response properties read in the code that follows the call (until the next call).
195
+ const nextIdx = matches[idx + 1]?.index ?? code.length;
196
+ const windowText = code.slice(call.openIdx, Math.min(end + 900, nextIdx));
197
+ const responseFields = new Set();
198
+ const respRe = /\b(?:data|response|resp|result|res|json|payload)\.([A-Za-z_]\w*)/g;
199
+ let rm;
200
+ while ((rm = respRe.exec(windowText)))
201
+ if (!RESPONSE_IGNORE.has(rm[1]))
202
+ responseFields.add(rm[1]);
203
+ result.push({
204
+ kind,
205
+ method,
206
+ url,
207
+ line: lineAt(call.index),
208
+ requestFields: [...requestFields],
209
+ responseFields: [...responseFields]
210
+ });
211
+ });
212
+ return result;
213
+ }
@@ -0,0 +1,87 @@
1
+ import { type RawEndpoint } from './js.js';
2
+ import type { ConditionalKind, ConditionalLayout } from '../model.js';
3
+ export interface TaglibDecl {
4
+ prefix: string;
5
+ uri?: string;
6
+ tagdir?: string;
7
+ line: number;
8
+ }
9
+ export interface TagUsage {
10
+ prefix: string;
11
+ name: string;
12
+ tagdir: string;
13
+ line: number;
14
+ offset: number;
15
+ }
16
+ export interface IncludeRef {
17
+ kind: 'jsp:include' | 'directive' | 'c:import';
18
+ target: string;
19
+ line: number;
20
+ }
21
+ export interface I18nRef {
22
+ key: string;
23
+ tag: string;
24
+ line: number;
25
+ offset: number;
26
+ defaultText?: string;
27
+ dynamic: boolean;
28
+ }
29
+ export interface HardcodedRef {
30
+ text: string;
31
+ line: number;
32
+ kind: 'text' | 'attribute';
33
+ attribute?: string;
34
+ }
35
+ export interface ElRef {
36
+ path: string;
37
+ line: number;
38
+ }
39
+ export interface AliasDecl {
40
+ /** Simple class name from `<%@ attribute type="...OrderData" %>`. */
41
+ declaredType?: string;
42
+ /** EL path this variable was derived from (`c:forEach items` / `c:set value`). */
43
+ from?: string;
44
+ /** `from` is a collection and the variable is one element. */
45
+ element?: boolean;
46
+ /** Variable is something we cannot type (loop status, literals...). */
47
+ ignore?: boolean;
48
+ }
49
+ export interface RawConditional {
50
+ id: string;
51
+ kind: ConditionalKind;
52
+ test?: string;
53
+ line: number;
54
+ endLine: number;
55
+ depth: number;
56
+ parent?: string;
57
+ chooseGroup?: string;
58
+ layout: ConditionalLayout;
59
+ containsTags: string[];
60
+ containsI18nKeys: string[];
61
+ label: string;
62
+ }
63
+ export type ScanEndpoint = RawEndpoint & {
64
+ usage: 'ajax' | 'navigation';
65
+ };
66
+ export interface TemplateScan {
67
+ file: string;
68
+ taglibs: TaglibDecl[];
69
+ tagUsages: TagUsage[];
70
+ includes: IncludeRef[];
71
+ conditionals: RawConditional[];
72
+ i18n: I18nRef[];
73
+ hardcoded: HardcodedRef[];
74
+ elRefs: ElRef[];
75
+ aliases: Record<string, AliasDecl>;
76
+ endpoints: ScanEndpoint[];
77
+ scriptRefs: string[];
78
+ cmsSlots: Array<{
79
+ position: string;
80
+ line: number;
81
+ }>;
82
+ warnings: string[];
83
+ }
84
+ export declare function parseAttrs(s: string): Record<string, string>;
85
+ /** Extract `a.b.c` style data paths from an EL expression body. */
86
+ export declare function elPaths(expr: string): string[];
87
+ export declare function scanTemplate(source: string, file: string): TemplateScan;
@@ -0,0 +1,394 @@
1
+ /**
2
+ * Tolerant scanner for JSP / .tag files. Regex + a small tokenizer; no full JSP grammar.
3
+ * Never throws: oddities are recorded in `warnings`.
4
+ */
5
+ import { LineMap, blank } from '../util.js';
6
+ import { isStaticUrl, normalizeUrlText, scanJsEndpoints } from './js.js';
7
+ const STD_URIS = {
8
+ 'http://java.sun.com/jsp/jstl/core': 'c',
9
+ 'jakarta.tags.core': 'c',
10
+ 'http://java.sun.com/jsp/jstl/fmt': 'fmt',
11
+ 'jakarta.tags.fmt': 'fmt',
12
+ 'http://www.springframework.org/tags': 'spring',
13
+ 'http://www.springframework.org/tags/form': 'form',
14
+ 'http://java.sun.com/jsp/jstl/functions': 'fn'
15
+ };
16
+ const TEXT_ATTRS = ['title', 'alt', 'placeholder', 'aria-label'];
17
+ const TOKEN_RE = /<(\/?)([A-Za-z][\w:.-]*)((?:"[^"]*"|'[^']*'|[^'">])*)>/g;
18
+ export function parseAttrs(s) {
19
+ const out = {};
20
+ const re = /([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
21
+ let m;
22
+ while ((m = re.exec(s)))
23
+ out[m[1]] = m[2] ?? m[3] ?? '';
24
+ return out;
25
+ }
26
+ const stripEl = (v) => {
27
+ const m = /^\s*\$\{([\s\S]*)\}\s*$/.exec(v);
28
+ return m ? m[1].trim() : undefined;
29
+ };
30
+ /** Extract `a.b.c` style data paths from an EL expression body. */
31
+ export function elPaths(expr) {
32
+ const cleaned = expr.replace(/'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g, "''");
33
+ const re = /(?<![\w.:$])([A-Za-z_]\w*)((?:\s*\.\s*[A-Za-z_]\w*|\s*\[[^\]]*\])+)/g;
34
+ const out = [];
35
+ let m;
36
+ while ((m = re.exec(cleaned))) {
37
+ const segs = m[2].replace(/\[[^\]]*\]/g, '').replace(/\s+/g, '');
38
+ if (!segs)
39
+ continue;
40
+ out.push(m[1] + segs);
41
+ }
42
+ return out;
43
+ }
44
+ export function scanTemplate(source, file) {
45
+ const warnings = [];
46
+ const scan = {
47
+ file,
48
+ taglibs: [],
49
+ tagUsages: [],
50
+ includes: [],
51
+ conditionals: [],
52
+ i18n: [],
53
+ hardcoded: [],
54
+ elRefs: [],
55
+ aliases: {},
56
+ endpoints: [],
57
+ scriptRefs: [],
58
+ cmsSlots: [],
59
+ warnings
60
+ };
61
+ try {
62
+ doScan(source, file, scan);
63
+ }
64
+ catch (e) {
65
+ warnings.push(`${file}: scanner error: ${e.message}`);
66
+ }
67
+ return scan;
68
+ }
69
+ function doScan(source, file, scan) {
70
+ const { warnings } = scan;
71
+ const lines = new LineMap(source);
72
+ const base = file.split('/').pop() ?? file;
73
+ // 1. Mask comments, then directives/scriptlets (extract taglib + attribute directives first).
74
+ let masked = source
75
+ .replace(/<%--[\s\S]*?--%>/g, blank)
76
+ .replace(/<!--[\s\S]*?-->/g, blank);
77
+ const dirRe = /<%@\s*(taglib|attribute|include)\b([\s\S]*?)%>/g;
78
+ let dm;
79
+ while ((dm = dirRe.exec(masked))) {
80
+ const attrs = parseAttrs(dm[2]);
81
+ const line = lines.lineAt(dm.index);
82
+ if (dm[1] === 'taglib' && attrs.prefix) {
83
+ scan.taglibs.push({ prefix: attrs.prefix, uri: attrs.uri, tagdir: attrs.tagdir, line });
84
+ }
85
+ else if (dm[1] === 'attribute' && attrs.name) {
86
+ const t = attrs.type?.split('.').pop();
87
+ scan.aliases[attrs.name] = { declaredType: t };
88
+ }
89
+ else if (dm[1] === 'include' && attrs.file) {
90
+ scan.includes.push({ kind: 'directive', target: attrs.file, line });
91
+ }
92
+ }
93
+ masked = masked.replace(/<%[\s\S]*?%>/g, blank);
94
+ // 2. Blank <script>/<style> bodies for the HTML-oriented passes; scan inline scripts separately.
95
+ const scriptBodies = [];
96
+ const noScript = masked.replace(/(<(script|style)\b[^>]*>)([\s\S]*?)(<\/\2\s*>)/gi, (full, open, tag, body, close, offset) => {
97
+ if (tag.toLowerCase() === 'script') {
98
+ scriptBodies.push({ offset: offset + open.length, code: body, hasSrc: /\bsrc\s*=/.test(open) });
99
+ }
100
+ return open + blank(body) + close;
101
+ });
102
+ // 3. Tokenize.
103
+ const tokens = [];
104
+ TOKEN_RE.lastIndex = 0;
105
+ let tm;
106
+ while ((tm = TOKEN_RE.exec(noScript))) {
107
+ const attrText = tm[3];
108
+ tokens.push({
109
+ name: tm[2],
110
+ closing: tm[1] === '/',
111
+ selfClosing: /\/\s*$/.test(attrText),
112
+ attrs: tm[1] === '/' ? {} : parseAttrs(attrText),
113
+ start: tm.index,
114
+ end: tm.index + tm[0].length,
115
+ line: lines.lineAt(tm.index)
116
+ });
117
+ }
118
+ const prefixCanon = {};
119
+ const tagdirByPrefix = new Map();
120
+ for (const t of scan.taglibs) {
121
+ if (t.uri && STD_URIS[t.uri])
122
+ prefixCanon[t.prefix] = STD_URIS[t.uri];
123
+ if (t.tagdir)
124
+ tagdirByPrefix.set(t.prefix, t.tagdir);
125
+ }
126
+ const canon = (name) => {
127
+ const i = name.indexOf(':');
128
+ if (i < 0)
129
+ return name;
130
+ const p = name.slice(0, i);
131
+ return (prefixCanon[p] ?? p) + ':' + name.slice(i + 1);
132
+ };
133
+ const stack = [];
134
+ const condRanges = new Map();
135
+ const usedIds = new Set();
136
+ const mkId = (line) => {
137
+ let id = `${base}:${line}`;
138
+ let n = 2;
139
+ while (usedIds.has(id))
140
+ id = `${base}:${line}#${n++}`;
141
+ usedIds.add(id);
142
+ return id;
143
+ };
144
+ const nearestCond = () => {
145
+ for (let i = stack.length - 1; i >= 0; i--)
146
+ if (stack[i].cond)
147
+ return stack[i].cond;
148
+ return undefined;
149
+ };
150
+ const condDepth = () => stack.filter((s) => s.cond).length;
151
+ const closeEntry = (e, endOffset, endLine) => {
152
+ if (e.cond) {
153
+ e.cond.endLine = endLine;
154
+ condRanges.set(e.cond, { start: e.startOffset ?? 0, end: endOffset });
155
+ }
156
+ };
157
+ const pendingEndpoints = [];
158
+ for (const tok of tokens) {
159
+ const cname = canon(tok.name);
160
+ const prefix = tok.name.includes(':') ? tok.name.slice(0, tok.name.indexOf(':')) : '';
161
+ // --- conditionals ---
162
+ if (cname === 'c:if' || cname === 'c:choose' || cname === 'c:when' || cname === 'c:otherwise') {
163
+ if (!tok.closing) {
164
+ if (tok.selfClosing)
165
+ continue;
166
+ if (cname === 'c:choose') {
167
+ stack.push({ name: cname, chooseId: mkId(tok.line) });
168
+ }
169
+ else {
170
+ const parent = nearestCond();
171
+ let chooseGroup;
172
+ if (cname !== 'c:if') {
173
+ for (let i = stack.length - 1; i >= 0; i--) {
174
+ if (stack[i].name === 'c:choose') {
175
+ chooseGroup = stack[i].chooseId;
176
+ break;
177
+ }
178
+ }
179
+ if (!chooseGroup)
180
+ warnings.push(`${file}:${tok.line}: <${tok.name}> outside of c:choose`);
181
+ }
182
+ const kind = cname === 'c:if' ? 'if' : cname === 'c:when' ? 'when' : 'otherwise';
183
+ const rawTest = tok.attrs.test;
184
+ const cond = {
185
+ id: mkId(tok.line),
186
+ kind,
187
+ test: rawTest === undefined ? undefined : (stripEl(rawTest) ?? rawTest),
188
+ line: tok.line,
189
+ endLine: tok.line,
190
+ depth: condDepth(),
191
+ parent: parent?.id,
192
+ chooseGroup,
193
+ layout: 'text',
194
+ containsTags: [],
195
+ containsI18nKeys: [],
196
+ label: ''
197
+ };
198
+ if (kind !== 'otherwise' && rawTest === undefined)
199
+ warnings.push(`${file}:${tok.line}: <${tok.name}> without test attribute`);
200
+ scan.conditionals.push(cond);
201
+ stack.push({ name: cname, cond, startOffset: tok.end });
202
+ }
203
+ }
204
+ else {
205
+ let idx = -1;
206
+ for (let i = stack.length - 1; i >= 0; i--) {
207
+ if (stack[i].name === cname) {
208
+ idx = i;
209
+ break;
210
+ }
211
+ }
212
+ if (idx < 0) {
213
+ warnings.push(`${file}:${tok.line}: unmatched </${tok.name}>`);
214
+ }
215
+ else {
216
+ while (stack.length - 1 > idx) {
217
+ const dangling = stack.pop();
218
+ warnings.push(`${file}:${tok.line}: <${dangling.name}> was not closed before </${tok.name}>`);
219
+ closeEntry(dangling, tok.start, tok.line);
220
+ }
221
+ closeEntry(stack.pop(), tok.start, tok.line);
222
+ }
223
+ }
224
+ continue;
225
+ }
226
+ if (tok.closing)
227
+ continue;
228
+ // --- tag-file usage ---
229
+ const tagdir = prefix ? tagdirByPrefix.get(prefix) : undefined;
230
+ if (tagdir) {
231
+ scan.tagUsages.push({ prefix, name: tok.name.slice(prefix.length + 1), tagdir, line: tok.line, offset: tok.start });
232
+ }
233
+ // --- i18n ---
234
+ if (cname === 'spring:theme' || cname === 'spring:message' || cname === 'fmt:message') {
235
+ const key = cname === 'fmt:message' ? tok.attrs.key : tok.attrs.code;
236
+ if (key !== undefined) {
237
+ scan.i18n.push({
238
+ key,
239
+ tag: cname,
240
+ line: tok.line,
241
+ offset: tok.start,
242
+ defaultText: tok.attrs.text,
243
+ dynamic: key.includes('${') || key.trim() === ''
244
+ });
245
+ }
246
+ }
247
+ // --- aliases ---
248
+ if (cname === 'c:forEach') {
249
+ const expr = tok.attrs.items ? stripEl(tok.attrs.items) : undefined;
250
+ const from = expr ? elPaths(expr)[0] ?? (/^[A-Za-z_]\w*$/.test(expr) ? expr : undefined) : undefined;
251
+ if (tok.attrs.var)
252
+ scan.aliases[tok.attrs.var] = from ? { from, element: true } : { ignore: true };
253
+ if (tok.attrs.varStatus)
254
+ scan.aliases[tok.attrs.varStatus] = { ignore: true };
255
+ }
256
+ else if (cname === 'c:set') {
257
+ if (tok.attrs.var) {
258
+ const expr = tok.attrs.value ? stripEl(tok.attrs.value) : undefined;
259
+ const from = expr ? elPaths(expr)[0] ?? (/^[A-Za-z_]\w*$/.test(expr) ? expr : undefined) : undefined;
260
+ scan.aliases[tok.attrs.var] = from ? { from } : { ignore: true };
261
+ }
262
+ }
263
+ else if (tok.attrs.var && !scan.aliases[tok.attrs.var]) {
264
+ scan.aliases[tok.attrs.var] = { ignore: true };
265
+ }
266
+ // --- includes / scripts / cms ---
267
+ if (cname === 'jsp:include' && tok.attrs.page)
268
+ scan.includes.push({ kind: 'jsp:include', target: tok.attrs.page, line: tok.line });
269
+ if (cname === 'jsp:directive.include' && tok.attrs.file)
270
+ scan.includes.push({ kind: 'directive', target: tok.attrs.file, line: tok.line });
271
+ if (cname === 'c:import' && tok.attrs.url)
272
+ scan.includes.push({ kind: 'c:import', target: tok.attrs.url, line: tok.line });
273
+ if (tok.name.toLowerCase() === 'script' && tok.attrs.src)
274
+ scan.scriptRefs.push(tok.attrs.src);
275
+ if (cname === 'cms:pageSlot' && tok.attrs.position)
276
+ scan.cmsSlots.push({ position: tok.attrs.position, line: tok.line });
277
+ // --- URL candidates ---
278
+ if (cname === 'c:url' && tok.attrs.value) {
279
+ const url = normalizeUrlText(tok.attrs.value);
280
+ if (url && !isStaticUrl(url)) {
281
+ pendingEndpoints.push({
282
+ tok,
283
+ varName: tok.attrs.var,
284
+ raw: { kind: 'c:url', method: 'ANY', url, line: tok.line, requestFields: [], responseFields: [], usage: 'navigation' }
285
+ });
286
+ }
287
+ }
288
+ if ((tok.name === 'form' || cname === 'form:form') && tok.attrs.action) {
289
+ const url = normalizeUrlText(tok.attrs.action);
290
+ const method = (tok.attrs.method ?? (cname === 'form:form' ? 'POST' : 'GET')).toUpperCase();
291
+ if (url && !isStaticUrl(url)) {
292
+ scan.endpoints.push({
293
+ kind: 'form',
294
+ method,
295
+ url,
296
+ line: tok.line,
297
+ requestFields: [],
298
+ responseFields: [],
299
+ usage: method === 'GET' ? 'navigation' : 'ajax'
300
+ });
301
+ }
302
+ }
303
+ }
304
+ while (stack.length) {
305
+ const dangling = stack.pop();
306
+ warnings.push(`${file}: <${dangling.name}> was never closed`);
307
+ closeEntry(dangling, source.length, lines.lineAt(source.length));
308
+ }
309
+ // 5. Conditional contents (tags, i18n keys, layout hint).
310
+ const HTML_LAYOUT = { th: 'column', td: 'column', tr: 'row', li: 'item' };
311
+ for (const cond of scan.conditionals) {
312
+ const range = condRanges.get(cond);
313
+ if (!range)
314
+ continue;
315
+ for (const u of scan.tagUsages) {
316
+ if (u.offset >= range.start && u.offset < range.end)
317
+ cond.containsTags.push(`${u.prefix}:${u.name}`);
318
+ }
319
+ for (const r of scan.i18n) {
320
+ if (r.offset >= range.start && r.offset < range.end && !r.dynamic)
321
+ cond.containsI18nKeys.push(r.key);
322
+ }
323
+ const first = tokens.find((t) => t.start >= range.start && t.start < range.end && !t.closing && !t.name.includes(':'));
324
+ cond.layout = first ? (HTML_LAYOUT[first.name.toLowerCase()] ?? 'block') : cond.containsTags.length ? 'block' : 'text';
325
+ cond.containsTags = [...new Set(cond.containsTags)];
326
+ cond.containsI18nKeys = [...new Set(cond.containsI18nKeys)];
327
+ cond.label = cond.containsI18nKeys[0] ? `\`${cond.containsI18nKeys[0]}\`` : cond.containsTags[0] ? `\`<${cond.containsTags[0]}>\`` : 'content';
328
+ }
329
+ // 6. EL references.
330
+ const elRe = /\$\{([^}]*)\}/g;
331
+ const seen = new Set();
332
+ let em;
333
+ while ((em = elRe.exec(masked))) {
334
+ const line = lines.lineAt(em.index);
335
+ for (const p of elPaths(em[1])) {
336
+ const k = `${p}@${line}`;
337
+ if (seen.has(k))
338
+ continue;
339
+ seen.add(k);
340
+ scan.elRefs.push({ path: p, line });
341
+ }
342
+ }
343
+ // 7. Endpoints from c:url (usage heuristic) and inline scripts.
344
+ const scriptText = scriptBodies.map((s) => s.code).join('\n');
345
+ for (const p of pendingEndpoints) {
346
+ if (!p.raw)
347
+ continue;
348
+ if (p.varName) {
349
+ const v = p.varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
350
+ const inData = new RegExp(`data-[\\w-]+\\s*=\\s*["'][^"']*\\$\\{\\s*${v}\\s*\\}`).test(noScript);
351
+ const inScript = new RegExp(`\\$\\{\\s*${v}\\s*\\}`).test(scriptText);
352
+ if (inData || inScript)
353
+ p.raw.usage = 'ajax';
354
+ }
355
+ scan.endpoints.push(p.raw);
356
+ }
357
+ for (const s of scriptBodies) {
358
+ if (!s.code.trim())
359
+ continue;
360
+ for (const ep of scanJsEndpoints(s.code, (i) => lines.lineAt(s.offset + i))) {
361
+ if (!isStaticUrl(ep.url))
362
+ scan.endpoints.push({ ...ep, usage: 'ajax' });
363
+ }
364
+ }
365
+ // 8. Hardcoded user-facing text.
366
+ const pieces = [];
367
+ let cursor = 0;
368
+ for (const t of tokens) {
369
+ pieces.push(noScript.slice(cursor, t.start), blank(noScript.slice(t.start, t.end)));
370
+ cursor = t.end;
371
+ }
372
+ pieces.push(noScript.slice(cursor));
373
+ const textOnly = pieces
374
+ .join('')
375
+ .replace(/\$\{[^}]*\}/g, blank)
376
+ .replace(/&#?\w+;/g, blank);
377
+ const textLines = textOnly.split('\n');
378
+ textLines.forEach((ln, i) => {
379
+ const text = ln.trim();
380
+ if (/[A-Za-zÀ-ɏ]{2,}/.test(text))
381
+ scan.hardcoded.push({ text, line: i + 1, kind: 'text' });
382
+ });
383
+ for (const t of tokens) {
384
+ if (t.closing)
385
+ continue;
386
+ for (const a of TEXT_ATTRS) {
387
+ const v = t.attrs[a];
388
+ if (v && !v.includes('${') && /[A-Za-zÀ-ɏ]{2,}/.test(v)) {
389
+ scan.hardcoded.push({ text: v.trim(), line: t.line, kind: 'attribute', attribute: a });
390
+ }
391
+ }
392
+ }
393
+ scan.hardcoded.sort((a, b) => a.line - b.line);
394
+ }