···11391139 let html = render_text_with_facets_html(text, Some(&facets), &limits);
11401140 assert_eq!(html, "Hello world");
11411141 }
11421142+11431143+ #[test]
11441144+ fn test_parse_urls_from_atproto_record_text() {
11451145+ // Test parsing URLs from real AT Protocol record description text.
11461146+ // This demonstrates the correct byte positions that should be used for facets.
11471147+ let text = "Dev, Power Users, and Generally inquisitive folks get a completely unprofessionally amateur interview. Just a yap sesh where chat is part of the call!\n\n✨the daniel✨ & I will be on a Zoom call and I will stream out to https://stream.place/psingletary.com\n\nSubscribe to the publications! https://atprotocalls.leaflet.pub/";
11481148+11491149+ let url_spans = parse_urls(text);
11501150+11511151+ assert_eq!(url_spans.len(), 2, "Should find 2 URLs");
11521152+11531153+ // First URL: https://stream.place/psingletary.com
11541154+ assert_eq!(url_spans[0].url, "https://stream.place/psingletary.com");
11551155+ assert_eq!(url_spans[0].start, 221);
11561156+ assert_eq!(url_spans[0].end, 257);
11571157+11581158+ // Second URL: https://atprotocalls.leaflet.pub/
11591159+ assert_eq!(url_spans[1].url, "https://atprotocalls.leaflet.pub/");
11601160+ assert_eq!(url_spans[1].start, 290);
11611161+ assert_eq!(url_spans[1].end, 323);
11621162+11631163+ // Verify the byte slices match the expected text
11641164+ let text_bytes = text.as_bytes();
11651165+ assert_eq!(
11661166+ std::str::from_utf8(&text_bytes[221..257]).unwrap(),
11671167+ "https://stream.place/psingletary.com"
11681168+ );
11691169+ assert_eq!(
11701170+ std::str::from_utf8(&text_bytes[290..323]).unwrap(),
11711171+ "https://atprotocalls.leaflet.pub/"
11721172+ );
11731173+11741174+ // Note: The AT Protocol record had incorrect facet indices:
11751175+ // - First link: byteStart=222, byteEnd=258 (should be 221, 257)
11761176+ // - Second link: byteStart=291, byteEnd=324 (should be 290, 323)
11771177+ // This off-by-one error was in the source data, not our parser.
11781178+ }
11791179+11801180+ #[test]
11811181+ fn test_render_with_off_by_one_facet_indices() {
11821182+ // Regression test for facets with off-by-one byte indices from external AT Protocol data.
11831183+ // The facets in this test have byteStart values that are 1 byte too high, causing
11841184+ // the first character of the URL to appear outside the link tag.
11851185+ //
11861186+ // This test documents the current behavior: the renderer faithfully applies facets
11871187+ // at the specified byte positions, even if those positions are incorrect.
11881188+ // The root cause is incorrect facet generation by the client that created the record.
11891189+ let text = "Dev, Power Users, and Generally inquisitive folks get a completely unprofessionally amateur interview. Just a yap sesh where chat is part of the call!\n\n✨the daniel✨ & I will be on a Zoom call and I will stream out to https://stream.place/psingletary.com\n\nSubscribe to the publications! https://atprotocalls.leaflet.pub/";
11901190+11911191+ // Verify text length - the second facet's byte_end (324) exceeds this
11921192+ assert_eq!(text.len(), 323, "Text should be 323 bytes");
11931193+11941194+ let limits = FacetLimits::default();
11951195+11961196+ // These facets have incorrect byte indices (off by 1) - this is real data from AT Protocol
11971197+ let facets = vec![
11981198+ Facet {
11991199+ index: ByteSlice {
12001200+ byte_start: 222, // Should be 221
12011201+ byte_end: 258, // Should be 257 (but 258 is within bounds)
12021202+ },
12031203+ features: vec![FacetFeature::Link(Link {
12041204+ uri: "https://stream.place/psingletary.com".to_string(),
12051205+ })],
12061206+ },
12071207+ Facet {
12081208+ index: ByteSlice {
12091209+ byte_start: 291, // Should be 290
12101210+ byte_end: 324, // Should be 323 - but 324 > text.len() so this facet is SKIPPED
12111211+ },
12121212+ features: vec![FacetFeature::Link(Link {
12131213+ uri: "https://atprotocalls.leaflet.pub/".to_string(),
12141214+ })],
12151215+ },
12161216+ ];
12171217+12181218+ let html = render_text_with_facets_html(text, Some(&facets), &limits);
12191219+12201220+ // Due to off-by-one facet indices, the 'h' from 'https' appears before the link tag
12211221+ assert!(
12221222+ html.contains(r#"stream out to h<a href="https://stream.place/psingletary.com""#),
12231223+ "First link should have 'h' outside due to off-by-one error. Got: {}",
12241224+ html
12251225+ );
12261226+12271227+ // The second facet is SKIPPED entirely because byte_end (324) > text.len() (323)
12281228+ // This is the bounds check in render_text_with_facets_html preventing out-of-bounds access
12291229+ assert!(
12301230+ !html.contains(r#"<a href="https://atprotocalls.leaflet.pub/""#),
12311231+ "Second link should NOT be rendered because facet is out of bounds. Got: {}",
12321232+ html
12331233+ );
12341234+ assert!(
12351235+ html.contains("https://atprotocalls.leaflet.pub/"),
12361236+ "Second URL should appear as plain text. Got: {}",
12371237+ html
12381238+ );
12391239+12401240+ // Verify correct byte positions from our parser
12411241+ let url_spans = parse_urls(text);
12421242+ assert_eq!(url_spans.len(), 2, "Should find 2 URLs");
12431243+12441244+ // The correct byte positions from our parser
12451245+ assert_eq!(
12461246+ url_spans[0].start, 221,
12471247+ "First URL should start at byte 221, not 222"
12481248+ );
12491249+ assert_eq!(
12501250+ url_spans[0].end, 257,
12511251+ "First URL should end at byte 257, not 258"
12521252+ );
12531253+ assert_eq!(
12541254+ url_spans[1].start, 290,
12551255+ "Second URL should start at byte 290, not 291"
12561256+ );
12571257+ assert_eq!(
12581258+ url_spans[1].end, 323,
12591259+ "Second URL should end at byte 323, not 324"
12601260+ );
12611261+ }
11421262}
+6-3
src/http/handle_create_event.rs
···630630 })
631631 .collect();
632632633633+ // Trim description first - facets must be parsed from the same text that gets stored
634634+ let description = request.description.trim().to_string();
635635+633636 // Parse facets from description
634637 let facet_limits = crate::facets::FacetLimits {
635638 mentions_max: web_context.config.facets_mentions_max,
···638641 max: web_context.config.facets_max,
639642 };
640643641641- let facets = if !request.description.is_empty() {
644644+ let facets = if !description.is_empty() {
642645 crate::facets::parse_facets_from_text(
643643- &request.description,
646646+ &description,
644647 web_context.identity_resolver.as_ref(),
645648 &facet_limits,
646649 )
···697700 // Create the event record
698701 let the_record = Event {
699702 name: request.name.trim().to_string(),
700700- description: request.description.trim().to_string(),
703703+ description,
701704 created_at: now,
702705 starts_at,
703706 ends_at,
+6-3
src/http/handle_edit_event.rs
···757757 })
758758 .collect();
759759760760+ // Trim description first - facets must be parsed from the same text that gets stored
761761+ let description = request.description.trim().to_string();
762762+760763 // Parse facets
761764 let facet_limits = crate::facets::FacetLimits {
762765 mentions_max: ctx.web_context.config.facets_mentions_max,
···765768 max: ctx.web_context.config.facets_max,
766769 };
767770768768- let facets = if !request.description.is_empty() {
771771+ let facets = if !description.is_empty() {
769772 crate::facets::parse_facets_from_text(
770770- &request.description,
773773+ &description,
771774 ctx.web_context.identity_resolver.as_ref(),
772775 &facet_limits,
773776 )
···827830 // Create updated record (preserve extra fields from existing event)
828831 let the_record = LexiconCommunityEvent {
829832 name: request.name.trim().to_string(),
830830- description: request.description.trim().to_string(),
833833+ description,
831834 created_at: existing_event.created_at, // Preserve original creation time
832835 starts_at,
833836 ends_at,
+5-2
src/http/handle_preview_description.rs
···4747 .into_response());
4848 }
49495050+ // Trim description to match create/edit behavior - facets must be parsed from the same text
5151+ let description = request.description.trim();
5252+5053 // Parse facets from the description
5154 let limits = FacetLimits::default();
5255 let facets = parse_facets_from_text(
5353- &request.description,
5656+ description,
5457 web_context.identity_resolver.as_ref(),
5558 &limits,
5659 )
5760 .await;
58615962 // Render HTML with facets
6060- let html = render_text_with_facets_html(&request.description, facets.as_ref(), &limits);
6363+ let html = render_text_with_facets_html(description, facets.as_ref(), &limits);
61646265 // Convert newlines to <br> tags for proper display
6366 let html_with_breaks = html.replace('\n', "<br>");