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,34 @@
1
+ import { PageTransition } from '@/components/markdown/PageTransition';
2
+
3
+ /**
4
+ * Home page - shows empty state for New Tab
5
+ */
6
+ export default function Home() {
7
+ return (
8
+ <PageTransition>
9
+ <div className="flex flex-col items-center justify-center min-h-[60vh] text-center px-4">
10
+ <div className="max-w-md">
11
+ <svg
12
+ className="w-20 h-20 mx-auto mb-6 text-gray-300 dark:text-gray-700"
13
+ fill="none"
14
+ stroke="currentColor"
15
+ viewBox="0 0 24 24"
16
+ >
17
+ <path
18
+ strokeLinecap="round"
19
+ strokeLinejoin="round"
20
+ strokeWidth={1.5}
21
+ d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
22
+ />
23
+ </svg>
24
+ <h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100 mb-3">
25
+ Welcome to Documentation
26
+ </h2>
27
+ <p className="text-gray-600 dark:text-gray-400">
28
+ Select a page from the sidebar to get started, or click the + button to open a new tab.
29
+ </p>
30
+ </div>
31
+ </div>
32
+ </PageTransition>
33
+ );
34
+ }
@@ -0,0 +1,20 @@
1
+ import { MetadataRoute } from 'next';
2
+ import { payload } from '@/payload/config';
3
+ import { fileUrl } from '@/lib/basePath';
4
+
5
+ /**
6
+ * Generate robots.txt file
7
+ * Controls how search engines crawl and index the site
8
+ *
9
+ * @returns Robots configuration
10
+ */
11
+ export default function robots(): MetadataRoute.Robots {
12
+ return {
13
+ rules: {
14
+ userAgent: '*',
15
+ allow: '/',
16
+ disallow: ['/api/', '/_next/'],
17
+ },
18
+ sitemap: fileUrl('/sitemap.xml', payload.global.baseUrl),
19
+ };
20
+ }
@@ -0,0 +1,48 @@
1
+ import { MetadataRoute } from 'next';
2
+ import { getSite } from '@/lib/site';
3
+ import { docPathToUrl } from '@/lib/navigation/url';
4
+ import { pageUrl } from '@/lib/basePath';
5
+
6
+ /**
7
+ * Generates the sitemap for every published page.
8
+ *
9
+ * Entries are derived from the content registry rather than from navigation,
10
+ * so documents reachable only by direct link are still discoverable. Pages
11
+ * marked `hidden` are excluded — they are deliberately unlisted, and
12
+ * advertising them in the sitemap would defeat that.
13
+ *
14
+ * Under the `hash` URL strategy the emitted URLs are hashes, which search
15
+ * engines can crawl but which carry no descriptive value; the `path` strategy
16
+ * is the one to use if organic search matters.
17
+ *
18
+ * @returns Sitemap entries for the home page and all visible content
19
+ */
20
+ export default function sitemap(): MetadataRoute.Sitemap {
21
+ const { global, urlMap, docPaths, hiddenPaths } = getSite();
22
+ const lastModified = new Date();
23
+
24
+ const homeEntry: MetadataRoute.Sitemap[0] = {
25
+ url: pageUrl('', global.baseUrl),
26
+ lastModified,
27
+ changeFrequency: 'weekly',
28
+ priority: 1,
29
+ };
30
+
31
+ const contentEntries = docPaths.flatMap((path): MetadataRoute.Sitemap => {
32
+ if (hiddenPaths.has(path)) return [];
33
+
34
+ const url = docPathToUrl(urlMap, path);
35
+ if (!url) return [];
36
+
37
+ return [
38
+ {
39
+ url: pageUrl(url, global.baseUrl),
40
+ lastModified,
41
+ changeFrequency: 'weekly',
42
+ priority: 0.8,
43
+ },
44
+ ];
45
+ });
46
+
47
+ return [homeEntry, ...contentEntries];
48
+ }
@@ -0,0 +1,77 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+
5
+ interface ThemeToggleProps {
6
+ className?: string;
7
+ }
8
+
9
+ export function ThemeToggle({ className = 'w-5 h-5' }: ThemeToggleProps) {
10
+ const [theme, setTheme] = useState<'light' | 'dark'>('light');
11
+
12
+ useEffect(() => {
13
+ // Get initial theme from localStorage or system preference
14
+ const stored = localStorage.getItem('theme') as 'light' | 'dark' | null;
15
+ if (stored) {
16
+ setTheme(stored);
17
+ document.documentElement.classList.toggle('dark', stored === 'dark');
18
+ } else {
19
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
20
+ const initialTheme = prefersDark ? 'dark' : 'light';
21
+ setTheme(initialTheme);
22
+ document.documentElement.classList.toggle('dark', prefersDark);
23
+ }
24
+ }, []);
25
+
26
+ const toggleTheme = () => {
27
+ const newTheme = theme === 'dark' ? 'light' : 'dark';
28
+ setTheme(newTheme);
29
+ localStorage.setItem('theme', newTheme);
30
+ document.documentElement.classList.toggle('dark', newTheme === 'dark');
31
+ };
32
+
33
+ return (
34
+ <button
35
+ onClick={toggleTheme}
36
+ className="p-2 rounded-md text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-800 transition-colors"
37
+ aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
38
+ title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
39
+ >
40
+ {theme === 'dark' ? (
41
+ <svg
42
+ xmlns="http://www.w3.org/2000/svg"
43
+ viewBox="0 0 24 24"
44
+ fill="none"
45
+ stroke="currentColor"
46
+ strokeWidth="2"
47
+ strokeLinecap="round"
48
+ strokeLinejoin="round"
49
+ className={className}
50
+ >
51
+ <circle cx="12" cy="12" r="5" />
52
+ <line x1="12" y1="1" x2="12" y2="3" />
53
+ <line x1="12" y1="21" x2="12" y2="23" />
54
+ <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
55
+ <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
56
+ <line x1="1" y1="12" x2="3" y2="12" />
57
+ <line x1="21" y1="12" x2="23" y2="12" />
58
+ <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
59
+ <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
60
+ </svg>
61
+ ) : (
62
+ <svg
63
+ xmlns="http://www.w3.org/2000/svg"
64
+ viewBox="0 0 24 24"
65
+ fill="none"
66
+ stroke="currentColor"
67
+ strokeWidth="2"
68
+ strokeLinecap="round"
69
+ strokeLinejoin="round"
70
+ className={className}
71
+ >
72
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
73
+ </svg>
74
+ )}
75
+ </button>
76
+ );
77
+ }
@@ -0,0 +1,156 @@
1
+ 'use client';
2
+
3
+ import React, { useMemo, useState } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+ import { bounds, layout, type LayoutEdge } from '@/lib/graph/layout';
6
+
7
+ /**
8
+ * Renders the document link graph as an interactive SVG.
9
+ *
10
+ * The layout is computed once from a deterministic seed, so the graph looks the
11
+ * same on every visit — a graph that rearranges itself each time is hard to
12
+ * build any familiarity with. Hovering a node dims everything it is not
13
+ * connected to, which is the only practical way to read a dense graph.
14
+ */
15
+
16
+ /** A node as supplied by the server. */
17
+ export interface GraphViewNode {
18
+ path: string;
19
+ title: string;
20
+ url: string;
21
+ degree: number;
22
+ }
23
+
24
+ interface GraphViewProps {
25
+ nodes: GraphViewNode[];
26
+ edges: LayoutEdge[];
27
+ }
28
+
29
+ /** Nominal layout area; the SVG viewBox scales the result to fit. */
30
+ const AREA = { width: 900, height: 640 };
31
+
32
+ /** Node radius bounds, interpolated by link count. */
33
+ const MIN_RADIUS = 5;
34
+ const MAX_RADIUS = 14;
35
+
36
+ export function GraphView({ nodes, edges }: GraphViewProps) {
37
+ const router = useRouter();
38
+ const [hovered, setHovered] = useState<string | null>(null);
39
+
40
+ const positions = useMemo(() => {
41
+ const settled = layout(
42
+ nodes.map((node) => node.path),
43
+ edges,
44
+ AREA,
45
+ );
46
+
47
+ return new Map(settled.map((node) => [node.id, node]));
48
+ }, [nodes, edges]);
49
+
50
+ const box = useMemo(() => bounds([...positions.values()]), [positions]);
51
+
52
+ const maxDegree = useMemo(() => Math.max(1, ...nodes.map((node) => node.degree)), [nodes]);
53
+
54
+ /** Paths connected to the hovered node, including itself. */
55
+ const connected = useMemo(() => {
56
+ if (!hovered) return null;
57
+
58
+ const set = new Set<string>([hovered]);
59
+ for (const edge of edges) {
60
+ if (edge.from === hovered) set.add(edge.to);
61
+ if (edge.to === hovered) set.add(edge.from);
62
+ }
63
+ return set;
64
+ }, [hovered, edges]);
65
+
66
+ if (nodes.length === 0) {
67
+ return (
68
+ <p className="py-16 text-center text-sm text-gray-500 dark:text-gray-400">
69
+ No pages to graph yet.
70
+ </p>
71
+ );
72
+ }
73
+
74
+ return (
75
+ <div className="overflow-hidden rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-900">
76
+ <svg
77
+ viewBox={`${box.x} ${box.y} ${box.width} ${box.height}`}
78
+ className="h-[70vh] w-full"
79
+ role="img"
80
+ aria-label={`Link graph of ${nodes.length} pages and ${edges.length} links`}
81
+ >
82
+ <g>
83
+ {edges.map((edge, index) => {
84
+ const from = positions.get(edge.from);
85
+ const to = positions.get(edge.to);
86
+ if (!from || !to) return null;
87
+
88
+ const active = !connected || (connected.has(edge.from) && connected.has(edge.to));
89
+
90
+ return (
91
+ <line
92
+ key={`${edge.from}->${edge.to}-${index}`}
93
+ x1={from.x}
94
+ y1={from.y}
95
+ x2={to.x}
96
+ y2={to.y}
97
+ className="stroke-gray-300 dark:stroke-gray-700"
98
+ strokeWidth={active ? 1.4 : 0.6}
99
+ opacity={active ? 0.9 : 0.15}
100
+ />
101
+ );
102
+ })}
103
+ </g>
104
+
105
+ <g>
106
+ {nodes.map((node) => {
107
+ const position = positions.get(node.path);
108
+ if (!position) return null;
109
+
110
+ const radius =
111
+ MIN_RADIUS + (MAX_RADIUS - MIN_RADIUS) * Math.sqrt(node.degree / maxDegree);
112
+ const active = !connected || connected.has(node.path);
113
+
114
+ return (
115
+ <g
116
+ key={node.path}
117
+ transform={`translate(${position.x}, ${position.y})`}
118
+ opacity={active ? 1 : 0.2}
119
+ className="cursor-pointer"
120
+ onMouseEnter={() => setHovered(node.path)}
121
+ onMouseLeave={() => setHovered(null)}
122
+ onClick={() => router.push(node.url)}
123
+ onKeyDown={(event) => {
124
+ if (event.key === 'Enter' || event.key === ' ') {
125
+ event.preventDefault();
126
+ router.push(node.url);
127
+ }
128
+ }}
129
+ role="link"
130
+ tabIndex={0}
131
+ aria-label={node.title}
132
+ >
133
+ <circle
134
+ r={radius}
135
+ className={
136
+ hovered === node.path
137
+ ? 'fill-blue-500 stroke-white dark:stroke-gray-900'
138
+ : 'fill-blue-400/80 stroke-white dark:fill-blue-500/70 dark:stroke-gray-900'
139
+ }
140
+ strokeWidth={1.5}
141
+ />
142
+ <text
143
+ y={radius + 12}
144
+ textAnchor="middle"
145
+ className="pointer-events-none fill-gray-700 text-[11px] dark:fill-gray-300"
146
+ >
147
+ {node.title}
148
+ </text>
149
+ </g>
150
+ );
151
+ })}
152
+ </g>
153
+ </svg>
154
+ </div>
155
+ );
156
+ }
@@ -0,0 +1,42 @@
1
+ import Link from 'next/link';
2
+ import { CornerUpLeft } from 'lucide-react';
3
+ import type { GraphNode } from '@/lib/graph/build';
4
+
5
+ /**
6
+ * Lists the pages that link to the current one.
7
+ *
8
+ * Backlinks are what turn a set of documents into a wiki: they show how a page
9
+ * is reached and what context it sits in, which forward links alone never
10
+ * reveal. Computed at build time, so this is a plain server component.
11
+ */
12
+ export function Backlinks({ links }: { links: GraphNode[] }) {
13
+ if (links.length === 0) return null;
14
+
15
+ return (
16
+ <section
17
+ aria-labelledby="backlinks-heading"
18
+ className="mt-16 border-t border-gray-200 pt-6 dark:border-gray-800"
19
+ >
20
+ <h2
21
+ id="backlinks-heading"
22
+ className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
23
+ >
24
+ <CornerUpLeft className="h-3.5 w-3.5" />
25
+ Linked from {links.length} {links.length === 1 ? 'page' : 'pages'}
26
+ </h2>
27
+
28
+ <ul className="flex flex-wrap gap-2">
29
+ {links.map((link) => (
30
+ <li key={link.path}>
31
+ <Link
32
+ href={link.url}
33
+ className="inline-block rounded-md border border-gray-200 px-2.5 py-1 text-sm text-gray-700 no-underline transition-colors hover:border-gray-300 hover:bg-gray-50 dark:border-gray-800 dark:text-gray-300 dark:hover:border-gray-700 dark:hover:bg-gray-800/50"
34
+ >
35
+ {link.title}
36
+ </Link>
37
+ </li>
38
+ ))}
39
+ </ul>
40
+ </section>
41
+ );
42
+ }
@@ -0,0 +1,88 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { usePathname } from 'next/navigation';
5
+ import Link from 'next/link';
6
+ import { NavigationItem } from '@/lib/payload/types';
7
+ import { useUrlMap } from '@/components/providers/UrlMapProvider';
8
+
9
+ interface BreadcrumbProps {
10
+ navigation: NavigationItem[];
11
+ }
12
+
13
+ /**
14
+ * Build breadcrumb trail by finding the path through navigation tree
15
+ */
16
+ function buildBreadcrumbTrail(
17
+ items: NavigationItem[],
18
+ targetPath: string,
19
+ trail: Array<{ name: string; path?: string }> = [],
20
+ ): Array<{ name: string; path?: string }> | null {
21
+ for (const item of items) {
22
+ if (item.path === targetPath) {
23
+ return [...trail, { name: item.name, path: item.path }];
24
+ }
25
+
26
+ if (item.children) {
27
+ const found = buildBreadcrumbTrail(item.children, targetPath, [
28
+ ...trail,
29
+ { name: item.name, path: item.path },
30
+ ]);
31
+ if (found) return found;
32
+ }
33
+ }
34
+
35
+ return null;
36
+ }
37
+
38
+ export function Breadcrumb({ navigation }: BreadcrumbProps) {
39
+ const pathname = usePathname();
40
+ const { href: urlFor, toPath } = useUrlMap();
41
+
42
+ // Home page - don't show breadcrumb
43
+ if (pathname === '/') {
44
+ return null;
45
+ }
46
+
47
+ const currentPath = toPath(pathname);
48
+
49
+ if (!currentPath) {
50
+ return null;
51
+ }
52
+
53
+ const trail = buildBreadcrumbTrail(navigation, currentPath);
54
+
55
+ if (!trail || trail.length === 0) {
56
+ return null;
57
+ }
58
+
59
+ return (
60
+ <nav className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
61
+ {trail.map((item, index) => (
62
+ <React.Fragment key={index}>
63
+ {index > 0 && (
64
+ <svg
65
+ className="w-3.5 h-3.5 flex-shrink-0"
66
+ fill="none"
67
+ stroke="currentColor"
68
+ viewBox="0 0 24 24"
69
+ >
70
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
71
+ </svg>
72
+ )}
73
+
74
+ {index === trail.length - 1 || !item.path ? (
75
+ <span className="font-medium text-gray-900 dark:text-gray-100">{item.name}</span>
76
+ ) : (
77
+ <Link
78
+ href={urlFor(item.path)}
79
+ className="hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
80
+ >
81
+ {item.name}
82
+ </Link>
83
+ )}
84
+ </React.Fragment>
85
+ ))}
86
+ </nav>
87
+ );
88
+ }