@ndes/iiif-images 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,117 @@
1
- # Temporary Holding Version
1
+ # @ndes/iiif-images
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Enriches SCHEMA-AP-NDE works with the dimensions and sizes of the IIIF image that
4
+ represents them, so a frontend can reserve the right aspect ratio and pick a size
5
+ before the image loads.
6
+
7
+ It is a quad transform for `@lde/search-indexer`. For each work with a IIIF
8
+ Presentation manifest in `associatedMedia`, it reads the manifest, selects an image
9
+ service, reads that service’s `info.json` and mints a nested `representativeImage`.
10
+
11
+ ## Usage
12
+
13
+ Declare the fields in your schema module. The `/schema` entry point has no runtime
14
+ dependencies, so a bundled schema module stays self-contained:
15
+
16
+ ```ts
17
+ import {
18
+ iiifImageTypes,
19
+ representativeImageField,
20
+ } from '@ndes/iiif-images/schema';
21
+
22
+ const creativeWork = defineSearchType({
23
+ name: 'CreativeWork',
24
+ class: 'https://schema.org/CreativeWork',
25
+ fields: [representativeImageField /* … */],
26
+ });
27
+ ```
28
+
29
+ Add `iiifImageTypes` to the schema, then attach the transform after any transform
30
+ that repairs IRIs:
31
+
32
+ ```ts
33
+ import { iiifImages } from '@ndes/iiif-images';
34
+
35
+ const images = iiifImages(schema, { storePath: '/provenance/iiif.sqlite' });
36
+ await images.check();
37
+
38
+ const transform = images.transformFor(creativeWork);
39
+ const pipeline = await createSearchIndexer(config, {
40
+ transforms: {
41
+ CreativeWork: [anyUriToIri(schema), ...(transform ? [transform] : [])],
42
+ },
43
+ });
44
+ ```
45
+
46
+ `transformFor` answers `undefined` for a root type that does not declare
47
+ `representativeImage` and `associatedMedia`, so a deployment on an older schema loses
48
+ the enrichment rather than the run.
49
+
50
+ | Option | Default | Purpose |
51
+ | --------------- | --------------- | -------------------------------------------------------- |
52
+ | `storePath` | required | SQLite file holding resolved facts between runs |
53
+ | `fetchesPerRun` | 500 | Manifests, and separately `info.json` documents, per run |
54
+ | `report` | console warning | Called for each reference whose fetch failed |
55
+
56
+ `storePath` has to be on a durable volume. The store is the only place these facts
57
+ live between runs, and with an empty one every image is fetched from the publisher
58
+ again.
59
+
60
+ ## Consuming the field
61
+
62
+ ```json
63
+ {
64
+ "representativeImage": {
65
+ "width": 1335,
66
+ "height": 2750,
67
+ "sizes": [
68
+ {
69
+ "width": 83,
70
+ "height": 172,
71
+ "url": "https://example.org/iiif/image/full/83,172/0/default.jpg"
72
+ }
73
+ ],
74
+ "service": {
75
+ "url": "https://example.org/iiif/image"
76
+ }
77
+ }
78
+ }
79
+ ```
80
+
81
+ - Pick the smallest entry in `sizes` that is at least your target width. These are
82
+ sizes the server lists or its tiles precompute, so they are served fastest. Do not
83
+ rely on the array’s order.
84
+ - Use `service` only for deep zoom or region crops.
85
+ - Without `representativeImage`, use `associatedMedia[].thumbnailUrl`, with no
86
+ dimensions. That is the case for a work with no manifest, no image service in its
87
+ manifest, or a fetch that has not succeeded yet.
88
+
89
+ ## Behaviour
90
+
91
+ - **Selection:** the first painted image in the manifest that has an image service,
92
+ found by [@lde/iiif-image](https://ldelements.org/reference/iiif-image).
93
+ - **Freshness:** manifest facts are refetched after 30 days, because which image
94
+ represents a work can change. `info.json` facts are refetched after 180 days: an
95
+ image’s dimensions do not change, but its server’s size URLs can.
96
+ - **Safety:** fetches go through
97
+ [@lde/public-fetch](https://ldelements.org/reference/public-fetch), which refuses a
98
+ URL, or a redirect target, that is not `http(s)` or names a private address or
99
+ cluster-internal host, and caps a body at 20 MiB. A refused URL or a larger body is
100
+ reported and counts as having no such document. It looks at URLs only, so it does not catch DNS
101
+ pointing inwards. Run the indexer under an egress policy as well.
102
+ - **Failures:** a failed fetch is reported and retried on a later run, and the work
103
+ is indexed without an image. A 401, 403, 404 or 410 counts as having no such
104
+ document and is rechecked after a week, so a brief 401 or 403 can replace a stored
105
+ fact for that long. So does a collection, which has no image of its own, and a
106
+ manifest too broken to read. A 200 that is no IIIF document at all, such as an error
107
+ page served as JSON, counts as a failure, so it is retried every run and five in a
108
+ row stop that dataset’s manifests for the run.
109
+ - **Budget:** `fetchesPerRun` covers all datasets together. What it defers is not
110
+ reported, and those works are indexed without an image until a later run.
111
+ - **Later runs:** retries, deferred works and refreshes all need the dataset to be
112
+ reprocessed. With `PROVENANCE_FILE`, an unchanged dataset waits until it changes
113
+ or `PIPELINE_VERSION` is bumped, so set `fetchesPerRun` high enough to cover it in
114
+ one run.
115
+ - **Limits:** manifest requests are grouped per dataset, because manifest URLs often
116
+ sit on a redirector such as `n2t.net`, and `info.json` requests per host. Datasets
117
+ on one server are limited separately, with only the overall limit across them.
@@ -0,0 +1,2 @@
1
+ export { iiifImages, type IiifImages, type IiifImagesContext, type IiifImagesOptions, type IiifImagesTransform, } from './lib/iiif-images.js';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,UAAU,EACV,KAAK,UAAU,EACf,KAAK,iBAAiB,EACtB,KAAK,iBAAiB,EACtB,KAAK,mBAAmB,GACzB,MAAM,sBAAsB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { iiifImages, } from './lib/iiif-images.js';
@@ -0,0 +1,51 @@
1
+ import { type ResolutionFailure } from '@lde/resolver';
2
+ import type { RootType, SearchSchema } from '@lde/search';
3
+ import type { Quad } from '@rdfjs/types';
4
+ export interface IiifImagesOptions {
5
+ /**
6
+ * The SQLite file resolved facts live in between runs. It has to survive the
7
+ * run: the search index is rebuilt from it, and nothing else holds these facts.
8
+ */
9
+ readonly storePath: string;
10
+ /**
11
+ * Manifests, and separately `info.json` documents, this object may fetch across
12
+ * all datasets, so construct one per run. Defaults to the resolver’s 500.
13
+ *
14
+ * What the budget defers is not reported, and those works are indexed without an
15
+ * image. A cold store then warms only as datasets are reprocessed, so where
16
+ * unchanged datasets are skipped, set this high enough to cover them in one run.
17
+ */
18
+ readonly fetchesPerRun?: number;
19
+ /** Told about each reference that failed. Defaults to a console warning. */
20
+ readonly report?: (failure: ResolutionFailure) => void;
21
+ }
22
+ /** What the transform reads from the reader it is attached to. */
23
+ export interface IiifImagesContext {
24
+ readonly dataset: {
25
+ readonly iri: URL;
26
+ };
27
+ }
28
+ /** A transform attachable to a reader as an `@lde/pipeline` `QuadTransform`. */
29
+ export type IiifImagesTransform = (quads: AsyncIterable<Quad>, context: IiifImagesContext) => AsyncIterable<Quad>;
30
+ export interface IiifImages {
31
+ /**
32
+ * The transform to attach to a root type’s reader, after any transform that
33
+ * repairs IRIs, or `undefined` where that type does not declare
34
+ * `representativeImage` and `associatedMedia` – so a deployment on an older
35
+ * schema loses the enrichment rather than the run.
36
+ */
37
+ transformFor(rootType: RootType): IiifImagesTransform | undefined;
38
+ /** Throws when the store cannot persist, to fail a run before it starts. */
39
+ check(): Promise<void>;
40
+ }
41
+ /**
42
+ * Enrich works with the image that represents them: its dimensions and a ladder
43
+ * of precomputed sizes, resolved from the work’s IIIF Presentation manifest and
44
+ * the selected image’s `info.json`.
45
+ *
46
+ * A work with no manifest, or whose resolution has not succeeded, gets no
47
+ * `representativeImage`; consumers then fall back to the `thumbnailUrl` already
48
+ * in `associatedMedia`, and a failed resolution is retried on a later run.
49
+ */
50
+ export declare function iiifImages(schema: SearchSchema, options: IiifImagesOptions): IiifImages;
51
+ //# sourceMappingURL=iiif-images.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"iiif-images.d.ts","sourceRoot":"","sources":["../../src/lib/iiif-images.ts"],"names":[],"mappings":"AAUA,OAAO,EAIL,KAAK,iBAAiB,EAEvB,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EACV,QAAQ,EAER,YAAY,EAEb,MAAM,aAAa,CAAC;AAErB,OAAO,KAAK,EAAE,IAAI,EAA6B,MAAM,cAAc,CAAC;AASpE,MAAM,WAAW,iBAAiB;IAChC;;;OAGG;IACH,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;;;;;;;OAOG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,4EAA4E;IAC5E,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,iBAAiB,KAAK,IAAI,CAAC;CACxD;AAED,kEAAkE;AAClE,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,OAAO,EAAE;QAAE,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAA;KAAE,CAAC;CACzC;AAED,gFAAgF;AAChF,MAAM,MAAM,mBAAmB,GAAG,CAChC,KAAK,EAAE,aAAa,CAAC,IAAI,CAAC,EAC1B,OAAO,EAAE,iBAAiB,KACvB,aAAa,CAAC,IAAI,CAAC,CAAC;AAEzB,MAAM,WAAW,UAAU;IACzB;;;;;OAKG;IACH,YAAY,CAAC,QAAQ,EAAE,QAAQ,GAAG,mBAAmB,GAAG,SAAS,CAAC;IAClE,4EAA4E;IAC5E,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED;;;;;;;;GAQG;AACH,wBAAgB,UAAU,CACxB,MAAM,EAAE,YAAY,EACpB,OAAO,EAAE,iBAAiB,GACzB,UAAU,CAiHZ"}
@@ -0,0 +1,290 @@
1
+ import { imageServices, readImageService, } from '@lde/iiif-image';
2
+ import { fetchPublic, RefusedUrlError, ResponseTooLargeError, } from '@lde/public-fetch';
3
+ import { createResolver, sqliteFactStore, urlHost, } from '@lde/resolver';
4
+ import { fieldNamed, irAlias, referenceTypeNamed } from '@lde/search/adapter';
5
+ import { DataFactory } from 'rdf-data-factory';
6
+ import { iiifImage, iiifImageService, iiifImageSize, representativeImageField, } from '../schema.js';
7
+ /**
8
+ * Enrich works with the image that represents them: its dimensions and a ladder
9
+ * of precomputed sizes, resolved from the work’s IIIF Presentation manifest and
10
+ * the selected image’s `info.json`.
11
+ *
12
+ * A work with no manifest, or whose resolution has not succeeded, gets no
13
+ * `representativeImage`; consumers then fall back to the `thumbnailUrl` already
14
+ * in `associatedMedia`, and a failed resolution is retried on a later run.
15
+ */
16
+ export function iiifImages(schema, options) {
17
+ const limits = options.fetchesPerRun === undefined
18
+ ? {}
19
+ : { fetchesPerResolver: options.fetchesPerRun };
20
+ const report = options.report ??
21
+ ((failure) => console.warn(`${failure.key}: ${failure.reason}`));
22
+ const manifests = createResolver({
23
+ key: (reference) => reference.url,
24
+ // Not the manifest’s own host: many are on n2t.net, which only redirects, so
25
+ // its limits and failure cut-off would land on the wrong server and lump
26
+ // publishers together. A dataset is the closest known stand-in for the server
27
+ // at the end of those redirects.
28
+ host: (reference) => reference.dataset,
29
+ fetch: (references, { signal }) => fetchFacts(references.map(({ url }) => url), (url) => url, (document) => {
30
+ try {
31
+ const [service] = imageServices(document);
32
+ return service;
33
+ }
34
+ catch (error) {
35
+ // A document that says it is a collection, or a manifest too broken
36
+ // to read, has no image to offer: a fact about that document. Anything
37
+ // else – an error page served as JSON – is a server misbehaving, so
38
+ // no absence may overwrite the stored fact.
39
+ if (error instanceof TypeError && declaresIiifType(document)) {
40
+ return undefined;
41
+ }
42
+ throw error;
43
+ }
44
+ }, signal, report),
45
+ store: sqliteFactStore({
46
+ path: options.storePath,
47
+ table: 'manifests',
48
+ }),
49
+ limits: { ...limits, ttlMs: MANIFEST_TTL_MS },
50
+ report,
51
+ });
52
+ const images = createResolver({
53
+ key: (service) => service,
54
+ host: urlHost,
55
+ fetch: (services, { signal }) => fetchFacts(services, (service) => `${service}/info.json`, (info, service) => {
56
+ // Same reasoning, with more at stake: these facts are otherwise permanent.
57
+ const fact = readImageService(service, info);
58
+ if (fact === undefined) {
59
+ throw new Error(`${service}/info.json states no dimensions`);
60
+ }
61
+ return fact;
62
+ }, signal, report),
63
+ store: sqliteFactStore({
64
+ path: options.storePath,
65
+ table: 'images',
66
+ }),
67
+ limits: { ...limits, ttlMs: IMAGE_TTL_MS },
68
+ report,
69
+ });
70
+ return {
71
+ transformFor(rootType) {
72
+ const aliases = aliasesOf(rootType, schema);
73
+ return aliases === undefined
74
+ ? undefined
75
+ : async function* enrich(quads, context) {
76
+ // A batch of roots is the unit of work and what bounds the memory: a
77
+ // work’s manifest entry is only known once the batch is, and resolving
78
+ // per batch is what lets the resolver share one store read.
79
+ const batch = [];
80
+ for await (const quad of quads) {
81
+ batch.push(quad);
82
+ yield quad;
83
+ }
84
+ const manifestOfWork = manifestsIn(batch, aliases);
85
+ const dataset = context.dataset.iri.toString();
86
+ const selected = await manifests.resolveAll([...manifestOfWork.values()].map((url) => ({ url, dataset })));
87
+ const serviceOfWork = new Map();
88
+ for (const [work, manifest] of manifestOfWork) {
89
+ const resolution = selected.get(manifest);
90
+ if (resolution?.outcome === 'resolved') {
91
+ serviceOfWork.set(work, resolution.fact);
92
+ }
93
+ }
94
+ const resolved = await images.resolveAll([
95
+ ...serviceOfWork.values(),
96
+ ]);
97
+ for (const [work, service] of serviceOfWork) {
98
+ const resolution = resolved.get(service);
99
+ if (resolution?.outcome === 'resolved') {
100
+ yield* mint(factory.namedNode(work), resolution.fact, aliases);
101
+ }
102
+ }
103
+ };
104
+ },
105
+ async check() {
106
+ await manifests.check();
107
+ await images.check();
108
+ },
109
+ };
110
+ }
111
+ const factory = new DataFactory();
112
+ const INTEGER = factory.namedNode('http://www.w3.org/2001/XMLSchema#integer');
113
+ /** What the profile’s `encodingFormat` for a manifest entry contains. */
114
+ const IIIF_PRESENTATION_CONTEXT = 'iiif.io/api/presentation';
115
+ /** A manifest’s selection can change, so it is reconsidered after 30 days. */
116
+ const MANIFEST_TTL_MS = 30 * 24 * 3_600_000;
117
+ /**
118
+ * An image’s dimensions do not change, but what its server offers does: an upgrade
119
+ * can change the size URLs stored with them. So these facts are reconsidered too,
120
+ * only rarely.
121
+ */
122
+ const IMAGE_TTL_MS = 180 * 24 * 3_600_000;
123
+ const IIIF_TYPES = new Set([
124
+ 'Manifest',
125
+ 'Collection',
126
+ 'sc:Manifest',
127
+ 'sc:Collection',
128
+ ]);
129
+ /** Answers meaning this document is not ours to have, now or on a retry. */
130
+ const ABSENT_STATUSES = new Set([401, 403, 404, 410]);
131
+ function aliasesOf(rootType, schema) {
132
+ const media = fieldNamed(rootType, 'associatedMedia');
133
+ const image = fieldNamed(rootType, representativeImageField.name);
134
+ const mediaType = referencedType(schema, media);
135
+ const encodingFormat = mediaType === undefined ? undefined : aliasOf(mediaType, 'encodingFormat');
136
+ const imageType = referencedType(schema, image);
137
+ const sizeType = imageType && referencedType(schema, fieldNamed(imageType, 'sizes'));
138
+ const serviceType = imageType && referencedType(schema, fieldNamed(imageType, 'service'));
139
+ if (media === undefined ||
140
+ image === undefined ||
141
+ encodingFormat === undefined ||
142
+ imageType === undefined ||
143
+ sizeType === undefined ||
144
+ serviceType === undefined) {
145
+ return undefined;
146
+ }
147
+ const imageAliases = fieldAliases(imageType, iiifImage);
148
+ const sizeAliases = fieldAliases(sizeType, iiifImageSize);
149
+ const serviceAliases = fieldAliases(serviceType, iiifImageService);
150
+ return imageAliases === undefined ||
151
+ sizeAliases === undefined ||
152
+ serviceAliases === undefined
153
+ ? undefined
154
+ : {
155
+ associatedMedia: irAlias(rootType, media),
156
+ encodingFormat,
157
+ representativeImage: irAlias(rootType, image),
158
+ image: imageAliases,
159
+ size: sizeAliases,
160
+ service: serviceAliases,
161
+ };
162
+ }
163
+ /**
164
+ * The type a reference field nests. Read from the declaration rather than through
165
+ * `nestedReferenceType`, which answers only for surfaced fields: a schema may read
166
+ * `associatedMedia` without surfacing it, and should still be enriched.
167
+ */
168
+ function referencedType(schema, field) {
169
+ return field?.kind === 'reference' && field.ref?.strategy === 'inline'
170
+ ? referenceTypeNamed(schema, field.ref.typeName)
171
+ : undefined;
172
+ }
173
+ /** Every field `declaration` names, as the schema’s own `searchType` aliases it,
174
+ * or `undefined` where that type lacks one. */
175
+ function fieldAliases(searchType, declaration) {
176
+ const entries = declaration.fields.map(({ name }) => [name, aliasOf(searchType, name)]);
177
+ return entries.every(([, alias]) => alias !== undefined)
178
+ ? Object.fromEntries(entries)
179
+ : undefined;
180
+ }
181
+ function aliasOf(searchType, name) {
182
+ const field = fieldNamed(searchType, name);
183
+ return field === undefined ? undefined : irAlias(searchType, field);
184
+ }
185
+ /**
186
+ * Each work’s manifest: its `associatedMedia` entry that is a named node and names
187
+ * the IIIF Presentation context in its `encodingFormat`. The profile allows one;
188
+ * where a publisher states more, the first by IRI, because quad order can change
189
+ * between runs and the representative image should not change with it. Only works
190
+ * named by an IRI, because the image is minted onto the work by its name.
191
+ */
192
+ function manifestsIn(batch, aliases) {
193
+ const manifestEntries = new Set(batch
194
+ .filter((quad) => quad.predicate.value === aliases.encodingFormat &&
195
+ quad.subject.termType === 'NamedNode' &&
196
+ quad.object.value.includes(IIIF_PRESENTATION_CONTEXT))
197
+ .map((quad) => quad.subject.value));
198
+ const manifestOfWork = new Map();
199
+ for (const quad of batch) {
200
+ if (quad.predicate.value !== aliases.associatedMedia ||
201
+ quad.subject.termType !== 'NamedNode' ||
202
+ !manifestEntries.has(quad.object.value)) {
203
+ continue;
204
+ }
205
+ const current = manifestOfWork.get(quad.subject.value);
206
+ if (current === undefined || quad.object.value < current) {
207
+ manifestOfWork.set(quad.subject.value, quad.object.value);
208
+ }
209
+ }
210
+ return manifestOfWork;
211
+ }
212
+ /**
213
+ * Fetch each key’s JSON document and derive its fact. An {@link ABSENT_STATUSES}
214
+ * answer, or a document `derive` finds no fact in, is an absence and left out;
215
+ * anything else throws, so the resolver retries it and a stored fact survives a
216
+ * passing outage. Restricted images are absences so that they cannot write off a
217
+ * host whose public images are fine. So, reported, is a URL that is not a public
218
+ * web address – publisher data decides these URLs, and this runs inside the
219
+ * cluster – and a document too large to read, which a retry would not shrink.
220
+ */
221
+ async function fetchFacts(keys, urlOf, derive, signal, report) {
222
+ const facts = [];
223
+ for (const key of keys) {
224
+ let document;
225
+ try {
226
+ document = await fetchDocument(urlOf(key), signal);
227
+ }
228
+ catch (error) {
229
+ if (!(error instanceof RefusedUrlError) &&
230
+ !(error instanceof ResponseTooLargeError)) {
231
+ throw error;
232
+ }
233
+ report({ key, reason: error.message, degradedToStore: false });
234
+ continue;
235
+ }
236
+ const fact = document === undefined ? undefined : derive(document, key);
237
+ if (fact !== undefined) {
238
+ facts.push([key, fact]);
239
+ }
240
+ }
241
+ return facts;
242
+ }
243
+ /** The JSON document at `url`, or `undefined` for an {@link ABSENT_STATUSES} answer. */
244
+ async function fetchDocument(url, signal) {
245
+ // Only `*/*`: LDmaX answers 404 to any JSON media type in `Accept`, IIIF’s own
246
+ // included, even with `*/*` as a fallback beside it.
247
+ const response = await fetchPublic(url, {
248
+ signal,
249
+ headers: { Accept: '*/*' },
250
+ });
251
+ if (!response.ok) {
252
+ // An unread body keeps its connection until garbage collection.
253
+ await response.body?.cancel();
254
+ if (ABSENT_STATUSES.has(response.status)) {
255
+ return undefined;
256
+ }
257
+ throw new Error(`${url} answered ${response.status}`);
258
+ }
259
+ return response.json();
260
+ }
261
+ /** Whether a document says it is a IIIF manifest or collection, in Presentation 3 or 2. */
262
+ function declaresIiifType(document) {
263
+ if (typeof document !== 'object' || document === null) {
264
+ return false;
265
+ }
266
+ const { type, '@type': legacyType } = document;
267
+ return IIIF_TYPES.has(String(type ?? legacyType));
268
+ }
269
+ function* mint(work, fact, aliases) {
270
+ const image = factory.blankNode();
271
+ yield triple(work, aliases.representativeImage, image);
272
+ yield triple(image, aliases.image.width, integer(fact.width));
273
+ yield triple(image, aliases.image.height, integer(fact.height));
274
+ for (const size of fact.sizes) {
275
+ const node = factory.blankNode();
276
+ yield triple(image, aliases.image.sizes, node);
277
+ yield triple(node, aliases.size.width, integer(size.width));
278
+ yield triple(node, aliases.size.height, integer(size.height));
279
+ yield triple(node, aliases.size.url, factory.namedNode(size.url));
280
+ }
281
+ const service = factory.blankNode();
282
+ yield triple(image, aliases.image.service, service);
283
+ yield triple(service, aliases.service.url, factory.namedNode(fact.url));
284
+ }
285
+ function triple(subject, predicate, object) {
286
+ return factory.quad(subject, factory.namedNode(predicate), object);
287
+ }
288
+ function integer(value) {
289
+ return factory.literal(String(value), INTEGER);
290
+ }
@@ -0,0 +1,26 @@
1
+ /** What an image service’s `info.json` tells a consumer, in the consumer’s terms. */
2
+ export interface ImageFact {
3
+ readonly width: number;
4
+ readonly height: number;
5
+ /** Renditions to pick from, smallest first. */
6
+ readonly sizes: readonly ImageSize[];
7
+ /** The service itself, for consumers doing deep zoom or region crops. */
8
+ readonly service: {
9
+ readonly url: string;
10
+ readonly type: string;
11
+ readonly profile: string;
12
+ };
13
+ }
14
+ export interface ImageSize {
15
+ readonly width: number;
16
+ readonly height: number;
17
+ readonly url: string;
18
+ }
19
+ /**
20
+ * An `info.json` as an {@link ImageFact}, or `undefined` where it states no
21
+ * dimensions. `serviceUrl` rather than the document’s own `id` names the service,
22
+ * because that is what the manifest resolved and what a proxied server’s
23
+ * self-reported `id` need not be.
24
+ */
25
+ export declare function imageFactFrom(serviceUrl: string, info: unknown): ImageFact | undefined;
26
+ //# sourceMappingURL=image-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"image-service.d.ts","sourceRoot":"","sources":["../../src/lib/image-service.ts"],"names":[],"mappings":"AAAA,qFAAqF;AACrF,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,+CAA+C;IAC/C,QAAQ,CAAC,KAAK,EAAE,SAAS,SAAS,EAAE,CAAC;IACrC,yEAAyE;IACzE,QAAQ,CAAC,OAAO,EAAE;QAChB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;QACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;KAC1B,CAAC;CACH;AAED,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAC3B,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,OAAO,GACZ,SAAS,GAAG,SAAS,CA4BvB"}
@@ -0,0 +1,116 @@
1
+ /**
2
+ * An `info.json` as an {@link ImageFact}, or `undefined` where it states no
3
+ * dimensions. `serviceUrl` rather than the document’s own `id` names the service,
4
+ * because that is what the manifest resolved and what a proxied server’s
5
+ * self-reported `id` need not be.
6
+ */
7
+ export function imageFactFrom(serviceUrl, info) {
8
+ if (typeof info !== 'object' || info === null) {
9
+ return undefined;
10
+ }
11
+ const document = info;
12
+ const { width, height } = document;
13
+ if (!isPositive(width) || !isPositive(height)) {
14
+ return undefined;
15
+ }
16
+ const type = serviceTypeOf(document);
17
+ const profile = profileOf(document);
18
+ const isVersion3 = type === 'ImageService3';
19
+ // Image API 1.1 names its qualities differently (`native`, not `default`) and has
20
+ // no `sizes`, and is rare enough that its dimensions alone will do.
21
+ const sizes = (type === 'ImageService1' ? [] : sizesOf(document, width, height, profile))
22
+ .sort((left, right) => left.width - right.width)
23
+ .map((size) => ({
24
+ ...size,
25
+ url: `${serviceUrl}/full/${isVersion3 ? `${size.width},${size.height}` : `${size.width},`}/0/default.jpg`,
26
+ }));
27
+ return {
28
+ width,
29
+ height,
30
+ sizes,
31
+ service: { url: serviceUrl, type, profile },
32
+ };
33
+ }
34
+ /** Rungs below this width are too small to be worth a request. */
35
+ const MINIMUM_SYNTHESISED_WIDTH = 64;
36
+ function sizesOf(document, width, height, profile) {
37
+ const declared = Array.isArray(document.sizes)
38
+ ? document.sizes.filter((size) => isPositive(size?.width) && isPositive(size?.height))
39
+ : [];
40
+ if (declared.length > 0) {
41
+ return declared.map((size) => ({ width: size.width, height: size.height }));
42
+ }
43
+ // A level-0 server only serves what it lists, so nothing can be asked of it.
44
+ if (/level0/.test(profile)) {
45
+ return [];
46
+ }
47
+ const rungs = new Map();
48
+ for (const scaleFactor of scaleFactorsOf(document, width)) {
49
+ const rung = {
50
+ width: Math.round(width / scaleFactor),
51
+ height: Math.round(height / scaleFactor),
52
+ };
53
+ if (rung.width >= MINIMUM_SYNTHESISED_WIDTH &&
54
+ rung.height >= 1 &&
55
+ isWithinMaximums(rung, document)) {
56
+ rungs.set(rung.width, rung);
57
+ }
58
+ }
59
+ return [...rungs.values()];
60
+ }
61
+ /** The server’s own precomputed factors where it has tiles, else powers of two. */
62
+ function scaleFactorsOf(document, width) {
63
+ const tiles = Array.isArray(document.tiles) ? document.tiles : [];
64
+ const declared = tiles
65
+ .flatMap((tile) => Array.isArray(tile?.scaleFactors) ? tile.scaleFactors : [])
66
+ .filter(isPositive);
67
+ if (declared.length > 0) {
68
+ return declared;
69
+ }
70
+ const factors = [];
71
+ for (let factor = 1; width / factor >= MINIMUM_SYNTHESISED_WIDTH; factor *= 2) {
72
+ factors.push(factor);
73
+ }
74
+ return factors;
75
+ }
76
+ /**
77
+ * Asking above a declared maximum answers 404, not 400 (Image API 3.0 § 7.3),
78
+ * which would read as a dead link. `maxWidth` alone also bounds the height
79
+ * (§ 5.3), so it is the fallback for a missing `maxHeight`.
80
+ */
81
+ function isWithinMaximums(rung, document) {
82
+ const { maxWidth, maxHeight, maxArea } = document;
83
+ const heightLimit = isPositive(maxHeight) ? maxHeight : maxWidth;
84
+ return ((!isPositive(maxWidth) || rung.width <= maxWidth) &&
85
+ (!isPositive(heightLimit) || rung.height <= heightLimit) &&
86
+ (!isPositive(maxArea) || rung.width * rung.height <= maxArea));
87
+ }
88
+ function serviceTypeOf(document) {
89
+ // Only this family: Image API 2 documents commonly say `@type: iiif:Image`, which
90
+ // names what they describe and not the kind of service.
91
+ const type = document.type ?? document['@type'];
92
+ if (typeof type === 'string' && type.startsWith('ImageService')) {
93
+ return type;
94
+ }
95
+ const context = JSON.stringify(document['@context'] ?? '');
96
+ if (context.includes('image/3')) {
97
+ return 'ImageService3';
98
+ }
99
+ if (context.includes('image/2')) {
100
+ return 'ImageService2';
101
+ }
102
+ // A 1.0 document may state no context, so the profile has to answer as well.
103
+ return /image-api|api\/image\/1/.test(`${context} ${profileOf(document)}`)
104
+ ? 'ImageService1'
105
+ : 'ImageService2';
106
+ }
107
+ /** The compliance level, which Image API 2 states first in an array. */
108
+ function profileOf(document) {
109
+ const profile = Array.isArray(document.profile)
110
+ ? document.profile[0]
111
+ : document.profile;
112
+ return typeof profile === 'string' ? profile : '';
113
+ }
114
+ function isPositive(value) {
115
+ return Number.isInteger(value) && value > 0;
116
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The image service of a manifest’s first painted image that has one, in either
3
+ * Presentation version.
4
+ *
5
+ * A walk over the plain JSON rather than a vault: real manifests carry shapes a
6
+ * normalising parser rejects outright – Deventit Atlantis writes
7
+ * `"annotations": [null]` – and only this path matters here. Which image
8
+ * represents a work is a rule that lives in this one function, so a better one
9
+ * can replace it without touching what it feeds.
10
+ */
11
+ export declare function selectImageService(manifest: unknown): string | undefined;
12
+ /** Whether a document has the canvases every manifest must have, in either version. */
13
+ export declare function listsCanvases(document: unknown): boolean;
14
+ //# sourceMappingURL=manifest.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../../src/lib/manifest.ts"],"names":[],"mappings":"AAGA;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAcxE;AAED,uFAAuF;AACvF,wBAAgB,aAAa,CAAC,QAAQ,EAAE,OAAO,GAAG,OAAO,CAKxD"}
@@ -0,0 +1,98 @@
1
+ import { isImageService as isKnownImageService } from '@iiif/parser/image-3';
2
+ import { convertPresentation2 } from '@iiif/parser/presentation-2';
3
+ /**
4
+ * The image service of a manifest’s first painted image that has one, in either
5
+ * Presentation version.
6
+ *
7
+ * A walk over the plain JSON rather than a vault: real manifests carry shapes a
8
+ * normalising parser rejects outright – Deventit Atlantis writes
9
+ * `"annotations": [null]` – and only this path matters here. Which image
10
+ * represents a work is a rule that lives in this one function, so a better one
11
+ * can replace it without touching what it feeds.
12
+ */
13
+ export function selectImageService(manifest) {
14
+ for (const canvas of arrayOf(fieldOf(upgraded(manifest), 'items'))) {
15
+ for (const page of arrayOf(fieldOf(canvas, 'items'))) {
16
+ for (const annotation of arrayOf(fieldOf(page, 'items'))) {
17
+ for (const body of paintedResources(fieldOf(annotation, 'body'))) {
18
+ const service = imageServiceOf(body);
19
+ if (service !== undefined) {
20
+ return service;
21
+ }
22
+ }
23
+ }
24
+ }
25
+ }
26
+ return undefined;
27
+ }
28
+ /** Whether a document has the canvases every manifest must have, in either version. */
29
+ export function listsCanvases(document) {
30
+ return (arrayOf(fieldOf(document, 'items')).length > 0 ||
31
+ arrayOf(fieldOf(document, 'sequences')).length > 0);
32
+ }
33
+ /**
34
+ * The manifest in Presentation 3 terms. A Presentation 2 manifest the upgrade
35
+ * chokes on, such as one with `"images": [null]`, has nothing to find: retrying
36
+ * cannot help, and a run of them must not write off a dataset’s other manifests.
37
+ */
38
+ function upgraded(manifest) {
39
+ if (fieldOf(manifest, 'sequences') === undefined) {
40
+ return manifest;
41
+ }
42
+ try {
43
+ return convertPresentation2(manifest);
44
+ }
45
+ catch {
46
+ return undefined;
47
+ }
48
+ }
49
+ /** A body’s resources in order, with each `Choice` opened up and each
50
+ * `SpecificResource`, which is how a cropped painting is stated, unwrapped. */
51
+ function paintedResources(body) {
52
+ return arrayOf(body).flatMap((resource) => {
53
+ const type = fieldOf(resource, 'type');
54
+ if (type === 'Choice') {
55
+ return paintedResources(fieldOf(resource, 'items'));
56
+ }
57
+ return type === 'SpecificResource'
58
+ ? paintedResources(fieldOf(resource, 'source'))
59
+ : [resource];
60
+ });
61
+ }
62
+ function imageServiceOf(resource) {
63
+ for (const service of arrayOf(fieldOf(resource, 'service'))) {
64
+ const id = fieldOf(service, 'id') ?? fieldOf(service, '@id');
65
+ if (typeof id === 'string' && isImageService(service)) {
66
+ return serviceBase(id);
67
+ }
68
+ }
69
+ return undefined;
70
+ }
71
+ /** The URL `/info.json` and sizes are appended to. Some manifests state the
72
+ * service with a trailing slash or as the `info.json` itself. */
73
+ function serviceBase(id) {
74
+ return id.replace(/\/info\.json$/, '').replace(/\/+$/, '');
75
+ }
76
+ /**
77
+ * The parser’s test, plus the two forms it misses: a `type` with no `profile`, and
78
+ * a profile merely naming a compliance level. Limb Gallery’s is the `https` form
79
+ * of the Stanford URL from Image API 1.1, which the parser’s list does not include.
80
+ */
81
+ function isImageService(service) {
82
+ const type = fieldOf(service, 'type') ?? fieldOf(service, '@type');
83
+ const profile = fieldOf(service, 'profile');
84
+ return (isKnownImageService(service) ||
85
+ (typeof type === 'string' && type.startsWith('ImageService')) ||
86
+ (typeof profile === 'string' && /level[012]/.test(profile)));
87
+ }
88
+ function fieldOf(value, name) {
89
+ return typeof value === 'object' && value !== null
90
+ ? value[name]
91
+ : undefined;
92
+ }
93
+ function arrayOf(value) {
94
+ if (value === undefined || value === null) {
95
+ return [];
96
+ }
97
+ return Array.isArray(value) ? value : [value];
98
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Whether a URL taken from a publisher’s data may be fetched from inside the
3
+ * cluster: a web URL that does not name a private, loopback or link-local address
4
+ * or a cluster-internal host.
5
+ *
6
+ * This looks only at the URL, so it cannot see a public name resolving to a private
7
+ * address, or a redirect to one. It stops the plain case, and a cluster egress
8
+ * policy has to cover the rest.
9
+ */
10
+ export declare function isPublicWebUrl(url: string): boolean;
11
+ //# sourceMappingURL=public-url.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"public-url.d.ts","sourceRoot":"","sources":["../../src/lib/public-url.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAoBnD"}
@@ -0,0 +1,53 @@
1
+ import { BlockList, isIP } from 'node:net';
2
+ /**
3
+ * Whether a URL taken from a publisher’s data may be fetched from inside the
4
+ * cluster: a web URL that does not name a private, loopback or link-local address
5
+ * or a cluster-internal host.
6
+ *
7
+ * This looks only at the URL, so it cannot see a public name resolving to a private
8
+ * address, or a redirect to one. It stops the plain case, and a cluster egress
9
+ * policy has to cover the rest.
10
+ */
11
+ export function isPublicWebUrl(url) {
12
+ let parsed;
13
+ try {
14
+ parsed = new URL(url);
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
20
+ return false;
21
+ }
22
+ const host = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase();
23
+ const family = isIP(host);
24
+ if (family === 4) {
25
+ return !PRIVATE_ADDRESSES.check(host, 'ipv4');
26
+ }
27
+ if (family === 6) {
28
+ return !PRIVATE_ADDRESSES.check(host, 'ipv6');
29
+ }
30
+ // A single label is a Kubernetes service or a machine on the local network.
31
+ return host.includes('.') && !INTERNAL_SUFFIX.test(host);
32
+ }
33
+ const INTERNAL_SUFFIX = /\.(localhost|local|internal|svc)$/;
34
+ const PRIVATE_ADDRESSES = new BlockList();
35
+ for (const [network, prefix] of [
36
+ ['0.0.0.0', 8],
37
+ ['10.0.0.0', 8],
38
+ ['100.64.0.0', 10],
39
+ ['127.0.0.0', 8],
40
+ ['169.254.0.0', 16],
41
+ ['172.16.0.0', 12],
42
+ ['192.168.0.0', 16],
43
+ ]) {
44
+ PRIVATE_ADDRESSES.addSubnet(network, prefix, 'ipv4');
45
+ }
46
+ for (const [network, prefix] of [
47
+ ['::', 128],
48
+ ['::1', 128],
49
+ ['fc00::', 7],
50
+ ['fe80::', 10],
51
+ ]) {
52
+ PRIVATE_ADDRESSES.addSubnet(network, prefix, 'ipv6');
53
+ }
@@ -0,0 +1,152 @@
1
+ /** One precomputed rendition of the image, with the URL to request it at. */
2
+ export declare const iiifImageSize: {
3
+ readonly name: "IiifImageSize";
4
+ readonly fields: readonly [{
5
+ readonly name: "width";
6
+ readonly kind: "integer";
7
+ readonly output: true;
8
+ readonly path: "<https://schema.org/width>";
9
+ }, {
10
+ readonly name: "height";
11
+ readonly kind: "integer";
12
+ readonly output: true;
13
+ readonly path: "<https://schema.org/height>";
14
+ }, {
15
+ readonly name: "url";
16
+ readonly kind: "reference";
17
+ readonly output: true;
18
+ readonly path: "<https://schema.org/contentUrl>";
19
+ readonly ref: {
20
+ readonly strategy: "idOnly";
21
+ };
22
+ }];
23
+ };
24
+ /** The IIIF image service itself, for consumers doing deep zoom or region crops. */
25
+ export declare const iiifImageService: {
26
+ readonly name: "IiifImageService";
27
+ readonly fields: readonly [{
28
+ readonly name: "url";
29
+ readonly kind: "reference";
30
+ readonly output: true;
31
+ readonly path: "<https://schema.org/url>";
32
+ readonly ref: {
33
+ readonly strategy: "idOnly";
34
+ };
35
+ }];
36
+ };
37
+ /**
38
+ * The image that represents a work: the full dimensions, and the renditions a
39
+ * consumer can pick from without knowing Image API compliance levels.
40
+ */
41
+ export declare const iiifImage: {
42
+ readonly name: "IiifImage";
43
+ readonly fields: readonly [{
44
+ readonly name: "width";
45
+ readonly kind: "integer";
46
+ readonly output: true;
47
+ readonly path: "<https://schema.org/width>";
48
+ }, {
49
+ readonly name: "height";
50
+ readonly kind: "integer";
51
+ readonly output: true;
52
+ readonly path: "<https://schema.org/height>";
53
+ }, {
54
+ readonly name: "sizes";
55
+ readonly kind: "reference";
56
+ readonly array: true;
57
+ readonly output: true;
58
+ readonly path: "<https://def.nde.nl/iiif#size>";
59
+ readonly ref: {
60
+ readonly typeName: "IiifImageSize";
61
+ readonly strategy: "inline";
62
+ };
63
+ }, {
64
+ readonly name: "service";
65
+ readonly kind: "reference";
66
+ readonly output: true;
67
+ readonly path: "<https://def.nde.nl/iiif#service>";
68
+ readonly ref: {
69
+ readonly typeName: "IiifImageService";
70
+ readonly strategy: "inline";
71
+ };
72
+ }];
73
+ };
74
+ /** The types {@link representativeImageField} nests, to add to the schema. */
75
+ export declare const iiifImageTypes: ({
76
+ readonly name: "IiifImageSize";
77
+ readonly fields: readonly [{
78
+ readonly name: "width";
79
+ readonly kind: "integer";
80
+ readonly output: true;
81
+ readonly path: "<https://schema.org/width>";
82
+ }, {
83
+ readonly name: "height";
84
+ readonly kind: "integer";
85
+ readonly output: true;
86
+ readonly path: "<https://schema.org/height>";
87
+ }, {
88
+ readonly name: "url";
89
+ readonly kind: "reference";
90
+ readonly output: true;
91
+ readonly path: "<https://schema.org/contentUrl>";
92
+ readonly ref: {
93
+ readonly strategy: "idOnly";
94
+ };
95
+ }];
96
+ } | {
97
+ readonly name: "IiifImageService";
98
+ readonly fields: readonly [{
99
+ readonly name: "url";
100
+ readonly kind: "reference";
101
+ readonly output: true;
102
+ readonly path: "<https://schema.org/url>";
103
+ readonly ref: {
104
+ readonly strategy: "idOnly";
105
+ };
106
+ }];
107
+ } | {
108
+ readonly name: "IiifImage";
109
+ readonly fields: readonly [{
110
+ readonly name: "width";
111
+ readonly kind: "integer";
112
+ readonly output: true;
113
+ readonly path: "<https://schema.org/width>";
114
+ }, {
115
+ readonly name: "height";
116
+ readonly kind: "integer";
117
+ readonly output: true;
118
+ readonly path: "<https://schema.org/height>";
119
+ }, {
120
+ readonly name: "sizes";
121
+ readonly kind: "reference";
122
+ readonly array: true;
123
+ readonly output: true;
124
+ readonly path: "<https://def.nde.nl/iiif#size>";
125
+ readonly ref: {
126
+ readonly typeName: "IiifImageSize";
127
+ readonly strategy: "inline";
128
+ };
129
+ }, {
130
+ readonly name: "service";
131
+ readonly kind: "reference";
132
+ readonly output: true;
133
+ readonly path: "<https://def.nde.nl/iiif#service>";
134
+ readonly ref: {
135
+ readonly typeName: "IiifImageService";
136
+ readonly strategy: "inline";
137
+ };
138
+ }];
139
+ })[];
140
+ /** Add to a root type’s fields, and {@link iiifImageTypes} to the schema, to opt
141
+ * that type in to enrichment. */
142
+ export declare const representativeImageField: {
143
+ readonly name: "representativeImage";
144
+ readonly kind: "reference";
145
+ readonly output: true;
146
+ readonly path: "<https://def.nde.nl/iiif#representativeImage>";
147
+ readonly ref: {
148
+ readonly typeName: "IiifImage";
149
+ readonly strategy: "inline";
150
+ };
151
+ };
152
+ //# sourceMappingURL=schema.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAKA,6EAA6E;AAC7E,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;;;CAkBK,CAAC;AAEhC,oFAAoF;AACpF,eAAO,MAAM,gBAAgB;;;;;;;;;;;CAWE,CAAC;AAEhC;;;GAGG;AACH,eAAO,MAAM,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BS,CAAC;AAEhC,8EAA8E;AAC9E,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;IAA+C,CAAC;AAE3E;kCACkC;AAClC,eAAO,MAAM,wBAAwB;;;;;;;;;CAML,CAAC"}
package/dist/schema.js ADDED
@@ -0,0 +1,77 @@
1
+ const SCHEMA = 'https://schema.org/';
2
+ const IIIF = 'https://def.nde.nl/iiif#';
3
+ /** One precomputed rendition of the image, with the URL to request it at. */
4
+ export const iiifImageSize = {
5
+ name: 'IiifImageSize',
6
+ fields: [
7
+ { name: 'width', kind: 'integer', output: true, path: `<${SCHEMA}width>` },
8
+ {
9
+ name: 'height',
10
+ kind: 'integer',
11
+ output: true,
12
+ path: `<${SCHEMA}height>`,
13
+ },
14
+ {
15
+ name: 'url',
16
+ kind: 'reference',
17
+ output: true,
18
+ path: `<${SCHEMA}contentUrl>`,
19
+ ref: { strategy: 'idOnly' },
20
+ },
21
+ ],
22
+ };
23
+ /** The IIIF image service itself, for consumers doing deep zoom or region crops. */
24
+ export const iiifImageService = {
25
+ name: 'IiifImageService',
26
+ fields: [
27
+ {
28
+ name: 'url',
29
+ kind: 'reference',
30
+ output: true,
31
+ path: `<${SCHEMA}url>`,
32
+ ref: { strategy: 'idOnly' },
33
+ },
34
+ ],
35
+ };
36
+ /**
37
+ * The image that represents a work: the full dimensions, and the renditions a
38
+ * consumer can pick from without knowing Image API compliance levels.
39
+ */
40
+ export const iiifImage = {
41
+ name: 'IiifImage',
42
+ fields: [
43
+ { name: 'width', kind: 'integer', output: true, path: `<${SCHEMA}width>` },
44
+ {
45
+ name: 'height',
46
+ kind: 'integer',
47
+ output: true,
48
+ path: `<${SCHEMA}height>`,
49
+ },
50
+ {
51
+ name: 'sizes',
52
+ kind: 'reference',
53
+ array: true,
54
+ output: true,
55
+ path: `<${IIIF}size>`,
56
+ ref: { typeName: iiifImageSize.name, strategy: 'inline' },
57
+ },
58
+ {
59
+ name: 'service',
60
+ kind: 'reference',
61
+ output: true,
62
+ path: `<${IIIF}service>`,
63
+ ref: { typeName: iiifImageService.name, strategy: 'inline' },
64
+ },
65
+ ],
66
+ };
67
+ /** The types {@link representativeImageField} nests, to add to the schema. */
68
+ export const iiifImageTypes = [iiifImage, iiifImageSize, iiifImageService];
69
+ /** Add to a root type’s fields, and {@link iiifImageTypes} to the schema, to opt
70
+ * that type in to enrichment. */
71
+ export const representativeImageField = {
72
+ name: 'representativeImage',
73
+ kind: 'reference',
74
+ output: true,
75
+ path: `<${IIIF}representativeImage>`,
76
+ ref: { typeName: iiifImage.name, strategy: 'inline' },
77
+ };
package/package.json CHANGED
@@ -1,6 +1,53 @@
1
1
  {
2
2
  "name": "@ndes/iiif-images",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.1",
4
+ "description": "Enriches SCHEMA-AP-NDE works with the dimensions and sizes of their representative IIIF image, as a quad transform for the search indexer.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ "./package.json": "./package.json",
9
+ ".": {
10
+ "@ndes/source": "./src/index.ts",
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "./schema": {
16
+ "@ndes/source": "./src/schema.ts",
17
+ "types": "./dist/schema.d.ts",
18
+ "import": "./dist/schema.js",
19
+ "default": "./dist/schema.js"
20
+ }
21
+ },
22
+ "main": "./dist/index.js",
23
+ "module": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+ "files": [
26
+ "dist",
27
+ "!**/*.tsbuildinfo"
28
+ ],
29
+ "dependencies": {
30
+ "@lde/iiif-image": "^0.1.0",
31
+ "@lde/public-fetch": "^0.1.0",
32
+ "@lde/resolver": "^0.2.1",
33
+ "@rdfjs/types": "^2.0.1",
34
+ "rdf-data-factory": "^2.1.0",
35
+ "tslib": "^2.3.0"
36
+ },
37
+ "devDependencies": {
38
+ "@lde/pipeline": "^0.36.7",
39
+ "@lde/search": "^0.34.0"
40
+ },
41
+ "peerDependencies": {
42
+ "@lde/search": "^0.34.0"
43
+ },
44
+ "engines": {
45
+ "node": ">=24"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "nx": {
51
+ "name": "iiif-images"
52
+ }
53
+ }