diff --git a/packages/alfa-cli/bin/alfa.ts b/packages/alfa-cli/bin/alfa.ts
index e4ed12a5d3..76f4cd7b61 100644
--- a/packages/alfa-cli/bin/alfa.ts
+++ b/packages/alfa-cli/bin/alfa.ts
@@ -1,14 +1,62 @@
#!/usr/bin/env node
-import * as command from "@oclif/command";
-import * as errors from "@oclif/errors";
-
-async function alfa() {
- try {
- await command.run();
- } catch (err) {
- errors.handle(err);
- }
-}
-
-alfa();
+///
+
+import * as path from "path";
+import * as process from "process";
+import * as tty from "tty";
+
+import { Command, Flag } from "@siteimprove/alfa-command";
+import { None } from "@siteimprove/alfa-option";
+import { Err } from "@siteimprove/alfa-result";
+
+import * as pkg from "../package.json";
+
+import audit from "./alfa/command/audit";
+import scrape from "./alfa/command/scrape";
+
+const {
+ argv: [node, bin],
+ platform,
+ arch,
+ version,
+} = process;
+
+const application = Command.withSubcommands(
+ path.basename(bin),
+ `${pkg.name}/${pkg.version} ${platform}-${arch} node-${version}`,
+ `The tool for all your accessibility needs on the command line.`,
+ {
+ help: Flag.help("Display the help information."),
+ version: Flag.version("Output the current version."),
+ },
+ (self) => ({
+ audit: audit(self),
+ scrape: scrape(self),
+ }),
+ None
+);
+
+application
+ .run(process.argv.slice(2))
+ .catch((err: Error) => Err.of(`${err.message}`))
+ .then((result) => {
+ let stream: tty.WriteStream;
+ let output: string;
+
+ if (result.isOk()) {
+ stream = process.stdout;
+ output = result.get();
+ } else {
+ stream = process.stderr;
+ output = result.getErr();
+ }
+
+ output = output.trimRight();
+
+ if (output.length > 0) {
+ stream.write(output + "\n");
+ }
+
+ process.exit(result.isOk() ? 0 : 1);
+ });
diff --git a/packages/alfa-cli/bin/alfa/command/audit.ts b/packages/alfa-cli/bin/alfa/command/audit.ts
new file mode 100644
index 0000000000..b62114dcaf
--- /dev/null
+++ b/packages/alfa-cli/bin/alfa/command/audit.ts
@@ -0,0 +1,22 @@
+import { Command } from "@siteimprove/alfa-command";
+import { Option } from "@siteimprove/alfa-option";
+
+import { Arguments } from "./audit/arguments";
+import { Flags } from "./audit/flags";
+
+/**
+ * @internal
+ */
+export default (parent: Command) =>
+ Command.withArguments(
+ "audit",
+ parent.version,
+ "Perform an accessibility audit of a page.",
+ Flags,
+ Arguments,
+ Option.of(parent),
+ () => async (...args) => {
+ const { run } = await import("./audit/run");
+ return run(...args);
+ }
+ );
diff --git a/packages/alfa-cli/bin/alfa/command/audit/arguments.ts b/packages/alfa-cli/bin/alfa/command/audit/arguments.ts
new file mode 100644
index 0000000000..79d9b04e15
--- /dev/null
+++ b/packages/alfa-cli/bin/alfa/command/audit/arguments.ts
@@ -0,0 +1,11 @@
+import { Argument } from "@siteimprove/alfa-command";
+
+export const Arguments = {
+ url: Argument.string(
+ "url",
+ `The URL of the page to audit. Both remote and local protocols are
+ supported so the URL can either be an address of a remote page or a path to
+ a local file. If no URL is provided, an already serialised page will be read
+ from stdin.`
+ ).optional(),
+};
diff --git a/packages/alfa-cli/bin/alfa/command/audit/flags.ts b/packages/alfa-cli/bin/alfa/command/audit/flags.ts
new file mode 100644
index 0000000000..cdc683ed37
--- /dev/null
+++ b/packages/alfa-cli/bin/alfa/command/audit/flags.ts
@@ -0,0 +1,40 @@
+import { Flag } from "@siteimprove/alfa-command";
+
+import * as scrape from "../scrape/flags";
+
+export const Flags = {
+ help: Flag.help("Display the help information."),
+
+ interactive: Flag.boolean(
+ "interactive",
+ "Whether or not to run an interactive audit."
+ )
+ .alias("i")
+ .default(false),
+
+ format: Flag.string("format", "The reporting format to use.")
+ .type("format or package")
+ .alias("f")
+ .default("earl"),
+
+ output: Flag.string(
+ "output",
+ `The path to write results to. If no path is provided, results are written
+ to stdout.`
+ )
+ .type("path")
+ .alias("o")
+ .optional(),
+
+ outcomes: Flag.string(
+ "outcome",
+ `The type of outcome to include in the results. If not provided, all types
+ of outcomes are included. This flag can be repeated to include multiple
+ types of outcomes.`
+ )
+ .choices("passed", "failed", "inapplicable", "cantTell")
+ .repeatable()
+ .optional(),
+
+ ...scrape.Flags,
+};
diff --git a/packages/alfa-cli/bin/alfa/command/audit/run.ts b/packages/alfa-cli/bin/alfa/command/audit/run.ts
new file mode 100644
index 0000000000..a2cb2e913a
--- /dev/null
+++ b/packages/alfa-cli/bin/alfa/command/audit/run.ts
@@ -0,0 +1,97 @@
+///
+
+import * as fs from "fs";
+
+import { Audit, Outcome } from "@siteimprove/alfa-act";
+import { Command } from "@siteimprove/alfa-command";
+import { Node } from "@siteimprove/alfa-dom";
+import { Formatter } from "@siteimprove/alfa-formatter";
+import { Iterable } from "@siteimprove/alfa-iterable";
+import { None } from "@siteimprove/alfa-option";
+import { Ok } from "@siteimprove/alfa-result";
+import { Rules, Question } from "@siteimprove/alfa-rules";
+import { Page } from "@siteimprove/alfa-web";
+
+import { Oracle } from "../../oracle";
+
+import type { Arguments } from "./arguments";
+import type { Flags } from "./flags";
+
+import * as scrape from "../scrape/run";
+
+type Input = Page;
+type Target = Node | Iterable;
+
+export const run: Command.Runner = async ({
+ flags,
+ args: { url: target },
+}) => {
+ const formatter = Formatter.load(flags.format);
+
+ if (formatter.isErr()) {
+ return formatter;
+ }
+
+ let json: string;
+
+ if (target.isNone()) {
+ json = fs.readFileSync(0, "utf-8");
+ } else {
+ const result = await scrape.run({
+ flags: {
+ ...flags,
+ output: None,
+ },
+ args: {
+ url: target.get(),
+ },
+ });
+
+ if (result.isErr()) {
+ return result;
+ }
+
+ json = result.get();
+ }
+
+ const page = Page.from(JSON.parse(json));
+
+ const audit = Rules.reduce(
+ (audit, rule) => audit.add(rule),
+ Audit.of(
+ page,
+ flags.interactive ? Oracle(page) : undefined
+ )
+ );
+
+ let outcomes = await audit.evaluate();
+
+ if (flags.outcomes.isSome()) {
+ const filter = new Set(flags.outcomes.get());
+
+ outcomes = Iterable.filter(outcomes, (outcome) => {
+ if (Outcome.isPassed(outcome)) {
+ return filter.has("passed");
+ }
+
+ if (Outcome.isFailed(outcome)) {
+ return filter.has("failed");
+ }
+
+ if (Outcome.isInapplicable(outcome)) {
+ return filter.has("inapplicable");
+ }
+
+ return filter.has("cantTell");
+ });
+ }
+
+ const output = formatter.get()(page, outcomes);
+
+ if (flags.output.isNone()) {
+ return Ok.of(output);
+ } else {
+ fs.writeFileSync(flags.output.get() + "\n", output);
+ return Ok.of("");
+ }
+};
diff --git a/packages/alfa-cli/bin/alfa/command/scrape.ts b/packages/alfa-cli/bin/alfa/command/scrape.ts
new file mode 100644
index 0000000000..f2f76c836e
--- /dev/null
+++ b/packages/alfa-cli/bin/alfa/command/scrape.ts
@@ -0,0 +1,22 @@
+import { Command } from "@siteimprove/alfa-command";
+import { Option } from "@siteimprove/alfa-option";
+
+import { Arguments } from "./scrape/arguments";
+import { Flags } from "./scrape/flags";
+
+/**
+ * @internal
+ */
+export default (parent: Command) =>
+ Command.withArguments(
+ "scrape",
+ parent.version,
+ "Scrape a page and output it in a serialisable format.",
+ Flags,
+ Arguments,
+ Option.of(parent),
+ () => async (...args) => {
+ const { run } = await import("./scrape/run");
+ return run(...args);
+ }
+ );
diff --git a/packages/alfa-cli/bin/alfa/command/scrape/arguments.ts b/packages/alfa-cli/bin/alfa/command/scrape/arguments.ts
new file mode 100644
index 0000000000..03061db0de
--- /dev/null
+++ b/packages/alfa-cli/bin/alfa/command/scrape/arguments.ts
@@ -0,0 +1,10 @@
+import { Argument } from "@siteimprove/alfa-command";
+
+export const Arguments = {
+ url: Argument.string(
+ "url",
+ `The URL of the page to scrape. Both remote and local protocols are
+ supported so the URL can either be an address of a remote page or a path to
+ a local file.`
+ ),
+};
diff --git a/packages/alfa-cli/bin/alfa/command/scrape/flags.ts b/packages/alfa-cli/bin/alfa/command/scrape/flags.ts
new file mode 100644
index 0000000000..ff9fcb955e
--- /dev/null
+++ b/packages/alfa-cli/bin/alfa/command/scrape/flags.ts
@@ -0,0 +1,156 @@
+import { Flag } from "@siteimprove/alfa-command";
+
+export const Flags = {
+ help: Flag.help("Display the help information."),
+
+ output: Flag.string(
+ "output",
+ `The path to write the page to. If no path is provided, the page is written
+ to stdout.`
+ )
+ .type("path")
+ .alias("o")
+ .optional(),
+
+ timeout: Flag.integer(
+ "timeout",
+ "The maximum time to wait for the page to load."
+ )
+ .type("milliseconds")
+ .default(10000),
+
+ width: Flag.integer("width", "The width of the browser viewport.")
+ .type("pixels")
+ .alias("w")
+ .default(1280),
+
+ height: Flag.integer("height", "The height of the browser viewport.")
+ .type("pixels")
+ .alias("h")
+ .default(720),
+
+ orientation: Flag.string(
+ "orientation",
+ "The orientation of the browser viewport."
+ )
+ .choices("landscape", "portrait")
+ .default("landscape"),
+
+ resolution: Flag.integer(
+ "resolution",
+ "The pixel density of the browser as a device pixel ratio."
+ )
+ .type("ratio")
+ .default(1),
+
+ scripting: Flag.boolean(
+ "scripting",
+ "Whether or not scripts, such as JavaScript, are evaluated."
+ ).default(true),
+
+ username: Flag.string(
+ "username",
+ "The username to use for HTTP Basic authentication."
+ )
+ .alias("u")
+ .optional(),
+
+ password: Flag.string(
+ "password",
+ "The password to use for HTTP Basic authentication."
+ )
+ .alias("p")
+ .optional(),
+
+ headers: Flag.string(
+ "header",
+ `An additional header to set as a name:value pair. This flag can be repeated
+ to set multiple headers.`
+ )
+ .type("name:value")
+ .repeatable()
+ .default([]),
+
+ headersPath: Flag.string(
+ "headers",
+ `A path to a JSON file containing additional headers to set. The file must
+ contain a list of { name: string, value: string } objects.`
+ )
+ .type("path")
+ .optional(),
+
+ cookies: Flag.string(
+ "cookie",
+ `An additional cookie to set as a name=value pair. This flag can be repeated
+ to set multiple cookies.`
+ )
+ .type("name=value")
+ .repeatable()
+ .default([]),
+
+ cookiesPath: Flag.string(
+ "cookies",
+ `A path to a JSON file containing additional cookies to set. The file must
+ contain a list of { name: string, value: string } objects.`
+ )
+ .type("path")
+ .optional(),
+
+ awaitState: Flag.string(
+ "await-state",
+ "The state to await before considering the page loaded."
+ )
+ .choices("ready", "loaded", "idle")
+ .default("loaded"),
+
+ awaitDuration: Flag.integer(
+ "await-duration",
+ "The duration to wait before considering the page loaded."
+ )
+ .type("milliseconds")
+ .optional(),
+
+ awaitSelector: Flag.string(
+ "await-selector",
+ `A CSS selector matching an element that must be present before considering
+ the page loaded.`
+ )
+ .type("selector")
+ .optional(),
+
+ awaitXPath: Flag.string(
+ "await-xpath",
+ `An XPath expression evaluating to an element that must be present before
+ considering the page loaded.`
+ )
+ .type("expression")
+ .optional(),
+
+ screenshot: Flag.string(
+ "screenshot",
+ "The path to write a screenshot to. If not provided, no screenshot is taken."
+ )
+ .type("path")
+ .optional(),
+
+ screenshotType: Flag.string(
+ "screenshot-type",
+ "The file type of the screenshot"
+ )
+ .choices("png", "jpeg")
+ .default("png"),
+
+ screenshotBackground: Flag.boolean(
+ "screenshot-background",
+ `Whether or not the screenshot should include a default white background.
+ Only applies to PNG screenshots.`
+ ).default(true),
+
+ screenshotQuality: Flag.integer(
+ "screenshot-quality",
+ "The quality of the screenshot. Only applies to JPEG screenshots."
+ )
+ .type("0-100")
+ .filter((value) => value >= 0 && value <= 100)
+ .default(100),
+};
diff --git a/packages/alfa-cli/bin/alfa/command/scrape/run.ts b/packages/alfa-cli/bin/alfa/command/scrape/run.ts
new file mode 100644
index 0000000000..652d4b0379
--- /dev/null
+++ b/packages/alfa-cli/bin/alfa/command/scrape/run.ts
@@ -0,0 +1,168 @@
+///
+
+import * as fs from "fs";
+import * as path from "path";
+import * as url from "url";
+
+import type { Command } from "@siteimprove/alfa-command";
+import { Device, Display, Scripting, Viewport } from "@siteimprove/alfa-device";
+import { Header, Cookie } from "@siteimprove/alfa-http";
+import { Ok, Err } from "@siteimprove/alfa-result";
+import {
+ Awaiter,
+ Credentials,
+ Scraper,
+ Screenshot,
+} from "@siteimprove/alfa-scraper";
+import { Timeout } from "@siteimprove/alfa-time";
+
+import type { Arguments } from "./arguments";
+import type { Flags } from "./flags";
+
+export const run: Command.Runner = async ({
+ flags,
+ args: { url: target },
+}) => {
+ const scraper = await Scraper.of();
+
+ let awaiter: Awaiter | undefined;
+
+ for (const state of flags.awaitState) {
+ switch (state) {
+ case "ready":
+ awaiter = Awaiter.ready();
+ break;
+ case "loaded":
+ awaiter = Awaiter.loaded();
+ break;
+ case "idle":
+ awaiter = Awaiter.idle();
+ }
+ }
+
+ for (const duration of flags.awaitDuration) {
+ awaiter = Awaiter.duration(duration);
+ }
+
+ for (const selector of flags.awaitSelector) {
+ awaiter = Awaiter.selector(selector);
+ }
+
+ for (const xpath of flags.awaitXPath) {
+ awaiter = Awaiter.xpath(xpath);
+ }
+
+ const orientation =
+ flags.orientation === "portrait"
+ ? Viewport.Orientation.Portrait
+ : Viewport.Orientation.Landscape;
+
+ const device = Device.of(
+ Device.Type.Screen,
+ Viewport.of(flags.width, flags.height, orientation),
+ Display.of(flags.resolution),
+ Scripting.of(flags.scripting)
+ );
+
+ const credentials =
+ flags.username.isNone() || flags.password.isNone()
+ ? undefined
+ : Credentials.of(flags.username.get(), flags.password.get());
+
+ let screenshot: Screenshot | undefined;
+
+ for (const path of flags.screenshot) {
+ switch (flags.screenshotType) {
+ case "png":
+ screenshot = Screenshot.of(
+ path,
+ Screenshot.Type.PNG.of(flags.screenshotBackground)
+ );
+ break;
+
+ case "jpeg":
+ screenshot = Screenshot.of(
+ path,
+ Screenshot.Type.JPEG.of(flags.screenshotQuality)
+ );
+ }
+ }
+
+ const headers = [...flags.headers].map((header) => {
+ const index = header.indexOf(":");
+
+ if (index === -1) {
+ return Header.of(header, "");
+ }
+
+ return Header.of(header.substring(0, index), header.substring(index + 1));
+ });
+
+ for (const path of flags.headersPath) {
+ try {
+ const parsed = Array.from(
+ JSON.parse(fs.readFileSync(path, "utf-8"))
+ );
+
+ headers.push(
+ ...parsed.map((header) => Header.of(header.name, header.value))
+ );
+ } catch (err) {
+ return Err.of(err.message);
+ }
+ }
+
+ const cookies = [...flags.cookies].map((cookie) => {
+ const index = cookie.indexOf("=");
+
+ if (index === -1) {
+ return Cookie.of(cookie, "");
+ }
+
+ return Cookie.of(cookie.substring(0, index), cookie.substring(index + 1));
+ });
+
+ for (const path of flags.cookiesPath) {
+ try {
+ const parsed = Array.from(
+ JSON.parse(fs.readFileSync(path, "utf-8"))
+ );
+
+ cookies.push(
+ ...parsed.map((cookie) => Cookie.of(cookie.name, cookie.value))
+ );
+ } catch (err) {
+ return Err.of(err.message);
+ }
+ }
+
+ const timeout = Timeout.of(flags.timeout);
+
+ const result = await scraper.scrape(
+ new URL(target, url.pathToFileURL(process.cwd() + path.sep)),
+ {
+ timeout,
+ awaiter,
+ device,
+ credentials,
+ screenshot,
+ headers,
+ cookies,
+ }
+ );
+
+ await scraper.close();
+
+ if (result.isErr()) {
+ return result;
+ }
+
+ const output = JSON.stringify(result.get());
+
+ if (flags.output.isNone()) {
+ return Ok.of(output);
+ } else {
+ fs.writeFileSync(flags.output.get() + "\n", output);
+ return Ok.of("");
+ }
+};
diff --git a/packages/alfa-cli/bin/alfa/commands/audit.ts b/packages/alfa-cli/bin/alfa/commands/audit.ts
deleted file mode 100644
index bab8cb866d..0000000000
--- a/packages/alfa-cli/bin/alfa/commands/audit.ts
+++ /dev/null
@@ -1,143 +0,0 @@
-///
-
-import * as fs from "fs";
-import * as temp from "tempy";
-
-import { Command, flags } from "@oclif/command";
-import { error } from "@oclif/errors";
-
-import * as parser from "@oclif/parser";
-
-import { Outcome } from "@siteimprove/alfa-act";
-import { Node } from "@siteimprove/alfa-dom";
-import { Formatter } from "@siteimprove/alfa-formatter";
-import { Iterable } from "@siteimprove/alfa-iterable";
-import { Rules, Question } from "@siteimprove/alfa-rules";
-import { Page } from "@siteimprove/alfa-web";
-
-import * as act from "@siteimprove/alfa-act";
-
-import Scrape from "./scrape";
-
-import { Oracle } from "../../../src/oracle";
-
-type Input = Page;
-type Target = Node | Iterable;
-
-export default class Audit extends Command {
- public static description = "Perform an accessibility audit of a page";
-
- public static flags = {
- ...Scrape.flags,
-
- help: flags.help({
- description: "Display this command help",
- }),
-
- interactive: flags.boolean({
- char: "i",
- default: false,
- allowNo: true,
- description: "Whether or not to run an interactive audit",
- }),
-
- format: flags.string({
- char: "f",
- default: "earl",
- helpValue: "format or package",
- description: "The reporting format to use",
- }),
-
- output: flags.string({
- char: "o",
- helpValue: "path",
- description:
- "The path to write results to. If no path is provided, results are written to stdout",
- }),
-
- outcomes: flags.string({
- options: ["passed", "failed", "inapplicable", "cantTell"],
- multiple: true,
- description:
- "The outcomes to include in the results. If not provided, all outcomes are included",
- }),
- };
-
- public static args = [
- {
- name: "url",
- description:
- "The URL of the page to audit. If no URL is provided, an already serialised page will be read from stdin",
- },
- ];
-
- public async run() {
- const { args, flags } = this.parse(Audit);
-
- await Audit.runWithFlags(flags, args.url);
- }
-
- public static async runWithFlags(flags: Flags, target?: string) {
- const formatter = Formatter.load(flags.format);
-
- if (formatter.isErr()) {
- error(formatter.getErr(), { exit: 1 });
- }
-
- let json: string;
-
- if (target === undefined) {
- json = fs.readFileSync(0, "utf-8");
- } else {
- const output = temp.file({ extension: "json" });
-
- await Scrape.runWithFlags({ ...flags, output }, target);
-
- json = fs.readFileSync(output, "utf-8");
-
- fs.unlinkSync(output);
- }
-
- const page = Page.from(JSON.parse(json));
-
- const audit = Rules.reduce(
- (audit, rule) => audit.add(rule),
- act.Audit.of(
- page,
- flags.interactive ? Oracle(page) : undefined
- )
- );
-
- let outcomes = await audit.evaluate();
-
- if (flags.outcomes !== undefined) {
- const filter = new Set(flags.outcomes);
-
- outcomes = Iterable.filter(outcomes, (outcome) => {
- if (Outcome.isPassed(outcome)) {
- return filter.has("passed");
- }
-
- if (Outcome.isFailed(outcome)) {
- return filter.has("failed");
- }
-
- if (Outcome.isInapplicable(outcome)) {
- return filter.has("inapplicable");
- }
-
- return filter.has("cantTell");
- });
- }
-
- const output = formatter.get()(page, outcomes) + "\n";
-
- if (flags.output === undefined) {
- process.stdout.write(output);
- } else {
- fs.writeFileSync(flags.output, output);
- }
- }
-}
-
-export type Flags = typeof Audit extends parser.Input ? F : never;
diff --git a/packages/alfa-cli/bin/alfa/commands/scrape.ts b/packages/alfa-cli/bin/alfa/commands/scrape.ts
deleted file mode 100644
index 2a10e261ca..0000000000
--- a/packages/alfa-cli/bin/alfa/commands/scrape.ts
+++ /dev/null
@@ -1,302 +0,0 @@
-///
-
-import * as fs from "fs";
-import * as path from "path";
-import * as url from "url";
-
-import { Command, flags } from "@oclif/command";
-import { error } from "@oclif/errors";
-
-import * as parser from "@oclif/parser";
-
-import { Device, Display, Scripting, Viewport } from "@siteimprove/alfa-device";
-import { Header, Cookie } from "@siteimprove/alfa-http";
-import {
- Awaiter,
- Credentials,
- Scraper,
- Screenshot,
-} from "@siteimprove/alfa-scraper";
-import { Sequence } from "@siteimprove/alfa-sequence";
-import { Timeout } from "@siteimprove/alfa-time";
-
-export default class Scrape extends Command {
- public static description =
- "Scrape a page and output it in a serialisable format";
-
- public static flags = {
- help: flags.help({
- description: "Display this command help",
- }),
-
- output: flags.string({
- char: "o",
- helpValue: "path",
- description:
- "The path to write the page to. If no path is provided, the page is written to stdout",
- }),
-
- timeout: flags.integer({
- default: 10000,
- helpValue: "milliseconds",
- description: "The maximum time to wait for the page to load",
- }),
-
- width: flags.integer({
- char: "w",
- default: Viewport.standard().width,
- helpValue: "pixels",
- description: "The width of the browser viewport",
- }),
-
- height: flags.integer({
- char: "h",
- default: Viewport.standard().height,
- helpValue: "pixels",
- description: "The height of the browser viewport",
- }),
-
- orientation: flags.enum({
- options: ["landscape", "portrait"],
- default: Viewport.standard().orientation,
- description: "The orientation of the browser viewport",
- }),
-
- resolution: flags.integer({
- default: 1,
- description: "The pixel density of the browser",
- }),
-
- scripting: flags.boolean({
- default: true,
- allowNo: true,
- description: "Whether or not scripts, such as JavaScript, are evaluated",
- }),
-
- username: flags.string({
- char: "u",
- dependsOn: ["password"],
- description: "The username to use for HTTP Basic authentication",
- }),
-
- password: flags.string({
- char: "p",
- dependsOn: ["username"],
- description: "The password to use for HTTP Basic authentication",
- }),
-
- headers: flags.string({
- helpValue: "name:value or path",
- multiple: true,
- description: `Additional headers to set, either as name:value pairs or a path to a JSON file`,
- }),
-
- cookies: flags.string({
- helpValue: "name=value or path",
- multiple: true,
- description: `Additional cookies to set, either as name=value pairs or a path to a JSON file`,
- }),
-
- "await-state": flags.enum({
- options: ["ready", "loaded", "idle"],
- exclusive: ["await-duration", "await-selector", "await-xpath"],
- description: "The state to await before considering the page loaded",
- }),
-
- "await-duration": flags.integer({
- exclusive: ["await-state", "await-selector", "await-xpath"],
- helpValue: "milliseconds",
- description: "The duration to wait before considering the page loaded",
- }),
-
- "await-selector": flags.string({
- exclusive: ["await-state", "await-duration", "await-xpath"],
- helpValue: "selector",
- description:
- "A CSS selector matching an element that must be present before considering the page loaded",
- }),
-
- "await-xpath": flags.string({
- exclusive: ["await-state", "await-duration", "await-selector"],
- helpValue: "expression",
- description:
- "An XPath expression evaluating to an element that must be present before considering the page loaded",
- }),
-
- screenshot: flags.string({
- helpValue: "path",
- description:
- "The path to write a screenshot to. If not provided, no screenshot is taken",
- }),
-
- "screenshot-type": flags.enum({
- options: ["png", "jpeg"],
- default: "png",
- description: "The file type of the screenshot",
- }),
-
- "screenshot-background": flags.boolean({
- default: true,
- allowNo: true,
- description:
- "Whether or not the screenshot should include a default white background. Only applies to PNG screenshots",
- }),
-
- "screenshot-quality": flags.integer({
- default: 100,
- helpValue: "0-100",
- description:
- "The quality of the screenshot. Only applies to JPEG screenshots",
- }),
- };
-
- public static args = [
- {
- name: "url",
- required: true,
- description: "The URL of the page to scrape",
- },
- ];
-
- public async run() {
- const { args, flags } = this.parse(Scrape);
-
- await Scrape.runWithFlags(flags, args.url);
- }
-
- public static async runWithFlags(flags: Flags, target: string) {
- const scraper = await Scraper.of();
-
- let awaiter: Awaiter | undefined;
-
- switch (flags["await-state"]) {
- case "ready":
- awaiter = Awaiter.ready();
- break;
- case "loaded":
- awaiter = Awaiter.loaded();
- break;
- case "idle":
- awaiter = Awaiter.idle();
- }
-
- if (flags["await-duration"] !== undefined) {
- awaiter = Awaiter.duration(flags["await-duration"]);
- }
-
- if (flags["await-selector"] !== undefined) {
- awaiter = Awaiter.selector(flags["await-selector"]);
- }
-
- if (flags["await-xpath"] !== undefined) {
- awaiter = Awaiter.xpath(flags["await-xpath"]);
- }
-
- const orientation =
- flags.orientation === "portrait"
- ? Viewport.Orientation.Portrait
- : Viewport.Orientation.Landscape;
-
- const device = Device.of(
- Device.Type.Screen,
- Viewport.of(flags.width, flags.height, orientation),
- Display.of(flags.resolution),
- Scripting.of(flags.scripting)
- );
-
- const credentials =
- flags.username === undefined || flags.password === undefined
- ? undefined
- : Credentials.of(flags.username, flags.password);
-
- let screenshot: Screenshot | undefined;
-
- if (flags.screenshot !== undefined) {
- switch (flags["screenshot-type"]) {
- case "png":
- screenshot = Screenshot.of(
- flags.screenshot,
- Screenshot.Type.PNG.of(flags["screenshot-background"])
- );
- break;
-
- case "jpeg":
- screenshot = Screenshot.of(
- flags.screenshot,
- Screenshot.Type.JPEG.of(flags["screenshot-quality"])
- );
- }
- }
-
- const headers = Sequence.from(flags.headers ?? []).flatMap((header) => {
- const index = header.indexOf(":");
-
- if (index === -1) {
- try {
- const headers = Sequence.from(
- JSON.parse(fs.readFileSync(header, "utf-8"))
- );
-
- return headers.map((header) => Header.of(header.name, header.value));
- } catch (err) {
- error(err.message, { exit: 1 });
- }
- }
-
- return Sequence.of(
- Header.of(header.substring(0, index), header.substring(index + 1))
- );
- });
-
- const cookies = Sequence.from(flags.cookies ?? []).flatMap((cookie) => {
- const index = cookie.indexOf("=");
-
- if (index === -1) {
- try {
- const cookies = Sequence.from(
- JSON.parse(fs.readFileSync(cookie, "utf-8"))
- );
-
- return cookies.map((cookie) => Cookie.of(cookie.name, cookie.value));
- } catch (err) {
- error(err.message, { exit: 1 });
- }
- }
-
- return Sequence.of(
- Cookie.of(cookie.substring(0, index), cookie.substring(index + 1))
- );
- });
-
- const timeout = Timeout.of(flags.timeout);
-
- const result = await scraper.scrape(
- new URL(target, url.pathToFileURL(process.cwd() + path.sep)),
- {
- timeout,
- awaiter,
- device,
- credentials,
- screenshot,
- headers,
- cookies,
- }
- );
-
- await scraper.close();
-
- if (result.isErr()) {
- error(result.getErr(), { exit: 1 });
- }
-
- const output = JSON.stringify(result.get()) + "\n";
-
- if (flags.output === undefined) {
- process.stdout.write(output);
- } else {
- fs.writeFileSync(flags.output, output);
- }
- }
-}
-
-export type Flags = typeof Scrape extends parser.Input ? F : never;
diff --git a/packages/alfa-cli/src/oracle.ts b/packages/alfa-cli/bin/alfa/oracle.ts
similarity index 93%
rename from packages/alfa-cli/src/oracle.ts
rename to packages/alfa-cli/bin/alfa/oracle.ts
index e38005db1f..aa6e053ad6 100644
--- a/packages/alfa-cli/src/oracle.ts
+++ b/packages/alfa-cli/bin/alfa/oracle.ts
@@ -10,6 +10,12 @@ import { Page } from "@siteimprove/alfa-web";
import * as act from "@siteimprove/alfa-act";
import * as xpath from "@siteimprove/alfa-xpath";
+/**
+ * @internal
+ *
+ * @todo Make this stuff external to the CLI so that it simply requires a package
+ * responsible for doing the interview.
+ */
export const Oracle = (page: Page): act.Oracle => {
const answers = Cache.empty>>();
diff --git a/packages/alfa-cli/package.json b/packages/alfa-cli/package.json
index 9a69637ecf..99fc5c6762 100644
--- a/packages/alfa-cli/package.json
+++ b/packages/alfa-cli/package.json
@@ -11,29 +11,17 @@
"directory": "packages/alfa-cli"
},
"bugs": "https://github.com/siteimprove/alfa/issues",
- "main": "src/index.js",
- "types": "src/index.d.ts",
"bin": {
"alfa": "bin/alfa.js"
},
"files": [
- "src/**/*.js",
- "src/**/*.d.ts",
"bin/**/*.js",
"bin/**/*.d.ts"
],
- "oclif": {
- "commands": "./bin/alfa/commands",
- "bin": "alfa"
- },
"dependencies": {
- "@oclif/command": "^1.5.19",
- "@oclif/config": "^1.13.3",
- "@oclif/errors": "^1.2.2",
- "@oclif/parser": "^3.8.5",
- "@oclif/plugin-help": "^2.2.1",
"@siteimprove/alfa-act": "^0.2.0",
"@siteimprove/alfa-cache": "^0.2.0",
+ "@siteimprove/alfa-command": "^0.2.0",
"@siteimprove/alfa-device": "^0.2.0",
"@siteimprove/alfa-dom": "^0.2.0",
"@siteimprove/alfa-formatter": "^0.2.0",
@@ -43,15 +31,14 @@
"@siteimprove/alfa-http": "^0.2.0",
"@siteimprove/alfa-iterable": "^0.2.0",
"@siteimprove/alfa-option": "^0.2.0",
+ "@siteimprove/alfa-result": "^0.2.0",
"@siteimprove/alfa-rules": "^0.2.0",
"@siteimprove/alfa-scraper": "^0.2.0",
- "@siteimprove/alfa-sequence": "^0.2.0",
"@siteimprove/alfa-time": "^0.2.0",
"@siteimprove/alfa-web": "^0.2.0",
"@siteimprove/alfa-xpath": "^0.2.0",
"@types/node": "^14.0.12",
- "enquirer": "^2.3.0",
- "tempy": "^0.5.0"
+ "enquirer": "^2.3.0"
},
"devDependencies": {
"@siteimprove/alfa-test": "^0.2.0"
diff --git a/packages/alfa-cli/src/index.ts b/packages/alfa-cli/src/index.ts
deleted file mode 100644
index 281f234a37..0000000000
--- a/packages/alfa-cli/src/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./oracle";
diff --git a/packages/alfa-cli/tsconfig.json b/packages/alfa-cli/tsconfig.json
index d3b46d5e4b..3c58e64109 100644
--- a/packages/alfa-cli/tsconfig.json
+++ b/packages/alfa-cli/tsconfig.json
@@ -1,12 +1,21 @@
{
"$schema": "http://json.schemastore.org/tsconfig",
"extends": "../tsconfig.json",
+ "compilerOptions": {
+ "resolveJsonModule": true
+ },
"files": [
+ "package.json",
"bin/alfa.ts",
- "bin/alfa/commands/audit.ts",
- "bin/alfa/commands/scrape.ts",
- "src/index.ts",
- "src/oracle.ts"
+ "bin/alfa/command/audit.ts",
+ "bin/alfa/command/audit/arguments.ts",
+ "bin/alfa/command/audit/flags.ts",
+ "bin/alfa/command/audit/run.ts",
+ "bin/alfa/command/scrape.ts",
+ "bin/alfa/command/scrape/arguments.ts",
+ "bin/alfa/command/scrape/flags.ts",
+ "bin/alfa/command/scrape/run.ts",
+ "bin/alfa/oracle.ts"
],
"references": [
{
@@ -15,6 +24,9 @@
{
"path": "../alfa-cache"
},
+ {
+ "path": "../alfa-command"
+ },
{
"path": "../alfa-device"
},
@@ -30,18 +42,39 @@
{
"path": "../alfa-formatter-json"
},
+ {
+ "path": "../alfa-functor"
+ },
{
"path": "../alfa-future"
},
+ {
+ "path": "../alfa-highlight"
+ },
{
"path": "../alfa-http"
},
{
"path": "../alfa-iterable"
},
+ {
+ "path": "../alfa-json"
+ },
+ {
+ "path": "../alfa-mapper"
+ },
{
"path": "../alfa-option"
},
+ {
+ "path": "../alfa-parser"
+ },
+ {
+ "path": "../alfa-predicate"
+ },
+ {
+ "path": "../alfa-result"
+ },
{
"path": "../alfa-rules"
},
diff --git a/packages/alfa-command/package.json b/packages/alfa-command/package.json
new file mode 100644
index 0000000000..c2294a4e40
--- /dev/null
+++ b/packages/alfa-command/package.json
@@ -0,0 +1,38 @@
+{
+ "$schema": "http://json.schemastore.org/package",
+ "name": "@siteimprove/alfa-command",
+ "homepage": "https://siteimprove.com",
+ "version": "0.2.0",
+ "license": "MIT",
+ "description": "Functionality for building robust command line interfaces",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/siteimprove/alfa.git",
+ "directory": "packages/alfa-command"
+ },
+ "bugs": "https://github.com/siteimprove/alfa/issues",
+ "main": "src/index.js",
+ "types": "src/index.d.ts",
+ "files": [
+ "src/**/*.js",
+ "src/**/*.d.ts"
+ ],
+ "dependencies": {
+ "@siteimprove/alfa-functor": "^0.2.0",
+ "@siteimprove/alfa-highlight": "^0.2.0",
+ "@siteimprove/alfa-json": "^0.2.0",
+ "@siteimprove/alfa-mapper": "^0.2.0",
+ "@siteimprove/alfa-option": "^0.2.0",
+ "@siteimprove/alfa-parser": "^0.2.0",
+ "@siteimprove/alfa-predicate": "^0.2.0",
+ "@siteimprove/alfa-result": "^0.2.0",
+ "@siteimprove/alfa-thunk": "^0.2.0"
+ },
+ "devDependencies": {
+ "@siteimprove/alfa-test": "^0.2.0"
+ },
+ "publishConfig": {
+ "access": "public",
+ "registry": "https://npm.pkg.github.com/"
+ }
+}
diff --git a/packages/alfa-command/src/argument.ts b/packages/alfa-command/src/argument.ts
new file mode 100644
index 0000000000..8c6754b4ff
--- /dev/null
+++ b/packages/alfa-command/src/argument.ts
@@ -0,0 +1,229 @@
+import { Functor } from "@siteimprove/alfa-functor";
+import { Serializable } from "@siteimprove/alfa-json";
+import { Mapper } from "@siteimprove/alfa-mapper";
+import { Option, None } from "@siteimprove/alfa-option";
+import { Parser } from "@siteimprove/alfa-parser";
+import { Predicate } from "@siteimprove/alfa-predicate";
+import { Ok, Err } from "@siteimprove/alfa-result";
+import { Thunk } from "@siteimprove/alfa-thunk";
+
+import * as json from "@siteimprove/alfa-json";
+import * as parser from "@siteimprove/alfa-parser";
+
+export class Argument implements Functor, Serializable {
+ public static of(
+ name: string,
+ description: string,
+ parse: Argument.Parser
+ ): Argument {
+ const options: Argument.Options = {
+ default: None,
+ optional: false,
+ repeatable: false,
+ };
+
+ return new Argument(
+ name,
+ description.replace(/\s+/g, " ").trim(),
+ options,
+ parse
+ );
+ }
+
+ private readonly _name: string;
+ private readonly _description: string;
+ private readonly _options: Argument.Options;
+ private readonly _parse: Argument.Parser;
+
+ private constructor(
+ name: string,
+ description: string,
+ options: Argument.Options,
+ parse: Argument.Parser
+ ) {
+ this._name = name;
+ this._description = description;
+ this._options = options;
+ this._parse = parse;
+ }
+
+ public get name(): string {
+ return this._name;
+ }
+
+ public get description(): string {
+ return this._description;
+ }
+
+ public get options(): Argument.Options {
+ return this._options;
+ }
+
+ public get parse(): Argument.Parser {
+ return this._parse;
+ }
+
+ public map(mapper: Mapper): Argument {
+ return new Argument(
+ this._name,
+ this._description,
+ this._options,
+ Parser.map(this._parse, mapper)
+ );
+ }
+
+ public filter(
+ predicate: Predicate,
+ ifError: Thunk = () => "Incorrect value"
+ ): Argument {
+ return new Argument(
+ this._name,
+ this._description,
+ this._options,
+ Parser.filter(this._parse, predicate, ifError)
+ );
+ }
+
+ public optional(): Argument