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,108 @@
1
+ /**
2
+ * Base path the site is served from, empty when served from the root.
3
+ *
4
+ * Set by the deploy workflow, and read here rather than inferred from CI so
5
+ * that tests, forks, and local builds are unaffected. `next.config.js` reads
6
+ * the same variable to configure Next itself; everything on this side of the
7
+ * build reads it through this module.
8
+ */
9
+ export const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH || '';
10
+
11
+ /**
12
+ * Prefixes a path served from `public/` with the deployment base path.
13
+ *
14
+ * Next rewrites the links and assets it generates itself, but not a `href` or
15
+ * a `url()` written by hand — those keep pointing at the domain root and 404
16
+ * once the site moves into a subdirectory. Anything referencing `public/`
17
+ * outside of Next's own output has to go through here.
18
+ *
19
+ * Absolute and protocol-relative URLs are returned untouched, so a favicon or
20
+ * an image hosted elsewhere keeps working.
21
+ *
22
+ * @param path - Root-relative public path, such as `/favicon.svg`
23
+ * @returns The path as the browser should request it
24
+ *
25
+ * @example
26
+ * ```typescript
27
+ * asset('/favicon.svg'); // '/eziwiki/favicon.svg' when deployed to /eziwiki
28
+ * asset('https://cdn.example.com/logo.svg'); // unchanged
29
+ * ```
30
+ */
31
+ export function asset(path: string): string {
32
+ if (!BASE_PATH) return path;
33
+ if (!path.startsWith('/') || path.startsWith('//')) return path;
34
+ return `${BASE_PATH}${path}`;
35
+ }
36
+
37
+ /**
38
+ * Origin the site is published under, without a trailing slash.
39
+ *
40
+ * Separate from the payload's `baseUrl`, and overriding it when set, because
41
+ * the origin is a property of the deployment rather than of the project: the
42
+ * same content is published to GitHub Pages, a preview host, and a custom
43
+ * domain, and each needs its own canonical URLs. The deploy workflow sets this
44
+ * alongside the base path.
45
+ */
46
+ const SITE_ORIGIN = (process.env.NEXT_PUBLIC_SITE_URL || '').replace(/\/+$/, '');
47
+
48
+ /**
49
+ * Resolves the root that absolute URLs are built from.
50
+ *
51
+ * The base path is part of it only when this deployment declared its own
52
+ * origin, because only then do the two describe the same site. A deployment
53
+ * that leaves the origin unset is publishing a copy of somewhere else — a
54
+ * GitHub Pages mirror of a site hosted elsewhere, say — and its canonical URLs
55
+ * have to name that other site, whose layout its own subdirectory says nothing
56
+ * about. Applying both would produce a URL that exists in neither place.
57
+ *
58
+ * @param payloadBaseUrl - `global.baseUrl` from the payload, used when the
59
+ * deployment does not declare an origin of its own
60
+ * @returns Root without a trailing slash
61
+ */
62
+ function canonicalRoot(payloadBaseUrl?: string): string {
63
+ if (SITE_ORIGIN) return `${SITE_ORIGIN}${BASE_PATH}`;
64
+ return (payloadBaseUrl || 'https://example.com').replace(/\/+$/, '');
65
+ }
66
+
67
+ /**
68
+ * Builds the absolute URL of a file, such as the sitemap or an OG image.
69
+ *
70
+ * Canonical tags, sitemap entries and social-preview metadata are read off the
71
+ * site by other machines, so they have to be absolute and they have to agree
72
+ * with each other. Composing them here is what keeps the origin and the base
73
+ * path from being applied in one place and forgotten in another — a canonical
74
+ * tag that omits the subdirectory points at a URL that does not exist.
75
+ *
76
+ * @param path - Root-relative path, such as `/sitemap.xml`
77
+ * @param payloadBaseUrl - Fallback origin from the payload
78
+ * @returns Absolute URL
79
+ *
80
+ * @example
81
+ * ```typescript
82
+ * fileUrl('/og-image.svg', 'https://example.com');
83
+ * // 'https://example.com/eziwiki/og-image.svg' when deployed to /eziwiki
84
+ * ```
85
+ */
86
+ export function fileUrl(path: string, payloadBaseUrl?: string): string {
87
+ if (/^https?:\/\//.test(path)) return path;
88
+ const trimmed = path.replace(/^\/+/, '');
89
+ return `${canonicalRoot(payloadBaseUrl)}/${trimmed}`;
90
+ }
91
+
92
+ /**
93
+ * Builds the absolute URL of a page, with the trailing slash Next emits.
94
+ *
95
+ * `trailingSlash` is on, so every page is served from `…/name/` and a link to
96
+ * `…/name` is a redirect. Sitemap entries and canonical tags that disagree on
97
+ * the slash advertise a URL that is not the one the page answers on, so the
98
+ * form is applied here rather than at each call site.
99
+ *
100
+ * @param urlSegment - Page URL segment, without leading or trailing slash
101
+ * @param payloadBaseUrl - Fallback origin from the payload
102
+ * @returns Absolute URL ending in a slash
103
+ */
104
+ export function pageUrl(urlSegment: string, payloadBaseUrl?: string): string {
105
+ const trimmed = urlSegment.replace(/^\/+|\/+$/g, '');
106
+ const base = canonicalRoot(payloadBaseUrl);
107
+ return trimmed ? `${base}/${trimmed}/` : `${base}/`;
108
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Whether derived data may be memoised for the lifetime of the process.
3
+ *
4
+ * Everything the site renders is derived from files under `content/`. During a
5
+ * build those files never change, so scanning and rendering them once and
6
+ * reusing the result is pure win. During `next dev` they change constantly —
7
+ * that is the entire point — and a process-lifetime cache would serve stale
8
+ * pages until the server was restarted.
9
+ *
10
+ * Test runs keep caching on: content is fixed there too, and the memoisation
11
+ * behaviour is itself something the tests assert.
12
+ */
13
+ export const CACHE_DERIVED_CONTENT = process.env.NODE_ENV !== 'development';
14
+
15
+ /**
16
+ * Returns the cached value when caching is enabled, otherwise nothing.
17
+ *
18
+ * Reads as a guard at the top of a memoised getter:
19
+ *
20
+ * @example
21
+ * ```typescript
22
+ * export function getThing(): Thing {
23
+ * const hit = cached(memo);
24
+ * if (hit) return hit;
25
+ *
26
+ * memo = buildThing();
27
+ * return memo;
28
+ * }
29
+ * ```
30
+ *
31
+ * @param value - The memoised value, or null when nothing is stored yet
32
+ * @returns The value in production and test, null in development
33
+ */
34
+ export function cached<T>(value: T | null): T | null {
35
+ return CACHE_DERIVED_CONTENT ? value : null;
36
+ }
@@ -0,0 +1,311 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import matter from 'gray-matter';
4
+ import { cached } from '../cache';
5
+
6
+ /**
7
+ * A single Markdown document discovered under the content directory.
8
+ *
9
+ * Every downstream feature — navigation, URL resolution, search indexing,
10
+ * backlinks — is derived from this shape rather than from hand-written
11
+ * configuration, so adding a file to `content/` is enough to publish a page.
12
+ */
13
+ export interface ContentDoc {
14
+ /** Content-relative path without extension (e.g. 'guides/quick-start') */
15
+ path: string;
16
+ /** Path split on '/', useful for grouping by directory */
17
+ segments: string[];
18
+ /** Directory portion of the path, '' for root-level documents */
19
+ dir: string;
20
+ /** Display title, from frontmatter or derived from the filename */
21
+ title: string;
22
+ /** Short summary from frontmatter, if provided */
23
+ description?: string;
24
+ /** Sort weight within its directory; unset values sort last */
25
+ order: number;
26
+ /** Excluded from navigation, but still reachable by direct URL */
27
+ hidden: boolean;
28
+ /** Full parsed frontmatter, for consumers that need custom fields */
29
+ frontmatter: Record<string, unknown>;
30
+ /** Markdown body with frontmatter stripped */
31
+ content: string;
32
+ /** Absolute path on disk */
33
+ filePath: string;
34
+ }
35
+
36
+ /**
37
+ * Optional per-directory metadata, read from `_meta.json` inside a
38
+ * content subdirectory. Lets a folder control how its section is presented
39
+ * without pushing that concern into the global config.
40
+ */
41
+ export interface DirMeta {
42
+ /** Section label shown in navigation */
43
+ name?: string;
44
+ /** Sort weight among sibling sections */
45
+ order?: number;
46
+ /** Background colour applied to the section header */
47
+ color?: string;
48
+ /** Icon identifier for the section */
49
+ icon?: string;
50
+ /** Hide the whole section from navigation */
51
+ hidden?: boolean;
52
+ }
53
+
54
+ /** Directory holding all Markdown content, resolved from the project root. */
55
+ export const CONTENT_DIR = path.join(process.cwd(), 'content');
56
+
57
+ /** Sort weight applied to documents that do not declare `order`. */
58
+ const DEFAULT_ORDER = Number.MAX_SAFE_INTEGER;
59
+
60
+ /**
61
+ * Converts a file or directory name into a human-readable title.
62
+ *
63
+ * Used as the fallback when a document has no `title` in its frontmatter
64
+ * and when a directory has no `_meta.json`.
65
+ *
66
+ * @param name - Raw file or directory name (without extension)
67
+ * @returns Title-cased label with separators replaced by spaces
68
+ *
69
+ * @example
70
+ * ```typescript
71
+ * titleize('quick-start'); // 'Quick Start'
72
+ * titleize('api_reference'); // 'Api Reference'
73
+ * ```
74
+ */
75
+ export function titleize(name: string): string {
76
+ return name
77
+ .replace(/[-_]+/g, ' ')
78
+ .replace(/\s+/g, ' ')
79
+ .trim()
80
+ .replace(/\b\w/g, (c) => c.toUpperCase());
81
+ }
82
+
83
+ /**
84
+ * Reads a numeric frontmatter field, tolerating string values.
85
+ *
86
+ * @param value - Raw frontmatter value
87
+ * @returns The parsed number, or DEFAULT_ORDER when absent or unparseable
88
+ */
89
+ function readOrder(value: unknown): number {
90
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
91
+ if (typeof value === 'string') {
92
+ const parsed = Number(value);
93
+ if (Number.isFinite(parsed)) return parsed;
94
+ }
95
+ return DEFAULT_ORDER;
96
+ }
97
+
98
+ /**
99
+ * Reads a boolean frontmatter field, tolerating string values.
100
+ *
101
+ * @param value - Raw frontmatter value
102
+ * @returns True when the value represents an affirmative
103
+ */
104
+ function readBoolean(value: unknown): boolean {
105
+ if (typeof value === 'boolean') return value;
106
+ if (typeof value === 'string') return value.toLowerCase() === 'true';
107
+ return false;
108
+ }
109
+
110
+ /**
111
+ * Reads the optional `_meta.json` for a content subdirectory.
112
+ *
113
+ * A malformed or unreadable file is treated as absent rather than fatal, so a
114
+ * typo in an optional presentation file never breaks the build.
115
+ *
116
+ * @param dirPath - Absolute path to the directory
117
+ * @returns Parsed directory metadata, or an empty object
118
+ */
119
+ function readDirMeta(dirPath: string): DirMeta {
120
+ const metaPath = path.join(dirPath, '_meta.json');
121
+
122
+ try {
123
+ const raw = fs.readFileSync(metaPath, 'utf-8');
124
+ const parsed = JSON.parse(raw) as unknown;
125
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
126
+ return parsed as DirMeta;
127
+ }
128
+ return {};
129
+ } catch {
130
+ return {};
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Recursively collects every Markdown file beneath a directory.
136
+ *
137
+ * Files and directories whose names begin with `_` or `.` are skipped, which
138
+ * keeps `_meta.json`, drafts, and editor cruft out of the published site.
139
+ *
140
+ * @param dir - Absolute directory to walk
141
+ * @param baseDir - Absolute root of the content tree, used to derive relative paths
142
+ * @returns Absolute paths of every discovered `.md` file
143
+ */
144
+ function walkMarkdown(dir: string, baseDir: string): string[] {
145
+ let entries: fs.Dirent[];
146
+
147
+ try {
148
+ entries = fs.readdirSync(dir, { withFileTypes: true });
149
+ } catch {
150
+ return [];
151
+ }
152
+
153
+ const files: string[] = [];
154
+
155
+ for (const entry of entries) {
156
+ if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue;
157
+
158
+ const fullPath = path.join(dir, entry.name);
159
+
160
+ if (entry.isDirectory()) {
161
+ files.push(...walkMarkdown(fullPath, baseDir));
162
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
163
+ files.push(fullPath);
164
+ }
165
+ }
166
+
167
+ return files;
168
+ }
169
+
170
+ /**
171
+ * Parses a single Markdown file into a ContentDoc.
172
+ *
173
+ * @param filePath - Absolute path to the Markdown file
174
+ * @returns The parsed document, or null if the file could not be read
175
+ */
176
+ function readDoc(filePath: string): ContentDoc | null {
177
+ let raw: string;
178
+
179
+ try {
180
+ raw = fs.readFileSync(filePath, 'utf-8');
181
+ } catch {
182
+ return null;
183
+ }
184
+
185
+ const { data, content } = matter(raw);
186
+ const frontmatter = data as Record<string, unknown>;
187
+
188
+ const relative = path.relative(CONTENT_DIR, filePath).replace(/\\/g, '/');
189
+ const docPath = relative.replace(/\.md$/, '');
190
+ const segments = docPath.split('/');
191
+ const fileName = segments[segments.length - 1];
192
+
193
+ return {
194
+ path: docPath,
195
+ segments,
196
+ dir: segments.slice(0, -1).join('/'),
197
+ title: typeof frontmatter.title === 'string' ? frontmatter.title : titleize(fileName),
198
+ description: typeof frontmatter.description === 'string' ? frontmatter.description : undefined,
199
+ order: readOrder(frontmatter.order),
200
+ hidden: readBoolean(frontmatter.hidden) || frontmatter.nav === false,
201
+ frontmatter,
202
+ content,
203
+ filePath,
204
+ };
205
+ }
206
+
207
+ /**
208
+ * Orders documents by explicit weight first, then alphabetically by title.
209
+ *
210
+ * Documents without an `order` fall back to title ordering, so a partially
211
+ * annotated directory still produces a stable, sensible sequence.
212
+ */
213
+ function compareDocs(a: ContentDoc, b: ContentDoc): number {
214
+ if (a.order !== b.order) return a.order - b.order;
215
+ return a.title.localeCompare(b.title);
216
+ }
217
+
218
+ /**
219
+ * The full content registry: every document plus the directory metadata
220
+ * needed to render sections around them.
221
+ */
222
+ export interface ContentRegistry {
223
+ /** All discovered documents, sorted by order then title */
224
+ docs: ContentDoc[];
225
+ /** Documents keyed by their content-relative path */
226
+ byPath: Map<string, ContentDoc>;
227
+ /** Directory metadata keyed by directory path relative to `content/` */
228
+ dirMeta: Map<string, DirMeta>;
229
+ }
230
+
231
+ let memo: ContentRegistry | null = null;
232
+
233
+ /**
234
+ * Scans the content directory and builds the document registry.
235
+ *
236
+ * The result is memoised for the lifetime of the process. A build renders many
237
+ * pages from the same content tree, and rescanning the filesystem for each one
238
+ * is pure waste; call {@link clearRegistryCache} if the tree changes in-process.
239
+ *
240
+ * @returns The populated registry
241
+ *
242
+ * @example
243
+ * ```typescript
244
+ * const { docs, byPath } = getContentRegistry();
245
+ *
246
+ * console.log(docs.length); // 21
247
+ * console.log(byPath.get('getting-started/quick-start')?.title); // 'Quick Start'
248
+ * ```
249
+ */
250
+ export function getContentRegistry(): ContentRegistry {
251
+ const hit = cached(memo);
252
+ if (hit) return hit;
253
+
254
+ const files = walkMarkdown(CONTENT_DIR, CONTENT_DIR);
255
+ const docs = files
256
+ .map(readDoc)
257
+ .filter((doc): doc is ContentDoc => doc !== null)
258
+ .sort(compareDocs);
259
+
260
+ const byPath = new Map(docs.map((doc) => [doc.path, doc]));
261
+
262
+ // Collect metadata for every directory that actually contains documents.
263
+ const dirMeta = new Map<string, DirMeta>();
264
+ const dirs = new Set<string>();
265
+
266
+ for (const doc of docs) {
267
+ // Register the document's directory and each of its ancestors, so that a
268
+ // nested section such as 'api/v2' contributes both 'api' and 'api/v2'.
269
+ for (let i = 1; i < doc.segments.length; i++) {
270
+ dirs.add(doc.segments.slice(0, i).join('/'));
271
+ }
272
+ }
273
+
274
+ for (const dir of dirs) {
275
+ dirMeta.set(dir, readDirMeta(path.join(CONTENT_DIR, dir)));
276
+ }
277
+
278
+ memo = { docs, byPath, dirMeta };
279
+ return memo;
280
+ }
281
+
282
+ /**
283
+ * Discards the memoised registry so the next call rescans the filesystem.
284
+ *
285
+ * Primarily useful in tests, and for tooling that mutates content between reads.
286
+ */
287
+ export function clearRegistryCache(): void {
288
+ memo = null;
289
+ }
290
+
291
+ /**
292
+ * Looks up a single document by its content-relative path.
293
+ *
294
+ * @param docPath - Path without extension (e.g. 'guides/quick-start')
295
+ * @returns The document, or undefined if no such file exists
296
+ */
297
+ export function getDoc(docPath: string): ContentDoc | undefined {
298
+ return getContentRegistry().byPath.get(docPath);
299
+ }
300
+
301
+ /**
302
+ * Returns every document path in the content tree.
303
+ *
304
+ * Unlike navigation-derived path lists, this includes documents that are
305
+ * hidden or absent from the sidebar — they still need to be built.
306
+ *
307
+ * @returns Content-relative paths, sorted
308
+ */
309
+ export function getAllDocPaths(): string[] {
310
+ return getContentRegistry().docs.map((doc) => doc.path);
311
+ }
@@ -0,0 +1,109 @@
1
+ import { getContentRegistry, type ContentDoc } from './registry';
2
+ import { cached } from '../cache';
3
+
4
+ /**
5
+ * Resolves the loose targets people write in wiki links to actual documents.
6
+ *
7
+ * Obsidian-style linking lets an author write `[[quick-start]]` without knowing
8
+ * where the file sits in the tree. That convenience only holds if the shorthand
9
+ * resolves predictably, so lookups run in a fixed order and ambiguous shorthand
10
+ * is refused rather than guessed at.
11
+ */
12
+
13
+ /** How a target was matched, for diagnostics. */
14
+ export type ResolutionKind = 'path' | 'basename' | 'title' | 'ambiguous' | 'missing';
15
+
16
+ /** Outcome of resolving a link target. */
17
+ export interface Resolution {
18
+ /** The matched document, if exactly one was found */
19
+ doc?: ContentDoc;
20
+ /** How the match was made, or why it failed */
21
+ kind: ResolutionKind;
22
+ /** Candidate paths, when the target was ambiguous */
23
+ candidates?: string[];
24
+ }
25
+
26
+ interface ResolverIndex {
27
+ byPath: Map<string, ContentDoc>;
28
+ byBasename: Map<string, ContentDoc[]>;
29
+ byTitle: Map<string, ContentDoc[]>;
30
+ }
31
+
32
+ let memo: ResolverIndex | null = null;
33
+
34
+ /**
35
+ * Normalises a target for comparison: trimmed, lower-cased, extension removed.
36
+ */
37
+ function normalize(target: string): string {
38
+ return target.trim().replace(/^\/+/, '').replace(/\/+$/, '').replace(/\.md$/i, '').toLowerCase();
39
+ }
40
+
41
+ /**
42
+ * Builds the lookup tables, memoised per process.
43
+ */
44
+ function getIndex(): ResolverIndex {
45
+ const hit = cached(memo);
46
+ if (hit) return hit;
47
+
48
+ const byPath = new Map<string, ContentDoc>();
49
+ const byBasename = new Map<string, ContentDoc[]>();
50
+ const byTitle = new Map<string, ContentDoc[]>();
51
+
52
+ const push = (map: Map<string, ContentDoc[]>, key: string, doc: ContentDoc) => {
53
+ const existing = map.get(key);
54
+ if (existing) existing.push(doc);
55
+ else map.set(key, [doc]);
56
+ };
57
+
58
+ for (const doc of getContentRegistry().docs) {
59
+ byPath.set(normalize(doc.path), doc);
60
+ push(byBasename, normalize(doc.segments[doc.segments.length - 1]), doc);
61
+ push(byTitle, normalize(doc.title), doc);
62
+ }
63
+
64
+ memo = { byPath, byBasename, byTitle };
65
+ return memo;
66
+ }
67
+
68
+ /**
69
+ * Resolves a wiki-link target to a document.
70
+ *
71
+ * Order is full path, then file name, then page title. A shorthand matching
72
+ * several documents resolves to none of them: silently picking the first would
73
+ * make the link's destination depend on directory scan order, which is exactly
74
+ * the kind of bug nobody finds until the wrong page ships.
75
+ *
76
+ * @param target - Raw target text from inside the brackets
77
+ * @returns The resolution, including why it failed when it did
78
+ *
79
+ * @example
80
+ * ```typescript
81
+ * resolveTarget('getting-started/quick-start').kind; // 'path'
82
+ * resolveTarget('quick-start').kind; // 'basename'
83
+ * resolveTarget('Quick Start').kind; // 'title'
84
+ * resolveTarget('nope').kind; // 'missing'
85
+ * ```
86
+ */
87
+ export function resolveTarget(target: string): Resolution {
88
+ const key = normalize(target);
89
+ if (!key) return { kind: 'missing' };
90
+
91
+ const { byPath, byBasename, byTitle } = getIndex();
92
+
93
+ const exact = byPath.get(key);
94
+ if (exact) return { doc: exact, kind: 'path' };
95
+
96
+ for (const [map, kind] of [
97
+ [byBasename, 'basename'],
98
+ [byTitle, 'title'],
99
+ ] as const) {
100
+ const matches = map.get(key);
101
+ if (!matches || matches.length === 0) continue;
102
+
103
+ if (matches.length === 1) return { doc: matches[0], kind };
104
+
105
+ return { kind: 'ambiguous', candidates: matches.map((doc) => doc.path) };
106
+ }
107
+
108
+ return { kind: 'missing' };
109
+ }