@human-synthesis/norns 0.0.16 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/norns.js +146 -6
- package/package.json +9 -3
- package/src/auto-import.js +7 -3
- package/src/config.js +17 -3
- package/src/kernel/absorb.js +279 -0
- package/src/kernel/address.js +228 -0
- package/src/kernel/adopt.js +157 -0
- package/src/kernel/emit-machines.js +87 -0
- package/src/kernel/emit-schema.js +199 -0
- package/src/kernel/emit-units.js +855 -0
- package/src/kernel/emit-wrangler.js +134 -0
- package/src/kernel/expr-compile.js +191 -0
- package/src/kernel/expr.js +290 -0
- package/src/kernel/flow.js +444 -0
- package/src/kernel/generate.js +840 -0
- package/src/kernel/graph.js +222 -0
- package/src/kernel/index.js +78 -0
- package/src/kernel/meta.js +381 -0
- package/src/kernel/migrate.js +134 -0
- package/src/kernel/refine.js +277 -0
- package/src/kernel/trace.js +465 -0
- package/src/kernel/validate.js +92 -0
- package/src/live-client.js +216 -0
- package/src/server/boot.js +83 -4
- package/src/server/cron.js +105 -0
- package/src/server/db.js +66 -1
- package/src/server/endpoint.js +142 -0
- package/src/server/events.js +86 -0
- package/src/server/guard.js +48 -0
- package/src/server/handle/auth.js +54 -0
- package/src/server/index.js +15 -1
- package/src/server/job.js +102 -0
- package/src/server/live.js +134 -0
- package/src/server/machine.js +35 -0
- package/src/server/page.js +7 -3
- package/src/server/room.js +179 -0
- package/src/server/service.js +188 -0
- package/src/server/storage.js +97 -0
|
@@ -0,0 +1,840 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generator pipeline (K-09): load → validate → refuse → plan → emit.
|
|
3
|
+
*
|
|
4
|
+
* Incremental by module hash: a cache under `.norns/cache/generate.json`
|
|
5
|
+
* records the per-module spec hash of the last successful run; only
|
|
6
|
+
* changed modules are re-emitted. Emitters (K-10..K-12: schema, queries,
|
|
7
|
+
* actions, pages, routes) register in EMITTERS — each returns files
|
|
8
|
+
* relative to `.norns/generated/`.
|
|
9
|
+
*
|
|
10
|
+
* The refusal engine turns unsafe-but-shapely specs into structured
|
|
11
|
+
* errors `{ address, path, code, message, fix? }` — the safe path is the
|
|
12
|
+
* only path.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import { dirname, join, resolve } from 'node:path';
|
|
18
|
+
|
|
19
|
+
import * as v from 'valibot';
|
|
20
|
+
|
|
21
|
+
import { isAddress, listUnits, parseAddress } from './address.js';
|
|
22
|
+
import { schemaEmitter } from './emit-schema.js';
|
|
23
|
+
import {
|
|
24
|
+
actionsEmitter,
|
|
25
|
+
componentKey,
|
|
26
|
+
endpointsEmitter,
|
|
27
|
+
jobsEmitter,
|
|
28
|
+
pagesEmitter,
|
|
29
|
+
policiesEmitter,
|
|
30
|
+
queriesEmitter,
|
|
31
|
+
remotesEmitter,
|
|
32
|
+
servicesEmitter,
|
|
33
|
+
triggersEmitter
|
|
34
|
+
} from './emit-units.js';
|
|
35
|
+
import { machinesEmitter } from './emit-machines.js';
|
|
36
|
+
import { wranglerFile } from './emit-wrangler.js';
|
|
37
|
+
import { emitFlow } from './flow.js';
|
|
38
|
+
import { buildGraph } from './graph.js';
|
|
39
|
+
import { loadSpecs, validateSpecs } from './validate.js';
|
|
40
|
+
|
|
41
|
+
/** @typedef {{ address: string, path?: string, code: string, message: string, fix?: string }} Refusal */
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Generator-specific refusals, beyond what `validate` rejects.
|
|
45
|
+
*
|
|
46
|
+
* @param {{ modules: Record<string, *> }} specs
|
|
47
|
+
* @param {{ contracts?: Record<string, *> }} [opts] palette props contracts
|
|
48
|
+
* (valibot schemas keyed by component tag, normally loaded from
|
|
49
|
+
* `@human-synthesis/norns-ui/contracts` in the app's node_modules)
|
|
50
|
+
* @returns {Refusal[]}
|
|
51
|
+
*/
|
|
52
|
+
export function checkGenerate(specs, opts = {}) {
|
|
53
|
+
/** @type {Refusal[]} */
|
|
54
|
+
const refusals = [];
|
|
55
|
+
const graph = buildGraph(specs.modules);
|
|
56
|
+
if (opts.contracts) refusals.push(...checkBindings(specs, opts.contracts));
|
|
57
|
+
refusals.push(...checkLiveBindings(specs));
|
|
58
|
+
refusals.push(...checkSnippetBindings(specs, opts.snippetSlots ?? null));
|
|
59
|
+
refusals.push(...checkTokenOverrides(specs, opts.tokens ?? null));
|
|
60
|
+
refusals.push(...checkNetworkInBodies(specs));
|
|
61
|
+
|
|
62
|
+
for (const [moduleName, spec] of Object.entries(specs.modules)) {
|
|
63
|
+
for (const unit of listUnits(moduleName, spec)) {
|
|
64
|
+
if (unit.kind === 'Action') {
|
|
65
|
+
const writes = (graph.outbound.get(unit.address) ?? []).filter(
|
|
66
|
+
(e) => e.type === 'writes'
|
|
67
|
+
);
|
|
68
|
+
for (const edge of writes) {
|
|
69
|
+
const guarded = (graph.inbound.get(edge.to) ?? []).some((e) => e.type === 'guards');
|
|
70
|
+
if (!guarded) {
|
|
71
|
+
refusals.push({
|
|
72
|
+
address: unit.address,
|
|
73
|
+
path: `${unit.address}.steps`,
|
|
74
|
+
code: 'UNGUARDED_ACTION',
|
|
75
|
+
message: `action writes ${edge.to} but no Policy guards that entity`,
|
|
76
|
+
fix: `add policies.${edge.to.split('.').pop()} with read/write rules to module "${moduleName}"`
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (unit.kind === 'Action') {
|
|
82
|
+
const a = unit.value;
|
|
83
|
+
if (a && typeof a === 'object' && a.transport === 'remote') {
|
|
84
|
+
refusals.push({
|
|
85
|
+
address: unit.address,
|
|
86
|
+
path: `${unit.address}.transport`,
|
|
87
|
+
code: 'UNSPIKED_TRANSPORT',
|
|
88
|
+
message: '`transport: remote` is not generated yet (spike pending) — only `form` actions are emitted',
|
|
89
|
+
fix: 'use `transport: form` (default) or drop the field'
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (unit.kind === 'Service' || unit.kind === 'Endpoint') {
|
|
94
|
+
refusals.push(...checkServiceSecrets(unit));
|
|
95
|
+
}
|
|
96
|
+
if (unit.kind === 'Query') {
|
|
97
|
+
const q = unit.value;
|
|
98
|
+
if (q && typeof q === 'object' && !q.live && !q.groupBy && q.limit === undefined) {
|
|
99
|
+
refusals.push({
|
|
100
|
+
address: unit.address,
|
|
101
|
+
path: `${unit.address}.limit`,
|
|
102
|
+
code: 'UNPAGINATED_QUERY',
|
|
103
|
+
message: 'query has no limit and is neither live nor grouped — unbounded reads are refused',
|
|
104
|
+
fix: 'add `limit` (or mark the query `live` / add `groupBy`)'
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return refusals;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const SECRET_SHAPE_RE =
|
|
114
|
+
/(?:^|[^A-Za-z0-9])(?:sk|pk|rk)_(?:live|test|prod)_[A-Za-z0-9]{8,}|^(?:ghp|gho|github_pat)_|^xox[a-z]-|^AKIA[0-9A-Z]{12}|^eyJ[A-Za-z0-9_-]{10,}/;
|
|
115
|
+
|
|
116
|
+
/** Token-shaped: known prefixes, or long spaceless mixed-class strings that are not URLs. */
|
|
117
|
+
function looksLikeSecret(s) {
|
|
118
|
+
if (typeof s !== 'string') return false;
|
|
119
|
+
if (SECRET_SHAPE_RE.test(s)) return true;
|
|
120
|
+
return (
|
|
121
|
+
s.length >= 32 &&
|
|
122
|
+
!/\s/.test(s) &&
|
|
123
|
+
/[A-Z]/.test(s) &&
|
|
124
|
+
/[a-z]/.test(s) &&
|
|
125
|
+
/[0-9]/.test(s) &&
|
|
126
|
+
!/^https?:\/\//.test(s)
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function* stringLeaves(value, path = '') {
|
|
131
|
+
if (typeof value === 'string') yield [path, value];
|
|
132
|
+
else if (value && typeof value === 'object') {
|
|
133
|
+
for (const [k, child] of Object.entries(value)) {
|
|
134
|
+
yield* stringLeaves(child, path ? `${path}.${k}` : k);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* D15: credentials never in spec. Refuses userinfo/query params in a
|
|
141
|
+
* service base URL and any token-shaped literal anywhere in the unit —
|
|
142
|
+
* `auth.binding` is a binding *name*; the value lives in the environment
|
|
143
|
+
* (`wrangler secret put`).
|
|
144
|
+
*
|
|
145
|
+
* @param {{ address: string, value: * }} unit a Service unit
|
|
146
|
+
* @returns {Refusal[]}
|
|
147
|
+
*/
|
|
148
|
+
export function checkServiceSecrets(unit) {
|
|
149
|
+
/** @type {Refusal[]} */
|
|
150
|
+
const refusals = [];
|
|
151
|
+
const svc = unit.value ?? {};
|
|
152
|
+
const push = (path, message) =>
|
|
153
|
+
refusals.push({
|
|
154
|
+
address: unit.address,
|
|
155
|
+
path: `${unit.address}.${path}`,
|
|
156
|
+
code: 'SECRET_IN_SPEC',
|
|
157
|
+
message,
|
|
158
|
+
fix: 'keep only an UPPER_SNAKE binding name in spec and set the value with `wrangler secret put`'
|
|
159
|
+
});
|
|
160
|
+
if (typeof svc.base === 'string') {
|
|
161
|
+
try {
|
|
162
|
+
const u = new URL(svc.base);
|
|
163
|
+
if (u.username || u.password) push('base', 'base URL embeds userinfo credentials');
|
|
164
|
+
if (u.search) push('base', 'base URL embeds query parameters — move keys/tokens to an env binding');
|
|
165
|
+
} catch {
|
|
166
|
+
// meta-schema already rejects non-URL bases
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
for (const [path, s] of stringLeaves(svc)) {
|
|
170
|
+
if (path !== 'base' && looksLikeSecret(s)) {
|
|
171
|
+
push(path, `"${s.slice(0, 8)}…" looks like a literal secret — credentials never go in spec`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return refusals;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Global `fetch(` — not `.fetch(` (DO stubs, service bindings) or `myfetch(`. */
|
|
178
|
+
const GLOBAL_FETCH_RE = /(?<![.\w])fetch\s*\(/;
|
|
179
|
+
|
|
180
|
+
function* customBodyFiles(moduleName, spec) {
|
|
181
|
+
for (const [name, a] of Object.entries(spec.actions ?? {})) {
|
|
182
|
+
if (a?.impl === 'custom') yield [`${moduleName}.Action.${name}`, `src/${moduleName}/actions/${name}.c`];
|
|
183
|
+
}
|
|
184
|
+
for (const [name, j] of Object.entries(spec.jobs ?? {})) {
|
|
185
|
+
if (j?.impl === 'custom') yield [`${moduleName}.Job.${name}`, `src/${moduleName}/jobs/${name}.c`];
|
|
186
|
+
}
|
|
187
|
+
for (const name of Object.keys(spec.functions ?? {})) {
|
|
188
|
+
yield [`${moduleName}.Function.${name}`, `src/${moduleName}/functions/${name}.c`];
|
|
189
|
+
}
|
|
190
|
+
for (const name of Object.keys(spec.endpoints ?? {})) {
|
|
191
|
+
yield [`${moduleName}.Endpoint.${name}`, `src/${moduleName}/endpoints/${name}.c`];
|
|
192
|
+
}
|
|
193
|
+
for (const [name, w] of Object.entries(spec.workers ?? {})) {
|
|
194
|
+
if (typeof w?.source === 'string') yield [`${moduleName}.Worker.${name}`, w.source];
|
|
195
|
+
}
|
|
196
|
+
for (const [name, r] of Object.entries(spec.routes ?? {})) {
|
|
197
|
+
if (typeof r?.source === 'string') yield [`${moduleName}.Route.${name}`, r.source];
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* X-07: the generated service client is the only network path a custom
|
|
203
|
+
* body may take. Scans declared L3 bodies (custom actions/jobs, functions,
|
|
204
|
+
* endpoints, worker/route sources) for direct global `fetch(` calls.
|
|
205
|
+
* No-op for in-memory specs (no `dir`) — the lint needs files on disk.
|
|
206
|
+
*
|
|
207
|
+
* @param {{ dir?: string, modules: Record<string, *> }} specs
|
|
208
|
+
* @returns {Refusal[]}
|
|
209
|
+
*/
|
|
210
|
+
export function checkNetworkInBodies(specs) {
|
|
211
|
+
if (typeof specs.dir !== 'string') return [];
|
|
212
|
+
const appRoot = dirname(specs.dir);
|
|
213
|
+
/** @type {Refusal[]} */
|
|
214
|
+
const refusals = [];
|
|
215
|
+
for (const [moduleName, spec] of Object.entries(specs.modules)) {
|
|
216
|
+
for (const [address, rel] of customBodyFiles(moduleName, spec)) {
|
|
217
|
+
const file = join(appRoot, rel);
|
|
218
|
+
if (!existsSync(file)) continue;
|
|
219
|
+
const hit = readFileSync(file, 'utf-8')
|
|
220
|
+
.split('\n')
|
|
221
|
+
.findIndex((line) => GLOBAL_FETCH_RE.test(line));
|
|
222
|
+
if (hit === -1) continue;
|
|
223
|
+
refusals.push({
|
|
224
|
+
address,
|
|
225
|
+
path: `${rel}:${hit + 1}`,
|
|
226
|
+
code: 'UNDECLARED_NETWORK',
|
|
227
|
+
message: `custom body calls global fetch() at ${rel}:${hit + 1} — the generated service client is the only network path`,
|
|
228
|
+
fix: 'declare the host as a Service (with auth binding + operations) and call it through the generated client'
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return refusals;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Validate page `components:` entries against palette props contracts
|
|
237
|
+
* (U-02). Each entry is normalized the way the pages emitter binds it: the
|
|
238
|
+
* first key names the component; its value becomes the `data` prop when it
|
|
239
|
+
* is a Query address and the `action` prop when it is an Action address.
|
|
240
|
+
* Tags without a contract are left alone — they may be custom components.
|
|
241
|
+
*
|
|
242
|
+
* @param {{ modules: Record<string, *> }} specs
|
|
243
|
+
* @param {Record<string, *>} contracts valibot schema per component tag
|
|
244
|
+
* @returns {Refusal[]}
|
|
245
|
+
*/
|
|
246
|
+
export function checkBindings(specs, contracts) {
|
|
247
|
+
/** @type {Refusal[]} */
|
|
248
|
+
const refusals = [];
|
|
249
|
+
for (const [moduleName, moduleSpec] of Object.entries(specs.modules)) {
|
|
250
|
+
for (const [pageName, page] of Object.entries(moduleSpec.pages ?? {})) {
|
|
251
|
+
(page.components ?? []).forEach((entry, i) => {
|
|
252
|
+
const keys = Object.keys(entry);
|
|
253
|
+
if (keys.length === 0) return;
|
|
254
|
+
const first = componentKey(entry);
|
|
255
|
+
const rest = keys.filter((k) => k !== first);
|
|
256
|
+
const tag = first[0].toUpperCase() + first.slice(1);
|
|
257
|
+
const contract = contracts[tag];
|
|
258
|
+
if (!contract) return;
|
|
259
|
+
|
|
260
|
+
const primary = entry[first];
|
|
261
|
+
const parsed =
|
|
262
|
+
typeof primary === 'string' && isAddress(primary) ? parseAddress(primary) : null;
|
|
263
|
+
const props = {};
|
|
264
|
+
if (parsed) props[parsed.kind === 'Action' ? 'action' : 'data'] = primary;
|
|
265
|
+
else if (primary && typeof primary === 'object' && !Array.isArray(primary)) {
|
|
266
|
+
// realtime bindings (K-27) — the emitter turns these into
|
|
267
|
+
// streamSource/roomChannel props, so validate that shape
|
|
268
|
+
if (typeof primary.stream === 'string') props.streamSource = primary.stream;
|
|
269
|
+
if (typeof primary.room === 'string') props.roomChannel = primary.room;
|
|
270
|
+
}
|
|
271
|
+
for (const key of rest) props[key] = entry[key];
|
|
272
|
+
|
|
273
|
+
const result = v.safeParse(contract, props);
|
|
274
|
+
if (result.success) return;
|
|
275
|
+
const issue = result.issues[0];
|
|
276
|
+
const at = issue.path?.map((p) => p.key).join('.');
|
|
277
|
+
refusals.push({
|
|
278
|
+
address: `${moduleName}.Page.${pageName}`,
|
|
279
|
+
path: `${moduleName}.Page.${pageName}.components[${i}]${at ? `.${at}` : ''}`,
|
|
280
|
+
code: 'INVALID_BINDING',
|
|
281
|
+
message: `<${tag}> binding rejected${at ? ` at \`${at}\`` : ''}: ${issue.message}`,
|
|
282
|
+
fix: `match the ${tag} props contract exported by @human-synthesis/norns-ui/contracts`
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return refusals;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Validate realtime page bindings (K-27) — object-primary component
|
|
292
|
+
* entries like `{ streamText: { stream: "m.Endpoint.x" } }` and
|
|
293
|
+
* `{ chatThread: { room: "m.Worker.x", sends: [...], receives: [...] } }`.
|
|
294
|
+
* `stream` must target an Endpoint with a `stream` output mode; `room` a
|
|
295
|
+
* Worker declared `room: true`. Optional `sends`/`receives` message-name
|
|
296
|
+
* lists are cross-checked against the Room's declared message schemas
|
|
297
|
+
* (`in` for sends, `out` for receives). Needs no palette contracts — the
|
|
298
|
+
* target shape lives in the spec itself.
|
|
299
|
+
*
|
|
300
|
+
* @param {{ modules: Record<string, *> }} specs
|
|
301
|
+
* @returns {Refusal[]}
|
|
302
|
+
*/
|
|
303
|
+
export function checkLiveBindings(specs) {
|
|
304
|
+
/** @type {Refusal[]} */
|
|
305
|
+
const refusals = [];
|
|
306
|
+
for (const [moduleName, moduleSpec] of Object.entries(specs.modules)) {
|
|
307
|
+
for (const [pageName, page] of Object.entries(moduleSpec.pages ?? {})) {
|
|
308
|
+
(page.components ?? []).forEach((entry, i) => {
|
|
309
|
+
const first = componentKey(entry);
|
|
310
|
+
if (!first) return;
|
|
311
|
+
const binding = entry[first];
|
|
312
|
+
if (!binding || typeof binding !== 'object' || Array.isArray(binding)) return;
|
|
313
|
+
const tag = first[0].toUpperCase() + first.slice(1);
|
|
314
|
+
const refuse = (sub, message, fix) =>
|
|
315
|
+
refusals.push({
|
|
316
|
+
address: `${moduleName}.Page.${pageName}`,
|
|
317
|
+
path: `${moduleName}.Page.${pageName}.components[${i}].${first}.${sub}`,
|
|
318
|
+
code: 'INVALID_BINDING',
|
|
319
|
+
message,
|
|
320
|
+
fix
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
if ('stream' in binding) {
|
|
324
|
+
const addr = binding.stream;
|
|
325
|
+
const parsed = typeof addr === 'string' && isAddress(addr) ? parseAddress(addr) : null;
|
|
326
|
+
const target =
|
|
327
|
+
parsed?.kind === 'Endpoint'
|
|
328
|
+
? specs.modules[parsed.module]?.endpoints?.[parsed.name]
|
|
329
|
+
: undefined;
|
|
330
|
+
if (!parsed || parsed.kind !== 'Endpoint' || !target) {
|
|
331
|
+
refuse(
|
|
332
|
+
'stream',
|
|
333
|
+
`<${tag}> \`stream\` binding needs an existing Endpoint address, got ${JSON.stringify(addr)}`,
|
|
334
|
+
'point `stream` at a declared module.Endpoint.name'
|
|
335
|
+
);
|
|
336
|
+
} else if (!target.stream) {
|
|
337
|
+
refuse(
|
|
338
|
+
'stream',
|
|
339
|
+
`${addr} has no \`stream\` output mode — <${tag}> consumes typed SSE frames`,
|
|
340
|
+
'declare `stream: { frame: { … } }` on the Endpoint (output and stream are exclusive)'
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if ('room' in binding) {
|
|
346
|
+
const addr = binding.room;
|
|
347
|
+
const parsed = typeof addr === 'string' && isAddress(addr) ? parseAddress(addr) : null;
|
|
348
|
+
const target =
|
|
349
|
+
parsed?.kind === 'Worker'
|
|
350
|
+
? specs.modules[parsed.module]?.workers?.[parsed.name]
|
|
351
|
+
: undefined;
|
|
352
|
+
if (!parsed || parsed.kind !== 'Worker' || !target) {
|
|
353
|
+
refuse(
|
|
354
|
+
'room',
|
|
355
|
+
`<${tag}> \`room\` binding needs an existing Worker address, got ${JSON.stringify(addr)}`,
|
|
356
|
+
'point `room` at a declared module.Worker.name'
|
|
357
|
+
);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (target.room !== true) {
|
|
361
|
+
refuse(
|
|
362
|
+
'room',
|
|
363
|
+
`${addr} is not a Room — <${tag}> needs a Worker declared \`room: true\``,
|
|
364
|
+
'set `room: true` (plus `messages`/`state`) on the Worker'
|
|
365
|
+
);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const messages = target.messages ?? {};
|
|
369
|
+
for (const [listKey, dir] of [
|
|
370
|
+
['sends', 'in'],
|
|
371
|
+
['receives', 'out']
|
|
372
|
+
]) {
|
|
373
|
+
for (const name of Array.isArray(binding[listKey]) ? binding[listKey] : []) {
|
|
374
|
+
if (messages[name]?.[dir]) continue;
|
|
375
|
+
refuse(
|
|
376
|
+
listKey,
|
|
377
|
+
`${addr} declares no \`${dir}\` schema for message ${JSON.stringify(name)}`,
|
|
378
|
+
`declare \`messages.${name}.${dir}\` on the Worker or drop it from \`${listKey}\``
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return refusals;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Validate Snippet bindings (U-07): a page component prop bound to a
|
|
391
|
+
* `m.Snippet.n` address must reference a declared Snippet unit, and — when
|
|
392
|
+
* palette slot metadata is available — its declared `args` must match the
|
|
393
|
+
* slot's calling convention (the emitter forwards those args as props to
|
|
394
|
+
* the custom body, so a mismatch is a broken render, not a style issue).
|
|
395
|
+
*
|
|
396
|
+
* @param {{ modules: Record<string, *> }} specs
|
|
397
|
+
* @param {Record<string, Record<string, string[]>> | null} [slots]
|
|
398
|
+
* `snippetSlots` from @human-synthesis/norns-ui/contracts: tag → prop → args
|
|
399
|
+
* @returns {Refusal[]}
|
|
400
|
+
*/
|
|
401
|
+
export function checkSnippetBindings(specs, slots = null) {
|
|
402
|
+
/** @type {Refusal[]} */
|
|
403
|
+
const refusals = [];
|
|
404
|
+
for (const [moduleName, moduleSpec] of Object.entries(specs.modules)) {
|
|
405
|
+
for (const [pageName, page] of Object.entries(moduleSpec.pages ?? {})) {
|
|
406
|
+
(page.components ?? []).forEach((entry, i) => {
|
|
407
|
+
const keys = Object.keys(entry ?? {});
|
|
408
|
+
if (keys.length === 0) return;
|
|
409
|
+
const first = componentKey(entry);
|
|
410
|
+
const tag = first[0].toUpperCase() + first.slice(1);
|
|
411
|
+
for (const key of keys) {
|
|
412
|
+
const value = entry[key];
|
|
413
|
+
if (typeof value !== 'string' || !isAddress(value)) continue;
|
|
414
|
+
const parsed = parseAddress(value);
|
|
415
|
+
if (parsed.kind !== 'Snippet') continue;
|
|
416
|
+
const refuse = (message, fix) =>
|
|
417
|
+
refusals.push({
|
|
418
|
+
address: `${moduleName}.Page.${pageName}`,
|
|
419
|
+
path: `${moduleName}.Page.${pageName}.components[${i}].${key}`,
|
|
420
|
+
code: 'INVALID_BINDING',
|
|
421
|
+
message,
|
|
422
|
+
fix
|
|
423
|
+
});
|
|
424
|
+
const target = specs.modules[parsed.module]?.snippets?.[parsed.name];
|
|
425
|
+
if (!target) {
|
|
426
|
+
refuse(
|
|
427
|
+
`no Snippet declared at ${value}`,
|
|
428
|
+
`declare \`snippets.${parsed.name}\` (with \`args\`) in module "${parsed.module}" — the body lives in src/${parsed.module}/snippets/${parsed.name}.n`
|
|
429
|
+
);
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
const want = slots?.[tag]?.[key];
|
|
433
|
+
if (!want) continue;
|
|
434
|
+
const got = target.args ?? [];
|
|
435
|
+
if (want.length !== got.length || want.some((a, j) => a !== got[j])) {
|
|
436
|
+
refuse(
|
|
437
|
+
`<${tag}> \`${key}\` slot passes (${want.join(', ')}) but ${value} declares args (${got.join(', ')})`,
|
|
438
|
+
`set \`args: [${want.map((a) => `"${a}"`).join(', ')}]\` on the Snippet — the emitter forwards them as same-named props`
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return refusals;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const TOKEN_NAME_RE = /^--[a-z][a-z0-9-]*$/;
|
|
449
|
+
|
|
450
|
+
const asVarName = (name) => (name.startsWith('--') ? name : `--${name}`);
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Validate app-level design-token overrides (U-10): `app.settings.tokens`
|
|
454
|
+
* is a record of token name → CSS value, emitted verbatim into a
|
|
455
|
+
* generated stylesheet — so names must be kebab-case custom properties
|
|
456
|
+
* (checked against the palette token manifest when available) and values
|
|
457
|
+
* must not be able to escape their declaration.
|
|
458
|
+
*
|
|
459
|
+
* @param {{ app?: * }} specs
|
|
460
|
+
* @param {{ vars?: Record<string, string> } | null} [tokens]
|
|
461
|
+
* `tokens` section of @human-synthesis/norns-ui/manifest
|
|
462
|
+
* @returns {Refusal[]}
|
|
463
|
+
*/
|
|
464
|
+
export function checkTokenOverrides(specs, tokens = null) {
|
|
465
|
+
const overrides = specs.app?.settings?.tokens;
|
|
466
|
+
if (overrides === undefined) return [];
|
|
467
|
+
const at = (name) => ({
|
|
468
|
+
address: 'app.settings.tokens',
|
|
469
|
+
path: name ? `app.settings.tokens.${name}` : 'app.settings.tokens'
|
|
470
|
+
});
|
|
471
|
+
if (overrides === null || typeof overrides !== 'object' || Array.isArray(overrides)) {
|
|
472
|
+
return [
|
|
473
|
+
{
|
|
474
|
+
...at(''),
|
|
475
|
+
code: 'INVALID_TOKEN',
|
|
476
|
+
message: 'settings.tokens must be a record of design-token name → CSS value',
|
|
477
|
+
fix: 'e.g. `"settings": { "tokens": { "color-primary-500": "oklch(55% 0.2 260)" } }`'
|
|
478
|
+
}
|
|
479
|
+
];
|
|
480
|
+
}
|
|
481
|
+
/** @type {Refusal[]} */
|
|
482
|
+
const refusals = [];
|
|
483
|
+
const known = tokens?.vars ? new Set(Object.keys(tokens.vars)) : null;
|
|
484
|
+
for (const [name, value] of Object.entries(overrides)) {
|
|
485
|
+
const varName = asVarName(name);
|
|
486
|
+
if (!TOKEN_NAME_RE.test(varName)) {
|
|
487
|
+
refusals.push({
|
|
488
|
+
...at(name),
|
|
489
|
+
code: 'INVALID_TOKEN',
|
|
490
|
+
message: `"${name}" is not a token name (lowercase kebab-case, optional leading --)`
|
|
491
|
+
});
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
if (known && !known.has(varName)) {
|
|
495
|
+
refusals.push({
|
|
496
|
+
...at(name),
|
|
497
|
+
code: 'UNKNOWN_TOKEN',
|
|
498
|
+
message: `"${varName}" is not a palette design token`,
|
|
499
|
+
fix: 'browse `tokens.vars` in @human-synthesis/norns-ui/manifest for the addressable set'
|
|
500
|
+
});
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
504
|
+
refusals.push({
|
|
505
|
+
...at(name),
|
|
506
|
+
code: 'INVALID_TOKEN',
|
|
507
|
+
message: `override for "${varName}" must be a non-empty CSS value string`
|
|
508
|
+
});
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
// eslint-disable-next-line no-control-regex
|
|
512
|
+
if (/[;{}]|url\s*\(|[-]/i.test(value)) {
|
|
513
|
+
refusals.push({
|
|
514
|
+
...at(name),
|
|
515
|
+
code: 'INVALID_TOKEN',
|
|
516
|
+
message: `override for "${varName}" is not a plain CSS value — \`;\`, braces, \`url()\` and control characters are refused`
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return refusals;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Normalized `--name → value` record from `app.settings.tokens`, or null
|
|
525
|
+
* when the app declares no overrides.
|
|
526
|
+
*
|
|
527
|
+
* @param {{ app?: * }} specs
|
|
528
|
+
* @returns {Record<string, string> | null}
|
|
529
|
+
*/
|
|
530
|
+
export function tokenOverrides(specs) {
|
|
531
|
+
const raw = specs.app?.settings?.tokens;
|
|
532
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
533
|
+
const entries = Object.entries(raw);
|
|
534
|
+
if (entries.length === 0) return null;
|
|
535
|
+
return Object.fromEntries(entries.map(([name, value]) => [asVarName(name), value]));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Emitters (K-10..K-12). Each: { name, emit(ctx) } where ctx =
|
|
540
|
+
* { moduleName, moduleSpec, specs, graph } and the return value is a list
|
|
541
|
+
* of { path, text } relative to the generated root.
|
|
542
|
+
* @type {{ name: string, emit: (ctx: *) => { path: string, text: string }[] }[]}
|
|
543
|
+
*/
|
|
544
|
+
export const EMITTERS = [
|
|
545
|
+
schemaEmitter,
|
|
546
|
+
policiesEmitter,
|
|
547
|
+
machinesEmitter,
|
|
548
|
+
queriesEmitter,
|
|
549
|
+
actionsEmitter,
|
|
550
|
+
servicesEmitter,
|
|
551
|
+
jobsEmitter,
|
|
552
|
+
triggersEmitter,
|
|
553
|
+
pagesEmitter,
|
|
554
|
+
remotesEmitter,
|
|
555
|
+
endpointsEmitter
|
|
556
|
+
];
|
|
557
|
+
|
|
558
|
+
const require = createRequire(import.meta.url);
|
|
559
|
+
|
|
560
|
+
const FILE_KINDS = {
|
|
561
|
+
'schema.c': 'Entity',
|
|
562
|
+
'queries.c': 'Query',
|
|
563
|
+
'actions.c': 'Action',
|
|
564
|
+
'machines.c': 'Action',
|
|
565
|
+
'policies.c': 'Policy',
|
|
566
|
+
'services.c': 'Service',
|
|
567
|
+
'jobs.c': 'Job',
|
|
568
|
+
'triggers.c': 'Trigger'
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
/** `lib/orders/actions.c` + an error near line N → `orders.Action.<unit>`. */
|
|
572
|
+
function selfCheckAddress(file, message) {
|
|
573
|
+
const lib = file.path.match(/^lib\/([^/]+)\/([^/]+)$/);
|
|
574
|
+
const kind = lib ? FILE_KINDS[lib[2]] : file.path.startsWith('routes/') ? 'Page' : null;
|
|
575
|
+
if (!kind) return file.path;
|
|
576
|
+
const line = Number(message.match(/:(\d+):\d+/)?.[1] ?? NaN);
|
|
577
|
+
const lines = file.text.split('\n');
|
|
578
|
+
for (let i = Math.min(line, lines.length) - 1; i >= 0; i--) {
|
|
579
|
+
const unit = lines[i]?.match(/^export (\w+) :=/)?.[1];
|
|
580
|
+
if (unit && lib) return `${lib[1]}.${kind}.${unit}`;
|
|
581
|
+
}
|
|
582
|
+
return lib ? `${lib[1]}.${kind}.*` : file.path;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const scriptOf = (pug) => pug.match(/<script>\n([\s\S]*?)<\/script>/)?.[1] ?? null;
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Self-check (K-14): every emitted `.c` file and `.n` script block must
|
|
589
|
+
* compile through Civet before anything is written — a generator bug can
|
|
590
|
+
* never leave a broken tree behind. (svelte-check across the assembled
|
|
591
|
+
* app runs in the app's own check pipeline, not here.)
|
|
592
|
+
*
|
|
593
|
+
* @param {{ path: string, text: string }[]} files
|
|
594
|
+
* @returns {Refusal[]}
|
|
595
|
+
*/
|
|
596
|
+
export function selfCheck(files) {
|
|
597
|
+
const { compile } = require('@danielx/civet');
|
|
598
|
+
const refusals = [];
|
|
599
|
+
for (const file of files) {
|
|
600
|
+
const src = file.path.endsWith('.c') ? file.text : file.path.endsWith('.n') ? scriptOf(file.text) : null;
|
|
601
|
+
if (src === null) continue;
|
|
602
|
+
try {
|
|
603
|
+
compile(src, { sync: true, js: true });
|
|
604
|
+
} catch (e) {
|
|
605
|
+
const message = String(e.message ?? e).split('\n')[0];
|
|
606
|
+
refusals.push({
|
|
607
|
+
address: selfCheckAddress(file, message),
|
|
608
|
+
path: file.path,
|
|
609
|
+
code: 'SELFCHECK_FAILED',
|
|
610
|
+
message: `emitted file does not compile: ${message}`
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return refusals;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
export class GenerateError extends Error {
|
|
618
|
+
/** @param {Refusal[]} refusals */
|
|
619
|
+
constructor(refusals) {
|
|
620
|
+
const lines = refusals.map(
|
|
621
|
+
(r) => ` [${r.code}] ${r.address}: ${r.message}${r.fix ? `\n fix: ${r.fix}` : ''}`
|
|
622
|
+
);
|
|
623
|
+
super(`norns generate: refused\n${lines.join('\n')}`);
|
|
624
|
+
this.name = 'GenerateError';
|
|
625
|
+
this.refusals = refusals;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Load palette props contracts + snippet slot metadata from the app's own
|
|
631
|
+
* dependency tree. Apps that don't use norns-ui simply skip binding
|
|
632
|
+
* validation.
|
|
633
|
+
*
|
|
634
|
+
* @param {string} appRoot
|
|
635
|
+
* @returns {{ contracts: Record<string, *> | null, snippetSlots: Record<string, *> | null, tokens: { vars?: Record<string, string> } | null }}
|
|
636
|
+
*/
|
|
637
|
+
function loadPalette(appRoot) {
|
|
638
|
+
try {
|
|
639
|
+
const appRequire = createRequire(join(appRoot, 'package.json'));
|
|
640
|
+
const mod = appRequire('@human-synthesis/norns-ui/contracts');
|
|
641
|
+
let tokens = null;
|
|
642
|
+
try {
|
|
643
|
+
tokens = appRequire('@human-synthesis/norns-ui/manifest')?.tokens ?? null;
|
|
644
|
+
} catch {
|
|
645
|
+
tokens = null;
|
|
646
|
+
}
|
|
647
|
+
return { contracts: mod.contracts ?? null, snippetSlots: mod.snippetSlots ?? null, tokens };
|
|
648
|
+
} catch {
|
|
649
|
+
return { contracts: null, snippetSlots: null, tokens: null };
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** Any `live: true` query anywhere means the app serves `/_norns/live`. */
|
|
654
|
+
function hasLiveQueries(specs) {
|
|
655
|
+
for (const mod of Object.values(specs.modules)) {
|
|
656
|
+
for (const query of Object.values(mod.queries ?? {})) {
|
|
657
|
+
if (query?.live === true) return true;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return false;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* App-level SSE endpoint streaming live-query refresh signals (R-11).
|
|
665
|
+
*
|
|
666
|
+
* @returns {{ path: string, text: string }}
|
|
667
|
+
*/
|
|
668
|
+
export function liveRouteFile() {
|
|
669
|
+
return {
|
|
670
|
+
path: 'routes/_norns/live/+server.c',
|
|
671
|
+
text: [
|
|
672
|
+
'// GENERATED by `norns generate` — do not edit.',
|
|
673
|
+
'',
|
|
674
|
+
`import { liveHandler } from '@human-synthesis/norns/server'`,
|
|
675
|
+
'',
|
|
676
|
+
`export GET := liveHandler`,
|
|
677
|
+
''
|
|
678
|
+
].join('\n')
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Root layout for the generated route tree (app-level, like wrangler.json).
|
|
684
|
+
* Plain `.svelte` — no Pug/Civet — so it stays outside the vetted-subset
|
|
685
|
+
* surface. Imports the app's global stylesheet when `src/app.css` exists,
|
|
686
|
+
* then the generated token-override sheet (after, so `app.settings.tokens`
|
|
687
|
+
* wins over library defaults pulled in via app.css).
|
|
688
|
+
*
|
|
689
|
+
* @param {boolean} hasAppCss
|
|
690
|
+
* @param {boolean} [hasTokens]
|
|
691
|
+
* @returns {{ path: string, text: string }}
|
|
692
|
+
*/
|
|
693
|
+
export function layoutFile(hasAppCss, hasTokens = false) {
|
|
694
|
+
return {
|
|
695
|
+
path: 'routes/+layout.svelte',
|
|
696
|
+
text: [
|
|
697
|
+
'<!-- GENERATED by `norns generate` — do not edit. -->',
|
|
698
|
+
'<script>',
|
|
699
|
+
...(hasAppCss ? ["\timport '$custom/app.css';"] : []),
|
|
700
|
+
...(hasTokens ? ["\timport './tokens.css';"] : []),
|
|
701
|
+
'\tlet { children } = $props();',
|
|
702
|
+
'</script>',
|
|
703
|
+
'',
|
|
704
|
+
'{@render children()}',
|
|
705
|
+
''
|
|
706
|
+
].join('\n')
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* App-level stylesheet applying `app.settings.tokens` overrides (U-10).
|
|
712
|
+
*
|
|
713
|
+
* @param {Record<string, string>} overrides normalized `--name → value`
|
|
714
|
+
* @returns {{ path: string, text: string }}
|
|
715
|
+
*/
|
|
716
|
+
export function tokensFile(overrides) {
|
|
717
|
+
return {
|
|
718
|
+
path: 'routes/tokens.css',
|
|
719
|
+
text: [
|
|
720
|
+
'/* GENERATED by `norns generate` — do not edit. */',
|
|
721
|
+
':root {',
|
|
722
|
+
...Object.entries(overrides).map(([name, value]) => `\t${name}: ${value};`),
|
|
723
|
+
'}',
|
|
724
|
+
''
|
|
725
|
+
].join('\n')
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function readCache(file) {
|
|
730
|
+
try {
|
|
731
|
+
return JSON.parse(readFileSync(file, 'utf-8'));
|
|
732
|
+
} catch {
|
|
733
|
+
return { moduleHashes: {} };
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Generate an app from its specs into `.norns/generated/`.
|
|
739
|
+
*
|
|
740
|
+
* @param {string} [dir] specs directory, defaults to `<cwd>/specs`
|
|
741
|
+
* @param {{ out?: string, force?: boolean, contracts?: Record<string, *> }} [opts]
|
|
742
|
+
* @returns {{ version: string, written: string[], skipped: string[], refusals: [] }}
|
|
743
|
+
*/
|
|
744
|
+
export function generateApp(dir, opts = {}) {
|
|
745
|
+
const specs = loadSpecs(dir);
|
|
746
|
+
const appRoot = dirname(specs.dir);
|
|
747
|
+
const outRoot = resolve(opts.out ?? join(appRoot, '.norns', 'generated'));
|
|
748
|
+
const cacheFile = join(appRoot, '.norns', 'cache', 'generate.json');
|
|
749
|
+
|
|
750
|
+
const validation = validateSpecs(specs.dir);
|
|
751
|
+
if (!validation.ok) {
|
|
752
|
+
throw new GenerateError(
|
|
753
|
+
validation.issues
|
|
754
|
+
.filter((i) => i.level === 'error')
|
|
755
|
+
.map((i) => ({ address: i.address, code: 'INVALID_SPEC', message: i.message }))
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
const palette = loadPalette(appRoot);
|
|
759
|
+
const contracts = opts.contracts ?? palette.contracts;
|
|
760
|
+
const snippetSlots = opts.snippetSlots ?? palette.snippetSlots;
|
|
761
|
+
const tokens = opts.tokens ?? palette.tokens;
|
|
762
|
+
const refusals = checkGenerate(specs, {
|
|
763
|
+
...(contracts ? { contracts } : {}),
|
|
764
|
+
...(snippetSlots ? { snippetSlots } : {}),
|
|
765
|
+
...(tokens ? { tokens } : {})
|
|
766
|
+
});
|
|
767
|
+
if (refusals.length > 0) throw new GenerateError(refusals);
|
|
768
|
+
|
|
769
|
+
const cache = opts.force ? { moduleHashes: {} } : readCache(cacheFile);
|
|
770
|
+
const graph = buildGraph(specs.modules);
|
|
771
|
+
const written = [];
|
|
772
|
+
const skipped = [];
|
|
773
|
+
const pending = [];
|
|
774
|
+
|
|
775
|
+
for (const [moduleName, moduleSpec] of Object.entries(specs.modules)) {
|
|
776
|
+
if (cache.moduleHashes[moduleName] === specs.hashes[moduleName]) {
|
|
777
|
+
skipped.push(moduleName);
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
for (const emitter of EMITTERS) {
|
|
781
|
+
pending.push(...emitter.emit({ moduleName, moduleSpec, specs, graph }));
|
|
782
|
+
}
|
|
783
|
+
cache.moduleHashes[moduleName] = specs.hashes[moduleName];
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// App-level: the wrangler config derives from all modules AND app.tron
|
|
787
|
+
// (name, dialect, cloudflare settings), so refresh it whenever anything
|
|
788
|
+
// re-emitted, the app spec itself changed, or it's missing entirely.
|
|
789
|
+
const appChanged = cache.appHash !== specs.hashes.app;
|
|
790
|
+
if (pending.length > 0 || appChanged || !existsSync(join(outRoot, 'wrangler.json'))) {
|
|
791
|
+
pending.push(wranglerFile(specs));
|
|
792
|
+
}
|
|
793
|
+
cache.appHash = specs.hashes.app;
|
|
794
|
+
|
|
795
|
+
// App-level: the live SSE route exists iff any query is live.
|
|
796
|
+
if (
|
|
797
|
+
hasLiveQueries(specs) &&
|
|
798
|
+
(pending.length > 0 || !existsSync(join(outRoot, 'routes', '_norns', 'live', '+server.c')))
|
|
799
|
+
) {
|
|
800
|
+
pending.push(liveRouteFile());
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// App-level: token overrides from `app.settings.tokens` (U-10).
|
|
804
|
+
const overrides = tokenOverrides(specs);
|
|
805
|
+
if (overrides && (pending.length > 0 || !existsSync(join(outRoot, 'routes', 'tokens.css')))) {
|
|
806
|
+
pending.push(tokensFile(overrides));
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// App-level: a root layout so generated routes render inside a shell.
|
|
810
|
+
if (pending.length > 0 || !existsSync(join(outRoot, 'routes', '+layout.svelte'))) {
|
|
811
|
+
pending.push(layoutFile(existsSync(join(appRoot, 'src', 'app.css')), Boolean(overrides)));
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const failures = selfCheck(pending);
|
|
815
|
+
if (failures.length > 0) throw new GenerateError(failures);
|
|
816
|
+
|
|
817
|
+
for (const file of pending) {
|
|
818
|
+
const full = join(outRoot, file.path);
|
|
819
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
820
|
+
writeFileSync(full, file.text, 'utf-8');
|
|
821
|
+
written.push(file.path);
|
|
822
|
+
}
|
|
823
|
+
for (const name of Object.keys(cache.moduleHashes)) {
|
|
824
|
+
if (!(name in specs.modules)) delete cache.moduleHashes[name];
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const manifest = {
|
|
828
|
+
version: specs.version,
|
|
829
|
+
modules: specs.hashes,
|
|
830
|
+
generatedAt: new Date().toISOString()
|
|
831
|
+
};
|
|
832
|
+
mkdirSync(outRoot, { recursive: true });
|
|
833
|
+
writeFileSync(join(outRoot, 'manifest.json'), JSON.stringify(manifest, null, '\t') + '\n');
|
|
834
|
+
mkdirSync(dirname(cacheFile), { recursive: true });
|
|
835
|
+
writeFileSync(cacheFile, JSON.stringify(cache, null, '\t') + '\n');
|
|
836
|
+
|
|
837
|
+
emitFlow(specs, { force: opts.force });
|
|
838
|
+
|
|
839
|
+
return { version: specs.version, written, skipped, refusals: [] };
|
|
840
|
+
}
|