@human-synthesis/norns-core 0.0.6 → 0.0.7

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. CoffeeScript is no longer supported.
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,14 @@ 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
+
36
44
  ## License
37
45
 
38
46
  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.7",
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
- }
26
-
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
- }
50
-
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
- }
2
+ import { compile as compileCivet } from '@danielx/civet';
147
3
 
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
- }
4
+ export { transformIfChains, transformSnippets, rewritePugClasses };
158
5
 
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,67 @@ 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.
437
+ *
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.
558
441
  *
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.
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
564
447
  *
565
- * This pass parses the JS, finds all `ImportDeclaration` nodes, and rewrites
566
- * them in source order at the very top of the module.
448
+ * No `var X; X = expr` split, so `nornCoffeeRuneFusion` and
449
+ * `nornsCoffeeImportLift` aren't needed for Civet sources.
567
450
  */
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() {
451
+ function nornsCivetScript() {
600
452
  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 };
453
+ name: 'norns-civet-script',
454
+ async script({ content, attributes, filename }) {
455
+ if (attributes.lang !== 'civet' && attributes.lang !== 'cv') return null;
456
+ const result = await compileCivet(content, {
457
+ js: true,
458
+ sourceMap: true,
459
+ filename: filename ?? 'unknown'
460
+ });
461
+ // Drop the `lang` attribute so svelte-preprocess doesn't try to load
462
+ // a `./transformers/civet` module — at this point the script body is
463
+ // already plain JS, no further script-level transform needed.
464
+ const { lang: _drop, ...nextAttrs } = attributes;
465
+ return {
466
+ code: result.code,
467
+ map: result.sourceMap?.json?.(filename ?? 'unknown') ?? null,
468
+ attributes: nextAttrs
469
+ };
606
470
  }
607
471
  };
608
472
  }
609
473
 
474
+
610
475
  /**
611
476
  * Norns preprocessor stack.
612
477
  *
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.
478
+ * - `.norn` files default `<script>` to Civet and `<template>` to Pug (and
479
+ * auto-wrap top-level content in `<template lang="pug">` if no template
480
+ * block is present).
481
+ * - `<script lang="civet">` blocks are compiled to JS via @danielx/civet
482
+ * before svelte-preprocess sees them. Civet emits ESM-correct
483
+ * `let count = $state(0)` directly, so no rune-fusion or import-lift
484
+ * passes are needed.
485
+ *
486
+ * CoffeeScript is no longer supported — `lang="coffee"` will fail. Use
487
+ * `lang="civet"` (or omit lang and rely on the default).
619
488
  *
620
489
  * @param {import('svelte-preprocess').AutoPreprocessOptions} [options]
621
490
  */
622
491
  export function nornsPreprocess(options = {}) {
623
492
  return [
624
493
  nornDefaultLangs(),
494
+ nornsCivetScript(),
625
495
  sveltePreprocess({
626
- coffeescript: { bare: true },
627
496
  pug: {},
628
497
  typescript: {
629
498
  compilerOptions: {
@@ -639,8 +508,6 @@ export function nornsPreprocess(options = {}) {
639
508
  }
640
509
  },
641
510
  ...options
642
- }),
643
- nornsCoffeeImportLift(),
644
- nornCoffeeRuneFusion()
511
+ })
645
512
  ];
646
513
  }