@human-synthesis/norns 0.0.6 → 0.0.8

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.
@@ -0,0 +1,648 @@
1
+ import { readdirSync, readFileSync } from 'node:fs';
2
+ import { basename, dirname, extname, join, relative, resolve } from 'node:path';
3
+
4
+ // `match`: optional regex tested against the filename. When set, the helper
5
+ // module only fires for matching files. Used to scope helpers that have
6
+ // distinct client / server variants — most importantly `page`, which is
7
+ // exported by both `$app/state` (client) and `@human-synthesis/norns/server`
8
+ // (server) with completely different shapes.
9
+ const SERVER_PATH_RE = /(\.server\.|\/server\/|\+server\.)/;
10
+ const NON_SERVER_PATH_RE = /^(?!.*(?:\.server\.|\/server\/|\+server\.))/;
11
+
12
+ const DEFAULT_HELPERS = [
13
+ {
14
+ from: 'svelte',
15
+ imports: [
16
+ 'onMount',
17
+ 'onDestroy',
18
+ 'beforeUpdate',
19
+ 'afterUpdate',
20
+ 'tick',
21
+ 'getContext',
22
+ 'setContext',
23
+ 'hasContext',
24
+ 'createEventDispatcher',
25
+ 'untrack',
26
+ 'mount',
27
+ 'unmount',
28
+ 'flushSync'
29
+ ]
30
+ },
31
+ {
32
+ from: 'svelte/store',
33
+ imports: ['writable', 'readable', 'derived', 'readonly', 'get']
34
+ },
35
+ {
36
+ from: '@sveltejs/kit',
37
+ imports: [
38
+ 'error',
39
+ 'redirect',
40
+ 'fail',
41
+ 'isRedirect',
42
+ 'isHttpError',
43
+ 'isActionFailure',
44
+ 'json',
45
+ 'text'
46
+ ]
47
+ },
48
+ {
49
+ from: '$app/state',
50
+ imports: ['page', 'navigating', 'updated'],
51
+ match: NON_SERVER_PATH_RE
52
+ },
53
+ {
54
+ from: '@human-synthesis/norns/server',
55
+ imports: [
56
+ 'Container',
57
+ 'createContainer',
58
+ 'withScope',
59
+ 'getScope',
60
+ 'getContainer',
61
+ 'boot',
62
+ 'createApp',
63
+ 'contextHandle',
64
+ 'errorHandle',
65
+ 'route',
66
+ 'page',
67
+ 'validate',
68
+ 'ValidationError',
69
+ 'betterSqlite',
70
+ 'd1',
71
+ 'libsql',
72
+ 'postgres',
73
+ 'withTransaction'
74
+ ],
75
+ match: SERVER_PATH_RE
76
+ }
77
+ ];
78
+
79
+ const DEFAULT_COMPONENT_DIRS = ['src/lib/components'];
80
+ const DEFAULT_COMPONENT_EXTS = ['.svelte', '.n'];
81
+ const DEFAULT_EXPORT_EXTS = ['.c', '.civet', '.js'];
82
+ const DEFAULT_LIB_ROOT = 'src/lib';
83
+ const DEFAULT_LIB_ALIAS = '$lib';
84
+
85
+ const IDENT_RE = /\b[A-Za-z_$][\w$]*\b/g;
86
+ const SCRIPT_OR_STYLE_RE = /<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi;
87
+
88
+ /**
89
+ * Walk `dir` recursively and return absolute paths of files matching `exts`.
90
+ * Returns [] silently if dir doesn't exist — letting users register dirs
91
+ * that may be created later.
92
+ *
93
+ * @param {string} dir
94
+ * @param {string[]} exts
95
+ * @returns {string[]}
96
+ */
97
+ function walk(dir, exts) {
98
+ const out = [];
99
+ const stack = [dir];
100
+ while (stack.length > 0) {
101
+ const cur = /** @type {string} */ (stack.pop());
102
+ let entries;
103
+ try {
104
+ entries = readdirSync(cur, { withFileTypes: true });
105
+ } catch {
106
+ continue;
107
+ }
108
+ for (const entry of entries) {
109
+ const full = join(cur, entry.name);
110
+ if (entry.isDirectory()) stack.push(full);
111
+ else if (entry.isFile() && exts.includes(extname(entry.name))) out.push(full);
112
+ }
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /**
118
+ * Resolve the import path used to reference `componentFile` from
119
+ * `importerFile`. Components under `<root>/<libRoot>` get the `$lib/...`
120
+ * alias (portable across the project, friendly to dts output). Components
121
+ * outside that root fall back to a path relative to the importer — needed
122
+ * for route-colocated components in `src/routes/**`, which SvelteKit
123
+ * doesn't expose under any built-in alias.
124
+ *
125
+ * @param {string} componentFile Absolute path of the discovered component.
126
+ * @param {string | undefined} importerFile Absolute path of the file pulling it in.
127
+ * @param {string} root
128
+ * @param {string} libRoot
129
+ * @param {string} libAlias
130
+ * @returns {string | null}
131
+ */
132
+ function resolveComponentPath(componentFile, importerFile, root, libRoot, libAlias) {
133
+ const libBase = resolve(root, libRoot);
134
+ const fromLib = relative(libBase, componentFile).replace(/\\/g, '/');
135
+ if (!fromLib.startsWith('..')) return `${libAlias}/${fromLib}`;
136
+
137
+ if (!importerFile) return null;
138
+ let rel = relative(dirname(importerFile), componentFile).replace(/\\/g, '/');
139
+ if (!rel.startsWith('.')) rel = `./${rel}`;
140
+ return rel;
141
+ }
142
+
143
+ /**
144
+ * @param {string} root
145
+ * @param {string[]} dirs
146
+ * @param {string[]} exts
147
+ * @returns {Map<string, string>} name → absolute file path
148
+ */
149
+ function buildComponentMap(root, dirs, exts) {
150
+ /** @type {Map<string, string>} */
151
+ const map = new Map();
152
+ for (const d of dirs) {
153
+ const abs = resolve(root, d);
154
+ for (const file of walk(abs, exts)) {
155
+ const name = basename(file, extname(file));
156
+ if (!/^[A-Z]/.test(name)) continue; // components must be capitalised
157
+ if (map.has(name)) continue; // first match wins
158
+ map.set(name, file);
159
+ }
160
+ }
161
+ return map;
162
+ }
163
+
164
+ // Standard ES + Civet `:=` / `.=` export shapes. Type-only exports
165
+ // (`export type X`, `export interface X`, `export type { … }`) deliberately
166
+ // don't match — auto-import emits value imports, and emitting a value import
167
+ // for a type-only export breaks under TS `verbatimModuleSyntax`. Default
168
+ // exports also skipped: filename-as-identifier collides with the component
169
+ // scanner and the semantics of "auto-import a default" are project-specific.
170
+ const EXPORT_VAR_RE = /^\s*export\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/gm;
171
+ const EXPORT_FN_CLASS_RE =
172
+ /^\s*export\s+(?:async\s+)?(?:function|class)\s+([A-Za-z_$][\w$]*)/gm;
173
+ const EXPORT_CIVET_RE = /^\s*export\s+([A-Za-z_$][\w$]*)\s*[:.]=/gm;
174
+ const EXPORT_BLOCK_RE = /^\s*export\s*\{([^}]*)\}/gm;
175
+
176
+ /**
177
+ * Extract the set of named-value exports declared in `source`. Regex-based;
178
+ * accepts standard ES (`export const`, `export function`, `export {}`) and
179
+ * Civet's `:=` / `.=` operators. Rare misses just mean a name doesn't
180
+ * auto-import; the user notices and adds an explicit import — non-fatal.
181
+ *
182
+ * @param {string} source
183
+ * @returns {Set<string>}
184
+ */
185
+ function extractExports(source) {
186
+ /** @type {Set<string>} */
187
+ const out = new Set();
188
+ let m;
189
+
190
+ for (const re of [EXPORT_VAR_RE, EXPORT_FN_CLASS_RE, EXPORT_CIVET_RE]) {
191
+ re.lastIndex = 0;
192
+ while ((m = re.exec(source)) !== null) out.add(m[1]);
193
+ }
194
+
195
+ EXPORT_BLOCK_RE.lastIndex = 0;
196
+ while ((m = EXPORT_BLOCK_RE.exec(source)) !== null) {
197
+ for (const part of m[1].split(',')) {
198
+ const seg = part.trim();
199
+ if (!seg) continue;
200
+ // `type` prefix on a `{}` block entry is TS type-only — `{ type Foo }`
201
+ // or `{ Foo as type Bar }`. Skip those rather than emit a value import.
202
+ if (/^type\s/.test(seg)) continue;
203
+ const asMatch = seg.match(/(\w+)\s+as\s+(\w+)/);
204
+ out.add(asMatch ? asMatch[2] : seg);
205
+ }
206
+ }
207
+
208
+ return out;
209
+ }
210
+
211
+ // SvelteKit route conventions (`+page.server.c`, `+layout.c`, `+server.c`,
212
+ // `+error.svelte`, …) and hooks (`hooks.server.c`, `hooks.client.c`) export
213
+ // names like `load`, `actions`, `GET`, `handle`, `prerender` that are
214
+ // CONSUMED BY THE FRAMEWORK — never meant to be imported by other code. If
215
+ // they entered the export map, a user identifier called `load` would
216
+ // auto-import a random route's load function. Excluded by basename.
217
+ const ROUTE_FILE_RE = /^(\+|hooks\.)/;
218
+
219
+ /**
220
+ * Walk `dirs` and build a name → absolute-file-path map of every named
221
+ * value export found. First-match-wins on collisions (same as the component
222
+ * scanner) — silent because warnings would noise up the dev server on
223
+ * intentional re-exports. SvelteKit route/hook files are excluded by
224
+ * basename so framework-consumed exports don't leak into the map.
225
+ *
226
+ * @param {string} root
227
+ * @param {string[]} dirs
228
+ * @param {string[]} exts
229
+ * @returns {Map<string, string>}
230
+ */
231
+ function buildExportMap(root, dirs, exts) {
232
+ /** @type {Map<string, string>} */
233
+ const map = new Map();
234
+ for (const d of dirs) {
235
+ const abs = resolve(root, d);
236
+ for (const file of walk(abs, exts)) {
237
+ if (ROUTE_FILE_RE.test(basename(file))) continue;
238
+ let source;
239
+ try {
240
+ source = readFileSync(file, 'utf8');
241
+ } catch {
242
+ continue;
243
+ }
244
+ for (const name of extractExports(source)) {
245
+ if (!map.has(name)) map.set(name, file);
246
+ }
247
+ }
248
+ }
249
+ return map;
250
+ }
251
+
252
+ /**
253
+ * Resolve the import specifier for a project-utility file. Same path logic
254
+ * as `resolveComponentPath`, but strips the file extension so imports use
255
+ * the user's existing convention (`'$lib/notes/server/public'`, not
256
+ * `'$lib/notes/server/public.c'`). Vite resolves these via the configured
257
+ * `extensions` array.
258
+ *
259
+ * @param {string} file
260
+ * @param {string | undefined} importer
261
+ * @param {string} root
262
+ * @param {string} libRoot
263
+ * @param {string} libAlias
264
+ * @returns {string | null}
265
+ */
266
+ function resolveExportPath(file, importer, root, libRoot, libAlias) {
267
+ const path = resolveComponentPath(file, importer, root, libRoot, libAlias);
268
+ if (!path) return null;
269
+ return path.replace(/\.[a-z0-9]+$/i, '');
270
+ }
271
+
272
+ /**
273
+ * Collect every identifier that appears in `source`. Scans raw text — does
274
+ * not strip strings or comments. Worst case is an unused import, which the
275
+ * Svelte / Vite pipeline tree-shakes at build time, so the looseness is
276
+ * cheap.
277
+ *
278
+ * @param {string} source
279
+ * @param {Set<string>} into
280
+ */
281
+ function collectIdentifiers(source, into) {
282
+ IDENT_RE.lastIndex = 0;
283
+ let m;
284
+ while ((m = IDENT_RE.exec(source)) !== null) into.add(m[0]);
285
+ }
286
+
287
+ /**
288
+ * Names already in the script's lexical scope: existing imports plus
289
+ * top-level declarations. Heuristic regex — covers the common shapes; rare
290
+ * misses just produce a duplicate-import error which the user notices
291
+ * immediately.
292
+ *
293
+ * @param {string} script
294
+ * @returns {Set<string>}
295
+ */
296
+ function collectDeclared(script) {
297
+ /** @type {Set<string>} */
298
+ const out = new Set();
299
+
300
+ // import { a, b as c } from '...' / import D from '...' / import * as E from '...'
301
+ const importRe = /import\s+(?:(\w+)\s*,?\s*)?(?:\{\s*([^}]+)\s*\}|\*\s+as\s+(\w+))?\s*from/g;
302
+ let m;
303
+ while ((m = importRe.exec(script)) !== null) {
304
+ if (m[1]) out.add(m[1]);
305
+ if (m[3]) out.add(m[3]);
306
+ if (m[2]) {
307
+ for (const part of m[2].split(',')) {
308
+ const seg = part.trim();
309
+ if (!seg) continue;
310
+ const asMatch = seg.match(/(\w+)\s+as\s+(\w+)/);
311
+ out.add(asMatch ? asMatch[2] : seg);
312
+ }
313
+ }
314
+ }
315
+
316
+ const declRe = /\b(?:const|let|var|function|class)\s+(\w+)/g;
317
+ while ((m = declRe.exec(script)) !== null) out.add(m[1]);
318
+
319
+ // Destructured object patterns: `let { a, b: aliased, c = 1, ...rest } = expr`.
320
+ // Critical for Svelte 5 components that pull `page` etc. via `$props()` —
321
+ // without this, the auto-importer injects a duplicate `page` import that
322
+ // collides with the destructured binding (Kit's generated `root.svelte`
323
+ // hits this exactly).
324
+ const objDestructRe = /\b(?:const|let|var)\s*\{([^}]+)\}\s*=/g;
325
+ while ((m = objDestructRe.exec(script)) !== null) {
326
+ const tokenRe = /(?:\.\.\.\s*)?([\w$]+)(?:\s*:\s*([\w$]+))?/g;
327
+ let pm;
328
+ while ((pm = tokenRe.exec(m[1])) !== null) out.add(pm[2] || pm[1]);
329
+ }
330
+
331
+ // Destructured array patterns: `let [a, b, ...rest] = expr`.
332
+ const arrDestructRe = /\b(?:const|let|var)\s*\[([^\]]+)\]\s*=/g;
333
+ while ((m = arrDestructRe.exec(script)) !== null) {
334
+ const idRe = /(?:\.\.\.\s*)?([\w$]+)/g;
335
+ let pm;
336
+ while ((pm = idRe.exec(m[1])) !== null) out.add(pm[1]);
337
+ }
338
+
339
+ return out;
340
+ }
341
+
342
+ /**
343
+ * @param {Set<string>} referenced
344
+ * @param {Set<string>} declared
345
+ * @param {Array<{ from: string, imports: string[], match?: RegExp }>} helpers
346
+ * @param {Map<string, string>} components name → absolute file path (from dir scan)
347
+ * @param {string} [filename]
348
+ * @param {{ root?: string, libRoot?: string, libAlias?: string }} [ctx]
349
+ * @param {Record<string, string> | null} [componentSpecs] name → bare specifier (from user `components` map). Resolved AFTER the dir-scan map so user folders override silently.
350
+ * @param {Map<string, string> | null} [exports] name → absolute file path (from project-utility scan). Resolved LAST.
351
+ * @returns {Array<{ name: string, from: string, kind: 'named' | 'default' }>}
352
+ */
353
+ function computeImports(
354
+ referenced,
355
+ declared,
356
+ helpers,
357
+ components,
358
+ filename = '',
359
+ ctx = {},
360
+ componentSpecs = null,
361
+ exports = null
362
+ ) {
363
+ const out = [];
364
+ const root = ctx.root ?? '';
365
+ const libRoot = ctx.libRoot ?? '';
366
+ const libAlias = ctx.libAlias ?? '';
367
+
368
+ /** @type {Set<string>} every name we've already added — prevents collisions across helpers / components / specs / exports (resolution order = priority order). */
369
+ const added = new Set();
370
+
371
+ const wants = (name) => referenced.has(name) && !declared.has(name) && !added.has(name);
372
+
373
+ // 1. Helpers — fastest match, runs first. A path-gated helper (e.g.
374
+ // `$app/state.page` non-server, or `@human-synthesis/norns/server.page`
375
+ // server) takes precedence over project-utility scans for the same name.
376
+ for (const helper of helpers) {
377
+ if (helper.match && !helper.match.test(filename)) continue;
378
+ for (const name of helper.imports) {
379
+ if (wants(name)) {
380
+ out.push({ name, from: helper.from, kind: 'named' });
381
+ added.add(name);
382
+ }
383
+ }
384
+ }
385
+
386
+ // 2. Components from dir scan — capitalised basenames in `componentDirs`.
387
+ for (const [name, componentFile] of components) {
388
+ if (!wants(name)) continue;
389
+ const from = resolveComponentPath(componentFile, filename, root, libRoot, libAlias);
390
+ if (from) {
391
+ out.push({ name, from, kind: 'default' });
392
+ added.add(name);
393
+ }
394
+ }
395
+
396
+ // 3. Components from bare-specifier map (UI library presets like
397
+ // `presetUI()`). Used verbatim — no $lib aliasing or relative-path
398
+ // computation. A user's dir-scan match (step 2) shadows this silently.
399
+ if (componentSpecs) {
400
+ for (const name of Object.keys(componentSpecs)) {
401
+ if (!wants(name)) continue;
402
+ out.push({ name, from: componentSpecs[name], kind: 'default' });
403
+ added.add(name);
404
+ }
405
+ }
406
+
407
+ // 4. Project-utility named exports — `notes` from `$lib/notes/public`,
408
+ // `scheduleAiMove` from sibling `./ai`, etc. Imports emit extension-less
409
+ // paths (`'./store'`, `'$lib/notes/public'`) to match the convention
410
+ // already used in user code; Vite resolves via configured `extensions`.
411
+ if (exports) {
412
+ for (const [name, file] of exports) {
413
+ if (!wants(name)) continue;
414
+ const from = resolveExportPath(file, filename, root, libRoot, libAlias);
415
+ if (from) {
416
+ out.push({ name, from, kind: 'named' });
417
+ added.add(name);
418
+ }
419
+ }
420
+ }
421
+
422
+ return out;
423
+ }
424
+
425
+ /**
426
+ * @param {Array<{ name: string, from: string, kind: 'named' | 'default' }>} entries
427
+ * @returns {string}
428
+ */
429
+ function renderImports(entries) {
430
+ /** @type {Map<string, { default: string | null, named: string[] }>} */
431
+ const byFrom = new Map();
432
+ for (const { name, from, kind } of entries) {
433
+ let g = byFrom.get(from);
434
+ if (!g) {
435
+ g = { default: null, named: [] };
436
+ byFrom.set(from, g);
437
+ }
438
+ if (kind === 'default') g.default = name;
439
+ else g.named.push(name);
440
+ }
441
+ const lines = [];
442
+ for (const [from, { default: def, named }] of byFrom) {
443
+ const parts = [];
444
+ if (def) parts.push(def);
445
+ if (named.length > 0) parts.push(`{ ${named.join(', ')} }`);
446
+ lines.push(`import ${parts.join(', ')} from '${from}';`);
447
+ }
448
+ return lines.join('\n');
449
+ }
450
+
451
+ /**
452
+ * Norns auto-import. The returned object is BOTH a Svelte preprocessor
453
+ * (handles `.n` / `.svelte` markup + script blocks) AND a Vite plugin
454
+ * (handles standalone `.c` / `.civet` modules — server hooks, route
455
+ * handlers, repo / service modules). Wire it in both places:
456
+ *
457
+ * ```js
458
+ * // svelte.config.js
459
+ * import { nornsConfig } from '@human-synthesis/norns/config';
460
+ * import { nornsPreprocess } from '@human-synthesis/norns/preprocess';
461
+ * import { nornsAutoImport } from '@human-synthesis/norns/auto-import';
462
+ *
463
+ * export default nornsConfig({
464
+ * preprocess: [...nornsPreprocess(), nornsAutoImport()]
465
+ * });
466
+ *
467
+ * // vite.config.js
468
+ * import { nornsCivetPlugin } from '@human-synthesis/norns/vite';
469
+ * import { nornsAutoImport } from '@human-synthesis/norns/auto-import';
470
+ *
471
+ * export default { plugins: [nornsCivetPlugin(), nornsAutoImport()] };
472
+ * ```
473
+ *
474
+ * Detection rules:
475
+ * - `.n` / `.svelte`: scans markup + `<script>` body. Injects into the
476
+ * existing script block, or prepends a fresh one when a component is
477
+ * referenced from markup but no script block exists.
478
+ * - `.c` / `.civet`: scans the JS that `nornsCivetPlugin` produced and
479
+ * prepends imports for any referenced helper that's not already in
480
+ * scope. Components don't apply here.
481
+ * - Helper modules can carry an optional `match` regex that gates them
482
+ * by filename — used for the server-only Norns DI/route helpers so
483
+ * they don't false-positive on a client `.civet` utility.
484
+ *
485
+ * @param {object} [options]
486
+ * @param {Array<{ from: string, imports: string[], match?: RegExp }> | false} [options.helpers]
487
+ * Override or extend the helper-import list. `false` disables helpers.
488
+ * Defaults cover `svelte`, `svelte/store`, and `@human-synthesis/norns/server`
489
+ * (the latter scoped to server-path files via `match`).
490
+ * @param {string[] | false} [options.componentDirs]
491
+ * Project-relative dirs to scan for components. Default
492
+ * `['src/lib/components']`. `[]` or `false` disables component auto-import.
493
+ * @param {string[]} [options.componentExtensions]
494
+ * File extensions treated as components. Default `['.svelte', '.n']`.
495
+ * @param {Record<string, string>} [options.components]
496
+ * Name → bare-specifier import-path map. Used by UI library presets such
497
+ * as `presetUI()` from `@human-synthesis/norns-ui/auto-import` —
498
+ * `{ Btn: '@human-synthesis/norns-ui/components/Btn.n', … }`. Resolved
499
+ * AFTER `componentDirs`, so a user's `src/lib/components/Btn.n` overrides
500
+ * the library's `Btn` silently (first-match-wins). The string is used as
501
+ * the import source verbatim — no `$lib` aliasing or relative-path
502
+ * computation.
503
+ * @param {string[] | false} [options.exportDirs]
504
+ * Project-relative dirs to scan for named-value exports (think `store.c`'s
505
+ * `export { board, play, … }` or `public.c`'s `export notes := …`).
506
+ * Off by default — opt in with e.g. `['src/lib', 'src/routes']`. Files
507
+ * inside `libRoot` import as `$lib/...`, files outside import via paths
508
+ * relative to the importer. Default `false`.
509
+ * @param {string[]} [options.exportExtensions]
510
+ * File extensions scanned for exports. Default `['.c', '.civet', '.js']`
511
+ * — `.ts` is excluded by default because regex-scanned `.ts` can't reliably
512
+ * distinguish value exports from type-only exports.
513
+ * @param {string} [options.libRoot] Default `'src/lib'`.
514
+ * @param {string} [options.libAlias] Default `'$lib'`.
515
+ * @param {string} [options.root] Default `process.cwd()`.
516
+ */
517
+ export function nornsAutoImport(options = {}) {
518
+ const root = options.root ?? process.cwd();
519
+ const helpers = options.helpers === false ? [] : (options.helpers ?? DEFAULT_HELPERS);
520
+ const componentDirs =
521
+ options.componentDirs === false ? [] : (options.componentDirs ?? DEFAULT_COMPONENT_DIRS);
522
+ const componentExts = options.componentExtensions ?? DEFAULT_COMPONENT_EXTS;
523
+ const exportDirs =
524
+ options.exportDirs === false || options.exportDirs == null ? [] : options.exportDirs;
525
+ const exportExts = options.exportExtensions ?? DEFAULT_EXPORT_EXTS;
526
+ const libRoot = options.libRoot ?? DEFAULT_LIB_ROOT;
527
+ const libAlias = options.libAlias ?? DEFAULT_LIB_ALIAS;
528
+ const componentSpecs = options.components ?? null;
529
+
530
+ const components =
531
+ componentDirs.length === 0
532
+ ? new Map()
533
+ : buildComponentMap(root, componentDirs, componentExts);
534
+ const exportsMap =
535
+ exportDirs.length === 0 ? null : buildExportMap(root, exportDirs, exportExts);
536
+ const componentCtx = { root, libRoot, libAlias };
537
+
538
+ /** @type {Map<string, string>} */
539
+ const markupByFile = new Map();
540
+
541
+ return {
542
+ name: 'norns-auto-import',
543
+
544
+ markup({ content, filename }) {
545
+ if (!filename) return null;
546
+ const stripped = content.replace(SCRIPT_OR_STYLE_RE, '');
547
+ markupByFile.set(filename, stripped);
548
+
549
+ // If a script block exists, the script hook will handle injection.
550
+ if (/<script\b/i.test(content)) return null;
551
+
552
+ // No script block — scan markup alone and prepend a new one if any
553
+ // known identifier is referenced (almost always a component, since
554
+ // helpers like onMount only make sense from script).
555
+ /** @type {Set<string>} */
556
+ const referenced = new Set();
557
+ collectIdentifiers(stripped, referenced);
558
+
559
+ const toAdd = computeImports(
560
+ referenced,
561
+ new Set(),
562
+ helpers,
563
+ components,
564
+ filename,
565
+ componentCtx,
566
+ componentSpecs,
567
+ exportsMap
568
+ );
569
+ if (toAdd.length === 0) return null;
570
+
571
+ return { code: `<script>\n${renderImports(toAdd)}\n</script>\n${content}` };
572
+ },
573
+
574
+ script({ content, attributes, filename }) {
575
+ const lang = attributes?.lang;
576
+ if (lang && lang !== 'js' && lang !== 'javascript' && lang !== 'ts' && lang !== 'typescript') {
577
+ return null;
578
+ }
579
+
580
+ const markup = filename ? (markupByFile.get(filename) ?? '') : '';
581
+
582
+ /** @type {Set<string>} */
583
+ const referenced = new Set();
584
+ collectIdentifiers(markup, referenced);
585
+ collectIdentifiers(content, referenced);
586
+
587
+ const declared = collectDeclared(content);
588
+
589
+ const toAdd = computeImports(
590
+ referenced,
591
+ declared,
592
+ helpers,
593
+ components,
594
+ filename,
595
+ componentCtx,
596
+ componentSpecs,
597
+ exportsMap
598
+ );
599
+ if (toAdd.length === 0) return null;
600
+
601
+ return { code: `${renderImports(toAdd)}\n${content}` };
602
+ },
603
+
604
+ // Vite plugin hook — runs on standalone `.c` / `.civet` modules
605
+ // (server hooks, +page.server.c, repo.c, …). `nornsCivetPlugin`'s
606
+ // `load` already compiled them to JS by the time this fires, so we're
607
+ // scanning real JS. Components don't apply here (server code doesn't
608
+ // import .svelte), only helpers — and the path-based `match` filter
609
+ // keeps server-only helpers (e.g. `@human-synthesis/norns/server`)
610
+ // out of any client `.civet` utility modules.
611
+ enforce: 'post',
612
+ transform(code, id) {
613
+ const [path] = id.split('?');
614
+ if (path.includes('/node_modules/')) return null;
615
+ const ext = extname(path);
616
+ if (ext !== '.c' && ext !== '.civet') return null;
617
+
618
+ /** @type {Set<string>} */
619
+ const referenced = new Set();
620
+ collectIdentifiers(code, referenced);
621
+ const declared = collectDeclared(code);
622
+
623
+ const toAdd = computeImports(
624
+ referenced,
625
+ declared,
626
+ helpers,
627
+ new Map(),
628
+ path,
629
+ componentCtx,
630
+ null,
631
+ exportsMap
632
+ );
633
+ if (toAdd.length === 0) return null;
634
+
635
+ return { code: `${renderImports(toAdd)}\n${code}`, map: null };
636
+ }
637
+ };
638
+ }
639
+
640
+ export {
641
+ buildComponentMap as _buildComponentMap,
642
+ buildExportMap as _buildExportMap,
643
+ collectDeclared as _collectDeclared,
644
+ collectIdentifiers as _collectIdentifiers,
645
+ computeImports as _computeImports,
646
+ extractExports as _extractExports,
647
+ renderImports as _renderImports
648
+ };