@swissjs/swite 0.4.1 → 0.4.2
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/CHANGELOG.md +7 -0
- package/__tests__/import-rewriter-bug.test.ts +122 -122
- package/__tests__/security-r001-r002.test.ts +190 -190
- package/dist/cli.js +0 -0
- package/dist/dev-engine/hmr/hmr-client-template.js +111 -111
- package/dist/dev-engine/middleware/middleware-setup.js +4 -3
- package/docs/architecture/build-pipeline.md +97 -97
- package/docs/architecture/dev-server.md +87 -87
- package/docs/architecture/hmr.md +78 -78
- package/docs/architecture/import-rewriting.md +101 -101
- package/docs/architecture/index.md +16 -16
- package/docs/architecture/python-integration.md +93 -93
- package/docs/architecture/resolution.md +92 -92
- package/docs/cli/build.md +78 -78
- package/docs/cli/dev.md +90 -90
- package/docs/cli/index.md +15 -15
- package/docs/cli/start.md +45 -45
- package/docs/development/contributing.md +74 -74
- package/docs/development/index.md +12 -12
- package/docs/development/internals.md +101 -101
- package/docs/guide/configuration.md +89 -89
- package/docs/guide/index.md +13 -13
- package/docs/guide/project-structure.md +75 -75
- package/docs/guide/quickstart.md +113 -113
- package/docs/index.md +16 -16
- package/package.json +10 -9
- package/src/config/env.ts +98 -98
- package/src/dev-engine/handlers/ui-handler.ts +30 -30
- package/src/dev-engine/handlers/uix-handler.ts +21 -21
- package/src/dev-engine/hmr/hmr-client-template.ts +122 -122
- package/src/dev-engine/middleware/middleware-setup.ts +354 -354
- package/src/dev-engine/middleware/static-files.ts +813 -813
- package/src/resolution/cdn/cdn-fallback.ts +40 -40
- package/src/resolution/path/path-fixup.ts +27 -27
- package/src/resolution/rewriting/import-rewriter.ts +237 -237
- package/src/resolution/symlink-registry.ts +114 -114
|
@@ -1,40 +1,40 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* CDN fallback policy.
|
|
3
|
-
*
|
|
4
|
-
* Swite can fall back to jsDelivr (+esm) for packages it can't resolve locally.
|
|
5
|
-
* This must be safe and project-agnostic:
|
|
6
|
-
* - Unscoped packages (e.g. "react") are usually public on npm; allow by default.
|
|
7
|
-
* - Scoped packages (e.g. "@scope/pkg") may be private; do NOT CDN-fallback by default.
|
|
8
|
-
*
|
|
9
|
-
* Opt-in:
|
|
10
|
-
* - Set `SWITE_CDN_FALLBACK_SCOPES` to a comma-separated list of scopes to allow,
|
|
11
|
-
* e.g. "@types,@tanstack".
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
function getScope(specifierOrPkg: string): string | null {
|
|
15
|
-
if (!specifierOrPkg.startsWith("@")) return null;
|
|
16
|
-
const firstSlash = specifierOrPkg.indexOf("/");
|
|
17
|
-
if (firstSlash === -1) return null;
|
|
18
|
-
return specifierOrPkg.slice(0, firstSlash); // "@scope"
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function parseAllowList(): Set<string> {
|
|
22
|
-
const raw = process.env.SWITE_CDN_FALLBACK_SCOPES || "";
|
|
23
|
-
const scopes = raw
|
|
24
|
-
.split(",")
|
|
25
|
-
.map((s) => s.trim())
|
|
26
|
-
.filter(Boolean)
|
|
27
|
-
.map((s) => (s.startsWith("@") ? s : `@${s}`));
|
|
28
|
-
return new Set(scopes);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function shouldUseCdnFallback(specifierOrPkg: string): boolean {
|
|
32
|
-
const scope = getScope(specifierOrPkg);
|
|
33
|
-
// Unscoped packages (e.g. "react") are not automatically CDN-eligible; require
|
|
34
|
-
// explicit opt-in via SWITE_CDN_FALLBACK_SCOPES to prevent accidental exfiltration
|
|
35
|
-
// of package requests for private-registry or unscoped internal packages.
|
|
36
|
-
if (!scope) return false;
|
|
37
|
-
const allow = parseAllowList();
|
|
38
|
-
return allow.has(scope);
|
|
39
|
-
}
|
|
40
|
-
|
|
1
|
+
/**
|
|
2
|
+
* CDN fallback policy.
|
|
3
|
+
*
|
|
4
|
+
* Swite can fall back to jsDelivr (+esm) for packages it can't resolve locally.
|
|
5
|
+
* This must be safe and project-agnostic:
|
|
6
|
+
* - Unscoped packages (e.g. "react") are usually public on npm; allow by default.
|
|
7
|
+
* - Scoped packages (e.g. "@scope/pkg") may be private; do NOT CDN-fallback by default.
|
|
8
|
+
*
|
|
9
|
+
* Opt-in:
|
|
10
|
+
* - Set `SWITE_CDN_FALLBACK_SCOPES` to a comma-separated list of scopes to allow,
|
|
11
|
+
* e.g. "@types,@tanstack".
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function getScope(specifierOrPkg: string): string | null {
|
|
15
|
+
if (!specifierOrPkg.startsWith("@")) return null;
|
|
16
|
+
const firstSlash = specifierOrPkg.indexOf("/");
|
|
17
|
+
if (firstSlash === -1) return null;
|
|
18
|
+
return specifierOrPkg.slice(0, firstSlash); // "@scope"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseAllowList(): Set<string> {
|
|
22
|
+
const raw = process.env.SWITE_CDN_FALLBACK_SCOPES || "";
|
|
23
|
+
const scopes = raw
|
|
24
|
+
.split(",")
|
|
25
|
+
.map((s) => s.trim())
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
.map((s) => (s.startsWith("@") ? s : `@${s}`));
|
|
28
|
+
return new Set(scopes);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function shouldUseCdnFallback(specifierOrPkg: string): boolean {
|
|
32
|
+
const scope = getScope(specifierOrPkg);
|
|
33
|
+
// Unscoped packages (e.g. "react") are not automatically CDN-eligible; require
|
|
34
|
+
// explicit opt-in via SWITE_CDN_FALLBACK_SCOPES to prevent accidental exfiltration
|
|
35
|
+
// of package requests for private-registry or unscoped internal packages.
|
|
36
|
+
if (!scope) return false;
|
|
37
|
+
const allow = parseAllowList();
|
|
38
|
+
return allow.has(scope);
|
|
39
|
+
}
|
|
40
|
+
|
|
@@ -1,27 +1,27 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Centralised /swiss-lib/ → /swiss-packages/ path fixup.
|
|
3
|
-
*
|
|
4
|
-
* Root cause: the UiCompiler emits absolute `/swiss-lib/` paths in some code
|
|
5
|
-
* paths (compiler was written against an older directory structure). Until the
|
|
6
|
-
* compiler is fixed at source this single function is the authoritative fixup.
|
|
7
|
-
* Apply it once per compilation, before passing code to the import rewriter.
|
|
8
|
-
*
|
|
9
|
-
* Pass `patterns` from `userConfig.compilerPathFixup.patterns` to override the
|
|
10
|
-
* defaults. Pass an empty array to disable all fixups.
|
|
11
|
-
*/
|
|
12
|
-
export function fixSwissLibPaths(
|
|
13
|
-
code: string,
|
|
14
|
-
patterns?: Array<{ from: string; to: string }>,
|
|
15
|
-
): string {
|
|
16
|
-
const activePatterns = patterns ?? [
|
|
17
|
-
{ from: '/swiss-lib/packages/', to: '/swiss-packages/' },
|
|
18
|
-
{ from: '/swiss-lib/', to: '/swiss-packages/' },
|
|
19
|
-
];
|
|
20
|
-
let result = code;
|
|
21
|
-
for (const { from, to } of activePatterns) {
|
|
22
|
-
if (result.includes(from)) {
|
|
23
|
-
result = result.split(from).join(to);
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
return result;
|
|
27
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Centralised /swiss-lib/ → /swiss-packages/ path fixup.
|
|
3
|
+
*
|
|
4
|
+
* Root cause: the UiCompiler emits absolute `/swiss-lib/` paths in some code
|
|
5
|
+
* paths (compiler was written against an older directory structure). Until the
|
|
6
|
+
* compiler is fixed at source this single function is the authoritative fixup.
|
|
7
|
+
* Apply it once per compilation, before passing code to the import rewriter.
|
|
8
|
+
*
|
|
9
|
+
* Pass `patterns` from `userConfig.compilerPathFixup.patterns` to override the
|
|
10
|
+
* defaults. Pass an empty array to disable all fixups.
|
|
11
|
+
*/
|
|
12
|
+
export function fixSwissLibPaths(
|
|
13
|
+
code: string,
|
|
14
|
+
patterns?: Array<{ from: string; to: string }>,
|
|
15
|
+
): string {
|
|
16
|
+
const activePatterns = patterns ?? [
|
|
17
|
+
{ from: '/swiss-lib/packages/', to: '/swiss-packages/' },
|
|
18
|
+
{ from: '/swiss-lib/', to: '/swiss-packages/' },
|
|
19
|
+
];
|
|
20
|
+
let result = code;
|
|
21
|
+
for (const { from, to } of activePatterns) {
|
|
22
|
+
if (result.includes(from)) {
|
|
23
|
+
result = result.split(from).join(to);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
@@ -1,237 +1,237 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Import Rewriter for SWITE
|
|
3
|
-
*
|
|
4
|
-
* Design: collect-then-apply-right-to-left
|
|
5
|
-
*
|
|
6
|
-
* es-module-lexer gives positions {s, e} in the ORIGINAL string. The previous
|
|
7
|
-
* implementation tracked a running `offset` as replacements were applied, which
|
|
8
|
-
* accumulated errors when quote handling changed string lengths in unexpected
|
|
9
|
-
* ways and required three layers of fallback replacement logic.
|
|
10
|
-
*
|
|
11
|
-
* Instead we now:
|
|
12
|
-
* 1. Collect every replacement as {start, end, text} in original-string coordinates
|
|
13
|
-
* 2. Sort descending by start position
|
|
14
|
-
* 3. Apply right-to-left — each substitution cannot shift the position of any
|
|
15
|
-
* replacement to its left, so no offset tracking is needed at all.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import { init, parse } from "es-module-lexer";
|
|
19
|
-
import { ModuleResolver } from "../resolver.js";
|
|
20
|
-
import { promises as fs } from "node:fs";
|
|
21
|
-
import path from "node:path";
|
|
22
|
-
import chalk from "chalk";
|
|
23
|
-
import { shouldUseCdnFallback } from "../cdn/cdn-fallback.js";
|
|
24
|
-
|
|
25
|
-
interface Replacement {
|
|
26
|
-
start: number;
|
|
27
|
-
end: number;
|
|
28
|
-
text: string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export async function rewriteImports(
|
|
32
|
-
code: string,
|
|
33
|
-
importer: string,
|
|
34
|
-
resolver: ModuleResolver,
|
|
35
|
-
): Promise<string> {
|
|
36
|
-
await init;
|
|
37
|
-
|
|
38
|
-
try {
|
|
39
|
-
const [imports] = parse(code);
|
|
40
|
-
if (imports.length === 0) return code;
|
|
41
|
-
|
|
42
|
-
const replacements: Replacement[] = [];
|
|
43
|
-
|
|
44
|
-
for (const imp of imports) {
|
|
45
|
-
const { s: rawStart, e: rawEnd } = imp;
|
|
46
|
-
const rawSpecifier = code.slice(rawStart, rawEnd);
|
|
47
|
-
|
|
48
|
-
// Skip CSS imports — handled as static assets
|
|
49
|
-
if (rawSpecifier.includes(".css")) continue;
|
|
50
|
-
|
|
51
|
-
// Determine actual specifier string and the span in `code` that includes quotes
|
|
52
|
-
const { specifier, start, end } = resolveQuotedSpan(code, rawSpecifier, rawStart, rawEnd);
|
|
53
|
-
if (specifier === null) continue;
|
|
54
|
-
|
|
55
|
-
// Fix compiler bug: .uix/.ui imports emitted as .js or .tsx
|
|
56
|
-
if (
|
|
57
|
-
specifier.startsWith(".") &&
|
|
58
|
-
(specifier.endsWith(".js") || specifier.endsWith(".tsx")) &&
|
|
59
|
-
!specifier.includes("node_modules")
|
|
60
|
-
) {
|
|
61
|
-
const newExt = await resolveExtensionFix(specifier, importer);
|
|
62
|
-
if (newExt) {
|
|
63
|
-
const base = specifier.endsWith(".tsx") ? specifier.slice(0, -4) : specifier.slice(0, -3);
|
|
64
|
-
replacements.push({ start, end, text: `"${base}${newExt}"` });
|
|
65
|
-
continue;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Skip relative and absolute path imports (already resolved)
|
|
70
|
-
if (specifier.startsWith(".") || specifier.startsWith("/")) continue;
|
|
71
|
-
|
|
72
|
-
if (!/^[@a-zA-Z]/.test(specifier)) {
|
|
73
|
-
console.warn(`[SWITE] import-rewriter: Invalid specifier format: ${specifier}`);
|
|
74
|
-
continue;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Resolve bare import
|
|
78
|
-
let resolved: string;
|
|
79
|
-
try {
|
|
80
|
-
resolved = await resolver.resolve(specifier, importer);
|
|
81
|
-
if (!resolved || resolved === specifier || (!resolved.startsWith("/") && !resolved.startsWith("http"))) {
|
|
82
|
-
console.warn(chalk.yellow(`[SWITE] import-rewriter: Resolver returned invalid result for ${specifier}, using CDN fallback`));
|
|
83
|
-
resolved = shouldUseCdnFallback(specifier)
|
|
84
|
-
? `https://cdn.jsdelivr.net/npm/${specifier}/+esm`
|
|
85
|
-
: `/node_modules/${specifier}`;
|
|
86
|
-
}
|
|
87
|
-
} catch (error) {
|
|
88
|
-
console.error(chalk.red(`[SWITE] import-rewriter: Error resolving ${specifier}:`), error);
|
|
89
|
-
resolved = shouldUseCdnFallback(specifier)
|
|
90
|
-
? `https://cdn.jsdelivr.net/npm/${specifier}/+esm`
|
|
91
|
-
: `/node_modules/${specifier}`;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Prefer src/ over dist/ for workspace/swiss packages in dev
|
|
95
|
-
if (resolved.includes("/dist/") && (resolved.includes("/swiss-packages/") || resolved.includes("/packages/"))) {
|
|
96
|
-
resolved = resolved.replace("/dist/", "/src/").replace(/\.js$/, ".ts");
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
replacements.push({ start, end, text: `"${resolved}"` });
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Apply right-to-left so earlier positions are never shifted by later replacements
|
|
103
|
-
replacements.sort((a, b) => b.start - a.start);
|
|
104
|
-
let result = code;
|
|
105
|
-
for (const { start, end, text } of replacements) {
|
|
106
|
-
result = result.slice(0, start) + text + result.slice(end);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Safety net: catch any bare scoped imports the lexer may have missed
|
|
110
|
-
const barePattern = /(?:import|from|export)\s+['"](@[^'"]+\/[^'"]+)[^'"]*['"]/g;
|
|
111
|
-
for (const match of Array.from(result.matchAll(barePattern))) {
|
|
112
|
-
const bareImport = match[1];
|
|
113
|
-
if (!bareImport.startsWith("/") && !bareImport.startsWith("http") && !bareImport.startsWith(".")) {
|
|
114
|
-
console.error(chalk.red(`[SWITE] import-rewriter: CRITICAL — bare import "${bareImport}" still present after rewriting`));
|
|
115
|
-
const replacement = shouldUseCdnFallback(bareImport)
|
|
116
|
-
? `https://cdn.jsdelivr.net/npm/${bareImport}/+esm`
|
|
117
|
-
: `/node_modules/${bareImport}`;
|
|
118
|
-
result = result.replace(
|
|
119
|
-
new RegExp(bareImport.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"),
|
|
120
|
-
replacement,
|
|
121
|
-
);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Regex fallback: fix relative .js/.tsx extension mismatches the lexer may have missed
|
|
126
|
-
const normalizedImporter = importer.replace(/\\/g, "/");
|
|
127
|
-
const isSwissPackage = normalizedImporter.includes("/swiss-packages/");
|
|
128
|
-
const isUixFile = normalizedImporter.endsWith(".uix") || normalizedImporter.endsWith(".ui");
|
|
129
|
-
|
|
130
|
-
result = result.replace(
|
|
131
|
-
/from\s+(["'])(\.\.?\/[^"']*?)(\.js|\.tsx)(\1)/g,
|
|
132
|
-
(match, quote, importPath, _ext, endQuote) => {
|
|
133
|
-
if (importPath.includes("node_modules") || !importPath.startsWith(".")) return match;
|
|
134
|
-
const isLibPath = normalizedImporter.includes("/lib/");
|
|
135
|
-
let newExt: string;
|
|
136
|
-
if (isSwissPackage || isLibPath) {
|
|
137
|
-
newExt = ".ts";
|
|
138
|
-
} else if (isUixFile) {
|
|
139
|
-
newExt = normalizedImporter.endsWith(".ui") ? ".ui" : ".uix";
|
|
140
|
-
} else {
|
|
141
|
-
newExt = ".ts";
|
|
142
|
-
}
|
|
143
|
-
return `from ${quote}${importPath}${newExt}${endQuote}`;
|
|
144
|
-
},
|
|
145
|
-
);
|
|
146
|
-
|
|
147
|
-
return result;
|
|
148
|
-
} catch (error) {
|
|
149
|
-
console.error(chalk.red(`[SWITE] import-rewriter: Error rewriting imports in ${importer}:`), error);
|
|
150
|
-
return code;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Given a raw specifier token from es-module-lexer, find the full quoted span
|
|
156
|
-
* in `code` (including the surrounding quote characters) and extract the clean
|
|
157
|
-
* specifier string. Returns `{specifier: null}` when the span cannot be found.
|
|
158
|
-
*/
|
|
159
|
-
function resolveQuotedSpan(
|
|
160
|
-
code: string,
|
|
161
|
-
rawSpecifier: string,
|
|
162
|
-
rawStart: number,
|
|
163
|
-
rawEnd: number,
|
|
164
|
-
): { specifier: string | null; start: number; end: number } {
|
|
165
|
-
const first = rawSpecifier[0];
|
|
166
|
-
const last = rawSpecifier[rawSpecifier.length - 1];
|
|
167
|
-
|
|
168
|
-
// Case 1: lexer returned the specifier WITH surrounding quotes
|
|
169
|
-
if ((first === '"' || first === "'") && first === last) {
|
|
170
|
-
return {
|
|
171
|
-
specifier: rawSpecifier.slice(1, -1),
|
|
172
|
-
start: rawStart,
|
|
173
|
-
end: rawEnd,
|
|
174
|
-
};
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// Case 2: lexer returned the bare specifier; look one char back/forward for quotes
|
|
178
|
-
const charBefore = rawStart > 0 ? code[rawStart - 1] : "";
|
|
179
|
-
const charAfter = rawEnd < code.length ? code[rawEnd] : "";
|
|
180
|
-
if ((charBefore === '"' || charBefore === "'") && charBefore === charAfter) {
|
|
181
|
-
return {
|
|
182
|
-
specifier: rawSpecifier,
|
|
183
|
-
start: rawStart - 1,
|
|
184
|
-
end: rawEnd + 1,
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// Case 3: search nearby for a quoted pattern
|
|
189
|
-
const escaped = rawSpecifier.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
|
|
190
|
-
const pattern = new RegExp(`(['"])${escaped}\\1`);
|
|
191
|
-
const match = pattern.exec(code);
|
|
192
|
-
if (match) {
|
|
193
|
-
return {
|
|
194
|
-
specifier: rawSpecifier,
|
|
195
|
-
start: match.index,
|
|
196
|
-
end: match.index + match[0].length,
|
|
197
|
-
};
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
console.warn(`[SWITE] import-rewriter: Could not find quotes for specifier: ${rawSpecifier}`);
|
|
201
|
-
return { specifier: null, start: rawStart, end: rawEnd };
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Determine what extension a .js/.tsx import should be rewritten to,
|
|
206
|
-
* based on the importer's context and the actual files present on disk.
|
|
207
|
-
* Returns null when no rewrite is needed.
|
|
208
|
-
*/
|
|
209
|
-
async function resolveExtensionFix(specifier: string, importer: string): Promise<string | null> {
|
|
210
|
-
const normalizedImporter = importer.replace(/\\/g, "/");
|
|
211
|
-
const isSwissPackage = normalizedImporter.includes("/swiss-packages/");
|
|
212
|
-
const isLibPath = normalizedImporter.includes("/lib/");
|
|
213
|
-
const isUixFile = normalizedImporter.endsWith(".uix") || normalizedImporter.endsWith(".ui");
|
|
214
|
-
|
|
215
|
-
if (isSwissPackage || isLibPath) return ".ts";
|
|
216
|
-
|
|
217
|
-
if (isUixFile) {
|
|
218
|
-
const base = specifier.endsWith(".tsx") ? specifier.slice(0, -4) : specifier.slice(0, -3);
|
|
219
|
-
const currentDir = path.dirname(importer);
|
|
220
|
-
const cleanPath = base.startsWith("./") ? base.slice(2) : base;
|
|
221
|
-
const uiPath = path.resolve(currentDir, cleanPath + ".ui");
|
|
222
|
-
const uixPath = path.resolve(currentDir, cleanPath + ".uix");
|
|
223
|
-
try {
|
|
224
|
-
await fs.access(uiPath);
|
|
225
|
-
return ".ui";
|
|
226
|
-
} catch {
|
|
227
|
-
try {
|
|
228
|
-
await fs.access(uixPath);
|
|
229
|
-
return ".uix";
|
|
230
|
-
} catch {
|
|
231
|
-
return ".ui";
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
return ".ts";
|
|
237
|
-
}
|
|
1
|
+
/*
|
|
2
|
+
* Import Rewriter for SWITE
|
|
3
|
+
*
|
|
4
|
+
* Design: collect-then-apply-right-to-left
|
|
5
|
+
*
|
|
6
|
+
* es-module-lexer gives positions {s, e} in the ORIGINAL string. The previous
|
|
7
|
+
* implementation tracked a running `offset` as replacements were applied, which
|
|
8
|
+
* accumulated errors when quote handling changed string lengths in unexpected
|
|
9
|
+
* ways and required three layers of fallback replacement logic.
|
|
10
|
+
*
|
|
11
|
+
* Instead we now:
|
|
12
|
+
* 1. Collect every replacement as {start, end, text} in original-string coordinates
|
|
13
|
+
* 2. Sort descending by start position
|
|
14
|
+
* 3. Apply right-to-left — each substitution cannot shift the position of any
|
|
15
|
+
* replacement to its left, so no offset tracking is needed at all.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { init, parse } from "es-module-lexer";
|
|
19
|
+
import { ModuleResolver } from "../resolver.js";
|
|
20
|
+
import { promises as fs } from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import chalk from "chalk";
|
|
23
|
+
import { shouldUseCdnFallback } from "../cdn/cdn-fallback.js";
|
|
24
|
+
|
|
25
|
+
interface Replacement {
|
|
26
|
+
start: number;
|
|
27
|
+
end: number;
|
|
28
|
+
text: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function rewriteImports(
|
|
32
|
+
code: string,
|
|
33
|
+
importer: string,
|
|
34
|
+
resolver: ModuleResolver,
|
|
35
|
+
): Promise<string> {
|
|
36
|
+
await init;
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const [imports] = parse(code);
|
|
40
|
+
if (imports.length === 0) return code;
|
|
41
|
+
|
|
42
|
+
const replacements: Replacement[] = [];
|
|
43
|
+
|
|
44
|
+
for (const imp of imports) {
|
|
45
|
+
const { s: rawStart, e: rawEnd } = imp;
|
|
46
|
+
const rawSpecifier = code.slice(rawStart, rawEnd);
|
|
47
|
+
|
|
48
|
+
// Skip CSS imports — handled as static assets
|
|
49
|
+
if (rawSpecifier.includes(".css")) continue;
|
|
50
|
+
|
|
51
|
+
// Determine actual specifier string and the span in `code` that includes quotes
|
|
52
|
+
const { specifier, start, end } = resolveQuotedSpan(code, rawSpecifier, rawStart, rawEnd);
|
|
53
|
+
if (specifier === null) continue;
|
|
54
|
+
|
|
55
|
+
// Fix compiler bug: .uix/.ui imports emitted as .js or .tsx
|
|
56
|
+
if (
|
|
57
|
+
specifier.startsWith(".") &&
|
|
58
|
+
(specifier.endsWith(".js") || specifier.endsWith(".tsx")) &&
|
|
59
|
+
!specifier.includes("node_modules")
|
|
60
|
+
) {
|
|
61
|
+
const newExt = await resolveExtensionFix(specifier, importer);
|
|
62
|
+
if (newExt) {
|
|
63
|
+
const base = specifier.endsWith(".tsx") ? specifier.slice(0, -4) : specifier.slice(0, -3);
|
|
64
|
+
replacements.push({ start, end, text: `"${base}${newExt}"` });
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Skip relative and absolute path imports (already resolved)
|
|
70
|
+
if (specifier.startsWith(".") || specifier.startsWith("/")) continue;
|
|
71
|
+
|
|
72
|
+
if (!/^[@a-zA-Z]/.test(specifier)) {
|
|
73
|
+
console.warn(`[SWITE] import-rewriter: Invalid specifier format: ${specifier}`);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Resolve bare import
|
|
78
|
+
let resolved: string;
|
|
79
|
+
try {
|
|
80
|
+
resolved = await resolver.resolve(specifier, importer);
|
|
81
|
+
if (!resolved || resolved === specifier || (!resolved.startsWith("/") && !resolved.startsWith("http"))) {
|
|
82
|
+
console.warn(chalk.yellow(`[SWITE] import-rewriter: Resolver returned invalid result for ${specifier}, using CDN fallback`));
|
|
83
|
+
resolved = shouldUseCdnFallback(specifier)
|
|
84
|
+
? `https://cdn.jsdelivr.net/npm/${specifier}/+esm`
|
|
85
|
+
: `/node_modules/${specifier}`;
|
|
86
|
+
}
|
|
87
|
+
} catch (error) {
|
|
88
|
+
console.error(chalk.red(`[SWITE] import-rewriter: Error resolving ${specifier}:`), error);
|
|
89
|
+
resolved = shouldUseCdnFallback(specifier)
|
|
90
|
+
? `https://cdn.jsdelivr.net/npm/${specifier}/+esm`
|
|
91
|
+
: `/node_modules/${specifier}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Prefer src/ over dist/ for workspace/swiss packages in dev
|
|
95
|
+
if (resolved.includes("/dist/") && (resolved.includes("/swiss-packages/") || resolved.includes("/packages/"))) {
|
|
96
|
+
resolved = resolved.replace("/dist/", "/src/").replace(/\.js$/, ".ts");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
replacements.push({ start, end, text: `"${resolved}"` });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Apply right-to-left so earlier positions are never shifted by later replacements
|
|
103
|
+
replacements.sort((a, b) => b.start - a.start);
|
|
104
|
+
let result = code;
|
|
105
|
+
for (const { start, end, text } of replacements) {
|
|
106
|
+
result = result.slice(0, start) + text + result.slice(end);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Safety net: catch any bare scoped imports the lexer may have missed
|
|
110
|
+
const barePattern = /(?:import|from|export)\s+['"](@[^'"]+\/[^'"]+)[^'"]*['"]/g;
|
|
111
|
+
for (const match of Array.from(result.matchAll(barePattern))) {
|
|
112
|
+
const bareImport = match[1];
|
|
113
|
+
if (!bareImport.startsWith("/") && !bareImport.startsWith("http") && !bareImport.startsWith(".")) {
|
|
114
|
+
console.error(chalk.red(`[SWITE] import-rewriter: CRITICAL — bare import "${bareImport}" still present after rewriting`));
|
|
115
|
+
const replacement = shouldUseCdnFallback(bareImport)
|
|
116
|
+
? `https://cdn.jsdelivr.net/npm/${bareImport}/+esm`
|
|
117
|
+
: `/node_modules/${bareImport}`;
|
|
118
|
+
result = result.replace(
|
|
119
|
+
new RegExp(bareImport.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"),
|
|
120
|
+
replacement,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Regex fallback: fix relative .js/.tsx extension mismatches the lexer may have missed
|
|
126
|
+
const normalizedImporter = importer.replace(/\\/g, "/");
|
|
127
|
+
const isSwissPackage = normalizedImporter.includes("/swiss-packages/");
|
|
128
|
+
const isUixFile = normalizedImporter.endsWith(".uix") || normalizedImporter.endsWith(".ui");
|
|
129
|
+
|
|
130
|
+
result = result.replace(
|
|
131
|
+
/from\s+(["'])(\.\.?\/[^"']*?)(\.js|\.tsx)(\1)/g,
|
|
132
|
+
(match, quote, importPath, _ext, endQuote) => {
|
|
133
|
+
if (importPath.includes("node_modules") || !importPath.startsWith(".")) return match;
|
|
134
|
+
const isLibPath = normalizedImporter.includes("/lib/");
|
|
135
|
+
let newExt: string;
|
|
136
|
+
if (isSwissPackage || isLibPath) {
|
|
137
|
+
newExt = ".ts";
|
|
138
|
+
} else if (isUixFile) {
|
|
139
|
+
newExt = normalizedImporter.endsWith(".ui") ? ".ui" : ".uix";
|
|
140
|
+
} else {
|
|
141
|
+
newExt = ".ts";
|
|
142
|
+
}
|
|
143
|
+
return `from ${quote}${importPath}${newExt}${endQuote}`;
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
return result;
|
|
148
|
+
} catch (error) {
|
|
149
|
+
console.error(chalk.red(`[SWITE] import-rewriter: Error rewriting imports in ${importer}:`), error);
|
|
150
|
+
return code;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Given a raw specifier token from es-module-lexer, find the full quoted span
|
|
156
|
+
* in `code` (including the surrounding quote characters) and extract the clean
|
|
157
|
+
* specifier string. Returns `{specifier: null}` when the span cannot be found.
|
|
158
|
+
*/
|
|
159
|
+
function resolveQuotedSpan(
|
|
160
|
+
code: string,
|
|
161
|
+
rawSpecifier: string,
|
|
162
|
+
rawStart: number,
|
|
163
|
+
rawEnd: number,
|
|
164
|
+
): { specifier: string | null; start: number; end: number } {
|
|
165
|
+
const first = rawSpecifier[0];
|
|
166
|
+
const last = rawSpecifier[rawSpecifier.length - 1];
|
|
167
|
+
|
|
168
|
+
// Case 1: lexer returned the specifier WITH surrounding quotes
|
|
169
|
+
if ((first === '"' || first === "'") && first === last) {
|
|
170
|
+
return {
|
|
171
|
+
specifier: rawSpecifier.slice(1, -1),
|
|
172
|
+
start: rawStart,
|
|
173
|
+
end: rawEnd,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Case 2: lexer returned the bare specifier; look one char back/forward for quotes
|
|
178
|
+
const charBefore = rawStart > 0 ? code[rawStart - 1] : "";
|
|
179
|
+
const charAfter = rawEnd < code.length ? code[rawEnd] : "";
|
|
180
|
+
if ((charBefore === '"' || charBefore === "'") && charBefore === charAfter) {
|
|
181
|
+
return {
|
|
182
|
+
specifier: rawSpecifier,
|
|
183
|
+
start: rawStart - 1,
|
|
184
|
+
end: rawEnd + 1,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Case 3: search nearby for a quoted pattern
|
|
189
|
+
const escaped = rawSpecifier.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
|
|
190
|
+
const pattern = new RegExp(`(['"])${escaped}\\1`);
|
|
191
|
+
const match = pattern.exec(code);
|
|
192
|
+
if (match) {
|
|
193
|
+
return {
|
|
194
|
+
specifier: rawSpecifier,
|
|
195
|
+
start: match.index,
|
|
196
|
+
end: match.index + match[0].length,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
console.warn(`[SWITE] import-rewriter: Could not find quotes for specifier: ${rawSpecifier}`);
|
|
201
|
+
return { specifier: null, start: rawStart, end: rawEnd };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Determine what extension a .js/.tsx import should be rewritten to,
|
|
206
|
+
* based on the importer's context and the actual files present on disk.
|
|
207
|
+
* Returns null when no rewrite is needed.
|
|
208
|
+
*/
|
|
209
|
+
async function resolveExtensionFix(specifier: string, importer: string): Promise<string | null> {
|
|
210
|
+
const normalizedImporter = importer.replace(/\\/g, "/");
|
|
211
|
+
const isSwissPackage = normalizedImporter.includes("/swiss-packages/");
|
|
212
|
+
const isLibPath = normalizedImporter.includes("/lib/");
|
|
213
|
+
const isUixFile = normalizedImporter.endsWith(".uix") || normalizedImporter.endsWith(".ui");
|
|
214
|
+
|
|
215
|
+
if (isSwissPackage || isLibPath) return ".ts";
|
|
216
|
+
|
|
217
|
+
if (isUixFile) {
|
|
218
|
+
const base = specifier.endsWith(".tsx") ? specifier.slice(0, -4) : specifier.slice(0, -3);
|
|
219
|
+
const currentDir = path.dirname(importer);
|
|
220
|
+
const cleanPath = base.startsWith("./") ? base.slice(2) : base;
|
|
221
|
+
const uiPath = path.resolve(currentDir, cleanPath + ".ui");
|
|
222
|
+
const uixPath = path.resolve(currentDir, cleanPath + ".uix");
|
|
223
|
+
try {
|
|
224
|
+
await fs.access(uiPath);
|
|
225
|
+
return ".ui";
|
|
226
|
+
} catch {
|
|
227
|
+
try {
|
|
228
|
+
await fs.access(uixPath);
|
|
229
|
+
return ".uix";
|
|
230
|
+
} catch {
|
|
231
|
+
return ".ui";
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return ".ts";
|
|
237
|
+
}
|