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,105 @@
|
|
|
1
|
+
import type { UrlStrategy } from '../navigation/url';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Navigation item in the sidebar hierarchy
|
|
5
|
+
*/
|
|
6
|
+
export interface NavigationItem {
|
|
7
|
+
/** Display name of the navigation item */
|
|
8
|
+
name: string;
|
|
9
|
+
/** Optional path to content file (without .md extension) */
|
|
10
|
+
path?: string;
|
|
11
|
+
/** Optional nested navigation items */
|
|
12
|
+
children?: NavigationItem[];
|
|
13
|
+
/** Optional icon identifier */
|
|
14
|
+
icon?: string;
|
|
15
|
+
/** Optional background color for the navigation item */
|
|
16
|
+
color?: string;
|
|
17
|
+
/** Hide this item from navigation (accessible only via direct URL) */
|
|
18
|
+
hidden?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Global site configuration
|
|
23
|
+
*/
|
|
24
|
+
export interface GlobalConfig {
|
|
25
|
+
/** Site title displayed in browser tab */
|
|
26
|
+
title: string;
|
|
27
|
+
/** Site description for SEO */
|
|
28
|
+
description: string;
|
|
29
|
+
/** Path to favicon */
|
|
30
|
+
favicon?: string;
|
|
31
|
+
/** Base URL for the site */
|
|
32
|
+
baseUrl?: string;
|
|
33
|
+
/**
|
|
34
|
+
* How content paths are expressed in URLs.
|
|
35
|
+
*
|
|
36
|
+
* `path` produces readable, indexable URLs mirroring the content tree.
|
|
37
|
+
* `hash` produces opaque hashes that conceal the structure at the cost of
|
|
38
|
+
* SEO and shareability. Defaults to `path`.
|
|
39
|
+
*/
|
|
40
|
+
urlStrategy?: UrlStrategy;
|
|
41
|
+
/**
|
|
42
|
+
* Discover documents under `content/` and add any that navigation does not
|
|
43
|
+
* already reference. Enabled by default, so a new Markdown file appears in
|
|
44
|
+
* the sidebar without touching this config.
|
|
45
|
+
*/
|
|
46
|
+
autoNavigation?: boolean;
|
|
47
|
+
/** SEO metadata */
|
|
48
|
+
seo?: {
|
|
49
|
+
openGraph?: {
|
|
50
|
+
title?: string;
|
|
51
|
+
description?: string;
|
|
52
|
+
images?: Array<{
|
|
53
|
+
url: string;
|
|
54
|
+
width?: number;
|
|
55
|
+
height?: number;
|
|
56
|
+
alt?: string;
|
|
57
|
+
}>;
|
|
58
|
+
};
|
|
59
|
+
twitter?: {
|
|
60
|
+
card?: 'summary' | 'summary_large_image' | 'app' | 'player';
|
|
61
|
+
site?: string;
|
|
62
|
+
creator?: string;
|
|
63
|
+
title?: string;
|
|
64
|
+
description?: string;
|
|
65
|
+
images?: string[];
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Theme color configuration
|
|
72
|
+
*/
|
|
73
|
+
export interface ThemeConfig {
|
|
74
|
+
/** Primary brand color */
|
|
75
|
+
primary: string;
|
|
76
|
+
/** Secondary accent color */
|
|
77
|
+
secondary: string;
|
|
78
|
+
/** Background color */
|
|
79
|
+
background: string;
|
|
80
|
+
/** Text color */
|
|
81
|
+
text: string;
|
|
82
|
+
/** Sidebar background color */
|
|
83
|
+
sidebarBg: string;
|
|
84
|
+
/** Code block background */
|
|
85
|
+
codeBg: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Complete payload structure
|
|
90
|
+
*/
|
|
91
|
+
export interface Payload {
|
|
92
|
+
/** Global site configuration */
|
|
93
|
+
global: GlobalConfig;
|
|
94
|
+
/**
|
|
95
|
+
* Curated navigation structure.
|
|
96
|
+
*
|
|
97
|
+
* Optional: when omitted, navigation is derived entirely from the content
|
|
98
|
+
* directory. When present, these entries control naming and ordering, and
|
|
99
|
+
* undeclared documents are appended automatically unless
|
|
100
|
+
* {@link GlobalConfig.autoNavigation} is disabled.
|
|
101
|
+
*/
|
|
102
|
+
navigation?: NavigationItem[];
|
|
103
|
+
/** Theme customization */
|
|
104
|
+
theme?: Partial<ThemeConfig>;
|
|
105
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import Ajv from 'ajv';
|
|
2
|
+
import addFormats from 'ajv-formats';
|
|
3
|
+
import { payloadSchema } from './schema';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Result of payload validation
|
|
7
|
+
*/
|
|
8
|
+
export interface ValidationResult {
|
|
9
|
+
/** Whether the payload is valid */
|
|
10
|
+
valid: boolean;
|
|
11
|
+
/** Array of error messages if validation failed */
|
|
12
|
+
errors?: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Validates payload configuration against JSON Schema
|
|
17
|
+
*
|
|
18
|
+
* This function ensures that the user-provided payload matches the required
|
|
19
|
+
* structure for the landing page generator. It validates:
|
|
20
|
+
* - Required fields (global.title, global.description, navigation)
|
|
21
|
+
* - Data types and formats (URLs, hex colors)
|
|
22
|
+
* - Navigation structure and nesting
|
|
23
|
+
* - Theme color values
|
|
24
|
+
*
|
|
25
|
+
* @param payload - User-provided payload object to validate
|
|
26
|
+
* @returns Validation result with detailed error messages if invalid
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```typescript
|
|
30
|
+
* import { validatePayload } from './lib/payload/validator';
|
|
31
|
+
* import payload from './payload/config';
|
|
32
|
+
*
|
|
33
|
+
* const result = validatePayload(payload);
|
|
34
|
+
* if (!result.valid) {
|
|
35
|
+
* console.error('Payload validation failed:');
|
|
36
|
+
* result.errors?.forEach(err => console.error(` - ${err}`));
|
|
37
|
+
* process.exit(1);
|
|
38
|
+
* }
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export function validatePayload(payload: unknown): ValidationResult {
|
|
42
|
+
const ajv = new Ajv({ allErrors: true });
|
|
43
|
+
addFormats(ajv);
|
|
44
|
+
|
|
45
|
+
const validate = ajv.compile(payloadSchema);
|
|
46
|
+
const valid = validate(payload);
|
|
47
|
+
|
|
48
|
+
if (!valid && validate.errors) {
|
|
49
|
+
return {
|
|
50
|
+
valid: false,
|
|
51
|
+
errors: validate.errors.map((err) => `${err.instancePath || 'root'} ${err.message}`),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { valid: true };
|
|
56
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { getContentRegistry } from '../content/registry';
|
|
2
|
+
import { renderDoc } from '../markdown/render';
|
|
3
|
+
import { docPathToUrl } from '../navigation/url';
|
|
4
|
+
import { getSite } from '../site';
|
|
5
|
+
import { SEARCH_INDEX_VERSION, type SearchDoc, type SearchIndex } from './types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Builds the search index from the content registry.
|
|
9
|
+
*
|
|
10
|
+
* Documents are split into one entry per section so a hit can link directly to
|
|
11
|
+
* the relevant heading rather than dropping the reader at the top of a long
|
|
12
|
+
* page. Server-only; the output is written to `public/` before the build.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Heading depths that start a new indexed section. */
|
|
16
|
+
const SECTION_DEPTHS = new Set([2, 3, 4]);
|
|
17
|
+
|
|
18
|
+
/** Characters of body text kept per entry, to bound the index size. */
|
|
19
|
+
const MAX_BODY_CHARS = 1200;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Converts Markdown to plain text suitable for indexing and previews.
|
|
23
|
+
*
|
|
24
|
+
* Code content is deliberately kept — searching for an API name or a flag is
|
|
25
|
+
* one of the main things people do in developer documentation — but the fence
|
|
26
|
+
* markers, link targets, and emphasis characters are stripped so they cannot
|
|
27
|
+
* pollute matches.
|
|
28
|
+
*
|
|
29
|
+
* @param markdown - Markdown source for a document or section
|
|
30
|
+
* @returns Collapsed plain text
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```typescript
|
|
34
|
+
* markdownToText('See [the guide](/guides/x) for `--flag`.');
|
|
35
|
+
* // 'See the guide for --flag.'
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export function markdownToText(markdown: string): string {
|
|
39
|
+
return (
|
|
40
|
+
markdown
|
|
41
|
+
// Fenced code: drop the fence lines, keep the code itself.
|
|
42
|
+
.replace(/^ {0,3}(`{3,}|~{3,}).*$/gm, '')
|
|
43
|
+
// HTML tags and comments.
|
|
44
|
+
.replace(/<!--[\s\S]*?-->/g, ' ')
|
|
45
|
+
.replace(/<[^>]+>/g, ' ')
|
|
46
|
+
// Images before links, so alt text survives and the src does not.
|
|
47
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
48
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
49
|
+
// Reference-style links and bare autolinks.
|
|
50
|
+
.replace(/\[([^\]]*)\]\[[^\]]*\]/g, '$1')
|
|
51
|
+
.replace(/<(https?:\/\/[^>]+)>/g, '$1')
|
|
52
|
+
// Heading markers. Depths 2-4 start their own section, but an h1 or an h5
|
|
53
|
+
// stays inline and would otherwise leave a stray '#' in the indexed text.
|
|
54
|
+
.replace(/^ {0,3}#{1,6} +/gm, '')
|
|
55
|
+
// Emphasis, inline code, blockquote and list markers, table pipes.
|
|
56
|
+
.replace(/[*_~`]+/g, '')
|
|
57
|
+
.replace(/^ {0,3}>+ ?/gm, '')
|
|
58
|
+
.replace(/^ {0,3}([-*+]|\d+\.) +/gm, '')
|
|
59
|
+
.replace(/^ {0,3}\|/gm, ' ')
|
|
60
|
+
.replace(/\|/g, ' ')
|
|
61
|
+
// Horizontal rules and table delimiter rows.
|
|
62
|
+
.replace(/^ {0,3}([-*_])(\s*\1){2,}\s*$/gm, ' ')
|
|
63
|
+
.replace(/^[\s:|-]+$/gm, ' ')
|
|
64
|
+
.replace(/\s+/g, ' ')
|
|
65
|
+
.trim()
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A slice of a document between two headings. */
|
|
70
|
+
interface Section {
|
|
71
|
+
/** Heading text, absent for the text preceding the first heading */
|
|
72
|
+
heading?: string;
|
|
73
|
+
/** Raw Markdown of the section body */
|
|
74
|
+
markdown: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Splits Markdown into sections at ATX headings of indexable depth.
|
|
79
|
+
*
|
|
80
|
+
* Fence state is tracked so that a `#` comment inside a code block is not
|
|
81
|
+
* mistaken for a heading — which would otherwise split a document at arbitrary
|
|
82
|
+
* points and misalign sections from their anchors.
|
|
83
|
+
*
|
|
84
|
+
* @param markdown - Markdown source with frontmatter removed
|
|
85
|
+
* @returns Sections in document order, the first being any preamble
|
|
86
|
+
*/
|
|
87
|
+
export function splitSections(markdown: string): Section[] {
|
|
88
|
+
const sections: Section[] = [{ markdown: '' }];
|
|
89
|
+
let fence: string | null = null;
|
|
90
|
+
|
|
91
|
+
for (const line of markdown.split('\n')) {
|
|
92
|
+
const fenceMatch = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
93
|
+
|
|
94
|
+
if (fenceMatch) {
|
|
95
|
+
const marker = fenceMatch[1][0];
|
|
96
|
+
if (fence === null) {
|
|
97
|
+
fence = marker;
|
|
98
|
+
} else if (fence === marker) {
|
|
99
|
+
fence = null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const headingMatch = fence === null ? /^ {0,3}(#{1,6}) +(.*?)#*\s*$/.exec(line) : null;
|
|
104
|
+
|
|
105
|
+
if (headingMatch && SECTION_DEPTHS.has(headingMatch[1].length)) {
|
|
106
|
+
sections.push({ heading: headingMatch[2].trim(), markdown: '' });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
sections[sections.length - 1].markdown += `${line}\n`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return sections;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Pairs each section with the anchor id of its heading.
|
|
118
|
+
*
|
|
119
|
+
* Sections come from the Markdown source and anchors from the rendered HTML, so
|
|
120
|
+
* the two are ordered alike but not guaranteed to correspond one-to-one — a
|
|
121
|
+
* heading written as raw HTML, for instance, appears in the render but not in
|
|
122
|
+
* the source scan. Matching on heading text and only advancing on a hit keeps
|
|
123
|
+
* one such discrepancy from shifting every subsequent anchor, which would
|
|
124
|
+
* silently point search results at the wrong sections.
|
|
125
|
+
*
|
|
126
|
+
* @param sections - Sections after the preamble, in document order
|
|
127
|
+
* @param anchors - Rendered headings, in document order
|
|
128
|
+
* @returns Anchor id per section, or undefined where none could be matched
|
|
129
|
+
*/
|
|
130
|
+
function matchAnchors(
|
|
131
|
+
sections: Section[],
|
|
132
|
+
anchors: Array<{ id: string; text: string }>,
|
|
133
|
+
): Array<string | undefined> {
|
|
134
|
+
const normalize = (value: string) => markdownToText(value).toLowerCase();
|
|
135
|
+
let cursor = 0;
|
|
136
|
+
|
|
137
|
+
return sections.map((section) => {
|
|
138
|
+
const target = normalize(section.heading ?? '');
|
|
139
|
+
|
|
140
|
+
for (let i = cursor; i < anchors.length; i++) {
|
|
141
|
+
if (normalize(anchors[i].text) === target) {
|
|
142
|
+
cursor = i + 1;
|
|
143
|
+
return anchors[i].id;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return undefined;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Builds the search index for every published document.
|
|
153
|
+
*
|
|
154
|
+
* Hidden documents are excluded: they are unlisted by intent, and surfacing
|
|
155
|
+
* them in search would defeat that.
|
|
156
|
+
*
|
|
157
|
+
* @returns The index, ready to serialise
|
|
158
|
+
*/
|
|
159
|
+
export async function buildSearchIndex(): Promise<SearchIndex> {
|
|
160
|
+
const { docs } = getContentRegistry();
|
|
161
|
+
const { urlMap, hiddenPaths } = getSite();
|
|
162
|
+
const entries: SearchDoc[] = [];
|
|
163
|
+
|
|
164
|
+
for (const doc of docs) {
|
|
165
|
+
if (hiddenPaths.has(doc.path)) continue;
|
|
166
|
+
|
|
167
|
+
const url = docPathToUrl(urlMap, doc.path);
|
|
168
|
+
if (!url) continue;
|
|
169
|
+
|
|
170
|
+
const href = `/${url}`;
|
|
171
|
+
const rendered = await renderDoc(doc.path);
|
|
172
|
+
|
|
173
|
+
// Anchors come from the rendered document, so they are exactly the ids
|
|
174
|
+
// rehype-slug produced and the links will resolve.
|
|
175
|
+
const [preamble, ...sections] = splitSections(doc.content);
|
|
176
|
+
const anchors = matchAnchors(sections, rendered?.headings ?? []);
|
|
177
|
+
|
|
178
|
+
entries.push({
|
|
179
|
+
id: doc.path,
|
|
180
|
+
path: doc.path,
|
|
181
|
+
url: href,
|
|
182
|
+
title: doc.title,
|
|
183
|
+
description: doc.description,
|
|
184
|
+
body: markdownToText(preamble.markdown).slice(0, MAX_BODY_CHARS),
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
sections.forEach((section, index) => {
|
|
188
|
+
const anchor = anchors[index];
|
|
189
|
+
|
|
190
|
+
entries.push({
|
|
191
|
+
id: anchor ? `${doc.path}#${anchor}` : `${doc.path}#${index}`,
|
|
192
|
+
path: doc.path,
|
|
193
|
+
url: anchor ? `${href}#${anchor}` : href,
|
|
194
|
+
title: doc.title,
|
|
195
|
+
section: section.heading,
|
|
196
|
+
anchor,
|
|
197
|
+
description: doc.description,
|
|
198
|
+
body: markdownToText(section.markdown).slice(0, MAX_BODY_CHARS),
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { version: SEARCH_INDEX_VERSION, docs: entries };
|
|
204
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type MiniSearch from 'minisearch';
|
|
2
|
+
import { tokenize } from './tokenizer';
|
|
3
|
+
import { SEARCH_INDEX_PATH, type SearchDoc, type SearchIndex } from './types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Browser-side search.
|
|
7
|
+
*
|
|
8
|
+
* The index is fetched and built on first use rather than at page load, so a
|
|
9
|
+
* reader who never opens search never pays for it.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A search hit, with the excerpt to display. */
|
|
13
|
+
export interface SearchResult {
|
|
14
|
+
/** Stable identifier of the matched entry */
|
|
15
|
+
id: string;
|
|
16
|
+
/** Href to navigate to, including the section anchor when there is one */
|
|
17
|
+
url: string;
|
|
18
|
+
/** Page title */
|
|
19
|
+
title: string;
|
|
20
|
+
/** Section heading, when the hit is a section rather than a whole page */
|
|
21
|
+
section?: string;
|
|
22
|
+
/** Body excerpt centred on the match */
|
|
23
|
+
excerpt: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Characters of context shown around a match. */
|
|
27
|
+
const EXCERPT_CHARS = 160;
|
|
28
|
+
|
|
29
|
+
/** Maximum hits returned to the UI. */
|
|
30
|
+
const MAX_RESULTS = 20;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Field weights.
|
|
34
|
+
*
|
|
35
|
+
* A query naming a section should surface that section above pages that merely
|
|
36
|
+
* mention the words in passing, so headings outrank body text substantially.
|
|
37
|
+
*/
|
|
38
|
+
const BOOST = { title: 4, section: 3, description: 2, body: 1 };
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Extra weight given to whole-page entries over the sections within them.
|
|
42
|
+
*
|
|
43
|
+
* A section entry carries its page's title as well as its own heading, so it
|
|
44
|
+
* matches both boosted fields and would otherwise always outrank the page it
|
|
45
|
+
* belongs to. Searching "dark mode" should land on the Dark Mode page, not on
|
|
46
|
+
* its "Disable Dark Mode" subsection.
|
|
47
|
+
*/
|
|
48
|
+
const PAGE_BOOST = 1.6;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Scales a hit's score according to whether it is a page or a section.
|
|
52
|
+
*
|
|
53
|
+
* @param _id - Document id, unused
|
|
54
|
+
* @param _term - Matched term, unused
|
|
55
|
+
* @param stored - The fields stored alongside the document
|
|
56
|
+
* @returns Multiplier applied to the hit's score
|
|
57
|
+
*/
|
|
58
|
+
function boostPages(_id: string, _term: string, stored?: Record<string, unknown>): number {
|
|
59
|
+
return stored?.section ? 1 : PAGE_BOOST;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let loading: Promise<MiniSearch<SearchDoc>> | null = null;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Builds the MiniSearch instance from index data.
|
|
66
|
+
*
|
|
67
|
+
* The library is imported dynamically so it lands in its own chunk, fetched
|
|
68
|
+
* alongside the index on first search rather than on every page load.
|
|
69
|
+
*
|
|
70
|
+
* @param index - Parsed index file
|
|
71
|
+
* @returns A populated searcher
|
|
72
|
+
*/
|
|
73
|
+
export async function createSearcher(index: SearchIndex): Promise<MiniSearch<SearchDoc>> {
|
|
74
|
+
const { default: MiniSearchCtor } = await import('minisearch');
|
|
75
|
+
|
|
76
|
+
const searcher = new MiniSearchCtor<SearchDoc>({
|
|
77
|
+
idField: 'id',
|
|
78
|
+
fields: ['title', 'section', 'description', 'body'],
|
|
79
|
+
storeFields: ['title', 'section', 'url', 'body'],
|
|
80
|
+
// The same tokeniser must run over queries and documents, or a CJK query
|
|
81
|
+
// would be split differently from the terms stored for a document.
|
|
82
|
+
tokenize,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
searcher.addAll(index.docs);
|
|
86
|
+
return searcher;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Fetches and prepares the search index, once per page load.
|
|
91
|
+
*
|
|
92
|
+
* Concurrent callers share a single request: opening the dialog, typing
|
|
93
|
+
* immediately, and a keyboard shortcut firing can all land within the same tick.
|
|
94
|
+
*
|
|
95
|
+
* @returns The ready searcher
|
|
96
|
+
* @throws Error if the index cannot be fetched or is of an unexpected version
|
|
97
|
+
*/
|
|
98
|
+
export function loadSearcher(): Promise<MiniSearch<SearchDoc>> {
|
|
99
|
+
loading ??= (async () => {
|
|
100
|
+
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || '';
|
|
101
|
+
const response = await fetch(`${basePath}${SEARCH_INDEX_PATH}`);
|
|
102
|
+
|
|
103
|
+
if (!response.ok) {
|
|
104
|
+
throw new Error(`Search index unavailable (${response.status})`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return await createSearcher((await response.json()) as SearchIndex);
|
|
108
|
+
})().catch((error) => {
|
|
109
|
+
// Allow a later attempt to retry rather than caching the failure forever.
|
|
110
|
+
loading = null;
|
|
111
|
+
throw error;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return loading;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Builds an excerpt centred on the first matched term.
|
|
119
|
+
*
|
|
120
|
+
* Showing the start of a long section is rarely useful — the reason a result
|
|
121
|
+
* matched is usually somewhere in the middle.
|
|
122
|
+
*
|
|
123
|
+
* @param body - Full body text of the entry
|
|
124
|
+
* @param terms - Terms MiniSearch matched
|
|
125
|
+
* @returns A trimmed excerpt, with ellipses where text was cut
|
|
126
|
+
*/
|
|
127
|
+
export function buildExcerpt(body: string, terms: string[]): string {
|
|
128
|
+
if (!body) return '';
|
|
129
|
+
|
|
130
|
+
const lower = body.toLowerCase();
|
|
131
|
+
let at = -1;
|
|
132
|
+
|
|
133
|
+
for (const term of terms) {
|
|
134
|
+
const found = lower.indexOf(term.toLowerCase());
|
|
135
|
+
if (found !== -1 && (at === -1 || found < at)) at = found;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (at === -1 || body.length <= EXCERPT_CHARS) {
|
|
139
|
+
return body.slice(0, EXCERPT_CHARS) + (body.length > EXCERPT_CHARS ? '…' : '');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const start = Math.max(0, at - EXCERPT_CHARS / 3);
|
|
143
|
+
const end = Math.min(body.length, start + EXCERPT_CHARS);
|
|
144
|
+
|
|
145
|
+
return (start > 0 ? '…' : '') + body.slice(start, end).trim() + (end < body.length ? '…' : '');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Runs a query against the loaded index.
|
|
150
|
+
*
|
|
151
|
+
* @param query - Raw user input
|
|
152
|
+
* @returns Ranked results, empty for a blank query
|
|
153
|
+
*
|
|
154
|
+
* @example
|
|
155
|
+
* ```typescript
|
|
156
|
+
* const results = await search('dark mode');
|
|
157
|
+
* results[0].url; // '/features/dark-mode'
|
|
158
|
+
* ```
|
|
159
|
+
*/
|
|
160
|
+
export async function search(query: string): Promise<SearchResult[]> {
|
|
161
|
+
const trimmed = query.trim();
|
|
162
|
+
if (!trimmed) return [];
|
|
163
|
+
|
|
164
|
+
const searcher = await loadSearcher();
|
|
165
|
+
|
|
166
|
+
const options = {
|
|
167
|
+
boost: BOOST,
|
|
168
|
+
prefix: true,
|
|
169
|
+
// Fuzziness is proportional to term length, so short terms stay exact and
|
|
170
|
+
// long ones tolerate a typo or two.
|
|
171
|
+
fuzzy: 0.2,
|
|
172
|
+
boostDocument: boostPages,
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const hits = searcher.search(trimmed, { ...options, combineWith: 'AND' as const });
|
|
176
|
+
|
|
177
|
+
// An AND query that matches nothing is usually one stray word away from a
|
|
178
|
+
// useful result, so fall back to OR rather than showing an empty list.
|
|
179
|
+
const results = hits.length
|
|
180
|
+
? hits
|
|
181
|
+
: searcher.search(trimmed, { ...options, combineWith: 'OR' as const });
|
|
182
|
+
|
|
183
|
+
return results.slice(0, MAX_RESULTS).map((hit) => ({
|
|
184
|
+
id: String(hit.id),
|
|
185
|
+
url: hit.url as string,
|
|
186
|
+
title: hit.title as string,
|
|
187
|
+
section: hit.section as string | undefined,
|
|
188
|
+
excerpt: buildExcerpt((hit.body as string) ?? '', hit.terms),
|
|
189
|
+
}));
|
|
190
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { bigrams, tokenize } from './tokenizer';
|
|
3
|
+
|
|
4
|
+
describe('bigrams', () => {
|
|
5
|
+
it('produces overlapping character pairs', () => {
|
|
6
|
+
expect(bigrams('위키문서')).toEqual(['위키', '키문', '문서']);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('keeps a single character as-is', () => {
|
|
10
|
+
expect(bigrams('한')).toEqual(['한']);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('returns nothing for empty input', () => {
|
|
14
|
+
expect(bigrams('')).toEqual([]);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('tokenize', () => {
|
|
19
|
+
it('splits Latin text on non-word characters and lower-cases it', () => {
|
|
20
|
+
expect(tokenize('Quick Start Guide')).toEqual(['quick', 'start', 'guide']);
|
|
21
|
+
expect(tokenize('dark-mode, enabled!')).toEqual(['dark', 'mode', 'enabled']);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('keeps digits as terms', () => {
|
|
25
|
+
expect(tokenize('Next.js 14')).toEqual(['next', 'js', '14']);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('expands CJK runs into bigrams alongside the whole term', () => {
|
|
29
|
+
expect(tokenize('시작하기')).toEqual(['시작하기', '시작', '작하', '하기']);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('handles mixed scripts in one string', () => {
|
|
33
|
+
expect(tokenize('Quick Start 시작하기')).toEqual([
|
|
34
|
+
'quick',
|
|
35
|
+
'start',
|
|
36
|
+
'시작하기',
|
|
37
|
+
'시작',
|
|
38
|
+
'작하',
|
|
39
|
+
'하기',
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('does not bigram a term that merely contains CJK', () => {
|
|
44
|
+
// Mixed-script terms are left whole; splitting them would produce bigrams
|
|
45
|
+
// that straddle the script boundary and match nothing.
|
|
46
|
+
expect(tokenize('API키')).toEqual(['api키']);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('lets a CJK query match the terms stored for a document', () => {
|
|
50
|
+
const indexed = new Set(tokenize('한국어 위키문서를 만들어 봅시다'));
|
|
51
|
+
const query = tokenize('위키');
|
|
52
|
+
|
|
53
|
+
expect(query.every((term) => indexed.has(term))).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('returns nothing for whitespace or punctuation alone', () => {
|
|
57
|
+
expect(tokenize(' ')).toEqual([]);
|
|
58
|
+
expect(tokenize('--- ...')).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tokenisation shared by the index builder and the browser-side searcher.
|
|
3
|
+
*
|
|
4
|
+
* Both sides must tokenise identically or a query will never match the terms
|
|
5
|
+
* stored for a document.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Latin, digit, and other non-CJK word characters. */
|
|
9
|
+
const WORD = /[^\p{L}\p{N}]+/u;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Characters that CJK scripts write without spaces between words.
|
|
13
|
+
* Covers Hangul syllables and jamo, CJK ideographs, and kana.
|
|
14
|
+
*/
|
|
15
|
+
const CJK = /[ᄀ-ᇿ-ヿ-㐀-䶿一-鿿가-豈-]/;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Whether a string consists entirely of CJK characters.
|
|
19
|
+
*/
|
|
20
|
+
function isCjk(term: string): boolean {
|
|
21
|
+
return [...term].every((char) => CJK.test(char));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Splits a CJK run into overlapping character bigrams.
|
|
26
|
+
*
|
|
27
|
+
* Korean, Chinese, and Japanese are written without spaces, so whitespace
|
|
28
|
+
* tokenisation yields one enormous token per phrase and a search for a word
|
|
29
|
+
* inside it never matches. Indexing bigrams — 위키문서 becomes 위키, 키문, 문서 —
|
|
30
|
+
* makes substring queries work without a morphological analyser. A single
|
|
31
|
+
* character is kept as-is so one-character queries still resolve.
|
|
32
|
+
*
|
|
33
|
+
* @param term - A run of CJK characters
|
|
34
|
+
* @returns The bigrams covering that run
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```typescript
|
|
38
|
+
* bigrams('위키문서'); // ['위키', '키문', '문서']
|
|
39
|
+
* bigrams('한'); // ['한']
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export function bigrams(term: string): string[] {
|
|
43
|
+
const chars = [...term];
|
|
44
|
+
if (chars.length < 2) return chars;
|
|
45
|
+
|
|
46
|
+
const result: string[] = [];
|
|
47
|
+
for (let i = 0; i < chars.length - 1; i++) {
|
|
48
|
+
result.push(chars[i] + chars[i + 1]);
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Splits text into searchable terms.
|
|
55
|
+
*
|
|
56
|
+
* Latin text is split on non-word characters; CJK runs are additionally
|
|
57
|
+
* expanded into bigrams. The whole CJK term is kept alongside its bigrams so
|
|
58
|
+
* that an exact phrase still scores highest.
|
|
59
|
+
*
|
|
60
|
+
* @param text - Text to tokenise
|
|
61
|
+
* @returns Lower-cased terms
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* ```typescript
|
|
65
|
+
* tokenize('Quick Start 시작하기');
|
|
66
|
+
* // ['quick', 'start', '시작하기', '시작', '작하', '하기']
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export function tokenize(text: string): string[] {
|
|
70
|
+
const raw = text.split(WORD).filter(Boolean);
|
|
71
|
+
const terms: string[] = [];
|
|
72
|
+
|
|
73
|
+
for (const term of raw) {
|
|
74
|
+
const lower = term.toLowerCase();
|
|
75
|
+
terms.push(lower);
|
|
76
|
+
|
|
77
|
+
if (isCjk(lower) && lower.length > 1) {
|
|
78
|
+
terms.push(...bigrams(lower));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return terms;
|
|
83
|
+
}
|