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