initial commit

This commit is contained in:
Pedro Pérez 2025-09-29 23:53:04 +02:00
commit 085b645265
13 changed files with 476 additions and 0 deletions

8
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

8
.idea/modules.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/templating.iml" filepath="$PROJECT_DIR$/.idea/templating.iml" />
</modules>
</component>
</project>

9
.idea/templating.iml generated Normal file
View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

32
go.mod Normal file
View File

@ -0,0 +1,32 @@
module templating
go 1.25.0
require (
github.com/go-playground/validator/v10 v10.27.0
github.com/gofiber/fiber/v2 v2.52.9
github.com/gofiber/template/html/v2 v2.1.3
)
require (
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/gofiber/template v1.8.3 // indirect
github.com/gofiber/utils v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
)

59
go.sum Normal file
View File

@ -0,0 +1,59 @@
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw=
github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc=
github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8=
github.com/gofiber/template/html/v2 v2.1.3 h1:n1LYBtmr9C0V/k/3qBblXyMxV5B0o/gpb6dFLp8ea+o=
github.com/gofiber/template/html/v2 v2.1.3/go.mod h1:U5Fxgc5KpyujU9OqKzy6Kn6Qup6Tm7zdsISR+VpnHRE=
github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM=
github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

109
main.go Normal file
View File

@ -0,0 +1,109 @@
package main
import (
"log/slog"
"templating/ui"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/template/html/v2"
)
func main() {
engine := html.New("./templates", ".html")
engine.Reload(true)
app := fiber.New(
fiber.Config{
Views: engine,
})
app.Get("/", func(c *fiber.Ctx) error {
return c.Render("pages/index", fiber.Map{
"Message": "Hello World",
"UsernameField": ui.UsernameField().ToMap(),
"EmailField": ui.EmailField().ToMap(),
"PasswordField": ui.PasswordField().ToMap(),
}, "layouts/base")
})
type SomeForm struct {
Username string `json:"username" validate:"required,min=3,max=30,username"`
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=8,max=20"`
}
app.Post("/validate/username", func(c *fiber.Ctx) error {
slog.Info(`validate username triggered`)
username := c.FormValue("username")
input := SomeForm{
Username: username,
}
validate := ui.GetValidator()
err := validate.StructPartial(input, "Username")
if err != nil {
field := ui.UsernameField().
WithValue(username).
WithError(ui.GetFirstError(err))
return c.Render("components/input", field.ToMap())
}
// Emular que el nombre de usuario existe
if username == "juan" {
slog.Info("username already exists")
field := ui.UsernameField().
WithValue(username).
WithError("El nombre de usuario ya existe")
return c.Render("components/input", field.ToMap())
}
field := ui.UsernameField().WithValue(username)
return c.Render("components/input", field.ToMap())
})
app.Post("/validate/email", func(c *fiber.Ctx) error {
slog.Info(`validate email triggered`)
email := c.FormValue("email")
input := SomeForm{
Email: email,
}
validate := ui.GetValidator()
err := validate.StructPartial(input, "Email")
if err != nil {
field := ui.EmailField().
WithValue(email).
WithError(ui.GetFirstError(err))
return c.Render("components/input", field.ToMap())
}
field := ui.EmailField().WithValue(email)
return c.Render("components/input", field.ToMap())
})
app.Post("/validate/password", func(c *fiber.Ctx) error {
slog.Info(`validate password triggered`)
password := c.FormValue("password")
input := SomeForm{
Password: password,
}
validate := ui.GetValidator()
err := validate.StructPartial(input, "Password")
if err != nil {
field := ui.PasswordField().
WithValue(password).
WithError(ui.GetFirstError(err))
return c.Render("components/input", field.ToMap())
}
field := ui.PasswordField().WithValue(password)
return c.Render("components/input", field.ToMap())
})
app.Listen(":3000")
}

View File

@ -0,0 +1,28 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>{{ .Title }}</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5.0" rel="stylesheet" type="text/css" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" />
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js" defer></script>
<style>
.spinner {
display: none;
}
.htmx-request .spinner {
display: inline;
}
.htmx-request.spinner {
display: inline;
}
.htmx-request .button-text {
display: none;
}
</style>

View File

@ -0,0 +1,20 @@
<fieldset class="fieldset" id="{{ .ID }}">
<legend class="fieldset-legend">{{ .Legend }}</legend>
<input
type="{{ .Type }}"
class="input {{ .InputClass }}"
placeholder="{{ .Placeholder }}"
name="{{ .Name }}"
value="{{ .Value }}"
hx-post="{{ .Endpoint }}"
hx-trigger="{{ .Trigger }}"
hx-target="#{{ .ID }}"
hx-swap="outerHTML"
x-data
@input="$el.classList.remove('input-error', 'text-error'); $el.nextElementSibling.textContent = ''"
/>
<p class="label min-h-[1.2rem] {{ .InputClass }}">
{{ .Hint }}
</p>
</fieldset>

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="es" data-theme="bumblebee" class="bg-base-200">
<head>
{{ template "components/head" . }}
</head>
<body>
{{ embed }}
</body>
</html>

View File

@ -0,0 +1,12 @@
<div class="container p-2">
<h1 class="text-2xl">Inicio de sesión</h1>
{{ template "components/input" .EmailField }}
{{ template "components/input" .PasswordField }}
</div>

67
ui/components.go Normal file
View File

@ -0,0 +1,67 @@
package ui
import "github.com/gofiber/fiber/v2"
type InputField struct {
ID string
Legend string
Type string
Placeholder string
Name string
Value string
Endpoint string
Trigger string
Hint string
InputClass string
}
func NewInputField(id, name, legend, placeholder, endpoint string) InputField {
return InputField{
ID: id,
Legend: legend,
Type: "text",
Placeholder: placeholder,
Name: name,
Value: "",
Endpoint: endpoint,
Trigger: "blur",
Hint: "",
InputClass: "",
}
}
func (f InputField) ToMap() fiber.Map {
return fiber.Map{
"ID": f.ID,
"Legend": f.Legend,
"Type": f.Type,
"Placeholder": f.Placeholder,
"Name": f.Name,
"Value": f.Value,
"Endpoint": f.Endpoint,
"Trigger": f.Trigger,
"Hint": f.Hint,
"InputClass": f.InputClass,
}
}
func (f InputField) WithValue(value string) InputField {
f.Value = value
return f
}
func (f InputField) WithError(hint string) InputField {
f.Hint = hint
f.InputClass = "input-error text-error"
return f
}
func (f InputField) WithType(inputType string) InputField {
f.Type = inputType
return f
}
func (f InputField) WithTrigger(trigger string) InputField {
f.Trigger = trigger
return f
}

35
ui/helpers.go Normal file
View File

@ -0,0 +1,35 @@
package ui
// Aquí puedes añadir helpers predefinidos para campos comunes
// UsernameField crea un campo de username preconfigurado
func UsernameField() InputField {
return NewInputField(
"username-container",
"username",
"Nombre de usuario",
"juan.01",
"/validate/username",
)
}
// EmailField crea un campo de email preconfigurado
func EmailField() InputField {
return NewInputField(
"email-container",
"email",
"Correo electrónico",
"juan.01@email.com",
"/validate/email",
).WithType("email")
}
func PasswordField() InputField {
return NewInputField(
"password-container",
"password",
"Contraseña",
"••••••••",
"/validate/password",
).WithType("password")
}

75
ui/validation.go Normal file
View File

@ -0,0 +1,75 @@
package ui
import (
"errors"
"fmt"
"github.com/go-playground/validator/v10"
"regexp"
)
// Instancia global del validador
var validate *validator.Validate
func init() {
validate = validator.New()
_ = validate.RegisterValidation("username", validateUsername)
}
// GetValidator devuelve la instancia del validador
func GetValidator() *validator.Validate {
return validate
}
// Validación personalizada de username (ejemplo)
func validateUsername(fl validator.FieldLevel) bool {
value := fl.Field().String()
re := regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*[a-zA-Z0-9_]$`)
if !re.MatchString(value) {
return false
}
if regexp.MustCompile(`[.-]{2}`).MatchString(value) {
return false
}
return true
}
var ErrorMessages = map[string]string{
"required": "Este campo es obligatorio",
"min": "Debe tener al menos %s caracteres",
"max": "No puede tener más de %s caracteres",
"email": "Correo electrónico no válido",
"alphanum": "Solo se permiten letras y números",
"alpha": "Solo se permiten letras",
"numeric": "Solo se permiten números",
"len": "Debe tener exactamente %s caracteres",
"url": "Formato de URL no válido",
"containsany": "Debe contener al menos uno de estos caracteres: %s",
"username": "Formato de usuario no válido",
}
// translateError convierte un error de validación a mensaje legible
func translateError(err validator.FieldError) string {
template, exists := ErrorMessages[err.Tag()]
if !exists {
return "Valor inválido"
}
// Si el mensaje tiene placeholder, reemplazar con el parámetro
if err.Param() != "" {
return fmt.Sprintf(template, err.Param())
}
return template
}
// GetFirstError extrae el primer error de validación
func GetFirstError(err error) string {
var validationErrors validator.ValidationErrors
if errors.As(err, &validationErrors) {
if len(validationErrors) > 0 {
return translateError(validationErrors[0])
}
}
return "Error de validación"
}