// output.mbt — deterministic text and JSON rendering of links and reports.
//
// JSON here is emitted by hand (concatenation, never string interpolation
// inside quote-heavy literals) so the CLI has no runtime dependency beyond
// the library. The `parse --json` shape is a display format; the round-trippable
// documents are produced by the `to-linkset-json` / `from-linkset-json`
// subcommands through the library's own serializer.
///| Escapes a string for JSON: `"` and `\` are escaped, control characters
///|
/// below 0x20 become `\u00XX`, everything else is emitted as UTF-8.
fn json_quote(s : String) -> String {
let sb = StringBuilder()
sb.write_char('"')
for c in s.iter() {
let v = c.to_int()
if v == 34 {
sb.write_string("\\\"")
} else if v == 92 {
sb.write_string("\\\\")
} else if v < 32 {
sb.write_string("\\u00")
sb.write_char(hex_digit(v / 16))
sb.write_char(hex_digit(v % 16))
} else {
sb.write_char(c)
}
}
sb.write_char('"')
sb.to_string()
}
///|
fn hex_digit(v : Int) -> Char {
if v < 10 {
(48 + v).unsafe_to_char()
} else {
(65 + v - 10).unsafe_to_char()
}
}
///|
/// The relation-type display text: the extension URI or the registered name.
fn relation_display(rt : @weblink.RelationType) -> String {
match rt.extension_uri() {
Some(u) => u
None =>
match rt.name() {
Some(n) => n
None => "?"
}
}
}
///|
/// The relation list of a link joined by single spaces (the `rel` value).
fn relation_list_text(link : @weblink.WebLink) -> String {
let parts : Array[String] = Array::new()
for rt in link.relations() {
parts.push(relation_display(rt))
}
join(parts, " ")
}
///|
fn join(arr : Array[String], sep : String) -> String {
let sb = StringBuilder()
for i = 0; i < arr.length(); i = i + 1 {
if i > 0 {
sb.write_string(sep)
}
sb.write_string(arr[i])
}
sb.to_string()
}
///|
/// A human-readable multi-line rendering of one link.
fn render_link(index : Int, link : @weblink.WebLink) -> String {
let sb = StringBuilder()
sb.write_string("link ")
sb.write_string(index.to_string())
sb.write_string(": <")
sb.write_string(link.target())
sb.write_string(">; rel=\"")
sb.write_string(relation_list_text(link))
sb.write_string("\"")
match link.anchor() {
Some(a) => {
sb.write_string("\n anchor: ")
sb.write_string(a)
}
None => ()
}
for hl in link.hreflang() {
sb.write_string("\n hreflang: ")
sb.write_string(hl)
}
match link.media() {
Some(m) => {
sb.write_string("\n media: ")
sb.write_string(m)
}
None => ()
}
match link.title() {
Some(t) => {
sb.write_string("\n title: ")
sb.write_string(t)
}
None => ()
}
match link.title_star() {
Some(ev) => {
sb.write_string("\n title*: ")
sb.write_string(@weblink.serialize_extended_value(ev))
}
None => ()
}
match link.media_type() {
Some(t) => {
sb.write_string("\n type: ")
sb.write_string(t)
}
None => ()
}
for p in link.extensions() {
sb.write_string("\n ")
sb.write_string(p.name())
match p.value() {
Some(v) => {
sb.write_string("=")
sb.write_string(v)
}
None => ()
}
}
sb.to_string()
}
///|
/// A compact JSON object describing one link.
fn link_to_json(link : @weblink.WebLink) -> String {
let sb = StringBuilder()
sb.write_string("{\"target\": " + json_quote(link.target()))
sb.write_string(", \"relations\": [" + rels_json(link) + "]")
sb.write_string(", \"anchor\": " + opt_json(link.anchor()))
sb.write_string(", \"hreflang\": [" + hreflang_json(link) + "]")
sb.write_string(", \"media\": " + opt_json(link.media()))
sb.write_string(", \"title\": " + opt_json(link.title()))
match link.title_star() {
Some(ev) =>
sb.write_string(
", \"title*\": " + json_quote(@weblink.serialize_extended_value(ev)),
)
None => sb.write_string(", \"title*\": null")
}
sb.write_string(", \"type\": " + opt_json(link.media_type()))
sb.write_string(", \"extensions\": [" + extensions_json(link) + "]}")
sb.to_string()
}
///|
fn rels_json(link : @weblink.WebLink) -> String {
let parts : Array[String] = Array::new()
for rt in link.relations() {
parts.push(json_quote(relation_display(rt)))
}
join(parts, ", ")
}
///|
fn hreflang_json(link : @weblink.WebLink) -> String {
let parts : Array[String] = Array::new()
for hl in link.hreflang() {
parts.push(json_quote(hl))
}
join(parts, ", ")
}
///|
fn extensions_json(link : @weblink.WebLink) -> String {
let parts : Array[String] = Array::new()
for p in link.extensions() {
match p.value() {
Some(v) => parts.push(json_quote(p.name() + "=" + v))
None => parts.push(json_quote(p.name()))
}
}
join(parts, ", ")
}
///|
fn opt_json(v : String?) -> String {
match v {
Some(s) => json_quote(s)
None => "null"
}
}
///|
/// A JSON array of link objects.
fn links_json(links : Array[@weblink.WebLink]) -> String {
let parts : Array[String] = Array::new()
for link in links {
parts.push(link_to_json(link))
}
"[" + join(parts, ", ") + "]"
}
///|
/// A JSON object describing an audit report.
fn audit_json(report : @weblink.AuditReport) -> String {
let parts : Array[String] = Array::new()
for issue in report.issues() {
let sb = StringBuilder()
sb.write_string(
"{\"severity\": " + json_quote(issue.severity().to_string()),
)
sb.write_string(", \"code\": " + json_quote(issue.code()))
sb.write_string(", \"message\": " + json_quote(issue.message()))
sb.write_string(", \"link_index\": " + issue.link_index().to_string() + "}")
parts.push(sb.to_string())
}
"{\"issues\": [" +
join(parts, ", ") +
"], \"clean\": " +
report.is_clean().to_string() +
"}"
}