init
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
package-lock.json
|
||||||
|
dist
|
||||||
16
package.json
Normal file
16
package.json
Normal 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
74
src/dom.ts
Normal 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
5
src/index.ts
Normal 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
123
src/router.ts
Normal 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
71
src/state.ts
Normal 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
22
tsconfig.json
Normal 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"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user