init
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
18
.prettierrc.json
Normal 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
16
.u8.json
Normal 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
1
bundle/bundle.js
Normal 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
68
demo/main.tsx
Normal 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
1
demo/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
51
eslint.config.mjs
Normal file
51
eslint.config.mjs
Normal 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
11
index.css
Normal 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
14
index.html
Normal 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
31
lib/component.ts
Normal 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
3
lib/exports.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './component.js';
|
||||
export * from './store.js';
|
||||
export * from './html.js';
|
||||
82
lib/html.ts
Normal file
82
lib/html.ts
Normal 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
112
lib/store.ts
Normal 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
40
package.json
Normal 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
4860
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
10
rolldown.config.js
Normal file
10
rolldown.config.js
Normal 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
21
tsconfig.json
Normal 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
16
vite.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user