From eac3bf199f165e6fb5b1a1b076d98ef9a7450968 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:51:29 +0100 Subject: [PATCH 1/2] fix tag validator for plugins/datapacks --- apps/labrinth/fixtures/dummy_data.sql | 14 +++ apps/labrinth/src/test/dummy_data.rs | 67 ++++++++++++- apps/labrinth/src/validate/project/tags.rs | 8 +- apps/labrinth/tests/project.rs | 104 ++++++++++++++++++++- 4 files changed, 187 insertions(+), 6 deletions(-) diff --git a/apps/labrinth/fixtures/dummy_data.sql b/apps/labrinth/fixtures/dummy_data.sql index 5ab97bb347..90d580ea3f 100644 --- a/apps/labrinth/fixtures/dummy_data.sql +++ b/apps/labrinth/fixtures/dummy_data.sql @@ -28,6 +28,20 @@ INSERT INTO loaders_project_types (joining_loader_id, joining_project_type_id) V INSERT INTO loaders (id, loader, metadata) VALUES (7, 'bukkit', '{"platform":false}'::JSONB); INSERT INTO loaders (id, loader, metadata) VALUES (8, 'waterfall', '{"platform":true}'::JSONB); +INSERT INTO loaders (id, loader) VALUES (9, 'datapack'); + +INSERT INTO loaders_project_types (joining_loader_id, joining_project_type_id) +SELECT l.id, pt.id +FROM loaders l +CROSS JOIN project_types pt +WHERE + (l.loader IN ('bukkit', 'waterfall') AND pt.name = 'plugin') + OR (l.loader = 'datapack' AND pt.name = 'datapack'); + +INSERT INTO loaders_project_types_games (loader_id, project_type_id, game_id) +SELECT lpt.joining_loader_id, lpt.joining_project_type_id, 1 +FROM loaders_project_types lpt +WHERE lpt.joining_loader_id IN (7, 8, 9); -- Adds dummies to mrpack_loaders INSERT INTO loader_field_enum_values (enum_id, value) diff --git a/apps/labrinth/src/test/dummy_data.rs b/apps/labrinth/src/test/dummy_data.rs index 4f26c5b9aa..4b8038be1a 100644 --- a/apps/labrinth/src/test/dummy_data.rs +++ b/apps/labrinth/src/test/dummy_data.rs @@ -21,7 +21,7 @@ use super::{ use super::{database::USER_USER_ID, get_json_val_str}; -pub const DUMMY_DATA_UPDATE: i64 = 8; +pub const DUMMY_DATA_UPDATE: i64 = 9; pub const DUMMY_CATEGORIES: &[&str] = &[ "combat", @@ -47,6 +47,8 @@ pub enum TestFile { // and BasicModRandom.bytes() will return a different file each time. BasicModRandom { filename: String, bytes: Vec }, BasicModpackRandom { filename: String, bytes: Vec }, + BasicPluginRandom { filename: String, bytes: Vec }, + BasicDatapackRandom { filename: String, bytes: Vec }, } impl TestFile { @@ -163,6 +165,59 @@ impl TestFile { TestFile::BasicModpackRandom { filename, bytes } } + + pub fn build_random_plugin() -> Self { + let filename = format!("random-plugin-{}.jar", rand::random::()); + let plugin_yml = + "name: TestPlugin\nversion: 1.0.0\nmain: com.example.TestPlugin\n"; + + let mut cursor = Cursor::new(Vec::new()); + { + let mut zip = ZipWriter::new(&mut cursor); + zip.start_file( + "plugin.yml", + FileOptions::<()>::default() + .compression_method(CompressionMethod::Stored), + ) + .unwrap(); + zip.write_all(plugin_yml.as_bytes()).unwrap(); + zip.finish().unwrap(); + } + + TestFile::BasicPluginRandom { + filename, + bytes: cursor.into_inner(), + } + } + + pub fn build_random_datapack() -> Self { + let filename = format!("random-datapack-{}.zip", rand::random::()); + let pack_mcmeta = serde_json::json!({ + "pack": { + "pack_format": 15, + "description": "Test datapack" + } + }) + .to_string(); + + let mut cursor = Cursor::new(Vec::new()); + { + let mut zip = ZipWriter::new(&mut cursor); + zip.start_file( + "pack.mcmeta", + FileOptions::<()>::default() + .compression_method(CompressionMethod::Stored), + ) + .unwrap(); + zip.write_all(pack_mcmeta.as_bytes()).unwrap(); + zip.finish().unwrap(); + } + + TestFile::BasicDatapackRandom { + filename, + bytes: cursor.into_inner(), + } + } } #[derive(Clone)] @@ -472,6 +527,8 @@ impl TestFile { TestFile::BasicModDifferent => "basic-mod-different.jar", TestFile::BasicModRandom { filename, .. } => filename, TestFile::BasicModpackRandom { filename, .. } => filename, + TestFile::BasicPluginRandom { filename, .. } => filename, + TestFile::BasicDatapackRandom { filename, .. } => filename, } .to_string() } @@ -498,6 +555,8 @@ impl TestFile { } TestFile::BasicModRandom { bytes, .. } => bytes.clone(), TestFile::BasicModpackRandom { bytes, .. } => bytes.clone(), + TestFile::BasicPluginRandom { bytes, .. } => bytes.clone(), + TestFile::BasicDatapackRandom { bytes, .. } => bytes.clone(), } } @@ -512,6 +571,8 @@ impl TestFile { TestFile::BasicZip => "resourcepack", TestFile::BasicModpackRandom { .. } => "modpack", + TestFile::BasicPluginRandom { .. } => "plugin", + TestFile::BasicDatapackRandom { .. } => "datapack", } .to_string() } @@ -529,6 +590,10 @@ impl TestFile { TestFile::BasicModpackRandom { .. } => { Some("application/x-modrinth-modpack+zip") } + TestFile::BasicPluginRandom { .. } => { + Some("application/java-archive") + } + TestFile::BasicDatapackRandom { .. } => Some("application/zip"), } .map(|s| s.to_string()) } diff --git a/apps/labrinth/src/validate/project/tags.rs b/apps/labrinth/src/validate/project/tags.rs index 53a8f40746..fda8188387 100644 --- a/apps/labrinth/src/validate/project/tags.rs +++ b/apps/labrinth/src/validate/project/tags.rs @@ -18,8 +18,8 @@ pub(super) fn validate( let tag_count = project.categories.len() + project.additional_categories.len(); let is_minecraft_server = project.components.minecraft_server.is_some(); - let (project_type, actual_project_type) = - LegacyProject::get_project_type(&project.project_types); + let project_type = + LegacyProject::get_project_type(&project.project_types).0; if !project.versions.is_empty() && project.categories.is_empty() { nags.push(ProjectNag::new( @@ -83,9 +83,9 @@ pub(super) fn validate( if let Some(available_categories) = available_categories { let total_available_tags = available_categories .iter() - .filter(|category| category.project_type == actual_project_type) + .filter(|category| category.project_type == project_type) .count(); - if tag_count == total_available_tags && project_type != "project" { + if total_available_tags > 0 && tag_count == total_available_tags { nags.push( ProjectNag::new( ProjectNagKind::AllTagsSelected, diff --git a/apps/labrinth/tests/project.rs b/apps/labrinth/tests/project.rs index 02f3551fa1..023aca2028 100644 --- a/apps/labrinth/tests/project.rs +++ b/apps/labrinth/tests/project.rs @@ -1,13 +1,14 @@ use actix_http::StatusCode; use actix_web::test; use common::api_v3::ApiV3; +use common::api_v3::request_data::get_public_project_creation_data; use common::database::*; use common::dummy_data::DUMMY_CATEGORIES; use crate::common::api_common::models::CommonProject; use crate::common::api_common::request_data::ProjectCreationRequestData; use crate::common::api_common::{ - Api, ApiProject, ApiTeams, ApiVersion, AppendsOptionalPat, + Api, ApiProject, ApiTags, ApiTeams, ApiVersion, AppendsOptionalPat, }; use crate::common::dummy_data::{ DummyImage, DummyOrganizationZeta, DummyProjectAlpha, DummyProjectBeta, @@ -657,6 +658,107 @@ async fn test_leaving_review_skips_validation() { .await; } +#[actix_rt::test] +async fn test_plugin_and_datapack_validation_use_mod_tags() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + let mod_categories = api + .get_categories_deserialized_common() + .await + .into_iter() + .filter(|category| category.project_type == "mod") + .map(|category| category.name) + .collect::>(); + assert!(mod_categories.len() > 3); + + for (project_type, loader, file) in [ + ("plugin", "bukkit", TestFile::build_random_plugin()), + ( + "datapack", + "datapack", + TestFile::build_random_datapack(), + ), + ] { + let slug = format!("all-tags-selected-{project_type}"); + let modify_json = serde_json::from_value(json!([ + { "op": "replace", "path": "/is_draft", "value": true }, + { "op": "replace", "path": "/categories", "value": mod_categories[..3] }, + { "op": "add", "path": "/additional_categories", "value": mod_categories[3..] }, + { "op": "replace", "path": "/initial_versions/0/loaders", "value": [loader] }, + ])) + .unwrap(); + let creation_data = get_public_project_creation_data( + &slug, + Some(file), + Some(modify_json), + ); + let response = + api.create_project(creation_data, USER_USER_PAT).await; + assert_status!(&response, StatusCode::OK); + + let request = test::TestRequest::get() + .uri(&format!("/v3/project/{slug}/validate")) + .append_pat(USER_USER_PAT) + .to_request(); + let response = api.call(request).await; + assert_status!(&response, StatusCode::OK); + let validation: serde_json::Value = + test::read_body_json(response).await; + let all_tags_selected = validation["nags"] + .as_array() + .unwrap() + .iter() + .find(|nag| nag["kind"] == "all_tags_selected") + .expect("all shared mod tags should be detected as selected"); + + assert_eq!( + all_tags_selected["details"]["total_available_tags"], + mod_categories.len() + ); + } + }, + ) + .await; +} + +#[actix_rt::test] +async fn test_zero_available_tags_are_not_all_selected() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + let creation_data = get_public_project_creation_data( + "project-without-an-inferred-type", + None, + None, + ); + let response = + api.create_project(creation_data, USER_USER_PAT).await; + assert_status!(&response, StatusCode::OK); + + let request = test::TestRequest::get() + .uri("/v3/project/project-without-an-inferred-type/validate") + .append_pat(USER_USER_PAT) + .to_request(); + let response = api.call(request).await; + assert_status!(&response, StatusCode::OK); + let validation: serde_json::Value = + test::read_body_json(response).await; + + assert!( + validation["nags"] + .as_array() + .unwrap() + .iter() + .all(|nag| nag["kind"] != "all_tags_selected") + ); + }, + ) + .await; +} + #[actix_rt::test] async fn test_add_invalid_gallery_item_in_review_rolls_back() { with_test_environment( From 22750f66ca24a363e2960a3fa0bc136d08a3c7b4 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Sun, 6 Sep 2026 18:22:07 +0100 Subject: [PATCH 2/2] Ignore some nags inside code blocks --- Cargo.lock | 12 + Cargo.toml | 3 +- apps/labrinth/Cargo.toml | 1 + .../src/validate/project/description.rs | 42 +-- .../validate/project/description/markdown.rs | 253 ++++++++++++++++++ apps/labrinth/src/validate/project/text.rs | 135 +--------- 6 files changed, 297 insertions(+), 149 deletions(-) create mode 100644 apps/labrinth/src/validate/project/description/markdown.rs diff --git a/Cargo.lock b/Cargo.lock index acce146a94..e00fed2cab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5689,6 +5689,7 @@ dependencies = [ "postcard", "prometheus", "psl", + "pulldown-cmark", "quick-xml 0.38.3", "rand 0.8.5", "rand_chacha 0.3.1", @@ -8155,6 +8156,17 @@ dependencies = [ "psl-types", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.9.4", + "memchr", + "unicase", +] + [[package]] name = "pxfm" version = "0.1.25" diff --git a/Cargo.toml b/Cargo.toml index a4a779c2dd..a2a4264bf9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -152,7 +152,8 @@ postcard = { version = "1.1.3", default-features = false, features = ["alloc"] } postcard-bindgen = "0.8.0" proc-macro2 = { version = "1.0" } prometheus = "0.14.0" -psl = "2.1.226" +pulldown-cmark = { version = "0.13.4", default-features = false } +psl = "2.1.145" quartz_nbt = "0.2.9" quick-xml = "0.38.3" quote = { version = "1.0" } diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml index e813bd2c3d..f1b6f64518 100644 --- a/apps/labrinth/Cargo.toml +++ b/apps/labrinth/Cargo.toml @@ -82,6 +82,7 @@ paste = { workspace = true } path-util = { workspace = true } postcard = { workspace = true } prometheus = { workspace = true } +pulldown-cmark = { workspace = true } psl = { workspace = true } quick-xml = { workspace = true } rand = { workspace = true } diff --git a/apps/labrinth/src/validate/project/description.rs b/apps/labrinth/src/validate/project/description.rs index d38e8774d9..27dab4c4e3 100644 --- a/apps/labrinth/src/validate/project/description.rs +++ b/apps/labrinth/src/validate/project/description.rs @@ -1,12 +1,14 @@ +mod markdown; + use serde_json::json; +use self::markdown::DescriptionMarkdown; use super::text::{ - ProfanityKind, contains_description_spam, description_ends_with_header, - extract_description_blocks, extract_description_text, - find_banned_description_link, has_adjacent_same_level_headers, + ProfanityKind, contains_description_spam, extract_description_blocks, + extract_description_text, find_banned_description_link, has_image_without_alt_text, has_sufficient_english_blocks, - js_string_length, long_header_count, non_standard_text_ratio, - normalize_project_field_text, profanity_matches, project_requires_english, + js_string_length, non_standard_text_ratio, normalize_project_field_text, + profanity_matches, project_requires_english, }; use super::{ProjectNag, ProjectNagKind, ProjectNagSeverity}; @@ -19,11 +21,15 @@ const NON_STANDARD_TEXT_FAILURE_THRESHOLD: f64 = 0.05; pub(super) fn validate(project: &Project) -> Vec { let mut nags = Vec::new(); let description = project.description.as_str(); + let markdown = DescriptionMarkdown::parse(description); + let description_without_code = markdown.without_code(); let normalized_description = normalize_project_field_text(description); - let text = extract_description_text(description); - let has_spam = has_description_spam(description); - let normalized_text = extract_description_text(&normalized_description); - let blocks = extract_description_blocks(description); + let text = extract_description_text(&description_without_code); + let has_spam = has_description_spam(&description_without_code); + let normalized_text = + normalize_project_field_text(&description_without_code); + let normalized_text = extract_description_text(&normalized_text); + let blocks = extract_description_blocks(&description_without_code); let profanity = profanity_matches(description); if let Some(matched) = profanity @@ -51,7 +57,7 @@ pub(super) fn validate(project: &Project) -> Vec { .with_details(json!({ "value": matched.raw_text })), ); } - if non_standard_text_ratio(description) + if non_standard_text_ratio(&description_without_code) >= NON_STANDARD_TEXT_FAILURE_THRESHOLD { nags.push(ProjectNag::new( @@ -91,7 +97,7 @@ pub(super) fn validate(project: &Project) -> Vec { ProjectNagSeverity::Required, )); } - if let Some(url) = find_banned_description_link(description) { + if let Some(url) = find_banned_description_link(&description_without_code) { nags.push( ProjectNag::new( ProjectNagKind::ProjectDescriptionBannedLink, @@ -100,7 +106,7 @@ pub(super) fn validate(project: &Project) -> Vec { .with_details(json!({ "full_url": url })), ); } - let long_headers = long_header_count(description); + let long_headers = markdown.long_header_count(); if long_headers > 0 { nags.push( ProjectNag::new( @@ -110,19 +116,19 @@ pub(super) fn validate(project: &Project) -> Vec { .with_details(json!({ "count": long_headers })), ); } - if description_ends_with_header(description) { + if markdown.ends_with_header() { nags.push(ProjectNag::new( ProjectNagKind::DescriptionEndsWithHeader, ProjectNagSeverity::Required, )); } - if has_adjacent_same_level_headers(description) { + if markdown.has_adjacent_same_level_headers() { nags.push(ProjectNag::new( ProjectNagKind::AdjacentHeaders, ProjectNagSeverity::Required, )); } - if has_image_without_alt_text(description) { + if has_image_without_alt_text(&description_without_code) { nags.push(ProjectNag::new( ProjectNagKind::MissingAltText, ProjectNagSeverity::Warning, @@ -133,8 +139,10 @@ pub(super) fn validate(project: &Project) -> Vec { } pub(super) fn is_non_english(project: &Project) -> bool { - let text = extract_description_text(&project.description); - let blocks = extract_description_blocks(&project.description); + let markdown = DescriptionMarkdown::parse(&project.description); + let description_without_code = markdown.without_code(); + let text = extract_description_text(&description_without_code); + let blocks = extract_description_blocks(&description_without_code); is_non_english_text(project, &text, &blocks) } diff --git a/apps/labrinth/src/validate/project/description/markdown.rs b/apps/labrinth/src/validate/project/description/markdown.rs new file mode 100644 index 0000000000..f21e058a90 --- /dev/null +++ b/apps/labrinth/src/validate/project/description/markdown.rs @@ -0,0 +1,253 @@ +use std::ops::Range; +use std::sync::LazyLock; + +use pulldown_cmark::{Event, HeadingLevel, Parser, Tag, TagEnd}; +use regex::Regex; +use unicode_segmentation::UnicodeSegmentation; + +static HTML_HEADER: LazyLock = LazyLock::new(|| { + Regex::new(r"(?is)]*>(.*?)").unwrap() +}); +static ADJACENT_HTML_HEADERS: LazyLock = + LazyLock::new(|| Regex::new(r"(?is)\s* = LazyLock::new(|| { + Regex::new(r"(?is)\s*(?:]*>\s*)*$").unwrap() +}); + +#[derive(Debug)] +struct MarkdownHeading { + level: HeadingLevel, + text: String, + range: Range, +} + +pub(super) struct DescriptionMarkdown<'a> { + markdown: &'a str, + code_ranges: Vec>, + headings: Vec, +} + +impl<'a> DescriptionMarkdown<'a> { + pub(super) fn parse(markdown: &'a str) -> Self { + let mut code_ranges = Vec::new(); + let mut code_block_start = None; + let mut headings = Vec::new(); + let mut current_heading = None; + + for (event, range) in Parser::new(markdown).into_offset_iter() { + match event { + Event::Start(Tag::CodeBlock(_)) => { + code_block_start = Some(range.start); + } + Event::End(TagEnd::CodeBlock) => { + if let Some(start) = code_block_start.take() { + code_ranges.push(start..range.end); + } + } + Event::Start(Tag::Heading { level, .. }) => { + current_heading = Some(MarkdownHeading { + level, + text: String::new(), + range: range.start..range.end, + }); + } + Event::End(TagEnd::Heading(_)) => { + if let Some(mut heading) = current_heading.take() { + heading.range.end = range.end; + headings.push(heading); + } + } + Event::Code(text) => { + if code_block_start.is_none() { + code_ranges.push(range); + } + if let Some(heading) = &mut current_heading { + heading.text.push_str(&text); + } + } + Event::Text(text) + | Event::InlineMath(text) + | Event::DisplayMath(text) + | Event::FootnoteReference(text) => { + if let Some(heading) = &mut current_heading { + heading.text.push_str(&text); + } + } + Event::SoftBreak | Event::HardBreak => { + if let Some(heading) = &mut current_heading { + heading.text.push(' '); + } + } + _ => {} + } + } + + if let Some(start) = code_block_start { + code_ranges.push(start..markdown.len()); + } + + Self { + markdown, + code_ranges, + headings, + } + } + + pub(super) fn without_code(&self) -> String { + self.replace_code(" ") + } + + pub(super) fn long_header_count(&self) -> usize { + let markdown_headers = self + .headings + .iter() + .filter(|heading| is_primary_heading(heading.level)) + .filter(|heading| header_is_long(&heading.text)) + .count(); + let without_code = self.replace_code(" "); + let html_headers = HTML_HEADER + .captures_iter(&without_code) + .filter(|captures| header_is_long(&captures[1])) + .count(); + + markdown_headers + html_headers + } + + pub(super) fn ends_with_header(&self) -> bool { + let ends_with_markdown_header = + self.headings.last().is_some_and(|heading| { + self.markdown[heading.range.end..].trim().is_empty() + }); + let without_code = self.replace_code("\n[code]\n"); + + ends_with_markdown_header + || TRAILING_HTML_HEADER.is_match(without_code.trim_end()) + } + + pub(super) fn has_adjacent_same_level_headers(&self) -> bool { + let has_adjacent_markdown_headers = + self.headings.windows(2).any(|headings| { + let [previous, current] = headings else { + return false; + }; + is_primary_heading(previous.level) + && previous.level == current.level + && self.markdown[previous.range.end..current.range.start] + .trim() + .is_empty() + }); + let without_code = self.replace_code("\n[code]\n"); + let has_adjacent_html_headers = ADJACENT_HTML_HEADERS + .captures_iter(&without_code) + .any(|captures| { + captures.get(1).map(|level| level.as_str()) + == captures.get(2).map(|level| level.as_str()) + }); + + has_adjacent_markdown_headers || has_adjacent_html_headers + } + + fn replace_code(&self, replacement: &str) -> String { + if self.code_ranges.is_empty() { + return self.markdown.to_owned(); + } + + let mut without_code = String::with_capacity(self.markdown.len()); + let mut previous_end = 0; + for range in &self.code_ranges { + without_code.push_str(&self.markdown[previous_end..range.start]); + without_code.push_str(replacement); + previous_end = range.end; + } + without_code.push_str(&self.markdown[previous_end..]); + without_code + } +} + +fn header_is_long(header: &str) -> bool { + let mut rendered = String::new(); + for event in Parser::new(header) { + match event { + Event::Text(text) + | Event::Code(text) + | Event::InlineMath(text) + | Event::DisplayMath(text) + | Event::FootnoteReference(text) => rendered.push_str(&text), + _ => {} + } + } + + rendered.graphemes(true).count() > 80 +} + +fn is_primary_heading(level: HeadingLevel) -> bool { + matches!( + level, + HeadingLevel::H1 | HeadingLevel::H2 | HeadingLevel::H3 + ) +} + +#[cfg(test)] +mod tests { + use super::DescriptionMarkdown; + + #[test] + fn description_content_ignores_markdown_code() { + let markdown = r#" +```yaml +# This is a YAML comment rather than a heading +homepage: https://bit.ly/example +image: "![](/missing-alt.png)" +``` + +`https://bit.ly/inline` +"#; + + let markdown = DescriptionMarkdown::parse(markdown); + let without_code = markdown.without_code(); + assert!(!without_code.contains("YAML comment")); + assert!(!without_code.contains("bit.ly")); + } + + #[test] + fn heading_checks_ignore_fenced_and_indented_code() { + let long_comment = format!("# {}", "comment ".repeat(12)); + for markdown in [ + format!("```yaml\n{long_comment}\n```"), + format!("~~~yaml\n{long_comment}\n~~~"), + format!("````yaml\n```\n{long_comment}\n````"), + format!("Example: ```yaml\n{long_comment}\n```"), + format!("```yaml\n{long_comment}"), + format!(" {long_comment}"), + ] { + let markdown = DescriptionMarkdown::parse(&markdown); + assert_eq!(markdown.long_header_count(), 0); + assert!(!markdown.ends_with_header()); + } + } + + #[test] + fn code_blocks_separate_headers() { + let markdown = DescriptionMarkdown::parse( + "# First\n\n```yaml\n# comment\n```\n\n# Second", + ); + + assert!(!markdown.has_adjacent_same_level_headers()); + assert!(markdown.ends_with_header()); + } + + #[test] + fn rendered_headers_are_still_validated() { + let long_header = format!("# {}", "heading ".repeat(12)); + + assert_eq!( + DescriptionMarkdown::parse(&long_header).long_header_count(), + 1 + ); + assert!(DescriptionMarkdown::parse("Title\n=====").ends_with_header()); + assert!( + DescriptionMarkdown::parse("## First\n\n## Second") + .has_adjacent_same_level_headers() + ); + } +} diff --git a/apps/labrinth/src/validate/project/text.rs b/apps/labrinth/src/validate/project/text.rs index 0b04a3654f..c51df4d3d6 100644 --- a/apps/labrinth/src/validate/project/text.rs +++ b/apps/labrinth/src/validate/project/text.rs @@ -54,21 +54,6 @@ static DESCRIPTION_LINK_FINDER: LazyLock = LazyLock::new(|| { finder.kinds(&[LinkKind::Url]).url_must_have_scheme(false); finder }); -static HEADER: LazyLock = - LazyLock::new(|| Regex::new(r"(?m)^#{1,3}[\t ]+(.+?)\s*#*\s*$").unwrap()); -static HEADER_LINE: LazyLock = - LazyLock::new(|| Regex::new(r"^([#]{1,6})[\t ]+.+?\s*#*\s*$").unwrap()); -static SETEXT_HEADER: LazyLock = LazyLock::new(|| { - Regex::new(r"(?m)^([^\r\n]+)\r?\n[\t ]*(?:=+|-+)[\t ]*$").unwrap() -}); -static HTML_HEADER: LazyLock = LazyLock::new(|| { - Regex::new(r"(?is)]*>(.*?)").unwrap() -}); -static ADJACENT_HTML_HEADERS: LazyLock = - LazyLock::new(|| Regex::new(r"(?is)\s* = LazyLock::new(|| { - Regex::new(r"(?is)\s*(?:]*>\s*)*$").unwrap() -}); const URL_SHORTENERS: &[&str] = &["bit.ly", "adf.ly", "tinyurl.com", "short.io", "is.gd"]; @@ -646,8 +631,7 @@ pub(super) fn contains_description_spam(markdown: &str) -> bool { } fn extract_description_spam_blocks(markdown: &str) -> Vec { - let without_code = CODE_BLOCK.replace_all(markdown, "\n\n"); - let with_inline_code = INLINE_CODE.replace_all(&without_code, "$1"); + let with_inline_code = INLINE_CODE.replace_all(markdown, "$1"); let without_images = MARKDOWN_IMAGE.replace_all(&with_inline_code, " "); let with_link_labels = MARKDOWN_LINK.replace_all(&without_images, "$1"); let without_html_images = HTML_IMAGE.replace_all(&with_link_labels, " "); @@ -890,8 +874,7 @@ pub(super) fn has_paired_html_formatting(text: &str) -> bool { } pub(super) fn extract_description_text(markdown: &str) -> String { - let without_code = CODE_BLOCK.replace_all(markdown, " "); - let without_code = INLINE_CODE.replace_all(&without_code, " "); + let without_code = INLINE_CODE.replace_all(markdown, " "); let with_image_alt = MARKDOWN_IMAGE.replace_all(&without_code, "$1"); let without_links = MARKDOWN_LINK.replace_all(&with_image_alt, " "); let with_html_image_alt = HTML_IMAGE.replace_all( @@ -918,125 +901,15 @@ pub(super) fn extract_description_text(markdown: &str) -> String { } pub(super) fn extract_description_blocks(markdown: &str) -> Vec { - let without_code = CODE_BLOCK.replace_all(markdown, ""); DESCRIPTION_BLOCK_BREAK - .split(&without_code) + .split(markdown) .map(extract_description_text) .filter(|block| !block.is_empty()) .collect() } -pub(super) fn long_header_count(markdown: &str) -> usize { - let markdown_headers = HEADER - .captures_iter(markdown) - .filter(|captures| header_is_long(&captures[1])) - .count(); - let setext_headers = SETEXT_HEADER - .captures_iter(markdown) - .filter(|captures| !captures[1].trim_start().starts_with('#')) - .filter(|captures| header_is_long(&captures[1])) - .count(); - let html_headers = HTML_HEADER - .captures_iter(markdown) - .filter(|captures| header_is_long(&captures[1])) - .count(); - - markdown_headers + setext_headers + html_headers -} - -fn header_is_long(header: &str) -> bool { - let with_image_alt = MARKDOWN_IMAGE.replace_all(header, "$1"); - let with_link_text = MARKDOWN_LINK.replace_all(&with_image_alt, "$1"); - let without_html = HTML_TAG.replace_all(&with_link_text, " "); - let rendered = without_html - .replace(['*', '_', '~', '`'], "") - .split_whitespace() - .collect::>() - .join(" "); - - rendered.graphemes(true).count() > 80 -} - -pub(super) fn description_ends_with_header(markdown: &str) -> bool { - let trimmed = markdown.trim_end(); - if trimmed.is_empty() { - return false; - } - - let lines = trimmed.lines().collect::>(); - let last_line = lines.last().map_or("", |line| line.trim()); - if HEADER_LINE.is_match(last_line) { - return true; - } - if lines.len() >= 2 - && is_setext_underline(last_line) - && !lines[lines.len() - 2].trim().is_empty() - { - return true; - } - - TRAILING_HTML_HEADER.is_match(trimmed) -} - -pub(super) fn has_adjacent_same_level_headers(markdown: &str) -> bool { - let lines = markdown.lines().collect::>(); - let mut previous_header = None; - let mut index = 0; - while index < lines.len() { - let line = lines[index].trim(); - if line.is_empty() { - index += 1; - continue; - } - - let mut header_level = HEADER_LINE - .captures(line) - .and_then(|captures| captures.get(1)) - .map(|hashes| hashes.as_str().len()); - if header_level.is_none() - && lines - .get(index + 1) - .is_some_and(|underline| is_setext_underline(underline.trim())) - { - header_level = - Some(if lines[index + 1].trim_start().starts_with('=') { - 1 - } else { - 2 - }); - index += 1; - } - - if let Some(level) = header_level { - if level <= 3 && previous_header == Some(level) { - return true; - } - previous_header = Some(level); - } else { - previous_header = None; - } - index += 1; - } - - ADJACENT_HTML_HEADERS - .captures_iter(markdown) - .any(|captures| { - captures.get(1).map(|level| level.as_str()) - == captures.get(2).map(|level| level.as_str()) - }) -} - -fn is_setext_underline(line: &str) -> bool { - let mut characters = line.chars(); - let Some(marker @ ('=' | '-')) = characters.next() else { - return false; - }; - characters.all(|character| character == marker) -} - pub(super) fn has_image_without_alt_text(markdown: &str) -> bool { - let without_code = CODE_BLOCK.replace_all(markdown, ""); - let without_code = INLINE_CODE.replace_all(&without_code, ""); + let without_code = INLINE_CODE.replace_all(markdown, ""); MARKDOWN_IMAGE .captures_iter(&without_code) .any(|captures| captures[1].trim().is_empty())