Introduction

IIIF (International Image Interoperability Framework) is an international standard for image delivery widely used in digital archives and museum collections. The IIIF Content Search API allows you to search annotations (notes and tags) within manifests.

However, the IIIF Content Search API typically assumes server-side implementation, and it has been considered difficult to implement on static sites (GitHub Pages, Vercel, Netlify, etc.).

In this article, I will introduce a method for implementing the IIIF Content Search API on the client side using Service Workers. This approach enables the use of search functionality in IIIF viewers such as Mirador even on static sites.

Challenge

How Traditional IIIF Search API Works

[Mirador]  GET /search?q=keyword  [Server]  Search processing  JSON response

The IIIF Content Search API requires an endpoint that receives query parameters (?q=search term) and returns search results in JSON. This assumes dynamic server processing.

Limitations of Static Sites

On static sites:

  • Dynamic responses based on query parameters cannot be returned
  • Server-side search processing cannot be executed
  • Only static JSON files can be served

Solution: Request Interception with Service Workers

A Service Worker functions as a proxy positioned between the browser and the network. By leveraging this, we can intercept search requests and perform search processing on the client side.

Architecture

[Mirador]
    |
    | GET /iiif/site/search/index.json?q=keyword
    v
[Service Worker] <- Intercept
    |
    +-- Fetch static index.json (first time only)
    |
    +-- Execute search in JavaScript
    |
    +-- Respond in IIIF Content Search API format
    v
[Mirador] <- Display search results

Implementation

1. Generating the Search Index (At Build Time)

First, generate a search index from annotation data.

// build-search-index.js
function generateSearchIndex(annotations, options) {
  const entries = [];

  annotations.forEach(anno => {
    entries.push({
      text: anno.body_value,
      language: anno.body_language,
      motivation: anno.motivation,
      annotationId: `${options.baseUrl}/annotation/${anno.id}`,
      canvasId: `${options.baseUrl}/canvas/${anno.canvas_index}`,
      manifestId: `${options.baseUrl}/manifest.json`,
      target: anno.target_region
        ? `${canvasId}#xywh=${anno.target_region}`
        : canvasId
    });
  });

  return {
    '@context': 'http://iiif.io/api/search/2/context.json',
    type: 'SearchIndex',
    totalEntries: entries.length,
    entries
  };
}

Generated index.json:

{
  "@context": "http://iiif.io/api/search/2/context.json",
  "type": "SearchIndex",
  "totalEntries": 12,
  "entries": [
    {
      "text": "この画像は16世紀のゲッティンゲン地図です。",
      "language": "ja",
      "motivation": "commenting",
      "annotationId": "http://example.com/annotation/1",
      "canvasId": "http://example.com/canvas/1",
      "manifestId": "http://example.com/manifest.json",
      "target": "http://example.com/canvas/1"
    }
  ]
}

2. Service Worker Implementation

Create a Service Worker that intercepts search requests and processes them on the client side.

// iiif-search-sw.js

// Search index cache
const searchIndexCache = new Map();

/**
 * Load search index
 */
async function loadSearchIndex(indexUrl) {
  if (searchIndexCache.has(indexUrl)) {
    return searchIndexCache.get(indexUrl);
  }

  const response = await fetch(indexUrl);
  const index = await response.json();
  searchIndexCache.set(indexUrl, index);
  return index;
}

/**
 * Execute search
 */
function executeSearch(index, query) {
  const normalizedQuery = query.toLowerCase().trim();

  return index.entries
    .filter(entry => entry.text.toLowerCase().includes(normalizedQuery))
    .map(entry => ({
      entry,
      match: query,
      // Get surrounding context
      before: '...',
      after: '...'
    }));
}

/**
 * Generate response in IIIF Content Search API format
 */
function formatSearchResponse(requestUrl, results) {
  return {
    '@context': 'http://iiif.io/api/search/1/context.json',
    '@id': requestUrl,
    '@type': 'sc:AnnotationList',
    within: {
      '@type': 'sc:Layer',
      total: results.length
    },
    resources: results.map(r => ({
      '@id': r.entry.annotationId,
      '@type': 'oa:Annotation',
      motivation: r.entry.motivation,
      resource: {
        '@type': 'cnt:ContentAsText',
        chars: r.entry.text
      },
      on: r.entry.target
    })),
    hits: results.map(r => ({
      '@type': 'search:Hit',
      annotations: [r.entry.annotationId],
      match: r.match,
      before: r.before,
      after: r.after
    }))
  };
}

/**
 * Handle fetch events
 */
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);

  // Intercept IIIF search requests
  if (url.pathname.includes('/search/index.json') && url.searchParams.has('q')) {
    event.respondWith(handleSearchRequest(event.request, url));
  }
});

async function handleSearchRequest(request, url) {
  const query = url.searchParams.get('q') || '';
  const indexUrl = `${url.origin}${url.pathname}`;

  const index = await loadSearchIndex(indexUrl);
  const results = executeSearch(index, query);
  const responseData = formatSearchResponse(request.url, results);

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

self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => event.waitUntil(clients.claim()));

3. Service Worker Registration

Register the Service Worker when the application starts.

// register-iiif-search-sw.ts
export async function registerIIIFSearchServiceWorker(): Promise<boolean> {
  if (!('serviceWorker' in navigator)) {
    console.warn('Service Worker is not supported');
    return false;
  }

  try {
    const registration = await navigator.serviceWorker.register('/iiif-search-sw.js');
    console.log('IIIF Search Service Worker registered');
    return true;
  } catch (error) {
    console.error('Registration failed:', error);
    return false;
  }
}

4. Search Service Declaration in the Manifest

Declare the search service in the IIIF manifest.

{
  "@context": "http://iiif.io/api/presentation/3/context.json",
  "id": "http://example.com/manifest.json",
  "type": "Manifest",
  "service": [
    {
      "@context": "http://iiif.io/api/search/1/context.json",
      "@id": "http://example.com/search/index.json",
      "@type": "SearchService1",
      "profile": "http://iiif.io/api/search/1/search",
      "label": "Search within this manifest"
    }
  ]
}

5. Usage with Mirador

Simply registering the Service Worker before initializing Mirador enables the standard search functionality.

// MiradorViewer.tsx
import { registerIIIFSearchServiceWorker } from '@/lib/register-iiif-search-sw';

export default function MiradorViewer({ manifestUrl }) {
  useEffect(() => {
    // Register Service Worker
    registerIIIFSearchServiceWorker();
  }, []);

  // ... Mirador initialization
}

Operation Flow

1. User opens the page
   v
2. Service Worker is registered
   v
3. Mirador loads the manifest and detects the search service
   v
4. User types in the search box
   v
5. Mirador sends a search request
   GET /iiif/site/search/index.json?q=map
   v
6. Service Worker intercepts the request
   v
7. Static index.json is fetched (cached)
   v
8. Search is executed in JavaScript
   v
9. Response is generated in IIIF Content Search API format
   v
10. Mirador displays the search results

Benefits and Limitations

Benefits

ItemDescription
Static site compatibleWorks on GitHub Pages, Vercel, Netlify, etc.
No server requiredEverything is completed on the client side
Low costNo server maintenance costs
FastIndex is cached in the browser
No Mirador modification neededStandard Mirador works as-is

Limitations

ItemDescription
Index sizeInitial loading may take time for large collections
HTTPS requiredService Workers only work in HTTPS environments (localhost is an exception)
Browser supportNot supported in IE11 (all modern browsers are supported)
Full-text search limitationsCannot achieve the same precision as advanced full-text search engines

Handling Large Collections

When the index size becomes large, the following measures are effective:

  1. Index splitting: Split the index per manifest
  2. Lazy loading: Fetch the index only when searching
  3. Compression: Reduce file size with gzip compression
// Index URL per manifest
const indexUrl = `/iiif/${siteId}/3/${itemId}/search-index.json`;

Summary

By leveraging Service Workers, the IIIF Content Search API can be implemented even on static sites. The key points of this approach are:

  1. Generate search index at build time
  2. Intercept requests with Service Worker
  3. Execute search on the client side
  4. Return responses in IIIF standard format

This enables providing search functionality on static sites without modifying IIIF viewers like Mirador. Please consider this as an option for providing a rich search experience while keeping costs low with a serverless approach when building digital archives.