@human-synthesis/norns-core 0.0.5 → 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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Human Synthesis
3
+ Copyright (c) 2026 Daniel Teodoroiu / Human Synthesis
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,13 +1,25 @@
1
- # @human-synthesis/norns-core
1
+ # Norns Core
2
2
 
3
- Svelte with CoffeeScript, Pug, and UnoCSS preconfigured.
3
+ **AI-driven software architecture and development framework, based on Svelte.**
4
+
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
+
7
+ ## Stack
8
+
9
+ - [Svelte 5](https://svelte.dev) — components and runes
10
+ - [Pug](https://pugjs.org) — templates
11
+ - [Civet](https://civet.dev) — script (TypeScript-flavored, indented)
12
+ - [Vite](https://vitejs.dev) — bundler
13
+ - [bun](https://bun.sh) — runtime / package manager
4
14
 
5
15
  ## Install
6
16
 
7
17
  ```sh
8
- pnpm add -D @human-synthesis/norns-core svelte unocss vite
18
+ bun add -D @human-synthesis/norns-core svelte
9
19
  ```
10
20
 
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
+
11
23
  ## Usage
12
24
 
13
25
  `svelte.config.js`:
@@ -16,47 +28,19 @@ pnpm add -D @human-synthesis/norns-core svelte unocss vite
16
28
  import { nornsPreprocess } from '@human-synthesis/norns-core/preprocess';
17
29
 
18
30
  export default {
31
+ extensions: ['.svelte', '.n'],
19
32
  preprocess: nornsPreprocess()
20
33
  };
21
34
  ```
22
35
 
23
- `vite.config.js`:
24
-
25
- ```js
26
- import { defineConfig } from 'vite';
27
- import { svelte } from '@sveltejs/vite-plugin-svelte';
28
- import { nornsUno } from '@human-synthesis/norns-core/uno';
29
-
30
- export default defineConfig({
31
- plugins: [nornsUno(), svelte()]
32
- });
33
- ```
34
-
35
- ## Writing components
36
-
37
- ```svelte
38
- <template lang="pug">
39
- h1.text-3xl.font-bold Hello {name}
40
- button(on:click="{increment}") count is {count}
41
- </template>
42
-
43
- <script lang="coffee">
44
- export let name = 'world'
45
- count = 0
46
- increment = -> count++
47
- </script>
48
- ```
49
-
50
- ## Why this exists
51
-
52
- Norns wraps Svelte with the language and styling defaults we like:
53
-
54
- - **CoffeeScript** in `<script lang="coffee">` blocks
55
- - **Pug** in `<template lang="pug">` blocks
56
- - **UnoCSS** with `presetUno`, `presetAttributify`, `presetIcons`, `presetTypography` enabled
36
+ ## What it does
57
37
 
58
- The wrapper is intentionally thin under the hood it's just `svelte-preprocess` and `unocss/vite` with reasonable defaults. Override anything by passing options.
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}`).
59
43
 
60
- ## License & attribution
44
+ ## License
61
45
 
62
- MIT © Human Synthesis. Built on top of [Svelte](https://github.com/sveltejs/svelte) © Svelte Contributors, MIT licensed.
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,9 +1,9 @@
1
1
  {
2
2
  "name": "@human-synthesis/norns-core",
3
- "version": "0.0.5",
4
- "description": "Norns core — Svelte with CoffeeScript, Pug, and UnoCSS preconfigured",
3
+ "version": "0.0.7",
4
+ "description": "Norns core — Svelte preprocessor for Civet, Pug, and .n files",
5
5
  "license": "MIT",
6
- "author": "Human Synthesis",
6
+ "author": "Daniel Teodoroiu (https://humansynthesis.ai)",
7
7
  "type": "module",
8
8
  "repository": {
9
9
  "type": "git",
@@ -13,22 +13,19 @@
13
13
  "src",
14
14
  "README.md"
15
15
  ],
16
+ "scripts": {
17
+ "test": "bun test"
18
+ },
16
19
  "exports": {
17
20
  ".": "./src/index.js",
18
21
  "./preprocess": "./src/preprocess.js",
19
- "./uno": "./src/uno.js",
20
22
  "./package.json": "./package.json"
21
23
  },
22
24
  "peerDependencies": {
23
- "svelte": "^5.0.0",
24
- "unocss": "^0.65.0",
25
- "vite": "^5.0.0 || ^6.0.0"
25
+ "svelte": "^5.0.0"
26
26
  },
27
27
  "dependencies": {
28
- "@unocss/extractor-pug": "^0.65.0 || ^66.0.0",
29
- "acorn": "^8.14.0",
30
- "coffeescript": "^2.7.0",
31
- "magic-string": "^0.30.10",
28
+ "@danielx/civet": "^0.11.0",
32
29
  "pug": "^3.0.3",
33
30
  "svelte-preprocess": "^6.0.3"
34
31
  },
package/src/index.js CHANGED
@@ -1,2 +1 @@
1
- export { nornsPreprocess, fuseRuneDeclarations } from './preprocess.js';
2
- export { nornsUno } from './uno.js';
1
+ export { nornsPreprocess } from './preprocess.js';
package/src/preprocess.js CHANGED
@@ -1,193 +1,399 @@
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}`);
2
+ import { compile as compileCivet } from '@danielx/civet';
3
+
4
+ export { transformIfChains, transformSnippets, rewritePugClasses };
5
+
6
+
7
+ const SCRIPT_TAG = /<script\b([^>]*)>/i;
8
+ const TEMPLATE_TAG = /<template\b([^>]*)>/i;
9
+
10
+ function hasLangAttr(attrs) {
11
+ return /\blang\s*=/.test(attrs || '');
12
+ }
13
+
14
+ /**
15
+ * For .norn files: inject lang="civet" / lang="pug" defaults on
16
+ * <script> and <template> blocks, and auto-wrap any top-level non-script /
17
+ * non-style content in <template lang="pug">.
18
+ */
19
+ /**
20
+ * If a .n file ends with an opening <script> or <style> tag but no matching
21
+ * close before EOF, append the closing tag. Lets users skip the boilerplate
22
+ * when the block is the very last thing in the file.
23
+ */
24
+ function autoCloseTrailingBlock(content) {
25
+ let out = content;
26
+ for (const tag of ['script', 'style']) {
27
+ const opens = (out.match(new RegExp(`<${tag}\\b[^>]*>`, 'gi')) ?? []).length;
28
+ const closes = (out.match(new RegExp(`</${tag}>`, 'gi')) ?? []).length;
29
+ if (opens > closes) {
30
+ out = out.replace(/\s*$/, `\n</${tag}>\n`);
31
+ }
23
32
  }
24
- return false;
33
+ return out;
34
+ }
35
+
36
+ const IF_RE = /^(\s*)\+if\s*\((.+)\)\s*$/;
37
+ const ELSEIF_RE_TPL = (ind) => new RegExp(`^${escapeRegex(ind)}\\+elseif\\s*\\((.+)\\)\\s*$`);
38
+ const ELSE_RE_TPL = (ind) => new RegExp(`^${escapeRegex(ind)}\\+else\\s*$`);
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
+
46
+ function escapeRegex(s) {
47
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
48
+ }
49
+
50
+ function stripQuotes(s) {
51
+ s = s.trim();
52
+ if (s.length >= 2) {
53
+ const first = s[0];
54
+ const last = s[s.length - 1];
55
+ if ((first === "'" || first === '"') && first === last) return s.slice(1, -1);
56
+ }
57
+ return s;
58
+ }
59
+
60
+ function detectIndentDiff(lines, fromIdx, parentIndent) {
61
+ for (let j = fromIdx; j < lines.length; j++) {
62
+ const line = lines[j];
63
+ if (line.trim() === '') continue;
64
+ const m = line.match(/^(\s*)/);
65
+ if (m && m[1].length > parentIndent.length) {
66
+ return m[1].slice(parentIndent.length);
67
+ }
68
+ break;
69
+ }
70
+ return '\t';
25
71
  }
26
72
 
27
73
  /**
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".
74
+ * Find the position of the matching `)` for an `(` at `start`, respecting
75
+ * nested parens and quoted strings.
76
+ */
77
+ function findMatchingParen(str, start) {
78
+ if (str[start] !== '(') return -1;
79
+ let depth = 1;
80
+ let inSingle = false;
81
+ let inDouble = false;
82
+ for (let i = start + 1; i < str.length; i++) {
83
+ const c = str[i];
84
+ if (inSingle) {
85
+ if (c === "'" && str[i - 1] !== '\\') inSingle = false;
86
+ } else if (inDouble) {
87
+ if (c === '"' && str[i - 1] !== '\\') inDouble = false;
88
+ } else {
89
+ if (c === "'") inSingle = true;
90
+ else if (c === '"') inDouble = true;
91
+ else if (c === '(') depth++;
92
+ else if (c === ')') {
93
+ depth--;
94
+ if (depth === 0) return i;
95
+ }
96
+ }
97
+ }
98
+ return -1;
99
+ }
100
+
101
+ const PUG_CLASS_SPECIAL = /[:.\/]/;
102
+
103
+ /**
104
+ * Rewrite Pug element lines whose class shorthand contains characters Pug's
105
+ * lexer rejects (`:`, `/`) or that Pug already mis-parses (fractional `.\d+`
106
+ * continuations like `.gap-2.5`). Route those classes from shorthand into the
107
+ * `(class="...")` attribute. Pug then sees only safe class shorthand.
31
108
  *
32
- * Also handles `var X; ({X, ...} = $props())` (destructured props).
109
+ * Examples:
110
+ * `.text-blue.hover:bg-red(href="/")`
111
+ * → `.text-blue(class="hover:bg-red" href="/")`
112
+ * `.gap-2.5.flex`
113
+ * → `.flex(class="gap-2.5")`
114
+ * `.bg-white/40.text-4xl(class="static")`
115
+ * → `.text-4xl(class="bg-white/40 static")`
33
116
  *
34
- * Walks function bodies recursively so closures get the same treatment.
117
+ * Skips lines inside `<script>` / `<style>` blocks.
35
118
  */
36
- export function fuseRuneDeclarations(code) {
37
- let ast;
38
- try {
39
- ast = parse(code, {
40
- ecmaVersion: 'latest',
41
- sourceType: 'module',
42
- allowReturnOutsideFunction: true,
43
- allowAwaitOutsideFunction: true
44
- });
45
- } catch {
46
- return code;
119
+ function rewritePugClasses(content) {
120
+ const blockRanges = [];
121
+ const blockRe = /<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi;
122
+ let m;
123
+ while ((m = blockRe.exec(content)) !== null) {
124
+ blockRanges.push([m.index, m.index + m[0].length]);
47
125
  }
48
126
 
49
- const s = new MagicString(code);
50
- walkBody(ast.body);
127
+ const lines = content.split('\n');
128
+ let offset = 0;
129
+ const out = lines.map((line) => {
130
+ const lineEnd = offset + line.length;
131
+ const inBlock = blockRanges.some(([s, e]) => offset < e && lineEnd > s);
132
+ const result = inBlock ? line : rewritePugLine(line);
133
+ offset = lineEnd + 1; // +1 for the newline
134
+ return result;
135
+ });
136
+ return out.join('\n');
137
+ }
51
138
 
52
- function walkBody(body) {
53
- const fused = new Set();
54
- const varStmts = [];
55
- const claimed = new Set();
139
+ function rewritePugLine(line) {
140
+ const trimmed = line.trimStart();
141
+ if (!trimmed) return line;
142
+ const first = trimmed[0];
143
+ if (first === '|' || first === '<') return line; // text emit / raw HTML
144
+ if (trimmed.startsWith('//')) return line; // pug comment
145
+ if (first === '+' || first === ':') return line; // mixin call / pug filter
56
146
 
57
- for (let i = 0; i < body.length; i++) {
58
- const stmt = body[i];
147
+ let i = 0;
148
+ while (i < line.length && /\s/.test(line[i])) i++;
149
+ const indent = line.slice(0, i);
59
150
 
60
- if (stmt.type === 'VariableDeclaration' && stmt.kind === 'var') {
61
- varStmts.push(stmt);
62
- continue;
63
- }
64
- if (stmt.type !== 'ExpressionStatement') continue;
65
- const e = stmt.expression;
66
-
67
- // Pattern: X = expr (where X was declared via var earlier in this scope)
68
- if (
69
- e.type === 'AssignmentExpression' &&
70
- e.operator === '=' &&
71
- e.left.type === 'Identifier' &&
72
- isVarDeclared(varStmts, e.left.name) &&
73
- !claimed.has(e.left.name)
74
- ) {
75
- claimed.add(e.left.name);
76
- fused.add(e.left.name);
77
- const init = code.slice(e.right.start, e.right.end);
78
- const keyword = isRuneCall(e.right) ? 'let' : 'let';
79
- s.overwrite(stmt.start, stmt.end, `${keyword} ${e.left.name} = ${init};`);
80
- continue;
81
- }
151
+ let tag = '';
152
+ if (i < line.length && /[a-zA-Z]/.test(line[i])) {
153
+ let j = i;
154
+ while (j < line.length && /[\w-]/.test(line[j])) j++;
155
+ tag = line.slice(i, j);
156
+ i = j;
157
+ }
82
158
 
83
- // Pattern: ({X, Y} = $props()) → let {X, Y} = $props()
84
- if (
85
- e.type === 'AssignmentExpression' &&
86
- e.operator === '=' &&
87
- e.left.type === 'ObjectPattern' &&
88
- isRuneCall(e.right)
89
- ) {
90
- const names = collectPatternNames(e.left);
91
- if (names.every((n) => isVarDeclared(varStmts, n) && !claimed.has(n))) {
92
- for (const n of names) {
93
- claimed.add(n);
94
- fused.add(n);
95
- }
96
- const lhs = code.slice(e.left.start, e.left.end);
97
- const rhs = code.slice(e.right.start, e.right.end);
98
- s.overwrite(stmt.start, stmt.end, `let ${lhs} = ${rhs};`);
99
- }
159
+ const segs = [];
160
+ while (i < line.length && (line[i] === '.' || line[i] === '#')) {
161
+ const sep = line[i];
162
+ let j = i + 1;
163
+ if (sep === '#') {
164
+ while (j < line.length && /[\w-]/.test(line[j])) j++;
165
+ } else {
166
+ // class extend chars to allow `/`, `:`, plus fractional `.\d+` suffixes
167
+ while (j < line.length && /[\w/:-]/.test(line[j])) j++;
168
+ while (j < line.length && line[j] === '.' && /\d/.test(line[j + 1] || '')) {
169
+ j++;
170
+ while (j < line.length && /\d/.test(line[j])) j++;
100
171
  }
101
172
  }
173
+ if (j === i + 1) break; // empty token, abort
174
+ segs.push(line.slice(i, j));
175
+ i = j;
176
+ }
102
177
 
103
- for (const stmt of varStmts) {
104
- const remaining = stmt.declarations.filter(
105
- (d) => !(d.id.type === 'Identifier' && fused.has(d.id.name))
106
- );
107
- if (remaining.length === 0) {
108
- s.remove(stmt.start, stmt.end);
109
- } else if (remaining.length < stmt.declarations.length) {
110
- const rebuilt = remaining
111
- .map((d) =>
112
- d.init ? `${d.id.name} = ${code.slice(d.init.start, d.init.end)}` : d.id.name
113
- )
114
- .join(', ');
115
- s.overwrite(stmt.start, stmt.end, `var ${rebuilt};`);
116
- }
178
+ if (segs.length === 0) return line;
179
+
180
+ let attrs = '';
181
+ if (line[i] === '(') {
182
+ const close = findMatchingParen(line, i);
183
+ if (close !== -1) {
184
+ attrs = line.slice(i, close + 1);
185
+ i = close + 1;
117
186
  }
187
+ }
188
+ const rest = line.slice(i);
118
189
 
119
- // Recurse into nested function bodies (closures)
120
- for (const stmt of body) {
121
- recurseInto(stmt);
190
+ const safe = [];
191
+ const routed = [];
192
+ for (const seg of segs) {
193
+ if (seg[0] === '#') {
194
+ safe.push(seg);
195
+ } else {
196
+ const cls = seg.slice(1);
197
+ if (PUG_CLASS_SPECIAL.test(cls)) routed.push(cls);
198
+ else safe.push(seg);
122
199
  }
123
200
  }
124
201
 
125
- function recurseInto(node) {
126
- if (!node || typeof node !== 'object') return;
127
- if (Array.isArray(node)) {
128
- node.forEach(recurseInto);
129
- return;
202
+ if (routed.length === 0) return line;
203
+
204
+ const newAttrs = mergeClassIntoAttrs(attrs, routed);
205
+ return `${indent}${tag}${safe.join('')}${newAttrs}${rest}`;
206
+ }
207
+
208
+ function mergeClassIntoAttrs(attrsStr, classesToAdd) {
209
+ const classStr = classesToAdd.join(' ');
210
+ if (!attrsStr) return `(class="${classStr}")`;
211
+ const inner = attrsStr.slice(1, -1);
212
+
213
+ // Existing static `class="..."` → prepend our routed classes.
214
+ const staticRe = /((?:^|\s)class\s*=\s*)"([^"]*)"/;
215
+ if (staticRe.test(inner)) {
216
+ return `(${inner.replace(staticRe, (_, prefix, val) => `${prefix}"${classStr} ${val}"`)})`;
217
+ }
218
+ // Existing dynamic `class!="{expr}"` → prepend static text. Svelte parses
219
+ // the resulting `class="static {expr}"` as text + interpolation.
220
+ const dynamicRe = /((?:^|\s)class\s*!=\s*)"([^"]*)"/;
221
+ if (dynamicRe.test(inner)) {
222
+ return `(${inner.replace(dynamicRe, (_, prefix, val) => `${prefix}"${classStr} ${val}"`)})`;
223
+ }
224
+
225
+ // No class= attribute exists — insert one.
226
+ return `(class="${classStr}" ${inner})`;
227
+ }
228
+
229
+ /**
230
+ * Rewrite Pug `+if('expr') / +elseif('expr') / +else` chains to raw Svelte
231
+ * block syntax emitted via Pug `|` text. Bypasses svelte-preprocess's `+if`
232
+ * mixin (which doesn't support chaining).
233
+ *
234
+ * Input:
235
+ * +if('a')
236
+ * div one
237
+ * +elseif('b')
238
+ * div two
239
+ * +else
240
+ * div three
241
+ *
242
+ * Output:
243
+ * | {#if a}
244
+ * div one
245
+ * | {:else if b}
246
+ * div two
247
+ * | {:else}
248
+ * div three
249
+ * | {/if}
250
+ */
251
+ function transformIfChains(content) {
252
+ const lines = content.split('\n');
253
+ const out = [];
254
+ let i = 0;
255
+
256
+ while (i < lines.length) {
257
+ const m = lines[i].match(IF_RE);
258
+ if (!m) {
259
+ out.push(lines[i]);
260
+ i++;
261
+ continue;
130
262
  }
131
- if (node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression') {
132
- if (node.body && node.body.type === 'BlockStatement') {
133
- walkBody(node.body.body);
134
- return;
263
+
264
+ const chainIndent = m[1];
265
+ const ifExpr = stripQuotes(m[2]);
266
+ const indentDiff = detectIndentDiff(lines, i + 1, chainIndent);
267
+ const elseIfRe = ELSEIF_RE_TPL(chainIndent);
268
+ const elseRe = ELSE_RE_TPL(chainIndent);
269
+
270
+ out.push(`${chainIndent}| {#if ${ifExpr}}`);
271
+ i++;
272
+
273
+ while (i < lines.length) {
274
+ const cur = lines[i];
275
+
276
+ const eIfM = cur.match(elseIfRe);
277
+ if (eIfM) {
278
+ out.push(`${chainIndent}| {:else if ${stripQuotes(eIfM[1])}}`);
279
+ i++;
280
+ continue;
281
+ }
282
+ const eM = cur.match(elseRe);
283
+ if (eM) {
284
+ out.push(`${chainIndent}| {:else}`);
285
+ i++;
286
+ continue;
135
287
  }
136
- }
137
- for (const key of Object.keys(node)) {
138
- if (key === 'parent' || key === 'loc' || key === 'range') continue;
139
- recurseInto(node[key]);
140
- }
141
- }
142
288
 
143
- function isVarDeclared(varStmts, name) {
144
- for (const stmt of varStmts) {
145
- for (const d of stmt.declarations) {
146
- if (d.id.type === 'Identifier' && d.id.name === name && !d.init) {
147
- return true;
148
- }
289
+ if (cur.trim() === '') {
290
+ out.push(cur);
291
+ i++;
292
+ continue;
149
293
  }
150
- }
151
- return false;
152
- }
153
294
 
154
- function collectPatternNames(pat) {
155
- const names = [];
156
- if (pat.type === 'ObjectPattern') {
157
- for (const prop of pat.properties) {
158
- if (prop.type === 'Property' && prop.value.type === 'Identifier') names.push(prop.value.name);
159
- else if (prop.type === 'RestElement' && prop.argument.type === 'Identifier')
160
- names.push(prop.argument.name);
295
+ const lineIndent = cur.match(/^(\s*)/)[1];
296
+ if (lineIndent.length > chainIndent.length) {
297
+ // Body line — de-indent by one level so it sits at the chain's level.
298
+ out.push(cur.startsWith(indentDiff) ? cur.slice(indentDiff.length) : cur);
299
+ i++;
300
+ continue;
161
301
  }
302
+
303
+ break;
162
304
  }
163
- return names;
305
+
306
+ out.push(`${chainIndent}| {/if}`);
164
307
  }
165
308
 
166
- return s.toString();
309
+ return out.join('\n');
167
310
  }
168
311
 
169
- const SCRIPT_TAG = /<script\b([^>]*)>/i;
170
- const TEMPLATE_TAG = /<template\b([^>]*)>/i;
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;
171
337
 
172
- function hasLangAttr(attrs) {
173
- return /\blang\s*=/.test(attrs || '');
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');
174
382
  }
175
383
 
176
- /**
177
- * For .norn files: inject lang="coffee" / lang="pug" defaults on
178
- * <script> and <template> blocks, and auto-wrap any top-level non-script /
179
- * non-style content in <template lang="pug">.
180
- */
181
384
  function nornDefaultLangs() {
182
385
  return {
183
386
  name: 'norns-default-langs',
184
387
  markup({ content, filename }) {
185
- if (!filename || !filename.endsWith('.norn')) return null;
388
+ if (!filename || !filename.endsWith('.n')) return null;
186
389
 
187
- let out = content;
390
+ let out = autoCloseTrailingBlock(content);
391
+ out = transformIfChains(out);
392
+ out = transformSnippets(out);
393
+ out = rewritePugClasses(out);
188
394
 
189
395
  // If no <template> exists, scan for script/style blocks and wrap the rest.
190
- if (!TEMPLATE_TAG.test(content)) {
396
+ if (!TEMPLATE_TAG.test(out)) {
191
397
  const blocks = [];
192
398
  const blockRe = /<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi;
193
399
  let m;
@@ -211,9 +417,9 @@ function nornDefaultLangs() {
211
417
  }
212
418
  }
213
419
 
214
- // Inject lang="coffee" on <script> tags missing lang=
420
+ // Inject lang="civet" on <script> tags missing lang=
215
421
  out = out.replace(SCRIPT_TAG, (full, attrs) =>
216
- hasLangAttr(attrs) ? full : `<script lang="coffee"${attrs}>`
422
+ hasLangAttr(attrs) ? full : `<script lang="civet"${attrs}>`
217
423
  );
218
424
 
219
425
  // Inject lang="pug" on <template> tags missing lang=
@@ -226,37 +432,82 @@ function nornDefaultLangs() {
226
432
  };
227
433
  }
228
434
 
229
- function nornCoffeeRuneFusion() {
435
+ /**
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.
441
+ *
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
447
+ *
448
+ * No `var X; X = expr` split, so `nornCoffeeRuneFusion` and
449
+ * `nornsCoffeeImportLift` aren't needed for Civet sources.
450
+ */
451
+ function nornsCivetScript() {
230
452
  return {
231
- name: 'norns-coffee-rune-fusion',
232
- script({ content, attributes }) {
233
- if (attributes.lang !== 'coffee' && attributes.lang !== 'coffeescript') return null;
234
- const code = fuseRuneDeclarations(content);
235
- 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
+ };
236
470
  }
237
471
  };
238
472
  }
239
473
 
474
+
240
475
  /**
241
476
  * Norns preprocessor stack.
242
477
  *
243
- * - `.norn` files default `<script>` to CoffeeScript and `<template>` to Pug
244
- * (and auto-wrap top-level content in `<template lang="pug">` if no
245
- * template block is present).
246
- * - CoffeeScript output is post-processed: `var X; X = expr` patterns become
247
- * `let X = expr` so Svelte 5 runes work without backtick-embedded JS and
248
- * 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).
249
488
  *
250
489
  * @param {import('svelte-preprocess').AutoPreprocessOptions} [options]
251
490
  */
252
491
  export function nornsPreprocess(options = {}) {
253
492
  return [
254
493
  nornDefaultLangs(),
494
+ nornsCivetScript(),
255
495
  sveltePreprocess({
256
- coffeescript: { bare: true },
257
496
  pug: {},
497
+ typescript: {
498
+ compilerOptions: {
499
+ // Silence TS 6.x's deprecation warning for older moduleResolution
500
+ // values (node10) that some toolchains still default to.
501
+ ignoreDeprecations: '6.0',
502
+ // Preserve value imports (Svelte component imports look "unused"
503
+ // to the TS transpiler since their usage lives in the template,
504
+ // but they MUST be emitted). verbatimModuleSyntax keeps any
505
+ // non-`import type` imports verbatim.
506
+ verbatimModuleSyntax: true,
507
+ isolatedModules: true
508
+ }
509
+ },
258
510
  ...options
259
- }),
260
- nornCoffeeRuneFusion()
511
+ })
261
512
  ];
262
513
  }
package/src/uno.js DELETED
@@ -1,66 +0,0 @@
1
- import UnoCSS from 'unocss/vite';
2
- import {
3
- presetUno,
4
- presetAttributify,
5
- presetIcons,
6
- presetTypography,
7
- transformerDirectives,
8
- transformerVariantGroup
9
- } from 'unocss';
10
- import extractorPug from '@unocss/extractor-pug';
11
-
12
- const NORN_PIPELINE = [
13
- /\.(vue|svelte|[jt]sx|mdx?|astro|elm|php|phtml|html)($|\?)/,
14
- /\.(norn|coffee)($|\?)/
15
- ];
16
-
17
- /**
18
- * Norns UnoCSS Vite plugin.
19
- *
20
- * Defaults baked in for the Norns stack:
21
- * - presets: Uno + Attributify + Icons + Typography
22
- * - transformers: Directives (`@apply`) + VariantGroup
23
- * - extractor: extractor-pug, so Pug class shorthand inside `.svelte` and
24
- * `.norn` files is recognized
25
- * - content pipeline includes `.norn` and `.coffee`
26
- * - hmrTopLevelAwait: false to avoid a TDZ error in WebKit/Safari when
27
- * `virtual:uno.css` is imported from a SvelteKit route module
28
- *
29
- * **Naming caveat for shortcuts:** avoid names starting with a CSS
30
- * pseudo-class prefix (`hover-`, `focus-`, `link-`, `active-`, `visited-`,
31
- * `disabled-`, `checked-`, etc.) — UnoCSS parses those as variant + utility,
32
- * not as a shortcut name. Use `nav-link`, `lk-muted`, etc. instead of
33
- * `link-muted`.
34
- *
35
- * @param {import('unocss/vite').VitePluginConfig} [options]
36
- */
37
- export function nornsUno(options = {}) {
38
- const {
39
- presets = [],
40
- transformers = [],
41
- extractors = [],
42
- content = {},
43
- hmrTopLevelAwait = false,
44
- ...rest
45
- } = options;
46
- return UnoCSS({
47
- presets: [
48
- presetUno(),
49
- presetAttributify(),
50
- presetIcons(),
51
- presetTypography(),
52
- ...presets
53
- ],
54
- transformers: [transformerDirectives(), transformerVariantGroup(), ...transformers],
55
- extractors: [extractorPug(), ...extractors],
56
- content: {
57
- pipeline: {
58
- include: NORN_PIPELINE,
59
- ...(content.pipeline ?? {})
60
- },
61
- ...content
62
- },
63
- hmrTopLevelAwait,
64
- ...rest
65
- });
66
- }