@human-synthesis/norns 0.0.8 → 0.0.10

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/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.8",
3
+ "version": "0.0.10",
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
  {
@@ -216,39 +226,158 @@ function extractExports(source) {
216
226
  // auto-import a random route's load function. Excluded by basename.
217
227
  const ROUTE_FILE_RE = /^(\+|hooks\.)/;
218
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
+
219
304
  /**
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.
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.
225
311
  *
226
312
  * @param {string} root
227
- * @param {string[]} dirs
228
- * @param {string[]} exts
229
- * @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 }>>}
230
316
  */
231
- function buildExportMap(root, dirs, exts) {
232
- /** @type {Map<string, string>} */
317
+ function buildExportMap(root, globs, exts) {
318
+ /** @type {Map<string, Array<{ file: string; isServer: boolean }>>} */
233
319
  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);
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);
246
337
  }
338
+ list.push({ file, isServer });
247
339
  }
248
340
  }
249
341
  return map;
250
342
  }
251
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
+
252
381
  /**
253
382
  * Resolve the import specifier for a project-utility file. Same path logic
254
383
  * as `resolveComponentPath`, but strips the file extension so imports use
@@ -347,8 +476,8 @@ function collectDeclared(script) {
347
476
  * @param {string} [filename]
348
477
  * @param {{ root?: string, libRoot?: string, libAlias?: string }} [ctx]
349
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.
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' }>}
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 }>}
352
481
  */
353
482
  function computeImports(
354
483
  referenced,
@@ -404,16 +533,24 @@ function computeImports(
404
533
  }
405
534
  }
406
535
 
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`.
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.
411
543
  if (exports) {
412
- for (const [name, file] of exports) {
544
+ const importerIsServer = isServerPath(filename);
545
+ for (const [name, scoped] of exports) {
413
546
  if (!wants(name)) continue;
414
- 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);
415
552
  if (from) {
416
- out.push({ name, from, kind: 'named' });
553
+ out.push({ name, from, kind: 'named', annotate: true });
417
554
  added.add(name);
418
555
  }
419
556
  }
@@ -423,27 +560,29 @@ function computeImports(
423
560
  }
424
561
 
425
562
  /**
426
- * @param {Array<{ name: string, from: string, kind: 'named' | 'default' }>} entries
563
+ * @param {Array<{ name: string, from: string, kind: 'named' | 'default', annotate?: boolean }>} entries
427
564
  * @returns {string}
428
565
  */
429
566
  function renderImports(entries) {
430
- /** @type {Map<string, { default: string | null, named: string[] }>} */
567
+ /** @type {Map<string, { default: string | null, named: string[], annotate: boolean }>} */
431
568
  const byFrom = new Map();
432
- for (const { name, from, kind } of entries) {
569
+ for (const { name, from, kind, annotate } of entries) {
433
570
  let g = byFrom.get(from);
434
571
  if (!g) {
435
- g = { default: null, named: [] };
572
+ g = { default: null, named: [], annotate: false };
436
573
  byFrom.set(from, g);
437
574
  }
438
575
  if (kind === 'default') g.default = name;
439
576
  else g.named.push(name);
577
+ if (annotate) g.annotate = true;
440
578
  }
441
579
  const lines = [];
442
- for (const [from, { default: def, named }] of byFrom) {
580
+ for (const [from, { default: def, named, annotate }] of byFrom) {
443
581
  const parts = [];
444
582
  if (def) parts.push(def);
445
583
  if (named.length > 0) parts.push(`{ ${named.join(', ')} }`);
446
- lines.push(`import ${parts.join(', ')} from '${from}';`);
584
+ const stmt = `import ${parts.join(', ')} from '${from}';`;
585
+ lines.push(annotate ? `${stmt} // auto-import` : stmt);
447
586
  }
448
587
  return lines.join('\n');
449
588
  }
@@ -500,19 +639,36 @@ function renderImports(entries) {
500
639
  * the library's `Btn` silently (first-match-wins). The string is used as
501
640
  * the import source verbatim — no `$lib` aliasing or relative-path
502
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).
503
656
  * @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`.
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.
509
662
  * @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.
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.
513
666
  * @param {string} [options.libRoot] Default `'src/lib'`.
514
667
  * @param {string} [options.libAlias] Default `'$lib'`.
515
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.
516
672
  */
517
673
  export function nornsAutoImport(options = {}) {
518
674
  const root = options.root ?? process.cwd();
@@ -520,19 +676,37 @@ export function nornsAutoImport(options = {}) {
520
676
  const componentDirs =
521
677
  options.componentDirs === false ? [] : (options.componentDirs ?? DEFAULT_COMPONENT_DIRS);
522
678
  const componentExts = options.componentExtensions ?? DEFAULT_COMPONENT_EXTS;
523
- const exportDirs =
524
- options.exportDirs === false || options.exportDirs == null ? [] : options.exportDirs;
525
679
  const exportExts = options.exportExtensions ?? DEFAULT_EXPORT_EXTS;
526
680
  const libRoot = options.libRoot ?? DEFAULT_LIB_ROOT;
527
681
  const libAlias = options.libAlias ?? DEFAULT_LIB_ALIAS;
528
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
+ }
529
702
 
530
703
  const components =
531
704
  componentDirs.length === 0
532
705
  ? new Map()
533
706
  : buildComponentMap(root, componentDirs, componentExts);
534
- const exportsMap =
535
- 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;
536
710
  const componentCtx = { root, libRoot, libAlias };
537
711
 
538
712
  /** @type {Map<string, string>} */
@@ -642,7 +816,10 @@ export {
642
816
  buildExportMap as _buildExportMap,
643
817
  collectDeclared as _collectDeclared,
644
818
  collectIdentifiers as _collectIdentifiers,
819
+ compileGlob as _compileGlob,
645
820
  computeImports as _computeImports,
646
821
  extractExports as _extractExports,
647
- renderImports as _renderImports
822
+ isServerPath as _isServerPath,
823
+ renderImports as _renderImports,
824
+ resolveExportConflicts as _resolveExportConflicts
648
825
  };
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
+ }
package/src/vite.js CHANGED
@@ -1,4 +1,4 @@
1
- import { readFile, stat, realpath } from 'node:fs/promises';
1
+ import { readFile, stat, realpath, readdir, mkdir, writeFile } from 'node:fs/promises';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { createRequire } from 'node:module';
4
4
  import { compile as compileCivet } from '@danielx/civet';
@@ -140,3 +140,153 @@ export function nornsCivetPlugin() {
140
140
  }
141
141
  };
142
142
  }
143
+
144
+ /* === pugTailwindExtract ==================================================
145
+ *
146
+ * Tailwind v4's content extractor tokenizes candidates against a fixed
147
+ * non-class alphabet. Pug class-shorthand chains followed directly by an
148
+ * attribute paren — `.grid.gap-6(class="…")` — fail that tokenizer: the
149
+ * substring `.gap-6(` reads as one non-class token, so `gap-6` never
150
+ * reaches the candidate set and the CSS rule is never generated.
151
+ *
152
+ * Because the `tag.cls.cls(attrs)` form is idiomatic Pug, asking authors
153
+ * to either move every utility into `class="…"` or hand-maintain a
154
+ * safelist is a paper cut on every page they touch.
155
+ *
156
+ * This plugin walks every `.n` file under `root`, extracts each `.cls`
157
+ * segment via a permissive regex, and writes the deduplicated set into a
158
+ * single sidecar HTML file. Consumers reference the file from their CSS
159
+ * via `@source "./.tailwind-pug-classes.html";` so Tailwind picks it up
160
+ * like any other content source.
161
+ *
162
+ * The regex is permissive on purpose — it captures every `.candidate`
163
+ * segment in the source, including occasional false positives like
164
+ * `.svelte` inside template text. Those are free: Tailwind's own
165
+ * candidate-validation step drops anything that isn't a real utility, so
166
+ * the only cost is a few extra bytes in the sidecar.
167
+ *
168
+ * Runs with `enforce: 'pre'` so the scan sees the raw Pug source, not the
169
+ * Svelte output that the rest of the chain emits.
170
+ * ========================================================================
171
+ */
172
+
173
+ /**
174
+ * Match every `.candidate` segment. Class names may contain Tailwind's
175
+ * full alphabet — letters, digits, `-`, `_`, `:`, `/`, and arbitrary-value
176
+ * brackets `[...]`. Pug shorthand never contains a `.` inside a class
177
+ * (the dot is the delimiter), so `text-[1.5rem]`-style values never appear
178
+ * in shorthand — those always live inside `class="…"`, which Tailwind
179
+ * extracts directly.
180
+ */
181
+ const SEGMENT_RE = /\.([A-Za-z][\w\-:/]*(?:\[[^\]]*\])?)/g;
182
+
183
+ function extractPugClasses(source) {
184
+ const out = new Set();
185
+ let m;
186
+ SEGMENT_RE.lastIndex = 0;
187
+ while ((m = SEGMENT_RE.exec(source))) {
188
+ if (m[1]) out.add(m[1]);
189
+ }
190
+ return out;
191
+ }
192
+
193
+ async function walkNFiles(dir, ext, out = []) {
194
+ let entries;
195
+ try {
196
+ entries = await readdir(dir, { withFileTypes: true });
197
+ } catch {
198
+ return out;
199
+ }
200
+ for (const entry of entries) {
201
+ const full = join(dir, entry.name);
202
+ if (entry.isDirectory()) {
203
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
204
+ await walkNFiles(full, ext, out);
205
+ } else if (entry.isFile() && full.endsWith(ext)) {
206
+ out.push(full);
207
+ }
208
+ }
209
+ return out;
210
+ }
211
+
212
+ /**
213
+ * Vite plugin that extracts Tailwind class candidates from Pug shorthand
214
+ * in `.n` files and writes them to a sidecar file Tailwind can scan.
215
+ *
216
+ * @param {object} [options]
217
+ * @param {string} [options.root] Directory to scan (default `src`).
218
+ * @param {string} [options.ext] File extension (default `.n`).
219
+ * @param {string} [options.outFile] Sidecar path relative to root
220
+ * (default `.tailwind-pug-classes.html`).
221
+ * Reference it from your CSS with:
222
+ * @source "./.tailwind-pug-classes.html";
223
+ *
224
+ * @returns {import('vite').Plugin}
225
+ */
226
+ export function pugTailwindExtract({
227
+ root = 'src',
228
+ ext = '.n',
229
+ outFile = '.tailwind-pug-classes.html'
230
+ } = {}) {
231
+ let projectRoot = process.cwd();
232
+ const fileClasses = new Map(); // absolute path -> Set<string>
233
+
234
+ async function writeSidecar() {
235
+ const all = new Set();
236
+ for (const set of fileClasses.values()) {
237
+ for (const c of set) all.add(c);
238
+ }
239
+ const sorted = [...all].sort();
240
+ const html =
241
+ '<!-- AUTO-GENERATED by @human-synthesis/norns/vite pugTailwindExtract. Do not edit. -->\n' +
242
+ `<div class="${sorted.join(' ')}"></div>\n`;
243
+ const out = join(projectRoot, root, outFile);
244
+ await mkdir(dirname(out), { recursive: true });
245
+ await writeFile(out, html, 'utf8');
246
+ }
247
+
248
+ async function scanFile(abs) {
249
+ try {
250
+ const content = await readFile(abs, 'utf8');
251
+ fileClasses.set(abs, extractPugClasses(content));
252
+ } catch {
253
+ fileClasses.delete(abs);
254
+ }
255
+ }
256
+
257
+ async function scanAll() {
258
+ const dir = join(projectRoot, root);
259
+ const files = await walkNFiles(dir, ext);
260
+ await Promise.all(files.map(scanFile));
261
+ await writeSidecar();
262
+ }
263
+
264
+ return {
265
+ name: 'norns:pug-tailwind-extract',
266
+ // Run before the Civet/Pug transform so the scan sees raw shorthand.
267
+ enforce: 'pre',
268
+ configResolved(config) {
269
+ projectRoot = config.root || process.cwd();
270
+ },
271
+ async buildStart() {
272
+ await scanAll();
273
+ },
274
+ async handleHotUpdate({ file }) {
275
+ if (!file.endsWith(ext)) return;
276
+ await scanFile(file);
277
+ await writeSidecar();
278
+ },
279
+ configureServer(server) {
280
+ server.watcher.on('add', async (file) => {
281
+ if (!file.endsWith(ext)) return;
282
+ await scanFile(file);
283
+ await writeSidecar();
284
+ });
285
+ server.watcher.on('unlink', async (file) => {
286
+ if (!file.endsWith(ext)) return;
287
+ fileClasses.delete(file);
288
+ await writeSidecar();
289
+ });
290
+ }
291
+ };
292
+ }