mdorigin 0.1.0 → 0.1.2

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.
@@ -32,14 +32,25 @@ export async function loadSiteConfig(options = {}) {
32
32
  parsedConfig.siteDescription !== ''
33
33
  ? parsedConfig.siteDescription
34
34
  : undefined,
35
+ siteUrl: normalizeSiteUrl(parsedConfig.siteUrl),
36
+ favicon: normalizeSiteHref(parsedConfig.favicon),
37
+ logo: normalizeLogo(parsedConfig.logo),
35
38
  showDate: parsedConfig.showDate ?? true,
36
39
  showSummary: parsedConfig.showSummary ?? true,
37
40
  theme: isBuiltInThemeName(parsedConfig.theme) ? parsedConfig.theme : 'paper',
38
41
  template: isTemplateName(parsedConfig.template) ? parsedConfig.template : 'document',
39
42
  topNav: normalizeTopNav(parsedConfig.topNav),
43
+ footerNav: normalizeTopNav(parsedConfig.footerNav),
44
+ footerText: typeof parsedConfig.footerText === 'string' && parsedConfig.footerText !== ''
45
+ ? parsedConfig.footerText
46
+ : undefined,
47
+ socialLinks: normalizeSocialLinks(parsedConfig.socialLinks),
48
+ editLink: normalizeEditLink(parsedConfig.editLink),
40
49
  showHomeIndex: typeof parsedConfig.showHomeIndex === 'boolean'
41
50
  ? parsedConfig.showHomeIndex
42
51
  : normalizeTopNav(parsedConfig.topNav).length === 0,
52
+ catalogInitialPostCount: normalizePositiveInteger(parsedConfig.catalogInitialPostCount, 10),
53
+ catalogLoadMoreStep: normalizePositiveInteger(parsedConfig.catalogLoadMoreStep, 10),
43
54
  stylesheetContent,
44
55
  siteTitleConfigured: typeof parsedConfig.siteTitle === 'string' && parsedConfig.siteTitle !== '',
45
56
  siteDescriptionConfigured: typeof parsedConfig.siteDescription === 'string' &&
@@ -83,7 +94,7 @@ function isBuiltInThemeName(value) {
83
94
  return value === 'paper' || value === 'atlas' || value === 'gazette';
84
95
  }
85
96
  function isTemplateName(value) {
86
- return value === 'document' || value === 'editorial';
97
+ return value === 'document' || value === 'catalog';
87
98
  }
88
99
  function isNodeNotFound(error) {
89
100
  return (typeof error === 'object' &&
@@ -116,8 +127,93 @@ function normalizeTopNav(value) {
116
127
  item.label !== '' &&
117
128
  typeof item.href === 'string' &&
118
129
  item.href !== '') {
119
- return [{ label: item.label, href: item.href }];
130
+ const href = normalizeSiteHref(item.href);
131
+ return href ? [{ label: item.label, href }] : [];
120
132
  }
121
133
  return [];
122
134
  });
123
135
  }
136
+ function normalizePositiveInteger(value, fallback) {
137
+ if (typeof value === 'number' && Number.isInteger(value) && value > 0) {
138
+ return value;
139
+ }
140
+ if (typeof value === 'string') {
141
+ const parsed = Number.parseInt(value, 10);
142
+ if (Number.isInteger(parsed) && parsed > 0) {
143
+ return parsed;
144
+ }
145
+ }
146
+ return fallback;
147
+ }
148
+ function normalizeLogo(value) {
149
+ if (typeof value !== 'object' ||
150
+ value === null ||
151
+ !('src' in value) ||
152
+ typeof value.src !== 'string' ||
153
+ value.src === '') {
154
+ return undefined;
155
+ }
156
+ const src = normalizeSiteHref(value.src);
157
+ if (!src) {
158
+ return undefined;
159
+ }
160
+ const href = 'href' in value && typeof value.href === 'string'
161
+ ? normalizeSiteHref(value.href)
162
+ : undefined;
163
+ return {
164
+ src,
165
+ alt: 'alt' in value && typeof value.alt === 'string' && value.alt !== ''
166
+ ? value.alt
167
+ : undefined,
168
+ href,
169
+ };
170
+ }
171
+ function normalizeSocialLinks(value) {
172
+ if (!Array.isArray(value)) {
173
+ return [];
174
+ }
175
+ return value.flatMap((item) => {
176
+ if (typeof item !== 'object' ||
177
+ item === null ||
178
+ !('icon' in item) ||
179
+ !('label' in item) ||
180
+ !('href' in item) ||
181
+ typeof item.icon !== 'string' ||
182
+ item.icon === '' ||
183
+ typeof item.label !== 'string' ||
184
+ item.label === '' ||
185
+ typeof item.href !== 'string' ||
186
+ item.href === '') {
187
+ return [];
188
+ }
189
+ const href = normalizeSiteHref(item.href);
190
+ return href
191
+ ? [{ icon: item.icon, label: item.label, href }]
192
+ : [];
193
+ });
194
+ }
195
+ function normalizeEditLink(value) {
196
+ if (typeof value !== 'object' ||
197
+ value === null ||
198
+ !('baseUrl' in value) ||
199
+ typeof value.baseUrl !== 'string' ||
200
+ value.baseUrl === '') {
201
+ return undefined;
202
+ }
203
+ return { baseUrl: value.baseUrl };
204
+ }
205
+ function normalizeSiteUrl(value) {
206
+ return typeof value === 'string' && value !== '' ? value.replace(/\/+$/, '') : undefined;
207
+ }
208
+ function normalizeSiteHref(value) {
209
+ if (typeof value !== 'string' || value === '') {
210
+ return undefined;
211
+ }
212
+ if (value.startsWith('/') ||
213
+ value.startsWith('#') ||
214
+ value.startsWith('//') ||
215
+ /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value)) {
216
+ return value;
217
+ }
218
+ return `/${value.replace(/^\.?\//, '')}`;
219
+ }
@@ -1 +1 @@
1
- export type TemplateName = 'document' | 'editorial';
1
+ export type TemplateName = 'document' | 'catalog';
@@ -1,9 +1,13 @@
1
- import type { SiteNavItem } from '../core/site-config.js';
1
+ import type { SiteLogo, SiteNavItem, SiteSocialLink } from '../core/site-config.js';
2
+ import type { ManagedIndexEntry } from '../core/markdown.js';
2
3
  import type { TemplateName } from './template-kind.js';
3
4
  import { type BuiltInThemeName } from './theme.js';
4
5
  export interface RenderDocumentOptions {
5
6
  siteTitle: string;
6
7
  siteDescription?: string;
8
+ siteUrl?: string;
9
+ favicon?: string;
10
+ logo?: SiteLogo;
7
11
  title: string;
8
12
  body: string;
9
13
  summary?: string;
@@ -13,7 +17,19 @@ export interface RenderDocumentOptions {
13
17
  theme: BuiltInThemeName;
14
18
  template: TemplateName;
15
19
  topNav?: SiteNavItem[];
20
+ footerNav?: SiteNavItem[];
21
+ footerText?: string;
22
+ socialLinks?: SiteSocialLink[];
23
+ editLinkHref?: string;
16
24
  stylesheetContent?: string;
25
+ canonicalPath?: string;
26
+ alternateMarkdownPath?: string;
27
+ catalogEntries?: ManagedIndexEntry[];
28
+ catalogRequestPath?: string;
29
+ catalogInitialPostCount?: number;
30
+ catalogLoadMoreStep?: number;
31
+ searchEnabled?: boolean;
17
32
  }
18
33
  export declare function renderDocument(options: RenderDocumentOptions): string;
19
34
  export declare function escapeHtml(value: string): string;
35
+ export declare function renderCatalogArticleItems(entries: readonly ManagedIndexEntry[]): string;
@@ -8,18 +8,76 @@ export function renderDocument(options) {
8
8
  const summaryMeta = options.summary
9
9
  ? `<meta name="description" content="${escapeHtml(options.summary)}">`
10
10
  : '';
11
+ const canonicalMeta = options.siteUrl && options.canonicalPath
12
+ ? `<link rel="canonical" href="${escapeHtml(`${options.siteUrl}${options.canonicalPath}`)}">`
13
+ : '';
14
+ const faviconMeta = options.favicon
15
+ ? `<link rel="icon" href="${escapeHtml(options.favicon)}">`
16
+ : '';
17
+ const alternateMarkdownMeta = options.alternateMarkdownPath
18
+ ? `<link rel="alternate" type="text/markdown" href="${escapeHtml(options.alternateMarkdownPath)}">`
19
+ : '';
11
20
  const stylesheetBlock = `<style>${getBuiltInThemeStyles(options.theme)}${options.stylesheetContent ? `\n${options.stylesheetContent}` : ''}</style>`;
12
21
  const navBlock = options.topNav && options.topNav.length > 0
13
22
  ? `<nav class="site-nav"><ul>${options.topNav
14
23
  .map((item) => `<li><a href="${escapeHtml(item.href)}">${escapeHtml(item.label)}</a></li>`)
15
24
  .join('')}</ul></nav>`
16
25
  : '';
26
+ const searchToggleBlock = options.searchEnabled
27
+ ? [
28
+ '<div class="site-search" data-site-search>',
29
+ '<button type="button" class="site-search__toggle" aria-expanded="false" aria-controls="site-search-panel">Search</button>',
30
+ '<div id="site-search-panel" class="site-search__panel" hidden>',
31
+ '<form class="site-search__form" role="search" action="/api/search" method="get">',
32
+ '<label class="site-search__label" for="site-search-input">Search site</label>',
33
+ '<div class="site-search__controls">',
34
+ '<input id="site-search-input" class="site-search__input" type="search" name="q" placeholder="Search docs and skills" autocomplete="off">',
35
+ '<button type="submit" class="site-search__submit">Go</button>',
36
+ '</div>',
37
+ '<p class="site-search__hint">Search is powered by <code>/api/search</code>.</p>',
38
+ '</form>',
39
+ '<div class="site-search__results" data-site-search-results></div>',
40
+ '</div>',
41
+ '</div>',
42
+ ].join('')
43
+ : '';
44
+ const footerNavBlock = options.footerNav && options.footerNav.length > 0
45
+ ? `<nav class="site-footer__nav"><ul>${options.footerNav
46
+ .map((item) => `<li><a href="${escapeHtml(item.href)}">${escapeHtml(item.label)}</a></li>`)
47
+ .join('')}</ul></nav>`
48
+ : '';
49
+ const socialLinksBlock = options.socialLinks && options.socialLinks.length > 0
50
+ ? `<ul class="site-footer__social">${options.socialLinks
51
+ .map((item) => `<li><a href="${escapeHtml(item.href)}" aria-label="${escapeHtml(item.label)}" title="${escapeHtml(item.label)}">${renderSocialIcon(item.icon)}</a></li>`)
52
+ .join('')}</ul>`
53
+ : '';
17
54
  const siteDescriptionBlock = siteDescription
18
55
  ? `<span>${siteDescription}</span>`
19
56
  : '';
20
- const articleBody = options.template === 'editorial'
21
- ? renderEditorialBody(options)
57
+ const logoBlock = options.logo
58
+ ? `<span class="site-header__logo"><img src="${escapeHtml(options.logo.src)}" alt="${escapeHtml(options.logo.alt ?? '')}"></span>`
59
+ : '';
60
+ const brandHref = escapeHtml(options.logo?.href ?? '/');
61
+ const editLinkBlock = options.editLinkHref
62
+ ? `<a class="site-footer__edit-link" href="${escapeHtml(options.editLinkHref)}">Edit this page</a>`
63
+ : '';
64
+ const footerTextBlock = options.footerText
65
+ ? `<p class="site-footer__text">${escapeHtml(options.footerText)}</p>`
66
+ : '';
67
+ const footerMetaBlock = socialLinksBlock || editLinkBlock
68
+ ? `<div class="site-footer__meta">${socialLinksBlock}${editLinkBlock}</div>`
69
+ : '';
70
+ const footerBlock = footerNavBlock || footerTextBlock || footerMetaBlock
71
+ ? `<footer class="site-footer"><div class="site-footer__inner">${footerNavBlock}${footerTextBlock}${footerMetaBlock}</div></footer>`
72
+ : '';
73
+ const articleBody = options.template === 'catalog'
74
+ ? renderCatalogArticle(options.body, options.catalogEntries ?? [], {
75
+ requestPath: options.catalogRequestPath ?? '/',
76
+ initialPostCount: options.catalogInitialPostCount ?? 10,
77
+ loadMoreStep: options.catalogLoadMoreStep ?? 10,
78
+ })
22
79
  : options.body;
80
+ const searchScript = options.searchEnabled ? renderSearchScript() : '';
23
81
  return [
24
82
  '<!doctype html>',
25
83
  '<html lang="en">',
@@ -28,35 +86,22 @@ export function renderDocument(options) {
28
86
  '<meta name="viewport" content="width=device-width, initial-scale=1">',
29
87
  `<title>${title} | ${siteTitle}</title>`,
30
88
  summaryMeta,
89
+ canonicalMeta,
90
+ faviconMeta,
91
+ alternateMarkdownMeta,
31
92
  stylesheetBlock,
32
93
  '</head>',
33
94
  `<body data-theme="${options.theme}" data-template="${options.template}">`,
34
- `<header class="site-header"><div class="site-header__inner"><div class="site-header__brand"><p class="site-header__title"><a href="/">${siteTitle}</a></p>${siteDescriptionBlock}</div>${navBlock}</div></header>`,
95
+ `<header class="site-header"><div class="site-header__inner"><div class="site-header__brand"><p class="site-header__title"><a href="${brandHref}">${logoBlock}<span>${siteTitle}</span></a></p>${siteDescriptionBlock}</div><div class="site-header__actions">${navBlock}${searchToggleBlock}</div></div></header>`,
35
96
  '<main>',
36
97
  `<article>${articleBody}</article>`,
37
98
  '</main>',
99
+ footerBlock,
100
+ searchScript,
38
101
  '</body>',
39
102
  '</html>',
40
103
  ].join('');
41
104
  }
42
- function renderEditorialBody(options) {
43
- const heroLines = [
44
- '<div class="page-intro">',
45
- `<p class="page-intro__eyebrow">${escapeHtml(options.siteTitle)}</p>`,
46
- `<h1 class="page-intro__title">${escapeHtml(options.title)}</h1>`,
47
- ];
48
- if (options.showSummary !== false && options.summary) {
49
- heroLines.push(`<p class="page-intro__summary">${escapeHtml(options.summary)}</p>`);
50
- }
51
- if (options.showDate !== false && options.date) {
52
- heroLines.push(`<p class="page-intro__meta">${escapeHtml(options.date)}</p>`);
53
- }
54
- heroLines.push('</div>');
55
- return `${heroLines.join('')}<div class="page-body">${stripLeadingH1(options.body)}</div>`;
56
- }
57
- function stripLeadingH1(html) {
58
- return html.replace(/^\s*<h1>.*?<\/h1>\s*/s, '');
59
- }
60
105
  export function escapeHtml(value) {
61
106
  return value
62
107
  .replaceAll('&', '&amp;')
@@ -65,3 +110,219 @@ export function escapeHtml(value) {
65
110
  .replaceAll('"', '&quot;')
66
111
  .replaceAll("'", '&#39;');
67
112
  }
113
+ function renderSocialIcon(icon) {
114
+ switch (icon) {
115
+ case 'github':
116
+ return iconSvg('M12 2C6.48 2 2 6.58 2 12.11c0 4.43 2.87 8.18 6.84 9.5.5.1.68-.22.68-.48 0-.24-.01-1.04-.01-1.89-2.78.62-3.37-1.19-3.37-1.19-.46-1.17-1.11-1.48-1.11-1.48-.91-.63.07-.62.07-.62 1 .08 1.53 1.04 1.53 1.04.9 1.54 2.35 1.09 2.92.84.09-.66.35-1.09.63-1.34-2.22-.25-4.55-1.12-4.55-4.97 0-1.1.39-2 1.03-2.71-.1-.26-.45-1.29.1-2.68 0 0 .84-.27 2.75 1.03A9.4 9.4 0 0 1 12 6.84c.85 0 1.71.12 2.51.35 1.91-1.3 2.75-1.03 2.75-1.03.55 1.39.2 2.42.1 2.68.64.71 1.03 1.61 1.03 2.71 0 3.86-2.33 4.72-4.56 4.97.36.31.68.91.68 1.84 0 1.33-.01 2.4-.01 2.73 0 .27.18.58.69.48A10.11 10.11 0 0 0 22 12.11C22 6.58 17.52 2 12 2Z');
117
+ case 'rss':
118
+ return iconSvg('M5 3a16 16 0 0 1 16 16h-3A13 13 0 0 0 5 6V3Zm0 6a10 10 0 0 1 10 10h-3a7 7 0 0 0-7-7V9Zm0 6a4 4 0 0 1 4 4H5v-4Zm0 3a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z');
119
+ case 'npm':
120
+ return iconSvg('M3 7h18v10h-9v-8h-3v8H3V7Zm10 2h2v6h2V9h2v6h1V9h1V8h-8v1Z');
121
+ case 'x':
122
+ return iconSvg('M18.9 3H21l-6.87 7.85L22 21h-6.17l-4.83-6.32L5.47 21H3.36l7.35-8.4L2 3h6.32l4.37 5.77L18.9 3Zm-2.17 16h1.17L7.68 4H6.43l10.3 15Z');
123
+ case 'home':
124
+ return iconSvg('M12 3 3 10.2V21h6v-6h6v6h6V10.2L12 3Z');
125
+ default:
126
+ return `<span class="site-footer__social-label">${escapeHtml(icon.slice(0, 1).toUpperCase())}</span>`;
127
+ }
128
+ }
129
+ function iconSvg(pathData) {
130
+ return `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="${pathData}"></path></svg>`;
131
+ }
132
+ function renderCatalogArticle(body, entries, options) {
133
+ if (entries.length === 0) {
134
+ return body;
135
+ }
136
+ const directories = entries.filter((entry) => entry.kind === 'directory');
137
+ const articles = entries.filter((entry) => entry.kind === 'article');
138
+ const initialPostCount = Math.max(1, options.initialPostCount);
139
+ const visibleArticles = articles.slice(0, initialPostCount);
140
+ const shouldLoadMore = articles.length > visibleArticles.length;
141
+ return [
142
+ `<div class="catalog-page__body">${body}</div>`,
143
+ '<section class="catalog-page" aria-label="Catalog">',
144
+ directories.length > 0 ? renderCatalogDirectories(directories) : '',
145
+ articles.length > 0
146
+ ? renderCatalogArticles(visibleArticles, {
147
+ requestPath: options.requestPath,
148
+ nextOffset: visibleArticles.length,
149
+ loadMoreStep: options.loadMoreStep,
150
+ hasMore: shouldLoadMore,
151
+ })
152
+ : '',
153
+ '</section>',
154
+ shouldLoadMore ? renderCatalogLoadMoreScript() : '',
155
+ ].join('');
156
+ }
157
+ function renderCatalogDirectories(entries) {
158
+ return [
159
+ '<div class="catalog-list catalog-list--directories">',
160
+ ...entries.map((entry) => `<a class="catalog-item catalog-item--directory" href="${escapeHtml(entry.href)}"><strong class="catalog-item__title">${escapeHtml(entry.title)}</strong>${entry.detail
161
+ ? `<span class="catalog-item__detail">${escapeHtml(entry.detail)}</span>`
162
+ : '<span class="catalog-item__detail">Browse this section.</span>'}</a>`),
163
+ '</div>',
164
+ ].join('');
165
+ }
166
+ export function renderCatalogArticleItems(entries) {
167
+ return entries
168
+ .map((entry) => `<a class="catalog-item" href="${escapeHtml(entry.href)}"><strong class="catalog-item__title">${escapeHtml(entry.title)}</strong>${entry.detail
169
+ ? `<span class="catalog-item__detail">${escapeHtml(entry.detail)}</span>`
170
+ : ''}</a>`)
171
+ .join('');
172
+ }
173
+ function renderCatalogArticles(entries, options) {
174
+ return [
175
+ '<div class="catalog-list" data-catalog-articles>',
176
+ renderCatalogArticleItems(entries),
177
+ '</div>',
178
+ options.hasMore
179
+ ? `<div class="catalog-load-more"><button type="button" class="catalog-load-more__button" data-catalog-load-more data-request-path="${escapeHtml(options.requestPath)}" data-next-offset="${escapeHtml(String(options.nextOffset))}" data-load-more-step="${escapeHtml(String(options.loadMoreStep))}">Load more</button></div>`
180
+ : '',
181
+ ].join('');
182
+ }
183
+ function renderCatalogLoadMoreScript() {
184
+ return `<script>
185
+ (() => {
186
+ const button = document.querySelector('[data-catalog-load-more]');
187
+ const list = document.querySelector('[data-catalog-articles]');
188
+ if (!(button instanceof HTMLButtonElement) || !(list instanceof HTMLElement)) {
189
+ return;
190
+ }
191
+
192
+ const loadMore = async () => {
193
+ const requestPath = button.dataset.requestPath;
194
+ const nextOffset = button.dataset.nextOffset;
195
+ const loadMoreStep = button.dataset.loadMoreStep;
196
+ if (!requestPath || !nextOffset || !loadMoreStep) {
197
+ return;
198
+ }
199
+
200
+ button.disabled = true;
201
+ const previousLabel = button.textContent;
202
+ button.textContent = 'Loading...';
203
+
204
+ try {
205
+ const url = new URL(requestPath, window.location.origin);
206
+ url.searchParams.set('catalog-format', 'posts');
207
+ url.searchParams.set('catalog-offset', nextOffset);
208
+ url.searchParams.set('catalog-limit', loadMoreStep);
209
+
210
+ const response = await fetch(url.toString(), {
211
+ headers: { Accept: 'application/json' },
212
+ });
213
+ if (!response.ok) {
214
+ throw new Error('Failed to load more posts');
215
+ }
216
+
217
+ const payload = await response.json();
218
+ if (typeof payload.itemsHtml === 'string' && payload.itemsHtml !== '') {
219
+ list.insertAdjacentHTML('beforeend', payload.itemsHtml);
220
+ }
221
+
222
+ if (payload.hasMore === true && typeof payload.nextOffset === 'number') {
223
+ button.dataset.nextOffset = String(payload.nextOffset);
224
+ button.disabled = false;
225
+ button.textContent = previousLabel ?? 'Load more';
226
+ return;
227
+ }
228
+
229
+ button.remove();
230
+ } catch {
231
+ button.disabled = false;
232
+ button.textContent = previousLabel ?? 'Load more';
233
+ }
234
+ };
235
+
236
+ button.addEventListener('click', () => {
237
+ void loadMore();
238
+ });
239
+ })();
240
+ </script>`;
241
+ }
242
+ function renderSearchScript() {
243
+ return [
244
+ '<script>',
245
+ '(function () {',
246
+ ' const root = document.querySelector("[data-site-search]");',
247
+ ' if (!root) return;',
248
+ ' const toggle = root.querySelector(".site-search__toggle");',
249
+ ' const panel = root.querySelector(".site-search__panel");',
250
+ ' const form = root.querySelector(".site-search__form");',
251
+ ' const input = root.querySelector(".site-search__input");',
252
+ ' const results = root.querySelector("[data-site-search-results]");',
253
+ ' if (!toggle || !panel || !form || !input || !results) return;',
254
+ ' let controller = null;',
255
+ ' function renderMessage(message) {',
256
+ ' results.innerHTML = `<p class="site-search__message">${escapeHtmlForScript(message)}</p>`;',
257
+ ' }',
258
+ ' function renderHits(hits) {',
259
+ ' if (!Array.isArray(hits) || hits.length === 0) {',
260
+ ' renderMessage("No results.");',
261
+ ' return;',
262
+ ' }',
263
+ ' results.innerHTML = hits.map((hit) => {',
264
+ ' const href = escapeHtmlForScript(hit.canonicalUrl || hit.docId || "#");',
265
+ ' const title = escapeHtmlForScript(hit.title || hit.relativePath || "Untitled");',
266
+ ' const summary = typeof hit.summary === "string" ? `<span class="site-search__item-summary">${escapeHtmlForScript(hit.summary)}</span>` : "";',
267
+ ' const excerpt = hit.bestMatch && typeof hit.bestMatch.excerpt === "string" ? `<span class="site-search__item-excerpt">${escapeHtmlForScript(hit.bestMatch.excerpt)}</span>` : "";',
268
+ ' return `<a class="site-search__item" href="${href}"><strong class="site-search__item-title">${title}</strong>${summary}${excerpt}</a>`;',
269
+ ' }).join("");',
270
+ ' }',
271
+ ' async function runSearch(query) {',
272
+ ' if (controller) controller.abort();',
273
+ ' controller = new AbortController();',
274
+ ' renderMessage("Searching...");',
275
+ ' try {',
276
+ ' const url = new URL("/api/search", window.location.origin);',
277
+ ' url.searchParams.set("q", query);',
278
+ ' url.searchParams.set("topK", "8");',
279
+ ' const response = await fetch(url, { signal: controller.signal });',
280
+ ' if (!response.ok) {',
281
+ ' renderMessage(`Search failed (${response.status}).`);',
282
+ ' return;',
283
+ ' }',
284
+ ' const payload = await response.json();',
285
+ ' renderHits(payload.hits);',
286
+ ' } catch (error) {',
287
+ ' if (error && typeof error === "object" && "name" in error && error.name === "AbortError") return;',
288
+ ' renderMessage("Search failed.");',
289
+ ' }',
290
+ ' }',
291
+ ' function setOpen(open) {',
292
+ ' toggle.setAttribute("aria-expanded", open ? "true" : "false");',
293
+ ' panel.hidden = !open;',
294
+ ' if (open) {',
295
+ ' input.focus();',
296
+ ' if (!results.innerHTML) renderMessage("Search docs, guides, and skills.");',
297
+ ' }',
298
+ ' }',
299
+ ' toggle.addEventListener("click", () => setOpen(panel.hidden));',
300
+ ' form.addEventListener("submit", (event) => {',
301
+ ' event.preventDefault();',
302
+ ' const query = input.value.trim();',
303
+ ' if (!query) {',
304
+ ' renderMessage("Enter a search query.");',
305
+ ' return;',
306
+ ' }',
307
+ ' void runSearch(query);',
308
+ ' });',
309
+ ' document.addEventListener("keydown", (event) => {',
310
+ ' if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {',
311
+ ' event.preventDefault();',
312
+ ' setOpen(true);',
313
+ ' }',
314
+ ' if (event.key === "Escape" && !panel.hidden) setOpen(false);',
315
+ ' });',
316
+ '})();',
317
+ '',
318
+ 'function escapeHtmlForScript(value) {',
319
+ ' return String(value)',
320
+ ' .replaceAll("&", "&amp;")',
321
+ ' .replaceAll("<", "&lt;")',
322
+ ' .replaceAll(">", "&gt;")',
323
+ ' .replaceAll(\'"\', "&quot;")',
324
+ ' .replaceAll("\\\'", "&#39;");',
325
+ '}',
326
+ '</script>',
327
+ ].join('');
328
+ }