@lincyaw/web 1.0.0-rc.1 → 1.0.0-rc.3

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.1",
3
+ "version": "1.0.0-rc.3",
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.3"
44
45
  },
45
46
  "peerDependencies": {
46
- "@lincyaw/host": "^1.0.0-rc.1",
47
- "@lincyaw/kernel": "^1.0.0-rc.1",
48
- "@lincyaw/viewer": "^1.0.0-rc.1"
47
+ "@lincyaw/host": "^1.0.0-rc.3",
48
+ "@lincyaw/kernel": "^1.0.0-rc.3",
49
+ "@lincyaw/viewer": "^1.0.0-rc.3"
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/host": "1.0.0-rc.1",
56
- "@lincyaw/kernel": "1.0.0-rc.1",
57
- "@lincyaw/cli": "1.0.0-rc.1",
58
- "@lincyaw/viewer": "1.0.0-rc.1"
56
+ "@lincyaw/host": "1.0.0-rc.3",
57
+ "@lincyaw/kernel": "1.0.0-rc.3",
58
+ "@lincyaw/cli": "1.0.0-rc.3",
59
+ "@lincyaw/viewer": "1.0.0-rc.3"
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> }
@@ -28,20 +29,35 @@ export function NavLinks({ items, vertical = false, onNavigate }: { items: NavIt
28
29
  })}</ul>
29
30
  }
30
31
  function LanguageMenu({ locales, lang, hrefIn }: { locales: SiteLocale[], lang: string, hrefIn(locale: SiteLocale): string }) { const t = useMessages(WEB_MESSAGES), [open, setOpen] = useState(false); return <PmPopover open={open} label={t.language} name="pm-site-languages" onOpenChange={setOpen} trigger={<PmIconButton icon="language" label={t.language} />}><ul className="pm-site-languages">{locales.map(locale => <li key={locale.lang}><a className="pm-site-languages__link" href={hrefIn(locale)} lang={locale.lang} aria-current={locale.lang === lang ? 'true' : undefined}>{locale.label}</a></li>)}</ul></PmPopover> }
31
- function Sidebar({ router, course = false }: { router: PageRouter, course?: boolean }) {
32
- const viewer = useViewer(), t = useMessages(WEB_MESSAGES), pages = useStore(router.pages), shown = useStore(router.shown), doc = useStore(viewer.doc)
32
+ /**
33
+ * The site's documents, grouped by their first tag, with this document's chapters under it; a document on its own
34
+ * shows its chapters. A document has one page, so a chapter is a place on it.
35
+ */
36
+ function Sidebar({ config, router, course = false }: { config: AppConfig, router: PageRouter, course?: boolean }) {
37
+ const viewer = useViewer(), t = useMessages(WEB_MESSAGES), doc = useStore(viewer.doc), pages = useStore(router.pages), position = useStore(viewer.position)
38
+ const top = doc.sections.filter(section => isChapterTitle(doc, section))
39
+ const own = router.href(pages[0] ?? { path: 'index', title: '', sections: [] })
40
+ const chapters = <ol className="pm-site-sidebar__sections">{(top.length > 1 ? top : doc.sections).map(section => <li key={section.id}><a className="pm-site-sidebar__section" href={router.sectionHref(section.id)} aria-current={position.section === section.id ? 'location' : undefined}>{section.title}</a></li>)}</ol>
41
+ const groups = new Map<string, { dir: string, title: string, href: string }[]>()
42
+ for (const item of config.site.documents ?? [{ dir: config.document, title: doc.meta.title, href: own }]) groups.set(item.group ?? '', [...groups.get(item.group ?? '') ?? [], item])
43
+ let number = 0
33
44
  return <nav className="pm-site-sidebar" aria-label={course ? t.courseContents : t.sidebarNav}>
34
- {course && <div className="pm-site-sidebar__intro"><p className="pm-ui-heading">{t.courseContents}</p><p className="pm-site-sidebar__title">{doc.meta.title}</p></div>}
35
- <ol className="pm-site-sidebar__chapters">{pages.map((page, index) => {
36
- const current = page.path === shown?.path, sections = page.sections.slice(1).flatMap(id => viewer.section(id) ?? [])
37
- return <li key={page.path} className={`pm-site-sidebar__chapter${current ? ' pm-current' : ''}`}>
38
- <a className="pm-site-sidebar__link" href={router.href(page)} aria-current={current ? 'page' : undefined}>{course && <span className="pm-site-sidebar__number" aria-hidden="true">{String(index + 1).padStart(2, '0')}</span>}<span>{page.title}</span></a>
39
- {sections.length > 0 && <ol className="pm-site-sidebar__sections">{sections.map(section => <li key={section.id}><a className="pm-site-sidebar__section" href={router.sectionHref(section.id)}>{section.title}</a></li>)}</ol>}
40
- </li>
41
- })}</ol>
45
+ {course && <div className="pm-site-sidebar__intro"><p className="pm-ui-heading">{t.courseContents}</p><p className="pm-site-sidebar__title">{config.site.brandTitle ?? doc.meta.title}</p></div>}
46
+ {[...groups].map(([group, items]) => <div key={group} className="pm-site-sidebar__group">
47
+ {group && <p className="pm-site-sidebar__heading">{group}</p>}
48
+ <ol className="pm-site-sidebar__chapters">{items.map((item) => {
49
+ const current = item.dir === config.document
50
+ number++
51
+ return <li key={item.dir} className={`pm-site-sidebar__chapter${current ? ' pm-current' : ''}`}>
52
+ <a className="pm-site-sidebar__link" href={current ? own : item.href} aria-current={current ? 'page' : undefined}>{course && <span className="pm-site-sidebar__number" aria-hidden="true">{String(number).padStart(2, '0')}</span>}<span>{item.title}</span></a>
53
+ {current && chapters}
54
+ </li>
55
+ })}</ol>
56
+ </div>)}
42
57
  </nav>
43
58
  }
44
- function Outline({ router, compact = false }: { router: PageRouter, compact?: boolean }) { const viewer = useViewer(), doc = useStore(viewer.doc), position = useStore(viewer.position), shown = useStore(router.shown), t = useMessages(WEB_MESSAGES); const sections = (shown?.sections ?? []).flatMap(id => viewer.section(id) ?? []).filter(section => !isChapterTitle(doc, section)); const title = compact ? 'pm-site-outline-title-compact' : 'pm-site-outline-title'; return sections.length ? <nav className="pm-site-outline" aria-labelledby={title}><p id={title} className={`pm-site-outline__title${compact ? ' pm-ui-sr-only' : ''}`}>{t.outline}</p><ol className="pm-site-outline__items">{sections.map(section => <li key={section.id}><a className="pm-site-outline__link" href={`#${encodeURIComponent(section.id)}`} aria-current={position.section === section.id ? 'location' : undefined}>{section.title}</a></li>)}</ol></nav> : null }
59
+ /** Every section of the page; in a document of chapters, the sections within one are indented under it. */
60
+ function Outline({ router, compact = false }: { router: PageRouter, compact?: boolean }) { const viewer = useViewer(), doc = useStore(viewer.doc), position = useStore(viewer.position), shown = useStore(router.shown), t = useMessages(WEB_MESSAGES); const sections = (shown?.sections ?? []).flatMap(id => viewer.section(id) ?? []); const nested = sections.some(section => isChapterTitle(doc, section)) && sections.some(section => !isChapterTitle(doc, section)); const title = compact ? 'pm-site-outline-title-compact' : 'pm-site-outline-title'; return sections.length > 1 ? <nav className="pm-site-outline" aria-labelledby={title}><p id={title} className={`pm-site-outline__title${compact ? ' pm-ui-sr-only' : ''}`}>{t.outline}</p><ol className="pm-site-outline__items">{sections.map(section => <li key={section.id}><a className={`pm-site-outline__link${nested && !isChapterTitle(doc, section) ? ' pm-site-outline__link--nested' : ''}`} href={`#${encodeURIComponent(section.id)}`} aria-current={position.section === section.id ? 'location' : undefined}>{section.title}</a></li>)}</ol></nav> : null }
45
61
  function Pager({ config, router }: { config: AppConfig, router: PageRouter }) { const t = useMessages(WEB_MESSAGES), pages = useStore(router.pages), shown = useStore(router.shown), index = pages.findIndex(page => page.path === shown?.path); const neighbour = (step: number) => { const page = pages[index + step]; return page ? { text: page.title, link: router.href(page) } : step < 0 ? config.site.neighbours?.prev : config.site.neighbours?.next }; const prev = neighbour(-1), next = neighbour(1); return prev || next ? <nav className="pm-site-pager" aria-label={t.pager}>{[[prev, t.prevChapter, 'prev'], [next, t.nextChapter, 'next']].map(([other, label, rel]) => { const link = other as { text: string, link: string } | undefined; return link ? <a key={String(rel)} className={`pm-site-pager__link pm-site-pager__link--${rel}`} href={link.link} rel={String(rel)}><span className="pm-site-pager__label">{String(label)}</span><span className="pm-site-pager__title">{link.text}</span></a> : <span key={String(rel)} /> })}</nav> : null }
46
62
  export function SiteFooter({ config }: { config: AppConfig }) { const { message, copyright } = config.site.shell.footer ?? {}; return message || copyright ? <footer className="pm-site-footer">{message && <p className="pm-site-footer__message" dangerouslySetInnerHTML={{ __html: message }} />}{copyright && <p className="pm-site-footer__copyright" dangerouslySetInnerHTML={{ __html: copyright }} />}</footer> : null }
47
63
  export function SiteHeader({ config, titleHref, hrefIn, document: isDocument, onMenu, onSearch }: { config: AppConfig, titleHref: string, hrefIn(locale: SiteLocale): string, document?: boolean, onMenu?(): void, onSearch?(): void }) {
@@ -51,28 +67,27 @@ export function SiteHeader({ config, titleHref, hrefIn, document: isDocument, on
51
67
  export function DocumentSite({ config, router }: { config: AppConfig, router: PageRouter }) {
52
68
  const viewer = useViewer(), t = useMessages(WEB_MESSAGES), doc = useStore(viewer.doc), page = useStore(router.page), shown = useStore(router.shown), pages = useStore(router.pages), view = useStore(viewer.view)
53
69
  const [drawer, setDrawer] = useState<'site' | 'chapters'>(), [outline, setOutline] = useState(false), [searching, setSearching] = useState(false)
54
- const theme = config.site.shell.theme?.id ?? 'docs', layout = config.landing ? 'landing' : sitePageLayout(isRecord(doc.config.renderers.web) ? doc.config.renderers.web : {}, config.lang), landing = layout === 'landing', course = theme === 'course', showcase = theme === 'showcase' && !landing
70
+ const theme = config.site.shell.theme?.id ?? 'docs', layout = config.landing ? 'landing' : sitePageLayout(isRecord(doc.config.renderers.web) ? doc.config.renderers.web : {}, config.lang), landing = layout === 'landing', course = theme === 'course'
55
71
  useEffect(() => { setDrawer(undefined); setOutline(false) }, [page])
56
72
  useEffect(() => { const key = (event: KeyboardEvent) => { if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k' && config.search) { event.preventDefault(); setSearching(true) } }; window.addEventListener('keydown', key); return () => window.removeEventListener('keydown', key) }, [config.search])
57
73
  const hrefIn = (locale: SiteLocale) => { const own = locale.base.replace(/\/$/, ''); return shown && shown.path !== 'index' && (!locale.layout || locale.layout === layout) ? `${own}/${shown.path}.html` : `${own}/` }
58
74
  return <div className="pm-site" {...siteThemeAttributes(config)} data-pm-site-layout={layout}>
59
75
  <a className="pm-site__skip" href="#pm-site-main">{t.skipToContent}</a>
60
76
  <SiteHeader config={config} titleHref={config.site.home ?? router.href(pages[0] ?? { path: 'index', title: '', sections: [] })} hrefIn={hrefIn} document onMenu={() => setDrawer('site')} onSearch={() => setSearching(true)} />
61
- {!landing && !showcase && <div className="pm-site-localnav"><button type="button" className="pm-site-localnav__button" onClick={() => setDrawer('chapters')}><PmIcon name="navigation" />{course ? t.courseContents : t.menu}</button>{layout !== 'wide' && (shown?.sections.length ?? 0) > 1 && <PmPopover open={outline} label={t.outline} placement="below-end" name="pm-site-outline-menu" onOpenChange={setOutline} trigger={<button type="button" className="pm-site-localnav__button">{t.outline}<PmIcon name="chevron-down" /></button>}><Outline router={router} compact /></PmPopover>}</div>}
62
- {showcase && <nav className="pm-site-chapterbar" aria-label={t.menu}>{pages.map(item => <a key={item.path} className={`pm-ui-button pm-ui-button--${item.path === shown?.path ? 'accent' : 'ghost'}`} href={router.href(item)} aria-current={item.path === shown?.path ? 'page' : undefined}>{item.title}</a>)}</nav>}
77
+ {!landing && <div className="pm-site-localnav"><button type="button" className="pm-site-localnav__button" onClick={() => setDrawer('chapters')}><PmIcon name="navigation" />{course ? t.courseContents : t.menu}</button>{layout !== 'wide' && (shown?.sections.length ?? 0) > 1 && <PmPopover open={outline} label={t.outline} placement="below-end" name="pm-site-outline-menu" onOpenChange={setOutline} trigger={<button type="button" className="pm-site-localnav__button">{t.outline}<PmIcon name="chevron-down" /></button>}><Outline router={router} compact /></PmPopover>}</div>}
63
78
  <div className="pm-site__body">
64
- {!landing && !showcase && <aside className="pm-site__sidebar"><Sidebar router={router} course={course} /></aside>}
79
+ {!landing && <aside className="pm-site__sidebar"><Sidebar config={config} router={router} course={course} /></aside>}
65
80
  <main id="pm-site-main" className="pm-site__main" tabIndex={-1}>
66
81
  <article className="pm-site__article"><PageViewOutlet key={shown?.path} sections={shown?.sections ?? []} /></article>
67
82
  {!landing && <Pager config={config} router={router} />}<SiteFooter config={config} />
68
83
  </main>
69
- {!landing && !showcase && layout !== 'wide' && <aside className="pm-site__aside"><Outline router={router} /></aside>}
84
+ {!landing && layout !== 'wide' && <aside className="pm-site__aside"><Outline router={router} /></aside>}
70
85
  </div>
71
86
  <div className="pm-document pm-overlay-views"><OverlayViewOutlet /></div>
72
87
  {view === 'presenter' && <PmPresenterPanels to=".pm-presenter__side" docked />}
73
88
  {config.search && <SearchDialog open={searching} index={config.search} onOpenChange={setSearching} />}
74
89
  <PmDrawer open={drawer !== undefined} label={drawer === 'site' ? t.mobileNav : course ? t.courseContents : t.menu} closeLabel={t.close} name="pm-site-menu" onOpenChange={open => { if (!open) setDrawer(undefined) }}>
75
- {drawer === 'site' ? <>{(config.site.nav ?? config.site.shell.nav)?.length ? <NavLinks items={config.site.nav ?? config.site.shell.nav!} vertical /> : null}{config.site.home && <a className="pm-site-link" href={config.site.home}><PmIcon name="arrow-left" />{t.documents}</a>}<div className="pm-site-menu__tools"><ViewMenu place="screen" /><PmToolbar className="pm-toolbar--screen" /></div></> : <Sidebar router={router} course={course} />}
90
+ {drawer === 'site' ? <>{(config.site.nav ?? config.site.shell.nav)?.length ? <NavLinks items={config.site.nav ?? config.site.shell.nav!} vertical /> : null}{config.site.home && <a className="pm-site-link" href={config.site.home}><PmIcon name="arrow-left" />{t.documents}</a>}<div className="pm-site-menu__tools"><ViewMenu place="screen" /><PmToolbar className="pm-toolbar--screen" /></div></> : <Sidebar config={config} router={router} course={course} />}
76
91
  </PmDrawer>
77
92
  </div>
78
93
  }
package/src/app/site.css CHANGED
@@ -57,7 +57,8 @@
57
57
  background: var(--pm-color-product-bg);
58
58
  }
59
59
 
60
- .pm-site[data-pm-site-theme='showcase'] .pm-site-header {
60
+ .pm-site[data-pm-site-theme='showcase'] .pm-site-header,
61
+ .pm-site[data-pm-site-theme='showcase'] .pm-site-localnav {
61
62
  border-bottom-color: color-mix(in srgb, var(--pm-color-line) 70%, transparent);
62
63
  background: color-mix(in srgb, var(--pm-color-surface) 68%, transparent);
63
64
  backdrop-filter: blur(var(--pm-product-glass-blur));
@@ -350,6 +351,50 @@
350
351
  color: var(--pm-color-ink);
351
352
  }
352
353
 
354
+ .pm-site-sidebar__section[aria-current] {
355
+ color: var(--pm-color-accent);
356
+ }
357
+
358
+ /* The site's documents, in groups by their first tag. */
359
+ .pm-site-sidebar__group + .pm-site-sidebar__group {
360
+ margin-top: var(--pm-space-5);
361
+ }
362
+
363
+ .pm-site-sidebar__heading {
364
+ margin: 0 0 var(--pm-space-2);
365
+ padding-inline: var(--pm-space-2);
366
+ color: var(--pm-color-muted);
367
+ font-size: var(--pm-text-xs);
368
+ font-weight: var(--pm-weight-semibold);
369
+ letter-spacing: var(--pm-tracking-label);
370
+ }
371
+
372
+ /* A section's permalink: a mark beside its title, shown when the title is pointed at or the link focused. */
373
+ .pm-site .pm-section__title {
374
+ position: relative;
375
+ }
376
+
377
+ .pm-site .header-anchor {
378
+ position: absolute;
379
+ left: 0;
380
+ padding-right: 0.3em;
381
+ transform: translateX(-100%);
382
+ color: var(--pm-color-accent);
383
+ font-weight: var(--pm-weight-medium);
384
+ text-decoration: none;
385
+ opacity: 0;
386
+ transition: opacity var(--pm-duration) var(--pm-ease);
387
+ }
388
+
389
+ .pm-site .header-anchor::before {
390
+ content: '#';
391
+ }
392
+
393
+ .pm-site .pm-section__title:hover .header-anchor,
394
+ .pm-site .header-anchor:focus-visible {
395
+ opacity: 1;
396
+ }
397
+
353
398
  .pm-site-outline__title {
354
399
  margin: 0 0 var(--pm-space-2);
355
400
  color: var(--pm-color-ink);
@@ -371,6 +416,10 @@
371
416
  line-height: var(--pm-leading-tight);
372
417
  }
373
418
 
419
+ .pm-site-outline__link--nested {
420
+ padding-left: calc(2 * var(--pm-space-3));
421
+ }
422
+
374
423
  .pm-site-outline__link:hover {
375
424
  color: var(--pm-color-ink);
376
425
  }
@@ -932,20 +981,6 @@ html.pm-presenting {
932
981
  font-size: var(--pm-text-xs);
933
982
  }
934
983
 
935
- .pm-site-chapterbar {
936
- display: flex;
937
- gap: var(--pm-space-2);
938
- max-width: var(--pm-site-wide-width);
939
- margin-inline: auto;
940
- padding: var(--pm-space-4) var(--pm-space-6) 0;
941
- overflow-x: auto;
942
- }
943
-
944
- /* The page buttons keep their width and the bar scrolls sideways on narrow screens. */
945
- .pm-site-chapterbar > * {
946
- flex: none;
947
- }
948
-
949
984
  .pm-site[data-pm-site-layout='wide'] .pm-site__body {
950
985
  grid-template-columns: var(--pm-site-sidebar-width) minmax(0, 1fr);
951
986
  max-width: calc(var(--pm-site-sidebar-width) + var(--pm-site-wide-width) + 2 * var(--pm-space-8));
@@ -955,8 +990,7 @@ html.pm-presenting {
955
990
  max-width: var(--pm-site-wide-width);
956
991
  }
957
992
 
958
- .pm-site[data-pm-site-layout='landing'] .pm-site__body,
959
- .pm-site[data-pm-site-theme='showcase'] .pm-site__body {
993
+ .pm-site[data-pm-site-layout='landing'] .pm-site__body {
960
994
  grid-template-columns: minmax(0, 1fr);
961
995
  max-width: var(--pm-site-wide-width);
962
996
  }
@@ -33,6 +33,8 @@ export interface SiteConfig {
33
33
  home?: string
34
34
  /** The document's languages, its own first; one when it has no translation. */
35
35
  locales: SiteLocale[]
36
+ /** The workspace's documents in its order, for the sidebar; a document's first tag is its group there. */
37
+ documents?: { dir: string, title: string, href: string, group?: string }[]
36
38
  /** The documents before and after this one in the workspace: the pager goes on to them. */
37
39
  neighbours?: { prev?: Neighbour, next?: Neighbour }
38
40
  links: CrossFormLinks
@@ -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'
@@ -8,10 +8,10 @@ export type { AppConfig, SiteConfig, SiteLocale } from './app-config.ts'
8
8
  export { parsePositionQuery, parsePrintQuery, positionUrl, PRINTED_ATTRIBUTE, printQuery, SERVICE_WORKER, SLIDE_PARAM, urlPath, withPositionQuery } from './links.ts'
9
9
  export type { SlidesPrintQuery } from './links.ts'
10
10
  export type { PositionQuery } from './links.ts'
11
- export { pageLink, SEARCH_FIELDS, searchDocuments, sectionLinks, translationPages, webPages } from './pages.ts'
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',
@@ -11,19 +11,28 @@ function baseName(file: string): string {
11
11
  return file.split(/[\\/]/).pop()!.replace(/\.[^.]+$/, '')
12
12
  }
13
13
 
14
+ /** A document is one web page with every section, as a static site shows each source file on one page. */
14
15
  export function webPages(doc: IRDocument): WebPage[] {
16
+ return [{ path: 'index', title: doc.meta.title, sections: doc.sections.map(section => section.id) }]
17
+ }
18
+
19
+ /**
20
+ * The pages earlier sites split a document into, one per chapter: their addresses go on to the chapter's section on
21
+ * the document's one page, so links to them keep working.
22
+ */
23
+ export function chapterRedirects(doc: IRDocument): { path: string, section: string }[] {
15
24
  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) }]
25
+ if (isRecord(web) && web.layout === 'landing') return []
17
26
  const paths = new Set<string>()
18
27
  let file: string | undefined
19
- return chapters(doc).map((chapter, i) => {
28
+ return chapters(doc).flatMap((chapter, i) => {
20
29
  const first = chapter.sections[0]!
21
30
  const name = i === 0 ? 'index' : urlSlug(first.source.file === file ? first.id : baseName(first.source.file)) || 'page'
22
31
  let path = name
23
32
  for (let n = 2; paths.has(path); n++) path = `${name}-${n}`
24
33
  paths.add(path)
25
34
  file = first.source.file
26
- return { path, title: chapter.title, sections: chapter.sections.map(section => section.id) }
35
+ return i === 0 ? [] : [{ path, section: first.id }]
27
36
  })
28
37
  }
29
38
 
@@ -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()
@@ -6,7 +6,7 @@ import { ABSOLUTE_URL, errorMessage, isRecord } from '@lincyaw/kernel'
6
6
  import type { IRDocument, NodeVitePlugin, RenderContext, SiteSpec } from '@lincyaw/kernel'
7
7
  import { FONT_LICENSE, fontLicense } from '@lincyaw/kernel/node'
8
8
  import { SitemapStream, streamToPromise } from 'sitemap'
9
- import { offlineEnabled, pageLink, SERVICE_WORKER, searchDocuments, sitePageLayout, translationPages, webPages } from '../domain/index.ts'
9
+ import { chapterRedirects, offlineEnabled, pageLink, SERVICE_WORKER, searchDocuments, sitePageLayout, translationPages, webPages } from '../domain/index.ts'
10
10
  import type { AppConfig, SiteConfig, SiteShell, WebPage } from '../domain/index.ts'
11
11
  import { BUNDLE_ENTRY, buildDocumentBundle, documentComponents } from './document-bundle.ts'
12
12
  import type { DocumentBundle } from './document-bundle.ts'
@@ -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'
@@ -181,10 +182,17 @@ async function buildDocumentPages(site: SiteSpec, ctx: RenderContext, built: Bui
181
182
  await renderPage(built, join(ctx.outDir, locale.prefix, 'index.html'), landing, title, metaTags(shell, image, title), locale.doc, components)
182
183
  }
183
184
  }
185
+ for (const { path, section } of chapterRedirects(published)) writeFileSync(join(out, locale.prefix, `${path}.html`), chapterRedirect(section))
184
186
  }
185
187
  if (offline) writeServiceWorker(out, base, { dir: built.appDir, url: built.appUrl })
186
188
  }
187
189
 
190
+ /** The page a chapter used to have: it goes on to the chapter's section on the document's page. */
191
+ function chapterRedirect(section: string): string {
192
+ const target = `./#${encodeURIComponent(section)}`
193
+ return `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0; url=${target}"><link rel="canonical" href="${target}"><meta name="robots" content="noindex"></head><body><a href="${target}">${target}</a></body></html>\n`
194
+ }
195
+
188
196
  /** A page that shows no document (the home page, a missing page), below the site's path. */
189
197
  function pageConfig(kind: 'home' | 'missing', base: string, lang: string, site: SiteConfig, extra: Partial<AppConfig> = {}): AppConfig {
190
198
  return { kind, document: '', base, lang, site, origins: [], rendered: true, ...extra }
@@ -235,6 +243,7 @@ async function buildMissing(site: SiteSpec, ctx: RenderContext, built: Built, ti
235
243
  export async function buildSite(site: SiteSpec, ctx: RenderContext, documents: readonly BuiltDocument[]): Promise<void> {
236
244
  const workspace = !isSingle(site)
237
245
  // Invalid presentation settings fail before replacing an existing build.
246
+ site = themedSite(site, ctx)
238
247
  siteShellBelow(site, ctx.base)
239
248
  landingDocument(site)
240
249
  loadLanding(site, ctx)
@@ -280,7 +289,7 @@ export async function buildSlidesSite(doc: IRDocument, ctx: RenderContext, nodeP
280
289
  const { code } = await documentFiles(ctx, built, entry)
281
290
  writeFileSync(join(ctx.outDir, 'ir.json'), JSON.stringify(published))
282
291
  const lang = doc.meta.lang ?? ctx.lang ?? 'zh-CN'
283
- 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)
284
293
  const config: AppConfig = {
285
294
  kind: 'document',
286
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> {
@@ -299,7 +299,11 @@ export function documentSiteConfig(input: { site: SiteSpec, base: string, dir: s
299
299
  shell,
300
300
  locales,
301
301
  links: input.workspace ? { ...links, slides: Boolean(links.slidesUrl) } : links,
302
- ...input.workspace ? { home: listLink(site, root), neighbours: siteNeighbours(site, base, dir, input.docs).neighbours } : {},
302
+ ...input.workspace ? {
303
+ home: listLink(site, root),
304
+ neighbours: siteNeighbours(site, base, dir, input.docs).neighbours,
305
+ documents: site.documents.filter(other => other.dir !== '').map(other => ({ dir: other.dir, title: other.title, href: documentBase(base, other.dir), ...other.tags?.[0] ? { group: other.tags[0] } : {} })),
306
+ } : {},
303
307
  }
304
308
  }
305
309
 
@@ -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>`,
@@ -14,7 +14,7 @@ import { isInside, spawnInteractiveNode } from '@lincyaw/kernel/node'
14
14
  import { diffDocuments } from '@lincyaw/viewer/domain'
15
15
  import { createProxyServer } from 'http-proxy-3'
16
16
  import send from 'send'
17
- import { searchDocuments, sitePageLayout, translationPages, UPDATE_EVENT, webPages } from '../domain/index.ts'
17
+ import { chapterRedirects, searchDocuments, sitePageLayout, translationPages, UPDATE_EVENT, webPages } from '../domain/index.ts'
18
18
  import type { AppConfig, DevUpdateEvent, WebPage } from '../domain/index.ts'
19
19
  import { BUNDLE_ENTRY, documentComponents, findDocumentBundle } from './document-bundle.ts'
20
20
  import type { DocumentBundle, DocumentBundleInput } from './document-bundle.ts'
@@ -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)
@@ -643,8 +646,13 @@ export async function startSiteServer(options: SiteServerOptions): Promise<SiteS
643
646
  if (formOf(url) === 'slides' && (rest === '' || rest === 'index.html')) return documentPageResponse(res, dir, '', undefined, 'slides', url.searchParams.get('presmium-block') ?? undefined)
644
647
  if (first && others.length > 0 && translationOf(dir, first)) return documentPageResponse(res, dir, first, others.join('/'), formOf(url), url.searchParams.get('presmium-block') ?? undefined)
645
648
  if (rest === '' || rest.endsWith('.html')) {
646
- const pages = webPages(published(dir, doc).doc)
647
- if (pageAt(pages, rest)) return documentPageResponse(res, dir, '', rest, formOf(url), url.searchParams.get('presmium-block') ?? undefined)
649
+ const shown = published(dir, doc).doc
650
+ if (pageAt(webPages(shown), rest)) return documentPageResponse(res, dir, '', rest, formOf(url), url.searchParams.get('presmium-block') ?? undefined)
651
+ const chapter = chapterRedirects(shown).find(redirect => `${redirect.path}.html` === rest)
652
+ if (chapter) {
653
+ res.writeHead(302, { Location: `./#${encodeURIComponent(chapter.section)}` })
654
+ return void res.end()
655
+ }
648
656
  }
649
657
  if (rest.startsWith('media/')) return serveFile(req, res, join(options.workDir, 'media', segment(dir)), `/${rest.split('/').map(encodeURIComponent).join('/')}`, false)
650
658
  const state = stateOf(dir)
@@ -661,7 +669,7 @@ export async function startSiteServer(options: SiteServerOptions): Promise<SiteS
661
669
 
662
670
  /** The workspace's own pages: its home page (the list, when its home page is a document), in its languages. */
663
671
  const siteRoute = async (res: ServerResponse, rest: string) => {
664
- const site = options.site()
672
+ const site = siteOf()
665
673
  const target = homeDocument(site)
666
674
  const landing = landingDocument(site)
667
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
+ }