create-eziwiki 0.1.1 → 0.3.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/README.md +3 -2
- package/package.json +1 -1
- package/template/app/[...slug]/page.tsx +59 -3
- package/template/app/layout.tsx +3 -1
- package/template/components/graph/GraphView.tsx +20 -7
- package/template/components/layout/LocalGraph.tsx +52 -0
- package/template/components/layout/MobileMenu.tsx +16 -1
- package/template/components/layout/MovedPage.tsx +45 -0
- package/template/components/layout/PageLayout.tsx +11 -3
- package/template/components/layout/PageNavigation.tsx +73 -0
- package/template/components/layout/Sidebar.tsx +30 -2
- package/template/components/markdown/LinkPreview.tsx +164 -0
- package/template/components/markdown/MarkdownContent.tsx +3 -1
- package/template/lib/content/aliases.test.ts +76 -0
- package/template/lib/content/aliases.ts +114 -0
- package/template/lib/content/assets.ts +158 -0
- package/template/lib/content/excerpt.test.ts +68 -0
- package/template/lib/content/excerpt.ts +142 -0
- package/template/lib/content/registry.ts +31 -0
- package/template/lib/graph/build.ts +55 -0
- package/template/lib/graph/health.test.ts +60 -0
- package/template/lib/graph/health.ts +58 -0
- package/template/lib/markdown/callout.test.ts +87 -0
- package/template/lib/markdown/mermaid.test.ts +72 -0
- package/template/lib/markdown/rehype-mermaid.ts +133 -0
- package/template/lib/markdown/rehype-plugins.ts +50 -0
- package/template/lib/markdown/remark-callout.ts +173 -0
- package/template/lib/markdown/remark-wikilink.ts +201 -14
- package/template/lib/markdown/render.ts +125 -9
- package/template/lib/markdown/wikilink.test.ts +30 -0
- package/template/lib/markdown/wikilink.ts +12 -4
- package/template/lib/navigation/sequence.test.ts +73 -0
- package/template/lib/navigation/sequence.ts +100 -0
- package/template/lib/payload/schema.ts +1 -0
- package/template/lib/payload/types.ts +7 -0
- package/template/package-lock.json +32 -83
- package/template/package.json +3 -0
- package/template/scripts/check-links.ts +60 -18
- package/template/styles/markdown.css +231 -0
package/README.md
CHANGED
|
@@ -16,8 +16,9 @@ A complete, static-exportable wiki:
|
|
|
16
16
|
- **Pages from files** — every Markdown file under `content/` is published, no registration step
|
|
17
17
|
- **Search** — full-text over titles, headings, and body, with a ⌘K palette; runs entirely in the browser
|
|
18
18
|
- **Contents rail** with scroll tracking, generated at build time
|
|
19
|
-
- **Wiki links** — `[[page]]` resolves by path, file name, or title
|
|
20
|
-
- **
|
|
19
|
+
- **Wiki links** — `[[page]]` resolves by path, file name, or title, and hovering one previews where it goes
|
|
20
|
+
- **Embeds** — `![[image.png]]` places a file, `![[page]]` includes another page's text, `![[page#section]]` just one section
|
|
21
|
+
- **Backlinks** on every page, plus a graph of its **neighbourhood** — and a `/graph` view of the whole site
|
|
21
22
|
- **Build-time rendering** — Markdown is compiled and syntax-highlighted during the build, so no parser ships to the browser
|
|
22
23
|
- Dark mode, maths, GFM, SEO metadata, sitemap
|
|
23
24
|
|
package/package.json
CHANGED
|
@@ -2,7 +2,12 @@ import { MarkdownContent } from '@/components/markdown/MarkdownContent';
|
|
|
2
2
|
import { PageTransition } from '@/components/markdown/PageTransition';
|
|
3
3
|
import { TableOfContents } from '@/components/layout/TableOfContents';
|
|
4
4
|
import { Backlinks } from '@/components/layout/Backlinks';
|
|
5
|
-
import {
|
|
5
|
+
import { LocalGraph } from '@/components/layout/LocalGraph';
|
|
6
|
+
import { PageNavigation } from '@/components/layout/PageNavigation';
|
|
7
|
+
import { MovedPage } from '@/components/layout/MovedPage';
|
|
8
|
+
import { getBacklinks, getLocalGraph } from '@/lib/graph/build';
|
|
9
|
+
import { getAdjacentPages } from '@/lib/navigation/sequence';
|
|
10
|
+
import { getAliasMap, aliasUrl, resolveAliasUrl } from '@/lib/content/aliases';
|
|
6
11
|
import { renderDoc } from '@/lib/markdown/render';
|
|
7
12
|
import { getDoc, type ContentDoc } from '@/lib/content/registry';
|
|
8
13
|
import { docPathToUrl, urlToDocPath } from '@/lib/navigation/url';
|
|
@@ -35,6 +40,25 @@ function resolveSlug(slug: string[]): { path: string; url: string } | null {
|
|
|
35
40
|
return path ? { path, url } : null;
|
|
36
41
|
}
|
|
37
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Resolves a slug that names a page's former address.
|
|
45
|
+
*
|
|
46
|
+
* Checked only after the live map misses, so a real page always wins over an
|
|
47
|
+
* alias — an alias shadowing a page is refused when the index is built, but
|
|
48
|
+
* order here makes the intent explicit.
|
|
49
|
+
*
|
|
50
|
+
* @param slug - Route segments captured by the catch-all route
|
|
51
|
+
* @returns The document that superseded the address, and its URL, or null
|
|
52
|
+
*/
|
|
53
|
+
function resolveMoved(slug: string[]): { path: string; url: string } | null {
|
|
54
|
+
const { urlMap } = getSite();
|
|
55
|
+
const path = resolveAliasUrl(slug.join('/'), urlMap.strategy);
|
|
56
|
+
if (!path) return null;
|
|
57
|
+
|
|
58
|
+
const url = docPathToUrl(urlMap, path);
|
|
59
|
+
return url ? { path, url } : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
38
62
|
/**
|
|
39
63
|
* Generates per-page metadata from the document's frontmatter.
|
|
40
64
|
*/
|
|
@@ -44,6 +68,21 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|
|
44
68
|
const doc = resolved ? getDoc(resolved.path) : undefined;
|
|
45
69
|
|
|
46
70
|
if (!resolved || !doc) {
|
|
71
|
+
const moved = resolveMoved(params.slug);
|
|
72
|
+
const target = moved ? getDoc(moved.path) : undefined;
|
|
73
|
+
|
|
74
|
+
// A former address should not compete with the page it forwards to: it is
|
|
75
|
+
// kept out of the index, and points its canonical at the destination so any
|
|
76
|
+
// ranking the old URL earned transfers rather than being split.
|
|
77
|
+
if (moved && target) {
|
|
78
|
+
return {
|
|
79
|
+
title: target.title,
|
|
80
|
+
description: target.description || global.description,
|
|
81
|
+
alternates: { canonical: pageUrl(moved.url, global.baseUrl) },
|
|
82
|
+
robots: { index: false, follow: true },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
47
86
|
return { title: global.title, description: global.description };
|
|
48
87
|
}
|
|
49
88
|
|
|
@@ -89,10 +128,18 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|
|
89
128
|
export async function generateStaticParams() {
|
|
90
129
|
const { urlMap, docPaths } = getSite();
|
|
91
130
|
|
|
92
|
-
|
|
131
|
+
const pages = docPaths.flatMap((path) => {
|
|
93
132
|
const url = docPathToUrl(urlMap, path);
|
|
94
133
|
return url ? [{ slug: url.split('/') }] : [];
|
|
95
134
|
});
|
|
135
|
+
|
|
136
|
+
// Former addresses are built too, each as a page that forwards. Without this
|
|
137
|
+
// there is nothing at the old URL for a static host to serve.
|
|
138
|
+
const moved = [...getAliasMap().keys()].map((alias) => ({
|
|
139
|
+
slug: aliasUrl(alias, urlMap.strategy).split('/'),
|
|
140
|
+
}));
|
|
141
|
+
|
|
142
|
+
return [...pages, ...moved];
|
|
96
143
|
}
|
|
97
144
|
|
|
98
145
|
/**
|
|
@@ -135,7 +182,14 @@ function ArticleSchema({ doc, url }: { doc: ContentDoc; url: string }) {
|
|
|
135
182
|
export default async function ContentPage({ params }: PageProps) {
|
|
136
183
|
const resolved = resolveSlug(params.slug);
|
|
137
184
|
|
|
138
|
-
if (!resolved)
|
|
185
|
+
if (!resolved) {
|
|
186
|
+
const moved = resolveMoved(params.slug);
|
|
187
|
+
const target = moved ? getDoc(moved.path) : undefined;
|
|
188
|
+
|
|
189
|
+
if (moved && target) return <MovedPage url={`/${moved.url}/`} title={target.title} />;
|
|
190
|
+
|
|
191
|
+
notFound();
|
|
192
|
+
}
|
|
139
193
|
|
|
140
194
|
const doc = getDoc(resolved.path);
|
|
141
195
|
const rendered = await renderDoc(resolved.path);
|
|
@@ -148,7 +202,9 @@ export default async function ContentPage({ params }: PageProps) {
|
|
|
148
202
|
<article className="prose prose-slate min-w-0 max-w-none flex-1 dark:prose-invert">
|
|
149
203
|
<ArticleSchema doc={doc} url={resolved.url} />
|
|
150
204
|
<MarkdownContent html={rendered.html} />
|
|
205
|
+
<PageNavigation adjacent={getAdjacentPages(resolved.path)} />
|
|
151
206
|
<Backlinks links={getBacklinks(resolved.path)} />
|
|
207
|
+
<LocalGraph graph={getLocalGraph(resolved.path)} path={resolved.path} />
|
|
152
208
|
</article>
|
|
153
209
|
|
|
154
210
|
<aside className="hidden w-56 flex-shrink-0 xl:block">
|
package/template/app/layout.tsx
CHANGED
|
@@ -175,7 +175,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|
|
175
175
|
</a>
|
|
176
176
|
<UrlMapProvider value={site.urlMap}>
|
|
177
177
|
<TabInitializer navigation={site.navigation} />
|
|
178
|
-
<PageLayout navigation={site.navigation}
|
|
178
|
+
<PageLayout navigation={site.navigation} repoUrl={site.global.repoUrl}>
|
|
179
|
+
{children}
|
|
180
|
+
</PageLayout>
|
|
179
181
|
<SearchDialog />
|
|
180
182
|
</UrlMapProvider>
|
|
181
183
|
</body>
|
|
@@ -24,6 +24,10 @@ export interface GraphViewNode {
|
|
|
24
24
|
interface GraphViewProps {
|
|
25
25
|
nodes: GraphViewNode[];
|
|
26
26
|
edges: LayoutEdge[];
|
|
27
|
+
/** Page to mark as the one being read, when the graph is centred on one */
|
|
28
|
+
activePath?: string;
|
|
29
|
+
/** Height utility class; the default suits a full page of its own */
|
|
30
|
+
heightClass?: string;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
/** Nominal layout area; the SVG viewBox scales the result to fit. */
|
|
@@ -33,7 +37,7 @@ const AREA = { width: 900, height: 640 };
|
|
|
33
37
|
const MIN_RADIUS = 5;
|
|
34
38
|
const MAX_RADIUS = 14;
|
|
35
39
|
|
|
36
|
-
export function GraphView({ nodes, edges }: GraphViewProps) {
|
|
40
|
+
export function GraphView({ nodes, edges, activePath, heightClass = 'h-[70vh]' }: GraphViewProps) {
|
|
37
41
|
const router = useRouter();
|
|
38
42
|
const [hovered, setHovered] = useState<string | null>(null);
|
|
39
43
|
|
|
@@ -75,7 +79,7 @@ export function GraphView({ nodes, edges }: GraphViewProps) {
|
|
|
75
79
|
<div className="overflow-hidden rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-900">
|
|
76
80
|
<svg
|
|
77
81
|
viewBox={`${box.x} ${box.y} ${box.width} ${box.height}`}
|
|
78
|
-
className=
|
|
82
|
+
className={`${heightClass} w-full`}
|
|
79
83
|
role="img"
|
|
80
84
|
aria-label={`Link graph of ${nodes.length} pages and ${edges.length} links`}
|
|
81
85
|
>
|
|
@@ -110,6 +114,7 @@ export function GraphView({ nodes, edges }: GraphViewProps) {
|
|
|
110
114
|
const radius =
|
|
111
115
|
MIN_RADIUS + (MAX_RADIUS - MIN_RADIUS) * Math.sqrt(node.degree / maxDegree);
|
|
112
116
|
const active = !connected || connected.has(node.path);
|
|
117
|
+
const isCurrent = node.path === activePath;
|
|
113
118
|
|
|
114
119
|
return (
|
|
115
120
|
<g
|
|
@@ -131,18 +136,26 @@ export function GraphView({ nodes, edges }: GraphViewProps) {
|
|
|
131
136
|
aria-label={node.title}
|
|
132
137
|
>
|
|
133
138
|
<circle
|
|
134
|
-
r={radius}
|
|
139
|
+
r={isCurrent ? radius + 2 : radius}
|
|
135
140
|
className={
|
|
136
|
-
|
|
137
|
-
?
|
|
138
|
-
|
|
141
|
+
isCurrent
|
|
142
|
+
? // The page being read is filled solid rather than tinted,
|
|
143
|
+
// so it is findable in its own neighbourhood at a glance.
|
|
144
|
+
'fill-blue-600 stroke-white dark:fill-blue-400 dark:stroke-gray-900'
|
|
145
|
+
: hovered === node.path
|
|
146
|
+
? 'fill-blue-500 stroke-white dark:stroke-gray-900'
|
|
147
|
+
: 'fill-blue-400/80 stroke-white dark:fill-blue-500/70 dark:stroke-gray-900'
|
|
139
148
|
}
|
|
140
149
|
strokeWidth={1.5}
|
|
141
150
|
/>
|
|
142
151
|
<text
|
|
143
152
|
y={radius + 12}
|
|
144
153
|
textAnchor="middle"
|
|
145
|
-
className=
|
|
154
|
+
className={`pointer-events-none text-[11px] ${
|
|
155
|
+
isCurrent
|
|
156
|
+
? 'fill-gray-900 font-semibold dark:fill-gray-100'
|
|
157
|
+
: 'fill-gray-700 dark:fill-gray-300'
|
|
158
|
+
}`}
|
|
146
159
|
>
|
|
147
160
|
{node.title}
|
|
148
161
|
</text>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Share2 } from 'lucide-react';
|
|
2
|
+
import { GraphView } from '@/components/graph/GraphView';
|
|
3
|
+
import type { LocalGraph as LocalGraphData } from '@/lib/graph/build';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shows the pages immediately around the one being read.
|
|
7
|
+
*
|
|
8
|
+
* The whole-site graph on its own page answers what the wiki looks like. This
|
|
9
|
+
* answers what is next to *here*, which is the question a reader has while
|
|
10
|
+
* reading, and which the full graph stops answering once there are more than a
|
|
11
|
+
* few dozen pages to draw.
|
|
12
|
+
*
|
|
13
|
+
* It sits below the backlinks list and covers the same ground from the other
|
|
14
|
+
* side: backlinks name the pages that point here, the graph shows those and the
|
|
15
|
+
* ones this page points at, and how they relate to each other.
|
|
16
|
+
*
|
|
17
|
+
* Computed at build time, so this is a plain server component; only the SVG
|
|
18
|
+
* beneath it is interactive.
|
|
19
|
+
*
|
|
20
|
+
* @param props - Component props
|
|
21
|
+
* @param props.graph - Neighbourhood from `getLocalGraph()`
|
|
22
|
+
* @param props.path - Content path of the page at the centre
|
|
23
|
+
*/
|
|
24
|
+
export function LocalGraph({ graph, path }: { graph: LocalGraphData; path: string }) {
|
|
25
|
+
// A page with nothing linking either way has no neighbourhood to draw, and an
|
|
26
|
+
// empty box would only be a question the reader cannot answer.
|
|
27
|
+
if (graph.nodes.length < 2) return null;
|
|
28
|
+
|
|
29
|
+
const neighbours = graph.nodes.length - 1;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<section
|
|
33
|
+
aria-labelledby="local-graph-heading"
|
|
34
|
+
className="mt-10 border-t border-gray-200 pt-6 dark:border-gray-800"
|
|
35
|
+
>
|
|
36
|
+
<h2
|
|
37
|
+
id="local-graph-heading"
|
|
38
|
+
className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
|
|
39
|
+
>
|
|
40
|
+
<Share2 className="h-3.5 w-3.5" />
|
|
41
|
+
Connected to {neighbours} {neighbours === 1 ? 'page' : 'pages'}
|
|
42
|
+
</h2>
|
|
43
|
+
|
|
44
|
+
<GraphView
|
|
45
|
+
nodes={graph.nodes}
|
|
46
|
+
edges={graph.edges}
|
|
47
|
+
activePath={path}
|
|
48
|
+
heightClass="h-64 sm:h-72"
|
|
49
|
+
/>
|
|
50
|
+
</section>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -7,6 +7,7 @@ import { NavigationItem } from '@/lib/payload/types';
|
|
|
7
7
|
import { useTabStore } from '@/lib/store/tabStore';
|
|
8
8
|
import { useUrlMap } from '@/components/providers/UrlMapProvider';
|
|
9
9
|
import { filterHiddenItems } from '@/lib/navigation/builder';
|
|
10
|
+
import { Github } from 'lucide-react';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Props for the MobileMenu component
|
|
@@ -18,6 +19,8 @@ interface MobileMenuProps {
|
|
|
18
19
|
isOpen: boolean;
|
|
19
20
|
/** Callback function to close the menu */
|
|
20
21
|
onClose: () => void;
|
|
22
|
+
/** Source repository, linked from the drawer header when configured */
|
|
23
|
+
repoUrl?: string;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
/**
|
|
@@ -221,7 +224,7 @@ function MobileNavigationItem({
|
|
|
221
224
|
* @param props.onClose - Callback function to close the menu
|
|
222
225
|
*
|
|
223
226
|
*/
|
|
224
|
-
export function MobileMenu({ navigation, isOpen, onClose }: MobileMenuProps) {
|
|
227
|
+
export function MobileMenu({ navigation, isOpen, onClose, repoUrl }: MobileMenuProps) {
|
|
225
228
|
const pathname = usePathname();
|
|
226
229
|
const { toPath } = useUrlMap();
|
|
227
230
|
|
|
@@ -267,6 +270,18 @@ export function MobileMenu({ navigation, isOpen, onClose }: MobileMenuProps) {
|
|
|
267
270
|
<nav>
|
|
268
271
|
<div className="flex items-center justify-between mb-1">
|
|
269
272
|
<div className="flex-1" />
|
|
273
|
+
{repoUrl && (
|
|
274
|
+
<a
|
|
275
|
+
href={repoUrl}
|
|
276
|
+
target="_blank"
|
|
277
|
+
rel="noopener noreferrer"
|
|
278
|
+
aria-label="Source repository"
|
|
279
|
+
title="Source repository"
|
|
280
|
+
className="rounded-md p-2 text-gray-500 transition-colors hover:text-gray-700 active:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-200 dark:active:bg-gray-800"
|
|
281
|
+
>
|
|
282
|
+
<Github className="h-5 w-5" />
|
|
283
|
+
</a>
|
|
284
|
+
)}
|
|
270
285
|
<button
|
|
271
286
|
onClick={onClose}
|
|
272
287
|
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 active:bg-gray-100 dark:active:bg-gray-800 rounded-md transition-colors touch-manipulation"
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { asset } from '@/lib/basePath';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Stands in for a page that has moved, and sends the reader on.
|
|
6
|
+
*
|
|
7
|
+
* A static export has no server to answer with a 301, so the redirect is a
|
|
8
|
+
* `meta refresh` in the document itself. Search engines treat that as a
|
|
9
|
+
* permanent move when the delay is zero, and the canonical link says the same
|
|
10
|
+
* thing again for anything that reads markup rather than following it.
|
|
11
|
+
*
|
|
12
|
+
* The visible text is not decoration. A reader whose browser blocks the refresh
|
|
13
|
+
* — or who arrives with scripting and meta refresh disabled — still needs a way
|
|
14
|
+
* through, so the link is real and focusable rather than a spinner.
|
|
15
|
+
*
|
|
16
|
+
* @param props - Component props
|
|
17
|
+
* @param props.url - Href of the page that superseded this address
|
|
18
|
+
* @param props.title - Title of that page
|
|
19
|
+
*/
|
|
20
|
+
export function MovedPage({ url, title }: { url: string; title: string }) {
|
|
21
|
+
return (
|
|
22
|
+
<>
|
|
23
|
+
<meta httpEquiv="refresh" content={`0; url=${asset(url)}`} />
|
|
24
|
+
|
|
25
|
+
<div className="mx-auto max-w-lg px-6 py-24 text-center">
|
|
26
|
+
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
27
|
+
This page moved
|
|
28
|
+
</p>
|
|
29
|
+
|
|
30
|
+
<h1 className="mt-3 text-2xl font-semibold text-gray-900 dark:text-gray-100">{title}</h1>
|
|
31
|
+
|
|
32
|
+
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">
|
|
33
|
+
You are being taken there now. If nothing happens, follow the link.
|
|
34
|
+
</p>
|
|
35
|
+
|
|
36
|
+
<Link
|
|
37
|
+
href={url}
|
|
38
|
+
className="mt-6 inline-block rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-900 no-underline transition-colors hover:bg-gray-50 dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-800"
|
|
39
|
+
>
|
|
40
|
+
Continue to {title}
|
|
41
|
+
</Link>
|
|
42
|
+
</div>
|
|
43
|
+
</>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -15,6 +15,8 @@ import { SearchTrigger } from '@/components/search/SearchTrigger';
|
|
|
15
15
|
interface PageLayoutProps {
|
|
16
16
|
/** Array of top-level navigation items */
|
|
17
17
|
navigation: NavigationItem[];
|
|
18
|
+
/** Source repository, linked from the sidebar when configured */
|
|
19
|
+
repoUrl?: string;
|
|
18
20
|
/** Page content to render in the main area */
|
|
19
21
|
children: React.ReactNode;
|
|
20
22
|
}
|
|
@@ -25,10 +27,11 @@ interface PageLayoutProps {
|
|
|
25
27
|
*
|
|
26
28
|
* @param props - Component props
|
|
27
29
|
* @param props.navigation - Array of navigation items to display in sidebar/menu
|
|
30
|
+
* @param props.repoUrl - Source repository, linked from the sidebar when set
|
|
28
31
|
* @param props.children - Page content to render in the main content area
|
|
29
32
|
*
|
|
30
33
|
*/
|
|
31
|
-
export function PageLayout({ navigation, children }: PageLayoutProps) {
|
|
34
|
+
export function PageLayout({ navigation, repoUrl, children }: PageLayoutProps) {
|
|
32
35
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
|
33
36
|
|
|
34
37
|
const toggleMobileMenu = () => {
|
|
@@ -63,10 +66,15 @@ export function PageLayout({ navigation, children }: PageLayoutProps) {
|
|
|
63
66
|
</div>
|
|
64
67
|
</header>
|
|
65
68
|
|
|
66
|
-
<MobileMenu
|
|
69
|
+
<MobileMenu
|
|
70
|
+
navigation={navigation}
|
|
71
|
+
isOpen={isMobileMenuOpen}
|
|
72
|
+
onClose={closeMobileMenu}
|
|
73
|
+
repoUrl={repoUrl}
|
|
74
|
+
/>
|
|
67
75
|
|
|
68
76
|
<div className="flex">
|
|
69
|
-
<Sidebar navigation={navigation} />
|
|
77
|
+
<Sidebar navigation={navigation} repoUrl={repoUrl} />
|
|
70
78
|
|
|
71
79
|
<main id="main-content" tabIndex={-1} className="flex-1 min-w-0 flex flex-col">
|
|
72
80
|
<div className="sticky top-0 z-20 bg-white dark:bg-gray-950">
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
|
3
|
+
import type { Adjacent } from '@/lib/navigation/sequence';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Links to the pages either side of this one in reading order.
|
|
7
|
+
*
|
|
8
|
+
* A guide is written to be read through, and until now the only way onwards was
|
|
9
|
+
* back to the sidebar to find where you had got to. The order is the sidebar's
|
|
10
|
+
* own, flattened, so the two cannot drift apart.
|
|
11
|
+
*
|
|
12
|
+
* `rel="prev"` and `rel="next"` say the same thing to a crawler, which is how a
|
|
13
|
+
* sequence of pages is declared to be one.
|
|
14
|
+
*
|
|
15
|
+
* @param props - Component props
|
|
16
|
+
* @param props.adjacent - Neighbours from `getAdjacentPages()`
|
|
17
|
+
*/
|
|
18
|
+
export function PageNavigation({ adjacent }: { adjacent: Adjacent }) {
|
|
19
|
+
const { previous, next } = adjacent;
|
|
20
|
+
|
|
21
|
+
// The first and last pages have one neighbour; a page outside the sequence
|
|
22
|
+
// has none, and gets nothing rather than an empty bar.
|
|
23
|
+
if (!previous && !next) return null;
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<nav
|
|
27
|
+
aria-label="Page navigation"
|
|
28
|
+
className="mt-12 flex items-stretch gap-4 border-t border-gray-200 pt-6 dark:border-gray-800"
|
|
29
|
+
>
|
|
30
|
+
{previous ? (
|
|
31
|
+
<Link
|
|
32
|
+
href={previous.url}
|
|
33
|
+
rel="prev"
|
|
34
|
+
className="group flex flex-1 items-center gap-3 rounded-lg border border-gray-200 p-4 no-underline transition-colors hover:border-gray-300 hover:bg-gray-50 dark:border-gray-800 dark:hover:border-gray-700 dark:hover:bg-gray-800/50"
|
|
35
|
+
>
|
|
36
|
+
<ChevronLeft className="h-4 w-4 flex-shrink-0 text-gray-400 transition-transform group-hover:-translate-x-0.5" />
|
|
37
|
+
<span className="min-w-0">
|
|
38
|
+
<span className="block text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
39
|
+
Previous
|
|
40
|
+
</span>
|
|
41
|
+
<span className="block truncate text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
42
|
+
{previous.title}
|
|
43
|
+
</span>
|
|
44
|
+
</span>
|
|
45
|
+
</Link>
|
|
46
|
+
) : (
|
|
47
|
+
// Holds the column so a lone "next" stays on the right, where it is
|
|
48
|
+
// when there is a pair.
|
|
49
|
+
<div className="flex-1" />
|
|
50
|
+
)}
|
|
51
|
+
|
|
52
|
+
{next ? (
|
|
53
|
+
<Link
|
|
54
|
+
href={next.url}
|
|
55
|
+
rel="next"
|
|
56
|
+
className="group flex flex-1 items-center justify-end gap-3 rounded-lg border border-gray-200 p-4 text-right no-underline transition-colors hover:border-gray-300 hover:bg-gray-50 dark:border-gray-800 dark:hover:border-gray-700 dark:hover:bg-gray-800/50"
|
|
57
|
+
>
|
|
58
|
+
<span className="min-w-0">
|
|
59
|
+
<span className="block text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
60
|
+
Next
|
|
61
|
+
</span>
|
|
62
|
+
<span className="block truncate text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
63
|
+
{next.title}
|
|
64
|
+
</span>
|
|
65
|
+
</span>
|
|
66
|
+
<ChevronRight className="h-4 w-4 flex-shrink-0 text-gray-400 transition-transform group-hover:translate-x-0.5" />
|
|
67
|
+
</Link>
|
|
68
|
+
) : (
|
|
69
|
+
<div className="flex-1" />
|
|
70
|
+
)}
|
|
71
|
+
</nav>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import React, { useState, useRef, useEffect } from 'react';
|
|
4
4
|
import Link from 'next/link';
|
|
5
5
|
import { useRouter } from 'next/navigation';
|
|
6
|
-
import { ChevronRight, ChevronsLeft, ChevronsRight, Search, Share2 } from 'lucide-react';
|
|
6
|
+
import { ChevronRight, ChevronsLeft, ChevronsRight, Github, Search, Share2 } from 'lucide-react';
|
|
7
7
|
import { NavigationItem } from '@/lib/payload/types';
|
|
8
8
|
import { useTabStore } from '@/lib/store/tabStore';
|
|
9
9
|
import { ThemeToggle } from '@/components/ThemeToggle';
|
|
@@ -18,6 +18,32 @@ import { filterHiddenItems } from '@/lib/navigation/builder';
|
|
|
18
18
|
interface SidebarProps {
|
|
19
19
|
/** Array of top-level navigation items */
|
|
20
20
|
navigation: NavigationItem[];
|
|
21
|
+
/** Source repository, linked from the header when configured */
|
|
22
|
+
repoUrl?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Links out to the site's source repository.
|
|
27
|
+
*
|
|
28
|
+
* Rendered only when the payload names one, so a wiki with no public source
|
|
29
|
+
* does not show a dead control. A published site otherwise gives a reader no
|
|
30
|
+
* way to reach the project it came from.
|
|
31
|
+
*/
|
|
32
|
+
function RepoLink({ href, collapsed }: { href: string; collapsed: boolean }) {
|
|
33
|
+
return (
|
|
34
|
+
<a
|
|
35
|
+
href={href}
|
|
36
|
+
target="_blank"
|
|
37
|
+
rel="noopener noreferrer"
|
|
38
|
+
aria-label="Source repository"
|
|
39
|
+
title="Source repository"
|
|
40
|
+
className={`rounded-md p-2 text-gray-600 transition-colors hover:bg-gray-200 dark:text-gray-400 dark:hover:bg-gray-800 ${
|
|
41
|
+
collapsed ? '' : 'flex-shrink-0'
|
|
42
|
+
}`}
|
|
43
|
+
>
|
|
44
|
+
<Github className="h-4 w-4" />
|
|
45
|
+
</a>
|
|
46
|
+
);
|
|
21
47
|
}
|
|
22
48
|
|
|
23
49
|
/**
|
|
@@ -235,7 +261,7 @@ function NavigationItemComponent({
|
|
|
235
261
|
* @param props.navigation - Array of top-level navigation items to display
|
|
236
262
|
*
|
|
237
263
|
*/
|
|
238
|
-
export function Sidebar({ navigation }: SidebarProps) {
|
|
264
|
+
export function Sidebar({ navigation, repoUrl }: SidebarProps) {
|
|
239
265
|
const { sidebarWidth, sidebarCollapsed, setSidebarWidth, setSidebarCollapsed } = useTabStore();
|
|
240
266
|
|
|
241
267
|
const visibleNavigation = filterHiddenItems(navigation);
|
|
@@ -312,6 +338,7 @@ export function Sidebar({ navigation }: SidebarProps) {
|
|
|
312
338
|
<>
|
|
313
339
|
<SearchTrigger className="min-w-0 flex-1" />
|
|
314
340
|
<ThemeToggle className="w-4 h-4" />
|
|
341
|
+
{repoUrl && <RepoLink href={repoUrl} collapsed={false} />}
|
|
315
342
|
<button
|
|
316
343
|
onClick={handleToggle}
|
|
317
344
|
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-800 rounded-md transition-colors flex-shrink-0"
|
|
@@ -331,6 +358,7 @@ export function Sidebar({ navigation }: SidebarProps) {
|
|
|
331
358
|
>
|
|
332
359
|
<Search className="h-4 w-4" />
|
|
333
360
|
</button>
|
|
361
|
+
{repoUrl && <RepoLink href={repoUrl} collapsed />}
|
|
334
362
|
<button
|
|
335
363
|
onClick={handleToggle}
|
|
336
364
|
className="rounded-md p-2 text-gray-600 transition-colors hover:bg-gray-200 dark:text-gray-400 dark:hover:bg-gray-800"
|