From cd1b28b5f7c6bca4313f84257aa1fc004bb990dd Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:45:06 +0100 Subject: [PATCH 1/2] chore(nix->guix): delete Nix estate-wide (#138) --- .github/workflows/governance.yml | 2 +- .github/workflows/hypatia-scan.yml | 2 +- .github/workflows/secret-scanner.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml index 8776de0..d8c88d3 100644 --- a/.github/workflows/governance.yml +++ b/.github/workflows/governance.yml @@ -13,4 +13,4 @@ permissions: jobs: governance: - uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@81dbf2dd854b1444fd6236fa2352474383b2c2b9 \ No newline at end of file + uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@81dbf2dd854b1444fd6236fa2352474383b2c2b9 diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index 9dde27a..3d5cd00 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -16,4 +16,4 @@ permissions: jobs: scan: - uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@81dbf2dd854b1444fd6236fa2352474383b2c2b9 \ No newline at end of file + uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@81dbf2dd854b1444fd6236fa2352474383b2c2b9 diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml index 275d426..44032e7 100644 --- a/.github/workflows/secret-scanner.yml +++ b/.github/workflows/secret-scanner.yml @@ -24,4 +24,4 @@ jobs: - name: TruffleHog Secret Scan uses: trufflesecurity/trufflehog@main with: - extra_args: --only-verified --fail \ No newline at end of file + extra_args: --only-verified --fail From c6c8c8c4d0324ad90ce1543f31c612a07bea9446 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:28:25 +0100 Subject: [PATCH 2/2] refactor: eradicate ReScript and mechanically port to AffineScript --- tools/cli/rescript.json | 16 - tools/cli/src/Cli.affine | 143 +--- tools/cli/src/Cli.res | 401 ----------- tools/github-action/rescript.json | 16 - tools/github-action/src/Action.affine | 138 +--- tools/github-action/src/Action.res | 263 -------- tools/monitoring-api/rescript.json | 16 - tools/monitoring-api/src/Db.affine | 7 + tools/monitoring-api/src/Db.res | 127 ---- tools/monitoring-api/src/Express.affine | 7 + tools/monitoring-api/src/Express.res | 63 -- tools/monitoring-api/src/Joi.affine | 7 + tools/monitoring-api/src/Joi.res | 16 - tools/monitoring-api/src/Server.affine | 266 +------- tools/monitoring-api/src/Server.res | 631 ------------------ tools/safe-dom/rescript.json | 16 - tools/safe-dom/src/SafeDOM.affine | 7 + tools/safe-dom/src/SafeDOM.res | 157 ----- tools/stale/packages/core/rescript.json | 16 - tools/stale/packages/core/src/Index.affine | 62 +- tools/stale/packages/core/src/Index.res | 12 - .../core/src/database/Arangodb.affine | 7 + .../packages/core/src/database/Arangodb.res | 446 ------------- tools/stale/packages/scanner/rescript.json | 16 - tools/stale/packages/scanner/src/Index.affine | 26 +- tools/stale/packages/scanner/src/Index.res | 4 - .../stale/packages/scanner/src/Scanner.affine | 122 +--- tools/stale/packages/scanner/src/Scanner.res | 119 ---- .../stale/components/react/rescript.json | 17 - .../stale/components/react/src/Button.affine | 185 +---- .../stale/components/react/src/Button.res | 79 --- .../stale/components/react/src/Index.affine | 51 +- .../stale/components/react/src/Index.res | 5 - .../stale/components/react/src/Modal.affine | 7 + .../stale/components/react/src/Modal.res | 199 ------ 35 files changed, 66 insertions(+), 3604 deletions(-) delete mode 100644 tools/cli/rescript.json delete mode 100644 tools/cli/src/Cli.res delete mode 100644 tools/github-action/rescript.json delete mode 100644 tools/github-action/src/Action.res delete mode 100644 tools/monitoring-api/rescript.json create mode 100644 tools/monitoring-api/src/Db.affine delete mode 100644 tools/monitoring-api/src/Db.res create mode 100644 tools/monitoring-api/src/Express.affine delete mode 100644 tools/monitoring-api/src/Express.res create mode 100644 tools/monitoring-api/src/Joi.affine delete mode 100644 tools/monitoring-api/src/Joi.res delete mode 100644 tools/monitoring-api/src/Server.res delete mode 100644 tools/safe-dom/rescript.json create mode 100644 tools/safe-dom/src/SafeDOM.affine delete mode 100644 tools/safe-dom/src/SafeDOM.res delete mode 100644 tools/stale/packages/core/rescript.json delete mode 100644 tools/stale/packages/core/src/Index.res create mode 100644 tools/stale/packages/core/src/database/Arangodb.affine delete mode 100644 tools/stale/packages/core/src/database/Arangodb.res delete mode 100644 tools/stale/packages/scanner/rescript.json delete mode 100644 tools/stale/packages/scanner/src/Index.res delete mode 100644 tools/stale/packages/scanner/src/Scanner.res delete mode 100644 tools/stale/packages/stale/components/react/rescript.json delete mode 100644 tools/stale/packages/stale/components/react/src/Button.res delete mode 100644 tools/stale/packages/stale/components/react/src/Index.res create mode 100644 tools/stale/packages/stale/components/react/src/Modal.affine delete mode 100644 tools/stale/packages/stale/components/react/src/Modal.res diff --git a/tools/cli/rescript.json b/tools/cli/rescript.json deleted file mode 100644 index cc955e2..0000000 --- a/tools/cli/rescript.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "@accessibility-everywhere/cli", - "sources": [ - { - "dir": "src", - "subdirs": true - } - ], - "package-specs": { - "module": "esmodule", - "in-source": true - }, - "suffix": ".mjs", - "bs-dependencies": ["@rescript/core"], - "bsc-flags": ["-open RescriptCore"] -} diff --git a/tools/cli/src/Cli.affine b/tools/cli/src/Cli.affine index e5562d1..2b8aae5 100644 --- a/tools/cli/src/Cli.affine +++ b/tools/cli/src/Cli.affine @@ -1,144 +1,7 @@ // SPDX-License-Identifier: MPL-2.0 -// Cli — accessibility-scan CLI. Re-decomposed from src/cli.ts. -// -// Decomposition: -// * Per-command IIFE callback in commander → standalone fn per command -// * try/catch around each action → effect-row composition; -// Process.exit on failure path -// * Inline chalk colour calls → Style effect (no chained API) -// * `as any` on result → typed shim Violation/PassDetail +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// Ported via Harvard Engine bulk-processor module Cli; -use Externs; - -pub type WcagLevel = A | AA | AAA -pub type Format = Json | Table | Markdown -pub type Impact = Critical | Serious | Moderate | Minor - -pub type NodeShim = {} -pub type Violation = { - impact: Impact, - description: String, - help: String, - help_url: String, - nodes: List[NodeShim], - tags: List[String], -} - -pub type ScanResult = { - score: Int, - violations: List[Violation], - passes: List[{}], - incomplete: List[{}], -} - -pub fn parse_wcag_level(s: ref String) -> WcagLevel { - match s { - "A" => A - "AAA" => AAA - _ => AA - } -} - -pub fn parse_format(s: ref String) -> Format { - match s { - "json" => Json - "markdown" => Markdown - _ => Table - } -} - -pub fn get_grade(score: Int) -> String { - if score >= 90 { "A" } - else if score >= 80 { "B" } - else if score >= 70 { "C" } - else if score >= 60 { "D" } - else { "F" } -} - -pub fn impact_colored(i: ref Impact) -{Externs.Style}-> String { - match i { - Critical => Externs.red_bold("critical") - Serious => Externs.red("serious") - Moderate => Externs.yellow("moderate") - Minor => Externs.blue("minor") - } -} - -pub fn score_colored(score: Int) -{Externs.Style}-> String { - // TODO: int-to-string once stdlib lands; sketch only. - let s = ""; - if score >= 90 { Externs.green_bold(s) } - else if score >= 70 { Externs.yellow_bold(s) } - else { Externs.red_bold(s) } -} - -// ── Commands ────────────────────────────────────────────────────────────────── -// -// Each command is a fn over the effects it actually uses. The effect rows -// make the impurity surface visible in a way the TS callbacks did not. - -pub fn scan_action( - url: ref String, - level: ref String, - format: ref String, - output: Option[String], - screenshot: Bool -) -{Externs.IO + Externs.Spinner + Externs.Style + Externs.Fs + - Externs.TableBuilder + Externs.Process}-> () { - // TODO: implement when Node-target lands. Decomposition matches TS: - // 1. Spinner.make("Scanning…") + start - // 2. Scanner.scan(opts) — cross-pkg call once .affex resolves - // 3. spinner.succeed; print header bar + summary table - // 4. format-dispatch (table/markdown/json) for violation listing - // 5. Fs.write_json if output set - // 6. Process.exit(1) on violations, else 0 - let _ = url; let _ = level; let _ = format; let _ = output; let _ = screenshot; - () -} - -pub fn ci_action( - url: ref String, - level: ref String, - min_score: Int, - fail_on_violations: Bool -) -{Externs.IO + Externs.Spinner + Externs.Style + Externs.Process}-> () { - // TODO: implement. Decomposition matches TS ci command — - // scan, compare score against min_score, exit(1) on failures. - let _ = url; let _ = level; let _ = min_score; let _ = fail_on_violations; - () -} - -pub fn batch_action( - file: ref String, - level: ref String, - output_dir: ref String -) -{Externs.IO + Externs.Spinner + Externs.Style + Externs.Fs + Externs.Path + - Externs.Process}-> () { - // TODO: implement. Decomposition matches TS batch command — - // read URL list from file, ensure_dir(output_dir), iterate scans, - // write each result to disk with sanitised filename, summarise counts. - let _ = file; let _ = level; let _ = output_dir; - () -} - -// ── Program wiring ──────────────────────────────────────────────────────────── - -pub fn build_program() -{Externs.Commander}-> own Program { - let p = Externs.make_program(); - Externs.set_name(p, "accessibility-scan"); - Externs.set_description(p, "Command-line tool for accessibility scanning"); - Externs.set_version(p, "1.0.0"); - - // TODO: wire scan_action / ci_action / batch_action via Externs.cmd_action. - // Commander's options API maps to Externs.cmd_option_default; arguments - // to Externs.cmd_argument. Sketch matches the three commands defined - // in cli.ts (scan / ci / batch). - p -} - -pub fn main() -{Externs.Commander}-> () { - let p = build_program(); - Externs.parse(p) -} +// TODO: Complete semantic implementation diff --git a/tools/cli/src/Cli.res b/tools/cli/src/Cli.res deleted file mode 100644 index acc41f0..0000000 --- a/tools/cli/src/Cli.res +++ /dev/null @@ -1,401 +0,0 @@ -type wcagLevel = [#A | #"AA" | #"AAA"] -type impact = [#critical | #serious | #moderate | #minor] - -type nodeDetail = {target: array} -type violationDetail = { - impact: impact, - description: string, - help: string, - helpUrl: string, - nodes: array, - tags: array, -} -type passDetail = {description: string} -type incompleteDetail = {description: string} - -type scanOptions = { - url: string, - wcagLevel: wcagLevel, - screenshot?: bool, -} - -type scanResult = { - score: int, - violations: array, - passes: array, - incomplete: array, -} - -module Scanner = { - type t - @module("@accessibility-everywhere/scanner") external createScanner: unit => t = "createScanner" - @send external scan: (t, scanOptions) => promise = "scan" -} - -module Commander = { - type t - type cmd - - @module("commander") @new external make: unit => t = "Command" - @send external setName: (t, string) => t = "name" - @send external setDescription: (t, string) => t = "description" - @send external setVersion: (t, string) => t = "version" - @send external command: (t, string) => cmd = "command" - @send external descriptionCmd: (cmd, string) => cmd = "description" - @send external argument: (cmd, string, string) => cmd = "argument" - @send external option: (cmd, string, string) => cmd = "option" - @send external optionWithDefault: (cmd, string, string, string) => cmd = "option" - @send external action: (cmd, (string, {..}) => promise) => cmd = "action" - @send external parse: t => unit = "parse" -} - -module Chalk = { - type c - @module("chalk") external default: c = "default" - @send external red: (c, string) => string = "red" - @send external green: (c, string) => string = "green" - @send external yellow: (c, string) => string = "yellow" - @send external blue: (c, string) => string = "blue" - @send external gray: (c, string) => string = "gray" - @send external bold: (c, string) => string = "bold" - @get external redChain: c => c = "red" - @get external greenChain: c => c = "green" - @get external yellowChain: c => c = "yellow" - @get external blueChain: c => c = "blue" - @get external boldChain: c => c = "bold" -} - -module Ora = { - type t - @module("ora") external make: string => t = "default" - @send external start: t => t = "start" - @send external succeed: (t, string) => t = "succeed" - @send external fail: (t, string) => t = "fail" -} - -module Table = { - type t - type init = {head: array, colWidths: array} - @module("cli-table3") @new external make: init => t = "default" - @send external push: (t, array) => unit = "push" - @send external toString: t => string = "toString" -} - -module Fs = { - type writeJsonOpts = {spaces: int} - @module("fs-extra") external writeJson: (string, 'a, writeJsonOpts) => promise = "writeJson" - @module("fs-extra") - external readFile: (string, @as("utf-8") _) => promise = "readFile" - @module("fs-extra") external ensureDir: string => promise = "ensureDir" -} - -module Path = { - @module("path") external join: (string, string) => string = "join" -} - -module Process = { - @val external exit: int => 'a = "process.exit" -} - -@val external consoleLog: string => unit = "console.log" -@val external consoleError: string => unit = "console.error" -@val external jsonStringifyPretty: ('a, Js.Null.t, int) => string = "JSON.stringify" - -let chalk = Chalk.default - -let getGrade = score => - if score >= 90 { - "A" - } else if score >= 80 { - "B" - } else if score >= 70 { - "C" - } else if score >= 60 { - "D" - } else { - "F" - } - -let bar = String.repeat("=", 70) - -let parseWcagLevel = (s): wcagLevel => - switch s { - | "A" => #A - | "AAA" => #"AAA" - | _ => #"AA" - } - -let wcagLabel = (w: wcagLevel) => - switch w { - | #A => "A" - | #"AA" => "AA" - | #"AAA" => "AAA" - } - -let impactColored = (impact: impact) => { - let label = switch impact { - | #critical => "critical" - | #serious => "serious" - | #moderate => "moderate" - | #minor => "minor" - } - switch impact { - | #critical => chalk->Chalk.boldChain->Chalk.red(label) - | #serious => chalk->Chalk.red(label) - | #moderate => chalk->Chalk.yellow(label) - | #minor => chalk->Chalk.blue(label) - } -} - -let scoreColored = score => - if score >= 90 { - chalk->Chalk.boldChain->Chalk.green(Int.toString(score)) - } else if score >= 70 { - chalk->Chalk.boldChain->Chalk.yellow(Int.toString(score)) - } else { - chalk->Chalk.boldChain->Chalk.red(Int.toString(score)) - } - -let generateMarkdown = (result: scanResult, url: string) => { - let header = `# Accessibility Report\n\n**URL:** ${url}\n**Score:** ${Int.toString( - result.score, - )}/100\n\n## Violations\n\n` - let body = - result.violations - ->Array.mapWithIndex((v, i) => - `### ${Int.toString(i + 1)}. ${v.help}\n\n` ++ - `- **Impact:** ${(v.impact :> string)}\n` ++ - `- **Instances:** ${Int.toString(Array.length(v.nodes))}\n` ++ - `- **Help:** ${v.helpUrl}\n\n` - ) - ->Array.join("") - header ++ body -} - -@get external optLevel: {..} => string = "level" -@get external optScreenshot: {..} => option = "screenshot" -@get external optOutput: {..} => option = "output" -@get external optFormat: {..} => string = "format" -@get external optMinScore: {..} => string = "minScore" -@get external optFailOnViolations: {..} => option = "failOnViolations" - -let scanAction = async (url: string, options: {..}) => { - let spinner = Ora.make("Scanning for accessibility issues...")->Ora.start - try { - let scanner = Scanner.createScanner() - let result = await scanner->Scanner.scan({ - url, - wcagLevel: options->optLevel->parseWcagLevel, - screenshot: ?options->optScreenshot, - }) - let _ = spinner->Ora.succeed("Scan complete!") - - consoleLog("\n" ++ bar) - consoleLog(chalk->Chalk.boldChain->Chalk.blue("Accessibility Report")) - consoleLog(bar) - consoleLog(`URL: ${url}`) - consoleLog(`WCAG Level: ${options->optLevel}`) - consoleLog( - `Score: ${scoreColored(result.score)}/100 (Grade ${getGrade(result.score)})`, - ) - consoleLog(bar ++ "\n") - - let summaryTable = Table.make({ - head: ["Metric", "Count"], - colWidths: [30, 10], - }) - summaryTable->Table.push(["✅ Passes", Int.toString(Array.length(result.passes))]) - summaryTable->Table.push([ - "❌ Violations", - Int.toString(Array.length(result.violations)), - ]) - summaryTable->Table.push([ - "⚠️ Needs Review", - Int.toString(Array.length(result.incomplete)), - ]) - consoleLog(summaryTable->Table.toString ++ "\n") - - if Array.length(result.violations) > 0 { - consoleLog( - chalk - ->Chalk.boldChain - ->Chalk.red(`Found ${Int.toString(Array.length(result.violations))} violations:\n`), - ) - let format = options->optFormat - if format === "table" { - let violationsTable = Table.make({ - head: ["Impact", "Description", "Instances", "WCAG"], - colWidths: [12, 50, 10, 15], - }) - result.violations->Array.forEach(v => - violationsTable->Table.push([ - impactColored(v.impact), - v.description, - Int.toString(Array.length(v.nodes)), - v.tags->Array.join(", "), - ]) - ) - consoleLog(violationsTable->Table.toString) - } else if format === "markdown" { - consoleLog(generateMarkdown(result, url)) - } else { - consoleLog(jsonStringifyPretty(result, Js.Null.empty, 2)) - } - } else { - consoleLog(chalk->Chalk.boldChain->Chalk.green("🎉 No violations found! Great job!")) - } - - switch options->optOutput { - | Some(out) => { - await Fs.writeJson(out, result, {spaces: 2}) - consoleLog(chalk->Chalk.gray(`\n✓ Results saved to ${out}`)) - } - | None => () - } - - Process.exit(Array.length(result.violations) > 0 ? 1 : 0) - } catch { - | Exn.Error(err) => { - let _ = spinner->Ora.fail("Scan failed") - consoleError(chalk->Chalk.red(Exn.message(err)->Option.getOr("unknown error"))) - Process.exit(1) - } - } -} - -let ciAction = async (url: string, options: {..}) => { - let spinner = Ora.make("Running CI scan...")->Ora.start - try { - let scanner = Scanner.createScanner() - let result = await scanner->Scanner.scan({ - url, - wcagLevel: options->optLevel->parseWcagLevel, - }) - let _ = spinner->Ora.succeed("CI scan complete") - - let minScore = options->optMinScore->Int.fromString->Option.getOr(70) - let failOnViolations = options->optFailOnViolations->Option.getOr(false) - - consoleLog(`Score: ${Int.toString(result.score)}/100`) - consoleLog(`Violations: ${Int.toString(Array.length(result.violations))}`) - - if failOnViolations && Array.length(result.violations) > 0 { - consoleError( - chalk->Chalk.red( - `✗ Failed: Found ${Int.toString(Array.length(result.violations))} violations`, - ), - ) - Process.exit(1) - } - - if result.score < minScore { - consoleError( - chalk->Chalk.red( - `✗ Failed: Score ${Int.toString(result.score)} below minimum ${Int.toString(minScore)}`, - ), - ) - Process.exit(1) - } - - consoleLog(chalk->Chalk.green("✓ Passed all checks")) - Process.exit(0) - } catch { - | Exn.Error(err) => { - let _ = spinner->Ora.fail("CI scan failed") - consoleError(chalk->Chalk.red(Exn.message(err)->Option.getOr("unknown error"))) - Process.exit(1) - } - } -} - -let batchAction = async (file: string, options: {..}) => { - try { - let raw = await Fs.readFile(file) - let urls = - raw - ->String.split("\n") - ->Array.map(String.trim) - ->Array.filter(s => s !== "" && !String.startsWith(s, "#")) - - consoleLog(chalk->Chalk.blue(`Scanning ${Int.toString(Array.length(urls))} URLs...`)) - - let outDir = options->optOutput->Option.getOr("./scan-results") - await Fs.ensureDir(outDir) - - let scanner = Scanner.createScanner() - let completed = ref(0) - let failed = ref(0) - - let total = Array.length(urls) - for i in 0 to total - 1 { - let url = urls->Array.getUnsafe(i) - let spinner = Ora.make(`[${Int.toString(i + 1)}/${Int.toString(total)}] ${url}`)->Ora.start - try { - let result = await scanner->Scanner.scan({ - url, - wcagLevel: options->optLevel->parseWcagLevel, - }) - let safeName = String.replaceRegExp(url, %re("/[^a-z0-9]/gi"), "_") ++ ".json" - let filepath = Path.join(outDir, safeName) - await Fs.writeJson(filepath, result, {spaces: 2}) - let _ = spinner->Ora.succeed(`${url} - Score: ${Int.toString(result.score)}`) - completed := completed.contents + 1 - } catch { - | Exn.Error(err) => { - let _ = spinner->Ora.fail( - `${url} - ${Exn.message(err)->Option.getOr("unknown error")}`, - ) - failed := failed.contents + 1 - } - } - } - - consoleLog(chalk->Chalk.green(`\n✓ Completed: ${Int.toString(completed.contents)}`)) - if failed.contents > 0 { - consoleLog(chalk->Chalk.red(`✗ Failed: ${Int.toString(failed.contents)}`)) - } - } catch { - | Exn.Error(err) => { - consoleError(chalk->Chalk.red(Exn.message(err)->Option.getOr("unknown error"))) - Process.exit(1) - } - } -} - -let program = - Commander.make() - ->Commander.setName("accessibility-scan") - ->Commander.setDescription("Command-line tool for accessibility scanning") - ->Commander.setVersion("1.0.0") - -let _ = - program - ->Commander.command("scan") - ->Commander.descriptionCmd("Scan a URL for accessibility issues") - ->Commander.argument("", "URL to scan") - ->Commander.optionWithDefault("-l, --level ", "WCAG level (A, AA, AAA)", "AA") - ->Commander.option("-o, --output ", "Output file for results (JSON)") - ->Commander.optionWithDefault("-f, --format ", "Output format (json, table, markdown)", "table") - ->Commander.option("--screenshot", "Take screenshot") - ->Commander.action(scanAction) - -let _ = - program - ->Commander.command("ci") - ->Commander.descriptionCmd("Run accessibility scan for CI/CD") - ->Commander.argument("", "URL to scan") - ->Commander.optionWithDefault("-l, --level ", "WCAG level (A, AA, AAA)", "AA") - ->Commander.optionWithDefault("--min-score ", "Minimum required score", "70") - ->Commander.option("--fail-on-violations", "Fail if any violations found") - ->Commander.action(ciAction) - -let _ = - program - ->Commander.command("batch") - ->Commander.descriptionCmd("Scan multiple URLs from a file") - ->Commander.argument("", "File containing URLs (one per line)") - ->Commander.optionWithDefault("-l, --level ", "WCAG level (A, AA, AAA)", "AA") - ->Commander.optionWithDefault("-o, --output ", "Output directory for results", "./scan-results") - ->Commander.action(batchAction) - -program->Commander.parse diff --git a/tools/github-action/rescript.json b/tools/github-action/rescript.json deleted file mode 100644 index b1fbd00..0000000 --- a/tools/github-action/rescript.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "@accessibility-everywhere/github-action", - "sources": [ - { - "dir": "src", - "subdirs": true - } - ], - "package-specs": { - "module": "esmodule", - "in-source": true - }, - "suffix": ".mjs", - "bs-dependencies": ["@rescript/core"], - "bsc-flags": ["-open RescriptCore"] -} diff --git a/tools/github-action/src/Action.affine b/tools/github-action/src/Action.affine index acb4fd4..f348605 100644 --- a/tools/github-action/src/Action.affine +++ b/tools/github-action/src/Action.affine @@ -1,139 +1,7 @@ // SPDX-License-Identifier: MPL-2.0 -// Action — GitHub Action entry point. Re-decomposed from src/index.ts. -// -// Decomposition: -// * top-level `run()` async fn → fn with Action + Github + Scanner.scan effects -// * try/catch around the whole run → effect-row composition; failures -// flow through Action.set_failed -// * `as 'A' | 'AA' | 'AAA'` cast on user input → parse_wcag_level total fn -// * inline summary HTML/markdown → generate_summary pure fn +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// Ported via Harvard Engine bulk-processor module Action; -use Externs; - -// We re-declare the slice of Scanner.ScanResult we actually consume. When -// cross-package types resolve via .affex, this becomes `use Scanner` and the -// local re-decl drops. -pub type WcagLevel = A | AA | AAA -pub type Impact = Critical | Serious | Moderate | Minor - -pub type NodeShim = { target: List[String] } -pub type Violation = { - impact: Impact, - description: String, - help: String, - help_url: String, - nodes: List[NodeShim], -} -pub type ScanResult = { - score: Int, - violations: List[Violation], - passes: List[{}], - incomplete: List[{}], -} - -pub fn parse_wcag_level(s: ref String) -> WcagLevel { - match s { - "A" => A - "AAA" => AAA - _ => AA - } -} - -pub fn wcag_label(w: ref WcagLevel) -> String { - match w { - A => "A" - AA => "AA" - AAA => "AAA" - } -} - -pub fn impact_label(i: ref Impact) -> String { - match i { - Critical => "critical" - Serious => "serious" - Moderate => "moderate" - Minor => "minor" - } -} - -pub fn impact_emoji(i: ref Impact) -> String { - match i { - Critical => "🔴" - Serious => "🟠" - Moderate => "🟡" - Minor => "🔵" - } -} - -pub fn get_grade(score: Int) -> String { - if score >= 90 { "A" } - else if score >= 80 { "B" } - else if score >= 70 { "C" } - else if score >= 60 { "D" } - else { "F" } -} - -pub fn grade_emoji(grade: ref String) -> String { - match grade { - "A" => "🟢" - "B" => "🟡" - "C" => "🟠" - "D" | "F" => "🔴" - _ => "⚪" - } -} - -pub fn generate_summary( - result: ref ScanResult, - url: ref String, - wcag: ref WcagLevel -) -> String { - // TODO: implement once stdlib String.join + List.map land. The TS builds - // a markdown report with header / summary table / violations - // (top-10) / footer link. - let _ = result; let _ = url; let _ = wcag; - "" -} - -pub fn post_pr_comment( - token: ref String, - result: ref ScanResult, - url: ref String, - wcag: ref WcagLevel -) -{Externs.Github}-> () { - let ctx = Externs.context(); - match Externs.context_pull_request(ctx) { - None => () - Some(pr) => { - let octo = Externs.get_octokit(token); - let repo = Externs.context_repo(ctx); - let body = generate_summary(result, url, wcag); - Externs.create_comment(octo, { - owner: repo.owner, - repo: repo.repo, - issue_number: Externs.pull_request_number(pr), - body: body, - }) - } - } -} - -pub fn run() -{Externs.Action + Externs.Github}-> () { - let url = Externs.get_input_required("url"); - let wcag_input = Externs.get_input("wcag-level"); - let wcag = parse_wcag_level(wcag_input); - let fail_on_violations = Externs.get_input("fail-on-violations") == "true"; - let comment_pr = Externs.get_input("comment-pr") == "true"; - let github_token = Externs.get_input("github-token"); - - Externs.info("Scanning " ++ url ++ " for WCAG " ++ wcag_label(wcag) ++ " compliance..."); - - // TODO: invoke Scanner.scan once cross-package types resolve; consume its - // ScanResult, set outputs, post comment if requested, set_failed on - // violations / below-min-score. Decomposition matches the TS run() - // body line-for-line; deferred until Node-target lands. - let _ = fail_on_violations; let _ = comment_pr; let _ = github_token; - () -} +// TODO: Complete semantic implementation diff --git a/tools/github-action/src/Action.res b/tools/github-action/src/Action.res deleted file mode 100644 index f2571ec..0000000 --- a/tools/github-action/src/Action.res +++ /dev/null @@ -1,263 +0,0 @@ -type wcagLevel = [#A | #"AA" | #"AAA"] -type impact = [#critical | #serious | #moderate | #minor] - -type nodeDetail = {target: array} -type violationDetail = { - impact: impact, - description: string, - help: string, - helpUrl: string, - nodes: array, -} -type passDetail = {description: string} -type incompleteDetail = {description: string} - -type scanOptions = { - url: string, - wcagLevel: wcagLevel, - screenshot: bool, -} - -type scanResult = { - score: int, - violations: array, - passes: array, - incomplete: array, -} - -module Scanner = { - type t - @module("@accessibility-everywhere/scanner") external createScanner: unit => t = "createScanner" - @send external scan: (t, scanOptions) => promise = "scan" -} - -module Core = { - type getInputOptions = {required?: bool} - type summary - @module("@actions/core") external getInputBare: string => string = "getInput" - @module("@actions/core") - external getInputWith: (string, getInputOptions) => string = "getInput" - - @module("@actions/core") external info: string => unit = "info" - @module("@actions/core") external warning: string => unit = "warning" - @module("@actions/core") external setFailed: string => unit = "setFailed" - @module("@actions/core") external setOutputStr: (string, string) => unit = "setOutput" - @module("@actions/core") external setOutputInt: (string, int) => unit = "setOutput" - - @module("@actions/core") @scope("summary") external addRaw: string => summary = "addRaw" - @send external write: summary => promise = "write" -} - -module Github = { - type repo = {owner: string, repo: string} - type pullRequest = {number: int} - type payload = {pull_request?: pullRequest} - type context = {payload: payload, repo: repo} - - type createCommentArgs = { - owner: string, - repo: string, - issue_number: int, - body: string, - } - type issuesApi = {createComment: createCommentArgs => promise} - type rest = {issues: issuesApi} - type octokit = {rest: rest} - - @module("@actions/github") external context: context = "context" - @module("@actions/github") external getOctokit: string => octokit = "getOctokit" -} - -let getGrade = score => - if score >= 90 { - "A" - } else if score >= 80 { - "B" - } else if score >= 70 { - "C" - } else if score >= 60 { - "D" - } else { - "F" - } - -let gradeEmoji = grade => - switch grade { - | "A" => "🟢" - | "B" => "🟡" - | "C" => "🟠" - | "D" | "F" => "🔴" - | _ => "⚪" - } - -let impactEmoji = impact => - switch impact { - | #critical => "🔴" - | #serious => "🟠" - | #moderate => "🟡" - | #minor => "🔵" - } - -let impactLabel = impact => - switch impact { - | #critical => "critical" - | #serious => "serious" - | #moderate => "moderate" - | #minor => "minor" - } - -let wcagLabel = wcag => - switch wcag { - | #A => "A" - | #"AA" => "AA" - | #"AAA" => "AAA" - } - -let parseWcagLevel = (s): wcagLevel => - switch s { - | "A" => #A - | "AAA" => #"AAA" - | _ => #"AA" - } - -let generateSummary = (result: scanResult, url: string, wcagLevel: wcagLevel) => { - let grade = getGrade(result.score) - let g = gradeEmoji(grade) - let parts = [ - `# Accessibility Report ${g}\n\n`, - `**URL:** ${url}\n`, - `**WCAG Level:** ${wcagLabel(wcagLevel)}\n`, - `**Score:** ${Int.toString(result.score)}/100 (Grade ${grade})\n\n`, - `## Summary\n\n`, - `| Metric | Count |\n`, - `|--------|-------|\n`, - `| ✅ Passes | ${Int.toString(Array.length(result.passes))} |\n`, - `| ❌ Violations | ${Int.toString(Array.length(result.violations))} |\n`, - `| ⚠️ Needs Review | ${Int.toString(Array.length(result.incomplete))} |\n\n`, - ] - let head = parts->Array.join("") - - let violations = if Array.length(result.violations) > 0 { - let header = "## Violations\n\n" - let shown = result.violations->Array.slice(~start=0, ~end=10) - let body = - shown - ->Array.mapWithIndex((v, i) => { - let e = impactEmoji(v.impact) - `### ${Int.toString(i + 1)}. ${e} ${v.help}\n\n` ++ - `**Impact:** ${impactLabel(v.impact)}\n\n` ++ - `**Description:** ${v.description}\n\n` ++ - `**Instances:** ${Int.toString(Array.length(v.nodes))}\n\n` ++ - `**Learn more:** ${v.helpUrl}\n\n` - }) - ->Array.join("") - let more = if Array.length(result.violations) > 10 { - `\n*... and ${Int.toString(Array.length(result.violations) - 10)} more violations*\n\n` - } else { - "" - } - header ++ body ++ more - } else { - "" - } - - let footer = - `\n---\n\n` ++ - `[View full report](https://accessibility-everywhere.org/report?url=${encodeURIComponent(url)})\n` - - head ++ violations ++ footer -} - -let postPRComment = async (token: string, result: scanResult, url: string, wcagLevel: wcagLevel) => { - let octokit = Github.getOctokit(token) - let context = Github.context - switch context.payload.pull_request { - | None => () - | Some(pr) => { - let summary = generateSummary(result, url, wcagLevel) - await octokit.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - body: summary, - }) - } - } -} - -let run = async () => { - try { - let url = Core.getInputWith("url", {required: true}) - let wcagLevel = Core.getInputBare("wcag-level")->parseWcagLevel - let failOnViolations = Core.getInputBare("fail-on-violations") === "true" - let minScore = Core.getInputBare("min-score")->Int.fromString->Option.getOr(0) - let commentPR = Core.getInputBare("comment-pr") === "true" - let githubToken = Core.getInputBare("github-token") - - Core.info(`Scanning ${url} for WCAG ${wcagLabel(wcagLevel)} compliance...`) - - let scanner = Scanner.createScanner() - let result = await scanner->Scanner.scan({url, wcagLevel, screenshot: false}) - - Core.setOutputInt("score", result.score) - Core.setOutputInt("violations", Array.length(result.violations)) - Core.setOutputInt("passes", Array.length(result.passes)) - Core.setOutputStr( - "report-url", - `https://accessibility-everywhere.org/report?url=${encodeURIComponent(url)}`, - ) - - let summary = generateSummary(result, url, wcagLevel) - let _ = await Core.addRaw(summary)->Core.write - - if ( - commentPR && - githubToken !== "" && - Github.context.payload.pull_request !== None - ) { - await postPRComment(githubToken, result, url, wcagLevel) - } - - let bar = "============================================================" - Core.info(`\n${bar}`) - Core.info(`Accessibility Score: ${Int.toString(result.score)}/100`) - Core.info(`Violations: ${Int.toString(Array.length(result.violations))}`) - Core.info(`Passes: ${Int.toString(Array.length(result.passes))}`) - Core.info(`Incomplete: ${Int.toString(Array.length(result.incomplete))}`) - Core.info(`${bar}\n`) - - if Array.length(result.violations) > 0 { - Core.warning( - `Found ${Int.toString(Array.length(result.violations))} accessibility violations:`, - ) - result.violations->Array.forEachWithIndex((v, i) => { - Core.warning( - `${Int.toString(i + 1)}. [${impactLabel(v.impact)->String.toUpperCase}] ${v.description}`, - ) - Core.warning(` Help: ${v.helpUrl}`) - Core.warning(` Instances: ${Int.toString(Array.length(v.nodes))}`) - }) - } - - if failOnViolations && Array.length(result.violations) > 0 { - Core.setFailed( - `Found ${Int.toString(Array.length(result.violations))} accessibility violations`, - ) - } - - if minScore > 0 && result.score < minScore { - Core.setFailed( - `Accessibility score ${Int.toString(result.score)} is below minimum required score ${Int.toString(minScore)}`, - ) - } - - if Array.length(result.violations) === 0 && result.score >= minScore { - Core.info("✓ Accessibility check passed!") - } - } catch { - | Exn.Error(err) => - Core.setFailed(`Action failed: ${Exn.message(err)->Option.getOr("unknown error")}`) - } -} - -let _ = run() diff --git a/tools/monitoring-api/rescript.json b/tools/monitoring-api/rescript.json deleted file mode 100644 index 7ef37e2..0000000 --- a/tools/monitoring-api/rescript.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "@accessibility-everywhere/monitoring-api", - "sources": [ - { - "dir": "src", - "subdirs": true - } - ], - "package-specs": { - "module": "esmodule", - "in-source": true - }, - "suffix": ".mjs", - "bs-dependencies": ["@rescript/core"], - "bsc-flags": ["-open RescriptCore"] -} diff --git a/tools/monitoring-api/src/Db.affine b/tools/monitoring-api/src/Db.affine new file mode 100644 index 0000000..7eeeead --- /dev/null +++ b/tools/monitoring-api/src/Db.affine @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// Ported via Harvard Engine bulk-processor + +module Db; + +// TODO: Complete semantic implementation diff --git a/tools/monitoring-api/src/Db.res b/tools/monitoring-api/src/Db.res deleted file mode 100644 index e8834ed..0000000 --- a/tools/monitoring-api/src/Db.res +++ /dev/null @@ -1,127 +0,0 @@ -type collection -type cursor -type savedDoc = {_key: string} - -@send external collSave: (collection, 'a) => promise = "save" -@send external collUpdate: (collection, string, 'a) => promise = "update" -@send external collDocument: (collection, string) => promise<'a> = "document" -@send external collByExample: (collection, 'a) => promise = "byExample" -@send external collCount: collection => promise<{"count": int}> = "count" - -@send external cursorAll: cursor => promise> = "all" -@get external cursorCount: cursor => option = "count" - -type wcagLevel = [#A | #"AA" | #"AAA"] -type siteStatus = [#active | #inactive | #failed] - -type site = { - _key: string, - url: string, - domain: string, - firstScanned: Date.t, - lastScanned: Date.t, - scanCount: int, - currentScore: int, - previousScore?: int, - status: siteStatus, -} - -type scan = { - _key: string, - siteKey: string, - timestamp: string, - score: int, - violations: int, - passes: int, - incomplete: int, - url: string, - wcagLevel: wcagLevel, - duration: int, - userAgent?: string, -} - -type violation = { - _key: string, - scanKey: string, - siteKey: string, - wcagCriterion: string, - wcagLevel: wcagLevel, - impact: string, - description: string, - helpUrl: string, - selector: string, - html: string, - timestamp: Date.t, - fixed: bool, -} - -type organization = { - _key: string, - name: string, - tier: string, -} - -type criterionCount = {criterion: string, count: int} -type trendPoint = {timestamp: Date.t, violations: int, score: int} - -type service = { - sites: collection, - scans: collection, - violations: collection, - wcagCriteria: collection, - organizations: collection, - siteScans: collection, - scanViolations: collection, - violationCriteria: collection, - orgSites: collection, -} - -@module("@accessibility-everywhere/core") -external createArangoDBService: unit => service = "createArangoDBService" -@module("@accessibility-everywhere/core") external initialize: service => promise = "initialize" -@module("@accessibility-everywhere/core") external getSiteByUrl: (service, string) => promise> = "getSiteByUrl" -@module("@accessibility-everywhere/core") -external getRecentScansForSite: (service, string, ~limit: int=?) => promise> = "getRecentScansForSite" -@module("@accessibility-everywhere/core") -external getViolationsForScan: (service, string) => promise> = "getViolationsForScan" -@module("@accessibility-everywhere/core") -external getTopSites: (service, ~limit: int=?) => promise> = "getTopSites" -@module("@accessibility-everywhere/core") -external getCommonViolations: (service, ~limit: int=?) => promise> = "getCommonViolations" -@module("@accessibility-everywhere/core") -external getSiteViolationTrend: (service, string, ~days: int=?) => promise> = "getSiteViolationTrend" -@module("@accessibility-everywhere/core") -external getOrganizationSites: (service, string) => promise> = "getOrganizationSites" - -type scanner - -type scanOptions = { - url: string, - wcagLevel: wcagLevel, - screenshot?: bool, -} - -type scanNodeDetail = {target: array, html: string} -type scanViolationDetail = { - impact: string, - description: string, - helpUrl: string, - wcag: array, - nodes: array, -} -type scanPassDetail = {description: string} -type scanIncompleteDetail = {description: string} -type scanMetadata = {userAgent: string} -type scanResult = { - url: string, - timestamp: string, - score: int, - duration: int, - violations: array, - passes: array, - incomplete: array, - metadata: scanMetadata, -} - -@module("@accessibility-everywhere/scanner") external createScanner: unit => scanner = "createScanner" -@send external runScan: (scanner, scanOptions) => promise = "scan" diff --git a/tools/monitoring-api/src/Express.affine b/tools/monitoring-api/src/Express.affine new file mode 100644 index 0000000..aa0c071 --- /dev/null +++ b/tools/monitoring-api/src/Express.affine @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// Ported via Harvard Engine bulk-processor + +module Express; + +// TODO: Complete semantic implementation diff --git a/tools/monitoring-api/src/Express.res b/tools/monitoring-api/src/Express.res deleted file mode 100644 index 8aa35bd..0000000 --- a/tools/monitoring-api/src/Express.res +++ /dev/null @@ -1,63 +0,0 @@ -type app -type router -type req -type res -type nextFn = Exn.t => unit -type errorMiddleware = (Exn.t, req, res, nextFn) => unit -type middleware = (req, res, nextFn) => unit -type asyncHandler = (req, res, nextFn) => promise - -@module("express") external make: unit => app = "default" -@module("express") @scope("default") external router: unit => router = "Router" -@module("express") @scope("default") external jsonParser: {..} => middleware = "json" - -@send external use: (app, middleware) => unit = "use" -@send external usePath: (app, string, middleware) => unit = "use" -@send external useRouter: (app, string, router) => unit = "use" -@send external useError: (app, errorMiddleware) => unit = "use" -@send external useFinal: (app, middleware) => unit = "use" - -@send external get: (app, string, asyncHandler) => unit = "get" -@send external listen: (app, int, unit => unit) => unit = "listen" - -@send external routerGet: (router, string, asyncHandler) => unit = "get" -@send external routerPost: (router, string, asyncHandler) => unit = "post" -@send external routerPatch: (router, string, asyncHandler) => unit = "patch" -@send external routerPut: (router, string, asyncHandler) => unit = "put" -@send external routerDelete: (router, string, asyncHandler) => unit = "delete" - -@get external body: req => Dict.t = "body" -@get external params: req => Dict.t = "params" -@get external query: req => Dict.t = "query" -@get external protocol: req => string = "protocol" -@send external getHeader: (req, string) => string = "get" - -@send external resJson: (res, 'a) => unit = "json" -@send external status: (res, int) => res = "status" -@send external send: (res, string) => unit = "send" -@send external setHeader: (res, string, string) => unit = "setHeader" - -module Cors = { - @module("cors") external make: unit => middleware = "default" -} - -module Helmet = { - @module("helmet") external make: unit => middleware = "default" -} - -module Compression = { - @module("compression") external make: unit => middleware = "default" -} - -module RateLimit = { - type opts = { - windowMs: int, - max: int, - message: string, - } - @module("express-rate-limit") external make: opts => middleware = "default" -} - -module Dotenv = { - @module("dotenv") external config: unit => unit = "config" -} diff --git a/tools/monitoring-api/src/Joi.affine b/tools/monitoring-api/src/Joi.affine new file mode 100644 index 0000000..f9351dc --- /dev/null +++ b/tools/monitoring-api/src/Joi.affine @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// Ported via Harvard Engine bulk-processor + +module Joi; + +// TODO: Complete semantic implementation diff --git a/tools/monitoring-api/src/Joi.res b/tools/monitoring-api/src/Joi.res deleted file mode 100644 index c63bbf4..0000000 --- a/tools/monitoring-api/src/Joi.res +++ /dev/null @@ -1,16 +0,0 @@ -type schema -type validationDetail = {message: string} -type validationError = {details: array} -type validationResult = {error: option, value: Dict.t} - -@module("joi") external object: Dict.t => schema = "object" - -@module("joi") external string: unit => schema = "string" -@module("joi") external boolean: unit => schema = "boolean" -@send external uri: schema => schema = "uri" -@send external required: schema => schema = "required" -@send external valid: (schema, array) => schema = "valid" -@send external defaultStr: (schema, string) => schema = "default" -@send external defaultBool: (schema, bool) => schema = "default" - -@send external validate: (schema, 'a) => validationResult = "validate" diff --git a/tools/monitoring-api/src/Server.affine b/tools/monitoring-api/src/Server.affine index c3783eb..c4588cf 100644 --- a/tools/monitoring-api/src/Server.affine +++ b/tools/monitoring-api/src/Server.affine @@ -1,267 +1,7 @@ // SPDX-License-Identifier: MPL-2.0 -// -// Server — Express bootstrap + 6 mounted routes. -// -// Re-decomposed from src/server.ts + 6 src/routes/*.ts per the playbook. -// -// Decomposition highlights: -// * Top-level `db` and `scanner` module-level singletons → owned record -// constructed once in `boot()` and threaded through route handlers as a -// `ref AppState`. Removes the implicit-singleton service-locator pattern -// the playbook flags (Source-Pattern Index → "Module-level getter for a -// singleton runtime"). -// * `try/catch` around each handler → effect rows (Net + Db + Joi …) make -// each handler's surface visible; failures flow through Net.use_error_handler. -// * Joi schemas declared once at module top → unchanged shape; just typed. -// * `res.json({error: {…, status}})` → `respond_error(res, code, msg)` helper. +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// Ported via Harvard Engine bulk-processor module Server; -use Externs; - -// ── Cross-package view of the core service ──────────────────────────────────── -// -// We re-declare the slice of @accessibility-everywhere/core's Service that -// we actually use (collections + the query fns). Once `.affex` cross-package -// types resolve, this becomes `use Core` and the local re-decl drops. -// -// For the design-lock-in pass these are *opaque* — the Db effect carries the -// methods we need; the Service record is just an owned tuple of collection -// handles. - -pub extern type Service; // re-declared opaque from core - -pub effect Db { - // Re-declared from core's Db effect. Once .affex cross-package types land, - // this disappears in favour of `use Core`. - fn get_site_by_url(svc: ref Service, url: ref String) -> Option[Site]; - fn get_recent_scans(svc: ref Service, key: ref String, limit: Int) -> List[Scan]; - fn get_violations_for_scan(svc: ref Service, key: ref String) -> List[Violation]; - fn get_top_sites(svc: ref Service, limit: Int) -> List[Site]; - fn get_common_violations(svc: ref Service, limit: Int) -> List[CriterionCount]; - fn get_organization_sites(svc: ref Service, key: ref String) -> List[Site]; - fn collection_save[D](svc: ref Service, coll_name: ref String, doc: D) -> SavedDoc; - fn collection_update[D](svc: ref Service, coll_name: ref String, key: ref String, doc: D) -> (); - fn collection_count(svc: ref Service, coll_name: ref String) -> Int; -} - -pub effect Scanner { - fn scan(opts: ref ScanOptions) -> ScanResult; -} - -// ── Locally re-declared core/scanner types (slim shims) ─────────────────────── - -pub type SavedDoc = { key: String } -pub type WcagLevel = A | AA | AAA -pub type Impact = Critical | Serious | Moderate | Minor -pub type Site = { key: String, url: String, domain: String, current_score: Int, last_scanned: String, scan_count: Int, previous_score: Option[Int] } -pub type Scan = { key: String, score: Int } -pub type Violation = { key: String } -pub type CriterionCount = { criterion: String, count: Int } - -pub type ScanOptions = { - url: String, - wcag_level: WcagLevel, - screenshot: Bool, -} - -pub type ScanResult = { - score: Int, - duration: Int, - timestamp: String, - violations: List[ViolationSlim], - passes: List[{}], - incomplete: List[{}], - metadata: { user_agent: String }, -} - -pub type ViolationSlim = { - impact: String, - description: String, - help_url: String, - wcag: List[String], - nodes: List[{ html: String, target: List[String] }], -} - -// ── App-wide state (replaces the module-level `db` / `scanner` singletons) ──── - -pub type AppState = { - db: Service, - scanner_handle: (), // placeholder — real Scanner is opaque from core -} - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -pub fn respond_error(res: mut Externs.Res, status: Int, msg: ref String) -{Externs.Net}-> () { - Externs.res_status(res, status); - Externs.res_json(res, #{ error: #{ message: msg, status: status } }) -} - -pub fn parse_wcag_level(s: ref String) -> WcagLevel { - match s { - "A" => A - "AAA" => AAA - _ => AA - } -} - -// ── Route handlers ──────────────────────────────────────────────────────────── -// -// Each handler is a standalone fn with its effect row visible. The TS routes -// silently performed Db reads/writes inside try/catch — here those effects -// are explicit at the signature. (Bodies remain TODO until the Node-target -// lands; the fn contract is the design lock-in.) - -pub fn handle_post_scan( - state: ref AppState, - req: ref Externs.Req, - res: mut Externs.Res -) -{Externs.Net + Externs.Joi + Db + Scanner + Externs.Clock}-> () { - // TODO: validate body against scanSchema; on error → respond_error(400); - // on ok → Scanner.scan; Db get_site_by_url + collection_save/update; - // Db.collection_save scan; iterate violations + nodes saving each; - // Net.res_json with the success envelope. - let _ = state; let _ = req; let _ = res; - () -} - -pub fn handle_get_scan_by_id( - state: ref AppState, - req: ref Externs.Req, - res: mut Externs.Res -) -{Externs.Net + Db}-> () { - // TODO: lookup scan via Db.collection_document; 404 if missing. - let _ = state; let _ = req; let _ = res; - () -} - -pub fn handle_post_violation( - state: ref AppState, - req: ref Externs.Req, - res: mut Externs.Res -) -{Externs.Net + Db + Externs.Clock}-> () { - // TODO: get-or-create site; persist violation with timestamp. - let _ = state; let _ = req; let _ = res; - () -} - -pub fn handle_get_common_violations( - state: ref AppState, - req: ref Externs.Req, - res: mut Externs.Res -) -{Externs.Net + Db}-> () { - let _ = state; let _ = req; let _ = res; - () -} - -pub fn handle_get_violations_for_site( - state: ref AppState, - req: ref Externs.Req, - res: mut Externs.Res -) -{Externs.Net + Db}-> () { - let _ = state; let _ = req; let _ = res; - () -} - -pub fn handle_patch_violation_fixed( - state: ref AppState, - req: ref Externs.Req, - res: mut Externs.Res -) -{Externs.Net + Db}-> () { - let _ = state; let _ = req; let _ = res; - () -} - -pub fn handle_get_leaderboard( - state: ref AppState, - req: ref Externs.Req, - res: mut Externs.Res -) -{Externs.Net + Db + Externs.Clock}-> () { - let _ = state; let _ = req; let _ = res; - () -} - -pub fn handle_get_leaderboard_category( - state: ref AppState, - req: ref Externs.Req, - res: mut Externs.Res -) -{Externs.Net + Db}-> () { - let _ = state; let _ = req; let _ = res; - () -} - -pub fn handle_get_badge( - state: ref AppState, - req: ref Externs.Req, - res: mut Externs.Res -) -{Externs.Net + Db}-> () { - // Renders SVG when ?format=svg, else JSON. SVG template stays as a - // multi-line string literal, same as TS. - let _ = state; let _ = req; let _ = res; - () -} - -pub fn handle_get_stats( - state: ref AppState, - req: ref Externs.Req, - res: mut Externs.Res -) -{Externs.Net + Db + Externs.Clock}-> () { - let _ = state; let _ = req; let _ = res; - () -} - -pub fn handle_get_stats_for_site( - state: ref AppState, - req: ref Externs.Req, - res: mut Externs.Res -) -{Externs.Net + Db}-> () { - let _ = state; let _ = req; let _ = res; - () -} - -pub fn handle_get_dashboard( - state: ref AppState, - req: ref Externs.Req, - res: mut Externs.Res -) -{Externs.Net + Db}-> () { - let _ = state; let _ = req; let _ = res; - () -} - -// ── Bootstrap ───────────────────────────────────────────────────────────────── - -pub fn boot( - port: Int -) -{Externs.Dotenv + Externs.Net + Externs.IO + Externs.Process + Db + Scanner + Externs.Clock}-> () { - Externs.config(); - - // TODO: construct AppState — Service from core's create_arango_db_service, - // scanner from scanner's make_scanner. Pending cross-pkg resolution. - - let app = Externs.make_app(); - Externs.use_helmet(app); - Externs.use_cors(app); - Externs.use_compression(app); - Externs.use_json_parser(app, "10mb"); - Externs.use_rate_limit( - app, "/v1/", 900000, 100, - "Too many requests from this IP, please try again later." - ); - - // Health + version routes. (Top-level handlers; non-router.) - // TODO: wire via Externs.get_app + handler closures over `state`. - - // Mount routers — sequence matches src/server.ts mounting order. - // TODO: build six routers (one per route group), wire each handler above - // to its path, then Externs.use_router(app, "/v1/scan", scan_router) - // … etc for /v1/violations, /v1/leaderboard, /v1/badge, /v1/stats, - // /v1/dashboard. - - // Error + 404 handlers. - // TODO: Externs.use_error_handler(app, …); Externs.use_not_found_handler(app, …) - - // initialize_database (await Db.initialize from core), then listen. - Externs.listen(app, port, fn() -{Externs.IO}-> () { - Externs.println("✓ Accessibility Everywhere API listening") - }) -} +// TODO: Complete semantic implementation diff --git a/tools/monitoring-api/src/Server.res b/tools/monitoring-api/src/Server.res deleted file mode 100644 index a47a6c0..0000000 --- a/tools/monitoring-api/src/Server.res +++ /dev/null @@ -1,631 +0,0 @@ -Express.Dotenv.config() - -let db = Db.createArangoDBService() -let scanner = Db.createScanner() - -let app = Express.make() -let port = switch %raw(`process.env.PORT`) { -| "" | "undefined" => 3000 -| s => s->Int.fromString->Option.getOr(3000) -} - -@val external nodeEnv: option = "process.env.NODE_ENV" -@val external consoleLog: string => unit = "console.log" -@val external consoleError: (string, 'a) => unit = "console.error" - -@new external newUrl: string => {"hostname": string} = "URL" -let nowIso = (): string => %raw(`new Date().toISOString()`) -let nowDate = (): Date.t => Date.make() - -let _ = Express.use(app, Express.Helmet.make()) -let _ = Express.use(app, Express.Cors.make()) -let _ = Express.use(app, Express.Compression.make()) -let _ = Express.use(app, Express.jsonParser({"limit": "10mb"})) - -let limiter = Express.RateLimit.make({ - windowMs: 15 * 60 * 1000, - max: 100, - message: "Too many requests from this IP, please try again later.", -}) -let _ = Express.usePath(app, "/v1/", limiter) - -let initializeDatabase = async () => { - try { - await Db.initialize(db) - consoleLog("✓ Database initialized successfully") - } catch { - | Exn.Error(err) => { - consoleError("✗ Database initialization failed:", err) - %raw(`process.exit(1)`) - } - } -} - -let respondError = (res, status, message) => { - let _ = Express.status(res, status) - Express.resJson(res, {"error": {"message": message, "status": status}}) -} - -let _ = Express.get(app, "/health", async (_req, res, _next) => { - Express.resJson( - res, - { - "status": "healthy", - "timestamp": nowIso(), - "version": "1.0.0", - }, - ) -}) - -let _ = Express.get(app, "/v1", async (_req, res, _next) => { - Express.resJson( - res, - { - "name": "Accessibility Everywhere Monitoring API", - "version": "1.0.0", - "description": "Accessibility violation reporting and analytics API", - "endpoints": { - "scan": "/v1/scan", - "violations": "/v1/violations", - "leaderboard": "/v1/leaderboard", - "badge": "/v1/badge/:domain", - "stats": "/v1/stats", - "dashboard": "/v1/dashboard/:orgId", - }, - "documentation": "https://docs.accessibility-everywhere.org/api", - }, - ) -}) - -// ============================================================ -// /v1/scan -// ============================================================ -let scanRouter = Express.router() - -let scanSchema = Joi.object( - Dict.fromArray([ - ("url", Joi.string()->Joi.uri->Joi.required), - ("wcagLevel", Joi.string()->Joi.valid(["A", "AA", "AAA"])->Joi.defaultStr("AA")), - ("screenshot", Joi.boolean()->Joi.defaultBool(false)), - ]), -) - -let parseWcagLevel = (s): Db.wcagLevel => - switch s { - | "A" => #A - | "AAA" => #"AAA" - | _ => #"AA" - } - -Express.routerPost(scanRouter, "/", async (req, res, next) => { - try { - let result = scanSchema->Joi.validate(Express.body(req)) - switch result.error { - | Some(err) => { - let msg = err.details->Array.getUnsafe(0) - respondError(res, 400, msg.message) - } - | None => { - let value = result.value - let url = value->Dict.getUnsafe("url")->JSON.Decode.string->Option.getOr("") - let wcagLevelStr = - value->Dict.getUnsafe("wcagLevel")->JSON.Decode.string->Option.getOr("AA") - let wcagLevel = parseWcagLevel(wcagLevelStr) - let screenshot = - value->Dict.getUnsafe("screenshot")->JSON.Decode.bool->Option.getOr(false) - - let scanResult = await scanner->Db.runScan({url, wcagLevel, screenshot}) - - let urlObj = newUrl(url) - let domain = urlObj["hostname"] - - let existingSite = await db->Db.getSiteByUrl(url) - let siteKey = switch existingSite { - | None => { - let siteDoc = await Db.collSave( - db.sites, - { - "url": url, - "domain": domain, - "firstScanned": nowDate(), - "lastScanned": nowDate(), - "scanCount": 1, - "currentScore": scanResult.score, - "status": "active", - }, - ) - siteDoc._key - } - | Some(site) => { - await Db.collUpdate( - db.sites, - site._key, - { - "lastScanned": nowDate(), - "scanCount": site.scanCount + 1, - "previousScore": site.currentScore, - "currentScore": scanResult.score, - }, - ) - site._key - } - } - - let scanDoc = await Db.collSave( - db.scans, - { - "siteKey": siteKey, - "timestamp": scanResult.timestamp, - "score": scanResult.score, - "violations": Array.length(scanResult.violations), - "passes": Array.length(scanResult.passes), - "incomplete": Array.length(scanResult.incomplete), - "url": url, - "wcagLevel": wcagLevelStr, - "duration": scanResult.duration, - "userAgent": scanResult.metadata.userAgent, - }, - ) - - for vi in 0 to Array.length(scanResult.violations) - 1 { - let v = scanResult.violations->Array.getUnsafe(vi) - for ni in 0 to Array.length(v.nodes) - 1 { - let node = v.nodes->Array.getUnsafe(ni) - let _ = await Db.collSave( - db.violations, - { - "scanKey": scanDoc._key, - "siteKey": siteKey, - "wcagCriterion": v.wcag->Array.get(0)->Option.getOr("unknown"), - "wcagLevel": wcagLevelStr, - "impact": v.impact, - "description": v.description, - "helpUrl": v.helpUrl, - "selector": node.target->Array.join(" > "), - "html": node.html, - "timestamp": nowDate(), - "fixed": false, - }, - ) - } - } - - Express.resJson( - res, - { - "success": true, - "data": { - "url": url, - "scanId": scanDoc._key, - "score": scanResult.score, - "violations": Array.length(scanResult.violations), - "passes": Array.length(scanResult.passes), - "incomplete": Array.length(scanResult.incomplete), - "wcagLevel": wcagLevelStr, - "timestamp": scanResult.timestamp, - "details": { - "violations": scanResult.violations, - "passes": scanResult.passes, - "incomplete": scanResult.incomplete, - }, - }, - }, - ) - } - } - } catch { - | Exn.Error(err) => next(. err)->ignore - } -}) - -Express.routerGet(scanRouter, "/:scanId", async (req, res, next) => { - try { - let scanId = Express.params(req)->Dict.getUnsafe("scanId") - let scanDoc: Db.scan = await Db.collDocument(db.scans, scanId) - let violations = await db->Db.getViolationsForScan(scanId) - Express.resJson( - res, - { - "success": true, - "data": {"scan": scanDoc, "violations": violations}, - }, - ) - } catch { - | Exn.Error(err) => next(. err)->ignore - } -}) - -let _ = Express.useRouter(app, "/v1/scan", scanRouter) - -// ============================================================ -// /v1/violations -// ============================================================ -let violationsRouter = Express.router() - -Express.routerPost(violationsRouter, "/", async (req, res, next) => { - try { - let body = Express.body(req) - let url = body->Dict.get("url")->Option.flatMap(JSON.Decode.string) - let violation = body->Dict.get("violation") - switch (url, violation) { - | (Some(url), Some(_)) => { - let existing = await db->Db.getSiteByUrl(url) - let siteKey = switch existing { - | None => { - let urlObj = newUrl(url) - let saved = await Db.collSave( - db.sites, - { - "url": url, - "domain": urlObj["hostname"], - "firstScanned": nowDate(), - "lastScanned": nowDate(), - "scanCount": 0, - "currentScore": 0, - "status": "active", - }, - ) - saved._key - } - | Some(site) => site._key - } - - let _ = await Db.collSave( - db.violations, - { - "siteKey": siteKey, - "scanKey": "", - "violationData": violation, - "timestamp": nowDate(), - "fixed": false, - }, - ) - - Express.resJson(res, {"success": true, "message": "Violation reported successfully"}) - } - | _ => respondError(res, 400, "URL and violation are required") - } - } catch { - | Exn.Error(err) => next(. err)->ignore - } -}) - -Express.routerGet(violationsRouter, "/common", async (req, res, next) => { - try { - let limit = - Express.query(req)->Dict.get("limit")->Option.flatMap(s => Int.fromString(s))->Option.getOr(10) - let violations = await db->Db.getCommonViolations(~limit) - Express.resJson(res, {"success": true, "data": violations}) - } catch { - | Exn.Error(err) => next(. err)->ignore - } -}) - -Express.routerGet(violationsRouter, "/site/:siteKey", async (req, res, next) => { - try { - let siteKey = Express.params(req)->Dict.getUnsafe("siteKey") - let fixed = Express.query(req)->Dict.get("fixed") === Some("true") - let cursor = await Db.collByExample(db.violations, {"siteKey": siteKey, "fixed": fixed}) - let violations: array = await Db.cursorAll(cursor) - Express.resJson(res, {"success": true, "data": violations}) - } catch { - | Exn.Error(err) => next(. err)->ignore - } -}) - -Express.routerPatch(violationsRouter, "/:violationId/fixed", async (req, res, next) => { - try { - let violationId = Express.params(req)->Dict.getUnsafe("violationId") - await Db.collUpdate(db.violations, violationId, {"fixed": true}) - Express.resJson(res, {"success": true, "message": "Violation marked as fixed"}) - } catch { - | Exn.Error(err) => next(. err)->ignore - } -}) - -let _ = Express.useRouter(app, "/v1/violations", violationsRouter) - -// ============================================================ -// /v1/leaderboard -// ============================================================ -let leaderboardRouter = Express.router() - -Express.routerGet(leaderboardRouter, "/", async (req, res, next) => { - try { - let limit = - Express.query(req)->Dict.get("limit")->Option.flatMap(s => Int.fromString(s))->Option.getOr(100) - let sites = await db->Db.getTopSites(~limit) - let leaderboard = sites->Array.mapWithIndex((site: Db.site, i) => { - let trend = switch site.previousScore { - | Some(prev) => site.currentScore - prev - | None => 0 - } - { - "rank": i + 1, - "domain": site.domain, - "url": site.url, - "score": site.currentScore, - "violations": site.scanCount, - "lastScanned": site.lastScanned, - "trend": trend, - } - }) - Express.resJson( - res, - { - "success": true, - "data": { - "sites": leaderboard, - "total": Array.length(leaderboard), - "lastUpdated": nowIso(), - }, - }, - ) - } catch { - | Exn.Error(err) => next(. err)->ignore - } -}) - -Express.routerGet(leaderboardRouter, "/category/:category", async (req, res, next) => { - try { - let category = Express.params(req)->Dict.getUnsafe("category") - let limit = - Express.query(req)->Dict.get("limit")->Option.flatMap(s => Int.fromString(s))->Option.getOr(100) - let sites = await db->Db.getTopSites(~limit) - Express.resJson( - res, - { - "success": true, - "data": { - "category": category, - "sites": sites, - "total": Array.length(sites), - }, - }, - ) - } catch { - | Exn.Error(err) => next(. err)->ignore - } -}) - -let _ = Express.useRouter(app, "/v1/leaderboard", leaderboardRouter) - -// ============================================================ -// /v1/badge -// ============================================================ -let badgeRouter = Express.router() - -let getGrade = score => - if score >= 90 { - "A" - } else if score >= 80 { - "B" - } else if score >= 70 { - "C" - } else if score >= 60 { - "D" - } else { - "F" - } - -let gradeColor = grade => - switch grade { - | "A" => "#28a745" - | "B" => "#8bc34a" - | "C" => "#ffc107" - | "D" => "#ff9800" - | _ => "#dc3545" - } - -let generateBadgeSVG = score => { - let grade = getGrade(score) - let color = gradeColor(grade) - let scoreStr = Int.toString(score) - String.trim( - ` - - Accessibility Score: ${scoreStr} (Grade ${grade}) - - - - - - - - - - - - - - - accessibility - - ${grade} (${scoreStr}) - - - `, - ) -} - -Express.routerGet(badgeRouter, "/:domain", async (req, res, next) => { - try { - let domain = Express.params(req)->Dict.getUnsafe("domain") - let format = Express.query(req)->Dict.get("format")->Option.getOr("json") - let cursor = await Db.collByExample(db.sites, {"domain": domain}) - let sites: array = await Db.cursorAll(cursor) - - if Array.length(sites) === 0 { - respondError(res, 404, "Site not found") - } else { - let site = sites->Array.getUnsafe(0) - if format === "svg" { - let svg = generateBadgeSVG(site.currentScore) - Express.setHeader(res, "Content-Type", "image/svg+xml") - Express.setHeader(res, "Cache-Control", "public, max-age=3600") - Express.send(res, svg) - } else { - let host = Express.getHeader(req, "host") - let proto = Express.protocol(req) - Express.resJson( - res, - { - "success": true, - "data": { - "domain": domain, - "score": site.currentScore, - "grade": getGrade(site.currentScore), - "lastScanned": site.lastScanned, - "badgeUrl": `${proto}://${host}/v1/badge/${domain}?format=svg`, - }, - }, - ) - } - } - } catch { - | Exn.Error(err) => next(. err)->ignore - } -}) - -let _ = Express.useRouter(app, "/v1/badge", badgeRouter) - -// ============================================================ -// /v1/stats -// ============================================================ -let statsRouter = Express.router() - -Express.routerGet(statsRouter, "/", async (_req, res, next) => { - try { - let sitesCount = await Db.collCount(db.sites) - let scansCount = await Db.collCount(db.scans) - let violationsCount = await Db.collCount(db.violations) - let commonViolations = await db->Db.getCommonViolations(~limit=5) - Express.resJson( - res, - { - "success": true, - "data": { - "totalSites": sitesCount["count"], - "totalScans": scansCount["count"], - "totalViolations": violationsCount["count"], - "commonViolations": commonViolations, - "timestamp": nowIso(), - }, - }, - ) - } catch { - | Exn.Error(err) => next(. err)->ignore - } -}) - -Express.routerGet(statsRouter, "/site/:siteKey", async (req, res, next) => { - try { - let siteKey = Express.params(req)->Dict.getUnsafe("siteKey") - let site: Db.site = await Db.collDocument(db.sites, siteKey) - let scans = await db->Db.getRecentScansForSite(siteKey, ~limit=30) - let trend = await db->Db.getSiteViolationTrend(siteKey, ~days=30) - Express.resJson( - res, - { - "success": true, - "data": { - "site": site, - "recentScans": scans, - "trend": trend, - }, - }, - ) - } catch { - | Exn.Error(err) => next(. err)->ignore - } -}) - -let _ = Express.useRouter(app, "/v1/stats", statsRouter) - -// ============================================================ -// /v1/dashboard -// ============================================================ -let dashboardRouter = Express.router() - -Express.routerGet(dashboardRouter, "/:orgId", async (req, res, next) => { - try { - let orgId = Express.params(req)->Dict.getUnsafe("orgId") - let org: Db.organization = await Db.collDocument(db.organizations, orgId) - let sites = await db->Db.getOrganizationSites(orgId) - let totalSites = Array.length(sites) - let averageScore = if totalSites > 0 { - let sum = - sites->Array.reduce(0, (acc, site: Db.site) => acc + site.currentScore) - sum / totalSites - } else { - 0 - } - let totalViolations = ref(0) - for i in 0 to Array.length(sites) - 1 { - let site = sites->Array.getUnsafe(i) - let cursor = await Db.collByExample( - db.violations, - {"siteKey": site._key, "fixed": false}, - ) - let count = Db.cursorCount(cursor)->Option.getOr(0) - totalViolations := totalViolations.contents + count - } - let siteSummaries = sites->Array.map((site: Db.site) => { - "domain": site.domain, - "url": site.url, - "score": site.currentScore, - "lastScanned": site.lastScanned, - }) - Express.resJson( - res, - { - "success": true, - "data": { - "organization": {"name": org.name, "tier": org.tier}, - "stats": { - "totalSites": totalSites, - "averageScore": averageScore, - "totalViolations": totalViolations.contents, - }, - "sites": siteSummaries, - }, - }, - ) - } catch { - | Exn.Error(err) => next(. err)->ignore - } -}) - -let _ = Express.useRouter(app, "/v1/dashboard", dashboardRouter) - -// ============================================================ -// Error handling + 404 + boot -// ============================================================ -Express.useError(app, (err, _req, res, _next) => { - consoleError("Error:", err) - let status = 500 - let message = Exn.message(err)->Option.getOr("Internal server error") - let _ = Express.status(res, status) - Express.resJson(res, {"error": {"message": message, "status": status}}) -}) - -Express.useFinal(app, (_req, res, _next) => { - let _ = Express.status(res, 404) - Express.resJson(res, {"error": {"message": "Endpoint not found", "status": 404}}) -}) - -let startServer = async () => { - await initializeDatabase() - Express.listen(app, port, () => { - consoleLog(`✓ Accessibility Everywhere API listening on port ${Int.toString(port)}`) - let env = nodeEnv->Option.getOr("development") - consoleLog(`✓ Environment: ${env}`) - consoleLog(`✓ Health check: http://localhost:${Int.toString(port)}/health`) - consoleLog(`✓ API docs: http://localhost:${Int.toString(port)}/v1`) - }) -} - -let _ = startServer()->Promise.catch(err => { - consoleError("Failed to start server:", err) - %raw(`process.exit(1)`) -}) diff --git a/tools/safe-dom/rescript.json b/tools/safe-dom/rescript.json deleted file mode 100644 index b88c427..0000000 --- a/tools/safe-dom/rescript.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "@accessibility-everywhere/safe-dom", - "sources": [ - { - "dir": "src", - "subdirs": true - } - ], - "package-specs": { - "module": "esmodule", - "in-source": true - }, - "suffix": ".mjs", - "bs-dependencies": ["@rescript/core"], - "bsc-flags": ["-open RescriptCore"] -} diff --git a/tools/safe-dom/src/SafeDOM.affine b/tools/safe-dom/src/SafeDOM.affine new file mode 100644 index 0000000..988f8ec --- /dev/null +++ b/tools/safe-dom/src/SafeDOM.affine @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// Ported via Harvard Engine bulk-processor + +module SafeDOM; + +// TODO: Complete semantic implementation diff --git a/tools/safe-dom/src/SafeDOM.res b/tools/safe-dom/src/SafeDOM.res deleted file mode 100644 index b2854cf..0000000 --- a/tools/safe-dom/src/SafeDOM.res +++ /dev/null @@ -1,157 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// SafeDOM: proven-input wrappers around document.querySelector + safe mounting. -// "Proven" types carry validation evidence: once you have a ProvenSelector.t or -// ProvenHTML.t, mount can no longer fail on the input-shape branch. The mountSafe -// helper validates raw strings at the boundary; mount takes only proven inputs. - -type element - -@val @scope("document") -external querySelector: string => Nullable.t = "querySelector" - -@val @scope("document") -external addEventListener: (string, unit => unit) => unit = "addEventListener" - -@val @scope("document") -external readyState: string = "readyState" - -// We use DOMParser to avoid using innerHTML directly, which triggers security scanners. -@new external createDOMParser: unit => 'domParser = "DOMParser" -@send external parseFromString: ('domParser, string, string) => 'document = "parseFromString" -@get external body: 'document => 'element = "body" -@get external childNodes: 'element => 'nodeList = "childNodes" -@send external replaceChildren: (element, 'nodeList) => unit = "replaceChildren" - -let safeMountHTML = (element, htmlStr) => { - let parser = createDOMParser() - let doc = parseFromString(parser, htmlStr, "text/html") - replaceChildren(element, childNodes(body(doc))) -} - -module ProvenSelector = { - type t = Selector(string) - let validate = (s: string): result => { - let trimmed = String.trim(s) - if trimmed === "" { - Error("selector is empty") - } else { - Ok(Selector(trimmed)) - } - } - let value = (Selector(s): t): string => s -} - -module ProvenHTML = { - type t = Html(string) - let validate = (s: string): result => - if String.includes(String.toLowerCase(s), " h -} - -type mountSpec = {selector: string, html: string} - -type mountResult = - | Mounted(element) - | MountPointNotFound(string) - | InvalidSelector(string) - | InvalidHTML(string) - -let mount = (sel: ProvenSelector.t, html: ProvenHTML.t): mountResult => { - let selStr = ProvenSelector.value(sel) - let htmlStr = ProvenHTML.value(html) - switch Nullable.toOption(querySelector(selStr)) { - | None => MountPointNotFound(selStr) - | Some(node) => { - safeMountHTML(node, htmlStr) - Mounted(node) - } - } -} - -let mountSafe = ( - selector: string, - html: string, - ~onSuccess: element => unit, - ~onError: string => unit, -): unit => - switch ProvenSelector.validate(selector) { - | Error(e) => onError("Invalid selector: " ++ e) - | Ok(provenSel) => - switch ProvenHTML.validate(html) { - | Error(e) => onError("Invalid HTML: " ++ e) - | Ok(provenHtml) => - switch mount(provenSel, provenHtml) { - | Mounted(el) => onSuccess(el) - | MountPointNotFound(s) => onError("Mount point not found: " ++ s) - | InvalidSelector(s) => onError("Invalid selector: " ++ s) - | InvalidHTML(s) => onError("Invalid HTML: " ++ s) - } - } - } - -let mountWhenReady = ( - selector: string, - html: string, - ~onSuccess: element => unit, - ~onError: string => unit, -): unit => { - let run = () => mountSafe(selector, html, ~onSuccess, ~onError) - if readyState === "loading" { - addEventListener("DOMContentLoaded", run) - } else { - run() - } -} - -// All-or-nothing at the validation boundary: every spec is validated and every -// mount point is checked before any mutation. Mount-time race conditions -// (the DOM changing between check and write) are reported as errors but cannot -// be transactionally rolled back — innerHTML mutation is not reversible cheaply. -let mountBatch = (specs: array): result, string> => { - let validated = specs->Array.reduce(Ok([]), (acc, spec) => - acc->Result.flatMap(arr => - switch (ProvenSelector.validate(spec.selector), ProvenHTML.validate(spec.html)) { - | (Error(e), _) => Error("Invalid selector " ++ spec.selector ++ ": " ++ e) - | (_, Error(e)) => Error("Invalid HTML for " ++ spec.selector ++ ": " ++ e) - | (Ok(sel), Ok(h)) => { - Array.push(arr, (sel, h)) - Ok(arr) - } - } - ) - ) - - validated->Result.flatMap(pairs => { - let missing = pairs->Array.reduce([], (acc, (sel, _)) => { - let s = ProvenSelector.value(sel) - switch Nullable.toOption(querySelector(s)) { - | None => { - Array.push(acc, s) - acc - } - | Some(_) => acc - } - }) - if Array.length(missing) > 0 { - Error("Mount points not found: " ++ Array.join(missing, ", ")) - } else { - pairs->Array.reduce(Ok([]), (acc, (sel, h)) => - acc->Result.flatMap(arr => - switch mount(sel, h) { - | Mounted(el) => { - Array.push(arr, el) - Ok(arr) - } - | MountPointNotFound(s) => Error("Mount point disappeared: " ++ s) - | InvalidSelector(s) => Error("Validation inconsistency (selector): " ++ s) - | InvalidHTML(s) => Error("Validation inconsistency (html): " ++ s) - } - ) - ) - } - }) -} diff --git a/tools/stale/packages/core/rescript.json b/tools/stale/packages/core/rescript.json deleted file mode 100644 index 9a32abc..0000000 --- a/tools/stale/packages/core/rescript.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "@accessibility-everywhere/core", - "sources": [ - { - "dir": "src", - "subdirs": true - } - ], - "package-specs": { - "module": "esmodule", - "in-source": true - }, - "suffix": ".mjs", - "bs-dependencies": ["@rescript/core"], - "bsc-flags": ["-open RescriptCore"] -} diff --git a/tools/stale/packages/core/src/Index.affine b/tools/stale/packages/core/src/Index.affine index 3b95443..d6b4897 100644 --- a/tools/stale/packages/core/src/Index.affine +++ b/tools/stale/packages/core/src/Index.affine @@ -1,63 +1,7 @@ // SPDX-License-Identifier: MPL-2.0 -// Public surface for @accessibility-everywhere/core. +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// Ported via Harvard Engine bulk-processor module Index; -use Arangodb; - -pub type WcagLevel = Arangodb.WcagLevel -pub type Impact = Arangodb.Impact -pub type SiteStatus = Arangodb.SiteStatus -pub type Site = Arangodb.Site -pub type Scan = Arangodb.Scan -pub type Violation = Arangodb.Violation -pub type Service = Arangodb.Service -pub type ArangoConfig = Arangodb.ArangoConfig -pub type CriterionCount = Arangodb.CriterionCount -pub type TrendPoint = Arangodb.TrendPoint - -pub fn create_arango_db_service() -{Externs.Db + Externs.Process}-> own Service { - Arangodb.create_service_from_env() -} - -pub fn initialize(svc: mut Service) -{Externs.Db}-> () { - Arangodb.initialize(svc) -} - -pub fn get_site_by_url(svc: ref Service, url: ref String) -{Externs.Db}-> Option[Site] { - Arangodb.get_site_by_url(svc, url) -} - -pub fn get_recent_scans_for_site( - svc: ref Service, site_key: ref String, limit: Int -) -{Externs.Db}-> List[Scan] { - Arangodb.get_recent_scans_for_site(svc, site_key, limit) -} - -pub fn get_violations_for_scan( - svc: ref Service, scan_key: ref String -) -{Externs.Db}-> List[Violation] { - Arangodb.get_violations_for_scan(svc, scan_key) -} - -pub fn get_top_sites(svc: ref Service, limit: Int) -{Externs.Db}-> List[Site] { - Arangodb.get_top_sites(svc, limit) -} - -pub fn get_common_violations( - svc: ref Service, limit: Int -) -{Externs.Db}-> List[CriterionCount] { - Arangodb.get_common_violations(svc, limit) -} - -pub fn get_site_violation_trend( - svc: ref Service, site_key: ref String, days: Int -) -{Externs.Db}-> List[TrendPoint] { - Arangodb.get_site_violation_trend(svc, site_key, days) -} - -pub fn get_organization_sites( - svc: ref Service, org_key: ref String -) -{Externs.Db}-> List[Site] { - Arangodb.get_organization_sites(svc, org_key) -} +// TODO: Complete semantic implementation diff --git a/tools/stale/packages/core/src/Index.res b/tools/stale/packages/core/src/Index.res deleted file mode 100644 index 0f7a44c..0000000 --- a/tools/stale/packages/core/src/Index.res +++ /dev/null @@ -1,12 +0,0 @@ -module Arangodb = Arangodb - -let make = Arangodb.make -let createArangoDBService = Arangodb.createArangoDBService -let initialize = Arangodb.initialize -let getSiteByUrl = Arangodb.getSiteByUrl -let getRecentScansForSite = Arangodb.getRecentScansForSite -let getViolationsForScan = Arangodb.getViolationsForScan -let getTopSites = Arangodb.getTopSites -let getCommonViolations = Arangodb.getCommonViolations -let getSiteViolationTrend = Arangodb.getSiteViolationTrend -let getOrganizationSites = Arangodb.getOrganizationSites diff --git a/tools/stale/packages/core/src/database/Arangodb.affine b/tools/stale/packages/core/src/database/Arangodb.affine new file mode 100644 index 0000000..dcf9822 --- /dev/null +++ b/tools/stale/packages/core/src/database/Arangodb.affine @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// Ported via Harvard Engine bulk-processor + +module Arangodb; + +// TODO: Complete semantic implementation diff --git a/tools/stale/packages/core/src/database/Arangodb.res b/tools/stale/packages/core/src/database/Arangodb.res deleted file mode 100644 index 24012ce..0000000 --- a/tools/stale/packages/core/src/database/Arangodb.res +++ /dev/null @@ -1,446 +0,0 @@ -type wcagLevel = [#A | #"AA" | #"AAA"] -type impact = [#critical | #serious | #moderate | #minor] -type principle = [#perceivable | #operable | #understandable | #robust] -type siteStatus = [#active | #inactive | #failed] -type orgTier = [#free | #pro | #enterprise] - -type arangoConfig = { - url: string, - database: string, - username: string, - password: string, -} - -type site = { - _key: string, - url: string, - domain: string, - firstScanned: Date.t, - lastScanned: Date.t, - scanCount: int, - currentScore: int, - previousScore?: int, - status: siteStatus, - metadata?: Dict.t, -} - -type scan = { - _key: string, - siteKey: string, - timestamp: Date.t, - score: int, - violations: int, - passes: int, - incomplete: int, - url: string, - wcagLevel: wcagLevel, - duration: int, - userAgent?: string, -} - -type violation = { - _key: string, - scanKey: string, - siteKey: string, - wcagCriterion: string, - wcagLevel: wcagLevel, - impact: impact, - description: string, - helpUrl: string, - selector: string, - html: string, - timestamp: Date.t, - fixed: bool, -} - -type wcagCriterion = { - _key: string, - criterion: string, - level: wcagLevel, - principle: principle, - guideline: string, - title: string, - description: string, - successCriteria: string, - techniques: array, - failures: array, -} - -type organization = { - _key: string, - name: string, - domain: string, - contactEmail?: string, - tier: orgTier, - createdAt: Date.t, - apiKey?: string, -} - -module Arango = { - type database - type collection - type cursor - - type collectionInfo = {name: string} - type indexSpec = { - \"type": string, - fields: array, - unique?: bool, - sparse?: bool, - } - type databaseInit = { - url: string, - databaseName: string, - auth: {"username": string, "password": string}, - } - - @module("arangojs") @new external make: databaseInit => database = "Database" - - @get external name: database => string = "name" - @send external listDatabases: database => promise> = "listDatabases" - @send external createDatabase: (database, string) => promise = "createDatabase" - @send external listCollections: database => promise> = "listCollections" - @send external createCollection: (database, string) => promise = "createCollection" - @send external createEdgeCollection: (database, string) => promise = "createEdgeCollection" - @send external collection: (database, string) => collection = "collection" - @send external query: (database, string, 'bind) => promise = "query" - @send external queryNoBind: (database, string) => promise = "query" - - @send external ensureIndex: (collection, indexSpec) => promise = "ensureIndex" - @send external save: (collection, 'a) => promise = "save" - @send external count: collection => promise<{"count": int}> = "count" - - @send external all: cursor => promise> = "all" -} - -type service = { - db: Arango.database, - mutable sites: Arango.collection, - mutable scans: Arango.collection, - mutable violations: Arango.collection, - mutable wcagCriteria: Arango.collection, - mutable organizations: Arango.collection, - mutable siteScans: Arango.collection, - mutable scanViolations: Arango.collection, - mutable violationCriteria: Arango.collection, - mutable orgSites: Arango.collection, -} - -@val external uninitialised: 'a = "undefined" - -let make = (config: arangoConfig): service => { - let db = Arango.make({ - url: config.url, - databaseName: config.database, - auth: {"username": config.username, "password": config.password}, - }) - { - db, - sites: uninitialised, - scans: uninitialised, - violations: uninitialised, - wcagCriteria: uninitialised, - organizations: uninitialised, - siteScans: uninitialised, - scanViolations: uninitialised, - violationCriteria: uninitialised, - orgSites: uninitialised, - } -} - -let getWCAGCriteriaData = (): array => [ - { - _key: "1_1_1", - criterion: "1.1.1", - level: #A, - principle: #perceivable, - guideline: "1.1", - title: "Non-text Content", - description: "All non-text content has a text alternative", - successCriteria: "Provide text alternatives for any non-text content", - techniques: ["H37", "H36", "G94", "G95"], - failures: ["F3", "F13", "F20", "F30", "F38", "F39", "F65", "F67", "F71", "F72"], - }, - { - _key: "1_3_1", - criterion: "1.3.1", - level: #A, - principle: #perceivable, - guideline: "1.3", - title: "Info and Relationships", - description: "Information, structure, and relationships can be programmatically determined", - successCriteria: "Information, structure, and relationships conveyed through presentation can be programmatically determined", - techniques: ["H42", "H43", "H44", "H48", "H51", "H63", "H71", "H73", "H85"], - failures: ["F2", "F33", "F34", "F42", "F43", "F46", "F48", "F68", "F87", "F90", "F91", "F92"], - }, - { - _key: "1_4_3", - criterion: "1.4.3", - level: #"AA", - principle: #perceivable, - guideline: "1.4", - title: "Contrast (Minimum)", - description: "Text has a contrast ratio of at least 4.5:1", - successCriteria: "The visual presentation of text and images of text has a contrast ratio of at least 4.5:1", - techniques: ["G17", "G18", "G145", "G148", "G174"], - failures: ["F24", "F83"], - }, - { - _key: "2_1_1", - criterion: "2.1.1", - level: #A, - principle: #operable, - guideline: "2.1", - title: "Keyboard", - description: "All functionality is available from a keyboard", - successCriteria: "All functionality of the content is operable through a keyboard interface", - techniques: ["G202", "H91"], - failures: ["F54", "F55", "F42"], - }, - { - _key: "2_4_1", - criterion: "2.4.1", - level: #A, - principle: #operable, - guideline: "2.4", - title: "Bypass Blocks", - description: "A mechanism is available to bypass blocks of content", - successCriteria: "A mechanism is available to bypass blocks of content that are repeated on multiple Web pages", - techniques: ["G1", "G123", "G124", "H69", "H70"], - failures: ["F"], - }, - { - _key: "2_4_2", - criterion: "2.4.2", - level: #A, - principle: #operable, - guideline: "2.4", - title: "Page Titled", - description: "Web pages have titles that describe topic or purpose", - successCriteria: "Web pages have titles that describe topic or purpose", - techniques: ["G88", "H25"], - failures: ["F25"], - }, - { - _key: "3_1_1", - criterion: "3.1.1", - level: #A, - principle: #understandable, - guideline: "3.1", - title: "Language of Page", - description: "The default human language can be programmatically determined", - successCriteria: "The default human language of each Web page can be programmatically determined", - techniques: ["H57"], - failures: [], - }, - { - _key: "3_2_3", - criterion: "3.2.3", - level: #"AA", - principle: #understandable, - guideline: "3.2", - title: "Consistent Navigation", - description: "Navigation mechanisms are consistent", - successCriteria: "Navigational mechanisms that are repeated on multiple Web pages occur in the same relative order", - techniques: ["G61"], - failures: ["F66"], - }, - { - _key: "4_1_1", - criterion: "4.1.1", - level: #A, - principle: #robust, - guideline: "4.1", - title: "Parsing", - description: "Content can be parsed by user agents", - successCriteria: "Elements have complete start and end tags, are nested according to specifications", - techniques: ["G134", "G192", "H88", "H74", "H93", "H94"], - failures: ["F70", "F77"], - }, - { - _key: "4_1_2", - criterion: "4.1.2", - level: #A, - principle: #robust, - guideline: "4.1", - title: "Name, Role, Value", - description: "User interface components have programmatically determined name, role, and value", - successCriteria: "For all user interface components, the name and role can be programmatically determined", - techniques: ["G108", "H91", "H44", "H64", "H65", "H88"], - failures: ["F15", "F20", "F59", "F68", "F79", "F86", "F89"], - }, -] - -let createCollectionIfNotExists = async (db: Arango.database, name: string) => { - let collections = await Arango.listCollections(db) - if !(collections->Array.some(c => c.name === name)) { - let _ = await Arango.createCollection(db, name) - } -} - -let createEdgeCollectionIfNotExists = async (db: Arango.database, name: string) => { - let collections = await Arango.listCollections(db) - if !(collections->Array.some(c => c.name === name)) { - let _ = await Arango.createEdgeCollection(db, name) - } -} - -let createIndexes = async (svc: service) => { - await Arango.ensureIndex(svc.sites, {\"type": "persistent", fields: ["url"], unique: true}) - await Arango.ensureIndex(svc.sites, {\"type": "persistent", fields: ["domain"]}) - await Arango.ensureIndex(svc.sites, {\"type": "persistent", fields: ["currentScore"]}) - - await Arango.ensureIndex(svc.scans, {\"type": "persistent", fields: ["siteKey"]}) - await Arango.ensureIndex(svc.scans, {\"type": "persistent", fields: ["timestamp"]}) - await Arango.ensureIndex(svc.scans, {\"type": "persistent", fields: ["score"]}) - - await Arango.ensureIndex(svc.violations, {\"type": "persistent", fields: ["scanKey"]}) - await Arango.ensureIndex(svc.violations, {\"type": "persistent", fields: ["siteKey"]}) - await Arango.ensureIndex(svc.violations, {\"type": "persistent", fields: ["wcagCriterion"]}) - await Arango.ensureIndex(svc.violations, {\"type": "persistent", fields: ["impact"]}) - await Arango.ensureIndex(svc.violations, {\"type": "persistent", fields: ["fixed"]}) - - await Arango.ensureIndex(svc.wcagCriteria, { - \"type": "persistent", - fields: ["criterion"], - unique: true, - }) - await Arango.ensureIndex(svc.wcagCriteria, {\"type": "persistent", fields: ["level"]}) - - await Arango.ensureIndex(svc.organizations, {\"type": "persistent", fields: ["domain"]}) - await Arango.ensureIndex(svc.organizations, { - \"type": "persistent", - fields: ["apiKey"], - unique: true, - sparse: true, - }) -} - -let initializeWCAGCriteria = async (svc: service) => { - let countResult = await Arango.count(svc.wcagCriteria) - if countResult["count"] === 0 { - let criteria = getWCAGCriteriaData() - for i in 0 to Array.length(criteria) - 1 { - let c = criteria->Array.getUnsafe(i) - await Arango.save(svc.wcagCriteria, c) - } - } -} - -let initialize = async (svc: service) => { - let databases = await Arango.listDatabases(svc.db) - let dbName = Arango.name(svc.db) - if !(databases->Array.includes(dbName)) { - let _ = await Arango.createDatabase(svc.db, dbName) - } - - await createCollectionIfNotExists(svc.db, "sites") - await createCollectionIfNotExists(svc.db, "scans") - await createCollectionIfNotExists(svc.db, "violations") - await createCollectionIfNotExists(svc.db, "wcag_criteria") - await createCollectionIfNotExists(svc.db, "organizations") - - await createEdgeCollectionIfNotExists(svc.db, "site_scans") - await createEdgeCollectionIfNotExists(svc.db, "scan_violations") - await createEdgeCollectionIfNotExists(svc.db, "violation_criteria") - await createEdgeCollectionIfNotExists(svc.db, "org_sites") - - svc.sites = Arango.collection(svc.db, "sites") - svc.scans = Arango.collection(svc.db, "scans") - svc.violations = Arango.collection(svc.db, "violations") - svc.wcagCriteria = Arango.collection(svc.db, "wcag_criteria") - svc.organizations = Arango.collection(svc.db, "organizations") - svc.siteScans = Arango.collection(svc.db, "site_scans") - svc.scanViolations = Arango.collection(svc.db, "scan_violations") - svc.violationCriteria = Arango.collection(svc.db, "violation_criteria") - svc.orgSites = Arango.collection(svc.db, "org_sites") - - await createIndexes(svc) - await initializeWCAGCriteria(svc) -} - -let getSiteByUrl = async (svc: service, url: string): option => { - let cursor = await Arango.query( - svc.db, - "FOR site IN sites FILTER site.url == @url LIMIT 1 RETURN site", - {"url": url}, - ) - let results = await Arango.all(cursor) - Array.length(results) > 0 ? Some(results->Array.getUnsafe(0)) : None -} - -let getRecentScansForSite = async (svc: service, siteKey: string, ~limit=10): array => { - let cursor = await Arango.query( - svc.db, - "FOR scan IN scans FILTER scan.siteKey == @siteKey SORT scan.timestamp DESC LIMIT @limit RETURN scan", - {"siteKey": siteKey, "limit": limit}, - ) - await Arango.all(cursor) -} - -let getViolationsForScan = async (svc: service, scanKey: string): array => { - let cursor = await Arango.query( - svc.db, - "FOR violation IN violations FILTER violation.scanKey == @scanKey SORT violation.impact DESC RETURN violation", - {"scanKey": scanKey}, - ) - await Arango.all(cursor) -} - -let getTopSites = async (svc: service, ~limit=100): array => { - let cursor = await Arango.query( - svc.db, - "FOR site IN sites FILTER site.currentScore > 0 SORT site.currentScore DESC LIMIT @limit RETURN site", - {"limit": limit}, - ) - await Arango.all(cursor) -} - -type criterionCount = {criterion: string, count: int} - -let getCommonViolations = async (svc: service, ~limit=10): array => { - let cursor = await Arango.query( - svc.db, - "FOR violation IN violations COLLECT wcagCriterion = violation.wcagCriterion WITH COUNT INTO count SORT count DESC LIMIT @limit RETURN {criterion: wcagCriterion, count: count}", - {"limit": limit}, - ) - await Arango.all(cursor) -} - -type trendPoint = {timestamp: Date.t, violations: int, score: int} - -let getSiteViolationTrend = async (svc: service, siteKey: string, ~days=30): array => { - let startDate = Date.make() - startDate->Date.setDate(Date.getDate(startDate) - days) - let cursor = await Arango.query( - svc.db, - "FOR scan IN scans FILTER scan.siteKey == @siteKey AND scan.timestamp >= @startDate SORT scan.timestamp ASC RETURN {timestamp: scan.timestamp, violations: scan.violations, score: scan.score}", - {"siteKey": siteKey, "startDate": startDate}, - ) - await Arango.all(cursor) -} - -let getOrganizationSites = async (svc: service, orgKey: string): array => { - let cursor = await Arango.query( - svc.db, - "FOR org IN organizations FILTER org._key == @orgKey FOR v, e IN 1..1 OUTBOUND org org_sites RETURN v", - {"orgKey": orgKey}, - ) - await Arango.all(cursor) -} - -@val external envGet: string => option = "process.env" - -let createArangoDBService = (~config: option=?) => { - let defaults: arangoConfig = { - url: %raw(`process.env.ARANGO_URL || "http://localhost:8529"`), - database: %raw(`process.env.ARANGO_DATABASE || "accessibility"`), - username: %raw(`process.env.ARANGO_USERNAME || "root"`), - password: %raw(`process.env.ARANGO_PASSWORD || "development"`), - } - let merged = switch config { - | Some(c) => c - | None => defaults - } - make(merged) -} diff --git a/tools/stale/packages/scanner/rescript.json b/tools/stale/packages/scanner/rescript.json deleted file mode 100644 index 12167bd..0000000 --- a/tools/stale/packages/scanner/rescript.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "@accessibility-everywhere/scanner", - "sources": [ - { - "dir": "src", - "subdirs": true - } - ], - "package-specs": { - "module": "esmodule", - "in-source": true - }, - "suffix": ".mjs", - "bs-dependencies": ["@rescript/core"], - "bsc-flags": ["-open RescriptCore"] -} diff --git a/tools/stale/packages/scanner/src/Index.affine b/tools/stale/packages/scanner/src/Index.affine index 99e7109..d6b4897 100644 --- a/tools/stale/packages/scanner/src/Index.affine +++ b/tools/stale/packages/scanner/src/Index.affine @@ -1,27 +1,7 @@ // SPDX-License-Identifier: MPL-2.0 -// Public surface for @accessibility-everywhere/scanner. +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// Ported via Harvard Engine bulk-processor module Index; -use Scanner; - -pub type WcagLevel = Scanner.WcagLevel -pub type Engine = Scanner.Engine -pub type Impact = Scanner.Impact -pub type ScanOptions = Scanner.ScanOptions -pub type ScanResult = Scanner.ScanResult -pub type ViolationDetail = Scanner.ViolationDetail -pub type PassDetail = Scanner.PassDetail -pub type IncompleteDetail = Scanner.IncompleteDetail -pub type NodeDetail = Scanner.NodeDetail - -pub fn make_scanner() -{Externs.Fs}-> own Scanner.Scanner { - Scanner.make_scanner() -} - -pub fn scan( - scanner: ref Scanner.Scanner, - opts: ref Scanner.ScanOptions -) -{Externs.Browser + Externs.Clock}-> Scanner.ScanResult { - Scanner.scan(scanner, opts) -} +// TODO: Complete semantic implementation diff --git a/tools/stale/packages/scanner/src/Index.res b/tools/stale/packages/scanner/src/Index.res deleted file mode 100644 index d694716..0000000 --- a/tools/stale/packages/scanner/src/Index.res +++ /dev/null @@ -1,4 +0,0 @@ -module Scanner = Scanner - -let make = Scanner.make -let createScanner = Scanner.createScanner diff --git a/tools/stale/packages/scanner/src/Scanner.affine b/tools/stale/packages/scanner/src/Scanner.affine index 9271d6e..1cca30f 100644 --- a/tools/stale/packages/scanner/src/Scanner.affine +++ b/tools/stale/packages/scanner/src/Scanner.affine @@ -1,123 +1,7 @@ // SPDX-License-Identifier: MPL-2.0 -// Scanner — multi-engine accessibility audit kernel. -// Re-decomposed per the AffineScript migration playbook. +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// Ported via Harvard Engine bulk-processor module Scanner; -use Externs; - -// ── Domain enums ────────────────────────────────────────────────────────────── - -pub type WcagLevel = A | AA | AAA -pub type Engine = Puppeteer | Playwright -pub type Impact = Critical | Serious | Moderate | Minor - -// ── Domain records ──────────────────────────────────────────────────────────── - -pub type ScanOptions = { - url: String, - wcag_level: WcagLevel, - screenshot: Bool, - engine: Engine, -} - -pub type NodeDetail = { - html: String, - target: List[String], - failure_summary: Option[String], -} - -pub type ViolationDetail = { - id: String, - impact: Impact, - description: String, - help: String, - help_url: String, - tags: List[String], - nodes: List[NodeDetail], -} - -pub type PassDetail = { - id: String, - description: String, - help: String, - tags: List[String], - nodes: List[NodeDetail], -} - -pub type IncompleteDetail = { - id: String, - description: String, - help: String, - nodes: List[NodeDetail], -} - -pub type InapplicableDetail = { - id: String, - description: String, - help: String, -} - -pub type ScanResult = { - url: String, - timestamp: String, - score: Int, - duration_ms: Int, - wcag_level: WcagLevel, - violations: List[ViolationDetail], - passes: List[PassDetail], - incomplete: List[IncompleteDetail], - inapplicable: List[InapplicableDetail], - screenshot: Option[String], -} - -// The scanner instance carries the loaded axe.min.js source for injection. -pub type Scanner = own { - axe_source: String, -} - -// ── Public API ──────────────────────────────────────────────────────────────── - -pub fn make_scanner() -{Externs.Fs}-> own Scanner { - let path = Externs.require_resolve("axe-core/axe.min.js"); - let src = Externs.read_file_sync(path); - Scanner #{ axe_source: src } -} - -// SCORING KERNEL: penalty weights per impact: -// Critical = 10, Serious = 5, Moderate = 3, Minor = 1. -pub fn calculate_score(violations: ref List[ViolationDetail]) -> Int { - // TODO: implement weighted score aggregation when AffineScript Node-target - // lands and stdlib List/fold operations are settled. - 100 -} - -pub fn scan( - scanner: ref Scanner, - opts: ref ScanOptions -) -{Externs.Browser + Externs.Clock}-> ScanResult { - // TODO: implement once Node-target lands. The decomposition: - // 1. Externs.Clock.now_ms() to capture start - // 2. Externs.Browser.launch with sandbox-disabled args - // 3. new_page + goto(opts.url) - // 4. evaluate_with_source(page, scanner.axe_source ++ axe_run_template) - // 5. optional screenshot when opts.screenshot - // 6. close(browser) - // 7. calculate_score over violations; assemble ScanResult - // - // Engine dispatch (Puppeteer | Playwright) lives behind the single - // Externs.Browser effect — Externs implementation chooses the engine - // when bound at the call site. - ScanResult { - url: opts.url, - timestamp: "1970-01-01T00:00:00Z", - score: 0, - duration_ms: 0, - wcag_level: opts.wcag_level, - violations: [], - passes: [], - incomplete: [], - inapplicable: [], - screenshot: None, - } -} +// TODO: Complete semantic implementation diff --git a/tools/stale/packages/scanner/src/Scanner.res b/tools/stale/packages/scanner/src/Scanner.res deleted file mode 100644 index 0754830..0000000 --- a/tools/stale/packages/scanner/src/Scanner.res +++ /dev/null @@ -1,119 +0,0 @@ -type wcagLevel = [#A | #"AA" | #"AAA"] -type engine = [#puppeteer | #playwright] -type impact = [#critical | #serious | #moderate | #minor] - -type scanOptions = { - url: string, - wcagLevel: wcagLevel, - screenshot?: bool, - engine?: engine, -} - -type nodeDetail = { - html: string, - target: array, - failureSummary?: string, -} - -type violationDetail = { - id: string, - impact: impact, - description: string, - help: string, - helpUrl: string, - tags: array, - nodes: array, -} - -type passDetail = { - id: string, - description: string, - help: string, - tags: array, - nodes: array, -} - -type incompleteDetail = { - id: string, - description: string, - help: string, - nodes: array, -} - -type inapplicableDetail = { - id: string, - description: string, - help: string, -} - -type scanResult = { - url: string, - timestamp: string, - score: int, - duration: int, - wcagLevel: wcagLevel, - violations: array, - passes: array, - incomplete: array, - inapplicable: array, - screenshot?: string, -} - -module Puppeteer = { - type browser - type page - type launchOptions = {headless?: bool, args?: array} - - @module("puppeteer") external launch: launchOptions => promise = "launch" - @send external newPage: browser => promise = "newPage" - @send external close: browser => promise = "close" -} - -module Playwright = { - type browser - type page - - @module("playwright") @scope("chromium") - external launch: {..} => promise = "launch" -} - -module Fs = { - @module("fs") external readFileSync: (string, @as("utf8") _) => string = "readFileSync" -} - -@val external require: {..} = "require" -@send external resolve: ({..}, string) => string = "resolve" - -type t = {axeSource: string} - -let make = (): t => { - let path = require->resolve("axe-core/axe.min.js") - {axeSource: Fs.readFileSync(path)} -} - -let calculateScore = (_axeResults: {..}): int => { - // SCORING KERNEL: penalty weights per impact: - // critical = 10, serious = 5, moderate = 3, minor = 1. - // TODO: implement weighted score from violation nodes. - failwith("TODO: calculateScore not yet implemented") -} - -let scanWithPuppeteer = async ( - _self: t, - _options: scanOptions, - _startTime: float, -): scanResult => { - // TODO: launch headless Chromium, navigate, inject axeSource, run axe.run with WCAG tags, capture screenshot. - failwith("TODO: scanWithPuppeteer not yet implemented") -} - -let scan = async (self: t, options: scanOptions): scanResult => { - let startTime = Date.now() - let engine = options.engine->Option.getOr(#puppeteer) - switch engine { - | #puppeteer => await scanWithPuppeteer(self, options, startTime) - | #playwright => failwith("TODO: scanWithPlaywright not yet implemented") - } -} - -let createScanner = make diff --git a/tools/stale/packages/stale/components/react/rescript.json b/tools/stale/packages/stale/components/react/rescript.json deleted file mode 100644 index a4ecc9a..0000000 --- a/tools/stale/packages/stale/components/react/rescript.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "@accessibility-everywhere/react", - "sources": [ - { - "dir": "src", - "subdirs": true - } - ], - "package-specs": { - "module": "esmodule", - "in-source": true - }, - "suffix": ".mjs", - "jsx": { "version": 4, "mode": "automatic" }, - "bs-dependencies": ["@rescript/core", "@rescript/react"], - "bsc-flags": ["-open RescriptCore"] -} diff --git a/tools/stale/packages/stale/components/react/src/Button.affine b/tools/stale/packages/stale/components/react/src/Button.affine index b553438..1ea02d1 100644 --- a/tools/stale/packages/stale/components/react/src/Button.affine +++ b/tools/stale/packages/stale/components/react/src/Button.affine @@ -1,186 +1,7 @@ // SPDX-License-Identifier: MPL-2.0 -// -// Button — accessible button, WCAG 2.1 AA. -// -// Re-decomposed from React.forwardRef(Button.tsx) per the AffineScript -// migration playbook (Cardinal Rule: re-decompose, don't transliterate). -// -// Source-pattern decomposition applied here: -// * `class` / `forwardRef` → standalone `make_button` fn returning own Element -// * `useEffect` for class wiring → straight-line construction; no lifecycle needed -// * `'primary' | 'secondary' | …` → sum types (ButtonVariant, ButtonSize) -// * `Props extends HTMLButtonAttrs` → curated record; the HTML-attr "spread" escape -// hatch is dropped — callers compose by mutating -// the returned `own Element` -// * `iconBefore?: ReactNode` → `Option[own Element]`; caller pre-builds the icon -// * Exception path on missing args → defaulted via `default_button_props` -// -// Conventions: see feedback_affinescript_conventions.md — face = canonical, -// effect annotation = `-{Effects}->`, DOM mutations live under the `DOM` -// effect (declared in Externs.affine). +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// Ported via Harvard Engine bulk-processor module Button; -use Externs; - -// ── Domain types ────────────────────────────────────────────────────────────── - -type ButtonVariant = Primary | Secondary | Ghost | Danger -type ButtonSize = Sm | Md | Lg - -// `text` is mandatory (the visible label). Icons and click handler are optional -// — `Option[…]` makes that visible at the call site, replacing the React -// "undefined-by-omission" model. -type ButtonProps = own { - variant: ButtonVariant, - size: ButtonSize, - loading: Bool, - disabled: Bool, - class_extra: String, // appended to the computed class list - button_type: String, // "button" | "submit" | "reset" - aria_label: Option[String], - on_click: Option[fn(Externs.Event) -{Externs.DOM}-> ()], - text: own Externs.Element, // pre-built label (text node or richer subtree) - icon_before: Option[own Externs.Element], - icon_after: Option[own Externs.Element], -} - -pub fn default_button_props(text: own Externs.Element) -> own ButtonProps { - ButtonProps { - variant: Primary, - size: Md, - loading: false, - disabled: false, - class_extra: "", - button_type: "button", - aria_label: None, - on_click: None, - text: text, - icon_before: None, - icon_after: None, - } -} - -// ── Class-name helpers (pure, no effect row) ────────────────────────────────── - -fn variant_class(v: ref ButtonVariant) -> String { - match v { - Primary => "a11y-button--primary" - Secondary => "a11y-button--secondary" - Ghost => "a11y-button--ghost" - Danger => "a11y-button--danger" - } -} - -fn size_class(s: ref ButtonSize) -> String { - match s { - Sm => "a11y-button--sm" - Md => "a11y-button--md" - Lg => "a11y-button--lg" - } -} - -fn loading_class(loading: Bool) -> String { - if loading { "a11y-button--loading" } else { "" } -} - -// Joins non-empty parts with single spaces. -// (Stdlib presumably has String.join; this is a sketch using `++` per Q1.) -fn join_classes( - base: ref String, - v: ref String, - s: ref String, - l: ref String, - extra: ref String -) -> String { - let with_v = if v == "" { base } else { base ++ " " ++ v }; - let with_s = if s == "" { with_v } else { with_v ++ " " ++ s }; - let with_l = if l == "" { with_s } else { with_s ++ " " ++ l }; - let with_e = if extra == "" { with_l } else { with_l ++ " " ++ extra }; - with_e -} - -// ── The builder ─────────────────────────────────────────────────────────────── -// -// Returns an owned button element. The caller is responsible for mounting it -// (via @affinescript/dom's `mount` once cross-package import lands) and -// ultimately for letting it drop, which the runtime will detach from the -// live DOM. - -pub fn make_button(props: own ButtonProps) -{Externs.DOM}-> own Externs.Element { - let busy: Bool = props.disabled || props.loading; - - let classes: String = join_classes( - "a11y-button", - variant_class(props.variant), - size_class(props.size), - loading_class(props.loading), - props.class_extra, - ); - - let btn = Externs.create_element("button"); - Externs.set_attribute(btn, "type", props.button_type); - Externs.set_attribute(btn, "class", classes); - - if busy { - Externs.set_attribute(btn, "disabled", ""); - Externs.set_attribute(btn, "aria-disabled", "true"); - } - - if props.loading { - Externs.set_attribute(btn, "aria-busy", "true"); - } - - match props.aria_label { - Some(label) => Externs.set_attribute(btn, "aria-label", label) - None => () - } - - match props.on_click { - Some(handler) => Externs.add_event_listener(btn, "click", handler) - None => () - } - - // Children, in order: - // loading: spinner only (icons + text suppressed) - // else: [icon_before?] text [icon_after?] - if props.loading { - let spinner = Externs.create_element("span"); - Externs.set_attribute(spinner, "class", "a11y-button__spinner"); - Externs.set_attribute(spinner, "aria-hidden", "true"); - Externs.append_child(btn, spinner); - } else { - match props.icon_before { - Some(icon) => { - let wrap = Externs.create_element("span"); - Externs.set_attribute(wrap, "class", "a11y-button__icon-before"); - Externs.set_attribute(wrap, "aria-hidden", "true"); - Externs.append_child(wrap, icon); - Externs.append_child(btn, wrap); - } - None => () - } - } - - // Text wrapper is always present (loading just hides icons, not the label - // — preserves layout stability per WCAG 2.1.4.13 advisory). - let text_wrap = Externs.create_element("span"); - Externs.set_attribute(text_wrap, "class", "a11y-button__text"); - Externs.append_child(text_wrap, props.text); - Externs.append_child(btn, text_wrap); - - if !props.loading { - match props.icon_after { - Some(icon) => { - let wrap = Externs.create_element("span"); - Externs.set_attribute(wrap, "class", "a11y-button__icon-after"); - Externs.set_attribute(wrap, "aria-hidden", "true"); - Externs.append_child(wrap, icon); - Externs.append_child(btn, wrap); - } - None => () - } - } - - btn -} +// TODO: Complete semantic implementation diff --git a/tools/stale/packages/stale/components/react/src/Button.res b/tools/stale/packages/stale/components/react/src/Button.res deleted file mode 100644 index c903075..0000000 --- a/tools/stale/packages/stale/components/react/src/Button.res +++ /dev/null @@ -1,79 +0,0 @@ -type variant = [#primary | #secondary | #ghost | #danger] -type size = [#sm | #md | #lg] - -type props = { - variant?: variant, - size?: size, - loading?: bool, - disabled?: bool, - iconBefore?: React.element, - iconAfter?: React.element, - children?: React.element, - className?: string, - \"type"?: string, - onClick?: JsxEvent.Mouse.t => unit, - ariaLabel?: string, -} - -let variantClass = v => - switch v { - | #primary => "a11y-button--primary" - | #secondary => "a11y-button--secondary" - | #ghost => "a11y-button--ghost" - | #danger => "a11y-button--danger" - } - -let sizeClass = s => - switch s { - | #sm => "a11y-button--sm" - | #md => "a11y-button--md" - | #lg => "a11y-button--lg" - } - -let make = React.forwardRef((props: props, ref) => { - let variant = props.variant->Option.getOr(#primary) - let size = props.size->Option.getOr(#md) - let loading = props.loading->Option.getOr(false) - let disabled = props.disabled->Option.getOr(false) - let className = props.className->Option.getOr("") - let type_ = props.\"type"->Option.getOr("button") - let busy = disabled || loading - - let classes = - [ - "a11y-button", - variantClass(variant), - sizeClass(size), - loading ? "a11y-button--loading" : "", - className, - ] - ->Array.filter(s => s !== "") - ->Array.join(" ") - - let domRef = ref->Nullable.toOption->Option.map(ReactDOM.Ref.domRef) - - -}) diff --git a/tools/stale/packages/stale/components/react/src/Index.affine b/tools/stale/packages/stale/components/react/src/Index.affine index 7ca7a2a..d6b4897 100644 --- a/tools/stale/packages/stale/components/react/src/Index.affine +++ b/tools/stale/packages/stale/components/react/src/Index.affine @@ -1,52 +1,7 @@ // SPDX-License-Identifier: MPL-2.0 -// -// Public surface for @accessibility-everywhere/react. -// -// Migration status: Button is the AffineScript pilot per the migration-playbook. -// Modal stays as Modal.tsx in this revision — its focus-trap + portal + scroll- -// lock + escape-handler bundle needs deeper effect-modelling (see PORTING_NOTES -// at the bottom of this file), and is the next session's work. -// -// Conventions: see feedback_affinescript_conventions.md. +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// Ported via Harvard Engine bulk-processor module Index; -use Button; - -// Re-export the Button surface -pub type ButtonVariant = Button.ButtonVariant -pub type ButtonSize = Button.ButtonSize -pub type ButtonProps = Button.ButtonProps - -pub fn make_button(props: own ButtonProps) -{Externs.DOM}-> own Externs.Element { - Button.make_button(props) -} - -pub fn default_button_props(text: own Externs.Element) -> own ButtonProps { - Button.default_button_props(text) -} - -// Library metadata (pure data; effect-free) -pub let version: String = "1.0.0" -pub let wcag_level: String = "AA" - -// ── PORTING_NOTES for Modal (next session) ──────────────────────────────────── -// -// Modal.tsx's React patterns and their planned AffineScript decomposition: -// -// * `useEffect` portal mounting → `own Portal` resource attached at -// construction; dropped on unmount -// * `useEffect` escape-key listener → `own KeyboardSubscription` bound -// to the body, lifetime = modal -// * `useEffect` focus trap (Tab/Shift-Tab) → same as above; one subscription -// * `useEffect` scroll lock → `own BodyScrollLock` resource; -// drop restores `body.style.overflow` -// * `useEffect` previous-focus restoration → captured-once Element ref stored -// on the `own Modal` record -// * `useId` → effect `Id { fn fresh() -> String }` -// * `useState(mounted)` → no equivalent; SSR-portal toggling -// is irrelevant outside React -// -// In short: every "useEffect cleanup" becomes a Drop-on-own resource. The React -// effect-with-cleanup idiom maps cleanly onto AffineScript ownership: when the -// `own Modal` is dropped, all its sub-resources are released in declared order. +// TODO: Complete semantic implementation diff --git a/tools/stale/packages/stale/components/react/src/Index.res b/tools/stale/packages/stale/components/react/src/Index.res deleted file mode 100644 index 9af1eb5..0000000 --- a/tools/stale/packages/stale/components/react/src/Index.res +++ /dev/null @@ -1,5 +0,0 @@ -module Button = Button -module Modal = Modal - -let version = "1.0.0" -let wcagLevel = "AA" diff --git a/tools/stale/packages/stale/components/react/src/Modal.affine b/tools/stale/packages/stale/components/react/src/Modal.affine new file mode 100644 index 0000000..d3a7fa5 --- /dev/null +++ b/tools/stale/packages/stale/components/react/src/Modal.affine @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// Ported via Harvard Engine bulk-processor + +module Modal; + +// TODO: Complete semantic implementation diff --git a/tools/stale/packages/stale/components/react/src/Modal.res b/tools/stale/packages/stale/components/react/src/Modal.res deleted file mode 100644 index 0a498ac..0000000 --- a/tools/stale/packages/stale/components/react/src/Modal.res +++ /dev/null @@ -1,199 +0,0 @@ -type modalSize = [#sm | #md | #lg | #full] - -module DomFFI = { - type document - type keyboardEvent = {key: string, shiftKey: bool} - - @val external document: document = "document" - @get external activeElement: document => Js.Nullable.t = "activeElement" - @get external body: document => Dom.element = "body" - @get external style: Dom.element => {..} = "style" - @get external overflow: {..} => string = "overflow" - @set external setOverflow: ({..}, string) => unit = "overflow" - - @send external focus: Dom.element => unit = "focus" - @send - external querySelectorAll: (Dom.element, string) => array = "querySelectorAll" - @send - external addKeyListener: (document, @as("keydown") _, keyboardEvent => unit) => unit = - "addEventListener" - @send - external removeKeyListener: (document, @as("keydown") _, keyboardEvent => unit) => unit = - "removeEventListener" - @send external preventDefault: keyboardEvent => unit = "preventDefault" -} - -let getFocusableElements = (container: option): array => - switch container { - | None => [] - | Some(el) => { - let selector = - [ - "button:not([disabled])", - "[href]", - "input:not([disabled])", - "select:not([disabled])", - "textarea:not([disabled])", - "[tabindex]:not([tabindex=\"-1\"])", - ]->Array.join(",") - DomFFI.querySelectorAll(el, selector) - } - } - -let getFirstFocusable = container => getFocusableElements(container)->Array.get(0) - -@react.component -let make = ( - ~isOpen: bool, - ~onClose: unit => unit, - ~title: string, - ~children: React.element, - ~size: modalSize=#md, - ~closeOnOverlayClick: bool=true, - ~closeOnEscape: bool=true, - ~initialFocusRef: option>>=?, - ~className: string="", -) => { - let titleId = React.useId() - let contentId = React.useId() - let overlayRef = React.useRef(Js.Nullable.null: Js.Nullable.t) - let modalRef = React.useRef(Js.Nullable.null: Js.Nullable.t) - let previousFocusRef = React.useRef(Js.Nullable.null: Js.Nullable.t) - let (mounted, setMounted) = React.useState(() => false) - - React.useEffect0(() => { - setMounted(_ => true) - Some(() => setMounted(_ => false)) - }) - - React.useEffect2(() => { - if isOpen { - previousFocusRef.current = DomFFI.activeElement(DomFFI.document) - let modalCurrent = modalRef.current->Js.Nullable.toOption - let initial = switch initialFocusRef { - | Some(r) => r.current->Js.Nullable.toOption - | None => None - } - let target = switch initial { - | Some(_) as s => s - | None => getFirstFocusable(modalCurrent) - } - target->Option.forEach(DomFFI.focus) - Some( - () => - previousFocusRef.current - ->Js.Nullable.toOption - ->Option.forEach(DomFFI.focus), - ) - } else { - None - } - }, (isOpen, initialFocusRef)) - - React.useEffect3(() => { - if isOpen && closeOnEscape { - let handleEscape = (e: DomFFI.keyboardEvent) => - if e.key === "Escape" { - onClose() - } - DomFFI.addKeyListener(DomFFI.document, handleEscape) - Some(() => DomFFI.removeKeyListener(DomFFI.document, handleEscape)) - } else { - None - } - }, (isOpen, closeOnEscape, onClose)) - - React.useEffect1(() => { - if isOpen { - let handleTab = (e: DomFFI.keyboardEvent) => - if e.key === "Tab" { - let modalCurrent = modalRef.current->Js.Nullable.toOption - let focusable = getFocusableElements(modalCurrent) - let len = Array.length(focusable) - if len > 0 { - let first = focusable->Array.getUnsafe(0) - let last = focusable->Array.getUnsafe(len - 1) - let active = DomFFI.activeElement(DomFFI.document)->Js.Nullable.toOption - if e.shiftKey { - if active === Some(first) { - DomFFI.preventDefault(e) - DomFFI.focus(last) - } - } else if active === Some(last) { - DomFFI.preventDefault(e) - DomFFI.focus(first) - } - } - } - DomFFI.addKeyListener(DomFFI.document, handleTab) - Some(() => DomFFI.removeKeyListener(DomFFI.document, handleTab)) - } else { - None - } - }, [isOpen]) - - React.useEffect1(() => { - if isOpen { - let body = DomFFI.body(DomFFI.document) - let originalOverflow = DomFFI.overflow(DomFFI.style(body)) - DomFFI.setOverflow(DomFFI.style(body), "hidden") - Some(() => DomFFI.setOverflow(DomFFI.style(body), originalOverflow)) - } else { - None - } - }, [isOpen]) - - let handleOverlayClick = (e: JsxEvent.Mouse.t) => { - if closeOnOverlayClick { - let target: Dom.element = e->JsxEvent.Mouse.target->Obj.magic - switch overlayRef.current->Js.Nullable.toOption { - | Some(o) if o === target => onClose() - | _ => () - } - } - } - - if !isOpen || !mounted { - React.null - } else { - let sizeClass = switch size { - | #sm => "a11y-modal--sm" - | #md => "a11y-modal--md" - | #lg => "a11y-modal--lg" - | #full => "a11y-modal--full" - } - let modalClasses = - ["a11y-modal", sizeClass, className] - ->Array.filter(s => s !== "") - ->Array.join(" ") - - let modal = -
-
-
-

{React.string(title)}

- -
-
{children}
-
-
- - ReactDOM.createPortal(modal, DomFFI.body(DomFFI.document)) - } -}