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,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,4 @@
1
+ {
2
+ "name": "📖 Guides",
3
+ "order": 2
4
+ }
@@ -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,7 @@
1
+ node_modules
2
+ .next
3
+ out
4
+ reference
5
+ *.config.js
6
+ *.config.ts
7
+ packages/*/template
@@ -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
+ });