diff --git a/$index.ts b/$index.ts index 9d7918b..cfd621d 100644 --- a/$index.ts +++ b/$index.ts @@ -8,7 +8,6 @@ import { $Input } from "./lib/node/$Input"; import { $Container } from "./lib/node/$Container"; import { $Element } from "./lib/node/$Element"; import { $Label } from "./lib/node/$Label"; -import { Router } from "./lib/router/Router"; import { $Image } from "./lib/node/$Image"; import { $Canvas } from "./lib/node/$Canvas"; import { $Dialog } from "./lib/node/$Dialog"; @@ -19,7 +18,7 @@ import { $OptGroup } from "./lib/node/$OptGroup"; import { $Textarea } from "./lib/node/$Textarea"; import { $Util } from "./lib/$Util"; import { $HTMLElement } from "./lib/node/$HTMLElement"; -import { $AsyncNode } from "./lib/node/$AsyncNode"; +import { $Async } from "./lib/node/$Async"; export type $ = typeof $; export function $(query: `::${string}`): E[]; @@ -41,25 +40,11 @@ export function $(resolver: any) { if (resolver.startsWith('::')) return Array.from(document.querySelectorAll(resolver.replace(/^::/, ''))).map(dom => $(dom)); else if (resolver.startsWith(':')) return $(document.querySelector(resolver.replace(/^:/, ''))); else if (resolver in $.TagNameElementMap) { - const instance = $.TagNameElementMap[resolver as keyof typeof $.TagNameElementMap] - switch (instance) { - case $HTMLElement: return new $HTMLElement(resolver); - case $Anchor: return new $Anchor(); - case $Container: return new $Container(resolver); - case $Input: return new $Input(); - case $Label: return new $Label(); - case $Form: return new $Form(); - case $Button: return new $Button(); - case $Image: return new $Image(); - case $Canvas: return new $Canvas(); - case $Dialog: return new $Dialog(); - case $View: return new $View(); - case $Select: return new $Select(); - case $Option: return new $Option(); - case $OptGroup: return new $OptGroup(); - case $Textarea: return new $Textarea(); - case $AsyncNode: return new $AsyncNode(); - } + const instance = $.TagNameElementMap[resolver as keyof $.TagNameElementMap] + if (instance === $HTMLElement) return new $HTMLElement(resolver); + if (instance === $Container) return new $Container(resolver); + //@ts-expect-error + return new instance(); } else return new $Container(resolver); } if (resolver instanceof Node) { @@ -71,7 +56,6 @@ export function $(resolver: any) { export namespace $ { export let anchorHandler: null | (($a: $Anchor, e: Event) => void) = null; export let anchorPreventDefault: boolean = false; - export const routers = new Set; export const TagNameElementMap = { 'document': $Document, 'body': $Container, @@ -104,10 +88,12 @@ export namespace $ { 'option': $Option, 'optgroup': $OptGroup, 'textarea': $Textarea, - 'async': $AsyncNode, + 'async': $Async, } + export type TagNameElementMapType = typeof TagNameElementMap; + export interface TagNameElementMap extends TagNameElementMapType {} export type TagNameTypeMap = { - [key in keyof typeof $.TagNameElementMap]: InstanceType; + [key in keyof $.TagNameElementMap]: InstanceType<$.TagNameElementMap[key]>; }; export type ContainerTypeTagName = Exclude; export type SelfTypeTagName = 'input'; @@ -128,10 +114,6 @@ export namespace $ { : H extends HTMLTextAreaElement ? $Textarea : $Container; - export function open(path: string | URL | undefined) { return Router.open(path) } - export function replace(path: string | URL | undefined) { return Router.replace(path) } - export function back() { return Router.back() } - /** * A helper for fluent method design. Return the `instance` object when arguments length not equal 0. Otherwise, return the `value`. * @param instance The object to return when arguments length not equal 0. @@ -249,6 +231,11 @@ export namespace $ { else return false; } } + + export function registerTagName(string: string, node: {new(...args: undefined[]): $Node}) { + Object.assign($.TagNameElementMap, {[string]: node}); + return $.TagNameElementMap; + } } type BuildNodeFunction = (...args: any[]) => $Node; type BuilderSelfFunction = (self: K) => void; diff --git a/.gitignore b/.gitignore index f3ce53f..0b3c9c0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -extensions .npmignore bun.lockb node_modules \ No newline at end of file diff --git a/README.md b/README.md index bb1feec..39d3d76 100644 --- a/README.md +++ b/README.md @@ -1,132 +1,44 @@ -# fluentX - Fast, fluent, simple web builder. -Inspired by jQuery, but not selecting query anymore, just create it. +# ElexisJS +TypeScript First Web Framework, for Humans. +> ElexisJS is still in beta test now, some breaking changes might happen very often. -## Usage +## What does ElexisJS bring to developer? +1. Write website with Native JavaScript syntax and full TypeScript development experiance, no more HTML or JSX. +2. For fluent method lovers. +3. Easy to import or create extensions to extend more functional. + +## Installation +1. Install from npm + ``` + npm i elexis + ``` +2. Import to your project main entry js/ts file. + ```ts + import 'elexis'; + ``` +3. Use web packaging tools like [Vite](https://vitejs.dev/) to compile your project. + +## How to Create Element +Using the simple $ function to create any element with node name. ```ts -import { $ } from 'fluentx' +$('a'); +``` +> This is not jQuery selector! It looks like same but it actually create `` element, not selecting them. -const $app = $('app').content([ - $('h1').content('Hello World!') -]) - -document.body.append($app.dom) // render $app +## Fluent method +Create and modify element in one line. +```ts +$('h1').class('title').css({color: 'red'}) ``` -## Forget HTML, create any element just like this +## Build your first "Hello, world!" ElexisJS project +Let's try this in your entry file: ```ts -$('a') -``` - -## Yes, Fluent Method. -```ts -$('h1').class('amazing-title').css({color: 'red'}).content('Fluuuuuuuuuuuuent!') -``` - -## Single Page App with Router -```ts -const router = new Router('/') - // example.com - .addRoute(new Route('/', () => 'Welcome to your first page!')) - - // example.com/user/anyusername - .addRoute(new Route('/user/:username', (params) => { - return $('div').content([ - $('h1').content(params.username) - ]) - })) - - .listen() // start resolve pathname and listen state change - -// prevent jump to other page from link -$.anchorPreventDefault = true; -$.anchorHandler = (url) => { router.open(url) } - -$('a').href('/about').content('Click me will not reload page.') -``` - -## Insert element(s) with condition -```ts -// Example 1 -$('div').content([ - $('h1').content(params.username), - // conditional - params.username === 'admin' ? $('span').content('Admin is here!') : undefined -]) - -// Example 2 -$('div').content([ - $('h1').content(params.username), - params.username === 'alien' ? [ - // the elements in this array will insert to
when conditional is true - $('span').content('Warning'), - $('span').content('You are contacting with alien!') - ] : undefined +$(document.body).content([ + $('h1').class('title').content('Hello, world!') ]) ``` -## Replace or Insert -```ts -$('div').content(['1', '2', '3']) // 123 - .content(['4']) // 4 - // content method will replace children with elements - - .insert(['5', '6', '7']) // 4567 - // using insert method to avoid replacement - - .class('class1, class2') // class1, class2 - // class method is replacement method - - .addClass('class3') // class1, class2, class3 - // using addClass method -``` - -## Multiple element builder -```ts -$('ul').content([ - // create 10
  • element with same content - $.builder('li', 10, ($li) => $li.content('Not a unique content of list item!')) - - // create
  • element depend on array length - $.builder('li', [ - // if insert a function, - // builder will callback this function after create this
  • element - ($li) => $li.css({color: 'red'}).content('List item with customize style!'), - - // if insert a string or element, - // builder will create
  • element and insert this into
  • - 'List item with just text', - $('a').href('/').content('List item but with a link!') - ]) -]) -``` - -## Element builder with function -```ts -// This is a template function that return a
    element -function UserCard(name: string, age: number) { - return $('div').content([ - $('h2').content(name), - $('span').content(`${bio} year old`) - ]) -} - -// A user data array -const userDataList = [ - { name: 'Amateras', age: 16 }, - { name: 'Tsukimi', age: 16}, - { name: 'Rei', age: 14}, - { name: 'Ichi', age: 14}, -] - -// This function will create 10 UserCard element with same name and age -// Using tuple [Function, ...args] to call function with paramerters -$.builder([UserCard, 'Shizuka', 16], 100) - -// This function will create UserCard with the amount depend on array length -$.builder( - UserCard, - userDataList.map(userData => [userData.name, userData.age])) - -// Same result as (prefer) -userDataList.map(userData => UserCard(userData.name, userData.age)) -``` \ No newline at end of file +## Extensions +1. [@elexis/router](https://github.com/elexisjs/router): Router for Single Page App. +2. [@elexis/layout](https://github.com/elexisjs/layout): Build waterfall/justified layout with automatic compute content size and position. \ No newline at end of file diff --git a/index.ts b/index.ts index f48887d..c90f8dd 100644 --- a/index.ts +++ b/index.ts @@ -18,7 +18,7 @@ declare global { type TextDirection = 'ltr' | 'rtl' | 'auto' | ''; type ImageDecoding = "async" | "sync" | "auto"; type ImageLoading = "eager" | "lazy"; - type ContructorType = { new (...args: any[]): T } + type ConstructorType = { new (...args: any[]): T } interface Node { $: import('./lib/node/$Node').$Node; } @@ -30,8 +30,6 @@ Array.prototype.detype = function (this: O[], ... }) as Exclude[]; } export * from "./$index"; -export * from "./lib/router/Route"; -export * from "./lib/router/Router"; export * from "./lib/node/$Node"; export * from "./lib/node/$Anchor"; export * from "./lib/node/$Element"; @@ -48,5 +46,5 @@ export * from "./lib/node/$Option"; export * from "./lib/node/$OptGroup"; export * from "./lib/node/$Textarea"; export * from "./lib/node/$Image"; -export * from "./lib/node/$AsyncNode"; +export * from "./lib/node/$Async"; export * from "./lib/node/$Document"; \ No newline at end of file diff --git a/lib/node/$Async.ts b/lib/node/$Async.ts new file mode 100644 index 0000000..5ef5358 --- /dev/null +++ b/lib/node/$Async.ts @@ -0,0 +1,22 @@ +import { $Container, $ContainerOptions } from "./$Container"; +import { $Node } from "./$Node"; +export interface $AsyncNodeOptions extends $ContainerOptions {} +export class $Async extends $Container { + #loaded: boolean = false; + constructor(options?: $AsyncNodeOptions) { + super('async', options) + } + + await($node: Promise) { + $node.then($node => this._loaded($node)); + return this as $Async + } + + protected _loaded($node: $Node) { + this.#loaded = true; + this.replace($node) + this.dom.dispatchEvent(new Event('load')) + } + + get loaded() { return this.#loaded } +} \ No newline at end of file diff --git a/lib/node/$AsyncNode.ts b/lib/node/$AsyncNode.ts deleted file mode 100644 index 4865332..0000000 --- a/lib/node/$AsyncNode.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { $Node } from "./$Node"; -export interface $AsyncNodeOptions { - dom?: Node; -} -export class $AsyncNode extends $Node { - dom: Node = document.createElement('async'); - loaded: boolean = false; - constructor(options?: $AsyncNodeOptions) { - super() - this.dom.$ = this; - } - - await($node: Promise) { - $node.then($node => this._loaded($node)); - return this as $AsyncNode - } - - protected _loaded($node: $Node) { - this.loaded = true; - this.replace($node) - this.dom.dispatchEvent(new Event('load')) - } -} \ No newline at end of file diff --git a/lib/node/$Container.ts b/lib/node/$Container.ts index 8ce02e3..4706276 100644 --- a/lib/node/$Container.ts +++ b/lib/node/$Container.ts @@ -43,10 +43,10 @@ export class $Container extends $HTMLElemen } //**Query selector one of child element */ - $(query: string) { return $(this.dom.querySelector(query)) as E | null } + $(query: string): E | null { return $(this.dom.querySelector(query)) as E | null } //**Query selector of child elements */ - $all(query: string) { return Array.from(this.dom.querySelectorAll(query)).map($dom => $($dom) as E) } + $all(query: string): E[] { return Array.from(this.dom.querySelectorAll(query)).map($dom => $($dom) as E) } get scrollHeight() { return this.dom.scrollHeight } get scrollWidth() { return this.dom.scrollWidth } diff --git a/lib/router/README.md b/lib/router/README.md deleted file mode 100644 index f5731d5..0000000 --- a/lib/router/README.md +++ /dev/null @@ -1,79 +0,0 @@ -# fluentX/router - -## Usage -```ts -import { $, Router, Route } from 'fluentX'; - -// create new Router with base path '/', -// also create a custom view Element for router container -const router = new Router('/', $('view')); - -// append router view element -const $app = $('app').content([ - router.$view -]) - -const home_page_route = new Route('/', () => { - // which this callback function return will be appended - // into router view element when path match - return $('h1').content('This is a Homepage!') -}) - -// Add home_page_route into router -router.addRoute(home_page_route); -// Router starting listen location path change -router.listen(); -``` - -## Events - -### Router - load -```ts -const gallery_route = new Route('/gallery', ({loaded}) => { - - async function $ImageList() { - // fetch images and return elements - - // after all image loaded, using loaded function. - // this will trigger load event on anccestor router - loaded(); - } - return $('div').content([ - $ImageList() - ]) -}) - -router.on('load', () => {...}) -``` - -### RouteRecord - open -```ts -const viewer_route = new Route('/viewer', ({record}) => { - const page_open_count$ = $.state(0); - - // this event will be fire everytime this page opened - record.on('open', () => { - page_open_count$.set( page_open_count$.value + 1 ); - }) - - return $('div').content(page_open_count$) -}) -``` - -### Router - notfound -```ts -// Route will remove all child of view when path is not exist. -// Using preventDefault function to prevent this action. -router.on('notfound', ({preventDefault}) => { - preventDefault(); // prevent remove all child of view - ... // do something -}) -``` - -### Router - pathchange -```ts -// This event fired on location change happened -router.on('pathchange', () => { - ... // do something -}) -``` \ No newline at end of file diff --git a/lib/router/Route.ts b/lib/router/Route.ts deleted file mode 100644 index 5a08e4d..0000000 --- a/lib/router/Route.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { $EventManager, $EventMethod } from "../$EventManager"; -import { $Node } from "../node/$Node"; -import { $Util } from "../$Util"; -export class Route { - path: string | PathResolverFn; - builder: (req: RouteRequest) => RouteContent; - constructor(path: Path, builder: ((req: RouteRequest) => RouteContent) | RouteContent) { - this.path = path; - this.builder = builder instanceof Function ? builder : (req: RouteRequest) => builder; - } -} - -type PathParams = Path extends `${infer Segment}/${infer Rest}` - ? Segment extends `${string}:${infer Param}` ? Record & PathParams : PathParams - : Path extends `${string}:${infer Param}` ? Record : {} - -export type PathResolverFn = (path: string) => undefined | string; - -type PathParamResolver

    = P extends PathResolverFn -? undefined : PathParams

    - -// type PathResolverRecord

    = { -// [key in keyof ReturnType

    ]: ReturnType

    [key] -// } - - -export interface RouteRecord extends $EventMethod {}; -export class RouteRecord { - id: string; - readonly content?: $Node; - events = new $EventManager().register('open', 'load') - constructor(id: string) { - this.id = id; - } -} -$Util.mixin(RouteRecord, $EventMethod) -export interface RouteRecordEventMap { - 'open': [{path: string, record: RouteRecord}]; - 'load': [{path: string, record: RouteRecord}]; -} - -export interface RouteRequest { - params: PathParamResolver, - record: RouteRecord, - loaded: () => void; -} - -export type RouteContent = $Node | string | void; \ No newline at end of file diff --git a/lib/router/Router.ts b/lib/router/Router.ts deleted file mode 100644 index 043cf1e..0000000 --- a/lib/router/Router.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { $EventManager, $EventMethod } from "../$EventManager"; -import { $Text } from "../node/$Text"; -import { $Util } from "../$Util"; -import { $View } from "../node/$View"; -import { PathResolverFn, Route, RouteRecord } from "./Route"; -export interface Router extends $EventMethod {}; -export class Router { - routeMap = new Map>(); - recordMap = new Map(); - $view: $View; - static index: number = 0; - static events = new $EventManager().register('pathchange', 'notfound', 'load'); - events = new $EventManager().register('notfound', 'load'); - basePath: string; - static currentPath: URL = new URL(location.href); - constructor(basePath: string, view?: $View) { - this.basePath = basePath; - this.$view = view ?? new $View(); - } - - /**Add route to Router. @example Router.addRoute(new Route('/', 'Hello World')) */ - addRoute(routes: OrArray>) { - routes = $.orArrayResolve(routes); - for (const route of routes) this.routeMap.set(route.path, route); - return this; - } - - /**Start listen to the path change */ - listen() { - if (!history.state || 'index' in history.state === false) { - const routeData: RouteData = {index: Router.index, data: {}} - history.replaceState(routeData, '') - } else { - Router.index = history.state.index - } - addEventListener('popstate', this.popstate) - $.routers.add(this); - this.resolvePath(); - Router.events.fire('pathchange', {prevURL: undefined, nextURL: Router.currentPath, navigation: 'Forward'}); - return this; - } - - /**Open URL */ - static open(url: string | URL | undefined) { - if (url === undefined) return this; - url = new URL(url); - if (url.origin !== location.origin) return this; - if (url.href === location.href) return this; - const prevPath = Router.currentPath; - this.index += 1; - const routeData: RouteData = { index: this.index, data: {} }; - history.pushState(routeData, '', url); - Router.currentPath = new URL(location.href); - $.routers.forEach(router => router.resolvePath()) - Router.events.fire('pathchange', {prevURL: prevPath, nextURL: Router.currentPath, navigation: 'Forward'}); - return this; - } - - /**Back to previous page */ - static back() { - const prevPath = Router.currentPath; - history.back(); - Router.currentPath = new URL(location.href); - Router.events.fire('pathchange', {prevURL: prevPath, nextURL: Router.currentPath, navigation: 'Back'}); - return this - } - - static replace(url: string | URL | undefined) { - if (url === undefined) return this; - if (typeof url === 'string' && !url.startsWith(location.origin)) url = location.origin + url; - url = new URL(url); - if (url.origin !== location.origin) return this; - if (url.href === location.href) return this; - const prevPath = Router.currentPath; - history.replaceState({index: Router.index}, '', url) - Router.currentPath = new URL(location.href); - $.routers.forEach(router => router.resolvePath(url.pathname)); - Router.events.fire('pathchange', {prevURL: prevPath, nextURL: Router.currentPath, navigation: 'Forward'}); - return this; - } - - setStateData(key: string, value: any) { - if (history.state.data === undefined) history.state.data = {}; - history.state.data[key] = value; - return this; - } - - private popstate = (() => { - // Forward - if (history.state.index > Router.index) { } - // Back - else if (history.state.index < Router.index) { } - const prevPath = Router.currentPath; - Router.index = history.state.index; - this.resolvePath(); - Router.currentPath = new URL(location.href); - Router.events.fire('pathchange', {prevURL: prevPath, nextURL: Router.currentPath, navigation: 'Forward'}); - }).bind(this) - - private resolvePath(path = location.pathname) { - if (!path.startsWith(this.basePath)) return; - path = path.replace(this.basePath, '/').replace('//', '/'); - let found = false; - const openCached = (pathId: string) => { - const record = this.recordMap.get(pathId); - if (record) { - found = true; - if (record.content && !this.$view.contains(record.content)) this.$view.switchView(pathId); - record.events.fire('open', {path, record}); - return true; - } - return false; - } - const create = (pathId: string, route: Route, data: any) => { - const record = new RouteRecord(pathId); - let content = route.builder({ - params: data, - record: record, - loaded: () => { - record.events.fire('load', {path: pathId, record}); - this.events.fire('load', {path: pathId}); - } - }); - if (typeof content === 'string') content = new $Text(content); - if (content === undefined) return; - (record as Mutable).content = content; - this.recordMap.set(pathId, record); - this.$view.setView(pathId, content).switchView(pathId); - record.events.fire('open', {path, record}); - found = true; - } - for (const [pathResolver, route] of this.routeMap.entries()) { - // PathResolverFn - if (pathResolver instanceof Function) { - const routeId = pathResolver(path) - if (routeId) { if (!openCached(routeId)) create(routeId, route, undefined) } - continue; - } - // string - const [_routeParts, _pathParts] = [pathResolver.split('/').map(p => `/${p}`), path.split('/').map(p => `/${p}`)]; - _routeParts.shift(); _pathParts.shift(); - const data = {}; - let pathString = ''; - for (let i = 0; i < _pathParts.length; i++) { - const [routePart, pathPart] = [_routeParts.at(i), _pathParts.at(i)]; - if (!routePart || !pathPart) continue; - if (routePart === pathPart) { - pathString += pathPart; - if (routePart === _routeParts.at(-1)) { - if (!openCached(pathString)) create(pathString, route, data); - return; - } - } - else if (routePart.includes(':')) { - const [prefix, param] = routePart.split(':'); - if (!pathPart.startsWith(prefix)) continue; - Object.assign(data, {[param]: pathPart.replace(prefix, '')}) - pathString += pathPart; - if (routePart === _routeParts.at(-1)) { - if (!openCached(pathString)) create(pathString, route, data); - return; - } - } - } - } - - if (!found) { - let preventDefaultState = false; - const preventDefault = () => preventDefaultState = true; - this.events.fire('notfound', {path, preventDefault}); - if (!preventDefaultState) this.$view.clear(); - } - } - - static on(type: K, callback: (...args: RouterGlobalEventMap[K]) => any) { this.events.on(type, callback); return this } - static off(type: K, callback: (...args: RouterGlobalEventMap[K]) => any) { this.events.off(type, callback); return this } - static once(type: K, callback: (...args: RouterGlobalEventMap[K]) => any) { this.events.once(type, callback); return this } -} -$Util.mixin(Router, $EventMethod); -interface RouterEventMap { - notfound: [{path: string, preventDefault: () => any}]; - load: [{path: string}]; -} - -interface RouterGlobalEventMap { - pathchange: [{prevURL?: URL, nextURL: URL, navigation: 'Back' | 'Forward'}]; -} - -type RouteData = { - index: number; - data: {[key: string]: any}; -} \ No newline at end of file diff --git a/package.json b/package.json index 0b3d653..283c2ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { - "name": "fluentx", - "version": "0.0.9", + "name": "elexis", + "description": "Web library design for JS/TS lover.", + "version": "0.1.0", "author": { "name": "defaultkavy", "email": "defaultkavy@gmail.com", @@ -8,18 +9,14 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/defaultkavy/fluentx.git" + "url": "git+https://github.com/defaultkavy/elexis.git" }, "module": "index.ts", "bugs": { - "url": "https://github.com/defaultkavy/fluentx/issues" + "url": "https://github.com/defaultkavy/elexis/issues" }, - "description": "Fast, fluent, simple web builder", - "homepage": "https://github.com/defaultkavy/fluentx", + "homepage": "https://github.com/defaultkavy/elexis", "keywords": ["web", "front-end", "lib", "fluent", "framework"], "license": "ISC", - "type": "module", - "workspaces": [ - "extensions/*" - ] + "type": "module" } \ No newline at end of file