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,99 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef } from 'react';
4
+ import { usePathname } from 'next/navigation';
5
+ import { useTabStore } from '@/lib/store/tabStore';
6
+ import { NavigationItem } from '@/lib/payload/types';
7
+ import { useUrlMap } from '@/components/providers/UrlMapProvider';
8
+
9
+ interface TabInitializerProps {
10
+ navigation: NavigationItem[];
11
+ }
12
+
13
+ /**
14
+ * Helper function to find navigation item by path
15
+ */
16
+ export function findNavigationItemByPath(
17
+ items: NavigationItem[],
18
+ path: string,
19
+ ): NavigationItem | null {
20
+ const normalizedPath = path.replace(/\/$/, '');
21
+
22
+ for (const item of items) {
23
+ if (!item.path) {
24
+ if (item.children) {
25
+ const found = findNavigationItemByPath(item.children, path);
26
+ if (found) return found;
27
+ }
28
+ continue;
29
+ }
30
+
31
+ const normalizedItemPath = item.path.replace(/\/$/, '');
32
+ if (normalizedItemPath === normalizedPath) {
33
+ return item;
34
+ }
35
+
36
+ if (item.children) {
37
+ const found = findNavigationItemByPath(item.children, path);
38
+ if (found) return found;
39
+ }
40
+ }
41
+ return null;
42
+ }
43
+
44
+ /**
45
+ * Initializes tabs on first load and handles URL changes
46
+ */
47
+ export function TabInitializer({ navigation }: TabInitializerProps) {
48
+ const pathname = usePathname();
49
+ const { toPath } = useUrlMap();
50
+ const { tabs, addTab, activeTabId, updateTabPath, navigateInHistory, hasHydrated } =
51
+ useTabStore();
52
+ const isInitialMount = useRef(true);
53
+ const previousPathname = useRef(pathname);
54
+
55
+ useEffect(() => {
56
+ if (!hasHydrated) return;
57
+
58
+ const currentPath = pathname === '/' ? '' : (toPath(pathname) ?? '');
59
+
60
+ // Initial mount - set up first tab
61
+ if (isInitialMount.current) {
62
+ if (tabs.length === 0) {
63
+ // No saved tabs - create initial tab based on URL
64
+ const navItem = findNavigationItemByPath(navigation, currentPath);
65
+ const title = navItem?.name || 'New Tab';
66
+ addTab({ title, path: currentPath });
67
+ } else if (activeTabId) {
68
+ // Tabs exist - update active tab to match URL
69
+ const navItem = findNavigationItemByPath(navigation, currentPath);
70
+ const title = navItem?.name || 'New Tab';
71
+ updateTabPath(activeTabId, currentPath, title);
72
+ }
73
+
74
+ isInitialMount.current = false;
75
+ previousPathname.current = pathname;
76
+ return;
77
+ }
78
+
79
+ // URL changed - add to history
80
+ if (pathname !== previousPathname.current && activeTabId) {
81
+ const navItem = findNavigationItemByPath(navigation, currentPath);
82
+ const title = navItem?.name || 'New Tab';
83
+ navigateInHistory(activeTabId, currentPath, title);
84
+ previousPathname.current = pathname;
85
+ }
86
+ }, [
87
+ hasHydrated,
88
+ tabs.length,
89
+ pathname,
90
+ navigation,
91
+ toPath,
92
+ addTab,
93
+ activeTabId,
94
+ updateTabPath,
95
+ navigateInHistory,
96
+ ]);
97
+
98
+ return null;
99
+ }
@@ -0,0 +1,138 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import type { Heading } from '@/lib/markdown/rehype-plugins';
5
+
6
+ /**
7
+ * On-page table of contents with scroll tracking.
8
+ *
9
+ * Headings are collected during the build, so the list is server-rendered and
10
+ * present in the HTML; the only client-side work is highlighting whichever
11
+ * section the reader is currently looking at.
12
+ */
13
+
14
+ interface TableOfContentsProps {
15
+ /** Headings for the current document, in order */
16
+ headings: Heading[];
17
+ }
18
+
19
+ /**
20
+ * Top offset used when deciding which heading is "current".
21
+ *
22
+ * Matches the sticky header, so a heading counts as active once it reaches the
23
+ * point where it becomes visible rather than when it touches the viewport edge.
24
+ */
25
+ const SCROLL_OFFSET = 96;
26
+
27
+ /**
28
+ * Tracks which heading the reader is currently under.
29
+ *
30
+ * Chosen by position rather than by intersection ratio: a long section whose
31
+ * heading has scrolled off should stay active, which observer-visibility alone
32
+ * does not express.
33
+ *
34
+ * @param headings - Headings to track
35
+ * @returns The id of the active heading
36
+ */
37
+ function useActiveHeading(headings: Heading[]): string | null {
38
+ const [active, setActive] = useState<string | null>(null);
39
+
40
+ useEffect(() => {
41
+ if (headings.length === 0) return;
42
+
43
+ let frame = 0;
44
+
45
+ const update = () => {
46
+ frame = 0;
47
+
48
+ const positions = headings
49
+ .map((heading) => {
50
+ const element = document.getElementById(heading.id);
51
+ return element ? { id: heading.id, top: element.getBoundingClientRect().top } : null;
52
+ })
53
+ .filter((entry): entry is { id: string; top: number } => entry !== null);
54
+
55
+ if (positions.length === 0) return;
56
+
57
+ // The last heading at or above the offset line is the one being read.
58
+ const passed = positions.filter((entry) => entry.top <= SCROLL_OFFSET);
59
+
60
+ // Near the bottom of the page the final section may never reach the
61
+ // offset line, so pin the last heading once the page is scrolled to end.
62
+ const atBottom =
63
+ window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2;
64
+
65
+ if (atBottom) {
66
+ setActive(positions[positions.length - 1].id);
67
+ } else {
68
+ setActive(passed.length > 0 ? passed[passed.length - 1].id : positions[0].id);
69
+ }
70
+ };
71
+
72
+ const onScroll = () => {
73
+ // Scroll fires far more often than the display refreshes; coalescing to
74
+ // one measurement per frame keeps layout reads off the scroll path.
75
+ if (frame === 0) frame = requestAnimationFrame(update);
76
+ };
77
+
78
+ update();
79
+ window.addEventListener('scroll', onScroll, { passive: true });
80
+ window.addEventListener('resize', onScroll);
81
+
82
+ return () => {
83
+ if (frame) cancelAnimationFrame(frame);
84
+ window.removeEventListener('scroll', onScroll);
85
+ window.removeEventListener('resize', onScroll);
86
+ };
87
+ }, [headings]);
88
+
89
+ return active;
90
+ }
91
+
92
+ /**
93
+ * Renders the table of contents rail.
94
+ *
95
+ * Returns nothing for documents with fewer than two headings — a contents list
96
+ * with one entry is noise.
97
+ *
98
+ * @param props.headings - Headings for the current document
99
+ */
100
+ export function TableOfContents({ headings }: TableOfContentsProps) {
101
+ const active = useActiveHeading(headings);
102
+
103
+ if (headings.length < 2) return null;
104
+
105
+ // Normalise depth so a document starting at h3 is not indented as if nested.
106
+ const minDepth = Math.min(...headings.map((heading) => heading.depth));
107
+
108
+ return (
109
+ <nav aria-label="On this page" className="text-sm">
110
+ <p className="mb-3 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
111
+ On this page
112
+ </p>
113
+
114
+ <ul className="space-y-1 border-l border-gray-200 dark:border-gray-800">
115
+ {headings.map((heading) => {
116
+ const isActive = heading.id === active;
117
+
118
+ return (
119
+ <li key={heading.id}>
120
+ <a
121
+ href={`#${heading.id}`}
122
+ aria-current={isActive ? 'location' : undefined}
123
+ className={`-ml-px block border-l py-1 pr-2 transition-colors ${
124
+ isActive
125
+ ? 'border-blue-500 text-blue-600 dark:text-blue-400'
126
+ : 'border-transparent text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'
127
+ }`}
128
+ style={{ paddingLeft: `${(heading.depth - minDepth) * 12 + 12}px` }}
129
+ >
130
+ {heading.text}
131
+ </a>
132
+ </li>
133
+ );
134
+ })}
135
+ </ul>
136
+ </nav>
137
+ );
138
+ }
@@ -0,0 +1,65 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+
5
+ /**
6
+ * Wires up copy-to-clipboard for build-time rendered code blocks.
7
+ *
8
+ * The buttons themselves are emitted during the build by `rehypeCodeShell`;
9
+ * this component only attaches behaviour. A single delegated listener on the
10
+ * document handles every block on the page, which is what lets the syntax
11
+ * highlighting stack stay out of the client bundle entirely — previously the
12
+ * whole Prism highlighter shipped just to render a copy button.
13
+ *
14
+ * @example
15
+ * ```tsx
16
+ * <article dangerouslySetInnerHTML={{ __html: html }} />
17
+ * <CodeCopy />
18
+ * ```
19
+ */
20
+ export function CodeCopy() {
21
+ useEffect(() => {
22
+ /** Milliseconds the confirmation label stays visible. */
23
+ const CONFIRM_MS = 2000;
24
+ const timers = new Map<HTMLElement, ReturnType<typeof setTimeout>>();
25
+
26
+ async function handleClick(event: MouseEvent) {
27
+ const target = event.target as HTMLElement | null;
28
+ const button = target?.closest<HTMLElement>('[data-ezw-copy]');
29
+ if (!button) return;
30
+
31
+ const code = button.closest('[data-ezw-code]')?.querySelector('pre');
32
+ if (!code) return;
33
+
34
+ try {
35
+ await navigator.clipboard.writeText(code.textContent ?? '');
36
+ } catch {
37
+ // Clipboard access is denied outside secure contexts; leave the label
38
+ // unchanged so the failure is visible rather than falsely confirmed.
39
+ return;
40
+ }
41
+
42
+ button.textContent = '✓ Copied';
43
+ button.setAttribute('data-copied', '');
44
+
45
+ clearTimeout(timers.get(button));
46
+ timers.set(
47
+ button,
48
+ setTimeout(() => {
49
+ button.textContent = 'Copy';
50
+ button.removeAttribute('data-copied');
51
+ timers.delete(button);
52
+ }, CONFIRM_MS),
53
+ );
54
+ }
55
+
56
+ document.addEventListener('click', handleClick);
57
+
58
+ return () => {
59
+ document.removeEventListener('click', handleClick);
60
+ timers.forEach(clearTimeout);
61
+ };
62
+ }, []);
63
+
64
+ return null;
65
+ }
@@ -0,0 +1,38 @@
1
+ import { CodeCopy } from './CodeCopy';
2
+
3
+ /**
4
+ * Props for the MarkdownContent component
5
+ */
6
+ interface MarkdownContentProps {
7
+ /** HTML produced at build time by the Markdown pipeline */
8
+ html: string;
9
+ }
10
+
11
+ /**
12
+ * Renders pre-compiled Markdown HTML.
13
+ *
14
+ * The markup arrives already parsed, highlighted, and link-resolved from
15
+ * `renderDoc()`, so this is a server component that emits static HTML. The only
16
+ * client-side code is the small copy-button listener.
17
+ *
18
+ * Passing build-time output to `dangerouslySetInnerHTML` is safe here in the
19
+ * sense that matters: the input is the repository's own content files, not user
20
+ * submissions. Raw HTML in Markdown is intentionally supported.
21
+ *
22
+ * @param props - Component props
23
+ * @param props.html - Rendered HTML for the document body
24
+ *
25
+ * @example
26
+ * ```tsx
27
+ * const rendered = await renderDoc('guides/quick-start');
28
+ * <MarkdownContent html={rendered.html} />;
29
+ * ```
30
+ */
31
+ export function MarkdownContent({ html }: MarkdownContentProps) {
32
+ return (
33
+ <>
34
+ <div className="ezw-prose" dangerouslySetInnerHTML={{ __html: html }} />
35
+ <CodeCopy />
36
+ </>
37
+ );
38
+ }
@@ -0,0 +1,56 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useState } from 'react';
4
+ import { usePathname } from 'next/navigation';
5
+
6
+ /**
7
+ * Props for the PageTransition component
8
+ */
9
+ interface PageTransitionProps {
10
+ /** Child content to animate */
11
+ children: React.ReactNode;
12
+ }
13
+
14
+ /**
15
+ * Page transition wrapper that animates content on route changes
16
+ *
17
+ * Provides smooth fade-in and slide-up animations when navigating
18
+ * between pages. The animation is triggered by pathname changes.
19
+ *
20
+ * @param props - Component props
21
+ * @param props.children - Content to wrap with transition effects
22
+ *
23
+ * @example
24
+ * ```tsx
25
+ * <PageTransition>
26
+ * <article>Content here</article>
27
+ * </PageTransition>
28
+ * ```
29
+ */
30
+ export function PageTransition({ children }: PageTransitionProps) {
31
+ const pathname = usePathname();
32
+ const [isVisible, setIsVisible] = useState(false);
33
+
34
+ useEffect(() => {
35
+ // Reset animation on route change
36
+ setIsVisible(false);
37
+
38
+ // Trigger animation after a brief delay
39
+ const timer = setTimeout(() => {
40
+ setIsVisible(true);
41
+ }, 50);
42
+
43
+ return () => clearTimeout(timer);
44
+ }, [pathname]);
45
+
46
+ return (
47
+ <div
48
+ className={`
49
+ transition-all duration-500 ease-out
50
+ ${isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}
51
+ `}
52
+ >
53
+ {children}
54
+ </div>
55
+ );
56
+ }
@@ -0,0 +1,68 @@
1
+ 'use client';
2
+
3
+ import React, { createContext, useContext, useMemo } from 'react';
4
+ import {
5
+ EMPTY_URL_MAP,
6
+ docPathToUrl,
7
+ hrefFor,
8
+ urlToDocPath,
9
+ type UrlMap,
10
+ } from '@/lib/navigation/url';
11
+
12
+ /**
13
+ * Makes the build-time URL mapping available to client components.
14
+ *
15
+ * The mapping is computed on the server — under the hash strategy that means
16
+ * running SHA-256 over every content path — and passed down as plain data.
17
+ * Client components look URLs up instead of deriving them, so the hashing
18
+ * implementation never reaches the browser bundle.
19
+ */
20
+
21
+ const UrlMapContext = createContext<UrlMap>(EMPTY_URL_MAP);
22
+
23
+ /** Helpers bound to the current URL map. */
24
+ export interface UrlMapHelpers {
25
+ /** The raw mapping */
26
+ map: UrlMap;
27
+ /** Builds a root-relative href for a content path */
28
+ href: (docPath: string | undefined) => string;
29
+ /** Resolves a content path to its URL segment */
30
+ toUrl: (docPath: string) => string | null;
31
+ /** Resolves a URL segment back to its content path */
32
+ toPath: (slug: string) => string | null;
33
+ }
34
+
35
+ /**
36
+ * Provides the URL map to the client component tree.
37
+ *
38
+ * @param props.value - Mapping produced on the server by `getUrlMap()`
39
+ * @param props.children - Subtree that may consume the mapping
40
+ */
41
+ export function UrlMapProvider({ value, children }: { value: UrlMap; children: React.ReactNode }) {
42
+ return <UrlMapContext.Provider value={value}>{children}</UrlMapContext.Provider>;
43
+ }
44
+
45
+ /**
46
+ * Reads the URL map and its bound helpers.
47
+ *
48
+ * @returns Helpers for converting between content paths and URLs
49
+ *
50
+ * @example
51
+ * ```tsx
52
+ * const { href } = useUrlMap();
53
+ * <Link href={href('guides/quick-start')}>Quick Start</Link>;
54
+ * ```
55
+ */
56
+ export function useUrlMap(): UrlMapHelpers {
57
+ const map = useContext(UrlMapContext);
58
+
59
+ return useMemo(
60
+ () => ({
61
+ map,
62
+ href: (docPath: string | undefined) => hrefFor(map, docPath),
63
+ toUrl: (docPath: string) => docPathToUrl(map, docPath),
64
+ toPath: (slug: string) => urlToDocPath(map, slug),
65
+ }),
66
+ [map],
67
+ );
68
+ }