Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ await pipeline.run();
<td><a href="https://www.npmjs.com/package/@lde/distribution-probe"><img src="https://img.shields.io/npm/v/@lde/distribution-probe" alt="npm"></a></td>
<td>Probe distributions for availability and metadata</td>
</tr>
<tr>
<td><a href="packages/iiif-validator">@lde/iiif-validator</a></td>
<td><a href="https://www.npmjs.com/package/@lde/iiif-validator"><img src="https://img.shields.io/npm/v/@lde/iiif-validator" alt="npm"></a></td>
<td>Validate that a URL resolves to a valid IIIF Presentation Manifest</td>
</tr>
<tr>
<td><a href="packages/sparql-importer">@lde/sparql-importer</a></td>
<td><a href="https://www.npmjs.com/package/@lde/sparql-importer"><img src="https://img.shields.io/npm/v/@lde/sparql-importer" alt="npm"></a></td>
Expand Down
40 changes: 26 additions & 14 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

44 changes: 44 additions & 0 deletions packages/iiif-validator/README.md
Original file line number Diff line number Diff line change
@@ -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;
}
```
22 changes: 22 additions & 0 deletions packages/iiif-validator/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -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'),
},
},
];
29 changes: 29 additions & 0 deletions packages/iiif-validator/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
6 changes: 6 additions & 0 deletions packages/iiif-validator/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export {
validateManifest,
type ManifestValidation,
type ManifestValidationReason,
type ValidateManifestOptions,
} from './validateManifest.js';
135 changes: 135 additions & 0 deletions packages/iiif-validator/src/validateManifest.ts
Original file line number Diff line number Diff line change
@@ -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<ManifestValidation> {
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<string, unknown>;
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<string, unknown>): 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');
}
Loading