@noego/app 0.0.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/AGENTS.md +457 -0
- package/bin/app.js +5 -0
- package/docs/design.md +107 -0
- package/package.json +24 -0
- package/src/args.js +180 -0
- package/src/build/bootstrap.js +43 -0
- package/src/build/client-modules.js +9 -0
- package/src/build/client.js +206 -0
- package/src/build/context.js +16 -0
- package/src/build/fix-imports.js +99 -0
- package/src/build/helpers.js +29 -0
- package/src/build/html.js +83 -0
- package/src/build/openapi.js +249 -0
- package/src/build/plugins/client-exclude.js +90 -0
- package/src/build/runtime-manifest.js +64 -0
- package/src/build/server.js +294 -0
- package/src/build/ssr.js +257 -0
- package/src/build/ui-common.js +188 -0
- package/src/build/vite.js +45 -0
- package/src/cli.js +72 -0
- package/src/commands/build.js +59 -0
- package/src/commands/preview.js +33 -0
- package/src/commands/serve.js +213 -0
- package/src/config.js +584 -0
- package/src/logger.js +16 -0
- package/src/utils/command.js +23 -0
package/src/args.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { exit } from 'node:process';
|
|
2
|
+
|
|
3
|
+
const FLAG_MAP = new Map([
|
|
4
|
+
['root', 'root'],
|
|
5
|
+
['out', 'out'],
|
|
6
|
+
['server', 'server'],
|
|
7
|
+
['page', 'page'],
|
|
8
|
+
['server-root', 'serverRoot'],
|
|
9
|
+
['ui-root', 'uiRoot'],
|
|
10
|
+
['ui-options', 'uiOptions'],
|
|
11
|
+
['controllers', 'controllers'],
|
|
12
|
+
['middleware', 'middleware'],
|
|
13
|
+
['openapi', 'openapi'],
|
|
14
|
+
['ui-openapi', 'uiOpenapi'],
|
|
15
|
+
['sql-glob', 'sqlGlob'],
|
|
16
|
+
['assets', 'assets'],
|
|
17
|
+
['client-exclude', 'clientExclude'],
|
|
18
|
+
['client-vite-config', 'clientViteConfig'],
|
|
19
|
+
['client-vite-override', 'clientViteOverride'],
|
|
20
|
+
['ssr-vite-config', 'ssrViteConfig'],
|
|
21
|
+
['ssr-vite-override', 'ssrViteOverride'],
|
|
22
|
+
['config', 'configFile'],
|
|
23
|
+
['watch', 'watch'],
|
|
24
|
+
['watch-path', 'watchPath'],
|
|
25
|
+
['split-serve', 'splitServe'],
|
|
26
|
+
['frontend-cmd', 'frontendCmd'],
|
|
27
|
+
['mode', 'mode'],
|
|
28
|
+
['help', 'help'],
|
|
29
|
+
['version', 'version']
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const MULTI_VALUE_FLAGS = new Set(['sqlGlob', 'assets', 'clientExclude', 'watchPath']);
|
|
33
|
+
const BOOLEAN_FLAGS = new Set(['watch', 'splitServe']);
|
|
34
|
+
|
|
35
|
+
export function parseCliArgs(argv) {
|
|
36
|
+
const result = {
|
|
37
|
+
command: null,
|
|
38
|
+
options: Object.create(null)
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const tokens = Array.from(argv);
|
|
42
|
+
while (tokens.length > 0) {
|
|
43
|
+
const token = tokens.shift();
|
|
44
|
+
if (!token) continue;
|
|
45
|
+
|
|
46
|
+
if (token.startsWith('--')) {
|
|
47
|
+
parseLongFlag(token, tokens, result.options);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!result.command) {
|
|
52
|
+
result.command = token;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
pushOption(result.options, '_', token);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseLongFlag(token, tokens, options) {
|
|
63
|
+
const eqIndex = token.indexOf('=');
|
|
64
|
+
const rawFlag = eqIndex >= 0 ? token.slice(2, eqIndex) : token.slice(2);
|
|
65
|
+
const mapped = FLAG_MAP.get(rawFlag);
|
|
66
|
+
|
|
67
|
+
if (!mapped) {
|
|
68
|
+
throwCliError(`Unknown option --${rawFlag}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (mapped === 'help') {
|
|
72
|
+
options.help = true;
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (mapped === 'version') {
|
|
77
|
+
options.version = true;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (BOOLEAN_FLAGS.has(mapped)) {
|
|
82
|
+
if (eqIndex >= 0) {
|
|
83
|
+
pushOption(options, mapped, coerceBoolean(token.slice(eqIndex + 1), rawFlag));
|
|
84
|
+
} else {
|
|
85
|
+
pushOption(options, mapped, true);
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const value =
|
|
91
|
+
eqIndex >= 0
|
|
92
|
+
? token.slice(eqIndex + 1)
|
|
93
|
+
: tokens.length > 0
|
|
94
|
+
? tokens.shift()
|
|
95
|
+
: throwCliError(`Missing value for --${rawFlag}`);
|
|
96
|
+
|
|
97
|
+
pushOption(options, mapped, value);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function pushOption(storage, key, value) {
|
|
101
|
+
if (MULTI_VALUE_FLAGS.has(key)) {
|
|
102
|
+
if (!storage[key]) {
|
|
103
|
+
storage[key] = [];
|
|
104
|
+
}
|
|
105
|
+
storage[key].push(value);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (storage[key] !== undefined) {
|
|
110
|
+
throwCliError(`Option ${formatFlag(key)} specified multiple times`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
storage[key] = value;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function formatFlag(key) {
|
|
117
|
+
for (const [raw, mapped] of FLAG_MAP.entries()) {
|
|
118
|
+
if (mapped === key) {
|
|
119
|
+
return `--${raw}`;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return `--${key}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function printHelpAndExit({ stdout = process.stdout } = {}) {
|
|
126
|
+
stdout.write(`Usage:
|
|
127
|
+
app build [options]
|
|
128
|
+
app serve [options]
|
|
129
|
+
app preview [options]
|
|
130
|
+
|
|
131
|
+
Options (shared):
|
|
132
|
+
--root <dir> Project root (default: .)
|
|
133
|
+
--out <dir> Output directory root (default: dist)
|
|
134
|
+
--server <file> Server entry (default: index.ts)
|
|
135
|
+
--server-root <dir> Base directory for server entry (default: --root)
|
|
136
|
+
--page <file> HTML page entry (default: ui/index.html)
|
|
137
|
+
--ui-root <dir> Base directory for UI assets (default: dirname(--page))
|
|
138
|
+
--ui-options <file> Forge options module (default: ui/options.ts)
|
|
139
|
+
--controllers <dir> Controller directory (default: server/controller)
|
|
140
|
+
--middleware <dir> Middleware directory (default: middleware)
|
|
141
|
+
--openapi <file> Server OpenAPI entry (default: server/stitch.yaml)
|
|
142
|
+
--ui-openapi <file> UI OpenAPI entry (default: ui/stitch.yaml)
|
|
143
|
+
--sql-glob <pattern> Glob(s) for SQL files to copy (default: server/repo/**/*.sql)
|
|
144
|
+
--assets <pattern> Additional asset glob(s) to copy (default: ui/resources/**)
|
|
145
|
+
--client-exclude <pattern>
|
|
146
|
+
Exclude glob(s) from client bundle (default: server/**, middleware/**)
|
|
147
|
+
--client-vite-config <path>
|
|
148
|
+
Alternate Vite config for client build
|
|
149
|
+
--client-vite-override <json>
|
|
150
|
+
JSON override merged into client Vite config
|
|
151
|
+
--ssr-vite-config <path>
|
|
152
|
+
Alternate Vite config for SSR build
|
|
153
|
+
--ssr-vite-override <json>
|
|
154
|
+
JSON override merged into SSR Vite config
|
|
155
|
+
--watch Enable server restart on change (serve only)
|
|
156
|
+
--watch-path <pattern> Additional watch glob(s) to trigger restarts (repeatable)
|
|
157
|
+
--split-serve Run frontend dev server in a separate process (watch mode only)
|
|
158
|
+
--frontend-cmd <name> Frontend command: currently supports 'vite' (default when split-serve)
|
|
159
|
+
--mode <value> Build mode forwarded to Vite (default: production)
|
|
160
|
+
--help Show this message
|
|
161
|
+
--version Show App version
|
|
162
|
+
|
|
163
|
+
Paths are resolved relative to --root unless supplied as absolute.
|
|
164
|
+
`);
|
|
165
|
+
exit(0);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function coerceBoolean(value, flag) {
|
|
169
|
+
if (typeof value === 'boolean') return value;
|
|
170
|
+
const normalized = String(value).toLowerCase();
|
|
171
|
+
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
|
|
172
|
+
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
|
|
173
|
+
throwCliError(`Invalid boolean for --${flag}: ${value}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function throwCliError(message) {
|
|
177
|
+
const error = new Error(message);
|
|
178
|
+
error.code = 'CLI_USAGE';
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generates a bootstrap wrapper (hammer.js) that sets up the runtime environment
|
|
6
|
+
* before importing the compiled application entry.
|
|
7
|
+
*
|
|
8
|
+
* This ensures:
|
|
9
|
+
* - FORGE_ROOT is set to the dist directory (for consistent asset resolution)
|
|
10
|
+
* - Application works regardless of invocation directory (node dist/hammer.js vs cd dist && node hammer.js)
|
|
11
|
+
* - Environment-agnostic (staging, QA, production all work identically)
|
|
12
|
+
*/
|
|
13
|
+
export async function generateBootstrap(context) {
|
|
14
|
+
const { config, logger } = context;
|
|
15
|
+
|
|
16
|
+
// Compute relative path from outDir to compiled entry
|
|
17
|
+
// The entry is copied to outDir root by syncRootEntry in server.js
|
|
18
|
+
const entryRel = path.relative(
|
|
19
|
+
config.rootDir,
|
|
20
|
+
config.server.entry.absolute
|
|
21
|
+
).replace(/\.ts$/, '.js');
|
|
22
|
+
|
|
23
|
+
const template = `import path from 'path';
|
|
24
|
+
import { fileURLToPath } from 'url';
|
|
25
|
+
|
|
26
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
|
|
28
|
+
// Set Forge root explicitly (highest priority in resolveRuntimeRoot)
|
|
29
|
+
process.env.FORGE_ROOT = __dirname;
|
|
30
|
+
|
|
31
|
+
// Signal that we're running from a built environment (for Forge auto-detection)
|
|
32
|
+
process.env.FORGE_BUILT = 'true';
|
|
33
|
+
|
|
34
|
+
// Import and execute the application
|
|
35
|
+
await import('./${entryRel}');
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
const targetPath = path.join(config.outDir, 'no_ego.js');
|
|
39
|
+
await fs.writeFile(targetPath, template, 'utf8');
|
|
40
|
+
|
|
41
|
+
logger.info(`Generated bootstrap: ${path.relative(config.rootDir, targetPath)}`);
|
|
42
|
+
return targetPath;
|
|
43
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export async function resolveClientModulesConfig() {
|
|
2
|
+
return { config: null, components: [], missing: [] };
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export async function buildClientModules(context) {
|
|
6
|
+
const { logger } = context;
|
|
7
|
+
logger.debug('Client module artifacts are emitted by the primary UI build; skipping duplicate build step.');
|
|
8
|
+
return { components: [] };
|
|
9
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { createUiBuildConfig } from './ui-common.js';
|
|
5
|
+
import { buildWithVite } from './vite.js';
|
|
6
|
+
|
|
7
|
+
export async function resolveClientEntry(context) {
|
|
8
|
+
const { config } = context;
|
|
9
|
+
const htmlTemplate = await fs.readFile(config.ui.page.absolute, 'utf8');
|
|
10
|
+
const scriptInfo = extractClientScript(htmlTemplate);
|
|
11
|
+
const entryAbsolute = resolveEntryAbsolute(config, scriptInfo);
|
|
12
|
+
return {
|
|
13
|
+
html: htmlTemplate,
|
|
14
|
+
scriptInfo,
|
|
15
|
+
entryAbsolute
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function resolveClientBuildConfig(context, discovery) {
|
|
20
|
+
const { config } = context;
|
|
21
|
+
const { html, scriptInfo, entryAbsolute } = await resolveClientEntry(context);
|
|
22
|
+
const shared = await createUiBuildConfig(context, {
|
|
23
|
+
discovery,
|
|
24
|
+
entryAbsolute,
|
|
25
|
+
outDir: config.layout.clientOutDir,
|
|
26
|
+
ssr: false,
|
|
27
|
+
configFileOption: config.vite.client.configFile,
|
|
28
|
+
overrideOption: config.vite.client.override,
|
|
29
|
+
emptyOutDir: true,
|
|
30
|
+
manifest: true
|
|
31
|
+
});
|
|
32
|
+
return {
|
|
33
|
+
config: shared.config,
|
|
34
|
+
scriptInfo,
|
|
35
|
+
entryAbsolute,
|
|
36
|
+
html,
|
|
37
|
+
components: shared.components,
|
|
38
|
+
missing: shared.missing
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function buildClient(context, discovery) {
|
|
43
|
+
const { config, logger } = context;
|
|
44
|
+
|
|
45
|
+
logger.info('Bundling client assets with Vite');
|
|
46
|
+
|
|
47
|
+
const {
|
|
48
|
+
config: inlineConfig,
|
|
49
|
+
scriptInfo,
|
|
50
|
+
entryAbsolute,
|
|
51
|
+
missing
|
|
52
|
+
} = await resolveClientBuildConfig(context, discovery);
|
|
53
|
+
|
|
54
|
+
if (Array.isArray(missing) && missing.length > 0) {
|
|
55
|
+
missing.forEach((component) => {
|
|
56
|
+
logger.warn(`Skipping missing UI component: ${component}`);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
await buildWithVite(context, inlineConfig);
|
|
61
|
+
|
|
62
|
+
const manifestPath = await resolveManifestPath(config.layout.clientOutDir);
|
|
63
|
+
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
|
|
64
|
+
|
|
65
|
+
const scriptKey = resolveManifestKeyForScript(context, manifest, scriptInfo, entryAbsolute);
|
|
66
|
+
const entryGraph = buildClientGraph(manifest, scriptKey);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
manifestPath,
|
|
70
|
+
manifest,
|
|
71
|
+
scriptKey,
|
|
72
|
+
entryGraph,
|
|
73
|
+
scriptInfo,
|
|
74
|
+
entryAbsolute,
|
|
75
|
+
bundleRoot: config.layout.clientOutDir
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function extractClientScript(html) {
|
|
80
|
+
const scriptRegex = /<script[^>]*type=["']module["'][^>]*src=["']([^"']+)["'][^>]*><\/script>/gi;
|
|
81
|
+
let match;
|
|
82
|
+
while ((match = scriptRegex.exec(html))) {
|
|
83
|
+
const src = match[1];
|
|
84
|
+
if (!src) continue;
|
|
85
|
+
if (src.includes('/@vite/client')) continue;
|
|
86
|
+
return {
|
|
87
|
+
raw: match[0],
|
|
88
|
+
src
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function resolveManifestKeyForScript(context, manifest, scriptInfo, entryAbsolute) {
|
|
95
|
+
const keys = Object.keys(manifest);
|
|
96
|
+
if (!scriptInfo) {
|
|
97
|
+
// Fallback: attempt to find ui/client.ts
|
|
98
|
+
const fallbackCandidates = ['ui/client.ts', 'client.ts', path.relative(context.config.ui.rootDir, entryAbsolute)];
|
|
99
|
+
for (const candidate of fallbackCandidates) {
|
|
100
|
+
const key = findMatchingKey(keys, candidate);
|
|
101
|
+
if (key) return key;
|
|
102
|
+
}
|
|
103
|
+
throw new Error('Unable to locate client entry in manifest (expected ui/client.ts)');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const candidates = buildScriptCandidates(context, scriptInfo, entryAbsolute);
|
|
107
|
+
for (const candidate of candidates) {
|
|
108
|
+
const key = findMatchingKey(keys, candidate);
|
|
109
|
+
if (key) return key;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
throw new Error(`Unable to resolve Vite manifest entry for script ${scriptInfo.src}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function buildClientGraph(manifest, entryKey) {
|
|
116
|
+
const visited = new Set();
|
|
117
|
+
const jsFiles = new Set();
|
|
118
|
+
const cssFiles = new Set();
|
|
119
|
+
|
|
120
|
+
function traverse(key, isRoot = false) {
|
|
121
|
+
if (!manifest[key] || visited.has(key)) return;
|
|
122
|
+
visited.add(key);
|
|
123
|
+
|
|
124
|
+
const entry = manifest[key];
|
|
125
|
+
if (entry.file) {
|
|
126
|
+
jsFiles.add(entry.file);
|
|
127
|
+
}
|
|
128
|
+
if (Array.isArray(entry.css)) {
|
|
129
|
+
entry.css.forEach((file) => cssFiles.add(file));
|
|
130
|
+
}
|
|
131
|
+
if (Array.isArray(entry.assets)) {
|
|
132
|
+
entry.assets.forEach((file) => jsFiles.add(file));
|
|
133
|
+
}
|
|
134
|
+
if (Array.isArray(entry.imports)) {
|
|
135
|
+
entry.imports.forEach((importKey) => traverse(importKey));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
traverse(entryKey, true);
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
entry: manifest[entryKey],
|
|
143
|
+
js: Array.from(jsFiles),
|
|
144
|
+
css: Array.from(cssFiles)
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function findMatchingKey(keys, candidate) {
|
|
149
|
+
const normalized = toPosix(candidate);
|
|
150
|
+
const direct = keys.find((key) => toPosix(key) === normalized);
|
|
151
|
+
if (direct) return direct;
|
|
152
|
+
return keys.find((key) => toPosix(key).endsWith(`/${normalized}`) || toPosix(key).endsWith(normalized));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function buildScriptCandidates(context, scriptInfo, entryAbsolute) {
|
|
156
|
+
const { config } = context;
|
|
157
|
+
const src = scriptInfo.src;
|
|
158
|
+
const candidates = [];
|
|
159
|
+
|
|
160
|
+
const trimmed = src.startsWith('/') ? src.slice(1) : src;
|
|
161
|
+
candidates.push(trimmed);
|
|
162
|
+
|
|
163
|
+
const htmlDir = path.dirname(config.ui.page.absolute);
|
|
164
|
+
const resolved = src.startsWith('/')
|
|
165
|
+
? path.resolve(config.rootDir, trimmed)
|
|
166
|
+
: path.resolve(htmlDir, src);
|
|
167
|
+
|
|
168
|
+
const relativeToUi = path.relative(config.ui.rootDir, resolved);
|
|
169
|
+
candidates.push(relativeToUi);
|
|
170
|
+
candidates.push(path.relative(config.ui.rootDir, entryAbsolute));
|
|
171
|
+
|
|
172
|
+
return candidates.filter(Boolean).map(toPosix);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function toPosix(value) {
|
|
176
|
+
return value.split(path.sep).join('/');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function resolveEntryAbsolute(config, scriptInfo) {
|
|
180
|
+
if (scriptInfo?.src) {
|
|
181
|
+
const src = scriptInfo.src;
|
|
182
|
+
if (src.startsWith('/')) {
|
|
183
|
+
return path.resolve(config.rootDir, src.slice(1));
|
|
184
|
+
}
|
|
185
|
+
const htmlDir = path.dirname(config.ui.page.absolute);
|
|
186
|
+
return path.resolve(htmlDir, src);
|
|
187
|
+
}
|
|
188
|
+
// Default: ui/client.ts
|
|
189
|
+
return path.resolve(config.ui.rootDir, 'client.ts');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function resolveManifestPath(clientOutDir) {
|
|
193
|
+
const candidates = [
|
|
194
|
+
path.join(clientOutDir, 'manifest.json'),
|
|
195
|
+
path.join(clientOutDir, '.vite', 'manifest.json')
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
for (const candidate of candidates) {
|
|
199
|
+
try {
|
|
200
|
+
await fs.access(candidate);
|
|
201
|
+
return candidate;
|
|
202
|
+
} catch {}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
throw new Error(`Vite manifest not found in ${clientOutDir}`);
|
|
206
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
|
|
4
|
+
import { createLogger } from '../logger.js';
|
|
5
|
+
|
|
6
|
+
export function createBuildContext(config, options = {}) {
|
|
7
|
+
const logger = options.logger ?? createLogger(options);
|
|
8
|
+
const requireFromRoot = createRequire(path.join(config.rootDir, 'package.json'));
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
config,
|
|
12
|
+
logger,
|
|
13
|
+
requireFromRoot,
|
|
14
|
+
resolveFromRoot: (target) => path.resolve(config.rootDir, target)
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const RELATIVE_IMPORT_RE =
|
|
5
|
+
/((?:import|export)\s[^'"`]*from\s*|import\s*)(['"])(\.{1,2}\/[^'"`]*)(\2)/g;
|
|
6
|
+
|
|
7
|
+
export async function fixImportExtensions(rootDir) {
|
|
8
|
+
await recurse(rootDir);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function recurse(dir) {
|
|
12
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
13
|
+
await Promise.all(
|
|
14
|
+
entries.map(async (entry) => {
|
|
15
|
+
const fullPath = path.join(dir, entry.name);
|
|
16
|
+
if (entry.isDirectory()) {
|
|
17
|
+
await recurse(fullPath);
|
|
18
|
+
} else if (entry.isFile() && entry.name.endsWith('.js')) {
|
|
19
|
+
await fixFile(fullPath);
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function fixFile(filePath) {
|
|
26
|
+
let source = await fs.readFile(filePath, 'utf8');
|
|
27
|
+
let changed = false;
|
|
28
|
+
|
|
29
|
+
source = await replaceAsync(
|
|
30
|
+
source,
|
|
31
|
+
RELATIVE_IMPORT_RE,
|
|
32
|
+
async (match, prefix, quote, spec) => {
|
|
33
|
+
const updated = await withJsExtension(filePath, spec);
|
|
34
|
+
if (updated === spec) {
|
|
35
|
+
return match;
|
|
36
|
+
}
|
|
37
|
+
changed = true;
|
|
38
|
+
return `${prefix}${quote}${updated}${quote}`;
|
|
39
|
+
}
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
// Handle dynamic import("...") separately
|
|
43
|
+
source = await replaceAsync(
|
|
44
|
+
source,
|
|
45
|
+
/(import\s*\(\s*)(['"])(\.{1,2}\/[^'"`]*)(\2)/g,
|
|
46
|
+
async (match, prefix, quote, spec) => {
|
|
47
|
+
const updated = await withJsExtension(filePath, spec);
|
|
48
|
+
if (updated === spec) {
|
|
49
|
+
return match;
|
|
50
|
+
}
|
|
51
|
+
changed = true;
|
|
52
|
+
return `${prefix}${quote}${updated}${quote}`;
|
|
53
|
+
}
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
if (changed) {
|
|
57
|
+
await fs.writeFile(filePath, source, 'utf8');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function withJsExtension(filePath, specifier) {
|
|
62
|
+
if (!specifier.startsWith('.')) {
|
|
63
|
+
return specifier;
|
|
64
|
+
}
|
|
65
|
+
if (hasExtension(specifier)) {
|
|
66
|
+
return specifier;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const candidate = path.resolve(path.dirname(filePath), specifier);
|
|
70
|
+
const jsCandidate = `${candidate}.js`;
|
|
71
|
+
try {
|
|
72
|
+
await fs.access(jsCandidate);
|
|
73
|
+
return `${specifier}.js`;
|
|
74
|
+
} catch {}
|
|
75
|
+
|
|
76
|
+
// Support directory imports with index.js
|
|
77
|
+
const indexCandidate = path.join(candidate, 'index.js');
|
|
78
|
+
try {
|
|
79
|
+
await fs.access(indexCandidate);
|
|
80
|
+
return `${specifier}/index.js`;
|
|
81
|
+
} catch {}
|
|
82
|
+
|
|
83
|
+
return specifier;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function hasExtension(specifier) {
|
|
87
|
+
return /\.[\w-]+($|\?)/.test(specifier);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function replaceAsync(str, regex, asyncFn) {
|
|
91
|
+
const matches = [];
|
|
92
|
+
str.replace(regex, (...args) => {
|
|
93
|
+
matches.push(asyncFn(...args));
|
|
94
|
+
return args[0];
|
|
95
|
+
});
|
|
96
|
+
const replacements = await Promise.all(matches);
|
|
97
|
+
let i = 0;
|
|
98
|
+
return str.replace(regex, () => replacements[i++]);
|
|
99
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export async function resolveConfigFile(preferred, fallback) {
|
|
5
|
+
if (preferred) {
|
|
6
|
+
const absolutePreferred = path.resolve(preferred);
|
|
7
|
+
if (await fileExists(absolutePreferred)) {
|
|
8
|
+
return absolutePreferred;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (fallback) {
|
|
13
|
+
const absoluteFallback = path.resolve(fallback);
|
|
14
|
+
if (await fileExists(absoluteFallback)) {
|
|
15
|
+
return absoluteFallback;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function fileExists(target) {
|
|
23
|
+
try {
|
|
24
|
+
await fs.access(target);
|
|
25
|
+
return true;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export async function rewriteHtmlTemplate(context, clientArtifacts) {
|
|
5
|
+
const { config, logger } = context;
|
|
6
|
+
const templatePath = config.ui.page.absolute;
|
|
7
|
+
const targetPath = path.join(
|
|
8
|
+
config.outDir,
|
|
9
|
+
path.relative(config.rootDir, templatePath)
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
let html = await fs.readFile(templatePath, 'utf8');
|
|
13
|
+
|
|
14
|
+
// Strip development-only scripts
|
|
15
|
+
html = html.replace(/\s*<script[^>]*src="\/@vite\/client"[^>]*><\/script>\s*/gi, '');
|
|
16
|
+
html = html.replace(/\s*<script[^>]*>[^<]*import\(['\"]\/@vite\/client['\"]\)[\s\S]*?<\/script>\s*/gi, '');
|
|
17
|
+
if (clientArtifacts.scriptInfo?.raw) {
|
|
18
|
+
const escaped = escapeRegExp(clientArtifacts.scriptInfo.raw);
|
|
19
|
+
const pattern = new RegExp(`\\s*${escaped}\\s*`, 'g');
|
|
20
|
+
html = html.replace(pattern, '');
|
|
21
|
+
}
|
|
22
|
+
// Client bundle public base served at '/assets'
|
|
23
|
+
const clientBase = '/assets';
|
|
24
|
+
html = html.replace(/(href|src)=(['"])\/assets\//g, `$1=$2${clientBase}/`);
|
|
25
|
+
// Remove inline dev loader blocks ( import('<whatever>/client.ts') ) and related comments
|
|
26
|
+
html = html.replace(/\s*<!--[^>]*Development[^>]*-->\s*/gi, '');
|
|
27
|
+
html = html.replace(/\s*<script[^>]*>[^<]*import\(['\"][^'\"]*client\.ts['\"][^)]*\)[\s\S]*?<\/script>\s*/gi, '');
|
|
28
|
+
|
|
29
|
+
const entryFile = clientArtifacts.entryGraph.entry?.file;
|
|
30
|
+
if (!entryFile) {
|
|
31
|
+
throw new Error('Client build completed without an entry file in manifest.');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const preloadFiles = new Set(
|
|
35
|
+
clientArtifacts.entryGraph.js.filter((file) => file !== entryFile)
|
|
36
|
+
);
|
|
37
|
+
const cssFiles = new Set(clientArtifacts.entryGraph.css);
|
|
38
|
+
|
|
39
|
+
const makeClientPublicPath = (file) => `${clientBase}/${toPosix(file)}`;
|
|
40
|
+
const headInjections = [];
|
|
41
|
+
for (const file of preloadFiles) {
|
|
42
|
+
headInjections.push(
|
|
43
|
+
`<link rel="modulepreload" href="${makeClientPublicPath(file)}">`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
for (const file of cssFiles) {
|
|
47
|
+
headInjections.push(
|
|
48
|
+
`<link rel="stylesheet" href="${makeClientPublicPath(file)}">`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (headInjections.length > 0) {
|
|
53
|
+
html = injectBeforeClose(html, '</head>', headInjections.join('\n '));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const scriptTag = `<script type="module" src="${makeClientPublicPath(entryFile)}"></script>`;
|
|
57
|
+
html = injectBeforeClose(html, '</body>', scriptTag);
|
|
58
|
+
|
|
59
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
60
|
+
await fs.writeFile(targetPath, html, 'utf8');
|
|
61
|
+
|
|
62
|
+
logger.info(`Wrote production HTML template to ${path.relative(config.rootDir, targetPath)}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// clientPublicPath no longer needed; use makeClientPublicPath closure above
|
|
66
|
+
|
|
67
|
+
function injectBeforeClose(html, marker, injection) {
|
|
68
|
+
const index = html.lastIndexOf(marker);
|
|
69
|
+
if (index === -1) {
|
|
70
|
+
return `${html}\n${injection}\n`;
|
|
71
|
+
}
|
|
72
|
+
const before = html.slice(0, index);
|
|
73
|
+
const after = html.slice(index);
|
|
74
|
+
return `${before} ${injection}\n${after}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function escapeRegExp(value) {
|
|
78
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function toPosix(value) {
|
|
82
|
+
return value.split(path.sep).join('/');
|
|
83
|
+
}
|