Compare commits
3 commits
5c3eba62b1
...
c74defd696
Author | SHA1 | Date | |
---|---|---|---|
|
c74defd696 | ||
|
45e43928e7 | ||
|
a7fea79d98 |
7 changed files with 219 additions and 88 deletions
|
@ -58,7 +58,8 @@ All commands are run from the root of the project, from a terminal:
|
|||
## 🗺 Roadmap
|
||||
- [x] Initial project setup
|
||||
- [x] Basic theme implementation
|
||||
- [ ] Better full-text search without `Fuse.js`
|
||||
- [x] Better full-text search without `Fuse.js`
|
||||
- [ ] A mode to make the site 0 javascript
|
||||
- [ ] Multiple authors via YAML
|
||||
- [ ] i18n support
|
||||
- [ ] Integrate with Fediverse w/ activityPub
|
||||
|
|
|
@ -18,7 +18,6 @@
|
|||
"@astrojs/sitemap": "^3.3.1",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.5",
|
||||
"astro": "^5.2.5",
|
||||
"fuse.js": "^7.0.0",
|
||||
"sharp": "^0.34.1",
|
||||
"ultrahtml": "^1.6.0"
|
||||
},
|
||||
|
|
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
|
@ -29,9 +29,6 @@ importers:
|
|||
astro:
|
||||
specifier: ^5.2.5
|
||||
version: 5.7.10(@azure/identity@4.9.1)(@types/node@22.15.3)(rollup@4.40.1)(typescript@5.8.3)(yaml@2.7.1)
|
||||
fuse.js:
|
||||
specifier: ^7.0.0
|
||||
version: 7.1.0
|
||||
sharp:
|
||||
specifier: ^0.34.1
|
||||
version: 0.34.1
|
||||
|
@ -1692,10 +1689,6 @@ packages:
|
|||
function-bind@1.1.2:
|
||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||
|
||||
fuse.js@7.1.0:
|
||||
resolution: {integrity: sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
get-caller-file@2.0.5:
|
||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||
engines: {node: 6.* || 8.* || >= 10.*}
|
||||
|
@ -4927,8 +4920,6 @@ snapshots:
|
|||
|
||||
function-bind@1.1.2: {}
|
||||
|
||||
fuse.js@7.1.0: {}
|
||||
|
||||
get-caller-file@2.0.5: {}
|
||||
|
||||
get-east-asian-width@1.3.0: {}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
---
|
||||
|
||||
import {siteConfig} from "../config";
|
||||
const noscript = siteConfig.noClientJavaScript
|
||||
---
|
||||
<button id="toTopBtn" title="Go to top">Top</button>
|
||||
{noscript ? <a href="#top"><button id="toTopBtn" style="display: block" title="Go to top">Top</button></a> : <button id="toTopBtn" title="Go to top">Top</button>
|
||||
<script>
|
||||
// Get the button
|
||||
let toTopButton = document.getElementById("toTopBtn");
|
||||
|
@ -31,4 +32,4 @@
|
|||
document.documentElement.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>}
|
|
@ -1,5 +1,17 @@
|
|||
---
|
||||
import {siteConfig} from "../config";
|
||||
const noscript = siteConfig.noClientJavaScript
|
||||
---
|
||||
{noscript ?
|
||||
<form class="search-container" action="https://www.google.com/search" method="GET" target="_blank">
|
||||
<div>
|
||||
<label for="search-input"><span class="command">search</span></label>
|
||||
<input name="q" type="text" id="search-input" class="search-input" autocomplete="off" placeholder="Type to search..." />
|
||||
<input type="hidden" name="as_sitesearch" value={Astro.url.host} />
|
||||
</div>
|
||||
<input type="submit" style="display: none" />
|
||||
</form>
|
||||
:
|
||||
<div class="search-container">
|
||||
<div class="command-prompt">
|
||||
<span class="command">search</span>
|
||||
|
@ -13,86 +25,203 @@
|
|||
</div>
|
||||
<div id="search-results" class="search-results"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import Fuse from 'fuse.js';
|
||||
/*
|
||||
====================================================================
|
||||
|
||||
let fuse;
|
||||
let posts = [];
|
||||
let searchInitialized = false;
|
||||
tweaked from FAST SEARCH —
|
||||
https://gist.github.com/cmod/5410eae147e4318164258742dd053993
|
||||
|
||||
async function initializeSearch() {
|
||||
// Prevent multiple initializations
|
||||
if (searchInitialized) return;
|
||||
====================================================================
|
||||
*/
|
||||
// Configuration
|
||||
const CONFIG = {
|
||||
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
|
||||
content: true // Allow searching in contents
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Function to initialize search with custom config
|
||||
function initSearch(): void {
|
||||
// Cache DOM elements
|
||||
const searchInput = document.getElementById('search-input') as HTMLInputElement;
|
||||
const searchResults = document.getElementById('search-results') as HTMLElement;
|
||||
|
||||
let searchIndex: Array<{
|
||||
title: string;
|
||||
description?: string;
|
||||
slug?: string;
|
||||
pubDate?: Date;
|
||||
searchableTitle: string;
|
||||
searchableDesc: string;
|
||||
searchableContent: string;
|
||||
}> | null = null;
|
||||
let resultsAvailable = false;
|
||||
let firstRun = true;
|
||||
|
||||
// Load the search index
|
||||
async function loadSearchIndex(): Promise<void> {
|
||||
try {
|
||||
// Fetch the search index
|
||||
const response = await fetch('/search-index.json');
|
||||
posts = await response.json();
|
||||
if (!response.ok) throw new Error('Failed to load search index');
|
||||
const data = await response.json();
|
||||
|
||||
fuse = new Fuse(posts, {
|
||||
keys: ['title', 'description', 'content'],
|
||||
threshold: 0.3,
|
||||
includeMatches: true
|
||||
});
|
||||
searchInitialized = true;
|
||||
document.getElementById('search-results').innerHTML = '';
|
||||
searchIndex = data.map((item: any) => ({
|
||||
...item,
|
||||
searchableTitle: item.title?.toLowerCase() || '',
|
||||
searchableDesc: item.description?.toLowerCase() || '',
|
||||
searchableContent: (item.content as string)?.toLowerCase() || ''
|
||||
}));
|
||||
|
||||
if (searchInput && searchInput.value) {
|
||||
performSearch(searchInput.value);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching search index:', error);
|
||||
console.error('Error loading search index:', error);
|
||||
if (searchResults) {
|
||||
searchResults.innerHTML = '<li class="search-message">Error loading search index...</li>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function performSearch(query) {
|
||||
if (!query) {
|
||||
document.getElementById('search-results').innerHTML = '';
|
||||
// Simple fuzzy match for single words
|
||||
function simpleFuzzyMatch(text: string, term: string): boolean {
|
||||
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;
|
||||
}
|
||||
|
||||
function performSearch(term: string): void {
|
||||
term = term.toLowerCase().trim();
|
||||
|
||||
if (!term || !searchIndex) {
|
||||
searchResults.innerHTML = '';
|
||||
resultsAvailable = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!searchInitialized) return;
|
||||
|
||||
const results = fuse.search(query);
|
||||
const resultsElement = document.getElementById('search-results');
|
||||
|
||||
if (results.length === 0) {
|
||||
resultsElement.innerHTML = '<p>No results found.</p>';
|
||||
if (term.length < CONFIG.search.minChars) {
|
||||
searchResults.innerHTML = '<span class="search-message">Please enter at least 2 characters...</span>';
|
||||
resultsAvailable = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const html = results
|
||||
.map(result => `
|
||||
// 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.content && item.searchableContent.includes(term)) {
|
||||
score += 0.5; // Lower score for content 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 = '<span class="search-message">No matching results found...</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
const searchItems = results.map(item => `
|
||||
<div class="search-result">
|
||||
<a href="/post/${result.item.slug}">
|
||||
<span class="result-title">${result.item.title}</span>
|
||||
<span class="result-date">${new Date(result.item.pubDate).toISOString().split('T')[0]}</span>
|
||||
<a href="/post/${item.slug}" tabindex="0">
|
||||
<span class="result-title">${item.title}</span><br />
|
||||
<span class="result-date">${new Date(item.pubDate).toISOString().split('T')[0]|| '' } —
|
||||
<em>${item.description || ''}</em></span>
|
||||
</a>
|
||||
</div>
|
||||
`)
|
||||
.join('');
|
||||
`).join('');
|
||||
|
||||
resultsElement.innerHTML = html;
|
||||
searchResults.innerHTML = searchItems;
|
||||
}
|
||||
|
||||
// Basic HTML escaping for security
|
||||
function escapeHtml(unsafe: string): string {
|
||||
if (!unsafe) return '';
|
||||
return unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Add event listener for search input
|
||||
const searchInput = document.getElementById('search-input');
|
||||
|
||||
// Initialize search only when the input is focused or clicked
|
||||
searchInput.addEventListener('focus', initializeSearch);
|
||||
searchInput.addEventListener('click', initializeSearch);
|
||||
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
if (!searchInitialized) {
|
||||
initializeSearch().then(() => {
|
||||
performSearch(e.target.value);
|
||||
if (searchInput) {
|
||||
// Load index on focus
|
||||
searchInput.addEventListener('focus', function() {
|
||||
loadSearchIndex();
|
||||
});
|
||||
} else {
|
||||
performSearch(e.target.value);
|
||||
|
||||
searchInput.addEventListener('input', function() {
|
||||
if (!searchIndex && !firstRun) {
|
||||
searchResults.innerHTML = '<span class="search-message">Loading search index...</span>';
|
||||
return;
|
||||
}
|
||||
performSearch(this.value);
|
||||
});
|
||||
|
||||
searchInput.addEventListener('focus', () => {
|
||||
if (!searchInitialized) {
|
||||
document.getElementById('search-results').innerHTML = '<p>Loading search index...</p>';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
firstRun = false;
|
||||
}
|
||||
|
||||
// Initialize with default config
|
||||
initSearch();
|
||||
</script>}
|
||||
|
||||
<style>
|
||||
.search-container {
|
||||
|
|
|
@ -3,6 +3,13 @@ export const siteConfig = {
|
|||
title: '/var/log/mercury',
|
||||
description: 'A blog about software development, technology, and life.',
|
||||
homepageOgImage: '',
|
||||
// features
|
||||
noClientJavaScript: false, // disable client-side javascript, this will:
|
||||
// 1. disable all built-in client-side javascript from rendering
|
||||
// 2. the full text search will be redirected to a search engine
|
||||
// 3. the comments will be replaced with email reply
|
||||
// 4. the night mode & back to top will not use Javascript to function
|
||||
// 5. the neko will be force-disabled
|
||||
// site components
|
||||
navBarItems: [
|
||||
// additional items in the navbar
|
||||
|
@ -16,7 +23,7 @@ export const siteConfig = {
|
|||
customFooter: '<i>I have no mouth, and I must SCREAM</i>',
|
||||
// comments
|
||||
comments: {
|
||||
type: 'artalk', // 'artalk','giscus','fediverse','hatsu'
|
||||
type: 'artalk', // 'artalk','giscus','fediverse','email','hatsu'
|
||||
artalk: {
|
||||
instanceDomain: '', // the domain of your artalk instance
|
||||
},
|
||||
|
|
|
@ -15,6 +15,8 @@ interface Props {
|
|||
ogImage?: string;
|
||||
}
|
||||
|
||||
const noscript = siteConfig.noClientJavaScript
|
||||
|
||||
const defaultTitle = siteConfig.title
|
||||
const formattedRootPath = defaultTitle.toLowerCase().replace(/\s+/g, '-');
|
||||
const relativePath = Astro.url.pathname
|
||||
|
@ -41,6 +43,7 @@ const { title = pageTitle, description = siteConfig.description, ogImage = "" }
|
|||
</head>
|
||||
<body>
|
||||
<main>
|
||||
{noscript && <div id="top" style="visibility: hidden">Back To Top</div>}
|
||||
<div class="container">
|
||||
<div class="terminal-path">
|
||||
{path}
|
||||
|
@ -70,7 +73,7 @@ const { title = pageTitle, description = siteConfig.description, ogImage = "" }
|
|||
<p>Powered by <a href="https://git.gb0.dev/gb/mercury" target="_blank"><Logo width={16} height={16} /> mercury</a></p>
|
||||
</div>
|
||||
</footer>
|
||||
{ siteConfig.neko.enabled &&
|
||||
{ !noscript || siteConfig.neko.enabled &&
|
||||
<>
|
||||
<script is:inline define:vars={{ nekoType }}>
|
||||
window.NekoType = nekoType;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue