Add search with lunr and read time to site
- Add lunr dependency to package.json and pnpm-lock.yaml. - Implement SearchDialog component for full‑text search using lunr. - Add readTime prop to BlogCard with default 5 min read. - Add reading‑time utility for estimated read duration. - Update layout and page components to include new props. - Generate search.json data for indexing. Hubert The Eunuch
This commit is contained in:
parent
874e4ffe74
commit
adf3f376ba
@ -27,6 +27,7 @@
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"astro": "^5.16.4",
|
||||
"lunr": "^2.3.9",
|
||||
"marked": "^17.0.1",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@ -38,6 +38,9 @@ importers:
|
||||
astro:
|
||||
specifier: ^5.16.4
|
||||
version: 5.16.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(typescript@5.9.3)
|
||||
lunr:
|
||||
specifier: ^2.3.9
|
||||
version: 2.3.9
|
||||
marked:
|
||||
specifier: ^17.0.1
|
||||
version: 17.0.1
|
||||
@ -1960,6 +1963,9 @@ packages:
|
||||
lru-cache@5.1.1:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
lunr@2.3.9:
|
||||
resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==}
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
@ -4626,6 +4632,8 @@ snapshots:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
||||
lunr@2.3.9: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
@ -11,6 +11,7 @@ interface Props {
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
href: string;
|
||||
readTime?: string;
|
||||
variant?: 'default' | 'compact' | 'featured';
|
||||
class?: string;
|
||||
}
|
||||
@ -23,14 +24,11 @@ const {
|
||||
category,
|
||||
tags,
|
||||
href,
|
||||
readTime = '5 min read',
|
||||
variant = 'default',
|
||||
class: className = '',
|
||||
} = Astro.props;
|
||||
|
||||
// Compute estimated read time (rough estimate: 200 words per minute)
|
||||
// For now, we'll show a placeholder since we don't have word count in frontmatter
|
||||
const readTime = '5 min read';
|
||||
|
||||
const isCompact = variant === 'compact';
|
||||
const isFeatured = variant === 'featured';
|
||||
---
|
||||
|
||||
371
src/components/SearchDialog.tsx
Normal file
371
src/components/SearchDialog.tsx
Normal file
@ -0,0 +1,371 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import lunr from 'lunr';
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
tags: string[];
|
||||
url: string;
|
||||
pubDate: string;
|
||||
}
|
||||
|
||||
interface IndexedResult extends SearchResult {
|
||||
score: number;
|
||||
}
|
||||
|
||||
export default function SearchDialog() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<IndexedResult[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [searchData, setSearchData] = useState<SearchResult[]>([]);
|
||||
const [searchIndex, setSearchIndex] = useState<lunr.Index | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const resultsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load search data and build index
|
||||
useEffect(() => {
|
||||
fetch('/search.json')
|
||||
.then((res) => res.json())
|
||||
.then((data: SearchResult[]) => {
|
||||
setSearchData(data);
|
||||
|
||||
// Build Lunr index
|
||||
const idx = lunr(function () {
|
||||
this.ref('id');
|
||||
this.field('title', { boost: 10 });
|
||||
this.field('description', { boost: 5 });
|
||||
this.field('content');
|
||||
this.field('category', { boost: 3 });
|
||||
this.field('tags', { boost: 3 });
|
||||
|
||||
data.forEach((doc) => {
|
||||
this.add(doc);
|
||||
});
|
||||
});
|
||||
|
||||
setSearchIndex(idx);
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to load search data:', err);
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Keyboard shortcut to open search
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
}
|
||||
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
closeSearch();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen]);
|
||||
|
||||
// Focus input when dialog opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
inputRef.current?.focus();
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Real-time search
|
||||
useEffect(() => {
|
||||
if (!query.trim() || !searchIndex || !searchData) {
|
||||
setResults([]);
|
||||
setSelectedIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Add wildcards for partial matching
|
||||
const searchQuery = query
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((term) => `${term}* ${term}~1`)
|
||||
.join(' ');
|
||||
|
||||
const searchResults = searchIndex.search(searchQuery);
|
||||
const matchedResults = searchResults
|
||||
.map((result) => {
|
||||
const data = searchData.find((d) => d.id === result.ref);
|
||||
return data ? { ...data, score: result.score } : null;
|
||||
})
|
||||
.filter((r): r is IndexedResult => r !== null)
|
||||
.slice(0, 8);
|
||||
|
||||
setResults(matchedResults);
|
||||
setSelectedIndex(0);
|
||||
} catch (err) {
|
||||
// Fallback to simple search if query syntax is invalid
|
||||
try {
|
||||
const searchResults = searchIndex.search(query);
|
||||
const matchedResults = searchResults
|
||||
.map((result) => {
|
||||
const data = searchData.find((d) => d.id === result.ref);
|
||||
return data ? { ...data, score: result.score } : null;
|
||||
})
|
||||
.filter((r): r is IndexedResult => r !== null)
|
||||
.slice(0, 8);
|
||||
|
||||
setResults(matchedResults);
|
||||
setSelectedIndex(0);
|
||||
} catch {
|
||||
setResults([]);
|
||||
}
|
||||
}
|
||||
}, [query, searchIndex, searchData]);
|
||||
|
||||
const closeSearch = () => {
|
||||
setIsOpen(false);
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
setSelectedIndex(0);
|
||||
};
|
||||
|
||||
// Keyboard navigation
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => Math.max(prev - 1, 0));
|
||||
} else if (e.key === 'Enter' && results[selectedIndex]) {
|
||||
window.location.href = results[selectedIndex].url;
|
||||
}
|
||||
};
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
if (resultsRef.current && results.length > 0) {
|
||||
const selectedElement = resultsRef.current.children[selectedIndex] as HTMLElement;
|
||||
selectedElement?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
}, [selectedIndex, results]);
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="hidden md:flex items-center gap-3 px-4 py-2 border border-[var(--theme-border-primary)] bg-[var(--theme-hover-bg)] text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-[var(--theme-text-primary)] transition-all duration-300 text-xs"
|
||||
aria-label="Open search"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
<span className="font-mono text-[10px] uppercase tracking-wider">Search</span>
|
||||
<kbd className="px-2 py-1 bg-[var(--theme-bg-secondary)] border border-[var(--theme-border-secondary)] font-mono text-[9px] text-[var(--theme-text-subtle)]">
|
||||
⌘K
|
||||
</kbd>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh] px-4">
|
||||
{/* Backdrop with scan line effect */}
|
||||
<div
|
||||
className="absolute inset-0 bg-[var(--theme-bg-primary)]/95 backdrop-blur-md"
|
||||
onClick={closeSearch}
|
||||
style={{
|
||||
backgroundImage: 'linear-gradient(0deg, transparent 50%, rgba(221, 65, 50, 0.02) 50%)',
|
||||
backgroundSize: '100% 4px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Search Dialog */}
|
||||
<div className="relative w-full max-w-3xl animate-on-scroll fade-in is-visible">
|
||||
<div className="bg-[var(--theme-bg-secondary)] border-2 border-[var(--theme-border-primary)] shadow-2xl">
|
||||
{/* Header Bar */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b-2 border-[var(--theme-border-primary)] bg-[var(--theme-hover-bg)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="w-2 h-2 bg-brand-accent animate-pulse" />
|
||||
<div className="w-2 h-2 bg-[var(--theme-border-strong)]" />
|
||||
<div className="w-2 h-2 bg-[var(--theme-border-strong)]" />
|
||||
</div>
|
||||
<span className="text-[10px] font-mono font-bold uppercase tracking-[0.2em] text-brand-accent">
|
||||
/// SEARCH_QUERY
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={closeSearch}
|
||||
className="text-[9px] font-mono uppercase tracking-wider px-3 py-1.5 border border-[var(--theme-border-primary)] text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent transition-all"
|
||||
>
|
||||
[ESC]
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search Input */}
|
||||
<div className="px-6 py-5 border-b border-[var(--theme-border-secondary)]">
|
||||
<div className="flex items-center gap-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-brand-accent flex-shrink-0"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="ENTER SEARCH QUERY..."
|
||||
className="flex-1 bg-transparent border-none outline-none text-[var(--theme-text-primary)] placeholder:text-[var(--theme-text-subtle)] font-mono text-base tracking-wide uppercase"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setQuery('');
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
className="text-[10px] font-mono uppercase tracking-wider px-3 py-1.5 border border-[var(--theme-border-primary)] text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent transition-all"
|
||||
>
|
||||
[CLR]
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div
|
||||
ref={resultsRef}
|
||||
className="max-h-[55vh] overflow-y-auto"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="p-16 text-center">
|
||||
<div className="text-brand-accent font-mono text-sm uppercase tracking-widest mb-3 animate-pulse">
|
||||
/// INITIALIZING SEARCH PROTOCOL
|
||||
</div>
|
||||
<div className="flex justify-center gap-1">
|
||||
<div className="w-2 h-2 bg-brand-accent animate-pulse" style={{ animationDelay: '0ms' }} />
|
||||
<div className="w-2 h-2 bg-brand-accent animate-pulse" style={{ animationDelay: '150ms' }} />
|
||||
<div className="w-2 h-2 bg-brand-accent animate-pulse" style={{ animationDelay: '300ms' }} />
|
||||
</div>
|
||||
</div>
|
||||
) : results.length > 0 ? (
|
||||
<>
|
||||
{results.map((result, index) => (
|
||||
<a
|
||||
key={result.id}
|
||||
href={result.url}
|
||||
className={`block border-l-4 transition-all duration-200 ${
|
||||
index === selectedIndex
|
||||
? 'border-brand-accent bg-[var(--theme-hover-bg-strong)]'
|
||||
: 'border-transparent hover:border-[var(--theme-border-strong)] hover:bg-[var(--theme-hover-bg)]'
|
||||
}`}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<div className="px-6 py-5 border-b border-[var(--theme-border-secondary)]">
|
||||
<div className="flex items-start justify-between gap-4 mb-3">
|
||||
<h3 className="text-base font-bold text-[var(--theme-text-primary)] uppercase tracking-tight leading-tight">
|
||||
{result.title}
|
||||
</h3>
|
||||
{result.category && (
|
||||
<span className="px-2.5 py-1 text-[9px] font-mono font-bold uppercase tracking-widest border border-brand-accent/50 text-brand-accent whitespace-nowrap">
|
||||
{result.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-[var(--theme-text-secondary)] line-clamp-2 mb-3 leading-relaxed">
|
||||
{result.description}
|
||||
</p>
|
||||
{result.tags && result.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{result.tags.slice(0, 4).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-[9px] font-mono text-[var(--theme-text-muted)] uppercase"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</>
|
||||
) : query ? (
|
||||
<div className="p-16 text-center">
|
||||
<div className="text-[var(--theme-text-muted)] font-mono text-sm uppercase tracking-widest mb-3">
|
||||
/// NO RESULTS FOUND
|
||||
</div>
|
||||
<p className="text-[var(--theme-text-secondary)] text-sm font-mono">
|
||||
Query returned 0 matches. Try different keywords.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-16 text-center">
|
||||
<div className="text-[var(--theme-text-muted)] font-mono text-sm uppercase tracking-widest mb-3">
|
||||
/// AWAITING INPUT
|
||||
</div>
|
||||
<p className="text-[var(--theme-text-secondary)] text-sm font-mono">
|
||||
Begin typing to search all blog content
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{results.length > 0 && (
|
||||
<div className="px-6 py-4 bg-[var(--theme-hover-bg)] border-t-2 border-[var(--theme-border-primary)] flex items-center justify-between">
|
||||
<div className="flex items-center gap-6 text-[9px] font-mono text-[var(--theme-text-muted)] uppercase tracking-wider">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-brand-accent">↑↓</span> Navigate
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-brand-accent">↵</span> Select
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-brand-accent">ESC</span> Close
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-3 py-1.5 bg-[var(--theme-bg-secondary)] border border-[var(--theme-border-primary)]">
|
||||
<span className="text-[9px] font-mono font-bold text-brand-accent uppercase tracking-wider">
|
||||
{results.length} RESULT{results.length !== 1 ? 'S' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -70,7 +70,7 @@ const personSchema = {
|
||||
<!-- Theme initialization script - runs before page render to prevent flash -->
|
||||
<script is:inline>
|
||||
(function() {
|
||||
// Check localStorage first (persistent), then sessionStorage (current session)
|
||||
// Apply theme
|
||||
const storedLocal = localStorage.getItem('theme');
|
||||
const storedSession = sessionStorage.getItem('theme');
|
||||
const theme =
|
||||
@ -78,6 +78,12 @@ const personSchema = {
|
||||
(storedSession === 'light' || storedSession === 'dark') ? storedSession :
|
||||
'dark'; // Default fallback
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
|
||||
// Apply saved accent color
|
||||
const savedColor = localStorage.getItem('accent-color');
|
||||
if (savedColor) {
|
||||
document.documentElement.style.setProperty('--color-brand-accent', savedColor);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<BaseHead
|
||||
|
||||
@ -49,7 +49,26 @@ import { SITE_TITLE, SITE_DESCRIPTION } from '../consts';
|
||||
<span>TIMESTAMP</span>
|
||||
<span id="error-time">--:--:--</span>
|
||||
</div>
|
||||
<div class="mt-8 p-4 border border-white/5 bg-white/[0.02]">
|
||||
|
||||
<!-- ASCII Art -->
|
||||
<div class="mt-8 p-4 border border-white/5 bg-white/[0.02] text-brand-accent/60 text-[9px] leading-tight overflow-hidden">
|
||||
<pre> _____________________
|
||||
/ \
|
||||
/ SYSTEM FAILURE \
|
||||
/_________________________\
|
||||
| ___________________ |
|
||||
| | | |
|
||||
| | > FATAL ERROR | |
|
||||
| | > PAGE NOT FOUND | |
|
||||
| | > 0x404 | |
|
||||
| | _ | |
|
||||
| |___________________| |
|
||||
| |
|
||||
| ⚠ SIGNAL LOST ⚠ |
|
||||
\_______________________/</pre>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 p-4 border border-white/5 bg-white/[0.02]">
|
||||
<span class="block mb-2">> DIAGNOSTIC_TOOL --RUN</span>
|
||||
<span class="block text-brand-accent">> TRACE COMPLETE</span>
|
||||
<span class="block">> END OF LINE.</span>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
---
|
||||
import { type CollectionEntry, getCollection, render } from 'astro:content';
|
||||
import BlogPost from '../../layouts/BlogPost.astro';
|
||||
import { calculateReadingTime } from '../../utils/reading-time';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getCollection('blog');
|
||||
@ -90,11 +91,8 @@ interface Props {
|
||||
const { post, prevPost, nextPost, relatedPosts } = Astro.props;
|
||||
const { Content, headings } = await render(post);
|
||||
|
||||
// Calculate reading time (average 200 words per minute)
|
||||
const wordsPerMinute = 200;
|
||||
const wordCount = post.body?.split(/\s+/).length || 0;
|
||||
const readingTime = Math.max(1, Math.ceil(wordCount / wordsPerMinute));
|
||||
const readTimeText = `${readingTime} min read`;
|
||||
// Calculate reading time
|
||||
const readTimeText = calculateReadingTime(post.body);
|
||||
---
|
||||
|
||||
<BlogPost
|
||||
|
||||
@ -6,6 +6,7 @@ import FormattedDate from '../../components/FormattedDate.astro';
|
||||
import BlogCard from '../../components/BlogCard.astro';
|
||||
import BlogFilters from '../../components/BlogFilters.astro';
|
||||
import { SITE_DESCRIPTION, SITE_TITLE } from '../../consts';
|
||||
import { calculateReadingTime } from '../../utils/reading-time';
|
||||
|
||||
// Fetch all posts sorted by date (newest first)
|
||||
const allPosts = (await getCollection('blog')).sort(
|
||||
@ -114,7 +115,7 @@ const categories = [...new Set(allPosts.map((post) => post.data.category).filter
|
||||
</span>
|
||||
<span class="h-px w-8 bg-[var(--theme-border-strong)]"></span>
|
||||
<span class="text-[10px] font-mono text-[var(--theme-text-muted)] uppercase tracking-widest">
|
||||
5 min read
|
||||
{calculateReadingTime(featuredPost.body)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -202,6 +203,7 @@ const categories = [...new Set(allPosts.map((post) => post.data.category).filter
|
||||
category={post.data.category}
|
||||
tags={post.data.tags}
|
||||
href={`/blog/${post.id}/`}
|
||||
readTime={calculateReadingTime(post.body)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
23
src/pages/search.json.ts
Normal file
23
src/pages/search.json.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
export async function GET() {
|
||||
const posts = await getCollection('blog');
|
||||
|
||||
const searchData = posts.map((post) => ({
|
||||
id: post.id,
|
||||
title: post.data.title,
|
||||
description: post.data.description,
|
||||
content: post.body,
|
||||
category: post.data.category || '',
|
||||
tags: post.data.tags || [],
|
||||
url: `/blog/${post.id}/`,
|
||||
pubDate: post.data.pubDate.toISOString(),
|
||||
}));
|
||||
|
||||
return new Response(JSON.stringify(searchData), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
11
src/utils/reading-time.ts
Normal file
11
src/utils/reading-time.ts
Normal file
@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Calculate reading time for a given text content
|
||||
* @param content - The text content to analyze
|
||||
* @param wordsPerMinute - Reading speed (default: 200 wpm)
|
||||
* @returns Reading time string (e.g., "5 min read")
|
||||
*/
|
||||
export function calculateReadingTime(content: string, wordsPerMinute: number = 200): string {
|
||||
const wordCount = content?.split(/\s+/).length || 0;
|
||||
const readingTime = Math.max(1, Math.ceil(wordCount / wordsPerMinute));
|
||||
return `${readingTime} min read`;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user