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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +33 -4
  2. package/package.json +20 -19
  3. package/src/app/base.css +117 -0
  4. package/src/app/block-playback.ts +81 -0
  5. package/src/app/block-report.ts +104 -0
  6. package/src/app/document-modules.ts +30 -0
  7. package/src/app/env.d.ts +12 -0
  8. package/src/app/main.ts +198 -0
  9. package/src/app/page-app.ts +166 -0
  10. package/src/app/router.ts +109 -0
  11. package/src/app/server.ts +32 -0
  12. package/src/app/site/home.tsx +117 -0
  13. package/src/app/site/layout.tsx +92 -0
  14. package/src/app/site/search.tsx +22 -0
  15. package/src/app/site.css +1450 -0
  16. package/src/client/document.css +172 -0
  17. package/src/client/host.ts +22 -94
  18. package/src/client/preview-frame.ts +152 -0
  19. package/src/client/runtime.ts +159 -0
  20. package/src/domain/app-config.ts +91 -0
  21. package/src/domain/config.ts +17 -9
  22. package/src/domain/dev-updates.ts +12 -0
  23. package/src/domain/index.ts +17 -7
  24. package/src/domain/landing.ts +83 -0
  25. package/src/domain/links.ts +23 -18
  26. package/src/domain/messages.ts +74 -63
  27. package/src/domain/pages.ts +23 -41
  28. package/src/domain/themes.ts +59 -0
  29. package/src/domain/workspace-home.ts +12 -0
  30. package/src/node/client-modules.ts +92 -0
  31. package/src/node/document-bundle.ts +227 -0
  32. package/src/node/landing-template.ts +34 -0
  33. package/src/node/landing.ts +82 -0
  34. package/src/node/offline.ts +34 -11
  35. package/src/node/publish.ts +83 -15
  36. package/src/node/renderer.ts +78 -117
  37. package/src/node/site-assets.ts +17 -0
  38. package/src/node/site-build.ts +304 -0
  39. package/src/node/site-files.ts +87 -0
  40. package/src/node/site-model.ts +337 -0
  41. package/src/node/site-page.ts +82 -0
  42. package/src/node/site-server.ts +822 -0
  43. package/src/node/slides.ts +135 -0
  44. package/src/node/viewer-app.ts +504 -0
  45. package/src/node/viewer-build.ts +38 -0
  46. package/custom-nav-controls.vue +0 -17
  47. package/src/client/env.d.ts +0 -1
  48. package/src/client/home.ts +0 -182
  49. package/src/client/index.ts +0 -4
  50. package/src/client/page.ts +0 -38
  51. package/src/client/preview.ts +0 -71
  52. package/src/client/style.css +0 -427
  53. package/src/client/theme.ts +0 -322
  54. package/src/node/addons.ts +0 -76
  55. package/src/node/site.ts +0 -349
  56. package/src/node/sites.ts +0 -531
  57. package/src/node/vite.ts +0 -166
  58. package/src/node/vitepress.ts +0 -72
@@ -0,0 +1,91 @@
1
+ import type { CrossFormLinks, NavItem, SiteShell } from './config.ts'
2
+ import type { Neighbour, WebPage } from './pages.ts'
3
+ import type { HomeDocument } from './workspace-home.ts'
4
+ import type { SitePageLayout } from './themes.ts'
5
+ import type { SiteLandingPage } from './landing.ts'
6
+
7
+ /** The element of a page whose JSON tells the viewer app what the page is (`AppConfig`). */
8
+ export const APP_CONFIG_ELEMENT = 'presmium-app'
9
+
10
+ /** A language a document is shown in: its own, or a translation's below `<base><lang>/`. */
11
+ export interface SiteLocale {
12
+ lang: string
13
+ label: string
14
+ /** The site path of the document in this language, ending with `/`. */
15
+ base: string
16
+ /** A translated document can choose a different page layout. */
17
+ layout?: SitePageLayout
18
+ }
19
+
20
+ /** What a page of the web form has around the document: the site's shell. */
21
+ export interface SiteConfig {
22
+ /** The site's title: the document's, or the workspace's on its home page. */
23
+ title: string
24
+ /** The workspace brand and its root, shared by every document's shell. */
25
+ brandTitle?: string
26
+ root?: string
27
+ /** A page's presentation within the selected site theme. */
28
+ layout?: SitePageLayout
29
+ shell: SiteShell
30
+ /** The navigation in this page's language (a translation's own, when the workspace gives one). */
31
+ nav?: NavItem[]
32
+ /** The workspace's list of documents, when the document is one of several. */
33
+ home?: string
34
+ /** The document's languages, its own first; one when it has no translation. */
35
+ locales: SiteLocale[]
36
+ /** The workspace's documents in its order, for the sidebar; a document's first tag is its group there. */
37
+ documents?: { dir: string, title: string, href: string, group?: string }[]
38
+ /** The documents before and after this one in the workspace: the pager goes on to them. */
39
+ neighbours?: { prev?: Neighbour, next?: Neighbour }
40
+ links: CrossFormLinks
41
+ }
42
+
43
+ /**
44
+ * What the HTML of a page tells the viewer app (as JSON in `#presmium-app`): the same app shows every page of every
45
+ * document, in a built site, in a dev server and in the workbench, so what a page is comes with the page.
46
+ */
47
+ export interface AppConfig {
48
+ /** A page of a document, the workspace's home page (its list of documents), or a page that does not exist. */
49
+ kind: 'document' | 'home' | 'missing'
50
+ /** The document's directory in the workspace (`''` for the document at its root). */
51
+ document: string
52
+ /** One compiled block in a read-only authoring widget; no live or source-edit transport. */
53
+ block?: string
54
+ /** The document's site path, ending with `/`: its pages, its published files and its plugins' run-time files. */
55
+ base: string
56
+ /** The language of the page; a translation's pages are below `<base><lang>/`. */
57
+ lang: string
58
+ /** The form the page shows: the document's pages (`web`, by default), or its slides alone. */
59
+ form?: 'web' | 'slides'
60
+ /** Where the page's IR is: JSON `{ revision, doc }`. */
61
+ ir?: string
62
+ /** The IR's changes as the dev server compiles them (server-sent events). */
63
+ events?: string
64
+ /** The Presmium server's routes (`<path>__presmium/<plugin>/…`), ending with `/`; `/` when omitted. */
65
+ server?: string
66
+ /** The document's own code (`document-bundle.ts`), when it has any. */
67
+ bundle?: string
68
+ /** The search index of the page's language (MiniSearch's JSON). */
69
+ search?: string
70
+ /** The page shown: `index` or a chapter's path (`webPages`). */
71
+ page?: string
72
+ /** The site's root shows every section of its canonical landing document on one page. */
73
+ landing?: boolean
74
+ /** A translation's pages, at the paths of the original's (`translationPages`); a document's own are its `webPages`. */
75
+ pages?: WebPage[]
76
+ site: SiteConfig
77
+ /** The workspace's documents, on its home page. */
78
+ documents?: HomeDocument[]
79
+ /** An independent website page; it does not use the teaching-document runtime. */
80
+ landingPage?: SiteLandingPage
81
+ /** The plugin packages whose browser parts the page runs: the document's (and the operator's, on a site); the home page's are the operator's. */
82
+ plugins?: string[]
83
+ /** Who may embed the page: the workbench's origins. */
84
+ origins: string[]
85
+ /** Where a dev server opens a source line in the author's editor. */
86
+ source?: string
87
+ /** A built site's offline service worker. */
88
+ serviceWorker?: string
89
+ /** Whether the page was rendered on the server: the app then hydrates it. */
90
+ rendered?: boolean
91
+ }
@@ -1,13 +1,18 @@
1
1
  import { isRecord } from '@lincyaw/kernel'
2
2
  import { DEFAULT_LANG } from '@lincyaw/kernel/contracts'
3
+ import { siteTheme } from './themes.ts'
4
+ import type { SiteTheme } from './themes.ts'
3
5
 
4
6
  export const WEB_ID = 'presmium.web'
5
7
 
6
- export interface FormOptions {
8
+ /** The settings that link the web form and the slides to each other. */
9
+ export interface CrossFormLinks {
10
+ /** The document has slides: at `slidesUrl`, else beside its web form (`../slides`). */
7
11
  slides?: boolean
8
12
  slidesUrl?: string
13
+ /** The document has a web form: at `webUrl`, else beside its slides (`../web`). */
14
+ web?: boolean
9
15
  webUrl?: string
10
- routerMode?: 'history' | 'hash'
11
16
  }
12
17
 
13
18
  export interface NavItem {
@@ -17,6 +22,7 @@ export interface NavItem {
17
22
  }
18
23
 
19
24
  export interface SiteShell {
25
+ theme?: SiteTheme
20
26
  nav?: NavItem[]
21
27
  footer?: { message?: string, copyright?: string }
22
28
  head?: { description?: string, image?: string }
@@ -37,8 +43,8 @@ function navItems(value: unknown): NavItem[] {
37
43
  })
38
44
  }
39
45
 
40
- export function siteShell(config: Record<string, unknown>): SiteShell {
41
- const shell: SiteShell = {}
46
+ export function siteShell(config: Record<string, unknown>, lang?: string): SiteShell {
47
+ const shell: SiteShell = { theme: siteTheme(config, lang) }
42
48
  const nav = navItems(config.nav)
43
49
  if (nav.length > 0) shell.nav = nav
44
50
  if (isRecord(config.footer)) {
@@ -66,17 +72,19 @@ function url(value: unknown): string | undefined {
66
72
  return typeof value === 'string' && value.trim() ? value.trim().replace(/\/+$/, '') : undefined
67
73
  }
68
74
 
69
- export function formOptions(presmium: unknown, renderers?: unknown): FormOptions {
75
+ export function crossFormLinks(presmium: unknown, renderers?: unknown): CrossFormLinks {
70
76
  const all = isRecord(presmium) ? presmium : {}
71
77
  const config = { ...(isRecord(all.web) ? all.web : {}), ...(isRecord(all[WEB_ID]) ? all[WEB_ID] : {}) }
72
78
  const configured = isRecord(renderers) ? renderers : {}
73
79
  const slides = configured.slides
74
80
  const slidesUrl = url(config.slidesUrl)
81
+ const webUrl = url(config.webUrl)
82
+ const none = Object.keys(configured).length === 0
75
83
  return {
76
- slides: Boolean(slidesUrl) || slides !== undefined || Object.keys(configured).length === 0,
84
+ slides: Boolean(slidesUrl) || slides !== undefined || none,
77
85
  slidesUrl,
78
- webUrl: url(config.webUrl),
79
- routerMode: isRecord(slides) && slides.routerMode === 'hash' ? 'hash' : 'history',
86
+ web: Boolean(webUrl) || configured.web !== undefined || none,
87
+ webUrl,
80
88
  }
81
89
  }
82
90
 
@@ -94,6 +102,6 @@ export function rendererConfig(ctx: { config: Record<string, unknown>, base: str
94
102
  port: Number.isInteger(port) && port > 0 ? port : undefined,
95
103
  lang: ctx.lang || DEFAULT_LANG,
96
104
  ...ctx.server ? { server: ctx.server.url } : {},
97
- shell: siteShell(ctx.config),
105
+ shell: siteShell(ctx.config, ctx.lang),
98
106
  }
99
107
  }
@@ -0,0 +1,12 @@
1
+ /** The event by which a dev server tells a page that its document changed. */
2
+ export const UPDATE_EVENT = 'ir'
3
+
4
+ /**
5
+ * A change of the document from the IR revision `base` to `revision`: the sections that changed (`update`) when the
6
+ * page has `base`; otherwise the page reads the whole IR again.
7
+ */
8
+ export interface DevUpdateEvent<Update = unknown> {
9
+ revision: string
10
+ base?: string
11
+ update?: Update
12
+ }
@@ -1,7 +1,17 @@
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'
1
+ export { crossFormLinks, offlineEnabled, rendererConfig, siteShell, WEB_ID } from './config.ts'
2
+ export type { CrossFormLinks, NavItem, SiteShell, WebRendererConfig } from './config.ts'
3
+ export { UPDATE_EVENT } from './dev-updates.ts'
4
+ export type { DevUpdateEvent } from './dev-updates.ts'
5
+ export { WEB_MESSAGES } from './messages.ts'
6
+ export { APP_CONFIG_ELEMENT } from './app-config.ts'
7
+ export type { AppConfig, SiteConfig, SiteLocale } from './app-config.ts'
8
+ export { parsePositionQuery, parsePrintQuery, positionUrl, PRINTED_ATTRIBUTE, printQuery, SERVICE_WORKER, SLIDE_PARAM, urlPath, withPositionQuery } from './links.ts'
9
+ export type { SlidesPrintQuery } from './links.ts'
10
+ export type { PositionQuery } from './links.ts'
11
+ export { chapterRedirects, pageLink, SEARCH_FIELDS, searchDocuments, sectionLinks, translationPages, webPages } from './pages.ts'
12
+ export type { Neighbour, SearchDocument, WebPage } from './pages.ts'
13
+ export type { HomeDocument } from './workspace-home.ts'
14
+ export { SITE_THEME_IDS, SITE_THEMES, siteTheme, sitePageLayout } from './themes.ts'
15
+ export type { SiteTheme, SiteThemeId, SiteThemeParams, SitePageLayout } from './themes.ts'
16
+ export { parseLandingPage, SITE_CONTENT_ELEMENT } from './landing.ts'
17
+ export type { SiteAction, SiteHero, SiteLandingPage, SiteLandingSection, SiteContent, SiteContentDocument } from './landing.ts'
@@ -0,0 +1,83 @@
1
+ import { deckLocale, isRecord } from '@lincyaw/kernel'
2
+ import { WEB_MESSAGES } from './messages.ts'
3
+
4
+ export interface SiteAction { text: string, link: string, variant?: 'primary' | 'secondary' }
5
+ export interface SiteHero { eyebrow?: string, title: string, description?: string, image?: string, imageAlt?: string, actions?: SiteAction[] }
6
+ interface SiteSection { id: string, title?: string, description?: string }
7
+ export type SiteLandingSection =
8
+ | SiteSection & { type: 'features', items: { title: string, description?: string, image?: string, imageAlt?: string, link?: string }[] }
9
+ | SiteSection & { type: 'documents', tags?: string[], directories?: string[] }
10
+ | SiteSection & { type: 'media', kind: 'image' | 'video', src: string, alt?: string, poster?: string, captions?: Record<string, string> }
11
+ | SiteSection & { type: 'text', body: string }
12
+ | SiteSection & { type: 'cta', title: string, actions: SiteAction[] }
13
+
14
+ /** Website content is independent of teaching documents and may reference their published collection. */
15
+ export interface SiteLandingPage { schemaVersion: 1, title: string, description?: string, hero?: SiteHero, sections: SiteLandingSection[] }
16
+
17
+ export interface SiteContentDocument { id: string, title: string, href: string, description?: string, lang?: string, tags?: string[], pages?: number }
18
+ export interface SiteContent { schemaVersion: 1, title: string, base: string, lang: string, documents: SiteContentDocument[] }
19
+ export const SITE_CONTENT_ELEMENT = 'presmium-site-data'
20
+
21
+ /** One validator for inline definitions and JSON files, used before replacing an existing build. */
22
+ export function parseLandingPage(value: unknown, lang?: string): SiteLandingPage {
23
+ const fail = (path: string): never => { throw new Error(WEB_MESSAGES[deckLocale(lang)].invalidSiteParameter(path)) }
24
+ const record = (item: unknown, path: string): Record<string, unknown> => isRecord(item) ? item : fail(path)
25
+ const text = (item: unknown, path: string): string => typeof item === 'string' && item.trim() ? item.trim() : fail(path)
26
+ const optional = (item: unknown, path: string): string | undefined => item === undefined ? undefined : typeof item === 'string' ? item : fail(path)
27
+ const url = (item: unknown, path: string): string => {
28
+ const result = text(item, path)
29
+ if (/^(?:javascript|vbscript|data):/i.test(result)) fail(path)
30
+ return result
31
+ }
32
+ const strings = (item: unknown, path: string): string[] | undefined => item === undefined ? undefined : Array.isArray(item) ? item.map((word, i) => text(word, `${path}.${i}`)) : fail(path)
33
+ const actions = (item: unknown, path: string): SiteAction[] => {
34
+ if (!Array.isArray(item)) return fail(path)
35
+ return item.map((input, i) => {
36
+ const action = record(input, `${path}.${i}`)
37
+ if (action.variant !== undefined && action.variant !== 'primary' && action.variant !== 'secondary') fail(`${path}.${i}.variant`)
38
+ return { text: text(action.text, `${path}.${i}.text`), link: url(action.link, `${path}.${i}.link`), ...action.variant ? { variant: action.variant as SiteAction['variant'] } : {} }
39
+ })
40
+ }
41
+ const input = record(value, 'site.landing')
42
+ if (input.schemaVersion !== undefined && input.schemaVersion !== 1) fail('site.landing.schemaVersion')
43
+ const page: SiteLandingPage = { schemaVersion: 1, title: text(input.title, 'site.landing.title'), description: optional(input.description, 'site.landing.description'), sections: [] }
44
+ if (input.hero !== undefined) {
45
+ const hero = record(input.hero, 'site.landing.hero')
46
+ page.hero = {
47
+ title: text(hero.title, 'site.landing.hero.title'),
48
+ eyebrow: optional(hero.eyebrow, 'site.landing.hero.eyebrow'),
49
+ description: optional(hero.description, 'site.landing.hero.description'),
50
+ image: hero.image === undefined ? undefined : url(hero.image, 'site.landing.hero.image'),
51
+ imageAlt: optional(hero.imageAlt, 'site.landing.hero.imageAlt'),
52
+ actions: hero.actions === undefined ? undefined : actions(hero.actions, 'site.landing.hero.actions'),
53
+ }
54
+ }
55
+ if (!Array.isArray(input.sections)) fail('site.landing.sections')
56
+ const ids = new Set<string>()
57
+ page.sections = (input.sections as unknown[]).map((item, i): SiteLandingSection => {
58
+ const at = `site.landing.sections.${i}`, section = record(item, at)
59
+ const id = text(section.id, `${at}.id`)
60
+ if (ids.has(id) || /\s/.test(id) || id === 'pm-site-main') fail(`${at}.id`)
61
+ ids.add(id)
62
+ const common: SiteSection = { id, title: optional(section.title, `${at}.title`), description: optional(section.description, `${at}.description`) }
63
+ switch (section.type) {
64
+ case 'text': return { ...common, type: 'text', body: text(section.body, `${at}.body`) }
65
+ case 'cta': return { ...common, type: 'cta', title: text(section.title, `${at}.title`), actions: actions(section.actions, `${at}.actions`) }
66
+ case 'documents': return { ...common, type: 'documents', tags: strings(section.tags, `${at}.tags`), directories: strings(section.directories, `${at}.directories`) }
67
+ case 'features': {
68
+ if (!Array.isArray(section.items)) fail(`${at}.items`)
69
+ return { ...common, type: 'features', items: (section.items as unknown[]).map((item, j) => {
70
+ const feature = record(item, `${at}.items.${j}`)
71
+ return { title: text(feature.title, `${at}.items.${j}.title`), description: optional(feature.description, `${at}.items.${j}.description`), image: feature.image === undefined ? undefined : url(feature.image, `${at}.items.${j}.image`), imageAlt: optional(feature.imageAlt, `${at}.items.${j}.imageAlt`), link: feature.link === undefined ? undefined : url(feature.link, `${at}.items.${j}.link`) }
72
+ }) }
73
+ }
74
+ case 'media': {
75
+ if (section.kind !== 'image' && section.kind !== 'video') fail(`${at}.kind`)
76
+ const captions = section.captions === undefined ? undefined : Object.fromEntries(Object.entries(record(section.captions, `${at}.captions`)).map(([lang, file]) => [lang, url(file, `${at}.captions.${lang}`)]))
77
+ return { ...common, type: 'media', kind: section.kind as 'image' | 'video', src: url(section.src, `${at}.src`), alt: optional(section.alt, `${at}.alt`), poster: section.poster === undefined ? undefined : url(section.poster, `${at}.poster`), captions }
78
+ }
79
+ default: return fail(`${at}.type`)
80
+ }
81
+ })
82
+ return page
83
+ }
@@ -1,37 +1,42 @@
1
1
  import type { IRDocument } from '@lincyaw/kernel'
2
- import type { FormOptions } from './config.ts'
3
2
 
4
3
  export function urlPath(path: string): string {
5
4
  return encodeURI(path).replace(/[#?]/g, char => encodeURIComponent(char))
6
5
  }
7
6
 
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
7
  export const SERVICE_WORKER = 'presmium-sw.js'
14
8
 
15
- export const SOURCE_ENDPOINT = '/__presmium/source'
9
+ export const SLIDE_PARAM = 'slide'
16
10
 
17
- export const IR_ENDPOINT = '/__presmium/ir'
11
+ /** The address of a form's page (the web form's, or the slides') that shows a slide and its step. */
12
+ export function positionUrl(base: string, slide: number, step = 0): string {
13
+ return `${base.replace(/\/$/, '')}/?${SLIDE_PARAM}=${slide}${step > 0 && Number.isFinite(step) ? `&step=${step}` : ''}`
14
+ }
18
15
 
19
- export const SERVER_PROXY = '^/__presmium/(?!(?:ir|source)(?:[/?]|$))'
16
+ /** What a slides page prints (an export): each slide at its last step, or every step with `steps`; `slides` by number from 1, else all. */
17
+ export interface SlidesPrintQuery {
18
+ steps: boolean
19
+ slides?: number[]
20
+ }
20
21
 
21
- export const SLIDE_PARAM = 'slide'
22
+ /** The attribute of the root element of a printing page once its slides are ready to print. */
23
+ export const PRINTED_ATTRIBUTE = 'data-presmium-printed'
22
24
 
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}` : ''}`
25
+ export function printQuery(print: SlidesPrintQuery): string {
26
+ return `?print${print.steps ? '&steps' : ''}${print.slides ? `&slides=${print.slides.join(',')}` : ''}`
26
27
  }
27
28
 
28
- export function webUrl(webBase: string, slide: number, step = 0): string {
29
- return `${webBase}/?${SLIDE_PARAM}=${slide}${step > 0 ? `&step=${step}` : ''}`
29
+ export function parsePrintQuery(search: string): SlidesPrintQuery | undefined {
30
+ const params = new URLSearchParams(search)
31
+ if (!params.has('print')) return undefined
32
+ const slides = params.get('slides')?.split(',').map(Number).filter(n => Number.isInteger(n) && n > 0)
33
+ return { steps: params.has('steps'), ...slides ? { slides } : {} }
30
34
  }
31
35
 
32
36
  const POSITION_PARAMS = [SLIDE_PARAM, 'section', 'step', 'view']
33
37
 
34
- export function viewSearch(search: string, position?: { view: string, section: string, step: number }): string {
38
+ /** The query string with its position parameters replaced by the given position, or removed. */
39
+ export function withPositionQuery(search: string, position?: { view: string, section: string, step: number }): string {
35
40
  const params = new URLSearchParams(search)
36
41
  for (const name of POSITION_PARAMS) params.delete(name)
37
42
  if (position) {
@@ -43,13 +48,13 @@ export function viewSearch(search: string, position?: { view: string, section: s
43
48
  return query ? `?${query}` : ''
44
49
  }
45
50
 
46
- export interface Requested {
51
+ export interface PositionQuery {
47
52
  section?: string
48
53
  step?: number
49
54
  view?: string
50
55
  }
51
56
 
52
- export function requested(search: string, doc: IRDocument): Requested {
57
+ export function parsePositionQuery(search: string, doc: IRDocument): PositionQuery {
53
58
  const params = new URLSearchParams(search)
54
59
  const slide = Number(params.get(SLIDE_PARAM))
55
60
  const section = params.get('section') ?? doc.sections[slide - 1]?.id
@@ -2,7 +2,16 @@ import type { Locale } from '@lincyaw/kernel/contracts'
2
2
 
3
3
  export const WEB_MESSAGES = {
4
4
  'zh-CN': {
5
+ courseContents: '课程目录',
6
+ startReading: '开始阅读',
7
+ browseDocuments: '浏览文稿',
8
+ documentCount: (count: number) => `${count} 份文稿`,
9
+ unknownTheme: (name: string) => `找不到主题“${name}”。请选择 course、docs 或 showcase。`,
10
+ invalidSiteParameter: (name: string) => `站点配置 ${name} 无效。请检查主题配置中的值。`,
11
+ invalidLanding: '站点首页配置无效。请检查 site.landing 的独立页面定义或文稿目录,并只保留 site.landing 或 site.home。',
5
12
  viewMenu: '视图',
13
+ blockPreviewFailed: '无法显示此预览。请检查内容块与素材。',
14
+ startFailed: '无法打开此文稿。请重新加载页面。',
6
15
  openInSlides: '在幻灯片中打开',
7
16
  slides: '幻灯片',
8
17
  readOnWeb: '在网页中阅读',
@@ -10,96 +19,98 @@ export const WEB_MESSAGES = {
10
19
  pager: '翻页',
11
20
  mobileNav: '打开导航菜单',
12
21
  extraNav: '更多导航',
13
- documents: '文档列表',
14
- documentsHeading: '文档',
15
- searchDocuments: '搜索文档',
22
+ documents: '文稿列表',
23
+ documentsHeading: '文稿',
24
+ searchDocuments: '搜索文稿',
16
25
  tags: '标签',
17
26
  allTags: '全部',
18
- noDocuments: '没有文档',
27
+ noDocuments: '没有文稿',
19
28
  files: '工作区文件',
20
29
  search: '搜索',
21
30
  pages: (count: number) => `${count} 页`,
22
31
  checked: '检查通过',
23
32
  warnings: (count: number) => `${count} 个警告`,
24
33
  errors: (count: number) => `${count} 个错误`,
25
- mcp: '连接外部 agent(MCP)',
26
34
  trail: '位置',
27
35
  close: '关闭',
36
+ skipToContent: '跳到正文',
37
+ outline: '本页大纲',
38
+ prevChapter: '上一页',
39
+ nextChapter: '下一页',
40
+ menu: '目录',
41
+ returnToTop: '回到顶部',
42
+ appearance: '外观',
43
+ toLight: '切换到浅色模式',
44
+ toDark: '切换到深色模式',
45
+ language: '切换语言',
46
+ notFound: '页面不存在',
47
+ notFoundText: '这个地址没有对应的页面,可能已经移动或删除。',
48
+ backHome: '返回首页',
49
+ searchPlaceholder: '搜索这份文稿',
50
+ noResults: (query: string) => `没有结果:“${query}”`,
51
+ keyEnter: '回车键',
52
+ keyUp: '上箭头',
53
+ keyDown: '下箭头',
54
+ keyEscape: 'Esc 键',
55
+ searchSelect: '选择',
56
+ searchNavigate: '切换',
57
+ searchClose: '关闭',
58
+ searchShortcut: 'Ctrl K',
28
59
  },
29
60
  'en': {
61
+ courseContents: 'Course Contents',
62
+ startReading: 'Start Reading',
63
+ browseDocuments: 'Browse Documents',
64
+ documentCount: (count: number) => `${count} ${count === 1 ? 'document' : 'documents'}`,
65
+ unknownTheme: (name: string) => `Theme “${name}” was not found. Choose course, docs or showcase.`,
66
+ invalidSiteParameter: (name: string) => `Site setting ${name} is invalid. Check the value in the theme configuration.`,
67
+ invalidLanding: 'The site home configuration is invalid. Check the independent page definition or document directory in site.landing, and use either site.landing or site.home.',
30
68
  viewMenu: 'View',
31
- openInSlides: 'Open in the slides',
69
+ blockPreviewFailed: 'Couldn’t show this preview. Check its blocks and assets.',
70
+ startFailed: 'Couldn’t open this document. Please reload the page.',
71
+ openInSlides: 'Open in Slides',
32
72
  slides: 'Slides',
33
- readOnWeb: 'Read on the web',
73
+ readOnWeb: 'Read on the Web',
34
74
  sidebarNav: 'Sidebar Navigation',
35
75
  pager: 'Pager',
36
- mobileNav: 'mobile navigation',
37
- extraNav: 'extra navigation',
76
+ mobileNav: 'Open Navigation Menu',
77
+ extraNav: 'More Navigation',
38
78
  documents: 'Documents',
39
79
  documentsHeading: 'Documents',
40
- searchDocuments: 'Search the documents',
80
+ searchDocuments: 'Search documents',
41
81
  tags: 'Tags',
42
82
  allTags: 'All',
43
83
  noDocuments: 'No documents',
44
- files: 'Workspace files',
84
+ files: 'Workspace Files',
45
85
  search: 'Search',
46
86
  pages: (count: number) => `${count} ${count === 1 ? 'page' : 'pages'}`,
47
87
  checked: 'Checks pass',
48
88
  warnings: (count: number) => `${count} ${count === 1 ? 'warning' : 'warnings'}`,
49
89
  errors: (count: number) => `${count} ${count === 1 ? 'error' : 'errors'}`,
50
- mcp: 'Connect an outside agent (MCP)',
51
90
  trail: 'Location',
52
91
  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,
92
+ skipToContent: 'Skip to Content',
93
+ outline: 'On This Page',
94
+ prevChapter: 'Previous Page',
95
+ nextChapter: 'Next Page',
96
+ menu: 'Menu',
97
+ returnToTop: 'Return to Top',
98
+ appearance: 'Appearance',
99
+ toLight: 'Switch to Light Mode',
100
+ toDark: 'Switch to Dark Mode',
101
+ language: 'Change Language',
102
+ notFound: 'Page Not Found',
103
+ notFoundText: 'Nothing is at this address; it may have moved or been removed.',
104
+ backHome: 'Back to Home',
105
+ searchPlaceholder: 'Search this document',
106
+ noResults: (query: string) => `No results for “${query}”`,
107
+ keyEnter: 'Enter',
108
+ keyUp: 'Up arrow',
109
+ keyDown: 'Down arrow',
110
+ keyEscape: 'Esc',
111
+ searchSelect: 'to select',
112
+ searchNavigate: 'to navigate',
113
+ searchClose: 'to close',
114
+ searchShortcut: 'Ctrl K',
104
115
  },
105
116
  } satisfies Record<Locale, unknown>