This commit is contained in:
Morten Olsen
2025-09-21 08:43:15 +02:00
commit 0ab9a27493
18 changed files with 5379 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

18
.prettierrc.json Normal file
View File

@@ -0,0 +1,18 @@
{
"arrowParens": "always",
"bracketSpacing": true,
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"bracketSameLine": false,
"jsxSingleQuote": false,
"printWidth": 120,
"proseWrap": "preserve",
"quoteProps": "as-needed",
"requirePragma": false,
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "all",
"useTabs": false,
"singleAttributePerLine": false
}

16
.u8.json Normal file
View File

@@ -0,0 +1,16 @@
{
"values": {
"monoRepo": false,
"packageVersion": "1.0.0"
},
"entries": [
{
"timestamp": "2025-09-21T01:01:13.340Z",
"template": "eslint",
"values": {
"monoRepo": false,
"packageVersion": "1.0.0"
}
}
]
}

1
bundle/bundle.js Normal file
View File

@@ -0,0 +1 @@
const e=new Map,t=Symbol(`getValue`),n=Symbol(`htmlStore`),r=Symbol(`undefinedValue`),i=e=>{let n=new EventTarget,i=e=>new Proxy(e,{get:(e,n)=>{if(n===t)return e;let a=e[n];return!a||typeof a!=`object`?Object.assign(a??r,{[t]:()=>e[n]}):i(a)},set:(e,t,r)=>(e[t]=r,n.dispatchEvent(new Event(`change`)),!0)});return{state:i(e),emitter:n}},a=t=>{let r=()=>{let e=new EventTarget,n=i(t.setup());return n.emitter.addEventListener(`change`,()=>{e.dispatchEvent(new Event(`change`))}),{emitter:e,state:n.state}};return{create:r,get:t=>{for(;t;){if(n in t){let e=t[n];if(!e.has(void 0))continue;return e.get(void 0)}t=t.parentElement||void 0}return e.has(void 0)||e.set(void 0,r()),e.get(void 0)}}},o=e=>{let t=new EventTarget;return{emitter:t,get:n=>{let r=n.get(e);return r.emitter.addEventListener(`change`,()=>{t.dispatchEvent(new Event(`change`))}),r.state}}},s=e=>a({setup:()=>({value:e})});function c(e){return e&&typeof e==`object`&&e&&t in e?e[t]():e}const l=(e,...t)=>e.flatMap((e,n)=>[e,c(t[n])??``]),u=e=>{let t=t=>({toHtmlElement:()=>{let n=document.createElement(`div`);n.style.display=`content`;let r=o(n),i={context:r.get,props:t},a=e(i);return r.emitter.addEventListener(`change`,()=>{a(n)}),a(n),n}});return Object.assign(t,{toHtmlElement:()=>t({}).toHtmlElement()})},d=e=>{if(e instanceof HTMLElement||e instanceof Text)return e;let t=Object.getOwnPropertyDescriptor(e,`toHtmlElement`);return t&&typeof t.value==`function`?t.value():typeof e==`object`?document.createTextNode(JSON.stringify(e)):e===void 0?document.createTextNode(``):document.createTextNode(String(e))},f=e=>{let t=t=>(n,...r)=>{let i=e=>{for(let[n,r]of Object.entries(t))typeof r==`function`&&e.addEventListener(n,()=>r());if(`style`in t)for(let[n,r]of Object.entries(t.style))e.style[n]=r;let i=l(n,...r).map(d).filter(Boolean);e.innerHTML=``;for(let t of i)e.appendChild(t)};return Object.assign(i,{toHtmlElement:()=>{let t=typeof e==`function`?e():document.createElement(e);return i(t),t}})};return Object.assign((e,...n)=>t({})(e,...n),{$:e=>(n,...r)=>t(e)(n,...r)})},p=f(`div`),m=f(`h1`),h=f(`p`),g=f(`button`),_=(...e)=>f(()=>{let t=document.createElement(`div`);t.style.display=`content`;let r=new Map;t[n]=r;for(let t of e)r.set(t,t.create());return t});export{g as button,u as component,p as div,l as expandTemplateStringFromStore,c as getWithStore,m as h1,f as html,o as htmlElementContext,h as p,_ as scope,a as store,n as storeSymbol,s as value};

68
demo/main.tsx Normal file
View File

@@ -0,0 +1,68 @@
import { value, component, div, h1, p, button, scope } from '../lib/exports';
const counter = value(0);
const child = component<{ count: number }>(({ context, props }) => {
const count = context(counter);
return div`
And the count according to the child: ${count.value} which got ${props.count}
${button.$({
click: () => {
count.value -= 1;
},
})`
Decrement
`}
`;
});
const root = component(({ context }) => {
const count = context(counter);
return div`
${h1.$({ style: { color: 'red' } })`
Hello
`}
The current count is: ${count.value}
${div`
${h1`Hello`}
... Something
${p`
Testing
`}
`}
${button.$({
click: () => {
count.value += 1;
},
})`
Increment
`}
${div`
${h1`With prop`}
${child({ count: count.value })}
`}
${div`
${h1`Without prop`}
${child}
`}
${scope(counter)`
${h1`Without scope`}
${child}
`}
So long and thanks for all the fish
`;
});
document.body.appendChild(root.toHtmlElement());

1
demo/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

51
eslint.config.mjs Normal file
View File

@@ -0,0 +1,51 @@
import { FlatCompat } from '@eslint/eslintrc';
import importPlugin from 'eslint-plugin-import';
import eslint from '@eslint/js';
import eslintConfigPrettier from 'eslint-config-prettier';
import tseslint from 'typescript-eslint';
const compat = new FlatCompat({
baseDirectory: import.meta.__dirname,
resolvePluginsRelativeTo: import.meta.__dirname,
});
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.strict,
...tseslint.configs.stylistic,
eslintConfigPrettier,
{
files: ['**/*.{ts,tsxx}'],
extends: [importPlugin.flatConfigs.recommended, importPlugin.flatConfigs.typescript],
rules: {
'import/no-unresolved': 'off',
'import/extensions': ['error', 'ignorePackages'],
'import/exports-last': 'error',
'import/no-default-export': 'error',
'import/order': [
'error',
{
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
'newlines-between': 'always',
},
],
'import/no-duplicates': 'error',
},
},
{
rules: {
'@typescript-eslint/consistent-type-definitions': ['error', 'type'],
},
},
{
files: ['**.d.ts'],
rules: {
'@typescript-eslint/triple-slash-reference': 'off',
'@typescript-eslint/consistent-type-definitions': 'off',
},
},
...compat.extends('plugin:prettier/recommended'),
{
ignores: ['**/node_modules/', '**/dist/', '**/.turbo/', '**/generated/'],
},
);

11
index.css Normal file
View File

@@ -0,0 +1,11 @@
@import "tailwindcss";
h1 {
font-size: 2rem;
}
button {
border: solid 1px #ddd;
border-radius: 5px;
padding: 5px;
}

14
index.html Normal file
View File

@@ -0,0 +1,14 @@
<!-- u8-override:false -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/index.css" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/demo/main.tsx"></script>
</body>
</html>

31
lib/component.ts Normal file
View File

@@ -0,0 +1,31 @@
import type { Html } from './html.js';
import { htmlElementContext, type HtmlElementContext } from './store.js';
type ComponentOptions<T> = (options: { props: T; context: HtmlElementContext['get'] }) => Html;
const component = <T>(options: ComponentOptions<T>) => {
const render = (props: T) => {
const toHtmlElement = () => {
const root = document.createElement('div');
root.style.display = 'content';
const context = htmlElementContext(root);
const api = { context: context.get, props };
const view = options(api);
context.emitter.addEventListener('change', () => {
view(root);
});
view(root);
return root;
};
return {
toHtmlElement,
};
};
return Object.assign(render, {
toHtmlElement: () => render({} as T).toHtmlElement(),
});
};
export { component };

3
lib/exports.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './component.js';
export * from './store.js';
export * from './html.js';

82
lib/html.ts Normal file
View File

@@ -0,0 +1,82 @@
import { expandTemplateStringFromStore, storeSymbol, type Store } from './store.js';
const toHtmlElement = (input: unknown): HTMLElement | Text => {
if (input instanceof HTMLElement || input instanceof Text) {
return input;
}
const toHtmlProperty = Object.getOwnPropertyDescriptor(input, 'toHtmlElement');
if (toHtmlProperty && typeof toHtmlProperty.value === 'function') {
const elm = toHtmlProperty.value();
return elm;
}
if (typeof input === 'object') {
return document.createTextNode(JSON.stringify(input));
}
if (input === undefined) {
return document.createTextNode('');
}
return document.createTextNode(String(input));
};
const html = (tag: string | (() => HTMLElement)) => {
const render =
(options: Record<string, unknown>) =>
(strings: TemplateStringsArray, ...params: unknown[]) => {
const fn = (elm: HTMLElement) => {
for (const [event, listener] of Object.entries(options)) {
if (typeof listener === 'function') {
elm.addEventListener(event, () => listener());
}
}
if ('style' in options) {
for (const [name, value] of Object.entries(options.style as any)) {
(elm.style as any)[name] = value;
}
}
const elements = expandTemplateStringFromStore(strings, ...params);
const htmlElements = elements.map(toHtmlElement).filter(Boolean);
elm.innerHTML = '';
for (const element of htmlElements) {
elm.appendChild(element);
}
};
return Object.assign(fn, {
toHtmlElement: () => {
const root = typeof tag === 'function' ? tag() : document.createElement(tag);
fn(root);
return root;
},
});
};
return Object.assign((strings: TemplateStringsArray, ...params: unknown[]) => render({})(strings, ...params), {
$:
(props: Record<string, unknown>) =>
(strings: TemplateStringsArray, ...params: unknown[]) =>
render(props)(strings, ...params),
});
};
type Html = ReturnType<ReturnType<typeof html>>;
const div = html('div');
const h1 = html('h1');
const p = html('p');
const button = html('button');
const scope = (...stores: Store[]) =>
html(() => {
const elm = document.createElement('div');
elm.style.display = 'content';
const storeMap = new Map();
(elm as any)[storeSymbol] = storeMap;
for (const store of stores) {
storeMap.set(store, store.create());
}
return elm;
});
export type { Html };
export { html, div, h1, p, button, scope };

112
lib/store.ts Normal file
View File

@@ -0,0 +1,112 @@
type StoreOptions<TState extends Record<string, unknown> = Record<string, unknown>> = {
setup: () => TState;
};
const globalScope = new Map<unknown, unknown>();
const getSymbol = Symbol('getValue');
const storeSymbol = Symbol('htmlStore');
const undefinedSymbol = Symbol('undefinedValue');
const proxyObject = <T extends Record<string, unknown>>(init: T) => {
const emitter = new EventTarget();
const wrap = <T extends Record<string | number | symbol, unknown>>(obj: T) =>
new Proxy(obj, {
get: (target, prop) => {
if (prop === getSymbol) {
return target;
}
const value = target[prop];
if (!value || typeof value !== 'object') {
return Object.assign(value ?? undefinedSymbol, {
[getSymbol]: () => target[prop],
});
}
return wrap(value as Record<string, unknown>);
},
set: (target: Record<string | number | symbol, unknown>, prop, value) => {
target[prop] = value;
emitter.dispatchEvent(new Event('change'));
return true;
},
});
const state = wrap(init);
return { state, emitter };
};
const store = <TState extends Record<string, unknown>>(options: StoreOptions<TState>) => {
const create = () => {
const emitter = new EventTarget();
const stateProxy = proxyObject(options.setup());
stateProxy.emitter.addEventListener('change', () => {
emitter.dispatchEvent(new Event('change'));
});
return {
emitter,
state: stateProxy.state,
};
};
const get = (elm?: HTMLElement) => {
while (elm) {
if (storeSymbol in elm) {
const localStore = elm[storeSymbol] as Map<unknown, unknown>;
if (!localStore.has(this)) {
continue;
}
return localStore.get(this) as ReturnType<typeof create>;
}
elm = elm.parentElement || undefined;
}
if (!globalScope.has(this)) {
globalScope.set(this, create());
}
return globalScope.get(this) as ReturnType<typeof create>;
};
return {
create,
get,
};
};
type Store<TState extends Record<string, unknown> = Record<string, unknown>> = ReturnType<typeof store<TState>>;
const htmlElementContext = (htmlElement: HTMLElement) => {
const emitter = new EventTarget();
return {
emitter,
get: <TStore extends Store>(store: TStore) => {
const context = store.get(htmlElement);
context.emitter.addEventListener('change', () => {
emitter.dispatchEvent(new Event('change'));
});
return context.state;
},
};
};
type HtmlElementContext = ReturnType<typeof htmlElementContext>;
const value = <T>(init: T) =>
store({
setup: () => ({ value: init }),
});
function getWithStore<T>(value: T) {
if (value && value !== null && typeof value === 'object' && getSymbol in value) {
return (value[getSymbol] as () => unknown)();
}
return value;
}
const expandTemplateStringFromStore = <T = unknown>(strings: TemplateStringsArray, ...values: T[]) => {
return strings.flatMap((current, i) => {
return [current, getWithStore(values[i]) ?? ''];
});
};
export type { HtmlElementContext, Store };
export { store, value, htmlElementContext, getWithStore, expandTemplateStringFromStore, storeSymbol };

40
package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"private": true,
"type": "module",
"scripts": {
"test:lint": "eslint",
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"bundler": "rolldown -c",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "4.1.13",
"@tanstack/react-query": "5.89.0",
"react": "19.1.1",
"react-dom": "19.1.1",
"tailwindcss": "4.1.13"
},
"devDependencies": {
"@eslint/eslintrc": "3.3.1",
"@eslint/js": "9.36.0",
"@pnpm/find-workspace-packages": "6.0.9",
"@types/node": "^24.5.2",
"@types/react": "19.1.13",
"@types/react-dom": "19.1.9",
"@vitejs/plugin-react-swc": "4.1.0",
"eslint": "9.36.0",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-prettier": "5.5.4",
"prettier": "3.6.2",
"rolldown": "1.0.0-beta.38",
"typescript": "5.9.2",
"typescript-eslint": "8.44.0",
"vite": "7.1.6"
},
"name": "@morten-olsen/yafff",
"version": "1.0.0",
"packageManager": "pnpm@10.15.0+sha512.486ebc259d3e999a4e8691ce03b5cac4a71cbeca39372a9b762cb500cfdf0873e2cb16abe3d951b1ee2cf012503f027b98b6584e4df22524e0c7450d9ec7aa7b"
}

4860
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

10
rolldown.config.js Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'rolldown';
export default defineConfig({
input: 'lib/exports.js',
output: {
format: 'esm',
file: 'bundle/bundle.js',
minify: true,
},
});

21
tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"outDir": "dist",
"jsx": "react-jsx",
"isolatedModules": true,
"verbatimModuleSyntax": true,
"baseUrl": ".",
"rootDir": "."
},
"include": ["lib/**/*.ts"]
}

16
vite.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { resolve } from 'node:path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
import tailwindcss from '@tailwindcss/vite';
// https://vite.dev/config/
// eslint-disable-next-line import/no-default-export
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@morten-olsen/yafff': resolve('./lib/exports.ts'),
},
},
});