@erclx/canon 5.18.0 → 5.19.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/README.md +1 -1
- package/claude/.claude-plugin/plugin.json +1 -1
- package/docs/agents/commands.md +1 -0
- package/docs/agents/teach.md +7 -1
- package/package.json +2 -1
- package/src/commands/slides.ts +73 -4
- package/src/commands/teach.ts +112 -4
- package/src/serve/live.ts +113 -0
- package/src/serve/static.ts +60 -2
- package/src/slides/convert/export.ts +323 -0
- package/src/slides/convert/shapes.ts +764 -0
- package/src/slides/convert/svg.ts +38 -0
- package/src/slides/convert/walk.ts +444 -0
- package/src/teach/browser/reload.ts +9 -0
- package/src/teach/nav.ts +7 -6
- package/src/teach/workspace.ts +2 -1
- package/src/teach/write-if-changed.ts +17 -0
package/README.md
CHANGED
|
@@ -68,7 +68,7 @@ Each domain has a canonical source in this repo and a thin install or sync CLI o
|
|
|
68
68
|
| Standards | Authoring conventions for commits, branches, plans, tasks, and markdown | Opened by name, read by name with `canon standards <name>` |
|
|
69
69
|
| Tooling stacks | Golden configs, seeds, and a reference per framework | Laid down by `canon init`, reconciled by `canon tooling sync` |
|
|
70
70
|
| Design system | Ships a `DESIGN.md` token format, a skill that drafts one from an existing project or from scratch, and a render command | `canon design render` |
|
|
71
|
-
| Slides | A `SLIDES.md` source format with a layout catalog
|
|
71
|
+
| Slides | A `SLIDES.md` source format with a layout catalog, or a folder of HTML slides rebuilt as editable shapes | `canon slides render` writes PowerPoint |
|
|
72
72
|
| Teach | Lessons, sources, and a glossary in one workspace, rendered to a small site | `canon teach` |
|
|
73
73
|
| Canvas | Pages of HTML frames on a local pan and zoom surface, each frame drawn with the project's tokens | `canon canvas serve` |
|
|
74
74
|
| Transcripts | A YouTube transcript with metadata frontmatter | `canon transcripts <url>` writes it into any repo |
|
package/docs/agents/commands.md
CHANGED
|
@@ -129,6 +129,7 @@ Subcommands no other page describes:
|
|
|
129
129
|
- `claude skills rank` scores either skill corpus's descriptions against a case corpus by TF-IDF cosine similarity, reporting rank-one and top-three (`--cases <path>`).
|
|
130
130
|
- `design regen` rewrites this repository's `canon/DESIGN.md` from `src/design/tokens.ts` and `src/design/base.css` from `src/design/neutral.ts`. `design css` emits the tokens and components as CSS on stdout, with `--no-components` for properties alone, and `design render` declares each vendored face the typography table names under `--embed-fonts`.
|
|
131
131
|
- `slides render` renders a `.claude/SLIDES.md` source into a PowerPoint deck and reports any unrecognized layout name on stderr, and `slides list --json` emits the layout catalog.
|
|
132
|
+
- `slides render --source <folder>` lays out each `.html` file in filename order in Chromium at 1280 by 720 and rebuilds it as editable shapes. Each element drawn as a picture prints one `✗` line on stderr naming the slide, the selector, and the properties that forced it. A link left out, being a `#slide-N` past the deck's end or any target that is neither that nor an absolute URL, prints one too, and the exit stays 0.
|
|
132
133
|
- `canvas`, with its verbs, content format, and write routes, is described in `canvas.md`.
|
|
133
134
|
- `labels scan` fails a pull request or a posted review whose title, body, or review comment carries a phase label, a label a code span quotes, a gitignored record path, a session link, a title word no dictionary holds, or a title breaking `standards/pr.md`'s format, casing, or length rule (`--event`, `--body-file`, `--json`).
|
|
134
135
|
- `repo metadata propose` compares a description, homepage, and topic set computed from the README and `package.json` against what the remote carries, writing nothing, and `repo metadata apply` writes an explicitly supplied set through `gh repo edit` (`--description`, `--homepage`, `--topics`).
|
package/docs/agents/teach.md
CHANGED
|
@@ -126,9 +126,15 @@ The link opens the named workspace's contents page, or the root listing when no
|
|
|
126
126
|
|
|
127
127
|
The JSON record is one line carrying the `nav` fields, then `served`, the absolute folder, and the server's `host`, `port`, `entry`, `url`, and `entryExists`. A lesson `nav` skips lands in `skipped` and the server still starts, since a partial refresh is still worth viewing. A `nav` refusal starts no server.
|
|
128
128
|
|
|
129
|
+
While it runs, `up` watches the teach root. A change there, such as a lesson, a workspace, a glossary term, or a reference page added by hand or by another `teach` verb, runs the `nav` pass again, and every open page reloads once that pass changes what is served. Passes run one at a time, so a burst of saves costs one more pass rather than several at once. A pass `nav` refuses, such as one over a workspace deleted while it was served, is reported on stderr, the server keeps running, and no page reloads.
|
|
130
|
+
|
|
131
|
+
Each refresh is logged to stderr, so stdout stays the one record line. Dotfiles and editor backups ending in `~` start no pass.
|
|
132
|
+
|
|
133
|
+
The reload script is added to each page as the server sends it and never written to disk, so a page saved from the browser, a promoted lesson, and a committed fixture carry no trace of it. The script listens on `/__live`, which answers only a request naming the server by its loopback address or `localhost`.
|
|
134
|
+
|
|
129
135
|
Viewing writes. Every page and generated stylesheet `nav` owns is rewritten first, which on a committed fixture shows as a diff. To serve without rewriting anything, `canon teach list <topic>` ends its human output with a `canon serve` line for that workspace, with the teach folder written relative to the cwd, or absolute when it sits outside it. The JSON record of `list` carries no such field.
|
|
130
136
|
|
|
131
|
-
`canon serve` itself stays general, since nothing about serving a directory is specific to a learning workspace and the same verb carries a slide render and a design preview. What `up` adds in front of it is the refresh,
|
|
137
|
+
`canon serve` itself stays general, since nothing about serving a directory is specific to a learning workspace and the same verb carries a slide render and a design preview. What `up` adds in front of it is the refresh and the watch that repeats it, both teach-specific, and it serves through the same server rather than a second one.
|
|
132
138
|
|
|
133
139
|
Read `url` off the record rather than building one from the port that was asked for. The verb walks forward past a port already in use, which is routine when a second workspace is already open, and the port it took is the one thing a composed URL gets wrong.
|
|
134
140
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erclx/canon",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "5.
|
|
4
|
+
"version": "5.19.0",
|
|
5
5
|
"description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
"commander": "^13.1.0",
|
|
57
57
|
"execa": "^9.5.2",
|
|
58
58
|
"highlight.js": "^11.12.0",
|
|
59
|
+
"jszip": "^3.10.1",
|
|
59
60
|
"playwright-core": "1.59.1",
|
|
60
61
|
"pptxgenjs": "^4.0.1",
|
|
61
62
|
"preact": "^11.0.0"
|
package/src/commands/slides.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs'
|
|
1
|
+
import { existsSync, statSync } from 'node:fs'
|
|
2
2
|
import { resolve } from 'node:path'
|
|
3
3
|
import type { Command } from 'commander'
|
|
4
|
+
import { INSTALL_BROWSER } from '@/browser/engine'
|
|
4
5
|
import { creationRel, SCRATCH } from '@/record-root'
|
|
5
6
|
import { LAYOUTS } from '@/slides/layouts'
|
|
6
7
|
import { openDeck } from '@/slides/open'
|
|
@@ -17,8 +18,14 @@ export function register(program: Command): void {
|
|
|
17
18
|
slides
|
|
18
19
|
.command('render')
|
|
19
20
|
.helpOption('-h, --help', 'Show this help message')
|
|
20
|
-
.description(
|
|
21
|
-
|
|
21
|
+
.description(
|
|
22
|
+
'Render a SLIDES.md source, or a folder of HTML slides, into a PowerPoint deck',
|
|
23
|
+
)
|
|
24
|
+
.option(
|
|
25
|
+
'-s, --source <path>',
|
|
26
|
+
'Source SLIDES.md file, or a folder of .html slides read in filename order',
|
|
27
|
+
'.claude/SLIDES.md',
|
|
28
|
+
)
|
|
22
29
|
.option(
|
|
23
30
|
'-o, --out <path>',
|
|
24
31
|
'Output directory',
|
|
@@ -44,8 +51,15 @@ export function register(program: Command): void {
|
|
|
44
51
|
if (!existsSync(sourcePath)) {
|
|
45
52
|
fail(`${opts.source} not found`)
|
|
46
53
|
}
|
|
47
|
-
const variant = parseVariant(opts.variant)
|
|
48
54
|
const mirror = resolveMirror(opts.mirror)
|
|
55
|
+
if (statSync(sourcePath).isDirectory()) {
|
|
56
|
+
if (opts.variant !== undefined) {
|
|
57
|
+
fail('--variant applies to a SLIDES.md source, not a folder')
|
|
58
|
+
}
|
|
59
|
+
await renderHtmlFolder(sourcePath, outDir, mirror, opts.open)
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
const variant = parseVariant(opts.variant)
|
|
49
63
|
const { GREEN, GREY, NC, RED } = palette(process.stderr)
|
|
50
64
|
intro('Render slides')
|
|
51
65
|
const result = await renderSlidesDoc(sourcePath, outDir, {
|
|
@@ -103,6 +117,61 @@ export function register(program: Command): void {
|
|
|
103
117
|
})
|
|
104
118
|
}
|
|
105
119
|
|
|
120
|
+
/**
|
|
121
|
+
* The converter loads on demand, so no other command resolves pptxgenjs,
|
|
122
|
+
* jszip, or the token module at startup. A fallback or a refused link reports
|
|
123
|
+
* one line each and keeps the exit at 0, as an unrecognized layout does on the
|
|
124
|
+
* markdown path, since the deck is written either way.
|
|
125
|
+
*/
|
|
126
|
+
async function renderHtmlFolder(
|
|
127
|
+
sourceDir: string,
|
|
128
|
+
outDir: string,
|
|
129
|
+
mirror: string | undefined,
|
|
130
|
+
shouldOpen: boolean | undefined,
|
|
131
|
+
): Promise<void> {
|
|
132
|
+
const { exportHtmlDeck } = await import('@/slides/convert/export')
|
|
133
|
+
const { GREEN, GREY, NC, RED } = palette(process.stderr)
|
|
134
|
+
const result = await exportHtmlDeck(sourceDir, outDir, {
|
|
135
|
+
root: process.cwd(),
|
|
136
|
+
mirror,
|
|
137
|
+
})
|
|
138
|
+
if (result.status === 'refused') {
|
|
139
|
+
fail(
|
|
140
|
+
result.reason === 'browser-missing'
|
|
141
|
+
? `Chromium is not installed. Run ${INSTALL_BROWSER}`
|
|
142
|
+
: result.message,
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
intro('Render slides')
|
|
146
|
+
process.stderr.write(
|
|
147
|
+
`${GREY}│${NC} ${GREEN}✓${NC} ${result.slideCount} slides\n${GREY}│${NC} ${GREEN}✓${NC} ${result.pptxPath}\n`,
|
|
148
|
+
)
|
|
149
|
+
for (const { slide, selector, properties } of result.fallbacks) {
|
|
150
|
+
process.stderr.write(
|
|
151
|
+
`${GREY}│${NC} ${RED}✗${NC} slide ${slide} ${selector} drawn as a picture for ${properties.join(', ')}\n`,
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
for (const { slide, selector, href, reason } of result.refusedLinks) {
|
|
155
|
+
process.stderr.write(
|
|
156
|
+
`${GREY}│${NC} ${RED}✗${NC} slide ${slide} ${selector} link "${href}" left out: ${reason}\n`,
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
if (result.mirrorPath) {
|
|
160
|
+
process.stderr.write(
|
|
161
|
+
`${GREY}│${NC} ${GREEN}✓${NC} mirrored to ${result.mirrorPath}\n`,
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
if (shouldOpen) {
|
|
165
|
+
const target = result.mirrorPath ?? result.pptxPath
|
|
166
|
+
const opened = await openDeck(target)
|
|
167
|
+
const mark = opened ? `${GREEN}✓${NC}` : `${RED}✗${NC}`
|
|
168
|
+
process.stderr.write(
|
|
169
|
+
`${GREY}│${NC} ${mark} ${opened ? 'opened' : 'could not open'} ${target}\n`,
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
outro()
|
|
173
|
+
}
|
|
174
|
+
|
|
106
175
|
function parseVariant(value: string | undefined): Variant | undefined {
|
|
107
176
|
if (value === undefined) return undefined
|
|
108
177
|
if (value === 'light' || value === 'dark') return value
|
package/src/commands/teach.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
2
3
|
import type { Command } from 'commander'
|
|
4
|
+
import { LiveStream } from '@/serve/live'
|
|
3
5
|
import {
|
|
4
6
|
logServing,
|
|
5
7
|
parsePort,
|
|
6
8
|
serveFields,
|
|
7
9
|
waitForInterrupt,
|
|
8
10
|
} from '@/serve/report'
|
|
9
|
-
import { DEFAULT_PORT, startServer } from '@/serve/static'
|
|
11
|
+
import { DEFAULT_PORT, LIVE_EVENTS_PATH, startServer } from '@/serve/static'
|
|
12
|
+
import { compileScript } from '@/teach/browser/compile'
|
|
13
|
+
import { reload } from '@/teach/browser/reload'
|
|
10
14
|
import { type LessonOutcome, planLesson } from '@/teach/lesson'
|
|
11
15
|
import { type NavGenerated, type NavOutcome, generateNav } from '@/teach/nav'
|
|
12
16
|
import type { RenderOutcome } from '@/teach/render'
|
|
@@ -942,8 +946,17 @@ async function runUp(
|
|
|
942
946
|
? 'index.html'
|
|
943
947
|
: relative(dir, resolve(root, contents)).split(sep).join('/')
|
|
944
948
|
|
|
945
|
-
const
|
|
949
|
+
const stream = refreshOnChange(root, dir, topic, emitJson)
|
|
950
|
+
const served = startServer(dir, {
|
|
951
|
+
port,
|
|
952
|
+
entry,
|
|
953
|
+
live: {
|
|
954
|
+
stream,
|
|
955
|
+
script: compileScript(reload, [LIVE_EVENTS_PATH]),
|
|
956
|
+
},
|
|
957
|
+
})
|
|
946
958
|
if (!served.ok) {
|
|
959
|
+
stream.close()
|
|
947
960
|
// A serve refusal carries a reason the teach refusals do not list, so it
|
|
948
961
|
// is written here in the teach record's shape rather than through one.
|
|
949
962
|
if (emitJson) {
|
|
@@ -970,10 +983,105 @@ async function runUp(
|
|
|
970
983
|
logServing(served)
|
|
971
984
|
}
|
|
972
985
|
|
|
973
|
-
await waitForInterrupt(
|
|
986
|
+
await waitForInterrupt(async () => {
|
|
987
|
+
stream.close()
|
|
988
|
+
await served.stop()
|
|
989
|
+
})
|
|
974
990
|
return 0
|
|
975
991
|
}
|
|
976
992
|
|
|
993
|
+
/**
|
|
994
|
+
* An editor's swap or backup file, or anything under a dot folder. It changes
|
|
995
|
+
* nothing a page shows, so it neither starts a pass nor counts as served.
|
|
996
|
+
*/
|
|
997
|
+
function isScratch(path: string): boolean {
|
|
998
|
+
return path
|
|
999
|
+
.split(/[\\/]/)
|
|
1000
|
+
.some((segment) => segment.startsWith('.') || segment.endsWith('~'))
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* What the served folder holds, as each file's path, size, and mtime. A pass
|
|
1005
|
+
* whose `nav` wrote nothing leaves it unchanged, which is how the echo of a
|
|
1006
|
+
* pass's own writes settles without a second reload.
|
|
1007
|
+
*/
|
|
1008
|
+
function servedState(dir: string): string {
|
|
1009
|
+
return readdirSync(dir, { recursive: true, withFileTypes: true })
|
|
1010
|
+
.filter((entry) => entry.isFile())
|
|
1011
|
+
.map((entry) => relative(dir, join(entry.parentPath, entry.name)))
|
|
1012
|
+
.filter((path) => !isScratch(path))
|
|
1013
|
+
.sort()
|
|
1014
|
+
.map((path) => {
|
|
1015
|
+
const stat = statSync(join(dir, path), { throwIfNoEntry: false })
|
|
1016
|
+
return `${path}:${stat?.size}:${stat?.mtimeMs}`
|
|
1017
|
+
})
|
|
1018
|
+
.join('\n')
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* Re-runs `nav` on each settled change under the teach folder and tells every
|
|
1023
|
+
* open page to reload once a pass changed what is served. Passes run one at a
|
|
1024
|
+
* time, since `nav` is not reentrant over the files it rewrites, and a change
|
|
1025
|
+
* arriving mid-pass is coalesced into exactly one more. A refused or failed
|
|
1026
|
+
* pass sends no reload, so a page never reloads onto a half-written state.
|
|
1027
|
+
*/
|
|
1028
|
+
function refreshOnChange(
|
|
1029
|
+
root: string,
|
|
1030
|
+
dir: string,
|
|
1031
|
+
topic: string | undefined,
|
|
1032
|
+
emitJson: boolean,
|
|
1033
|
+
): LiveStream<string> {
|
|
1034
|
+
let isRunning = false
|
|
1035
|
+
let isDirty = false
|
|
1036
|
+
let state = servedState(dir)
|
|
1037
|
+
|
|
1038
|
+
const report = (message: string, isWarning: boolean) => {
|
|
1039
|
+
if (emitJson) process.stderr.write(`${message}\n`)
|
|
1040
|
+
else if (isWarning) logWarn(message)
|
|
1041
|
+
else logInfo(message)
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const pass = async (): Promise<void> => {
|
|
1045
|
+
const nav = await generateNav(root, topic)
|
|
1046
|
+
if (!nav.ok) {
|
|
1047
|
+
report(`Refresh refused: ${nav.message}`, true)
|
|
1048
|
+
return
|
|
1049
|
+
}
|
|
1050
|
+
const next = servedState(dir)
|
|
1051
|
+
if (next === state) return
|
|
1052
|
+
state = next
|
|
1053
|
+
stream.send({ reload: true })
|
|
1054
|
+
report(`Refreshed ${nav.lessons} lessons, reloading open pages`, false)
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
const schedule = async (): Promise<void> => {
|
|
1058
|
+
if (isRunning) {
|
|
1059
|
+
isDirty = true
|
|
1060
|
+
return
|
|
1061
|
+
}
|
|
1062
|
+
isRunning = true
|
|
1063
|
+
do {
|
|
1064
|
+
isDirty = false
|
|
1065
|
+
// A throw, such as the teach folder removed mid-pass, is reported and
|
|
1066
|
+
// the loop carries on, since a rejection left here would end the
|
|
1067
|
+
// process and a stuck flag would end every later refresh.
|
|
1068
|
+
try {
|
|
1069
|
+
await pass()
|
|
1070
|
+
} catch (error) {
|
|
1071
|
+
const code = (error as NodeJS.ErrnoException).code ?? String(error)
|
|
1072
|
+
report(`Refresh failed: ${code}`, true)
|
|
1073
|
+
}
|
|
1074
|
+
} while (isDirty)
|
|
1075
|
+
isRunning = false
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const stream = new LiveStream<string>(dir, {
|
|
1079
|
+
map: (path) => (isScratch(path) ? undefined : path),
|
|
1080
|
+
onChange: () => void schedule(),
|
|
1081
|
+
})
|
|
1082
|
+
return stream
|
|
1083
|
+
}
|
|
1084
|
+
|
|
977
1085
|
function reportRefusal(
|
|
978
1086
|
title: string,
|
|
979
1087
|
refused: TeachRefused,
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { type FSWatcher, watch } from 'node:fs'
|
|
2
|
+
import { SERVE_HOST } from '@/serve/static'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* How long a burst of watch events for one path waits before it is sent. One
|
|
6
|
+
* editor save raises several, and each would otherwise reach a reader again.
|
|
7
|
+
*/
|
|
8
|
+
const SETTLE_MS = 40
|
|
9
|
+
|
|
10
|
+
export interface LiveOptions<T> {
|
|
11
|
+
/**
|
|
12
|
+
* Turns a changed path, relative to the watched directory, into what a change
|
|
13
|
+
* carries, or into undefined to drop it, such as a write's staging file.
|
|
14
|
+
*/
|
|
15
|
+
readonly map: (path: string) => T | undefined
|
|
16
|
+
/**
|
|
17
|
+
* Takes each settled change in place of the clients. A caller that has work
|
|
18
|
+
* to do before a reader may look, such as regenerating pages, sends its own
|
|
19
|
+
* payload through `send` once that work lands.
|
|
20
|
+
*/
|
|
21
|
+
readonly onChange?: (payload: T) => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether a request names this server by a loopback name and its own port.
|
|
26
|
+
* The bind keeps other machines out, and this keeps out a page the operator
|
|
27
|
+
* visits whose domain was rebound to 127.0.0.1, which reaches the socket with
|
|
28
|
+
* its own name in the Host header.
|
|
29
|
+
*/
|
|
30
|
+
export function isLoopbackHost(host: string | null, port: number): boolean {
|
|
31
|
+
return host === `${SERVE_HOST}:${port}` || host === `localhost:${port}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Watches a directory and fans each settled change out as a server-sent event
|
|
36
|
+
* to every open client, or to `onChange` when the caller takes changes itself.
|
|
37
|
+
*/
|
|
38
|
+
export class LiveStream<T> {
|
|
39
|
+
private readonly clients = new Set<ReadableStreamDefaultController<string>>()
|
|
40
|
+
private readonly pending = new Map<string, ReturnType<typeof setTimeout>>()
|
|
41
|
+
private readonly watcher: FSWatcher
|
|
42
|
+
|
|
43
|
+
constructor(
|
|
44
|
+
dir: string,
|
|
45
|
+
private readonly options: LiveOptions<T>,
|
|
46
|
+
) {
|
|
47
|
+
this.watcher = watch(dir, { recursive: true }, (_event, filename) => {
|
|
48
|
+
if (filename) this.queue(filename.toString())
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private queue(path: string): void {
|
|
53
|
+
const payload = this.options.map(path)
|
|
54
|
+
if (payload === undefined) return
|
|
55
|
+
clearTimeout(this.pending.get(path))
|
|
56
|
+
this.pending.set(
|
|
57
|
+
path,
|
|
58
|
+
setTimeout(() => {
|
|
59
|
+
this.pending.delete(path)
|
|
60
|
+
if (this.options.onChange) this.options.onChange(payload)
|
|
61
|
+
else this.send(payload)
|
|
62
|
+
}, SETTLE_MS),
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
send(payload: unknown): void {
|
|
67
|
+
const frame = `data: ${JSON.stringify(payload)}\n\n`
|
|
68
|
+
for (const client of this.clients) client.enqueue(frame)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
open(): Response {
|
|
72
|
+
let self: ReadableStreamDefaultController<string> | undefined
|
|
73
|
+
const stream = new ReadableStream<string>({
|
|
74
|
+
start: (controller) => {
|
|
75
|
+
self = controller
|
|
76
|
+
this.clients.add(controller)
|
|
77
|
+
controller.enqueue(': open\n\n')
|
|
78
|
+
},
|
|
79
|
+
cancel: () => {
|
|
80
|
+
if (self) this.clients.delete(self)
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
return new Response(stream, {
|
|
84
|
+
headers: {
|
|
85
|
+
'content-type': 'text/event-stream',
|
|
86
|
+
'cache-control': 'no-store',
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Opens a stream for a request naming this server, and refuses any other. */
|
|
92
|
+
answer(request: Request, port: number): Response {
|
|
93
|
+
if (isLoopbackHost(request.headers.get('host'), port)) return this.open()
|
|
94
|
+
return new Response('Forbidden\n', {
|
|
95
|
+
status: 403,
|
|
96
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
close(): void {
|
|
101
|
+
this.watcher.close()
|
|
102
|
+
for (const timer of this.pending.values()) clearTimeout(timer)
|
|
103
|
+
this.pending.clear()
|
|
104
|
+
for (const client of this.clients) {
|
|
105
|
+
try {
|
|
106
|
+
client.close()
|
|
107
|
+
} catch {
|
|
108
|
+
/* Already closed by the browser going away. */
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
this.clients.clear()
|
|
112
|
+
}
|
|
113
|
+
}
|
package/src/serve/static.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs'
|
|
2
2
|
import { join, resolve, sep } from 'node:path'
|
|
3
|
+
import type { LiveStream } from '@/serve/live'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* The loopback interface, never a wildcard bind. A preview serves whatever
|
|
@@ -78,11 +79,25 @@ export interface ServeStarted {
|
|
|
78
79
|
|
|
79
80
|
export type ServeOutcome = ServeStarted | ServeRefused
|
|
80
81
|
|
|
82
|
+
/** The route a live page's script opens its event stream on. */
|
|
83
|
+
export const LIVE_EVENTS_PATH = '/__live'
|
|
84
|
+
|
|
85
|
+
export interface LiveServe {
|
|
86
|
+
readonly stream: Pick<LiveStream<unknown>, 'answer'>
|
|
87
|
+
/**
|
|
88
|
+
* The `<script>` element spliced into every page served, built by the caller
|
|
89
|
+
* so this module stays free of what a reload means to any one surface.
|
|
90
|
+
*/
|
|
91
|
+
readonly script: string
|
|
92
|
+
}
|
|
93
|
+
|
|
81
94
|
export interface ServeOptions {
|
|
82
95
|
readonly port?: number
|
|
83
96
|
readonly entry?: string
|
|
84
97
|
/** Answers a directory request with no index page by listing it. Off by default. */
|
|
85
98
|
readonly index?: boolean
|
|
99
|
+
/** Answers the events route and splices a script into each page. Off by default. */
|
|
100
|
+
readonly live?: LiveServe
|
|
86
101
|
}
|
|
87
102
|
|
|
88
103
|
function escapeHtml(text: string): string {
|
|
@@ -253,16 +268,59 @@ export function bindFirstFree(
|
|
|
253
268
|
return undefined
|
|
254
269
|
}
|
|
255
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Splices the script in before the last closing body tag of a page. A page
|
|
273
|
+
* carrying none goes out as the bytes on disk, since guessing a place for it
|
|
274
|
+
* risks breaking markup the author wrote. The splice happens per response and
|
|
275
|
+
* never on disk, so a page the reader saves or commits carries no hook.
|
|
276
|
+
*/
|
|
277
|
+
export async function injectLive(
|
|
278
|
+
response: Response,
|
|
279
|
+
script: string,
|
|
280
|
+
): Promise<Response> {
|
|
281
|
+
const type = response.headers.get('content-type') ?? ''
|
|
282
|
+
if (response.status !== 200 || !type.startsWith('text/html')) return response
|
|
283
|
+
|
|
284
|
+
const bytes = new Uint8Array(await response.arrayBuffer())
|
|
285
|
+
const html = new TextDecoder().decode(bytes)
|
|
286
|
+
/*
|
|
287
|
+
* Searched in the original rather than a lowercased copy, since lowercasing
|
|
288
|
+
* changes the length of some characters, such as İ, and an index read off
|
|
289
|
+
* the copy would cut the original inside the tag.
|
|
290
|
+
*/
|
|
291
|
+
const at = [...html.matchAll(/<\/body>/gi)].at(-1)?.index
|
|
292
|
+
const init = { status: response.status, headers: response.headers }
|
|
293
|
+
if (at === undefined) return new Response(bytes, init)
|
|
294
|
+
return new Response(`${html.slice(0, at)}${script}${html.slice(at)}`, init)
|
|
295
|
+
}
|
|
296
|
+
|
|
256
297
|
function listen(
|
|
257
298
|
root: string,
|
|
258
299
|
first: number,
|
|
259
300
|
index: boolean,
|
|
301
|
+
live: LiveServe | undefined,
|
|
260
302
|
): { server: BoundServer; port: number } | undefined {
|
|
303
|
+
if (!live) {
|
|
304
|
+
return bindFirstFree(first, (port) =>
|
|
305
|
+
Bun.serve({
|
|
306
|
+
hostname: SERVE_HOST,
|
|
307
|
+
port,
|
|
308
|
+
fetch: (request) => respond(root, request, index),
|
|
309
|
+
}),
|
|
310
|
+
)
|
|
311
|
+
}
|
|
261
312
|
return bindFirstFree(first, (port) =>
|
|
262
313
|
Bun.serve({
|
|
263
314
|
hostname: SERVE_HOST,
|
|
264
315
|
port,
|
|
265
|
-
|
|
316
|
+
/* The event stream stays open for as long as the page does. */
|
|
317
|
+
idleTimeout: 0,
|
|
318
|
+
fetch: async (request, server) => {
|
|
319
|
+
if (new URL(request.url).pathname === LIVE_EVENTS_PATH) {
|
|
320
|
+
return live.stream.answer(request, server.port ?? 0)
|
|
321
|
+
}
|
|
322
|
+
return injectLive(await respond(root, request, index), live.script)
|
|
323
|
+
},
|
|
266
324
|
}),
|
|
267
325
|
)
|
|
268
326
|
}
|
|
@@ -378,7 +436,7 @@ export function startServer(
|
|
|
378
436
|
*/
|
|
379
437
|
let bound: ReturnType<typeof listen>
|
|
380
438
|
try {
|
|
381
|
-
bound = listen(root, first, options.index ?? false)
|
|
439
|
+
bound = listen(root, first, options.index ?? false, options.live)
|
|
382
440
|
} catch (error) {
|
|
383
441
|
const code = (error as NodeJS.ErrnoException).code ?? 'unknown'
|
|
384
442
|
return refuse(
|