This commit is contained in:
Kevand
2026-03-02 15:28:20 +00:00
commit 6a73d483bb
7 changed files with 314 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
package-lock.json
dist

16
package.json Normal file
View File

@@ -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"
}
}

74
src/dom.ts Normal file
View File

@@ -0,0 +1,74 @@
import { type State, isState } from "./state.js";
export function t<K extends keyof HTMLElementTagNameMap>(
tagName: K,
attr: Record<string, any>,
children?: (Node | string | State<any>)[],
): 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;
}

5
src/index.ts Normal file
View File

@@ -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 };

123
src/router.ts Normal file
View File

@@ -0,0 +1,123 @@
type RouteParams = Record<string, string>;
type QueryParams = Record<string, string>;
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;
}
}

71
src/state.ts Normal file
View File

@@ -0,0 +1,71 @@
type Listener = () => void;
type Unsubscribe = () => void;
export const isState = (obj: any): obj is State<any> => {
return (
obj !== null &&
typeof obj === "object" &&
"sub" in obj &&
typeof obj.sub === "function"
);
};
export interface State<T> {
get(): T;
set(v: T): void;
sub(fn: Listener): Unsubscribe;
}
export function createState<T>(initial: T): State<T> {
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<T, U>(
original: State<T>,
selector: (val: T) => U,
): State<U> {
return {
get: () => selector(original.get()),
set: (v) => {
console.warn("Cannot set value on derived state.");
},
sub: (fn) => {
return original.sub(fn);
},
};
}
export function createComputed<T>(fn: () => T, deps: State<any>[]): State<T> {
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,
};
}

22
tsconfig.json Normal file
View File

@@ -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"
],
}
}