#![no_std]
#![doc(html_root_url = "https://docs.rs/striptags/0.1.0")]
extern crate alloc;
use alloc::string::String;
use alloc::vec::Vec;
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeDoctests;
#[must_use]
pub fn strip_tags(html: &str) -> String {
strip_tags_with(html, &[], "")
}
#[must_use]
pub fn strip_tags_with(html: &str, allowed: &[&str], replacement: &str) -> String {
let mut ctx = Context::new();
run(html, allowed, replacement, &mut ctx)
}
#[derive(Debug, Clone)]
pub struct StripTags {
allowed: Vec<String>,
replacement: String,
ctx: Context,
}
impl Default for StripTags {
fn default() -> Self {
Self::new()
}
}
impl StripTags {
#[must_use]
pub fn new() -> Self {
Self {
allowed: Vec::new(),
replacement: String::new(),
ctx: Context::new(),
}
}
#[must_use]
pub fn with_allowed(allowed: &[&str]) -> Self {
Self {
allowed: allowed.iter().map(|s| (*s).into()).collect(),
replacement: String::new(),
ctx: Context::new(),
}
}
#[must_use]
pub fn replacement(mut self, replacement: &str) -> Self {
self.replacement = replacement.into();
self
}
pub fn feed(&mut self, html: &str) -> String {
let allowed: Vec<&str> = self.allowed.iter().map(String::as_str).collect();
run(html, &allowed, &self.replacement, &mut self.ctx)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum State {
Plaintext,
Html,
Comment,
}
#[derive(Debug, Clone)]
struct Context {
state: State,
tag_buffer: String,
depth: u32,
in_quote: Option<char>,
}
impl Context {
fn new() -> Self {
Self {
state: State::Plaintext,
tag_buffer: String::new(),
depth: 0,
in_quote: None,
}
}
}
fn run(html: &str, allowed: &[&str], replacement: &str, ctx: &mut Context) -> String {
let mut output = String::new();
for c in html.chars() {
match ctx.state {
State::Plaintext => {
if c == '<' {
ctx.state = State::Html;
ctx.tag_buffer.push('<');
} else {
output.push(c);
}
}
State::Html => match c {
'<' => {
if ctx.in_quote.is_none() {
ctx.depth += 1;
}
}
'>' => {
if ctx.in_quote.is_some() {
} else if ctx.depth > 0 {
ctx.depth -= 1;
} else {
ctx.in_quote = None;
ctx.state = State::Plaintext;
ctx.tag_buffer.push('>');
match normalize_tag(&ctx.tag_buffer) {
Some(tag) if allowed.iter().any(|t| *t == tag) => {
output.push_str(&ctx.tag_buffer);
}
_ => output.push_str(replacement),
}
ctx.tag_buffer.clear();
}
}
'"' | '\'' => {
if ctx.in_quote == Some(c) {
ctx.in_quote = None;
} else if ctx.in_quote.is_none() {
ctx.in_quote = Some(c);
}
ctx.tag_buffer.push(c);
}
'-' => {
if ctx.tag_buffer == "<!-" {
ctx.state = State::Comment;
}
ctx.tag_buffer.push('-');
}
' ' | '\n' => {
if ctx.tag_buffer == "<" {
ctx.state = State::Plaintext;
output.push_str("< ");
ctx.tag_buffer.clear();
} else {
ctx.tag_buffer.push(c);
}
}
_ => ctx.tag_buffer.push(c),
},
State::Comment => {
if c == '>' {
if ctx.tag_buffer.ends_with("--") {
ctx.state = State::Plaintext;
}
ctx.tag_buffer.clear();
} else {
ctx.tag_buffer.push(c);
}
}
}
}
output
}
fn normalize_tag(tag_buffer: &str) -> Option<String> {
let chars: Vec<char> = tag_buffer.chars().collect();
let lt = chars.iter().position(|&c| c == '<')?;
let mut i = lt + 1;
if chars.get(i) == Some(&'/') {
i += 1;
}
let start = i;
while i < chars.len() && !is_js_whitespace(chars[i]) && chars[i] != '/' && chars[i] != '>' {
i += 1;
}
if i == start {
return None;
}
Some(
chars[start..i]
.iter()
.flat_map(|c| c.to_lowercase())
.collect(),
)
}
fn is_js_whitespace(c: char) -> bool {
(c.is_whitespace() && c != '\u{0085}') || c == '\u{feff}'
}