diff --git a/cmd/templ/generatecmd/cmd.go b/cmd/templ/generatecmd/cmd.go index 4419dc9a9..a348e94b6 100644 --- a/cmd/templ/generatecmd/cmd.go +++ b/cmd/templ/generatecmd/cmd.go @@ -84,6 +84,12 @@ func (cmd Generate) Run(ctx context.Context) (err error) { if cmd.Args.IncludeTimestamp { opts = append(opts, generator.WithTimestamp(time.Now())) } + if cmd.Args.MinifyJS { + opts = append(opts, generator.WithJsMinification()) + } + if cmd.Args.MinifyCSS { + opts = append(opts, generator.WithCSSMinification()) + } // Check the version of the templ module. if err := modcheck.Check(cmd.Args.Path); err != nil { diff --git a/cmd/templ/generatecmd/main.go b/cmd/templ/generatecmd/main.go index 538f820a4..4bc43db3e 100644 --- a/cmd/templ/generatecmd/main.go +++ b/cmd/templ/generatecmd/main.go @@ -23,6 +23,8 @@ type Arguments struct { GenerateSourceMapVisualisations bool IncludeVersion bool IncludeTimestamp bool + MinifyJS bool + MinifyCSS bool // PPROFPort is the port to run the pprof server on. PPROFPort int KeepOrphanedFiles bool diff --git a/cmd/templ/main.go b/cmd/templ/main.go index 7df4fd4bd..20573d763 100644 --- a/cmd/templ/main.go +++ b/cmd/templ/main.go @@ -155,6 +155,10 @@ Args: Set to false to skip inclusion of the templ version in the generated code. (default true) -include-timestamp Set to true to include the current time in the generated code. + -minify-js + Minify Javascript script blocks and tags. (default false) + -minify-css + Minify CSS style blocks and tags. (default false) -watch Set to true to watch the path for changes and regenerate code. -cmd @@ -219,6 +223,8 @@ func generateCmd(stdout, stderr io.Writer, args []string) (code int) { logLevelFlag := cmd.String("log-level", "info", "") lazyFlag := cmd.Bool("lazy", false, "") helpFlag := cmd.Bool("help", false, "") + minifyJSFlag := cmd.Bool("minify-js", false, "") + minifyCSSFlag := cmd.Bool("minify-css", false, "") err := cmd.Parse(args) if err != nil { fmt.Fprint(stderr, generateUsageText) @@ -262,6 +268,8 @@ func generateCmd(stdout, stderr io.Writer, args []string) (code int) { IncludeTimestamp: *includeTimestampFlag, PPROFPort: *pprofPortFlag, KeepOrphanedFiles: *keepOrphanedFilesFlag, + MinifyJS: *minifyJSFlag, + MinifyCSS: *minifyCSSFlag, Lazy: *lazyFlag, }) if err != nil { diff --git a/docs/docs/04-core-concepts/02-template-generation.md b/docs/docs/04-core-concepts/02-template-generation.md index b3f88f2f0..84a695aa7 100644 --- a/docs/docs/04-core-concepts/02-template-generation.md +++ b/docs/docs/04-core-concepts/02-template-generation.md @@ -46,6 +46,10 @@ Args: Set to false to skip inclusion of the templ version in the generated code. (default true) -include-timestamp Set to true to include the current time in the generated code. + -minify-js + Minify Javascript script blocks and tags. (default false) + -minify-css + Minify CSS style blocks and tags. (default false) -watch Set to true to watch the path for changes and regenerate code. -cmd diff --git a/docs/docs/09-commands-and-tools/01-cli.md b/docs/docs/09-commands-and-tools/01-cli.md index 9d1992834..53851568b 100644 --- a/docs/docs/09-commands-and-tools/01-cli.md +++ b/docs/docs/09-commands-and-tools/01-cli.md @@ -39,6 +39,10 @@ Args: Set to false to skip inclusion of the templ version in the generated code. (default true) -include-timestamp Set to true to include the current time in the generated code. + -minify-js + Minify Javascript script blocks and tags. (default false) + -minify-css + Minify CSS style blocks and tags. (default false) -watch Set to true to watch the path for changes and regenerate code. -cmd diff --git a/generator/generator.go b/generator/generator.go index 5614e22ae..aac0f972d 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -8,6 +8,7 @@ import ( "io" "path/filepath" "reflect" + "regexp" "strconv" "strings" "time" @@ -16,6 +17,7 @@ import ( _ "embed" "github.com/a-h/templ/parser/v2" + "github.com/tdewolff/minify/v2/minify" ) type GenerateOpt func(g *generator) error @@ -57,6 +59,20 @@ func WithExtractStrings() GenerateOpt { } } +func WithJsMinification() GenerateOpt { + return func(g *generator) error { + g.minifyJS = true + return nil + } +} + +func WithCSSMinification() GenerateOpt { + return func(g *generator) error { + g.minifyCSS = true + return nil + } +} + // WithSkipCodeGeneratedComment skips the code generated comment at the top of the file. // gopls disables edit related functionality for generated files, so the templ LSP may // wish to skip generation of this comment so that gopls provides expected results. @@ -99,6 +115,10 @@ type generator struct { generatedDate string // fileName to include in error messages if string expressions return an error. fileName string + // minifyJS bool to set js minification on or off + minifyJS bool + // minifyCSS bool to set css minification on or off + minifyCSS bool // skipCodeGeneratedComment skips the code generated comment at the top of the file. skipCodeGeneratedComment bool } @@ -1303,6 +1323,19 @@ func (g *generator) writeRawElement(indentLevel int, n parser.RawElement) (err e } } // Contents. + if n.Name == "script" && g.minifyJS { + if err := minifyScriptElementContents(&n); err != nil { + return err + } + } + + if n.Name == "style" && g.minifyCSS { + var err error + if n.Contents, err = minify.Default.String("text/css", n.Contents); err != nil { + return err + } + } + if err = g.writeText(indentLevel, parser.Text{Value: n.Contents}); err != nil { return err } @@ -1460,6 +1493,13 @@ func (g *generator) writeScript(t parser.ScriptTemplate) error { prefix := "function " + fn + "(" + stripTypes(t.Parameters.Value) + "){" body := strings.TrimLeftFunc(t.Value, unicode.IsSpace) suffix := "}" + + if g.minifyJS { + if body, err = minify.JS(body); err != nil { + return nil + } + } + if _, err = g.w.WriteIndent(indentLevel, "Function: "+createGoString(prefix+body+suffix)+",\n"); err != nil { return err } @@ -1512,3 +1552,44 @@ func stripTypes(parameters string) string { } return strings.Join(variableNames, ", ") } + +func minifyScriptElementContents(element *parser.RawElement) error { + if element.Name != "script" { + return nil + } + + mimetype := "text/javascript" + for _, attr := range element.Attributes { + switch attr.GetName() { + case "type": + // Warning: this is build on the assumption that + // the script attribute 'type' is not defined by a expressive/conditional + // statement but a constant one. This may cause unexpected + // behaviour when the type is set using a spread, expressive + // or conditional attribute + + // Check if this is a ConstantAttribute + if a, ok := attr.(parser.ConstantAttribute); ok { + typeVal := strings.ToLower(strings.Trim(a.Value, " ")) + r := regexp.MustCompile(`^(application|text)/(x-)?(java|ecma|j|live)script(1\.[0-5])?$|^module$`) + if r.Match([]byte(typeVal)) || typeVal == "application/json" { + mimetype = typeVal + continue + } + + // Unsupported script type + return nil + } + case "src": + // Lets not minify remote scripts + return nil + } + } + + var err error + if element.Contents, err = minify.Default.String(mimetype, element.Contents); err == nil { + return err + } + + return nil +} diff --git a/generator/test-raw-elements/expected_with_minification.html b/generator/test-raw-elements/expected_with_minification.html new file mode 100644 index 000000000..1e5da306d --- /dev/null +++ b/generator/test-raw-elements/expected_with_minification.html @@ -0,0 +1,14 @@ +

Hello

World
\ No newline at end of file diff --git a/generator/test-raw-elements/render_test.go b/generator/test-raw-elements/render_test.go index bbb5ecb26..9b8006d93 100644 --- a/generator/test-raw-elements/render_test.go +++ b/generator/test-raw-elements/render_test.go @@ -10,6 +10,9 @@ import ( //go:embed expected.html var expected string +//go:embed expected_with_minification.html +var expectedWithMinification string + func Test(t *testing.T) { component := Example() diff, err := htmldiff.Diff(component, expected) @@ -20,3 +23,14 @@ func Test(t *testing.T) { t.Error(diff) } } + +func TestWithJSMinification(t *testing.T) { + component := ExampleWithMinification() + diff, err := htmldiff.Diff(component, expectedWithMinification) + if err != nil { + t.Fatal(err) + } + if diff != "" { + t.Error(diff) + } +} diff --git a/generator/test-raw-elements/template_with_js_minification.templ b/generator/test-raw-elements/template_with_js_minification.templ new file mode 100644 index 000000000..51c9febee --- /dev/null +++ b/generator/test-raw-elements/template_with_js_minification.templ @@ -0,0 +1,47 @@ +package testrawelements + +templ ExampleWithMinification() { + + + + + + + + + +

Hello

+ @templ.Raw("
World
") + + +} diff --git a/generator/test-raw-elements/template_with_js_minification_templ.go b/generator/test-raw-elements/template_with_js_minification_templ.go new file mode 100644 index 000000000..08b8b0a02 --- /dev/null +++ b/generator/test-raw-elements/template_with_js_minification_templ.go @@ -0,0 +1,45 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.2.759 +package testrawelements + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func ExampleWithMinification() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Hello

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.Raw("
World
").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/go.mod b/go.mod index 8708add70..2d2bd4652 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,8 @@ require ( github.com/segmentio/asm v1.2.0 // indirect github.com/segmentio/encoding v0.4.0 // indirect github.com/stretchr/testify v1.8.4 // indirect + github.com/tdewolff/minify/v2 v2.20.32 // indirect + github.com/tdewolff/parse/v2 v2.7.14 // indirect go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/net v0.24.0 // indirect diff --git a/go.sum b/go.sum index cfc3b4216..81d208f52 100644 --- a/go.sum +++ b/go.sum @@ -42,6 +42,11 @@ github.com/segmentio/encoding v0.4.0 h1:MEBYvRqiUB2nfR2criEXWqwdY6HJOUrCn5hboVOV github.com/segmentio/encoding v0.4.0/go.mod h1:/d03Cd8PoaDeceuhUUUQWjU0KhWjrmYrWPgtJHYZSnI= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tdewolff/minify/v2 v2.20.32 h1:rk4THvBPLEU+gGDKaJxyvFhF5+quSwCk3HKv1GpSVyE= +github.com/tdewolff/minify/v2 v2.20.32/go.mod h1:1TJni7+mATKu24cBQQpgwakrYRD27uC1/rdJOgdv8ns= +github.com/tdewolff/parse/v2 v2.7.14 h1:100KJ+QAO3PpMb3uUjzEU/NpmCdbBYz6KPmCIAfWpR8= +github.com/tdewolff/parse/v2 v2.7.14/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA= +github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.lsp.dev/jsonrpc2 v0.10.0 h1:Pr/YcXJoEOTMc/b6OTmcR1DPJ3mSWl/SWiU1Cct6VmI= go.lsp.dev/jsonrpc2 v0.10.0/go.mod h1:fmEzIdXPi/rf6d4uFcayi8HpFP1nBF99ERP1htC72Ac= diff --git a/parser/v2/types.go b/parser/v2/types.go index 34186c438..096c83ac0 100644 --- a/parser/v2/types.go +++ b/parser/v2/types.go @@ -703,6 +703,9 @@ func (e RawElement) Write(w io.Writer, indent int) error { } type Attribute interface { + // GetName returns the attribute name if possible, otherwise empty string + // eg. ConditionalAttribute or SpreadAttributes may contain many names + GetName() string // Write out the string. Write(w io.Writer, indent int) error } @@ -713,6 +716,10 @@ type BoolConstantAttribute struct { NameRange Range } +func (bca BoolConstantAttribute) GetName() string { + return bca.Name +} + func (bca BoolConstantAttribute) String() string { return bca.Name } @@ -729,6 +736,10 @@ type ConstantAttribute struct { NameRange Range } +func (ca ConstantAttribute) GetName() string { + return ca.Name +} + func (ca ConstantAttribute) String() string { quote := `"` if ca.SingleQuote { @@ -748,6 +759,10 @@ type BoolExpressionAttribute struct { NameRange Range } +func (bea BoolExpressionAttribute) GetName() string { + return bea.Name +} + func (bea BoolExpressionAttribute) String() string { return bea.Name + `?={ ` + bea.Expression.Value + ` }` } @@ -763,6 +778,10 @@ type ExpressionAttribute struct { NameRange Range } +func (ea ExpressionAttribute) GetName() string { + return ea.Name +} + func (ea ExpressionAttribute) String() string { sb := new(strings.Builder) _ = ea.Write(sb, 0) @@ -820,6 +839,10 @@ type SpreadAttributes struct { Expression Expression } +func (sa SpreadAttributes) GetName() string { + return "" +} + func (sa SpreadAttributes) String() string { return `{ ` + sa.Expression.Value + `... }` } @@ -838,6 +861,10 @@ type ConditionalAttribute struct { Else []Attribute } +func (ca ConditionalAttribute) GetName() string { + return "" +} + func (ca ConditionalAttribute) String() string { sb := new(strings.Builder) _ = ca.Write(sb, 0)