@prestack/thread-ui 0.1.8 → 0.1.10

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.
@@ -1,10 +1,11 @@
1
1
  import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from '@prestack/thread/integrations/disk';
2
2
  import { basename, dirname, join, relative } from 'node:path';
3
3
  import { argv, stdout } from 'node:process';
4
+ import ts from 'typescript';
4
5
  import { SHAPED } from '@prestack/thread/core/init';
5
6
  import { exit, invoked } from '@prestack/thread/core/invoked';
6
7
  import { quoted, valuesIn } from '@prestack/thread/core/rows';
7
- import { named as packageOf } from '@prestack/thread/core/scope';
8
+ import { THING, named as packageOf } from '@prestack/thread/core/scope';
8
9
  import { findTree, inTree } from '@prestack/thread/core/tree';
9
10
  import { COMPILED } from '@prestack/thread/operations/capabilities';
10
11
  import { compiled } from './generators/react.js';
@@ -156,7 +157,21 @@ export function settled(root, rows, checking, by, kept) {
156
157
  return said;
157
158
  }
158
159
  const ORG = packageOf('ui').split('/')[0] ?? '';
159
- const REACHED = /^(?:import|export)\s+(type\s+)?(?:(?:[^\n]|\n(?=[ \t}]))*?\s)?from\s*['"]([^'"]+)['"]/gm;
160
+ const CORE = packageOf('core');
161
+ const FROM_CORE = `${COMPILED}/${THING}/`;
162
+ export function reachedIn(at, text) {
163
+ const file = ts.createSourceFile(at, text, ts.ScriptTarget.Latest, false, at.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
164
+ const found = [];
165
+ for (const one of file.statements) {
166
+ if (!ts.isImportDeclaration(one) && !ts.isExportDeclaration(one))
167
+ continue;
168
+ const said = one.moduleSpecifier;
169
+ if (said === undefined || !ts.isStringLiteral(said))
170
+ continue;
171
+ found.push({ from: said.text, typed: ts.isImportDeclaration(one) ? one.importClause?.isTypeOnly === true : one.isTypeOnly });
172
+ }
173
+ return found;
174
+ }
160
175
  export const RUNTIME = ['wrappers.tsx', 'drag.ts', 'provider.tsx', 'islands/map.ts'];
161
176
  const NODE = /^node:/;
162
177
  function installs(root) {
@@ -182,6 +197,31 @@ export function emitting(root, entries, checking, kept) {
182
197
  ];
183
198
  }
184
199
  const seen = new Set();
200
+ const core = join(root, 'node_modules', CORE, 'dist');
201
+ const copied = (path) => {
202
+ const file = join(core, `${path}.ts`);
203
+ if (!existsSync(file))
204
+ return undefined;
205
+ const text = readFileSync(file, 'utf8');
206
+ if (reachedIn(path, text).length > 0)
207
+ return undefined;
208
+ const where = `${FROM_CORE}${path}.ts`;
209
+ if (seen.has(where))
210
+ return where;
211
+ seen.add(where);
212
+ kept.add(where);
213
+ const to = join(root, where);
214
+ if (checking) {
215
+ if (!existsSync(to) || readFileSync(to, 'utf8') !== text) {
216
+ said.push(`${where} is not what this app emits from ${CORE}. An emitted file that differs from its source is a hand ` +
217
+ 'edit wearing a generated name. Run `thread compositions` and commit what it writes.');
218
+ }
219
+ }
220
+ else {
221
+ writeFileSync(to, text, 'utf8');
222
+ }
223
+ return where;
224
+ };
185
225
  const walk = (at) => {
186
226
  if (seen.has(at))
187
227
  return;
@@ -194,19 +234,9 @@ export function emitting(root, entries, checking, kept) {
194
234
  }
195
235
  const text = readFileSync(file, 'utf8');
196
236
  const where = `${COMPILED}/ui/${at}`;
197
- kept.add(where);
198
- const to = join(root, where);
199
- if (checking) {
200
- if (!existsSync(to) || readFileSync(to, 'utf8') !== text) {
201
- said.push(`${where} is not what this app emits from ${packageOf('ui')}. An emitted file that differs from ` +
202
- 'its source is a hand edit wearing a generated name. Run `thread compositions` and commit what it writes.');
203
- }
204
- }
205
- else {
206
- writeFileSync(to, text, 'utf8');
207
- }
208
- for (const one of text.matchAll(REACHED)) {
209
- const named = one[2] ?? '';
237
+ let shipped = text;
238
+ for (const one of reachedIn(at, text)) {
239
+ const named = one.from;
210
240
  if (named.startsWith('.')) {
211
241
  const next = slashed(join(dirname(at), named));
212
242
  if (next.startsWith('..')) {
@@ -216,7 +246,7 @@ export function emitting(root, entries, checking, kept) {
216
246
  walk(next);
217
247
  continue;
218
248
  }
219
- if (one[1] !== undefined)
249
+ if (one.typed)
220
250
  continue;
221
251
  if (!named.startsWith(`${ORG}/`)) {
222
252
  const held = packaging(named);
@@ -227,9 +257,27 @@ export function emitting(root, entries, checking, kept) {
227
257
  `thread attach ${packageOf('ui')} runs.`);
228
258
  continue;
229
259
  }
260
+ const copy = named.startsWith(`${CORE}/`) ? copied(named.slice(CORE.length + 1)) : undefined;
261
+ if (copy !== undefined) {
262
+ const near = slashed(relative(dirname(where), copy));
263
+ const pointed = near.startsWith('.') ? near : `./${near}`;
264
+ shipped = shipped.split(`'${named}'`).join(`'${pointed}'`).split(`"${named}"`).join(`"${pointed}"`);
265
+ continue;
266
+ }
230
267
  said.push(`${at} reaches \`${named}\`, so emitting it would leave this app importing the framework while it runs. ` +
231
268
  'It reaches what is emitted beside it, or nothing of this framework at all.');
232
269
  }
270
+ kept.add(where);
271
+ const to = join(root, where);
272
+ if (checking) {
273
+ if (!existsSync(to) || readFileSync(to, 'utf8') !== shipped) {
274
+ said.push(`${where} is not what this app emits from ${packageOf('ui')}. An emitted file that differs from ` +
275
+ 'its source is a hand edit wearing a generated name. Run `thread compositions` and commit what it writes.');
276
+ }
277
+ }
278
+ else {
279
+ writeFileSync(to, shipped, 'utf8');
280
+ }
233
281
  };
234
282
  for (const one of entries)
235
283
  walk(one);
@@ -266,6 +314,11 @@ export const SCREENED_BY = 'thread screens';
266
314
  const SHIPS = `${COMPILED}/ui/`;
267
315
  const idFor = (at) => `compiled-${at.slice(COMPILED.length + 1).replace(/\.[^./]+$/, '').split('/').join('-')}`;
268
316
  function meaning(at, by) {
317
+ if (at.startsWith(FROM_CORE)) {
318
+ return ('`' + at.slice(FROM_CORE.length) + '` from ' + CORE + ', copied into this app beside the interface that imports it. ' +
319
+ 'It imports nothing itself, so a page runs it with nothing of the framework installed. `' + by + '` writes it, so an ' +
320
+ 'edit made here is refused.');
321
+ }
269
322
  if (at.startsWith(SHIPS)) {
270
323
  return ('`' + at.slice(SHIPS.length) + '` from ' + packageOf('ui') + ', copied into this app so a page runs it with nothing of ' +
271
324
  'the framework installed. `' + by + '` writes it, so an edit made here is refused.');
@@ -1,12 +1,13 @@
1
1
  import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from '@prestack/thread/integrations/disk'
2
2
  import { basename, dirname, join, relative } from 'node:path'
3
3
  import { argv, stdout } from 'node:process'
4
+ import ts from 'typescript'
4
5
 
5
6
  import { SHAPED } from '@prestack/thread/core/init'
6
7
  import { exit, invoked } from '@prestack/thread/core/invoked'
7
8
  import { quoted, valuesIn } from '@prestack/thread/core/rows'
8
9
  import type { Threaded } from '@prestack/thread/core/thread'
9
- import { named as packageOf } from '@prestack/thread/core/scope'
10
+ import { THING, named as packageOf } from '@prestack/thread/core/scope'
10
11
  import type { Tree } from '@prestack/thread/core/tree'
11
12
  import { findTree, inTree } from '@prestack/thread/core/tree'
12
13
  import { COMPILED } from '@prestack/thread/operations/capabilities'
@@ -208,7 +209,26 @@ export function settled(
208
209
 
209
210
  const ORG = packageOf('ui').split('/')[0] ?? ''
210
211
 
211
- const REACHED = /^(?:import|export)\s+(type\s+)?(?:(?:[^\n]|\n(?=[ \t}]))*?\s)?from\s*['"]([^'"]+)['"]/gm
212
+ const CORE = packageOf('core')
213
+
214
+ const FROM_CORE = `${COMPILED}/${THING}/`
215
+
216
+ interface Reached {
217
+ readonly from: string
218
+ readonly typed: boolean
219
+ }
220
+
221
+ export function reachedIn(at: string, text: string): readonly Reached[] {
222
+ const file = ts.createSourceFile(at, text, ts.ScriptTarget.Latest, false, at.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS)
223
+ const found: Reached[] = []
224
+ for (const one of file.statements) {
225
+ if (!ts.isImportDeclaration(one) && !ts.isExportDeclaration(one)) continue
226
+ const said = one.moduleSpecifier
227
+ if (said === undefined || !ts.isStringLiteral(said)) continue
228
+ found.push({ from: said.text, typed: ts.isImportDeclaration(one) ? one.importClause?.isTypeOnly === true : one.isTypeOnly })
229
+ }
230
+ return found
231
+ }
212
232
 
213
233
  export const RUNTIME: readonly string[] = ['wrappers.tsx', 'drag.ts', 'provider.tsx', 'islands/map.ts']
214
234
 
@@ -246,6 +266,29 @@ export function emitting(
246
266
  ]
247
267
  }
248
268
  const seen = new Set<string>()
269
+ const core = join(root, 'node_modules', CORE, 'dist')
270
+ const copied = (path: string): string | undefined => {
271
+ const file = join(core, `${path}.ts`)
272
+ if (!existsSync(file)) return undefined
273
+ const text = readFileSync(file, 'utf8')
274
+ if (reachedIn(path, text).length > 0) return undefined
275
+ const where = `${FROM_CORE}${path}.ts`
276
+ if (seen.has(where)) return where
277
+ seen.add(where)
278
+ kept.add(where)
279
+ const to = join(root, where)
280
+ if (checking) {
281
+ if (!existsSync(to) || readFileSync(to, 'utf8') !== text) {
282
+ said.push(
283
+ `${where} is not what this app emits from ${CORE}. An emitted file that differs from its source is a hand ` +
284
+ 'edit wearing a generated name. Run `thread compositions` and commit what it writes.',
285
+ )
286
+ }
287
+ } else {
288
+ writeFileSync(to, text, 'utf8')
289
+ }
290
+ return where
291
+ }
249
292
  const walk = (at: string): void => {
250
293
  if (seen.has(at)) return
251
294
  seen.add(at)
@@ -259,20 +302,9 @@ export function emitting(
259
302
  }
260
303
  const text = readFileSync(file, 'utf8')
261
304
  const where = `${COMPILED}/ui/${at}`
262
- kept.add(where)
263
- const to = join(root, where)
264
- if (checking) {
265
- if (!existsSync(to) || readFileSync(to, 'utf8') !== text) {
266
- said.push(
267
- `${where} is not what this app emits from ${packageOf('ui')}. An emitted file that differs from ` +
268
- 'its source is a hand edit wearing a generated name. Run `thread compositions` and commit what it writes.',
269
- )
270
- }
271
- } else {
272
- writeFileSync(to, text, 'utf8')
273
- }
274
- for (const one of text.matchAll(REACHED)) {
275
- const named = one[2] ?? ''
305
+ let shipped = text
306
+ for (const one of reachedIn(at, text)) {
307
+ const named = one.from
276
308
  if (named.startsWith('.')) {
277
309
  const next = slashed(join(dirname(at), named))
278
310
  if (next.startsWith('..')) {
@@ -282,7 +314,7 @@ export function emitting(
282
314
  walk(next)
283
315
  continue
284
316
  }
285
- if (one[1] !== undefined) continue
317
+ if (one.typed) continue
286
318
  if (!named.startsWith(`${ORG}/`)) {
287
319
  const held = packaging(named)
288
320
  if (NODE.test(named) || has.has(held)) continue
@@ -293,11 +325,30 @@ export function emitting(
293
325
  )
294
326
  continue
295
327
  }
328
+ const copy = named.startsWith(`${CORE}/`) ? copied(named.slice(CORE.length + 1)) : undefined
329
+ if (copy !== undefined) {
330
+ const near = slashed(relative(dirname(where), copy))
331
+ const pointed = near.startsWith('.') ? near : `./${near}`
332
+ shipped = shipped.split(`'${named}'`).join(`'${pointed}'`).split(`"${named}"`).join(`"${pointed}"`)
333
+ continue
334
+ }
296
335
  said.push(
297
336
  `${at} reaches \`${named}\`, so emitting it would leave this app importing the framework while it runs. ` +
298
337
  'It reaches what is emitted beside it, or nothing of this framework at all.',
299
338
  )
300
339
  }
340
+ kept.add(where)
341
+ const to = join(root, where)
342
+ if (checking) {
343
+ if (!existsSync(to) || readFileSync(to, 'utf8') !== shipped) {
344
+ said.push(
345
+ `${where} is not what this app emits from ${packageOf('ui')}. An emitted file that differs from ` +
346
+ 'its source is a hand edit wearing a generated name. Run `thread compositions` and commit what it writes.',
347
+ )
348
+ }
349
+ } else {
350
+ writeFileSync(to, shipped, 'utf8')
351
+ }
301
352
  }
302
353
  for (const one of entries) walk(one)
303
354
  return said
@@ -340,6 +391,13 @@ const SHIPS = `${COMPILED}/ui/`
340
391
  const idFor = (at: string): string => `compiled-${at.slice(COMPILED.length + 1).replace(/\.[^./]+$/, '').split('/').join('-')}`
341
392
 
342
393
  function meaning(at: string, by: string): string {
394
+ if (at.startsWith(FROM_CORE)) {
395
+ return (
396
+ '`' + at.slice(FROM_CORE.length) + '` from ' + CORE + ', copied into this app beside the interface that imports it. ' +
397
+ 'It imports nothing itself, so a page runs it with nothing of the framework installed. `' + by + '` writes it, so an ' +
398
+ 'edit made here is refused.'
399
+ )
400
+ }
343
401
  if (at.startsWith(SHIPS)) {
344
402
  return (
345
403
  '`' + at.slice(SHIPS.length) + '` from ' + packageOf('ui') + ', copied into this app so a page runs it with nothing of ' +
@@ -1,9 +1,9 @@
1
1
  import { releases, starts } from '@prestack/thread/integrations/cli';
2
2
  import { basename } from 'node:path';
3
- import { argv, execPath, stdout } from 'node:process';
4
- import { generators, settling } from '@prestack/thread/core/chain';
5
- import { existsSync } from '@prestack/thread/integrations/disk';
6
- import { CLI, exit, invoked } from '@prestack/thread/core/invoked';
3
+ import { argv, stdout } from 'node:process';
4
+ import { batched, generators, settling } from '@prestack/thread/core/chain';
5
+ import { existsSync, readFileSync } from '@prestack/thread/integrations/disk';
6
+ import { exit, invoked } from '@prestack/thread/core/invoked';
7
7
  import { named } from '@prestack/thread/core/scope';
8
8
  import { findTree } from '@prestack/thread/core/tree';
9
9
  import { programOf } from '@prestack/thread/integrations/target';
@@ -11,7 +11,7 @@ const SERVER = 'next';
11
11
  const SHIP = '--ship';
12
12
  const ENTRY = 'dev';
13
13
  const SHIPPED = 'build';
14
- const GATE = 'gate';
14
+ const GATE = 'thread gate';
15
15
  const say = (line) => {
16
16
  stdout.write(line);
17
17
  };
@@ -23,8 +23,8 @@ async function run() {
23
23
  const tree = findTree();
24
24
  const shipping = argv.includes(SHIP);
25
25
  const entry = shipping ? SHIPPED : ENTRY;
26
- const program = programOf(SERVER, tree.at, existsSync);
27
- if (program === undefined) {
26
+ const found = programOf(SERVER, tree.at, existsSync, (path) => readFileSync(path, 'utf8'));
27
+ if (found === undefined) {
28
28
  say('thread: ' + unrunnable(SERVER) + '\n');
29
29
  return 1;
30
30
  }
@@ -38,16 +38,20 @@ async function run() {
38
38
  return 1;
39
39
  }
40
40
  say('thread: ' + basename(tree.at) + ' settled in ' + String(settled.rounds) + ' round(s); running the gate\n');
41
- const gated = starts(execPath, [...CLI, GATE], { at: tree.at, shows: true });
42
- if (!gated.ok) {
41
+ const gated = batched(tree.at, [GATE]);
42
+ for (const one of gated)
43
+ if (one.said !== '')
44
+ say(one.said + '\n');
45
+ if (!gated.every((one) => one.ok)) {
43
46
  say('thread: the gate refused this tree, and nothing is served over a red gate. What it refused is printed above.\n');
44
- return gated.code === 0 ? 1 : gated.code;
47
+ return 1;
45
48
  }
46
49
  say('thread: the gate passed; running ' + SERVER + ' ' + entry + '\n');
47
50
  const extra = argv.slice(2).filter((one) => one !== SHIP);
48
51
  if (!shipping)
49
52
  releases();
50
- const held = starts(program, [entry, ...extra], { at: tree.at, shows: true });
53
+ const [program = '', ...lead] = found;
54
+ const held = starts(program, [...lead, entry, ...extra], { at: tree.at, shows: true });
51
55
  if (!held.there) {
52
56
  say('thread: could not start ' + SERVER + ' ' + entry + ': ' + held.why + '\n');
53
57
  return 1;
@@ -1,10 +1,10 @@
1
1
  import { releases, starts } from '@prestack/thread/integrations/cli'
2
2
  import { basename } from 'node:path'
3
- import { argv, execPath, stdout } from 'node:process'
3
+ import { argv, stdout } from 'node:process'
4
4
 
5
- import { generators, settling } from '@prestack/thread/core/chain'
6
- import { existsSync } from '@prestack/thread/integrations/disk'
7
- import { CLI, exit, invoked } from '@prestack/thread/core/invoked'
5
+ import { batched, generators, settling } from '@prestack/thread/core/chain'
6
+ import { existsSync, readFileSync } from '@prestack/thread/integrations/disk'
7
+ import { exit, invoked } from '@prestack/thread/core/invoked'
8
8
  import { named } from '@prestack/thread/core/scope'
9
9
  import { findTree } from '@prestack/thread/core/tree'
10
10
  import { programOf } from '@prestack/thread/integrations/target'
@@ -17,7 +17,7 @@ const ENTRY = 'dev'
17
17
 
18
18
  const SHIPPED = 'build'
19
19
 
20
- const GATE = 'gate'
20
+ const GATE = 'thread gate'
21
21
 
22
22
  const say = (line: string): void => {
23
23
  stdout.write(line)
@@ -35,8 +35,8 @@ async function run(): Promise<number> {
35
35
  const shipping = argv.includes(SHIP)
36
36
  const entry = shipping ? SHIPPED : ENTRY
37
37
 
38
- const program = programOf(SERVER, tree.at, existsSync)
39
- if (program === undefined) {
38
+ const found = programOf(SERVER, tree.at, existsSync, (path) => readFileSync(path, 'utf8'))
39
+ if (found === undefined) {
40
40
  say('thread: ' + unrunnable(SERVER) + '\n')
41
41
  return 1
42
42
  }
@@ -53,16 +53,18 @@ async function run(): Promise<number> {
53
53
  }
54
54
 
55
55
  say('thread: ' + basename(tree.at) + ' settled in ' + String(settled.rounds) + ' round(s); running the gate\n')
56
- const gated = starts(execPath, [...CLI, GATE], { at: tree.at, shows: true })
57
- if (!gated.ok) {
56
+ const gated = batched(tree.at, [GATE])
57
+ for (const one of gated) if (one.said !== '') say(one.said + '\n')
58
+ if (!gated.every((one) => one.ok)) {
58
59
  say('thread: the gate refused this tree, and nothing is served over a red gate. What it refused is printed above.\n')
59
- return gated.code === 0 ? 1 : gated.code
60
+ return 1
60
61
  }
61
62
 
62
63
  say('thread: the gate passed; running ' + SERVER + ' ' + entry + '\n')
63
64
  const extra = argv.slice(2).filter((one) => one !== SHIP)
64
65
  if (!shipping) releases()
65
- const held = starts(program, [entry, ...extra], { at: tree.at, shows: true })
66
+ const [program = '', ...lead] = found
67
+ const held = starts(program, [...lead, entry, ...extra], { at: tree.at, shows: true })
66
68
  if (!held.there) {
67
69
  say('thread: could not start ' + SERVER + ' ' + entry + ': ' + held.why + '\n')
68
70
  return 1
@@ -1,12 +1,13 @@
1
1
  import { dirname, join, relative } from 'node:path';
2
2
  import { argv, stderr, stdout } from 'node:process';
3
3
  import ts from 'typescript';
4
- import { existsSync, readFileSync, realpathSync, writeFileSync } from '@prestack/thread/integrations/disk';
4
+ import { existsSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from '@prestack/thread/integrations/disk';
5
5
  import { exit, invoked } from '@prestack/thread/core/invoked';
6
- import { valuesIn } from '@prestack/thread/core/rows';
6
+ import { listBounds, nestedBounds, rowsIn, valuesIn, withField, withRow, withoutField, withoutRow } from '@prestack/thread/core/rows';
7
7
  import { named as packageOf } from '@prestack/thread/core/scope';
8
8
  import { findTree, inTree } from '@prestack/thread/core/tree';
9
9
  import { PERMITS_AT } from '@prestack/thread/operations/access';
10
+ import { said } from '@prestack/thread/operations/attach';
10
11
  import { ACTOR_AT, COMPILED } from '@prestack/thread/operations/capabilities';
11
12
  import { compiledAt, nameOf as compositionOf } from '../compositions.js';
12
13
  import { PRESENTATIONS } from '../door.js';
@@ -21,6 +22,11 @@ const MAP = `app/_generated/map.${'ts'}`;
21
22
  const PROVIDER = `${COMPILED}/ui/provider.tsx`;
22
23
  const GRAMMAR = `${COMPILED}/ui/islands/map.ts`;
23
24
  const MOVES = 'navigateTo';
25
+ const PAGE_ROW = 'page';
26
+ const WRITER = 'thread routes';
27
+ const PAGE_SAID = 'The page a browser gets at the address of this screen: `/` when it is the first page this app declares, and its own ' +
28
+ 'name otherwise. `thread routes` writes the page and this row from where the screen sits, and moves or deletes both ' +
29
+ 'when that changes.';
24
30
  function served(at) {
25
31
  try {
26
32
  const held = JSON.parse(readFileSync(join(at, 'package.json'), 'utf8'));
@@ -316,6 +322,40 @@ function page(name, at, route) {
316
322
  '',
317
323
  ].join('\n');
318
324
  }
325
+ const pageShaped = (path) => path === pageAt(`/${path.split('/').slice(1, -1).join('/')}`);
326
+ function unpaged(one) {
327
+ if (one.where === undefined) {
328
+ return (`\`${one.id}\` is presented as a ${one.shown}, so it has no page, and its row still derives ${String(one.was)}. ` +
329
+ `Run \`${WRITER}\`, which takes that row out and deletes the page.`);
330
+ }
331
+ return (`\`${one.id}\` answers ${String(one.route)}, so its page is ${one.where}, and its row ` +
332
+ `${one.was === undefined ? 'derives no page' : `says ${one.was}`}. Run \`${WRITER}\`, which writes that row from ` +
333
+ 'where the screen now sits and puts the page there.');
334
+ }
335
+ function repaged(text, moved) {
336
+ let next = text;
337
+ for (const one of moved) {
338
+ const list = listBounds(next, 'declares');
339
+ const inside = list === undefined ? undefined : nestedBounds(next, list, one.id, 'derives');
340
+ if (inside !== undefined)
341
+ next = withoutRow(next, inside, PAGE_ROW);
342
+ const rows = listBounds(next, 'declares');
343
+ if (rows === undefined)
344
+ return undefined;
345
+ const within = nestedBounds(next, rows, one.id, 'derives');
346
+ if (one.where === undefined) {
347
+ if (within !== undefined && rowsIn(next, within).length === 0)
348
+ next = withoutField(next, rows, one.id, 'derives');
349
+ continue;
350
+ }
351
+ const row = said({ id: PAGE_ROW, at: one.where, describes: PAGE_SAID, version: 1, by: WRITER, runs: `${WRITER} --check` }, ' ');
352
+ const put = within === undefined ? withField(next, rows, one.id, 'derives', `[\n${row}\n ]`) : withRow(next, within, row);
353
+ if (put === undefined)
354
+ return undefined;
355
+ next = put;
356
+ }
357
+ return next;
358
+ }
319
359
  function gate(typed) {
320
360
  const said = typed ? ' as { of?: Record<string, string> }' : '';
321
361
  return [
@@ -532,6 +572,7 @@ async function run() {
532
572
  const shownOf = (id) => presentedIn(texts.get(id) ?? '');
533
573
  const entry = entryAmong(rows, shownOf);
534
574
  const made = [];
575
+ const moved = [];
535
576
  const routes = new Map();
536
577
  const screens = [];
537
578
  const reaches = reachable(at);
@@ -541,7 +582,7 @@ async function run() {
541
582
  if (typed !== undefined) {
542
583
  stderr.write(`routes: ${row.at} states \`route: '${typed}'\`, and a route is never typed: the first page an app declares ` +
543
584
  'answers `/`, every other page answers its own name, and a sheet has no route. Take `route:` out of the ' +
544
- 'file — `thread create screen edit` rewrites it without one when it is given the body.\n');
585
+ `file with \`thread edit ${row.at} --find <text> --replace <text>\`, which changes that one stretch of it.\n`);
545
586
  return 1;
546
587
  }
547
588
  const name = nameOf(row.at);
@@ -576,18 +617,19 @@ async function run() {
576
617
  entry: row.id === entry,
577
618
  shows: [...shows],
578
619
  });
579
- if (route === undefined)
620
+ const where = route === undefined ? undefined : pageAt(route);
621
+ const was = (row.derives ?? []).find((one) => one.id === PAGE_ROW)?.at;
622
+ if (was !== where)
623
+ moved.push({ id: row.id, shown: shownAs, route, was, where });
624
+ if (route === undefined || where === undefined)
580
625
  continue;
581
- const where = pageAt(route);
582
- const stated = (row.derives ?? []).find((one) => one.id === 'page')?.at;
583
- if (stated !== where) {
584
- stderr.write(`routes: \`${row.id}\` answers ${route}, so its page is ${where}, and its row ` +
585
- `${stated === undefined ? 'derives no page' : `says ${stated}`}. Run \`thread create screen edit\` on it — ` +
586
- 'the row is rewritten from where the screen now lives.\n');
587
- return 1;
588
- }
589
626
  made.push({ path: where, text: page(name, row.at, route) });
590
627
  }
628
+ if (moved.length > 0 && argv.includes('--check')) {
629
+ for (const one of moved)
630
+ stderr.write(`routes: ${unpaged(one)}\n`);
631
+ return 1;
632
+ }
591
633
  const { links, wrong } = linksOf(at, rows, compositions, routes, wordingFirst(keyProps(at)));
592
634
  if (wrong.length > 0) {
593
635
  for (const one of wrong)
@@ -611,6 +653,30 @@ async function run() {
611
653
  });
612
654
  const paged = map.screens.filter((one) => one.path !== undefined).length;
613
655
  const counted = `${String(map.screens.length)} screen(s), ${String(paged)} route(s), ${String(map.links.length)} link(s)`;
656
+ if (moved.length > 0) {
657
+ const declaredIn = relative(at, screensAt);
658
+ const rewritten = repaged(readFileSync(screensAt, 'utf8'), moved);
659
+ if (rewritten === undefined) {
660
+ stderr.write(`routes: ${declaredIn} could not be given the page row of each screen, so nothing was written. ` +
661
+ `\`thread read ${declaredIn}\` prints what it states now.\n`);
662
+ return 1;
663
+ }
664
+ writeFileSync(screensAt, rewritten, 'utf8');
665
+ const answered = new Set(made.map((one) => one.path));
666
+ const home = dirname(join(at, pageAt('/')));
667
+ for (const one of moved) {
668
+ stdout.write(one.where === undefined
669
+ ? `routes: \`${one.id}\` has no route, so its row no longer derives ${String(one.was)}\n`
670
+ : `routes: \`${one.id}\` answers ${String(one.route)}, so its row now derives ${one.where}\n`);
671
+ if (one.was === undefined || answered.has(one.was) || !pageShaped(one.was) || !existsSync(join(at, one.was)))
672
+ continue;
673
+ rmSync(join(at, one.was));
674
+ const folder = dirname(join(at, one.was));
675
+ if (folder !== home && readdirSync(folder).length === 0)
676
+ rmSync(folder, { recursive: true, force: true });
677
+ stdout.write(`routes: deleted ${one.was}, because no screen answers there any more\n`);
678
+ }
679
+ }
614
680
  const refused = landed(at, made);
615
681
  if (refused !== undefined)
616
682
  return refused;