Compare commits

...

3 commits

Author SHA1 Message Date
grassblock
c74defd696 chore(docs): update roadmap 2025-05-22 22:55:21 +08:00
grassblock
45e43928e7 refactor: rewrite search component with fastsearch 2025-05-22 22:54:13 +08:00
grassblock
a7fea79d98 feat: implement no-client-JavaScript support for back-to-top and search (WIP) 2025-05-22 21:43:35 +08:00
7 changed files with 219 additions and 88 deletions

View file

@ -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

View file

@ -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
View file

@ -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: {}

View file

@ -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>}

View file

@ -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;
try {
// Fetch the search index
const response = await fetch('/search-index.json');
posts = await response.json();
====================================================================
*/
// 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
}
}
};
fuse = new Fuse(posts, {
keys: ['title', 'description', 'content'],
threshold: 0.3,
includeMatches: true
// 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 {
const response = await fetch('/search-index.json');
if (!response.ok) throw new Error('Failed to load search index');
const data = await response.json();
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 loading search index:', error);
if (searchResults) {
searchResults.innerHTML = '<li class="search-message">Error loading search index...</li>';
}
}
}
// 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 (term.length < CONFIG.search.minChars) {
searchResults.innerHTML = '<span class="search-message">Please enter at least 2 characters...</span>';
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.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/${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('');
searchResults.innerHTML = searchItems;
}
// Basic HTML escaping for security
function escapeHtml(unsafe: string): string {
if (!unsafe) return '';
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// Add event listener for search input
if (searchInput) {
// Load index on focus
searchInput.addEventListener('focus', function() {
loadSearchIndex();
});
searchInput.addEventListener('input', function() {
if (!searchIndex && !firstRun) {
searchResults.innerHTML = '<span class="search-message">Loading search index...</span>';
return;
}
performSearch(this.value);
});
searchInitialized = true;
document.getElementById('search-results').innerHTML = '';
} catch (error) {
console.error('Error fetching search index:', error);
}
firstRun = false;
}
function performSearch(query) {
if (!query) {
document.getElementById('search-results').innerHTML = '';
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>';
return;
}
const html = results
.map(result => `
<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>
</div>
`)
.join('');
resultsElement.innerHTML = html;
}
// 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);
});
} else {
performSearch(e.target.value);
}
});
searchInput.addEventListener('focus', () => {
if (!searchInitialized) {
document.getElementById('search-results').innerHTML = '<p>Loading search index...</p>';
}
});
</script>
// Initialize with default config
initSearch();
</script>}
<style>
.search-container {

View file

@ -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
},

View file

@ -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;