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/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};
@@ -76,28 +76,19 @@ fn run() -> Result<()> {
76
76
  }
77
77
 
78
78
  fn new_audit(workspace: &Workspace) -> Result<()> {
79
- ui::section("New Audit");
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 findings.md, scorecard.md, and pages/*.md");
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.write_audit_file(
124
+ let path = workspace.update_workspace_section(
134
125
  &folder,
135
- "automated-check.md",
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.write_audit_file(
140
+ let path = workspace.update_workspace_section(
150
141
  &folder,
151
- "automated-check.md",
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.write_audit_file(
157
+ let path = workspace.update_workspace_section(
167
158
  &folder,
168
- "security.md",
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
- 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
+ )?;
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 output_folder = match save {
193
- Some(folder) => Some(workspace.audit_folder(&workspace.resolve_target(Some(folder))?)),
194
- None => None,
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, output_folder.as_deref())
194
+ lighthouse::run_lighthouse(&workspace.root, target, temp_dir.as_deref())
198
195
  })?;
199
- ui::saved(paths.markdown_path.display());
200
- 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
+ }
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 audit_folder = workspace.audit_folder(&folder);
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(&audit_folder))
214
+ lighthouse::run_lighthouse(&workspace.root, &website, Some(&temp_dir))
209
215
  })?;
210
- ui::saved(paths.markdown_path.display());
211
- 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);
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 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
  }