Module:Punycode: Difference between revisions

// via Wikitext Extension for VSCode
Tag: Reverted
// via Wikitext Extension for VSCode
 
(5 intermediate revisions by the same user not shown)
Line 1: Line 1:
-- Module:Punycode –  pure Lua 5.1 implementation of RFC 3492
--[[
-- Provides:
* Name: Punycode
--  punycode.encode(label)  – encode one DNS label (no dots) to Punycode
* Author: Mark W. Datysgeld
--  punycode.decode(label)  – decode one Punycode label
* Description: RFC3492 Punycode implementation for IDN support with caching
--  punycode.toASCII(domain) – Unicode → IDNA (handles dots, adds xn--)
* Notes: encode/decode for single labels (no dots); toASCII/toUnicode for full domains (handles dots, xn-- prefixes); UTF-8 compatible with mw.ustring fallback; includes caching for performance
--   punycode.toUnicode(domain) – IDNA → Unicode
]]
-- No external dependencies; works in stock Lua 5.1 and MediaWiki Scribunto.
local punycode = {}


local punycode = {}
----------------------------------------------------------------
-- Caches (persist only for a single page render)
----------------------------------------------------------------
local encodeCache, decodeCache = {}, {}


------------------------------------------------------------
----------------------------------------------------------------
-- RFC 3492 constants
-- Constants from RFC 3492
------------------------------------------------------------
----------------------------------------------------------------
local base, tmin, tmax = 36, 1, 26
local base, tmin, tmax = 36, 1, 26
local skew, damp      = 38, 700
local skew, damp      = 38, 700
local initial_bias    = 72
local initial_bias    = 72
local initial_n        = 128       -- 0x80
local initial_n        = 128           -- 0x80
local delim            = '-'       -- ASCII hyphen
local delimiter        = '-'           -- ASCII hyphen


------------------------------------------------------------
----------------------------------------------------------------
-- UTF-8 helpers (pure Lua 5.1 – no bit32 / utf8 libs)
-- UTF-8 helpers (mw.ustring exists in Scribunto; falls back otherwise)
------------------------------------------------------------
----------------------------------------------------------------
local us = mw and mw.ustring
local function toCodePoints(s)
local function toCodePoints(s)
    if s == "" then return {} end
    if us then
        local cps, i = {}, 1
        for ch in us.gmatch(s, ".") do
            cps[i] = us.codepoint(ch)
            i = i + 1
        end
        return cps
    end
    -- plain Lua 5.1 fallback (minimal; good enough for Punycode paths)
     local cps, i, len = {}, 1, #s
     local cps, i, len = {}, 1, #s
     while i <= len do
     while i <= len do
         local b1 = s:byte(i)
         local b1 = s:byte(i)
         if b1 < 0x80 then
         if b1 < 0x80 then
             cps[#cps + 1] = b1
             cps[#cps + 1], i = b1, i + 1
            i = i + 1
         elseif b1 < 0xE0 then
         elseif b1 < 0xE0 then -- 2-byte
             local b2 = s:byte(i + 1)
             local b2 = s:byte(i + 1)
             cps[#cps + 1] = (b1 - 0xC0) * 0x40 + (b2 - 0x80)
             cps[#cps + 1] = (b1 - 0xC0) * 0x40 + (b2 - 0x80)
             i = i + 2
             i = i + 2
         elseif b1 < 0xF0 then -- 3-byte
         elseif b1 < 0xF0 then
             local b2, b3 = s:byte(i + 1, i + 2)
             local b2, b3 = s:byte(i + 1, i + 2)
             cps[#cps + 1] = (b1 - 0xE0) * 0x1000 + (b2 - 0x80) * 0x40 + (b3 - 0x80)
             cps[#cps + 1] =
                  (b1 - 0xE0) * 0x1000
                + (b2 - 0x80) * 0x40
                + (b3 - 0x80)
             i = i + 3
             i = i + 3
         else                 -- 4-byte
         else
             local b2, b3, b4 = s:byte(i + 1, i + 3)
             local b2, b3, b4 = s:byte(i + 1, i + 3)
             cps[#cps + 1] =
             cps[#cps + 1] =
                (b1 - 0xF0) * 0x40000 +
                  (b1 - 0xF0) * 0x40000
                 (b2 - 0x80) * 0x1000 +
                 + (b2 - 0x80) * 0x1000
                 (b3 - 0x80) * 0x40   +
                 + (b3 - 0x80) * 0x40
                 (b4 - 0x80)
                 + (b4 - 0x80)
             i = i + 4
             i = i + 4
         end
         end
Line 49: Line 65:
end
end


local function cpToUtf8(cp)
local function fromCodePoints(cps)
    if cp < 0x80 then
    if us then
        return string.char(cp)
        local out = {}
    elseif cp < 0x800 then
        for i = 1, #cps do out[i] = us.char(cps[i]) end
        return string.char(
        return table.concat(out)
            0xC0 + math.floor(cp / 0x40),
    end
            0x80 + (cp % 0x40)
    local function cp2utf8(cp)
         )
        if cp < 0x80   then return string.char(cp) end
    elseif cp < 0x10000 then
        if cp < 0x800 then
        return string.char(
            return string.char(
            0xE0 + math.floor(cp / 0x1000),
                0xC0 + math.floor(cp / 0x40),
            0x80 + (math.floor(cp / 0x40) % 0x40),
                0x80 + (cp % 0x40)
            0x80 + (cp % 0x40)
            )
        )
         end
    else
        if cp < 0x10000 then
            return string.char(
                0xE0 + math.floor(cp / 0x1000),
                0x80 + (math.floor(cp / 0x40) % 0x40),
                0x80 + (cp % 0x40)
            )
        end
         return string.char(
         return string.char(
             0xF0 + math.floor(cp / 0x40000),
             0xF0 + math.floor(cp / 0x40000),
Line 71: Line 93:
         )
         )
     end
     end
end
local function fromCodePoints(cps)
     local out = {}
     local out = {}
     for i = 1, #cps do
     for i = 1, #cps do out[i] = cp2utf8(cps[i]) end
        out[i] = cpToUtf8(cps[i])
    end
     return table.concat(out)
     return table.concat(out)
end
end


------------------------------------------------------------
----------------------------------------------------------------
-- Punycode helpers
-- RFC 3492 helpers
------------------------------------------------------------
----------------------------------------------------------------
local function digitToBasic(d)
local function digitToBasic(d)
     return string.char(d < 26 and (d + 97) or (d - 26 + 48)) -- a-z / 0-9
     return string.char(d < 26 and (d + 97) or (d - 26 + 48)) -- a-z / 0-9
end
end


local function basicToDigit(cp)
local function basicToDigit(byte)
     if cp >= 48 and cp <= 57  then return cp - 22 end -- '0'-'9' → 26-35
     if byte >= 48 and byte <= 57  then return byte - 22 end -- '0'-'9' → 26-35
     if cp >= 65 and cp <= 90  then return cp - 65 end -- 'A'-'Z'
     if byte >= 65 and byte <= 90  then return byte - 65 end -- 'A'-'Z'
     if cp >= 97 and cp <= 122 then return cp - 97 end -- 'a'-'z'
     if byte >= 97 and byte <= 122 then return byte - 97 end -- 'a'-'z'
     return base                                         -- invalid
     return base                                             -- invalid
end
end


Line 106: Line 123:
end
end


------------------------------------------------------------
----------------------------------------------------------------
-- Encode / decode a single label
-- Single-label Punycode encode / decode
------------------------------------------------------------
----------------------------------------------------------------
local encodeCache, decodeCache = {}, {}
local function isASCII(str)
    for i = 1, #str do if str:byte(i) > 127 then return false end end
    return true
end


function punycode.encode(label)
function punycode.encode(label)
     if label == ""       then return "" end
     if not label or label == "" then return "" end
    label = label:gsub("%.$", "")              -- strip *trailing* dot
    if label:find("%.") then
        error("punycode.encode: one label at a time (no dots)")
    end
    label = (us and us.lower or string.lower)(label)
     if encodeCache[label] then return encodeCache[label] end
     if encodeCache[label] then return encodeCache[label] end
    if label:find("%.")  then error("punycode.encode: single label expected") end


    label = string.lower(label)          -- IDNA case-insensitive
     local cp_arr = toCodePoints(label)
     local cps  = toCodePoints(label)
     local out, n, delta, bias = {}, initial_n, 0, initial_bias
     local out = {}
     local basic = 0
    local n    = initial_n
    local bias = initial_bias
     local delta, basic = 0, 0


     -- copy ASCII
     -- copy ASCII code points
     for _, cp in ipairs(cps) do
     for _, cp in ipairs(cp_arr) do
         if cp < 0x80 then
         if cp < 128 then
             out[#out + 1] = string.char(cp)
             out[#out + 1] = string.char(cp)
             basic = basic + 1
             basic = basic + 1
         end
         end
     end
     end
     if basic > 0 then out[#out + 1] = delim end
     if basic > 0 and basic < #cp_arr then out[#out + 1] = delimiter end


     local h = basic
     local h = basic
     while h < #cps do
     while h < #cp_arr do
        -- find next smallest code point ≥ n
         local m = 0x7FFFFFFF
         local m = 0x7fffffff
         for _, cp in ipairs(cp_arr) do
         for _, cp in ipairs(cps) do
             if cp >= n and cp < m then m = cp end
             if cp >= n and cp < m then m = cp end
         end
         end
         delta = delta + (m - n) * (h + 1)
         delta = delta + (m - n) * (h + 1)
         n = m
         n = m
 
         for _, cp in ipairs(cp_arr) do
         for _, cp in ipairs(cps) do
             if cp < n then
             if cp < n then
                 delta = delta + 1
                 delta = delta + 1
Line 149: Line 168:
                 while true do
                 while true do
                     local t
                     local t
                     if    k <= bias         then t = tmin
                     if    k <= bias         then t = tmin
                     elseif k >= bias + tmax   then t = tmax
                     elseif k >= bias + tmax then t = tmax
                     else                         t = k - bias end
                     else                         t = k - bias end
                     if q < t then break end
                     if q < t then break end
                     out[#out + 1] = digitToBasic(t + (q - t) % (base - t))
                     out[#out + 1] = digitToBasic(t + (q - t) % (base - t))
Line 167: Line 186:
     end
     end


     local result = table.concat(out)
     local res = table.concat(out)
     encodeCache[label] = result
     encodeCache[label] = res
     return result
     return res
end
end


function punycode.decode(label)
function punycode.decode(label)
     if label == ""       then return "" end
     if not label or label == "" then return "" end
     if decodeCache[label] then return decodeCache[label] end
     if decodeCache[label] then return decodeCache[label] end


     local cps, d = {}, (label:find(delim, 1, true) or 0)
     local cps, d = {}, (label:find(delimiter, 1, true) or 0)
     for i = 1, d - 1 do cps[#cps + 1] = label:byte(i) end
     for i = 1, d - 1 do cps[#cps + 1] = label:byte(i) end


Line 185: Line 204:
         local oldi, w, k = i_val, 1, base
         local oldi, w, k = i_val, 1, base
         while true do
         while true do
            if pos > len then error("punycode.decode: bad input") end
             local digit = basicToDigit(label:byte(pos))
             local digit = basicToDigit(label:byte(pos))
             pos   = pos + 1
             pos = pos + 1
             i_val = i_val + digit * w
             i_val = i_val + digit * w
             local t
             local t
             if    k <= bias         then t = tmin
             if    k <= bias         then t = tmin
             elseif k >= bias + tmax   then t = tmax
             elseif k >= bias + tmax then t = tmax
             else                         t = k - bias end
             else                         t = k - bias end
             if digit < t then break end
             if digit < t then break end
             w = w * (base - t)
             w = w * (base - t)
Line 204: Line 222:
     end
     end


     local result = fromCodePoints(cps)
     local res = fromCodePoints(cps)
     decodeCache[label] = result
     decodeCache[label] = res
     return result
     return res
end
 
----------------------------------------------------------------
-- Domain-level helpers  (the requested FIX)
----------------------------------------------------------------
local function splitLabels(domain)
    local labels, i = {}, 1
    for label in domain:gmatch("([^%.]+)") do
        labels[i], i = label, i + 1
    end
    return labels
end
end


------------------------------------------------------------
--  Domain-level helpers (dot handling)
------------------------------------------------------------
local function stripTrailingDot(s)
local function stripTrailingDot(s)
     return (s:sub(-1) == '.' and s:sub(1, -2) or s), (s:sub(-1) == '.')
     return (s:sub(-1) == '.' and s:sub(1, -2) or s),
          (s:sub(-1) == '.')
end
end


-- Unicode → ASCII/IDNA (strips dot *before* encoding, encodes each label separately)
function punycode.toASCII(domain)
function punycode.toASCII(domain)
     if domain == "" then return "" end
     if not domain or domain == "" then return "" end
    local trailing
     domain, trailing = stripTrailingDot(domain)
     domain, trailing = stripTrailingDot(domain)
     local out = {}
 
     for label in domain:gmatch("([^%.]+)") do
     local ascii = {}
         if label:find("[^\x00-\x7F]") then
     for _, lbl in ipairs(splitLabels(domain)) do
            out[#out + 1] = "xn--" .. punycode.encode(label)
         ascii[#ascii + 1] = isASCII(lbl) and lbl
        else
                        or ("xn--" .. punycode.encode(lbl))
            out[#out + 1] = label
        end
     end
     end
     local res = table.concat(out, ".")
     local res = table.concat(ascii, ".")
     return trailing and (res .. ".") or res
     return trailing and (res .. ".") or res
end
end


-- ASCII/IDNA → Unicode (each label separately)
function punycode.toUnicode(domain)
function punycode.toUnicode(domain)
     if domain == "" then return "" end
     if not domain or domain == "" then return "" end
    local trailing
     domain, trailing = stripTrailingDot(domain)
     domain, trailing = stripTrailingDot(domain)
     local out = {}
 
     for label in domain:gmatch("([^%.]+)") do
     local uni = {}
         if label:sub(1, 4):lower() == "xn--" then
     for _, lbl in ipairs(splitLabels(domain)) do
             out[#out + 1] = punycode.decode(label:sub(5))
         if lbl:sub(1, 4):lower() == "xn--" then
             uni[#uni + 1] = punycode.decode(lbl:sub(5))
         else
         else
             out[#out + 1] = label
             uni[#uni + 1] = lbl
         end
         end
     end
     end
     local res = table.concat(out, ".")
     local res = table.concat(uni, ".")
     return trailing and (res .. ".") or res
     return trailing and (res .. ".") or res
end
end


return punycode
return punycode