Skip to content

Commit 6aa6b1f

Browse files
authored
refactor: replace monolithic utils.lua with focused modules (#27)
1 parent a2bf4ad commit 6aa6b1f

5 files changed

Lines changed: 314 additions & 653 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
## Unreleased
44

5-
No user-facing changes.
5+
### Refactoring
6+
7+
- refactor: Replace monolithic `utils.lua` with focused modules (`string.lua`, `logging.lua`, `metadata.lua`, `pandoc-helpers.lua`, `html.lua`, `paths.lua`, `colour.lua`).
68

79
## 1.3.0 (2026-02-21)
810

@@ -67,4 +69,3 @@ No user-facing changes.
6769

6870
## 0.1.0 (2022-09-12)
6971

70-
No user-facing changes.
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
--- MC HTML - HTML generation and dependency management for Quarto Lua filters and shortcodes
2+
--- @module html
3+
--- @license MIT
4+
--- @copyright 2026 Mickaël Canouil
5+
--- @author Mickaël Canouil
6+
--- @version 1.0.0
7+
8+
local M = {}
9+
10+
--- Load a sibling module from the same directory as this file.
11+
--- @param filename string The sibling module filename (e.g., 'string.lua')
12+
--- @return table The loaded module
13+
local function load_sibling(filename)
14+
local source = debug.getinfo(1, 'S').source:sub(2)
15+
local dir = source:match('(.*[/\\])') or ''
16+
return require((dir .. filename):gsub('%.lua$', ''))
17+
end
18+
19+
--- Load string module for escape_html and escape_attribute
20+
local str = load_sibling('string.lua')
21+
22+
-- ============================================================================
23+
-- HTML RAW GENERATION UTILITIES
24+
-- ============================================================================
25+
26+
--- Generates a raw HTML header element.
27+
--- @param level integer The header level (e.g., 2 for <h2>)
28+
--- @param text string|nil The header text
29+
--- @param id string The id attribute for the header
30+
--- @param classes table List of classes for the header
31+
--- @param attributes table|nil Additional HTML attributes
32+
--- @return string Raw HTML string for the header
33+
function M.raw_header(level, text, id, classes, attributes)
34+
local attr_str = ''
35+
if id and id ~= '' then
36+
attr_str = attr_str .. ' id="' .. str.escape_attribute(id) .. '"'
37+
end
38+
if classes and #classes > 0 then
39+
local escaped_classes = {}
40+
for i, cls in ipairs(classes) do
41+
escaped_classes[i] = str.escape_attribute(cls)
42+
end
43+
attr_str = attr_str .. ' class="' .. table.concat(escaped_classes, ' ') .. '"'
44+
end
45+
if attributes then
46+
for k, v in pairs(attributes) do
47+
attr_str = attr_str .. ' ' .. str.escape_attribute(k) .. '="' .. str.escape_attribute(v) .. '"'
48+
end
49+
end
50+
return string.format('<h%d%s>%s</h%d>', level, attr_str, str.escape_html(text or ''), level)
51+
end
52+
53+
-- ============================================================================
54+
-- HTML DEPENDENCY UTILITIES
55+
-- ============================================================================
56+
57+
--- Managed HTML dependency tracker
58+
--- Tracks which dependencies have been added to prevent duplication
59+
--- @type table<string, boolean>
60+
local dependency_tracker = {}
61+
62+
--- Ensure HTML dependency is added only once per document.
63+
--- Prevents duplicate dependency injection by tracking dependencies by name.
64+
--- Returns true if dependency was added, false if already present.
65+
---
66+
--- @param config table Dependency configuration with fields: name (required), version, scripts, stylesheets, head
67+
--- @return boolean True if dependency was added, false if already added
68+
--- @usage M.ensure_html_dependency({name = 'my-lib', version = '1.0.0', scripts = {'lib.js'}})
69+
function M.ensure_html_dependency(config)
70+
if not config or not config.name then
71+
error("HTML dependency configuration must include a 'name' field")
72+
end
73+
74+
--- @type string Unique key for this dependency
75+
local dep_key = config.name
76+
77+
-- Check if already added
78+
if dependency_tracker[dep_key] then
79+
return false
80+
end
81+
82+
-- Add the dependency
83+
quarto.doc.add_html_dependency(config)
84+
85+
-- Mark as added
86+
dependency_tracker[dep_key] = true
87+
return true
88+
end
89+
90+
--- Reset dependency tracker.
91+
--- Useful for testing or when processing multiple independent documents.
92+
--- In normal usage, this should not be called as dependencies persist per document.
93+
---
94+
--- @return nil
95+
function M.reset_dependencies()
96+
dependency_tracker = {}
97+
end
98+
99+
-- ============================================================================
100+
-- MODULE EXPORT
101+
-- ============================================================================
102+
103+
return M
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
--- MC String - String manipulation and escaping for Quarto Lua filters and shortcodes
2+
--- @module string
3+
--- @license MIT
4+
--- @copyright 2026 Mickaël Canouil
5+
--- @author Mickaël Canouil
6+
--- @version 1.0.0
7+
8+
local M = {}
9+
10+
-- ============================================================================
11+
-- STRING UTILITIES
12+
-- ============================================================================
13+
14+
--- Pandoc utility function for converting values to strings
15+
--- @type function
16+
M.stringify = pandoc.utils.stringify
17+
18+
--- Check if a string is empty or nil.
19+
--- Utility function to determine if a value is empty or nil,
20+
--- which is useful for parameter validation throughout the module.
21+
--- @param s string|nil|table The value to check for emptiness
22+
--- @return boolean True if the value is nil or empty, false otherwise
23+
--- @usage local result = M.is_empty("") -- returns true
24+
--- @usage local result = M.is_empty(nil) -- returns true
25+
--- @usage local result = M.is_empty("hello") -- returns false
26+
function M.is_empty(s)
27+
return s == nil or s == ''
28+
end
29+
30+
--- Escape special pattern characters in a string for Lua pattern matching
31+
--- @param s string The string to escape
32+
--- @return string The escaped string
33+
--- @usage local escaped = M.escape_pattern("user/repo#123")
34+
function M.escape_pattern(s)
35+
local escaped = s:gsub('([%^%$%(%)%%%.%[%]%*%+%-%?])', '%%%1')
36+
return escaped
37+
end
38+
39+
--- Split a string by a separator
40+
--- @param str string The string to split
41+
--- @param sep string The separator pattern
42+
--- @return table Array of string fields
43+
--- @usage local parts = M.split("a.b.c", ".")
44+
function M.split(str, sep)
45+
local fields = {}
46+
local pattern = string.format('([^%s]+)', sep)
47+
str:gsub(pattern, function(c) fields[#fields + 1] = c end)
48+
return fields
49+
end
50+
51+
--- Trim leading and trailing whitespace from a string
52+
--- @param str string The string to trim
53+
--- @return string The trimmed string
54+
--- @usage local trimmed = M.trim(" hello world ") -- returns "hello world"
55+
function M.trim(str)
56+
if str == nil then return '' end
57+
return str:match('^%s*(.-)%s*$')
58+
end
59+
60+
--- Convert any value to a string, handling Pandoc objects and empty values.
61+
--- Returns nil for empty or nil values, otherwise returns a string representation.
62+
--- @param val any The value to convert
63+
--- @return string|nil The string value or nil if empty
64+
--- @usage local str = M.to_string(kwargs.value)
65+
function M.to_string(val)
66+
if not val then return nil end
67+
if type(val) == 'string' then
68+
return val ~= '' and val or nil
69+
end
70+
-- Handle Pandoc objects
71+
if pandoc and pandoc.utils and pandoc.utils.stringify then
72+
local str = pandoc.utils.stringify(val)
73+
return str ~= '' and str or nil
74+
end
75+
local str = tostring(val)
76+
return str ~= '' and str or nil
77+
end
78+
79+
-- ============================================================================
80+
-- ESCAPE UTILITIES
81+
-- ============================================================================
82+
83+
--- Escape special LaTeX characters in text.
84+
--- @param text string The text to escape
85+
--- @return string The escaped text safe for LaTeX
86+
function M.escape_latex(text)
87+
text = string.gsub(text, '\\', '\\textbackslash{}')
88+
text = string.gsub(text, '%{', '\\{')
89+
text = string.gsub(text, '%}', '\\}')
90+
text = string.gsub(text, '%$', '\\$')
91+
text = string.gsub(text, '%&', '\\&')
92+
text = string.gsub(text, '%%', '\\%%')
93+
text = string.gsub(text, '%#', '\\#')
94+
text = string.gsub(text, '%^', '\\textasciicircum{}')
95+
text = string.gsub(text, '%_', '\\_')
96+
text = string.gsub(text, '~', '\\textasciitilde{}')
97+
return text
98+
end
99+
100+
--- Escape special Typst characters in text.
101+
--- @param text string The text to escape
102+
--- @return string The escaped text safe for Typst
103+
function M.escape_typst(text)
104+
text = string.gsub(text, '%#', '\\#')
105+
return text
106+
end
107+
108+
--- Escape characters for Typst string literals (inside `"..."`).
109+
--- @param text string The text to escape
110+
--- @return string The escaped text safe for Typst string literals
111+
function M.escape_typst_string(text)
112+
return text:gsub('\\', '\\\\'):gsub('"', '\\"')
113+
end
114+
115+
--- Escape special Lua pattern characters for use in string.gsub.
116+
--- @param text string The text containing characters to escape
117+
--- @return string The escaped text safe for Lua patterns
118+
function M.escape_lua_pattern(text)
119+
text = string.gsub(text, '%%', '%%%%')
120+
text = string.gsub(text, '%^', '%%^')
121+
text = string.gsub(text, '%$', '%%$')
122+
text = string.gsub(text, '%(', '%%(')
123+
text = string.gsub(text, '%)', '%%)')
124+
text = string.gsub(text, '%.', '%%.')
125+
text = string.gsub(text, '%[', '%%[')
126+
text = string.gsub(text, '%]', '%%]')
127+
text = string.gsub(text, '%*', '%%*')
128+
text = string.gsub(text, '%+', '%%+')
129+
text = string.gsub(text, '%-', '%%-')
130+
text = string.gsub(text, '%?', '%%?')
131+
return text
132+
end
133+
134+
--- Escape special HTML characters in text.
135+
--- Escapes &, <, >, ", and ' to prevent XSS and ensure valid HTML.
136+
--- @param text string The text to escape
137+
--- @return string Escaped text safe for use in HTML
138+
--- @usage local escaped = M.escape_html('Hello <World>')
139+
function M.escape_html(text)
140+
if text == nil then return '' end
141+
if type(text) ~= 'string' then text = tostring(text) end
142+
local result = text
143+
:gsub('&', '&amp;')
144+
:gsub('<', '&lt;')
145+
:gsub('>', '&gt;')
146+
:gsub('"', '&quot;')
147+
:gsub("'", '&#39;')
148+
return result
149+
end
150+
151+
--- Escape special HTML attribute characters.
152+
--- Escapes characters that could break attribute values.
153+
--- @param value string The attribute value to escape
154+
--- @return string Escaped value safe for use in HTML attributes
155+
--- @usage local escaped = M.escape_attribute('Hello "World"')
156+
function M.escape_attribute(value)
157+
if value == nil then return '' end
158+
if type(value) ~= 'string' then value = tostring(value) end
159+
local result = value
160+
:gsub('&', '&amp;')
161+
:gsub('"', '&quot;')
162+
:gsub('<', '&lt;')
163+
:gsub('>', '&gt;')
164+
return result
165+
end
166+
167+
--- Escape text for different formats.
168+
--- @param text string The text to escape
169+
--- @param format string The format to escape for (e.g., "latex", "typst", "lua")
170+
--- @return string The escaped text
171+
function M.escape_text(text, format)
172+
local escape_functions = {
173+
latex = M.escape_latex,
174+
typst = M.escape_typst,
175+
lua = M.escape_lua_pattern
176+
}
177+
178+
local escape = escape_functions[format]
179+
if escape then
180+
return escape(text)
181+
else
182+
error('Unsupported escape format: ' .. format)
183+
end
184+
end
185+
186+
--- Converts a string to a valid HTML id by lowercasing and replacing spaces.
187+
--- @param text string The text to convert
188+
--- @return string The HTML id
189+
function M.ascii_id(text)
190+
local id = text:lower():gsub('[^a-z0-9 ]', ''):gsub(' +', '-')
191+
return id
192+
end
193+
194+
-- ============================================================================
195+
-- MODULE EXPORT
196+
-- ============================================================================
197+
198+
return M

0 commit comments

Comments
 (0)