@human-synthesis/norns-core 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 CHANGED
@@ -2,13 +2,13 @@
2
2
 
3
3
  **AI-driven software architecture and development framework, based on Svelte.**
4
4
 
5
- Svelte preprocessor for the Norns stack: Pug + CoffeeScript in `.n` files, with the small set of fixes needed to make Svelte 5 runes feel native in Coffee.
5
+ Svelte preprocessor for the Norns stack: **Pug + Civet** in `.n` files. The `.c` extension is recognised as an alias for `.civet` both compile through Civet.
6
6
 
7
7
  ## Stack
8
8
 
9
9
  - [Svelte 5](https://svelte.dev) — components and runes
10
10
  - [Pug](https://pugjs.org) — templates
11
- - [CoffeeScript 2](https://coffeescript.org) — script
11
+ - [Civet](https://civet.dev) — script (TypeScript-flavored, indented)
12
12
  - [Vite](https://vitejs.dev) — bundler
13
13
  - [bun](https://bun.sh) — runtime / package manager
14
14
 
@@ -18,7 +18,7 @@ Svelte preprocessor for the Norns stack: Pug + CoffeeScript in `.n` files, with
18
18
  bun add -D @human-synthesis/norns-core svelte
19
19
  ```
20
20
 
21
- Most users want the umbrella package [`@human-synthesis/norns`](https://github.com/human-synthesis/norns) instead.
21
+ Most users want the umbrella package [`@human-synthesis/norns`](https://github.com/human-synthesis/norns) instead — it adds the SvelteKit config, the Vite plugin, and the runtime layer.
22
22
 
23
23
  ## Usage
24
24
 
@@ -33,6 +33,18 @@ export default {
33
33
  };
34
34
  ```
35
35
 
36
+ ## What it does
37
+
38
+ - `.n` files default `<script>` to `lang="civet"` and `<template>` to `lang="pug"` — write neither attribute and it just works.
39
+ - `<script lang="civet">` blocks are compiled to JavaScript via [@danielx/civet](https://civet.dev) before svelte-preprocess sees them.
40
+ - Top-level Pug-only content is auto-wrapped in `<template lang="pug">` so you don't need the wrapper boilerplate.
41
+ - Pug class shorthand is rewritten so Tailwind variants (`.hover:bg-X`) and fractional values (`.gap-2.5`) work without escaping.
42
+ - `+if` / `+elseif` / `+else` chains are rewritten to Svelte block syntax (`{#if}/{:else if}/{:else}/{/if}`).
43
+
44
+ ## Auto-imports — see the umbrella
45
+
46
+ The auto-import layer (helpers, components, project utilities, UI library presets) lives in [`@human-synthesis/norns`](https://github.com/human-synthesis/norns), not here. `norns-core` is the syntax preprocessor only — the import resolver needs Vite-plugin hooks the umbrella provides.
47
+
36
48
  ## License
37
49
 
38
50
  MIT © Daniel Teodoroiu / [Human Synthesis](https://humansynthesis.ai). Built on top of [Svelte](https://github.com/sveltejs/svelte) © Svelte Contributors, MIT licensed.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@human-synthesis/norns-core",
3
- "version": "0.0.6",
4
- "description": "Norns core — Svelte preprocessor for CoffeeScript, Pug, and .n files",
3
+ "version": "0.0.8",
4
+ "description": "Norns core — Svelte preprocessor for Civet, Pug, and .n files",
5
5
  "license": "MIT",
6
6
  "author": "Daniel Teodoroiu (https://humansynthesis.ai)",
7
7
  "type": "module",
@@ -25,9 +25,7 @@
25
25
  "svelte": "^5.0.0"
26
26
  },
27
27
  "dependencies": {
28
- "acorn": "^8.14.0",
29
- "coffeescript": "^2.7.0",
30
- "magic-string": "^0.30.10",
28
+ "@danielx/civet": "^0.11.0",
31
29
  "pug": "^3.0.3",
32
30
  "svelte-preprocess": "^6.0.3"
33
31
  },
package/src/index.js CHANGED
@@ -1 +1 @@
1
- export { nornsPreprocess, fuseRuneDeclarations } from './preprocess.js';
1
+ export { nornsPreprocess } from './preprocess.js';
package/src/preprocess.js CHANGED
@@ -1,197 +1,8 @@
1
1
  import { sveltePreprocess } from 'svelte-preprocess';
2
- import { parse } from 'acorn';
3
- import MagicString from 'magic-string';
4
-
5
- const RUNES = new Set([
6
- '$state',
7
- '$state.raw',
8
- '$derived',
9
- '$derived.by',
10
- '$effect',
11
- '$effect.pre',
12
- '$effect.root',
13
- '$props',
14
- '$bindable'
15
- ]);
16
-
17
- function isRuneCall(node) {
18
- if (!node || node.type !== 'CallExpression') return false;
19
- const c = node.callee;
20
- if (c.type === 'Identifier') return RUNES.has(c.name);
21
- if (c.type === 'MemberExpression' && c.object.type === 'Identifier') {
22
- return RUNES.has(`${c.object.name}.${c.property.name}`);
23
- }
24
- return false;
25
- }
2
+ import { compile as compileCivet } from '@danielx/civet';
26
3
 
27
- /**
28
- * Fuse `var X; X = expr` patterns (CoffeeScript output) into `let X = expr`,
29
- * so Svelte 5 accepts runes in declaration position and doesn't warn about
30
- * non-state variables being "updated".
31
- *
32
- * Also handles `var X; ({X, ...} = $props())` (destructured props).
33
- *
34
- * Walks function bodies recursively so closures get the same treatment.
35
- */
36
- export { transformIfChains, rewritePugClasses };
37
-
38
- export function fuseRuneDeclarations(code) {
39
- let ast;
40
- try {
41
- ast = parse(code, {
42
- ecmaVersion: 'latest',
43
- sourceType: 'module',
44
- allowReturnOutsideFunction: true,
45
- allowAwaitOutsideFunction: true
46
- });
47
- } catch {
48
- return code;
49
- }
4
+ export { transformIfChains, transformSnippets, rewritePugClasses };
50
5
 
51
- const s = new MagicString(code);
52
- walkBody(ast.body);
53
-
54
- function walkBody(body) {
55
- const fused = new Set();
56
- const varStmts = [];
57
- const claimed = new Set();
58
-
59
- for (let i = 0; i < body.length; i++) {
60
- const stmt = body[i];
61
-
62
- if (stmt.type === 'VariableDeclaration' && stmt.kind === 'var') {
63
- varStmts.push(stmt);
64
- continue;
65
- }
66
- if (stmt.type !== 'ExpressionStatement') continue;
67
- const e = stmt.expression;
68
-
69
- // Pattern: X = expr (where X is var-declared earlier in this scope).
70
- // We fuse to `let X = expr` for any expression. Coffee always emits
71
- // `var X; X = ...` for top-level assignments, and Svelte 5 reports
72
- // `non_reactive_update` warnings for any `var` that's reassigned —
73
- // even if the user only wrote a single function declaration. We
74
- // limit to TOP LEVEL only (no recursion into nested function bodies)
75
- // to avoid MagicString chunk conflicts.
76
- //
77
- // Special-case: `$derived(IIFE())` — Coffee compiles `$derived do ->`
78
- // to `$derived((function(){...})())`, which evaluates ONCE at
79
- // definition time instead of reactively. Rewrite to `$derived.by(fn)`.
80
- if (
81
- e.type === 'AssignmentExpression' &&
82
- e.operator === '=' &&
83
- e.left.type === 'Identifier' &&
84
- isVarDeclared(varStmts, e.left.name) &&
85
- !claimed.has(e.left.name)
86
- ) {
87
- claimed.add(e.left.name);
88
- fused.add(e.left.name);
89
-
90
- const rewrite = rewriteDerivedIIFE(e.right, code);
91
- if (rewrite) {
92
- s.overwrite(
93
- stmt.start,
94
- stmt.end,
95
- `let ${e.left.name} = $derived.by(${rewrite});`
96
- );
97
- } else {
98
- s.appendLeft(stmt.start, 'let ');
99
- }
100
- continue;
101
- }
102
-
103
- // Pattern: ({X, Y} = $props()) → let {X, Y} = $props()
104
- if (
105
- e.type === 'AssignmentExpression' &&
106
- e.operator === '=' &&
107
- e.left.type === 'ObjectPattern' &&
108
- isRuneCall(e.right)
109
- ) {
110
- const names = collectPatternNames(e.left);
111
- if (names.every((n) => isVarDeclared(varStmts, n) && !claimed.has(n))) {
112
- for (const n of names) {
113
- claimed.add(n);
114
- fused.add(n);
115
- }
116
- const lhs = code.slice(e.left.start, e.left.end);
117
- const rhs = code.slice(e.right.start, e.right.end);
118
- s.overwrite(stmt.start, stmt.end, `let ${lhs} = ${rhs};`);
119
- }
120
- }
121
- }
122
-
123
- for (const stmt of varStmts) {
124
- const remaining = stmt.declarations.filter(
125
- (d) => !(d.id.type === 'Identifier' && fused.has(d.id.name))
126
- );
127
- if (remaining.length === 0) {
128
- s.remove(stmt.start, stmt.end);
129
- } else if (remaining.length < stmt.declarations.length) {
130
- const rebuilt = remaining
131
- .map((d) =>
132
- d.init ? `${d.id.name} = ${code.slice(d.init.start, d.init.end)}` : d.id.name
133
- )
134
- .join(', ');
135
- s.overwrite(stmt.start, stmt.end, `var ${rebuilt};`);
136
- }
137
- }
138
-
139
- // Note: we deliberately do NOT recurse into nested function bodies.
140
- // Svelte runes ($state, $derived, etc.) only apply at component
141
- // top-level. Inner function bodies (event handlers, callbacks) have
142
- // `var X; X = ...` patterns from CoffeeScript output too, but they
143
- // don't trigger Svelte warnings and don't need fusion. Recursing
144
- // also creates MagicString chunk conflicts when an outer assignment
145
- // is being modified at the same time as something inside its body.
146
- }
147
-
148
- function isVarDeclared(varStmts, name) {
149
- for (const stmt of varStmts) {
150
- for (const d of stmt.declarations) {
151
- if (d.id.type === 'Identifier' && d.id.name === name && !d.init) {
152
- return true;
153
- }
154
- }
155
- }
156
- return false;
157
- }
158
-
159
- /**
160
- * If `node` is `$derived((function(){...})())` (the result of Coffee's
161
- * `$derived do ->`), return the source text of the inner FunctionExpression
162
- * so the caller can rewrite to `$derived.by(<fn>)`. Otherwise null.
163
- */
164
- function rewriteDerivedIIFE(node, src) {
165
- if (!node || node.type !== 'CallExpression') return null;
166
- if (node.callee.type !== 'Identifier' || node.callee.name !== '$derived') return null;
167
- if (node.arguments.length !== 1) return null;
168
- const arg = node.arguments[0];
169
- if (arg.type !== 'CallExpression') return null;
170
- if (arg.arguments.length !== 0) return null;
171
- const fn = arg.callee;
172
- if (fn.type !== 'FunctionExpression' && fn.type !== 'ArrowFunctionExpression') return null;
173
- return src.slice(fn.start, fn.end);
174
- }
175
-
176
- function collectPatternNames(pat) {
177
- const names = [];
178
- if (pat.type === 'ObjectPattern') {
179
- for (const prop of pat.properties) {
180
- if (prop.type === 'Property') {
181
- let v = prop.value;
182
- // Unwrap default value: `{ x = 1 }` has Property → AssignmentPattern → Identifier
183
- if (v.type === 'AssignmentPattern') v = v.left;
184
- if (v.type === 'Identifier') names.push(v.name);
185
- } else if (prop.type === 'RestElement' && prop.argument.type === 'Identifier') {
186
- names.push(prop.argument.name);
187
- }
188
- }
189
- }
190
- return names;
191
- }
192
-
193
- return s.toString();
194
- }
195
6
 
196
7
  const SCRIPT_TAG = /<script\b([^>]*)>/i;
197
8
  const TEMPLATE_TAG = /<template\b([^>]*)>/i;
@@ -201,7 +12,7 @@ function hasLangAttr(attrs) {
201
12
  }
202
13
 
203
14
  /**
204
- * For .norn files: inject lang="coffee" / lang="pug" defaults on
15
+ * For .norn files: inject lang="civet" / lang="pug" defaults on
205
16
  * <script> and <template> blocks, and auto-wrap any top-level non-script /
206
17
  * non-style content in <template lang="pug">.
207
18
  */
@@ -226,6 +37,12 @@ const IF_RE = /^(\s*)\+if\s*\((.+)\)\s*$/;
226
37
  const ELSEIF_RE_TPL = (ind) => new RegExp(`^${escapeRegex(ind)}\\+elseif\\s*\\((.+)\\)\\s*$`);
227
38
  const ELSE_RE_TPL = (ind) => new RegExp(`^${escapeRegex(ind)}\\+else\\s*$`);
228
39
 
40
+ // `+snippet('name')` or `+snippet('name', arg1, arg2)`. Lazy match with `$`
41
+ // anchor lets the args list contain parens (e.g. `+snippet('row', fn(a))`)
42
+ // because the engine extends the lazy capture only until the outer `)` lands
43
+ // at end-of-line.
44
+ const SNIPPET_RE = /^(\s*)\+snippet\s*\(\s*['"](\w+)['"](?:\s*,\s*([\s\S]+?))?\s*\)\s*$/;
45
+
229
46
  function escapeRegex(s) {
230
47
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
231
48
  }
@@ -492,6 +309,78 @@ function transformIfChains(content) {
492
309
  return out.join('\n');
493
310
  }
494
311
 
312
+ /**
313
+ * Rewrite Pug `+snippet('name', args…)` blocks to Svelte 5 `{#snippet name(args)}`
314
+ * via Pug `|` text emit. Recurses into the body so nested snippets work
315
+ * (`Tabs > +snippet('item', tab) > Card > +snippet('header')`).
316
+ *
317
+ * Input:
318
+ * +snippet('header')
319
+ * h2 Title
320
+ *
321
+ * +snippet('row', user, idx)
322
+ * .row Hello {user.name} {idx}
323
+ *
324
+ * Output:
325
+ * | {#snippet header()}
326
+ * h2 Title
327
+ * | {/snippet}
328
+ *
329
+ * | {#snippet row(user, idx)}
330
+ * .row Hello {user.name} {idx}
331
+ * | {/snippet}
332
+ */
333
+ function transformSnippets(content) {
334
+ const lines = content.split('\n');
335
+ const out = [];
336
+ let i = 0;
337
+
338
+ while (i < lines.length) {
339
+ const m = lines[i].match(SNIPPET_RE);
340
+ if (!m) {
341
+ out.push(lines[i]);
342
+ i++;
343
+ continue;
344
+ }
345
+
346
+ const indent = m[1];
347
+ const name = m[2];
348
+ const args = m[3] ? m[3].trim() : '';
349
+ const indentDiff = detectIndentDiff(lines, i + 1, indent);
350
+
351
+ out.push(`${indent}| {#snippet ${name}(${args})}`);
352
+ i++;
353
+
354
+ // Collect body lines (more indented than the +snippet header) and
355
+ // process them recursively so nested +snippet blocks resolve.
356
+ /** @type {string[]} */
357
+ const body = [];
358
+ while (i < lines.length) {
359
+ const cur = lines[i];
360
+
361
+ if (cur.trim() === '') {
362
+ body.push(cur);
363
+ i++;
364
+ continue;
365
+ }
366
+
367
+ const lineIndent = cur.match(/^(\s*)/)[1];
368
+ if (lineIndent.length > indent.length) {
369
+ body.push(cur.startsWith(indentDiff) ? cur.slice(indentDiff.length) : cur);
370
+ i++;
371
+ continue;
372
+ }
373
+
374
+ break;
375
+ }
376
+
377
+ if (body.length > 0) out.push(transformSnippets(body.join('\n')));
378
+ out.push(`${indent}| {/snippet}`);
379
+ }
380
+
381
+ return out.join('\n');
382
+ }
383
+
495
384
  function nornDefaultLangs() {
496
385
  return {
497
386
  name: 'norns-default-langs',
@@ -500,6 +389,7 @@ function nornDefaultLangs() {
500
389
 
501
390
  let out = autoCloseTrailingBlock(content);
502
391
  out = transformIfChains(out);
392
+ out = transformSnippets(out);
503
393
  out = rewritePugClasses(out);
504
394
 
505
395
  // If no <template> exists, scan for script/style blocks and wrap the rest.
@@ -527,9 +417,9 @@ function nornDefaultLangs() {
527
417
  }
528
418
  }
529
419
 
530
- // Inject lang="coffee" on <script> tags missing lang=
420
+ // Inject lang="civet" on <script> tags missing lang=
531
421
  out = out.replace(SCRIPT_TAG, (full, attrs) =>
532
- hasLangAttr(attrs) ? full : `<script lang="coffee"${attrs}>`
422
+ hasLangAttr(attrs) ? full : `<script lang="civet"${attrs}>`
533
423
  );
534
424
 
535
425
  // Inject lang="pug" on <template> tags missing lang=
@@ -542,88 +432,61 @@ function nornDefaultLangs() {
542
432
  };
543
433
  }
544
434
 
545
- function nornCoffeeRuneFusion() {
546
- return {
547
- name: 'norns-coffee-rune-fusion',
548
- script({ content, attributes }) {
549
- if (attributes.lang !== 'coffee' && attributes.lang !== 'coffeescript') return null;
550
- const code = fuseRuneDeclarations(content);
551
- return code === content ? null : { code };
552
- }
553
- };
554
- }
555
-
556
435
  /**
557
- * Lift `import` statements to the top of a JS module.
436
+ * Compile `<script lang="civet">` blocks to JavaScript via Civet.
558
437
  *
559
- * CoffeeScript 2 hoists all variables to a single `var x, y, z;` line at the
560
- * very top of the file, then emits `import` statements *after* it. JS modules
561
- * require imports above all other top-level statements, and svelte-preprocess
562
- * + Svelte's MagicString-based pipeline don't handle this gracefully when
563
- * there are also nested var/assignment patterns inside script function bodies.
438
+ * Runs before svelte-preprocess so that downstream stages see plain JS.
439
+ * Civet emits source maps; we forward them so devtools can resolve back to
440
+ * the original `.civet` source.
564
441
  *
565
- * This pass parses the JS, finds all `ImportDeclaration` nodes, and rewrites
566
- * them in source order at the very top of the module.
442
+ * Civet's emit characteristics (verified May 2026, civet@0.11):
443
+ * - `count .= $state 0` → `let count = $state(0)`
444
+ * - `count := $state 0` → `const count = $state(0)`
445
+ * - `{ a, b = 0 } := $props()` → `const { a, b = 0 } = $props()`
446
+ * - imports stay where written; if user writes them at top, output is fine
567
447
  */
568
- export function liftImports(code) {
569
- let ast;
570
- try {
571
- ast = parse(code, {
572
- ecmaVersion: 'latest',
573
- sourceType: 'module',
574
- allowReturnOutsideFunction: true,
575
- allowAwaitOutsideFunction: true
576
- });
577
- } catch {
578
- return code;
579
- }
580
-
581
- const imports = ast.body.filter((s) => s.type === 'ImportDeclaration');
582
- if (imports.length === 0) return code;
583
-
584
- const firstNonImportIdx = ast.body.findIndex((s) => s.type !== 'ImportDeclaration');
585
- if (firstNonImportIdx === -1) return code; // already all imports
586
-
587
- const lateImports = imports.filter((i) => ast.body.indexOf(i) > firstNonImportIdx);
588
- if (lateImports.length === 0) return code; // already in the right order
589
-
590
- const s = new MagicString(code);
591
- const importTexts = imports.map((i) => code.slice(i.start, i.end));
592
- for (const i of imports) {
593
- s.remove(i.start, i.end);
594
- }
595
- s.prependLeft(0, importTexts.join('\n') + '\n');
596
- return s.toString();
597
- }
598
-
599
- function nornsCoffeeImportLift() {
448
+ function nornsCivetScript() {
600
449
  return {
601
- name: 'norns-coffee-import-lift',
602
- script({ content, attributes }) {
603
- if (attributes.lang !== 'coffee' && attributes.lang !== 'coffeescript') return null;
604
- const code = liftImports(content);
605
- return code === content ? null : { code };
450
+ name: 'norns-civet-script',
451
+ async script({ content, attributes, filename }) {
452
+ if (attributes.lang !== 'civet' && attributes.lang !== 'cv') return null;
453
+ const result = await compileCivet(content, {
454
+ js: true,
455
+ sourceMap: true,
456
+ filename: filename ?? 'unknown'
457
+ });
458
+ // Drop the `lang` attribute so svelte-preprocess doesn't try to load
459
+ // a `./transformers/civet` module — at this point the script body is
460
+ // already plain JS, no further script-level transform needed.
461
+ const { lang: _drop, ...nextAttrs } = attributes;
462
+ return {
463
+ code: result.code,
464
+ map: result.sourceMap?.json?.(filename ?? 'unknown') ?? null,
465
+ attributes: nextAttrs
466
+ };
606
467
  }
607
468
  };
608
469
  }
609
470
 
471
+
610
472
  /**
611
473
  * Norns preprocessor stack.
612
474
  *
613
- * - `.norn` files default `<script>` to CoffeeScript and `<template>` to Pug
614
- * (and auto-wrap top-level content in `<template lang="pug">` if no
615
- * template block is present).
616
- * - CoffeeScript output is post-processed: `var X; X = expr` patterns become
617
- * `let X = expr` so Svelte 5 runes work without backtick-embedded JS and
618
- * normal variables/functions don't trigger non-reactive warnings.
475
+ * - `.norn` files default `<script>` to Civet and `<template>` to Pug (and
476
+ * auto-wrap top-level content in `<template lang="pug">` if no template
477
+ * block is present).
478
+ * - `<script lang="civet">` blocks are compiled to JS via @danielx/civet
479
+ * before svelte-preprocess sees them. Civet emits ESM-correct
480
+ * `let count = $state(0)` directly, so no rune-fusion or import-lift
481
+ * passes are needed.
619
482
  *
620
483
  * @param {import('svelte-preprocess').AutoPreprocessOptions} [options]
621
484
  */
622
485
  export function nornsPreprocess(options = {}) {
623
486
  return [
624
487
  nornDefaultLangs(),
488
+ nornsCivetScript(),
625
489
  sveltePreprocess({
626
- coffeescript: { bare: true },
627
490
  pug: {},
628
491
  typescript: {
629
492
  compilerOptions: {
@@ -639,8 +502,6 @@ export function nornsPreprocess(options = {}) {
639
502
  }
640
503
  },
641
504
  ...options
642
- }),
643
- nornsCoffeeImportLift(),
644
- nornCoffeeRuneFusion()
505
+ })
645
506
  ];
646
507
  }