@lincyaw/web 1.0.0-rc.2 → 1.0.0-rc.4
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/package.json +10 -9
- package/src/app/site/layout.tsx +1 -0
- package/src/domain/config.ts +0 -20
- package/src/domain/index.ts +3 -3
- package/src/domain/landing.ts +1 -1
- package/src/domain/messages.ts +4 -2
- package/src/domain/themes.ts +15 -0
- package/src/node/landing.ts +12 -3
- package/src/node/renderer.ts +1 -2
- package/src/node/site-assets.ts +3 -1
- package/src/node/site-build.ts +3 -1
- package/src/node/site-model.ts +1 -1
- package/src/node/site-page.ts +2 -0
- package/src/node/site-server.ts +12 -9
- package/src/node/site-theme.ts +190 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lincyaw/web",
|
|
3
|
-
"version": "1.0.0-rc.
|
|
3
|
+
"version": "1.0.0-rc.4",
|
|
4
4
|
"description": "Presmium's web renderer: one prebuilt viewer that shows the web form in the workbench, in a dev server and as a built site, with search and form switching",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -40,21 +40,22 @@
|
|
|
40
40
|
"sitemap": "9.0.1",
|
|
41
41
|
"vite": "8.3.4",
|
|
42
42
|
"react": "18.3.1",
|
|
43
|
-
"react-dom": "18.3.1"
|
|
43
|
+
"react-dom": "18.3.1",
|
|
44
|
+
"@lincyaw/design": "1.0.0-rc.4"
|
|
44
45
|
},
|
|
45
46
|
"peerDependencies": {
|
|
46
|
-
"@lincyaw/host": "^1.0.0-rc.
|
|
47
|
-
"@lincyaw/kernel": "^1.0.0-rc.
|
|
48
|
-
"@lincyaw/viewer": "^1.0.0-rc.
|
|
47
|
+
"@lincyaw/host": "^1.0.0-rc.4",
|
|
48
|
+
"@lincyaw/kernel": "^1.0.0-rc.4",
|
|
49
|
+
"@lincyaw/viewer": "^1.0.0-rc.4"
|
|
49
50
|
},
|
|
50
51
|
"devDependencies": {
|
|
51
52
|
"@types/escape-html": "1.0.4",
|
|
52
53
|
"@types/send": "^1.2.1",
|
|
53
54
|
"@types/react": "18.3.28",
|
|
54
55
|
"@types/react-dom": "18.3.7",
|
|
55
|
-
"@lincyaw/cli": "1.0.0-rc.
|
|
56
|
-
"@lincyaw/
|
|
57
|
-
"@lincyaw/
|
|
58
|
-
"@lincyaw/
|
|
56
|
+
"@lincyaw/cli": "1.0.0-rc.4",
|
|
57
|
+
"@lincyaw/kernel": "1.0.0-rc.4",
|
|
58
|
+
"@lincyaw/viewer": "1.0.0-rc.4",
|
|
59
|
+
"@lincyaw/host": "1.0.0-rc.4"
|
|
59
60
|
}
|
|
60
61
|
}
|
package/src/app/site/layout.tsx
CHANGED
|
@@ -16,6 +16,7 @@ export function siteThemeAttributes(config: AppConfig) {
|
|
|
16
16
|
'data-pm-site-accent': theme?.params.accent,
|
|
17
17
|
'data-pm-site-typography': theme?.params.typography,
|
|
18
18
|
'data-pm-site-width': theme?.params.contentWidth,
|
|
19
|
+
'data-pm-site-theme-name': theme?.name,
|
|
19
20
|
}
|
|
20
21
|
}
|
|
21
22
|
export function ViewMenu({ place = 'bar' }: { place?: 'bar' | 'screen' }) { const viewer = useViewer(), t = useMessages(WEB_MESSAGES), view = useStore(viewer.view); return <select className={`pm-view-menu pm-ui-select pm-view-menu--${place}`} aria-label={t.viewMenu} value={view} onChange={event => viewer.setView(event.target.value)}>{listViews().filter(v => !v.hidden || v.id === view).map(v => <option key={v.id} value={v.id}>{v.label}</option>)}</select> }
|
package/src/domain/config.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { isRecord } from '@lincyaw/kernel'
|
|
2
|
-
import { DEFAULT_LANG } from '@lincyaw/kernel/contracts'
|
|
3
2
|
import { siteTheme } from './themes.ts'
|
|
4
3
|
import type { SiteTheme } from './themes.ts'
|
|
5
4
|
|
|
@@ -60,14 +59,6 @@ export function siteShell(config: Record<string, unknown>, lang?: string): SiteS
|
|
|
60
59
|
return shell
|
|
61
60
|
}
|
|
62
61
|
|
|
63
|
-
export interface WebRendererConfig {
|
|
64
|
-
base: string
|
|
65
|
-
port?: number
|
|
66
|
-
lang: string
|
|
67
|
-
server?: string
|
|
68
|
-
shell: SiteShell
|
|
69
|
-
}
|
|
70
|
-
|
|
71
62
|
function url(value: unknown): string | undefined {
|
|
72
63
|
return typeof value === 'string' && value.trim() ? value.trim().replace(/\/+$/, '') : undefined
|
|
73
64
|
}
|
|
@@ -94,14 +85,3 @@ export function offlineEnabled(presmium: unknown): boolean {
|
|
|
94
85
|
const publish = isRecord(presmium) ? presmium[PUBLISH_ID] : undefined
|
|
95
86
|
return !(isRecord(publish) && publish.offline === false)
|
|
96
87
|
}
|
|
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
|
-
}
|
package/src/domain/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export { crossFormLinks, offlineEnabled,
|
|
2
|
-
export type { CrossFormLinks, NavItem, SiteShell
|
|
1
|
+
export { crossFormLinks, offlineEnabled, siteShell, WEB_ID } from './config.ts'
|
|
2
|
+
export type { CrossFormLinks, NavItem, SiteShell } from './config.ts'
|
|
3
3
|
export { UPDATE_EVENT } from './dev-updates.ts'
|
|
4
4
|
export type { DevUpdateEvent } from './dev-updates.ts'
|
|
5
5
|
export { WEB_MESSAGES } from './messages.ts'
|
|
@@ -11,7 +11,7 @@ export type { PositionQuery } from './links.ts'
|
|
|
11
11
|
export { chapterRedirects, pageLink, SEARCH_FIELDS, searchDocuments, sectionLinks, translationPages, webPages } from './pages.ts'
|
|
12
12
|
export type { Neighbour, SearchDocument, WebPage } from './pages.ts'
|
|
13
13
|
export type { HomeDocument } from './workspace-home.ts'
|
|
14
|
-
export { SITE_THEME_IDS, SITE_THEMES, siteTheme, sitePageLayout } from './themes.ts'
|
|
14
|
+
export { isThemeReference, SITE_THEME_IDS, SITE_THEMES, siteTheme, sitePageLayout } from './themes.ts'
|
|
15
15
|
export type { SiteTheme, SiteThemeId, SiteThemeParams, SitePageLayout } from './themes.ts'
|
|
16
16
|
export { parseLandingPage, SITE_CONTENT_ELEMENT } from './landing.ts'
|
|
17
17
|
export type { SiteAction, SiteHero, SiteLandingPage, SiteLandingSection, SiteContent, SiteContentDocument } from './landing.ts'
|
package/src/domain/landing.ts
CHANGED
|
@@ -15,7 +15,7 @@ export type SiteLandingSection =
|
|
|
15
15
|
export interface SiteLandingPage { schemaVersion: 1, title: string, description?: string, hero?: SiteHero, sections: SiteLandingSection[] }
|
|
16
16
|
|
|
17
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[] }
|
|
18
|
+
export interface SiteContent { schemaVersion: 1, title: string, base: string, lang: string, documents: SiteContentDocument[], theme?: { name: string, stylesheets: string[] } }
|
|
19
19
|
export const SITE_CONTENT_ELEMENT = 'presmium-site-data'
|
|
20
20
|
|
|
21
21
|
/** One validator for inline definitions and JSON files, used before replacing an existing build. */
|
package/src/domain/messages.ts
CHANGED
|
@@ -6,7 +6,8 @@ export const WEB_MESSAGES = {
|
|
|
6
6
|
startReading: '开始阅读',
|
|
7
7
|
browseDocuments: '浏览文稿',
|
|
8
8
|
documentCount: (count: number) => `${count} 份文稿`,
|
|
9
|
-
unknownTheme: (name: string) => `找不到主题“${name}”。请选择 course、docs 或 showcase
|
|
9
|
+
unknownTheme: (name: string) => `找不到主题“${name}”。请选择 course、docs 或 showcase,或写主题目录(如 ./theme)或已安装的主题包。`,
|
|
10
|
+
invalidTheme: (file: string, field: string) => `主题文件 ${file} 中的 ${field} 无效。tokens 中只能写设计系统已有的变量名,dark 中只能写颜色与阴影,样式表须在主题目录内。`,
|
|
10
11
|
invalidSiteParameter: (name: string) => `站点配置 ${name} 无效。请检查主题配置中的值。`,
|
|
11
12
|
invalidLanding: '站点首页配置无效。请检查 site.landing 的独立页面定义或文稿目录,并只保留 site.landing 或 site.home。',
|
|
12
13
|
viewMenu: '视图',
|
|
@@ -62,7 +63,8 @@ export const WEB_MESSAGES = {
|
|
|
62
63
|
startReading: 'Start Reading',
|
|
63
64
|
browseDocuments: 'Browse Documents',
|
|
64
65
|
documentCount: (count: number) => `${count} ${count === 1 ? 'document' : 'documents'}`,
|
|
65
|
-
unknownTheme: (name: string) => `Theme “${name}” was not found. Choose course, docs or showcase.`,
|
|
66
|
+
unknownTheme: (name: string) => `Theme “${name}” was not found. Choose course, docs or showcase, or name a theme directory (such as ./theme) or an installed theme package.`,
|
|
67
|
+
invalidTheme: (file: string, field: string) => `${field} in the theme file ${file} is invalid. Tokens must be names of the design system, dark tokens only colors and shadows, and stylesheets must be inside the theme directory.`,
|
|
66
68
|
invalidSiteParameter: (name: string) => `Site setting ${name} is invalid. Check the value in the theme configuration.`,
|
|
67
69
|
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
70
|
viewMenu: 'View',
|
package/src/domain/themes.ts
CHANGED
|
@@ -15,8 +15,17 @@ export interface SiteThemeParams {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export interface SiteTheme {
|
|
18
|
+
/** The built-in theme whose layouts the site uses: the selected one, or the one a workspace theme extends. */
|
|
18
19
|
id: SiteThemeId
|
|
19
20
|
params: SiteThemeParams
|
|
21
|
+
/** A workspace theme (`site.theme: ./theme`): its name marks the pages, its published stylesheets follow the app's. */
|
|
22
|
+
name?: string
|
|
23
|
+
stylesheets?: string[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Where `site.theme` names a theme of the workspace's own: a directory, or an installed package. */
|
|
27
|
+
export function isThemeReference(value: unknown): value is string {
|
|
28
|
+
return typeof value === 'string' && !SITE_THEME_IDS.includes(value as SiteThemeId) && value.trim() !== ''
|
|
20
29
|
}
|
|
21
30
|
|
|
22
31
|
export const SITE_THEMES: Readonly<Record<SiteThemeId, Readonly<SiteThemeParams>>> = {
|
|
@@ -49,6 +58,12 @@ export function siteTheme(config: Record<string, unknown>, lang?: string): SiteT
|
|
|
49
58
|
if (typeof input[key] !== 'string' || key === 'logo' && !input[key].trim()) throw new Error(t.invalidSiteParameter(`site.params.${key}`))
|
|
50
59
|
params[key] = input[key].trim()
|
|
51
60
|
}
|
|
61
|
+
// A workspace theme is resolved by the node side (`site-theme.ts`) into the theme it extends and `themePackage`.
|
|
62
|
+
const own = isRecord(config.themePackage) ? config.themePackage : undefined
|
|
63
|
+
if (own && typeof own.name === 'string') {
|
|
64
|
+
const stylesheets = Array.isArray(own.stylesheets) ? own.stylesheets.filter((url): url is string => typeof url === 'string') : []
|
|
65
|
+
return { id: id as SiteThemeId, params, name: own.name, ...stylesheets.length ? { stylesheets } : {} }
|
|
66
|
+
}
|
|
52
67
|
return { id: id as SiteThemeId, params }
|
|
53
68
|
}
|
|
54
69
|
|
package/src/node/landing.ts
CHANGED
|
@@ -3,10 +3,10 @@ import { basename, dirname, extname, join, resolve } from 'node:path'
|
|
|
3
3
|
import { ABSOLUTE_URL, deckLocale, DOCUMENT_ENTRY, isRecord, safeDecodeURIComponent } from '@lincyaw/kernel'
|
|
4
4
|
import type { IRDocument, RenderContext, SiteSpec } from '@lincyaw/kernel'
|
|
5
5
|
import { isInside } from '@lincyaw/kernel/node'
|
|
6
|
-
import { parseLandingPage, WEB_MESSAGES } from '../domain/index.ts'
|
|
6
|
+
import { parseLandingPage, siteTheme, WEB_MESSAGES } from '../domain/index.ts'
|
|
7
7
|
import type { HomeDocument, SiteContent, SiteLandingPage } from '../domain/index.ts'
|
|
8
8
|
import { publishedFile } from './publish.ts'
|
|
9
|
-
import { documentLinkOf, documentPage, siteLanguage, siteTitle } from './site-model.ts'
|
|
9
|
+
import { documentLinkOf, documentPage, shellOptions, siteLanguage, siteTitle } from './site-model.ts'
|
|
10
10
|
|
|
11
11
|
export type IndependentLanding = { kind: 'page', page: SiteLandingPage, source: string } | { kind: 'template', file: string }
|
|
12
12
|
|
|
@@ -78,5 +78,14 @@ export function publishLanding(landing: Extract<IndependentLanding, { kind: 'pag
|
|
|
78
78
|
|
|
79
79
|
/** The public content collection available to a free-form HTML template. */
|
|
80
80
|
export function siteContent(site: SiteSpec, base: string, lang: string, documents: HomeDocument[]): SiteContent {
|
|
81
|
-
|
|
81
|
+
// A template may link the workspace theme's stylesheets and mark its root with `data-pm-site-theme-name`.
|
|
82
|
+
const { name, stylesheets } = siteTheme(shellOptions(site), lang)
|
|
83
|
+
return {
|
|
84
|
+
schemaVersion: 1,
|
|
85
|
+
title: siteTitle(site, lang),
|
|
86
|
+
base,
|
|
87
|
+
lang,
|
|
88
|
+
documents: documents.map(({ dir, title, href, description, lang, tags, pages }) => ({ id: dir, title, href, description, lang, tags, pages })),
|
|
89
|
+
...name ? { theme: { name, stylesheets: stylesheets ?? [] } } : {},
|
|
90
|
+
}
|
|
82
91
|
}
|
package/src/node/renderer.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { join, resolve } from 'node:path'
|
|
2
2
|
import type { IRDocument, RenderContext, RenderDevServer, Renderer, SiteDevServer, SiteDocument, SiteSpec } from '@lincyaw/kernel'
|
|
3
3
|
import launchEditor from 'launch-editor'
|
|
4
|
-
import { rendererConfig } from '../domain/index.ts'
|
|
5
4
|
import { buildSite as buildPages } from './site-build.ts'
|
|
6
5
|
import type { BuiltDocument } from './site-build.ts'
|
|
7
6
|
import { documentContext, documentSourceFiles } from './site-model.ts'
|
|
@@ -23,7 +22,7 @@ function documentWorkDir(ctx: RenderContext, dir: string): string {
|
|
|
23
22
|
/** The dev server of one document (`presmium dev`): its pages, or its slides. */
|
|
24
23
|
export async function devDocument(doc: IRDocument, ctx: RenderContext, form: 'web' | 'slides'): Promise<RenderDevServer> {
|
|
25
24
|
let current = doc
|
|
26
|
-
const
|
|
25
|
+
const port = Number(ctx.config.port) > 0 && Number.isInteger(Number(ctx.config.port)) ? Number(ctx.config.port) : undefined
|
|
27
26
|
const server = await startSiteServer({
|
|
28
27
|
site: () => documentSite(current, ctx),
|
|
29
28
|
form,
|
package/src/node/site-assets.ts
CHANGED
|
@@ -3,9 +3,11 @@ import { ABSOLUTE_URL, isRecord } from '@lincyaw/kernel'
|
|
|
3
3
|
import type { RenderContext, SiteSpec } from '@lincyaw/kernel'
|
|
4
4
|
import { publishedFile } from './publish.ts'
|
|
5
5
|
import { isSingle } from './site-model.ts'
|
|
6
|
+
import { publishedTheme } from './site-theme.ts'
|
|
6
7
|
|
|
7
|
-
/** Publish shell assets from the site's configuration owner, once for every document in the site. */
|
|
8
|
+
/** Publish shell assets from the site's configuration owner, once for every document in the site: its logo and theme. */
|
|
8
9
|
export function publishedSite(site: SiteSpec, ctx: RenderContext, outDir = ctx.outDir, base = ctx.base): SiteSpec {
|
|
10
|
+
site = publishedTheme(site, outDir, base)
|
|
9
11
|
const params = isRecord(site.config.params) ? site.config.params : undefined
|
|
10
12
|
if (!params || typeof params.logo !== 'string') return site
|
|
11
13
|
const logo = params.logo.trim()
|
package/src/node/site-build.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { publishedDocument, publishedFile } from './publish.ts'
|
|
|
15
15
|
import { APP_DIR, CODE_DIR, contentKey, copyApp, copyPublic, copyRuntimeAssets, runtimeAssets, searchIndex } from './site-files.ts'
|
|
16
16
|
import { documentBase, documentPage, documentSiteConfig, homeDocument, homeDocuments, isSingle, landingConfig, landingDocument, languageLabel, LIST_PAGE, siteLanguage, siteLanguages, siteNeighbours, siteShellBelow, siteTitle, sitemapPages, translationOf } from './site-model.ts'
|
|
17
17
|
import { publishedSite } from './site-assets.ts'
|
|
18
|
+
import { themedSite } from './site-theme.ts'
|
|
18
19
|
import { loadLanding, publishLanding, siteContent } from './landing.ts'
|
|
19
20
|
import { buildLandingTemplate, writeLandingTemplate } from './landing-template.ts'
|
|
20
21
|
import { pageHtml, redirectHtml } from './site-page.ts'
|
|
@@ -242,6 +243,7 @@ async function buildMissing(site: SiteSpec, ctx: RenderContext, built: Built, ti
|
|
|
242
243
|
export async function buildSite(site: SiteSpec, ctx: RenderContext, documents: readonly BuiltDocument[]): Promise<void> {
|
|
243
244
|
const workspace = !isSingle(site)
|
|
244
245
|
// Invalid presentation settings fail before replacing an existing build.
|
|
246
|
+
site = themedSite(site, ctx)
|
|
245
247
|
siteShellBelow(site, ctx.base)
|
|
246
248
|
landingDocument(site)
|
|
247
249
|
loadLanding(site, ctx)
|
|
@@ -287,7 +289,7 @@ export async function buildSlidesSite(doc: IRDocument, ctx: RenderContext, nodeP
|
|
|
287
289
|
const { code } = await documentFiles(ctx, built, entry)
|
|
288
290
|
writeFileSync(join(ctx.outDir, 'ir.json'), JSON.stringify(published))
|
|
289
291
|
const lang = doc.meta.lang ?? ctx.lang ?? 'zh-CN'
|
|
290
|
-
const site
|
|
292
|
+
const site = publishedSite(themedSite({ title: doc.meta.title, documents: [{ dir: '', title: doc.meta.title, doc }], config: ctx.config }, ctx), ctx)
|
|
291
293
|
const config: AppConfig = {
|
|
292
294
|
kind: 'document',
|
|
293
295
|
document: '',
|
package/src/node/site-model.ts
CHANGED
|
@@ -16,7 +16,7 @@ import type { DocumentPage } from './publish.ts'
|
|
|
16
16
|
|
|
17
17
|
export const LIST_PAGE = 'documents'
|
|
18
18
|
const LINK_LANGUAGE = new WeakMap<DocumentLink, string>()
|
|
19
|
-
const SHELL_KEYS = ['nav', 'footer', 'head', 'sitemap', 'theme', 'params']
|
|
19
|
+
const SHELL_KEYS = ['nav', 'footer', 'head', 'sitemap', 'theme', 'params', 'themePackage']
|
|
20
20
|
|
|
21
21
|
/** A document's source files: those of its sections and of all their blocks. */
|
|
22
22
|
export function documentSourceFiles(doc: IRDocument): Set<string> {
|
package/src/node/site-page.ts
CHANGED
|
@@ -47,6 +47,8 @@ export function pageHtml(input: PageHtmlInput): string {
|
|
|
47
47
|
`<script>${APPEARANCE}</script>`,
|
|
48
48
|
`<script type="importmap">${json({ imports })}</script>`,
|
|
49
49
|
...entry.css.map(file => `<link rel="stylesheet" href="${escapeHtml(`${appUrl}${file}`)}">`),
|
|
50
|
+
// A workspace theme's fonts, tokens and styles come after the app's, so that they override them.
|
|
51
|
+
...(config.site?.shell.theme?.stylesheets ?? []).map(url => `<link rel="stylesheet" href="${escapeHtml(url)}">`),
|
|
50
52
|
...entry.preload.map(file => `<link rel="modulepreload" href="${escapeHtml(`${appUrl}${file}`)}">`),
|
|
51
53
|
`<script type="application/json" id="${APP_CONFIG_ELEMENT}">${json(config)}</script>`,
|
|
52
54
|
`<script type="module" src="${escapeHtml(`${appUrl}${entry.script}`)}"></script>`,
|
package/src/node/site-server.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { irRevisionOfJson, PublicationCache, publishedDocument } from './publish
|
|
|
22
22
|
import { findRuntimeAsset, REVISION_HEADER, runtimeAssets, searchIndex } from './site-files.ts'
|
|
23
23
|
import { documentBase, documentPage, documentSiteConfig, homeDocument, homeDocuments, isSingle, landingConfig, landingDocument, languageLabel, LIST_PAGE, siteLanguage, siteLanguages, siteNeighbours, siteShellBelow } from './site-model.ts'
|
|
24
24
|
import { publishedSite } from './site-assets.ts'
|
|
25
|
+
import { themedSite } from './site-theme.ts'
|
|
25
26
|
import { loadLanding, publishLanding, siteContent } from './landing.ts'
|
|
26
27
|
import { buildLandingTemplate, writeLandingTemplate } from './landing-template.ts'
|
|
27
28
|
import { pageHtml } from './site-page.ts'
|
|
@@ -190,9 +191,11 @@ function pageAt(pages: readonly WebPage[], rest: string): WebPage | undefined {
|
|
|
190
191
|
export async function startSiteServer(options: SiteServerOptions): Promise<SiteServer> {
|
|
191
192
|
const { ctx } = options
|
|
192
193
|
const base = ctx.base
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
194
|
+
// The site as its pages see it: a workspace theme resolved into the built-in theme it extends.
|
|
195
|
+
const siteOf = () => themedSite(options.site(), ctx)
|
|
196
|
+
siteShellBelow(siteOf(), base)
|
|
197
|
+
landingDocument(siteOf())
|
|
198
|
+
loadLanding(siteOf(), ctx)
|
|
196
199
|
const templateOutputs = new Set<string>()
|
|
197
200
|
const templateBuilds = new Map<string, Promise<string>>()
|
|
198
201
|
const states = new Map<string, DocumentState>()
|
|
@@ -212,8 +215,8 @@ export async function startSiteServer(options: SiteServerOptions): Promise<SiteS
|
|
|
212
215
|
const framed = ctx.preview ? previewFrameAncestors(ctx.preview.origins) : undefined
|
|
213
216
|
const latest = new Map<string, IRDocument>()
|
|
214
217
|
|
|
215
|
-
const documents = () =>
|
|
216
|
-
const shellSite = () => publishedSite(
|
|
218
|
+
const documents = () => siteOf().documents.map(entry => entry.dir)
|
|
219
|
+
const shellSite = () => publishedSite(siteOf(), ctx, join(options.workDir, 'site-assets'), `${base}${PREFIX}site-assets/`)
|
|
217
220
|
const stateOf = (dir: string): DocumentState => {
|
|
218
221
|
let state = states.get(dir)
|
|
219
222
|
if (!state) states.set(dir, state = { caches: new Map(), listeners: new Set(), published: new Map() })
|
|
@@ -424,7 +427,7 @@ export async function startSiteServer(options: SiteServerOptions): Promise<SiteS
|
|
|
424
427
|
if (known?.source === doc) return known
|
|
425
428
|
const own = options.context(dir, doc)
|
|
426
429
|
const docBase = documentBase(base, dir)
|
|
427
|
-
const linked = isSingle(
|
|
430
|
+
const linked = isSingle(siteOf()) ? undefined : documentPage(siteOf(), base, docs())
|
|
428
431
|
let cache = state.caches.get(form)
|
|
429
432
|
if (!cache) state.caches.set(form, cache = new PublicationCache())
|
|
430
433
|
const shown = publishedDocument(lang || unfiltered ? doc : viewOf(doc, form), own, join(options.workDir, 'media', segment(dir)), docBase, linked, lang ? undefined : cache)
|
|
@@ -543,7 +546,7 @@ export async function startSiteServer(options: SiteServerOptions): Promise<SiteS
|
|
|
543
546
|
if (rest !== undefined && !page) return plainPage(res, 'missing')
|
|
544
547
|
const state = stateOf(dir)
|
|
545
548
|
let config = documentConfig(dir, doc, lang, form === 'slides' ? undefined : page, state.bundle, rest === undefined, block === undefined ? form : 'web')
|
|
546
|
-
if (landing) config = landingConfig(config, shown,
|
|
549
|
+
if (landing) config = landingConfig(config, shown, siteOf(), base, lang)
|
|
547
550
|
if (block !== undefined) {
|
|
548
551
|
config.block = block
|
|
549
552
|
config.ir += `&presmium-block=${encodeURIComponent(block)}`
|
|
@@ -581,7 +584,7 @@ export async function startSiteServer(options: SiteServerOptions): Promise<SiteS
|
|
|
581
584
|
const answer = published(dir, shown, lang)
|
|
582
585
|
const root = `${documentBase(base, dir)}${lang ? `${lang}/` : ''}`
|
|
583
586
|
const pages = lang ? translationPages(answer.doc, webPages(published(dir, doc).doc)) : webPages(answer.doc)
|
|
584
|
-
const site =
|
|
587
|
+
const site = siteOf()
|
|
585
588
|
answer.search ??= searchIndex([...searchDocuments(answer.doc, root, pages), ...isSingle(site) ? [] : siteNeighbours(site, base, dir, docs()).search])
|
|
586
589
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
587
590
|
res.end(answer.search)
|
|
@@ -666,7 +669,7 @@ export async function startSiteServer(options: SiteServerOptions): Promise<SiteS
|
|
|
666
669
|
|
|
667
670
|
/** The workspace's own pages: its home page (the list, when its home page is a document), in its languages. */
|
|
668
671
|
const siteRoute = async (res: ServerResponse, rest: string) => {
|
|
669
|
-
const site =
|
|
672
|
+
const site = siteOf()
|
|
670
673
|
const target = homeDocument(site)
|
|
671
674
|
const landing = landingDocument(site)
|
|
672
675
|
const [first, ...others] = rest.split('/')
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
4
|
+
import { deckLocale, isRecord } from '@lincyaw/kernel'
|
|
5
|
+
import type { RenderContext, SiteSpec } from '@lincyaw/kernel'
|
|
6
|
+
import { findInstalledPackage } from '@lincyaw/kernel/node'
|
|
7
|
+
import { isThemeReference, SITE_THEME_IDS, WEB_MESSAGES } from '../domain/index.ts'
|
|
8
|
+
import type { SiteThemeId } from '../domain/index.ts'
|
|
9
|
+
import { isSingle, siteLanguage } from './site-model.ts'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A site theme of the workspace's own (`site.theme: ./theme`, or an installed package): a directory with `theme.json`.
|
|
13
|
+
* It extends a built-in theme (its layouts and defaults), sets the theme's parameters, overrides design tokens for the
|
|
14
|
+
* light and the dark appearance, and adds web fonts and stylesheets. Its files are published below `_theme/<name>/`;
|
|
15
|
+
* every page of the site links them after the app's styles and carries `data-pm-site-theme-name`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const THEME_FILE = 'theme.json'
|
|
19
|
+
export const THEME_DIR = '_theme'
|
|
20
|
+
const TOKENS_FILE = 'presmium-tokens.css'
|
|
21
|
+
|
|
22
|
+
export interface ThemeManifest {
|
|
23
|
+
name: string
|
|
24
|
+
extends: SiteThemeId
|
|
25
|
+
params: Record<string, unknown>
|
|
26
|
+
/** Token name (without `--pm-`) to value; those the dark appearance overrides (colors, shadows) are the light ones. */
|
|
27
|
+
tokens: Record<string, string>
|
|
28
|
+
/** Values for the dark appearance, of the tokens it overrides (colors, shadows). */
|
|
29
|
+
dark: Record<string, string>
|
|
30
|
+
/** Stylesheets of web fonts (`https:` URLs). */
|
|
31
|
+
fonts: string[]
|
|
32
|
+
/** The theme's own stylesheets, relative to its directory. */
|
|
33
|
+
styles: string[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface LoadedTheme {
|
|
37
|
+
dir: string
|
|
38
|
+
file: string
|
|
39
|
+
manifest: ThemeManifest
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let designTokens: { all: Set<string>, mode: Set<string> } | undefined
|
|
43
|
+
/** The design system's token names, without `--pm-`: what a theme may set, and those the dark appearance overrides. */
|
|
44
|
+
function tokenNames(): { all: Set<string>, mode: Set<string> } {
|
|
45
|
+
if (designTokens) return designTokens
|
|
46
|
+
const css = readFileSync(createRequire(import.meta.url).resolve('@lincyaw/design/tokens.css'), 'utf8')
|
|
47
|
+
const names = (text: string) => [...text.matchAll(/--pm-([a-z0-9-]+)\s*:/g)].map(match => match[1]!)
|
|
48
|
+
const dark = [...css.matchAll(/:root\.dark\s*\{([^}]*)\}/g)].flatMap(match => names(match[1]!))
|
|
49
|
+
designTokens = { all: new Set(names(css)), mode: new Set(dark) }
|
|
50
|
+
return designTokens
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const NAME = /^[a-z0-9][a-z0-9-]{0,63}$/
|
|
54
|
+
// A value stays inside its declaration: no other declaration, rule or markup.
|
|
55
|
+
const VALUE = /^[^;{}<>\\]+$/
|
|
56
|
+
|
|
57
|
+
/** The directory `ref` names, from `from`: a path (`./theme`), else an installed package with `theme.json`. */
|
|
58
|
+
function themeDirectory(ref: string, from: string, lang: string): string {
|
|
59
|
+
const t = WEB_MESSAGES[deckLocale(lang)]
|
|
60
|
+
let dir: string | undefined
|
|
61
|
+
if (ref.startsWith('.') || isAbsolute(ref)) dir = resolve(from, ref)
|
|
62
|
+
else {
|
|
63
|
+
const pkg = findInstalledPackage(ref, from)
|
|
64
|
+
if (pkg) {
|
|
65
|
+
const own = JSON.parse(readFileSync(join(pkg, 'package.json'), 'utf8')) as { presmium?: { theme?: unknown } }
|
|
66
|
+
dir = typeof own.presmium?.theme === 'string' ? resolve(pkg, own.presmium.theme) : pkg
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!dir || !existsSync(join(dir, THEME_FILE))) throw new Error(t.unknownTheme(ref))
|
|
70
|
+
return dir
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const loaded = new Map<string, { at: number, theme: LoadedTheme }>()
|
|
74
|
+
|
|
75
|
+
export function loadTheme(ref: string, from: string, lang: string): LoadedTheme {
|
|
76
|
+
const dir = themeDirectory(ref, from, lang)
|
|
77
|
+
const file = join(dir, THEME_FILE)
|
|
78
|
+
const at = statSync(file).mtimeMs
|
|
79
|
+
const known = loaded.get(file)
|
|
80
|
+
if (known?.at === at) return known.theme
|
|
81
|
+
const t = WEB_MESSAGES[deckLocale(lang)]
|
|
82
|
+
const fail = (field: string): never => { throw new Error(t.invalidTheme(file, field)) }
|
|
83
|
+
let input: unknown
|
|
84
|
+
try { input = JSON.parse(readFileSync(file, 'utf8')) } catch { fail('JSON') }
|
|
85
|
+
if (!isRecord(input)) return fail('JSON')
|
|
86
|
+
if (input.schemaVersion !== undefined && input.schemaVersion !== 1) fail('schemaVersion')
|
|
87
|
+
if (typeof input.name !== 'string' || !NAME.test(input.name)) fail('name')
|
|
88
|
+
const base = input.extends ?? 'docs'
|
|
89
|
+
if (!SITE_THEME_IDS.includes(base as SiteThemeId)) fail('extends')
|
|
90
|
+
if (input.params !== undefined && !isRecord(input.params)) fail('params')
|
|
91
|
+
const tokens = (key: 'tokens' | 'dark'): Record<string, string> => {
|
|
92
|
+
const value = input[key]
|
|
93
|
+
if (value === undefined) return {}
|
|
94
|
+
if (!isRecord(value)) return fail(key)
|
|
95
|
+
for (const [name, token] of Object.entries(value)) {
|
|
96
|
+
if (!tokenNames().all.has(name) || key === 'dark' && !tokenNames().mode.has(name)) fail(`${key}.${name}`)
|
|
97
|
+
if (typeof token !== 'string' || !VALUE.test(token.trim())) fail(`${key}.${name}`)
|
|
98
|
+
}
|
|
99
|
+
return Object.fromEntries(Object.entries(value).map(([name, token]) => [name, (token as string).trim()]))
|
|
100
|
+
}
|
|
101
|
+
const list = (key: 'fonts' | 'styles', valid: (item: string) => boolean): string[] => {
|
|
102
|
+
const value = input[key]
|
|
103
|
+
if (value === undefined) return []
|
|
104
|
+
if (!Array.isArray(value)) return fail(key)
|
|
105
|
+
value.forEach((item, i) => { if (typeof item !== 'string' || !valid(item)) fail(`${key}[${i}]`) })
|
|
106
|
+
return value as string[]
|
|
107
|
+
}
|
|
108
|
+
const manifest: ThemeManifest = {
|
|
109
|
+
name: input.name as string,
|
|
110
|
+
extends: base as SiteThemeId,
|
|
111
|
+
params: isRecord(input.params) ? input.params : {},
|
|
112
|
+
tokens: tokens('tokens'),
|
|
113
|
+
dark: tokens('dark'),
|
|
114
|
+
fonts: list('fonts', item => /^https:\/\/[^\s"'<>]+$/.test(item)),
|
|
115
|
+
styles: list('styles', (item) => {
|
|
116
|
+
const path = resolve(dir, item)
|
|
117
|
+
return item.endsWith('.css') && !isAbsolute(item) && !relative(dir, path).startsWith('..') && existsSync(path)
|
|
118
|
+
}),
|
|
119
|
+
}
|
|
120
|
+
const theme = { dir, file, manifest }
|
|
121
|
+
loaded.set(file, { at, theme })
|
|
122
|
+
return theme
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The directory the site's configuration is written in: a theme's path is relative to it. */
|
|
126
|
+
function configDirectory(site: SiteSpec, ctx: RenderContext): string {
|
|
127
|
+
return isSingle(site) ? ctx.deckDir : ctx.workspaceRoot
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The site with its workspace theme resolved: `theme` is the built-in theme it extends, `params` the theme's under the
|
|
132
|
+
* site's own, and `themePackage` names it. Every path that reads the site's shell goes through this first.
|
|
133
|
+
*/
|
|
134
|
+
export function themedSite(site: SiteSpec, ctx: RenderContext): SiteSpec {
|
|
135
|
+
const ref = site.config.theme
|
|
136
|
+
if (!isThemeReference(ref) || isRecord(site.config.themePackage)) return site
|
|
137
|
+
const { dir, manifest } = loadTheme(ref.trim(), configDirectory(site, ctx), siteLanguage(site))
|
|
138
|
+
const own = site.config.params
|
|
139
|
+
const params = own === undefined ? manifest.params : isRecord(own) ? { ...manifest.params, ...own } : own
|
|
140
|
+
return { ...site, config: { ...site.config, theme: manifest.extends, params, themePackage: { name: manifest.name, dir } } }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The design tokens of the theme as CSS: the light appearance's colors and shadows, the dark one's, and the rest. */
|
|
144
|
+
export function themeTokensCss(manifest: ThemeManifest): string {
|
|
145
|
+
const own = `[data-pm-site-theme-name='${manifest.name}']`
|
|
146
|
+
const rule = (selectors: string[], entries: [string, string][]) => entries.length ? `${selectors.join(',\n')} {\n${entries.map(([name, value]) => ` --pm-${name}: ${value};`).join('\n')}\n}\n` : ''
|
|
147
|
+
const tokens = Object.entries(manifest.tokens)
|
|
148
|
+
return [
|
|
149
|
+
`/* The tokens of site theme ${manifest.name}, generated from its ${THEME_FILE}. */\n`,
|
|
150
|
+
rule([`:root:has(${own})`, own], tokens.filter(([name]) => !tokenNames().mode.has(name))),
|
|
151
|
+
rule([`:root:not(.dark):has(${own})`, `:root:not(.dark) ${own}`], tokens.filter(([name]) => tokenNames().mode.has(name))),
|
|
152
|
+
rule([`:root.dark:has(${own})`, `:root.dark ${own}`], Object.entries(manifest.dark)),
|
|
153
|
+
].filter(Boolean).join('\n')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The files of a theme directory with their modification times, to copy it again only when one changed. */
|
|
157
|
+
function signature(dir: string): string {
|
|
158
|
+
const files: string[] = []
|
|
159
|
+
const walk = (at: string) => {
|
|
160
|
+
for (const entry of readdirSync(at, { withFileTypes: true })) {
|
|
161
|
+
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue
|
|
162
|
+
const path = join(at, entry.name)
|
|
163
|
+
if (entry.isDirectory()) walk(path)
|
|
164
|
+
else files.push(`${relative(dir, path)}:${statSync(path).mtimeMs}`)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
walk(dir)
|
|
168
|
+
return files.sort().join('\n')
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const copied = new Map<string, string>()
|
|
172
|
+
|
|
173
|
+
/** Publishes the theme's files below `outDir/_theme/<name>/` and gives the site the stylesheets its pages link. */
|
|
174
|
+
export function publishedTheme(site: SiteSpec, outDir: string, base: string): SiteSpec {
|
|
175
|
+
const own = site.config.themePackage
|
|
176
|
+
if (!isRecord(own) || typeof own.dir !== 'string' || typeof own.name !== 'string') return site
|
|
177
|
+
const { dir, manifest } = loadTheme(own.dir, own.dir, siteLanguage(site))
|
|
178
|
+
const target = join(outDir, THEME_DIR, manifest.name)
|
|
179
|
+
const files = signature(dir) + manifest.name
|
|
180
|
+
if (copied.get(target) !== files || !existsSync(join(target, TOKENS_FILE))) {
|
|
181
|
+
rmSync(target, { recursive: true, force: true })
|
|
182
|
+
mkdirSync(target, { recursive: true })
|
|
183
|
+
cpSync(dir, target, { recursive: true, filter: source => !source.slice(dir.length).split(sep).some(part => part === 'node_modules' || part.startsWith('.')) })
|
|
184
|
+
writeFileSync(join(target, TOKENS_FILE), themeTokensCss(manifest))
|
|
185
|
+
copied.set(target, files)
|
|
186
|
+
}
|
|
187
|
+
const url = (path: string) => `${base}${THEME_DIR}/${manifest.name}/${relative(dir, resolve(dir, path)).split(sep).join('/')}`
|
|
188
|
+
const stylesheets = [...manifest.fonts, url(TOKENS_FILE), ...manifest.styles.map(url)]
|
|
189
|
+
return { ...site, config: { ...site.config, themePackage: { name: manifest.name, stylesheets } } }
|
|
190
|
+
}
|