commit 6a73d483bbb5c539096d5be2dc8f6d4f3a66b3a6 Author: Kevand Date: Mon Mar 2 15:28:20 2026 +0000 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..897cb9f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +package-lock.json +dist \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..ec28079 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "nlg-creator", + "version": "1.0.0", + "description": "Simple SPA framework", + "license": "ISC", + "author": "Kevand", + "type": "module", + "main": "dist/index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "build": "tsc" + }, + "devDependencies": { + "typescript": "^5.9.3" + } +} \ No newline at end of file diff --git a/src/dom.ts b/src/dom.ts new file mode 100644 index 0000000..3e17d98 --- /dev/null +++ b/src/dom.ts @@ -0,0 +1,74 @@ +import { type State, isState } from "./state.js"; + +export function t( + tagName: K, + attr: Record, + children?: (Node | string | State)[], +): HTMLElementTagNameMap[K] { + const e = document.createElement(tagName); + + const apply = (key: string, value: any) => { + if (key === "class") { + e.className = value; + } else if (key === "value" && "value" in e) { + if ((e as any).value !== value) { + (e as any).value = value; + } + } else if (key === "disabled" || key === "checked") { + (e as any)[key] = Boolean(value); + } else if (key.startsWith("on") && typeof value === "function") { + (e as any)[key.toLowerCase()] = value; + } else { + if (value === null || value === undefined) { + e.removeAttribute(key); + } else { + e.setAttribute(key, String(value)); + } + } + }; + + for (const [key, val] of Object.entries(attr)) { + if (isState(val)) { + apply(key, val.get()); + + val.sub(() => { + apply(key, val.get()); + }); + + if (key === "value" && (tagName === "input" || tagName === "textarea")) { + e.addEventListener("input", (event: Event) => { + val.set((event.target as HTMLInputElement).value); + }); + } + + if (key === "checked" || key === "disabled") { + e.addEventListener("change", (event: Event) => { + val.set((event.target as HTMLInputElement).checked); + }); + } + } else { + apply(key, val); + } + } + + if (children) { + children.forEach((child) => { + if (isState(child)) { + const node = document.createTextNode(String(child.get())); + e.append(node); + + child.sub(() => { + node.nodeValue = String(child.get()); + }); + } else { + if (child instanceof Node) { + e.append(child); + } else { + e.append(document.createTextNode(String(child))); + } + } + }); + } + + return e; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..fd59231 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,5 @@ +import { createState, createComputed, createDerived } from "./state.js"; +import { t } from "./dom.js"; +import { Router } from "./router.js"; + +export { createState, createComputed, createDerived, t, Router }; \ No newline at end of file diff --git a/src/router.ts b/src/router.ts new file mode 100644 index 0000000..52bd20b --- /dev/null +++ b/src/router.ts @@ -0,0 +1,123 @@ +type RouteParams = Record; +type QueryParams = Record; + +export interface RouteContext { + params: RouteParams; + query: QueryParams +} + +type NextFunction = () => void; +type RouteHandler = (ctx: RouteContext) => void; +type RouteGuard = (ctx: RouteContext, next: NextFunction) => void; + +interface Route { + path: string; + regex: RegExp, + keys: string[], + handler: RouteHandler; + guards: RouteGuard[]; +} + +export class Router { + + private routes: Route[] = []; + private rootElement: HTMLElement | null = null; + private isTransitioning = false; + + private static instance: Router | null = null; + + constructor(rootId: string) { + this.rootElement = document.getElementById(rootId); + + Router.instance = this; + + window.addEventListener('hashchange', () => this.handleRoute()); + window.addEventListener('load', () => this.handleRoute()) + } + + public static reload() { + if (Router.instance) { + Router.instance.handleRoute(); + } + } + + public addRoute(path: string, handler: RouteHandler, guards: RouteGuard[] = []) { + const keys: string[] = []; + const regexpPath = path.replace(/:(\w+)/g, (_, key) => { + keys.push(key); + return '([^/]+)' + }) + + this.routes.push({ + path, + regex: new RegExp(`^${regexpPath}$`), + keys, handler, guards + }) + } + + private handleRoute() { + if (this.isTransitioning) return; + + const hash = window.location.hash.slice(1) || '/'; + const [path, queryString] = hash.split("?"); + + const query: QueryParams = {}; + new URLSearchParams(queryString).forEach((val, key) => { + query[key] = val + }); + + for (const route of this.routes) { + const match = path.match(route.regex); + + if (match) { + const params: RouteParams = {}; + route.keys.forEach((key, index) => params[key] = match[index + 1]); + + const ctx: RouteContext = { params, query }; + + this.runGuards(route.guards, ctx, () => { + this.performTransition(() => { + route.handler(ctx); + }) + }); + return; + } + } + + } + + private runGuards(guards: RouteGuard[], ctx: RouteContext, callback: () => void) { + let index = 0; + + const next = () => { + if (index < guards.length) { + guards[index++](ctx, next) + } else { + callback(); + } + } + + next(); + } + + private performTransition(updateDomFn: () => void) { + if (!this.rootElement) { + updateDomFn(); + return; + } + + this.isTransitioning = true; + + this.rootElement.classList.add('fading'); + + setTimeout(() => { + updateDomFn(); + this.rootElement?.classList.remove('fading'); + this.isTransitioning = false; + }, 100) + } + + static navigate(path: string) { + window.location.hash = path; + } +} \ No newline at end of file diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 0000000..4dad65f --- /dev/null +++ b/src/state.ts @@ -0,0 +1,71 @@ +type Listener = () => void; +type Unsubscribe = () => void; +export const isState = (obj: any): obj is State => { + return ( + obj !== null && + typeof obj === "object" && + "sub" in obj && + typeof obj.sub === "function" + ); +}; + +export interface State { + get(): T; + set(v: T): void; + sub(fn: Listener): Unsubscribe; +} + +export function createState(initial: T): State { + let val = initial; + const subs: Listener[] = []; + + function get() { + return val; + } + + function set(v: T) { + val = v; + subs.forEach((s) => s()); + } + + function sub(fn: Listener) { + subs.push(fn); + + return () => { + const idx = subs.indexOf(fn); + if (idx > -1) { + subs.splice(idx, 1); + } + }; + } + + return { get, set, sub }; +} + +export function createDerived( + original: State, + selector: (val: T) => U, +): State { + return { + get: () => selector(original.get()), + set: (v) => { + console.warn("Cannot set value on derived state."); + }, + sub: (fn) => { + return original.sub(fn); + }, + }; +} + +export function createComputed(fn: () => T, deps: State[]): State { + const derived = createState(fn()); + const update = () => derived.set(fn()); + + deps.forEach((dep) => dep.sub(update)); + + return { + get: derived.get, + set: () => console.warn("Computed states are read-only"), + sub: derived.sub, + }; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..995bf85 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "skipLibCheck": true, + "target": "es2022", + "allowJs": true, + "resolveJsonModule": true, + "moduleDetection": "force", + "isolatedModules": true, + "verbatimModuleSyntax": true, + "rootDir": "./src", + "declaration": true, + "module": "NodeNext", + "outDir": "dist", + "sourceMap": true, + "lib": [ + "es2022", + "dom", + "dom.iterable" + ], + } +} \ No newline at end of file