@lincyaw/web 0.0.0-stage → 1.0.0-rc.1

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 (47) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +53 -2
  3. package/package.json +57 -3
  4. package/src/app/base.css +117 -0
  5. package/src/app/block-playback.ts +81 -0
  6. package/src/app/block-report.ts +104 -0
  7. package/src/app/document-modules.ts +30 -0
  8. package/src/app/env.d.ts +12 -0
  9. package/src/app/main.ts +198 -0
  10. package/src/app/page-app.ts +166 -0
  11. package/src/app/router.ts +109 -0
  12. package/src/app/server.ts +32 -0
  13. package/src/app/site/home.tsx +117 -0
  14. package/src/app/site/layout.tsx +78 -0
  15. package/src/app/site/search.tsx +22 -0
  16. package/src/app/site.css +1416 -0
  17. package/src/client/document.css +172 -0
  18. package/src/client/host.ts +22 -0
  19. package/src/client/preview-frame.ts +152 -0
  20. package/src/client/runtime.ts +159 -0
  21. package/src/domain/app-config.ts +89 -0
  22. package/src/domain/config.ts +107 -0
  23. package/src/domain/dev-updates.ts +12 -0
  24. package/src/domain/index.ts +17 -0
  25. package/src/domain/landing.ts +83 -0
  26. package/src/domain/links.ts +67 -0
  27. package/src/domain/messages.ts +116 -0
  28. package/src/domain/pages.ts +81 -0
  29. package/src/domain/themes.ts +59 -0
  30. package/src/domain/workspace-home.ts +12 -0
  31. package/src/node/client-modules.ts +92 -0
  32. package/src/node/document-bundle.ts +227 -0
  33. package/src/node/landing-template.ts +34 -0
  34. package/src/node/landing.ts +82 -0
  35. package/src/node/offline.ts +153 -0
  36. package/src/node/presmium.ts +10 -0
  37. package/src/node/publish.ts +169 -0
  38. package/src/node/renderer.ts +108 -0
  39. package/src/node/site-assets.ts +17 -0
  40. package/src/node/site-build.ts +297 -0
  41. package/src/node/site-files.ts +87 -0
  42. package/src/node/site-model.ts +333 -0
  43. package/src/node/site-page.ts +82 -0
  44. package/src/node/site-server.ts +817 -0
  45. package/src/node/slides.ts +135 -0
  46. package/src/node/viewer-app.ts +504 -0
  47. package/src/node/viewer-build.ts +38 -0
@@ -0,0 +1,107 @@
1
+ import { isRecord } from '@lincyaw/kernel'
2
+ import { DEFAULT_LANG } from '@lincyaw/kernel/contracts'
3
+ import { siteTheme } from './themes.ts'
4
+ import type { SiteTheme } from './themes.ts'
5
+
6
+ export const WEB_ID = 'presmium.web'
7
+
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`). */
11
+ slides?: boolean
12
+ slidesUrl?: string
13
+ /** The document has a web form: at `webUrl`, else beside its slides (`../web`). */
14
+ web?: boolean
15
+ webUrl?: string
16
+ }
17
+
18
+ export interface NavItem {
19
+ text: string
20
+ link?: string
21
+ items?: NavItem[]
22
+ }
23
+
24
+ export interface SiteShell {
25
+ theme?: SiteTheme
26
+ nav?: NavItem[]
27
+ footer?: { message?: string, copyright?: string }
28
+ head?: { description?: string, image?: string }
29
+ sitemap?: { hostname: string }
30
+ }
31
+
32
+ function text(value: unknown): string | undefined {
33
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined
34
+ }
35
+
36
+ function navItems(value: unknown): NavItem[] {
37
+ if (!Array.isArray(value)) return []
38
+ return value.flatMap((item): NavItem[] => {
39
+ if (!isRecord(item) || !text(item.text)) return []
40
+ const items = navItems(item.items)
41
+ const link = text(item.link)
42
+ return link || items.length > 0 ? [{ text: text(item.text)!, ...link ? { link } : {}, ...items.length > 0 ? { items } : {} }] : []
43
+ })
44
+ }
45
+
46
+ export function siteShell(config: Record<string, unknown>, lang?: string): SiteShell {
47
+ const shell: SiteShell = { theme: siteTheme(config, lang) }
48
+ const nav = navItems(config.nav)
49
+ if (nav.length > 0) shell.nav = nav
50
+ if (isRecord(config.footer)) {
51
+ const footer = { message: text(config.footer.message), copyright: text(config.footer.copyright) }
52
+ if (footer.message || footer.copyright) shell.footer = Object.fromEntries(Object.entries(footer).filter(([, v]) => v)) as SiteShell['footer']
53
+ }
54
+ if (isRecord(config.head)) {
55
+ const head = { description: text(config.head.description), image: text(config.head.image) }
56
+ if (head.description || head.image) shell.head = Object.fromEntries(Object.entries(head).filter(([, v]) => v)) as SiteShell['head']
57
+ }
58
+ const hostname = isRecord(config.sitemap) ? text(config.sitemap.hostname) : undefined
59
+ if (hostname) shell.sitemap = { hostname: hostname.replace(/\/+$/, '') }
60
+ return shell
61
+ }
62
+
63
+ export interface WebRendererConfig {
64
+ base: string
65
+ port?: number
66
+ lang: string
67
+ server?: string
68
+ shell: SiteShell
69
+ }
70
+
71
+ function url(value: unknown): string | undefined {
72
+ return typeof value === 'string' && value.trim() ? value.trim().replace(/\/+$/, '') : undefined
73
+ }
74
+
75
+ export function crossFormLinks(presmium: unknown, renderers?: unknown): CrossFormLinks {
76
+ const all = isRecord(presmium) ? presmium : {}
77
+ const config = { ...(isRecord(all.web) ? all.web : {}), ...(isRecord(all[WEB_ID]) ? all[WEB_ID] : {}) }
78
+ const configured = isRecord(renderers) ? renderers : {}
79
+ const slides = configured.slides
80
+ const slidesUrl = url(config.slidesUrl)
81
+ const webUrl = url(config.webUrl)
82
+ const none = Object.keys(configured).length === 0
83
+ return {
84
+ slides: Boolean(slidesUrl) || slides !== undefined || none,
85
+ slidesUrl,
86
+ web: Boolean(webUrl) || configured.web !== undefined || none,
87
+ webUrl,
88
+ }
89
+ }
90
+
91
+ const PUBLISH_ID = 'presmium.publish'
92
+
93
+ export function offlineEnabled(presmium: unknown): boolean {
94
+ const publish = isRecord(presmium) ? presmium[PUBLISH_ID] : undefined
95
+ return !(isRecord(publish) && publish.offline === false)
96
+ }
97
+
98
+ export function rendererConfig(ctx: { config: Record<string, unknown>, base: string, lang?: string, server?: { url: string } }): WebRendererConfig {
99
+ const port = Number(ctx.config.port)
100
+ return {
101
+ base: ctx.base,
102
+ port: Number.isInteger(port) && port > 0 ? port : undefined,
103
+ lang: ctx.lang || DEFAULT_LANG,
104
+ ...ctx.server ? { server: ctx.server.url } : {},
105
+ shell: siteShell(ctx.config, ctx.lang),
106
+ }
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
+ }
@@ -0,0 +1,17 @@
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 { 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
+ }
@@ -0,0 +1,67 @@
1
+ import type { IRDocument } from '@lincyaw/kernel'
2
+
3
+ export function urlPath(path: string): string {
4
+ return encodeURI(path).replace(/[#?]/g, char => encodeURIComponent(char))
5
+ }
6
+
7
+ export const SERVICE_WORKER = 'presmium-sw.js'
8
+
9
+ export const SLIDE_PARAM = 'slide'
10
+
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
+ }
15
+
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
+ }
21
+
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'
24
+
25
+ export function printQuery(print: SlidesPrintQuery): string {
26
+ return `?print${print.steps ? '&steps' : ''}${print.slides ? `&slides=${print.slides.join(',')}` : ''}`
27
+ }
28
+
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 } : {} }
34
+ }
35
+
36
+ const POSITION_PARAMS = [SLIDE_PARAM, 'section', 'step', 'view']
37
+
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 {
40
+ const params = new URLSearchParams(search)
41
+ for (const name of POSITION_PARAMS) params.delete(name)
42
+ if (position) {
43
+ params.set('view', position.view)
44
+ params.set('section', position.section)
45
+ params.set('step', String(position.step))
46
+ }
47
+ const query = params.toString()
48
+ return query ? `?${query}` : ''
49
+ }
50
+
51
+ export interface PositionQuery {
52
+ section?: string
53
+ step?: number
54
+ view?: string
55
+ }
56
+
57
+ export function parsePositionQuery(search: string, doc: IRDocument): PositionQuery {
58
+ const params = new URLSearchParams(search)
59
+ const slide = Number(params.get(SLIDE_PARAM))
60
+ const section = params.get('section') ?? doc.sections[slide - 1]?.id
61
+ const step = Number(params.get('step'))
62
+ return {
63
+ section: doc.sections.some(s => s.id === section) ? section! : undefined,
64
+ step: Number.isInteger(step) && step >= 0 ? step : undefined,
65
+ view: params.get('view') ?? undefined,
66
+ }
67
+ }
@@ -0,0 +1,116 @@
1
+ import type { Locale } from '@lincyaw/kernel/contracts'
2
+
3
+ export const WEB_MESSAGES = {
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。',
12
+ viewMenu: '视图',
13
+ blockPreviewFailed: '无法显示此预览。请检查内容块与素材。',
14
+ startFailed: '无法打开此文稿。请重新加载页面。',
15
+ openInSlides: '在幻灯片中打开',
16
+ slides: '幻灯片',
17
+ readOnWeb: '在网页中阅读',
18
+ sidebarNav: '侧边栏导航',
19
+ pager: '翻页',
20
+ mobileNav: '打开导航菜单',
21
+ extraNav: '更多导航',
22
+ documents: '文稿列表',
23
+ documentsHeading: '文稿',
24
+ searchDocuments: '搜索文稿',
25
+ tags: '标签',
26
+ allTags: '全部',
27
+ noDocuments: '没有文稿',
28
+ files: '工作区文件',
29
+ search: '搜索',
30
+ pages: (count: number) => `${count} 页`,
31
+ checked: '检查通过',
32
+ warnings: (count: number) => `${count} 个警告`,
33
+ errors: (count: number) => `${count} 个错误`,
34
+ trail: '位置',
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',
59
+ },
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.',
68
+ viewMenu: 'View',
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',
72
+ slides: 'Slides',
73
+ readOnWeb: 'Read on the Web',
74
+ sidebarNav: 'Sidebar Navigation',
75
+ pager: 'Pager',
76
+ mobileNav: 'Open Navigation Menu',
77
+ extraNav: 'More Navigation',
78
+ documents: 'Documents',
79
+ documentsHeading: 'Documents',
80
+ searchDocuments: 'Search documents',
81
+ tags: 'Tags',
82
+ allTags: 'All',
83
+ noDocuments: 'No documents',
84
+ files: 'Workspace Files',
85
+ search: 'Search',
86
+ pages: (count: number) => `${count} ${count === 1 ? 'page' : 'pages'}`,
87
+ checked: 'Checks pass',
88
+ warnings: (count: number) => `${count} ${count === 1 ? 'warning' : 'warnings'}`,
89
+ errors: (count: number) => `${count} ${count === 1 ? 'error' : 'errors'}`,
90
+ trail: 'Location',
91
+ close: 'Close',
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',
115
+ },
116
+ } satisfies Record<Locale, unknown>
@@ -0,0 +1,81 @@
1
+ import { blockText, chapters, isRecord, urlSlug } from '@lincyaw/kernel'
2
+ import type { IRDocument } from '@lincyaw/kernel'
3
+
4
+ export interface WebPage {
5
+ path: string
6
+ title: string
7
+ sections: string[]
8
+ }
9
+
10
+ function baseName(file: string): string {
11
+ return file.split(/[\\/]/).pop()!.replace(/\.[^.]+$/, '')
12
+ }
13
+
14
+ export function webPages(doc: IRDocument): WebPage[] {
15
+ const web = doc.config.renderers.web
16
+ if (isRecord(web) && web.layout === 'landing') return [{ path: 'index', title: doc.meta.title, sections: doc.sections.map(section => section.id) }]
17
+ const paths = new Set<string>()
18
+ let file: string | undefined
19
+ return chapters(doc).map((chapter, i) => {
20
+ const first = chapter.sections[0]!
21
+ const name = i === 0 ? 'index' : urlSlug(first.source.file === file ? first.id : baseName(first.source.file)) || 'page'
22
+ let path = name
23
+ for (let n = 2; paths.has(path); n++) path = `${name}-${n}`
24
+ paths.add(path)
25
+ file = first.source.file
26
+ return { path, title: chapter.title, sections: chapter.sections.map(section => section.id) }
27
+ })
28
+ }
29
+
30
+ export function translationPages(doc: IRDocument, original: readonly WebPage[]): WebPage[] {
31
+ const pages = webPages(doc)
32
+ const matched = pages.map(page => original.find(o => o.sections[0] === page.sections[0])?.path)
33
+ const paths = new Set(matched.filter(path => path !== undefined))
34
+ return pages.map((page, i) => {
35
+ let path = matched[i]
36
+ if (path === undefined) {
37
+ path = page.path
38
+ for (let n = 2; paths.has(path); n++) path = `${page.path}-${n}`
39
+ paths.add(path)
40
+ }
41
+ return { ...page, path }
42
+ })
43
+ }
44
+
45
+ export function pageLink(page: WebPage, prefix = ''): string {
46
+ return page.path === 'index' ? `${prefix}/` : `${prefix}/${page.path}.html`
47
+ }
48
+
49
+ export function sectionLinks(pages: readonly WebPage[], prefix = ''): Record<string, string> {
50
+ return Object.fromEntries(pages.flatMap(page => page.sections.map((id, i) =>
51
+ [id, i === 0 ? pageLink(page, prefix) : `${pageLink(page, prefix)}#${encodeURIComponent(id)}`])))
52
+ }
53
+
54
+ /** A page of another document the pager goes on to. */
55
+ export interface Neighbour {
56
+ text: string
57
+ link: string
58
+ }
59
+
60
+ /** The fields of a document's search index (MiniSearch's), and those its results carry. */
61
+ export const SEARCH_FIELDS = { fields: ['title', 'titles', 'text'], storeFields: ['title', 'titles'] }
62
+
63
+ export interface SearchDocument {
64
+ id: string
65
+ title: string
66
+ titles: string[]
67
+ text: string
68
+ }
69
+
70
+ export function searchDocuments(doc: IRDocument, base: string, pages: readonly WebPage[] = webPages(doc), titles: string[] = []): SearchDocument[] {
71
+ const byId = new Map(doc.sections.map(section => [section.id, section]))
72
+ return pages.flatMap((page) => {
73
+ const path = page.path === 'index' ? '' : `${page.path}.html`
74
+ return page.sections.flatMap(id => byId.get(id) ?? []).map((section, i) => ({
75
+ id: `${base}${path}#${section.id}`,
76
+ title: section.title,
77
+ titles: [...titles, ...i === 0 ? [] : [page.title]],
78
+ text: section.blocks.map(blockText).filter(Boolean).join('\n'),
79
+ }))
80
+ })
81
+ }
@@ -0,0 +1,59 @@
1
+ import { deckLocale, isRecord } from '@lincyaw/kernel'
2
+ import { WEB_MESSAGES } from './messages.ts'
3
+
4
+ export const SITE_THEME_IDS = ['course', 'docs', 'showcase'] as const
5
+ export type SiteThemeId = typeof SITE_THEME_IDS[number]
6
+ export type SitePageLayout = 'article' | 'landing' | 'wide'
7
+
8
+ /** Public presentation options; the content and its stable identities stay in lecture.json. */
9
+ export interface SiteThemeParams {
10
+ accent: 'blue' | 'teal' | 'violet' | 'amber'
11
+ typography: 'reading' | 'sans'
12
+ contentWidth: 'comfortable' | 'wide'
13
+ logo?: string
14
+ logoAlt?: string
15
+ }
16
+
17
+ export interface SiteTheme {
18
+ id: SiteThemeId
19
+ params: SiteThemeParams
20
+ }
21
+
22
+ export const SITE_THEMES: Readonly<Record<SiteThemeId, Readonly<SiteThemeParams>>> = {
23
+ docs: { accent: 'teal', typography: 'reading', contentWidth: 'comfortable' },
24
+ course: { accent: 'teal', typography: 'reading', contentWidth: 'comfortable' },
25
+ showcase: { accent: 'violet', typography: 'sans', contentWidth: 'wide' },
26
+ }
27
+
28
+ /** Resolve and validate the same configuration in builds, dev servers and independent documents. */
29
+ export function siteTheme(config: Record<string, unknown>, lang?: string): SiteTheme {
30
+ const t = WEB_MESSAGES[deckLocale(lang)]
31
+ const id = config.theme ?? 'docs'
32
+ if (!SITE_THEME_IDS.includes(id as SiteThemeId)) throw new Error(t.unknownTheme(String(id)))
33
+ const defaults = SITE_THEMES[id as SiteThemeId]
34
+ if (config.params !== undefined && !isRecord(config.params)) throw new Error(t.invalidSiteParameter('site.params'))
35
+ const input = isRecord(config.params) ? config.params : {}
36
+ const choice = <T extends string>(key: string, values: readonly T[], fallback: T): T => {
37
+ const value = input[key]
38
+ if (value === undefined) return fallback
39
+ if (!values.includes(value as T)) throw new Error(t.invalidSiteParameter(`site.params.${key}`))
40
+ return value as T
41
+ }
42
+ const params: SiteThemeParams = {
43
+ accent: choice('accent', ['blue', 'teal', 'violet', 'amber'], defaults.accent),
44
+ typography: choice('typography', ['reading', 'sans'], defaults.typography),
45
+ contentWidth: choice('contentWidth', ['comfortable', 'wide'], defaults.contentWidth),
46
+ }
47
+ for (const key of ['logo', 'logoAlt'] as const) {
48
+ if (input[key] === undefined) continue
49
+ if (typeof input[key] !== 'string' || key === 'logo' && !input[key].trim()) throw new Error(t.invalidSiteParameter(`site.params.${key}`))
50
+ params[key] = input[key].trim()
51
+ }
52
+ return { id: id as SiteThemeId, params }
53
+ }
54
+
55
+ export function sitePageLayout(config: Record<string, unknown>, lang?: string): SitePageLayout {
56
+ const value = config.layout ?? 'article'
57
+ if (!['article', 'landing', 'wide'].includes(value as string)) throw new Error(WEB_MESSAGES[deckLocale(lang)].invalidSiteParameter('renderers.web.layout'))
58
+ return value as SitePageLayout
59
+ }
@@ -0,0 +1,12 @@
1
+ /** A document's card on the workspace site's home page. */
2
+ export interface HomeDocument {
3
+ dir: string
4
+ title: string
5
+ lang?: string
6
+ tags?: string[]
7
+ href: string
8
+ description?: string
9
+ pages?: number
10
+ problems?: { errors: number, warnings: number }
11
+ sections?: { id: string, title: string, href: string }[]
12
+ }
@@ -0,0 +1,92 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { dirname, join, relative } from 'node:path'
3
+ import { isRecord } from '@lincyaw/kernel'
4
+ import type { NodeVitePlugin, RenderContext } from '@lincyaw/kernel'
5
+ import { packageOf } from '@lincyaw/kernel/node'
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 = /^[^.]+\.([jt]sx?)$/
11
+
12
+ export interface ClientModules {
13
+ restartable: boolean
14
+ components: Map<string, string>
15
+ plugins: string[]
16
+ viewers: { name: string, file: string }[]
17
+ vite: string[]
18
+ nodeVite: NodeVitePlugin[]
19
+ }
20
+
21
+ /** Adds the browser modules of the package at `dir` to `modules` (a plugin's components do not replace those already there). */
22
+ export function collectBrowserModules(dir: string, name: string, modules: ClientModules, plugin: boolean, manifestDir: string | undefined = dir): void {
23
+ let browser = false
24
+ const components = join(dir, 'components')
25
+ if (existsSync(components)) {
26
+ for (const file of readdirSync(components).filter(f => COMPONENT.test(f)).sort()) {
27
+ browser = true
28
+ const name = file.replace(/\.[^.]+$/, '')
29
+ if (!plugin || !modules.components.has(name)) modules.components.set(name, join(components, file))
30
+ }
31
+ }
32
+ if (existsSync(join(dir, CLIENT_PLUGIN))) {
33
+ browser = true
34
+ modules.plugins.push(join(dir, CLIENT_PLUGIN))
35
+ }
36
+ if (existsSync(join(dir, VIEWER_PLUGIN))) {
37
+ browser = true
38
+ modules.viewers.push({ name, file: join(dir, VIEWER_PLUGIN) })
39
+ }
40
+ if (plugin && existsSync(join(dir, VITE_CONFIG))) {
41
+ browser = true
42
+ modules.vite.push(join(dir, VITE_CONFIG))
43
+ }
44
+ if (browser && !isRestartablePackage(manifestDir)) modules.restartable = false
45
+ }
46
+
47
+ /** The directory of the document's own package: its own `package.json`, else the nearest one up to the workspace's root. */
48
+ export function deckPackage(deckDir: string, workspaceRoot: string): string | undefined {
49
+ for (let dir = deckDir; ; dir = dirname(dir)) {
50
+ if (existsSync(join(dir, 'package.json'))) return dir
51
+ if (dir === workspaceRoot || relative(workspaceRoot, dir).startsWith('..') || dirname(dir) === dir) return undefined
52
+ }
53
+ }
54
+
55
+ function readManifest(dir: string): Record<string, unknown> {
56
+ try {
57
+ const pkg: unknown = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
58
+ return isRecord(pkg) ? pkg : {}
59
+ } catch {
60
+ return {}
61
+ }
62
+ }
63
+
64
+ export function isRestartablePackage(dir: string | undefined): boolean {
65
+ if (!dir) return false
66
+ const { presmium } = readManifest(dir)
67
+ return isRecord(presmium) && isRecord(presmium.preview) && presmium.preview.restartable === true
68
+ }
69
+
70
+ /** No browser modules yet. */
71
+ export function emptyClientModules(nodeVite: NodeVitePlugin[] = []): ClientModules {
72
+ return { restartable: true, components: new Map(), plugins: [], viewers: [], vite: [], nodeVite }
73
+ }
74
+
75
+ /** The browser modules of the document and its plugins: components, browser and viewer plugins, Vite configurations. */
76
+ export function clientModules(ctx: RenderContext, nodeVite: NodeVitePlugin[]): ClientModules {
77
+ const modules = emptyClientModules(nodeVite)
78
+ for (const { entry } of nodeVite) {
79
+ try {
80
+ if (!isRestartablePackage(packageOf(entry))) modules.restartable = false
81
+ } catch {
82
+ modules.restartable = false
83
+ }
84
+ }
85
+ for (const name of ctx.plugins) {
86
+ const dir = ctx.pluginDirs[name]
87
+ const { name: pkg } = dir ? readManifest(dir) : {}
88
+ if (dir) collectBrowserModules(dir, typeof pkg === 'string' ? pkg : name, modules, true)
89
+ }
90
+ collectBrowserModules(ctx.deckDir, 'deck', modules, false, deckPackage(ctx.deckDir, ctx.workspaceRoot))
91
+ return modules
92
+ }