@lincyaw/web 1.0.0-rc.0 → 1.0.0-rc.2

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.
Files changed (58) hide show
  1. package/README.md +33 -4
  2. package/package.json +20 -19
  3. package/src/app/base.css +117 -0
  4. package/src/app/block-playback.ts +81 -0
  5. package/src/app/block-report.ts +104 -0
  6. package/src/app/document-modules.ts +30 -0
  7. package/src/app/env.d.ts +12 -0
  8. package/src/app/main.ts +198 -0
  9. package/src/app/page-app.ts +166 -0
  10. package/src/app/router.ts +109 -0
  11. package/src/app/server.ts +32 -0
  12. package/src/app/site/home.tsx +117 -0
  13. package/src/app/site/layout.tsx +92 -0
  14. package/src/app/site/search.tsx +22 -0
  15. package/src/app/site.css +1450 -0
  16. package/src/client/document.css +172 -0
  17. package/src/client/host.ts +22 -94
  18. package/src/client/preview-frame.ts +152 -0
  19. package/src/client/runtime.ts +159 -0
  20. package/src/domain/app-config.ts +91 -0
  21. package/src/domain/config.ts +17 -9
  22. package/src/domain/dev-updates.ts +12 -0
  23. package/src/domain/index.ts +17 -7
  24. package/src/domain/landing.ts +83 -0
  25. package/src/domain/links.ts +23 -18
  26. package/src/domain/messages.ts +74 -63
  27. package/src/domain/pages.ts +23 -41
  28. package/src/domain/themes.ts +59 -0
  29. package/src/domain/workspace-home.ts +12 -0
  30. package/src/node/client-modules.ts +92 -0
  31. package/src/node/document-bundle.ts +227 -0
  32. package/src/node/landing-template.ts +34 -0
  33. package/src/node/landing.ts +82 -0
  34. package/src/node/offline.ts +34 -11
  35. package/src/node/publish.ts +83 -15
  36. package/src/node/renderer.ts +78 -117
  37. package/src/node/site-assets.ts +17 -0
  38. package/src/node/site-build.ts +304 -0
  39. package/src/node/site-files.ts +87 -0
  40. package/src/node/site-model.ts +337 -0
  41. package/src/node/site-page.ts +82 -0
  42. package/src/node/site-server.ts +822 -0
  43. package/src/node/slides.ts +135 -0
  44. package/src/node/viewer-app.ts +504 -0
  45. package/src/node/viewer-build.ts +38 -0
  46. package/custom-nav-controls.vue +0 -17
  47. package/src/client/env.d.ts +0 -1
  48. package/src/client/home.ts +0 -182
  49. package/src/client/index.ts +0 -4
  50. package/src/client/page.ts +0 -38
  51. package/src/client/preview.ts +0 -71
  52. package/src/client/style.css +0 -427
  53. package/src/client/theme.ts +0 -322
  54. package/src/node/addons.ts +0 -76
  55. package/src/node/site.ts +0 -349
  56. package/src/node/sites.ts +0 -531
  57. package/src/node/vite.ts +0 -166
  58. package/src/node/vitepress.ts +0 -72
@@ -0,0 +1,92 @@
1
+ import { PmDrawer, PmIcon, PmIconButton, PmPopover, PmPresenterPanels, PmToolbar, useAppearance, useMessages, useStore } from '@lincyaw/host'
2
+ import { isChapterTitle, isRecord } from '@lincyaw/kernel'
3
+ import { listViews, OverlayViewOutlet, PageViewOutlet, useViewer } from '@lincyaw/viewer'
4
+ import { useEffect, useState } from 'react'
5
+ import { sitePageLayout, WEB_MESSAGES } from '../../domain/index.ts'
6
+ import type { AppConfig, NavItem, SiteLocale } from '../../domain/index.ts'
7
+ import type { PageRouter } from '../router.ts'
8
+ import { SearchDialog } from './search.tsx'
9
+
10
+ export const APPEARANCE_KEY = 'presmium-appearance'
11
+ /** The same attributes drive site layouts and the design system's theme token mappings. */
12
+ export function siteThemeAttributes(config: AppConfig) {
13
+ const theme = config.site.shell.theme
14
+ return {
15
+ 'data-pm-site-theme': theme?.id ?? 'docs',
16
+ 'data-pm-site-accent': theme?.params.accent,
17
+ 'data-pm-site-typography': theme?.params.typography,
18
+ 'data-pm-site-width': theme?.params.contentWidth,
19
+ }
20
+ }
21
+ 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> }
22
+ function AppearanceToggle() { const t = useMessages(WEB_MESSAGES), { dark, setDark } = useAppearance(); return <PmIconButton className="pm-site-appearance" icon={dark ? 'sun' : 'moon'} label={dark ? t.toLight : t.toDark} onClick={() => { setDark(!dark); try { localStorage.setItem(APPEARANCE_KEY, dark ? 'light' : 'dark') } catch {} }} /> }
23
+ export function NavLinks({ items, vertical = false, onNavigate }: { items: NavItem[], vertical?: boolean, onNavigate?(): void }) {
24
+ const [open, setOpen] = useState<string>()
25
+ return <ul className={`pm-site-nav${vertical ? ' pm-site-nav--vertical' : ''}`}>{items.map((item, index) => {
26
+ if (!item.items?.length) { const external = item.link !== undefined && /^[a-z][a-z\d+.-]*:/i.test(item.link); return <li key={`${item.text}-${index}`} className="pm-site-nav__item"><a className="pm-site-nav__link" href={item.link} onClick={onNavigate} {...external ? { target: '_blank', rel: 'noopener' } : {}}>{item.text}{external && <PmIcon name="external" />}</a></li> }
27
+ return <li key={`${item.text}-${index}`} className="pm-site-nav__group">{vertical ? <><p className="pm-site-nav__heading">{item.text}</p><NavLinks items={item.items} vertical onNavigate={onNavigate} /></> : <PmPopover open={open === item.text} label={item.text} placement="below-start" name="pm-site-nav" onOpenChange={next => setOpen(next ? item.text : undefined)} trigger={<button type="button" className="pm-site-nav__link">{item.text}<PmIcon name="chevron-down" /></button>}><NavLinks items={item.items} vertical onNavigate={onNavigate} /></PmPopover>}</li>
28
+ })}</ul>
29
+ }
30
+ 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
+ * The site's documents, grouped by their first tag, with this document's chapters under it; a document on its own
33
+ * shows its chapters. A document has one page, so a chapter is a place on it.
34
+ */
35
+ function Sidebar({ config, router, course = false }: { config: AppConfig, router: PageRouter, course?: boolean }) {
36
+ const viewer = useViewer(), t = useMessages(WEB_MESSAGES), doc = useStore(viewer.doc), pages = useStore(router.pages), position = useStore(viewer.position)
37
+ const top = doc.sections.filter(section => isChapterTitle(doc, section))
38
+ const own = router.href(pages[0] ?? { path: 'index', title: '', sections: [] })
39
+ 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>
40
+ const groups = new Map<string, { dir: string, title: string, href: string }[]>()
41
+ 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])
42
+ let number = 0
43
+ return <nav className="pm-site-sidebar" aria-label={course ? t.courseContents : t.sidebarNav}>
44
+ {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>}
45
+ {[...groups].map(([group, items]) => <div key={group} className="pm-site-sidebar__group">
46
+ {group && <p className="pm-site-sidebar__heading">{group}</p>}
47
+ <ol className="pm-site-sidebar__chapters">{items.map((item) => {
48
+ const current = item.dir === config.document
49
+ number++
50
+ return <li key={item.dir} className={`pm-site-sidebar__chapter${current ? ' pm-current' : ''}`}>
51
+ <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>
52
+ {current && chapters}
53
+ </li>
54
+ })}</ol>
55
+ </div>)}
56
+ </nav>
57
+ }
58
+ /** Every section of the page; in a document of chapters, the sections within one are indented under it. */
59
+ 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 }
60
+ 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 }
61
+ 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 }
62
+ export function SiteHeader({ config, titleHref, hrefIn, document: isDocument, onMenu, onSearch }: { config: AppConfig, titleHref: string, hrefIn(locale: SiteLocale): string, document?: boolean, onMenu?(): void, onSearch?(): void }) {
63
+ const t = useMessages(WEB_MESSAGES), nav = config.site.nav ?? config.site.shell.nav ?? [], brand = config.site.brandTitle ?? config.site.title, logo = config.site.shell.theme?.params.logo
64
+ return <header className="pm-site-header"><div className="pm-site-header__start"><PmIconButton className="pm-site-header__menu" icon="menu" label={t.mobileNav} onClick={onMenu} /><a className="pm-site-header__title" href={config.site.root ?? titleHref}>{logo && <img className="pm-site-header__logo" src={logo} alt={config.site.shell.theme?.params.logoAlt ?? ''} />}<span>{brand}</span></a></div>{nav.length > 0 && <nav className="pm-site-header__nav" aria-label={t.extraNav}><NavLinks items={nav} /></nav>}<div className="pm-site-header__tools">{isDocument && config.search && <button type="button" className="pm-site-search-button pm-ui-search" aria-label={t.search} onClick={onSearch}><PmIcon name="search" /><span className="pm-site-search-button__text">{t.search}</span><kbd className="pm-ui-kbd">{t.searchShortcut}</kbd></button>}{config.site.home && <a className="pm-site-link" href={config.site.home}><PmIcon name="arrow-left" />{t.documents}</a>}{config.site.locales.length > 1 && <LanguageMenu locales={config.site.locales} lang={config.lang} hrefIn={hrefIn} />}{isDocument && <ViewMenu place="bar" />}<PmToolbar className="pm-toolbar--bar" /><AppearanceToggle /></div></header>
65
+ }
66
+ export function DocumentSite({ config, router }: { config: AppConfig, router: PageRouter }) {
67
+ 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)
68
+ const [drawer, setDrawer] = useState<'site' | 'chapters'>(), [outline, setOutline] = useState(false), [searching, setSearching] = useState(false)
69
+ 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
+ useEffect(() => { setDrawer(undefined); setOutline(false) }, [page])
71
+ 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])
72
+ const hrefIn = (locale: SiteLocale) => { const own = locale.base.replace(/\/$/, ''); return shown && shown.path !== 'index' && (!locale.layout || locale.layout === layout) ? `${own}/${shown.path}.html` : `${own}/` }
73
+ return <div className="pm-site" {...siteThemeAttributes(config)} data-pm-site-layout={layout}>
74
+ <a className="pm-site__skip" href="#pm-site-main">{t.skipToContent}</a>
75
+ <SiteHeader config={config} titleHref={config.site.home ?? router.href(pages[0] ?? { path: 'index', title: '', sections: [] })} hrefIn={hrefIn} document onMenu={() => setDrawer('site')} onSearch={() => setSearching(true)} />
76
+ {!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>}
77
+ <div className="pm-site__body">
78
+ {!landing && <aside className="pm-site__sidebar"><Sidebar config={config} router={router} course={course} /></aside>}
79
+ <main id="pm-site-main" className="pm-site__main" tabIndex={-1}>
80
+ <article className="pm-site__article"><PageViewOutlet key={shown?.path} sections={shown?.sections ?? []} /></article>
81
+ {!landing && <Pager config={config} router={router} />}<SiteFooter config={config} />
82
+ </main>
83
+ {!landing && layout !== 'wide' && <aside className="pm-site__aside"><Outline router={router} /></aside>}
84
+ </div>
85
+ <div className="pm-document pm-overlay-views"><OverlayViewOutlet /></div>
86
+ {view === 'presenter' && <PmPresenterPanels to=".pm-presenter__side" docked />}
87
+ {config.search && <SearchDialog open={searching} index={config.search} onOpenChange={setSearching} />}
88
+ <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) }}>
89
+ {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} />}
90
+ </PmDrawer>
91
+ </div>
92
+ }
@@ -0,0 +1,22 @@
1
+ import { PmDialog, PmIcon, useMessages } from '@lincyaw/host'
2
+ import { tokenize } from '@lincyaw/kernel/reading'
3
+ import type MiniSearch from 'minisearch'
4
+ import type { SearchResult } from 'minisearch'
5
+ import { useEffect, useRef, useState } from 'react'
6
+ import { SEARCH_FIELDS, WEB_MESSAGES } from '../../domain/index.ts'
7
+ const QUERY = { prefix: true, fuzzy: 0.2, boost: { title: 4, text: 2, titles: 1 } }
8
+ const indexes = new Map<string, Promise<MiniSearch>>()
9
+ function loadIndex(url: string): Promise<MiniSearch> {
10
+ let index = indexes.get(url)
11
+ if (!index) { index = Promise.all([import('minisearch'), fetch(url).then(response => { if (!response.ok) throw new Error(`search index HTTP ${response.status}`); return response.text() })]).then(([{ default: Search }, json]) => Search.loadJSON(json, { ...SEARCH_FIELDS, tokenize })); index.catch(() => indexes.delete(url)); indexes.set(url, index) }
12
+ return index
13
+ }
14
+ export function forgetIndex(url: string) { indexes.delete(url) }
15
+ export function SearchDialog({ open, index, onOpenChange }: { open: boolean, index: string, onOpenChange?(open: boolean): void }) {
16
+ const t = useMessages(WEB_MESSAGES), [query, setQuery] = useState(''), [results, setResults] = useState<SearchResult[]>([]), [active, setActive] = useState(0), [failed, setFailed] = useState(false)
17
+ const input = useRef<HTMLInputElement>(null), list = useRef<HTMLUListElement>(null)
18
+ useEffect(() => { let current = true; if (!query.trim()) { setResults([]); return } void loadIndex(index).then(search => { if (current) { setResults(search.search(query.trim(), QUERY).slice(0, 30)); setActive(0); setFailed(false) } }).catch(error => { if (current) { console.warn('[presmium] search unavailable', error); setFailed(true) } }); return () => { current = false } }, [query, index])
19
+ useEffect(() => { if (open) { const frame = requestAnimationFrame(() => { input.current?.focus(); input.current?.select() }); return () => cancelAnimationFrame(frame) } }, [open])
20
+ useEffect(() => { list.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' }) }, [active])
21
+ return <PmDialog open={open} label={t.search} closeLabel={t.close} name="pm-site-search" onOpenChange={onOpenChange}><label className="pm-ui-search pm-site-search__field"><PmIcon name="search" /><input ref={input} className="pm-site-search__input" type="search" aria-controls="pm-site-search-results" aria-label={t.search} placeholder={t.searchPlaceholder} value={query} onChange={event => setQuery(event.target.value)} onKeyDown={event => { if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); if (results.length) setActive((active + (event.key === 'ArrowDown' ? 1 : -1) + results.length) % results.length) } else if (event.key === 'Enter') { const link = list.current?.querySelector<HTMLAnchorElement>('[aria-selected="true"] a'); if (link) { event.preventDefault(); onOpenChange?.(false); link.click() } } }} /></label><ul id="pm-site-search-results" ref={list} className="pm-site-search__results" role="listbox" aria-label={t.search}>{results.map((result, i) => <li key={String(result.id)} role="option" aria-selected={i === active} className="pm-site-search__result" onMouseEnter={() => setActive(i)}><a className="pm-site-search__link" href={String(result.id)} aria-label={[...(result.titles as string[] | undefined) ?? [], String(result.title)].join(' > ')} onClick={() => onOpenChange?.(false)}>{(result.titles as string[] | undefined)?.length ? <span className="pm-site-search__path">{(result.titles as string[]).join(' › ')}</span> : null}<span className="pm-site-search__title">{String(result.title)}</span></a></li>)}</ul>{((query.trim() && !results.length) || failed) && <p className="pm-site-search__empty">{t.noResults(query.trim())}</p>}<p className="pm-site-search__keys"><span><kbd className="pm-ui-kbd" aria-label={t.keyEnter}>↵</kbd> {t.searchSelect}</span><span><kbd className="pm-ui-kbd" aria-label={t.keyUp}>↑</kbd><kbd className="pm-ui-kbd" aria-label={t.keyDown}>↓</kbd> {t.searchNavigate}</span><span><kbd className="pm-ui-kbd" aria-label={t.keyEscape}>Esc</kbd> {t.searchClose}</span></p></PmDialog>
22
+ }