ember-tribe 2.6.8 → 2.6.10
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/README.md +167 -77
- package/blueprints/ember-tribe/files/app/components/storylang/arc-diagram.hbs +242 -0
- package/blueprints/ember-tribe/files/app/components/storylang/arc-diagram.js +432 -0
- package/blueprints/ember-tribe/files/app/components/storylang/index.hbs +457 -0
- package/blueprints/ember-tribe/files/app/components/storylang/index.js +177 -0
- package/blueprints/ember-tribe/files/app/components/storylang/node-detail.hbs +265 -0
- package/blueprints/ember-tribe/files/app/components/storylang/node-detail.js +91 -0
- package/blueprints/ember-tribe/files/app/index.html +2 -2
- package/blueprints/ember-tribe/files/app/templates/index.hbs +4 -4
- package/blueprints/ember-tribe/files/public/composer.json +11 -13
- package/blueprints/ember-tribe/files/storylang +492 -75
- package/blueprints/ember-tribe/index.js +0 -1
- package/package.json +2 -2
- package/blueprints/ember-tribe/files/app/components/welcome-flame.hbs +0 -5
- package/blueprints/ember-tribe/files/public/assets/css/custom.css +0 -0
- package/blueprints/ember-tribe/files/public/assets/js/custom.js +0 -0
|
@@ -50,20 +50,233 @@ function nameFromPath(filePath, appDir, category) {
|
|
|
50
50
|
.join('/');
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Comment strippers
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Removes all HBS comment blocks from a Handlebars/Glimmer template so that
|
|
59
|
+
* commented-out markup is never picked up by any of the parsers.
|
|
60
|
+
*
|
|
61
|
+
* Strips:
|
|
62
|
+
* {{!-- anything, including newlines --}} (block comment)
|
|
63
|
+
* {{! anything on one line }} (inline comment)
|
|
64
|
+
* <!-- HTML comment --> (HTML comment inside HBS)
|
|
65
|
+
*/
|
|
66
|
+
function stripHbsComments(src) {
|
|
67
|
+
// Block comments: {{!-- ... --}} (may span multiple lines)
|
|
68
|
+
src = src.replace(/\{\{!--[\s\S]*?--\}\}/g, '');
|
|
69
|
+
// Inline comments: {{! ... }}
|
|
70
|
+
src = src.replace(/\{\{![\s\S]*?\}\}/g, '');
|
|
71
|
+
// HTML comments: <!-- ... --> (may span multiple lines)
|
|
72
|
+
src = src.replace(/<!--[\s\S]*?-->/g, '');
|
|
73
|
+
return src;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Removes all JavaScript/TypeScript comments from source so that
|
|
78
|
+
* commented-out code is never picked up by any of the parsers.
|
|
79
|
+
*
|
|
80
|
+
* Strips:
|
|
81
|
+
* // single-line comments
|
|
82
|
+
* /* block comments (may span multiple lines) *\/
|
|
83
|
+
*
|
|
84
|
+
* Preserves string literals so URLs / regex patterns inside strings are safe.
|
|
85
|
+
*/
|
|
86
|
+
function stripJsComments(src) {
|
|
87
|
+
// Use a single-pass regex that correctly handles strings, template literals,
|
|
88
|
+
// and both comment styles in source order.
|
|
89
|
+
return src.replace(
|
|
90
|
+
/(\/\/[^\n]*|\/\*[\s\S]*?\*\/|("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`))/g,
|
|
91
|
+
(match, _full, strLiteral) => {
|
|
92
|
+
// If it matched a string literal, keep it unchanged
|
|
93
|
+
if (strLiteral !== undefined) return strLiteral;
|
|
94
|
+
// Otherwise it's a comment — replace with whitespace to preserve line numbers
|
|
95
|
+
return match.replace(/[^\n]/g, ' ');
|
|
96
|
+
}
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
53
100
|
// ---------------------------------------------------------------------------
|
|
54
101
|
// Parsers
|
|
55
102
|
// ---------------------------------------------------------------------------
|
|
56
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Extracts the source body of a function starting right after its opening `{`.
|
|
106
|
+
* Handles nested braces so the full function body is captured regardless of
|
|
107
|
+
* how many levels deep the code goes.
|
|
108
|
+
*/
|
|
109
|
+
function extractFunctionBody(src, openBraceIndex) {
|
|
110
|
+
let depth = 1;
|
|
111
|
+
let i = openBraceIndex + 1;
|
|
112
|
+
while (i < src.length && depth > 0) {
|
|
113
|
+
if (src[i] === '{') depth++;
|
|
114
|
+
else if (src[i] === '}') depth--;
|
|
115
|
+
i++;
|
|
116
|
+
}
|
|
117
|
+
return src.slice(openBraceIndex + 1, i - 1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Builds a map of class-level property names → PHP paths for patterns like:
|
|
122
|
+
* backendUrl = `${ENV.TribeENV.API_URL}/custom/apollo/get-apollo-data.php`;
|
|
123
|
+
* companyUrl = `${ENV.TribeENV.API_URL}/custom/apollo/get-apollo-company.php`;
|
|
124
|
+
*
|
|
125
|
+
* This lets parseActions resolve `this.backendUrl` references inside method
|
|
126
|
+
* bodies even when the PHP path string is not repeated inline.
|
|
127
|
+
*/
|
|
128
|
+
function extractPhpPropertyMap(src) {
|
|
129
|
+
const map = new Map(); // propertyName → [phpPaths]
|
|
130
|
+
// Match: identifier = `...` ; or identifier = '...' ; at class body level
|
|
131
|
+
const propRe = /^\s{0,4}(\w+)\s*=\s*(`[^`]*`|'[^']*'|"[^"]*")\s*;/gm;
|
|
132
|
+
let match;
|
|
133
|
+
while ((match = propRe.exec(src)) !== null) {
|
|
134
|
+
const propName = match[1];
|
|
135
|
+
const value = match[2];
|
|
136
|
+
const phpPaths = extractCustomPhpCalls(value);
|
|
137
|
+
if (phpPaths.length > 0) {
|
|
138
|
+
map.set(propName, phpPaths);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return map;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Given a method body and the file-level PHP property map, returns all PHP
|
|
146
|
+
* paths reachable from that body — both inline strings and via `this.prop`
|
|
147
|
+
* references that resolve to a known PHP property.
|
|
148
|
+
*/
|
|
149
|
+
function resolvePhpCallsInBody(body, phpPropMap) {
|
|
150
|
+
const found = new Set(extractCustomPhpCalls(body));
|
|
151
|
+
|
|
152
|
+
// Resolve this.someUrl references
|
|
153
|
+
for (const [, propName] of body.matchAll(/this\.(\w+)/g)) {
|
|
154
|
+
if (phpPropMap.has(propName)) {
|
|
155
|
+
for (const path of phpPropMap.get(propName)) {
|
|
156
|
+
found.add(path);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return [...found];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Parses every @action AND every public async method in a JS/TS file and
|
|
166
|
+
* returns a mixed actions array:
|
|
167
|
+
* - plain string when the method makes no /custom/*.php calls
|
|
168
|
+
* - { methodName: [phpPaths] } when it does
|
|
169
|
+
*
|
|
170
|
+
* Handles two method styles:
|
|
171
|
+
* @action (async) methodName(...) { ... } ← Ember action decorator
|
|
172
|
+
* async methodName(...) { ... } ← plain async service method
|
|
173
|
+
*
|
|
174
|
+
* PHP paths that live in class-level string properties (e.g. backendUrl) are
|
|
175
|
+
* resolved via `this.propName` references inside each method body.
|
|
176
|
+
*/
|
|
177
|
+
function parseActions(src) {
|
|
178
|
+
src = stripJsComments(src);
|
|
179
|
+
const phpPropMap = extractPhpPropertyMap(src);
|
|
180
|
+
const actions = [];
|
|
181
|
+
const seen = new Set();
|
|
182
|
+
|
|
183
|
+
// Helper: process one matched method
|
|
184
|
+
function processMethod(name, openBraceIndex) {
|
|
185
|
+
const kebab = toKebabCase(name);
|
|
186
|
+
if (seen.has(kebab)) return; // @action match already captured this method
|
|
187
|
+
seen.add(kebab);
|
|
188
|
+
const body = extractFunctionBody(src, openBraceIndex);
|
|
189
|
+
const phpCalls = resolvePhpCallsInBody(body, phpPropMap);
|
|
190
|
+
|
|
191
|
+
// Detect store/model type usages within this method body
|
|
192
|
+
const bodyTypeSet = new Set();
|
|
193
|
+
for (const [, typeName] of body.matchAll(/this\.store\.\w+\(\s*['"`]([\w-]+)['"`]/g)) {
|
|
194
|
+
bodyTypeSet.add(typeName);
|
|
195
|
+
}
|
|
196
|
+
for (const [, typeName] of body.matchAll(/this\.model\.([\w-]+)/g)) {
|
|
197
|
+
bodyTypeSet.add(toKebabCase(typeName));
|
|
198
|
+
}
|
|
199
|
+
const bodyTypes = [...bodyTypeSet];
|
|
200
|
+
|
|
201
|
+
const extras = [...phpCalls, ...bodyTypes];
|
|
202
|
+
if (extras.length === 0) {
|
|
203
|
+
actions.push(kebab);
|
|
204
|
+
} else {
|
|
205
|
+
actions.push({ [kebab]: extras });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Pass 1: @action-decorated methods (existing behaviour)
|
|
210
|
+
const actionRe = /@action\s*\n?\s*(?:async\s+)?(\w+)\s*\([^)]*\)\s*\{/g;
|
|
211
|
+
let match;
|
|
212
|
+
while ((match = actionRe.exec(src)) !== null) {
|
|
213
|
+
const name = match[1];
|
|
214
|
+
const bodyStart = match.index + match[0].length - 1;
|
|
215
|
+
processMethod(name, bodyStart);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Pass 2: non-decorated async methods (e.g. service methods like enrichByEmail)
|
|
219
|
+
// Only match class-body-level methods (indented 2 spaces) to avoid false positives
|
|
220
|
+
const asyncMethodRe = /^\s{2}async\s+(\w+)\s*\([^)]*\)\s*\{/gm;
|
|
221
|
+
while ((match = asyncMethodRe.exec(src)) !== null) {
|
|
222
|
+
const name = match[1];
|
|
223
|
+
const bodyStart = match.index + match[0].length - 1;
|
|
224
|
+
processMethod(name, bodyStart);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Pass 3: plain (non-async, non-decorated) class-body-level methods
|
|
228
|
+
// Matches: methodName(...) { ... } indented exactly 2 spaces
|
|
229
|
+
// Excludes getter accessors (get propName()) which are captured separately.
|
|
230
|
+
const plainMethodRe = /^\s{2}(?!get\s+\w+\s*\(\s*\))(\w+)\s*\([^)]*\)\s*\{/gm;
|
|
231
|
+
while ((match = plainMethodRe.exec(src)) !== null) {
|
|
232
|
+
const name = match[1];
|
|
233
|
+
const bodyStart = match.index + match[0].length - 1;
|
|
234
|
+
processMethod(name, bodyStart);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Pass 4: module-level named functions (files that are not class-based,
|
|
238
|
+
// e.g. utility modules, initializers, instance-initializers).
|
|
239
|
+
// Only runs when no class body was detected to avoid double-counting.
|
|
240
|
+
const hasClass = /^\s*(?:export\s+default\s+)?class\s+/m.test(src);
|
|
241
|
+
if (!hasClass) {
|
|
242
|
+
const moduleFnRe = /^(?:export\s+(?:default\s+)?)?function\s+(\w+)\s*\([^)]*\)\s*\{/gm;
|
|
243
|
+
while ((match = moduleFnRe.exec(src)) !== null) {
|
|
244
|
+
const name = match[1];
|
|
245
|
+
const bodyStart = match.index + match[0].length - 1;
|
|
246
|
+
processMethod(name, bodyStart);
|
|
247
|
+
}
|
|
248
|
+
// Arrow / const functions: export const foo = (...) => { ... }
|
|
249
|
+
const arrowFnRe = /^(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>\s*\{/gm;
|
|
250
|
+
while ((match = arrowFnRe.exec(src)) !== null) {
|
|
251
|
+
const name = match[1];
|
|
252
|
+
const bodyStart = match.index + match[0].length - 1;
|
|
253
|
+
processMethod(name, bodyStart);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return actions;
|
|
258
|
+
}
|
|
259
|
+
|
|
57
260
|
function parseJsFile(filePath) {
|
|
58
|
-
const
|
|
261
|
+
const raw = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';
|
|
262
|
+
const src = stripJsComments(raw);
|
|
59
263
|
|
|
60
264
|
const trackedVars = [...src.matchAll(/@tracked\s+(\w+)\s*(?:=\s*([^;]+))?/g)].map(
|
|
61
265
|
([, name, val]) => ({ [toKebabCase(name)]: inferType(val) })
|
|
62
266
|
);
|
|
63
267
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
268
|
+
// Capture native JS getters: get someProperty() { ... }
|
|
269
|
+
const getters = [...src.matchAll(/^\s{2}get\s+(\w+)\s*\(\s*\)\s*\{/gm)].map(([, name]) => toKebabCase(name));
|
|
270
|
+
|
|
271
|
+
const allParsed = parseActions(src);
|
|
272
|
+
|
|
273
|
+
// Separate @action / async class methods from plain functions (Pass 3 / Pass 4)
|
|
274
|
+
const actionNames = new Set();
|
|
275
|
+
for (const [, n] of src.matchAll(/@action\s*\n?\s*(?:async\s+)?(\w+)\s*\(/g)) actionNames.add(toKebabCase(n));
|
|
276
|
+
for (const [, n] of src.matchAll(/^\s{2}async\s+(\w+)\s*\(/gm)) actionNames.add(toKebabCase(n));
|
|
277
|
+
|
|
278
|
+
const actions = allParsed.filter((e) => actionNames.has(typeof e === 'string' ? e : Object.keys(e)[0]));
|
|
279
|
+
const functions = allParsed.filter((e) => !actionNames.has(typeof e === 'string' ? e : Object.keys(e)[0]));
|
|
67
280
|
|
|
68
281
|
const services = [...src.matchAll(/@service\s+(\w+)/g)].map(([, name]) => toKebabCase(name));
|
|
69
282
|
|
|
@@ -71,7 +284,7 @@ function parseJsFile(filePath) {
|
|
|
71
284
|
[...block.matchAll(/(\w+)\s*:/g)].map(([, k]) => ({ [toKebabCase(k)]: 'string' }))
|
|
72
285
|
);
|
|
73
286
|
|
|
74
|
-
return { trackedVars, actions, services, getVars };
|
|
287
|
+
return { trackedVars, getters, actions, functions, services, getVars };
|
|
75
288
|
}
|
|
76
289
|
|
|
77
290
|
function inferType(val = '') {
|
|
@@ -84,10 +297,10 @@ function inferType(val = '') {
|
|
|
84
297
|
return 'string';
|
|
85
298
|
}
|
|
86
299
|
|
|
87
|
-
function parseHbsFile(filePath) {
|
|
300
|
+
function parseHbsFile(filePath, knownHelperNames = null, knownModifierNames = null) {
|
|
88
301
|
if (!fs.existsSync(filePath)) return { inheritedArgs: [], helpers: [], modifiers: [], components: [] };
|
|
89
302
|
|
|
90
|
-
const src = fs.readFileSync(filePath, 'utf8');
|
|
303
|
+
const src = stripHbsComments(fs.readFileSync(filePath, 'utf8'));
|
|
91
304
|
|
|
92
305
|
const inheritedArgsSet = new Set(
|
|
93
306
|
[...src.matchAll(/@(\w[\w.]*)/g)]
|
|
@@ -97,106 +310,296 @@ function parseHbsFile(filePath) {
|
|
|
97
310
|
const inheritedArgs = [...inheritedArgsSet].map((name) => ({ [toKebabCase(name)]: 'var' }));
|
|
98
311
|
|
|
99
312
|
const builtinHelpers = new Set([
|
|
100
|
-
'if', 'unless', 'each', 'let', 'with', 'yield', 'outlet', 'component',
|
|
313
|
+
'if', 'unless', 'each', 'else', 'let', 'with', 'yield', 'outlet', 'component',
|
|
101
314
|
'on', 'get', 'concat', 'array', 'hash', 'log', 'action', 'mut',
|
|
102
315
|
'page-title', 'link-to', 'BasicDropdownWormhole',
|
|
103
316
|
]);
|
|
104
|
-
const helpersSet = new Set(
|
|
105
|
-
[...src.matchAll(/\{\{([\w-]+)/g)]
|
|
106
|
-
.map(([, name]) => name)
|
|
107
|
-
.filter((n) => n.includes('-') && !builtinHelpers.has(n))
|
|
108
|
-
);
|
|
109
|
-
const helpers = [...helpersSet];
|
|
110
317
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
318
|
+
// Modifiers: {{word ...}} or {{word}} that appear DIRECTLY inside an HTML opening tag
|
|
319
|
+
// but NOT as the value side of an attribute (i.e. not preceded by = or =").
|
|
320
|
+
// Pattern: inside < ... >, a mustache that is NOT preceded by `=` or `="` or `='`
|
|
321
|
+
//
|
|
322
|
+
// Strategy: find all opening HTML tags, then within each tag scan for mustaches
|
|
323
|
+
// that are NOT attribute values (i.e. not preceded by = sign).
|
|
324
|
+
const modifiersSet = new Set();
|
|
325
|
+
const helpersSet = new Set();
|
|
326
|
+
|
|
327
|
+
// Extract all opening HTML tags (including multi-line), capturing their full content.
|
|
328
|
+
// We match from `<tagname` up to the closing `>`, handling nested mustaches.
|
|
329
|
+
const openTagRe = /<[a-zA-Z][\w:./-]*(\s[\s\S]*?)?\/?>/g;
|
|
330
|
+
let tagMatch;
|
|
331
|
+
while ((tagMatch = openTagRe.exec(src)) !== null) {
|
|
332
|
+
const tagContent = tagMatch[0];
|
|
333
|
+
|
|
334
|
+
// Find all mustache expressions within this tag
|
|
335
|
+
const mustacheRe = /\{\{([\w-]+)/g;
|
|
336
|
+
let mustacheMatch;
|
|
337
|
+
while ((mustacheMatch = mustacheRe.exec(tagContent)) !== null) {
|
|
338
|
+
const name = mustacheMatch[1];
|
|
339
|
+
if (builtinHelpers.has(name) || !name.includes('-')) continue;
|
|
340
|
+
|
|
341
|
+
// Look at what comes immediately before this `{{` in the tag.
|
|
342
|
+
// A helper is used as a value when the mustache appears:
|
|
343
|
+
// - directly after `=`, `="`, or `='` (e.g. value={{h}}, value="{{h}}")
|
|
344
|
+
// - inside an already-open quoted attribute string (e.g. style="color: {{h}}")
|
|
345
|
+
// detected by finding an unmatched opening quote after the last `=`
|
|
346
|
+
const before = tagContent.slice(0, mustacheMatch.index);
|
|
347
|
+
const lastEqIdx = before.lastIndexOf('=');
|
|
348
|
+
let isValue = false;
|
|
349
|
+
if (lastEqIdx !== -1) {
|
|
350
|
+
const afterEq = before.slice(lastEqIdx + 1).trimStart();
|
|
351
|
+
if (afterEq === '' || afterEq === '"' || afterEq === "'") {
|
|
352
|
+
// directly after `=` or `="` or `='` with nothing else yet
|
|
353
|
+
isValue = true;
|
|
354
|
+
} else if (afterEq.startsWith('"') || afterEq.startsWith("'")) {
|
|
355
|
+
// inside an open quoted string — check the quote hasn't been closed yet
|
|
356
|
+
const quoteChar = afterEq[0];
|
|
357
|
+
const inner = afterEq.slice(1);
|
|
358
|
+
isValue = !inner.includes(quoteChar);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (isValue) {
|
|
363
|
+
helpersSet.add(name);
|
|
364
|
+
} else {
|
|
365
|
+
modifiersSet.add(name);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Also capture helpers used in standalone mustaches OUTSIDE of HTML tags.
|
|
371
|
+
// Helpers ALWAYS start with {{ — never match bare CSS values, text, or JS expressions.
|
|
372
|
+
// Scans the full mustache body so subexpressions like {{#if (some-helper x)}} are caught.
|
|
373
|
+
const withoutTags = src.replace(/<[a-zA-Z][\w:./-]*(\s[\s\S]*?)?\/?>/g, '');
|
|
374
|
+
for (const [, body] of withoutTags.matchAll(/\{\{([\s\S]*?)\}\}/g)) {
|
|
375
|
+
for (const [, name] of body.matchAll(/([\w][-\w]*)/g)) {
|
|
376
|
+
if (name.includes('-') && !builtinHelpers.has(name) && !modifiersSet.has(name)) {
|
|
377
|
+
helpersSet.add(name);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// If caller supplied a whitelist, restrict to only known helpers / modifiers.
|
|
383
|
+
// This prevents false positives from CSS class names, JS identifiers, etc.
|
|
384
|
+
const allHelpers = [...helpersSet];
|
|
385
|
+
const allModifiers = [...modifiersSet].filter((n) => !helpersSet.has(n));
|
|
386
|
+
|
|
387
|
+
const helpers = knownHelperNames
|
|
388
|
+
? allHelpers.filter((n) => knownHelperNames.has(n))
|
|
389
|
+
: allHelpers;
|
|
390
|
+
|
|
391
|
+
const modifiers = knownModifierNames
|
|
392
|
+
? allModifiers.filter((n) => knownModifierNames.has(n))
|
|
393
|
+
: allModifiers;
|
|
117
394
|
|
|
118
395
|
const componentsSet = new Set(
|
|
119
|
-
[...src.matchAll(/<([A-Z][\w::/]*)/g)].map(([, name]) => toKebabCase(name))
|
|
396
|
+
[...src.matchAll(/<([A-Z][\w::/]*)/g)].map(([, name]) => toKebabCase(name).replace(/::/g, '/'))
|
|
120
397
|
);
|
|
121
398
|
const components = [...componentsSet];
|
|
122
399
|
|
|
123
400
|
return { inheritedArgs, helpers, modifiers, components };
|
|
124
401
|
}
|
|
125
402
|
|
|
403
|
+
// ---------------------------------------------------------------------------
|
|
404
|
+
// Custom PHP scanner
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Extracts all `/custom/*.php` paths called from a source file.
|
|
409
|
+
*
|
|
410
|
+
* Matches patterns like:
|
|
411
|
+
* `${ENV.TribeENV.API_URL}/custom/apollo/get-apollo-data.php`
|
|
412
|
+
* ENV.TribeENV.API_URL + '/custom/jobs/list.php'
|
|
413
|
+
* fetch('/custom/reports/export.php') ← direct relative calls
|
|
414
|
+
* '/custom/utils/helper.php' ← any string containing /custom/*.php
|
|
415
|
+
*/
|
|
416
|
+
function extractCustomPhpCalls(src) {
|
|
417
|
+
const found = new Set();
|
|
418
|
+
|
|
419
|
+
// Pattern 1: template-literal `${...}/custom/path/file.php`
|
|
420
|
+
for (const [, phpPath] of src.matchAll(/\$\{[^}]+\}\/?(custom\/[^`'" \t\n)]+\.php)/g)) {
|
|
421
|
+
found.add(phpPath);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Pattern 2: string concatenation something + '/custom/path/file.php'
|
|
425
|
+
for (const [, phpPath] of src.matchAll(/['"`]\s*\+?\s*['"`]?\s*(custom\/[^'"`\s)]+\.php)/g)) {
|
|
426
|
+
found.add(phpPath);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Pattern 3: bare string literal '/custom/path/file.php' or "/custom/..."
|
|
430
|
+
for (const [, phpPath] of src.matchAll(/['"`](custom\/[^'"`\s)]+\.php)['"`]/g)) {
|
|
431
|
+
found.add(phpPath);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Pattern 4: anything containing /custom/*.php that wasn't caught above
|
|
435
|
+
for (const [, phpPath] of src.matchAll(/[/](custom\/[\w/-]+\.php)/g)) {
|
|
436
|
+
found.add(phpPath);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return [...found];
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Walks the entire app/ directory, extracts every /custom/*.php call, and
|
|
444
|
+
* returns a map: phpPath → { called_from: { routes, components, services } }
|
|
445
|
+
*/
|
|
446
|
+
function buildCustomPhp(appDir) {
|
|
447
|
+
const phpMap = new Map(); // phpPath → { routes: Set, components: Set, services: Set }
|
|
448
|
+
|
|
449
|
+
const categories = [
|
|
450
|
+
{ dir: path.join(appDir, 'routes'), label: 'routes' },
|
|
451
|
+
{ dir: path.join(appDir, 'controllers'),label: 'routes' }, // controllers are part of their route
|
|
452
|
+
{ dir: path.join(appDir, 'components'), label: 'components' },
|
|
453
|
+
{ dir: path.join(appDir, 'services'), label: 'services' },
|
|
454
|
+
{ dir: path.join(appDir, 'templates'), label: 'routes' }, // template JS or HBS
|
|
455
|
+
];
|
|
456
|
+
|
|
457
|
+
for (const { dir, label } of categories) {
|
|
458
|
+
const files = walkDir(dir, (n) => /\.(js|ts|hbs)$/.test(n));
|
|
459
|
+
for (const filePath of files) {
|
|
460
|
+
const src = fs.readFileSync(filePath, 'utf8');
|
|
461
|
+
const phpPaths = extractCustomPhpCalls(src);
|
|
462
|
+
if (phpPaths.length === 0) continue;
|
|
463
|
+
|
|
464
|
+
// Derive a human-readable caller name from the file path
|
|
465
|
+
let callerName;
|
|
466
|
+
if (filePath.includes('/routes/') || filePath.includes('/controllers/') || filePath.includes('/templates/')) {
|
|
467
|
+
callerName = nameFromPath(filePath.replace('/controllers/', '/routes/').replace('/templates/', '/routes/'), appDir, 'routes');
|
|
468
|
+
} else if (filePath.includes('/components/')) {
|
|
469
|
+
callerName = nameFromPath(filePath, appDir, 'components');
|
|
470
|
+
} else if (filePath.includes('/services/')) {
|
|
471
|
+
callerName = nameFromPath(filePath, appDir, 'services');
|
|
472
|
+
} else {
|
|
473
|
+
callerName = path.relative(appDir, filePath);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
for (const phpPath of phpPaths) {
|
|
477
|
+
if (!phpMap.has(phpPath)) {
|
|
478
|
+
phpMap.set(phpPath, { routes: new Set(), components: new Set(), services: new Set() });
|
|
479
|
+
}
|
|
480
|
+
phpMap.get(phpPath)[label].add(callerName);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Convert Sets to sorted arrays and produce final structure
|
|
486
|
+
return [...phpMap.entries()]
|
|
487
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
488
|
+
.map(([phpPath, callers]) => {
|
|
489
|
+
const entry = { path: phpPath };
|
|
490
|
+
const called_from = {};
|
|
491
|
+
if (callers.routes.size) called_from.routes = [...callers.routes].sort();
|
|
492
|
+
if (callers.components.size) called_from.components = [...callers.components].sort();
|
|
493
|
+
if (callers.services.size) called_from.services = [...callers.services].sort();
|
|
494
|
+
if (Object.keys(called_from).length) entry.called_from = called_from;
|
|
495
|
+
return entry;
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
126
499
|
// ---------------------------------------------------------------------------
|
|
127
500
|
// Section builders
|
|
128
501
|
// ---------------------------------------------------------------------------
|
|
129
502
|
|
|
130
|
-
function buildComponents(appDir) {
|
|
503
|
+
function buildComponents(appDir, knownHelperNames, knownModifierNames) {
|
|
131
504
|
const componentDir = path.join(appDir, 'components');
|
|
132
505
|
const jsFiles = walkDir(componentDir, (n) => /\.(js|ts)$/.test(n));
|
|
133
506
|
const hbsFiles = walkDir(componentDir, (n) => /\.hbs$/.test(n));
|
|
134
507
|
|
|
135
508
|
const componentMap = new Map();
|
|
136
509
|
for (const jsFile of jsFiles) {
|
|
137
|
-
const
|
|
138
|
-
componentMap.set(
|
|
510
|
+
const slug = nameFromPath(jsFile, appDir, 'components');
|
|
511
|
+
componentMap.set(slug, { ...componentMap.get(slug), jsFile });
|
|
139
512
|
}
|
|
140
513
|
for (const hbsFile of hbsFiles) {
|
|
141
|
-
const
|
|
142
|
-
componentMap.set(
|
|
514
|
+
const slug = nameFromPath(hbsFile, appDir, 'components');
|
|
515
|
+
componentMap.set(slug, { ...componentMap.get(slug), hbsFile });
|
|
143
516
|
}
|
|
144
517
|
|
|
145
|
-
return [...componentMap.entries()].map(([
|
|
146
|
-
const { trackedVars, actions, services } = parseJsFile(jsFile);
|
|
147
|
-
const { inheritedArgs, helpers, modifiers } = parseHbsFile(hbsFile);
|
|
148
|
-
return {
|
|
518
|
+
return [...componentMap.entries()].map(([slug, { jsFile, hbsFile }]) => {
|
|
519
|
+
const { trackedVars, getters, actions, functions, services } = parseJsFile(jsFile);
|
|
520
|
+
const { inheritedArgs, helpers, modifiers } = parseHbsFile(hbsFile, knownHelperNames, knownModifierNames);
|
|
521
|
+
return { slug, tracked_vars: trackedVars, inherited_args: inheritedArgs, getters, actions, functions, helpers, modifiers, services };
|
|
149
522
|
});
|
|
150
523
|
}
|
|
151
524
|
|
|
152
|
-
function buildRoutes(appDir) {
|
|
525
|
+
function buildRoutes(appDir, knownHelperNames, knownModifierNames) {
|
|
153
526
|
return walkDir(path.join(appDir, 'routes'), (n) => /\.(js|ts)$/.test(n)).map((jsFile) => {
|
|
154
|
-
const
|
|
155
|
-
const hbsFile = path.join(appDir, 'templates',
|
|
156
|
-
const { trackedVars, actions, services, getVars } = parseJsFile(jsFile);
|
|
157
|
-
const { helpers, components } = parseHbsFile(hbsFile);
|
|
158
|
-
return {
|
|
527
|
+
const slug = nameFromPath(jsFile, appDir, 'routes');
|
|
528
|
+
const hbsFile = path.join(appDir, 'templates', slug.replace(/\/$/, '') + '.hbs');
|
|
529
|
+
const { trackedVars, getters, actions, functions, services, getVars } = parseJsFile(jsFile);
|
|
530
|
+
const { helpers, components } = parseHbsFile(hbsFile, knownHelperNames, knownModifierNames);
|
|
531
|
+
return { slug, tracked_vars: trackedVars, get_vars: getVars, getters, actions, functions, helpers, services, components };
|
|
159
532
|
});
|
|
160
533
|
}
|
|
161
534
|
|
|
162
535
|
function buildServices(appDir) {
|
|
163
536
|
return walkDir(path.join(appDir, 'services'), (n) => /\.(js|ts)$/.test(n)).map((jsFile) => {
|
|
164
|
-
const
|
|
165
|
-
const { trackedVars, actions, services } = parseJsFile(jsFile);
|
|
166
|
-
return {
|
|
537
|
+
const slug = nameFromPath(jsFile, appDir, 'services');
|
|
538
|
+
const { trackedVars, getters, actions, functions, services } = parseJsFile(jsFile);
|
|
539
|
+
return { slug, tracked_vars: trackedVars, getters, actions, functions, services };
|
|
167
540
|
});
|
|
168
541
|
}
|
|
169
542
|
|
|
170
543
|
function buildHelpers(appDir) {
|
|
171
544
|
return walkDir(path.join(appDir, 'helpers'), (n) => /\.(js|ts)$/.test(n)).map((jsFile) => {
|
|
172
|
-
const
|
|
545
|
+
const slug = nameFromPath(jsFile, appDir, 'helpers');
|
|
173
546
|
const src = fs.readFileSync(jsFile, 'utf8');
|
|
174
547
|
const sig = src.match(/function\s+\w*\s*\(\[([^\]]*)\](?:,\s*\{([^}]*)\})?\)/);
|
|
175
548
|
const posArgs = sig ? sig[1].split(',').map((s) => s.trim()).filter(Boolean).map((a) => ({ [toKebabCase(a)]: 'string' })) : [];
|
|
176
549
|
const namedArgs = sig && sig[2]
|
|
177
550
|
? sig[2].split(',').map((s) => s.trim().split(/\s*=\s*/)[0]).filter(Boolean).map((a) => ({ [toKebabCase(a)]: 'string' }))
|
|
178
551
|
: [];
|
|
179
|
-
return {
|
|
552
|
+
return { slug, args: [...posArgs, ...namedArgs], return: 'string' };
|
|
180
553
|
});
|
|
181
554
|
}
|
|
182
555
|
|
|
183
556
|
function buildModifiers(appDir) {
|
|
184
557
|
return walkDir(path.join(appDir, 'modifiers'), (n) => /\.(js|ts)$/.test(n)).map((jsFile) => {
|
|
185
|
-
const
|
|
558
|
+
const slug = nameFromPath(jsFile, appDir, 'modifiers');
|
|
186
559
|
const { services } = parseJsFile(jsFile);
|
|
187
|
-
return {
|
|
560
|
+
return { slug, args: [], services };
|
|
188
561
|
});
|
|
189
562
|
}
|
|
190
563
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
564
|
+
|
|
565
|
+
// ---------------------------------------------------------------------------
|
|
566
|
+
// Master types list
|
|
567
|
+
// ---------------------------------------------------------------------------
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Collects every model-type slug used anywhere in store calls across
|
|
571
|
+
* routes, components, and services.
|
|
572
|
+
*
|
|
573
|
+
* Matches patterns like:
|
|
574
|
+
* this.store.findRecord('post', id)
|
|
575
|
+
* this.store.query('blog-post', { ... })
|
|
576
|
+
* this.store.findAll('comment')
|
|
577
|
+
* this.store.peekRecord('tag', id)
|
|
578
|
+
* store.createRecord('attachment', { ... })
|
|
579
|
+
*/
|
|
580
|
+
function buildTypes(appDir) {
|
|
581
|
+
const typeSet = new Set();
|
|
582
|
+
|
|
583
|
+
const dirs = [
|
|
584
|
+
path.join(appDir, 'routes'),
|
|
585
|
+
path.join(appDir, 'controllers'),
|
|
586
|
+
path.join(appDir, 'components'),
|
|
587
|
+
path.join(appDir, 'services'),
|
|
588
|
+
];
|
|
589
|
+
|
|
590
|
+
const storeCallRe = /(?:this\.)?store\.(?:findRecord|findAll|query|queryRecord|peekRecord|peekAll|createRecord|pushPayload|normalize)\(\s*['"`]([\w-]+)['"`]/g;
|
|
591
|
+
|
|
592
|
+
for (const dir of dirs) {
|
|
593
|
+
const files = walkDir(dir, (n) => /\.(js|ts)$/.test(n));
|
|
594
|
+
for (const filePath of files) {
|
|
595
|
+
const src = stripJsComments(fs.readFileSync(filePath, 'utf8'));
|
|
596
|
+
for (const [, typeName] of src.matchAll(storeCallRe)) {
|
|
597
|
+
typeSet.add(typeName);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
return [...typeSet].sort().map((slug) => ({ slug }));
|
|
200
603
|
}
|
|
201
604
|
|
|
202
605
|
function compactJSON(value, indent = 2) {
|
|
@@ -206,7 +609,11 @@ function compactJSON(value, indent = 2) {
|
|
|
206
609
|
if (typeof item === 'string') return true;
|
|
207
610
|
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
|
208
611
|
const keys = Object.keys(item);
|
|
209
|
-
|
|
612
|
+
if (keys.length !== 1) return false;
|
|
613
|
+
const val = item[keys[0]];
|
|
614
|
+
// plain string value OR array-of-strings value (mixed actions entries)
|
|
615
|
+
if (typeof val === 'string') return true;
|
|
616
|
+
if (Array.isArray(val) && val.every((v) => typeof v === 'string')) return true;
|
|
210
617
|
}
|
|
211
618
|
return false;
|
|
212
619
|
});
|
|
@@ -225,7 +632,11 @@ function compactJSON(value, indent = 2) {
|
|
|
225
632
|
const items = val.map((item) => {
|
|
226
633
|
if (typeof item === 'string') return JSON.stringify(item);
|
|
227
634
|
const k = Object.keys(item)[0];
|
|
228
|
-
|
|
635
|
+
const v = item[k];
|
|
636
|
+
if (Array.isArray(v)) {
|
|
637
|
+
return `{ ${JSON.stringify(k)}: [${v.map((s) => JSON.stringify(s)).join(', ')}] }`;
|
|
638
|
+
}
|
|
639
|
+
return `{ ${JSON.stringify(k)}: ${JSON.stringify(v)} }`;
|
|
229
640
|
});
|
|
230
641
|
return '[' + items.join(', ') + ']';
|
|
231
642
|
}
|
|
@@ -259,20 +670,20 @@ function stripEmpty(obj) {
|
|
|
259
670
|
}
|
|
260
671
|
|
|
261
672
|
function mergeByKey(existing, scanned, key) {
|
|
262
|
-
|
|
673
|
+
// Support migrating from old 'name'-keyed entries to new 'slug'-keyed entries.
|
|
674
|
+
// If an existing entry has no 'slug' but has a 'name', treat 'name' as the key.
|
|
675
|
+
const existingMap = new Map(existing.map((e) => [e[key] ?? e['name'], e]));
|
|
263
676
|
const scannedMap = new Map(scanned.map((s) => [s[key], s]));
|
|
264
677
|
const allKeys = new Set([...existingMap.keys(), ...scannedMap.keys()]);
|
|
265
678
|
return [...allKeys].map((k) => ({ ...(scannedMap.get(k) || {}) }));
|
|
266
679
|
}
|
|
267
680
|
|
|
268
|
-
|
|
269
|
-
// Run
|
|
270
|
-
// ---------------------------------------------------------------------------
|
|
681
|
+
|
|
271
682
|
|
|
272
683
|
(async () => {
|
|
273
684
|
const cwd = process.cwd();
|
|
274
685
|
const appDir = path.join(cwd, 'app');
|
|
275
|
-
const configDir = path.join(cwd, '
|
|
686
|
+
const configDir = path.join(cwd, 'public');
|
|
276
687
|
const outputFile = path.join(configDir, 'storylang.json');
|
|
277
688
|
|
|
278
689
|
if (!fs.existsSync(appDir)) {
|
|
@@ -282,12 +693,18 @@ function mergeByKey(existing, scanned, key) {
|
|
|
282
693
|
|
|
283
694
|
console.log('Storylang — scanning project files…\n');
|
|
284
695
|
|
|
285
|
-
const components = buildComponents(appDir);
|
|
286
|
-
const routes = buildRoutes(appDir);
|
|
287
|
-
const services = buildServices(appDir);
|
|
288
696
|
const helpers = buildHelpers(appDir);
|
|
289
697
|
const modifiers = buildModifiers(appDir);
|
|
290
|
-
|
|
698
|
+
|
|
699
|
+
// Build lookup sets so parseHbsFile can filter to only real helpers / modifiers
|
|
700
|
+
const knownHelperNames = new Set(helpers.map((h) => h.slug));
|
|
701
|
+
const knownModifierNames = new Set(modifiers.map((m) => m.slug));
|
|
702
|
+
|
|
703
|
+
const components = buildComponents(appDir, knownHelperNames, knownModifierNames);
|
|
704
|
+
const routes = buildRoutes(appDir, knownHelperNames, knownModifierNames);
|
|
705
|
+
const services = buildServices(appDir);
|
|
706
|
+
const customPhp = buildCustomPhp(appDir);
|
|
707
|
+
const types = buildTypes(appDir);
|
|
291
708
|
|
|
292
709
|
let existing = {};
|
|
293
710
|
if (fs.existsSync(outputFile)) {
|
|
@@ -295,22 +712,22 @@ function mergeByKey(existing, scanned, key) {
|
|
|
295
712
|
}
|
|
296
713
|
|
|
297
714
|
const merged = {
|
|
715
|
+
routes: mergeByKey(existing.routes || [], routes, 'slug'),
|
|
716
|
+
services: mergeByKey(existing.services || [], services, 'slug'),
|
|
298
717
|
types: mergeByKey(existing.types || [], types, 'slug'),
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
helpers: mergeByKey(existing.helpers || [], helpers, 'name'),
|
|
303
|
-
modifiers: mergeByKey(existing.modifiers || [], modifiers, 'name'),
|
|
718
|
+
helpers: mergeByKey(existing.helpers || [], helpers, 'slug'),
|
|
719
|
+
modifiers: mergeByKey(existing.modifiers || [], modifiers, 'slug'),
|
|
720
|
+
components: mergeByKey(existing.components || [], components, 'slug'),
|
|
304
721
|
};
|
|
305
722
|
|
|
306
723
|
if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true });
|
|
307
724
|
fs.writeFileSync(outputFile, compactJSON(stripEmpty(merged)) + '\n', 'utf8');
|
|
308
725
|
|
|
309
|
-
console.log(`✅
|
|
310
|
-
console.log(` routes:
|
|
311
|
-
console.log(`
|
|
312
|
-
console.log(`
|
|
313
|
-
console.log(` helpers:
|
|
314
|
-
console.log(` modifiers:
|
|
315
|
-
console.log(`
|
|
726
|
+
console.log(`✅ public/storylang.json updated`);
|
|
727
|
+
console.log(` routes: ${routes.length}`);
|
|
728
|
+
console.log(` services: ${services.length}`);
|
|
729
|
+
console.log(` types: ${types.length}`);
|
|
730
|
+
console.log(` helpers: ${helpers.length}`);
|
|
731
|
+
console.log(` modifiers: ${modifiers.length}`);
|
|
732
|
+
console.log(` components: ${components.length}`);
|
|
316
733
|
})();
|