zig langref cli nate.tngl.io/zigman
0

Configure Feed

Select the types of activity you want to include in your feed.

zigman / src / render.zig
11 kB 330 lines
1//! HTML fragment -> markdown renderer for the zig language reference. 2//! 3//! Scope: this renders a *known-clean* fragment (one langref section, sliced by 4//! id anchor). It is NOT a general web extractor — there is no boilerplate to 5//! strip inside a section, so it just preserves structure: headings, paragraphs, 6//! lists, tables, code, and inline code. `render` is a clean seam, so a real 7//! content extractor could front it later if zigman ever read arbitrary pages. 8 9const std = @import("std"); 10const Writer = std.Io.Writer; 11 12/// Render an HTML section fragment to markdown. Caller owns the returned slice. 13pub fn render(allocator: std.mem.Allocator, html: []const u8) ![]u8 { 14 var out: Writer.Allocating = .init(allocator); 15 defer out.deinit(); 16 var e: Emitter = .{ .w = &out.writer }; 17 18 var i: usize = 0; 19 while (i < html.len) { 20 if (html[i] == '<') { 21 const tag = parseTag(html[i..]) orelse { 22 try e.text("<"); 23 i += 1; 24 continue; 25 }; 26 if (!tag.close and eql(tag.name, "a") and contains(tag.raw, "class=\"hdr\"")) { 27 i += skipUntilClose(html[i..], "a"); 28 continue; 29 } 30 if (!tag.close and eql(tag.name, "figcaption")) { 31 i += skipUntilClose(html[i..], "figcaption"); 32 continue; 33 } 34 if (!tag.close and eql(tag.name, "table")) { 35 const span = skipUntilClose(html[i..], "table"); 36 e.newlines(2); 37 try e.flushWs(); 38 try renderTable(allocator, e.w, html[i .. i + span]); 39 e.wrote = true; 40 e.newlines(2); 41 i += span; 42 continue; 43 } 44 try applyTag(&e, tag); 45 i += tag.len; 46 continue; 47 } 48 const end = std.mem.indexOfScalarPos(u8, html, i, '<') orelse html.len; 49 try e.text(html[i..end]); 50 i = end; 51 } 52 53 return allocator.dupe(u8, std.mem.trim(u8, out.written(), " \n")); 54} 55 56/// Coalesces whitespace so block tags can freely request blank lines / spaces 57/// without producing runs of empty lines or leading indentation. Inside a 58/// `<pre>`, bytes pass through verbatim. 59const Emitter = struct { 60 w: *Writer, 61 pending_nl: u8 = 0, // requested newlines, capped at 2, flushed before content 62 pending_sp: bool = false, 63 wrote: bool = false, 64 in_pre: bool = false, 65 66 fn newlines(e: *Emitter, n: u8) void { 67 if (n > e.pending_nl) e.pending_nl = @min(n, 2); 68 e.pending_sp = false; 69 } 70 fn space(e: *Emitter) void { 71 e.pending_sp = true; 72 } 73 74 fn flushWs(e: *Emitter) !void { 75 if (!e.wrote) { 76 e.pending_nl = 0; 77 e.pending_sp = false; 78 return; 79 } 80 if (e.pending_nl > 0) { 81 try e.w.splatByteAll('\n', e.pending_nl); 82 } else if (e.pending_sp) { 83 try e.w.writeByte(' '); 84 } 85 e.pending_nl = 0; 86 e.pending_sp = false; 87 } 88 89 /// raw literal content (already markdown), flushing pending whitespace first. 90 fn raw(e: *Emitter, s: []const u8) !void { 91 if (s.len == 0) return; 92 try e.flushWs(); 93 try e.w.writeAll(s); 94 e.wrote = true; 95 } 96 97 /// HTML text run: decode entities; outside <pre>, collapse whitespace and 98 /// drop the § pilcrow; inside <pre>, preserve everything verbatim. 99 fn text(e: *Emitter, run: []const u8) !void { 100 if (e.in_pre) { 101 // emit pending block boundary (the fence opener), then raw code 102 try e.flushWs(); 103 try decodeEntities(e.w, run); 104 if (run.len > 0) e.wrote = true; 105 return; 106 } 107 var k: usize = 0; 108 while (k < run.len) { 109 const c = run[k]; 110 if (c == ' ' or c == '\n' or c == '\t' or c == '\r') { 111 e.space(); 112 k += 1; 113 continue; 114 } 115 if (c == 0xC2 and k + 1 < run.len and run[k + 1] == 0xA7) { // § 116 k += 2; 117 continue; 118 } 119 try e.flushWs(); 120 if (c == '&') { 121 k += try writeEntity(e.w, run[k..]); 122 } else { 123 try e.w.writeByte(c); 124 k += 1; 125 } 126 e.wrote = true; 127 } 128 } 129}; 130 131fn applyTag(e: *Emitter, tag: Tag) !void { 132 const n = tag.name; 133 if (headingLevel(n)) |lvl| { 134 if (!tag.close) { 135 e.newlines(2); 136 try e.flushWs(); 137 try e.w.splatByteAll('#', lvl); 138 try e.w.writeByte(' '); 139 e.wrote = true; 140 } else e.newlines(2); 141 } else if (eql(n, "p") or eql(n, "ul") or eql(n, "ol") or eql(n, "blockquote")) { 142 e.newlines(2); 143 } else if (eql(n, "li")) { 144 if (!tag.close) { 145 e.newlines(1); 146 try e.raw("- "); 147 } 148 } else if (eql(n, "pre")) { 149 if (!tag.close) { 150 e.newlines(2); 151 try e.raw("```zig\n"); 152 e.in_pre = true; 153 } else { 154 e.in_pre = false; 155 try e.raw("\n```"); 156 e.newlines(2); 157 } 158 } else if (eql(n, "code") and !e.in_pre) { 159 try e.raw("`"); 160 } else if (eql(n, "br")) { 161 e.newlines(1); 162 } 163 // span, a, em, i, b, figure, cite, … : structurally dropped 164} 165 166const Tag = struct { 167 name: []const u8, 168 close: bool, 169 len: usize, 170 raw: []const u8, 171}; 172 173fn parseTag(s: []const u8) ?Tag { 174 if (s.len < 2 or s[0] != '<') return null; 175 const gt = std.mem.indexOfScalar(u8, s, '>') orelse return null; 176 var j: usize = 1; 177 const close = s[j] == '/'; 178 if (close) j += 1; 179 const start = j; 180 while (j < gt and isNameChar(s[j])) j += 1; 181 return .{ .name = s[start..j], .close = close, .len = gt + 1, .raw = s[0 .. gt + 1] }; 182} 183 184fn decodeEntities(w: *Writer, raw: []const u8) !void { 185 var k: usize = 0; 186 while (k < raw.len) { 187 if (raw[k] == '&') { 188 k += try writeEntity(w, raw[k..]); 189 } else { 190 try w.writeByte(raw[k]); 191 k += 1; 192 } 193 } 194} 195 196fn writeEntity(w: *Writer, s: []const u8) !usize { 197 const map = .{ 198 .{ "&quot;", "\"" }, .{ "&amp;", "&" }, .{ "&lt;", "<" }, 199 .{ "&gt;", ">" }, .{ "&#39;", "'" }, .{ "&apos;", "'" }, 200 .{ "&nbsp;", " " }, 201 }; 202 inline for (map) |m| { 203 if (std.mem.startsWith(u8, s, m[0])) { 204 try w.writeAll(m[1]); 205 return m[0].len; 206 } 207 } 208 try w.writeByte('&'); 209 return 1; 210} 211 212/// Render a `<table>…</table>` region as a markdown table. The first row is 213/// treated as the header (langref tables lead with `<th>`). 214fn renderTable(allocator: std.mem.Allocator, w: *Writer, table: []const u8) !void { 215 var first = true; 216 var ncols: usize = 0; 217 var ri: usize = 0; 218 while (std.mem.indexOfPos(u8, table, ri, "<tr")) |tr| { 219 const tr_end = tr + skipUntilClose(table[tr..], "tr"); 220 const row = table[tr..tr_end]; 221 222 var cells: usize = 0; 223 try w.writeAll("|"); 224 var ci: usize = 0; 225 while (nextCell(row, ci)) |cell| { 226 const md = try renderCell(allocator, cell.inner); 227 defer allocator.free(md); 228 try w.print(" {s} |", .{md}); 229 cells += 1; 230 ci = cell.next; 231 } 232 try w.writeByte('\n'); 233 if (first) { 234 ncols = cells; 235 try w.writeAll("|"); 236 for (0..ncols) |_| try w.writeAll(" --- |"); 237 try w.writeByte('\n'); 238 first = false; 239 } 240 ri = tr_end; 241 } 242} 243 244const Cell = struct { inner: []const u8, next: usize }; 245 246fn nextCell(row: []const u8, from: usize) ?Cell { 247 var i = from; 248 while (std.mem.indexOfScalarPos(u8, row, i, '<')) |lt| { 249 const tag = parseTag(row[lt..]) orelse { 250 i = lt + 1; 251 continue; 252 }; 253 if (!tag.close and (eql(tag.name, "td") or eql(tag.name, "th"))) { 254 const inner_start = lt + tag.len; 255 const close = skipUntilClose(row[inner_start..], tag.name); 256 // close includes the closing tag; trim it back to inner content 257 const inner = trimClose(row[inner_start .. inner_start + close], tag.name); 258 return .{ .inner = inner, .next = inner_start + close }; 259 } 260 i = lt + tag.len; 261 } 262 return null; 263} 264 265fn trimClose(s: []const u8, name: []const u8) []const u8 { 266 // s ends with "</name>"; drop it 267 const tail = std.mem.lastIndexOf(u8, s, "</") orelse return s; 268 _ = name; 269 return s[0..tail]; 270} 271 272/// Render one table cell's inner HTML to a single inline string (no newlines, 273/// `|` escaped). Reuses the Emitter for code/entity/whitespace handling. 274fn renderCell(allocator: std.mem.Allocator, inner: []const u8) ![]u8 { 275 var tmp: Writer.Allocating = .init(allocator); 276 defer tmp.deinit(); 277 var e: Emitter = .{ .w = &tmp.writer }; 278 var i: usize = 0; 279 while (i < inner.len) { 280 if (inner[i] == '<') { 281 const tag = parseTag(inner[i..]) orelse { 282 try e.text("<"); 283 i += 1; 284 continue; 285 }; 286 if (eql(tag.name, "code")) try e.raw("`"); 287 i += tag.len; 288 continue; 289 } 290 const end = std.mem.indexOfScalarPos(u8, inner, i, '<') orelse inner.len; 291 try e.text(inner[i..end]); 292 i = end; 293 } 294 const flat = std.mem.trim(u8, tmp.written(), " \n"); 295 // escape pipes so cells don't break the table 296 var buf: std.ArrayList(u8) = .empty; 297 errdefer buf.deinit(allocator); 298 for (flat) |c| { 299 if (c == '|') try buf.append(allocator, '\\'); 300 if (c == '\n') { 301 try buf.append(allocator, ' '); 302 } else try buf.append(allocator, c); 303 } 304 return buf.toOwnedSlice(allocator); 305} 306 307fn skipUntilClose(s: []const u8, name: []const u8) usize { 308 var i: usize = 0; 309 while (std.mem.indexOfScalarPos(u8, s, i, '<')) |lt| { 310 if (parseTag(s[lt..])) |t| { 311 if (t.close and eql(t.name, name)) return lt + t.len; 312 i = lt + t.len; 313 } else i = lt + 1; 314 } 315 return s.len; 316} 317 318fn headingLevel(n: []const u8) ?u3 { 319 if (n.len == 2 and n[0] == 'h' and n[1] >= '1' and n[1] <= '6') return @intCast(n[1] - '0'); 320 return null; 321} 322fn isNameChar(c: u8) bool { 323 return (c >= 'a' and c <= 'z') or (c >= 'A' and c <= 'Z') or (c >= '0' and c <= '9'); 324} 325fn eql(a: []const u8, b: []const u8) bool { 326 return std.ascii.eqlIgnoreCase(a, b); 327} 328fn contains(h: []const u8, n: []const u8) bool { 329 return std.mem.indexOf(u8, h, n) != null; 330}