`s use the same class names the Confluence export view would emit; `html::convert_to_md` then maps those classes to GitHub `[!IMPORTANT|NOTE|TIP|WARNING|CAUTION]` blocks during the final Markdown conversion.
+The alert `
`s use the same class names the Confluence export view would emit; `html::convert_html_to_markdown` then maps those classes to GitHub `[!IMPORTANT|NOTE|TIP|WARNING|CAUTION]` blocks during the final Markdown conversion.
Jira storage macros do not carry a browse URL. `jira::replace_jira_macros` derives the browse base from rendered Jira issue links already present in the REST response, avoiding any hardcoded Jira instance. Rendered issue spans are normalized to only the issue-key link so placeholder summary/status text is not emitted to Markdown.
diff --git a/README.md b/README.md
index 0afafd6..bb4bc8e 100644
--- a/README.md
+++ b/README.md
@@ -77,6 +77,7 @@ Set the following environment variables before running:
| `CONFLUENCE2MD_DUMP_STATE_PATH` | Directory to write diagnostic state and raw intermediate files | `dumps` |
| `CONFLUENCE2MD_LOG_LEVEL` | Log verbosity: `DEBUG` \| `INFO` \| `WARNING` \| `ERROR` (default: `INFO`) | `DEBUG` |
| `CONFLUENCE2MD_TABLE_CONVERSION` | Table conversion mode: `default` \| `always` (default: `default`) | `always` |
+| `CONFLUENCE2MD_REMOVE_STRIKETHROUGH_TEXT` | Set to `true` to remove strikethrough text entirely | `true` |
You can export them in your shell profile or pass them inline:
@@ -97,18 +98,20 @@ confluence2md 'https://confluence.example.com/pages/viewpage.action?pageId=39322
### Options
-| Option | Description | Default |
-| --------------------------- | -------------------------------------------------------------------------------- | ----------------- |
-| `--output-path
` | Directory to write the output Markdown file | Current directory |
-| `--dump-state-path ` | Directory to write raw API, intermediate HTML dumps, and raw `.drawio` XML files | Not written |
-| `--log-level ` | Log verbosity: `DEBUG` \| `INFO` \| `WARNING` \| `ERROR` | `INFO` |
-| `--table-conversion ` | Table conversion mode: `default` \| `always` | `default` |
-| `--version` | Print the version and exit | — |
+| Option | Description | Default |
+| ------------------------------- | -------------------------------------------------------------------------------- | ----------------- |
+| `--output-path ` | Directory to write the output Markdown file | Current directory |
+| `--dump-state-path ` | Directory to write raw API, intermediate HTML dumps, and raw `.drawio` XML files | Not written |
+| `--log-level ` | Log verbosity: `DEBUG` \| `INFO` \| `WARNING` \| `ERROR` | `INFO` |
+| `--table-conversion ` | Table conversion mode: `default` \| `always` | `default` |
+| `--remove-strikethrough-text[=true|false]` | Remove strikethrough text entirely instead of converting to `~~text~~`. Omitting the value implies `true`. When passing a value, the equals form is required (e.g. `--remove-strikethrough-text=false`). | Off |
+| `--version` | Print the version and exit | — |
> 💡 `--output-path` takes precedence over `CONFLUENCE2MD_OUTPUT_PATH`.
> 💡 `--dump-state-path` takes precedence over `CONFLUENCE2MD_DUMP_STATE_PATH`.
> 💡 `--log-level` takes precedence over `CONFLUENCE2MD_LOG_LEVEL`.
> 💡 `--table-conversion` takes precedence over `CONFLUENCE2MD_TABLE_CONVERSION`.
+> 💡 `--remove-strikethrough-text` takes precedence over `CONFLUENCE2MD_REMOVE_STRIKETHROUGH_TEXT`.
#### Table conversion modes
diff --git a/src/html.rs b/src/html.rs
index 1e10f10..eee006e 100644
--- a/src/html.rs
+++ b/src/html.rs
@@ -56,25 +56,29 @@ pub enum TableConversion {
#[derive(Debug, Clone, Default)]
pub struct ConvertOptions {
pub table_conversion: TableConversion,
+ /// When `true`, strikethrough text (``, ``, and
+ /// ``) is removed entirely
+ /// instead of being converted to `~~text~~`.
+ pub remove_strikethrough_text: bool,
}
/// Convert `html` to GitHub-flavoured Markdown with Confluence-specific
/// extensions.
-pub fn convert_to_md(html: &str, options: ConvertOptions) -> String {
+pub fn convert_html_to_markdown(html: &str, options: ConvertOptions) -> String {
let heading_links_rewritten = rewrite_internal_heading_links(html);
match options.table_conversion {
TableConversion::Default => {
let promoted = promote_markdown_compatible_tables_to_thead(&heading_links_rewritten);
- let converter = build_converter(true);
+ let converter = build_converter(&options);
let md = converter.convert(&promoted).unwrap_or_default();
post_process(&md)
}
TableConversion::Always => {
let (nested_processed, nested_replacements) =
- preprocess_nested_tables_always(&heading_links_rewritten);
+ preprocess_nested_tables_always(&heading_links_rewritten, &options);
let expanded = expand_merged_cells(&nested_processed);
let promoted = promote_first_row_to_thead(&expanded);
- let converter = build_converter(false);
+ let converter = build_converter(&options);
let mut md = post_process(&converter.convert(&promoted).unwrap_or_default());
for (token, replacement) in nested_replacements {
md = md.replace(&token, &replacement);
@@ -86,7 +90,8 @@ pub fn convert_to_md(html: &str, options: ConvertOptions) -> String {
// ── Converter setup ────────────────────────────────────────────────
-fn build_converter(preserve_merged_tables: bool) -> HtmlToMarkdown {
+fn build_converter(options: &ConvertOptions) -> HtmlToMarkdown {
+ let remove_strikethrough_text = options.remove_strikethrough_text;
let mut builder = HtmlToMarkdown::builder()
.options(Options {
bullet_list_marker: BulletListMarker::Dash,
@@ -98,9 +103,20 @@ fn build_converter(preserve_merged_tables: bool) -> HtmlToMarkdown {
.add_handler(vec!["pre"], pre_tag_handler)
.add_handler(vec!["details"], details_tag_handler)
.add_handler(vec!["summary"], summary_tag_handler)
- .add_handler(vec!["span"], span_tag_handler)
- .add_handler(vec!["s", "del"], strikethrough_tag_handler);
+ .add_handler(
+ vec!["span"],
+ move |handlers: &dyn Handlers, element: Element| -> Option {
+ span_tag_handler(handlers, element, remove_strikethrough_text)
+ },
+ )
+ .add_handler(
+ vec!["s", "del"],
+ move |handlers: &dyn Handlers, element: Element| -> Option {
+ strikethrough_tag_handler(handlers, element, remove_strikethrough_text)
+ },
+ );
+ let preserve_merged_tables = options.table_conversion == TableConversion::Default;
if preserve_merged_tables {
builder = builder.add_handler(vec!["table"], table_tag_handler_preserve_merged);
} else {
@@ -111,16 +127,32 @@ fn build_converter(preserve_merged_tables: bool) -> HtmlToMarkdown {
// ── Handlers ───────────────────────────────────────────────────────
-fn strikethrough_tag_handler(handlers: &dyn Handlers, element: Element) -> Option {
+fn strikethrough_tag_handler(
+ handlers: &dyn Handlers,
+ element: Element,
+ remove_strikethrough_text: bool,
+) -> Option {
+ if remove_strikethrough_text {
+ return Some(String::new().into());
+ }
+
let content = handlers.walk_children(element.node).content;
Some(format!("~~{content}~~").into())
}
-fn span_tag_handler(handlers: &dyn Handlers, element: Element) -> Option {
+fn span_tag_handler(
+ handlers: &dyn Handlers,
+ element: Element,
+ remove_strikethrough_text: bool,
+) -> Option {
let content = handlers.walk_children(element.node).content;
if span_has_style_text_decoration_line_through(element) {
- return Some(format!("~~{content}~~").into());
+ return if remove_strikethrough_text {
+ Some(String::new().into())
+ } else {
+ Some(format!("~~{content}~~").into())
+ };
}
// Default for plain : walk children transparently.
@@ -743,10 +775,10 @@ fn extract_nested_tables_from_table(
(outer, extracted)
}
-fn convert_table_fragment_always(table_html: &str) -> String {
+fn convert_table_fragment_always(table_html: &str, options: &ConvertOptions) -> String {
let expanded = expand_merged_cells(table_html);
let promoted = promote_first_row_to_thead(&expanded);
- let converter = build_converter(false);
+ let converter = build_converter(options);
post_process(&converter.convert(&promoted).unwrap_or_default())
.trim()
.to_string()
@@ -757,7 +789,10 @@ fn convert_table_fragment_always(table_html: &str) -> String {
///
/// 1. Outer table markdown (nested cells replaced by markers)
/// 2. Extracted nested table markdowns appended after the outer table
-fn preprocess_nested_tables_always(html: &str) -> (String, Vec<(String, String)>) {
+fn preprocess_nested_tables_always(
+ html: &str,
+ options: &ConvertOptions,
+) -> (String, Vec<(String, String)>) {
let ranges = find_top_level_table_ranges(html);
if ranges.is_empty() {
return (html.to_string(), Vec::new());
@@ -779,9 +814,9 @@ fn preprocess_nested_tables_always(html: &str) -> (String, Vec<(String, String)>
let (outer, extracted) =
extract_nested_tables_from_table(table_html, &mut marker_counter);
- let mut snippet = convert_table_fragment_always(&outer);
+ let mut snippet = convert_table_fragment_always(&outer, options);
for (marker, nested_table_html) in extracted {
- let nested_md = convert_table_fragment_always(&nested_table_html);
+ let nested_md = convert_table_fragment_always(&nested_table_html, options);
snippet.push_str("\n\n");
snippet.push_str(&marker);
snippet.push_str("\n\n");
@@ -1062,14 +1097,15 @@ mod tests {
use super::*;
fn td(html: &str) -> String {
- convert_to_md(html, ConvertOptions::default())
+ convert_html_to_markdown(html, ConvertOptions::default())
}
fn td_always(html: &str) -> String {
- convert_to_md(
+ convert_html_to_markdown(
html,
ConvertOptions {
table_conversion: TableConversion::Always,
+ ..ConvertOptions::default()
},
)
}
@@ -1391,4 +1427,40 @@ A -> B
let actual = td("strikethrough");
assert_eq!(actual, "~~strikethrough~~\n");
}
+
+ fn td_remove_st(html: &str) -> String {
+ convert_html_to_markdown(
+ html,
+ ConvertOptions {
+ remove_strikethrough_text: true,
+ ..ConvertOptions::default()
+ },
+ )
+ }
+
+ #[test]
+ fn it_should_remove_strikethrough_when_option_enabled() {
+ // tag
+ assert_eq!(td_remove_st("gone"), "\n");
+ assert_eq!(td_remove_st("keep gone keep"), "keep keep\n");
+
+ // tag
+ assert_eq!(td_remove_st("gone"), "\n");
+
+ //
+ assert_eq!(
+ td_remove_st("gone"),
+ "\n"
+ );
+ assert_eq!(
+ td_remove_st("keep gone keep"),
+ "keep keep\n"
+ );
+
+ // Non-strikethrough span is unaffected
+ assert_eq!(
+ td_remove_st("visible"),
+ "visible\n"
+ );
+ }
}
diff --git a/src/main.rs b/src/main.rs
index f97b2f3..10075dc 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4,7 +4,7 @@ use std::collections::HashSet;
use std::path::PathBuf;
use anyhow::{Context, Result};
-use clap::Parser;
+use clap::{CommandFactory, Parser, builder::TypedValueParser};
use confluence2md::confluence::{
DownloadImagesOptions, build_attachment_maps, build_http_client,
@@ -12,14 +12,14 @@ use confluence2md::confluence::{
resolve_page_id_from_url,
};
use confluence2md::drawio::{ResolveDrawioOptions, resolve_drawio_diagrams};
-use confluence2md::html::{ConvertOptions, TableConversion, convert_to_md};
+use confluence2md::html::{ConvertOptions, TableConversion, convert_html_to_markdown};
use confluence2md::logger::{self, parse_log_level};
use confluence2md::plantuml::{ResolvePlantUmlOptions, resolve_plantuml_diagrams};
use confluence2md::utils::{
apply_task_list_statuses, ensure_dir, make_assets_info, normalize_base_url,
preprocess_confluence_macros, sanitize_file_name,
};
-use tracing::{debug, error, info};
+use tracing::{debug, info};
const VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -36,6 +36,8 @@ const VERSION: &str = env!("CARGO_PKG_VERSION");
" CONFLUENCE2MD_DUMP_STATE_PATH dump-state directory (overridden by --dump-state-path)\n",
" CONFLUENCE2MD_LOG_LEVEL log level (overridden by --log-level)\n",
" CONFLUENCE2MD_TABLE_CONVERSION table conversion mode (overridden by --table-conversion)\n",
+ " CONFLUENCE2MD_REMOVE_STRIKETHROUGH_TEXT set to \"true\" to remove strikethrough text\n",
+ " (overridden by --remove-strikethrough-text)\n",
"\n",
"Example:\n",
" CONFLUENCE2MD_PERSONAL_ACCESS_TOKEN=\"xxx\" \\\n",
@@ -60,31 +62,43 @@ struct Cli {
#[arg(long = "table-conversion", value_name = "MODE")]
table_conversion: Option,
+ /// Remove strikethrough text entirely instead of converting to ~~text~~.
+ /// Accepts an optional value (true/false). Omitting the value implies true.
+ /// When passing a value, the equals form is required (e.g. `--remove-strikethrough-text=false`);
+ /// a space-separated value like `--remove-strikethrough-text false` is not supported.
+ #[arg(
+ long = "remove-strikethrough-text",
+ value_parser = clap::builder::BoolishValueParser::new(),
+ num_args = 0..=1,
+ default_missing_value = "true",
+ require_equals = true
+ )]
+ remove_strikethrough_text: Option,
+
/// Confluence page URL.
page_url: Option,
}
-#[tokio::main]
-async fn main() {
- if let Err(err) = run().await {
- error!("{err:#}");
- std::process::exit(1);
- }
+/// Resolved configuration from CLI arguments and environment variables.
+#[derive(Debug)]
+struct ResolvedConfig {
+ page_url: String,
+ output_dir: PathBuf,
+ dump_state_dir: Option,
+ log_level: logger::LogLevel,
+ table_conversion: TableConversion,
+ remove_strikethrough_text: bool,
}
-async fn run() -> Result<()> {
- let cli = Cli::parse();
-
- let level = if let Some(level_str) = cli
+/// Resolves all configuration from CLI arguments and environment variables.
+fn resolve_config(cli: &Cli) -> Result {
+ let log_level = cli
.log_level
.clone()
.or_else(|| std::env::var("CONFLUENCE2MD_LOG_LEVEL").ok())
- {
- parse_log_level(&level_str).context("parsing log level")?
- } else {
- logger::LogLevel::Info
- };
- logger::init(level);
+ .map(|s| parse_log_level(&s).context("parsing log level"))
+ .transpose()?
+ .unwrap_or(logger::LogLevel::Info);
let table_mode_str = cli
.table_conversion
@@ -103,7 +117,7 @@ async fn run() -> Result<()> {
anyhow::anyhow!("Missing required argument. Use --help for usage.")
})?;
- let output_dir_input = cli
+ let output_dir = cli
.output_path
.clone()
.or_else(|| {
@@ -112,7 +126,7 @@ async fn run() -> Result<()> {
.map(PathBuf::from)
})
.unwrap_or_else(|| PathBuf::from("."));
- let output_dir = absolutize_path(output_dir_input)?;
+
let dump_state_dir = cli
.dump_state_path
.clone()
@@ -124,50 +138,101 @@ async fn run() -> Result<()> {
.map(absolutize_path)
.transpose()?;
- let parsed_url = url::Url::parse(&page_url).context("Invalid page URL")?;
+ let remove_strikethrough_text = cli
+ .remove_strikethrough_text
+ .map(Ok)
+ .or_else(|| {
+ std::env::var("CONFLUENCE2MD_REMOVE_STRIKETHROUGH_TEXT")
+ .ok()
+ .map(|value| {
+ clap::builder::BoolishValueParser::new()
+ .parse_ref(&Cli::command(), None, std::ffi::OsStr::new(&value))
+ .map_err(|_| {
+ anyhow::anyhow!(
+ "Invalid CONFLUENCE2MD_REMOVE_STRIKETHROUGH_TEXT value: \"{value}\". \
+ Must be \"true\", \"false\", \"1\", \"0\", \"yes\", \"no\", \"on\", or \"off\"."
+ )
+ })
+ })
+ })
+ .transpose()?
+ .unwrap_or(false);
+
+ Ok(ResolvedConfig {
+ page_url,
+ output_dir: absolutize_path(output_dir)?,
+ dump_state_dir,
+ log_level,
+ table_conversion,
+ remove_strikethrough_text,
+ })
+}
+
+/// Extracts and normalizes the base URL (scheme + host + optional port) from a page URL.
+fn extract_base_url(page_url: &str) -> Result {
+ let parsed = url::Url::parse(page_url).context("Invalid page URL")?;
let origin = format!(
"{}://{}{}",
- parsed_url.scheme(),
- parsed_url.host_str().unwrap_or(""),
- parsed_url
- .port()
- .map(|p| format!(":{p}"))
- .unwrap_or_default()
+ parsed.scheme(),
+ parsed.host_str().unwrap_or(""),
+ parsed.port().map(|p| format!(":{p}")).unwrap_or_default()
);
- let base_url = normalize_base_url(&origin);
- let env = get_required_env()?;
- let token = env.personal_access_token;
+ Ok(normalize_base_url(&origin))
+}
+
+fn append_markdown_header(title: &str, page_id: &str, webui: Option<&str>, body: &str) -> String {
+ let mut markdown = format!("# {title}\n\n- Confluence Page ID: {page_id}\n");
+ if let Some(url) = webui {
+ markdown.push_str(&format!("- URL: {url}\n"));
+ }
+ markdown.push_str("\n---\n\n");
+ markdown.push_str(body);
+ markdown.push('\n');
+ markdown
+}
+#[tokio::main]
+async fn main() {
+ if let Err(err) = run().await {
+ eprintln!("Error: {err:#}");
+ std::process::exit(1);
+ }
+}
+
+async fn run() -> Result<()> {
+ let cli = Cli::parse();
+ let config = resolve_config(&cli)?;
+ logger::init(config.log_level);
+
+ let base_url = extract_base_url(&config.page_url)?;
+ let token = get_required_env()?.personal_access_token;
let client = build_http_client()?;
- let page_id = match resolve_page_id_from_url(&client, &page_url, &base_url, &token).await {
- Ok(id) => id,
- Err(err) => {
- error!("failed to resolve page ID from URL: {err}");
- std::process::exit(1);
- }
- };
- debug!("Resolved page ID for \"{page_url}\": {page_id}");
+ let page_id = resolve_page_id_from_url(&client, &config.page_url, &base_url, &token)
+ .await
+ .context("failed to resolve page ID from URL")?;
+ debug!("Resolved page ID for \"{}\": {page_id}", config.page_url);
- ensure_dir(&output_dir).await?;
- if let Some(dir) = &dump_state_dir {
+ ensure_dir(&config.output_dir).await?;
+ if let Some(dir) = &config.dump_state_dir {
ensure_dir(dir).await?;
}
let page = fetch_confluence_page(&client, &page_id, &base_url, &token).await?;
- write_dump_state(&dump_state_dir, "content.json", &page.content_json).await?;
+ write_dump_state(&config.dump_state_dir, "content.json", &page.content_json).await?;
let title = if page.title.is_empty() {
format!("page-{page_id}")
} else {
page.title.clone()
};
- let output_file_name = format!("{}.md", sanitize_file_name(&title));
- let output_path = output_dir.join(&output_file_name);
+ let output_path = config
+ .output_dir
+ .join(format!("{}.md", sanitize_file_name(&title)));
- write_dump_state(&dump_state_dir, "export.html", &page.export_html).await?;
+ write_dump_state(&config.dump_state_dir, "export.html", &page.export_html).await?;
write_dump_state(
- &dump_state_dir,
+ &config.dump_state_dir,
"storage.html",
page.storage_html.as_deref().unwrap_or(""),
)
@@ -176,11 +241,10 @@ async fn run() -> Result<()> {
let attachments = list_attachments(&client, &page_id, &base_url, &token).await?;
let maps = build_attachment_maps(&attachments);
- let mut html_for_markdown: String = page.export_html.clone();
-
let assets_info = make_assets_info(&page_id, &title, &output_path);
ensure_dir(&assets_info.assets_abs_dir).await?;
+ let mut html = page.export_html.clone();
let mut used_names: HashSet = HashSet::new();
let drawio_result = resolve_drawio_diagrams(
@@ -188,23 +252,23 @@ async fn run() -> Result<()> {
ResolveDrawioOptions {
page_id: &page_id,
storage_html: page.storage_html.as_deref(),
- export_html: &html_for_markdown,
+ export_html: &html,
attachments_by_title: &maps.by_title,
base_url: &base_url,
token: &token,
assets_abs_dir: &assets_info.assets_abs_dir,
- dump_state_abs_dir: dump_state_dir.as_deref(),
+ dump_state_abs_dir: config.dump_state_dir.as_deref(),
markdown_image_prefix: &assets_info.markdown_image_prefix,
used_names: &mut used_names,
},
)
.await?;
- html_for_markdown = drawio_result.0;
- write_dump_state(&dump_state_dir, "rewrite_drawio.html", &html_for_markdown).await?;
+ html = drawio_result.0;
+ write_dump_state(&config.dump_state_dir, "rewrite_drawio.html", &html).await?;
- html_for_markdown = download_images_and_rewrite_html(
+ html = download_images_and_rewrite_html(
&client,
- &html_for_markdown,
+ &html,
DownloadImagesOptions {
base_url: &base_url,
personal_access_token: &token,
@@ -214,14 +278,14 @@ async fn run() -> Result<()> {
},
)
.await?;
- write_dump_state(&dump_state_dir, "rewrite_image.html", &html_for_markdown).await?;
+ write_dump_state(&config.dump_state_dir, "rewrite_image.html", &html).await?;
let plantuml_result = resolve_plantuml_diagrams(
&client,
ResolvePlantUmlOptions {
page_id: &page_id,
storage_html: page.storage_html.as_deref(),
- html: &html_for_markdown,
+ html: &html,
attachments_by_title: &maps.by_title,
base_url: &base_url,
token: &token,
@@ -231,34 +295,28 @@ async fn run() -> Result<()> {
},
)
.await?;
- html_for_markdown = plantuml_result.0;
- write_dump_state(&dump_state_dir, "rewrite_plantuml.html", &html_for_markdown).await?;
+ html = plantuml_result.0;
+ write_dump_state(&config.dump_state_dir, "rewrite_plantuml.html", &html).await?;
if let Some(storage_html) = page.storage_html.as_deref() {
- html_for_markdown = apply_task_list_statuses(&html_for_markdown, storage_html);
- }
- html_for_markdown = preprocess_confluence_macros(&html_for_markdown);
- write_dump_state(&dump_state_dir, "rewrite_macros.html", &html_for_markdown).await?;
-
- let markdown_body = convert_to_md(&html_for_markdown, ConvertOptions { table_conversion });
-
- let mut markdown = String::new();
- markdown.push_str(&format!("# {title}\n"));
- markdown.push('\n');
- markdown.push_str(&format!("- Confluence Page ID: {page_id}\n"));
- if let Some(webui) = &page.webui {
- markdown.push_str(&format!("- URL: {webui}\n"));
+ html = apply_task_list_statuses(&html, storage_html);
}
- markdown.push('\n');
- markdown.push_str("---\n");
- markdown.push('\n');
- markdown.push_str(&markdown_body);
- markdown.push('\n');
+ html = preprocess_confluence_macros(&html);
+ write_dump_state(&config.dump_state_dir, "rewrite_macros.html", &html).await?;
+
+ let markdown_body = convert_html_to_markdown(
+ &html,
+ ConvertOptions {
+ table_conversion: config.table_conversion,
+ remove_strikethrough_text: config.remove_strikethrough_text,
+ },
+ );
+ let markdown = append_markdown_header(&title, &page_id, page.webui.as_deref(), &markdown_body);
tokio::fs::write(&output_path, markdown).await?;
info!("Written: {}", output_path.display());
info!("Assets: {}", assets_info.assets_abs_dir.display());
- if let Some(dir) = &dump_state_dir {
+ if let Some(dir) = &config.dump_state_dir {
info!("Dump state: {}", dir.display());
}
@@ -279,3 +337,324 @@ async fn write_dump_state(dir: &Option, file_name: &str, contents: &str
}
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ // ── helpers ──────────────────────────────────────────────────────────────
+
+ fn make_cli(
+ page_url: Option<&str>,
+ output_path: Option<&str>,
+ dump_state_path: Option<&str>,
+ log_level: Option<&str>,
+ table_conversion: Option<&str>,
+ remove_strikethrough_text: Option,
+ ) -> Cli {
+ Cli {
+ page_url: page_url.map(str::to_owned),
+ output_path: output_path.map(PathBuf::from),
+ dump_state_path: dump_state_path.map(PathBuf::from),
+ log_level: log_level.map(str::to_owned),
+ table_conversion: table_conversion.map(str::to_owned),
+ remove_strikethrough_text,
+ }
+ }
+
+ // ── resolve_config ───────────────────────────────────────────────────────
+
+ #[test]
+ fn resolve_config_missing_page_url_returns_error() {
+ let cli = make_cli(None, None, None, None, None, None);
+ let err = resolve_config(&cli).unwrap_err();
+ assert!(
+ err.to_string().contains("Missing required "),
+ "unexpected error: {err}"
+ );
+ }
+
+ #[test]
+ fn resolve_config_invalid_table_conversion_returns_error() {
+ let cli = make_cli(
+ Some("https://confluence.example.com/pages/viewpage.action?pageId=1"),
+ None,
+ None,
+ None,
+ Some("invalid"),
+ None,
+ );
+ let err = resolve_config(&cli).unwrap_err();
+ assert!(
+ err.to_string().contains("Invalid --table-conversion"),
+ "unexpected error: {err}"
+ );
+ }
+
+ #[test]
+ fn resolve_config_defaults_output_dir_to_absolute_path() {
+ let cli = make_cli(
+ Some("https://confluence.example.com/pages/viewpage.action?pageId=1"),
+ None,
+ None,
+ None,
+ None,
+ None,
+ );
+ let config = resolve_config(&cli).unwrap();
+ assert!(
+ config.output_dir.is_absolute(),
+ "output_dir should be absolute, got: {:?}",
+ config.output_dir
+ );
+ }
+
+ #[test]
+ fn resolve_config_explicit_absolute_output_path() {
+ let cli = make_cli(
+ Some("https://confluence.example.com/pages/viewpage.action?pageId=1"),
+ Some("/tmp/out"),
+ None,
+ None,
+ None,
+ None,
+ );
+ let config = resolve_config(&cli).unwrap();
+ assert_eq!(config.output_dir, PathBuf::from("/tmp/out"));
+ }
+
+ #[test]
+ fn resolve_config_table_conversion_always() {
+ let cli = make_cli(
+ Some("https://confluence.example.com/pages/viewpage.action?pageId=1"),
+ None,
+ None,
+ None,
+ Some("always"),
+ None,
+ );
+ let config = resolve_config(&cli).unwrap();
+ assert!(matches!(config.table_conversion, TableConversion::Always));
+ }
+
+ #[test]
+ fn resolve_config_remove_strikethrough_flag() {
+ let cli = make_cli(
+ Some("https://confluence.example.com/pages/viewpage.action?pageId=1"),
+ None,
+ None,
+ None,
+ None,
+ Some(true),
+ );
+ let config = resolve_config(&cli).unwrap();
+ assert!(config.remove_strikethrough_text);
+ }
+
+ #[test]
+ fn resolve_config_remove_strikethrough_env_var() {
+ // SAFETY: test-only; single-threaded test environment
+ unsafe { std::env::set_var("CONFLUENCE2MD_REMOVE_STRIKETHROUGH_TEXT", "true") };
+ let cli = make_cli(
+ Some("https://confluence.example.com/pages/viewpage.action?pageId=1"),
+ None,
+ None,
+ None,
+ None,
+ None,
+ );
+ let config = resolve_config(&cli).unwrap();
+ // SAFETY: test-only; single-threaded test environment
+ unsafe { std::env::remove_var("CONFLUENCE2MD_REMOVE_STRIKETHROUGH_TEXT") };
+ assert!(config.remove_strikethrough_text);
+ }
+
+ #[test]
+ fn resolve_config_remove_strikethrough_cli_false_overrides_env_var() {
+ // SAFETY: test-only; single-threaded test environment
+ unsafe { std::env::set_var("CONFLUENCE2MD_REMOVE_STRIKETHROUGH_TEXT", "true") };
+ let cli = make_cli(
+ Some("https://confluence.example.com/pages/viewpage.action?pageId=1"),
+ None,
+ None,
+ None,
+ None,
+ Some(false),
+ );
+ let config = resolve_config(&cli).unwrap();
+ // SAFETY: test-only; single-threaded test environment
+ unsafe { std::env::remove_var("CONFLUENCE2MD_REMOVE_STRIKETHROUGH_TEXT") };
+ assert!(!config.remove_strikethrough_text);
+ }
+
+ #[test]
+ fn resolve_config_remove_strikethrough_env_var_truthy_values() {
+ for value in &["1", "yes", "on", "TRUE"] {
+ // SAFETY: test-only; single-threaded test environment
+ unsafe { std::env::set_var("CONFLUENCE2MD_REMOVE_STRIKETHROUGH_TEXT", value) };
+ let cli = make_cli(
+ Some("https://confluence.example.com/pages/viewpage.action?pageId=1"),
+ None,
+ None,
+ None,
+ None,
+ None,
+ );
+ let config = resolve_config(&cli).unwrap();
+ // SAFETY: test-only; single-threaded test environment
+ unsafe { std::env::remove_var("CONFLUENCE2MD_REMOVE_STRIKETHROUGH_TEXT") };
+ assert!(
+ config.remove_strikethrough_text,
+ "expected true for env var value {value:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn resolve_config_remove_strikethrough_env_var_falsy_values() {
+ for value in &["0", "no", "off", "FALSE"] {
+ // SAFETY: test-only; single-threaded test environment
+ unsafe { std::env::set_var("CONFLUENCE2MD_REMOVE_STRIKETHROUGH_TEXT", value) };
+ let cli = make_cli(
+ Some("https://confluence.example.com/pages/viewpage.action?pageId=1"),
+ None,
+ None,
+ None,
+ None,
+ None,
+ );
+ let config = resolve_config(&cli).unwrap();
+ // SAFETY: test-only; single-threaded test environment
+ unsafe { std::env::remove_var("CONFLUENCE2MD_REMOVE_STRIKETHROUGH_TEXT") };
+ assert!(
+ !config.remove_strikethrough_text,
+ "expected false for env var value {value:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn resolve_config_remove_strikethrough_env_var_invalid_value_returns_error() {
+ // SAFETY: test-only; single-threaded test environment
+ unsafe { std::env::set_var("CONFLUENCE2MD_REMOVE_STRIKETHROUGH_TEXT", "invalid") };
+ let cli = make_cli(
+ Some("https://confluence.example.com/pages/viewpage.action?pageId=1"),
+ None,
+ None,
+ None,
+ None,
+ None,
+ );
+ let err = resolve_config(&cli).unwrap_err();
+ // SAFETY: test-only; single-threaded test environment
+ unsafe { std::env::remove_var("CONFLUENCE2MD_REMOVE_STRIKETHROUGH_TEXT") };
+ assert!(
+ err.to_string()
+ .contains("Invalid CONFLUENCE2MD_REMOVE_STRIKETHROUGH_TEXT"),
+ "unexpected error: {err}"
+ );
+ }
+
+ #[test]
+ fn resolve_config_invalid_log_level_returns_error() {
+ let cli = make_cli(
+ Some("https://confluence.example.com/pages/viewpage.action?pageId=1"),
+ None,
+ None,
+ Some("TRACE"),
+ None,
+ None,
+ );
+ let err = resolve_config(&cli).unwrap_err();
+ assert!(
+ err.to_string().contains("parsing log level"),
+ "unexpected error: {err}"
+ );
+ }
+
+ // ── extract_base_url ─────────────────────────────────────────────────────
+
+ #[test]
+ fn extract_base_url_standard_https() {
+ let url = "https://confluence.example.com/pages/viewpage.action?pageId=1";
+ assert_eq!(
+ extract_base_url(url).unwrap(),
+ "https://confluence.example.com"
+ );
+ }
+
+ #[test]
+ fn extract_base_url_retains_explicit_port() {
+ let url = "https://confluence.example.com:8443/pages/viewpage.action?pageId=1";
+ assert_eq!(
+ extract_base_url(url).unwrap(),
+ "https://confluence.example.com:8443"
+ );
+ }
+
+ #[test]
+ fn extract_base_url_strips_path_and_query() {
+ let url = "http://confluence.example.com/wiki/spaces/PROJ/pages/123/Title?foo=bar";
+ assert_eq!(
+ extract_base_url(url).unwrap(),
+ "http://confluence.example.com"
+ );
+ }
+
+ #[test]
+ fn extract_base_url_invalid_url_returns_error() {
+ let err = extract_base_url("not-a-url").unwrap_err();
+ assert!(
+ err.to_string().contains("Invalid page URL"),
+ "unexpected error: {err}"
+ );
+ }
+
+ // ── append_markdown_header ───────────────────────────────────────────────
+
+ #[test]
+ fn append_markdown_header_produces_h1_title() {
+ let md = append_markdown_header("My Page", "12345", None, "body");
+ assert!(md.starts_with("# My Page\n"), "got: {md:?}");
+ }
+
+ #[test]
+ fn append_markdown_header_includes_page_id() {
+ let md = append_markdown_header("My Page", "12345", None, "body");
+ assert!(md.contains("- Confluence Page ID: 12345\n"));
+ }
+
+ #[test]
+ fn append_markdown_header_includes_webui_url_when_present() {
+ let md = append_markdown_header(
+ "My Page",
+ "12345",
+ Some("https://confluence.example.com/pages/viewpage.action?pageId=12345"),
+ "body",
+ );
+ assert!(md.contains(
+ "- URL: https://confluence.example.com/pages/viewpage.action?pageId=12345\n"
+ ));
+ }
+
+ #[test]
+ fn append_markdown_header_omits_url_line_when_webui_is_none() {
+ let md = append_markdown_header("My Page", "12345", None, "body");
+ assert!(!md.contains("- URL:"), "unexpected URL line in: {md:?}");
+ }
+
+ #[test]
+ fn append_markdown_header_contains_body_after_separator() {
+ let md = append_markdown_header("Title", "1", None, "## Section\n\nContent here.");
+ let sep_pos = md.find("---\n").expect("separator not found");
+ let after_sep = &md[sep_pos + 4..];
+ assert!(after_sep.contains("## Section"));
+ assert!(after_sep.contains("Content here."));
+ }
+
+ #[test]
+ fn append_markdown_header_ends_with_newline() {
+ let md = append_markdown_header("Title", "1", None, "body");
+ assert!(md.ends_with('\n'));
+ }
+}
diff --git a/tests/integration.rs b/tests/integration.rs
index b7ee2ab..89146a4 100644
--- a/tests/integration.rs
+++ b/tests/integration.rs
@@ -7,7 +7,7 @@ use std::sync::OnceLock;
use serde_json::Value;
use confluence2md::drawio::{extract_drawio_diagram_names, replace_drawio_img_srcs};
-use confluence2md::html::{ConvertOptions, TableConversion, convert_to_md};
+use confluence2md::html::{ConvertOptions, TableConversion, convert_html_to_markdown};
use confluence2md::plantuml::{extract_plantuml_sources, replace_plantuml_imgs_with_code};
use confluence2md::utils::{
apply_task_list_statuses, preprocess_confluence_macros, sanitize_file_name,
@@ -65,7 +65,7 @@ fn sanitize_file_name_handles_fixture_title() {
fn converts_export_view_html_from_fixture() {
let html = export_view();
assert!(!html.is_empty());
- let md = convert_to_md(html, ConvertOptions::default());
+ let md = convert_html_to_markdown(html, ConvertOptions::default());
assert!(!md.is_empty());
assert!(
md.lines()
@@ -77,7 +77,7 @@ fn converts_export_view_html_from_fixture() {
#[test]
fn output_contains_expected_section_headings() {
- let md = convert_to_md(export_view(), ConvertOptions::default());
+ let md = convert_html_to_markdown(export_view(), ConvertOptions::default());
assert!(md.contains("背景"));
assert!(md.contains("PlantUML"));
assert!(md.contains("Draw.io"));
@@ -85,7 +85,7 @@ fn output_contains_expected_section_headings() {
#[test]
fn output_preserves_table_content() {
- let md = convert_to_md(export_view(), ConvertOptions::default());
+ let md = convert_html_to_markdown(export_view(), ConvertOptions::default());
assert!(md.contains("列1"));
assert!(md.contains("列5"));
}
@@ -94,7 +94,7 @@ fn output_preserves_table_content() {
fn storage_html_is_also_convertible() {
let html = storage();
assert!(!html.is_empty());
- let md = convert_to_md(html, ConvertOptions::default());
+ let md = convert_html_to_markdown(html, ConvertOptions::default());
assert!(!md.is_empty());
}
@@ -142,21 +142,21 @@ fn extract_plantuml_sources_returns_expected_blocks() {
fn rewriting_plantuml_imgs_produces_valid_fenced_blocks() {
let sources = extract_plantuml_sources(Some(storage()));
let rewritten = replace_plantuml_imgs_with_code(export_view(), &sources);
- let md = convert_to_md(&rewritten, ConvertOptions::default());
+ let md = convert_html_to_markdown(&rewritten, ConvertOptions::default());
let count = md.matches("@startuml").count();
assert_eq!(count, 2, "expected 2 @startuml occurrences, got {count}");
assert!(md.contains("```plantuml"));
}
-// ── End-to-end macro preprocessing + convert_to_md ────────────────────
+// ── End-to-end macro preprocessing + convert_html_to_markdown ────────────────────
#[test]
-fn code_macro_roundtrip_via_preprocess_and_convert_to_md() {
+fn code_macro_roundtrip_via_preprocess_and_convert_html_to_markdown() {
let html = r#"rust"#;
let processed = preprocess_confluence_macros(html);
- let md = convert_to_md(&processed, ConvertOptions::default());
+ let md = convert_html_to_markdown(&processed, ConvertOptions::default());
assert!(md.contains("```rust"));
assert!(md.contains("fn main()"));
assert!(md.contains("let x = 1;"));
@@ -166,7 +166,7 @@ fn code_macro_roundtrip_via_preprocess_and_convert_to_md() {
fn info_macro_renders_as_important_callout() {
let html = r#"Important info.
"#;
let processed = preprocess_confluence_macros(html);
- let md = convert_to_md(&processed, ConvertOptions::default());
+ let md = convert_html_to_markdown(&processed, ConvertOptions::default());
assert!(md.contains("> [!IMPORTANT]"));
assert!(md.contains("> Important info."));
}
@@ -175,7 +175,7 @@ fn info_macro_renders_as_important_callout() {
fn warning_macro_renders_as_warning_callout() {
let html = r#"Beware.
"#;
let processed = preprocess_confluence_macros(html);
- let md = convert_to_md(&processed, ConvertOptions::default());
+ let md = convert_html_to_markdown(&processed, ConvertOptions::default());
assert!(md.contains("> [!CAUTION]"));
}
@@ -183,7 +183,7 @@ fn warning_macro_renders_as_warning_callout() {
fn expand_macro_renders_as_details_summary() {
let html = r#"ClickHidden.
"#;
let processed = preprocess_confluence_macros(html);
- let md = convert_to_md(&processed, ConvertOptions::default());
+ let md = convert_html_to_markdown(&processed, ConvertOptions::default());
assert!(md.contains(""));
assert!(md.contains("Click
"));
assert!(md.contains("Hidden."));
@@ -199,7 +199,7 @@ fn inline_tasks_render_as_markdown_checkboxes() {
let annotated = apply_task_list_statuses(rendered, storage);
let processed = preprocess_confluence_macros(&annotated);
- let md = convert_to_md(&processed, ConvertOptions::default());
+ let md = convert_html_to_markdown(&processed, ConvertOptions::default());
assert!(md.contains("- [x] done"), "{md}");
assert!(md.contains("- [ ] todo"), "{md}");
@@ -208,10 +208,11 @@ fn inline_tasks_render_as_markdown_checkboxes() {
#[test]
fn always_table_mode_unwraps_single_cell_table() {
let html = "";
- let md = convert_to_md(
+ let md = convert_html_to_markdown(
html,
ConvertOptions {
table_conversion: TableConversion::Always,
+ ..ConvertOptions::default()
},
);
assert_eq!(md, "Only cell\n");