gallic
v0.0.0
Published
Write components in plain TypeScript and TSX. gallic compiles them ahead of time to fine-grained reactive DOM updates. No virtual DOM, no custom file format, and **no framework imports inside components**. Labels like `$:`, `mount:`, `cleanup:`, and `untr
Readme
gallic
Write components in plain TypeScript and TSX. gallic compiles them ahead of time
to fine-grained reactive DOM updates. No virtual DOM, no custom file format, and
no framework imports inside components. Labels like $:, mount:,
cleanup:, and untrack: are the only directives.
export function Counter() {
let count = 0;
return <button onclick={() => count++}>count is {count}</button>;
}count becomes a signal because it is a let that is assigned and read in
JSX. Everything else stays a normal variable.
Install
bun add gallic
bun add -d vite @typescript/typescript6@typescript/typescript6 is required by the Vite plugin / compiler. Optional:
install typescript ≥ 7 in your app if you want native tsc typecheck
alongside tsc6.
tsconfig.json
{
"compilerOptions": {
"jsx": "preserve",
"allowUnusedLabels": true,
"types": ["gallic/jsx"],
"strict": true
}
}jsx: "preserve"— Vite + gallic compile the JSX; TypeScript does not emit a factory.allowUnusedLabels: true— silences stocktscunused-label noise for$:/mount:/cleanup:/untrack:. gallic still warns on typos likemout:.types: ["gallic/jsx"]— ambient JSX types only; nothing is imported at runtime.
vite.config.ts
import gallic from "gallic/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [gallic()],
});Entry point
Component files import nothing from gallica. The app entry is the one place that touches the runtime:
// main.ts
import { mount } from "gallic/runtime";
import { Counter } from "./Counter";
mount(Counter, document.getElementById("root")!);Language guide
State
A let or var becomes a signal when it is written after init and read in a
reactive place (JSX, $: block, or as a live prop). const never does.
export function Counter() {
let count = 0; // signal
const label = "count is"; // plain
return (
<button onclick={() => count++}>
{label} {count}
</button>
);
}Compound updates like count++ do not create a feedback loop from the embedded
read.
Derived values and effects ($:)
A $: that assigns to a binding is a derived value.
A $: with only side effects is an effect. One-liners are fine:
let doubled: number;
$: doubled = count * 2;
$: {
document.title = `count ${count}`;
}export function Temperature() {
let celsius = 20;
let fahrenheit: number;
$: fahrenheit = (celsius * 9) / 5 + 32;
$: {
document.title = `It's ${fahrenheit}°F`;
}
return (
<label>
<input
type="number"
value={celsius}
oninput={(e) => (celsius = e.currentTarget.valueAsNumber)}
/>
°C = {fahrenheit}°F
</label>
);
}Declare derived bindings outside (let fahrenheit: number), then assign in
$:. Labels cannot sit on let/const in JavaScript; use a block with
var if you want declaration and derive in one place:
$: { var doubled = count * 2; }.
Props and children
Props are a typed parameter. Destructuring stays live: reading count below
tracks the parent.
type BadgeProps = { count: number; children: JSX.Element | string };
function Badge(props: BadgeProps) {
const { count } = props;
return (
<span class="badge">
{props.children}
{count > 0 && <sup>{count}</sup>}
</span>
);
}
export function Inbox() {
let unread = 3;
return (
<button onclick={() => (unread = 0)}>
<Badge count={unread}>Inbox</Badge>
</button>
);
}Conditionals use ordinary JSX: {cond && <Node/>}, {a ? <A/> : <B/>}. Only
the active branch is mounted.
Lists
Map over data with a key. Same key reuses the row; changing the key remounts
it. Row fields that you read reactively update in place when the item changes
under a stable key.
type Todo = { id: string; text: string; done: boolean };
export function TodoApp() {
let todos: Todo[] = [];
let text = "";
let hideDone = false;
let visible: Todo[];
let remaining: number;
$: {
visible = hideDone ? todos.filter((t) => !t.done) : todos;
remaining = todos.filter((t) => !t.done).length;
}
function add(e: SubmitEvent) {
e.preventDefault();
if (!text.trim()) return;
todos = [...todos, { id: crypto.randomUUID(), text, done: false }];
text = "";
}
function toggle(id: string) {
todos = todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t));
}
return (
<main>
<form onsubmit={add}>
<input
value={text}
oninput={(e) => (text = e.currentTarget.value)}
/>
</form>
<label>
<input
type="checkbox"
checked={hideDone}
oninput={(e) => (hideDone = e.currentTarget.checked)}
/>
Hide completed
</label>
<ul>
{visible.map((todo) => (
<li key={todo.id} class={["todo", todo.done && "done"]}>
<input
type="checkbox"
checked={todo.done}
oninput={() => toggle(todo.id)}
/>
{todo.text}
</li>
))}
</ul>
{todos.length > 0 && <footer>{remaining} remaining</footer>}
</main>
);
}Lifecycle (mount:, cleanup:)
mount: runs after the component's DOM is connected. Nest cleanup: inside
mount: (or at component top level) for disposal.
ref={el} assigns the element when the template is wired — before $:
effects first run, and before mount:. So an effect can guard on the ref and
still subscribe to signals read inside the branch:
$: {
if (el) el.textContent = String(count);
}Use mount: for connect-time work (listeners, measuring layout, timers). Use
$: for reactive updates against a ref that already exists.
export function Clock() {
let now = new Date().toLocaleTimeString();
let el!: HTMLTimeElement;
mount: {
el.classList.add("ticking");
const id = setInterval(() => {
now = new Date().toLocaleTimeString();
}, 1000);
cleanup: {
clearInterval(id);
}
}
return <time ref={el}>{now}</time>;
}Untracked reads (untrack:)
Signal reads inside untrack: do not subscribe. Use it when you need the
current value without creating a dependency (for example, sampling state inside
an effect that should not re-run on that signal).
Compound updates like count++ already avoid self-subscription; you only need
the label for explicit non-tracking regions.
export function WatchA() {
let a = 0;
let b = 0;
$: {
console.log("a is", a);
untrack: {
// Reading `b` does not subscribe — this effect only re-runs when `a` changes.
console.log("b is", b);
}
}
return (
<>
<button onclick={() => a++}>a++</button>
<button onclick={() => b++}>b++</button>
</>
);
}Module-scope stores
A module-level let that is assigned anywhere in the module becomes a shared
signal. Import it from components with no special API.
// stores/theme.ts
export let theme: "light" | "dark" = "light";
export function toggleTheme() {
theme = theme === "light" ? "dark" : "light";
}// ThemeButton.tsx
import { theme, toggleTheme } from "./stores/theme";
export function ThemeButton() {
$: {
document.documentElement.setAttribute("data-theme", theme);
document.documentElement.style.colorScheme = theme;
}
return <button onclick={toggleTheme}>Theme: {theme}</button>;
}Class and style
class accepts strings, arrays, and objects (clsx-style). Falsy array entries
drop; object keys apply when truthy.
export function ClassConditionals() {
let primary = true;
let disabled = false;
return (
<button
class={[
"btn",
primary ? "btn-primary" : "btn-secondary",
disabled && "btn-disabled",
]}
onclick={() => (primary = !primary)}
>
variant: {primary ? "primary" : "secondary"}
</button>
);
}style accepts a string or an object. Object keys become per-property updates
(including custom properties):
export function Progress() {
let hue = 200;
let progress = 30;
return (
<div
style={{
color: "white",
background: `hsl(${hue}, 70%, 45%)`,
"--progress": `${progress}%`,
}}
/>
);
}Events
Lowercase DOM events on elements: onclick, oninput, onsubmit, and so on.
Handlers are plain functions; call preventDefault yourself when you need it.
Package exports
| Import | Role |
| --- | --- |
| gallic / gallic/runtime | mount, flushSync |
| gallic/vite | Vite plugin |
| gallic/jsx | Ambient JSX types |
Components import nothing from gallica. Call mount from your app entry.
