@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 +8 -8
- package/src/app/site/layout.tsx +31 -17
- package/src/app/site.css +51 -17
- package/src/domain/app-config.ts +2 -0
- package/src/domain/index.ts +1 -1
- package/src/domain/pages.ts +12 -3
- package/src/node/site-build.ts +8 -1
- package/src/node/site-model.ts +5 -1
- package/src/node/site-server.ts +8 -3
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.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.
|
|
47
|
-
"@lincyaw/kernel": "^1.0.0-rc.
|
|
48
|
-
"@lincyaw/viewer": "^1.0.0-rc.
|
|
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/
|
|
56
|
-
"@lincyaw/
|
|
57
|
-
"@lincyaw/
|
|
58
|
-
"@lincyaw/viewer": "1.0.0-rc.
|
|
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
|
}
|
package/src/app/site/layout.tsx
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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'
|
|
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 &&
|
|
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 &&
|
|
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 &&
|
|
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
|
}
|
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/index.ts
CHANGED
|
@@ -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'
|
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/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'
|
|
@@ -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 }
|
package/src/node/site-model.ts
CHANGED
|
@@ -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-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'
|
|
@@ -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
|
|
647
|
-
if (pageAt(
|
|
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)
|