@lincyaw/web 1.0.0-rc.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincyaw/web",
3
- "version": "1.0.0-rc.1",
3
+ "version": "1.0.0-rc.2",
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": {
@@ -43,18 +43,18 @@
43
43
  "react-dom": "18.3.1"
44
44
  },
45
45
  "peerDependencies": {
46
- "@lincyaw/host": "^1.0.0-rc.1",
47
- "@lincyaw/kernel": "^1.0.0-rc.1",
48
- "@lincyaw/viewer": "^1.0.0-rc.1"
46
+ "@lincyaw/host": "^1.0.0-rc.2",
47
+ "@lincyaw/kernel": "^1.0.0-rc.2",
48
+ "@lincyaw/viewer": "^1.0.0-rc.2"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@types/escape-html": "1.0.4",
52
52
  "@types/send": "^1.2.1",
53
53
  "@types/react": "18.3.28",
54
54
  "@types/react-dom": "18.3.7",
55
- "@lincyaw/host": "1.0.0-rc.1",
56
- "@lincyaw/kernel": "1.0.0-rc.1",
57
- "@lincyaw/cli": "1.0.0-rc.1",
58
- "@lincyaw/viewer": "1.0.0-rc.1"
55
+ "@lincyaw/cli": "1.0.0-rc.2",
56
+ "@lincyaw/host": "1.0.0-rc.2",
57
+ "@lincyaw/kernel": "1.0.0-rc.2",
58
+ "@lincyaw/viewer": "1.0.0-rc.2"
59
59
  }
60
60
  }
@@ -28,20 +28,35 @@ export function NavLinks({ items, vertical = false, onNavigate }: { items: NavIt
28
28
  })}</ul>
29
29
  }
30
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)
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
33
43
  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>
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>)}
42
56
  </nav>
43
57
  }
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 }
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 }
45
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 }
46
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 }
47
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 }) {
@@ -51,28 +66,27 @@ export function SiteHeader({ config, titleHref, hrefIn, document: isDocument, on
51
66
  export function DocumentSite({ config, router }: { config: AppConfig, router: PageRouter }) {
52
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)
53
68
  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
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'
55
70
  useEffect(() => { setDrawer(undefined); setOutline(false) }, [page])
56
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])
57
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}/` }
58
73
  return <div className="pm-site" {...siteThemeAttributes(config)} data-pm-site-layout={layout}>
59
74
  <a className="pm-site__skip" href="#pm-site-main">{t.skipToContent}</a>
60
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)} />
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>}
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>}
63
77
  <div className="pm-site__body">
64
- {!landing && !showcase && <aside className="pm-site__sidebar"><Sidebar router={router} course={course} /></aside>}
78
+ {!landing && <aside className="pm-site__sidebar"><Sidebar config={config} router={router} course={course} /></aside>}
65
79
  <main id="pm-site-main" className="pm-site__main" tabIndex={-1}>
66
80
  <article className="pm-site__article"><PageViewOutlet key={shown?.path} sections={shown?.sections ?? []} /></article>
67
81
  {!landing && <Pager config={config} router={router} />}<SiteFooter config={config} />
68
82
  </main>
69
- {!landing && !showcase && layout !== 'wide' && <aside className="pm-site__aside"><Outline router={router} /></aside>}
83
+ {!landing && layout !== 'wide' && <aside className="pm-site__aside"><Outline router={router} /></aside>}
70
84
  </div>
71
85
  <div className="pm-document pm-overlay-views"><OverlayViewOutlet /></div>
72
86
  {view === 'presenter' && <PmPresenterPanels to=".pm-presenter__side" docked />}
73
87
  {config.search && <SearchDialog open={searching} index={config.search} onOpenChange={setSearching} />}
74
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) }}>
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} />}
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} />}
76
90
  </PmDrawer>
77
91
  </div>
78
92
  }
package/src/app/site.css CHANGED
@@ -57,7 +57,8 @@
57
57
  background: var(--pm-color-product-bg);
58
58
  }
59
59
 
60
- .pm-site[data-pm-site-theme='showcase'] .pm-site-header {
60
+ .pm-site[data-pm-site-theme='showcase'] .pm-site-header,
61
+ .pm-site[data-pm-site-theme='showcase'] .pm-site-localnav {
61
62
  border-bottom-color: color-mix(in srgb, var(--pm-color-line) 70%, transparent);
62
63
  background: color-mix(in srgb, var(--pm-color-surface) 68%, transparent);
63
64
  backdrop-filter: blur(var(--pm-product-glass-blur));
@@ -350,6 +351,50 @@
350
351
  color: var(--pm-color-ink);
351
352
  }
352
353
 
354
+ .pm-site-sidebar__section[aria-current] {
355
+ color: var(--pm-color-accent);
356
+ }
357
+
358
+ /* The site's documents, in groups by their first tag. */
359
+ .pm-site-sidebar__group + .pm-site-sidebar__group {
360
+ margin-top: var(--pm-space-5);
361
+ }
362
+
363
+ .pm-site-sidebar__heading {
364
+ margin: 0 0 var(--pm-space-2);
365
+ padding-inline: var(--pm-space-2);
366
+ color: var(--pm-color-muted);
367
+ font-size: var(--pm-text-xs);
368
+ font-weight: var(--pm-weight-semibold);
369
+ letter-spacing: var(--pm-tracking-label);
370
+ }
371
+
372
+ /* A section's permalink: a mark beside its title, shown when the title is pointed at or the link focused. */
373
+ .pm-site .pm-section__title {
374
+ position: relative;
375
+ }
376
+
377
+ .pm-site .header-anchor {
378
+ position: absolute;
379
+ left: 0;
380
+ padding-right: 0.3em;
381
+ transform: translateX(-100%);
382
+ color: var(--pm-color-accent);
383
+ font-weight: var(--pm-weight-medium);
384
+ text-decoration: none;
385
+ opacity: 0;
386
+ transition: opacity var(--pm-duration) var(--pm-ease);
387
+ }
388
+
389
+ .pm-site .header-anchor::before {
390
+ content: '#';
391
+ }
392
+
393
+ .pm-site .pm-section__title:hover .header-anchor,
394
+ .pm-site .header-anchor:focus-visible {
395
+ opacity: 1;
396
+ }
397
+
353
398
  .pm-site-outline__title {
354
399
  margin: 0 0 var(--pm-space-2);
355
400
  color: var(--pm-color-ink);
@@ -371,6 +416,10 @@
371
416
  line-height: var(--pm-leading-tight);
372
417
  }
373
418
 
419
+ .pm-site-outline__link--nested {
420
+ padding-left: calc(2 * var(--pm-space-3));
421
+ }
422
+
374
423
  .pm-site-outline__link:hover {
375
424
  color: var(--pm-color-ink);
376
425
  }
@@ -932,20 +981,6 @@ html.pm-presenting {
932
981
  font-size: var(--pm-text-xs);
933
982
  }
934
983
 
935
- .pm-site-chapterbar {
936
- display: flex;
937
- gap: var(--pm-space-2);
938
- max-width: var(--pm-site-wide-width);
939
- margin-inline: auto;
940
- padding: var(--pm-space-4) var(--pm-space-6) 0;
941
- overflow-x: auto;
942
- }
943
-
944
- /* The page buttons keep their width and the bar scrolls sideways on narrow screens. */
945
- .pm-site-chapterbar > * {
946
- flex: none;
947
- }
948
-
949
984
  .pm-site[data-pm-site-layout='wide'] .pm-site__body {
950
985
  grid-template-columns: var(--pm-site-sidebar-width) minmax(0, 1fr);
951
986
  max-width: calc(var(--pm-site-sidebar-width) + var(--pm-site-wide-width) + 2 * var(--pm-space-8));
@@ -955,8 +990,7 @@ html.pm-presenting {
955
990
  max-width: var(--pm-site-wide-width);
956
991
  }
957
992
 
958
- .pm-site[data-pm-site-layout='landing'] .pm-site__body,
959
- .pm-site[data-pm-site-theme='showcase'] .pm-site__body {
993
+ .pm-site[data-pm-site-layout='landing'] .pm-site__body {
960
994
  grid-template-columns: minmax(0, 1fr);
961
995
  max-width: var(--pm-site-wide-width);
962
996
  }
@@ -33,6 +33,8 @@ export interface SiteConfig {
33
33
  home?: string
34
34
  /** The document's languages, its own first; one when it has no translation. */
35
35
  locales: SiteLocale[]
36
+ /** The workspace's documents in its order, for the sidebar; a document's first tag is its group there. */
37
+ documents?: { dir: string, title: string, href: string, group?: string }[]
36
38
  /** The documents before and after this one in the workspace: the pager goes on to them. */
37
39
  neighbours?: { prev?: Neighbour, next?: Neighbour }
38
40
  links: CrossFormLinks
@@ -8,7 +8,7 @@ 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
14
  export { SITE_THEME_IDS, SITE_THEMES, siteTheme, sitePageLayout } from './themes.ts'
@@ -11,19 +11,28 @@ function baseName(file: string): string {
11
11
  return file.split(/[\\/]/).pop()!.replace(/\.[^.]+$/, '')
12
12
  }
13
13
 
14
+ /** A document is one web page with every section, as a static site shows each source file on one page. */
14
15
  export function webPages(doc: IRDocument): WebPage[] {
16
+ return [{ path: 'index', title: doc.meta.title, sections: doc.sections.map(section => section.id) }]
17
+ }
18
+
19
+ /**
20
+ * The pages earlier sites split a document into, one per chapter: their addresses go on to the chapter's section on
21
+ * the document's one page, so links to them keep working.
22
+ */
23
+ export function chapterRedirects(doc: IRDocument): { path: string, section: string }[] {
15
24
  const web = doc.config.renderers.web
16
- if (isRecord(web) && web.layout === 'landing') return [{ path: 'index', title: doc.meta.title, sections: doc.sections.map(section => section.id) }]
25
+ if (isRecord(web) && web.layout === 'landing') return []
17
26
  const paths = new Set<string>()
18
27
  let file: string | undefined
19
- return chapters(doc).map((chapter, i) => {
28
+ return chapters(doc).flatMap((chapter, i) => {
20
29
  const first = chapter.sections[0]!
21
30
  const name = i === 0 ? 'index' : urlSlug(first.source.file === file ? first.id : baseName(first.source.file)) || 'page'
22
31
  let path = name
23
32
  for (let n = 2; paths.has(path); n++) path = `${name}-${n}`
24
33
  paths.add(path)
25
34
  file = first.source.file
26
- return { path, title: chapter.title, sections: chapter.sections.map(section => section.id) }
35
+ return i === 0 ? [] : [{ path, section: first.id }]
27
36
  })
28
37
  }
29
38
 
@@ -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'
@@ -181,10 +181,17 @@ async function buildDocumentPages(site: SiteSpec, ctx: RenderContext, built: Bui
181
181
  await renderPage(built, join(ctx.outDir, locale.prefix, 'index.html'), landing, title, metaTags(shell, image, title), locale.doc, components)
182
182
  }
183
183
  }
184
+ for (const { path, section } of chapterRedirects(published)) writeFileSync(join(out, locale.prefix, `${path}.html`), chapterRedirect(section))
184
185
  }
185
186
  if (offline) writeServiceWorker(out, base, { dir: built.appDir, url: built.appUrl })
186
187
  }
187
188
 
189
+ /** The page a chapter used to have: it goes on to the chapter's section on the document's page. */
190
+ function chapterRedirect(section: string): string {
191
+ const target = `./#${encodeURIComponent(section)}`
192
+ 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`
193
+ }
194
+
188
195
  /** A page that shows no document (the home page, a missing page), below the site's path. */
189
196
  function pageConfig(kind: 'home' | 'missing', base: string, lang: string, site: SiteConfig, extra: Partial<AppConfig> = {}): AppConfig {
190
197
  return { kind, document: '', base, lang, site, origins: [], rendered: true, ...extra }
@@ -299,7 +299,11 @@ export function documentSiteConfig(input: { site: SiteSpec, base: string, dir: s
299
299
  shell,
300
300
  locales,
301
301
  links: input.workspace ? { ...links, slides: Boolean(links.slidesUrl) } : links,
302
- ...input.workspace ? { home: listLink(site, root), neighbours: siteNeighbours(site, base, dir, input.docs).neighbours } : {},
302
+ ...input.workspace ? {
303
+ home: listLink(site, root),
304
+ neighbours: siteNeighbours(site, base, dir, input.docs).neighbours,
305
+ documents: site.documents.filter(other => other.dir !== '').map(other => ({ dir: other.dir, title: other.title, href: documentBase(base, other.dir), ...other.tags?.[0] ? { group: other.tags[0] } : {} })),
306
+ } : {},
303
307
  }
304
308
  }
305
309
 
@@ -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'
@@ -643,8 +643,13 @@ export async function startSiteServer(options: SiteServerOptions): Promise<SiteS
643
643
  if (formOf(url) === 'slides' && (rest === '' || rest === 'index.html')) return documentPageResponse(res, dir, '', undefined, 'slides', url.searchParams.get('presmium-block') ?? undefined)
644
644
  if (first && others.length > 0 && translationOf(dir, first)) return documentPageResponse(res, dir, first, others.join('/'), formOf(url), url.searchParams.get('presmium-block') ?? undefined)
645
645
  if (rest === '' || rest.endsWith('.html')) {
646
- const pages = webPages(published(dir, doc).doc)
647
- if (pageAt(pages, rest)) return documentPageResponse(res, dir, '', rest, formOf(url), url.searchParams.get('presmium-block') ?? undefined)
646
+ const shown = published(dir, doc).doc
647
+ if (pageAt(webPages(shown), rest)) return documentPageResponse(res, dir, '', rest, formOf(url), url.searchParams.get('presmium-block') ?? undefined)
648
+ const chapter = chapterRedirects(shown).find(redirect => `${redirect.path}.html` === rest)
649
+ if (chapter) {
650
+ res.writeHead(302, { Location: `./#${encodeURIComponent(chapter.section)}` })
651
+ return void res.end()
652
+ }
648
653
  }
649
654
  if (rest.startsWith('media/')) return serveFile(req, res, join(options.workDir, 'media', segment(dir)), `/${rest.split('/').map(encodeURIComponent).join('/')}`, false)
650
655
  const state = stateOf(dir)