@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.
- package/README.md +33 -4
- package/package.json +20 -19
- package/src/app/base.css +117 -0
- package/src/app/block-playback.ts +81 -0
- package/src/app/block-report.ts +104 -0
- package/src/app/document-modules.ts +30 -0
- package/src/app/env.d.ts +12 -0
- package/src/app/main.ts +198 -0
- package/src/app/page-app.ts +166 -0
- package/src/app/router.ts +109 -0
- package/src/app/server.ts +32 -0
- package/src/app/site/home.tsx +117 -0
- package/src/app/site/layout.tsx +92 -0
- package/src/app/site/search.tsx +22 -0
- package/src/app/site.css +1450 -0
- package/src/client/document.css +172 -0
- package/src/client/host.ts +22 -94
- package/src/client/preview-frame.ts +152 -0
- package/src/client/runtime.ts +159 -0
- package/src/domain/app-config.ts +91 -0
- package/src/domain/config.ts +17 -9
- package/src/domain/dev-updates.ts +12 -0
- package/src/domain/index.ts +17 -7
- package/src/domain/landing.ts +83 -0
- package/src/domain/links.ts +23 -18
- package/src/domain/messages.ts +74 -63
- package/src/domain/pages.ts +23 -41
- package/src/domain/themes.ts +59 -0
- package/src/domain/workspace-home.ts +12 -0
- package/src/node/client-modules.ts +92 -0
- package/src/node/document-bundle.ts +227 -0
- package/src/node/landing-template.ts +34 -0
- package/src/node/landing.ts +82 -0
- package/src/node/offline.ts +34 -11
- package/src/node/publish.ts +83 -15
- package/src/node/renderer.ts +78 -117
- package/src/node/site-assets.ts +17 -0
- package/src/node/site-build.ts +304 -0
- package/src/node/site-files.ts +87 -0
- package/src/node/site-model.ts +337 -0
- package/src/node/site-page.ts +82 -0
- package/src/node/site-server.ts +822 -0
- package/src/node/slides.ts +135 -0
- package/src/node/viewer-app.ts +504 -0
- package/src/node/viewer-build.ts +38 -0
- package/custom-nav-controls.vue +0 -17
- package/src/client/env.d.ts +0 -1
- package/src/client/home.ts +0 -182
- package/src/client/index.ts +0 -4
- package/src/client/page.ts +0 -38
- package/src/client/preview.ts +0 -71
- package/src/client/style.css +0 -427
- package/src/client/theme.ts +0 -322
- package/src/node/addons.ts +0 -76
- package/src/node/site.ts +0 -349
- package/src/node/sites.ts +0 -531
- package/src/node/vite.ts +0 -166
- package/src/node/vitepress.ts +0 -72
package/src/app/main.ts
ADDED
|
@@ -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§ion=…`); 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> }
|