@lincyaw/web 1.0.0-rc.0 → 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 (58) hide show
  1. package/README.md +33 -4
  2. package/package.json +20 -19
  3. package/src/app/base.css +117 -0
  4. package/src/app/block-playback.ts +81 -0
  5. package/src/app/block-report.ts +104 -0
  6. package/src/app/document-modules.ts +30 -0
  7. package/src/app/env.d.ts +12 -0
  8. package/src/app/main.ts +198 -0
  9. package/src/app/page-app.ts +166 -0
  10. package/src/app/router.ts +109 -0
  11. package/src/app/server.ts +32 -0
  12. package/src/app/site/home.tsx +117 -0
  13. package/src/app/site/layout.tsx +78 -0
  14. package/src/app/site/search.tsx +22 -0
  15. package/src/app/site.css +1416 -0
  16. package/src/client/document.css +172 -0
  17. package/src/client/host.ts +22 -94
  18. package/src/client/preview-frame.ts +152 -0
  19. package/src/client/runtime.ts +159 -0
  20. package/src/domain/app-config.ts +89 -0
  21. package/src/domain/config.ts +17 -9
  22. package/src/domain/dev-updates.ts +12 -0
  23. package/src/domain/index.ts +17 -7
  24. package/src/domain/landing.ts +83 -0
  25. package/src/domain/links.ts +23 -18
  26. package/src/domain/messages.ts +74 -63
  27. package/src/domain/pages.ts +12 -39
  28. package/src/domain/themes.ts +59 -0
  29. package/src/domain/workspace-home.ts +12 -0
  30. package/src/node/client-modules.ts +92 -0
  31. package/src/node/document-bundle.ts +227 -0
  32. package/src/node/landing-template.ts +34 -0
  33. package/src/node/landing.ts +82 -0
  34. package/src/node/offline.ts +34 -11
  35. package/src/node/publish.ts +83 -15
  36. package/src/node/renderer.ts +78 -117
  37. package/src/node/site-assets.ts +17 -0
  38. package/src/node/site-build.ts +297 -0
  39. package/src/node/site-files.ts +87 -0
  40. package/src/node/site-model.ts +333 -0
  41. package/src/node/site-page.ts +82 -0
  42. package/src/node/site-server.ts +817 -0
  43. package/src/node/slides.ts +135 -0
  44. package/src/node/viewer-app.ts +504 -0
  45. package/src/node/viewer-build.ts +38 -0
  46. package/custom-nav-controls.vue +0 -17
  47. package/src/client/env.d.ts +0 -1
  48. package/src/client/home.ts +0 -182
  49. package/src/client/index.ts +0 -4
  50. package/src/client/page.ts +0 -38
  51. package/src/client/preview.ts +0 -71
  52. package/src/client/style.css +0 -427
  53. package/src/client/theme.ts +0 -322
  54. package/src/node/addons.ts +0 -76
  55. package/src/node/site.ts +0 -349
  56. package/src/node/sites.ts +0 -531
  57. package/src/node/vite.ts +0 -166
  58. package/src/node/vitepress.ts +0 -72
@@ -0,0 +1,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
+ }
@@ -1,94 +1,22 @@
1
- import { presmiumReport, presmiumService } from '@lincyaw/host'
2
- import type { HostAdapter, Navigation, NavigationPosition, SharedStates, ViewRole } from '@lincyaw/host'
3
- import { isRecord } from '@lincyaw/kernel'
4
- import type { IRSection, PresentationMode } from '@lincyaw/kernel'
5
- import { backSection, FINAL_STEP, followedPlace, getView, stepKeys, stepTrail, useSectionContext } from '@lincyaw/viewer'
6
- import type { FollowAnchor, StepKeys, Viewer } from '@lincyaw/viewer'
7
- import { useData } from 'vitepress'
8
- import { computed, readonly, ref } from 'vue'
9
-
10
- export interface ViewerHostOptions {
11
- lang: string
12
- shared: SharedStates
13
- }
14
-
15
- function roleOf(view: string): ViewRole {
16
- return view === 'presenter' ? 'presenter' : getView(view)?.kind === 'overlay' ? 'audience' : 'reader'
17
- }
18
-
19
- export function createViewerHost(viewer: Viewer, options: ViewerHostOptions): HostAdapter {
20
- const overlay = () => getView(viewer.view.value)?.kind === 'overlay'
21
- const role = computed(() => roleOf(viewer.view.value))
22
- const staticRender = readonly(ref(false))
23
- return {
24
- get mode(): PresentationMode {
25
- return overlay() ? 'slides' : 'document'
26
- },
27
- lang: options.lang,
28
- useCursor() {
29
- const section = useSectionContext()
30
- return computed(() => {
31
- if (section) return { slide: section.section.value.index, step: section.step.value }
32
- const { position } = viewer
33
- return { slide: viewer.section(position.value.section)?.index ?? 1, step: position.value.step }
34
- })
35
- },
36
- useStep() {
37
- const section = useSectionContext()
38
- return computed(() => section?.step.value ?? FINAL_STEP)
39
- },
40
- useViewRole() {
41
- const section = useSectionContext()
42
- return section ? computed(() => roleOf(section.view.value)) : role
43
- },
44
- useStaticRender: () => staticRender,
45
- useSectionConfig(pluginId) {
46
- const context = useSectionContext()
47
- return computed(() => {
48
- const section = context?.section.value ?? viewer.section(viewer.position.value.section)
49
- const global = viewer.doc.value.config.presmium[pluginId]
50
- return { ...(isRecord(global) ? global : {}), ...section?.config?.[pluginId] }
51
- })
52
- },
53
- useSharedState: options.shared.sharedState,
54
- getService: presmiumService,
55
- startReport: presmiumReport,
56
- useDarkMode: () => useData().isDark,
57
- }
58
- }
59
-
60
- export function createViewerNavigation(viewer: Viewer): Navigation {
61
- const keys = new WeakMap<IRSection, StepKeys>()
62
- const keysOf = (section: IRSection) => {
63
- let known = keys.get(section)
64
- if (!known) keys.set(section, known = stepKeys(section))
65
- return known
66
- }
67
- return {
68
- position: computed((): NavigationPosition & FollowAnchor => {
69
- const { section: id, step } = viewer.position.value
70
- const section = viewer.section(id)
71
- const back = backSection(viewer.doc.value.sections, id)
72
- return {
73
- slide: section?.index ?? 1,
74
- step,
75
- view: viewer.view.value,
76
- section: id,
77
- ...section ? { trail: stepTrail(keysOf(section), step) } : {},
78
- ...back ? { back } : {},
79
- }
80
- }),
81
- go(position: NavigationPosition & FollowAnchor) {
82
- const { slide, step, view, section: id } = position
83
- const target = view === undefined ? undefined : getView(view)
84
- if (target && !target.hidden) viewer.setView(target.id)
85
- const place = id === undefined
86
- ? { section: viewer.doc.value.sections.find(s => s.index === slide)?.id, step }
87
- : followedPlace(position, other => {
88
- const section = viewer.section(other)
89
- return section && keysOf(section)
90
- })
91
- if (place?.section !== undefined) viewer.show({ section: place.section, step: place.step })
92
- },
93
- }
94
- }
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
+ }