commit f8840962d93043ed8cd2e5e84d1928cf7bb874a8
Author: Yongbin Kim <iam@yongbin.kim>
Date: Tue, 25 Jan 2022 03:06:30 +0900
First Commit
Diffstat:
25 files changed, 1770 insertions(+), 0 deletions(-)
diff --git a/.gitattributes b/.gitattributes
@@ -0,0 +1 @@
+go.sum binary
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,190 @@
+# Created by https://www.toptal.com/developers/gitignore/api/intellij+all,macos,windows,linux,visualstudiocode
+# Edit at https://www.toptal.com/developers/gitignore?templates=intellij+all,macos,windows,linux,visualstudiocode
+
+### Intellij+all ###
+# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
+# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
+
+# User-specific stuff
+.idea/**/workspace.xml
+.idea/**/tasks.xml
+.idea/**/usage.statistics.xml
+.idea/**/dictionaries
+.idea/**/shelf
+
+# AWS User-specific
+.idea/**/aws.xml
+
+# Generated files
+.idea/**/contentModel.xml
+
+# Sensitive or high-churn files
+.idea/**/dataSources/
+.idea/**/dataSources.ids
+.idea/**/dataSources.local.xml
+.idea/**/sqlDataSources.xml
+.idea/**/dynamic.xml
+.idea/**/uiDesigner.xml
+.idea/**/dbnavigator.xml
+
+# Gradle
+.idea/**/gradle.xml
+.idea/**/libraries
+
+# Gradle and Maven with auto-import
+# When using Gradle or Maven with auto-import, you should exclude module files,
+# since they will be recreated, and may cause churn. Uncomment if using
+# auto-import.
+# .idea/artifacts
+# .idea/compiler.xml
+# .idea/jarRepositories.xml
+# .idea/modules.xml
+# .idea/*.iml
+# .idea/modules
+# *.iml
+# *.ipr
+
+# CMake
+cmake-build-*/
+
+# Mongo Explorer plugin
+.idea/**/mongoSettings.xml
+
+# File-based project format
+*.iws
+
+# IntelliJ
+out/
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Cursive Clojure plugin
+.idea/replstate.xml
+
+# SonarLint plugin
+.idea/sonarlint/
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+
+# Editor-based Rest Client
+.idea/httpRequests
+
+# Android studio 3.1+ serialized cache file
+.idea/caches/build_file_checksums.ser
+
+### Intellij+all Patch ###
+# Ignores the whole .idea folder and all .iml files
+# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360
+
+.idea/*
+
+# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023
+
+*.iml
+modules.xml
+.idea/misc.xml
+*.ipr
+
+# Sonarlint plugin
+.idea/sonarlint
+
+### Linux ###
+*~
+
+# temporary files which can be created if a process still has a handle open of a deleted file
+.fuse_hidden*
+
+# KDE directory preferences
+.directory
+
+# Linux trash folder which might appear on any partition or disk
+.Trash-*
+
+# .nfs files are created when an open file is removed but is still being accessed
+.nfs*
+
+### macOS ###
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+### VisualStudioCode ###
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+!.vscode/*.code-snippets
+
+# Local History for Visual Studio Code
+.history/
+
+# Built Visual Studio Code Extensions
+*.vsix
+
+### VisualStudioCode Patch ###
+# Ignore all local history of files
+.history
+.ionide
+
+# Support for Project snippet scope
+
+### Windows ###
+# Windows thumbnail cache files
+Thumbs.db
+Thumbs.db:encryptable
+ehthumbs.db
+ehthumbs_vista.db
+
+# Dump file
+*.stackdump
+
+# Folder config file
+[Dd]esktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Windows Installer files
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# Windows shortcuts
+*.lnk
+
+# End of https://www.toptal.com/developers/gitignore/api/intellij+all,macos,windows,linux,visualstudiocode
diff --git a/LICENSE b/LICENSE
@@ -0,0 +1,4 @@
+Yongbin Kim <iam@yongbin.kim> wrote this software.
+
+0. Sure, no problem.
+1. Please use it at your own risk.
diff --git a/README.md b/README.md
@@ -0,0 +1,103 @@
+# go.lair.cx/tpls
+
+Fast and extendable template engine for Go.
+
+## Quickstart
+
+Let's start with this small example.
+
+```
+<template class="Render(name string)">
+ <div>Hello, <string>name</string>!</div>
+</template>
+```
+
+Save this file as `./templates/example.html`, and compile it:
+
+```
+$ go run go.lair.cx/tpls/cmd/tplc
+```
+
+Command tplc finds html files in current directory, and writes the Go code.
+Generated file is `./templates/example.html.go`, let's open this.
+
+```
+// Code generated by tplc. DO NOT EDIT.
+
+package templates
+
+import "go.lair.cx/tpls"
+
+func Render(w tpls.Writer, name string) {
+ w.WriteRaw(`<div>Hello, `)
+ w.WriteString(name)
+ w.WriteRaw(`!</div>`)
+}
+```
+
+Looks good? Let's write server code.
+
+```
+package main
+
+import (
+ "log"
+ "net/http"
+
+ "go.lair.cx/tpls"
+
+ "package/templates"
+)
+
+func main() {
+ log.Fatalln(http.ListenAndServe(":8080", http.HandlerFunc(handler)))
+}
+
+func handler(w http.ResponseWriter, r *http.Request) {
+ tplw := tpls.NewWriter(nil)
+ templates.Render(tplw, "World")
+ w.Header().Set("Content-Type", "text/html;charset=utf-8")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(tplw.Bytes())
+}
+```
+
+Save this file as `./main.go`, `go run .`, open `localhost:8080`. The browser
+will print `Hello, World!`.
+
+For more examples, See `examples/` directory.
+
+## Built-in Tags
+
+`tpls` has many built-in tags. Built-in tags are use class name to define name,
+condition, etc.
+
+* `template` produces template function. all child elements are rendered in
+ scope. In other words, no UI element can be rendered in outside of scope.
+* `module` defines new tag. For example, the module defined with
+ `<module class="card">` is can be used as `<card>`.
+ * `b *tpls.Builder, w *tpls.Writer, t *htmlx.Tokenizer` is provided in
+ the scope.
+ * `<body>` tag is provided in this scope.
+* `integer` prints integer number.
+* `float` prints floating number. `<float class="n">` for precision.
+* `string` prints HTML-safe string.
+ * `<string class="unsafe">` produces unsafe string.
+ * `<string class="bytes">` can handle `[]byte` type.
+* `if` represents Go's `if`.
+ * `<if class="cond"><then> T1 </then><else> T2 </else></if>`
+ * When `cond` is true, `T1` is executed, otherwise, `T2` is executed.
+ * `else` tag can be used continuously to represent else-if, like
+ `<else class="cond"> T2 </else><else> T3 </else>`.
+ * `<else>` with class name means else-if.
+* `for` represents Go's `for`.
+ * `<for class="_, i := range V"> T1 </for>`
+ * `T1` executed `len(V)` times. variable `i` can be used in the scope,
+ just same as Go.
+* `import` produces `import ( ... )`.
+* `interface` and `struct` defines type.
+* `raw` executes it's code at runtime.
+* `title` produces `<title>` tag. It acts the same as the `<string>` tag except
+ that the result is wrapped with a plain html's `<title>` tag.
+ * You can use Go string with this tag for represent plain text.
+ For example: `<title>"Hello, World!"</title>`
diff --git a/cmd/tplc/main.go b/cmd/tplc/main.go
@@ -0,0 +1,9 @@
+package main
+
+import (
+ "go.lair.cx/tpls/tplc"
+)
+
+func main() {
+ tplc.New().UseBuiltin().Run()
+}
diff --git a/examples/basic/main.go b/examples/basic/main.go
@@ -0,0 +1,24 @@
+package main
+
+import (
+ "log"
+ "net/http"
+
+ "go.lair.cx/tpls"
+ "go.lair.cx/tpls/examples/basic/templates"
+)
+
+func main() {
+ log.Fatalln(http.ListenAndServe(":8080", http.HandlerFunc(handler)))
+}
+
+func handler(w http.ResponseWriter, r *http.Request) {
+ tplw := tpls.NewWriter(nil)
+ templates.Render(tplw, &templates.MainPage{
+ Name: "World",
+ Cond: 1,
+ })
+ w.Header().Set("Content-Type", "text/html;charset=utf-8")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(tplw.Bytes())
+}
diff --git a/examples/basic/templates/_card.html b/examples/basic/templates/_card.html
@@ -0,0 +1,19 @@
+<!--suppress HtmlUnknownAttribute, HtmlUnknownTag -->
+
+<module class="card">
+ <div class="card" style="
+ display: block;
+ width: 100%;
+ max-width: 50rem;
+ padding: 1rem;
+ border: 1px solid rgba(0, 0, 0, 0.14);
+ margin: 1rem auto;
+ border-radius: 2px;
+ ">
+ <body>
+ <p>
+ Card with no content
+ </p>
+ </body>
+ </div>
+</module>
diff --git a/examples/basic/templates/_card.html.go b/examples/basic/templates/_card.html.go
@@ -0,0 +1,5 @@
+// Code generated by tplc. DO NOT EDIT.
+
+package templates
+
+import "go.lair.cx/tpls"
diff --git a/examples/basic/templates/template.html b/examples/basic/templates/template.html
@@ -0,0 +1,77 @@
+<!--suppress HtmlUnknownAttribute, HtmlUnknownTag -->
+
+<import>
+ "path"
+</import>
+
+<interface class="Page">
+ Title(w tpls.Writer)
+ Body(w tpls.Writer)
+</interface>
+
+<template class="Render(p Page)">
+ <!doctype html>
+ <html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <meta name="viewport"
+ content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
+ <meta http-equiv="X-UA-Compatible" content="ie=edge">
+
+ <title>
+ p.Title(w)
+ </title>
+ </head>
+ <body>
+ <raw>p.Body(w)</raw>
+ </body>
+ </html>
+</template>
+
+<struct class="MainPage">
+ Name string
+ Cond int
+</struct>
+
+<template class="(*MainPage) Title()">
+ Main Page
+</template>
+
+<template class="(p *MainPage) Body()">
+ <card>Custom Card!</card>
+ <card></card>
+ <p>
+ Strings:<br>
+ Hello, <string>"World"</string>!<br>
+ <string class="unsafe">
+ `<a href="http://example.com">Unsafe String</a>`</string><br>
+ <string class="bytes">[]byte{72, 101, 108, 108, 111, 44, 32, 87, 111, 114, 108, 100, 33}</string><br>
+ <string>path.Join("/path", "./to/file")</string>
+ </p>
+ <p>
+ Numbers:<br>
+ <integer>123</integer><br>
+ <float>123.45</float><br>
+ <float class="1">123.45</float>
+ </p>
+ <p>
+ If:<br>
+ <if class="p.Cond == 1">
+ <then>
+ p.Cond is 1
+ </then>
+ <else class="p.Cond == 2">
+ p.Cond is 2
+ </else>
+ <else>
+ else.
+ </else>
+ </if>
+ </p>
+ <p>
+ Loop:<br>
+ <for class="_, s := range []string{ `a`, `b`, `c` }">
+ <string>s</string><br>
+ </for>
+ </p>
+</template>
diff --git a/examples/basic/templates/template.html.go b/examples/basic/templates/template.html.go
@@ -0,0 +1,87 @@
+// Code generated by tplc. DO NOT EDIT.
+
+package templates
+
+import "go.lair.cx/tpls"
+
+import (
+ "path"
+)
+
+type Page interface {
+ Title(w tpls.Writer)
+ Body(w tpls.Writer)
+}
+
+func Render(w tpls.Writer, p Page) {
+ w.WriteRaw(`<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport"
+ content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"><meta http-equiv="X-UA-Compatible" content="ie=edge">`)
+ w.WriteRaw(`<title>`)
+ p.Title(w)
+ w.WriteRaw(`</title>`)
+ w.WriteRaw(`</head><body>`)
+ p.Body(w)
+ w.WriteRaw(`</body></html>`)
+}
+
+type MainPage struct {
+ Name string
+ Cond int
+}
+
+func (*MainPage) Title(w tpls.Writer) {
+ w.WriteRaw(` Main Page `)
+}
+
+func (p *MainPage) Body(w tpls.Writer) {
+ w.WriteRaw(`<div class="card" style="
+ display: block;
+ width: 100%;
+ max-width: 50rem;
+ padding: 1rem;
+ border: 1px solid rgba(0, 0, 0, 0.14);
+ margin: 1rem auto;
+ border-radius: 2px;
+ ">`)
+ w.WriteRaw(`Custom Card!`)
+ w.WriteRaw(`</div>`)
+ w.WriteRaw(`<div class="card" style="
+ display: block;
+ width: 100%;
+ max-width: 50rem;
+ padding: 1rem;
+ border: 1px solid rgba(0, 0, 0, 0.14);
+ margin: 1rem auto;
+ border-radius: 2px;
+ ">`)
+ w.WriteRaw(`<p> Card with no content </p>`)
+ w.WriteRaw(`</div>`)
+ w.WriteRaw(`<p> Strings:<br> Hello, `)
+ w.WriteString("World")
+ w.WriteRaw(`!<br>`)
+ w.WriteRaw(`<a href="http://example.com">Unsafe String</a>`)
+ w.WriteRaw(`<br>`)
+ w.WriteByteString([]byte{72, 101, 108, 108, 111, 44, 32, 87, 111, 114, 108, 100, 33})
+ w.WriteRaw(`<br>`)
+ w.WriteString(path.Join("/path", "./to/file"))
+ w.WriteRaw(`</p><p> Numbers:<br>`)
+ w.WriteInteger(123)
+ w.WriteRaw(`<br>`)
+ w.WriteFloat(123.45, -1)
+ w.WriteRaw(`<br>`)
+ w.WriteFloat(123.45, 1)
+ w.WriteRaw(`</p><p> If:<br>`)
+ if p.Cond == 1 {
+ w.WriteRaw(` p.Cond is 1 `)
+ } else if p.Cond == 2 {
+ w.WriteRaw(` p.Cond is 2 `)
+ } else {
+ w.WriteRaw(` else. `)
+ }
+ w.WriteRaw(`</p><p> Loop:<br>`)
+ for _, s := range []string{`a`, `b`, `c`} {
+ w.WriteString(s)
+ w.WriteRaw(`<br>`)
+ }
+ w.WriteRaw(`</p>`)
+}
diff --git a/go.mod b/go.mod
@@ -0,0 +1,13 @@
+module go.lair.cx/tpls
+
+go 1.17
+
+require (
+ github.com/pkg/errors v0.9.1
+ github.com/valyala/bytebufferpool v1.0.0
+ go.lair.cx/go-core v0.0.0-20220120181239-fe959b43869a
+)
+
+require golang.org/x/net v0.0.0-20220121210141-e204ce36a2ba // indirect
+
+replace go.lair.cx/go-core => ../../projects/go-core
diff --git a/go.sum b/go.sum
@@ -0,0 +1,111 @@
+github.com/andybalholm/brotli v1.0.2/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
+github.com/andybalholm/brotli v1.0.3/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
+github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
+github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
+github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
+github.com/fasthttp/router v1.4.1/go.mod h1:4P0Kq4C882tA2evBKDW7De7hGfWmvV8FN+zqt8Lu49Q=
+github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
+github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
+github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
+github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
+github.com/klauspost/compress v1.12.2/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
+github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
+github.com/klauspost/cpuid v1.2.3/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
+github.com/klauspost/cpuid v1.3.1/go.mod h1:bYW4mA6ZgKPob1/Dlai2LviZJO7KGI3uoWLd42rAQw4=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/minio/md5-simd v1.1.0/go.mod h1:XpBqgZULrMYD3R+M28PcmP0CkI7PEMzB3U77ZrKZ0Gw=
+github.com/minio/minio-go/v7 v7.0.14/go.mod h1:S23iSP5/gbMwtxeY5FM71R+TkAYyzEdoNEDDwpt8yWs=
+github.com/minio/sha256-simd v0.1.1/go.mod h1:B5e1o+1/KgNmWrSQK08Y6Z1Vb5pwIktudl0J58iy0KM=
+github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
+github.com/savsgio/gotils v0.0.0-20210617111740-97865ed5a873/go.mod h1:dmPawKuiAeG/aFYVs2i+Dyosoo7FNcm+Pi8iK6ZUrX8=
+github.com/segmentio/fasthash v1.0.3/go.mod h1:waKX8l2N8yckOgmSsXJi7x1ZfdKZ4x7KRMzBtS3oedY=
+github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
+github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
+github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/ua-parser/uap-go v0.0.0-20210824134941-3b2ceb1c75a3/go.mod h1:OBcG9bn7sHtXgarhUEb3OfCnNsgtGnkVf41ilSZ3K3E=
+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.28.0/go.mod h1:cmWIqlu99AO/RKcp1HWaViTqc57FswJOfYYdPJBl8BA=
+github.com/valyala/fasthttp v1.29.0/go.mod h1:2rsYD01CKFrjjsvFxx75KlEUNpWNBY9JWD3K/7o2Cus=
+github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
+github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec/go.mod h1:owBmyHYMLkxyrugmfwE/DLJyW8Ro9mkphwuVErQ0iUw=
+go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
+go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
+go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20201216223049-8b5274cf687f/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
+golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
+golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
+golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20220121210141-e204ce36a2ba h1:6u6sik+bn/y7vILcYkK3iwTBWN7WtBvB0+SZswQnbf8=
+golang.org/x/net v0.0.0-20220121210141-e204ce36a2ba/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210820121016-41cdb8703e55/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/ini.v1 v1.57.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/tplc/builder.go b/tplc/builder.go
@@ -0,0 +1,124 @@
+package tplc
+
+import (
+ "go/format"
+ "io"
+ "log"
+ "os"
+
+ "github.com/pkg/errors"
+ "github.com/valyala/bytebufferpool"
+
+ "go.lair.cx/go-core/net/htmlx"
+)
+
+type TagHandler func(b *Builder, w *Writer, t *htmlx.Tokenizer) error
+
+type Builder struct {
+ tags map[string]TagHandler
+}
+
+func New() *Builder {
+ return &Builder{
+ tags: make(map[string]TagHandler),
+ }
+}
+
+// UseBuiltin enables built-in tags. if replaceStandard is true, it replaces
+// standard tag, such as title.
+func (b *Builder) UseBuiltin() *Builder {
+ b.AddTag("float", TagFloat)
+ b.AddTag("for", TagFor)
+ b.AddTag("if", TagIf)
+ b.AddTag("import", TagImport)
+ b.AddTag("integer", TagInteger)
+ b.AddTag("interface", TagInterface)
+ b.AddTag("module", TagModule)
+ b.AddTag("raw", TagRaw)
+ b.AddTag("string", TagString)
+ b.AddTag("struct", TagStruct)
+ b.AddTag("template", TagTemplate)
+ b.AddTag("title", TagTitle)
+
+ return b
+}
+
+func (b *Builder) AddTag(name string, tag TagHandler) *Builder {
+ b.tags[name] = tag
+ return b
+}
+
+func (b *Builder) Handler(name string) TagHandler {
+ handler, _ := b.tags[name]
+ return handler
+}
+
+func (b *Builder) render(r io.Reader, buf []byte) ([]byte, error) {
+ var w = &Writer{buf: buf, builder: b}
+
+ var (
+ t = htmlx.NewTokenizer(r)
+ err error
+ )
+
+ for kind := t.Next(); kind != htmlx.ErrorToken; kind = t.Next() {
+ if kind != htmlx.StartTagToken {
+ continue
+ }
+
+ tagName, _ := t.TagName()
+
+ handler, ok := w.builder.tags[string(tagName)]
+ if !ok {
+ handler = TagFallback(tagName)
+ }
+
+ err = handler(b, w, t)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ err = t.Err()
+ if err != nil && err != io.EOF {
+ return nil, err
+ }
+
+ return w.Bytes(), nil
+}
+
+func (b *Builder) build(src, dst string, pkg string) error {
+ fp, err := os.Open(src)
+ if err != nil {
+ return errors.Wrap(err, "file open failed")
+ }
+ defer func(fp *os.File) {
+ _ = fp.Close()
+ }(fp)
+
+ buf := bytebufferpool.Get()
+ defer bytebufferpool.Put(buf)
+
+ // Package name
+ buf.B = append(buf.B, "// Code generated by tplc. DO NOT EDIT.\n\npackage "...)
+ buf.B = append(buf.B, pkg...)
+ buf.B = append(buf.B, "\n\nimport \"go.lair.cx/tpls\"\n\n"...)
+
+ buf.B, err = b.render(fp, buf.B)
+ if err != nil {
+ return errors.Wrap(err, "render error")
+ }
+
+ prettyCode, err := format.Source(buf.B)
+ if err != nil {
+ log.Println(string(buf.B))
+ return errors.Wrap(err, "format failed")
+ }
+
+ err = os.WriteFile(dst, prettyCode, 0644)
+ if err != nil {
+ return errors.Wrap(err, "write error")
+ }
+
+ return nil
+}
diff --git a/tplc/builtin_fallback.go b/tplc/builtin_fallback.go
@@ -0,0 +1,63 @@
+package tplc
+
+import (
+ "bytes"
+ "io"
+
+ "github.com/valyala/bytebufferpool"
+
+ "go.lair.cx/go-core/net/htmlx"
+)
+
+func TagFallback(tagName []byte) TagHandler {
+ return func(_ *Builder, w *Writer, t *htmlx.Tokenizer) error {
+ stackBuf := bytebufferpool.Get()
+ defer bytebufferpool.Put(stackBuf)
+
+ stackBuf.B = append(stackBuf.B, tagName...)
+
+ var stack = [][]byte{
+ stackBuf.B[:len(tagName)],
+ }
+
+ loop:
+ for {
+ switch t.Next() {
+ case htmlx.StartTagToken:
+ tagName, _ = t.TagName()
+
+ // put stack
+ stackBuf.B = append(stackBuf.B, tagName...)
+ stack = append(stack, stackBuf.B[len(stackBuf.B)-len(tagName):])
+
+ case htmlx.EndTagToken:
+ tagName, _ = t.TagName()
+
+ // search the stack. ignore unmatched tags.
+ for i := len(stack) - 1; i >= 0; i-- {
+ var item []byte
+ item, stack = stack[i], stack[:i]
+ stackBuf.B = stackBuf.B[:len(stackBuf.B)-len(item)]
+
+ if bytes.Equal(item, tagName) {
+ break
+ }
+ }
+
+ if len(stack) == 0 {
+ break loop
+ }
+
+ case htmlx.ErrorToken:
+ err := t.Err()
+ if err == io.EOF {
+ return io.ErrUnexpectedEOF
+ } else {
+ return err
+ }
+ }
+ }
+
+ return nil
+ }
+}
diff --git a/tplc/builtin_for.go b/tplc/builtin_for.go
@@ -0,0 +1,27 @@
+package tplc
+
+import (
+ "strings"
+
+ "github.com/pkg/errors"
+
+ "go.lair.cx/go-core/net/htmlx"
+)
+
+func TagFor(b *Builder, w *Writer, t *htmlx.Tokenizer) error {
+ attrs := getAttrs(t)
+ className, ok := attrs["class"]
+ if !ok {
+ return errors.New("class is required for 'for' tag")
+ }
+
+ w.WriteGoCode("for ", strings.TrimSpace(className), " {\n")
+
+ err := RenderTag(b, w, t, "for")
+ if err != nil {
+ return err
+ }
+
+ w.WriteGoCode("}\n")
+ return nil
+}
diff --git a/tplc/builtin_if.go b/tplc/builtin_if.go
@@ -0,0 +1,56 @@
+package tplc
+
+import (
+ "strings"
+
+ "github.com/pkg/errors"
+
+ "go.lair.cx/go-core/net/htmlx"
+)
+
+func TagIf(b *Builder, w *Writer, t *htmlx.Tokenizer) error {
+ attrs := getAttrs(t)
+ className, ok := attrs["class"]
+ if !ok {
+ return errors.New("class is required for if tag")
+ }
+
+ w.WriteGoCode("if ", strings.TrimSpace(className), " {\n")
+
+ err := RenderTagWithCustomTags(b, w, t, "if", map[string]TagHandler{
+ "then": handleThen,
+ "else": handleElse,
+ }, true)
+ if err != nil {
+ return err
+ }
+
+ w.WriteGoCode("}\n")
+ return nil
+}
+
+func handleThen(b *Builder, w *Writer, t *htmlx.Tokenizer) error {
+ err := RenderTag(b, w, t, "then")
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func handleElse(b *Builder, w *Writer, t *htmlx.Tokenizer) error {
+ attrs := getAttrs(t)
+ className, _ := attrs["class"]
+
+ w.WriteGoCode("} else")
+ if len(className) > 0 {
+ w.WriteGoCode(" if ", strings.TrimSpace(className))
+ }
+ w.WriteGoCode(" {\n")
+
+ err := RenderTag(b, w, t, "else")
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
diff --git a/tplc/builtin_import.go b/tplc/builtin_import.go
@@ -0,0 +1,28 @@
+package tplc
+
+import (
+ "fmt"
+
+ "go.lair.cx/go-core/net/htmlx"
+)
+
+func TagImport(_ *Builder, w *Writer, t *htmlx.Tokenizer) error {
+ w.WriteGoCode("import (\n")
+
+ t.SetRawTag("import")
+ if t.Next() != htmlx.TextToken {
+ return fmt.Errorf("invalid import tag syntax")
+ }
+
+ w.WriteGoCodeBytes(t.Text())
+
+ if t.Next() != htmlx.EndTagToken {
+ return fmt.Errorf("invalid import tag syntax")
+ }
+ if tagName, _ := t.TagName(); string(tagName) != "import" {
+ return fmt.Errorf("invalid import tag syntax")
+ }
+
+ w.WriteGoCode(")\n\n")
+ return nil
+}
diff --git a/tplc/builtin_module.go b/tplc/builtin_module.go
@@ -0,0 +1,94 @@
+package tplc
+
+import (
+ "bytes"
+ "fmt"
+
+ "github.com/pkg/errors"
+
+ "go.lair.cx/go-core/net/htmlx"
+)
+
+func TagModule(b *Builder, _ *Writer, t *htmlx.Tokenizer) error {
+ attrs := getAttrs(t)
+ className, ok := attrs["class"]
+ if !ok {
+ return errors.New("class name is required for module tag")
+ }
+
+ var moduleContent []byte
+
+ t.SetRawTag("module")
+
+loop:
+ for {
+ switch t.Next() {
+ case htmlx.TextToken:
+ content := t.Text()
+ moduleContent = make([]byte, len(content))
+ copy(moduleContent, content)
+
+ case htmlx.EndTagToken:
+ if err := checkTagName(t, "module"); err != nil {
+ return err
+ }
+ break loop
+
+ default:
+ return errors.New("invalid module tag syntax")
+ }
+ }
+
+ b.AddTag(className, func(b *Builder, w *Writer, t *htmlx.Tokenizer) error {
+ t.SetRawTag(className)
+
+ var moduleBody []byte
+
+ loop:
+ for {
+ kind := t.Next()
+ switch kind {
+ case htmlx.TextToken:
+ content := t.Text()
+ moduleBody = make([]byte, len(content))
+ copy(moduleBody, content)
+
+ case htmlx.EndTagToken:
+ if err := checkTagName(t, className); err != nil {
+ return err
+ }
+ break loop
+
+ default:
+ return fmt.Errorf(
+ "invalid module tag syntax: unexpected token: %s",
+ kind.String(),
+ )
+ }
+ }
+
+ handleBodyTag := func(b *Builder, w *Writer, t *htmlx.Tokenizer) error {
+ if len(moduleBody) > 0 {
+ err := TagFallback([]byte("body"))(b, w, t)
+ if err != nil {
+ return err
+ }
+ mbt := htmlx.NewTokenizer(bytes.NewReader(moduleBody))
+ return RenderTag(b, w, mbt, "")
+ } else {
+ return RenderTag(b, w, t, "body")
+ }
+ }
+
+ mct := htmlx.NewTokenizer(bytes.NewReader(moduleContent))
+ return RenderTagWithCustomTags(
+ b, w, mct, "",
+ map[string]TagHandler{
+ "body": handleBodyTag,
+ },
+ false,
+ )
+ })
+
+ return nil
+}
diff --git a/tplc/builtin_resources.go b/tplc/builtin_resources.go
@@ -0,0 +1,80 @@
+package tplc
+
+import (
+ "bytes"
+ "fmt"
+
+ "go.lair.cx/go-core/net/htmlx"
+)
+
+func TagString(_ *Builder, w *Writer, t *htmlx.Tokenizer) error {
+ attrs := getAttrs(t)
+ className, _ := attrs["class"]
+ return handleResourceTag(t, "string", func(body []byte) {
+ switch className {
+ case "unsafe":
+ w.WriteRawBytes(body)
+
+ case "bytes":
+ w.WriteByteStringBytes(body)
+
+ default:
+ w.WriteStringBytes(body)
+ }
+ })
+}
+
+func TagInteger(_ *Builder, w *Writer, t *htmlx.Tokenizer) error {
+ return handleResourceTag(t, "integer", func(body []byte) {
+ w.WriteIntegerBytes(body)
+ })
+}
+
+func TagFloat(_ *Builder, w *Writer, t *htmlx.Tokenizer) error {
+ attrs := getByteAttrs(t)
+ className, _ := attrs["class"]
+
+ return handleResourceTag(t, "float", func(body []byte) {
+ w.WriteFloatBytes(body, className)
+ })
+}
+
+func TagRaw(_ *Builder, w *Writer, t *htmlx.Tokenizer) error {
+ return handleResourceTag(
+ t,
+ "raw",
+ func(body []byte) {
+ w.WriteGoCodeBytes(body, []byte{'\n'})
+ },
+ )
+}
+
+func TagTitle(_ *Builder, w *Writer, t *htmlx.Tokenizer) error {
+ return handleResourceTag(
+ t,
+ "title",
+ func(body []byte) {
+ w.WriteRawWithBacktick("<title>")
+ w.WriteGoCodeBytes(body, []byte{'\n'})
+ w.WriteRawWithBacktick("</title>")
+ },
+ )
+}
+
+func handleResourceTag(t *htmlx.Tokenizer, name string, handler func([]byte)) error {
+ t.SetRawTag(name)
+
+ if t.Next() != htmlx.TextToken {
+ return fmt.Errorf("invalid %s syntax: unexpected text token", name)
+ }
+
+ handler(bytes.TrimSpace(t.Text()))
+
+ if t.Next() != htmlx.EndTagToken {
+ return fmt.Errorf("invalid %s syntax", name)
+ }
+ if tagName, _ := t.TagName(); string(tagName) != name {
+ return fmt.Errorf("invalid %s syntax", name)
+ }
+ return nil
+}
diff --git a/tplc/builtin_template.go b/tplc/builtin_template.go
@@ -0,0 +1,195 @@
+package tplc
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "regexp"
+ "strconv"
+ "unicode"
+ "unicode/utf8"
+
+ "github.com/pkg/errors"
+ "github.com/valyala/bytebufferpool"
+
+ "go.lair.cx/go-core/net/htmlx"
+)
+
+var regexTemplateClassParser = regexp.MustCompile(
+ `^\s*(\((?:[^\s)]+[ \t]+)?[^\s)]+\)[ \t]+)?([^\s(]+)\(([^)]+)?\)\s*$`)
+
+func TagTemplate(b *Builder, w *Writer, t *htmlx.Tokenizer) error {
+ attrs := getAttrs(t)
+ className, ok := attrs["class"]
+ if !ok {
+ return errors.New("class is required for struct tag")
+ }
+ caps := regexTemplateClassParser.FindStringSubmatch(className)
+ if caps == nil {
+ return fmt.Errorf("invalid template name: %s", strconv.Quote(className))
+ }
+
+ w.WriteGoCode("func ", caps[1], caps[2], "(w tpls.Writer,", caps[3], ") {\n")
+
+ err := RenderTag(b, w, t, "template")
+ if err != nil {
+ return err
+ }
+
+ w.WriteGoCode("}\n\n")
+ return nil
+}
+
+func RenderTag(
+ b *Builder,
+ w *Writer,
+ t *htmlx.Tokenizer,
+ name string,
+) error {
+ return renderTag(b, w, t, name, nil, false)
+}
+
+func RenderTagWithCustomTags(
+ b *Builder,
+ w *Writer,
+ t *htmlx.Tokenizer,
+ name string,
+ customHandlers map[string]TagHandler,
+ useCustomOnly bool,
+) error {
+ return renderTag(b, w, t, name, customHandlers, useCustomOnly)
+}
+
+func renderTag(
+ b *Builder,
+ w *Writer,
+ t *htmlx.Tokenizer,
+ name string,
+ customHandlers map[string]TagHandler,
+ useCustomOnly bool,
+) error {
+ buf := bytebufferpool.Get()
+ defer bytebufferpool.Put(buf)
+
+ flushBuffer := func() {
+ if len(buf.B) > 0 {
+ w.WriteRawBytesWithBacktick(buf.B)
+ buf.B = buf.B[:0]
+ }
+ }
+
+ var stack [][]byte
+
+loop:
+ for {
+ switch t.Next() {
+ case htmlx.StartTagToken:
+ tagName, _ := t.TagName()
+ handler := b.Handler(string(tagName))
+
+ if handler == nil {
+ handler, _ = customHandlers[string(tagName)]
+ }
+ if handler == nil {
+ if useCustomOnly {
+ return fmt.Errorf("unexpected tag: %s", tagName)
+ }
+ buf.B = append(buf.B, t.Raw()...)
+ stack = append(stack, tagName)
+ } else {
+ flushBuffer()
+
+ err := handler(b, w, t)
+ if err != nil {
+ return err
+ }
+ }
+
+ case htmlx.EndTagToken:
+ tagName, _ := t.TagName()
+
+ if len(stack) == 0 {
+ if string(tagName) != name {
+ return errors.New("unexpected end tag: " + string(tagName))
+ }
+ break loop
+ }
+
+ for i := len(stack) - 1; i >= 0; i-- {
+ var item []byte
+ item, stack = stack[i], stack[:i]
+
+ if bytes.Equal(item, tagName) {
+ break
+ }
+ }
+
+ if len(stack) == 0 && string(tagName) == name {
+ break loop
+ }
+
+ buf.B = append(buf.B, t.Raw()...)
+
+ case htmlx.TextToken:
+ var (
+ txt = t.Text()
+ l, r int
+ )
+
+ // Reduce spaces.
+ // Skip this token if there are only space characters. Replace the
+ // spaces at the beginning and end to ' '.
+ // Through this, the 'weird padding' between tags which is useless
+ // in most cases is removed, the <if> tag works properly and save
+ // few bytes while keep a gap between texts.
+
+ for l < len(txt) {
+ c, width := utf8.DecodeRune(txt[l:])
+ if !unicode.IsSpace(c) {
+ break
+ }
+ l += width
+ }
+
+ if l == len(txt) {
+ continue
+ }
+
+ for r = len(txt); r > 0; {
+ c, width := utf8.DecodeLastRune(txt[:r])
+ if !unicode.IsSpace(c) {
+ break
+ }
+ r -= width
+ }
+
+ if l != 0 {
+ buf.B = append(buf.B, ' ')
+ }
+
+ buf.B = append(buf.B, txt[l:r]...)
+
+ if r != len(txt) {
+ buf.B = append(buf.B, ' ')
+ }
+
+ case htmlx.ErrorToken:
+ err := t.Err()
+ if err == io.EOF {
+ if len(stack) == 0 && len(name) == 0 {
+ break loop
+ }
+ return io.ErrUnexpectedEOF
+ } else {
+ return err
+ }
+
+ default:
+ buf.B = append(buf.B, t.Raw()...)
+ }
+ }
+
+ flushBuffer()
+
+ return nil
+}
diff --git a/tplc/builtin_typedef.go b/tplc/builtin_typedef.go
@@ -0,0 +1,45 @@
+package tplc
+
+import (
+ "fmt"
+
+ "github.com/pkg/errors"
+
+ "go.lair.cx/go-core/net/htmlx"
+)
+
+func TagStruct(_ *Builder, w *Writer, t *htmlx.Tokenizer) error {
+ return handleTypeTag(w, t, "struct")
+}
+
+func TagInterface(_ *Builder, w *Writer, t *htmlx.Tokenizer) error {
+ return handleTypeTag(w, t, "interface")
+}
+
+func handleTypeTag(w *Writer, t *htmlx.Tokenizer, name string) error {
+ attrs := getAttrs(t)
+ className, ok := attrs["class"]
+ if !ok {
+ return errors.New("class is required for struct tag")
+ }
+
+ w.WriteGoCode("type ", className, " ", name, " {")
+
+ t.SetRawTag(name)
+ if t.Next() != htmlx.TextToken {
+ return fmt.Errorf("invalid %s tag syntax", name)
+ }
+
+ w.WriteGoCodeBytes(t.Text())
+
+ if t.Next() != htmlx.EndTagToken {
+ return fmt.Errorf("invalid %s syntax", name)
+ }
+ if tagName, _ := t.TagName(); string(tagName) != name {
+ return fmt.Errorf("invalid %s syntax", name)
+ }
+
+ w.WriteGoCode("}\n\n")
+
+ return nil
+}
diff --git a/tplc/cli.go b/tplc/cli.go
@@ -0,0 +1,77 @@
+package tplc
+
+import (
+ "flag"
+ "log"
+ "os"
+ "path"
+)
+
+var (
+ flagDir string
+ flagDst string
+ flagExt string
+ flagPkg string
+)
+
+func init() {
+ flag.StringVar(
+ &flagDir, "dir", ".",
+ "Path to source directory.\nThe compiler does not process files in subdirectories.")
+ flag.StringVar(
+ &flagDst, "dst", "",
+ "Path to distribution directory.")
+ flag.StringVar(
+ &flagExt, "ext", ".html",
+ "Extension name with leading dot.\nOnly files with this extension are compiled.")
+ flag.StringVar(
+ &flagPkg, "pkg", "",
+ "Package name. For default, name of dist directory is used.",
+ )
+}
+
+func (b *Builder) Run() {
+ flag.Parse()
+
+ if len(flagDst) == 0 {
+ flagDst = flagDir
+ }
+
+ if len(flagPkg) == 0 {
+ pkg, err := getPackageName(flagDst)
+ if err != nil {
+ log.Fatalf("cannot get package name: %s\n", err.Error())
+ }
+ flagPkg = pkg
+ }
+
+ files, err := os.ReadDir(flagDir)
+ if err != nil {
+ log.Fatalf("readdir failed: %s", err.Error())
+ }
+
+ for _, file := range files {
+ if file.IsDir() {
+ continue
+ }
+
+ var (
+ src = path.Join(flagDir, file.Name())
+ dst = path.Join(flagDst, file.Name()+".go")
+ )
+
+ if path.Ext(src) != flagExt {
+ continue
+ }
+
+ log.Printf(
+ "Build: %s => %s",
+ src, dst,
+ )
+
+ err = b.build(src, dst, flagPkg)
+ if err != nil {
+ log.Fatalf("Build failed: %s\n", err.Error())
+ }
+ }
+}
diff --git a/tplc/utils.go b/tplc/utils.go
@@ -0,0 +1,124 @@
+package tplc
+
+import (
+ "fmt"
+ "path"
+ "path/filepath"
+ "unicode/utf8"
+
+ "github.com/pkg/errors"
+
+ "go.lair.cx/go-core/net/htmlx"
+)
+
+func getPackageName(dirName string) (string, error) {
+ absPath, err := filepath.Abs(dirName)
+ if err != nil {
+ return "", err
+ }
+
+ if absPath == "/" {
+ return "", errors.New("root directory is not allowed")
+ }
+
+ _, pkg := path.Split(absPath)
+ return pkg, nil
+}
+
+func getAttrs(t *htmlx.Tokenizer) map[string]string {
+ out := make(map[string]string)
+
+ for {
+ key, val, hasMore := t.TagAttr()
+ if key == nil {
+ break
+ }
+
+ out[string(key)] = string(val)
+
+ if !hasMore {
+ break
+ }
+ }
+
+ return out
+}
+
+func getByteAttrs(t *htmlx.Tokenizer) map[string][]byte {
+ out := make(map[string][]byte)
+
+ for {
+ key, val, hasMore := t.TagAttr()
+ if key == nil {
+ break
+ }
+
+ out[string(key)] = val
+
+ if !hasMore {
+ break
+ }
+ }
+
+ return out
+}
+
+func appendBacktickString(buf []byte, s string) ([]byte, error) {
+ if cap(buf)-len(buf) < len(s)+2 {
+ nbuf := make([]byte, len(buf), len(buf)+len(s)+2)
+ copy(nbuf, buf)
+ buf = nbuf
+ }
+ buf = append(buf, '`')
+ for width := 0; len(s) > 0; s = s[width:] {
+ var r rune
+ r, width = utf8.DecodeRuneInString(s)
+ if r == '\ufeff' {
+ return nil, errors.New("UTF-8 with BOM can not be encoded")
+ } else if r == utf8.RuneError {
+ return nil, errors.New("invalid utf-8 string")
+ } else if r < ' ' && r != '\t' || r == '\u007F' {
+ return nil, errors.New("control character can not be encoded")
+ } else if r == '`' {
+ buf = append(buf, "`+\"`\"+`"...)
+ } else {
+ buf = append(buf, s[:width]...)
+ }
+ }
+ buf = append(buf, '`')
+ return buf, nil
+}
+
+func appendBacktickByteString(buf []byte, s []byte) ([]byte, error) {
+ if cap(buf)-len(buf) < len(s)+2 {
+ nbuf := make([]byte, len(buf), len(buf)+len(s)+2)
+ copy(nbuf, buf)
+ buf = nbuf
+ }
+ buf = append(buf, '`')
+ for width := 0; len(s) > 0; s = s[width:] {
+ var r rune
+ r, width = utf8.DecodeRune(s)
+ if r == '\ufeff' {
+ return nil, errors.New("UTF-8 with BOM can not be encoded")
+ } else if r == utf8.RuneError {
+ return nil, errors.New("invalid utf-8 string")
+ } else if (r < ' ' && r != '\t' && r != '\r' && r != '\n') || r == '\u007F' {
+ return nil, errors.New("control character can not be encoded")
+ } else if r == '`' {
+ buf = append(buf, "`+\"`\"+`"...)
+ } else {
+ buf = append(buf, s[:width]...)
+ }
+ }
+ buf = append(buf, '`')
+ return buf, nil
+}
+
+func checkTagName(t *htmlx.Tokenizer, name string) error {
+ tagName, _ := t.TagName()
+ if string(tagName) != name {
+ return fmt.Errorf("unexpected closing tag: %s", tagName)
+ }
+ return nil
+}
diff --git a/tplc/writer.go b/tplc/writer.go
@@ -0,0 +1,141 @@
+package tplc
+
+import (
+ "bytes"
+ "log"
+ "strconv"
+ "strings"
+)
+
+type Writer struct {
+ builder *Builder
+ buf []byte
+}
+
+func (w *Writer) Grow(n int) {
+ w.appendMethod(strconv.FormatInt(int64(n), 10), "w.Grow")
+}
+
+func (w *Writer) WriteRaw(s string) {
+ w.appendMethod(s, "w.WriteRaw")
+}
+
+func (w *Writer) WriteRawBytes(s []byte) {
+ w.appendMethodBytes(s, "w.WriteRaw")
+}
+
+func (w *Writer) WriteRawWithBacktick(s string) {
+ w.appendMethodWithBacktick(s, "w.WriteRaw")
+}
+
+func (w *Writer) WriteRawBytesWithBacktick(s []byte) {
+ w.appendMethodBytesWithBacktick(s, "w.WriteRaw")
+}
+
+func (w *Writer) WriteString(s string) {
+ w.appendMethod(s, "w.WriteString")
+}
+
+func (w *Writer) WriteStringBytes(s []byte) {
+ w.appendMethodBytes(s, "w.WriteString")
+}
+
+func (w *Writer) WriteStringWithBacktick(s string) {
+ w.appendMethodWithBacktick(s, "w.WriteString")
+}
+
+func (w *Writer) WriteStringBytesWithBacktick(s []byte) {
+ w.appendMethodBytesWithBacktick(s, "w.WriteString")
+}
+
+func (w *Writer) WriteByteString(s string) {
+ w.appendMethod(s, "w.WriteByteString")
+}
+
+func (w *Writer) WriteByteStringBytes(s []byte) {
+ w.appendMethodBytes(s, "w.WriteByteString")
+}
+
+func (w *Writer) WriteInteger(s string) {
+ w.appendMethod(s, "w.WriteInteger")
+}
+
+func (w *Writer) WriteIntegerBytes(s []byte) {
+ w.appendMethodBytes(s, "w.WriteInteger")
+}
+
+func (w *Writer) WriteFloat(s, precision string) {
+ if len(precision) == 0 {
+ precision = "-1"
+ }
+ w.appendMethod(
+ strings.Join([]string{s, precision}, ", "),
+ "w.WriteFloat",
+ )
+}
+
+func (w *Writer) WriteFloatBytes(s []byte, precision []byte) {
+ if len(precision) == 0 {
+ precision = []byte("-1")
+ }
+ w.appendMethodBytes(
+ bytes.Join([][]byte{s, precision}, []byte{','}),
+ "w.WriteFloat",
+ )
+}
+
+func (w *Writer) appendMethod(s string, method string) {
+ w.buf = append(w.buf, method...)
+ w.buf = append(w.buf, '(')
+ w.buf = append(w.buf, s...)
+ w.buf = append(w.buf, ")\n"...)
+}
+
+func (w *Writer) appendMethodBytes(s []byte, method string) {
+ w.buf = append(w.buf, method...)
+ w.buf = append(w.buf, '(')
+ w.buf = append(w.buf, s...)
+ w.buf = append(w.buf, ")\n"...)
+}
+
+func (w *Writer) appendMethodWithBacktick(s string, method string) {
+ w.buf = append(w.buf, method...)
+ w.buf = append(w.buf, '(')
+ var err error
+ w.buf, err = appendBacktickString(w.buf, s)
+ if err != nil {
+ log.Fatalln(err)
+ }
+ w.buf = append(w.buf, ")\n"...)
+}
+
+func (w *Writer) appendMethodBytesWithBacktick(s []byte, method string) {
+ w.buf = append(w.buf, method...)
+ w.buf = append(w.buf, '(')
+ var err error
+ w.buf, err = appendBacktickByteString(w.buf, s)
+ if err != nil {
+ log.Fatalln(err)
+ }
+ w.buf = append(w.buf, ")\n"...)
+}
+
+func (w *Writer) WriteGoCode(ss ...string) {
+ for _, s := range ss {
+ w.buf = append(w.buf, s...)
+ }
+}
+
+func (w *Writer) WriteGoCodeBytes(zz ...[]byte) {
+ for _, z := range zz {
+ w.buf = append(w.buf, z...)
+ }
+}
+
+func (w *Writer) Bytes() []byte {
+ return w.buf
+}
+
+func (w *Writer) String() string {
+ return string(w.buf)
+}
diff --git a/writer.go b/writer.go
@@ -0,0 +1,73 @@
+package tpls
+
+import (
+ "strconv"
+
+ "go.lair.cx/go-core/net/htmlx"
+)
+
+type Writer interface {
+ Grow(n int)
+ WriteRaw(s string)
+ WriteBytesRaw(z []byte)
+ WriteString(s string)
+ WriteByteString(z []byte)
+ WriteInteger(n int)
+ WriteFloat(n float64, precision int)
+}
+
+func NewWriter(buf []byte) *BufferedWriter {
+ return &BufferedWriter{buf: buf}
+}
+
+type BufferedWriter struct {
+ buf []byte
+}
+
+func (w *BufferedWriter) WriteRaw(s string) {
+ w.grow(len(s))
+ w.buf = append(w.buf, s...)
+}
+
+func (w *BufferedWriter) WriteBytesRaw(z []byte) {
+ w.grow(len(z))
+ w.buf = append(w.buf, z...)
+}
+
+func (w *BufferedWriter) WriteString(s string) {
+ w.WriteRaw(htmlx.EscapeString(s))
+}
+
+func (w *BufferedWriter) WriteByteString(s []byte) {
+ w.WriteBytesRaw(htmlx.Escape(s))
+}
+
+func (w *BufferedWriter) WriteInteger(n int) {
+ formatted := strconv.FormatInt(int64(n), 10)
+ w.WriteRaw(formatted)
+}
+
+func (w *BufferedWriter) WriteFloat(n float64, precision int) {
+ formatted := strconv.FormatFloat(n, 'f', precision, 64)
+ w.WriteRaw(formatted)
+}
+
+func (w *BufferedWriter) Bytes() []byte {
+ return w.buf
+}
+
+func (w *BufferedWriter) Grow(n int) {
+ w.grow(n)
+}
+
+func (w *BufferedWriter) grow(n int) {
+ if cap(w.buf) >= len(w.buf)+n {
+ return
+ }
+
+ buf := make([]byte, len(w.buf), len(w.buf)*2+n)
+ copy(buf, w.buf)
+ w.buf = buf
+}
+
+var _ Writer = (*BufferedWriter)(nil)