@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.
- package/README.md +186 -6
- package/bin/norns.js +173 -4
- package/package.json +9 -4
- package/src/auto-import.js +648 -0
- package/src/config.js +49 -4
- package/src/index.js +2 -1
- package/src/migrate.js +212 -0
- package/src/server/boot.js +74 -0
- package/src/server/container.js +175 -0
- package/src/server/db.js +131 -0
- package/src/server/handle/context.js +24 -0
- package/src/server/handle/error.js +18 -0
- package/src/server/index.js +9 -0
- package/src/server/page.js +107 -0
- package/src/server/route.js +114 -0
- package/src/server/scope.js +50 -0
- package/src/server/validate.js +60 -0
- package/src/vite.js +23 -17
|
@@ -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
|
+
};
|