auditkit 0.1.0 → 0.1.3
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 +501 -6
- package/Cargo.toml +6 -1
- package/LICENSE +21 -0
- package/README.md +4 -23
- package/audits/.gitkeep +1 -0
- package/package.json +15 -17
- package/scripts/auditkit/ak +10 -9
- package/scripts/auditkit/lighthouse-runner.mjs +148 -20
- package/scripts/auditkit/lighthouse.test.mjs +17 -0
- package/scripts/auditkit/postinstall.mjs +71 -31
- package/scripts/lighthouse.mjs +38 -0
- package/src/html_check.rs +113 -5
- package/src/lighthouse.rs +8 -5
- package/src/main.rs +78 -54
- package/src/report.rs +58 -5
- package/src/security.rs +94 -10
- package/src/templates.rs +24 -75
- package/src/ui.rs +533 -23
- package/src/workspace.rs +75 -0
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
package/src/main.rs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
use std::
|
|
1
|
+
use std::{fs, process};
|
|
2
2
|
|
|
3
3
|
use anyhow::{Context, Result};
|
|
4
4
|
use auditkit::audit::{slugify, split_comma_list, AuditInput};
|
|
@@ -12,11 +12,7 @@ use chrono::Local;
|
|
|
12
12
|
use clap::{Parser, Subcommand};
|
|
13
13
|
|
|
14
14
|
#[derive(Parser)]
|
|
15
|
-
#[command(
|
|
16
|
-
name = "auditkit",
|
|
17
|
-
version,
|
|
18
|
-
about = "Audit Kit: small agency website audit workflow"
|
|
19
|
-
)]
|
|
15
|
+
#[command(name = "ak", about = "Audit Kit: small agency website audit workflow")]
|
|
20
16
|
struct Cli {
|
|
21
17
|
#[command(subcommand)]
|
|
22
18
|
command: Option<Command>,
|
|
@@ -80,28 +76,19 @@ fn run() -> Result<()> {
|
|
|
80
76
|
}
|
|
81
77
|
|
|
82
78
|
fn new_audit(workspace: &Workspace) -> Result<()> {
|
|
83
|
-
ui::
|
|
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 ")?;
|
|
79
|
+
let answers = ui::collect_audit_input()?;
|
|
93
80
|
|
|
94
81
|
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),
|
|
82
|
+
slug: slugify(&answers.client_name),
|
|
83
|
+
client_name: answers.client_name,
|
|
84
|
+
url: answers.url,
|
|
85
|
+
business_type: answers.business_type,
|
|
86
|
+
goal: answers.goal,
|
|
87
|
+
target_customer: answers.target_customer,
|
|
88
|
+
conversion_action: answers.conversion_action,
|
|
89
|
+
pages: split_comma_list(&answers.pages),
|
|
90
|
+
known_concerns: split_comma_list(&answers.known_concerns),
|
|
91
|
+
competitors: split_comma_list(&answers.competitors),
|
|
105
92
|
created_at: Local::now().format("%Y-%m-%d").to_string(),
|
|
106
93
|
};
|
|
107
94
|
|
|
@@ -109,7 +96,7 @@ fn new_audit(workspace: &Workspace) -> Result<()> {
|
|
|
109
96
|
ui::section("Audit Created");
|
|
110
97
|
ui::saved(folder.display());
|
|
111
98
|
ui::bullet("Next: ak inspect latest");
|
|
112
|
-
ui::bullet("Then fill
|
|
99
|
+
ui::bullet("Then fill workspace.md and findings.md");
|
|
113
100
|
Ok(())
|
|
114
101
|
}
|
|
115
102
|
|
|
@@ -134,9 +121,9 @@ fn check(workspace: &Workspace, target: &str, save: Option<&str>) -> Result<()>
|
|
|
134
121
|
println!("{}", html_check::format_cli(&result));
|
|
135
122
|
if let Some(folder) = save {
|
|
136
123
|
let folder = workspace.resolve_target(Some(folder))?;
|
|
137
|
-
let path = workspace.
|
|
124
|
+
let path = workspace.update_workspace_section(
|
|
138
125
|
&folder,
|
|
139
|
-
"
|
|
126
|
+
"Automated Check",
|
|
140
127
|
&html_check::format_markdown(&result),
|
|
141
128
|
)?;
|
|
142
129
|
ui::saved(path.display());
|
|
@@ -150,9 +137,9 @@ fn check(workspace: &Workspace, target: &str, save: Option<&str>) -> Result<()>
|
|
|
150
137
|
html_check::check_url(&website)
|
|
151
138
|
})?;
|
|
152
139
|
println!("{}", html_check::format_cli(&result));
|
|
153
|
-
let path = workspace.
|
|
140
|
+
let path = workspace.update_workspace_section(
|
|
154
141
|
&folder,
|
|
155
|
-
"
|
|
142
|
+
"Automated Check",
|
|
156
143
|
&html_check::format_markdown(&result),
|
|
157
144
|
)?;
|
|
158
145
|
ui::saved(path.display());
|
|
@@ -167,9 +154,9 @@ fn security_check(workspace: &Workspace, target: Option<&str>, save: Option<&str
|
|
|
167
154
|
println!("{}", security::format_cli(&result));
|
|
168
155
|
if let Some(folder) = save {
|
|
169
156
|
let folder = workspace.resolve_target(Some(folder))?;
|
|
170
|
-
let path = workspace.
|
|
157
|
+
let path = workspace.update_workspace_section(
|
|
171
158
|
&folder,
|
|
172
|
-
"
|
|
159
|
+
"Security Check",
|
|
173
160
|
&security::format_markdown(&result),
|
|
174
161
|
)?;
|
|
175
162
|
ui::saved(path.display());
|
|
@@ -183,8 +170,11 @@ fn security_check(workspace: &Workspace, target: Option<&str>, save: Option<&str
|
|
|
183
170
|
security::check_url(&website)
|
|
184
171
|
})?;
|
|
185
172
|
println!("{}", security::format_cli(&result));
|
|
186
|
-
let path =
|
|
187
|
-
|
|
173
|
+
let path = workspace.update_workspace_section(
|
|
174
|
+
&folder,
|
|
175
|
+
"Security Check",
|
|
176
|
+
&security::format_markdown(&result),
|
|
177
|
+
)?;
|
|
188
178
|
ui::saved(path.display());
|
|
189
179
|
Ok(())
|
|
190
180
|
}
|
|
@@ -193,29 +183,71 @@ fn lighthouse_check(workspace: &Workspace, target: Option<&str>, save: Option<&s
|
|
|
193
183
|
let target = target.unwrap_or("latest");
|
|
194
184
|
|
|
195
185
|
if looks_like_url(target) {
|
|
196
|
-
let
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
186
|
+
let save_folder = save
|
|
187
|
+
.map(|folder| workspace.resolve_target(Some(folder)))
|
|
188
|
+
.transpose()?;
|
|
189
|
+
let temp_dir = save_folder.as_ref().map(|_| lighthouse_temp_dir(workspace));
|
|
190
|
+
if let Some(folder) = &temp_dir {
|
|
191
|
+
fs::create_dir_all(folder)?;
|
|
192
|
+
}
|
|
200
193
|
let paths = ui::with_task("Running Lighthouse in Helium", || {
|
|
201
|
-
lighthouse::run_lighthouse(&workspace.root, target,
|
|
194
|
+
lighthouse::run_lighthouse(&workspace.root, target, temp_dir.as_deref())
|
|
202
195
|
})?;
|
|
203
|
-
|
|
204
|
-
|
|
196
|
+
print_lighthouse_output(&paths.cli_output);
|
|
197
|
+
if let Some(folder) = save_folder {
|
|
198
|
+
save_lighthouse_summary(workspace, &folder, &paths)?;
|
|
199
|
+
} else {
|
|
200
|
+
ui::saved(paths.markdown_path.display());
|
|
201
|
+
ui::saved(paths.json_path.display());
|
|
202
|
+
}
|
|
203
|
+
if let Some(folder) = temp_dir {
|
|
204
|
+
let _ = fs::remove_dir_all(folder);
|
|
205
|
+
}
|
|
205
206
|
return Ok(());
|
|
206
207
|
}
|
|
207
208
|
|
|
208
209
|
let folder = workspace.resolve_target(Some(target))?;
|
|
209
210
|
let website = audit_website(workspace, &folder)?;
|
|
210
|
-
let
|
|
211
|
+
let temp_dir = lighthouse_temp_dir(workspace);
|
|
212
|
+
fs::create_dir_all(&temp_dir)?;
|
|
211
213
|
let paths = ui::with_task("Running Lighthouse in Helium", || {
|
|
212
|
-
lighthouse::run_lighthouse(&workspace.root, &website, Some(&
|
|
214
|
+
lighthouse::run_lighthouse(&workspace.root, &website, Some(&temp_dir))
|
|
213
215
|
})?;
|
|
214
|
-
|
|
215
|
-
|
|
216
|
+
print_lighthouse_output(&paths.cli_output);
|
|
217
|
+
save_lighthouse_summary(workspace, &folder, &paths)?;
|
|
218
|
+
let _ = fs::remove_dir_all(temp_dir);
|
|
216
219
|
Ok(())
|
|
217
220
|
}
|
|
218
221
|
|
|
222
|
+
fn print_lighthouse_output(output: &str) {
|
|
223
|
+
if !output.trim().is_empty() {
|
|
224
|
+
println!("{output}");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
fn save_lighthouse_summary(
|
|
229
|
+
workspace: &Workspace,
|
|
230
|
+
folder: &str,
|
|
231
|
+
paths: &lighthouse::LighthousePaths,
|
|
232
|
+
) -> Result<()> {
|
|
233
|
+
let markdown = fs::read_to_string(&paths.markdown_path)?;
|
|
234
|
+
let json = fs::read_to_string(&paths.json_path)?;
|
|
235
|
+
let workspace_path =
|
|
236
|
+
workspace.update_workspace_section(folder, "Lighthouse Check", &markdown)?;
|
|
237
|
+
let json_path = workspace.write_audit_file(folder, "raw/lighthouse.json", &json)?;
|
|
238
|
+
ui::saved(workspace_path.display());
|
|
239
|
+
ui::saved(json_path.display());
|
|
240
|
+
Ok(())
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
fn lighthouse_temp_dir(workspace: &Workspace) -> std::path::PathBuf {
|
|
244
|
+
workspace.root.join("target").join(format!(
|
|
245
|
+
"auditkit-lighthouse-{}-{}",
|
|
246
|
+
process::id(),
|
|
247
|
+
Local::now().timestamp_millis()
|
|
248
|
+
))
|
|
249
|
+
}
|
|
250
|
+
|
|
219
251
|
fn inspect(workspace: &Workspace, target: Option<&str>) -> Result<()> {
|
|
220
252
|
let folder = workspace.resolve_target(target)?;
|
|
221
253
|
ui::section("Inspect");
|
|
@@ -251,11 +283,3 @@ fn audit_website(workspace: &Workspace, folder: &str) -> Result<String> {
|
|
|
251
283
|
fn looks_like_url(value: &str) -> bool {
|
|
252
284
|
value.starts_with("http://") || value.starts_with("https://") || value.contains('.')
|
|
253
285
|
}
|
|
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
|
-
}
|
package/src/report.rs
CHANGED
|
@@ -100,7 +100,7 @@ pub fn build_final_report(
|
|
|
100
100
|
files: &BTreeMap<String, String>,
|
|
101
101
|
) -> String {
|
|
102
102
|
let brief = parse_brief(files.get("brief.md").map(String::as_str).unwrap_or(""));
|
|
103
|
-
let
|
|
103
|
+
let old_page_sections = files
|
|
104
104
|
.iter()
|
|
105
105
|
.filter(|(path, _)| path.starts_with("pages/") && path.ends_with(".md"))
|
|
106
106
|
.map(|(path, content)| {
|
|
@@ -112,6 +112,9 @@ pub fn build_final_report(
|
|
|
112
112
|
})
|
|
113
113
|
.collect::<Vec<_>>()
|
|
114
114
|
.join("\n\n");
|
|
115
|
+
let page_sections = workspace_section(files, "Page Reviews", "")
|
|
116
|
+
.filter(|content| !content.contains("No page reviews provided yet."))
|
|
117
|
+
.unwrap_or(old_page_sections);
|
|
115
118
|
|
|
116
119
|
format!(
|
|
117
120
|
"# {} Website Audit\n\nPrepared by {}, {}\nAudit folder: {}\n\n## Context\n\nWebsite: {}\nGoal: {}\nBusiness type: {}\nTarget customer: {}\nMain conversion action: {}\n\n## Scorecard\n\n{}\n\n## Automated Check\n\n{}\n\n## Security Check\n\n{}\n\n## Lighthouse Check\n\n{}\n\n## Findings\n\n{}\n\n## Page Reviews\n\n{}\n\n## Recommended Next Step\n\nAudit: {}\nRefresh: {}\nGrowth: {}\n\nUse the audit to prioritise immediate fixes. If most findings require design, copy, and frontend changes, the Refresh package is likely the best next step.\n",
|
|
@@ -124,10 +127,14 @@ pub fn build_final_report(
|
|
|
124
127
|
empty_or(&brief.business_type, "Not specified"),
|
|
125
128
|
empty_or(&brief.target_customer, "Not specified"),
|
|
126
129
|
empty_or(&brief.conversion_action, "Not specified"),
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
130
|
+
workspace_section(files, "Scorecard", "Not completed yet.")
|
|
131
|
+
.unwrap_or_else(|| section_file(files, "scorecard.md", "Not completed yet.")),
|
|
132
|
+
workspace_section(files, "Automated Check", "Not run yet.")
|
|
133
|
+
.unwrap_or_else(|| section_file(files, "automated-check.md", "Not run yet.")),
|
|
134
|
+
workspace_section(files, "Security Check", "Not run yet.")
|
|
135
|
+
.unwrap_or_else(|| section_file(files, "security.md", "Not run yet.")),
|
|
136
|
+
workspace_section(files, "Lighthouse Check", "Not run yet.")
|
|
137
|
+
.unwrap_or_else(|| section_file(files, "lighthouse.md", "Not run yet.")),
|
|
131
138
|
section_file(files, "findings.md", "No findings captured yet."),
|
|
132
139
|
if page_sections.is_empty() { "No page reviews captured yet.".to_string() } else { page_sections },
|
|
133
140
|
config.audit_price,
|
|
@@ -166,6 +173,29 @@ fn section_file(files: &BTreeMap<String, String>, path: &str, fallback: &str) ->
|
|
|
166
173
|
.unwrap_or_else(|| fallback.to_string())
|
|
167
174
|
}
|
|
168
175
|
|
|
176
|
+
fn workspace_section(
|
|
177
|
+
files: &BTreeMap<String, String>,
|
|
178
|
+
heading: &str,
|
|
179
|
+
fallback: &str,
|
|
180
|
+
) -> Option<String> {
|
|
181
|
+
let workspace = files.get("workspace.md")?;
|
|
182
|
+
let heading = format!("## {heading}");
|
|
183
|
+
let lines = workspace.lines().collect::<Vec<_>>();
|
|
184
|
+
let start = lines.iter().position(|line| line.trim() == heading)? + 1;
|
|
185
|
+
let end = lines
|
|
186
|
+
.iter()
|
|
187
|
+
.enumerate()
|
|
188
|
+
.skip(start)
|
|
189
|
+
.find_map(|(index, line)| line.starts_with("## ").then_some(index))
|
|
190
|
+
.unwrap_or(lines.len());
|
|
191
|
+
let content = lines[start..end].join("\n").trim().to_string();
|
|
192
|
+
if content.is_empty() {
|
|
193
|
+
Some(fallback.to_string())
|
|
194
|
+
} else {
|
|
195
|
+
Some(content)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
169
199
|
fn empty_or<'a>(value: &'a str, fallback: &'a str) -> &'a str {
|
|
170
200
|
if value.trim().is_empty() {
|
|
171
201
|
fallback
|
|
@@ -247,4 +277,27 @@ mod tests {
|
|
|
247
277
|
assert!(report.contains("## Lighthouse Check"));
|
|
248
278
|
assert!(report.contains("### Homepage Review"));
|
|
249
279
|
}
|
|
280
|
+
|
|
281
|
+
#[test]
|
|
282
|
+
fn final_report_reads_consolidated_workspace_sections() {
|
|
283
|
+
let mut files = BTreeMap::new();
|
|
284
|
+
files.insert(
|
|
285
|
+
"brief.md".to_string(),
|
|
286
|
+
"# Acme Dental Audit Brief\n\nWebsite: https://example.com\nGoal: Leads".to_string(),
|
|
287
|
+
);
|
|
288
|
+
files.insert(
|
|
289
|
+
"workspace.md".to_string(),
|
|
290
|
+
"# Audit Workspace\n\n## Scorecard\n\nPerformance: 8/10\n\n## Automated Check\n\nScore: 74/100\n\n## Security Check\n\nScore: 50/100\n\n## Lighthouse Check\n\nPerformance: 87/100\n\n## Page Reviews\n\n### Homepage Review\n\nCTA needs work.\n".to_string(),
|
|
291
|
+
);
|
|
292
|
+
files.insert(
|
|
293
|
+
"findings.md".to_string(),
|
|
294
|
+
"# Findings\n\n## Critical".to_string(),
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
let report = build_final_report(&AgencyConfig::default(), "2026-acme", &files);
|
|
298
|
+
assert!(report.contains("Performance: 8/10"));
|
|
299
|
+
assert!(report.contains("Score: 74/100"));
|
|
300
|
+
assert!(report.contains("Performance: 87/100"));
|
|
301
|
+
assert!(report.contains("CTA needs work."));
|
|
302
|
+
}
|
|
250
303
|
}
|
package/src/security.rs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
use anyhow::Result;
|
|
2
2
|
use reqwest::header::HeaderMap;
|
|
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 SecurityCheck {
|
|
@@ -22,17 +22,15 @@ fn header_present(headers: &HeaderMap, name: &str) -> bool {
|
|
|
22
22
|
|
|
23
23
|
fn cookies_have_security_flags(headers: &HeaderMap) -> bool {
|
|
24
24
|
let cookies = headers.get_all("set-cookie");
|
|
25
|
-
let mut saw_cookie = false;
|
|
26
25
|
|
|
27
26
|
for cookie in cookies.iter() {
|
|
28
|
-
saw_cookie = true;
|
|
29
27
|
let value = cookie.to_str().unwrap_or("").to_lowercase();
|
|
30
28
|
if !(value.contains("secure") && value.contains("httponly") && value.contains("samesite")) {
|
|
31
29
|
return false;
|
|
32
30
|
}
|
|
33
31
|
}
|
|
34
32
|
|
|
35
|
-
|
|
33
|
+
true
|
|
36
34
|
}
|
|
37
35
|
|
|
38
36
|
pub fn analyze_security_headers(url: &str, headers: &HeaderMap) -> SecurityCheck {
|
|
@@ -114,14 +112,90 @@ pub fn check_url(input_url: &str) -> Result<SecurityCheck> {
|
|
|
114
112
|
.header("accept", "text/html,application/xhtml+xml")
|
|
115
113
|
.send()?;
|
|
116
114
|
Ok(analyze_security_headers(
|
|
117
|
-
|
|
115
|
+
response.url().as_ref(),
|
|
118
116
|
response.headers(),
|
|
119
117
|
))
|
|
120
118
|
}
|
|
121
119
|
|
|
122
120
|
pub fn format_cli(result: &SecurityCheck) -> String {
|
|
123
121
|
let mut output = format!(
|
|
124
|
-
"\n
|
|
122
|
+
"\n{}\n{} {}\n{} {}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n\n{}\n",
|
|
123
|
+
ui::frame_line("╭─ Security Check"),
|
|
124
|
+
ui::frame_line("│ URL "),
|
|
125
|
+
result.url,
|
|
126
|
+
ui::frame_line("│ Score"),
|
|
127
|
+
ui::score_badge(result.score),
|
|
128
|
+
ui::frame_line("╰─ Signals"),
|
|
129
|
+
ui::signal_line(
|
|
130
|
+
"HTTPS",
|
|
131
|
+
yes_no(result.https),
|
|
132
|
+
if result.https {
|
|
133
|
+
FeedbackTone::Positive
|
|
134
|
+
} else {
|
|
135
|
+
FeedbackTone::Critical
|
|
136
|
+
}
|
|
137
|
+
),
|
|
138
|
+
ui::signal_line(
|
|
139
|
+
"HSTS",
|
|
140
|
+
yes_no(result.hsts),
|
|
141
|
+
if result.hsts {
|
|
142
|
+
FeedbackTone::Positive
|
|
143
|
+
} else {
|
|
144
|
+
FeedbackTone::Warning
|
|
145
|
+
}
|
|
146
|
+
),
|
|
147
|
+
ui::signal_line(
|
|
148
|
+
"CSP",
|
|
149
|
+
yes_no(result.csp),
|
|
150
|
+
if result.csp {
|
|
151
|
+
FeedbackTone::Positive
|
|
152
|
+
} else {
|
|
153
|
+
FeedbackTone::Critical
|
|
154
|
+
}
|
|
155
|
+
),
|
|
156
|
+
ui::signal_line(
|
|
157
|
+
"X-Frame-Options",
|
|
158
|
+
yes_no(result.frame_options),
|
|
159
|
+
if result.frame_options {
|
|
160
|
+
FeedbackTone::Positive
|
|
161
|
+
} else {
|
|
162
|
+
FeedbackTone::Warning
|
|
163
|
+
}
|
|
164
|
+
),
|
|
165
|
+
ui::signal_line(
|
|
166
|
+
"Referrer-Policy",
|
|
167
|
+
yes_no(result.referrer_policy),
|
|
168
|
+
if result.referrer_policy {
|
|
169
|
+
FeedbackTone::Positive
|
|
170
|
+
} else {
|
|
171
|
+
FeedbackTone::Warning
|
|
172
|
+
}
|
|
173
|
+
),
|
|
174
|
+
ui::signal_line(
|
|
175
|
+
"Secure cookies",
|
|
176
|
+
yes_no(result.secure_cookies),
|
|
177
|
+
if result.secure_cookies {
|
|
178
|
+
FeedbackTone::Positive
|
|
179
|
+
} else {
|
|
180
|
+
FeedbackTone::Critical
|
|
181
|
+
}
|
|
182
|
+
),
|
|
183
|
+
ui::frame_line("Feedback")
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
for item in &result.feedback {
|
|
187
|
+
output.push_str(&format!(
|
|
188
|
+
"{}\n",
|
|
189
|
+
ui::feedback_line(feedback_tone(item), item)
|
|
190
|
+
));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
output
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
pub fn format_markdown(result: &SecurityCheck) -> String {
|
|
197
|
+
let mut output = format!(
|
|
198
|
+
"# Security Check\n\nURL: {}\nScore: {}/100 ({})\n\n## Signals\n\n- HTTPS: {}\n- HSTS: {}\n- CSP: {}\n- X-Frame-Options: {}\n- Referrer-Policy: {}\n- Secure cookies: {}\n\n## Feedback\n\n",
|
|
125
199
|
result.url,
|
|
126
200
|
result.score,
|
|
127
201
|
score_status(result.score),
|
|
@@ -140,10 +214,6 @@ pub fn format_cli(result: &SecurityCheck) -> String {
|
|
|
140
214
|
output
|
|
141
215
|
}
|
|
142
216
|
|
|
143
|
-
pub fn format_markdown(result: &SecurityCheck) -> String {
|
|
144
|
-
format!("# Security Check\n\n{}\n", format_cli(result))
|
|
145
|
-
}
|
|
146
|
-
|
|
147
217
|
fn yes_no(value: bool) -> &'static str {
|
|
148
218
|
if value {
|
|
149
219
|
"yes"
|
|
@@ -152,6 +222,20 @@ fn yes_no(value: bool) -> &'static str {
|
|
|
152
222
|
}
|
|
153
223
|
}
|
|
154
224
|
|
|
225
|
+
fn feedback_tone(value: &str) -> FeedbackTone {
|
|
226
|
+
let value = value.to_lowercase();
|
|
227
|
+
if value.contains("not using https")
|
|
228
|
+
|| value.contains("content-security-policy")
|
|
229
|
+
|| value.contains("cookies are missing")
|
|
230
|
+
{
|
|
231
|
+
FeedbackTone::Critical
|
|
232
|
+
} else if value.starts_with("missing") {
|
|
233
|
+
FeedbackTone::Warning
|
|
234
|
+
} else {
|
|
235
|
+
FeedbackTone::Positive
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
155
239
|
#[cfg(test)]
|
|
156
240
|
mod tests {
|
|
157
241
|
use reqwest::header::{HeaderMap, HeaderValue};
|
package/src/templates.rs
CHANGED
|
@@ -36,45 +36,12 @@ fn title_case(value: &str) -> String {
|
|
|
36
36
|
.join(" ")
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
fn page_file_name(page: &str) -> String {
|
|
40
|
-
let raw = if page == "/" {
|
|
41
|
-
"home".to_string()
|
|
42
|
-
} else {
|
|
43
|
-
page.trim_matches('/').to_string()
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
let mut slug = String::new();
|
|
47
|
-
let mut previous_dash = false;
|
|
48
|
-
|
|
49
|
-
for character in raw.chars() {
|
|
50
|
-
if character.is_ascii_alphanumeric() {
|
|
51
|
-
slug.push(character.to_ascii_lowercase());
|
|
52
|
-
previous_dash = false;
|
|
53
|
-
} else if !previous_dash {
|
|
54
|
-
slug.push('-');
|
|
55
|
-
previous_dash = true;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
format!("pages/{}.md", slug.trim_matches('-'))
|
|
60
|
-
}
|
|
61
|
-
|
|
62
39
|
pub fn create_audit_files(audit: &AuditInput) -> BTreeMap<String, String> {
|
|
63
40
|
let mut files = BTreeMap::new();
|
|
64
41
|
|
|
65
42
|
files.insert("brief.md".to_string(), brief_template(audit));
|
|
66
|
-
files.insert("
|
|
67
|
-
files.insert("scorecard.md".to_string(), scorecard_template());
|
|
68
|
-
files.insert("checklist.md".to_string(), checklist_template());
|
|
43
|
+
files.insert("workspace.md".to_string(), workspace_template(audit));
|
|
69
44
|
files.insert("findings.md".to_string(), findings_template());
|
|
70
|
-
files.insert("report.md".to_string(), report_template(audit));
|
|
71
|
-
files.insert("video-script.md".to_string(), video_script_template(audit));
|
|
72
|
-
files.insert("links.md".to_string(), links_template(audit));
|
|
73
|
-
files.insert("raw-notes.md".to_string(), "# Raw Notes\n\n".to_string());
|
|
74
|
-
|
|
75
|
-
for page in &audit.pages {
|
|
76
|
-
files.insert(page_file_name(page), page_review_template(page));
|
|
77
|
-
}
|
|
78
45
|
|
|
79
46
|
files
|
|
80
47
|
}
|
|
@@ -95,45 +62,11 @@ pub fn brief_template(audit: &AuditInput) -> String {
|
|
|
95
62
|
)
|
|
96
63
|
}
|
|
97
64
|
|
|
98
|
-
pub fn checklist_template() -> String {
|
|
99
|
-
"# Audit Checklist\n\n## Performance\n\n- [ ] Run PageSpeed on key pages\n- [ ] Check mobile score\n- [ ] Identify LCP element\n- [ ] Check image weight\n- [ ] Check font loading\n- [ ] Check layout shift\n\n## UX and Conversion\n\n- [ ] Above-fold offer is clear\n- [ ] Primary CTA is obvious\n- [ ] User can contact/book within 1 click\n- [ ] Trust signals are visible\n- [ ] Forms are short enough\n- [ ] Navigation supports key journey\n\n## SEO\n\n- [ ] Title tags\n- [ ] Meta descriptions\n- [ ] H1 structure\n- [ ] Internal links\n- [ ] Image alt text\n- [ ] Schema opportunity\n- [ ] Local SEO basics\n".to_string()
|
|
100
|
-
}
|
|
101
|
-
|
|
102
65
|
pub fn findings_template() -> String {
|
|
103
|
-
"# Findings\n\n## Finding Template\n\nTitle:\nCategory: Performance / UX / SEO / Conversion / Trust\nSeverity: Critical / High / Medium / Low\nPage:\nEvidence:\nWhy it matters:\nRecommendation:\nEstimated effort:\nBusiness impact:\nScreenshot/video note:\n
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
pub fn scorecard_template() -> String {
|
|
107
|
-
"# Scorecard\n\nPerformance: /10\nUX clarity: /10\nConversion path: /10\nSEO basics: /10\nTrust signals: /10\nMobile experience: /10\n\nOverall:\n\n## Notes\n\n".to_string()
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
pub fn status_template() -> String {
|
|
111
|
-
"# Audit Status\n\n- [ ] Intake complete\n- [ ] Pages reviewed\n- [ ] Performance checked\n- [ ] SEO checked\n- [ ] Findings prioritised\n- [ ] Report drafted\n- [ ] Video recorded\n- [ ] Sent to client\n".to_string()
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
pub fn page_review_template(page: &str) -> String {
|
|
115
|
-
format!(
|
|
116
|
-
"# {} Review\n\nPath: {}\n\n## First Impression\n\n## CTA\n\n## Copy Clarity\n\n## Visual Hierarchy\n\n## Mobile Issues\n\n## SEO Notes\n\n## Performance Notes\n\n## Recommended Fixes\n",
|
|
117
|
-
title_case(page),
|
|
118
|
-
page
|
|
119
|
-
)
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
pub fn report_template(audit: &AuditInput) -> String {
|
|
123
|
-
format!(
|
|
124
|
-
"# {} Website Audit\n\nWebsite: {}\nGoal: {}\n\n## Executive Summary\n\n## Priority Fixes\n\n1.\n2.\n3.\n\n## Performance\n\n## UX and Conversion\n\n## SEO\n\n## Recommended Next Step\n",
|
|
125
|
-
audit.client_name, audit.url, audit.goal
|
|
126
|
-
)
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
pub fn video_script_template(audit: &AuditInput) -> String {
|
|
130
|
-
format!(
|
|
131
|
-
"# {} Video Script\n\n## Opening\n\nWhat I reviewed and what matters most.\n\n## Biggest Conversion Issue\n\n## Performance Issue\n\n## SEO or Structure Issue\n\n## Close\n\nSummarise top 3 fixes and recommended next step.\n",
|
|
132
|
-
audit.client_name
|
|
133
|
-
)
|
|
66
|
+
"# Findings\n\n## Critical\n\n## High\n\n## Medium\n\n## Low\n\n## Finding Template\n\nTitle:\nCategory: Performance / UX / SEO / Conversion / Trust\nSeverity: Critical / High / Medium / Low\nPage:\nEvidence:\nWhy it matters:\nRecommendation:\nEstimated effort:\nBusiness impact:\nScreenshot/video note:\n".to_string()
|
|
134
67
|
}
|
|
135
68
|
|
|
136
|
-
pub fn
|
|
69
|
+
pub fn workspace_template(audit: &AuditInput) -> String {
|
|
137
70
|
let encoded = urlencoding(&audit.url);
|
|
138
71
|
let pages = audit
|
|
139
72
|
.pages
|
|
@@ -147,13 +80,27 @@ pub fn links_template(audit: &AuditInput) -> String {
|
|
|
147
80
|
.collect::<Vec<_>>()
|
|
148
81
|
.join("\n");
|
|
149
82
|
|
|
83
|
+
let page_reviews = audit
|
|
84
|
+
.pages
|
|
85
|
+
.iter()
|
|
86
|
+
.map(|page| {
|
|
87
|
+
format!(
|
|
88
|
+
"### {} Review\n\nPath: {}\n\nFirst impression:\n\nCTA:\n\nCopy clarity:\n\nVisual hierarchy:\n\nMobile issues:\n\nSEO notes:\n\nPerformance notes:\n\nRecommended fixes:\n",
|
|
89
|
+
title_case(page),
|
|
90
|
+
page
|
|
91
|
+
)
|
|
92
|
+
})
|
|
93
|
+
.collect::<Vec<_>>()
|
|
94
|
+
.join("\n");
|
|
95
|
+
|
|
150
96
|
format!(
|
|
151
|
-
"# Audit
|
|
97
|
+
"# Audit Workspace\n\n## Status\n\n- [ ] Intake complete\n- [ ] Pages reviewed\n- [ ] Performance checked\n- [ ] SEO checked\n- [ ] Findings prioritised\n- [ ] Report drafted\n- [ ] Video recorded\n- [ ] Sent to client\n\n## Scorecard\n\nPerformance: /10\nUX clarity: /10\nConversion path: /10\nSEO basics: /10\nTrust signals: /10\nMobile experience: /10\n\nOverall:\n\nNotes:\n\n## Review Checklist\n\n### Performance\n\n- [ ] Check mobile score\n- [ ] Identify LCP element\n- [ ] Check image weight\n- [ ] Check font loading\n- [ ] Check layout shift\n\n### UX and Conversion\n\n- [ ] Above-fold offer is clear\n- [ ] Primary CTA is obvious\n- [ ] User can contact/book within 1 click\n- [ ] Trust signals are visible\n- [ ] Forms are short enough\n- [ ] Navigation supports key journey\n\n### SEO\n\n- [ ] Title tags\n- [ ] Meta descriptions\n- [ ] H1 structure\n- [ ] Internal links\n- [ ] Image alt text\n- [ ] Schema opportunity\n- [ ] Local SEO basics\n\n## Test Links\n\n- PageSpeed: https://pagespeed.web.dev/analysis?url={}\n- Rich Results: https://search.google.com/test/rich-results?url={}\n- Meta Preview: https://metatags.io/?url={}\n\n## Pages To Review\n\n{}\n\n## Competitors\n\n{}\n\n## Automated Check\n\nNot run yet.\n\n## Security Check\n\nNot run yet.\n\n## Lighthouse Check\n\nNot run yet.\n\n## Page Reviews\n\n{}\n\n## Raw Notes\n\n",
|
|
152
98
|
encoded,
|
|
153
99
|
encoded,
|
|
154
100
|
encoded,
|
|
155
101
|
if pages.is_empty() { "- None provided".to_string() } else { pages },
|
|
156
|
-
markdown_list(&audit.competitors)
|
|
102
|
+
markdown_list(&audit.competitors),
|
|
103
|
+
if page_reviews.is_empty() { "No page reviews provided yet.".to_string() } else { page_reviews }
|
|
157
104
|
)
|
|
158
105
|
}
|
|
159
106
|
|
|
@@ -189,9 +136,11 @@ mod tests {
|
|
|
189
136
|
fn create_audit_files_contains_expected_workspace() {
|
|
190
137
|
let files = create_audit_files(&audit());
|
|
191
138
|
assert!(files.contains_key("brief.md"));
|
|
192
|
-
assert!(files.contains_key("
|
|
193
|
-
assert!(files.contains_key("
|
|
194
|
-
|
|
139
|
+
assert!(files.contains_key("workspace.md"));
|
|
140
|
+
assert!(files.contains_key("findings.md"));
|
|
141
|
+
assert_eq!(files.len(), 3);
|
|
195
142
|
assert!(files["brief.md"].contains("Business type: Dental clinic"));
|
|
143
|
+
assert!(files["workspace.md"].contains("### Homepage Review"));
|
|
144
|
+
assert!(files["workspace.md"].contains("## Automated Check"));
|
|
196
145
|
}
|
|
197
146
|
}
|