├── scripts └── minimal_init.vim ├── tests ├── files │ ├── page.html │ ├── index.dj │ ├── template.html │ └── dummy.png ├── string.lua ├── djotter.lua ├── path.lua ├── request.lua └── server.lua ├── .luacheckrc ├── lua └── web-server │ ├── common.lua │ ├── path.lua │ ├── djotter.lua │ ├── djot │ ├── json.lua │ ├── init.lua │ ├── filter.lua │ ├── attributes.lua │ ├── html.lua │ ├── inline.lua │ ├── block.lua │ └── ast.lua │ └── init.lua ├── Makefile ├── .github └── workflows │ └── ci.yml ├── COPYING.txt └── README.md /scripts/minimal_init.vim: -------------------------------------------------------------------------------- 1 | set rtp+=. 2 | -------------------------------------------------------------------------------- /tests/files/page.html: -------------------------------------------------------------------------------- 1 |

page

2 |

content

3 | -------------------------------------------------------------------------------- /tests/files/index.dj: -------------------------------------------------------------------------------- 1 | # foo 2 | 3 | ![bar](./dummy.png) 4 | 5 | -------------------------------------------------------------------------------- /.luacheckrc: -------------------------------------------------------------------------------- 1 | std = luajit 2 | codes = true 3 | read_globals = {"vim"} 4 | -------------------------------------------------------------------------------- /tests/files/template.html: -------------------------------------------------------------------------------- 1 | {{ title }} 2 | {{ content }} 3 | -------------------------------------------------------------------------------- /tests/files/dummy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gn0/nvim-web-server/HEAD/tests/files/dummy.png -------------------------------------------------------------------------------- /lua/web-server/common.lua: -------------------------------------------------------------------------------- 1 | 2 | local M = {} 3 | 4 | function M.cmd_error(...) 5 | vim.api.nvim_echo({{ string.format(...) }}, true, { err = true }) 6 | end 7 | 8 | return M 9 | -------------------------------------------------------------------------------- /tests/string.lua: -------------------------------------------------------------------------------- 1 | local M = require("web-server") 2 | local escape = M.internal.escape 3 | local truncate = M.internal.truncate 4 | 5 | assert(escape("") == "") 6 | assert(escape(" ") == " ") 7 | assert(escape("\n") == "\\n") 8 | assert(escape("\r\n") == "\\r\\n") 9 | assert(escape("foo bar") == "foo bar") 10 | assert(escape("foo\nbar") == "foo\\nbar") 11 | assert(escape("foo\r\nbar") == "foo\\r\\nbar") 12 | 13 | assert(truncate("foo", 4) == "foo") 14 | assert(truncate("foo", 3) == "foo") 15 | assert(truncate("foo", 2) == "fo...") 16 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | MODULES = init djotter path 2 | TESTS = djotter path request server string 3 | 4 | .PHONY: build lint luadoc test 5 | 6 | build: lint test luadoc 7 | 8 | lint: 9 | $(foreach x,$(MODULES), \ 10 | luacheck lua/web-server/$(x).lua &&) true 11 | 12 | test: 13 | $(foreach x,$(TESTS), \ 14 | nvim --headless --clean --noplugin -u scripts/minimal_init.vim \ 15 | -l tests/$(x).lua &&) true 16 | 17 | luadoc: $(foreach x,init djotter path,luadoc/$(x).html) 18 | 19 | luadoc/%.html: lua/web-server/%.lua 20 | [ -d luadoc ] || mkdir luadoc 21 | ldoc \ 22 | --all \ 23 | --verbose \ 24 | --dir luadoc \ 25 | --output $(basename $(notdir $@)) \ 26 | $< 27 | 28 | .PHONY: clean 29 | clean: 30 | -rm -rf luadoc/ 31 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | lint_and_tests: 11 | name: lint and unit tests 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | rev: [nightly, stable, v0.10.0] 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - uses: rhysd/action-setup-vim@v1 22 | with: 23 | neovim: true 24 | version: ${{ matrix.rev }} 25 | 26 | - name: Install luacheck 27 | run: sudo apt-get update && sudo apt-get install -y lua-check 28 | 29 | - name: Run lint 30 | run: make lint 31 | 32 | - name: Run tests 33 | run: | 34 | nvim --version 35 | make test 36 | -------------------------------------------------------------------------------- /COPYING.txt: -------------------------------------------------------------------------------- 1 | Copyright (C) 2025 Gábor Nyéki 2 | Copyright (C) 2022 John MacFarlane 3 | Copyright (C) 2020 rxi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included 14 | in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /lua/web-server/path.lua: -------------------------------------------------------------------------------- 1 | --- Normalizes paths. 2 | -- @module web-server.path 3 | -- @author Gábor Nyéki 4 | -- @license MIT 5 | -- 6 | 7 | --- Module class. 8 | -- @field value (string) normalized path 9 | -- @field[opt] query_string (string) the part of the requested path that 10 | -- begins with a `?` 11 | -- @usage 12 | -- -- A path with extra slashes and without a query string. 13 | -- -- 14 | -- 15 | -- local path_a = Path.new("/foo//bar/") 16 | -- 17 | -- assert(path_a.value == "/foo/bar") 18 | -- assert(not path_a.query_string) 19 | -- 20 | -- -- A path with a query string. 21 | -- -- 22 | -- 23 | -- local path_b = Path.new("/foo?bar=baz&asd=f") 24 | -- 25 | -- assert(path_b.value == "/foo") 26 | -- assert(path_b.query_string == "?bar=baz&asd=f") 27 | -- 28 | local M = {} 29 | 30 | function M.new(raw) 31 | local query_string = raw:match("?.*") 32 | local normalized = raw:gsub("?.*", ""):gsub("/+", "/") 33 | 34 | if normalized ~= "/" then 35 | normalized = normalized:gsub("/$", "") 36 | end 37 | 38 | return setmetatable({ 39 | value = normalized, 40 | query_string = query_string 41 | }, { 42 | __index = M 43 | }) 44 | end 45 | 46 | return M 47 | -------------------------------------------------------------------------------- /tests/djotter.lua: -------------------------------------------------------------------------------- 1 | local Djotter = require("web-server.djotter") 2 | 3 | for i, case in ipairs({ 4 | { t = "{{ title }}", d = "# foo\n\n## bar\n\nbaz\n", h = "foo" }, 5 | { t = "{{ title }}", d = "x\n\n# foo\n\n## bar\n\nbaz\n", h = "foo" }, 6 | { t = "{{ title }}", d = "# foo\n\n# bar\n\nbaz\n", h = "foo" }, 7 | { t = "{{ content }}", 8 | d = "# foo\n\n## bar\n\nbaz\n", 9 | h = 10 | "
\n" 11 | .. "

foo

\n" 12 | .. "
\n" 13 | .. "

bar

\n" 14 | .. "

baz

\n" 15 | .. "
\n" 16 | .. "
\n" }, 17 | { t = "{{ title }}\n{{ content }}", 18 | d = "# foo\n\n## bar\n\nbaz\n", 19 | h = 20 | "foo\n" 21 | .. "
\n" 22 | .. "

foo

\n" 23 | .. "
\n" 24 | .. "

bar

\n" 25 | .. "

baz

\n" 26 | .. "
\n" 27 | .. "
\n" 28 | .. "" }, 29 | }) do 30 | local djotter = Djotter.new() 31 | 32 | djotter.template = case.t 33 | 34 | local result = djotter:to_html(case.d) 35 | 36 | assert(result == case.h, "#" .. i .. " -> " .. result) 37 | end 38 | -------------------------------------------------------------------------------- /tests/path.lua: -------------------------------------------------------------------------------- 1 | local Path = require("web-server.path") 2 | local f = Path.new 3 | 4 | for i, case in ipairs({ 5 | { path = "/", normalized = "/", query_string = nil }, 6 | { path = "//", normalized = "/", query_string = nil }, 7 | { path = "/foo", normalized = "/foo", query_string = nil }, 8 | { path = "//foo", normalized = "/foo", query_string = nil }, 9 | { path = "/foo/", normalized = "/foo", query_string = nil }, 10 | { path = "/foo//", normalized = "/foo", query_string = nil }, 11 | { path = "/?", normalized = "/", query_string = "?" }, 12 | { path = "/?foo", normalized = "/", query_string = "?foo" }, 13 | { path = "/?foo=bar", normalized = "/", query_string = "?foo=bar" }, 14 | { path = "/?foo&bar", normalized = "/", query_string = "?foo&bar" }, 15 | { path = "/foo?", normalized = "/foo", query_string = "?" }, 16 | { path = "/foo?bar", normalized = "/foo", query_string = "?bar" }, 17 | { path = "/foo/?", normalized = "/foo", query_string = "?" }, 18 | { path = "/foo/?bar", normalized = "/foo", query_string = "?bar" }, 19 | }) do 20 | local a = case.path 21 | local b = case.normalized 22 | local c = case.query_string 23 | 24 | assert(f(a).value == b, a .. " not -> " .. b) 25 | assert( 26 | f(a).query_string == c, 27 | string.format("%s not -> query_string = %s", a, c) 28 | ) 29 | end 30 | -------------------------------------------------------------------------------- /lua/web-server/djotter.lua: -------------------------------------------------------------------------------- 1 | --- Converts Djot markup to HTML. It wraps John MacFarlane's 2 | -- "djot.lua". 3 | -- @module web-server.djotter 4 | -- @author Gábor Nyéki 5 | -- @license MIT 6 | -- 7 | 8 | local djot = require("web-server.djot") 9 | local cmd_error = require("web-server.common").cmd_error 10 | 11 | --- Module class. 12 | -- @field template (string) 13 | local M = {} 14 | 15 | function M.new() 16 | local state = { 17 | template = ( 18 | "" .. 19 | "" .. 20 | "{{ title }}" .. 21 | "" .. 22 | "{{ content }}" .. 23 | "" 24 | ) 25 | } 26 | return setmetatable(state, { __index = M }) 27 | end 28 | 29 | --- Converts the input string from Djot to HTML. 30 | -- @param input (string) 31 | -- @return (string) 32 | function M:to_html(input) 33 | local ast = djot.parse(input, false, function(warning) 34 | cmd_error( 35 | "Djot parse error: %s at byte position %d", 36 | warning.message, 37 | warning.pos 38 | ) 39 | end) 40 | 41 | local content = djot.render_html(ast) 42 | local content_escaped = content:gsub("%%", "%%%%") 43 | 44 | local title = content:match("([^<]*)") or "" 45 | local title_escaped = title:gsub("%%", "%%%%") 46 | 47 | return ( 48 | self.template 49 | :gsub("{{ title }}", title_escaped) 50 | :gsub("{{ content }}", content_escaped) 51 | ) 52 | end 53 | 54 | return M 55 | -------------------------------------------------------------------------------- /tests/request.lua: -------------------------------------------------------------------------------- 1 | local M = require("web-server") 2 | local process_request_line = M.internal.process_request_line 3 | 4 | for _, input in ipairs({"/", "/foo", "/foo/bar", "/foo?bar=baz"}) do 5 | local _, method, path, proto, bad = process_request_line( 6 | "GET " .. input .. " HTTP/1.1\n\n" 7 | ) 8 | assert(method == "GET", "wrong method for " .. input) 9 | assert(path == input, "wrong path for " .. input) 10 | assert(proto == "HTTP/1.1", "wrong proto for " .. input) 11 | assert(not bad, "wrong bad for " .. input) 12 | end 13 | 14 | for _, input in ipairs({ 15 | "GET", 16 | "GET /", 17 | "GET / HTTP/1.1 foo", 18 | "POST / HTTP/1.1", 19 | "DELETE / HTTP/1.1", 20 | }) do 21 | local _, _, _, _, bad = process_request_line(input .. "\n\n") 22 | assert(bad, "wrong bad for '" .. input .. "'") 23 | end 24 | 25 | local process_request_header = M.internal.process_request_header 26 | 27 | for i, input in ipairs({ 28 | "GET / HTTP/1.1\nIf-None-Match: foo-bar\n\n", 29 | "GET / HTTP/1.1\nIf-None-Match: \"foo-bar\"\n\n", 30 | "GET / HTTP/1.1\nHost: foo\nIf-None-Match: foo-bar\n\n", 31 | "GET / HTTP/1.1\nHost: foo\nIf-None-Match: \"foo-bar\"\n\n", 32 | "GET / HTTP/1.1\nHost: foo\nIf-None-Match: foo-bar\n" 33 | .. "Connection: keep-alive\n\n", 34 | "GET / HTTP/1.1\nHost: foo\nIf-None-Match: \"foo-bar\"\n" 35 | .. "Connection: keep-alive\n\n", 36 | }) do 37 | local result = process_request_header(input) 38 | assert(result == "foo-bar", "wrong for #" .. i) 39 | end 40 | 41 | for i, input in ipairs({ 42 | "GET / HTTP/1.1\n\n", 43 | "GET / HTTP/1.1\nHost: foo\n\n", 44 | "GET / HTTP/1.1\nHost: foo\nConnection: keep-alive\n\n", 45 | }) do 46 | assert(not process_request_header(input), "wrong for #" .. i) 47 | end 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nvim-web-server 2 | 3 | [![CI](https://github.com/gn0/nvim-web-server/actions/workflows/ci.yml/badge.svg)](https://github.com/gn0/nvim-web-server/actions/workflows/ci.yml) 4 | 5 | This plugin turns Neovim into a web server. 6 | It's written in Lua using only Neovim's API, so no external tools are required. 7 | 8 | ## Features 9 | 10 | - [X] Serve paths from Neovim buffers. 11 | - [X] Natively support the [Djot](https://djot.net) markup language and automatically convert Djot buffers to HTML using [djot.lua](https://github.com/jgm/djot.lua). 12 | - [X] Set custom HTML template for the Djot-to-HTML conversion (also via a Neovim buffer). 13 | - [X] Update content when buffers are written to disk. 14 | - [X] Allow browsers to cache content via [entity tags](https://en.wikipedia.org/wiki/HTTP_ETag). 15 | 16 | ## Usage 17 | 18 | Launch nvim-web-server interactively by typing 19 | 20 | ```vim 21 | :lua require("web-server").init() 22 | ``` 23 | 24 | You can route paths to buffers by switching to the buffer that you want to serve and using the `:WSAddBuffer` command. 25 | By default, nvim-web-server interprets the buffer content as Djot and automatically converts it to HTML: 26 | 27 | ```vim 28 | " Serve / from the current buffer and treat the buffer as Djot. 29 | :WSAddBuffer / 30 | ``` 31 | 32 | But you can specify the `Content-Type` to be anything: 33 | 34 | ```vim 35 | " Serve /rss.xml as an XML file. 36 | :WSAddBuffer /rss.xml text/xml 37 | 38 | " Serve /picture.png as a PNG file. 39 | :WSAddBuffer /picture.png image/png 40 | ``` 41 | 42 | You can also set a buffer as the template for the Djot-to-HTML conversion. 43 | Take this template: 44 | 45 | ```html 46 | 47 | 48 | 49 | {{ title }} - Super Duper Website 50 | 55 | 56 | {{ content }} 57 | 58 | ``` 59 | 60 | `{{ content }}` will be replaced with the result of the conversion, and `{{ title }}` with the content of the first heading (if present). 61 | You can set this as your template using the `:WSSetBufferAsTemplate` command: 62 | 63 | ```vim 64 | " Set current buffer as the template and automatically update the content for 65 | " every Djot-based path. 66 | :WSSetBufferAsTemplate 67 | ``` 68 | 69 | If you want to see what paths are currently routed and to which buffers, use `:WSPaths`. 70 | If you want to delete a path, use `:WSDeletePath`: 71 | 72 | ```vim 73 | " No longer serve /picture.png. 74 | :WSDeletePath /picture.png 75 | ``` 76 | 77 | You can configure nvim-web-server by passing a table to the `init` method when you launch the server. 78 | For example, if you want the server log to be periodically saved to a file: 79 | 80 | ```vim 81 | :lua require("web-server").init({ log_filename = "server.log" }) 82 | ``` 83 | 84 | This is the default configuration: 85 | 86 | | Option | Default value | 87 | |---------------------------|---------------| 88 | | `host` | `"127.0.0.1"` | 89 | | `port` | `4999` | 90 | | `log_filename` | `nil` | 91 | | `log_each_request` | `false` | 92 | | `log_views_period` | `0` | 93 | | `log_resource_use_period` | `0` | 94 | | `keep_alive` | `false` | 95 | 96 | ## Installation 97 | 98 | Use your Neovim package manager, or install it manually: 99 | 100 | ```sh 101 | mkdir -p ~/.config/nvim/pack/gn0/start 102 | cd ~/.config/nvim/pack/gn0/start 103 | git clone https://github.com/gn0/nvim-web-server.git 104 | ``` 105 | 106 | ## License 107 | 108 | nvim-web-server is distributed under the [MIT](./COPYING.txt) license. 109 | `vim-cheatsheet.lua` and associated source files are Copyright (C) 2025 Gábor Nyéki. 110 | `djot.lua` and its source files are Copyright (C) 2022 John MacFarlane, with the exception of `lua/web-server/djot/json.lua` which is Copyright (C) 2020 rxi. 111 | 112 | -------------------------------------------------------------------------------- /lua/web-server/djot/json.lua: -------------------------------------------------------------------------------- 1 | -- This file has been adapted from John MacFarlane's djot.lua. 2 | -- 3 | -- Author: John MacFarlane 4 | -- License: MIT 5 | -- 6 | 7 | -- Modified from 8 | -- json.lua 9 | -- Copyright (c) 2020 rxi 10 | -- 11 | -- Permission is hereby granted, free of charge, to any person obtaining a copy of 12 | -- this software and associated documentation files (the "Software"), to deal in 13 | -- the Software without restriction, including without limitation the rights to 14 | -- use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 15 | -- of the Software, and to permit persons to whom the Software is furnished to do 16 | -- so, subject to the following conditions: 17 | -- 18 | -- The above copyright notice and this permission notice shall be included in all 19 | -- copies or substantial portions of the Software. 20 | -- 21 | -- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 22 | -- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 23 | -- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 24 | -- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 25 | -- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 26 | -- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 27 | -- SOFTWARE. 28 | -- 29 | -- Modifications to the original code: 30 | -- 31 | -- * Removed JSON decoding code 32 | 33 | local json = { _version = "0.1.2" } 34 | 35 | -- Encode 36 | 37 | local encode 38 | 39 | local escape_char_map = { 40 | [ "\\" ] = "\\", 41 | [ "\"" ] = "\"", 42 | [ "\b" ] = "b", 43 | [ "\f" ] = "f", 44 | [ "\n" ] = "n", 45 | [ "\r" ] = "r", 46 | [ "\t" ] = "t", 47 | } 48 | 49 | local escape_char_map_inv = { [ "/" ] = "/" } 50 | for k, v in pairs(escape_char_map) do 51 | escape_char_map_inv[v] = k 52 | end 53 | 54 | 55 | local function escape_char(c) 56 | return "\\" .. (escape_char_map[c] or string.format("u%04x", c:byte())) 57 | end 58 | 59 | 60 | local function encode_nil(val) 61 | return "null" 62 | end 63 | 64 | local function encode_table(val, stack) 65 | local res = {} 66 | stack = stack or {} 67 | 68 | -- Circular reference? 69 | if stack[val] then error("circular reference") end 70 | 71 | stack[val] = true 72 | 73 | if rawget(val, 1) ~= nil or next(val) == nil then 74 | -- Treat as array -- check keys are valid and it is not sparse 75 | local n = 0 76 | for k in pairs(val) do 77 | if type(k) ~= "number" then 78 | error("invalid table: mixed or invalid key types") 79 | end 80 | n = n + 1 81 | end 82 | if n ~= #val then 83 | error("invalid table: sparse array") 84 | end 85 | -- Encode 86 | for i, v in ipairs(val) do 87 | table.insert(res, encode(v, stack)) 88 | end 89 | stack[val] = nil 90 | return "[" .. table.concat(res, ",") .. "]" 91 | 92 | else 93 | -- Treat as an object 94 | for k, v in pairs(val) do 95 | if type(k) ~= "string" then 96 | error("invalid table: mixed or invalid key types") 97 | end 98 | table.insert(res, encode(k, stack) .. ":" .. encode(v, stack)) 99 | end 100 | stack[val] = nil 101 | return "{" .. table.concat(res, ",") .. "}" 102 | end 103 | end 104 | 105 | 106 | local function encode_string(val) 107 | return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"' 108 | end 109 | 110 | 111 | local function encode_number(val) 112 | -- Check for NaN, -inf and inf 113 | if val ~= val or val <= -math.huge or val >= math.huge then 114 | error("unexpected number value '" .. tostring(val) .. "'") 115 | end 116 | return string.format("%.14g", val) 117 | end 118 | 119 | 120 | local type_func_map = { 121 | [ "nil" ] = encode_nil, 122 | [ "table" ] = encode_table, 123 | [ "string" ] = encode_string, 124 | [ "number" ] = encode_number, 125 | [ "boolean" ] = tostring, 126 | } 127 | 128 | 129 | encode = function(val, stack) 130 | local t = type(val) 131 | local f = type_func_map[t] 132 | if f then 133 | return f(val, stack) 134 | end 135 | error("unexpected type '" .. t .. "'") 136 | end 137 | 138 | 139 | function json.encode(val) 140 | return ( encode(val) ) 141 | end 142 | 143 | return json 144 | -------------------------------------------------------------------------------- /tests/server.lua: -------------------------------------------------------------------------------- 1 | local M = require("web-server") 2 | 3 | function setup() 4 | M.init() 5 | 6 | vim.cmd.split("tests/files/template.html") 7 | vim.cmd("WSSetBufferAsTemplate") 8 | 9 | vim.cmd.split("tests/files/index.dj") 10 | vim.cmd("WSAddBuffer /") 11 | 12 | vim.cmd.split("tests/files/dummy.png") 13 | vim.cmd("WSAddBuffer /dummy.png image/png") 14 | 15 | vim.cmd.split("tests/files/page.html") 16 | vim.cmd("WSAddBuffer /page text/html") 17 | end 18 | 19 | function test(opts) 20 | local command = { 21 | "curl", "-si", "http://127.0.0.1:4999" .. opts.path 22 | } 23 | 24 | if opts.etag then 25 | table.insert(command, "-H") 26 | table.insert(command, "If-None-Match: " .. opts.etag) 27 | end 28 | 29 | local done = false 30 | 31 | vim.system(command, { text = true }, function(result) 32 | local first_line = result.stdout:match("^[^\r\n]*") 33 | local status = tonumber( 34 | first_line:match("^HTTP/1.[01] ([0-9]+)") 35 | ) 36 | local content_type = nil 37 | local content_length = nil 38 | local content = nil 39 | local is_content = false 40 | 41 | for line in result.stdout:gmatch("[\r\n]([^\r\n]*)") do 42 | if line == "" then 43 | is_content = true 44 | elseif is_content then 45 | if not content then 46 | content = line 47 | else 48 | content = content .. "\n" .. line 49 | end 50 | else 51 | content_type = ( 52 | line:match("^Content%-Type: (.*)") 53 | or content_type 54 | ) 55 | content_length = ( 56 | tonumber(line:match("^Content%-Length: (.*)")) 57 | or content_length 58 | ) 59 | end 60 | end 61 | 62 | opts.callback({ 63 | status = status, 64 | content_type = content_type, 65 | content_length = content_length, 66 | content = content, 67 | }) 68 | 69 | done = true 70 | end) 71 | 72 | vim.wait(1000, function() return done end, 100) 73 | 74 | assert(done) 75 | end 76 | 77 | setup() 78 | 79 | test({ 80 | path = "/", 81 | callback = function(result) 82 | assert(result.status == 200, result.status) 83 | assert(result.content_type == "text/html", result.content_type) 84 | assert(result.content_length == 117, result.content_length) 85 | assert( 86 | result.content == 87 | "foo\n" 88 | .. "
\n" 89 | .. "

foo

\n" 90 | .. "

\"bar\"

\n" 91 | .. "
\n" 92 | .. "", 93 | result.content 94 | ) 95 | end 96 | }) 97 | 98 | test({ 99 | path = "/", 100 | etag = "a9711f82001d1ecfc2a95e63dead45a1895f4869f9d4d1cac29a918e29c7c96e", 101 | callback = function(result) 102 | assert(result.status == 304, result.status) 103 | assert(not result.content_type, result.content_type) 104 | assert(not result.content_length, result.content_length) 105 | assert(not result.content, result.content) 106 | end 107 | }) 108 | 109 | test({ 110 | path = "/dummy.png", 111 | callback = function(result) 112 | -- NOTE `test` replaces \r\n and \r with \n, so the magic that 113 | -- we test for is not the real one: 114 | -- 115 | -- "\x89PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0DIHDR" 116 | -- 117 | local png_magic = "\x89PNG\x0a\x1a\x0a\x00\x00\x00\x0aIHDR" 118 | 119 | assert(result.status == 200, result.status) 120 | assert(result.content_type == "image/png", result.content_type) 121 | assert(result.content_length == 49959, result.content_length) 122 | assert(result.content:match("^" .. png_magic)) 123 | end 124 | }) 125 | 126 | test({ 127 | path = "/page", 128 | callback = function(result) 129 | assert(result.status == 200, result.status) 130 | assert(result.content_type == "text/html", result.content_type) 131 | assert(result.content_length == 29, result.content_length) 132 | assert( 133 | result.content == 134 | "

page

\n" 135 | .. "

content

", 136 | result.content 137 | ) 138 | end 139 | }) 140 | 141 | test({ 142 | path = "/nonexistent", 143 | callback = function(result) 144 | assert(result.status == 404, result.status) 145 | assert(result.content_type == "text/html", result.content_type) 146 | end 147 | }) 148 | -------------------------------------------------------------------------------- /lua/web-server/djot/init.lua: -------------------------------------------------------------------------------- 1 | -- This file has been adapted from John MacFarlane's djot.lua. 2 | -- 3 | -- Author: John MacFarlane 4 | -- License: MIT 5 | -- 6 | 7 | --- @module 'djot' 8 | --- Parse and render djot light markup format. See https://djot.net. 9 | --- 10 | --- @usage 11 | --- local djot = require("djot") 12 | --- local input = "This is *djot*" 13 | --- local doc = djot.parse(input) 14 | --- -- render as HTML: 15 | --- print(djot.render_html(doc)) 16 | --- 17 | --- -- render as AST: 18 | --- print(djot.render_ast_pretty(doc)) 19 | --- 20 | --- -- or in JSON: 21 | --- print(djot.render_ast_json(doc)) 22 | --- 23 | --- -- alter the AST with a filter: 24 | --- local src = "return { str = function(e) e.text = e.text:upper() end }" 25 | --- -- subordinate modules like filter can be accessed as fields 26 | --- -- and are lazily loaded. 27 | --- local filter = djot.filter.load_filter(src) 28 | --- djot.filter.apply_filter(doc, filter) 29 | --- 30 | --- -- streaming parser: 31 | --- for startpos, endpos, annotation in djot.parse_events("*hello there*") do 32 | --- print(startpos, endpos, annotation) 33 | --- end 34 | 35 | local unpack = unpack or table.unpack 36 | local Parser = require("web-server.djot.block").Parser 37 | local ast = require("web-server.djot.ast") 38 | local html = require("web-server.djot.html") 39 | local json = require("web-server.djot.json") 40 | local filter = require("web-server.djot.filter") 41 | 42 | --- @class StringHandle 43 | local StringHandle = {} 44 | 45 | --- @return (StringHandle) 46 | function StringHandle:new() 47 | local buffer = {} 48 | setmetatable(buffer, StringHandle) 49 | StringHandle.__index = StringHandle 50 | return buffer 51 | end 52 | 53 | --- @param s (string) 54 | function StringHandle:write(s) 55 | self[#self + 1] = s 56 | end 57 | 58 | --- @return (string) 59 | function StringHandle:flush() 60 | return table.concat(self) 61 | end 62 | 63 | --- Parse a djot text and construct an abstract syntax tree (AST) 64 | --- representing the document. 65 | --- @param input (string) input string 66 | --- @param sourcepos (boolean) if true, source positions are included in the AST 67 | --- @param warn (function) function that processes a warning, accepting a warning 68 | --- object with `pos` and `message` fields. 69 | --- @return (AST) 70 | local function parse(input, sourcepos, warn) 71 | local parser = Parser:new(input, warn) 72 | return ast.to_ast(parser, sourcepos) 73 | end 74 | 75 | --- Parses a djot text and returns an iterator over events, consisting 76 | --- of a start position (bytes), and an position (bytes), and an 77 | --- annotation. 78 | --- @param input (string) input string 79 | --- @param warn (function) function that processes a warning, accepting a warning 80 | --- object with `pos` and `message` fields. 81 | --- @return integer, integer, string an iterator over events. 82 | --- 83 | --- for startpos, endpos, annotation in djot.parse_events("hello *world") do 84 | --- ... 85 | --- end 86 | local function parse_events(input, warn) 87 | return Parser:new(input):events() 88 | end 89 | 90 | --- Render a document's AST in human-readable form. 91 | --- @param doc (AST) the AST 92 | --- @return (string) rendered AST 93 | local function render_ast_pretty(doc) 94 | local handle = StringHandle:new() 95 | ast.render(doc, handle) 96 | return handle:flush() 97 | end 98 | 99 | --- Render a document's AST in JSON. 100 | --- @param doc (AST) the AST 101 | --- @return (string) rendered AST (JSON string) 102 | local function render_ast_json(doc) 103 | return json.encode(doc) .. "\n" 104 | end 105 | 106 | --- Render a document as HTML. 107 | --- @param doc (AST) the AST 108 | --- @return (string) rendered document (HTML string) 109 | local function render_html(doc) 110 | local handle = StringHandle:new() 111 | local renderer = html.Renderer:new() 112 | renderer:render(doc, handle) 113 | return handle:flush() 114 | end 115 | 116 | --- Render an event as a JSON array. 117 | --- @param startpos (integer) starting byte position 118 | --- @param endpos (integer) ending byte position 119 | --- @param annotation (string) annotation of event 120 | --- @return (string) rendered event (JSON string) 121 | local function render_event(startpos, endpos, annotation) 122 | return string.format("[%q,%d,%d]", annotation, startpos, endpos) 123 | end 124 | 125 | --- Parse a document and render as a JSON array of events. 126 | --- @param input (string) the djot document 127 | --- @param warn (function) function that emits warnings, taking as argumnet 128 | --- an object with fields 'message' and 'pos' 129 | --- @return (string) rendered events (JSON string) 130 | local function parse_and_render_events(input, warn) 131 | local handle = StringHandle:new() 132 | local idx = 0 133 | for startpos, endpos, annotation in parse_events(input, warn) do 134 | idx = idx + 1 135 | if idx == 1 then 136 | handle:write("[") 137 | else 138 | handle:write(",") 139 | end 140 | handle:write(render_event(startpos, endpos, annotation) .. "\n") 141 | end 142 | handle:write("]\n") 143 | return handle:flush() 144 | end 145 | 146 | --- djot version (string) 147 | local version = "0.2.1" 148 | 149 | --- @export 150 | local G = { 151 | parse = parse, 152 | parse_events = parse_events, 153 | parse_and_render_events = parse_and_render_events, 154 | render_html = render_html, 155 | render_ast_pretty = render_ast_pretty, 156 | render_ast_json = render_ast_json, 157 | render_event = render_event, 158 | version = version 159 | } 160 | 161 | -- Lazily load submodules, e.g. djot.filter 162 | setmetatable(G,{ __index = function(t,name) 163 | local mod = require("web-server.djot." .. name) 164 | rawset(t,name,mod) 165 | return t[name] 166 | end }) 167 | 168 | return G 169 | -------------------------------------------------------------------------------- /lua/web-server/djot/filter.lua: -------------------------------------------------------------------------------- 1 | -- This file has been adapted from John MacFarlane's djot.lua. 2 | -- 3 | -- Author: John MacFarlane 4 | -- License: MIT 5 | -- 6 | 7 | --- @module 'djot.filter' 8 | --- Support filters that walk the AST and transform a 9 | --- document between parsing and rendering, like pandoc Lua filters. 10 | --- 11 | --- This filter uppercases all str elements. 12 | --- 13 | --- return { 14 | --- str = function(e) 15 | --- e.text = e.text:upper() 16 | --- end 17 | --- } 18 | --- 19 | --- A filter may define functions for as many different tag types 20 | --- as it likes. traverse will walk the AST and apply matching 21 | --- functions to each node. 22 | --- 23 | --- To load a filter: 24 | --- 25 | --- local filter = require_filter(path) 26 | --- 27 | --- or 28 | --- 29 | --- local filter = load_filter(string) 30 | --- 31 | --- By default filters do a bottom-up traversal; that is, the 32 | --- filter for a node is run after its children have been processed. 33 | --- It is possible to do a top-down travel, though, and even 34 | --- to run separate actions on entering a node (before processing the 35 | --- children) and on exiting (after processing the children). To do 36 | --- this, associate the node's tag with a table containing `enter` and/or 37 | --- `exit` functions. The following filter will capitalize text 38 | --- that is nested inside emphasis, but not other text: 39 | --- 40 | --- local capitalize = 0 41 | --- return { 42 | --- emph = { 43 | --- enter = function(e) 44 | --- capitalize = capitalize + 1 45 | --- end, 46 | --- exit = function(e) 47 | --- capitalize = capitalize - 1 48 | --- end, 49 | --- }, 50 | --- str = function(e) 51 | --- if capitalize > 0 then 52 | --- e.text = e.text:upper() 53 | --- end 54 | --- end 55 | --- } 56 | --- 57 | --- For a top-down traversal, you'd just use the `enter` functions. 58 | --- If the tag is associated directly with a function, as in the 59 | --- first example above, it is treated as an `exit` function. 60 | --- 61 | --- It is possible to inhibit traversal into the children of a node, 62 | --- by having the `enter` function return the value true (or any truish 63 | --- value, say `'stop'`). This can be used, for example, to prevent 64 | --- the contents of a footnote from being processed: 65 | --- 66 | --- return { 67 | --- footnote = { 68 | --- enter = function(e) 69 | --- return true 70 | --- end 71 | --- } 72 | --- } 73 | --- 74 | --- A single filter may return a table with multiple tables, which will be 75 | --- applied sequentially. 76 | 77 | local function handle_node(node, filterpart) 78 | local action = filterpart[node.t] 79 | local action_in, action_out 80 | if type(action) == "table" then 81 | action_in = action.enter 82 | action_out = action.exit 83 | elseif type(action) == "function" then 84 | action_out = action 85 | end 86 | if action_in then 87 | local stop_traversal = action_in(node) 88 | if stop_traversal then 89 | return 90 | end 91 | end 92 | if node.c then 93 | for _,child in ipairs(node.c) do 94 | handle_node(child, filterpart) 95 | end 96 | end 97 | if node.footnotes then 98 | for _, note in pairs(node.footnotes) do 99 | handle_node(note, filterpart) 100 | end 101 | end 102 | if action_out then 103 | action_out(node) 104 | end 105 | end 106 | 107 | local function traverse(node, filterpart) 108 | handle_node(node, filterpart) 109 | return node 110 | end 111 | 112 | --- Apply a filter to a document. 113 | --- @param node document (AST) 114 | --- @param filter the filter to apply 115 | local function apply_filter(node, filter) 116 | for _,filterpart in ipairs(filter) do 117 | traverse(node, filterpart) 118 | end 119 | end 120 | 121 | --- Returns a table containing the filter defined in `fp`. 122 | --- `fp` will be sought using `require`, so it may occur anywhere 123 | --- on the `LUA_PATH`, or in the working directory. On error, 124 | --- returns nil and an error message. 125 | --- @param fp path of file containing filter 126 | --- @return the compiled filter, or nil and and error message 127 | local function require_filter(fp) 128 | local oldpackagepath = package.path 129 | -- allow omitting or providing the .lua extension: 130 | local ok, filter = pcall(function() 131 | package.path = "./?.lua;" .. package.path 132 | local f = require(fp:gsub("%.lua$","")) 133 | package.path = oldpackagepath 134 | return f 135 | end) 136 | if not ok then 137 | return nil, filter 138 | elseif type(filter) ~= "table" then 139 | return nil, "filter must be a table" 140 | end 141 | if #filter == 0 then -- just a single filter part given 142 | return {filter} 143 | else 144 | return filter 145 | end 146 | end 147 | 148 | --- Load filter from a string, which should have the 149 | --- form `return { ... }`. On error, return nil and an 150 | --- error message. 151 | --- @param s string containing the filter 152 | --- @return the compiled filter, or nil and and error message 153 | local function load_filter(s) 154 | local fn, err 155 | if _VERSION:match("5.1") then 156 | fn, err = loadstring(s) 157 | else 158 | fn, err = load(s) 159 | end 160 | if fn then 161 | local filter = fn() 162 | if type(filter) ~= "table" then 163 | return nil, "filter must be a table" 164 | end 165 | if #filter == 0 then -- just a single filter given 166 | return {filter} 167 | else 168 | return filter 169 | end 170 | else 171 | return nil, err 172 | end 173 | end 174 | 175 | --- @export 176 | return { 177 | apply_filter = apply_filter, 178 | require_filter = require_filter, 179 | load_filter = load_filter 180 | } 181 | -------------------------------------------------------------------------------- /lua/web-server/djot/attributes.lua: -------------------------------------------------------------------------------- 1 | -- This file has been adapted from John MacFarlane's djot.lua. 2 | -- 3 | -- Author: John MacFarlane 4 | -- License: MIT 5 | -- 6 | 7 | local find, sub = string.find, string.sub 8 | 9 | -- Parser for attributes 10 | -- attributes { id = "foo", class = "bar baz", 11 | -- key1 = "val1", key2 = "val2" } 12 | -- syntax: 13 | -- 14 | -- attributes <- '{' whitespace* attribute (whitespace attribute)* whitespace* '}' 15 | -- attribute <- identifier | class | keyval 16 | -- identifier <- '#' name 17 | -- class <- '.' name 18 | -- name <- (nonspace, nonpunctuation other than ':', '_', '-')+ 19 | -- keyval <- key '=' val 20 | -- key <- (ASCII_ALPHANUM | ':' | '_' | '-')+ 21 | -- val <- bareval | quotedval 22 | -- bareval <- (ASCII_ALPHANUM | ':' | '_' | '-')+ 23 | -- quotedval <- '"' ([^"] | '\"') '"' 24 | 25 | -- states: 26 | local SCANNING = 0 27 | local SCANNING_ID = 1 28 | local SCANNING_CLASS= 2 29 | local SCANNING_KEY = 3 30 | local SCANNING_VALUE = 4 31 | local SCANNING_BARE_VALUE = 5 32 | local SCANNING_QUOTED_VALUE = 6 33 | local SCANNING_QUOTED_VALUE_CONTINUATION = 7 34 | local SCANNING_ESCAPED = 8 35 | local SCANNING_ESCAPED_IN_CONTINUATION = 9 36 | local SCANNING_COMMENT = 10 37 | local FAIL = 11 38 | local DONE = 12 39 | local START = 13 40 | 41 | local AttributeParser = {} 42 | 43 | local handlers = {} 44 | 45 | handlers[START] = function(self, pos) 46 | if find(self.subject, "^{", pos) then 47 | return SCANNING 48 | else 49 | return FAIL 50 | end 51 | end 52 | 53 | handlers[FAIL] = function(_self, _pos) 54 | return FAIL 55 | end 56 | 57 | handlers[DONE] = function(_self, _pos) 58 | return DONE 59 | end 60 | 61 | handlers[SCANNING] = function(self, pos) 62 | local c = sub(self.subject, pos, pos) 63 | if c == ' ' or c == '\t' or c == '\n' or c == '\r' then 64 | return SCANNING 65 | elseif c == '}' then 66 | return DONE 67 | elseif c == '#' then 68 | self.begin = pos 69 | return SCANNING_ID 70 | elseif c == '%' then 71 | self.begin = pos 72 | return SCANNING_COMMENT 73 | elseif c == '.' then 74 | self.begin = pos 75 | return SCANNING_CLASS 76 | elseif find(c, "^[%a%d_:-]") then 77 | self.begin = pos 78 | return SCANNING_KEY 79 | else -- TODO 80 | return FAIL 81 | end 82 | end 83 | 84 | handlers[SCANNING_COMMENT] = function(self, pos) 85 | local c = sub(self.subject, pos, pos) 86 | if c == "%" then 87 | return SCANNING 88 | elseif c == "}" then 89 | return DONE 90 | else 91 | return SCANNING_COMMENT 92 | end 93 | end 94 | 95 | handlers[SCANNING_ID] = function(self, pos) 96 | local c = sub(self.subject, pos, pos) 97 | if find(c, "^[^%s%p]") or c == "_" or c == "-" or c == ":" then 98 | return SCANNING_ID 99 | elseif c == '}' then 100 | if self.lastpos > self.begin then 101 | self:add_match(self.begin + 1, self.lastpos, "id") 102 | end 103 | self.begin = nil 104 | return DONE 105 | elseif find(c, "^%s") then 106 | if self.lastpos > self.begin then 107 | self:add_match(self.begin + 1, self.lastpos, "id") 108 | end 109 | self.begin = nil 110 | return SCANNING 111 | else 112 | return FAIL 113 | end 114 | end 115 | 116 | handlers[SCANNING_CLASS] = function(self, pos) 117 | local c = sub(self.subject, pos, pos) 118 | if find(c, "^[^%s%p]") or c == "_" or c == "-" or c == ":" then 119 | return SCANNING_CLASS 120 | elseif c == '}' then 121 | if self.lastpos > self.begin then 122 | self:add_match(self.begin + 1, self.lastpos, "class") 123 | end 124 | self.begin = nil 125 | return DONE 126 | elseif find(c, "^%s") then 127 | if self.lastpos > self.begin then 128 | self:add_match(self.begin + 1, self.lastpos, "class") 129 | end 130 | self.begin = nil 131 | return SCANNING 132 | else 133 | return FAIL 134 | end 135 | end 136 | 137 | handlers[SCANNING_KEY] = function(self, pos) 138 | local c = sub(self.subject, pos, pos) 139 | if c == "=" then 140 | self:add_match(self.begin, self.lastpos, "key") 141 | self.begin = nil 142 | return SCANNING_VALUE 143 | elseif find(c, "^[%a%d_:-]") then 144 | return SCANNING_KEY 145 | else 146 | return FAIL 147 | end 148 | end 149 | 150 | handlers[SCANNING_VALUE] = function(self, pos) 151 | local c = sub(self.subject, pos, pos) 152 | if c == '"' then 153 | self.begin = pos 154 | return SCANNING_QUOTED_VALUE 155 | elseif find(c, "^[%a%d_:-]") then 156 | self.begin = pos 157 | return SCANNING_BARE_VALUE 158 | else 159 | return FAIL 160 | end 161 | end 162 | 163 | handlers[SCANNING_BARE_VALUE] = function(self, pos) 164 | local c = sub(self.subject, pos, pos) 165 | if find(c, "^[%a%d_:-]") then 166 | return SCANNING_BARE_VALUE 167 | elseif c == '}' then 168 | self:add_match(self.begin, self.lastpos, "value") 169 | self.begin = nil 170 | return DONE 171 | elseif find(c, "^%s") then 172 | self:add_match(self.begin, self.lastpos, "value") 173 | self.begin = nil 174 | return SCANNING 175 | else 176 | return FAIL 177 | end 178 | end 179 | 180 | handlers[SCANNING_ESCAPED] = function(_self, _pos) 181 | return SCANNING_QUOTED_VALUE 182 | end 183 | 184 | handlers[SCANNING_ESCAPED_IN_CONTINUATION] = function(_self, _pos) 185 | return SCANNING_QUOTED_VALUE_CONTINUATION 186 | end 187 | 188 | handlers[SCANNING_QUOTED_VALUE] = function(self, pos) 189 | local c = sub(self.subject, pos, pos) 190 | if c == '"' then 191 | self:add_match(self.begin + 1, self.lastpos, "value") 192 | self.begin = nil 193 | return SCANNING 194 | elseif c == "\n" then 195 | self:add_match(self.begin + 1, self.lastpos, "value") 196 | self.begin = nil 197 | return SCANNING_QUOTED_VALUE_CONTINUATION 198 | elseif c == "\\" then 199 | return SCANNING_ESCAPED 200 | else 201 | return SCANNING_QUOTED_VALUE 202 | end 203 | end 204 | 205 | handlers[SCANNING_QUOTED_VALUE_CONTINUATION] = function(self, pos) 206 | local c = sub(self.subject, pos, pos) 207 | if self.begin == nil then 208 | self.begin = pos 209 | end 210 | if c == '"' then 211 | self:add_match(self.begin, self.lastpos, "value") 212 | self.begin = nil 213 | return SCANNING 214 | elseif c == "\n" then 215 | self:add_match(self.begin, self.lastpos, "value") 216 | self.begin = nil 217 | return SCANNING_QUOTED_VALUE_CONTINUATION 218 | elseif c == "\\" then 219 | return SCANNING_ESCAPED_IN_CONTINUATION 220 | else 221 | return SCANNING_QUOTED_VALUE_CONTINUATION 222 | end 223 | end 224 | 225 | function AttributeParser:new(subject) 226 | local state = { 227 | subject = subject, 228 | state = START, 229 | begin = nil, 230 | lastpos = nil, 231 | matches = {} 232 | } 233 | setmetatable(state, self) 234 | self.__index = self 235 | return state 236 | end 237 | 238 | function AttributeParser:add_match(sp, ep, tag) 239 | self.matches[#self.matches + 1] = {sp, ep, tag} 240 | end 241 | 242 | function AttributeParser:get_matches() 243 | return self.matches 244 | end 245 | 246 | -- Feed parser a slice of text from the subject, between 247 | -- startpos and endpos inclusive. Return status, position, 248 | -- where status is either "done" (position should point to 249 | -- final '}'), "fail" (position should point to first character 250 | -- that could not be parsed), or "continue" (position should 251 | -- point to last character parsed). 252 | function AttributeParser:feed(startpos, endpos) 253 | local pos = startpos 254 | while pos <= endpos do 255 | self.state = handlers[self.state](self, pos) 256 | if self.state == DONE then 257 | return "done", pos 258 | elseif self.state == FAIL then 259 | self.lastpos = pos 260 | return "fail", pos 261 | else 262 | self.lastpos = pos 263 | pos = pos + 1 264 | end 265 | end 266 | return "continue", endpos 267 | end 268 | 269 | --[[ 270 | local test = function() 271 | local parser = AttributeParser:new("{a=b #ident\n.class\nkey=val1\n .class key2=\"val two \\\" ok\" x") 272 | local x,y,z = parser:feed(1,56) 273 | print(require'inspect'(parser:get_matches{})) 274 | end 275 | 276 | test() 277 | --]] 278 | 279 | return { AttributeParser = AttributeParser } 280 | -------------------------------------------------------------------------------- /lua/web-server/djot/html.lua: -------------------------------------------------------------------------------- 1 | -- This file has been adapted from John MacFarlane's djot.lua. 2 | -- 3 | -- Author: John MacFarlane 4 | -- License: MIT 5 | -- 6 | 7 | local ast = require("web-server.djot.ast") 8 | local new_node = ast.new_node 9 | local new_attributes = ast.new_attributes 10 | local add_child = ast.add_child 11 | local unpack = unpack or table.unpack 12 | local insert_attribute, copy_attributes = 13 | ast.insert_attribute, ast.copy_attributes 14 | local format = string.format 15 | local find, gsub = string.find, string.gsub 16 | 17 | -- Produce a copy of a table. 18 | local function copy(tbl) 19 | local result = {} 20 | if tbl then 21 | for k,v in pairs(tbl) do 22 | local newv = v 23 | if type(v) == "table" then 24 | newv = copy(v) 25 | end 26 | result[k] = newv 27 | end 28 | end 29 | return result 30 | end 31 | 32 | local function to_text(node) 33 | local buffer = {} 34 | if node.t == "str" then 35 | buffer[#buffer + 1] = node.s 36 | elseif node.t == "nbsp" then 37 | buffer[#buffer + 1] = "\160" 38 | elseif node.t == "softbreak" then 39 | buffer[#buffer + 1] = " " 40 | elseif node.c and #node.c > 0 then 41 | for i=1,#node.c do 42 | buffer[#buffer + 1] = to_text(node.c[i]) 43 | end 44 | end 45 | return table.concat(buffer) 46 | end 47 | 48 | local Renderer = {} 49 | 50 | function Renderer:new() 51 | local state = { 52 | out = function(s) 53 | io.stdout:write(s) 54 | end, 55 | tight = false, 56 | footnote_index = {}, 57 | next_footnote_index = 1, 58 | references = nil, 59 | footnotes = nil } 60 | setmetatable(state, self) 61 | self.__index = self 62 | return state 63 | end 64 | 65 | Renderer.html_escapes = 66 | { ["<"] = "<", 67 | [">"] = ">", 68 | ["&"] = "&", 69 | ['"'] = """ } 70 | 71 | function Renderer:escape_html(s) 72 | if find(s, '[<>&]') then 73 | return (gsub(s, '[<>&]', self.html_escapes)) 74 | else 75 | return s 76 | end 77 | end 78 | 79 | function Renderer:escape_html_attribute(s) 80 | if find(s, '[<>&"]') then 81 | return (gsub(s, '[<>&"]', self.html_escapes)) 82 | else 83 | return s 84 | end 85 | end 86 | 87 | function Renderer:render(doc, handle) 88 | self.references = doc.references 89 | self.footnotes = doc.footnotes 90 | if handle then 91 | self.out = function(s) 92 | handle:write(s) 93 | end 94 | end 95 | self[doc.t](self, doc) 96 | end 97 | 98 | 99 | function Renderer:render_children(node) 100 | -- trap stack overflow 101 | local ok, err = pcall(function () 102 | if node.c and #node.c > 0 then 103 | local oldtight 104 | if node.tight ~= nil then 105 | oldtight = self.tight 106 | self.tight = node.tight 107 | end 108 | for i=1,#node.c do 109 | self[node.c[i].t](self, node.c[i]) 110 | end 111 | if node.tight ~= nil then 112 | self.tight = oldtight 113 | end 114 | end 115 | end) 116 | if not ok and err:find("stack overflow") then 117 | self.out("(((DEEPLY NESTED CONTENT OMITTED)))\n") 118 | end 119 | end 120 | 121 | function Renderer:render_attrs(node) 122 | if node.attr then 123 | for k,v in pairs(node.attr) do 124 | self.out(" " .. k .. "=" .. '"' .. 125 | self:escape_html_attribute(v) .. '"') 126 | end 127 | end 128 | if node.pos then 129 | local sp, ep = unpack(node.pos) 130 | self.out(' data-startpos="' .. tostring(sp) .. 131 | '" data-endpos="' .. tostring(ep) .. '"') 132 | end 133 | end 134 | 135 | function Renderer:render_tag(tag, node) 136 | self.out("<" .. tag) 137 | self:render_attrs(node) 138 | self.out(">") 139 | end 140 | 141 | function Renderer:add_backlink(nodes, i) 142 | local backlink = new_node("link") 143 | backlink.destination = "#fnref" .. tostring(i) 144 | backlink.attr = ast.new_attributes({role = "doc-backlink"}) 145 | local arrow = new_node("str") 146 | arrow.s = "↩︎︎" 147 | add_child(backlink, arrow) 148 | if nodes.c[#nodes.c].t == "para" then 149 | add_child(nodes.c[#nodes.c], backlink) 150 | else 151 | local para = new_node("para") 152 | add_child(para, backlink) 153 | add_child(nodes, para) 154 | end 155 | end 156 | 157 | function Renderer:doc(node) 158 | self:render_children(node) 159 | -- render notes 160 | if self.next_footnote_index > 1 then 161 | local ordered_footnotes = {} 162 | for k,v in pairs(self.footnotes) do 163 | if self.footnote_index[k] then 164 | ordered_footnotes[self.footnote_index[k]] = v 165 | end 166 | end 167 | self.out('
\n
\n
    \n') 168 | for i=1,#ordered_footnotes do 169 | local note = ordered_footnotes[i] 170 | if note then 171 | self.out(format('
  1. \n', i)) 172 | self:add_backlink(note,i) 173 | self:render_children(note) 174 | self.out('
  2. \n') 175 | end 176 | end 177 | self.out('
\n
\n') 178 | end 179 | end 180 | 181 | function Renderer:raw_block(node) 182 | if node.format == "html" then 183 | self.out(node.s) -- no escaping 184 | end 185 | end 186 | 187 | function Renderer:para(node) 188 | if not self.tight then 189 | self:render_tag("p", node) 190 | end 191 | self:render_children(node) 192 | if not self.tight then 193 | self.out("

") 194 | end 195 | self.out("\n") 196 | end 197 | 198 | function Renderer:blockquote(node) 199 | self:render_tag("blockquote", node) 200 | self.out("\n") 201 | self:render_children(node) 202 | self.out("\n") 203 | end 204 | 205 | function Renderer:div(node) 206 | self:render_tag("div", node) 207 | self.out("\n") 208 | self:render_children(node) 209 | self.out("\n") 210 | end 211 | 212 | function Renderer:section(node) 213 | self:render_tag("section", node) 214 | self.out("\n") 215 | self:render_children(node) 216 | self.out("\n") 217 | end 218 | 219 | function Renderer:heading(node) 220 | self:render_tag("h" .. node.level , node) 221 | self:render_children(node) 222 | self.out("\n") 223 | end 224 | 225 | function Renderer:thematic_break(node) 226 | self:render_tag("hr", node) 227 | self.out("\n") 228 | end 229 | 230 | function Renderer:code_block(node) 231 | self:render_tag("pre", node) 232 | self.out(" 0 then 234 | self.out(" class=\"language-" .. node.lang .. "\"") 235 | end 236 | self.out(">") 237 | self.out(self:escape_html(node.s)) 238 | self.out("\n") 239 | end 240 | 241 | function Renderer:table(node) 242 | self:render_tag("table", node) 243 | self.out("\n") 244 | self:render_children(node) 245 | self.out("\n") 246 | end 247 | 248 | function Renderer:row(node) 249 | self:render_tag("tr", node) 250 | self.out("\n") 251 | self:render_children(node) 252 | self.out("\n") 253 | end 254 | 255 | function Renderer:cell(node) 256 | local tag 257 | if node.head then 258 | tag = "th" 259 | else 260 | tag = "td" 261 | end 262 | local attr = copy(node.attr) 263 | if node.align then 264 | insert_attribute(attr, "style", "text-align: " .. node.align .. ";") 265 | end 266 | self:render_tag(tag, {attr = attr}) 267 | self:render_children(node) 268 | self.out("\n") 269 | end 270 | 271 | function Renderer:caption(node) 272 | self:render_tag("caption", node) 273 | self:render_children(node) 274 | self.out("\n") 275 | end 276 | 277 | function Renderer:list(node) 278 | local sty = node.style 279 | if sty == "*" or sty == "+" or sty == "-" then 280 | self:render_tag("ul", node) 281 | self.out("\n") 282 | self:render_children(node) 283 | self.out("\n") 284 | elseif sty == "X" then 285 | local attr = copy(node.attr) 286 | if attr.class then 287 | attr.class = "task-list " .. attr.class 288 | else 289 | insert_attribute(attr, "class", "task-list") 290 | end 291 | self:render_tag("ul", {attr = attr}) 292 | self.out("\n") 293 | self:render_children(node) 294 | self.out("\n") 295 | elseif sty == ":" then 296 | self:render_tag("dl", node) 297 | self.out("\n") 298 | self:render_children(node) 299 | self.out("\n") 300 | else 301 | self.out(" 1 then 303 | self.out(" start=\"" .. node.start .. "\"") 304 | end 305 | local list_type = gsub(node.style, "%p", "") 306 | if list_type ~= "1" then 307 | self.out(" type=\"" .. list_type .. "\"") 308 | end 309 | self:render_attrs(node) 310 | self.out(">\n") 311 | self:render_children(node) 312 | self.out("\n") 313 | end 314 | end 315 | 316 | function Renderer:list_item(node) 317 | if node.checkbox then 318 | if node.checkbox == "checked" then 319 | self.out('
  • ') 320 | elseif node.checkbox == "unchecked" then 321 | self.out('
  • ') 322 | end 323 | else 324 | self:render_tag("li", node) 325 | end 326 | self.out("\n") 327 | self:render_children(node) 328 | self.out("
  • \n") 329 | end 330 | 331 | function Renderer:term(node) 332 | self:render_tag("dt", node) 333 | self:render_children(node) 334 | self.out("\n") 335 | end 336 | 337 | function Renderer:definition(node) 338 | self:render_tag("dd", node) 339 | self.out("\n") 340 | self:render_children(node) 341 | self.out("\n") 342 | end 343 | 344 | function Renderer:definition_list_item(node) 345 | self:render_children(node) 346 | end 347 | 348 | function Renderer:reference_definition() 349 | end 350 | 351 | function Renderer:footnote_reference(node) 352 | local label = node.s 353 | local index = self.footnote_index[label] 354 | if not index then 355 | index = self.next_footnote_index 356 | self.footnote_index[label] = index 357 | self.next_footnote_index = self.next_footnote_index + 1 358 | end 359 | self.out(format('%d', index, index, index)) 360 | end 361 | 362 | function Renderer:raw_inline(node) 363 | if node.format == "html" then 364 | self.out(node.s) -- no escaping 365 | end 366 | end 367 | 368 | function Renderer:str(node) 369 | -- add a span, if needed, to contain attribute on a bare string: 370 | if node.attr then 371 | self:render_tag("span", node) 372 | self.out(self:escape_html(node.s)) 373 | self.out("") 374 | else 375 | self.out(self:escape_html(node.s)) 376 | end 377 | end 378 | 379 | function Renderer:softbreak() 380 | self.out("\n") 381 | end 382 | 383 | function Renderer:hardbreak() 384 | self.out("
    \n") 385 | end 386 | 387 | function Renderer:nbsp() 388 | self.out(" ") 389 | end 390 | 391 | function Renderer:verbatim(node) 392 | self:render_tag("code", node) 393 | self.out(self:escape_html(node.s)) 394 | self.out("") 395 | end 396 | 397 | function Renderer:link(node) 398 | local attrs = new_attributes{} 399 | if node.reference then 400 | local ref = self.references[node.reference] 401 | if ref then 402 | if ref.attr then 403 | copy_attributes(attrs, ref.attr) 404 | end 405 | insert_attribute(attrs, "href", ref.destination) 406 | end 407 | elseif node.destination then 408 | insert_attribute(attrs, "href", node.destination) 409 | end 410 | -- link's attributes override reference's: 411 | copy_attributes(attrs, node.attr) 412 | self:render_tag("a", {attr = attrs}) 413 | self:render_children(node) 414 | self.out("") 415 | end 416 | 417 | Renderer.url = Renderer.link 418 | 419 | Renderer.email = Renderer.link 420 | 421 | function Renderer:image(node) 422 | local attrs = new_attributes{} 423 | local alt_text = to_text(node) 424 | if #alt_text > 0 then 425 | insert_attribute(attrs, "alt", to_text(node)) 426 | end 427 | if node.reference then 428 | local ref = self.references[node.reference] 429 | if ref then 430 | if ref.attr then 431 | copy_attributes(attrs, ref.attr) 432 | end 433 | insert_attribute(attrs, "src", ref.destination) 434 | end 435 | elseif node.destination then 436 | insert_attribute(attrs, "src", node.destination) 437 | end 438 | -- image's attributes override reference's: 439 | copy_attributes(attrs, node.attr) 440 | self:render_tag("img", {attr = attrs}) 441 | end 442 | 443 | function Renderer:span(node) 444 | self:render_tag("span", node) 445 | self:render_children(node) 446 | self.out("") 447 | end 448 | 449 | function Renderer:mark(node) 450 | self:render_tag("mark", node) 451 | self:render_children(node) 452 | self.out("") 453 | end 454 | 455 | function Renderer:insert(node) 456 | self:render_tag("ins", node) 457 | self:render_children(node) 458 | self.out("") 459 | end 460 | 461 | function Renderer:delete(node) 462 | self:render_tag("del", node) 463 | self:render_children(node) 464 | self.out("") 465 | end 466 | 467 | function Renderer:subscript(node) 468 | self:render_tag("sub", node) 469 | self:render_children(node) 470 | self.out("") 471 | end 472 | 473 | function Renderer:superscript(node) 474 | self:render_tag("sup", node) 475 | self:render_children(node) 476 | self.out("") 477 | end 478 | 479 | function Renderer:emph(node) 480 | self:render_tag("em", node) 481 | self:render_children(node) 482 | self.out("") 483 | end 484 | 485 | function Renderer:strong(node) 486 | self:render_tag("strong", node) 487 | self:render_children(node) 488 | self.out("") 489 | end 490 | 491 | function Renderer:double_quoted(node) 492 | self.out("“") 493 | self:render_children(node) 494 | self.out("”") 495 | end 496 | 497 | function Renderer:single_quoted(node) 498 | self.out("‘") 499 | self:render_children(node) 500 | self.out("’") 501 | end 502 | 503 | function Renderer:left_double_quote() 504 | self.out("“") 505 | end 506 | 507 | function Renderer:right_double_quote() 508 | self.out("”") 509 | end 510 | 511 | function Renderer:left_single_quote() 512 | self.out("‘") 513 | end 514 | 515 | function Renderer:right_single_quote() 516 | self.out("’") 517 | end 518 | 519 | function Renderer:ellipses() 520 | self.out("…") 521 | end 522 | 523 | function Renderer:em_dash() 524 | self.out("—") 525 | end 526 | 527 | function Renderer:en_dash() 528 | self.out("–") 529 | end 530 | 531 | function Renderer:symbol(node) 532 | self.out(":" .. node.alias .. ":") 533 | end 534 | 535 | function Renderer:math(node) 536 | local math_t = "inline" 537 | if find(node.attr.class, "display") then 538 | math_t = "display" 539 | end 540 | self:render_tag("span", node) 541 | if math_t == "inline" then 542 | self.out("\\(") 543 | else 544 | self.out("\\[") 545 | end 546 | self.out(self:escape_html(node.s)) 547 | if math_t == "inline" then 548 | self.out("\\)") 549 | else 550 | self.out("\\]") 551 | end 552 | self.out("") 553 | end 554 | 555 | return { Renderer = Renderer } 556 | -------------------------------------------------------------------------------- /lua/web-server/djot/inline.lua: -------------------------------------------------------------------------------- 1 | -- This file has been adapted from John MacFarlane's djot.lua. 2 | -- 3 | -- Author: John MacFarlane 4 | -- License: MIT 5 | -- 6 | 7 | -- this allows the code to work with both lua and luajit: 8 | local unpack = unpack or table.unpack 9 | local attributes = require("web-server.djot.attributes") 10 | local find, byte = string.find, string.byte 11 | 12 | -- allow up to 3 captures... 13 | local function bounded_find(subj, patt, startpos, endpos) 14 | local sp,ep,c1,c2,c3 = find(subj, patt, startpos) 15 | if ep and ep <= endpos then 16 | return sp,ep,c1,c2,c3 17 | end 18 | end 19 | 20 | -- General note on the parsing strategy: our objective is to 21 | -- parse without backtracking. To that end, we keep a stack of 22 | -- potential 'openers' for links, images, emphasis, and other 23 | -- inline containers. When we parse a potential closer for 24 | -- one of these constructions, we can scan the stack of openers 25 | -- for a match, which will tell us the location of the potential 26 | -- opener. We can then change the annotation of the match at 27 | -- that location to '+emphasis' or whatever. 28 | 29 | local InlineParser = {} 30 | 31 | function InlineParser:new(subject, warn) 32 | local state = 33 | { warn = warn or function() end, -- function to issue warnings 34 | subject = subject, -- text to parse 35 | matches = {}, -- table pos : (endpos, annotation) 36 | openers = {}, -- map from closer_type to array of (pos, data) in reverse order 37 | verbatim = 0, -- parsing verbatim span to be ended by n backticks 38 | verbatim_type = nil, -- whether verbatim is math or regular 39 | destination = false, -- parsing link destination in () 40 | firstpos = 0, -- position of first slice 41 | lastpos = 0, -- position of last slice 42 | allow_attributes = true, -- allow parsing of attributes 43 | attribute_parser = nil, -- attribute parser 44 | attribute_start = nil, -- start of potential attribute 45 | attribute_slices = nil, -- slices we've tried to parse as attributes 46 | } 47 | setmetatable(state, self) 48 | self.__index = self 49 | return state 50 | end 51 | 52 | function InlineParser:add_match(startpos, endpos, annotation) 53 | self.matches[startpos] = {startpos, endpos, annotation} 54 | end 55 | 56 | function InlineParser:add_opener(name, ...) 57 | -- 1 = startpos, 2 = endpos, 3 = annotation, 4 = substartpos, 5 = endpos 58 | -- 59 | -- [link text](url) 60 | -- ^ ^^ 61 | -- 1,2 4 5 3 = "explicit_link" 62 | 63 | if not self.openers[name] then 64 | self.openers[name] = {} 65 | end 66 | table.insert(self.openers[name], {...}) 67 | end 68 | 69 | function InlineParser:clear_openers(startpos, endpos) 70 | -- remove other openers in between the matches 71 | for _,v in pairs(self.openers) do 72 | local i = #v 73 | while v[i] do 74 | local sp,ep,_,sp2,ep2 = unpack(v[i]) 75 | if sp >= startpos and ep <= endpos then 76 | v[i] = nil 77 | elseif (sp2 and sp2 >= startpos) and (ep2 and ep2 <= endpos) then 78 | v[i][3] = nil 79 | v[i][4] = nil 80 | v[i][5] = nil 81 | else 82 | break 83 | end 84 | i = i - 1 85 | end 86 | end 87 | end 88 | 89 | function InlineParser:str_matches(startpos, endpos) 90 | for i = startpos, endpos do 91 | local m = self.matches[i] 92 | if m then 93 | local sp, ep, annot = unpack(m) 94 | if annot ~= "str" and annot ~= "escape" then 95 | self.matches[i] = {sp, ep, "str"} 96 | end 97 | end 98 | end 99 | end 100 | 101 | local function matches_pattern(match, patt) 102 | if match then 103 | return string.find(match[3], patt) 104 | end 105 | end 106 | 107 | 108 | function InlineParser.between_matched(c, annotation, defaultmatch, opentest) 109 | return function(self, pos, endpos) 110 | defaultmatch = defaultmatch or "str" 111 | local subject = self.subject 112 | local can_open = find(subject, "^%S", pos + 1) 113 | local can_close = find(subject, "^%S", pos - 1) 114 | local has_open_marker = matches_pattern(self.matches[pos - 1], "^open%_marker") 115 | local has_close_marker = pos + 1 <= endpos and 116 | byte(subject, pos + 1) == 125 -- } 117 | local endcloser = pos 118 | local startopener = pos 119 | 120 | if type(opentest) == "function" then 121 | can_open = can_open and opentest(self, pos) 122 | end 123 | 124 | -- allow explicit open/close markers to override: 125 | if has_open_marker then 126 | can_open = true 127 | can_close = false 128 | startopener = pos - 1 129 | end 130 | if not has_open_marker and has_close_marker then 131 | can_close = true 132 | can_open = false 133 | endcloser = pos + 1 134 | end 135 | 136 | if has_open_marker and defaultmatch:match("^right") then 137 | defaultmatch = defaultmatch:gsub("^right", "left") 138 | elseif has_close_marker and defaultmatch:match("^left") then 139 | defaultmatch = defaultmatch:gsub("^left", "right") 140 | end 141 | 142 | local d 143 | if has_close_marker then 144 | d = "{" .. c 145 | else 146 | d = c 147 | end 148 | local openers = self.openers[d] 149 | if can_close and openers and #openers > 0 then 150 | -- check openers for a match 151 | local openpos, openposend = unpack(openers[#openers]) 152 | if openposend ~= pos - 1 then -- exclude empty emph 153 | self:clear_openers(openpos, pos) 154 | self:add_match(openpos, openposend, "+" .. annotation) 155 | self:add_match(pos, endcloser, "-" .. annotation) 156 | return endcloser + 1 157 | end 158 | end 159 | 160 | -- if we get here, we didn't match an opener 161 | if can_open then 162 | if has_open_marker then 163 | d = "{" .. c 164 | else 165 | d = c 166 | end 167 | self:add_opener(d, startopener, pos) 168 | self:add_match(startopener, pos, defaultmatch) 169 | return pos + 1 170 | else 171 | self:add_match(pos, endcloser, defaultmatch) 172 | return endcloser + 1 173 | end 174 | end 175 | end 176 | 177 | InlineParser.matchers = { 178 | -- 96 = ` 179 | [96] = function(self, pos, endpos) 180 | local subject = self.subject 181 | local _, endchar = bounded_find(subject, "^`*", pos, endpos) 182 | if not endchar then 183 | return nil 184 | end 185 | if find(subject, "^%$%$", pos - 2) and 186 | not find(subject, "^\\", pos - 3) then 187 | self.matches[pos - 2] = nil 188 | self.matches[pos - 1] = nil 189 | self:add_match(pos - 2, endchar, "+display_math") 190 | self.verbatim_type = "display_math" 191 | elseif find(subject, "^%$", pos - 1) then 192 | self.matches[pos - 1] = nil 193 | self:add_match(pos - 1, endchar, "+inline_math") 194 | self.verbatim_type = "inline_math" 195 | else 196 | self:add_match(pos, endchar, "+verbatim") 197 | self.verbatim_type = "verbatim" 198 | end 199 | self.verbatim = endchar - pos + 1 200 | return endchar + 1 201 | end, 202 | 203 | -- 92 = \ 204 | [92] = function(self, pos, endpos) 205 | local subject = self.subject 206 | local _, endchar = bounded_find(subject, "^[ \t]*\r?\n", pos + 1, endpos) 207 | self:add_match(pos, pos, "escape") 208 | if endchar then 209 | -- see if there were preceding spaces 210 | if #self.matches > 0 then 211 | local sp, ep, annot = unpack(self.matches[#self.matches]) 212 | if annot == "str" then 213 | while ep >= sp and 214 | (subject:byte(ep) == 32 or subject:byte(ep) == 9) do 215 | ep = ep -1 216 | end 217 | if ep < sp then 218 | self.matches[#self.matches] = nil 219 | else 220 | self:add_match(sp, ep, "str") 221 | end 222 | end 223 | end 224 | self:add_match(pos + 1, endchar, "hardbreak") 225 | return endchar + 1 226 | else 227 | local _, ec = bounded_find(subject, "^[%p ]", pos + 1, endpos) 228 | if not ec then 229 | self:add_match(pos, pos, "str") 230 | return pos + 1 231 | else 232 | self:add_match(pos, pos, "escape") 233 | if find(subject, "^ ", pos + 1) then 234 | self:add_match(pos + 1, ec, "nbsp") 235 | else 236 | self:add_match(pos + 1, ec, "str") 237 | end 238 | return ec + 1 239 | end 240 | end 241 | end, 242 | 243 | -- 60 = < 244 | [60] = function(self, pos, endpos) 245 | local subject = self.subject 246 | local starturl, endurl = 247 | bounded_find(subject, "^%<[^<>%s]+%>", pos, endpos) 248 | if starturl then 249 | local is_url = bounded_find(subject, "^%a+:", pos + 1, endurl) 250 | local is_email = bounded_find(subject, "^[^:]+%@", pos + 1, endurl) 251 | if is_email then 252 | self:add_match(starturl, starturl, "+email") 253 | self:add_match(starturl + 1, endurl - 1, "str") 254 | self:add_match(endurl, endurl, "-email") 255 | return endurl + 1 256 | elseif is_url then 257 | self:add_match(starturl, starturl, "+url") 258 | self:add_match(starturl + 1, endurl - 1, "str") 259 | self:add_match(endurl, endurl, "-url") 260 | return endurl + 1 261 | end 262 | end 263 | end, 264 | 265 | -- 126 = ~ 266 | [126] = InlineParser.between_matched('~', 'subscript'), 267 | 268 | -- 94 = ^ 269 | [94] = InlineParser.between_matched('^', 'superscript'), 270 | 271 | -- 91 = [ 272 | [91] = function(self, pos, endpos) 273 | local sp, ep = bounded_find(self.subject, "^%^([^]]+)%]", pos + 1, endpos) 274 | if sp then -- footnote ref 275 | self:add_match(pos, ep, "footnote_reference") 276 | return ep + 1 277 | else 278 | self:add_opener("[", pos, pos) 279 | self:add_match(pos, pos, "str") 280 | return pos + 1 281 | end 282 | end, 283 | 284 | -- 93 = ] 285 | [93] = function(self, pos, endpos) 286 | local openers = self.openers["["] 287 | local subject = self.subject 288 | if openers and #openers > 0 then 289 | local opener = openers[#openers] 290 | if opener[3] == "reference_link" then 291 | -- found a reference link 292 | -- add the matches 293 | local is_image = bounded_find(subject, "^!", opener[1] - 1, endpos) 294 | and not bounded_find(subject, "^[\\]", opener[1] - 2, endpos) 295 | if is_image then 296 | self:add_match(opener[1] - 1, opener[1] - 1, "image_marker") 297 | self:add_match(opener[1], opener[2], "+imagetext") 298 | self:add_match(opener[4], opener[4], "-imagetext") 299 | else 300 | self:add_match(opener[1], opener[2], "+linktext") 301 | self:add_match(opener[4], opener[4], "-linktext") 302 | end 303 | self:add_match(opener[5], opener[5], "+reference") 304 | self:add_match(pos, pos, "-reference") 305 | -- convert all matches to str 306 | self:str_matches(opener[5] + 1, pos - 1) 307 | -- remove from openers 308 | self:clear_openers(opener[1], pos) 309 | return pos + 1 310 | elseif bounded_find(subject, "^%[", pos + 1, endpos) then 311 | opener[3] = "reference_link" 312 | opener[4] = pos -- intermediate ] 313 | opener[5] = pos + 1 -- intermediate [ 314 | self:add_match(pos, pos + 1, "str") 315 | -- remove any openers between [ and ] 316 | self:clear_openers(opener[1] + 1, pos - 1) 317 | return pos + 2 318 | elseif bounded_find(subject, "^%(", pos + 1, endpos) then 319 | self.openers["("] = {} -- clear ( openers 320 | opener[3] = "explicit_link" 321 | opener[4] = pos -- intermediate ] 322 | opener[5] = pos + 1 -- intermediate ( 323 | self.destination = true 324 | self:add_match(pos, pos + 1, "str") 325 | -- remove any openers between [ and ] 326 | self:clear_openers(opener[1] + 1, pos - 1) 327 | return pos + 2 328 | elseif bounded_find(subject, "^%{", pos + 1, endpos) then 329 | -- assume this is attributes, bracketed span 330 | self:add_match(opener[1], opener[2], "+span") 331 | self:add_match(pos, pos, "-span") 332 | -- remove any openers between [ and ] 333 | self:clear_openers(opener[1], pos) 334 | return pos + 1 335 | end 336 | end 337 | end, 338 | 339 | 340 | -- 40 = ( 341 | [40] = function(self, pos) 342 | if not self.destination then return nil end 343 | self:add_opener("(", pos, pos) 344 | self:add_match(pos, pos, "str") 345 | return pos + 1 346 | end, 347 | 348 | -- 41 = ) 349 | [41] = function(self, pos, endpos) 350 | if not self.destination then return nil end 351 | local parens = self.openers["("] 352 | if parens and #parens > 0 and parens[#parens][1] then 353 | parens[#parens] = nil -- clear opener 354 | self:add_match(pos, pos, "str") 355 | return pos + 1 356 | else 357 | local subject = self.subject 358 | local openers = self.openers["["] 359 | if openers and #openers > 0 360 | and openers[#openers][3] == "explicit_link" then 361 | local opener = openers[#openers] 362 | -- we have inline link 363 | local is_image = bounded_find(subject, "^!", opener[1] - 1, endpos) 364 | and not bounded_find(subject, "^[\\]", opener[1] - 2, endpos) 365 | if is_image then 366 | self:add_match(opener[1] - 1, opener[1] - 1, "image_marker") 367 | self:add_match(opener[1], opener[2], "+imagetext") 368 | self:add_match(opener[4], opener[4], "-imagetext") 369 | else 370 | self:add_match(opener[1], opener[2], "+linktext") 371 | self:add_match(opener[4], opener[4], "-linktext") 372 | end 373 | self:add_match(opener[5], opener[5], "+destination") 374 | self:add_match(pos, pos, "-destination") 375 | self.destination = false 376 | -- convert all matches to str 377 | self:str_matches(opener[5] + 1, pos - 1) 378 | -- remove from openers 379 | self:clear_openers(opener[1], pos) 380 | return pos + 1 381 | end 382 | end 383 | end, 384 | 385 | -- 95 = _ 386 | [95] = InlineParser.between_matched('_', 'emph'), 387 | 388 | -- 42 = * 389 | [42] = InlineParser.between_matched('*', 'strong'), 390 | 391 | -- 123 = { 392 | [123] = function(self, pos, endpos) 393 | if bounded_find(self.subject, "^[_*~^+='\"-]", pos + 1, endpos) then 394 | self:add_match(pos, pos, "open_marker") 395 | return pos + 1 396 | elseif self.allow_attributes then 397 | self.attribute_parser = attributes.AttributeParser:new(self.subject) 398 | self.attribute_start = pos 399 | self.attribute_slices = {} 400 | return pos 401 | else 402 | self:add_match(pos, pos, "str") 403 | return pos + 1 404 | end 405 | end, 406 | 407 | -- 58 = : 408 | [58] = function(self, pos, endpos) 409 | local sp, ep = bounded_find(self.subject, "^%:[%w_+-]+%:", pos, endpos) 410 | if sp then 411 | self:add_match(sp, ep, "symbol") 412 | return ep + 1 413 | else 414 | self:add_match(pos, pos, "str") 415 | return pos + 1 416 | end 417 | end, 418 | 419 | -- 43 = + 420 | [43] = InlineParser.between_matched("+", "insert", "str", 421 | function(self, pos) 422 | return find(self.subject, "^%{", pos - 1) or 423 | find(self.subject, "^%}", pos + 1) 424 | end), 425 | 426 | -- 61 = = 427 | [61] = InlineParser.between_matched("=", "mark", "str", 428 | function(self, pos) 429 | return find(self.subject, "^%{", pos - 1) or 430 | find(self.subject, "^%}", pos + 1) 431 | end), 432 | 433 | -- 39 = ' 434 | [39] = InlineParser.between_matched("'", "single_quoted", "right_single_quote", 435 | function(self, pos) -- test to open 436 | return pos == 1 or 437 | find(self.subject, "^[%s\"'-([]", pos - 1) 438 | end), 439 | 440 | -- 34 = " 441 | [34] = InlineParser.between_matched('"', "double_quoted", "left_double_quote"), 442 | 443 | -- 45 = - 444 | [45] = function(self, pos, endpos) 445 | local subject = self.subject 446 | local nextpos 447 | if byte(subject, pos - 1) == 123 or 448 | byte(subject, pos + 1) == 125 then -- (123 = { 125 = }) 449 | nextpos = InlineParser.between_matched("-", "delete", "str", 450 | function(slf, p) 451 | return find(slf.subject, "^%{", p - 1) or 452 | find(slf.subject, "^%}", p + 1) 453 | end)(self, pos, endpos) 454 | return nextpos 455 | end 456 | -- didn't match a del, try for smart hyphens: 457 | local _, ep = find(subject, "^%-*", pos) 458 | if endpos < ep then 459 | ep = endpos 460 | end 461 | local hyphens = 1 + ep - pos 462 | if byte(subject, ep + 1) == 125 then -- 125 = } 463 | hyphens = hyphens - 1 -- last hyphen is close del 464 | end 465 | if hyphens == 0 then -- this means we have '-}' 466 | self:add_match(pos, pos + 1, "str") 467 | return pos + 2 468 | end 469 | -- Try to construct a homogeneous sequence of dashes 470 | local all_em = hyphens % 3 == 0 471 | local all_en = hyphens % 2 == 0 472 | while hyphens > 0 do 473 | if all_em then 474 | self:add_match(pos, pos + 2, "em_dash") 475 | pos = pos + 3 476 | hyphens = hyphens - 3 477 | elseif all_en then 478 | self:add_match(pos, pos + 1, "en_dash") 479 | pos = pos + 2 480 | hyphens = hyphens - 2 481 | elseif hyphens >= 3 and (hyphens % 2 ~= 0 or hyphens > 4) then 482 | self:add_match(pos, pos + 2, "em_dash") 483 | pos = pos + 3 484 | hyphens = hyphens - 3 485 | elseif hyphens >= 2 then 486 | self:add_match(pos, pos + 1, "en_dash") 487 | pos = pos + 2 488 | hyphens = hyphens - 2 489 | else 490 | self:add_match(pos, pos, "str") 491 | pos = pos + 1 492 | hyphens = hyphens - 1 493 | end 494 | end 495 | return pos 496 | end, 497 | 498 | -- 46 = . 499 | [46] = function(self, pos, endpos) 500 | if bounded_find(self.subject, "^%.%.", pos + 1, endpos) then 501 | self:add_match(pos, pos +2, "ellipses") 502 | return pos + 3 503 | end 504 | end 505 | } 506 | 507 | function InlineParser:single_char(pos) 508 | self:add_match(pos, pos, "str") 509 | return pos + 1 510 | end 511 | 512 | -- Reparse attribute_slices that we tried to parse as an attribute 513 | function InlineParser:reparse_attributes() 514 | local slices = self.attribute_slices 515 | if not slices then 516 | return 517 | end 518 | self.allow_attributes = false 519 | self.attribute_parser = nil 520 | self.attribute_start = nil 521 | if slices then 522 | for i=1,#slices do 523 | self:feed(unpack(slices[i])) 524 | end 525 | end 526 | self.allow_attributes = true 527 | self.attribute_slices = nil 528 | end 529 | 530 | -- Feed a slice to the parser, updating state. 531 | function InlineParser:feed(spos, endpos) 532 | local special = "[][\\`{}_*()!<>~^:=+$\r\n'\".-]" 533 | local subject = self.subject 534 | local matchers = self.matchers 535 | local pos 536 | if self.firstpos == 0 or spos < self.firstpos then 537 | self.firstpos = spos 538 | end 539 | if self.lastpos == 0 or endpos > self.lastpos then 540 | self.lastpos = endpos 541 | end 542 | pos = spos 543 | while pos <= endpos do 544 | if self.attribute_parser then 545 | local sp = pos 546 | local ep2 = bounded_find(subject, special, pos, endpos) 547 | if not ep2 or ep2 > endpos then 548 | ep2 = endpos 549 | end 550 | local status, ep = self.attribute_parser:feed(sp, ep2) 551 | if status == "done" then 552 | local attribute_start = self.attribute_start 553 | -- add attribute matches 554 | self:add_match(attribute_start, attribute_start, "+attributes") 555 | self:add_match(ep, ep, "-attributes") 556 | local attr_matches = self.attribute_parser:get_matches() 557 | -- add attribute matches 558 | for i=1,#attr_matches do 559 | self:add_match(unpack(attr_matches[i])) 560 | end 561 | -- restore state to prior to adding attribute parser: 562 | self.attribute_parser = nil 563 | self.attribute_start = nil 564 | self.attribute_slices = nil 565 | pos = ep + 1 566 | elseif status == "fail" then 567 | self:reparse_attributes() 568 | pos = sp -- we'll want to go over the whole failed portion again, 569 | -- as no slice was added for it 570 | elseif status == "continue" then 571 | if #self.attribute_slices == 0 then 572 | self.attribute_slices = {} 573 | end 574 | self.attribute_slices[#self.attribute_slices + 1] = {sp,ep} 575 | pos = ep + 1 576 | end 577 | else 578 | -- find next interesting character: 579 | local newpos = bounded_find(subject, special, pos, endpos) or endpos + 1 580 | if newpos > pos then 581 | self:add_match(pos, newpos - 1, "str") 582 | pos = newpos 583 | if pos > endpos then 584 | break -- otherwise, fall through: 585 | end 586 | end 587 | -- if we get here, then newpos = pos, 588 | -- i.e. we have something interesting at pos 589 | local c = byte(subject, pos) 590 | 591 | if c == 13 or c == 10 then -- cr or lf 592 | if c == 13 and bounded_find(subject, "^[%n]", pos + 1, endpos) then 593 | self:add_match(pos, pos + 1, "softbreak") 594 | pos = pos + 2 595 | else 596 | self:add_match(pos, pos, "softbreak") 597 | pos = pos + 1 598 | end 599 | elseif self.verbatim > 0 then 600 | if c == 96 then 601 | local _, endchar = bounded_find(subject, "^`+", pos, endpos) 602 | if endchar and endchar - pos + 1 == self.verbatim then 603 | -- check for raw attribute 604 | local sp, ep = 605 | bounded_find(subject, "^%{%=[^%s{}`]+%}", endchar + 1, endpos) 606 | if sp and self.verbatim_type == "verbatim" then -- raw 607 | self:add_match(pos, endchar, "-" .. self.verbatim_type) 608 | self:add_match(sp, ep, "raw_format") 609 | pos = ep + 1 610 | else 611 | self:add_match(pos, endchar, "-" .. self.verbatim_type) 612 | pos = endchar + 1 613 | end 614 | self.verbatim = 0 615 | self.verbatim_type = nil 616 | else 617 | endchar = endchar or endpos 618 | self:add_match(pos, endchar, "str") 619 | pos = endchar + 1 620 | end 621 | else 622 | self:add_match(pos, pos, "str") 623 | pos = pos + 1 624 | end 625 | else 626 | local matcher = matchers[c] 627 | pos = (matcher and matcher(self, pos, endpos)) or self:single_char(pos) 628 | end 629 | end 630 | end 631 | end 632 | 633 | -- Return true if we're parsing verbatim content. 634 | function InlineParser:in_verbatim() 635 | return self.verbatim > 0 636 | end 637 | 638 | function InlineParser:get_matches() 639 | local sorted = {} 640 | local subject = self.subject 641 | local lastsp, lastep, lastannot 642 | if self.attribute_parser then -- we're still in an attribute parse 643 | self:reparse_attributes() 644 | end 645 | for i=self.firstpos, self.lastpos do 646 | if self.matches[i] then 647 | local sp, ep, annot = unpack(self.matches[i]) 648 | if annot == "str" and lastannot == "str" and lastep + 1 == sp then 649 | -- consolidate adjacent strs 650 | sorted[#sorted] = {lastsp, ep, annot} 651 | lastsp, lastep, lastannot = lastsp, ep, annot 652 | else 653 | sorted[#sorted + 1] = self.matches[i] 654 | lastsp, lastep, lastannot = sp, ep, annot 655 | end 656 | end 657 | end 658 | if #sorted > 0 then 659 | local last = sorted[#sorted] 660 | local startpos, endpos, annot = unpack(last) 661 | -- remove final softbreak 662 | if annot == "softbreak" then 663 | sorted[#sorted] = nil 664 | last = sorted[#sorted] 665 | if not last then 666 | return sorted 667 | end 668 | startpos, endpos, annot = unpack(last) 669 | end 670 | -- remove trailing spaces 671 | if annot == "str" and byte(subject, endpos) == 32 then 672 | while endpos > startpos and byte(subject, endpos) == 32 do 673 | endpos = endpos - 1 674 | end 675 | sorted[#sorted] = {startpos, endpos, annot} 676 | end 677 | if self.verbatim > 0 then -- unclosed verbatim 678 | self.warn({ message = "Unclosed verbatim", pos = endpos }) 679 | sorted[#sorted + 1] = {endpos, endpos, "-" .. self.verbatim_type} 680 | end 681 | end 682 | return sorted 683 | end 684 | 685 | return { InlineParser = InlineParser } 686 | -------------------------------------------------------------------------------- /lua/web-server/init.lua: -------------------------------------------------------------------------------- 1 | --- An HTTP server for Neovim. 2 | -- @module web-server 3 | -- @author Gábor Nyéki 4 | -- @license MIT 5 | -- 6 | 7 | local default_config = { 8 | host = "127.0.0.1", 9 | port = 4999, 10 | log_filename = nil, 11 | log_each_request = false, 12 | log_views_period = 0, 13 | log_resource_use_period = 0, 14 | 15 | -- NOTE Keep-alive means that we run out of sockets real quick under 16 | -- load, and clients will begin getting "connection reset by peer" 17 | -- errors. 18 | -- 19 | keep_alive = false 20 | } 21 | 22 | local Djotter = require("web-server.djotter") 23 | local Path = require("web-server.path") 24 | local cmd_error = require("web-server.common").cmd_error 25 | 26 | local djotter = nil 27 | 28 | --- Module class. 29 | local M = {} 30 | 31 | --- Server configuration. 32 | -- @field host (string) IP address to listen on 33 | -- @field port (integer) TCP port to listen on 34 | -- @field[opt] log_filename (string) file to save the server's log 35 | -- buffer 36 | -- @field log_each_request (boolean) include requests in the server log 37 | -- @field log_views_period (integer) interval at which to log view 38 | -- counts for each path (in minutes) 39 | -- @field keep_alive (boolean) whether to support Connection: keep-alive 40 | -- @table config 41 | M.config = vim.deepcopy(default_config) 42 | 43 | --- Manages a buffer that contains the server log. 44 | -- @field buf_id (integer) the ID of the log buffer 45 | -- @field win_id (integer) the ID of the window in which the log buffer 46 | -- is displayed 47 | -- @field empty (boolean) whether the log buffer is currently empty 48 | -- @field[opt] timer (uv_timer_t userdata) a timer that periodically 49 | -- saves the buffer to the file specified by `log_filename` in the 50 | -- server configuration 51 | local Logger = {} 52 | 53 | function Logger.new(filename) 54 | local buf_id = vim.api.nvim_create_buf(true, true) 55 | local timer = nil 56 | local empty = true 57 | 58 | if filename then 59 | -- Open `filename`. 60 | -- 61 | vim.api.nvim_buf_call(buf_id, function() 62 | vim.cmd.edit(filename) 63 | empty = vim.fn.wordcount().bytes == 0 64 | end) 65 | 66 | -- Save the log buffer to `filename` every 5 minutes. 67 | -- 68 | local dur_5m = 5 * 60 * 1000 69 | timer = vim.uv.new_timer() 70 | timer:start(dur_5m, dur_5m, vim.schedule_wrap(function() 71 | vim.api.nvim_buf_call(buf_id, function() 72 | vim.cmd.write() 73 | end) 74 | end)) 75 | end 76 | 77 | local win_id = vim.api.nvim_open_win(buf_id, 0, { split = "above" }) 78 | local state = { 79 | buf_id = buf_id, 80 | win_id = win_id, 81 | empty = empty, 82 | timer = timer 83 | } 84 | return setmetatable(state, { __index = Logger }) 85 | end 86 | 87 | local function escape(str) 88 | return str:gsub("\r", "\\r"):gsub("\n", "\\n") 89 | end 90 | 91 | --- Adds a line to the log buffer, prepending a timestamp. 92 | local function print_to_log(self, ...) 93 | local message = string.format(...) 94 | 95 | vim.schedule(function() 96 | local line = ( 97 | vim.fn.strftime("[%Y-%m-%d %H:%M:%S] ") .. escape(message) 98 | ) 99 | 100 | vim.api.nvim_buf_set_lines(self.buf_id, -1, -1, true, { line }) 101 | 102 | if self.empty then 103 | -- If the log buffer was empty, then the first message was 104 | -- appended to an empty line. We want to delete that empty 105 | -- line. 106 | vim.api.nvim_buf_set_lines(self.buf_id, 0, 1, true, {}) 107 | self.empty = false 108 | end 109 | end) 110 | end 111 | 112 | --- Adds a piece of information to the log buffer. 113 | -- @function Logger:info 114 | Logger.info = print_to_log 115 | 116 | --- Adds a client request to the log buffer. 117 | -- Overwritten if `log_each_request` is `false` in the server 118 | -- configuration. 119 | -- @function Logger:request 120 | Logger.request = print_to_log 121 | 122 | local log = nil 123 | 124 | -- TODO Wrap network I/O calls in `pcall()` in case of failure? 125 | 126 | local function create_server(host, port, on_connect) 127 | log:info("Initializing server at %s:%d.", host, port) 128 | 129 | local server = vim.uv.new_tcp() 130 | server:bind(host, port) 131 | server:listen(1024, function(error) 132 | assert(not error, error) 133 | 134 | local socket = vim.uv.new_tcp() 135 | server:accept(socket) 136 | 137 | on_connect(socket) 138 | end) 139 | return server 140 | end 141 | 142 | --- An HTTP response. 143 | -- @field status (integer) HTTP status code 144 | -- @field value (string) response header and body to send to the client 145 | local Response = { status = nil, value = nil } 146 | 147 | local response_connection = "Connection: keep-alive\n" 148 | 149 | --- Status code 200. 150 | function Response.ok(proto, etag, content_type, content) 151 | return setmetatable({ 152 | status = 200, 153 | value = string.format( 154 | "%s 200 OK\n" .. 155 | "Server: nvim-web-server\n" .. 156 | 'ETag: "' .. etag .. '"\n' .. 157 | "Content-Type: %s\n" .. 158 | "Content-Length: %d\n" .. 159 | response_connection .. 160 | "\n" .. 161 | "%s", 162 | proto, content_type, content:len(), content 163 | ) 164 | }, { 165 | __index = Response 166 | }) 167 | end 168 | 169 | --- Status code 304. 170 | function Response.not_modified(proto, etag) 171 | return setmetatable({ 172 | status = 304, 173 | value = ( 174 | proto .. " 304 Not Modified\n" .. 175 | "Server: nvim-web-server\n" .. 176 | 'ETag: "' .. etag .. '"\n' .. 177 | response_connection .. 178 | "\n" 179 | ) 180 | }, { 181 | __index = Response 182 | }) 183 | end 184 | 185 | --- Status code 400. 186 | function Response.bad(proto) 187 | local content = ( 188 | "" .. 189 | "" .. 190 | "Bad Request" .. 191 | "" .. 192 | "

    Bad Request

    " .. 193 | "
    " .. 194 | "
    nvim-web-server
    " .. 195 | "" .. 196 | "" .. 197 | "\n" 198 | ) 199 | 200 | return setmetatable({ 201 | status = 400, 202 | value = ( 203 | proto .. " 400 Bad Request\n" .. 204 | "Server: nvim-web-server\n" .. 205 | "Content-Type: text/html\n" .. 206 | "Content-Length: " .. content:len() .. "\n" .. 207 | "Connection: close\n" .. 208 | "\n" .. 209 | content 210 | ) 211 | }, { 212 | __index = Response 213 | }) 214 | end 215 | 216 | --- Status code 404. 217 | function Response.not_found(proto) 218 | local content = ( 219 | "" .. 220 | "" .. 221 | "Not Found" .. 222 | "" .. 223 | "

    Not Found

    " .. 224 | "
    " .. 225 | "
    nvim-web-server
    " .. 226 | "" .. 227 | "" .. 228 | "\n" 229 | ) 230 | 231 | return setmetatable({ 232 | status = 404, 233 | value = ( 234 | proto .. " 404 Not Found\n" .. 235 | "Server: nvim-web-server\n" .. 236 | "Content-Type: text/html\n" .. 237 | "Content-Length: " .. content:len() .. "\n" .. 238 | response_connection .. 239 | "\n" .. 240 | content 241 | ) 242 | }, { 243 | __index = Response 244 | }) 245 | end 246 | 247 | --- Maps paths to content. 248 | -- @field djotter (Djotter) converter from Djot to HTML 249 | -- @field paths (@{paths}) the keys of this table are normalized paths, 250 | -- the values are tables 251 | local Routing = {} 252 | 253 | --- Path routing table. 254 | -- The keys are normalized paths. 255 | -- The values are @{content} tables. 256 | -- @table paths 257 | 258 | --- Content table. 259 | -- @field buf_id (integer) 260 | -- @field buf_name (string) 261 | -- @field buf_type (string) e.g., "text/djot", "image/png" 262 | -- @field content_type (string) e.g., "text/html", "image/png" 263 | -- @field content (string) message body to send to clients 264 | -- @field etag (string) SHA-256 hash of content 265 | -- @field autocmd_id (integer) ID of autocmd that updates content if 266 | -- buffer changes 267 | -- @table content 268 | 269 | --- Constructs an empty routing table with a Djot converter. 270 | function Routing.new(this_djotter) 271 | return setmetatable({ 272 | djotter = this_djotter, 273 | paths = {} 274 | }, { 275 | __index = Routing 276 | }) 277 | end 278 | 279 | function Routing:add_path(path, value) 280 | local normalized = Path.new(path).value 281 | 282 | if self.paths[normalized] then 283 | return false 284 | end 285 | 286 | value.buf_name = ( 287 | vim.api.nvim_buf_get_name(value.buf_id) or "[unnamed]" 288 | ) 289 | 290 | log:info( 291 | "Routing path '%s' to buffer '%s' (%s).", 292 | normalized, 293 | value.buf_name, 294 | value.buf_type 295 | ) 296 | 297 | self.paths[normalized] = value 298 | 299 | return true 300 | end 301 | 302 | local function get_buffer_content(buf_id) 303 | return table.concat( 304 | vim.api.nvim_buf_get_lines(buf_id, 0, -1, true), 305 | "\n" 306 | ) .. "\n" 307 | end 308 | 309 | function Routing:update_content(buf_id) 310 | local path = self:get_path_by_buf_id(buf_id) 311 | 312 | assert(path, string.format( 313 | "Buffer %d has a callback attached but no path routed to it.", 314 | buf_id 315 | )) 316 | 317 | local value = self.paths[path] 318 | 319 | log:info( 320 | "Updating content for path '%s' from buffer '%s'.", 321 | path, 322 | value.buf_name 323 | ) 324 | 325 | local buf_type = value.buf_type 326 | local content 327 | local content_type = buf_type 328 | 329 | if not buf_type:match("^text/") then 330 | local file_path = vim.api.nvim_buf_get_name(0) 331 | local handle, err = io.open(file_path) 332 | 333 | assert(not err, err) 334 | 335 | content = handle:read("*a") 336 | handle:close() 337 | else 338 | content = get_buffer_content(buf_id) 339 | end 340 | 341 | if buf_type == "text/djot" then 342 | content = self.djotter:to_html(content) 343 | content_type = "text/html" 344 | end 345 | 346 | -- For binary files, `content` is a blob, and `vim.fn.sha256` 347 | -- expects a string. 348 | -- 349 | if vim.fn.type(content) == vim.v.t_blob then 350 | self.paths[path].etag = vim.fn.sha256(vim.fn.string(content)) 351 | else 352 | self.paths[path].etag = vim.fn.sha256(content) 353 | end 354 | 355 | self.paths[path].content_type = content_type 356 | self.paths[path].content = content 357 | end 358 | 359 | function Routing:update_djot_paths() 360 | for _, value in pairs(self.paths) do 361 | if value.buf_type == "text/djot" then 362 | self:update_content(value.buf_id) 363 | end 364 | end 365 | end 366 | 367 | function Routing:delete_path(path) 368 | log:info("Deleting path '%s'.", path) 369 | 370 | local value = self.paths[path] 371 | 372 | vim.api.nvim_del_autocmd(value.autocmd_id) 373 | 374 | self.paths[path] = nil 375 | end 376 | 377 | function Routing:has_path(path) 378 | return self.paths[path] ~= nil 379 | end 380 | 381 | function Routing:has_buf_id(buf_id) 382 | for _, value in pairs(self.paths) do 383 | if value.buf_id == buf_id then 384 | return true 385 | end 386 | end 387 | return false 388 | end 389 | 390 | function Routing:get_path_by_buf_id(buf_id) 391 | for path, value in pairs(self.paths) do 392 | if value.buf_id == buf_id then 393 | return path 394 | end 395 | end 396 | return nil 397 | end 398 | 399 | local routing = nil 400 | 401 | --- Parses the first line of the client's request. 402 | -- @param request (string) 403 | -- @return (string) first line of the client's request 404 | -- @return (string) method (e.g., GET, POST) 405 | -- @return (string) path requested by the client 406 | -- @return (string) protocol (e.g., HTTP/1.1) 407 | -- @return (boolean) whether the server considers the request malformed 408 | local function process_request_line(request) 409 | local request_line = request:match("[^\r\n]*") 410 | local method = nil 411 | local path = nil 412 | local proto = nil 413 | local bad = false 414 | 415 | for word in request_line:gmatch("[^ ]+") do 416 | if not method then 417 | method = word 418 | elseif not path then 419 | path = word 420 | elseif not proto then 421 | proto = word 422 | else 423 | bad = true 424 | break 425 | end 426 | end 427 | 428 | if method ~= "GET" or not proto then 429 | bad = true 430 | end 431 | 432 | return request_line, method, path, proto, bad 433 | end 434 | 435 | --- Looks for "If-None-Match" in the client's request header. 436 | -- @param request (string) 437 | -- @return[1] (string) "tag" in "If-None-Match: tag" 438 | -- @return[2] nil if the client sent no "If-None-Match" 439 | local function process_request_header(request) 440 | for line in string.gmatch(request, "[^\r\n]+") do 441 | local field_name = line:match("^If%-None%-Match: *") 442 | 443 | if field_name then 444 | local value = line:sub(field_name:len() + 1):gsub('"', "") 445 | 446 | if value then 447 | return value 448 | end 449 | 450 | break 451 | end 452 | end 453 | end 454 | 455 | --- Parses the client's request and prepares the response. 456 | -- @param request (string) 457 | -- @return (table) request protocol, path, request line, and the 458 | -- corresponding Response object 459 | local function process_request(request) 460 | local request_line, _, path, proto, bad = process_request_line( 461 | request 462 | ) 463 | local response 464 | 465 | if bad then 466 | response = Response.bad(proto or "HTTP/1.1") 467 | else 468 | local if_none_match = process_request_header(request) 469 | local normalized = Path.new(path).value 470 | 471 | if not routing:has_path(normalized) then 472 | response = Response.not_found(proto) 473 | else 474 | local value = routing.paths[normalized] 475 | 476 | if if_none_match and if_none_match == value.etag then 477 | response = Response.not_modified(proto, value.etag) 478 | else 479 | response = Response.ok( 480 | proto, 481 | value.etag, 482 | value.content_type, 483 | value.content 484 | ) 485 | end 486 | end 487 | end 488 | 489 | return { 490 | proto = proto, 491 | path = path, 492 | request = request_line, 493 | response = response 494 | } 495 | end 496 | 497 | --- Command-line command to add the current buffer to the routing table. 498 | local function ws_add_buffer(opts) 499 | if #opts.fargs == 0 or #opts.fargs > 2 then 500 | cmd_error("Usage: :WSAddBuffer [content-type]") 501 | return 502 | end 503 | 504 | local path = opts.fargs[1] 505 | 506 | if not path:match("^/") then 507 | cmd_error("Path '%s' is not absolute.", path) 508 | return 509 | end 510 | 511 | local buf_id = vim.fn.bufnr() 512 | local buf_type = opts.fargs[2] or "text/djot" 513 | local content_type = buf_type 514 | local content = nil 515 | local autocmd_id = vim.api.nvim_create_autocmd("BufWrite", { 516 | buffer = buf_id, 517 | callback = function(arg) routing:update_content(arg.buf) end 518 | }) 519 | 520 | routing:add_path(path, { 521 | buf_id = buf_id, 522 | buf_type = opts.fargs[2] or "text/djot", 523 | content_type = content_type, 524 | content = content, 525 | autocmd_id = autocmd_id 526 | }) 527 | 528 | routing:update_content(buf_id) 529 | end 530 | 531 | --- Command-line command to delete a path from the routing table. 532 | local function ws_delete_path(opts) 533 | if #opts.fargs ~= 1 then 534 | cmd_error("Usage: :WSDeletePath ") 535 | return 536 | end 537 | 538 | local path = opts.fargs[1] 539 | 540 | if not routing:has_path(path) then 541 | cmd_error("Path '%s' does not exist.", path) 542 | else 543 | routing:delete_path(path) 544 | end 545 | end 546 | 547 | --- Command-line command to write all currently routed paths to the 548 | -- server log. 549 | local function ws_paths() 550 | for path, value in pairs(routing.paths) do 551 | local length = 0 552 | if value.content then 553 | length = value.content:len() 554 | end 555 | 556 | log:info( 557 | "Path '%s' is routed to '%s' (%s, length %d).", 558 | path, 559 | value.buf_name, 560 | value.content_type, 561 | length 562 | ) 563 | end 564 | end 565 | 566 | --- Command-line command to set the current buffer as the HTML template 567 | -- used by the Djot converter. 568 | local function ws_set_buffer_as_template() 569 | if djotter.template_buf_name then 570 | log:info( 571 | "Unsetting '%s' as template.", djotter.template_buf_name 572 | ) 573 | 574 | vim.api.nvim_del_autocmd(djotter.template_autocmd_id) 575 | end 576 | 577 | local buf_id = vim.fn.bufnr() 578 | local buf_name = vim.api.nvim_buf_get_name(buf_id) 579 | 580 | log:info("Setting '%s' as template.", buf_name) 581 | 582 | local function update_template() 583 | djotter.template = get_buffer_content(buf_id) 584 | routing:update_djot_paths() 585 | end 586 | 587 | local autocmd_id = vim.api.nvim_create_autocmd("BufWrite", { 588 | buffer = buf_id, 589 | callback = update_template 590 | }) 591 | 592 | djotter.template_buf_name = buf_name 593 | djotter.template_autocmd_id = autocmd_id 594 | 595 | update_template() 596 | end 597 | 598 | local function truncate(str, max_len) 599 | if str:len() > max_len then 600 | return str:sub(1, max_len) .. "..." 601 | end 602 | 603 | return str 604 | end 605 | 606 | --- Counts and logs views if a timer is running. 607 | -- @field[opt] timer (uv_timer_t userdata) a timer that periodically 608 | -- logs view counts and resets the counter 609 | -- @field[opt] human_dur (string) human-readable representation of the 610 | -- duration used by `timer` 611 | -- @field count (table) keys are paths, values are view counts 612 | -- @field increment (function) increments the count for the given path 613 | -- if a timer is running 614 | local Views = {} 615 | 616 | function Views.new() 617 | local state = { 618 | timer = nil, 619 | human_dur = nil, 620 | count = {}, 621 | increment = function() end 622 | } 623 | return setmetatable(state, { __index = Views }) 624 | end 625 | 626 | --- Sets up a timer to log view counts. 627 | -- @param dur_minutes (integer) log view counts at this interval 628 | function Views:start_timer(dur_minutes) 629 | local units 630 | 631 | if dur_minutes % 1440 == 0 then 632 | units = dur_minutes / 1440 633 | self.human_dur = string.format("%d day", units) 634 | elseif dur_minutes % 60 == 0 then 635 | units = dur_minutes / 60 636 | self.human_dur = string.format("%d hour", units) 637 | else 638 | units = dur_minutes 639 | self.human_dur = string.format("%d minute", units) 640 | end 641 | 642 | if units > 1 then 643 | self.human_dur = self.human_dur .. "s" 644 | end 645 | 646 | local dur_ms = dur_minutes * 60 * 1000 647 | 648 | self.timer = vim.uv.new_timer() 649 | self.timer:start(dur_ms, dur_ms, vim.schedule_wrap(function() 650 | local had_views = false 651 | local log_views = function(...) 652 | if not had_views then 653 | had_views = true 654 | log:info("Views in the past %s:", self.human_dur) 655 | end 656 | log:info(...) 657 | end 658 | local not_found = { paths = 0, views = 0 } 659 | 660 | for path, count in pairs(self.count) do 661 | self.count[path] = nil 662 | 663 | if routing:has_path(path) then 664 | log_views("- %d for '%s'", count, path) 665 | else 666 | -- Rather than log each such path individually, only log 667 | -- them in the aggregate, in order to avoid DoS attacks 668 | -- that aim to fill up the log. 669 | -- 670 | not_found.paths = not_found.paths + 1 671 | not_found.views = not_found.views + count 672 | end 673 | end 674 | 675 | if not_found.paths > 0 then 676 | log_views( 677 | "- %d for %d nonexistent %s", 678 | not_found.views, 679 | not_found.paths, 680 | not_found.paths > 1 and "paths" or "path" 681 | ) 682 | end 683 | end)) 684 | 685 | self.increment = function(this, path) 686 | this.count[path] = (this.count[path] or 0) + 1 687 | end 688 | end 689 | 690 | local views = nil 691 | 692 | --- Periodically logs resource use if a timer is running. 693 | -- @field[opt] timer (uv_timer_t userdata) a timer that periodically 694 | -- logs resource use 695 | local ResourceUse = {} 696 | 697 | function ResourceUse.new() 698 | local state = { timer = nil } 699 | return setmetatable(state, { __index = ResourceUse }) 700 | end 701 | 702 | --- Sets up a timer to log resource use. 703 | -- @param dur_minutes (integer) log resource use at this interval 704 | function ResourceUse:start_timer(dur_minutes) 705 | local dur_ms = dur_minutes * 60 * 1000 706 | 707 | self.timer = vim.uv.new_timer() 708 | self.timer:start(dur_ms, dur_ms, vim.schedule_wrap(function() 709 | local rusage = vim.uv.getrusage() 710 | local rss = { -- In MBs. 711 | current = vim.uv.resident_set_memory() / 1024 / 1024, 712 | max = rusage.maxrss / 1024 713 | } 714 | local cpu = { -- In seconds. 715 | user = rusage.utime.sec + rusage.utime.usec / 1000 / 1000, 716 | sys = rusage.stime.sec + rusage.stime.usec / 1000 / 1000 717 | } 718 | local ipc = { sent = rusage.msgsnd, recvd = rusage.msgrcv } 719 | local block = { in_ = rusage.inblock, out = rusage.oublock } 720 | local signals = rusage.nsignals 721 | local ctx = { vol = rusage.nvcsw, invol = rusage.nivcsw } 722 | 723 | log:info("Resource use:") 724 | log:info( 725 | "- RSS: %.2f MB (current), %.2f MB (max)", 726 | rss.current, 727 | rss.max 728 | ) 729 | log:info("- CPU: %.2fs (user), %.2fs (sys)", cpu.user, cpu.sys) 730 | log:info("- IPC: %d (sent), %d (received)", ipc.sent, ipc.recvd) 731 | log:info("- Block ops: %d (in), %d (out)", block.in_, block.out) 732 | log:info("- Signals: %d", signals) 733 | log:info( 734 | "- Context switches: %d (voluntary), %d (involuntary)", 735 | ctx.vol, 736 | ctx.invol 737 | ) 738 | end)) 739 | end 740 | 741 | local resource_use = nil 742 | 743 | --- Launches the HTTP server. 744 | -- @param config (@{config}) 745 | -- @usage 746 | -- -- Launch the web server on a different port from the default. 747 | -- -- 748 | -- require("web-server").init({ port = 8080 }) 749 | -- 750 | -- -- Launch the web server with the log buffer saved to a file. 751 | -- -- 752 | -- require("web-server").init({ log_filename = "server.log" }) 753 | -- 754 | function M.init(config) 755 | M.config = vim.tbl_extend("force", default_config, config or {}) 756 | 757 | log = Logger.new(M.config.log_filename) 758 | djotter = Djotter.new() 759 | routing = Routing.new(djotter) 760 | views = Views.new() 761 | resource_use = ResourceUse.new() 762 | 763 | if not M.config.log_each_request then 764 | Logger.request = function() end 765 | end 766 | 767 | if M.config.log_views_period > 0 then 768 | views:start_timer(M.config.log_views_period) 769 | end 770 | 771 | if M.config.log_resource_use_period > 0 then 772 | resource_use:start_timer(M.config.log_resource_use_period) 773 | end 774 | 775 | if not M.config.keep_alive then 776 | response_connection = "Connection: close\n" 777 | end 778 | 779 | local new_cmd = vim.api.nvim_create_user_command 780 | new_cmd("WSAddBuffer", ws_add_buffer, { nargs = "*" }) 781 | new_cmd("WSDeletePath", ws_delete_path, { nargs = "*" }) 782 | new_cmd("WSPaths", ws_paths, { nargs = 0 }) 783 | new_cmd( 784 | "WSSetBufferAsTemplate", 785 | ws_set_buffer_as_template, 786 | { nargs = 0 } 787 | ) 788 | 789 | local host = M.config.host 790 | local port = M.config.port 791 | 792 | create_server(host, port, function(socket) 793 | local request = "" 794 | local result = nil 795 | 796 | socket:read_start(function(error, chunk) 797 | if error then 798 | log:info("Read error: '%s'.", error) 799 | socket:close() 800 | return 801 | elseif not chunk then 802 | socket:close() 803 | return 804 | end 805 | 806 | request = request .. chunk 807 | 808 | if request:len() > 2048 then 809 | result = { 810 | proto = "HTTP/1.1", 811 | request = request, 812 | response = Response.bad("HTTP/1.1") 813 | } 814 | elseif request:match("\r?\n\r?\n$") then 815 | result = process_request(request) 816 | end 817 | 818 | if result ~= nil then 819 | log:request( 820 | "%d %s %d %d '%s'", 821 | result.response.status, 822 | socket:getsockname().ip, 823 | request:len(), 824 | result.response.value:len(), 825 | truncate(result.request, 40) 826 | ) 827 | socket:write(result.response.value) 828 | 829 | if result.response.status ~= 400 then 830 | views:increment(result.path) 831 | end 832 | 833 | local keep_alive = ( 834 | M.config.keep_alive 835 | and result.proto ~= "HTTP/1.0" 836 | and result.response.status ~= 400 837 | ) 838 | 839 | if keep_alive then 840 | request = "" 841 | result = nil 842 | else 843 | socket:read_stop() 844 | socket:close() 845 | end 846 | end 847 | end) 848 | end) 849 | end 850 | 851 | --- Exported for testing. 852 | M.internal = { 853 | escape = escape, 854 | process_request_line = process_request_line, 855 | process_request_header = process_request_header, 856 | truncate = truncate, 857 | } 858 | 859 | return M 860 | -------------------------------------------------------------------------------- /lua/web-server/djot/block.lua: -------------------------------------------------------------------------------- 1 | -- This file has been adapted from John MacFarlane's djot.lua. 2 | -- 3 | -- Author: John MacFarlane 4 | -- License: MIT 5 | -- 6 | 7 | local InlineParser = require("web-server.djot.inline").InlineParser 8 | local attributes = require("web-server.djot.attributes") 9 | local unpack = unpack or table.unpack 10 | local find, sub, byte = string.find, string.sub, string.byte 11 | 12 | local Container = {} 13 | 14 | function Container:new(spec, data) 15 | self = spec 16 | local contents = {} 17 | setmetatable(contents, self) 18 | self.__index = self 19 | if data then 20 | for k,v in pairs(data) do 21 | contents[k] = v 22 | end 23 | end 24 | return contents 25 | end 26 | 27 | local function get_list_styles(marker) 28 | if marker == "+" or marker == "-" or marker == "*" or marker == ":" then 29 | return {marker} 30 | elseif find(marker, "^[+*-] %[[Xx ]%]") then 31 | return {"X"} -- task list 32 | elseif find(marker, "^[(]?%d+[).]") then 33 | return {(marker:gsub("%d+","1"))} 34 | -- in ambiguous cases we return two values 35 | elseif find(marker, "^[(]?[ivxlcdm][).]") then 36 | return {(marker:gsub("%a+", "i")), (marker:gsub("%a+", "a"))} 37 | elseif find(marker, "^[(]?[IVXLCDM][).]") then 38 | return {(marker:gsub("%a+", "I")), (marker:gsub("%a+", "A"))} 39 | elseif find(marker, "^[(]?%l[).]") then 40 | return {(marker:gsub("%l", "a"))} 41 | elseif find(marker, "^[(]?%u[).]") then 42 | return {(marker:gsub("%u", "A"))} 43 | elseif find(marker, "^[(]?[ivxlcdm]+[).]") then 44 | return {(marker:gsub("%a+", "i"))} 45 | elseif find(marker, "^[(]?[IVXLCDM]+[).]") then 46 | return {(marker:gsub("%a+", "I"))} 47 | else -- doesn't match any list style 48 | return {} 49 | end 50 | end 51 | 52 | ---@class Parser 53 | ---@field subject string 54 | ---@field warn function 55 | ---@field matches table 56 | ---@field containers table 57 | local Parser = {} 58 | 59 | function Parser:new(subject, warn) 60 | -- ensure the subject ends with a newline character 61 | if not subject:find("[\r\n]$") then 62 | subject = subject .. "\n" 63 | end 64 | local state = { 65 | warn = warn or function() end, 66 | subject = subject, 67 | indent = 0, 68 | startline = nil, 69 | starteol = nil, 70 | endeol = nil, 71 | matches = {}, 72 | containers = {}, 73 | pos = 1, 74 | last_matched_container = 0, 75 | timer = {}, 76 | finished_line = false, 77 | returned = 0 } 78 | setmetatable(state, self) 79 | self.__index = self 80 | return state 81 | end 82 | 83 | -- parameters are start and end position 84 | function Parser:parse_table_row(sp, ep) 85 | local orig_matches = #self.matches -- so we can rewind 86 | local startpos = self.pos 87 | self:add_match(sp, sp, "+row") 88 | -- skip | and any initial space in the cell: 89 | self.pos = find(self.subject, "%S", sp + 1) 90 | -- check to see if we have a separator line 91 | local seps = {} 92 | local p = self.pos 93 | local sepfound = false 94 | while not sepfound do 95 | local sepsp, sepep, left, right, trailing = 96 | find(self.subject, "^(%:?)%-%-*(%:?)([ \t]*%|[ \t]*)", p) 97 | if sepep then 98 | local st = "separator_default" 99 | if #left > 0 and #right > 0 then 100 | st = "separator_center" 101 | elseif #right > 0 then 102 | st = "separator_right" 103 | elseif #left > 0 then 104 | st = "separator_left" 105 | end 106 | seps[#seps + 1] = {sepsp, sepep - #trailing, st} 107 | p = sepep + 1 108 | if p == self.starteol then 109 | sepfound = true 110 | break 111 | end 112 | else 113 | break 114 | end 115 | end 116 | if sepfound then 117 | for i=1,#seps do 118 | self:add_match(unpack(seps[i])) 119 | end 120 | self:add_match(self.starteol - 1, self.starteol - 1, "-row") 121 | self.pos = self.starteol 122 | self.finished_line = true 123 | return true 124 | end 125 | local inline_parser = InlineParser:new(self.subject, self.warn) 126 | self:add_match(sp, sp, "+cell") 127 | local complete_cell = false 128 | while self.pos <= ep do 129 | -- parse a chunk as inline content 130 | local nextbar, _ 131 | while not nextbar do 132 | _, nextbar = self:find("^[^|\r\n]*|") 133 | if not nextbar then 134 | break 135 | end 136 | if string.find(self.subject, "^\\", nextbar - 1) then -- \| 137 | inline_parser:feed(self.pos, nextbar) 138 | self.pos = nextbar + 1 139 | nextbar = nil 140 | else 141 | inline_parser:feed(self.pos, nextbar - 1) 142 | if inline_parser:in_verbatim() then 143 | inline_parser:feed(nextbar, nextbar) 144 | self.pos = nextbar + 1 145 | nextbar = nil 146 | else 147 | self.pos = nextbar + 1 148 | end 149 | end 150 | end 151 | complete_cell = nextbar 152 | if not complete_cell then 153 | break 154 | end 155 | -- add a table cell 156 | local cell_matches = inline_parser:get_matches() 157 | for i=1,#cell_matches do 158 | local s,e,ann = unpack(cell_matches[i]) 159 | if i == #cell_matches and ann == "str" then 160 | -- strip trailing space 161 | while byte(self.subject, e) == 32 and e >= s do 162 | e = e - 1 163 | end 164 | end 165 | self:add_match(s,e,ann) 166 | end 167 | self:add_match(nextbar, nextbar, "-cell") 168 | if nextbar < ep then 169 | -- reset inline parser state 170 | inline_parser = InlineParser:new(self.subject, self.warn) 171 | self:add_match(nextbar, nextbar, "+cell") 172 | self.pos = find(self.subject, "%S", self.pos) 173 | end 174 | end 175 | if not complete_cell then 176 | -- rewind, this is not a valid table row 177 | self.pos = startpos 178 | for i = orig_matches,#self.matches do 179 | self.matches[i] = nil 180 | end 181 | return false 182 | else 183 | self:add_match(self.pos, self.pos, "-row") 184 | self.pos = self.starteol 185 | self.finished_line = true 186 | return true 187 | end 188 | end 189 | 190 | function Parser:specs() 191 | return { 192 | { name = "para", 193 | is_para = true, 194 | content = "inline", 195 | continue = function() 196 | if self:find("^%S") then 197 | return true 198 | else 199 | return false 200 | end 201 | end, 202 | open = function(spec) 203 | self:add_container(Container:new(spec, 204 | { inline_parser = 205 | InlineParser:new(self.subject, self.warn) })) 206 | self:add_match(self.pos, self.pos, "+para") 207 | return true 208 | end, 209 | close = function() 210 | self:get_inline_matches() 211 | local last = self.matches[#self.matches] or {self.pos, self.pos, ""} 212 | local sp, ep, annot = unpack(last) 213 | self:add_match(ep + 1, ep + 1, "-para") 214 | self.containers[#self.containers] = nil 215 | end 216 | }, 217 | 218 | { name = "caption", 219 | is_para = false, 220 | content = "inline", 221 | continue = function() 222 | return self:find("^%S") 223 | end, 224 | open = function(spec) 225 | local _, ep = self:find("^%^[ \t]+") 226 | if ep then 227 | self.pos = ep + 1 228 | self:add_container(Container:new(spec, 229 | { inline_parser = 230 | InlineParser:new(self.subject, self.warn) })) 231 | self:add_match(self.pos, self.pos, "+caption") 232 | return true 233 | end 234 | end, 235 | close = function() 236 | self:get_inline_matches() 237 | self:add_match(self.pos - 1, self.pos - 1, "-caption") 238 | self.containers[#self.containers] = nil 239 | end 240 | }, 241 | 242 | { name = "blockquote", 243 | content = "block", 244 | continue = function() 245 | if self:find("^%>%s") then 246 | self.pos = self.pos + 1 247 | return true 248 | else 249 | return false 250 | end 251 | end, 252 | open = function(spec) 253 | if self:find("^%>%s") then 254 | self:add_container(Container:new(spec)) 255 | self:add_match(self.pos, self.pos, "+blockquote") 256 | self.pos = self.pos + 1 257 | return true 258 | end 259 | end, 260 | close = function() 261 | self:add_match(self.pos, self.pos, "-blockquote") 262 | self.containers[#self.containers] = nil 263 | end 264 | }, 265 | 266 | -- should go before reference definitions 267 | { name = "footnote", 268 | content = "block", 269 | continue = function(container) 270 | if self.indent > container.indent or self:find("^[\r\n]") then 271 | return true 272 | else 273 | return false 274 | end 275 | end, 276 | open = function(spec) 277 | local sp, ep, label = self:find("^%[%^([^]]+)%]:%s") 278 | if not sp then 279 | return nil 280 | end 281 | -- adding container will close others 282 | self:add_container(Container:new(spec, {note_label = label, 283 | indent = self.indent})) 284 | self:add_match(sp, sp, "+footnote") 285 | self:add_match(sp + 2, ep - 3, "note_label") 286 | self.pos = ep 287 | return true 288 | end, 289 | close = function(_container) 290 | self:add_match(self.pos, self.pos, "-footnote") 291 | self.containers[#self.containers] = nil 292 | end 293 | }, 294 | 295 | -- should go before list_item_spec 296 | { name = "thematic_break", 297 | content = nil, 298 | continue = function() 299 | return false 300 | end, 301 | open = function(spec) 302 | local sp, ep = self:find("^[-*][ \t]*[-*][ \t]*[-*][-* \t]*[\r\n]") 303 | if ep then 304 | self:add_container(Container:new(spec)) 305 | self:add_match(sp, ep, "thematic_break") 306 | self.pos = ep 307 | return true 308 | end 309 | end, 310 | close = function(_container) 311 | self.containers[#self.containers] = nil 312 | end 313 | }, 314 | 315 | { name = "list_item", 316 | content = "block", 317 | continue = function(container) 318 | if self.indent > container.indent or self:find("^[\r\n]") then 319 | return true 320 | else 321 | return false 322 | end 323 | end, 324 | open = function(spec) 325 | local sp, ep = self:find("^[-*+:]%s") 326 | if not sp then 327 | sp, ep = self:find("^%d+[.)]%s") 328 | end 329 | if not sp then 330 | sp, ep = self:find("^%(%d+%)%s") 331 | end 332 | if not sp then 333 | sp, ep = self:find("^[ivxlcdmIVXLCDM]+[.)]%s") 334 | end 335 | if not sp then 336 | sp, ep = self:find("^%([ivxlcdmIVXLCDM]+%)%s") 337 | end 338 | if not sp then 339 | sp, ep = self:find("^%a[.)]%s") 340 | end 341 | if not sp then 342 | sp, ep = self:find("^%(%a%)%s") 343 | end 344 | if not sp then 345 | return nil 346 | end 347 | local marker = sub(self.subject, sp, ep - 1) 348 | local checkbox = nil 349 | if self:find("^[*+-] %[[Xx ]%]%s", sp + 1) then -- task list 350 | marker = sub(self.subject, sp, sp + 4) 351 | checkbox = sub(self.subject, sp + 3, sp + 3) 352 | end 353 | -- some items have ambiguous style 354 | local styles = get_list_styles(marker) 355 | if #styles == 0 then 356 | return nil 357 | end 358 | local data = { styles = styles, 359 | indent = self.indent } 360 | -- adding container will close others 361 | self:add_container(Container:new(spec, data)) 362 | local annot = "+list_item" 363 | for i=1,#styles do 364 | annot = annot .. "|" .. styles[i] 365 | end 366 | self:add_match(sp, ep - 1, annot) 367 | self.pos = ep 368 | if checkbox then 369 | if checkbox == " " then 370 | self:add_match(sp + 2, sp + 4, "checkbox_unchecked") 371 | else 372 | self:add_match(sp + 2, sp + 4, "checkbox_checked") 373 | end 374 | self.pos = sp + 5 375 | end 376 | return true 377 | end, 378 | close = function(_container) 379 | self:add_match(self.pos, self.pos, "-list_item") 380 | self.containers[#self.containers] = nil 381 | end 382 | }, 383 | 384 | { name = "reference_definition", 385 | content = nil, 386 | continue = function(container) 387 | if container.indent >= self.indent then 388 | return false 389 | end 390 | local _, ep, rest = self:find("^(%S+)") 391 | if ep and self.starteol == ep + 1 then 392 | self:add_match(ep - #rest + 1, ep, "reference_value") 393 | self.pos = ep + 1 394 | return true 395 | else 396 | return false 397 | end 398 | end, 399 | open = function(spec) 400 | local sp, ep, label, rest = self:find("^%[([^]\r\n]*)%]:[ \t]*(%S*)") 401 | if ep and self.starteol == ep + 1 then 402 | self:add_container(Container:new(spec, 403 | { key = label, 404 | indent = self.indent })) 405 | self:add_match(sp, sp, "+reference_definition") 406 | self:add_match(sp, sp + #label + 1, "reference_key") 407 | if #rest > 0 then 408 | self:add_match(ep - #rest + 1, ep, "reference_value") 409 | end 410 | self.pos = ep + 1 411 | return true 412 | end 413 | end, 414 | close = function(_container) 415 | self:add_match(self.pos, self.pos, "-reference_definition") 416 | self.containers[#self.containers] = nil 417 | end 418 | }, 419 | 420 | { name = "heading", 421 | content = "inline", 422 | continue = function(container) 423 | local sp, ep = self:find("^%#+%s") 424 | if sp and ep and container.level == ep - sp then 425 | self.pos = ep 426 | return true 427 | else 428 | return false 429 | end 430 | end, 431 | open = function(spec) 432 | local sp, ep = self:find("^#+") 433 | if ep and find(self.subject, "^%s", ep + 1) then 434 | local level = ep - sp + 1 435 | self:add_container(Container:new(spec, {level = level, 436 | inline_parser = InlineParser:new(self.subject, self.warn) })) 437 | self:add_match(sp, ep, "+heading") 438 | self.pos = ep + 1 439 | return true 440 | end 441 | end, 442 | close = function(_container) 443 | self:get_inline_matches() 444 | local last = self.matches[#self.matches] or {self.pos, self.pos, ""} 445 | local sp, ep, annot = unpack(last) 446 | self:add_match(ep + 1, ep + 1, "-heading") 447 | self.containers[#self.containers] = nil 448 | end 449 | }, 450 | 451 | { name = "code_block", 452 | content = "text", 453 | continue = function(container) 454 | local char = sub(container.border, 1, 1) 455 | local sp, ep, border = self:find("^(" .. container.border .. 456 | char .. "*)[ \t]*[\r\n]") 457 | if ep then 458 | container.end_fence_sp = sp 459 | container.end_fence_ep = sp + #border - 1 460 | self.pos = ep -- before newline 461 | self.finished_line = true 462 | return false 463 | else 464 | return true 465 | end 466 | end, 467 | open = function(spec) 468 | local sp, ep, border, ws, lang = 469 | self:find("^(~~~~*)([ \t]*)(%S*)[ \t]*[\r\n]") 470 | if not ep then 471 | sp, ep, border, ws, lang = 472 | self:find("^(````*)([ \t]*)([^%s`]*)[ \t]*[\r\n]") 473 | end 474 | if border then 475 | local is_raw = find(lang, "^=") and true or false 476 | self:add_container(Container:new(spec, {border = border, 477 | indent = self.indent })) 478 | self:add_match(sp, sp + #border - 1, "+code_block") 479 | if #lang > 0 then 480 | local langstart = sp + #border + #ws 481 | if is_raw then 482 | self:add_match(langstart, langstart + #lang - 1, "raw_format") 483 | else 484 | self:add_match(langstart, langstart + #lang - 1, "code_language") 485 | end 486 | end 487 | self.pos = ep -- before newline 488 | self.finished_line = true 489 | return true 490 | end 491 | end, 492 | close = function(container) 493 | local sp = container.end_fence_sp or self.pos 494 | local ep = container.end_fence_ep or self.pos 495 | self:add_match(sp, ep, "-code_block") 496 | if sp == ep then 497 | self.warn({ pos = self.pos, message = "Unclosed code block" }) 498 | end 499 | self.containers[#self.containers] = nil 500 | end 501 | }, 502 | 503 | { name = "fenced_div", 504 | content = "block", 505 | continue = function(container) 506 | if self.containers[#self.containers].name == "code_block" then 507 | return true -- see #109 508 | end 509 | local sp, ep, equals = self:find("^(::::*)[ \t]*[\r\n]") 510 | if ep and #equals >= container.equals then 511 | container.end_fence_sp = sp 512 | container.end_fence_ep = sp + #equals - 1 513 | self.pos = ep -- before newline 514 | return false 515 | else 516 | return true 517 | end 518 | end, 519 | open = function(spec) 520 | local sp, ep1, equals = self:find("^(::::*)[ \t]*") 521 | if not ep1 then 522 | return false 523 | end 524 | local clsp, ep = find(self.subject, "^[%w_-]*", ep1 + 1) 525 | local _, eol = find(self.subject, "^[ \t]*[\r\n]", ep + 1) 526 | if eol then 527 | self:add_container(Container:new(spec, {equals = #equals})) 528 | self:add_match(sp, ep, "+div") 529 | if ep >= clsp then 530 | self:add_match(clsp, ep, "class") 531 | end 532 | self.pos = eol + 1 533 | self.finished_line = true 534 | return true 535 | end 536 | end, 537 | close = function(container) 538 | local sp = container.end_fence_sp or self.pos 539 | local ep = container.end_fence_ep or self.pos 540 | -- check to make sure the match is in order 541 | self:add_match(sp, ep, "-div") 542 | if sp == ep then 543 | self.warn({pos = self.pos, message = "Unclosed div"}) 544 | end 545 | self.containers[#self.containers] = nil 546 | end 547 | }, 548 | 549 | { name = "table", 550 | content = "cells", 551 | continue = function(_container) 552 | local sp, ep = self:find("^|[^\r\n]*|") 553 | local eolsp = ep and find(self.subject, "^[ \t]*[\r\n]", ep + 1); 554 | if eolsp then 555 | return self:parse_table_row(sp, ep) 556 | end 557 | end, 558 | open = function(spec) 559 | local sp, ep = self:find("^|[^\r\n]*|") 560 | local eolsp = " *[\r\n]" -- make sure at end of line 561 | if sp and eolsp then 562 | self:add_container(Container:new(spec, { columns = 0 })) 563 | self:add_match(sp, sp, "+table") 564 | if self:parse_table_row(sp, ep) then 565 | return true 566 | else 567 | self.containers[#self.containers] = nil 568 | return false 569 | end 570 | end 571 | end, 572 | close = function(_container) 573 | self:add_match(self.pos, self.pos, "-table") 574 | self.containers[#self.containers] = nil 575 | end 576 | }, 577 | 578 | { name = "attributes", 579 | content = "attributes", 580 | open = function(spec) 581 | if self:find("^%{") then 582 | local attribute_parser = 583 | attributes.AttributeParser:new(self.subject) 584 | local status, ep = 585 | attribute_parser:feed(self.pos, self.endeol) 586 | if status == 'fail' or ep + 1 < self.endeol then 587 | return false 588 | else 589 | self:add_container(Container:new(spec, 590 | { status = status, 591 | indent = self.indent, 592 | startpos = self.pos, 593 | slices = {}, 594 | attribute_parser = attribute_parser })) 595 | local container = self.containers[#self.containers] 596 | container.slices = { {self.pos, self.endeol } } 597 | self.pos = self.starteol 598 | return true 599 | end 600 | 601 | end 602 | end, 603 | continue = function(container) 604 | if self.indent > container.indent then 605 | table.insert(container.slices, { self.pos, self.endeol }) 606 | local status, ep = 607 | container.attribute_parser:feed(self.pos, self.endeol) 608 | container.status = status 609 | if status ~= 'fail' or ep + 1 < self.endeol then 610 | self.pos = self.starteol 611 | return true 612 | end 613 | end 614 | -- if we get to here, we don't continue; either we 615 | -- reached the end of indentation or we failed in 616 | -- parsing attributes 617 | if container.status == 'done' then 618 | return false 619 | else -- attribute parsing failed; convert to para and continue 620 | -- with that 621 | local para_spec = self:specs()[1] 622 | local para = Container:new(para_spec, 623 | { inline_parser = 624 | InlineParser:new(self.subject, self.warn) }) 625 | self:add_match(container.startpos, container.startpos, "+para") 626 | self.containers[#self.containers] = para 627 | -- reparse the text we couldn't parse as a block attribute: 628 | para.inline_parser.attribute_slices = container.slices 629 | para.inline_parser:reparse_attributes() 630 | self.pos = para.inline_parser.lastpos + 1 631 | return true 632 | end 633 | end, 634 | close = function(container) 635 | local attr_matches = container.attribute_parser:get_matches() 636 | self:add_match(container.startpos, container.startpos, "+block_attributes") 637 | for i=1,#attr_matches do 638 | self:add_match(unpack(attr_matches[i])) 639 | end 640 | self:add_match(self.pos, self.pos, "-block_attributes") 641 | self.containers[#self.containers] = nil 642 | end 643 | } 644 | } 645 | end 646 | 647 | function Parser:get_inline_matches() 648 | local matches = 649 | self.containers[#self.containers].inline_parser:get_matches() 650 | for i=1,#matches do 651 | self.matches[#self.matches + 1] = matches[i] 652 | end 653 | end 654 | 655 | function Parser:find(patt) 656 | return find(self.subject, patt, self.pos) 657 | end 658 | 659 | function Parser:add_match(startpos, endpos, annotation) 660 | self.matches[#self.matches + 1] = {startpos, endpos, annotation} 661 | end 662 | 663 | function Parser:add_container(container) 664 | local last_matched = self.last_matched_container 665 | while #self.containers > last_matched or 666 | (#self.containers > 0 and 667 | self.containers[#self.containers].content ~= "block") do 668 | self.containers[#self.containers]:close() 669 | end 670 | self.containers[#self.containers + 1] = container 671 | end 672 | 673 | function Parser:skip_space() 674 | local newpos, _ = find(self.subject, "[^ \t]", self.pos) 675 | if newpos then 676 | self.indent = newpos - self.startline 677 | self.pos = newpos 678 | end 679 | end 680 | 681 | function Parser:get_eol() 682 | local starteol, endeol = find(self.subject, "[\r]?[\n]", self.pos) 683 | if not endeol then 684 | starteol, endeol = #self.subject, #self.subject 685 | end 686 | self.starteol = starteol 687 | self.endeol = endeol 688 | end 689 | 690 | -- Returns an iterator over events. At each iteration, the iterator 691 | -- returns three values: start byte position, end byte position, 692 | -- and annotation. 693 | function Parser:events() 694 | local specs = self:specs() 695 | local para_spec = specs[1] 696 | local subjectlen = #self.subject 697 | 698 | return function() -- iterator 699 | 700 | while self.pos <= subjectlen do 701 | 702 | -- return any accumulated matches 703 | if self.returned < #self.matches then 704 | self.returned = self.returned + 1 705 | return unpack(self.matches[self.returned]) 706 | end 707 | 708 | self.indent = 0 709 | self.startline = self.pos 710 | self.finished_line = false 711 | self:get_eol() 712 | 713 | -- check open containers for continuation 714 | self.last_matched_container = 0 715 | local idx = 0 716 | while idx < #self.containers do 717 | idx = idx + 1 718 | local container = self.containers[idx] 719 | -- skip any indentation 720 | self:skip_space() 721 | if container:continue() then 722 | self.last_matched_container = idx 723 | else 724 | break 725 | end 726 | end 727 | 728 | -- if we hit a close fence, we can move to next line 729 | if self.finished_line then 730 | while #self.containers > self.last_matched_container do 731 | self.containers[#self.containers]:close() 732 | end 733 | end 734 | 735 | if not self.finished_line then 736 | -- check for new containers 737 | self:skip_space() 738 | local is_blank = (self.pos == self.starteol) 739 | 740 | local new_starts = false 741 | local last_match = self.containers[self.last_matched_container] 742 | local check_starts = not is_blank and 743 | (not last_match or last_match.content == "block") and 744 | not self:find("^%a+%s") -- optimization 745 | while check_starts do 746 | check_starts = false 747 | for i=1,#specs do 748 | local spec = specs[i] 749 | if not spec.is_para then 750 | if spec:open() then 751 | self.last_matched_container = #self.containers 752 | if self.finished_line then 753 | check_starts = false 754 | else 755 | self:skip_space() 756 | new_starts = true 757 | check_starts = spec.content == "block" 758 | end 759 | break 760 | end 761 | end 762 | end 763 | end 764 | 765 | if not self.finished_line then 766 | -- handle remaining content 767 | self:skip_space() 768 | 769 | is_blank = (self.pos == self.starteol) 770 | 771 | local is_lazy = not is_blank and 772 | not new_starts and 773 | self.last_matched_container < #self.containers and 774 | self.containers[#self.containers].content == 'inline' 775 | 776 | local last_matched = self.last_matched_container 777 | if not is_lazy then 778 | while #self.containers > 0 and #self.containers > last_matched do 779 | self.containers[#self.containers]:close() 780 | end 781 | end 782 | 783 | local tip = self.containers[#self.containers] 784 | 785 | -- add para by default if there's text 786 | if not tip or tip.content == 'block' then 787 | if is_blank then 788 | if not new_starts then 789 | -- need to track these for tight/loose lists 790 | self:add_match(self.pos, self.endeol, "blankline") 791 | end 792 | else 793 | para_spec:open() 794 | end 795 | tip = self.containers[#self.containers] 796 | end 797 | 798 | if tip then 799 | if tip.content == "text" then 800 | local startpos = self.pos 801 | if tip.indent and self.indent > tip.indent then 802 | -- get back the leading spaces we gobbled 803 | startpos = startpos - (self.indent - tip.indent) 804 | end 805 | self:add_match(startpos, self.endeol, "str") 806 | elseif tip.content == "inline" then 807 | if not is_blank then 808 | tip.inline_parser:feed(self.pos, self.endeol) 809 | end 810 | end 811 | end 812 | end 813 | end 814 | 815 | self.pos = self.endeol + 1 816 | 817 | end 818 | 819 | -- close unmatched containers 820 | while #self.containers > 0 do 821 | self.containers[#self.containers]:close() 822 | end 823 | -- return any accumulated matches 824 | if self.returned < #self.matches then 825 | self.returned = self.returned + 1 826 | return unpack(self.matches[self.returned]) 827 | end 828 | 829 | end 830 | 831 | end 832 | 833 | return { Parser = Parser, 834 | Container = Container } 835 | -------------------------------------------------------------------------------- /lua/web-server/djot/ast.lua: -------------------------------------------------------------------------------- 1 | -- This file has been adapted from John MacFarlane's djot.lua. 2 | -- 3 | -- Author: John MacFarlane 4 | -- License: MIT 5 | -- 6 | 7 | --- @module 'djot.ast' 8 | --- Construct an AST for a djot document. 9 | 10 | --- @class Attributes 11 | --- @field class? string 12 | --- @field id? string 13 | 14 | --- @class AST 15 | --- @field t string tag for the node 16 | --- @field s? string text for the node 17 | --- @field c AST[] child node 18 | --- @field alias string 19 | --- @field level integer 20 | --- @field startidx integer 21 | --- @field startmarker string 22 | --- @field styles table 23 | --- @field style_marker string 24 | --- @field attr Attributes 25 | --- @field display boolean 26 | --- @field references table 27 | --- @field footnotes table 28 | --- @field pos? string[] 29 | --- @field destination? string[] 30 | 31 | if not utf8 then -- if not lua 5.3 or higher... 32 | -- this is needed for the __pairs metamethod, used below 33 | -- The following code is derived from the compat53 rock: 34 | -- override pairs 35 | local oldpairs = pairs 36 | pairs = function(t) 37 | local mt = getmetatable(t) 38 | if type(mt) == "table" and type(mt.__pairs) == "function" then 39 | return mt.__pairs(t) 40 | else 41 | return oldpairs(t) 42 | end 43 | end 44 | end 45 | local unpack = unpack or table.unpack 46 | 47 | local find, lower, sub, rep, format = 48 | string.find, string.lower, string.sub, string.rep, string.format 49 | 50 | -- Creates a sparse array whose indices are byte positions. 51 | -- sourcepos_map[bytepos] = "line:column:charpos" 52 | local function make_sourcepos_map(input) 53 | local sourcepos_map = {line = {}, col = {}, charpos = {}} 54 | local line = 1 55 | local col = 0 56 | local charpos = 0 57 | local bytepos = 1 58 | 59 | local byte = string.byte(input, bytepos) 60 | while byte do 61 | col = col + 1 62 | charpos = charpos + 1 63 | -- get next code point: 64 | local newbytepos 65 | if byte < 0xC0 then 66 | newbytepos = bytepos + 1 67 | elseif byte < 0xE0 then 68 | newbytepos = bytepos + 2 69 | elseif byte < 0xF0 then 70 | newbytepos = bytepos + 3 71 | else 72 | newbytepos = bytepos + 4 73 | end 74 | while bytepos < newbytepos do 75 | sourcepos_map.line[bytepos] = line 76 | sourcepos_map.col[bytepos] = col 77 | sourcepos_map.charpos[bytepos] = charpos 78 | bytepos = bytepos + 1 79 | end 80 | if byte == 10 then -- newline 81 | line = line + 1 82 | col = 0 83 | end 84 | byte = string.byte(input, bytepos) 85 | end 86 | 87 | sourcepos_map.line[bytepos] = line + 1 88 | sourcepos_map.col[bytepos] = 1 89 | sourcepos_map.charpos[bytepos] = charpos + 1 90 | 91 | return sourcepos_map 92 | end 93 | 94 | local function add_string_content(node, buffer) 95 | if node.s then 96 | buffer[#buffer + 1] = node.s 97 | elseif node.t == "softbreak" then 98 | buffer[#buffer + 1] = "\n" 99 | elseif node.c then 100 | for i=1, #node.c do 101 | add_string_content(node.c[i], buffer) 102 | end 103 | end 104 | end 105 | 106 | local function get_string_content(node) 107 | local buffer = {}; 108 | add_string_content(node, buffer) 109 | return table.concat(buffer) 110 | end 111 | 112 | local roman_digits = { 113 | i = 1, 114 | v = 5, 115 | x = 10, 116 | l = 50, 117 | c = 100, 118 | d = 500, 119 | m = 1000 } 120 | 121 | local function roman_to_number(s) 122 | -- go backwards through the digits 123 | local total = 0 124 | local prevdigit = 0 125 | local i=#s 126 | while i > 0 do 127 | local c = lower(sub(s,i,i)) 128 | local n = roman_digits[c] 129 | assert(n ~= nil, "Encountered bad character in roman numeral " .. s) 130 | if n < prevdigit then -- e.g. ix 131 | total = total - n 132 | else 133 | total = total + n 134 | end 135 | prevdigit = n 136 | i = i - 1 137 | end 138 | return total 139 | end 140 | 141 | local function get_list_start(marker, style) 142 | local numtype = string.gsub(style, "%p", "") 143 | local s = string.gsub(marker, "%p", "") 144 | if numtype == "1" then 145 | return tonumber(s) 146 | elseif numtype == "A" then 147 | return (string.byte(s) - string.byte("A") + 1) 148 | elseif numtype == "a" then 149 | return (string.byte(s) - string.byte("a") + 1) 150 | elseif numtype == "I" then 151 | return roman_to_number(s) 152 | elseif numtype == "i" then 153 | return roman_to_number(s) 154 | elseif numtype == "" then 155 | return nil 156 | end 157 | end 158 | 159 | local ignorable = { 160 | image_marker = true, 161 | escape = true, 162 | blankline = true 163 | } 164 | 165 | local function sortedpairs(compare_function, to_displaykey) 166 | return function(tbl) 167 | local keys = {} 168 | local k = nil 169 | k = next(tbl, k) 170 | while k do 171 | keys[#keys + 1] = k 172 | k = next(tbl, k) 173 | end 174 | table.sort(keys, compare_function) 175 | local keyindex = 0 176 | local function ordered_next(tabl,_) 177 | keyindex = keyindex + 1 178 | local key = keys[keyindex] 179 | -- use canonical names 180 | local displaykey = to_displaykey(key) 181 | if key then 182 | return displaykey, tabl[key] 183 | else 184 | return nil 185 | end 186 | end 187 | -- Return an iterator function, the table, starting point 188 | return ordered_next, tbl, nil 189 | end 190 | end 191 | 192 | -- provide children, tag, and text as aliases of c, t, s, 193 | -- which we use above for better performance: 194 | local mt = {} 195 | local special = { 196 | children = 'c', 197 | text = 's', 198 | tag = 't' } 199 | local displaykeys = { 200 | c = 'children', 201 | s = 'text', 202 | t = 'tag' } 203 | mt.__index = function(table, key) 204 | local k = special[key] 205 | if k then 206 | return rawget(table, k) 207 | else 208 | return rawget(table, key) 209 | end 210 | end 211 | mt.__newindex = function(table, key, val) 212 | local k = special[key] 213 | if k then 214 | rawset(table, k, val) 215 | else 216 | rawset(table, key, val) 217 | end 218 | end 219 | mt.__pairs = sortedpairs(function(a,b) 220 | if a == "t" then -- t is always first 221 | return true 222 | elseif a == "s" then -- s is always second 223 | return (b ~= "t") 224 | elseif a == "c" then -- c only before references, footnotes 225 | return (b == "references" or b == "footnotes") 226 | elseif a == "references" then 227 | return (b == "footnotes") 228 | elseif a == "footnotes" then 229 | return false 230 | elseif b == "t" or b == "s" then 231 | return false 232 | elseif b == "c" or b == "references" or b == "footnotes" then 233 | return true 234 | else 235 | return (a < b) 236 | end 237 | end, function(k) return displaykeys[k] or k end) 238 | 239 | 240 | --- Create a new AST node. 241 | --- @param tag (string) tag for the node 242 | --- @return (AST) node (table) 243 | local function new_node(tag) 244 | local node = { t = tag, c = nil } 245 | setmetatable(node, mt) 246 | return node 247 | end 248 | 249 | --- Add `child` as a child of `node`. 250 | --- @param node (AST) node parent node 251 | --- @param child (AST) node child node 252 | local function add_child(node, child) 253 | if (not node.c) then 254 | node.c = {child} 255 | else 256 | node.c[#node.c + 1] = child 257 | end 258 | end 259 | 260 | --- Returns true if `node` has children. 261 | --- @param node (AST) node to check 262 | --- @return (boolean) true if node has children 263 | local function has_children(node) 264 | return (node.c and #node.c > 0) 265 | end 266 | 267 | --- Returns an attributes object. 268 | --- @param tbl (Attributes?) table of attributes and values 269 | --- @return (Attributes) attributes object (table including special metatable for 270 | --- deterministic order of iteration) 271 | local function new_attributes(tbl) 272 | local attr = tbl or {} 273 | -- ensure deterministic order of iteration 274 | setmetatable(attr, {__pairs = sortedpairs(function(a,b) return a < b end, 275 | function(k) return k end)}) 276 | return attr 277 | end 278 | 279 | --- Insert an attribute into an attributes object. 280 | --- @param attr (Attributes) 281 | --- @param key (string) key of new attribute 282 | --- @param val (string) value of new attribute 283 | local function insert_attribute(attr, key, val) 284 | val = val:gsub("%s+", " ") -- normalize spaces 285 | if key == "class" then 286 | if attr.class then 287 | attr.class = attr.class .. " " .. val 288 | else 289 | attr.class = val 290 | end 291 | else 292 | attr[key] = val 293 | end 294 | end 295 | 296 | --- Copy attributes from `source` to `target`. 297 | --- @param target (Attributes) 298 | --- @param source (table) associating keys and values 299 | local function copy_attributes(target, source) 300 | if source then 301 | for k,v in pairs(source) do 302 | insert_attribute(target, k, v) 303 | end 304 | end 305 | end 306 | 307 | --- @param targetnode (AST) 308 | --- @param cs (AST) 309 | local function insert_attributes_from_nodes(targetnode, cs) 310 | targetnode.attr = targetnode.attr or new_attributes() 311 | local i=1 312 | while i <= #cs do 313 | local x, y = cs[i].t, cs[i].s 314 | if x == "id" or x == "class" then 315 | insert_attribute(targetnode.attr, x, y) 316 | elseif x == "key" then 317 | local val = {} 318 | while cs[i + 1] and cs[i + 1].t == "value" do 319 | val[#val + 1] = cs[i + 1].s:gsub("\\(%p)", "%1") 320 | -- resolve backslash escapes 321 | i = i + 1 322 | end 323 | insert_attribute(targetnode.attr, y, table.concat(val,"\n")) 324 | end 325 | i = i + 1 326 | end 327 | end 328 | 329 | --- @param node (AST) 330 | local function make_definition_list_item(node) 331 | node.t = "definition_list_item" 332 | if not has_children(node) then 333 | node.c = {} 334 | end 335 | if node.c[1] and node.c[1].t == "para" then 336 | node.c[1].t = "term" 337 | else 338 | table.insert(node.c, 1, new_node("term")) 339 | end 340 | if node.c[2] then 341 | local defn = new_node("definition") 342 | defn.c = {} 343 | for i=2,#node.c do 344 | defn.c[#defn.c + 1] = node.c[i] 345 | node.c[i] = nil 346 | end 347 | node.c[2] = defn 348 | end 349 | end 350 | 351 | local function resolve_style(list) 352 | local style = nil 353 | for k,i in pairs(list.styles) do 354 | if not style or i < style.priority then 355 | style = {name = k, priority = i} 356 | end 357 | end 358 | list.style = style.name 359 | list.styles = nil 360 | list.start = get_list_start(list.startmarker, list.style) 361 | list.startmarker = nil 362 | end 363 | 364 | local function get_verbatim_content(node) 365 | local s = get_string_content(node) 366 | -- trim space next to ` at beginning or end 367 | if find(s, "^ +`") then 368 | s = s:sub(2) 369 | end 370 | if find(s, "` +$") then 371 | s = s:sub(1, #s - 1) 372 | end 373 | return s 374 | end 375 | 376 | local function add_sections(ast) 377 | if not has_children(ast) then 378 | return ast 379 | end 380 | local newast = new_node("doc") 381 | local secs = { {sec = newast, level = 0 } } 382 | for _,node in ipairs(ast.c) do 383 | if node.t == "heading" then 384 | local level = node.level 385 | local curlevel = (#secs > 0 and secs[#secs].level) or 0 386 | if curlevel >= level then 387 | while secs[#secs].level >= level do 388 | local sec = table.remove(secs).sec 389 | add_child(secs[#secs].sec, sec) 390 | end 391 | end 392 | -- now we know: curlevel < level 393 | local newsec = new_node("section") 394 | newsec.attr = new_attributes{id = node.attr.id} 395 | node.attr.id = nil 396 | add_child(newsec, node) 397 | secs[#secs + 1] = {sec = newsec, level = level} 398 | else 399 | add_child(secs[#secs].sec, node) 400 | end 401 | end 402 | while #secs > 1 do 403 | local sec = table.remove(secs).sec 404 | add_child(secs[#secs].sec, sec) 405 | end 406 | assert(secs[1].sec == newast) 407 | return newast 408 | end 409 | 410 | 411 | --- Create an abstract syntax tree based on an event 412 | --- stream and references. 413 | --- @param parser (Parser) djot streaming parser 414 | --- @param sourcepos (boolean) if true, include source positions 415 | --- @return table representing the AST 416 | local function to_ast(parser, sourcepos) 417 | local subject = parser.subject 418 | local warn = parser.warn 419 | if not warn then 420 | warn = function() end 421 | end 422 | local sourceposmap 423 | if sourcepos then 424 | sourceposmap = make_sourcepos_map(subject) 425 | end 426 | local references = {} 427 | local footnotes = {} 428 | local identifiers = {} -- identifiers used (to ensure uniqueness) 429 | 430 | -- generate auto identifier for heading 431 | local function get_identifier(s) 432 | local base = s:gsub("[][~!@#$%^&*(){}`,.<>\\|=+/?]","") 433 | :gsub("^%s+",""):gsub("%s+$","") 434 | :gsub("%s+","-") 435 | local i = 0 436 | local ident = base 437 | -- generate unique id 438 | while ident == "" or identifiers[ident] do 439 | i = i + 1 440 | if base == "" then 441 | base = "s" 442 | end 443 | ident = base .. "-" .. tostring(i) 444 | end 445 | identifiers[ident] = true 446 | return ident 447 | end 448 | 449 | local function format_sourcepos(bytepos) 450 | if bytepos then 451 | return string.format("%d:%d:%d", sourceposmap.line[bytepos], 452 | sourceposmap.col[bytepos], sourceposmap.charpos[bytepos]) 453 | end 454 | end 455 | 456 | local function set_startpos(node, pos) 457 | if sourceposmap then 458 | local sp = format_sourcepos(pos) 459 | if node.pos then 460 | node.pos[1] = sp 461 | else 462 | node.pos = {sp, nil} 463 | end 464 | end 465 | end 466 | 467 | local function set_endpos(node, pos) 468 | if sourceposmap and node.pos then 469 | local ep = format_sourcepos(pos) 470 | if node.pos then 471 | node.pos[2] = ep 472 | else 473 | node.pos = {nil, ep} 474 | end 475 | end 476 | end 477 | 478 | local blocktag = { 479 | heading = true, 480 | div = true, 481 | list = true, 482 | list_item = true, 483 | code_block = true, 484 | para = true, 485 | blockquote = true, 486 | table = true, 487 | thematic_break = true, 488 | raw_block = true, 489 | reference_definition = true, 490 | footnote = true 491 | } 492 | 493 | local block_attributes = nil 494 | local function add_block_attributes(node) 495 | if block_attributes and blocktag[node.t:gsub("%|.*","")] then 496 | for i=1,#block_attributes do 497 | insert_attributes_from_nodes(node, block_attributes[i]) 498 | end 499 | -- add to identifiers table so we don't get duplicate auto-generated ids 500 | if node.attr and node.attr.id then 501 | identifiers[node.attr.id] = true 502 | end 503 | block_attributes = nil 504 | end 505 | end 506 | 507 | -- two variables used for tight/loose list determination: 508 | local tags = {} -- used to keep track of blank lines 509 | local matchidx = 0 -- keep track of the index of the match 510 | 511 | local function is_tight(startidx, endidx, is_last_item) 512 | -- see if there are any blank lines between blocks in a list item. 513 | local blanklines = 0 514 | -- we don't care about blank lines at very end of list 515 | if is_last_item then 516 | while tags[endidx] == "blankline" or tags[endidx] == "-list_item" do 517 | endidx = endidx - 1 518 | end 519 | end 520 | for i=startidx, endidx do 521 | local tag = tags[i] 522 | if tag == "blankline" then 523 | if not ((string.find(tags[i+1], "%+list_item") or 524 | (string.find(tags[i+1], "%-list_item") and 525 | (is_last_item or 526 | string.find(tags[i+2], "%-list_item"))))) then 527 | -- don't count blank lines before list starts 528 | -- don't count blank lines at end of nested lists or end of last item 529 | blanklines = blanklines + 1 530 | end 531 | end 532 | end 533 | return (blanklines == 0) 534 | end 535 | 536 | local function add_child_to_tip(containers, child) 537 | if containers[#containers].t == "list" and 538 | not (child.t == "list_item" or child.t == "definition_list_item") then 539 | -- close list 540 | local oldlist = table.remove(containers) 541 | add_child_to_tip(containers, oldlist) 542 | end 543 | if child.t == "list" then 544 | if child.pos then 545 | child.pos[2] = child.c[#child.c].pos[2] 546 | end 547 | -- calculate tightness (TODO not quite right) 548 | local tight = true 549 | for i=1,#child.c do 550 | tight = tight and is_tight(child.c[i].startidx, 551 | child.c[i].endidx, i == #child.c) 552 | child.c[i].startidx = nil 553 | child.c[i].endidx = nil 554 | end 555 | child.tight = tight 556 | 557 | -- resolve style if still ambiguous 558 | resolve_style(child) 559 | end 560 | add_child(containers[#containers], child) 561 | end 562 | 563 | 564 | -- process a match: 565 | -- containers is the stack of containers, with #container 566 | -- being the one that would receive a new node 567 | local function handle_match(containers, startpos, endpos, annot) 568 | matchidx = matchidx + 1 569 | local mod, tag = string.match(annot, "^([-+]?)(.+)") 570 | tags[matchidx] = annot 571 | if ignorable[tag] then 572 | return 573 | end 574 | if mod == "+" then 575 | -- process open match: 576 | -- * open a new node and put it at end of containers stack 577 | -- * depending on the tag name, do other things 578 | local node = new_node(tag) 579 | set_startpos(node, startpos) 580 | 581 | -- add block attributes if any have accumulated: 582 | add_block_attributes(node) 583 | 584 | if tag == "heading" then 585 | node.level = (endpos - startpos) + 1 586 | 587 | elseif find(tag, "^list_item") then 588 | node.t = "list_item" 589 | node.startidx = matchidx -- for tight/loose determination 590 | local _, _, style_marker = string.find(tag, "(%|.*)") 591 | local styles = {} 592 | if style_marker then 593 | local i=1 594 | for sty in string.gmatch(style_marker, "%|([^%|%]]*)") do 595 | styles[sty] = i 596 | i = i + 1 597 | end 598 | end 599 | node.style_marker = style_marker 600 | 601 | local marker = string.match(subject, "^%S+", startpos) 602 | 603 | -- adjust container stack so that the tip can accept this 604 | -- kind of list item, adding a list if needed and possibly 605 | -- closing an existing list 606 | 607 | local tip = containers[#containers] 608 | if tip.t ~= "list" then 609 | -- container is not a list ; add one 610 | local list = new_node("list") 611 | set_startpos(list, startpos) 612 | list.styles = styles 613 | list.attr = node.attr 614 | list.startmarker = marker 615 | node.attr = nil 616 | containers[#containers + 1] = list 617 | else 618 | -- it's a list, but is it the right kind? 619 | local matched_styles = {} 620 | local has_match = false 621 | for k,_ in pairs(styles) do 622 | if tip.styles[k] then 623 | has_match = true 624 | matched_styles[k] = styles[k] 625 | end 626 | end 627 | if has_match then 628 | -- yes, list can accept this item 629 | tip.styles = matched_styles 630 | else 631 | -- no, list can't accept this item ; close it 632 | local oldlist = table.remove(containers) 633 | add_child_to_tip(containers, oldlist) 634 | -- add a new sibling list node with the right style 635 | local list = new_node("list") 636 | set_startpos(list, startpos) 637 | list.styles = styles 638 | list.attr = node.attr 639 | list.startmarker = marker 640 | node.attr = nil 641 | containers[#containers + 1] = list 642 | end 643 | end 644 | 645 | 646 | end 647 | 648 | -- add to container stack 649 | containers[#containers + 1] = node 650 | 651 | elseif mod == "-" then 652 | -- process close match: 653 | -- * check end of containers stack; if tag matches, add 654 | -- end position, pop the item off the stack, and add 655 | -- it as a child of the next container on the stack 656 | -- * if it doesn't match, issue a warning and ignore this tag 657 | 658 | if containers[#containers].t == "list" then 659 | local listnode = table.remove(containers) 660 | add_child_to_tip(containers, listnode) 661 | end 662 | 663 | if tag == containers[#containers].t then 664 | local node = table.remove(containers) 665 | set_endpos(node, endpos) 666 | 667 | if node.t == "block_attributes" then 668 | if not block_attributes then 669 | block_attributes = {} 670 | end 671 | block_attributes[#block_attributes + 1] = node.c 672 | return -- we don't add this to parent; instead we store 673 | -- the block attributes and add them to the next block 674 | 675 | elseif node.t == "attributes" then 676 | -- parse attributes, add to last node 677 | local tip = containers[#containers] 678 | --- @type AST|false 679 | local prevnode = has_children(tip) and tip.c[#tip.c] 680 | if prevnode then 681 | local endswithspace = false 682 | if prevnode.t == "str" then 683 | -- split off last consecutive word of string 684 | -- to which to attach attributes 685 | local lastwordpos = string.find(prevnode.s, "[^%s]+$") 686 | if not lastwordpos then 687 | endswithspace = true 688 | elseif lastwordpos > 1 then 689 | local newnode = new_node("str") 690 | newnode.s = sub(prevnode.s, lastwordpos, -1) 691 | prevnode.s = sub(prevnode.s, 1, lastwordpos - 1) 692 | add_child_to_tip(containers, newnode) 693 | prevnode = newnode 694 | end 695 | end 696 | if has_children(node) and not endswithspace then 697 | insert_attributes_from_nodes(prevnode, node.c) 698 | else 699 | warn({message = "Ignoring unattached attribute", pos = startpos}) 700 | end 701 | else 702 | warn({message = "Ignoring unattached attribute", pos = startpos}) 703 | end 704 | return -- don't add the attribute node to the tree 705 | 706 | elseif tag == "reference_definition" then 707 | local dest = "" 708 | local key 709 | for i=1,#node.c do 710 | if node.c[i].t == "reference_key" then 711 | key = node.c[i].s 712 | end 713 | if node.c[i].t == "reference_value" then 714 | dest = dest .. node.c[i].s 715 | end 716 | end 717 | references[key] = new_node("reference") 718 | references[key].destination = dest 719 | if node.attr then 720 | references[key].attr = node.attr 721 | end 722 | return -- don't include in tree 723 | 724 | elseif tag == "footnote" then 725 | local label 726 | if has_children(node) and node.c[1].t == "note_label" then 727 | label = node.c[1].s 728 | table.remove(node.c, 1) 729 | end 730 | if label then 731 | footnotes[label] = node 732 | end 733 | return -- don't include in tree 734 | 735 | 736 | elseif tag == "table" then 737 | 738 | -- Children are the rows. Look for a separator line: 739 | -- if found, make the preceding rows headings 740 | -- and set attributes for column alignments on the table. 741 | 742 | local i=1 743 | local aligns = {} 744 | while i <= #node.c do 745 | local found, align, _ 746 | if node.c[i].t == "row" then 747 | local row = node.c[i].c 748 | for j=1,#row do 749 | found, _, align = find(row[j].t, "^separator_(.*)") 750 | if not found then 751 | break 752 | end 753 | aligns[j] = align 754 | end 755 | if found and #aligns > 0 then 756 | -- set previous row to head and adjust aligns 757 | local prevrow = node.c[i - 1] 758 | if prevrow and prevrow.t == "row" then 759 | prevrow.head = true 760 | for k=1,#prevrow.c do 761 | -- set head on cells too 762 | prevrow.c[k].head = true 763 | if aligns[k] ~= "default" then 764 | prevrow.c[k].align = aligns[k] 765 | end 766 | end 767 | end 768 | table.remove(node.c, i) -- remove sep line 769 | -- we don't need to increment i because we removed ith elt 770 | else 771 | if #aligns > 0 then 772 | for l=1,#node.c[i].c do 773 | if aligns[l] ~= "default" then 774 | node.c[i].c[l].align = aligns[l] 775 | end 776 | end 777 | end 778 | i = i + 1 779 | end 780 | end 781 | end 782 | 783 | elseif tag == "code_block" then 784 | if has_children(node) then 785 | if node.c[1].t == "code_language" then 786 | node.lang = node.c[1].s 787 | table.remove(node.c, 1) 788 | elseif node.c[1].t == "raw_format" then 789 | local fmt = node.c[1].s:sub(2) 790 | table.remove(node.c, 1) 791 | node.t = "raw_block" 792 | node.format = fmt 793 | end 794 | end 795 | node.s = get_string_content(node) 796 | node.c = nil 797 | 798 | elseif find(tag, "^list_item") then 799 | node.t = "list_item" 800 | node.endidx = matchidx -- for tight/loose determination 801 | 802 | if node.style_marker == "|:" then 803 | make_definition_list_item(node) 804 | end 805 | 806 | if node.style_marker == "|X" and has_children(node) then 807 | if node.c[1].t == "checkbox_checked" then 808 | node.checkbox = "checked" 809 | table.remove(node.c, 1) 810 | elseif node.c[1].t == "checkbox_unchecked" then 811 | node.checkbox = "unchecked" 812 | table.remove(node.c, 1) 813 | end 814 | end 815 | 816 | node.style_marker = nil 817 | 818 | elseif tag == "inline_math" then 819 | node.t = "math" 820 | node.s = get_verbatim_content(node) 821 | node.c = nil 822 | node.display = false 823 | node.attr = new_attributes{class = "math inline"} 824 | 825 | elseif tag == "display_math" then 826 | node.t = "math" 827 | node.s = get_verbatim_content(node) 828 | node.c = nil 829 | node.display = true 830 | node.attr = new_attributes{class = "math display"} 831 | 832 | elseif tag == "imagetext" then 833 | node.t = "image" 834 | 835 | elseif tag == "linktext" then 836 | node.t = "link" 837 | 838 | elseif tag == "div" then 839 | node.c = node.c or {} 840 | if node.c[1] and node.c[1].t == "class" then 841 | node.attr = new_attributes(node.attr) 842 | insert_attribute(node.attr, "class", get_string_content(node.c[1])) 843 | table.remove(node.c, 1) 844 | end 845 | 846 | elseif tag == "verbatim" then 847 | node.s = get_verbatim_content(node) 848 | node.c = nil 849 | 850 | elseif tag == "url" then 851 | node.destination = get_string_content(node) 852 | 853 | elseif tag == "email" then 854 | node.destination = "mailto:" .. get_string_content(node) 855 | 856 | elseif tag == "caption" then 857 | local tip = containers[#containers] 858 | local prevnode = has_children(tip) and tip.c[#tip.c] 859 | if prevnode and prevnode.t == "table" then 860 | -- move caption in table node 861 | table.insert(prevnode.c, 1, node) 862 | else 863 | warn({ message = "Ignoring caption without preceding table", 864 | pos = startpos }) 865 | end 866 | return 867 | 868 | elseif tag == "heading" then 869 | local heading_str = 870 | get_string_content(node):gsub("^%s+",""):gsub("%s+$","") 871 | if not node.attr then 872 | node.attr = new_attributes{} 873 | end 874 | if not node.attr.id then -- generate id attribute from heading 875 | insert_attribute(node.attr, "id", get_identifier(heading_str)) 876 | end 877 | -- insert into references unless there's a same-named one already: 878 | if not references[heading_str] then 879 | references[heading_str] = 880 | new_node("reference") 881 | references[heading_str].destination = "#" .. node.attr.id 882 | end 883 | 884 | elseif tag == "destination" then 885 | local tip = containers[#containers] 886 | local prevnode = has_children(tip) and tip.c[#tip.c] 887 | assert(prevnode and (prevnode.t == "image" or prevnode.t == "link"), 888 | "destination with no preceding link or image") 889 | prevnode.destination = get_string_content(node):gsub("\r?\n", "") 890 | return -- do not put on container stack 891 | 892 | elseif tag == "reference" then 893 | local tip = containers[#containers] 894 | local prevnode = has_children(tip) and tip.c[#tip.c] 895 | assert(prevnode and (prevnode.t == "image" or prevnode.t == "link"), 896 | "reference with no preceding link or image") 897 | if has_children(node) then 898 | prevnode.reference = get_string_content(node):gsub("\r?\n", " ") 899 | else 900 | prevnode.reference = get_string_content(prevnode):gsub("\r?\n", " ") 901 | end 902 | return -- do not put on container stack 903 | end 904 | 905 | add_child_to_tip(containers, node) 906 | else 907 | assert(false, "unmatched " .. annot .. " encountered at byte " .. 908 | startpos) 909 | return 910 | end 911 | else 912 | -- process leaf node: 913 | -- * add position info 914 | -- * special handling depending on tag type 915 | -- * add node as child of container at end of containers stack 916 | local node = new_node(tag) 917 | add_block_attributes(node) 918 | set_startpos(node, startpos) 919 | set_endpos(node, endpos) 920 | 921 | -- special handling: 922 | if tag == "softbreak" then 923 | node.s = nil 924 | elseif tag == "reference_key" then 925 | node.s = sub(subject, startpos + 1, endpos - 1) 926 | elseif tag == "footnote_reference" then 927 | node.s = sub(subject, startpos + 2, endpos - 1) 928 | elseif tag == "symbol" then 929 | node.alias = sub(subject, startpos + 1, endpos - 1) 930 | elseif tag == "raw_format" then 931 | local tip = containers[#containers] 932 | local prevnode = has_children(tip) and tip.c[#tip.c] 933 | if prevnode and prevnode.t == "verbatim" then 934 | local s = get_string_content(prevnode) 935 | prevnode.t = "raw_inline" 936 | prevnode.s = s 937 | prevnode.c = nil 938 | prevnode.format = sub(subject, startpos + 2, endpos - 1) 939 | return -- don't add this node to containers 940 | else 941 | node.s = sub(subject, startpos, endpos) 942 | end 943 | else 944 | node.s = sub(subject, startpos, endpos) 945 | end 946 | 947 | add_child_to_tip(containers, node) 948 | 949 | end 950 | end 951 | 952 | local doc = new_node("doc") 953 | local containers = {doc} 954 | for sp, ep, annot in parser:events() do 955 | handle_match(containers, sp, ep, annot) 956 | end 957 | -- close any open containers 958 | while #containers > 1 do 959 | local node = table.remove(containers) 960 | add_child_to_tip(containers, node) 961 | -- note: doc container doesn't have pos, so we check: 962 | if sourceposmap and containers[#containers].pos then 963 | containers[#containers].pos[2] = node.pos[2] 964 | end 965 | end 966 | doc = add_sections(doc) 967 | 968 | doc.references = references 969 | doc.footnotes = footnotes 970 | 971 | return doc 972 | end 973 | 974 | local function render_node(node, handle, indent) 975 | indent = indent or 0 976 | handle:write(rep(" ", indent)) 977 | if indent > 128 then 978 | handle:write("(((DEEPLY NESTED CONTENT OMITTED)))\n") 979 | return 980 | end 981 | 982 | if node.t then 983 | handle:write(node.t) 984 | if node.pos then 985 | handle:write(format(" (%s-%s)", node.pos[1], node.pos[2])) 986 | end 987 | for k,v in pairs(node) do 988 | if type(k) == "string" and k ~= "children" and 989 | k ~= "tag" and k ~= "pos" and k ~= "attr" and 990 | k ~= "references" and k ~= "footnotes" then 991 | handle:write(format(" %s=%q", k, tostring(v))) 992 | end 993 | end 994 | if node.attr then 995 | for k,v in pairs(node.attr) do 996 | handle:write(format(" %s=%q", k, v)) 997 | end 998 | end 999 | else 1000 | io.stderr:write("Encountered node without tag:\n" .. 1001 | require'inspect'(node)) 1002 | os.exit(1) 1003 | end 1004 | handle:write("\n") 1005 | if node.c then 1006 | for _,v in ipairs(node.c) do 1007 | render_node(v, handle, indent + 2) 1008 | end 1009 | end 1010 | end 1011 | 1012 | --- Render an AST in human-readable form, with indentation 1013 | --- showing the hierarchy. 1014 | --- @param doc (AST) djot AST 1015 | --- @param handle (StringHandle) handle to which to write content 1016 | local function render(doc, handle) 1017 | render_node(doc, handle, 0) 1018 | if next(doc.references) ~= nil then 1019 | handle:write("references\n") 1020 | for k,v in pairs(doc.references) do 1021 | handle:write(format(" [%q] =\n", k)) 1022 | render_node(v, handle, 4) 1023 | end 1024 | end 1025 | if next(doc.footnotes) ~= nil then 1026 | handle:write("footnotes\n") 1027 | for k,v in pairs(doc.footnotes) do 1028 | handle:write(format(" [%q] =\n", k)) 1029 | render_node(v, handle, 4) 1030 | end 1031 | end 1032 | end 1033 | 1034 | --- @export 1035 | return { to_ast = to_ast, 1036 | render = render, 1037 | insert_attribute = insert_attribute, 1038 | copy_attributes = copy_attributes, 1039 | new_attributes = new_attributes, 1040 | new_node = new_node, 1041 | add_child = add_child, 1042 | has_children = has_children } 1043 | --------------------------------------------------------------------------------