local _orig_find = string.find local _orig_match = string.match local REVERSE_THRESHOLD = 100 local LENGTH_THRESHOLD = 1000 function string.split(s, delimiter) if type(s) ~= "string" then error("string.split: first argument must be a string", 2) end if type(delimiter) ~= "string" then error("string.split: second argument must be a string", 2) end if delimiter == "" then local result = {} for i = 1, #s do result[i] = s:sub(i, i) end return result end local result = {} local start = 1 local delimiter_len = #delimiter while true do local pos = _orig_find(s, delimiter, start, true) -- Use original find if not pos then table.insert(result, s:sub(start)) break end table.insert(result, s:sub(start, pos - 1)) start = pos + delimiter_len end return result end getmetatable("").__index.split = string.split function string.join(arr, separator) if type(arr) ~= "table" then error("string.join: first argument must be a table", 2) end if type(separator) ~= "string" then error("string.join: second argument must be a string", 2) end return table.concat(arr, separator) end function string.trim(s, cutset) if type(s) ~= "string" then error("string.trim: first argument must be a string", 2) end if cutset then if type(cutset) ~= "string" then error("string.trim: second argument must be a string", 2) end local escaped = cutset:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1") local pattern = "^[" .. escaped .. "]*(.-)[" .. escaped .. "]*$" return s:match(pattern) else return s:match("^%s*(.-)%s*$") end end getmetatable("").__index.trim = string.trim function string.trim_left(s, cutset) if type(s) ~= "string" then error("string.trim_left: first argument must be a string", 2) end if cutset then if type(cutset) ~= "string" then error("string.trim_left: second argument must be a string", 2) end local pattern = "^[" .. cutset:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1") .. "]*" return s:gsub(pattern, "") else return s:match("^%s*(.*)") end end getmetatable("").__index.trim_left = string.trim_left function string.trim_right(s, cutset) if type(s) ~= "string" then error("string.trim_right: first argument must be a string", 2) end if cutset then if type(cutset) ~= "string" then error("string.trim_right: second argument must be a string", 2) end local escaped = cutset:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1") local pattern = "[" .. escaped .. "]*$" return s:gsub(pattern, "") else return s:match("(.-)%s*$") end end function string.title(s) if type(s) ~= "string" then error("string.title: argument must be a string", 2) end return s:gsub("(%w)([%w]*)", function(first, rest) return first:upper() .. rest:lower() end) end getmetatable("").__index.title = string.title function string.contains(s, substr) if type(s) ~= "string" then error("string.contains: first argument must be a string", 2) end if type(substr) ~= "string" then error("string.contains: second argument must be a string", 2) end return _orig_find(s, substr, 1, true) ~= nil end getmetatable("").__index.contains = string.contains function string.starts_with(s, prefix) if type(s) ~= "string" then error("string.starts_with: first argument must be a string", 2) end if type(prefix) ~= "string" then error("string.starts_with: second argument must be a string", 2) end return s:sub(1, #prefix) == prefix end getmetatable("").__index.starts_with = string.starts_with function string.ends_with(s, suffix) if type(s) ~= "string" then error("string.ends_with: first argument must be a string", 2) end if type(suffix) ~= "string" then error("string.ends_with: second argument must be a string", 2) end if #suffix == 0 then return true end return s:sub(-#suffix) == suffix end getmetatable("").__index.ends_with = string.ends_with function string.replace(s, old, new) if type(s) ~= "string" then error("string.replace: first argument must be a string", 2) end if type(old) ~= "string" then error("string.replace: second argument must be a string", 2) end if type(new) ~= "string" then error("string.replace: third argument must be a string", 2) end if old == "" then error("string.replace: cannot replace empty string", 2) end return s:gsub(old:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1"), new) end getmetatable("").__index.replace = string.replace function string.replace_n(s, old, new, n) if type(s) ~= "string" then error("string.replace_n: first argument must be a string", 2) end if type(old) ~= "string" then error("string.replace_n: second argument must be a string", 2) end if type(new) ~= "string" then error("string.replace_n: third argument must be a string", 2) end if type(n) ~= "number" or n < 0 or n ~= math.floor(n) then error("string.replace_n: fourth argument must be a non-negative integer", 2) end if old == "" then error("string.replace_n: cannot replace empty string", 2) end local escaped = old:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1") return (s:gsub(escaped, new, n)) end getmetatable("").__index.replace_n = string.replace_n function string.index(s, substr) if type(s) ~= "string" then error("string.index: first argument must be a string", 2) end if type(substr) ~= "string" then error("string.index: second argument must be a string", 2) end local pos = _orig_find(s, substr, 1, true) return pos end getmetatable("").__index.index = string.index function string.last_index(s, substr) if type(s) ~= "string" then error("string.last_index: first argument must be a string", 2) end if type(substr) ~= "string" then error("string.last_index: second argument must be a string", 2) end local last_pos = nil local pos = 1 while true do local found = _orig_find(s, substr, pos, true) if not found then break end last_pos = found pos = found + 1 end return last_pos end getmetatable("").__index.last_index = string.last_index function string.count(s, substr) if type(s) ~= "string" then error("string.count: first argument must be a string", 2) end if type(substr) ~= "string" then error("string.count: second argument must be a string", 2) end if substr == "" then return #s + 1 end local count = 0 local pos = 1 while true do local found = _orig_find(s, substr, pos, true) if not found then break end count = count + 1 pos = found + #substr end return count end getmetatable("").__index.count = string.count function string.repeat_(s, n) if type(s) ~= "string" then error("string.repeat_: first argument must be a string", 2) end if type(n) ~= "number" or n < 0 or n ~= math.floor(n) then error("string.repeat_: second argument must be a non-negative integer", 2) end return string.rep(s, n) end function string.reverse(s) if type(s) ~= "string" then error("string.reverse: argument must be a string", 2) end if #s > REVERSE_THRESHOLD then local result, err = moonshark.string_reverse(s) if not result then error("string.reverse: " .. err, 2) end return result else local result = {} for i = #s, 1, -1 do result[#result + 1] = s:sub(i, i) end return table.concat(result) end end getmetatable("").__index.reverse = string.reverse function string.length(s) if type(s) ~= "string" then error("string.length: argument must be a string", 2) end return moonshark.string_length(s) end getmetatable("").__index.length = string.length function string.byte_length(s) if type(s) ~= "string" then error("string.byte_length: argument must be a string", 2) end return moonshark.string_byte_length(s) end getmetatable("").__index.byte_length = string.byte_length function string.lines(s) if type(s) ~= "string" then error("string.lines: argument must be a string", 2) end if s == "" then return {""} end s = s:gsub("\r\n", "\n"):gsub("\r", "\n") local lines = {} for line in (s .. "\n"):gmatch("([^\n]*)\n") do table.insert(lines, line) end if #lines > 0 and lines[#lines] == "" then table.remove(lines) end return lines end getmetatable("").__index.lines = string.lines function string.words(s) if type(s) ~= "string" then error("string.words: argument must be a string", 2) end local words = {} for word in s:gmatch("%S+") do table.insert(words, word) end return words end getmetatable("").__index.words = string.words function string.pad_left(s, width, pad_char) if type(s) ~= "string" then error("string.pad_left: first argument must be a string", 2) end if type(width) ~= "number" or width < 0 or width ~= math.floor(width) then error("string.pad_left: second argument must be a non-negative integer", 2) end pad_char = pad_char or " " if type(pad_char) ~= "string" then error("string.pad_left: third argument must be a string", 2) end if #pad_char == 0 then pad_char = " " else pad_char = pad_char:sub(1,1) end local current_len = string.length(s) if current_len >= width then return s end return string.rep(pad_char, width - current_len) .. s end getmetatable("").__index.pad_left = string.pad_left function string.pad_right(s, width, pad_char) if type(s) ~= "string" then error("string.pad_right: first argument must be a string", 2) end if type(width) ~= "number" or width < 0 or width ~= math.floor(width) then error("string.pad_right: second argument must be a non-negative integer", 2) end pad_char = pad_char or " " if type(pad_char) ~= "string" then error("string.pad_right: third argument must be a string", 2) end if #pad_char == 0 then pad_char = " " else pad_char = pad_char:sub(1,1) end local current_len = string.length(s) if current_len >= width then return s end return s .. string.rep(pad_char, width - current_len) end getmetatable("").__index.pad_right = string.pad_right function string.slice(s, start, end_pos) if type(s) ~= "string" then error("string.slice: first argument must be a string", 2) end if type(start) ~= "number" or start ~= math.floor(start) then error("string.slice: second argument must be an integer", 2) end if end_pos ~= nil and (type(end_pos) ~= "number" or end_pos ~= math.floor(end_pos)) then error("string.slice: third argument must be an integer", 2) end local result, err = moonshark.string_slice(s, start, end_pos) if not result then error("string.slice: " .. err, 2) end return result end getmetatable("").__index.slice = string.slice function string.find(s, pattern, init, plain) if type(s) ~= "string" then error("string.find: first argument must be a string", 2) end if type(pattern) ~= "string" then error("string.find: second argument must be a string", 2) end return _orig_find(s, pattern, init, plain) end getmetatable("").__index.find = string.find function string.find_match(s, pattern, init, plain) if type(s) ~= "string" then error("string.find_match: first argument must be a string", 2) end if type(pattern) ~= "string" then error("string.find_match: second argument must be a string", 2) end local start_pos, end_pos = _orig_find(s, pattern, init, plain) if start_pos then return s:sub(start_pos, end_pos) end return nil end getmetatable("").__index.find_match = string.find_match function string.find_all(s, pattern) if type(s) ~= "string" then error("string.find_all: first argument must be a string", 2) end if type(pattern) ~= "string" then error("string.find_all: second argument must be a string", 2) end local matches = {} for match in s:gmatch(pattern) do table.insert(matches, match) end return matches end getmetatable("").__index.find_all = string.find_all function string.to_number(s) if type(s) ~= "string" then error("string.to_number: argument must be a string", 2) end s = string.trim(s) return tonumber(s) end getmetatable("").__index.to_number = string.to_number function string.is_numeric(s) if type(s) ~= "string" then error("string.is_numeric: argument must be a string", 2) end s = string.trim(s) return tonumber(s) ~= nil end getmetatable("").__index.is_numeric = string.is_numeric function string.is_alpha(s) if type(s) ~= "string" then error("string.is_alpha: argument must be a string", 2) end if #s == 0 then return false end return s:match("^%a+$") ~= nil end getmetatable("").__index.is_alpha = string.is_alpha function string.is_alphanumeric(s) if type(s) ~= "string" then error("string.is_alphanumeric: argument must be a string", 2) end if #s == 0 then return false end return s:match("^%w+$") ~= nil end getmetatable("").__index.is_alphanumeric = string.is_alphanumeric function string.is_utf8(s) if type(s) ~= "string" then error("string.is_utf8: argument must be a string", 2) end return moonshark.string_is_valid_utf8(s) end getmetatable("").__index.is_utf8 = string.is_utf8 function string.is_empty(s) return s == nil or s == "" end getmetatable("").__index.is_empty = string.is_empty function string.is_blank(s) return s == nil or s == "" or string.trim(s) == "" end getmetatable("").__index.is_blank = string.is_blank function string.capitalize(s) if type(s) ~= "string" then error("string.capitalize: argument must be a string", 2) end return s:gsub("(%a)([%w_']*)", function(first, rest) return first:upper() .. rest:lower() end) end getmetatable("").__index.capitalize = string.capitalize function string.camel_case(s) if type(s) ~= "string" then error("string.camel_case: argument must be a string", 2) end local words = string.words(s) if #words == 0 then return s end local result = words[1]:lower() for i = 2, #words do result = result .. words[i]:sub(1,1):upper() .. words[i]:sub(2):lower() end return result end getmetatable("").__index.camel_case = string.camel_case function string.pascal_case(s) if type(s) ~= "string" then error("string.pascal_case: argument must be a string", 2) end local words = string.words(s) local result = "" for _, word in ipairs(words) do result = result .. word:sub(1,1):upper() .. word:sub(2):lower() end return result end getmetatable("").__index.pascal_case = string.pascal_case function string.snake_case(s) if type(s) ~= "string" then error("string.snake_case: argument must be a string", 2) end local words = string.words(s) local result = {} for _, word in ipairs(words) do table.insert(result, word:lower()) end return table.concat(result, "_") end getmetatable("").__index.snake_case = string.snake_case function string.kebab_case(s) if type(s) ~= "string" then error("string.kebab_case: argument must be a string", 2) end local words = string.words(s) local result = {} for _, word in ipairs(words) do table.insert(result, word:lower()) end return table.concat(result, "-") end getmetatable("").__index.kebab_case = string.kebab_case function string.screaming_snake_case(s) if type(s) ~= "string" then error("string.screaming_snake_case: argument must be a string", 2) end return string.snake_case(s):upper() end getmetatable("").__index.screaming_snake_case = string.screaming_snake_case function string.center(s, width, fill_char) if type(s) ~= "string" then error("string.center: first argument must be a string", 2) end if type(width) ~= "number" or width < 0 or width ~= math.floor(width) then error("string.center: second argument must be a non-negative integer", 2) end fill_char = fill_char or " " if type(fill_char) ~= "string" or #fill_char == 0 then error("string.center: fill character must be a non-empty string", 2) end fill_char = fill_char:sub(1,1) local len = string.length(s) if len >= width then return s end local pad_total = width - len local pad_left = math.floor(pad_total / 2) local pad_right = pad_total - pad_left return string.rep(fill_char, pad_left) .. s .. string.rep(fill_char, pad_right) end getmetatable("").__index.center = string.center function string.truncate(s, max_length, suffix) if type(s) ~= "string" then error("string.truncate: first argument must be a string", 2) end if type(max_length) ~= "number" or max_length < 0 or max_length ~= math.floor(max_length) then error("string.truncate: second argument must be a non-negative integer", 2) end suffix = suffix or "..." if type(suffix) ~= "string" then error("string.truncate: third argument must be a string", 2) end local len = string.length(s) if len <= max_length then return s end local suffix_len = string.length(suffix) if max_length <= suffix_len then return string.slice(suffix, 1, max_length) end local main_part = string.slice(s, 1, max_length - suffix_len) main_part = string.trim_right(main_part) return main_part .. suffix end getmetatable("").__index.truncate = string.truncate function string.wrap(s, width) if type(s) ~= "string" then error("string.wrap: first argument must be a string", 2) end if type(width) ~= "number" or width <= 0 or width ~= math.floor(width) then error("string.wrap: second argument must be a positive integer", 2) end if s == "" then return {""} end local words = string.words(s) if #words == 0 then return {""} end local lines = {} local current_line = "" for _, word in ipairs(words) do if string.length(word) > width then if current_line ~= "" then table.insert(lines, current_line) current_line = "" end table.insert(lines, word) elseif current_line == "" then current_line = word elseif string.length(current_line) + 1 + string.length(word) <= width then current_line = current_line .. " " .. word else table.insert(lines, current_line) current_line = word end end if current_line ~= "" then table.insert(lines, current_line) end return lines end getmetatable("").__index.wrap = string.wrap function string.dedent(s) if type(s) ~= "string" then error("string.dedent: argument must be a string", 2) end local lines = string.lines(s) if #lines == 0 then return s end local min_indent = math.huge for _, line in ipairs(lines) do if string.trim(line) ~= "" then local indent = 0 for i = 1, #line do if line:sub(i,i) == " " then indent = indent + 1 else break end end min_indent = math.min(min_indent, indent) end end if min_indent == math.huge then return s end local result = {} for _, line in ipairs(lines) do if string.trim(line) == "" then table.insert(result, "") else table.insert(result, line:sub(min_indent + 1)) end end return table.concat(result, "\n") end getmetatable("").__index.dedent = string.dedent function string.escape(s) if type(s) ~= "string" then error("string.escape: argument must be a string", 2) end return (s:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1")) end getmetatable("").__index.escape = string.escape function string.shell_quote(s) if type(s) ~= "string" then error("string.shell_quote: argument must be a string", 2) end if s:match("^[%w%.%-_/]+$") then return s end return "'" .. s:gsub("'", "'\"'\"'") .. "'" end getmetatable("").__index.shell_quote = string.shell_quote function string.url_encode(s) if type(s) ~= "string" then error("string.url_encode: argument must be a string", 2) end return s:gsub("([^%w%-%.%_%~])", function(c) return string.format("%%%02X", string.byte(c)) end) end getmetatable("").__index.url_encode = string.url_encode function string.url_decode(s) if type(s) ~= "string" then error("string.url_decode: argument must be a string", 2) end s = s:gsub("+", " ") return s:gsub("%%(%x%x)", function(hex) return string.char(tonumber(hex, 16)) end) end getmetatable("").__index.url_decode = string.url_decode function string.slug(s) if type(s) ~= "string" then error("string.slug: argument must be a string", 2) end if s == "" then return "" end local result = s:lower() -- Remove accents first result = string.remove_accents(result) -- Keep only alphanumeric, spaces, and hyphens result = result:gsub("[^%w%s%-]", "") -- Replace spaces with hyphens result = result:gsub("%s+", "-") -- Remove duplicate hyphens result = result:gsub("%-+", "-") -- Remove leading/trailing hyphens result = result:gsub("^%-", "") result = result:gsub("%-$", "") return result end getmetatable("").__index.slug = string.slug function string.iequals(a, b) if type(a) ~= "string" then error("string.iequals: first argument must be a string", 2) end if type(b) ~= "string" then error("string.iequals: second argument must be a string", 2) end return string.lower(a) == string.lower(b) end getmetatable("").__index.iequals = string.iequals function string.is_whitespace(s) if type(s) ~= "string" then error("string.is_whitespace: argument must be a string", 2) end return s:match("^%s*$") ~= nil end getmetatable("").__index.is_whitespace = string.is_whitespace function string.strip_whitespace(s) if type(s) ~= "string" then error("string.strip_whitespace: argument must be a string", 2) end return s:gsub("%s", "") end getmetatable("").__index.strip_whitespace = string.strip_whitespace function string.normalize_whitespace(s) if type(s) ~= "string" then error("string.normalize_whitespace: argument must be a string", 2) end return string.trim((s:gsub("%s+", " "))) end getmetatable("").__index.normalize_whitespace = string.normalize_whitespace function string.extract_numbers(s) if type(s) ~= "string" then error("string.extract_numbers: argument must be a string", 2) end local numbers = {} for num in s:gmatch("%-?%d+%.?%d*") do local n = tonumber(num) if n then table.insert(numbers, n) end end return numbers end getmetatable("").__index.extract_numbers = string.extract_numbers function string.remove_accents(s) if type(s) ~= "string" then error("string.remove_accents: argument must be a string", 2) end local accents = { ["á"] = "a", ["à"] = "a", ["ä"] = "a", ["â"] = "a", ["ã"] = "a", ["å"] = "a", ["Á"] = "A", ["À"] = "A", ["Ä"] = "A", ["Â"] = "A", ["Ã"] = "A", ["Å"] = "A", ["é"] = "e", ["è"] = "e", ["ë"] = "e", ["ê"] = "e", ["É"] = "E", ["È"] = "E", ["Ë"] = "E", ["Ê"] = "E", ["í"] = "i", ["ì"] = "i", ["ï"] = "i", ["î"] = "i", ["Í"] = "I", ["Ì"] = "I", ["Ï"] = "I", ["Î"] = "I", ["ó"] = "o", ["ò"] = "o", ["ö"] = "o", ["ô"] = "o", ["õ"] = "o", ["Ó"] = "O", ["Ò"] = "O", ["Ö"] = "O", ["Ô"] = "O", ["Õ"] = "O", ["ú"] = "u", ["ù"] = "u", ["ü"] = "u", ["û"] = "u", ["Ú"] = "U", ["Ù"] = "U", ["Ü"] = "U", ["Û"] = "U", ["ñ"] = "n", ["Ñ"] = "N", ["ç"] = "c", ["Ç"] = "C" } local result = s for accented, plain in pairs(accents) do result = result:gsub(accented, plain) end return result end getmetatable("").__index.remove_accents = string.remove_accents function string.template(template_str, vars) if type(template_str) ~= "string" then error("string.template: first argument must be a string", 2) end if type(vars) ~= "table" then error("string.template: second argument must be a table", 2) end return template_str:gsub("%${([%w_%.-]+)}", function(path) local value = vars -- Handle simple variables (no dots) if not path:match("%.") then return tostring(value[path] or "") end -- Handle nested properties for key in path:gmatch("[^%.]+") do if type(value) == "table" and value[key] ~= nil then value = value[key] else return "" end end return tostring(value) end) end getmetatable("").__index.template = string.template function string.random(length, charset) local result, err = moonshark.random_string(length, charset) if not result then error(err) end return result end -- Template processing with code execution function string.render(template_str, env) local function is_control_structure(code) -- Check if code is a control structure that doesn't produce output local trimmed = code:match("^%s*(.-)%s*$") return trimmed == "else" or trimmed == "end" or trimmed:match("^if%s") or trimmed:match("^elseif%s") or trimmed:match("^for%s") or trimmed:match("^while%s") or trimmed:match("^repeat%s*$") or trimmed:match("^until%s") or trimmed:match("^do%s*$") or trimmed:match("^local%s") or trimmed:match("^function%s") or trimmed:match(".*=%s*function%s*%(") or trimmed:match(".*then%s*$") or trimmed:match(".*do%s*$") end local pos, chunks = 1, {} while pos <= #template_str do local unescaped_start = template_str:find("{{{", pos, true) local escaped_start = template_str:find("{{", pos, true) local start, tag_type, open_len if unescaped_start and (not escaped_start or unescaped_start <= escaped_start) then start, tag_type, open_len = unescaped_start, "-", 3 elseif escaped_start then start, tag_type, open_len = escaped_start, "=", 2 else table.insert(chunks, template_str:sub(pos)) break end if start > pos then table.insert(chunks, template_str:sub(pos, start-1)) end pos = start + open_len local close_tag = tag_type == "-" and "}}}" or "}}" local close_start, close_stop = template_str:find(close_tag, pos, true) if not close_start then error("Failed to find closing tag at position " .. pos) end local code = template_str:sub(pos, close_start-1):match("^%s*(.-)%s*$") local is_control = is_control_structure(code) table.insert(chunks, {tag_type, code, pos, is_control}) pos = close_stop + 1 end local buffer = {"local _tostring, _escape, _b, _b_i = ...\n"} for _, chunk in ipairs(chunks) do local t = type(chunk) if t == "string" then table.insert(buffer, "_b_i = _b_i + 1\n") table.insert(buffer, "_b[_b_i] = " .. string.format("%q", chunk) .. "\n") else local tag_type, code, pos, is_control = chunk[1], chunk[2], chunk[3], chunk[4] if is_control then -- Control structure - just insert as raw Lua code table.insert(buffer, "--[[" .. pos .. "]] " .. code .. "\n") elseif tag_type == "=" then -- Simple variable check if code:match("^[%w_]+$") then table.insert(buffer, "_b_i = _b_i + 1\n") table.insert(buffer, "--[[" .. pos .. "]] _b[_b_i] = _escape(_tostring(" .. code .. "))\n") else -- Expression output with escaping table.insert(buffer, "_b_i = _b_i + 1\n") table.insert(buffer, "--[[" .. pos .. "]] _b[_b_i] = _escape(_tostring(" .. code .. "))\n") end elseif tag_type == "-" then -- Unescaped output table.insert(buffer, "_b_i = _b_i + 1\n") table.insert(buffer, "--[[" .. pos .. "]] _b[_b_i] = _tostring(" .. code .. ")\n") end end end table.insert(buffer, "return _b") local generated_code = table.concat(buffer) -- DEBUG: Uncomment to see generated code -- print("Generated Lua code:") -- print(generated_code) -- print("---") local fn, err = loadstring(generated_code) if not fn then print("Generated code that failed to compile:") print(generated_code) error(err) end env = env or {} local runtime_env = setmetatable({}, {__index = function(_, k) return env[k] or _G[k] end}) setfenv(fn, runtime_env) local output_buffer = {} fn(tostring, html_special_chars, output_buffer, 0) return table.concat(output_buffer) end -- Named placeholder processing function string.parse(template_str, env) local pos, output = 1, {} env = env or {} while pos <= #template_str do local unescaped_start, unescaped_end, unescaped_name = template_str:find("{{{%s*([%w_]+)%s*}}}", pos) local escaped_start, escaped_end, escaped_name = template_str:find("{{%s*([%w_]+)%s*}}", pos) local next_pos, placeholder_end, name, escaped if unescaped_start and (not escaped_start or unescaped_start <= escaped_start) then next_pos, placeholder_end, name, escaped = unescaped_start, unescaped_end, unescaped_name, false elseif escaped_start then next_pos, placeholder_end, name, escaped = escaped_start, escaped_end, escaped_name, true else local text = template_str:sub(pos) if text and #text > 0 then table.insert(output, text) end break end local text = template_str:sub(pos, next_pos - 1) if text and #text > 0 then table.insert(output, text) end local value = env[name] local str = tostring(value or "") if escaped then str:html_special_chars() end table.insert(output, str) pos = placeholder_end + 1 end return table.concat(output) end -- Indexed placeholder processing function string.iparse(template_str, values) local pos, output, value_index = 1, {}, 1 values = values or {} while pos <= #template_str do local unescaped_start, unescaped_end = template_str:find("{{{}}}", pos, true) local escaped_start, escaped_end = template_str:find("{{}}", pos, true) local next_pos, placeholder_end, escaped if unescaped_start and (not escaped_start or unescaped_start <= escaped_start) then next_pos, placeholder_end, escaped = unescaped_start, unescaped_end, false elseif escaped_start then next_pos, placeholder_end, escaped = escaped_start, escaped_end, true else local text = template_str:sub(pos) if text and #text > 0 then table.insert(output, text) end break end local text = template_str:sub(pos, next_pos - 1) if text and #text > 0 then table.insert(output, text) end local value = values[value_index] local str = tostring(value or "") if escaped then str:html_special_chars() end table.insert(output, str) pos = placeholder_end + 1 value_index = value_index + 1 end return table.concat(output) end string.special_chars_pattern = '[&<>"\']' string.entity_decode_pattern = '&[#%w]+;' string.special_encode_map = { ['&'] = '&', ['<'] = '<', ['>'] = '>', ['"'] = '"', ["'"] = ''' } string.special_decode_map = { ['&'] = '&', ['<'] = '<', ['>'] = '>', ['"'] = '"', ['''] = "'", ['''] = "'" } string.extended_encode_map = { -- Special chars ['&'] = '&', ['<'] = '<', ['>'] = '>', ['"'] = '"', ["'"] = ''', -- Extended characters [' '] = ' ', ['¡'] = '¡', ['¢'] = '¢', ['£'] = '£', ['¤'] = '¤', ['¥'] = '¥', ['¦'] = '¦', ['§'] = '§', ['¨'] = '¨', ['©'] = '©', ['ª'] = 'ª', ['«'] = '«', ['¬'] = '¬', ['®'] = '®', ['¯'] = '¯', ['°'] = '°', ['±'] = '±', ['²'] = '²', ['³'] = '³', ['´'] = '´', ['µ'] = 'µ', ['¶'] = '¶', ['·'] = '·', ['¸'] = '¸', ['¹'] = '¹', ['º'] = 'º', ['»'] = '»', ['¼'] = '¼', ['½'] = '½', ['¾'] = '¾', ['¿'] = '¿', ['À'] = 'À', ['Á'] = 'Á', ['Â'] = 'Â', ['Ã'] = 'Ã', ['Ä'] = 'Ä', ['Å'] = 'Å', ['Æ'] = 'Æ', ['Ç'] = 'Ç', ['È'] = 'È', ['É'] = 'É', ['Ê'] = 'Ê', ['Ë'] = 'Ë', ['Ì'] = 'Ì', ['Í'] = 'Í', ['Î'] = 'Î', ['Ï'] = 'Ï', ['Ð'] = 'Ð', ['Ñ'] = 'Ñ', ['Ò'] = 'Ò', ['Ó'] = 'Ó', ['Ô'] = 'Ô', ['Õ'] = 'Õ', ['Ö'] = 'Ö', ['×'] = '×', ['Ø'] = 'Ø', ['Ù'] = 'Ù', ['Ú'] = 'Ú', ['Û'] = 'Û', ['Ü'] = 'Ü', ['Ý'] = 'Ý', ['Þ'] = 'Þ', ['ß'] = 'ß', ['à'] = 'à', ['á'] = 'á', ['â'] = 'â', ['ã'] = 'ã', ['ä'] = 'ä', ['å'] = 'å', ['æ'] = 'æ', ['ç'] = 'ç', ['è'] = 'è', ['é'] = 'é', ['ê'] = 'ê', ['ë'] = 'ë', ['ì'] = 'ì', ['í'] = 'í', ['î'] = 'î', ['ï'] = 'ï', ['ð'] = 'ð', ['ñ'] = 'ñ', ['ò'] = 'ò', ['ó'] = 'ó', ['ô'] = 'ô', ['õ'] = 'õ', ['ö'] = 'ö', ['÷'] = '÷', ['ø'] = 'ø', ['ù'] = 'ù', ['ú'] = 'ú', ['û'] = 'û', ['ü'] = 'ü', ['ý'] = 'ý', ['þ'] = 'þ', ['ÿ'] = 'ÿ' } string.extended_decode_map = {} for char, entity in pairs(string.extended_encode_map) do string.extended_decode_map[entity] = char end -- Add common named entities not in extended_encode_map string.extended_decode_map['''] = "'" string.extended_decode_map[' '] = ' ' -- Converts HTML special characters (&, <, >, ", ') to entities function string.html_special_chars(str) if not str then return nil end if not str:find(string.special_chars_pattern) then return str end return (str:gsub(string.special_chars_pattern, string.special_encode_map)) end getmetatable("").__index.html_special_chars = string.html_special_chars -- Decodes HTML special character entities back to characters function string.html_special_chars_decode(str) if not str then return nil end return (str:gsub('&[lg]t;', string.special_decode_map) :gsub('"', '"') :gsub(''', "'") :gsub(''', "'") :gsub('&', '&')) -- Must be last to avoid double-decoding end getmetatable("").__index.html_special_chars_decode = string.html_special_chars_decode -- More comprehensive HTML entity encoding -- Handles special chars plus extended Latin-1 characters function string.html_entities(str) if not str then return nil end local result = {} local result_len = 0 for i = 1, #str do local char = str:sub(i, i) local entity = string.extended_encode_map[char] if entity then result_len = result_len + 1 result[result_len] = entity else local byte = string.byte(char) if byte > 127 then -- Encode high-bit characters as numeric entities result_len = result_len + 1 result[result_len] = '&#' .. byte .. ';' else result_len = result_len + 1 result[result_len] = char end end end return table.concat(result) end getmetatable("").__index.html_entities = string.html_entities function string.html_entities_decode(str) if not str then return nil end -- Handle numeric entities first local result = str:gsub('&#(%d+);', function(num) local n = tonumber(num) if n and n >= 0 and n <= 255 then return string.char(n) end return '&#' .. num .. ';' -- Return unchanged if invalid end) -- Handle named entities result = result:gsub(string.entity_decode_pattern, function(entity) return string.extended_decode_map[entity] or entity end) return result end getmetatable("").__index.html_entities_decode = string.html_entities_decode