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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +33 -4
  2. package/package.json +20 -19
  3. package/src/app/base.css +117 -0
  4. package/src/app/block-playback.ts +81 -0
  5. package/src/app/block-report.ts +104 -0
  6. package/src/app/document-modules.ts +30 -0
  7. package/src/app/env.d.ts +12 -0
  8. package/src/app/main.ts +198 -0
  9. package/src/app/page-app.ts +166 -0
  10. package/src/app/router.ts +109 -0
  11. package/src/app/server.ts +32 -0
  12. package/src/app/site/home.tsx +117 -0
  13. package/src/app/site/layout.tsx +92 -0
  14. package/src/app/site/search.tsx +22 -0
  15. package/src/app/site.css +1450 -0
  16. package/src/client/document.css +172 -0
  17. package/src/client/host.ts +22 -94
  18. package/src/client/preview-frame.ts +152 -0
  19. package/src/client/runtime.ts +159 -0
  20. package/src/domain/app-config.ts +91 -0
  21. package/src/domain/config.ts +17 -9
  22. package/src/domain/dev-updates.ts +12 -0
  23. package/src/domain/index.ts +17 -7
  24. package/src/domain/landing.ts +83 -0
  25. package/src/domain/links.ts +23 -18
  26. package/src/domain/messages.ts +74 -63
  27. package/src/domain/pages.ts +23 -41
  28. package/src/domain/themes.ts +59 -0
  29. package/src/domain/workspace-home.ts +12 -0
  30. package/src/node/client-modules.ts +92 -0
  31. package/src/node/document-bundle.ts +227 -0
  32. package/src/node/landing-template.ts +34 -0
  33. package/src/node/landing.ts +82 -0
  34. package/src/node/offline.ts +34 -11
  35. package/src/node/publish.ts +83 -15
  36. package/src/node/renderer.ts +78 -117
  37. package/src/node/site-assets.ts +17 -0
  38. package/src/node/site-build.ts +304 -0
  39. package/src/node/site-files.ts +87 -0
  40. package/src/node/site-model.ts +337 -0
  41. package/src/node/site-page.ts +82 -0
  42. package/src/node/site-server.ts +822 -0
  43. package/src/node/slides.ts +135 -0
  44. package/src/node/viewer-app.ts +504 -0
  45. package/src/node/viewer-build.ts +38 -0
  46. package/custom-nav-controls.vue +0 -17
  47. package/src/client/env.d.ts +0 -1
  48. package/src/client/home.ts +0 -182
  49. package/src/client/index.ts +0 -4
  50. package/src/client/page.ts +0 -38
  51. package/src/client/preview.ts +0 -71
  52. package/src/client/style.css +0 -427
  53. package/src/client/theme.ts +0 -322
  54. package/src/node/addons.ts +0 -76
  55. package/src/node/site.ts +0 -349
  56. package/src/node/sites.ts +0 -531
  57. package/src/node/vite.ts +0 -166
  58. package/src/node/vitepress.ts +0 -72
@@ -0,0 +1,822 @@
1
+ import { createReadStream, existsSync, mkdirSync, realpathSync, statSync, watch } from 'node:fs'
2
+ import type { ChildProcess } from 'node:child_process'
3
+ import type { FSWatcher } from 'node:fs'
4
+ import { Agent, createServer } from 'node:http'
5
+ import type { IncomingMessage, ServerResponse } from 'node:http'
6
+ import type { AddressInfo } from 'node:net'
7
+ import type { Duplex } from 'node:stream'
8
+ import { join } from 'node:path'
9
+ import { fileURLToPath } from 'node:url'
10
+ import { stripVTControlCharacters } from 'node:util'
11
+ import { errorMessage, irBlocks, isRecord, localRequestRefusal, previewFrameAncestors, PreviewPreparing, safeDecodeURIComponent, viewFor } from '@lincyaw/kernel'
12
+ import type { IRDocument, NodeVitePlugin, RenderContext, RuntimeAssets, SiteSpec } from '@lincyaw/kernel'
13
+ import { isInside, spawnInteractiveNode } from '@lincyaw/kernel/node'
14
+ import { diffDocuments } from '@lincyaw/viewer/domain'
15
+ import { createProxyServer } from 'http-proxy-3'
16
+ import send from 'send'
17
+ import { chapterRedirects, searchDocuments, sitePageLayout, translationPages, UPDATE_EVENT, webPages } from '../domain/index.ts'
18
+ import type { AppConfig, DevUpdateEvent, WebPage } from '../domain/index.ts'
19
+ import { BUNDLE_ENTRY, documentComponents, findDocumentBundle } from './document-bundle.ts'
20
+ import type { DocumentBundle, DocumentBundleInput } from './document-bundle.ts'
21
+ import { irRevisionOfJson, PublicationCache, publishedDocument } from './publish.ts'
22
+ import { findRuntimeAsset, REVISION_HEADER, runtimeAssets, searchIndex } from './site-files.ts'
23
+ import { documentBase, documentPage, documentSiteConfig, homeDocument, homeDocuments, isSingle, landingConfig, landingDocument, languageLabel, LIST_PAGE, siteLanguage, siteLanguages, siteNeighbours, siteShellBelow } from './site-model.ts'
24
+ import { publishedSite } from './site-assets.ts'
25
+ import { loadLanding, publishLanding, siteContent } from './landing.ts'
26
+ import { buildLandingTemplate, writeLandingTemplate } from './landing-template.ts'
27
+ import { pageHtml } from './site-page.ts'
28
+ import { appEntry, appPlugin, coreFingerprint, findViewerApp, sharedSpecifiers } from './viewer-app.ts'
29
+ import type { AppPlugin, ViewerApp } from './viewer-app.ts'
30
+ import type { ViewerBuildRequest } from './viewer-build.ts'
31
+
32
+ /**
33
+ * The web form in development: the viewer app, prebuilt once for the site's plugins, shows every page of every document
34
+ * as the built site would, rendered in the browser. Below `<base>__app/` are the app's own routes: a document's IR
35
+ * (`ir`), its changes (`events`, server-sent; the sections that changed), its search index (`search`), the workbench's
36
+ * page of a document (`view`), the app's files (`v/<key>/`) and the documents' bundles (`d/<dir>/<key>/`). Below each
37
+ * document's site path are its pages, its published files (`media/`), its plugins' run-time files and its `public/`.
38
+ */
39
+
40
+ const BUILD_SCRIPT = fileURLToPath(new URL('./viewer-build.ts', import.meta.url))
41
+ const PREFIX = '__app/'
42
+ const SERVER_PROXY = '/__presmium/'
43
+ // The fingerprints of sources are taken again at most this often.
44
+ const FINGERPRINT_MS = 3000
45
+ const CHANGE_MS = 200
46
+ const KEEPALIVE_MS = 25_000
47
+ // Files whose address names their content are cached a year.
48
+ const IMMUTABLE_MS = 365 * 24 * 3600_000
49
+ // The workbench's first preview waits this long for the app and the document's bundle.
50
+ const WAIT_MS = 15_000
51
+ const IGNORED = /(?:^|[\\/])(?:node_modules|\.presmium|\.git|dist)(?:[\\/]|$)/
52
+
53
+ /** What a page shows of a document: its pages, or its slides. */
54
+ type Form = 'web' | 'slides'
55
+
56
+ export interface SiteServerOptions {
57
+ site(): SiteSpec
58
+ /** The form of the server's pages (`presmium dev --to slides` serves the slides); the workbench asks for either. */
59
+ form?: Form
60
+ /** The site's render context: its path (`base`), the workspace, the operator's plugins, the previews' origins. */
61
+ ctx: RenderContext
62
+ /** Where viewer apps are kept, shared by every workspace. */
63
+ cacheDir: string
64
+ /** The server's own state: the documents' bundles and published files. */
65
+ workDir: string
66
+ /** The document as the site shows it (compiled when not yet). */
67
+ document(dir: string): Promise<IRDocument>
68
+ /** The document's render context, below its own site path. */
69
+ context(dir: string, doc: IRDocument): RenderContext
70
+ nodePlugins(dir: string): readonly NodeVitePlugin[]
71
+ /** The document's translations, as compiled now. */
72
+ translations(dir: string): readonly { lang: string, doc: IRDocument }[]
73
+ listen: { host: string, port: number }
74
+ /** Opens a source line in the author's editor (`presmium dev`). */
75
+ openSource?(file: string, line: number): void
76
+ log?(message: string): void
77
+ }
78
+
79
+ export interface SiteServer {
80
+ /** The site's address. */
81
+ url: string
82
+ origin: string
83
+ /** The document changed: its pages get the sections that changed. */
84
+ update(dir: string, doc: IRDocument): void
85
+ /**
86
+ * The path of the workbench's page of the document, once the app has its plugins and the document's bundle is built:
87
+ * started when missing, waited for a while with `wait`.
88
+ */
89
+ app(dir: string, wait?: boolean): Promise<string | undefined>
90
+ /** Builds what the document's pages need ahead of their first request. */
91
+ warm(dir: string): Promise<void>
92
+ close(): Promise<void>
93
+ }
94
+
95
+ interface Published {
96
+ source: IRDocument
97
+ doc: IRDocument
98
+ json: string
99
+ revision: string
100
+ search?: string
101
+ }
102
+
103
+ interface DocumentState {
104
+ /** The document's bundle: none when it has no code of its own, unknown until looked for. */
105
+ bundle?: DocumentBundle | null
106
+ bundleFailure?: string
107
+ /** The modules the bundle imports by name: the app's. */
108
+ externals?: readonly string[]
109
+ building?: Promise<void>
110
+ stale?: boolean
111
+ watcher?: FSWatcher
112
+ timer?: ReturnType<typeof setTimeout>
113
+ /** The published IR of each form and language (`slides:`, `web:` the document's own, `web:<lang>` a translation's). */
114
+ published: Map<string, Published>
115
+ caches: Map<Form, PublicationCache>
116
+ listeners: Set<{ res: ServerResponse, app: string, lang: string, form: Form }>
117
+ assets?: Promise<RuntimeAssets[]>
118
+ }
119
+
120
+ // A document's directory as one segment of a path; the document at the root as `~` (a `.` would be taken out of the address).
121
+ const segment = (dir: string) => (dir ? encodeURIComponent(dir) : '~')
122
+
123
+ /** Sends the file at `path` (URL-encoded, below `root`) through `send` (Express's): types, ranges and validators as HTTP defines them. */
124
+ function serveFile(req: IncomingMessage, res: ServerResponse, root: string, path: string, immutable: boolean): void {
125
+ send(req, path, { root, ...immutable ? { maxAge: IMMUTABLE_MS, immutable: true } : {}, index: false, dotfiles: 'allow' })
126
+ .on('error', (error: { status?: number }) => {
127
+ if (res.headersSent) return res.destroy()
128
+ res.writeHead(error.status ?? 500, { 'Content-Type': 'text/plain; charset=utf-8' }).end(error.status === 404 ? 'not found' : 'the file could not be read')
129
+ })
130
+ .pipe(res)
131
+ }
132
+
133
+ interface BuildWorker {
134
+ run<T>(request: ViewerBuildRequest): Promise<T>
135
+ close(): void
136
+ }
137
+
138
+ /**
139
+ * The process that builds the viewer app and the documents' bundles, apart from the server, which stays responsive:
140
+ * started on the first build, at the interactive budget (the author may be waiting), and kept for the next.
141
+ */
142
+ function createBuildWorker(): BuildWorker {
143
+ let child: ChildProcess | undefined
144
+ let serial = 0
145
+ const waiting = new Map<number, { resolve(value: unknown): void, reject(error: Error): void }>()
146
+ const start = () => {
147
+ const spawned = spawnInteractiveNode(BUILD_SCRIPT, [], { stdio: ['ignore', 'inherit', 'inherit', 'ipc'] })
148
+ spawned.on('message', ({ id, ok, result, error }: { id: number, ok: boolean, result?: unknown, error?: string }) => {
149
+ const answer = waiting.get(id)
150
+ waiting.delete(id)
151
+ if (ok) answer?.resolve(result)
152
+ else answer?.reject(new Error(error))
153
+ })
154
+ spawned.once('exit', (code) => {
155
+ if (child === spawned) child = undefined
156
+ for (const answer of waiting.values()) answer.reject(new Error(`the viewer's build process exited with code ${code}`))
157
+ waiting.clear()
158
+ })
159
+ spawned.once('error', () => undefined)
160
+ return spawned
161
+ }
162
+ return {
163
+ run<T>(request: ViewerBuildRequest) {
164
+ const worker = child ??= start()
165
+ const id = ++serial
166
+ return new Promise<T>((resolve, reject) => {
167
+ waiting.set(id, { resolve: resolve as (value: unknown) => void, reject })
168
+ worker.send({ ...request, id })
169
+ })
170
+ },
171
+ close() {
172
+ child?.kill()
173
+ },
174
+ }
175
+ }
176
+
177
+ /** Whether a bundle importing `imported` by name imports only modules of `externals`. */
178
+ function importsOnly(imported: readonly string[] | undefined, externals: readonly string[]): boolean {
179
+ if (imported === undefined) return false
180
+ const names = new Set(externals)
181
+ return imported.every(name => names.has(name))
182
+ }
183
+
184
+ /** The page of a document a path below its language's site path is: `''` and `index.html` its first, `<path>.html` another. */
185
+ function pageAt(pages: readonly WebPage[], rest: string): WebPage | undefined {
186
+ const name = rest === '' || rest === 'index.html' ? 'index' : rest.endsWith('.html') ? rest.slice(0, -'.html'.length) : undefined
187
+ return name === undefined ? undefined : pages.find(page => page.path === name)
188
+ }
189
+
190
+ export async function startSiteServer(options: SiteServerOptions): Promise<SiteServer> {
191
+ const { ctx } = options
192
+ const base = ctx.base
193
+ siteShellBelow(options.site(), base)
194
+ landingDocument(options.site())
195
+ loadLanding(options.site(), ctx)
196
+ const templateOutputs = new Set<string>()
197
+ const templateBuilds = new Map<string, Promise<string>>()
198
+ const states = new Map<string, DocumentState>()
199
+ const plugins = new Map<string, { plugin: Promise<AppPlugin>, at: number }>()
200
+ const worker = createBuildWorker()
201
+ let core = { value: coreFingerprint(), at: Date.now() }
202
+ let current: ViewerApp | undefined
203
+ // The apps whose pages show code since changed.
204
+ const outdated = new Set<string>()
205
+ let building: Promise<void> | undefined
206
+ // Why the last build of the app failed; cleared when one succeeds.
207
+ let appFailure: string | undefined
208
+ let pending: AppPlugin[] | undefined
209
+ let closed = false
210
+ const log = options.log ?? ((message: string) => console.warn(`[presmium] ${message}`))
211
+ // The workspace service's pages: only the workbench's origins may frame them, and none when it names none.
212
+ const framed = ctx.preview ? previewFrameAncestors(ctx.preview.origins) : undefined
213
+ const latest = new Map<string, IRDocument>()
214
+
215
+ const documents = () => options.site().documents.map(entry => entry.dir)
216
+ const shellSite = () => publishedSite(options.site(), ctx, join(options.workDir, 'site-assets'), `${base}${PREFIX}site-assets/`)
217
+ const stateOf = (dir: string): DocumentState => {
218
+ let state = states.get(dir)
219
+ if (!state) states.set(dir, state = { caches: new Map(), listeners: new Set(), published: new Map() })
220
+ return state
221
+ }
222
+ const documentOf = async (dir: string): Promise<IRDocument> => {
223
+ const doc = latest.get(dir) ?? await options.document(dir)
224
+ if (!latest.has(dir)) latest.set(dir, doc)
225
+ return latest.get(dir)!
226
+ }
227
+
228
+ const coreNow = () => {
229
+ if (Date.now() - core.at > FINGERPRINT_MS) core = { value: coreFingerprint(), at: Date.now() }
230
+ return core.value
231
+ }
232
+
233
+ /** The plugin package at `dir` as it is now (its fingerprint taken again when old). */
234
+ const pluginAt = (name: string, dir: string): Promise<AppPlugin> => {
235
+ const known = plugins.get(dir)
236
+ if (known && Date.now() - known.at < FINGERPRINT_MS) return known.plugin
237
+ const plugin = appPlugin(name, dir)
238
+ plugins.set(dir, { plugin, at: Date.now() })
239
+ plugin.catch(() => plugins.delete(dir))
240
+ return plugin
241
+ }
242
+
243
+ /** The plugin packages a document's pages run: its own and the operator's. */
244
+ const pluginsOf = async (dir: string, doc: IRDocument): Promise<AppPlugin[]> => {
245
+ const own = options.context(dir, doc)
246
+ const named = new Map<string, string>()
247
+ for (const name of own.plugins) if (own.pluginDirs[name]) named.set(name, own.pluginDirs[name]!)
248
+ for (const name of ctx.plugins) if (ctx.pluginDirs[name]) named.set(name, ctx.pluginDirs[name]!)
249
+ return await Promise.all([...named].map(([name, at]) => pluginAt(name, at)))
250
+ }
251
+ const operatorPlugins = async () => await Promise.all(ctx.plugins.filter(name => ctx.pluginDirs[name]).map(name => pluginAt(name, ctx.pluginDirs[name]!)))
252
+
253
+ const notify = (dir: string, event: string, data: (lang: string) => unknown, which: (app: string) => boolean = () => true) => {
254
+ for (const listener of states.get(dir)?.listeners ?? []) {
255
+ if (which(listener.app)) listener.res.write(`event: ${event}\ndata: ${JSON.stringify(data(listener.lang))}\n\n`)
256
+ }
257
+ }
258
+ const reload = (dir: string, which?: (app: string) => boolean) => notify(dir, 'reload', () => ({}), which)
259
+
260
+ /**
261
+ * Makes `app` the current one. The pages of the one before stay when it only had fewer plugins (its files stay too);
262
+ * when Presmium's code or a plugin's changed, they reload.
263
+ */
264
+ const replace = (app: ViewerApp) => {
265
+ const before = current
266
+ current = app
267
+ if (!before || before.manifest.key === app.manifest.key) return
268
+ const { core, plugins } = before.manifest
269
+ if (core === app.manifest.core && Object.entries(plugins).every(([name, known]) => (app.manifest.plugins[name] ?? known) === known)) return
270
+ outdated.add(before.manifest.key)
271
+ for (const dir of states.keys()) reload(dir, key => outdated.has(key))
272
+ }
273
+
274
+ /** Whether the current app has these plugins as they are now. */
275
+ const hasPlugins = (wanted: readonly AppPlugin[]) =>
276
+ current !== undefined && current.manifest.core === coreNow() && wanted.every(p => current!.manifest.plugins[p.name] === p.fingerprint)
277
+
278
+ /** Builds the app with these plugins, one build at a time; the pages of an older app reload. */
279
+ const buildApp = (wanted: AppPlugin[]): Promise<void> => {
280
+ pending = wanted
281
+ building ??= (async () => {
282
+ while (pending && !closed) {
283
+ const all = pending
284
+ pending = undefined
285
+ if (hasPlugins(all)) continue
286
+ const started = Date.now()
287
+ try {
288
+ const app = await worker.run<ViewerApp>({ kind: 'app', cacheDir: options.cacheDir, plugins: all })
289
+ if (closed) return
290
+ replace(app)
291
+ appFailure = undefined
292
+ log(`the viewer app was built in ${((Date.now() - started) / 1000).toFixed(1)} s`)
293
+ } catch (error) {
294
+ appFailure = stripVTControlCharacters(errorMessage(error))
295
+ log(`the viewer app could not be built: ${errorMessage(error)}`)
296
+ }
297
+ }
298
+ })().finally(() => {
299
+ building = undefined
300
+ })
301
+ return building
302
+ }
303
+
304
+ /** The app with `wanted` and every plugin of the pages shown so far, found in the cache or built. */
305
+ const appWith = async (wanted: readonly AppPlugin[]): Promise<{ externals: string[], built: Promise<unknown> }> => {
306
+ if (!hasPlugins(wanted)) {
307
+ const found = findViewerApp(options.cacheDir, coreNow(), wanted)
308
+ if (found) replace(found)
309
+ }
310
+ if (hasPlugins(wanted)) return { externals: Object.keys(current!.manifest.shared), built: Promise.resolve() }
311
+ // The app has the plugins of the documents shown before too, so that it serves them all.
312
+ const seen = await Promise.all([...states.keys()].map(async other => pluginsOf(other, await documentOf(other)).catch(() => [])))
313
+ const all = [...new Map([...seen.flat(), ...await operatorPlugins(), ...wanted].map(p => [p.name, p])).values()]
314
+ return { externals: Object.keys(sharedSpecifiers(all)), built: buildApp(all) }
315
+ }
316
+
317
+ const bundleInput = (dir: string, deckDir: string, externals: readonly string[]): DocumentBundleInput => ({
318
+ deckDir,
319
+ workspaceRoot: ctx.workspaceRoot,
320
+ outDir: join(options.workDir, 'bundles', segment(dir)),
321
+ externals: [...externals],
322
+ nodePlugins: options.nodePlugins(dir).map(({ entry, config }) => ({ entry, config })),
323
+ components: documentComponents(latest.get(dir)),
324
+ })
325
+
326
+ /** Watches the document's files: when its code changes, its bundle is built again and its pages reload. */
327
+ const watchDocument = (dir: string, deckDir: string, state: DocumentState) => {
328
+ if (state.watcher || closed) return
329
+ try {
330
+ state.watcher = watch(deckDir, { recursive: true }, (_event, name) => {
331
+ if (!name || IGNORED.test(name.toString())) return
332
+ clearTimeout(state.timer)
333
+ state.timer = setTimeout(() => void refreshBundle(dir, deckDir, state), CHANGE_MS)
334
+ })
335
+ state.watcher.on('error', () => undefined)
336
+ } catch {}
337
+ }
338
+
339
+ /** Finds the bundle of the document's files as they are now, or builds it; its pages reload when it changed. */
340
+ const refreshBundle = async (dir: string, deckDir: string, state: DocumentState) => {
341
+ if (closed || state.externals === undefined) return
342
+ if (state.building) {
343
+ state.stale = true
344
+ return
345
+ }
346
+ const { externals } = state
347
+ const found = await findDocumentBundle(bundleInput(dir, deckDir, externals)).catch(() => undefined)
348
+ if (closed || state.externals !== externals) return
349
+ if (found === undefined) {
350
+ const before = state.bundle?.key ?? null
351
+ await buildBundle(dir, deckDir, state, externals)
352
+ if (!closed && state.externals === externals && (state.bundle?.key ?? null) !== before) reload(dir)
353
+ return
354
+ }
355
+ if ((found?.key ?? null) === (state.bundle?.key ?? null)) return
356
+ state.bundle = found
357
+ reload(dir)
358
+ }
359
+
360
+ /** Builds the document's bundle importing `externals` by name; it is kept when they are still the ones wanted. */
361
+ const buildBundle = (dir: string, deckDir: string, state: DocumentState, externals: readonly string[]): Promise<void> => {
362
+ state.building ??= (async () => {
363
+ const started = Date.now()
364
+ try {
365
+ const bundle = await worker.run<DocumentBundle | null>({ kind: 'document', input: bundleInput(dir, deckDir, externals) })
366
+ if (state.externals === externals) { state.bundle = bundle; state.bundleFailure = undefined }
367
+ if (bundle) log(`the code of ${dir || '.'} was bundled in ${((Date.now() - started) / 1000).toFixed(1)} s`)
368
+ } catch (error) {
369
+ if (state.externals === externals) state.bundleFailure = stripVTControlCharacters(errorMessage(error))
370
+ log(`the code of ${dir || '.'} could not be bundled: ${errorMessage(error)}`)
371
+ } finally {
372
+ state.building = undefined
373
+ if (state.stale) {
374
+ state.stale = false
375
+ void refreshBundle(dir, deckDir, state)
376
+ }
377
+ }
378
+ })()
379
+ return state.building
380
+ }
381
+
382
+ /**
383
+ * Prepares what the document's pages need: the app with its plugins and the document's bundle, built at the same time
384
+ * (the modules the bundle imports by name are known before the app is built).
385
+ */
386
+ const prepare = async (dir: string): Promise<{ ready: () => boolean, done: Promise<unknown> }> => {
387
+ const doc = await documentOf(dir)
388
+ const own = options.context(dir, doc)
389
+ const wanted = await pluginsOf(dir, doc)
390
+ const { externals, built } = await appWith(wanted)
391
+ const state = stateOf(dir)
392
+ watchDocument(dir, own.deckDir, state)
393
+ // A bundle that imports some of the app's modules by name runs with it; one that bundled any of them is built again.
394
+ if (!importsOnly(state.externals, externals)) {
395
+ state.externals = externals
396
+ state.bundle = undefined
397
+ state.bundleFailure = undefined
398
+ }
399
+ if (state.bundle === undefined && !state.building) {
400
+ const found = await findDocumentBundle(bundleInput(dir, own.deckDir, externals)).catch(() => undefined)
401
+ if (found !== undefined) state.bundle = found
402
+ else void buildBundle(dir, own.deckDir, state, externals)
403
+ }
404
+ const ready = () => hasPlugins(wanted) && state.bundle !== undefined && importsOnly(state.externals, Object.keys(current!.manifest.shared))
405
+ return { ready, done: Promise.all([built, state.building]) }
406
+ }
407
+
408
+ // A document as each form sees it (`{only}`): the server gets the document whole.
409
+ const views = new WeakMap<IRDocument, Map<Form, IRDocument>>()
410
+ const viewOf = (doc: IRDocument, form: Form): IRDocument => {
411
+ let known = views.get(doc)
412
+ if (!known) views.set(doc, known = new Map())
413
+ let view = known.get(form)
414
+ if (!view) known.set(form, view = viewFor(doc, form))
415
+ return view
416
+ }
417
+ const docs = () => new Map([...latest].map(([dir, doc]) => [dir, viewOf(doc, 'web')] as const))
418
+
419
+ /** The document's IR in a form and language as its pages read it: published, its files below its site path. */
420
+ const published = (dir: string, doc: IRDocument, lang = '', form: Form = 'web', unfiltered = false): Published => {
421
+ const state = stateOf(dir)
422
+ const key = `${unfiltered ? 'block' : form}:${lang}`
423
+ const known = state.published.get(key)
424
+ if (known?.source === doc) return known
425
+ const own = options.context(dir, doc)
426
+ const docBase = documentBase(base, dir)
427
+ const linked = isSingle(options.site()) ? undefined : documentPage(options.site(), base, docs())
428
+ let cache = state.caches.get(form)
429
+ if (!cache) state.caches.set(form, cache = new PublicationCache())
430
+ const shown = publishedDocument(lang || unfiltered ? doc : viewOf(doc, form), own, join(options.workDir, 'media', segment(dir)), docBase, linked, lang ? undefined : cache)
431
+ const json = JSON.stringify(shown)
432
+ const next = { source: doc, doc: shown, json, revision: irRevisionOfJson(json) }
433
+ state.published.set(key, next)
434
+ return next
435
+ }
436
+
437
+ const translationOf = (dir: string, lang: string) => options.translations(dir).find(t => t.lang === lang)
438
+
439
+ /** What a document's page in `lang` (`''` its own) tells the app. */
440
+ const documentConfig = (dir: string, doc: IRDocument, lang: string, page: WebPage | undefined, bundle: DocumentBundle | null | undefined, workbench: boolean, form: Form): AppConfig => {
441
+ const site = shellSite()
442
+ const docBase = documentBase(base, dir)
443
+ const query = `document=${encodeURIComponent(dir)}${lang ? `&lang=${encodeURIComponent(lang)}` : ''}${form === 'slides' ? '&form=slides' : ''}`
444
+ const shown = lang ? translationOf(dir, lang)?.doc ?? doc : doc
445
+ const own = doc.meta.lang ?? ctx.lang ?? 'zh-CN'
446
+ const pages = lang ? translationPages(published(dir, shown, lang).doc, webPages(published(dir, doc).doc)) : undefined
447
+ const shownSite = documentSiteConfig({ site, base, dir, doc, lang: lang || own, docs: docs(), workspace: !isSingle(site) })
448
+ if (!workbench && form === 'slides' && options.form !== 'slides') shownSite.links = { ...shownSite.links, webUrl: `${docBase}${lang ? `${lang}/` : ''}` }
449
+ return {
450
+ kind: 'document',
451
+ document: dir,
452
+ base: docBase,
453
+ lang: lang || own,
454
+ ...form === 'slides' ? { form } : {},
455
+ ir: `${base}${PREFIX}ir?${query}`,
456
+ events: `${base}${PREFIX}events?${query}&app=${current!.manifest.key}`,
457
+ ...base !== '/' ? { server: base } : {},
458
+ ...bundle ? { bundle: `${base}${PREFIX}d/${segment(dir)}/${bundle.key}/${BUNDLE_ENTRY}` } : {},
459
+ ...form === 'web' ? { search: `${base}${PREFIX}search?${query}` } : {},
460
+ ...page ? { page: page.path } : {},
461
+ ...pages ? { pages } : {},
462
+ site: { ...shownSite, ...isSingle(site) ? { brandTitle: shown.meta.title } : {}, layout: sitePageLayout(isRecord(shown.config.renderers.web) ? shown.config.renderers.web : {}, lang || own) },
463
+ plugins: workbench ? options.context(dir, doc).plugins : [...new Set([...options.context(dir, doc).plugins, ...ctx.plugins])],
464
+ origins: ctx.preview?.origins ?? [],
465
+ ...options.openSource && !workbench ? { source: `${base}${PREFIX}source` } : {},
466
+ }
467
+ }
468
+
469
+ const html = (res: ServerResponse, status: number, config: AppConfig, title: string) => {
470
+ res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', ...framed ? { 'Content-Security-Policy': framed } : {} })
471
+ res.end(pageHtml({ entry: appEntry(current!), appUrl: `${base}${PREFIX}v/${current!.manifest.key}/`, shared: current!.manifest.shared, config, title }))
472
+ }
473
+
474
+ /** A page that shows no document: the home page or a missing page, with the operator's plugins. */
475
+ const plainPage = async (res: ServerResponse, kind: 'home' | 'missing', lang?: string, independent = false) => {
476
+ const site = shellSite()
477
+ const own = siteLanguage(site, ctx.lang)
478
+ const others = siteLanguages(site, own)
479
+ const locales = [{ lang: own, label: languageLabel(own), base }, ...others.map(other => ({ lang: other.lang, label: languageLabel(other.lang), base: `${base}${other.lang}/` }))]
480
+ const landing = independent ? loadLanding(site, ctx) : undefined
481
+ if (landing?.kind === 'template') {
482
+ const key = `${lang ?? own}:${landing.file}`
483
+ let pending = templateBuilds.get(key)
484
+ if (!pending) {
485
+ pending = (async () => {
486
+ const output = await buildLandingTemplate(landing.file, join(options.workDir, 'landing', lang ?? own), base, siteContent(site, base, lang ?? own, homeDocuments(site, base, docs(), lang)), ctx.workspaceRoot)
487
+ const published = join(options.workDir, 'landing', 'published')
488
+ writeLandingTemplate(output, published)
489
+ templateOutputs.add(published)
490
+ return output.html
491
+ })()
492
+ templateBuilds.set(key, pending)
493
+ }
494
+ let body: string
495
+ try { body = await pending } finally { if (templateBuilds.get(key) === pending) templateBuilds.delete(key) }
496
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', ...framed ? { 'Content-Security-Policy': framed } : {} })
497
+ return void res.end(body)
498
+ }
499
+ const { built } = await appWith(await operatorPlugins())
500
+ await built
501
+ if (!current) return void res.writeHead(503, { 'Content-Type': 'text/plain; charset=utf-8' }).end('the viewer app could not be built')
502
+ const title = others.find(other => other.lang === lang)?.title ?? site.title
503
+ const config: AppConfig = {
504
+ kind,
505
+ document: '',
506
+ base,
507
+ lang: lang ?? own,
508
+ site: { title, brandTitle: title, root: `${base}${lang ? `${lang}/` : ''}`, shell: siteShellBelow(site, base), locales, links: {} },
509
+ ...kind === 'home' ? { documents: homeDocuments(site, base, docs(), lang) } : {},
510
+ ...landing?.kind === 'page' ? { landingPage: publishLanding(landing, site, ctx, join(options.workDir, 'site-assets'), `${base}${PREFIX}site-assets/`, docs()) } : {},
511
+ plugins: ctx.plugins,
512
+ origins: ctx.preview?.origins ?? [],
513
+ }
514
+ html(res, kind === 'missing' ? 404 : 200, config, config.landingPage?.title ?? title)
515
+ }
516
+
517
+ /** A new preview must confirm current project files, even before the debounced watcher rebuilds. */
518
+ const currentDocumentBundle = async (dir: string, doc: IRDocument): Promise<boolean> => {
519
+ const state = stateOf(dir), own = options.context(dir, doc)
520
+ for (let attempt = 0; attempt < 3; attempt++) {
521
+ const externals = state.externals
522
+ if (closed || !externals || !current) return false
523
+ const found = await findDocumentBundle(bundleInput(dir, own.deckDir, externals)).catch(() => undefined)
524
+ if (closed || state.externals !== externals) continue
525
+ if (found !== undefined) { state.bundle = found; state.bundleFailure = undefined; return true }
526
+ await (state.building ?? buildBundle(dir, own.deckDir, state, externals))
527
+ }
528
+ return false
529
+ }
530
+
531
+ /** A document's page (or its workbench page), once the app and the document's bundle are there. */
532
+ const documentPageResponse = async (res: ServerResponse, dir: string, lang: string, rest: string | undefined, form: Form = 'web', block?: string, landing = false) => {
533
+ const { ready, done } = await prepare(dir)
534
+ if (!ready()) await done
535
+ if (!ready() || !current) return void res.writeHead(503, { 'Content-Type': 'text/plain; charset=utf-8' }).end('the viewer app or the document\'s code could not be built; the server\'s log says why')
536
+ const doc = await documentOf(dir)
537
+ const shown = lang ? translationOf(dir, lang)?.doc : doc
538
+ if (!shown) return plainPage(res, 'missing')
539
+ if (!await currentDocumentBundle(dir, doc)) return void res.writeHead(503, { 'Content-Type': 'text/plain; charset=utf-8' }).end('the compiled document code is not current')
540
+ if (block !== undefined && !shown.sections.some(section => [...irBlocks(section.blocks)].some(node => node.id === block))) return void res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }).end('the compiled block does not exist')
541
+ const pages = lang ? translationPages(published(dir, shown, lang).doc, webPages(published(dir, doc).doc)) : webPages(published(dir, doc).doc)
542
+ const page = rest === undefined ? undefined : pageAt(pages, rest)
543
+ if (rest !== undefined && !page) return plainPage(res, 'missing')
544
+ const state = stateOf(dir)
545
+ let config = documentConfig(dir, doc, lang, form === 'slides' ? undefined : page, state.bundle, rest === undefined, block === undefined ? form : 'web')
546
+ if (landing) config = landingConfig(config, shown, options.site(), base, lang)
547
+ if (block !== undefined) {
548
+ config.block = block
549
+ config.ir += `&presmium-block=${encodeURIComponent(block)}`
550
+ delete config.events; delete config.source; delete config.search; delete config.serviceWorker
551
+ }
552
+ html(res, 200, config, page && page.title !== shown.meta.title ? `${page.title} | ${shown.meta.title}` : shown.meta.title)
553
+ }
554
+
555
+ const formOf = (url: URL): Form => (url.searchParams.get('form') === 'slides' ? 'slides' : url.searchParams.get('form') === 'web' ? 'web' : options.form ?? 'web')
556
+
557
+ const irRoute = async (req: IncomingMessage, res: ServerResponse, url: URL) => {
558
+ const dir = url.searchParams.get('document') ?? ''
559
+ const lang = url.searchParams.get('lang') ?? ''
560
+ if (!documents().includes(dir)) return void res.writeHead(404).end()
561
+ const doc = await documentOf(dir)
562
+ const shown = lang ? translationOf(dir, lang)?.doc : doc
563
+ if (!shown) return void res.writeHead(404).end()
564
+ const block = url.searchParams.get('presmium-block')
565
+ if (block !== null && !shown.sections.some(section => [...irBlocks(section.blocks)].some(node => node.id === block))) return void res.writeHead(404).end()
566
+ const answer = published(dir, shown, lang, formOf(url), block !== null)
567
+ const etag = `"${answer.revision}"`
568
+ const headers = { 'ETag': etag, [REVISION_HEADER]: answer.revision, 'Cache-Control': 'no-store' }
569
+ if (req.headers['if-none-match'] === etag) return void res.writeHead(304, headers).end()
570
+ res.writeHead(200, { ...headers, 'Content-Type': 'application/json; charset=utf-8' })
571
+ res.end(answer.json)
572
+ }
573
+
574
+ const searchRoute = async (res: ServerResponse, url: URL) => {
575
+ const dir = url.searchParams.get('document') ?? ''
576
+ const lang = url.searchParams.get('lang') ?? ''
577
+ if (!documents().includes(dir)) return void res.writeHead(404).end()
578
+ const doc = await documentOf(dir)
579
+ const shown = lang ? translationOf(dir, lang)?.doc : doc
580
+ if (!shown) return void res.writeHead(404).end()
581
+ const answer = published(dir, shown, lang)
582
+ const root = `${documentBase(base, dir)}${lang ? `${lang}/` : ''}`
583
+ const pages = lang ? translationPages(answer.doc, webPages(published(dir, doc).doc)) : webPages(answer.doc)
584
+ const site = options.site()
585
+ answer.search ??= searchIndex([...searchDocuments(answer.doc, root, pages), ...isSingle(site) ? [] : siteNeighbours(site, base, dir, docs()).search])
586
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
587
+ res.end(answer.search)
588
+ }
589
+
590
+ const eventsRoute = (req: IncomingMessage, res: ServerResponse, url: URL) => {
591
+ const dir = url.searchParams.get('document') ?? ''
592
+ if (!documents().includes(dir)) return void res.writeHead(404).end()
593
+ const listener = { res, app: url.searchParams.get('app') ?? '', lang: url.searchParams.get('lang') ?? '', form: formOf(url) }
594
+ const state = stateOf(dir)
595
+ res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-store', 'X-Accel-Buffering': 'no' })
596
+ res.write('retry: 1000\n\n')
597
+ // A page of an app whose code has since changed reloads to the current one.
598
+ if (outdated.has(listener.app)) res.write('event: reload\ndata: {}\n\n')
599
+ state.listeners.add(listener)
600
+ const keepalive = setInterval(() => res.write(': keepalive\n\n'), KEEPALIVE_MS)
601
+ req.on('close', () => {
602
+ clearInterval(keepalive)
603
+ state.listeners.delete(listener)
604
+ })
605
+ }
606
+
607
+ const sourceRoute = (req: IncomingMessage, res: ServerResponse, url: URL) => {
608
+ if (req.method !== 'POST' || req.headers['presmium-request'] !== 'source' || !options.openSource) return void res.writeHead(405).end()
609
+ const file = url.searchParams.get('file')
610
+ const line = Number(url.searchParams.get('line'))
611
+ if (file) options.openSource(file, Number.isInteger(line) && line > 0 ? line : 1)
612
+ res.writeHead(file ? 204 : 400).end()
613
+ }
614
+
615
+ /** The app's own routes, below `<base>__app/`. */
616
+ const appRoute = async (req: IncomingMessage, res: ServerResponse, url: URL, rest: string) => {
617
+ if (rest === 'view') {
618
+ const dir = url.searchParams.get('document') ?? ''
619
+ return documents().includes(dir) ? documentPageResponse(res, dir, '', undefined, formOf(url), url.searchParams.get('presmium-block') ?? undefined) : plainPage(res, 'missing')
620
+ }
621
+ if (rest === 'ir') return irRoute(req, res, url)
622
+ if (rest === 'search') return searchRoute(res, url)
623
+ if (rest === 'events') return eventsRoute(req, res, url)
624
+ if (rest === 'source') return sourceRoute(req, res, url)
625
+ if (rest.startsWith('site-assets/')) return serveFile(req, res, join(options.workDir, 'site-assets'), `/${rest.slice('site-assets/'.length)}`, false)
626
+ const [kind, ...parts] = rest.split('/')
627
+ const below = (n: number) => `/${parts.slice(n).join('/')}`
628
+ if (kind === 'v' && parts.length >= 2) return serveFile(req, res, join(options.cacheDir, parts[0]!), below(1), true)
629
+ if (kind === 'd' && parts.length >= 3) return serveFile(req, res, join(options.workDir, 'bundles', parts[0]!, parts[1]!), below(2), true)
630
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }).end('not found')
631
+ }
632
+
633
+ /** A path below a document's site path: a page (its own, or a translation's), a published or run-time file, or its `public/`. */
634
+ const documentRoute = async (req: IncomingMessage, res: ServerResponse, url: URL, dir: string, rest: string) => {
635
+ const doc = await documentOf(dir)
636
+ const own = options.context(dir, doc)
637
+ const [first, ...others] = rest.split('/')
638
+ if (doc.config.renderers.slides !== undefined) {
639
+ const lang = first && others.length > 0 && translationOf(dir, first) ? first : ''
640
+ const path = lang ? others.join('/') : rest
641
+ if (path === 'slides' || path === 'slides/' || path === 'slides/index.html') return documentPageResponse(res, dir, lang, '', 'slides', url.searchParams.get('presmium-block') ?? undefined)
642
+ }
643
+ if (formOf(url) === 'slides' && (rest === '' || rest === 'index.html')) return documentPageResponse(res, dir, '', undefined, 'slides', url.searchParams.get('presmium-block') ?? undefined)
644
+ if (first && others.length > 0 && translationOf(dir, first)) return documentPageResponse(res, dir, first, others.join('/'), formOf(url), url.searchParams.get('presmium-block') ?? undefined)
645
+ if (rest === '' || rest.endsWith('.html')) {
646
+ const shown = published(dir, doc).doc
647
+ if (pageAt(webPages(shown), rest)) return documentPageResponse(res, dir, '', rest, formOf(url), url.searchParams.get('presmium-block') ?? undefined)
648
+ const chapter = chapterRedirects(shown).find(redirect => `${redirect.path}.html` === rest)
649
+ if (chapter) {
650
+ res.writeHead(302, { Location: `./#${encodeURIComponent(chapter.section)}` })
651
+ return void res.end()
652
+ }
653
+ }
654
+ if (rest.startsWith('media/')) return serveFile(req, res, join(options.workDir, 'media', segment(dir)), `/${rest.split('/').map(encodeURIComponent).join('/')}`, false)
655
+ const state = stateOf(dir)
656
+ state.assets ??= runtimeAssets(options.nodePlugins(dir), own)
657
+ const asset = findRuntimeAsset(await state.assets, rest)
658
+ if (asset) {
659
+ res.writeHead(200, { 'Content-Type': asset.type })
660
+ return void createReadStream(asset.file).pipe(res)
661
+ }
662
+ const file = join(own.deckDir, 'public', rest)
663
+ if (rest && existsSync(file) && statSync(file).isFile()) return serveFile(req, res, join(own.deckDir, 'public'), `/${rest.split('/').map(encodeURIComponent).join('/')}`, false)
664
+ return plainPage(res, 'missing')
665
+ }
666
+
667
+ /** The workspace's own pages: its home page (the list, when its home page is a document), in its languages. */
668
+ const siteRoute = async (res: ServerResponse, rest: string) => {
669
+ const site = options.site()
670
+ const target = homeDocument(site)
671
+ const landing = landingDocument(site)
672
+ const [first, ...others] = rest.split('/')
673
+ const lang = first && others.length <= 1 && siteLanguages(site, siteLanguage(site, ctx.lang)).some(other => other.lang === first) ? first : undefined
674
+ const own = lang ? others.join('/') : rest
675
+ if (own === '' || own === 'index.html') {
676
+ if (landing) {
677
+ if (lang && !translationOf(landing.dir, lang)) {
678
+ res.writeHead(302, { Location: base, 'Cache-Control': 'no-store' })
679
+ return void res.end()
680
+ }
681
+ return documentPageResponse(res, landing.dir, lang ?? '', '', 'web', undefined, true)
682
+ }
683
+ if (target) {
684
+ res.writeHead(302, { 'Location': `${documentBase(base, target.dir)}${lang ? `${lang}/` : ''}`, 'Cache-Control': 'no-store' })
685
+ return void res.end()
686
+ }
687
+ return plainPage(res, 'home', lang, site.config.landing !== undefined)
688
+ }
689
+ if ((target || site.config.landing !== undefined) && own === `${LIST_PAGE}.html`) return plainPage(res, 'home', lang)
690
+ return plainPage(res, 'missing')
691
+ }
692
+
693
+ const route = async (req: IncomingMessage, res: ServerResponse) => {
694
+ const url = new URL(req.url ?? '/', 'http://site')
695
+ const path = url.pathname
696
+ if (!path.startsWith(base)) return plainPage(res, 'missing')
697
+ // The app's routes by the address as it is (their segments name directories as encoded); the documents' by their names.
698
+ const raw = path.slice(base.length)
699
+ if (raw.startsWith(PREFIX)) return appRoute(req, res, url, raw.slice(PREFIX.length))
700
+ if (raw.startsWith('_landing/')) {
701
+ const output = [...templateOutputs].find(dir => existsSync(join(dir, safeDecodeURIComponent(raw))))
702
+ if (output) return serveFile(req, res, output, `/${raw}`, false)
703
+ }
704
+ const rest = safeDecodeURIComponent(raw)
705
+ const listed = documents()
706
+ const dir = listed.filter(d => d === '' || rest === d || rest.startsWith(`${d}/`)).sort((a, b) => b.length - a.length)[0]
707
+ if (dir !== undefined && rest === dir && dir !== '') {
708
+ // A document's directory without its slash: its pages' relative links need it.
709
+ res.writeHead(301, { Location: `${path}/` })
710
+ return void res.end()
711
+ }
712
+ if (dir === undefined) {
713
+ const publicRoot = join(ctx.workspaceRoot, 'public'), file = join(publicRoot, rest)
714
+ if (rest && existsSync(file) && statSync(file).isFile() && isInside(realpathSync(file), realpathSync(publicRoot))) return serveFile(req, res, publicRoot, `/${raw}`, false)
715
+ return siteRoute(res, rest)
716
+ }
717
+ return documentRoute(req, res, url, dir, dir ? rest.slice(dir.length + 1) : rest)
718
+ }
719
+
720
+ // `presmium dev`: the Presmium server's routes below `/__presmium/`, WebSockets included, as the page's own.
721
+ const agent = new Agent({ keepAlive: true, maxSockets: 64 })
722
+ const proxy = ctx.server ? createProxyServer({ ws: true, xfwd: false, agent, target: ctx.server.url }) : undefined
723
+ proxy?.on('error', (error: Error, _req: unknown, res: unknown) => {
724
+ const response = res as ServerResponse | Duplex | undefined
725
+ if (response && 'writeHead' in response && !response.headersSent) response.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' }).end(`the Presmium server did not answer: ${error.message}`)
726
+ else response?.destroy?.()
727
+ })
728
+
729
+ mkdirSync(options.workDir, { recursive: true })
730
+ const server = createServer((req, res) => {
731
+ const refused = localRequestRefusal(req.headers)
732
+ if (refused) return void res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' }).end(refused)
733
+ if (proxy && req.url?.startsWith(SERVER_PROXY)) return void proxy.web(req, res)
734
+ if (req.method !== 'GET' && req.method !== 'HEAD' && !req.url?.startsWith(`${base}${PREFIX}source`)) return void res.writeHead(405).end()
735
+ route(req, res).catch((error: unknown) => {
736
+ if (res.headersSent) return res.destroy()
737
+ res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' }).end(`the site could not answer: ${errorMessage(error)}`)
738
+ })
739
+ })
740
+ server.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer) => {
741
+ if (!proxy || localRequestRefusal(req.headers) || !req.url?.startsWith(SERVER_PROXY)) return socket.destroy()
742
+ proxy.ws(req, socket, head)
743
+ })
744
+ const port = await new Promise<number>((resolve, reject) => {
745
+ server.once('error', reject)
746
+ server.listen(options.listen.port, options.listen.host, () => resolve((server.address() as AddressInfo).port))
747
+ })
748
+ const origin = `http://${options.listen.host === '0.0.0.0' || options.listen.host === '::' ? '127.0.0.1' : options.listen.host}:${port}`
749
+
750
+ return {
751
+ url: `${origin}${base}`,
752
+ origin,
753
+ update(dir, doc) {
754
+ if (closed) return
755
+ latest.set(dir, doc)
756
+ const state = states.get(dir)
757
+ if (!state) return
758
+ const previous = new Map(state.published)
759
+ // The plugins' run-time files follow their configuration of the document.
760
+ state.assets = undefined
761
+ for (const key of [...state.published.keys()].filter(key => !key.endsWith(':'))) state.published.delete(key)
762
+ // Compare each language's own snapshot: a translation may change while the original stays identical.
763
+ const changes = new Map<string, DevUpdateEvent | undefined>()
764
+ for (const listener of state.listeners) {
765
+ const { form, lang } = listener
766
+ const key = `${form}:${lang}`
767
+ if (!changes.has(key)) {
768
+ const shown = lang ? translationOf(dir, lang)?.doc : doc
769
+ const before = previous.get(key)
770
+ const after = shown && published(dir, shown, lang, form)
771
+ const update = !lang && before && after ? diffDocuments(before.doc, after.doc) : undefined
772
+ changes.set(key, after && after.revision !== before?.revision ? { revision: after.revision, ...update && before ? { base: before.revision, update } : {} } : undefined)
773
+ }
774
+ const change = changes.get(key)
775
+ if (change) listener.res.write(`event: ${UPDATE_EVENT}\ndata: ${JSON.stringify(change)}\n\n`)
776
+ }
777
+ },
778
+ async app(dir, wait = false) {
779
+ if (closed || !documents().includes(dir)) return undefined
780
+ const started = Date.now()
781
+ const { ready, done } = await prepare(dir)
782
+ const cached = ready()
783
+ if (wait && !ready()) {
784
+ let timer: ReturnType<typeof setTimeout> | undefined
785
+ await Promise.race([done, new Promise(resolve => timer = setTimeout(resolve, WAIT_MS))])
786
+ clearTimeout(timer)
787
+ }
788
+ const available = ready() && (!wait || await currentDocumentBundle(dir, await documentOf(dir)))
789
+ // A build still running when the wait ends is not a failure: the workbench asks again.
790
+ const preparing = !available && (building !== undefined || stateOf(dir).building !== undefined)
791
+ log(`[viewer] document=${JSON.stringify(dir || '.')} cache=${cached ? 'hit' : 'miss'} wait=${wait} state=${available ? 'ready' : preparing || !wait ? 'building' : 'failed'} elapsedMs=${Date.now() - started}`)
792
+ if (wait && preparing) throw new PreviewPreparing()
793
+ if (wait && !available) throw new Error(stateOf(dir).bundleFailure ?? appFailure ?? 'the viewer app or the current document code could not be built; the server\'s log says why')
794
+ return available ? `${base}${PREFIX}view?document=${encodeURIComponent(dir)}` : undefined
795
+ },
796
+ async warm(dir) {
797
+ if (closed || !documents().includes(dir)) return
798
+ await (await prepare(dir)).done
799
+ },
800
+ async close() {
801
+ closed = true
802
+ worker.close()
803
+ for (const state of states.values()) {
804
+ state.watcher?.close()
805
+ clearTimeout(state.timer)
806
+ for (const { res } of state.listeners) res.end()
807
+ state.listeners.clear()
808
+ }
809
+ proxy?.close()
810
+ agent.destroy()
811
+ await new Promise<void>((done) => {
812
+ server.close(() => done())
813
+ server.closeAllConnections()
814
+ })
815
+ },
816
+ }
817
+ }
818
+
819
+ /** Whether a path of the site is the viewer app's own. */
820
+ export function isAppPath(base: string, path: string): boolean {
821
+ return path.startsWith(`${base}${PREFIX}`)
822
+ }