@lincyaw/web 0.0.0-stage → 1.0.0-rc.0
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/LICENSE +202 -0
- package/README.md +24 -2
- package/custom-nav-controls.vue +17 -0
- package/package.json +56 -3
- package/src/client/env.d.ts +1 -0
- package/src/client/home.ts +182 -0
- package/src/client/host.ts +94 -0
- package/src/client/index.ts +4 -0
- package/src/client/page.ts +38 -0
- package/src/client/preview.ts +71 -0
- package/src/client/style.css +427 -0
- package/src/client/theme.ts +322 -0
- package/src/domain/config.ts +99 -0
- package/src/domain/index.ts +7 -0
- package/src/domain/links.ts +62 -0
- package/src/domain/messages.ts +105 -0
- package/src/domain/pages.ts +108 -0
- package/src/node/addons.ts +76 -0
- package/src/node/offline.ts +130 -0
- package/src/node/presmium.ts +10 -0
- package/src/node/publish.ts +101 -0
- package/src/node/renderer.ts +147 -0
- package/src/node/site.ts +349 -0
- package/src/node/sites.ts +531 -0
- package/src/node/vite.ts +166 -0
- package/src/node/vitepress.ts +72 -0
|
@@ -0,0 +1,322 @@
|
|
|
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())
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { isRecord } from '@lincyaw/kernel'
|
|
2
|
+
import { DEFAULT_LANG } from '@lincyaw/kernel/contracts'
|
|
3
|
+
|
|
4
|
+
export const WEB_ID = 'presmium.web'
|
|
5
|
+
|
|
6
|
+
export interface FormOptions {
|
|
7
|
+
slides?: boolean
|
|
8
|
+
slidesUrl?: string
|
|
9
|
+
webUrl?: string
|
|
10
|
+
routerMode?: 'history' | 'hash'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface NavItem {
|
|
14
|
+
text: string
|
|
15
|
+
link?: string
|
|
16
|
+
items?: NavItem[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SiteShell {
|
|
20
|
+
nav?: NavItem[]
|
|
21
|
+
footer?: { message?: string, copyright?: string }
|
|
22
|
+
head?: { description?: string, image?: string }
|
|
23
|
+
sitemap?: { hostname: string }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function text(value: unknown): string | undefined {
|
|
27
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function navItems(value: unknown): NavItem[] {
|
|
31
|
+
if (!Array.isArray(value)) return []
|
|
32
|
+
return value.flatMap((item): NavItem[] => {
|
|
33
|
+
if (!isRecord(item) || !text(item.text)) return []
|
|
34
|
+
const items = navItems(item.items)
|
|
35
|
+
const link = text(item.link)
|
|
36
|
+
return link || items.length > 0 ? [{ text: text(item.text)!, ...link ? { link } : {}, ...items.length > 0 ? { items } : {} }] : []
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function siteShell(config: Record<string, unknown>): SiteShell {
|
|
41
|
+
const shell: SiteShell = {}
|
|
42
|
+
const nav = navItems(config.nav)
|
|
43
|
+
if (nav.length > 0) shell.nav = nav
|
|
44
|
+
if (isRecord(config.footer)) {
|
|
45
|
+
const footer = { message: text(config.footer.message), copyright: text(config.footer.copyright) }
|
|
46
|
+
if (footer.message || footer.copyright) shell.footer = Object.fromEntries(Object.entries(footer).filter(([, v]) => v)) as SiteShell['footer']
|
|
47
|
+
}
|
|
48
|
+
if (isRecord(config.head)) {
|
|
49
|
+
const head = { description: text(config.head.description), image: text(config.head.image) }
|
|
50
|
+
if (head.description || head.image) shell.head = Object.fromEntries(Object.entries(head).filter(([, v]) => v)) as SiteShell['head']
|
|
51
|
+
}
|
|
52
|
+
const hostname = isRecord(config.sitemap) ? text(config.sitemap.hostname) : undefined
|
|
53
|
+
if (hostname) shell.sitemap = { hostname: hostname.replace(/\/+$/, '') }
|
|
54
|
+
return shell
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface WebRendererConfig {
|
|
58
|
+
base: string
|
|
59
|
+
port?: number
|
|
60
|
+
lang: string
|
|
61
|
+
server?: string
|
|
62
|
+
shell: SiteShell
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function url(value: unknown): string | undefined {
|
|
66
|
+
return typeof value === 'string' && value.trim() ? value.trim().replace(/\/+$/, '') : undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function formOptions(presmium: unknown, renderers?: unknown): FormOptions {
|
|
70
|
+
const all = isRecord(presmium) ? presmium : {}
|
|
71
|
+
const config = { ...(isRecord(all.web) ? all.web : {}), ...(isRecord(all[WEB_ID]) ? all[WEB_ID] : {}) }
|
|
72
|
+
const configured = isRecord(renderers) ? renderers : {}
|
|
73
|
+
const slides = configured.slides
|
|
74
|
+
const slidesUrl = url(config.slidesUrl)
|
|
75
|
+
return {
|
|
76
|
+
slides: Boolean(slidesUrl) || slides !== undefined || Object.keys(configured).length === 0,
|
|
77
|
+
slidesUrl,
|
|
78
|
+
webUrl: url(config.webUrl),
|
|
79
|
+
routerMode: isRecord(slides) && slides.routerMode === 'hash' ? 'hash' : 'history',
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const PUBLISH_ID = 'presmium.publish'
|
|
84
|
+
|
|
85
|
+
export function offlineEnabled(presmium: unknown): boolean {
|
|
86
|
+
const publish = isRecord(presmium) ? presmium[PUBLISH_ID] : undefined
|
|
87
|
+
return !(isRecord(publish) && publish.offline === false)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function rendererConfig(ctx: { config: Record<string, unknown>, base: string, lang?: string, server?: { url: string } }): WebRendererConfig {
|
|
91
|
+
const port = Number(ctx.config.port)
|
|
92
|
+
return {
|
|
93
|
+
base: ctx.base,
|
|
94
|
+
port: Number.isInteger(port) && port > 0 ? port : undefined,
|
|
95
|
+
lang: ctx.lang || DEFAULT_LANG,
|
|
96
|
+
...ctx.server ? { server: ctx.server.url } : {},
|
|
97
|
+
shell: siteShell(ctx.config),
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { formOptions, offlineEnabled, rendererConfig, siteShell, WEB_ID } from './config.ts'
|
|
2
|
+
export type { FormOptions, NavItem, SiteShell, WebRendererConfig } from './config.ts'
|
|
3
|
+
export { SITE_LABELS, WEB_MESSAGES } from './messages.ts'
|
|
4
|
+
export { IR_ENDPOINT, requested, samePage, SERVER_PROXY, SERVICE_WORKER, SLIDE_PARAM, slideUrl, SOURCE_ENDPOINT, urlPath, viewSearch, webUrl } from './links.ts'
|
|
5
|
+
export type { Requested } from './links.ts'
|
|
6
|
+
export { neighbour, pageLink, pageMarkdown, pageOf, searchDocuments, sectionLinks, sidebar, toPages, translationPages } from './pages.ts'
|
|
7
|
+
export type { Neighbour, SearchDocument, SidebarItem, WebPage } from './pages.ts'
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { IRDocument } from '@lincyaw/kernel'
|
|
2
|
+
import type { FormOptions } from './config.ts'
|
|
3
|
+
|
|
4
|
+
export function urlPath(path: string): string {
|
|
5
|
+
return encodeURI(path).replace(/[#?]/g, char => encodeURIComponent(char))
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function samePage(a: string, b: string): boolean {
|
|
9
|
+
const page = (path: string) => path.replace(/(?:\.html|\/)$/, '')
|
|
10
|
+
return page(a) === page(b)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const SERVICE_WORKER = 'presmium-sw.js'
|
|
14
|
+
|
|
15
|
+
export const SOURCE_ENDPOINT = '/__presmium/source'
|
|
16
|
+
|
|
17
|
+
export const IR_ENDPOINT = '/__presmium/ir'
|
|
18
|
+
|
|
19
|
+
export const SERVER_PROXY = '^/__presmium/(?!(?:ir|source)(?:[/?]|$))'
|
|
20
|
+
|
|
21
|
+
export const SLIDE_PARAM = 'slide'
|
|
22
|
+
|
|
23
|
+
export function slideUrl(slidesUrl: string, options: FormOptions, slide: number, step = 0): string {
|
|
24
|
+
const path = `${options.routerMode === 'hash' ? '/#/' : '/'}${slide}`
|
|
25
|
+
return `${slidesUrl}${path}${step > 0 && Number.isFinite(step) ? `?clicks=${step}` : ''}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function webUrl(webBase: string, slide: number, step = 0): string {
|
|
29
|
+
return `${webBase}/?${SLIDE_PARAM}=${slide}${step > 0 ? `&step=${step}` : ''}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const POSITION_PARAMS = [SLIDE_PARAM, 'section', 'step', 'view']
|
|
33
|
+
|
|
34
|
+
export function viewSearch(search: string, position?: { view: string, section: string, step: number }): string {
|
|
35
|
+
const params = new URLSearchParams(search)
|
|
36
|
+
for (const name of POSITION_PARAMS) params.delete(name)
|
|
37
|
+
if (position) {
|
|
38
|
+
params.set('view', position.view)
|
|
39
|
+
params.set('section', position.section)
|
|
40
|
+
params.set('step', String(position.step))
|
|
41
|
+
}
|
|
42
|
+
const query = params.toString()
|
|
43
|
+
return query ? `?${query}` : ''
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface Requested {
|
|
47
|
+
section?: string
|
|
48
|
+
step?: number
|
|
49
|
+
view?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function requested(search: string, doc: IRDocument): Requested {
|
|
53
|
+
const params = new URLSearchParams(search)
|
|
54
|
+
const slide = Number(params.get(SLIDE_PARAM))
|
|
55
|
+
const section = params.get('section') ?? doc.sections[slide - 1]?.id
|
|
56
|
+
const step = Number(params.get('step'))
|
|
57
|
+
return {
|
|
58
|
+
section: doc.sections.some(s => s.id === section) ? section! : undefined,
|
|
59
|
+
step: Number.isInteger(step) && step >= 0 ? step : undefined,
|
|
60
|
+
view: params.get('view') ?? undefined,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { Locale } from '@lincyaw/kernel/contracts'
|
|
2
|
+
|
|
3
|
+
export const WEB_MESSAGES = {
|
|
4
|
+
'zh-CN': {
|
|
5
|
+
viewMenu: '视图',
|
|
6
|
+
openInSlides: '在幻灯片中打开',
|
|
7
|
+
slides: '幻灯片',
|
|
8
|
+
readOnWeb: '在网页中阅读',
|
|
9
|
+
sidebarNav: '侧边栏导航',
|
|
10
|
+
pager: '翻页',
|
|
11
|
+
mobileNav: '打开导航菜单',
|
|
12
|
+
extraNav: '更多导航',
|
|
13
|
+
documents: '文档列表',
|
|
14
|
+
documentsHeading: '文档',
|
|
15
|
+
searchDocuments: '搜索文档',
|
|
16
|
+
tags: '标签',
|
|
17
|
+
allTags: '全部',
|
|
18
|
+
noDocuments: '没有文档',
|
|
19
|
+
files: '工作区文件',
|
|
20
|
+
search: '搜索',
|
|
21
|
+
pages: (count: number) => `${count} 页`,
|
|
22
|
+
checked: '检查通过',
|
|
23
|
+
warnings: (count: number) => `${count} 个警告`,
|
|
24
|
+
errors: (count: number) => `${count} 个错误`,
|
|
25
|
+
mcp: '连接外部 agent(MCP)',
|
|
26
|
+
trail: '位置',
|
|
27
|
+
close: '关闭',
|
|
28
|
+
},
|
|
29
|
+
'en': {
|
|
30
|
+
viewMenu: 'View',
|
|
31
|
+
openInSlides: 'Open in the slides',
|
|
32
|
+
slides: 'Slides',
|
|
33
|
+
readOnWeb: 'Read on the web',
|
|
34
|
+
sidebarNav: 'Sidebar Navigation',
|
|
35
|
+
pager: 'Pager',
|
|
36
|
+
mobileNav: 'mobile navigation',
|
|
37
|
+
extraNav: 'extra navigation',
|
|
38
|
+
documents: 'Documents',
|
|
39
|
+
documentsHeading: 'Documents',
|
|
40
|
+
searchDocuments: 'Search the documents',
|
|
41
|
+
tags: 'Tags',
|
|
42
|
+
allTags: 'All',
|
|
43
|
+
noDocuments: 'No documents',
|
|
44
|
+
files: 'Workspace files',
|
|
45
|
+
search: 'Search',
|
|
46
|
+
pages: (count: number) => `${count} ${count === 1 ? 'page' : 'pages'}`,
|
|
47
|
+
checked: 'Checks pass',
|
|
48
|
+
warnings: (count: number) => `${count} ${count === 1 ? 'warning' : 'warnings'}`,
|
|
49
|
+
errors: (count: number) => `${count} ${count === 1 ? 'error' : 'errors'}`,
|
|
50
|
+
mcp: 'Connect an outside agent (MCP)',
|
|
51
|
+
trail: 'Location',
|
|
52
|
+
close: 'Close',
|
|
53
|
+
},
|
|
54
|
+
} satisfies Record<Locale, unknown>
|
|
55
|
+
|
|
56
|
+
export const SITE_LABELS = {
|
|
57
|
+
'zh-CN': {
|
|
58
|
+
themeConfig: {
|
|
59
|
+
outline: { level: [2, 3], label: '本页大纲' },
|
|
60
|
+
docFooter: { prev: '上一章', next: '下一章' },
|
|
61
|
+
sidebarMenuLabel: '目录',
|
|
62
|
+
returnToTopLabel: '回到顶部',
|
|
63
|
+
darkModeSwitchLabel: '外观',
|
|
64
|
+
lightModeSwitchTitle: '切换到浅色模式',
|
|
65
|
+
darkModeSwitchTitle: '切换到深色模式',
|
|
66
|
+
skipToContentLabel: '跳到正文',
|
|
67
|
+
langMenuLabel: '切换语言',
|
|
68
|
+
lastUpdated: { text: '最后更新于' },
|
|
69
|
+
notFound: {
|
|
70
|
+
title: '页面不存在',
|
|
71
|
+
quote: '这个地址没有对应的页面,可能已经移动或删除。',
|
|
72
|
+
linkLabel: '返回首页',
|
|
73
|
+
linkText: '返回首页',
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
search: {
|
|
77
|
+
button: { buttonText: '搜索', buttonAriaLabel: '搜索' },
|
|
78
|
+
modal: {
|
|
79
|
+
displayDetails: '显示详细列表',
|
|
80
|
+
resetButtonTitle: '清除',
|
|
81
|
+
backButtonTitle: '关闭搜索',
|
|
82
|
+
noResultsText: '没有结果',
|
|
83
|
+
footer: {
|
|
84
|
+
selectText: '选择',
|
|
85
|
+
selectKeyAriaLabel: '回车键',
|
|
86
|
+
navigateText: '切换',
|
|
87
|
+
navigateUpKeyAriaLabel: '上箭头',
|
|
88
|
+
navigateDownKeyAriaLabel: '下箭头',
|
|
89
|
+
closeText: '关闭',
|
|
90
|
+
closeKeyAriaLabel: 'Esc 键',
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
} as Record<string, unknown> | undefined,
|
|
94
|
+
},
|
|
95
|
+
'en': {
|
|
96
|
+
themeConfig: {
|
|
97
|
+
outline: { level: [2, 3], label: 'On this page' },
|
|
98
|
+
docFooter: { prev: 'Previous chapter', next: 'Next chapter' },
|
|
99
|
+
sidebarMenuLabel: 'Menu',
|
|
100
|
+
returnToTopLabel: 'Return to top',
|
|
101
|
+
darkModeSwitchLabel: 'Appearance',
|
|
102
|
+
},
|
|
103
|
+
search: undefined as Record<string, unknown> | undefined,
|
|
104
|
+
},
|
|
105
|
+
} satisfies Record<Locale, unknown>
|