@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
@@ -1,322 +0,0 @@
1
- import { createServerClient, createSharedStates, currentLocale, installHost, OPERATIONS, pluginsOf, PmIcon, PmPresenterPanels, PmToolbar, presmiumService, provideServer, registerPresmiumPlugin, reportPluginFailure, startHost, stopPresmium } from '@lincyaw/host'
2
- import type { ServerClient, SyncTransports } from '@lincyaw/host'
3
- import { errorMessage } from '@lincyaw/kernel'
4
- import type { IRDocument } from '@lincyaw/kernel'
5
- import { createViewer, followOperations, getView, installViewer, installViewerPlugin, OverlayHost, PmComponentFallback, registerComponent, useViewer, withSlideGlobals } from '@lincyaw/viewer'
6
- import type { LiveUpdate, Position, Viewer } from '@lincyaw/viewer'
7
- import { applyUpdate } from '@lincyaw/viewer/domain'
8
- import type { Theme } from 'vitepress'
9
- import { inBrowser, useData, withBase } from 'vitepress'
10
- import DefaultTheme from 'vitepress/theme-without-fonts'
11
- import type { Component } from 'vue'
12
- import { computed, defineAsyncComponent, defineComponent, h, nextTick, onMounted, ref, shallowRef, watch } from 'vue'
13
- import { IR_ENDPOINT, offlineEnabled, requested, samePage, SERVICE_WORKER, sectionLinks, sidebar, slideUrl, SOURCE_ENDPOINT, toPages, translationPages, viewSearch, WEB_MESSAGES } from '../domain/index.ts'
14
- import type { FormOptions } from '../domain/index.ts'
15
- import { createViewerHost, createViewerNavigation } from './host.ts'
16
- import { PAGES, PresmiumPage, ViewMenu } from './page.ts'
17
- import type { WebPreviewPage } from './preview.ts'
18
- import './style.css'
19
-
20
- export interface ViewerModule {
21
- name: string
22
- load: () => Promise<unknown>
23
- }
24
-
25
- export interface ThemeInput {
26
- doc: IRDocument
27
- revision?: string
28
- components: Record<string, () => Promise<unknown>>
29
- plugins: (() => Promise<unknown>)[]
30
- viewers?: ViewerModule[]
31
- translations?: Record<string, () => Promise<{ default: IRDocument }>>
32
- preview?: (page: WebPreviewPage) => void
33
- }
34
-
35
- interface ThemeOptions {
36
- form?: FormOptions
37
- server?: boolean
38
- document?: string
39
- home?: string
40
- ir?: string
41
- }
42
-
43
- interface UpdateEvent {
44
- update: LiveUpdate
45
- base: string
46
- revision: string
47
- }
48
-
49
- async function startKernel(loaders: ThemeInput['plugins'], doc: IRDocument, viewer: Viewer, transports: SyncTransports, server?: ServerClient): Promise<void> {
50
- const config = doc.config.presmium
51
- const modules = await Promise.all(loaders.map(load => load().catch((error) => {
52
- console.error('[presmium] a plugin module failed to load', error)
53
- return {}
54
- })))
55
- modules.flatMap(module => pluginsOf(module, config)).forEach(registerPresmiumPlugin)
56
- await startHost({ mode: 'document', config, navigation: createViewerNavigation(viewer), transports, server, environments: doc.config.execute?.environments })
57
- }
58
-
59
- function clientOnly(name: string, load: () => Promise<unknown>): Component {
60
- const loaded = defineAsyncComponent(() => load().then(module => withSlideGlobals((module as { default: Component }).default)))
61
- return defineComponent({
62
- name,
63
- inheritAttrs: false,
64
- setup(_, { attrs, slots }) {
65
- const mounted = ref(false)
66
- onMounted(() => {
67
- mounted.value = true
68
- })
69
- return () => (mounted.value
70
- ? h(loaded, attrs, slots)
71
- : h('div', { 'class': 'pm-client-only', 'data-component': name }, [h(PmComponentFallback)]))
72
- },
73
- })
74
- }
75
-
76
- async function loadViewerPlugins(modules: ViewerModule[]): Promise<void> {
77
- await Promise.all(modules.map(async ({ name, load }) => {
78
- const id = `${name} (viewer)`
79
- try {
80
- const reason = installViewerPlugin(((await load()) as { default?: unknown }).default, id)
81
- if (reason) reportPluginFailure(id, reason)
82
- } catch (error) {
83
- console.error(`[presmium] ${id} failed to load`, error)
84
- reportPluginFailure(id, errorMessage(error))
85
- }
86
- }))
87
- }
88
-
89
- function serviceWorker(offline: boolean): void {
90
- if (!('serviceWorker' in navigator)) return
91
- if (!offline) {
92
- void navigator.serviceWorker.getRegistrations().then(registrations => registrations
93
- .filter(registration => (registration.active ?? registration.waiting ?? registration.installing)?.scriptURL.endsWith(`/${SERVICE_WORKER}`))
94
- .forEach(registration => void registration.unregister()))
95
- return
96
- }
97
- const register = () => navigator.serviceWorker.register(withBase(`/${SERVICE_WORKER}`)).catch((error) => {
98
- console.warn('[presmium] offline support unavailable', error)
99
- })
100
- if (document.readyState === 'complete') void register()
101
- else window.addEventListener('load', register, { once: true })
102
- }
103
-
104
- function slidesIcon() {
105
- return h('svg', { 'viewBox': '0 0 24 24', 'width': '1em', 'height': '1em', 'aria-hidden': 'true', 'fill': 'none', 'stroke': 'currentColor', 'stroke-width': 2, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, [
106
- h('rect', { x: 2, y: 3, width: 20, height: 14, rx: 2 }),
107
- h('path', { d: 'M8 21h8M12 17v4' }),
108
- ])
109
- }
110
-
111
- function localizeHiddenLabels(): void {
112
- const t = WEB_MESSAGES[currentLocale()]
113
- for (const [id, text] of [['sidebar-aria-label', t.sidebarNav], ['doc-footer-aria-label', t.pager]] as const) {
114
- const label = document.getElementById(id)
115
- if (label && label.textContent !== text) label.textContent = text
116
- }
117
- for (const [selector, text] of [['.VPNavBarHamburger', t.mobileNav], ['.VPNavBarExtra > button', t.extraNav]] as const) {
118
- for (const button of document.querySelectorAll(selector)) {
119
- if (button.getAttribute('aria-label') !== text) button.setAttribute('aria-label', text)
120
- }
121
- }
122
- }
123
-
124
- const slidesBase = ref<string>()
125
- let preview: ThemeInput['preview']
126
- let viewersReady: Promise<void> = Promise.resolve()
127
-
128
- const Layout = defineComponent({
129
- name: 'PresmiumLayout',
130
- setup() {
131
- const viewer = useViewer()
132
- const { theme, page, isDark } = useData()
133
- onMounted(async () => {
134
- const agent = preview
135
- preview = undefined
136
- agent?.({ viewer, dark: isDark })
137
- localizeHiddenLabels()
138
- watch(() => page.value.relativePath, () => void nextTick(localizeHiddenLabels), { flush: 'post' })
139
- const form: FormOptions = theme.value.presmium?.form ?? {}
140
- if (form.slides) slidesBase.value = form.slidesUrl ?? new URL('../slides', new URL(withBase('/'), location.href)).href
141
- const ask = requested(location.search, viewer.doc.value)
142
- if (ask.section || ask.view) {
143
- history.replaceState(history.state, '', location.pathname + viewSearch(location.search) + location.hash)
144
- if (ask.section) viewer.show({ section: ask.section, step: ask.step ?? 0 })
145
- if (ask.view) {
146
- await viewersReady
147
- if (getView(ask.view)) viewer.setView(ask.view)
148
- }
149
- }
150
- watch([viewer.view, viewer.position], ([view, position]) => {
151
- const search = viewSearch(location.search, getView(view)?.kind === 'overlay' ? { view, ...position } : undefined)
152
- if (search !== location.search) history.replaceState(history.state, '', location.pathname + search + location.hash)
153
- }, { immediate: true })
154
- })
155
- const home = () => {
156
- const href = theme.value.presmium?.home
157
- return href ? h('a', { class: 'pm-site-link', href }, [h(PmIcon, { name: 'arrow-left', size: 14, strokeWidth: 2 }), WEB_MESSAGES[currentLocale()].documents]) : null
158
- }
159
- const footer = () => {
160
- const { message, copyright } = (theme.value.footer ?? {}) as { message?: string, copyright?: string }
161
- if (!message && !copyright) return null
162
- return h('footer', { class: 'pm-site-footer' }, [
163
- message ? h('p', { class: 'pm-site-footer__message', innerHTML: message }) : null,
164
- copyright ? h('p', { class: 'pm-site-footer__copyright', innerHTML: copyright }) : null,
165
- ])
166
- }
167
- return () => h(DefaultTheme.Layout, null, {
168
- 'nav-bar-content-after': () => [home(), h(ViewMenu, { class: 'pm-view-menu--bar' }), h(PmToolbar, { class: 'pm-toolbar--bar' })],
169
- 'nav-screen-content-after': () => [home(), h(ViewMenu, { class: 'pm-view-menu--screen' }), h(PmToolbar, { class: 'pm-toolbar--screen' })],
170
- 'doc-after': footer,
171
- 'layout-bottom': () => [
172
- h('div', { class: 'vp-doc pm-overlay-views' }, [h(OverlayHost)]),
173
- viewer.view.value === 'presenter' ? h(PmPresenterPanels, { to: '.pm-presenter__side', docked: true }) : null,
174
- ],
175
- })
176
- },
177
- })
178
-
179
- function followDevServer(original: () => IRDocument, revision: string | undefined, endpoint: string, follow: (doc: IRDocument) => void): void {
180
- const hot = import.meta.hot
181
- if (!hot) return
182
- let current = revision
183
- let updates = 0
184
- let asked = 0
185
- let answered = 0
186
- const fetchCurrent = async () => {
187
- const request = ++asked
188
- const since = updates
189
- try {
190
- const response = await fetch(endpoint, { cache: 'no-store' })
191
- if (!response.ok) return
192
- const { revision: latest, doc } = await response.json() as { revision: string, doc: IRDocument }
193
- if (updates !== since || request < answered || latest === current) return
194
- answered = request
195
- current = latest
196
- follow(doc)
197
- } catch (error) {
198
- console.warn('[presmium] could not get the current document from the dev server', error)
199
- }
200
- }
201
- hot.on('presmium:update', ({ update, base, revision: next }: UpdateEvent) => {
202
- if (base !== current) {
203
- void fetchCurrent()
204
- return
205
- }
206
- current = next
207
- updates++
208
- follow(applyUpdate(original(), update))
209
- })
210
- hot.on('vite:ws:connect', () => void fetchCurrent())
211
- void fetchCurrent()
212
- }
213
-
214
- function localeOf(path: string, base: string, langs: string[]): string {
215
- const rest = path.startsWith(base) ? path.slice(base.length) : path.replace(/^\//, '')
216
- const first = rest.split('/')[0]!
217
- return langs.includes(first) ? first : 'root'
218
- }
219
-
220
- export function createPresmiumTheme(input: ThemeInput): Theme {
221
- return {
222
- extends: DefaultTheme,
223
- Layout,
224
- enhanceApp({ app, router, siteData }) {
225
- const presmium: ThemeOptions = siteData.value.themeConfig.presmium ?? {}
226
- const form: FormOptions = presmium.form ?? {}
227
- const translations = input.translations ?? {}
228
- const langs = Object.keys(translations)
229
- const original = shallowRef(input.doc)
230
- const locale = ref('root')
231
- const loaded = new Map<string, Promise<IRDocument>>()
232
- const documentOf = (lang: string): Promise<IRDocument> => {
233
- if (lang === 'root' || !translations[lang]) return Promise.resolve(original.value)
234
- let doc = loaded.get(lang)
235
- if (!doc) loaded.set(lang, doc = translations[lang]().then(module => module.default))
236
- return doc
237
- }
238
- const pages = computed(() => (locale.value === 'root' ? toPages(current.doc.value) : translationPages(current.doc.value, toPages(original.value))))
239
- const prefix = computed(() => (locale.value === 'root' ? '' : `/${locale.value}`))
240
- const links = computed(() => sectionLinks(pages.value, prefix.value))
241
- const pageOf = (position: Position) => withBase((links.value[position.section] ?? `${prefix.value}/`).split('#')[0]!)
242
- const current = createViewer(input.doc, {
243
- renderer: 'web',
244
- sectionHref: id => withBase(links.value[id] ?? `${prefix.value}/#${id}`),
245
- sectionHeading(section, step) {
246
- if (!slidesBase.value) return null
247
- const t = WEB_MESSAGES[currentLocale()]
248
- return h('a', {
249
- 'class': 'pm-open-slides',
250
- 'href': slideUrl(slidesBase.value, form, section.index, step),
251
- 'title': t.openInSlides,
252
- 'aria-label': t.openInSlides,
253
- 'data-label': t.slides,
254
- }, [slidesIcon()])
255
- },
256
- diagnostics: import.meta.env.DEV,
257
- openSource: import.meta.env.DEV && presmium.document === undefined
258
- ? source => void fetch(`${SOURCE_ENDPOINT}?file=${encodeURIComponent(source.file)}&line=${source.line}`, { method: 'POST', headers: { 'Presmium-Request': 'source' } })
259
- : undefined,
260
- navigate: position => void router.go(pageOf(position)),
261
- openPresenter(position) {
262
- const url = new URL(location.href)
263
- url.hash = ''
264
- url.search = viewSearch(url.search, { view: 'presenter', ...position })
265
- window.open(url.href, 'presmium-presenter', 'popup,width=1100,height=720')
266
- },
267
- })
268
- router.onBeforePageLoad = async (to) => {
269
- if (locale.value === 'root') original.value = current.doc.value
270
- const lang = localeOf(new URL(to, 'http://site').pathname, siteData.value.base, langs)
271
- const doc = await documentOf(lang)
272
- locale.value = lang
273
- if (current.doc.value !== doc) current.replace(doc)
274
- }
275
- const ownPage = (path: string) => {
276
- const { base } = siteData.value
277
- if (!path.startsWith(base)) return false
278
- const lang = localeOf(path, base, langs)
279
- let rest = path.slice(base.length + (lang === 'root' ? 0 : lang.length + 1))
280
- try {
281
- rest = decodeURI(rest)
282
- } catch {
283
- }
284
- const name = rest === '' || rest === 'index.html' ? 'index' : rest.replace(/\.html$/, '')
285
- return toPages(original.value).some(page => page.path === name) || (lang !== 'root' && pages.value.some(page => page.path === name))
286
- }
287
- router.onBeforeRouteChange = (to) => {
288
- if (!inBrowser) return
289
- const url = new URL(to, location.href)
290
- if (url.origin !== location.origin || samePage(url.pathname, location.pathname) || ownPage(url.pathname)) return
291
- location.assign(url.href)
292
- return false
293
- }
294
- const shared = createSharedStates(input.doc.meta.title)
295
- installHost(app, createViewerHost(current, { lang: siteData.value.lang, shared }))
296
- installViewer(app, current)
297
- app.provide(PAGES, { pages, prefix })
298
- app.component('PresmiumPage', PresmiumPage)
299
- for (const [name, load] of Object.entries(input.components)) registerComponent(name, clientOnly(name, load))
300
- if (!inBrowser) return
301
- preview = input.preview
302
- viewersReady = loadViewerPlugins(input.viewers ?? [])
303
- const server = presmium.server ? createServerClient({ document: presmium.document }) : undefined
304
- provideServer(server)
305
- void startKernel(input.plugins, input.doc, current, shared.transports, server)
306
- watch(() => presmiumService(OPERATIONS), (operations, _, onCleanup) => {
307
- if (operations) onCleanup(followOperations(current, operations))
308
- }, { immediate: true })
309
- if (import.meta.env.PROD) serviceWorker(offlineEnabled(input.doc.config.presmium))
310
- followDevServer(() => original.value, input.revision, presmium.ir ?? IR_ENDPOINT, (doc) => {
311
- original.value = doc
312
- if (locale.value === 'root') current.replace(doc)
313
- const site = siteData.value
314
- const nav = sidebar(doc, toPages(doc))
315
- if (site.title === doc.meta.title && JSON.stringify(site.themeConfig.sidebar) === JSON.stringify(nav)) return
316
- siteData.value = { ...site, title: doc.meta.title, themeConfig: { ...site.themeConfig, sidebar: nav } }
317
- })
318
- },
319
- }
320
- }
321
-
322
- import.meta.hot?.dispose(() => stopPresmium())
@@ -1,76 +0,0 @@
1
- import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
- import { join, resolve } from 'node:path'
3
- import { pathToFileURL } from 'node:url'
4
- import { isRecord } from '@lincyaw/kernel'
5
- import type { PresmiumNodePlugin, RenderContext } from '@lincyaw/kernel'
6
-
7
- const CLIENT_PLUGIN = 'src/client/plugin.ts'
8
- const VIEWER_PLUGIN = 'src/client/viewer.ts'
9
- const VITE_CONFIG = 'vite.config.ts'
10
- const COMPONENT = /^[^.]+\.(vue|[jt]sx?)$/
11
-
12
- export interface VitePluginsEntry {
13
- file: string
14
- config: Record<string, unknown>
15
- }
16
-
17
- export interface ClientModules {
18
- components: Map<string, string>
19
- plugins: string[]
20
- viewers: { name: string, file: string }[]
21
- vite: string[]
22
- nodeVite: VitePluginsEntry[]
23
- }
24
-
25
- function collect(dir: string, name: string, modules: ClientModules, plugin: boolean): void {
26
- const components = join(dir, 'components')
27
- if (existsSync(components)) {
28
- for (const file of readdirSync(components).filter(f => COMPONENT.test(f)).sort()) {
29
- modules.components.set(file.replace(/\.[^.]+$/, ''), join(components, file))
30
- }
31
- }
32
- if (existsSync(join(dir, CLIENT_PLUGIN))) modules.plugins.push(join(dir, CLIENT_PLUGIN))
33
- if (existsSync(join(dir, VIEWER_PLUGIN))) modules.viewers.push({ name, file: join(dir, VIEWER_PLUGIN) })
34
- if (plugin && existsSync(join(dir, VITE_CONFIG))) modules.vite.push(join(dir, VITE_CONFIG))
35
- }
36
-
37
- function manifest(dir: string): Record<string, unknown> {
38
- try {
39
- const pkg: unknown = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
40
- return isRecord(pkg) ? pkg : {}
41
- } catch {
42
- return {}
43
- }
44
- }
45
-
46
- function nodeEntry(dir: string): string | undefined {
47
- const { exports } = manifest(dir)
48
- const entry = isRecord(exports) ? exports['./presmium'] : undefined
49
- return typeof entry === 'string' ? resolve(dir, entry) : undefined
50
- }
51
-
52
- export async function nodeVitePlugins(ctx: RenderContext, presmium: Record<string, unknown>, always = false): Promise<VitePluginsEntry[]> {
53
- if (ctx.vitePlugins.length === 0 && !always) return []
54
- const entries: VitePluginsEntry[] = []
55
- for (const name of ctx.plugins) {
56
- const dir = ctx.pluginDirs[name]
57
- const file = dir && nodeEntry(dir)
58
- if (!file) continue
59
- const plugin = (await import(pathToFileURL(file).href).catch(() => ({}))).default as PresmiumNodePlugin | undefined
60
- if (typeof plugin?.vitePlugins !== 'function') continue
61
- const config = presmium[plugin.id]
62
- entries.push({ file, config: isRecord(config) ? config : {} })
63
- }
64
- return entries
65
- }
66
-
67
- export function clientModules(ctx: RenderContext, nodeVite: VitePluginsEntry[]): ClientModules {
68
- const modules: ClientModules = { components: new Map(), plugins: [], viewers: [], vite: [], nodeVite }
69
- for (const name of ctx.plugins) {
70
- const dir = ctx.pluginDirs[name]
71
- const { name: pkg } = dir ? manifest(dir) : {}
72
- if (dir) collect(dir, typeof pkg === 'string' ? pkg : name, modules, true)
73
- }
74
- collect(ctx.deckDir, 'deck', modules, false)
75
- return modules
76
- }