@human-synthesis/norns 0.0.7 → 0.0.9

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  **AI-driven software architecture and development framework, based on Svelte.**
4
4
 
5
- SvelteKit with **Pug + Civet** and the `.n` / `.c` file extensions — preconfigured. The `.c` extension is recognised as an alias for `.civet`; both compile through Civet. CoffeeScript is no longer supported.
5
+ SvelteKit with **Pug + Civet** and the `.n` / `.c` file extensions — preconfigured. The `.c` extension is recognised as an alias for `.civet`; both compile through Civet.
6
6
 
7
7
  Includes a small runtime layer: feature-folder modularity, a DI container, route/page wrappers with valibot validation, and a migrations CLI.
8
8
 
@@ -63,7 +63,7 @@ export default defineConfig({
63
63
 
64
64
  ## Auto-imports
65
65
 
66
- `nornsAutoImport()` returns an object that's both a Svelte preprocessor (for `.n` / `.svelte` files) and a Vite plugin (for standalone `.c` / `.civet` modules). Wire it in both places:
66
+ `nornsAutoImport()` returns an object that's both a Svelte preprocessor (for `.n` / `.svelte` files) and a Vite plugin (for standalone `.c` / `.civet` modules). The same instance has all four resolvers: framework helpers, project components, project utilities, and library presets. Wire it in both places — Svelte's compiler ignores the Vite hooks, Vite ignores the Svelte hooks:
67
67
 
68
68
  ```js
69
69
  // svelte.config.js
@@ -72,7 +72,13 @@ import { nornsPreprocess } from '@human-synthesis/norns/preprocess';
72
72
  import { nornsAutoImport } from '@human-synthesis/norns/auto-import';
73
73
 
74
74
  export default nornsConfig({
75
- preprocess: [...nornsPreprocess(), nornsAutoImport()]
75
+ preprocess: [
76
+ ...nornsPreprocess(),
77
+ nornsAutoImport({
78
+ componentDirs: ['src/lib/components', 'src/routes'],
79
+ exportDirs: ['src/lib', 'src/routes']
80
+ })
81
+ ]
76
82
  });
77
83
  ```
78
84
 
@@ -81,24 +87,87 @@ export default nornsConfig({
81
87
  import { nornsCivetPlugin } from '@human-synthesis/norns/vite';
82
88
  import { nornsAutoImport } from '@human-synthesis/norns/auto-import';
83
89
 
84
- export default { plugins: [nornsCivetPlugin(), nornsAutoImport(), sveltekit()] };
90
+ export default {
91
+ plugins: [
92
+ nornsCivetPlugin(),
93
+ nornsAutoImport({ exportDirs: ['src/lib', 'src/routes'] }),
94
+ sveltekit()
95
+ ]
96
+ };
85
97
  ```
86
98
 
87
- With both in place:
99
+ ### What gets auto-imported
88
100
 
89
- - **Svelte helpers** `onMount`, `tick`, `getContext`, …, plus `svelte/store` (`writable`, `readable`, `derived`, `get`).
90
- - **SvelteKit helpers** — `error`, `redirect`, `fail`, `json`, `text`, … from `@sveltejs/kit`. Plus `page`, `navigating`, `updated` from `$app/state` — gated to non-server paths so it doesn't collide with the Norns server `page` (different shape, same name).
91
- - **Norns server helpers** `boot`, `page`, `route`, `Container`, `validate`, `betterSqlite`, from `@human-synthesis/norns/server`. Gated to server paths (`*.server.{c,civet}`, `**/server/**`, `+server.{c,civet}`).
92
- - **Project components** capitalised references like `<Card>` or `<Modal>` resolve to files under `src/lib/components/**` by default. Components inside `$lib` emit `$lib/...` import paths; components outside (e.g. route-colocated under `src/routes/**`, when you add it to `componentDirs`) emit a path relative to the importer. Files without a `<script>` block get one prepended automatically.
93
- - **Runes** (`$state`, `$derived`, `$effect`, `$props`) are Svelte compiler globals no import needed; the plugin doesn't touch them.
101
+ | Layer | Resolves | Examples |
102
+ |-------|----------|----------|
103
+ | Helpers | Hardcoded module-name lists, optionally path-gated | `onMount` from `svelte`, `redirect` from `@sveltejs/kit`, `page` from `$app/state` (client) or `@human-synthesis/norns/server` (server) |
104
+ | Components (dir scan) | Capitalised basenames in `componentDirs` | `<Card>` `$lib/components/Card.svelte`; `<Game>` `./Game.n` (route-colocated, importer-relative) |
105
+ | Components (preset map) | Bare-specifier `Record<name, importPath>` from a UI library | `<Btn>` `'@human-synthesis/norns-ui/components/Btn.n'` (used verbatim) |
106
+ | Project utilities | Named exports (`export const X`, `export X := …`, `export { a, b }`) discovered in `exportDirs` | `notes` from `$lib/notes/server/public`; `scheduleAiMove` from `./ai` (sibling) |
94
107
 
95
- Configurable on `nornsAutoImport({ })`: `helpers` (each entry can carry an optional `match: RegExp` to gate by filename), `componentDirs`, `componentExtensions`, `libRoot`, `libAlias`. Pass `helpers: false` or `componentDirs: false` to disable either layer.
108
+ Resolution priority is **helpers component dir component preset exports**. A name picked up earlier shadows a later match silently first-match-wins lets you override a library preset by dropping a file under your own `componentDirs`.
109
+
110
+ Path emission:
111
+
112
+ - Files inside `$lib` emit `$lib/...` paths (portable, friendly to the dts file).
113
+ - Files outside `$lib` emit a path relative to the importer.
114
+ - Project-utility paths are stripped of their file extension to match Norns/SvelteKit convention (`'$lib/notes/server/public'`, not `…/public.c`); the configured `extensions` array does the rest.
115
+
116
+ Files without a `<script>` block get one prepended automatically when a known component is referenced from markup. Runes (`$state`, `$derived`, `$effect`, `$props`) are Svelte compiler globals — no import needed; the plugin doesn't touch them.
117
+
118
+ ### Defaults
119
+
120
+ - **Helpers**: `svelte`, `svelte/store`, `@sveltejs/kit`, `$app/state` (non-server paths), `@human-synthesis/norns/server` (server paths only).
121
+ - **Component dirs**: `['src/lib/components']`.
122
+ - **Component extensions**: `['.svelte', '.n']`.
123
+ - **Export dirs**: `false` (off) — opt in. SvelteKit route/hook files (`+*.{c,svelte,n}`, `hooks.*`) are excluded from the export scan since their named exports (`load`, `actions`, `handle`, …) are framework-consumed.
124
+ - **Export extensions**: `['.c', '.civet', '.js']`. `.ts` is excluded by default — regex-based scanning can't reliably tell value exports from type-only ones under `verbatimModuleSyntax`.
125
+
126
+ ### UI library presets
127
+
128
+ A preset is a function returning a config slice — typically a `components` map. Compose it with your own config:
129
+
130
+ ```js
131
+ // vite.config.js
132
+ import { presetUI } from '@human-synthesis/norns-ui/auto-import';
133
+
134
+ const ui = presetUI();
135
+
136
+ export default {
137
+ plugins: [
138
+ nornsCivetPlugin(),
139
+ nornsAutoImport({
140
+ exportDirs: ['src/lib', 'src/routes'],
141
+ components: ui.components // { Btn: '@human-synthesis/norns-ui/components/Btn.n', … }
142
+ }),
143
+ sveltekit()
144
+ ]
145
+ };
146
+ ```
147
+
148
+ Drop `src/lib/components/Btn.n` in your project and it shadows the preset's `Btn` silently — `componentDirs` resolves first.
149
+
150
+ > **Roadmap.** Helpers from a preset (e.g. `toast()` from a UI library) currently can't merge with the defaults — passing `helpers` to `nornsAutoImport` _replaces_ the default list. A `presets` (or `additionalHelpers`) option to extend without replacing is a planned follow-up; for now, presets only deliver components.
151
+
152
+ ### Full options reference
153
+
154
+ | Option | Default | Notes |
155
+ |--------|---------|-------|
156
+ | `helpers` | `DEFAULT_HELPERS` (5 modules) | Pass `false` to disable. Each entry: `{ from, imports[], match? }` where `match` is a regex tested against the filename. |
157
+ | `componentDirs` | `['src/lib/components']` | `false` or `[]` to disable. |
158
+ | `componentExtensions` | `['.svelte', '.n']` | |
159
+ | `components` | `null` | `Record<name, importPath>` — bare-specifier preset map. |
160
+ | `exportDirs` | `false` | Off by default. Opt in with e.g. `['src/lib', 'src/routes']`. |
161
+ | `exportExtensions` | `['.c', '.civet', '.js']` | |
162
+ | `libRoot` | `'src/lib'` | Project-relative root that `libAlias` maps to. |
163
+ | `libAlias` | `'$lib'` | Alias prefix emitted in import paths. |
164
+ | `root` | `process.cwd()` | Project root. |
96
165
 
97
166
  ## Runtime — feature folders + DI
98
167
 
99
168
  Wire your hooks once:
100
169
 
101
- ```coffee
170
+ ```civet
102
171
  # src/hooks.server.c
103
172
  import { boot } from '@human-synthesis/norns/server'
104
173
 
@@ -124,7 +193,7 @@ src/lib/notes/
124
193
 
125
194
  Routes use thin wrappers from `@human-synthesis/norns/server`:
126
195
 
127
- ```coffee
196
+ ```civet
128
197
  # src/routes/notes/+page.server.c
129
198
  import { page } from '@human-synthesis/norns/server'
130
199
  import { notes } from '$lib/notes/server/public'
@@ -160,7 +229,7 @@ v1 supports SQLite via `better-sqlite3`. For Cloudflare D1 use `wrangler d1 migr
160
229
 
161
230
  The `db` helpers wire Drizzle across multiple targets:
162
231
 
163
- ```coffee
232
+ ```civet
164
233
  # module.c — Node + better-sqlite3 in dev
165
234
  import { betterSqlite } from '@human-synthesis/norns/server'
166
235
  db := await betterSqlite 'data/app.db', { pragma: ['journal_mode = WAL'] }
package/bin/norns.js CHANGED
@@ -11,6 +11,8 @@ import {
11
11
  applyMigrations,
12
12
  createMigration
13
13
  } from '../src/migrate.js';
14
+ import { nornsLint, printFindings } from '../src/lint.js';
15
+ import { nornsDiag } from '../src/diag.js';
14
16
 
15
17
  const FRAMEWORK_PKGS = ['@human-synthesis/norns-core', '@human-synthesis/norns'];
16
18
 
@@ -271,6 +273,29 @@ function openTargetDb(cwd) {
271
273
  return openSqliteDb(cwd, target.path);
272
274
  }
273
275
 
276
+ function lintCommand() {
277
+ const findings = nornsLint(process.cwd());
278
+ const { errors } = printFindings(findings);
279
+ process.exit(errors > 0 ? 1 : 0);
280
+ }
281
+
282
+ async function diagCommand(rest) {
283
+ const file = rest[0];
284
+ if (!file) {
285
+ console.error('Usage: norns diag <file.c | file.civet | file.n>');
286
+ process.exit(1);
287
+ }
288
+ try {
289
+ const js = await nornsDiag(file);
290
+ process.stdout.write(js);
291
+ if (!js.endsWith('\n')) process.stdout.write('\n');
292
+ } catch (err) {
293
+ console.error(`norns diag: ${err.message}`);
294
+ if (err.stack) console.error(err.stack);
295
+ process.exit(1);
296
+ }
297
+ }
298
+
274
299
  const [, , cmd = 'dev', ...rest] = process.argv;
275
300
 
276
301
  switch (cmd) {
@@ -284,6 +309,12 @@ switch (cmd) {
284
309
  case 'migrate':
285
310
  migrateCommand(rest);
286
311
  break;
312
+ case 'lint':
313
+ lintCommand();
314
+ break;
315
+ case 'diag':
316
+ diagCommand(rest);
317
+ break;
287
318
  case '-h':
288
319
  case '--help':
289
320
  console.log(`norns <command>
@@ -295,6 +326,8 @@ Commands:
295
326
  migrate status list applied + pending migrations
296
327
  migrate up apply pending migrations
297
328
  migrate create <feature>/<name> scaffold a new SQL migration
329
+ lint scan .c/.civet/.n + vite.config for known AI pitfalls
330
+ diag <file> print the compiled JS for a .c/.civet/.n file
298
331
 
299
332
  Migration db is read from \$DATABASE_URL (default: file:./data/app.db).
300
333
  Only file: (better-sqlite3) is supported in v1; for D1 use \`wrangler d1 migrations apply\`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@human-synthesis/norns",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
4
4
  "description": "Norns — SvelteKit with Civet, Pug, and the .n / .civet / .c file extensions",
5
5
  "license": "MIT",
6
6
  "author": "Daniel Teodoroiu (https://humansynthesis.ai)",
@@ -36,7 +36,7 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "@danielx/civet": "^0.11.0",
39
- "@human-synthesis/norns-core": "^0.0.7"
39
+ "@human-synthesis/norns-core": "^0.0.9"
40
40
  },
41
41
  "engines": {
42
42
  "node": ">=18"
@@ -6,8 +6,18 @@ import { basename, dirname, extname, join, relative, resolve } from 'node:path';
6
6
  // distinct client / server variants — most importantly `page`, which is
7
7
  // exported by both `$app/state` (client) and `@human-synthesis/norns/server`
8
8
  // (server) with completely different shapes.
9
- const SERVER_PATH_RE = /(\.server\.|\/server\/|\+server\.)/;
10
- const NON_SERVER_PATH_RE = /^(?!.*(?:\.server\.|\/server\/|\+server\.))/;
9
+ //
10
+ // The same predicate is reused (`isServerPath`) to classify project-utility
11
+ // exports: an export from a server-path file is invisible to client importers.
12
+ // That's the structural guarantee that prevents `db` / `bcrypt` / `repo.c`
13
+ // internals from being silently auto-imported into `.n` components and
14
+ // dragged into the client bundle.
15
+ const SERVER_PATH_RE = /(\.server\.|\/server\/|\+server\.|hooks\.server\.)/;
16
+ const NON_SERVER_PATH_RE = /^(?!.*(?:\.server\.|\/server\/|\+server\.|hooks\.server\.))/;
17
+
18
+ function isServerPath(file) {
19
+ return SERVER_PATH_RE.test(file.replace(/\\/g, '/'));
20
+ }
11
21
 
12
22
  const DEFAULT_HELPERS = [
13
23
  {
@@ -208,37 +218,166 @@ function extractExports(source) {
208
218
  return out;
209
219
  }
210
220
 
221
+ // SvelteKit route conventions (`+page.server.c`, `+layout.c`, `+server.c`,
222
+ // `+error.svelte`, …) and hooks (`hooks.server.c`, `hooks.client.c`) export
223
+ // names like `load`, `actions`, `GET`, `handle`, `prerender` that are
224
+ // CONSUMED BY THE FRAMEWORK — never meant to be imported by other code. If
225
+ // they entered the export map, a user identifier called `load` would
226
+ // auto-import a random route's load function. Excluded by basename.
227
+ const ROUTE_FILE_RE = /^(\+|hooks\.)/;
228
+
229
+ // Glob → regex. Supports `**`, `*`, `?`, and brace alternation `{a,b}`.
230
+ // Patterns are matched against project-relative paths (POSIX-style separators).
231
+ //
232
+ // src/lib/**/public.{c,civet} → src/lib/(?:.*/)?public\.(?:c|civet)
233
+ // src/**/store.c → src/(?:.*/)?store\.c
234
+ //
235
+ // Substitutions cascade: an early `**/` → `(?:.*/)?` expansion contains `*`,
236
+ // which a later single-`*` rule would clobber. Every regex expansion is
237
+ // stashed in a multi-char placeholder first; placeholders are swapped for
238
+ // their final regex form once all glob meta has been consumed.
239
+ function compileGlob(pattern) {
240
+ const PH_Q = '__NORNS_GLOB_Q__';
241
+ const PH_AS = '__NORNS_GLOB_AS__';
242
+ const PH_NS = '__NORNS_GLOB_NS__';
243
+
244
+ let p = pattern.replace(/[.+()^$|]/g, '\\$&');
245
+ p = p.replace(/\?/g, PH_Q);
246
+ p = p.replace(/\{([^}]+)\}/g, (_, inner) =>
247
+ '(?:' +
248
+ inner
249
+ .split(',')
250
+ .map((s) => s.trim().replace(/[.+()^$|?]/g, '\\$&'))
251
+ .join('|') +
252
+ ')'
253
+ );
254
+ p = p.replace(/\*\*\//g, `(?:${PH_AS}/)?`);
255
+ p = p.replace(/\/\*\*/g, `(?:/${PH_AS})?`);
256
+ p = p.replace(/\*\*/g, PH_AS);
257
+ p = p.replace(/\*/g, PH_NS);
258
+ p = p.replace(new RegExp(PH_AS, 'g'), '.*');
259
+ p = p.replace(new RegExp(PH_NS, 'g'), '[^/]*');
260
+ p = p.replace(new RegExp(PH_Q, 'g'), '[^/]');
261
+ return new RegExp('^' + p + '$');
262
+ }
263
+
264
+ const EXPORT_WALK_SKIP = new Set([
265
+ 'node_modules',
266
+ '.svelte-kit',
267
+ '.git',
268
+ 'build',
269
+ 'dist',
270
+ '.cache',
271
+ '.turbo'
272
+ ]);
273
+
274
+ /** Walk `root` and return absolute paths of files matching any of `globs`. */
275
+ function walkGlobs(root, globs) {
276
+ if (globs.length === 0) return [];
277
+ const regexes = globs.map(compileGlob);
278
+ /** @type {string[]} */
279
+ const out = [];
280
+ const stack = [root];
281
+ while (stack.length > 0) {
282
+ const cur = /** @type {string} */ (stack.pop());
283
+ let entries;
284
+ try {
285
+ entries = readdirSync(cur, { withFileTypes: true });
286
+ } catch {
287
+ continue;
288
+ }
289
+ for (const entry of entries) {
290
+ if (entry.name.startsWith('.') && entry.name !== '.') continue;
291
+ if (EXPORT_WALK_SKIP.has(entry.name)) continue;
292
+ const full = join(cur, entry.name);
293
+ if (entry.isDirectory()) {
294
+ stack.push(full);
295
+ } else if (entry.isFile()) {
296
+ const rel = relative(root, full).replace(/\\/g, '/');
297
+ if (regexes.some((re) => re.test(rel))) out.push(full);
298
+ }
299
+ }
300
+ }
301
+ return out;
302
+ }
303
+
211
304
  /**
212
- * Walk `dirs` and build a name → absolute-file-path map of every named
213
- * value export found. First-match-wins on collisions (same as the component
214
- * scanner) silent because warnings would noise up the dev server on
215
- * intentional re-exports.
305
+ * Walk files matching `globs` and build a name → candidate map.
306
+ *
307
+ * Each candidate carries its `isServer` classification. Resolution at
308
+ * import time picks the right candidate based on the importer's scope.
309
+ * SvelteKit route/hook files are excluded by basename so framework-consumed
310
+ * exports (`load`, `actions`, `handle`) don't enter the map.
216
311
  *
217
312
  * @param {string} root
218
- * @param {string[]} dirs
219
- * @param {string[]} exts
220
- * @returns {Map<string, string>}
313
+ * @param {string[]} globs project-relative glob patterns
314
+ * @param {string[]} exts file extensions accepted (defence-in-depth)
315
+ * @returns {Map<string, Array<{ file: string; isServer: boolean }>>}
221
316
  */
222
- function buildExportMap(root, dirs, exts) {
223
- /** @type {Map<string, string>} */
317
+ function buildExportMap(root, globs, exts) {
318
+ /** @type {Map<string, Array<{ file: string; isServer: boolean }>>} */
224
319
  const map = new Map();
225
- for (const d of dirs) {
226
- const abs = resolve(root, d);
227
- for (const file of walk(abs, exts)) {
228
- let source;
229
- try {
230
- source = readFileSync(file, 'utf8');
231
- } catch {
232
- continue;
233
- }
234
- for (const name of extractExports(source)) {
235
- if (!map.has(name)) map.set(name, file);
320
+ const files = walkGlobs(root, globs);
321
+ for (const file of files) {
322
+ if (ROUTE_FILE_RE.test(basename(file))) continue;
323
+ if (!exts.includes(extname(file))) continue;
324
+ let source;
325
+ try {
326
+ source = readFileSync(file, 'utf8');
327
+ } catch {
328
+ continue;
329
+ }
330
+ const rel = relative(root, file).replace(/\\/g, '/');
331
+ const isServer = isServerPath(rel);
332
+ for (const name of extractExports(source)) {
333
+ let list = map.get(name);
334
+ if (!list) {
335
+ list = [];
336
+ map.set(name, list);
236
337
  }
338
+ list.push({ file, isServer });
237
339
  }
238
340
  }
239
341
  return map;
240
342
  }
241
343
 
344
+ /**
345
+ * Collapse a raw candidate map into one server-scope and one client-scope
346
+ * candidate per name. If a single scope has multiple candidates, that's a
347
+ * conflict — log it and exclude that scope. Mixed scopes (one server + one
348
+ * client) are kept and resolved dynamically by importer scope.
349
+ *
350
+ * @param {Map<string, Array<{ file: string; isServer: boolean }>>} raw
351
+ * @param {(msg: string) => void} [log]
352
+ * @returns {Map<string, { server?: string; client?: string }>}
353
+ */
354
+ function resolveExportConflicts(raw, log = console.warn) {
355
+ /** @type {Map<string, { server?: string; client?: string }>} */
356
+ const out = new Map();
357
+ for (const [name, candidates] of raw) {
358
+ const servers = candidates.filter((c) => c.isServer);
359
+ const clients = candidates.filter((c) => !c.isServer);
360
+ /** @type {{ server?: string; client?: string }} */
361
+ const entry = {};
362
+ if (servers.length === 1) entry.server = servers[0].file;
363
+ else if (servers.length > 1) {
364
+ log(
365
+ `[norns-auto-import] conflict: \`${name}\` exported from multiple server files — not auto-imported, use explicit imports:`
366
+ );
367
+ for (const c of servers) log(` - ${c.file}`);
368
+ }
369
+ if (clients.length === 1) entry.client = clients[0].file;
370
+ else if (clients.length > 1) {
371
+ log(
372
+ `[norns-auto-import] conflict: \`${name}\` exported from multiple client files — not auto-imported, use explicit imports:`
373
+ );
374
+ for (const c of clients) log(` - ${c.file}`);
375
+ }
376
+ if (entry.server || entry.client) out.set(name, entry);
377
+ }
378
+ return out;
379
+ }
380
+
242
381
  /**
243
382
  * Resolve the import specifier for a project-utility file. Same path logic
244
383
  * as `resolveComponentPath`, but strips the file extension so imports use
@@ -337,8 +476,8 @@ function collectDeclared(script) {
337
476
  * @param {string} [filename]
338
477
  * @param {{ root?: string, libRoot?: string, libAlias?: string }} [ctx]
339
478
  * @param {Record<string, string> | null} [componentSpecs] name → bare specifier (from user `components` map). Resolved AFTER the dir-scan map so user folders override silently.
340
- * @param {Map<string, string> | null} [exports] name → absolute file path (from project-utility scan). Resolved LAST.
341
- * @returns {Array<{ name: string, from: string, kind: 'named' | 'default' }>}
479
+ * @param {Map<string, { server?: string; client?: string }> | null} [exports] name → scoped candidate map. Resolved LAST and gated by importer scope (`isServerPath`).
480
+ * @returns {Array<{ name: string, from: string, kind: 'named' | 'default', annotate?: boolean }>}
342
481
  */
343
482
  function computeImports(
344
483
  referenced,
@@ -394,16 +533,24 @@ function computeImports(
394
533
  }
395
534
  }
396
535
 
397
- // 4. Project-utility named exports — `notes` from `$lib/notes/public`,
398
- // `scheduleAiMove` from sibling `./ai`, etc. Imports emit extension-less
399
- // paths (`'./store'`, `'$lib/notes/public'`) to match the convention
400
- // already used in user code; Vite resolves via configured `extensions`.
536
+ // 4. Project-utility named exports — `notes` from `$lib/notes/public`, etc.
537
+ // Importer scope decides which candidate is allowed:
538
+ // - Server importer → server candidate preferred, falls back to client.
539
+ // - Client importer → ONLY a client-safe candidate; server-only exports
540
+ // are invisible (prevents bundling server code into the client).
541
+ // Auto-injected imports get an `annotate` flag so `renderImports` can mark
542
+ // them with a `// auto-import` comment.
401
543
  if (exports) {
402
- for (const [name, file] of exports) {
544
+ const importerIsServer = isServerPath(filename);
545
+ for (const [name, scoped] of exports) {
403
546
  if (!wants(name)) continue;
404
- const from = resolveExportPath(file, filename, root, libRoot, libAlias);
547
+ const chosenFile = importerIsServer
548
+ ? (scoped.server ?? scoped.client)
549
+ : scoped.client;
550
+ if (!chosenFile) continue;
551
+ const from = resolveExportPath(chosenFile, filename, root, libRoot, libAlias);
405
552
  if (from) {
406
- out.push({ name, from, kind: 'named' });
553
+ out.push({ name, from, kind: 'named', annotate: true });
407
554
  added.add(name);
408
555
  }
409
556
  }
@@ -413,27 +560,29 @@ function computeImports(
413
560
  }
414
561
 
415
562
  /**
416
- * @param {Array<{ name: string, from: string, kind: 'named' | 'default' }>} entries
563
+ * @param {Array<{ name: string, from: string, kind: 'named' | 'default', annotate?: boolean }>} entries
417
564
  * @returns {string}
418
565
  */
419
566
  function renderImports(entries) {
420
- /** @type {Map<string, { default: string | null, named: string[] }>} */
567
+ /** @type {Map<string, { default: string | null, named: string[], annotate: boolean }>} */
421
568
  const byFrom = new Map();
422
- for (const { name, from, kind } of entries) {
569
+ for (const { name, from, kind, annotate } of entries) {
423
570
  let g = byFrom.get(from);
424
571
  if (!g) {
425
- g = { default: null, named: [] };
572
+ g = { default: null, named: [], annotate: false };
426
573
  byFrom.set(from, g);
427
574
  }
428
575
  if (kind === 'default') g.default = name;
429
576
  else g.named.push(name);
577
+ if (annotate) g.annotate = true;
430
578
  }
431
579
  const lines = [];
432
- for (const [from, { default: def, named }] of byFrom) {
580
+ for (const [from, { default: def, named, annotate }] of byFrom) {
433
581
  const parts = [];
434
582
  if (def) parts.push(def);
435
583
  if (named.length > 0) parts.push(`{ ${named.join(', ')} }`);
436
- lines.push(`import ${parts.join(', ')} from '${from}';`);
584
+ const stmt = `import ${parts.join(', ')} from '${from}';`;
585
+ lines.push(annotate ? `${stmt} // auto-import` : stmt);
437
586
  }
438
587
  return lines.join('\n');
439
588
  }
@@ -490,19 +639,36 @@ function renderImports(entries) {
490
639
  * the library's `Btn` silently (first-match-wins). The string is used as
491
640
  * the import source verbatim — no `$lib` aliasing or relative-path
492
641
  * computation.
642
+ * @param {string[] | false} [options.exportGlobs]
643
+ * Glob patterns (project-relative, POSIX separators) matched against files
644
+ * to scan for named-value exports. The recommended convention is barrel-file
645
+ * scope — `['src/lib/**\/public.c']` exposes only each feature's intentional
646
+ * API surface and leaves repo/service/module internals invisible to
647
+ * auto-import. Supports `**`, `*`, `?`, and `{a,b}` alternation.
648
+ *
649
+ * Path-based safety is enforced at resolution time: a file under
650
+ * `/server/` / `*.server.*` / `+server.*` / `hooks.server.*` is classified
651
+ * server-only and is NEVER auto-imported into a client (non-server) file.
652
+ * Name collisions inside the same scope are detected at startup, logged,
653
+ * and excluded from auto-import — forcing an explicit import to disambiguate.
654
+ *
655
+ * Default `[]` (off; explicit imports for service-layer code).
493
656
  * @param {string[] | false} [options.exportDirs]
494
- * Project-relative dirs to scan for named-value exports (think `store.c`'s
495
- * `export { board, play, }` or `public.c`'s `export notes := …`).
496
- * Off by default opt in with e.g. `['src/lib', 'src/routes']`. Files
497
- * inside `libRoot` import as `$lib/...`, files outside import via paths
498
- * relative to the importer. Default `false`.
657
+ * DEPRECATED. Equivalent to `exportGlobs: dirs.map(d => '${d}/**\/*.{c,civet,js}')`,
658
+ * which scans every file under those dirs. Path-scoping is still applied,
659
+ * so server exports won't leak into client files but the broad scan
660
+ * surfaces every internal export as a potential auto-import. Migrate to
661
+ * `exportGlobs: ['src/lib/**\/public.c']` for an intentional API surface.
499
662
  * @param {string[]} [options.exportExtensions]
500
- * File extensions scanned for exports. Default `['.c', '.civet', '.js']`
501
- * `.ts` is excluded by default because regex-scanned `.ts` can't reliably
502
- * distinguish value exports from type-only exports.
663
+ * File extensions accepted for exports (defence-in-depth on top of the
664
+ * glob). Default `['.c', '.civet', '.js']` `.ts` excluded because
665
+ * regex-scanned `.ts` can't reliably distinguish value vs type-only exports.
503
666
  * @param {string} [options.libRoot] Default `'src/lib'`.
504
667
  * @param {string} [options.libAlias] Default `'$lib'`.
505
668
  * @param {string} [options.root] Default `process.cwd()`.
669
+ * @param {(msg: string) => void} [options.log]
670
+ * Channel for conflict / deprecation warnings. Default `console.warn`.
671
+ * Tests pass a stub to assert behavior without polluting output.
506
672
  */
507
673
  export function nornsAutoImport(options = {}) {
508
674
  const root = options.root ?? process.cwd();
@@ -510,19 +676,37 @@ export function nornsAutoImport(options = {}) {
510
676
  const componentDirs =
511
677
  options.componentDirs === false ? [] : (options.componentDirs ?? DEFAULT_COMPONENT_DIRS);
512
678
  const componentExts = options.componentExtensions ?? DEFAULT_COMPONENT_EXTS;
513
- const exportDirs =
514
- options.exportDirs === false || options.exportDirs == null ? [] : options.exportDirs;
515
679
  const exportExts = options.exportExtensions ?? DEFAULT_EXPORT_EXTS;
516
680
  const libRoot = options.libRoot ?? DEFAULT_LIB_ROOT;
517
681
  const libAlias = options.libAlias ?? DEFAULT_LIB_ALIAS;
518
682
  const componentSpecs = options.components ?? null;
683
+ const log = options.log ?? console.warn;
684
+
685
+ // Build the effective glob list. `exportGlobs` is the new API;
686
+ // `exportDirs` is shimmed in for backward compatibility with a one-time
687
+ // deprecation notice per init.
688
+ const explicitGlobs =
689
+ options.exportGlobs === false || options.exportGlobs == null ? [] : options.exportGlobs;
690
+ const legacyDirs =
691
+ options.exportDirs === false || options.exportDirs == null ? [] : options.exportDirs;
692
+ let effectiveGlobs = [...explicitGlobs];
693
+ if (legacyDirs.length > 0) {
694
+ log(
695
+ '[norns-auto-import] `exportDirs` is deprecated. Migrate to `exportGlobs`, e.g. ' +
696
+ "`exportGlobs: ['src/lib/**/public.c']` — barrel-file scope is the safe default."
697
+ );
698
+ effectiveGlobs = effectiveGlobs.concat(
699
+ legacyDirs.map((d) => `${d.replace(/\\/g, '/').replace(/\/+$/, '')}/**/*.{c,civet,js}`)
700
+ );
701
+ }
519
702
 
520
703
  const components =
521
704
  componentDirs.length === 0
522
705
  ? new Map()
523
706
  : buildComponentMap(root, componentDirs, componentExts);
524
- const exportsMap =
525
- exportDirs.length === 0 ? null : buildExportMap(root, exportDirs, exportExts);
707
+ const rawExports =
708
+ effectiveGlobs.length === 0 ? null : buildExportMap(root, effectiveGlobs, exportExts);
709
+ const exportsMap = rawExports ? resolveExportConflicts(rawExports, log) : null;
526
710
  const componentCtx = { root, libRoot, libAlias };
527
711
 
528
712
  /** @type {Map<string, string>} */
@@ -632,7 +816,10 @@ export {
632
816
  buildExportMap as _buildExportMap,
633
817
  collectDeclared as _collectDeclared,
634
818
  collectIdentifiers as _collectIdentifiers,
819
+ compileGlob as _compileGlob,
635
820
  computeImports as _computeImports,
636
821
  extractExports as _extractExports,
637
- renderImports as _renderImports
822
+ isServerPath as _isServerPath,
823
+ renderImports as _renderImports,
824
+ resolveExportConflicts as _resolveExportConflicts
638
825
  };
package/src/config.js CHANGED
@@ -14,7 +14,7 @@ import { nornsPreprocess } from '@human-synthesis/norns-core/preprocess';
14
14
  * `.ts` for hooks (it doesn't honor `moduleExtensions`), so the explicit
15
15
  * path is the non-invasive way to make `.c`/`.civet` hooks discoverable.
16
16
  * Same for the client and universal counterparts.
17
- * - `preprocess: nornsPreprocess()` — Coffee + Pug + rune fusion + auto-close
17
+ * - `preprocess: nornsPreprocess()` — Pug + Civet
18
18
  *
19
19
  * Spread your own overrides at the call site to extend or replace defaults.
20
20
  *
package/src/diag.js ADDED
@@ -0,0 +1,38 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { compile as compileCivet } from '@danielx/civet';
4
+
5
+ /**
6
+ * Compile a `.c` / `.civet` file (or the `<script lang="civet">` block of a
7
+ * `.n` / `.svelte` file) to plain JS so callers can inspect what Civet
8
+ * actually produced. The diagnosis recipe is:
9
+ *
10
+ * bun norns diag path/to/file.c
11
+ *
12
+ * Use it when a Civet error message is unhelpful — the compiled output
13
+ * proves whether the source is correct and the bug is downstream.
14
+ *
15
+ * @param {string} file path (relative or absolute)
16
+ * @returns {Promise<string>} compiled JS
17
+ */
18
+ export async function nornsDiag(file) {
19
+ const abs = resolve(file);
20
+ if (!existsSync(abs)) throw new Error(`No such file: ${file}`);
21
+
22
+ const content = readFileSync(abs, 'utf8');
23
+ let source = content;
24
+
25
+ if (abs.endsWith('.n') || abs.endsWith('.svelte')) {
26
+ const m = content.match(/<script\b[^>]*>([\s\S]*?)<\/script>/i);
27
+ if (!m) throw new Error(`No <script> block in ${file}`);
28
+ source = m[1];
29
+ }
30
+
31
+ const result = await compileCivet(source, {
32
+ js: true,
33
+ filename: abs
34
+ });
35
+ // Civet returns a plain string when no sourceMap option is supplied,
36
+ // otherwise an object with `.code`. Handle both.
37
+ return typeof result === 'string' ? result : result.code;
38
+ }
package/src/lint.js ADDED
@@ -0,0 +1,294 @@
1
+ import { readdirSync, readFileSync, existsSync } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+
4
+ const SKIP_DIRS = new Set([
5
+ 'node_modules',
6
+ '.svelte-kit',
7
+ '.git',
8
+ 'build',
9
+ 'dist',
10
+ 'static',
11
+ '.next',
12
+ '.cache',
13
+ '.turbo',
14
+ 'data',
15
+ 'coverage'
16
+ ]);
17
+
18
+ /**
19
+ * @typedef {{ file: string; line: number; severity: 'error' | 'warning'; rule: string; msg: string }} Finding
20
+ */
21
+
22
+ function walk(dir, filter, out = []) {
23
+ let entries;
24
+ try {
25
+ entries = readdirSync(dir, { withFileTypes: true });
26
+ } catch {
27
+ return out;
28
+ }
29
+ for (const entry of entries) {
30
+ if (entry.name.startsWith('.') && entry.name !== '.') continue;
31
+ const full = join(dir, entry.name);
32
+ if (entry.isDirectory()) {
33
+ if (SKIP_DIRS.has(entry.name)) continue;
34
+ walk(full, filter, out);
35
+ } else if (entry.isFile() && filter(entry.name)) {
36
+ out.push(full);
37
+ }
38
+ }
39
+ return out;
40
+ }
41
+
42
+ /** Remove string and template literals from a line so regexes don't match inside them. */
43
+ function stripStrings(line) {
44
+ let out = '';
45
+ let mode = 0; // 0=code, 1=', 2=", 3=`
46
+ for (let i = 0; i < line.length; i++) {
47
+ const c = line[i];
48
+ const prev = line[i - 1];
49
+ if (mode === 0) {
50
+ if (c === "'") mode = 1;
51
+ else if (c === '"') mode = 2;
52
+ else if (c === '`') mode = 3;
53
+ else out += c;
54
+ } else if (mode === 1 && c === "'" && prev !== '\\') mode = 0;
55
+ else if (mode === 2 && c === '"' && prev !== '\\') mode = 0;
56
+ else if (mode === 3 && c === '`' && prev !== '\\') mode = 0;
57
+ }
58
+ return out;
59
+ }
60
+
61
+ /**
62
+ * @param {string} file
63
+ * @param {string} content
64
+ * @returns {Finding[]}
65
+ */
66
+ function lintCivetFile(file, content) {
67
+ /** @type {Finding[]} */
68
+ const out = [];
69
+ const lines = content.split('\n');
70
+
71
+ for (let i = 0; i < lines.length; i++) {
72
+ const ln = lines[i];
73
+ const lineNo = i + 1;
74
+ const trimmed = ln.trim();
75
+ if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('#')) continue;
76
+
77
+ const codeOnly = stripStrings(ln);
78
+
79
+ // `isnt` compiles to an undefined identifier reference at runtime.
80
+ if (/\bisnt\b/.test(codeOnly)) {
81
+ out.push({
82
+ file,
83
+ line: lineNo,
84
+ severity: 'error',
85
+ rule: 'civet/no-isnt',
86
+ msg: '`isnt` compiles to a bare identifier reference. Use `!==`.'
87
+ });
88
+ }
89
+
90
+ // `async *name(` as class method shorthand — Civet parser rejects it.
91
+ // Match indented lines (likely inside a class) where the next token after
92
+ // `async *` is an identifier followed by `(`.
93
+ if (/^\s+async\s*\*\s*\w+\s*\(/.test(ln)) {
94
+ out.push({
95
+ file,
96
+ line: lineNo,
97
+ severity: 'error',
98
+ rule: 'civet/no-async-generator-method',
99
+ msg: 'Civet rejects `async *name()` as class method shorthand. Use a callback API or top-level `async function*`.'
100
+ });
101
+ }
102
+
103
+ // `:= $state` (const) then later reassignment of the same name.
104
+ const stateConst = codeOnly.match(/(?:^|[\s,({[])(\w+)\s*:=\s*\$state\b/);
105
+ if (stateConst) {
106
+ const name = stateConst[1];
107
+ const reassignRe = new RegExp(`^\\s*${name}\\s*=(?!=|>)`);
108
+ for (let j = i + 1; j < lines.length; j++) {
109
+ if (reassignRe.test(lines[j])) {
110
+ out.push({
111
+ file,
112
+ line: lineNo,
113
+ severity: 'error',
114
+ rule: 'civet/state-const-reassign',
115
+ msg: `\`${name}\` uses \`:=\` ($state const) but is reassigned at line ${j + 1}. Use \`.=\` for $state values you reassign.`
116
+ });
117
+ break;
118
+ }
119
+ }
120
+ }
121
+
122
+ }
123
+
124
+ return out;
125
+ }
126
+
127
+ /**
128
+ * @param {string} file
129
+ * @param {string} content
130
+ * @returns {Finding[]}
131
+ */
132
+ function lintNornFile(file, content) {
133
+ /** @type {Finding[]} */
134
+ const out = [];
135
+
136
+ // Identify <script> / <style> ranges so we lint only template lines.
137
+ const blockRanges = [];
138
+ const blockRe = /<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi;
139
+ let m;
140
+ while ((m = blockRe.exec(content)) !== null) {
141
+ blockRanges.push([m.index, m.index + m[0].length]);
142
+ }
143
+
144
+ // Map line index → starting offset
145
+ const lineStart = [0];
146
+ for (let i = 0; i < content.length; i++) {
147
+ if (content[i] === '\n') lineStart.push(i + 1);
148
+ }
149
+ const inBlock = (lineNo) => {
150
+ const s = lineStart[lineNo - 1];
151
+ return blockRanges.some(([a, b]) => s >= a && s < b);
152
+ };
153
+
154
+ const lines = content.split('\n');
155
+ for (let i = 0; i < lines.length; i++) {
156
+ const ln = lines[i];
157
+ const lineNo = i + 1;
158
+ if (inBlock(lineNo)) continue;
159
+ const trimmed = ln.trim();
160
+ if (!trimmed || trimmed.startsWith('//')) continue;
161
+
162
+ // `{@html ...}` / `{#each}` etc. at start of pug line without `| ` prefix.
163
+ if (/^\s*\{[@#:/]/.test(ln)) {
164
+ out.push({
165
+ file,
166
+ line: lineNo,
167
+ severity: 'error',
168
+ rule: 'pug/svelte-block-needs-pipe',
169
+ msg: 'Leading `{` is parsed by Pug as a tag. Prefix with `| ` to emit as text.'
170
+ });
171
+ }
172
+
173
+ // `#{expr}` Pug interpolation — evaluates at preprocess time, not runtime.
174
+ // Allow `\#{` escaped form.
175
+ if (/(^|[^\\])#\{/.test(ln)) {
176
+ out.push({
177
+ file,
178
+ line: lineNo,
179
+ severity: 'error',
180
+ rule: 'pug/no-pug-interpolation',
181
+ msg: 'Pug `#{expr}` evaluates at preprocess time. Use Svelte `{expr}` for runtime data.'
182
+ });
183
+ }
184
+
185
+ }
186
+
187
+ return out;
188
+ }
189
+
190
+ /**
191
+ * @param {string} file
192
+ * @param {string} content
193
+ * @returns {Finding[]}
194
+ */
195
+ function lintViteConfig(file, content) {
196
+ /** @type {Finding[]} */
197
+ const out = [];
198
+ if (!/allowedHosts\s*:\s*(true|\[)/.test(content)) {
199
+ out.push({
200
+ file,
201
+ line: 1,
202
+ severity: 'warning',
203
+ rule: 'vite/allowed-hosts',
204
+ msg: 'Set `server.allowedHosts: true` (or an explicit list) so Vite accepts reverse-proxied Host headers in dev.'
205
+ });
206
+ }
207
+ return out;
208
+ }
209
+
210
+ /**
211
+ * @param {string} cwd
212
+ * @returns {Finding[]}
213
+ */
214
+ export function nornsLint(cwd) {
215
+ /** @type {Finding[]} */
216
+ const findings = [];
217
+
218
+ const srcDir = existsSync(join(cwd, 'src')) ? join(cwd, 'src') : cwd;
219
+ const civetFiles = walk(
220
+ srcDir,
221
+ (n) => n.endsWith('.c') || n.endsWith('.civet')
222
+ );
223
+ const nornFiles = walk(srcDir, (n) => n.endsWith('.n'));
224
+
225
+ for (const f of civetFiles) {
226
+ try {
227
+ findings.push(...lintCivetFile(f, readFileSync(f, 'utf8')));
228
+ } catch (e) {
229
+ findings.push({
230
+ file: f,
231
+ line: 1,
232
+ severity: 'warning',
233
+ rule: 'lint/read-error',
234
+ msg: `Could not read: ${e.message}`
235
+ });
236
+ }
237
+ }
238
+ for (const f of nornFiles) {
239
+ try {
240
+ findings.push(...lintNornFile(f, readFileSync(f, 'utf8')));
241
+ } catch (e) {
242
+ findings.push({
243
+ file: f,
244
+ line: 1,
245
+ severity: 'warning',
246
+ rule: 'lint/read-error',
247
+ msg: `Could not read: ${e.message}`
248
+ });
249
+ }
250
+ }
251
+
252
+ const viteCfg = ['vite.config.js', 'vite.config.ts', 'vite.config.mjs']
253
+ .map((n) => join(cwd, n))
254
+ .find(existsSync);
255
+ if (viteCfg) {
256
+ findings.push(...lintViteConfig(viteCfg, readFileSync(viteCfg, 'utf8')));
257
+ }
258
+
259
+ return findings.map((f) => ({ ...f, file: relative(cwd, f.file) }));
260
+ }
261
+
262
+ /**
263
+ * Pretty-print findings. Returns the number of errors.
264
+ * @param {Finding[]} findings
265
+ * @returns {{ errors: number; warnings: number }}
266
+ */
267
+ export function printFindings(findings) {
268
+ let errors = 0;
269
+ let warnings = 0;
270
+ if (findings.length === 0) {
271
+ console.log('norns lint: no issues found.');
272
+ return { errors: 0, warnings: 0 };
273
+ }
274
+ // Group by file for readability.
275
+ /** @type {Map<string, Finding[]>} */
276
+ const byFile = new Map();
277
+ for (const f of findings) {
278
+ if (!byFile.has(f.file)) byFile.set(f.file, []);
279
+ byFile.get(f.file).push(f);
280
+ }
281
+ for (const [file, items] of byFile) {
282
+ console.log(`\n${file}`);
283
+ for (const it of items) {
284
+ const tag = it.severity === 'error' ? 'error' : 'warn ';
285
+ if (it.severity === 'error') errors++;
286
+ else warnings++;
287
+ console.log(` ${it.line.toString().padStart(4)} ${tag} ${it.rule} ${it.msg}`);
288
+ }
289
+ }
290
+ console.log(
291
+ `\nnorns lint: ${errors} error(s), ${warnings} warning(s) across ${byFile.size} file(s).`
292
+ );
293
+ return { errors, warnings };
294
+ }
@@ -2,7 +2,7 @@
2
2
  * Validation glue. Norns doesn't bundle a schema library — it speaks the
3
3
  * Standard Schema interface (https://github.com/standard-schema/standard-schema)
4
4
  * supported by Valibot, Zod 3.24+, ArkType, etc. A plain function (`input -> parsed`)
5
- * also works, for ad-hoc cases or simple Coffee parsers.
5
+ * also works, for ad-hoc cases or simple hand-rolled parsers.
6
6
  */
7
7
 
8
8
  /** @typedef {{ '~standard': { validate: (input: unknown) => any } }} StandardSchema */
package/src/vite.js CHANGED
@@ -68,7 +68,6 @@ async function resolveWorkspaceFrameworkSrcs(root) {
68
68
  * Node's ESM module cache survives `server.restart()`.
69
69
  *
70
70
  * `.c` is recognised as an alias for `.civet` — both compile through Civet.
71
- * CoffeeScript is no longer supported.
72
71
  *
73
72
  * @returns {import('vite').Plugin}
74
73
  */