From c9522aea2fa5d39488cf4dcac062d9701f3091a1 Mon Sep 17 00:00:00 2001 From: David de Boer Date: Thu, 4 Jun 2026 10:45:13 +0200 Subject: [PATCH 1/2] feat(iiif-validator): add IIIF Presentation Manifest validator New dependency-light package exposing validateManifest(url, { fetch, timeoutMs }). Dereferences a URL with Accept: application/ld+json and an AbortSignal timeout. Lightweight version-aware check: accepts v2 (sc:Manifest) and v3 (Manifest), @context as string, array or object. Never throws; returns a coarse verdict. fetch is injectable. --- README.md | 5 + packages/iiif-validator/README.md | 44 ++++++ packages/iiif-validator/eslint.config.mjs | 22 +++ packages/iiif-validator/package.json | 29 ++++ packages/iiif-validator/src/index.ts | 6 + .../iiif-validator/src/validateManifest.ts | 135 ++++++++++++++++ .../test/validateManifest.test.ts | 145 ++++++++++++++++++ packages/iiif-validator/tsconfig.json | 13 ++ packages/iiif-validator/tsconfig.lib.json | 13 ++ packages/iiif-validator/tsconfig.spec.json | 23 +++ packages/iiif-validator/vite.config.ts | 22 +++ tsconfig.json | 3 + 12 files changed, 460 insertions(+) create mode 100644 packages/iiif-validator/README.md create mode 100644 packages/iiif-validator/eslint.config.mjs create mode 100644 packages/iiif-validator/package.json create mode 100644 packages/iiif-validator/src/index.ts create mode 100644 packages/iiif-validator/src/validateManifest.ts create mode 100644 packages/iiif-validator/test/validateManifest.test.ts create mode 100644 packages/iiif-validator/tsconfig.json create mode 100644 packages/iiif-validator/tsconfig.lib.json create mode 100644 packages/iiif-validator/tsconfig.spec.json create mode 100644 packages/iiif-validator/vite.config.ts diff --git a/README.md b/README.md index f2bf9026..ab2ab03a 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,11 @@ await pipeline.run(); npm Probe distributions for availability and metadata + + @lde/iiif-validator + npm + Validate that a URL resolves to a valid IIIF Presentation Manifest + @lde/sparql-importer npm diff --git a/packages/iiif-validator/README.md b/packages/iiif-validator/README.md new file mode 100644 index 00000000..444cece4 --- /dev/null +++ b/packages/iiif-validator/README.md @@ -0,0 +1,44 @@ +# IIIF Validator + +Validates that a URL dereferences to a valid [IIIF Presentation](https://iiif.io/api/presentation/) Manifest. A small, dependency-light building block for Linked Data tooling that needs to tell a _declared_ IIIF manifest apart from one that actually resolves and parses. + +```ts +import { validateManifest } from '@lde/iiif-validator'; + +const verdict = await validateManifest('https://example.org/manifest.json'); +if (verdict.valid) { + // verdict.reason === 'valid-manifest' +} +``` + +`validateManifest` never throws; every outcome is reported as a `ManifestValidation`: + +```ts +interface ManifestValidation { + valid: boolean; + reason: + | 'valid-manifest' + | 'timeout' + | 'network-error' + | 'http-error' + | 'invalid-json' + | 'not-a-manifest'; +} +``` + +## Behaviour + +- **Dereference over HTTP** with `Accept: application/ld+json, application/json`, following redirects, using the global `fetch` with an `AbortSignal` timeout (default 10 000 ms). Both are injectable via the options argument (`fetch`, `timeoutMs`). +- **Lightweight, version-aware structural check.** A document is valid when the response is HTTP 2xx, the body parses as JSON, its `@context` references an IIIF Presentation context, and its `type`/`@type` indicates a manifest – accepting both v3 (`Manifest`) and v2 (`sc:Manifest`). The `@context` value may be a string, an array, or an object; all forms are handled. The version segment of the context is accepted version-agnostically. +- **Strict failure semantics, no retries.** A timeout, network error, non-2xx status, unparseable body, missing IIIF `@context`, or wrong `type` all yield `valid: false` with the corresponding coarse `reason`. There is no deep JSON Schema validation and no dependency on the hosted IIIF Presentation Validator service. + +## Options + +```ts +interface ValidateManifestOptions { + /** `fetch` implementation to use. Injectable for testing; defaults to the global `fetch`. */ + fetch?: typeof globalThis.fetch; + /** Per-request timeout in milliseconds. Defaults to 10 000. */ + timeoutMs?: number; +} +``` diff --git a/packages/iiif-validator/eslint.config.mjs b/packages/iiif-validator/eslint.config.mjs new file mode 100644 index 00000000..2dcaf60c --- /dev/null +++ b/packages/iiif-validator/eslint.config.mjs @@ -0,0 +1,22 @@ +import baseConfig from '../../eslint.config.mjs'; + +export default [ + ...baseConfig, + { + files: ['**/*.json'], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: [ + '{projectRoot}/eslint.config.{js,cjs,mjs}', + '{projectRoot}/vite.config.{js,ts,mjs,mts}', + ], + }, + ], + }, + languageOptions: { + parser: await import('jsonc-eslint-parser'), + }, + }, +]; diff --git a/packages/iiif-validator/package.json b/packages/iiif-validator/package.json new file mode 100644 index 00000000..21330ed0 --- /dev/null +++ b/packages/iiif-validator/package.json @@ -0,0 +1,29 @@ +{ + "name": "@lde/iiif-validator", + "version": "0.1.0", + "repository": { + "url": "git+https://github.com/ldelements/lde.git", + "directory": "packages/iiif-validator" + }, + "license": "MIT", + "type": "module", + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "development": "./src/index.ts", + "default": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "!**/*.tsbuildinfo" + ], + "dependencies": { + "tslib": "^2.3.0" + } +} diff --git a/packages/iiif-validator/src/index.ts b/packages/iiif-validator/src/index.ts new file mode 100644 index 00000000..496cf0e9 --- /dev/null +++ b/packages/iiif-validator/src/index.ts @@ -0,0 +1,6 @@ +export { + validateManifest, + type ManifestValidation, + type ManifestValidationReason, + type ValidateManifestOptions, +} from './validateManifest.js'; diff --git a/packages/iiif-validator/src/validateManifest.ts b/packages/iiif-validator/src/validateManifest.ts new file mode 100644 index 00000000..e1fe6c26 --- /dev/null +++ b/packages/iiif-validator/src/validateManifest.ts @@ -0,0 +1,135 @@ +/** + * Coarse outcome of a manifest validation. On failure the reason describes + * *what kind* of failure occurred, not a detailed diagnosis — only enough to + * tell apart an unreachable host from a malformed document. + */ +export type ManifestValidationReason = + | 'valid-manifest' + | 'timeout' + | 'network-error' + | 'http-error' + | 'invalid-json' + | 'not-a-manifest'; + +/** + * Verdict returned by {@link validateManifest}. + */ +export interface ManifestValidation { + /** Whether the URL dereferenced to a valid IIIF Presentation Manifest. */ + valid: boolean; + /** Coarse classification of the outcome. */ + reason: ManifestValidationReason; +} + +/** + * Options for {@link validateManifest}. + */ +export interface ValidateManifestOptions { + /** + * `fetch` implementation to use. Injectable for testing; defaults to the + * global `fetch`. + */ + fetch?: typeof globalThis.fetch; + /** Per-request timeout in milliseconds. Defaults to 10 000. */ + timeoutMs?: number; +} + +const IIIF_PRESENTATION_CONTEXT = 'iiif.io/api/presentation/'; +const DEFAULT_TIMEOUT_MS = 10_000; + +/** + * Dereference a URL and check whether it is a valid IIIF Presentation + * Manifest. Never throws; every outcome is reported as a + * {@link ManifestValidation}. + */ +export async function validateManifest( + url: string, + options?: ValidateManifestOptions, +): Promise { + const doFetch = options?.fetch ?? globalThis.fetch; + const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; + + let response: Response; + try { + response = await doFetch(url, { + headers: { Accept: 'application/ld+json, application/json' }, + signal: AbortSignal.timeout(timeoutMs), + }); + } catch (error) { + return { valid: false, reason: classifyFetchError(error) }; + } + + if (!response.ok) { + return { valid: false, reason: 'http-error' }; + } + + let body: unknown; + try { + body = await response.json(); + } catch { + return { valid: false, reason: 'invalid-json' }; + } + + if (isPresentationManifest(body)) { + return { valid: true, reason: 'valid-manifest' }; + } + return { valid: false, reason: 'not-a-manifest' }; +} + +/** + * Classify a thrown `fetch` error. An aborted request (our own + * `AbortSignal.timeout` firing, surfaced as `AbortError`/`TimeoutError`) + * counts as a timeout; anything else (DNS failure, connection refused, TLS) is + * a network error. + */ +function classifyFetchError(error: unknown): ManifestValidationReason { + if ( + error instanceof Error && + (error.name === 'AbortError' || error.name === 'TimeoutError') + ) { + return 'timeout'; + } + return 'network-error'; +} + +/** + * Structural check: the document declares an IIIF Presentation `@context` and + * a manifest `type` (`Manifest` in v3, `sc:Manifest` in v2). The version + * segment of the context is not constrained, matching the forwards-compatible + * spirit of the detection query. + */ +function isPresentationManifest(body: unknown): boolean { + if (typeof body !== 'object' || body === null) return false; + const document = body as Record; + return ( + hasPresentationContext(document['@context']) && hasManifestType(document) + ); +} + +function hasPresentationContext(context: unknown): boolean { + return contextStrings(context).some((value) => + value.includes(IIIF_PRESENTATION_CONTEXT), + ); +} + +/** + * Flatten a JSON-LD `@context` to the string IRIs it contains. The value may + * be a string, an array (mixing strings and objects), or a single object. + */ +function contextStrings(context: unknown): string[] { + if (typeof context === 'string') return [context]; + if (Array.isArray(context)) { + return context.flatMap((entry) => contextStrings(entry)); + } + if (typeof context === 'object' && context !== null) { + return Object.values(context).flatMap((entry) => contextStrings(entry)); + } + return []; +} + +function hasManifestType(document: Record): boolean { + const types = [document['type'], document['@type']] + .flatMap((value) => (Array.isArray(value) ? value : [value])) + .filter((value): value is string => typeof value === 'string'); + return types.includes('Manifest') || types.includes('sc:Manifest'); +} diff --git a/packages/iiif-validator/test/validateManifest.test.ts b/packages/iiif-validator/test/validateManifest.test.ts new file mode 100644 index 00000000..63572a3e --- /dev/null +++ b/packages/iiif-validator/test/validateManifest.test.ts @@ -0,0 +1,145 @@ +import { validateManifest } from '../src/index.js'; +import { describe, it, expect } from 'vitest'; + +/** + * Build a fake `fetch` that always resolves with the given body and status. + */ +function fetchReturning( + body: string, + init?: { status?: number; contentType?: string }, +): typeof globalThis.fetch { + return (async () => + new Response(body, { + status: init?.status ?? 200, + headers: { 'Content-Type': init?.contentType ?? 'application/ld+json' }, + })) as typeof globalThis.fetch; +} + +const URL = 'https://example.org/manifest.json'; + +describe('validateManifest', () => { + it('accepts a IIIF Presentation v3 Manifest', async () => { + const fetch = fetchReturning( + JSON.stringify({ + '@context': 'http://iiif.io/api/presentation/3/context.json', + id: URL, + type: 'Manifest', + }), + ); + + const verdict = await validateManifest(URL, { fetch }); + + expect(verdict).toEqual({ valid: true, reason: 'valid-manifest' }); + }); + + it('accepts a IIIF Presentation v2 Manifest (sc:Manifest)', async () => { + const fetch = fetchReturning( + JSON.stringify({ + '@context': 'http://iiif.io/api/presentation/2/context.json', + '@id': URL, + '@type': 'sc:Manifest', + }), + ); + + const verdict = await validateManifest(URL, { fetch }); + + expect(verdict).toEqual({ valid: true, reason: 'valid-manifest' }); + }); + + it('accepts an @context array that includes the IIIF Presentation context', async () => { + const fetch = fetchReturning( + JSON.stringify({ + '@context': [ + 'http://www.w3.org/ns/anno.jsonld', + 'http://iiif.io/api/presentation/3/context.json', + ], + id: URL, + type: 'Manifest', + }), + ); + + const verdict = await validateManifest(URL, { fetch }); + + expect(verdict).toEqual({ valid: true, reason: 'valid-manifest' }); + }); + + it('accepts an @context object that references the IIIF Presentation context', async () => { + const fetch = fetchReturning( + JSON.stringify({ + '@context': { + '@import': 'http://iiif.io/api/presentation/3/context.json', + }, + id: URL, + type: 'Manifest', + }), + ); + + const verdict = await validateManifest(URL, { fetch }); + + expect(verdict).toEqual({ valid: true, reason: 'valid-manifest' }); + }); + + it('reports http-error for a non-2xx response', async () => { + const fetch = fetchReturning('Not Found', { status: 404 }); + + const verdict = await validateManifest(URL, { fetch }); + + expect(verdict).toEqual({ valid: false, reason: 'http-error' }); + }); + + it('reports invalid-json for an unparseable body', async () => { + const fetch = fetchReturning('not json'); + + const verdict = await validateManifest(URL, { fetch }); + + expect(verdict).toEqual({ valid: false, reason: 'invalid-json' }); + }); + + it('reports not-a-manifest for a non-Manifest type (e.g. a Collection)', async () => { + const fetch = fetchReturning( + JSON.stringify({ + '@context': 'http://iiif.io/api/presentation/3/context.json', + id: URL, + type: 'Collection', + }), + ); + + const verdict = await validateManifest(URL, { fetch }); + + expect(verdict).toEqual({ valid: false, reason: 'not-a-manifest' }); + }); + + it('reports not-a-manifest when the IIIF Presentation @context is absent', async () => { + const fetch = fetchReturning( + JSON.stringify({ + '@context': 'http://schema.org/', + id: URL, + type: 'Manifest', + }), + ); + + const verdict = await validateManifest(URL, { fetch }); + + expect(verdict).toEqual({ valid: false, reason: 'not-a-manifest' }); + }); + + it('reports timeout when the request aborts', async () => { + const fetch = (async () => { + throw new DOMException('The operation timed out.', 'TimeoutError'); + }) as typeof globalThis.fetch; + + const verdict = await validateManifest(URL, { fetch }); + + expect(verdict).toEqual({ valid: false, reason: 'timeout' }); + }); + + it('reports network-error when the request fails to connect', async () => { + const fetch = (async () => { + throw new TypeError('fetch failed'); + }) as typeof globalThis.fetch; + + const verdict = await validateManifest(URL, { fetch }); + + expect(verdict).toEqual({ valid: false, reason: 'network-error' }); + }); +}); diff --git a/packages/iiif-validator/tsconfig.json b/packages/iiif-validator/tsconfig.json new file mode 100644 index 00000000..62ebbd94 --- /dev/null +++ b/packages/iiif-validator/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/iiif-validator/tsconfig.lib.json b/packages/iiif-validator/tsconfig.lib.json new file mode 100644 index 00000000..862bb817 --- /dev/null +++ b/packages/iiif-validator/tsconfig.lib.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo", + "emitDeclarationOnly": false, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "references": [], + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/packages/iiif-validator/tsconfig.spec.json b/packages/iiif-validator/tsconfig.spec.json new file mode 100644 index 00000000..421e6769 --- /dev/null +++ b/packages/iiif-validator/tsconfig.spec.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./out-tsc/vitest", + "types": ["node"] + }, + "include": [ + "test/**/*.test.ts", + "test/**/*.spec.ts", + "test/**/*.test.tsx", + "test/**/*.spec.tsx", + "test/**/*.test.js", + "test/**/*.spec.js", + "test/**/*.test.jsx", + "test/**/*.spec.jsx", + "test/**/*.d.ts" + ], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/packages/iiif-validator/vite.config.ts b/packages/iiif-validator/vite.config.ts new file mode 100644 index 00000000..c6028661 --- /dev/null +++ b/packages/iiif-validator/vite.config.ts @@ -0,0 +1,22 @@ +/// +import { defineConfig, mergeConfig } from 'vite'; +import baseConfig from '../../vite.base.config.js'; + +export default mergeConfig( + baseConfig, + defineConfig({ + root: __dirname, + cacheDir: '../../node_modules/.vite/packages/iiif-validator', + test: { + coverage: { + thresholds: { + autoUpdate: true, + lines: 96.96, + functions: 100, + branches: 87.09, + statements: 94.59, + }, + }, + }, + }), +); diff --git a/tsconfig.json b/tsconfig.json index fdb313c0..2b0b678e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -61,6 +61,9 @@ }, { "path": "./packages/distribution-probe" + }, + { + "path": "./packages/iiif-validator" } ] } From 40c69e3bec00c081a91f468cc56103d567af0cdf Mon Sep 17 00:00:00 2001 From: David de Boer Date: Thu, 4 Jun 2026 10:47:09 +0200 Subject: [PATCH 2/2] chore(iiif-validator): add package to workspace lock file --- package-lock.json | 40 ++++++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index a551b2f2..eac209a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25310,6 +25310,10 @@ "resolved": "packages/fastify-rdf", "link": true }, + "node_modules/@lde/iiif-validator": { + "resolved": "packages/iiif-validator", + "link": true + }, "node_modules/@lde/local-sparql-endpoint": { "resolved": "packages/local-sparql-endpoint", "link": true @@ -40853,11 +40857,11 @@ }, "packages/distribution-monitor": { "name": "@lde/distribution-monitor", - "version": "0.1.3", + "version": "0.1.4", "license": "MIT", "dependencies": { "@lde/dataset": "0.7.4", - "@lde/distribution-probe": "0.1.4", + "@lde/distribution-probe": "0.1.5", "c12": "^3.3.4", "commander": "^14.0.3", "cron": "^4.1.0", @@ -40884,7 +40888,7 @@ }, "packages/distribution-probe": { "name": "@lde/distribution-probe", - "version": "0.1.4", + "version": "0.1.5", "license": "MIT", "dependencies": { "@lde/dataset": "0.7.4", @@ -41612,6 +41616,14 @@ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "license": "MIT" }, + "packages/iiif-validator": { + "name": "@lde/iiif-validator", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + } + }, "packages/local-sparql-endpoint": { "name": "@lde/local-sparql-endpoint", "version": "0.2.13", @@ -41627,12 +41639,12 @@ }, "packages/pipeline": { "name": "@lde/pipeline", - "version": "0.30.3", + "version": "0.30.4", "license": "MIT", "dependencies": { "@lde/dataset": "0.7.4", "@lde/dataset-registry-client": "0.8.0", - "@lde/distribution-probe": "0.1.4", + "@lde/distribution-probe": "0.1.5", "@lde/sparql-importer": "0.6.2", "@lde/sparql-server": "0.4.11", "@rdfjs/types": "^2.0.1", @@ -41650,7 +41662,7 @@ }, "packages/pipeline-console-reporter": { "name": "@lde/pipeline-console-reporter", - "version": "0.21.3", + "version": "0.21.4", "license": "MIT", "dependencies": { "chalk": "^5.4.1", @@ -41661,7 +41673,7 @@ }, "peerDependencies": { "@lde/dataset": "0.7.4", - "@lde/pipeline": "0.30.3" + "@lde/pipeline": "0.30.4" } }, "packages/pipeline-console-reporter/node_modules/ansi-regex": { @@ -41841,7 +41853,7 @@ }, "packages/pipeline-shacl-sampler": { "name": "@lde/pipeline-shacl-sampler", - "version": "0.4.3", + "version": "0.4.4", "license": "MIT", "dependencies": { "@rdfjs/types": "^2.0.1", @@ -41851,7 +41863,7 @@ }, "peerDependencies": { "@lde/dataset": "0.7.4", - "@lde/pipeline": "0.30.3" + "@lde/pipeline": "0.30.4" } }, "packages/pipeline-shacl-sampler/node_modules/n3": { @@ -41869,7 +41881,7 @@ }, "packages/pipeline-shacl-validator": { "name": "@lde/pipeline-shacl-validator", - "version": "0.12.3", + "version": "0.12.4", "license": "MIT", "dependencies": { "@rdfjs/types": "^2.0.1", @@ -41883,7 +41895,7 @@ }, "peerDependencies": { "@lde/dataset": "0.7.4", - "@lde/pipeline": "0.30.3" + "@lde/pipeline": "0.30.4" } }, "packages/pipeline-shacl-validator/node_modules/n3": { @@ -41902,7 +41914,7 @@ }, "packages/pipeline-void": { "name": "@lde/pipeline-void", - "version": "0.28.3", + "version": "0.28.4", "license": "MIT", "dependencies": { "@rdfjs/types": "^2.0.1", @@ -41912,7 +41924,7 @@ }, "peerDependencies": { "@lde/dataset": "0.7.4", - "@lde/pipeline": "0.30.3" + "@lde/pipeline": "0.30.4" } }, "packages/pipeline-void/node_modules/n3": { @@ -41991,7 +42003,7 @@ }, "packages/sparql-qlever": { "name": "@lde/sparql-qlever", - "version": "0.14.4", + "version": "0.14.5", "license": "MIT", "dependencies": { "@lde/dataset": "0.7.4",