@lincyaw/workspace 0.0.0-stage → 1.0.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +35 -2
- package/package.json +36 -3
- package/src/domain/api.ts +102 -0
- package/src/domain/index.ts +6 -0
- package/src/domain/paths.ts +98 -0
- package/src/domain/revisions.ts +80 -0
- package/src/index.ts +6 -0
- package/src/node/build.ts +42 -0
- package/src/node/config.ts +126 -0
- package/src/node/documents.ts +185 -0
- package/src/node/files.ts +252 -0
- package/src/node/git.ts +68 -0
- package/src/node/locks.ts +41 -0
- package/src/node/review/git-guard.ts +103 -0
- package/src/node/review/git-review.ts +416 -0
- package/src/node/review/review-record.ts +73 -0
- package/src/node/review/reviews.ts +422 -0
- package/src/node/review/routes.ts +72 -0
- package/src/node/revisions.ts +346 -0
- package/src/node/routes.ts +239 -0
- package/src/node/serve.ts +302 -0
- package/src/node/service.ts +237 -0
- package/src/node/session.ts +123 -0
- package/src/node/site.ts +121 -0
- package/src/node/source.ts +148 -0
- package/templates/document/default/lecture.md +18 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
|
3
|
+
import { homedir, tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { buildView, Compiler, pluginLogger, runBlock, workspacePlugins } from '@lincyaw/compiler'
|
|
6
|
+
import type { Compilation, CompilerOptions } from '@lincyaw/compiler'
|
|
7
|
+
import { CHANGE_REVIEW, DOCUMENT_FILES, DOCUMENT_LOCKS, errorMessage, REVISIONS } from '@lincyaw/kernel'
|
|
8
|
+
import type { Finding, IRDocument, Logger, ServiceToken, SiteSpec } from '@lincyaw/kernel'
|
|
9
|
+
import { servedDocument, startServer } from '@lincyaw/server'
|
|
10
|
+
import type { PresmiumServer, ServedDocument, ServedPlugin, ServerHost } from '@lincyaw/server'
|
|
11
|
+
import { WORKSPACE_ID } from '../domain/api.ts'
|
|
12
|
+
import { listDocuments, readWorkspace } from './config.ts'
|
|
13
|
+
import { workspaceService } from './service.ts'
|
|
14
|
+
import type { Service } from './service.ts'
|
|
15
|
+
import { Documents, RequestError } from './documents.ts'
|
|
16
|
+
import type { DocumentsChange } from './documents.ts'
|
|
17
|
+
import { localWriter } from './files.ts'
|
|
18
|
+
import type { DocumentWriter } from './files.ts'
|
|
19
|
+
import { Git } from './git.ts'
|
|
20
|
+
import { DocumentLocks } from './locks.ts'
|
|
21
|
+
import { Reviews } from './review/reviews.ts'
|
|
22
|
+
import { Revisions } from './revisions.ts'
|
|
23
|
+
import { workspacePlugin } from './routes.ts'
|
|
24
|
+
import type { WorkspaceRoutes } from './routes.ts'
|
|
25
|
+
import { DocumentSession } from './session.ts'
|
|
26
|
+
import { siteContext, siteDocument, siteIR, siteSpec, untranslated, webRenderer } from './site.ts'
|
|
27
|
+
|
|
28
|
+
export interface ServeOptions {
|
|
29
|
+
dir: string
|
|
30
|
+
host?: string
|
|
31
|
+
port?: number
|
|
32
|
+
operatorDir?: string
|
|
33
|
+
bundled?: string
|
|
34
|
+
check?(compilation: Compilation): Promise<Finding[]>
|
|
35
|
+
previewOrigins?: string[]
|
|
36
|
+
env?: NodeJS.ProcessEnv
|
|
37
|
+
approvalTimeoutMs?: number
|
|
38
|
+
log?: Logger
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface WorkspaceServer {
|
|
42
|
+
url: string
|
|
43
|
+
root: string
|
|
44
|
+
mcp: { url: string, stateFile: string }
|
|
45
|
+
close(): Promise<void>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const DISK_LOOK_MS = 1000
|
|
49
|
+
|
|
50
|
+
function stateDir(root: string, env: NodeJS.ProcessEnv): string {
|
|
51
|
+
const base = env.XDG_CACHE_HOME || join(homedir(), '.cache')
|
|
52
|
+
const dir = join(base, 'presmium', 'serve', createHash('sha256').update(root).digest('hex').slice(0, 16))
|
|
53
|
+
mkdirSync(dir, { recursive: true })
|
|
54
|
+
return dir
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function serveWorkspace(options: ServeOptions): Promise<WorkspaceServer> {
|
|
58
|
+
const env = options.env ?? process.env
|
|
59
|
+
const log = options.log ?? pluginLogger('presmium serve')
|
|
60
|
+
const config = readWorkspace(options.dir)
|
|
61
|
+
const { root } = config
|
|
62
|
+
const docs = () => listDocuments(config)
|
|
63
|
+
const sessions = new Map<string, DocumentSession>()
|
|
64
|
+
const locks = new DocumentLocks()
|
|
65
|
+
const check = options.check ?? (compilation => compilation.check())
|
|
66
|
+
const token = env.PRESMIUM_SERVICE_TOKEN || undefined
|
|
67
|
+
const preview = { origins: options.previewOrigins ?? [] }
|
|
68
|
+
|
|
69
|
+
let services: [ServiceToken<unknown>, unknown][] = []
|
|
70
|
+
let hostServices: [ServiceToken<unknown>, unknown][] = []
|
|
71
|
+
let site: SiteBackend | undefined
|
|
72
|
+
let server: PresmiumServer | undefined
|
|
73
|
+
let service: Service | undefined
|
|
74
|
+
|
|
75
|
+
const sessionOf = (dir: string): DocumentSession => {
|
|
76
|
+
const known = sessions.get(dir)
|
|
77
|
+
if (known) return known
|
|
78
|
+
const document = docs().find(doc => doc.dir === dir)
|
|
79
|
+
if (!document) throw new RequestError(404, `no document ${JSON.stringify(dir)}`)
|
|
80
|
+
const session = new DocumentSession(dir, new Compiler(join(root, document.entry), { ...pluginOptions(), execution: { env: 'serve' } }), log)
|
|
81
|
+
session.onCompile(async (compilation) => {
|
|
82
|
+
for (const finding of untranslated(compilation, root)) {
|
|
83
|
+
if (!compilation.doc.diagnostics.some(f => f.message === finding.message)) compilation.doc.diagnostics.push(finding)
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
session.onCompile((compilation, changed) => {
|
|
87
|
+
service?.compiled(dir, compilation, changed)
|
|
88
|
+
void Promise.resolve(site?.update(dir, compilation)).catch((error: unknown) => log.warn(`the site did not take ${dir}: ${errorMessage(error)}`))
|
|
89
|
+
})
|
|
90
|
+
sessions.set(dir, session)
|
|
91
|
+
return session
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const pluginOptions = (): CompilerOptions => ({
|
|
95
|
+
workspaceRoot: root,
|
|
96
|
+
settings: config.settings,
|
|
97
|
+
services: [...services, ...hostServices],
|
|
98
|
+
bundled: options.bundled,
|
|
99
|
+
...options.operatorDir ? { operatorDir: options.operatorDir } : {},
|
|
100
|
+
secret: name => env[name],
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const spec: SiteSpec = siteSpec(config, docs().map(doc => siteDocument(doc)))
|
|
104
|
+
const refreshSpec = (): DocumentsChange => {
|
|
105
|
+
const now = docs()
|
|
106
|
+
const before = spec.documents.map(doc => doc.dir)
|
|
107
|
+
spec.documents.splice(0, spec.documents.length, ...now.map(doc => siteDocument(doc)))
|
|
108
|
+
return { removed: before.filter(dir => !now.some(doc => doc.dir === dir)), added: now.map(doc => doc.dir).filter(dir => !before.includes(dir)) }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const documentsChanged = async (change: DocumentsChange): Promise<void> => {
|
|
112
|
+
for (const dir of change.removed) {
|
|
113
|
+
sessions.get(dir)?.close()
|
|
114
|
+
sessions.delete(dir)
|
|
115
|
+
}
|
|
116
|
+
const listed = refreshSpec()
|
|
117
|
+
const removed = [...new Set([...change.removed, ...listed.removed])]
|
|
118
|
+
for (const dir of new Set([...removed, ...change.added, ...listed.added])) await server?.unmount(dir)
|
|
119
|
+
await site?.documentsChanged(removed)
|
|
120
|
+
await service?.removed(removed)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let lookedAt = 0
|
|
124
|
+
const lookAtDisk = (): void => {
|
|
125
|
+
if (Date.now() - lookedAt < DISK_LOOK_MS) return
|
|
126
|
+
lookedAt = Date.now()
|
|
127
|
+
const now = docs().map(doc => doc.dir)
|
|
128
|
+
const known = spec.documents.map(doc => doc.dir)
|
|
129
|
+
const change = { removed: known.filter(dir => !now.includes(dir)), added: now.filter(dir => !known.includes(dir)) }
|
|
130
|
+
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)}`))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const workspaceOf = (): { revisions?: Revisions, reviews?: Reviews, writer: DocumentWriter, plugin: ServedPlugin } => {
|
|
134
|
+
const writer = localWriter(config)
|
|
135
|
+
const git = Git.isRepository(root) ? new Git(root) : undefined
|
|
136
|
+
if (!git) log.warn(`${root} is not a git repository: editing, the document operations and the history are not available (run git init)`)
|
|
137
|
+
const revisions = git ? new Revisions(git, { config, writer }) : undefined
|
|
138
|
+
const reviews = revisions ? new Reviews({ config, revisions, locks, files: writer, log }) : undefined
|
|
139
|
+
const documents = new Documents({ config, git: git!, revisions: revisions!, writer, changed: documentsChanged, locks })
|
|
140
|
+
const routes: WorkspaceRoutes = { documents, ...revisions ? { revisions } : {}, locks, documentsChanged, ...service ? { service } : {}, ...token ? { token } : {}, ...reviews ? { reviews } : {} }
|
|
141
|
+
return { ...revisions ? { revisions } : {}, ...reviews ? { reviews } : {}, writer, plugin: { id: WORKSPACE_ID, plugin: workspacePlugin(routes), config: {} } }
|
|
142
|
+
}
|
|
143
|
+
let workspace: ReturnType<typeof workspaceOf> | undefined
|
|
144
|
+
const plugin = (): ServedPlugin => workspace!.plugin
|
|
145
|
+
|
|
146
|
+
let started!: () => void
|
|
147
|
+
const ready = new Promise<void>(resolve => started = resolve)
|
|
148
|
+
|
|
149
|
+
const serverHost: ServerHost = {
|
|
150
|
+
async documents() {
|
|
151
|
+
return docs().map(({ entry: _entry, ...doc }) => doc)
|
|
152
|
+
},
|
|
153
|
+
async compile(document) {
|
|
154
|
+
await ready
|
|
155
|
+
const { doc } = await sessionOf(document).ready()
|
|
156
|
+
return { doc, diagnostics: doc.diagnostics }
|
|
157
|
+
},
|
|
158
|
+
async check(document) {
|
|
159
|
+
await ready
|
|
160
|
+
const compilation = await sessionOf(document).ready()
|
|
161
|
+
return [...compilation.doc.diagnostics, ...await check(compilation)]
|
|
162
|
+
},
|
|
163
|
+
async build(document, buildOptions = {}) {
|
|
164
|
+
await ready
|
|
165
|
+
const compilation = await sessionOf(document).ready()
|
|
166
|
+
const outDir = join(stateDir(root, env), 'builds', encodeURIComponent(document) || '_')
|
|
167
|
+
const report = await buildView(compilation, { to: buildOptions.to, outDir })
|
|
168
|
+
return { outputs: report.outputs.map(({ renderer, outDir: written }) => ({ renderer, outDir: written })), findings: report.findings }
|
|
169
|
+
},
|
|
170
|
+
async run(document, target, signal) {
|
|
171
|
+
await ready
|
|
172
|
+
return runBlock(await sessionOf(document).ready(), target, signal)
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let operators: ReturnType<typeof workspacePlugins> | undefined
|
|
177
|
+
let home: string | undefined
|
|
178
|
+
const operatorsOf = async () => {
|
|
179
|
+
await ready
|
|
180
|
+
home ??= mkdtempSync(join(tmpdir(), 'presmium-home-'))
|
|
181
|
+
return operators ??= workspacePlugins(root, pluginOptions())
|
|
182
|
+
}
|
|
183
|
+
const homeDocument = async (): Promise<ServedDocument> => {
|
|
184
|
+
const loaded = await operatorsOf()
|
|
185
|
+
for (const problem of loaded.problems) log.warn(`the home page: ${problem.message}`)
|
|
186
|
+
const own = (token: ServiceToken<unknown>) => [...services, ...hostServices].find(([known]) => known.id === token.id)?.[1]
|
|
187
|
+
return {
|
|
188
|
+
deckDir: home!,
|
|
189
|
+
workspaceRoot: home!,
|
|
190
|
+
plugins: [plugin(), ...loaded.plugins.flatMap(({ plugin: node }) => (node ? [{ id: node.id, plugin: node, config: {} }] : []))],
|
|
191
|
+
service: <T>(token: ServiceToken<T>) => (own(token) ?? loaded.service(token)) as T | undefined,
|
|
192
|
+
settings: config.settings,
|
|
193
|
+
...options.operatorDir ? { operatorDir: options.operatorDir } : {},
|
|
194
|
+
home: true,
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const stateFile = join(stateDir(root, env), 'server.json')
|
|
199
|
+
server = await startServer({
|
|
200
|
+
mode: 'serve',
|
|
201
|
+
host: options.host,
|
|
202
|
+
port: options.port,
|
|
203
|
+
stateFile,
|
|
204
|
+
forward: () => {
|
|
205
|
+
lookAtDisk()
|
|
206
|
+
return site?.forward()
|
|
207
|
+
},
|
|
208
|
+
settings: config.settings,
|
|
209
|
+
approvals: config.approvals,
|
|
210
|
+
...options.operatorDir ? { operatorDir: options.operatorDir } : {},
|
|
211
|
+
serverHost,
|
|
212
|
+
env,
|
|
213
|
+
...options.approvalTimeoutMs ? { approvalTimeoutMs: options.approvalTimeoutMs } : {},
|
|
214
|
+
log,
|
|
215
|
+
async document(dir) {
|
|
216
|
+
await ready
|
|
217
|
+
if (dir === '' && !config.single) return await homeDocument()
|
|
218
|
+
if (!docs().some(doc => doc.dir === dir)) return undefined
|
|
219
|
+
const compilation = await sessionOf(dir).ready()
|
|
220
|
+
const served = servedDocument(compilation)
|
|
221
|
+
const reviews = workspace?.reviews
|
|
222
|
+
const service = <T>(token: ServiceToken<T>) => served.service(token) ?? (reviews && token.id === CHANGE_REVIEW.id ? reviews.service as T : undefined)
|
|
223
|
+
return { ...served, service, plugins: [plugin(), ...served.plugins] }
|
|
224
|
+
},
|
|
225
|
+
})
|
|
226
|
+
const running = server
|
|
227
|
+
services = running.services
|
|
228
|
+
service = workspaceService({ root, sessionOf, serverHost, url: () => running.url, preview, stateDir: stateDir(root, env), log })
|
|
229
|
+
workspace = workspaceOf()
|
|
230
|
+
hostServices = [
|
|
231
|
+
...workspace.revisions ? [[REVISIONS, workspace.revisions] as [ServiceToken<unknown>, unknown]] : [],
|
|
232
|
+
[DOCUMENT_FILES, workspace.writer],
|
|
233
|
+
[DOCUMENT_LOCKS, locks],
|
|
234
|
+
]
|
|
235
|
+
await workspace.reviews?.recover(docs().map(doc => doc.dir))
|
|
236
|
+
|
|
237
|
+
started()
|
|
238
|
+
if (config.single) for (const { dir } of docs()) void sessionOf(dir).ready().catch(() => undefined)
|
|
239
|
+
|
|
240
|
+
const compileFull = async (dir: string): Promise<IRDocument> => {
|
|
241
|
+
const compilation = await sessionOf(dir).ready()
|
|
242
|
+
return service!.siteDoc(compilation, siteIR(compilation))
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
site = await localSite({ root, spec, bundled: options.bundled, state: stateDir(root, env), compile: compileFull, siteDoc: (compilation, doc) => service!.siteDoc(compilation, doc), preview, log })
|
|
246
|
+
} catch (error) {
|
|
247
|
+
await service?.close()
|
|
248
|
+
await running.close()
|
|
249
|
+
if (home) rmSync(home, { recursive: true, force: true })
|
|
250
|
+
throw error
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
url: running.url,
|
|
255
|
+
root,
|
|
256
|
+
mcp: { url: `${running.url}/mcp`, stateFile },
|
|
257
|
+
async close() {
|
|
258
|
+
workspace?.reviews?.close()
|
|
259
|
+
for (const session of sessions.values()) session.close()
|
|
260
|
+
await service?.close()
|
|
261
|
+
await site?.close()
|
|
262
|
+
await running.close()
|
|
263
|
+
if (home) rmSync(home, { recursive: true, force: true })
|
|
264
|
+
},
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
interface SiteBackend {
|
|
269
|
+
forward(): string | undefined
|
|
270
|
+
update(dir: string, compilation: Compilation): Promise<void> | void
|
|
271
|
+
documentsChanged(removed: string[]): Promise<void>
|
|
272
|
+
close(): Promise<void>
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
interface LocalSiteOptions {
|
|
276
|
+
root: string
|
|
277
|
+
spec: SiteSpec
|
|
278
|
+
bundled?: string
|
|
279
|
+
state: string
|
|
280
|
+
compile(dir: string): Promise<IRDocument>
|
|
281
|
+
siteDoc(compilation: Compilation, doc: IRDocument): IRDocument
|
|
282
|
+
preview: { origins: string[] }
|
|
283
|
+
log: Logger
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function localSite(options: LocalSiteOptions): Promise<SiteBackend> {
|
|
287
|
+
const { root, spec, log } = options
|
|
288
|
+
const renderer = await webRenderer(root, options.bundled)
|
|
289
|
+
const findings: Finding[] = []
|
|
290
|
+
const work = join(options.state, 'site', 'full')
|
|
291
|
+
const ctx = siteContext({ root, outDir: work, workDir: work, findings })
|
|
292
|
+
ctx.preview = options.preview
|
|
293
|
+
const dev = await renderer.devSite!(spec, ctx, options.compile)
|
|
294
|
+
const origin = new URL(dev.url).origin
|
|
295
|
+
log.info(`the workspace site's dev router is at ${origin} (this machine only; pages reach it through serve)`)
|
|
296
|
+
return {
|
|
297
|
+
forward: () => origin,
|
|
298
|
+
update: (dir, compilation) => dev.update(dir, options.siteDoc(compilation, siteIR(compilation))).catch((error: unknown) => log.warn(`the site did not take ${dir}: ${errorMessage(error)}`)),
|
|
299
|
+
documentsChanged: async () => {},
|
|
300
|
+
close: () => dev.close(),
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { createServer } from 'node:net'
|
|
3
|
+
import type { AddressInfo } from 'node:net'
|
|
4
|
+
import { join, relative, sep } from 'node:path'
|
|
5
|
+
import { dev, relativeIR } from '@lincyaw/compiler'
|
|
6
|
+
import type { Compilation } from '@lincyaw/compiler'
|
|
7
|
+
import { errorMessage, PREVIEW_REVISION } from '@lincyaw/kernel'
|
|
8
|
+
import type { Finding, IRDocument, Logger, ProxySpec } from '@lincyaw/kernel'
|
|
9
|
+
import type { ServerHost } from '@lincyaw/server'
|
|
10
|
+
import { WORKSPACE_ID } from '../domain/api.ts'
|
|
11
|
+
import type { DocumentSession } from './session.ts'
|
|
12
|
+
|
|
13
|
+
export interface IrAnswer {
|
|
14
|
+
document: string
|
|
15
|
+
revision: string
|
|
16
|
+
doc: IRDocument
|
|
17
|
+
diagnostics: Finding[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface IrEvent {
|
|
21
|
+
document: string
|
|
22
|
+
revision: string
|
|
23
|
+
diagnostics: { errors: number, warnings: number }
|
|
24
|
+
files: string[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PreviewAnswer {
|
|
28
|
+
document: string
|
|
29
|
+
web: string
|
|
30
|
+
slides?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface BuildAnswer {
|
|
34
|
+
outputs: { renderer: string, outDir: string }[]
|
|
35
|
+
findings: Finding[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CheckAnswer {
|
|
39
|
+
findings: Finding[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface WorkspaceService {
|
|
43
|
+
ir(document: string): Promise<IrAnswer>
|
|
44
|
+
events(document: string, signal: AbortSignal): AsyncIterable<{ event: string, data: IrEvent }>
|
|
45
|
+
preview(document: string): Promise<PreviewAnswer>
|
|
46
|
+
build(document: string, to?: string[]): Promise<BuildAnswer>
|
|
47
|
+
check(document: string): Promise<CheckAnswer>
|
|
48
|
+
proxies: ProxySpec[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function irRevision(doc: IRDocument): string {
|
|
52
|
+
return createHash('sha256').update(JSON.stringify(doc)).digest('hex').slice(0, 16)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function withRevision(doc: IRDocument, revision: string): IRDocument {
|
|
56
|
+
return { ...doc, meta: { ...doc.meta, [PREVIEW_REVISION]: revision } }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function workspacePath(root: string, file: string): string {
|
|
60
|
+
return relative(root, file).split(sep).join('/')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function freePort(): Promise<number> {
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
const server = createServer()
|
|
66
|
+
server.once('error', reject)
|
|
67
|
+
server.listen(0, '127.0.0.1', () => {
|
|
68
|
+
const { port } = server.address() as AddressInfo
|
|
69
|
+
server.close(() => resolve(port))
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function urlPath(dir: string): string {
|
|
75
|
+
return dir.split('/').map(encodeURIComponent).join('/')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function slidesPath(dir: string): string {
|
|
79
|
+
return `slides/${dir ? `${urlPath(dir)}/` : ''}`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ServiceOptions {
|
|
83
|
+
root: string
|
|
84
|
+
sessionOf(dir: string): DocumentSession
|
|
85
|
+
serverHost: ServerHost
|
|
86
|
+
url(): string
|
|
87
|
+
preview?: { origins: string[] }
|
|
88
|
+
stateDir: string
|
|
89
|
+
log: Logger
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface Service extends WorkspaceService {
|
|
93
|
+
compiled(dir: string, compilation: Compilation, changed: string[]): void
|
|
94
|
+
siteDoc(compilation: Compilation, doc: IRDocument): IRDocument
|
|
95
|
+
removed(dirs: string[]): Promise<void>
|
|
96
|
+
close(): Promise<void>
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function workspaceService(options: ServiceOptions): Service {
|
|
100
|
+
const { root, log } = options
|
|
101
|
+
const revisions = new WeakMap<Compilation, string>()
|
|
102
|
+
const revisionOf = (compilation: Compilation) => {
|
|
103
|
+
let known = revisions.get(compilation)
|
|
104
|
+
if (!known) revisions.set(compilation, known = irRevision(compilation.doc))
|
|
105
|
+
return known
|
|
106
|
+
}
|
|
107
|
+
const listeners = new Map<string, Set<(event: IrEvent) => void>>()
|
|
108
|
+
const slides = new Map<string, Promise<{ update(compilation: Compilation): Promise<void>, close(): Promise<void> } | undefined>>()
|
|
109
|
+
const proxies: ProxySpec[] = []
|
|
110
|
+
let closed = false
|
|
111
|
+
|
|
112
|
+
const eventOf = (dir: string, compilation: Compilation, changed: string[]): IrEvent => {
|
|
113
|
+
const count = (severity: Finding['severity']) => compilation.doc.diagnostics.filter(finding => finding.severity === severity).length
|
|
114
|
+
return { document: dir, revision: revisionOf(compilation), diagnostics: { errors: count('error'), warnings: count('warning') }, files: changed.map(file => workspacePath(root, file)) }
|
|
115
|
+
}
|
|
116
|
+
const stamped = (compilation: Compilation): Compilation => (options.preview ? { ...compilation, doc: withRevision(compilation.doc, revisionOf(compilation)) } : compilation)
|
|
117
|
+
|
|
118
|
+
const slidesOf = (dir: string) => {
|
|
119
|
+
let known = slides.get(dir)
|
|
120
|
+
if (!known) {
|
|
121
|
+
known = (async () => {
|
|
122
|
+
const session = options.sessionOf(dir)
|
|
123
|
+
const compilation = await session.ready()
|
|
124
|
+
const configured = Object.keys(compilation.doc.config.renderers)
|
|
125
|
+
if (configured.length > 0 && !configured.includes('slides')) return undefined
|
|
126
|
+
const path = slidesPath(dir)
|
|
127
|
+
const started = await dev(session.compiler, {
|
|
128
|
+
outDir: join(options.stateDir, 'preview', encodeURIComponent(dir) || '_'),
|
|
129
|
+
to: ['slides'],
|
|
130
|
+
compilation: stamped(compilation),
|
|
131
|
+
base: `/__presmium/${WORKSPACE_ID}/${path}`,
|
|
132
|
+
port: await freePort(),
|
|
133
|
+
...options.preview ? { preview: { origins: options.preview.origins } } : {},
|
|
134
|
+
onCompile(_compilation, findings) {
|
|
135
|
+
for (const finding of findings.filter(f => f.checkId === 'presmium.render')) log.warn(`the slides' preview of ${dir || '.'}: ${finding.message}`)
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
const server = started.servers[0]
|
|
139
|
+
if (!server || closed) {
|
|
140
|
+
await started.close()
|
|
141
|
+
return undefined
|
|
142
|
+
}
|
|
143
|
+
const target = new URL(server.url)
|
|
144
|
+
proxies.push({ path: path.replace(/\/$/, ''), target: `${target.origin}${target.pathname.replace(/\/$/, '')}` })
|
|
145
|
+
proxies.sort((a, b) => b.path.length - a.path.length)
|
|
146
|
+
return started
|
|
147
|
+
})()
|
|
148
|
+
known.catch((error: unknown) => {
|
|
149
|
+
log.warn(`the slides' preview of ${dir || '.'} did not start: ${errorMessage(error)}`)
|
|
150
|
+
slides.delete(dir)
|
|
151
|
+
})
|
|
152
|
+
slides.set(dir, known)
|
|
153
|
+
}
|
|
154
|
+
return known
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const stop = async (dir: string) => {
|
|
158
|
+
const known = slides.get(dir)
|
|
159
|
+
slides.delete(dir)
|
|
160
|
+
const path = slidesPath(dir).replace(/\/$/, '')
|
|
161
|
+
const at = proxies.findIndex(proxy => proxy.path === path)
|
|
162
|
+
if (at >= 0) proxies.splice(at, 1)
|
|
163
|
+
await (await known?.catch(() => undefined))?.close()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
proxies,
|
|
168
|
+
async ir(document) {
|
|
169
|
+
const compilation = await options.sessionOf(document).ready()
|
|
170
|
+
const doc = relativeIR(compilation.doc, root, root)
|
|
171
|
+
return { document, revision: revisionOf(compilation), doc, diagnostics: doc.diagnostics }
|
|
172
|
+
},
|
|
173
|
+
events(document, signal) {
|
|
174
|
+
const waiting: IrEvent[] = []
|
|
175
|
+
let wake: (() => void) | undefined
|
|
176
|
+
const listener = (event: IrEvent) => {
|
|
177
|
+
waiting.push(event)
|
|
178
|
+
wake?.()
|
|
179
|
+
}
|
|
180
|
+
const own = listeners.get(document) ?? new Set()
|
|
181
|
+
listeners.set(document, own.add(listener))
|
|
182
|
+
const done = () => own.delete(listener)
|
|
183
|
+
signal.addEventListener('abort', () => {
|
|
184
|
+
done()
|
|
185
|
+
wake?.()
|
|
186
|
+
}, { once: true })
|
|
187
|
+
const session = options.sessionOf(document)
|
|
188
|
+
if (session.latest) waiting.push(eventOf(document, session.latest, []))
|
|
189
|
+
else void session.ready().catch(() => undefined)
|
|
190
|
+
return {
|
|
191
|
+
async* [Symbol.asyncIterator]() {
|
|
192
|
+
try {
|
|
193
|
+
while (!signal.aborted) {
|
|
194
|
+
const next = waiting.shift()
|
|
195
|
+
if (next) {
|
|
196
|
+
yield { event: 'revision', data: next }
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
await new Promise<void>(resolve => wake = resolve)
|
|
200
|
+
wake = undefined
|
|
201
|
+
}
|
|
202
|
+
} finally {
|
|
203
|
+
done()
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
async preview(document) {
|
|
209
|
+
const base = options.url()
|
|
210
|
+
const web = `${base}/${document ? `${urlPath(document)}/` : ''}`
|
|
211
|
+
const own = await slidesOf(document).catch(() => undefined)
|
|
212
|
+
return { document, web, ...own ? { slides: `${base}/__presmium/${WORKSPACE_ID}/${slidesPath(document)}` } : {} }
|
|
213
|
+
},
|
|
214
|
+
async build(document, to) {
|
|
215
|
+
return await options.serverHost.build(document, to ? { to } : {})
|
|
216
|
+
},
|
|
217
|
+
async check(document) {
|
|
218
|
+
return { findings: await options.serverHost.check(document) }
|
|
219
|
+
},
|
|
220
|
+
compiled(dir, compilation, changed) {
|
|
221
|
+
const event = eventOf(dir, compilation, changed)
|
|
222
|
+
for (const listener of listeners.get(dir) ?? []) listener(event)
|
|
223
|
+
const known = slides.get(dir)
|
|
224
|
+
if (known) void known.then(started => started?.update(stamped(compilation))).catch((error: unknown) => log.warn(`the slides' preview of ${dir || '.'} did not update: ${errorMessage(error)}`))
|
|
225
|
+
},
|
|
226
|
+
siteDoc(compilation, doc) {
|
|
227
|
+
return options.preview ? withRevision(doc, revisionOf(compilation)) : doc
|
|
228
|
+
},
|
|
229
|
+
async removed(dirs) {
|
|
230
|
+
for (const dir of dirs) await stop(dir)
|
|
231
|
+
},
|
|
232
|
+
async close() {
|
|
233
|
+
closed = true
|
|
234
|
+
for (const dir of [...slides.keys()]) await stop(dir)
|
|
235
|
+
},
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { existsSync, statSync, watch } from 'node:fs'
|
|
2
|
+
import type { FSWatcher } from 'node:fs'
|
|
3
|
+
import { basename, dirname, join } from 'node:path'
|
|
4
|
+
import type { Compilation, Compiler } from '@lincyaw/compiler'
|
|
5
|
+
import { errorMessage } from '@lincyaw/kernel'
|
|
6
|
+
import type { Logger } from '@lincyaw/kernel'
|
|
7
|
+
|
|
8
|
+
export class DocumentSession {
|
|
9
|
+
readonly dir: string
|
|
10
|
+
readonly compiler: Compiler
|
|
11
|
+
#latest?: Compilation
|
|
12
|
+
#first?: Promise<Compilation>
|
|
13
|
+
#running: Promise<unknown> = Promise.resolve()
|
|
14
|
+
#queued?: Promise<Compilation>
|
|
15
|
+
readonly #listeners = new Set<(compilation: Compilation, changed: string[]) => void | Promise<void>>()
|
|
16
|
+
#changedFiles = new Set<string>()
|
|
17
|
+
readonly #watchers = new Map<string, FSWatcher>()
|
|
18
|
+
#relevant = new Map<string, Set<string>>()
|
|
19
|
+
#timer?: NodeJS.Timeout
|
|
20
|
+
#closed = false
|
|
21
|
+
readonly #log: Logger
|
|
22
|
+
|
|
23
|
+
constructor(dir: string, compiler: Compiler, log: Logger) {
|
|
24
|
+
this.dir = dir
|
|
25
|
+
this.compiler = compiler
|
|
26
|
+
this.#log = log
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
ready(): Promise<Compilation> {
|
|
30
|
+
if (this.#latest) return Promise.resolve(this.#latest)
|
|
31
|
+
if (!this.#first) {
|
|
32
|
+
const first = this.recompile()
|
|
33
|
+
first.catch(() => {
|
|
34
|
+
if (this.#first === first) this.#first = undefined
|
|
35
|
+
})
|
|
36
|
+
this.#first = first
|
|
37
|
+
}
|
|
38
|
+
return this.#first
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get latest(): Compilation | undefined {
|
|
42
|
+
return this.#latest
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
onCompile(listener: (compilation: Compilation, changed: string[]) => void | Promise<void>): () => void {
|
|
46
|
+
this.#listeners.add(listener)
|
|
47
|
+
return () => this.#listeners.delete(listener)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
recompile(): Promise<Compilation> {
|
|
51
|
+
if (this.#queued) return this.#queued
|
|
52
|
+
const next = this.#running.then(async () => {
|
|
53
|
+
this.#queued = undefined
|
|
54
|
+
const started = Date.now()
|
|
55
|
+
const changed = [...this.#changedFiles]
|
|
56
|
+
this.#changedFiles.clear()
|
|
57
|
+
const compilation = await this.compiler.compile()
|
|
58
|
+
this.#latest = compilation
|
|
59
|
+
for (const listener of this.#listeners) {
|
|
60
|
+
try {
|
|
61
|
+
await listener(compilation, changed)
|
|
62
|
+
} catch (error) {
|
|
63
|
+
this.#log.error(`after compiling ${this.dir || '.'}: ${errorMessage(error)}`)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
this.#watch(compilation, started)
|
|
67
|
+
return compilation
|
|
68
|
+
})
|
|
69
|
+
this.#queued = next
|
|
70
|
+
this.#running = next.catch((error: unknown) => this.#log.error(`compiling ${this.dir || '.'} failed: ${errorMessage(error)}`))
|
|
71
|
+
return next
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
close(): void {
|
|
75
|
+
this.#closed = true
|
|
76
|
+
clearTimeout(this.#timer)
|
|
77
|
+
for (const watcher of this.#watchers.values()) watcher.close()
|
|
78
|
+
this.#watchers.clear()
|
|
79
|
+
this.#listeners.clear()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#watch(compilation: Compilation, started: number): void {
|
|
83
|
+
if (this.#closed) return
|
|
84
|
+
const files = new Set([...compilation.files, ...compilation.dependencies.files])
|
|
85
|
+
const next = new Map<string, Set<string>>()
|
|
86
|
+
for (const file of files) {
|
|
87
|
+
let dir = dirname(file)
|
|
88
|
+
let name = basename(file)
|
|
89
|
+
while (!existsSync(dir) && dirname(dir) !== dir) {
|
|
90
|
+
name = basename(dir)
|
|
91
|
+
dir = dirname(dir)
|
|
92
|
+
}
|
|
93
|
+
next.set(dir, (next.get(dir) ?? new Set<string>()).add(name))
|
|
94
|
+
}
|
|
95
|
+
this.#relevant = next
|
|
96
|
+
for (const [dir, watcher] of this.#watchers) {
|
|
97
|
+
if (next.has(dir)) continue
|
|
98
|
+
watcher.close()
|
|
99
|
+
this.#watchers.delete(dir)
|
|
100
|
+
}
|
|
101
|
+
for (const dir of next.keys()) {
|
|
102
|
+
if (this.#watchers.has(dir)) continue
|
|
103
|
+
try {
|
|
104
|
+
this.#watchers.set(dir, watch(dir, (_event, name) => {
|
|
105
|
+
if (name && this.#relevant.get(dir)?.has(basename(name))) this.#changed(join(dir, name))
|
|
106
|
+
}))
|
|
107
|
+
} catch {
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const modified = [...files].filter((file) => {
|
|
111
|
+
const time = statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? 0
|
|
112
|
+
return time >= started - 20 && time <= Date.now()
|
|
113
|
+
})
|
|
114
|
+
for (const file of modified) this.#changed(file)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#changed(file: string): void {
|
|
118
|
+
if (this.#closed) return
|
|
119
|
+
this.#changedFiles.add(file)
|
|
120
|
+
clearTimeout(this.#timer)
|
|
121
|
+
this.#timer = setTimeout(() => void this.recompile().catch(() => undefined), 50)
|
|
122
|
+
}
|
|
123
|
+
}
|