create-eziwiki 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/README.md +46 -0
- package/bin/create-eziwiki.mjs +83 -0
- package/lib/scaffold.mjs +230 -0
- package/lib/scaffold.test.mjs +270 -0
- package/package.json +38 -0
- package/template/README.md +39 -0
- package/template/app/[...slug]/page.tsx +162 -0
- package/template/app/error.tsx +77 -0
- package/template/app/global-error.tsx +76 -0
- package/template/app/globals.css +44 -0
- package/template/app/graph/page.tsx +62 -0
- package/template/app/layout.tsx +164 -0
- package/template/app/not-found.tsx +48 -0
- package/template/app/page.tsx +34 -0
- package/template/app/robots.ts +20 -0
- package/template/app/sitemap.ts +48 -0
- package/template/components/ThemeToggle.tsx +77 -0
- package/template/components/graph/GraphView.tsx +156 -0
- package/template/components/layout/Backlinks.tsx +42 -0
- package/template/components/layout/Breadcrumb.tsx +88 -0
- package/template/components/layout/MobileMenu.tsx +299 -0
- package/template/components/layout/NavigationButtons.tsx +87 -0
- package/template/components/layout/PageLayout.tsx +89 -0
- package/template/components/layout/Sidebar.tsx +376 -0
- package/template/components/layout/TabBar.tsx +312 -0
- package/template/components/layout/TabBarSkeleton.tsx +12 -0
- package/template/components/layout/TabInitializer.tsx +99 -0
- package/template/components/layout/TableOfContents.tsx +138 -0
- package/template/components/markdown/CodeCopy.tsx +65 -0
- package/template/components/markdown/MarkdownContent.tsx +38 -0
- package/template/components/markdown/PageTransition.tsx +56 -0
- package/template/components/providers/UrlMapProvider.tsx +68 -0
- package/template/components/search/SearchDialog.tsx +286 -0
- package/template/components/search/SearchTrigger.tsx +41 -0
- package/template/content/guides/_meta.json +4 -0
- package/template/content/guides/writing.md +82 -0
- package/template/content/intro.md +29 -0
- package/template/eslintignore +7 -0
- package/template/eslintrc.js +40 -0
- package/template/gitignore +40 -0
- package/template/lib/basePath.test.ts +120 -0
- package/template/lib/basePath.ts +108 -0
- package/template/lib/cache.ts +36 -0
- package/template/lib/content/registry.ts +311 -0
- package/template/lib/content/resolver.ts +109 -0
- package/template/lib/graph/build.ts +214 -0
- package/template/lib/graph/layout.test.ts +189 -0
- package/template/lib/graph/layout.ts +247 -0
- package/template/lib/markdown/languages.test.ts +85 -0
- package/template/lib/markdown/languages.ts +103 -0
- package/template/lib/markdown/rehype-plugins.ts +240 -0
- package/template/lib/markdown/remark-wikilink.ts +141 -0
- package/template/lib/markdown/render.ts +175 -0
- package/template/lib/markdown/wikilink.test.ts +91 -0
- package/template/lib/markdown/wikilink.ts +85 -0
- package/template/lib/navigation/auto.ts +227 -0
- package/template/lib/navigation/builder.test.ts +129 -0
- package/template/lib/navigation/builder.ts +122 -0
- package/template/lib/navigation/hash.ts +32 -0
- package/template/lib/navigation/url.test.ts +88 -0
- package/template/lib/navigation/url.ts +108 -0
- package/template/lib/navigation/urlMap.ts +81 -0
- package/template/lib/payload/schema.ts +81 -0
- package/template/lib/payload/types.ts +105 -0
- package/template/lib/payload/validator.ts +56 -0
- package/template/lib/search/build.ts +204 -0
- package/template/lib/search/client.ts +190 -0
- package/template/lib/search/tokenizer.test.ts +60 -0
- package/template/lib/search/tokenizer.ts +83 -0
- package/template/lib/search/types.ts +40 -0
- package/template/lib/site.ts +86 -0
- package/template/lib/store/searchStore.ts +26 -0
- package/template/lib/store/tabStore.ts +313 -0
- package/template/next-env.d.ts +5 -0
- package/template/next.config.js +43 -0
- package/template/package-lock.json +9933 -0
- package/template/package.json +69 -0
- package/template/payload/config.ts +34 -0
- package/template/postcss.config.js +6 -0
- package/template/prettierignore +7 -0
- package/template/prettierrc +8 -0
- package/template/public/favicon.svg +10 -0
- package/template/public/fonts/Pretandard/Pretendard-Bold.woff2 +0 -0
- package/template/public/fonts/Pretandard/Pretendard-Regular.woff2 +0 -0
- package/template/public/fonts/Pretandard/Pretendard-SemiBold.woff2 +0 -0
- package/template/public/fonts/SUITE/SUITE-Bold.woff2 +0 -0
- package/template/public/fonts/SUITE/SUITE-Regular.woff2 +0 -0
- package/template/public/fonts/SUITE/SUITE-SemiBold.woff2 +0 -0
- package/template/public/images/.gitkeep +0 -0
- package/template/scripts/build-search-index.ts +36 -0
- package/template/scripts/check-links.ts +40 -0
- package/template/scripts/show-urls.ts +48 -0
- package/template/scripts/validate-payload.ts +30 -0
- package/template/styles/markdown.css +167 -0
- package/template/styles/theme.css +65 -0
- package/template/tailwind.config.ts +156 -0
- package/template/tsconfig.json +32 -0
- package/template/vitest.config.ts +30 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# My Wiki
|
|
2
|
+
|
|
3
|
+
Built with [eziwiki](https://github.com/i3months/eziwiki).
|
|
4
|
+
|
|
5
|
+
## Develop
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install
|
|
9
|
+
npm run dev
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Open <http://localhost:3000>.
|
|
13
|
+
|
|
14
|
+
## Write
|
|
15
|
+
|
|
16
|
+
Drop Markdown files into `content/`. Every file becomes a page automatically —
|
|
17
|
+
folders become sidebar sections. See `content/guides/writing.md` for what a page
|
|
18
|
+
can contain.
|
|
19
|
+
|
|
20
|
+
Set the site title, theme, and URL style in `payload/config.ts`.
|
|
21
|
+
|
|
22
|
+
## Build
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm run build
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The result is a fully static site in `out/`, deployable to GitHub Pages,
|
|
29
|
+
Netlify, Vercel, S3, or any static host.
|
|
30
|
+
|
|
31
|
+
## Commands
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm run dev # Development server
|
|
35
|
+
npm run build # Static production build
|
|
36
|
+
npm run check:links # Report links that point at no page
|
|
37
|
+
npm run show-urls # List every page and its URL
|
|
38
|
+
npm test # Run the test suite
|
|
39
|
+
```
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { MarkdownContent } from '@/components/markdown/MarkdownContent';
|
|
2
|
+
import { PageTransition } from '@/components/markdown/PageTransition';
|
|
3
|
+
import { TableOfContents } from '@/components/layout/TableOfContents';
|
|
4
|
+
import { Backlinks } from '@/components/layout/Backlinks';
|
|
5
|
+
import { getBacklinks } from '@/lib/graph/build';
|
|
6
|
+
import { renderDoc } from '@/lib/markdown/render';
|
|
7
|
+
import { getDoc, type ContentDoc } from '@/lib/content/registry';
|
|
8
|
+
import { docPathToUrl, urlToDocPath } from '@/lib/navigation/url';
|
|
9
|
+
import { getSite } from '@/lib/site';
|
|
10
|
+
import { asset, fileUrl, pageUrl } from '@/lib/basePath';
|
|
11
|
+
import { notFound } from 'next/navigation';
|
|
12
|
+
import type { Metadata } from 'next';
|
|
13
|
+
|
|
14
|
+
interface PageProps {
|
|
15
|
+
params: {
|
|
16
|
+
slug: string[];
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolves a route's slug segments to a document in the content registry.
|
|
22
|
+
*
|
|
23
|
+
* Under the `path` strategy a slug has one segment per directory level; under
|
|
24
|
+
* `hash` it is a single opaque segment. Joining first and resolving through the
|
|
25
|
+
* URL map handles both without the route needing to know which is in effect.
|
|
26
|
+
*
|
|
27
|
+
* @param slug - Route segments captured by the catch-all route
|
|
28
|
+
* @returns The content path and its canonical URL segment, or null
|
|
29
|
+
*/
|
|
30
|
+
function resolveSlug(slug: string[]): { path: string; url: string } | null {
|
|
31
|
+
const { urlMap } = getSite();
|
|
32
|
+
const url = slug.join('/');
|
|
33
|
+
const path = urlToDocPath(urlMap, url);
|
|
34
|
+
|
|
35
|
+
return path ? { path, url } : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Generates per-page metadata from the document's frontmatter.
|
|
40
|
+
*/
|
|
41
|
+
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
|
42
|
+
const { global, hiddenPaths } = getSite();
|
|
43
|
+
const resolved = resolveSlug(params.slug);
|
|
44
|
+
const doc = resolved ? getDoc(resolved.path) : undefined;
|
|
45
|
+
|
|
46
|
+
if (!resolved || !doc) {
|
|
47
|
+
return { title: global.title, description: global.description };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const title = doc.title;
|
|
51
|
+
const description = doc.description || global.description;
|
|
52
|
+
const rawOgImage = doc.frontmatter.ogImage as string | undefined;
|
|
53
|
+
const ogImage = rawOgImage ? fileUrl(rawOgImage, global.baseUrl) : undefined;
|
|
54
|
+
const canonicalUrl = pageUrl(resolved.url, global.baseUrl);
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
title,
|
|
58
|
+
description,
|
|
59
|
+
alternates: {
|
|
60
|
+
canonical: canonicalUrl,
|
|
61
|
+
},
|
|
62
|
+
icons: {
|
|
63
|
+
icon: asset((doc.frontmatter.favicon as string) || global.favicon || '/favicon.ico'),
|
|
64
|
+
},
|
|
65
|
+
// Hidden pages stay reachable by direct link but should not be indexed.
|
|
66
|
+
robots: hiddenPaths.has(resolved.path) ? { index: false, follow: false } : undefined,
|
|
67
|
+
openGraph: {
|
|
68
|
+
title,
|
|
69
|
+
description,
|
|
70
|
+
url: canonicalUrl,
|
|
71
|
+
images: ogImage ? [ogImage] : undefined,
|
|
72
|
+
},
|
|
73
|
+
twitter: {
|
|
74
|
+
card: 'summary_large_image',
|
|
75
|
+
title,
|
|
76
|
+
description,
|
|
77
|
+
images: ogImage ? [ogImage] : undefined,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Enumerates every document for static generation.
|
|
84
|
+
*
|
|
85
|
+
* The list comes from the content registry, so a Markdown file is built whether
|
|
86
|
+
* or not navigation references it. That is what lets hidden and unlisted pages
|
|
87
|
+
* work without a parallel registration step.
|
|
88
|
+
*/
|
|
89
|
+
export async function generateStaticParams() {
|
|
90
|
+
const { urlMap, docPaths } = getSite();
|
|
91
|
+
|
|
92
|
+
return docPaths.flatMap((path) => {
|
|
93
|
+
const url = docPathToUrl(urlMap, path);
|
|
94
|
+
return url ? [{ slug: url.split('/') }] : [];
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Emits Article structured data for a document.
|
|
100
|
+
*/
|
|
101
|
+
function ArticleSchema({ doc, url }: { doc: ContentDoc; url: string }) {
|
|
102
|
+
const { global } = getSite();
|
|
103
|
+
const baseUrl = global.baseUrl || 'https://example.com';
|
|
104
|
+
const published = doc.frontmatter.date ?? null;
|
|
105
|
+
const modified = doc.frontmatter.updated ?? published;
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<script
|
|
109
|
+
type="application/ld+json"
|
|
110
|
+
dangerouslySetInnerHTML={{
|
|
111
|
+
__html: JSON.stringify({
|
|
112
|
+
'@context': 'https://schema.org',
|
|
113
|
+
'@type': 'Article',
|
|
114
|
+
headline: doc.title,
|
|
115
|
+
description: doc.description || global.description,
|
|
116
|
+
url: `${baseUrl}/${url}`,
|
|
117
|
+
// Dates are omitted when absent rather than stamped with the build
|
|
118
|
+
// time: a fabricated date misleads both readers and crawlers.
|
|
119
|
+
...(published ? { datePublished: published } : {}),
|
|
120
|
+
...(modified ? { dateModified: modified } : {}),
|
|
121
|
+
author: {
|
|
122
|
+
'@type': 'Organization',
|
|
123
|
+
name: global.title,
|
|
124
|
+
},
|
|
125
|
+
}),
|
|
126
|
+
}}
|
|
127
|
+
/>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Renders a content page: the document body, plus its table of contents on
|
|
133
|
+
* screens wide enough to carry a second column.
|
|
134
|
+
*/
|
|
135
|
+
export default async function ContentPage({ params }: PageProps) {
|
|
136
|
+
const resolved = resolveSlug(params.slug);
|
|
137
|
+
|
|
138
|
+
if (!resolved) notFound();
|
|
139
|
+
|
|
140
|
+
const doc = getDoc(resolved.path);
|
|
141
|
+
const rendered = await renderDoc(resolved.path);
|
|
142
|
+
|
|
143
|
+
if (!doc || !rendered) notFound();
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<PageTransition>
|
|
147
|
+
<div className="flex gap-8">
|
|
148
|
+
<article className="prose prose-slate min-w-0 max-w-none flex-1 dark:prose-invert">
|
|
149
|
+
<ArticleSchema doc={doc} url={resolved.url} />
|
|
150
|
+
<MarkdownContent html={rendered.html} />
|
|
151
|
+
<Backlinks links={getBacklinks(resolved.path)} />
|
|
152
|
+
</article>
|
|
153
|
+
|
|
154
|
+
<aside className="hidden w-56 flex-shrink-0 xl:block">
|
|
155
|
+
<div className="sticky top-24 max-h-[calc(100vh-8rem)] overflow-y-auto">
|
|
156
|
+
<TableOfContents headings={rendered.headings} />
|
|
157
|
+
</div>
|
|
158
|
+
</aside>
|
|
159
|
+
</div>
|
|
160
|
+
</PageTransition>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
interface ErrorProps {
|
|
6
|
+
error: Error & { digest?: string };
|
|
7
|
+
reset: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Error boundary component for runtime error handling
|
|
12
|
+
*/
|
|
13
|
+
export default function Error({ error, reset }: ErrorProps) {
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
console.error('Application error:', error);
|
|
16
|
+
}, [error]);
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className="min-h-screen flex items-center justify-center px-4">
|
|
20
|
+
<div className="max-w-md w-full text-center">
|
|
21
|
+
<div className="mb-8">
|
|
22
|
+
<svg
|
|
23
|
+
className="mx-auto h-16 w-16 text-red-500"
|
|
24
|
+
fill="none"
|
|
25
|
+
viewBox="0 0 24 24"
|
|
26
|
+
stroke="currentColor"
|
|
27
|
+
>
|
|
28
|
+
<path
|
|
29
|
+
strokeLinecap="round"
|
|
30
|
+
strokeLinejoin="round"
|
|
31
|
+
strokeWidth={2}
|
|
32
|
+
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
|
33
|
+
/>
|
|
34
|
+
</svg>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-4">
|
|
38
|
+
Oops! Something went wrong
|
|
39
|
+
</h1>
|
|
40
|
+
|
|
41
|
+
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
|
42
|
+
We encountered an unexpected error while loading this page.
|
|
43
|
+
</p>
|
|
44
|
+
|
|
45
|
+
{error.message && (
|
|
46
|
+
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6">
|
|
47
|
+
<p className="text-sm text-red-800 dark:text-red-200 font-mono break-words">
|
|
48
|
+
{error.message}
|
|
49
|
+
</p>
|
|
50
|
+
</div>
|
|
51
|
+
)}
|
|
52
|
+
|
|
53
|
+
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
|
54
|
+
<button
|
|
55
|
+
onClick={reset}
|
|
56
|
+
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
|
|
57
|
+
>
|
|
58
|
+
Try again
|
|
59
|
+
</button>
|
|
60
|
+
|
|
61
|
+
<a
|
|
62
|
+
href="/"
|
|
63
|
+
className="px-6 py-3 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 font-medium rounded-lg transition-colors"
|
|
64
|
+
>
|
|
65
|
+
Go home
|
|
66
|
+
</a>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
{process.env.NODE_ENV === 'development' && error.digest && (
|
|
70
|
+
<p className="mt-6 text-xs text-gray-500 dark:text-gray-500">
|
|
71
|
+
Error digest: {error.digest}
|
|
72
|
+
</p>
|
|
73
|
+
)}
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
interface GlobalErrorProps {
|
|
6
|
+
error: Error & { digest?: string };
|
|
7
|
+
reset: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Global error boundary component
|
|
12
|
+
* Catches errors that occur outside of the normal error boundary
|
|
13
|
+
*/
|
|
14
|
+
export default function GlobalError({ error, reset }: GlobalErrorProps) {
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
console.error('Global application error:', error);
|
|
17
|
+
}, [error]);
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<html lang="en">
|
|
21
|
+
<body>
|
|
22
|
+
<div className="min-h-screen flex items-center justify-center px-4 bg-gray-50 dark:bg-gray-900">
|
|
23
|
+
<div className="max-w-md w-full text-center">
|
|
24
|
+
<div className="mb-8">
|
|
25
|
+
<svg
|
|
26
|
+
className="mx-auto h-16 w-16 text-red-500"
|
|
27
|
+
fill="none"
|
|
28
|
+
viewBox="0 0 24 24"
|
|
29
|
+
stroke="currentColor"
|
|
30
|
+
>
|
|
31
|
+
<path
|
|
32
|
+
strokeLinecap="round"
|
|
33
|
+
strokeLinejoin="round"
|
|
34
|
+
strokeWidth={2}
|
|
35
|
+
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
|
36
|
+
/>
|
|
37
|
+
</svg>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-4">
|
|
41
|
+
Critical Error
|
|
42
|
+
</h1>
|
|
43
|
+
|
|
44
|
+
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
|
45
|
+
A critical error occurred. Please try refreshing the page.
|
|
46
|
+
</p>
|
|
47
|
+
|
|
48
|
+
{error.message && (
|
|
49
|
+
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6">
|
|
50
|
+
<p className="text-sm text-red-800 dark:text-red-200 font-mono break-words">
|
|
51
|
+
{error.message}
|
|
52
|
+
</p>
|
|
53
|
+
</div>
|
|
54
|
+
)}
|
|
55
|
+
|
|
56
|
+
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
|
57
|
+
<button
|
|
58
|
+
onClick={reset}
|
|
59
|
+
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
|
|
60
|
+
>
|
|
61
|
+
Try again
|
|
62
|
+
</button>
|
|
63
|
+
|
|
64
|
+
<a
|
|
65
|
+
href="/"
|
|
66
|
+
className="px-6 py-3 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 font-medium rounded-lg transition-colors"
|
|
67
|
+
>
|
|
68
|
+
Go home
|
|
69
|
+
</a>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
</body>
|
|
74
|
+
</html>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
@tailwind base;
|
|
2
|
+
@tailwind components;
|
|
3
|
+
@tailwind utilities;
|
|
4
|
+
|
|
5
|
+
/* Import theme CSS variables */
|
|
6
|
+
@import '../styles/theme.css';
|
|
7
|
+
|
|
8
|
+
/* Styles for build-time rendered Markdown */
|
|
9
|
+
@import '../styles/markdown.css';
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
* `@font-face` for Pretendard and SUITE is declared in `app/layout.tsx`, not
|
|
13
|
+
* here: the font URLs need the deployment base path prefixed, which a
|
|
14
|
+
* stylesheet cannot read. See FONT_FACES there.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
body {
|
|
18
|
+
font-family:
|
|
19
|
+
'Pretendard',
|
|
20
|
+
'SUITE',
|
|
21
|
+
-apple-system,
|
|
22
|
+
BlinkMacSystemFont,
|
|
23
|
+
'Segoe UI',
|
|
24
|
+
sans-serif;
|
|
25
|
+
color: var(--color-text);
|
|
26
|
+
background: var(--color-background);
|
|
27
|
+
-webkit-overflow-scrolling: touch;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
* {
|
|
31
|
+
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.05);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
html {
|
|
35
|
+
scroll-behavior: smooth;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@media (max-width: 768px) {
|
|
39
|
+
button,
|
|
40
|
+
a {
|
|
41
|
+
min-height: 44px;
|
|
42
|
+
min-width: 44px;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { GraphView } from '@/components/graph/GraphView';
|
|
2
|
+
import { PageTransition } from '@/components/markdown/PageTransition';
|
|
3
|
+
import { getLinkGraph } from '@/lib/graph/build';
|
|
4
|
+
import { getSite } from '@/lib/site';
|
|
5
|
+
import type { Metadata } from 'next';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The link graph view.
|
|
9
|
+
*
|
|
10
|
+
* A static route, so it takes precedence over the catch-all content route and
|
|
11
|
+
* cannot be shadowed by a page named `graph`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export function generateMetadata(): Metadata {
|
|
15
|
+
const { global } = getSite();
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
title: `Graph · ${global.title}`,
|
|
19
|
+
description: 'How the pages in this wiki link to one another.',
|
|
20
|
+
// The graph is navigation, not content; there is nothing here for a search
|
|
21
|
+
// engine to index that the pages themselves do not already provide.
|
|
22
|
+
robots: { index: false, follow: true },
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default function GraphPage() {
|
|
27
|
+
const { nodes, edges, broken } = getLinkGraph();
|
|
28
|
+
const linked = nodes.filter((node) => node.degree > 0).length;
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<PageTransition>
|
|
32
|
+
<div className="mb-6">
|
|
33
|
+
<h1 className="mb-2 text-2xl font-semibold text-gray-900 dark:text-gray-100">Graph</h1>
|
|
34
|
+
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
35
|
+
{nodes.length} pages, {edges.length} links. {linked} pages are connected to at least one
|
|
36
|
+
other. Hover a node to isolate its neighbours; click to open the page.
|
|
37
|
+
</p>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<GraphView nodes={nodes} edges={edges} />
|
|
41
|
+
|
|
42
|
+
{broken.length > 0 && (
|
|
43
|
+
<section className="mt-8">
|
|
44
|
+
<h2 className="mb-2 text-sm font-semibold text-gray-900 dark:text-gray-100">
|
|
45
|
+
Unresolved links ({broken.length})
|
|
46
|
+
</h2>
|
|
47
|
+
<ul className="space-y-1 text-sm text-gray-600 dark:text-gray-400">
|
|
48
|
+
{broken.map((link, index) => (
|
|
49
|
+
<li key={`${link.from}-${link.target}-${index}`}>
|
|
50
|
+
<code className="text-red-600 dark:text-red-400">[[{link.target}]]</code> in{' '}
|
|
51
|
+
<span className="text-gray-900 dark:text-gray-200">{link.from}</span>
|
|
52
|
+
{link.reason === 'ambiguous' && link.candidates && (
|
|
53
|
+
<> — matches {link.candidates.join(', ')}</>
|
|
54
|
+
)}
|
|
55
|
+
</li>
|
|
56
|
+
))}
|
|
57
|
+
</ul>
|
|
58
|
+
</section>
|
|
59
|
+
)}
|
|
60
|
+
</PageTransition>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import './globals.css';
|
|
3
|
+
import 'katex/dist/katex.min.css';
|
|
4
|
+
import { PageLayout } from '@/components/layout/PageLayout';
|
|
5
|
+
import { TabInitializer } from '@/components/layout/TabInitializer';
|
|
6
|
+
import { UrlMapProvider } from '@/components/providers/UrlMapProvider';
|
|
7
|
+
import { SearchDialog } from '@/components/search/SearchDialog';
|
|
8
|
+
import { payload } from '@/payload/config';
|
|
9
|
+
import { validatePayload } from '@/lib/payload/validator';
|
|
10
|
+
import { getSite } from '@/lib/site';
|
|
11
|
+
import { asset, fileUrl, pageUrl } from '@/lib/basePath';
|
|
12
|
+
|
|
13
|
+
// Validate payload at build time
|
|
14
|
+
const validation = validatePayload(payload);
|
|
15
|
+
if (!validation.valid) {
|
|
16
|
+
console.error('❌ Payload validation failed:');
|
|
17
|
+
validation.errors?.forEach((err) => console.error(` - ${err}`));
|
|
18
|
+
throw new Error('Invalid payload configuration. Please fix the errors above.');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Rewrites configured social images to absolute URLs.
|
|
23
|
+
*
|
|
24
|
+
* A crawler fetching `og:image` has no page to resolve a relative path
|
|
25
|
+
* against, so the value has to carry the origin and the base path. Entries
|
|
26
|
+
* already absolute are left alone, which is how an image on a CDN keeps
|
|
27
|
+
* working.
|
|
28
|
+
*
|
|
29
|
+
* @param images - `images` as written in the payload, in any form Next accepts
|
|
30
|
+
* @returns The same shape with every local path made absolute
|
|
31
|
+
*/
|
|
32
|
+
function absoluteImages<T>(images: T): T {
|
|
33
|
+
const toAbsolute = (image: unknown): unknown => {
|
|
34
|
+
if (typeof image === 'string') return fileUrl(image, payload.global.baseUrl);
|
|
35
|
+
if (image && typeof image === 'object' && 'url' in image) {
|
|
36
|
+
const { url } = image as { url: string };
|
|
37
|
+
return { ...image, url: fileUrl(url, payload.global.baseUrl) };
|
|
38
|
+
}
|
|
39
|
+
return image;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
if (Array.isArray(images)) return images.map(toAbsolute) as T;
|
|
43
|
+
return toAbsolute(images) as T;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Generate metadata from payload
|
|
47
|
+
export const metadata: Metadata = {
|
|
48
|
+
metadataBase: new URL(pageUrl('', payload.global.baseUrl)),
|
|
49
|
+
title: payload.global.title,
|
|
50
|
+
description: payload.global.description,
|
|
51
|
+
icons: {
|
|
52
|
+
icon: asset(payload.global.favicon || '/favicon.ico'),
|
|
53
|
+
},
|
|
54
|
+
alternates: {
|
|
55
|
+
canonical: pageUrl('', payload.global.baseUrl),
|
|
56
|
+
},
|
|
57
|
+
openGraph: payload.global.seo?.openGraph
|
|
58
|
+
? {
|
|
59
|
+
title: payload.global.seo.openGraph.title || payload.global.title,
|
|
60
|
+
description: payload.global.seo.openGraph.description || payload.global.description,
|
|
61
|
+
url: pageUrl('', payload.global.baseUrl),
|
|
62
|
+
images: absoluteImages(payload.global.seo.openGraph.images),
|
|
63
|
+
}
|
|
64
|
+
: undefined,
|
|
65
|
+
twitter: payload.global.seo?.twitter
|
|
66
|
+
? {
|
|
67
|
+
card: payload.global.seo.twitter.card || 'summary_large_image',
|
|
68
|
+
site: payload.global.seo.twitter.site,
|
|
69
|
+
creator: payload.global.seo.twitter.creator,
|
|
70
|
+
title: payload.global.seo.twitter.title || payload.global.title,
|
|
71
|
+
description: payload.global.seo.twitter.description || payload.global.description,
|
|
72
|
+
images: absoluteImages(payload.global.seo.twitter.images),
|
|
73
|
+
}
|
|
74
|
+
: undefined,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Web fonts, declared here rather than in `globals.css`.
|
|
79
|
+
*
|
|
80
|
+
* A stylesheet has no way to read the deployment base path, so a hardcoded
|
|
81
|
+
* `url('/fonts/…')` keeps pointing at the domain root and 404s once the site is
|
|
82
|
+
* served from a subdirectory — leaving every visitor on fallback fonts. Emitting
|
|
83
|
+
* the declarations from here puts them through `asset()` like every other file
|
|
84
|
+
* in `public/`.
|
|
85
|
+
*
|
|
86
|
+
* `preload` marks the weights the first paint needs; the rest load on demand.
|
|
87
|
+
*/
|
|
88
|
+
const FONT_FACES = [
|
|
89
|
+
{ family: 'SUITE', weight: 400, file: '/fonts/SUITE/SUITE-Regular.woff2' },
|
|
90
|
+
{ family: 'SUITE', weight: 600, file: '/fonts/SUITE/SUITE-SemiBold.woff2' },
|
|
91
|
+
{ family: 'SUITE', weight: 700, file: '/fonts/SUITE/SUITE-Bold.woff2' },
|
|
92
|
+
{
|
|
93
|
+
family: 'Pretendard',
|
|
94
|
+
weight: 400,
|
|
95
|
+
file: '/fonts/Pretandard/Pretendard-Regular.woff2',
|
|
96
|
+
preload: true,
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
family: 'Pretendard',
|
|
100
|
+
weight: 600,
|
|
101
|
+
file: '/fonts/Pretandard/Pretendard-SemiBold.woff2',
|
|
102
|
+
preload: true,
|
|
103
|
+
},
|
|
104
|
+
{ family: 'Pretendard', weight: 700, file: '/fonts/Pretandard/Pretendard-Bold.woff2' },
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
const fontFaceCss = FONT_FACES.map(
|
|
108
|
+
({ family, weight, file }) =>
|
|
109
|
+
`@font-face{font-family:'${family}';font-weight:${weight};` +
|
|
110
|
+
`src:url('${asset(file)}') format('woff2');font-display:swap}`,
|
|
111
|
+
).join('');
|
|
112
|
+
|
|
113
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
114
|
+
const site = getSite();
|
|
115
|
+
const homeUrl = pageUrl('', site.global.baseUrl);
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<html lang="en">
|
|
119
|
+
<head>
|
|
120
|
+
{FONT_FACES.filter((font) => font.preload).map((font) => (
|
|
121
|
+
<link
|
|
122
|
+
key={font.file}
|
|
123
|
+
rel="preload"
|
|
124
|
+
href={asset(font.file)}
|
|
125
|
+
as="font"
|
|
126
|
+
type="font/woff2"
|
|
127
|
+
crossOrigin="anonymous"
|
|
128
|
+
/>
|
|
129
|
+
))}
|
|
130
|
+
<style dangerouslySetInnerHTML={{ __html: fontFaceCss }} />
|
|
131
|
+
<script
|
|
132
|
+
type="application/ld+json"
|
|
133
|
+
dangerouslySetInnerHTML={{
|
|
134
|
+
__html: JSON.stringify({
|
|
135
|
+
'@context': 'https://schema.org',
|
|
136
|
+
'@type': 'WebSite',
|
|
137
|
+
name: site.global.title,
|
|
138
|
+
description: site.global.description,
|
|
139
|
+
url: homeUrl,
|
|
140
|
+
}),
|
|
141
|
+
}}
|
|
142
|
+
/>
|
|
143
|
+
</head>
|
|
144
|
+
<body>
|
|
145
|
+
{/*
|
|
146
|
+
Reaching the article by keyboard otherwise means tabbing past the
|
|
147
|
+
whole navigation tree — thirty-six stops on this site, on every page.
|
|
148
|
+
Visible only while focused, so it costs nothing to a mouse user.
|
|
149
|
+
*/}
|
|
150
|
+
<a
|
|
151
|
+
href="#main-content"
|
|
152
|
+
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-50 focus:rounded-md focus:bg-white focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-gray-900 focus:shadow-lg focus:outline focus:outline-2 focus:outline-blue-600 dark:focus:bg-gray-900 dark:focus:text-gray-100"
|
|
153
|
+
>
|
|
154
|
+
Skip to content
|
|
155
|
+
</a>
|
|
156
|
+
<UrlMapProvider value={site.urlMap}>
|
|
157
|
+
<TabInitializer navigation={site.navigation} />
|
|
158
|
+
<PageLayout navigation={site.navigation}>{children}</PageLayout>
|
|
159
|
+
<SearchDialog />
|
|
160
|
+
</UrlMapProvider>
|
|
161
|
+
</body>
|
|
162
|
+
</html>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 404 Not Found page
|
|
5
|
+
*/
|
|
6
|
+
export default function NotFound() {
|
|
7
|
+
return (
|
|
8
|
+
<div className="min-h-[60vh] flex items-center justify-center px-4">
|
|
9
|
+
<div className="max-w-md w-full text-center">
|
|
10
|
+
<div className="mb-8">
|
|
11
|
+
<svg
|
|
12
|
+
className="mx-auto h-24 w-24 text-gray-300 dark:text-gray-700"
|
|
13
|
+
fill="none"
|
|
14
|
+
viewBox="0 0 24 24"
|
|
15
|
+
stroke="currentColor"
|
|
16
|
+
>
|
|
17
|
+
<path
|
|
18
|
+
strokeLinecap="round"
|
|
19
|
+
strokeLinejoin="round"
|
|
20
|
+
strokeWidth={1.5}
|
|
21
|
+
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
22
|
+
/>
|
|
23
|
+
</svg>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<h1 className="text-6xl font-bold text-gray-900 dark:text-gray-100 mb-2">404</h1>
|
|
27
|
+
<h2 className="text-2xl font-semibold text-gray-700 dark:text-gray-300 mb-4">
|
|
28
|
+
Page not found
|
|
29
|
+
</h2>
|
|
30
|
+
|
|
31
|
+
<p className="text-gray-600 dark:text-gray-400 mb-2">
|
|
32
|
+
The page you're looking for doesn't exist or has been moved.
|
|
33
|
+
</p>
|
|
34
|
+
|
|
35
|
+
<p className="text-sm text-gray-500 dark:text-gray-500 mb-8">
|
|
36
|
+
Try using the navigation sidebar to find what you're looking for.
|
|
37
|
+
</p>
|
|
38
|
+
|
|
39
|
+
<Link
|
|
40
|
+
href="/"
|
|
41
|
+
className="inline-block px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors shadow-sm"
|
|
42
|
+
>
|
|
43
|
+
Go back home
|
|
44
|
+
</Link>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|