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 | |
| 71 | local 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 |
| 99 | end |
| 100 | |
| 101 | local function traverse(node, filterpart) |
| 102 | handle_node(node, filterpart) |
| 103 | return node |
| 104 | end |
| 105 | |
| 106 | --- Apply a filter to a document. |
| 107 | --- @param node document (AST) |
| 108 | --- @param filter the filter to apply |
| 109 | local function apply_filter(node, filter) |
| 110 | for _,filterpart in ipairs(filter) do |
| 111 | traverse(node, filterpart) |
| 112 | end |
| 113 | end |
| 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 |
| 121 | local 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 |
| 140 | end |
| 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 |
| 147 | local 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 |
| 167 | end |
| 168 | |
| 169 | --- @export |
| 170 | return { |
| 171 | apply_filter = apply_filter, |
| 172 | require_filter = require_filter, |
| 173 | load_filter = load_filter |
| 174 | } |