auditkit 0.1.0
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.lock +1992 -0
- package/Cargo.toml +18 -0
- package/README.md +153 -0
- package/auditkit.config.json +10 -0
- package/docs/ARCHITECTURE.md +44 -0
- package/package.json +46 -0
- package/scripts/auditkit/ak +22 -0
- package/scripts/auditkit/lighthouse-runner.mjs +183 -0
- package/scripts/auditkit/lighthouse.test.mjs +78 -0
- package/scripts/auditkit/postinstall.mjs +31 -0
- package/src/audit.rs +59 -0
- package/src/html_check.rs +240 -0
- package/src/lib.rs +21 -0
- package/src/lighthouse.rs +71 -0
- package/src/main.rs +261 -0
- package/src/report.rs +250 -0
- package/src/security.rs +193 -0
- package/src/templates.rs +197 -0
- package/src/ui.rs +84 -0
- package/src/workspace.rs +111 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
use anyhow::Result;
|
|
2
|
+
use regex::Regex;
|
|
3
|
+
|
|
4
|
+
use crate::ui::score_status;
|
|
5
|
+
|
|
6
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
7
|
+
pub struct HtmlCheck {
|
|
8
|
+
pub url: String,
|
|
9
|
+
pub score: i32,
|
|
10
|
+
pub status: u16,
|
|
11
|
+
pub bytes: usize,
|
|
12
|
+
pub title: String,
|
|
13
|
+
pub meta_description_present: bool,
|
|
14
|
+
pub h1_count: usize,
|
|
15
|
+
pub image_total: usize,
|
|
16
|
+
pub image_missing_alt: usize,
|
|
17
|
+
pub feedback: Vec<String>,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
fn normalise_url(value: &str) -> String {
|
|
21
|
+
if value.starts_with("http://") || value.starts_with("https://") {
|
|
22
|
+
value.to_string()
|
|
23
|
+
} else {
|
|
24
|
+
format!("https://{value}")
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
fn strip_tags(value: &str) -> String {
|
|
29
|
+
Regex::new(r"<[^>]*>")
|
|
30
|
+
.expect("valid regex")
|
|
31
|
+
.replace_all(value, " ")
|
|
32
|
+
.split_whitespace()
|
|
33
|
+
.collect::<Vec<_>>()
|
|
34
|
+
.join(" ")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
fn first_capture(html: &str, pattern: &str) -> String {
|
|
38
|
+
Regex::new(pattern)
|
|
39
|
+
.expect("valid regex")
|
|
40
|
+
.captures(html)
|
|
41
|
+
.and_then(|captures| captures.get(1))
|
|
42
|
+
.map(|match_value| strip_tags(match_value.as_str().trim()))
|
|
43
|
+
.unwrap_or_default()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
fn contains_cta(html: &str) -> bool {
|
|
47
|
+
let text = strip_tags(html).to_lowercase();
|
|
48
|
+
[
|
|
49
|
+
"book",
|
|
50
|
+
"buy",
|
|
51
|
+
"call",
|
|
52
|
+
"contact",
|
|
53
|
+
"demo",
|
|
54
|
+
"enquire",
|
|
55
|
+
"get started",
|
|
56
|
+
"quote",
|
|
57
|
+
"schedule",
|
|
58
|
+
"start",
|
|
59
|
+
]
|
|
60
|
+
.iter()
|
|
61
|
+
.any(|word| text.contains(word))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
fn tag_has_attribute(html: &str, tag_pattern: &str, required_parts: &[&str]) -> bool {
|
|
65
|
+
let tag_regex = Regex::new(tag_pattern).expect("valid regex");
|
|
66
|
+
let found = tag_regex.find_iter(html).any(|tag| {
|
|
67
|
+
let lower = tag.as_str().to_lowercase();
|
|
68
|
+
required_parts.iter().all(|part| lower.contains(part))
|
|
69
|
+
});
|
|
70
|
+
found
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
pub fn analyze_html(url: &str, html: &str, status: u16, bytes: usize) -> HtmlCheck {
|
|
74
|
+
let title = first_capture(html, r"(?is)<title\b[^>]*>(.*?)</title>");
|
|
75
|
+
let meta_description_present = tag_has_attribute(
|
|
76
|
+
html,
|
|
77
|
+
r#"(?is)<meta\b[^>]*>"#,
|
|
78
|
+
&["name=\"description\"", "content="],
|
|
79
|
+
) || tag_has_attribute(
|
|
80
|
+
html,
|
|
81
|
+
r#"(?is)<meta\b[^>]*>"#,
|
|
82
|
+
&["name='description'", "content="],
|
|
83
|
+
);
|
|
84
|
+
let h1_count = Regex::new(r"(?is)<h1\b[^>]*>.*?</h1>")
|
|
85
|
+
.expect("valid regex")
|
|
86
|
+
.find_iter(html)
|
|
87
|
+
.count();
|
|
88
|
+
let images = Regex::new(r#"(?is)<img\b[^>]*>"#).expect("valid regex");
|
|
89
|
+
let image_total = images.find_iter(html).count();
|
|
90
|
+
let image_missing_alt = images
|
|
91
|
+
.find_iter(html)
|
|
92
|
+
.filter(|image| {
|
|
93
|
+
!Regex::new(r#"\balt\s*=\s*["'][^"']+["']"#)
|
|
94
|
+
.expect("valid regex")
|
|
95
|
+
.is_match(image.as_str())
|
|
96
|
+
})
|
|
97
|
+
.count();
|
|
98
|
+
let has_viewport = tag_has_attribute(html, r#"(?is)<meta\b[^>]*>"#, &["name=\"viewport\""])
|
|
99
|
+
|| tag_has_attribute(html, r#"(?is)<meta\b[^>]*>"#, &["name='viewport'"]);
|
|
100
|
+
let has_canonical = tag_has_attribute(html, r#"(?is)<link\b[^>]*>"#, &["rel=\"canonical\""])
|
|
101
|
+
|| tag_has_attribute(html, r#"(?is)<link\b[^>]*>"#, &["rel='canonical'"]);
|
|
102
|
+
let has_cta = contains_cta(html);
|
|
103
|
+
|
|
104
|
+
let mut score = 100;
|
|
105
|
+
let mut feedback = Vec::new();
|
|
106
|
+
|
|
107
|
+
if title.is_empty() {
|
|
108
|
+
score -= 15;
|
|
109
|
+
feedback.push(
|
|
110
|
+
"Missing title tag. Add a clear page title for search results and browser tabs."
|
|
111
|
+
.to_string(),
|
|
112
|
+
);
|
|
113
|
+
} else if title.len() < 10 || title.len() > 65 {
|
|
114
|
+
score -= 8;
|
|
115
|
+
feedback.push(format!(
|
|
116
|
+
"Title length is {}. Aim for roughly 10-65 characters.",
|
|
117
|
+
title.len()
|
|
118
|
+
));
|
|
119
|
+
} else {
|
|
120
|
+
feedback.push(format!("Title found: \"{title}\"."));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if !meta_description_present {
|
|
124
|
+
score -= 12;
|
|
125
|
+
feedback.push(
|
|
126
|
+
"Missing meta description. Add a concise summary for search snippets.".to_string(),
|
|
127
|
+
);
|
|
128
|
+
} else {
|
|
129
|
+
feedback.push("Meta description found.".to_string());
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if h1_count == 0 {
|
|
133
|
+
score -= 12;
|
|
134
|
+
feedback.push("Missing H1. Add one clear primary heading.".to_string());
|
|
135
|
+
} else if h1_count > 1 {
|
|
136
|
+
score -= 8;
|
|
137
|
+
feedback.push(format!(
|
|
138
|
+
"Multiple H1 tags found ({h1_count}). Keep one primary page heading."
|
|
139
|
+
));
|
|
140
|
+
} else {
|
|
141
|
+
feedback.push("Single H1 found.".to_string());
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if image_missing_alt > 0 {
|
|
145
|
+
score -= (image_missing_alt as i32 * 8).min(15);
|
|
146
|
+
feedback.push(format!("{image_missing_alt} image(s) missing alt text."));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if !has_viewport {
|
|
150
|
+
score -= 10;
|
|
151
|
+
feedback.push("Missing viewport meta tag. Mobile layout may render poorly.".to_string());
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if !has_canonical {
|
|
155
|
+
score -= 4;
|
|
156
|
+
feedback
|
|
157
|
+
.push("No canonical link found. Add one if duplicate URLs are possible.".to_string());
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if !has_cta {
|
|
161
|
+
score -= 10;
|
|
162
|
+
feedback.push("No obvious CTA language found. Make the next action clear.".to_string());
|
|
163
|
+
} else {
|
|
164
|
+
feedback.push("Primary CTA found.".to_string());
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if bytes > 200_000 {
|
|
168
|
+
score -= 7;
|
|
169
|
+
feedback.push(
|
|
170
|
+
"HTML response is large. Check unnecessary markup, scripts, or inline payloads."
|
|
171
|
+
.to_string(),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
HtmlCheck {
|
|
176
|
+
url: url.to_string(),
|
|
177
|
+
score: score.max(0),
|
|
178
|
+
status,
|
|
179
|
+
bytes,
|
|
180
|
+
title,
|
|
181
|
+
meta_description_present,
|
|
182
|
+
h1_count,
|
|
183
|
+
image_total,
|
|
184
|
+
image_missing_alt,
|
|
185
|
+
feedback,
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
pub fn check_url(input_url: &str) -> Result<HtmlCheck> {
|
|
190
|
+
let url = normalise_url(input_url);
|
|
191
|
+
let response = reqwest::blocking::Client::new()
|
|
192
|
+
.get(&url)
|
|
193
|
+
.header("user-agent", "AuditKit/0.1")
|
|
194
|
+
.header("accept", "text/html,application/xhtml+xml")
|
|
195
|
+
.send()?;
|
|
196
|
+
let final_url = response.url().to_string();
|
|
197
|
+
let status = response.status().as_u16();
|
|
198
|
+
let html = response.text()?;
|
|
199
|
+
Ok(analyze_html(&final_url, &html, status, html.len()))
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
pub fn format_cli(result: &HtmlCheck) -> String {
|
|
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",
|
|
205
|
+
result.url,
|
|
206
|
+
result.score,
|
|
207
|
+
score_status(result.score),
|
|
208
|
+
result.status,
|
|
209
|
+
result.bytes,
|
|
210
|
+
if result.title.is_empty() { "missing" } else { &result.title },
|
|
211
|
+
if result.meta_description_present { "present" } else { "missing" },
|
|
212
|
+
result.h1_count,
|
|
213
|
+
result.image_missing_alt,
|
|
214
|
+
result.image_total
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
for item in &result.feedback {
|
|
218
|
+
output.push_str(&format!("- {item}\n"));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
output
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
pub fn format_markdown(result: &HtmlCheck) -> String {
|
|
225
|
+
format!("# Automated Check\n\n{}\n", format_cli(result))
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
#[cfg(test)]
|
|
229
|
+
mod tests {
|
|
230
|
+
use super::*;
|
|
231
|
+
|
|
232
|
+
#[test]
|
|
233
|
+
fn analyze_html_finds_useful_feedback() {
|
|
234
|
+
let html = r#"<title>Acme Dental</title><meta name="description" content="Dental care"><h1>Care</h1><a>Book now</a><img src="/x.jpg">"#;
|
|
235
|
+
let result = analyze_html("https://example.com", html, 200, html.len());
|
|
236
|
+
assert_eq!(result.score, 78);
|
|
237
|
+
assert_eq!(result.image_missing_alt, 1);
|
|
238
|
+
assert!(result.feedback.join("\n").contains("Primary CTA found"));
|
|
239
|
+
}
|
|
240
|
+
}
|
package/src/lib.rs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
//! Audit Kit library modules.
|
|
2
|
+
//!
|
|
3
|
+
//! The binary in `main.rs` wires these modules together. Each module owns one
|
|
4
|
+
//! small part of the workflow, so a Rust beginner can read one file at a time.
|
|
5
|
+
|
|
6
|
+
/// Shared audit input helpers such as slug creation and comma-list parsing.
|
|
7
|
+
pub mod audit;
|
|
8
|
+
/// Lightweight HTML checks: title, meta description, H1, images, CTA signals.
|
|
9
|
+
pub mod html_check;
|
|
10
|
+
/// Bridge from Rust to the Node Lighthouse helper.
|
|
11
|
+
pub mod lighthouse;
|
|
12
|
+
/// Final report and client email generation.
|
|
13
|
+
pub mod report;
|
|
14
|
+
/// Security header checks.
|
|
15
|
+
pub mod security;
|
|
16
|
+
/// Markdown templates used when creating a new audit workspace.
|
|
17
|
+
pub mod templates;
|
|
18
|
+
/// Terminal output helpers.
|
|
19
|
+
pub mod ui;
|
|
20
|
+
/// File paths, audit folder lookup, and read/write helpers.
|
|
21
|
+
pub mod workspace;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
use std::path::{Path, PathBuf};
|
|
2
|
+
use std::process::Command;
|
|
3
|
+
|
|
4
|
+
use anyhow::{Context, Result};
|
|
5
|
+
|
|
6
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
7
|
+
pub struct LighthousePaths {
|
|
8
|
+
pub markdown_path: PathBuf,
|
|
9
|
+
pub json_path: PathBuf,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
pub fn run_lighthouse(
|
|
13
|
+
root: &Path,
|
|
14
|
+
url: &str,
|
|
15
|
+
output_folder: Option<&Path>,
|
|
16
|
+
) -> Result<LighthousePaths> {
|
|
17
|
+
let script = root.join("scripts/lighthouse.mjs");
|
|
18
|
+
let mut command = Command::new("node");
|
|
19
|
+
command.arg(script).arg(url);
|
|
20
|
+
|
|
21
|
+
if let Some(folder) = output_folder {
|
|
22
|
+
command.arg("--out").arg(folder);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let output = command.output().context("Running Node Lighthouse helper")?;
|
|
26
|
+
if !output.status.success() {
|
|
27
|
+
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
28
|
+
anyhow::bail!("{}", stderr.trim());
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
32
|
+
let markdown_path = find_output_path(&stdout, "LIGHTHOUSE_MD:")
|
|
33
|
+
.map(PathBuf::from)
|
|
34
|
+
.unwrap_or_else(|| output_folder.unwrap_or(root).join("lighthouse.md"));
|
|
35
|
+
let json_path = find_output_path(&stdout, "LIGHTHOUSE_JSON:")
|
|
36
|
+
.map(PathBuf::from)
|
|
37
|
+
.unwrap_or_else(|| output_folder.unwrap_or(root).join("lighthouse.json"));
|
|
38
|
+
|
|
39
|
+
for line in stdout.lines() {
|
|
40
|
+
if !line.contains("LIGHTHOUSE_MD:") && !line.contains("LIGHTHOUSE_JSON:") {
|
|
41
|
+
println!("{line}");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
Ok(LighthousePaths {
|
|
45
|
+
markdown_path,
|
|
46
|
+
json_path,
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
fn find_output_path(stdout: &str, prefix: &str) -> Option<String> {
|
|
51
|
+
stdout
|
|
52
|
+
.lines()
|
|
53
|
+
.find_map(|line| line.strip_prefix(prefix))
|
|
54
|
+
.map(str::trim)
|
|
55
|
+
.map(ToOwned::to_owned)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#[cfg(test)]
|
|
59
|
+
mod tests {
|
|
60
|
+
use super::*;
|
|
61
|
+
|
|
62
|
+
#[test]
|
|
63
|
+
fn parses_helper_output_paths() {
|
|
64
|
+
let stdout =
|
|
65
|
+
"ok\nLIGHTHOUSE_MD: /tmp/lighthouse.md\nLIGHTHOUSE_JSON: /tmp/lighthouse.json\n";
|
|
66
|
+
assert_eq!(
|
|
67
|
+
find_output_path(stdout, "LIGHTHOUSE_MD:"),
|
|
68
|
+
Some("/tmp/lighthouse.md".to_string())
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
package/src/main.rs
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
use std::io::{self, Write};
|
|
2
|
+
|
|
3
|
+
use anyhow::{Context, Result};
|
|
4
|
+
use auditkit::audit::{slugify, split_comma_list, AuditInput};
|
|
5
|
+
use auditkit::html_check;
|
|
6
|
+
use auditkit::lighthouse;
|
|
7
|
+
use auditkit::report;
|
|
8
|
+
use auditkit::security;
|
|
9
|
+
use auditkit::ui;
|
|
10
|
+
use auditkit::workspace::Workspace;
|
|
11
|
+
use chrono::Local;
|
|
12
|
+
use clap::{Parser, Subcommand};
|
|
13
|
+
|
|
14
|
+
#[derive(Parser)]
|
|
15
|
+
#[command(
|
|
16
|
+
name = "auditkit",
|
|
17
|
+
version,
|
|
18
|
+
about = "Audit Kit: small agency website audit workflow"
|
|
19
|
+
)]
|
|
20
|
+
struct Cli {
|
|
21
|
+
#[command(subcommand)]
|
|
22
|
+
command: Option<Command>,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#[derive(Subcommand)]
|
|
26
|
+
enum Command {
|
|
27
|
+
New,
|
|
28
|
+
List,
|
|
29
|
+
Check {
|
|
30
|
+
target: String,
|
|
31
|
+
#[arg(long)]
|
|
32
|
+
save: Option<String>,
|
|
33
|
+
},
|
|
34
|
+
Security {
|
|
35
|
+
target: Option<String>,
|
|
36
|
+
#[arg(long)]
|
|
37
|
+
save: Option<String>,
|
|
38
|
+
},
|
|
39
|
+
Lighthouse {
|
|
40
|
+
target: Option<String>,
|
|
41
|
+
#[arg(long)]
|
|
42
|
+
save: Option<String>,
|
|
43
|
+
},
|
|
44
|
+
Inspect {
|
|
45
|
+
target: Option<String>,
|
|
46
|
+
},
|
|
47
|
+
Report {
|
|
48
|
+
target: Option<String>,
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
fn main() {
|
|
53
|
+
if let Err(error) = run() {
|
|
54
|
+
ui::error(error);
|
|
55
|
+
std::process::exit(1);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
fn run() -> Result<()> {
|
|
60
|
+
let cli = Cli::parse();
|
|
61
|
+
let workspace = Workspace::discover()?;
|
|
62
|
+
|
|
63
|
+
match cli.command {
|
|
64
|
+
Some(Command::New) => new_audit(&workspace),
|
|
65
|
+
Some(Command::List) => list_audits(&workspace),
|
|
66
|
+
Some(Command::Check { target, save }) => check(&workspace, &target, save.as_deref()),
|
|
67
|
+
Some(Command::Security { target, save }) => {
|
|
68
|
+
security_check(&workspace, target.as_deref(), save.as_deref())
|
|
69
|
+
}
|
|
70
|
+
Some(Command::Lighthouse { target, save }) => {
|
|
71
|
+
lighthouse_check(&workspace, target.as_deref(), save.as_deref())
|
|
72
|
+
}
|
|
73
|
+
Some(Command::Inspect { target }) => inspect(&workspace, target.as_deref()),
|
|
74
|
+
Some(Command::Report { target }) => generate_report(&workspace, target.as_deref()),
|
|
75
|
+
None => {
|
|
76
|
+
ui::help();
|
|
77
|
+
Ok(())
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
fn new_audit(workspace: &Workspace) -> Result<()> {
|
|
83
|
+
ui::section("New Audit");
|
|
84
|
+
let client_name = prompt("Client name ")?;
|
|
85
|
+
let url = prompt("Website URL ")?;
|
|
86
|
+
let business_type = prompt("Business type ")?;
|
|
87
|
+
let goal = prompt("Primary goal ")?;
|
|
88
|
+
let target_customer = prompt("Target customer ")?;
|
|
89
|
+
let conversion_action = prompt("Main conversion action ")?;
|
|
90
|
+
let pages = prompt("Pages, comma-separated ")?;
|
|
91
|
+
let known_concerns = prompt("Known concerns ")?;
|
|
92
|
+
let competitors = prompt("Competitors ")?;
|
|
93
|
+
|
|
94
|
+
let audit = AuditInput {
|
|
95
|
+
slug: slugify(&client_name),
|
|
96
|
+
client_name,
|
|
97
|
+
url,
|
|
98
|
+
business_type,
|
|
99
|
+
goal,
|
|
100
|
+
target_customer,
|
|
101
|
+
conversion_action,
|
|
102
|
+
pages: split_comma_list(&pages),
|
|
103
|
+
known_concerns: split_comma_list(&known_concerns),
|
|
104
|
+
competitors: split_comma_list(&competitors),
|
|
105
|
+
created_at: Local::now().format("%Y-%m-%d").to_string(),
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
let folder = workspace.create_audit(&audit)?;
|
|
109
|
+
ui::section("Audit Created");
|
|
110
|
+
ui::saved(folder.display());
|
|
111
|
+
ui::bullet("Next: ak inspect latest");
|
|
112
|
+
ui::bullet("Then fill findings.md, scorecard.md, and pages/*.md");
|
|
113
|
+
Ok(())
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
fn list_audits(workspace: &Workspace) -> Result<()> {
|
|
117
|
+
ui::section("Audits");
|
|
118
|
+
let folders = workspace.list_audits()?;
|
|
119
|
+
if folders.is_empty() {
|
|
120
|
+
println!("No audits yet.");
|
|
121
|
+
} else {
|
|
122
|
+
for folder in folders {
|
|
123
|
+
ui::bullet(&folder);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
Ok(())
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
fn check(workspace: &Workspace, target: &str, save: Option<&str>) -> Result<()> {
|
|
130
|
+
if looks_like_url(target) {
|
|
131
|
+
let result = ui::with_task("Fetching website and reading HTML", || {
|
|
132
|
+
html_check::check_url(target)
|
|
133
|
+
})?;
|
|
134
|
+
println!("{}", html_check::format_cli(&result));
|
|
135
|
+
if let Some(folder) = save {
|
|
136
|
+
let folder = workspace.resolve_target(Some(folder))?;
|
|
137
|
+
let path = workspace.write_audit_file(
|
|
138
|
+
&folder,
|
|
139
|
+
"automated-check.md",
|
|
140
|
+
&html_check::format_markdown(&result),
|
|
141
|
+
)?;
|
|
142
|
+
ui::saved(path.display());
|
|
143
|
+
}
|
|
144
|
+
return Ok(());
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let folder = workspace.resolve_target(Some(target))?;
|
|
148
|
+
let website = audit_website(workspace, &folder)?;
|
|
149
|
+
let result = ui::with_task("Fetching website and reading HTML", || {
|
|
150
|
+
html_check::check_url(&website)
|
|
151
|
+
})?;
|
|
152
|
+
println!("{}", html_check::format_cli(&result));
|
|
153
|
+
let path = workspace.write_audit_file(
|
|
154
|
+
&folder,
|
|
155
|
+
"automated-check.md",
|
|
156
|
+
&html_check::format_markdown(&result),
|
|
157
|
+
)?;
|
|
158
|
+
ui::saved(path.display());
|
|
159
|
+
Ok(())
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
fn security_check(workspace: &Workspace, target: Option<&str>, save: Option<&str>) -> Result<()> {
|
|
163
|
+
let target = target.unwrap_or("latest");
|
|
164
|
+
|
|
165
|
+
if looks_like_url(target) {
|
|
166
|
+
let result = ui::with_task("Checking security headers", || security::check_url(target))?;
|
|
167
|
+
println!("{}", security::format_cli(&result));
|
|
168
|
+
if let Some(folder) = save {
|
|
169
|
+
let folder = workspace.resolve_target(Some(folder))?;
|
|
170
|
+
let path = workspace.write_audit_file(
|
|
171
|
+
&folder,
|
|
172
|
+
"security.md",
|
|
173
|
+
&security::format_markdown(&result),
|
|
174
|
+
)?;
|
|
175
|
+
ui::saved(path.display());
|
|
176
|
+
}
|
|
177
|
+
return Ok(());
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let folder = workspace.resolve_target(Some(target))?;
|
|
181
|
+
let website = audit_website(workspace, &folder)?;
|
|
182
|
+
let result = ui::with_task("Checking security headers", || {
|
|
183
|
+
security::check_url(&website)
|
|
184
|
+
})?;
|
|
185
|
+
println!("{}", security::format_cli(&result));
|
|
186
|
+
let path =
|
|
187
|
+
workspace.write_audit_file(&folder, "security.md", &security::format_markdown(&result))?;
|
|
188
|
+
ui::saved(path.display());
|
|
189
|
+
Ok(())
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
fn lighthouse_check(workspace: &Workspace, target: Option<&str>, save: Option<&str>) -> Result<()> {
|
|
193
|
+
let target = target.unwrap_or("latest");
|
|
194
|
+
|
|
195
|
+
if looks_like_url(target) {
|
|
196
|
+
let output_folder = match save {
|
|
197
|
+
Some(folder) => Some(workspace.audit_folder(&workspace.resolve_target(Some(folder))?)),
|
|
198
|
+
None => None,
|
|
199
|
+
};
|
|
200
|
+
let paths = ui::with_task("Running Lighthouse in Helium", || {
|
|
201
|
+
lighthouse::run_lighthouse(&workspace.root, target, output_folder.as_deref())
|
|
202
|
+
})?;
|
|
203
|
+
ui::saved(paths.markdown_path.display());
|
|
204
|
+
ui::saved(paths.json_path.display());
|
|
205
|
+
return Ok(());
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let folder = workspace.resolve_target(Some(target))?;
|
|
209
|
+
let website = audit_website(workspace, &folder)?;
|
|
210
|
+
let audit_folder = workspace.audit_folder(&folder);
|
|
211
|
+
let paths = ui::with_task("Running Lighthouse in Helium", || {
|
|
212
|
+
lighthouse::run_lighthouse(&workspace.root, &website, Some(&audit_folder))
|
|
213
|
+
})?;
|
|
214
|
+
ui::saved(paths.markdown_path.display());
|
|
215
|
+
ui::saved(paths.json_path.display());
|
|
216
|
+
Ok(())
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
fn inspect(workspace: &Workspace, target: Option<&str>) -> Result<()> {
|
|
220
|
+
let folder = workspace.resolve_target(target)?;
|
|
221
|
+
ui::section("Inspect");
|
|
222
|
+
ui::bullet(&format!("Running checks for {folder}"));
|
|
223
|
+
check(workspace, &folder, None)?;
|
|
224
|
+
security_check(workspace, Some(&folder), None)?;
|
|
225
|
+
lighthouse_check(workspace, Some(&folder), None)?;
|
|
226
|
+
Ok(())
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
fn generate_report(workspace: &Workspace, target: Option<&str>) -> Result<()> {
|
|
230
|
+
let folder = workspace.resolve_target(target)?;
|
|
231
|
+
let (report_path, email_path) =
|
|
232
|
+
ui::with_task("Building final report and client email", || {
|
|
233
|
+
report::generate_report(workspace, &folder)
|
|
234
|
+
})?;
|
|
235
|
+
ui::section("Report Generated");
|
|
236
|
+
ui::saved(report_path.display());
|
|
237
|
+
ui::saved(email_path.display());
|
|
238
|
+
Ok(())
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
fn audit_website(workspace: &Workspace, folder: &str) -> Result<String> {
|
|
242
|
+
let files = workspace.read_audit_files(folder)?;
|
|
243
|
+
let brief = files.get("brief.md").context("Missing brief.md")?;
|
|
244
|
+
let parsed = report::parse_brief(brief);
|
|
245
|
+
if parsed.website.is_empty() {
|
|
246
|
+
anyhow::bail!("No website found in {folder}/brief.md");
|
|
247
|
+
}
|
|
248
|
+
Ok(parsed.website)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
fn looks_like_url(value: &str) -> bool {
|
|
252
|
+
value.starts_with("http://") || value.starts_with("https://") || value.contains('.')
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
fn prompt(label: &str) -> Result<String> {
|
|
256
|
+
print!("{label}");
|
|
257
|
+
io::stdout().flush()?;
|
|
258
|
+
let mut value = String::new();
|
|
259
|
+
io::stdin().read_line(&mut value)?;
|
|
260
|
+
Ok(value.trim().to_string())
|
|
261
|
+
}
|