create-eziwiki 0.1.0 → 0.2.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 (42) hide show
  1. package/README.md +3 -2
  2. package/package.json +1 -1
  3. package/template/app/[...slug]/page.tsx +3 -1
  4. package/template/app/globals.css +8 -4
  5. package/template/app/layout.tsx +46 -24
  6. package/template/app/robots.ts +11 -3
  7. package/template/components/graph/GraphView.tsx +20 -7
  8. package/template/components/layout/LocalGraph.tsx +52 -0
  9. package/template/components/layout/MobileMenu.tsx +16 -1
  10. package/template/components/layout/PageLayout.tsx +11 -3
  11. package/template/components/layout/Sidebar.tsx +30 -2
  12. package/template/components/markdown/LinkPreview.tsx +164 -0
  13. package/template/components/markdown/MarkdownContent.tsx +3 -1
  14. package/template/components/search/SearchTrigger.tsx +11 -4
  15. package/template/lib/content/assets.ts +158 -0
  16. package/template/lib/content/excerpt.test.ts +68 -0
  17. package/template/lib/content/excerpt.ts +142 -0
  18. package/template/lib/graph/build.ts +55 -0
  19. package/template/lib/markdown/rehype-plugins.ts +24 -2
  20. package/template/lib/markdown/remark-wikilink.ts +201 -14
  21. package/template/lib/markdown/render.ts +130 -10
  22. package/template/lib/markdown/wikilink.test.ts +30 -0
  23. package/template/lib/markdown/wikilink.ts +12 -4
  24. package/template/lib/payload/schema.ts +1 -0
  25. package/template/lib/payload/types.ts +7 -0
  26. package/template/package-lock.json +3 -83
  27. package/template/package.json +2 -0
  28. package/template/public/fonts/Pretendard/pretendard-400-korean.woff2 +0 -0
  29. package/template/public/fonts/Pretendard/pretendard-400-latin.woff2 +0 -0
  30. package/template/public/fonts/Pretendard/pretendard-600-korean.woff2 +0 -0
  31. package/template/public/fonts/Pretendard/pretendard-600-latin.woff2 +0 -0
  32. package/template/public/fonts/Pretendard/pretendard-700-korean.woff2 +0 -0
  33. package/template/public/fonts/Pretendard/pretendard-700-latin.woff2 +0 -0
  34. package/template/styles/markdown.css +76 -2
  35. package/template/styles/theme.css +7 -0
  36. package/template/vercel.json +31 -0
  37. package/template/public/fonts/Pretandard/Pretendard-Bold.woff2 +0 -0
  38. package/template/public/fonts/Pretandard/Pretendard-Regular.woff2 +0 -0
  39. package/template/public/fonts/Pretandard/Pretendard-SemiBold.woff2 +0 -0
  40. package/template/public/fonts/SUITE/SUITE-Bold.woff2 +0 -0
  41. package/template/public/fonts/SUITE/SUITE-Regular.woff2 +0 -0
  42. package/template/public/fonts/SUITE/SUITE-SemiBold.woff2 +0 -0
package/README.md CHANGED
@@ -16,8 +16,9 @@ A complete, static-exportable wiki:
16
16
  - **Pages from files** — every Markdown file under `content/` is published, no registration step
17
17
  - **Search** — full-text over titles, headings, and body, with a ⌘K palette; runs entirely in the browser
18
18
  - **Contents rail** with scroll tracking, generated at build time
19
- - **Wiki links** — `[[page]]` resolves by path, file name, or title
20
- - **Backlinks** on every page, and a **graph view** of how pages connect
19
+ - **Wiki links** — `[[page]]` resolves by path, file name, or title, and hovering one previews where it goes
20
+ - **Embeds** `![[image.png]]` places a file, `![[page]]` includes another page's text, `![[page#section]]` just one section
21
+ - **Backlinks** on every page, plus a graph of its **neighbourhood** — and a `/graph` view of the whole site
21
22
  - **Build-time rendering** — Markdown is compiled and syntax-highlighted during the build, so no parser ships to the browser
22
23
  - Dark mode, maths, GFM, SEO metadata, sitemap
23
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-eziwiki",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Scaffold a new eziwiki documentation site",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,7 +2,8 @@ import { MarkdownContent } from '@/components/markdown/MarkdownContent';
2
2
  import { PageTransition } from '@/components/markdown/PageTransition';
3
3
  import { TableOfContents } from '@/components/layout/TableOfContents';
4
4
  import { Backlinks } from '@/components/layout/Backlinks';
5
- import { getBacklinks } from '@/lib/graph/build';
5
+ import { LocalGraph } from '@/components/layout/LocalGraph';
6
+ import { getBacklinks, getLocalGraph } from '@/lib/graph/build';
6
7
  import { renderDoc } from '@/lib/markdown/render';
7
8
  import { getDoc, type ContentDoc } from '@/lib/content/registry';
8
9
  import { docPathToUrl, urlToDocPath } from '@/lib/navigation/url';
@@ -149,6 +150,7 @@ export default async function ContentPage({ params }: PageProps) {
149
150
  <ArticleSchema doc={doc} url={resolved.url} />
150
151
  <MarkdownContent html={rendered.html} />
151
152
  <Backlinks links={getBacklinks(resolved.path)} />
153
+ <LocalGraph graph={getLocalGraph(resolved.path)} path={resolved.path} />
152
154
  </article>
153
155
 
154
156
  <aside className="hidden w-56 flex-shrink-0 xl:block">
@@ -9,15 +9,19 @@
9
9
  @import '../styles/markdown.css';
10
10
 
11
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.
12
+ * `@font-face` for Pretendard is declared in `app/layout.tsx`, not here: the
13
+ * font URLs need the deployment base path prefixed, which a stylesheet cannot
14
+ * read. See FONT_FACES there, which also splits each weight by `unicode-range`
15
+ * so a page fetches only the character sets it uses.
15
16
  */
16
17
 
17
18
  body {
19
+ /* SUITE used to sit between Pretendard and the system stack. Pretendard
20
+ covers Latin and Korean, so nothing ever reached it except the emoji in
21
+ section names — which it has no glyphs for either, and which the platform
22
+ emoji font renders. It was 274 kB nobody saw. */
18
23
  font-family:
19
24
  'Pretendard',
20
- 'SUITE',
21
25
  -apple-system,
22
26
  BlinkMacSystemFont,
23
27
  'Segoe UI',
@@ -74,6 +74,23 @@ export const metadata: Metadata = {
74
74
  : undefined,
75
75
  };
76
76
 
77
+ /**
78
+ * Character ranges each font file covers.
79
+ *
80
+ * Pretendard carries the full Korean syllabary, which is most of its weight:
81
+ * one weight is 750 kB whole, 27 kB once the Hangul is taken out. Splitting it
82
+ * and declaring what each half covers lets the browser fetch only the halves a
83
+ * page actually uses — an English page never asks for the Korean file, and a
84
+ * Korean one pays for it once and keeps it, since `public/` is served
85
+ * immutable.
86
+ */
87
+ const RANGES = {
88
+ latin:
89
+ 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F,' +
90
+ 'U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
91
+ korean: 'U+1100-11FF,U+3000-303F,U+3130-318F,U+A960-A97F,U+AC00-D7A3,U+D7B0-D7FF,U+FF00-FFEF',
92
+ } as const;
93
+
77
94
  /**
78
95
  * Web fonts, declared here rather than in `globals.css`.
79
96
  *
@@ -83,31 +100,34 @@ export const metadata: Metadata = {
83
100
  * the declarations from here puts them through `asset()` like every other file
84
101
  * in `public/`.
85
102
  *
86
- * `preload` marks the weights the first paint needs; the rest load on demand.
103
+ * Only the Latin halves are preloaded. Preloading the Korean ones would undo
104
+ * the split by fetching them before anything has asked for a Korean glyph.
87
105
  */
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' },
106
+ interface FontFace {
107
+ weight: number;
108
+ subset: keyof typeof RANGES;
109
+ preload?: boolean;
110
+ }
111
+
112
+ const FONT_FACES: FontFace[] = [
113
+ { weight: 400, subset: 'latin', preload: true },
114
+ { weight: 400, subset: 'korean' },
115
+ { weight: 600, subset: 'latin', preload: true },
116
+ { weight: 600, subset: 'korean' },
117
+ { weight: 700, subset: 'latin' },
118
+ { weight: 700, subset: 'korean' },
105
119
  ];
106
120
 
121
+ /** Where a weight-and-subset pair is served from. */
122
+ function fontFile({ weight, subset }: FontFace): string {
123
+ return `/fonts/Pretendard/pretendard-${weight}-${subset}.woff2`;
124
+ }
125
+
107
126
  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}`,
127
+ (font) =>
128
+ `@font-face{font-family:'Pretendard';font-weight:${font.weight};` +
129
+ `src:url('${asset(fontFile(font))}') format('woff2');font-display:swap;` +
130
+ `unicode-range:${RANGES[font.subset]}}`,
111
131
  ).join('');
112
132
 
113
133
  export default function RootLayout({ children }: { children: React.ReactNode }) {
@@ -119,9 +139,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
119
139
  <head>
120
140
  {FONT_FACES.filter((font) => font.preload).map((font) => (
121
141
  <link
122
- key={font.file}
142
+ key={fontFile(font)}
123
143
  rel="preload"
124
- href={asset(font.file)}
144
+ href={asset(fontFile(font))}
125
145
  as="font"
126
146
  type="font/woff2"
127
147
  crossOrigin="anonymous"
@@ -155,7 +175,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
155
175
  </a>
156
176
  <UrlMapProvider value={site.urlMap}>
157
177
  <TabInitializer navigation={site.navigation} />
158
- <PageLayout navigation={site.navigation}>{children}</PageLayout>
178
+ <PageLayout navigation={site.navigation} repoUrl={site.global.repoUrl}>
179
+ {children}
180
+ </PageLayout>
159
181
  <SearchDialog />
160
182
  </UrlMapProvider>
161
183
  </body>
@@ -3,8 +3,17 @@ import { payload } from '@/payload/config';
3
3
  import { fileUrl } from '@/lib/basePath';
4
4
 
5
5
  /**
6
- * Generate robots.txt file
7
- * Controls how search engines crawl and index the site
6
+ * Generates robots.txt.
7
+ *
8
+ * Nothing is disallowed. Search engines render a page before judging it, so
9
+ * blocking `/_next/` — where the stylesheet and scripts live — leaves the
10
+ * crawler looking at an unstyled document and reporting the assets as blocked
11
+ * resources. There is no `/api/` either: the site is a static export.
12
+ *
13
+ * Pages that should stay out of the index carry `noindex` in their own
14
+ * metadata, which is the mechanism that actually keeps them out. A path
15
+ * disallowed here would still be indexable if something linked to it, since a
16
+ * crawler that is told not to fetch a page cannot read the `noindex` on it.
8
17
  *
9
18
  * @returns Robots configuration
10
19
  */
@@ -13,7 +22,6 @@ export default function robots(): MetadataRoute.Robots {
13
22
  rules: {
14
23
  userAgent: '*',
15
24
  allow: '/',
16
- disallow: ['/api/', '/_next/'],
17
25
  },
18
26
  sitemap: fileUrl('/sitemap.xml', payload.global.baseUrl),
19
27
  };
@@ -24,6 +24,10 @@ export interface GraphViewNode {
24
24
  interface GraphViewProps {
25
25
  nodes: GraphViewNode[];
26
26
  edges: LayoutEdge[];
27
+ /** Page to mark as the one being read, when the graph is centred on one */
28
+ activePath?: string;
29
+ /** Height utility class; the default suits a full page of its own */
30
+ heightClass?: string;
27
31
  }
28
32
 
29
33
  /** Nominal layout area; the SVG viewBox scales the result to fit. */
@@ -33,7 +37,7 @@ const AREA = { width: 900, height: 640 };
33
37
  const MIN_RADIUS = 5;
34
38
  const MAX_RADIUS = 14;
35
39
 
36
- export function GraphView({ nodes, edges }: GraphViewProps) {
40
+ export function GraphView({ nodes, edges, activePath, heightClass = 'h-[70vh]' }: GraphViewProps) {
37
41
  const router = useRouter();
38
42
  const [hovered, setHovered] = useState<string | null>(null);
39
43
 
@@ -75,7 +79,7 @@ export function GraphView({ nodes, edges }: GraphViewProps) {
75
79
  <div className="overflow-hidden rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-900">
76
80
  <svg
77
81
  viewBox={`${box.x} ${box.y} ${box.width} ${box.height}`}
78
- className="h-[70vh] w-full"
82
+ className={`${heightClass} w-full`}
79
83
  role="img"
80
84
  aria-label={`Link graph of ${nodes.length} pages and ${edges.length} links`}
81
85
  >
@@ -110,6 +114,7 @@ export function GraphView({ nodes, edges }: GraphViewProps) {
110
114
  const radius =
111
115
  MIN_RADIUS + (MAX_RADIUS - MIN_RADIUS) * Math.sqrt(node.degree / maxDegree);
112
116
  const active = !connected || connected.has(node.path);
117
+ const isCurrent = node.path === activePath;
113
118
 
114
119
  return (
115
120
  <g
@@ -131,18 +136,26 @@ export function GraphView({ nodes, edges }: GraphViewProps) {
131
136
  aria-label={node.title}
132
137
  >
133
138
  <circle
134
- r={radius}
139
+ r={isCurrent ? radius + 2 : radius}
135
140
  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'
141
+ isCurrent
142
+ ? // The page being read is filled solid rather than tinted,
143
+ // so it is findable in its own neighbourhood at a glance.
144
+ 'fill-blue-600 stroke-white dark:fill-blue-400 dark:stroke-gray-900'
145
+ : hovered === node.path
146
+ ? 'fill-blue-500 stroke-white dark:stroke-gray-900'
147
+ : 'fill-blue-400/80 stroke-white dark:fill-blue-500/70 dark:stroke-gray-900'
139
148
  }
140
149
  strokeWidth={1.5}
141
150
  />
142
151
  <text
143
152
  y={radius + 12}
144
153
  textAnchor="middle"
145
- className="pointer-events-none fill-gray-700 text-[11px] dark:fill-gray-300"
154
+ className={`pointer-events-none text-[11px] ${
155
+ isCurrent
156
+ ? 'fill-gray-900 font-semibold dark:fill-gray-100'
157
+ : 'fill-gray-700 dark:fill-gray-300'
158
+ }`}
146
159
  >
147
160
  {node.title}
148
161
  </text>
@@ -0,0 +1,52 @@
1
+ import { Share2 } from 'lucide-react';
2
+ import { GraphView } from '@/components/graph/GraphView';
3
+ import type { LocalGraph as LocalGraphData } from '@/lib/graph/build';
4
+
5
+ /**
6
+ * Shows the pages immediately around the one being read.
7
+ *
8
+ * The whole-site graph on its own page answers what the wiki looks like. This
9
+ * answers what is next to *here*, which is the question a reader has while
10
+ * reading, and which the full graph stops answering once there are more than a
11
+ * few dozen pages to draw.
12
+ *
13
+ * It sits below the backlinks list and covers the same ground from the other
14
+ * side: backlinks name the pages that point here, the graph shows those and the
15
+ * ones this page points at, and how they relate to each other.
16
+ *
17
+ * Computed at build time, so this is a plain server component; only the SVG
18
+ * beneath it is interactive.
19
+ *
20
+ * @param props - Component props
21
+ * @param props.graph - Neighbourhood from `getLocalGraph()`
22
+ * @param props.path - Content path of the page at the centre
23
+ */
24
+ export function LocalGraph({ graph, path }: { graph: LocalGraphData; path: string }) {
25
+ // A page with nothing linking either way has no neighbourhood to draw, and an
26
+ // empty box would only be a question the reader cannot answer.
27
+ if (graph.nodes.length < 2) return null;
28
+
29
+ const neighbours = graph.nodes.length - 1;
30
+
31
+ return (
32
+ <section
33
+ aria-labelledby="local-graph-heading"
34
+ className="mt-10 border-t border-gray-200 pt-6 dark:border-gray-800"
35
+ >
36
+ <h2
37
+ id="local-graph-heading"
38
+ className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
39
+ >
40
+ <Share2 className="h-3.5 w-3.5" />
41
+ Connected to {neighbours} {neighbours === 1 ? 'page' : 'pages'}
42
+ </h2>
43
+
44
+ <GraphView
45
+ nodes={graph.nodes}
46
+ edges={graph.edges}
47
+ activePath={path}
48
+ heightClass="h-64 sm:h-72"
49
+ />
50
+ </section>
51
+ );
52
+ }
@@ -7,6 +7,7 @@ import { NavigationItem } from '@/lib/payload/types';
7
7
  import { useTabStore } from '@/lib/store/tabStore';
8
8
  import { useUrlMap } from '@/components/providers/UrlMapProvider';
9
9
  import { filterHiddenItems } from '@/lib/navigation/builder';
10
+ import { Github } from 'lucide-react';
10
11
 
11
12
  /**
12
13
  * Props for the MobileMenu component
@@ -18,6 +19,8 @@ interface MobileMenuProps {
18
19
  isOpen: boolean;
19
20
  /** Callback function to close the menu */
20
21
  onClose: () => void;
22
+ /** Source repository, linked from the drawer header when configured */
23
+ repoUrl?: string;
21
24
  }
22
25
 
23
26
  /**
@@ -221,7 +224,7 @@ function MobileNavigationItem({
221
224
  * @param props.onClose - Callback function to close the menu
222
225
  *
223
226
  */
224
- export function MobileMenu({ navigation, isOpen, onClose }: MobileMenuProps) {
227
+ export function MobileMenu({ navigation, isOpen, onClose, repoUrl }: MobileMenuProps) {
225
228
  const pathname = usePathname();
226
229
  const { toPath } = useUrlMap();
227
230
 
@@ -267,6 +270,18 @@ export function MobileMenu({ navigation, isOpen, onClose }: MobileMenuProps) {
267
270
  <nav>
268
271
  <div className="flex items-center justify-between mb-1">
269
272
  <div className="flex-1" />
273
+ {repoUrl && (
274
+ <a
275
+ href={repoUrl}
276
+ target="_blank"
277
+ rel="noopener noreferrer"
278
+ aria-label="Source repository"
279
+ title="Source repository"
280
+ className="rounded-md p-2 text-gray-500 transition-colors hover:text-gray-700 active:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-200 dark:active:bg-gray-800"
281
+ >
282
+ <Github className="h-5 w-5" />
283
+ </a>
284
+ )}
270
285
  <button
271
286
  onClick={onClose}
272
287
  className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 active:bg-gray-100 dark:active:bg-gray-800 rounded-md transition-colors touch-manipulation"
@@ -15,6 +15,8 @@ import { SearchTrigger } from '@/components/search/SearchTrigger';
15
15
  interface PageLayoutProps {
16
16
  /** Array of top-level navigation items */
17
17
  navigation: NavigationItem[];
18
+ /** Source repository, linked from the sidebar when configured */
19
+ repoUrl?: string;
18
20
  /** Page content to render in the main area */
19
21
  children: React.ReactNode;
20
22
  }
@@ -25,10 +27,11 @@ interface PageLayoutProps {
25
27
  *
26
28
  * @param props - Component props
27
29
  * @param props.navigation - Array of navigation items to display in sidebar/menu
30
+ * @param props.repoUrl - Source repository, linked from the sidebar when set
28
31
  * @param props.children - Page content to render in the main content area
29
32
  *
30
33
  */
31
- export function PageLayout({ navigation, children }: PageLayoutProps) {
34
+ export function PageLayout({ navigation, repoUrl, children }: PageLayoutProps) {
32
35
  const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
33
36
 
34
37
  const toggleMobileMenu = () => {
@@ -63,10 +66,15 @@ export function PageLayout({ navigation, children }: PageLayoutProps) {
63
66
  </div>
64
67
  </header>
65
68
 
66
- <MobileMenu navigation={navigation} isOpen={isMobileMenuOpen} onClose={closeMobileMenu} />
69
+ <MobileMenu
70
+ navigation={navigation}
71
+ isOpen={isMobileMenuOpen}
72
+ onClose={closeMobileMenu}
73
+ repoUrl={repoUrl}
74
+ />
67
75
 
68
76
  <div className="flex">
69
- <Sidebar navigation={navigation} />
77
+ <Sidebar navigation={navigation} repoUrl={repoUrl} />
70
78
 
71
79
  <main id="main-content" tabIndex={-1} className="flex-1 min-w-0 flex flex-col">
72
80
  <div className="sticky top-0 z-20 bg-white dark:bg-gray-950">
@@ -3,7 +3,7 @@
3
3
  import React, { useState, useRef, useEffect } from 'react';
4
4
  import Link from 'next/link';
5
5
  import { useRouter } from 'next/navigation';
6
- import { ChevronRight, ChevronsLeft, ChevronsRight, Search, Share2 } from 'lucide-react';
6
+ import { ChevronRight, ChevronsLeft, ChevronsRight, Github, Search, Share2 } from 'lucide-react';
7
7
  import { NavigationItem } from '@/lib/payload/types';
8
8
  import { useTabStore } from '@/lib/store/tabStore';
9
9
  import { ThemeToggle } from '@/components/ThemeToggle';
@@ -18,6 +18,32 @@ import { filterHiddenItems } from '@/lib/navigation/builder';
18
18
  interface SidebarProps {
19
19
  /** Array of top-level navigation items */
20
20
  navigation: NavigationItem[];
21
+ /** Source repository, linked from the header when configured */
22
+ repoUrl?: string;
23
+ }
24
+
25
+ /**
26
+ * Links out to the site's source repository.
27
+ *
28
+ * Rendered only when the payload names one, so a wiki with no public source
29
+ * does not show a dead control. A published site otherwise gives a reader no
30
+ * way to reach the project it came from.
31
+ */
32
+ function RepoLink({ href, collapsed }: { href: string; collapsed: boolean }) {
33
+ return (
34
+ <a
35
+ href={href}
36
+ target="_blank"
37
+ rel="noopener noreferrer"
38
+ aria-label="Source repository"
39
+ title="Source repository"
40
+ className={`rounded-md p-2 text-gray-600 transition-colors hover:bg-gray-200 dark:text-gray-400 dark:hover:bg-gray-800 ${
41
+ collapsed ? '' : 'flex-shrink-0'
42
+ }`}
43
+ >
44
+ <Github className="h-4 w-4" />
45
+ </a>
46
+ );
21
47
  }
22
48
 
23
49
  /**
@@ -235,7 +261,7 @@ function NavigationItemComponent({
235
261
  * @param props.navigation - Array of top-level navigation items to display
236
262
  *
237
263
  */
238
- export function Sidebar({ navigation }: SidebarProps) {
264
+ export function Sidebar({ navigation, repoUrl }: SidebarProps) {
239
265
  const { sidebarWidth, sidebarCollapsed, setSidebarWidth, setSidebarCollapsed } = useTabStore();
240
266
 
241
267
  const visibleNavigation = filterHiddenItems(navigation);
@@ -312,6 +338,7 @@ export function Sidebar({ navigation }: SidebarProps) {
312
338
  <>
313
339
  <SearchTrigger className="min-w-0 flex-1" />
314
340
  <ThemeToggle className="w-4 h-4" />
341
+ {repoUrl && <RepoLink href={repoUrl} collapsed={false} />}
315
342
  <button
316
343
  onClick={handleToggle}
317
344
  className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-800 rounded-md transition-colors flex-shrink-0"
@@ -331,6 +358,7 @@ export function Sidebar({ navigation }: SidebarProps) {
331
358
  >
332
359
  <Search className="h-4 w-4" />
333
360
  </button>
361
+ {repoUrl && <RepoLink href={repoUrl} collapsed />}
334
362
  <button
335
363
  onClick={handleToggle}
336
364
  className="rounded-md p-2 text-gray-600 transition-colors hover:bg-gray-200 dark:text-gray-400 dark:hover:bg-gray-800"
@@ -0,0 +1,164 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+
6
+ /**
7
+ * Shows where a wiki link goes before a reader commits to following it.
8
+ *
9
+ * The title and summary are already on the anchor as `data-preview-*`,
10
+ * written there during the build, so the card costs no request and appears
11
+ * immediately. A single delegated listener covers every link on the page,
12
+ * including any that arrive with a transcluded block.
13
+ *
14
+ * Rendered into `document.body` rather than in place. The card is positioned
15
+ * against the viewport, and `position: fixed` resolves against the nearest
16
+ * transformed ancestor instead — which the page-transition wrapper is, so a
17
+ * card rendered here landed roughly a screenful off the top of the window.
18
+ *
19
+ * @example
20
+ * ```tsx
21
+ * <article dangerouslySetInnerHTML={{ __html: html }} />
22
+ * <LinkPreview />
23
+ * ```
24
+ */
25
+
26
+ /** Milliseconds a pointer must rest on a link before the card appears. */
27
+ const OPEN_DELAY_MS = 350;
28
+
29
+ /** Milliseconds before a card closes, so a pointer may cross a gap. */
30
+ const CLOSE_DELAY_MS = 120;
31
+
32
+ /** Distance from the link to the card. */
33
+ const OFFSET_PX = 8;
34
+
35
+ /** Card width, needed here to keep it on screen. */
36
+ const WIDTH_PX = 320;
37
+
38
+ interface PreviewState {
39
+ title: string;
40
+ excerpt: string;
41
+ top: number;
42
+ left: number;
43
+ /** Whether the card sits below its link rather than above */
44
+ below: boolean;
45
+ }
46
+
47
+ export function LinkPreview() {
48
+ const [preview, setPreview] = useState<PreviewState | null>(null);
49
+ const openTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
50
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
51
+
52
+ useEffect(() => {
53
+ function clearTimers() {
54
+ if (openTimer.current) clearTimeout(openTimer.current);
55
+ if (closeTimer.current) clearTimeout(closeTimer.current);
56
+ }
57
+
58
+ function place(anchor: HTMLElement): PreviewState | null {
59
+ const title = anchor.getAttribute('data-preview-title');
60
+ if (!title) return null;
61
+
62
+ const rect = anchor.getBoundingClientRect();
63
+
64
+ // Above the link by default, below it when there is no room above —
65
+ // a card that opens off the top of the viewport shows nothing.
66
+ const below = rect.top < 160;
67
+
68
+ return {
69
+ title,
70
+ excerpt: anchor.getAttribute('data-preview') ?? '',
71
+ top: below ? rect.bottom + OFFSET_PX : rect.top - OFFSET_PX,
72
+ left: Math.max(8, Math.min(rect.left, document.documentElement.clientWidth - WIDTH_PX - 8)),
73
+ below,
74
+ };
75
+ }
76
+
77
+ function open(anchor: HTMLElement, delay: number) {
78
+ clearTimers();
79
+ openTimer.current = setTimeout(() => {
80
+ const next = place(anchor);
81
+ if (next) setPreview(next);
82
+ }, delay);
83
+ }
84
+
85
+ function close(delay = CLOSE_DELAY_MS) {
86
+ clearTimers();
87
+ closeTimer.current = setTimeout(() => setPreview(null), delay);
88
+ }
89
+
90
+ function anchorFrom(target: EventTarget | null): HTMLElement | null {
91
+ return (target as HTMLElement | null)?.closest?.<HTMLElement>('a.ezw-wikilink') ?? null;
92
+ }
93
+
94
+ function handleOver(event: MouseEvent) {
95
+ const anchor = anchorFrom(event.target);
96
+ if (anchor) open(anchor, OPEN_DELAY_MS);
97
+ }
98
+
99
+ function handleOut(event: MouseEvent) {
100
+ if (anchorFrom(event.target)) close();
101
+ }
102
+
103
+ // Keyboard users reach the link by tabbing, and get the same card without
104
+ // the delay a pointer needs to signal intent.
105
+ function handleFocus(event: FocusEvent) {
106
+ const anchor = anchorFrom(event.target);
107
+ if (anchor) open(anchor, 0);
108
+ }
109
+
110
+ function handleBlur(event: FocusEvent) {
111
+ if (anchorFrom(event.target)) close(0);
112
+ }
113
+
114
+ // Dismissible without moving the pointer, which WCAG asks of anything that
115
+ // appears on hover.
116
+ function handleKey(event: KeyboardEvent) {
117
+ if (event.key === 'Escape') close(0);
118
+ }
119
+
120
+ document.addEventListener('mouseover', handleOver);
121
+ document.addEventListener('mouseout', handleOut);
122
+ document.addEventListener('focusin', handleFocus);
123
+ document.addEventListener('focusout', handleBlur);
124
+ // Any movement of the page leaves the card pointing at nothing. Named so
125
+ // that the cleanup below can actually remove it.
126
+ function handleScroll() {
127
+ close(0);
128
+ }
129
+
130
+ document.addEventListener('keydown', handleKey);
131
+ window.addEventListener('scroll', handleScroll, { passive: true });
132
+
133
+ return () => {
134
+ clearTimers();
135
+ document.removeEventListener('mouseover', handleOver);
136
+ document.removeEventListener('mouseout', handleOut);
137
+ document.removeEventListener('focusin', handleFocus);
138
+ document.removeEventListener('focusout', handleBlur);
139
+ document.removeEventListener('keydown', handleKey);
140
+ window.removeEventListener('scroll', handleScroll);
141
+ };
142
+ }, []);
143
+
144
+ if (!preview || typeof document === 'undefined') return null;
145
+
146
+ return createPortal(
147
+ <div
148
+ // Presentational: the link it describes is already in the accessible
149
+ // tree, and announcing the summary twice would be noise.
150
+ aria-hidden="true"
151
+ className="ezw-link-preview"
152
+ style={{
153
+ top: preview.top,
154
+ left: preview.left,
155
+ width: WIDTH_PX,
156
+ transform: preview.below ? undefined : 'translateY(-100%)',
157
+ }}
158
+ >
159
+ <p className="ezw-link-preview__title">{preview.title}</p>
160
+ {preview.excerpt && <p className="ezw-link-preview__excerpt">{preview.excerpt}</p>}
161
+ </div>,
162
+ document.body,
163
+ );
164
+ }
@@ -1,4 +1,5 @@
1
1
  import { CodeCopy } from './CodeCopy';
2
+ import { LinkPreview } from './LinkPreview';
2
3
 
3
4
  /**
4
5
  * Props for the MarkdownContent component
@@ -13,7 +14,7 @@ interface MarkdownContentProps {
13
14
  *
14
15
  * The markup arrives already parsed, highlighted, and link-resolved from
15
16
  * `renderDoc()`, so this is a server component that emits static HTML. The only
16
- * client-side code is the small copy-button listener.
17
+ * client-side code is the copy-button and link-preview listeners.
17
18
  *
18
19
  * Passing build-time output to `dangerouslySetInnerHTML` is safe here in the
19
20
  * sense that matters: the input is the repository's own content files, not user
@@ -33,6 +34,7 @@ export function MarkdownContent({ html }: MarkdownContentProps) {
33
34
  <>
34
35
  <div className="ezw-prose" dangerouslySetInnerHTML={{ __html: html }} />
35
36
  <CodeCopy />
37
+ <LinkPreview />
36
38
  </>
37
39
  );
38
40
  }