packages feed

bureaucromancy 0.1.0.1 → 0.1.0.2

raw patch · 28 files changed

+1017/−101 lines, 28 filesdep +aesondep ~basedep ~html-conduitdep ~http-typesPVP: major bump suggested

API removals or changes: PVP suggests a major version bump

Dependencies added: aeson

Dependency ranges changed: base, html-conduit, http-types, text, wai, warp

API changes (from Hackage documentation)

+ Text.HTML.Form: [lang] :: Form -> String
+ Text.HTML.Form: parseDocument' :: Text -> Document -> Text -> Maybe Form
+ Text.HTML.Form: parseElement' :: Text -> Cursor -> Maybe Form
+ Text.HTML.Form.WebApp: [lang] :: Form -> String
- Text.HTML.Form: Form :: URI -> Text -> Text -> Bool -> Text -> [Text] -> Bool -> Text -> Text -> [Input] -> Form
+ Text.HTML.Form: Form :: URI -> Text -> Text -> Bool -> Text -> [Text] -> Bool -> Text -> Text -> [Input] -> String -> Form
- Text.HTML.Form.Colours: tailwindColours :: [(String, [(Int, Colour)])]
+ Text.HTML.Form.Colours: tailwindColours :: String -> [(String, [(Int, Colour)])]
- Text.HTML.Form.Validate: inputErrorMessage :: Input -> String
+ Text.HTML.Form.Validate: inputErrorMessage :: String -> Input -> String
- Text.HTML.Form.Validate: inputErrorMessage' :: Input -> [Char]
+ Text.HTML.Form.Validate: inputErrorMessage' :: String -> Input -> [Char]
- Text.HTML.Form.WebApp: Form :: URI -> Text -> Text -> Bool -> Text -> [Text] -> Bool -> Text -> Text -> [Input] -> Form
+ Text.HTML.Form.WebApp: Form :: URI -> Text -> Text -> Bool -> Text -> [Text] -> Bool -> Text -> Text -> [Input] -> String -> Form

Files

app/Main.hs view
@@ -26,6 +26,7 @@         [] -> ("form.html", "0")   doc <- HTML.readFile filename   let form = ensureButtons $ fromJust $ parseDocument doc $ Txt.pack ident+  putStrLn "http://127.0.0.1:2018/"   runEnv 2018 $ servePage form  servePage :: Form -> Application
bureaucromancy.cabal view
@@ -20,7 +20,7 @@ -- PVP summary:     +-+------- breaking API changes --                  | | +----- non-breaking API additions --                  | | | +--- code changes with no API change-version:            0.1.0.1+version:            0.1.0.2  -- A short (one-line) description of the package. synopsis:           Parse webforms & render to interactive hypertext@@ -52,7 +52,7 @@ extra-doc-files:    CHANGELOG.md  -- Extra source files to be distributed with the package, such as examples, or a tutorial module.--- extra-source-files:+extra-source-files: tpl/**/*.html, i18n/*.txt  common warnings     ghc-options: -Wall@@ -68,7 +68,7 @@         Text.HTML.Form.Colours, Text.HTML.Form.Validate      -- Modules included in this library but not exported.-    -- other-modules:+    other-modules:  Text.HTML.Form.I18n      -- LANGUAGE extensions used by modules in this package.     -- other-extensions:@@ -78,7 +78,7 @@             file-embed-lzma >=0.0.1 && <1, file-embed >=0.0.15 && < 0.1, mtl >2 && <3,             bytestring >=0.11 && <1, text >=2 && <3, containers >=0.6 && <1,             xml-conduit >= 1.9 && <2, network-uri >=2.6 && <3, regex-tdfa >=1.3 && <2,-            filepath >=1.4 && <2, directory >=1.2 && <2,+            filepath >=1.4 && <2, directory >=1.2 && <2, aeson,             hourglass >= 0.2.12 && < 0.3, tz >= 0.1 && < 0.2      -- Directories containing source files.@@ -102,8 +102,9 @@      -- Other library packages from which modules are imported.     build-depends:-        base >=4.16.4.0 && <5,-        bureaucromancy, warp, wai, http-types, text, html-conduit+        base >=4.16.4.0 && <5, bureaucromancy,+        warp >= 3.3.31 && < 3.4, wai >= 3.2.3 && < 3.3, http-types >= 0.12.3 && < 0.13,+        text >= 2.0.1 && < 2.1, html-conduit >= 1.3.2 && < 1.4      -- Directories containing source files.     hs-source-dirs:   app@@ -135,5 +136,5 @@      -- Test dependencies.     build-depends:-        base ^>=4.16.4.0,+        base >=4.16.4.0 && <5,         bureaucromancy
+ i18n/en.txt view
@@ -0,0 +1,65 @@+# Colour Pallet+Slate: Slate+Gray: Gray+Zinc: Zinc+Neutral: Neutral+Stone: Stone+Red: Red+Orange: Orange+Amber: Amber+Yellow: Yellow+Lime: Lime+Green: Green+Emerald: Emerald+Teal: Teal+Cyan: Cyan+Sky: Sky+Blue: Blue+Indigo: Indigo+Violet: Violet+Fuchsia: Fuchsia+Pink: Pink+Rose: Rose++# Error messages+err required: Required!+err format: Invalid format!+err min chars: Must be at least %0 characters!+err max chars: Must be at most %0 characters!+err min: Must be at least %0!+err max: Must be at most %0!+err increments: Must be in increments of %0 from %1!+err colour: Invalid colour value!+err email: Obviously invalid email address, needs an '@'!+err number: Invalid number!+err URL: Invalid web address!+err time: Invalid time format!+errored: Please correct errors listed in sidebar before submitting this form!++# Months+January: January+February: February+March: March+April: April+May: May+June: June+July: July+August: August+September: September+October: October+November: November+December: December++# Keyboard+SPACE: SPACE+DEL: DEL+CLEAR: CLEAR++# Other+Start!: Start!+Now: Now+# Combines to "Upload to" with "Upload" linked.+# Not great idea to split this phrase into 2 strings, but I don't want to link "to"...+Upload Link: Upload+Upload suffix: to+Restore defaults: Restore defaults
src/Text/HTML/Form.hs view
@@ -2,7 +2,8 @@ -- | Parse webforms out of webpages module Text.HTML.Form (Form(..), Input(..), OptionGroup(..), Option(..),     FileSelector(..), defaultFileData, ImageData(..), defaultImageData,-    TextArea(..), defaultTextArea, parseElement, parseDocument, ensureButtons) where+    TextArea(..), defaultTextArea, parseElement, parseElement',+    parseDocument, parseDocument', ensureButtons) where  import Data.Text (Text) import qualified Data.Text as Txt@@ -39,7 +40,9 @@     -- | The purpose of this form, typically using an external vocabulary.     rel :: Text,     -- | What data should be sent to the endpoint.-    inputs :: [Input]+    inputs :: [Input],+    -- | Which human language the form is written? To which additional messages should be localized?+    lang :: String }  -- | Individual piece of data to send to a webservice.@@ -189,7 +192,9 @@ mAttr n = listToMaybe . laxAttribute n -- | Parse a form from the selected HTML element. parseElement :: Cursor -> Maybe Form-parseElement el | _:_ <- laxElement "form" el = Just Form {+parseElement = parseElement' "en"+parseElement' :: Text -> Cursor -> Maybe Form+parseElement' language el | _:_ <- laxElement "form" el = Just Form {         action = attr'' "action" el (fromMaybe nullURI . parseURIReference) ".",         enctype = attr "enctype" el "",         method = attr "method" el "GET",@@ -199,7 +204,9 @@         autocomplete = hasAttr "autocomplete" el,         formName = attr "name" el "",         rel = attr "rel" el "",-        inputs = mapMaybe parseInput $ queryInputs el+        inputs = mapMaybe parseInput $ queryInputs el,+        lang = Txt.unpack $ fromMaybe language $ listToMaybe $+                mapMaybe (mAttr "lang") $ orSelf ancestor el       }     | otherwise = Nothing @@ -431,10 +438,12 @@  -- | Parse a named or numerically-indexed form from an HTML document. parseDocument :: Document -> Text -> Maybe Form-parseDocument doc n+parseDocument = parseDocument' "en"+parseDocument' :: Text -> Document -> Text -> Maybe Form+parseDocument' language doc n     | Just n' <- readMaybe $ Txt.unpack n, n' < length (forms doc') =-        parseElement (forms doc' !! n')-    | el:_ <- (forms >=> attributeIs "name" n) doc' = parseElement el+        parseElement' language (forms doc' !! n')+    | el:_ <- (forms >=> attributeIs "name" n) doc' = parseElement' language el     | otherwise = Nothing   where     forms = orSelf descendant >=> laxElement "form"
src/Text/HTML/Form/Colours.hs view
@@ -2,6 +2,8 @@ -- for where we can't provide free-form colour selection. module Text.HTML.Form.Colours(tailwindColours, Colour) where +import Text.HTML.Form.I18n (i18n)+ -- Finally a good use for Tailwind! -- Otherwise it strikes me as little more than an illegible alternative to -- the `style` attribute!@@ -9,75 +11,75 @@ -- | Stores a CSS hexcode, with preceding "#" type Colour = String -- | The Tailwind colour pallet: https://v1.tailwindcss.com/docs/customizing-colors#default-color-palette-tailwindColours :: [(String, [(Int, Colour)])]-tailwindColours = [-    "Slate"~>[50~>"#f8fafc", 100~>"#f1f5f9", 200~>"#e2e8f0", 300~>"#cbd5e1",+tailwindColours :: String -> [(String, [(Int, Colour)])]+tailwindColours lang = [+    i "Slate"~>[50~>"#f8fafc", 100~>"#f1f5f9", 200~>"#e2e8f0", 300~>"#cbd5e1",             400~>"#94a3b8", 500~>"#64748b", 600~>"#475569", 700~>"#334155",             800~>"#1e293b", 900~>"#0f172a", 950~>"#020617"],-    "Gray"~>[50~>"#f9fafb", 100~>"#f3f4f6", 200~>"#e5e7eb", 300~>"#d1d5db",+    i "Gray"~>[50~>"#f9fafb", 100~>"#f3f4f6", 200~>"#e5e7eb", 300~>"#d1d5db",             400~>"#9ca3af", 500~>"#6b7280", 600~>"#4b5563", 700~>"#374151",             800~>"#1f2937", 900~>"#111827", 950~>"#030712"],-    "Zinc"~>[50~>"#fafafa", 100~>"#f4f4f5", 200~>"#e4e4e7", 300~>"#d4d4d8",+    i "Zinc"~>[50~>"#fafafa", 100~>"#f4f4f5", 200~>"#e4e4e7", 300~>"#d4d4d8",             400~>"#a1a1aa", 500~>"#71717a", 600~>"#52525b", 700~>"#3f3f46",             800~>"#27272a", 900~>"#18181b", 950~>"#09090b"],-    "Neutral"~>[50~>"#fafafa", 100~>"#f5f5f5", 200~>"#e5e5e5", 300~>"#d4d4d4",+    i "Neutral"~>[50~>"#fafafa", 100~>"#f5f5f5", 200~>"#e5e5e5", 300~>"#d4d4d4",             400~>"#a3a3a3", 500~>"#737373", 600~>"#404040", 700~>"#404040",             800~>"#262626", 900~>"#171717", 950~>"#0a0a0a"],-    "Stone"~>[50~>"#fafaf9", 100~>"#f5f5f4", 200~>"#e7e5e4", 300~>"#d6d3d1",+    i "Stone"~>[50~>"#fafaf9", 100~>"#f5f5f4", 200~>"#e7e5e4", 300~>"#d6d3d1",             400~>"#a8a29e", 500~>"#78716c", 600~>"#57534e", 700~>"#44403c",             800~>"#292524", 900~>"#1c1917", 950~>"#0c0a09"],-    "Red"~>[50~>"#fef2f2", 100~>"#fee2e2", 200~>"#fecaca", 300~>"#fca5a5",+    i "Red"~>[50~>"#fef2f2", 100~>"#fee2e2", 200~>"#fecaca", 300~>"#fca5a5",             400~>"#f87171", 500~>"#ef4444", 600~>"#dc2626", 700~>"#b91c1c",             800~>"#991b1b", 900~>"#7f1d1d", 950~>"#450a0a"],-    "Orange"~>[50~>"#fff7ed", 100~>"#ffedd5", 200~>"#fed7aa", 300~>"#fdba74",+    i "Orange"~>[50~>"#fff7ed", 100~>"#ffedd5", 200~>"#fed7aa", 300~>"#fdba74",             400~>"#fb923c", 500~>"#f97316", 600~>"#ea580c", 700~>"#c2410c",             800~>"#9a3412", 900~>"#7c2d12", 950~>"#431407"],-    "Amber"~>[50~>"#fffbeb", 100~>"#fef3c7", 200~>"#fde68a", 300~>"#fcd34d",+    i "Amber"~>[50~>"#fffbeb", 100~>"#fef3c7", 200~>"#fde68a", 300~>"#fcd34d",             400~>"#fbbf24", 500~>"#f59e0b", 600~>"#d97706", 700~>"#b45309",             800~>"#92400e", 900~>"#78350f", 950~>"#451a03"],-    "Yellow"~>[50~>"#fefce8", 100~>"#fef9c3", 200~>"#fef08a", 300~>"#fde047",+    i "Yellow"~>[50~>"#fefce8", 100~>"#fef9c3", 200~>"#fef08a", 300~>"#fde047",             400~>"#facc15", 500~>"#eab308", 600~>"#ca8a04", 700~>"#a16207",             800~>"#854d0e", 900~>"#713f12", 950~>"#422006"],-    "Lime"~>[50~>"#f7fee7", 100~>"#ecfccb", 200~>"#d9f99d", 300~>"#bef264",+    i "Lime"~>[50~>"#f7fee7", 100~>"#ecfccb", 200~>"#d9f99d", 300~>"#bef264",             400~>"#a3e635", 500~>"#84cc16", 600~>"#65a30d", 700~>"#4d7c0f",             800~>"#3f6212", 900~>"#365314", 950~>"#1a2e05"],-    "Green"~>[50~>"#f0fdf4", 100~>"#dcfce7", 200~>"#bbf7d0", 300~>"#86efac",+    i "Green"~>[50~>"#f0fdf4", 100~>"#dcfce7", 200~>"#bbf7d0", 300~>"#86efac",             400~>"#4ade80", 500~>"#22c55e", 600~>"#16a34a", 700~>"#15803d",             800~>"#166534", 900~>"#14532d", 950~>"#052e16"],-    "Emerald"~>[50~>"#ecfdf5", 100~>"#d1fae5", 200~>"#a7f3d0", 300~>"#6ee7b7",+    i "Emerald"~>[50~>"#ecfdf5", 100~>"#d1fae5", 200~>"#a7f3d0", 300~>"#6ee7b7",             400~>"#34d399", 500~>"#10b981", 600~>"#059669", 700~>"#047857",             800~>"#065f46", 900~>"#064e3b", 950~>"#022c22"],-    "Teal"~>[50~>"#f0fdfa", 100~>"#ccfbf1", 200~>"#99f6e4", 300~>"#5eead4",+    i "Teal"~>[50~>"#f0fdfa", 100~>"#ccfbf1", 200~>"#99f6e4", 300~>"#5eead4",             400~>"#2dd4bf", 500~>"#14b8a6", 600~>"#0f766e", 700~>"#0f766e",             800~>"#115e59", 900~>"#134e4a", 950~>"#042f2e"],-    "Cyan"~>[50~>"#ecfeff", 100~>"#cffafe", 200~>"#a5f3fc", 300~>"#67e8f9",+    i "Cyan"~>[50~>"#ecfeff", 100~>"#cffafe", 200~>"#a5f3fc", 300~>"#67e8f9",             400~>"#22d3ee", 500~>"#06b6d4", 600~>"#0891b2", 700~>"#0e7490",             800~>"#155e75", 900~>"#164e63", 950~>"#083344"],-    "Sky"~>[50~>"#f0f9ff", 100~>"#e0f2fe", 200~>"#bae6fd", 300~>"#7dd3fc",+    i "Sky"~>[50~>"#f0f9ff", 100~>"#e0f2fe", 200~>"#bae6fd", 300~>"#7dd3fc",             400~>"#38bdf8", 500~>"#0ea5e9", 600~>"#0284c7", 700~>"#0369a1",             800~>"#075985", 900~>"#0c4a6e", 950~>"#082f49"],-    "Blue"~>[50~>"#eff6ff", 100~>"#dbeafe", 200~>"#bfdbfe", 300~>"#93c5fd",+    i "Blue"~>[50~>"#eff6ff", 100~>"#dbeafe", 200~>"#bfdbfe", 300~>"#93c5fd",             400~>"#60a5fa", 500~>"#3b82f6", 600~>"#2563eb", 700~>"#1d4ed8",             800~>"#1e40af", 900~>"#1e3a8a", 950~>"#172554"],-    "Indigo"~>[50~>"#eef2ff", 100~>"#e0e7ff", 200~>"#c7d2fe", 300~>"#a5b4fc",+    i "Indigo"~>[50~>"#eef2ff", 100~>"#e0e7ff", 200~>"#c7d2fe", 300~>"#a5b4fc",             400~>"#818cf8", 500~>"#6366f1", 600~>"#4f46e5", 700~>"#4338ca",             800~>"#3730a3", 900~>"#312e81", 950~>"#1e1b4b"],-    "Violet"~>[50~>"#f5f3ff", 100~>"#ede9fe", 200~>"#ddd6fe", 300~>"#c4b5fd",+    i "Violet"~>[50~>"#f5f3ff", 100~>"#ede9fe", 200~>"#ddd6fe", 300~>"#c4b5fd",             400~>"#a78bfa", 500~>"#8b5cf6", 600~>"#7c3aed", 700~>"#6d28d9",             800~>"#5b21b6", 900~>"#4c1d95", 950~>"#2e1065"],-    "Purple"~>[50~>"#faf5ff", 100~>"#f3e8ff", 200~>"#e9d5ff", 300~>"#d8b4fe",+    i "Purple"~>[50~>"#faf5ff", 100~>"#f3e8ff", 200~>"#e9d5ff", 300~>"#d8b4fe",             400~>"#c084fc", 500~>"#a855f7", 600~>"#9333ea", 700~>"#7e22ce",             800~>"#6b21a8", 900~>"#581c87", 950~>"#3b0764"],-    "Fuchsia"~>[50~>"#fdf4ff", 100~>"#fae8ff", 200~>"#f5d0fe", 300~>"#f0abfc",+    i "Fuchsia"~>[50~>"#fdf4ff", 100~>"#fae8ff", 200~>"#f5d0fe", 300~>"#f0abfc",             400~>"#e879f9", 500~>"#d946ef", 600~>"#c026d3", 700~>"#a21caf",             800~>"#86198f", 900~>"#701a75", 950~>"#4a044e"],-    "Pink"~>[50~>"#fdf2f8", 100~>"#fce7f3", 200~>"#fbcfe8", 300~>"#f9a8d4",+    i "Pink"~>[50~>"#fdf2f8", 100~>"#fce7f3", 200~>"#fbcfe8", 300~>"#f9a8d4",             400~>"#f472b6", 500~>"#ec4899", 600~>"#db2777", 700~>"#be185d",             800~>"#9d174d", 900~>"#831843", 950~>"#500724"],-    "Rose"~>[50~>"#fff1f2", 100~>"#ffe4e6", 200~>"#fecdd3", 300~>"#fda4af",+    i "Rose"~>[50~>"#fff1f2", 100~>"#ffe4e6", 200~>"#fecdd3", 300~>"#fda4af",             400~>"#fb7185", 500~>"#f43f5e", 600~>"#e11d48", 700~>"#be123c",             800~>"#9f1239", 900~>"#881337", 950~>"#4c0519"]-  ]+  ] where i = i18n lang  (~>) :: a -> b -> (a, b) a ~> b = (a, b)
+ src/Text/HTML/Form/I18n.hs view
@@ -0,0 +1,54 @@+{-# LANGUAGE TemplateHaskell #-}+module Text.HTML.Form.I18n(strings, langs, i18n, i18n', i18n2, stringsJSON) where++import Data.FileEmbed (embedDir, makeRelativeToProject)+import Data.Text.Encoding (decodeUtf8Lenient)+import Data.Text (unpack, pack, strip, replace)+import Data.ByteString (ByteString)+import System.FilePath (dropExtension)++import qualified Data.Aeson.KeyMap as KM+import Data.Aeson.Key as K+import Data.Aeson (Value(String, Object))++import Data.List (isPrefixOf)+import Data.Maybe (fromMaybe)++files :: [(FilePath, ByteString)]+files = $(embedDir =<< makeRelativeToProject "i18n")+bs2str :: ByteString -> String+bs2str = unpack . decodeUtf8Lenient++strings :: String -> [(String, String)]+strings = fromMaybe [] .+        flip lookup [(dropExtension k, parseKVs $ bs2str v) | (k, v) <- files]+langs :: [String]+langs = map dropExtension $ map fst files++i18n :: String -> String -> String+i18n lang key = fromMaybe key $ lookup key $ strings lang++i18n' :: Show a => String -> String -> a -> String+i18n' lang key subs = replace' "%0" (show subs) $ i18n lang key+i18n2 :: (Show a, Show b) => String -> String -> a -> b -> String+i18n2 lang key subs1 subs2 = replace' "%1" (show subs2) $ i18n' lang key subs1++stringsJSON :: String -> Value+stringsJSON = Object . KM.fromList . map inner . strings+  where inner (k, v) = (K.fromString k, String $ pack v)++------+--- Support+------++parseKVs :: String -> [(String, String)]+parseKVs = map inner . filter (isPrefixOf "#") . filter null . map strip' . lines+  where inner line = let (k, v) = break (==':') line in (strip' k, strip' v)++-- | Removes any whitespace at the start or end of a string+strip' :: String -> String+strip' = unpack . strip . pack++-- | Substitutes one string for another+replace' :: String -> String -> String -> String+replace' needle alt = unpack . replace (pack needle) (pack alt) . pack
src/Text/HTML/Form/Validate.hs view
@@ -4,7 +4,8 @@ module Text.HTML.Form.Validate(isInputValid, isInputValid', isFormValid, isFormValid',         inputErrorMessage, inputErrorMessage', normalizeInput, normalizeForm) where -import Text.HTML.Form+import Text.HTML.Form hiding (lang)+import Text.HTML.Form.I18n import qualified Data.Text as Txt import Text.Read (readMaybe) import Data.Hourglass@@ -22,82 +23,78 @@  -- | Is the given input valid? isInputValid :: Input -> Bool-isInputValid = null . inputErrorMessage+isInputValid = null . inputErrorMessage "en"  -- | Is the given input once normalized valid? isInputValid' :: Input -> Bool-isInputValid' = null . inputErrorMessage'+isInputValid' = null . inputErrorMessage' "en"  -- | Describe why a form input is invalid, or return the empty string.-inputErrorMessage :: Input -> String-inputErrorMessage Input { inputType = "hidden" } = "" -- Don't validate hiddens!-inputErrorMessage self@Input { required = True }-    | inputType self == "checkbox", not $ checked self = "Required!"+inputErrorMessage :: String -> Input -> String+inputErrorMessage _ Input { inputType = "hidden" } = "" -- Don't validate hiddens!+inputErrorMessage lang self@Input { required = True }+    | inputType self == "checkbox", not $ checked self = i18n lang "err required"     -- Not validating "radio", needs different API...-    | value self == "" = "Required!"-inputErrorMessage Input { value = "" } = "" -- Skip further checks for empty!-inputErrorMessage self@Input { pattern = Just re }-    | not $ re `matchTest` value self = "Invalid format!"-inputErrorMessage Input { lengthRange = (Just min', _), value = val }-    | Txt.length val < min' = "Must be at least " ++ show min' ++ " characters!"-inputErrorMessage Input { lengthRange = (_, Just max'), value = val }-    | Txt.length val > max' = "Must be at most " ++ show max' ++ " characters!"-inputErrorMessage Input { range = (Just min', _), value = val }+    | value self == "" = i18n lang "err required"+inputErrorMessage _ Input { value = "" } = "" -- Skip further checks for empty!+inputErrorMessage lang self@Input { pattern = Just re }+    | not $ re `matchTest` value self = i18n lang "err format"+inputErrorMessage lang Input { lengthRange = (Just min', _), value = val }+    | Txt.length val < min' = i18n' lang "err min chars" min'+inputErrorMessage lang Input { lengthRange = (_, Just max'), value = val }+    | Txt.length val > max' = i18n' lang "err max chars" max'+inputErrorMessage lang Input { range = (Just min', _), value = val }     | Just x <- readMaybe' val :: Maybe Float, Just y <- readMaybe' min', x < y =-        "Must be at least " ++ Txt.unpack min' ++ "!"-inputErrorMessage Input { range = (_, Just max'), value = val }+        i18n' lang "err min" min'+inputErrorMessage lang Input { range = (_, Just max'), value = val }     | Just x <- readMaybe' val :: Maybe Float, Just y <- readMaybe' max', x > y =-        "Must be at most " ++ Txt.unpack max' ++ "!"-inputErrorMessage Input { range = (Just min', _), step = Just step', value = val }+        i18n' lang "err max" max'+inputErrorMessage lang Input { range = (Just min', _), step = Just step', value = val }     | Just x <- readMaybe' val :: Maybe Integer, Just y <- readMaybe' min',         Just z <- readMaybe' step', z /= 0, rem (x - y) z == 0 =-            ("Must be in increments of " ++ Txt.unpack step' ++ " from "-            ++ Txt.unpack min' ++ "!")-inputErrorMessage Input { range = (Just min', _), value = val }+            i18n2 lang "err increments" step' min'+inputErrorMessage lang Input { range = (Just min', _), value = val }     | Just x <- parseTime $ Txt.unpack val, Just y <- parseTime $ Txt.unpack min',-        x < y = "Must be at least " ++ Txt.unpack min' ++ "!"-inputErrorMessage Input { range = (_, Just max'), value = val }+        x < y = i18n' lang "err min" min'+inputErrorMessage lang Input { range = (_, Just max'), value = val }     | Just x <- parseTime $ Txt.unpack val, Just y <- parseTime $ Txt.unpack max',-        x > y = "Must be at most " ++ Txt.unpack max' ++ "!"-inputErrorMessage Input {+        x > y = i18n' lang "err max" max'+inputErrorMessage lang Input {         range = (Just min', _), step = Just step',         inputType = ty, value = val       }     | ty == "date", Just x <- parseTime $ Txt.unpack val,         Just y <- parseTime $ Txt.unpack min', Just z <- readMaybe' step',         timeDiff x y `rem` toSeconds mempty { durationSeconds = 24*z } == Seconds 0 =-            ("Must be in increments of " ++ Txt.unpack step' ++ " days from " ++-            Txt.unpack min' ++ "!")+            i18n2 lang "err increments" step' min'     | ty == "month" = "" -- Not prepared to properly validate this...     | Just x <- parseTime $ Txt.unpack val, Just y <- parseTime $ Txt.unpack min',         Just z <- readMaybe' step', timeDiff x y `rem` Seconds z == Seconds 0 =-            ("Must be in increments of " ++ Txt.unpack step' ++ "s from " ++-            Txt.unpack min' ++ "!")+            i18n2 lang "err increments" step' min'  -- Validation specific to input types-inputErrorMessage self@Input { inputType = "color" }-    | ("#[0-9a-fA-F]{6}" :: String) =~ value self = "Invalid colour value!"-inputErrorMessage self@Input { inputType = "date" } = isTime' self-inputErrorMessage self@Input { inputType = "datetime" } = isTime' self-inputErrorMessage self@Input { inputType = "datetime-local" } = isTime' self+inputErrorMessage lang self@Input { inputType = "color" }+    | ("#[0-9a-fA-F]{6}" :: String) =~ value self = i18n lang "err colour"+inputErrorMessage lang self@Input { inputType = "date" } = isTime' lang self+inputErrorMessage lang self@Input { inputType = "datetime" } = isTime' lang self+inputErrorMessage l self@Input { inputType = "datetime-local" } = isTime' l self -- This validation is less strict than many sites expect, but don't over-validate...-inputErrorMessage self@Input { inputType = "email" }-    | '@' `Txt.elem` value self =-        "Obviously invalid email address, needs an '@'!"-inputErrorMessage self@Input { inputType = "month" } = isTime' self-inputErrorMessage Input { inputType = "number", value = val }-    | isNothing (readMaybe' val :: Maybe Float) = "Invalid number!"-inputErrorMessage Input { inputType = "range", value = val }-    | isNothing (readMaybe' val :: Maybe Float) = "Invalid number!"-inputErrorMessage self@Input { inputType = "time" } = isTime' self-inputErrorMessage self@Input { inputType = "url" }-    | isURL $ value self = "Invalid web address!"-inputErrorMessage self@Input { inputType = "week" } = isTime' self-inputErrorMessage _ = ""+inputErrorMessage lang self@Input { inputType = "email" }+    | '@' `Txt.elem` value self = i18n lang "err email"+inputErrorMessage lang self@Input { inputType = "month" } = isTime' lang self+inputErrorMessage lang Input { inputType = "number", value = val }+    | isNothing (readMaybe' val :: Maybe Float) = i18n lang "err number"+inputErrorMessage lang Input { inputType = "range", value = val }+    | isNothing (readMaybe' val :: Maybe Float) = i18n lang "err number"+inputErrorMessage lang self@Input { inputType = "time" } = isTime' lang self+inputErrorMessage lang self@Input { inputType = "url" }+    | isURL $ value self = i18n lang "err URL"+inputErrorMessage lang self@Input { inputType = "week" } = isTime' lang self+inputErrorMessage _ _ = ""  -- | Describe why an input, once normalized, is invalid? Or returns empty string.-inputErrorMessage' :: Input -> [Char]-inputErrorMessage' = inputErrorMessage . normalizeInput+inputErrorMessage' :: String -> Input -> [Char]+inputErrorMessage' lang = inputErrorMessage lang . normalizeInput  -- | Helper to parse the time stored in an input. parseTime :: String -> Maybe DateTime@@ -106,9 +103,9 @@ isTime :: Input -> Bool isTime = isJust . parseTime . Txt.unpack . value -- | Emit an error message if an input doesn't store a valid time.-isTime' :: Input -> String-isTime' x | isTime x = ""-    | otherwise = "Invalid time format!"+isTime' :: String -> Input -> String+isTime' lang x | isTime x = ""+    | otherwise = i18n lang "err time" -- | Parse a Text into any type that can be parsed from strings. readMaybe' :: Read a => Txt.Text -> Maybe a readMaybe' = readMaybe . Txt.unpack
src/Text/HTML/Form/WebApp.hs view
@@ -182,18 +182,19 @@         _ -> toGVal () renderInput form ix input@Input { inputType = "color" } [] qs =     template' "color.html" form ix input qs $ \x -> case x of-        "colours" -> V.list $ L.map colourGVal tailwindColours+        "colours" -> V.list $ L.map colourGVal $ tailwindColours $ lang form         "shades" -> toGVal False         "subfolder" -> toGVal False         _ -> toGVal () renderInput form ix input@Input { inputType = "color" } [c, ""] qs =     template' "color.html" form ix input qs $ \x -> case x of-        "colours" -> V.list $ L.map colourGVal tailwindColours-        "shades" -> case Txt.unpack c `lookup` tailwindColours of+        "colours" -> V.list $ L.map colourGVal $ tailwindColours l+        "shades" -> case Txt.unpack c `lookup` tailwindColours l of             Just shades -> V.list $ L.map shadeGVal shades             Nothing -> toGVal False         "subfolder" -> toGVal True         _ -> toGVal ()+ where l = lang form renderInput form ix input [keyboard] qs =     renderInput form ix input [keyboard, ""] qs renderInput form ix input [keyboard, ""] qs | Just (Just _) <- resolveSource path =
src/Text/HTML/Form/WebApp/Ginger.hs view
@@ -29,6 +29,7 @@  import Text.HTML.Form.Validate (inputErrorMessage') import Text.HTML.Form.Query (applyQuery)+import Text.HTML.Form.I18n (stringsJSON)  -- | A key-value query string. type Query = [(ByteString, Maybe ByteString)]@@ -52,14 +53,16 @@     ctxt :: Text -> GVal (Run SourcePos (Writer Html) Html)     ctxt "Q" = query2gval query     ctxt "form" = form2gval form-    ctxt "inputs" = list' $ Prelude.map (flip input2gval query) $+    ctxt "inputs" = list' $ Prelude.map (\x -> input2gval language x query) $         Prelude.zip [0..] $ inputs form-    ctxt "input" = input2gval (ix, input) query+    ctxt "input" = input2gval language (ix, input) query     ctxt "xURI" = fromFunction xURI+    ctxt "_" = toGVal $ stringsJSON language     ctxt x = ctxt' x     xURI [(_, uri)] = let uri' = Txt.unpack $ asText uri in         return$toGVal$Txt.pack $ escapeURIString isUnescapedInURIComponent uri'     xURI _ = return $ toGVal ()+    language = lang form  -- | Lookup the given template from a compiled-in directory. resolveSource :: FilePath -> Maybe (Maybe [Char])@@ -97,11 +100,11 @@   ]  -- | Convert an input to Ginger's datamodel.-input2gval :: (Int, Input) -> Query -> GVal m-input2gval (ix, input) query = orderedDict [+input2gval :: String -> (Int, Input) -> Query -> GVal m+input2gval language (ix, input) query = orderedDict [     "index"       ~> ix,     "label"       ~> label input,-    "error"       ~> inputErrorMessage' (applyQuery input+    "error"       ~> inputErrorMessage' language (applyQuery input             [(B8.unpack k, B8.unpack $ fromMaybe "" v) | (k, v) <- query]),     "description" ~> html (description input),     "inputType"   ~> inputType input,
+ tpl/base.html view
@@ -0,0 +1,71 @@+<!DOCTYPE html>+<html>+<head>+  <title>{{form.method}} {{form.action}}</title>+  <meta charset=utf-8 />+  <style>+    body { background: black; color: #eee; font: medium sans-serif; }+    h1 { text-align: center }+    aside { width: 20%; border-right: thin solid #eee; }+    dt { font-weight: bold; border-top: thin dashed #999; }+    dt a { color: inherit }+    .checked { color: lightgreen }+    .readonly { font-style: italic }+    .disabled { text-decoration: line-through }+    .selected { border-right: thick solid green }+    :link { color: white }++    input, select, textarea { display: none; }++    main { display: flex; flex-direction: row; }+    section { padding: 20px }++    nav { column-count: 4 } /* FIXME: Screen width issues? */+    nav dl { margin: 0 }+    dt, dd { break-inside: avoid }++    table td:not([colspan]) { width: 25% }+    table td { text-align: center }++    .error { color: red; }+  </style>+</head>+<body>+  <h1><strong>{{form.method}}</strong> <em>{{form.action}}</em></h1>+  <main>+  {% set selected=input.index %}+  <aside><dl>{% for input in inputs %}+    <dt class="{% if input.checked %}checked{% endif %} {% if input.index == selected %}selected{% endif %}">+      {% if input.checked %}&check;{% endif %}+      {% if input.type == 'password' && input.value != '' %}&check;{% endif %}+      {% if input.disabled %}{{ input.label }}+      {% else %}<a href="/{{input.index}}/{{Q}}" title="{{input.title}}">{{ input.label }}</a>+      {% endif %}</dt>+    {% if input.error %}<dd class="error">⚠️ {{ input.error }}</dd>{% endif %}+    {% if input.type != 'password' %}+      <dd class="{% if input.readonly %}readonly{% endif %}"+          {% if input.inputType == "color" %}style="background-color: {{ input.value }}"{% endif %}>+        {{ input.value }}+      </dd>+    {% endif %}+  {% endfor %}</dl></aside>++  {% block main %}+    {% if input.list %}<aside><dl>{% for grp in input.list %}+      <dt>{{ grp.label }}</dt>+      {% for opt in grp.opts %}+        {% if opt.disabled %}<dd>{{ opt.label }}</dd>+        {% else %}<dd>+          <a href="={{ opt.value|xURI }}{{Q}}" title="{{ opt.value }}">+            {{ opt.label }}</a>+        </dd>{% endif %}+      {% endfor %}+    {% endfor %}</dl></aside>{% endif %}++    {% block control %}<section>+      {{ input.description }}+    </section>{% endblock %}+  {% endblock %}+  </main>+</body>+</html>
+ tpl/cal/timezone.html view
@@ -0,0 +1,13 @@+{% extends "/base.html" %}++{%- block main -%}<section>+  <ul style="list-style: none">{% for cont in continents %}+    <li><a href="{{cont|xURI}}">{{ cont }}</a></li>+  {% endfor %}</ul>+</section>+<section>+  <h2><a href="..">{{ T.zone }}</a></h2>+  <ul style="display: flex; flex-wrap: wrap; justify-content: space-between; list-style: none">{% for tz in zones %}+    <li><a href="../zone={{tz.value}}">{{ tz.label }}</a> ({{ tz.offset }})</li>+  {% endfor %}</ul>+</section>{%- endblock -%}
+ tpl/cal/year-numpad.html view
@@ -0,0 +1,28 @@+{% extends "/base.html" %}++{%- block main -%}<section>+  <p>{{ T.year }}</p>+  <hr />+  <table>+    <tr>+      <td><a href=":7{{Q}}">7</a></td>+      <td><a href=":8{{Q}}">8</a></td>+      <td><a href=":9{{Q}}">9</a></td>+    </tr>+    <tr>+      <td><a href=":4{{Q}}">4</a></td>+      <td><a href=":5{{Q}}">5</a></td>+      <td><a href=":6{{Q}}">6</a></td>+    </tr>+    <tr>+      <td><a href=":1{{Q}}">1</a></td>+      <td><a href=":2{{Q}}">2</a></td>+      <td><a href=":3{{Q}}">3</a></td>+    </tr>+    <tr>+      <td><a href="-{{Q}}">{{ _["DEL"] }}</a></td>+      <td><a href=":0{{Q}}">0</a></td>+      <td><a href="..{{Q}}">&check;</a></td>+    </tr>+  </table>+</section>{%- endblock -%}
+ tpl/checkbox.html view
@@ -0,0 +1,1 @@+{% extends "base.html" %}
+ tpl/color.html view
@@ -0,0 +1,21 @@+{% extends "base.html" %}++{%- block main -%}+  {% if shades %}<section>+    <ul style="list-style: none; background: linear-gradient(black, white, white)">+      {% for shade in shades %}+        <li><a href="={{ shade.value|xURI }}{{Q}}" style="color: {{ shade.value }}">+            {{ shade.label }}+        </a></li>+      {% endfor %}+    </ul>+  </section>{% endif %}+  <section>+    <ul style="list-style: none; display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px">+      {% for colour in colours %}+        <li><a href="{% if subfolder %}../{% endif %}{{ colour.label|xURI }}/={{ colour.value|xURI }}{{Q}}"+            style="color: {{ colour.value }}">{{ colour.label }}</a></li>+      {% endfor %}+    </ul>+  </section>+{%- endblock -%}
+ tpl/files.html view
@@ -0,0 +1,15 @@+{% extends "base.html" %}++{%- block main -%}+  <ul>{% for ancestor in path %}{% if ancestor %}+    <li><a href="{{ ancestor.link }}">{{ ancestor.name }}</a></li>+  {% endif %}{% endfor %}</ul>+  <ul>{% for dir in dirs %}+    <li><a href="{{ dir }}/">{{ dir }}</a></li>+  {% endfor %}</ul>+  <ul>{% for file in files %}+    <li><a href="/{{ input.index }}/={{ path|xURI }}{{ '/'|xURI }}{{ file|xURI }}">+      {{ file }}+    </a></li>+  {% endfor %}</ul>+{%- endblock -%}
+ tpl/gregorian.html view
@@ -0,0 +1,91 @@+{% extends "base.html" %}++{%- block control -%}<section>+    <style>+      .multi { display: flex }+      ul.control, .multi > ul {+        display: grid;+        gap: 5px;+        grid-template-columns: repeat(3, min-content);+        list-style-type: none;+      }+    </style>++    <p><a href="-year{{Q}}" title="Past year">↓</a>+        {% if input.inputType != "time" %}+        <a href="year/{{Q}}" title="Edit year">{{ T.year }}</a>+        <a href="+year{{Q}}" title="Next year">↑</a>++        <a href="-month{{Q}}" title="Past month">↓</a>+        {{ T.month }}+        <a href="+month{{Q}}" title="Next month">↑</a>+        {% endif %}++        {% if input.inputType != "month" %}+        {% if input.inputType != "time" %}+        <a href="-date{{Q}}" title="Past day">↓</a>+        {{ T.date }}+        <a href="+date{{Q}}" title="Next day">↑</a>+        {% endif %}++        {# NOTE: "week" handling is largely a matter of switching to +/- 7days #}+        {% if input.inputType != "date" && input.inputType != "week" %}+        <a href="-hour{{Q}}" title="Past hour">↓</a>+        {{ T.hour|pad2 }}+        <a href="+hour{{Q}}" title="Next hour">↑</a>+        :+        <a href="-minute{{Q}}" title="Past minute">↓</a>+        {{ T.minute|pad2 }}+        <a href="+minute{{Q}}" title="Next minute">↑</a>+        <a href="meridiem{{Q}}" title="Toggle AM/PM">{{ T.meridiem }}</a>++        {% if input.inputType != "datetime-local" && input.inputType != "time" %}+        <a href="-zone{{Q}}" title="Previous timezone">↓</a>+        <a href="zone/{{Q}}">{{ T.zone }}</a>+        <a href="+zone{{Q}}" title="Next zone">↑</a></p>+        {% endif %}+        {% endif %}+        {% endif %}+    <p><a href="now{{Q}}" title="Select current date & time">Now</a></a>++    <div class="multi">+      {% if input.inputType != "time" %}+      <ul>+        <li><a href="month=0{{Q}}">{{ _["January"] }}</a></li>+        <li><a href="month=1{{Q}}">{{ _["February"] }}</a></li>+        <li><a href="month=2{{Q}}">{{ _["March"] }}</a></li>+        <li><a href="month=3{{Q}}">{{ _["April"] }}</a></li>+        <li><a href="month=4{{Q}}">{{ _["May"] }}</a></li>+        <li><a href="month=5{{Q}}">{{ _["June"] }}</a></li>+        <li><a href="month=6{{Q}}">{{ _["July"] }}</a></li>+        <li><a href="month=7{{Q}}">{{ _["August"] }}</a></li>+        <li><a href="month=8{{Q}}">{{ _["September"] }}</a></li>+        <li><a href="month=9{{Q}}">{{ _["October"] }}</a></li>+        <li><a href="month=10{{Q}}">{{ _["November"] }}</a></li>+        <li><a href="month=11{{Q}}">{{ _["December"] }}</a></li>+      </ul>+      {% endif %}++      {% if input.inputType != "month" %}+      {# TODO: Do we want to refine this for "week" inputs? #}+      {% if input.inputType != "time" %}+      <ul style="grid-template-columns: repeat(7, min-content)">+        {% for i in 1|seqTo(T.daysInMonth) %}+          <li {% if i == 1 %}style="grid-column: {{ T.monthStart }}"{% endif %}>+            <a href="date={{ i }}{{Q}}">{{ i }}</a>+          </li>+        {% endfor %}+      </ul>+      {% endif %}++      {% if input.inputType != "date" && input.inputType != "week" %}+      <ul>{% for i in 1|seqTo(12) %}+        <li><a href="hour={{ i }}{{Q}}">{{ i|pad2 }}:{{ T.minute|pad2 }}</a></li>+      {% endfor %}</ul>+      <ul>{% for i in 0|seqTo(5, 55) %}+        <li><a href="minute={{ i }}{{Q}}">{{ T.hour|pad2 }}:{{ i|pad2 }}</a></li>+      {% endfor %}</ul>+      {% endif %}+      {% endif %}+    </div>+</section>{%- endblock -%}
+ tpl/image-button.html view
@@ -0,0 +1,15 @@+{% extends "base.html" %}++{# NOTE: This is pretty much a legacy input type now.+   We should be sending x/y coordinates to the server,+   but that'd be frustrating to operate using e.g. a TV remote. #}++{%- block main -%}<section>+  <img src="{{ input.src }}" alt="{{ input.alt }}" title="{{ input.alt }}" />+  <div>{{ input.description }}</div>+  <hr />+  <!-- TODO: Internationalize! -->+  <p><a href="_{{Q}}">{{ _["Upload Link"] }}</a> {{ _["Upload Suffix"] }}+    <code>{{ input.form.action|default(form.action) }}+        ({{ input.form.method|default(form.method) }})</code></p>+</section>{%- endblock -%}
+ tpl/keyboards/latin1.html view
@@ -0,0 +1,78 @@+{% extends "/base.html" %}++{%- block control -%}<section>+  {% if input.type != 'password' %}+  <p><span style="white-space: pre">{{ input.value }}</span>+    {% if input.multiple %}<a href=":%0A{{Q}}">↲</a>{% endif %}</p>+  <hr />+  {% endif %}+  <p><strong>abc</strong> | <a href="latin1-upper/{{Q}}">ABC</a> |+      <a href="latin1-symbol/{{Q}}">!@#</a> |+      <a href="latin1-accent-lower/{{Q}}">àêï</a> |+      <a href="latin1-accent/{{Q}}">ÀÊÏ</a></p>+  <table>+    <tr>+      <td><a href=":a{{Q}}">a</a></td>+      <td><a href=":b{{Q}}">b</a></td>+      <td><a href=":c{{Q}}">c</a></td>+      <td><a href=":d{{Q}}">d</a></td>+      <td><a href=":e{{Q}}">e</a></td>+      <td><a href=":f{{Q}}">f</a></td>+    </tr>+    <tr>+      <td><a href=":g{{Q}}">g</a></td>+      <td><a href=":h{{Q}}">h</a></td>+      <td><a href=":i{{Q}}">i</a></td>+      <td><a href=":j{{Q}}">j</a></td>+      <td><a href=":k{{Q}}">k</a></td>+      <td><a href=":l{{Q}}">l</a></td>+    </tr>+    <tr>+      <td><a href=":m{{Q}}">m</a></td>+      <td><a href=":n{{Q}}">n</a></td>+      <td><a href=":o{{Q}}">o</a></td>+      <td><a href=":p{{Q}}">p</a></td>+      <td><a href=":q{{Q}}">q</a></td>+      <td><a href=":r{{Q}}">r</a></td>+    </tr>+    <tr>+      <td><a href=":s{{Q}}">s</a></td>+      <td><a href=":t{{Q}}">t</a></td>+      <td><a href=":u{{Q}}">u</a></td>+      <td><a href=":v{{Q}}">v</a></td>+      <td><a href=":w{{Q}}">w</a></td>+      <td><a href=":x{{Q}}">x</a></td>+    </tr>+    <tr>+      <td><a href=":y{{Q}}">y</a></td>+      <td><a href=":z{{Q}}">z</a></td>+      <td><a href=":1{{Q}}">1</a></td>+      <td><a href=":2{{Q}}">2</a></td>+      <td><a href=":3{{Q}}">3</a></td>+      <td><a href=":4{{Q}}">4</a></td>+    </tr>+    <tr>+      <td><a href=":5{{Q}}">5</a></td>+      <td><a href=":6{{Q}}">6</a></td>+      <td><a href=":7{{Q}}">7</a></td>+      <td><a href=":8{{Q}}">8</a></td>+      <td><a href=":9{{Q}}">9</a></td>+      <td><a href=":0{{Q}}">0</a></td>+    </tr>+    {% if input.inputType == "email" || input.inputType == "url" %}<tr>+      {% if input.inputType == "email" %}+        <td><a href=":@{{Q}}">@</a></td>+      {% else %}+        <td><a href=":{{ '/'|xURI }}{{Q}}">/</a></td>+      {% endif %}+      <td><a href=":.{{Q}}">.</a></td>+      <td colspan="2"><a href=":.com{{Q}}">.com</a>+      <td colspan="2"><a href=":.org{{Q}}">.org</a>+    </tr>{% endif %}+    <tr>+      <td colspan="2"><a href=":%20{{Q}}">{{ _["SPACE"] }}</a></td>+      <td colspan="2"><a href="-{{Q}}">{{ _["DEL"] }}</a></td>+      <td colspan="2"><a href="={{Q}}">{{ _["CLEAR"] }}</a></td>+    </tr>+  </table>+</section>{%- endblock -%}
+ tpl/keyboards/latin1/latin1-accent-lower.html view
@@ -0,0 +1,70 @@+{% extends "/base.html" %}++{%- block control -%}<section>+  {% if input.type != 'password' %}+  <p><span style="white-space: pre">{{ input.value }}</span>+    {% if input.multiple %}<a href=":%0A{{Q}}">↲</a>{% endif %}</p>+  <hr />+  {% endif %}+  <p><a href="..">abc</a> | <a href="../latin1-upper/{{Q}}">ABC</a> |+      <a href="../latin1-symbol/{{Q}}">!@#</a> | <strong>àêï</strong> |+      <a href="../latin1-accent/{{Q}}">ÀÊÏ</a></p>+  <table>+    <tr>+      <td><a href=":{{'à'|xURI}}{{Q}}">à</a></td>+      <td><a href=":{{'á'|xURI}}{{Q}}">á</a></td>+      <td><a href=":{{'â'|xURI}}{{Q}}">â</a></td>+      <td><a href=":{{'ä'|xURI}}{{Q}}">ä</a></td>+      <td><a href=":{{'ã'|xURI}}{{Q}}">ã</a></td>+      <td><a href=":{{'å'|xURI}}{{Q}}">å</a></td>+    </tr>+    <tr>+      <td><a href=":{{'è'|xURI}}{{Q}}">è</a></td>+      <td><a href=":{{'é'|xURI}}{{Q}}">é</a></td>+      <td><a href=":{{'ê'|xURI}}{{Q}}">ê</a></td>+      <td><a href=":{{'ë'|xURI}}{{Q}}">ë</a></td>+      <td><a href=":{{'ç'|xURI}}{{Q}}">ç</a></td>+      <td><a href=":{{'æ'|xURI}}{{Q}}">æ</a></td>+    </tr>+    <tr>+      <td><a href=":{{'ì'|xURI}}{{Q}}">ì</a></td>+      <td><a href=":{{'í'|xURI}}{{Q}}">í</a></td>+      <td><a href=":{{'î'|xURI}}{{Q}}">î</a></td>+      <td><a href=":{{'ï'|xURI}}{{Q}}">ï</a></td>+      <td><a href=":{{'ð'|xURI}}{{Q}}">ð</a></td>+      <td><a href=":{{'ñ'|xURI}}{{Q}}">ñ</a></td>+    </tr>+    <tr>+      <td><a href=":{{'ò'|xURI}}{{Q}}">ò</a></td>+      <td><a href=":{{'ó'|xURI}}{{Q}}">ó</a></td>+      <td><a href=":{{'ô'|xURI}}{{Q}}">ô</a></td>+      <td><a href=":{{'ö'|xURI}}{{Q}}">ö</a></td>+      <td><a href=":{{'õ'|xURI}}{{Q}}">õ</a></td>+      <td><a href=":{{'ø'|xURI}}{{Q}}">ø</a></td>+    </tr>+    <tr>+      <td><a href=":{{'ù'|xURI}}{{Q}}">ù</a></td>+      <td><a href=":{{'ú'|xURI}}{{Q}}">ú</a></td>+      <td><a href=":{{'û'|xURI}}{{Q}}">û</a></td>+      <td><a href=":{{'ü'|xURI}}{{Q}}">ü</a></td>+      <td><a href=":{{'ý'|xURI}}{{Q}}">ý</a></td>+      <td><a href=":{{'ÿ'|xURI}}{{Q}}">ÿ</a></td>+    </tr>+    {% if input.inputType == "email" || input.inputType == "url" %}<tr>+      {% if input.inputType == "email" %}+        <td><a href=":@{{Q}}">@</a></td>+      {% else %}+        <td><a href=":{{ '/'|xURI }}{{Q}}">/</a></td>+      {% endif %}+      <td><a href=":.{{Q}}">.</a></td>+      <td colspan="2"><a href=":.com{{Q}}">.com</a>+      <td colspan="2"><a href=":.org{{Q}}">.org</a>+    </tr>{% endif %}+    <tr>+      <td><a href=":{{'þ'|xURI}}{{Q}}">þ</a></td>+      <td><a href=":%20{{Q}}">{{ _["SPACE"] }}</a></td>+      <td colspan="2"><a href="-{{Q}}">{{ _["DEL"] }}</a></td>+      <td colspan="2"><a href="={{Q}}">{{ _["CLEAR"] }}</a></td>+    </tr>+  </table>+</section>{%- endblock -%}
+ tpl/keyboards/latin1/latin1-accent.html view
@@ -0,0 +1,71 @@+{% extends "/base.html" %}++{%- block control -%}<section>+  {% if input.type != 'password' %}+  <p><span style="white-space: pre">{{ input.value }}</span>+    {% if input.multiple %}<a href=":%0A{{Q}}">↲</a>{% endif %}</p>+  <hr />+  {% endif %}+  <p><a href="..">abc</a> | <a href="../latin1-upper/{{Q}}">ABC</a> |+      <a href="../latin1-symbol/{{Q}}">!@#</a> |+      <a href="../latin1-accent-lower/{{Q}}">àêï</a> |+      <strong>ÀÊÏ</strong></p>+  <table>+    <tr>+      <td><a href=":{{'À'|xURI}}{{Q}}">À</a></td>+      <td><a href=":{{'Á'|xURI}}{{Q}}">Á</a></td>+      <td><a href=":{{'Â'|xURI}}{{Q}}">Â</a></td>+      <td><a href=":{{'Ä'|xURI}}{{Q}}">Ä</a></td>+      <td><a href=":{{'Ã'|xURI}}{{Q}}">Ã</a></td>+      <td><a href=":{{'Å'|xURI}}{{Q}}">Å</a></td>+    </tr>+    <tr>+      <td><a href=":{{'È'|xURI}}{{Q}}">È</a></td>+      <td><a href=":{{'É'|xURI}}{{Q}}">É</a></td>+      <td><a href=":{{'Ê'|xURI}}{{Q}}">Ê</a></td>+      <td><a href=":{{'Ë'|xURI}}{{Q}}">Ë</a></td>+      <td><a href=":{{'Ç'|xURI}}{{Q}}">Ç</a></td>+      <td><a href=":{{'Æ'|xURI}}{{Q}}">Æ</a></td>+    </tr>+    <tr>+      <td><a href=":{{'Ì'|xURI}}{{Q}}">Ì</a></td>+      <td><a href=":{{'Í'|xURI}}{{Q}}">Í</a></td>+      <td><a href=":{{'Î'|xURI}}{{Q}}">Î</a></td>+      <td><a href=":{{'Ï'|xURI}}{{Q}}">Ï</a></td>+      <td><a href=":{{'Ð'|xURI}}{{Q}}">Ð</a></td>+      <td><a href=":{{'Ñ'|xURI}}{{Q}}">Ñ</a></td>+    </tr>+    <tr>+      <td><a href=":{{'Ò'|xURI}}{{Q}}">Ò</a></td>+      <td><a href=":{{'Ó'|xURI}}{{Q}}">Ó</a></td>+      <td><a href=":{{'Ô'|xURI}}{{Q}}">Ô</a></td>+      <td><a href=":{{'Ö'|xURI}}{{Q}}">Ö</a></td>+      <td><a href=":{{'Õ'|xURI}}{{Q}}">Õ</a></td>+      <td><a href=":{{'Ø'|xURI}}{{Q}}">Ø</a></td>+    </tr>+    <tr>+      <td><a href=":{{'Ù'|xURI}}{{Q}}">Ù</a></td>+      <td><a href=":{{'Ú'|xURI}}{{Q}}">Ú</a></td>+      <td><a href=":{{'Û'|xURI}}{{Q}}">Û</a></td>+      <td><a href=":{{'Ü'|xURI}}{{Q}}">Ü</a></td>+      <td><a href=":{{'Ý'|xURI}}{{Q}}">Ý</a></td>+      <td><a href=":{{'ß'|xURI}}{{Q}}">ß</a></td>+    </tr>+    {% if input.inputType == "email" || input.inputType == "url" %}<tr>+      {% if input.inputType == "email" %}+        <td><a href=":@{{Q}}">@</a></td>+      {% else %}+        <td><a href=":{{ '/'|xURI }}{{Q}}">/</a></td>+      {% endif %}+      <td><a href=":.{{Q}}">.</a></td>+      <td colspan="2"><a href=":.com{{Q}}">.com</a>+      <td colspan="2"><a href=":.org{{Q}}">.org</a>+    </tr>{% endif %}+    <tr>+      <td><a href=":{{'Þ'|xURI}}{{Q}}">Þ</a></td>+      <td><a href=":%20{{Q}}">{{ _["SPACE"] }}</a></td>+      <td colspan="2"><a href="-{{Q}}">{{ _["DEL"] }}</a></td>+      <td colspan="2"><a href="={{Q}}">{{ _["CLEAR"] }}</a></td>+    </tr>+  </table>+</section>{%- endblock -%}
+ tpl/keyboards/latin1/latin1-symbol.html view
@@ -0,0 +1,111 @@+{% extends "/base.html" %}++{%- block control -%}<section>+  {% if input.type != 'password' %}+  <p><span style="white-space: pre">{{ input.value }}</span>+    {% if input.multiple %}<a href=":%0A{{Q}}">↲</a>{% endif %}</p>+  <hr />+  {% endif %}+  <p><a href="..{{Q}}">abc</a> | <a href="../latin1-upper/{{Q}}">ABC</a> |+      <strong>!@#</strong> | <a href="../latin1-accent-lower/{{Q}}">àêï</a> |+      <a href="../latin1-accent/{{Q}}">ÀÊÏ</a></p>+  <table>+    <tr>+      <td><a href=":{{'?'|xURI}}{{Q}}">?</a></td>+      <td><a href=":,{{Q}}">,</a></td>+      <td><a href=":.{{Q}}">.</a></td>+      <td><a href="::{{Q}}">:</a></td>+      <td><a href=":;{{Q}}">;</a></td>+      <td><a href=":{{'('|xURI}}{{Q}}">(</a></td>+      <td><a href=":{{')'|xURI}}{{Q}}">)</a></td>+    </tr>+    <tr>+      <td><a href=":{{'/'|xURI}}{{Q}}">/</a></td>+      <td><a href=":{{'='|xURI}}{{Q}}">=</a></td>+      <td><a href=':"{{Q}}'>"</a></td>+      <td><a href=":'{{Q}}">'</a></td>+      <td><a href=":{{'!'|xURI}}{{Q}}">!</a></td>+      <td><a href=":{{'['|xURI}}{{Q}}">[</a></td>+      <td><a href=":{{']'|xURI}}{{Q}}">]</a></td>+    </tr>+    <tr>+      <td><a href=":\{{Q}}">\</a></td>+      <td><a href=":{{'~'|xURI}}{{Q}}">~</a></td>+      <td><a href=":{{'`'|xURI}}{{Q}}">`</a></td>+      <td><a href=":{{'|'|xURI}}{{Q}}">|</a></td>+      <td><a href=":{{'@'|xURI}}{{Q}}">@</a></td>+      <td><a href=":{{'{'|xURI}}{{Q}}">{</a></td>+      <td><a href=":{{'}'|xURI}}{{Q}}">}</a></td>+    </tr>+    <tr>+      <td><a href=":{{'¸'|xURI}}{{Q}}">¸</a></td>+      <td><a href=":{{'%'|xURI}}{{Q}}">%</a></td>+      <td><a href=":{{'^'|xURI}}{{Q}}">^</a></td>+      <td><a href=":{{'&'|xURI}}{{Q}}">&</a></td>+      <td><a href=":{{'*'|xURI}}{{Q}}">*</a></td>+      <td><a href=":{{'<'|xURI}}{{Q}}">&lt;</a></td>+      <td><a href=":{{'>'|xURI}}{{Q}}">&gt;</a></td>+    </tr>+    <tr>+      <td><a href=":{{'¶'|xURI}}{{Q}}">¶</a></td>+      <td><a href=":{{'#'|xURI}}{{Q}}">#</a></td>+      <td><a href=":{{'$'|xURI}}{{Q}}">$</a></td>+      <td><a href=":{{'-'|xURI}}{{Q}}">-</a></td>+      <td><a href=":{{'_'|xURI}}{{Q}}">_</a></td>+      <td><a href=":{{'´'|xURI}}{{Q}}">´</a></td>+      <td><a href=":{{'·'|xURI}}{{Q}}">·</a></td>+    </tr>+    <tr>+      <td><a href=":{{'×'|xURI}}{{Q}}">×</a></td>+      <td><a href=":{{'¢'|xURI}}{{Q}}">¢</a></td>+      <td><a href=":{{'£'|xURI}}{{Q}}">£</a></td>+      <td><a href=":{{'¤'|xURI}}{{Q}}">¤</a></td>+      <td><a href=":{{'¥'|xURI}}{{Q}}">¥</a></td>+      <td><a href=":{{'«'|xURI}}{{Q}}">«</a></td>+      <td><a href=":{{'»'|xURI}}{{Q}}">»</a></td>+    </tr>+    <tr>+      <td><a href=":{{'÷'|xURI}}{{Q}}">÷</a></td>+      <td><a href=":{{'§'|xURI}}{{Q}}">§</a></td>+      <td><a href=":{{'¨'|xURI}}{{Q}}">¨</a></td>+      <td><a href=":{{'©'|xURI}}{{Q}}">©</a></td>+      <td><a href=":{{'ª'|xURI}}{{Q}}">ª</a></td>+      <td><a href=":{{'¡'|xURI}}{{Q}}">¡</a></td>+      <td><a href=":{{'¦'|xURI}}{{Q}}">¦</a></td>+    </tr>+    <tr>+      <td><a href=":{{'µ'|xURI}}{{Q}}">µ</a></td>+      <td><a href=":{{'¬'|xURI}}{{Q}}">¬</a></td>+      <td><a href=":{{'¯'|xURI}}{{Q}}">¯</a></td>+      <td><a href=":{{'®'|xURI}}{{Q}}">®</a></td>+      <td><a href=":{{'°'|xURI}}{{Q}}">°</a></td>+      <td><a href=":{{'±'|xURI}}{{Q}}">±</a></td>+      <td><a href=":{{'¿'|xURI}}{{Q}}">¿</a></td>+    </tr>+    <tr>+      <td><a href=":{{'º'|xURI}}{{Q}}">º</a></td>+      <td><a href=":{{'¹'|xURI}}{{Q}}">¹</a></td>+      <td><a href=":{{'²'|xURI}}{{Q}}">²</a></td>+      <td><a href=":{{'³'|xURI}}{{Q}}">³</a></td>+      <td><a href=":{{'¼'|xURI}}{{Q}}">¼</a></td>+      <td><a href=":{{'½'|xURI}}{{Q}}">½</a></td>+      <td><a href=":{{'¾'|xURI}}{{Q}}">¾</a></td>+    </tr>+    {% if input.inputType == "email" || input.inputType == "url" %}<tr>+      {% if input.inputType == "email" %}+        <td><a href=":@{{Q}}">@</a></td>+      {% else %}+        <td><a href=":{{ '/'|xURI }}{{Q}}">/</a></td>+      {% endif %}+      <td><a href=":.{{Q}}">.</a></td>+      <td colspan="2"><a href=":.com{{Q}}">.com</a>+      <td colspan="2"><a href=":.org{{Q}}">.org</a>+    </tr>{% endif %}+    <tr>+      <td><a href=":{{'+'|xURI}}{{Q}}">+</a></td>+      <td colspan="2"><a href=":%20{{Q}}">{{ _["SPACE"] }}</a></td>+      <td colspan="2"><a href="-{{Q}}">{{ _["DEL"] }}</a></td>+      <td colspan="2"><a href="={{Q}}">{{ _["CLEAR"] }}</a></td>+    </tr>+  </table>+</section>{%- endblock -%}
+ tpl/keyboards/latin1/latin1-upper.html view
@@ -0,0 +1,78 @@+{% extends "/base.html" %}++{%- block control -%}<section>+  {% if input.type != 'password' %}+  <p><span style="white-space: pre">{{ input.value }}</span>+    {% if input.multiple %}<a href=":%0A{{Q}}">↲</a>{% endif %}</p>+  <hr />+  {% endif %}+  <p><a href="..{{Q}}">abc</a> | <strong>ABC</strong> |+      <a href="../latin1-symbol/{{Q}}">!@#</a> |+      <a href="../latin1-accent-lower/{{Q}}">àêï</a> |+      <a href="../latin1-accent/{{Q}}">ÀÊÏ</a></p>+  <table>+    <tr>+      <td><a href=":A{{Q}}">A</a></td>+      <td><a href=":B{{Q}}">B</a></td>+      <td><a href=":C{{Q}}">C</a></td>+      <td><a href=":D{{Q}}">D</a></td>+      <td><a href=":E{{Q}}">E</a></td>+      <td><a href=":F{{Q}}">F</a></td>+    </tr>+    <tr>+      <td><a href=":G{{Q}}">G</a></td>+      <td><a href=":H{{Q}}">H</a></td>+      <td><a href=":I{{Q}}">I</a></td>+      <td><a href=":J{{Q}}">J</a></td>+      <td><a href=":K{{Q}}">K</a></td>+      <td><a href=":L{{Q}}">L</a></td>+    </tr>+    <tr>+      <td><a href=":M{{Q}}">M</a></td>+      <td><a href=":N{{Q}}">N</a></td>+      <td><a href=":O{{Q}}">O</a></td>+      <td><a href=":P{{Q}}">P</a></td>+      <td><a href=":Q{{Q}}">Q</a></td>+      <td><a href=":R{{Q}}">R</a></td>+    </tr>+    <tr>+      <td><a href=":S{{Q}}">S</a></td>+      <td><a href=":T{{Q}}">T</a></td>+      <td><a href=":U{{Q}}">U</a></td>+      <td><a href=":V{{Q}}">V</a></td>+      <td><a href=":W{{Q}}">W</a></td>+      <td><a href=":X{{Q}}">X</a></td>+    </tr>+    <tr>+      <td><a href=":Y{{Q}}">Y</a></td>+      <td><a href=":Z{{Q}}">Z</a></td>+      <td><a href=":1{{Q}}">1</a></td>+      <td><a href=":2{{Q}}">2</a></td>+      <td><a href=":3{{Q}}">3</a></td>+      <td><a href=":4{{Q}}">4</a></td>+    </tr>+    <tr>+      <td><a href=":5{{Q}}">5</a></td>+      <td><a href=":6{{Q}}">6</a></td>+      <td><a href=":7{{Q}}">7</a></td>+      <td><a href=":8{{Q}}">8</a></td>+      <td><a href=":9{{Q}}">9</a></td>+      <td><a href=":0{{Q}}">0</a></td>+    </tr>+    {% if input.inputType == "email" || input.inputType == "url" %}<tr>+      {% if input.inputType == "email" %}+        <td><a href=":@{{Q}}">@</a></td>+      {% else %}+        <td><a href=":{{ '/'|xURI }}{{Q}}">/</a></td>+      {% endif %}+      <td><a href=":.{{Q}}">.</a></td>+      <td colspan="2"><a href=":.com{{Q}}">.com</a>+      <td colspan="2"><a href=":.org{{Q}}">.org</a>+    </tr>{% endif %}+    <tr>+      <td colspan="2"><a href=":%20{{Q}}">{{ _["SPACE"] }}</a></td>+      <td colspan="2"><a href="-{{Q}}">{{ _["DEL"] }}</a></td>+      <td colspan="2"><a href="={{Q}}">{{ _["CLEAR"] }}</a></td>+    </tr>+  </table>+</section>{%- endblock -%}
+ tpl/number.html view
@@ -0,0 +1,34 @@+{% extends "base.html" %}++{%- block main -%}<section>+  <p><a href="+-{{input.step|default(1)}}{{Q}}">↓</a>+    {{ input.value }}+    <a href="+{{input.step|default(1)}}{{Q}}">↑</a></p>+  <hr />+  <table>+    <tr>+      <td><a href=":7{{Q}}">7</a></td>+      <td><a href=":8{{Q}}">8</a></td>+      <td><a href=":9{{Q}}">9</a></td>+      <td><a href=":-{{Q}}">-</a></td>+    </tr>+    <tr>+      <td><a href=":4{{Q}}">4</a></td>+      <td><a href=":5{{Q}}">5</a></td>+      <td><a href=":6{{Q}}">6</a></td>+      <td><a href=":%20{{Q}}">{{ _["SPACE"] }}</a></td>+    </tr>+    <tr>+      <td><a href=":1{{Q}}">1</a></td>+      <td><a href=":2{{Q}}">2</a></td>+      <td><a href=":3{{Q}}">3</a></td>+      <td><a href="-{{Q}}">{{ _["DEL"] }}</a></td>+    </tr>+    <tr>+      <td><a href=":.{{Q}}">.</a></td>+      <td><a href=":0{{Q}}">0</a></td>+      <td><a href=":,{{Q}}">,</a></td>+      <td><a href="={{Q}}">{{ _["CLEAR"] }}</a></td>+    </tr>+  </table>+</section>{%- endblock -%}
+ tpl/radio.html view
@@ -0,0 +1,1 @@+{% extends "base.html" %}
+ tpl/reset.html view
@@ -0,0 +1,7 @@+{% extends "base.html" %}++{%- block main -%}<section>+  <div>{{ input.description }}</div>+  <hr />+  <p><a href="_">{{ _["Restore defaults"] }}</a></p>+</section>{%- endblock -%}
+ tpl/select.html view
@@ -0,0 +1,32 @@+{% extends "base.html" %}++{%- block main -%}<section>+  <h2>{{ input.label }}</h2>+  {{ input.description }}+  <hr />+  <nav><dl>{% for grp in input.list %}+    {% if grp.label == "" %}{% for opt in grp.opts %}+      {% if opt.disabled %}+        <dt>{{ opt.label }}</dt>+      {% else %}+        <dt {% if opt.selected %}class="checked"{% endif %}>+          {% if opt.selected %}&check;{% endif %}+          <a href="={{opt.value|xURI}}{{Q}}" title="{{opt.value}}">{{opt.label}}</a>+        </dt>+      {% endif %}+    {% endfor %}{% else %}+      <dt>{{ opt.label }}</dt>+      {% for opt in grp.opts %}+        {% if opt.disabled %}+          <dd>{{ opt.label }}</dd>+        {% else %}+          <dd {% if opt.selected %}class="checked"{% endif %}>+            {% if opt.selected %}&check;{% endif %}+            <a href="={{opt.value|xURI}}{{Q}}" title="{{opt.value}}">+              {{opt.label}}</a>+          </dd>+        {% endif %}+      {% endfor %}+    {% endif %}+  {% endfor %}</dl></nav>+</section>{%- endblock -%}
+ tpl/submit.html view
@@ -0,0 +1,14 @@+{% extends "base.html" %}++{%- block main -%}<section>+  <div>{{ input.description }}</div>+  <hr />+  {% if isFormValid %}+  <!-- TODO: Internationalize! -->+  <p><a href="_{{Q}}">Upload</a> to+    <code>{{ input.form.action|default(form.action) }}+        ({{ input.form.method|default(form.method) }})</code></p>+  {% else %}+  <p>{{ _["errored"] }}</p>+  {% endif %}+</section>{%- endblock -%}
+ tpl/tel.html view
@@ -0,0 +1,32 @@+{% extends "base.html" %}++{%- block control -%}<section>+  <p>{{ input.value }}</p>+  <hr />+  <table>+    <tr>+      <td><a href=":7{{Q}}">7</a></td>+      <td><a href=":8{{Q}}">8</a></td>+      <td><a href=":9{{Q}}">9</a></td>+      <td><a href=":-{{Q}}">-</a></td>+    </tr>+    <tr>+      <td><a href=":4{{Q}}">4</a></td>+      <td><a href=":5{{Q}}">5</a></td>+      <td><a href=":6{{Q}}">6</a></td>+      <td><a href=":%20{{Q}}">{{ _["SPACE"] }}</a></td>+    </tr>+    <tr>+      <td><a href=":1{{Q}}">1</a></td>+      <td><a href=":2{{Q}}">2</a></td>+      <td><a href=":3{{Q}}">3</a></td>+      <td><a href="-{{Q}}">{{ _["DEL"] }}</a></td>+    </tr>+    <tr>+      <td><a href=":*{{Q}}">*</a></td>+      <td><a href=":0{{Q}}">0</a></td>+      <td><a href=":#{{Q}}">#</a></td>+      <td><a href=":.{{Q}}">.</a></td>+    </tr>+  </table>+</section>{%- endblock -%}