@nseaprotector/acme-script
v0.4.0
Published
Elixir-flavored JS helpers for LiveView hooks and .heex templates
Maintainers
Readme
AcmeScript
Client-side JavaScript for Elixir/Phoenix projects. Brings helpers close to Elixir
idioms (pipe, ok/error, match, with, Enum, immutable access on nested maps)
to simplify writing LiveView hooks and .heex templates.
No build step required: native ES modules, importable directly in the browser or
through your Phoenix asset bundler (esbuild/vite). Ships hand-written .d.ts
typings (acmescript.d.ts, hooks/index.d.ts), so plain JS consumers get
autocomplete/IDE hints with no extra setup.
pnpm add @nseaprotector/acme-scriptimport { pipe, ok, error, match, cond, unless, inspect, H, J, find, show, hide,
createHook, createLiveComponent, Enum, getIn, putIn, updateIn,
PubSub, withDo } from "./acmescript.js";Structure
acmescript.js public entry point (re-exports src/index.js)
acmescript.d.ts typings for the entry point (hand-written, checked via `pnpm typecheck`)
src/
core.js pipe, ok, error, match, cond, unless, inspect
sigils.js H (HTML template), J (safe JSON)
dom.js find (fluent DOM selection)
transitions.js transition, show, hide
hook.js createHook (LiveView hook wrapper)
live_component.js createLiveComponent (stateful custom element)
enum.js Enum (map/filter/reduce/... Elixir-style)
access.js getIn, putIn, updateIn (immutable access)
pubsub.js PubSub (client-side, independent of the server)
with.js withDo (Elixir `with`-style chaining)
hooks/ classic LiveView hooks built on top of the lib, see below
demo/ a Phoenix app demoing every hook, see belowAPI
pipe(value, ...fns)
Chains unary functions, |>-style.
pipe(5, (x) => x + 1, (x) => x * 2); // 12ok(data) / error(err) / match(result, clauses)
Typed result {ok, data, error} (also iterable as [bool, data]), {:ok, _} / {:error, _}-style.
const result = ok({ id: 1 });
match(result, {
ok: (data) => console.log("found", data),
error: (err) => console.error(err)
});cond(pairs)
Evaluates [test, resultOrFn] pairs in order, cond do block-style. resultOrFn can
be a plain value or a zero-arg function (called lazily, only for the matching branch).
cond([
[score > 90, "A"],
[score > 70, () => computeGrade(score)],
[true, "F"]
]);Two differences from Elixir's
cond do:pairsis a plain array, so every test expression is evaluated eagerly beforecond()runs (only the matching branch is lazy). And because a function branch is called rather than returned, wrap it (() => myFn) if you actually need the function itself as the result.
unless(test, branch)
Runs branch when test is falsy, the inverse of if.
unless(user.isActive, () => deactivate(user));inspect(label)
IO.inspect-style: logs the value (with an optional label) and passes it through
unchanged. Meant to be dropped into a pipe chain for debugging.
pipe(input, inspect("before"), transform, inspect("after"));H`...` — HTML sigil
Parses a template literal into an HTMLElement or DocumentFragment.
const el = H`<div class="card">${title}</div>`;
document.body.append(el);J`...` — safe JSON sigil
Parses a JSON template literal, returns ok/error (never throws).
const [success, data] = J`${jsonString}`;
if (success) render(data);find(selector, parent = document)
Selects an element and returns a fluent wrapper (ok/error + chainable methods).
find("#modal")
.addClass("open")
.attr("aria-hidden", "false")
.on("click", (e) => console.log(e));transition(target, opts) / show(target, opts) / hide(target, opts)
Class-based CSS transitions (Tailwind-compatible), inspired by Phoenix LiveView's JS.show/hide.
show("#modal");
hide("#modal", { duration: 200 });createHook(spec)
LiveView hook wrapper: injects a ctx (push, pushTo, handle, upload) into mounted/updated/destroyed.
export default createHook({
mounted(ctx) {
ctx.handle("refresh", (payload) => this.el.textContent = payload.value);
ctx.push("ready");
}
});createLiveComponent({ mount, handleEvent, render })
Standalone custom element with local state, hydrated from phx-state.
customElements.define("acme-counter", createLiveComponent({
mount: (state) => ({ count: state.count ?? 0 }),
handleEvent: {
inc: (state) => ({ count: state.count + 1 })
},
render: (state, send) => H`<button onclick="${() => send("inc")}">${state.count}</button>`
}));Enum
List transformation pipeline, Elixir Enum-style. Each function returns a
(list) => list (or (list) => value) transformer, to compose with pipe.
map, filter, reject, reduce, take, chunkEvery, uniq, sort, each,
any, all, count, find, groupBy, sum.
pipe(
users,
Enum.filter((u) => u.active),
Enum.map((u) => u.name),
Enum.uniq(),
Enum.take(10)
);
Enum.groupBy((u) => u.role)(users); // { admin: [...], user: [...] }
Enum.count((u) => u.active)(users); // 3getIn(obj, path, default) / putIn(obj, path, val) / updateIn(obj, path, fn)
Immutable read/write on nested objects, Kernel.get_in/put_in/update_in-style.
const state = { user: { profile: { name: "Ada" } } };
getIn(state, ["user", "profile", "name"]); // "Ada"
putIn(state, ["user", "profile", "name"], "Grace"); // new object
updateIn(state, ["user", "profile", "name"], (n) => n.toUpperCase());PubSub
Client-side pub/sub, independent of the server-side Phoenix.PubSub — useful for
letting hooks/components on the page talk to each other.
const unsubscribe = PubSub.subscribe("cart:updated", (payload) => render(payload));
PubSub.broadcast("cart:updated", { count: 3 });
unsubscribe();withDo(...steps)
Chains steps that return ok(data)/error(err), short-circuits on the first error,
Elixir with-style. Each ok(data).data is merged into the accumulated context.
withDo(
() => validateForm(input) ? ok({ input }) : error("invalid"),
(ctx) => saveToServer(ctx.input)
);Examples
See examples/ for complete use cases (LiveView hook, live component,
functional composition).
Classic hooks
hooks/ is a small cookbook of common LiveView hooks built with
createHook and the rest of the lib: CopyToClipboard, AutoResize,
ClickOutside, InfiniteScroll, Reorderable, LocalStorageSync, Hotkey,
ScrollRestore, ContextMenu, TagsInput, Dropzone. They ship as a package subpath so
you don't need to copy-paste them:
import { CopyToClipboard, ClickOutside } from "@nseaprotector/acme-script/hooks";
const liveSocket = new LiveSocket("/live", Socket, {
hooks: { CopyToClipboard, ClickOutside }
});Each hook reads its config from data-* attributes — see the comment at the
top of its source file for the expected markup, or the demo below for a
working example of every one.
Demo
demo/ is a minimal Phoenix app with one LiveView page per hook
(mix phx.new --no-ecto --no-mailer), wired to the local lib via a pnpm
workspace so it always tracks the current source, no publish needed:
pnpm install # from the repo root, links demo/assets to the lib
cd demo
mix setup
mix phx.serverThen open localhost:4000 for the index of every hook demo.
Tests
pnpm testSee test/ (vitest + jsdom, one file per module in src/).
Typings
pnpm typecheckChecks acmescript.d.ts and hooks/index.d.ts against
typecheck/acmescript.test-d.ts, a file that
exercises the full public API — the typings equivalent of the test suite, catching
declarations that drift from actual usage.
Minified build
pnpm buildGenerates dist/acmescript.min.js (ESM, ~3.3kb minified) via esbuild. Not versioned,
regenerate it when deploying Phoenix assets.
