ember-tribe 2.5.3 → 2.6.1

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/bin/storylang ADDED
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const path = require('path');
5
+ const args = process.argv.slice(2);
6
+ const command = args[0];
7
+ const flags = args.slice(1);
8
+
9
+ const pull = require('../lib/storylang/pull');
10
+ const push = require('../lib/storylang/push');
11
+
12
+ const CWD = process.cwd();
13
+
14
+ function printUsage() {
15
+ console.log(`
16
+ storylang — ember-tribe spec synchroniser
17
+
18
+ Usage:
19
+ storylang <command> [options]
20
+
21
+ Commands:
22
+ pull Updates config/storylang.json from current project files
23
+ push Generates any missing routes, components, services, helpers, modifiers, and controllers
24
+ push -o Regenerates all artefacts in storylang.json, overwriting existing files
25
+
26
+ Examples:
27
+ storylang pull
28
+ storylang push
29
+ storylang push -o
30
+ `);
31
+ }
32
+
33
+ async function main() {
34
+ switch (command) {
35
+ case 'pull':
36
+ await pull(CWD);
37
+ break;
38
+
39
+ case 'push': {
40
+ const overwrite = flags.includes('-o');
41
+ await push(CWD, { overwrite });
42
+ break;
43
+ }
44
+
45
+ case undefined:
46
+ case '--help':
47
+ case '-h':
48
+ printUsage();
49
+ break;
50
+
51
+ default:
52
+ console.error(`Unknown command: "${command}"`);
53
+ printUsage();
54
+ process.exit(1);
55
+ }
56
+ }
57
+
58
+ main().catch((err) => {
59
+ console.error('storylang error:', err.message);
60
+ process.exit(1);
61
+ });
@@ -17,19 +17,20 @@ module.exports = {
17
17
  { name: 'ember-math-helpers' },
18
18
  { name: 'ember-cli-string-helpers' },
19
19
  { name: 'ember-promise-helpers' },
20
- { name: '@ember/test-waiters' },
20
+ { name: '@ember/test-waiters', target: '^3.1.0' },
21
21
  { name: 'ember-tag-input' },
22
22
  { name: 'ember-file-upload' },
23
23
  { name: 'ember-toggle' },
24
- { name: 'ember-concurrency' },
24
+ { name: 'ember-basic-dropdown' },
25
+ { name: 'ember-power-select' },
25
26
  { name: 'ember-click-outside' },
26
27
  { name: 'ember-web-app' },
27
28
  { name: 'tracked-built-ins' },
28
29
  { name: 'ember-keyboard' },
29
30
  { name: 'ember-router-scroll' },
31
+ { name: 'ember-concurrency' },
30
32
  { name: 'ember-table' },
31
33
  { name: 'ember-animated' },
32
- { name: 'ember-power-select' },
33
34
  ],
34
35
  }).then(() => {
35
36
  return this.addPackagesToProject([
@@ -0,0 +1,357 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * storylang pull
5
+ *
6
+ * Scans the current Ember project and writes/updates config/storylang.json
7
+ * from the actual files that exist in the app/ directory.
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Helpers
15
+ // ---------------------------------------------------------------------------
16
+
17
+ function toCamelCase(str) {
18
+ return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
19
+ }
20
+
21
+ /**
22
+ * Walk a directory and return all files (recursively) that pass the filter fn.
23
+ */
24
+ function walkDir(dir, filterFn = () => true) {
25
+ if (!fs.existsSync(dir)) return [];
26
+ const results = [];
27
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
28
+ const fullPath = path.join(dir, entry.name);
29
+ if (entry.isDirectory()) {
30
+ results.push(...walkDir(fullPath, filterFn));
31
+ } else if (filterFn(entry.name, fullPath)) {
32
+ results.push(fullPath);
33
+ }
34
+ }
35
+ return results;
36
+ }
37
+
38
+ /**
39
+ * Given a file path inside app/<category>/, derive a kebab-case name
40
+ * suitable for storylang.json.
41
+ */
42
+ function nameFromPath(filePath, appDir, category) {
43
+ const rel = path.relative(path.join(appDir, category), filePath);
44
+ // strip extension(s) and turn path separators into slashes (nested components)
45
+ return rel
46
+ .replace(/\.(js|ts|hbs|scss|css)$/, '')
47
+ .split(path.sep)
48
+ .join('/');
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Parsers — extract metadata from source files
53
+ // ---------------------------------------------------------------------------
54
+
55
+ /**
56
+ * Very lightweight static analysis: look for @tracked, @action, @service,
57
+ * imported helpers / modifiers, and inherited args from HBS counterpart.
58
+ */
59
+ function parseJsFile(filePath) {
60
+ const src = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';
61
+
62
+ // @tracked varName
63
+ const trackedVars = [...src.matchAll(/@tracked\s+(\w+)\s*(?:=\s*([^;]+))?/g)].map(
64
+ ([, name, val]) => {
65
+ const type = inferType(val);
66
+ return { [name]: type };
67
+ }
68
+ );
69
+
70
+ // @action methodName(
71
+ const actions = [...src.matchAll(/@action\s*\n?\s*(?:async\s+)?(\w+)\s*\(/g)].map(
72
+ ([, name]) => name
73
+ );
74
+
75
+ // @service serviceName;
76
+ const services = [...src.matchAll(/@service\s+(\w+)/g)].map(([, name]) => name);
77
+
78
+ // queryParams = { key: ... }
79
+ const getVars = [...src.matchAll(/queryParams\s*=\s*\{([^}]+)\}/gs)].flatMap(([, block]) =>
80
+ [...block.matchAll(/(\w+)\s*:/g)].map(([, k]) => ({ [k]: 'string' }))
81
+ );
82
+
83
+ return { trackedVars, actions, services, getVars };
84
+ }
85
+
86
+ function inferType(val = '') {
87
+ val = (val || '').trim();
88
+ if (val === 'false' || val === 'true') return 'bool';
89
+ if (val.startsWith('[')) return 'array';
90
+ if (val.startsWith('{') || val.startsWith('new Map') || val.startsWith('new Set'))
91
+ return 'object';
92
+ if (/^\d+$/.test(val)) return 'int';
93
+ if (val.startsWith("'") || val.startsWith('"') || val.startsWith('`')) return 'string';
94
+ return 'string';
95
+ }
96
+
97
+ /**
98
+ * Parse a Handlebars template for:
99
+ * - @arg references → inherited_args
100
+ * - <ComponentName> invocations → sub-components used
101
+ * - {{helper-name}} calls → helpers used
102
+ * - {{modifier}} element modifiers → modifiers used
103
+ */
104
+ function parseHbsFile(filePath) {
105
+ if (!fs.existsSync(filePath)) return { inheritedArgs: [], helpers: [], modifiers: [], components: [] };
106
+
107
+ const src = fs.readFileSync(filePath, 'utf8');
108
+
109
+ // @argName (distinct, excluding @glimmer internals)
110
+ const inheritedArgsSet = new Set(
111
+ [...src.matchAll(/@(\w[\w.]*)/g)]
112
+ .map(([, name]) => name.split('.')[0])
113
+ .filter((n) => !['ember', 'glimmer'].includes(n))
114
+ );
115
+ const inheritedArgs = [...inheritedArgsSet].map((name) => ({ [name]: 'var' }));
116
+
117
+ // {{some-helper ...}} — exclude built-ins
118
+ const builtinHelpers = new Set([
119
+ 'if', 'unless', 'each', 'let', 'with', 'yield', 'outlet', 'component',
120
+ 'on', 'get', 'concat', 'array', 'hash', 'log', 'action', 'mut',
121
+ 'page-title', 'link-to', 'BasicDropdownWormhole',
122
+ ]);
123
+ const helpersSet = new Set(
124
+ [...src.matchAll(/\{\{([\w-]+)/g)]
125
+ .map(([, name]) => name)
126
+ .filter((n) => n.includes('-') && !builtinHelpers.has(n))
127
+ );
128
+ const helpers = [...helpersSet];
129
+
130
+ // {{modifier-name}} inside element modifier position — {{some-modifier ...}}
131
+ const modifiersSet = new Set(
132
+ [...src.matchAll(/\{\{([\w-]+-modifier|autofocus|tooltip|play-when|[\w-]+)\s/g)]
133
+ .map(([, name]) => name)
134
+ .filter((n) => helpers.includes(n) === false && !builtinHelpers.has(n))
135
+ );
136
+ const modifiers = [...modifiersSet];
137
+
138
+ // <ComponentName (PascalCase or path-style)
139
+ const componentsSet = new Set(
140
+ [...src.matchAll(/<([A-Z][\w::/]*)/g)].map(([, name]) => name)
141
+ );
142
+ const components = [...componentsSet];
143
+
144
+ return { inheritedArgs, helpers, modifiers, components };
145
+ }
146
+
147
+ /**
148
+ * Sniff the type of component from its name, matching README built-in types.
149
+ */
150
+ function guessComponentType(name) {
151
+ const LAYOUT = ['table', 'figure', 'accordion', 'card', 'list-group', 'navbar', 'nav', 'tab', 'breadcrumb'];
152
+ const INTERACTIVE = [
153
+ 'button', 'button-group', 'dropdown', 'modal', 'collapse', 'offcanvas', 'pagination',
154
+ 'popover', 'tooltip', 'swiper-carousel', 'videojs-player', 'howlerjs-player', 'input-field',
155
+ 'input-group', 'textarea', 'checkbox', 'radio', 'range', 'select', 'multi-select', 'date',
156
+ 'file-uploader', 'alert', 'badge', 'toast', 'placeholder', 'progress', 'spinner', 'scrollspy',
157
+ ];
158
+ for (const t of [...LAYOUT, ...INTERACTIVE]) {
159
+ if (name.includes(t)) return t;
160
+ }
161
+ return 'component';
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Section builders
166
+ // ---------------------------------------------------------------------------
167
+
168
+ function buildComponents(appDir) {
169
+ const componentDir = path.join(appDir, 'components');
170
+ const jsFiles = walkDir(componentDir, (n) => /\.(js|ts)$/.test(n));
171
+
172
+ return jsFiles.map((jsFile) => {
173
+ const name = nameFromPath(jsFile, appDir, 'components');
174
+ const hbsFile = jsFile.replace(/\.(js|ts)$/, '.hbs');
175
+ const { trackedVars, actions, services } = parseJsFile(jsFile);
176
+ const { inheritedArgs, helpers, modifiers } = parseHbsFile(hbsFile);
177
+
178
+ return {
179
+ name,
180
+ type: guessComponentType(name),
181
+ tracked_vars: trackedVars,
182
+ inherited_args: inheritedArgs,
183
+ actions,
184
+ helpers,
185
+ modifiers,
186
+ services,
187
+ };
188
+ });
189
+ }
190
+
191
+ function buildRoutes(appDir) {
192
+ const routeDir = path.join(appDir, 'routes');
193
+ const jsFiles = walkDir(routeDir, (n) => /\.(js|ts)$/.test(n));
194
+
195
+ return jsFiles.map((jsFile) => {
196
+ const name = nameFromPath(jsFile, appDir, 'routes');
197
+ const hbsFile = path.join(appDir, 'templates', name.replace(/\/$/, '') + '.hbs');
198
+ const { trackedVars, actions, services, getVars } = parseJsFile(jsFile);
199
+ const { helpers, components } = parseHbsFile(hbsFile);
200
+
201
+ return {
202
+ name,
203
+ tracked_vars: trackedVars,
204
+ get_vars: getVars,
205
+ actions,
206
+ helpers,
207
+ services,
208
+ components,
209
+ types: [],
210
+ };
211
+ });
212
+ }
213
+
214
+ function buildServices(appDir) {
215
+ const serviceDir = path.join(appDir, 'services');
216
+ const jsFiles = walkDir(serviceDir, (n) => /\.(js|ts)$/.test(n));
217
+
218
+ return jsFiles.map((jsFile) => {
219
+ const name = nameFromPath(jsFile, appDir, 'services');
220
+ const { trackedVars, actions, services } = parseJsFile(jsFile);
221
+ return { name, tracked_vars: trackedVars, actions, services };
222
+ });
223
+ }
224
+
225
+ function buildHelpers(appDir) {
226
+ const helperDir = path.join(appDir, 'helpers');
227
+ const jsFiles = walkDir(helperDir, (n) => /\.(js|ts)$/.test(n));
228
+
229
+ return jsFiles.map((jsFile) => {
230
+ const name = nameFromPath(jsFile, appDir, 'helpers');
231
+ const src = fs.readFileSync(jsFile, 'utf8');
232
+
233
+ // Infer args from function signature
234
+ const sig = src.match(/function\s+\w*\s*\(\[([^\]]*)\](?:,\s*\{([^}]*)\})?\)/);
235
+ const posArgs = sig ? sig[1].split(',').map((s) => s.trim()).filter(Boolean).map((a) => ({ [a]: 'string' })) : [];
236
+ const namedArgs = sig && sig[2]
237
+ ? sig[2].split(',').map((s) => s.trim().split(/\s*=\s*/)[0]).filter(Boolean).map((a) => ({ [a]: 'string' }))
238
+ : [];
239
+
240
+ return {
241
+ name,
242
+ description: '',
243
+ args: [...posArgs, ...namedArgs],
244
+ return: 'string',
245
+ };
246
+ });
247
+ }
248
+
249
+ function buildModifiers(appDir) {
250
+ const modifierDir = path.join(appDir, 'modifiers');
251
+ const jsFiles = walkDir(modifierDir, (n) => /\.(js|ts)$/.test(n));
252
+
253
+ return jsFiles.map((jsFile) => {
254
+ const name = nameFromPath(jsFile, appDir, 'modifiers');
255
+ const { services } = parseJsFile(jsFile);
256
+ return { name, description: '', args: [], services };
257
+ });
258
+ }
259
+
260
+ /**
261
+ * Build the types section by cross-referencing component/route usages.
262
+ */
263
+ function buildTypes(routes, components, services) {
264
+ const typeMap = new Map();
265
+
266
+ function register(typeSlug, category, name) {
267
+ if (!typeMap.has(typeSlug)) {
268
+ typeMap.set(typeSlug, { routes: [], components: [], services: [], helpers: [], modifiers: [] });
269
+ }
270
+ typeMap.get(typeSlug)[category].push(name);
271
+ }
272
+
273
+ for (const r of routes) {
274
+ for (const t of r.types || []) register(t, 'routes', r.name);
275
+ }
276
+ for (const c of components) {
277
+ for (const t of c.types || []) register(t, 'components', c.name);
278
+ }
279
+
280
+ return [...typeMap.entries()].map(([slug, used_in]) => ({ slug, used_in }));
281
+ }
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // Main export
285
+ // ---------------------------------------------------------------------------
286
+
287
+ module.exports = async function pull(cwd) {
288
+ const appDir = path.join(cwd, 'app');
289
+ const configDir = path.join(cwd, 'config');
290
+ const outputFile = path.join(configDir, 'storylang.json');
291
+
292
+ if (!fs.existsSync(appDir)) {
293
+ throw new Error(
294
+ `Could not find app/ directory at ${appDir}. ` +
295
+ `Make sure you are running storylang from the root of your Ember project.`
296
+ );
297
+ }
298
+
299
+ console.log('šŸ“– storylang pull — scanning project files…\n');
300
+
301
+ const components = buildComponents(appDir);
302
+ const routes = buildRoutes(appDir);
303
+ const services = buildServices(appDir);
304
+ const helpers = buildHelpers(appDir);
305
+ const modifiers = buildModifiers(appDir);
306
+ const types = buildTypes(routes, components, services);
307
+
308
+ // Merge with existing storylang.json so hand-written fields are preserved
309
+ let existing = {};
310
+ if (fs.existsSync(outputFile)) {
311
+ try {
312
+ existing = JSON.parse(fs.readFileSync(outputFile, 'utf8'));
313
+ } catch (_) {
314
+ // corrupt file — start fresh
315
+ }
316
+ }
317
+
318
+ const merged = {
319
+ implementation_approach: existing.implementation_approach || '',
320
+ types: mergeByKey(existing.types || [], types, 'slug'),
321
+ components: mergeByKey(existing.components || [], components, 'name'),
322
+ routes: mergeByKey(existing.routes || [], routes, 'name'),
323
+ services: mergeByKey(existing.services || [], services, 'name'),
324
+ helpers: mergeByKey(existing.helpers || [], helpers, 'name'),
325
+ modifiers: mergeByKey(existing.modifiers || [], modifiers, 'name'),
326
+ };
327
+
328
+ if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true });
329
+ fs.writeFileSync(outputFile, JSON.stringify(merged, null, 2) + '\n', 'utf8');
330
+
331
+ console.log(`āœ… config/storylang.json updated`);
332
+ console.log(` routes: ${routes.length}`);
333
+ console.log(` components: ${components.length}`);
334
+ console.log(` services: ${services.length}`);
335
+ console.log(` helpers: ${helpers.length}`);
336
+ console.log(` modifiers: ${modifiers.length}`);
337
+ console.log(` types: ${types.length}`);
338
+ };
339
+
340
+ /**
341
+ * Merge two arrays by a key, preferring scanned values for structural fields
342
+ * but keeping existing hand-written description / implementation_approach fields.
343
+ */
344
+ function mergeByKey(existing, scanned, key) {
345
+ const existingMap = new Map(existing.map((e) => [e[key], e]));
346
+ const scannedMap = new Map(scanned.map((s) => [s[key], s]));
347
+
348
+ const allKeys = new Set([...existingMap.keys(), ...scannedMap.keys()]);
349
+ const result = [];
350
+ for (const k of allKeys) {
351
+ const e = existingMap.get(k) || {};
352
+ const s = scannedMap.get(k) || {};
353
+ // scanned data wins for structural fields; preserve description from existing
354
+ result.push({ ...s, description: e.description || s.description || '' });
355
+ }
356
+ return result;
357
+ }
@@ -0,0 +1,403 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * storylang push [--overwrite / -o]
5
+ *
6
+ * Reads config/storylang.json and generates missing (or all, when -o is set)
7
+ * Ember project files: routes, controllers, components, services, helpers,
8
+ * modifiers, and their corresponding templates where applicable.
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Small utilities
16
+ // ---------------------------------------------------------------------------
17
+
18
+ function toPascalCase(str) {
19
+ return str
20
+ .split(/[-/]/)
21
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
22
+ .join('');
23
+ }
24
+
25
+ function toCamelCase(str) {
26
+ const pascal = toPascalCase(str);
27
+ return pascal.charAt(0).toLowerCase() + pascal.slice(1);
28
+ }
29
+
30
+ function ensureDir(filePath) {
31
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
32
+ }
33
+
34
+ /**
35
+ * Write a file only if it does not exist (or overwrite=true).
36
+ * Returns true if the file was actually written.
37
+ */
38
+ function writeFile(filePath, content, overwrite) {
39
+ if (!overwrite && fs.existsSync(filePath)) return false;
40
+ ensureDir(filePath);
41
+ fs.writeFileSync(filePath, content, 'utf8');
42
+ return true;
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Template generators
47
+ // ---------------------------------------------------------------------------
48
+
49
+ /**
50
+ * Route JS file
51
+ */
52
+ function routeJs(def) {
53
+ const className = toPascalCase(def.name) + 'Route';
54
+ const services = def.services || [];
55
+ const getVars = def.get_vars || [];
56
+ const actions = def.actions || [];
57
+
58
+ const serviceImports = services.length ? `import { service } from '@ember/service';\n` : '';
59
+ const actionImport = actions.length ? `import { action } from '@ember/object';\n` : '';
60
+
61
+ const serviceProps = services.map((s) => ` @service ${toCamelCase(s)};`).join('\n');
62
+
63
+ const queryParamsBlock =
64
+ getVars.length
65
+ ? `\n queryParams = {\n${getVars
66
+ .map((v) => {
67
+ const key = Object.keys(v)[0];
68
+ return ` ${key}: { refreshModel: true },`;
69
+ })
70
+ .join('\n')}\n };\n`
71
+ : '';
72
+
73
+ const modelArgs = getVars.length ? 'params' : '';
74
+ const modelBody =
75
+ getVars.length
76
+ ? ` // TODO: use params to filter/paginate\n // return await this.store.query('type-name', { page: { offset: 0, limit: 10 } });`
77
+ : ` // TODO: return model data\n // return await this.store.query('type-name', {});`;
78
+
79
+ const actionMethods = actions
80
+ .map(
81
+ (a) =>
82
+ `\n @action\n async ${a}() {\n // TODO: implement ${a}\n }`
83
+ )
84
+ .join('\n');
85
+
86
+ return `import Route from '@ember/routing/route';
87
+ ${serviceImports}${actionImport}
88
+ export default class ${className} extends Route {
89
+ ${serviceProps ? serviceProps + '\n' : ''}${queryParamsBlock}
90
+ async model(${modelArgs}) {
91
+ ${modelBody}
92
+ }
93
+ ${actionMethods}
94
+ }
95
+ `;
96
+ }
97
+
98
+ /**
99
+ * Controller JS file (minimal — logic lives in components)
100
+ */
101
+ function controllerJs(def) {
102
+ const className = toPascalCase(def.name) + 'Controller';
103
+ return `import Controller from '@ember/controller';
104
+
105
+ export default class ${className} extends Controller {
106
+ // Minimal controller — keep business logic in components and services.
107
+ }
108
+ `;
109
+ }
110
+
111
+ /**
112
+ * Route HBS template
113
+ */
114
+ function routeHbs(def) {
115
+ const components = def.components || [];
116
+ const inner = components.length
117
+ ? components.map((c) => ` <${toPascalCase(c)} />`).join('\n')
118
+ : ' {{outlet}}';
119
+ return `<div class="route-${def.name}">\n${inner}\n</div>\n`;
120
+ }
121
+
122
+ /**
123
+ * Component JS file
124
+ */
125
+ function componentJs(def) {
126
+ const className = toPascalCase(def.name) + 'Component';
127
+ const trackedVars = def.tracked_vars || [];
128
+ const actions = def.actions || [];
129
+ const services = def.services || [];
130
+
131
+ const hasTracked = trackedVars.length > 0;
132
+ const hasActions = actions.length > 0;
133
+ const hasServices = services.length > 0;
134
+
135
+ const imports = [
136
+ `import Component from '@glimmer/component';`,
137
+ hasTracked ? `import { tracked } from '@glimmer/tracking';` : '',
138
+ hasActions ? `import { action } from '@ember/object';` : '',
139
+ hasServices ? `import { service } from '@ember/service';` : '',
140
+ ]
141
+ .filter(Boolean)
142
+ .join('\n');
143
+
144
+ const serviceProps = services.map((s) => ` @service ${toCamelCase(s)};`).join('\n');
145
+ const trackedProps = trackedVars
146
+ .map((v) => {
147
+ const [name, type] = Object.entries(v)[0];
148
+ const defaultVal = defaultForType(type);
149
+ return ` @tracked ${name} = ${defaultVal};`;
150
+ })
151
+ .join('\n');
152
+
153
+ const actionMethods = actions
154
+ .map(
155
+ (a) =>
156
+ `\n @action\n async ${a}() {\n // TODO: implement ${a}\n }`
157
+ )
158
+ .join('\n');
159
+
160
+ return `${imports}
161
+
162
+ export default class ${className} extends Component {
163
+ ${serviceProps ? serviceProps + '\n' : ''}${trackedProps ? trackedProps + '\n' : ''}${actionMethods}
164
+ }
165
+ `;
166
+ }
167
+
168
+ function defaultForType(type) {
169
+ switch (type) {
170
+ case 'bool': return 'false';
171
+ case 'int': return '0';
172
+ case 'array': return '[]';
173
+ case 'object': return 'null';
174
+ default: return 'null';
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Component HBS template
180
+ */
181
+ function componentHbs(def) {
182
+ const type = def.type || 'component';
183
+ const inheritedArgs = def.inherited_args || [];
184
+
185
+ // Pick a sensible wrapper element/class based on component type
186
+ const wrapperClass = bootstrapClass(type);
187
+ const argDisplay = inheritedArgs
188
+ .filter((a) => {
189
+ const key = Object.keys(a)[0];
190
+ const argType = Object.values(a)[0];
191
+ return argType === 'var';
192
+ })
193
+ .map((a) => {
194
+ const key = Object.keys(a)[0];
195
+ return ` <p>{{@${key}}}</p>`;
196
+ })
197
+ .join('\n');
198
+
199
+ return `<div class="${wrapperClass}">
200
+ ${argDisplay || ' {{! TODO: add template content }}'}
201
+ </div>
202
+ `;
203
+ }
204
+
205
+ function bootstrapClass(type) {
206
+ const map = {
207
+ card: 'card',
208
+ table: 'table-responsive',
209
+ modal: 'modal',
210
+ accordion: 'accordion',
211
+ alert: 'alert',
212
+ badge: 'badge',
213
+ toast: 'toast',
214
+ navbar: 'navbar navbar-expand-lg',
215
+ nav: 'nav',
216
+ tab: 'nav nav-tabs',
217
+ breadcrumb: 'breadcrumb',
218
+ 'list-group': 'list-group',
219
+ button: 'btn btn-primary',
220
+ 'button-group': 'btn-group',
221
+ dropdown: 'dropdown',
222
+ collapse: 'collapse',
223
+ offcanvas: 'offcanvas',
224
+ pagination: 'pagination',
225
+ progress: 'progress',
226
+ spinner: 'spinner-border',
227
+ };
228
+ return map[type] || 'container';
229
+ }
230
+
231
+ /**
232
+ * Service JS file
233
+ */
234
+ function serviceJs(def) {
235
+ const className = toPascalCase(def.name) + 'Service';
236
+ const trackedVars = def.tracked_vars || [];
237
+ const actions = def.actions || [];
238
+ const services = def.services || [];
239
+
240
+ const hasTracked = trackedVars.length > 0;
241
+ const hasActions = actions.length > 0;
242
+ const hasServices = services.length > 0;
243
+
244
+ const imports = [
245
+ `import Service from '@ember/service';`,
246
+ hasTracked ? `import { tracked } from '@glimmer/tracking';` : '',
247
+ hasActions ? `import { action } from '@ember/object';` : '',
248
+ hasServices ? `import { service } from '@ember/service';` : '',
249
+ ]
250
+ .filter(Boolean)
251
+ .join('\n');
252
+
253
+ const serviceProps = services.map((s) => ` @service ${toCamelCase(s)};`).join('\n');
254
+ const trackedProps = trackedVars
255
+ .map((v) => {
256
+ const [name, type] = Object.entries(v)[0];
257
+ return ` @tracked ${name} = ${defaultForType(type)};`;
258
+ })
259
+ .join('\n');
260
+
261
+ const actionMethods = actions
262
+ .map(
263
+ (a) =>
264
+ `\n async ${a}() {\n // TODO: implement ${a}\n }`
265
+ )
266
+ .join('\n');
267
+
268
+ return `${imports}
269
+
270
+ export default class ${className} extends Service {
271
+ ${serviceProps ? serviceProps + '\n' : ''}${trackedProps ? trackedProps + '\n' : ''}${actionMethods}
272
+ }
273
+ `;
274
+ }
275
+
276
+ /**
277
+ * Helper JS file
278
+ */
279
+ function helperJs(def) {
280
+ const fnName = toCamelCase(def.name);
281
+ const argNames = (def.args || []).map((a) => Object.keys(a)[0]);
282
+ const positional = argNames.length ? `[${argNames.join(', ')}]` : '[]';
283
+ const description = def.description ? `// ${def.description}\n` : '';
284
+
285
+ return `import { helper } from '@ember/component/helper';
286
+
287
+ ${description}export default helper(function ${fnName}(${positional}/*, namedArgs*/) {
288
+ // TODO: implement ${def.name}
289
+ return '';
290
+ });
291
+ `;
292
+ }
293
+
294
+ /**
295
+ * Modifier JS file
296
+ */
297
+ function modifierJs(def) {
298
+ const argNames = (def.args || []).map((a) => Object.keys(a)[0]);
299
+ const positional = argNames.length ? `[${argNames.join(', ')}]` : '[]';
300
+ const services = def.services || [];
301
+ const description = def.description ? `// ${def.description}\n` : '';
302
+ const serviceImports = services.length ? `import { service } from '@ember/service';\n` : '';
303
+
304
+ return `import { modifier } from 'ember-modifier';
305
+ ${serviceImports}
306
+ ${description}export default modifier((element, ${positional}/*, namedArgs*/) => {
307
+ // TODO: implement ${def.name} modifier
308
+
309
+ return () => {
310
+ // cleanup
311
+ };
312
+ });
313
+ `;
314
+ }
315
+
316
+ // ---------------------------------------------------------------------------
317
+ // Main export
318
+ // ---------------------------------------------------------------------------
319
+
320
+ module.exports = async function push(cwd, { overwrite = false } = {}) {
321
+ const configFile = path.join(cwd, 'config', 'storylang.json');
322
+
323
+ if (!fs.existsSync(configFile)) {
324
+ throw new Error(
325
+ `config/storylang.json not found at ${configFile}.\n` +
326
+ `Run "storylang pull" first, or create config/storylang.json manually.`
327
+ );
328
+ }
329
+
330
+ let spec;
331
+ try {
332
+ spec = JSON.parse(fs.readFileSync(configFile, 'utf8'));
333
+ } catch (e) {
334
+ throw new Error(`Failed to parse config/storylang.json: ${e.message}`);
335
+ }
336
+
337
+ const appDir = path.join(cwd, 'app');
338
+ const mode = overwrite ? '(overwrite mode)' : '(skip existing)';
339
+ console.log(`šŸš€ storylang push ${mode}\n`);
340
+
341
+ const stats = { written: 0, skipped: 0 };
342
+
343
+ function write(filePath, content) {
344
+ const wrote = writeFile(filePath, content, overwrite);
345
+ if (wrote) {
346
+ console.log(` āœ” ${path.relative(cwd, filePath)}`);
347
+ stats.written++;
348
+ } else {
349
+ console.log(` – ${path.relative(cwd, filePath)} (skipped — already exists)`);
350
+ stats.skipped++;
351
+ }
352
+ }
353
+
354
+ // ── Routes ──────────────────────────────────────────────────────────────
355
+ if (spec.routes && spec.routes.length) {
356
+ console.log('Routes:');
357
+ for (const def of spec.routes) {
358
+ if (!def.name) continue;
359
+ write(path.join(appDir, 'routes', `${def.name}.js`), routeJs(def));
360
+ write(path.join(appDir, 'controllers', `${def.name}.js`), controllerJs(def));
361
+ write(path.join(appDir, 'templates', `${def.name}.hbs`), routeHbs(def));
362
+ }
363
+ }
364
+
365
+ // ── Components ──────────────────────────────────────────────────────────
366
+ if (spec.components && spec.components.length) {
367
+ console.log('\nComponents:');
368
+ for (const def of spec.components) {
369
+ if (!def.name) continue;
370
+ write(path.join(appDir, 'components', `${def.name}.js`), componentJs(def));
371
+ write(path.join(appDir, 'components', `${def.name}.hbs`), componentHbs(def));
372
+ }
373
+ }
374
+
375
+ // ── Services ─────────────────────────────────────────────────────────────
376
+ if (spec.services && spec.services.length) {
377
+ console.log('\nServices:');
378
+ for (const def of spec.services) {
379
+ if (!def.name) continue;
380
+ write(path.join(appDir, 'services', `${def.name}.js`), serviceJs(def));
381
+ }
382
+ }
383
+
384
+ // ── Helpers ──────────────────────────────────────────────────────────────
385
+ if (spec.helpers && spec.helpers.length) {
386
+ console.log('\nHelpers:');
387
+ for (const def of spec.helpers) {
388
+ if (!def.name) continue;
389
+ write(path.join(appDir, 'helpers', `${def.name}.js`), helperJs(def));
390
+ }
391
+ }
392
+
393
+ // ── Modifiers ────────────────────────────────────────────────────────────
394
+ if (spec.modifiers && spec.modifiers.length) {
395
+ console.log('\nModifiers:');
396
+ for (const def of spec.modifiers) {
397
+ if (!def.name) continue;
398
+ write(path.join(appDir, 'modifiers', `${def.name}.js`), modifierJs(def));
399
+ }
400
+ }
401
+
402
+ console.log(`\nāœ… Done — ${stats.written} file(s) written, ${stats.skipped} skipped.`);
403
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ember-tribe",
3
- "version": "2.5.3",
3
+ "version": "2.6.1",
4
4
  "description": "The default blueprint for using Tribe API and Junction within EmberJS.",
5
5
  "keywords": [
6
6
  "ember-addon"
@@ -25,6 +25,9 @@
25
25
  "test:ember": "ember test",
26
26
  "test:ember-compatibility": "ember try:each"
27
27
  },
28
+ "bin": {
29
+ "storylang": "bin/storylang"
30
+ },
28
31
  "dependencies": {
29
32
  "ember-cli-babel": "^7.26.11",
30
33
  "ember-cli-htmlbars": "^6.0.1"
@@ -78,4 +81,4 @@
78
81
  "ember-addon": {
79
82
  "configPath": "tests/dummy/config"
80
83
  }
81
- }
84
+ }