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.
Files changed (98) hide show
  1. package/README.md +46 -0
  2. package/bin/create-eziwiki.mjs +83 -0
  3. package/lib/scaffold.mjs +230 -0
  4. package/lib/scaffold.test.mjs +270 -0
  5. package/package.json +38 -0
  6. package/template/README.md +39 -0
  7. package/template/app/[...slug]/page.tsx +162 -0
  8. package/template/app/error.tsx +77 -0
  9. package/template/app/global-error.tsx +76 -0
  10. package/template/app/globals.css +44 -0
  11. package/template/app/graph/page.tsx +62 -0
  12. package/template/app/layout.tsx +164 -0
  13. package/template/app/not-found.tsx +48 -0
  14. package/template/app/page.tsx +34 -0
  15. package/template/app/robots.ts +20 -0
  16. package/template/app/sitemap.ts +48 -0
  17. package/template/components/ThemeToggle.tsx +77 -0
  18. package/template/components/graph/GraphView.tsx +156 -0
  19. package/template/components/layout/Backlinks.tsx +42 -0
  20. package/template/components/layout/Breadcrumb.tsx +88 -0
  21. package/template/components/layout/MobileMenu.tsx +299 -0
  22. package/template/components/layout/NavigationButtons.tsx +87 -0
  23. package/template/components/layout/PageLayout.tsx +89 -0
  24. package/template/components/layout/Sidebar.tsx +376 -0
  25. package/template/components/layout/TabBar.tsx +312 -0
  26. package/template/components/layout/TabBarSkeleton.tsx +12 -0
  27. package/template/components/layout/TabInitializer.tsx +99 -0
  28. package/template/components/layout/TableOfContents.tsx +138 -0
  29. package/template/components/markdown/CodeCopy.tsx +65 -0
  30. package/template/components/markdown/MarkdownContent.tsx +38 -0
  31. package/template/components/markdown/PageTransition.tsx +56 -0
  32. package/template/components/providers/UrlMapProvider.tsx +68 -0
  33. package/template/components/search/SearchDialog.tsx +286 -0
  34. package/template/components/search/SearchTrigger.tsx +41 -0
  35. package/template/content/guides/_meta.json +4 -0
  36. package/template/content/guides/writing.md +82 -0
  37. package/template/content/intro.md +29 -0
  38. package/template/eslintignore +7 -0
  39. package/template/eslintrc.js +40 -0
  40. package/template/gitignore +40 -0
  41. package/template/lib/basePath.test.ts +120 -0
  42. package/template/lib/basePath.ts +108 -0
  43. package/template/lib/cache.ts +36 -0
  44. package/template/lib/content/registry.ts +311 -0
  45. package/template/lib/content/resolver.ts +109 -0
  46. package/template/lib/graph/build.ts +214 -0
  47. package/template/lib/graph/layout.test.ts +189 -0
  48. package/template/lib/graph/layout.ts +247 -0
  49. package/template/lib/markdown/languages.test.ts +85 -0
  50. package/template/lib/markdown/languages.ts +103 -0
  51. package/template/lib/markdown/rehype-plugins.ts +240 -0
  52. package/template/lib/markdown/remark-wikilink.ts +141 -0
  53. package/template/lib/markdown/render.ts +175 -0
  54. package/template/lib/markdown/wikilink.test.ts +91 -0
  55. package/template/lib/markdown/wikilink.ts +85 -0
  56. package/template/lib/navigation/auto.ts +227 -0
  57. package/template/lib/navigation/builder.test.ts +129 -0
  58. package/template/lib/navigation/builder.ts +122 -0
  59. package/template/lib/navigation/hash.ts +32 -0
  60. package/template/lib/navigation/url.test.ts +88 -0
  61. package/template/lib/navigation/url.ts +108 -0
  62. package/template/lib/navigation/urlMap.ts +81 -0
  63. package/template/lib/payload/schema.ts +81 -0
  64. package/template/lib/payload/types.ts +105 -0
  65. package/template/lib/payload/validator.ts +56 -0
  66. package/template/lib/search/build.ts +204 -0
  67. package/template/lib/search/client.ts +190 -0
  68. package/template/lib/search/tokenizer.test.ts +60 -0
  69. package/template/lib/search/tokenizer.ts +83 -0
  70. package/template/lib/search/types.ts +40 -0
  71. package/template/lib/site.ts +86 -0
  72. package/template/lib/store/searchStore.ts +26 -0
  73. package/template/lib/store/tabStore.ts +313 -0
  74. package/template/next-env.d.ts +5 -0
  75. package/template/next.config.js +43 -0
  76. package/template/package-lock.json +9933 -0
  77. package/template/package.json +69 -0
  78. package/template/payload/config.ts +34 -0
  79. package/template/postcss.config.js +6 -0
  80. package/template/prettierignore +7 -0
  81. package/template/prettierrc +8 -0
  82. package/template/public/favicon.svg +10 -0
  83. package/template/public/fonts/Pretandard/Pretendard-Bold.woff2 +0 -0
  84. package/template/public/fonts/Pretandard/Pretendard-Regular.woff2 +0 -0
  85. package/template/public/fonts/Pretandard/Pretendard-SemiBold.woff2 +0 -0
  86. package/template/public/fonts/SUITE/SUITE-Bold.woff2 +0 -0
  87. package/template/public/fonts/SUITE/SUITE-Regular.woff2 +0 -0
  88. package/template/public/fonts/SUITE/SUITE-SemiBold.woff2 +0 -0
  89. package/template/public/images/.gitkeep +0 -0
  90. package/template/scripts/build-search-index.ts +36 -0
  91. package/template/scripts/check-links.ts +40 -0
  92. package/template/scripts/show-urls.ts +48 -0
  93. package/template/scripts/validate-payload.ts +30 -0
  94. package/template/styles/markdown.css +167 -0
  95. package/template/styles/theme.css +65 -0
  96. package/template/tailwind.config.ts +156 -0
  97. package/template/tsconfig.json +32 -0
  98. 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&apos;re looking for doesn&apos;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&apos;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
+ }