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.lock +501 -6
- package/Cargo.toml +3 -1
- package/README.md +2 -4
- package/package.json +2 -1
- package/scripts/auditkit/ak +8 -5
- package/scripts/auditkit/lighthouse-runner.mjs +148 -20
- package/scripts/auditkit/lighthouse.test.mjs +17 -0
- package/scripts/auditkit/postinstall.mjs +71 -0
- package/src/html_check.rs +113 -5
- package/src/lighthouse.rs +8 -5
- package/src/main.rs +77 -49
- 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/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};
|
|
@@ -76,28 +76,19 @@ fn run() -> Result<()> {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
fn new_audit(workspace: &Workspace) -> Result<()> {
|
|
79
|
-
ui::
|
|
80
|
-
let client_name = prompt("Client name ")?;
|
|
81
|
-
let url = prompt("Website URL ")?;
|
|
82
|
-
let business_type = prompt("Business type ")?;
|
|
83
|
-
let goal = prompt("Primary goal ")?;
|
|
84
|
-
let target_customer = prompt("Target customer ")?;
|
|
85
|
-
let conversion_action = prompt("Main conversion action ")?;
|
|
86
|
-
let pages = prompt("Pages, comma-separated ")?;
|
|
87
|
-
let known_concerns = prompt("Known concerns ")?;
|
|
88
|
-
let competitors = prompt("Competitors ")?;
|
|
79
|
+
let answers = ui::collect_audit_input()?;
|
|
89
80
|
|
|
90
81
|
let audit = AuditInput {
|
|
91
|
-
slug: slugify(&client_name),
|
|
92
|
-
client_name,
|
|
93
|
-
url,
|
|
94
|
-
business_type,
|
|
95
|
-
goal,
|
|
96
|
-
target_customer,
|
|
97
|
-
conversion_action,
|
|
98
|
-
pages: split_comma_list(&pages),
|
|
99
|
-
known_concerns: split_comma_list(&known_concerns),
|
|
100
|
-
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),
|
|
101
92
|
created_at: Local::now().format("%Y-%m-%d").to_string(),
|
|
102
93
|
};
|
|
103
94
|
|
|
@@ -105,7 +96,7 @@ fn new_audit(workspace: &Workspace) -> Result<()> {
|
|
|
105
96
|
ui::section("Audit Created");
|
|
106
97
|
ui::saved(folder.display());
|
|
107
98
|
ui::bullet("Next: ak inspect latest");
|
|
108
|
-
ui::bullet("Then fill
|
|
99
|
+
ui::bullet("Then fill workspace.md and findings.md");
|
|
109
100
|
Ok(())
|
|
110
101
|
}
|
|
111
102
|
|
|
@@ -130,9 +121,9 @@ fn check(workspace: &Workspace, target: &str, save: Option<&str>) -> Result<()>
|
|
|
130
121
|
println!("{}", html_check::format_cli(&result));
|
|
131
122
|
if let Some(folder) = save {
|
|
132
123
|
let folder = workspace.resolve_target(Some(folder))?;
|
|
133
|
-
let path = workspace.
|
|
124
|
+
let path = workspace.update_workspace_section(
|
|
134
125
|
&folder,
|
|
135
|
-
"
|
|
126
|
+
"Automated Check",
|
|
136
127
|
&html_check::format_markdown(&result),
|
|
137
128
|
)?;
|
|
138
129
|
ui::saved(path.display());
|
|
@@ -146,9 +137,9 @@ fn check(workspace: &Workspace, target: &str, save: Option<&str>) -> Result<()>
|
|
|
146
137
|
html_check::check_url(&website)
|
|
147
138
|
})?;
|
|
148
139
|
println!("{}", html_check::format_cli(&result));
|
|
149
|
-
let path = workspace.
|
|
140
|
+
let path = workspace.update_workspace_section(
|
|
150
141
|
&folder,
|
|
151
|
-
"
|
|
142
|
+
"Automated Check",
|
|
152
143
|
&html_check::format_markdown(&result),
|
|
153
144
|
)?;
|
|
154
145
|
ui::saved(path.display());
|
|
@@ -163,9 +154,9 @@ fn security_check(workspace: &Workspace, target: Option<&str>, save: Option<&str
|
|
|
163
154
|
println!("{}", security::format_cli(&result));
|
|
164
155
|
if let Some(folder) = save {
|
|
165
156
|
let folder = workspace.resolve_target(Some(folder))?;
|
|
166
|
-
let path = workspace.
|
|
157
|
+
let path = workspace.update_workspace_section(
|
|
167
158
|
&folder,
|
|
168
|
-
"
|
|
159
|
+
"Security Check",
|
|
169
160
|
&security::format_markdown(&result),
|
|
170
161
|
)?;
|
|
171
162
|
ui::saved(path.display());
|
|
@@ -179,8 +170,11 @@ fn security_check(workspace: &Workspace, target: Option<&str>, save: Option<&str
|
|
|
179
170
|
security::check_url(&website)
|
|
180
171
|
})?;
|
|
181
172
|
println!("{}", security::format_cli(&result));
|
|
182
|
-
let path =
|
|
183
|
-
|
|
173
|
+
let path = workspace.update_workspace_section(
|
|
174
|
+
&folder,
|
|
175
|
+
"Security Check",
|
|
176
|
+
&security::format_markdown(&result),
|
|
177
|
+
)?;
|
|
184
178
|
ui::saved(path.display());
|
|
185
179
|
Ok(())
|
|
186
180
|
}
|
|
@@ -189,29 +183,71 @@ fn lighthouse_check(workspace: &Workspace, target: Option<&str>, save: Option<&s
|
|
|
189
183
|
let target = target.unwrap_or("latest");
|
|
190
184
|
|
|
191
185
|
if looks_like_url(target) {
|
|
192
|
-
let
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
+
}
|
|
196
193
|
let paths = ui::with_task("Running Lighthouse in Helium", || {
|
|
197
|
-
lighthouse::run_lighthouse(&workspace.root, target,
|
|
194
|
+
lighthouse::run_lighthouse(&workspace.root, target, temp_dir.as_deref())
|
|
198
195
|
})?;
|
|
199
|
-
|
|
200
|
-
|
|
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
|
+
}
|
|
201
206
|
return Ok(());
|
|
202
207
|
}
|
|
203
208
|
|
|
204
209
|
let folder = workspace.resolve_target(Some(target))?;
|
|
205
210
|
let website = audit_website(workspace, &folder)?;
|
|
206
|
-
let
|
|
211
|
+
let temp_dir = lighthouse_temp_dir(workspace);
|
|
212
|
+
fs::create_dir_all(&temp_dir)?;
|
|
207
213
|
let paths = ui::with_task("Running Lighthouse in Helium", || {
|
|
208
|
-
lighthouse::run_lighthouse(&workspace.root, &website, Some(&
|
|
214
|
+
lighthouse::run_lighthouse(&workspace.root, &website, Some(&temp_dir))
|
|
209
215
|
})?;
|
|
210
|
-
|
|
211
|
-
|
|
216
|
+
print_lighthouse_output(&paths.cli_output);
|
|
217
|
+
save_lighthouse_summary(workspace, &folder, &paths)?;
|
|
218
|
+
let _ = fs::remove_dir_all(temp_dir);
|
|
212
219
|
Ok(())
|
|
213
220
|
}
|
|
214
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
|
+
|
|
215
251
|
fn inspect(workspace: &Workspace, target: Option<&str>) -> Result<()> {
|
|
216
252
|
let folder = workspace.resolve_target(target)?;
|
|
217
253
|
ui::section("Inspect");
|
|
@@ -247,11 +283,3 @@ fn audit_website(workspace: &Workspace, folder: &str) -> Result<String> {
|
|
|
247
283
|
fn looks_like_url(value: &str) -> bool {
|
|
248
284
|
value.starts_with("http://") || value.starts_with("https://") || value.contains('.')
|
|
249
285
|
}
|
|
250
|
-
|
|
251
|
-
fn prompt(label: &str) -> Result<String> {
|
|
252
|
-
print!("{label}");
|
|
253
|
-
io::stdout().flush()?;
|
|
254
|
-
let mut value = String::new();
|
|
255
|
-
io::stdin().read_line(&mut value)?;
|
|
256
|
-
Ok(value.trim().to_string())
|
|
257
|
-
}
|
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
|
}
|