From 79be97fb4a9a6d2e323fbd04cbea0d86dca37ec7 Mon Sep 17 00:00:00 2001 From: JP Hastings-Spital Date: Sat, 10 Jun 2023 11:53:55 +0100 Subject: [PATCH 1/4] Proof of concept for Typescript custom functions This is a rough sketch of code that allows users of Hugo to provide custom code for use in templates, via Typescript (or Javascript). The use of the [goja runtime](https://github.com/dop251/goja) means execution is entirely within Go, and sandboxed. --- docs/content/en/functions/external.md | 69 +++++++++++ go.mod | 9 +- go.sum | 26 ++++ tpl/external/init.go | 33 +++++ tpl/external/load.go | 171 ++++++++++++++++++++++++++ tpl/tplimpl/template_funcs.go | 1 + 6 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 docs/content/en/functions/external.md create mode 100644 tpl/external/init.go create mode 100644 tpl/external/load.go diff --git a/docs/content/en/functions/external.md b/docs/content/en/functions/external.md new file mode 100644 index 00000000000..41781a58fb8 --- /dev/null +++ b/docs/content/en/functions/external.md @@ -0,0 +1,69 @@ +--- +title: fn +description: Calls a custom, external Typescript/Jacascript function. +categories: [functions] +menu: + docs: + parent: functions +keywords: [function, external, code] +signature: ["external.Function FILE.FUNCTION ARGUMENTS…", "fn FILE.FUNCTION ARGUMENTS…"] +relatedfuncs: [] +--- + +This function allows the use of custom, portable Typescript/Javascript code stored within the hugo site files. + +For example if the file `functions/hello.ts` exists in a site with the exported function `Name`: + +```typescript +export function Name(name: string): string { + return `Hello ${name || "World"}!` +} +``` + +Then a Hugo template can call the external function `hello.Name`: + +```go-html-template + +``` + +Which will call the custom code and placing the results in the page: + +```html + +``` + +## Naming + +Capitalisation matters in the file/function name; calling `{{ fn "hello.name" }}` in the example above would fail with: + +```plain +error calling fn: the function named name does not exist in hello +``` + +Similarly calling `{{ fn "heLLo.Name" }}` would fail with: + +```plain +error calling fn: the function file named heLLo has not been loaded +``` + +## Function signature + +The exported functions can accept any number of arguments, but must only return a single string. + +Arguments are converted using [goja](https://github.com/dop251/goja)), currently only the most basic types are supported (strings, numbers). Times/dates aren't supported yet. + +## Imports + +Though the [Almond AMD loader](https://github.com/requirejs/almond) is readily available (via [clarkmcc's go-typescript](https://github.com/clarkmcc/go-typescript)) the Typescript execution environment does not have access to the filesystem. + +(Perhaps encourage to use webpack or similar to build single file?) diff --git a/go.mod b/go.mod index 18847d14271..3939a3f5f9d 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ require ( github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 github.com/PuerkitoBio/purell v1.1.1 github.com/alecthomas/chroma/v2 v2.7.0 + github.com/apitoolkit/doctests v0.0.0-20220724172649-f36dc3e57bf8 github.com/armon/go-radix v1.0.0 github.com/aws/aws-sdk-go v1.43.5 github.com/bep/clock v0.3.0 @@ -18,9 +19,11 @@ require ( github.com/bep/overlayfs v0.6.0 github.com/bep/simplecobra v0.3.1 github.com/bep/tmc v0.5.1 + github.com/clarkmcc/go-typescript v0.7.0 github.com/clbanning/mxj/v2 v2.5.7 github.com/cli/safeexec v1.0.1 github.com/disintegration/gift v1.2.1 + github.com/dop251/goja v0.0.0-20230605162241-28ee0ee714f3 github.com/dustin/go-humanize v1.0.1 github.com/evanw/esbuild v0.17.19 github.com/fortytw2/leaktest v1.3.0 @@ -60,6 +63,7 @@ require ( github.com/spf13/pflag v1.0.5 github.com/tdewolff/minify/v2 v2.12.6 github.com/tdewolff/parse/v2 v2.6.6 + github.com/wasmerio/wasmer-go v1.0.4 github.com/yuin/goldmark v1.5.4 go.uber.org/atomic v1.10.0 go.uber.org/automaxprocs v1.5.2 @@ -102,9 +106,11 @@ require ( github.com/dlclark/regexp2 v1.10.0 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/go-openapi/swag v0.19.5 // indirect + github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect github.com/golang-jwt/jwt/v4 v4.0.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // indirect + github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect github.com/google/s2a-go v0.1.3 // indirect github.com/google/uuid v1.3.0 // indirect github.com/google/wire v0.5.0 // indirect @@ -119,10 +125,11 @@ require ( github.com/kr/text v0.2.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-ieproxy v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.9 // indirect + github.com/mattn/go-runewidth v0.0.13 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/perimeterx/marshmallow v1.1.4 // indirect + github.com/rivo/uniseg v0.2.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect go.opencensus.io v0.24.0 // indirect golang.org/x/crypto v0.3.0 // indirect diff --git a/go.sum b/go.sum index f02171e68dc..017f4b98125 100644 --- a/go.sum +++ b/go.sum @@ -131,6 +131,8 @@ github.com/alecthomas/chroma/v2 v2.7.0 h1:hm1rY6c/Ob4eGclpQ7X/A3yhqBOZNUTk9q+yhy github.com/alecthomas/chroma/v2 v2.7.0/go.mod h1:yrkMI9807G1ROx13fhe1v6PN2DDeaR73L3d+1nmYQtw= github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/apitoolkit/doctests v0.0.0-20220724172649-f36dc3e57bf8 h1:fmoAv97b/HW49lAcOK9Vwlbaqsp5IMZuXBxiFezZc7E= +github.com/apitoolkit/doctests v0.0.0-20220724172649-f36dc3e57bf8/go.mod h1:KEnxZQRHcFa1CHfKU1B40Ds+rscvWLKrXFW03iQOdms= github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0= @@ -192,8 +194,13 @@ github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghf github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cheekybits/is v0.0.0-20150225183255-68e9c0620927/go.mod h1:h/aW8ynjgkuj+NQRlZcDbAbM1ORAbXjXX77sX7T289U= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/clarkmcc/go-typescript v0.7.0 h1:3nVeaPYyTCWjX6Lf8GoEOTxME2bM5tLuWmwhSZ86uxg= +github.com/clarkmcc/go-typescript v0.7.0/go.mod h1:IZ/nzoVeydAmyfX7l6Jmp8lJDOEnae3jffoXwP4UyYg= github.com/clbanning/mxj/v2 v2.5.7 h1:7q5lvUpaPF/WOkqgIDiwjBJaznaLCCBd78pi8ZyAnE0= github.com/clbanning/mxj/v2 v2.5.7/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= @@ -225,8 +232,15 @@ github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/ github.com/disintegration/gift v1.2.1 h1:Y005a1X4Z7Uc+0gLpSAsKhWi4qLtsdEcMIbbdvdZ6pc= github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI= github.com/djherbis/atime v1.1.0/go.mod h1:28OF6Y8s3NQWwacXc5eZTsEsiMzp7LF8MbXE+XJPdBE= +github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0= github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk= +github.com/dop251/goja v0.0.0-20230605162241-28ee0ee714f3 h1:+3HCtB74++ClLy8GgjUQYeC8R4ILzVcIe8+5edAJJnE= +github.com/dop251/goja v0.0.0-20230605162241-28ee0ee714f3/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4= +github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y= +github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -270,6 +284,8 @@ github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvSc github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= @@ -373,6 +389,8 @@ github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210715191844-86eeefc3e471/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U= +github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.3 h1:FAgZmpLl/SXurPEZyCMPBIiiYeTbqfjlbdnCNTAkbGE= github.com/google/s2a-go v0.1.3/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A= @@ -405,6 +423,7 @@ github.com/hashicorp/golang-lru/v2 v2.0.1/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyf github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/invopop/yaml v0.1.0 h1:YW3WGUoJEXYfzWBjn00zIlrw7brGVD0fUKRYDPAPhrc= @@ -457,6 +476,8 @@ github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPn github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/hashstructure v1.1.0 h1:P6P1hdjqAAknpY/M1CGipelZgp+4y9ja9kmUZPXP+H0= @@ -493,6 +514,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= @@ -548,6 +571,8 @@ github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6 github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= +github.com/wasmerio/wasmer-go v1.0.4 h1:MnqHoOGfiQ8MMq2RF6wyCeebKOe84G88h5yv+vmxJgs= +github.com/wasmerio/wasmer-go v1.0.4/go.mod h1:0gzVdSfg6pysA6QVp6iVRPTagC6Wq9pOE8J86WKb2Fk= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -777,6 +802,7 @@ golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/tpl/external/init.go b/tpl/external/init.go new file mode 100644 index 00000000000..de7a81819c3 --- /dev/null +++ b/tpl/external/init.go @@ -0,0 +1,33 @@ +package external + +import ( + "context" + + "github.com/gohugoio/hugo/deps" + "github.com/gohugoio/hugo/helpers" + "github.com/gohugoio/hugo/tpl/internal" +) + +// TODO: Pull functions location from config.toml, with this as default +const defaultFunctionsPath = "functions/" + +func init() { + f := func(d *deps.Deps) *internal.TemplateFuncsNamespace { + ctx, err := LoadFunctionFiles(defaultFunctionsPath) + if err != nil { + helpers.DistinctErrorLog.Warnf("Unable to load any function files: %v\n", err) + return nil + } + + ns := &internal.TemplateFuncsNamespace{ + Name: "external", + Context: func(cctx context.Context, args ...any) (any, error) { return ctx, nil }, + } + + ns.AddMethodMapping(ctx.Function, []string{"fn"}, nil) + + return ns + } + + internal.AddTemplateFuncsNamespace(f) +} diff --git a/tpl/external/load.go b/tpl/external/load.go new file mode 100644 index 00000000000..f6e967aa711 --- /dev/null +++ b/tpl/external/load.go @@ -0,0 +1,171 @@ +package external + +import ( + "context" + "fmt" + "os" + "path" + "path/filepath" + "reflect" + "strings" + "time" + + "github.com/clarkmcc/go-typescript" + "github.com/dop251/goja" +) + +const maxFuncFileLoadTime = time.Second + +type FunctionDetails struct { + Name string + Func func(args ...reflect.Value) string + Examples [][2]string +} + +type Namespace struct { + funcs map[string]map[string]func(...reflect.Value) string +} + +func (ns *Namespace) Function(name string, args ...reflect.Value) (string, error) { + parts := strings.Split(name, ".") + if len(parts) != 2 { + return "", fmt.Errorf("invalid function name: %s. Function names must be of the form fileName.FunctionName", name) + } + + mFuncs, ok := ns.funcs[parts[0]] + if !ok { + return "", fmt.Errorf("the function file named %s has not been loaded", parts[0]) + } + + fn, ok := mFuncs[parts[1]] + if !ok { + return "", fmt.Errorf("the function named %s does not exist in %s", parts[1], parts[0]) + } + + return fn(args...), nil +} + +func LoadFunctionFiles(funcsPath string) (*Namespace, error) { + tsPaths, err := filepath.Glob(path.Join(funcsPath, "*.ts")) + if err != nil { + return nil, err + } + + ns := &Namespace{ + funcs: make(map[string]map[string]func(...reflect.Value) string), + } + + for _, tsPath := range tsPaths { + mName := strings.TrimSuffix(path.Base(tsPath), ".ts") + funcs, warns := loadTsFunctionsFile(tsPath) + + // TODO: Send warnings to the console + _ = warns + + if len(funcs) == 0 { + continue + } + + ns.funcs[mName] = make(map[string]func(...reflect.Value) string) + + for _, fn := range funcs { + ns.funcs[mName][fn.Name] = fn.Func + } + } + + return ns, nil +} + +func loadTsFunctionsFile(tsPath string) ([]FunctionDetails, []error) { + vm, exports, err := executeTS(tsPath) + if err != nil { + return nil, []error{err} + } + + return extractFunctions(vm, exports) +} + +func executeTS(tsPath string) (*goja.Runtime, goja.Value, error) { + ctx, cancel := context.WithTimeout(context.Background(), maxFuncFileLoadTime) + defer cancel() + + tsFile, err := os.Open(tsPath) + if err != nil { + return nil, nil, err + } + + var vm *goja.Runtime + exportRuntime := func(cfg *typescript.EvaluateConfig) { vm = cfg.Runtime } + + _, err = typescript.EvaluateCtx(ctx, tsFile, + typescript.WithTranspile(), + typescript.WithAlmondModuleLoader(), + exportRuntime, + ) + if err != nil { + return nil, nil, fmt.Errorf("could not evaluate function file: %v", err) + } + + exports, err := vm.RunString("exports") + if err != nil { + return nil, nil, fmt.Errorf("the function file is missing exports: %v", err) + } + + return vm, exports, nil +} + +func extractFunctions(vm *goja.Runtime, exports goja.Value) ([]FunctionDetails, []error) { + var funcs []FunctionDetails + var warns []error + + exportMap, ok := exports.Export().(map[string]any) + if !ok { + return nil, []error{fmt.Errorf("the function file's 'exports' variable is not a map")} + } + + for name, obj := range exportMap { + fn, ok := obj.(func(goja.FunctionCall) goja.Value) + if !ok { + // Only looking for exported functions + continue + } + + detail := FunctionDetails{ + Name: name, + Func: func(args ...reflect.Value) string { + valueArgs := make([]goja.Value, len(args)) + for i, arg := range args { + // TODO: Be better at converting types; dates, for example? + valueArgs[i] = vm.ToValue(arg) + } + + val := fn(goja.FunctionCall{Arguments: valueArgs}) + + var out string + vm.ExportTo(val, &out) + return out + }, + } + + if obj, ok := exportMap[name+"Examples"]; ok { + examples, err := extractExamples(obj.([]interface{})) + if err == nil { + detail.Examples = examples + } else { + warns = append(warns, err) + } + } + + funcs = append(funcs, detail) + } + + return funcs, warns +} + +func extractExamples(ifaces []interface{}) ([][2]string, error) { + var examples [][2]string + + _ = ifaces + + return examples, nil +} diff --git a/tpl/tplimpl/template_funcs.go b/tpl/tplimpl/template_funcs.go index 97d1b40ddbc..309d59b7d76 100644 --- a/tpl/tplimpl/template_funcs.go +++ b/tpl/tplimpl/template_funcs.go @@ -41,6 +41,7 @@ import ( _ "github.com/gohugoio/hugo/tpl/debug" _ "github.com/gohugoio/hugo/tpl/diagrams" _ "github.com/gohugoio/hugo/tpl/encoding" + _ "github.com/gohugoio/hugo/tpl/external" _ "github.com/gohugoio/hugo/tpl/fmt" _ "github.com/gohugoio/hugo/tpl/hugo" _ "github.com/gohugoio/hugo/tpl/images" From 99d4e6e3531c41c024cc0959a460cb40a953b843 Mon Sep 17 00:00:00 2001 From: JP Hastings-Spital Date: Sun, 11 Jun 2023 21:58:43 +0100 Subject: [PATCH 2/4] Capture errors with type inference --- tpl/external/load.go | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/tpl/external/load.go b/tpl/external/load.go index f6e967aa711..865eb4f6d99 100644 --- a/tpl/external/load.go +++ b/tpl/external/load.go @@ -12,18 +12,19 @@ import ( "github.com/clarkmcc/go-typescript" "github.com/dop251/goja" + "github.com/gohugoio/hugo/helpers" ) const maxFuncFileLoadTime = time.Second type FunctionDetails struct { Name string - Func func(args ...reflect.Value) string + Func func(args ...reflect.Value) (string, error) Examples [][2]string } type Namespace struct { - funcs map[string]map[string]func(...reflect.Value) string + funcs map[string]map[string]func(...reflect.Value) (string, error) } func (ns *Namespace) Function(name string, args ...reflect.Value) (string, error) { @@ -42,7 +43,7 @@ func (ns *Namespace) Function(name string, args ...reflect.Value) (string, error return "", fmt.Errorf("the function named %s does not exist in %s", parts[1], parts[0]) } - return fn(args...), nil + return fn(args...) } func LoadFunctionFiles(funcsPath string) (*Namespace, error) { @@ -52,24 +53,26 @@ func LoadFunctionFiles(funcsPath string) (*Namespace, error) { } ns := &Namespace{ - funcs: make(map[string]map[string]func(...reflect.Value) string), + funcs: make(map[string]map[string]func(...reflect.Value) (string, error)), } for _, tsPath := range tsPaths { mName := strings.TrimSuffix(path.Base(tsPath), ".ts") funcs, warns := loadTsFunctionsFile(tsPath) - // TODO: Send warnings to the console - _ = warns + for _, warn := range warns { + helpers.DistinctWarnLog.Warnf("Issue loading functions from %s: %v\n", tsPath, warn) + } if len(funcs) == 0 { continue } - ns.funcs[mName] = make(map[string]func(...reflect.Value) string) + ns.funcs[mName] = make(map[string]func(...reflect.Value) (string, error)) for _, fn := range funcs { ns.funcs[mName][fn.Name] = fn.Func + helpers.DistinctWarnLog.Infof("Loaded function: %s.%s\n", mName, fn.Name) } } @@ -132,18 +135,18 @@ func extractFunctions(vm *goja.Runtime, exports goja.Value) ([]FunctionDetails, detail := FunctionDetails{ Name: name, - Func: func(args ...reflect.Value) string { + Func: func(args ...reflect.Value) (string, error) { valueArgs := make([]goja.Value, len(args)) for i, arg := range args { - // TODO: Be better at converting types; dates, for example? - valueArgs[i] = vm.ToValue(arg) + // TODO: Actually convert types, rather than leaning on coersion. + valueArgs[i] = vm.ToValue(arg.Interface()) } val := fn(goja.FunctionCall{Arguments: valueArgs}) var out string - vm.ExportTo(val, &out) - return out + err := vm.ExportTo(val, &out) + return out, err }, } From 7ae07e9c85ecd1430571405d568e698f91d4fbad Mon Sep 17 00:00:00 2001 From: JP Hastings-Spital Date: Sun, 11 Jun 2023 22:22:46 +0100 Subject: [PATCH 3/4] Docs on time.Time use --- docs/content/en/functions/external.md | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/content/en/functions/external.md b/docs/content/en/functions/external.md index 41781a58fb8..157cd186b64 100644 --- a/docs/content/en/functions/external.md +++ b/docs/content/en/functions/external.md @@ -56,11 +56,25 @@ Similarly calling `{{ fn "heLLo.Name" }}` would fail with: error calling fn: the function file named heLLo has not been loaded ``` -## Function signature +## Function signatures -The exported functions can accept any number of arguments, but must only return a single string. +The exported functions can accept any number of arguments, of any type, but must only return a single string. any exceptions `throw`n will be handled gracefully. -Arguments are converted using [goja](https://github.com/dop251/goja)), currently only the most basic types are supported (strings, numbers). Times/dates aren't supported yet. +Arguments are converted using [goja's `ToValue` method](https://pkg.go.dev/github.com/dop251/goja#Runtime.ToValue)). Note in particular that [dates do not work as expected](https://pkg.go.dev/github.com/dop251/goja#hdr-Handling_of_time_Time) in order to provide access to timezone information. If timezone is unimportant you can use `.UnixNano()`: + +```typescript +export function WorkWithDates(myDateObj: object): string { + const myDate = new Date(myDateObj.UnixNano()/1e6); + + return myDate.toString(); +} +``` + +Which could be called with: + +```go-template +{{ fb "yours.WorkWithDates" .Date }} → Sun Jun 11 2023 12:00:00 GMT+0100 (British Summer Time) +``` ## Imports From 34edf2c40ceeb7ae61b119123c124a40947077b1 Mon Sep 17 00:00:00 2001 From: JP Hastings-Spital Date: Sun, 11 Jun 2023 22:44:27 +0100 Subject: [PATCH 4/4] Simplify times --- docs/content/en/functions/external.md | 14 +++----------- tpl/external/load.go | 17 +++++++++++++++-- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/docs/content/en/functions/external.md b/docs/content/en/functions/external.md index 157cd186b64..706c6155167 100644 --- a/docs/content/en/functions/external.md +++ b/docs/content/en/functions/external.md @@ -60,20 +60,12 @@ error calling fn: the function file named heLLo has not been loaded The exported functions can accept any number of arguments, of any type, but must only return a single string. any exceptions `throw`n will be handled gracefully. -Arguments are converted using [goja's `ToValue` method](https://pkg.go.dev/github.com/dop251/goja#Runtime.ToValue)). Note in particular that [dates do not work as expected](https://pkg.go.dev/github.com/dop251/goja#hdr-Handling_of_time_Time) in order to provide access to timezone information. If timezone is unimportant you can use `.UnixNano()`: +Arguments are automatically converted to Javascript native formats using [goja's `ToValue` method](https://pkg.go.dev/github.com/dop251/goja#Runtime.ToValue)). -```typescript -export function WorkWithDates(myDateObj: object): string { - const myDate = new Date(myDateObj.UnixNano()/1e6); - - return myDate.toString(); -} -``` - -Which could be called with: +Dates/times are also converted to native `Date` objects (ie. _not_ like [default goja](https://pkg.go.dev/github.com/dop251/goja#hdr-Handling_of_time_Time)). This does mean that Timezone information is lost; the `Date` object in your function will be in the timezone of the machine Hugo is running on (not the timezone of the passed argument). You can work around this by sending any needed timezone information as a separate argument, eg: ```go-template -{{ fb "yours.WorkWithDates" .Date }} → Sun Jun 11 2023 12:00:00 GMT+0100 (British Summer Time) +{{ fn "example.Timezone" .Date (.Date.Format "-0700") }} ``` ## Imports diff --git a/tpl/external/load.go b/tpl/external/load.go index 865eb4f6d99..604696f3807 100644 --- a/tpl/external/load.go +++ b/tpl/external/load.go @@ -138,8 +138,11 @@ func extractFunctions(vm *goja.Runtime, exports goja.Value) ([]FunctionDetails, Func: func(args ...reflect.Value) (string, error) { valueArgs := make([]goja.Value, len(args)) for i, arg := range args { - // TODO: Actually convert types, rather than leaning on coersion. - valueArgs[i] = vm.ToValue(arg.Interface()) + val, err := toGojaType(vm, arg) + if err != nil { + return "", err + } + valueArgs[i] = val } val := fn(goja.FunctionCall{Arguments: valueArgs}) @@ -172,3 +175,13 @@ func extractExamples(ifaces []interface{}) ([][2]string, error) { return examples, nil } + +func toGojaType(vm *goja.Runtime, val reflect.Value) (goja.Value, error) { + iface := val.Interface() + + if t, ok := iface.(time.Time); ok { + return vm.New(vm.Get("Date").ToObject(vm), vm.ToValue(t.UnixNano()/1e6)) + } + + return vm.ToValue(val.Interface()), nil +}