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.
@@ -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 src = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';
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
- const actions = [...src.matchAll(/@action\s*\n?\s*(?:async\s+)?(\w+)\s*\(/g)].map(
65
- ([, name]) => toKebabCase(name)
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
- const modifiersSet = new Set(
112
- [...src.matchAll(/\{\{([\w-]+-modifier|tooltip|[\w-]+)\s/g)]
113
- .map(([, name]) => name)
114
- .filter((n) => !helpers.includes(n) && !builtinHelpers.has(n))
115
- );
116
- const modifiers = [...modifiersSet];
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 name = nameFromPath(jsFile, appDir, 'components');
138
- componentMap.set(name, { ...componentMap.get(name), jsFile });
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 name = nameFromPath(hbsFile, appDir, 'components');
142
- componentMap.set(name, { ...componentMap.get(name), hbsFile });
514
+ const slug = nameFromPath(hbsFile, appDir, 'components');
515
+ componentMap.set(slug, { ...componentMap.get(slug), hbsFile });
143
516
  }
144
517
 
145
- return [...componentMap.entries()].map(([name, { jsFile, hbsFile }]) => {
146
- const { trackedVars, actions, services } = parseJsFile(jsFile);
147
- const { inheritedArgs, helpers, modifiers } = parseHbsFile(hbsFile);
148
- return { name, tracked_vars: trackedVars, inherited_args: inheritedArgs, actions, helpers, modifiers, services };
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 name = nameFromPath(jsFile, appDir, 'routes');
155
- const hbsFile = path.join(appDir, 'templates', name.replace(/\/$/, '') + '.hbs');
156
- const { trackedVars, actions, services, getVars } = parseJsFile(jsFile);
157
- const { helpers, components } = parseHbsFile(hbsFile);
158
- return { name, tracked_vars: trackedVars, get_vars: getVars, actions, helpers, services, components, types: [] };
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 name = nameFromPath(jsFile, appDir, 'services');
165
- const { trackedVars, actions, services } = parseJsFile(jsFile);
166
- return { name, tracked_vars: trackedVars, actions, services };
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 name = nameFromPath(jsFile, appDir, 'helpers');
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 { name, args: [...posArgs, ...namedArgs], return: 'string' };
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 name = nameFromPath(jsFile, appDir, 'modifiers');
558
+ const slug = nameFromPath(jsFile, appDir, 'modifiers');
186
559
  const { services } = parseJsFile(jsFile);
187
- return { name, args: [], services };
560
+ return { slug, args: [], services };
188
561
  });
189
562
  }
190
563
 
191
- function buildTypes(routes, components) {
192
- const typeMap = new Map();
193
- const register = (typeSlug, category, name) => {
194
- if (!typeMap.has(typeSlug)) typeMap.set(typeSlug, { routes: [], components: [], services: [], helpers: [], modifiers: [] });
195
- typeMap.get(typeSlug)[category].push(name);
196
- };
197
- for (const r of routes) for (const t of r.types || []) register(t, 'routes', r.name);
198
- for (const c of components) for (const t of c.types || []) register(t, 'components', c.name);
199
- return [...typeMap.entries()].map(([slug, used_in]) => ({ slug, used_in }));
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
- return keys.length === 1 && typeof item[keys[0]] === 'string';
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
- return `{ ${JSON.stringify(k)}: ${JSON.stringify(item[k])} }`;
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
- const existingMap = new Map(existing.map((e) => [e[key], e]));
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, 'config');
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
- const types = buildTypes(routes, components);
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
- components: mergeByKey(existing.components || [], components, 'name'),
300
- routes: mergeByKey(existing.routes || [], routes, 'name'),
301
- services: mergeByKey(existing.services || [], services, 'name'),
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(`✅ config/storylang.json updated`);
310
- console.log(` routes: ${routes.length}`);
311
- console.log(` components: ${components.length}`);
312
- console.log(` services: ${services.length}`);
313
- console.log(` helpers: ${helpers.length}`);
314
- console.log(` modifiers: ${modifiers.length}`);
315
- console.log(` types: ${types.length}`);
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
  })();