diff --git a/README.md b/README.md index 3c34fc9..18dfe90 100644 --- a/README.md +++ b/README.md @@ -21,3 +21,6 @@ https://rebase.js.org ## License MIT + + +Rebase 0.3 includes reactive blocks, reusable components, and built-in routing. diff --git a/Roadmap.md b/Roadmap.md index c817ab9..1dcf958 100644 --- a/Roadmap.md +++ b/Roadmap.md @@ -1,13 +1,13 @@ -# Roadmap +# Rebase roadmap -- [ ] Add `{#each}` -- [ ] Add `{#if}` and `{:else}` and `{:elif}` -- [ ] Rebase should update its content automatically, a bit like Svelte -- [ ] Routing (such as SvelteKit or Vite) is built-in, without any more installations -- [ ] we can save components via `.rebase` files to reuse them like `` -- [ ] Also, routing and paging should be built-in so we can go to different (clean format) URLs -- [ ] `rebase.config.js` -- [ ] `on:` activator, like `on:click`, `on:change`, `on:...` -- [ ] Variables inside the code, using `{VARIABLENAME}` -- [ ] the good old structure form Vue, so first theres ``, then `` and lastly `` (you can also switch style and template if you want) -- [ ] Dynamic Content Loading using `[slug]` or so like in svelte, for blogs +- [x] Add `{#each}` +- [x] Add `{#if}`, `{:else}`, `{:elif}` +- [x] Automatic reactive updates +- [x] Built-in routing +- [x] Reusable `.rebase` components +- [x] Clean URL routing and dynamic `[slug]` parameters +- [x] `rebase.config.js` +- [x] `on:` event activators +- [x] `{VARIABLE}` interpolation +- [x] Vue-style `script/template/style` sections in any order +- [x] Dynamic route parameters diff --git a/docs/404.html b/docs/404.html deleted file mode 100644 index 1657cc1..0000000 --- a/docs/404.html +++ /dev/null @@ -1 +0,0 @@ -Rebase — Not found

404

This Rebase page does not exist.

Back to documentation \ No newline at end of file diff --git a/docs/api.md b/docs/api.md deleted file mode 100644 index 23d87e3..0000000 --- a/docs/api.md +++ /dev/null @@ -1,157 +0,0 @@ -# Rebase API - -Rebase is an experimental compiler-first JavaScript framework. This page lists the current public API in the prototype. - -## \`component(definition)\` - -Creates an immutable component definition. - -\`\`\`js -import { component } from "rebase"; - -const Button = component({ - template: "", - style: "button { padding: 8px; }" -}); -\`\`\` - -## \`mount(component, target)\` - -Mounts a component into a CSS selector or DOM element. - -\`\`\`js -import { mount } from "rebase"; - -const app = mount(App, "#app"); -app.update(); -app.unmount(); -\`\`\` - -## \`createApp(component)\` - -Creates a small app controller. - -\`\`\`js -const app = createApp(App); -const instance = app.mount("#app"); -app.unmount(); -\`\`\` - -## \`state(initialValue)\` - -Creates reactive state. - -\`\`\`js -const count = state(0); - -count.value++; -count.subscribe((value, previous) => { - console.log(value, previous); -}); -\`\`\` - -## \`computed(getter, dependencies)\` - -Creates derived reactive state. - -\`\`\`js -const total = computed( - () => price.value * quantity.value, - [price, quantity] -); - -console.log(total.value); -\`\`\` - -## \`effect(fn, dependencies)\` - -Runs a function immediately and again when dependencies change. - -\`\`\`js -const stop = effect( - () => console.log(count.value), - [count] -); - -stop(); -\`\`\` - -## \`watch(source, callback)\` - -Observes reactive state or a getter. - -\`\`\`js -const stop = watch(count, (value, previous) => { - console.log("changed", previous, "→", value); -}); - -stop(); -\`\`\` - -## \`html(strings, ...values)\` - -Builds an HTML string from template literals. - -\`\`\`js -const name = "Rebase"; -const markup = html\`

Hello \${name}

\`; -\`\`\` - -## \`.rebase\` single-file components - -Components combine JavaScript, markup and CSS: - -\`\`\`html - - -
-

{title}

- -
- - -\`\`\` - -The compiler extracts \` - `); +test("compiles sections in any order", () => { + const result = compile(""); assert.equal(result.template, "

{message}

"); assert.equal(result.style, ".app { color: red; }"); - assert.match(result.code, /return \\{ message \\};/); + 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\\\"/); + const result = compile(""); + assert.match(result.code, /from \"\.\/Card\.js\"/); + assert.match(result.code, /data-component=\"Card\"/); +}); + +test("keeps each/if syntax for the runtime", () => { + const result = compile(""); + 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/src/index.js b/packages/runtime/src/index.js index af916e6..c42c336 100644 --- a/packages/runtime/src/index.js +++ b/packages/runtime/src/index.js @@ -30,7 +30,7 @@ export function effect(fn, dependencies = []) { } export function watch(source, callback) { - if (source && typeof source.subscribe === "function") return source.subscribe(callback); + if (source?.subscribe) return source.subscribe(callback); let previous = source(); return effect(() => { const next = source(); @@ -43,37 +43,72 @@ export function watch(source, callback) { } export function component(definition = {}) { - return Object.freeze({ template: "", style: "", setup: undefined, ...definition }); + return Object.freeze({ template: "", style: "", setup: undefined, components: {}, ...definition }); } -export function mount(definition, 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 definition.setup === "function" ? definition.setup() : {}; + const instance = typeof definition.setup === "function" ? definition.setup(options.props ?? {}) : { ...(options.props ?? {}) }; + let childCleanups = []; + const render = () => { - element.innerHTML = renderTemplate(definition.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 cleanups = Object.values(instance) - .filter(value => value && typeof value.subscribe === "function") - .map(value => value.subscribe(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); + return { element, instance, update: render, unmount() { - cleanups.forEach(stop => stop()); + subscriptions.forEach(stop => stop?.()); + childCleanups.forEach(stop => stop?.()); definition.onUnmount?.(instance); element.replaceChildren(); } }; } -export function createApp(definition) { +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); + } + const placeholder = document.createElement("span"); + node.replaceWith(placeholder); + const mounted = mount(child, placeholder, { props, components }); + cleanups.push(() => mounted.unmount()); + } +} + +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 { - mount(target) { current = mount(definition, target); return current; }, + mount(target) { current = mount(definition, target, options); return current; }, unmount() { current?.unmount(); current = undefined; } }; } @@ -87,17 +122,47 @@ export function html(strings, ...values) { result + part + (index < values.length ? String(unwrap(values[index])) : ""), ""); } +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); - output = interpolate(output, scope); - return output; + 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 = evaluate(expression, scope); + const collection = unwrap(evaluate(expression, scope)); if (!collection || typeof collection[Symbol.iterator] !== "function") return ""; return [...collection].map((item, index) => { const child = { ...scope, [itemName]: item }; @@ -110,19 +175,14 @@ function resolveEach(template, scope) { function resolveIf(template, scope) { return template.replace(/\{#if\s+(.+?)\}([\s\S]*?)\{\/if\}/g, (_, first, body) => { const parts = []; - let expression = first; - let cursor = 0; + 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 (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)); @@ -137,11 +197,8 @@ function interpolate(template, scope) { } function evaluate(expression, scope) { - try { - return Function("scope", "with (scope) { return (" + expression + "); }")(scope); - } catch { - return ""; - } + try { return Function("scope", "with (scope) { return (" + expression + "); }")(scope); } + catch { return ""; } } function bindEvents(element, scope) { @@ -169,11 +226,25 @@ function injectStyle(document, css) { 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("'", "'"); +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])])); } -export { stateMarker }; +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 index cfc8de1..18d9ea6 100644 --- a/packages/runtime/test/runtime.test.js +++ b/packages/runtime/test/runtime.test.js @@ -7,7 +7,6 @@ test("state supports functional updates", () => { 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]); @@ -15,22 +14,14 @@ test("computed follows dependencies", () => { assert.equal(doubled.value, 8); doubled.stop(); }); - test("watch reports old and new values", () => { - const count = state(1); - let seen; + const count = state(1); let seen; const stop = watch(count, (next, previous) => { seen = [next, previous]; }); - count.value = 2; - stop(); - assert.deepEqual(seen, [2, 1]); + 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>/); +test("render supports each and if/elif/else", () => { + 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>/); });