đŸ‘‹ Next.js Conf 2024: Come build, party, run, and connect with us! See all events
Last updated December 29, 2020

Sanity, IIIF Image API and manifests

By Tarje Lavik

Take advantage of the IIIF image capability of Sanity to construct a IIIF manifest.

Sanity supports the International Image Interoperability Framework (IIIF) Image API out of the box, so lets take advantage of this!

IIIF is both a metadata and API standard for images and how to present them. From iiif.io:

Access to image-based resources is fundamental to research, scholarship and the transmission of cultural knowledge. Digital images are a container for much of the information content in the Web-based delivery of images, books, newspapers, manuscripts, maps, scrolls, single sheet collections, and archival materials. Yet much of the Internet’s image-based resources are locked up in silos, with access restricted to bespoke, locally built applications.

As said, Sanity will serve images using IIIF if you construct this URI: https://cdn.sanity.io/image/{projectId}/{dataset}/iiif/{identifier}.

Let us use this to create a IIIF manifest that describe a thing in our Sanity dataset! A manifest contains basic metadata about the thing, the images of the thing and how to sequence the images. We can feed this manifest to viewers that will handle all the view logic for us. I recommend Mirador3, though this guide is not about how to implement this in your frontend...

The code is taken from an Next.js API on the path pages/api/manifest/[id].js. After getting the data we need to fix the image urls as we do not get the IIIF urls in the asset metadata and finally we construct a simple manifest.

I am not a good coder and this could probably be more elegant, but it works for a demonstration of the power of IIIF :-).

import client, {previewClient} from '../../../lib/sanity'
const getClient = (preview) => (preview ? previewClient : client)

/* 
  NB! Sanity does not store the IIIF Image API url in the asset metadata
  so we ned to fix this.
*/
const fixIIIFUrl = i => {
  const url = new URL(i)
  const p = url.pathname.split('/')
  const imageUrl = url.protocol + '//' + url.hostname + p.slice(0,-1).join('/') + '/iiif/' + p.slice(-1)
  return imageUrl
}

/* 
  Construct a IIIF Presentation v3 manifest json
*/
const constructManifest = async (object) => {
  const iiified = {
    ...object,
    images: object.mainRepresentation.images.map(i => ({
      ...i,
      url: fixIIIFUrl(i.url)
    }))
  }

  const manifest = {
    "@context": "http://iiif.io/api/presentation/3/context.json",
    id: `https://example.org/iiif/${iiified._id}/manifest`,
    type: "Manifest",
    label: { "none": [ `${iiified.label}` ] },
    provider: [
      {
        id: "https://example.org",
        type: "Agent",
        label: { 
          no: [ "Eksempel organisasjon" ],
          en: [ "Example organisation" ] 
        },
        homepage: [
          {
            id: "https://example.org",
            type: "Text",
            label: { 
              no: [ "Eksempel organisasjon hjemmeside" ],
              en: [ "Example organisation Homepage" ] 
            },
            format: "text/html"
          }
        ],
        logo: [
          {
            id: "https://example.org/logo.svg",
            type: "Image",
            format: "image/svg+xml"
          }
        ]
      }
    ],
    rights: "https://creativecommons.org/licenses/by/4.0/",
    requiredStatement: {
      label: { 
        no: [ "Kreditering" ],
        en: [ "Attribution" ] 
      },
      value: { 
        no: [ "Tilgjengeliggjort av Eksempel organisasjon" ],
        en: [ "Provided by Example organisation" ] 
      }
    },
    items: [
      ...iiified.images.map((image, index) => {
        return {
          id: `https://example.org/iiif/${iiified._id}/canvas/${index+1}`,
          type: "Canvas",
          label: {
            none: [ `${index+1}` ]
          },
          width: image.width,
          height: image.height,
          items: [
            {
              id: `https://example.org/iiif/${iiified._id}/page/${index+1}`,
              type: "AnnotationPage",
              items: [
                {
                  id: `https://example.org/iiif/${iiified._id}/annotation/p${index+1}`,
                  type: "Annotation",
                  motivation: "painting",
                  target: `https://example.org/iiif/${iiified._id}/canvas/${index+1}`,
                  body: {
                    id: image.url,
                    type: "Image",
                    format: "image/jpeg",
                    service: {
                      id: image.url,
                      type: "ImageService2",
                      profile: "level2"
                    }
                  }
                }
              ]
            }
          ]
        }
      })
    ],
    structures: [{
      id: `https://example.org/iiif/${iiified._id}/seq/s1`,
      type: "Range",
      label: {
        en: [ "Table of contents" ]
      },
      items: [
        ...iiified.images.map((image, index) => {
          return {
              type: "Canvas",
              id: `https://example.org/iiif/${iiified._id}/canvas/${index+1}`
            }
          })
        ]
      }
    ]
  }
  return manifest
}

export default async function handler(req, res) {
  const {
    query: {id},
    method,
  } = req
  const preview = false

  /* 
    Change the query to fit your data :-)
  */
  async function getObject(id, preview = false) {
    const results = await getClient(preview).fetch(
      `*[_id == $id] {
        _id,
        label,
        mainRepresentation {
          "images": [
            asset-> {
              url, 
              "height": metadata.dimensions.height,
              "width": metadata.dimensions.width
            }
          ]
        }
      }`,
      {id},
    )
    return results
  }

  switch (method) {
    case 'GET':
      const results = getObject(id, preview)
      const object = await results

      const constructedManifest = constructManifest(object[0])
      const manifest = await constructedManifest

      res.status(200).json(manifest)
      break
    default:
      res.setHeader('Allow', ['GET'])
      res.status(405).end(`Method ${method} Not Allowed`)
  }
}

Sanity – build remarkable experiences at scale

Sanity Composable Content Cloud is the headless CMS that gives you (and your team) a content backend to drive websites and applications with modern tooling. It offers a real-time editing environment for content creators that’s easy to configure but designed to be customized with JavaScript and React when needed. With the hosted document store, you query content freely and easily integrate with any framework or data source to distribute and enrich content.

Sanity scales from weekend projects to enterprise needs and is used by companies like Puma, AT&T, Burger King, Tata, and Figma.