├── 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 | 
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 | [](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 | .. "
\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('- \n', i))
172 | self:add_backlink(note,i)
173 | self:render_children(note)
174 | self.out('
\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("" .. tag .. ">\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 |
--------------------------------------------------------------------------------