incorporate search into website
This commit is contained in:
parent
702cd0201c
commit
1c4fe877f5
5 changed files with 414 additions and 0 deletions
|
@ -63,3 +63,5 @@ aria = "Mi correo electrónico"
|
||||||
name = "rss"
|
name = "rss"
|
||||||
weight = 4
|
weight = 4
|
||||||
url = "/blog/index.xml"
|
url = "/blog/index.xml"
|
||||||
|
[outputs]
|
||||||
|
home = ["HTML", "RSS", "JSON"]
|
||||||
|
|
1
layouts/_default/index.json
Normal file
1
layouts/_default/index.json
Normal file
|
@ -0,0 +1 @@
|
||||||
|
{{- $.Scratch.Add "index" slice -}} {{- range .Site.RegularPages -}} {{- $.Scratch.Add "index" (dict "date" (.PublishDate.Format "01-12-2006") "title" .Title "tags" .Params.tags "section" (index .Params.categories 0) "desc" .Plain "summary" (printf "%s..." (substr (.Summary | plainify) 0 90)) "permalink" .Permalink) -}} {{- end -}} {{- $.Scratch.Get "index" | jsonify -}}
|
|
@ -3,8 +3,68 @@
|
||||||
<head>
|
<head>
|
||||||
<style>
|
<style>
|
||||||
code { padding: 2px 4px; font-size: 90%; border-radius: 4px; background-color: #1c1c1d; }
|
code { padding: 2px 4px; font-size: 90%; border-radius: 4px; background-color: #1c1c1d; }
|
||||||
|
|
||||||
|
.searchprompt {
|
||||||
|
color: #4b4b4b;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: 42em;
|
||||||
|
position: absolute;
|
||||||
|
top:24px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fastSearch {
|
||||||
|
visibility: hidden;
|
||||||
|
position: absolute;
|
||||||
|
right: 0px;
|
||||||
|
top: 70px;
|
||||||
|
display: inline-block;
|
||||||
|
width: 340px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fastSearch input {
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
width: 100%;
|
||||||
|
margin-left: -2em;
|
||||||
|
height: 20px;
|
||||||
|
font-size: 1.4em;
|
||||||
|
font-family: "FiraMono";
|
||||||
|
color: #f3f8f8;
|
||||||
|
font-weight: bold;
|
||||||
|
background-color: #1e1f20;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
text-align: left;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#searchResults li {
|
||||||
|
border-radius: 8px;
|
||||||
|
list-style: none;
|
||||||
|
width: 350px;
|
||||||
|
padding: 6px 6px 6px 6px;
|
||||||
|
margin-left: -5.3em;
|
||||||
|
color: #f3f8f8;
|
||||||
|
background-color: #1e1f20;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-family: "OpenSans";
|
||||||
|
|
||||||
|
if (isPlatformAltOrCmdKey(event) && event.key === '/') {
|
||||||
|
}
|
||||||
|
#searchResults li .title { font-size: 1.1em; margin-bottom: 10px; display: inline-block;}
|
||||||
|
|
||||||
|
#searchResults { visibility: inherit; display: inline-block; width: 320px; }
|
||||||
|
#searchResults a { text-decoration: none !important; padding: 10px; display: inline-block; }
|
||||||
|
#searchResults a:hover, a:focus { outline: 0; background-color: #666; color: #f3f8f8; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
</body>
|
</body>
|
||||||
|
<div id="fastSearch">
|
||||||
|
<input id="searchInput" tabindex="0">
|
||||||
|
<ul id="searchResults">
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<script src="/js/fastsearch.js"></script>
|
||||||
</html>
|
</html>
|
||||||
|
|
23
layouts/partials/header.html
Normal file
23
layouts/partials/header.html
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
<header class="headerWrapper">
|
||||||
|
<div class="header">
|
||||||
|
<div>
|
||||||
|
<a class="terminal" href="{{ .Site.BaseURL | relLangURL }}">
|
||||||
|
<span>{{ .Site.Params.user }}@{{ .Site.Params.hostname }} ~ $</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<input class="side-menu" type="checkbox" id="side-menu">
|
||||||
|
<label class="hamb" for="side-menu"><span class="hamb-line"></span></label>
|
||||||
|
<p><span class="searchprompt">Buscar: Presiona Meta (tecla Win) + /</span></p>
|
||||||
|
<nav class="headerLinks">
|
||||||
|
<ul>
|
||||||
|
{{ range .Site.Menus.header }}
|
||||||
|
<li>
|
||||||
|
<a href="{{ .URL | absLangURL }}" title="{{ .Title }}" >
|
||||||
|
~/{{- .Name -}}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
328
static/js/fastsearch.js
Normal file
328
static/js/fastsearch.js
Normal file
|
@ -0,0 +1,328 @@
|
||||||
|
/*
|
||||||
|
====================================================================
|
||||||
|
|
||||||
|
FAST SEARCH —
|
||||||
|
https://gist.github.com/cmod/5410eae147e4318164258742dd053993
|
||||||
|
Updated to work with fuse 7 (Jan 2025)
|
||||||
|
Updated Feb 2025 — no more fuse dependency, more modern js,
|
||||||
|
proper config items, ability to easily modify shortcut,
|
||||||
|
general speed improvements
|
||||||
|
|
||||||
|
====================================================================
|
||||||
|
*/
|
||||||
|
// Configuration
|
||||||
|
const DEFAULT_CONFIG = {
|
||||||
|
shortcuts: {
|
||||||
|
open: { // Shortcut to open/close search
|
||||||
|
key: '/', // The key to trigger the shortcut
|
||||||
|
metaKey: true, // Requires Cmd/Win key
|
||||||
|
altKey: false, // Requires Alt key
|
||||||
|
ctrlKey: false, // Requires Ctrl key
|
||||||
|
shiftKey: false // Requires Shift key
|
||||||
|
}
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
minChars: 2, // Minimum characters before searching
|
||||||
|
maxResults: 5, // Maximum number of results to show
|
||||||
|
fields: { // Fields to search through
|
||||||
|
title: true, // Allow searching in title
|
||||||
|
description: true, // Allow searching in description
|
||||||
|
section: true // Allow searching in section
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to initialize search with custom config
|
||||||
|
function initSearch(userConfig = {}) {
|
||||||
|
// Deep merge of default config with user config
|
||||||
|
const CONFIG = mergeConfigs(DEFAULT_CONFIG, userConfig);
|
||||||
|
|
||||||
|
// Cache DOM elements
|
||||||
|
const fastSearch = document.getElementById('fastSearch');
|
||||||
|
const searchInput = document.getElementById('searchInput');
|
||||||
|
const searchResults = document.getElementById('searchResults');
|
||||||
|
|
||||||
|
let searchIndex = null;
|
||||||
|
let searchVisible = false;
|
||||||
|
let resultsAvailable = false;
|
||||||
|
let firstRun = true;
|
||||||
|
|
||||||
|
// Load the search index
|
||||||
|
async function loadSearchIndex() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/index.json');
|
||||||
|
if (!response.ok) throw new Error('Failed to load search index');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
searchIndex = data.map(item => ({
|
||||||
|
...item,
|
||||||
|
searchableTitle: item.title?.toLowerCase() || '',
|
||||||
|
searchableDesc: item.desc?.toLowerCase() || '',
|
||||||
|
searchableSection: item.section?.toLowerCase() || ''
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (searchInput.value) {
|
||||||
|
performSearch(searchInput.value);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading search index:', error);
|
||||||
|
searchResults.innerHTML = '<li class="search-message">Error loading search index...</li>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple fuzzy match for single words
|
||||||
|
function simpleFuzzyMatch(text, term) {
|
||||||
|
if (text.includes(term)) return true;
|
||||||
|
if (term.length < 3) return false;
|
||||||
|
|
||||||
|
let matches = 0;
|
||||||
|
let lastMatchIndex = -1;
|
||||||
|
|
||||||
|
for (let i = 0; i < term.length; i++) {
|
||||||
|
const found = text.indexOf(term[i], lastMatchIndex + 1);
|
||||||
|
if (found > -1) {
|
||||||
|
matches++;
|
||||||
|
lastMatchIndex = found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches === term.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if keyboard event matches shortcut config
|
||||||
|
function matchesShortcut(event, shortcutConfig) {
|
||||||
|
return event.key === shortcutConfig.key &&
|
||||||
|
event.metaKey === shortcutConfig.metaKey &&
|
||||||
|
event.altKey === shortcutConfig.altKey &&
|
||||||
|
event.ctrlKey === shortcutConfig.ctrlKey &&
|
||||||
|
event.shiftKey === shortcutConfig.shiftKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
// Check for configured search shortcut
|
||||||
|
if (matchesShortcut(event, CONFIG.shortcuts.open)) {
|
||||||
|
event.preventDefault();
|
||||||
|
searchVisible = !searchVisible;
|
||||||
|
fastSearch.style.visibility = searchVisible ? 'visible' : 'hidden';
|
||||||
|
|
||||||
|
if (searchVisible) {
|
||||||
|
if (firstRun) {
|
||||||
|
loadSearchIndex();
|
||||||
|
firstRun = false;
|
||||||
|
}
|
||||||
|
searchInput.focus();
|
||||||
|
searchInput.value = '';
|
||||||
|
} else {
|
||||||
|
searchInput.blur();
|
||||||
|
searchResults.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESC to close search
|
||||||
|
if (event.key === 'Escape' && searchVisible) {
|
||||||
|
fastSearch.style.visibility = 'hidden';
|
||||||
|
searchInput.blur();
|
||||||
|
searchInput.value = '';
|
||||||
|
searchResults.innerHTML = '';
|
||||||
|
searchVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter to select first result
|
||||||
|
if (event.key === 'Enter' && searchVisible) {
|
||||||
|
const firstResult = searchResults.querySelector('a');
|
||||||
|
if (firstResult) {
|
||||||
|
event.preventDefault();
|
||||||
|
window.location.href = firstResult.href;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrow navigation
|
||||||
|
if (searchVisible && resultsAvailable) {
|
||||||
|
const links = Array.from(searchResults.getElementsByTagName('a'));
|
||||||
|
if (!links.length) return;
|
||||||
|
|
||||||
|
const first = links[0];
|
||||||
|
const last = links[links.length - 1];
|
||||||
|
const active = document.activeElement;
|
||||||
|
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
if (active === searchInput) {
|
||||||
|
first.focus();
|
||||||
|
} else if (active.tagName === 'A') {
|
||||||
|
const currentIndex = links.indexOf(active);
|
||||||
|
if (currentIndex !== -1 && currentIndex < links.length - 1) {
|
||||||
|
links[currentIndex + 1].focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
if (active === first) {
|
||||||
|
searchInput.focus();
|
||||||
|
} else if (active.tagName === 'A') {
|
||||||
|
const currentIndex = links.indexOf(active);
|
||||||
|
if (currentIndex > 0) {
|
||||||
|
links[currentIndex - 1].focus();
|
||||||
|
} else {
|
||||||
|
searchInput.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function performSearch(term) {
|
||||||
|
term = term.toLowerCase().trim();
|
||||||
|
|
||||||
|
if (!term || !searchIndex) {
|
||||||
|
searchResults.innerHTML = '';
|
||||||
|
resultsAvailable = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (term.length < CONFIG.search.minChars) {
|
||||||
|
searchResults.innerHTML = '<li class="search-message">Favor de introducir al menos 2 caracteres...</li>';
|
||||||
|
resultsAvailable = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split search into terms
|
||||||
|
const searchTerms = term.split(/\s+/).filter(t => t.length > 0);
|
||||||
|
|
||||||
|
// Search with scoring
|
||||||
|
const results = searchIndex
|
||||||
|
.map(item => {
|
||||||
|
let score = 0;
|
||||||
|
const matchesAllTerms = searchTerms.every(term => {
|
||||||
|
let matched = false;
|
||||||
|
|
||||||
|
// Title matches (weighted higher)
|
||||||
|
if (CONFIG.search.fields.title) {
|
||||||
|
if (item.searchableTitle.startsWith(term)) {
|
||||||
|
score += 3; // Highest score for prefix matches in title
|
||||||
|
matched = true;
|
||||||
|
} else if (simpleFuzzyMatch(item.searchableTitle, term)) {
|
||||||
|
score += 2; // Good score for fuzzy matches in title
|
||||||
|
matched = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other field matches
|
||||||
|
if (!matched) {
|
||||||
|
if (CONFIG.search.fields.description && item.searchableDesc.includes(term)) {
|
||||||
|
score += 0.5; // Lower score for description matches
|
||||||
|
matched = true;
|
||||||
|
}
|
||||||
|
if (CONFIG.search.fields.section && item.searchableSection.includes(term)) {
|
||||||
|
score += 0.5; // Lower score for section matches
|
||||||
|
matched = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matched;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
item,
|
||||||
|
score: matchesAllTerms ? score : 0
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(result => result.score > 0)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, CONFIG.search.maxResults)
|
||||||
|
.map(result => result.item);
|
||||||
|
|
||||||
|
resultsAvailable = results.length > 0;
|
||||||
|
|
||||||
|
if (!resultsAvailable) {
|
||||||
|
searchResults.innerHTML = '<li class="search-message">No se encoraron resultados...</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchItems = results.map(item => `
|
||||||
|
<li>
|
||||||
|
<a href="${escapeHtml(item.permalink)}" tabindex="0">
|
||||||
|
<span class="title">${escapeHtml(item.title)}</span><br />
|
||||||
|
<span class="sc">${escapeHtml(item.section)}</span> —
|
||||||
|
${escapeHtml(item.date)} —
|
||||||
|
<em>${escapeHtml(item.summary)}</em>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
searchResults.innerHTML = searchItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchInput.addEventListener('input', function() {
|
||||||
|
if (!searchIndex && !firstRun) {
|
||||||
|
searchResults.innerHTML = '<li class="search-message">Loading search index...</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
performSearch(this.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add minimal styles
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
.search-message {
|
||||||
|
padding: 8px;
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
#searchResults li {
|
||||||
|
animation: fadeSlideIn 0.2s ease-out;
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
#searchResults li:nth-child(1) { animation-delay: 0.0s; }
|
||||||
|
#searchResults li:nth-child(2) { animation-delay: 0.02s; }
|
||||||
|
#searchResults li:nth-child(3) { animation-delay: 0.04s; }
|
||||||
|
#searchResults li:nth-child(4) { animation-delay: 0.06s; }
|
||||||
|
#searchResults li:nth-child(5) { animation-delay: 0.08s; }
|
||||||
|
|
||||||
|
@keyframes fadeSlideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to deep merge configs
|
||||||
|
function mergeConfigs(defaultConfig, userConfig) {
|
||||||
|
const merged = { ...defaultConfig };
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(userConfig)) {
|
||||||
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
merged[key] = mergeConfigs(defaultConfig[key] || {}, value);
|
||||||
|
} else {
|
||||||
|
merged[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic HTML escaping for security
|
||||||
|
function escapeHtml(unsafe) {
|
||||||
|
if (!unsafe) return '';
|
||||||
|
return unsafe
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize with default config
|
||||||
|
initSearch();
|
Loading…
Add table
Add a link
Reference in a new issue