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,286 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import { FileText, Hash, Loader2, Search as SearchIcon } from 'lucide-react';
|
|
6
|
+
import { useSearchStore } from '@/lib/store/searchStore';
|
|
7
|
+
import { search, type SearchResult } from '@/lib/search/client';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Full-text search dialog, opened with ⌘K or from the sidebar.
|
|
11
|
+
*
|
|
12
|
+
* The index is fetched the first time the dialog opens, so search costs nothing
|
|
13
|
+
* until it is used. All matching happens in the browser against a static JSON
|
|
14
|
+
* file, which keeps it working on any static host.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Debounce applied to keystrokes before querying. */
|
|
18
|
+
const DEBOUNCE_MS = 120;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Renders text with the query terms highlighted.
|
|
22
|
+
*
|
|
23
|
+
* Terms are matched case-insensitively and escaped before being put into a
|
|
24
|
+
* pattern, so a query containing regex metacharacters cannot break the match.
|
|
25
|
+
*/
|
|
26
|
+
function Highlighted({ text, query }: { text: string; query: string }) {
|
|
27
|
+
const terms = query
|
|
28
|
+
.trim()
|
|
29
|
+
.split(/\s+/)
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
32
|
+
|
|
33
|
+
if (terms.length === 0) return <>{text}</>;
|
|
34
|
+
|
|
35
|
+
const parts = text.split(new RegExp(`(${terms.join('|')})`, 'gi'));
|
|
36
|
+
const lowered = terms.map((term) => term.toLowerCase());
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<>
|
|
40
|
+
{parts.map((part, index) =>
|
|
41
|
+
lowered.includes(part.toLowerCase()) ? (
|
|
42
|
+
<mark
|
|
43
|
+
key={index}
|
|
44
|
+
className="bg-yellow-200 dark:bg-yellow-500/30 text-inherit rounded-sm px-0.5"
|
|
45
|
+
>
|
|
46
|
+
{part}
|
|
47
|
+
</mark>
|
|
48
|
+
) : (
|
|
49
|
+
<React.Fragment key={index}>{part}</React.Fragment>
|
|
50
|
+
),
|
|
51
|
+
)}
|
|
52
|
+
</>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function SearchDialog() {
|
|
57
|
+
const router = useRouter();
|
|
58
|
+
const { isOpen, close, toggle } = useSearchStore();
|
|
59
|
+
|
|
60
|
+
const [query, setQuery] = useState('');
|
|
61
|
+
const [results, setResults] = useState<SearchResult[]>([]);
|
|
62
|
+
const [selected, setSelected] = useState(0);
|
|
63
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
64
|
+
const [error, setError] = useState<string | null>(null);
|
|
65
|
+
|
|
66
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
67
|
+
const listRef = useRef<HTMLUListElement>(null);
|
|
68
|
+
|
|
69
|
+
// Global shortcut. Bound on the document so it works regardless of focus.
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const handler = (event: KeyboardEvent) => {
|
|
72
|
+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
|
|
73
|
+
event.preventDefault();
|
|
74
|
+
toggle();
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
document.addEventListener('keydown', handler);
|
|
79
|
+
return () => document.removeEventListener('keydown', handler);
|
|
80
|
+
}, [toggle]);
|
|
81
|
+
|
|
82
|
+
// Reset and focus each time the dialog opens.
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!isOpen) return;
|
|
85
|
+
|
|
86
|
+
setQuery('');
|
|
87
|
+
setResults([]);
|
|
88
|
+
setSelected(0);
|
|
89
|
+
setError(null);
|
|
90
|
+
|
|
91
|
+
// Focus after paint, or the input is not yet mounted.
|
|
92
|
+
const raf = requestAnimationFrame(() => inputRef.current?.focus());
|
|
93
|
+
return () => cancelAnimationFrame(raf);
|
|
94
|
+
}, [isOpen]);
|
|
95
|
+
|
|
96
|
+
// Prevent the page behind the dialog from scrolling.
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (!isOpen) return;
|
|
99
|
+
|
|
100
|
+
const previous = document.body.style.overflow;
|
|
101
|
+
document.body.style.overflow = 'hidden';
|
|
102
|
+
|
|
103
|
+
return () => {
|
|
104
|
+
document.body.style.overflow = previous;
|
|
105
|
+
};
|
|
106
|
+
}, [isOpen]);
|
|
107
|
+
|
|
108
|
+
// Run the query, debounced, discarding responses that arrive out of order.
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (!isOpen) return;
|
|
111
|
+
|
|
112
|
+
const trimmed = query.trim();
|
|
113
|
+
|
|
114
|
+
if (!trimmed) {
|
|
115
|
+
setResults([]);
|
|
116
|
+
setIsLoading(false);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let cancelled = false;
|
|
121
|
+
setIsLoading(true);
|
|
122
|
+
|
|
123
|
+
const timer = setTimeout(async () => {
|
|
124
|
+
try {
|
|
125
|
+
const found = await search(trimmed);
|
|
126
|
+
if (cancelled) return;
|
|
127
|
+
|
|
128
|
+
setResults(found);
|
|
129
|
+
setSelected(0);
|
|
130
|
+
setError(null);
|
|
131
|
+
} catch {
|
|
132
|
+
if (cancelled) return;
|
|
133
|
+
setError('Search is unavailable. Try reloading the page.');
|
|
134
|
+
setResults([]);
|
|
135
|
+
} finally {
|
|
136
|
+
if (!cancelled) setIsLoading(false);
|
|
137
|
+
}
|
|
138
|
+
}, DEBOUNCE_MS);
|
|
139
|
+
|
|
140
|
+
return () => {
|
|
141
|
+
cancelled = true;
|
|
142
|
+
clearTimeout(timer);
|
|
143
|
+
};
|
|
144
|
+
}, [query, isOpen]);
|
|
145
|
+
|
|
146
|
+
const go = useCallback(
|
|
147
|
+
(result: SearchResult) => {
|
|
148
|
+
close();
|
|
149
|
+
router.push(result.url);
|
|
150
|
+
},
|
|
151
|
+
[close, router],
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const handleKeyDown = (event: React.KeyboardEvent) => {
|
|
155
|
+
if (event.key === 'Escape') {
|
|
156
|
+
event.preventDefault();
|
|
157
|
+
close();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
162
|
+
event.preventDefault();
|
|
163
|
+
if (results.length === 0) return;
|
|
164
|
+
|
|
165
|
+
const delta = event.key === 'ArrowDown' ? 1 : -1;
|
|
166
|
+
const next = (selected + delta + results.length) % results.length;
|
|
167
|
+
|
|
168
|
+
setSelected(next);
|
|
169
|
+
listRef.current?.children[next]?.scrollIntoView({ block: 'nearest' });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (event.key === 'Enter' && results[selected]) {
|
|
174
|
+
event.preventDefault();
|
|
175
|
+
go(results[selected]);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
if (!isOpen) return null;
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div
|
|
183
|
+
className="fixed inset-0 z-50 flex items-start justify-center px-4 pt-[10vh]"
|
|
184
|
+
role="presentation"
|
|
185
|
+
onMouseDown={close}
|
|
186
|
+
>
|
|
187
|
+
<div className="fixed inset-0 bg-black/40 backdrop-blur-[2px]" aria-hidden="true" />
|
|
188
|
+
|
|
189
|
+
<div
|
|
190
|
+
role="dialog"
|
|
191
|
+
aria-modal="true"
|
|
192
|
+
aria-label="Search documentation"
|
|
193
|
+
className="relative w-full max-w-2xl overflow-hidden rounded-xl bg-white shadow-2xl ring-1 ring-black/10 dark:bg-gray-900 dark:ring-white/10"
|
|
194
|
+
onMouseDown={(event) => event.stopPropagation()}
|
|
195
|
+
onKeyDown={handleKeyDown}
|
|
196
|
+
>
|
|
197
|
+
<div className="flex items-center gap-3 border-b border-gray-200 px-4 dark:border-gray-800">
|
|
198
|
+
<SearchIcon className="h-4 w-4 flex-shrink-0 text-gray-400" />
|
|
199
|
+
<input
|
|
200
|
+
ref={inputRef}
|
|
201
|
+
type="text"
|
|
202
|
+
value={query}
|
|
203
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
204
|
+
placeholder="Search documentation…"
|
|
205
|
+
aria-label="Search query"
|
|
206
|
+
aria-controls="search-results"
|
|
207
|
+
className="flex-1 bg-transparent py-3.5 text-sm text-gray-900 outline-none placeholder:text-gray-400 dark:text-gray-100"
|
|
208
|
+
/>
|
|
209
|
+
{isLoading && <Loader2 className="h-4 w-4 flex-shrink-0 animate-spin text-gray-400" />}
|
|
210
|
+
<kbd className="hidden flex-shrink-0 rounded border border-gray-300 px-1.5 py-0.5 text-[10px] text-gray-500 sm:block dark:border-gray-700 dark:text-gray-400">
|
|
211
|
+
ESC
|
|
212
|
+
</kbd>
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
<div className="max-h-[60vh] overflow-y-auto">
|
|
216
|
+
{error && <p className="px-4 py-8 text-center text-sm text-red-600">{error}</p>}
|
|
217
|
+
|
|
218
|
+
{!error && query.trim() && !isLoading && results.length === 0 && (
|
|
219
|
+
<p className="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
|
220
|
+
No results for “{query.trim()}”
|
|
221
|
+
</p>
|
|
222
|
+
)}
|
|
223
|
+
|
|
224
|
+
{!error && !query.trim() && (
|
|
225
|
+
<p className="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
|
226
|
+
Search titles, headings, and page contents.
|
|
227
|
+
</p>
|
|
228
|
+
)}
|
|
229
|
+
|
|
230
|
+
{results.length > 0 && (
|
|
231
|
+
<ul ref={listRef} id="search-results" role="listbox" className="py-2">
|
|
232
|
+
{results.map((result, index) => (
|
|
233
|
+
<li key={result.id} role="option" aria-selected={index === selected}>
|
|
234
|
+
<button
|
|
235
|
+
type="button"
|
|
236
|
+
onClick={() => go(result)}
|
|
237
|
+
onMouseEnter={() => setSelected(index)}
|
|
238
|
+
className={`flex w-full items-start gap-3 px-4 py-2.5 text-left transition-colors ${
|
|
239
|
+
index === selected ? 'bg-blue-50 dark:bg-blue-950/40' : ''
|
|
240
|
+
}`}
|
|
241
|
+
>
|
|
242
|
+
{result.section ? (
|
|
243
|
+
<Hash className="mt-0.5 h-4 w-4 flex-shrink-0 text-gray-400" />
|
|
244
|
+
) : (
|
|
245
|
+
<FileText className="mt-0.5 h-4 w-4 flex-shrink-0 text-gray-400" />
|
|
246
|
+
)}
|
|
247
|
+
|
|
248
|
+
<span className="min-w-0 flex-1">
|
|
249
|
+
<span className="block truncate text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
250
|
+
<Highlighted text={result.section ?? result.title} query={query} />
|
|
251
|
+
</span>
|
|
252
|
+
|
|
253
|
+
{result.section && (
|
|
254
|
+
<span className="block truncate text-xs text-gray-500 dark:text-gray-400">
|
|
255
|
+
{result.title}
|
|
256
|
+
</span>
|
|
257
|
+
)}
|
|
258
|
+
|
|
259
|
+
{result.excerpt && (
|
|
260
|
+
<span className="mt-0.5 block line-clamp-2 text-xs text-gray-600 dark:text-gray-400">
|
|
261
|
+
<Highlighted text={result.excerpt} query={query} />
|
|
262
|
+
</span>
|
|
263
|
+
)}
|
|
264
|
+
</span>
|
|
265
|
+
</button>
|
|
266
|
+
</li>
|
|
267
|
+
))}
|
|
268
|
+
</ul>
|
|
269
|
+
)}
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
<div className="hidden items-center gap-4 border-t border-gray-200 px-4 py-2 text-[11px] text-gray-500 sm:flex dark:border-gray-800 dark:text-gray-400">
|
|
273
|
+
<span>
|
|
274
|
+
<kbd className="font-sans">↑↓</kbd> to navigate
|
|
275
|
+
</span>
|
|
276
|
+
<span>
|
|
277
|
+
<kbd className="font-sans">↵</kbd> to select
|
|
278
|
+
</span>
|
|
279
|
+
<span>
|
|
280
|
+
<kbd className="font-sans">esc</kbd> to close
|
|
281
|
+
</span>
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { Search } from 'lucide-react';
|
|
5
|
+
import { useSearchStore } from '@/lib/store/searchStore';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Opens the search dialog.
|
|
9
|
+
*
|
|
10
|
+
* Rendered as a fake input rather than a button-looking control because that is
|
|
11
|
+
* what readers expect to click to search; the real input lives in the dialog.
|
|
12
|
+
*/
|
|
13
|
+
export function SearchTrigger({ className = '' }: { className?: string }) {
|
|
14
|
+
const open = useSearchStore((state) => state.open);
|
|
15
|
+
const [shortcut, setShortcut] = useState<string | null>(null);
|
|
16
|
+
|
|
17
|
+
// The modifier differs by platform, and the platform is only known in the
|
|
18
|
+
// browser — rendering a guess on the server would hydrate mismatched.
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
|
21
|
+
setShortcut(isMac ? '⌘K' : 'Ctrl K');
|
|
22
|
+
}, []);
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<button
|
|
26
|
+
type="button"
|
|
27
|
+
onClick={open}
|
|
28
|
+
aria-label="Search documentation"
|
|
29
|
+
aria-keyshortcuts="Meta+K Control+K"
|
|
30
|
+
className={`flex items-center gap-2 rounded-md border border-gray-300 bg-white px-2 py-1 text-sm text-gray-400 transition-colors hover:border-gray-400 dark:border-gray-700 dark:bg-gray-800 dark:hover:border-gray-600 ${className}`}
|
|
31
|
+
>
|
|
32
|
+
<Search className="h-4 w-4 flex-shrink-0" />
|
|
33
|
+
<span className="min-w-0 flex-1 truncate text-left">Search…</span>
|
|
34
|
+
{shortcut && (
|
|
35
|
+
<kbd className="hidden flex-shrink-0 rounded border border-gray-300 px-1 py-0.5 text-[10px] text-gray-500 sm:block dark:border-gray-600 dark:text-gray-400">
|
|
36
|
+
{shortcut}
|
|
37
|
+
</kbd>
|
|
38
|
+
)}
|
|
39
|
+
</button>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Writing Pages
|
|
3
|
+
description: Frontmatter, links, code, and everything else a page can contain
|
|
4
|
+
order: 1
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Writing Pages
|
|
8
|
+
|
|
9
|
+
## Frontmatter
|
|
10
|
+
|
|
11
|
+
Every page can start with a frontmatter block. All of it is optional.
|
|
12
|
+
|
|
13
|
+
```markdown
|
|
14
|
+
---
|
|
15
|
+
title: Writing Pages # Sidebar label; defaults to the file name
|
|
16
|
+
description: What this page covers # Used for SEO and search results
|
|
17
|
+
order: 1 # Sort weight within its folder
|
|
18
|
+
hidden: false # true keeps it out of the sidebar and search
|
|
19
|
+
---
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Organising pages
|
|
23
|
+
|
|
24
|
+
Folders become sidebar sections. To name or order one, add a `_meta.json`
|
|
25
|
+
beside its pages:
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{ "name": "📖 Guides", "order": 1, "color": "#dbeafe" }
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Files and folders starting with `_` or `.` are skipped, so drafts can live in
|
|
32
|
+
`content/_drafts/` without being published.
|
|
33
|
+
|
|
34
|
+
## Linking
|
|
35
|
+
|
|
36
|
+
Ordinary Markdown links work with content paths:
|
|
37
|
+
|
|
38
|
+
```markdown
|
|
39
|
+
[Welcome](/intro)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Wiki links resolve by full path, file name, or page title — so you can link to
|
|
43
|
+
a page without knowing where it sits:
|
|
44
|
+
|
|
45
|
+
```markdown
|
|
46
|
+
[[intro]] [[Writing Pages]] [[guides/writing|see the guide]] [[intro#next]]
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
A link that resolves to nothing is shown as visibly broken rather than as a
|
|
50
|
+
dead link. Run `npm run check:links` to list them all.
|
|
51
|
+
|
|
52
|
+
## Code
|
|
53
|
+
|
|
54
|
+
Fenced blocks are highlighted at build time and get a copy button:
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
export function greet(name: string): string {
|
|
58
|
+
return `Hello, ${name}!`;
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Maths
|
|
63
|
+
|
|
64
|
+
Inline $E = mc^2$ and display maths both render:
|
|
65
|
+
|
|
66
|
+
$$
|
|
67
|
+
\int_{-\infty}^{\infty} e^{-x^2}\,dx = \sqrt{\pi}
|
|
68
|
+
$$
|
|
69
|
+
|
|
70
|
+
## Tables and task lists
|
|
71
|
+
|
|
72
|
+
| Feature | Included |
|
|
73
|
+
| ------- | -------- |
|
|
74
|
+
| Search | Yes |
|
|
75
|
+
| Graph | Yes |
|
|
76
|
+
|
|
77
|
+
- [x] Write a page
|
|
78
|
+
- [ ] Publish it
|
|
79
|
+
|
|
80
|
+
## Back
|
|
81
|
+
|
|
82
|
+
Return to [[intro]].
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Welcome
|
|
3
|
+
description: The starting point of your new wiki
|
|
4
|
+
order: 1
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Welcome 👋
|
|
8
|
+
|
|
9
|
+
This is your new wiki. Every Markdown file under `content/` becomes a page —
|
|
10
|
+
there is nothing to register anywhere.
|
|
11
|
+
|
|
12
|
+
## Getting started
|
|
13
|
+
|
|
14
|
+
1. Edit this file, or drop a new `.md` file into `content/`.
|
|
15
|
+
2. Change the site title in `payload/config.ts`.
|
|
16
|
+
3. Run `npm run dev` and open <http://localhost:3000>.
|
|
17
|
+
|
|
18
|
+
## What you get
|
|
19
|
+
|
|
20
|
+
- **Search** — press <kbd>⌘K</kbd> (or <kbd>Ctrl K</kbd>) to search titles, headings, and text
|
|
21
|
+
- **Contents rail** — built automatically from each page's headings
|
|
22
|
+
- **Wiki links** — write [[guides/writing]] and it resolves by path, file name, or title
|
|
23
|
+
- **Backlinks** — every page lists what links to it, at the bottom
|
|
24
|
+
- **Graph** — see how your pages connect on the [Graph](/graph) page
|
|
25
|
+
- **Dark mode**, syntax highlighting, and maths, with no configuration
|
|
26
|
+
|
|
27
|
+
## Next
|
|
28
|
+
|
|
29
|
+
Read [[writing]] to see what you can put in a page.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
parser: '@typescript-eslint/parser',
|
|
3
|
+
extends: [
|
|
4
|
+
'next/core-web-vitals',
|
|
5
|
+
'plugin:@typescript-eslint/recommended',
|
|
6
|
+
'plugin:prettier/recommended',
|
|
7
|
+
'prettier',
|
|
8
|
+
],
|
|
9
|
+
plugins: ['@typescript-eslint', 'prettier'],
|
|
10
|
+
parserOptions: {
|
|
11
|
+
ecmaVersion: 2020,
|
|
12
|
+
sourceType: 'module',
|
|
13
|
+
ecmaFeatures: {
|
|
14
|
+
jsx: true,
|
|
15
|
+
},
|
|
16
|
+
project: './tsconfig.json',
|
|
17
|
+
},
|
|
18
|
+
env: {
|
|
19
|
+
browser: true,
|
|
20
|
+
node: true,
|
|
21
|
+
es6: true,
|
|
22
|
+
},
|
|
23
|
+
rules: {
|
|
24
|
+
'no-underscore-dangle': 0,
|
|
25
|
+
'@typescript-eslint/no-namespace': 0,
|
|
26
|
+
'@typescript-eslint/explicit-function-return-type': 0,
|
|
27
|
+
'@typescript-eslint/no-use-before-define': 0,
|
|
28
|
+
'import/prefer-default-export': 0,
|
|
29
|
+
'import/extensions': 0,
|
|
30
|
+
'react/react-in-jsx-scope': 0,
|
|
31
|
+
quotes: [
|
|
32
|
+
2,
|
|
33
|
+
'single',
|
|
34
|
+
{
|
|
35
|
+
avoidEscape: true,
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
'@typescript-eslint/no-explicit-any': 'warn',
|
|
39
|
+
},
|
|
40
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Dependencies
|
|
2
|
+
node_modules/
|
|
3
|
+
/.pnp
|
|
4
|
+
.pnp.js
|
|
5
|
+
|
|
6
|
+
# Testing
|
|
7
|
+
/coverage
|
|
8
|
+
|
|
9
|
+
# Next.js
|
|
10
|
+
/.next/
|
|
11
|
+
/out/
|
|
12
|
+
|
|
13
|
+
# Production
|
|
14
|
+
/build
|
|
15
|
+
|
|
16
|
+
# Misc
|
|
17
|
+
.DS_Store
|
|
18
|
+
*.pem
|
|
19
|
+
|
|
20
|
+
# Debug
|
|
21
|
+
npm-debug.log*
|
|
22
|
+
yarn-debug.log*
|
|
23
|
+
yarn-error.log*
|
|
24
|
+
|
|
25
|
+
# Local env files
|
|
26
|
+
.env*.local
|
|
27
|
+
|
|
28
|
+
# Vercel
|
|
29
|
+
.vercel
|
|
30
|
+
|
|
31
|
+
# TypeScript
|
|
32
|
+
*.tsbuildinfo
|
|
33
|
+
next-env.d.ts
|
|
34
|
+
|
|
35
|
+
.kiro
|
|
36
|
+
# Generated search index
|
|
37
|
+
/public/search-index.json
|
|
38
|
+
|
|
39
|
+
# Generated CLI template
|
|
40
|
+
/packages/create-eziwiki/template/
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Loads the module with a given deployment environment.
|
|
5
|
+
*
|
|
6
|
+
* The base path and origin are read once at import time — they cannot change
|
|
7
|
+
* during a build — so exercising a different deployment means re-importing
|
|
8
|
+
* rather than reassigning.
|
|
9
|
+
*
|
|
10
|
+
* @param env - Values for the two deployment variables
|
|
11
|
+
* @returns The freshly imported module
|
|
12
|
+
*/
|
|
13
|
+
async function load(env: { basePath?: string; siteUrl?: string }) {
|
|
14
|
+
vi.resetModules();
|
|
15
|
+
process.env.NEXT_PUBLIC_BASE_PATH = env.basePath ?? '';
|
|
16
|
+
process.env.NEXT_PUBLIC_SITE_URL = env.siteUrl ?? '';
|
|
17
|
+
return import('./basePath');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
delete process.env.NEXT_PUBLIC_BASE_PATH;
|
|
22
|
+
delete process.env.NEXT_PUBLIC_SITE_URL;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('asset', () => {
|
|
26
|
+
it('leaves public paths alone when served from the root', async () => {
|
|
27
|
+
const { asset } = await load({});
|
|
28
|
+
expect(asset('/favicon.svg')).toBe('/favicon.svg');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('prefixes public paths with the base path', async () => {
|
|
32
|
+
const { asset } = await load({ basePath: '/eziwiki' });
|
|
33
|
+
expect(asset('/favicon.svg')).toBe('/eziwiki/favicon.svg');
|
|
34
|
+
expect(asset('/fonts/SUITE/SUITE-Regular.woff2')).toBe(
|
|
35
|
+
'/eziwiki/fonts/SUITE/SUITE-Regular.woff2',
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('leaves absolute and protocol-relative URLs untouched', async () => {
|
|
40
|
+
const { asset } = await load({ basePath: '/eziwiki' });
|
|
41
|
+
expect(asset('https://cdn.example.com/logo.svg')).toBe('https://cdn.example.com/logo.svg');
|
|
42
|
+
expect(asset('//cdn.example.com/logo.svg')).toBe('//cdn.example.com/logo.svg');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('pageUrl', () => {
|
|
47
|
+
it('composes origin, base path and a trailing slash', async () => {
|
|
48
|
+
const { pageUrl } = await load({ basePath: '/eziwiki', siteUrl: 'https://user.github.io' });
|
|
49
|
+
expect(pageUrl('getting-started/quick-start')).toBe(
|
|
50
|
+
'https://user.github.io/eziwiki/getting-started/quick-start/',
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns the home page for an empty segment', async () => {
|
|
55
|
+
const { pageUrl } = await load({ basePath: '/eziwiki', siteUrl: 'https://user.github.io' });
|
|
56
|
+
expect(pageUrl('')).toBe('https://user.github.io/eziwiki/');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('falls back to the payload base URL when the deployment declares none', async () => {
|
|
60
|
+
const { pageUrl } = await load({});
|
|
61
|
+
expect(pageUrl('intro', 'https://example.com')).toBe('https://example.com/intro/');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('lets the deployment origin override the payload base URL', async () => {
|
|
65
|
+
const { pageUrl } = await load({ siteUrl: 'https://user.github.io' });
|
|
66
|
+
expect(pageUrl('intro', 'https://placeholder.dev')).toBe('https://user.github.io/intro/');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('does not double up slashes however the parts are written', async () => {
|
|
70
|
+
const { pageUrl } = await load({ siteUrl: 'https://user.github.io/' });
|
|
71
|
+
expect(pageUrl('/intro/', 'https://placeholder.dev/')).toBe('https://user.github.io/intro/');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// The sitemap advertises these URLs and the canonical tag names them; if the
|
|
75
|
+
// two disagree on the slash, the sitemap points at a redirect.
|
|
76
|
+
it('agrees with the trailing-slash form Next serves', async () => {
|
|
77
|
+
const { pageUrl } = await load({ basePath: '/eziwiki', siteUrl: 'https://user.github.io' });
|
|
78
|
+
expect(pageUrl('intro')).toMatch(/\/$/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// A mirror is served from a subdirectory of its own host while the canonical
|
|
82
|
+
// site is elsewhere. Combining the two would name a path that exists on
|
|
83
|
+
// neither: 'https://production.app/eziwiki/intro/'.
|
|
84
|
+
it('omits the base path when canonicalising to another deployment', async () => {
|
|
85
|
+
const { pageUrl } = await load({ basePath: '/eziwiki' });
|
|
86
|
+
expect(pageUrl('intro', 'https://production.app')).toBe('https://production.app/intro/');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('includes the base path when the deployment is itself canonical', async () => {
|
|
90
|
+
const { pageUrl } = await load({ basePath: '/eziwiki', siteUrl: 'https://user.github.io' });
|
|
91
|
+
expect(pageUrl('intro', 'https://production.app')).toBe(
|
|
92
|
+
'https://user.github.io/eziwiki/intro/',
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('fileUrl', () => {
|
|
98
|
+
it('composes origin and base path without a trailing slash', async () => {
|
|
99
|
+
const { fileUrl } = await load({ basePath: '/eziwiki', siteUrl: 'https://user.github.io' });
|
|
100
|
+
expect(fileUrl('/sitemap.xml')).toBe('https://user.github.io/eziwiki/sitemap.xml');
|
|
101
|
+
expect(fileUrl('/og-image.svg')).toBe('https://user.github.io/eziwiki/og-image.svg');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('accepts a path written without a leading slash', async () => {
|
|
105
|
+
const { fileUrl } = await load({ siteUrl: 'https://user.github.io' });
|
|
106
|
+
expect(fileUrl('og-image.svg')).toBe('https://user.github.io/og-image.svg');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('leaves an already absolute image alone', async () => {
|
|
110
|
+
const { fileUrl } = await load({ basePath: '/eziwiki', siteUrl: 'https://user.github.io' });
|
|
111
|
+
expect(fileUrl('https://cdn.example.com/og.png')).toBe('https://cdn.example.com/og.png');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('omits the base path when canonicalising to another deployment', async () => {
|
|
115
|
+
const { fileUrl } = await load({ basePath: '/eziwiki' });
|
|
116
|
+
expect(fileUrl('/og-image.svg', 'https://production.app')).toBe(
|
|
117
|
+
'https://production.app/og-image.svg',
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
});
|