diff --git a/lib/babycode.lua b/lib/babycode.lua
new file mode 100644
index 0000000..afbb9a4
--- /dev/null
+++ b/lib/babycode.lua
@@ -0,0 +1,62 @@
+local babycode = {}
+
+local _escape_html = function(text)
+ return text:gsub("[&<>\"']", {
+ ["&"] = "&",
+ ["<"] = "<",
+ [">"] = ">",
+ ['"'] = """,
+ ["'"] = "'"
+ })
+end
+
+---renders babycode to html
+---@param s string input babycode
+---@param escape_html fun(s: string): string function that escapes html
+function babycode.to_html(s, escape_html)
+ if not s or s == "" then return "" end
+ -- extract code blocks first and store them as placeholders
+ -- don't want to process bbcode embedded into a code block
+ local code_blocks = {}
+ local code_count = 0
+ local text = s:gsub("%[code%](.-)%[/code%]", function(code)
+ code_count = code_count + 1
+ code_blocks[code_count] = code
+ return "\1CODE:"..code_count.."\1"
+ end)
+
+ -- replace `[url=https://example.com]Example[/url] tags
+ text = text:gsub("%[url=([^%]]+)%](.-)%[/url%]", function(url, label)
+ return ''..escape_html(label)..''
+ end)
+
+ -- replace `[url]https://example.com[/url] tags
+ text = text:gsub("%[url%]([^%]]+)%[/url%]", function(url)
+ return ''..escape_html(url)..''
+ end)
+
+ -- bold, italics, strikethrough
+ text = text:gsub("%[b%](.-)%[/b%]", "%1")
+ text = text:gsub("%[i%](.-)%[/i%]", "%1")
+ text = text:gsub("%[s%](.-)%[/s%]", "%1")
+
+ -- replace loose links
+ text = text:gsub("(https?://[%w-_%.%?%.:/%+=&~%@#%%]+[%w-/])", function(url)
+ if not text:find(']*>'..url..'') then
+ return ''..escape_html(url)..''
+ end
+ return url
+ end)
+
+ -- replace code block placeholders back with their original contents
+ text = text:gsub("\1CODE:(%d+)\1", function(n)
+ return "
"..code_blocks[tonumber(n)].."
"
+ end)
+
+ -- finally, normalize newlines replace them with <%- require("lapis.html").escape(post.content):gsub("\n", "
") %>
<%- post.content %>