Skip to content

Commit 3cfa7bf

Browse files
authored
Merge pull request #29 from acrontum/feat/custom-templates
feat/custom-templates
2 parents 3d40f45 + ad067aa commit 3cfa7bf

33 files changed

+1435
-70
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"scripts": {
1515
"prebuild": "rm -rf dist/*",
1616
"build": "tsc",
17-
"lint": "eslint src test",
17+
"lint": "eslint '{src,test}/**/*.ts'",
1818
"version": "npm verison --no-commit-hooks --no-git-tag-version",
1919
"setup": "git config core.hooksPath ./githooks",
2020
"docs": "npm run docs-readme && npm run docs-changelog",

readme.md

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,120 @@ npm i --save-dev @acrontum/boats-cli
7373
then you can just run `npx bc model test --dry-run`.
7474

7575

76+
## Custom Generator Overrides
77+
78+
You can override any of the generators by adding any of these files to a templates folder, and passing `--templates <folder>` to the cli:
79+
- boats-rc.js
80+
- component-index.js
81+
- create.js
82+
- delete.js
83+
- index.js
84+
- list.js
85+
- model.js
86+
- models.js
87+
- pagination-model.js
88+
- param.js
89+
- path-index.js
90+
- replace.js
91+
- show.js
92+
- update.js
93+
94+
Or, alternatively, exporting any of the following methods from a module (local file or node module):
95+
```js
96+
exports.getBoatsRc = (opts, file) => { /* ... */ };
97+
exports.getIndex = (opts, file) => { /* ... */ };
98+
exports.getComponentIndex = (opts, file) => { /* ... */ };
99+
exports.getModel = (opts, file) => { /* ... */ };
100+
exports.getModels = (opts, file) => { /* ... */ };
101+
exports.getParam = (opts, file) => { /* ... */ };
102+
exports.getPaginationModel = (opts, file) => { /* ... */ };
103+
exports.getPathIndex = (opts, file) => { /* ... */ };
104+
exports.getList = (opts, file) => { /* ... */ };
105+
exports.getCreate = (opts, file) => { /* ... */ };
106+
exports.getShow = (opts, file) => { /* ... */ };
107+
exports.getDelete = (opts, file) => { /* ... */ };
108+
exports.getUpdate = (opts, file) => { /* ... */ };
109+
exports.getReplace = (opts, file) => { /* ... */ };
110+
```
111+
112+
for exmaple, `templates/index.js` or `exports.getList`:
113+
```js
114+
// @ts-check
115+
const { toYaml } = require('@acrontum/boats-cli/dist/src/lib');
116+
117+
/** @type{import('@acrontum/boats-cli/').CustomTemplates['getList']} */
118+
module.exports = (_globalOptions, file, pluralName, schemaRef, parameters) => {
119+
return toYaml({
120+
summary: `from ${file}`,
121+
description: `pluralName ${pluralName}`,
122+
...(parameters?.length ? { parameters } : {}),
123+
responses: {
124+
'"200"': {
125+
description: 'Success',
126+
content: {
127+
'application/json': {
128+
schema: { $ref: schemaRef },
129+
},
130+
},
131+
},
132+
},
133+
});
134+
};
135+
136+
````
137+
138+
or disabling the default generator and instead creating 2 different files for models `templates/model.yml` or `exports.getModel`:
139+
```js
140+
// @ts-check
141+
const { toYaml } = require('@acrontum/boats-cli/dist/src/lib');
142+
const { writeFile, mkdir } = require('node:fs/promises');
143+
const { dirname, join } = require('node:path');
144+
145+
/** @type{import('@acrontum/boats-cli/').CustomTemplates['getModel']} */
146+
module.exports = async (globalOptions, file) => {
147+
const base = join(globalOptions.output || '.', file.replace('model.yml', 'base.yml'));
148+
const extend = join(globalOptions.output || '.', file.replace('model.yml', 'extend.yml'));
149+
150+
await mkdir(dirname(base), { recursive: true });
151+
152+
await Promise.all([
153+
writeFile(
154+
base,
155+
toYaml({
156+
type: 'object',
157+
required: ['name'],
158+
properties: {
159+
name: { type: 'string' },
160+
},
161+
}),
162+
),
163+
writeFile(
164+
extend,
165+
toYaml({
166+
allOf: [
167+
{ $ref: './base.yml' },
168+
{
169+
type: 'object',
170+
properties: {
171+
id: {
172+
type: 'string',
173+
format: 'uuid',
174+
},
175+
},
176+
},
177+
],
178+
}),
179+
),
180+
]);
181+
182+
// prevent default generation
183+
return '';
184+
};
185+
```
186+
187+
see [custom-models.spec.ts](./test/custom-models.spec.ts) and the [overrides folder]('./test/fixtures/overrides/') for more examples.
188+
189+
76190
## Development
77191

78192
Configure githooks:

src/cli.ts

Lines changed: 165 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
#!/usr/bin/env node
22

3-
import { mkdir } from 'node:fs/promises';
4-
import { relative } from 'node:path';
3+
import { mkdir, access, stat, readdir } from 'node:fs/promises';
4+
import { join, relative, resolve } from 'node:path';
55
import { parseArgs, ParseArgsConfig } from 'node:util';
66
import { generate, GenerationTask, logger } from './generate';
77
import { argname, description } from './lib';
88
import { parseInitCommand } from './subcommands/init';
99
import { modelCliArguments, parseModelCommand } from './subcommands/model';
1010
import { parsePathCommand, pathCliArguments } from './subcommands/path';
1111
import { getBoatsRc, getIndex } from './templates/init';
12+
import { getComponentIndex, getModel, getModels, getPaginationModel, getParam } from './templates/model';
13+
import { getCreate, getDelete, getList, getPathIndex, getReplace, getShow, getUpdate } from './templates/path';
1214

1315
export type ParseArgsOptionConfig = NonNullable<ParseArgsConfig['options']>[string];
1416
export type CliArg = ParseArgsOptionConfig & { [description]: string; [argname]?: string };
@@ -22,8 +24,27 @@ export type GlobalOptions = {
2224
quiet?: boolean;
2325
verbose?: boolean;
2426
'root-ref'?: string;
27+
customTemplates?: {
28+
getBoatsRc?: typeof getBoatsRc;
29+
getIndex?: typeof getIndex;
30+
31+
getComponentIndex?: typeof getComponentIndex;
32+
getModel?: typeof getModel;
33+
getModels?: typeof getModels;
34+
getParam?: typeof getParam;
35+
getPaginationModel?: typeof getPaginationModel;
36+
37+
getPathIndex?: typeof getPathIndex;
38+
getList?: typeof getList;
39+
getCreate?: typeof getCreate;
40+
getShow?: typeof getShow;
41+
getDelete?: typeof getDelete;
42+
getUpdate?: typeof getUpdate;
43+
getReplace?: typeof getReplace;
44+
};
2545
};
2646
export type SubcommandGenerator = (args: string[], options: GlobalOptions) => GenerationTask[] | null;
47+
export type CustomTemplates = Exclude<GlobalOptions['customTemplates'], undefined>;
2748

2849
/**
2950
* Custom error to immediately exit on errors.
@@ -45,8 +66,35 @@ const subcommands: Record<string, SubcommandGenerator> = {
4566
init: parseInitCommand,
4667
};
4768

69+
const templateFileMapping = {
70+
'boats-rc.js': 'getBoatsRc',
71+
'component-index.js': 'getComponentIndex',
72+
'create.js': 'getCreate',
73+
'delete.js': 'getDelete',
74+
'index.js': 'getIndex',
75+
'list.js': 'getList',
76+
'model.js': 'getModel',
77+
'models.js': 'getModels',
78+
'pagination-model.js': 'getPaginationModel',
79+
'param.js': 'getParam',
80+
'path-index.js': 'getPathIndex',
81+
'replace.js': 'getReplace',
82+
'show.js': 'getShow',
83+
'update.js': 'getUpdate',
84+
} as const satisfies Record<string, keyof Exclude<GlobalOptions['customTemplates'], undefined>>;
85+
4886
export const cliArguments: Record<string, CliArg> = {
87+
/*
88+
-t --templates
89+
create.js delete.js list.js model.js models.js pagination.js param.js replace.js show.js update.js
90+
*/
4991
'dry-run': { type: 'boolean', short: 'D', [description]: 'Print the changes to be made' },
92+
templates: {
93+
type: 'string',
94+
short: 'T',
95+
[argname]: 'TEMPLATES',
96+
[description]: 'Folder or module containing template overrides',
97+
},
5098
force: { type: 'boolean', short: 'f', [description]: 'Overwrite existing files' },
5199
'no-index': { type: 'boolean', short: 'I', [description]: 'Skip auto-creating index files, only models' },
52100
'no-init': { type: 'boolean', short: 'N', [description]: 'Skip auto-creating init files' },
@@ -114,6 +162,11 @@ Subcommands:
114162
model SINGULAR_NAME [...OPTIONS]
115163
init [-a]
116164
165+
If templates are used (-T, --templates), the following filenames / exports are used (files when path is a folder, export function name if is a module):
166+
- ${Object.entries(templateFileMapping)
167+
.map(([file, fn]) => `${file.padEnd(19, ' ')} - ${fn}`)
168+
.join('\n - ')}
169+
117170
Examples:
118171
npx bc path users/:id --list --get --delete --patch --put
119172
@@ -153,6 +206,90 @@ export const parseCliArgs = (
153206
}
154207
};
155208

209+
const tryRequire = (path: string): GlobalOptions['customTemplates'] | null => {
210+
const overrides: Exclude<GlobalOptions['customTemplates'], undefined> = {};
211+
let lib: Exclude<GlobalOptions['customTemplates'], undefined> | null = null;
212+
try {
213+
// eslint-disable-next-line @typescript-eslint/no-require-imports
214+
lib = require(path) as Exclude<GlobalOptions['customTemplates'], undefined>;
215+
} catch (_) {}
216+
try {
217+
// eslint-disable-next-line @typescript-eslint/no-require-imports
218+
lib = require(resolve(path)) as Exclude<GlobalOptions['customTemplates'], undefined>;
219+
} catch (_) {}
220+
221+
if (lib) {
222+
overrides.getBoatsRc = lib.getBoatsRc;
223+
overrides.getIndex = lib.getIndex;
224+
overrides.getComponentIndex = lib.getComponentIndex;
225+
overrides.getModel = lib.getModel;
226+
overrides.getModels = lib.getModels;
227+
overrides.getParam = lib.getParam;
228+
overrides.getPaginationModel = lib.getPaginationModel;
229+
overrides.getPathIndex = lib.getPathIndex;
230+
overrides.getList = lib.getList;
231+
overrides.getCreate = lib.getCreate;
232+
overrides.getShow = lib.getShow;
233+
overrides.getDelete = lib.getDelete;
234+
overrides.getUpdate = lib.getUpdate;
235+
overrides.getReplace = lib.getReplace;
236+
237+
if (!Object.values(overrides).find((v) => typeof v !== 'undefined')) {
238+
logger.console.error(`cannot load templates "${path}": module has no override exports\n`);
239+
240+
return help(1);
241+
}
242+
243+
return overrides;
244+
}
245+
246+
logger.console.error(`cannot load templates "${path}": not a module\n`);
247+
248+
return help(1);
249+
};
250+
251+
const getTemplates = async (path: string): Promise<GlobalOptions['customTemplates'] | null> => {
252+
const overrides: Exclude<GlobalOptions['customTemplates'], undefined> = {};
253+
const fullPath = resolve(path);
254+
255+
const accessible = await access(fullPath)
256+
.then(() => true)
257+
.catch(() => false);
258+
if (!accessible) {
259+
return tryRequire(path);
260+
}
261+
262+
const folder = await stat(fullPath).catch(() => null);
263+
if (folder === null) {
264+
return tryRequire(path);
265+
}
266+
267+
if (!folder.isDirectory()) {
268+
return tryRequire(path);
269+
}
270+
271+
const files = await readdir(fullPath).catch(() => null);
272+
if (files === null) {
273+
logger.console.error(`cannot load templates "${path}": could not read template folder contents\n`);
274+
275+
return help(1);
276+
}
277+
278+
const matchingFiles = files.filter((file) => file in templateFileMapping) as (keyof typeof templateFileMapping)[];
279+
if (matchingFiles.length === 0) {
280+
logger.console.error(`cannot load templates "${path}": template folder has no override files\n`);
281+
282+
return help(1);
283+
}
284+
285+
for (const file of matchingFiles) {
286+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
287+
overrides[templateFileMapping[file]] = require(join(fullPath, file));
288+
}
289+
290+
return overrides;
291+
};
292+
156293
export const cli = async (args: string[]): Promise<Record<string, GenerationTask>> => {
157294
const processed = parseCliArgs(
158295
{
@@ -176,6 +313,7 @@ export const cli = async (args: string[]): Promise<Record<string, GenerationTask
176313
const globalOptions: GlobalOptions = {};
177314
const todo: string[][] = [];
178315
const subcommand: string[] = [];
316+
let processTemplates: string | null = null;
179317

180318
let done = false;
181319
let hasSubCommand = false;
@@ -194,13 +332,24 @@ export const cli = async (args: string[]): Promise<Record<string, GenerationTask
194332
}
195333

196334
if (arg.name in cliArguments) {
197-
if (arg.name === 'output' || arg.name === 'root-ref') {
335+
if (arg.name === 'templates') {
336+
if (!arg.value) {
337+
help(1, `Parameter '--${arg.name}' requires a value`);
338+
339+
return {};
340+
}
341+
processTemplates = arg.inlineValue ? arg.value.slice(1) : arg.value;
342+
} else if (arg.name === 'output' || arg.name === 'root-ref') {
198343
if (!arg.value) {
199344
help(1, `Parameter '--${arg.name}' requires a value`);
200345

201346
return {};
202347
}
203348
globalOptions[arg.name] = arg.value;
349+
} else if (arg.value) {
350+
help(1, `Parameter '--${arg.name}' is not expecting a value`);
351+
352+
return {};
204353
} else {
205354
globalOptions[arg.name as Exclude<keyof typeof globalOptions, 'output' | 'root-ref'>] = true;
206355
}
@@ -266,8 +415,18 @@ export const cli = async (args: string[]): Promise<Record<string, GenerationTask
266415
globalOptions.output = relative('.', globalOptions.output);
267416
}
268417

418+
if (processTemplates) {
419+
const templates = await getTemplates(processTemplates);
420+
if (templates !== null) {
421+
globalOptions.customTemplates = templates;
422+
}
423+
}
424+
269425
if (!globalOptions['no-init']) {
270-
tasks.push({ contents: () => getIndex(globalOptions), filename: 'src/index.yml' }, { contents: getBoatsRc, filename: '.boatsrc' });
426+
tasks.push(
427+
{ contents: () => getIndex(globalOptions, 'src/index.yml'), filename: 'src/index.yml' },
428+
{ contents: () => getBoatsRc(globalOptions, '.boatsrc'), filename: '.boatsrc' },
429+
);
271430
}
272431

273432
if (!tasks.length) {
@@ -291,6 +450,8 @@ export const cli = async (args: string[]): Promise<Record<string, GenerationTask
291450
}
292451
}
293452

453+
// if custom templates - find all and import
454+
294455
return await generate(tasks, globalOptions);
295456
};
296457

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{{ autoComponentIndexer() }}

src/components/schemas/index.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{{ autoComponentIndexer('Model') }}

0 commit comments

Comments
 (0)