conjure-js 0.0.12 → 0.0.13

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.
Files changed (77) hide show
  1. package/dist-cli/conjure-js.mjs +9360 -5298
  2. package/dist-vite-plugin/index.mjs +9463 -5185
  3. package/package.json +3 -1
  4. package/src/bin/cli.ts +2 -2
  5. package/src/bin/nrepl-symbol.ts +150 -0
  6. package/src/bin/nrepl.ts +289 -167
  7. package/src/bin/version.ts +1 -1
  8. package/src/clojure/core.clj +757 -29
  9. package/src/clojure/core.clj.d.ts +75 -131
  10. package/src/clojure/generated/builtin-namespace-registry.ts +4 -0
  11. package/src/clojure/generated/clojure-core-source.ts +758 -29
  12. package/src/clojure/generated/clojure-set-source.ts +136 -0
  13. package/src/clojure/generated/clojure-walk-source.ts +72 -0
  14. package/src/clojure/set.clj +132 -0
  15. package/src/clojure/set.clj.d.ts +20 -0
  16. package/src/clojure/string.clj.d.ts +14 -0
  17. package/src/clojure/walk.clj +68 -0
  18. package/src/clojure/walk.clj.d.ts +7 -0
  19. package/src/core/assertions.ts +114 -6
  20. package/src/core/bootstrap.ts +337 -0
  21. package/src/core/conversions.ts +48 -31
  22. package/src/core/core-module.ts +303 -0
  23. package/src/core/env.ts +20 -6
  24. package/src/core/evaluator/apply.ts +40 -25
  25. package/src/core/evaluator/arity.ts +8 -8
  26. package/src/core/evaluator/async-evaluator.ts +565 -0
  27. package/src/core/evaluator/collections.ts +28 -5
  28. package/src/core/evaluator/destructure.ts +180 -69
  29. package/src/core/evaluator/dispatch.ts +12 -14
  30. package/src/core/evaluator/evaluate.ts +22 -20
  31. package/src/core/evaluator/expand.ts +45 -15
  32. package/src/core/evaluator/form-parsers.ts +178 -0
  33. package/src/core/evaluator/index.ts +7 -9
  34. package/src/core/evaluator/js-interop.ts +189 -0
  35. package/src/core/evaluator/quasiquote.ts +14 -8
  36. package/src/core/evaluator/recur-check.ts +6 -6
  37. package/src/core/evaluator/special-forms.ts +234 -191
  38. package/src/core/factories.ts +182 -3
  39. package/src/core/index.ts +54 -4
  40. package/src/core/module.ts +136 -0
  41. package/src/core/ns-forms.ts +107 -0
  42. package/src/core/printer.ts +371 -11
  43. package/src/core/reader.ts +84 -33
  44. package/src/core/registry.ts +209 -0
  45. package/src/core/runtime.ts +376 -0
  46. package/src/core/session.ts +253 -487
  47. package/src/core/stdlib/arithmetic.ts +528 -194
  48. package/src/core/stdlib/async-fns.ts +132 -0
  49. package/src/core/stdlib/atoms.ts +291 -56
  50. package/src/core/stdlib/errors.ts +54 -50
  51. package/src/core/stdlib/hof.ts +82 -166
  52. package/src/core/stdlib/js-namespace.ts +344 -0
  53. package/src/core/stdlib/lazy.ts +34 -0
  54. package/src/core/stdlib/maps-sets.ts +322 -0
  55. package/src/core/stdlib/meta.ts +61 -30
  56. package/src/core/stdlib/predicates.ts +325 -187
  57. package/src/core/stdlib/regex.ts +126 -98
  58. package/src/core/stdlib/seq.ts +564 -0
  59. package/src/core/stdlib/strings.ts +164 -135
  60. package/src/core/stdlib/transducers.ts +95 -100
  61. package/src/core/stdlib/utils.ts +292 -130
  62. package/src/core/stdlib/vars.ts +27 -27
  63. package/src/core/stdlib/vectors.ts +122 -0
  64. package/src/core/tokenizer.ts +2 -2
  65. package/src/core/transformations.ts +117 -9
  66. package/src/core/types.ts +98 -2
  67. package/src/host/node-host-module.ts +74 -0
  68. package/src/{vite-plugin-clj/nrepl-relay.ts → nrepl/relay.ts} +72 -11
  69. package/src/vite-plugin-clj/codegen.ts +87 -95
  70. package/src/vite-plugin-clj/index.ts +178 -23
  71. package/src/vite-plugin-clj/namespace-utils.ts +39 -0
  72. package/src/vite-plugin-clj/static-analysis.ts +211 -0
  73. package/src/clojure/demo.clj +0 -72
  74. package/src/clojure/demo.clj.d.ts +0 -0
  75. package/src/core/core-env.ts +0 -61
  76. package/src/core/stdlib/collections.ts +0 -739
  77. package/src/host/node.ts +0 -55
@@ -1,10 +1,8 @@
1
- import { isMacro } from '../core/assertions'
2
- import type { Session } from '../core/session'
3
- import type { Arity, CljValue } from '../core/types'
4
- import { extractNsName, extractNsRequires } from './namespace-utils'
1
+ import type { Arity, DestructurePattern } from '../core/types'
2
+ import { extractNsName, extractNsRequires, extractStringRequires } from './namespace-utils'
3
+ import { readNamespaceVars } from './static-analysis'
5
4
 
6
5
  export interface CodegenContext {
7
- session: Session
8
6
  sourceRoots: string[]
9
7
  coreIndexPath: string
10
8
  virtualSessionId: string
@@ -14,11 +12,13 @@ export interface CodegenContext {
14
12
  export function generateModuleCode(
15
13
  ctx: CodegenContext,
16
14
  nsNameFromPath: string,
17
- source: string
15
+ source: string,
16
+ filePath?: string
18
17
  ): string {
19
18
  const nsName = extractNsName(source) ?? nsNameFromPath
20
19
 
21
- ctx.session.loadFile(source, nsName)
20
+ // Detect string requires from AST — determines sync vs async load call.
21
+ const hasStringRequires = extractStringRequires(source, filePath).length > 0
22
22
 
23
23
  const requires = extractNsRequires(source)
24
24
  const depImports = requires
@@ -30,91 +30,120 @@ export function generateModuleCode(
30
30
  .filter(Boolean)
31
31
  .join('\n')
32
32
 
33
- const nsData = ctx.session.getNs(nsName)
34
- if (!nsData) {
35
- return `throw new Error('Namespace ${nsName} failed to load');`
36
- }
37
-
33
+ // Static analysis: pure AST walk, no execution.
34
+ const vars = readNamespaceVars(source)
38
35
  const exportLines: string[] = []
39
- for (const [name, v] of nsData.vars) {
40
- const value = v.value
41
- if (isMacro(value)) continue
42
36
 
43
- const safeName = safeJsIdentifier(name)
37
+ for (const descriptor of vars) {
38
+ if (descriptor.isMacro) continue
39
+ if (descriptor.isPrivate) continue
40
+
41
+ const safeName = safeJsIdentifier(descriptor.name)
44
42
  // At runtime, vars.get() returns a CljVar; deref with .value
45
- const deref = `__ns.vars.get(${JSON.stringify(name)}).value`
46
- if (isAFunction(value)) {
43
+ const deref = `__ns.vars.get(${JSON.stringify(descriptor.name)}).value`
44
+
45
+ if (descriptor.kind === 'fn') {
47
46
  exportLines.push(
48
47
  `export function ${safeName}(...args) {` +
49
48
  ` const fn = ${deref};` +
50
49
  ` const cljArgs = args.map(jsToClj);` +
51
- ` const result = applyFunction(fn, cljArgs);` +
52
- ` return cljToJs(result);` +
50
+ ` const result = __session.applyFunction(fn, cljArgs);` +
51
+ ` return cljToJs(result, __session);` +
53
52
  `}`
54
53
  )
55
54
  } else {
56
55
  exportLines.push(
57
- `export const ${safeName} = cljToJs(${deref});`
56
+ `export const ${safeName} = cljToJs(${deref}, __session);`
58
57
  )
59
58
  }
60
59
  }
61
60
 
62
61
  const escapedSource = JSON.stringify(source)
62
+ // Files with string requires need async loading (top-level await, requires target: esnext).
63
+ // Files without string requires use the sync path — no top-level await overhead.
64
+ const loadCall = hasStringRequires
65
+ ? `await __session.loadFileAsync(${escapedSource}, ${JSON.stringify(nsName)});`
66
+ : `__session.loadFile(${escapedSource}, ${JSON.stringify(nsName)});`
67
+
68
+ if (exportLines.length === 0) {
69
+ // No public exports — emit a minimal module that loads the namespace at runtime.
70
+ // Namespace will be available in the session even without JS-side exports.
71
+ return [
72
+ `import { getSession } from ${JSON.stringify(ctx.virtualSessionId)};`,
73
+ depImports,
74
+ ``,
75
+ `const __session = getSession();`,
76
+ loadCall,
77
+ ``,
78
+ `if (import.meta.hot) { import.meta.hot.accept() }`,
79
+ ].join('\n')
80
+ }
63
81
 
64
82
  return [
65
83
  `import { getSession } from ${JSON.stringify(ctx.virtualSessionId)};`,
66
- `import { cljToJs, jsToClj, applyFunction } from ${JSON.stringify(ctx.coreIndexPath)};`,
84
+ `import { cljToJs, jsToClj } from ${JSON.stringify(ctx.coreIndexPath)};`,
67
85
  depImports,
68
86
  ``,
69
87
  `const __session = getSession();`,
70
- `__session.loadFile(${escapedSource}, ${JSON.stringify(nsName)});`,
88
+ loadCall,
71
89
  `const __ns = __session.getNs(${JSON.stringify(nsName)});`,
72
90
  ``,
73
91
  ...exportLines,
92
+ ``,
93
+ `// Self-accept HMR: re-execute this module on save (updates browser session)`,
94
+ `// without propagating to parent modules — prevents full page reload.`,
95
+ `if (import.meta.hot) { import.meta.hot.accept() }`,
74
96
  ].join('\n')
75
97
  }
76
98
 
77
- function isAFunction(value: CljValue): boolean {
78
- return value.kind === 'function' || value.kind === 'native-function'
79
- }
99
+ export function generateDts(
100
+ _ctx: CodegenContext,
101
+ nsNameFromPath: string,
102
+ source: string
103
+ ): string {
104
+ const nsName = extractNsName(source) ?? nsNameFromPath
105
+ const vars = readNamespaceVars(source)
80
106
 
81
- function cljValueToTsType(value: CljValue): string {
82
- switch (value.kind) {
83
- case 'number':
84
- return 'number'
85
- case 'string':
86
- return 'string'
87
- case 'boolean':
88
- return 'boolean'
89
- case 'nil':
90
- return 'null'
91
- case 'keyword':
92
- return 'string'
93
- case 'symbol':
94
- return 'string'
95
- case 'list':
96
- case 'vector':
97
- return 'unknown[]'
98
- case 'map':
99
- return 'Record<string, unknown>'
100
- case 'function':
101
- case 'native-function':
102
- return '(...args: unknown[]) => unknown'
103
- case 'macro':
104
- return 'never'
105
- case 'var':
106
- return 'unknown'
107
- default:
108
- throw new Error(`Unknown CljValue kind: ${value.kind}`)
107
+ const declarations: string[] = []
108
+ for (const descriptor of vars) {
109
+ if (descriptor.isMacro) continue
110
+ if (descriptor.isPrivate) continue
111
+
112
+ const safeName = safeJsIdentifier(descriptor.name)
113
+
114
+ if (descriptor.kind === 'fn') {
115
+ if (descriptor.arities && descriptor.arities.length > 0) {
116
+ for (const arity of descriptor.arities) {
117
+ declarations.push(`export function ${safeName}${arityToSignature(arity)};`)
118
+ }
119
+ } else {
120
+ declarations.push(`export function ${safeName}(...args: unknown[]): unknown;`)
121
+ }
122
+ } else {
123
+ // 'const' with inferred type, or 'unknown'
124
+ const tsType = descriptor.tsType ?? 'unknown'
125
+ declarations.push(`export const ${safeName}: ${tsType};`)
126
+ }
109
127
  }
128
+
129
+ // Suppress the unused-variable warning — nsName is used for documentation only here
130
+ void nsName
131
+
132
+ return declarations.join('\n')
110
133
  }
111
134
 
135
+ // ---------------------------------------------------------------------------
136
+ // Signature helpers
137
+ // ---------------------------------------------------------------------------
138
+
139
+ type ArityShape = { params: DestructurePattern[]; restParam: DestructurePattern | null }
140
+
112
141
  function patternName(p: Arity['params'][number], index: number): string {
113
142
  if (p.kind === 'symbol') return safeJsIdentifier(p.name)
114
143
  return `arg${index}`
115
144
  }
116
145
 
117
- function arityToSignature(arity: Arity): string {
146
+ function arityToSignature(arity: ArityShape): string {
118
147
  const fixedParams = arity.params
119
148
  .map((p, i) => `${patternName(p, i)}: unknown`)
120
149
  .join(', ')
@@ -133,46 +162,9 @@ function arityToSignature(arity: Arity): string {
133
162
  return `(${fixedParams}): unknown`
134
163
  }
135
164
 
136
- export function generateDts(
137
- ctx: CodegenContext,
138
- nsNameFromPath: string,
139
- source: string
140
- ): string {
141
- const nsName = extractNsName(source) ?? nsNameFromPath
142
-
143
- try {
144
- ctx.session.loadFile(source, nsName)
145
- } catch {
146
- return ''
147
- }
148
-
149
- const nsData = ctx.session.getNs(nsName)
150
- if (!nsData) return ''
151
-
152
- const declarations: string[] = []
153
- for (const [name, v] of nsData.vars) {
154
- const value = v.value
155
- if (isMacro(value)) continue
156
-
157
- const safeName = safeJsIdentifier(name)
158
-
159
- if (value.kind === 'function') {
160
- for (const arity of value.arities) {
161
- declarations.push(
162
- `export function ${safeName}${arityToSignature(arity)};`
163
- )
164
- }
165
- } else if (value.kind === 'native-function') {
166
- declarations.push(
167
- `export function ${safeName}(...args: unknown[]): unknown;`
168
- )
169
- } else {
170
- declarations.push(`export const ${safeName}: ${cljValueToTsType(value)};`)
171
- }
172
- }
173
-
174
- return declarations.join('\n')
175
- }
165
+ // ---------------------------------------------------------------------------
166
+ // Identifier sanitization
167
+ // ---------------------------------------------------------------------------
176
168
 
177
169
  const JS_RESERVED_WORDS = new Set([
178
170
  'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger',
@@ -1,18 +1,31 @@
1
1
  import { execFileSync } from 'node:child_process'
2
2
  import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs'
3
3
  import { resolve, relative, join, dirname } from 'node:path'
4
- import { fileURLToPath } from 'node:url'
4
+ import { fileURLToPath, pathToFileURL } from 'node:url'
5
+ import { createRequire } from 'node:module'
5
6
  import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite'
6
7
  import { createSession } from '../core/session'
7
8
  import type { Session } from '../core/session'
8
- import { nsToPath, pathToNs } from './namespace-utils'
9
+ import { nsToPath, pathToNs, extractStringRequires } from './namespace-utils'
9
10
  import { generateModuleCode, generateDts, safeJsIdentifier } from './codegen'
10
11
  import type { CodegenContext } from './codegen'
11
- import { startBrowserNreplRelay } from './nrepl-relay'
12
+ import { startBrowserNreplRelay } from '../nrepl/relay'
12
13
 
13
14
  interface CljPluginOptions {
14
15
  sourceRoots?: string[]
15
16
  nreplPort?: number
17
+ /**
18
+ * Path to a user-defined session factory (relative to project root).
19
+ * The factory must be the default export with signature:
20
+ * (importMap: Record<string, unknown>) => SessionOptions | null | undefined
21
+ *
22
+ * The plugin automatically injects `importModule` (wired to the import map) and
23
+ * `output` (wired to nREPL output capture + console.log). Do NOT provide these.
24
+ * Return only what you need: hostBindings, modules, entries, stderr, etc.
25
+ *
26
+ * Example: entrypoint: 'src/conjure.ts'
27
+ */
28
+ entrypoint?: string
16
29
  }
17
30
 
18
31
  // Resolve the conjure-js core index path regardless of whether this plugin
@@ -42,6 +55,12 @@ export function cljPlugin(options?: CljPluginOptions): Plugin {
42
55
  let codegenCtx: CodegenContext
43
56
  let generatorScriptPath: string
44
57
  let serveMode = false
58
+ // Collected during configResolved: original CLJ source string → resolved path/package name.
59
+ // Original string = what the CLJ runtime passes to importModule(s).
60
+ // Resolved = what Vite should actually import (absolute path for relative, unchanged for pkgs).
61
+ let stringRequires: Array<{ original: string; resolved: string }> = []
62
+ // Resolved absolute path to the user-defined session entrypoint (Mode 2), or null (Mode 1).
63
+ let entrypointPath: string | null = null
45
64
 
46
65
  function writeFileIfChanged(path: string, content: string) {
47
66
  try {
@@ -88,7 +107,7 @@ export function cljPlugin(options?: CljPluginOptions): Plugin {
88
107
  const source = readFileSync(filePath, 'utf-8')
89
108
  const nsNameFromPath = pathToNs(relative(projectRoot, filePath), sourceRoots)
90
109
  const dts = generateDts(codegenCtx, nsNameFromPath, source)
91
- writeFileIfChanged(filePath + '.d.ts', dts)
110
+ if (dts) writeFileIfChanged(filePath + '.d.ts', dts)
92
111
  } catch {
93
112
  continue
94
113
  }
@@ -97,14 +116,45 @@ export function cljPlugin(options?: CljPluginOptions): Plugin {
97
116
  }
98
117
 
99
118
  function initServerSession() {
119
+ // Use a require resolver anchored to the project root so that the server session
120
+ // can import npm packages from the consuming project's node_modules (e.g. date-fns),
121
+ // not just from the plugin's own node_modules.
122
+ const projectRequire = createRequire(resolve(projectRoot, 'package.json'))
123
+
100
124
  serverSession = createSession({
101
125
  sourceRoots,
102
126
  readFile: (filePath: string) =>
103
127
  readFileSync(resolve(projectRoot, filePath), 'utf-8'),
104
128
  output: () => {},
129
+ // Node dynamic import — used only during server-side code generation (DTS inference).
130
+ // In the browser bundle, importModule is a synchronous import map lookup instead.
131
+ importModule: async (s: string) => {
132
+ if (!s.startsWith('.') && !s.startsWith('/')) {
133
+ // Package import: resolve from project root context so the consuming project's
134
+ // node_modules are searched instead of (or in addition to) the plugin's.
135
+ try {
136
+ const resolved = projectRequire.resolve(s)
137
+ return import(pathToFileURL(resolved).href)
138
+ } catch {
139
+ try {
140
+ return await import(s)
141
+ } catch {
142
+ // Package not importable in Node context (browser-only) — return stub
143
+ // so that namespace loading succeeds and codegen can infer exported vars.
144
+ return {}
145
+ }
146
+ }
147
+ }
148
+ // Local/absolute path: may be a browser-only file (e.g. local .ts with DOM deps).
149
+ // Return a stub so the CLJ namespace still loads for server-side var inference.
150
+ try {
151
+ return await import(s)
152
+ } catch {
153
+ return {}
154
+ }
155
+ },
105
156
  })
106
157
  codegenCtx = {
107
- session: serverSession,
108
158
  sourceRoots,
109
159
  coreIndexPath,
110
160
  virtualSessionId: VIRTUAL_SESSION_ID,
@@ -123,6 +173,32 @@ export function cljPlugin(options?: CljPluginOptions): Plugin {
123
173
  }
124
174
  }
125
175
 
176
+ function scanStringRequires() {
177
+ // original string (what CLJ runtime passes to importModule) → resolved path/pkg
178
+ const seen = new Map<string, string>()
179
+ for (const root of sourceRoots) {
180
+ const rootPath = resolve(projectRoot, root)
181
+ for (const filePath of collectCljFiles(rootPath)) {
182
+ try {
183
+ const source = readFileSync(filePath, 'utf-8')
184
+ // Call twice: without filePath to get original CLJ source strings,
185
+ // with filePath to get resolved absolute paths for relative specifiers.
186
+ const originals = extractStringRequires(source)
187
+ const resolved = extractStringRequires(source, filePath)
188
+ for (let i = 0; i < originals.length; i++) {
189
+ seen.set(originals[i], resolved[i])
190
+ }
191
+ } catch {
192
+ continue
193
+ }
194
+ }
195
+ }
196
+ stringRequires = [...seen.entries()].map(([original, resolved]) => ({
197
+ original,
198
+ resolved,
199
+ }))
200
+ }
201
+
126
202
  function regenerateBuiltInNamespaceSources() {
127
203
  try {
128
204
  statSync(generatorScriptPath)
@@ -142,6 +218,23 @@ export function cljPlugin(options?: CljPluginOptions): Plugin {
142
218
  }
143
219
  }
144
220
 
221
+ /**
222
+ * Generate the static import table lines for the virtual session module.
223
+ * Each specifier gets a unique variable name. Returns { importLines, mapEntries }.
224
+ */
225
+ function buildImportTable(): { importLines: string[]; mapEntries: string[] } {
226
+ const importLines: string[] = []
227
+ const mapEntries: string[] = []
228
+ stringRequires.forEach(({ original, resolved }, i) => {
229
+ const varName = `_imp_${i}`
230
+ // Import statement uses the resolved path (absolute for local files, pkg name for packages).
231
+ // Map key uses the original CLJ source string — this is what importModule(s) receives at runtime.
232
+ importLines.push(`import * as ${varName} from ${JSON.stringify(resolved)};`)
233
+ mapEntries.push(` ${JSON.stringify(original)}: ${varName},`)
234
+ })
235
+ return { importLines, mapEntries }
236
+ }
237
+
145
238
  return {
146
239
  name: 'vite-plugin-clj',
147
240
 
@@ -152,6 +245,22 @@ export function cljPlugin(options?: CljPluginOptions): Plugin {
152
245
  regenerateBuiltInNamespaceSources()
153
246
  coreIndexPath = resolveCoreIndexPath()
154
247
  initServerSession()
248
+
249
+ // Detect Mode 2: explicit user entrypoint
250
+ if (options?.entrypoint) {
251
+ const ep = resolve(projectRoot, options.entrypoint)
252
+ try {
253
+ statSync(ep)
254
+ entrypointPath = ep
255
+ } catch {
256
+ // Configured entrypoint doesn't exist — fall through to Mode 1 with a warning
257
+ console.warn(
258
+ `[vite-plugin-clj] entrypoint not found: ${options.entrypoint} — falling back to auto-generated session`
259
+ )
260
+ }
261
+ }
262
+
263
+ scanStringRequires()
155
264
  eagerlyGenerateDts()
156
265
  },
157
266
 
@@ -176,42 +285,88 @@ export function cljPlugin(options?: CljPluginOptions): Plugin {
176
285
 
177
286
  load(id: string) {
178
287
  if (id === RESOLVED_VIRTUAL_SESSION_ID) {
179
- const lines = [
288
+ const { importLines, mapEntries } = buildImportTable()
289
+
290
+ // All static imports must be at the top of the module (ESM hoist semantics)
291
+ const lines: string[] = [
180
292
  `import { createSession, printString } from ${JSON.stringify(coreIndexPath)};`,
293
+ ...importLines,
294
+ ...(entrypointPath
295
+ ? [`import __conjureFactory from ${JSON.stringify(entrypointPath)};`]
296
+ : []),
297
+ ``,
298
+ `const __importMap = {`,
299
+ ...mapEntries,
300
+ `};`,
181
301
  ``,
182
302
  `let _session = null;`,
183
- `export function getSession() {`,
184
- ` if (!_session) {`,
185
- ` _session = createSession();`,
186
- ` }`,
187
- ` return _session;`,
188
- `}`,
303
+ `let _outputLines = [];`,
189
304
  ]
190
305
 
306
+ if (entrypointPath) {
307
+ // Mode 2: user-defined factory returns SessionOptions (without output/importModule).
308
+ // The plugin owns output capture (for nREPL relay) and importModule (import map wiring).
309
+ // User factory controls hostBindings, modules, entries, etc.
310
+ lines.push(
311
+ `export function getSession() {`,
312
+ ` if (!_session) {`,
313
+ ` const __userOpts = __conjureFactory(__importMap) ?? {};`,
314
+ ` _session = createSession({`,
315
+ ` ...(__userOpts),`,
316
+ ` importModule: (s) => __importMap[s],`,
317
+ ` output: (text) => { _outputLines.push(text); console.log(text.replace(/\\n$/, '')); },`,
318
+ ` });`,
319
+ ` }`,
320
+ ` return _session;`,
321
+ `}`,
322
+ )
323
+ } else {
324
+ // Mode 1: auto-generate session with import map wired in
325
+ lines.push(
326
+ `export function getSession() {`,
327
+ ` if (!_session) {`,
328
+ ` _session = createSession({`,
329
+ ` importModule: (s) => __importMap[s],`,
330
+ ` output: (text) => { _outputLines.push(text); console.log(text.replace(/\\n$/, '')); },`,
331
+ ` });`,
332
+ ` }`,
333
+ ` return _session;`,
334
+ `}`,
335
+ )
336
+ }
337
+
191
338
  if (serveMode) {
192
339
  lines.push(
193
340
  ``,
194
341
  `// Browser nREPL relay — active only in Vite dev server`,
195
342
  `if (import.meta.hot) {`,
196
- ` import.meta.hot.on('conjure:eval', ({ id, code, ns }) => {`,
343
+ ` import.meta.hot.on('conjure:eval', async ({ id, code, ns }) => {`,
197
344
  ` const session = getSession();`,
345
+ ` _outputLines = [];`,
198
346
  ` try {`,
199
347
  ` if (ns && ns !== session.currentNs) session.setNs(ns);`,
200
- ` const result = session.evaluate(code);`,
201
- ` import.meta.hot.send('conjure:eval-result', { id, value: printString(result), ns: session.currentNs });`,
348
+ ` const result = await session.evaluateAsync(code);`,
349
+ ` const out = _outputLines.join('');`,
350
+ ` import.meta.hot.send('conjure:eval-result', { id, value: printString(result), ns: session.currentNs, ...(out ? { out } : {}) });`,
202
351
  ` } catch (err) {`,
203
- ` import.meta.hot.send('conjure:eval-result', { id, error: err instanceof Error ? err.message : String(err), ns: session.currentNs });`,
352
+ ` console.error(err);`,
353
+ ` const out = _outputLines.join('');`,
354
+ ` import.meta.hot.send('conjure:eval-result', { id, error: err instanceof Error ? err.message : String(err), ns: session.currentNs, ...(out ? { out } : {}) });`,
204
355
  ` }`,
205
356
  ` });`,
206
357
  ``,
207
- ` import.meta.hot.on('conjure:load-file', ({ id, source, nsHint, filePath }) => {`,
358
+ ` import.meta.hot.on('conjure:load-file', async ({ id, source, nsHint, filePath }) => {`,
208
359
  ` const session = getSession();`,
360
+ ` _outputLines = [];`,
209
361
  ` try {`,
210
- ` const loadedNs = session.loadFile(source, nsHint, filePath || undefined);`,
362
+ ` const loadedNs = await session.loadFileAsync(source, nsHint, filePath || undefined);`,
211
363
  ` if (loadedNs) session.setNs(loadedNs);`,
212
- ` import.meta.hot.send('conjure:load-file-result', { id, value: 'nil', ns: session.currentNs });`,
364
+ ` const out = _outputLines.join('');`,
365
+ ` import.meta.hot.send('conjure:load-file-result', { id, value: 'nil', ns: session.currentNs, ...(out ? { out } : {}) });`,
213
366
  ` } catch (err) {`,
214
- ` import.meta.hot.send('conjure:load-file-result', { id, error: err instanceof Error ? err.message : String(err), ns: session.currentNs });`,
367
+ ` console.error(err);`,
368
+ ` const out = _outputLines.join('');`,
369
+ ` import.meta.hot.send('conjure:load-file-result', { id, error: err instanceof Error ? err.message : String(err), ns: session.currentNs, ...(out ? { out } : {}) });`,
215
370
  ` }`,
216
371
  ` });`,
217
372
  `}`,
@@ -224,9 +379,9 @@ export function cljPlugin(options?: CljPluginOptions): Plugin {
224
379
  if (id.endsWith('.clj') && !id.includes('?')) {
225
380
  const source = readFileSync(id, 'utf-8')
226
381
  const nsNameFromPath = pathToNs(relative(projectRoot, id), sourceRoots)
227
- const code = generateModuleCode(codegenCtx, nsNameFromPath, source)
382
+ const code = generateModuleCode(codegenCtx, nsNameFromPath, source, id)
228
383
  const dts = generateDts(codegenCtx, nsNameFromPath, source)
229
- writeFileIfChanged(id + '.d.ts', dts)
384
+ if (dts) writeFileIfChanged(id + '.d.ts', dts)
230
385
  return code
231
386
  }
232
387
  },
@@ -241,7 +396,7 @@ export function cljPlugin(options?: CljPluginOptions): Plugin {
241
396
  const source = await read()
242
397
  try {
243
398
  const nsNameFromPath = pathToNs(relative(projectRoot, file), sourceRoots)
244
- serverSession.loadFile(source, nsNameFromPath)
399
+ await serverSession.loadFileAsync(source, nsNameFromPath)
245
400
  const dts = generateDts(codegenCtx, nsNameFromPath, source)
246
401
  writeFileIfChanged(file + '.d.ts', dts)
247
402
  } catch {
@@ -1,3 +1,4 @@
1
+ import { resolve, dirname } from 'node:path'
1
2
  import { isKeyword, isList, isSymbol, isVector } from '../core/assertions'
2
3
  import { readForms } from '../core/reader'
3
4
  import { tokenize } from '../core/tokenizer'
@@ -63,6 +64,44 @@ export function extractNsRequires(source: string): string[] {
63
64
  return requires
64
65
  }
65
66
 
67
+ /**
68
+ * Parse Clojure source and extract all string module specifiers from (:require ...) clauses.
69
+ * These are the JS/npm imports written as string literals: (:require ["react" :as React]).
70
+ * Returns an array of resolved specifier strings (deduplicated).
71
+ *
72
+ * If filePath is provided, relative specifiers (starting with ./ or ../) are resolved
73
+ * to absolute paths using the file's directory. Package specifiers are returned as-is.
74
+ */
75
+ export function extractStringRequires(source: string, filePath?: string): string[] {
76
+ const forms = readForms(tokenize(source))
77
+ const nsForm = forms.find(
78
+ (f) => isList(f) && isSymbol(f.value[0]) && f.value[0].name === 'ns'
79
+ )
80
+ if (!nsForm || !isList(nsForm)) return []
81
+
82
+ const specifiers: string[] = []
83
+ for (let i = 2; i < nsForm.value.length; i++) {
84
+ const clause = nsForm.value[i]
85
+ if (
86
+ isList(clause) &&
87
+ isKeyword(clause.value[0]) &&
88
+ clause.value[0].name === ':require'
89
+ ) {
90
+ for (let j = 1; j < clause.value.length; j++) {
91
+ const spec = clause.value[j]
92
+ const first = isVector(spec) && spec.value.length > 0 ? spec.value[0] : null
93
+ if (!first || first.kind !== 'string') continue
94
+ let specifier = first.value
95
+ if (filePath && (specifier.startsWith('./') || specifier.startsWith('../'))) {
96
+ specifier = resolve(dirname(filePath), specifier)
97
+ }
98
+ specifiers.push(specifier)
99
+ }
100
+ }
101
+ }
102
+ return [...new Set(specifiers)]
103
+ }
104
+
66
105
  /**
67
106
  * Extract the namespace name from the (ns ...) form in a Clojure source string.
68
107
  * Returns null if no ns form is found.