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