diff --git a/hugo.yaml b/hugo.yaml index 128f567..94e395c 100644 --- a/hugo.yaml +++ b/hugo.yaml @@ -33,4 +33,7 @@ taxonomies: permalinks: articles: "/s/articles/:year/:month/:day/:slug" +outputs: + home: ["HTML", "RSS", "JSON"] + ignoreLogs: [ "warning-goldmark-raw-html" ] diff --git a/layouts/_default/index.json b/layouts/_default/index.json new file mode 100644 index 0000000..433276b --- /dev/null +++ b/layouts/_default/index.json @@ -0,0 +1,11 @@ +{{- $index := slice -}} +{{- range .Site.RegularPages -}} + {{- $index = $index | append (dict + "title" .Title + "desc" (.Summary | plainify) + "contents" .Plain + "section" .Section + "date" (.Date.Format "2006-01-02") + "permalink" .Permalink) -}} +{{- end -}} +{{- $index | jsonify -}} diff --git a/static/js/fastsearch.js b/static/js/fastsearch.js new file mode 100644 index 0000000..78394f6 --- /dev/null +++ b/static/js/fastsearch.js @@ -0,0 +1,240 @@ +/* + Fast Search for Hugo — adapted from + https://gist.github.com/cmod/5410eae147e4318164258742dd053993 + MIT License +*/ + +const DEFAULT_CONFIG = { + shortcuts: { + open: { + key: '/', + metaKey: false, + altKey: false, + ctrlKey: false, + shiftKey: false + } + }, + search: { + minChars: 2, + maxResults: 8, + fields: { + title: true, + description: true, + section: true + } + } +}; + +function initSearch(userConfig = {}) { + const CONFIG = mergeConfigs(DEFAULT_CONFIG, userConfig); + + const mainNav = document.getElementById('mainNav'); + 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; + + 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() || '', + searchableContents: item.contents?.toLowerCase() || '' + })); + if (searchInput.value) performSearch(searchInput.value); + } catch (error) { + console.error('Error loading search index:', error); + searchResults.innerHTML = '
  • Error loading search index.
  • '; + } + } + + function simpleFuzzyMatch(text, term) { + if (text.includes(term)) return true; + if (term.length < 3) return false; + let matches = 0, 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; + } + + function matchesShortcut(event, sc) { + return event.key === sc.key && + event.metaKey === sc.metaKey && + event.altKey === sc.altKey && + event.ctrlKey === sc.ctrlKey && + event.shiftKey === sc.shiftKey; + } + + function openSearch() { + searchVisible = true; + mainNav.style.display = 'none'; + fastSearch.style.display = 'flex'; + if (firstRun) { loadSearchIndex(); firstRun = false; } + searchInput.focus(); + searchInput.value = ''; + searchResults.innerHTML = ''; + } + + function closeSearch() { + searchVisible = false; + fastSearch.style.display = 'none'; + mainNav.style.display = ''; + searchInput.blur(); + searchInput.value = ''; + searchResults.innerHTML = ''; + resultsAvailable = false; + } + + document.addEventListener('keydown', (event) => { + const tag = event.target.tagName; + const inOtherInput = tag === 'TEXTAREA' || (tag === 'INPUT' && event.target.id !== 'searchInput'); + const inSearchInput = tag === 'INPUT' && event.target.id === 'searchInput'; + + // Never steal keystrokes from other inputs/textareas + if (inOtherInput) return; + + // Toggle shortcut — not when the user is already typing in the search box + if (!inSearchInput && matchesShortcut(event, CONFIG.shortcuts.open)) { + event.preventDefault(); + searchVisible ? closeSearch() : openSearch(); + return; + } + + // ESC closes from anywhere, including the search input + if (event.key === 'Escape' && searchVisible) { + closeSearch(); + return; + } + + // Arrow navigation and Enter + if (searchVisible && resultsAvailable) { + const links = Array.from(searchResults.getElementsByTagName('a')); + if (!links.length) return; + const active = document.activeElement; + const activeInResults = searchResults.contains(active) && active.tagName === 'A'; + const i = activeInResults ? links.indexOf(active) : -1; + + if (event.key === 'Enter') { + // Follow the focused result, not always the first one + const target = activeInResults ? active : links[0]; + event.preventDefault(); + window.location.href = target.href; + return; + } + + if (event.key === 'ArrowDown') { + event.preventDefault(); + if (!activeInResults) { links[0].focus(); } // from input or stale focus → first result + else if (i < links.length - 1) { links[i + 1].focus(); } + return; + } + + if (event.key === 'ArrowUp') { + event.preventDefault(); + if (!activeInResults || i === 0) { searchInput.focus(); } + else { links[i - 1].focus(); } + return; + } + } + + // Enter with no results: do nothing but swallow it so the form doesn't submit + if (event.key === 'Enter' && searchVisible) { + event.preventDefault(); + return; + } + }); + + function performSearch(term) { + term = term.toLowerCase().trim(); + if (!term || !searchIndex) { searchResults.innerHTML = ''; resultsAvailable = false; return; } + + if (term.length < CONFIG.search.minChars) { + searchResults.innerHTML = '
  • Type at least 2 characters…
  • '; + resultsAvailable = false; + return; + } + + const searchTerms = term.split(/\s+/).filter(t => t.length > 0); + + const results = searchIndex + .map(item => { + let score = 0; + const matchesAll = searchTerms.every(t => { + let matched = false; + if (CONFIG.search.fields.title) { + if (item.searchableTitle.startsWith(t)) { score += 3; matched = true; } + else if (simpleFuzzyMatch(item.searchableTitle, t)) { score += 2; matched = true; } + } + if (!matched && CONFIG.search.fields.description && item.searchableDesc.includes(t)) { score += 0.5; matched = true; } + if (!matched && CONFIG.search.fields.section && item.searchableSection.includes(t)) { score += 0.5; matched = true; } + if (!matched && item.searchableContents.includes(t)) { score += 0.1; matched = true; } + return matched; + }); + return { item, score: matchesAll ? score : 0 }; + }) + .filter(r => r.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, CONFIG.search.maxResults) + .map(r => r.item); + + resultsAvailable = results.length > 0; + + if (!resultsAvailable) { + searchResults.innerHTML = '
  • No results found.
  • '; + return; + } + + searchResults.innerHTML = results.map(item => ` +
  • + + ${escapeHtml(item.title)} + ${escapeHtml(item.section)} — ${escapeHtml(item.date)} + ${escapeHtml(item.desc)} + +
  • + `).join(''); + } + + searchInput.addEventListener('input', function () { + if (!searchIndex && !firstRun) { + searchResults.innerHTML = '
  • Loading…
  • '; + return; + } + performSearch(this.value); + }); +} + +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; +} + +function escapeHtml(unsafe) { + if (!unsafe) return ''; + return unsafe + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +initSearch(); diff --git a/themes/hugo-theme-ipng/assets/styles.scss b/themes/hugo-theme-ipng/assets/styles.scss index b295c1c..24f931e 100644 --- a/themes/hugo-theme-ipng/assets/styles.scss +++ b/themes/hugo-theme-ipng/assets/styles.scss @@ -266,7 +266,7 @@ nav { justify-content: flex-end; flex-flow: row wrap; overflow: hidden; - margin: auto 2% .6em auto; + margin: 0 2% 0 auto; padding-left: 2em; } @@ -342,6 +342,7 @@ nav li:hover { text-align: center; display: flex; flex-flow: row wrap; + align-items: center; /* $mq-mini or smaller: */ margin: 0; width: 100%; font-size: $base-font-size * 0.8; @media #{$mq-small} { margin: 0 5%; width: 90%; font-size: $base-font-size * 0.9; } @@ -779,3 +780,96 @@ table { td { } } + +// Search — sits in the header, replaces nav when active +#fastSearch { + display: none; // toggled to flex by JS + flex: 1; + align-items: center; + align-self: center; // vertically align with the header text + position: relative; // anchor for the results dropdown + margin: 0; + + input#searchInput { + flex: 1; + width: 100%; + padding: .25em .63em; + font-size: 1.1em; // matches nav a font-size + color: $text-dark; + background-color: transparent; + border: none; // header border-bottom already provides the bottom line + outline: none; + box-sizing: border-box; + + &::placeholder { color: $text-very-light; font-style: italic; } + } + + ul#searchResults { + position: absolute; + top: 100%; + right: 0; + left: 0; + list-style: none; + margin: 0; + padding: 0; + background-color: #f7f7f7; // matches header bg + border: 1px solid $tab-border-color; + border-top: 2px solid $ipng-orange; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + z-index: 200; + + &:empty { display: none; } + + li { + border-bottom: 1px solid $tab-border-color; + + &:last-child { border-bottom: none; } + + &.search-message { + padding: .5em .63em; + color: $text-normal; + font-style: italic; + font-size: .9em; + } + + a { + display: block; + padding: .4em .63em; + text-decoration: none; + color: $text-dark; + + &:hover, &:focus { + outline: none; + background-color: $ipng-orange; + color: #fff; + + .meta, .desc { color: rgba(255,255,255,0.85); } + } + + .title { + display: block; + font-size: 1em; + font-weight: bold; + } + + .meta { + display: block; + font-size: .72em; + color: $text-normal; + text-transform: uppercase; + letter-spacing: .04em; + margin: .1em 0; + } + + .desc { + display: block; + font-size: .85em; + color: $text-normal; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + } + } +} diff --git a/themes/hugo-theme-ipng/layouts/_default/baseof.html b/themes/hugo-theme-ipng/layouts/_default/baseof.html index 0ddd37e..33992d0 100644 --- a/themes/hugo-theme-ipng/layouts/_default/baseof.html +++ b/themes/hugo-theme-ipng/layouts/_default/baseof.html @@ -7,5 +7,6 @@ {{- block "main" . }}{{- end }} {{- partial "footer.html" . -}} + diff --git a/themes/hugo-theme-ipng/layouts/partials/header.html b/themes/hugo-theme-ipng/layouts/partials/header.html index 8abd754..99d797a 100644 --- a/themes/hugo-theme-ipng/layouts/partials/header.html +++ b/themes/hugo-theme-ipng/layouts/partials/header.html @@ -3,7 +3,7 @@

    {{ default .Site.Params.Author .Site.Params.siteHeading }}

    -