@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,135 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { createServer } from 'node:http'
3
+ import type { AddressInfo } from 'node:net'
4
+ import { join } from 'node:path'
5
+ import { PREVIEW_APPEARANCE_PARAM, slideShape } from '@lincyaw/kernel'
6
+ import type { IRDocument, Renderer, SlideShape } from '@lincyaw/kernel'
7
+ import send from 'send'
8
+ import { PRINTED_ATTRIBUTE, printQuery } from '../domain/index.ts'
9
+ import type { SlidesPrintQuery } from '../domain/index.ts'
10
+ import { devDocument } from './renderer.ts'
11
+ import { buildSlidesSite } from './site-build.ts'
12
+
13
+ /**
14
+ * The slides form: the viewer app's slides page (its presentation, one slide at a time on a canvas of a fixed size), built
15
+ * as the web form is, and served by the same dev server. The exports print it: the page lays every slide out at the
16
+ * canvas's size (`?print`), and Chromium prints it to a PDF or takes a picture of each slide.
17
+ */
18
+
19
+ const BUILD_DIR = '.presmium/slides/build'
20
+ const PRINT_TIMEOUT = 120_000
21
+ // The pictures are taken at twice the canvas's size, sharp on a projector.
22
+ const PICTURE_SCALE = 2
23
+
24
+ export const slidesRenderer: Renderer = {
25
+ id: 'slides',
26
+ site: true,
27
+ support: () => 'full',
28
+ async build(doc, ctx) {
29
+ await buildSlidesSite(doc, { ...ctx, workDir: join(ctx.deckDir, BUILD_DIR) }, ctx.nodeVitePlugins ?? [])
30
+ return { outDir: ctx.outDir, skipped: [] }
31
+ },
32
+ dev: (doc, ctx) => devDocument(doc, ctx, 'slides'),
33
+ }
34
+
35
+ interface PrintingPage {
36
+ goto(url: string, options: { waitUntil: 'load', timeout: number }): Promise<unknown>
37
+ waitForSelector(selector: string, options: { timeout: number }): Promise<unknown>
38
+ pdf(options: { path: string, width: string, height: string, printBackground: boolean }): Promise<unknown>
39
+ $$(selector: string): Promise<{ screenshot(options: { path: string }): Promise<unknown> }[]>
40
+ evaluate<T>(run: () => T): Promise<T>
41
+ }
42
+
43
+ /** What printing uses of Playwright's Chromium (`playwright-chromium`), which the caller loads. */
44
+ export interface PrintingChromium {
45
+ launch(): Promise<{
46
+ newPage(options: { viewport: SlideShape, deviceScaleFactor: number, reducedMotion: 'reduce', serviceWorkers: 'block' }): Promise<PrintingPage>
47
+ close(): Promise<void>
48
+ }>
49
+ }
50
+
51
+ export interface PrintOptions extends SlidesPrintQuery {
52
+ /** The slides as `slidesRenderer` built them, for the root path. */
53
+ dir: string
54
+ chromium: PrintingChromium
55
+ /** One PDF file, or a picture of each page in a directory. */
56
+ format: 'pdf' | 'png'
57
+ /** The PDF file, or the directory of the pictures. */
58
+ output: string
59
+ dark?: boolean
60
+ /** How long the page may take to be ready, in milliseconds. */
61
+ timeout?: number
62
+ signal?: AbortSignal
63
+ }
64
+
65
+ /** A printed page: a slide (by number from 1) at a step, and its picture (`<slide>.png`, `<slide>-<step + 1>.png` with `steps`). */
66
+ export interface PrintedPage {
67
+ section: string
68
+ slide: number
69
+ step: number
70
+ file?: string
71
+ }
72
+
73
+ export interface Printed {
74
+ shape: SlideShape
75
+ pages: PrintedPage[]
76
+ }
77
+
78
+ /** Serves the built slides on a loopback port, through `send` (Express's): types and validators as HTTP defines them. */
79
+ async function serveBuilt(dir: string): Promise<{ url: string, close(): Promise<void> }> {
80
+ const server = createServer((req, res) => {
81
+ send(req, new URL(req.url ?? '/', 'http://localhost').pathname, { root: dir, dotfiles: 'allow' })
82
+ .on('error', (error: { status?: number }) => res.writeHead(error.status ?? 500).end())
83
+ .pipe(res)
84
+ })
85
+ await new Promise<void>(done => server.listen(0, '127.0.0.1', done))
86
+ return {
87
+ url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
88
+ close: () => new Promise((done) => {
89
+ server.close(() => done())
90
+ server.closeAllConnections()
91
+ }),
92
+ }
93
+ }
94
+
95
+ /** Prints the built slides: every slide (or `slides`) at its last step, or each of its steps with `steps`. */
96
+ export async function printSlides(options: PrintOptions): Promise<Printed> {
97
+ const shape = slideShape(JSON.parse(await readFile(join(options.dir, 'ir.json'), 'utf8')) as IRDocument)
98
+ const timeout = options.timeout ?? PRINT_TIMEOUT
99
+ const server = await serveBuilt(options.dir)
100
+ try {
101
+ const browser = await options.chromium.launch()
102
+ const stop = () => void browser.close()
103
+ options.signal?.addEventListener('abort', stop, { once: true })
104
+ try {
105
+ options.signal?.throwIfAborted()
106
+ const page = await browser.newPage({ viewport: shape, deviceScaleFactor: options.format === 'png' ? PICTURE_SCALE : 1, reducedMotion: 'reduce', serviceWorkers: 'block' })
107
+ const query = `${printQuery(options)}&${PREVIEW_APPEARANCE_PARAM}=${options.dark ? 'dark' : 'light'}`
108
+ await page.goto(`${server.url}/${query}`, { waitUntil: 'load', timeout })
109
+ await page.waitForSelector(`html[${PRINTED_ATTRIBUTE}]`, { timeout })
110
+ options.signal?.throwIfAborted()
111
+ const pages: PrintedPage[] = await page.evaluate(() => [...document.querySelectorAll<HTMLElement>('.pm-slides-print__page > .pm-slide')]
112
+ .map(slide => ({ section: slide.dataset.section!, slide: Number(slide.dataset.slide), step: Number(slide.dataset.step) })))
113
+ if (options.format === 'pdf') {
114
+ await page.pdf({ path: options.output, width: `${shape.width}px`, height: `${shape.height}px`, printBackground: true })
115
+ return { shape, pages }
116
+ }
117
+ const elements = await page.$$('.pm-slides-print__page')
118
+ for (const [i, element] of elements.entries()) {
119
+ options.signal?.throwIfAborted()
120
+ const printed = pages[i]!
121
+ printed.file = `${printed.slide}${options.steps ? `-${printed.step + 1}` : ''}.png`
122
+ await element.screenshot({ path: join(options.output, printed.file) })
123
+ }
124
+ return { shape, pages }
125
+ } catch (error) {
126
+ options.signal?.throwIfAborted()
127
+ throw error
128
+ } finally {
129
+ options.signal?.removeEventListener('abort', stop)
130
+ await browser.close()
131
+ }
132
+ } finally {
133
+ await server.close()
134
+ }
135
+ }
@@ -0,0 +1,504 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs'
3
+ import { createRequire } from 'node:module'
4
+ import { homedir } from 'node:os'
5
+ import { dirname, extname, join, relative, sep } from 'node:path'
6
+ import { fileURLToPath, pathToFileURL } from 'node:url'
7
+ import { isRecord } from '@lincyaw/kernel'
8
+ import { findInstalledPackage, FONT_LICENSE, fontLicense } from '@lincyaw/kernel/node'
9
+ import type { PresmiumNodePlugin } from '@lincyaw/kernel'
10
+ import { collectBrowserModules, emptyClientModules } from './client-modules.ts'
11
+
12
+ /**
13
+ * The viewer app, prebuilt once for a set of plugin packages (as a PDF viewer is for every PDF): the web form's runtime
14
+ * with every browser module of those plugins, lazily loaded, and the modules a document's own bundle may import (React,
15
+ * Presmium's packages, the plugins' public subpaths) as entries of their own, which an import map names. It shows every
16
+ * page of the web form: in the workbench, in a dev server and in a built site, whose pages it also renders on the
17
+ * server first (`buildViewerServer`). A build is kept under the cache by a key of what it was built from, and found
18
+ * again by any later session.
19
+ */
20
+
21
+ const WEB_DIR = fileURLToPath(new URL('../..', import.meta.url))
22
+ const APP_MAIN = fileURLToPath(new URL('../app/main.ts', import.meta.url))
23
+ const APP_SERVER = fileURLToPath(new URL('../app/server.ts', import.meta.url))
24
+ const DOCUMENT_MODULES = fileURLToPath(new URL('../app/document-modules.ts', import.meta.url))
25
+ const REGISTRY = 'virtual:presmium-app/registry'
26
+ const DOCUMENT_MODULE = '\0presmium-document-module:'
27
+ export const APP_MANIFEST = 'manifest.json'
28
+ // Vite's own manifest of the build: the entry's file and its styles and imports.
29
+ const BUILD_MANIFEST = '.vite/manifest.json'
30
+ const SERVER_DIR = 'server'
31
+ const SERVER_ENTRY = 'server.mjs'
32
+ const NOT_SOURCE = new Set(['node_modules', 'test', 'dist', '.presmium', '.git'])
33
+ const OWN_PACKAGES = ['@lincyaw/host', '@lincyaw/kernel', '@lincyaw/viewer', '@lincyaw/design']
34
+ // The subpaths of Presmium's packages that run in the browser (`./node` of the kernel does not).
35
+ const OWN_SHARED: Record<string, string[]> = {
36
+ '@lincyaw/host': ['.'],
37
+ '@lincyaw/kernel': ['.', './contracts', './ir', './reading'],
38
+ '@lincyaw/viewer': ['.', './domain'],
39
+ '@lincyaw/design': ['.'],
40
+ }
41
+ // A plugin package's exports that are not for a document's code (`architecture.md` section 3: the Node entry).
42
+ const NOT_SHARED = new Set(['./presmium', './package.json'])
43
+ // The files a bundler loads as modules, by their extension.
44
+ const MODULE_FILE = new Set(['.js', '.mjs', '.cjs', '.jsx', '.ts', '.mts', '.cts', '.tsx'])
45
+ const SHARED = 'virtual:presmium-app/shared:'
46
+ // Builds kept unused this long are removed, the newest few always kept.
47
+ const KEEP_MS = 7 * 24 * 3600_000
48
+ const KEEP_NEWEST = 3
49
+
50
+ /** What a build's plugin hooks may ask of the bundler (Rolldown's plugin context). */
51
+ interface BuildContext {
52
+ resolve(source: string, importer?: string): Promise<{ id: string, external?: boolean | 'absolute' | 'relative' } | null>
53
+ load(options: { id: string, resolveDependencies?: boolean }): Promise<{ importedIds: string[], exports: string[] }>
54
+ }
55
+
56
+ interface PluginViteConfig { plugins?: unknown[] }
57
+
58
+ /** A plugin package as the viewer app holds it: its browser modules, its public subpaths, its document modules. */
59
+ export interface AppPlugin {
60
+ name: string
61
+ /** Its package's own name, which reports name it by. */
62
+ packageName: string
63
+ dir: string
64
+ fingerprint: string
65
+ components: Record<string, string>
66
+ plugins: string[]
67
+ viewers: string[]
68
+ /** Its `vite.config.ts`, whose Vite plugins the app is built with, as the dev servers are. */
69
+ vite: string[]
70
+ restartable: boolean
71
+ /** Its exports that a document's own code may import, by specifier, with their files. */
72
+ shared: Record<string, string>
73
+ documentModules: string[]
74
+ /** Whether the viewer app can show its documents: the Vite plugins of its Node entry, if any, only give document modules. */
75
+ supported: boolean
76
+ }
77
+
78
+ export interface ViewerAppManifest {
79
+ key: string
80
+ core: string
81
+ plugins: Record<string, string>
82
+ /** The import map's entries: specifier to the file below the app's directory. */
83
+ shared: Record<string, string>
84
+ documentModules: string[]
85
+ builtAt: string
86
+ }
87
+
88
+ export interface ViewerApp {
89
+ dir: string
90
+ manifest: ViewerAppManifest
91
+ }
92
+
93
+ /** What a page loads to run the app: its script, with the styles and the modules it imports, below the app's directory. */
94
+ export interface AppEntry {
95
+ script: string
96
+ css: string[]
97
+ preload: string[]
98
+ }
99
+
100
+ interface BuildManifestChunk {
101
+ file: string
102
+ isEntry?: boolean
103
+ css?: string[]
104
+ imports?: string[]
105
+ }
106
+
107
+ /** The app's entry as Vite's build manifest gives it: its file, its styles and the chunks it imports at once. */
108
+ export function appEntry(app: ViewerApp): AppEntry {
109
+ const chunks = JSON.parse(readFileSync(join(app.dir, BUILD_MANIFEST), 'utf8')) as Record<string, BuildManifestChunk>
110
+ const main = Object.entries(chunks).find(([source, chunk]) => chunk.isEntry && source.endsWith('main.ts'))?.[1]
111
+ if (!main) throw new Error(`the viewer app at ${app.dir} has no entry in its build manifest`)
112
+ const preload = new Set<string>()
113
+ const css = new Set(main.css ?? [])
114
+ const walk = (keys: readonly string[] = []) => {
115
+ for (const key of keys) {
116
+ const chunk = chunks[key]
117
+ if (!chunk || preload.has(chunk.file)) continue
118
+ preload.add(chunk.file)
119
+ for (const file of chunk.css ?? []) css.add(file)
120
+ walk(chunk.imports)
121
+ }
122
+ }
123
+ walk(main.imports)
124
+ return { script: main.file, css: [...css], preload: [...preload] }
125
+ }
126
+
127
+ /** Where viewer apps are kept: the user's cache, shared by every workspace and session. */
128
+ export function viewerAppCache(env: NodeJS.ProcessEnv = process.env): string {
129
+ return join(env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'presmium', 'viewer-app')
130
+ }
131
+
132
+ export function readPackageManifest(dir: string): Record<string, unknown> {
133
+ try {
134
+ const value: unknown = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
135
+ return isRecord(value) ? value : {}
136
+ } catch {
137
+ return {}
138
+ }
139
+ }
140
+
141
+ /** What a directory of sources is now: its files' paths, sizes and times, or a package's name and version under node_modules. */
142
+ export function fingerprint(dir: string): string {
143
+ const real = realpathSync(dir)
144
+ const hash = createHash('sha256')
145
+ if (real.split(sep).includes('node_modules')) {
146
+ const { name, version } = readPackageManifest(real)
147
+ return hash.update(`${String(name)}@${String(version)}`).digest('hex').slice(0, 16)
148
+ }
149
+ const walk = (at: string) => {
150
+ for (const entry of readdirSync(at, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
151
+ if (NOT_SOURCE.has(entry.name)) continue
152
+ const path = join(at, entry.name)
153
+ if (entry.isDirectory()) walk(path)
154
+ else if (entry.isFile()) {
155
+ const { size, mtimeMs } = statSync(path)
156
+ hash.update(`${relative(real, path)}:${size}:${Math.trunc(mtimeMs)}\n`)
157
+ }
158
+ }
159
+ }
160
+ walk(real)
161
+ return hash.digest('hex').slice(0, 16)
162
+ }
163
+
164
+ /** Presmium's packages the app runs, where the web renderer finds them (the design system through the host). */
165
+ function ownPackages(): Map<string, string> {
166
+ const found = new Map<string, string>()
167
+ for (const name of OWN_PACKAGES) {
168
+ const dir = findInstalledPackage(name, WEB_DIR) ?? [...found.values()].map(from => findInstalledPackage(name, from)).find(Boolean)
169
+ if (!dir) throw new Error(`${name} is not installed beside @lincyaw/web`)
170
+ found.set(name, dir)
171
+ }
172
+ return found
173
+ }
174
+
175
+ /** The sources of the app itself and of Presmium's packages it runs, with the versions of the tools that build it. */
176
+ export function coreFingerprint(): string {
177
+ const require = createRequire(join(WEB_DIR, 'package.json'))
178
+ const versions = ['vite', 'react', 'react-dom'].map(name => `${name}@${String((require(`${name}/package.json`) as { version: string }).version)}`)
179
+ const dirs = [WEB_DIR, ...ownPackages().values()]
180
+ return createHash('sha256').update([...versions, ...dirs.map(fingerprint)].join('\n')).digest('hex').slice(0, 16)
181
+ }
182
+
183
+ function exportTarget(value: unknown): string | undefined {
184
+ if (typeof value === 'string') return value
185
+ if (!isRecord(value)) return undefined
186
+ return exportTarget(value.browser) ?? exportTarget(value.import) ?? exportTarget(value.default)
187
+ }
188
+
189
+ /** The node entry of a plugin package (its `./presmium` export), loaded. */
190
+ async function nodeEntry(dir: string, exports: Record<string, unknown>): Promise<PresmiumNodePlugin | undefined> {
191
+ const file = exportTarget(exports['./presmium'])
192
+ if (!file) return undefined
193
+ return (await import(pathToFileURL(join(dir, file)).href) as { default?: PresmiumNodePlugin }).default
194
+ }
195
+
196
+ /**
197
+ * The modules of a package's exports, by specifier, with their files: `subpaths` when given, else every one but the Node
198
+ * entry. Pattern subpaths (`./*`) name no module by themselves, and only module files are taken.
199
+ */
200
+ function sharedExports(name: string, dir: string, exports: Record<string, unknown>, subpaths?: string[]): Record<string, string> {
201
+ const shared: Record<string, string> = {}
202
+ for (const [subpath, value] of Object.entries(exports)) {
203
+ const target = exportTarget(value)
204
+ if (subpaths ? !subpaths.includes(subpath) : NOT_SHARED.has(subpath) || subpath.includes('*')) continue
205
+ if (!target || !MODULE_FILE.has(extname(target))) continue
206
+ shared[subpath === '.' ? name : `${name}/${subpath.slice(2)}`] = join(dir, target)
207
+ }
208
+ return shared
209
+ }
210
+
211
+ /** A plugin package's browser modules and public subpaths, and its node entry's document modules. */
212
+ export async function appPlugin(name: string, dir: string): Promise<AppPlugin> {
213
+ const manifest = readPackageManifest(dir)
214
+ const modules = emptyClientModules()
215
+ collectBrowserModules(dir, name, modules, true)
216
+ const exports = isRecord(manifest.exports) ? manifest.exports : {}
217
+ // A document names a plugin as its configuration does (a path, maybe); its code imports it by its package's name.
218
+ const shared = sharedExports(typeof manifest.name === 'string' ? manifest.name : name, dir, exports)
219
+ let documentModules: string[] = []
220
+ let supported = true
221
+ try {
222
+ const node = await nodeEntry(dir, exports)
223
+ documentModules = Object.keys(node?.documentModules?.({ deckDir: dir, workspaceRoot: dir, config: {} }) ?? {})
224
+ } catch {
225
+ supported = false
226
+ }
227
+ return {
228
+ name,
229
+ packageName: typeof manifest.name === 'string' ? manifest.name : name,
230
+ dir,
231
+ fingerprint: fingerprint(dir),
232
+ components: Object.fromEntries(modules.components),
233
+ plugins: modules.plugins,
234
+ viewers: modules.viewers.map(viewer => viewer.file),
235
+ vite: modules.vite,
236
+ restartable: modules.restartable,
237
+ shared,
238
+ documentModules,
239
+ supported,
240
+ }
241
+ }
242
+
243
+ const keyOf = (core: string, plugins: readonly AppPlugin[]) =>
244
+ createHash('sha256').update([core, ...plugins.map(p => `${p.name}:${p.fingerprint}`).sort()].join('\n')).digest('hex').slice(0, 16)
245
+
246
+ /** The newest app under `cacheDir` built with every one of `plugins` as they are now, if there is one. */
247
+ export function findViewerApp(cacheDir: string, core: string, plugins: readonly AppPlugin[]): ViewerApp | undefined {
248
+ const seed = process.env.PRESMIUM_VIEWER_SEED
249
+ const roots = [...new Set([cacheDir, ...seed ? [seed] : []])]
250
+ let found: ViewerApp | undefined
251
+ for (const root of roots) for (const entry of existsSync(root) ? readdirSync(root) : []) {
252
+ const file = join(root, entry, APP_MANIFEST)
253
+ if (entry.startsWith('.') || !existsSync(file)) continue
254
+ try {
255
+ const manifest = JSON.parse(readFileSync(file, 'utf8')) as ViewerAppManifest
256
+ if (manifest.core !== core || !plugins.every(p => manifest.plugins[p.name] === p.fingerprint)) continue
257
+ if (!found || manifest.builtAt > found.manifest.builtAt) found = { dir: join(root, entry), manifest }
258
+ } catch {}
259
+ }
260
+ // Images can ship a read-only build. Materialize it in this user's ordinary
261
+ // cache so every existing asset route and later source rebuild keeps working.
262
+ // A seed with changed core or plugin sources was rejected above.
263
+ if (found && dirname(found.dir) !== cacheDir) {
264
+ const target = join(cacheDir, found.manifest.key)
265
+ if (!/^[a-f0-9]{16}$/.test(found.manifest.key)) return undefined
266
+ if (!existsSync(join(target, APP_MANIFEST))) {
267
+ mkdirSync(cacheDir, { recursive: true })
268
+ const temporary = join(cacheDir, `.build-seed-${found.manifest.key}-${process.pid}-${Date.now()}`)
269
+ try {
270
+ cpSync(found.dir, temporary, { recursive: true })
271
+ try { renameSync(temporary, target) }
272
+ catch (error) { if (!existsSync(join(target, APP_MANIFEST))) throw error }
273
+ } finally { rmSync(temporary, { recursive: true, force: true }) }
274
+ }
275
+ found = { dir: target, manifest: found.manifest }
276
+ }
277
+ // Marked as used, so that the cleaning of another session keeps it.
278
+ if (found) {
279
+ const now = new Date()
280
+ try { utimesSync(join(found.dir, APP_MANIFEST), now, now) } catch {}
281
+ }
282
+ return found
283
+ }
284
+
285
+ /** Removes the apps no session used for a week, keeping the newest. */
286
+ function cleanViewerApps(cacheDir: string, keep: string): void {
287
+ const builds = readdirSync(cacheDir).flatMap((entry) => {
288
+ const dir = join(cacheDir, entry)
289
+ try {
290
+ return [{ dir, used: statSync(join(dir, APP_MANIFEST)).mtimeMs }]
291
+ } catch {
292
+ // A build another process left unfinished a day ago.
293
+ try {
294
+ return entry.startsWith('.build-') && Date.now() - statSync(dir).mtimeMs > 24 * 3600_000 ? [{ dir, used: 0 }] : []
295
+ } catch {
296
+ return []
297
+ }
298
+ }
299
+ }).sort((a, b) => b.used - a.used)
300
+ for (const [i, build] of builds.entries()) {
301
+ if (build.dir !== keep && (build.used === 0 || (i >= KEEP_NEWEST && Date.now() - build.used > KEEP_MS))) rmSync(build.dir, { recursive: true, force: true })
302
+ }
303
+ }
304
+
305
+ /** The browser modules of each plugin, lazily loaded; on the server, the components' names only (they render once mounted). */
306
+ function registrySource(plugins: readonly AppPlugin[], ssr = false): string {
307
+ const load = (file: string) => (ssr ? 'never' : `() => import(${JSON.stringify(file)})`)
308
+ const entries = plugins.map(p => ` ${JSON.stringify(p.name)}: {
309
+ name: ${JSON.stringify(p.packageName)},
310
+ components: {${Object.entries(p.components).map(([name, file]) => `\n ${JSON.stringify(name)}: ${load(file)},`).join('')}
311
+ },
312
+ plugins: [${ssr ? '' : p.plugins.map(load).join(', ')}],
313
+ viewers: [${ssr ? '' : p.viewers.map(load).join(', ')}],
314
+ restartable: ${p.restartable},
315
+ },`)
316
+ return `${ssr ? 'const never = () => new Promise(() => {})\n' : ''}export const packages = {\n${entries.join('\n')}\n}\n`
317
+ }
318
+
319
+ /** Leaves out the bundler's notes on "use client" (Remotion's modules start with it; it means nothing here). */
320
+ export function quietDirectives(_level: string, log: { code?: string, message?: string }): false | undefined {
321
+ return log.code === 'MODULE_LEVEL_DIRECTIVE' || /module level directive/i.test(log.message ?? '') ? false : undefined
322
+ }
323
+
324
+ const sharedName = (specifier: string) => `shared/${specifier.replace(/^@/, '').replace(/[^\w.-]+/g, '_')}`
325
+
326
+ /**
327
+ * The modules the app's import map names, by specifier, with their sources: React by its name, resolved as every other import
328
+ * of it; Presmium's packages and the plugins' public subpaths by their files. A document's bundle imports these by name.
329
+ */
330
+ export function sharedSpecifiers(plugins: readonly AppPlugin[]): Record<string, string> {
331
+ const specifiers: Record<string, string> = { react: 'react', 'react/jsx-runtime': 'react/jsx-runtime', 'react/jsx-dev-runtime': 'react/jsx-dev-runtime', 'react-dom': 'react-dom', 'react-dom/client': 'react-dom/client' }
332
+ for (const [name, dir] of ownPackages()) {
333
+ const { exports } = readPackageManifest(dir)
334
+ Object.assign(specifiers, sharedExports(name, dir, isRecord(exports) ? exports : {}, OWN_SHARED[name]))
335
+ }
336
+ for (const plugin of plugins) Object.assign(specifiers, plugin.shared)
337
+ return specifiers
338
+ }
339
+
340
+ /** Builds the viewer app for `plugins` into `cacheDir`, unless one is there already, and returns it. */
341
+ export async function buildViewerApp(cacheDir: string, plugins: readonly AppPlugin[]): Promise<ViewerApp> {
342
+ const core = coreFingerprint()
343
+ const known = findViewerApp(cacheDir, core, plugins)
344
+ if (known) return known
345
+ const key = keyOf(core, plugins)
346
+ const out = join(cacheDir, key)
347
+ const work = join(cacheDir, `.build-${key}-${process.pid}`)
348
+ rmSync(work, { recursive: true, force: true })
349
+ mkdirSync(work, { recursive: true })
350
+ // Generated shared entries resolve the same React packages as the web renderer.
351
+ mkdirSync(join(work, 'node_modules'), { recursive: true })
352
+ for (const name of ['react', 'react-dom']) {
353
+ const dir = findInstalledPackage(name, WEB_DIR)
354
+ if (!dir) throw new Error(`${name} is not installed beside @lincyaw/web`)
355
+ symlinkSync(dir, join(work, 'node_modules', name), 'junction')
356
+ }
357
+ writeFileSync(join(work, 'main.ts'), `import ${JSON.stringify(APP_MAIN)}\n`)
358
+ const specifiers = sharedSpecifiers(plugins)
359
+ const input: Record<string, string> = { main: join(work, 'main.ts') }
360
+ const shared: Record<string, string> = {}
361
+ for (const specifier of Object.keys(specifiers)) {
362
+ const name = sharedName(specifier)
363
+ input[name] = `${SHARED}${specifier}`
364
+ shared[specifier] = `${name}.js`
365
+ }
366
+ const documentModules = [...new Set(plugins.flatMap(p => p.documentModules))]
367
+ const viteConfigs = await Promise.all(plugins.flatMap(p => p.vite).map(async file => (await import(pathToFileURL(file).href) as { default?: PluginViteConfig }).default))
368
+ const importer = join(work, 'main.ts')
369
+ /**
370
+ * The module the import map names for `specifier`: all the exports of its source, as the bundler finds them (its
371
+ * default export too when it has one).
372
+ */
373
+ const sharedModule = async (ctx: BuildContext, specifier: string): Promise<string> => {
374
+ const resolved = await ctx.resolve(specifiers[specifier]!, importer)
375
+ if (!resolved || resolved.external) throw new Error(`${specifier} cannot be resolved for the viewer app`)
376
+ const source = JSON.stringify(resolved.id)
377
+ if (specifier === 'react' || specifier.startsWith('react/') || specifier === 'react-dom' || specifier.startsWith('react-dom/')) {
378
+ const require = createRequire(join(WEB_DIR, 'package.json'))
379
+ const names = Object.keys(require(specifier) as Record<string, unknown>).filter(name => /^[A-Za-z_$][\w$]*$/.test(name) && name !== 'default')
380
+ return `import runtime from ${source}\nexport const { ${names.join(', ')} } = runtime\nexport default runtime\n`
381
+ }
382
+ const { exports } = await ctx.load({ id: resolved.id })
383
+ return `export * from ${source}\n${exports.includes('default') ? `export { default } from ${source}\n` : ''}`
384
+ }
385
+ const { build } = await import('vite')
386
+ await build({
387
+ configFile: false,
388
+ root: work,
389
+ base: './',
390
+ logLevel: 'warn',
391
+ mode: 'production',
392
+ esbuild: { jsx: 'automatic' },
393
+ plugins: [
394
+ {
395
+ name: 'presmium:viewer-app',
396
+ enforce: 'pre' as const,
397
+ resolveId(id: string) {
398
+ if (id === REGISTRY) return `\0${REGISTRY}`
399
+ if (id.startsWith(SHARED)) return `\0${id}`
400
+ if (documentModules.includes(id)) return `${DOCUMENT_MODULE}${id}`
401
+ return undefined
402
+ },
403
+ onLog: quietDirectives,
404
+ async load(id: string) {
405
+ if (id.startsWith(`\0${SHARED}`)) return await sharedModule(this, id.slice(SHARED.length + 1))
406
+ if (id === `\0${REGISTRY}`) return registrySource(plugins)
407
+ if (id.startsWith(DOCUMENT_MODULE)) return `import { documentModule } from ${JSON.stringify(DOCUMENT_MODULES)}\nexport default documentModule(${JSON.stringify(id.slice(DOCUMENT_MODULE.length))})\n`
408
+ return undefined
409
+ },
410
+ },
411
+ ...viteConfigs.flatMap(config => config?.plugins ?? []) as never[],
412
+ ],
413
+ resolve: { dedupe: ['react', 'react-dom'] },
414
+ build: {
415
+ outDir: join(work, 'out'),
416
+ emptyOutDir: true,
417
+ target: 'es2022',
418
+ manifest: true,
419
+ reportCompressedSize: false,
420
+ chunkSizeWarningLimit: 100_000,
421
+ rollupOptions: {
422
+ input,
423
+ preserveEntrySignatures: 'exports-only',
424
+ output: {
425
+ entryFileNames: chunk => (chunk.name.startsWith('shared/') ? '[name].js' : 'assets/[name]-[hash].js'),
426
+ chunkFileNames: 'assets/[name]-[hash].js',
427
+ assetFileNames: 'assets/[name]-[hash][extname]',
428
+ },
429
+ },
430
+ },
431
+ })
432
+ const manifest: ViewerAppManifest = { key, core, plugins: Object.fromEntries(plugins.map(p => [p.name, p.fingerprint])), shared, documentModules, builtAt: new Date().toISOString() }
433
+ writeFileSync(join(work, 'out', APP_MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`)
434
+ // The license of the fonts the app's styles bring, beside them wherever the app goes.
435
+ mkdirSync(dirname(join(work, 'out', FONT_LICENSE)), { recursive: true })
436
+ writeFileSync(join(work, 'out', FONT_LICENSE), fontLicense(import.meta.url))
437
+ // Another session may have built the same app meanwhile: its build stays, since its pages may be loading from it.
438
+ if (!existsSync(join(out, APP_MANIFEST))) {
439
+ rmSync(out, { recursive: true, force: true })
440
+ renameSync(join(work, 'out'), out)
441
+ }
442
+ rmSync(work, { recursive: true, force: true })
443
+ cleanViewerApps(cacheDir, out)
444
+ return { dir: out, manifest: JSON.parse(readFileSync(join(out, APP_MANIFEST), 'utf8')) as ViewerAppManifest }
445
+ }
446
+
447
+ /**
448
+ * The app's server-side render (`app/server.ts`) for the same plugins, beside the app: a module that renders a built
449
+ * site's pages to HTML. The plugins' components are only names there, so nothing of theirs is bundled; built the first
450
+ * time a site is built with this app.
451
+ */
452
+ export async function buildViewerServer(app: ViewerApp, plugins: readonly AppPlugin[]): Promise<string> {
453
+ const file = join(app.dir, SERVER_DIR, SERVER_ENTRY)
454
+ if (existsSync(file)) return file
455
+ const work = join(app.dir, `.${SERVER_DIR}-${process.pid}`)
456
+ rmSync(work, { recursive: true, force: true })
457
+ mkdirSync(work, { recursive: true })
458
+ const documentModules = [...new Set(plugins.flatMap(p => p.documentModules))]
459
+ const viteConfigs = await Promise.all(plugins.flatMap(p => p.vite).map(async config => (await import(pathToFileURL(config).href) as { default?: PluginViteConfig }).default))
460
+ const { build } = await import('vite')
461
+ await build({
462
+ configFile: false,
463
+ root: WEB_DIR,
464
+ logLevel: 'warn',
465
+ mode: 'production',
466
+ esbuild: { jsx: 'automatic' },
467
+ plugins: [
468
+ {
469
+ name: 'presmium:viewer-server',
470
+ enforce: 'pre' as const,
471
+ resolveId(id: string) {
472
+ if (id === REGISTRY) return `\0${REGISTRY}`
473
+ if (documentModules.includes(id)) return `${DOCUMENT_MODULE}${id}`
474
+ return undefined
475
+ },
476
+ onLog: quietDirectives,
477
+ load(id: string) {
478
+ if (id === `\0${REGISTRY}`) return registrySource(plugins, true)
479
+ if (id.startsWith(DOCUMENT_MODULE)) return `import { documentModule } from ${JSON.stringify(DOCUMENT_MODULES)}\nexport default documentModule(${JSON.stringify(id.slice(DOCUMENT_MODULE.length))})\n`
480
+ return undefined
481
+ },
482
+ },
483
+ ...viteConfigs.flatMap(config => config?.plugins ?? []) as never[],
484
+ ],
485
+ resolve: { dedupe: ['react', 'react-dom'] },
486
+ ssr: { noExternal: true, target: 'node' },
487
+ build: {
488
+ ssr: APP_SERVER,
489
+ outDir: work,
490
+ emptyOutDir: true,
491
+ target: 'node22',
492
+ reportCompressedSize: false,
493
+ chunkSizeWarningLimit: 100_000,
494
+ rollupOptions: { output: { format: 'es', entryFileNames: SERVER_ENTRY, chunkFileNames: 'chunks/[name]-[hash].mjs' } },
495
+ },
496
+ })
497
+ // Another build of the same site may have written it meanwhile.
498
+ if (!existsSync(file)) {
499
+ rmSync(join(app.dir, SERVER_DIR), { recursive: true, force: true })
500
+ renameSync(work, join(app.dir, SERVER_DIR))
501
+ }
502
+ rmSync(work, { recursive: true, force: true })
503
+ return file
504
+ }
@@ -0,0 +1,38 @@
1
+ import { errorMessage } from '@lincyaw/kernel'
2
+ import { buildDocumentBundle } from './document-bundle.ts'
3
+ import type { DocumentBundleInput } from './document-bundle.ts'
4
+ import { buildViewerApp } from './viewer-app.ts'
5
+ import type { AppPlugin } from './viewer-app.ts'
6
+
7
+ /** The process that builds the viewer app or a document's bundle, apart from the workspace service, which stays responsive. */
8
+
9
+ // Its dependencies' deprecation notes are not the author's to act on.
10
+ process.noDeprecation = true
11
+
12
+ export type ViewerBuildRequest =
13
+ | { kind: 'app', cacheDir: string, plugins: AppPlugin[] }
14
+ | { kind: 'document', input: DocumentBundleInput }
15
+
16
+ // Kept for the next build a while, then gone (a build holds much memory).
17
+ const IDLE_MS = 30_000
18
+ let running = 0
19
+ let idle: ReturnType<typeof setTimeout> | undefined
20
+ const rest = () => {
21
+ clearTimeout(idle)
22
+ if (running === 0) idle = setTimeout(() => process.exit(0), IDLE_MS)
23
+ }
24
+
25
+ process.on('message', ({ id, ...request }: ViewerBuildRequest & { id: number }) => {
26
+ running++
27
+ clearTimeout(idle)
28
+ const building = request.kind === 'app' ? buildViewerApp(request.cacheDir, request.plugins) : buildDocumentBundle(request.input)
29
+ building.then(
30
+ result => process.send!({ id, ok: true, result }),
31
+ (error: unknown) => process.send!({ id, ok: false, error: errorMessage(error) }),
32
+ ).finally(() => {
33
+ running--
34
+ rest()
35
+ })
36
+ })
37
+ process.on('disconnect', () => process.exit(0))
38
+ rest()