Astro กับ Algolia: Full-text Search

#astro13 เม.ย. 2569

Astro กับ Algolia: Full-text Search

Algolia เป็น search-as-a-service platform ที่ให้ full-text search ที่รวดเร็วและแม่นยำ ด้วย typo tolerance, faceted search, และ instant results ทำให้ Algolia เป็นตัวเลือกยอดนิยมสำหรับ websites ที่ต้องการ search functionality ระดับสูง

ทำไมต้องใช้ Algolia?

  • Speed: ผลลัพธ์ใน milliseconds
  • Typo Tolerance: ค้นหาได้แม้พิมพ์ผิด
  • Faceted Search: กรองผลลัพธ์ตาม attributes
  • Analytics: วิเคราะห์พฤติกรรมการค้นหา
  • InstantSearch: UI components สำเร็จรูป

การติดตั้ง

npm install algoliasearch @algolia/client-search
npm install react-instantsearch  # ถ้าใช้ React

ตั้งค่า environment variables:

# .env
ALGOLIA_APP_ID=your-app-id
ALGOLIA_SEARCH_KEY=your-search-only-api-key
ALGOLIA_ADMIN_KEY=your-admin-api-key
ALGOLIA_INDEX_NAME=your-index-name

สร้าง Algolia Client

// src/lib/algolia.ts
import algoliasearch from 'algoliasearch';

export const searchClient = algoliasearch(
  import.meta.env.ALGOLIA_APP_ID,
  import.meta.env.ALGOLIA_SEARCH_KEY
);

export const adminClient = algoliasearch(
  import.meta.env.ALGOLIA_APP_ID,
  import.meta.env.ALGOLIA_ADMIN_KEY
);

export const searchIndex = searchClient.initIndex(
  import.meta.env.ALGOLIA_INDEX_NAME
);

export const adminIndex = adminClient.initIndex(
  import.meta.env.ALGOLIA_INDEX_NAME
);

// TypeScript type สำหรับ search record
export interface SearchRecord {
  objectID: string;
  title: string;
  excerpt: string;
  content: string;
  slug: string;
  tags: string[];
  publishedAt: number; // Unix timestamp
  author: string;
}

Indexing Content

สร้าง script สำหรับ index content:

// scripts/indexContent.ts
import { adminIndex } from '../src/lib/algolia';
import type { SearchRecord } from '../src/lib/algolia';

async function indexAllContent() {
  // ดึงข้อมูลจาก CMS หรือ markdown files
  const posts = await getAllPosts();

  const records: SearchRecord[] = posts.map((post) => ({
    objectID: post.id,
    title: post.title,
    excerpt: post.excerpt,
    content: stripHtml(post.content).substring(0, 5000),
    slug: post.slug,
    tags: post.tags,
    publishedAt: new Date(post.publishedAt).getTime() / 1000,
    author: post.author.name,
  }));

  // Configure index settings
  await adminIndex.setSettings({
    searchableAttributes: [
      'title',
      'excerpt',
      'content',
      'tags',
      'author',
    ],
    attributesForFaceting: ['tags', 'author'],
    customRanking: ['desc(publishedAt)'],
    highlightPreTag: '<mark>',
    highlightPostTag: '</mark>',
  });

  // Batch index
  const { objectIDs } = await adminIndex.saveObjects(records);
  console.log(`Indexed ${objectIDs.length} records`);
}

function stripHtml(html: string): string {
  return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
}

async function getAllPosts() {
  // implement fetching posts
  return [];
}

indexAllContent().catch(console.error);

Search API Route

// src/pages/api/search.ts
import type { APIRoute } from 'astro';
import { searchIndex } from '../../lib/algolia';

export const GET: APIRoute = async ({ url }) => {
  const query = url.searchParams.get('q') ?? '';
  const page = parseInt(url.searchParams.get('page') ?? '0');
  const tags = url.searchParams.getAll('tag');

  if (!query.trim()) {
    return new Response(JSON.stringify({ hits: [], nbHits: 0 }), {
      headers: { 'Content-Type': 'application/json' },
    });
  }

  const results = await searchIndex.search(query, {
    page,
    hitsPerPage: 10,
    attributesToHighlight: ['title', 'excerpt'],
    attributesToSnippet: ['content:30'],
    facetFilters: tags.length ? [tags.map((t) => `tags:${t}`)] : undefined,
    facets: ['tags'],
  });

  return new Response(JSON.stringify(results), {
    headers: { 'Content-Type': 'application/json' },
  });
};

Search UI Component (React)

// src/components/SearchBox.tsx
import { useState, useCallback } from 'react';
import { useDebounce } from '../hooks/useDebounce';

interface SearchHit {
  objectID: string;
  title: string;
  excerpt: string;
  slug: string;
  _highlightResult?: {
    title?: { value: string };
    excerpt?: { value: string };
  };
}

export default function SearchBox() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<SearchHit[]>([]);
  const [loading, setLoading] = useState(false);
  const [isOpen, setIsOpen] = useState(false);

  const debouncedQuery = useDebounce(query, 300);

  const search = useCallback(async (q: string) => {
    if (!q.trim()) {
      setResults([]);
      return;
    }

    setLoading(true);
    try {
      const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
      const data = await res.json();
      setResults(data.hits);
    } finally {
      setLoading(false);
    }
  }, []);

  // Search when debounced query changes
  useState(() => {
    search(debouncedQuery);
  });

  return (
    <div className="search-container">
      <input
        type="search"
        value={query}
        onChange={(e) => {
          setQuery(e.target.value);
          setIsOpen(true);
        }}
        placeholder="ค้นหาบทความ..."
        className="search-input"
      />

      {isOpen && results.length > 0 && (
        <div className="search-results">
          {loading && <p>กำลังค้นหา...</p>}
          {results.map((hit) => (
            <a key={hit.objectID} href={`/blog/${hit.slug}`} className="search-hit">
              <h3
                dangerouslySetInnerHTML={{
                  __html: hit._highlightResult?.title?.value ?? hit.title,
                }}
              />
              <p
                dangerouslySetInnerHTML={{
                  __html: hit._highlightResult?.excerpt?.value ?? hit.excerpt,
                }}
              />
            </a>
          ))}
        </div>
      )}
    </div>
  );
}

Webhook สำหรับ Auto-indexing

// src/pages/api/algolia-sync.ts
import type { APIRoute } from 'astro';
import { adminIndex } from '../../lib/algolia';

export const POST: APIRoute = async ({ request }) => {
  const secret = request.headers.get('x-webhook-secret');
  if (secret !== import.meta.env.WEBHOOK_SECRET) {
    return new Response('Unauthorized', { status: 401 });
  }

  const { action, post } = await request.json();

  if (action === 'publish') {
    await adminIndex.saveObject({
      objectID: post.id,
      title: post.title,
      excerpt: post.excerpt,
      content: post.content,
      slug: post.slug,
      tags: post.tags,
    });
  } else if (action === 'unpublish' || action === 'delete') {
    await adminIndex.deleteObject(post.id);
  }

  return new Response(JSON.stringify({ success: true }));
};

สรุป

Algolia กับ Astro ทำให้การเพิ่ม search functionality เป็นเรื่องง่าย ด้วย instant search, typo tolerance, และ faceted filtering ทำให้ user experience ในการค้นหาดีขึ้นอย่างมาก การ sync content ผ่าน webhooks ทำให้ search index อัปเดตอัตโนมัติเมื่อมีการเปลี่ยนแปลง content