use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum DocBlock {
Markdown { text: String },
Admonition {
variant: String,
title: Option<String>,
blocks: Vec<DocBlock>,
},
Tabs { panes: Vec<DocTabPane> },
Cards { items: Vec<DocCard> },
CodeGroup { tabs: Vec<DocCodeTab> },
Code {
language: String,
title: Option<String>,
diff: bool,
lines: Vec<DocCodeLine>,
},
Unknown { name: String, blocks: Vec<DocBlock> },
Component {
name: String,
props: std::collections::BTreeMap<String, serde_json::Value>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DocTabPane {
pub label: String,
pub blocks: Vec<DocBlock>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DocCard {
pub title: String,
pub href: Option<String>,
pub description: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DocCodeTab {
pub label: String,
pub language: String,
pub diff: bool,
pub lines: Vec<DocCodeLine>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DocCodeLine {
pub text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mark: Option<String>,
#[serde(default, skip_serializing_if = "is_not_highlighted")]
pub highlight: bool,
}
fn is_not_highlighted(value: &bool) -> bool {
!*value
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FenceInfo {
pub language: String,
pub title: Option<String>,
pub highlight: Vec<usize>,
}
const HIGHLIGHT_RANGE_MAX: usize = 500;
const ADMONITIONS: [&str; 6] = ["note", "tip", "info", "warning", "danger", "caution"];
pub fn parse(markdown: &str) -> Vec<DocBlock> {
group(parse_pieces(markdown))
}
enum Piece {
Block(DocBlock),
Pane(DocTabPane),
}
fn group(pieces: Vec<Piece>) -> Vec<DocBlock> {
let mut out: Vec<DocBlock> = Vec::new();
let mut panes: Vec<DocTabPane> = Vec::new();
for piece in pieces {
match piece {
Piece::Pane(pane) => panes.push(pane),
Piece::Block(block) => {
if !panes.is_empty() {
out.push(DocBlock::Tabs { panes: std::mem::take(&mut panes) });
}
out.push(block);
}
}
}
if !panes.is_empty() {
out.push(DocBlock::Tabs { panes });
}
out
}
fn parse_pieces(markdown: &str) -> Vec<Piece> {
let lines: Vec<&str> = markdown.lines().collect();
let mut out: Vec<Piece> = Vec::new();
let mut text: Vec<&str> = Vec::new();
let mut i = 0;
let mut fence: Option<usize> = None;
while i < lines.len() {
let line = lines[i];
if let Some(open) = fence {
if closes_fence(line, open) {
fence = None;
}
text.push(line);
i += 1;
continue;
}
if let Some(width) = opens_fence(line) {
if !line.starts_with([' ', '\t']) {
let (block, after) = code_block(line, &lines, i + 1, width);
flush(&mut text, &mut out);
out.push(Piece::Block(block));
i = after;
continue;
}
fence = Some(width);
text.push(line);
i += 1;
continue;
}
if let Some(call) = component_call(line) {
flush(&mut text, &mut out);
out.push(Piece::Block(call));
i += 1;
continue;
}
match directive_open(line) {
Some((_colons, name, argument)) => {
let (content, after) = directive_body(&lines, i + 1);
flush(&mut text, &mut out);
out.extend(directive(&name, &argument, &content));
i = after;
}
None => {
text.push(line);
i += 1;
}
}
}
flush(&mut text, &mut out);
out
}
fn flush(text: &mut Vec<&str>, out: &mut Vec<Piece>) {
let joined = text.join("\n");
text.clear();
if !joined.trim().is_empty() {
out.push(Piece::Block(DocBlock::Markdown { text: joined.trim_matches('\n').to_string() }));
}
}
fn component_call(line: &str) -> Option<DocBlock> {
let trimmed = line.trim();
let body = trimmed.strip_prefix('<')?.strip_suffix("/>")?;
if body.contains('<') || body.contains('{') || body.contains('}') {
return None;
}
let name_len = body.find(char::is_whitespace).unwrap_or(body.len());
let name = &body[..name_len];
if !name.starts_with(|c: char| c.is_ascii_uppercase()) || !name.chars().all(|c| c.is_ascii_alphanumeric()) {
return None;
}
let props = component_props(body[name_len..].trim())?;
Some(DocBlock::Component { name: name.to_string(), props })
}
fn component_props(text: &str) -> Option<std::collections::BTreeMap<String, serde_json::Value>> {
let mut props = std::collections::BTreeMap::new();
let chars: Vec<char> = text.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i].is_whitespace() {
i += 1;
continue;
}
let start = i;
while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '-' || chars[i] == '_') {
i += 1;
}
if i == start {
return None;
}
let key: String = chars[start..i].iter().collect();
if chars.get(i) != Some(&'=') {
props.insert(key, serde_json::Value::Bool(true));
continue;
}
i += 1;
let quote = *chars.get(i)?;
if quote != '"' && quote != '\'' {
return None;
}
i += 1;
let from = i;
while i < chars.len() && chars[i] != quote {
i += 1;
}
if i >= chars.len() {
return None;
}
let raw: String = chars[from..i].iter().collect();
i += 1;
props.insert(key, literal(&raw));
}
Some(props)
}
fn literal(raw: &str) -> serde_json::Value {
match raw {
"true" => return serde_json::Value::Bool(true),
"false" => return serde_json::Value::Bool(false),
_ => {}
}
if let Ok(number) = raw.parse::<f64>()
&& let Some(number) = serde_json::Number::from_f64(number)
{
return serde_json::Value::Number(number);
}
serde_json::Value::String(raw.to_string())
}
fn directive_open(line: &str) -> Option<(usize, String, String)> {
let trimmed = line.trim();
let colons = trimmed.chars().take_while(|c| *c == ':').count();
if colons < 3 {
return None;
}
let rest = trimmed[colons..].trim();
let mut chars = rest.chars();
let first = chars.next()?;
if !first.is_ascii_alphabetic() {
return None;
}
let name_len = rest.find(char::is_whitespace).unwrap_or(rest.len());
let name = &rest[..name_len];
if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
return None;
}
Some((colons, name.to_ascii_lowercase(), rest[name_len..].trim().to_string()))
}
fn directive_close(line: &str) -> Option<usize> {
let trimmed = line.trim();
let colons = trimmed.chars().take_while(|c| *c == ':').count();
(colons >= 3 && colons == trimmed.chars().count()).then_some(colons)
}
fn directive_body(lines: &[&str], start: usize) -> (Vec<String>, usize) {
let mut depth = 1usize;
let mut body = Vec::new();
let mut fence: Option<usize> = None;
let mut i = start;
while i < lines.len() {
let line = lines[i];
if let Some(open) = fence {
if closes_fence(line, open) {
fence = None;
}
body.push(line.to_string());
i += 1;
continue;
}
if let Some(width) = opens_fence(line) {
fence = Some(width);
body.push(line.to_string());
i += 1;
continue;
}
if directive_close(line).is_some() {
depth -= 1;
if depth == 0 {
return (body, i + 1);
}
body.push(line.to_string());
i += 1;
continue;
}
if directive_open(line).is_some() {
depth += 1;
}
body.push(line.to_string());
i += 1;
}
(body, lines.len())
}
fn directive(name: &str, argument: &str, content: &[String]) -> Vec<Piece> {
let body = content.join("\n");
match name {
"tab" => vec![Piece::Pane(DocTabPane {
label: if argument.is_empty() { "Tab".to_string() } else { strip_quotes(argument) },
blocks: parse(&body),
})],
"tabs" => parse_pieces(&body),
"cards" => vec![Piece::Block(DocBlock::Cards { items: cards(&body) })],
"code-group" | "codegroup" => vec![Piece::Block(DocBlock::CodeGroup { tabs: code_tabs(&body) })],
other if ADMONITIONS.contains(&other) => vec![Piece::Block(DocBlock::Admonition {
variant: other.to_string(),
title: (!argument.is_empty()).then(|| strip_quotes(argument)),
blocks: parse(&body),
})],
other => vec![Piece::Block(DocBlock::Unknown { name: other.to_string(), blocks: parse(&body) })],
}
}
fn cards(body: &str) -> Vec<DocCard> {
let mut items: Vec<DocCard> = Vec::new();
for line in body.lines() {
let trimmed = line.trim_start();
let bullet = trimmed.strip_prefix("- ").or_else(|| trimmed.strip_prefix("* "));
let Some(rest) = bullet else {
if let Some(last) = items.last_mut()
&& !trimmed.is_empty()
{
if !last.description.is_empty() {
last.description.push(' ');
}
last.description.push_str(trimmed);
}
continue;
};
let rest = rest.trim();
match link_at_start(rest) {
Some((title, href, tail)) => {
items.push(DocCard { title, href: Some(href), description: card_description(tail) })
}
None => {
let (title, description) = match rest.find([':', '\u{2014}', '\u{2013}']) {
Some(at) => (rest[..at].trim().to_string(), card_description(&rest[at..])),
None => (rest.to_string(), String::new()),
};
items.push(DocCard { title, href: None, description })
}
}
}
items
}
fn link_at_start(text: &str) -> Option<(String, String, &str)> {
let rest = text.strip_prefix('[')?;
let close = rest.find("](")?;
let title = &rest[..close];
let after = &rest[close + 2..];
let end = after.find(')')?;
let href = after[..end].split_whitespace().next().unwrap_or("");
Some((title.trim().to_string(), href.to_string(), &after[end + 1..]))
}
fn card_description(tail: &str) -> String {
tail.trim().trim_start_matches(['—', '–', '-', ':']).trim().to_string()
}
fn code_tabs(body: &str) -> Vec<DocCodeTab> {
let lines: Vec<&str> = body.lines().collect();
let mut tabs = Vec::new();
let mut i = 0;
while i < lines.len() {
let Some(width) = opens_fence(lines[i]) else {
i += 1;
continue;
};
let (info, body, after) = fenced(lines[i], &lines, i + 1, width);
i = after;
let label = info.title.clone().unwrap_or_else(|| {
if info.language.is_empty() { "Code".to_string() } else { info.language.clone() }
});
let (diff, code) = code_lines(&info.language, &info.highlight, &body);
tabs.push(DocCodeTab { label, language: info.language, diff, lines: code });
}
tabs
}
fn code_block(opening: &str, lines: &[&str], start: usize, width: usize) -> (DocBlock, usize) {
let (info, body, after) = fenced(opening, lines, start, width);
let (diff, code) = code_lines(&info.language, &info.highlight, &body);
(
DocBlock::Code { language: info.language, title: info.title, diff, lines: code },
after,
)
}
fn fenced<'a>(
opening: &str,
lines: &[&'a str],
start: usize,
width: usize,
) -> (FenceInfo, Vec<&'a str>, usize) {
let info = fence_info(opening.trim().trim_start_matches('`').trim());
let mut body: Vec<&'a str> = Vec::new();
let mut i = start;
while i < lines.len() && !closes_fence(lines[i], width) {
body.push(lines[i]);
i += 1;
}
(info, body, (i + 1).min(lines.len()))
}
fn code_lines(language: &str, highlight: &[usize], body: &[&str]) -> (bool, Vec<DocCodeLine>) {
let diff = language.eq_ignore_ascii_case("diff");
let lines = body
.iter()
.enumerate()
.map(|(index, line)| {
let (mark, text) = if diff { diff_mark(line) } else { (None, (*line).to_string()) };
DocCodeLine { text, mark, highlight: highlight.contains(&(index + 1)) }
})
.collect();
(diff, lines)
}
fn diff_mark(line: &str) -> (Option<String>, String) {
if line.starts_with("+++") || line.starts_with("---") || line.starts_with("@@") {
return (Some("meta".to_string()), line.to_string());
}
match line.as_bytes().first() {
Some(b'+') => (Some("add".to_string()), line[1..].to_string()),
Some(b'-') => (Some("remove".to_string()), line[1..].to_string()),
Some(b' ') => (None, line[1..].to_string()),
_ => (None, line.to_string()),
}
}
pub fn fence_info(info: &str) -> FenceInfo {
let language = info.split_whitespace().next().unwrap_or("").to_string();
let rest = &info[language.len()..];
FenceInfo { language, title: fence_title(rest), highlight: highlight_lines(rest) }
}
fn fence_title(rest: &str) -> Option<String> {
if let Some(at) = rest.find("title=") {
let value = rest[at + "title=".len()..].trim();
let label = match value.strip_prefix(['"', '\'']) {
Some(quoted) => quoted.split(['"', '\'']).next().unwrap_or("").to_string(),
None => value.split_whitespace().next().unwrap_or("").to_string(),
};
if !label.is_empty() {
return Some(label);
}
}
if let Some(open) = rest.find('[')
&& let Some(close) = rest[open..].find(']')
{
let label = rest[open + 1..open + close].trim().to_string();
if !label.is_empty() {
return Some(label);
}
}
None
}
fn highlight_lines(rest: &str) -> Vec<usize> {
let Some(open) = rest.find('{') else {
return Vec::new();
};
let Some(close) = rest[open..].find('}') else {
return Vec::new();
};
let mut out: Vec<usize> = Vec::new();
for part in rest[open + 1..open + close].split(',') {
let part = part.trim();
match part.split_once('-') {
Some((from, to)) => {
if let (Ok(from), Ok(to)) =
(from.trim().parse::<usize>(), to.trim().parse::<usize>())
{
let (low, high) = if from <= to { (from, to) } else { (to, from) };
if high - low <= HIGHLIGHT_RANGE_MAX {
out.extend(low..=high);
}
}
}
None => {
if let Ok(number) = part.parse::<usize>() {
out.push(number);
}
}
}
}
out.sort_unstable();
out.dedup();
out
}
fn opens_fence(line: &str) -> Option<usize> {
let trimmed = line.trim_start();
let width = trimmed.chars().take_while(|c| *c == '`').count();
(width >= 3).then_some(width)
}
fn closes_fence(line: &str, width: usize) -> bool {
let trimmed = line.trim();
let count = trimmed.chars().take_while(|c| *c == '`').count();
count >= width && trimmed.chars().skip(count).all(char::is_whitespace)
}
fn strip_quotes(text: &str) -> String {
let trimmed = text.trim();
match trimmed.strip_prefix(['"', '\'']) {
Some(rest) => rest.trim_end_matches(['"', '\'']).to_string(),
None => trimmed.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn code_text(lines: &[DocCodeLine]) -> String {
lines.iter().map(|line| line.text.as_str()).collect::<Vec<_>>().join("\n")
}
fn markdown_text(block: &DocBlock) -> &str {
match block {
DocBlock::Markdown { text } => text,
other => panic!("expected markdown, got {other:?}"),
}
}
#[test]
fn a_page_without_a_directive_is_one_markdown_block() {
let blocks = parse("# Title\n\nA paragraph.\n");
assert_eq!(blocks.len(), 1);
assert_eq!(markdown_text(&blocks[0]), "# Title\n\nA paragraph.");
}
#[test]
fn an_admonition_takes_its_title_from_the_opening_line() {
let blocks = parse("Before.\n\n:::warning Read this first\nBe careful.\n:::\n\nAfter.\n");
assert_eq!(blocks.len(), 3, "{blocks:?}");
match &blocks[1] {
DocBlock::Admonition { variant, title, blocks } => {
assert_eq!(variant, "warning");
assert_eq!(title.as_deref(), Some("Read this first"));
assert_eq!(markdown_text(&blocks[0]), "Be careful.");
}
other => panic!("expected an admonition, got {other:?}"),
}
assert_eq!(markdown_text(&blocks[2]), "After.");
}
#[test]
fn an_admonition_nests_inside_an_admonition() {
let blocks = parse("::::danger\nOuter.\n\n:::note\nInner.\n:::\n::::\n");
assert_eq!(blocks.len(), 1);
match &blocks[0] {
DocBlock::Admonition { variant, blocks, .. } => {
assert_eq!(variant, "danger");
assert_eq!(blocks.len(), 2, "{blocks:?}");
assert!(matches!(blocks[1], DocBlock::Admonition { .. }));
}
other => panic!("expected an admonition, got {other:?}"),
}
}
#[test]
fn consecutive_panes_become_one_tab_set() {
let blocks = parse(":::tab Linux\nApt.\n:::\n:::tab macOS\nBrew.\n:::\n");
assert_eq!(blocks.len(), 1);
match &blocks[0] {
DocBlock::Tabs { panes } => {
assert_eq!(panes.len(), 2);
assert_eq!(panes[0].label, "Linux");
assert_eq!(panes[1].label, "macOS");
assert_eq!(markdown_text(&panes[1].blocks[0]), "Brew.");
}
other => panic!("expected tabs, got {other:?}"),
}
}
#[test]
fn a_tabs_wrapper_holds_the_same_panes() {
let wrapped = parse("::::tabs\n:::tab One\nA.\n:::\n:::tab Two\nB.\n:::\n::::\n");
let bare = parse(":::tab One\nA.\n:::\n:::tab Two\nB.\n:::\n");
assert_eq!(wrapped, bare);
}
#[test]
fn a_card_list_keeps_its_links_and_descriptions() {
let blocks = parse(":::cards\n- [Getting started](./start.md) — install and run\n- [Concepts](./concepts.md): the model\n- Coming soon — one day\n:::\n");
match &blocks[0] {
DocBlock::Cards { items } => {
assert_eq!(items.len(), 3);
assert_eq!(items[0].title, "Getting started");
assert_eq!(items[0].href.as_deref(), Some("./start.md"));
assert_eq!(items[0].description, "install and run");
assert_eq!(items[1].description, "the model");
assert_eq!(items[2].href, None);
assert_eq!(items[2].title, "Coming soon");
assert_eq!(items[2].description, "one day");
}
other => panic!("expected cards, got {other:?}"),
}
}
#[test]
fn a_code_group_labels_each_block() {
let blocks = parse(":::code-group\n```sh title=\"npm\"\nnpm i\n```\n```sh [pnpm]\npnpm i\n```\n```rust\nfn main() {}\n```\n:::\n");
match &blocks[0] {
DocBlock::CodeGroup { tabs } => {
assert_eq!(tabs.len(), 3, "{tabs:?}");
assert_eq!(tabs[0].label, "npm");
assert_eq!(tabs[0].language, "sh");
assert_eq!(code_text(&tabs[0].lines), "npm i");
assert_eq!(tabs[1].label, "pnpm");
assert_eq!(tabs[2].label, "rust");
}
other => panic!("expected a code group, got {other:?}"),
}
}
#[test]
fn a_directive_inside_a_fence_stays_text() {
let source = "Write it like this:\n\n```md\n:::note\nhello\n:::\n```\n";
let blocks = parse(source);
assert_eq!(blocks.len(), 2, "{blocks:?}");
match &blocks[1] {
DocBlock::Code { language, lines, .. } => {
assert_eq!(language, "md");
assert_eq!(code_text(lines), ":::note\nhello\n:::");
}
other => panic!("expected a code block, got {other:?}"),
}
}
#[test]
fn a_fence_becomes_a_code_block_with_its_filename_and_highlights() {
let blocks = parse("Before.\n\n```ts title=\"server.ts\" {2,4-5}\none\ntwo\nthree\nfour\nfive\n```\n\nAfter.\n");
assert_eq!(blocks.len(), 3, "{blocks:?}");
match &blocks[1] {
DocBlock::Code { language, title, diff, lines } => {
assert_eq!(language, "ts");
assert_eq!(title.as_deref(), Some("server.ts"));
assert!(!diff);
let marked: Vec<bool> = lines.iter().map(|line| line.highlight).collect();
assert_eq!(marked, vec![false, true, false, true, true]);
}
other => panic!("expected a code block, got {other:?}"),
}
assert_eq!(markdown_text(&blocks[2]), "After.");
}
#[test]
fn an_indented_fence_stays_in_its_list() {
let blocks = parse("1. Install it:\n\n ```sh\n npm i\n ```\n\n2. Run it.\n");
assert_eq!(blocks.len(), 1, "{blocks:?}");
let text = markdown_text(&blocks[0]);
assert!(text.contains("npm i"), "{text}");
assert!(text.contains("2. Run it."), "{text}");
}
#[test]
fn a_diff_keeps_its_signs_out_of_the_line_text() {
let blocks = parse("```diff\n--- a/server.ts\n@@ -1,2 +1,2 @@\n-const port = 80;\n+const port = 8080;\n const host = \"localhost\";\n```\n");
match &blocks[0] {
DocBlock::Code { diff, lines, .. } => {
assert!(diff);
let marks: Vec<Option<&str>> =
lines.iter().map(|line| line.mark.as_deref()).collect();
assert_eq!(
marks,
vec![Some("meta"), Some("meta"), Some("remove"), Some("add"), None]
);
assert_eq!(
code_text(lines),
"--- a/server.ts\n@@ -1,2 +1,2 @@\nconst port = 80;\nconst port = 8080;\nconst host = \"localhost\";"
);
}
other => panic!("expected a code block, got {other:?}"),
}
}
#[test]
fn a_broken_info_string_still_answers_a_block() {
for info in ["ts {", "ts {abc}", "ts {0}", "ts {9-3}", "ts {1-1000000000}", ""] {
let source = format!("```{info}\none\ntwo\nthree\n```\n");
match &parse(&source)[0] {
DocBlock::Code { lines, .. } => {
assert_eq!(code_text(lines), "one\ntwo\nthree", "{info}");
}
other => panic!("{info} should still be a code block, got {other:?}"),
}
}
match &parse("```ts {9-3}\n1\n2\n3\n4\n```\n")[0] {
DocBlock::Code { lines, .. } => {
let marked: Vec<bool> = lines.iter().map(|line| line.highlight).collect();
assert_eq!(marked, vec![false, false, true, true]);
}
other => panic!("expected a code block, got {other:?}"),
}
}
#[test]
fn an_unclosed_fence_does_not_lose_the_rest_of_the_page() {
let blocks = parse("```sh\nnpm i\nstill here\n");
assert_eq!(blocks.len(), 1, "{blocks:?}");
match &blocks[0] {
DocBlock::Code { lines, .. } => assert_eq!(code_text(lines), "npm i\nstill here"),
other => panic!("expected a code block, got {other:?}"),
}
}
#[test]
fn an_unknown_directive_keeps_its_content() {
let blocks = parse(":::script\nalert(1)\n:::\n");
match &blocks[0] {
DocBlock::Unknown { name, blocks } => {
assert_eq!(name, "script");
assert_eq!(markdown_text(&blocks[0]), "alert(1)");
}
other => panic!("expected an unknown block, got {other:?}"),
}
}
#[test]
fn a_component_call_becomes_a_block_with_its_props() {
let blocks = parse("Before.\n\n<PotoPlayer src=\"stories/intro.poto\" controls height=\"420\" />\n\nAfter.\n");
assert_eq!(blocks.len(), 3, "{blocks:?}");
match &blocks[1] {
DocBlock::Component { name, props } => {
assert_eq!(name, "PotoPlayer");
assert_eq!(props["src"], serde_json::json!("stories/intro.poto"));
assert_eq!(props["controls"], serde_json::json!(true));
assert_eq!(props["height"], serde_json::json!(420.0));
}
other => panic!("expected a component, got {other:?}"),
}
}
#[test]
fn a_tag_that_is_not_a_literal_call_stays_text() {
for line in [
"<PotoPlayer src={story} />", "<PotoPlayer src=\"a\"></PotoPlayer>", "<div class=\"x\" />", "<potoPlayer src=\"a\" />", ] {
let blocks = parse(line);
assert!(
matches!(blocks.as_slice(), [DocBlock::Markdown { .. }]),
"{line} should stay text, got {blocks:?}"
);
}
}
#[test]
fn an_unclosed_directive_does_not_lose_the_rest_of_the_page() {
let blocks = parse(":::note\nStill here.\n\nAnd this too.\n");
match &blocks[0] {
DocBlock::Admonition { blocks, .. } => {
assert!(markdown_text(&blocks[0]).contains("And this too."));
}
other => panic!("expected an admonition, got {other:?}"),
}
}
}