Skip to content

Commit f422ee4

Browse files
committed
Validating URLs via CLI
1 parent 772e324 commit f422ee4

File tree

3 files changed

+109
-13
lines changed

3 files changed

+109
-13
lines changed

__tests__/cli.test.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { join } from "node:path";
44
import { describe, expect, test } from "vitest";
55
import { runCli } from "../src/cli";
66

7-
function testDeps(overrides: { files?: Record<string, string>; fetchJson?: () => Promise<unknown> }) {
7+
function testDeps(overrides: { files?: Record<string, string>; fetchJson?: (url: string) => Promise<unknown> }) {
88
const files: Record<string, string> = overrides.files ?? {};
99
const stdout: string[] = [];
1010
const stderr: string[] = [];
@@ -488,6 +488,82 @@ describe("iiif-parser CLI", () => {
488488
}
489489
});
490490

491+
test("validate-p4 accepts a manifest URL input", async () => {
492+
const manifestUrl = "https://example.org/manifest.json";
493+
const { stdout, deps } = testDeps({
494+
fetchJson: async (url: string) => {
495+
expect(url).toBe(manifestUrl);
496+
return {
497+
"@context": "http://iiif.io/api/presentation/4/context.json",
498+
id: "https://example.org/manifest",
499+
type: "Manifest",
500+
label: { en: ["Remote"] },
501+
items: [{ id: "https://example.org/canvas/1", type: "Canvas", width: 1000, height: 1000, items: [] }],
502+
};
503+
},
504+
});
505+
506+
const code = await runCli(["validate-p4", manifestUrl], deps);
507+
expect(code).toBe(0);
508+
509+
const allOutput = stdout.join("\n");
510+
expect(allOutput).toContain("PASS");
511+
expect(allOutput).toContain(manifestUrl);
512+
expect(allOutput).toContain("Scanned:");
513+
expect(allOutput).toContain("1");
514+
});
515+
516+
test("validate-p4 supports mixed local path and URL inputs", async () => {
517+
const dir = await mkdtemp(join(tmpdir(), "iiif-parser-cli-"));
518+
const manifestPath = join(dir, "local.json");
519+
const manifestUrl = "https://example.org/remote-manifest.json";
520+
521+
try {
522+
await writeFile(
523+
manifestPath,
524+
JSON.stringify({
525+
"@context": "http://iiif.io/api/presentation/4/context.json",
526+
id: "https://example.org/manifest/local",
527+
type: "Manifest",
528+
label: { en: ["Local"] },
529+
items: [{ id: "https://example.org/canvas/1", type: "Canvas", width: 1000, height: 1000, items: [] }],
530+
}),
531+
"utf8"
532+
);
533+
534+
const stdout: string[] = [];
535+
const stderr: string[] = [];
536+
const deps = {
537+
...fsDeps(stdout, stderr),
538+
fetchJson: async (url: string) => {
539+
expect(url).toBe(manifestUrl);
540+
return {
541+
"@context": "http://iiif.io/api/presentation/4/context.json",
542+
id: "https://example.org/manifest/remote",
543+
type: "Manifest",
544+
label: { en: ["Remote"] },
545+
items: [{ id: "https://example.org/canvas/2", type: "Canvas", width: 1000, height: 1000, items: [] }],
546+
};
547+
},
548+
};
549+
550+
const code = await runCli(["validate-p4", manifestPath, manifestUrl], deps);
551+
expect(code).toBe(0);
552+
expect(stderr).toEqual([]);
553+
554+
const allOutput = stdout.join("\n");
555+
expect(allOutput).toContain("PASS");
556+
expect(allOutput).toContain(manifestPath);
557+
expect(allOutput).toContain(manifestUrl);
558+
expect(allOutput).toContain("Scanned:");
559+
expect(allOutput).toContain("2");
560+
expect(allOutput).toContain("Validated:");
561+
expect(allOutput).toContain("2");
562+
} finally {
563+
await rm(dir, { recursive: true, force: true });
564+
}
565+
});
566+
491567
// ── Validate-p4: usage errors ────────────────────────────────────
492568

493569
test("validate-p4 shows usage when missing arguments", async () => {

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
"main": "dist/index.cjs",
1313
"module": "dist/index.js",
1414
"types": "dist/index.d.ts",
15-
"bin": "./dist/cli.mjs",
15+
"bin": {
16+
"iiif-parser": "./dist/cli.mjs"
17+
},
1618
"files": [
1719
"dist"
1820
],

src/cli.ts

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ function usage(c: Colors): string {
169169
"",
170170
` ${c.green("iiif-parser")} ${c.yellow("upgrade")} ${c.dim("<input.json> <output.json>")}`,
171171
` ${c.green("iiif-parser")} ${c.yellow("download")} ${c.dim("<manifest-url> <output.json>")} ${c.dim("[--version 3|4]")}`,
172-
` ${c.green("iiif-parser")} ${c.yellow("validate-p4")} ${c.dim("<input-path...>")} ${c.dim("[--strict] [--json] [--show-warnings]")}`,
172+
` ${c.green("iiif-parser")} ${c.yellow("validate-p4")} ${c.dim("<input-path-or-url...>")} ${c.dim("[--strict] [--json] [--show-warnings]")}`,
173173
"",
174174
` ${c.bold(c.cyan("Commands:"))}`,
175175
"",
@@ -179,7 +179,7 @@ function usage(c: Colors): string {
179179
` ${c.yellow("download")} Download a manifest and save as Presentation 3`,
180180
` ${c.dim("(default)")} or Presentation 4.`,
181181
"",
182-
` ${c.yellow("validate-p4")} Validate one or more files/folders of Presentation 4`,
182+
` ${c.yellow("validate-p4")} Validate one or more files/folders/URLs of Presentation 4`,
183183
` manifests.`,
184184
"",
185185
` ${c.bold(c.cyan("Options:"))}`,
@@ -241,6 +241,15 @@ function parseJson(contents: string, source: string): unknown {
241241
}
242242
}
243243

244+
function isHttpUrl(value: string): boolean {
245+
try {
246+
const url = new URL(value);
247+
return url.protocol === "http:" || url.protocol === "https:";
248+
} catch {
249+
return false;
250+
}
251+
}
252+
244253
function toSerializedPresentation4(input: unknown): unknown {
245254
const upgraded = upgradeToPresentation4(input);
246255
const normalized = normalize(upgraded);
@@ -339,7 +348,7 @@ async function runValidateP4(
339348
if (positionals.length < 2) {
340349
deps.stderr(`\n ${c.red(`${SYM.cross} Missing arguments`)}\n`);
341350
deps.stderr(
342-
` Usage: ${c.green("iiif-parser")} ${c.yellow("validate-p4")} ${c.dim("<input-path...> [--strict] [--json] [--show-warnings]")}\n`
351+
` Usage: ${c.green("iiif-parser")} ${c.yellow("validate-p4")} ${c.dim("<input-path-or-url...> [--strict] [--json] [--show-warnings]")}\n`
343352
);
344353
return 2;
345354
}
@@ -348,12 +357,17 @@ async function runValidateP4(
348357
const strict = options.strict === true;
349358
const jsonOutput = options.json === true;
350359
const showWarnings = options["show-warnings"] === true;
351-
const expandedPaths: string[] = [];
360+
const expandedInputs: Array<{ type: "file" | "url"; path: string }> = [];
352361

353362
async function collectJsonFiles(path: string): Promise<void> {
363+
if (isHttpUrl(path)) {
364+
expandedInputs.push({ type: "url", path });
365+
return;
366+
}
367+
354368
const pathInfo = await stat(path);
355369
if (!pathInfo.isDirectory()) {
356-
expandedPaths.push(path);
370+
expandedInputs.push({ type: "file", path });
357371
return;
358372
}
359373

@@ -365,7 +379,7 @@ async function runValidateP4(
365379
continue;
366380
}
367381
if (entry.isFile() && entry.name.toLowerCase().endsWith(".json")) {
368-
expandedPaths.push(entryPath);
382+
expandedInputs.push({ type: "file", path: entryPath });
369383
}
370384
}
371385
}
@@ -374,10 +388,10 @@ async function runValidateP4(
374388
await collectJsonFiles(inputPath);
375389
}
376390

377-
expandedPaths.sort();
391+
expandedInputs.sort((a, b) => a.path.localeCompare(b.path));
378392

379393
const summary = {
380-
scanned: expandedPaths.length,
394+
scanned: expandedInputs.length,
381395
validated: 0,
382396
skipped: 0,
383397
valid: 0,
@@ -397,14 +411,18 @@ async function runValidateP4(
397411
deps.stdout(
398412
` ${c.bold(c.cyan("Presentation 4 Validation"))} ${c.dim(`(${strict ? "strict" : "tolerant"} mode)`)}`
399413
);
400-
deps.stdout(` ${c.dim(`Scanning ${expandedPaths.length} file${expandedPaths.length === 1 ? "" : "s"}...`)}`);
414+
deps.stdout(` ${c.dim(`Scanning ${expandedInputs.length} input${expandedInputs.length === 1 ? "" : "s"}...`)}`);
401415
deps.stdout("");
402416
}
403417

404418
// ── First pass: compact one-line-per-file results ──────────────
405419

406-
for (const inputPath of expandedPaths) {
407-
const input = parseJson(await deps.readFileText(inputPath), inputPath);
420+
for (const inputRef of expandedInputs) {
421+
const inputPath = inputRef.path;
422+
const input =
423+
inputRef.type === "url"
424+
? await deps.fetchJson(inputPath)
425+
: parseJson(await deps.readFileText(inputPath), inputPath);
408426
const resourceType = (input as { type?: string; "@type"?: string })?.type ?? (input as any)?.["@type"];
409427
if (resourceType !== "Manifest") {
410428
summary.skipped++;

0 commit comments

Comments
 (0)