@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 +10 -9
- package/src/app/site/layout.tsx +32 -17
- package/src/app/site.css +51 -17
- package/src/domain/app-config.ts +2 -0
- package/src/domain/config.ts +0 -20
- package/src/domain/index.ts +4 -4
- package/src/domain/landing.ts +1 -1
- package/src/domain/messages.ts +4 -2
- package/src/domain/pages.ts +12 -3
- 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 +11 -2
- package/src/node/site-model.ts +6 -2
- package/src/node/site-page.ts +2 -0
- package/src/node/site-server.ts +20 -12
- 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.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.
|
|
47
|
-
"@lincyaw/kernel": "^1.0.0-rc.
|
|
48
|
-
"@lincyaw/viewer": "^1.0.0-rc.
|
|
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.
|
|
56
|
-
"@lincyaw/kernel": "1.0.0-rc.
|
|
57
|
-
"@lincyaw/cli": "1.0.0-rc.
|
|
58
|
-
"@lincyaw/viewer": "1.0.0-rc.
|
|
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
|
}
|
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> }
|
|
@@ -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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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'
|
|
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 &&
|
|
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 &&
|
|
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 &&
|
|
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
|
}
|
package/src/domain/app-config.ts
CHANGED
|
@@ -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
|
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'
|
|
@@ -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'
|
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/pages.ts
CHANGED
|
@@ -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 [
|
|
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).
|
|
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
|
|
35
|
+
return i === 0 ? [] : [{ path, section: first.id }]
|
|
27
36
|
})
|
|
28
37
|
}
|
|
29
38
|
|
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
|
@@ -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
|
|
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: '',
|
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> {
|
|
@@ -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 ? {
|
|
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
|
|
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
|
@@ -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
|
-
|
|
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)
|
|
@@ -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
|
|
647
|
-
if (pageAt(
|
|
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 =
|
|
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
|
+
}
|