zig langref cli
nate.tngl.io/zigman
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 .{ """, "\"" }, .{ "&", "&" }, .{ "<", "<" },
199 .{ ">", ">" }, .{ "'", "'" }, .{ "'", "'" },
200 .{ " ", " " },
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}