@dominikcz/greg 0.9.27

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 (183) hide show
  1. package/README.md +397 -0
  2. package/bin/greg.js +241 -0
  3. package/bin/init.js +351 -0
  4. package/bin/templates/docs/getting-started.md +47 -0
  5. package/bin/templates/docs/index.md +11 -0
  6. package/bin/templates/greg.config.js +39 -0
  7. package/bin/templates/greg.config.ts +38 -0
  8. package/bin/templates/index.html +16 -0
  9. package/bin/templates/src/App.svelte +5 -0
  10. package/bin/templates/src/app.css +20 -0
  11. package/bin/templates/src/main.js +9 -0
  12. package/bin/templates/svelte.config.js +1 -0
  13. package/bin/templates/tsconfig.json +21 -0
  14. package/bin/templates/vite.config.js +23 -0
  15. package/docs/__partials/markdown/examples/basic.md +4 -0
  16. package/docs/__partials/markdown/examples/diff.md +10 -0
  17. package/docs/__partials/markdown/examples/focus.md +5 -0
  18. package/docs/__partials/markdown/examples/language-title.md +3 -0
  19. package/docs/__partials/markdown/examples/line-highlighting.md +5 -0
  20. package/docs/__partials/markdown/examples/line-numbers.md +5 -0
  21. package/docs/__partials/note.md +4 -0
  22. package/docs/guide/__shared-warning.md +4 -0
  23. package/docs/guide/asset-handling.md +88 -0
  24. package/docs/guide/deploying.md +162 -0
  25. package/docs/guide/getting-started.md +334 -0
  26. package/docs/guide/index.md +23 -0
  27. package/docs/guide/localization.md +290 -0
  28. package/docs/guide/markdown/code.md +95 -0
  29. package/docs/guide/markdown/components-and-mermaid.md +43 -0
  30. package/docs/guide/markdown/containers.md +110 -0
  31. package/docs/guide/markdown/header-anchors.md +34 -0
  32. package/docs/guide/markdown/includes.md +84 -0
  33. package/docs/guide/markdown/index.md +20 -0
  34. package/docs/guide/markdown/inline-attributes.md +21 -0
  35. package/docs/guide/markdown/links-and-toc.md +64 -0
  36. package/docs/guide/markdown/math.md +54 -0
  37. package/docs/guide/markdown/syntax-highlighting.md +75 -0
  38. package/docs/guide/routing.md +150 -0
  39. package/docs/guide/using-svelte.md +88 -0
  40. package/docs/guide/versioning.md +281 -0
  41. package/docs/incompatibilities.md +48 -0
  42. package/docs/index.md +43 -0
  43. package/docs/reference/badge.md +100 -0
  44. package/docs/reference/carbon-ads.md +46 -0
  45. package/docs/reference/code-group.md +126 -0
  46. package/docs/reference/home-page.md +232 -0
  47. package/docs/reference/index.md +18 -0
  48. package/docs/reference/markdowndocs.md +275 -0
  49. package/docs/reference/outline.md +79 -0
  50. package/docs/reference/search.md +263 -0
  51. package/docs/reference/steps.md +200 -0
  52. package/docs/reference/team-page.md +189 -0
  53. package/docs/reference/theme.md +150 -0
  54. package/fakeDocsGenerator/generate_docs.js +310 -0
  55. package/package.json +92 -0
  56. package/scripts/build-versions.js +609 -0
  57. package/scripts/generate-static.js +79 -0
  58. package/scripts/render-markdown.js +420 -0
  59. package/src/lib/MarkdownDocs/AiChat.svelte +936 -0
  60. package/src/lib/MarkdownDocs/BackToTop.svelte +68 -0
  61. package/src/lib/MarkdownDocs/Breadcrumb.svelte +68 -0
  62. package/src/lib/MarkdownDocs/DocsNavigation.svelte +149 -0
  63. package/src/lib/MarkdownDocs/DocsSiteHeader.svelte +758 -0
  64. package/src/lib/MarkdownDocs/DocsVersionSwitcher.svelte +103 -0
  65. package/src/lib/MarkdownDocs/MarkdownDocs.svelte +2115 -0
  66. package/src/lib/MarkdownDocs/MarkdownRenderer.svelte +487 -0
  67. package/src/lib/MarkdownDocs/Outline.svelte +238 -0
  68. package/src/lib/MarkdownDocs/PrevNext.svelte +115 -0
  69. package/src/lib/MarkdownDocs/SearchModal.svelte +1241 -0
  70. package/src/lib/MarkdownDocs/TreeView.svelte +32 -0
  71. package/src/lib/MarkdownDocs/TreeViewItem.svelte +219 -0
  72. package/src/lib/MarkdownDocs/VersionOutdatedNotice.svelte +72 -0
  73. package/src/lib/MarkdownDocs/__tests__/codeDirectives.test.js +54 -0
  74. package/src/lib/MarkdownDocs/__tests__/common.test.js +41 -0
  75. package/src/lib/MarkdownDocs/__tests__/docsExamplesLint.test.js +77 -0
  76. package/src/lib/MarkdownDocs/__tests__/fixtures/docs/markdown/__partial-basic.md +3 -0
  77. package/src/lib/MarkdownDocs/__tests__/fixtures/docs/markdown/snippet.js +9 -0
  78. package/src/lib/MarkdownDocs/__tests__/fixtures/includes/part.md +11 -0
  79. package/src/lib/MarkdownDocs/__tests__/fixtures/includes/wrapper.md +5 -0
  80. package/src/lib/MarkdownDocs/__tests__/fixtures/snippets/sample.js +8 -0
  81. package/src/lib/MarkdownDocs/__tests__/fixtures/snippets/sample.md +5 -0
  82. package/src/lib/MarkdownDocs/__tests__/helpers.js +67 -0
  83. package/src/lib/MarkdownDocs/__tests__/localeUtils.test.js +204 -0
  84. package/src/lib/MarkdownDocs/__tests__/markdown.test.js +704 -0
  85. package/src/lib/MarkdownDocs/__tests__/markdownRendererRuntime.test.js +65 -0
  86. package/src/lib/MarkdownDocs/__tests__/searchIndexBuilder.test.js +117 -0
  87. package/src/lib/MarkdownDocs/__tests__/sqliteStore.test.js +202 -0
  88. package/src/lib/MarkdownDocs/__tests__/useRouter.test.js +16 -0
  89. package/src/lib/MarkdownDocs/ai/adapters/customAdapter.js +14 -0
  90. package/src/lib/MarkdownDocs/ai/adapters/customAdapter.ts +43 -0
  91. package/src/lib/MarkdownDocs/ai/adapters/ollamaAdapter.js +81 -0
  92. package/src/lib/MarkdownDocs/ai/adapters/ollamaAdapter.ts +116 -0
  93. package/src/lib/MarkdownDocs/ai/adapters/openaiAdapter.js +92 -0
  94. package/src/lib/MarkdownDocs/ai/adapters/openaiAdapter.ts +137 -0
  95. package/src/lib/MarkdownDocs/ai/aiProvider.ts +31 -0
  96. package/src/lib/MarkdownDocs/ai/characters.js +52 -0
  97. package/src/lib/MarkdownDocs/ai/characters.ts +69 -0
  98. package/src/lib/MarkdownDocs/ai/chunkStore.ts +25 -0
  99. package/src/lib/MarkdownDocs/ai/chunker.js +85 -0
  100. package/src/lib/MarkdownDocs/ai/chunker.ts +135 -0
  101. package/src/lib/MarkdownDocs/ai/docLinker.js +26 -0
  102. package/src/lib/MarkdownDocs/ai/docLinker.ts +36 -0
  103. package/src/lib/MarkdownDocs/ai/promptBuilder.js +33 -0
  104. package/src/lib/MarkdownDocs/ai/promptBuilder.ts +53 -0
  105. package/src/lib/MarkdownDocs/ai/ragPipeline.js +54 -0
  106. package/src/lib/MarkdownDocs/ai/ragPipeline.ts +106 -0
  107. package/src/lib/MarkdownDocs/ai/stores/memoryStore.js +88 -0
  108. package/src/lib/MarkdownDocs/ai/stores/memoryStore.ts +112 -0
  109. package/src/lib/MarkdownDocs/ai/stores/sqliteStore.ts +372 -0
  110. package/src/lib/MarkdownDocs/ai/types.ts +71 -0
  111. package/src/lib/MarkdownDocs/aiServer.js +288 -0
  112. package/src/lib/MarkdownDocs/codeDirectives.js +191 -0
  113. package/src/lib/MarkdownDocs/codeFenceInfo.js +45 -0
  114. package/src/lib/MarkdownDocs/codeGroup.ts +46 -0
  115. package/src/lib/MarkdownDocs/common.ts +47 -0
  116. package/src/lib/MarkdownDocs/docsUtils.js +281 -0
  117. package/src/lib/MarkdownDocs/index.plugins.js +22 -0
  118. package/src/lib/MarkdownDocs/layouts/LayoutDoc.svelte +8 -0
  119. package/src/lib/MarkdownDocs/layouts/LayoutHome.svelte +58 -0
  120. package/src/lib/MarkdownDocs/layouts/LayoutPage.svelte +9 -0
  121. package/src/lib/MarkdownDocs/loadGregConfig.js +82 -0
  122. package/src/lib/MarkdownDocs/localeUtils.ts +682 -0
  123. package/src/lib/MarkdownDocs/markdownRendererRuntime.ts +314 -0
  124. package/src/lib/MarkdownDocs/mermaidThemes.js +319 -0
  125. package/src/lib/MarkdownDocs/navigationUtils.js +22 -0
  126. package/src/lib/MarkdownDocs/rehypeCodeGroup.js +326 -0
  127. package/src/lib/MarkdownDocs/rehypeCodeTitle.js +96 -0
  128. package/src/lib/MarkdownDocs/rehypeToc.js +170 -0
  129. package/src/lib/MarkdownDocs/remarkCodeMeta.js +22 -0
  130. package/src/lib/MarkdownDocs/remarkContainers.js +329 -0
  131. package/src/lib/MarkdownDocs/remarkCustomAnchors.js +42 -0
  132. package/src/lib/MarkdownDocs/remarkEscapeSvelte.js +33 -0
  133. package/src/lib/MarkdownDocs/remarkGlobalComponents.js +65 -0
  134. package/src/lib/MarkdownDocs/remarkImports.js +461 -0
  135. package/src/lib/MarkdownDocs/remarkImportsBrowser.js +349 -0
  136. package/src/lib/MarkdownDocs/remarkInlineAttrs.js +95 -0
  137. package/src/lib/MarkdownDocs/remarkMathToHtml.js +138 -0
  138. package/src/lib/MarkdownDocs/searchIndexBuilder.js +497 -0
  139. package/src/lib/MarkdownDocs/searchServer.js +263 -0
  140. package/src/lib/MarkdownDocs/treeViewTypes.ts +11 -0
  141. package/src/lib/MarkdownDocs/useRouter.svelte.ts +114 -0
  142. package/src/lib/MarkdownDocs/useSplitter.svelte.ts +33 -0
  143. package/src/lib/MarkdownDocs/versioningDefaults.js +20 -0
  144. package/src/lib/MarkdownDocs/vitePluginAiServer.js +204 -0
  145. package/src/lib/MarkdownDocs/vitePluginCopyDocs.js +153 -0
  146. package/src/lib/MarkdownDocs/vitePluginFrontmatter.js +109 -0
  147. package/src/lib/MarkdownDocs/vitePluginGregConfig.js +108 -0
  148. package/src/lib/MarkdownDocs/vitePluginSearchIndex.js +57 -0
  149. package/src/lib/MarkdownDocs/vitePluginSearchServer.js +190 -0
  150. package/src/lib/components/Badge.svelte +59 -0
  151. package/src/lib/components/Button.svelte +138 -0
  152. package/src/lib/components/CarbonAds.svelte +99 -0
  153. package/src/lib/components/CodeGroup.svelte +102 -0
  154. package/src/lib/components/Feature.svelte +209 -0
  155. package/src/lib/components/Features.svelte +123 -0
  156. package/src/lib/components/Hero.svelte +399 -0
  157. package/src/lib/components/Image.svelte +128 -0
  158. package/src/lib/components/Link.svelte +105 -0
  159. package/src/lib/components/SocialLink.svelte +84 -0
  160. package/src/lib/components/SocialLinks.svelte +33 -0
  161. package/src/lib/components/Steps.svelte +143 -0
  162. package/src/lib/components/TeamMember.svelte +273 -0
  163. package/src/lib/components/TeamMembers.svelte +81 -0
  164. package/src/lib/components/TeamPage.svelte +65 -0
  165. package/src/lib/components/TeamPageSection.svelte +108 -0
  166. package/src/lib/components/TeamPageTitle.svelte +89 -0
  167. package/src/lib/components/index.js +24 -0
  168. package/src/lib/portal/context.js +12 -0
  169. package/src/lib/portal/index.js +3 -0
  170. package/src/lib/portal/portal.svelte +14 -0
  171. package/src/lib/portal/slot.svelte +8 -0
  172. package/src/lib/scss/__code.scss +128 -0
  173. package/src/lib/scss/__containers.scss +99 -0
  174. package/src/lib/scss/__markdown.scss +447 -0
  175. package/src/lib/scss/__scrollbar.scss +60 -0
  176. package/src/lib/scss/__steps.scss +100 -0
  177. package/src/lib/scss/__theme.scss +238 -0
  178. package/src/lib/scss/__toc.scss +55 -0
  179. package/src/lib/scss/__utilities.scss +7 -0
  180. package/src/lib/scss/greg.scss +9 -0
  181. package/src/lib/spinner/spinner.svelte +42 -0
  182. package/svelte.config.js +146 -0
  183. package/types/index.d.ts +456 -0
@@ -0,0 +1,936 @@
1
+ <script lang="ts">
2
+ import { onMount } from "svelte";
3
+ import gregConfig from "virtual:greg-config";
4
+ import { withBase } from "./common";
5
+
6
+ // ── Types ──────────────────────────────────────────────────────────────────
7
+
8
+ type AiCharacter = {
9
+ id: string;
10
+ name: string;
11
+ icon: string;
12
+ description: string;
13
+ systemPrompt: string;
14
+ };
15
+
16
+ type AiSource = {
17
+ pageId: string;
18
+ pageTitle: string;
19
+ sectionHeading: string;
20
+ sectionAnchor: string;
21
+ };
22
+
23
+ type AiResponse = {
24
+ answer: string;
25
+ sources: AiSource[];
26
+ character: string;
27
+ };
28
+
29
+ type ChatMessage = {
30
+ id: number;
31
+ role: "user" | "assistant";
32
+ content: string;
33
+ sources?: AiSource[];
34
+ character?: string;
35
+ error?: boolean;
36
+ };
37
+
38
+ // ── Props ──────────────────────────────────────────────────────────────────
39
+
40
+ type Props = {
41
+ onNavigate: (path: string, anchor?: string) => void;
42
+ onClose: () => void;
43
+ localeSrcDir?: string;
44
+ placeholder?: string;
45
+ loadingText?: string;
46
+ errorText?: string;
47
+ startText?: string;
48
+ sourcesLabel?: string;
49
+ aiLabel?: string;
50
+ clearChatLabel?: string;
51
+ sendLabel?: string;
52
+ /** Exposed handle so the parent can read hasMessages and call clear(). */
53
+ chatHandle?: { clear: () => void; hasMessages: boolean };
54
+ };
55
+
56
+ let {
57
+ onNavigate,
58
+ onClose,
59
+ localeSrcDir = "/docs",
60
+ placeholder = "Ask a question about the docs…",
61
+ loadingText = "Thinking…",
62
+ errorText = "Something went wrong. Please try again.",
63
+ startText = "Ask me anything about this documentation. My answers are based exclusively on the docs.",
64
+ sourcesLabel = "Sources",
65
+ aiLabel = "Ask AI",
66
+ clearChatLabel = "Clear chat",
67
+ sendLabel = "Send",
68
+ chatHandle = $bindable<{ clear: () => void; hasMessages: boolean } | undefined>(undefined),
69
+ }: Props = $props();
70
+
71
+ // ── Config ─────────────────────────────────────────────────────────────────
72
+
73
+ const aiCfg = (gregConfig as any)?.search?.ai ?? {};
74
+ const aiUrl: string = aiCfg.serverUrl ?? "/api/ai";
75
+
76
+ // ── State ──────────────────────────────────────────────────────────────────
77
+
78
+ const STORAGE_KEY = 'greg-ai-chat-history';
79
+ const MAX_STORED_MESSAGES = 100;
80
+ const CHARACTER_KEY = 'greg-ai-character';
81
+
82
+ function loadStoredCharacter(): string {
83
+ try {
84
+ return localStorage.getItem(CHARACTER_KEY) ?? '';
85
+ } catch { return ''; }
86
+ }
87
+
88
+ function saveCharacter(id: string) {
89
+ try { localStorage.setItem(CHARACTER_KEY, id); } catch { /* ignore */ }
90
+ }
91
+
92
+ function loadStoredMessages(): ChatMessage[] {
93
+ try {
94
+ const raw = localStorage.getItem(STORAGE_KEY);
95
+ if (raw) {
96
+ const parsed = JSON.parse(raw) as ChatMessage[];
97
+ if (Array.isArray(parsed)) return parsed;
98
+ }
99
+ } catch { /* ignore */ }
100
+ return [];
101
+ }
102
+
103
+ function saveMessages(msgs: ChatMessage[]) {
104
+ try {
105
+ // Never persist error messages or excess entries
106
+ const toSave = msgs
107
+ .filter(m => !m.error)
108
+ .slice(-MAX_STORED_MESSAGES);
109
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave));
110
+ } catch { /* storage unavailable */ }
111
+ }
112
+
113
+ let characters = $state<AiCharacter[]>([]);
114
+ let selectedCharacter = $state<string>(loadStoredCharacter() || (aiCfg.defaultCharacter ?? "professional"));
115
+ let query = $state("");
116
+ const _storedMessages = loadStoredMessages();
117
+ let messages = $state<ChatMessage[]>(_storedMessages);
118
+ let isLoading = $state(false);
119
+ let inputEl = $state<HTMLTextAreaElement | undefined>(undefined);
120
+ let messagesEl = $state<HTMLDivElement | undefined>(undefined);
121
+ let counter = _storedMessages.length > 0 ? Math.max(..._storedMessages.map(m => m.id)) : 0;
122
+
123
+ // ── Query history (arrow-key navigation) ───────────────────────────────────
124
+
125
+ const QUERY_HISTORY_KEY = 'greg-ai-query-history';
126
+ const MAX_QUERY_HISTORY = 50;
127
+
128
+ function loadQueryHistory(): string[] {
129
+ try {
130
+ const raw = localStorage.getItem(QUERY_HISTORY_KEY);
131
+ if (raw) {
132
+ const parsed = JSON.parse(raw);
133
+ if (Array.isArray(parsed)) return parsed as string[];
134
+ }
135
+ } catch { /* ignore */ }
136
+ return [];
137
+ }
138
+
139
+ function saveQueryHistory(history: string[]) {
140
+ try { localStorage.setItem(QUERY_HISTORY_KEY, JSON.stringify(history)); } catch { /* ignore */ }
141
+ }
142
+
143
+ let queryHistory: string[] = loadQueryHistory();
144
+ let historyIdx = -1; // -1 = editing fresh draft
145
+ let historyDraft = ''; // text saved on entering history navigation
146
+
147
+ // ── Icon helpers ─────────────────────────────────────────────────────────────
148
+
149
+ /** Returns true when the icon string looks like an image URL / path. */
150
+ function isImageIcon(icon: string): boolean {
151
+ return (
152
+ /^(?:https?:\/\/|\/|\.\/|data:image\/)/.test(icon) ||
153
+ /\.(?:png|jpe?g|gif|webp|svg|avif)$/i.test(icon)
154
+ );
155
+ }
156
+
157
+ // ── Load available characters on mount ─────────────────────────────────────
158
+
159
+ onMount(async () => {
160
+ // Focus the textarea immediately so arrow-key history works on open
161
+ inputEl?.focus();
162
+ try {
163
+ const res = await fetch(withBase(`${aiUrl}/characters`));
164
+ if (res.ok) {
165
+ const data = await res.json();
166
+ characters = data.characters ?? [];
167
+ // Restore saved character if it exists, else fall back to defaultCharacter or first
168
+ const savedChar = loadStoredCharacter();
169
+ const preferred = savedChar || aiCfg.defaultCharacter;
170
+ if (characters.length > 0) {
171
+ if (preferred && characters.find(c => c.id === preferred)) {
172
+ selectedCharacter = preferred;
173
+ } else {
174
+ selectedCharacter = characters[0].id;
175
+ }
176
+ }
177
+ }
178
+ } catch {
179
+ // Characters list unavailable — the selector will be hidden
180
+ }
181
+ });
182
+
183
+ // Persist messages to localStorage whenever they change
184
+ $effect(() => {
185
+ saveMessages(messages);
186
+ });
187
+
188
+ // Persist selected character whenever it changes
189
+ $effect(() => {
190
+ saveCharacter(selectedCharacter);
191
+ });
192
+
193
+ // Expose handle to parent (hasMessages + clear fn)
194
+ $effect(() => {
195
+ chatHandle = { clear: clearHistory, hasMessages: messages.length > 0 };
196
+ });
197
+
198
+ // Scroll to bottom whenever a new message appears
199
+ $effect(() => {
200
+ if (messages.length > 0) {
201
+ void messages[messages.length - 1]; // track reactively
202
+ requestAnimationFrame(() => {
203
+ if (messagesEl) {
204
+ messagesEl.scrollTop = messagesEl.scrollHeight;
205
+ }
206
+ });
207
+ }
208
+ });
209
+
210
+ // ── Submit ─────────────────────────────────────────────────────────────────
211
+
212
+ async function submit() {
213
+ const q = query.trim();
214
+ if (!q || isLoading) return;
215
+
216
+ // Save to history (avoid consecutive duplicates)
217
+ if (q !== queryHistory[queryHistory.length - 1]) {
218
+ queryHistory = [...queryHistory, q].slice(-MAX_QUERY_HISTORY);
219
+ saveQueryHistory(queryHistory);
220
+ }
221
+ historyIdx = -1;
222
+ historyDraft = '';
223
+
224
+ query = "";
225
+ messages = [...messages, { id: ++counter, role: "user", content: q }];
226
+ isLoading = true;
227
+ const assistantId = ++counter;
228
+
229
+ try {
230
+ const res = await fetch(withBase(`${aiUrl}/ask`), {
231
+ method: "POST",
232
+ headers: { "Content-Type": "application/json" },
233
+ body: JSON.stringify({
234
+ query: q,
235
+ character: selectedCharacter,
236
+ locale: localeSrcDir,
237
+ }),
238
+ });
239
+
240
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
241
+
242
+ const data: AiResponse = await res.json();
243
+ messages = [
244
+ ...messages,
245
+ {
246
+ id: assistantId,
247
+ role: "assistant",
248
+ content: data.answer,
249
+ sources: data.sources,
250
+ character: data.character,
251
+ },
252
+ ];
253
+ } catch (err) {
254
+ console.error("[AI Chat]", err);
255
+ messages = [
256
+ ...messages,
257
+ {
258
+ id: assistantId,
259
+ role: "assistant",
260
+ content: errorText,
261
+ error: true,
262
+ },
263
+ ];
264
+ } finally {
265
+ isLoading = false;
266
+ requestAnimationFrame(() => inputEl?.focus());
267
+ }
268
+ }
269
+
270
+ function handleKeydown(e: KeyboardEvent) {
271
+ if (e.key === "Enter" && !e.shiftKey) {
272
+ e.preventDefault();
273
+ submit();
274
+ return;
275
+ }
276
+ if (e.key === "Escape") {
277
+ onClose();
278
+ return;
279
+ }
280
+
281
+ // Arrow-key history navigation
282
+ if ((e.key === "ArrowUp" || e.key === "ArrowDown") && queryHistory.length > 0) {
283
+ const ta = inputEl;
284
+ const atTopLine = !ta || ta.selectionStart === 0 ||
285
+ !ta.value.slice(0, ta.selectionStart).includes('\n');
286
+ const atBottomLine = !ta || ta.selectionEnd === ta.value.length ||
287
+ !ta.value.slice(ta.selectionEnd).includes('\n');
288
+
289
+ if (e.key === "ArrowUp" && (atTopLine || historyIdx >= 0)) {
290
+ e.preventDefault();
291
+ if (historyIdx === -1) {
292
+ historyDraft = query;
293
+ historyIdx = 0;
294
+ } else if (historyIdx < queryHistory.length - 1) {
295
+ historyIdx++;
296
+ }
297
+ query = queryHistory[queryHistory.length - 1 - historyIdx];
298
+ requestAnimationFrame(() => {
299
+ if (inputEl) inputEl.setSelectionRange(0, inputEl.value.length);
300
+ });
301
+ return;
302
+ }
303
+
304
+ if (e.key === "ArrowDown" && historyIdx >= 0 && atBottomLine) {
305
+ e.preventDefault();
306
+ if (historyIdx === 0) {
307
+ historyIdx = -1;
308
+ query = historyDraft;
309
+ } else {
310
+ historyIdx--;
311
+ query = queryHistory[queryHistory.length - 1 - historyIdx];
312
+ }
313
+ requestAnimationFrame(() => {
314
+ if (inputEl) inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length);
315
+ });
316
+ return;
317
+ }
318
+ }
319
+ }
320
+
321
+ function clearHistory() {
322
+ messages = [];
323
+ try { localStorage.removeItem(STORAGE_KEY); } catch { /* ignore */ }
324
+ requestAnimationFrame(() => inputEl?.focus());
325
+ }
326
+
327
+ function navigateTo(source: AiSource) {
328
+ onNavigate(source.pageId, source.sectionAnchor || undefined);
329
+ onClose();
330
+ }
331
+
332
+ function currentCharacter(): AiCharacter | undefined {
333
+ return characters.find(c => c.id === selectedCharacter);
334
+ }
335
+
336
+ function characterById(id?: string): AiCharacter | undefined {
337
+ return characters.find(c => c.id === id);
338
+ }
339
+
340
+ // ── Markdown answer renderer ────────────────────────────────────────────────
341
+
342
+ function escHtml(s: string): string {
343
+ return s
344
+ .replace(/&/g, '&amp;')
345
+ .replace(/</g, '&lt;')
346
+ .replace(/>/g, '&gt;')
347
+ .replace(/"/g, '&quot;');
348
+ }
349
+
350
+ /** Sanitize a URL from LLM output: allow relative paths and http(s), normalize //. */
351
+ function sanitizeUrl(url: string): string {
352
+ const trimmed = url.trim();
353
+ if (/^https?:\/\//i.test(trimmed)) return trimmed;
354
+ if (/^\//.test(trimmed)) return trimmed.replace(/\/\/+/g, '/');
355
+ if (/^#/.test(trimmed)) return trimmed;
356
+ return '#';
357
+ }
358
+
359
+ /** Apply inline markdown (bold, italic, code) and links to an already-HTML-escaped string. */
360
+ function processInlineEscaped(escaped: string): string {
361
+ // Bold **text** or __text__
362
+ let r = escaped
363
+ .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
364
+ .replace(/__(.+?)__/g, '<strong>$1</strong>');
365
+ // Italic *text* (not ** boundary)
366
+ r = r.replace(/\*([^*\n]+)\*/g, '<em>$1</em>');
367
+ // Inline code `code`
368
+ r = r.replace(/`([^`\n]+)`/g, '<code>$1</code>');
369
+ return r;
370
+ }
371
+
372
+ /** Process a line of text: handle links first (before escaping) then escape+inline. */
373
+ function renderLine(line: string): string {
374
+ let result = '';
375
+ let pos = 0;
376
+ // Match markdown links: [text](url)
377
+ const linkRe = /\[([^\]\n]+)\]\(([^)\n]+)\)/g;
378
+ let m: RegExpExecArray | null;
379
+
380
+ while ((m = linkRe.exec(line)) !== null) {
381
+ // Process the plain text segment before this link
382
+ result += processInlineEscaped(escHtml(line.slice(pos, m.index)));
383
+
384
+ const linkText = m[1];
385
+ const url = sanitizeUrl(m[2]);
386
+ const isExternal = /^https?:\/\//i.test(url);
387
+ result += `<a href="${escHtml(url)}"${
388
+ isExternal
389
+ ? ' target="_blank" rel="noopener noreferrer"'
390
+ : ' data-nav="1"'
391
+ }>${escHtml(linkText)}</a>`;
392
+ pos = m.index + m[0].length;
393
+ }
394
+ result += processInlineEscaped(escHtml(line.slice(pos)));
395
+ return result;
396
+ }
397
+
398
+ /**
399
+ * Convert LLM markdown answer to safe HTML.
400
+ * Handles: paragraphs, **bold**, *italic*, `code`, [links](url).
401
+ * Lists and headings are rendered as paragraphs with basic formatting.
402
+ */
403
+ function renderAnswer(text: string): string {
404
+ // Split on blank lines into paragraph blocks
405
+ const blocks = text.split(/\n{2,}/);
406
+ return blocks.map(block => {
407
+ const lines = block.split('\n');
408
+ const rendered = lines.map(line => renderLine(line)).join('<br>');
409
+ return `<p>${rendered}</p>`;
410
+ }).join('');
411
+ }
412
+
413
+ /** Handle clicks on internal navigation links inside `.ai-answer`. */
414
+ function handleAnswerClick(e: MouseEvent) {
415
+ const anchor = (e.target as HTMLElement).closest<HTMLAnchorElement>('a[data-nav]');
416
+ if (!anchor) return;
417
+ e.preventDefault();
418
+ const href = anchor.getAttribute('href') ?? '';
419
+ if (!href) return;
420
+ const hashIdx = href.indexOf('#');
421
+ const path = hashIdx >= 0 ? href.slice(0, hashIdx) : href;
422
+ const section = hashIdx >= 0 ? href.slice(hashIdx + 1) : undefined;
423
+ onNavigate(path, section || undefined);
424
+ onClose();
425
+ }
426
+ </script>
427
+
428
+ <div class="ai-chat">
429
+ {#snippet iconDisplay(ic: string, cls: string)}
430
+ {#if isImageIcon(ic)}
431
+ <img class="{cls} icon-img" src={ic} alt="" aria-hidden="true" />
432
+ {:else}
433
+ <span class={cls} aria-hidden="true">{ic}</span>
434
+ {/if}
435
+ {/snippet}
436
+
437
+ <!-- ── Header: character selector (only when > 1 character) ──────────────────── -->
438
+ {#if characters.length > 1}
439
+ <div class="ai-character-bar" role="group" aria-label="AI character">
440
+ {#each characters as char}
441
+ <button
442
+ class="ai-character-btn"
443
+ class:active={selectedCharacter === char.id}
444
+ onclick={() => (selectedCharacter = char.id)}
445
+ title={char.description}
446
+ aria-pressed={selectedCharacter === char.id}
447
+ type="button"
448
+ >
449
+ {@render iconDisplay(char.icon, 'char-icon')}
450
+ <span class="char-name">{char.name}</span>
451
+ </button>
452
+ {/each}
453
+ </div>
454
+ {/if}
455
+
456
+ <!-- ── Message area ─────────────────────────────────────────────────── -->
457
+ <div class="ai-messages" bind:this={messagesEl} aria-live="polite" aria-label={aiLabel}>
458
+ {#if messages.length === 0}
459
+ <div class="ai-start-hint">
460
+ {#if currentCharacter()}
461
+ {@render iconDisplay(currentCharacter()!.icon, 'ai-start-icon')}
462
+ {/if}
463
+ <p>{startText}</p>
464
+ </div>
465
+ {:else}
466
+ {#each messages as msg (msg.id)}
467
+ <div
468
+ class="ai-message"
469
+ class:user={msg.role === "user"}
470
+ class:assistant={msg.role === "assistant"}
471
+ class:error={msg.error}
472
+ role={msg.role === "assistant" ? "article" : undefined}
473
+ >
474
+ {#if msg.role === "assistant"}
475
+ {@const msgChar = characterById(msg.character)}
476
+ {#if msgChar}
477
+ {@render iconDisplay(msgChar.icon, 'ai-msg-icon')}
478
+ {/if}
479
+ {/if}
480
+
481
+ <div class="ai-message-body">
482
+ {#if msg.role === "user"}
483
+ <p class="ai-user-text">{msg.content}</p>
484
+ {:else}
485
+ <!-- Render answer with markdown links as clickable elements -->
486
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
487
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
488
+ <div
489
+ class="ai-answer"
490
+ onclick={handleAnswerClick}
491
+ role="article"
492
+ >{@html renderAnswer(msg.content)}</div>
493
+
494
+ {#if msg.sources && msg.sources.length > 0}
495
+ <div class="ai-sources">
496
+ <span class="ai-sources-label"
497
+ >{sourcesLabel}:</span
498
+ >
499
+ <ul class="ai-sources-list">
500
+ {#each msg.sources as src}
501
+ <li>
502
+ <!-- svelte-ignore a11y_interactive_supports_focus -->
503
+ <button
504
+ class="ai-source-btn"
505
+ onclick={() => navigateTo(src)}
506
+ type="button"
507
+ title={src.pageId +
508
+ (src.sectionAnchor
509
+ ? "#" +
510
+ src.sectionAnchor
511
+ : "")}
512
+ >
513
+ <svg
514
+ class="ai-source-icon"
515
+ xmlns="http://www.w3.org/2000/svg"
516
+ viewBox="0 0 24 24"
517
+ fill="none"
518
+ stroke="currentColor"
519
+ stroke-width="2"
520
+ aria-hidden="true"
521
+ >
522
+ <path
523
+ d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
524
+ />
525
+ <polyline
526
+ points="14 2 14 8 20 8"
527
+ />
528
+ </svg>
529
+ <span class="ai-source-title"
530
+ >{src.pageTitle}</span
531
+ >
532
+ {#if src.sectionHeading}
533
+ <span
534
+ class="ai-source-section"
535
+ > › {src.sectionHeading}</span
536
+ >
537
+ {/if}
538
+ </button>
539
+ </li>
540
+ {/each}
541
+ </ul>
542
+ </div>
543
+ {/if}
544
+ {/if}
545
+ </div>
546
+ </div>
547
+ {/each}
548
+
549
+ {#if isLoading}
550
+ <div class="ai-message assistant">
551
+ {#if currentCharacter()}
552
+ {@render iconDisplay(currentCharacter()!.icon, 'ai-msg-icon')}
553
+ {/if}
554
+ <div class="ai-loading">
555
+ <span class="ai-spinner" aria-hidden="true"></span>
556
+ {loadingText}
557
+ </div>
558
+ </div>
559
+ {/if}
560
+ {/if}
561
+ </div>
562
+
563
+ <!-- ── Input area ───────────────────────────────────────────────────── -->
564
+ <div class="ai-input-area">
565
+ <textarea
566
+ bind:this={inputEl}
567
+ bind:value={query}
568
+ onkeydown={handleKeydown}
569
+ rows={2}
570
+ class="ai-textarea"
571
+ {placeholder}
572
+ disabled={isLoading}
573
+ autocomplete="off"
574
+ spellcheck="false"
575
+ aria-label={placeholder}
576
+ ></textarea>
577
+ <button
578
+ class="ai-send-btn"
579
+ onclick={submit}
580
+ disabled={!query.trim() || isLoading}
581
+ type="button"
582
+ title={sendLabel}
583
+ aria-label={sendLabel}
584
+ >
585
+ <svg
586
+ xmlns="http://www.w3.org/2000/svg"
587
+ viewBox="0 0 24 24"
588
+ fill="none"
589
+ stroke="currentColor"
590
+ stroke-width="2"
591
+ aria-hidden="true"
592
+ >
593
+ <line x1="22" y1="2" x2="11" y2="13" />
594
+ <polygon points="22 2 15 22 11 13 2 9 22 2" />
595
+ </svg>
596
+ </button>
597
+ </div>
598
+ </div>
599
+
600
+ <style lang="scss">
601
+ .ai-chat {
602
+ display: flex;
603
+ flex-direction: column;
604
+ flex: 1;
605
+ min-height: 0;
606
+ overflow: hidden;
607
+ }
608
+
609
+ /* ── Character selector ──────────────────────────────────── */
610
+
611
+ .ai-character-bar {
612
+ display: flex;
613
+ flex-wrap: wrap;
614
+ gap: 0.3rem;
615
+ padding: 0.5rem 0.75rem;
616
+ border-bottom: 1px solid var(--greg-border-color);
617
+ flex-shrink: 0;
618
+ }
619
+
620
+ .ai-character-btn {
621
+ display: inline-flex;
622
+ align-items: center;
623
+ gap: 0.3rem;
624
+ padding: 0.25rem 0.6rem;
625
+ border-radius: 999px;
626
+ border: 1px solid var(--greg-border-color);
627
+ background: transparent;
628
+ color: var(--greg-menu-section-color);
629
+ cursor: pointer;
630
+ font-size: 0.72rem;
631
+ font-family: inherit;
632
+ white-space: nowrap;
633
+ transition:
634
+ border-color 0.15s,
635
+ color 0.15s,
636
+ background 0.15s;
637
+
638
+ &:hover {
639
+ border-color: var(--greg-accent);
640
+ color: var(--greg-accent);
641
+ }
642
+
643
+ &.active {
644
+ background: var(--greg-accent);
645
+ border-color: var(--greg-accent);
646
+ color: #fff;
647
+ }
648
+ }
649
+
650
+ .char-icon {
651
+ font-size: 2rem;
652
+ line-height: 1;
653
+ }
654
+
655
+ .char-name {
656
+ font-weight: 500;
657
+ }
658
+
659
+ /* ── Message area ─────────────────────────────────────────── */
660
+
661
+ .ai-messages {
662
+ flex: 1;
663
+ overflow-y: auto;
664
+ padding: 0.75rem;
665
+ display: flex;
666
+ flex-direction: column;
667
+ gap: 0.65rem;
668
+ min-height: 120px;
669
+ }
670
+
671
+ .ai-start-hint {
672
+ display: flex;
673
+ flex-direction: column;
674
+ align-items: center;
675
+ justify-content: center;
676
+ flex: 1;
677
+ gap: 0.5rem;
678
+ color: var(--greg-menu-section-color);
679
+ font-size: 0.8rem;
680
+ text-align: center;
681
+ padding: 2rem 1.5rem;
682
+
683
+ p {
684
+ margin: 0;
685
+ line-height: 1.5;
686
+ }
687
+ }
688
+
689
+ .ai-start-icon {
690
+ font-size: 3.2rem;
691
+ line-height: 1;
692
+ }
693
+
694
+ .ai-message {
695
+ display: flex;
696
+ align-items: flex-start;
697
+ gap: 0.4rem;
698
+
699
+ &.user {
700
+ flex-direction: row-reverse;
701
+ }
702
+
703
+ &.assistant {
704
+ flex-direction: row;
705
+ }
706
+ }
707
+
708
+ .ai-msg-icon {
709
+ font-size: 3rem;
710
+ line-height: 1;
711
+ padding-top: 0.1rem;
712
+ flex-shrink: 0;
713
+ }
714
+
715
+ .ai-message-body {
716
+ max-width: 90%;
717
+ display: flex;
718
+ flex-direction: column;
719
+ gap: 0.4rem;
720
+ }
721
+
722
+ .ai-user-text {
723
+ background: var(--greg-accent);
724
+ color: #fff;
725
+ padding: 0.5rem 0.85rem;
726
+ border-radius: 12px 12px 2px 12px;
727
+ font-size: 0.875rem;
728
+ line-height: 1.5;
729
+ margin: 0;
730
+ word-break: break-word;
731
+ }
732
+
733
+ .ai-answer {
734
+ font-size: 0.875rem;
735
+ line-height: 1.65;
736
+ color: var(--greg-color);
737
+ word-break: break-word;
738
+ background: var(--greg-menu-hover-background);
739
+ padding: 0.65rem 0.9rem;
740
+ border-radius: 2px 12px 12px 12px;
741
+
742
+ :global(p) {
743
+ margin: 0 0 0.45em;
744
+ &:last-child { margin-bottom: 0; }
745
+ }
746
+
747
+ :global(strong) { font-weight: 700; }
748
+ :global(em) { font-style: italic; }
749
+
750
+ :global(code) {
751
+ font-family: var(--vp-font-family-mono, 'Fira Code', 'Cascadia Code', monospace);
752
+ font-size: 0.82em;
753
+ background: var(--greg-code-block-bg, rgba(0, 0, 0, 0.06));
754
+ padding: 0.1em 0.3em;
755
+ border-radius: 3px;
756
+ }
757
+
758
+ :global(a[data-nav]) {
759
+ color: var(--greg-accent);
760
+ text-decoration: underline;
761
+ cursor: pointer;
762
+ &:hover { opacity: 0.8; }
763
+ }
764
+
765
+ :global(a[target='_blank']) {
766
+ color: var(--greg-accent);
767
+ text-decoration: underline;
768
+ }
769
+ }
770
+
771
+ .error .ai-answer {
772
+ color: var(--greg-danger-text, #e53e3e);
773
+ border-left-color: var(--greg-danger-text, #e53e3e);
774
+ }
775
+
776
+ /* ── Sources ──────────────────────────────────────────────── */
777
+
778
+ .ai-sources {
779
+ font-size: 0.72rem;
780
+ }
781
+
782
+ .ai-sources-label {
783
+ color: var(--greg-menu-section-color);
784
+ font-weight: 700;
785
+ text-transform: uppercase;
786
+ letter-spacing: 0.06em;
787
+ }
788
+
789
+ .ai-sources-list {
790
+ list-style: none;
791
+ margin: 0.3rem 0 0;
792
+ padding: 0;
793
+ display: flex;
794
+ flex-direction: column;
795
+ gap: 0.15rem;
796
+ }
797
+
798
+ .ai-source-btn {
799
+ display: inline-flex;
800
+ align-items: center;
801
+ gap: 0.3rem;
802
+ color: var(--greg-accent);
803
+ background: none;
804
+ border: none;
805
+ cursor: pointer;
806
+ padding: 0;
807
+ font-size: 0.72rem;
808
+ font-family: inherit;
809
+ text-align: left;
810
+
811
+ &:hover {
812
+ text-decoration: underline;
813
+ }
814
+ }
815
+
816
+ .ai-source-icon {
817
+ width: 11px;
818
+ height: 11px;
819
+ flex-shrink: 0;
820
+ }
821
+
822
+ .ai-source-title {
823
+ font-weight: 600;
824
+ }
825
+
826
+ .ai-source-section {
827
+ color: var(--greg-menu-section-color);
828
+ }
829
+
830
+ /* ── Loading state ────────────────────────────────────────── */
831
+
832
+ .ai-loading {
833
+ display: flex;
834
+ align-items: center;
835
+ gap: 0.5rem;
836
+ color: var(--greg-menu-section-color);
837
+ font-size: 0.8rem;
838
+ padding: 0.4rem 0;
839
+ }
840
+
841
+ .ai-spinner {
842
+ display: inline-block;
843
+ width: 1em;
844
+ height: 1em;
845
+ border: 2px solid var(--greg-border-color);
846
+ border-top-color: var(--greg-accent);
847
+ border-radius: 50%;
848
+ animation: spin 0.6s linear infinite;
849
+ flex-shrink: 0;
850
+ }
851
+
852
+ @keyframes spin {
853
+ to {
854
+ transform: rotate(360deg);
855
+ }
856
+ }
857
+
858
+ /* ── Input area ───────────────────────────────────────────── */
859
+
860
+ .ai-input-area {
861
+ display: flex;
862
+ align-items: flex-end;
863
+ gap: 0.5rem;
864
+ padding: 0.625rem 0.75rem;
865
+ border-top: 1px solid var(--greg-border-color);
866
+ flex-shrink: 0;
867
+ }
868
+
869
+ .ai-textarea {
870
+ flex: 1;
871
+ background: var(--greg-menu-hover-background);
872
+ border: 1px solid var(--greg-border-color);
873
+ border-radius: 8px;
874
+ padding: 0.5rem 0.7rem;
875
+ color: var(--greg-color);
876
+ font-size: 0.875rem;
877
+ font-family: inherit;
878
+ line-height: 1.5;
879
+ resize: none;
880
+ outline: none;
881
+ transition: border-color 0.15s;
882
+ min-width: 0;
883
+
884
+ &::placeholder {
885
+ color: var(--greg-menu-section-color);
886
+ }
887
+
888
+ &:focus {
889
+ border-color: var(--greg-accent);
890
+ }
891
+
892
+ &:disabled {
893
+ opacity: 0.6;
894
+ }
895
+ }
896
+
897
+ .ai-send-btn {
898
+ width: 36px;
899
+ align-self: stretch;
900
+ min-height: 36px;
901
+ border-radius: 8px;
902
+ border: none;
903
+ background: var(--greg-accent);
904
+ color: #fff;
905
+ cursor: pointer;
906
+ display: flex;
907
+ align-items: center;
908
+ justify-content: center;
909
+ flex-shrink: 0;
910
+ transition: opacity 0.15s;
911
+
912
+ svg {
913
+ width: 14px;
914
+ height: 14px;
915
+ }
916
+
917
+ &:disabled {
918
+ opacity: 0.35;
919
+ cursor: default;
920
+ }
921
+
922
+ &:not(:disabled):hover {
923
+ opacity: 0.82;
924
+ }
925
+ }
926
+
927
+ /* ── Image icons ──────────────────────────────────────────── */
928
+
929
+ .icon-img {
930
+ width: 1em;
931
+ height: 1em;
932
+ object-fit: cover;
933
+ border-radius: 50%;
934
+ display: block;
935
+ }
936
+ </style>