lectio-docs 0.1.0
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/LICENSE +21 -0
- package/app/components/site-header.tsx +149 -0
- package/app/content.server.ts +49 -0
- package/app/root.tsx +52 -0
- package/app/routes/docs.css +92 -0
- package/app/routes/docs.tsx +166 -0
- package/app/routes.ts +29 -0
- package/app/site.config.ts +8 -0
- package/bin/lectio.mjs +176 -0
- package/package.json +42 -0
- package/react-router.config.ts +32 -0
- package/vite.config.ts +6 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Losol AS
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { Link, useLocation, useMatches, useNavigate } from 'react-router';
|
|
3
|
+
|
|
4
|
+
import { OramaProvider } from '@eventuras/lectio-docs/search';
|
|
5
|
+
import { Search } from '@eventuras/lectio-docs-react';
|
|
6
|
+
import { Navbar } from '@eventuras/ratio-ui/core/Navbar';
|
|
7
|
+
import { NavTree } from '@eventuras/ratio-ui/core/NavTree';
|
|
8
|
+
import { ThemeToggle } from '@eventuras/ratio-ui/core/ThemeToggle';
|
|
9
|
+
|
|
10
|
+
import type { DocsNavGroup } from '../routes/docs';
|
|
11
|
+
import { site } from '../site.config';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Site header on ratio-ui's Navbar (sticky app-header form: fluid + elevated —
|
|
15
|
+
* elevation, never borders). The search zone hosts the lectio <Search>
|
|
16
|
+
* CommandPalette, which brings its own trigger and the global ⌘K shortcut.
|
|
17
|
+
*
|
|
18
|
+
* On narrow screens the docs nav collapses into the navbar's own
|
|
19
|
+
* Toggle/Collapse pair (the "pocket library" pattern): a burger that folds the
|
|
20
|
+
* NavTree out under the bar. The nav data comes from the docs route's loader
|
|
21
|
+
* via useMatches, so the header stays route-agnostic. The Navbar is keyed on
|
|
22
|
+
* the pathname because the disclosure state is internal with no controlled
|
|
23
|
+
* API — remounting on navigation is what closes the panel after a link is
|
|
24
|
+
* followed.
|
|
25
|
+
*
|
|
26
|
+
* Theme state lives on <html data-theme> (set before first paint by the
|
|
27
|
+
* blocking script in root.tsx). The toggle reads it after mount — `null`
|
|
28
|
+
* during SSR keeps server and client renders identical — and persists the
|
|
29
|
+
* choice so the init script can restore it next visit.
|
|
30
|
+
*/
|
|
31
|
+
export function SiteHeader() {
|
|
32
|
+
const navigate = useNavigate();
|
|
33
|
+
const { pathname } = useLocation();
|
|
34
|
+
const [theme, setTheme] = useState<'light' | 'dark' | null>(null);
|
|
35
|
+
|
|
36
|
+
// The index is built from the collected manifest at build time and shipped
|
|
37
|
+
// as a static asset; Orama restores it in the browser on the first query.
|
|
38
|
+
const provider = useMemo(() => new OramaProvider('/search-index.json'), []);
|
|
39
|
+
|
|
40
|
+
// Nav tree from whichever active route provided it (the docs loader).
|
|
41
|
+
// UIMatch carries the loader's return as `loaderData` in React Router v8.
|
|
42
|
+
const matches = useMatches();
|
|
43
|
+
const docsData = matches.find(
|
|
44
|
+
(m) => m.loaderData && typeof m.loaderData === 'object' && 'navGroups' in m.loaderData,
|
|
45
|
+
)?.loaderData as { navGroups: DocsNavGroup[]; slug: string } | undefined;
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const current = document.documentElement.getAttribute('data-theme');
|
|
49
|
+
setTheme(current === 'dark' ? 'dark' : 'light');
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
const onThemeChange = (next: 'light' | 'dark') => {
|
|
53
|
+
document.documentElement.setAttribute('data-theme', next);
|
|
54
|
+
try {
|
|
55
|
+
localStorage.setItem('lectio-theme', next);
|
|
56
|
+
} catch {
|
|
57
|
+
// Storage disabled (private mode) — the toggle still works for this
|
|
58
|
+
// visit, the choice just isn't remembered. Mirrors the init script's
|
|
59
|
+
// guarded read.
|
|
60
|
+
}
|
|
61
|
+
setTheme(next);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<Navbar key={pathname} sticky elevated fluid>
|
|
66
|
+
<Navbar.Brand>
|
|
67
|
+
<Link
|
|
68
|
+
to="/"
|
|
69
|
+
style={{ display: 'flex', alignItems: 'center', gap: 10, textDecoration: 'none', color: 'var(--text)' }}
|
|
70
|
+
>
|
|
71
|
+
<span
|
|
72
|
+
aria-hidden
|
|
73
|
+
style={{
|
|
74
|
+
display: 'inline-flex',
|
|
75
|
+
alignItems: 'center',
|
|
76
|
+
justifyContent: 'center',
|
|
77
|
+
width: 32,
|
|
78
|
+
height: 32,
|
|
79
|
+
borderRadius: 9,
|
|
80
|
+
background: 'var(--color-primary-700)',
|
|
81
|
+
color: '#fff',
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
|
85
|
+
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
|
|
86
|
+
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
|
|
87
|
+
</svg>
|
|
88
|
+
</span>
|
|
89
|
+
<span style={{ fontFamily: 'var(--font-serif)', fontSize: 20, fontWeight: 700, letterSpacing: '-0.02em' }}>
|
|
90
|
+
{site.title}
|
|
91
|
+
</span>
|
|
92
|
+
</Link>
|
|
93
|
+
</Navbar.Brand>
|
|
94
|
+
|
|
95
|
+
<Navbar.Search>
|
|
96
|
+
{/* CommandPalette renders its own trigger and registers global ⌘K —
|
|
97
|
+
search is available from every page, no /search route. */}
|
|
98
|
+
<Search
|
|
99
|
+
provider={provider}
|
|
100
|
+
placeholder="Search the docs…"
|
|
101
|
+
onNavigate={(url) => navigate(url)}
|
|
102
|
+
/>
|
|
103
|
+
</Navbar.Search>
|
|
104
|
+
|
|
105
|
+
<Navbar.Spacer />
|
|
106
|
+
|
|
107
|
+
<Navbar.Actions>
|
|
108
|
+
<ThemeToggle theme={theme} onThemeChange={onThemeChange} ariaLabel="Switch theme" />
|
|
109
|
+
{site.githubUrl && (
|
|
110
|
+
<a
|
|
111
|
+
href={site.githubUrl}
|
|
112
|
+
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, fontSize: 14, color: 'var(--text-muted)', textDecoration: 'none' }}
|
|
113
|
+
>
|
|
114
|
+
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
115
|
+
<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 .5 5 .5 5 .5c-.3 1.15-.3 2.35 0 3.5A5.4 5.4 0 0 0 4 7.5c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" />
|
|
116
|
+
<path d="M9 18c-4.51 2-5-2-7-2" />
|
|
117
|
+
</svg>
|
|
118
|
+
GitHub
|
|
119
|
+
</a>
|
|
120
|
+
)}
|
|
121
|
+
{docsData && (
|
|
122
|
+
/* Wrapper owns responsive visibility (display: none / contents in
|
|
123
|
+
docs.css) so we never fight the component's own display classes. */
|
|
124
|
+
<span className="site-nav-toggle">
|
|
125
|
+
<Navbar.Toggle controls="docs-nav" ariaLabel="Documentation menu" />
|
|
126
|
+
</span>
|
|
127
|
+
)}
|
|
128
|
+
</Navbar.Actions>
|
|
129
|
+
|
|
130
|
+
{docsData && (
|
|
131
|
+
<div className="site-nav-collapse">
|
|
132
|
+
<Navbar.Collapse id="docs-nav">
|
|
133
|
+
<NavTree
|
|
134
|
+
groups={docsData.navGroups}
|
|
135
|
+
currentPath={docsData.slug}
|
|
136
|
+
LinkComponent={NavCollapseLink}
|
|
137
|
+
aria-label="Documentation"
|
|
138
|
+
/>
|
|
139
|
+
</Navbar.Collapse>
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
142
|
+
</Navbar>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** React Router adapter for NavTree — forwards active/indent/aria props. */
|
|
147
|
+
function NavCollapseLink({ href, ...rest }: { href: string; children: React.ReactNode }) {
|
|
148
|
+
return <Link to={href} {...rest} />;
|
|
149
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join, resolve, sep } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
createContentSource,
|
|
6
|
+
type ContentSource,
|
|
7
|
+
type Manifest,
|
|
8
|
+
} from '@eventuras/lectio-docs/content';
|
|
9
|
+
|
|
10
|
+
// Absolute path to the collected content (manifest.json + markdown), from the
|
|
11
|
+
// generated lectio.config.json. Written next to the app before every build —
|
|
12
|
+
// by this app's collect script here, by the lectio CLI when it materializes
|
|
13
|
+
// this app elsewhere — so this module is generic. Located via cwd, which the
|
|
14
|
+
// build always runs in.
|
|
15
|
+
const { contentDir } = JSON.parse(
|
|
16
|
+
readFileSync(join(process.cwd(), 'lectio.config.json'), 'utf-8'),
|
|
17
|
+
) as { contentDir: string };
|
|
18
|
+
const root = resolve(contentDir);
|
|
19
|
+
|
|
20
|
+
let cached: ContentSource | null = null;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Build the content source from the collected manifest, loading bodies from disk.
|
|
24
|
+
*
|
|
25
|
+
* `fs` is the right seam for this host: prerendering runs loaders in Node at
|
|
26
|
+
* build time, so the static output needs no server.
|
|
27
|
+
*/
|
|
28
|
+
export function getContentSource(): ContentSource {
|
|
29
|
+
if (cached) return cached;
|
|
30
|
+
|
|
31
|
+
const manifest = JSON.parse(
|
|
32
|
+
readFileSync(join(root, 'manifest.json'), 'utf-8'),
|
|
33
|
+
) as Manifest;
|
|
34
|
+
|
|
35
|
+
cached = createContentSource({
|
|
36
|
+
manifest,
|
|
37
|
+
loadBody: (page) => {
|
|
38
|
+
// Resolve and enforce a separator boundary so a page.file can't escape
|
|
39
|
+
// contentDir via `..` or a prefix like `${contentDir}2/…`.
|
|
40
|
+
const filePath = resolve(root, page.file);
|
|
41
|
+
if (filePath !== root && !filePath.startsWith(root + sep)) {
|
|
42
|
+
throw new Error(`Refusing to read outside the content directory: ${page.file}`);
|
|
43
|
+
}
|
|
44
|
+
return readFileSync(filePath, 'utf-8');
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return cached;
|
|
49
|
+
}
|
package/app/root.tsx
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router';
|
|
3
|
+
|
|
4
|
+
// Design tokens + component styles for ratio-ui, which @eventuras/markdown
|
|
5
|
+
// renders the collected markdown with.
|
|
6
|
+
//
|
|
7
|
+
// `?url` + the links export, not a side-effect import: React Router renders
|
|
8
|
+
// the whole <html> document, so the stylesheet must be a real <link> in the
|
|
9
|
+
// SSR head rather than something Vite injects after hydration.
|
|
10
|
+
import ratioUiStylesheet from '@eventuras/ratio-ui/ratio-ui.css?url';
|
|
11
|
+
|
|
12
|
+
import { SiteHeader } from './components/site-header';
|
|
13
|
+
|
|
14
|
+
export function links() {
|
|
15
|
+
return [{ rel: 'stylesheet', href: ratioUiStylesheet }];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Resolve the theme before first paint so a stored dark-mode choice (the
|
|
19
|
+
// header's ThemeToggle writes it) or the system preference paints correctly
|
|
20
|
+
// from the first frame — an effect after hydration would flash light first.
|
|
21
|
+
// Since ratio-ui 2.15 this is purely about avoiding that flash: the page is
|
|
22
|
+
// visible by default and the old hide-until-themed gate is opt-in
|
|
23
|
+
// (`data-theme-loading`), which we don't need with a blocking script.
|
|
24
|
+
const themeInit = `document.documentElement.setAttribute('data-theme', (function () { try { var t = localStorage.getItem('lectio-theme'); if (t === 'light' || t === 'dark') return t; } catch (e) {} return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; })());`;
|
|
25
|
+
|
|
26
|
+
export function Layout({ children }: { children: ReactNode }) {
|
|
27
|
+
return (
|
|
28
|
+
<html lang="en">
|
|
29
|
+
<head>
|
|
30
|
+
<meta charSet="utf-8" />
|
|
31
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
32
|
+
<script dangerouslySetInnerHTML={{ __html: themeInit }} />
|
|
33
|
+
<Meta />
|
|
34
|
+
<Links />
|
|
35
|
+
</head>
|
|
36
|
+
<body>
|
|
37
|
+
{children}
|
|
38
|
+
<ScrollRestoration />
|
|
39
|
+
<Scripts />
|
|
40
|
+
</body>
|
|
41
|
+
</html>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default function App() {
|
|
46
|
+
return (
|
|
47
|
+
<>
|
|
48
|
+
<SiteHeader />
|
|
49
|
+
<Outlet />
|
|
50
|
+
</>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Structural layout for the docs pages. Lives in CSS rather than inline
|
|
3
|
+
* styles for one reason: the responsive behavior needs media queries.
|
|
4
|
+
*
|
|
5
|
+
* Three columns on wide screens; the TOC drops first, then the sidebar
|
|
6
|
+
* collapses into the site header's Navbar.Toggle/Collapse pair (see
|
|
7
|
+
* site-header.tsx and the .site-nav-* rules below).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
.docs-shell {
|
|
11
|
+
display: flex;
|
|
12
|
+
align-items: flex-start;
|
|
13
|
+
max-width: 1400px;
|
|
14
|
+
margin: 0 auto;
|
|
15
|
+
width: 100%;
|
|
16
|
+
font-family: var(--font-body);
|
|
17
|
+
color: var(--text);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.docs-sidebar {
|
|
21
|
+
position: sticky;
|
|
22
|
+
top: 60px;
|
|
23
|
+
flex-shrink: 0;
|
|
24
|
+
width: 262px;
|
|
25
|
+
max-height: calc(100vh - 60px);
|
|
26
|
+
overflow-y: auto;
|
|
27
|
+
padding: 26px 16px 40px 24px;
|
|
28
|
+
border-right: 1px solid var(--border-1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.docs-content-wrap {
|
|
32
|
+
flex: 1;
|
|
33
|
+
min-width: 0;
|
|
34
|
+
display: flex;
|
|
35
|
+
align-items: flex-start;
|
|
36
|
+
gap: 40px;
|
|
37
|
+
max-width: 1040px;
|
|
38
|
+
margin: 0 auto;
|
|
39
|
+
padding: 40px 40px 80px;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.docs-main {
|
|
43
|
+
flex: 1;
|
|
44
|
+
/* Lets the column actually shrink — without it, wide content (code blocks)
|
|
45
|
+
pushes the flex row apart and under the TOC column. */
|
|
46
|
+
min-width: 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* The TOC owns a real, non-shrinking column — it never sits over the text. */
|
|
50
|
+
.docs-toc {
|
|
51
|
+
flex-shrink: 0;
|
|
52
|
+
width: 190px;
|
|
53
|
+
position: sticky;
|
|
54
|
+
top: 86px;
|
|
55
|
+
align-self: flex-start;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/*
|
|
59
|
+
* Mobile nav lives in the site header as a Navbar.Toggle/Collapse pair (the
|
|
60
|
+
* "pocket library" pattern). These wrappers own the responsive visibility so
|
|
61
|
+
* we never fight the components' own display classes: display none/contents
|
|
62
|
+
* on a wrapper hides or reveals the child regardless of what it renders.
|
|
63
|
+
* Breakpoints are ours, aligned with the sidebar's — the components ship no
|
|
64
|
+
* visibility of their own (the "typically md:hidden" in their docs is advice
|
|
65
|
+
* to the consumer, not behavior).
|
|
66
|
+
*/
|
|
67
|
+
.site-nav-toggle,
|
|
68
|
+
.site-nav-collapse {
|
|
69
|
+
display: none;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@media (max-width: 1100px) {
|
|
73
|
+
/* "On this page" earns its keep on wide screens only. */
|
|
74
|
+
.docs-toc {
|
|
75
|
+
display: none;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
@media (max-width: 900px) {
|
|
80
|
+
.docs-sidebar {
|
|
81
|
+
display: none;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.site-nav-toggle,
|
|
85
|
+
.site-nav-collapse {
|
|
86
|
+
display: contents;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.docs-content-wrap {
|
|
90
|
+
padding: 20px 16px 60px;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { Link, useLoaderData } from 'react-router';
|
|
3
|
+
|
|
4
|
+
import type { TreeNode } from '@eventuras/lectio-docs/content';
|
|
5
|
+
import { MarkdownContent, extractHeadings, type MarkdownComponents } from '@eventuras/markdown';
|
|
6
|
+
import { Heading } from '@eventuras/ratio-ui/core/Heading';
|
|
7
|
+
import { NavTree } from '@eventuras/ratio-ui/core/NavTree';
|
|
8
|
+
import { TableOfContents } from '@eventuras/ratio-ui/core/TableOfContents';
|
|
9
|
+
import { getTextContent, slugify } from '@eventuras/ratio-ui/utils';
|
|
10
|
+
|
|
11
|
+
import { getContentSource } from '../content.server';
|
|
12
|
+
import docsStylesheet from './docs.css?url';
|
|
13
|
+
|
|
14
|
+
export function links() {
|
|
15
|
+
return [{ rel: 'stylesheet', href: docsStylesheet }];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function loader({ params }: { params: Record<string, string | undefined> }) {
|
|
19
|
+
const source = getContentSource();
|
|
20
|
+
|
|
21
|
+
// Docs are served at the site root, so manifest slugs map 1:1 onto URLs.
|
|
22
|
+
const rest = (params['*'] ?? '').replace(/\/$/, '');
|
|
23
|
+
const slug = rest ? `/${rest}` : '/';
|
|
24
|
+
|
|
25
|
+
const page = await source.getPage(slug);
|
|
26
|
+
if (!page) throw new Response(`No document for "${slug}"`, { status: 404 });
|
|
27
|
+
|
|
28
|
+
// navGroups is computed here (plain, serializable data) rather than in the
|
|
29
|
+
// component so the site header can pick it up via useMatches and render the
|
|
30
|
+
// same tree in its mobile Navbar.Collapse.
|
|
31
|
+
return { page, navGroups: toNavGroups(source.getTree()), slug };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Serializable shape of the nav tree — a plain-strings subset of ratio-ui's
|
|
36
|
+
* NavTreeGroup (whose `title: ReactNode` doesn't survive React Router's
|
|
37
|
+
* loader-serialization typing). Structurally assignable to what NavTree takes,
|
|
38
|
+
* and shared with the site header, which reads it back via useMatches.
|
|
39
|
+
*/
|
|
40
|
+
export interface DocsNavItem {
|
|
41
|
+
title: string;
|
|
42
|
+
href?: string;
|
|
43
|
+
id?: string;
|
|
44
|
+
children?: DocsNavItem[];
|
|
45
|
+
}
|
|
46
|
+
export interface DocsNavGroup {
|
|
47
|
+
label?: string;
|
|
48
|
+
items: DocsNavItem[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Content tree → NavTree groups: root-level pages form the first (unlabelled)
|
|
53
|
+
* group, and each bare section becomes its own group with its title as the
|
|
54
|
+
* uppercase eyebrow — the grouped-sidebar look from the design sketch, derived
|
|
55
|
+
* from the manifest rather than hand-maintained.
|
|
56
|
+
*/
|
|
57
|
+
function toNavGroups(tree: TreeNode[]): DocsNavGroup[] {
|
|
58
|
+
const toItem = (node: TreeNode): DocsNavItem => ({
|
|
59
|
+
title: node.title,
|
|
60
|
+
...(node.slug ? { href: node.slug } : { id: node.title }),
|
|
61
|
+
...(node.children.length > 0 ? { children: node.children.map(toItem) } : {}),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const rootPages = tree.filter((n) => n.children.length === 0);
|
|
65
|
+
const sections = tree.filter((n) => n.children.length > 0);
|
|
66
|
+
|
|
67
|
+
return [
|
|
68
|
+
{ items: rootPages.map(toItem) },
|
|
69
|
+
...sections.map((section) => ({
|
|
70
|
+
label: section.title,
|
|
71
|
+
items: [
|
|
72
|
+
// A section that is itself a page keeps a link to it at the top.
|
|
73
|
+
...(section.slug ? [{ title: 'Overview', href: section.slug }] : []),
|
|
74
|
+
...section.children.map(toItem),
|
|
75
|
+
],
|
|
76
|
+
})),
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** React Router adapter for NavTree — forwards active/indent/aria props. */
|
|
81
|
+
function NavLink({ href, ...rest }: { href: string; children: React.ReactNode }) {
|
|
82
|
+
return <Link to={href} {...rest} />;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export default function DocsPage() {
|
|
86
|
+
const { page, navGroups, slug } = useLoaderData<typeof loader>();
|
|
87
|
+
|
|
88
|
+
const headings = useMemo(() => extractHeadings(page.body), [page.body]);
|
|
89
|
+
|
|
90
|
+
// The only override left: anchor ids on h2/h3, slugged with ratio-ui's own
|
|
91
|
+
// slugify — the same function extractHeadings uses for the TOC, so scroll-spy
|
|
92
|
+
// and anchors can't drift. Everything else (code blocks, inline code,
|
|
93
|
+
// blockquotes, dividers) renders theme-aware upstream since markdown 0.13.
|
|
94
|
+
const markdownComponents: MarkdownComponents = useMemo(
|
|
95
|
+
() => ({
|
|
96
|
+
h2: ({ children }) => (
|
|
97
|
+
<Heading as="h2" id={slugify(getTextContent(children))} style={{ scrollMarginTop: 76 }}>
|
|
98
|
+
{children}
|
|
99
|
+
</Heading>
|
|
100
|
+
),
|
|
101
|
+
h3: ({ children }) => (
|
|
102
|
+
<Heading as="h3" id={slugify(getTextContent(children))} style={{ scrollMarginTop: 76 }}>
|
|
103
|
+
{children}
|
|
104
|
+
</Heading>
|
|
105
|
+
),
|
|
106
|
+
}),
|
|
107
|
+
[],
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div className="docs-shell">
|
|
112
|
+
<aside className="docs-sidebar">
|
|
113
|
+
<NavTree groups={navGroups} currentPath={slug} LinkComponent={NavLink} aria-label="Documentation" />
|
|
114
|
+
</aside>
|
|
115
|
+
|
|
116
|
+
<div className="docs-content-wrap">
|
|
117
|
+
<main className="docs-main">
|
|
118
|
+
<MarkdownContent
|
|
119
|
+
markdown={page.body}
|
|
120
|
+
allowExternalLinks
|
|
121
|
+
customComponents={markdownComponents}
|
|
122
|
+
/>
|
|
123
|
+
|
|
124
|
+
<footer
|
|
125
|
+
style={{
|
|
126
|
+
display: 'flex',
|
|
127
|
+
justifyContent: 'space-between',
|
|
128
|
+
alignItems: 'center',
|
|
129
|
+
marginTop: 48,
|
|
130
|
+
paddingTop: 20,
|
|
131
|
+
borderTop: '1px solid var(--border-1)',
|
|
132
|
+
fontSize: 14,
|
|
133
|
+
color: 'var(--text-subtle)',
|
|
134
|
+
}}
|
|
135
|
+
>
|
|
136
|
+
<span>
|
|
137
|
+
collected from <code>{page.source}</code>
|
|
138
|
+
</span>
|
|
139
|
+
{/* The edit link is provenance from the manifest (collect() resolves
|
|
140
|
+
the configured editUrl template per page) — the site knows
|
|
141
|
+
nothing about which repo the content came from. */}
|
|
142
|
+
{page.editUrl && (
|
|
143
|
+
<a
|
|
144
|
+
href={page.editUrl}
|
|
145
|
+
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, textDecoration: 'none', color: 'var(--primary)' }}
|
|
146
|
+
>
|
|
147
|
+
Edit this page
|
|
148
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
149
|
+
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
|
150
|
+
<path d="M15 3h6v6" />
|
|
151
|
+
<path d="M10 14 21 3" />
|
|
152
|
+
</svg>
|
|
153
|
+
</a>
|
|
154
|
+
)}
|
|
155
|
+
</footer>
|
|
156
|
+
</main>
|
|
157
|
+
|
|
158
|
+
{headings.length > 0 && (
|
|
159
|
+
<aside className="docs-toc">
|
|
160
|
+
<TableOfContents headings={headings} />
|
|
161
|
+
</aside>
|
|
162
|
+
)}
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|
package/app/routes.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { type RouteConfig, index, route } from '@react-router/dev/routes';
|
|
5
|
+
|
|
6
|
+
// Docs occupy the site root: manifest slugs map 1:1 onto URLs, and one module
|
|
7
|
+
// (docs.tsx) serves them all — "/" via the index route, everything else via the
|
|
8
|
+
// splat, which reads the slug from params["*"]. Search lives in the site header
|
|
9
|
+
// (CommandPalette, ⌘K), so there is no route for it.
|
|
10
|
+
//
|
|
11
|
+
// Under ssr:false every route exporting a loader must have prerendered paths,
|
|
12
|
+
// so each route is included only when it has pages to serve: the index only
|
|
13
|
+
// when there is a root page, the splat only when there are non-root pages.
|
|
14
|
+
// Without this a single-page site fails the build. Content location comes from
|
|
15
|
+
// the generated lectio.config.json, read via cwd (the build runs there).
|
|
16
|
+
const { contentDir } = JSON.parse(
|
|
17
|
+
readFileSync(join(process.cwd(), 'lectio.config.json'), 'utf-8'),
|
|
18
|
+
) as { contentDir: string };
|
|
19
|
+
const manifest = JSON.parse(
|
|
20
|
+
readFileSync(join(contentDir, 'manifest.json'), 'utf-8'),
|
|
21
|
+
) as { pages: Array<{ slug: string }> };
|
|
22
|
+
|
|
23
|
+
const hasRoot = manifest.pages.some((page) => page.slug === '/');
|
|
24
|
+
const hasNonRoot = manifest.pages.some((page) => page.slug !== '/');
|
|
25
|
+
|
|
26
|
+
export default [
|
|
27
|
+
...(hasRoot ? [index('routes/docs.tsx', { id: 'docs-index' })] : []),
|
|
28
|
+
...(hasNonRoot ? [route('*', 'routes/docs.tsx')] : []),
|
|
29
|
+
] satisfies RouteConfig;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Site branding, the one place the app is not generic. Committed here with the
|
|
2
|
+
// reference site's own values; the lectio CLI overwrites this file when it
|
|
3
|
+
// materializes this same app for another repo, so everything under app/ stays
|
|
4
|
+
// shared between the two.
|
|
5
|
+
export const site = {
|
|
6
|
+
title: 'Lectio Docs',
|
|
7
|
+
githubUrl: 'https://github.com/losol/lectio-docs' as string | undefined,
|
|
8
|
+
};
|
package/bin/lectio.mjs
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* lectio — run one command, get a docs site.
|
|
4
|
+
*
|
|
5
|
+
* `lectio build` in a repo with a docs.config.{ts,js,mjs}:
|
|
6
|
+
*
|
|
7
|
+
* 1. collects the configured sources (manifest + markdown)
|
|
8
|
+
* 2. builds the search index
|
|
9
|
+
* 3. materializes THIS package's own site app into .lectio/site — the same
|
|
10
|
+
* app that ships as the demo (apps/site-builder), so there is no separate
|
|
11
|
+
* template to keep in sync. React Router does not apply its app-source
|
|
12
|
+
* transforms (the .server boundary, loader stripping) to files under
|
|
13
|
+
* node_modules, so the app is copied out to a normal build dir here.
|
|
14
|
+
* 4. links the build dir's node_modules to THIS package's dependency dir —
|
|
15
|
+
* dirname(realpath(<own package>)) holds the deps in both pnpm's
|
|
16
|
+
* virtual-store layout and npm's flat layout — so the consumer installs
|
|
17
|
+
* nothing beyond lectio-docs itself
|
|
18
|
+
* 5. runs the React Router build there (prerenders every page)
|
|
19
|
+
* 6. copies the static output to ./dist
|
|
20
|
+
*
|
|
21
|
+
* Spike quality: build command only, minimal argument handling.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { createRequire } from 'node:module';
|
|
26
|
+
import { dirname, join, resolve } from 'node:path';
|
|
27
|
+
import { spawnSync } from 'node:child_process';
|
|
28
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
29
|
+
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
|
|
32
|
+
const command = process.argv[2];
|
|
33
|
+
if (command !== 'build') {
|
|
34
|
+
console.error('Usage: lectio build');
|
|
35
|
+
process.exit(command ? 1 : 0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const cwd = process.cwd();
|
|
39
|
+
|
|
40
|
+
// --- 1. discover + load the user's docs config -----------------------------
|
|
41
|
+
const configPath = ['docs.config.ts', 'docs.config.js', 'docs.config.mjs']
|
|
42
|
+
.map((name) => resolve(cwd, name))
|
|
43
|
+
.find((candidate) => existsSync(candidate));
|
|
44
|
+
if (!configPath) {
|
|
45
|
+
console.error('No docs.config.{ts,js,mjs} found in the current directory.');
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const config = (await import(pathToFileURL(configPath).href)).default;
|
|
50
|
+
if (!config?.sources || !config?.output) {
|
|
51
|
+
console.error('The docs config must export { output, sources }.');
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The repo root is where you run this from — the dir holding docs.config. Every
|
|
56
|
+
// source glob and edit-URL path is resolved relative to it (collect's rootDir).
|
|
57
|
+
// We deliberately do NOT walk up to an ancestor .git: that made the base depend
|
|
58
|
+
// on where a .git happened to sit above cwd, which breaks when the config lives
|
|
59
|
+
// in a nested dir (e.g. examples/) or in some CI checkouts.
|
|
60
|
+
const rootDir = cwd;
|
|
61
|
+
|
|
62
|
+
// --- 2. collect + index -----------------------------------------------------
|
|
63
|
+
const { collect } = await import('@eventuras/lectio-docs');
|
|
64
|
+
const { buildSearchIndex } = await import('@eventuras/lectio-docs/build-index');
|
|
65
|
+
|
|
66
|
+
await collect({ rootDir, config, configDir: cwd });
|
|
67
|
+
const contentDir = resolve(cwd, config.output);
|
|
68
|
+
|
|
69
|
+
// --- 3. materialize this package's site app --------------------------------
|
|
70
|
+
// Copy app/ + wiring out of the package. Once installed, the package lives
|
|
71
|
+
// under node_modules, where RR won't apply its app-source transforms — copying
|
|
72
|
+
// to a normal dir restores the .server boundary and loader stripping.
|
|
73
|
+
const siteDir = resolve(cwd, '.lectio/site');
|
|
74
|
+
rmSync(siteDir, { recursive: true, force: true });
|
|
75
|
+
mkdirSync(siteDir, { recursive: true });
|
|
76
|
+
|
|
77
|
+
const pkgRoot = fileURLToPath(new URL('..', import.meta.url));
|
|
78
|
+
cpSync(join(pkgRoot, 'app'), join(siteDir, 'app'), { recursive: true });
|
|
79
|
+
cpSync(join(pkgRoot, 'react-router.config.ts'), join(siteDir, 'react-router.config.ts'));
|
|
80
|
+
cpSync(join(pkgRoot, 'vite.config.ts'), join(siteDir, 'vite.config.ts'));
|
|
81
|
+
|
|
82
|
+
// Minimal package.json for the build dir. Only isbot matters: react-router dev
|
|
83
|
+
// auto-installs it when missing, which would hit the network mid-build.
|
|
84
|
+
// Everything else resolves through the linked node_modules (step 4).
|
|
85
|
+
writeFileSync(
|
|
86
|
+
join(siteDir, 'package.json'),
|
|
87
|
+
JSON.stringify({ name: 'lectio-site', private: true, type: 'module', dependencies: { isbot: '^5' } }, null, 2) + '\n',
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
// A self-contained tsconfig so esbuild (which transforms react-router.config.ts
|
|
91
|
+
// and vite.config.ts) stops here instead of walking up into the consumer's
|
|
92
|
+
// repo. Without it, a parent tsconfig — e.g. one that `extends` a base package
|
|
93
|
+
// — gets picked up and can fail to resolve, breaking the build for reasons that
|
|
94
|
+
// have nothing to do with the docs.
|
|
95
|
+
writeFileSync(
|
|
96
|
+
join(siteDir, 'tsconfig.json'),
|
|
97
|
+
JSON.stringify(
|
|
98
|
+
{
|
|
99
|
+
compilerOptions: {
|
|
100
|
+
target: 'ES2022',
|
|
101
|
+
module: 'ESNext',
|
|
102
|
+
moduleResolution: 'bundler',
|
|
103
|
+
jsx: 'react-jsx',
|
|
104
|
+
skipLibCheck: true,
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
null,
|
|
108
|
+
2,
|
|
109
|
+
) + '\n',
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// Where the app reads content from, and the branding it renders. site.config.ts
|
|
113
|
+
// was copied above with this repo's own branding; overwrite it with the
|
|
114
|
+
// consumer's, so app/ stays generic and config-driven.
|
|
115
|
+
writeFileSync(join(siteDir, 'lectio.config.json'), JSON.stringify({ contentDir }, null, 2) + '\n');
|
|
116
|
+
writeFileSync(
|
|
117
|
+
join(siteDir, 'app', 'site.config.ts'),
|
|
118
|
+
`// Generated by lectio build — do not edit.
|
|
119
|
+
export const site = {
|
|
120
|
+
title: ${JSON.stringify(config.site?.title ?? 'Docs')},
|
|
121
|
+
githubUrl: ${JSON.stringify(config.site?.githubUrl)} as string | undefined,
|
|
122
|
+
};
|
|
123
|
+
`,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
mkdirSync(join(siteDir, 'public'), { recursive: true });
|
|
127
|
+
await buildSearchIndex({
|
|
128
|
+
contentDir,
|
|
129
|
+
outputPath: join(siteDir, 'public', 'search-index.json'),
|
|
130
|
+
log: (message) => console.log(message),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// --- 4. dependency resolution via symlink -----------------------------------
|
|
134
|
+
// Own package dir = parent of bin/. Where the deps live depends on layout:
|
|
135
|
+
// - workspace dev / npm with nesting: <own>/node_modules exists — use it
|
|
136
|
+
// - installed via pnpm: realpath resolves into the virtual store, whose
|
|
137
|
+
// parent dir holds this package's deps as siblings
|
|
138
|
+
// - npm flat: <own>/node_modules absent, dirname(realpath) is the flat
|
|
139
|
+
// node_modules that holds everything
|
|
140
|
+
const ownPackageDir = realpathSync(pkgRoot);
|
|
141
|
+
const ownNodeModules = join(ownPackageDir, 'node_modules');
|
|
142
|
+
const depsDir = existsSync(ownNodeModules) ? ownNodeModules : dirname(ownPackageDir);
|
|
143
|
+
|
|
144
|
+
// A REAL node_modules directory with one symlink per package — not a single
|
|
145
|
+
// symlink to depsDir. Vite writes into node_modules (.vite-temp config
|
|
146
|
+
// bundles, dep cache); with a whole-dir symlink those writes would land in
|
|
147
|
+
// this package's own node_modules. Per-package links keep resolution working
|
|
148
|
+
// while writes stay local to the materialized site.
|
|
149
|
+
const siteModules = join(siteDir, 'node_modules');
|
|
150
|
+
mkdirSync(siteModules, { recursive: true });
|
|
151
|
+
for (const entry of readdirSync(depsDir)) {
|
|
152
|
+
if (entry.startsWith('.')) continue; // .bin, .vite-temp, .modules.yaml, …
|
|
153
|
+
if (entry.startsWith('@')) {
|
|
154
|
+
mkdirSync(join(siteModules, entry), { recursive: true });
|
|
155
|
+
for (const scoped of readdirSync(join(depsDir, entry))) {
|
|
156
|
+
symlinkSync(join(depsDir, entry, scoped), join(siteModules, entry, scoped), 'dir');
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
symlinkSync(join(depsDir, entry), join(siteModules, entry), 'dir');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// --- 5. run the React Router build ------------------------------------------
|
|
164
|
+
const rrDevPkg = require.resolve('@react-router/dev/package.json');
|
|
165
|
+
const rrBin = join(dirname(rrDevPkg), require(rrDevPkg).bin['react-router']);
|
|
166
|
+
const result = spawnSync(process.execPath, [rrBin, 'build'], {
|
|
167
|
+
cwd: siteDir,
|
|
168
|
+
stdio: 'inherit',
|
|
169
|
+
});
|
|
170
|
+
if (result.status !== 0) process.exit(result.status ?? 1);
|
|
171
|
+
|
|
172
|
+
// --- 6. static output --------------------------------------------------------
|
|
173
|
+
const outDir = resolve(cwd, 'dist');
|
|
174
|
+
rmSync(outDir, { recursive: true, force: true });
|
|
175
|
+
cpSync(join(siteDir, 'build', 'client'), outDir, { recursive: true });
|
|
176
|
+
console.log(`\nDocs site built → ${outDir}`);
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lectio-docs",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Run one command, get a docs site: collects scattered repo docs and builds a static, searchable site.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"lectio": "./bin/lectio.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"app",
|
|
12
|
+
"bin",
|
|
13
|
+
"react-router.config.ts",
|
|
14
|
+
"vite.config.ts"
|
|
15
|
+
],
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@eventuras/markdown": "^0.13.0",
|
|
18
|
+
"@eventuras/ratio-ui": "^2.15.0",
|
|
19
|
+
"@react-router/dev": "8.2.0",
|
|
20
|
+
"@react-router/node": "8.2.0",
|
|
21
|
+
"isbot": "^5.2.1",
|
|
22
|
+
"react": "^19.2.7",
|
|
23
|
+
"react-dom": "^19.2.7",
|
|
24
|
+
"react-router": "8.2.0",
|
|
25
|
+
"vite": "^8.1.5",
|
|
26
|
+
"@eventuras/lectio-docs": "0.3.0",
|
|
27
|
+
"@eventuras/lectio-docs-react": "0.1.1"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^24.13.3",
|
|
31
|
+
"@types/react": "^19.2.17",
|
|
32
|
+
"@types/react-dom": "^19.2.3",
|
|
33
|
+
"typescript": "^6.0.3"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"collect": "node scripts/collect.mjs",
|
|
37
|
+
"dev": "pnpm collect && react-router dev",
|
|
38
|
+
"build": "pnpm collect && react-router build",
|
|
39
|
+
"preview": "vite preview",
|
|
40
|
+
"typecheck": "pnpm collect && react-router typegen && tsc --noEmit"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import type { Config } from '@react-router/dev/config';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Static site. Every collected page is prerendered at build time, so there is
|
|
8
|
+
* no server to run — the output is plain HTML on a CDN.
|
|
9
|
+
*
|
|
10
|
+
* Prerendering still executes the loaders, but in Node at build time, which is
|
|
11
|
+
* why `content.server.ts` can keep reading the collected markdown from disk.
|
|
12
|
+
* The path list is simply the manifest's slugs: collect() has already decided
|
|
13
|
+
* what exists, and slugs map 1:1 onto URLs.
|
|
14
|
+
*
|
|
15
|
+
* Content location comes from the generated lectio.config.json, read via cwd —
|
|
16
|
+
* the same file routes.ts and content.server.ts read. Written next to the build
|
|
17
|
+
* by this app's collect script (the demo) or by the lectio CLI when it
|
|
18
|
+
* materializes this app for another repo, so the wiring is identical in both.
|
|
19
|
+
*/
|
|
20
|
+
export default {
|
|
21
|
+
ssr: false,
|
|
22
|
+
async prerender() {
|
|
23
|
+
const { contentDir } = JSON.parse(
|
|
24
|
+
readFileSync(join(process.cwd(), 'lectio.config.json'), 'utf-8'),
|
|
25
|
+
) as { contentDir: string };
|
|
26
|
+
const manifest = JSON.parse(
|
|
27
|
+
readFileSync(join(contentDir, 'manifest.json'), 'utf-8'),
|
|
28
|
+
) as { pages: Array<{ slug: string }> };
|
|
29
|
+
|
|
30
|
+
return manifest.pages.map((page) => page.slug);
|
|
31
|
+
},
|
|
32
|
+
} satisfies Config;
|