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 `
-
-{title}
-
-
{message}
"); + assert.equal(result.style, ".app { color: red; }"); + assert.match(result.code, /return \{ message \};/); +}); + +test("rewrites .rebase imports and custom components", () => { + const result = compile("{#each items as item}{item}{/each}
{:else}no
{/if}"); + assert.match(result.template, /#each/); + assert.match(result.template, /#if/); +}); diff --git a/packages/rebase/index.js b/packages/rebase/index.js index 271297e..0724f7d 100644 --- a/packages/rebase/index.js +++ b/packages/rebase/index.js @@ -1,10 +1 @@ -export { - component, - mount, - createApp, - state, - computed, - effect, - watch, - html -} from "../runtime/src/index.js"; +export { component, mount, createApp, state, computed, effect, watch, render, html, createRouter } from "../runtime/src/index.js"; \ No newline at end of file diff --git a/packages/rebase/package.json b/packages/rebase/package.json index 289a46d..db83163 100644 --- a/packages/rebase/package.json +++ b/packages/rebase/package.json @@ -1,7 +1,7 @@ { "name": "rebase", - "version": "0.2.0", + "version": "0.3.0", "type": "module", "main": "./index.js", "exports": "./index.js" -} +} \ No newline at end of file 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..c42c336 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?.subscribe) return source.subscribe(callback); let previous = source(); return effect(() => { const next = source(); @@ -50,79 +43,78 @@ 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, components: {}, ...definition }); } -export function mount(componentDefinition, target) { - const element = typeof target === "string" - ? document.querySelector(target) - : target; +export function mount(definition, target, options = {}) { + 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(options.props ?? {}) : { ...(options.props ?? {}) }; + let childCleanups = []; const render = () => { - element.innerHTML = interpolate(componentDefinition.template, instance); - bindEvents(element, instance); + childCleanups.forEach(stop => stop()); + childCleanups = []; + const active = options.router?.match?.() ?? null; + const activeDefinition = active?.component ?? definition; + const activeInstance = activeDefinition === definition ? instance : + (typeof activeDefinition.setup === "function" ? activeDefinition.setup({ ...(options.props ?? {}), ...(active?.params ?? {}) }) : active?.params ?? {}); + element.innerHTML = renderTemplate(activeDefinition.template ?? "", activeInstance); + bindEvents(element, activeInstance); + mountChildren(element, activeDefinition.components ?? options.components ?? {}, activeInstance, childCleanups); + if (activeDefinition.style) injectStyle(element.ownerDocument, activeDefinition.style); }; render(); + const subscriptions = Object.values(instance).filter(value => value?.subscribe).map(value => value.subscribe(render)); + const routerStop = options.router?.subscribe?.(render); + if (routerStop) subscriptions.push(routerStop); + if (definition.style) injectStyle(element.ownerDocument, definition.style); + definition.onMount?.(instance); - for (const value of Object.values(instance)) { - if (value && typeof value.subscribe === "function") { - cleanups.push(value.subscribe(render)); + return { + element, instance, update: render, + unmount() { + subscriptions.forEach(stop => stop?.()); + childCleanups.forEach(stop => stop?.()); + definition.onUnmount?.(instance); + element.replaceChildren(); } - } + }; +} - 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); +function mountChildren(root, components, parentScope, cleanups) { + for (const node of root.querySelectorAll("rebase-component")) { + const name = node.dataset.component; + const child = components[name]; + if (!child) continue; + const props = {}; + for (const attr of [...node.attributes]) { + if (attr.name === "data-component") continue; + props[attr.name.replace(/^data-/, "")] = parseAttribute(attr.value, parentScope); } - style.textContent += componentDefinition.style + "\n"; + const placeholder = document.createElement("span"); + node.replaceWith(placeholder); + const mounted = mount(child, placeholder, { props, components }); + cleanups.push(() => mounted.unmount()); } +} - mounted = true; - componentDefinition.onMount?.(instance); +function parseAttribute(value, scope) { + const expression = value.match(/^\{([\s\S]*)\}$/); + return expression ? evaluate(expression[1], scope) : value; +} +export function createApp(definition, options = {}) { + let current; return { - element, - instance, - update: render, - unmount() { - if (!mounted) return; - mounted = false; - cleanups.forEach(stop => stop()); - componentDefinition.onUnmount?.(instance); - element.replaceChildren(); - } + mount(target) { current = mount(definition, target, options); return current; }, + unmount() { current?.unmount(); current = undefined; } }; } -export function createApp(componentDefinition) { - let current = null; - return { - mount(target) { - current = mount(componentDefinition, target); - return current; - }, - unmount() { - current?.unmount(); - current = null; - } - }; +export function render(template, scope = {}) { + return renderTemplate(template, scope); } export function html(strings, ...values) { @@ -130,22 +122,83 @@ export function html(strings, ...values) { result + part + (index < values.length ? String(unwrap(values[index])) : ""), ""); } -function unwrap(value) { - return value && value[stateMarker] ? value.value : value; +export function createRouter(routes = {}, options = {}) { + const browser = typeof window !== "undefined"; + let currentPath = normalizePath(options.initial ?? (browser ? window.location.pathname : "/")); + const subscribers = new Set(); + const router = { + get path() { return currentPath; }, + match() { + for (const [pattern, componentDefinition] of Object.entries(routes)) { + const params = matchPath(pattern, currentPath); + if (params) return { component: componentDefinition, params }; + } + return null; + }, + navigate(path) { + const next = normalizePath(path); + if (next === currentPath) return; + if (browser) history.pushState({}, "", next); + currentPath = next; + subscribers.forEach(fn => fn(router)); + }, + subscribe(fn) { subscribers.add(fn); return () => subscribers.delete(fn); }, + destroy() { if (browser) removeEventListener("popstate", pop); subscribers.clear(); } + }; + function pop() { + currentPath = normalizePath(browser ? window.location.pathname : currentPath); + subscribers.forEach(fn => fn(router)); + } + if (browser) addEventListener("popstate", pop); + return router; +} + +function renderTemplate(template, scope) { + let output = resolveEach(template, scope); + output = resolveIf(output, scope); + return interpolate(output, scope); +} + +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 = unwrap(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, 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 +206,45 @@ 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 }); + }); } } } -export { stateMarker }; +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 matchPath(pattern, pathname) { + const names = []; + const regex = pattern.split("/").filter(Boolean).map(part => { + if (part.startsWith("[") && part.endsWith("]")) { + names.push(part.slice(1, -1)); return "([^/]+)"; + } + if (part === "*") { names.push("wildcard"); return "(.*)"; } + return escapeRegex(part); + }).join("/"); + const match = new RegExp("^/" + regex + "/?$").exec(pathname); + if (!match) return null; + return Object.fromEntries(names.map((name, i) => [name, decodeURIComponent(match[i + 1])])); +} + +function normalizePath(value) { + return value.replace(/\/{2,}/g, "/").replace(/\/$/, "") || "/"; +} +function escapeRegex(value) { return value.replace(/[.*+?^()|[\]\\]/g, "\\$&"); } +function unwrap(value) { return value?.[stateMarker] ? value.value : value; } +function escapeHtml(value) { + return String(unwrap(value)).replaceAll("&","&").replaceAll("<","<").replaceAll(">",">").replaceAll('"',""").replaceAll("'","'"); +} diff --git a/packages/runtime/test/router.test.js b/packages/runtime/test/router.test.js new file mode 100644 index 0000000..5adb943 --- /dev/null +++ b/packages/runtime/test/router.test.js @@ -0,0 +1,12 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createRouter } from "../src/index.js"; + +test("router matches static and dynamic routes", () => { + const router = createRouter({ + "/": {}, + "/blog/[slug]": {} + }, { initial: "/blog/hello" }); + assert.deepEqual(router.match().params, { slug: "hello" }); + router.destroy(); +}); diff --git a/packages/runtime/test/runtime.test.js b/packages/runtime/test/runtime.test.js new file mode 100644 index 0000000..18d9ea6 --- /dev/null +++ b/packages/runtime/test/runtime.test.js @@ -0,0 +1,27 @@ +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", () => { + const output = render("{#if score > 10}high{:elif score > 0}low{:else}zero{/if}