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 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;
package/vite.config.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { reactRouter } from '@react-router/dev/vite';
2
+ import { defineConfig } from 'vite';
3
+
4
+ export default defineConfig({
5
+ plugins: [reactRouter()],
6
+ });