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