diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..2b26942
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,22 @@
+---
+name: Bug report
+about: Report a reproducible problem in Rebase
+title: "bug: "
+labels: bug
+---
+
+## What happened?
+
+## Reproduction
+
+```rebase
+
+```
+
+## Expected behavior
+
+## Environment
+
+- Rebase version:
+- Node version:
+- Browser:
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..51f5f23
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,16 @@
+---
+name: Feature request
+about: Suggest an improvement to Rebase
+title: "feat: "
+labels: enhancement
+---
+
+## Problem
+
+## Proposed API or syntax
+
+```rebase
+
+```
+
+## Alternatives considered
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..ad571d3
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,19 @@
+name: CI
+
+on:
+ push:
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - uses: actions/setup-node@v6
+ with:
+ node-version: 22
+ - run: npm test
+ - run: npm run build
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..db48afc
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,20 @@
+# AGENTS.md
+
+## Project model
+
+Rebase is a compiler-first JavaScript framework. Keep the compiler and runtime dependency-free and browser-oriented.
+
+## Structure
+
+- `packages/compiler` — .rebase parsing, code generation, and CLI
+- `packages/runtime` — reactive state, rendering, events, components, and routing
+- `packages/rebase` — public package entry point
+- `packages/*/test` — Node test suite
+
+## Development rules
+
+- Preserve ESM syntax.
+- Do not add a runtime dependency for functionality that can be implemented with platform APIs.
+- Keep generated JavaScript readable.
+- Add a regression test for compiler/runtime bugs.
+- Do not commit generated `dist` output.
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..84dd848
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,7 @@
+# Code of Conduct
+
+We expect contributors to be respectful, constructive, and inclusive. Harassment, discrimination, personal attacks, and deliberately disruptive behavior are not acceptable.
+
+Maintainers may remove comments, reject contributions, or restrict participation when behavior violates this code of conduct.
+
+For serious incidents, use the private contact method described in `SECURITY.md`.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..365f020
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,21 @@
+# Contributing to Rebase
+
+## Development
+
+Rebase requires a current Node.js release with ESM support.
+
+```bash
+npm test
+npm run build
+```
+
+## Pull requests
+
+- Keep changes focused.
+- Add tests for behavior changes and bug fixes.
+- Do not commit generated `dist` output.
+- Explain syntax or public API changes in the pull request.
+
+## Rebase syntax
+
+A `.rebase` file may use `
+ `);
+ assert.equal(result.template, "
{message}
");
+ assert.equal(result.style, ".app { color: red; }");
+ assert.match(result.code, /return \\{ message \\};/);
+});
+
+test("rewrites .rebase imports and custom components", () => {
+ const result = compile(`
+
+
+ `);
+ assert.match(result.code, /from "\\.\\/Card\\.js"/);
+ assert.match(result.code, /data-component=\\\"Card\\\"/);
+});
diff --git a/packages/runtime/package.json b/packages/runtime/package.json
new file mode 100644
index 0000000..b0e81e7
--- /dev/null
+++ b/packages/runtime/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "@rebasejs/runtime",
+ "version": "0.3.0",
+ "type": "module",
+ "exports": "./src/index.js"
+}
diff --git a/packages/runtime/src/index.js b/packages/runtime/src/index.js
index c8ab42c..af916e6 100644
--- a/packages/runtime/src/index.js
+++ b/packages/runtime/src/index.js
@@ -12,32 +12,25 @@ export function state(initialValue) {
if (Object.is(previous, value)) return;
subscribers.forEach(fn => fn(value, previous));
},
- subscribe(fn) {
- subscribers.add(fn);
- return () => subscribers.delete(fn);
- }
+ subscribe(fn) { subscribers.add(fn); return () => subscribers.delete(fn); }
};
}
export function computed(getter, dependencies = []) {
const result = state(getter());
- const stop = dependencies.map(dep => dep.subscribe(() => {
- result.value = getter();
- }));
- result.stop = () => stop.forEach(fn => fn());
+ const stops = dependencies.map(dep => dep.subscribe(() => { result.value = getter(); }));
+ result.stop = () => stops.forEach(stop => stop());
return result;
}
export function effect(fn, dependencies = []) {
fn();
- const stops = dependencies.map(dep => dep.subscribe(() => fn()));
+ const stops = dependencies.map(dep => dep.subscribe(fn));
return () => stops.forEach(stop => stop());
}
export function watch(source, callback) {
- if (source && typeof source.subscribe === "function") {
- return source.subscribe((value, previous) => callback(value, previous));
- }
+ if (source && typeof source.subscribe === "function") return source.subscribe(callback);
let previous = source();
return effect(() => {
const next = source();
@@ -50,102 +43,105 @@ export function watch(source, callback) {
}
export function component(definition = {}) {
- return Object.freeze({
- template: definition.template ?? "",
- style: definition.style ?? "",
- setup: definition.setup,
- ...definition
- });
+ return Object.freeze({ template: "", style: "", setup: undefined, ...definition });
}
-export function mount(componentDefinition, target) {
- const element = typeof target === "string"
- ? document.querySelector(target)
- : target;
+export function mount(definition, target) {
+ const element = typeof target === "string" ? document.querySelector(target) : target;
if (!element) throw new Error("Rebase: mount target not found.");
-
- const instance = typeof componentDefinition.setup === "function"
- ? componentDefinition.setup()
- : {};
- const cleanups = [];
- let mounted = false;
-
+ const instance = typeof definition.setup === "function" ? definition.setup() : {};
const render = () => {
- element.innerHTML = interpolate(componentDefinition.template, instance);
+ element.innerHTML = renderTemplate(definition.template, instance);
bindEvents(element, instance);
};
-
render();
-
- for (const value of Object.values(instance)) {
- if (value && typeof value.subscribe === "function") {
- cleanups.push(value.subscribe(render));
- }
- }
-
- if (componentDefinition.style) {
- const doc = element.ownerDocument;
- let style = doc.head.querySelector('style[data-rebase]');
- if (!style) {
- style = doc.createElement("style");
- style.dataset.rebase = "";
- doc.head.appendChild(style);
- }
- style.textContent += componentDefinition.style + "\n";
- }
-
- mounted = true;
- componentDefinition.onMount?.(instance);
-
+ const cleanups = Object.values(instance)
+ .filter(value => value && typeof value.subscribe === "function")
+ .map(value => value.subscribe(render));
+ if (definition.style) injectStyle(element.ownerDocument, definition.style);
+ definition.onMount?.(instance);
return {
- element,
- instance,
- update: render,
+ element, instance, update: render,
unmount() {
- if (!mounted) return;
- mounted = false;
cleanups.forEach(stop => stop());
- componentDefinition.onUnmount?.(instance);
+ definition.onUnmount?.(instance);
element.replaceChildren();
}
};
}
-export function createApp(componentDefinition) {
- let current = null;
+export function createApp(definition) {
+ let current;
return {
- mount(target) {
- current = mount(componentDefinition, target);
- return current;
- },
- unmount() {
- current?.unmount();
- current = null;
- }
+ mount(target) { current = mount(definition, target); return current; },
+ unmount() { current?.unmount(); current = undefined; }
};
}
+export function render(template, scope = {}) {
+ return renderTemplate(template, scope);
+}
+
export function html(strings, ...values) {
return strings.reduce((result, part, index) =>
result + part + (index < values.length ? String(unwrap(values[index])) : ""), "");
}
-function unwrap(value) {
- return value && value[stateMarker] ? value.value : value;
+function renderTemplate(template, scope) {
+ let output = resolveEach(template, scope);
+ output = resolveIf(output, scope);
+ output = interpolate(output, scope);
+ return output;
+}
+
+function resolveEach(template, scope) {
+ const pattern = /\{#each\s+(.+?)\s+as\s+([A-Za-z_$][\w$]*)(?:\s*,\s*([A-Za-z_$][\w$]*))?\s*\}([\s\S]*?)\{\/each\}/g;
+ return template.replace(pattern, (_, expression, itemName, indexName, body) => {
+ const collection = evaluate(expression, scope);
+ if (!collection || typeof collection[Symbol.iterator] !== "function") return "";
+ return [...collection].map((item, index) => {
+ const child = { ...scope, [itemName]: item };
+ if (indexName) child[indexName] = index;
+ return renderTemplate(body, child);
+ }).join("");
+ });
+}
+
+function resolveIf(template, scope) {
+ return template.replace(/\{#if\s+(.+?)\}([\s\S]*?)\{\/if\}/g, (_, first, body) => {
+ const parts = [];
+ let expression = first;
+ let cursor = 0;
+ const marker = /\{:(elif)\s+(.+?)\}|\{:else\}/g;
+ let match;
+ while ((match = marker.exec(body))) {
+ parts.push({ expression, content: body.slice(cursor, match.index) });
+ expression = match[1] === "elif" ? match[2] : null;
+ cursor = match.index + match[0].length;
+ if (expression === null) {
+ parts.push({ expression: null, content: body.slice(cursor) });
+ cursor = body.length;
+ break;
+ }
+ }
+ if (cursor < body.length) parts.push({ expression, content: body.slice(cursor) });
+ const selected = parts.find(part => part.expression === null || evaluate(part.expression, scope));
+ return selected ? renderTemplate(selected.content, scope) : "";
+ });
}
function interpolate(template, scope) {
- return template.replace(/\{\s*([A-Za-z_$][\w$]*)\s*\}/g, (_, key) =>
- escapeHtml(unwrap(scope[key]) ?? ""));
+ return template.replace(/\{\s*([^{}]+?)\s*\}/g, (_, expression) =>
+ /^[#:\/]/.test(expression) ? "{" + expression + "}" : escapeHtml(evaluate(expression, scope) ?? "")
+ );
}
-function escapeHtml(value) {
- return String(value)
- .replaceAll("&", "&")
- .replaceAll("<", "<")
- .replaceAll(">", ">")
- .replaceAll('"', """)
- .replaceAll("'", "'");
+function evaluate(expression, scope) {
+ try {
+ return Function("scope", "with (scope) { return (" + expression + "); }")(scope);
+ } catch {
+ return "";
+ }
}
function bindEvents(element, scope) {
@@ -153,11 +149,31 @@ function bindEvents(element, scope) {
for (const attribute of [...node.attributes]) {
if (!attribute.name.startsWith("on:")) continue;
const event = attribute.name.slice(3);
- const handler = scope[attribute.value];
+ const handler = attribute.value.replace(/^\{|\}$/g, "").trim();
node.removeAttribute(attribute.name);
- if (typeof handler === "function") node.addEventListener(event, handler);
+ node.addEventListener(event, eventObject => {
+ if (typeof scope[handler] === "function") scope[handler](eventObject);
+ else evaluate(handler, { ...scope, event: eventObject });
+ });
}
}
}
+function injectStyle(document, css) {
+ let style = document.head.querySelector("style[data-rebase]");
+ if (!style) {
+ style = document.createElement("style");
+ style.dataset.rebase = "";
+ document.head.appendChild(style);
+ }
+ if (!style.textContent.includes(css)) style.textContent += css + "\n";
+}
+
+function unwrap(value) { return value && value[stateMarker] ? value.value : value; }
+function escapeHtml(value) {
+ return String(unwrap(value))
+ .replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")
+ .replaceAll('"', """).replaceAll("'", "'");
+}
+
export { stateMarker };
diff --git a/packages/runtime/test/runtime.test.js b/packages/runtime/test/runtime.test.js
new file mode 100644
index 0000000..cfc8de1
--- /dev/null
+++ b/packages/runtime/test/runtime.test.js
@@ -0,0 +1,36 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { computed, render, state, watch } from "../src/index.js";
+
+test("state supports functional updates", () => {
+ const count = state(1);
+ count.value = value => value + 2;
+ assert.equal(count.value, 3);
+});
+
+test("computed follows dependencies", () => {
+ const count = state(2);
+ const doubled = computed(() => count.value * 2, [count]);
+ count.value = 4;
+ assert.equal(doubled.value, 8);
+ doubled.stop();
+});
+
+test("watch reports old and new values", () => {
+ const count = state(1);
+ let seen;
+ const stop = watch(count, (next, previous) => { seen = [next, previous]; });
+ count.value = 2;
+ stop();
+ assert.deepEqual(seen, [2, 1]);
+});
+
+test("render supports each and if/elif/else blocks", () => {
+ const output = render(`
+ {#if score > 10}high{:elif score > 0}low{:else}zero{/if}
+ {#each items as item, index}- {index}:{item}
{/each}
+ `, { score: 2, items: ["a", "b"] });
+ assert.match(output, /low<\\/b>/);
+ assert.match(output, /0:a<\\/li>/);
+ assert.match(output, /1:b<\\/li>/);
+});
diff --git a/rebase.config.js b/rebase.config.js
new file mode 100644
index 0000000..af9fb5f
--- /dev/null
+++ b/rebase.config.js
@@ -0,0 +1,5 @@
+export default {
+ root: "src",
+ outDir: "dist",
+ clean: true
+};