diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad571d3..12b66b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,4 +16,3 @@ jobs: with: node-version: 22 - run: npm test - - run: npm run build diff --git a/AGENTS.md b/AGENTS.md index db48afc..12cabbe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,20 +1,55 @@ # AGENTS.md -## Project model +## Project -Rebase is a compiler-first JavaScript framework. Keep the compiler and runtime dependency-free and browser-oriented. +Rebase is a JavaScript-first, framework-agnostic enhancement layer for the web. -## Structure +Core principles: +1. Rebase adds to existing stacks; it does not replace them. +2. Syntax is opt-in and explicitly registered. +3. Framework-native syntax must never be consumed accidentally. +4. Plugins are first-class extension points. +5. Keep the core small; optional features belong in plugins. +6. Prefer standards-based JavaScript and small dependencies. +7. Document public APIs before calling them stable. -- `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 +## Repository layout -## Development rules +- `packages/core`: core runtime, parser and plugin API. +- `packages/rebase`: user-facing `rebase` package. +- `packages/adapters`: host-framework integration entry points. +- `packages/*/test`: package tests. +- Documentation: https://js-rebase.github.io -- 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. +## API changes + +When changing public behavior: +- update tests; +- update README usage when necessary; +- update ROADMAP.md when scope changes; +- update the documentation site when applicable; +- provide a migration path for breaking plugin changes. + +## Plugins + +Plugins are independent JavaScript packages and must be explicitly enabled by applications. + +Do not add automatic plugin discovery to the core. + +Official packages use the `@rebase/*` scope. Community packages should use names such as `rebase-foo` or `@author/rebase-foo`. + +Plugins should declare the Rebase version range they support. + +## Testing + +Run: + +```bash +npm test +``` + +Tests should focus on public behavior rather than private implementation details. + +## Releases + +Beta releases use `1.0.0-beta.N`. Do not call the API stable until the plugin and adapter contracts are documented. \ No newline at end of file diff --git a/README.md b/README.md index 18dfe90..514ad95 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,208 @@ # Rebase -Rebase is an experimental compiler-first JavaScript framework for building web interfaces. +[![Beta](https://img.shields.io/badge/status-beta-7c3aed.svg)](https://github.com/intensed-dev/code) +[![License](https://img.shields.io/github/license/intensed-dev/code.svg)](LICENSE) +[![Tests](https://img.shields.io/github/actions/workflow/status/intensed-dev/code/ci.yml?label=tests)](https://github.com/intensed-dev/code/actions) -> Build the web. On your terms. +**A JavaScript-first enhancement layer for the web.** -## Status +Rebase adds small, opt-in features to HTML and existing web stacks without trying to become another framework. -Rebase is in early development. The component syntax, compiler, runtime, and public API are experimental. +> **Rebase doesn't replace your stack. It adds to it.** -## Documentation +## What Rebase is -https://rebase.js.org +Rebase is a small runtime and plugin API for extending web markup with explicitly registered syntax. -## Packages +It is designed to work alongside: -- `packages/compiler` — .rebase compiler prototype -- `packages/runtime` — browser runtime -- `packages/rebase` — public package entry +- HTML +- JavaScript +- Svelte +- Vue +- React +- other build systems and frameworks -## License +Rebase only owns syntax that the active Rebase instance explicitly registers. Host-framework syntax is left alone. -MIT +## Installation + +### Package + +```bash +npm install rebase +``` + +Then: + +```js +import { createRebase } from "rebase"; + +const rebase = createRebase(); + +rebase.directive("hello", ({ expression }) => + `Hello ${expression}` +); +``` + +### Core package + +For library authors and integrations: + +```bash +npm install @rebase/core +``` + +### Documentation + +Full documentation and API reference: + +**https://js-rebase.github.io** + +## Syntax + +Rebase has three extension points: + +| Syntax | Purpose | +| --- | --- | +| `{@name ...}` | Directive | +| `{#name ...}{/name}` | Block | +| `{{ expression }}` | Runtime interpolation | + +A plugin registers its own names. Rebase does not reserve arbitrary syntax globally. + +Example: + +```html +{@icon lucide:arrow-up} + +{#feature} +

Plugin-provided content.

+{/feature} +``` + +## Plugins + +A plugin is just a normal JavaScript package. + +```js +export default { + name: "rebase-lucide", + + install(rebase) { + rebase.directive("lucide", ({ expression }) => { + // return the generated HTML/SVG + }); + } +}; +``` + +Users install plugins separately: + +```bash +npm install rebase rebase-lucide +``` +and enable them explicitly: -Rebase 0.3 includes reactive blocks, reusable components, and built-in routing. +```js +import { createRebase } from "rebase"; +import lucide from "rebase-lucide"; + +const rebase = createRebase(); +rebase.use(lucide); +``` + +This means a plugin cannot silently change a user's Rebase instance merely by existing in `node_modules`. + +## Framework compatibility + +Rebase is intentionally non-invasive. + +For Svelte, the adapter protects native blocks such as: + +- `{#if}` +- `{#each}` +- `{#await}` +- `{#key}` +- `{#snippet}` + +The Vue and React adapters provide the same host-aware entry point. Framework-specific source transformations remain the responsibility of the corresponding integration. + +## API + +The public API is intentionally small: + +```js +rebase.expression(name, handler) +rebase.directive(name, handler) +rebase.block(name, handler) +rebase.hook(name, handler) +rebase.use(plugin) +rebase.transform(source, options) +rebase.mount(target, options) +``` + +Plugin handlers receive a context containing: + +```js +{ + name, + expression, + body, + scope, + rebase +} +``` + +## Repository structure + +```text +. +├── .github/ +│ ├── ISSUE_TEMPLATE/ +│ └── workflows/ +├── packages/ +│ ├── core/ +│ │ ├── index.js +│ │ └── test/ +│ ├── adapters/ +│ │ ├── svelte.js +│ │ ├── vue.js +│ │ └── react.js +│ └── rebase/ +│ └── index.js +├── AGENTS.md +├── CONTRIBUTING.md +├── CODE_OF_CONDUCT.md +├── LICENSE +├── README.md +├── ROADMAP.md +├── SECURITY.md +└── package.json +``` + +## Development + +Requirements: + +- Node.js 20+ +- npm + +Run the test suite: + +```bash +npm test +``` + +## Status + +**Beta.** + +The API is usable for experimentation and plugin development, but minor API changes may still happen before 1.0. + +See the [roadmap](ROADMAP.md) and the [documentation](https://js-rebase.github.io). + +## License + +MIT diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..a51e63b --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,87 @@ +# Rebase Roadmap + +Rebase is a small, framework-agnostic JavaScript enhancement layer. + +The roadmap describes direction, not guaranteed release dates. + +## Beta + +### Core +- [x] Public Rebase API +- [x] Plugin registration +- [x] Expressions, directives and blocks +- [x] Hooks +- [x] Browser mounting +- [x] Async source transformation +- [x] Host-aware syntax protection +- [ ] Nested block parser +- [ ] Diagnostics and useful parser errors +- [ ] Stable public API documentation +- [ ] TypeScript declarations + +### Distribution +- [x] `@rebase/core` +- [x] `rebase` +- [x] `@rebase/adapters` +- [x] Consistent beta versions +- [ ] npm release workflow +- [ ] Browser/CDN build +- [ ] ESM/browser compatibility matrix + +### Plugins +- [ ] Official plugin specification +- [ ] Plugin naming and compatibility rules +- [ ] Official example plugins +- [ ] Plugin author guide +- [ ] Plugin test utilities +- [ ] Plugin discovery page + +### Frameworks +- [x] Svelte host protection +- [x] Svelte adapter entry point +- [x] Vue adapter entry point +- [x] React adapter entry point +- [ ] Build-time Svelte integration +- [ ] Build-time Vue integration +- [ ] Build-time React integration + +### Documentation +- [ ] API reference +- [ ] Plugin guide +- [ ] Framework guides +- [ ] Examples +- [ ] Migration guide +- [ ] Versioned documentation + +## 1.0 + +- [ ] Stable parser +- [ ] Stable plugin contract +- [ ] Stable adapter contract +- [ ] CDN/browser distribution +- [ ] TypeScript declarations +- [ ] Security model documentation +- [ ] Performance baseline +- [ ] Automated releases +- [ ] Versioned docs + +## Possible future plugins + +- icons +- dates and formatting +- i18n +- Markdown +- math +- accessibility helpers +- development diagnostics + +These should normally remain plugins rather than becoming core features. + +## Non-goals + +Rebase is not intended to become: + +- a replacement for Svelte, Vue or React +- a full templating language +- a dependency-heavy meta-framework +- a mandatory global syntax standard \ No newline at end of file diff --git a/Roadmap.md b/Roadmap.md deleted file mode 100644 index 1dcf958..0000000 --- a/Roadmap.md +++ /dev/null @@ -1,13 +0,0 @@ -# Rebase roadmap - -- [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/package.json b/package.json index e1bff35..7835139 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,12 @@ { "name": "rebase", - "version": "0.3.0", - "description": "A compiler-first JavaScript framework for building web interfaces.", + "version": "1.0.0-beta.1", + "description": "A JavaScript-first enhancement layer for the web.", "type": "module", "private": true, - "scripts": {"build": "node packages/compiler/src/cli.js", "test": "node --test"}, + "workspaces": ["packages/*"], + "scripts": { + "test": "node --test packages/core/test/*.test.js" + }, "license": "MIT" -} \ No newline at end of file +} diff --git a/packages/adapters/index.js b/packages/adapters/index.js new file mode 100644 index 0000000..35a9df8 --- /dev/null +++ b/packages/adapters/index.js @@ -0,0 +1,3 @@ +export { createSvelteRebase } from "./svelte.js"; +export { createVueRebase } from "./vue.js"; +export { createReactRebase } from "./react.js"; diff --git a/packages/adapters/package.json b/packages/adapters/package.json new file mode 100644 index 0000000..73605e2 --- /dev/null +++ b/packages/adapters/package.json @@ -0,0 +1,17 @@ +{ + "name": "@rebase/adapters", + "version": "1.0.0-beta.1", + "description": "Host-framework adapters for Rebase.", + "type": "module", + "main": "index.js", + "exports": { + ".": "./index.js", + "./svelte": "./svelte.js", + "./vue": "./vue.js", + "./react": "./react.js" + }, + "peerDependencies": { + "@rebase/core": "1.0.0-beta.1" + }, + "license": "MIT" +} \ No newline at end of file diff --git a/packages/adapters/react.js b/packages/adapters/react.js new file mode 100644 index 0000000..24af7fc --- /dev/null +++ b/packages/adapters/react.js @@ -0,0 +1,4 @@ +import { createRebase } from "../core/index.js"; +export function createReactRebase(options = {}) { + return createRebase({ ...options, host: "react" }); +} \ No newline at end of file diff --git a/packages/adapters/svelte.js b/packages/adapters/svelte.js new file mode 100644 index 0000000..d5ba398 --- /dev/null +++ b/packages/adapters/svelte.js @@ -0,0 +1,4 @@ +import { createRebase } from "../core/index.js"; +export function createSvelteRebase(options = {}) { + return createRebase({ ...options, host: "svelte" }); +} \ No newline at end of file diff --git a/packages/adapters/vue.js b/packages/adapters/vue.js new file mode 100644 index 0000000..5e76506 --- /dev/null +++ b/packages/adapters/vue.js @@ -0,0 +1,4 @@ +import { createRebase } from "../core/index.js"; +export function createVueRebase(options = {}) { + return createRebase({ ...options, host: "vue" }); +} \ No newline at end of file diff --git a/packages/compiler/index.js b/packages/compiler/index.js deleted file mode 100644 index fbb27bf..0000000 --- a/packages/compiler/index.js +++ /dev/null @@ -1 +0,0 @@ -export { compile, compileFile } from "./src/compiler.js"; \ No newline at end of file diff --git a/packages/compiler/package.json b/packages/compiler/package.json deleted file mode 100644 index 6c29905..0000000 --- a/packages/compiler/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "@rebasejs/compiler", - "version": "0.3.0", - "type": "module", - "bin": { - "rebase": "./src/cli.js" - }, - "exports": "./src/compiler.js" -} diff --git a/packages/compiler/src/cli.js b/packages/compiler/src/cli.js deleted file mode 100644 index c693404..0000000 --- a/packages/compiler/src/cli.js +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env node -import fs from "node:fs"; -import path from "node:path"; -import { compileFile } from "./compiler.js"; - -const [input, output = input.replace(/\\.rebase$/i, ".js")] = process.argv.slice(2); -if (!input) { - console.error("Usage: rebase [output.js]"); - process.exit(1); -} -compileFile(input, output); -console.log(`Compiled ${path.resolve(input)} -> ${path.resolve(output)}`); diff --git a/packages/compiler/src/compiler.js b/packages/compiler/src/compiler.js deleted file mode 100644 index a08ca78..0000000 --- a/packages/compiler/src/compiler.js +++ /dev/null @@ -1,78 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; - -const RESERVED = new Set(["const","let","var","function","return","if","else","for","while","class","import","export","from"]); - -export function compile(source) { - const sections = extractSections(source); - const imports = [...sections.script.matchAll(/^\s*import\s+([\s\S]*?)\s+from\s+["']([^"']+)["']\s*;?\s*$/gm)] - .map(m => ({ statement: m[0].trim(), specifier: m[1].trim(), source: m[2] })); - - let setup = sections.script; - for (const item of imports) setup = setup.replace(item.statement, ""); - setup = setup.trim(); - - const bindings = collectBindings(setup); - const importCode = imports.map(item => { - const source = item.source.endsWith(".rebase") ? item.source.slice(0, -7) + ".js" : item.source; - return `import ${item.specifier} from ${JSON.stringify(source)};`; - }); - - const componentNames = imports - .map(item => item.specifier.match(/^(?:\{\s*)?([A-Za-z_$][\w$]*)/)?.[1]) - .filter(Boolean); - - const code = [ - ...importCode, - 'import { component } from "rebase";', - "export default component({", - " setup() {", - indent(setup), - ` return { ${bindings.join(", ")} };`, - " },", - ` template: ${JSON.stringify(transformComponents(sections.template))},`, - ` style: ${JSON.stringify(sections.style)},`, - ` components: { ${componentNames.map(name => `${name}: ${name}`).join(", ")} }`, - "});" - ].join("\n"); - - return { ...sections, imports, bindings, code }; -} - -export function compileFile(input, output) { - const result = compile(fs.readFileSync(input, "utf8")); - fs.mkdirSync(path.dirname(output), { recursive: true }); - fs.writeFileSync(output, result.code + "\n"); - return result; -} - -function extractSections(source) { - const blocks = [...source.matchAll(/<(script|template|style)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/gi)]; - const sections = { script: "", template: "", style: "" }; - for (const match of blocks) sections[match[1].toLowerCase()] = match[2].trim(); - if (!blocks.some(m => m[1].toLowerCase() === "template")) { - sections.template = source - .replace(/<(script|style)(?:\s[^>]*)?>[\s\S]*?<\/\1>/gi, "") - .trim(); - } - return sections; -} - -function collectBindings(script) { - const names = new Set(); - for (const match of script.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)/g)) names.add(match[1]); - for (const match of script.matchAll(/function\s+([A-Za-z_$][\w$]*)\s*\(/g)) names.add(match[1]); - return [...names].filter(name => !RESERVED.has(name)); -} - -function transformComponents(template) { - return template - .replace(/<([A-Z][A-Za-z0-9_$-]*)(\s[^>]*)?\s*\/\s*>/g, - '') - .replace(/<([A-Z][A-Za-z0-9_$-]*)(\s[^>]*)>([\s\S]*?)<\/\1>/g, - '$3'); -} - -function indent(value) { - return value ? value.split("\n").map(line => " " + line).join("\n") : " // no script"; -} diff --git a/packages/compiler/test/compiler.test.js b/packages/compiler/test/compiler.test.js deleted file mode 100644 index 023c24e..0000000 --- a/packages/compiler/test/compiler.test.js +++ /dev/null @@ -1,22 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { compile } from "../src/compiler.js"; - -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 \};/); -}); - -test("rewrites .rebase imports and custom components", () => { - 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/core/index.js b/packages/core/index.js new file mode 100644 index 0000000..bf8ac48 --- /dev/null +++ b/packages/core/index.js @@ -0,0 +1,44 @@ +export const version = "1.0.0-beta.1"; + +export class SyntaxRegistry { + constructor(){this.expressions=new Map();this.directives=new Map();this.blocks=new Map();this.hooks=new Map();this.disabled=new Map();} + expression(name,handler,options={}){add(this.expressions,name,handler,options);return this} + directive(name,handler,options={}){add(this.directives,name,handler,options);return this} + block(name,handler,options={}){add(this.blocks,name,handler,options);return this} + hook(name,handler){if(typeof handler!=="function")throw new TypeError("Invalid hook");(this.hooks.get(name)||this.hooks.set(name,[]).get(name)).push(handler);return this} + disable(type,name){(this.disabled.get(type)||this.disabled.set(type,new Set()).get(type)).add(name);return this} + enabled(type,name){return !this.disabled.get(type)?.has(name)} + emit(name,value){for(const fn of this.hooks.get(name)||[])fn(value)} +} + +export class Rebase { + constructor(options={}){this.options={...options};this.scope=options.scope||{};this.syntax=new SyntaxRegistry();this.plugins=new Map();this.roots=new Set();if(options.host)this.host(options.host);if(options.builtIns!==false)builtIns(this)} + host(name){this.options.host=name;for(const x of HOSTS[name]||[])this.syntax.disable(x[0],x[1]);return this} + use(plugin,options={}){const p=plugin?.default||plugin;if(typeof p==="function")p(this.api(),options);else if(p?.install)p.install(this.api(),options);else throw new TypeError("Invalid Rebase plugin");if(p?.name)this.plugins.set(p.name,p);return this} + expression(n,h,o){this.syntax.expression(n,h,o);return this} + directive(n,h,o){this.syntax.directive(n,h,o);return this} + block(n,h,o){this.syntax.block(n,h,o);return this} + hook(n,h){this.syntax.hook(n,h);return this} + api(){return Object.freeze({version,rebase:this,expression:this.expression.bind(this),directive:this.directive.bind(this),block:this.block.bind(this),hook:this.hook.bind(this),syntax:this.syntax})} + async transform(source,{scope={},host=this.options.host}={}){return render(String(source),{...this.scope,...scope},this,host)} + mount(target,{template,scope={}}={}){const root=typeof target==="string"?document.querySelector(target):target;if(!root)throw new Error("Rebase mount target not found");const record={root,source:template??root.innerHTML,scope:{...this.scope,...scope}};this.roots.add(record);this.render(record);return{update:()=>this.render(record),unmount:()=>{this.roots.delete(record);root.innerHTML=record.source}}} + render(record){const html=renderSync(record.source,record.scope,this,this.options.host);record.root.innerHTML=html;bind(record.root,record.scope);this.syntax.emit("render",{root:record.root,scope:record.scope});return html} + update(){for(const r of this.roots)this.render(r);return this} +} + +export const createRebase=options=>new Rebase(options); +export const plugin=definition=>definition; + +function builtIns(r){r.expression("value",x=>evalIn(x.expression,x.scope));r.block("if",x=>evalIn(x.expression,x.scope)?x.body:"");r.block("each",x=>{const m=x.expression.match(/^(.+?)\s+as\s+([A-Za-z_$][\w$]*)(?:\s*,\s*([A-Za-z_$][\w$]*))?$/);if(!m)return"";const list=evalIn(m[1],x.scope);if(!list?.[Symbol.iterator])return"";return[...list].map((v,i)=>renderSync(x.body,{...x.scope,[m[2]]:v,...m[3]?[m[3]]:[],...(m[3]?{[m[3]]:i}:{})},x.rebase)).join("")})} + +function renderSync(s,scope,r,host){let out=s;out=blocks(out,r,scope,host);out=directives(out,r,scope);if(!host)out=out.replace(/\{\{([^{}]+)\}\}/g,(_,e)=>esc(evalIn(e.trim(),scope)));return out} +async function render(s,scope,r,host){let out=s;out=blocks(out,r,scope,host);out=await directivesAsync(out,r,scope);if(!host)out=out.replace(/\{\{([^{}]+)\}\}/g,(_,e)=>esc(evalIn(e.trim(),scope)));return out} + +function blocks(s,r,scope,host){const re=/\{#([\w$-]+)(?:\s+([^}]*))?\}([\s\S]*?)\{\/\1\}/g;return s.replace(re,(full,n,e,b)=>{if(!r.syntax.enabled("block",n)||host&&HOSTS[host]?.some(x=>x[0]==="block"&&x[1]===n))return full;const h=r.syntax.blocks.get(n);if(!h)return full;return String(h.handler({name:n,expression:(e||"").trim(),body:blocks(b,r,scope,host),scope,rebase:r})??"")})} +function directives(s,r,scope){return s.replace(/\{@([\w$:-]+)(?:\s+([^{}]*))?\}/g,(full,n,e)=>{const h=r.syntax.directives.get(n);if(!h)return full;const v=h.handler({name:n,expression:(e||"").trim(),scope,rebase:r});if(v?.then)throw new Error("Async Rebase plugin requires transform()");return v==null?"":String(v)})} +async function directivesAsync(s,r,scope){let out="",i=0;for(const m of s.matchAll(/\{@([\w$:-]+)(?:\s+([^{}]*))?\}/g)){out+=s.slice(i,m.index);const h=r.syntax.directives.get(m[1]);if(!h)out+=m[0];else out+=String(await h.handler({name:m[1],expression:(m[2]||"").trim(),scope,rebase:r})??"");i=m.index+m[0].length}return out+s.slice(i)} +function bind(root,scope){for(const el of root.querySelectorAll("*"))for(const a of [...el.attributes])if(a.name.startsWith("on:")){const e=a.name.slice(3),x=a.value.replace(/^\{|\}$/g,"");el.removeAttribute(a.name);el.addEventListener(e,ev=>evalIn(x,{...scope,event:ev}))}} +function evalIn(e,s){try{return Function("scope","with(scope)return ("+e+")")(s)}catch{return undefined}} +function esc(v){return String(v??"").replaceAll("&","&").replaceAll("<","<").replaceAll(">",">").replaceAll('"',""").replaceAll("'","'")} +function add(map,name,handler,options){if(!/^[A-Za-z_$][\w$-]*$/.test(name)||typeof handler!=="function")throw new TypeError("Invalid Rebase syntax registration");map.set(name,{handler,options})} +const HOSTS={svelte:[["block","if"],["block","each"],["block","await"],["block","key"],["block","snippet"]],vue:[],react:[]}; diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..c61ff96 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,10 @@ +{ + "name": "@rebase/core", + "version": "1.0.0-beta.1", + "description": "The core runtime and plugin API for Rebase.", + "type": "module", + "exports": { + ".": "./index.js" + }, + "license": "MIT" +} \ No newline at end of file diff --git a/packages/core/test/api.test.js b/packages/core/test/api.test.js new file mode 100644 index 0000000..7aa30a5 --- /dev/null +++ b/packages/core/test/api.test.js @@ -0,0 +1,36 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createRebase } from "../index.js"; + +test("plugins can register directives and blocks", async () => { + const rebase = createRebase(); + + rebase.directive("hello", ({ expression }) => "Hello " + expression); + rebase.block("feature", ({ body }) => "FEATURE:" + body); + + assert.equal( + await rebase.transform("{@hello world} {#feature}works{/feature}"), + "Hello world FEATURE:works" + ); +}); + +test("Svelte native blocks remain untouched", async () => { + const rebase = createRebase({ host: "svelte" }); + + rebase.directive("custom", ({ expression }) => expression); + + assert.equal( + await rebase.transform("{#if visible}

{name}

{/if} {@custom test}", { + scope: { visible: true, name: "A" } + }), + "{#if visible}

{name}

{/if} test" + ); +}); + +test("async directives work through transform", async () => { + const rebase = createRebase(); + + rebase.directive("async", async ({ expression }) => expression.toUpperCase()); + + assert.equal(await rebase.transform("{@async hello}"), "HELLO"); +}); diff --git a/packages/rebase/index.js b/packages/rebase/index.js index 0724f7d..dd33c50 100644 --- a/packages/rebase/index.js +++ b/packages/rebase/index.js @@ -1 +1 @@ -export { component, mount, createApp, state, computed, effect, watch, render, html, createRouter } from "../runtime/src/index.js"; \ No newline at end of file +export * from "@rebase/core"; \ No newline at end of file diff --git a/packages/rebase/package.json b/packages/rebase/package.json index db83163..b67c1a7 100644 --- a/packages/rebase/package.json +++ b/packages/rebase/package.json @@ -1,7 +1,15 @@ { "name": "rebase", - "version": "0.3.0", + "version": "1.0.0-beta.1", + "description": "A JavaScript-first enhancement layer for the web.", "type": "module", - "main": "./index.js", - "exports": "./index.js" + "main": "index.js", + "module": "index.js", + "exports": { + ".": "./index.js" + }, + "dependencies": { + "@rebase/core": "1.0.0-beta.1" + }, + "license": "MIT" } \ No newline at end of file diff --git a/packages/runtime/package.json b/packages/runtime/package.json deleted file mode 100644 index b0e81e7..0000000 --- a/packages/runtime/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "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 deleted file mode 100644 index c42c336..0000000 --- a/packages/runtime/src/index.js +++ /dev/null @@ -1,250 +0,0 @@ -const stateMarker = Symbol("rebase.state"); - -export function state(initialValue) { - let value = initialValue; - const subscribers = new Set(); - return { - [stateMarker]: true, - get value() { return value; }, - set value(next) { - const previous = value; - value = typeof next === "function" ? next(value) : next; - if (Object.is(previous, value)) return; - subscribers.forEach(fn => fn(value, previous)); - }, - subscribe(fn) { subscribers.add(fn); return () => subscribers.delete(fn); } - }; -} - -export function computed(getter, dependencies = []) { - const result = state(getter()); - 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)); - return () => stops.forEach(stop => stop()); -} - -export function watch(source, callback) { - if (source?.subscribe) return source.subscribe(callback); - let previous = source(); - return effect(() => { - const next = source(); - if (!Object.is(next, previous)) { - const old = previous; - previous = next; - callback(next, old); - } - }); -} - -export function component(definition = {}) { - return Object.freeze({ template: "", style: "", setup: undefined, components: {}, ...definition }); -} - -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(options.props ?? {}) : { ...(options.props ?? {}) }; - let childCleanups = []; - - const render = () => { - 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); - - return { - element, instance, update: render, - unmount() { - subscriptions.forEach(stop => stop?.()); - childCleanups.forEach(stop => stop?.()); - definition.onUnmount?.(instance); - element.replaceChildren(); - } - }; -} - -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, options); 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])) : ""), ""); -} - -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*([^{}]+?)\s*\}/g, (_, expression) => - /^[#:\/]/.test(expression) ? "{" + expression + "}" : escapeHtml(evaluate(expression, scope) ?? "") - ); -} - -function evaluate(expression, scope) { - try { return Function("scope", "with (scope) { return (" + expression + "); }")(scope); } - catch { return ""; } -} - -function bindEvents(element, scope) { - for (const node of element.querySelectorAll("*")) { - for (const attribute of [...node.attributes]) { - if (!attribute.name.startsWith("on:")) continue; - const event = attribute.name.slice(3); - const handler = attribute.value.replace(/^\{|\}$/g, "").trim(); - node.removeAttribute(attribute.name); - 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 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 deleted file mode 100644 index 5adb943..0000000 --- a/packages/runtime/test/router.test.js +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 18d9ea6..0000000 --- a/packages/runtime/test/runtime.test.js +++ /dev/null @@ -1,27 +0,0 @@ -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}", {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 deleted file mode 100644 index af9fb5f..0000000 --- a/rebase.config.js +++ /dev/null @@ -1,5 +0,0 @@ -export default { - root: "src", - outDir: "dist", - clean: true -};