auditkit 0.1.1 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "auditkit"
3
- version = "0.1.1"
3
+ version = "0.1.4"
4
4
  edition = "2021"
5
5
  description = "Local hybrid CLI for agency website audits."
6
6
  license = "MIT"
@@ -11,7 +11,9 @@ anyhow = "1.0"
11
11
  chrono = { version = "0.4", default-features = false, features = ["clock"] }
12
12
  clap = { version = "4.5", features = ["derive"] }
13
13
  colored = "3.0"
14
+ crossterm = "0.29.0"
14
15
  indicatif = "0.18"
16
+ ratatui = { version = "0.30.0", default-features = false, features = ["crossterm"] }
15
17
  regex = "1.12"
16
18
  reqwest = { version = "0.12", features = ["blocking", "rustls-tls"], default-features = false }
17
19
  serde_json = "1.0"
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Local hybrid CLI for agency website audits.
4
4
 
5
- Rust runs the core workflow: audit folders, quick HTML checks, security checks, and report generation. Node is used only for Lighthouse because Lighthouse is a Node tool.
5
+ Rust runs the core workflow: audit folders, quick HTML checks, security checks, and report generation. Node is used only for Lighthouse.
6
6
 
7
7
  ## Install
8
8
 
@@ -31,9 +31,7 @@ ak report latest
31
31
  `ak new` creates a workspace in `audits/`. Fill in:
32
32
 
33
33
  - `findings.md`
34
- - `scorecard.md`
35
- - `pages/*.md`
36
- - `raw-notes.md`
34
+ - `workspace.md`
37
35
 
38
36
  Then `ak report latest` creates:
39
37
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auditkit",
3
- "version": "0.1.1",
3
+ "version": "0.1.4",
4
4
  "description": "Local hybrid CLI for agency website audits.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,6 +32,7 @@
32
32
  "audit:security": "cargo run --quiet -- security",
33
33
  "audit:lighthouse": "cargo run --quiet -- lighthouse",
34
34
  "audit:inspect": "cargo run --quiet -- inspect",
35
+ "postinstall": "node scripts/auditkit/postinstall.mjs",
35
36
  "test": "npm run test:node && npm run test:rust",
36
37
  "test:node": "node --test",
37
38
  "test:rust": "cargo test"
@@ -1,15 +1,18 @@
1
- #!/usr/bin/env zsh
2
- set -euo pipefail
1
+ #!/usr/bin/env sh
2
+ set -eu
3
3
 
4
4
  SOURCE="$0"
5
5
  while [ -L "$SOURCE" ]; do
6
6
  DIR="$(cd "$(dirname "$SOURCE")" && pwd)"
7
7
  TARGET="$(readlink "$SOURCE")"
8
- if [[ "$TARGET" == /* ]]; then
8
+ case "$TARGET" in
9
+ /*)
9
10
  SOURCE="$TARGET"
10
- else
11
+ ;;
12
+ *)
11
13
  SOURCE="$DIR/$TARGET"
12
- fi
14
+ ;;
15
+ esac
13
16
  done
14
17
 
15
18
  SCRIPT_DIR="$(cd "$(dirname "$SOURCE")" && pwd)"
@@ -25,16 +25,135 @@ const browserCandidates = [
25
25
  "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
26
26
  ];
27
27
 
28
- function section(title) {
29
- return `\n== ${title} ==`;
28
+ const colorEnabled = !process.env.NO_COLOR;
29
+
30
+ function color(code, value) {
31
+ return colorEnabled ? `\x1b[${code}m${value}\x1b[0m` : value;
32
+ }
33
+
34
+ function positive(value) {
35
+ return color("1;32", value);
36
+ }
37
+
38
+ function warning(value) {
39
+ return color("1;33", value);
40
+ }
41
+
42
+ function critical(value) {
43
+ return color("1;31", value);
44
+ }
45
+
46
+ function frame(value) {
47
+ return color("1;36", value);
48
+ }
49
+
50
+ function dim(value) {
51
+ return color("2", value);
52
+ }
53
+
54
+ function toneIcon(tone) {
55
+ if (tone === "positive") {
56
+ return positive("✓");
57
+ }
58
+
59
+ if (tone === "warning") {
60
+ return warning("◆");
61
+ }
62
+
63
+ return critical("●");
64
+ }
65
+
66
+ function scoreTone(score) {
67
+ if (typeof score !== "number") {
68
+ return "warning";
69
+ }
70
+
71
+ if (score >= 85) {
72
+ return "positive";
73
+ }
74
+
75
+ if (score >= 65) {
76
+ return "warning";
77
+ }
78
+
79
+ return "critical";
80
+ }
81
+
82
+ function numericMetric(value) {
83
+ const match = String(value).match(/[0-9]+(?:\.[0-9]+)?/);
84
+ return match ? Number(match[0]) : null;
85
+ }
86
+
87
+ function lcpTone(value) {
88
+ const metric = numericMetric(value);
89
+ if (metric === null) {
90
+ return "warning";
91
+ }
92
+
93
+ if (metric <= 2.5) {
94
+ return "positive";
95
+ }
96
+
97
+ if (metric <= 4) {
98
+ return "warning";
99
+ }
100
+
101
+ return "critical";
30
102
  }
31
103
 
32
- function formatKeyValue(label, value) {
33
- return `${label}: ${value}`;
104
+ function clsTone(value) {
105
+ const metric = numericMetric(value);
106
+ if (metric === null) {
107
+ return "warning";
108
+ }
109
+
110
+ if (metric <= 0.1) {
111
+ return "positive";
112
+ }
113
+
114
+ if (metric <= 0.25) {
115
+ return "warning";
116
+ }
117
+
118
+ return "critical";
34
119
  }
35
120
 
36
- function bullet(value) {
37
- return `- ${value}`;
121
+ function tbtTone(value) {
122
+ const metric = numericMetric(value);
123
+ if (metric === null) {
124
+ return "warning";
125
+ }
126
+
127
+ if (metric <= 200) {
128
+ return "positive";
129
+ }
130
+
131
+ if (metric <= 600) {
132
+ return "warning";
133
+ }
134
+
135
+ return "critical";
136
+ }
137
+
138
+ function toneValue(tone, value) {
139
+ if (tone === "positive") {
140
+ return positive(value);
141
+ }
142
+
143
+ if (tone === "warning") {
144
+ return warning(value);
145
+ }
146
+
147
+ return critical(value);
148
+ }
149
+
150
+ function signalLine(label, value, tone) {
151
+ return ` ${toneIcon(tone)} ${dim(label.padEnd(21))} ${toneValue(tone, value)}`;
152
+ }
153
+
154
+ function feedbackLine(tone, value) {
155
+ const label = tone === "critical" ? critical("FIX") : warning("WATCH");
156
+ return ` ${toneIcon(tone)} ${label} ${toneValue(tone, value)}`;
38
157
  }
39
158
 
40
159
  export function findBrowserPath({ env = process.env, config = {}, exists = existsSync } = {}) {
@@ -92,21 +211,30 @@ export function summarizeLighthouse(lhr) {
92
211
  }
93
212
 
94
213
  export function formatLighthouseCli(summary) {
214
+ const opportunityLines = summary.opportunities.length
215
+ ? summary.opportunities.map((item) =>
216
+ feedbackLine(
217
+ item.score < 50 ? "critical" : "warning",
218
+ `${item.title}${item.displayValue ? ` — ${item.displayValue}` : ""}`,
219
+ ),
220
+ )
221
+ : [signalLine("Opportunities", "No major Lighthouse opportunities found.", "positive")];
222
+
95
223
  return [
96
- section("Lighthouse Check"),
97
- formatKeyValue("URL", summary.url),
98
- formatKeyValue("Performance", `${summary.scores.performance ?? "n/a"}/100`),
99
- formatKeyValue("Accessibility", `${summary.scores.accessibility ?? "n/a"}/100`),
100
- formatKeyValue("Best practices", `${summary.scores.bestPractices ?? "n/a"}/100`),
101
- formatKeyValue("SEO", `${summary.scores.seo ?? "n/a"}/100`),
102
- section("Vitals"),
103
- formatKeyValue("LCP", summary.vitals.lcp),
104
- formatKeyValue("CLS", summary.vitals.cls),
105
- formatKeyValue("TBT", summary.vitals.tbt),
106
- section("Top Opportunities"),
107
- ...(summary.opportunities.length
108
- ? summary.opportunities.map((item) => bullet(`${item.title}${item.displayValue ? ` — ${item.displayValue}` : ""}`))
109
- : [bullet("No major Lighthouse opportunities found.")]),
224
+ "",
225
+ frame("╭─ Lighthouse Check"),
226
+ `${frame("│ URL ")} ${summary.url}`,
227
+ frame("╰─ Signals"),
228
+ signalLine("Performance", `${summary.scores.performance ?? "n/a"}/100`, scoreTone(summary.scores.performance)),
229
+ signalLine("Accessibility", `${summary.scores.accessibility ?? "n/a"}/100`, scoreTone(summary.scores.accessibility)),
230
+ signalLine("Best practices", `${summary.scores.bestPractices ?? "n/a"}/100`, scoreTone(summary.scores.bestPractices)),
231
+ signalLine("SEO", `${summary.scores.seo ?? "n/a"}/100`, scoreTone(summary.scores.seo)),
232
+ signalLine("LCP", summary.vitals.lcp, lcpTone(summary.vitals.lcp)),
233
+ signalLine("CLS", summary.vitals.cls, clsTone(summary.vitals.cls)),
234
+ signalLine("TBT", summary.vitals.tbt, tbtTone(summary.vitals.tbt)),
235
+ "",
236
+ frame("Feedback"),
237
+ ...opportunityLines,
110
238
  ].join("\n");
111
239
  }
112
240
 
@@ -5,6 +5,7 @@ import {
5
5
  formatLighthouseReport,
6
6
  summarizeLighthouse,
7
7
  } from "./lighthouse-runner.mjs";
8
+ import { shouldShowWelcome, welcomeMessage } from "./postinstall.mjs";
8
9
 
9
10
  const lhr = {
10
11
  finalDisplayedUrl: "https://example.com/",
@@ -76,3 +77,19 @@ test("findBrowserPath falls back to Helium app path", () => {
76
77
  "/Applications/Helium.app/Contents/MacOS/Helium",
77
78
  );
78
79
  });
80
+
81
+ test("postinstall welcome explains first commands", () => {
82
+ const message = welcomeMessage();
83
+ const plainMessage = welcomeMessage({ NO_COLOR: "1" });
84
+
85
+ assert.match(message, /Audit Kit/);
86
+ assert.match(message, /ak new/);
87
+ assert.match(message, /ak inspect latest/);
88
+ assert.match(message, /\x1b\[/);
89
+ assert.doesNotMatch(plainMessage, /\x1b\[/);
90
+ assert.match(plainMessage, /Audit Kit/);
91
+ assert.equal(shouldShowWelcome({}), true);
92
+ assert.equal(shouldShowWelcome({ CI: "true" }), false);
93
+ assert.equal(shouldShowWelcome({ AUDITKIT_SKIP_WELCOME: "1" }), false);
94
+ assert.equal(shouldShowWelcome({ npm_config_loglevel: "silent" }), false);
95
+ });
@@ -0,0 +1,71 @@
1
+ import { pathToFileURL } from "node:url";
2
+
3
+ export function shouldShowWelcome(env = process.env) {
4
+ return !env.CI && !env.AUDITKIT_SKIP_WELCOME && env.npm_config_loglevel !== "silent";
5
+ }
6
+
7
+ export function welcomeMessage(env = process.env) {
8
+ const colors = !env.NO_COLOR;
9
+ const rows = [
10
+ line(" ", colors),
11
+ line(` ${paint("Audit Kit", 96, colors)} `, colors),
12
+ line(` ${paint("Local website audits from your terminal.", 90, colors)} `, colors),
13
+ line(" ", colors),
14
+ divider(colors),
15
+ line(` ${paint("Try first", 97, colors)} `, colors),
16
+ line(" ", colors),
17
+ commandLine("ak new", "create an audit workspace", colors),
18
+ commandLine("ak inspect latest", "run check, security, Lighthouse", colors),
19
+ commandLine("ak report latest", "generate report + client email", colors),
20
+ commandLine("ak list", "show saved audits", colors),
21
+ line(" ", colors),
22
+ ];
23
+
24
+ return [
25
+ "",
26
+ glow(" · ✦ · · · ✧ · · · ✦ ·", colors),
27
+ `${corner("╭", colors)}${gradient("────────────────────────────────────────────────────────────", colors)}${corner("╮", colors)}`,
28
+ ...rows,
29
+ `${corner("╰", colors)}${gradient("────────────────────────────────────────────────────────────", colors)}${corner("╯", colors)}`,
30
+ glow(" · · ✧ · · · ✦ · ·", colors),
31
+ ].join("\n");
32
+ }
33
+
34
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href && shouldShowWelcome()) {
35
+ console.log(welcomeMessage());
36
+ }
37
+
38
+ function commandLine(command, description, colors) {
39
+ return line(` ${paint(command.padEnd(18), 36, colors)} ${paint(description.padEnd(34), 90, colors)} `, colors);
40
+ }
41
+
42
+ function line(content, colors) {
43
+ return `${border("│", colors)}${content}${border("│", colors)}`;
44
+ }
45
+
46
+ function divider(colors) {
47
+ return `${border("├", colors)}${gradient("────────────────────────────────────────────────────────────", colors)}${border("┤", colors)}`;
48
+ }
49
+
50
+ function gradient(value, colors) {
51
+ const palette = [36, 96, 35, 93, 95, 94];
52
+ return [...value]
53
+ .map((character, index) => paint(character, palette[index % palette.length], colors))
54
+ .join("");
55
+ }
56
+
57
+ function border(value, colors) {
58
+ return paint(value, 96, colors);
59
+ }
60
+
61
+ function corner(value, colors) {
62
+ return paint(value, 95, colors);
63
+ }
64
+
65
+ function glow(value, colors) {
66
+ return paint(value, 90, colors);
67
+ }
68
+
69
+ function paint(value, code, colors) {
70
+ return colors ? `\x1b[${code}m${value}\x1b[0m` : value;
71
+ }
package/src/html_check.rs CHANGED
@@ -1,7 +1,7 @@
1
1
  use anyhow::Result;
2
2
  use regex::Regex;
3
3
 
4
- use crate::ui::score_status;
4
+ use crate::ui::{self, score_status, FeedbackTone};
5
5
 
6
6
  #[derive(Debug, Clone, PartialEq, Eq)]
7
7
  pub struct HtmlCheck {
@@ -201,13 +201,65 @@ pub fn check_url(input_url: &str) -> Result<HtmlCheck> {
201
201
 
202
202
  pub fn format_cli(result: &HtmlCheck) -> String {
203
203
  let mut output = format!(
204
- "\n== Audit Kit Check ==\nURL: {}\nScore: {}/100 ({})\n\n== Signals ==\nStatus: {}\nResponse: {} bytes\nTitle: {}\nMeta description: {}\nH1 count: {}\nImages missing alt: {}/{}\n\n== Feedback ==\n",
204
+ "\n{}\n{} {}\n{} {}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n\n{}\n",
205
+ ui::frame_line("╭─ Audit Kit Check"),
206
+ ui::frame_line("│ URL "),
207
+ result.url,
208
+ ui::frame_line("│ Score"),
209
+ ui::score_badge(result.score),
210
+ ui::frame_line("╰─ Signals"),
211
+ ui::signal_line("Status", result.status, status_tone(result.status)),
212
+ ui::signal_line(
213
+ "Response",
214
+ format!("{} bytes", result.bytes),
215
+ response_tone(result.bytes)
216
+ ),
217
+ ui::signal_line("Title", title_value(result), title_tone(result)),
218
+ ui::signal_line(
219
+ "Meta description",
220
+ if result.meta_description_present {
221
+ "present"
222
+ } else {
223
+ "missing"
224
+ },
225
+ if result.meta_description_present {
226
+ FeedbackTone::Positive
227
+ } else {
228
+ FeedbackTone::Critical
229
+ }
230
+ ),
231
+ ui::signal_line("H1 count", result.h1_count, h1_tone(result.h1_count)),
232
+ ui::signal_line(
233
+ "Images missing alt",
234
+ format!("{}/{}", result.image_missing_alt, result.image_total),
235
+ if result.image_missing_alt == 0 {
236
+ FeedbackTone::Positive
237
+ } else {
238
+ FeedbackTone::Critical
239
+ }
240
+ ),
241
+ ui::frame_line("Feedback")
242
+ );
243
+
244
+ for item in &result.feedback {
245
+ output.push_str(&format!(
246
+ "{}\n",
247
+ ui::feedback_line(feedback_tone(item), item)
248
+ ));
249
+ }
250
+
251
+ output
252
+ }
253
+
254
+ pub fn format_markdown(result: &HtmlCheck) -> String {
255
+ let mut output = format!(
256
+ "# Automated Check\n\nURL: {}\nScore: {}/100 ({})\n\n## Signals\n\n- Status: {}\n- Response: {} bytes\n- Title: {}\n- Meta description: {}\n- H1 count: {}\n- Images missing alt: {}/{}\n\n## Feedback\n\n",
205
257
  result.url,
206
258
  result.score,
207
259
  score_status(result.score),
208
260
  result.status,
209
261
  result.bytes,
210
- if result.title.is_empty() { "missing" } else { &result.title },
262
+ title_value(result),
211
263
  if result.meta_description_present { "present" } else { "missing" },
212
264
  result.h1_count,
213
265
  result.image_missing_alt,
@@ -221,8 +273,64 @@ pub fn format_cli(result: &HtmlCheck) -> String {
221
273
  output
222
274
  }
223
275
 
224
- pub fn format_markdown(result: &HtmlCheck) -> String {
225
- format!("# Automated Check\n\n{}\n", format_cli(result))
276
+ fn title_value(result: &HtmlCheck) -> &str {
277
+ if result.title.is_empty() {
278
+ "missing"
279
+ } else {
280
+ &result.title
281
+ }
282
+ }
283
+
284
+ fn status_tone(status: u16) -> FeedbackTone {
285
+ if (200..400).contains(&status) {
286
+ FeedbackTone::Positive
287
+ } else {
288
+ FeedbackTone::Critical
289
+ }
290
+ }
291
+
292
+ fn response_tone(bytes: usize) -> FeedbackTone {
293
+ if bytes > 200_000 {
294
+ FeedbackTone::Warning
295
+ } else {
296
+ FeedbackTone::Positive
297
+ }
298
+ }
299
+
300
+ fn title_tone(result: &HtmlCheck) -> FeedbackTone {
301
+ if result.title.is_empty() {
302
+ FeedbackTone::Critical
303
+ } else if result.title.len() < 10 || result.title.len() > 65 {
304
+ FeedbackTone::Warning
305
+ } else {
306
+ FeedbackTone::Positive
307
+ }
308
+ }
309
+
310
+ fn h1_tone(count: usize) -> FeedbackTone {
311
+ match count {
312
+ 1 => FeedbackTone::Positive,
313
+ 0 => FeedbackTone::Critical,
314
+ _ => FeedbackTone::Warning,
315
+ }
316
+ }
317
+
318
+ fn feedback_tone(value: &str) -> FeedbackTone {
319
+ let value = value.to_lowercase();
320
+ if value.starts_with("missing")
321
+ || value.starts_with("no obvious")
322
+ || value.contains("missing alt")
323
+ {
324
+ FeedbackTone::Critical
325
+ } else if value.starts_with("no canonical")
326
+ || value.starts_with("title length")
327
+ || value.starts_with("multiple")
328
+ || value.contains("large")
329
+ {
330
+ FeedbackTone::Warning
331
+ } else {
332
+ FeedbackTone::Positive
333
+ }
226
334
  }
227
335
 
228
336
  #[cfg(test)]
package/src/lighthouse.rs CHANGED
@@ -7,6 +7,7 @@ use anyhow::{Context, Result};
7
7
  pub struct LighthousePaths {
8
8
  pub markdown_path: PathBuf,
9
9
  pub json_path: PathBuf,
10
+ pub cli_output: String,
10
11
  }
11
12
 
12
13
  pub fn run_lighthouse(
@@ -36,14 +37,16 @@ pub fn run_lighthouse(
36
37
  .map(PathBuf::from)
37
38
  .unwrap_or_else(|| output_folder.unwrap_or(root).join("lighthouse.json"));
38
39
 
39
- for line in stdout.lines() {
40
- if !line.contains("LIGHTHOUSE_MD:") && !line.contains("LIGHTHOUSE_JSON:") {
41
- println!("{line}");
42
- }
43
- }
40
+ let cli_output = stdout
41
+ .lines()
42
+ .filter(|line| !line.contains("LIGHTHOUSE_MD:") && !line.contains("LIGHTHOUSE_JSON:"))
43
+ .collect::<Vec<_>>()
44
+ .join("\n");
45
+
44
46
  Ok(LighthousePaths {
45
47
  markdown_path,
46
48
  json_path,
49
+ cli_output,
47
50
  })
48
51
  }
49
52