Can you minimize Javascript inside a heex template?

I’m experimenting with different ways to incorporate a bit of Javascript into some of my pages. (Not using LiveView.)

Consider the following heex template:

<div>
  <button>MyButton</button>
</div>

<script>
  // Some Javascript goes in here....
</script>

Is there anywhere in the “build process” that I could minimize the Javascript that is within the script tags?

We’re aware we can put code in app.js, and even import other files into app.js, but we’re just curious if there’s a way to inline it like we have. For some pages, we’re only sprinkling a very small amount of Javascript and would rather keep that Javascript code as close to where it’s being used.

Discussions headed down the path of whether the script tag could be a functional component that, at compile time, could take the string and “call out” to something like esbuild for minification, though we have no idea if that’s possible within the Elixir build steps.

The goal here is to see if there’s any merrit in a design pattern that is mostly heex, but with a bit of JS “inlined” in the heex templates, rather than having to fallback to using Javascript files and imports / included.

I personally can’t think of any sane way to do this.

You can use esbuild to minify like so:

echo "const foo = bar -> bar * 2" | esbuild --minify

You would have to use System.shell to execute this and do the proper escaping and be very careful as that function is prone to injections.

However you can’t use render_slot outside of of ~H. I believe you can do this in Surface since it supports macro components (hopefully HEEx gets them one day as I would love a <.markdown /> component). But I don’t think there is any sane way to do this in HEEx.

I take no issue with your colocated script tag experiment but I do have to be “that guy” and ask: why do you care about minification if you are only sprinkling in a little bit of JS here and there? I can’t imagine it making a meaningful difference. Minification gets the big wins when the majority of your app is written in client-side JS along with its pit of dispair node_modules directory.

Yeah, I knew that would brought up… :wink:

You’re right that it’s likely to be irrelevant with regards to size. More of an “obfuscation” type of thing to prevent internal comments from potentially leaking out into public code.

Hmm, it should be possible.
Write a task that can be run like “mix test” or “mix ecto.setup”
That task will scan all files for some custom pattern (you can put some text in a script tag like x-inline-minify) and will take that code, minify it, and replace it.

What if we slightly relaxed our requirement of it being entirely inline and instead did something like a “Javascript sidecar” file.

Template:

form.html.heex

<div>
  <button>Hello World</button>
</div>

<.inline_js 'form.js' />

Sidecar:

form.js

function helloWorld() { 
  console.log("Hello World");
}

And then have some custom variant of embed_templates that looks for the JS sidecar files and creates functional components out of them?

And yes, this is getting a bit into the weeds, so consider this more a discussion of what’s possible and not necessarily what’s best-practice.

2 Likes

And I knew that you knew that I knew that you knew that I… wait what? :upside_down_face:

Hey, that’s a good reason!

I’m too tired to suggest any of the truly awful ideas I have around this :sweat_smile: @regex.sh’s suggestion isn’t bad, though. It sounds more and more like you may just want Surface. It already has collocated JS hooks.

Inspired by this blog post showing how to compile Markdown formatted blog posts into a Module, we came up with the following for minifying Javascript. Curious if there are obvious ways we could improve this as we’re not Elixir experts.

Minification is done by the Terser library and installed as a node module inside our assets folder.

Our HTML Module:

defmodule DemoWeb.RecordsHTML do
  use DemoWeb, :html

  @inline_js Path.expand(__DIR__)
             |> Path.join("templates_html/**/*.js")
             |> Path.wildcard()
             |> Enum.into(%{}, fn file ->
               {output, _} =
                 System.cmd(
                   "npx",
                   ["terser", "--toplevel", "--compress", "--mangle", "--", file],
                   cd: Path.join(File.cwd!(), "assets")
                 )

               {Path.basename(file), output}
             end)

  embed_templates "templates_html/*"

  @doc """
  embed_js
  """
  attr :filename, :string, doc: "The filename of the Javascript file to embed."

  def embed_js(assigns) do
    assigns = assign(assigns, files: @inline_js)

    ~H"""
    <script>
      <%= raw(Map.fetch!(@files, @filename)) %>
    </script>
    """
  end
end

A HEEX template:

<div>
  <div>Something that needs JS</div>
</div>

<.embed_js filename="form.js" />

Embedding JS with the script tag directly in HEEX template was problematic. Basic code formatting wasn’t working properly, for example. Using a JS “sidecar” file could be an acceptable trade-off.

Questions:

  • Are there ways we could convert this to a Macro along the lines of embed_templates?
  • Can we clean up the existing block of code for @inline_js in anyway?

Naturally, we might want to embed local Javascript files from other Modules.

3 Likes

Since you’ve started writing JS in a separate file anyways, have you considered wrapping your heex template into a custom element? You’ll get not only minification, but caching for free (well, at the cost of an extra initial HTTP request).

Not entirely sure I follow you. Our rendered HTML is standard heex templates that may include various function components.

Our primary desire for JS integration is to either “progressively enhance” some pages, or to write very lightweight “reactive” components.

For anyone coming across this in the future, we had fun building a working prototype that came close to achieving our original idea with the caveat that the JavaScript was in a sidecar file.

But in the end, it became obvious that the obscurity of such an implementation just wasn’t worth it.

Our JavaScript is now where it should be and our heex templates now just invoke a single line of inline JavaScript to pass a DOM element to an “enhancing” function, somewhat akin to how JS frameworks render a root component into a given element.

1 Like

Here’s an example to illustrate what I was suggesting in my previous comment.

Create a heex component wrapped into a custom element <my-button>:

attr :name, :string, required: true

def my_button(assigns) do
  ~H"""
  <my-button name={@name}>
    <button>Click me</button>
    <p>Will change</p>
  </my-button>
  """
end

Define this element somewhere in assets/scripts/custom_elements/my_button.js:

class MyButton extends HTMLElement {
	get name() {
		return this.getAttribute("name");
	}

	connectedCallback() {
		this.button = this.querySelector("button");
		this.paragraph = this.querySelector("p");

		this.button.addEventListener("click", () => {
			this.paragraph.textContent = `Hello ${this.name}`;
		});
	}
}

customElements.define("my-button", MyButton);

Load this script in layouts/root.html.heex:

<script type="module" src={~p"/scripts/custom_elements/my_button.js"}></script>

If you want to make your components truly isolated Declarative Shadow DOM is now widely supported.

Ah, Web Components. Definitely an option for larger components, especially those that might be re-used.

The initial goal was to not have a separate JS file and just write the JS inside a script tag within the heex template. This was somewhat inspired by how a .svelte file is structured.