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

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 +78 -0
  14. package/src/app/site/search.tsx +22 -0
  15. package/src/app/site.css +1416 -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 +89 -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 +12 -39
  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 +297 -0
  39. package/src/node/site-files.ts +87 -0
  40. package/src/node/site-model.ts +333 -0
  41. package/src/node/site-page.ts +82 -0
  42. package/src/node/site-server.ts +817 -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,78 @@
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
+ 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)
33
+ 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>
42
+ </nav>
43
+ }
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 }
45
+ 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
+ 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
+ export function SiteHeader({ config, titleHref, hrefIn, document: isDocument, onMenu, onSearch }: { config: AppConfig, titleHref: string, hrefIn(locale: SiteLocale): string, document?: boolean, onMenu?(): void, onSearch?(): void }) {
48
+ 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
49
+ 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>
50
+ }
51
+ export function DocumentSite({ config, router }: { config: AppConfig, router: PageRouter }) {
52
+ 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
+ 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
55
+ useEffect(() => { setDrawer(undefined); setOutline(false) }, [page])
56
+ 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
+ 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
+ return <div className="pm-site" {...siteThemeAttributes(config)} data-pm-site-layout={layout}>
59
+ <a className="pm-site__skip" href="#pm-site-main">{t.skipToContent}</a>
60
+ <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>}
63
+ <div className="pm-site__body">
64
+ {!landing && !showcase && <aside className="pm-site__sidebar"><Sidebar router={router} course={course} /></aside>}
65
+ <main id="pm-site-main" className="pm-site__main" tabIndex={-1}>
66
+ <article className="pm-site__article"><PageViewOutlet key={shown?.path} sections={shown?.sections ?? []} /></article>
67
+ {!landing && <Pager config={config} router={router} />}<SiteFooter config={config} />
68
+ </main>
69
+ {!landing && !showcase && layout !== 'wide' && <aside className="pm-site__aside"><Outline router={router} /></aside>}
70
+ </div>
71
+ <div className="pm-document pm-overlay-views"><OverlayViewOutlet /></div>
72
+ {view === 'presenter' && <PmPresenterPanels to=".pm-presenter__side" docked />}
73
+ {config.search && <SearchDialog open={searching} index={config.search} onOpenChange={setSearching} />}
74
+ <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} />}
76
+ </PmDrawer>
77
+ </div>
78
+ }
@@ -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
+ }