@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincyaw/web",
3
- "version": "1.0.0-rc.13",
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
- "send": "1.2.1",
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
- "@lincyaw/design": "1.0.0-rc.13"
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.13",
49
- "@lincyaw/viewer": "^1.0.0-rc.13",
50
- "@lincyaw/kernel": "^1.0.0-rc.13"
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.13",
58
- "@lincyaw/kernel": "1.0.0-rc.13",
59
- "@lincyaw/viewer": "1.0.0-rc.13",
60
- "@lincyaw/host": "1.0.0-rc.13"
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 { componentErrors, componentErrorRevision, presmiumReport, presmiumService, PLAYBACK_CONTROL, PRODUCTION_PLAYBACK, createStore, observe, runtimeRevision } from '@lincyaw/host'
2
- import type { NavigationPosition, PlaybackControl } from '@lincyaw/host'
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
- return { active: isPreviewActive(), embedded: isPreviewEmbedded(), ...events ? { events: events.subscribe } : {}, connect: page => connectWebPreviewFrame(page, options, events) }
36
- }
37
-
38
- function connectWebPreviewFrame(page: WebPreviewPage, options: WebPreviewOptions, events?: PreviewEvents): void {
39
- const { viewer, dark } = page
40
- const navigation = createViewerNavigation(viewer)
41
- const pendingGo = createStore<Parameters<typeof navigation.go>[0] | undefined>(undefined)
42
- // Preview ready can precede async viewer-plugin registration and the lecture list. Keep the latest requested view
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
- runtimeRevision.subscribe(report)
150
- componentErrorRevision.subscribe(report)
151
- report()
40
+ return { active: isPreviewActive(), embedded, ...events ? { events: events.subscribe } : {}, connect }
152
41
  }
@@ -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
- export interface SiteContentDocument { id: string, title: string, href: string, description?: string, lang?: string, tags?: string[], pages?: number }
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, { type: string, moduleIds?: 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
  })
@@ -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
- // A template may link the workspace theme's stylesheets and mark its root with `data-pm-site-theme-name`.
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
  }
@@ -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 function publishedDocument(doc: IRDocument, ctx: RenderContext, publicDir: string, base: string, documentPage?: DocumentPage, cache?: PublicationCache): IRDocument {
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
- copyPublished(asset, media)
112
- return publishedUrl(asset, url, { prefix: `${base}${MEDIA}/` })
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
@@ -1,5 +1,5 @@
1
- import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
- import { basename, dirname, join, resolve } from 'node:path'
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 { optimizeImages } from './optimize.ts'
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
- return { app, appDir: join(ctx.outDir, APP_DIR, key), appUrl: `${ctx.base}${APP_DIR}/${key}/`, server, plugins }
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) writeServiceWorker(out, base, { dir: built.appDir, url: built.appUrl })
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
- await reportOptimized(ctx, await optimizeImages(ctx.outDir))
285
+ reportEncoded(ctx, await images.done())
279
286
  }
280
287
 
281
- async function reportOptimized(ctx: RenderContext, { images, saved }: { images: number, saved: number }): Promise<void> {
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 scene, all known at build time: the document's chunks, the shared
290
- * modules they import (`@lincyaw/addon-motion/scene`) and the animation library their engine loads. Preloaded with the
291
- * app, they arrive together instead of each after the one that names it.
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 scenePreloads(codeDir: string, codeUrl: string, built: Built): string[] {
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
- for (const [specifier, file] of Object.entries(shared)) if (text.includes(`"${specifier}"`) || text.includes(`'${specifier}'`)) urls.push(`${built.appUrl}${file}`)
299
- const assets = existsSync(join(built.appDir, 'assets')) ? readdirSync(join(built.appDir, 'assets')) : []
300
- // The viewer the app opens for the slides, loaded by the app only once it runs.
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 published = publishedDocument(doc, ctx, ctx.outDir, ctx.base)
320
- const { code } = await documentFiles(ctx, built, entry)
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 ? scenePreloads(join(ctx.outDir, CODE_DIR, code), `${ctx.base}${CODE_DIR}/${code}/`, built) : []
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
- await reportOptimized(ctx, await optimizeImages(ctx.outDir))
334
+ reportEncoded(ctx, await images.done())
339
335
  }
@@ -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 const APP_DIR = '_app'
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])
@@ -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
- return { dir, title, ...own ? { lang: own } : {}, ...tags?.length ? { tags } : {}, href: root, ...card }
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
 
@@ -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; at most once in 30 s.
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 STALE = `(() => {
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
- let last = 0
46
- try { last = Number(sessionStorage.getItem(key)) || 0 } catch {}
47
- if (Date.now() - last < 30000) return
48
- try { sessionStorage.setItem(key, String(Date.now())) } catch {}
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
- addEventListener('vite:preloadError', (event) => { event.preventDefault(); reload() })
52
- addEventListener('unhandledrejection', (event) => { if (/dynamically imported module|Importing a module script failed|error loading dynamically imported module/i.test(String(event.reason && event.reason.message || event.reason))) reload() })
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>${STALE}</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
- ...entry.preload.map(file => `<link rel="modulepreload" href="${escapeHtml(`${appUrl}${file}`)}">`),
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
  ]
@@ -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, previewFrameAncestors, PreviewPreparing, safeDecodeURIComponent, viewFor } from '@lincyaw/kernel'
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 ? previewFrameAncestors(ctx.preview.origins) : undefined
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 === 'slides' || path === 'slides/' || path === 'slides/index.html') return documentPageResponse(res, dir, lang, '', 'slides', url.searchParams.get('presmium-block') ?? undefined)
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)
@@ -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
- /** 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) {
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 || preload.has(chunk.file)) continue
118
- preload.add(chunk.file)
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(main.imports)
124
- return { script: main.file, css: [...css], preload: [...preload] }
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
+ }
@@ -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
- }