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} + + `, { 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 +};