@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.
- package/README.md +33 -4
- package/package.json +20 -19
- package/src/app/base.css +117 -0
- package/src/app/block-playback.ts +81 -0
- package/src/app/block-report.ts +104 -0
- package/src/app/document-modules.ts +30 -0
- package/src/app/env.d.ts +12 -0
- package/src/app/main.ts +198 -0
- package/src/app/page-app.ts +166 -0
- package/src/app/router.ts +109 -0
- package/src/app/server.ts +32 -0
- package/src/app/site/home.tsx +117 -0
- package/src/app/site/layout.tsx +92 -0
- package/src/app/site/search.tsx +22 -0
- package/src/app/site.css +1450 -0
- package/src/client/document.css +172 -0
- package/src/client/host.ts +22 -94
- package/src/client/preview-frame.ts +152 -0
- package/src/client/runtime.ts +159 -0
- package/src/domain/app-config.ts +91 -0
- package/src/domain/config.ts +17 -9
- package/src/domain/dev-updates.ts +12 -0
- package/src/domain/index.ts +17 -7
- package/src/domain/landing.ts +83 -0
- package/src/domain/links.ts +23 -18
- package/src/domain/messages.ts +74 -63
- package/src/domain/pages.ts +23 -41
- package/src/domain/themes.ts +59 -0
- package/src/domain/workspace-home.ts +12 -0
- package/src/node/client-modules.ts +92 -0
- package/src/node/document-bundle.ts +227 -0
- package/src/node/landing-template.ts +34 -0
- package/src/node/landing.ts +82 -0
- package/src/node/offline.ts +34 -11
- package/src/node/publish.ts +83 -15
- package/src/node/renderer.ts +78 -117
- package/src/node/site-assets.ts +17 -0
- package/src/node/site-build.ts +304 -0
- package/src/node/site-files.ts +87 -0
- package/src/node/site-model.ts +337 -0
- package/src/node/site-page.ts +82 -0
- package/src/node/site-server.ts +822 -0
- package/src/node/slides.ts +135 -0
- package/src/node/viewer-app.ts +504 -0
- package/src/node/viewer-build.ts +38 -0
- package/custom-nav-controls.vue +0 -17
- package/src/client/env.d.ts +0 -1
- package/src/client/home.ts +0 -182
- package/src/client/index.ts +0 -4
- package/src/client/page.ts +0 -38
- package/src/client/preview.ts +0 -71
- package/src/client/style.css +0 -427
- package/src/client/theme.ts +0 -322
- package/src/node/addons.ts +0 -76
- package/src/node/site.ts +0 -349
- package/src/node/sites.ts +0 -531
- package/src/node/vite.ts +0 -166
- 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
|
+
}
|