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