publish elexis v0.1

- rename: $AsyncNode -> $Async
- remove Router
- update README.md
This commit is contained in:
defaultkavy 2024-04-25 21:11:20 +08:00
parent d28953d354
commit 74327f8eed
11 changed files with 83 additions and 510 deletions

View File

@ -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 $<E extends $Element = $Element>(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<Router>;
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<typeof $.TagNameElementMap[key]>;
[key in keyof $.TagNameElementMap]: InstanceType<$.TagNameElementMap[key]>;
};
export type ContainerTypeTagName = Exclude<keyof TagNameTypeMap, 'input'>;
export type SelfTypeTagName = 'input';
@ -128,10 +114,6 @@ export namespace $ {
: H extends HTMLTextAreaElement ? $Textarea
: $Container<H>;
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<K extends $Node> = (self: K) => void;

1
.gitignore vendored
View File

@ -1,4 +1,3 @@
extensions
.npmignore
bun.lockb
node_modules

158
README.md
View File

@ -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 `<a>` 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 <a> 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 <div> 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 <li> element with same content
$.builder('li', 10, ($li) => $li.content('Not a unique content of list item!'))
// create <li> element depend on array length
$.builder('li', [
// if insert a function,
// builder will callback this function after create this <li> element
($li) => $li.css({color: 'red'}).content('List item with customize style!'),
// if insert a string or element,
// builder will create <li> element and insert this into <li>
'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 <div> 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))
```
## 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.

View File

@ -18,7 +18,7 @@ declare global {
type TextDirection = 'ltr' | 'rtl' | 'auto' | '';
type ImageDecoding = "async" | "sync" | "auto";
type ImageLoading = "eager" | "lazy";
type ContructorType<T> = { new (...args: any[]): T }
type ConstructorType<T> = { new (...args: any[]): T }
interface Node {
$: import('./lib/node/$Node').$Node;
}
@ -30,8 +30,6 @@ Array.prototype.detype = function <T extends undefined | null, O>(this: O[], ...
}) as Exclude<O, T>[];
}
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";

22
lib/node/$Async.ts Normal file
View File

@ -0,0 +1,22 @@
import { $Container, $ContainerOptions } from "./$Container";
import { $Node } from "./$Node";
export interface $AsyncNodeOptions extends $ContainerOptions {}
export class $Async<N extends $Node = $Node> extends $Container {
#loaded: boolean = false;
constructor(options?: $AsyncNodeOptions) {
super('async', options)
}
await<T extends $Node = $Node>($node: Promise<T>) {
$node.then($node => this._loaded($node));
return this as $Async<T>
}
protected _loaded($node: $Node) {
this.#loaded = true;
this.replace($node)
this.dom.dispatchEvent(new Event('load'))
}
get loaded() { return this.#loaded }
}

View File

@ -1,23 +0,0 @@
import { $Node } from "./$Node";
export interface $AsyncNodeOptions {
dom?: Node;
}
export class $AsyncNode<N extends $Node = $Node> extends $Node {
dom: Node = document.createElement('async');
loaded: boolean = false;
constructor(options?: $AsyncNodeOptions) {
super()
this.dom.$ = this;
}
await<T extends $Node = $Node>($node: Promise<T>) {
$node.then($node => this._loaded($node));
return this as $AsyncNode<T>
}
protected _loaded($node: $Node) {
this.loaded = true;
this.replace($node)
this.dom.dispatchEvent(new Event('load'))
}
}

View File

@ -43,10 +43,10 @@ export class $Container<H extends HTMLElement = HTMLElement> extends $HTMLElemen
}
//**Query selector one of child element */
$<E extends $Element>(query: string) { return $(this.dom.querySelector(query)) as E | null }
$<E extends $Element>(query: string): E | null { return $(this.dom.querySelector(query)) as E | null }
//**Query selector of child elements */
$all<E extends $Element>(query: string) { return Array.from(this.dom.querySelectorAll(query)).map($dom => $($dom) as E) }
$all<E extends $Element>(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 }

View File

@ -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
})
```

View File

@ -1,48 +0,0 @@
import { $EventManager, $EventMethod } from "../$EventManager";
import { $Node } from "../node/$Node";
import { $Util } from "../$Util";
export class Route<Path extends string | PathResolverFn> {
path: string | PathResolverFn;
builder: (req: RouteRequest<Path>) => RouteContent;
constructor(path: Path, builder: ((req: RouteRequest<Path>) => RouteContent) | RouteContent) {
this.path = path;
this.builder = builder instanceof Function ? builder : (req: RouteRequest<Path>) => builder;
}
}
type PathParams<Path> = Path extends `${infer Segment}/${infer Rest}`
? Segment extends `${string}:${infer Param}` ? Record<Param, string> & PathParams<Rest> : PathParams<Rest>
: Path extends `${string}:${infer Param}` ? Record<Param,string> : {}
export type PathResolverFn = (path: string) => undefined | string;
type PathParamResolver<P extends PathResolverFn | string> = P extends PathResolverFn
? undefined : PathParams<P>
// type PathResolverRecord<P extends PathResolverFn> = {
// [key in keyof ReturnType<P>]: ReturnType<P>[key]
// }
export interface RouteRecord extends $EventMethod<RouteRecordEventMap> {};
export class RouteRecord {
id: string;
readonly content?: $Node;
events = new $EventManager<RouteRecordEventMap>().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<Path extends PathResolverFn | string> {
params: PathParamResolver<Path>,
record: RouteRecord,
loaded: () => void;
}
export type RouteContent = $Node | string | void;

View File

@ -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<RouterEventMap> {};
export class Router {
routeMap = new Map<string | PathResolverFn, Route<any>>();
recordMap = new Map<string, RouteRecord>();
$view: $View;
static index: number = 0;
static events = new $EventManager<RouterGlobalEventMap>().register('pathchange', 'notfound', 'load');
events = new $EventManager<RouterEventMap>().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<Route<any>>) {
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<any>, 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<RouteRecord>).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<K extends keyof RouterGlobalEventMap>(type: K, callback: (...args: RouterGlobalEventMap[K]) => any) { this.events.on(type, callback); return this }
static off<K extends keyof RouterGlobalEventMap>(type: K, callback: (...args: RouterGlobalEventMap[K]) => any) { this.events.off(type, callback); return this }
static once<K extends keyof RouterGlobalEventMap>(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};
}

View File

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