mirror of
https://github.com/nvms/soma3.git
synced 2025-12-13 06:40:52 +00:00
first
This commit is contained in:
commit
31fea795bf
25
.esr.yml
Normal file
25
.esr.yml
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
bundle: true
|
||||||
|
platform: browser
|
||||||
|
format: esm
|
||||||
|
sourcemap: true
|
||||||
|
outdir: public
|
||||||
|
|
||||||
|
watch:
|
||||||
|
paths: ['src/**/*.{ts,tsx,js,jsx,css,scss,html}', 'public/index.html']
|
||||||
|
|
||||||
|
serve:
|
||||||
|
html: public/index.html
|
||||||
|
port: 1234
|
||||||
|
|
||||||
|
build:
|
||||||
|
minify: true
|
||||||
|
minifyWhitespace: true
|
||||||
|
minifyIdentifiers: true
|
||||||
|
sourcemap: false
|
||||||
|
|
||||||
|
run:
|
||||||
|
runtime: bun
|
||||||
|
sourcemap: false
|
||||||
|
|
||||||
|
jsx: automatic
|
||||||
|
jsxFactory: React.createElement
|
||||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
6
.prettierrc
Normal file
6
.prettierrc
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 200,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"tabWidth": 2
|
||||||
|
}
|
||||||
22
package.json
Normal file
22
package.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "soma3",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"scripts": {
|
||||||
|
"serve": "esr --serve src/index.ts",
|
||||||
|
"build": "esr --build src/index.ts",
|
||||||
|
"build:watch": "esr --build --watch src/index.ts",
|
||||||
|
"run": "esr --run src/index.ts",
|
||||||
|
"run:watch": "esr --run --watch src/index.ts",
|
||||||
|
"test": "vitest"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"devDependencies": {
|
||||||
|
"happy-dom": "^15.7.4",
|
||||||
|
"vitest": "^2.1.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"path-to-regexp": "6.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
15
public/index.html
Normal file
15
public/index.html
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="color-scheme" content="light dark">
|
||||||
|
<title>Application</title>
|
||||||
|
{{ css }}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
{{ livereload }}
|
||||||
|
{{ js }}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1215
public/index.js
Executable file
1215
public/index.js
Executable file
File diff suppressed because it is too large
Load Diff
7
public/index.js.map
Executable file
7
public/index.js.map
Executable file
File diff suppressed because one or more lines are too long
113
src/directives/attribute.ts
Normal file
113
src/directives/attribute.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { Context, evalGet } from "..";
|
||||||
|
import { classNames, extractAttributeName } from "../util";
|
||||||
|
|
||||||
|
interface AttributeDirectiveOptions {
|
||||||
|
element: Element;
|
||||||
|
context: Context;
|
||||||
|
attr: Attr;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Is {
|
||||||
|
sameNameProperty: boolean;
|
||||||
|
bound: boolean;
|
||||||
|
spread: boolean;
|
||||||
|
componentProp: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AttributeDirective {
|
||||||
|
element: Element;
|
||||||
|
context: Context;
|
||||||
|
expression: string;
|
||||||
|
attr: Attr;
|
||||||
|
extractedAttributeName: string;
|
||||||
|
|
||||||
|
previousClasses: string[] = [];
|
||||||
|
previousStyles: { [key: string]: string } = {};
|
||||||
|
|
||||||
|
is: Is = {
|
||||||
|
sameNameProperty: false,
|
||||||
|
bound: false,
|
||||||
|
spread: false,
|
||||||
|
componentProp: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor({ element, context, attr }: AttributeDirectiveOptions) {
|
||||||
|
this.element = element;
|
||||||
|
this.context = context;
|
||||||
|
this.expression = attr.value;
|
||||||
|
this.attr = attr;
|
||||||
|
this.extractedAttributeName = extractAttributeName(attr.name);
|
||||||
|
|
||||||
|
this.is = {
|
||||||
|
sameNameProperty: attr.name.startsWith("{") && attr.name.endsWith("}"),
|
||||||
|
bound: attr.name.includes(":bind"),
|
||||||
|
spread: attr.name.startsWith("..."),
|
||||||
|
componentProp: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.is.sameNameProperty) {
|
||||||
|
this.expression = this.extractedAttributeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.is.spread) {
|
||||||
|
this.expression = this.extractedAttributeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("attribute", attr.name, "spread?", this.is.spread)
|
||||||
|
|
||||||
|
element.removeAttribute(attr.name);
|
||||||
|
|
||||||
|
if (this.is.bound) {
|
||||||
|
context.effect(this.update.bind(this));
|
||||||
|
} else {
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
let value = evalGet(this.context.scope, this.expression);
|
||||||
|
|
||||||
|
if (this.is.spread && typeof value === "object") {
|
||||||
|
for (const [key, val] of Object.entries(value)) {
|
||||||
|
this.element.setAttribute(key, String(val));
|
||||||
|
}
|
||||||
|
} else if ((typeof value === "object" || Array.isArray(value)) && this.extractedAttributeName === "class") {
|
||||||
|
value = classNames(value);
|
||||||
|
const next = value.split(" ");
|
||||||
|
|
||||||
|
// If we now have classes that are not already on the element, add them now.
|
||||||
|
// Remove classes that are no longer on the element.
|
||||||
|
const diff = next.filter((c: string) => !this.previousClasses.includes(c)).filter(Boolean);
|
||||||
|
const rm = this.previousClasses.filter((c) => !next.includes(c));
|
||||||
|
|
||||||
|
diff.forEach((c: string) => {
|
||||||
|
this.previousClasses.push(c);
|
||||||
|
this.element.classList.add(c);
|
||||||
|
});
|
||||||
|
|
||||||
|
rm.forEach((c) => {
|
||||||
|
this.previousClasses = this.previousClasses.filter((addedClass) => addedClass !== c);
|
||||||
|
this.element.classList.remove(c);
|
||||||
|
});
|
||||||
|
} else if (typeof value === "object" && this.extractedAttributeName === "style") {
|
||||||
|
const next = Object.keys(value);
|
||||||
|
const rm = Object.keys(this.previousStyles).filter((style) => !next.includes(style));
|
||||||
|
|
||||||
|
next.forEach((style) => {
|
||||||
|
this.previousStyles[style] = value[style];
|
||||||
|
// @ts-ignore
|
||||||
|
this.element.style[style] = value[style];
|
||||||
|
});
|
||||||
|
|
||||||
|
rm.forEach((style) => {
|
||||||
|
this.previousStyles[style] = "";
|
||||||
|
// @ts-ignore
|
||||||
|
this.element.style[style] = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
this.previousStyles = value;
|
||||||
|
} else {
|
||||||
|
this.element.setAttribute(this.extractedAttributeName, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/directives/event.ts
Normal file
40
src/directives/event.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { Context, evalGet } from "..";
|
||||||
|
|
||||||
|
interface EventDirectiveOptions {
|
||||||
|
element: Element;
|
||||||
|
context: Context;
|
||||||
|
attr: Attr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EventDirective {
|
||||||
|
element: Element;
|
||||||
|
context: Context;
|
||||||
|
expression: string;
|
||||||
|
attr: Attr;
|
||||||
|
eventCount = 0;
|
||||||
|
|
||||||
|
constructor({ element, context, attr }: EventDirectiveOptions) {
|
||||||
|
this.element = element;
|
||||||
|
this.context = context;
|
||||||
|
this.expression = attr.value;
|
||||||
|
this.attr = attr;
|
||||||
|
|
||||||
|
const eventName = attr.name.replace(/^@/, "");
|
||||||
|
const parts = eventName.split(".");
|
||||||
|
|
||||||
|
this.element.addEventListener(parts[0], (event) => {
|
||||||
|
if (parts.includes("prevent")) event.preventDefault();
|
||||||
|
if (parts.includes("stop")) event.stopPropagation();
|
||||||
|
if (parts.includes("once") && this.eventCount > 0) return;
|
||||||
|
|
||||||
|
this.eventCount++;
|
||||||
|
|
||||||
|
const handler = evalGet(context.scope, attr.value);
|
||||||
|
if (typeof handler === "function") {
|
||||||
|
handler(event);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
element.removeAttribute(attr.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
156
src/directives/for.ts
Normal file
156
src/directives/for.ts
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import { Block, Component, Context, createScopedContext, evalGet } from "..";
|
||||||
|
import { isArray, isObject } from "../util";
|
||||||
|
|
||||||
|
const forAliasRE = /([\s\S]*?)\s+(?:in|of)\s+([\s\S]*)/;
|
||||||
|
const forIteratorRE = /,([^,\}\]]*)(?:,([^,\}\]]*))?$/;
|
||||||
|
const stripParensRE = /^\(|\)$/g;
|
||||||
|
const destructureRE = /^[{[]\s*((?:[\w_$]+\s*,?\s*)+)[\]}]$/;
|
||||||
|
|
||||||
|
type KeyToIndexMap = Map<any, number>;
|
||||||
|
|
||||||
|
export const _for = (el: Element, exp: string, ctx: Context, component?: Component, componentProps?: Record<string, any>, allProps?: Record<string, any>) => {
|
||||||
|
const inMatch = exp.match(forAliasRE);
|
||||||
|
if (!inMatch) {
|
||||||
|
console.warn(`invalid :for expression: ${exp}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextNode = el.nextSibling;
|
||||||
|
|
||||||
|
const parent = el.parentElement!;
|
||||||
|
const anchor = new Text("");
|
||||||
|
parent.insertBefore(anchor, el);
|
||||||
|
parent.removeChild(el);
|
||||||
|
|
||||||
|
const sourceExp = inMatch[2].trim();
|
||||||
|
let valueExp = inMatch[1].trim().replace(stripParensRE, "").trim();
|
||||||
|
let destructureBindings: string[] | undefined;
|
||||||
|
let isArrayDestructure = false;
|
||||||
|
let indexExp: string | undefined;
|
||||||
|
let objIndexExp: string | undefined;
|
||||||
|
|
||||||
|
let keyAttr = "key";
|
||||||
|
let keyExp = el.getAttribute(keyAttr) || el.getAttribute((keyAttr = ":key")) || el.getAttribute((keyAttr = ":key:bind"));
|
||||||
|
if (keyExp) {
|
||||||
|
el.removeAttribute(keyAttr);
|
||||||
|
if (keyAttr === "key") keyExp = JSON.stringify(keyExp);
|
||||||
|
}
|
||||||
|
|
||||||
|
let match: any;
|
||||||
|
if ((match = valueExp.match(forIteratorRE))) {
|
||||||
|
valueExp = valueExp.replace(forIteratorRE, "").trim();
|
||||||
|
indexExp = match[1].trim();
|
||||||
|
if (match[2]) {
|
||||||
|
objIndexExp = match[2].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((match = valueExp.match(destructureRE))) {
|
||||||
|
destructureBindings = match[1].split(",").map((s: string) => s.trim());
|
||||||
|
isArrayDestructure = valueExp[0] === "[";
|
||||||
|
}
|
||||||
|
|
||||||
|
let mounted = false;
|
||||||
|
let blocks: Block[];
|
||||||
|
let childCtxs: Context[];
|
||||||
|
let keyToIndexMap: Map<any, number>;
|
||||||
|
|
||||||
|
const createChildContexts = (source: unknown): [Context[], KeyToIndexMap] => {
|
||||||
|
const map: KeyToIndexMap = new Map();
|
||||||
|
const ctxs: Context[] = [];
|
||||||
|
|
||||||
|
if (isArray(source)) {
|
||||||
|
for (let i = 0; i < source.length; i++) {
|
||||||
|
ctxs.push(createChildContext(map, source[i], i));
|
||||||
|
}
|
||||||
|
} else if (typeof source === "number") {
|
||||||
|
for (let i = 0; i < source; i++) {
|
||||||
|
ctxs.push(createChildContext(map, i + 1, i));
|
||||||
|
}
|
||||||
|
} else if (isObject(source)) {
|
||||||
|
let i = 0;
|
||||||
|
for (const key in source) {
|
||||||
|
ctxs.push(createChildContext(map, source[key], i++, key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [ctxs, map];
|
||||||
|
};
|
||||||
|
|
||||||
|
const createChildContext = (map: KeyToIndexMap, value: any, index: number, objKey?: string): Context => {
|
||||||
|
const data: any = {};
|
||||||
|
if (destructureBindings) {
|
||||||
|
destructureBindings.forEach((b, i) => (data[b] = value[isArrayDestructure ? i : b]));
|
||||||
|
} else {
|
||||||
|
data[valueExp] = value;
|
||||||
|
}
|
||||||
|
if (objKey) {
|
||||||
|
indexExp && (data[indexExp] = objKey);
|
||||||
|
objIndexExp && (data[objIndexExp] = index);
|
||||||
|
} else {
|
||||||
|
indexExp && (data[indexExp] = index);
|
||||||
|
}
|
||||||
|
|
||||||
|
const childCtx = createScopedContext(ctx, data);
|
||||||
|
const key = keyExp ? evalGet(childCtx.scope, keyExp) : index;
|
||||||
|
map.set(key, index);
|
||||||
|
childCtx.key = key;
|
||||||
|
return childCtx;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mountBlock = (ctx: Context, ref: Node) => {
|
||||||
|
const block = new Block({ element: el, parentContext: ctx, replacementType: "replace", component, componentProps, allProps });
|
||||||
|
block.key = ctx.key;
|
||||||
|
block.insert(parent, ref);
|
||||||
|
return block;
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.effect(() => {
|
||||||
|
const source = evalGet(ctx.scope, sourceExp);
|
||||||
|
const prevKeyToIndexMap = keyToIndexMap;
|
||||||
|
[childCtxs, keyToIndexMap] = createChildContexts(source);
|
||||||
|
if (!mounted) {
|
||||||
|
blocks = childCtxs.map((s) => mountBlock(s, anchor));
|
||||||
|
mounted = true;
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < blocks.length; i++) {
|
||||||
|
if (!keyToIndexMap.has(blocks[i].key)) {
|
||||||
|
blocks[i].remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextBlocks: Block[] = [];
|
||||||
|
let i = childCtxs.length;
|
||||||
|
let nextBlock: Block | undefined;
|
||||||
|
let prevMovedBlock: Block | undefined;
|
||||||
|
while (i--) {
|
||||||
|
const childCtx = childCtxs[i];
|
||||||
|
const oldIndex = prevKeyToIndexMap.get(childCtx.key);
|
||||||
|
let block: Block;
|
||||||
|
if (oldIndex == null) {
|
||||||
|
// new
|
||||||
|
block = mountBlock(childCtx, nextBlock ? nextBlock.element : anchor);
|
||||||
|
} else {
|
||||||
|
// update
|
||||||
|
block = blocks[oldIndex];
|
||||||
|
Object.assign(block.context.scope, childCtx.scope);
|
||||||
|
if (oldIndex !== i) {
|
||||||
|
// moved
|
||||||
|
if (
|
||||||
|
blocks[oldIndex + 1] !== nextBlock ||
|
||||||
|
// If the next has moved, it must move too
|
||||||
|
prevMovedBlock === nextBlock
|
||||||
|
) {
|
||||||
|
prevMovedBlock = block;
|
||||||
|
block.insert(parent, nextBlock ? nextBlock.element : anchor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextBlocks.unshift((nextBlock = block));
|
||||||
|
}
|
||||||
|
blocks = nextBlocks;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return nextNode;
|
||||||
|
};
|
||||||
67
src/directives/if.ts
Normal file
67
src/directives/if.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { Block, Component, Context, evalGet } from "..";
|
||||||
|
import { checkAndRemoveAttribute } from "../util";
|
||||||
|
|
||||||
|
interface Branch {
|
||||||
|
exp?: string | null;
|
||||||
|
el: Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _if(el: Element, exp: string, ctx: Context, component?: Component, componentProps?: Record<string, any>, allProps?: Record<string, any>) {
|
||||||
|
const parent = el.parentElement!;
|
||||||
|
const anchor = new Comment(":if");
|
||||||
|
|
||||||
|
parent.insertBefore(anchor, el);
|
||||||
|
|
||||||
|
const branches: Branch[] = [{ exp, el }];
|
||||||
|
|
||||||
|
let elseEl: Element | null;
|
||||||
|
let elseExp: string | null;
|
||||||
|
|
||||||
|
while ((elseEl = el.nextElementSibling)) {
|
||||||
|
elseExp = null;
|
||||||
|
|
||||||
|
if (checkAndRemoveAttribute(elseEl, ":else") === "" || (elseExp = checkAndRemoveAttribute(elseEl, ":else-if"))) {
|
||||||
|
parent.removeChild(elseEl);
|
||||||
|
branches.push({ exp: elseExp, el: elseEl });
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextNode = el.nextSibling;
|
||||||
|
parent.removeChild(el);
|
||||||
|
|
||||||
|
let block: Block | undefined;
|
||||||
|
let activeBranchIndex = -1;
|
||||||
|
|
||||||
|
const removeActiveBlock = () => {
|
||||||
|
if (block) {
|
||||||
|
parent.insertBefore(anchor, block.element);
|
||||||
|
block.remove();
|
||||||
|
block = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.effect(() => {
|
||||||
|
for (let i = 0; i < branches.length; i++) {
|
||||||
|
const { exp, el } = branches[i];
|
||||||
|
|
||||||
|
if (!exp || evalGet(ctx.scope, exp)) {
|
||||||
|
if (i !== activeBranchIndex) {
|
||||||
|
removeActiveBlock();
|
||||||
|
block = new Block({ element: el, parentContext: ctx, replacementType: "replace", component, componentProps, allProps });
|
||||||
|
block.insert(parent, anchor);
|
||||||
|
parent.removeChild(anchor);
|
||||||
|
activeBranchIndex = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
activeBranchIndex = -1;
|
||||||
|
removeActiveBlock();
|
||||||
|
});
|
||||||
|
|
||||||
|
return nextNode;
|
||||||
|
}
|
||||||
78
src/directives/interpolation.ts
Normal file
78
src/directives/interpolation.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { Context, evalGet } from "../";
|
||||||
|
import { insertAfter, toDisplayString } from "../util";
|
||||||
|
|
||||||
|
interface InterpolationDirectiveOptions {
|
||||||
|
element: Text;
|
||||||
|
context: Context;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delims = /{{\s?(.*?)\s?}}/g;
|
||||||
|
|
||||||
|
export class InterpolationDirective {
|
||||||
|
element: Text;
|
||||||
|
context: Context;
|
||||||
|
textNodes: Map<string, Node[]> = new Map();
|
||||||
|
|
||||||
|
constructor({ element, context }: InterpolationDirectiveOptions) {
|
||||||
|
this.element = element;
|
||||||
|
this.context = context;
|
||||||
|
|
||||||
|
this.findNodes();
|
||||||
|
|
||||||
|
this.textNodes.forEach((nodes, expression) => {
|
||||||
|
const trimmedExpression = expression.slice(2, -2).trim();
|
||||||
|
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
const getter = (exp = trimmedExpression) => evalGet(this.context.scope, exp, node);
|
||||||
|
|
||||||
|
context.effect(() => {
|
||||||
|
node.textContent = toDisplayString(getter());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
findNodes() {
|
||||||
|
const textContent = this.element.textContent.trim();
|
||||||
|
if (textContent?.match(delims)) {
|
||||||
|
const textNodes = textContent.split(/(\{\{\s?[^}]+\s?\}\})/g).filter(Boolean);
|
||||||
|
if (textNodes) {
|
||||||
|
let previousNode = this.element;
|
||||||
|
|
||||||
|
for (let i = 0; i < textNodes.length; i++) {
|
||||||
|
const textNode = textNodes[i];
|
||||||
|
|
||||||
|
if (textNode.match(/\{\{\s?.+\s?\}\}/)) {
|
||||||
|
const newNode = document.createTextNode(textNode);
|
||||||
|
|
||||||
|
if (i === 0) {
|
||||||
|
this.element.replaceWith(newNode);
|
||||||
|
} else {
|
||||||
|
insertAfter(newNode, previousNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
previousNode = newNode;
|
||||||
|
|
||||||
|
if (this.textNodes.has(textNode)) {
|
||||||
|
this.textNodes.get(textNode).push(newNode);
|
||||||
|
} else {
|
||||||
|
this.textNodes.set(textNode, [newNode]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const newNode = document.createTextNode(textNodes[i]);
|
||||||
|
|
||||||
|
if (i === 0) {
|
||||||
|
this.element.replaceWith(newNode);
|
||||||
|
} else {
|
||||||
|
insertAfter(newNode, previousNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
previousNode = newNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {}
|
||||||
|
}
|
||||||
32
src/directives/teleport.ts
Normal file
32
src/directives/teleport.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { Context } from "..";
|
||||||
|
import { nextTick } from "../util";
|
||||||
|
|
||||||
|
export function _teleport(el: Element, exp: string, ctx: Context) {
|
||||||
|
const anchor = new Comment(":teleport");
|
||||||
|
el.replaceWith(anchor);
|
||||||
|
|
||||||
|
const target = document.querySelector(exp);
|
||||||
|
if (!target) {
|
||||||
|
console.warn(`teleport target not found: ${exp}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
target.appendChild(el);
|
||||||
|
});
|
||||||
|
|
||||||
|
const observer = new MutationObserver((mutationsList) => {
|
||||||
|
mutationsList.forEach((mutation) => {
|
||||||
|
mutation.removedNodes.forEach((removedNode) => {
|
||||||
|
if (removedNode.contains(anchor)) {
|
||||||
|
el.remove();
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
return anchor;
|
||||||
|
}
|
||||||
111
src/directives/value.ts
Normal file
111
src/directives/value.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { Context, evalGet, evalSet } from "..";
|
||||||
|
|
||||||
|
interface ValueDirectiveOptions {
|
||||||
|
element: Element;
|
||||||
|
context: Context;
|
||||||
|
expression: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SupportedModelType = "text" | "checkbox" | "radio" | "number" | "password" | "color";
|
||||||
|
|
||||||
|
function isInput(element: Element): element is HTMLInputElement {
|
||||||
|
return element instanceof HTMLInputElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTextarea(element: Element): element is HTMLTextAreaElement {
|
||||||
|
return element instanceof HTMLTextAreaElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSelect(element: Element): element is HTMLSelectElement {
|
||||||
|
return element instanceof HTMLSelectElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ValueDirective {
|
||||||
|
element: Element;
|
||||||
|
context: Context;
|
||||||
|
expression: string;
|
||||||
|
inputType: SupportedModelType;
|
||||||
|
|
||||||
|
constructor({ element, context, expression }: ValueDirectiveOptions) {
|
||||||
|
this.element = element;
|
||||||
|
this.context = context;
|
||||||
|
this.expression = expression;
|
||||||
|
this.inputType = element.getAttribute("type") as SupportedModelType;
|
||||||
|
|
||||||
|
// Element -> Context
|
||||||
|
if (isInput(element)) {
|
||||||
|
switch (this.inputType) {
|
||||||
|
case "text":
|
||||||
|
case "password":
|
||||||
|
case "number":
|
||||||
|
case "color":
|
||||||
|
element.addEventListener("input", () => {
|
||||||
|
const value = this.inputType === "number" ? (element.value ? parseFloat(element.value) : 0) : element.value;
|
||||||
|
evalSet(this.context.scope, expression, value);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "checkbox":
|
||||||
|
element.addEventListener("change", (e: any) => {
|
||||||
|
evalSet(this.context.scope, expression, !!e.currentTarget.checked);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "radio":
|
||||||
|
element.addEventListener("change", (e: any) => {
|
||||||
|
if (e.currentTarget.checked) {
|
||||||
|
evalSet(this.context.scope, expression, element.getAttribute("value"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTextarea(element)) {
|
||||||
|
element.addEventListener("input", () => {
|
||||||
|
evalSet(this.context.scope, expression, element.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSelect(element)) {
|
||||||
|
element.addEventListener("change", () => {
|
||||||
|
evalSet(this.context.scope, expression, element.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context -> Element
|
||||||
|
context.effect(this.updateElementValue.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateElementValue() {
|
||||||
|
const value = evalGet(this.context.scope, this.expression, this.element);
|
||||||
|
|
||||||
|
if (isInput(this.element)) {
|
||||||
|
switch (this.inputType) {
|
||||||
|
case "text":
|
||||||
|
case "password":
|
||||||
|
case "number":
|
||||||
|
case "color":
|
||||||
|
this.element.value = value;
|
||||||
|
break;
|
||||||
|
case "checkbox":
|
||||||
|
this.element.checked = !!value;
|
||||||
|
break;
|
||||||
|
case "radio":
|
||||||
|
this.element.checked = this.element.value === value;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTextarea(this.element)) {
|
||||||
|
this.element.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSelect(this.element)) {
|
||||||
|
this.element.value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
778
src/index.ts
Normal file
778
src/index.ts
Normal file
@ -0,0 +1,778 @@
|
|||||||
|
import { AttributeDirective } from "./directives/attribute";
|
||||||
|
import { EventDirective } from "./directives/event";
|
||||||
|
import { _for } from "./directives/for";
|
||||||
|
import { _if } from "./directives/if";
|
||||||
|
import { InterpolationDirective } from "./directives/interpolation";
|
||||||
|
import { _teleport } from "./directives/teleport";
|
||||||
|
import { ValueDirective } from "./directives/value";
|
||||||
|
import { Plugin } from "./plugins";
|
||||||
|
import { isComputed } from "./reactivity/computed";
|
||||||
|
import { effect as _effect } from "./reactivity/effect";
|
||||||
|
import { reactive } from "./reactivity/reactive";
|
||||||
|
import { isRef, ref } from "./reactivity/ref";
|
||||||
|
import {
|
||||||
|
checkAndRemoveAttribute,
|
||||||
|
componentHasPropByName,
|
||||||
|
extractPropName,
|
||||||
|
findSlotNodes,
|
||||||
|
findTemplateNodes,
|
||||||
|
html,
|
||||||
|
isElement,
|
||||||
|
isEventAttribute,
|
||||||
|
isMirrorProp,
|
||||||
|
isObject,
|
||||||
|
isPropAttribute,
|
||||||
|
isRegularProp,
|
||||||
|
isSpreadProp,
|
||||||
|
isText,
|
||||||
|
Slot,
|
||||||
|
stringToElement,
|
||||||
|
Template,
|
||||||
|
} from "./util";
|
||||||
|
|
||||||
|
export function provide(key: string, value: unknown) {
|
||||||
|
if (!current.componentBlock) {
|
||||||
|
console.warn("Can't provide: no current component block");
|
||||||
|
}
|
||||||
|
|
||||||
|
current.componentBlock.provides.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inject(key: string) {
|
||||||
|
if (!current.componentBlock) {
|
||||||
|
console.warn("Can't inject: no current component block");
|
||||||
|
}
|
||||||
|
|
||||||
|
let c = current.componentBlock;
|
||||||
|
|
||||||
|
while (c) {
|
||||||
|
if (c.provides.has(key)) {
|
||||||
|
return c.provides.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
c = c.parentComponentBlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class App {
|
||||||
|
rootBlock: Block;
|
||||||
|
registry = new Map<string, Component>();
|
||||||
|
plugins = new Set<Plugin>();
|
||||||
|
|
||||||
|
register(name: string, component: Component) {
|
||||||
|
this.registry.set(name, component);
|
||||||
|
}
|
||||||
|
|
||||||
|
use(plugin: Plugin, ...config: any[]) {
|
||||||
|
this.plugins.add(plugin);
|
||||||
|
plugin.use(this, ...config);
|
||||||
|
}
|
||||||
|
|
||||||
|
getComponent(tag: string) {
|
||||||
|
return this.registry.get(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
mount(component: Component, target: string | HTMLElement = "body", props: Record<string, any> = {}) {
|
||||||
|
const root = typeof target === "string" ? (document.querySelector(target) as HTMLElement) : target;
|
||||||
|
const display = root.style.display;
|
||||||
|
root.style.display = "none";
|
||||||
|
this.rootBlock = this._mount(component, root, props, true);
|
||||||
|
root.style.display = display;
|
||||||
|
return this.rootBlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _mount(component: Component, target: HTMLElement, props: Record<string, any>, isRoot = false) {
|
||||||
|
const parentContext = createContext({ app: this });
|
||||||
|
|
||||||
|
if (props) {
|
||||||
|
parentContext.scope = reactive(props);
|
||||||
|
bindContextMethods(parentContext.scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
parentContext.scope.$isRef = isRef;
|
||||||
|
parentContext.scope.$isComputed = isComputed;
|
||||||
|
|
||||||
|
const block = new Block({
|
||||||
|
element: target,
|
||||||
|
parentContext,
|
||||||
|
component,
|
||||||
|
isRoot,
|
||||||
|
componentProps: props,
|
||||||
|
replacementType: "replaceChildren",
|
||||||
|
});
|
||||||
|
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
unmount() {
|
||||||
|
this.rootBlock.teardown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Context {
|
||||||
|
key?: any;
|
||||||
|
app: App;
|
||||||
|
scope: Record<string, any>;
|
||||||
|
blocks: Block[];
|
||||||
|
effects: Array<ReturnType<typeof _effect>>;
|
||||||
|
effect: typeof _effect;
|
||||||
|
slots: Slot[];
|
||||||
|
templates: Template[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateContextOptions {
|
||||||
|
parentContext?: Context;
|
||||||
|
app?: App;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createContext({ parentContext, app }: CreateContextOptions): Context {
|
||||||
|
const context: Context = {
|
||||||
|
app: app ? app : parentContext && parentContext.app ? parentContext.app : null,
|
||||||
|
scope: parentContext ? parentContext.scope : reactive({}),
|
||||||
|
blocks: [],
|
||||||
|
effects: [],
|
||||||
|
slots: [],
|
||||||
|
templates: parentContext ? parentContext.templates : [],
|
||||||
|
effect: (handler: () => void) => {
|
||||||
|
const e = _effect(handler);
|
||||||
|
context.effects.push(e);
|
||||||
|
return e;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createScopedContext = (ctx: Context, data = {}): Context => {
|
||||||
|
const parentScope = ctx.scope;
|
||||||
|
const mergedScope = Object.create(parentScope);
|
||||||
|
Object.defineProperties(mergedScope, Object.getOwnPropertyDescriptors(data));
|
||||||
|
let proxy: any;
|
||||||
|
proxy = reactive(
|
||||||
|
new Proxy(mergedScope, {
|
||||||
|
set(target, key, val, receiver) {
|
||||||
|
// when setting a property that doesn't exist on current scope,
|
||||||
|
// do not create it on the current scope and fallback to parent scope.
|
||||||
|
if (receiver === proxy && !target.hasOwnProperty(key)) {
|
||||||
|
return Reflect.set(parentScope, key, val);
|
||||||
|
}
|
||||||
|
return Reflect.set(target, key, val, receiver);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
bindContextMethods(proxy);
|
||||||
|
|
||||||
|
const out: Context = {
|
||||||
|
...ctx,
|
||||||
|
scope: {
|
||||||
|
...ctx.scope,
|
||||||
|
...proxy,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
function bindContextMethods(scope: Record<string, any>) {
|
||||||
|
for (const key of Object.keys(scope)) {
|
||||||
|
if (typeof scope[key] === "function") {
|
||||||
|
scope[key] = scope[key].bind(scope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeProps(props: Record<string, any>, defaultProps: Record<string, any>) {
|
||||||
|
const merged = {};
|
||||||
|
|
||||||
|
Object.keys(defaultProps).forEach((defaultProp) => {
|
||||||
|
const propValue = props.hasOwnProperty(defaultProp) ? props[defaultProp] : defaultProps[defaultProp]?.default;
|
||||||
|
|
||||||
|
merged[defaultProp] = reactive(typeof propValue === "function" ? propValue() : propValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Component {
|
||||||
|
template: string;
|
||||||
|
props?: Record<string, any>;
|
||||||
|
main?: (props?: Record<string, any>) => Record<string, any> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Current {
|
||||||
|
componentBlock?: Block;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const current: Current = { componentBlock: undefined };
|
||||||
|
|
||||||
|
interface BlockOptions {
|
||||||
|
element: Element;
|
||||||
|
isRoot?: boolean;
|
||||||
|
replacementType?: "replace" | "replaceChildren";
|
||||||
|
componentProps?: Record<string, any>;
|
||||||
|
allProps?: Record<string, any>;
|
||||||
|
parentContext?: Context;
|
||||||
|
component?: Component;
|
||||||
|
parentComponentBlock?: Block;
|
||||||
|
templates?: Template[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Block {
|
||||||
|
element: Element;
|
||||||
|
context: Context;
|
||||||
|
parentContext: Context;
|
||||||
|
component: Component;
|
||||||
|
provides = new Map<string, any>();
|
||||||
|
parentComponentBlock: Block | undefined;
|
||||||
|
componentProps: Record<string, any>;
|
||||||
|
allProps: Record<string, any>;
|
||||||
|
|
||||||
|
isFragment: boolean;
|
||||||
|
start?: Text;
|
||||||
|
end?: Text;
|
||||||
|
key?: any;
|
||||||
|
|
||||||
|
constructor(opts: BlockOptions) {
|
||||||
|
this.isFragment = opts.element instanceof HTMLTemplateElement;
|
||||||
|
this.parentComponentBlock = opts.parentComponentBlock;
|
||||||
|
|
||||||
|
if (opts.component) {
|
||||||
|
current.componentBlock = this;
|
||||||
|
this.element = stringToElement(opts.component.template);
|
||||||
|
} else {
|
||||||
|
if (this.isFragment) {
|
||||||
|
this.element = (opts.element as HTMLTemplateElement).content.cloneNode(true) as Element;
|
||||||
|
} else if (typeof opts.element === "string") {
|
||||||
|
this.element = stringToElement(opts.element);
|
||||||
|
} else {
|
||||||
|
this.element = opts.element.cloneNode(true) as Element;
|
||||||
|
opts.element.replaceWith(this.element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.isRoot) {
|
||||||
|
this.context = opts.parentContext;
|
||||||
|
} else {
|
||||||
|
this.parentContext = opts.parentContext ? opts.parentContext : createContext({});
|
||||||
|
this.parentContext.blocks.push(this);
|
||||||
|
this.context = createContext({ parentContext: opts.parentContext });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.component) {
|
||||||
|
this.componentProps = mergeProps(opts.componentProps ?? {}, opts.component.props ?? {});
|
||||||
|
|
||||||
|
if (opts.component.main) {
|
||||||
|
this.context.scope = {
|
||||||
|
...(opts.component.main(this.componentProps) || {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
opts.allProps?.forEach((prop) => {
|
||||||
|
if (prop.isBind) {
|
||||||
|
this.context.effect(() => {
|
||||||
|
let newValue: unknown;
|
||||||
|
|
||||||
|
if (prop.isSpread) {
|
||||||
|
const spreadProps = evalGet(this.parentContext.scope, prop.extractedName);
|
||||||
|
if (isObject(spreadProps)) {
|
||||||
|
Object.keys(spreadProps).forEach((key) => {
|
||||||
|
newValue = spreadProps[key];
|
||||||
|
this.setProp(key, newValue);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newValue = prop.isMirror ? evalGet(this.parentContext.scope, prop.extractedName) : evalGet(this.parentContext.scope, prop.exp);
|
||||||
|
this.setProp(prop.extractedName, newValue);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Capture slots
|
||||||
|
this.context.slots = findSlotNodes(this.element);
|
||||||
|
this.context.templates = opts.templates ?? [];
|
||||||
|
|
||||||
|
// Put templates into slots
|
||||||
|
this.context.slots.forEach((slot) => {
|
||||||
|
const template = this.context.templates.find((t) => t.targetSlotName === slot.name);
|
||||||
|
|
||||||
|
if (template) {
|
||||||
|
const templateContents = template.node.content.cloneNode(true);
|
||||||
|
slot.node.replaceWith(templateContents);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.context.scope.$isRef = isRef;
|
||||||
|
this.context.scope.$isComputed = isComputed;
|
||||||
|
|
||||||
|
walk(this.element, this.context);
|
||||||
|
|
||||||
|
if (opts.component) {
|
||||||
|
if (opts.replacementType === "replace") {
|
||||||
|
if (opts.element instanceof HTMLElement) {
|
||||||
|
opts.element.replaceWith(this.element);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (opts.element instanceof HTMLElement) {
|
||||||
|
opts.element.replaceChildren(this.element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setProp(name: string, value: unknown) {
|
||||||
|
if (isRef(this.componentProps[name])) {
|
||||||
|
this.componentProps[name].value = value;
|
||||||
|
} else {
|
||||||
|
this.componentProps[name] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
insert(parent: Element, anchor: Node | null = null) {
|
||||||
|
if (this.isFragment) {
|
||||||
|
if (this.start) {
|
||||||
|
// Already inserted, moving
|
||||||
|
let node: Node | null = this.start;
|
||||||
|
let next: Node | null;
|
||||||
|
|
||||||
|
while (node) {
|
||||||
|
next = node.nextSibling;
|
||||||
|
parent.insertBefore(node, anchor);
|
||||||
|
|
||||||
|
if (node === this.end) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
node = next;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.start = new Text("");
|
||||||
|
this.end = new Text("");
|
||||||
|
|
||||||
|
parent.insertBefore(this.end, anchor);
|
||||||
|
parent.insertBefore(this.start, this.end);
|
||||||
|
parent.insertBefore(this.element, this.end);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parent.insertBefore(this.element, anchor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remove() {
|
||||||
|
if (this.parentContext) {
|
||||||
|
const i = this.parentContext.blocks.indexOf(this);
|
||||||
|
|
||||||
|
if (i > -1) {
|
||||||
|
this.parentContext.blocks.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.start) {
|
||||||
|
const parent = this.start.parentNode!;
|
||||||
|
let node: Node | null = this.start;
|
||||||
|
let next: Node | null;
|
||||||
|
|
||||||
|
while (node) {
|
||||||
|
next = node.nextSibling;
|
||||||
|
parent.removeChild(node);
|
||||||
|
|
||||||
|
if (node === this.end) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
node = next;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// this.element.parentNode!.removeChild(this.element);
|
||||||
|
this.element.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.teardown();
|
||||||
|
}
|
||||||
|
|
||||||
|
teardown() {
|
||||||
|
this.context.blocks.forEach((block) => {
|
||||||
|
block.teardown();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.context.effects.forEach(stop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isComponent(element: Element, context: Context) {
|
||||||
|
return !!context.app.getComponent(element.tagName.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk(node: Node, context: Context) {
|
||||||
|
if (isText(node)) {
|
||||||
|
new InterpolationDirective({ element: node, context });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isElement(node)) {
|
||||||
|
let exp: string | null;
|
||||||
|
|
||||||
|
if (isComponent(node, context)) {
|
||||||
|
const component = context.app.getComponent(node.tagName.toLowerCase());
|
||||||
|
|
||||||
|
const allProps = Array.from(node.attributes)
|
||||||
|
.filter((attr) => isSpreadProp(attr.name) || isMirrorProp(attr.name) || (isRegularProp(attr.name) && componentHasPropByName(extractPropName(attr.name), component)))
|
||||||
|
.map((attr) => ({
|
||||||
|
isMirror: isMirrorProp(attr.name),
|
||||||
|
isSpread: isSpreadProp(attr.name),
|
||||||
|
isBind: attr.name.includes("bind"),
|
||||||
|
originalName: attr.name,
|
||||||
|
extractedName: extractPropName(attr.name),
|
||||||
|
exp: attr.value,
|
||||||
|
value: isMirrorProp(attr.name) ? evalGet(context.scope, extractPropName(attr.name)) : attr.value ? evalGet(context.scope, attr.value) : undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const componentProps = allProps.reduce((acc, { isSpread, isMirror, extractedName, value }) => {
|
||||||
|
if (isSpread) {
|
||||||
|
const spread = evalGet(context.scope, extractedName);
|
||||||
|
if (isObject(spread)) Object.assign(acc, spread);
|
||||||
|
} else if (isMirror) {
|
||||||
|
acc[extractedName] = evalGet(context.scope, extractedName);
|
||||||
|
} else {
|
||||||
|
acc[extractedName] = value;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
if ((exp = checkAndRemoveAttribute(node, ":teleport"))) {
|
||||||
|
return _teleport(node, exp, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((exp = checkAndRemoveAttribute(node, ":if"))) {
|
||||||
|
return _if(node, exp, context, component, componentProps, allProps);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((exp = checkAndRemoveAttribute(node, ":for"))) {
|
||||||
|
return _for(node, exp, context, component, componentProps, allProps);
|
||||||
|
}
|
||||||
|
|
||||||
|
const templates = findTemplateNodes(node);
|
||||||
|
|
||||||
|
const block = new Block({
|
||||||
|
element: node,
|
||||||
|
parentContext: context,
|
||||||
|
component,
|
||||||
|
replacementType: "replace",
|
||||||
|
parentComponentBlock: current.componentBlock,
|
||||||
|
templates,
|
||||||
|
componentProps,
|
||||||
|
allProps,
|
||||||
|
});
|
||||||
|
|
||||||
|
return block.element;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((exp = checkAndRemoveAttribute(node, ":teleport"))) {
|
||||||
|
return _teleport(node, exp, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((exp = checkAndRemoveAttribute(node, ":if"))) {
|
||||||
|
return _if(node, exp, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((exp = checkAndRemoveAttribute(node, ":for"))) {
|
||||||
|
return _for(node, exp, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((exp = checkAndRemoveAttribute(node, ":ref"))) {
|
||||||
|
context.scope[exp].value = node;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((exp = checkAndRemoveAttribute(node, ":value"))) {
|
||||||
|
new ValueDirective({ element: node, context, expression: exp });
|
||||||
|
}
|
||||||
|
|
||||||
|
Array.from(node.attributes).forEach((attr) => {
|
||||||
|
if (isPropAttribute(attr.name)) {
|
||||||
|
new AttributeDirective({ element: node, context, attr });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEventAttribute(attr.name)) {
|
||||||
|
new EventDirective({ element: node, context, attr });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
walkChildren(node, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkChildren(node: Node, context: Context) {
|
||||||
|
let child = node.firstChild;
|
||||||
|
|
||||||
|
while (child) {
|
||||||
|
child = walk(child, context) || child.nextSibling;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const evalFuncCache: Record<string, Function> = {};
|
||||||
|
|
||||||
|
export function evalGet(scope: any, exp: string, el?: Node) {
|
||||||
|
if (!exp.trim()) return undefined;
|
||||||
|
return execute(scope, `const ___value = (${exp.trim()}); return ___value;`, el);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evalSet(scope: any, exp: string, value: unknown) {
|
||||||
|
value = typeof value === "string" ? `"${value}"` : value;
|
||||||
|
return execute(scope, `const ___target = (${exp.trim()}); return $isRef(___target) ? ___target.value = ${value} : ___target = ${value};`, null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function execute(scope: any, exp: string, el?: Node, flatRefs = true) {
|
||||||
|
const newScope = flatRefs ? flattenRefs(scope) : scope;
|
||||||
|
const fn = evalFuncCache[exp] || (evalFuncCache[exp] = toFunction(exp));
|
||||||
|
|
||||||
|
try {
|
||||||
|
return fn(newScope, el);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Error evaluating expression: "${exp}":`);
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to convert expression strings to functions
|
||||||
|
function toFunction(exp: string) {
|
||||||
|
try {
|
||||||
|
return new Function("$data", "$el", `with($data){${exp}}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`${(e as Error).message} in expression: ${exp}`);
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map all ref properties in scope to their `.value`
|
||||||
|
function flattenRefs(scope: any): any {
|
||||||
|
const mapped = {};
|
||||||
|
|
||||||
|
for (const key in scope) {
|
||||||
|
if (scope.hasOwnProperty(key)) {
|
||||||
|
// Check if the value is a Ref
|
||||||
|
if (isRef(scope[key])) {
|
||||||
|
mapped[key] = scope[key].value;
|
||||||
|
} else {
|
||||||
|
mapped[key] = scope[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------
|
||||||
|
// Slots, multiple default and named, :if and :for
|
||||||
|
// const card = {
|
||||||
|
// template: html`<div>
|
||||||
|
// <h3><slot /></h3>
|
||||||
|
// <slot name="body" />
|
||||||
|
// </div>`,
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const main = {
|
||||||
|
// template: html`
|
||||||
|
// <div>
|
||||||
|
// <h1>card below</h1>
|
||||||
|
// <card>
|
||||||
|
// card title
|
||||||
|
// <template slot="body">Card body content</template>
|
||||||
|
// </card>
|
||||||
|
// </div>
|
||||||
|
// `,
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const app = new App();
|
||||||
|
// app.register("card", card);
|
||||||
|
// app.mount(main, "#app");
|
||||||
|
|
||||||
|
// ------------------------------------------------
|
||||||
|
// Slots, multiple default and named, :if and :for
|
||||||
|
// const app = new App();
|
||||||
|
|
||||||
|
// const parent = {
|
||||||
|
// template: html`
|
||||||
|
// <div>
|
||||||
|
// <h1>parent</h1>
|
||||||
|
// <card>
|
||||||
|
// <!-- default -->
|
||||||
|
// <div :if="bool">
|
||||||
|
// <div :teleport="body" id="teleported">
|
||||||
|
// <div>1</div>
|
||||||
|
// <div>2</div>
|
||||||
|
// <div>3</div>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// content 1 always shown
|
||||||
|
// <div :if="bool">
|
||||||
|
// content 2, animals:
|
||||||
|
// <div :for="animal in animals">animal: {{animal}}</div>
|
||||||
|
// </div>
|
||||||
|
|
||||||
|
// <!-- body -->
|
||||||
|
// <template slot="body">card body from parent</template>
|
||||||
|
// </card>
|
||||||
|
// </div>
|
||||||
|
// `,
|
||||||
|
// main() {
|
||||||
|
// const bool = ref(true);
|
||||||
|
// const animals = reactive(["dog", "cat", "bear"]);
|
||||||
|
|
||||||
|
// setInterval(() => {
|
||||||
|
// bool.value = !bool.value;
|
||||||
|
// }, 2000);
|
||||||
|
|
||||||
|
// return { bool, animals };
|
||||||
|
// },
|
||||||
|
// };
|
||||||
|
// const card = {
|
||||||
|
// template: html`<div>
|
||||||
|
// <h2>card</h2>
|
||||||
|
// <h3><slot /></h3>
|
||||||
|
// <slot name="body" />
|
||||||
|
// </div>`,
|
||||||
|
// };
|
||||||
|
// app.register("card", card);
|
||||||
|
// const parentBlock = app.mount(parent, "body");
|
||||||
|
// const cardBlock = parentBlock.context.blocks[0];
|
||||||
|
|
||||||
|
// ------------------------------------------------
|
||||||
|
// Component pros, mirror and spread, bind and no bind
|
||||||
|
// const child = {
|
||||||
|
// template: html`<div>Animal: {{animal}}</div>`,
|
||||||
|
// props: { animal: { default: "cat" } },
|
||||||
|
// main({ animal }) {
|
||||||
|
// return { animal };
|
||||||
|
// },
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const parent = {
|
||||||
|
// template: html`
|
||||||
|
// <div>
|
||||||
|
// <div>asdf</div>
|
||||||
|
// mirror, no bind:
|
||||||
|
// <child {animal} />
|
||||||
|
// <hr />
|
||||||
|
// mirror, bind:
|
||||||
|
// <child {animal:bind} />
|
||||||
|
// <hr />
|
||||||
|
// spread, no bind:
|
||||||
|
// <child ...spread />
|
||||||
|
// <hr />
|
||||||
|
// spread, bind:
|
||||||
|
// <child ...spread:bind />
|
||||||
|
// <hr />
|
||||||
|
// regular prop:
|
||||||
|
// <child .animal="animal" />
|
||||||
|
// <hr />
|
||||||
|
// regular prop, bind:
|
||||||
|
// <child .animal:bind="animal" />
|
||||||
|
// <hr />
|
||||||
|
// <div .id="animal">div has "id" set to animal.value</div>
|
||||||
|
// <hr />
|
||||||
|
// <div .id:bind="animal">div has "id" set and bound to animal.value</div>
|
||||||
|
// <hr />
|
||||||
|
// <div {animal}>div has "animal" set to animal.value</div>
|
||||||
|
// <hr />
|
||||||
|
// <div {animal:bind}>div has "animal" set and bound to animal.value</div>
|
||||||
|
// <hr />
|
||||||
|
// <div ...spread>div has "animal" spread</div>
|
||||||
|
// <hr />
|
||||||
|
// <div ...spread:bind>div has "animal" spread and bound</div>
|
||||||
|
// <hr />
|
||||||
|
// <hr />
|
||||||
|
// <hr />
|
||||||
|
// <hr />
|
||||||
|
// if bool, mirror, no bind:
|
||||||
|
// <child :if="bool" {animal} />
|
||||||
|
// if bool, mirror, bind:
|
||||||
|
// <child :if="bool" {animal:bind} />
|
||||||
|
// <hr />
|
||||||
|
// for list, mirror, no bind:
|
||||||
|
// <child :for="item in list" {animal} />
|
||||||
|
// <hr />
|
||||||
|
// for list, mirror, bind:
|
||||||
|
// <child :for="item in list" {animal:bind} />
|
||||||
|
// if bool, for list, mirror, no bind: these have the value "DOG!" because by the time for :for directive is evaluated, animal.value is "DOG!", and no longer "dog".
|
||||||
|
// <div :if="bool">
|
||||||
|
// <child :for="item in list" {animal} />
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// `,
|
||||||
|
// main() {
|
||||||
|
// const bool = ref(false);
|
||||||
|
// const animal = ref("dog");
|
||||||
|
// const spread = reactive({ animal: "panther" });
|
||||||
|
// const list = reactive([1, 2, 3]);
|
||||||
|
|
||||||
|
// setTimeout(() => {
|
||||||
|
// spread.animal = "PANTHER!";
|
||||||
|
// animal.value = "DOG!";
|
||||||
|
// bool.value = true;
|
||||||
|
// }, 500);
|
||||||
|
|
||||||
|
// setTimeout(() => {
|
||||||
|
// animal.value = "DOG!!!!!";
|
||||||
|
// }, 1000);
|
||||||
|
|
||||||
|
// return { animal, spread, bool, list };
|
||||||
|
// },
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const app = new App();
|
||||||
|
// app.register("child", child);
|
||||||
|
// app.mount(parent, "#app");
|
||||||
|
|
||||||
|
// ------------------------------------------------
|
||||||
|
// Event directive
|
||||||
|
const counter = {
|
||||||
|
template: html`
|
||||||
|
<div>
|
||||||
|
<div :teleport="body">true</div>
|
||||||
|
<p {style:bind}>Count: {{count}}{{count >= 2 ? '!!!' : ''}}</p>
|
||||||
|
<button @click="increment">Increment</button>
|
||||||
|
<button @click="decrement">Decrement</button>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
main() {
|
||||||
|
const count = ref(0);
|
||||||
|
const style = reactive({ color: "red" });
|
||||||
|
const increment = () => count.value++;
|
||||||
|
const decrement = () => count.value--;
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
if (style.color === "red") {
|
||||||
|
style.color = "blue";
|
||||||
|
} else {
|
||||||
|
style.color = "red";
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return { count, increment, decrement, style };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = new App();
|
||||||
|
app.mount(counter, "#app");
|
||||||
|
|
||||||
|
// ------------------------------------------------
|
||||||
|
// Vue SFC syntax of the above counter component
|
||||||
|
|
||||||
|
// <template>
|
||||||
|
// <div>
|
||||||
|
// <p>Count: {{ count }}</p>
|
||||||
|
// <button @click="increment">Increment</button>
|
||||||
|
// <button @click="decrement">Decrement</button>
|
||||||
|
// </div>
|
||||||
|
// </template>
|
||||||
|
|
||||||
|
// <script setup>
|
||||||
|
// import { ref } from 'vue';
|
||||||
|
|
||||||
|
// const count = ref(0);
|
||||||
|
// const increment = () => count.value++;
|
||||||
|
// const decrement = () => count.value--;
|
||||||
|
// </script>
|
||||||
7
src/plugins/index.ts
Normal file
7
src/plugins/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { App } from "..";
|
||||||
|
|
||||||
|
export interface Plugin {
|
||||||
|
use: (app: App, ...config: any[]) => void;
|
||||||
|
destroy: () => void;
|
||||||
|
compile: (element: Element) => void;
|
||||||
|
}
|
||||||
25
src/plugins/router/index.ts
Normal file
25
src/plugins/router/index.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { Component } from "../..";
|
||||||
|
|
||||||
|
export type Route = {
|
||||||
|
view?: string;
|
||||||
|
path: string;
|
||||||
|
component: Component;
|
||||||
|
componentFallback?: Component;
|
||||||
|
props?: Record<string, any>;
|
||||||
|
children?: Route[];
|
||||||
|
|
||||||
|
beforeEnter?: () => boolean | Promise<boolean>;
|
||||||
|
redirectTo?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RouteExpression = {
|
||||||
|
regex: RegExp;
|
||||||
|
params: string[];
|
||||||
|
path: string;
|
||||||
|
route: Route;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RouteMatch = {
|
||||||
|
match: RouteExpression;
|
||||||
|
parents: Route[];
|
||||||
|
};
|
||||||
260
src/plugins/router/plugin.ts
Normal file
260
src/plugins/router/plugin.ts
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
import { pathToRegexp } from "path-to-regexp";
|
||||||
|
import { Route, RouteExpression, RouteMatch } from ".";
|
||||||
|
import { Plugin } from "..";
|
||||||
|
import { App, Block, Component, current } from "../..";
|
||||||
|
import { reactive } from "../../reactivity/reactive";
|
||||||
|
import { unwrap } from "../../reactivity/unwrap";
|
||||||
|
import { html } from "../../util";
|
||||||
|
|
||||||
|
const activeRouters = new Set<RouterPlugin>();
|
||||||
|
|
||||||
|
const link = {
|
||||||
|
template: html`
|
||||||
|
<a {href:bind} @click="go" .class:bind="classes">
|
||||||
|
<slot>LINK</slot>
|
||||||
|
</a>
|
||||||
|
`,
|
||||||
|
props: { href: { default: "#" } },
|
||||||
|
main({ href }: { href: string }) {
|
||||||
|
const go = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
activeRouters.forEach((router) => {
|
||||||
|
router.doRouteChange(unwrap(href as unknown) as string);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const classes = reactive({ "router-link": true });
|
||||||
|
|
||||||
|
return { go, classes, href };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function runEnterTransition(enter: () => boolean | Promise<boolean>): Promise<boolean> {
|
||||||
|
return await enter();
|
||||||
|
}
|
||||||
|
|
||||||
|
const canEnterRoute = async (route: Route) => {
|
||||||
|
if (route.beforeEnter) {
|
||||||
|
return await runEnterTransition(route.beforeEnter);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const maybeRedirectRoute = (route: Route) => {
|
||||||
|
if (route.redirectTo) {
|
||||||
|
activeRouters.forEach((plugin) => plugin.doRouteChange(route.redirectTo));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export class RouterPlugin implements Plugin {
|
||||||
|
app: App;
|
||||||
|
routes: Route[] = [];
|
||||||
|
pathExpressions = new Map<string, RouteExpression>();
|
||||||
|
lastPath = "/";
|
||||||
|
knownRouterViews = new Map<Element, Block>();
|
||||||
|
knownRouterViewNames = new Map<string, Element>();
|
||||||
|
populatedRouterViews = new Map<Element, { block: Block; route: Route }>();
|
||||||
|
|
||||||
|
constructor(routes: Route[] = []) {
|
||||||
|
this.routes = routes;
|
||||||
|
}
|
||||||
|
|
||||||
|
use(app: App, ...config: any[]) {
|
||||||
|
this.app = app;
|
||||||
|
this.app.register("router-link", link);
|
||||||
|
|
||||||
|
window.addEventListener("popstate", this.onHistoryEvent.bind(this));
|
||||||
|
window.addEventListener("pushstate", this.onHistoryEvent.bind(this));
|
||||||
|
window.addEventListener("load", this.onHistoryEvent.bind(this));
|
||||||
|
|
||||||
|
for (const route of this.routes) {
|
||||||
|
this.cacheRouteExpression(route);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastPath = `${location.pathname}${location.search}`;
|
||||||
|
window.history.replaceState({}, "", this.lastPath);
|
||||||
|
|
||||||
|
activeRouters.add(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
compile(element: Element) {
|
||||||
|
if (element.nodeType === Node.ELEMENT_NODE && element.nodeName === "ROUTER-VIEW" && !this.knownRouterViews.has(element) && current.componentBlock) {
|
||||||
|
this.knownRouterViews.set(element, current.componentBlock);
|
||||||
|
this.knownRouterViewNames.set(element.getAttribute("name")?.trim() || "", element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onHistoryEvent(e: PopStateEvent | Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const path = new URL(e.currentTarget.location.href).pathname;
|
||||||
|
|
||||||
|
if (e.type === "load") {
|
||||||
|
window.history.replaceState({}, "", this.lastPath);
|
||||||
|
} else if (e.type === "pushstate") {
|
||||||
|
window.history.replaceState({}, "", path);
|
||||||
|
} else if (e.type === "popstate") {
|
||||||
|
window.history.replaceState({}, "", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastPath = path;
|
||||||
|
|
||||||
|
const matches = this.getMatchesForURL(path);
|
||||||
|
this.applyMatches(matches);
|
||||||
|
}
|
||||||
|
|
||||||
|
doRouteChange(to: string) {
|
||||||
|
window.history.pushState({}, "", to);
|
||||||
|
const matches = this.getMatchesForURL(`${location.pathname}${location.search}`);
|
||||||
|
this.applyMatches(matches);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMatchesForURL(url: string): RouteMatch[] {
|
||||||
|
let matches: RouteMatch[] = [];
|
||||||
|
|
||||||
|
const matchRoutes = (routes: Route[], parentPath: string = "", previousParents = []): RouteMatch[] => {
|
||||||
|
let parents = [];
|
||||||
|
|
||||||
|
for (const route of routes) {
|
||||||
|
parents.push(route);
|
||||||
|
const path = `${parentPath}${route.path}`.replace(/\/\//g, "/");
|
||||||
|
const match = this.getPathMatch(path, url);
|
||||||
|
if (match) matches.push({ match, parents: [...previousParents, ...parents] });
|
||||||
|
if (route.children?.length) {
|
||||||
|
matchRoutes(route.children, path, [...previousParents, ...parents]);
|
||||||
|
parents = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
};
|
||||||
|
matches = matchRoutes(this.routes);
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* getRouteExpression takes a path like "/users/:id" and returns a regex
|
||||||
|
* and an array of params that match the path.
|
||||||
|
* "/users/:id" => { regex: /^\/users\/([^\/]+)\?jwt=(\w)$/, params: ["id"], query: ["jwt"] }
|
||||||
|
*/
|
||||||
|
getRouteExpression(path: string, route: Route): RouteExpression {
|
||||||
|
if (this.pathExpressions.has(path)) return this.pathExpressions.get(path);
|
||||||
|
|
||||||
|
const params = [];
|
||||||
|
const regex = pathToRegexp(path, params, { strict: false, sensitive: false, end: true });
|
||||||
|
const expression = { regex, params, path, route };
|
||||||
|
this.pathExpressions.set(path, expression);
|
||||||
|
return expression;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param path A path like /foo/bar/:id
|
||||||
|
* @param url A url like /foo/bar/1234
|
||||||
|
* @returns A RouteExpression if the URL matches the regex cached for @param path, null otherwise.
|
||||||
|
*/
|
||||||
|
getPathMatch(path: string, url: string): RouteExpression | null {
|
||||||
|
if (this.pathExpressions.get(path)) {
|
||||||
|
const match = this.pathExpressions.get(path).regex.exec(url);
|
||||||
|
if (match) {
|
||||||
|
return this.pathExpressions.get(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async applyMatches(matches: RouteMatch[] | null) {
|
||||||
|
if (!matches) return;
|
||||||
|
|
||||||
|
const usedRouterViews = new Set<Element>();
|
||||||
|
|
||||||
|
const renderRoutes = async (routeChain: Route[], rootNode?: Element) => {
|
||||||
|
for (const route of routeChain) {
|
||||||
|
if (route.view) {
|
||||||
|
const viewNode = this.knownRouterViewNames.get(route.view);
|
||||||
|
if (viewNode && (await canEnterAndRenderRoute(viewNode, route))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else if (rootNode && (await canEnterAndRenderRoute(rootNode, route))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canEnterAndRenderRoute = async (node: Element, route: Route) => {
|
||||||
|
const canEnter = await canEnterRoute(route);
|
||||||
|
if (canEnter) {
|
||||||
|
renderRouteAtNode(node, route);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
if (route.componentFallback) {
|
||||||
|
renderRouteAtNode(node, route, route.componentFallback);
|
||||||
|
} else {
|
||||||
|
maybeRedirectRoute(route);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderRouteAtNode = (node: Element, route: Route, component?: Component) => {
|
||||||
|
if (!usedRouterViews.has(node) || this.populatedRouterViews.get(node)?.route !== route) {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
node.replaceChildren(div);
|
||||||
|
|
||||||
|
const target = div.parentElement;
|
||||||
|
|
||||||
|
const block = new Block({
|
||||||
|
element: div,
|
||||||
|
component: component ? component : route.component,
|
||||||
|
replacementType: "replaceChildren",
|
||||||
|
parentContext: current.componentBlock.context,
|
||||||
|
});
|
||||||
|
|
||||||
|
target.replaceChild(block.element, div);
|
||||||
|
|
||||||
|
this.populatedRouterViews.set(node, { block, route });
|
||||||
|
|
||||||
|
usedRouterViews.add(node);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
const routeChain = [...match.parents, match.match.route];
|
||||||
|
const uniqueRouteChain = routeChain.filter((route, index, self) => index === self.findIndex((r) => r.path === route.path));
|
||||||
|
const rootNode = this.knownRouterViewNames.get("") ?? null;
|
||||||
|
await renderRoutes(uniqueRouteChain, rootNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up stale views
|
||||||
|
for (const node of this.knownRouterViews.keys()) {
|
||||||
|
if (!usedRouterViews.has(node) && this.populatedRouterViews.has(node)) {
|
||||||
|
const entry = this.populatedRouterViews.get(node);
|
||||||
|
if (entry) {
|
||||||
|
entry.block.teardown();
|
||||||
|
this.populatedRouterViews.delete(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheRouteExpression(route: Route, parentPath: string = "") {
|
||||||
|
const path = `${parentPath}${route.path}`.replace(/\/\//g, "/");
|
||||||
|
this.getRouteExpression(path, route);
|
||||||
|
if (route.children?.length) {
|
||||||
|
route.children.forEach((child) => {
|
||||||
|
this.cacheRouteExpression(child, path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
window.removeEventListener("popstate", this.onHistoryEvent.bind(this));
|
||||||
|
window.removeEventListener("pushstate", this.onHistoryEvent.bind(this));
|
||||||
|
window.removeEventListener("load", this.onHistoryEvent.bind(this));
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/reactivity/computed.ts
Normal file
28
src/reactivity/computed.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { isObject } from "../util";
|
||||||
|
import { effect } from "./effect";
|
||||||
|
|
||||||
|
const $computed = Symbol("computed");
|
||||||
|
|
||||||
|
export type Computed<T> = {
|
||||||
|
readonly value: T;
|
||||||
|
readonly [$computed]: true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isComputed<T>(value: unknown): value is Computed<T> {
|
||||||
|
return isObject(value) && value[$computed];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computed<T>(getter: () => T): Computed<T> {
|
||||||
|
const ref = {
|
||||||
|
get value(): T {
|
||||||
|
return getter();
|
||||||
|
},
|
||||||
|
[$computed]: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
getter();
|
||||||
|
});
|
||||||
|
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
118
src/reactivity/effect.ts
Normal file
118
src/reactivity/effect.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
interface EffectOptions {
|
||||||
|
lazy?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EffectFunction {
|
||||||
|
active: boolean;
|
||||||
|
handler: () => void;
|
||||||
|
refs: Set<EffectFunction>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type Effects = Set<EffectFunction>;
|
||||||
|
type EffectsMap = Map<PropertyKey, Effects>;
|
||||||
|
type TargetMap = WeakMap<any, EffectsMap>;
|
||||||
|
|
||||||
|
const targetMap: TargetMap = new WeakMap();
|
||||||
|
const effectStack: (EffectFunction | undefined)[] = [];
|
||||||
|
|
||||||
|
export function track<T>(target: T, key: PropertyKey) {
|
||||||
|
const activeEffect = effectStack[effectStack.length - 1];
|
||||||
|
|
||||||
|
if (!activeEffect) return;
|
||||||
|
|
||||||
|
let effectsMap = targetMap.get(target);
|
||||||
|
if (!effectsMap)
|
||||||
|
targetMap.set(target, (effectsMap = new Map() as EffectsMap));
|
||||||
|
|
||||||
|
let effects = effectsMap.get(key);
|
||||||
|
if (!effects) effectsMap.set(key, (effects = new Set<EffectFunction>()));
|
||||||
|
|
||||||
|
if (!effects.has(activeEffect)) {
|
||||||
|
effects.add(activeEffect);
|
||||||
|
activeEffect.refs.push(effects);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trigger(target: any, key: PropertyKey) {
|
||||||
|
const effectsMap = targetMap.get(target);
|
||||||
|
if (!effectsMap) return;
|
||||||
|
|
||||||
|
const scheduled = new Set<EffectFunction>();
|
||||||
|
|
||||||
|
effectsMap.get(key)?.forEach((effect) => {
|
||||||
|
scheduled.add(effect);
|
||||||
|
});
|
||||||
|
|
||||||
|
scheduled.forEach(run);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop(effect: EffectFunction) {
|
||||||
|
if (effect.active) cleanup(effect);
|
||||||
|
effect.active = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function start(effect: EffectFunction) {
|
||||||
|
if (!effect.active) {
|
||||||
|
effect.active = true;
|
||||||
|
run(effect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(effect: EffectFunction): unknown {
|
||||||
|
if (!effect.active) return;
|
||||||
|
|
||||||
|
if (effectStack.includes(effect)) return;
|
||||||
|
|
||||||
|
cleanup(effect);
|
||||||
|
|
||||||
|
let val: unknown;
|
||||||
|
|
||||||
|
try {
|
||||||
|
effectStack.push(effect);
|
||||||
|
val = effect.handler();
|
||||||
|
} finally {
|
||||||
|
effectStack.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup(effect: EffectFunction) {
|
||||||
|
const { refs } = effect;
|
||||||
|
|
||||||
|
if (refs.length) {
|
||||||
|
for (const ref of refs) {
|
||||||
|
ref.delete(effect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refs.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function effect(handler: () => void, opts: EffectOptions = {}) {
|
||||||
|
const { lazy } = opts;
|
||||||
|
const newEffect: EffectFunction = {
|
||||||
|
active: !lazy,
|
||||||
|
handler,
|
||||||
|
refs: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
run(newEffect);
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: () => {
|
||||||
|
start(newEffect);
|
||||||
|
},
|
||||||
|
stop: () => {
|
||||||
|
stop(newEffect);
|
||||||
|
},
|
||||||
|
toggle: () => {
|
||||||
|
if (newEffect.active) {
|
||||||
|
stop(newEffect);
|
||||||
|
} else {
|
||||||
|
start(newEffect);
|
||||||
|
}
|
||||||
|
return newEffect.active;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
62
src/reactivity/reactive.ts
Normal file
62
src/reactivity/reactive.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { isObject } from "../util";
|
||||||
|
import { track, trigger } from "./effect";
|
||||||
|
import { ref } from "./ref";
|
||||||
|
|
||||||
|
const $reactive = Symbol("reactive");
|
||||||
|
|
||||||
|
export type Reactive<T> = T & { [$reactive]: true };
|
||||||
|
|
||||||
|
export function isReactive<T extends object>(
|
||||||
|
value: unknown,
|
||||||
|
): value is Reactive<T> {
|
||||||
|
return isObject(value) && !!value[$reactive];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reactive<T>(value: T): Reactive<T> {
|
||||||
|
// @ts-ignore
|
||||||
|
if (!isObject(value)) return ref(value) as Reactive<unknown>;
|
||||||
|
if (value[$reactive]) return value as Reactive<T>;
|
||||||
|
|
||||||
|
value[$reactive] = true;
|
||||||
|
|
||||||
|
Object.keys(value).forEach((key) => {
|
||||||
|
if (isObject(value[key])) {
|
||||||
|
value[key] = reactive(value[key]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Proxy(value, reactiveProxyHandler()) as Reactive<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reactiveProxyHandler() {
|
||||||
|
return {
|
||||||
|
deleteProperty(target: object, key: string | symbol) {
|
||||||
|
const had = Reflect.has(target, key);
|
||||||
|
const result = Reflect.deleteProperty(target, key);
|
||||||
|
if (had) trigger(target, key);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
get(target: object, key: string | symbol) {
|
||||||
|
track(target, key);
|
||||||
|
return Reflect.get(target, key);
|
||||||
|
},
|
||||||
|
set(target: object, key: string | symbol, value: unknown) {
|
||||||
|
if (target[key] === value) return true;
|
||||||
|
let newObj = false;
|
||||||
|
|
||||||
|
if (isObject(value) && !isObject(target[key])) {
|
||||||
|
newObj = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Reflect.set(target, key, value)) {
|
||||||
|
trigger(target, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newObj) {
|
||||||
|
target[key] = reactive(target[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
41
src/reactivity/ref.ts
Normal file
41
src/reactivity/ref.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { isObject } from "../util";
|
||||||
|
import { track, trigger } from "./effect";
|
||||||
|
import { Reactive, reactive } from "./reactive";
|
||||||
|
|
||||||
|
export const $ref = Symbol("ref");
|
||||||
|
|
||||||
|
export type Ref<T> = {
|
||||||
|
value: T;
|
||||||
|
[$ref]: true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isRef<T>(value: unknown): value is Ref<T> {
|
||||||
|
return isObject(value) && !!value[$ref];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ref<T>(value: T = null as unknown as T): Ref<T> {
|
||||||
|
if (isObject(value)) {
|
||||||
|
// @ts-ignore
|
||||||
|
return isRef(value) ? (value as Ref<T>) : (reactive(value) as Reactive<T>);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = { value, [$ref]: true };
|
||||||
|
|
||||||
|
return new Proxy(result, {
|
||||||
|
get(target: object, key: string | symbol, receiver: any) {
|
||||||
|
const val = Reflect.get(target, key, receiver);
|
||||||
|
track(result, "value");
|
||||||
|
return val;
|
||||||
|
},
|
||||||
|
set(target: object, key: string | symbol, value: unknown) {
|
||||||
|
const oldValue = target[key];
|
||||||
|
if (oldValue !== value) {
|
||||||
|
const success = Reflect.set(target, key, value);
|
||||||
|
if (success) {
|
||||||
|
trigger(result, "value");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}) as Ref<T>;
|
||||||
|
}
|
||||||
14
src/reactivity/unwrap.ts
Normal file
14
src/reactivity/unwrap.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { isComputed } from "./computed";
|
||||||
|
import { isRef } from "./ref";
|
||||||
|
|
||||||
|
export function unwrap(value: unknown) {
|
||||||
|
if (isRef(value) || isComputed(value)) {
|
||||||
|
return value.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "function") {
|
||||||
|
return value();
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
31
src/reactivity/watch.ts
Normal file
31
src/reactivity/watch.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { Computed, isComputed } from "./computed";
|
||||||
|
import { effect } from "./effect";
|
||||||
|
import { Reactive } from "./reactive";
|
||||||
|
import { isRef, Ref } from "./ref";
|
||||||
|
import { unwrap } from "./unwrap";
|
||||||
|
|
||||||
|
export type WatchCallback = (newVal: unknown, oldVal: unknown) => void;
|
||||||
|
|
||||||
|
type Watchable =
|
||||||
|
| Function
|
||||||
|
| Ref<unknown>
|
||||||
|
| Reactive<unknown>
|
||||||
|
| Computed<unknown>;
|
||||||
|
|
||||||
|
export function watch(getter: Watchable, callback: WatchCallback) {
|
||||||
|
if (typeof getter !== "function" && !isRef(getter) && !isComputed(getter)) {
|
||||||
|
console.warn(
|
||||||
|
"'watch' expects: ref, computed or function that returns one of those.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let oldVal = unwrap(getter);
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
const newVal = unwrap(getter);
|
||||||
|
if (newVal === oldVal) return;
|
||||||
|
callback(newVal, oldVal);
|
||||||
|
oldVal = newVal;
|
||||||
|
});
|
||||||
|
}
|
||||||
71
src/render.test.ts
Normal file
71
src/render.test.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { App, Block, createContext } from ".";
|
||||||
|
import { html } from "./util";
|
||||||
|
|
||||||
|
test("mounts", () => {
|
||||||
|
const app = new App();
|
||||||
|
const main = { template: "<div>Hello</div>" };
|
||||||
|
app.mount(main, "body");
|
||||||
|
expect(document.body.innerHTML).toBe("<div>Hello</div>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("when element is an HTMLTemplateElement, block.isFragment should be true", () => {
|
||||||
|
document.body.innerHTML = `<template></template>`;
|
||||||
|
const templateElement = document.body.firstElementChild as HTMLTemplateElement;
|
||||||
|
const parentContext = createContext({ app: new App() });
|
||||||
|
const block = new Block({
|
||||||
|
element: templateElement,
|
||||||
|
parentContext: parentContext,
|
||||||
|
});
|
||||||
|
expect(block.isFragment).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("block captures slots", () => {
|
||||||
|
const app = new App();
|
||||||
|
const main = { template: html`<div><slot /><slot name="x" /></div>` };
|
||||||
|
const block = app.mount(main, "body");
|
||||||
|
expect(document.body.innerHTML).toBe(`<div><slot></slot><slot name="x"></slot></div>`);
|
||||||
|
expect(block.context.slots.length).toBe(2);
|
||||||
|
expect(block.context.slots[0].name).toBeNull();
|
||||||
|
expect(block.context.slots[1].name).toBe("x");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("block captures templates", () => {
|
||||||
|
const app = new App();
|
||||||
|
|
||||||
|
const parent = {
|
||||||
|
template: html`
|
||||||
|
<div>
|
||||||
|
<card><template slot="body" /></card>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
const card = { template: html`<div><slot /><slot name="body" /></div>` };
|
||||||
|
|
||||||
|
app.register("card", card);
|
||||||
|
|
||||||
|
const parentBlock = app.mount(parent, "body");
|
||||||
|
console.log("parentBlock templates", parentBlock.context.blocks.length)
|
||||||
|
// console.log(JSON.stringify(parentBlock.context))
|
||||||
|
// console.log("parentBlock blocks", parentBlock.context.blocks.length)
|
||||||
|
// const cardBlock = parentBlock.context.blocks[0];
|
||||||
|
// console.log("cardBlock", cardBlock)
|
||||||
|
// console.log("cardBlock.tagName", cardBlock.element);
|
||||||
|
// expect(cardBlock.context.templates.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("child block has parentComponentBlock", () => {
|
||||||
|
const app = new App();
|
||||||
|
|
||||||
|
const parent = { template: "<div><child /></div>" };
|
||||||
|
const child = { template: "<span></span>" };
|
||||||
|
|
||||||
|
app.register("child", child);
|
||||||
|
|
||||||
|
const parentBlock = app.mount(parent, "body");
|
||||||
|
|
||||||
|
// the span
|
||||||
|
const childBlock = parentBlock.context.blocks[0];
|
||||||
|
|
||||||
|
expect(childBlock.parentComponentBlock).toBe(parentBlock);
|
||||||
|
});
|
||||||
230
src/util.ts
Normal file
230
src/util.ts
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
import { Component } from ".";
|
||||||
|
|
||||||
|
export function stringToElement(template: string): Element {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(template, "text/html");
|
||||||
|
return doc.body.firstChild as Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isText = (node: Node): node is Text => {
|
||||||
|
return node.nodeType === Node.TEXT_NODE;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isTemplate = (node: Node): node is HTMLTemplateElement => {
|
||||||
|
return node.nodeName === "TEMPLATE";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isElement = (node: Node): node is Element => {
|
||||||
|
return node.nodeType === Node.ELEMENT_NODE;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isObject(value: any): value is object {
|
||||||
|
return value !== null && typeof value === "object";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isArray(value: any): value is any[] {
|
||||||
|
return Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkAndRemoveAttribute(el: Element, attrName: string): string | null {
|
||||||
|
// Attempt to get the attribute value
|
||||||
|
const attributeValue = el.getAttribute(attrName);
|
||||||
|
|
||||||
|
// If attribute exists, remove it from the element
|
||||||
|
if (attributeValue !== null) {
|
||||||
|
el.removeAttribute(attrName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the value of the attribute or null if not present
|
||||||
|
return attributeValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Slot {
|
||||||
|
node: Element;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Template {
|
||||||
|
targetSlotName: string;
|
||||||
|
node: HTMLTemplateElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findSlotNodes(element: Element): Slot[] {
|
||||||
|
const slots: Slot[] = [];
|
||||||
|
|
||||||
|
const findSlots = (node: Element) => {
|
||||||
|
Array.from(node.childNodes).forEach((node) => {
|
||||||
|
if (isElement(node)) {
|
||||||
|
if (node.nodeName === "SLOT") {
|
||||||
|
slots.push({ node, name: node.getAttribute("name") || "default" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.hasChildNodes()) {
|
||||||
|
findSlots(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
findSlots(element);
|
||||||
|
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findTemplateNodes(element: Element) {
|
||||||
|
const templates: Template[] = [];
|
||||||
|
|
||||||
|
const findTemplates = (element: Element) => {
|
||||||
|
let defaultContentNodes: Node[] = [];
|
||||||
|
|
||||||
|
Array.from(element.childNodes).forEach((node) => {
|
||||||
|
if (isElement(node) || isText(node)) {
|
||||||
|
if (isElement(node) && node.nodeName === "TEMPLATE" && isTemplate(node)) {
|
||||||
|
templates.push({ targetSlotName: node.getAttribute("slot") || "", node });
|
||||||
|
} else {
|
||||||
|
// Capture non-template top-level nodes and text nodes for default slot
|
||||||
|
defaultContentNodes.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (defaultContentNodes.length > 0) {
|
||||||
|
// Create a template element with a default slot
|
||||||
|
const defaultTemplate = document.createElement("template");
|
||||||
|
defaultTemplate.setAttribute("slot", "default");
|
||||||
|
|
||||||
|
defaultContentNodes.forEach((node) => {
|
||||||
|
defaultTemplate.content.appendChild(node);
|
||||||
|
});
|
||||||
|
|
||||||
|
templates.push({ targetSlotName: "default", node: defaultTemplate });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
findTemplates(element);
|
||||||
|
|
||||||
|
return templates;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nextTick = async (f?: Function) => {
|
||||||
|
await new Promise<void>((r) =>
|
||||||
|
setTimeout((_) =>
|
||||||
|
requestAnimationFrame((_) => {
|
||||||
|
f && f();
|
||||||
|
r();
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function html(strings: TemplateStringsArray, ...values: any[]): string {
|
||||||
|
// List of valid self-closing tags in HTML
|
||||||
|
const selfClosingTags = ["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"];
|
||||||
|
|
||||||
|
// Join the strings and values into a single template
|
||||||
|
let result = strings.reduce((acc, str, i) => acc + str + (values[i] || ""), "");
|
||||||
|
|
||||||
|
// Match non-HTML valid self-closing tags
|
||||||
|
result = result.replace(/<([a-zA-Z][^\s/>]*)\s*([^>]*?)\/>/g, (match, tagName, attributes) => {
|
||||||
|
// If the tag is a valid self-closing tag, return it as is
|
||||||
|
if (selfClosingTags.includes(tagName.toLowerCase())) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the tag as an open/close tag preserving attributes
|
||||||
|
return `<${tagName} ${attributes}></${tagName}>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDisplayString(value: unknown) {
|
||||||
|
return value == null ? "" : isObject(value) ? JSON.stringify(value, null, 2) : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertAfter(newNode: Node, existingNode: Node) {
|
||||||
|
if (existingNode.nextSibling) {
|
||||||
|
existingNode.parentNode.insertBefore(newNode, existingNode.nextSibling);
|
||||||
|
} else {
|
||||||
|
existingNode?.parentNode?.appendChild(newNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPropAttribute(attrName: string) {
|
||||||
|
if (attrName.startsWith(".")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrName.startsWith("{") && attrName.endsWith("}")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSpreadProp(attr: string) {
|
||||||
|
return attr.startsWith("...");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMirrorProp(attr: string) {
|
||||||
|
return attr.startsWith("{") && attr.endsWith("}");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRegularProp(attr: string) {
|
||||||
|
return attr.startsWith(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEventAttribute(attrName: string) {
|
||||||
|
return attrName.startsWith("@");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function componentHasPropByName(name: string, component: Component) {
|
||||||
|
return Object.keys(component?.props ?? {}).some((prop) => prop === name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractAttributeName(attrName: string) {
|
||||||
|
return attrName
|
||||||
|
.replace(/^\.\.\./, "")
|
||||||
|
.replace(/^\./, "")
|
||||||
|
.replace(/^{/, "")
|
||||||
|
.replace(/}$/, "")
|
||||||
|
.replace(/:bind$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function dashToCamel(str: string) {
|
||||||
|
return str.toLowerCase().replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractPropName(attrName: string) {
|
||||||
|
return dashToCamel(extractAttributeName(attrName));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classNames(_: any) {
|
||||||
|
const classes = [];
|
||||||
|
for (let i = 0; i < arguments.length; i++) {
|
||||||
|
const arg = arguments[i];
|
||||||
|
if (!arg) continue;
|
||||||
|
const argType = typeof arg;
|
||||||
|
if (argType === "string" || argType === "number") {
|
||||||
|
classes.push(arg);
|
||||||
|
} else if (Array.isArray(arg)) {
|
||||||
|
if (arg.length) {
|
||||||
|
const inner = classNames.apply(null, arg);
|
||||||
|
if (inner) {
|
||||||
|
classes.push(inner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (argType === "object") {
|
||||||
|
if (arg.toString === Object.prototype.toString) {
|
||||||
|
for (let key in arg) {
|
||||||
|
if (Object.hasOwnProperty.call(arg, key) && arg[key]) {
|
||||||
|
classes.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
classes.push(arg.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return classes.join(" ");
|
||||||
|
}
|
||||||
8
tsconfig.json
Normal file
8
tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "ES2022",
|
||||||
|
"target": "ESNext",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"moduleResolution": "node"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
vitest.config.ts
Normal file
8
vitest.config.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ["src/**/*.{test,spec}.ts"],
|
||||||
|
environment: "happy-dom",
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user