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 +61 -0
- package/blueprints/ember-tribe/index.js +4 -3
- package/lib/storylang/pull.js +357 -0
- package/lib/storylang/push.js +403 -0
- package/package.json +5 -2
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-
|
|
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.
|
|
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
|
+
}
|