@lincyaw/web 1.0.0-rc.13 → 1.0.0-rc.14
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/package.json +14 -12
- package/src/app/main.ts +3 -1
- package/src/app/server.ts +2 -0
- package/src/client/preview-connect.ts +124 -0
- package/src/client/preview-frame.ts +11 -122
- package/src/domain/landing.ts +2 -1
- package/src/domain/workspace-home.ts +2 -0
- package/src/node/document-bundle.ts +33 -4
- package/src/node/images.ts +104 -0
- package/src/node/landing-template.ts +7 -1
- package/src/node/landing.ts +2 -2
- package/src/node/publish.ts +14 -3
- package/src/node/site-build.ts +34 -38
- package/src/node/site-files.ts +1 -1
- package/src/node/site-model.ts +3 -2
- package/src/node/site-page.ts +23 -12
- package/src/node/site-server.ts +4 -4
- package/src/node/viewer-app.ts +38 -14
- package/src/site/index.ts +58 -0
- package/src/node/optimize.ts +0 -65
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lincyaw/web",
|
|
3
|
-
"version": "1.0.0-rc.
|
|
3
|
+
"version": "1.0.0-rc.14",
|
|
4
4
|
"description": "Presmium's web renderer: one prebuilt viewer that shows the web form in the workbench, in a dev server and as a built site, with search and form switching",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"exports": {
|
|
25
25
|
"./domain": "./src/domain/index.ts",
|
|
26
26
|
"./presmium": "./src/node/presmium.ts",
|
|
27
|
+
"./site": "./src/site/index.ts",
|
|
27
28
|
"./slides": "./src/node/slides.ts",
|
|
28
29
|
"./package.json": "./package.json"
|
|
29
30
|
},
|
|
@@ -36,27 +37,28 @@
|
|
|
36
37
|
"katex": "0.18.10",
|
|
37
38
|
"launch-editor": "2.14.1",
|
|
38
39
|
"minisearch": "7.2.0",
|
|
39
|
-
"
|
|
40
|
-
"sitemap": "9.0.1",
|
|
41
|
-
"vite": "8.3.4",
|
|
40
|
+
"p-limit": "7.3.3",
|
|
42
41
|
"react": "18.3.1",
|
|
43
42
|
"react-dom": "18.3.1",
|
|
43
|
+
"send": "1.2.1",
|
|
44
44
|
"sharp": "0.35.5",
|
|
45
|
-
"
|
|
45
|
+
"sitemap": "9.0.1",
|
|
46
|
+
"vite": "8.3.4",
|
|
47
|
+
"@lincyaw/design": "1.0.0-rc.14"
|
|
46
48
|
},
|
|
47
49
|
"peerDependencies": {
|
|
48
|
-
"@lincyaw/host": "^1.0.0-rc.
|
|
49
|
-
"@lincyaw/
|
|
50
|
-
"@lincyaw/
|
|
50
|
+
"@lincyaw/host": "^1.0.0-rc.14",
|
|
51
|
+
"@lincyaw/kernel": "^1.0.0-rc.14",
|
|
52
|
+
"@lincyaw/viewer": "^1.0.0-rc.14"
|
|
51
53
|
},
|
|
52
54
|
"devDependencies": {
|
|
53
55
|
"@types/escape-html": "1.0.4",
|
|
54
56
|
"@types/send": "^1.2.1",
|
|
55
57
|
"@types/react": "18.3.28",
|
|
56
58
|
"@types/react-dom": "18.3.7",
|
|
57
|
-
"@lincyaw/cli": "1.0.0-rc.
|
|
58
|
-
"@lincyaw/
|
|
59
|
-
"@lincyaw/viewer": "1.0.0-rc.
|
|
60
|
-
"@lincyaw/
|
|
59
|
+
"@lincyaw/cli": "1.0.0-rc.14",
|
|
60
|
+
"@lincyaw/host": "1.0.0-rc.14",
|
|
61
|
+
"@lincyaw/viewer": "1.0.0-rc.14",
|
|
62
|
+
"@lincyaw/kernel": "1.0.0-rc.14"
|
|
61
63
|
}
|
|
62
64
|
}
|
package/src/app/main.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { currentLocale, OPERATIONS, pluginsOf, presmiumService, registerPresmiumPlugin, startHost, createStore, observe, runtimeRevision } from '@lincyaw/host'
|
|
2
2
|
import { previewAppearance, reportStaleModule } from '@lincyaw/kernel'
|
|
3
3
|
import type { IRDocument } from '@lincyaw/kernel'
|
|
4
|
-
import { findView, syncViewerWithOperations } from '@lincyaw/viewer'
|
|
4
|
+
import { findView, loadDocumentParts, syncViewerWithOperations } from '@lincyaw/viewer'
|
|
5
5
|
import type { LiveUpdate } from '@lincyaw/viewer/domain'
|
|
6
6
|
import { packages } from 'virtual:presmium-app/registry'
|
|
7
7
|
import { APP_CONFIG_ELEMENT, parsePositionQuery, parsePrintQuery, UPDATE_EVENT, WEB_MESSAGES, withPositionQuery } from '../domain/index.ts'
|
|
@@ -96,6 +96,8 @@ async function bootDocument(): Promise<void> {
|
|
|
96
96
|
let { revision, etag } = first
|
|
97
97
|
setDocumentModules(bundle.documentModules ?? {})
|
|
98
98
|
const doc = first.doc
|
|
99
|
+
// What of the viewer this document needs beyond the app's first part, as the server rendered it with.
|
|
100
|
+
await loadDocumentParts(doc)
|
|
99
101
|
document.documentElement.lang = config.lang
|
|
100
102
|
if (config.page === undefined) document.title = doc.meta.title
|
|
101
103
|
|
package/src/app/server.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IRDocument } from '@lincyaw/kernel'
|
|
2
2
|
import { createStore } from '@lincyaw/host'
|
|
3
|
+
import { loadDocumentParts } from '@lincyaw/viewer'
|
|
3
4
|
import { renderToString } from 'react-dom/server'
|
|
4
5
|
import type { AppConfig } from '../domain/index.ts'
|
|
5
6
|
import { createPageApp } from './page-app.ts'
|
|
@@ -20,6 +21,7 @@ export interface RenderInput {
|
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export async function render({ config, doc, components }: RenderInput): Promise<string> {
|
|
24
|
+
if (doc) await loadDocumentParts(doc)
|
|
23
25
|
const { app } = createPageApp({
|
|
24
26
|
config,
|
|
25
27
|
doc,
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { componentErrors, componentErrorRevision, presmiumReport, presmiumService, PLAYBACK_CONTROL, PRODUCTION_PLAYBACK, createStore, runtimeRevision } from '@lincyaw/host'
|
|
2
|
+
import type { NavigationPosition, PlaybackControl } from '@lincyaw/host'
|
|
3
|
+
import { connectPreviewFrame, irBlocks, PREVIEW_REVISION, stepKeys, stepTrail } from '@lincyaw/kernel'
|
|
4
|
+
import type { FollowAnchor, PreviewFrame, PreviewEvents } from '@lincyaw/kernel'
|
|
5
|
+
import { findView, registryRevision } from '@lincyaw/viewer'
|
|
6
|
+
import { createViewerNavigation } from './host.ts'
|
|
7
|
+
import type { WebPreviewOptions, WebPreviewPage } from './preview-frame.ts'
|
|
8
|
+
|
|
9
|
+
/** A page the workbench embeds, connected to it as its preview (`webPreview`, which loads this only then). */
|
|
10
|
+
export function connectWebPreviewFrame(page: WebPreviewPage, options: WebPreviewOptions, events?: PreviewEvents): void {
|
|
11
|
+
const { viewer, dark } = page
|
|
12
|
+
const navigation = createViewerNavigation(viewer)
|
|
13
|
+
const pendingGo = createStore<Parameters<typeof navigation.go>[0] | undefined>(undefined)
|
|
14
|
+
// Preview ready can precede async viewer-plugin registration and the lecture list. Keep the latest requested view
|
|
15
|
+
// until it is available instead of losing a first switch to video while this window is still starting.
|
|
16
|
+
const applyPending = () => {
|
|
17
|
+
const destination = pendingGo.getSnapshot()
|
|
18
|
+
if (!destination) return
|
|
19
|
+
const view = destination.view && findView(destination.view)
|
|
20
|
+
if (destination.view && (!view || view.hidden)) return
|
|
21
|
+
pendingGo.set(undefined)
|
|
22
|
+
navigation.go(destination)
|
|
23
|
+
}
|
|
24
|
+
registryRevision.subscribe(applyPending)
|
|
25
|
+
pendingGo.subscribe(applyPending)
|
|
26
|
+
const revision = () => {
|
|
27
|
+
const value = viewer.doc.getSnapshot().meta[PREVIEW_REVISION]
|
|
28
|
+
return typeof value === 'string' ? value : undefined
|
|
29
|
+
}
|
|
30
|
+
const blockStep = (section: string, block: string) => {
|
|
31
|
+
const found = viewer.section(section)
|
|
32
|
+
return found ? [...irBlocks(found.blocks)].find(b => b.id === block)?.at : undefined
|
|
33
|
+
}
|
|
34
|
+
const frame: PreviewFrame | undefined = connectPreviewFrame({
|
|
35
|
+
renderer: options.renderer ?? 'web',
|
|
36
|
+
events,
|
|
37
|
+
...viewer.options.confirmedRevision ? { confirmPresentation: (revision: string) => { viewer.options.confirmedRevision!.set(revision); frame?.moved() } } : {},
|
|
38
|
+
document: options.document,
|
|
39
|
+
origins: options.origins,
|
|
40
|
+
revision,
|
|
41
|
+
position() {
|
|
42
|
+
if (viewer.options.confirmedRevision && viewer.options.confirmedRevision.getSnapshot() !== revision()) return
|
|
43
|
+
const { section, step, trail, view } = navigation.position.getSnapshot() as NavigationPosition & FollowAnchor
|
|
44
|
+
return section === undefined ? undefined : { section, step, trail: trail ?? [], ...view ? { view } : {} }
|
|
45
|
+
},
|
|
46
|
+
go: ({ section, step, trail, view }) => { presmiumService(PLAYBACK_CONTROL)?.pause(); pendingGo.set({ slide: 0, step: step ?? 0, section, ...trail ? { trail } : {}, ...view ? { view } : {} }) },
|
|
47
|
+
reveal: ({ section, block }) => viewer.show({ section, step: (block && blockStep(section, block)) || 0 }),
|
|
48
|
+
setDark(value) {
|
|
49
|
+
dark.set(value)
|
|
50
|
+
},
|
|
51
|
+
setActive: page.setActive,
|
|
52
|
+
activityChanged: value => viewer.options.editingActive?.set(value),
|
|
53
|
+
pausePlayback: () => presmiumService(PLAYBACK_CONTROL)?.pause(),
|
|
54
|
+
async productionPlayback(command, signal) {
|
|
55
|
+
// The page's ready handshake can precede asynchronous browser-plugin setup.
|
|
56
|
+
if (!presmiumService(PRODUCTION_PLAYBACK) && !presmiumReport()) await new Promise<void>((resolve, reject) => {
|
|
57
|
+
const ready = () => {
|
|
58
|
+
if (!signal.aborted && !presmiumReport()) return
|
|
59
|
+
stop(); signal.removeEventListener('abort', ready)
|
|
60
|
+
signal.aborted ? reject(signal.reason ?? new DOMException('Playback cancelled', 'AbortError')) : resolve()
|
|
61
|
+
}
|
|
62
|
+
const stop = runtimeRevision.subscribe(ready)
|
|
63
|
+
signal.addEventListener('abort', ready, { once: true }); ready()
|
|
64
|
+
})
|
|
65
|
+
signal.throwIfAborted()
|
|
66
|
+
const service = presmiumService(PRODUCTION_PLAYBACK)
|
|
67
|
+
if (!service || service.version !== 1) throw new Error('production-playback-unavailable')
|
|
68
|
+
return service.control(command, signal)
|
|
69
|
+
},
|
|
70
|
+
subscribeProductionPlayback(listener) {
|
|
71
|
+
let current = presmiumService(PRODUCTION_PLAYBACK), release = () => {}
|
|
72
|
+
const attach = () => {
|
|
73
|
+
const next = presmiumService(PRODUCTION_PLAYBACK)
|
|
74
|
+
release(); current = next
|
|
75
|
+
release = current?.state.subscribe(() => listener(current?.state.getSnapshot())) ?? (() => {})
|
|
76
|
+
listener(current?.state.getSnapshot())
|
|
77
|
+
}
|
|
78
|
+
const stop = runtimeRevision.subscribe(attach)
|
|
79
|
+
attach()
|
|
80
|
+
return () => { stop(); release() }
|
|
81
|
+
},
|
|
82
|
+
setPlaybackGate(authorize) {
|
|
83
|
+
let current: PlaybackControl | undefined
|
|
84
|
+
let release = () => {}
|
|
85
|
+
const attach = () => {
|
|
86
|
+
const next = presmiumService(PLAYBACK_CONTROL)
|
|
87
|
+
if (next === current) return
|
|
88
|
+
release()
|
|
89
|
+
current = next
|
|
90
|
+
release = current?.setGate(authorize) ?? (() => {})
|
|
91
|
+
}
|
|
92
|
+
const stop = runtimeRevision.subscribe(attach)
|
|
93
|
+
attach()
|
|
94
|
+
return () => { stop(); release() }
|
|
95
|
+
},
|
|
96
|
+
stepOf({ section, block }) {
|
|
97
|
+
const step = blockStep(section, block)
|
|
98
|
+
const found = viewer.section(section)
|
|
99
|
+
return step === undefined || !found ? undefined : { step, trail: stepTrail(stepKeys(found), step) }
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
if (!frame) return
|
|
103
|
+
navigation.position.subscribe(() => frame.moved())
|
|
104
|
+
viewer.doc.subscribe(() => { presmiumService(PLAYBACK_CONTROL)?.pause(); requestAnimationFrame(() => frame.updated()) })
|
|
105
|
+
watchProblems(frame)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function watchProblems(frame: PreviewFrame): void {
|
|
109
|
+
let told = new Map<string, { level: 'error' | 'warning', plugin: string, message: string }>()
|
|
110
|
+
const report = () => {
|
|
111
|
+
const plugins = presmiumReport()?.plugins ?? [], errors = componentErrors()
|
|
112
|
+
const problems = [
|
|
113
|
+
...plugins.filter(p => p.status === 'failed' || p.status === 'disabled').map(p => ({ level: p.status === 'failed' ? 'error' as const : 'warning' as const, plugin: p.id, message: p.reason ?? p.status })),
|
|
114
|
+
...errors.map(e => ({ level: 'error' as const, plugin: e.component, message: e.message })),
|
|
115
|
+
]
|
|
116
|
+
const current = new Map(problems.map(problem => [JSON.stringify(problem), problem]))
|
|
117
|
+
for (const [key, problem] of told) if (!current.has(key)) frame.report({ ...problem, resolved: true })
|
|
118
|
+
for (const [key, problem] of current) if (!told.has(key)) frame.report(problem)
|
|
119
|
+
told = current
|
|
120
|
+
}
|
|
121
|
+
runtimeRevision.subscribe(report)
|
|
122
|
+
componentErrorRevision.subscribe(report)
|
|
123
|
+
report()
|
|
124
|
+
}
|
|
@@ -1,11 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type {
|
|
3
|
-
import { connectPreviewFrame, createPreviewEvents, irBlocks, isPreviewActive, isPreviewEmbedded, PREVIEW_REVISION, stepKeys, stepTrail } from '@lincyaw/kernel'
|
|
4
|
-
import type { FollowAnchor, PreviewFrame, PreviewEventSubscription, PreviewEvents } from '@lincyaw/kernel'
|
|
1
|
+
import { createPreviewEvents, isPreviewActive, isPreviewEmbedded, reportStaleModule } from '@lincyaw/kernel'
|
|
2
|
+
import type { PreviewEventSubscription } from '@lincyaw/kernel'
|
|
5
3
|
import type { Viewer } from '@lincyaw/viewer'
|
|
6
|
-
import { findView, registryRevision } from '@lincyaw/viewer'
|
|
7
4
|
import type { Store } from '@lincyaw/host'
|
|
8
|
-
import { createViewerNavigation } from './host.ts'
|
|
9
5
|
|
|
10
6
|
export interface WebPreviewOptions {
|
|
11
7
|
origins: string[]
|
|
@@ -32,121 +28,14 @@ export interface WebPreview {
|
|
|
32
28
|
|
|
33
29
|
export function webPreview(options: WebPreviewOptions): WebPreview {
|
|
34
30
|
const events = createPreviewEvents(options.origins, options.document)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
// until it is available instead of losing a first switch to video while this window is still starting.
|
|
44
|
-
const applyPending = () => {
|
|
45
|
-
const destination = pendingGo.getSnapshot()
|
|
46
|
-
if (!destination) return
|
|
47
|
-
const view = destination.view && findView(destination.view)
|
|
48
|
-
if (destination.view && (!view || view.hidden)) return
|
|
49
|
-
pendingGo.set(undefined)
|
|
50
|
-
navigation.go(destination)
|
|
51
|
-
}
|
|
52
|
-
registryRevision.subscribe(applyPending)
|
|
53
|
-
pendingGo.subscribe(applyPending)
|
|
54
|
-
const revision = () => {
|
|
55
|
-
const value = viewer.doc.getSnapshot().meta[PREVIEW_REVISION]
|
|
56
|
-
return typeof value === 'string' ? value : undefined
|
|
57
|
-
}
|
|
58
|
-
const blockStep = (section: string, block: string) => {
|
|
59
|
-
const found = viewer.section(section)
|
|
60
|
-
return found ? [...irBlocks(found.blocks)].find(b => b.id === block)?.at : undefined
|
|
61
|
-
}
|
|
62
|
-
const frame: PreviewFrame | undefined = connectPreviewFrame({
|
|
63
|
-
renderer: options.renderer ?? 'web',
|
|
64
|
-
events,
|
|
65
|
-
...viewer.options.confirmedRevision ? { confirmPresentation: (revision: string) => { viewer.options.confirmedRevision!.set(revision); frame?.moved() } } : {},
|
|
66
|
-
document: options.document,
|
|
67
|
-
origins: options.origins,
|
|
68
|
-
revision,
|
|
69
|
-
position() {
|
|
70
|
-
if (viewer.options.confirmedRevision && viewer.options.confirmedRevision.getSnapshot() !== revision()) return
|
|
71
|
-
const { section, step, trail, view } = navigation.position.getSnapshot() as NavigationPosition & FollowAnchor
|
|
72
|
-
return section === undefined ? undefined : { section, step, trail: trail ?? [], ...view ? { view } : {} }
|
|
73
|
-
},
|
|
74
|
-
go: ({ section, step, trail, view }) => { presmiumService(PLAYBACK_CONTROL)?.pause(); pendingGo.set({ slide: 0, step: step ?? 0, section, ...trail ? { trail } : {}, ...view ? { view } : {} }) },
|
|
75
|
-
reveal: ({ section, block }) => viewer.show({ section, step: (block && blockStep(section, block)) || 0 }),
|
|
76
|
-
setDark(value) {
|
|
77
|
-
dark.set(value)
|
|
78
|
-
},
|
|
79
|
-
setActive: page.setActive,
|
|
80
|
-
activityChanged: value => viewer.options.editingActive?.set(value),
|
|
81
|
-
pausePlayback: () => presmiumService(PLAYBACK_CONTROL)?.pause(),
|
|
82
|
-
async productionPlayback(command, signal) {
|
|
83
|
-
// The page's ready handshake can precede asynchronous browser-plugin setup.
|
|
84
|
-
if (!presmiumService(PRODUCTION_PLAYBACK) && !presmiumReport()) await new Promise<void>((resolve, reject) => {
|
|
85
|
-
const ready = () => {
|
|
86
|
-
if (!signal.aborted && !presmiumReport()) return
|
|
87
|
-
stop(); signal.removeEventListener('abort', ready)
|
|
88
|
-
signal.aborted ? reject(signal.reason ?? new DOMException('Playback cancelled', 'AbortError')) : resolve()
|
|
89
|
-
}
|
|
90
|
-
const stop = runtimeRevision.subscribe(ready)
|
|
91
|
-
signal.addEventListener('abort', ready, { once: true }); ready()
|
|
92
|
-
})
|
|
93
|
-
signal.throwIfAborted()
|
|
94
|
-
const service = presmiumService(PRODUCTION_PLAYBACK)
|
|
95
|
-
if (!service || service.version !== 1) throw new Error('production-playback-unavailable')
|
|
96
|
-
return service.control(command, signal)
|
|
97
|
-
},
|
|
98
|
-
subscribeProductionPlayback(listener) {
|
|
99
|
-
let current = presmiumService(PRODUCTION_PLAYBACK), release = () => {}
|
|
100
|
-
const attach = () => {
|
|
101
|
-
const next = presmiumService(PRODUCTION_PLAYBACK)
|
|
102
|
-
release(); current = next
|
|
103
|
-
release = current?.state.subscribe(() => listener(current?.state.getSnapshot())) ?? (() => {})
|
|
104
|
-
listener(current?.state.getSnapshot())
|
|
105
|
-
}
|
|
106
|
-
const stop = runtimeRevision.subscribe(attach)
|
|
107
|
-
attach()
|
|
108
|
-
return () => { stop(); release() }
|
|
109
|
-
},
|
|
110
|
-
setPlaybackGate(authorize) {
|
|
111
|
-
let current: PlaybackControl | undefined
|
|
112
|
-
let release = () => {}
|
|
113
|
-
const attach = () => {
|
|
114
|
-
const next = presmiumService(PLAYBACK_CONTROL)
|
|
115
|
-
if (next === current) return
|
|
116
|
-
release()
|
|
117
|
-
current = next
|
|
118
|
-
release = current?.setGate(authorize) ?? (() => {})
|
|
119
|
-
}
|
|
120
|
-
const stop = runtimeRevision.subscribe(attach)
|
|
121
|
-
attach()
|
|
122
|
-
return () => { stop(); release() }
|
|
123
|
-
},
|
|
124
|
-
stepOf({ section, block }) {
|
|
125
|
-
const step = blockStep(section, block)
|
|
126
|
-
const found = viewer.section(section)
|
|
127
|
-
return step === undefined || !found ? undefined : { step, trail: stepTrail(stepKeys(found), step) }
|
|
128
|
-
},
|
|
129
|
-
})
|
|
130
|
-
if (!frame) return
|
|
131
|
-
navigation.position.subscribe(() => frame.moved())
|
|
132
|
-
viewer.doc.subscribe(() => { presmiumService(PLAYBACK_CONTROL)?.pause(); requestAnimationFrame(() => frame.updated()) })
|
|
133
|
-
watchProblems(frame)
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function watchProblems(frame: PreviewFrame): void {
|
|
137
|
-
let told = new Map<string, { level: 'error' | 'warning', plugin: string, message: string }>()
|
|
138
|
-
const report = () => {
|
|
139
|
-
const plugins = presmiumReport()?.plugins ?? [], errors = componentErrors()
|
|
140
|
-
const problems = [
|
|
141
|
-
...plugins.filter(p => p.status === 'failed' || p.status === 'disabled').map(p => ({ level: p.status === 'failed' ? 'error' as const : 'warning' as const, plugin: p.id, message: p.reason ?? p.status })),
|
|
142
|
-
...errors.map(e => ({ level: 'error' as const, plugin: e.component, message: e.message })),
|
|
143
|
-
]
|
|
144
|
-
const current = new Map(problems.map(problem => [JSON.stringify(problem), problem]))
|
|
145
|
-
for (const [key, problem] of told) if (!current.has(key)) frame.report({ ...problem, resolved: true })
|
|
146
|
-
for (const [key, problem] of current) if (!told.has(key)) frame.report(problem)
|
|
147
|
-
told = current
|
|
31
|
+
const embedded = isPreviewEmbedded()
|
|
32
|
+
// The preview protocol loads only in a page the workbench embeds (`preview-connect.ts`).
|
|
33
|
+
const connect = (page: WebPreviewPage) => {
|
|
34
|
+
if (!embedded || options.origins.length === 0) return
|
|
35
|
+
void import('./preview-connect.ts').then(m => m.connectWebPreviewFrame(page, options, events)).catch((error: unknown) => {
|
|
36
|
+
console.error('[presmium] the preview did not connect', error)
|
|
37
|
+
reportStaleModule(error)
|
|
38
|
+
})
|
|
148
39
|
}
|
|
149
|
-
|
|
150
|
-
componentErrorRevision.subscribe(report)
|
|
151
|
-
report()
|
|
40
|
+
return { active: isPreviewActive(), embedded, ...events ? { events: events.subscribe } : {}, connect }
|
|
152
41
|
}
|
package/src/domain/landing.ts
CHANGED
|
@@ -14,7 +14,8 @@ export type SiteLandingSection =
|
|
|
14
14
|
/** Website content is independent of teaching documents and may reference their published collection. */
|
|
15
15
|
export interface SiteLandingPage { schemaVersion: 1, title: string, description?: string, hero?: SiteHero, sections: SiteLandingSection[] }
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
/** A document of the site for a template: its pages at `href`, and its presentation at `slides` when it has one. */
|
|
18
|
+
export interface SiteContentDocument { id: string, title: string, href: string, slides?: string, description?: string, lang?: string, tags?: string[], pages?: number }
|
|
18
19
|
export interface SiteContent { schemaVersion: 1, title: string, base: string, lang: string, documents: SiteContentDocument[], theme?: { name: string, stylesheets: string[] } }
|
|
19
20
|
export const SITE_CONTENT_ELEMENT = 'presmium-site-data'
|
|
20
21
|
|
|
@@ -5,6 +5,8 @@ export interface HomeDocument {
|
|
|
5
5
|
lang?: string
|
|
6
6
|
tags?: string[]
|
|
7
7
|
href: string
|
|
8
|
+
/** Its presentation, below its pages, when it asks for one (`renderers.slides`). */
|
|
9
|
+
slides?: string
|
|
8
10
|
description?: string
|
|
9
11
|
pages?: number
|
|
10
12
|
problems?: { errors: number, warnings: number }
|
|
@@ -5,6 +5,7 @@ import { pathToFileURL } from 'node:url'
|
|
|
5
5
|
import { irBlocks } from '@lincyaw/kernel'
|
|
6
6
|
import type { IRDocument, PresmiumNodePlugin } from '@lincyaw/kernel'
|
|
7
7
|
import { collectBrowserModules, deckPackage, emptyClientModules, isRestartablePackage } from './client-modules.ts'
|
|
8
|
+
import { imageCache, ImageEncoder } from './images.ts'
|
|
8
9
|
import { quietDirectives } from './viewer-app.ts'
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -17,6 +18,8 @@ const BUNDLE_MANIFEST = 'bundle.json'
|
|
|
17
18
|
export const BUNDLE_ENTRY = 'document.js'
|
|
18
19
|
const DOCUMENT_MODULE = '\0presmium-document-bundle:'
|
|
19
20
|
const KEEP = 3
|
|
21
|
+
// Changes when a bundle built before would differ now from one built of the same code: 2 imports images as WebP.
|
|
22
|
+
const FORMAT = 2
|
|
20
23
|
|
|
21
24
|
export interface DocumentBundleInput {
|
|
22
25
|
deckDir: string
|
|
@@ -55,6 +58,29 @@ export interface DocumentBundle {
|
|
|
55
58
|
restartable: boolean
|
|
56
59
|
/** The names of the document's own components. */
|
|
57
60
|
components: string[]
|
|
61
|
+
/**
|
|
62
|
+
* What the entry loads, as its build records it: its chunks, those it may import later (scenes), by path in the
|
|
63
|
+
* bundle, and the app's shared modules among their imports, by specifier. A page preloads them with the entry.
|
|
64
|
+
*/
|
|
65
|
+
loads: { files: string[], shared: string[] }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface OutputFile { type: string, fileName: string, isEntry?: boolean, imports?: string[], dynamicImports?: string[], moduleIds?: string[] }
|
|
69
|
+
|
|
70
|
+
/** The chunks an entry imports, at once or later, and the external modules among their imports. */
|
|
71
|
+
function entryLoads(bundle: Record<string, OutputFile>, externals: ReadonlySet<string>): DocumentBundle['loads'] {
|
|
72
|
+
const byName = new Map(Object.values(bundle).map(file => [file.fileName, file]))
|
|
73
|
+
const files = new Set<string>(), shared = new Set<string>()
|
|
74
|
+
const visit = (name: string) => {
|
|
75
|
+
if (externals.has(name)) shared.add(name)
|
|
76
|
+
const file = byName.get(name)
|
|
77
|
+
if (!file || files.has(name)) return
|
|
78
|
+
files.add(name)
|
|
79
|
+
for (const next of [...file.imports ?? [], ...file.dynamicImports ?? []]) visit(next)
|
|
80
|
+
}
|
|
81
|
+
for (const file of byName.values()) if (file.isEntry) visit(file.fileName)
|
|
82
|
+
files.delete(BUNDLE_ENTRY)
|
|
83
|
+
return { files: [...files], shared: [...shared] }
|
|
58
84
|
}
|
|
59
85
|
|
|
60
86
|
interface DocumentCode {
|
|
@@ -101,14 +127,14 @@ ${ids.map((id, i) => ` ${JSON.stringify(id)}: module${i},`).join('\n')}
|
|
|
101
127
|
}
|
|
102
128
|
export const restartable = ${modules.restartable}
|
|
103
129
|
`
|
|
104
|
-
const key = createHash('sha256').update(JSON.stringify([input.externals.toSorted(), entry, generated])).digest('hex').slice(0, 16)
|
|
130
|
+
const key = createHash('sha256').update(JSON.stringify([FORMAT, input.externals.toSorted(), entry, generated])).digest('hex').slice(0, 16)
|
|
105
131
|
return { key, entry, generated, restartable: modules.restartable, components: [...components.keys()] }
|
|
106
132
|
}
|
|
107
133
|
|
|
108
134
|
function readBundle(dir: string): DocumentBundle | undefined {
|
|
109
135
|
try {
|
|
110
136
|
const bundle = JSON.parse(readFileSync(join(dir, BUNDLE_MANIFEST), 'utf8')) as DocumentBundle
|
|
111
|
-
return Object.entries(bundle.inputs).every(([file, known]) => stamp(file) === known) ? { ...bundle, components: bundle.components ?? [], dir } : undefined
|
|
137
|
+
return Object.entries(bundle.inputs).every(([file, known]) => stamp(file) === known) && bundle.loads ? { ...bundle, components: bundle.components ?? [], dir } : undefined
|
|
112
138
|
} catch {
|
|
113
139
|
return undefined
|
|
114
140
|
}
|
|
@@ -155,6 +181,7 @@ export async function buildDocumentBundle(input: DocumentBundleInput): Promise<D
|
|
|
155
181
|
const { generated } = code
|
|
156
182
|
const externals = new Set(input.externals)
|
|
157
183
|
const inputs: Record<string, string> = {}
|
|
184
|
+
let loads: DocumentBundle['loads'] = { files: [], shared: [] }
|
|
158
185
|
const { build } = await import('vite')
|
|
159
186
|
await build({
|
|
160
187
|
configFile: false,
|
|
@@ -165,6 +192,7 @@ export async function buildDocumentBundle(input: DocumentBundleInput): Promise<D
|
|
|
165
192
|
esbuild: { jsx: 'automatic' },
|
|
166
193
|
publicDir: false,
|
|
167
194
|
plugins: [
|
|
195
|
+
new ImageEncoder(imageCache(input.workspaceRoot)).vitePlugin(),
|
|
168
196
|
{
|
|
169
197
|
name: 'presmium:document-bundle',
|
|
170
198
|
// Before Vite's own resolution: the app's modules stay imports by name.
|
|
@@ -179,7 +207,8 @@ export async function buildDocumentBundle(input: DocumentBundleInput): Promise<D
|
|
|
179
207
|
load(id: string) {
|
|
180
208
|
return id.startsWith(DOCUMENT_MODULE) ? generated[id.slice(DOCUMENT_MODULE.length)] : undefined
|
|
181
209
|
},
|
|
182
|
-
generateBundle(_: unknown, bundle: Record<string,
|
|
210
|
+
generateBundle(_: unknown, bundle: Record<string, OutputFile>) {
|
|
211
|
+
loads = entryLoads(bundle, externals)
|
|
183
212
|
for (const chunk of Object.values(bundle)) {
|
|
184
213
|
for (const id of chunk.moduleIds ?? []) {
|
|
185
214
|
const file = id.replace(/[?#].*$/s, '')
|
|
@@ -215,7 +244,7 @@ export async function buildDocumentBundle(input: DocumentBundleInput): Promise<D
|
|
|
215
244
|
const content = createHash('sha256').update(readFileSync(join(work, 'out', BUNDLE_ENTRY))).digest('hex').slice(0, 8)
|
|
216
245
|
const name = `${code.key}-${content}`
|
|
217
246
|
const out = join(input.outDir, name)
|
|
218
|
-
const bundle: DocumentBundle = { key: name, dir: out, inputs, restartable: code.restartable, components: code.components }
|
|
247
|
+
const bundle: DocumentBundle = { key: name, dir: out, inputs, restartable: code.restartable, components: code.components, loads }
|
|
219
248
|
writeFileSync(join(work, 'out', BUNDLE_MANIFEST), `${JSON.stringify(bundle, null, 2)}\n`)
|
|
220
249
|
rmSync(out, { recursive: true, force: true })
|
|
221
250
|
renameSync(join(work, 'out'), out)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { basename, extname, join } from 'node:path'
|
|
4
|
+
import type { LocalAsset } from '@lincyaw/kernel'
|
|
5
|
+
import { copyPublished, threadBudget } from '@lincyaw/kernel/node'
|
|
6
|
+
import pLimit from 'p-limit'
|
|
7
|
+
import sharp from 'sharp'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Raster images a site publishes are encoded again as WebP and no wider than any page shows them: a paper's PNG
|
|
11
|
+
* figures and photographs are the largest files most pages load. The name is decided where the image is published (a
|
|
12
|
+
* document's media, an image a scene imports), so every reference is written with it; an encoding is kept under the
|
|
13
|
+
* build's work directory by the image's content and made once. Files the author publishes as they are (`public/`) and
|
|
14
|
+
* the images a page's head names for link previews keep their format.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const RASTER = new Set(['.png', '.jpg', '.jpeg'])
|
|
18
|
+
// Below this an image is small enough that a second encoding gains little.
|
|
19
|
+
const MIN_BYTES = 48 * 1024
|
|
20
|
+
// The widest an image is drawn: a full-width figure on a large screen at twice its pixels.
|
|
21
|
+
const MAX_WIDTH = 2400
|
|
22
|
+
|
|
23
|
+
export interface EncodedImages {
|
|
24
|
+
images: number
|
|
25
|
+
saved: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function encodable(file: string): boolean {
|
|
29
|
+
if (!RASTER.has(extname(file).toLowerCase())) return false
|
|
30
|
+
const stat = statSync(file, { throwIfNoEntry: false })
|
|
31
|
+
return stat !== undefined && stat.isFile() && stat.size >= MIN_BYTES
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Where a workspace keeps its encoded images, by their content. */
|
|
35
|
+
export const imageCache = (workspaceRoot: string) => join(workspaceRoot, '.presmium', 'images')
|
|
36
|
+
|
|
37
|
+
const webpName = (name: string) => `${basename(name, extname(name))}.webp`
|
|
38
|
+
|
|
39
|
+
export class ImageEncoder {
|
|
40
|
+
readonly #cacheDir: string
|
|
41
|
+
readonly #limit = pLimit(threadBudget('background'))
|
|
42
|
+
readonly #pending: Promise<void>[] = []
|
|
43
|
+
#encoded: EncodedImages = { images: 0, saved: 0 }
|
|
44
|
+
|
|
45
|
+
constructor(cacheDir: string) {
|
|
46
|
+
this.#cacheDir = cacheDir
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The image as WebP, from the cache when it was encoded before. */
|
|
50
|
+
encode(file: string): Promise<Buffer> {
|
|
51
|
+
return this.#limit(async () => {
|
|
52
|
+
const source = readFileSync(file)
|
|
53
|
+
const cached = join(this.#cacheDir, `${createHash('sha256').update(source).digest('hex')}.webp`)
|
|
54
|
+
if (existsSync(cached)) return readFileSync(cached)
|
|
55
|
+
const image = sharp(source).rotate().resize({ width: MAX_WIDTH, withoutEnlargement: true })
|
|
56
|
+
// Figures and screenshots keep their sharp edges (near-lossless); photographs take ordinary lossy compression.
|
|
57
|
+
const bytes = await (extname(file).toLowerCase() === '.png' ? image.webp({ nearLossless: true, quality: 75, effort: 5 }) : image.webp({ quality: 80, effort: 5 })).toBuffer()
|
|
58
|
+
mkdirSync(this.#cacheDir, { recursive: true })
|
|
59
|
+
writeFileSync(`${cached}.${process.pid}`, bytes)
|
|
60
|
+
renameSync(`${cached}.${process.pid}`, cached)
|
|
61
|
+
this.#encoded = { images: this.#encoded.images + 1, saved: this.#encoded.saved + Math.max(0, source.length - bytes.length) }
|
|
62
|
+
return bytes
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Publishes a local file into `dir`: a raster image under its WebP name, written once its encoding is done. */
|
|
67
|
+
publish(asset: LocalAsset, dir: string): LocalAsset {
|
|
68
|
+
if (asset.directory || !encodable(asset.file)) {
|
|
69
|
+
copyPublished(asset, dir)
|
|
70
|
+
return asset
|
|
71
|
+
}
|
|
72
|
+
const published = { ...asset, name: webpName(asset.name) }
|
|
73
|
+
const target = join(dir, published.name)
|
|
74
|
+
if (!existsSync(target)) {
|
|
75
|
+
this.#pending.push(this.encode(asset.file).then((bytes) => {
|
|
76
|
+
mkdirSync(dir, { recursive: true })
|
|
77
|
+
writeFileSync(target, bytes)
|
|
78
|
+
}))
|
|
79
|
+
}
|
|
80
|
+
return published
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Waits for the images published so far; what this encoder has encoded (not taken from its cache). */
|
|
84
|
+
async done(): Promise<EncodedImages> {
|
|
85
|
+
while (this.#pending.length > 0) await Promise.all(this.#pending.splice(0))
|
|
86
|
+
return this.#encoded
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A Vite plugin: a raster image a module imports is emitted as WebP, and the import is its URL. */
|
|
90
|
+
vitePlugin() {
|
|
91
|
+
const encoder = this
|
|
92
|
+
return {
|
|
93
|
+
name: 'presmium:webp-images',
|
|
94
|
+
enforce: 'pre' as const,
|
|
95
|
+
load: async function (this: { emitFile(file: { type: 'asset', name: string, source: Uint8Array }): string }, id: string) {
|
|
96
|
+
// An image imported for its URL, plainly or with `?url`; other queries (`?raw`, `?inline`) want its bytes.
|
|
97
|
+
const file = id.replace(/\?url$/, '')
|
|
98
|
+
if (/[?#]/.test(file) || !encodable(file)) return undefined
|
|
99
|
+
const reference = this.emitFile({ type: 'asset', name: webpName(file), source: await encoder.encode(file) })
|
|
100
|
+
return `export default import.meta.ROLLUP_FILE_URL_${reference}`
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -3,6 +3,7 @@ import { basename, dirname, join } from 'node:path'
|
|
|
3
3
|
import { mapHtmlUrls } from '@lincyaw/kernel'
|
|
4
4
|
import { build } from 'vite'
|
|
5
5
|
import { SITE_CONTENT_ELEMENT } from '../domain/index.ts'
|
|
6
|
+
import { imageCache, ImageEncoder } from './images.ts'
|
|
6
7
|
import type { SiteContent } from '../domain/index.ts'
|
|
7
8
|
|
|
8
9
|
/** A website template is an ordinary Vite HTML entry, with its own CSS and modules. */
|
|
@@ -15,9 +16,14 @@ export async function buildLandingTemplate(file: string, workDir: string, base:
|
|
|
15
16
|
base,
|
|
16
17
|
publicDir: join(workspaceRoot, 'public'),
|
|
17
18
|
logLevel: 'silent',
|
|
18
|
-
plugins: [{
|
|
19
|
+
plugins: [new ImageEncoder(imageCache(workspaceRoot)).vitePlugin(), {
|
|
19
20
|
name: 'presmium-site-content',
|
|
20
21
|
transformIndexHtml: { order: 'pre', handler: () => [{ tag: 'script', attrs: { id: SITE_CONTENT_ELEMENT, type: 'application/json' }, children: data, injectTo: 'head' }] },
|
|
22
|
+
}, {
|
|
23
|
+
// The theme's stylesheets come first in the head, before the template's own, which can build on them; added after
|
|
24
|
+
// the build reads the page, since they are the site's files and not the template's.
|
|
25
|
+
name: 'presmium-site-theme',
|
|
26
|
+
transformIndexHtml: { order: 'post', handler: () => (content.theme?.stylesheets ?? []).map(href => ({ tag: 'link', attrs: { rel: 'stylesheet', href }, injectTo: 'head-prepend' as const })) },
|
|
21
27
|
}],
|
|
22
28
|
build: { outDir: dir, emptyOutDir: true, copyPublicDir: false, assetsDir: '_landing/assets', rollupOptions: { input: file } },
|
|
23
29
|
})
|
package/src/node/landing.ts
CHANGED
|
@@ -78,14 +78,14 @@ export function publishLanding(landing: Extract<IndependentLanding, { kind: 'pag
|
|
|
78
78
|
|
|
79
79
|
/** The public content collection available to a free-form HTML template. */
|
|
80
80
|
export function siteContent(site: SiteSpec, base: string, lang: string, documents: HomeDocument[]): SiteContent {
|
|
81
|
-
//
|
|
81
|
+
// The template's page links the workspace theme's stylesheets; it marks its root with `data-pm-site-theme-name`.
|
|
82
82
|
const { name, stylesheets } = siteTheme(shellOptions(site), lang)
|
|
83
83
|
return {
|
|
84
84
|
schemaVersion: 1,
|
|
85
85
|
title: siteTitle(site, lang),
|
|
86
86
|
base,
|
|
87
87
|
lang,
|
|
88
|
-
documents: documents.map(({ dir, title, href, description, lang, tags, pages }) => ({ id: dir, title, href, description, lang, tags, pages })),
|
|
88
|
+
documents: documents.map(({ dir, title, href, slides, description, lang, tags, pages }) => ({ id: dir, title, href, slides, description, lang, tags, pages })),
|
|
89
89
|
...name ? { theme: { name, stylesheets: stylesheets ?? [] } } : {},
|
|
90
90
|
}
|
|
91
91
|
}
|
package/src/node/publish.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { irBlocks, mapHtmlUrls, NON_FILE_URL, publishedUrl, withPublishedAssets
|
|
|
5
5
|
import type { DocumentLink, IRBlock, IRDocument, IRSection, LocalAsset, MdastNode, RenderContext, SourcePos } from '@lincyaw/kernel'
|
|
6
6
|
import { copyPublished, fileVersion } from '@lincyaw/kernel/node'
|
|
7
7
|
import katex from 'katex'
|
|
8
|
+
import type { ImageEncoder } from './images.ts'
|
|
8
9
|
|
|
9
10
|
export const MEDIA = 'media'
|
|
10
11
|
|
|
@@ -96,7 +97,16 @@ export class PublicationCache {
|
|
|
96
97
|
}
|
|
97
98
|
}
|
|
98
99
|
|
|
99
|
-
export
|
|
100
|
+
export interface PublishOptions {
|
|
101
|
+
/** Where a link to another document of the site leads. */
|
|
102
|
+
documentPage?: DocumentPage
|
|
103
|
+
/** The sections published before, kept by a server that publishes the document again on each change. */
|
|
104
|
+
cache?: PublicationCache
|
|
105
|
+
/** Encodes the raster images a build publishes (none while a server previews the document). */
|
|
106
|
+
images?: ImageEncoder
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function publishedDocument(doc: IRDocument, ctx: RenderContext, publicDir: string, base: string, { documentPage, cache, images }: PublishOptions = {}): IRDocument {
|
|
100
110
|
const { deckDir } = ctx
|
|
101
111
|
const copy: IRDocument = structuredClone(cache ? { ...doc, sections: [] } : doc)
|
|
102
112
|
const media = join(publicDir, MEDIA)
|
|
@@ -108,8 +118,9 @@ export function publishedDocument(doc: IRDocument, ctx: RenderContext, publicDir
|
|
|
108
118
|
if (linked) return documentPage(linked)
|
|
109
119
|
const asset = localAsset(ctx, source, url)
|
|
110
120
|
if (!asset) return undefined
|
|
111
|
-
|
|
112
|
-
|
|
121
|
+
const published = images ? images.publish(asset, media) : asset
|
|
122
|
+
if (!images) copyPublished(asset, media)
|
|
123
|
+
return publishedUrl(published, url, { prefix: `${base}${MEDIA}/` })
|
|
113
124
|
}
|
|
114
125
|
const mdast = (node: MdastNode, source: SourcePos) => {
|
|
115
126
|
if (LINKED.has(node.type) && typeof node.url === 'string') node.url = publish(node.url, source) ?? node.url
|
package/src/node/site-build.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { cpSync,
|
|
2
|
-
import {
|
|
1
|
+
import { cpSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join, resolve } from 'node:path'
|
|
3
3
|
import { Readable } from 'node:stream'
|
|
4
4
|
import { pathToFileURL } from 'node:url'
|
|
5
5
|
import { ABSOLUTE_URL, errorMessage, isRecord } from '@lincyaw/kernel'
|
|
@@ -14,14 +14,15 @@ import { writeServiceWorker } from './offline.ts'
|
|
|
14
14
|
import { publishedDocument, publishedFile } from './publish.ts'
|
|
15
15
|
import { APP_DIR, CODE_DIR, contentKey, copyApp, copyPublic, copyRuntimeAssets, runtimeAssets, searchIndex } from './site-files.ts'
|
|
16
16
|
import { documentBase, documentPage, documentSiteConfig, homeDocument, homeDocuments, isSingle, landingConfig, landingDocument, languageLabel, LIST_PAGE, siteLanguage, siteLanguages, siteNeighbours, siteShellBelow, siteTitle, sitemapPages, translationOf } from './site-model.ts'
|
|
17
|
-
import {
|
|
17
|
+
import { imageCache, ImageEncoder } from './images.ts'
|
|
18
|
+
import type { EncodedImages } from './images.ts'
|
|
18
19
|
import { publishedSite } from './site-assets.ts'
|
|
19
20
|
import { themedSite } from './site-theme.ts'
|
|
20
21
|
import { loadLanding, publishLanding, siteContent } from './landing.ts'
|
|
21
22
|
import { buildLandingTemplate, writeLandingTemplate } from './landing-template.ts'
|
|
22
23
|
import { pageHtml, redirectHtml } from './site-page.ts'
|
|
23
24
|
import type { MetaTag } from './site-page.ts'
|
|
24
|
-
import { appEntry, appPlugin, buildViewerApp, buildViewerServer, viewerAppCache } from './viewer-app.ts'
|
|
25
|
+
import { appEntry, appModuleImports, appPlugin, buildViewerApp, buildViewerServer, viewerAppCache } from './viewer-app.ts'
|
|
25
26
|
import type { AppPlugin, ViewerApp } from './viewer-app.ts'
|
|
26
27
|
|
|
27
28
|
/**
|
|
@@ -101,7 +102,9 @@ async function prepareApp(ctx: RenderContext, documents: readonly BuiltDocument[
|
|
|
101
102
|
const server = await import(pathToFileURL(await buildViewerServer(app, [...plugins.values()])).href) as ServerRender
|
|
102
103
|
ctx.log?.(`the viewer app is ready in ${((Date.now() - started) / 1000).toFixed(1)} s`)
|
|
103
104
|
const key = copyApp(app, join(ctx.outDir, APP_DIR))
|
|
104
|
-
|
|
105
|
+
// A presentation built as part of a site names the app where the site keeps it (`appBase`); the site moves it there.
|
|
106
|
+
const appBase = typeof ctx.config.appBase === 'string' ? ctx.config.appBase : ctx.base
|
|
107
|
+
return { app, appDir: join(ctx.outDir, APP_DIR, key), appUrl: `${appBase}${APP_DIR}/${key}/`, server, plugins }
|
|
105
108
|
}
|
|
106
109
|
|
|
107
110
|
async function renderPage(built: Built, file: string, config: AppConfig, title: string, meta: MetaTag[], doc: IRDocument | undefined, components: string[]): Promise<void> {
|
|
@@ -111,7 +114,7 @@ async function renderPage(built: Built, file: string, config: AppConfig, title:
|
|
|
111
114
|
}
|
|
112
115
|
|
|
113
116
|
/** A document's files beside its pages: its own code, its plugins' run-time files, its `public/`, the fonts' license. */
|
|
114
|
-
async function documentFiles(ctx: RenderContext, built: Built, entry: BuiltDocument): Promise<{ code?: string, components: string[] }> {
|
|
117
|
+
async function documentFiles(ctx: RenderContext, built: Built, entry: BuiltDocument): Promise<{ code?: string, components: string[], loads?: DocumentBundle['loads'] }> {
|
|
115
118
|
const { dir, ctx: own } = entry
|
|
116
119
|
const out = own.outDir
|
|
117
120
|
// The document's own code, built to import the app's modules by name.
|
|
@@ -131,19 +134,19 @@ async function documentFiles(ctx: RenderContext, built: Built, entry: BuiltDocum
|
|
|
131
134
|
mkdirSync(dirname(join(out, FONT_LICENSE)), { recursive: true })
|
|
132
135
|
writeFileSync(join(out, FONT_LICENSE), fontLicense(import.meta.url))
|
|
133
136
|
const components = [...new Set([...own.plugins.flatMap(name => Object.keys(built.plugins.get(name)?.components ?? {})), ...bundle?.components ?? []])]
|
|
134
|
-
return { ...code ? { code } : {}, components }
|
|
137
|
+
return { ...code ? { code, loads: bundle.loads } : {}, components }
|
|
135
138
|
}
|
|
136
139
|
|
|
137
|
-
async function buildDocumentPages(site: SiteSpec, ctx: RenderContext, built: Built, entry: BuiltDocument, docs: ReadonlyMap<string, IRDocument>, workspace: boolean): Promise<void> {
|
|
140
|
+
async function buildDocumentPages(site: SiteSpec, ctx: RenderContext, built: Built, entry: BuiltDocument, docs: ReadonlyMap<string, IRDocument>, workspace: boolean, images: ImageEncoder): Promise<void> {
|
|
138
141
|
const { dir, doc, ctx: own } = entry
|
|
139
142
|
const out = own.outDir
|
|
140
143
|
const base = documentBase(ctx.base, dir)
|
|
141
144
|
mkdirSync(out, { recursive: true })
|
|
142
145
|
const linked = workspace ? documentPage(site, ctx.base, docs) : undefined
|
|
143
|
-
const published = publishedDocument(doc, own, out, base, linked)
|
|
146
|
+
const published = publishedDocument(doc, own, out, base, { documentPage: linked, images })
|
|
144
147
|
const pages = webPages(published)
|
|
145
148
|
const translations = entry.translations.map(({ lang, doc: translated }) => {
|
|
146
|
-
const shown = publishedDocument(translated, own, out, base, linked)
|
|
149
|
+
const shown = publishedDocument(translated, own, out, base, { documentPage: linked, images })
|
|
147
150
|
return { lang, doc: shown, pages: translationPages(shown, pages) }
|
|
148
151
|
})
|
|
149
152
|
const lang = doc.meta.lang ?? own.lang ?? 'zh-CN'
|
|
@@ -185,7 +188,10 @@ async function buildDocumentPages(site: SiteSpec, ctx: RenderContext, built: Bui
|
|
|
185
188
|
}
|
|
186
189
|
for (const { path, section } of chapterRedirects(published)) writeFileSync(join(out, locale.prefix, `${path}.html`), chapterRedirect(section))
|
|
187
190
|
}
|
|
188
|
-
if (offline)
|
|
191
|
+
if (!offline) return
|
|
192
|
+
// The service worker lists the document's files, its images among them.
|
|
193
|
+
await images.done()
|
|
194
|
+
writeServiceWorker(out, base, { dir: built.appDir, url: built.appUrl })
|
|
189
195
|
}
|
|
190
196
|
|
|
191
197
|
/** The page a chapter used to have: it goes on to the chapter's section on the document's page. */
|
|
@@ -254,13 +260,14 @@ export async function buildSite(site: SiteSpec, ctx: RenderContext, documents: r
|
|
|
254
260
|
if (workspace) copyPublic(ctx.workspaceRoot, ctx.outDir)
|
|
255
261
|
site = publishedSite(site, ctx)
|
|
256
262
|
const built = await prepareApp(ctx, documents)
|
|
263
|
+
const images = new ImageEncoder(imageCache(ctx.workspaceRoot))
|
|
257
264
|
// Also allow publishing from a branch without Jekyll filtering the viewer's _app and _code directories.
|
|
258
265
|
writeFileSync(join(ctx.outDir, '.nojekyll'), '')
|
|
259
266
|
const docs = new Map(documents.map(entry => [entry.dir, entry.doc] as const))
|
|
260
267
|
if (workspace && ctx.config.home !== false) await buildHome(site, ctx, built, docs)
|
|
261
268
|
for (const entry of documents) {
|
|
262
269
|
try {
|
|
263
|
-
await buildDocumentPages(site, ctx, built, entry, docs, workspace)
|
|
270
|
+
await buildDocumentPages(site, ctx, built, entry, docs, workspace, images)
|
|
264
271
|
} catch (error) {
|
|
265
272
|
throw new Error(workspace ? `document ${entry.dir || '.'}: ${errorMessage(error)}` : errorMessage(error))
|
|
266
273
|
}
|
|
@@ -275,36 +282,24 @@ export async function buildSite(site: SiteSpec, ctx: RenderContext, documents: r
|
|
|
275
282
|
])
|
|
276
283
|
await writeSitemap(join(ctx.outDir, 'sitemap.xml'), sitemap.hostname, ctx.base, pages)
|
|
277
284
|
}
|
|
278
|
-
|
|
285
|
+
reportEncoded(ctx, await images.done())
|
|
279
286
|
}
|
|
280
287
|
|
|
281
|
-
|
|
288
|
+
function reportEncoded(ctx: RenderContext, { images, saved }: EncodedImages): void {
|
|
282
289
|
if (images > 0) ctx.log?.(`${images} images encoded as WebP, ${Math.round(saved / 1024)} KB smaller`)
|
|
283
290
|
}
|
|
284
291
|
|
|
285
|
-
/** The animation libraries a scene engine loads when its first scene is made, by the start of their file names. */
|
|
286
|
-
const SCENE_LIBRARIES: Record<string, string> = { gsapScene: 'gsap', animeScene: 'anime', motionScene: 'motion', threeScene: 'three.module', d3Scene: 'd3', remotionScene: 'remotion' }
|
|
287
|
-
|
|
288
292
|
/**
|
|
289
|
-
* What a presentation will load to draw its first
|
|
290
|
-
* modules they import (`@lincyaw/addon-motion/scene`) and the
|
|
291
|
-
* app, they arrive together instead of each after the one
|
|
293
|
+
* What a presentation will load to draw its first scenes, all known at build time: the document's chunks; the app's
|
|
294
|
+
* shared modules they import (`@lincyaw/addon-motion/scene`) and the browser and viewer modules of the presentation's
|
|
295
|
+
* plugins, with what those import at once. Preloaded with the app, they arrive together instead of each after the one
|
|
296
|
+
* that names it.
|
|
292
297
|
*/
|
|
293
|
-
function
|
|
294
|
-
const files = [join(codeDir, BUNDLE_ENTRY), ...existsSync(join(codeDir, 'chunks')) ? readdirSync(join(codeDir, 'chunks')).filter(file => file.endsWith('.js')).map(file => join(codeDir, 'chunks', file)) : []]
|
|
295
|
-
const text = files.filter(file => existsSync(file)).map(file => readFileSync(file, 'utf8')).join('\n')
|
|
296
|
-
const urls = files.slice(1).map(file => `${codeUrl}chunks/${basename(file)}`)
|
|
298
|
+
function presentationPreloads(codeUrl: string, loads: DocumentBundle['loads'], built: Built, plugins: readonly string[]): string[] {
|
|
297
299
|
const { shared } = built.app.manifest
|
|
298
|
-
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
for (const file of assets.filter(name => name.startsWith('viewer-') && name.endsWith('.js'))) urls.push(`${built.appUrl}assets/${file}`)
|
|
302
|
-
for (const [engine, library] of Object.entries(SCENE_LIBRARIES)) {
|
|
303
|
-
if (!text.includes(engine)) continue
|
|
304
|
-
const file = assets.find(name => name.startsWith(`${library}-`) && name.endsWith('.js'))
|
|
305
|
-
if (file) urls.push(`${built.appUrl}assets/${file}`)
|
|
306
|
-
}
|
|
307
|
-
return urls
|
|
300
|
+
const files = loads.shared.flatMap(specifier => shared[specifier] ?? [])
|
|
301
|
+
const sources = plugins.flatMap(name => [...built.plugins.get(name)?.plugins ?? [], ...built.plugins.get(name)?.viewers ?? []])
|
|
302
|
+
return [...loads.files.map(file => `${codeUrl}${file}`), ...appModuleImports(built.app, { files, sources }).map(file => `${built.appUrl}${file}`)]
|
|
308
303
|
}
|
|
309
304
|
|
|
310
305
|
/**
|
|
@@ -316,8 +311,9 @@ export async function buildSlidesSite(doc: IRDocument, ctx: RenderContext, nodeP
|
|
|
316
311
|
const entry: BuiltDocument = { dir: '', doc, ctx, nodePlugins, translations: [] }
|
|
317
312
|
const built = await prepareApp(ctx, [entry])
|
|
318
313
|
mkdirSync(ctx.outDir, { recursive: true })
|
|
319
|
-
const
|
|
320
|
-
const
|
|
314
|
+
const images = new ImageEncoder(imageCache(ctx.workspaceRoot))
|
|
315
|
+
const published = publishedDocument(doc, ctx, ctx.outDir, ctx.base, { images })
|
|
316
|
+
const { code, loads } = await documentFiles(ctx, built, entry)
|
|
321
317
|
writeFileSync(join(ctx.outDir, 'ir.json'), JSON.stringify(published))
|
|
322
318
|
const lang = doc.meta.lang ?? ctx.lang ?? 'zh-CN'
|
|
323
319
|
const site = publishedSite(themedSite({ title: doc.meta.title, documents: [{ dir: '', title: doc.meta.title, doc }], config: ctx.config }, ctx), ctx)
|
|
@@ -333,7 +329,7 @@ export async function buildSlidesSite(doc: IRDocument, ctx: RenderContext, nodeP
|
|
|
333
329
|
plugins: ctx.plugins,
|
|
334
330
|
origins: [],
|
|
335
331
|
}
|
|
336
|
-
const preload = code ?
|
|
332
|
+
const preload = code && loads ? presentationPreloads(`${ctx.base}${CODE_DIR}/${code}/`, loads, built, ctx.plugins) : []
|
|
337
333
|
writeFileSync(join(ctx.outDir, 'index.html'), pageHtml({ entry: appEntry(built.app), appUrl: built.appUrl, shared: built.app.manifest.shared, config, title: doc.meta.title, preload }))
|
|
338
|
-
|
|
334
|
+
reportEncoded(ctx, await images.done())
|
|
339
335
|
}
|
package/src/node/site-files.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { APP_MANIFEST } from './viewer-app.ts'
|
|
|
11
11
|
import type { ViewerApp } from './viewer-app.ts'
|
|
12
12
|
|
|
13
13
|
/** Where a built site keeps the viewer app (`_app/<key>/`, below the site's path) and a document its own code. */
|
|
14
|
-
export
|
|
14
|
+
export { APP_DIR } from '@lincyaw/kernel'
|
|
15
15
|
export const CODE_DIR = '_code'
|
|
16
16
|
// What of a cached app a site needs: not its server-side render, nor the build's own records.
|
|
17
17
|
const NOT_PUBLISHED = new Set(['server', '.vite', APP_MANIFEST])
|
package/src/node/site-model.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, realpathSync, statSync } from 'node:fs'
|
|
2
2
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
3
|
-
import { DEFAULT_LANG, DOCUMENT_ENTRY, deckLocale, docBlocks, isRecord, nodeMdast, NON_FILE_URL, safeDecodeURIComponent } from '@lincyaw/kernel'
|
|
3
|
+
import { DEFAULT_LANG, DOCUMENT_ENTRY, SLIDES_DIR, deckLocale, docBlocks, isRecord, nodeMdast, NON_FILE_URL, safeDecodeURIComponent } from '@lincyaw/kernel'
|
|
4
4
|
import type { DocumentLink, Finding, IRDocument, MdastNode, RenderContext, SiteDocument, SiteSpec, SourcePos } from '@lincyaw/kernel'
|
|
5
5
|
import { findInstalledPackage, isInside } from '@lincyaw/kernel/node'
|
|
6
6
|
import { crossFormLinks, pageLink, searchDocuments, sectionLinks, sitePageLayout, siteShell, translationPages, urlPath, WEB_MESSAGES, webPages } from '../domain/index.ts'
|
|
@@ -117,7 +117,8 @@ export function homeDocuments(site: SiteSpec, base: string, compiled: ReadonlyMa
|
|
|
117
117
|
const snapshot = compiled.get(dir) ?? doc
|
|
118
118
|
const root = documentBase(base, dir)
|
|
119
119
|
const card = snapshot ? cardOf(snapshot, root) : description?.trim() ? { description: description.trim() } : {}
|
|
120
|
-
|
|
120
|
+
const slides = (snapshot ? snapshot.config.renderers.slides !== undefined : entry.slides) ? { slides: `${root}${SLIDES_DIR}/` } : {}
|
|
121
|
+
return { dir, title, ...own ? { lang: own } : {}, ...tags?.length ? { tags } : {}, href: root, ...slides, ...card }
|
|
121
122
|
})
|
|
122
123
|
}
|
|
123
124
|
|
package/src/node/site-page.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import escapeHtml from 'escape-html'
|
|
2
|
+
import { STALE_MODULE_EVENT, STALE_MODULE_PATTERN } from '@lincyaw/kernel'
|
|
2
3
|
import { APP_CONFIG_ELEMENT } from '../domain/index.ts'
|
|
3
4
|
import type { AppConfig } from '../domain/index.ts'
|
|
4
5
|
import type { AppEntry } from './viewer-app.ts'
|
|
@@ -37,19 +38,29 @@ const APPEARANCE = `(() => {
|
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
40
|
* A page cached before a new deployment names an app version the deployment removed: its scripts and the modules they
|
|
40
|
-
* import then fail. The page loads once more, from the network, and gets the current version
|
|
41
|
+
* import then fail. The page loads once more, from the network, and gets the current version: at most once in 30 s,
|
|
42
|
+
* not while offline (a lost connection fails the same way) and not without session storage to remember it.
|
|
43
|
+
* Failures come as Vite's preload event, a rejected dynamic import, a module script that cannot load, or the event
|
|
44
|
+
* Presmium's own loaders dispatch (`reportStaleModule`).
|
|
41
45
|
*/
|
|
42
|
-
const
|
|
46
|
+
const STALE_RELOAD = `(() => {
|
|
43
47
|
const key = 'presmium-stale-reload'
|
|
48
|
+
// Reloads at most once in 30 s, only while online, and only where it can remember that it did: without storage a
|
|
49
|
+
// module that keeps failing would reload the page forever. Returns whether it reloads.
|
|
44
50
|
const reload = () => {
|
|
45
|
-
|
|
46
|
-
try {
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
if (navigator.onLine === false) return false
|
|
52
|
+
try {
|
|
53
|
+
if (Date.now() - (Number(sessionStorage.getItem(key)) || 0) < 30000) return false
|
|
54
|
+
sessionStorage.setItem(key, String(Date.now()))
|
|
55
|
+
} catch { return false }
|
|
49
56
|
location.reload()
|
|
57
|
+
return true
|
|
50
58
|
}
|
|
51
|
-
|
|
52
|
-
addEventListener(
|
|
59
|
+
const stale = new RegExp(${JSON.stringify(STALE_MODULE_PATTERN.source)}, ${JSON.stringify(STALE_MODULE_PATTERN.flags)})
|
|
60
|
+
addEventListener(${JSON.stringify(STALE_MODULE_EVENT)}, reload)
|
|
61
|
+
// Vite rethrows the failure unless the event is cancelled: cancel it only when the page is about to reload.
|
|
62
|
+
addEventListener('vite:preloadError', (event) => { if (reload()) event.preventDefault() })
|
|
63
|
+
addEventListener('unhandledrejection', (event) => { if (stale.test(String(event.reason && event.reason.message || event.reason))) reload() })
|
|
53
64
|
addEventListener('error', (event) => { const el = event.target; if (el && el.tagName === 'SCRIPT' && el.type === 'module') reload() }, true)
|
|
54
65
|
})()`
|
|
55
66
|
|
|
@@ -65,16 +76,16 @@ export function pageHtml(input: PageHtmlInput): string {
|
|
|
65
76
|
`<title>${escapeHtml(input.title)}</title>`,
|
|
66
77
|
...(input.meta ?? []).map(tag => `<meta ${attributes(tag)}>`),
|
|
67
78
|
`<script>${APPEARANCE}</script>`,
|
|
68
|
-
`<script>${
|
|
79
|
+
`<script>${STALE_RELOAD}</script>`,
|
|
69
80
|
`<script type="importmap">${json({ imports })}</script>`,
|
|
70
81
|
...entry.css.map(file => `<link rel="stylesheet" href="${escapeHtml(`${appUrl}${file}`)}">`),
|
|
71
82
|
// A workspace theme's fonts, tokens and styles come after the app's, so that they override them.
|
|
72
83
|
...(config.site?.shell.theme?.stylesheets ?? []).map(url => `<link rel="stylesheet" href="${escapeHtml(url)}">`),
|
|
73
|
-
|
|
84
|
+
// The app's modules, the document's own code (its bundle) and, when given, what its scenes load, all in parallel;
|
|
85
|
+
// each once, though the scenes' modules import the app's.
|
|
86
|
+
...[...new Set([...entry.preload.map(file => `${appUrl}${file}`), ...config.bundle ? [config.bundle] : [], ...input.preload ?? []])].map(url => `<link rel="modulepreload" href="${escapeHtml(url)}">`),
|
|
74
87
|
// The document itself, fetched with the app instead of after it has started.
|
|
75
88
|
...config.ir ? [`<link rel="preload" as="fetch" crossorigin href="${escapeHtml(config.ir)}">`] : [],
|
|
76
|
-
// The document's own code: its bundle and, when given, the chunks its scenes load, in parallel with the app.
|
|
77
|
-
...[...config.bundle ? [config.bundle] : [], ...input.preload ?? []].map(url => `<link rel="modulepreload" href="${escapeHtml(url)}">`),
|
|
78
89
|
`<script type="application/json" id="${APP_CONFIG_ELEMENT}">${json(config)}</script>`,
|
|
79
90
|
`<script type="module" src="${escapeHtml(`${appUrl}${entry.script}`)}"></script>`,
|
|
80
91
|
]
|
package/src/node/site-server.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { Duplex } from 'node:stream'
|
|
|
8
8
|
import { join } from 'node:path'
|
|
9
9
|
import { fileURLToPath } from 'node:url'
|
|
10
10
|
import { stripVTControlCharacters } from 'node:util'
|
|
11
|
-
import { errorMessage, irBlocks, isRecord, localRequestRefusal,
|
|
11
|
+
import { errorMessage, irBlocks, isRecord, localRequestRefusal, siteFrameAncestors, PreviewPreparing, safeDecodeURIComponent, SLIDES_DIR, viewFor } from '@lincyaw/kernel'
|
|
12
12
|
import type { IRDocument, NodeVitePlugin, RenderContext, RuntimeAssets, SiteSpec } from '@lincyaw/kernel'
|
|
13
13
|
import { isInside, spawnInteractiveNode } from '@lincyaw/kernel/node'
|
|
14
14
|
import { diffDocuments } from '@lincyaw/viewer/domain'
|
|
@@ -212,7 +212,7 @@ export async function startSiteServer(options: SiteServerOptions): Promise<SiteS
|
|
|
212
212
|
let closed = false
|
|
213
213
|
const log = options.log ?? ((message: string) => console.warn(`[presmium] ${message}`))
|
|
214
214
|
// The workspace service's pages: only the workbench's origins may frame them, and none when it names none.
|
|
215
|
-
const framed = ctx.preview ?
|
|
215
|
+
const framed = ctx.preview ? siteFrameAncestors(ctx.preview.origins) : undefined
|
|
216
216
|
const latest = new Map<string, IRDocument>()
|
|
217
217
|
|
|
218
218
|
const documents = () => siteOf().documents.map(entry => entry.dir)
|
|
@@ -430,7 +430,7 @@ export async function startSiteServer(options: SiteServerOptions): Promise<SiteS
|
|
|
430
430
|
const linked = isSingle(siteOf()) ? undefined : documentPage(siteOf(), base, docs())
|
|
431
431
|
let cache = state.caches.get(form)
|
|
432
432
|
if (!cache) state.caches.set(form, cache = new PublicationCache())
|
|
433
|
-
const shown = publishedDocument(lang || unfiltered ? doc : viewOf(doc, form), own, join(options.workDir, 'media', segment(dir)), docBase, linked, lang ? undefined : cache)
|
|
433
|
+
const shown = publishedDocument(lang || unfiltered ? doc : viewOf(doc, form), own, join(options.workDir, 'media', segment(dir)), docBase, { documentPage: linked, cache: lang ? undefined : cache })
|
|
434
434
|
const json = JSON.stringify(shown)
|
|
435
435
|
const next = { source: doc, doc: shown, json, revision: irRevisionOfJson(json) }
|
|
436
436
|
state.published.set(key, next)
|
|
@@ -641,7 +641,7 @@ export async function startSiteServer(options: SiteServerOptions): Promise<SiteS
|
|
|
641
641
|
if (doc.config.renderers.slides !== undefined) {
|
|
642
642
|
const lang = first && others.length > 0 && translationOf(dir, first) ? first : ''
|
|
643
643
|
const path = lang ? others.join('/') : rest
|
|
644
|
-
if (path ===
|
|
644
|
+
if (path === SLIDES_DIR || path === `${SLIDES_DIR}/` || path === `${SLIDES_DIR}/index.html`) return documentPageResponse(res, dir, lang, '', 'slides', url.searchParams.get('presmium-block') ?? undefined)
|
|
645
645
|
}
|
|
646
646
|
if (formOf(url) === 'slides' && (rest === '' || rest === 'index.html')) return documentPageResponse(res, dir, '', undefined, 'slides', url.searchParams.get('presmium-block') ?? undefined)
|
|
647
647
|
if (first && others.length > 0 && translationOf(dir, first)) return documentPageResponse(res, dir, first, others.join('/'), formOf(url), url.searchParams.get('presmium-block') ?? undefined)
|
package/src/node/viewer-app.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'
|
|
|
2
2
|
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs'
|
|
3
3
|
import { createRequire } from 'node:module'
|
|
4
4
|
import { homedir } from 'node:os'
|
|
5
|
-
import { dirname, extname, join, relative, sep } from 'node:path'
|
|
5
|
+
import { dirname, extname, join, relative, resolve, sep } from 'node:path'
|
|
6
6
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
7
7
|
import { isRecord } from '@lincyaw/kernel'
|
|
8
8
|
import { findInstalledPackage, FONT_LICENSE, fontLicense } from '@lincyaw/kernel/node'
|
|
@@ -104,24 +104,44 @@ interface BuildManifestChunk {
|
|
|
104
104
|
imports?: string[]
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
for (const key of keys) {
|
|
107
|
+
const buildManifest = (app: ViewerApp) => JSON.parse(readFileSync(join(app.dir, BUILD_MANIFEST), 'utf8')) as Record<string, BuildManifestChunk>
|
|
108
|
+
|
|
109
|
+
/** The chunks `keys` name in the build manifest and those they import at once, with their styles. */
|
|
110
|
+
function staticImports(chunks: Record<string, BuildManifestChunk>, keys: readonly string[] = []): { files: Set<string>, css: Set<string> } {
|
|
111
|
+
const files = new Set<string>()
|
|
112
|
+
const css = new Set<string>()
|
|
113
|
+
const walk = (next: readonly string[] = []) => {
|
|
114
|
+
for (const key of next) {
|
|
116
115
|
const chunk = chunks[key]
|
|
117
|
-
if (!chunk ||
|
|
118
|
-
|
|
116
|
+
if (!chunk || files.has(chunk.file)) continue
|
|
117
|
+
files.add(chunk.file)
|
|
119
118
|
for (const file of chunk.css ?? []) css.add(file)
|
|
120
119
|
walk(chunk.imports)
|
|
121
120
|
}
|
|
122
121
|
}
|
|
123
|
-
walk(
|
|
124
|
-
return {
|
|
122
|
+
walk(keys)
|
|
123
|
+
return { files, css }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The app's entry as Vite's build manifest gives it: its file, its styles and the chunks it imports at once. */
|
|
127
|
+
export function appEntry(app: ViewerApp): AppEntry {
|
|
128
|
+
const chunks = buildManifest(app)
|
|
129
|
+
const main = Object.entries(chunks).find(([source, chunk]) => chunk.isEntry && source.endsWith('main.ts'))?.[1]
|
|
130
|
+
if (!main) throw new Error(`the viewer app at ${app.dir} has no entry in its build manifest`)
|
|
131
|
+
const { files, css } = staticImports(chunks, main.imports)
|
|
132
|
+
return { script: main.file, css: [...new Set([...main.css ?? [], ...css])], preload: [...files] }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The chunks that the app's modules import at once, the modules included, for a page that will load them to preload:
|
|
137
|
+
* `files` are built files (an import map's entries), `sources` the modules' source files (a plugin's viewer module).
|
|
138
|
+
* The app was built in a directory beside the one it is kept in, so its manifest's paths resolve from there too.
|
|
139
|
+
*/
|
|
140
|
+
export function appModuleImports(app: ViewerApp, { files = [], sources = [] }: { files?: readonly string[], sources?: readonly string[] }): string[] {
|
|
141
|
+
const chunks = buildManifest(app)
|
|
142
|
+
const wanted = new Set([...files, ...sources.map(source => resolve(source))])
|
|
143
|
+
const keys = Object.entries(chunks).filter(([key, chunk]) => wanted.has(chunk.file) || wanted.has(resolve(app.dir, key))).map(([key]) => key)
|
|
144
|
+
return [...staticImports(chunks, keys).files]
|
|
125
145
|
}
|
|
126
146
|
|
|
127
147
|
/** Where viewer apps are kept: the user's cache, shared by every workspace and session. */
|
|
@@ -425,6 +445,10 @@ export async function buildViewerApp(cacheDir: string, plugins: readonly AppPlug
|
|
|
425
445
|
entryFileNames: chunk => (chunk.name.startsWith('shared/') ? '[name].js' : 'assets/[name]-[hash].js'),
|
|
426
446
|
chunkFileNames: 'assets/[name]-[hash].js',
|
|
427
447
|
assetFileNames: 'assets/[name]-[hash][extname]',
|
|
448
|
+
// What every page runs first, the app and the modules it imports statically, is one file: split by the
|
|
449
|
+
// entries of the import map that also reach them, it was dozens of small files, each a request before the
|
|
450
|
+
// page can start. What the page loads later (plugins, views, optional parts) stays in chunks of its own.
|
|
451
|
+
codeSplitting: { groups: [{ name: 'app', test: (id: string) => id === APP_MAIN }] },
|
|
428
452
|
},
|
|
429
453
|
},
|
|
430
454
|
},
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { EMBED_MESSAGE } from '@lincyaw/kernel'
|
|
2
|
+
import type { EmbedState } from '@lincyaw/kernel'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* For a site template's own page (`site.landing`) that plays the site's presentations in a frame: fetching one ahead
|
|
6
|
+
* of the reader's click, and knowing when the one in a frame is ready to be seen. A presentation tells the page that
|
|
7
|
+
* embeds it its state (`EMBED_MESSAGE`): whether its scene is drawn, and its lecture's state.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type { EmbedState } from '@lincyaw/kernel'
|
|
11
|
+
/** The site's content, as the page finds it in its `presmium-site-data` script. */
|
|
12
|
+
export type { SiteContent, SiteContentDocument } from '../domain/landing.ts'
|
|
13
|
+
|
|
14
|
+
const prefetched = new Map<string, Promise<void>>()
|
|
15
|
+
// The files already asked for: presentations of one site share their viewer app.
|
|
16
|
+
const files = new Set<string>()
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Prefetches what the presentation's page at `url` preloads (the viewer app, the document's IR and scenes), once, so
|
|
20
|
+
* that it opens without waiting for them. Nothing is fetched when the reader asked to save data.
|
|
21
|
+
*/
|
|
22
|
+
export function prefetchPresentation(url: string): Promise<void> {
|
|
23
|
+
const page = new URL(url, location.href).href
|
|
24
|
+
if ((navigator as Navigator & { connection?: { saveData?: boolean } }).connection?.saveData) return Promise.resolve()
|
|
25
|
+
let done = prefetched.get(page)
|
|
26
|
+
if (!done) {
|
|
27
|
+
done = fetch(page).then(response => (response.ok ? response.text() : '')).then((html) => {
|
|
28
|
+
const links = new DOMParser().parseFromString(html, 'text/html').querySelectorAll<HTMLLinkElement>('link[rel="modulepreload"], link[rel="preload"], link[rel="stylesheet"]')
|
|
29
|
+
for (const link of links) {
|
|
30
|
+
const href = new URL(link.getAttribute('href')!, page).href
|
|
31
|
+
if (files.has(href)) continue
|
|
32
|
+
files.add(href)
|
|
33
|
+
document.head.append(Object.assign(document.createElement('link'), { rel: 'prefetch', href }))
|
|
34
|
+
}
|
|
35
|
+
}).catch(() => {})
|
|
36
|
+
prefetched.set(page, done)
|
|
37
|
+
}
|
|
38
|
+
return done
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A lecture past loading: playing, paused, waiting for the reader's click where autoplay was refused, or failed. */
|
|
42
|
+
const SETTLED = new Set<EmbedState['lecture']>(['waiting', 'playing', 'paused', 'failed'])
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Calls `ready` once, when the presentation in `frame` has drawn its scene and, with `lecture`, its lecture has settled.
|
|
46
|
+
* Returns a function that stops watching.
|
|
47
|
+
*/
|
|
48
|
+
export function whenPresentationReady(frame: HTMLIFrameElement, ready: () => void, { lecture = false }: { lecture?: boolean } = {}): () => void {
|
|
49
|
+
const listen = (event: MessageEvent<Partial<EmbedState>>) => {
|
|
50
|
+
if (event.source !== frame.contentWindow || event.data?.type !== EMBED_MESSAGE) return
|
|
51
|
+
if (!event.data.drawn || (lecture && !SETTLED.has(event.data.lecture))) return
|
|
52
|
+
stop()
|
|
53
|
+
ready()
|
|
54
|
+
}
|
|
55
|
+
const stop = () => removeEventListener('message', listen)
|
|
56
|
+
addEventListener('message', listen)
|
|
57
|
+
return stop
|
|
58
|
+
}
|
package/src/node/optimize.ts
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
2
|
-
import { basename, dirname, extname, join, relative, sep } from 'node:path'
|
|
3
|
-
import sharp from 'sharp'
|
|
4
|
-
import { CODE_DIR } from './site-files.ts'
|
|
5
|
-
import { MEDIA } from './publish.ts'
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Raster images a site publishes (a document's media and the images its scenes import) are encoded again as WebP and
|
|
9
|
-
* no wider than any page shows them, when that saves at least a fifth: a paper's PNG figures and photographs are
|
|
10
|
-
* the largest files most pages load. References in the site's pages, data and scripts follow the new names; files
|
|
11
|
-
* the author publishes as they are (`public/`) are left alone.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
const RASTER = new Set(['.png', '.jpg', '.jpeg'])
|
|
15
|
-
// Below this an image is small enough that a second encoding gains little.
|
|
16
|
-
const MIN_BYTES = 48 * 1024
|
|
17
|
-
// The widest an image is drawn: a full-width figure on a large screen at twice its pixels.
|
|
18
|
-
const MAX_WIDTH = 2400
|
|
19
|
-
const TEXT = new Set(['.html', '.json', '.js', '.css', '.xml', '.vtt'])
|
|
20
|
-
|
|
21
|
-
function files(dir: string): string[] {
|
|
22
|
-
if (!existsSync(dir)) return []
|
|
23
|
-
return readdirSync(dir, { withFileTypes: true }).flatMap(entry => entry.isDirectory() ? files(join(dir, entry.name)) : [join(dir, entry.name)])
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** A published image the build may encode again: in a media directory or among a code bundle's assets. */
|
|
27
|
-
function published(outDir: string, file: string): boolean {
|
|
28
|
-
const parts = relative(outDir, file).split(sep)
|
|
29
|
-
return parts.includes(MEDIA) || parts.some((part, i) => part === CODE_DIR && parts[i + 2] === 'assets')
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export async function optimizeImages(outDir: string): Promise<{ images: number, saved: number }> {
|
|
33
|
-
const all = files(outDir)
|
|
34
|
-
const renamed = new Map<string, string>()
|
|
35
|
-
let saved = 0
|
|
36
|
-
for (const file of all) {
|
|
37
|
-
if (!RASTER.has(extname(file).toLowerCase()) || !published(outDir, file)) continue
|
|
38
|
-
const size = statSync(file).size
|
|
39
|
-
if (size < MIN_BYTES) continue
|
|
40
|
-
const target = join(dirname(file), `${basename(file, extname(file))}.webp`)
|
|
41
|
-
if (existsSync(target)) {
|
|
42
|
-
renamed.set(basename(file), basename(target))
|
|
43
|
-
continue
|
|
44
|
-
}
|
|
45
|
-
const lossless = extname(file).toLowerCase() === '.png'
|
|
46
|
-
const image = sharp(file).rotate().resize({ width: MAX_WIDTH, withoutEnlargement: true })
|
|
47
|
-
// Figures and screenshots keep their sharp edges (near-lossless); photographs take ordinary lossy compression.
|
|
48
|
-
const bytes = await (lossless ? image.webp({ nearLossless: true, quality: 75, effort: 5 }) : image.webp({ quality: 80, effort: 5 })).toBuffer()
|
|
49
|
-
if (bytes.length > size * 0.8) continue
|
|
50
|
-
// The original stays beside it: a reference this pass cannot see (an encoded name) still finds a file.
|
|
51
|
-
writeFileSync(target, bytes)
|
|
52
|
-
renamed.set(basename(file), basename(target))
|
|
53
|
-
saved += size - bytes.length
|
|
54
|
-
}
|
|
55
|
-
if (renamed.size > 0) {
|
|
56
|
-
const names = new RegExp([...renamed.keys()].map(name => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'), 'g')
|
|
57
|
-
for (const file of all) {
|
|
58
|
-
if (!TEXT.has(extname(file)) || !existsSync(file)) continue
|
|
59
|
-
const text = readFileSync(file, 'utf8')
|
|
60
|
-
const next = text.replace(names, name => renamed.get(name)!)
|
|
61
|
-
if (next !== text) writeFileSync(file, next)
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
return { images: renamed.size, saved }
|
|
65
|
-
}
|