clojurific 0.12.2 → 0.12.4

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/README.md CHANGED
@@ -16,6 +16,9 @@ counts Clojurific's releases, starting from 1.
16
16
 
17
17
  ## Usage
18
18
 
19
+ `npm create clojurific@latest` scaffolds a project (Vanilla, Reagent or UIx)
20
+ like the one below, installs it and starts the dev server.
21
+
19
22
  Dependencies and source paths come from the project's `deps.edn`:
20
23
 
21
24
  ```clojure
@@ -50,8 +53,14 @@ or with the command line:
50
53
  npx cljf build '{:main my.app :output-dir "out"}'
51
54
  npx cljf -A:test watch @cljs.edn
52
55
  npx cljf classpath
56
+ npx cljf repl
53
57
  ```
54
58
 
59
+ `cljf repl` is a ClojureScript REPL evaluating in the pages the dev server
60
+ runs, through its nREPL server: `cljs({ outputDir: 'target/cljs',
61
+ compilerOptions: { repl: { 'nrepl-port': 0 } } })` with `nrepl/nrepl` in
62
+ `deps.edn`.
63
+
55
64
  `cljf` resolves `deps.edn` with tools.deps, adds the compiler, and removes
56
65
  stock ClojureScript (`org.clojure/clojurescript`) that libraries depend on.
57
66
  Dependencies are downloaded into `~/.m2/repository`, which the Clojure CLI
package/cljf/cli.js CHANGED
@@ -5,6 +5,7 @@ import { spawn } from 'node:child_process';
5
5
  import path from 'node:path';
6
6
  import { LauncherError } from './errors.js';
7
7
  import { classpath, compilerCommand, findJava, setupJava, version } from './launcher.js';
8
+ import { repl } from './repl.js';
8
9
 
9
10
  const usage = `Usage: cljf [options] <command> [arguments]
10
11
 
@@ -13,6 +14,9 @@ Commands:
13
14
  and :profile keywords (defaults to :release)
14
15
  watch [options...] compile, then recompile on changes (defaults to :dev)
15
16
  classpath print the project's classpath
17
+ repl [--port <port>] a ClojureScript REPL into the pages running the dev
18
+ server's (or watch's) build, through its nREPL server
19
+ (:repl {:nrepl-port 0}, port from .nrepl-port)
16
20
  setup-java [version] download Eclipse Temurin (the latest LTS by default)
17
21
  into cljf's cache, for machines without Java
18
22
 
@@ -42,9 +46,10 @@ function parse(argv) {
42
46
 
43
47
  function run(command, args) {
44
48
  const child = spawn(command, args, { stdio: 'inherit' });
49
+ // forwarded: only a terminal signals the JVM too (SIGINT), not kill, an IDE
50
+ // or a process supervisor; a second SIGINT is harmless
45
51
  for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
46
- // the terminal sends SIGINT to the JVM too, other signals are forwarded
47
- process.on(signal, () => signal !== 'SIGINT' && child.kill(signal));
52
+ process.on(signal, () => child.kill(signal));
48
53
  }
49
54
  child.on('error', e => {
50
55
  console.error(`cljf: ${command}: ${e.message}`);
@@ -72,6 +77,11 @@ async function main() {
72
77
  console.log(cp.classpath.join(path.delimiter));
73
78
  break;
74
79
  }
80
+ case 'repl': {
81
+ const i = args.indexOf('--port');
82
+ process.exit(await repl({ port: i >= 0 ? Number(args[i + 1]) : undefined }));
83
+ break;
84
+ }
75
85
  case 'setup-java': {
76
86
  const { java } = await setupJava({ version: args[0], log });
77
87
  console.log(java);
package/cljf/java.js CHANGED
@@ -203,7 +203,8 @@ export async function setupJava({
203
203
  await fs.mkdir(dir, { recursive: true });
204
204
  const tmp = await fs.mkdtemp(path.join(dir, '.setup-'));
205
205
  try {
206
- const archive = path.join(tmp, pkg.name);
206
+ // a name from the API, not a path
207
+ const archive = path.join(tmp, path.basename(pkg.name));
207
208
  try {
208
209
  await download(pkg.link, archive, { sha256: pkg.checksum, fetch });
209
210
  } catch (e) {
package/cljf/launcher.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // Classpaths are cached in the project's .cljf/cpcache, like the Clojure
5
5
  // CLI's .cpcache, until a deps.edn they were resolved from changes.
6
6
  import { spawn } from 'node:child_process';
7
- import { createHash } from 'node:crypto';
7
+ import { createHash, randomBytes } from 'node:crypto';
8
8
  import { existsSync, readFileSync } from 'node:fs';
9
9
  import fs from 'node:fs/promises';
10
10
  import path from 'node:path';
@@ -78,8 +78,10 @@ async function resolve({ project, aliases, java, cache, env, log }) {
78
78
  repository: env.CLJF_MAVEN_REPO || undefined,
79
79
  log,
80
80
  });
81
- const input = `${cache}.resolve.edn`;
82
- const output = `${cache}.resolve.json`;
81
+ // per process: another one may resolve the same classpath at the same time
82
+ const tmp = `${cache}.${process.pid}.${randomBytes(4).toString('hex')}`;
83
+ const input = `${tmp}.resolve.edn`;
84
+ const output = `${tmp}.resolve.json`;
83
85
  await fs.writeFile(input, edn({
84
86
  ':aliases': aliases,
85
87
  ':compiler': compiler,
package/cljf/repl.js ADDED
@@ -0,0 +1,335 @@
1
+ // cljf repl: a ClojureScript REPL in the terminal, evaluating in the pages
2
+ // running a watched build. It's an nREPL client of the watcher's nREPL server
3
+ // (:repl {:nrepl-port 0}, see cljs.esm.repl.nrepl), whose session it
4
+ // switches to ClojureScript with (cljs.esm.repl/repl).
5
+ import { existsSync } from 'node:fs';
6
+ import fs from 'node:fs/promises';
7
+ import net from 'node:net';
8
+ import path from 'node:path';
9
+ import readline from 'node:readline';
10
+ import { LauncherError } from './errors.js';
11
+
12
+ // bencode, nREPL's encoding: dicts, lists, integers and (UTF-8) strings
13
+
14
+ export function bencode(v) {
15
+ if (typeof v === 'number') return Buffer.from(`i${Math.trunc(v)}e`);
16
+ if (typeof v === 'string' || Buffer.isBuffer(v)) {
17
+ const bytes = Buffer.isBuffer(v) ? v : Buffer.from(v);
18
+ return Buffer.concat([Buffer.from(`${bytes.length}:`), bytes]);
19
+ }
20
+ if (Array.isArray(v)) return Buffer.concat([Buffer.from('l'), ...v.map(bencode), Buffer.from('e')]);
21
+ const keys = Object.keys(v).filter(k => v[k] !== undefined).sort();
22
+ return Buffer.concat([Buffer.from('d'), ...keys.flatMap(k => [bencode(k), bencode(v[k])]), Buffer.from('e')]);
23
+ }
24
+
25
+ const INCOMPLETE = Symbol('incomplete');
26
+
27
+ // The value at buf[i], [value, next index], or INCOMPLETE
28
+ function decodeAt(buf, i) {
29
+ if (i >= buf.length) return INCOMPLETE;
30
+ const c = String.fromCharCode(buf[i]);
31
+ if (c === 'i') {
32
+ const end = buf.indexOf('e', i);
33
+ return end < 0 ? INCOMPLETE : [Number(buf.toString('latin1', i + 1, end)), end + 1];
34
+ }
35
+ if (c === 'l' || c === 'd') {
36
+ const items = [];
37
+ let j = i + 1;
38
+ while (j < buf.length && buf[j] !== 0x65) {
39
+ const item = decodeAt(buf, j);
40
+ if (item === INCOMPLETE) return INCOMPLETE;
41
+ items.push(item[0]);
42
+ j = item[1];
43
+ }
44
+ if (j >= buf.length) return INCOMPLETE;
45
+ if (c === 'l') return [items, j + 1];
46
+ const dict = {};
47
+ for (let k = 0; k < items.length; k += 2) dict[items[k]] = items[k + 1];
48
+ return [dict, j + 1];
49
+ }
50
+ if (c >= '0' && c <= '9') {
51
+ const colon = buf.indexOf(':', i);
52
+ if (colon < 0) return INCOMPLETE;
53
+ const start = colon + 1;
54
+ const end = start + Number(buf.toString('latin1', i, colon));
55
+ return end > buf.length ? INCOMPLETE : [buf.toString('utf8', start, end), end];
56
+ }
57
+ throw new Error(`bencode: unexpected ${JSON.stringify(c)}`);
58
+ }
59
+
60
+ /** Decodes the values of a byte stream, as chunks arrive. */
61
+ export class Decoder {
62
+ #buf = Buffer.alloc(0);
63
+
64
+ /** The values chunk completes. */
65
+ push(chunk) {
66
+ this.#buf = Buffer.concat([this.#buf, chunk]);
67
+ const values = [];
68
+ for (;;) {
69
+ const decoded = decodeAt(this.#buf, 0);
70
+ if (decoded === INCOMPLETE) return values;
71
+ values.push(decoded[0]);
72
+ this.#buf = this.#buf.subarray(decoded[1]);
73
+ }
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Whether code's forms are all closed, so that the REPL evaluates it rather
79
+ * than reading another line: no open (, [ or {, string or regex. Extra
80
+ * closing brackets are the reader's to report.
81
+ */
82
+ export function complete(code) {
83
+ let depth = 0;
84
+ for (let i = 0; i < code.length; i++) {
85
+ const c = code[i];
86
+ if (c === ';') {
87
+ while (i < code.length && code[i] !== '\n') i++;
88
+ } else if (c === '\\') {
89
+ i++;
90
+ } else if (c === '"') {
91
+ for (i++; i < code.length && code[i] !== '"'; i++) if (code[i] === '\\') i++;
92
+ if (i >= code.length) return false;
93
+ } else if ('([{'.includes(c)) {
94
+ depth++;
95
+ } else if (')]}'.includes(c)) {
96
+ depth--;
97
+ }
98
+ }
99
+ return depth <= 0;
100
+ }
101
+
102
+ /** An nREPL connection: requests whose responses end with status done. */
103
+ export class Connection {
104
+ #socket;
105
+ #ids = 0;
106
+ #pending = new Map();
107
+
108
+ constructor(socket) {
109
+ this.#socket = socket;
110
+ const decoder = new Decoder();
111
+ socket.on('data', chunk => {
112
+ for (const msg of decoder.push(chunk)) {
113
+ const request = this.#pending.get(msg.id);
114
+ if (!request) continue;
115
+ request.onResponse(msg);
116
+ if (msg.status?.includes('done')) {
117
+ this.#pending.delete(msg.id);
118
+ request.resolve(msg);
119
+ }
120
+ }
121
+ });
122
+ }
123
+
124
+ /** A message id for request. */
125
+ newId() {
126
+ return String(++this.#ids);
127
+ }
128
+
129
+ /**
130
+ * Sends msg (with an id from newId, or a new one), calls onResponse with
131
+ * its responses; resolves to the last.
132
+ */
133
+ request(msg, onResponse = () => {}) {
134
+ const id = msg.id ?? this.newId();
135
+ return new Promise(resolve => {
136
+ this.#pending.set(id, { onResponse, resolve });
137
+ this.#socket.write(bencode({ ...msg, id }));
138
+ });
139
+ }
140
+
141
+ close() {
142
+ this.#socket.end();
143
+ }
144
+ }
145
+
146
+ function connect(port, host) {
147
+ return new Promise((resolve, reject) => {
148
+ const socket = net.connect(port, host);
149
+ socket.once('connect', () => {
150
+ socket.off('error', reject);
151
+ resolve(socket);
152
+ });
153
+ socket.once('error', reject);
154
+ });
155
+ }
156
+
157
+ /**
158
+ * Connects to the nREPL server on port, or the one in cwd's .nrepl-port,
159
+ * waiting for it to start (the dev server writes the file once it built).
160
+ */
161
+ export async function connectToServer({ cwd = process.cwd(), port, host = '127.0.0.1', log = () => {}, interval = 500 } = {}) {
162
+ const portFile = path.join(cwd, '.nrepl-port');
163
+ let waiting = false;
164
+ for (;;) {
165
+ const p = port ?? (existsSync(portFile) ? Number((await fs.readFile(portFile, 'utf8')).trim()) : null);
166
+ if (p) {
167
+ try {
168
+ return await connect(p, host);
169
+ } catch (e) {
170
+ if (port) throw new LauncherError(`cljf repl: no nREPL server on ${host}:${port}: ${e.message}`);
171
+ }
172
+ }
173
+ if (!waiting) {
174
+ log(`; waiting for the nREPL server of the dev server (npm run dev) or cljf watch, with :repl {:nrepl-port 0},\n; to write ${portFile}`);
175
+ waiting = true;
176
+ }
177
+ await new Promise(resolve => setTimeout(resolve, interval));
178
+ }
179
+ }
180
+
181
+ async function readHistory(file) {
182
+ try {
183
+ return (await fs.readFile(file, 'utf8')).split('\n').filter(Boolean).reverse();
184
+ } catch {
185
+ return [];
186
+ }
187
+ }
188
+
189
+ /**
190
+ * The REPL: reads forms from input, evaluates them in the session switched
191
+ * to ClojureScript, writes their output and values to output (errors and
192
+ * warnings to errorOutput). Resolves to the exit code once input ends or
193
+ * :cljs/quit.
194
+ */
195
+ export async function repl({
196
+ cwd = process.cwd(),
197
+ port,
198
+ host,
199
+ input = process.stdin,
200
+ output = process.stdout,
201
+ errorOutput = process.stderr,
202
+ terminal = Boolean(input.isTTY && output.isTTY),
203
+ historyFile = path.join(cwd, '.cljf', 'repl-history'),
204
+ } = {}) {
205
+ const socket = await connectToServer({ cwd, port, host, log: line => errorOutput.write(`${line}\n`) });
206
+ const conn = new Connection(socket);
207
+ let closing = false;
208
+ const closed = new Promise(resolve => socket.on('close', resolve));
209
+ socket.on('error', () => {});
210
+
211
+ const write = (stream, text) => text && stream.write(text);
212
+ const { 'new-session': session } = await conn.request({ op: 'clone' });
213
+ let failed = false;
214
+ await conn.request({ op: 'eval', session, code: '(cljs.esm.repl/repl)' }, msg => {
215
+ write(output, msg.out);
216
+ write(errorOutput, msg.err);
217
+ if (msg.ex) failed = true;
218
+ });
219
+ if (failed) {
220
+ conn.close();
221
+ throw new LauncherError('cljf repl: the nREPL server has no ClojureScript REPL (cljs.esm.repl), is it the dev server\'s?');
222
+ }
223
+
224
+ const history = terminal ? await readHistory(historyFile) : [];
225
+ const rl = readline.createInterface({ input, output, terminal, history, historySize: 1000, removeHistoryDuplicates: true });
226
+ if (terminal) {
227
+ rl.on('history', lines => {
228
+ fs.mkdir(path.dirname(historyFile), { recursive: true })
229
+ .then(() => fs.writeFile(historyFile, `${[...lines].reverse().join('\n')}\n`))
230
+ .catch(() => {});
231
+ });
232
+ }
233
+
234
+ let ns = 'cljs.user';
235
+ let pending = '';
236
+ let evaluating = null;
237
+ let interrupted = false;
238
+ const prompt = () => {
239
+ if (!terminal) return;
240
+ rl.setPrompt(pending ? `${' '.repeat(Math.max(0, ns.length - 1))}#_=> ` : `${ns}=> `);
241
+ rl.prompt();
242
+ };
243
+
244
+ const done = new Promise(resolve => {
245
+ const quit = code => {
246
+ closing = true;
247
+ rl.close();
248
+ // the server forgets the session, unless it doesn't answer
249
+ const timeout = new Promise(r => setTimeout(r, 1000).unref());
250
+ Promise.race([conn.request({ op: 'close', session }), timeout]).finally(() => {
251
+ conn.close();
252
+ resolve(code);
253
+ });
254
+ };
255
+ closed.then(() => {
256
+ if (closing) return;
257
+ errorOutput.write('\n; the nREPL server closed the connection\n');
258
+ closing = true;
259
+ rl.close();
260
+ resolve(1);
261
+ });
262
+
263
+ rl.on('SIGINT', () => {
264
+ if (evaluating) {
265
+ conn.request({ op: 'interrupt', session, 'interrupt-id': evaluating });
266
+ } else if (pending || rl.line) {
267
+ pending = '';
268
+ interrupted = false;
269
+ rl.write(null, { ctrl: true, name: 'u' });
270
+ output.write('\n');
271
+ prompt();
272
+ } else if (interrupted) {
273
+ quit(0);
274
+ } else {
275
+ interrupted = true;
276
+ output.write('\n; Ctrl-C again, Ctrl-D or :cljs/quit to quit\n');
277
+ prompt();
278
+ }
279
+ });
280
+
281
+ // lines are read one at a time: a form waits for the one before
282
+ const lines = [];
283
+ let busy = false;
284
+ const next = async () => {
285
+ if (busy) return;
286
+ busy = true;
287
+ while (lines.length) {
288
+ const line = lines.shift();
289
+ if (line === null) {
290
+ quit(0);
291
+ return;
292
+ }
293
+ interrupted = false;
294
+ pending += `${line}\n`;
295
+ if (!complete(pending)) {
296
+ prompt();
297
+ continue;
298
+ }
299
+ const code = pending;
300
+ pending = '';
301
+ if (!code.trim()) {
302
+ prompt();
303
+ continue;
304
+ }
305
+ if (code.trim() === ':cljs/quit') {
306
+ quit(0);
307
+ return;
308
+ }
309
+ evaluating = conn.newId();
310
+ await conn.request({ op: 'eval', session, code, ns, id: evaluating }, msg => {
311
+ write(output, msg.out);
312
+ write(errorOutput, msg.err);
313
+ if (msg.value !== undefined) output.write(`${msg.value}\n`);
314
+ if (msg.ns) ns = msg.ns;
315
+ });
316
+ evaluating = null;
317
+ if (closing) return;
318
+ prompt();
319
+ }
320
+ busy = false;
321
+ };
322
+ rl.on('line', line => {
323
+ lines.push(line);
324
+ next();
325
+ });
326
+ rl.on('close', () => {
327
+ if (closing) return;
328
+ lines.push(null);
329
+ next();
330
+ });
331
+ });
332
+
333
+ prompt();
334
+ return done;
335
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clojurific",
3
- "version": "0.12.2",
3
+ "version": "0.12.4",
4
4
  "description": "ClojureScript compiled to ES modules, bundled by Vite",
5
5
  "license": "EPL-1.0",
6
6
  "repository": {
@@ -7,7 +7,7 @@
7
7
  // TypeScript files they import.
8
8
  import { spawn } from 'node:child_process';
9
9
  import fs from 'node:fs/promises';
10
- import { existsSync } from 'node:fs';
10
+ import { existsSync, statSync } from 'node:fs';
11
11
  import path from 'node:path';
12
12
  import { createRequire } from 'node:module';
13
13
  import { pathToFileURL } from 'node:url';
@@ -31,16 +31,51 @@ async function sourceNamespace(file) {
31
31
  return ns;
32
32
  }
33
33
 
34
- // The ClojureScript sources pages load with module scripts
35
- async function pageSources(pages, root) {
36
- const sources = [];
37
- for (const page of pages) {
38
- const html = await fs.readFile(page, 'utf8');
39
- for (const [, src] of html.matchAll(/<script\b[^>]*\bsrc\s*=\s*["']([^"'?#]+\.clj[sc])["'][^>]*>/gi)) {
40
- sources.push(src.startsWith('/') ? path.join(root, src) : path.resolve(path.dirname(page), src));
34
+ const SCRIPT_SRC = /<script\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi;
35
+ // import "x", import x from "x", export … from "x", import("x")
36
+ const IMPORT = /(?:\bimport\s*(?:[\w$*{}\s,]+\bfrom\s*)?|\bexport\s*[\w$*{}\s,]+\bfrom\s*|\bimport\s*\(\s*)["']([^"']+)["']/g;
37
+ const SCRIPT_FILE = /\.(?:[cm]?[jt]sx?)$/;
38
+ // extensionless imports, as TypeScript projects write them
39
+ const SCRIPT_SUFFIXES = ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '/index.ts', '/index.js'];
40
+
41
+ // The file a page's script or a module's import names: root-absolute or
42
+ // relative paths, not packages or aliases, which the bundler resolves
43
+ function importedFile(spec, from, root) {
44
+ const file = spec.replace(/[?#].*$/, '');
45
+ if (!file.startsWith('/') && !file.startsWith('.')) return null;
46
+ return file.startsWith('/') ? path.join(root, file) : path.resolve(path.dirname(from), file);
47
+ }
48
+
49
+ // The ClojureScript sources the entries (pages and JavaScript or TypeScript
50
+ // modules) load: the pages' scripts and the modules' imports, followed through
51
+ // the project's modules
52
+ async function entrySources(entries, root) {
53
+ const sources = new Set();
54
+ const seen = new Set();
55
+ const queue = [...entries];
56
+ while (queue.length) {
57
+ const file = queue.shift();
58
+ if (seen.has(file) || file.includes(`${path.sep}node_modules${path.sep}`)) continue;
59
+ seen.add(file);
60
+ let code;
61
+ try {
62
+ code = await fs.readFile(file, 'utf8');
63
+ } catch (e) {
64
+ continue;
65
+ }
66
+ for (const [, spec] of code.matchAll(file.endsWith('.html') ? SCRIPT_SRC : IMPORT)) {
67
+ const target = importedFile(spec, file, root);
68
+ if (!target) continue;
69
+ if (SOURCE_FILE.test(target)) {
70
+ if (existsSync(target)) sources.add(target);
71
+ } else {
72
+ const module = SCRIPT_SUFFIXES.map(suffix => target + suffix)
73
+ .find(f => SCRIPT_FILE.test(f) && existsSync(f) && !statSync(f).isDirectory());
74
+ if (module) queue.push(module);
75
+ }
41
76
  }
42
77
  }
43
- return [...new Set(sources)];
78
+ return [...sources];
44
79
  }
45
80
 
46
81
  // Whether address is this machine's
@@ -73,14 +108,20 @@ async function commonJSPackage(root) {
73
108
  }
74
109
  }
75
110
 
76
- // A Rollup input (a file, files or {name: file}) as {name: absolute file}
111
+ // A Rollup input (a file, files or {name: file}) as {name: absolute file},
112
+ // files named by their path in the root without the extension, so pages with
113
+ // the same file name in different directories (about/index.html) keep apart
77
114
  function inputObject(input, root) {
78
115
  if (!input) return {};
79
116
  if (typeof input === 'object' && !Array.isArray(input)) {
80
117
  return Object.fromEntries(Object.entries(input).map(([name, file]) => [name, path.resolve(root, file)]));
81
118
  }
82
- return Object.fromEntries([].concat(input).map(file =>
83
- [path.basename(file, path.extname(file)), path.resolve(root, file)]));
119
+ return Object.fromEntries([].concat(input).map(file => {
120
+ const absolute = path.resolve(root, file);
121
+ const relative = path.relative(root, absolute);
122
+ const name = relative.startsWith('..') || path.isAbsolute(relative) ? path.basename(absolute) : relative;
123
+ return [name.slice(0, name.length - path.extname(name).length).split(path.sep).join('/'), absolute];
124
+ }));
84
125
  }
85
126
 
86
127
  function ednString(s) {
@@ -315,6 +356,13 @@ export default function cljs(options) {
315
356
  return (compiled ? path.relative(outputDir, file) : path.relative(cwd, file)).split(path.sep).join('/');
316
357
  }
317
358
 
359
+ // Whether a ClojureScript source may be served: in Vite's root or
360
+ // server.fs.allow
361
+ function servedSource(file) {
362
+ const dirs = [config.root, ...(config.server?.fs?.allow ?? [])].map(dir => path.resolve(dir));
363
+ return SOURCE_FILE.test(file) && dirs.some(dir => file.startsWith(dir + path.sep));
364
+ }
365
+
318
366
  // The compiled module of a ClojureScript source file: its namespace's in the
319
367
  // build info, or its munged name
320
368
  async function compiledModule(file) {
@@ -325,7 +373,8 @@ export default function cljs(options) {
325
373
  `${ns.split('.').map(part => part.replace(/-/g, '_')).join('/')}.js`;
326
374
  const module = path.join(outputDir, relative);
327
375
  if (!existsSync(module)) {
328
- throw new Error(`${ns} (${file}) isn't compiled: load it with a page's script, or add it to :main`);
376
+ throw new Error(`${ns} (${file}) isn't compiled: load it from a page's script or a module the pages ` +
377
+ 'import (through relative or root-absolute imports, restarting Vite after adding it), or add it to :main');
329
378
  }
330
379
  return module;
331
380
  }
@@ -372,8 +421,8 @@ export default function cljs(options) {
372
421
  // the project's page, an input of builds and the dev server's /
373
422
  const indexHtml = existsSync(path.join(root, 'index.html')) ? path.join(root, 'index.html') : null;
374
423
  const userInput = userConfig.build?.rolldownOptions?.input ?? userConfig.build?.rollupOptions?.input;
375
- const pages = Object.values(inputObject(userInput ?? indexHtml, root)).filter(f => f.endsWith('.html'));
376
- pageNamespaces = await Promise.all((await pageSources(pages, root)).map(sourceNamespace));
424
+ const entries = Object.values(inputObject(userInput ?? indexHtml, root));
425
+ pageNamespaces = await Promise.all((await entrySources(entries, root)).map(sourceNamespace));
377
426
  if (env.command === 'build' && !env.isPreview) {
378
427
  const start = Date.now();
379
428
  if (userConfig.build?.watch) {
@@ -452,14 +501,17 @@ export default function cljs(options) {
452
501
  // A ClojureScript source: its compiled module, as a module importing it
453
502
  // when serving, which a page loading it by its source's URL and the
454
503
  // modules importing it by its own share
455
- const file = source.split('?')[0];
504
+ const file = source.split(/[?#]/)[0];
456
505
  if (SOURCE_FILE.test(file) && !source.startsWith('\0')) {
506
+ // URLs are root-relative, files are only served from the project
457
507
  const candidates = [
458
- ...(path.isAbsolute(file) ? [file] : []),
459
508
  ...(file.startsWith('/') ? [path.join(root, file)] : []),
460
- ...(importer && !importer.startsWith('\0') ? [path.resolve(path.dirname(importer.split('?')[0]), file)] : []),
509
+ ...(importer && !importer.startsWith('\0') && !file.startsWith('/')
510
+ ? [path.resolve(path.dirname(importer.split('?')[0]), file)]
511
+ : []),
512
+ ...(path.isAbsolute(file) ? [file] : []),
461
513
  ];
462
- const found = candidates.find(f => existsSync(f));
514
+ const found = candidates.find(f => servedSource(f) && existsSync(f));
463
515
  if (!found) return null;
464
516
  return config.command === 'build' ? compiledModule(found) : '\0' + SOURCE + found;
465
517
  }
@@ -479,7 +531,9 @@ export default function cljs(options) {
479
531
 
480
532
  async load(id) {
481
533
  if (id.startsWith('\0' + SOURCE)) {
482
- const module = await compiledModule(id.slice(SOURCE.length + 1));
534
+ const file = id.slice(SOURCE.length + 1);
535
+ if (!servedSource(file)) this.error(`${file} is outside the project`);
536
+ const module = await compiledModule(file);
483
537
  return `import ${JSON.stringify(module)};\nexport * from ${JSON.stringify(module)};\n`;
484
538
  }
485
539
  // compiled namespaces with their source maps, to the ClojureScript sources
@@ -604,9 +658,10 @@ export default function cljs(options) {
604
658
  async handler(html, { filename }) {
605
659
  if (config.command !== 'serve') return html;
606
660
  const replacements = await Promise.all(
607
- [...html.matchAll(/(<script\b[^>]*\bsrc\s*=\s*["'])([^"'?#]+\.clj[sc])(["'])/gi)].map(async match => {
661
+ // with a query or fragment, which the compiled module's URL keeps
662
+ [...html.matchAll(/(<script\b[^>]*\bsrc\s*=\s*["'])([^"'?#]+\.clj[sc])([?#][^"']*)?(["'])/gi)].map(async match => {
608
663
  const file = match[2].startsWith('/') ? path.join(config.root, match[2]) : path.resolve(path.dirname(filename), match[2]);
609
- return [match[0], match[1] + devUrl(await compiledModule(file)) + match[3]];
664
+ return [match[0], match[1] + devUrl(await compiledModule(file)) + (match[3] ?? '') + match[4]];
610
665
  }));
611
666
  return replacements.reduce((result, [from, to]) => result.replace(from, to), html);
612
667
  },