// @block processor-error
///|
/// Error type describing why an external preprocessor/transformer command
/// failed while running through `run_markdown_preprocessors` or
/// `run_markdown_transformers`.
pub suberror ProcessorError {
NonZeroExit(command~ : String, exit_code~ : Int, stderr~ : String)
StderrReported(command~ : String, message~ : String)
InvalidStdout(command~ : String, raw~ : String, reason~ : String)
} derive(Debug, Eq)
// @end
///|
pub impl Show for ProcessorError with fn to_string(self : ProcessorError) -> String {
match self {
NonZeroExit(command~, exit_code~, stderr~) =>
"ProcessorError: Command \{command} failed with exit code \{exit_code} and stderr: \{stderr}"
StderrReported(command~, message~) =>
"ProcessorError: Command \{command} reported stderr: \{message}"
InvalidStdout(command~, raw~, reason~) =>
"ProcessorError: Command \{command} produced invalid stdout: \{raw} (reason: \{reason})"
}
}
///|
struct MarkdownRequest {
config : @config.BookConfig
type_ : String
target : String
data : @markdown.Markdown
} derive(ToJson(fields(type_(rename="type"))))
///|
struct MarkdownResponse {
data : @markdown.Markdown
} derive(FromJson)
///|
struct TextRequest {
config : @config.BookConfig
type_ : String
data : String
target : String
} derive(ToJson(fields(type_(rename="type"))))
///|
struct TextResponse {
data : String
} derive(FromJson)
///|
/// Runs a chain of external "transformer" commands over a Markdown AST,
/// feeding the output of each command as the input to the next.
///
/// This mirrors `run_markdown_preprocessors`, except the payload's `data`
/// field carries a Markdown AST (`Array[@markdown.Block]`) rather than raw
/// text, and the `type` field sent to each command is `"markdown-ast"`
/// instead of `"markdown-text"`.
///
/// Each command receives on stdin:
/// ```json
/// { "config": , "type": "markdown-ast", "data": , "target": }
/// ```
/// and must write to stdout either:
/// - `{ "data": }` — the transformed AST, passed on to the
/// next command, or
/// - an empty stdout — meaning this command leaves the AST unchanged.
///
/// As with `run_markdown_preprocessors`, a non-zero exit code or any
/// stderr output from a command raises `ProcessorError` and aborts the
/// remaining chain.
pub async fn run_markdown_transformers(
config : @config.BookConfig,
data : @markdown.Markdown,
target : String,
) -> @markdown.Markdown {
let mut current = data
for script in config.extensions.transformers {
current = run_single_markdown_transformer(config, current, target, script)
}
current
}
///|
async fn run_single_markdown_transformer(
config : @config.BookConfig,
data : @markdown.Markdown,
target : String,
script : String,
) -> @markdown.Markdown {
let (cmd, args) = if script.split(" ").collect() is [cmd, .. args] {
(cmd, args)
} else {
(script, [])
}
let request : MarkdownRequest = {
config,
type_: "markdown-ast",
data,
target,
}
let input_json = request.to_json().stringify()
let (stdin_r, stdin_w) = @process.write_to_process()
let (stdout_r, stdout_w) = @process.read_from_process()
let (stderr_r, stderr_w) = @process.read_from_process()
let mut stdout_text = ""
let mut stderr_text = ""
let mut exit_code = -1
@async.with_task_group(group => {
group.spawn_bg(() => {
stdin_w.write(input_json)
stdin_w.close()
})
group.spawn_bg(() => {
let out = stdout_r.read_all()
stdout_text = out.text()
stdout_r.close()
})
group.spawn_bg(() => {
let err = stderr_r.read_all()
stderr_text = err.text()
stderr_r.close()
})
exit_code = @process.run(
cmd,
args.map(Show::to_string),
stdin=stdin_r,
stdout=stdout_w,
stderr=stderr_w,
)
})
if exit_code != 0 {
raise ProcessorError::NonZeroExit(
command=script,
exit_code~,
stderr=stderr_text,
)
}
if !stderr_text.trim(chars=" \n\r\t").is_empty() {
raise ProcessorError::StderrReported(command=script, message=stderr_text)
}
if stdout_text.trim(chars=" \n\r\t").is_empty() {
return data
}
try {
let json = @json.parse(stdout_text)
let response : MarkdownResponse = @json.from_json(json)
response.data
} catch {
err =>
raise ProcessorError::InvalidStdout(
command=script,
raw=stdout_text,
reason=err.to_string(),
)
}
}
///|
/// Runs a chain of external "preprocessor" commands over raw Markdown text,
/// feeding the output of each command as the input to the next.
///
/// Each command in the underlying pipeline receives a JSON payload on
/// stdin of the shape:
/// ```json
/// { "config": , "type": "markdown-text", "data": , "target": }
/// ```
/// and is expected to write one of the following to stdout:
/// - `{ "data": }` — the transformed text, which becomes the input
/// to the next command in the chain.
/// - An empty stdout — signals that this command does not want to modify
/// the data; the current text is passed through unchanged to the next
/// command.
///
/// If a command exits with a non-zero status, or writes anything to
/// stderr, this function raises `ProcessorError` and the chain is aborted;
/// no further commands in the pipeline are executed.
///
/// `config` is forwarded verbatim to every command invocation and is not
/// itself modified by this function.
pub async fn run_markdown_preprocessors(
config : @config.BookConfig,
data : String,
target : String,
) -> String {
let mut current = data
for script in config.extensions.preprocessors {
current = run_single_markdown_preprocessor(config, current, target, script)
}
current
}
///|
async fn run_single_markdown_preprocessor(
config : @config.BookConfig,
data : String,
target : String,
script : String,
) -> String {
let (cmd, args) = if script.split(" ").collect() is [cmd, .. args] {
(cmd, args)
} else {
(script, [])
}
let request : TextRequest = { config, type_: "markdown-text", data, target }
let input_json = request.to_json().stringify()
let (stdin_r, stdin_w) = @process.write_to_process()
let (stdout_r, stdout_w) = @process.read_from_process()
let (stderr_r, stderr_w) = @process.read_from_process()
let mut stdout_text = ""
let mut stderr_text = ""
let mut exit_code = -1
@async.with_task_group(group => {
group.spawn_bg(() => {
stdin_w.write(input_json)
stdin_w.close()
})
group.spawn_bg(() => {
let out = stdout_r.read_all()
stdout_text = out.text()
stdout_r.close()
})
group.spawn_bg(() => {
let err = stderr_r.read_all()
stderr_text = err.text()
stderr_r.close()
})
exit_code = @process.run(
cmd,
args.map(Show::to_string),
stdin=stdin_r,
stdout=stdout_w,
stderr=stderr_w,
)
})
if exit_code != 0 {
raise ProcessorError::NonZeroExit(
command=script,
exit_code~,
stderr=stderr_text,
)
}
if !stderr_text.trim(chars=" \n\r\t").is_empty() {
raise ProcessorError::StderrReported(command=script, message=stderr_text)
}
if stdout_text.trim(chars=" \n\r\t").is_empty() {
return data
}
try {
let json : Json = @json.parse(stdout_text)
let response : TextResponse = @json.from_json(json)
response.data
} catch {
err =>
raise ProcessorError::InvalidStdout(
command=script,
raw=stdout_text,
reason=err.to_string(),
)
}
}