---
Module:Date time – Date formatting and validation module.  
This module provides functions for validating and formatting dates in templates such as
, , , and .
It handles:
- Validation of date components (year, month, day)
- Validation of time components (hour, minute, second)
- Timezone formatting and validation
- Generation of appropriate hCalendar microformat markup
- "time-ago" calculations  
Design notes:
- Functions are organized into helper, validation, and formatting sections
- Error handling uses a consistent pattern with centralized error messages
- Timezone validation supports standard ISO 8601 formats
- Leap year calculation is cached for performance  
require("strict")  
local p = {}  
---
-- Constants --
---  
local HTML_SPACE = "&#32;"
local HTML_NBSP = "&nbsp;"  
-- Error message constants
local ERROR_MESSAGES = {
integers = "All values must be integers",
has_leading_zeros = "Values cannot have unnecessary leading zeros",
missing_year = "Year value is required",
invalid_month = "Value is not a valid month",
missing_month = "Month value is required when a day is provided",
invalid_day = "Value is not a valid day (Month %d has %d days)",
invalid_hour = "Value is not a valid hour",
invalid_minute = "Value is not a valid minute",
invalid_second = "Value is not a valid second",
timezone_incomplete_date = "A timezone cannot be set without a day and hour",
invalid_timezone = "Value is not a valid timezone",
yes_value_parameter = '%s must be either "yes" or "y"',
duplicate_parameters = 'Duplicate parameters used: %s and %s',
template = "Template not supported",
time_without_hour = "Minutes and seconds require an hour value"
}  
-- Template class mapping
-- "itvstart" and "itvend" are unique classes used by the TV infoboxes,
-- which only allow the usage of and .
local TEMPLATE_CLASSES = {
["start date"] = "bday dtstart published updated itvstart",
["start date and age"] = "bday dtstart published updated",
["end date"] = "dtend itvend",
["end date and age"] = "dtend"
}  
-- Templates that require "time ago" calculations
local TIME_AGO = {
["start date and age"] = true,
["end date and age"] = true
}  
-- English month names
local MONTHS = {
"சனவரி", "பெப்பிரவரி", "மார்ச்", "ஏப்ரல்", "மே", "சூன்",
"சூலை", "ஆகத்து", "செப்டம்பர்", "அக்டோபர்", "நவம்பர்", "டிசம்பர்"
}  
-- Error category
local ERROR_CATEGORY = ""  
-- Namespaces where error categories should be applied
local CATEGORY_NAMESPACES = {
[0] = true, -- Article
[1] = true, -- Article talk
[4] = true, -- Wikipedia
[10] = true, -- Template
[100] = true, -- Portal
[118] = true -- Draft
}  
-- Cached leap year calculations for performance
local leap_year_cache = {}  
-- Local variables for error handling
local help_link  
---
-- Helper Functions --
---  
--- Pads a number with leading zeros to ensure a minimum of two digits.
-- @param value (number|string) The value to pad with leading zeros
-- @return string The value padded to at least two digits, or nil if input is nil
local function pad_left_zeros(value)
if value == nil then
return nil
end  
local str = tostring(value)
return string.rep("0", math.max(0, 2 - #str)) .. str
end  
--- Replaces U+2212 (Unicode minus) with U+002D (ASCII hyphen) or vice versa.
-- @param value (string) The string value to process
-- @param to_unicode (boolean) If true, converts ASCII hyphen to Unicode minus;
-- If false, converts Unicode minus to ASCII hyphen
-- @return string The processed string with appropriate minus characters, or nil if input is nil
local function replace_minus_character(value, to_unicode)
if not value then
return nil
end  
if to_unicode then
return value:gsub("-", "−")
end  
return value:gsub("−", "-")
end  
--- Normalizes timezone format by ensuring proper padding of hours.
-- @param timezone (string) The timezone string to normalize
-- @return string The normalized timezone string with properly padded hours, or nil if input is nil
local function fix_timezone(timezone)
if not timezone then
return nil
end  
-- Replace U+2212 (Unicode minus) with U+002D (ASCII hyphen)
timezone = replace_minus_character(timezone, false)  
-- Match the timezone pattern for ±H:MM format
local sign, hour, minutes = timezone:match("^([+-])(%d+):(%d+)$")  
if sign and hour and minutes then
-- Pad the hour with a leading zero if necessary
hour = pad_left_zeros(hour)
return sign .. hour .. ":" .. minutes
end  
-- If no match, return the original timezone (this handles invalid or already padded timezones)
return timezone
end  
--- Checks if a timezone string is valid according to standard timezone formats.
-- Valid timezones range from UTC-12:00 to UTC+14:00.
-- @param timezone (string) The timezone string to validate
-- @return boolean true if the timezone is valid, false otherwise
local function is_timezone_valid(timezone)
-- Consolidated timezone pattern for better performance
local valid_patterns = {
-- Z (UTC)
"^Z$",
-- Full timezone with minutes ±HH:MM
"^[+]0[1-9]:[0-5][0-9]$",
"^[+-]0[1-9]:[0-5][0-9]$",
"^[+-]1[0-2]:[0-5][0-9]$",
"^[+]1[34]:[0-5][0-9]$",
-- Whole hour timezones ±HH
"^[+-]0[1-9]$",
"^[+-]1[0-2]$",
"^[+]1[34]$",
-- Special cases
"^[+]00:00$",
"^[+]00$"
}  
-- Additional checks for invalid -00 and -00:00 cases
if timezone == "-00" or timezone == "-00:00" then
return false
end  
for _, pattern in ipairs(valid_patterns) do
if string.match(timezone, pattern) then
return true
end
end  
return false
end  
--- Checks if a given year is a leap year.
-- Uses a cache for better performance.
-- @param year (number) The year to check for leap year status
-- @return boolean true if the year is a leap year, false otherwise
local function is_leap_year(year)
if leap_year_cache[year] == nil then
leap_year_cache[year] = (year % 4 == 0 and year % 100 ~= 0) or (year % 400 == 0)
end
return leap_year_cache[year]
end  
--- Returns the number of days in a given month of a specified year.
-- Handles leap years for February.
-- @param year (number) The year to check for leap year conditions
-- @param month (number) The month (1-12) for which to return the number of days
-- @return number The number of days in the specified month, accounting for leap years
local function get_days_in_month(year, month)
local days_in_month = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }  
if month == 2 and is_leap_year(year) then
return 29
end  
return days_in_month[month] or 0
end  
--- Checks if a given value has invalid leading zeros.
-- @param value (string) The value to check for leading zeros
-- @param field_type (string) Field type ("day", "month", "hour", "minute", "second")
-- @return boolean true if the value has invalid leading zeros, false otherwise
local function has_leading_zeros(value, field_type)
value = tostring(value)  
-- Common checks for day and month
if field_type == "day" or field_type == "month" then
-- Reject "00" and values with leading zero followed by more than one digit
return value == "00" or
string.match(value, "^0[0-9][0-9]$") ~= nil or
string.match(value, "^0[1-9][0-9]") ~= nil
end  
-- Checks for hour, minute, second
if field_type == "hour" or field_type == "minute" or field_type == "second" then
-- Allow "00" and "01" to "09"
if value == "00" or string.match(value, "^0[1-9]$") then
return false
end  
-- Reject values starting with "0" followed by more than one digit
return string.match(value, "^0[0-9][0-9]+$") ~= nil
end  
return false
end  
--- Checks if a given value is an integer.
-- @param value (string|number) The value to check
-- @return boolean true if the value is a valid integer, false otherwise
local function is_integer(value)
if not value then
return false
end
-- Check if the value is a number first
local num_value = tonumber(value)
if not num_value then
return false
end
-- Check if it's an integer by comparing floor with the original
if math.floor(num_value) ~= num_value then
return false
end
-- For string inputs, check for decimal point to reject values like "7."
if type(value) == "string" then