@lincyaw/web 1.0.0-rc.0 → 1.0.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +33 -4
  2. package/package.json +20 -19
  3. package/src/app/base.css +117 -0
  4. package/src/app/block-playback.ts +81 -0
  5. package/src/app/block-report.ts +104 -0
  6. package/src/app/document-modules.ts +30 -0
  7. package/src/app/env.d.ts +12 -0
  8. package/src/app/main.ts +198 -0
  9. package/src/app/page-app.ts +166 -0
  10. package/src/app/router.ts +109 -0
  11. package/src/app/server.ts +32 -0
  12. package/src/app/site/home.tsx +117 -0
  13. package/src/app/site/layout.tsx +92 -0
  14. package/src/app/site/search.tsx +22 -0
  15. package/src/app/site.css +1450 -0
  16. package/src/client/document.css +172 -0
  17. package/src/client/host.ts +22 -94
  18. package/src/client/preview-frame.ts +152 -0
  19. package/src/client/runtime.ts +159 -0
  20. package/src/domain/app-config.ts +91 -0
  21. package/src/domain/config.ts +17 -9
  22. package/src/domain/dev-updates.ts +12 -0
  23. package/src/domain/index.ts +17 -7
  24. package/src/domain/landing.ts +83 -0
  25. package/src/domain/links.ts +23 -18
  26. package/src/domain/messages.ts +74 -63
  27. package/src/domain/pages.ts +23 -41
  28. package/src/domain/themes.ts +59 -0
  29. package/src/domain/workspace-home.ts +12 -0
  30. package/src/node/client-modules.ts +92 -0
  31. package/src/node/document-bundle.ts +227 -0
  32. package/src/node/landing-template.ts +34 -0
  33. package/src/node/landing.ts +82 -0
  34. package/src/node/offline.ts +34 -11
  35. package/src/node/publish.ts +83 -15
  36. package/src/node/renderer.ts +78 -117
  37. package/src/node/site-assets.ts +17 -0
  38. package/src/node/site-build.ts +304 -0
  39. package/src/node/site-files.ts +87 -0
  40. package/src/node/site-model.ts +337 -0
  41. package/src/node/site-page.ts +82 -0
  42. package/src/node/site-server.ts +822 -0
  43. package/src/node/slides.ts +135 -0
  44. package/src/node/viewer-app.ts +504 -0
  45. package/src/node/viewer-build.ts +38 -0
  46. package/custom-nav-controls.vue +0 -17
  47. package/src/client/env.d.ts +0 -1
  48. package/src/client/home.ts +0 -182
  49. package/src/client/index.ts +0 -4
  50. package/src/client/page.ts +0 -38
  51. package/src/client/preview.ts +0 -71
  52. package/src/client/style.css +0 -427
  53. package/src/client/theme.ts +0 -322
  54. package/src/node/addons.ts +0 -76
  55. package/src/node/site.ts +0 -349
  56. package/src/node/sites.ts +0 -531
  57. package/src/node/vite.ts +0 -166
  58. package/src/node/vitepress.ts +0 -72
@@ -0,0 +1,198 @@
1
+ import { currentLocale, OPERATIONS, pluginsOf, presmiumService, registerPresmiumPlugin, startHost, createStore, observe, runtimeRevision } from '@lincyaw/host'
2
+ import { previewAppearance } from '@lincyaw/kernel'
3
+ import type { IRDocument } from '@lincyaw/kernel'
4
+ import { findView, syncViewerWithOperations } from '@lincyaw/viewer'
5
+ import type { LiveUpdate } from '@lincyaw/viewer/domain'
6
+ import { packages } from 'virtual:presmium-app/registry'
7
+ import { APP_CONFIG_ELEMENT, parsePositionQuery, parsePrintQuery, UPDATE_EVENT, WEB_MESSAGES, withPositionQuery } from '../domain/index.ts'
8
+ import type { AppConfig, DevUpdateEvent } from '../domain/index.ts'
9
+ import { webPreview } from '../client/preview-frame.ts'
10
+ import { createRuntimeLifecycle } from '../client/runtime.ts'
11
+ import type { ViewerModule } from '../client/runtime.ts'
12
+ import { setDocumentModules } from './document-modules.ts'
13
+ import { createPageApp } from './page-app.ts'
14
+ import { blockPreviewReporter } from './block-report.ts'
15
+ import { forgetIndex } from './site/search.tsx'
16
+ import './base.css'
17
+ import './site.css'
18
+
19
+ /**
20
+ * The viewer app in the browser: the web form's runtime, prebuilt once for a set of plugins, as a PDF viewer is for every
21
+ * PDF. The page it runs in says what to show (`AppConfig`): a document's page, with its IR and its own code from its
22
+ * bundle; the workspace's home page; or a missing page. A built page was rendered on the server, and is hydrated here.
23
+ */
24
+
25
+ /** The document's own code (`document-bundle.ts`). */
26
+ interface DocumentBundle {
27
+ components?: Record<string, () => Promise<unknown>>
28
+ plugins?: (() => Promise<unknown>)[]
29
+ viewers?: ViewerModule[]
30
+ documentModules?: Record<string, unknown>
31
+ restartable?: boolean
32
+ }
33
+
34
+ interface IrAnswer {
35
+ /** The IR's revision, as a dev server gives it; a built site's IR changes with the site. */
36
+ revision?: string
37
+ /** What the server calls this version of the IR (`ETag`), to ask for it again only when it changed. */
38
+ etag?: string
39
+ doc: IRDocument
40
+ }
41
+
42
+ // The header of a dev server's IR that holds its revision (`site-files.ts`).
43
+ const REVISION_HEADER = 'Presmium-Revision'
44
+
45
+ const IN_WORKBENCH = 'pm-in-workbench'
46
+
47
+ const config = JSON.parse(document.getElementById(APP_CONFIG_ELEMENT)!.textContent!) as AppConfig
48
+
49
+ async function fetchIr(url: string, etag?: string): Promise<IrAnswer | undefined> {
50
+ const response = await fetch(url, { cache: 'no-store', ...etag ? { headers: { 'If-None-Match': etag } } : {} })
51
+ if (response.status === 304) return undefined
52
+ if (!response.ok) throw new Error(`the document could not be read (${response.status})`)
53
+ return { revision: response.headers.get(REVISION_HEADER) ?? undefined, etag: response.headers.get('ETag') ?? undefined, doc: await response.json() as IRDocument }
54
+ }
55
+
56
+ /** The browser parts of the plugin packages the page runs, as the app was built with them. */
57
+ function used(names: readonly string[]) {
58
+ return names.flatMap(name => packages[name] ? [[name, packages[name]!] as const] : [])
59
+ }
60
+
61
+ /** Whether the page is dark: as the workbench loaded it, as the reader chose (the head read it), or as the system is. */
62
+ function isDark(): boolean {
63
+ const asked = previewAppearance()
64
+ return asked ? asked === 'dark' : document.documentElement.classList.contains('dark')
65
+ }
66
+
67
+ function registerServiceWorker(url: string): void {
68
+ if (!('serviceWorker' in navigator)) return
69
+ const register = () => navigator.serviceWorker.register(url).catch((error: unknown) => {
70
+ console.warn('[presmium] offline support unavailable', error)
71
+ })
72
+ if (document.readyState === 'complete') void register()
73
+ else window.addEventListener('load', register, { once: true })
74
+ }
75
+
76
+ /** A page that shows no document: its plugins (the operator's) run with no position. */
77
+ async function bootPage(): Promise<void> {
78
+ // A rendered page is hydrated as it was rendered, light; its appearance follows once it is the browser's.
79
+ const dark = createStore(config.rendered ? false : isDark())
80
+ const { app, shared } = createPageApp({ config, components: {}, embedded: false, ssr: config.rendered === true, dark })
81
+ observe(dark, on => document.documentElement.classList.toggle('dark', on))
82
+ await app.mount('#app')
83
+ dark.set(isDark())
84
+ const modules = await Promise.all(used(config.plugins ?? []).flatMap(([, own]) => own.plugins).map(load => load().catch((error: unknown) => {
85
+ console.error('[presmium] a plugin module failed to load', error)
86
+ return {}
87
+ })))
88
+ modules.flatMap(module => pluginsOf(module)).forEach(registerPresmiumPlugin)
89
+ await startHost({ mode: 'document', config: {}, navigation: { position: createStore({ slide: 1, step: 0 }), go: () => {} }, transports: shared.transports })
90
+ }
91
+
92
+ async function bootDocument(): Promise<void> {
93
+ const [answer, bundle] = await Promise.all([fetchIr(config.ir!), config.bundle ? import(/* @vite-ignore */ config.bundle) as Promise<DocumentBundle> : Promise.resolve({} as DocumentBundle)])
94
+ const first = answer!
95
+ let { revision, etag } = first
96
+ setDocumentModules(bundle.documentModules ?? {})
97
+ const doc = first.doc
98
+ document.documentElement.lang = config.lang
99
+ if (config.page === undefined) document.title = doc.meta.title
100
+
101
+ // The plugins' components, then the document's own; its plugins, then its own.
102
+ const own = used(config.plugins ?? doc.config.plugins)
103
+ const components: Record<string, () => Promise<unknown>> = {}
104
+ for (const [, pkg] of own) for (const [name, load] of Object.entries(pkg.components)) components[name] ??= load
105
+ Object.assign(components, bundle.components ?? {})
106
+ const viewers: ViewerModule[] = [...own.flatMap(([, pkg]) => pkg.viewers.map(load => ({ name: pkg.name, load }))), ...bundle.viewers ?? []]
107
+ const plugins = [...own.flatMap(([, pkg]) => pkg.plugins), ...bundle.plugins ?? []]
108
+ const restartable = own.every(([, pkg]) => pkg.restartable) && bundle.restartable !== false
109
+
110
+ const preview = config.block ? undefined : webPreview({ origins: config.origins, document: config.document, renderer: config.form ?? 'web' })
111
+ document.documentElement.classList.toggle(IN_WORKBENCH, preview?.embedded ?? false)
112
+ document.documentElement.classList.toggle('pm-block-document', config.block !== undefined)
113
+ const dark = createStore(config.rendered ? false : isDark())
114
+ observe(dark, on => document.documentElement.classList.toggle('dark', on))
115
+ // A slides page asked to print its slides: an export.
116
+ const print = config.form === 'slides' ? parsePrintQuery(location.search) : undefined
117
+ const { app, viewer, router, shared, active } = createPageApp({ config, doc, components, embedded: preview?.embedded ?? false, ssr: config.rendered === true, dark, print })
118
+ active.set(preview?.active ?? true)
119
+ const original = createStore(doc)
120
+ const refresh = async (force = false) => {
121
+ try {
122
+ const next = await fetchIr(config.ir!, etag)
123
+ if (!next || (next.revision !== undefined && next.revision === revision)) return
124
+ revision = next.revision
125
+ etag = next.etag
126
+ original.set(next.doc)
127
+ viewer!.replace(next.doc)
128
+ } catch (error) {
129
+ console.warn('[presmium] could not get the current document', error)
130
+ if (force) throw error
131
+ }
132
+ }
133
+ let viewersReady: Promise<unknown> = Promise.resolve()
134
+ const lifecycle = createRuntimeLifecycle({ modules: { plugins, viewers, restartable }, doc, viewer: viewer!, shared, original, active, ...!config.block && config.events ? { server: { document: config.document, root: config.server, subscribeEvents: preview?.events } } : {}, refresh, onViewers: (ready) => {
135
+ viewersReady = ready
136
+ } })
137
+ let stopOperations: (() => void) | undefined
138
+ if (!config.block) observe(runtimeRevision, () => { stopOperations?.(); const operations = presmiumService(OPERATIONS); stopOperations = operations ? syncViewerWithOperations(viewer!, operations) : undefined }, true)
139
+ await app.mount('#app')
140
+ dark.set(isDark())
141
+ preview?.connect({ viewer: viewer!, dark, setActive: lifecycle.setActive })
142
+ router?.listen()
143
+
144
+ // The dev server's changes: the sections that changed when the page has the revision they change, else the whole IR.
145
+ if (!config.block && config.events) {
146
+ const updated = (data: string) => {
147
+ if (config.search) forgetIndex(config.search)
148
+ if (!lifecycle.isRequested()) return
149
+ const change = JSON.parse(data) as DevUpdateEvent<LiveUpdate>
150
+ if (change.update && change.base === revision) {
151
+ revision = change.revision
152
+ etag = `"${change.revision}"`
153
+ viewer!.update(change.update)
154
+ original.set(viewer!.doc.getSnapshot())
155
+ } else void refresh()
156
+ }
157
+ const opened = () => void (lifecycle.isRequested() && refresh())
158
+ if (preview?.events) {
159
+ const address = new URL(config.events, location.href)
160
+ preview.events({ topic: 'viewer-site', document: config.document ?? '', app: address.searchParams.get('app') ?? '', lang: address.searchParams.get('lang') ?? '', form: address.searchParams.get('form') === 'slides' ? 'slides' : 'web' }, message => {
161
+ if (message.type === 'open') opened()
162
+ else if (message.type === 'event' && message.event === UPDATE_EVENT) updated(message.data)
163
+ else if (message.type === 'event' && message.event === 'reload') location.reload()
164
+ })
165
+ } else {
166
+ const events = new EventSource(config.events)
167
+ events.addEventListener(UPDATE_EVENT, (message: MessageEvent<string>) => updated(message.data))
168
+ events.addEventListener('reload', () => location.reload())
169
+ events.addEventListener('open', opened)
170
+ }
171
+ }
172
+ if (config.block) {
173
+ const reporter = blockPreviewReporter(config, doc, viewer!)
174
+ try { if (lifecycle.isRequested()) await lifecycle.setActive(true); await reporter.ready() }
175
+ catch (error) { reporter.failed(); console.error('[presmium] the block runtime did not start', error) }
176
+ return
177
+ }
178
+ if (lifecycle.isRequested()) await lifecycle.setActive(true).catch(error => console.error('[presmium] the document runtime did not start', error))
179
+ if (config.serviceWorker) registerServiceWorker(config.serviceWorker)
180
+
181
+ // The place and the view the address asks for (`?view=present&section=…`); while an overlay view is shown its place
182
+ // stays in the address, so that a reload comes back to it.
183
+ const asked = parsePositionQuery(location.search, doc)
184
+ if (asked.section) viewer!.show({ section: asked.section, step: asked.step ?? 0 })
185
+ if (asked.view) {
186
+ await viewersReady
187
+ if (findView(asked.view)) viewer!.setView(asked.view)
188
+ }
189
+ const syncAddress = () => { const view = viewer!.view.getSnapshot(); const position = viewer!.position.getSnapshot(); const search = withPositionQuery(location.search, findView(view)?.kind === 'overlay' ? { view, ...position } : undefined); if (search !== location.search) history.replaceState(history.state, '', location.pathname + search + location.hash) }
190
+ viewer!.view.subscribe(syncAddress)
191
+ viewer!.position.subscribe(syncAddress)
192
+ syncAddress()
193
+ }
194
+
195
+ void (config.kind === 'document' ? bootDocument() : bootPage()).catch((error: unknown) => {
196
+ console.error('[presmium] the viewer did not start', error)
197
+ document.getElementById('app')!.textContent = WEB_MESSAGES[currentLocale()].startFailed
198
+ })
@@ -0,0 +1,166 @@
1
+ import { createSharedStates, installHost, HostProvider, createStore, useStore, presmiumReport, presmiumService, setSiteBase } from '@lincyaw/host'
2
+ import type { HostAdapter, SharedStates, Store } from '@lincyaw/host'
3
+ import { deckLocale, irBlocks, PREVIEW_KEYS_PARAM, PREVIEW_PRESENTATION_PARAM, reportPreview } from '@lincyaw/kernel'
4
+ import type { IRDocument } from '@lincyaw/kernel'
5
+ import { createViewer, ViewerProvider, BlockPreview, OverlayViewOutlet, PageViewOutlet, registerComponent, SlidesPrint } from '@lincyaw/viewer'
6
+ import type { Viewer } from '@lincyaw/viewer'
7
+ import { FINAL_STEP } from '@lincyaw/viewer/domain'
8
+ import { createElement as h, useEffect } from 'react'
9
+ import type { ReactElement } from 'react'
10
+ import { createRoot, hydrateRoot } from 'react-dom/client'
11
+ import { positionUrl, WEB_MESSAGES, withPositionQuery } from '../domain/index.ts'
12
+ import type { AppConfig, SlidesPrintQuery } from '../domain/index.ts'
13
+ import { createWebHostAdapter } from '../client/host.ts'
14
+ import { clientOnly } from '../client/runtime.ts'
15
+ import { createPageRouter } from './router.ts'
16
+ import type { PageRouter } from './router.ts'
17
+ import { MissingPage, WorkspaceHome } from './site/home.tsx'
18
+ import { DocumentSite } from './site/layout.tsx'
19
+
20
+ /**
21
+ * A page of the web form as a React app: a document's page (on its site, or alone on the workbench's canvas), the
22
+ * workspace's home page, or a missing page. The browser mounts it (`main.ts`), a build renders it to HTML first
23
+ * (`server.ts`), and the browser then hydrates that HTML.
24
+ */
25
+
26
+ export interface PageAppInput {
27
+ config: AppConfig
28
+ /** The document of a document's page. */
29
+ doc?: IRDocument
30
+ /** The components the document can show, by name, loaded once shown. */
31
+ components: Record<string, () => Promise<unknown>>
32
+ /** A workbench's preview: the document alone, as a page on the editor's canvas. */
33
+ embedded: boolean
34
+ /** Rendered on the server, or hydrating what it rendered. */
35
+ ssr: boolean
36
+ /** Whether the page is dark. */
37
+ dark: Store<boolean>
38
+ /** The slides printed one after the other (an export). */
39
+ print?: SlidesPrintQuery
40
+ }
41
+
42
+ export interface PageApp {
43
+ app: { element: ReactElement, mount(selector: string): Promise<void> }
44
+ shared: SharedStates
45
+ /** Whether the page shows its content: a paused preview shows none. */
46
+ active: Store<boolean>
47
+ viewer?: Viewer
48
+ router?: PageRouter
49
+ }
50
+
51
+ /** The host of a page that shows no document: no position, no steps. */
52
+ function pageHostAdapter(lang: string, shared: SharedStates, dark: Store<boolean>): HostAdapter {
53
+ return { mode: 'document', lang, useCursor: () => ({ slide: 1, step: 0 }), useStep: () => FINAL_STEP, useViewRole: () => 'reader', useStaticRender: () => false, useSectionConfig: () => ({}), useSharedState(key, initial, scope) { const store = shared.sharedState(key, initial, scope); return [useStore(store), value => store.set(value)] }, sharedStateStore: shared.sharedState, getService: presmiumService, startReport: presmiumReport, useDarkMode: () => [useStore(dark), value => dark.set(value)] }
54
+ }
55
+ function pageRoot(element: ReactElement, hydrate: boolean) { return { element, mount(selector: string): Promise<void> { return new Promise(resolve => { function Mounted() { useEffect(() => { resolve() }, []); return element }; const root = document.querySelector(selector)!; if (hydrate) hydrateRoot(root, h(Mounted)); else createRoot(root).render(h(Mounted)) }) } } }
56
+ function PreviewSheet({ viewer }: { viewer: Viewer }) { const doc = useStore(viewer.doc); return h('div', { className: 'pm-sheet-canvas' }, h('article', { className: 'pm-sheet' }, h(PageViewOutlet, { sections: doc.sections.map(section => section.id) })), h('div', { className: 'pm-document pm-overlay-views' }, h(OverlayViewOutlet))) }
57
+
58
+ function slideIcon() {
59
+ return h('svg', { 'className': 'pm-ui-icon', 'viewBox': '0 0 24 24', 'aria-hidden': 'true', 'fill': 'none', 'stroke': 'currentColor', 'strokeWidth': 2, 'strokeLinecap': 'round', 'strokeLinejoin': 'round' }, [
60
+ h('rect', { x: 2, y: 3, width: 20, height: 14, rx: 2 }),
61
+ h('path', { d: 'M8 21h8M12 17v4' }),
62
+ ])
63
+ }
64
+
65
+ /** Where the document's slides are, when it has them: the address it names, or the `slides/` beside its web form. */
66
+ function slidesBase(config: AppConfig): string | undefined {
67
+ const { links } = config.site
68
+ if (!links.slides) return undefined
69
+ return links.slidesUrl ?? new URL('../slides', new URL(config.base, 'http://site')).pathname
70
+ }
71
+
72
+ /** Where the document's web form is, when it has one: the address it names, or the `web/` beside its slides. */
73
+ function webBase(config: AppConfig): string | undefined {
74
+ const { links } = config.site
75
+ if (!links.web) return undefined
76
+ return links.webUrl ?? new URL('../web', new URL(config.base, 'http://site')).pathname
77
+ }
78
+
79
+ export function createPageApp(input: PageAppInput): PageApp {
80
+ const { config, embedded, dark } = input
81
+ setSiteBase(config.base)
82
+ const shared = createSharedStates(input.doc ? input.doc.meta.title : `${config.site.title} (home)`)
83
+ const active = createStore(true)
84
+ if (!input.doc || config.kind !== 'document') {
85
+ const Root = config.kind === 'missing' ? MissingPage : WorkspaceHome
86
+ const adapter = pageHostAdapter(config.lang, shared, dark)
87
+ installHost(adapter)
88
+ const app = pageRoot(h(HostProvider, { adapter }, h(Root, { config })), input.ssr)
89
+ return { app, shared, active }
90
+ }
91
+
92
+ const initial = input.doc
93
+ const slides = !config.block && config.form === 'slides'
94
+ // The router pages the document as the viewer has it now (a dev server's changes come to the viewer).
95
+ let current: Viewer | undefined
96
+ const router = config.block ? undefined : createPageRouter(config, () => current?.doc.getSnapshot() ?? initial, !embedded)
97
+ const slidesUrl = embedded || slides ? undefined : slidesBase(config)
98
+ const webUrl = embedded || !slides ? undefined : webBase(config)
99
+ const t = WEB_MESSAGES[deckLocale(config.lang)]
100
+ const viewer = current = createViewer(initial, {
101
+ renderer: 'web',
102
+ embedded,
103
+ externalControls: embedded && typeof location !== 'undefined' && new URL(location.href).searchParams.get(PREVIEW_KEYS_PARAM) === 'playback',
104
+ ...embedded && !config.block && typeof location !== 'undefined' && new URL(location.href).searchParams.get(PREVIEW_PRESENTATION_PARAM) === 'confirmed' ? { confirmedRevision: createStore<string | undefined>(undefined) } : {},
105
+ ...embedded && !config.block ? { refreshPresentation: () => reportPreview({ type: 'presentation-action', action: 'refresh' }), exitPresentation: () => reportPreview({ type: 'presentation-action', action: 'exit' }) } : {},
106
+ documentIdentity: JSON.stringify([config.base, config.document]),
107
+ editingActive: createStore(true),
108
+ sectionHref: router?.sectionHref,
109
+ slidesOnly: slides,
110
+ ...webUrl
111
+ ? {
112
+ pageLink: (position: { section: string, step: number }) => {
113
+ const index = (current?.doc.getSnapshot() ?? initial).sections.findIndex(s => s.id === position.section) + 1
114
+ return { href: positionUrl(webUrl, Math.max(index, 1), position.step), label: t.readOnWeb }
115
+ },
116
+ }
117
+ : {},
118
+ sectionHeading: slidesUrl
119
+ ? (section, step) => h('a', {
120
+ 'className': 'pm-open-slides',
121
+ 'href': positionUrl(slidesUrl, section.index, step),
122
+ 'title': t.openInSlides,
123
+ 'aria-label': t.openInSlides,
124
+ 'data-label': t.slides,
125
+ }, [slideIcon()])
126
+ : undefined,
127
+ diagnostics: config.events !== undefined,
128
+ navigate: embedded || config.block ? undefined : position => router!.turnTo(position, viewer.restoring.getSnapshot()),
129
+ ...config.source
130
+ ? { openSource: source => void fetch(`${config.source}?file=${encodeURIComponent(source.file)}&line=${source.line}`, { method: 'POST', headers: { 'Presmium-Request': 'source' } }) }
131
+ : {},
132
+ ...embedded || config.block
133
+ ? {}
134
+ : {
135
+ openPresenter(position) {
136
+ const url = new URL(location.href)
137
+ url.hash = ''
138
+ url.search = withPositionQuery(url.search, { view: 'presenter', ...position })
139
+ window.open(url.href, 'presmium-presenter', 'popup,width=1100,height=720')
140
+ },
141
+ },
142
+ })
143
+ if (router) viewer.doc.subscribe(() => router.refresh())
144
+ // The slides alone: the presentation, which a slides page opens on (or every slide, printed).
145
+ if (slides && !input.print) viewer.setView('present')
146
+ const print = input.print
147
+ const adapter = createWebHostAdapter(viewer, { lang: config.lang, shared, isDark: () => dark, printed: print !== undefined })
148
+ installHost(adapter)
149
+ function Content() {
150
+ const doc = useStore(viewer.doc)
151
+ if (!useStore(active)) return null
152
+ if (config.block) {
153
+ for (const section of doc.sections) {
154
+ const block = [...irBlocks(section.blocks)].find(block => block.id === config.block)
155
+ if (block) return h(BlockPreview, { block, section })
156
+ }
157
+ return null
158
+ }
159
+ if (print) return h(SlidesPrint, { steps: print.steps, slides: print.slides })
160
+ if (slides) return h('div', { className: 'pm-slides-page pm-document' }, h(OverlayViewOutlet))
161
+ return embedded ? h(PreviewSheet, { viewer }) : h(DocumentSite, { config, router: router! })
162
+ }
163
+ const app = pageRoot(h(HostProvider, { adapter }, h(ViewerProvider, { viewer }, h(Content))), input.ssr)
164
+ for (const [name, load] of Object.entries(input.components)) registerComponent(name, clientOnly(name, load))
165
+ return { app, shared, active, viewer, router }
166
+ }
@@ -0,0 +1,109 @@
1
+ import { safeDecodeURIComponent } from '@lincyaw/kernel'
2
+ import type { IRDocument } from '@lincyaw/kernel'
3
+ import type { Position } from '@lincyaw/viewer/domain'
4
+ import { createStore } from '@lincyaw/host'
5
+ import type { Store } from '@lincyaw/host'
6
+ const nextTick = (work?: () => void) => new Promise<void>(resolve => requestAnimationFrame(() => { work?.(); resolve() }))
7
+ import { pageLink, sectionLinks, translationPages, webPages } from '../domain/index.ts'
8
+ import type { AppConfig, WebPage } from '../domain/index.ts'
9
+
10
+ /**
11
+ * The pages of the document shown: one chapter at a time, as the web form pages it. A site's pages have addresses of
12
+ * their own (`index.html`, `<chapter>.html` below the page's language), followed in the browser's history without
13
+ * loading the page again; the workbench's page keeps its one address and turns pages in place.
14
+ */
15
+ export interface PageRouter {
16
+ /** The path of the page shown (`index` or a chapter's). */
17
+ page: Store<string>
18
+ pages: Store<WebPage[]>
19
+ shown: Store<WebPage | undefined>
20
+ /** The site path of the page's language, ending with `/`: the document's, or a translation's. */
21
+ root: string
22
+ href(page: WebPage): string
23
+ sectionHref(id: string): string
24
+ /** Shows the page of `position`'s section. */
25
+ turnTo(position: Position, restoring: boolean): void
26
+ /** Follows the links to the document's own pages and the browser's back and forward; returns the function that stops. */
27
+ refresh(): void
28
+ listen(): () => void
29
+ }
30
+
31
+ /** The router of `config`'s page; `doc` gives the document as it is now. */
32
+ export function createPageRouter(config: AppConfig, doc: () => IRDocument, history: boolean): PageRouter {
33
+ const root = config.site.locales.find(locale => locale.lang === config.lang)?.base ?? config.base
34
+ const prefix = root.replace(/\/$/, '')
35
+ const pagesOf = () => config.landing ? [{ path: 'index', title: doc().meta.title, sections: doc().sections.map(section => section.id) }] : config.pages ? translationPages(doc(), config.pages) : webPages(doc())
36
+ const pages = createStore(pagesOf())
37
+ const page = createStore(config.page ?? 'index')
38
+ const shown = createStore(pages.getSnapshot().find(p => p.path === page.getSnapshot()) ?? pages.getSnapshot()[0])
39
+ const refresh = () => {
40
+ const next = pagesOf()
41
+ pages.set(next)
42
+ if (!next.some(target => target.path === page.getSnapshot()) && next[0]) {
43
+ page.set(next[0].path)
44
+ if (history) window.history.replaceState(null, '', `${pageLink(next[0], prefix)}${window.location.hash}`)
45
+ } else shown.set(next.find(target => target.path === page.getSnapshot()))
46
+ }
47
+ page.subscribe(() => shown.set(pages.getSnapshot().find(p => p.path === page.getSnapshot()) ?? pages.getSnapshot()[0]))
48
+ const links = () => sectionLinks(pages.getSnapshot(), prefix)
49
+ const href = (target: WebPage) => pageLink(target, prefix)
50
+
51
+ /** The page at a path of the site, if it is one of this document's in this language. */
52
+ const pageAt = (pathname: string): WebPage | undefined => {
53
+ if (!pathname.startsWith(root) && `${pathname}/` !== root) return undefined
54
+ const rest = safeDecodeURIComponent(pathname.slice(root.length))
55
+ const name = rest === '' || rest === 'index.html' ? 'index' : rest.endsWith('.html') ? rest.slice(0, -'.html'.length) : undefined
56
+ return name === undefined ? undefined : pages.getSnapshot().find(p => p.path === name)
57
+ }
58
+
59
+ const show = async (target: WebPage, hash: string) => {
60
+ page.set(target.path)
61
+ await nextTick()
62
+ const anchor = hash ? document.getElementById(safeDecodeURIComponent(hash.slice(1))) : null
63
+ if (anchor) anchor.scrollIntoView()
64
+ else window.scrollTo(0, 0)
65
+ }
66
+
67
+ return {
68
+ page,
69
+ pages,
70
+ shown,
71
+ root,
72
+ href,
73
+ sectionHref: id => (history ? links()[id] ?? `${href(pages.getSnapshot()[0]!)}#${encodeURIComponent(id)}` : `#${encodeURIComponent(id)}`),
74
+ turnTo(position, restoring) {
75
+ const target = pages.getSnapshot().find(p => p.sections.includes(position.section))
76
+ if (!target || target.path === page.getSnapshot()) return
77
+ page.set(target.path)
78
+ if (history) window.history.pushState(null, '', href(target))
79
+ if (!restoring) void nextTick(() => window.scrollTo(0, 0))
80
+ },
81
+ refresh,
82
+ listen() {
83
+ if (!history) return () => undefined
84
+ const onClick = (event: MouseEvent) => {
85
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
86
+ const link = (event.target as Element | null)?.closest?.('a[href]')
87
+ if (!(link instanceof HTMLAnchorElement) || (link.target && link.target !== '_self') || link.hasAttribute('download')) return
88
+ const url = new URL(link.href, location.href)
89
+ if (url.origin !== location.origin) return
90
+ const target = pageAt(url.pathname)
91
+ // The same page with an anchor: the browser goes to it, and the page view follows the address's fragment.
92
+ if (!target || target.path === page.getSnapshot()) return
93
+ event.preventDefault()
94
+ window.history.pushState(null, '', url.href)
95
+ void show(target, url.hash)
96
+ }
97
+ const onPop = () => {
98
+ const target = pageAt(location.pathname)
99
+ if (target && target.path !== page.getSnapshot()) void show(target, location.hash)
100
+ }
101
+ document.addEventListener('click', onClick)
102
+ window.addEventListener('popstate', onPop)
103
+ return () => {
104
+ document.removeEventListener('click', onClick)
105
+ window.removeEventListener('popstate', onPop)
106
+ }
107
+ },
108
+ }
109
+ }
@@ -0,0 +1,32 @@
1
+ import type { IRDocument } from '@lincyaw/kernel'
2
+ import { createStore } from '@lincyaw/host'
3
+ import { renderToString } from 'react-dom/server'
4
+ import type { AppConfig } from '../domain/index.ts'
5
+ import { createPageApp } from './page-app.ts'
6
+
7
+ /**
8
+ * The viewer app on the server: a built site's pages rendered to HTML, which the browser then hydrates. The document's
9
+ * components render as the browser first renders them, as placeholders that load once the page is mounted, so only
10
+ * their names are needed here.
11
+ */
12
+
13
+ const never = () => new Promise<never>(() => {})
14
+
15
+ export interface RenderInput {
16
+ config: AppConfig
17
+ doc?: IRDocument
18
+ /** The names of the components the document can show: the plugins' and its own. */
19
+ components: string[]
20
+ }
21
+
22
+ export async function render({ config, doc, components }: RenderInput): Promise<string> {
23
+ const { app } = createPageApp({
24
+ config,
25
+ doc,
26
+ components: Object.fromEntries(components.map(name => [name, never])),
27
+ embedded: false,
28
+ ssr: true,
29
+ dark: createStore(false),
30
+ })
31
+ return await renderToString(app.element)
32
+ }
@@ -0,0 +1,117 @@
1
+ import { PmBadge, PmDrawer, PmEmpty, PmIcon, useMessages } from '@lincyaw/host'
2
+ import { useEffect, useId, useRef, useState } from 'react'
3
+ import type { ReactNode } from 'react'
4
+ import { WEB_MESSAGES } from '../../domain/index.ts'
5
+ import type { AppConfig, HomeDocument, SiteAction, SiteLandingPage, SiteLandingSection } from '../../domain/index.ts'
6
+ import { NavLinks, SiteFooter, SiteHeader, siteThemeAttributes } from './layout.tsx'
7
+
8
+ function HomeHeader({ config }: { config: AppConfig }) {
9
+ const t = useMessages(WEB_MESSAGES)
10
+ const [open, setOpen] = useState(false)
11
+ const configured = config.site.nav ?? config.site.shell.nav ?? []
12
+ const documents = (config.documents ?? []).map(document => ({ text: document.title, link: document.href }))
13
+ const items = configured.length ? configured : documents.length ? documents : [{ text: t.backHome, link: config.base }]
14
+ return <><SiteHeader config={config} titleHref={config.base} hrefIn={locale => locale.base} onMenu={() => setOpen(true)} />
15
+ <PmDrawer open={open} label={t.mobileNav} closeLabel={t.close} name="pm-site-menu" onOpenChange={setOpen}>
16
+ <NavLinks items={items} vertical onNavigate={() => setOpen(false)} />
17
+ </PmDrawer></>
18
+ }
19
+
20
+ export function WorkspaceHome({ config }: { config: AppConfig }) {
21
+ return config.landingPage ? <IndependentLanding config={config} page={config.landingPage} /> : <AutomaticWorkspaceHome config={config} />
22
+ }
23
+
24
+ function LandingActions({ actions }: { actions?: SiteAction[] }) {
25
+ if (!actions?.length) return null
26
+ return <div className="pm-site-landing__actions">{actions.map((action, index) => <a key={`${action.link}-${index}`} className={`pm-ui-button${action.variant === 'secondary' ? '' : ' pm-ui-button--accent'}`} href={action.link}><span className="pm-ui-button__content">{action.text}</span></a>)}</div>
27
+ }
28
+
29
+ function LandingDocument({ document }: { document: HomeDocument }) {
30
+ const t = useMessages(WEB_MESSAGES)
31
+ return <li className="pm-site-landing__document pm-ui-card pm-ui-card--interactive" data-document={document.dir}>
32
+ <h3 className="pm-ui-card__title"><a className="pm-site-home__link" href={document.href} lang={document.lang}>{document.title}</a></h3>
33
+ {document.description && <p className="pm-site-home__description">{document.description}</p>}
34
+ <div className="pm-ui-badges">{document.pages ? <PmBadge>{t.pages(document.pages)}</PmBadge> : null}{document.lang && <PmBadge>{document.lang}</PmBadge>}{document.tags?.map(tag => <PmBadge key={tag}>{tag}</PmBadge>)}</div>
35
+ </li>
36
+ }
37
+
38
+ function LandingSection({ section, documents }: { section: SiteLandingSection, documents: HomeDocument[] }) {
39
+ const t = useMessages(WEB_MESSAGES), titleId = useId()
40
+ const title = section.title ?? (section.type === 'documents' ? t.documentsHeading : undefined)
41
+ let content: ReactNode
42
+ switch (section.type) {
43
+ case 'features': content = <ul className="pm-site-landing__features">{section.items.map((item, index) => <li key={index} className={`pm-site-landing__feature pm-ui-card${item.link ? ' pm-ui-card--interactive' : ''}`}>
44
+ {item.image && <img className="pm-site-landing__feature-image" src={item.image} alt={item.imageAlt ?? ''} loading="lazy" />}
45
+ <h3 className="pm-ui-card__title">{item.link ? <a className="pm-site-home__link" href={item.link}>{item.title}</a> : item.title}</h3>
46
+ {item.description && <p className="pm-site-home__description">{item.description}</p>}
47
+ </li>)}</ul>; break
48
+ case 'documents': {
49
+ const shown = documents.filter(document => (!section.directories?.length || section.directories.includes(document.dir)) && (!section.tags?.length || section.tags.some(tag => document.tags?.includes(tag))))
50
+ content = shown.length ? <ul className="pm-site-landing__documents">{shown.map(document => <LandingDocument key={document.dir} document={document} />)}</ul> : <PmEmpty icon="document">{t.noDocuments}</PmEmpty>
51
+ break
52
+ }
53
+ case 'media': content = <figure className="pm-site-landing__media">{section.kind === 'image'
54
+ ? <img src={section.src} alt={section.alt ?? ''} loading="lazy" />
55
+ : <video src={section.src} controls preload="metadata" poster={section.poster} aria-label={section.alt ?? title}>{Object.entries(section.captions ?? {}).map(([language, src]) => <track key={language} kind="captions" src={src} srcLang={language} label={language} />)}</video>}</figure>; break
56
+ case 'text': content = <div className="pm-site-landing__text">{section.body.split(/\n\s*\n/).filter(paragraph => paragraph.trim()).map((paragraph, index) => <p key={index}>{paragraph}</p>)}</div>; break
57
+ case 'cta': content = <LandingActions actions={section.actions} />; break
58
+ }
59
+ return <section id={section.id} className={`pm-site-landing__section pm-site-landing__section--${section.type}${section.type === 'cta' ? ' pm-ui-card' : ''}`} aria-labelledby={title ? titleId : undefined}>
60
+ {(title || section.description) && <header className="pm-site-landing__section-heading">{title && <h2 id={titleId} className="pm-site-landing__section-title">{title}</h2>}{section.description && <p className="pm-site-landing__description">{section.description}</p>}</header>}
61
+ {content}
62
+ </section>
63
+ }
64
+
65
+ /** A site's own page is independent from teaching documents and their viewer state. */
66
+ export function IndependentLanding({ config, page }: { config: AppConfig, page: SiteLandingPage }) {
67
+ const t = useMessages(WEB_MESSAGES), hero = page.hero, title = hero?.title ?? page.title, description = hero?.description ?? page.description
68
+ return <div className="pm-site pm-site--landing" {...siteThemeAttributes(config)}>
69
+ <a className="pm-site__skip" href="#pm-site-main">{t.skipToContent}</a><HomeHeader config={config} />
70
+ <main id="pm-site-main" className="pm-site-landing pm-ui" tabIndex={-1}>
71
+ <header className="pm-site-landing__hero" data-with-image={hero?.image ? '' : undefined}>
72
+ <div className="pm-site-landing__hero-content">
73
+ {hero?.eyebrow && <div className="pm-ui-badges"><PmBadge tone="accent">{hero.eyebrow}</PmBadge></div>}
74
+ <h1 className="pm-site-landing__title">{title}</h1>
75
+ {description && <p className="pm-site-landing__description">{description}</p>}
76
+ <LandingActions actions={hero?.actions} />
77
+ </div>
78
+ {hero?.image && <figure className="pm-site-landing__hero-visual"><img src={hero.image} alt={hero.imageAlt ?? ''} /></figure>}
79
+ </header>
80
+ {page.sections.map(section => <LandingSection key={section.id} section={section} documents={config.documents ?? []} />)}
81
+ <SiteFooter config={config} />
82
+ </main>
83
+ </div>
84
+ }
85
+
86
+ function AutomaticWorkspaceHome({ config }: { config: AppConfig }) {
87
+ const t = useMessages(WEB_MESSAGES)
88
+ const [query, setQuery] = useState('')
89
+ const [tag, setTag] = useState<string>()
90
+ const input = useRef<HTMLInputElement>(null)
91
+ const documents = config.documents ?? []
92
+ const documentOrder = new Map(documents.map((document, index) => [document.dir, index + 1]))
93
+ const theme = config.site.shell.theme?.id ?? 'docs', course = theme === 'course', showcase = theme === 'showcase'
94
+ const DocumentList = course ? 'ol' : 'ul'
95
+ const tags = [...new Set(documents.flatMap(doc => doc.tags ?? []))].sort()
96
+ const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean)
97
+ const shown = documents.filter(doc => (!tag || doc.tags?.includes(tag)) && words.every(word => `${doc.title} ${doc.dir} ${doc.description ?? ''} ${(doc.tags ?? []).join(' ')}`.toLowerCase().includes(word)))
98
+ useEffect(() => { const key = (event: KeyboardEvent) => { if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') { event.preventDefault(); input.current?.focus() } }; window.addEventListener('keydown', key); return () => window.removeEventListener('keydown', key) }, [])
99
+ const card = (doc: HomeDocument) => <li key={doc.dir} className="pm-site-home__document pm-ui-card pm-ui-card--interactive" data-document={doc.dir}>
100
+ {course && <div className="pm-site-home__order" aria-hidden="true"><PmBadge tone="accent" pill>{String(documentOrder.get(doc.dir)).padStart(2, '0')}</PmBadge></div>}
101
+ <div className="pm-site-home__card-content"><h3 className="pm-ui-card__title"><a className="pm-site-home__link" href={doc.href} lang={doc.lang}>{doc.title}</a></h3>
102
+ {doc.description && <p className="pm-site-home__description">{doc.description}</p>}
103
+ <div className="pm-ui-badges">{theme === 'docs' && <PmBadge className="pm-site-home__dir">{doc.dir}/</PmBadge>}{doc.pages ? <PmBadge>{t.pages(doc.pages)}</PmBadge> : null}{doc.lang && <PmBadge>{doc.lang}</PmBadge>}{doc.tags?.map(name => <PmBadge key={name} className="pm-site-home__badge">{name}</PmBadge>)}{theme === 'docs' && doc.problems && <PmBadge tone={doc.problems.errors ? 'danger' : doc.problems.warnings ? 'warn' : 'ok'}>{doc.problems.errors ? t.errors(doc.problems.errors) : doc.problems.warnings ? t.warnings(doc.problems.warnings) : t.checked}</PmBadge>}</div>
104
+ </div>
105
+ </li>
106
+ return <div className="pm-site pm-site--home" {...siteThemeAttributes(config)}><a className="pm-site__skip" href="#pm-site-main">{t.skipToContent}</a><HomeHeader config={config} /><main id="pm-site-main" className="pm-site-home pm-ui" tabIndex={-1}>
107
+ <header className="pm-site-home__hero">
108
+ {theme !== 'docs' && <div className="pm-ui-badges"><PmBadge tone="accent">{t.documentCount(documents.length)}</PmBadge></div>}
109
+ <h1 className="pm-site-home__title">{config.site.title}</h1>
110
+ {config.site.shell.head?.description && <p className="pm-site-home__intro">{config.site.shell.head.description}</p>}
111
+ {(course && documents[0] || showcase) && <div className="pm-site-home__actions">{course && documents[0] && <a className="pm-ui-button pm-ui-button--accent" href={documents[0].href}><span className="pm-ui-button__content">{t.startReading}<PmIcon name="chevron-right" /></span></a>}{showcase && <a className="pm-ui-button pm-ui-button--accent" href="#pm-site-home-documents"><span className="pm-ui-button__content">{t.browseDocuments}<PmIcon name="chevron-down" /></span></a>}</div>}
112
+ </header>
113
+ <section className="pm-site-home__section" aria-labelledby="pm-site-home-documents"><div className="pm-site-home__filters"><h2 id="pm-site-home-documents" className="pm-ui-heading">{course ? t.courseContents : t.documentsHeading}</h2><label className="pm-ui-search pm-site-home__search-box"><PmIcon name="search" /><input ref={input} className="pm-site-home__search" type="search" value={query} placeholder={t.searchDocuments} aria-label={t.searchDocuments} onChange={event => setQuery(event.target.value)} /><kbd className="pm-ui-kbd">{t.searchShortcut}</kbd></label>{tags.length > 0 && <div className="pm-site-home__tags pm-ui-segmented" role="group" aria-label={t.tags}><button type="button" className="pm-site-home__tag pm-ui-segmented__option" aria-pressed={tag === undefined} onClick={() => setTag(undefined)}>{t.allTags}</button>{tags.map(name => <button key={name} type="button" className="pm-site-home__tag pm-ui-segmented__option" aria-pressed={tag === name} onClick={() => setTag(tag === name ? undefined : name)}>{name}</button>)}</div>}</div>{shown.length ? <DocumentList className="pm-site-home__documents">{shown.map(card)}</DocumentList> : <PmEmpty icon="document">{t.noDocuments}</PmEmpty>}</section>
114
+ <SiteFooter config={config} />
115
+ </main></div>
116
+ }
117
+ export function MissingPage({ config }: { config: AppConfig }) { const t = useMessages(WEB_MESSAGES); return <div className="pm-site pm-site--missing" {...siteThemeAttributes(config)}><HomeHeader config={config} /><main id="pm-site-main" className="pm-site-missing"><p className="pm-site-missing__code">404</p><h1 className="pm-site-missing__title">{t.notFound}</h1><p className="pm-site-missing__text">{t.notFoundText}</p><a className="pm-ui-button pm-ui-button--primary" href={config.site.root ?? config.base}>{t.backHome}</a></main></div> }