Packages
phoenix_live_view
1.1.4
1.2.7
1.2.6
1.2.5
1.2.4
1.2.3
1.2.2
1.2.1
1.2.0
1.2.0-rc.3
1.2.0-rc.2
1.2.0-rc.1
1.2.0-rc.0
1.1.32
1.1.31
1.1.30
1.1.29
1.1.28
1.1.27
1.1.26
1.1.25
1.1.24
1.1.23
1.1.22
1.1.21
1.1.20
1.1.19
1.1.18
1.1.17
1.1.16
1.1.15
1.1.14
1.1.13
1.1.12
1.1.11
1.1.10
1.1.9
1.1.8
1.1.7
1.1.6
retired
1.1.5
1.1.4
1.1.3
1.1.2
1.1.1
1.1.0
1.1.0-rc.4
1.1.0-rc.3
1.1.0-rc.2
1.1.0-rc.1
1.1.0-rc.0
1.0.18
1.0.17
1.0.16
1.0.15
1.0.14
1.0.13
1.0.12
1.0.11
1.0.10
1.0.9
1.0.8
retired
1.0.7
1.0.6
retired
1.0.5
1.0.4
1.0.3
1.0.2
1.0.1
1.0.0
1.0.0-rc.9
1.0.0-rc.8
1.0.0-rc.7
1.0.0-rc.6
1.0.0-rc.5
1.0.0-rc.4
1.0.0-rc.3
1.0.0-rc.2
1.0.0-rc.1
1.0.0-rc.0
0.20.17
0.20.16
0.20.15
0.20.14
0.20.13
0.20.12
0.20.11
0.20.10
0.20.9
0.20.8
0.20.7
0.20.6
0.20.5
0.20.4
0.20.3
0.20.2
0.20.1
0.20.0
0.19.5
0.19.4
0.19.3
0.19.2
0.19.1
0.19.0
0.18.18
0.18.17
0.18.16
0.18.15
0.18.14
0.18.13
0.18.12
0.18.11
0.18.10
0.18.9
0.18.8
0.18.7
0.18.6
0.18.5
0.18.4
0.18.3
0.18.2
0.18.1
0.18.0
0.17.14
0.17.13
0.17.12
0.17.11
0.17.10
0.17.9
0.17.8
0.17.7
0.17.6
0.17.5
0.17.4
0.17.3
0.17.2
0.17.1
0.17.0
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.7
0.15.6
0.15.5
0.15.4
0.15.3
0.15.2
0.15.1
0.15.0
0.14.8
0.14.7
0.14.6
0.14.5
0.14.4
0.14.3
0.14.2
0.14.1
0.14.0
0.13.3
0.13.2
0.13.1
0.13.0
0.12.1
0.12.0
0.11.1
0.11.0
0.10.0
0.9.0
0.8.1
0.8.0
0.7.1
0.7.0
0.6.0
0.6.0-dev
0.5.2
0.5.1
0.5.0
0.4.1
0.4.0
0.3.1
0.3.0
0.2.1
0.2.0
0.1.1
0.1.0
Rich, real-time user experiences with server-rendered HTML
Current section
Files
Jump to
Current section
Files
lib/phoenix_live_view/colocated_js.ex
defmodule Phoenix.LiveView.ColocatedJS do
@moduledoc """
A special HEEx `:type` that extracts any JavaScript code from a co-located
`<script>` tag at compile time.
Note: To use `ColocatedJS`, you need to run Phoenix 1.8+.
Colocated JavaScript is a more generalized version of `Phoenix.LiveView.ColocatedHook`.
In fact, colocated hooks are built on top of `ColocatedJS`.
You can use `ColocatedJS` to define any JavaScript code (Web Components, global event listeners, etc.)
that do not necessarily need the functionalities of hooks, for example:
```heex
<script :type={Phoenix.LiveView.ColocatedJS} name="MyWebComponent">
export default class MyWebComponent extends HTMLElement {
connectedCallback() {
this.innerHTML = "Hello, world!";
}
}
</script>
```
Then, in your `app.js` file, you could import it like this:
```javascript
import colocated from "phoenix-colocated/my_app";
customElements.define("my-web-component", colocated.MyWebComponent);
```
In this example, you don't actually need to have special code for the web component
inside your `app.js` file, since you could also directly call `customElements.define`
inside the colocated JavaScript. However, this example shows how you can access the
exported values inside your bundle.
> #### A note on dependencies and umbrella projects {: .info}
>
> For each application that uses colocated JavaScript, a separate directory is created
> inside the `phoenix-colocated` folder. This allows to have clear separation between
> hooks and code of dependencies, but also applications inside umbrella projects.
>
> While dependencies would typically still bundle their own hooks and colocated JavaScript
> into a separate file before publishing, simple hooks or code snippets that do not require
> access to third-party libraries can also be directly imported into your own bundle.
> If a library requires this, it should be stated in its documentation.
## Internals
While compiling the template, colocated JavaScript is extracted into a special folder inside the
`Mix.Project.build_path()`, called `phoenix-colocated`. This is customizable, as we'll see below,
but it is important that it is a directory that is not tracked by version control, because the
components are the source of truth for the code. Also, the directory is shared between applications
(this also applies to applications in umbrella projects), so it should typically also be a shared
directory not specific to a single application.
The colocated JS directory follows this structure:
```text
_build/$MIX_ENV/phoenix-colocated/
_build/$MIX_ENV/phoenix-colocated/my_app/
_build/$MIX_ENV/phoenix-colocated/my_app/index.js
_build/$MIX_ENV/phoenix-colocated/my_app/MyAppWeb.DemoLive/line_HASH.js
_build/$MIX_ENV/phoenix-colocated/my_dependency/MyDependency.Module/line_HASH.js
...
```
Each application has its own folder. Inside, each module also gets its own folder, which allows
us to track and clean up outdated code.
To use colocated JS from your `app.js`, your bundler needs to be configured to resolve the
`phoenix-colocated` folder. For new Phoenix applications, this configuration is already included
in the esbuild configuration inside `config.exs`:
config :esbuild,
...
my_app: [
args:
~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.),
cd: Path.expand("../assets", __DIR__),
env: %{
"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]
}
]
The important part here is the `NODE_PATH` environment variable, which tells esbuild to also look
for packages inside the `deps` folder, as well as the `Mix.Project.build_path()`, which resolves to
`_build/$MIX_ENV`. If you use a different bundler, you'll need to configure it accordingly. If it is not
possible to configure the `NODE_PATH`, you can also change the folder to which LiveView writes colocated
JavaScript by setting the `:target_directory` option in your `config.exs`:
```elixir
config :phoenix_live_view, :colocated_js,
target_directory: Path.expand("../assets/node_modules/phoenix-colocated", __DIR__)
```
An alternative approach could be to symlink the `phoenix-colocated` folder into your `node_modules`
folder.
> #### Tip {: .info}
>
> If you remove or modify the contents of the `:target_directory` folder, you can use
> `mix clean --all` and `mix compile` to regenerate all colocated JavaScript.
> #### Warning! {: .warning}
>
> LiveView assumes full ownership over the configured `:target_directory`. When
> compiling, it will **delete** any files and folders inside the `:target_directory`,
> that it does not associate with a colocated JavaScript module or manifest.
### Imports in colocated JS
The colocated JS files are fully handled by your bundler. For Phoenix apps, this is typically
`esbuild`. Because colocated JS is extracted to a folder outside the regular `assets` folder,
special care is necessary when you need to import other files inside the colocated JS:
```javascript
import { someFunction } from "some-dependency";
import somethingElse from "@/vendor/vendored-file";
```
While dependencies from `node_modules` should work out of the box, you cannot simply refer to your
`assets/vendor` folder using a relative path. Instead, your bundler needs to be configured to handle
an alias like `@` to resolve to your local `assets` folder. This is configured by default in the
esbuild configuration for new Phoenix 1.8 applications using `esbuild`'s [alias option](https://esbuild.github.io/api/#alias),
as can be seen in the config snippet above (`--alias=@=.`).
## Options
Colocated JavaScript can be configured through the attributes of the `<script>` tag.
The supported attributes are:
* `name` - The name under which the default export of the script is available when importing
the manifest. If omitted, the file will be imported for side effects only.
* `key` - A custom key to use for the export. This is used by `Phoenix.LiveView.ColocatedHook` to
export all hooks under the named `hooks` export (`export { ... as hooks }`).
For example, you could set this to `web_components` for each colocated script that defines
a web component and then import all of them as `import { web_components } from "phoenix-colocated/my_app"`.
Defaults to `:default`, which means the export will be available under the manifest's `default` export.
This needs to be a valid JavaScript identifier. When given, a `name` is required as well.
* `extension` - a custom extension to use when writing the extracted file. The default is `js`.
* `manifest` - a custom manifest file to use instead of the default `index.js`. For example,
`web_components.ts`. If you change the manifest, you will need to change the
path of your JavaScript imports accordingly.
"""
@behaviour Phoenix.Component.MacroComponent
alias Phoenix.Component.MacroComponent
@impl true
def transform({"script", attributes, [text_content], _tag_meta} = _ast, meta) do
validate_phx_version!()
opts = Map.new(attributes)
validate_name!(opts)
data = extract(opts, text_content, meta)
# we always drop colocated JS from the rendered output
{:ok, "", data}
end
def transform(_ast, _meta) do
raise ArgumentError, "ColocatedJS can only be used on script tags"
end
defp validate_phx_version! do
phoenix_version = to_string(Application.spec(:phoenix, :vsn))
if not Version.match?(phoenix_version, "~> 1.8.0-rc.4") do
# TODO: bump message to 1.8 once released to avoid confusion
raise ArgumentError, ~s|ColocatedJS requires at least {:phoenix, "~> 1.8.0-rc.4"}|
end
end
defp validate_name!(opts) do
case opts do
%{"name" => name} when is_binary(name) ->
:ok
%{"name" => name} ->
raise ArgumentError,
"the name attribute of a colocated script must be a compile-time string. Got: #{Macro.to_string(name)}"
%{"key" => _} ->
raise ArgumentError,
"a name is required when a key is given"
_ ->
:ok
end
end
@doc false
def extract(opts, text_content, meta) do
# _build/dev/phoenix-colocated/otp_app/MyApp.MyComponent/line_no.js
target_path =
target_dir()
|> Path.join(inspect(meta.env.module))
filename_opts =
%{name: opts["name"]}
|> maybe_put_opt(opts, "key", :key)
|> maybe_put_opt(opts, "manifest", :manifest)
hashed_name =
(filename_opts.name || text_content)
|> then(&:crypto.hash(:md5, &1))
|> Base.encode32(case: :lower, padding: false)
filename = "#{meta.env.line}_#{hashed_name}.#{opts["extension"] || "js"}"
File.mkdir_p!(target_path)
File.write!(Path.join(target_path, filename), text_content)
{filename, filename_opts}
end
defp maybe_put_opt(map, opts, opts_key, target_key) do
case opts do
%{^opts_key => value} ->
Map.put(map, target_key, value)
_ ->
map
end
end
@doc false
def compile do
# this step runs after all modules have been compiled
# so we can write the final manifests and remove outdated hooks
clear_manifests!()
files = clear_outdated_and_get_files!()
write_new_manifests!(files)
end
defp clear_manifests! do
target_dir = target_dir()
manifests =
Path.wildcard(Path.join(target_dir, "*"))
|> Enum.filter(&File.regular?(&1))
for manifest <- manifests, do: File.rm!(manifest)
end
defp clear_outdated_and_get_files! do
target_dir = target_dir()
modules = subdirectories(target_dir)
Enum.flat_map(modules, fn module_folder ->
module = Module.concat([Path.basename(module_folder)])
process_module(module_folder, module)
end)
end
defp process_module(module_folder, module) do
with true <- Code.ensure_loaded?(module),
data when data != [] <- get_data(module) do
expected_files = Enum.map(data, fn {filename, _opts} -> filename end)
files = File.ls!(module_folder)
outdated_files = files -- expected_files
for file <- outdated_files do
File.rm!(Path.join(module_folder, file))
end
Enum.map(data, fn {filename, config} ->
absolute_file_path = Path.join(module_folder, filename)
{absolute_file_path, config}
end)
else
_ ->
# either the module does not exist any more or
# does not have any colocated hooks / JS
File.rm_rf!(module_folder)
[]
end
end
defp get_data(module) do
hooks_data = MacroComponent.get_data(module, Phoenix.LiveView.ColocatedHook)
js_data = MacroComponent.get_data(module, Phoenix.LiveView.ColocatedJS)
hooks_data ++ js_data
end
defp write_new_manifests!(files) do
if files == [] do
File.mkdir_p!(target_dir())
File.write!(
Path.join(target_dir(), "index.js"),
"export const hooks = {};\nexport default {};"
)
else
files
|> Enum.group_by(fn {_file, config} ->
config[:manifest] || "index.js"
end)
|> Enum.each(fn {manifest, entries} ->
write_manifest(manifest, entries)
end)
end
end
defp write_manifest(manifest, entries) do
target_dir = target_dir()
content =
entries
|> Enum.group_by(fn {_file, config} -> config[:key] || :default end)
|> Enum.reduce(["const js = {}; export default js;\n"], fn group, acc ->
case group do
{:default, entries} ->
[
acc,
Enum.map(entries, fn
{file, %{name: nil}} ->
~s[import "./#{Path.relative_to(file, target_dir)}";\n]
{file, %{name: name}} ->
import_name =
"js_" <> Base.encode32(:crypto.hash(:md5, file), case: :lower, padding: false)
escaped_name = Phoenix.HTML.javascript_escape(name)
~s<import #{import_name} from "./#{Path.relative_to(file, target_dir)}"; js["#{escaped_name}"] = #{import_name};\n>
end)
]
{key, entries} ->
tmp_name = "imp_#{Base.encode32(key, case: :lower, padding: false)}"
[
acc,
~s<const #{tmp_name} = {}; export { #{tmp_name} as #{key} };\n>,
Enum.map(entries, fn
{file, %{name: nil}} ->
~s[import "./#{Path.relative_to(file, target_dir)}";\n]
{file, %{name: name}} ->
import_name =
"js_" <> Base.encode32(:crypto.hash(:md5, file), case: :lower, padding: false)
escaped_name = Phoenix.HTML.javascript_escape(name)
~s<import #{import_name} from "./#{Path.relative_to(file, target_dir)}"; #{tmp_name}["#{escaped_name}"] = #{import_name};\n>
end)
]
end
end)
File.write!(Path.join(target_dir, manifest), content)
end
defp settings do
Application.get_env(:phoenix_live_view, :colocated_js, [])
end
defp target_dir do
default = Path.join(Mix.Project.build_path(), "phoenix-colocated")
app = to_string(Mix.Project.config()[:app])
settings()
|> Keyword.get(:target_directory, default)
|> Path.join(app)
end
defp subdirectories(path) do
Path.wildcard(Path.join(path, "*")) |> Enum.filter(&File.dir?(&1))
end
end