authorgravatar for ian@ianjohnson.devIan Johnson <ian@ianjohnson.dev> 2024-03-22 20:50:07-04:00
committergravatar for ian@ianjohnson.devIan Johnson <ian@ianjohnson.dev> 2024-03-22 20:53:25-04:00
logad34ed5a63ef912fba5232806a1adea6ea55181b
tree57e1e6a737c3978bbf9e30a86f0c5714ac72aa15
parentd3ca9d55d9bae81aa3d01cd9936ff66bb26a8e9c

Autodoc: recognize Markdown links in plain text

This extension to the typical `<>` Markdown autolink syntax allows HTTP(S) links to be recognized in normal text without being delimited by `<>`. This is the most natural way to write links in text, so it makes sense to support it and allow documentation comments to be written in a more natural way.

2 files changed, 137 insertions(+), 0 deletions(-)

lib/docs/wasm/markdown.zig+25
...@@ -81,6 +81,11 @@...@@ -81,6 +81,11 @@
81//! escapes). `target` is expected to be an absolute URI: an autolink will not81//! escapes). `target` is expected to be an absolute URI: an autolink will not
82//! be recognized unless `target` starts with a URI scheme followed by a `:`.82//! be recognized unless `target` starts with a URI scheme followed by a `:`.
83//!83//!
84//! For convenience, autolinks may also be recognized in plain text without
85//! any `<>` delimiters. Such autolinks are restricted to start with `http://`
86//! or `https://` followed by at least one other character, not including any
87//! trailing punctuation after the link.
88//!
84//! - **Image** - a link directly preceded by a `!`. The link text is89//! - **Image** - a link directly preceded by a `!`. The link text is
85//! interpreted as the alt text of the image.90//! interpreted as the alt text of the image.
86//!91//!
...@@ -740,6 +745,26 @@ test "autolinks" {...@@ -740,6 +745,26 @@ test "autolinks" {
740 );745 );
741}746}
742747
748test "text autolinks" {
749 try testRender(
750 \\Text autolinks must start with http:// or https://.
751 \\This doesn't count: ftp://example.com.
752 \\Example: https://ziglang.org.
753 \\Here is an important link: **http://example.com**
754 \\(Links may be in parentheses: https://example.com/?q=(parens))
755 \\Escaping a link so it's plain text: https\://example.com
756 \\
757 ,
758 \\<p>Text autolinks must start with http:// or https://.
759 \\This doesn't count: ftp://example.com.
760 \\Example: <a href="https://ziglang.org">https://ziglang.org</a>.
761 \\Here is an important link: <strong><a href="http://example.com">http://example.com</a></strong>
762 \\(Links may be in parentheses: <a href="https://example.com/?q=(parens)">https://example.com/?q=(parens)</a>)
763 \\Escaping a link so it's plain text: https://example.com</p>
764 \\
765 );
766}
767
743test "images" {768test "images" {
744 try testRender(769 try testRender(
745 \\![Alt text](https://example.com/image.png)770 \\![Alt text](https://example.com/image.png)
lib/docs/wasm/markdown/Parser.zig+112
...@@ -988,6 +988,9 @@ const InlineParser = struct {...@@ -988,6 +988,9 @@ const InlineParser = struct {
988 '<' => try ip.parseAutolink(),988 '<' => try ip.parseAutolink(),
989 '*', '_' => try ip.parseEmphasis(),989 '*', '_' => try ip.parseEmphasis(),
990 '`' => try ip.parseCodeSpan(),990 '`' => try ip.parseCodeSpan(),
991 'h' => if (ip.pos == 0 or isPreTextAutolink(ip.content[ip.pos - 1])) {
992 try ip.parseTextAutolink();
993 },
991 else => {},994 else => {},
992 }995 }
993 }996 }
...@@ -1123,6 +1126,115 @@ const InlineParser = struct {...@@ -1123,6 +1126,115 @@ const InlineParser = struct {
1123 ip.pos = start;1126 ip.pos = start;
1124 }1127 }
11251128
1129 /// Parses a plain text autolink (not delimited by `<>`), starting at the
1130 /// first character in the link (an `h`). `ip.pos` is left at the last
1131 /// character of the link, or remains unchanged if there is no valid link.
1132 fn parseTextAutolink(ip: *InlineParser) !void {
1133 const start = ip.pos;
1134 var state: union(enum) {
1135 /// Inside `http`. Contains the rest of the text to be matched.
1136 http: []const u8,
1137 after_http,
1138 after_https,
1139 /// Inside `://`. Contains the rest of the text to be matched.
1140 authority: []const u8,
1141 /// Inside link content.
1142 content: struct {
1143 start: usize,
1144 paren_nesting: usize,
1145 },
1146 } = .{ .http = "http" };
1147
1148 while (ip.pos < ip.content.len) : (ip.pos += 1) {
1149 switch (state) {
1150 .http => |rest| {
1151 if (ip.content[ip.pos] != rest[0]) break;
1152 if (rest.len > 1) {
1153 state = .{ .http = rest[1..] };
1154 } else {
1155 state = .after_http;
1156 }
1157 },
1158 .after_http => switch (ip.content[ip.pos]) {
1159 's' => state = .after_https,
1160 ':' => state = .{ .authority = "//" },
1161 else => break,
1162 },
1163 .after_https => switch (ip.content[ip.pos]) {
1164 ':' => state = .{ .authority = "//" },
1165 else => break,
1166 },
1167 .authority => |rest| {
1168 if (ip.content[ip.pos] != rest[0]) break;
1169 if (rest.len > 1) {
1170 state = .{ .authority = rest[1..] };
1171 } else {
1172 state = .{ .content = .{
1173 .start = ip.pos + 1,
1174 .paren_nesting = 0,
1175 } };
1176 }
1177 },
1178 .content => |*content| switch (ip.content[ip.pos]) {
1179 ' ', '\t', '\n' => break,
1180 '(' => content.paren_nesting += 1,
1181 ')' => if (content.paren_nesting == 0) {
1182 break;
1183 } else {
1184 content.paren_nesting -= 1;
1185 },
1186 else => {},
1187 },
1188 }
1189 }
1190
1191 switch (state) {
1192 .http, .after_http, .after_https, .authority => {
1193 ip.pos = start;
1194 },
1195 .content => |content| {
1196 while (ip.pos > content.start and isPostTextAutolink(ip.content[ip.pos - 1])) {
1197 ip.pos -= 1;
1198 }
1199 if (ip.pos == content.start) {
1200 ip.pos = start;
1201 return;
1202 }
1203
1204 const target = try ip.parent.addString(ip.content[start..ip.pos]);
1205 const node = try ip.parent.addNode(.{
1206 .tag = .autolink,
1207 .data = .{ .text = .{
1208 .content = target,
1209 } },
1210 });
1211 try ip.completed_inlines.append(ip.parent.allocator, .{
1212 .node = node,
1213 .start = start,
1214 .len = ip.pos - start,
1215 });
1216 ip.pos -= 1;
1217 },
1218 }
1219 }
1220
1221 /// Returns whether `c` may appear before a text autolink is recognized.
1222 fn isPreTextAutolink(c: u8) bool {
1223 return switch (c) {
1224 ' ', '\t', '\n', '*', '_', '(' => true,
1225 else => false,
1226 };
1227 }
1228
1229 /// Returns whether `c` is punctuation that may appear after a text autolink
1230 /// and not be considered part of it.
1231 fn isPostTextAutolink(c: u8) bool {
1232 return switch (c) {
1233 '?', '!', '.', ',', ':', '*', '_' => true,
1234 else => false,
1235 };
1236 }
1237
1126 /// Parses emphasis, starting at the beginning of a run of `*` or `_`1238 /// Parses emphasis, starting at the beginning of a run of `*` or `_`
1127 /// characters. `ip.pos` is left at the last character in the run after1239 /// characters. `ip.pos` is left at the last character in the run after
1128 /// parsing.1240 /// parsing.