Skip to content

Commit 5c24168

Browse files
committed
fix: "javascript:" issue with buttondown + integrates claude
1 parent 6fc8911 commit 5c24168

File tree

5 files changed

+180
-13
lines changed

5 files changed

+180
-13
lines changed

CLAUDE.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
This is a newsletter automation system for [FullStack Bulletin](https://fullstackbulletin.com/). It uses AWS SAM to deploy a Step Functions state machine that orchestrates Lambda functions to generate weekly newsletter issues.
8+
9+
## Build and Deploy Commands
10+
11+
```bash
12+
# Validate, build, and deploy to AWS
13+
sam validate --lint && sam build --beta-features && sam deploy
14+
15+
# Run all tests (linting + unit tests for Node.js)
16+
npm test
17+
18+
# Run only unit tests
19+
npm run test:unit
20+
21+
# Run only linting
22+
npm run test:lint
23+
24+
# Run Rust tests
25+
cargo test --all-features
26+
27+
# Run Rust clippy
28+
cargo clippy --all-features
29+
30+
# Format Rust code
31+
cargo fmt
32+
```
33+
34+
## Architecture
35+
36+
### AWS Step Functions Workflow (`statemachine/create_issue.asl.yaml`)
37+
38+
The state machine runs every Friday at 5PM UTC and executes:
39+
1. **Fetch Issue Number** - Scrapes Buttondown archive to get next issue number
40+
2. **Parallel data fetching:**
41+
- Fetch Quote - Gets a random programming quote
42+
- Fetch Book - Gets a recommended book
43+
- Fetch Sponsor - Retrieves sponsor from Airtable
44+
- Fetch Links - Processes Mastodon posts for newsletter links
45+
3. **Create Issue** - Generates and sends draft via Buttondown API
46+
47+
### Rust Lambda Functions (`functions/`)
48+
49+
All Rust functions use `cargo-lambda` for building and target ARM64 (`provided.al2023` runtime):
50+
51+
- **fetch-issue-number** - HTML scraper using `scraper` crate
52+
- **fetch-quote** - Random quote generator
53+
- **fetch-book** - Book recommendation fetcher
54+
- **fetch-sponsor** - Airtable API client for sponsor data
55+
- **create-issue** - Buttondown API integration with Tera templates
56+
57+
### Node.js Lambda Function
58+
59+
- **fetch-links** - Complex link processing pipeline that:
60+
- Fetches Mastodon statuses
61+
- Extracts, normalizes, and scores URLs
62+
- Retrieves metadata and canonical URLs
63+
- Uploads images to Cloudinary
64+
- Applies blacklist filtering
65+
66+
### Shared Library (`shared/`)
67+
68+
Contains common types used across Rust functions:
69+
- `Event` - Input event structure with `NextIssue`
70+
- `Issue` - Contains the issue number
71+
72+
## Key Patterns
73+
74+
- Rust functions read config from environment variables at startup
75+
- Functions use `reqwest` with `rustls-tls` for HTTP (Lambda compatible)
76+
- Step Functions handles retries with exponential backoff
77+
- Node.js function uses ES modules (`"type": "module"`)

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

functions/create-issue/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ reqwest = { version = "0.12", features = [
2222

2323
# Template engine
2424
tera = "1"
25+
regex = "1"
2526

2627
# Environment variables and error handling
2728
anyhow = "1"

functions/create-issue/src/template.rs

Lines changed: 94 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,28 @@
1+
use crate::model::{Book, Link, Quote, Sponsor};
12
use anyhow::Result;
3+
use regex::Regex;
24
use serde::Serialize;
3-
use tera::{Context, Tera};
4-
5-
use crate::model::{Book, Link, Quote, Sponsor};
5+
use std::collections::HashMap;
6+
use std::sync::LazyLock;
7+
use tera::{Context, Tera, Value};
8+
9+
static JAVASCRIPT_PROTOCOL_RE: LazyLock<Regex> =
10+
LazyLock::new(|| Regex::new(r"(?i)javascript:").unwrap());
11+
12+
/// Tera filter that sanitizes text by replacing "javascript:" (case insensitive) with "JavaScript - ".
13+
///
14+
/// This filter is needed because Buttondown's API rejects content containing "javascript:"
15+
/// even in Markdown body text, returning the error:
16+
/// `{"body": ["JavaScript in attributes is not allowed. (You added one with the `javascript:` attribute.)"]}`
17+
fn sanitize_js_filter(value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> {
18+
match value.as_str() {
19+
Some(s) => {
20+
let result = JAVASCRIPT_PROTOCOL_RE.replace_all(s, "JavaScript - ");
21+
Ok(Value::String(result.into_owned()))
22+
}
23+
None => Ok(value.clone()),
24+
}
25+
}
626

727
/// Enhanced link with action text for template rendering
828
#[derive(Serialize, Debug)]
@@ -201,9 +221,13 @@ impl TemplateRenderer {
201221
context.insert("closing_title", &closing_title);
202222
context.insert("closing_message", &closing_message);
203223

204-
// Use Tera's one-off rendering function with embedded template
205-
// autoescape=false since we're rendering Markdown, not HTML
206-
let rendered = Tera::one_off(NEWSLETTER_TEMPLATE, &context, false)?;
224+
// Create Tera instance with custom filter for sanitizing javascript: protocol
225+
let mut tera = Tera::default();
226+
tera.add_raw_template("newsletter", NEWSLETTER_TEMPLATE)?;
227+
tera.register_filter("sanitize_js", sanitize_js_filter);
228+
tera.autoescape_on(vec![]); // Disable autoescape since we're rendering Markdown
229+
230+
let rendered = tera.render("newsletter", &context)?;
207231
Ok(rendered)
208232
}
209233
}
@@ -296,6 +320,70 @@ mod tests {
296320
)
297321
}
298322

323+
#[test]
324+
fn test_sanitize_js_filter_lowercase() {
325+
let args = HashMap::new();
326+
// Note: "javascript:" is replaced with "JavaScript - ", so original space after colon remains
327+
let input = Value::String("Check out javascript: protocol".to_string());
328+
let result = sanitize_js_filter(&input, &args).unwrap();
329+
assert_eq!(result.as_str().unwrap(), "Check out JavaScript - protocol");
330+
}
331+
332+
#[test]
333+
fn test_sanitize_js_filter_uppercase() {
334+
let args = HashMap::new();
335+
let input = Value::String("Check out JAVASCRIPT: protocol".to_string());
336+
let result = sanitize_js_filter(&input, &args).unwrap();
337+
assert_eq!(result.as_str().unwrap(), "Check out JavaScript - protocol");
338+
}
339+
340+
#[test]
341+
fn test_sanitize_js_filter_mixed_case() {
342+
let args = HashMap::new();
343+
let input = Value::String("Check out JaVaScRiPt: protocol".to_string());
344+
let result = sanitize_js_filter(&input, &args).unwrap();
345+
assert_eq!(result.as_str().unwrap(), "Check out JavaScript - protocol");
346+
}
347+
348+
#[test]
349+
fn test_sanitize_js_filter_multiple_occurrences() {
350+
let args = HashMap::new();
351+
let input = Value::String("javascript: and JavaScript: both".to_string());
352+
let result = sanitize_js_filter(&input, &args).unwrap();
353+
assert_eq!(
354+
result.as_str().unwrap(),
355+
"JavaScript - and JavaScript - both"
356+
);
357+
}
358+
359+
#[test]
360+
fn test_sanitize_js_filter_no_trailing_space() {
361+
let args = HashMap::new();
362+
// When there's no space after the colon in the original, the replacement is clean
363+
let input = Value::String("javascript:void(0)".to_string());
364+
let result = sanitize_js_filter(&input, &args).unwrap();
365+
assert_eq!(result.as_str().unwrap(), "JavaScript - void(0)");
366+
}
367+
368+
#[test]
369+
fn test_sanitize_js_filter_no_match() {
370+
let args = HashMap::new();
371+
let input = Value::String("Just regular text without the pattern".to_string());
372+
let result = sanitize_js_filter(&input, &args).unwrap();
373+
assert_eq!(
374+
result.as_str().unwrap(),
375+
"Just regular text without the pattern"
376+
);
377+
}
378+
379+
#[test]
380+
fn test_sanitize_js_filter_non_string() {
381+
let args = HashMap::new();
382+
let input = Value::Number(42.into());
383+
let result = sanitize_js_filter(&input, &args).unwrap();
384+
assert_eq!(result, Value::Number(42.into()));
385+
}
386+
299387
#[test]
300388
fn test_get_link_action_text() {
301389
// Test GitHub URLs

functions/create-issue/templates/newsletter.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,24 +17,24 @@ TODO: WRITE INTRO
1717
{%- endif %}
1818

1919

20-
<a href="{{ primary_link.campaignUrls.image }}" target="_blank" rel="noopener noreferrer"><img src="{{ primary_link.image }}" draggable="false" alt="A screenshot from the article {{ primary_link.title }}"></a>
20+
<a href="{{ primary_link.campaignUrls.image }}" target="_blank" rel="noopener noreferrer"><img src="{{ primary_link.image }}" draggable="false" alt="A screenshot from the article {{ primary_link.title | sanitize_js }}"></a>
2121

22-
[**{{ primary_link.title }}**]({{ primary_link.campaignUrls.title }}) — {{ primary_link.description }} [**{{ primary_link.action_text }}**]({{ primary_link.campaignUrls.description }})
22+
[**{{ primary_link.title | sanitize_js }}**]({{ primary_link.campaignUrls.title }}) — {{ primary_link.description | sanitize_js }} [**{{ primary_link.action_text }}**]({{ primary_link.campaignUrls.description }})
2323

2424
{% for link in secondary_links -%}
25-
[**{{ link.title }}**]({{ link.campaignUrls.title }}) — {{ link.description }} [**{{ link.action_text }}**]({{ link.campaignUrls.description }})
25+
[**{{ link.title | sanitize_js }}**]({{ link.campaignUrls.title }}) — {{ link.description | sanitize_js }} [**{{ link.action_text }}**]({{ link.campaignUrls.description }})
2626

2727
{% endfor -%}
2828

2929
---
3030

3131
# 📕 Book of the week!
3232

33-
[**{{ book.title }}**, by {{ book.author }}]({{ book.links.us }})
33+
[**{{ book.title | sanitize_js }}**, by {{ book.author }}]({{ book.links.us }})
3434

35-
[![{{ book.title }}]({{ book.coverPicture }})]({{ book.links.us }})
35+
[![{{ book.title | sanitize_js }}]({{ book.coverPicture }})]({{ book.links.us }})
3636

37-
{{ book.description }}
37+
{{ book.description | sanitize_js }}
3838

3939
[**Buy on Amazon.com**]({{ book.links.us }}) - [**Buy on Amazon.co.uk**]({{ book.links.uk }})
4040

@@ -44,7 +44,7 @@ TODO: WRITE INTRO
4444
### {{ extra_content_title }}
4545

4646
{% for link in extra_links -%}
47-
- [{{ link.title }}]({{ link.campaignUrls.title }})
47+
- [{{ link.title | sanitize_js }}]({{ link.campaignUrls.title }})
4848
{% endfor -%}
4949

5050
---

0 commit comments

Comments
 (0)