@lincyaw/workspace 0.0.0-stage → 1.0.0-rc.1

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 (44) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +35 -2
  3. package/package.json +41 -3
  4. package/src/domain/commit-message.ts +80 -0
  5. package/src/domain/locks.ts +5 -0
  6. package/src/domain/paths.ts +98 -0
  7. package/src/index.ts +11 -0
  8. package/src/node/authoring-clients.ts +206 -0
  9. package/src/node/build.ts +42 -0
  10. package/src/node/compile-session.ts +187 -0
  11. package/src/node/config.ts +141 -0
  12. package/src/node/document-assets.ts +30 -0
  13. package/src/node/document-audio.ts +473 -0
  14. package/src/node/document-authoring.ts +436 -0
  15. package/src/node/document-editor.ts +157 -0
  16. package/src/node/document-history.ts +190 -0
  17. package/src/node/document-images.ts +241 -0
  18. package/src/node/document-operations.ts +191 -0
  19. package/src/node/document-transcription-adoption.ts +96 -0
  20. package/src/node/document-transcription.ts +471 -0
  21. package/src/node/document-voice.ts +115 -0
  22. package/src/node/errors.ts +23 -0
  23. package/src/node/git.ts +68 -0
  24. package/src/node/jobs.ts +361 -0
  25. package/src/node/locks.ts +46 -0
  26. package/src/node/page-delete.ts +106 -0
  27. package/src/node/project-files.ts +163 -0
  28. package/src/node/projects.ts +192 -0
  29. package/src/node/review/agent-session-reviews.ts +374 -0
  30. package/src/node/review/git-guard.ts +104 -0
  31. package/src/node/review/git-review.ts +428 -0
  32. package/src/node/review/record.ts +77 -0
  33. package/src/node/review/routes.ts +73 -0
  34. package/src/node/review/task-reviews.ts +50 -0
  35. package/src/node/revisions.ts +599 -0
  36. package/src/node/routes.ts +329 -0
  37. package/src/node/serve.ts +503 -0
  38. package/src/node/site.ts +132 -0
  39. package/src/node/source-editor.ts +332 -0
  40. package/src/node/speech-settings.ts +21 -0
  41. package/src/node/templates.ts +27 -0
  42. package/src/node/workbench-api.ts +176 -0
  43. package/src/node/writer.ts +389 -0
  44. package/templates/document/default/lecture.json +75 -0
@@ -0,0 +1,332 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { WORKSPACE_CONFIG } from '@lincyaw/compiler'
5
+ import { DOCUMENT_ENTRY, validateDocument } from '@lincyaw/kernel'
6
+ import type { ConflictAnswer, DocumentFileChange, FilesAnswer, Member, Revision, SavedAnswer, SourceAnswer, SourceFile, SourceTransactionAnswer, SourceTransactionChange } from '@lincyaw/kernel'
7
+ import { isWithin } from '../domain/paths.ts'
8
+ import { listDocumentEntries, readDocumentSummary } from './config.ts'
9
+ import type { DocumentEntry, WorkspaceConfig } from './config.ts'
10
+ import { RequestError } from './errors.ts'
11
+ import { lockedError } from './locks.ts'
12
+ import type { DocumentLocks } from './locks.ts'
13
+ import { documentDirOf, FilesError, writeChanges } from './writer.ts'
14
+ import type { DocumentWriter } from './writer.ts'
15
+
16
+ export const MAX_SOURCE = 2 << 20
17
+ export const MAX_SOURCE_CHANGES = 16
18
+ const MAX_FILES = 2000
19
+ const HIDDEN_FROM_EDITOR = new Set(['node_modules', 'dist', '.git', '.presmium'])
20
+ const BINARY = /\.(?:png|jpe?g|gif|webp|avif|ico|bmp|pdf|wasm|zip|gz|tgz|bz2|xz|7z|woff2?|ttf|otf|eot|mp3|mp4|m4a|wav|ogg|oga|webm|mov|avi|mkv|bin|so|dylib|dll|exe|pyc|xlsx?|docx?|pptx?|parquet|npy|npz|pkl|db|sqlite)$/i
21
+
22
+ export function validateDocumentSource(path: string, content: string, before?: Uint8Array): void {
23
+ if (path !== DOCUMENT_ENTRY && !path.endsWith(`/${DOCUMENT_ENTRY}`)) return
24
+ let value: unknown
25
+ try { value = JSON.parse(content.replace(/^\uFEFF/, '')) }
26
+ catch { throw new RequestError(422, 'document-invalid', { issues: [{ path: '', code: 'json-syntax', message: 'The document is not valid JSON.', severity: 'error' }] }) }
27
+ const checked = validateDocument(value)
28
+ if (!checked.document || checked.issues.some(issue => issue.severity === 'error')) throw new RequestError(422, 'document-invalid', { issues: checked.issues })
29
+ if (before) {
30
+ let previous: unknown
31
+ try { previous = JSON.parse(Buffer.from(before).toString('utf8').replace(/^\uFEFF/, '')) } catch {}
32
+ if (previous && typeof previous === 'object' && 'id' in previous && typeof previous.id === 'string' && previous.id !== checked.document.id) throw new RequestError(422, 'document-identity-changed')
33
+ }
34
+ }
35
+
36
+ export function versionOf(bytes: Uint8Array | string): string {
37
+ return createHash('sha256').update(bytes).digest('hex').slice(0, 20)
38
+ }
39
+
40
+ export interface SourceEditorOptions {
41
+ config: WorkspaceConfig
42
+ writer: DocumentWriter
43
+ locks?: DocumentLocks
44
+ }
45
+
46
+ interface Scope {
47
+ document: string
48
+ home: boolean
49
+ }
50
+
51
+ export interface SourceTransactionHistory {
52
+ run<T>(work: () => Promise<T>): Promise<T>
53
+ commit(paths: string[], lang: string | undefined): Promise<Revision | undefined>
54
+ }
55
+ interface TransactionFile { path: string, content: string | Uint8Array, base: string | null }
56
+
57
+ /** The source files the browser's editor lists, opens, creates and saves, as far as the member and the page's document may. */
58
+ export class SourceEditor {
59
+ readonly #config: WorkspaceConfig
60
+ readonly #writer: DocumentWriter
61
+ readonly #locks?: DocumentLocks
62
+
63
+ constructor(options: SourceEditorOptions) {
64
+ this.#config = options.config
65
+ this.#writer = options.writer
66
+ this.#locks = options.locks
67
+ }
68
+
69
+ scope(document: string): Scope {
70
+ return { document, home: document === '' && !this.#config.singleDeck }
71
+ }
72
+
73
+ roots(scope: Scope): string[] {
74
+ if (!scope.home) return [scope.document]
75
+ return ['']
76
+ }
77
+
78
+ canEdit(scope: Scope, path: string, member: Member): boolean {
79
+ return this.#canEdit(scope, path, member)
80
+ }
81
+
82
+ #canEdit(scope: Scope, path: string, member: Member, documents?: readonly DocumentEntry[]): boolean {
83
+ if (path === WORKSPACE_CONFIG) return (scope.home || (this.#config.singleDeck && scope.document === '')) && member.roles.includes('owner')
84
+ if (scope.home && member.roles.includes('owner')) return true
85
+ const dir = documentDirOf(this.#config, path)
86
+ if (dir === undefined) return scope.home && member.roles.includes('owner')
87
+ return scope.home ? (documents ?? listDocumentEntries(this.#config)).some(doc => doc.dir === dir) : dir === scope.document
88
+ }
89
+
90
+ files(scope: Scope, member: Member): FilesAnswer {
91
+ const files: SourceFile[] = []
92
+ const documents = scope.home ? listDocumentEntries(this.#config) : undefined
93
+ let truncated = false
94
+ const walk = (dir: string, owner: string | undefined) => {
95
+ let names: string[]
96
+ try {
97
+ names = readdirSync(join(this.#config.root, dir)).sort()
98
+ } catch {
99
+ return
100
+ }
101
+ for (const name of names) {
102
+ if (HIDDEN_FROM_EDITOR.has(name) || name.startsWith('.')) continue
103
+ const path = dir ? `${dir}/${name}` : name
104
+ const found = lstatSync(join(this.#config.root, path), { throwIfNoEntry: false })
105
+ if (!found || found.isSymbolicLink()) continue
106
+ if (found.isDirectory() && (scope.home || documentDirOf(this.#config, `${path}/x`) === owner)) {
107
+ walk(path, owner)
108
+ } else if (found.isFile() && this.#canEdit(scope, path, member, documents)) {
109
+ if (files.length >= MAX_FILES) {
110
+ truncated = true
111
+ return
112
+ }
113
+ files.push({ path, ...BINARY.test(name) ? { binary: true as const } : {} })
114
+ }
115
+ }
116
+ }
117
+ walk(scope.home ? '' : scope.document, scope.home ? undefined : scope.document)
118
+ return { document: scope.document, home: scope.home, ...documents ? { documents: documents.map(doc => doc.dir) } : {}, files, ...truncated ? { truncated: true as const } : {} }
119
+ }
120
+
121
+ #documentHolding(path: string, documents: readonly DocumentEntry[]): DocumentEntry | undefined {
122
+ let found: DocumentEntry | undefined
123
+ for (const doc of documents) if (isWithin(path, doc.dir) && (!found || doc.dir.length > found.dir.length)) found = doc
124
+ return found
125
+ }
126
+
127
+ /** Checks path and document policy without requiring a file to exist. */
128
+ assertEditable(scope: Scope, path: string, member: Member): void {
129
+ this.#assertEditable(scope, path, member)
130
+ }
131
+
132
+ #assertEditable(scope: Scope, path: string, member: Member, documents?: readonly DocumentEntry[]): void {
133
+ if (typeof path !== 'string' || !path || path.startsWith('/') || path.includes('\\') || path.includes('\0') || path.split('/').some(part => part === '' || part === '.' || part === '..' || HIDDEN_FROM_EDITOR.has(part))) throw new RequestError(400, 'workspace-path-invalid', { path: String(path).slice(0, 500) })
134
+ if (!this.#canEdit(scope, path, member, documents)) throw new RequestError(403, 'workspace-file-forbidden', { path })
135
+ }
136
+
137
+ /** A regular file under the same path and document policy as source editing. */
138
+ readableFile(scope: Scope, path: string, member: Member): string {
139
+ return this.#readableFile(scope, path, member)
140
+ }
141
+
142
+ #readableFile(scope: Scope, path: string, member: Member, documents?: readonly DocumentEntry[]): string {
143
+ this.#assertEditable(scope, path, member, documents)
144
+ const file = join(this.#config.root, path)
145
+ let real: string
146
+ try {
147
+ real = realpathSync(file)
148
+ } catch {
149
+ throw new RequestError(404, 'workspace-file-missing', { path })
150
+ }
151
+ if (real !== file || !lstatSync(file).isFile()) throw new RequestError(403, 'workspace-file-irregular', { path })
152
+ return file
153
+ }
154
+
155
+ #readBytes(scope: Scope, path: string, member: Member, documents?: readonly DocumentEntry[]): Buffer {
156
+ const file = this.#readableFile(scope, path, member, documents)
157
+ const bytes = readFileSync(file)
158
+ if (bytes.byteLength > MAX_SOURCE) throw new RequestError(413, `${path} is larger than the editor opens (${MAX_SOURCE} bytes)`)
159
+ return bytes
160
+ }
161
+
162
+ read(scope: Scope, path: string, member: Member): SourceAnswer {
163
+ const bytes = this.#readBytes(scope, path, member)
164
+ if (BINARY.test(path) || bytes.includes(0)) throw new RequestError(415, `${path} is not a text file`)
165
+ let content: string
166
+ try {
167
+ content = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes)
168
+ } catch {
169
+ throw new RequestError(415, `${path} is not UTF-8 text`)
170
+ }
171
+ return { path, content, version: versionOf(bytes) }
172
+ }
173
+
174
+ async create(scope: Scope, path: string, member: Member): Promise<SourceAnswer> {
175
+ const documents = listDocumentEntries(this.#config)
176
+ this.#assertEditable(scope, path, member, documents)
177
+ if (path === DOCUMENT_ENTRY || path.endsWith(`/${DOCUMENT_ENTRY}`)) throw new RequestError(422, 'create-a-document-with-valid-content')
178
+ if (BINARY.test(path)) throw new RequestError(415, `${path} is not a text file`)
179
+ const document = this.#documentHolding(path, documents)?.dir
180
+ const scopeDir = document ?? ''
181
+ const release = this.#locks ? this.#locks.claim(scopeDir, 'editor:create') : () => {}
182
+ if (!release) throw lockedError(scopeDir, this.#locks!.holder(scopeDir)!)
183
+ try {
184
+ await this.#writer.create(path, Buffer.alloc(0), document === undefined)
185
+ return { path, content: '', version: versionOf('') }
186
+ } catch (error) {
187
+ if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw new RequestError(409, `${path} already exists`, { exists: true })
188
+ if (error instanceof FilesError) throw new RequestError(403, error.message)
189
+ throw error
190
+ } finally {
191
+ release()
192
+ }
193
+ }
194
+
195
+ /** Saves related sources as one authoring change; any failed write or commit restores all preimages. */
196
+ async transaction(scope: Scope, changes: SourceTransactionChange[], member: Member, history?: SourceTransactionHistory): Promise<SourceTransactionAnswer> {
197
+ return this.#transaction(scope, changes, member, history)
198
+ }
199
+
200
+ /** Checks the same scope, size, CAS and candidate rules without writing or committing. */
201
+ async planTransaction(scope: Scope, changes: SourceTransactionChange[], member: Member): Promise<SourceTransactionAnswer> {
202
+ return this.#transaction(scope, changes, member, undefined, undefined, true)
203
+ }
204
+
205
+ /** A validated authoring document and one new original attachment share CAS, rollback and history. */
206
+ async transactionWithBinary(scope: Scope, document: SourceTransactionChange, attachment: { path: string, content: Uint8Array }, member: Member, history?: SourceTransactionHistory): Promise<SourceTransactionAnswer> {
207
+ if (document.path !== DOCUMENT_ENTRY && !document.path.endsWith(`/${DOCUMENT_ENTRY}`) || document.base === null) throw new RequestError(400, 'document-transaction-required')
208
+ if (!attachment.content.byteLength || attachment.content.byteLength > 16 << 20) throw new RequestError(413, 'production.asset-size')
209
+ return this.#transaction(scope, [document, { ...attachment, content: new Uint8Array(attachment.content), base: null }], member, history, attachment.path)
210
+ }
211
+
212
+ async #transaction(scope: Scope, changes: TransactionFile[], member: Member, history?: SourceTransactionHistory, binaryPath?: string, dryRun = false): Promise<SourceTransactionAnswer> {
213
+ if (changes.length === 0 || changes.length > MAX_SOURCE_CHANGES) throw new RequestError(400, `a source transaction takes 1 to ${MAX_SOURCE_CHANGES} files`)
214
+ const paths = new Set<string>()
215
+ const known = listDocumentEntries(this.#config)
216
+ let bytes = 0
217
+ const documents = changes.map(change => {
218
+ this.#assertEditable(scope, change.path, member, known)
219
+ if (paths.has(change.path)) throw new RequestError(400, `a source path occurs twice: ${change.path}`)
220
+ paths.add(change.path)
221
+ if (change.path !== binaryPath) {
222
+ if (typeof change.content !== 'string' || BINARY.test(change.path) || change.content.includes('\0')) throw new RequestError(415, `${change.path} is not a text file`)
223
+ bytes += Buffer.byteLength(change.content, 'utf8')
224
+ }
225
+ return this.#documentHolding(change.path, known)
226
+ })
227
+ if (bytes > MAX_SOURCE) throw new RequestError(413, `the transaction is larger than the editor saves (${MAX_SOURCE} bytes)`)
228
+ for (const path of paths) if ([...paths].some(other => path !== other && path.startsWith(`${other}/`))) throw new RequestError(400, `a source file cannot also be a parent directory: ${path}`)
229
+ const document = documents[0]
230
+ if (!document || documents.some(found => found?.dir !== document.dir)) throw new RequestError(400, 'a source transaction must stay in one document')
231
+ const scopeDir = document.dir
232
+ const release = this.#locks ? this.#locks.claim(scopeDir, 'editor:transaction') : () => {}
233
+ if (!release) throw lockedError(scopeDir, this.#locks!.holder(scopeDir)!)
234
+ const work = async (): Promise<SourceTransactionAnswer> => {
235
+ // Read every preimage before creating directories or writing any file.
236
+ const snapshots = changes.map(change => {
237
+ const written = typeof change.content === 'string' ? Buffer.from(change.content, 'utf8') : Buffer.from(change.content)
238
+ if (change.base !== null) {
239
+ const before = this.#readBytes(scope, change.path, member, known)
240
+ const version = versionOf(before)
241
+ if (version !== change.base) throw new RequestError(409, `${change.path} changed since it was opened`, { conflict: { path: change.path, version, content: before.toString('utf8') } })
242
+ if (BINARY.test(change.path) || before.includes(0)) throw new RequestError(415, `${change.path} is not a text file`)
243
+ try { new TextDecoder('utf-8', { fatal: true }).decode(before) } catch { throw new RequestError(415, `${change.path} is not UTF-8 text`) }
244
+ const executable = (lstatSync(join(this.#config.root, change.path)).mode & 0o111) !== 0
245
+ return { path: change.path, before, written, executable }
246
+ }
247
+ const file = join(this.#config.root, change.path)
248
+ let parent = this.#config.root
249
+ for (const part of change.path.split('/').slice(0, -1)) {
250
+ parent = join(parent, part)
251
+ const found = lstatSync(parent, { throwIfNoEntry: false })
252
+ if (found && (!found.isDirectory() || realpathSync(parent) !== parent)) throw new RequestError(403, `${change.path} has no regular workspace parent`)
253
+ if (!found) break
254
+ }
255
+ if (lstatSync(file, { throwIfNoEntry: false })) throw new RequestError(409, `${change.path} already exists`, { exists: true })
256
+ return { path: change.path, before: undefined, written, executable: false }
257
+ })
258
+ for (const snapshot of snapshots) if (snapshot.path !== binaryPath) validateDocumentSource(snapshot.path, snapshot.written.toString('utf8'), snapshot.before)
259
+ if (dryRun) return { versions: snapshots.map(snapshot => ({ path: snapshot.path, version: versionOf(snapshot.written) })) }
260
+ const created: string[] = []
261
+ const edited = snapshots.filter(snapshot => snapshot.before && !snapshot.written.equals(snapshot.before))
262
+ const touched = snapshots.filter(snapshot => !snapshot.before || !snapshot.written.equals(snapshot.before)).map(snapshot => snapshot.path)
263
+ const lang = history && touched.length ? readDocumentSummary(this.#config, document).lang : undefined
264
+ const write = (next: DocumentFileChange[]) => this.#writer.write(next)
265
+ try {
266
+ // Exclusive creation prevents replacing a file created outside serve after validation.
267
+ for (const snapshot of snapshots) if (!snapshot.before) {
268
+ await this.#writer.create(snapshot.path, snapshot.written)
269
+ created.push(snapshot.path)
270
+ }
271
+ if (edited.length) await write(edited.map(snapshot => ({ path: snapshot.path, content: snapshot.written, ...snapshot.executable ? { executable: true } : {} })))
272
+ const revision = history && touched.length ? await history.commit(touched, lang) : undefined
273
+ return { versions: snapshots.map(snapshot => ({ path: snapshot.path, version: versionOf(snapshot.written) })), ...revision ? { revision } : {} }
274
+ } catch (error) {
275
+ try {
276
+ await write([
277
+ ...snapshots.flatMap(snapshot => snapshot.before ? [{ path: snapshot.path, content: snapshot.before, ...snapshot.executable ? { executable: true } : {} }] : []),
278
+ ...created.map(path => ({ path, remove: true as const })),
279
+ ])
280
+ } catch (rollback) {
281
+ throw new Error(`source transaction failed (${error instanceof Error ? error.message : String(error)}); restoring sources also failed (${rollback instanceof Error ? rollback.message : String(rollback)})`)
282
+ }
283
+ throw error
284
+ }
285
+ }
286
+ try {
287
+ return await (history ? history.run(work) : work())
288
+ } finally {
289
+ release()
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Writes the text over the version the editor opened. `commit` makes a change a revision while the document's lock
295
+ * is still held; when it fails, the file is put back as it was and the save fails.
296
+ */
297
+ async save(scope: Scope, path: string, content: string, base: string, member: Member, commit?: (path: string, lang: string | undefined) => Promise<Revision | undefined>): Promise<SavedAnswer> {
298
+ const documents = listDocumentEntries(this.#config)
299
+ const document = this.#documentHolding(path, documents)
300
+ const outside = document === undefined
301
+ const scopeDir = document?.dir ?? ''
302
+ const release = this.#locks ? this.#locks.claim(scopeDir, 'editor:save') : () => {}
303
+ if (!release) throw lockedError(scopeDir, this.#locks!.holder(scopeDir)!)
304
+ try {
305
+ const bytes = this.#readBytes(scope, path, member, documents)
306
+ const now = versionOf(bytes)
307
+ if (now !== base) {
308
+ const conflict: Omit<ConflictAnswer, 'error'> = { conflict: { version: now, content: bytes.toString('utf8') } }
309
+ throw new RequestError(409, `${path} changed since it was opened`, conflict)
310
+ }
311
+ const written = Buffer.from(content, 'utf8')
312
+ validateDocumentSource(path, content, bytes)
313
+ if (written.byteLength > MAX_SOURCE) throw new RequestError(413, `the text is larger than the editor saves (${MAX_SOURCE} bytes)`)
314
+ const change: DocumentFileChange = { path, content: written, ...(lstatSync(join(this.#config.root, path)).mode & 0o111) !== 0 ? { executable: true } : {} }
315
+ const lang = commit && !written.equals(bytes) && document ? readDocumentSummary(this.#config, document).lang : undefined
316
+ const write = async (next: DocumentFileChange) => (outside ? writeChanges(this.#config, [next], true) : await this.#writer.write([next]))
317
+ await write(change)
318
+ let revision: Revision | undefined
319
+ if (commit && !written.equals(bytes)) {
320
+ try {
321
+ revision = await commit(path, lang)
322
+ } catch (error) {
323
+ await write({ ...change, content: bytes })
324
+ throw error
325
+ }
326
+ }
327
+ return { version: versionOf(written), ...revision ? { revision } : {} }
328
+ } finally {
329
+ release()
330
+ }
331
+ }
332
+ }
@@ -0,0 +1,21 @@
1
+ import { isRecord, jsonResponse, problemResponse } from '@lincyaw/kernel'
2
+ import type { Route, SpeechSettingsChange } from '@lincyaw/kernel'
3
+ import { OperatorSpeechSettings, SpeechSettingsError } from '@lincyaw/kernel/node'
4
+
5
+ /** Global operator route: works before opening a document and never returns credential values. */
6
+ export function speechSettingsRoute(directory: string, fallback: (name: string) => string | undefined): Route {
7
+ const settings = new OperatorSpeechSettings(directory, fallback)
8
+ return { role: 'owner', maxBody: 16384, handle: async request => {
9
+ try {
10
+ if (request.method === 'GET') return jsonResponse(settings.read(), 200, { 'cache-control': 'no-store' })
11
+ if (request.method !== 'POST') return problemResponse(405, 'GET or POST')
12
+ const body: unknown = await request.json().catch(() => undefined)
13
+ if (!isRecord(body) || typeof body.revision !== 'string' || !['save', 'clear'].includes(String(body.action)) || Object.keys(body).some(key => !['action', 'revision', 'apiKey', 'appId'].includes(key))
14
+ || body.action === 'save' && (body.apiKey !== undefined && typeof body.apiKey !== 'string' || body.appId !== undefined && typeof body.appId !== 'string')) return problemResponse(400, 'speech-settings-invalid')
15
+ return jsonResponse(await settings.change(body as unknown as SpeechSettingsChange), 200, { 'cache-control': 'no-store' })
16
+ } catch (error) {
17
+ const code = error instanceof SpeechSettingsError ? error.code : 'speech-settings-unavailable'
18
+ return problemResponse(code === 'speech-settings-invalid' ? 400 : code === 'speech-settings-conflict' || code === 'speech-settings-busy' ? 409 : 503, code)
19
+ }
20
+ } }
21
+ }
@@ -0,0 +1,27 @@
1
+ import { existsSync, lstatSync, readdirSync, statSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import type { WorkspaceConfig } from './config.ts'
5
+
6
+ const BUILTIN_TEMPLATES = fileURLToPath(new URL('../../templates/document', import.meta.url))
7
+
8
+ export const DEFAULT_TEMPLATE = 'default'
9
+
10
+ function templatesDir(config: WorkspaceConfig): string {
11
+ return config.templates ?? BUILTIN_TEMPLATES
12
+ }
13
+
14
+ export function listTemplates(config: WorkspaceConfig): string[] {
15
+ const dir = templatesDir(config)
16
+ try {
17
+ return readdirSync(dir).filter(name => statSync(join(dir, name)).isDirectory()).sort()
18
+ } catch {
19
+ return []
20
+ }
21
+ }
22
+
23
+ export function findTemplate(config: WorkspaceConfig, name: string): string | undefined {
24
+ if (!name || name.includes('/') || name.includes('\\') || name.startsWith('.')) return undefined
25
+ const dir = join(templatesDir(config), name)
26
+ return existsSync(dir) && lstatSync(dir, { throwIfNoEntry: false })?.isDirectory() ? dir : undefined
27
+ }
@@ -0,0 +1,176 @@
1
+ import { relative, sep } from 'node:path'
2
+ import { relativeIR } from '@lincyaw/compiler'
3
+ import type { Compilation } from '@lincyaw/compiler'
4
+ import { errorMessage, PREVIEW_REVISION, PreviewPreparing, pushStream } from '@lincyaw/kernel'
5
+ import { irRevision } from '@lincyaw/kernel/node'
6
+ import type { DocumentVoiceAuthoring, DocumentVoiceCapabilities, Finding, IrAnswer, IRDocument, IrEvent, Logger, Member, PreviewAnswer, PreviewRenderer, VoiceCatalogAnswer } from '@lincyaw/kernel'
7
+ import type { DiagnosticStore, ServerHost } from '@lincyaw/server'
8
+ import type { CompileSession } from './compile-session.ts'
9
+ import { RequestError } from './errors.ts'
10
+
11
+ // The workbench asks again this soon when the viewer app or the document's code is still being built.
12
+ const PREPARING_RETRY_MS = 1000
13
+
14
+ export interface BuildAnswer {
15
+ outputs: { renderer: string, outDir: string }[]
16
+ findings: Finding[]
17
+ }
18
+
19
+ export interface CheckAnswer {
20
+ findings: Finding[]
21
+ }
22
+
23
+ /** What the workbench's routes ask of the workspace server: a document's IR and its changes, its previews, builds and checks. */
24
+ export interface WorkbenchApi {
25
+ ir(document: string): Promise<IrAnswer>
26
+ /** Observe already-requested compilations without starting one for source editing. */
27
+ onCompiled(document: string, listener: (event: IrEvent) => void): () => void
28
+ voiceCatalog(document: string, member: Member, engine?: string, lang?: string, signal?: AbortSignal): Promise<VoiceCatalogAnswer>
29
+ voiceCapabilities(document: string, member: Member, signal?: AbortSignal): Promise<DocumentVoiceCapabilities>
30
+ events(document: string, signal: AbortSignal): AsyncIterable<{ event: string, data: IrEvent }>
31
+ /**
32
+ * The document's preview addresses: the viewer app's page of the document, and the same page showing its slides.
33
+ * `prefetch`: the workbench prepares the document ahead of the author's click, answered at once. A build still running
34
+ * after the wait is answered with 503 `preview-preparing` and `Retry-After`.
35
+ */
36
+ preview(document: string, renderer?: PreviewRenderer, prefetch?: boolean): Promise<PreviewAnswer>
37
+ build(document: string, to?: string[]): Promise<BuildAnswer>
38
+ check(document: string): Promise<CheckAnswer>
39
+ }
40
+
41
+ function withIrRevision(doc: IRDocument, revision: string): IRDocument {
42
+ return { ...doc, meta: { ...doc.meta, [PREVIEW_REVISION]: revision } }
43
+ }
44
+
45
+ function workspacePath(root: string, file: string): string {
46
+ return relative(root, file).split(sep).join('/')
47
+ }
48
+
49
+ function urlPath(dir: string): string {
50
+ return dir.split('/').map(encodeURIComponent).join('/')
51
+ }
52
+
53
+ export interface WorkbenchBackendOptions {
54
+ root: string
55
+ compileSessionOf(dir: string): CompileSession
56
+ serverHost: ServerHost
57
+ voices: DocumentVoiceAuthoring
58
+ diagnostics?: DiagnosticStore
59
+ /** The origin browsers reach the service at; `basePath` is the site's path on it (default `/`). */
60
+ url(): string
61
+ basePath?: string
62
+ preview?: { origins: string[] }
63
+ log: Logger
64
+ /** Starts the web dev server of a document ahead of its first request. */
65
+ warmSite?(dir: string): Promise<void>
66
+ /** The path of the document's page in the viewer app, once it can be shown there (prepared when not; `wait`: a while). */
67
+ viewerPage?(dir: string, wait: boolean): Promise<string | undefined>
68
+ }
69
+
70
+ /** The workbench's API with what the workspace server tells it: each compilation, the documents removed, closing. */
71
+ export interface WorkbenchBackend extends WorkbenchApi {
72
+ compiled(dir: string, compilation: Compilation, changed: string[]): void
73
+ /** The document as the site shows it, stamped with its IR revision when pages embed previews. */
74
+ stampPreviewRevision(compilation: Compilation, doc: IRDocument): IRDocument
75
+ documentsRemoved(dirs: string[]): Promise<void>
76
+ close(): Promise<void>
77
+ }
78
+
79
+ export function createWorkbenchBackend(options: WorkbenchBackendOptions): WorkbenchBackend {
80
+ const { root, log } = options
81
+ const irRevisions = new WeakMap<Compilation, string>()
82
+ const relativeDocs = new WeakMap<Compilation, IRDocument>()
83
+ const irRevisionOf = (compilation: Compilation) => {
84
+ let known = irRevisions.get(compilation)
85
+ if (!known) irRevisions.set(compilation, known = irRevision(compilation.doc))
86
+ return known
87
+ }
88
+ const listeners = new Map<string, Set<(event: IrEvent) => void>>()
89
+ let previewRequest = 0
90
+
91
+ const eventOf = (dir: string, compilation: Compilation, changed: string[]): IrEvent => {
92
+ const count = (severity: Finding['severity']) => compilation.doc.diagnostics.filter(finding => finding.severity === severity).length
93
+ return { document: dir, revision: irRevisionOf(compilation), diagnostics: { errors: count('error'), warnings: count('warning') }, files: changed.map(file => workspacePath(root, file)) }
94
+ }
95
+ return {
96
+ onCompiled(document, listener) {
97
+ const own = listeners.get(document) ?? new Set()
98
+ listeners.set(document, own); own.add(listener)
99
+ return () => { own.delete(listener); if (!own.size) listeners.delete(document) }
100
+ },
101
+ voiceCapabilities: (document, member, signal) => options.voices.capabilities(document, member, signal),
102
+ voiceCatalog: (document, member, engine, lang, signal) => options.voices.catalog(document, { ...engine ? { engine } : {}, ...lang ? { lang } : {} }, member, signal),
103
+ async ir(document) {
104
+ const compilation = await options.compileSessionOf(document).ready()
105
+ let doc = relativeDocs.get(compilation)
106
+ if (!doc) relativeDocs.set(compilation, doc = relativeIR(compilation.doc, root, root))
107
+ return { document, revision: irRevisionOf(compilation), doc, diagnostics: doc.diagnostics, sourceCapabilities: compilation.sourceCapabilities,
108
+ ...compilation.sourceFingerprints ? { sourceFingerprints: Object.fromEntries(Object.entries(compilation.sourceFingerprints).map(([file, value]) => [workspacePath(root, file), value])) } : {} }
109
+ },
110
+ events(document, signal) {
111
+ const session = options.compileSessionOf(document)
112
+ return pushStream((push) => {
113
+ const own = listeners.get(document) ?? new Set()
114
+ listeners.set(document, own)
115
+ const listener = (event: IrEvent) => push({ event: 'revision', data: event })
116
+ own.add(listener)
117
+ if (session.latest) listener(eventOf(document, session.latest, []))
118
+ else void session.ready().catch(() => undefined)
119
+ return () => own.delete(listener)
120
+ }, { signal })
121
+ },
122
+ async preview(document, renderer, prefetch = false) {
123
+ const request = ++previewRequest
124
+ const started = Date.now()
125
+ const label = `[preview ${request}] document=${JSON.stringify(document || '.')} renderer=${renderer ?? 'all'} prefetch=${prefetch}`
126
+ let phase = 'viewer'
127
+ let outcome = 'failed'
128
+ log.info(`${label} state=started`)
129
+ try {
130
+ const base = options.url()
131
+ // The viewer app's page once it has what the document needs (an opened document waits for it a while), else the
132
+ // document's page on the site.
133
+ const page = await options.viewerPage?.(document, !prefetch).catch((error: unknown) => {
134
+ if (error instanceof PreviewPreparing) {
135
+ outcome = 'preparing'
136
+ throw new RequestError(503, 'preview-preparing', { retryAfterMs: PREPARING_RETRY_MS }, { 'Retry-After': String(PREPARING_RETRY_MS / 1000) })
137
+ }
138
+ log.warn(`the viewer app cannot show ${document || '.'}: ${errorMessage(error)}`)
139
+ if (!prefetch) throw error
140
+ return undefined
141
+ })
142
+ const web = page ? `${base}${page}` : `${base}${options.basePath ?? '/'}${document ? `${urlPath(document)}/` : ''}`
143
+ log.info(`${label} phase=viewer source=${page ? 'app' : 'site'} elapsedMs=${Date.now() - started}`)
144
+ phase = 'compilation'
145
+ if (prefetch && !page) void options.warmSite?.(document).catch(() => undefined)
146
+ const compilation = await options.compileSessionOf(document).ready()
147
+ const configured = Object.keys(compilation.doc.config.renderers)
148
+ const slides = configured.length === 0 || configured.includes('slides')
149
+ outcome = 'ready'
150
+ return { document, web, ...slides ? { slides: `${web}${web.includes('?') ? '&' : '?'}form=slides` } : {} }
151
+ } catch (error) {
152
+ if (outcome !== 'preparing') log.warn(`${label} phase=${phase} error=${errorMessage(error)}`)
153
+ throw error
154
+ } finally {
155
+ log.info(`${label} state=${outcome} phase=${phase} elapsedMs=${Date.now() - started}`)
156
+ }
157
+ },
158
+ async build(document, to) {
159
+ return await options.serverHost.build(document, to ? { to } : {})
160
+ },
161
+ async check(document) {
162
+ return { findings: await options.serverHost.check(document) }
163
+ },
164
+ compiled(dir, compilation, changed) {
165
+ const event = eventOf(dir, compilation, changed)
166
+ for (const listener of listeners.get(dir) ?? []) listener(event)
167
+ },
168
+ stampPreviewRevision(compilation, doc) {
169
+ return options.preview ? withIrRevision(doc, irRevisionOf(compilation)) : doc
170
+ },
171
+ async documentsRemoved(dirs) {
172
+ for (const dir of dirs) options.diagnostics?.forget(dir)
173
+ },
174
+ async close() {},
175
+ }
176
+ }