/** * Terminal Emulator for An Oliphant Never Forgets * Provides a CLI-like interface for navigating the blog */ class Terminal { // Escape HTML entities to prevent XSS when inserting user input into innerHTML static escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } constructor(options = {}) { this.inputElement = options.input || document.getElementById('command-input'); this.outputElement = options.output || document.getElementById('terminal-output'); this.promptElement = options.prompt || document.getElementById('prompt-path'); this.history = []; this.historyIndex = -1; this.currentPath = this.getPathFromUrl(); // Command definitions this.commands = { help: this.cmdHelp.bind(this), ls: this.cmdLs.bind(this), cd: this.cmdCd.bind(this), cat: this.cmdCat.bind(this), grep: this.cmdGrep.bind(this), clear: this.cmdClear.bind(this), pwd: this.cmdPwd.bind(this), history: this.cmdHistory.bind(this), now: this.cmdNow.bind(this), whoami: this.cmdWhoami.bind(this), date: this.cmdDate.bind(this), tree: this.cmdTree.bind(this), }; // Cache for content items (for autocomplete) this.contentCache = {}; // Valid directories this.directories = ['/', '/notes', '/til', '/bookmarks', '/how-to', '/tags']; // Autocomplete selection state this.autocompleteState = null; this.init(); } init() { // Store bound handlers so destroy() can remove them this._boundHandleKeyDown = this.handleKeyDown.bind(this); this._boundTerminalClick = (e) => { if (!e.target.closest('.autocomplete-menu')) { this.inputElement?.focus(); } }; this._boundDocumentClick = (e) => { if (e.target.classList.contains('cmd-link')) { const cmd = e.target.dataset.cmd; if (cmd) { this.executeCommand(cmd); } } if (e.target.closest('.autocomplete-item')) { const item = e.target.closest('.autocomplete-item'); const slug = item.dataset.slug; if (slug) { this.selectAutocompleteItem(slug); } } }; if (this.inputElement) { this.inputElement.addEventListener('keydown', this._boundHandleKeyDown); this.inputElement.focus(); document.querySelector('.terminal')?.addEventListener('click', this._boundTerminalClick); } document.addEventListener('click', this._boundDocumentClick); } destroy() { if (this.inputElement) { this.inputElement.removeEventListener('keydown', this._boundHandleKeyDown); } document.querySelector('.terminal')?.removeEventListener('click', this._boundTerminalClick); document.removeEventListener('click', this._boundDocumentClick); this.closeAutocomplete(); } handleKeyDown(e) { // If autocomplete menu is open, handle navigation if (this.autocompleteState) { switch (e.key) { case 'ArrowUp': e.preventDefault(); this.navigateAutocomplete(-1); return; case 'ArrowDown': e.preventDefault(); this.navigateAutocomplete(1); return; case 'Enter': e.preventDefault(); this.confirmAutocompleteSelection(); return; case 'Escape': e.preventDefault(); this.closeAutocomplete(); return; case 'Tab': e.preventDefault(); this.navigateAutocomplete(1); return; } } switch (e.key) { case 'Enter': e.preventDefault(); this.processInput(); break; case 'ArrowUp': e.preventDefault(); this.navigateHistory(-1); break; case 'ArrowDown': e.preventDefault(); this.navigateHistory(1); break; case 'Tab': e.preventDefault(); this.autocomplete(); break; case 'c': if (e.ctrlKey) { e.preventDefault(); this.cancelInput(); } break; case 'l': if (e.ctrlKey) { e.preventDefault(); this.cmdClear(); } break; } } processInput() { const input = this.inputElement.value.trim(); if (input) { this.history.push(input); this.historyIndex = this.history.length; this.appendOutput(this.formatPrompt() + Terminal.escapeHtml(input)); this.executeCommand(input); } this.inputElement.value = ''; } async executeCommand(input) { const parts = input.split(/\s+/); const cmd = parts[0].toLowerCase(); const args = parts.slice(1); if (this.commands[cmd]) { // Handle both sync and async commands await this.commands[cmd](args); } else if (cmd) { // Check if it's a navigation shortcut if (this.directories.includes('/' + cmd) || this.directories.includes(cmd)) { this.navigate('/' + cmd.replace(/^\//, '')); } else { this.appendOutput(`command not found: ${Terminal.escapeHtml(cmd)}`); this.appendOutput(`Type 'help' for available commands.`); } } } navigateHistory(direction) { const newIndex = this.historyIndex + direction; if (newIndex >= 0 && newIndex < this.history.length) { this.historyIndex = newIndex; this.inputElement.value = this.history[newIndex]; } else if (newIndex >= this.history.length) { this.historyIndex = this.history.length; this.inputElement.value = ''; } } async autocomplete() { const input = this.inputElement.value; const parts = input.split(/\s+/); if (parts.length === 1) { // Autocomplete commands const matches = Object.keys(this.commands).filter(c => c.startsWith(parts[0])); if (matches.length === 1) { this.inputElement.value = matches[0] + ' '; } else if (matches.length > 1) { this.showAutocompleteMenu(matches.map(m => ({ slug: m, title: '', type: 'command' })), 'command'); } } else if (parts.length === 2 && (parts[0] === 'cd' || parts[0] === 'ls')) { // Autocomplete directories const matches = this.directories.filter(d => d.startsWith(parts[1]) || d.startsWith('/' + parts[1])); if (matches.length === 1) { this.inputElement.value = parts[0] + ' ' + matches[0]; } else if (matches.length > 1) { this.showAutocompleteMenu(matches.map(m => ({ slug: m, title: '', type: 'directory' })), 'directory'); } } else if (parts.length === 2 && parts[0] === 'cat') { // Autocomplete content slugs await this.autocompleteContent(parts[1]); } } async autocompleteContent(partial) { // Fetch content slugs if not cached if (Object.keys(this.contentCache).length === 0) { try { const response = await fetch('/api/content-slugs'); this.contentCache = await response.json(); } catch (e) { this.appendOutput(`Failed to fetch content list`); return; } } // Determine which content type to search based on current path const pathToType = { '/notes': 'notes', '/til': 'til', '/bookmarks': 'bookmarks', '/how-to': 'how_to', }; const contentType = pathToType[this.currentPath]; // Collect all matching slugs let allSlugs = []; const typesToSearch = contentType ? [contentType] : Object.keys(this.contentCache); for (const type of typesToSearch) { const items = this.contentCache[type] || []; for (const item of items) { if (item.slug.toLowerCase().startsWith(partial.toLowerCase()) || item.title.toLowerCase().includes(partial.toLowerCase())) { allSlugs.push({ slug: item.slug, title: item.title, type: type, }); } } } if (allSlugs.length === 1) { this.inputElement.value = 'cat ' + allSlugs[0].slug; } else if (allSlugs.length > 0) { this.showAutocompleteMenu(allSlugs.slice(0, 15), 'content'); } else { this.appendOutput(this.formatPrompt() + 'cat ' + Terminal.escapeHtml(partial)); this.appendOutput(`No matching content found`); } } showAutocompleteMenu(items, type) { // Close any existing menu this.closeAutocomplete(); // Create menu container with ARIA listbox role const menu = document.createElement('div'); menu.className = 'autocomplete-menu'; menu.id = 'autocomplete-menu'; menu.setAttribute('role', 'listbox'); menu.setAttribute('aria-label', type + ' suggestions'); // Add header const header = document.createElement('div'); header.className = 'autocomplete-header'; const hintSpan = document.createElement('span'); hintSpan.className = 'autocomplete-hint'; hintSpan.textContent = '\u2191\u2193 navigate \u2022 Enter select \u2022 Esc close'; header.appendChild(hintSpan); menu.appendChild(header); // Add items with ARIA option role items.forEach((item, index) => { const itemEl = document.createElement('div'); itemEl.className = 'autocomplete-item' + (index === 0 ? ' selected' : ''); itemEl.dataset.slug = item.slug; itemEl.dataset.index = index; itemEl.setAttribute('role', 'option'); itemEl.setAttribute('aria-selected', index === 0 ? 'true' : 'false'); itemEl.id = 'autocomplete-option-' + index; const slugSpan = document.createElement('span'); slugSpan.className = 'autocomplete-slug'; slugSpan.textContent = item.slug; itemEl.appendChild(slugSpan); if (type === 'content') { const titleSpan = document.createElement('span'); titleSpan.className = 'autocomplete-title'; titleSpan.textContent = item.title; itemEl.appendChild(titleSpan); const typeSpan = document.createElement('span'); typeSpan.className = 'autocomplete-type'; typeSpan.textContent = '[' + item.type + ']'; itemEl.appendChild(typeSpan); } menu.appendChild(itemEl); }); // Position menu near input const inputWrapper = document.querySelector('.command-input-wrapper'); inputWrapper.insertAdjacentElement('beforebegin', menu); // Connect input to autocomplete via ARIA this.inputElement.setAttribute('aria-expanded', 'true'); this.inputElement.setAttribute('aria-controls', 'autocomplete-menu'); this.inputElement.setAttribute('aria-activedescendant', 'autocomplete-option-0'); // Store state this.autocompleteState = { items: items, selectedIndex: 0, type: type, menuElement: menu, }; this.scrollToBottom(); } navigateAutocomplete(direction) { if (!this.autocompleteState) return; const { items, selectedIndex, menuElement } = this.autocompleteState; const newIndex = Math.max(0, Math.min(items.length - 1, selectedIndex + direction)); // Update selection and ARIA state const allItems = menuElement.querySelectorAll('.autocomplete-item'); allItems[selectedIndex].classList.remove('selected'); allItems[selectedIndex].setAttribute('aria-selected', 'false'); allItems[newIndex].classList.add('selected'); allItems[newIndex].setAttribute('aria-selected', 'true'); // Update active descendant for screen readers this.inputElement.setAttribute('aria-activedescendant', 'autocomplete-option-' + newIndex); // Scroll item into view allItems[newIndex].scrollIntoView({ block: 'nearest' }); this.autocompleteState.selectedIndex = newIndex; } confirmAutocompleteSelection() { if (!this.autocompleteState) return; const { items, selectedIndex, type } = this.autocompleteState; const selectedItem = items[selectedIndex]; this.selectAutocompleteItem(selectedItem.slug, type); } selectAutocompleteItem(slug, type) { const currentType = this.autocompleteState?.type || type; if (currentType === 'command') { this.inputElement.value = slug + ' '; this.closeAutocomplete(); this.inputElement.focus(); } else if (currentType === 'directory') { const parts = this.inputElement.value.split(/\s+/); this.inputElement.value = parts[0] + ' ' + slug; this.closeAutocomplete(); this.inputElement.focus(); } else { // For content, navigate directly to the page const selectedItem = this.autocompleteState?.items.find(i => i.slug === slug); const contentType = selectedItem?.type || 'notes'; this.closeAutocomplete(); this.navigateToContent(slug, contentType); } } navigateToContent(slug, contentType) { // Map content types to URL paths const typeToPath = { 'notes': '/notes/', 'til': '/til/', 'bookmarks': '/bookmarks/', 'how_to': '/how_to/', }; const basePath = typeToPath[contentType] || '/notes/'; window.location.href = basePath + slug; } closeAutocomplete() { if (this.autocompleteState?.menuElement) { this.autocompleteState.menuElement.remove(); } // Clean up ARIA attributes on input this.inputElement.removeAttribute('aria-expanded'); this.inputElement.removeAttribute('aria-controls'); this.inputElement.removeAttribute('aria-activedescendant'); this.autocompleteState = null; } cancelInput() { this.closeAutocomplete(); this.appendOutput(this.formatPrompt() + Terminal.escapeHtml(this.inputElement.value) + '^C'); this.inputElement.value = ''; } appendOutput(html) { const line = document.createElement('div'); line.className = 'output-line'; line.innerHTML = html; this.outputElement.appendChild(line); this.scrollToBottom(); } scrollToBottom() { window.scrollTo(0, document.body.scrollHeight); } getPathFromUrl() { const pathname = window.location.pathname; // Map URL paths to terminal paths const urlToPath = { '/': '/', '/garden': '/notes', '/til': '/til', '/bookmarks': '/bookmarks', '/how_to': '/how-to', '/topics': '/tags', '/tags': '/tags', '/now': '/now', }; // Check for exact match first if (urlToPath[pathname]) { return urlToPath[pathname]; } // Check for content paths (e.g., /notes/some-article) if (pathname.startsWith('/notes/')) return '/notes'; if (pathname.startsWith('/til/')) return '/til'; if (pathname.startsWith('/bookmarks/')) return '/bookmarks'; if (pathname.startsWith('/how_to/')) return '/how-to'; if (pathname.startsWith('/tags/')) return '/tags'; // Default to root return '/'; } formatPrompt() { return `visitor@garden:${this.currentPath}$ `; } updatePrompt() { if (this.promptElement) { this.promptElement.textContent = this.currentPath; } } navigate(path) { // Use HTMX or regular navigation let url; switch (path) { case '/': url = '/'; break; case '/notes': url = '/garden'; break; case '/til': url = '/til'; break; case '/bookmarks': url = '/bookmarks'; break; case '/how-to': url = '/garden-paths'; break; case '/tags': url = '/topics'; break; default: url = path; } window.location.href = url; } // Command implementations cmdHelp(args) { const helpText = `
. ├── notes/ Long-form articles and documentation ├── til/ Today I Learned - quick learnings ├── bookmarks/ Curated external links ├── how-to/ Step-by-step guides ├── tags/ Browse content by topic ├── now What I'm currently doing └── projects My projects`; this.appendOutput(tree); } } // Initialize terminal when DOM is ready document.addEventListener('DOMContentLoaded', () => { window.terminal = new Terminal(); });