@lincyaw/workspace 1.0.0-rc.0 → 1.0.0-rc.2

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.
Files changed (53) hide show
  1. package/README.md +5 -5
  2. package/package.json +10 -5
  3. package/src/domain/{revisions.ts → commit-message.ts} +7 -7
  4. package/src/domain/locks.ts +5 -0
  5. package/src/domain/paths.ts +2 -2
  6. package/src/index.ts +7 -2
  7. package/src/node/authoring-clients.ts +206 -0
  8. package/src/node/build.ts +4 -4
  9. package/src/node/compile-session.ts +187 -0
  10. package/src/node/config.ts +36 -21
  11. package/src/node/document-assets.ts +30 -0
  12. package/src/node/document-audio.ts +473 -0
  13. package/src/node/document-authoring.ts +436 -0
  14. package/src/node/document-editor.ts +157 -0
  15. package/src/node/document-history.ts +190 -0
  16. package/src/node/document-images.ts +241 -0
  17. package/src/node/document-operations.ts +191 -0
  18. package/src/node/document-transcription-adoption.ts +96 -0
  19. package/src/node/document-transcription.ts +471 -0
  20. package/src/node/document-voice.ts +115 -0
  21. package/src/node/errors.ts +23 -0
  22. package/src/node/git.ts +15 -15
  23. package/src/node/jobs.ts +361 -0
  24. package/src/node/locks.ts +10 -5
  25. package/src/node/page-delete.ts +106 -0
  26. package/src/node/project-files.ts +163 -0
  27. package/src/node/projects.ts +192 -0
  28. package/src/node/review/agent-session-reviews.ts +374 -0
  29. package/src/node/review/git-guard.ts +9 -8
  30. package/src/node/review/git-review.ts +102 -90
  31. package/src/node/review/record.ts +77 -0
  32. package/src/node/review/routes.ts +36 -35
  33. package/src/node/review/task-reviews.ts +50 -0
  34. package/src/node/revisions.ts +321 -68
  35. package/src/node/routes.ts +236 -146
  36. package/src/node/serve.ts +319 -118
  37. package/src/node/site.ts +34 -23
  38. package/src/node/source-editor.ts +332 -0
  39. package/src/node/speech-settings.ts +21 -0
  40. package/src/node/templates.ts +27 -0
  41. package/src/node/workbench-api.ts +176 -0
  42. package/src/node/writer.ts +389 -0
  43. package/templates/document/default/lecture.json +75 -0
  44. package/src/domain/api.ts +0 -102
  45. package/src/domain/index.ts +0 -6
  46. package/src/node/documents.ts +0 -185
  47. package/src/node/files.ts +0 -252
  48. package/src/node/review/review-record.ts +0 -73
  49. package/src/node/review/reviews.ts +0 -422
  50. package/src/node/service.ts +0 -237
  51. package/src/node/session.ts +0 -123
  52. package/src/node/source.ts +0 -148
  53. package/templates/document/default/lecture.md +0 -18
@@ -1,422 +0,0 @@
1
- import { randomUUID } from 'node:crypto'
2
- import { existsSync } from 'node:fs'
3
- import { join } from 'node:path'
4
- import { errorMessage } from '@lincyaw/kernel'
5
- import type { ChangeReviewService, DocumentFiles, Logger, Member, Revision, ReviewChange, ReviewStep } from '@lincyaw/kernel'
6
- import { trailers } from '../../domain/revisions.ts'
7
- import type { WorkspaceConfig } from '../config.ts'
8
- import { listDocuments } from '../config.ts'
9
- import { lockedError, RequestError } from '../documents.ts'
10
- import type { DocumentLocks } from '../locks.ts'
11
- import type { Revisions } from '../revisions.ts'
12
- import { GIT_ENGINE, leftSessionReview, openGitReview, recoverGitReview, resumeGitReview } from './git-review.ts'
13
- import type { GitReview } from './git-review.ts'
14
-
15
- export const SESSION_TRAILER = 'Presmium-Session'
16
- export const TURN_TRAILER = 'Presmium-Turn'
17
-
18
- export interface ReviewView {
19
- id: string
20
- document: string
21
- engine: string
22
- session: string
23
- holder: string
24
- turns: { from: number, to: number }
25
- running: boolean
26
- mode: 'required' | 'auto'
27
- opened: string
28
- steps: ReviewStep[]
29
- changes: ReviewChange[]
30
- }
31
-
32
- export interface ReviewEnd {
33
- outcome: 'accepted' | 'rejected' | 'rolled-back' | 'empty'
34
- revision?: Revision
35
- files: string[]
36
- }
37
-
38
- export interface ReviewFollowed {
39
- document: string
40
- review?: ReviewView
41
- ended?: ReviewEnd
42
- }
43
-
44
- export type ReviewEvent =
45
- | { type: 'review', document: string, session: string, review?: ReviewView, ended?: ReviewEnd }
46
- | { type: 'reverted', session: string, documents: string[], turns?: string, revision: string, subject: string, revertedBy: string, files: string[] }
47
-
48
- export interface ReviewsOptions {
49
- config: WorkspaceConfig
50
- revisions: Revisions
51
- locks: DocumentLocks
52
- files?: DocumentFiles
53
- log: Logger
54
- }
55
-
56
- interface Open {
57
- review: GitReview
58
- document: string
59
- session: string
60
- member: Member
61
- turns: { from: number, to: number }
62
- running: boolean
63
- opened: string
64
- release: () => void
65
- timer?: ReturnType<typeof setTimeout>
66
- }
67
-
68
- function reviewSettings(config: WorkspaceConfig): { mode: 'required' | 'auto', timeoutMs: number } {
69
- const settings = config.settings['presmium.assistant'] ?? {}
70
- const timeout = settings.reviewTimeoutMs
71
- return {
72
- mode: settings.review === 'auto' ? 'auto' : 'required',
73
- timeoutMs: typeof timeout === 'number' && Number.isFinite(timeout) && timeout > 0 ? timeout : 30 * 60_000,
74
- }
75
- }
76
-
77
- const holderOf = (session: string) => `agent:${session}`
78
-
79
- const inGitDir = (path: string) => path.split('/').some(part => part.toLowerCase() === '.git')
80
-
81
- function summaryOf(files: string[], lang: string | undefined): string {
82
- const zh = !lang || lang.startsWith('zh')
83
- const names = files.slice(0, 3).map(file => file.split('/').at(-1)!)
84
- const more = files.length - names.length
85
- return zh
86
- ? `AI 修改:${names.join('、')}${more > 0 ? ` 等 ${files.length} 个文件` : ''}`
87
- : `AI change: ${names.join(', ')}${more > 0 ? ` and ${more} more` : ''}`
88
- }
89
-
90
- export class Reviews {
91
- readonly #options: ReviewsOptions
92
- readonly #open = new Map<string, Open>()
93
- readonly #queues = new Map<string, Promise<unknown>>()
94
- readonly #followers = new Map<string, Set<(event: ReviewFollowed) => void>>()
95
- readonly #listeners = new Set<(event: ReviewEvent) => void>()
96
- #closed = false
97
-
98
- constructor(options: ReviewsOptions) {
99
- this.#options = options
100
- }
101
-
102
- readonly service: ChangeReviewService = {
103
- engine: GIT_ENGINE,
104
- open: ({ document, task }) => openGitReview({
105
- workspaceRoot: this.#options.config.root,
106
- deckDir: join(this.#options.config.root, document),
107
- task,
108
- files: this.#options.files,
109
- onRestore: paths => this.#options.log.warn(`task ${task}: the agent changed the git directory, put back: ${paths.join(', ')}`),
110
- warn: message => this.#options.log.warn(`task ${task}: ${message}`),
111
- }),
112
- }
113
-
114
- async recover(documents: string[], { resume = true } = {}): Promise<void> {
115
- const { config, log } = this.#options
116
- for (const document of documents) {
117
- const deckDir = join(config.root, document)
118
- if (!existsSync(deckDir)) continue
119
- try {
120
- const left = leftSessionReview(deckDir)
121
- if (left?.session) {
122
- if (resume) await this.#serial(document, () => this.#resume(document, left))
123
- continue
124
- }
125
- const found = await recoverGitReview({ workspaceRoot: config.root, deckDir, files: this.#options.files, onRestore: paths => log.warn(`the git directory as a change task found it, put back: ${paths.join(', ')}`) })
126
- if (found) log.warn(`task ${found.task} was waiting for its review when the server ended: rolled back (${found.paths.join(', ') || 'no change'})`)
127
- } catch (error) {
128
- log.warn(`the review left in ${document || '.'} was not taken up: ${errorMessage(error)}`)
129
- }
130
- }
131
- }
132
-
133
- async #resume(document: string, left: NonNullable<ReturnType<typeof leftSessionReview>>): Promise<void> {
134
- const { config, log } = this.#options
135
- const session = left.session!
136
- const release = this.#options.locks.claim(document, holderOf(session.session))
137
- if (!release) throw new Error(`its lock is ${this.#options.locks.holder(document)}'s`)
138
- try {
139
- const review = await resumeGitReview(left, { workspaceRoot: config.root, files: this.#options.files, onRestore: paths => log.warn(`session ${session.session}: the agent changed the git directory, put back: ${paths.join(', ')}`) })
140
- const open: Open = { review, document, session: session.session, member: session.member, turns: session.turns, running: false, opened: session.opened, release }
141
- this.#open.set(document, open)
142
- log.info(`the review of session ${session.session} in ${document || '.'} (turns ${session.turns.from}-${session.turns.to}) waits for its author again`)
143
- this.#arm(open)
144
- } catch (error) {
145
- release()
146
- throw error
147
- }
148
- }
149
-
150
- async view(document: string): Promise<ReviewView | undefined> {
151
- const open = this.#open.get(document)
152
- return open && await this.#view(open)
153
- }
154
-
155
- async #view(open: Open): Promise<ReviewView> {
156
- return {
157
- id: open.review.task,
158
- document: open.document,
159
- engine: GIT_ENGINE,
160
- session: open.session,
161
- holder: holderOf(open.session),
162
- turns: { ...open.turns },
163
- running: open.running,
164
- mode: reviewSettings(this.#options.config).mode,
165
- opened: open.opened,
166
- steps: [...open.review.steps()],
167
- changes: await open.review.diff(),
168
- }
169
- }
170
-
171
- follow(document: string, signal: AbortSignal): AsyncIterable<{ event: string, data: ReviewFollowed }> {
172
- const followers = this.#followers.get(document) ?? new Set()
173
- this.#followers.set(document, followers)
174
- return this.#stream(signal, (push) => {
175
- followers.add(push)
176
- void this.view(document).then(review => push({ document, ...review ? { review } : {} }), () => push({ document }))
177
- this.#rearm(document)
178
- return () => {
179
- followers.delete(push)
180
- this.#rearm(document)
181
- }
182
- }, 'review')
183
- }
184
-
185
- events(signal: AbortSignal): AsyncIterable<{ event: string, data: ReviewEvent }> {
186
- return this.#stream(signal, (push) => {
187
- this.#listeners.add(push)
188
- return () => this.#listeners.delete(push)
189
- }, undefined, data => data.type)
190
- }
191
-
192
- #stream<T>(signal: AbortSignal, subscribe: (push: (data: T) => void) => () => void, name?: string, nameOf?: (data: T) => string): AsyncIterable<{ event: string, data: T }> {
193
- const waiting: T[] = []
194
- let wake: (() => void) | undefined
195
- const push = (data: T) => {
196
- waiting.push(data)
197
- wake?.()
198
- }
199
- const unsubscribe = subscribe(push)
200
- signal.addEventListener('abort', () => wake?.(), { once: true })
201
- return {
202
- async* [Symbol.asyncIterator]() {
203
- try {
204
- while (!signal.aborted) {
205
- const next = waiting.shift()
206
- if (next !== undefined) {
207
- yield { event: name ?? nameOf!(next), data: next }
208
- continue
209
- }
210
- await new Promise<void>(resolve => wake = resolve)
211
- wake = undefined
212
- }
213
- } finally {
214
- unsubscribe()
215
- }
216
- },
217
- }
218
- }
219
-
220
- open(document: string, body: { session: string, turn?: number }, member: Member): Promise<ReviewView> {
221
- return this.#serial(document, async () => {
222
- if (this.#closed) throw new RequestError(503, 'the workspace service is closing')
223
- let open = this.#open.get(document)
224
- if (open && open.session !== body.session) throw lockedError(document, holderOf(open.session))
225
- if (!open) {
226
- const release = this.#options.locks.claim(document, holderOf(body.session))
227
- if (!release) throw lockedError(document, this.#options.locks.holder(document) ?? '')
228
- const { config, log } = this.#options
229
- const turn = body.turn ?? 0
230
- const opened = new Date().toISOString()
231
- try {
232
- const review = await openGitReview({
233
- workspaceRoot: config.root,
234
- deckDir: join(config.root, document),
235
- task: randomUUID(),
236
- files: this.#options.files,
237
- onRestore: paths => log.warn(`session ${body.session}: the agent changed the git directory, put back: ${paths.join(', ')}`),
238
- warn: message => log.warn(`session ${body.session}: ${message}`),
239
- session: { session: body.session, document, member, turns: { from: turn, to: turn }, opened },
240
- })
241
- open = { review, document, session: body.session, member, turns: { from: turn, to: turn }, running: true, opened, release }
242
- } catch (error) {
243
- release()
244
- throw new RequestError(409, `the review of ${document || '.'} did not open: ${errorMessage(error)}`)
245
- }
246
- this.#open.set(document, open)
247
- }
248
- this.#turn(open, body.turn)
249
- open.running = true
250
- this.#rearm(document)
251
- return await this.#changed(open)
252
- })
253
- }
254
-
255
- step(document: string, body: { session: string, turn?: number, label?: string, tool?: string, end?: boolean }): Promise<ReviewView | undefined> {
256
- return this.#serial(document, async () => {
257
- const open = this.#mine(document, body.session)
258
- this.#turn(open, body.turn)
259
- if (body.label) await open.review.step(body.label, body.tool)
260
- if (!body.end) {
261
- open.running = true
262
- return await this.#changed(open)
263
- }
264
- open.running = false
265
- const changes = await open.review.diff()
266
- if (changes.length === 0) {
267
- await open.review.close()
268
- await this.#end(open, { outcome: 'empty', files: [] })
269
- return undefined
270
- }
271
- if (reviewSettings(this.#options.config).mode === 'auto') {
272
- await this.#accept(open, [], undefined)
273
- return undefined
274
- }
275
- this.#arm(open)
276
- return await this.#changed(open)
277
- })
278
- }
279
-
280
- accept(document: string, body: { reject?: string[], message?: string }): Promise<ReviewEnd> {
281
- return this.#serial(document, () => this.#accept(this.#any(document), body.reject ?? [], body.message))
282
- }
283
-
284
- async #accept(open: Open, reject: string[], message: string | undefined): Promise<ReviewEnd> {
285
- const rejected = new Set(reject)
286
- for (const change of await open.review.diff()) if (inGitDir(change.path)) rejected.add(change.path)
287
- const files = await open.review.accept([...rejected])
288
- const lang = listDocuments(this.#options.config).find(doc => doc.dir === open.document)?.lang
289
- const steps = open.review.steps().map(step => `- ${step.label}`).slice(0, 20)
290
- let revision: Revision | undefined
291
- try {
292
- revision = await this.#options.revisions.commit({
293
- files,
294
- message: `${message?.trim() || summaryOf(files, lang)}${steps.length ? `\n\n${steps.join('\n')}` : ''}`,
295
- origin: { kind: 'agent', task: open.review.task, member: open.member },
296
- trailers: [[SESSION_TRAILER, open.session], [TURN_TRAILER, `${open.turns.from}-${open.turns.to}`]],
297
- })
298
- } catch (error) {
299
- this.#options.log.warn(`the review of session ${open.session} in ${open.document || '.'} was accepted, but not committed: ${errorMessage(error)}`)
300
- }
301
- const ended: ReviewEnd = { outcome: 'accepted', ...revision ? { revision } : {}, files }
302
- await this.#end(open, ended)
303
- return ended
304
- }
305
-
306
- reject(document: string, body: { paths?: string[] }): Promise<ReviewFollowed> {
307
- return this.#serial(document, async () => {
308
- const open = this.#any(document)
309
- if (body.paths) {
310
- await open.review.restoreFiles(body.paths)
311
- if ((await open.review.diff()).length > 0) return { document, review: await this.#changed(open) }
312
- }
313
- const files = (await open.review.diff()).map(change => change.path)
314
- await open.review.rollback()
315
- const ended: ReviewEnd = { outcome: 'rejected', files }
316
- await this.#end(open, ended)
317
- return { document, ended }
318
- })
319
- }
320
-
321
- restore(document: string, body: { step: string }): Promise<ReviewView> {
322
- return this.#serial(document, async () => {
323
- const open = this.#any(document)
324
- try {
325
- await open.review.restoreTo!(body.step)
326
- } catch (error) {
327
- throw new RequestError(409, errorMessage(error))
328
- }
329
- return await this.#changed(open)
330
- })
331
- }
332
-
333
- async reverted(id: string, revertedBy: string): Promise<void> {
334
- if (this.#listeners.size === 0) return
335
- const { revisions } = this.#options
336
- const message = await revisions.messageOf(id)
337
- const found = message === undefined ? {} : trailers(message)
338
- const session = found[SESSION_TRAILER]?.[0]
339
- if (!session || message === undefined) return
340
- const files = await revisions.filesOf(id)
341
- this.#tell({ type: 'reverted', session, documents: await revisions.documentsOf(id), ...found[TURN_TRAILER]?.[0] ? { turns: found[TURN_TRAILER][0] } : {}, revision: id, subject: message.split('\n')[0] ?? '', revertedBy, files })
342
- }
343
-
344
- close(): void {
345
- this.#closed = true
346
- for (const open of this.#open.values()) {
347
- clearTimeout(open.timer)
348
- open.review.suspend()
349
- open.release()
350
- }
351
- this.#open.clear()
352
- }
353
-
354
- #mine(document: string, session: string): Open {
355
- const open = this.#any(document)
356
- if (open.session !== session) throw lockedError(document, holderOf(open.session))
357
- return open
358
- }
359
-
360
- #any(document: string): Open {
361
- const open = this.#open.get(document)
362
- if (!open) throw new RequestError(404, `no review of ${document || '.'} is open`)
363
- return open
364
- }
365
-
366
- #turn(open: Open, turn: number | undefined): void {
367
- if (turn === undefined || !Number.isInteger(turn) || turn < 0) return
368
- open.turns = { from: open.turns.from === 0 ? turn : Math.min(open.turns.from, turn), to: Math.max(open.turns.to, turn) }
369
- open.review.note({ session: open.session, document: open.document, member: open.member, turns: open.turns, opened: open.opened })
370
- }
371
-
372
- async #changed(open: Open): Promise<ReviewView> {
373
- const review = await this.#view(open)
374
- this.#follow(open.document, { document: open.document, review })
375
- this.#tell({ type: 'review', document: open.document, session: open.session, review })
376
- return review
377
- }
378
-
379
- async #end(open: Open, ended: ReviewEnd): Promise<void> {
380
- clearTimeout(open.timer)
381
- this.#open.delete(open.document)
382
- open.release()
383
- this.#follow(open.document, { document: open.document, ended })
384
- this.#tell({ type: 'review', document: open.document, session: open.session, ended })
385
- }
386
-
387
- #follow(document: string, event: ReviewFollowed): void {
388
- for (const push of this.#followers.get(document) ?? []) push(event)
389
- }
390
-
391
- #tell(event: ReviewEvent): void {
392
- for (const push of this.#listeners) push(event)
393
- }
394
-
395
- #rearm(document: string): void {
396
- const open = this.#open.get(document)
397
- if (open) this.#arm(open)
398
- }
399
-
400
- #arm(open: Open): void {
401
- clearTimeout(open.timer)
402
- open.timer = undefined
403
- if (open.running || (this.#followers.get(open.document)?.size ?? 0) > 0) return
404
- const { timeoutMs } = reviewSettings(this.#options.config)
405
- open.timer = setTimeout(() => {
406
- void this.#serial(open.document, async () => {
407
- if (this.#open.get(open.document) !== open || open.running || (this.#followers.get(open.document)?.size ?? 0) > 0) return
408
- const files = (await open.review.diff()).map(change => change.path)
409
- await open.review.rollback()
410
- this.#options.log.info(`the review of session ${open.session} in ${open.document || '.'}: no page followed it for ${Math.round(timeoutMs / 1000)} s: rolled back`)
411
- await this.#end(open, { outcome: 'rolled-back', files })
412
- }).catch((error: unknown) => this.#options.log.warn(`the review of session ${open.session} did not roll back: ${errorMessage(error)}`))
413
- }, timeoutMs)
414
- open.timer.unref?.()
415
- }
416
-
417
- #serial<T>(document: string, work: () => Promise<T>): Promise<T> {
418
- const next = (this.#queues.get(document) ?? Promise.resolve()).then(work, work)
419
- this.#queues.set(document, next.catch(() => undefined))
420
- return next
421
- }
422
- }
@@ -1,237 +0,0 @@
1
- import { createHash } from 'node:crypto'
2
- import { createServer } from 'node:net'
3
- import type { AddressInfo } from 'node:net'
4
- import { join, relative, sep } from 'node:path'
5
- import { dev, relativeIR } from '@lincyaw/compiler'
6
- import type { Compilation } from '@lincyaw/compiler'
7
- import { errorMessage, PREVIEW_REVISION } from '@lincyaw/kernel'
8
- import type { Finding, IRDocument, Logger, ProxySpec } from '@lincyaw/kernel'
9
- import type { ServerHost } from '@lincyaw/server'
10
- import { WORKSPACE_ID } from '../domain/api.ts'
11
- import type { DocumentSession } from './session.ts'
12
-
13
- export interface IrAnswer {
14
- document: string
15
- revision: string
16
- doc: IRDocument
17
- diagnostics: Finding[]
18
- }
19
-
20
- export interface IrEvent {
21
- document: string
22
- revision: string
23
- diagnostics: { errors: number, warnings: number }
24
- files: string[]
25
- }
26
-
27
- export interface PreviewAnswer {
28
- document: string
29
- web: string
30
- slides?: string
31
- }
32
-
33
- export interface BuildAnswer {
34
- outputs: { renderer: string, outDir: string }[]
35
- findings: Finding[]
36
- }
37
-
38
- export interface CheckAnswer {
39
- findings: Finding[]
40
- }
41
-
42
- export interface WorkspaceService {
43
- ir(document: string): Promise<IrAnswer>
44
- events(document: string, signal: AbortSignal): AsyncIterable<{ event: string, data: IrEvent }>
45
- preview(document: string): Promise<PreviewAnswer>
46
- build(document: string, to?: string[]): Promise<BuildAnswer>
47
- check(document: string): Promise<CheckAnswer>
48
- proxies: ProxySpec[]
49
- }
50
-
51
- export function irRevision(doc: IRDocument): string {
52
- return createHash('sha256').update(JSON.stringify(doc)).digest('hex').slice(0, 16)
53
- }
54
-
55
- export function withRevision(doc: IRDocument, revision: string): IRDocument {
56
- return { ...doc, meta: { ...doc.meta, [PREVIEW_REVISION]: revision } }
57
- }
58
-
59
- function workspacePath(root: string, file: string): string {
60
- return relative(root, file).split(sep).join('/')
61
- }
62
-
63
- function freePort(): Promise<number> {
64
- return new Promise((resolve, reject) => {
65
- const server = createServer()
66
- server.once('error', reject)
67
- server.listen(0, '127.0.0.1', () => {
68
- const { port } = server.address() as AddressInfo
69
- server.close(() => resolve(port))
70
- })
71
- })
72
- }
73
-
74
- function urlPath(dir: string): string {
75
- return dir.split('/').map(encodeURIComponent).join('/')
76
- }
77
-
78
- function slidesPath(dir: string): string {
79
- return `slides/${dir ? `${urlPath(dir)}/` : ''}`
80
- }
81
-
82
- export interface ServiceOptions {
83
- root: string
84
- sessionOf(dir: string): DocumentSession
85
- serverHost: ServerHost
86
- url(): string
87
- preview?: { origins: string[] }
88
- stateDir: string
89
- log: Logger
90
- }
91
-
92
- export interface Service extends WorkspaceService {
93
- compiled(dir: string, compilation: Compilation, changed: string[]): void
94
- siteDoc(compilation: Compilation, doc: IRDocument): IRDocument
95
- removed(dirs: string[]): Promise<void>
96
- close(): Promise<void>
97
- }
98
-
99
- export function workspaceService(options: ServiceOptions): Service {
100
- const { root, log } = options
101
- const revisions = new WeakMap<Compilation, string>()
102
- const revisionOf = (compilation: Compilation) => {
103
- let known = revisions.get(compilation)
104
- if (!known) revisions.set(compilation, known = irRevision(compilation.doc))
105
- return known
106
- }
107
- const listeners = new Map<string, Set<(event: IrEvent) => void>>()
108
- const slides = new Map<string, Promise<{ update(compilation: Compilation): Promise<void>, close(): Promise<void> } | undefined>>()
109
- const proxies: ProxySpec[] = []
110
- let closed = false
111
-
112
- const eventOf = (dir: string, compilation: Compilation, changed: string[]): IrEvent => {
113
- const count = (severity: Finding['severity']) => compilation.doc.diagnostics.filter(finding => finding.severity === severity).length
114
- return { document: dir, revision: revisionOf(compilation), diagnostics: { errors: count('error'), warnings: count('warning') }, files: changed.map(file => workspacePath(root, file)) }
115
- }
116
- const stamped = (compilation: Compilation): Compilation => (options.preview ? { ...compilation, doc: withRevision(compilation.doc, revisionOf(compilation)) } : compilation)
117
-
118
- const slidesOf = (dir: string) => {
119
- let known = slides.get(dir)
120
- if (!known) {
121
- known = (async () => {
122
- const session = options.sessionOf(dir)
123
- const compilation = await session.ready()
124
- const configured = Object.keys(compilation.doc.config.renderers)
125
- if (configured.length > 0 && !configured.includes('slides')) return undefined
126
- const path = slidesPath(dir)
127
- const started = await dev(session.compiler, {
128
- outDir: join(options.stateDir, 'preview', encodeURIComponent(dir) || '_'),
129
- to: ['slides'],
130
- compilation: stamped(compilation),
131
- base: `/__presmium/${WORKSPACE_ID}/${path}`,
132
- port: await freePort(),
133
- ...options.preview ? { preview: { origins: options.preview.origins } } : {},
134
- onCompile(_compilation, findings) {
135
- for (const finding of findings.filter(f => f.checkId === 'presmium.render')) log.warn(`the slides' preview of ${dir || '.'}: ${finding.message}`)
136
- },
137
- })
138
- const server = started.servers[0]
139
- if (!server || closed) {
140
- await started.close()
141
- return undefined
142
- }
143
- const target = new URL(server.url)
144
- proxies.push({ path: path.replace(/\/$/, ''), target: `${target.origin}${target.pathname.replace(/\/$/, '')}` })
145
- proxies.sort((a, b) => b.path.length - a.path.length)
146
- return started
147
- })()
148
- known.catch((error: unknown) => {
149
- log.warn(`the slides' preview of ${dir || '.'} did not start: ${errorMessage(error)}`)
150
- slides.delete(dir)
151
- })
152
- slides.set(dir, known)
153
- }
154
- return known
155
- }
156
-
157
- const stop = async (dir: string) => {
158
- const known = slides.get(dir)
159
- slides.delete(dir)
160
- const path = slidesPath(dir).replace(/\/$/, '')
161
- const at = proxies.findIndex(proxy => proxy.path === path)
162
- if (at >= 0) proxies.splice(at, 1)
163
- await (await known?.catch(() => undefined))?.close()
164
- }
165
-
166
- return {
167
- proxies,
168
- async ir(document) {
169
- const compilation = await options.sessionOf(document).ready()
170
- const doc = relativeIR(compilation.doc, root, root)
171
- return { document, revision: revisionOf(compilation), doc, diagnostics: doc.diagnostics }
172
- },
173
- events(document, signal) {
174
- const waiting: IrEvent[] = []
175
- let wake: (() => void) | undefined
176
- const listener = (event: IrEvent) => {
177
- waiting.push(event)
178
- wake?.()
179
- }
180
- const own = listeners.get(document) ?? new Set()
181
- listeners.set(document, own.add(listener))
182
- const done = () => own.delete(listener)
183
- signal.addEventListener('abort', () => {
184
- done()
185
- wake?.()
186
- }, { once: true })
187
- const session = options.sessionOf(document)
188
- if (session.latest) waiting.push(eventOf(document, session.latest, []))
189
- else void session.ready().catch(() => undefined)
190
- return {
191
- async* [Symbol.asyncIterator]() {
192
- try {
193
- while (!signal.aborted) {
194
- const next = waiting.shift()
195
- if (next) {
196
- yield { event: 'revision', data: next }
197
- continue
198
- }
199
- await new Promise<void>(resolve => wake = resolve)
200
- wake = undefined
201
- }
202
- } finally {
203
- done()
204
- }
205
- },
206
- }
207
- },
208
- async preview(document) {
209
- const base = options.url()
210
- const web = `${base}/${document ? `${urlPath(document)}/` : ''}`
211
- const own = await slidesOf(document).catch(() => undefined)
212
- return { document, web, ...own ? { slides: `${base}/__presmium/${WORKSPACE_ID}/${slidesPath(document)}` } : {} }
213
- },
214
- async build(document, to) {
215
- return await options.serverHost.build(document, to ? { to } : {})
216
- },
217
- async check(document) {
218
- return { findings: await options.serverHost.check(document) }
219
- },
220
- compiled(dir, compilation, changed) {
221
- const event = eventOf(dir, compilation, changed)
222
- for (const listener of listeners.get(dir) ?? []) listener(event)
223
- const known = slides.get(dir)
224
- if (known) void known.then(started => started?.update(stamped(compilation))).catch((error: unknown) => log.warn(`the slides' preview of ${dir || '.'} did not update: ${errorMessage(error)}`))
225
- },
226
- siteDoc(compilation, doc) {
227
- return options.preview ? withRevision(doc, revisionOf(compilation)) : doc
228
- },
229
- async removed(dirs) {
230
- for (const dir of dirs) await stop(dir)
231
- },
232
- async close() {
233
- closed = true
234
- for (const dir of [...slides.keys()]) await stop(dir)
235
- },
236
- }
237
- }