diff options
| author | Stanisław Zagórowski <hasohasacz@gmail.com> | 2024-01-14 16:21:57 +0100 |
|---|---|---|
| committer | Stanisław Zagórowski <hasohasacz@gmail.com> | 2024-01-14 16:21:57 +0100 |
| commit | fe7254f7a7b7c8b95824f2d6ce2ee56709ba019d (patch) | |
| tree | 325d04c74c54d1b90b483a96be8c6b689f34137c /luax | |
| parent | 6b068ccd9156de8515203d0000c3a2a8ecb9c5fb (diff) | |
luax: stuff
Diffstat (limited to 'luax')
| -rw-r--r-- | luax/blog/building-reluax.md | 211 | ||||
| -rw-r--r-- | luax/blog/cache.lua | 23 | ||||
| -rw-r--r-- | luax/blog/posts.lua | 8 | ||||
| -rw-r--r-- | luax/common.luax | 19 | ||||
| -rw-r--r-- | luax/components/project.luax | 6 | ||||
| -rw-r--r-- | luax/libs/markdown.lua | 1281 | ||||
| -rw-r--r-- | luax/pages/article.luax | 3 | ||||
| -rw-r--r-- | luax/pages/blog.luax | 28 | ||||
| -rw-r--r-- | luax/pages/blogpost.luax | 23 | ||||
| -rw-r--r-- | luax/pages/home.luax | 2 | ||||
| -rw-r--r-- | luax/public/CNAME | 1 | ||||
| -rw-r--r-- | luax/public/assets/app.css | 4 | ||||
| -rw-r--r-- | luax/public/assets/blog.css | 85 | ||||
| -rw-r--r-- | luax/public/assets/blog/building-reluax/cli.png | bin | 0 -> 25294 bytes | |||
| -rw-r--r-- | luax/public/assets/blog/building-reluax/first-demo.png | bin | 0 -> 105706 bytes | |||
| -rw-r--r-- | luax/public/assets/blog/building-reluax/second-demo.png | bin | 0 -> 147578 bytes | |||
| -rw-r--r-- | luax/public/assets/index.js | 2 | ||||
| -rw-r--r-- | luax/reluax.luax | 25 |
18 files changed, 1697 insertions, 24 deletions
diff --git a/luax/blog/building-reluax.md b/luax/blog/building-reluax.md new file mode 100644 index 0000000..ed26c7a --- /dev/null +++ b/luax/blog/building-reluax.md @@ -0,0 +1,211 @@ +## The inspiration +Around a week ago, I was working on some projects, as usual, and had YouTube playing on the +second monitor. The video roulette landed on +[ThePrimagen](https://www.youtube.com/watch?v=r4HyasuvjKM). This time he was reading a post that +really intrigued me, because it shared many of the sentiments that were boiling in my head. This +website you're reading was originally written in raw HTML, served on GitHub pages. This was +obviously not ideal for maintenance. Adding anything involved copypasting large chunks of +code, changing anything about the layout required changes across multiple files. Not a very +satisfying process, and it made me avoid doing anything with it. + +Then, I had to learn React. It was pretty neat. I could define *actual, reusable components*. +I could actually write logic to manipulate data! Have it structured! Write functions! And it +only needed small changes to the actual HTML of my site, I just had to move it around a bit +so it sat right in the middle of some TypeScript code. + +I thought this was a really neat idea. So I rewrote the old website in it, and even added some +*visual flair*, like the shader at the top of this page (I had to show off my love of graphics +programming *somehow*). And to manage the project, I used [Vite](https://vitejs.dev/), which let +me easily pack up the project into static files, and still serve them from GitHub pages (with a +neat tool to automate it, [react-gh-pages](https://github.com/gitname/react-gh-pages)). I was +pretty proud of how it looked, and it was a good experience to write. + +But after a while, I wanted to build a blog. Everyone has one, right? Why not me? I can do +interesting things too. But as of then, the project and games sections of the website were just +big JSON files, included into the TS of the React project. I couldn't think of a simpler way to +have the data and still keep all of the site static. I did not really want to pay for hosting if +I didn't have to. But a project description needed formatting sometimes, so it used a rather ugly +way of just storing the HTML as a string in the JSON, like so: + +``` +json +[ + { + "name": "Mr Final Boss", + "name_normalized": "mr-final-boss", + "type": "game", + "link": "https://jellingfish-games.itch.io/mr-final-boss", + "tech": [ + "unity", + "csharp" + ], + "targets": [ + "windows", + "html5" + ], + "images": [ + "thumbnail.png", + "screenshot1.png", + "screenshot2.png", + "screenshot3.png", + "screenshot4.png" + ], + "description": "The legends say that one day, brave Heroes will slay the evil and malicious Ignatius, the Chaos Bringer... But YOU won't go down without a fight! Be the bossfight everyone is dreading and hunt down as many Heroes as you can in your last stand using a variety of abilities you can choose from! <br />Game made in 72 hours for <a href=\"https://ldjam.com/events/ludum-dare/50\">Ludum Dare 50</a>, with the theme \"Delay the Inevitable\"." + } +] +``` + +Since it's JSON, the string couldn't have line breaks, which is really just something I could +live with when the text was a short summary. This would just not work for blog posts. To actually +write blog posts, I wanted to use the beloved, old classic: Markdown. So I would basically have +two options: either have *another* preprocessing step when building the website, which would +require writing some more scripts and making the process of deployment more complicated. It would +also mean I would store all the content of all the blog posts in the code that gets served +to anyone who visits any page on the site, which just felt stupid, even if text is a fraction of +the size of a single image. + +The alternative was serving markdown files in full, and processing them into HTML client-side. +Now, this is my personal website, a site serving probably an average of the 2 times a month I +visit it myself to show people. But it still felt like a really dumb way to do things. I'm sure +many sites do more intensive processing client-side, but I just couldn't think for a good reason +"*why?*" + +To solve these issues, I would have to unavoidably write code. There were many options for what +paths I could take, such as rewriting my site in a Static Site Generator like +[Hugo](https://gohugo.io/), or writing a server in whatever of the countless options exist. +I didn't want to go the templating route. It felt really limiting after encountering JSX, and I +would have to rewrite my site inside-out, with a different approach. I could split my codebase in +half, keeping the React site as the front but making it actually do its job of requesting data +from the backend. I felt like doing it that way just for serving blog posts would grow the effort +required to change anything about the site yet again, which was something I really would rather +not do. The entire point is to be able to enjoy making this stuff. + +### The LuaX revelation +Coming back to the beginning of the article, I watched a video of ThePrimagen reading a +fascinating blog post, +[I made JSX for Lua (because I hate static sites)](https://bvisness.me/luax) by Ben Visness. +He seemed to share my feeling about the approach to managing HTML, and how JSX was really +convenient. And his solution was simple, powerful, and perhaps most importantly, cool. Writing a +preprocessor to create a custom dialect of Lua. And he finished the blog post with a note of +encouragement: +> I don’t know what the future holds for LuaX. It’s not ready for other people to use, and I’m +> not sure it will ever be. So rather than tell you all to go to luax dot dev and start building +> your websites with my tool, here’s what I’ll say instead: +> +> Try building something for yourself. Try writing code for you, and you alone. Don’t worry about +> whether it will look good on your resumé or attract lots of stars on GitHub. Just write +> something that feels good to you. Explore a weird idea and see where it takes you. +> +> Who knows—maybe someday other people will like it too. + +So I set out to recreate it. After all, I wanted my site to run on LuaX as well, and I wouldn't +be deterred by inconvenient facts such as the fact it doesn't really exist yet. + +### Enter Reluax +I decided to name my version `reluax`, since it is a remake, and it was relaxing to write on the +side, procrastinating other responsibilities. I wrote the bulk of it in Rust, since I had a +experience writing a language in it from writing [brakion](https://github.com/Duckonaut/brakion), +and I could copy the lexer almost in full. Rust would also provide a good ecosystem for the +HTTP server portion, with battle-tested libraries like [hyper](https://hyper.rs/). I didn't go +for any of the higher-level libraries like [axum](https://github.com/tokio-rs/axum), since I +felt like I wouldn't really benefit from them, since what I was building was not to be a layer +on top of one, but a replacement of it. + +The parser portion took a bit, but it was not anything too complex. Writing a lexer that +simultaneously structres the data, but can emit whatever garbage comes inside of an HTML tag, was +a bit tricky. But in two evenings I had a demo: + + + +It was a bit rough, but proved it was all working. The next day I had something akin to React's +functional components: + + + +And on the third day, this site you're reading right now was running fully on Reluax. I've also +taken the extra time to add some nicer output to the program, just for fun. + + + +And of course, with making a custom dialect of Lua, I would throw away all the goodies that give +modern developers a good experience writing code, like syntax highlighting or a language server. +Not content with that, I threw together a tree-sitter parser to at least have syntax highlighting +back in my beloved Neovim. I've written a couple in the past, so adapting the +[tree-sitter-lua](https://github.com/tjdevries/tree-sitter-lua) grammar only took a cople hours. +The syntax highlighting of this blog is powered by tree-sitter, so you can enjoy a pretty +view of how the entry point of this website looks! + +``` +luax +local Common = require("common") +local Navbar = require("components.navbar") +local AnimatedBg = require("components.animated_bg") + +local Home = require("pages.home") +local Projects = require("pages.projects") +local Games = require("pages.games") +local Contact = require("pages.contact") +local Blogpost = require("pages.blogpost") +local Blog = require("pages.blog") +local NotFound = require("not_found") + +local blogposts = require("blog.posts") + +math.randomseed(os.time()) + +local function wrap_component(component) + return + <Common> + <Navbar /> + <div class="back-shadows"></div> + <div id="app-main"> + {$ component $} + <AnimatedBg /> + </div> + </Common> +end + +local function route(path) + if path == '/' then + return 200, wrap_component(<Home />) + elseif path == '/projects' then + return 200, wrap_component(<Projects />) + elseif path == '/games' then + return 200, wrap_component(<Games />) + elseif path == '/contact' then + return 200, wrap_component(<Contact />) + elseif path == '/blog' then + return 200, wrap_component(<Blog />) + elseif reluax.url_matches('/blog/{slug}', path) then + local slug = reluax.url_extract('/blog/{slug}', path).slug + -- blogposts is a list of blogposts, with the slug a property of each + local post = nil + for _, p in ipairs(blogposts) do + if p.slug == slug then + post = p + break + end + end + if post then + local article = wrap_component(<Blogpost post={post} />) + return 200, article + end + end + + return 404, NotFound() +end + +return { + name = "duckonaut.dev", + route = route +} +``` + +After that, I've taken some time to wrap Reluax in a Nix flake so I could easily run it on my +Oracle Cloud server, add some descriptions to the CLI help, things like that. And now I'm writing +this blog post, satisfied with how quickly it all came together. + +Building things is fun! I highly recommend it. This project gave me a really fun distraction, and +now I have my site running on something I made, something I have full control over. That's a +really good feeling. And I won't end the article the same way as Ben: you can check out the early +version of Reluax over at [GitHub](https://github.com/Duckonaut/reluax) and try it for yourself. diff --git a/luax/blog/cache.lua b/luax/blog/cache.lua new file mode 100644 index 0000000..e8f5b0c --- /dev/null +++ b/luax/blog/cache.lua @@ -0,0 +1,23 @@ +local md = require "libs.markdown" + +local blog_cache = {} + +local function get_blog_post_content(post) + local slug = post.slug + if blog_cache[slug] then + return blog_cache[slug] + end + -- pull markdown content from file + local content = io.open("blog/" .. slug .. ".md", "r"):read("*a") + + if content then + -- convert markdown to html + content = md(content) + end + + blog_cache[slug] = content + + return content +end + +return get_blog_post_content diff --git a/luax/blog/posts.lua b/luax/blog/posts.lua new file mode 100644 index 0000000..7194706 --- /dev/null +++ b/luax/blog/posts.lua @@ -0,0 +1,8 @@ +return { + { + title = "I (re)made JSX for Lua", + slug = 'building-reluax', + date = os.time({year=2024, month=1, day=13}), + description = "And rebuilt my website with it.", + }, +} diff --git a/luax/common.luax b/luax/common.luax index 8d12b41..0bd3cf7 100644 --- a/luax/common.luax +++ b/luax/common.luax @@ -2,7 +2,7 @@ return function(props) return <html lang="en"> <head> <meta charset="UTF-8" /> - <link rel="icon" type="image/svg+xml" href="/assets/images/favicon.svg" /> + <link rel="icon" type="image/svg+xml" href="https://cdn.duckonaut.dev/assets/images/favicon.svg" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <link rel="stylesheet" href="//cdn.jsdelivr.net/npm/hack-font@3.3.0/build/web/hack-subset.css" /> @@ -23,16 +23,17 @@ return <html lang="en"> <script type="text/javascript" src="https://rawgit.com/patriciogonzalezvivo/glslCanvas/master/dist/GlslCanvas.js"></script> - <script src="/assets/index.js"></script> + <script src="https://cdn.duckonaut.dev/assets/index.js"></script> - <link rel="stylesheet" href="/assets/app.css" /> - <link rel="stylesheet" href="/assets/index.css" /> - <link rel="stylesheet" href="/assets/contact.css" /> - <link rel="stylesheet" href="/assets/project.css" /> - <link rel="stylesheet" href="/assets/navbar.css" /> - <link rel="stylesheet" href="/assets/carousel.css" /> + <link rel="stylesheet" href="https://cdn.duckonaut.dev/assets/app.css" /> + <link rel="stylesheet" href="https://cdn.duckonaut.dev/assets/index.css" /> + <link rel="stylesheet" href="https://cdn.duckonaut.dev/assets/contact.css" /> + <link rel="stylesheet" href="https://cdn.duckonaut.dev/assets/project.css" /> + <link rel="stylesheet" href="https://cdn.duckonaut.dev/assets/navbar.css" /> + <link rel="stylesheet" href="https://cdn.duckonaut.dev/assets/carousel.css" /> + <link rel="stylesheet" href="https://cdn.duckonaut.dev/assets/blog.css" /> - <meta property="og:image" content="/assets/images/thumbnail.png" /> + <meta property="og:image" content="https://cdn.duckonaut.dev/assets/images/thumbnail.png" /> <meta property="og:title" content="duckonaut.dev" /> <meta property="og:description" content="duckonaut's stuff" /> <meta property="og:url" content="https://duckonaut.dev" /> diff --git a/luax/components/project.luax b/luax/components/project.luax index ddb8c69..f8d6711 100644 --- a/luax/components/project.luax +++ b/luax/components/project.luax @@ -1,15 +1,15 @@ local Carousel = require('components.carousel') function getProjectImageLink(project, image) - return 'assets/' .. project.type .. 's/' .. project.name_normalized .. '/' .. image + return 'https://cdn.duckonaut.dev/assets/' .. project.type .. 's/' .. project.name_normalized .. '/' .. image end function getProjectTechIconLink(tech) - return 'assets/images/' .. tech .. '.png' + return 'https://cdn.duckonaut.dev/assets/images/' .. tech .. '.png' end function getProjectTargetIconLink(target) - return 'assets/images/' .. target .. '.png' + return 'https://cdn.duckonaut.dev/assets/images/' .. target .. '.png' end return function (props) diff --git a/luax/libs/markdown.lua b/luax/libs/markdown.lua new file mode 100644 index 0000000..ab42529 --- /dev/null +++ b/luax/libs/markdown.lua @@ -0,0 +1,1281 @@ +#!/usr/bin/env lua + +---------------------------------------------------------------------- +-- Utility functions +---------------------------------------------------------------------- + +local unpack = table.unpack or unpack + +-- Returns the result of mapping the values in table t through the function f +local function map(t, f) + local out = {} + for k,v in pairs(t) do out[k] = f(v,k) end + return out +end + +-- Functional style if statement. (NOTE: no short circuit evaluation) +local function iff(t, a, b) if t then return a else return b end end + +-- Splits the text into an array of separate lines. +local function split(text, sep) + sep = sep or "\n" + local lines = {} + local pos = 1 + while true do + local b,e = text:find(sep, pos) + if not b then table.insert(lines, text:sub(pos)) break end + table.insert(lines, text:sub(pos, b-1)) + pos = e + 1 + end + return lines +end + +-- Converts tabs to spaces +local function detab(text) + local tab_width = 4 + local function rep(match) + local spaces = -match:len() + while spaces<1 do spaces = spaces + tab_width end + return match .. string.rep(" ", spaces) + end + text = text:gsub("([^\n]-)\t", rep) + return text +end + +-- Applies string.find for every pattern in the list and returns the first match +local function find_first(s, patterns, index) + local res = {} + for _,p in ipairs(patterns) do + local match = {s:find(p, index)} + if #match>0 and (#res==0 or match[1] < res[1]) then res = match end + end + return unpack(res) +end + +-- If a replacement array is specified, the range [start, stop] in the array is replaced +-- with the replacement array and the resulting array is returned. Without a replacement +-- array the section of the array between start and stop is returned. +local function splice(array, start, stop, replacement) + if replacement then + local n = stop - start + 1 + while n > 0 do + table.remove(array, start) + n = n - 1 + end + for _,v in ipairs(replacement) do + table.insert(array, start, v) + end + return array + else + local res = {} + for i = start,stop do + table.insert(res, array[i]) + end + return res + end +end + +-- Outdents the text one step. +local function outdent(text) + text = "\n" .. text + text = text:gsub("\n ? ? ?", "\n") + text = text:sub(2) + return text +end + +-- Indents the text one step. +local function indent(text) + text = text:gsub("\n", "\n ") + return text +end + +-- Does a simple tokenization of html data. Returns the data as a list of tokens. +-- Each token is a table with a type field (which is either "tag" or "text") and +-- a text field (which contains the original token data). +local function tokenize_html(html) + local tokens = {} + local pos = 1 + while true do + local start = find_first(html, {"<!%-%-", "<[a-z/!$]", "<%?"}, pos) + if not start then + table.insert(tokens, {type="text", text=html:sub(pos)}) + break + end + if start ~= pos then table.insert(tokens, {type="text", text = html:sub(pos, start-1)}) end + + local _, stop + if html:match("^<!%-%-", start) then + _,stop = html:find("%-%->", start) + elseif html:match("^<%?", start) then + _,stop = html:find("?>", start) + else + _,stop = html:find("%b<>", start) + end + if not stop then + -- error("Could not match html tag " .. html:sub(start,start+30)) + table.insert(tokens, {type="text", text=html:sub(start, start)}) + pos = start + 1 + else + table.insert(tokens, {type="tag", text=html:sub(start, stop)}) + pos = stop + 1 + end + end + return tokens +end + +---------------------------------------------------------------------- +-- Hash +---------------------------------------------------------------------- + +-- This is used to "hash" data into alphanumeric strings that are unique +-- in the document. (Note that this is not cryptographic hash, the hash +-- function is not one-way.) The hash procedure is used to protect parts +-- of the document from further processing. + +local HASH = { + -- Has the hash been inited. + inited = false, + + -- The unique string prepended to all hash values. This is to ensure + -- that hash values do not accidently coincide with an actual existing + -- string in the document. + identifier = "", + + -- Counter that counts up for each new hash instance. + counter = 0, + + -- Hash table. + table = {} +} + +-- Inits hashing. Creates a hash_identifier that doesn't occur anywhere +-- in the text. +local function init_hash(text) + HASH.inited = true + HASH.identifier = "" + HASH.counter = 0 + HASH.table = {} + + local s = "HASH" + local counter = 0 + local id + while true do + id = s .. counter + if not text:find(id, 1, true) then break end + counter = counter + 1 + end + HASH.identifier = id +end + +-- Returns the hashed value for s. +local function hash(s) + assert(HASH.inited) + if not HASH.table[s] then + HASH.counter = HASH.counter + 1 + local id = HASH.identifier .. HASH.counter .. "X" + HASH.table[s] = id + end + return HASH.table[s] +end + +---------------------------------------------------------------------- +-- Protection +---------------------------------------------------------------------- + +-- The protection module is used to "protect" parts of a document +-- so that they are not modified by subsequent processing steps. +-- Protected parts are saved in a table for later unprotection + +-- Protection data +local PD = { + -- Saved blocks that have been converted + blocks = {}, + + -- Block level tags that will be protected + tags = {"p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", + "pre", "table", "dl", "ol", "ul", "script", "noscript", "form", "fieldset", + "iframe", "math", "ins", "del"} +} + +-- Pattern for matching a block tag that begins and ends in the leftmost +-- column and may contain indented subtags, i.e. +-- <div> +-- A nested block. +-- <div> +-- Nested data. +-- </div> +-- </div> +local function block_pattern(tag) + return "\n<" .. tag .. ".-\n</" .. tag .. ">[ \t]*\n" +end + +-- Pattern for matching a block tag that begins and ends with a newline +local function line_pattern(tag) + return "\n<" .. tag .. ".-</" .. tag .. ">[ \t]*\n" +end + +-- Protects the range of characters from start to stop in the text and +-- returns the protected string. +local function protect_range(text, start, stop) + local s = text:sub(start, stop) + local h = hash(s) + PD.blocks[h] = s + text = text:sub(1,start) .. h .. text:sub(stop) + return text +end + +-- Protect every part of the text that matches any of the patterns. The first +-- matching pattern is protected first, etc. +local function protect_matches(text, patterns) + while true do + local start, stop = find_first(text, patterns) + if not start then break end + text = protect_range(text, start, stop) + end + return text +end + +-- Protects blocklevel tags in the specified text +local function protect(text) + -- First protect potentially nested block tags + text = protect_matches(text, map(PD.tags, block_pattern)) + -- Then protect block tags at the line level. + text = protect_matches(text, map(PD.tags, line_pattern)) + -- Protect <hr> and comment tags + text = protect_matches(text, {"\n<hr[^>]->[ \t]*\n"}) + text = protect_matches(text, {"\n<!%-%-.-%-%->[ \t]*\n"}) + return text +end + +-- Returns true if the string s is a hash resulting from protection +local function is_protected(s) + return PD.blocks[s] +end + +-- Unprotects the specified text by expanding all the nonces +local function unprotect(text) + for k,v in pairs(PD.blocks) do + v = v:gsub("%%", "%%%%") + text = text:gsub(k, v) + end + return text +end + + +local code_block_ranges = {} + +---------------------------------------------------------------------- +-- Block transform +---------------------------------------------------------------------- + +-- The block transform functions transform the text on the block level. +-- They work with the text as an array of lines rather than as individual +-- characters. + +-- Returns true if the line is a ruler of (char) characters. +-- The line must contain at least three char characters and contain only spaces and +-- char characters. +local function is_ruler_of(line, char) + if not line:match("^[ %" .. char .. "]*$") then return false end + if not line:match("%" .. char .. ".*%" .. char .. ".*%" .. char) then return false end + return true +end + +-- Identifies the block level formatting present in the line +local function classify(line) + local info = {line = line, text = line} + + if line:match("^ ") then + info.type = "indented" + info.outdented = line:sub(5) + return info + end + + for _,c in ipairs({'*', '-', '_', '='}) do + if is_ruler_of(line, c) then + info.type = "ruler" + info.ruler_char = c + return info + end + end + + if line == "" then + info.type = "blank" + return info + end + + if line:match("^(#+)[ \t]*(.-)[ \t]*#*[ \t]*$") then + local m1, m2 = line:match("^(#+)[ \t]*(.-)[ \t]*#*[ \t]*$") + info.type = "header" + info.level = m1:len() + info.text = m2 + return info + end + + if line:match("^ ? ? ?(%d+)%.[ \t]+(.+)") then + local number, text = line:match("^ ? ? ?(%d+)%.[ \t]+(.+)") + info.type = "list_item" + info.list_type = "numeric" + info.number = 0 + number + info.text = text + return info + end + + if line:match("^ ? ? ?([%*%+%-])[ \t]+(.+)") then + local bullet, text = line:match("^ ? ? ?([%*%+%-])[ \t]+(.+)") + info.type = "list_item" + info.list_type = "bullet" + info.bullet = bullet + info.text= text + return info + end + + if line:match("^>[ \t]?(.*)") then + info.type = "blockquote" + info.text = line:match("^>[ \t]?(.*)") + return info + end + + if is_protected(line) then + info.type = "raw" + info.html = unprotect(line) + return info + end + + info.type = "normal" + return info +end + +-- Find headers constisting of a normal line followed by a ruler and converts them to +-- header entries. +local function headers(array) + local i = 1 + while i <= #array - 1 do + if array[i].type == "normal" and array[i+1].type == "ruler" and + (array[i+1].ruler_char == "-" or array[i+1].ruler_char == "=") then + local info = {line = array[i].line} + info.text = info.line + info.type = "header" + info.level = iff(array[i+1].ruler_char == "=", 1, 2) + table.remove(array, i+1) + array[i] = info + end + i = i + 1 + end + return array +end + +-- Forward declarations +local block_transform, span_transform, encode_code + +-- Convert lines to html code +local function blocks_to_html(lines, no_paragraphs) + local out = {} + local i = 1 + while i <= #lines do + local line = lines[i] + if line.type == "ruler" then + table.insert(out, "<hr/>") + elseif line.type == "raw" then + table.insert(out, line.html) + elseif line.type == "normal" then + local s = line.line + + while i+1 <= #lines and lines[i+1].type == "normal" do + i = i + 1 + s = s .. "\n" .. lines[i].line + end + + if no_paragraphs then + table.insert(out, span_transform(s)) + else + table.insert(out, "<p>" .. span_transform(s) .. "</p>") + end + elseif line.type == "header" then + local s = "<h" .. line.level .. ">" .. span_transform(line.text) .. "</h" .. line.level .. ">" + table.insert(out, s) + else + table.insert(out, line.line) + end + i = i + 1 + end + return out +end + +-- Find list blocks and convert them to protected data blocks +local function lists(array, sublist) + local function process_list(arr) + local function any_blanks(arr) + for i = 1, #arr do + if arr[i].type == "blank" then return true end + end + return false + end + + local function split_list_items(arr) + local acc = {arr[1]} + local res = {} + for i=2,#arr do + if arr[i].type == "list_item" then + table.insert(res, acc) + acc = {arr[i]} + else + table.insert(acc, arr[i]) + end + end + table.insert(res, acc) + return res + end + + local function process_list_item(lines, block) + while lines[#lines].type == "blank" do + table.remove(lines) + end + + local itemtext = lines[1].text + for i=2,#lines do + itemtext = itemtext .. "\n" .. outdent(lines[i].line) + end + if block then + itemtext = block_transform(itemtext, true) + if not itemtext:find("<pre>") then itemtext = indent(itemtext) end + return " <li>" .. itemtext .. "</li>" + else + local lines = split(itemtext) + lines = map(lines, classify) + lines = lists(lines, true) + lines = blocks_to_html(lines, true) + itemtext = table.concat(lines, "\n") + if not itemtext:find("<pre>") then itemtext = indent(itemtext) end + return " <li>" .. itemtext .. "</li>" + end + end + + local block_list = any_blanks(arr) + local items = split_list_items(arr) + local out = "" + for _, item in ipairs(items) do + out = out .. process_list_item(item, block_list) .. "\n" + end + if arr[1].list_type == "numeric" then + return "<ol>\n" .. out .. "</ol>" + else + return "<ul>\n" .. out .. "</ul>" + end + end + + -- Finds the range of lines composing the first list in the array. A list + -- starts with (^ list_item) or (blank list_item) and ends with + -- (blank* $) or (blank normal). + -- + -- A sublist can start with just (list_item) does not need a blank... + local function find_list(array, sublist) + local function find_list_start(array, sublist) + if array[1].type == "list_item" then return 1 end + if sublist then + for i = 1,#array do + if array[i].type == "list_item" then return i end + end + else + for i = 1, #array-1 do + if array[i].type == "blank" and array[i+1].type == "list_item" then + return i+1 + end + end + end + return nil + end + local function find_list_end(array, start) + local pos = #array + for i = start, #array-1 do + if array[i].type == "blank" and array[i+1].type ~= "list_item" + and array[i+1].type ~= "indented" and array[i+1].type ~= "blank" then + pos = i-1 + break + end + end + while pos > start and array[pos].type == "blank" do + pos = pos - 1 + end + return pos + end + + local start = find_list_start(array, sublist) + if not start then return nil end + return start, find_list_end(array, start) + end + + while true do + local start, stop = find_list(array, sublist) + if not start then break end + local text = process_list(splice(array, start, stop)) + local info = { + line = text, + type = "raw", + html = text + } + array = splice(array, start, stop, {info}) + end + + -- Convert any remaining list items to normal + for _,line in ipairs(array) do + if line.type == "list_item" then line.type = "normal" end + end + + return array +end + +-- Find and convert blockquote markers. +local function blockquotes(lines) + local function find_blockquote(lines) + local start + for i,line in ipairs(lines) do + if line.type == "blockquote" then + start = i + break + end + end + if not start then return nil end + + local stop = #lines + for i = start+1, #lines do + if lines[i].type == "blank" or lines[i].type == "blockquote" then + elseif lines[i].type == "normal" then + if lines[i-1].type == "blank" then stop = i-1 break end + else + stop = i-1 break + end + end + while lines[stop].type == "blank" do stop = stop - 1 end + return start, stop + end + + local function process_blockquote(lines) + local raw = lines[1].text + for i = 2,#lines do + raw = raw .. "\n" .. lines[i].text + end + local bt = block_transform(raw) + if not bt:find("<pre>") then bt = indent(bt) end + return "<blockquote>\n " .. bt .. + "\n</blockquote>" + end + + while true do + local start, stop = find_blockquote(lines) + if not start then break end + local text = process_blockquote(splice(lines, start, stop)) + local info = { + line = text, + type = "raw", + html = text + } + lines = splice(lines, start, stop, {info}) + end + return lines +end + +-- Find and convert codeblocks. +local function codeblocks(lines) + local function find_codeblock(lines) + local start + for i,line in ipairs(lines) do + if line.type == "indented" then start = i break end + end + if not start then return nil end + + local stop = #lines + for i = start+1, #lines do + if lines[i].type ~= "indented" and lines[i].type ~= "blank" then + stop = i-1 + break + end + end + while lines[stop].type == "blank" do stop = stop - 1 end + return start, stop + end + + local function process_codeblock(lines) + local raw = detab(encode_code(outdent(lines[1].line))) + for i = 2,#lines do + raw = raw .. "\n" .. detab(encode_code(outdent(lines[i].line))) + end + return "<pre><code>" .. raw .. "\n</code></pre>" + end + + while true do + local start, stop = find_codeblock(lines) + if not start then break end + local text = process_codeblock(splice(lines, start, stop)) + local info = { + line = text, + type = "raw", + html = text + } + lines = splice(lines, start, stop, {info}) + end + return lines +end + +-- Perform all the block level transforms +function block_transform(text, sublist) + local lines = split(text) + lines = map(lines, classify) + lines = headers(lines) + lines = lists(lines, sublist) + lines = codeblocks(lines) + lines = blockquotes(lines) + lines = blocks_to_html(lines) + local text = table.concat(lines, "\n") + return text +end + +---------------------------------------------------------------------- +-- Span transform +---------------------------------------------------------------------- + +-- Functions for transforming the text at the span level. + +-- These characters may need to be escaped because they have a special +-- meaning in markdown. +local escape_chars = "'\\`*_{}[]()>#+-.!'" +local escape_table = {} + +local function init_escape_table() + escape_table = {} + for i = 1,#escape_chars do + local c = escape_chars:sub(i,i) + escape_table[c] = hash(c) + end +end + +-- Adds a new escape to the escape table. +local function add_escape(text) + if not escape_table[text] then + escape_table[text] = hash(text) + end + return escape_table[text] +end + +-- Encode backspace-escaped characters in the markdown source. +local function encode_backslash_escapes(t) + for i=1,escape_chars:len() do + local c = escape_chars:sub(i,i) + t = t:gsub("\\%" .. c, escape_table[c]) + end + return t +end + +-- Escape characters that should not be disturbed by markdown. +local function escape_special_chars(text) + local tokens = tokenize_html(text) + + local out = "" + for _, token in ipairs(tokens) do + local t = token.text + if token.type == "tag" then + -- In tags, encode * and _ so they don't conflict with their use in markdown. + t = t:gsub("%*", escape_table["*"]) + t = t:gsub("%_", escape_table["_"]) + else + t = encode_backslash_escapes(t) + end + out = out .. t + end + return out +end + +-- Unescape characters that have been encoded. +local function unescape_special_chars(t) + local tin = t + for k,v in pairs(escape_table) do + k = k:gsub("%%", "%%%%") + t = t:gsub(v,k) + end + if t ~= tin then t = unescape_special_chars(t) end + return t +end + +-- Encode/escape certain characters inside Markdown code runs. +-- The point is that in code, these characters are literals, +-- and lose their special Markdown meanings. +function encode_code(s) + s = s:gsub("%&", "&") + s = s:gsub("<", "<") + s = s:gsub(">", ">") + for k,v in pairs(escape_table) do + s = s:gsub("%"..k, v) + end + return s +end + +-- Handle backtick blocks. +local function code_spans(s) + s = s:gsub("\\\\", escape_table["\\"]) + s = s:gsub("\\`", escape_table["`"]) + + local pos = 1 + while true do + local start, stop = s:find("`+", pos) + if not start then return s end + local count = stop - start + 1 + -- Find a matching numbert of backticks + local estart, estop = s:find(string.rep("`", count), stop+1) + local brstart = s:find("\n", stop+1) + if estart and (not brstart or estart < brstart) then + local code = s:sub(stop+1, estart-1) + code = code:gsub("^[ \t]+", "") + code = code:gsub("[ \t]+$", "") + code = code:gsub(escape_table["\\"], escape_table["\\"] .. escape_table["\\"]) + code = code:gsub(escape_table["`"], escape_table["\\"] .. escape_table["`"]) + code = "<code>" .. encode_code(code) .. "</code>" + code = add_escape(code) + s = s:sub(1, start-1) .. code .. s:sub(estop+1) + pos = start + code:len() + else + pos = stop + 1 + end + end + return s +end + +-- Encode alt text... enodes &, and ". +local function encode_alt(s) + if not s then return s end + s = s:gsub('&', '&') + s = s:gsub('"', '"') + s = s:gsub('<', '<') + return s +end + +-- Forward declaration for link_db as returned by strip_link_definitions. +local link_database + +-- Handle image references +local function images(text) + local function reference_link(alt, id) + alt = encode_alt(alt:match("%b[]"):sub(2,-2)) + id = id:match("%[(.*)%]"):lower() + if id == "" then id = text:lower() end + link_database[id] = link_database[id] or {} + if not link_database[id].url then return nil end + local url = link_database[id].url or id + url = encode_alt(url) + local title = encode_alt(link_database[id].title) + if title then title = " title=\"" .. title .. "\"" else title = "" end + return add_escape ('<img src="' .. url .. '" alt="' .. alt .. '"' .. title .. "/>") + end + + local function inline_link(alt, link) + alt = encode_alt(alt:match("%b[]"):sub(2,-2)) + local url, title = link:match("%(<?(.-)>?[ \t]*['\"](.+)['\"]") + url = url or link:match("%(<?(.-)>?%)") + url = encode_alt(url) + title = encode_alt(title) + if title then + return add_escape('<img src="' .. url .. '" alt="' .. alt .. '" title="' .. title .. '"/>') + else + return add_escape('<img src="' .. url .. '" alt="' .. alt .. '"/>') + end + end + + text = text:gsub("!(%b[])[ \t]*\n?[ \t]*(%b[])", reference_link) + text = text:gsub("!(%b[])(%b())", inline_link) + return text +end + +-- Handle anchor references +local function anchors(text) + local function reference_link(text, id) + text = text:match("%b[]"):sub(2,-2) + id = id:match("%b[]"):sub(2,-2):lower() + if id == "" then id = text:lower() end + link_database[id] = link_database[id] or {} + if not link_database[id].url then return nil end + local url = link_database[id].url or id + url = encode_alt(url) + local title = encode_alt(link_database[id].title) + if title then title = " title=\"" .. title .. "\"" else title = "" end + return add_escape("<a href=\"" .. url .. "\"" .. title .. ">") .. text .. add_escape("</a>") + end + + local function inline_link(text, link) + text = text:match("%b[]"):sub(2,-2) + local url, title = link:match("%(<?(.-)>?[ \t]*['\"](.+)['\"]") + title = encode_alt(title) + url = url or link:match("%(<?(.-)>?%)") or "" + url = encode_alt(url) + if title then + return add_escape("<a href=\"" .. url .. "\" title=\"" .. title .. "\">") .. text .. "</a>" + else + return add_escape("<a href=\"" .. url .. "\">") .. text .. add_escape("</a>") + end + end + + text = text:gsub("(%b[])[ \t]*\n?[ \t]*(%b[])", reference_link) + text = text:gsub("(%b[])(%b())", inline_link) + return text +end + +-- Handle auto links, i.e. <http://www.google.com/>. +local function auto_links(text) + local function link(s) + return add_escape("<a href=\"" .. s .. "\">") .. s .. "</a>" + end + -- Encode chars as a mix of dec and hex entitites to (perhaps) fool + -- spambots. + local function encode_email_address(s) + -- Use a deterministic encoding to make unit testing possible. + -- Code 45% hex, 45% dec, 10% plain. + local hex = {code = function(c) return "&#x" .. string.format("%x", c:byte()) .. ";" end, count = 1, rate = 0.45} + local dec = {code = function(c) return "&#" .. c:byte() .. ";" end, count = 0, rate = 0.45} + local plain = {code = function(c) return c end, count = 0, rate = 0.1} + local codes = {hex, dec, plain} + local function swap(t,k1,k2) local temp = t[k2] t[k2] = t[k1] t[k1] = temp end + + local out = "" + for i = 1,s:len() do + for _,code in ipairs(codes) do code.count = code.count + code.rate end + if codes[1].count < codes[2].count then swap(codes,1,2) end + if codes[2].count < codes[3].count then swap(codes,2,3) end + if codes[1].count < codes[2].count then swap(codes,1,2) end + + local code = codes[1] + local c = s:sub(i,i) + -- Force encoding of "@" to make email address more invisible. + if c == "@" and code == plain then code = codes[2] end + out = out .. code.code(c) + code.count = code.count - 1 + end + return out + end + local function mail(s) + s = unescape_special_chars(s) + local address = encode_email_address("mailto:" .. s) + local text = encode_email_address(s) + return add_escape("<a href=\"" .. address .. "\">") .. text .. "</a>" + end + -- links + text = text:gsub("<(https?:[^'\">%s]+)>", link) + text = text:gsub("<(ftp:[^'\">%s]+)>", link) + + -- mail + text = text:gsub("<mailto:([^'\">%s]+)>", mail) + text = text:gsub("<([-.%w]+%@[-.%w]+)>", mail) + return text +end + +-- Encode free standing amps (&) and angles (<)... note that this does not +-- encode free >. +local function amps_and_angles(s) + -- encode amps not part of &..; expression + local pos = 1 + while true do + local amp = s:find("&", pos) + if not amp then break end + local semi = s:find(";", amp+1) + local stop = s:find("[ \t\n&]", amp+1) + if not semi or (stop and stop < semi) or (semi - amp) > 15 then + s = s:sub(1,amp-1) .. "&" .. s:sub(amp+1) + pos = amp+1 + else + pos = amp+1 + end + end + + -- encode naked <'s + s = s:gsub("<([^a-zA-Z/?$!])", "<%1") + s = s:gsub("<$", "<") + + -- what about >, nothing done in the original markdown source to handle them + return s +end + +-- Handles emphasis markers (* and _) in the text. +local function emphasis(text) + for _, s in ipairs {"%*%*", "%_%_"} do + text = text:gsub(s .. "([^%s][%*%_]?)" .. s, "<strong>%1</strong>") + text = text:gsub(s .. "([^%s][^<>]-[^%s][%*%_]?)" .. s, "<strong>%1</strong>") + end + for _, s in ipairs {"%*", "%_"} do + text = text:gsub(s .. "([^%s_])" .. s, "<em>%1</em>") + text = text:gsub(s .. "(<strong>[^%s_]</strong>)" .. s, "<em>%1</em>") + text = text:gsub(s .. "([^%s_][^<>_]-[^%s_])" .. s, "<em>%1</em>") + text = text:gsub(s .. "([^<>_]-<strong>[^<>_]-</strong>[^<>_]-)" .. s, "<em>%1</em>") + end + return text +end + +-- Handles line break markers in the text. +local function line_breaks(text) + return text:gsub(" +\n", " <br/>\n") +end + +-- Perform all span level transforms. +function span_transform(text) + text = code_spans(text) + text = escape_special_chars(text) + text = images(text) + text = anchors(text) + text = auto_links(text) + text = amps_and_angles(text) + text = emphasis(text) + text = line_breaks(text) + return text +end + +---------------------------------------------------------------------- +-- Markdown +---------------------------------------------------------------------- + +-- Cleanup the text by normalizing some possible variations to make further +-- processing easier. +local function cleanup(text) + -- Standardize line endings + text = text:gsub("\r\n", "\n") -- DOS to UNIX + text = text:gsub("\r", "\n") -- Mac to UNIX + + -- Convert all tabs to spaces + text = detab(text) + + -- Strip lines with only spaces and tabs + while true do + local subs + text, subs = text:gsub("\n[ \t]+\n", "\n\n") + if subs == 0 then break end + end + + return "\n" .. text .. "\n" +end + +-- Strips link definitions from the text and stores the data in a lookup table. +local function strip_link_definitions(text) + local linkdb = {} + + local function link_def(id, url, title) + id = id:match("%[(.+)%]"):lower() + linkdb[id] = linkdb[id] or {} + linkdb[id].url = url or linkdb[id].url + linkdb[id].title = title or linkdb[id].title + return "" + end + + local def_no_title = "\n ? ? ?(%b[]):[ \t]*\n?[ \t]*<?([^%s>]+)>?[ \t]*" + local def_title1 = def_no_title .. "[ \t]+\n?[ \t]*[\"'(]([^\n]+)[\"')][ \t]*" + local def_title2 = def_no_title .. "[ \t]*\n[ \t]*[\"'(]([^\n]+)[\"')][ \t]*" + local def_title3 = def_no_title .. "[ \t]*\n?[ \t]+[\"'(]([^\n]+)[\"')][ \t]*" + + text = text:gsub(def_title1, link_def) + text = text:gsub(def_title2, link_def) + text = text:gsub(def_title3, link_def) + text = text:gsub(def_no_title, link_def) + return text, linkdb +end + +local lang_to_ext = { + rust = "rs", + lua = "lua", + python = "py", + javascript = "js", + luax = "luax", + cpp = "cpp", + c = "c", + java = "java", + go = "go", + html = "html", + css = "css", + bash = "bash", + sh = "sh", + json = "json", + toml = "toml", + c_sharp = "cs", +} + +local function highlight(lang, code) + -- create tmp file with code + local tmp_filename = os.tmpname() + local ext = lang_to_ext[lang] + if not ext then + ext = lang + end + -- add matching extension to tmp_filename (just lang for now) + tmp_filename = tmp_filename .. "." .. ext + local tmp_file = io.open(tmp_filename, "w") + if not tmp_file then + error("Could not open tmp file: " .. tmp_filename) + end + tmp_file:write(code) + tmp_file:close() + -- run tree-sitter highlight on the code with lang + local tree_sitter_output = io.popen("tree-sitter highlight -H " .. tmp_filename) + if not tree_sitter_output then + error("Could not run tree-sitter highlight") + end + + return tree_sitter_output:read("*a") +end + +local function code_block_highlight(text) + -- find ``` blocks + local pos = 1 + while true do + local start, stop = text:find("```", pos) + if not start then break end + local endstart, endstop = text:find("```", stop+1) + if not endstart then break end + + local block = text:sub(stop+2, endstart-1) + -- treat first line after ``` as language + local lang = block:match("^(.-)\n") + -- remove first line from block + block = block:gsub("^[^\n]*\n", "") + -- replace block with highlighted block + block = '<div class="code-block">' .. highlight(lang, block) .. '</div>' + text = text:sub(1, start-1) .. block .. text:sub(endstop+1) + code_block_ranges[#code_block_ranges+1] = {start, start+block:len()} + pos = start + block:len() + end + return text +end + +-- Main markdown processing function +local function markdown(text) + init_hash(text) + init_escape_table() + + text = cleanup(text) + text = protect(text) + text = code_block_highlight(text) + text, link_database = strip_link_definitions(text) + text = block_transform(text) + text = unescape_special_chars(text) + return text +end + +---------------------------------------------------------------------- +-- End of module +---------------------------------------------------------------------- + +-- For compatibility, set markdown function as a global +_G.markdown = markdown + +-- Class for parsing command-line options +local OptionParser = {} +OptionParser.__index = OptionParser + +-- Creates a new option parser +function OptionParser:new() + local o = {short = {}, long = {}} + setmetatable(o, self) + return o +end + +-- Calls f() whenever a flag with specified short and long name is encountered +function OptionParser:flag(short, long, f) + local info = {type = "flag", f = f} + if short then self.short[short] = info end + if long then self.long[long] = info end +end + +-- Calls f(param) whenever a parameter flag with specified short and long name is encountered +function OptionParser:param(short, long, f) + local info = {type = "param", f = f} + if short then self.short[short] = info end + if long then self.long[long] = info end +end + +-- Calls f(v) for each non-flag argument +function OptionParser:arg(f) + self.arg = f +end + +-- Runs the option parser for the specified set of arguments. Returns true if all arguments +-- where successfully parsed and false otherwise. +function OptionParser:run(args) + local pos = 1 + while pos <= #args do + local arg = args[pos] + if arg == "--" then + for i=pos+1,#args do + if self.arg then self.arg(args[i]) end + return true + end + end + if arg:match("^%-%-") then + local info = self.long[arg:sub(3)] + if not info then print("Unknown flag: " .. arg) return false end + if info.type == "flag" then + info.f() + pos = pos + 1 + else + local param = args[pos+1] + if not param then print("No parameter for flag: " .. arg) return false end + info.f(param) + pos = pos+2 + end + elseif arg:match("^%-") then + for i=2,arg:len() do + local c = arg:sub(i,i) + local info = self.short[c] + if not info then print("Unknown flag: -" .. c) return false end + if info.type == "flag" then + info.f() + else + if i == arg:len() then + local param = args[pos+1] + if not param then print("No parameter for flag: -" .. c) return false end + info.f(param) + pos = pos + 1 + else + local param = arg:sub(i+1) + info.f(param) + end + break + end + end + pos = pos + 1 + else + if self.arg then self.arg(arg) end + pos = pos + 1 + end + end + return true +end + +local function read_file(path, descr) + local file = io.open(path) or error("Could not open " .. descr .. " file: " .. path) + local contents = file:read("*a") or error("Could not read " .. descr .. " from " .. path) + file:close() + return contents +end + +-- Handles the case when markdown is run from the command line +local function run_command_line(arg) + -- Generate output for input s given options + local function run(s, options) + s = markdown(s) + if not options.wrap_header then return s end + local header + if options.header then + header = read_file(options.header, "header") + else + header = [[ +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> +<html> +<head> + <meta http-equiv="content-type" content="text/html; charset=CHARSET" /> + <title>TITLE</title> + <link rel="stylesheet" type="text/css" href="STYLESHEET" /> +</head> +<body> +]] + local title = options.title or s:match("<h1>(.-)</h1>") or s:match("<h2>(.-)</h2>") or + s:match("<h3>(.-)</h3>") or "Untitled" + header = header:gsub("TITLE", title) + if options.inline_style then + local style = read_file(options.stylesheet, "style sheet") + header = header:gsub('<link rel="stylesheet" type="text/css" href="STYLESHEET" />', + "<style type=\"text/css\"><!--\n" .. style .. "\n--></style>") + else + header = header:gsub("STYLESHEET", options.stylesheet) + end + header = header:gsub("CHARSET", options.charset) + end + local footer = "</body></html>" + if options.footer then + footer = read_file(options.footer, "footer") + end + return header .. s .. footer + end + + -- Generate output path name from input path name given options. + local function outpath(path, options) + if options.append then return path .. ".html" end + local m = path:match("^(.+%.html)[^/\\]+$") if m then return m end + m = path:match("^(.+%.)[^/\\]*$") if m and path ~= m .. "html" then return m .. "html" end + return path .. ".html" + end + + -- Default commandline options + local options = { + wrap_header = true, + header = nil, + footer = nil, + charset = "utf-8", + title = nil, + stylesheet = "default.css", + inline_style = false + } + local help = [[ +Usage: markdown.lua [OPTION] [FILE] +Runs the markdown text markup to HTML converter on each file specified on the +command line. If no files are specified, runs on standard input. + +No header: + -n, --no-wrap Don't wrap the output in <html>... tags. +Custom header: + -e, --header FILE Use content of FILE for header. + -f, --footer FILE Use content of FILE for footer. +Generated header: + -c, --charset SET Specifies charset (default utf-8). + -i, --title TITLE Specifies title (default from first <h1> tag). + -s, --style STYLE Specifies style sheet file (default default.css). + -l, --inline-style Include the style sheet file inline in the header. +Generated files: + -a, --append Append .html extension (instead of replacing). +Other options: + -h, --help Print this help text. + -t, --test Run the unit tests. +]] + + local run_stdin = true + local op = OptionParser:new() + op:flag("n", "no-wrap", function () options.wrap_header = false end) + op:param("e", "header", function (x) options.header = x end) + op:param("f", "footer", function (x) options.footer = x end) + op:param("c", "charset", function (x) options.charset = x end) + op:param("i", "title", function(x) options.title = x end) + op:param("s", "style", function(x) options.stylesheet = x end) + op:flag("l", "inline-style", function() options.inline_style = true end) + op:flag("a", "append", function() options.append = true end) + op:flag("t", "test", function() + local n = arg[0]:gsub("markdown%.lua", "markdown-tests.lua") + local f = io.open(n) + if f then + f:close() + package.loaded.markdown = markdown + dofile(n) + else + error("Cannot find markdown-tests.lua") + end + run_stdin = false + end) + op:flag("h", "help", function() print(help) run_stdin = false end) + op:arg(function(path) + local s = read_file(path, "input") + s = run(s, options) + local file = io.open(outpath(path, options), "w") or error("Could not open output file: " .. outpath(path, options)) + file:write(s) + file:close() + run_stdin = false + end + ) + + if not op:run(arg) then + print(help) + run_stdin = false + end + + if run_stdin then + local s = io.read("*a") + s = run(s, options) + io.write(s) + end +end + +-- If we are being run from the command-line, act accordingly +if arg and arg[0]:find("markdown%.lua$") then + run_command_line(arg) +else + return markdown +end diff --git a/luax/pages/article.luax b/luax/pages/article.luax deleted file mode 100644 index 670002c..0000000 --- a/luax/pages/article.luax +++ /dev/null @@ -1,3 +0,0 @@ -return function(props) - return <h1>{$ props.attrs.title $}</h1> -end diff --git a/luax/pages/blog.luax b/luax/pages/blog.luax new file mode 100644 index 0000000..26f9fc0 --- /dev/null +++ b/luax/pages/blog.luax @@ -0,0 +1,28 @@ +local blogposts = require('blog.posts') + +return function() + local blogpost_html = {} + for _, post in ipairs(blogposts) do + blogpost_html[#blogpost_html + 1] = + <div class="blog-post"> + <div class="blog-post-title"> + {$ post.title $} + </div> + <div class="blog-post-date"> + {$ os.date('%Y-%m-%d', post.date) $} + </div> + <div class="blog-post-description"> + {$ post.description $} + </div> + </div> + end + return + <div class="main-info"> + <div class="main-info-title"> + Blog + </div> + <div class="blog-posts"> + {$ blogpost_html $} + </div> + </div> +end diff --git a/luax/pages/blogpost.luax b/luax/pages/blogpost.luax new file mode 100644 index 0000000..e7d7b86 --- /dev/null +++ b/luax/pages/blogpost.luax @@ -0,0 +1,23 @@ +local blog_cache = require('blog.cache') + +return function(props) + local post = props.attrs.post + local content = blog_cache(post) + return + <div> + <div class="post"> + <div class="main-info"> + <div class="main-info-title"> + {$ post.title $} + </div> + <div class="post-date"> + {$ os.date('%Y-%m-%d', post.date) $} + </div> + <div class="post-description"> + {$ post.description $} + </div> + <div class="post-content">{$ content $}</div> + </div> + </div> + </div> +end diff --git a/luax/pages/home.luax b/luax/pages/home.luax index 19f3f6d..b61ed4e 100644 --- a/luax/pages/home.luax +++ b/luax/pages/home.luax @@ -6,7 +6,7 @@ return function() <div class="main-info-subtitle"> aka Duckonaut, EchoDuck </div> - <img src="assets/images/favicon.svg" class="main-info-image logo grow" /> + <img src="https://cdn.duckonaut.dev/assets/images/favicon.svg" class="main-info-image logo grow" /> <div class="about-paragraph"> <p> diff --git a/luax/public/CNAME b/luax/public/CNAME new file mode 100644 index 0000000..a2fa953 --- /dev/null +++ b/luax/public/CNAME @@ -0,0 +1 @@ +cdn.duckonaut.dev diff --git a/luax/public/assets/app.css b/luax/public/assets/app.css index c390f73..c98e9b0 100644 --- a/luax/public/assets/app.css +++ b/luax/public/assets/app.css @@ -2,7 +2,7 @@ font-family: 'Hack', monospace; max-width: 1280px; margin: 0 auto; - padding: 2rem 2rem 2rem 2rem; + padding: 1rem 1rem 1rem 1rem; text-align: center; } @@ -134,7 +134,7 @@ @media (min-width: 1150px) { .back-shadows { margin: auto; - max-width: 40%; + max-width: 50%; } .main-info-title { font-size: 42pt; diff --git a/luax/public/assets/blog.css b/luax/public/assets/blog.css new file mode 100644 index 0000000..f69e32e --- /dev/null +++ b/luax/public/assets/blog.css @@ -0,0 +1,85 @@ +.code-block { + text-align: left; + background-color: #161f30; + border: 2px solid #2a3a4f; + border-radius: 8px; + padding: 2px; + margin: 8px; + font-family: Hack, monospace; + font-size: 10pt; + overflow-x: auto; +} + +code { + font-family: monospace; + font-size: 14pt; +} + +blockquote { + border-left: 4px solid #2a3a4f; + padding-left: 8px; + margin-left: 8px; + font-style: italic; +} + +td.line-number { + text-align: right; + padding-right: 5px; + color: #647089; +} + +.post-description { + text-align: justify; + font-style: italic; + color: #647089; + margin: 0 auto; + max-width: 800px; + padding: 0 10px; +} + +.post-date { + text-align: right; + font-style: italic; + margin: 0 auto; + max-width: 800px; + padding: 0 10px; +} + +.post-content { + text-align: justify; + margin: 0 auto; + max-width: 800px; +} + +.post-content img { + max-width: 100%; + height: auto; + border-radius: 8px; + border: 2px solid #2a3a4f; + margin: auto 0; +} + +.post-content p:has(img) { + text-align: center; +} + +@media (max-width: 480px) { + .post-content { + margin: auto; + max-width: 96%; + } +} + +@media (max-width: 1150px) and (min-width: 480px) { + .post-content { + margin: auto; + max-width: 80%; + } +} + +@media (min-width: 1150px) { + .post-content { + margin: auto; + max-width: 60%; + } +} diff --git a/luax/public/assets/blog/building-reluax/cli.png b/luax/public/assets/blog/building-reluax/cli.png Binary files differnew file mode 100644 index 0000000..097957f --- /dev/null +++ b/luax/public/assets/blog/building-reluax/cli.png diff --git a/luax/public/assets/blog/building-reluax/first-demo.png b/luax/public/assets/blog/building-reluax/first-demo.png Binary files differnew file mode 100644 index 0000000..81c212c --- /dev/null +++ b/luax/public/assets/blog/building-reluax/first-demo.png diff --git a/luax/public/assets/blog/building-reluax/second-demo.png b/luax/public/assets/blog/building-reluax/second-demo.png Binary files differnew file mode 100644 index 0000000..52e9a10 --- /dev/null +++ b/luax/public/assets/blog/building-reluax/second-demo.png diff --git a/luax/public/assets/index.js b/luax/public/assets/index.js index 51716de..6dd6ab6 100644 --- a/luax/public/assets/index.js +++ b/luax/public/assets/index.js @@ -74,7 +74,7 @@ void main() { sandbox.load(shader_code); - sandbox.setUniform('t', "/assets/images/symbols.png"); + sandbox.setUniform('t', "https://cdn.duckonaut.dev/assets/images/symbols.png"); sandbox.setUniform('color', 42 / 255, 42 / 255, 80 / 255); diff --git a/luax/reluax.luax b/luax/reluax.luax index ebe1f21..b078ce8 100644 --- a/luax/reluax.luax +++ b/luax/reluax.luax @@ -6,9 +6,12 @@ local Home = require("pages.home") local Projects = require("pages.projects") local Games = require("pages.games") local Contact = require("pages.contact") -local Article = require("pages.article") +local Blogpost = require("pages.blogpost") +local Blog = require("pages.blog") local NotFound = require("not_found") +local blogposts = require("blog.posts") + math.randomseed(os.time()) local function wrap_component(component) @@ -32,17 +35,29 @@ local function route(path) return 200, wrap_component(<Games />) elseif path == '/contact' then return 200, wrap_component(<Contact />) - elseif reluax.url_matches('/article/{title}', path) then - local title = reluax.url_extract('/article/{title}', path).title - local article = <Common><Article title={title} /></Common> + elseif path == '/blog' then + return 200, wrap_component(<Blog />) + elseif reluax.url_matches('/blog/{slug}', path) then + local slug = reluax.url_extract('/blog/{slug}', path).slug + -- blogposts is a list of blogposts, with the slug a property of each + local post = nil + for _, p in ipairs(blogposts) do + if p.slug == slug then + post = p + break + end + end + if post then + local article = wrap_component(<Blogpost post={post} />) return 200, article + end end return 404, NotFound() end return { - name = "hello-world", + name = "duckonaut.dev", route = route } |
