From 85a7b8b19a73ef658220b83740a9cc5071d5b0a1 Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Tue, 21 May 2024 14:27:08 +0100 Subject: [PATCH] feat: add JSONString and JSONScript functions, update docs, refer to templ script as legacy in docs (#745) Co-authored-by: Joe Davidson --- .version | 2 +- README.md | 6 + .../12-script-templates.md | 142 +++++++++++++++++- examples/content-security-policy/main.go | 1 + examples/typescript/components/index.templ | 38 +---- examples/typescript/components/index_templ.go | 40 +---- flake.nix | 7 +- jsonscript.go | 60 ++++++++ jsonscript_test.go | 53 +++++++ jsonstring.go | 14 ++ jsonstring_test.go | 28 ++++ runtime.go | 3 + 12 files changed, 314 insertions(+), 80 deletions(-) create mode 100644 jsonscript.go create mode 100644 jsonscript_test.go create mode 100644 jsonstring.go create mode 100644 jsonstring_test.go diff --git a/.version b/.version index a4e96db55..5629baa03 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -0.2.698 \ No newline at end of file +0.2.700 \ No newline at end of file diff --git a/README.md b/README.md index b888fd54d..62839a00b 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,12 @@ go tool covdata textfmt -i=./coverage/fmt,./coverage/generate,./coverage/version go tool cover -func coverage.out | grep total ``` +### test-cover-watch + +```sh +gotestsum --watch -- -coverprofile=coverage.out +``` + ### benchmark Run benchmarks. diff --git a/docs/docs/03-syntax-and-usage/12-script-templates.md b/docs/docs/03-syntax-and-usage/12-script-templates.md index a3495f4ac..d197a66f6 100644 --- a/docs/docs/03-syntax-and-usage/12-script-templates.md +++ b/docs/docs/03-syntax-and-usage/12-script-templates.md @@ -2,7 +2,7 @@ ## Scripts -Use standard ` +``` + +The data in the script tag can then be accessed from client-side JavaScript. + +```javascript +const data = JSON.parse(document.getElementById('id').textContent); +``` + +## Working with NPM projects + +https://github.com/a-h/templ/tree/main/examples/typescript contains a TypeScript example that uses `esbuild` to transpile TypeScript into plain JavaScript, along with any required `npm` modules. + +After transpilation and bundling, the output JavaScript code can be used in a web page by including a ` + +} +``` + +You will need to configure your Go web server to serve the static content. + +```go title="main.go" +func main() { + mux := http.NewServeMux() + // Serve the JS bundle. + mux.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("assets")))) + + // Serve components. + data := map[string]any{"msg": "Hello, World!"} + h := templ.Handler(components.Page(data)) + mux.Handle("/", h) + + fmt.Println("Listening on http://localhost:8080") + http.ListenAndServe("localhost:8080", mux) +} +``` + ## Script templates +:::warning +Script templates are a legacy feature and are not recommended for new projects. Use standard ``, string(dataJSON)); err != nil { - return err - } - return nil - }) -} - templ Page(attributeData Data, scriptData Data) { @@ -46,8 +12,8 @@ templ Page(attributeData Data, scriptData Data) { - - @JSONScript("scriptData", scriptData) + + @templ.JSONScript("scriptData", scriptData) diff --git a/examples/typescript/components/index_templ.go b/examples/typescript/components/index_templ.go index 4f7b0bba5..70d484833 100644 --- a/examples/typescript/components/index_templ.go +++ b/examples/typescript/components/index_templ.go @@ -9,44 +9,10 @@ import "context" import "io" import "bytes" -import ( - "encoding/json" - "fmt" -) - type Data struct { Message string `json:"msg"` } -func JSON(v any) (string, error) { - s, err := json.Marshal(v) - if err != nil { - return "", err - } - return string(s), nil -} - -func JSONScript(id string, data any) templ.Component { - return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error { - dataJSON, err := json.Marshal(data) - if err != nil { - return err - } - if _, err = io.WriteString(w, `%s`, string(dataJSON)); err != nil { - return err - } - return nil - }) -} - func Page(attributeData Data, scriptData Data) templ.Component { return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) @@ -65,9 +31,9 @@ func Page(attributeData Data, scriptData Data) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var2 string - templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(JSON(attributeData)) + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(templ.JSONString(attributeData)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `examples/typescript/components/index.templ`, Line: 49, Col: 65} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `examples/typescript/components/index.templ`, Line: 15, Col: 77} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -77,7 +43,7 @@ func Page(attributeData Data, scriptData Data) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = JSONScript("scriptData", scriptData).Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = templ.JSONScript("scriptData", scriptData).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/flake.nix b/flake.nix index 87dd59d16..1e9009feb 100644 --- a/flake.nix +++ b/flake.nix @@ -62,14 +62,15 @@ pkgs.mkShell { buildInputs = with pkgs; [ (golangci-lint.override { buildGoModule = buildGo121Module; }) + cosign # Used to sign container images. esbuild # Used to package JS examples. go_1_21 + gomod2nix.legacyPackages.${system}.gomod2nix gopls goreleaser - nodejs # Used to build templ-docs. + gotestsum ko # Used to build Docker images. - cosign # Used to sign container images. - gomod2nix.legacyPackages.${system}.gomod2nix + nodejs # Used to build templ-docs. xc.packages.${system}.xc ]; }); diff --git a/jsonscript.go b/jsonscript.go new file mode 100644 index 000000000..5aa5f3bcc --- /dev/null +++ b/jsonscript.go @@ -0,0 +1,60 @@ +package templ + +import ( + "context" + "encoding/json" + "fmt" + "io" +) + +var _ Component = JSONScriptElement{} + +// JSONScript renders a JSON object inside a script element. +// e.g. +func JSONScript(id string, data any) JSONScriptElement { + return JSONScriptElement{ + ID: id, + Data: data, + Nonce: GetNonce, + } +} + +func (j JSONScriptElement) WithNonceFromString(nonce string) JSONScriptElement { + j.Nonce = func(context.Context) string { + return nonce + } + return j +} + +func (j JSONScriptElement) WithNonceFrom(f func(context.Context) string) JSONScriptElement { + j.Nonce = f + return j +} + +type JSONScriptElement struct { + // ID of the element in the DOM. + ID string + // Data that will be encoded as JSON. + Data any + // Nonce is a function that returns a CSP nonce. + // Defaults to CSPNonceFromContext. + // See https://content-security-policy.com/nonce for more information. + Nonce func(ctx context.Context) string +} + +func (j JSONScriptElement) Render(ctx context.Context, w io.Writer) (err error) { + var nonceAttr string + if nonce := j.Nonce(ctx); nonce != "" { + nonceAttr = fmt.Sprintf(" nonce=\"%s\"", EscapeString(nonce)) + } + if _, err = fmt.Fprintf(w, ""); err != nil { + return err + } + return nil +} diff --git a/jsonscript_test.go b/jsonscript_test.go new file mode 100644 index 000000000..0cc5328e4 --- /dev/null +++ b/jsonscript_test.go @@ -0,0 +1,53 @@ +package templ_test + +import ( + "bytes" + "context" + "testing" + + "github.com/a-h/templ" + "github.com/google/go-cmp/cmp" +) + +func TestJSONScriptElement(t *testing.T) { + data := map[string]interface{}{"foo": "bar"} + tests := []struct { + name string + ctx context.Context + e templ.JSONScriptElement + expected string + }{ + { + name: "renders data as JSON inside a script element", + e: templ.JSONScript("id", data), + expected: "", + }, + { + name: "if a nonce is available in the context, it is used", + ctx: templ.WithNonce(context.Background(), "nonce-from-context"), + e: templ.JSONScript("idc", data), + expected: "", + }, + { + name: "if a nonce is provided, it is used", + e: templ.JSONScript("ids", data).WithNonceFromString("nonce-from-string"), + expected: "", + }, + { + name: "if a nonce function is provided, it is used", + e: templ.JSONScript("idf", data).WithNonceFrom(func(context.Context) string { return "nonce-from-function" }), + expected: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := new(bytes.Buffer) + if err := tt.e.Render(tt.ctx, w); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if diff := cmp.Diff(tt.expected, w.String()); diff != "" { + t.Fatalf("unexpected output (-want +got):\n%s", diff) + } + }) + } +} diff --git a/jsonstring.go b/jsonstring.go new file mode 100644 index 000000000..425e4e8c1 --- /dev/null +++ b/jsonstring.go @@ -0,0 +1,14 @@ +package templ + +import ( + "encoding/json" +) + +// JSONString returns a JSON encoded string of v. +func JSONString(v any) (string, error) { + b, err := json.Marshal(v) + if err != nil { + return "", err + } + return string(b), nil +} diff --git a/jsonstring_test.go b/jsonstring_test.go new file mode 100644 index 000000000..b40c31e4c --- /dev/null +++ b/jsonstring_test.go @@ -0,0 +1,28 @@ +package templ_test + +import ( + "testing" + + "github.com/a-h/templ" +) + +func TestJSONString(t *testing.T) { + t.Run("renders input data as a JSON string", func(t *testing.T) { + data := map[string]any{"foo": "bar"} + actual, err := templ.JSONString(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := "{\"foo\":\"bar\"}" + if actual != expected { + t.Fatalf("unexpected output: want %q, got %q", expected, actual) + } + }) + t.Run("returns an error if the data cannot be marshalled", func(t *testing.T) { + data := make(chan int) + _, err := templ.JSONString(data) + if err == nil { + t.Fatalf("expected an error, got nil") + } + }) +} diff --git a/runtime.go b/runtime.go index b918a3f7f..ef9df246a 100644 --- a/runtime.go +++ b/runtime.go @@ -51,6 +51,9 @@ func WithNonce(ctx context.Context, nonce string) context.Context { // GetNonce returns the CSP nonce value set with WithNonce, or an // empty string if none has been set. func GetNonce(ctx context.Context) (nonce string) { + if ctx == nil { + return "" + } _, v := getContext(ctx) return v.nonce }