@lincyaw/workspace 1.0.0-rc.0 → 1.0.0-rc.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.
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 +71 -9
  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
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Presmium's workspace: the site of several documents (`presmium build` at a workspace root) and the local workbench `presmium serve`, with the document templates.
4
4
 
5
- Part of [Presmium](https://github.com/Lincyaw/presmium), which turns lectures written in MyST Markdown into web pages, slides, Jupyter notebooks and narrated lectures.
5
+ Part of [Presmium](https://github.com/Lincyaw/presmium), which compiles canonical lecture.json teaching documents into web pages, slides, Jupyter notebooks and narrated playback.
6
6
 
7
7
  ## Install
8
8
 
@@ -10,11 +10,11 @@ It comes with `@lincyaw/cli`.
10
10
 
11
11
  ## Workspaces
12
12
 
13
- A workspace has `presmium.workspace.yaml` at its root and each document in a subdirectory with its own `lecture.md`:
13
+ A workspace has `presmium.workspace.yaml` at its root and each document in a subdirectory with its own `lecture.json`:
14
14
 
15
15
  ```yaml
16
16
  title: Introduction to machine learning
17
- documents: [week1/lecture.md, week2/lecture.md]
17
+ documents: [week1/lecture.json, week2/lecture.json]
18
18
  site:
19
19
  nav:
20
20
  - { text: Week 1, link: /week1/ }
@@ -24,11 +24,11 @@ site:
24
24
 
25
25
  ## The workbench (preview)
26
26
 
27
- `presmium serve` runs the workbench on your machine: the live site, editing the source in the browser, managing documents and the history of changes. Its browser side (`@lincyaw/workspace/client`, `@lincyaw/workspace/domain` and the routes behind it) is experimental and will be replaced by a new workbench; the command, the workspace configuration file and the Node services stay.
27
+ `presmium serve` runs the workbench on your machine: the live site, editing the source in the browser, managing documents and the history of changes. The routes the workbench (`@lincyaw/workbench`) uses are experimental; the command, the workspace configuration file and the Node services stay.
28
28
 
29
29
  ## Documentation
30
30
 
31
- - [Author guide](https://github.com/Lincyaw/presmium/blob/main/site/guide/lecture.en.md): workspaces
31
+ - [Author guide](https://github.com/Lincyaw/presmium/blob/main/site/guide/locales/en/lecture.json): workspaces
32
32
  - [Security and the trust model](https://github.com/Lincyaw/presmium/blob/main/SECURITY.md)
33
33
 
34
34
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincyaw/workspace",
3
- "version": "1.0.0-rc.0",
3
+ "version": "1.0.0-rc.10",
4
4
  "description": "Presmium's workspace service (presmium serve): documents, their history, reviews and the workspace site",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -23,7 +23,6 @@
23
23
  },
24
24
  "exports": {
25
25
  ".": "./src/index.ts",
26
- "./domain": "./src/domain/index.ts",
27
26
  "./package.json": "./package.json"
28
27
  },
29
28
  "files": [
@@ -31,9 +30,15 @@
31
30
  "templates"
32
31
  ],
33
32
  "dependencies": {
33
+ "image-size": "2.0.4",
34
+ "mime-types": "3.0.2",
34
35
  "yaml": "^2.9.1",
35
- "@lincyaw/compiler": "1.0.0-rc.0",
36
- "@lincyaw/server": "1.0.0-rc.0",
37
- "@lincyaw/kernel": "1.0.0-rc.0"
36
+ "@lincyaw/compiler": "1.0.0-rc.10",
37
+ "@lincyaw/kernel": "1.0.0-rc.10",
38
+ "@lincyaw/server": "1.0.0-rc.10"
39
+ },
40
+ "devDependencies": {
41
+ "@types/mime-types": "^3.0.1",
42
+ "@lincyaw/mcp": "1.0.0-rc.10"
38
43
  }
39
44
  }
@@ -1,4 +1,4 @@
1
- import type { Member, RevisionOrigin } from '@lincyaw/kernel/contracts'
1
+ import type { Member, RevisionOperation, RevisionOrigin } from '@lincyaw/kernel/contracts'
2
2
 
3
3
  export const ORIGIN_TRAILER = 'Presmium-Origin'
4
4
  export const TASK_TRAILER = 'Presmium-Task'
@@ -7,12 +7,6 @@ const CO_AUTHOR = 'Co-authored-by'
7
7
 
8
8
  const ORIGINS = new Set<RevisionOrigin['kind']>(['agent', 'document', 'editor'])
9
9
 
10
- export type RevisionOperation =
11
- | { op: 'create', dir: string }
12
- | { op: 'delete', dir: string }
13
- | { op: 'rename', dir: string, to: string }
14
- | { op: 'copy', dir: string, to: string }
15
-
16
10
  export type CommitOrigin = RevisionOrigin & { operation?: RevisionOperation }
17
11
 
18
12
  export function memberAddress(member: Member): string {
@@ -44,6 +38,12 @@ export function commitMessage(summary: string, body: string, origin: CommitOrigi
44
38
  return `${head}\n\n${body.trim() ? `${body.trim()}\n\n` : ''}${trailers.join('\n')}\n`
45
39
  }
46
40
 
41
+ /** The summary of a save that came without the author's: the file's name, in the language of the document holding it. */
42
+ export function saveSummary(path: string, lang: string | undefined): string {
43
+ const name = path.split('/').at(-1)!
44
+ return !lang || lang.startsWith('zh') ? `编辑 ${name}` : `Edit ${name}`
45
+ }
46
+
47
47
  export function trailers(message: string): Record<string, string[]> {
48
48
  const paragraphs = message.trim().split(/\n\s*\n/)
49
49
  const last = paragraphs.at(-1) ?? ''
@@ -0,0 +1,5 @@
1
+ export type LockKind = 'agent' | 'revert' | 'operation'
2
+
3
+ export function lockKind(holder: string): LockKind {
4
+ return holder.startsWith('agent:') ? 'agent' : holder.endsWith(':revert') ? 'revert' : 'operation'
5
+ }
@@ -32,7 +32,7 @@ export function leavesRoot(path: string): boolean {
32
32
  return path === '..' || path.startsWith('../')
33
33
  }
34
34
 
35
- export function within(path: string, dir: string): boolean {
35
+ export function isWithin(path: string, dir: string): boolean {
36
36
  return dir === '' || path === dir || path.startsWith(`${dir}/`)
37
37
  }
38
38
 
@@ -76,7 +76,7 @@ export function movedUrl(url: string, file: string, was: string, from: string, t
76
76
  const dirOf = (p: string) => p.split('/').slice(0, -1).join('/')
77
77
  const target = joinPath(dirOf(was), decoded)
78
78
  if (target === undefined) return undefined
79
- const moved = within(target, from) ? `${to}${target.slice(from.length)}` : target
79
+ const moved = isWithin(target, from) ? `${to}${target.slice(from.length)}` : target
80
80
  let next = relativePath(dirOf(file), moved)
81
81
  if (decoded.endsWith('/') && !next.endsWith('/')) next += '/'
82
82
  if (decoded.startsWith('./') && !next.startsWith('.')) next = `./${next}`
package/src/index.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  export { buildWorkspace } from './node/build.ts'
2
2
  export type { WorkspaceBuild } from './node/build.ts'
3
- export { isWorkspaceRoot, listDocuments, readWorkspace } from './node/config.ts'
4
- export type { FoundDocument, WorkspaceConfig } from './node/config.ts'
3
+ export { isWorkspaceRoot } from './node/config.ts'
5
4
  export { serveWorkspace } from './node/serve.ts'
6
5
  export type { ServeOptions, WorkspaceServer } from './node/serve.ts'
6
+ export { createDocumentTranscriptions } from './node/document-transcription.ts'
7
+ export { createTranscriptionAdoption } from './node/document-transcription-adoption.ts'
8
+ export { createDocumentAudioAuthoring } from './node/document-audio.ts'
9
+ export type { DocumentAudioAuthoringOptions, DocumentAudioServices } from './node/document-audio.ts'
10
+ export { createDocumentVoiceAuthoring } from './node/document-voice.ts'
11
+ export type { DocumentVoiceAuthoringOptions, DocumentVoiceServices } from './node/document-voice.ts'
@@ -0,0 +1,206 @@
1
+ import { randomBytes, randomUUID, createHash } from 'node:crypto'
2
+ import { AUTHORING_CLIENT_ACK_MAX_BYTES, AUTHORING_CLIENT_MAX_CLIENTS, AUTHORING_CLIENT_MAX_REQUESTS, AUTHORING_CLIENT_MAX_REQUEST_CHARS, AUTHORING_CLIENT_MAX_RESPONSE_CHARS, AUTHORING_CLIENT_REQUEST_TIMEOUT_MS, authoringClientResultFits, DOCUMENT_ENTRY, fnv1a64, hasRole, isRecord, jsonResponse, problemResponse, pushStream, sseResponse } from '@lincyaw/kernel'
3
+ import type { AuthoringClientAcknowledgement, AuthoringClientConnection, AuthoringClientDescriptor, AuthoringClientEvent, AuthoringClientLease, AuthoringClientOpen, AuthoringClientReport, AuthoringClientRequest, AuthoringClientRequestResult, AuthoringClients, CanonicalSource, Member, PushStream, Route } from '@lincyaw/kernel'
4
+ import { validateAuthoringClientAcknowledgement, validateAuthoringClientLease, validateAuthoringClientOpen, validateAuthoringClientQuery, validateAuthoringClientReport, validateAuthoringClientRequest, validateAuthoringClientRequestQuery } from '@lincyaw/kernel/node'
5
+ import { readAuthoringDocument } from './document-editor.ts'
6
+ import { catchRequestErrors, RequestError } from './errors.ts'
7
+ import type { SourceEditor } from './source-editor.ts'
8
+
9
+ type Client = { member: string, credential: string, value: AuthoringClientDescriptor, sequence: number, lastSignature?: string, send?(value: AuthoringClientEvent): void, close?(): void }
10
+ type Pending = { member: string, signature: string, request: AuthoringClientRequest, value: AuthoringClientRequestResult, timer?: ReturnType<typeof setTimeout> }
11
+ const sameSource = (a: CanonicalSource, b: CanonicalSource) => a.documentId === b.documentId && a.version === b.version && a.fingerprint === b.fingerprint
12
+ const size = (value: unknown) => JSON.stringify(value, null, 2).length
13
+ const budget = (value: unknown, maximum = AUTHORING_CLIENT_MAX_REQUEST_CHARS) => { const text = JSON.stringify(value); if (text === undefined) throw new RequestError(400, 'client-request-invalid'); if (text.length > maximum) throw new RequestError(413, 'client-request-too-large') }
14
+ const digest = (value: unknown) => createHash('sha256').update(JSON.stringify(value, (_, entry) => isRecord(entry) ? Object.fromEntries(Object.entries(entry).sort(([a], [b]) => a.localeCompare(b))) : entry)).digest('hex')
15
+ const terminal = (pending: Pending) => !['sent', 'executing'].includes(pending.value.state)
16
+
17
+ /** Ephemeral live-client control. It owns no document writer, compilation or durable task ledger. */
18
+ export function createAuthoringClients(editor: SourceEditor) {
19
+ const clients = new Map<string, Client>(), requests = new Map<string, Pending>()
20
+ let closed = false
21
+ const clientKey = (member: string, clientId: string) => JSON.stringify([member, clientId])
22
+ const requestKey = (member: string, document: string, query: { clientId: string, epoch: string, requestId: string }) => JSON.stringify([member, document, query.clientId, query.epoch, query.requestId])
23
+ const authorize = (document: string, member: Member) => {
24
+ if (closed) throw new RequestError(503, 'client-owner-closed')
25
+ if (!hasRole(member, 'editor')) throw new RequestError(403, 'client-permission-denied')
26
+ editor.readableFile({ document, home: false }, document ? `${document}/${DOCUMENT_ENTRY}` : DOCUMENT_ENTRY, member)
27
+ }
28
+ const current = (document: string, member: Member) => {
29
+ authorize(document, member)
30
+ const opened = readAuthoringDocument(editor, { document, member })
31
+ return { documentId: opened.content.id, version: opened.source.version, fingerprint: fnv1a64(opened.source.content) }
32
+ }
33
+ const connection = (document: string, lease: AuthoringClientLease, member: Member) => {
34
+ authorize(document, member)
35
+ const client = clients.get(clientKey(member.id, lease.clientId))
36
+ if (!client || client.value.document !== document || client.value.epoch !== lease.epoch || client.credential !== lease.credential
37
+ || client.value.connectionId !== lease.connectionId || !client.value.connected) throw new RequestError(409, 'client-connection-changed')
38
+ return client
39
+ }
40
+ const finish = (pending: Pending, state: AuthoringClientRequestResult['state'], code?: string) => {
41
+ if (terminal(pending)) return
42
+ clearTimeout(pending.timer); pending.timer = undefined
43
+ pending.value = { ...pending.value, state, ...code ? { error: { code } } : {} }
44
+ if (state === 'expired') clients.get(clientKey(pending.member, pending.request.clientId))?.send?.({ type: 'cancel', requestId: pending.request.requestId, connectionId: pending.request.expectedClientState.connectionId })
45
+ }
46
+ const disconnect = (client: Client, connectionId: string) => {
47
+ if (client.value.connectionId !== connectionId) return
48
+ client.value = { ...client.value, connected: false }
49
+ client.send = undefined; client.close = undefined
50
+ for (const pending of requests.values()) if (!terminal(pending) && pending.member === client.member && pending.request.clientId === client.value.clientId && pending.request.epoch === client.value.epoch
51
+ && pending.request.expectedClientState.connectionId === connectionId) finish(pending, 'expired', 'client-disconnected')
52
+ }
53
+ const readResult = (pending: Pending, actual?: CanonicalSource): AuthoringClientRequestResult => {
54
+ const client = clients.get(clientKey(pending.member, pending.request.clientId)), value = pending.value
55
+ return structuredClone({ ...value, current: Boolean(client?.value.connected && client.value.epoch === value.epoch && client.value.connectionId === value.connectionId
56
+ && value.snapshot && actual && sameSource(actual, value.snapshot.source) && client.value.snapshot.revision === value.snapshot.revision && sameSource(client.value.snapshot.source, value.snapshot.source)) })
57
+ }
58
+ const report = (client: Client, value: AuthoringClientReport, signature: string) => {
59
+ if (value.sequence < client.sequence || value.sequence === client.sequence && client.lastSignature !== signature) throw new RequestError(409, 'client-report-superseded')
60
+ if (value.sequence === client.sequence) return
61
+ if (value.snapshot.revision < client.value.snapshot.revision) throw new RequestError(409, 'client-state-superseded')
62
+ client.sequence = value.sequence; client.lastSignature = signature
63
+ client.value = { ...client.value, snapshot: structuredClone(value.snapshot), observedAt: new Date().toISOString() }
64
+ }
65
+ const service: AuthoringClients = {
66
+ async list(document, query, member) {
67
+ authorize(document, member); budget(query)
68
+ if (!validateAuthoringClientQuery(query)) throw new RequestError(400, 'client-query-invalid')
69
+ const all = [...clients.values()].filter(client => client.member === member.id && client.value.document === document && (!query.clientId || client.value.clientId === query.clientId)
70
+ && (!query.after || client.value.clientId > query.after)).sort((a, b) => a.value.clientId.localeCompare(b.value.clientId))
71
+ const answer = { document, clients: [] as AuthoringClientDescriptor[], unavailable: ['recording', 'new-comment-restore', 'active-comment-discard', 'timing-drafts', 'recording-drafts', 'review', 'open-document'] }
72
+ for (const client of all) {
73
+ const row = structuredClone(client.value)
74
+ if (size({ ...answer, clients: [...answer.clients, row], nextAfter: row.clientId }) > AUTHORING_CLIENT_MAX_RESPONSE_CHARS) break
75
+ answer.clients.push(row)
76
+ }
77
+ if (all.length && !answer.clients.length) throw new RequestError(413, 'client-state-too-large')
78
+ return { ...answer, ...answer.clients.length < all.length ? { nextAfter: answer.clients.at(-1)!.clientId } : {} }
79
+ },
80
+ async request(document, request, member) {
81
+ const actual = current(document, member); budget(request)
82
+ if (!validateAuthoringClientRequest(request)) throw new RequestError(400, 'client-request-invalid')
83
+ const key = requestKey(member.id, document, request), signature = digest(request), known = requests.get(key)
84
+ if (known) { if (known.signature !== signature) throw new RequestError(409, 'client-request-id-reused'); return readResult(known, actual) }
85
+ const client = clients.get(clientKey(member.id, request.clientId)), state = client?.value
86
+ if (!client || !state?.connected || state.document !== document || state.epoch !== request.epoch) throw new RequestError(409, 'client-unavailable')
87
+ if (state.connectionId !== request.expectedClientState.connectionId || state.snapshot.revision !== request.expectedClientState.revision) throw new RequestError(409, 'client-state-changed')
88
+ if (!sameSource(actual, request.source) || !sameSource(state.snapshot.source, request.source)) throw new RequestError(409, 'client-source-changed')
89
+ if (!state.snapshot.actions.includes(request.action.type)) throw new RequestError(409, 'client-action-unavailable')
90
+ if ([...requests.values()].some(value => !terminal(value) && value.member === member.id && value.request.clientId === request.clientId)) throw new RequestError(409, 'client-busy')
91
+ while (requests.size >= AUTHORING_CLIENT_MAX_REQUESTS) {
92
+ const disposable = [...requests].find(([, value]) => terminal(value))
93
+ if (!disposable) throw new RequestError(429, 'client-request-capacity')
94
+ requests.delete(disposable[0])
95
+ }
96
+ const now = Date.now(), value: AuthoringClientRequestResult = { document, clientId: state.clientId, epoch: state.epoch, connectionId: state.connectionId, requestId: request.requestId, action: request.action.type, source: request.source,
97
+ state: 'sent', admittedAt: new Date(now).toISOString(), expiresAt: new Date(now + AUTHORING_CLIENT_REQUEST_TIMEOUT_MS).toISOString(), verification: 'not-acknowledged', current: false }
98
+ const pending: Pending = { member: member.id, signature, request: structuredClone(request), value }
99
+ pending.timer = setTimeout(() => finish(pending, 'expired', 'client-request-timeout'), AUTHORING_CLIENT_REQUEST_TIMEOUT_MS)
100
+ requests.set(key, pending)
101
+ client.send?.({ type: 'request', request: pending.request, expiresAt: value.expiresAt })
102
+ return readResult(pending, actual)
103
+ },
104
+ async get(document, query, member) {
105
+ authorize(document, member); budget(query)
106
+ if (!validateAuthoringClientRequestQuery(query)) throw new RequestError(400, 'client-request-query-invalid')
107
+ const pending = requests.get(requestKey(member.id, document, query))
108
+ if (!pending) throw new RequestError(404, 'client-request-not-found')
109
+ let actual: CanonicalSource | undefined
110
+ try { actual = current(document, member) } catch { /* An old receipt stays readable if the current source is invalid. */ }
111
+ return readResult(pending, actual)
112
+ },
113
+ }
114
+ const register = (document: string, value: AuthoringClientOpen, member: Member): AuthoringClientLease => {
115
+ const source = current(document, member)
116
+ // Opening a transport may race an ordinary autosave. Requests still require the exact current source.
117
+ if (source.documentId !== value.snapshot.source.documentId) throw new RequestError(409, 'client-source-changed')
118
+ const key = clientKey(member.id, value.clientId), old = clients.get(key)
119
+ if (!old && clients.size >= AUTHORING_CLIENT_MAX_CLIENTS) {
120
+ const disposable = [...clients].find(([, client]) => !client.value.connected)
121
+ if (!disposable) throw new RequestError(429, 'client-capacity')
122
+ clients.delete(disposable[0])
123
+ }
124
+ old?.close?.()
125
+ const credential = randomBytes(32).toString('base64url'), connectionId = randomUUID()
126
+ clients.set(key, { member: member.id, credential, sequence: -1, value: { document, clientId: value.clientId, epoch: value.epoch, connectionId, connected: false,
127
+ snapshot: structuredClone(value.snapshot), observedAt: new Date().toISOString(), verification: 'reported-state' } })
128
+ return { clientId: value.clientId, epoch: value.epoch, connectionId, credential }
129
+ }
130
+ const open = (document: string, lease: AuthoringClientLease, member: Member, signal: AbortSignal): PushStream<{ event: string, data: AuthoringClientEvent }> => {
131
+ authorize(document, member)
132
+ const client = clients.get(clientKey(member.id, lease.clientId))
133
+ if (!client || client.credential !== lease.credential || client.value.document !== document || client.value.epoch !== lease.epoch) throw new RequestError(409, 'client-connection-changed')
134
+ client.close?.()
135
+ const connectionId = randomUUID()
136
+ client.value = { ...client.value, connected: true, connectionId }
137
+ const stream = pushStream<{ event: string, data: AuthoringClientEvent }>(push => {
138
+ client.send = data => push({ event: 'authoring-client', data })
139
+ return () => disconnect(client, connectionId)
140
+ }, { signal })
141
+ client.close = () => stream.end()
142
+ client.send?.({ type: 'connected', connectionId })
143
+ return stream
144
+ }
145
+ const acknowledge = (document: string, lease: AuthoringClientLease, ack: AuthoringClientAcknowledgement, member: Member) => {
146
+ const client = connection(document, lease, member), pending = requests.get(requestKey(member.id, document, { ...lease, requestId: ack.requestId }))
147
+ if (pending && Date.now() >= Date.parse(pending.value.expiresAt)) finish(pending, 'expired', 'client-request-timeout')
148
+ if (!pending || terminal(pending) || pending.request.expectedClientState.connectionId !== lease.connectionId) throw new RequestError(409, 'client-request-expired')
149
+ if (ack.state === 'executing' && pending.value.state !== 'sent' || ack.state === 'done' && pending.value.state !== 'executing') throw new RequestError(409, 'client-ack-state-invalid')
150
+ if ((ack.state === 'executing' || ack.state === 'done') && !sameSource(ack.snapshot.source, pending.request.source) && !(ack.state === 'done' && pending.request.action.type === 'source.flush' && ack.snapshot.source.documentId === pending.request.source.documentId)) throw new RequestError(409, 'client-source-changed')
151
+ const actual = ack.state === 'executing' || ack.state === 'done' ? current(document, member) : undefined
152
+ if (ack.state === 'executing' && (ack.snapshot.revision !== pending.request.expectedClientState.revision || !actual || !sameSource(actual, pending.request.source))) throw new RequestError(409, 'client-state-changed')
153
+ if (ack.state === 'done' && (!actual || !sameSource(actual, ack.snapshot.source))) throw new RequestError(409, 'client-source-changed')
154
+ const action = pending.request.action
155
+ const family = action.type.startsWith('comment-drafts.') ? 'comment' : action.type.startsWith('component-drafts.') ? 'component' : undefined
156
+ if (ack.result && (!family || ack.state !== 'done' || ack.result.type !== action.type || ack.result.family !== family)) throw new RequestError(400, 'client-result-invalid')
157
+ if (family && ack.state === 'done') {
158
+ const result = ack.result
159
+ const mutation = action.type === 'component-drafts.restore' || action.type === 'component-drafts.discard' || action.type === 'comment-drafts.restore' || action.type === 'comment-drafts.discard'
160
+ if (!result || (mutation ? ack.snapshot.revision <= pending.request.expectedClientState.revision : ack.snapshot.revision !== pending.request.expectedClientState.revision)) throw new RequestError(409, `client-${family}-drafts-changed`)
161
+ if (!authoringClientResultFits({ request: pending.request, connection: client.value, document, snapshot: ack.snapshot }, result)) throw new RequestError(413, `client-${family}-result-too-large`)
162
+ if ((action.type === 'comment-drafts.read' || action.type === 'component-drafts.read') && (result.type !== action.type || result.draft.id !== action.draft.id || result.draft.revision !== action.draft.revision || result.draft.token !== action.draft.token
163
+ || action.type === 'component-drafts.read' && (result.type !== action.type || result.draft.blockId !== action.draft.blockId || result.draft.stepId !== action.draft.stepId)
164
+ || result.offset !== action.offset || result.endOffset !== result.offset + result.jsonText.length || result.endOffset > result.totalUtf16
165
+ || result.jsonText.length > (action.maxChars ?? 2048))) throw new RequestError(400, 'client-result-invalid')
166
+ if ((action.type === 'comment-drafts.inspect' || action.type === 'component-drafts.inspect') && (result.type !== action.type || result.drafts.length > (action.limit ?? 10)
167
+ || action.cursor && action.cursor.token !== result.token || result.next && result.next.token !== result.token)) throw new RequestError(400, 'client-result-invalid')
168
+ const sameComment = (a: import('@lincyaw/kernel').ClientCommentDraftReference | null, b: import('@lincyaw/kernel').ClientCommentDraftReference | null) => a === null || b === null ? a === b
169
+ : a.id === b.id && a.revision === b.revision && a.token === b.token
170
+ if (action.type === 'comment-drafts.restore' && (result.type !== action.type || !sameComment(result.draft, action.draft) || result.composerId !== action.composerId
171
+ || !sameComment(result.preservedCurrent, action.expectedCurrent) || result.restored.revision !== 1 || result.restored.id === action.draft.id || result.restored.id === action.expectedCurrent?.id)) throw new RequestError(400, 'client-result-invalid')
172
+ if (action.type === 'comment-drafts.discard' && (result.type !== action.type || !sameComment(result.draft, action.draft) || !result.discarded)) throw new RequestError(400, 'client-result-invalid')
173
+ const sameDraft = (a: import('@lincyaw/kernel').ClientComponentDraftReference | null, b: import('@lincyaw/kernel').ClientComponentDraftReference | null) => a === null || b === null ? a === b
174
+ : a.id === b.id && a.revision === b.revision && a.token === b.token && a.blockId === b.blockId && a.stepId === b.stepId
175
+ if (action.type === 'component-drafts.restore' && (result.type !== action.type || !sameDraft(result.draft, action.draft) || !sameDraft(result.preservedCurrent, action.expectedCurrent)
176
+ || !result.restored.current || result.restored.storage !== 'durable' || result.restored.status !== 'available' || result.restored.composing
177
+ || result.restored.draft.id === action.draft.id || result.restored.draft.id === action.expectedCurrent?.id
178
+ || result.restored.draft.revision !== 1 || result.restored.draft.blockId !== action.draft.blockId || result.restored.draft.stepId !== action.draft.stepId)) throw new RequestError(400, 'client-result-invalid')
179
+ if (action.type === 'component-drafts.discard' && (result.type !== action.type || !sameDraft(result.draft, action.draft) || result.current !== action.current || !result.discarded)) throw new RequestError(400, 'client-result-invalid')
180
+
181
+ }
182
+ report(client, ack, digest(ack))
183
+ pending.value = { ...pending.value, observedAt: new Date().toISOString(), snapshot: structuredClone(ack.snapshot), verification: 'browser-acknowledged', ...ack.error ? { error: structuredClone(ack.error) } : {}, ...ack.result ? { result: structuredClone(ack.result) } : {} }
184
+ if (ack.state === 'executing') pending.value.state = 'executing'
185
+ else finish(pending, ack.state)
186
+ return { accepted: true }
187
+ }
188
+ const post = (kind: 'register' | 'report' | 'ack'): Route => ({ role: 'editor', maxBody: kind === 'ack' ? AUTHORING_CLIENT_ACK_MAX_BYTES : 16384, handle: (request, ctx) => catchRequestErrors(async () => {
189
+ if (request.method !== 'POST') return problemResponse(405, 'POST')
190
+ const value: unknown = await request.json().catch(() => undefined); budget(value, kind === 'ack' ? AUTHORING_CLIENT_MAX_RESPONSE_CHARS : AUTHORING_CLIENT_MAX_REQUEST_CHARS)
191
+ if (kind === 'register') { if (!validateAuthoringClientOpen(value)) throw new RequestError(400, 'client-open-invalid'); return jsonResponse(register(ctx.document, value, ctx.member)) }
192
+ if (!isRecord(value) || Object.keys(value).some(key => !['connection', 'report'].includes(key)) || !validateAuthoringClientLease(value.connection)) throw new RequestError(400, 'client-connection-invalid')
193
+ if (kind === 'ack') { if (!validateAuthoringClientAcknowledgement(value.report)) throw new RequestError(400, 'client-ack-invalid'); return jsonResponse(acknowledge(ctx.document, value.connection, value.report, ctx.member)) }
194
+ if (!validateAuthoringClientReport(value.report)) throw new RequestError(400, 'client-report-invalid')
195
+ report(connection(ctx.document, value.connection, ctx.member), value.report, digest(value.report)); return jsonResponse({ accepted: true })
196
+ }) })
197
+ return { service, routes: {
198
+ 'authoring-clients/register': post('register'), 'authoring-clients/report': post('report'), 'authoring-clients/ack': post('ack'),
199
+ 'authoring-clients/events': { role: 'editor', handle: (request, ctx) => catchRequestErrors(async () => {
200
+ if (request.method !== 'GET') return problemResponse(405, 'GET')
201
+ const query = new URL(request.url).searchParams, lease = { clientId: query.get('clientId'), epoch: query.get('epoch'), connectionId: query.get('connectionId'), credential: request.headers.get('X-Presmium-Authoring-Client') }
202
+ if (!validateAuthoringClientLease(lease)) throw new RequestError(400, 'client-connection-invalid')
203
+ return sseResponse(open(ctx.document, lease, ctx.member, request.signal), request.signal)
204
+ }) } satisfies Route,
205
+ }, async close() { closed = true; for (const client of clients.values()) client.close?.(); for (const pending of requests.values()) clearTimeout(pending.timer); requests.clear(); clients.clear() } }
206
+ }
package/src/node/build.ts CHANGED
@@ -1,9 +1,10 @@
1
- import { join, resolve } from 'node:path'
2
- import { Compiler } from '@lincyaw/compiler'
3
- import type { RunStats } from '@lincyaw/compiler'
4
- import { errorMessage } from '@lincyaw/kernel'
1
+ import { cpSync, existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { isAbsolute, join, resolve } from 'node:path'
3
+ import { build, Compiler } from '@lincyaw/compiler'
4
+ import type { Compilation, RunStats } from '@lincyaw/compiler'
5
+ import { errorMessage, isRecord } from '@lincyaw/kernel'
5
6
  import type { Finding } from '@lincyaw/kernel'
6
- import { listDocuments, readWorkspace } from './config.ts'
7
+ import { listDocuments, readWorkspaceConfig } from './config.ts'
7
8
  import { siteContext, siteDocument, siteIR, siteSpec, siteTranslations, webRenderer } from './site.ts'
8
9
 
9
10
  export interface WorkspaceBuild {
@@ -13,29 +14,90 @@ export interface WorkspaceBuild {
13
14
  }
14
15
 
15
16
  export async function buildWorkspace(root: string, options: { outDir?: string, bundled?: string } = {}): Promise<WorkspaceBuild> {
16
- const config = readWorkspace(root)
17
+ const config = readWorkspaceConfig(root)
17
18
  const found = listDocuments(config)
18
19
  const findings: Finding[] = []
19
20
  const documents: WorkspaceBuild['documents'] = []
20
21
  const site = siteSpec(config, [])
22
+ const base = siteBase(config.site.base)
23
+ const forms: { dir: string, compilation: Compilation, to: string[] }[] = []
21
24
  for (const document of found) {
22
25
  const compilation = await new Compiler(join(config.root, document.entry), { bundled: options.bundled, settings: config.settings }).compile({ pruneCache: true })
26
+ const to = presentations(compilation, `${base}${urlPath(document.dir)}/`, siteLook(config.site, config.root))
27
+ if (to.length > 0) forms.push({ dir: document.dir, compilation, to })
23
28
  const translated = await siteTranslations(compilation)
24
29
  findings.push(...compilation.doc.diagnostics, ...translated.findings)
25
- documents.push({ dir: document.dir, title: document.title, runs: compilation.runs })
26
- site.documents.push(siteDocument(document, siteIR(compilation), translated.translations))
30
+ documents.push({ dir: document.dir, title: document.title, runs: compilation.runStats })
31
+ site.documents.push(siteDocument(document, siteIR(compilation), translated.translations, compilation))
27
32
  }
28
33
  const outDir = join(resolve(options.outDir ?? join(config.root, 'dist')), 'web')
29
34
  const renderer = await webRenderer(config.root, options.bundled)
30
- const ctx = siteContext({ root: config.root, outDir, workDir: join(config.root, '.presmium', 'site'), base: siteBase(config.site.base), findings })
35
+ const ctx = siteContext({ root: config.root, outDir, workDir: join(config.root, '.presmium', 'site'), base, findings })
31
36
  try {
32
37
  await renderer.buildSite!(site, ctx)
33
38
  } catch (error) {
34
39
  findings.push({ checkId: 'presmium.render', severity: 'error', message: `the workspace site failed: ${errorMessage(error)}` })
40
+ return { outDir, documents, findings }
41
+ }
42
+ // A document's presentation and its narrated lecture are built beside its pages: `<dir>/slides/` and `<dir>/lecture/`.
43
+ for (const { dir, compilation, to } of forms) {
44
+ const work = join(config.root, '.presmium', 'forms', dir)
45
+ rmSync(work, { recursive: true, force: true })
46
+ try {
47
+ const report = await build(compilation, { outDir: work, to })
48
+ findings.push(...report.findings)
49
+ for (const form of to) if (existsSync(join(work, form))) cpSync(join(work, form), join(outDir, dir, form), { recursive: true })
50
+ if (to.includes('slides')) shareSlidesApp(join(outDir, dir, 'slides'), `${base}${urlPath(dir)}/slides/`, outDir, base)
51
+ } catch (error) {
52
+ findings.push({ checkId: 'presmium.render', severity: 'error', message: `document ${dir}: its ${to.join(' and ')} failed: ${errorMessage(error)}` })
53
+ }
35
54
  }
36
55
  return { outDir, documents, findings }
37
56
  }
38
57
 
58
+ const PRESENTATIONS = ['slides', 'lecture']
59
+
60
+ /** The site's theme and its parameters, for its presentations to look like its pages; a theme directory made absolute. */
61
+ function siteLook(site: Record<string, unknown>, root: string): Record<string, unknown> {
62
+ const theme = typeof site.theme === 'string' && (site.theme.startsWith('.') || isAbsolute(site.theme)) ? resolve(root, site.theme) : site.theme
63
+ return { ...theme !== undefined ? { theme } : {}, ...isRecord(site.params) ? { params: site.params } : {} }
64
+ }
65
+
66
+ const urlPath = (dir: string) => dir.split('/').map(encodeURIComponent).join('/')
67
+
68
+ /**
69
+ * Every document's slides run the same viewer app: it moves to the site's `_app/<key>/`, beside the web pages' own,
70
+ * so a reader downloads it once for all the presentations of the site, and the slides' page points there.
71
+ */
72
+ function shareSlidesApp(slides: string, slidesBase: string, outDir: string, base: string): void {
73
+ const app = join(slides, '_app')
74
+ if (!existsSync(app)) return
75
+ for (const key of readdirSync(app)) if (!existsSync(join(outDir, '_app', key))) cpSync(join(app, key), join(outDir, '_app', key), { recursive: true })
76
+ rmSync(app, { recursive: true, force: true })
77
+ const page = join(slides, 'index.html')
78
+ writeFileSync(page, readFileSync(page, 'utf8').replaceAll(`${slidesBase}_app/`, `${base}_app/`))
79
+ }
80
+
81
+ /**
82
+ * The presentation forms a document asks for (`metadata.renderers.slides` or `lecture`), set to be served below its
83
+ * pages at `prefix`: the slides at `slides/`, and the web pages and the slides linked to each other.
84
+ */
85
+ function presentations(compilation: Compilation, prefix: string, look: Record<string, unknown>): string[] {
86
+ const { renderers } = compilation.doc.config
87
+ const to = PRESENTATIONS.filter(form => renderers[form] !== undefined)
88
+ if (to.length === 0) return to
89
+ if (to.includes('slides')) renderers.slides = { ...look, ...isRecord(renderers.slides) ? renderers.slides : {}, base: `${prefix}slides/` }
90
+ const settings = compilation.doc.config.presmium ??= {}
91
+ const web = isRecord(settings['presmium.web']) ? settings['presmium.web'] : {}
92
+ settings['presmium.web'] = { ...web, webUrl: prefix, ...to.includes('slides') ? { slidesUrl: `${prefix}slides/` } : {} }
93
+ // The slides find the lecture where it is built, without probing `slides/lecture/` first.
94
+ if (to.includes('lecture')) {
95
+ const lecture = isRecord(settings['presmium.lecture']) ? settings['presmium.lecture'] : {}
96
+ settings['presmium.lecture'] = { url: `${prefix}lecture/`, ...lecture }
97
+ }
98
+ return to
99
+ }
100
+
39
101
  function siteBase(value: unknown): string {
40
102
  const path = typeof value === 'string' ? value.trim().replace(/^\/+|\/+$/g, '') : ''
41
103
  return path ? `/${path}/` : '/'