@effijs/forge 0.0.0-stage ā 1.0.0-alpha.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/LICENSE +22 -0
- package/README.md +233 -2
- package/index.js +7 -0
- package/package.json +49 -4
- package/src/Forge.js +187 -0
- package/src/actions/index.js +175 -0
- package/src/cli/ForgeCLI.js +189 -0
- package/src/cli/parseArgv.js +50 -0
- package/src/cli/scaffoldLocal.js +228 -0
- package/src/config/esmConfigLoader.js +37 -0
- package/src/config/loadConfig.js +62 -0
- package/src/config/loadPreset.js +85 -0
- package/src/config/mergeConfig.js +104 -0
- package/src/config/resolveGenerator.js +149 -0
- package/src/engines/eta.js +28 -0
- package/src/engines/handlebars.js +61 -0
- package/src/fs/addFile.js +34 -0
- package/src/fs/modifyFile.js +110 -0
- package/src/fs/processTemplate.js +133 -0
- package/src/index.js +3 -0
- package/src/utils/deepMerge.js +23 -0
- package/src/utils/resolvePaths.js +27 -0
- package/src/utils/shouldSkip.js +13 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import inquirer from "inquirer";
|
|
3
|
+
import fs from "fs/promises";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
import ora from "ora";
|
|
7
|
+
import { Forge } from "../Forge.js";
|
|
8
|
+
import { discoverGenerators } from "../config/resolveGenerator.js";
|
|
9
|
+
import { scaffoldLocalGenerator } from "./scaffoldLocal.js";
|
|
10
|
+
import { parseUnknownFlags } from "./parseArgv.js";
|
|
11
|
+
|
|
12
|
+
export class ForgeCLI {
|
|
13
|
+
constructor() {
|
|
14
|
+
this.program = new Command();
|
|
15
|
+
this.forge = null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async init(cwd = process.cwd()) {
|
|
19
|
+
this.forge = await Forge.create(cwd);
|
|
20
|
+
|
|
21
|
+
this.program
|
|
22
|
+
.name("forge")
|
|
23
|
+
.description("A modern scaffolding tool for generating code and projects")
|
|
24
|
+
.version("1.0.0");
|
|
25
|
+
|
|
26
|
+
const generateCmd = this.program
|
|
27
|
+
.command("generate <generator>")
|
|
28
|
+
.alias("g")
|
|
29
|
+
.description("Run a generator")
|
|
30
|
+
.option("--dry-run", "Preview without writing files")
|
|
31
|
+
.option("--force", "Overwrite existing files")
|
|
32
|
+
.option("--yes", "Skip prompts and use defaults")
|
|
33
|
+
.option("--name <name>", "Pass name directly")
|
|
34
|
+
.option("--path <path>", "Pass path directly")
|
|
35
|
+
.allowUnknownOption()
|
|
36
|
+
.action(async (generator, options) => {
|
|
37
|
+
const runOptions = {
|
|
38
|
+
dryRun: options.dryRun,
|
|
39
|
+
force: options.force,
|
|
40
|
+
yes: options.yes,
|
|
41
|
+
};
|
|
42
|
+
if (options.name) runOptions.name = options.name;
|
|
43
|
+
if (options.path) runOptions.path = options.path;
|
|
44
|
+
|
|
45
|
+
Object.assign(runOptions, parseUnknownFlags(this.argv ?? process.argv));
|
|
46
|
+
|
|
47
|
+
await this.forge.run(generator, runOptions);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
this.program
|
|
51
|
+
.command("list")
|
|
52
|
+
.alias("ls")
|
|
53
|
+
.description("List available generators")
|
|
54
|
+
.action(async () => {
|
|
55
|
+
const spinner = ora("Loading generators...").start();
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const catalog = await discoverGenerators(this.forge.config);
|
|
59
|
+
spinner.stop();
|
|
60
|
+
|
|
61
|
+
const byNamespace = new Map();
|
|
62
|
+
for (const [id, meta] of catalog) {
|
|
63
|
+
const ns = meta.namespace ?? id.split(":")[0];
|
|
64
|
+
if (!byNamespace.has(ns)) byNamespace.set(ns, []);
|
|
65
|
+
byNamespace.get(ns).push({ id, meta });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
console.log(chalk.bold.cyan("\nš¦ Available generators:\n"));
|
|
69
|
+
|
|
70
|
+
for (const [ns, items] of byNamespace) {
|
|
71
|
+
const label =
|
|
72
|
+
ns === "local"
|
|
73
|
+
? "Local"
|
|
74
|
+
: `Extended (${ns})`;
|
|
75
|
+
console.log(chalk.bold(label + ":"));
|
|
76
|
+
for (const { id, meta } of items) {
|
|
77
|
+
const shortName = id.split(":")[1] ?? id;
|
|
78
|
+
const desc = meta.description ? chalk.dim(` ā ${meta.description}`) : "";
|
|
79
|
+
console.log(` ⢠${chalk.green(shortName)}${desc}`);
|
|
80
|
+
}
|
|
81
|
+
console.log("");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const aliases = this.forge.config.aliases ?? {};
|
|
85
|
+
const aliasEntries = Object.entries(aliases);
|
|
86
|
+
if (aliasEntries.length) {
|
|
87
|
+
console.log(chalk.bold("Aliases:"));
|
|
88
|
+
for (const [alias, target] of aliasEntries) {
|
|
89
|
+
console.log(` ${chalk.yellow(alias)} ā ${target}`);
|
|
90
|
+
}
|
|
91
|
+
console.log("");
|
|
92
|
+
}
|
|
93
|
+
} catch (error) {
|
|
94
|
+
spinner.fail("Failed to list generators");
|
|
95
|
+
console.error(chalk.red(error.message));
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
this.program
|
|
100
|
+
.command("init")
|
|
101
|
+
.description("Initialize Forge configuration and local directories")
|
|
102
|
+
.action(async () => {
|
|
103
|
+
const answers = await inquirer.prompt([
|
|
104
|
+
{
|
|
105
|
+
type: "input",
|
|
106
|
+
name: "templatesDir",
|
|
107
|
+
message: "Local templates directory:",
|
|
108
|
+
default: "./forge/templates",
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
type: "input",
|
|
112
|
+
name: "generatorsDir",
|
|
113
|
+
message: "Local generators directory:",
|
|
114
|
+
default: "./forge/generators",
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
type: "input",
|
|
118
|
+
name: "outputDir",
|
|
119
|
+
message: "Default output directory:",
|
|
120
|
+
default: "./src",
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
type: "list",
|
|
124
|
+
name: "templateEngine",
|
|
125
|
+
message: "Template engine:",
|
|
126
|
+
choices: ["handlebars", "eta"],
|
|
127
|
+
default: "handlebars",
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
type: "confirm",
|
|
131
|
+
name: "usePreset",
|
|
132
|
+
message: "Use @effijs/templates preset (react-native-fsd)?",
|
|
133
|
+
default: true,
|
|
134
|
+
},
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
const config = {
|
|
138
|
+
outputDir: answers.outputDir,
|
|
139
|
+
templateEngine: answers.templateEngine,
|
|
140
|
+
local: {
|
|
141
|
+
templatesDir: answers.templatesDir,
|
|
142
|
+
generatorsDir: answers.generatorsDir,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
if (answers.usePreset) {
|
|
147
|
+
config.preset = "react-native-fsd";
|
|
148
|
+
config.presetMap = {
|
|
149
|
+
"rn-fsd": "@effijs/templates/presets/react-native-fsd",
|
|
150
|
+
};
|
|
151
|
+
config.primaryPreset = "rn-fsd";
|
|
152
|
+
config.generators = ["rn-fsd:component"];
|
|
153
|
+
config.aliases = { comp: "rn-fsd:component" };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const configContent = `export default ${JSON.stringify(config, null, 2)};\n`;
|
|
157
|
+
await fs.writeFile("./forge.config.js", configContent);
|
|
158
|
+
|
|
159
|
+
await fs.mkdir(answers.templatesDir, { recursive: true });
|
|
160
|
+
await fs.mkdir(answers.generatorsDir, { recursive: true });
|
|
161
|
+
|
|
162
|
+
console.log(chalk.green("\nā
Created forge.config.js and ./forge/ directories\n"));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const newCmd = this.program
|
|
166
|
+
.command("new")
|
|
167
|
+
.description("Scaffold new resources");
|
|
168
|
+
|
|
169
|
+
newCmd
|
|
170
|
+
.command("local <name>")
|
|
171
|
+
.description("Create a local generator with example templates")
|
|
172
|
+
.option("--type <type>", "Variant: service | api | component", "service")
|
|
173
|
+
.option("--force", "Overwrite existing generator")
|
|
174
|
+
.option("--no-register", "Skip auto-register in forge.config.js")
|
|
175
|
+
.action(async (name, options) => {
|
|
176
|
+
await scaffoldLocalGenerator({
|
|
177
|
+
name,
|
|
178
|
+
type: options.type,
|
|
179
|
+
force: options.force,
|
|
180
|
+
register: options.register !== false,
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
run(argv = process.argv) {
|
|
186
|
+
this.argv = argv;
|
|
187
|
+
this.program.parse(argv);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const RESERVED_FLAGS = new Set(["dry-run", "force", "yes", "name", "path"]);
|
|
2
|
+
|
|
3
|
+
function camelCaseFlag(key) {
|
|
4
|
+
return key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function coerceFlagValue(value) {
|
|
8
|
+
if (value === "true") return true;
|
|
9
|
+
if (value === "false") return false;
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Collect generator data from unknown long flags.
|
|
15
|
+
* Reserved run flags stay with the commander options that already parsed them.
|
|
16
|
+
*/
|
|
17
|
+
export function parseUnknownFlags(argv) {
|
|
18
|
+
const data = {};
|
|
19
|
+
const args = Array.isArray(argv) ? argv : [];
|
|
20
|
+
|
|
21
|
+
for (let i = 0; i < args.length; i++) {
|
|
22
|
+
const arg = args[i];
|
|
23
|
+
if (arg === "--") break;
|
|
24
|
+
if (!arg.startsWith("--")) continue;
|
|
25
|
+
|
|
26
|
+
const body = arg.slice(2);
|
|
27
|
+
let key;
|
|
28
|
+
let value;
|
|
29
|
+
const eq = body.indexOf("=");
|
|
30
|
+
|
|
31
|
+
if (eq !== -1) {
|
|
32
|
+
key = body.slice(0, eq);
|
|
33
|
+
value = body.slice(eq + 1);
|
|
34
|
+
} else {
|
|
35
|
+
key = body;
|
|
36
|
+
const next = args[i + 1];
|
|
37
|
+
if (next !== undefined && !next.startsWith("-")) {
|
|
38
|
+
value = next;
|
|
39
|
+
i++;
|
|
40
|
+
} else {
|
|
41
|
+
value = true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!key || RESERVED_FLAGS.has(key)) continue;
|
|
46
|
+
data[camelCaseFlag(key)] = coerceFlagValue(value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return data;
|
|
50
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import * as changeCase from "change-case";
|
|
5
|
+
|
|
6
|
+
const TYPE_VARIANTS = {
|
|
7
|
+
service: {
|
|
8
|
+
files: [
|
|
9
|
+
{
|
|
10
|
+
name: "{{kebabCase name}}.service.ts.hbs",
|
|
11
|
+
content: `export class {{pascalCase name}}Service {
|
|
12
|
+
async execute(): Promise<void> {
|
|
13
|
+
// TODO: implement {{camelCase name}} service
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
`,
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: "{{kebabCase name}}.types.ts.hbs",
|
|
20
|
+
content: `export interface {{pascalCase name}}Config {
|
|
21
|
+
// TODO: define config
|
|
22
|
+
}
|
|
23
|
+
`,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: "index.ts.hbs",
|
|
27
|
+
content: `export * from './{{kebabCase name}}.service';\nexport * from './{{kebabCase name}}.types';\n`,
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
actions: `[
|
|
31
|
+
{
|
|
32
|
+
type: "add",
|
|
33
|
+
path: "{{kebabCase name}}/{{kebabCase name}}.service.ts",
|
|
34
|
+
templateFile: "{{kebabCase name}}/{{kebabCase name}}.service.ts.hbs",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
type: "add",
|
|
38
|
+
path: "{{kebabCase name}}/{{kebabCase name}}.types.ts",
|
|
39
|
+
templateFile: "{{kebabCase name}}/{{kebabCase name}}.types.ts.hbs",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
type: "add",
|
|
43
|
+
path: "{{kebabCase name}}/index.ts",
|
|
44
|
+
templateFile: "{{kebabCase name}}/index.ts.hbs",
|
|
45
|
+
},
|
|
46
|
+
]`,
|
|
47
|
+
},
|
|
48
|
+
api: {
|
|
49
|
+
files: [
|
|
50
|
+
{
|
|
51
|
+
name: "{{kebabCase name}}.api.ts.hbs",
|
|
52
|
+
content: `export async function fetch{{pascalCase name}}(): Promise<unknown> {
|
|
53
|
+
// TODO: implement API client
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
`,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "{{kebabCase name}}.types.ts.hbs",
|
|
60
|
+
content: `export type {{pascalCase name}}Response = Record<string, unknown>;\n`,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "index.ts.hbs",
|
|
64
|
+
content: `export * from './{{kebabCase name}}.api';\n`,
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
actions: `[
|
|
68
|
+
{
|
|
69
|
+
type: "add",
|
|
70
|
+
path: "api/{{kebabCase name}}.api.ts",
|
|
71
|
+
templateFile: "{{kebabCase name}}/{{kebabCase name}}.api.ts.hbs",
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
type: "add",
|
|
75
|
+
path: "api/{{kebabCase name}}.types.ts",
|
|
76
|
+
templateFile: "{{kebabCase name}}/{{kebabCase name}}.types.ts.hbs",
|
|
77
|
+
},
|
|
78
|
+
]`,
|
|
79
|
+
},
|
|
80
|
+
component: {
|
|
81
|
+
files: [
|
|
82
|
+
{
|
|
83
|
+
name: "{{pascalCase name}}.tsx.hbs",
|
|
84
|
+
content: `import React from 'react';
|
|
85
|
+
|
|
86
|
+
export function {{pascalCase name}}() {
|
|
87
|
+
return <div>{{pascalCase name}}</div>;
|
|
88
|
+
}
|
|
89
|
+
`,
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: "index.ts.hbs",
|
|
93
|
+
content: `export { {{pascalCase name}} } from './{{pascalCase name}}';\n`,
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
actions: `[
|
|
97
|
+
{
|
|
98
|
+
type: "add",
|
|
99
|
+
path: "components/{{pascalCase name}}/{{pascalCase name}}.tsx",
|
|
100
|
+
templateFile: "{{kebabCase name}}/{{pascalCase name}}.tsx.hbs",
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
type: "add",
|
|
104
|
+
path: "components/{{pascalCase name}}/index.ts",
|
|
105
|
+
templateFile: "{{kebabCase name}}/index.ts.hbs",
|
|
106
|
+
},
|
|
107
|
+
]`,
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export async function scaffoldLocalGenerator(options = {}) {
|
|
112
|
+
const {
|
|
113
|
+
name,
|
|
114
|
+
type = "service",
|
|
115
|
+
description = "",
|
|
116
|
+
force = false,
|
|
117
|
+
register = true,
|
|
118
|
+
cwd = process.cwd(),
|
|
119
|
+
} = options;
|
|
120
|
+
|
|
121
|
+
const kebabName = changeCase.kebabCase(name);
|
|
122
|
+
const variant = TYPE_VARIANTS[type] ?? TYPE_VARIANTS.service;
|
|
123
|
+
|
|
124
|
+
const generatorsDir = path.join(cwd, "forge", "generators", kebabName);
|
|
125
|
+
const templatesDir = path.join(cwd, "forge", "templates", kebabName);
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
await fs.access(generatorsDir);
|
|
129
|
+
if (!force) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`Generator "${kebabName}" already exists. Use --force to overwrite.`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (error.code !== "ENOENT") throw error;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
await fs.mkdir(generatorsDir, { recursive: true });
|
|
139
|
+
await fs.mkdir(templatesDir, { recursive: true });
|
|
140
|
+
|
|
141
|
+
for (const file of variant.files) {
|
|
142
|
+
const fileName = file.name
|
|
143
|
+
.replace(/\{\{kebabCase name\}\}/g, kebabName)
|
|
144
|
+
.replace(/\{\{pascalCase name\}\}/g, changeCase.pascalCase(name));
|
|
145
|
+
const filePath = path.join(templatesDir, fileName);
|
|
146
|
+
const content = file.content
|
|
147
|
+
.replace(/\{\{pascalCase name\}\}/g, changeCase.pascalCase(name))
|
|
148
|
+
.replace(/\{\{camelCase name\}\}/g, changeCase.camelCase(name))
|
|
149
|
+
.replace(/\{\{kebabCase name\}\}/g, kebabName);
|
|
150
|
+
await fs.writeFile(filePath, content);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const generatorJs = `export default {
|
|
154
|
+
description: "${description || `Local ${type} generator: ${kebabName}`}",
|
|
155
|
+
prompts: [
|
|
156
|
+
{
|
|
157
|
+
type: "input",
|
|
158
|
+
name: "name",
|
|
159
|
+
message: "Name:",
|
|
160
|
+
default: "${changeCase.pascalCase(name)}",
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
actions: ${variant.actions},
|
|
164
|
+
};
|
|
165
|
+
`;
|
|
166
|
+
|
|
167
|
+
await fs.writeFile(path.join(generatorsDir, "generator.js"), generatorJs);
|
|
168
|
+
|
|
169
|
+
const readme = `# ${kebabName}
|
|
170
|
+
|
|
171
|
+
Local Forge generator (${type}).
|
|
172
|
+
|
|
173
|
+
## Run
|
|
174
|
+
|
|
175
|
+
\`\`\`bash
|
|
176
|
+
forge g local:${kebabName}
|
|
177
|
+
\`\`\`
|
|
178
|
+
|
|
179
|
+
## Customize
|
|
180
|
+
|
|
181
|
+
- Edit \`forge/generators/${kebabName}/generator.js\`
|
|
182
|
+
- Edit templates in \`forge/templates/${kebabName}/\`
|
|
183
|
+
`;
|
|
184
|
+
|
|
185
|
+
await fs.writeFile(path.join(generatorsDir, "README.md"), readme);
|
|
186
|
+
|
|
187
|
+
if (register) {
|
|
188
|
+
await registerInConfig(cwd, kebabName);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
console.log(chalk.green(`\nā
Created local generator "${kebabName}"`));
|
|
192
|
+
console.log(chalk.dim(` forge/generators/${kebabName}/`));
|
|
193
|
+
console.log(chalk.dim(` forge/templates/${kebabName}/\n`));
|
|
194
|
+
|
|
195
|
+
return { name: kebabName, generatorsDir, templatesDir };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function registerInConfig(cwd, kebabName) {
|
|
199
|
+
const configPath = path.join(cwd, "forge.config.js");
|
|
200
|
+
const entry = `local:${kebabName}`;
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
let content = await fs.readFile(configPath, "utf-8");
|
|
204
|
+
|
|
205
|
+
if (content.includes(entry)) return;
|
|
206
|
+
|
|
207
|
+
if (content.includes("generators:")) {
|
|
208
|
+
content = content.replace(
|
|
209
|
+
/generators:\s*\[/,
|
|
210
|
+
`generators: [\n "${entry}",`,
|
|
211
|
+
);
|
|
212
|
+
} else {
|
|
213
|
+
content = content.replace(
|
|
214
|
+
/export default \{/,
|
|
215
|
+
`export default {\n generators: ["${entry}"],`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
await fs.writeFile(configPath, content);
|
|
220
|
+
console.log(chalk.dim(` Registered "${entry}" in forge.config.js`));
|
|
221
|
+
} catch {
|
|
222
|
+
console.log(
|
|
223
|
+
chalk.yellow(
|
|
224
|
+
` Add "${entry}" to generators in forge.config.js manually`,
|
|
225
|
+
),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const CONFIG_FILES = new Set(["forge.config.js", ".forgerc.js"]);
|
|
6
|
+
|
|
7
|
+
function isEsmSource(source) {
|
|
8
|
+
return /(^|\n)\s*(import|export)(\s|\{)/.test(source);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Node warns (MODULE_TYPELESS_PACKAGE_JSON) when a `.js` file uses ESM syntax
|
|
13
|
+
* and the nearest package.json has no `"type": "module"`. React Native apps
|
|
14
|
+
* stay CommonJS because babel, metro, and jest configs are `module.exports`.
|
|
15
|
+
* Force those Forge configs to load as ESM without changing the app package.
|
|
16
|
+
*/
|
|
17
|
+
export async function load(url, context, nextLoad) {
|
|
18
|
+
if (!url.startsWith("file:")) return nextLoad(url, context);
|
|
19
|
+
|
|
20
|
+
let filePath;
|
|
21
|
+
try {
|
|
22
|
+
filePath = fileURLToPath(url);
|
|
23
|
+
} catch {
|
|
24
|
+
return nextLoad(url, context);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!CONFIG_FILES.has(path.basename(filePath))) return nextLoad(url, context);
|
|
28
|
+
|
|
29
|
+
const source = await readFile(filePath, "utf8");
|
|
30
|
+
if (!isEsmSource(source)) return nextLoad(url, context);
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
format: "module",
|
|
34
|
+
source,
|
|
35
|
+
shortCircuit: true,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import { register } from "node:module";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { pathToFileURL } from "url";
|
|
5
|
+
|
|
6
|
+
let esmConfigLoaderReady = false;
|
|
7
|
+
|
|
8
|
+
function ensureEsmConfigLoader() {
|
|
9
|
+
if (esmConfigLoaderReady) return;
|
|
10
|
+
register(new URL("./esmConfigLoader.js", import.meta.url));
|
|
11
|
+
esmConfigLoaderReady = true;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const CONFIG_FILES = [
|
|
15
|
+
"forge.config.js",
|
|
16
|
+
"forge.config.mjs",
|
|
17
|
+
".forgerc.js",
|
|
18
|
+
".forgerc.json",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export const BASE_DEFAULTS = {
|
|
22
|
+
templatesDir: "./templates",
|
|
23
|
+
outputDir: "./src",
|
|
24
|
+
templateEngine: "handlebars",
|
|
25
|
+
helpers: {},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Load project forge config from cwd.
|
|
30
|
+
* @returns {Promise<{ config: object, configFileDir: string | null }>}
|
|
31
|
+
*/
|
|
32
|
+
export async function loadLocalConfig(cwd = process.cwd()) {
|
|
33
|
+
for (const configFile of CONFIG_FILES) {
|
|
34
|
+
const fullPath = path.resolve(cwd, configFile);
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
await fs.access(fullPath);
|
|
38
|
+
|
|
39
|
+
if (configFile.endsWith(".json")) {
|
|
40
|
+
const content = await fs.readFile(fullPath, "utf-8");
|
|
41
|
+
return {
|
|
42
|
+
config: JSON.parse(content),
|
|
43
|
+
configFileDir: path.dirname(fullPath),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (configFile.endsWith(".js")) ensureEsmConfigLoader();
|
|
48
|
+
const module = await import(pathToFileURL(fullPath).href);
|
|
49
|
+
return {
|
|
50
|
+
config: module.default ?? module,
|
|
51
|
+
configFileDir: path.dirname(fullPath),
|
|
52
|
+
};
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error.code === "ENOENT") continue;
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Failed to load config "${configFile}": ${error.message}`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { config: {}, configFileDir: null };
|
|
62
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { pathToFileURL, fileURLToPath } from "url";
|
|
3
|
+
import fs from "fs/promises";
|
|
4
|
+
|
|
5
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
|
|
7
|
+
function defaultResolve(specifier) {
|
|
8
|
+
return import.meta.resolve(specifier);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function finalizeModulePath(absPath) {
|
|
12
|
+
let stat = null;
|
|
13
|
+
try {
|
|
14
|
+
stat = await fs.stat(absPath);
|
|
15
|
+
} catch {
|
|
16
|
+
stat = null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (stat?.isFile()) return absPath;
|
|
20
|
+
if (stat?.isDirectory()) return path.join(absPath, "index.js");
|
|
21
|
+
if (path.extname(absPath)) return absPath;
|
|
22
|
+
return path.join(absPath, "index.js");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve a preset reference to the module file that should be imported.
|
|
27
|
+
* Absolute paths and `./` / `../` are files or directories on disk.
|
|
28
|
+
* Any other specifier goes through Node resolution, then a monorepo fallback.
|
|
29
|
+
*/
|
|
30
|
+
export async function resolvePresetPath(
|
|
31
|
+
presetRef,
|
|
32
|
+
presetMap = {},
|
|
33
|
+
resolveImport = defaultResolve,
|
|
34
|
+
) {
|
|
35
|
+
if (presetMap[presetRef]) {
|
|
36
|
+
return resolvePresetPath(presetMap[presetRef], {}, resolveImport);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (path.isAbsolute(presetRef)) {
|
|
40
|
+
return finalizeModulePath(presetRef);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (presetRef.startsWith("./") || presetRef.startsWith("../")) {
|
|
44
|
+
return finalizeModulePath(path.resolve(process.cwd(), presetRef));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const resolved = resolveImport(presetRef);
|
|
49
|
+
const filePath = String(resolved).startsWith("file:")
|
|
50
|
+
? fileURLToPath(resolved)
|
|
51
|
+
: resolved;
|
|
52
|
+
return finalizeModulePath(filePath);
|
|
53
|
+
} catch {
|
|
54
|
+
// Package resolution failed; try the checkout next to Forge, then cwd.
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const templatesPkg = path.resolve(__dirname, "../../../templates");
|
|
58
|
+
const fromTemplates = path.join(templatesPkg, "presets", presetRef);
|
|
59
|
+
try {
|
|
60
|
+
await fs.access(fromTemplates);
|
|
61
|
+
return finalizeModulePath(fromTemplates);
|
|
62
|
+
} catch {
|
|
63
|
+
return finalizeModulePath(path.resolve(process.cwd(), presetRef));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Load a preset config module.
|
|
69
|
+
*/
|
|
70
|
+
export async function loadPreset(presetRef, presetMap = {}) {
|
|
71
|
+
const modulePath = await resolvePresetPath(presetRef, presetMap);
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const module = await import(pathToFileURL(modulePath).href);
|
|
75
|
+
const config = module.default ?? module;
|
|
76
|
+
const presetDir = path.dirname(modulePath);
|
|
77
|
+
return {
|
|
78
|
+
...config,
|
|
79
|
+
id: config.id ?? path.basename(presetDir),
|
|
80
|
+
_presetDir: presetDir,
|
|
81
|
+
};
|
|
82
|
+
} catch (error) {
|
|
83
|
+
throw new Error(`Failed to load preset "${presetRef}": ${error.message}`);
|
|
84
|
+
}
|
|
85
|
+
}
|