tpls

Extendable, Fast Template Engine for Go
git clone git://git.lair.cx/tpls
Log | Files | Refs | README | LICENSE

commit f8840962d93043ed8cd2e5e84d1928cf7bb874a8
Author: Yongbin Kim <iam@yongbin.kim>
Date:   Tue, 25 Jan 2022 03:06:30 +0900

First Commit

Diffstat:
A.gitattributes | 1+
A.gitignore | 190+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ALICENSE | 4++++
AREADME.md | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acmd/tplc/main.go | 9+++++++++
Aexamples/basic/main.go | 24++++++++++++++++++++++++
Aexamples/basic/templates/_card.html | 19+++++++++++++++++++
Aexamples/basic/templates/_card.html.go | 5+++++
Aexamples/basic/templates/template.html | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexamples/basic/templates/template.html.go | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ago.mod | 13+++++++++++++
Ago.sum | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atplc/builder.go | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atplc/builtin_fallback.go | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atplc/builtin_for.go | 27+++++++++++++++++++++++++++
Atplc/builtin_if.go | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atplc/builtin_import.go | 28++++++++++++++++++++++++++++
Atplc/builtin_module.go | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atplc/builtin_resources.go | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atplc/builtin_template.go | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atplc/builtin_typedef.go | 45+++++++++++++++++++++++++++++++++++++++++++++
Atplc/cli.go | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atplc/utils.go | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atplc/writer.go | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awriter.go | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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)