@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,503 @@
1
+ import { createProjectAuthoring } from './projects.ts'
2
+ import { createDocumentHistory } from './document-history.ts'
3
+ import { createAuthoringClients } from './authoring-clients.ts'
4
+ import { createHash } from 'node:crypto'
5
+ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
6
+ import { homedir, tmpdir } from 'node:os'
7
+ import { join, relative } from 'node:path'
8
+ import { Compiler, operatorDir as resolveOperatorDir, pluginLogger, runBlock, workspacePlugins } from '@lincyaw/compiler'
9
+ import type { Compilation, CompilerOptions } from '@lincyaw/compiler'
10
+ import { CHANGE_REVIEW, DOCUMENT_FILES, DOCUMENT_LOCKS, DOCUMENT_VOICE_TASKS, errorMessage, PRODUCTION_AUDIO_AUTHORING, REVISIONS, SPEECH, VOICEOVER, VOICEOVER_EXECUTION } from '@lincyaw/kernel'
11
+ import { irRevision, operatorSpeechSecrets } from '@lincyaw/kernel/node'
12
+ import { speechSettingsRoute } from './speech-settings.ts'
13
+ import type { CanonicalSource, ChangeReviewService, Finding, IRDocument, Logger, ProductionAudioAuthoring, ServiceToken, SiteSpec, SpeechService } from '@lincyaw/kernel'
14
+ import { serverConfigKey, servedDocument, startServer } from '@lincyaw/server'
15
+ import type { DiagnosticStore, PresmiumServer, ServedDocument, ServedPlugin, ServerHost } from '@lincyaw/server'
16
+ import { CompileSession } from './compile-session.ts'
17
+ import { documentSummaries, listDocuments, readWorkspaceConfig } from './config.ts'
18
+ import { DocumentOperations, documentsDiff } from './document-operations.ts'
19
+ import type { DocumentsChange } from './document-operations.ts'
20
+ import { RequestError } from './errors.ts'
21
+ import { Git } from './git.ts'
22
+ import { createJobs, OUTPUTS_PATH } from './jobs.ts'
23
+ import { DocumentLocks } from './locks.ts'
24
+ import { AgentSessionReviews } from './review/agent-session-reviews.ts'
25
+ import { createTaskReviewService } from './review/task-reviews.ts'
26
+ import { Revisions } from './revisions.ts'
27
+ import { WORKSPACE_ID, authorizeWorkspaceRoutes, workspaceRoutes, workspaceRoutesPlugin } from './routes.ts'
28
+ import { refreshDocumentVitePlugins, siteContext, siteDocument, siteDocumentIR, siteSpec, untranslatedFindings, webRenderer } from './site.ts'
29
+ import { SourceEditor } from './source-editor.ts'
30
+ import { createDocumentEditor } from './document-editor.ts'
31
+ import type { DocumentEditor } from './document-editor.ts'
32
+ import { createDocumentTranscriptions } from './document-transcription.ts'
33
+ import { createTranscriptionAdoption } from './document-transcription-adoption.ts'
34
+ import { createDocumentAudioAuthoring } from './document-audio.ts'
35
+ import { createDocumentImageAuthoring } from './document-images.ts'
36
+ import { createDocumentVoiceAuthoring } from './document-voice.ts'
37
+ import type { DocumentVoiceServices } from './document-voice.ts'
38
+ import { createWorkbenchBackend } from './workbench-api.ts'
39
+ import type { WorkbenchBackend } from './workbench-api.ts'
40
+ import { localWriter } from './writer.ts'
41
+
42
+ export interface ServeOptions {
43
+ dir: string
44
+ listenAddress?: string
45
+ port?: number
46
+ operatorDir?: string
47
+ bundled?: string
48
+ check?(compilation: Compilation): Promise<Finding[]>
49
+ previewOrigins?: string[]
50
+ /** The address browsers use when a reverse proxy fronts the service (see `ServerOptions.publicUrl`). */
51
+ publicUrl?: string
52
+ env?: NodeJS.ProcessEnv
53
+ approvalTimeoutMs?: number
54
+ log?: Logger
55
+ }
56
+
57
+ export interface WorkspaceServer {
58
+ url: string
59
+ root: string
60
+ mcp: { url: string, stateFile: string }
61
+ close(): Promise<void>
62
+ }
63
+
64
+ /** What the workspace keeps when it is a git repository: its revisions, the document operations and the reviews. */
65
+ interface WorkspaceHistory {
66
+ git: Git
67
+ revisions: Revisions
68
+ operations: DocumentOperations
69
+ agentSessionReviews: AgentSessionReviews
70
+ taskReviews: ChangeReviewService
71
+ }
72
+
73
+ interface AuthoringServices extends DocumentVoiceServices { audio?: ProductionAudioAuthoring }
74
+ interface NodeServiceLoad { key: string, promise: Promise<AuthoringServices>, controller: AbortController }
75
+
76
+ const DISK_LOOK_MS = 1000
77
+
78
+ function makeStateDir(root: string, env: NodeJS.ProcessEnv): string {
79
+ const base = env.XDG_CACHE_HOME || join(homedir(), '.cache')
80
+ const dir = join(base, 'presmium', 'serve', createHash('sha256').update(root).digest('hex').slice(0, 16))
81
+ mkdirSync(dir, { recursive: true })
82
+ return dir
83
+ }
84
+
85
+ export async function serveWorkspace(options: ServeOptions): Promise<WorkspaceServer> {
86
+ const env = options.env ?? process.env
87
+ const log = options.log ?? pluginLogger('presmium serve')
88
+ const config = readWorkspaceConfig(options.dir)
89
+ const { root } = config
90
+ const operatorDirectory = resolveOperatorDir(root, options.operatorDir ?? env.PRESMIUM_OPERATOR_DIR)
91
+ const stateDir = makeStateDir(root, env)
92
+ const docs = () => listDocuments(config)
93
+ const compileSessions = new Map<string, CompileSession>()
94
+ const agentToolListeners = new Set<(document: string) => void>()
95
+ const locks = new DocumentLocks()
96
+ const writer = localWriter(config)
97
+ const editor = new SourceEditor({ config, writer, locks })
98
+ const check = options.check ?? (compilation => compilation.check())
99
+ const launcherToken = env.PRESMIUM_SERVICE_TOKEN || undefined
100
+ const preview = { origins: options.previewOrigins ?? [] }
101
+ // Behind a proxy that forwards a path (`--public-url https://host/user/a/preview/`), the site, the jobs' files and the
102
+ // previews' links are all below that path; the server finds its own routes below it too.
103
+ const basePath = options.publicUrl ? `${new URL(options.publicUrl).pathname.replace(/\/+$/, '')}/` : '/'
104
+ const outputsPath = `${basePath}${OUTPUTS_PATH.slice(1)}`
105
+ const publicOrigin = () => options.publicUrl ? new URL(options.publicUrl).origin : server?.url ?? ''
106
+
107
+ let serverServices: [ServiceToken<unknown>, unknown][] = []
108
+ let workspaceServices: [ServiceToken<unknown>, unknown][] = []
109
+ let site: WorkspaceSite | undefined
110
+ let server: PresmiumServer | undefined
111
+ let workbench: WorkbenchBackend | undefined
112
+ let history: WorkspaceHistory | undefined
113
+ let documentEditor: DocumentEditor | undefined
114
+ let documentHistory: ReturnType<typeof createDocumentHistory> | undefined
115
+ let projectAuthoring: ReturnType<typeof createProjectAuthoring> | undefined
116
+ let authoringClients: ReturnType<typeof createAuthoringClients> | undefined
117
+ let transcriptions: ReturnType<typeof createDocumentTranscriptions> | undefined
118
+ let transcriptionAdoption: ReturnType<typeof createTranscriptionAdoption> | undefined
119
+ let audioAuthoring: ReturnType<typeof createDocumentAudioAuthoring> | undefined
120
+ let imageAuthoring: ReturnType<typeof createDocumentImageAuthoring> | undefined
121
+ let voiceAuthoring: ReturnType<typeof createDocumentVoiceAuthoring> | undefined
122
+ let workspaceRoutesOwner: ReturnType<typeof workspaceRoutes> | undefined
123
+ let routesPlugin: ServedPlugin | undefined
124
+ // The builds and exports, as jobs the workbench and the AI start and follow.
125
+ const jobs = createJobs({
126
+ stateDir,
127
+ url: publicOrigin,
128
+ outputsPath,
129
+ compilation: (dir, fresh) => fresh ? compileSessionOf(dir).recompile() : compileSessionOf(dir).ready(),
130
+ revision: compilation => irRevision(compilation.doc),
131
+ origins: preview.origins,
132
+ log,
133
+ })
134
+
135
+ const compileSessionOf = (dir: string): CompileSession => {
136
+ const known = compileSessions.get(dir)
137
+ if (known) return known
138
+ const document = docs().find(doc => doc.dir === dir)
139
+ if (!document) throw new RequestError(404, `no document ${JSON.stringify(dir)}`)
140
+ const session = new CompileSession(dir, new Compiler(join(root, document.entry), { ...compilerOptions(), priority: 'interactive', execution: { env: 'serve' } }), log)
141
+ let configKey: string | undefined
142
+ session.onCompile(async (compilation) => {
143
+ const next = serverConfigKey(compilation)
144
+ if (configKey !== undefined && configKey !== next) void server?.unmount(dir, { preserveFocus: true }).catch((error: unknown) => {
145
+ log.error(`the previous server contributions of ${dir || '.'} did not close: ${errorMessage(error)}`)
146
+ })
147
+ configKey = next
148
+ for (const finding of untranslatedFindings(compilation, root)) {
149
+ if (!compilation.doc.diagnostics.some(f => f.message === finding.message)) compilation.doc.diagnostics.push(finding)
150
+ }
151
+ })
152
+ session.onCompile((compilation, changed) => {
153
+ server?.diagnostics.compiled(dir, compilation.doc, root, irRevision(compilation.doc))
154
+ workbench?.compiled(dir, compilation, changed)
155
+ for (const listener of agentToolListeners) listener(dir)
156
+ void site?.update(dir, compilation).catch((error: unknown) => log.warn(`the site did not take ${dir}: ${errorMessage(error)}`))
157
+ })
158
+ session.onFailure(error => server?.diagnostics.failed(dir, errorMessage(error), { file: relative(root, session.compiler.entry).replace(/\\/g, '/') }))
159
+ compileSessions.set(dir, session)
160
+ return session
161
+ }
162
+
163
+ const compilerOptions = (): CompilerOptions => ({
164
+ workspaceRoot: root,
165
+ settings: config.settings,
166
+ services: [...serverServices, ...workspaceServices],
167
+ bundled: options.bundled,
168
+ operatorDir: operatorDirectory,
169
+ secret: operatorSpeechSecrets(operatorDirectory, name => env[name]),
170
+ })
171
+
172
+ // Coalesce only in-flight discovery. A later read reassembles actual services and problems;
173
+ // source edits or a repaired plugin cannot be hidden behind a retained capability cache.
174
+ const nodeServiceLoads = new Map<string, NodeServiceLoad>()
175
+ const nodeServicePending = new Set<NodeServiceLoad>()
176
+ let authoringServicesClosed = false
177
+ const authoringServices = (document: string, source: CanonicalSource): Promise<AuthoringServices> => {
178
+ if (authoringServicesClosed) throw new RequestError(503, 'document-services-unavailable')
179
+ const key = JSON.stringify(source), known = nodeServiceLoads.get(document)
180
+ if (known?.key === key) return known.promise
181
+ const selected = docs().find(doc => doc.dir === document)
182
+ if (!selected) throw new RequestError(404, 'authoring-document-missing')
183
+ const controller = new AbortController()
184
+ const promise: Promise<AuthoringServices> = new Compiler(join(root, selected.entry), compilerOptions()).nodeServices({ signal: controller.signal }).then(loaded => {
185
+ if (loaded.source.documentId !== source.documentId || loaded.source.version !== source.version || loaded.source.fingerprint !== source.fingerprint) throw new RequestError(409, 'document-version-changed')
186
+ return { audio: loaded.service(PRODUCTION_AUDIO_AUTHORING), speech: loaded.service(SPEECH), voiceover: loaded.service(VOICEOVER), voiceoverExecution: loaded.service(VOICEOVER_EXECUTION), findings: loaded.findings }
187
+ }).finally(() => { if (nodeServiceLoads.get(document) === load) nodeServiceLoads.delete(document); nodeServicePending.delete(load) })
188
+ const load: NodeServiceLoad = { key, promise, controller }
189
+ nodeServiceLoads.set(document, load)
190
+ nodeServicePending.add(load)
191
+ return promise
192
+ }
193
+ const closeAuthoring = async () => {
194
+ authoringServicesClosed = true
195
+ transcriptionAdoption?.close()
196
+ for (const load of nodeServicePending) load.controller.abort()
197
+ await Promise.all([projectAuthoring?.close(), transcriptions?.close(), audioAuthoring?.close(), voiceAuthoring?.close(), documentHistory?.close(), authoringClients?.close(), workspaceRoutesOwner?.close?.()])
198
+ await Promise.allSettled([...nodeServicePending].map(load => load.promise))
199
+ nodeServiceLoads.clear()
200
+ }
201
+
202
+ const spec: SiteSpec = siteSpec(config, docs().map(doc => siteDocument(doc)))
203
+ const refreshSpec = (): DocumentsChange => {
204
+ const retained = new Map(spec.documents.map(doc => [doc.dir, doc]))
205
+ const before = [...retained.keys()]
206
+ const now = docs()
207
+ const listed = now.map(doc => {
208
+ const summary = siteDocument(doc)
209
+ const known = retained.get(doc.dir)
210
+ if (!known) return summary
211
+ // Undoing prose also refreshes the list: retain compiled document modules and translations.
212
+ delete known.lang
213
+ delete known.tags
214
+ return Object.assign(known, summary)
215
+ })
216
+ spec.documents.splice(0, spec.documents.length, ...listed)
217
+ return documentsDiff(before, now.map(doc => doc.dir))
218
+ }
219
+
220
+ const documentsChanged = async (change: DocumentsChange): Promise<void> => {
221
+ for (const dir of change.removed) {
222
+ server?.diagnostics.forget(dir)
223
+ compileSessions.get(dir)?.close()
224
+ compileSessions.delete(dir)
225
+ for (const listener of agentToolListeners) listener(dir)
226
+ }
227
+ const listed = refreshSpec()
228
+ const removed = [...new Set([...change.removed, ...listed.removed])]
229
+ for (const dir of new Set([...removed, ...change.added, ...listed.added])) await server?.unmount(dir)
230
+ await workbench?.documentsRemoved(removed)
231
+ }
232
+
233
+ let lookedAt = 0
234
+ const lookAtDisk = (): void => {
235
+ if (Date.now() - lookedAt < DISK_LOOK_MS) return
236
+ lookedAt = Date.now()
237
+ const change = documentsDiff(spec.documents.map(doc => doc.dir), docs().map(doc => doc.dir))
238
+ if (change.removed.length || change.added.length) void documentsChanged(change).catch((error: unknown) => log.warn(`the documents on disk changed, but the site did not follow: ${errorMessage(error)}`))
239
+ }
240
+
241
+ const openHistory = (): WorkspaceHistory | undefined => {
242
+ if (!Git.isRepository(root)) {
243
+ log.warn(`${root} is not a git repository: editing, the document operations and the history are not available (run git init)`)
244
+ return undefined
245
+ }
246
+ const git = new Git(root)
247
+ const revisions = new Revisions(git, { config, writer })
248
+ return {
249
+ revisions,
250
+ operations: new DocumentOperations({ config, git, revisions, writer, changed: documentsChanged, locks }),
251
+ git,
252
+ agentSessionReviews: new AgentSessionReviews({ config, revisions, locks, files: writer, log }),
253
+ taskReviews: createTaskReviewService({ config, locks, files: writer, log }),
254
+ }
255
+ }
256
+
257
+ let started!: () => void
258
+ const ready = new Promise<void>(resolve => started = resolve)
259
+
260
+ const serverHost: ServerHost = {
261
+ workspaceRoot: root,
262
+ get authoring() { return documentEditor },
263
+ get documentHistory() { return documentHistory?.service },
264
+ get projectAuthoring() { return projectAuthoring?.service },
265
+ get authoringClients() { return authoringClients?.service },
266
+ get audioAuthoring() { return audioAuthoring?.service },
267
+ get imageAuthoring() { return imageAuthoring?.service },
268
+ get voiceAuthoring() { return voiceAuthoring?.service },
269
+ get transcriptions() { return transcriptions },
270
+ get transcriptionAdoption() { return transcriptionAdoption?.service },
271
+ get routes() { return workspaceRoutesOwner?.routes ? { [WORKSPACE_ID]: workspaceRoutesOwner.routes, ...voiceAuthoring ? { 'presmium.lecture': authorizeWorkspaceRoutes(voiceAuthoring.routes, launcherToken) } : {} } : undefined },
272
+ agentTools(document) {
273
+ const compiled = (document === undefined ? [...compileSessions.values()] : [compileSessions.get(document)]).flatMap(session => session?.latest ? [session.latest] : [])
274
+ const tools = new Map(compiled.flatMap(compilation => compilation.plugins.flatMap(loaded => loaded.plugin?.tools ?? [])).map(tool => [tool.name, tool]))
275
+ return { tools: [...tools.values()], service: <T>(token: ServiceToken<T>) => compiled[0]?.service(token) }
276
+ },
277
+ watchAgentTools(listener) { agentToolListeners.add(listener); return () => { agentToolListeners.delete(listener) } },
278
+ async readSource(path, member) {
279
+ await ready
280
+ return editor.read(editor.scope(''), path, member)
281
+ },
282
+ async documents() {
283
+ return documentSummaries(config)
284
+ },
285
+ async compile(document) {
286
+ await ready
287
+ const { doc } = await compileSessionOf(document).ready()
288
+ return { doc, diagnostics: doc.diagnostics }
289
+ },
290
+ async check(document) {
291
+ await ready
292
+ const session = compileSessionOf(document)
293
+ const compilation = await session.ready()
294
+ const findings = await check(compilation)
295
+ if (session.latest === compilation) server?.diagnostics.replace({ document, origin: 'check' }, findings, { root, irRevision: irRevision(compilation.doc) })
296
+ return [...compilation.doc.diagnostics, ...findings]
297
+ },
298
+ async build(document, buildOptions = {}) {
299
+ await ready
300
+ // As jobs, which the workbench shows: one a renderer, waited for.
301
+ const targets = buildOptions.to ?? (await jobs.targets(document)).filter(target => target.kind === 'render').map(target => target.id)
302
+ const ended = await Promise.all((await Promise.all(targets.map(target => jobs.start(document, target)))).map(job => jobs.wait(job.id)))
303
+ const outputs = ended.filter(job => job.state === 'done').map(job => ({ renderer: job.target, outDir: jobs.directory(job) }))
304
+ const findings: Finding[] = ended.flatMap(job => [
305
+ ...job.state === 'failed' ? [{ checkId: 'presmium.render', severity: 'error' as const, message: `${job.target}: ${job.error ?? job.state}` }] : [],
306
+ ...job.warnings.map(message => ({ checkId: 'presmium.render', severity: 'warning' as const, message })),
307
+ ])
308
+ return { outputs, findings }
309
+ },
310
+ jobs,
311
+ async run(document, target, signal) {
312
+ await ready
313
+ return runBlock(await compileSessionOf(document).ready(), target, signal)
314
+ },
315
+ }
316
+
317
+ let operators: ReturnType<typeof workspacePlugins> | undefined
318
+ let home: string | undefined
319
+ const loadOperatorPlugins = async () => {
320
+ await ready
321
+ home ??= mkdtempSync(join(tmpdir(), 'presmium-home-'))
322
+ return operators ??= workspacePlugins(root, compilerOptions())
323
+ }
324
+ const homeDocument = async (): Promise<ServedDocument> => {
325
+ const loaded = await loadOperatorPlugins()
326
+ for (const problem of loaded.problems) log.warn(`the home page: ${problem.message}`)
327
+ const own = (token: ServiceToken<unknown>) => [...serverServices, ...workspaceServices].find(([known]) => known.id === token.id)?.[1]
328
+ return {
329
+ deckDir: home!,
330
+ workspaceRoot: home!,
331
+ plugins: [routesPlugin!, ...loaded.plugins.flatMap(({ plugin: node }) => (node ? [{ id: node.id, plugin: node, config: {} }] : []))],
332
+ service: <T>(token: ServiceToken<T>) => (own(token) ?? loaded.service(token)) as T | undefined,
333
+ settings: config.settings,
334
+ operatorDir: operatorDirectory,
335
+ home: true,
336
+ }
337
+ }
338
+
339
+ const stateFile = join(stateDir, 'server.json')
340
+ server = await startServer({
341
+ mode: 'serve',
342
+ listenAddress: options.listenAddress,
343
+ port: options.port,
344
+ ...options.publicUrl ? { publicUrl: options.publicUrl } : {},
345
+ stateFile,
346
+ forward: async (path) => {
347
+ lookAtDisk()
348
+ if (path.startsWith(outputsPath)) return await jobs.origin()
349
+ if (!site) return undefined
350
+ // Straight to the dev server of the path's document, not through the site's router: one hop less per module.
351
+ try {
352
+ return await site.target(path)
353
+ } catch {
354
+ return site.forward()
355
+ }
356
+ },
357
+ settings: config.settings,
358
+ approvals: config.approvals,
359
+ operatorDir: operatorDirectory,
360
+ serverHost,
361
+ env,
362
+ ...options.approvalTimeoutMs ? { approvalTimeoutMs: options.approvalTimeoutMs } : {},
363
+ log,
364
+ async document(dir) {
365
+ await ready
366
+ if (dir === '' && !config.singleDeck) return await homeDocument()
367
+ if (!docs().some(doc => doc.dir === dir)) return undefined
368
+ const compilation = await compileSessionOf(dir).ready()
369
+ const served = servedDocument(compilation)
370
+ const taskReviews = history?.taskReviews
371
+ const service = <T>(token: ServiceToken<T>) => served.service(token) ?? (taskReviews && token.id === CHANGE_REVIEW.id ? taskReviews as T : undefined)
372
+ return { ...served, service, plugins: [routesPlugin!, ...served.plugins] }
373
+ },
374
+ })
375
+ const running = server
376
+ serverServices = running.services
377
+ voiceAuthoring = createDocumentVoiceAuthoring({ editor, services: authoringServices, revision: document => { const latest = compileSessions.get(document)?.latest; return latest ? irRevision(latest.doc) : undefined } })
378
+ workbench = createWorkbenchBackend({ root, compileSessionOf, serverHost, voices: voiceAuthoring.service, diagnostics: running.diagnostics, url: () => options.publicUrl ? new URL(options.publicUrl).origin : running.url, basePath, preview, log, warmSite: dir => site?.warm(dir) ?? Promise.resolve(), viewerPage: (dir, wait) => site?.app(dir, wait) ?? Promise.resolve(undefined) })
379
+ history = openHistory()
380
+ documentEditor = createDocumentEditor({ productionPlan: async (document, source) => { const audio = (await authoringServices(document, source)).audio; if (!audio) throw new RequestError(503, 'caption-production-unavailable'); return audio.readPlan({ source }) }, editor, revisions: history?.revisions, reviewRequired: config.settings['presmium.assistant']?.review === 'required' })
381
+ projectAuthoring = createProjectAuthoring({ config, documentEditor, operations: history?.operations, revisions: history?.revisions, git: history?.git })
382
+ authoringClients = createAuthoringClients(editor)
383
+ documentHistory = createDocumentHistory({ config, editor, locks, revisions: history?.revisions, reviewRequired: config.settings['presmium.assistant']?.review === 'required', changed: (before, after) => documentsChanged(documentsDiff(before, after)), reverted: (target, revision) => history?.agentSessionReviews.reverted(target, revision) })
384
+ transcriptions = createDocumentTranscriptions({ root, editor, services: authoringServices })
385
+ transcriptionAdoption = createTranscriptionAdoption({ documentEditor, transcriptions })
386
+ audioAuthoring = createDocumentAudioAuthoring({ root, editor, revisions: history?.revisions, services: authoringServices, reviewRequired: config.settings['presmium.assistant']?.review === 'required' })
387
+ imageAuthoring = createDocumentImageAuthoring({ root, editor, revisions: history?.revisions, reviewRequired: config.settings['presmium.assistant']?.review === 'required' })
388
+ const routeOptions = {
389
+ speechSettings: speechSettingsRoute(operatorDirectory, name => env[name]),
390
+ config,
391
+ editor,
392
+ documentEditor,
393
+ transcriptions,
394
+ transcriptionAdoptionRoutes: transcriptionAdoption.routes,
395
+ audioAuthoringRoutes: audioAuthoring.routes,
396
+ imageAuthoringRoutes: imageAuthoring.routes,
397
+ historyRoutes: documentHistory.routes,
398
+ authoringClientRoutes: authoringClients.routes,
399
+ projectRoutes: projectAuthoring.routes,
400
+ locks,
401
+ ...history ? { revisions: history.revisions, operations: history.operations, reviews: history.agentSessionReviews } : {},
402
+ documentsChanged,
403
+ workbench,
404
+ jobs,
405
+ ...launcherToken ? { launcherToken } : {},
406
+ }
407
+ workspaceRoutesOwner = workspaceRoutes(routeOptions)
408
+ routesPlugin = { id: WORKSPACE_ID, plugin: workspaceRoutesPlugin(routeOptions, workspaceRoutesOwner.routes), config: {} }
409
+ workspaceServices = [
410
+ ...history ? [[REVISIONS, history.revisions] as [ServiceToken<unknown>, unknown]] : [],
411
+ [DOCUMENT_FILES, writer],
412
+ [DOCUMENT_LOCKS, locks],
413
+ [DOCUMENT_VOICE_TASKS, voiceAuthoring.service],
414
+ ]
415
+ try {
416
+ await history?.agentSessionReviews.recover(docs().map(doc => doc.dir))
417
+ } catch (error) {
418
+ history?.agentSessionReviews.close()
419
+ await closeAuthoring()
420
+ await workbench.close()
421
+ await running.close()
422
+ throw error
423
+ }
424
+
425
+ started()
426
+
427
+ const compileForSite = async (dir: string): Promise<IRDocument> => {
428
+ const compilation = await compileSessionOf(dir).ready()
429
+ refreshDocumentVitePlugins(spec, dir, compilation)
430
+ return workbench!.stampPreviewRevision(compilation, siteDocumentIR(compilation))
431
+ }
432
+ try {
433
+ site = await startWorkspaceSite({ root, spec, basePath, bundled: options.bundled, stateDir, compile: compileForSite, stampPreviewRevision: (compilation, doc) => workbench!.stampPreviewRevision(compilation, doc), diagnostics: running.diagnostics, preview, log })
434
+ } catch (error) {
435
+ await closeAuthoring()
436
+ await workbench.close()
437
+ await running.close()
438
+ if (home) rmSync(home, { recursive: true, force: true })
439
+ throw error
440
+ }
441
+
442
+ return {
443
+ url: running.url,
444
+ root,
445
+ mcp: { url: `${running.url}/mcp`, stateFile },
446
+ async close() {
447
+ history?.agentSessionReviews.close()
448
+ await closeAuthoring()
449
+ for (const session of compileSessions.values()) session.close()
450
+ await jobs.close()
451
+ await workbench?.close()
452
+ await site?.close()
453
+ await running.close()
454
+ if (home) rmSync(home, { recursive: true, force: true })
455
+ },
456
+ }
457
+ }
458
+
459
+ interface WorkspaceSiteOptions {
460
+ root: string
461
+ basePath: string
462
+ spec: SiteSpec
463
+ bundled?: string
464
+ stateDir: string
465
+ compile(dir: string): Promise<IRDocument>
466
+ stampPreviewRevision(compilation: Compilation, doc: IRDocument): IRDocument
467
+ preview: { origins: string[] }
468
+ log: Logger
469
+ diagnostics: DiagnosticStore
470
+ }
471
+
472
+ type WorkspaceSite = Awaited<ReturnType<typeof startWorkspaceSite>>
473
+
474
+ /** The workspace site's dev router in this process, which serve forwards the site's paths to. */
475
+ async function startWorkspaceSite(options: WorkspaceSiteOptions) {
476
+ const { root, spec, log } = options
477
+ const renderer = await webRenderer(root, options.bundled)
478
+ const findings: Finding[] = []
479
+ const work = join(options.stateDir, 'site', 'full')
480
+ const ctx = siteContext({ root, outDir: work, workDir: work, base: options.basePath, findings })
481
+ ctx.preview = options.preview
482
+ const dev = await renderer.devSite!(spec, ctx, options.compile)
483
+ const origin = new URL(dev.url).origin
484
+ log.info(`the workspace site's dev router is at ${origin} (this machine only; pages reach it through serve)`)
485
+ return {
486
+ forward: (): string => origin,
487
+ target: (path: string): Promise<string> => dev.target?.(path) ?? Promise.resolve(origin),
488
+ update: (dir: string, compilation: Compilation): Promise<void> => {
489
+ refreshDocumentVitePlugins(spec, dir, compilation)
490
+ const revision = irRevision(compilation.doc)
491
+ return dev.update(dir, options.stampPreviewRevision(compilation, siteDocumentIR(compilation))).then(() => {
492
+ if (options.diagnostics.irRevision(dir) === revision) options.diagnostics.clear(dir, 'preview', 'web')
493
+ }, (error: unknown) => {
494
+ const message = `the site did not take ${dir}: ${errorMessage(error)}`
495
+ if (options.diagnostics.irRevision(dir) === revision) options.diagnostics.replace({ document: dir, origin: 'preview', renderer: 'web' }, [{ checkId: 'presmium.preview', severity: 'error', message }], { irRevision: revision })
496
+ log.warn(message)
497
+ })
498
+ },
499
+ warm: (dir: string): Promise<void> => dev.warm?.(dir) ?? Promise.resolve(),
500
+ app: (dir: string, wait: boolean): Promise<string | undefined> => dev.app?.(dir, wait) ?? Promise.resolve(undefined),
501
+ close: (): Promise<void> => dev.close(),
502
+ }
503
+ }
@@ -0,0 +1,132 @@
1
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
2
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path'
3
+ import { compileTranslations, directoryAsset, fileHash, lineContaining, loadPlugins, nodeVitePlugins, portable, publishesDirectory, viewFor } from '@lincyaw/compiler'
4
+ import type { Compilation } from '@lincyaw/compiler'
5
+ import { DOCUMENT_ENTRY, isRecord, NON_FILE_URL, WEB_RENDERER_PACKAGE } from '@lincyaw/kernel'
6
+ import type { DocumentSummary, Finding, IRDocument, RenderContext, Renderer, SiteDocument, SiteSpec, SourcePos } from '@lincyaw/kernel'
7
+ import { isInside } from '@lincyaw/kernel/node'
8
+ import type { WorkspaceConfig } from './config.ts'
9
+
10
+ export async function webRenderer(root: string, bundled?: string): Promise<Renderer> {
11
+ const { plugins, problems } = await loadPlugins(root, [WEB_RENDERER_PACKAGE], { workspaceRoot: root, listed: [], bundled })
12
+ const renderer = plugins[0]?.plugin?.renderer
13
+ if (!renderer?.buildSite || !renderer.devSite) throw new Error(`the web renderer (${WEB_RENDERER_PACKAGE}) is not installed: ${problems.map(p => p.message).join('; ') || 'it has no workspace site'}`)
14
+ return renderer
15
+ }
16
+
17
+ /** The document as the workspace site has it, every form's blocks in it: its plugins named by their directories. */
18
+ export function siteDocumentIR(compilation: Compilation, doc: IRDocument = compilation.doc): IRDocument {
19
+ const dirs = doc.config.plugins.flatMap((name) => {
20
+ const loaded = compilation.plugins.find(plugin => plugin.name === name)
21
+ return loaded ? [loaded.dir] : []
22
+ })
23
+ return { ...doc, config: { ...doc.config, plugins: dirs } }
24
+ }
25
+
26
+ /** The document's web form on the workspace site. */
27
+ export function siteIR(compilation: Compilation, doc: IRDocument = compilation.doc): IRDocument {
28
+ return viewFor(siteDocumentIR(compilation, doc), 'web')
29
+ }
30
+
31
+ export async function siteTranslations(compilation: Compilation): Promise<{ translations: NonNullable<SiteDocument['translations']>, findings: Finding[] }> {
32
+ if (!compilation.doc.config.translations) return { translations: [], findings: [] }
33
+ const { translations, findings } = await compileTranslations(compilation)
34
+ return { translations: translations.map(({ lang, doc }) => ({ lang, doc: siteIR(compilation, doc) })), findings }
35
+ }
36
+
37
+ export function siteDocument(document: DocumentSummary, doc?: IRDocument, translations?: SiteDocument['translations'], compilation?: Compilation): SiteDocument {
38
+ return { dir: document.dir, title: document.title, description: document.description, ...document.lang ? { lang: document.lang } : {}, ...document.tags ? { tags: document.tags } : {}, ...doc ? { doc } : {}, ...translations?.length ? { translations } : {}, ...compilation ? { nodeVitePlugins: nodeVitePlugins(compilation) } : {} }
39
+ }
40
+
41
+ export function refreshDocumentVitePlugins(site: SiteSpec, dir: string, compilation: Compilation): void {
42
+ let document = site.documents.find(document => document.dir === dir)
43
+ if (!document) {
44
+ document = { dir, title: compilation.doc.meta.title, lang: compilation.doc.meta.lang }
45
+ site.documents.push(document)
46
+ }
47
+ document.nodeVitePlugins = nodeVitePlugins(compilation)
48
+ }
49
+
50
+ export function siteSpec(config: WorkspaceConfig, documents: SiteDocument[]): SiteSpec {
51
+ return { title: config.title, documents, ...typeof config.site.home === 'string' ? { home: config.site.home } : {}, config: config.site }
52
+ }
53
+
54
+ export interface SiteContextOptions {
55
+ root: string
56
+ outDir: string
57
+ workDir: string
58
+ base?: string
59
+ findings: Finding[]
60
+ }
61
+
62
+ export function siteContext(options: SiteContextOptions): RenderContext {
63
+ const { root } = options
64
+ const ctx: RenderContext = {
65
+ deckDir: root,
66
+ workspaceRoot: root,
67
+ outDir: options.outDir,
68
+ workDir: options.workDir,
69
+ config: {},
70
+ extensions: [],
71
+ plugins: [],
72
+ pluginDirs: {},
73
+ renderers: ['web'],
74
+ base: options.base ?? '/',
75
+ extensionFor: () => undefined,
76
+ portable: (value, from) => portable(value, from, root),
77
+ localAsset: (source, url) => publishedAsset(options, source, url, ctx.report),
78
+ report(finding) {
79
+ if (!options.findings.some(f => JSON.stringify(f) === JSON.stringify(finding))) options.findings.push(finding)
80
+ },
81
+ }
82
+ return ctx
83
+ }
84
+
85
+ function deckOf(file: string, root: string): string | undefined {
86
+ for (let dir = dirname(file); isInside(dir, root); dir = dirname(dir)) {
87
+ if (existsSync(join(dir, DOCUMENT_ENTRY))) return dir
88
+ if (dir === root) return undefined
89
+ }
90
+ return undefined
91
+ }
92
+
93
+ function publishedAsset(options: SiteContextOptions, source: SourcePos, url: string, report: (finding: Finding) => void): { file: string, name: string } | undefined {
94
+ if (NON_FILE_URL.test(url)) return undefined
95
+ const path = url.replace(/[?#].*$/s, '')
96
+ if (!path || path.startsWith('/')) return undefined
97
+ let name = path
98
+ try {
99
+ name = decodeURIComponent(path)
100
+ } catch {
101
+ }
102
+ const warn = (message: string) => report({ checkId: 'presmium.links', severity: 'warning', message, source: { file: source.file, line: lineContaining(source, url) } })
103
+ const target = resolve(dirname(source.file), name)
104
+ const stat = statSync(target, { throwIfNoEntry: false })
105
+ if (!stat) {
106
+ warn(`${url} is not published: there is no such file`)
107
+ return undefined
108
+ }
109
+ const root = realpathSync(options.root)
110
+ const real = realpathSync(target)
111
+ if (!isInside(real, root)) {
112
+ if (stat.isFile()) warn(`${url} is not published: the file lies outside the workspace`)
113
+ return undefined
114
+ }
115
+ if (stat.isFile()) return { file: target, name: `${fileHash(target).slice(0, 8)}-${basename(target)}` }
116
+ const deck = deckOf(source.file, options.root)
117
+ if (!deck || !publishesDirectory(target, deck)) return undefined
118
+ const document: unknown = JSON.parse(readFileSync(join(deck, DOCUMENT_ENTRY), 'utf8'))
119
+ const lang = isRecord(document) && isRecord(document.metadata) ? document.metadata.lang : undefined
120
+ return directoryAsset(target, deck, join(options.workDir, 'files'), typeof lang === 'string' ? lang : undefined, message => warn(`${url} is not published: ${message}`))
121
+ }
122
+
123
+ export function untranslatedFindings(compilation: Compilation, root: string): Finding[] {
124
+ const { translations } = compilation.doc.config
125
+ if (!translations) return []
126
+ return Object.entries(translations).map(([lang, entry]) => ({
127
+ checkId: 'presmium.translations',
128
+ severity: 'warning' as const,
129
+ message: `translation "${lang}" (${entry}) is not in the workspace site of presmium serve, which shows the originals; presmium build at the workspace root publishes it (or presmium build ${relative(root, compilation.deckDir).split(sep).join('/') || '.'} for this document alone)`,
130
+ source: { file: compilation.entry, line: 1 },
131
+ }))
132
+ }