jekyll-theme-zer0 1.15.0 → 1.16.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.
@@ -0,0 +1,579 @@
1
+ <!--
2
+ ===================================================================
3
+ AI CHAT - Frontend AI Chatbot with Page Context
4
+ ===================================================================
5
+
6
+ File: ai-chat.html
7
+ Path: _includes/components/ai-chat.html
8
+ Purpose: Provides an AI-powered chat widget that includes the
9
+ current page context in conversations. Uses the OpenAI
10
+ Chat Completions API with an API key injected at build
11
+ time from GitHub repository secrets.
12
+
13
+ Template Logic:
14
+ - Renders a floating chat button and expandable chat panel
15
+ - Collects current page metadata (title, description, content,
16
+ URL, categories, tags) as system context for the AI
17
+ - Sends messages to the configured AI provider endpoint
18
+ - Rendering is gated by ai_chat.enabled and either:
19
+ - Proxy mode: auth_mode: "proxy" with proxy_ready: true (recommended), or
20
+ - Direct mode: a non-empty ai_chat.api_key for client-side API access
21
+
22
+ Dependencies:
23
+ - Bootstrap 5 for UI components and styling
24
+ - Bootstrap Icons for chat button icon
25
+ - site.ai_chat configuration from _config.yml
26
+
27
+ Security:
28
+ - API key is injected at build time, not stored in source
29
+ - IMPORTANT: Client-side API keys are visible in page source.
30
+ For production, route requests through a server-side proxy
31
+ by changing the 'endpoint' config to your proxy URL.
32
+
33
+ Accessibility:
34
+ - ARIA labels for screen readers
35
+ - Keyboard navigation support (Escape to close)
36
+ - Focus management when opening/closing the chat panel
37
+ ===================================================================
38
+ -->
39
+
40
+ {% assign ai_auth_mode = site.ai_chat.auth_mode | default: 'proxy' %}
41
+ {% comment %}
42
+ Boolean expressions are NOT evaluated inside assign tags (Liquid stores a
43
+ truthy string instead) — compute the render flag with if-tags, which do
44
+ support and/comparisons. Render only when chat is enabled AND a usable
45
+ auth path exists: proxy mode with a deployed proxy, or direct mode with a
46
+ non-empty api_key.
47
+ {% endcomment %}
48
+ {% assign ai_render = false %}
49
+ {% if site.ai_chat.enabled %}
50
+ {% if ai_auth_mode == 'proxy' and site.ai_chat.proxy_ready %}
51
+ {% assign ai_render = true %}
52
+ {% elsif ai_auth_mode == 'direct' and site.ai_chat.api_key and site.ai_chat.api_key != '' %}
53
+ {% assign ai_render = true %}
54
+ {% endif %}
55
+ {% endif %}
56
+ {% if ai_render %}
57
+
58
+ <!-- AI Chat Toggle Button -->
59
+ <button id="aiChatToggle"
60
+ class="ai-chat-toggle btn btn-primary rounded-circle shadow-lg"
61
+ type="button"
62
+ aria-label="Open AI chat assistant"
63
+ aria-expanded="false"
64
+ aria-controls="aiChatPanel">
65
+ <i class="{{ site.ai_chat.icon | default: 'bi-robot' }} ai-chat-icon-open" aria-hidden="true"></i>
66
+ <i class="bi-x-lg ai-chat-icon-close d-none" aria-hidden="true"></i>
67
+ </button>
68
+
69
+ <!-- AI Chat Panel -->
70
+ <div id="aiChatPanel"
71
+ class="ai-chat-panel shadow-lg rounded-3"
72
+ role="dialog"
73
+ aria-labelledby="aiChatTitle"
74
+ aria-hidden="true">
75
+
76
+ <!-- Chat Header -->
77
+ <div class="ai-chat-header d-flex align-items-center justify-content-between p-3">
78
+ <div class="d-flex align-items-center gap-2">
79
+ <i class="{{ site.ai_chat.icon | default: 'bi-robot' }} fs-5" aria-hidden="true"></i>
80
+ <h6 id="aiChatTitle" class="mb-0 fw-semibold">Zer0 Assistant</h6>
81
+ </div>
82
+ <button type="button"
83
+ class="btn btn-sm btn-outline-light border-0"
84
+ id="aiChatClose"
85
+ aria-label="Close chat">
86
+ <i class="bi-x-lg" aria-hidden="true"></i>
87
+ </button>
88
+ </div>
89
+
90
+ <!-- Chat Messages -->
91
+ <div id="aiChatMessages" class="ai-chat-messages p-3" role="log" aria-live="polite">
92
+ <!-- Welcome message inserted by JS -->
93
+ </div>
94
+
95
+ <!-- Chat Input -->
96
+ <div class="ai-chat-input p-3">
97
+ <form id="aiChatForm" class="d-flex gap-2" autocomplete="off">
98
+ <label for="aiChatInput" class="visually-hidden">Chat message</label>
99
+ <input type="text"
100
+ id="aiChatInput"
101
+ class="form-control form-control-sm"
102
+ placeholder="{{ site.ai_chat.placeholder | default: 'Ask about this page...' }}"
103
+ maxlength="500"
104
+ required>
105
+ <button type="submit"
106
+ class="btn btn-primary btn-sm"
107
+ id="aiChatSend"
108
+ aria-label="Send message">
109
+ <i class="bi-send" aria-hidden="true"></i>
110
+ </button>
111
+ </form>
112
+ <p class="ai-chat-disclaimer text-muted mt-1 mb-0">
113
+ <small>AI responses may be inaccurate. Verify important information.</small>
114
+ </p>
115
+ </div>
116
+ </div>
117
+
118
+ <!-- Page Context Data (generated by Jekyll at build time) -->
119
+ <script id="aiChatPageContext" type="application/json">
120
+ {
121
+ "page_title": {{ page.title | default: site.title | jsonify }},
122
+ "page_description": {{ page.description | default: page.excerpt | strip_html | default: "" | jsonify }},
123
+ "page_url": {{ page.url | absolute_url | jsonify }},
124
+ "page_categories": {{ page.categories | default: "" | jsonify }},
125
+ "page_tags": {{ page.tags | default: "" | jsonify }},
126
+ "page_layout": {{ page.layout | default: "" | jsonify }},
127
+ "page_date": {{ page.date | date: "%Y-%m-%d" | default: "" | jsonify }},
128
+ "site_title": {{ site.title | jsonify }},
129
+ "site_description": {{ site.description | strip_html | strip | truncate: 200 | jsonify }}
130
+ }
131
+ </script>
132
+
133
+ {% comment %}
134
+ Page content is captured and truncated to stay within context limits.
135
+ HTML is stripped to send clean text to the AI.
136
+ {% endcomment %}
137
+ {% capture page_text %}{{ content | strip_html | strip_newlines | normalize_whitespace }}{% endcapture %}
138
+ {% assign context_length = site.ai_chat.context_max_length | default: 2000 %}
139
+ <script id="aiChatPageContent" type="text/plain">{{ page_text | truncate: context_length | replace: '</script>', '<\/script>' }}</script>
140
+
141
+ <script>
142
+ // AI Chat Widget
143
+ (function() {
144
+ 'use strict';
145
+
146
+ // Configuration from Jekyll
147
+ const CONFIG = {
148
+ endpoint: {{ site.ai_chat.endpoint | default: "/api/chat" | jsonify }},
149
+ authMode: {{ site.ai_chat.auth_mode | default: "proxy" | jsonify }},
150
+ apiKey: {{ site.ai_chat.api_key | jsonify }},
151
+ model: {{ site.ai_chat.model | default: "gpt-4o-mini" | jsonify }},
152
+ maxTokens: {{ site.ai_chat.max_tokens | default: 1024 }},
153
+ temperature: {{ site.ai_chat.temperature | default: 0.7 }},
154
+ strictContext: {{ site.ai_chat.strict_context | default: true | jsonify }},
155
+ outOfScopeMessage: {{ site.ai_chat.out_of_scope_message | default: "I can only answer from the content on this page." | jsonify }},
156
+ systemPrompt: {{ site.ai_chat.system_prompt | default: "You are a helpful assistant." | jsonify }},
157
+ welcomeMessage: {{ site.ai_chat.welcome_message | default: "Hi! Ask me anything about this page." | jsonify }}
158
+ };
159
+
160
+ // State
161
+ let isOpen = false;
162
+ let isLoading = false;
163
+ let lastSendTime = 0;
164
+ const SEND_COOLDOWN_MS = 1000; // Minimum time between sends
165
+ const MAX_MESSAGES = 40; // Maximum number of messages to retain in history
166
+ const _messages = [];
167
+ const messages = new Proxy(_messages, {
168
+ set(target, prop, value) {
169
+ const result = Reflect.set(target, prop, value);
170
+
171
+ // When the array grows (numeric index or length change), enforce the cap
172
+ if (prop === 'length' || !Number.isNaN(Number(prop))) {
173
+ const excess = target.length - MAX_MESSAGES;
174
+ if (excess > 0) {
175
+ // Remove oldest messages, keep only the most recent MAX_MESSAGES
176
+ target.splice(0, excess);
177
+ }
178
+ }
179
+
180
+ return result;
181
+ }
182
+ });
183
+ // DOM Elements
184
+ const toggle = document.getElementById('aiChatToggle');
185
+ const panel = document.getElementById('aiChatPanel');
186
+ const messagesContainer = document.getElementById('aiChatMessages');
187
+ const form = document.getElementById('aiChatForm');
188
+ const input = document.getElementById('aiChatInput');
189
+ const closeBtn = document.getElementById('aiChatClose');
190
+ const iconOpen = toggle.querySelector('.ai-chat-icon-open');
191
+ const iconClose = toggle.querySelector('.ai-chat-icon-close');
192
+
193
+ // Build page context for system message
194
+ function getPageContext() {
195
+ let context = '';
196
+ try {
197
+ const meta = JSON.parse(document.getElementById('aiChatPageContext').textContent);
198
+ const pageContent = document.getElementById('aiChatPageContent').textContent.trim();
199
+
200
+ context = 'Current page context:\n';
201
+ if (meta.page_title) context += '- Title: ' + meta.page_title + '\n';
202
+ if (meta.page_description) context += '- Description: ' + meta.page_description + '\n';
203
+ if (meta.page_url) context += '- URL: ' + meta.page_url + '\n';
204
+ if (meta.page_categories && meta.page_categories.length) {
205
+ context += '- Categories: ' + (Array.isArray(meta.page_categories) ? meta.page_categories.join(', ') : meta.page_categories) + '\n';
206
+ }
207
+ if (meta.page_tags && meta.page_tags.length) {
208
+ context += '- Tags: ' + (Array.isArray(meta.page_tags) ? meta.page_tags.join(', ') : meta.page_tags) + '\n';
209
+ }
210
+ if (meta.page_date) context += '- Date: ' + meta.page_date + '\n';
211
+ if (meta.site_title) context += '- Site: ' + meta.site_title + '\n';
212
+ if (pageContent) context += '\nPage content:\n' + pageContent + '\n';
213
+ } catch (e) {
214
+ console.warn('AI Chat: Could not parse page context', e);
215
+ }
216
+ return context;
217
+ }
218
+
219
+ // Build system message with page context
220
+ function getSystemMessage() {
221
+ const pageContext = getPageContext();
222
+ let systemContent = CONFIG.systemPrompt;
223
+ if (CONFIG.strictContext) {
224
+ systemContent += '\n\nGrounding rules:\n'
225
+ + '- Answer ONLY using the provided page context.\n'
226
+ + '- If the answer is not in the page context, reply exactly with: "' + CONFIG.outOfScopeMessage + '"\n'
227
+ + '- Do not invent facts, links, or features.\n';
228
+ }
229
+ if (pageContext) {
230
+ systemContent += '\n\n' + pageContext;
231
+ }
232
+ return { role: 'system', content: systemContent };
233
+ }
234
+
235
+ function escapeHtml(text) {
236
+ return String(text)
237
+ .replace(/&/g, '&amp;')
238
+ .replace(/</g, '&lt;')
239
+ .replace(/>/g, '&gt;')
240
+ .replace(/\"/g, '&quot;')
241
+ .replace(/'/g, '&#39;');
242
+ }
243
+
244
+ function renderAssistantMarkdown(raw) {
245
+ // Escape first, then allow a small markdown subset safely.
246
+ let safe = escapeHtml(raw || '');
247
+
248
+ safe = safe
249
+ .replace(/^###\s+(.+)$/gm, '<strong>$1</strong>')
250
+ .replace(/^##\s+(.+)$/gm, '<strong>$1</strong>')
251
+ .replace(/^#\s+(.+)$/gm, '<strong>$1</strong>')
252
+ .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
253
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
254
+ .replace(/\[(.+?)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
255
+ .replace(/^\s*[-*]\s+(.+)$/gm, '• $1')
256
+ .replace(/\n/g, '<br>');
257
+
258
+ return safe;
259
+ }
260
+
261
+ // Create a message element
262
+ function createMessageEl(role, content) {
263
+ const wrapper = document.createElement('div');
264
+ wrapper.className = 'ai-chat-message ai-chat-message--' + role + ' mb-2';
265
+
266
+ const bubble = document.createElement('div');
267
+ bubble.className = 'ai-chat-bubble p-2 rounded-3 small';
268
+ if (role === 'assistant') {
269
+ bubble.innerHTML = renderAssistantMarkdown(content);
270
+ } else {
271
+ bubble.textContent = content;
272
+ }
273
+
274
+ wrapper.appendChild(bubble);
275
+ return wrapper;
276
+ }
277
+
278
+ // Create a loading indicator
279
+ function createLoadingEl() {
280
+ const wrapper = document.createElement('div');
281
+ wrapper.className = 'ai-chat-message ai-chat-message--assistant mb-2';
282
+ wrapper.id = 'aiChatLoading';
283
+
284
+ const bubble = document.createElement('div');
285
+ bubble.className = 'ai-chat-bubble p-2 rounded-3 small';
286
+ bubble.innerHTML = '<span class="ai-chat-typing"><span>.</span><span>.</span><span>.</span></span>';
287
+
288
+ wrapper.appendChild(bubble);
289
+ return wrapper;
290
+ }
291
+
292
+ // Append message to chat
293
+ function appendMessage(role, content) {
294
+ const el = createMessageEl(role, content);
295
+ messagesContainer.appendChild(el);
296
+ messagesContainer.scrollTop = messagesContainer.scrollHeight;
297
+ return el;
298
+ }
299
+
300
+ // Remove loading indicator
301
+ function removeLoading() {
302
+ const loading = document.getElementById('aiChatLoading');
303
+ if (loading) loading.remove();
304
+ }
305
+
306
+ // Send message to AI
307
+ async function sendMessage(userMessage) {
308
+ if (isLoading || !userMessage.trim()) return;
309
+
310
+ // Enforce cooldown between sends
311
+ const now = Date.now();
312
+ if (now - lastSendTime < SEND_COOLDOWN_MS) return;
313
+ lastSendTime = now;
314
+
315
+ // Add user message to UI and history
316
+ appendMessage('user', userMessage);
317
+ messages.push({ role: 'user', content: userMessage });
318
+
319
+ // Show loading
320
+ isLoading = true;
321
+ input.disabled = true;
322
+ messagesContainer.appendChild(createLoadingEl());
323
+ messagesContainer.scrollTop = messagesContainer.scrollHeight;
324
+
325
+ try {
326
+ const response = await fetch(CONFIG.endpoint, {
327
+ method: 'POST',
328
+ headers: (function() {
329
+ const requestHeaders = {
330
+ 'Content-Type': 'application/json'
331
+ };
332
+ if (CONFIG.authMode === 'direct' && CONFIG.apiKey) {
333
+ requestHeaders.Authorization = 'Bearer ' + CONFIG.apiKey;
334
+ }
335
+ return requestHeaders;
336
+ })(),
337
+ body: JSON.stringify({
338
+ model: CONFIG.model,
339
+ messages: [getSystemMessage(), ...messages],
340
+ max_tokens: CONFIG.maxTokens,
341
+ temperature: CONFIG.temperature
342
+ })
343
+ });
344
+
345
+ removeLoading();
346
+
347
+ if (!response.ok) {
348
+ const errorData = await response.json().catch(function() { return {}; });
349
+ var statusMessages = {
350
+ 401: 'Authentication failed. The API key may be invalid or missing.',
351
+ 429: 'Rate limit exceeded. Please wait a moment and try again.',
352
+ 500: 'The AI service is temporarily unavailable. Please try again later.',
353
+ 503: 'The AI service is temporarily unavailable. Please try again later.'
354
+ };
355
+ throw new Error(errorData.error?.message || statusMessages[response.status] || 'API request failed (' + response.status + ')');
356
+ }
357
+
358
+ const data = await response.json();
359
+ let assistantMessage = data.choices?.[0]?.message?.content || 'Sorry, I could not generate a response.';
360
+ assistantMessage = String(assistantMessage).trim() || CONFIG.outOfScopeMessage;
361
+
362
+ // Add assistant message to UI and history
363
+ appendMessage('assistant', assistantMessage);
364
+ messages.push({ role: 'assistant', content: assistantMessage });
365
+
366
+ } catch (error) {
367
+ removeLoading();
368
+ appendMessage('assistant', 'Sorry, something went wrong: ' + error.message);
369
+ console.error('AI Chat error:', error);
370
+ } finally {
371
+ isLoading = false;
372
+ input.disabled = false;
373
+ input.focus();
374
+ }
375
+ }
376
+
377
+ // Toggle chat panel
378
+ function toggleChat() {
379
+ isOpen = !isOpen;
380
+ panel.classList.toggle('ai-chat-panel--open', isOpen);
381
+ panel.setAttribute('aria-hidden', !isOpen);
382
+ toggle.setAttribute('aria-expanded', isOpen);
383
+ iconOpen.classList.toggle('d-none', isOpen);
384
+ iconClose.classList.toggle('d-none', !isOpen);
385
+
386
+ if (isOpen) {
387
+ // Focus input when opening (short delay for CSS transition to start)
388
+ setTimeout(function() { input.focus(); }, 50);
389
+ }
390
+ }
391
+
392
+ // Initialize
393
+ function init() {
394
+ // Show welcome message
395
+ appendMessage('assistant', CONFIG.welcomeMessage);
396
+
397
+ // Event: toggle button
398
+ toggle.addEventListener('click', toggleChat);
399
+
400
+ // Event: close button
401
+ closeBtn.addEventListener('click', toggleChat);
402
+
403
+ // Event: form submit
404
+ form.addEventListener('submit', function(e) {
405
+ e.preventDefault();
406
+ const msg = input.value.trim();
407
+ if (msg) {
408
+ input.value = '';
409
+ sendMessage(msg);
410
+ }
411
+ });
412
+
413
+ // Event: Escape key closes chat
414
+ document.addEventListener('keydown', function(e) {
415
+ if (e.key === 'Escape' && isOpen) {
416
+ toggleChat();
417
+ }
418
+ });
419
+
420
+ // Event: click outside closes chat
421
+ document.addEventListener('click', function(e) {
422
+ if (isOpen && !panel.contains(e.target) && !toggle.contains(e.target)) {
423
+ toggleChat();
424
+ }
425
+ });
426
+ }
427
+
428
+ // Start when DOM is ready
429
+ if (document.readyState === 'loading') {
430
+ document.addEventListener('DOMContentLoaded', init);
431
+ } else {
432
+ init();
433
+ }
434
+ })();
435
+ </script>
436
+
437
+ <style>
438
+ /* AI Chat Toggle Button */
439
+ /* FAB stack slot above back-to-top, below the TOC FAB — driven by the
440
+ design tokens (_sass/tokens/_spacing.scss, _layers.scss) so all
441
+ floating buttons share one offset/stacking system. */
442
+ .ai-chat-toggle {
443
+ position: fixed;
444
+ bottom: calc(var(--zer0-space-fab-offset, 1rem) + var(--zer0-space-fab-size, 3.5rem) + var(--zer0-space-fab-gap, 0.75rem));
445
+ right: var(--zer0-space-fab-offset, 1rem);
446
+ z-index: var(--zer0-layer-fab-chat, 1052);
447
+ width: 3rem;
448
+ height: 3rem;
449
+ display: flex;
450
+ align-items: center;
451
+ justify-content: center;
452
+ font-size: 1.25rem;
453
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
454
+ }
455
+
456
+ .ai-chat-toggle:hover {
457
+ transform: scale(1.1);
458
+ }
459
+
460
+ /* AI Chat Panel */
461
+ .ai-chat-panel {
462
+ position: fixed;
463
+ bottom: calc(var(--zer0-space-fab-offset, 1rem) + 2 * (var(--zer0-space-fab-size, 3.5rem) + var(--zer0-space-fab-gap, 0.75rem)));
464
+ right: var(--zer0-space-fab-offset, 1rem);
465
+ z-index: var(--zer0-layer-fab-chat, 1052);
466
+ width: 360px;
467
+ max-height: 500px;
468
+ display: flex;
469
+ flex-direction: column;
470
+ background: var(--bs-body-bg, #212529);
471
+ border: 1px solid var(--bs-border-color, #495057);
472
+ opacity: 0;
473
+ visibility: hidden;
474
+ transform: translateY(1rem) scale(0.95);
475
+ transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s ease;
476
+ }
477
+
478
+ .ai-chat-panel--open {
479
+ opacity: 1;
480
+ visibility: visible;
481
+ transform: translateY(0) scale(1);
482
+ }
483
+
484
+ /* Chat Header */
485
+ .ai-chat-header {
486
+ background: var(--bs-primary, #0d6efd);
487
+ color: #fff;
488
+ border-radius: 0.375rem 0.375rem 0 0;
489
+ flex-shrink: 0;
490
+ }
491
+
492
+ /* Chat Messages */
493
+ .ai-chat-messages {
494
+ flex: 1 1 auto;
495
+ overflow-y: auto;
496
+ min-height: 200px;
497
+ max-height: 320px;
498
+ scroll-behavior: smooth;
499
+ }
500
+
501
+ /* Message Bubbles */
502
+ .ai-chat-message--user {
503
+ display: flex;
504
+ justify-content: flex-end;
505
+ }
506
+
507
+ .ai-chat-message--user .ai-chat-bubble {
508
+ background: var(--bs-primary, #0d6efd);
509
+ color: #fff;
510
+ max-width: 85%;
511
+ word-wrap: break-word;
512
+ }
513
+
514
+ .ai-chat-message--assistant .ai-chat-bubble {
515
+ background: var(--bs-tertiary-bg, #2b3035);
516
+ color: var(--bs-body-color, #dee2e6);
517
+ max-width: 85%;
518
+ word-wrap: break-word;
519
+ }
520
+
521
+ /* Chat Input */
522
+ .ai-chat-input {
523
+ border-top: 1px solid var(--bs-border-color, #495057);
524
+ flex-shrink: 0;
525
+ }
526
+
527
+ .ai-chat-disclaimer {
528
+ font-size: 0.7rem;
529
+ line-height: 1.2;
530
+ }
531
+
532
+ /* Typing Animation */
533
+ .ai-chat-typing span {
534
+ animation: aiChatTyping 1.4s infinite;
535
+ display: inline-block;
536
+ font-weight: bold;
537
+ font-size: 1.2em;
538
+ }
539
+
540
+ .ai-chat-typing span:nth-child(2) {
541
+ animation-delay: 0.2s;
542
+ }
543
+
544
+ .ai-chat-typing span:nth-child(3) {
545
+ animation-delay: 0.4s;
546
+ }
547
+
548
+ @keyframes aiChatTyping {
549
+ 0%, 60%, 100% { opacity: 0.3; transform: translateY(0); }
550
+ 30% { opacity: 1; transform: translateY(-4px); }
551
+ }
552
+
553
+ /* Mobile Responsive */
554
+ @media (max-width: 576px) {
555
+ .ai-chat-panel {
556
+ width: calc(100vw - 2rem);
557
+ right: 1rem;
558
+ bottom: 8rem;
559
+ max-height: 60vh;
560
+ }
561
+
562
+ .ai-chat-toggle {
563
+ bottom: 4.5rem;
564
+ right: 1rem;
565
+ }
566
+ }
567
+
568
+ /* Respect reduced motion */
569
+ @media (prefers-reduced-motion: reduce) {
570
+ .ai-chat-panel,
571
+ .ai-chat-toggle,
572
+ .ai-chat-typing span {
573
+ transition: none !important;
574
+ animation: none !important;
575
+ }
576
+ }
577
+ </style>
578
+
579
+ {% endif %}
@@ -260,10 +260,12 @@
260
260
  {%- endunless -%}
261
261
 
262
262
  <!-- Back to Top Button (controlled by assets/js/back-to-top.js) -->
263
+ <!-- Positioning/stacking handled by _sass/components/_back-to-top.scss
264
+ via the FAB tokens (bottom-up: back-to-top → chat toggle → TOC FAB) -->
263
265
  <a
264
266
  id="backToTopBtn"
265
267
  href="#"
266
- class="btn btn-primary position-fixed bottom-0 end-0 m-3"
268
+ class="btn btn-primary"
267
269
  aria-label="Back to top"
268
270
  title="Back to top"
269
271
  role="button"
data/_layouts/root.html CHANGED
@@ -96,6 +96,9 @@
96
96
  <!-- Privacy-compliant cookie consent banner -->
97
97
  {%- include components/cookie-consent.html -%}
98
98
 
99
+ <!-- AI Chat Assistant -->
100
+ {%- include components/ai-chat.html -%}
101
+
99
102
  <!-- JavaScript dependencies and custom scripts -->
100
103
  <div>
101
104
  <!-- Search functionality -->
@@ -60,7 +60,11 @@ module Jekyll
60
60
  'tag_base_url' => '/tags/',
61
61
  'callout_class_prefix' => 'obsidian-callout',
62
62
  'wiki_link_class' => 'wiki-link',
63
- 'broken_link_class' => 'wiki-link wiki-link-broken'
63
+ 'broken_link_class' => 'wiki-link wiki-link-broken',
64
+ # Slugs (normalised: lowercase, stripped) that are too generic to be
65
+ # useful as wiki-link targets and whose collisions should be silenced.
66
+ # Add entries in _config.yml under `obsidian.skip_slugs`.
67
+ 'skip_slugs' => []
64
68
  }.freeze
65
69
 
66
70
  # Bootstrap alert mapping per Obsidian callout type.
@@ -104,15 +108,23 @@ module Jekyll
104
108
  class Index
105
109
  attr_reader :entries
106
110
 
107
- def initialize(site)
111
+ def initialize(site, config = {})
108
112
  @entries = {}
113
+ config ||= {}
114
+ @skip_slugs = Array(config['skip_slugs'])
115
+ .map { |s| s.to_s.downcase.strip.gsub(/\s+/, ' ') }
116
+ .to_set
109
117
  build(site)
110
118
  end
111
119
 
112
120
  def build(site)
113
- # All renderable docs across collections + standalone pages.
121
+ # All renderable docs across collections + standalone HTML pages.
122
+ # Filtering to output_ext == '.html' ensures raw binary docs (e.g.
123
+ # .ipynb notebook files) are not indexed as wiki-link targets.
114
124
  items = []
115
- items.concat(site.documents) if site.respond_to?(:documents)
125
+ # Docs that don't expose output_ext (test doubles, exotic generators)
126
+ # stay indexable; real binary outputs are excluded.
127
+ items.concat(site.documents.reject { |d| d.respond_to?(:output_ext) && d.output_ext != '.html' }) if site.respond_to?(:documents)
116
128
  items.concat(site.pages.select { |p| p.output_ext == '.html' })
117
129
 
118
130
  items.each do |doc|
@@ -133,6 +145,7 @@ module Jekyll
133
145
  def register(key, url, doc)
134
146
  slug = normalize(key)
135
147
  return if slug.empty?
148
+ return if @skip_slugs.include?(slug)
136
149
 
137
150
  # First registration wins; subsequent collisions produce a deterministic
138
151
  # warning so authors can disambiguate (e.g. via `aliases:`).
@@ -449,7 +462,7 @@ module Jekyll
449
462
  return @cached_index
450
463
  end
451
464
 
452
- new_index = Index.new(site)
465
+ new_index = Index.new(site, self.config)
453
466
  @cached_fingerprint = current_fingerprint
454
467
  @cached_site_id = site.object_id
455
468
  @cached_index = new_index
@@ -18,6 +18,7 @@
18
18
 
19
19
  // Floating action buttons (stack bottom-to-top) --------------------------
20
20
  --zer0-layer-fab-back-to-top: 1050;
21
+ --zer0-layer-fab-chat: 1052;
21
22
  --zer0-layer-fab-toc: 1055;
22
23
  --zer0-layer-fab-local-graph: 1060;
23
24