///|
async fn main {
let command = @argparse.Command(
"obsmark",
about="Render markdown documents anywhere. ",
flags=[
@argparse.FlagArg(
"ascii",
about="Render text tables with ASCII line style.",
short='a',
),
@argparse.FlagArg(
"ansi",
about="Render plain text with ANSI escape codes.",
),
@argparse.FlagArg(
"watch",
about="Watch the input file and re-render on changes.",
short='w',
),
@argparse.FlagArg(
"disable-math",
about="Do not render math; keep $...$ and $$...$$ as-is.",
),
@argparse.FlagArg(
"cmark",
about="Render with the cmark (CommonMark) parser instead of notiz.",
short='c',
),
@argparse.FlagArg(
"halfwidth",
about="Replace full-width punctuation with half-width in text (not in code or math).",
),
],
options=[
OptionArg("output", about="Output file path.", short='o'),
OptionArg(
"target",
about="Output format (text|html|html-text).",
short='t',
default_values=["text"],
),
OptionArg(
"range",
about="Only render the source lines in the given range.",
short='r',
),
OptionArg("paper", about="Paper size for HTML output (a4|4-3|16-9)."),
],
positionals=[
PositionArg(
"input",
about="Path to the .md input file.",
num_args=@argparse.ValueRange::single(),
),
],
)
let matches = command.parse()
guard matches.values.get("input") is Some([input_path]) else {
return println(command.render_help())
}
let input_path = normalize_path(input_path)
let target = match matches.values.get("target") {
Some(["html"]) => @notiz.Html
Some(["html-text"]) => @notiz.HtmlText
Some(["text"]) => @notiz.Text
_ => @notiz.Text
}
let line_style = match matches.flags.get("ascii") {
Some(true) => @backend.LineStyle::Ascii
_ => @backend.LineStyle::Unicode
}
let paper_given = matches.values.get("paper") is Some([_])
let paper = match matches.values.get("paper") {
Some([value]) =>
match @backend.Paper::from_string(value) {
Ok(paper) => paper
Err(error) => return println("error: \{error}")
}
_ => @backend.Paper::A4
}
if paper_given {
match target {
Text =>
println(
"warning: --paper only applies to --target html/html-text; ignored",
)
_ => ()
}
}
let ansi = matches.flags.get("ansi") is Some(true)
let disable_math = matches.flags.get("disable-math") is Some(true)
let use_cmark = matches.flags.get("cmark") is Some(true)
let halfwidth_punct = matches.flags.get("halfwidth") is Some(true)
if use_cmark {
match matches.values.get("target") {
Some(["html"]) | Some(["html-text"]) =>
return println(
"error: --cmark only renders terminal text; --target html/html-text is not available with cmark yet",
)
_ => ()
}
}
let output_path = matches.values
.get("output")
.map_or(None, values => Some(values[0]))
let range = {
guard matches.values.get("range") is Some([value]) else { None }
let parsed = parse_range(value) catch {
error => return println("error: invalid --range \{value}: \{error}")
}
Some(parsed)
}
if matches.flags.get("watch") is Some(true) {
watch_and_render(
input_path,
target,
ansi~,
line_style~,
disable_math~,
use_cmark,
output_path,
paper~,
range~,
halfwidth_punct~,
)
} else {
render_input(
input_path,
target,
ansi~,
line_style~,
disable_math~,
use_cmark,
output_path,
paper~,
range~,
halfwidth_punct~,
)
}
}
///|
async fn watch_and_render(
input_path : String,
target : @notiz.CompileTarget,
ansi~ : Bool,
line_style~ : @backend.LineStyle,
disable_math~ : Bool,
use_cmark : Bool,
output_path : String?,
paper~ : @backend.Paper,
range~ : (Int, Int)?,
halfwidth_punct~ : Bool,
) -> Unit {
let input_name = input_path.unsafe_substring(
start=input_path.rev_find("/").map_or(0, i => i + 1),
end=input_path.length(),
)
let watched_dir = input_path
.rev_find("/")
.map_or(".", i => input_path.unsafe_substring(start=0, end=i + 1))
let ignored_paths = path => path != input_name
let watcher = @fs.Watcher(watched_dir, ignored_paths~) catch {
error => return println("error: unable to watch \{input_path}: \{error}")
}
defer watcher.close()
render_input(
input_path,
target,
ansi~,
line_style~,
disable_math~,
use_cmark,
output_path,
paper~,
range~,
halfwidth_punct~,
)
println("watching: \{input_path} (Ctrl-C to stop)")
for ;; {
watcher.wait_any() catch {
error if @async.is_cancellation_error(error) => return
error => return println("error: stopped watching \{input_path}: \{error}")
}
guard output_path is Some(_) else { println("\u001b[2J\u001b[H") }
render_input(
input_path,
target,
ansi~,
line_style~,
disable_math~,
use_cmark,
output_path,
paper~,
range~,
halfwidth_punct~,
)
}
}
///|
/// Reads the whole file at once, narrowing to `range` when given.
///
/// We deliberately avoid `@io.Reader::read_until` line-by-line reading.
/// Line-at-a-time reading pays a fixed cost per line — one async call, a
/// separator encode, and a `String` allocation, even for lines that are only
/// skipped — to save memory, and that trade only wins on 100MB+ inputs.
/// Markdown never realistically gets there: 1MB of source already renders to
/// a few hundred A4 pages and 10MB is encyclopedic. At those sizes reading
/// the whole file and letting `extract_range` lazily materialize only the
/// selected lines is both faster and still O(range) in memory, so this is
/// the optimal strategy given the input sizes obsmark actually handles.
async fn read_source(input_path : String, range~ : (Int, Int)?) -> String {
let content = @fs.read_file(input_path).text()
match range {
Some((start, end)) => extract_range(content, start, end).join("\n")
None => content
}
}
///|
async fn render_input(
input_path : String,
target : @notiz.CompileTarget,
ansi~ : Bool,
line_style~ : @backend.LineStyle,
disable_math~ : Bool,
use_cmark : Bool,
output_path : String?,
paper~ : @backend.Paper,
range~ : (Int, Int)?,
halfwidth_punct~ : Bool,
) -> Unit {
let source = read_source(input_path, range~) catch {
e => return println("error reading file \{input_path}: \{e}")
}
let output = if use_cmark {
@notiz.compile_cmark(
source,
cmark_options(),
line_style~,
ansi~,
disable_math~,
halfwidth_punct~,
)
} else {
@notiz.compile(
source,
target~,
ansi~,
line_style~,
disable_math~,
paper~,
halfwidth_punct~,
) catch {
error => return println(@parsing.format_parse_error(error, source))
}
}
match output_path {
None => println(output)
Some(path) =>
@fs.write_file(path, output, create_mode=@fs.CreateMode::CreateOrTruncate)
}
}
///|
/// Options for cmark rendering: the full CommonMark plus extension set.
fn cmark_options() -> @cmark.Options {
let options = @cmark.Options::empty()
options.insert(@cmark.enable_tables())
options.insert(@cmark.enable_footnotes())
options.insert(@cmark.enable_strikethrough())
options.insert(@cmark.enable_tasklists())
options.insert(@cmark.enable_heading_attributes())
options.insert(@cmark.enable_yaml_style_metadata_blocks())
options.insert(@cmark.enable_pluses_delimited_metadata_blocks())
options.insert(@cmark.enable_math())
options.insert(@cmark.enable_math_everywhere())
options.insert(@cmark.enable_gfm())
options.insert(@cmark.enable_definition_list())
options.insert(@cmark.enable_superscript())
options.insert(@cmark.enable_subscript())
options.insert(@cmark.enable_wikilinks())
options.insert(@cmark.enable_container_extensions())
options.insert(@cmark.enable_highlight())
options.insert(@cmark.enable_cjk_friendly_emphasis())
options
}
///|
fn normalize_path(path : String) -> String {
path.replace_all(old="\\", new="/")
}