@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincyaw/web",
3
- "version": "1.0.0-rc.2",
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.2",
47
- "@lincyaw/kernel": "^1.0.0-rc.2",
48
- "@lincyaw/viewer": "^1.0.0-rc.2"
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.2",
56
- "@lincyaw/host": "1.0.0-rc.2",
57
- "@lincyaw/kernel": "1.0.0-rc.2",
58
- "@lincyaw/viewer": "1.0.0-rc.2"
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
  }
@@ -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> }
@@ -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
- }
@@ -1,5 +1,5 @@
1
- export { crossFormLinks, offlineEnabled, rendererConfig, siteShell, WEB_ID } from './config.ts'
2
- export type { CrossFormLinks, NavItem, SiteShell, WebRendererConfig } from './config.ts'
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'
@@ -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. */
@@ -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',
@@ -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
 
@@ -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
- return { schemaVersion: 1, title: siteTitle(site, lang), base, lang, documents: documents.map(({ dir, title, href, description, lang, tags, pages }) => ({ id: dir, title, href, description, lang, tags, pages })) }
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
  }
@@ -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 { port } = rendererConfig(ctx)
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,
@@ -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()
@@ -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: SiteSpec = { title: doc.meta.title, documents: [{ dir: '', title: doc.meta.title, doc }], config: ctx.config }
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: '',
@@ -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> {
@@ -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>`,
@@ -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
- siteShellBelow(options.site(), base)
194
- landingDocument(options.site())
195
- loadLanding(options.site(), ctx)
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 = () => options.site().documents.map(entry => entry.dir)
216
- const shellSite = () => publishedSite(options.site(), ctx, join(options.workDir, 'site-assets'), `${base}${PREFIX}site-assets/`)
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(options.site()) ? undefined : documentPage(options.site(), base, docs())
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, options.site(), base, lang)
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 = options.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 = options.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
+ }