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/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
 
package/src/main.rs CHANGED
@@ -1,4 +1,4 @@
1
- use std::io::{self, Write};
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::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 ")?;
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 findings.md, scorecard.md, and pages/*.md");
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.write_audit_file(
124
+ let path = workspace.update_workspace_section(
138
125
  &folder,
139
- "automated-check.md",
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.write_audit_file(
140
+ let path = workspace.update_workspace_section(
154
141
  &folder,
155
- "automated-check.md",
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.write_audit_file(
157
+ let path = workspace.update_workspace_section(
171
158
  &folder,
172
- "security.md",
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
- workspace.write_audit_file(&folder, "security.md", &security::format_markdown(&result))?;
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 output_folder = match save {
197
- Some(folder) => Some(workspace.audit_folder(&workspace.resolve_target(Some(folder))?)),
198
- None => None,
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, output_folder.as_deref())
194
+ lighthouse::run_lighthouse(&workspace.root, target, temp_dir.as_deref())
202
195
  })?;
203
- ui::saved(paths.markdown_path.display());
204
- ui::saved(paths.json_path.display());
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 audit_folder = workspace.audit_folder(&folder);
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(&audit_folder))
214
+ lighthouse::run_lighthouse(&workspace.root, &website, Some(&temp_dir))
213
215
  })?;
214
- ui::saved(paths.markdown_path.display());
215
- ui::saved(paths.json_path.display());
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 page_sections = files
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
- section_file(files, "scorecard.md", "Not completed yet."),
128
- section_file(files, "automated-check.md", "Not run yet."),
129
- section_file(files, "security.md", "Not run yet."),
130
- section_file(files, "lighthouse.md", "Not run yet."),
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
- !saw_cookie || true
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
- &response.url().to_string(),
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== Security Check ==\nURL: {}\nScore: {}/100 ({})\n\n== Signals ==\nHTTPS: {}\nHSTS: {}\nCSP: {}\nX-Frame-Options: {}\nReferrer-Policy: {}\nSecure cookies: {}\n\n== Feedback ==\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("status.md".to_string(), status_template());
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\n## Critical\n\n### 1. Finding title\n\nEvidence:\n\nWhy it matters:\n\nRecommendation:\n\nEstimated effort:\n\nBusiness impact:\n\n## High\n\n## Medium\n\n## Low\n".to_string()
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 links_template(audit: &AuditInput) -> String {
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 Links\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\n\n{}\n\n## Competitors\n\n{}\n",
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("scorecard.md"));
193
- assert!(files.contains_key("pages/home.md"));
194
- assert!(files.contains_key("pages/pricing.md"));
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
  }