@ape-egg/codecane 1.0.0 → 1.1.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/core/build.js +5 -2
- package/core/cli.js +30 -6
- package/package.json +2 -2
- package/ui/codecane.css +30 -10
- package/ui/components/Story.html +32 -12
- package/ui/viewer.js +3 -2
- package/workflow.md +1 -1
package/core/build.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
1
2
|
import { cp, mkdir, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'
|
|
2
3
|
import { dirname, join } from 'node:path'
|
|
3
4
|
|
|
@@ -12,8 +13,10 @@ const listed = async dir => (await readdir(dir, { recursive: true, withFileTypes
|
|
|
12
13
|
.map(entry => join(entry.parentPath, entry.name).slice(dir.length + 1))
|
|
13
14
|
|
|
14
15
|
export const written = async (out, files) => Promise.all(Object.entries(files).map(async ([path, body]) => {
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
const file = join(out, path)
|
|
17
|
+
if (existsSync(file) && (await readFile(file)).equals(Buffer.from(body))) return
|
|
18
|
+
await mkdir(dirname(file), { recursive: true })
|
|
19
|
+
await writeFile(file, body)
|
|
17
20
|
}))
|
|
18
21
|
|
|
19
22
|
const viewerFiles = async ({ dir }, ids) => {
|
package/core/cli.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import {
|
|
2
|
+
import { watch } from 'node:fs'
|
|
3
|
+
import { readFile, rm } from 'node:fs/promises'
|
|
3
4
|
import { join, resolve } from 'node:path'
|
|
4
5
|
import { parseArgs } from 'node:util'
|
|
5
6
|
|
|
@@ -17,6 +18,7 @@ codecane dev [project] [--port n] serve the viewer and every stacked st
|
|
|
17
18
|
codecane build [project] [--out dir] stack every step into dist/, write its data and the viewer, and check that every step runs
|
|
18
19
|
codecane build [project] --bare the same without the viewer
|
|
19
20
|
codecane stack <address> [project] --out dir write one step on one timeline, <forks-step>@<fork-step>@<step>
|
|
21
|
+
codecane stack <address> [project] --out dir --watch the same, and write it again as steps/ changes, touching only the files that changed
|
|
20
22
|
codecane agents print the codecane workflow for a project's AGENTS.md`
|
|
21
23
|
|
|
22
24
|
const root = resolve(import.meta.dirname, '..')
|
|
@@ -25,7 +27,7 @@ const viewer = { dir: join(root, 'ui'), modules: Object.keys(dependencies) }
|
|
|
25
27
|
|
|
26
28
|
const { positionals: [command, ...rest], values } = parseArgs({
|
|
27
29
|
allowPositionals: true,
|
|
28
|
-
options: { port: { type: 'string' }, out: { type: 'string' }, bare: { type: 'boolean' }, project: { type: 'string' } },
|
|
30
|
+
options: { port: { type: 'string' }, out: { type: 'string' }, bare: { type: 'boolean' }, watch: { type: 'boolean' }, project: { type: 'string' } },
|
|
29
31
|
})
|
|
30
32
|
|
|
31
33
|
const stamped = async (project = '.') => {
|
|
@@ -75,10 +77,32 @@ const commands = {
|
|
|
75
77
|
process.exitCode = Object.keys(failures).length ? 1 : 0
|
|
76
78
|
},
|
|
77
79
|
stack: async ([address, project = '.']) => {
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
const out = resolve(values.out)
|
|
81
|
+
const stackedFiles = async () => {
|
|
82
|
+
const steps = await stamped(project)
|
|
83
|
+
const { id, through } = locate(steps, address)
|
|
84
|
+
return stack(steps, id, through).files
|
|
85
|
+
}
|
|
86
|
+
let files = await stackedFiles()
|
|
87
|
+
await written(out, files)
|
|
88
|
+
console.info(`step ${address} stacked into ${out}`)
|
|
89
|
+
if (!values.watch) return
|
|
90
|
+
const restacked = async () => {
|
|
91
|
+
const next = await stackedFiles()
|
|
92
|
+
const changed = Object.keys(next).filter(path => !(path in files) || !Buffer.from(files[path]).equals(Buffer.from(next[path])))
|
|
93
|
+
const gone = Object.keys(files).filter(path => !(path in next))
|
|
94
|
+
await written(out, Object.fromEntries(changed.map(path => [path, next[path]])))
|
|
95
|
+
await Promise.all(gone.map(path => rm(join(out, path))))
|
|
96
|
+
files = next
|
|
97
|
+
if (changed.length || gone.length) console.info(`step ${address} stacked into ${out} again: ${[...changed, ...gone].join(', ')}`)
|
|
98
|
+
}
|
|
99
|
+
let settling
|
|
100
|
+
let queue = Promise.resolve()
|
|
101
|
+
watch(resolve(project), { recursive: true }, (_, path) => {
|
|
102
|
+
if (!/^(steps|parts)\//.test(path ?? '')) return
|
|
103
|
+
clearTimeout(settling)
|
|
104
|
+
settling = setTimeout(() => queue = queue.then(restacked).catch(error => console.error(error.message)), 300)
|
|
105
|
+
})
|
|
82
106
|
},
|
|
83
107
|
agents: async () => console.info(await readFile(join(root, 'workflow.md'), 'utf8')),
|
|
84
108
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ape-egg/codecane",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Build a project up step by step: add-only step folders stacked into runnable steps, with a timeline viewer",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "ISC",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"README.md"
|
|
25
25
|
],
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@ape-egg/codie": "^0.
|
|
27
|
+
"@ape-egg/codie": "^0.2.0",
|
|
28
28
|
"@ape-egg/vibe": "^4.3.17"
|
|
29
29
|
},
|
|
30
30
|
"publishConfig": {
|
package/ui/codecane.css
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
body {
|
|
18
18
|
display: grid;
|
|
19
|
-
grid-template
|
|
19
|
+
grid-template: auto minmax(0, 1fr) / minmax(0, 1fr);
|
|
20
20
|
height: 100dvh;
|
|
21
21
|
margin: 0;
|
|
22
22
|
overflow: hidden;
|
|
@@ -113,10 +113,18 @@ header ol {
|
|
|
113
113
|
min-width: 0;
|
|
114
114
|
margin: 0 1.5rem;
|
|
115
115
|
padding: 0.75rem 0;
|
|
116
|
-
overflow
|
|
116
|
+
overflow: auto hidden;
|
|
117
117
|
list-style: none;
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
header[stylecheat] ol::-webkit-scrollbar {
|
|
121
|
+
height: var(--unit);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
header[stylecheat] ol::-webkit-scrollbar-thumb {
|
|
125
|
+
background-color: var(--dim);
|
|
126
|
+
}
|
|
127
|
+
|
|
120
128
|
header li {
|
|
121
129
|
display: flex;
|
|
122
130
|
align-self: center;
|
|
@@ -248,11 +256,28 @@ story-panel {
|
|
|
248
256
|
border-right: 1px solid var(--line);
|
|
249
257
|
}
|
|
250
258
|
|
|
259
|
+
story-panel [stylecheat] {
|
|
260
|
+
background: none;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
panel-tabs {
|
|
264
|
+
padding: 1.25rem 1.25rem 0;
|
|
265
|
+
}
|
|
266
|
+
|
|
251
267
|
article,
|
|
252
|
-
file-list
|
|
268
|
+
file-list,
|
|
269
|
+
footer {
|
|
253
270
|
padding: 1.25rem;
|
|
254
271
|
}
|
|
255
272
|
|
|
273
|
+
footer {
|
|
274
|
+
position: sticky;
|
|
275
|
+
bottom: 0;
|
|
276
|
+
margin-top: auto;
|
|
277
|
+
border-top: 1px solid var(--line);
|
|
278
|
+
background: inherit;
|
|
279
|
+
}
|
|
280
|
+
|
|
256
281
|
article small {
|
|
257
282
|
color: var(--dim);
|
|
258
283
|
}
|
|
@@ -308,7 +333,6 @@ step-links {
|
|
|
308
333
|
display: flex;
|
|
309
334
|
justify-content: space-between;
|
|
310
335
|
gap: 1rem;
|
|
311
|
-
margin-top: 1.25rem;
|
|
312
336
|
color: var(--dim);
|
|
313
337
|
}
|
|
314
338
|
|
|
@@ -326,7 +350,6 @@ step-links a[kind="seam"] {
|
|
|
326
350
|
|
|
327
351
|
file-list {
|
|
328
352
|
flex: 1;
|
|
329
|
-
border-top: 1px solid var(--line);
|
|
330
353
|
}
|
|
331
354
|
|
|
332
355
|
file {
|
|
@@ -481,11 +504,8 @@ changes-list {
|
|
|
481
504
|
border-left: 1px solid var(--line);
|
|
482
505
|
}
|
|
483
506
|
|
|
484
|
-
:is(file-list, changes-list) > h2 {
|
|
485
|
-
font-size: 0.875rem;
|
|
486
|
-
}
|
|
487
|
-
|
|
488
507
|
changes-list > h2 {
|
|
508
|
+
font-size: 0.875rem;
|
|
489
509
|
padding: 1.25rem 1.25rem 0.75rem;
|
|
490
510
|
}
|
|
491
511
|
|
|
@@ -516,7 +536,7 @@ change-header code {
|
|
|
516
536
|
text-overflow: ellipsis;
|
|
517
537
|
}
|
|
518
538
|
|
|
519
|
-
change-diff codie {
|
|
539
|
+
change-diff codie[dark] {
|
|
520
540
|
font-size: 0.75rem;
|
|
521
541
|
--codie-bg: transparent;
|
|
522
542
|
}
|
package/ui/components/Story.html
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
<story-panel ontransitionend="event.target === this && codecane.returned()">
|
|
2
|
+
<panel-tabs stylecheat dark>
|
|
3
|
+
<tabs primary>
|
|
4
|
+
<tab active="@[tab === 'about']" onclick="$.tab = 'about'">About</tab>
|
|
5
|
+
<tab active="@[tab === 'files']" onclick="$.tab = 'files'">Files</tab>
|
|
6
|
+
</tabs>
|
|
7
|
+
</panel-tabs>
|
|
8
|
+
<!-- if tab === 'about' -->
|
|
2
9
|
<article>
|
|
3
|
-
<
|
|
10
|
+
<step-status stylecheat dark>
|
|
11
|
+
<crow justify-between>
|
|
12
|
+
<small>Step @[timeline.indexOf(page.id) + 1] of @[timeline.length]</small>
|
|
13
|
+
<!-- if page.untested -->
|
|
14
|
+
<badge bg-slate text-slate>Untested</badge>
|
|
15
|
+
<!-- /if -->
|
|
16
|
+
</crow>
|
|
17
|
+
</step-status>
|
|
4
18
|
<!-- if page.convergence && page.convergence.kind === 'seam' -->
|
|
5
19
|
<story><h1>@[page.title]</h1></story>
|
|
6
20
|
<!-- else -->
|
|
@@ -43,19 +57,9 @@
|
|
|
43
57
|
<!-- /each -->
|
|
44
58
|
</alternatives>
|
|
45
59
|
<!-- /if -->
|
|
46
|
-
<step-links>
|
|
47
|
-
<!-- if previous -->
|
|
48
|
-
<a href="/@[previous.address]">← @[previous.title]</a>
|
|
49
|
-
<!-- /if -->
|
|
50
|
-
<!-- if next -->
|
|
51
|
-
<a href="/@[next.address]" next kind="@[next.kind]">@[next.title] →</a>
|
|
52
|
-
<!-- else -->
|
|
53
|
-
<span>Final build</span>
|
|
54
|
-
<!-- /if -->
|
|
55
|
-
</step-links>
|
|
56
60
|
</article>
|
|
61
|
+
<!-- else -->
|
|
57
62
|
<file-list stylecheat dark>
|
|
58
|
-
<h2>Files</h2>
|
|
59
63
|
<!-- each [...new Set(page.files.map(file => file.folder))] as folder (page.id + folder) -->
|
|
60
64
|
<!-- if folder -->
|
|
61
65
|
<folder>@[folder]</folder>
|
|
@@ -82,4 +86,20 @@
|
|
|
82
86
|
<!-- /each -->
|
|
83
87
|
<!-- /if -->
|
|
84
88
|
</file-list>
|
|
89
|
+
<!-- /if -->
|
|
90
|
+
<footer>
|
|
91
|
+
<!-- if page.ahead -->
|
|
92
|
+
<story>@[$.unsafe(page.ahead)]</story>
|
|
93
|
+
<!-- /if -->
|
|
94
|
+
<step-links>
|
|
95
|
+
<!-- if previous -->
|
|
96
|
+
<a href="/@[previous.address]" kind="@[previous.kind]">← @[previous.title]</a>
|
|
97
|
+
<!-- /if -->
|
|
98
|
+
<!-- if next -->
|
|
99
|
+
<a href="/@[next.address]" next kind="@[next.kind]">@[next.title] →</a>
|
|
100
|
+
<!-- else -->
|
|
101
|
+
<span>Final build</span>
|
|
102
|
+
<!-- /if -->
|
|
103
|
+
</step-links>
|
|
104
|
+
</footer>
|
|
85
105
|
</story-panel>
|
package/ui/viewer.js
CHANGED
|
@@ -34,7 +34,8 @@ const rootFirst = (a, b) => a.path.includes('/') - b.path.includes('/')
|
|
|
34
34
|
const opened = async stacked => {
|
|
35
35
|
const address = new URL(steps()[idOf(stacked)].data[stacked], source)
|
|
36
36
|
const step = await (loaded[stacked] ??= fetched(address))
|
|
37
|
-
|
|
37
|
+
const [story, ahead = ''] = step.story.split(/<hr\s*\/?>(?![\s\S]*<hr)/)
|
|
38
|
+
return { ...step, story, ahead, files: step.files.map(file => ({ ...file, folder: folderOf(file.path) })).sort(rootFirst), base: new URL(step.folder, address).href, texts: Object.fromEntries(step.changes.map(change => [change.path, change.after])) }
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
const running = page => page.files.some(file => file.path === 'index.html') ? `${page.base}index.html?revision=${digest(page.files.map(file => file.hash).join())}` : ''
|
|
@@ -144,7 +145,7 @@ const reports = {
|
|
|
144
145
|
addEventListener('message', ({ source, data }) => source === document.querySelector('stage iframe')?.contentWindow && reports[data.codecane]?.(data))
|
|
145
146
|
|
|
146
147
|
const first = placed(addressAt(location.pathname))
|
|
147
|
-
window.$ = vibe({ name: index.name, version: index.version, editable: index.editable, kinds: { merge: 'Merge', seam: 'Seam' }, through: null, panel: 50, dragging: false, closing: false, fullscreen: false, returning: false, root: atRoot(location.pathname), ...shown(await opened(first.address), first.lane) })
|
|
148
|
+
window.$ = vibe({ name: index.name, version: index.version, editable: index.editable, kinds: { merge: 'Merge', seam: 'Seam' }, tab: 'about', through: null, panel: 50, dragging: false, closing: false, fullscreen: false, returning: false, root: atRoot(location.pathname), ...shown(await opened(first.address), first.lane) })
|
|
148
149
|
const titled = address => document.title = `${index.name} · ${steps()[idOf(address)].title}`
|
|
149
150
|
titled(first.address)
|
|
150
151
|
|
package/workflow.md
CHANGED
|
@@ -78,4 +78,4 @@ A step's kind is the result of its last replay:
|
|
|
78
78
|
- `codecane patch <id> [path…]`: turns a step's copies of earlier files into patches.
|
|
79
79
|
- `codecane dev`: the viewer at `/`, every stacked step at `/steps/<id>/`, live from `steps/`. It watches `steps/`, turns copies into patches, rebases later patches and replays from each changed step forward, and the open viewer updates in place without reloading. Saving in the viewer writes to the step that last changed the file.
|
|
80
80
|
- `codecane build`: writes `dist/` (every stacked step, its JSON data and the viewer) and checks that every step runs. `--bare` leaves out the viewer.
|
|
81
|
-
- `codecane stack <address> --out <dir>`: writes one step on one timeline, such as `moving-the-center/05adc6a`; `codecane stack moving-the-center` writes it with the default forks.
|
|
81
|
+
- `codecane stack <address> --out <dir>`: writes one step on one timeline, such as `moving-the-center/05adc6a`; `codecane stack moving-the-center` writes it with the default forks. `--watch` writes it again whenever `steps/` or `parts/` changes, touching only the files whose stacked text changed, so a watcher such as `bun --watch` on the stacked folder restarts only when the stacked code really changed.
|