Skip to content

Commit 1129cdd

Browse files
fix: address items from #44. (#45)
1 parent cd4a6d2 commit 1129cdd

File tree

15 files changed

+210
-71
lines changed

15 files changed

+210
-71
lines changed

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ type ModuleOptions = {
125125
| '.mts'
126126
| '.cts'
127127
| ((value: string) => string | null | undefined)
128+
rewriteTemplateLiterals?: 'allow' | 'static-only'
128129
dirFilename?: 'inject' | 'preserve' | 'error'
129130
importMeta?: 'preserve' | 'shim' | 'error'
130131
importMetaMain?: 'shim' | 'warn' | 'error'
@@ -151,6 +152,7 @@ type ModuleOptions = {
151152
- `appendJsExtension` (`relative-only` when targeting ESM): append `.js` to relative specifiers; never touches bare specifiers.
152153
- `appendDirectoryIndex` (`index.js`): when a relative specifier ends with a slash, append this index filename (set `false` to disable).
153154
- `appenders` precedence: `rewriteSpecifier` runs first; if it returns a string, that result is used. If it returns `undefined` or `null`, `appendJsExtension` and `appendDirectoryIndex` still run. Bare specifiers are never modified by appenders.
155+
- `rewriteTemplateLiterals` (`allow`): when `static-only`, interpolated template literals are left untouched by specifier rewriting; string literals and non-interpolated templates still rewrite.
154156
- `dirFilename` (`inject`): inject `__dirname`/`__filename`, preserve existing, or throw.
155157
- `importMeta` (`shim`): rewrite `import.meta.*` to CommonJS equivalents.
156158
- `importMetaMain` (`shim`): gate `import.meta.main` with shimming/warning/error when Node support is too old.
@@ -159,7 +161,7 @@ type ModuleOptions = {
159161
- `detectCircularRequires` (`off`): optionally detect relative static require cycles and warn/throw.
160162
- `detectDualPackageHazard` (`warn`): flag when `import` and `require` mix for the same package or root/subpath are combined in ways that can resolve to separate module instances (dual packages). Set to `error` to fail the transform.
161163
- `dualPackageHazardScope` (`file`): `file` preserves the legacy per-file detector; `project` aggregates package usage across all CLI inputs (useful in monorepos/hoisted installs) and emits one diagnostic per package.
162-
- `topLevelAwait` (`error`): throw, wrap, or preserve when TLA appears in CommonJS output.
164+
- `topLevelAwait` (`error`): throw, wrap, or preserve when TLA appears in CommonJS output. `wrap` runs the file body inside an async IIFE (exports may resolve after the initial tick); `preserve` leaves `await` at top level, which Node will reject for CJS.
163165
- `rewriteSpecifier` (off): rewrite relative specifiers to a chosen extension or via a callback. Precedence: the callback (if provided) runs first; if it returns a string, that wins. If it returns `undefined` or `null`, the appenders still apply.
164166
- `requireSource` (`builtin`): whether `require` comes from Node or `createRequire`.
165167
- `cjsDefault` (`auto`): bundler-style default interop vs direct `module.exports`.

docs/roadmap.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,8 @@ Next:
2121

2222
- Emit source maps and clearer diagnostics for transform choices.
2323
- Benchmark scope analysis choices: compare `periscopic`, `scope-analyzer`, and `eslint-scope` on fixtures and pick the final adapter.
24+
25+
## Potential Breaking Changes (flag/document clearly)
26+
27+
- Template literal specifier rewriting: if we ever default to skipping interpolated `TemplateLiteral` specifiers, it would change outputs. Current implementation is opt-in via `rewriteTemplateLiterals: 'static-only'` (non-breaking); future default flips would need a major/minor note.
28+
- Cycle detection hardening: expanding extensions (.ts/.tsx/.mts/.cts) and normalize/realpath paths may surface new cycle warnings/errors, especially on Windows or mixed TS/JS projects.

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@knighted/module",
3-
"version": "1.4.0-rc.2",
3+
"version": "1.4.0-rc.3",
44
"description": "Bidirectional transform for ES modules and CommonJS.",
55
"type": "module",
66
"main": "dist/module.js",

src/cli.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,25 @@ import {
77
import { parseArgs } from 'node:util'
88
import { readFile, mkdir } from 'node:fs/promises'
99
import { dirname, resolve, relative, join } from 'node:path'
10-
import { builtinModules } from 'node:module'
1110
import { glob } from 'glob'
1211

12+
import type { TemplateLiteral } from '@oxc-project/types'
13+
1314
import { transform, collectProjectDualPackageHazards } from './module.js'
1415
import { parse } from './parse.js'
1516
import { format } from './format.js'
1617
import { specifier } from './specifier.js'
1718
import { getLangFromExt } from './utils/lang.js'
1819
import type { ModuleOptions, Diagnostic } from './types.js'
20+
import { builtinSpecifiers } from './utils/builtinSpecifiers.js'
1921

2022
const defaultOptions: ModuleOptions = {
2123
target: 'commonjs',
2224
sourceType: 'auto',
2325
transformSyntax: true,
2426
liveBindings: 'strict',
2527
rewriteSpecifier: undefined,
28+
rewriteTemplateLiterals: 'allow',
2629
appendJsExtension: undefined,
2730
appendDirectoryIndex: 'index.js',
2831
dirFilename: 'inject',
@@ -108,16 +111,6 @@ const colorize = (enabled: boolean) => {
108111
}
109112
}
110113
111-
const builtinSpecifiers = new Set<string>(
112-
builtinModules
113-
.map(mod => (mod.startsWith('node:') ? mod.slice(5) : mod))
114-
.flatMap(mod => {
115-
const parts = mod.split('/')
116-
const base = parts[0]
117-
return parts.length > 1 ? [mod, base] : [mod]
118-
}),
119-
)
120-
121114
const collapseSpecifier = (value: string) => value.replace(/['"`+)\s]|new String\(/g, '')
122115

123116
const appendExtensionIfNeeded = (
@@ -195,6 +188,12 @@ const optionsTable = [
195188
type: 'string',
196189
desc: 'Rewrite import specifiers (.js/.mjs/.cjs/.ts/.mts/.cts)',
197190
},
191+
{
192+
long: 'rewrite-template-literals',
193+
short: undefined,
194+
type: 'string',
195+
desc: 'Rewrite template literals (allow|static-only)',
196+
},
198197
{
199198
long: 'append-js-extension',
200199
short: 'j',
@@ -377,6 +376,11 @@ const toModuleOptions = (values: ParsedValues): ModuleOptions => {
377376
const transformSyntax = parseTransformSyntax(
378377
values['transform-syntax'] as string | undefined,
379378
)
379+
const rewriteTemplateLiterals =
380+
parseEnum(
381+
values['rewrite-template-literals'] as string | undefined,
382+
['allow', 'static-only'] as const,
383+
) ?? defaultOptions.rewriteTemplateLiterals
380384
const appendJsExtension = parseEnum(
381385
values['append-js-extension'] as string | undefined,
382386
['off', 'relative-only', 'all'] as const,
@@ -391,6 +395,7 @@ const toModuleOptions = (values: ParsedValues): ModuleOptions => {
391395
transformSyntax,
392396
rewriteSpecifier:
393397
(values['rewrite-specifier'] as ModuleOptions['rewriteSpecifier']) ?? undefined,
398+
rewriteTemplateLiterals,
394399
appendJsExtension: appendJsExtension,
395400
appendDirectoryIndex,
396401
detectCircularRequires:
@@ -513,6 +518,13 @@ const applySpecifierUpdates = async (
513518

514519
const lang = getLangFromExt(filename)
515520
const updated = await specifier.updateSrc(source, lang, spec => {
521+
if (
522+
spec.type === 'TemplateLiteral' &&
523+
opts.rewriteTemplateLiterals === 'static-only'
524+
) {
525+
const node = spec.node as TemplateLiteral
526+
if (node.expressions.length > 0) return
527+
}
516528
const normalized = normalizeBuiltinSpecifier(spec.value)
517529
const rewritten = rewriteSpecifierValue(
518530
normalized ?? spec.value,

src/format.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { builtinModules } from 'node:module'
21
import { dirname, join, resolve as pathResolve } from 'node:path'
32
import { readFile as fsReadFile, stat as fsStat } from 'node:fs/promises'
43
import type { Node, ParseResult } from 'oxc-parser'
@@ -31,6 +30,7 @@ import type { Diagnostic, ExportsMeta, FormatterOptions } from './types.js'
3130
import { collectCjsExports } from './utils/exports.js'
3231
import { collectModuleIdentifiers } from './utils/identifiers.js'
3332
import { isValidUrl } from './utils/url.js'
33+
import { builtinSpecifiers } from './utils/builtinSpecifiers.js'
3434
import { ancestorWalk } from './walk.js'
3535

3636
const isRequireMainMember = (node: Node, shadowed: Set<string>) =>
@@ -41,16 +41,6 @@ const isRequireMainMember = (node: Node, shadowed: Set<string>) =>
4141
node.property.type === 'Identifier' &&
4242
node.property.name === 'main'
4343

44-
const builtinSpecifiers = new Set<string>(
45-
builtinModules
46-
.map(mod => (mod.startsWith('node:') ? mod.slice(5) : mod))
47-
.flatMap(mod => {
48-
const parts = mod.split('/')
49-
const base = parts[0]
50-
return parts.length > 1 ? [mod, base] : [mod]
51-
}),
52-
)
53-
5444
const stripQuery = (value: string) =>
5545
value.includes('?') || value.includes('#') ? (value.split(/[?#]/)[0] ?? value) : value
5646

src/module.ts

Lines changed: 41 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -14,28 +14,18 @@ import {
1414
} from './format.js'
1515
import { getLangFromExt } from './utils/lang.js'
1616
import type { ModuleOptions, Diagnostic } from './types.js'
17-
import { builtinModules } from 'node:module'
1817
import { resolve as pathResolve, dirname as pathDirname, extname, join } from 'node:path'
19-
import { readFile as fsReadFile, stat } from 'node:fs/promises'
18+
import { readFile as fsReadFile, stat, realpath } from 'node:fs/promises'
2019
import { parse as parseModule } from './parse.js'
2120
import { walk } from './walk.js'
2221
import { collectModuleIdentifiers } from './utils/identifiers.js'
22+
import { builtinSpecifiers } from './utils/builtinSpecifiers.js'
2323

2424
type AppendJsExtensionMode = NonNullable<ModuleOptions['appendJsExtension']>
2525
type DetectCircularRequires = NonNullable<ModuleOptions['detectCircularRequires']>
2626

2727
const collapseSpecifier = (value: string) => value.replace(/['"`+)\s]|new String\(/g, '')
2828

29-
const builtinSpecifiers = new Set<string>(
30-
builtinModules
31-
.map(mod => (mod.startsWith('node:') ? mod.slice(5) : mod))
32-
.flatMap(mod => {
33-
const parts = mod.split('/')
34-
const base = parts[0]
35-
return parts.length > 1 ? [mod, base] : [mod]
36-
}),
37-
)
38-
3929
const appendExtensionIfNeeded = (
4030
spec: Spec,
4131
mode: AppendJsExtensionMode,
@@ -118,6 +108,8 @@ const fileExists = async (candidate: string) => {
118108
}
119109
}
120110

111+
const normalizePath = async (p: string) => pathResolve(await realpath(p).catch(() => p))
112+
121113
const resolveRequirePath = async (fromFile: string, spec: string, dirIndex: string) => {
122114
if (!spec.startsWith('./') && !spec.startsWith('../')) return null
123115
const base = pathResolve(pathDirname(fromFile), spec)
@@ -127,12 +119,19 @@ const resolveRequirePath = async (fromFile: string, spec: string, dirIndex: stri
127119
if (ext) {
128120
candidates.push(base)
129121
} else {
130-
candidates.push(`${base}.js`, `${base}.cjs`, `${base}.mjs`)
122+
candidates.push(
123+
`${base}.js`,
124+
`${base}.cjs`,
125+
`${base}.mjs`,
126+
`${base}.ts`,
127+
`${base}.mts`,
128+
`${base}.cts`,
129+
)
131130
candidates.push(join(base, dirIndex))
132131
}
133132

134133
for (const candidate of candidates) {
135-
if (await fileExists(candidate)) return candidate
134+
if (await fileExists(candidate)) return await normalizePath(candidate)
136135
}
137136

138137
return null
@@ -180,8 +179,10 @@ const detectCircularRequireGraph = async (
180179
const visited = new Set<string>()
181180

182181
const dfs = async (file: string, stack: string[]) => {
183-
if (visiting.has(file)) {
184-
const cycle = [...stack, file]
182+
const normalized = await normalizePath(file)
183+
184+
if (visiting.has(normalized)) {
185+
const cycle = [...stack, normalized]
185186
const msg = `Circular require detected: ${cycle.join(' -> ')}`
186187
if (mode === 'error') {
187188
throw new Error(msg)
@@ -191,26 +192,26 @@ const detectCircularRequireGraph = async (
191192
return
192193
}
193194

194-
if (visited.has(file)) return
195-
visiting.add(file)
196-
stack.push(file)
195+
if (visited.has(normalized)) return
196+
visiting.add(normalized)
197+
stack.push(normalized)
197198

198-
let deps = cache.get(file)
199+
let deps = cache.get(normalized)
199200
if (!deps) {
200-
deps = await collectStaticRequires(file, dirIndex)
201-
cache.set(file, deps)
201+
deps = await collectStaticRequires(normalized, dirIndex)
202+
cache.set(normalized, deps)
202203
}
203204

204205
for (const dep of deps) {
205206
await dfs(dep, stack)
206207
}
207208

208209
stack.pop()
209-
visiting.delete(file)
210-
visited.add(file)
210+
visiting.delete(normalized)
211+
visited.add(normalized)
211212
}
212213

213-
await dfs(entryFile, [])
214+
await dfs(await normalizePath(entryFile), [])
214215
}
215216

216217
const mergeUsageMaps = (
@@ -271,12 +272,13 @@ const collectProjectDualPackageHazards = async (files: string[], opts: ModuleOpt
271272
return byFile
272273
}
273274

274-
const defaultOptions = {
275+
const createDefaultOptions = (): ModuleOptions => ({
275276
target: 'commonjs',
276277
sourceType: 'auto',
277278
transformSyntax: true,
278279
liveBindings: 'strict',
279280
rewriteSpecifier: undefined,
281+
rewriteTemplateLiterals: 'allow',
280282
appendJsExtension: undefined,
281283
appendDirectoryIndex: 'index.js',
282284
dirFilename: 'inject',
@@ -295,9 +297,12 @@ const defaultOptions = {
295297
cwd: undefined,
296298
out: undefined,
297299
inPlace: false,
298-
} satisfies ModuleOptions
299-
const transform = async (filename: string, options: ModuleOptions = defaultOptions) => {
300-
const opts = { ...defaultOptions, ...options, filePath: filename }
300+
})
301+
const transform = async (filename: string, options?: ModuleOptions) => {
302+
const base = createDefaultOptions()
303+
const opts = options
304+
? { ...base, ...options, filePath: filename }
305+
: { ...base, filePath: filename }
301306
const cwdBase = opts.cwd ? resolve(opts.cwd) : process.cwd()
302307
const appendMode: AppendJsExtensionMode =
303308
options?.appendJsExtension ?? (opts.target === 'module' ? 'relative-only' : 'off')
@@ -311,6 +316,13 @@ const transform = async (filename: string, options: ModuleOptions = defaultOptio
311316

312317
if (opts.rewriteSpecifier || appendMode !== 'off' || dirIndex) {
313318
const code = await specifier.updateSrc(source, getLangFromExt(filename), spec => {
319+
if (
320+
spec.type === 'TemplateLiteral' &&
321+
opts.rewriteTemplateLiterals === 'static-only'
322+
) {
323+
const node = spec.node as TemplateLiteral
324+
if (node.expressions.length > 0) return
325+
}
314326
const normalized = normalizeBuiltinSpecifier(spec.value)
315327
const rewritten = rewriteSpecifierValue(
316328
normalized ?? spec.value,

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ export type ModuleOptions = {
3434
liveBindings?: 'strict' | 'loose' | 'off'
3535
/** Rewrite import specifiers (e.g. add extensions). */
3636
rewriteSpecifier?: RewriteSpecifier
37+
/** Whether to rewrite template literals that contain expressions. Default allows rewrites; set to 'static-only' to skip interpolated templates. */
38+
rewriteTemplateLiterals?: 'allow' | 'static-only'
3739
/** Whether to append .js to relative imports. */
3840
appendJsExtension?: 'off' | 'relative-only' | 'all'
3941
/** Add directory index (e.g. /index.js) or disable. */

src/utils/builtinSpecifiers.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { builtinModules } from 'node:module'
2+
3+
const builtinSpecifiers = new Set<string>(
4+
builtinModules
5+
.map(mod => (mod.startsWith('node:') ? mod.slice(5) : mod))
6+
.flatMap(mod => {
7+
const parts = mod.split('/')
8+
const base = parts[0]
9+
return parts.length > 1 ? [mod, base] : [mod]
10+
}),
11+
)
12+
13+
export { builtinSpecifiers }

test/cli.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,6 +638,33 @@ test('rewrites specifiers with --rewrite-specifier', () => {
638638
assert.match(result.stdout, /\.\/foo\.js'/)
639639
})
640640

641+
test('--rewrite-template-literals guards interpolated templates', () => {
642+
const source = [
643+
"const side = 'alpha'",
644+
"import './file.ts'",
645+
'import(`./tmpl/${side}.ts`)',
646+
'',
647+
].join('\n')
648+
649+
const result = runCli(
650+
[
651+
'--target',
652+
'module',
653+
'--stdin-filename',
654+
'input.mjs',
655+
'--rewrite-specifier',
656+
'.js',
657+
'--rewrite-template-literals',
658+
'static-only',
659+
],
660+
source,
661+
)
662+
663+
assert.equal(result.status, 0)
664+
assert.ok(result.stdout.includes("import './file.js'"))
665+
assert.ok(result.stdout.includes('import(`./tmpl/${side}.ts`)'))
666+
})
667+
641668
test('help example: out-dir mirror', async t => {
642669
const temp = await mkdtemp(join(tmpdir(), 'module-cli-'))
643670
const srcDir = join(temp, 'src')

0 commit comments

Comments
 (0)