@lincyaw/web 0.0.0-stage → 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +53 -2
  3. package/package.json +57 -3
  4. package/src/app/base.css +117 -0
  5. package/src/app/block-playback.ts +81 -0
  6. package/src/app/block-report.ts +104 -0
  7. package/src/app/document-modules.ts +30 -0
  8. package/src/app/env.d.ts +12 -0
  9. package/src/app/main.ts +198 -0
  10. package/src/app/page-app.ts +166 -0
  11. package/src/app/router.ts +109 -0
  12. package/src/app/server.ts +32 -0
  13. package/src/app/site/home.tsx +117 -0
  14. package/src/app/site/layout.tsx +78 -0
  15. package/src/app/site/search.tsx +22 -0
  16. package/src/app/site.css +1416 -0
  17. package/src/client/document.css +172 -0
  18. package/src/client/host.ts +22 -0
  19. package/src/client/preview-frame.ts +152 -0
  20. package/src/client/runtime.ts +159 -0
  21. package/src/domain/app-config.ts +89 -0
  22. package/src/domain/config.ts +107 -0
  23. package/src/domain/dev-updates.ts +12 -0
  24. package/src/domain/index.ts +17 -0
  25. package/src/domain/landing.ts +83 -0
  26. package/src/domain/links.ts +67 -0
  27. package/src/domain/messages.ts +116 -0
  28. package/src/domain/pages.ts +81 -0
  29. package/src/domain/themes.ts +59 -0
  30. package/src/domain/workspace-home.ts +12 -0
  31. package/src/node/client-modules.ts +92 -0
  32. package/src/node/document-bundle.ts +227 -0
  33. package/src/node/landing-template.ts +34 -0
  34. package/src/node/landing.ts +82 -0
  35. package/src/node/offline.ts +153 -0
  36. package/src/node/presmium.ts +10 -0
  37. package/src/node/publish.ts +169 -0
  38. package/src/node/renderer.ts +108 -0
  39. package/src/node/site-assets.ts +17 -0
  40. package/src/node/site-build.ts +297 -0
  41. package/src/node/site-files.ts +87 -0
  42. package/src/node/site-model.ts +333 -0
  43. package/src/node/site-page.ts +82 -0
  44. package/src/node/site-server.ts +817 -0
  45. package/src/node/slides.ts +135 -0
  46. package/src/node/viewer-app.ts +504 -0
  47. package/src/node/viewer-build.ts +38 -0
@@ -0,0 +1,172 @@
1
+ /*
2
+ * The typography of a document as the viewer shows it (`.pm-document`, the host of the page view and of the slides):
3
+ * the same on a built site, in a dev server and in the workbench.
4
+ */
5
+
6
+ .pm-document :is(h1, h2, h3, h4, h5, h6) {
7
+ position: relative;
8
+ margin: 0;
9
+ padding: 0;
10
+ border: 0;
11
+ font-weight: 600;
12
+ outline: none;
13
+ overflow-wrap: break-word;
14
+ }
15
+
16
+ .pm-document h1 {
17
+ letter-spacing: -0.02em;
18
+ line-height: 2.5rem;
19
+ font-size: 1.75rem;
20
+ }
21
+
22
+ @media (min-width: 768px) {
23
+ .pm-document h1 {
24
+ font-size: 2rem;
25
+ }
26
+ }
27
+
28
+ .pm-document h2 {
29
+ margin: var(--pm-space-12) 0 var(--pm-space-4);
30
+ padding-top: var(--pm-space-6);
31
+ border-top: var(--pm-size-hairline) solid var(--pm-color-line);
32
+ letter-spacing: -0.02em;
33
+ line-height: 2rem;
34
+ font-size: 1.5rem;
35
+ }
36
+
37
+ .pm-document h3 {
38
+ margin: var(--pm-space-8) 0 0;
39
+ letter-spacing: -0.01em;
40
+ line-height: 1.75rem;
41
+ font-size: 1.25rem;
42
+ }
43
+
44
+ .pm-document h4 {
45
+ margin: var(--pm-space-6) 0 0;
46
+ letter-spacing: -0.01em;
47
+ line-height: 1.5rem;
48
+ font-size: 1.125rem;
49
+ }
50
+
51
+ .pm-document :is(h5, h6) {
52
+ line-height: 1.5rem;
53
+ font-size: 1rem;
54
+ }
55
+
56
+ .pm-document :is(p, summary) {
57
+ margin: var(--pm-space-4) 0;
58
+ }
59
+
60
+ .pm-document p {
61
+ overflow-wrap: break-word;
62
+ line-height: 1.75rem;
63
+ }
64
+
65
+ .pm-document blockquote {
66
+ margin: var(--pm-space-4) 0;
67
+ padding-left: var(--pm-space-4);
68
+ border-left: var(--pm-size-stroke) solid var(--pm-color-line);
69
+ color: var(--pm-color-muted);
70
+ }
71
+
72
+ .pm-document blockquote > p {
73
+ margin: 0;
74
+ font-size: 1rem;
75
+ }
76
+
77
+ .pm-document a {
78
+ color: var(--pm-color-accent);
79
+ font-weight: 500;
80
+ text-decoration: underline;
81
+ text-underline-offset: var(--pm-space-px);
82
+ }
83
+
84
+ .pm-document a:hover {
85
+ color: color-mix(in srgb, var(--pm-color-accent) 88%, var(--pm-color-ink));
86
+ }
87
+
88
+ .pm-document :is(strong, b) {
89
+ font-weight: 600;
90
+ }
91
+
92
+ .pm-document :is(ul, ol) {
93
+ margin: var(--pm-space-4) 0;
94
+ padding-left: 1.25rem;
95
+ }
96
+
97
+ .pm-document ul {
98
+ list-style: disc;
99
+ }
100
+
101
+ .pm-document ol {
102
+ list-style: decimal;
103
+ }
104
+
105
+ .pm-document li {
106
+ overflow-wrap: break-word;
107
+ }
108
+
109
+ .pm-document li + li {
110
+ margin-top: var(--pm-space-2);
111
+ }
112
+
113
+ .pm-document li > :is(ol, ul) {
114
+ margin: var(--pm-space-2) 0 0;
115
+ }
116
+
117
+ .pm-document table {
118
+ display: block;
119
+ margin: var(--pm-space-5) 0;
120
+ overflow-x: auto;
121
+ border-collapse: collapse;
122
+ }
123
+
124
+ .pm-document tr {
125
+ border-top: var(--pm-size-hairline) solid var(--pm-color-line);
126
+ background-color: var(--pm-color-bg);
127
+ }
128
+
129
+ .pm-document tr:nth-child(2n) {
130
+ background-color: var(--pm-color-sunk);
131
+ }
132
+
133
+ .pm-document :is(th, td) {
134
+ padding: var(--pm-space-2) var(--pm-space-4);
135
+ border: var(--pm-size-hairline) solid var(--pm-color-line);
136
+ font-size: var(--pm-text-md);
137
+ }
138
+
139
+ .pm-document th {
140
+ background-color: var(--pm-color-sunk);
141
+ color: var(--pm-color-muted);
142
+ font-weight: 600;
143
+ text-align: left;
144
+ }
145
+
146
+ .pm-document hr {
147
+ margin: var(--pm-space-4) 0;
148
+ border: 0;
149
+ border-top: var(--pm-size-hairline) solid var(--pm-color-line);
150
+ }
151
+
152
+ .pm-document :not(pre) > code {
153
+ padding: var(--pm-space-px) var(--pm-space-1);
154
+ border-radius: var(--pm-radius-chip);
155
+ background-color: var(--pm-color-sunk);
156
+ color: var(--pm-color-ink);
157
+ font-family: var(--pm-font-mono);
158
+ font-size: 0.875em;
159
+ }
160
+
161
+ .pm-document :is(h1, h2, h3) > code {
162
+ font-size: 0.9em;
163
+ }
164
+
165
+ .pm-document a > code {
166
+ color: inherit;
167
+ }
168
+
169
+ .pm-document :is(img, video) {
170
+ max-width: 100%;
171
+ height: auto;
172
+ }
@@ -0,0 +1,22 @@
1
+ import { createStore, presmiumReport, presmiumService, useStore } from '@lincyaw/host'
2
+ import type { HostAdapter, Navigation, NavigationPosition, SharedStates, Store, ViewRole } from '@lincyaw/host'
3
+ import { backSection, followedPlace, isRecord, stepKeys, stepTrail } from '@lincyaw/kernel'
4
+ import type { FollowAnchor, IRSection, PresentationMode, StepKeys } from '@lincyaw/kernel'
5
+ import { findView, useSectionContext } from '@lincyaw/viewer'
6
+ import type { Viewer } from '@lincyaw/viewer'
7
+ import { FINAL_STEP } from '@lincyaw/viewer/domain'
8
+ export interface WebHostAdapterOptions { lang: string, shared: SharedStates, printed?: boolean, isDark(): Store<boolean> }
9
+ function roleOf(view: string): ViewRole { return view === 'presenter' ? 'presenter' : findView(view)?.kind === 'overlay' ? 'audience' : 'reader' }
10
+ export function createWebHostAdapter(viewer: Viewer, options: WebHostAdapterOptions): HostAdapter { return {
11
+ get mode(): PresentationMode { return findView(viewer.view.getSnapshot())?.kind === 'overlay' ? 'slides' : 'document' }, lang: options.lang,
12
+ useDoc() { return useStore(viewer.doc) },
13
+ useCursor() { const section = useSectionContext(); const position = useStore(section ? undefined : viewer.position, viewer.position.getSnapshot()); return section ? { slide: section.section.index, step: section.step } : { slide: viewer.section(position.section)?.index ?? 1, step: position.step } },
14
+ useStep() { return useSectionContext()?.step ?? FINAL_STEP },
15
+ useViewRole() { const section = useSectionContext(); const view = useStore(viewer.view); return roleOf(section?.view ?? view) },
16
+ useStaticRender: () => options.printed === true,
17
+ useSectionConfig(pluginId) { const context = useSectionContext(); const doc = useStore(viewer.doc); const position = useStore(viewer.position); const section = context?.section ?? viewer.section(position.section); const global = doc.config.presmium[pluginId]; return { ...(isRecord(global) ? global : {}), ...section?.config?.[pluginId] } },
18
+ useSharedState(key, initial, scope) { const store = options.shared.sharedState(key, initial, scope); return [useStore(store), value => store.set(value)] },
19
+ sharedStateStore: options.shared.sharedState, getService: presmiumService, startReport: presmiumReport,
20
+ useDarkMode() { const store = options.isDark(); return [useStore(store), value => store.set(value)] },
21
+ } }
22
+ export function createViewerNavigation(viewer: Viewer): Navigation { const keys = new WeakMap<IRSection, StepKeys>(); const keysOf = (section: IRSection) => { let known = keys.get(section); if (!known) keys.set(section, known = stepKeys(section)); return known }; const read = (): NavigationPosition & FollowAnchor => { const { section: id, step, boundary } = viewer.position.getSnapshot(); const section = viewer.section(id); const back = backSection(viewer.doc.getSnapshot().sections, id); return { slide: section?.index ?? 1, step, ...boundary ? { boundary } : {}, view: viewer.view.getSnapshot(), section: id, ...(section ? { trail: stepTrail(keysOf(section), step) } : {}), ...(back ? { back } : {}) } }; const position = createStore(read()); const update = () => position.set(read()); viewer.position.subscribe(update); viewer.view.subscribe(update); viewer.doc.subscribe(update); return { position, go(input: NavigationPosition & FollowAnchor) { const { slide, step, view, boundary, section: id } = input; const target = view === undefined ? undefined : findView(view); if (target && !target.hidden) viewer.setView(target.id); const destination = id === undefined ? { section: viewer.doc.getSnapshot().sections.find(s => s.index === slide)?.id, step } : followedPlace(input, other => { const section = viewer.section(other); return section && keysOf(section) }); if (destination?.section !== undefined) viewer.show({ section: destination.section, step: destination.step, ...boundary ? { boundary } : {} }) } } }
@@ -0,0 +1,152 @@
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'
5
+ import type { Viewer } from '@lincyaw/viewer'
6
+ import { findView, registryRevision } from '@lincyaw/viewer'
7
+ import type { Store } from '@lincyaw/host'
8
+ import { createViewerNavigation } from './host.ts'
9
+
10
+ export interface WebPreviewOptions {
11
+ origins: string[]
12
+ document: string
13
+ /** The form the page shows: its pages, or its slides. */
14
+ renderer?: 'web' | 'slides'
15
+ }
16
+
17
+ export interface WebPreviewPage {
18
+ viewer: Viewer
19
+ dark: Store<boolean>
20
+ setActive(active: boolean): Promise<void>
21
+ }
22
+
23
+ /** What a preview dev server's theme gives the document theme (`ThemeInput.preview`). */
24
+ export interface WebPreview {
25
+ /** False when the workbench paused the preview before the page loaded again. */
26
+ active: boolean
27
+ /** The page is in a workbench: it shows the document as a page, without the site's navigation. */
28
+ embedded: boolean
29
+ events?: PreviewEventSubscription
30
+ connect(page: WebPreviewPage): void
31
+ }
32
+
33
+ export function webPreview(options: WebPreviewOptions): WebPreview {
34
+ 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
148
+ }
149
+ runtimeRevision.subscribe(report)
150
+ componentErrorRevision.subscribe(report)
151
+ report()
152
+ }
@@ -0,0 +1,159 @@
1
+ import { componentErrors, createServerClient, pluginsOf, presmiumReport, provideServer, registerPresmiumPlugin, reportPluginFailure, setHostActive, startHost } from '@lincyaw/host'
2
+ import type { ServerClient, SharedStates, SyncTransports } from '@lincyaw/host'
3
+ import { errorMessage } from '@lincyaw/kernel'
4
+ import type { IRDocument } from '@lincyaw/kernel'
5
+ import { PmComponentFallback, tryInstallViewerPlugin, withSlideGlobals } from '@lincyaw/viewer'
6
+ import type { Viewer } from '@lincyaw/viewer'
7
+ import { createElement as h, useEffect, useState } from 'react'
8
+ import type { ComponentType } from 'react'
9
+ import type { Store } from '@lincyaw/host'
10
+ import { flushSync } from 'react-dom'
11
+ import { createViewerNavigation } from './host.ts'
12
+
13
+ /** The document's runtime, apart from the shell that shows it (the site, the slides page, or the workbench's preview). */
14
+
15
+ export interface ViewerModule {
16
+ name: string
17
+ load: () => Promise<unknown>
18
+ }
19
+
20
+ /** What the runtime starts: the document's browser plugins and viewer plugins, and whether all of them restart safely. */
21
+ export interface RuntimeModules {
22
+ plugins: (() => Promise<unknown>)[]
23
+ viewers?: ViewerModule[]
24
+ restartable?: boolean
25
+ }
26
+
27
+ /** Registers the browser plugins of the document and starts them, unless the runtime was disabled while their modules loaded. */
28
+ export async function startRuntime(loaders: RuntimeModules['plugins'], doc: IRDocument, viewer: Viewer, transports: SyncTransports, server: ServerClient | undefined, enabled: () => boolean): Promise<{ started: boolean, restartable: boolean }> {
29
+ const config = doc.config.presmium
30
+ let failed = false
31
+ const modules = await Promise.all(loaders.map(load => load().catch((error) => {
32
+ failed = true
33
+ console.error('[presmium] a plugin module failed to load', error)
34
+ return {}
35
+ })))
36
+ const plugins = modules.flatMap(module => pluginsOf(module))
37
+ const restartable = !failed && plugins.every(plugin => plugin.restartable === true)
38
+ if (!enabled()) return { started: false, restartable }
39
+ plugins.forEach(registerPresmiumPlugin)
40
+ await startHost({ mode: 'document', config, navigation: createViewerNavigation(viewer), transports, server, environments: doc.config.execute?.environments })
41
+ return { started: true, restartable }
42
+ }
43
+
44
+ /** A component of the document, rendered once mounted (its module may touch the browser), with a placeholder until then. */
45
+ export function clientOnly(name: string, load: () => Promise<unknown>): ComponentType<any> {
46
+ let pending: Promise<ComponentType<any>> | undefined
47
+ return function ClientOnly(props: Record<string, unknown>) {
48
+ const [component, setComponent] = useState<ComponentType<any>>()
49
+ const [error, setError] = useState<unknown>()
50
+ useEffect(() => { let disposed = false; pending ??= load().then(module => withSlideGlobals((module as { default: ComponentType<any> }).default)); pending.then(value => { if (!disposed) setComponent(() => value) }, reason => { if (!disposed) setError(reason) }); return () => { disposed = true } }, [])
51
+ if (error) throw error
52
+ return component ? h(component, props) : h('div', { className: 'pm-client-only', 'data-component': name }, h(PmComponentFallback))
53
+ }
54
+ }
55
+
56
+ export async function loadViewerPlugins(modules: ViewerModule[]): Promise<boolean> {
57
+ let valid = true
58
+ await Promise.all(modules.map(async ({ name, load }) => {
59
+ const id = `${name} (viewer)`
60
+ try {
61
+ const reason = tryInstallViewerPlugin(((await load()) as { default?: unknown }).default, id)
62
+ if (reason) {
63
+ valid = false
64
+ reportPluginFailure(id, reason)
65
+ }
66
+ } catch (error) {
67
+ valid = false
68
+ console.error(`[presmium] ${id} failed to load`, error)
69
+ reportPluginFailure(id, errorMessage(error))
70
+ }
71
+ }))
72
+ return valid
73
+ }
74
+
75
+ export interface RuntimeLifecycleInput {
76
+ modules: RuntimeModules
77
+ /** The document the runtime started with: a pause cannot carry it over a change of configuration or language. */
78
+ doc: IRDocument
79
+ viewer: Viewer
80
+ shared: SharedStates
81
+ /** The document as last fetched, in its own language. */
82
+ original: Store<IRDocument>
83
+ /** Whether the page shows its content; a paused page unmounts it. */
84
+ active: Store<boolean>
85
+ /** The server client of the document, when the page has a server. */
86
+ server?: Parameters<typeof createServerClient>[0]
87
+ /** Fetches the current IR; `force` fails when it cannot. */
88
+ refresh(force?: boolean): Promise<void>
89
+ /** Called once the viewer plugins are loaded (their views may be asked for). */
90
+ onViewers?(ready: Promise<boolean>): void
91
+ }
92
+
93
+ export interface RuntimeLifecycle {
94
+ /** Whether the runtime was last asked to be active. */
95
+ isRequested(): boolean
96
+ setActive(active: boolean): Promise<void>
97
+ }
98
+
99
+ /**
100
+ * Starts and pauses the document's runtime, the browser plugins and the page, as a preview asks: a paused page unmounts and
101
+ * stops its plugins; on resuming it fetches the current IR and restarts them, or fails when they cannot restart safely.
102
+ */
103
+ export function createRuntimeLifecycle({ modules, doc, viewer, shared, original, active, server: serverOptions, refresh, onViewers }: RuntimeLifecycleInput): RuntimeLifecycle {
104
+ let requested = active.getSnapshot()
105
+ let runtimeStarted = false
106
+ let restartable = false
107
+ let viewersLoaded = false
108
+ let viewersValid = false
109
+ let wasPaused = !requested
110
+ let queue = Promise.resolve()
111
+ const runtimeConfig = JSON.stringify(doc.config)
112
+ const pauseMedia = () => {
113
+ for (const media of document.querySelectorAll('audio, video')) (media as HTMLMediaElement).pause()
114
+ }
115
+ const setActive = (on: boolean): Promise<void> => {
116
+ requested = on
117
+ const change = queue.then(async () => {
118
+ if (!on) {
119
+ if (!restartable || componentErrors().length > 0 || presmiumReport()?.plugins.some(plugin => plugin.status !== 'started')) {
120
+ throw new Error('the document runtime cannot safely restart; reload the preview')
121
+ }
122
+ pauseMedia()
123
+ flushSync(() => active.set(false))
124
+ await Promise.resolve()
125
+ await setHostActive(false)
126
+ wasPaused = true
127
+ return
128
+ }
129
+ if (wasPaused) {
130
+ await refresh(true)
131
+ if (JSON.stringify(original.getSnapshot().config) !== runtimeConfig || original.getSnapshot().meta.lang !== doc.meta.lang) {
132
+ throw new Error('the document runtime configuration changed; reload the preview')
133
+ }
134
+ }
135
+ if (!runtimeStarted) {
136
+ if (!viewersLoaded) {
137
+ const ready = loadViewerPlugins(modules.viewers ?? [])
138
+ onViewers?.(ready)
139
+ viewersValid = await ready === true
140
+ viewersLoaded = true
141
+ }
142
+ const server = requested && serverOptions ? createServerClient(serverOptions) : undefined
143
+ if (requested) provideServer(server)
144
+ const runtime = await startRuntime(modules.plugins, viewer.doc.getSnapshot(), viewer, shared.transports, server, () => requested)
145
+ runtimeStarted = runtime.started
146
+ restartable = modules.restartable === true && viewersValid && runtime.restartable
147
+ } else await setHostActive(true)
148
+ wasPaused = false
149
+ // A queued pause checks the completed initial load before disposing anything.
150
+ if (requested) {
151
+ flushSync(() => active.set(true))
152
+ await Promise.resolve()
153
+ }
154
+ })
155
+ queue = change.catch(() => undefined)
156
+ return change
157
+ }
158
+ return { isRequested: () => requested, setActive }
159
+ }
@@ -0,0 +1,89 @@
1
+ import type { CrossFormLinks, NavItem, SiteShell } from './config.ts'
2
+ import type { Neighbour, WebPage } from './pages.ts'
3
+ import type { HomeDocument } from './workspace-home.ts'
4
+ import type { SitePageLayout } from './themes.ts'
5
+ import type { SiteLandingPage } from './landing.ts'
6
+
7
+ /** The element of a page whose JSON tells the viewer app what the page is (`AppConfig`). */
8
+ export const APP_CONFIG_ELEMENT = 'presmium-app'
9
+
10
+ /** A language a document is shown in: its own, or a translation's below `<base><lang>/`. */
11
+ export interface SiteLocale {
12
+ lang: string
13
+ label: string
14
+ /** The site path of the document in this language, ending with `/`. */
15
+ base: string
16
+ /** A translated document can choose a different page layout. */
17
+ layout?: SitePageLayout
18
+ }
19
+
20
+ /** What a page of the web form has around the document: the site's shell. */
21
+ export interface SiteConfig {
22
+ /** The site's title: the document's, or the workspace's on its home page. */
23
+ title: string
24
+ /** The workspace brand and its root, shared by every document's shell. */
25
+ brandTitle?: string
26
+ root?: string
27
+ /** A page's presentation within the selected site theme. */
28
+ layout?: SitePageLayout
29
+ shell: SiteShell
30
+ /** The navigation in this page's language (a translation's own, when the workspace gives one). */
31
+ nav?: NavItem[]
32
+ /** The workspace's list of documents, when the document is one of several. */
33
+ home?: string
34
+ /** The document's languages, its own first; one when it has no translation. */
35
+ locales: SiteLocale[]
36
+ /** The documents before and after this one in the workspace: the pager goes on to them. */
37
+ neighbours?: { prev?: Neighbour, next?: Neighbour }
38
+ links: CrossFormLinks
39
+ }
40
+
41
+ /**
42
+ * What the HTML of a page tells the viewer app (as JSON in `#presmium-app`): the same app shows every page of every
43
+ * document, in a built site, in a dev server and in the workbench, so what a page is comes with the page.
44
+ */
45
+ export interface AppConfig {
46
+ /** A page of a document, the workspace's home page (its list of documents), or a page that does not exist. */
47
+ kind: 'document' | 'home' | 'missing'
48
+ /** The document's directory in the workspace (`''` for the document at its root). */
49
+ document: string
50
+ /** One compiled block in a read-only authoring widget; no live or source-edit transport. */
51
+ block?: string
52
+ /** The document's site path, ending with `/`: its pages, its published files and its plugins' run-time files. */
53
+ base: string
54
+ /** The language of the page; a translation's pages are below `<base><lang>/`. */
55
+ lang: string
56
+ /** The form the page shows: the document's pages (`web`, by default), or its slides alone. */
57
+ form?: 'web' | 'slides'
58
+ /** Where the page's IR is: JSON `{ revision, doc }`. */
59
+ ir?: string
60
+ /** The IR's changes as the dev server compiles them (server-sent events). */
61
+ events?: string
62
+ /** The Presmium server's routes (`<path>__presmium/<plugin>/…`), ending with `/`; `/` when omitted. */
63
+ server?: string
64
+ /** The document's own code (`document-bundle.ts`), when it has any. */
65
+ bundle?: string
66
+ /** The search index of the page's language (MiniSearch's JSON). */
67
+ search?: string
68
+ /** The page shown: `index` or a chapter's path (`webPages`). */
69
+ page?: string
70
+ /** The site's root shows every section of its canonical landing document on one page. */
71
+ landing?: boolean
72
+ /** A translation's pages, at the paths of the original's (`translationPages`); a document's own are its `webPages`. */
73
+ pages?: WebPage[]
74
+ site: SiteConfig
75
+ /** The workspace's documents, on its home page. */
76
+ documents?: HomeDocument[]
77
+ /** An independent website page; it does not use the teaching-document runtime. */
78
+ landingPage?: SiteLandingPage
79
+ /** The plugin packages whose browser parts the page runs: the document's (and the operator's, on a site); the home page's are the operator's. */
80
+ plugins?: string[]
81
+ /** Who may embed the page: the workbench's origins. */
82
+ origins: string[]
83
+ /** Where a dev server opens a source line in the author's editor. */
84
+ source?: string
85
+ /** A built site's offline service worker. */
86
+ serviceWorker?: string
87
+ /** Whether the page was rendered on the server: the app then hydrates it. */
88
+ rendered?: boolean
89
+ }