|
3 | 3 | --- @license MIT |
4 | 4 | --- @copyright 2026 Mickaël Canouil |
5 | 5 | --- @author Mickaël Canouil |
6 | | ---- @version 1.0.0 |
| 6 | +--- @version 1.1.0 |
7 | 7 |
|
8 | 8 | local M = {} |
9 | 9 |
|
@@ -76,6 +76,158 @@ function M.to_string(val) |
76 | 76 | return str ~= '' and str or nil |
77 | 77 | end |
78 | 78 |
|
| 79 | +--- Strip one layer of surrounding bracket or punctuation characters. |
| 80 | +--- Handles balanced pairs: () [] {} "" '' `` «» |
| 81 | +--- Handles trailing-only punctuation: , . ; : ! ? |
| 82 | +--- @param text string The input text |
| 83 | +--- @return string prefix Characters stripped from the start (may be empty) |
| 84 | +--- @return string inner The inner text after stripping |
| 85 | +--- @return string suffix Characters stripped from the end (may be empty) |
| 86 | +function M.strip_surrounding(text) |
| 87 | + if not text or #text < 2 then |
| 88 | + return '', text or '', '' |
| 89 | + end |
| 90 | + |
| 91 | + local balanced = { |
| 92 | + ['('] = ')', ['['] = ']', ['{'] = '}', |
| 93 | + ['"'] = '"', ["'"] = "'", ['`'] = '`', |
| 94 | + } |
| 95 | + -- UTF-8 guillemets |
| 96 | + local first_two = text:sub(1, 2) |
| 97 | + local last_two = text:sub(-2) |
| 98 | + if first_two == '\xC2\xAB' and last_two == '\xC2\xBB' then |
| 99 | + return first_two, text:sub(3, -3), last_two |
| 100 | + end |
| 101 | + |
| 102 | + local first = text:sub(1, 1) |
| 103 | + local last = text:sub(-1) |
| 104 | + |
| 105 | + if balanced[first] and last == balanced[first] then |
| 106 | + return first, text:sub(2, -2), last |
| 107 | + end |
| 108 | + |
| 109 | + local trailing = { |
| 110 | + [','] = true, ['.'] = true, [';'] = true, |
| 111 | + [':'] = true, ['!'] = true, ['?'] = true, |
| 112 | + } |
| 113 | + if trailing[last] then |
| 114 | + return '', text:sub(1, -2), last |
| 115 | + end |
| 116 | + |
| 117 | + return '', text, '' |
| 118 | +end |
| 119 | + |
| 120 | +--- Peel unbalanced surrounding brackets and trailing punctuation from a token. |
| 121 | +--- Unlike `strip_surrounding`, this does not require a balanced pair: it removes |
| 122 | +--- any run of leading opening-bracket characters and any run of trailing |
| 123 | +--- closing-bracket or punctuation characters. This handles bracket groups that |
| 124 | +--- Pandoc split across whitespace, e.g. "(#2," and "#3)" from "(#2, #3)". |
| 125 | +--- Leading set: ( [ { " ' ` and the 2-byte UTF-8 « (\xC2\xAB). |
| 126 | +--- Trailing set: ) ] } " ' ` , . ; : ! ? and the 2-byte UTF-8 » (\xC2\xBB). |
| 127 | +--- @param text string The input text |
| 128 | +--- @return string prefix Characters peeled from the start (may be empty) |
| 129 | +--- @return string inner The inner text after peeling |
| 130 | +--- @return string suffix Characters peeled from the end (may be empty) |
| 131 | +function M.strip_edges(text) |
| 132 | + if not text or text == '' then |
| 133 | + return '', text or '', '' |
| 134 | + end |
| 135 | + |
| 136 | + local leading = { |
| 137 | + ['('] = true, ['['] = true, ['{'] = true, |
| 138 | + ['"'] = true, ["'"] = true, ['`'] = true, |
| 139 | + } |
| 140 | + local trailing = { |
| 141 | + [')'] = true, [']'] = true, ['}'] = true, |
| 142 | + ['"'] = true, ["'"] = true, ['`'] = true, |
| 143 | + [','] = true, ['.'] = true, [';'] = true, |
| 144 | + [':'] = true, ['!'] = true, ['?'] = true, |
| 145 | + } |
| 146 | + |
| 147 | + local first = 1 |
| 148 | + local last = #text |
| 149 | + local prefix = '' |
| 150 | + local suffix = '' |
| 151 | + |
| 152 | + while first <= last do |
| 153 | + if text:sub(first, first + 1) == '\xC2\xAB' then |
| 154 | + prefix = prefix .. '\xC2\xAB' |
| 155 | + first = first + 2 |
| 156 | + elseif leading[text:sub(first, first)] then |
| 157 | + prefix = prefix .. text:sub(first, first) |
| 158 | + first = first + 1 |
| 159 | + else |
| 160 | + break |
| 161 | + end |
| 162 | + end |
| 163 | + |
| 164 | + while last >= first do |
| 165 | + -- The two-byte window can only match a real «/» pair: the leading loop |
| 166 | + -- never leaves \xC2 at first - 1 (openers are ASCII or the \xAB of a peeled |
| 167 | + -- «), so the guillemet check cannot straddle the already-peeled prefix. |
| 168 | + if last >= 2 and text:sub(last - 1, last) == '\xC2\xBB' then |
| 169 | + suffix = '\xC2\xBB' .. suffix |
| 170 | + last = last - 2 |
| 171 | + elseif trailing[text:sub(last, last)] then |
| 172 | + suffix = text:sub(last, last) .. suffix |
| 173 | + last = last - 1 |
| 174 | + else |
| 175 | + break |
| 176 | + end |
| 177 | + end |
| 178 | + |
| 179 | + return prefix, text:sub(first, last), suffix |
| 180 | +end |
| 181 | + |
| 182 | +--- Find a balanced bracket pair anywhere in the text and split around it. |
| 183 | +--- Walks the text from `start_pos` looking for an opening bracket whose matching |
| 184 | +--- closing bracket appears later in the string. Returns the text split into |
| 185 | +--- a prefix (up to and including the opening bracket), the inner content, and |
| 186 | +--- a suffix (closing bracket and everything after). |
| 187 | +--- Supports the same bracket pairs as `strip_surrounding`: |
| 188 | +--- () [] {} "" '' `` and the 2-byte UTF-8 guillemets «». |
| 189 | +--- @param text string The input text |
| 190 | +--- @param start_pos integer|nil Byte position to start searching from (default 1) |
| 191 | +--- @return string|nil prefix Text up to and including the opening bracket |
| 192 | +--- @return string|nil content Non-empty text between the brackets |
| 193 | +--- @return string|nil suffix Closing bracket and trailing text |
| 194 | +--- @return integer|nil open_pos Byte position of the opening bracket |
| 195 | +function M.find_bracketed_content(text, start_pos) |
| 196 | + if not text or #text < 2 then |
| 197 | + return nil, nil, nil, nil |
| 198 | + end |
| 199 | + start_pos = start_pos or 1 |
| 200 | + |
| 201 | + local balanced = { |
| 202 | + ['('] = ')', ['['] = ']', ['{'] = '}', |
| 203 | + ['"'] = '"', ["'"] = "'", ['`'] = '`', |
| 204 | + } |
| 205 | + |
| 206 | + local i = start_pos |
| 207 | + while i <= #text do |
| 208 | + -- UTF-8 guillemet «…» |
| 209 | + if text:sub(i, i + 1) == '\xC2\xAB' then |
| 210 | + local close_pos = text:find('\xC2\xBB', i + 2, true) |
| 211 | + if close_pos and close_pos > i + 2 then |
| 212 | + return text:sub(1, i + 1), text:sub(i + 2, close_pos - 1), text:sub(close_pos), i |
| 213 | + end |
| 214 | + i = i + 2 |
| 215 | + else |
| 216 | + local c = text:sub(i, i) |
| 217 | + local close_char = balanced[c] |
| 218 | + if close_char then |
| 219 | + local close_pos = text:find(close_char, i + 1, true) |
| 220 | + if close_pos and close_pos > i + 1 then |
| 221 | + return text:sub(1, i), text:sub(i + 1, close_pos - 1), text:sub(close_pos), i |
| 222 | + end |
| 223 | + end |
| 224 | + i = i + 1 |
| 225 | + end |
| 226 | + end |
| 227 | + |
| 228 | + return nil, nil, nil, nil |
| 229 | +end |
| 230 | + |
79 | 231 | -- ============================================================================ |
80 | 232 | -- ESCAPE UTILITIES |
81 | 233 | -- ============================================================================ |
@@ -106,10 +258,37 @@ function M.escape_typst(text) |
106 | 258 | end |
107 | 259 |
|
108 | 260 | --- Escape characters for Typst string literals (inside `"..."`). |
| 261 | +--- Handles backslash, double quote, newline, carriage return, and tab. |
109 | 262 | --- @param text string The text to escape |
110 | 263 | --- @return string The escaped text safe for Typst string literals |
111 | 264 | function M.escape_typst_string(text) |
112 | | - return text:gsub('\\', '\\\\'):gsub('"', '\\"') |
| 265 | + return (text |
| 266 | + :gsub('\\', '\\\\') |
| 267 | + :gsub('"', '\\"') |
| 268 | + :gsub('\n', '\\n') |
| 269 | + :gsub('\r', '\\r') |
| 270 | + :gsub('\t', '\\t')) |
| 271 | +end |
| 272 | + |
| 273 | +--- Escape characters for JavaScript string literals (inside `"..."` or `'...'`). |
| 274 | +--- Handles backslash, both quote styles, newlines, carriage returns, tabs, |
| 275 | +--- form feeds, and the `</` sequence so payloads cannot break out of a |
| 276 | +--- surrounding inline `<script>` block. |
| 277 | +--- @param text string|nil The text to escape |
| 278 | +--- @return string The escaped text safe for JavaScript string literals |
| 279 | +--- @usage local safe = M.escape_js_string([[a "b" </script>]]) |
| 280 | +function M.escape_js_string(text) |
| 281 | + if text == nil then return '' end |
| 282 | + if type(text) ~= 'string' then text = tostring(text) end |
| 283 | + return (text |
| 284 | + :gsub('\\', '\\\\') |
| 285 | + :gsub('"', '\\"') |
| 286 | + :gsub("'", "\\'") |
| 287 | + :gsub('\n', '\\n') |
| 288 | + :gsub('\r', '\\r') |
| 289 | + :gsub('\t', '\\t') |
| 290 | + :gsub('\f', '\\f') |
| 291 | + :gsub('</', '<\\/')) |
113 | 292 | end |
114 | 293 |
|
115 | 294 | --- Escape special Lua pattern characters for use in string.gsub. |
|
0 commit comments