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,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
|
+
}
|