@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.
@@ -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>