@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.
- package/LICENSE +202 -0
- package/README.md +53 -2
- package/package.json +57 -3
- package/src/app/base.css +117 -0
- package/src/app/block-playback.ts +81 -0
- package/src/app/block-report.ts +104 -0
- package/src/app/document-modules.ts +30 -0
- package/src/app/env.d.ts +12 -0
- package/src/app/main.ts +198 -0
- package/src/app/page-app.ts +166 -0
- package/src/app/router.ts +109 -0
- package/src/app/server.ts +32 -0
- package/src/app/site/home.tsx +117 -0
- package/src/app/site/layout.tsx +78 -0
- package/src/app/site/search.tsx +22 -0
- package/src/app/site.css +1416 -0
- package/src/client/document.css +172 -0
- package/src/client/host.ts +22 -0
- package/src/client/preview-frame.ts +152 -0
- package/src/client/runtime.ts +159 -0
- package/src/domain/app-config.ts +89 -0
- package/src/domain/config.ts +107 -0
- package/src/domain/dev-updates.ts +12 -0
- package/src/domain/index.ts +17 -0
- package/src/domain/landing.ts +83 -0
- package/src/domain/links.ts +67 -0
- package/src/domain/messages.ts +116 -0
- package/src/domain/pages.ts +81 -0
- package/src/domain/themes.ts +59 -0
- package/src/domain/workspace-home.ts +12 -0
- package/src/node/client-modules.ts +92 -0
- package/src/node/document-bundle.ts +227 -0
- package/src/node/landing-template.ts +34 -0
- package/src/node/landing.ts +82 -0
- package/src/node/offline.ts +153 -0
- package/src/node/presmium.ts +10 -0
- package/src/node/publish.ts +169 -0
- package/src/node/renderer.ts +108 -0
- package/src/node/site-assets.ts +17 -0
- package/src/node/site-build.ts +297 -0
- package/src/node/site-files.ts +87 -0
- package/src/node/site-model.ts +333 -0
- package/src/node/site-page.ts +82 -0
- package/src/node/site-server.ts +817 -0
- package/src/node/slides.ts +135 -0
- package/src/node/viewer-app.ts +504 -0
- 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
|
+
}
|