From 23a529b902efca94d0b9cab18c6733b9c6ef2b23 Mon Sep 17 00:00:00 2001 From: techninja1008 Date: Fri, 20 Aug 2021 02:48:30 +0100 Subject: [PATCH] Add hooks (#221) * Add hooks example * Add image for tailwindcss yew example * Fix yew-tailwindcss example README.md * Add changelog entry --- CHANGELOG.md | 2 + Trunk.toml | 29 ++ examples/yew-tailwindcss/Cargo.lock | 464 ++++++++++++++++++ examples/yew-tailwindcss/Cargo.toml | 11 + examples/yew-tailwindcss/README.md | 9 + examples/yew-tailwindcss/Trunk.toml | 8 + .../example-yew-tailwindcss.png | Bin 0 -> 43493 bytes examples/yew-tailwindcss/index.html | 18 + examples/yew-tailwindcss/src/app.css | 1 + examples/yew-tailwindcss/src/index.scss | 3 + examples/yew-tailwindcss/src/inline-scss.scss | 4 + examples/yew-tailwindcss/src/main.rs | 77 +++ examples/yew-tailwindcss/src/tailwind.css | 3 + examples/yew-tailwindcss/src/yew.svg | 7 + examples/yew-tailwindcss/tailwind.config.js | 14 + site/content/assets.md | 31 ++ src/config/mod.rs | 2 +- src/config/models.rs | 33 +- src/config/rt.rs | 15 +- src/hooks.rs | 65 +++ src/main.rs | 1 + src/pipelines/html.rs | 16 +- src/pipelines/mod.rs | 15 + 23 files changed, 818 insertions(+), 10 deletions(-) create mode 100644 examples/yew-tailwindcss/Cargo.lock create mode 100644 examples/yew-tailwindcss/Cargo.toml create mode 100644 examples/yew-tailwindcss/README.md create mode 100644 examples/yew-tailwindcss/Trunk.toml create mode 100644 examples/yew-tailwindcss/example-yew-tailwindcss.png create mode 100644 examples/yew-tailwindcss/index.html create mode 100644 examples/yew-tailwindcss/src/app.css create mode 100644 examples/yew-tailwindcss/src/index.scss create mode 100644 examples/yew-tailwindcss/src/inline-scss.scss create mode 100644 examples/yew-tailwindcss/src/main.rs create mode 100644 examples/yew-tailwindcss/src/tailwind.css create mode 100644 examples/yew-tailwindcss/src/yew.svg create mode 100644 examples/yew-tailwindcss/tailwind.config.js create mode 100644 src/hooks.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5071a40c..3afeaa6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ This changelog follows the patterns described here: https://keepachangelog.com/e Subheadings to categorize changes are `added, changed, deprecated, removed, fixed, security`. ## Unreleased +### added +- Trunk now includes a hooks system. This improves many use cases where another build tool is needed alongside trunk, by allowing trunk to be configured to call them at various stages during the build pipeline. It is configured under `[[hooks]]` in `Trunk.toml`. More information can be found in the [Assets](https://trunkrs.dev/assets/) section of the docs. - Added `trunk serve` autoreload triggered over websocket that reloads the page when a change is detected. The `--no-autoreload` flag disables this feature. ## 0.13.1 diff --git a/Trunk.toml b/Trunk.toml index b9228097..4f3fc426 100644 --- a/Trunk.toml +++ b/Trunk.toml @@ -57,3 +57,32 @@ backend = "http://localhost:9000/" # This proxy specifies only the backend, which is the only required field. In this example, # request URIs are not modified when proxied. backend = "http://localhost:9000/api/v2/" + +## hooks +# Hooks are optional, and default to `None`. +# Hooks are executed as part of Trunk's main build pipeline, no matter how it is run. + +[[hooks]] +# This hook example shows all the current available fields. It will execute the equivalent of +# typing "echo Hello Trunk!" right at the start of the build process (even before the HTML file +# is read). By default, the command is spawned directly and no shell is used. +stage = "pre_build" +command = "echo" +command_arguments = ["Hello", "Trunk!"] + +[[hooks]] +# This hook example shows running a command inside a shell. As a result, features such as variable +# interpolation are available. This shows the TRUNK_STAGING_DIR environment variable, one of a set +# of default variables that Trunk inserts into your hook's environment. Additionally, this hook +# uses the build stage, meaning it executes in parallel with all of the existing asset pipelines. +stage = "build" +command = "sh" +command_arguments = ["-c", "echo Staging directory: $TRUNK_STAGING_DIR"] + +[[hooks]] +# This hook example shows how command_arguments defaults to an empty list when absent. It also uses +# the post_build stage, meaning it executes after the rest of the build is complete, just before +# the staging directory is copied over the dist directory. This means that it has access to all +# built assets, including the HTML file generated by trunk. +stage = "post_build" +command = "ls" diff --git a/examples/yew-tailwindcss/Cargo.lock b/examples/yew-tailwindcss/Cargo.lock new file mode 100644 index 00000000..9a0fe2de --- /dev/null +++ b/examples/yew-tailwindcss/Cargo.lock @@ -0,0 +1,464 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "anyhow" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "595d3cfa7a60d4555cb5067b99f07142a08ea778de5cf993f7b75c7d8fabc486" + +[[package]] +name = "anymap" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33954243bd79057c2de7338850b85983a44588021f8a5fee574a8888c6de4344" + +[[package]] +name = "autocfg" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "boolinator" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfa8873f51c92e232f9bac4065cddef41b714152812bfc5f7672ba16d6ef8cd9" + +[[package]] +name = "bumpalo" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c59e7af012c713f529e7a3ee57ce9b31ddd858d4b512923602f74608b009631" + +[[package]] +name = "bytes" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b700ce4376041dcd0a327fd0097c41095743c4c8af8887265942faf1100bd040" + +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "cfg-match" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8100e46ff92eb85bf6dc2930c73f2a4f7176393c84a9446b3d501e1b354e7b34" + +[[package]] +name = "console_error_panic_hook" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8d976903543e0c48546a91908f21588a680a8c8f984df9a5d69feccb2b2a211" +dependencies = [ + "cfg-if 0.1.10", + "wasm-bindgen", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "gloo" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ce6f2dfa9f57f15b848efa2aade5e1850dc72986b87a2b0752d44ca08f4967" +dependencies = [ + "gloo-console-timer", + "gloo-events", + "gloo-file", + "gloo-timers", +] + +[[package]] +name = "gloo-console-timer" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b48675544b29ac03402c6dffc31a912f716e38d19f7e74b78b7e900ec3c941ea" +dependencies = [ + "web-sys", +] + +[[package]] +name = "gloo-events" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "088514ec8ef284891c762c88a66b639b3a730134714692ee31829765c5bc814f" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-file" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f9fecfe46b5dc3cc46f58e98ba580cc714f2c93860796d002eb3527a465ef49" +dependencies = [ + "gloo-events", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-timers" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47204a46aaff920a1ea58b11d03dec6f704287d27561724a4631e450654a891f" +dependencies = [ + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "hashbrown" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" + +[[package]] +name = "http" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527e8c9ac747e28542699a951517aa9a6945af506cd1f2e1b53a576c17b6cc11" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "indexmap" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc633605454125dec4b66843673f01c7df2b89479b32e0ed634e43a91cff62a5" +dependencies = [ + "autocfg", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd25036021b0de88a0aff6b850051563c6516d0bf53f8638938edbb9de732736" + +[[package]] +name = "js-sys" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83bdfbace3a0e81a4253f73b49e960b053e396a11012cbd49b9b74d6a2b67062" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320cfe77175da3a483efed4bc0adc1968ca050b098ce4f2f1c13a56626128790" + +[[package]] +name = "log" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" +dependencies = [ + "cfg-if 1.0.0", +] + +[[package]] +name = "memory_units" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8452105ba047068f40ff7093dd1d9da90898e63dd61736462e9cdda6a90ad3c3" + +[[package]] +name = "proc-macro2" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7ed8b8c7b886ea3ed7dde405212185f423ab44682667c8c6dd14aa1d9f6612" +dependencies = [ + "unicode-xid", +] + +[[package]] +name = "quote" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ryu" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" + +[[package]] +name = "serde" +version = "1.0.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7505abeacaec74ae4778d9d9328fe5a5d04253220a85c4ee022239fc996d03" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "963a7dbc9895aeac7ac90e74f34a5d5261828f79df35cbed41e10189d3804d43" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799e97dc9fdae36a5c8b8f2cae9ce2ee9fdce2058c57a93e6099d919fd982f79" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "slab" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f173ac3d1a7e3b28003f40de0b5ce7fe2710f9b9dc3fc38664cebee46b3b6527" + +[[package]] +name = "syn" +version = "1.0.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1873d832550d4588c3dbc20f01361ab00bfe741048f71e3fecf145a7cc18b29c" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "thiserror" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93119e4feac1cbe6c798c34d3a53ea0026b0b1de6a120deef895137c0529bfe2" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "060d69a0afe7796bf42e9e2ff91f5ee691fb15c53d38b4b62a9a53eb23164745" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-xid" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" + +[[package]] +name = "wasm-bindgen" +version = "0.2.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d54ee1d4ed486f78874278e63e4069fc1ab9f6a18ca492076ffb90c5eb2997fd" +dependencies = [ + "cfg-if 1.0.0", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b33f6a0694ccfea53d94db8b2ed1c3a8a4c86dd936b13b9f0a15ec4a451b900" +dependencies = [ + "bumpalo", + "lazy_static", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fba7978c679d53ce2d0ac80c8c175840feb849a161664365d1287b41f2e67f1" +dependencies = [ + "cfg-if 1.0.0", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "088169ca61430fe1e58b8096c24975251700e7b1f6fd91cc9d59b04fb9b18bd4" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2241542ff3d9f241f5e2cb6dd09b37efe786df8851c54957683a49f0987a97" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7cff876b8f18eed75a66cf49b65e7f967cb354a7aa16003fb55dbfd25b44b4f" + +[[package]] +name = "web-sys" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e828417b379f3df7111d3a2a9e5753706cae29c41f7c4029ee9fd77f3e09e582" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wee_alloc" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb3b5a6b2bb17cb6ad44a2e68a43e8d2722c997da10e928665c72ec6c0a0b8e" +dependencies = [ + "cfg-if 0.1.10", + "libc", + "memory_units", + "winapi", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "yew" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d5154faef86dddd2eb333d4755ea5643787d20aca683e58759b0e53351409f" +dependencies = [ + "anyhow", + "anymap", + "bincode", + "cfg-if 1.0.0", + "cfg-match", + "console_error_panic_hook", + "gloo", + "http", + "indexmap", + "js-sys", + "log", + "ryu", + "serde", + "serde_json", + "slab", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "yew-macro", +] + +[[package]] +name = "yew-macro" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6e23bfe3dc3933fbe9592d149c9985f3047d08c637a884b9344c21e56e092ef" +dependencies = [ + "boolinator", + "lazy_static", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "yew-tailwindcss-example" +version = "0.1.0" +dependencies = [ + "console_error_panic_hook", + "wasm-bindgen", + "wee_alloc", + "yew", +] diff --git a/examples/yew-tailwindcss/Cargo.toml b/examples/yew-tailwindcss/Cargo.toml new file mode 100644 index 00000000..03a92285 --- /dev/null +++ b/examples/yew-tailwindcss/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "yew-tailwindcss-example" +version = "0.1.0" +authors = ["Anthony Dodd ", "Danny Wensley "] +edition = "2018" + +[dependencies] +console_error_panic_hook = "0.1.6" +wasm-bindgen = "=0.2.74" +wee_alloc = "0.4.5" +yew = "0.18" diff --git a/examples/yew-tailwindcss/README.md b/examples/yew-tailwindcss/README.md new file mode 100644 index 00000000..5e02dd1b --- /dev/null +++ b/examples/yew-tailwindcss/README.md @@ -0,0 +1,9 @@ +Trunk | Yew | Tailwind +====================== +An example application demonstrating building a WASM web application using Trunk, Yew & Tailwind. + +In order to run, this example requires a working nodejs installation that includes `npx`. + +Simply execute `trunk serve --open` from this example's directory, and you should see the following web application rendered in your browser. + + diff --git a/examples/yew-tailwindcss/Trunk.toml b/examples/yew-tailwindcss/Trunk.toml new file mode 100644 index 00000000..27bac4dc --- /dev/null +++ b/examples/yew-tailwindcss/Trunk.toml @@ -0,0 +1,8 @@ +[build] +target = "index.html" +dist = "dist" + +[[hooks]] +stage = "build" +command = "sh" +command_arguments = ["-c", "npx tailwindcss-cli build src/tailwind.css -o $TRUNK_STAGING_DIR/tailwind.css"] diff --git a/examples/yew-tailwindcss/example-yew-tailwindcss.png b/examples/yew-tailwindcss/example-yew-tailwindcss.png new file mode 100644 index 0000000000000000000000000000000000000000..8417ef3c4a93ca2c0c41cd6b4cf1c907d234a20a GIT binary patch literal 43493 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 aM+L2G{BKL>>Kxq3EdyiyitCSm|9=3t)iHkn literal 0 HcmV?d00001 diff --git a/examples/yew-tailwindcss/index.html b/examples/yew-tailwindcss/index.html new file mode 100644 index 00000000..1969fff5 --- /dev/null +++ b/examples/yew-tailwindcss/index.html @@ -0,0 +1,18 @@ + + + + + + Trunk | Yew | Tailwind + + + + + + + + + + + + diff --git a/examples/yew-tailwindcss/src/app.css b/examples/yew-tailwindcss/src/app.css new file mode 100644 index 00000000..208d16d4 --- /dev/null +++ b/examples/yew-tailwindcss/src/app.css @@ -0,0 +1 @@ +body {} diff --git a/examples/yew-tailwindcss/src/index.scss b/examples/yew-tailwindcss/src/index.scss new file mode 100644 index 00000000..35a19420 --- /dev/null +++ b/examples/yew-tailwindcss/src/index.scss @@ -0,0 +1,3 @@ +@charset "utf-8"; + +html {} diff --git a/examples/yew-tailwindcss/src/inline-scss.scss b/examples/yew-tailwindcss/src/inline-scss.scss new file mode 100644 index 00000000..42a75910 --- /dev/null +++ b/examples/yew-tailwindcss/src/inline-scss.scss @@ -0,0 +1,4 @@ +.trunk_yew_example_fancy_green { + $nice_color: green; + background-color: $nice_color; +} diff --git a/examples/yew-tailwindcss/src/main.rs b/examples/yew-tailwindcss/src/main.rs new file mode 100644 index 00000000..74172c7f --- /dev/null +++ b/examples/yew-tailwindcss/src/main.rs @@ -0,0 +1,77 @@ +#![recursion_limit = "1024"] + +#[global_allocator] +static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; + +use console_error_panic_hook::set_once as set_panic_hook; +use yew::prelude::*; + +struct App; + +impl Component for App { + type Message = (); + type Properties = (); + + fn create(_: Self::Properties, _: ComponentLink) -> Self { + Self + } + + fn update(&mut self, _: Self::Message) -> bool { + false + } + + fn change(&mut self, _: Self::Properties) -> bool { + false + } + + fn view(&self) -> Html { + let link_classes = "block px-4 py-2 hover:bg-black hover:text-white rounded border-black border"; + let links = [ + ("Trunk", "https://github.com/thedodd/trunk"), + ("Yew", "https://yew.rs/"), + ("Tailwind", "https://tailwindcss.com"), + ]; + html! { +
+ +
+ {view_card("Trunk", None, html! { +

{"Trunk is a WASM web application bundler for Rust."}

+ })} + {view_card("Yew", Some("yew.svg"), html! { +

{"Yew is a modern Rust framework for creating multi-threaded front-end web apps with WebAssembly."}

+ })} + {view_card("Tailwind CSS", None, html! { +

{"Tailwind CSS is a library for styling markup using a comprehensive set of utility classes, no CSS required."}

+ })} +
+
+ } + } +} + +fn view_card(title: &'static str, img_url: Option<&'static str>, content: Html) -> Html { + html! { +
+ {for img_url.map(|url| html! { + Logo + })} +

{title}

+ {content} +
+ } +} + +fn main() { + set_panic_hook(); + + yew::start_app::(); +} diff --git a/examples/yew-tailwindcss/src/tailwind.css b/examples/yew-tailwindcss/src/tailwind.css new file mode 100644 index 00000000..b5c61c95 --- /dev/null +++ b/examples/yew-tailwindcss/src/tailwind.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/examples/yew-tailwindcss/src/yew.svg b/examples/yew-tailwindcss/src/yew.svg new file mode 100644 index 00000000..3082a4c3 --- /dev/null +++ b/examples/yew-tailwindcss/src/yew.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/examples/yew-tailwindcss/tailwind.config.js b/examples/yew-tailwindcss/tailwind.config.js new file mode 100644 index 00000000..c742772f --- /dev/null +++ b/examples/yew-tailwindcss/tailwind.config.js @@ -0,0 +1,14 @@ +module.exports = { + mode: 'jit', + purge: [ + "src/**/*.rs" + ], + darkMode: false, // or 'media' or 'class' + theme: { + extend: {}, + }, + variants: { + extend: {}, + }, + plugins: [], +} diff --git a/site/content/assets.md b/site/content/assets.md index 02426f28..c3c0bab3 100644 --- a/site/content/assets.md +++ b/site/content/assets.md @@ -67,5 +67,36 @@ Trunk will set the `href` attribute of the element to the public URL. This chang You can also access this value at runtime using `document.baseURI` which is useful for apps that need to know the base URL on which they're hosted (e.g. for routing). +# Hooks +If you find that you need Trunk to perform an additional build action that isn't supported directly, then Trunk's flexible hooks system can be used to launch external processes at various stages in the pipeline. Hooks can be declared exclusively in `Trunk.toml`, and consist of a `stage`, `command` and `command_arguments`: + - `stage`: (required) one of `pre_build`, `build` or `post_build`. It specifies when in Trunk's build pipeline the hook is executed. + - `command` : (required) the name or path to the desired executable. + - `command_arguments`: (optional, defaults to none) any arguments to be passed, in the given order, to the executable. + +At the relevant point for each stage, all hooks for that stage are spawned simultaneously. After this, Trunk immediately waits for all the hooks to exit before proceeding, except in the case of the `build` stage, described further below. + +## Trunk's build process +This is a brief overview of Trunk's build process for the purpose of describing when hooks are executed. Please note that the exact ordering may change in the future to add new features. + - Step 1 - Read and parse the HTML file. + - Step 2 - Produce a plan of all assets to be built. + - Step 3 - Build all assets in parallel. + - Step 4 - Finalize and write assets to staging directory. + - Step 5 - Write HTML to staging directory. + - Step 6 - Replace `dist` directory contents with staging directory contents. + +The hook stages correspond to this as follows: + - `pre_build`: takes place before step 1. + - `build`: takes place at the same time as step 2, executing in parallel with asset builds. + - `post_build`: takes place after step 5 and before step 6. + +## Hook Environment & Execution +All hooks are executed using the same `stdin` and `stdout` as trunk. The executable is expected to return an error code of `0` to indicate success. Any other code will be treated as an error and terminate the build process. Additionally, the following environment variables are provided to the process: + - `TRUNK_PROFILE`: the build profile in use. Currently either `debug` or `release`. + - `TRUNK_HTML_FILE`: the full path to the HTML file (typically `index.html` in `TRUNK_SOURCE_DIR`) used by trunk. + - `TRUNK_SOURCE_DIR`: the full path to the source directory in use by Trunk. This is always the directory in which `TRUNK_HTML_FILE` resides. + - `TRUNK_STAGING_DIR`: the full path of the Trunk staging directory. + - `TRUNK_DIST_DIR`: the full path of the Trunk dist directory. + - `TRUNK_PUBLIC_URL`: the configured public URL for Trunk. + # Auto-Reload As of `v0.14.0`, Trunk now ships with the ability to automatically reload your web app as the Trunk build pipeline completes. diff --git a/src/config/mod.rs b/src/config/mod.rs index cc6b4b61..e451c873 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -17,5 +17,5 @@ pub const DIST_DIR: &str = "dist"; pub const STAGE_DIR: &str = ".stage"; pub use manifest::CargoMetadata; -pub use models::{ConfigOpts, ConfigOptsBuild, ConfigOptsClean, ConfigOptsProxy, ConfigOptsServe, ConfigOptsTools, ConfigOptsWatch}; +pub use models::{ConfigOpts, ConfigOptsBuild, ConfigOptsClean, ConfigOptsHook, ConfigOptsProxy, ConfigOptsServe, ConfigOptsTools, ConfigOptsWatch}; pub use rt::{RtcBuild, RtcClean, RtcServe, RtcWatch}; diff --git a/src/config/models.rs b/src/config/models.rs index 868acc14..cedcc18c 100644 --- a/src/config/models.rs +++ b/src/config/models.rs @@ -9,6 +9,7 @@ use structopt::StructOpt; use crate::common::parse_public_url; use crate::config::{RtcBuild, RtcClean, RtcServe, RtcWatch}; +use crate::pipelines::PipelineStage; /// Config options for the build system. #[derive(Clone, Debug, Default, Deserialize, StructOpt)] @@ -110,6 +111,19 @@ pub struct ConfigOptsProxy { pub ws: bool, } +/// Config options for build system hooks. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct ConfigOptsHook { + /// The stage in the build process to execute this hook. + pub stage: PipelineStage, + /// The command to run for this hook. + pub command: String, + /// Any arguments to pass to the command. + #[serde(default)] + pub command_arguments: Vec, +} + /// Deserialize a Uri from a string. fn deserialize_uri<'de, D, T>(data: D) -> std::result::Result where @@ -131,6 +145,7 @@ pub struct ConfigOpts { pub clean: Option, pub tools: Option, pub proxy: Option>, + pub hooks: Option>, } impl ConfigOpts { @@ -140,7 +155,8 @@ impl ConfigOpts { let build_layer = Self::cli_opts_layer_build(cli_build, base_layer); let build_opts = build_layer.build.unwrap_or_default(); let tools_opts = build_layer.tools.unwrap_or_default(); - Ok(Arc::new(RtcBuild::new(build_opts, tools_opts, false)?)) + let hooks_opts = build_layer.hooks.unwrap_or_default(); + Ok(Arc::new(RtcBuild::new(build_opts, tools_opts, hooks_opts, false)?)) } /// Extract the runtime config for the watch system based on all config layers. @@ -151,7 +167,8 @@ impl ConfigOpts { let build_opts = watch_layer.build.unwrap_or_default(); let watch_opts = watch_layer.watch.unwrap_or_default(); let tools_opts = watch_layer.tools.unwrap_or_default(); - Ok(Arc::new(RtcWatch::new(build_opts, watch_opts, tools_opts, false)?)) + let hooks_opts = watch_layer.hooks.unwrap_or_default(); + Ok(Arc::new(RtcWatch::new(build_opts, watch_opts, tools_opts, hooks_opts, false)?)) } /// Extract the runtime config for the serve system based on all config layers. @@ -166,11 +183,13 @@ impl ConfigOpts { let watch_opts = serve_layer.watch.unwrap_or_default(); let serve_opts = serve_layer.serve.unwrap_or_default(); let tools_opts = serve_layer.tools.unwrap_or_default(); + let hooks_opts = serve_layer.hooks.unwrap_or_default(); Ok(Arc::new(RtcServe::new( build_opts, watch_opts, serve_opts, tools_opts, + hooks_opts, serve_layer.proxy, )?)) } @@ -202,6 +221,7 @@ impl ConfigOpts { clean: None, tools: None, proxy: None, + hooks: None, }; Self::merge(cfg_base, cfg_build) } @@ -215,6 +235,7 @@ impl ConfigOpts { clean: None, tools: None, proxy: None, + hooks: None, }; Self::merge(cfg_base, cfg) } @@ -235,6 +256,7 @@ impl ConfigOpts { clean: None, tools: None, proxy: None, + hooks: None, }; Self::merge(cfg_base, cfg) } @@ -248,6 +270,7 @@ impl ConfigOpts { clean: Some(opts), tools: None, proxy: None, + hooks: None, }; Self::merge(cfg_base, cfg) } @@ -330,6 +353,7 @@ impl ConfigOpts { clean: Some(clean), tools: None, proxy: None, + hooks: None, }) } @@ -403,6 +427,11 @@ impl ConfigOpts { (Some(val), None) | (None, Some(val)) => Some(val), (Some(_), Some(g)) => Some(g), // No meshing/merging. Only take the greater value. }; + greater.hooks = match (lesser.hooks.take(), greater.hooks.take()) { + (None, None) => None, + (Some(val), None) | (None, Some(val)) => Some(val), + (Some(_), Some(g)) => Some(g), // No meshing/merging. Only take the greater value. + }; greater } } diff --git a/src/config/rt.rs b/src/config/rt.rs index eeae0c76..8954fa0b 100644 --- a/src/config/rt.rs +++ b/src/config/rt.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use anyhow::{anyhow, Context, Result}; use http::Uri; -use crate::config::{ConfigOptsBuild, ConfigOptsClean, ConfigOptsProxy, ConfigOptsServe, ConfigOptsTools, ConfigOptsWatch}; +use crate::config::{ConfigOptsBuild, ConfigOptsClean, ConfigOptsHook, ConfigOptsProxy, ConfigOptsServe, ConfigOptsTools, ConfigOptsWatch}; /// Runtime config for the build system. #[derive(Clone, Debug)] @@ -23,6 +23,8 @@ pub struct RtcBuild { pub staging_dist: PathBuf, /// Configuration for automatic application download. pub tools: ConfigOptsTools, + /// Build process hooks + pub hooks: Vec, /// A bool indicating if the output HTML should have the WebSocket autoloader injected. /// /// This value is configured via the server config only. If the server is not being used, then @@ -32,7 +34,7 @@ pub struct RtcBuild { impl RtcBuild { /// Construct a new instance. - pub(super) fn new(opts: ConfigOptsBuild, tools: ConfigOptsTools, inject_autoloader: bool) -> Result { + pub(super) fn new(opts: ConfigOptsBuild, tools: ConfigOptsTools, hooks: Vec, inject_autoloader: bool) -> Result { // Get the canonical path to the target HTML file. let pre_target = opts.target.clone().unwrap_or_else(|| "index.html".into()); let target = pre_target @@ -66,6 +68,7 @@ impl RtcBuild { final_dist, public_url: opts.public_url.unwrap_or_else(|| "/".into()), tools, + hooks, inject_autoloader, }) } @@ -83,8 +86,8 @@ pub struct RtcWatch { } impl RtcWatch { - pub(super) fn new(build_opts: ConfigOptsBuild, opts: ConfigOptsWatch, tools: ConfigOptsTools, inject_autoloader: bool) -> Result { - let build = Arc::new(RtcBuild::new(build_opts, tools, inject_autoloader)?); + pub(super) fn new(build_opts: ConfigOptsBuild, opts: ConfigOptsWatch, tools: ConfigOptsTools, hooks: Vec, inject_autoloader: bool) -> Result { + let build = Arc::new(RtcBuild::new(build_opts, tools, hooks, inject_autoloader)?); // Take the canonical path of each of the specified watch targets. let mut paths = vec![]; @@ -142,10 +145,10 @@ pub struct RtcServe { impl RtcServe { pub(super) fn new( - build_opts: ConfigOptsBuild, watch_opts: ConfigOptsWatch, opts: ConfigOptsServe, tools: ConfigOptsTools, + build_opts: ConfigOptsBuild, watch_opts: ConfigOptsWatch, opts: ConfigOptsServe, tools: ConfigOptsTools, hooks: Vec, proxies: Option>, ) -> Result { - let watch = Arc::new(RtcWatch::new(build_opts, watch_opts, tools, !opts.no_autoreload)?); + let watch = Arc::new(RtcWatch::new(build_opts, watch_opts, tools, hooks, !opts.no_autoreload)?); Ok(Self { watch, port: opts.port.unwrap_or(8080), diff --git a/src/hooks.rs b/src/hooks.rs new file mode 100644 index 00000000..dfe2eb80 --- /dev/null +++ b/src/hooks.rs @@ -0,0 +1,65 @@ +use std::{process::Stdio, sync::Arc}; + +use anyhow::{bail, Context, Result}; +use futures::stream::FuturesUnordered; +use futures::StreamExt; +use tokio::process::Command; +use tokio::task::JoinHandle; + +use crate::config::RtcBuild; +use crate::pipelines::PipelineStage; + +/// A `FuturesUnordered` containing a `JoinHandle` for each hook-running task. +pub type HookHandles = FuturesUnordered>>; + +/// Spawns tokio tasks for all hooks configured for the given `HookStage`. +pub fn spawn_hooks(cfg: Arc, stage: PipelineStage) -> HookHandles { + tracing::info!(?stage, "spawning hooks for stage {:?}", stage); + let futures: FuturesUnordered<_> = cfg + .hooks + .iter() + .filter(|hook_cfg| hook_cfg.stage == stage) + .map(|hook_cfg| { + let mut command = Command::new(&hook_cfg.command); + command + .args(&hook_cfg.command_arguments) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .env("TRUNK_PROFILE", if cfg.release { "release" } else { "debug" }) + .env("TRUNK_HTML_FILE", &cfg.target) + .env("TRUNK_SOURCE_DIR", &cfg.target_parent) + .env("TRUNK_STAGING_DIR", &cfg.staging_dist) + .env("TRUNK_DIST_DIR", &cfg.final_dist) + .env("TRUNK_PUBLIC_URL", &cfg.public_url); + + tracing::info!(command_arguments = ?hook_cfg.command_arguments, "spawned hook {}", hook_cfg.command); + + let command_name = hook_cfg.command.clone(); + + tokio::spawn(async move { + let status = command + .spawn() + .with_context(|| format!("error spawning hook call for {}", command_name))? + .wait() + .await + .with_context(|| format!("error calling hook to {}", command_name))?; + if !status.success() { + bail!("hook call to {} returned a bad status", command_name); + } + tracing::info!("finished hook {}", command_name); + Ok(()) + }) + }) + .collect(); + + futures +} + +/// Waits for all of the given hooks to finish. +pub async fn wait_hooks(mut futures: HookHandles) -> Result<()> { + while let Some(result) = futures.next().await { + result??; + } + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 92e6ef2c..200a1c72 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod build; mod cmd; mod common; mod config; +mod hooks; mod pipelines; mod proxy; mod serve; diff --git a/src/pipelines/html.rs b/src/pipelines/html.rs index b3e69e34..4474b63c 100644 --- a/src/pipelines/html.rs +++ b/src/pipelines/html.rs @@ -12,8 +12,9 @@ use tokio::sync::mpsc; use tokio::task::JoinHandle; use crate::config::RtcBuild; +use crate::hooks::{spawn_hooks, wait_hooks}; use crate::pipelines::rust_app::RustApp; -use crate::pipelines::{LinkAttrs, TrunkLink, TrunkLinkPipelineOutput, TRUNK_ID}; +use crate::pipelines::{LinkAttrs, PipelineStage, TrunkLink, TrunkLinkPipelineOutput, TRUNK_ID}; const PUBLIC_URL_MARKER_ATTR: &str = "data-trunk-public-url"; const RELOAD_SCRIPT: &str = include_str!("../autoreload.js"); @@ -70,6 +71,9 @@ impl HtmlPipeline { async fn run(self: Arc) -> Result<()> { tracing::info!("spawning asset pipelines"); + // Spawn and wait on pre-build hooks. + wait_hooks(spawn_hooks(self.cfg.clone(), PipelineStage::PreBuild)).await?; + // Open the source HTML file for processing. let raw_html = fs::read_to_string(&self.target_html_path).await?; let mut target_html = Document::from(&raw_html); @@ -102,9 +106,16 @@ impl HtmlPipeline { // Spawn all asset pipelines. let mut pipelines: AssetPipelineHandles = FuturesUnordered::new(); pipelines.extend(assets.into_iter().map(|asset| asset.spawn())); + // Spawn all build hooks. + let build_hooks = spawn_hooks(self.cfg.clone(), PipelineStage::Build); // Finalize asset pipelines. self.finalize_asset_pipelines(&mut target_html, pipelines).await?; + + // Wait for all build hooks to finish. + wait_hooks(build_hooks).await?; + + // Finalize HTML. self.finalize_html(&mut target_html); // Assemble a new output index.html file. @@ -113,6 +124,9 @@ impl HtmlPipeline { .await .context("error writing finalized HTML output")?; + // Spawn and wait on post-build hooks. + wait_hooks(spawn_hooks(self.cfg.clone(), PipelineStage::PostBuild)).await?; + Ok(()) } diff --git a/src/pipelines/mod.rs b/src/pipelines/mod.rs index 0c4637f1..d67bd11f 100644 --- a/src/pipelines/mod.rs +++ b/src/pipelines/mod.rs @@ -15,6 +15,7 @@ use std::sync::Arc; use anyhow::{bail, ensure, Context, Result}; use nipper::Document; +use serde::Deserialize; use tokio::fs; use tokio::sync::mpsc; use tokio::task::JoinHandle; @@ -231,6 +232,20 @@ pub struct HashedFileOutput { file_name: String, } +/// A stage stage in the build process. +/// +/// This is used to specify when a hook will run. +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PipelineStage { + /// The stage before asset builds are executed. + PreBuild, + /// The stage where all asset builds are executed. + Build, + /// The stage after asset builds are executed. + PostBuild, +} + /// Create the CSS selector for selecting a trunk link by ID. pub(self) fn trunk_id_selector(id: usize) -> String { format!(r#"link[{}="{}"]"#, TRUNK_ID, id)