size: 4 KiB

1--- @module 'djot.filter'
2--- Support filters that walk the AST and transform a
3--- document between parsing and rendering, like pandoc Lua filters.
4---
5--- This filter uppercases all str elements.
6---
7--- return {
8--- str = function(e)
9--- e.text = e.text:upper()
10--- end
11--- }
12---
13--- A filter may define functions for as many different tag types
14--- as it likes. traverse will walk the AST and apply matching
15--- functions to each node.
16---
17--- To load a filter:
18---
19--- local filter = require_filter(path)
20---
21--- or
22---
23--- local filter = load_filter(string)
24---
25--- By default filters do a bottom-up traversal; that is, the
26--- filter for a node is run after its children have been processed.
27--- It is possible to do a top-down travel, though, and even
28--- to run separate actions on entering a node (before processing the
29--- children) and on exiting (after processing the children). To do
30--- this, associate the node's tag with a table containing `enter` and/or
31--- `exit` functions. The following filter will capitalize text
32--- that is nested inside emphasis, but not other text:
33---
34--- local capitalize = 0
35--- return {
36--- emph = {
37--- enter = function(e)
38--- capitalize = capitalize + 1
39--- end,
40--- exit = function(e)
41--- capitalize = capitalize - 1
42--- end,
43--- },
44--- str = function(e)
45--- if capitalize > 0 then
46--- e.text = e.text:upper()
47--- end
48--- end
49--- }
50---
51--- For a top-down traversal, you'd just use the `enter` functions.
52--- If the tag is associated directly with a function, as in the
53--- first example above, it is treated as an `exit` function.
54---
55--- It is possible to inhibit traversal into the children of a node,
56--- by having the `enter` function return the value true (or any truish
57--- value, say `'stop'`). This can be used, for example, to prevent
58--- the contents of a footnote from being processed:
59---
60--- return {
61--- footnote = {
62--- enter = function(e)
63--- return true
64--- end
65--- }
66--- }
67---
68--- A single filter may return a table with multiple tables, which will be
69--- applied sequentially.
70
71local function handle_node(node, filterpart)
72 local action = filterpart[node.t]
73 local action_in, action_out
74 if type(action) == "table" then
75 action_in = action.enter
76 action_out = action.exit
77 elseif type(action) == "function" then
78 action_out = action
79 end
80 if action_in then
81 local stop_traversal = action_in(node)
82 if stop_traversal then
83 return
84 end
85 end
86 if node.c then
87 for _,child in ipairs(node.c) do
88 handle_node(child, filterpart)
89 end
90 end
91 if node.footnotes then
92 for _, note in pairs(node.footnotes) do
93 handle_node(note, filterpart)
94 end
95 end
96 if action_out then
97 action_out(node)
98 end
99end
100
101local function traverse(node, filterpart)
102 handle_node(node, filterpart)
103 return node
104end
105
106--- Apply a filter to a document.
107--- @param node document (AST)
108--- @param filter the filter to apply
109local function apply_filter(node, filter)
110 for _,filterpart in ipairs(filter) do
111 traverse(node, filterpart)
112 end
113end
114
115--- Returns a table containing the filter defined in `fp`.
116--- `fp` will be sought using `require`, so it may occur anywhere
117--- on the `LUA_PATH`, or in the working directory. On error,
118--- returns nil and an error message.
119--- @param fp path of file containing filter
120--- @return the compiled filter, or nil and and error message
121local function require_filter(fp)
122 local oldpackagepath = package.path
123 -- allow omitting or providing the .lua extension:
124 local ok, filter = pcall(function()
125 package.path = "./?.lua;" .. package.path
126 local f = require(fp:gsub("%.lua$",""))
127 package.path = oldpackagepath
128 return f
129 end)
130 if not ok then
131 return nil, filter
132 elseif type(filter) ~= "table" then
133 return nil, "filter must be a table"
134 end
135 if #filter == 0 then -- just a single filter part given
136 return {filter}
137 else
138 return filter
139 end
140end
141
142--- Load filter from a string, which should have the
143--- form `return { ... }`. On error, return nil and an
144--- error message.
145--- @param s string containing the filter
146--- @return the compiled filter, or nil and and error message
147local function load_filter(s)
148 local fn, err
149 if _VERSION:match("5.1") then
150 fn, err = loadstring(s)
151 else
152 fn, err = load(s)
153 end
154 if fn then
155 local filter = fn()
156 if type(filter) ~= "table" then
157 return nil, "filter must be a table"
158 end
159 if #filter == 0 then -- just a single filter given
160 return {filter}
161 else
162 return filter
163 end
164 else
165 return nil, err
166 end
167end
168
169--- @export
170return {
171 apply_filter = apply_filter,
172 require_filter = require_filter,
173 load_filter = load_filter
174}