diff --git a/README.md b/README.md
index f2bf902..ab2ab03 100644
--- a/README.md
+++ b/README.md
@@ -112,6 +112,11 @@ await pipeline.run();
 |
Probe distributions for availability and metadata |
+
+ | @lde/iiif-validator |
+  |
+ Validate that a URL resolves to a valid IIIF Presentation Manifest |
+
| @lde/sparql-importer |
 |
diff --git a/package-lock.json b/package-lock.json
index a551b2f..eac209a 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",
diff --git a/packages/iiif-validator/README.md b/packages/iiif-validator/README.md
new file mode 100644
index 0000000..444cece
--- /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 0000000..2dcaf60
--- /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 0000000..21330ed
--- /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 0000000..496cf0e
--- /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 0000000..e1fe6c2
--- /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 0000000..63572a3
--- /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 0000000..62ebbd9
--- /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 0000000..862bb81
--- /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 0000000..421e676
--- /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 0000000..c602866
--- /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 fdb313c..2b0b678 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -61,6 +61,9 @@
},
{
"path": "./packages/distribution-probe"
+ },
+ {
+ "path": "./packages/iiif-validator"
}
]
}