refac to deno
This commit is contained in:
parent
1b21c2d796
commit
b326d14676
33 changed files with 544 additions and 3347 deletions
|
@ -1,2 +0,0 @@
|
|||
/*
|
||||
!/src
|
|
@ -1,28 +0,0 @@
|
|||
parser: "@typescript-eslint/parser"
|
||||
env:
|
||||
es6: true
|
||||
node: true
|
||||
parserOptions:
|
||||
ecmaVersion: 2020
|
||||
sourceType: "module"
|
||||
extends:
|
||||
- prettier
|
||||
- plugin:prettier/recommended
|
||||
- "plugin:@typescript-eslint/recommended"
|
||||
rules:
|
||||
"@typescript-eslint/no-unused-vars":
|
||||
- error
|
||||
- vars: all
|
||||
args: after-used
|
||||
argsIgnorePattern: ^_
|
||||
varsIgnorePattern: ^_
|
||||
ignoreRestSiblings: true
|
||||
"@typescript-eslint/no-empty-interface": off
|
||||
"@typescript-eslint/no-explicit-any": off
|
||||
"@typescript-eslint/explicit-function-return-type":
|
||||
- warn
|
||||
- allowExpressions: false
|
||||
allowTypedFunctionExpressions: true
|
||||
allowHigherOrderFunctions: true
|
||||
"@typescript-eslint/camelcase": off
|
||||
"@typescript-eslint/no-use-before-define": off
|
16
.gitignore
vendored
16
.gitignore
vendored
|
@ -8,17 +8,9 @@
|
|||
|
||||
# config
|
||||
!/.gitignore
|
||||
!/.eslintignore
|
||||
!/.eslintrc.yml
|
||||
!/tsconfig.json
|
||||
!/deno.json
|
||||
!/import_map.json
|
||||
|
||||
# node modules
|
||||
!/package.json
|
||||
!/package-lock.json
|
||||
!/core
|
||||
!/ren
|
||||
|
||||
# sources
|
||||
!/src
|
||||
!/scripts
|
||||
|
||||
# builded
|
||||
!/lib
|
||||
|
|
68
core/node.test.ts
Normal file
68
core/node.test.ts
Normal file
|
@ -0,0 +1,68 @@
|
|||
import { assertEquals, assertInstanceOf } from "testing/asserts.ts";
|
||||
|
||||
import { E, Elem, F, Fragment, TextNode } from "./node.ts";
|
||||
|
||||
Deno.test({
|
||||
name: "should create text node from string",
|
||||
fn: () => {
|
||||
const sourceText = "hello world";
|
||||
const tn = new TextNode(sourceText);
|
||||
|
||||
assertInstanceOf(tn, TextNode);
|
||||
assertEquals(tn.innerText, sourceText);
|
||||
},
|
||||
});
|
||||
|
||||
Deno.test({
|
||||
name: "should create fragment from array",
|
||||
fn: () => {
|
||||
const hello = new TextNode("hello");
|
||||
const world = new TextNode("world");
|
||||
|
||||
const frag = new Fragment([hello, world]);
|
||||
|
||||
assertInstanceOf(frag, Fragment);
|
||||
assertEquals(frag.children, [hello, world]);
|
||||
},
|
||||
});
|
||||
|
||||
Deno.test({
|
||||
name: "should create fragment via util",
|
||||
fn: () => {
|
||||
const el = E("p", [], "hello world");
|
||||
const innerFrag = F(["inner"]);
|
||||
const frag = F(["hello", innerFrag, "world", el]);
|
||||
|
||||
assertInstanceOf(frag, Fragment);
|
||||
assertEquals(frag.children, [
|
||||
new TextNode("hello"),
|
||||
new TextNode("inner"),
|
||||
new TextNode("world"),
|
||||
el,
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
Deno.test({
|
||||
name: "should create element",
|
||||
fn: () => {
|
||||
const child = new Elem("p", {}, [new TextNode("hello world")]);
|
||||
const el = new Elem("div", { class: "hello" }, [child]);
|
||||
|
||||
assertEquals(el.tagName, "div");
|
||||
assertEquals(el.attrs, { class: "hello" });
|
||||
assertEquals(el.children, [child]);
|
||||
},
|
||||
});
|
||||
|
||||
Deno.test({
|
||||
name: "should create element via util",
|
||||
fn: () => {
|
||||
const child = E("p", [], "hello world");
|
||||
const el = E("div", { class: "hello" }, [child]);
|
||||
|
||||
assertEquals(el.tagName, "div");
|
||||
assertEquals(el.attrs, { class: "hello" });
|
||||
assertEquals(el.children, [child]);
|
||||
},
|
||||
});
|
112
core/node.ts
Normal file
112
core/node.ts
Normal file
|
@ -0,0 +1,112 @@
|
|||
import { isNotSkip, isNum, isStr, Nilable, Skipable, toArr } from "./utils.ts";
|
||||
|
||||
export type Attrs = Record<string, string>;
|
||||
export type AnyNode = Fragment | FragmentNode;
|
||||
|
||||
export function isTextNode(val: unknown): val is TextNode {
|
||||
return val instanceof TextNode;
|
||||
}
|
||||
|
||||
export class TextNode {
|
||||
#innerText: string;
|
||||
|
||||
get innerText(): string {
|
||||
return this.#innerText;
|
||||
}
|
||||
|
||||
constructor(text: string) {
|
||||
this.#innerText = text;
|
||||
}
|
||||
}
|
||||
|
||||
export function isFragment(val: unknown): val is Fragment {
|
||||
return val instanceof Fragment;
|
||||
}
|
||||
|
||||
export type FragmentNode = Elem | TextNode;
|
||||
|
||||
export class Fragment {
|
||||
#children: FragmentNode[];
|
||||
|
||||
get children(): FragmentNode[] {
|
||||
return this.#children;
|
||||
}
|
||||
|
||||
constructor(children: FragmentNode[]) {
|
||||
this.#children = children;
|
||||
}
|
||||
}
|
||||
|
||||
export function isElem(val: unknown): val is Elem {
|
||||
return val instanceof Elem;
|
||||
}
|
||||
|
||||
export type ElemTagName =
|
||||
| keyof HTMLElementTagNameMap
|
||||
| keyof SVGElementTagNameMap;
|
||||
|
||||
export class Elem {
|
||||
#tagName: ElemTagName;
|
||||
#attrs: Attrs;
|
||||
#children: AnyNode[];
|
||||
|
||||
get tagName(): ElemTagName {
|
||||
return this.#tagName;
|
||||
}
|
||||
|
||||
get attrs(): Attrs {
|
||||
return this.#attrs;
|
||||
}
|
||||
|
||||
get children(): AnyNode[] {
|
||||
return this.#children;
|
||||
}
|
||||
|
||||
constructor(
|
||||
tagName: ElemTagName,
|
||||
attrs?: Nilable<Attrs>,
|
||||
children?: Nilable<AnyNode[]>,
|
||||
) {
|
||||
this.#tagName = tagName;
|
||||
this.#attrs = attrs ?? {};
|
||||
this.#children = children ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
export function F(children: Skipable<EChild>[]): Fragment {
|
||||
return new Fragment(
|
||||
children.filter(isNotSkip).flatMap((c) =>
|
||||
normFragmentChild(normElemChild(c))
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
type EAttrs = Attrs | Attrs[];
|
||||
type ETextNode = string | number | TextNode;
|
||||
type EChild = ETextNode | Fragment | Elem;
|
||||
|
||||
export function E(
|
||||
tagName: ElemTagName,
|
||||
attrs: EAttrs,
|
||||
children?: ETextNode | Skipable<EChild>[],
|
||||
): Elem {
|
||||
return new Elem(
|
||||
tagName,
|
||||
mergeAttrs(attrs ?? []),
|
||||
toArr(children ?? []).filter(isNotSkip).map(normElemChild),
|
||||
);
|
||||
}
|
||||
|
||||
function mergeAttrs(attrs: EAttrs): Attrs {
|
||||
return !Array.isArray(attrs)
|
||||
? attrs
|
||||
: attrs.reduce((acc, attrs) => ({ ...acc, ...attrs }), {} as Attrs);
|
||||
}
|
||||
|
||||
function normFragmentChild(node: AnyNode): FragmentNode | FragmentNode[] {
|
||||
return isFragment(node) ? node.children : node;
|
||||
}
|
||||
|
||||
function normElemChild(node: EChild): AnyNode {
|
||||
return isStr(node) || isNum(node) ? new TextNode(String(node)) : node;
|
||||
}
|
29
core/utils.test.ts
Normal file
29
core/utils.test.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { assertEquals } from "testing/asserts.ts";
|
||||
|
||||
import { isNil, isSkip } from "./utils.ts";
|
||||
|
||||
Deno.test({
|
||||
name: "should check value on nil",
|
||||
fn: () => {
|
||||
assertEquals(isNil(null), true);
|
||||
assertEquals(isNil(undefined), true);
|
||||
assertEquals(isNil(0), false);
|
||||
assertEquals(isNil(""), false);
|
||||
assertEquals(isNil(false), false);
|
||||
assertEquals(isNil({}), false);
|
||||
assertEquals(isNil([]), false);
|
||||
},
|
||||
});
|
||||
|
||||
Deno.test({
|
||||
name: "should check value on skip",
|
||||
fn: () => {
|
||||
assertEquals(isSkip(null), true);
|
||||
assertEquals(isSkip(undefined), true);
|
||||
assertEquals(isSkip(0), false);
|
||||
assertEquals(isSkip(""), false);
|
||||
assertEquals(isSkip(false), true);
|
||||
assertEquals(isSkip({}), false);
|
||||
assertEquals(isSkip([]), false);
|
||||
},
|
||||
});
|
43
core/utils.ts
Normal file
43
core/utils.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
export function concat(arr: Skipable<string>[]): string {
|
||||
return join("", arr);
|
||||
}
|
||||
|
||||
export function join(sep: string, arr: Skipable<string>[]): string {
|
||||
return arr.filter((item) => !(isSkip(item) || isEmptyStr(item))).join(sep);
|
||||
}
|
||||
|
||||
export type Skipable<T> = T | Skip;
|
||||
|
||||
export type Skip = Nil | false;
|
||||
|
||||
export function isNotSkip<T>(val: Skipable<T>): val is T {
|
||||
return !isSkip(val);
|
||||
}
|
||||
|
||||
export function isSkip(val: unknown): val is Skip {
|
||||
return isNil(val) || val === false;
|
||||
}
|
||||
|
||||
export type Nilable<T> = T | Nil;
|
||||
|
||||
export type Nil = undefined | null;
|
||||
|
||||
export function isNil(val: unknown): val is Nil {
|
||||
return val == null;
|
||||
}
|
||||
|
||||
export function isEmptyStr(val: unknown): val is "" {
|
||||
return isStr(val) && val.length === 0;
|
||||
}
|
||||
|
||||
export function isStr(val: unknown): val is string {
|
||||
return typeof val === "string";
|
||||
}
|
||||
|
||||
export function isNum(val: unknown): val is number {
|
||||
return typeof val === "number";
|
||||
}
|
||||
|
||||
export function toArr<T>(val: T | T[]): T[] {
|
||||
return Array.isArray(val) ? val : [val];
|
||||
}
|
6
deno.json
Normal file
6
deno.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["deno.ns", "dom"]
|
||||
},
|
||||
"importMap": "./import_map.json"
|
||||
}
|
5
import_map.json
Normal file
5
import_map.json
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"imports": {
|
||||
"testing/": "https://deno.land/std@0.139.0/testing/"
|
||||
}
|
||||
}
|
3
lib/index.d.ts
vendored
3
lib/index.d.ts
vendored
|
@ -1,3 +0,0 @@
|
|||
export * from "./str.js";
|
||||
export * from "./nodes.js";
|
||||
export * from "./types.js";
|
|
@ -1,3 +0,0 @@
|
|||
export * from "./str.mjs";
|
||||
export * from "./nodes.mjs";
|
||||
export * from "./types.mjs";
|
7
lib/lang.d.ts
vendored
7
lib/lang.d.ts
vendored
|
@ -1,7 +0,0 @@
|
|||
export declare function isNil<T>(v: Nilable<T>): v is Nil;
|
||||
export declare type Nullable<T> = T | null;
|
||||
export declare type Nilable<T> = T | Nil;
|
||||
export declare type Nil = null | undefined;
|
||||
export declare function isBool(v: unknown): v is boolean;
|
||||
export declare function isStr(v: unknown): v is string;
|
||||
export declare function intoArr<T>(v: T | T[]): T[];
|
12
lib/lang.mjs
12
lib/lang.mjs
|
@ -1,12 +0,0 @@
|
|||
export function isNil(v) {
|
||||
return v == null;
|
||||
}
|
||||
export function isBool(v) {
|
||||
return typeof v === "boolean";
|
||||
}
|
||||
export function isStr(v) {
|
||||
return typeof v === "string";
|
||||
}
|
||||
export function intoArr(v) {
|
||||
return Array.isArray(v) ? v : [v];
|
||||
}
|
28
lib/nodes.d.ts
vendored
28
lib/nodes.d.ts
vendored
|
@ -1,28 +0,0 @@
|
|||
import { Nil, Nilable } from "./lang.js";
|
||||
export declare function Et(tagName: string, ...texts: string[]): Elem;
|
||||
export declare function Ea(tagName: string, attrs?: ElemAttrs, children?: AnyNode | AnyNode[]): Elem;
|
||||
export declare function E(tagName: string, children: AnyNode | AnyNode[]): Elem;
|
||||
export declare function F(...children: AnyNode[]): Frag;
|
||||
export declare type AnyNode = TextNode | Elem | Frag | Nil | false;
|
||||
export declare type TextNode = string;
|
||||
export declare class Frag {
|
||||
#private;
|
||||
constructor();
|
||||
get children(): Nilable<AnyNode[]>;
|
||||
withText(texts: TextNode | TextNode[]): this;
|
||||
addText(texts: TextNode | TextNode[]): void;
|
||||
withChildren(nodes: AnyNode | AnyNode[]): this;
|
||||
addChildren(nodes: AnyNode | AnyNode[]): void;
|
||||
addChild(node: AnyNode): void;
|
||||
}
|
||||
export declare type ElemAttrs = Record<string, unknown>;
|
||||
export declare class Elem extends Frag {
|
||||
#private;
|
||||
constructor(tagName: string);
|
||||
get tagName(): string;
|
||||
get attrs(): Record<string, unknown>;
|
||||
withAttrs(attrs: ElemAttrs): Elem;
|
||||
addAttrs(attrs: ElemAttrs): void;
|
||||
addAttr(name: string, value: unknown): void;
|
||||
addChild(node: AnyNode): void;
|
||||
}
|
|
@ -1,96 +0,0 @@
|
|||
import { intoArr, isNil } from "./lang.mjs";
|
||||
export function Et(tagName, ...texts) {
|
||||
return new Elem(tagName).withText(texts);
|
||||
}
|
||||
export function Ea(tagName, attrs, children) {
|
||||
const el = new Elem(tagName);
|
||||
if (attrs)
|
||||
el.addAttrs(attrs);
|
||||
if (children)
|
||||
el.addChildren(children);
|
||||
return el;
|
||||
}
|
||||
export function E(tagName, children) {
|
||||
return new Elem(tagName).withChildren(children);
|
||||
}
|
||||
export function F(...children) {
|
||||
return new Frag().withChildren(children);
|
||||
}
|
||||
export class Frag {
|
||||
#children;
|
||||
constructor() {
|
||||
this.#children = undefined;
|
||||
}
|
||||
get children() {
|
||||
return this.#children;
|
||||
}
|
||||
withText(texts) {
|
||||
this.addText(texts);
|
||||
return this;
|
||||
}
|
||||
addText(texts) {
|
||||
this.addChildren(intoArr(texts)
|
||||
.map((t) => t.trim())
|
||||
.join(" "));
|
||||
}
|
||||
withChildren(nodes) {
|
||||
this.addChildren(nodes);
|
||||
return this;
|
||||
}
|
||||
addChildren(nodes) {
|
||||
intoArr(nodes).forEach((n) => this.addChild(n));
|
||||
}
|
||||
addChild(node) {
|
||||
if (isNil(this.#children))
|
||||
this.#children = [];
|
||||
this.#children.push(node);
|
||||
}
|
||||
}
|
||||
export class Elem extends Frag {
|
||||
#tagName;
|
||||
#attrs;
|
||||
#isSelfClosed;
|
||||
constructor(tagName) {
|
||||
super();
|
||||
this.#tagName = tagName;
|
||||
this.#attrs = {};
|
||||
this.#isSelfClosed = selfClosedTagNames.has(tagName);
|
||||
}
|
||||
get tagName() {
|
||||
return this.#tagName;
|
||||
}
|
||||
get attrs() {
|
||||
return this.#attrs;
|
||||
}
|
||||
withAttrs(attrs) {
|
||||
this.addAttrs(attrs);
|
||||
return this;
|
||||
}
|
||||
addAttrs(attrs) {
|
||||
Object.entries(attrs).forEach(([key, value]) => this.addAttr(key, value));
|
||||
}
|
||||
addAttr(name, value) {
|
||||
this.#attrs[name] = value;
|
||||
}
|
||||
addChild(node) {
|
||||
if (this.#isSelfClosed)
|
||||
throw new Error("You cannot add child to self closed element");
|
||||
super.addChild(node);
|
||||
}
|
||||
}
|
||||
const selfClosedTagNames = new Set([
|
||||
"area",
|
||||
"base",
|
||||
"br",
|
||||
"col",
|
||||
"embed",
|
||||
"hr",
|
||||
"img",
|
||||
"input",
|
||||
"link",
|
||||
"meta",
|
||||
"param",
|
||||
"source",
|
||||
"track",
|
||||
"wbr",
|
||||
]);
|
5
lib/str.d.ts
vendored
5
lib/str.d.ts
vendored
|
@ -1,5 +0,0 @@
|
|||
import { Renderer } from "./types.js";
|
||||
import { Elem } from "./nodes.js";
|
||||
export declare class StrRenderer implements Renderer<string> {
|
||||
render(node: Elem): string;
|
||||
}
|
48
lib/str.mjs
48
lib/str.mjs
|
@ -1,48 +0,0 @@
|
|||
import { isBool, isNil, isStr } from "./lang.mjs";
|
||||
import { Elem } from "./nodes.mjs";
|
||||
export class StrRenderer {
|
||||
render(node) {
|
||||
return encodeNode(node);
|
||||
}
|
||||
}
|
||||
function encodeAnyNode(node) {
|
||||
return !node ? null : isStr(node) ? encodeTextNode(node) : encodeNode(node);
|
||||
}
|
||||
function encodeTextNode(node) {
|
||||
return String(node);
|
||||
}
|
||||
function encodeNode(node) {
|
||||
const encodedChildren = isNil(node.children)
|
||||
? undefined
|
||||
: node.children.map(encodeAnyNode);
|
||||
return node instanceof Elem
|
||||
? encodeHtmlElement(node.tagName, node.attrs, encodedChildren)
|
||||
: encodeHtmlFragment(encodedChildren);
|
||||
}
|
||||
function encodeHtmlFragment(children) {
|
||||
return concat(children ?? []);
|
||||
}
|
||||
function encodeHtmlElement(tagName, attrs, children) {
|
||||
const open = `<${join(" ", [tagName, encodeAttrs(attrs)])}>`;
|
||||
if (isNil(children))
|
||||
return open;
|
||||
return `${open}${concat(children)}</${tagName}>`;
|
||||
}
|
||||
function encodeAttrs(attrs) {
|
||||
if (!attrs)
|
||||
return null;
|
||||
return join(" ", Object.entries(attrs).map(([key, value]) => encodeAttr(key, value)));
|
||||
}
|
||||
function encodeAttr(key, value) {
|
||||
if (isNil(value))
|
||||
return null;
|
||||
if (isBool(value))
|
||||
return value ? key : null;
|
||||
return `${key}="${value}"`;
|
||||
}
|
||||
function concat(arr) {
|
||||
return join("", arr);
|
||||
}
|
||||
function join(sep, arr) {
|
||||
return arr.filter(Boolean).join(sep);
|
||||
}
|
4
lib/types.d.ts
vendored
4
lib/types.d.ts
vendored
|
@ -1,4 +0,0 @@
|
|||
import { Elem } from "./nodes.js";
|
||||
export interface Renderer<T> {
|
||||
render(node: Elem): T;
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export {};
|
2821
package-lock.json
generated
2821
package-lock.json
generated
File diff suppressed because it is too large
Load diff
32
package.json
32
package.json
|
@ -1,32 +0,0 @@
|
|||
{
|
||||
"name": "ren",
|
||||
"description": "",
|
||||
"version": "0.0.1",
|
||||
"main": "lib/index.mjs",
|
||||
"module": "lib/index.mjs",
|
||||
"types": "lib/index.d.ts",
|
||||
"directories": {
|
||||
"lib": "lib"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^17.0.21",
|
||||
"@typescript-eslint/eslint-plugin": "^5.14.0",
|
||||
"@typescript-eslint/parser": "^5.14.0",
|
||||
"eslint": "^8.10.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"prettier": "^2.5.1",
|
||||
"tsc-watch": "^4.6.0",
|
||||
"typescript": "^4.6.2"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/pleshevskiy/ren.git"
|
||||
},
|
||||
"author": "Dmitriy Pleshevskiy <dmitriy@ideascup.me>",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/pleshevskiy/ren/issues"
|
||||
},
|
||||
"homepage": "https://github.com/pleshevskiy/ren#readme"
|
||||
}
|
33
ren/attrs.test.ts
Normal file
33
ren/attrs.test.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { assertEquals } from "testing/asserts.ts";
|
||||
import * as a from "./attrs.ts";
|
||||
|
||||
Deno.test({
|
||||
name: "should return empty attrs object",
|
||||
fn: () => {
|
||||
assertEquals(a.classNames([]), {});
|
||||
assertEquals(a.classNames([false, null, undefined]), {});
|
||||
},
|
||||
});
|
||||
|
||||
Deno.test({
|
||||
name: "should return class attr",
|
||||
fn: () => {
|
||||
assertEquals(a.classNames(["hello"]), { class: "hello" });
|
||||
assertEquals(a.classNames(["hello", "world"]), { class: "hello world" });
|
||||
},
|
||||
});
|
||||
|
||||
Deno.test({
|
||||
name: "should return filter skipable and return class attr",
|
||||
fn: () => {
|
||||
assertEquals(
|
||||
a.classNames([
|
||||
null && "my",
|
||||
undefined && "name",
|
||||
"hello",
|
||||
false && "world",
|
||||
]),
|
||||
{ class: "hello" },
|
||||
);
|
||||
},
|
||||
});
|
7
ren/attrs.ts
Normal file
7
ren/attrs.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { Attrs } from "../core/node.ts";
|
||||
import { isNotSkip, join, Skipable } from "../core/utils.ts";
|
||||
|
||||
export function classNames(vals: Skipable<string>[]): Attrs {
|
||||
const val = join(" ", vals.filter(isNotSkip));
|
||||
return !val.length ? {} : { class: val };
|
||||
}
|
133
ren/str.test.ts
Normal file
133
ren/str.test.ts
Normal file
|
@ -0,0 +1,133 @@
|
|||
import { assertEquals } from "testing/asserts.ts";
|
||||
import { E, F, TextNode } from "../core/node.ts";
|
||||
|
||||
import { StrRenderer } from "./str.ts";
|
||||
|
||||
Deno.test({
|
||||
name: "should render text node",
|
||||
fn: () => {
|
||||
const tn = new TextNode("hello world");
|
||||
|
||||
const ren = new StrRenderer();
|
||||
const res = ren.render(tn);
|
||||
|
||||
assertEquals(res, "hello world");
|
||||
},
|
||||
});
|
||||
|
||||
Deno.test({
|
||||
name: "should render element",
|
||||
fn: () => {
|
||||
const el = E("p", [], "hello world");
|
||||
|
||||
const ren = new StrRenderer();
|
||||
const res = ren.render(el);
|
||||
|
||||
assertEquals(res, "<p>hello world</p>");
|
||||
},
|
||||
});
|
||||
|
||||
Deno.test({
|
||||
name: "should render empty fragment as empty string",
|
||||
fn: () => {
|
||||
const frag = F([]);
|
||||
|
||||
const ren = new StrRenderer();
|
||||
const res = ren.render(frag);
|
||||
|
||||
assertEquals(res, "");
|
||||
},
|
||||
});
|
||||
|
||||
Deno.test({
|
||||
name: "should render fragment",
|
||||
fn: () => {
|
||||
const frag = F([
|
||||
"hello world",
|
||||
E("div", { class: "hello" }),
|
||||
E("p", [], "world"),
|
||||
]);
|
||||
|
||||
const ren = new StrRenderer();
|
||||
const res = ren.render(frag);
|
||||
|
||||
assertEquals(res, 'hello world<div class="hello"></div><p>world</p>');
|
||||
},
|
||||
});
|
||||
|
||||
Deno.test({
|
||||
name: "should recursive render elements",
|
||||
fn: () => {
|
||||
const layout = E("body", [], [
|
||||
E("div", { id: "root" }, [
|
||||
E("p", [], "hello world"),
|
||||
]),
|
||||
]);
|
||||
|
||||
const ren = new StrRenderer();
|
||||
const res = ren.render(layout);
|
||||
|
||||
assertEquals(res, '<body><div id="root"><p>hello world</p></div></body>');
|
||||
},
|
||||
});
|
||||
|
||||
Deno.test({
|
||||
name: "should render attributes",
|
||||
fn: () => {
|
||||
const layout = E("body", { class: "body-lock" }, [
|
||||
E("div", { id: "root" }, [
|
||||
E("p", { class: "first" }, "hello world"),
|
||||
]),
|
||||
]);
|
||||
|
||||
const ren = new StrRenderer();
|
||||
const res = ren.render(layout);
|
||||
|
||||
assertEquals(
|
||||
res,
|
||||
'<body class="body-lock"><div id="root"><p class="first">hello world</p></div></body>',
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
Deno.test({
|
||||
name: "should render default doctype if root node is html",
|
||||
fn: () => {
|
||||
const layout = E("html", []);
|
||||
|
||||
const ren = new StrRenderer();
|
||||
const res = ren.render(layout);
|
||||
|
||||
assertEquals(res, "<!doctype html><html></html>");
|
||||
},
|
||||
});
|
||||
|
||||
Deno.test({
|
||||
name: "should render custom doctype if root node is html",
|
||||
fn: () => {
|
||||
const layout = E("html", []);
|
||||
|
||||
const ren = new StrRenderer({
|
||||
doctype:
|
||||
'html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd"',
|
||||
});
|
||||
const res = ren.render(layout);
|
||||
|
||||
assertEquals(
|
||||
res,
|
||||
'<!doctype html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd"><html></html>',
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
Deno.test({
|
||||
name: "should force render doctype if root nod is not html",
|
||||
fn: () => {
|
||||
const layout = E("body", [], []);
|
||||
|
||||
const ren = new StrRenderer({ forceRenderDoctype: true });
|
||||
const res = ren.render(layout);
|
||||
|
||||
assertEquals(res, "<!doctype html><body></body>");
|
||||
},
|
||||
});
|
99
ren/str.ts
Normal file
99
ren/str.ts
Normal file
|
@ -0,0 +1,99 @@
|
|||
import {
|
||||
AnyNode,
|
||||
Attrs,
|
||||
Elem,
|
||||
Fragment,
|
||||
isElem,
|
||||
isFragment,
|
||||
isTextNode,
|
||||
TextNode,
|
||||
} from "../core/node.ts";
|
||||
import { concat, join } from "../core/utils.ts";
|
||||
import { Renderer } from "./types.ts";
|
||||
|
||||
interface StrRendererOpts {
|
||||
doctype?: string;
|
||||
forceRenderDoctype?: boolean;
|
||||
}
|
||||
|
||||
export class StrRenderer implements Renderer<string> {
|
||||
#opts: StrRendererOpts;
|
||||
|
||||
constructor(opts?: StrRendererOpts) {
|
||||
this.#opts = opts ?? {};
|
||||
}
|
||||
|
||||
render(node: AnyNode): string {
|
||||
const shouldRenderDoctype = this.#opts.forceRenderDoctype ||
|
||||
(isElem(node) && node.tagName === "html");
|
||||
return concat([
|
||||
shouldRenderDoctype && encodeDoctype(this.#opts.doctype),
|
||||
encodeAnyNode(node),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function encodeDoctype(value?: string): string {
|
||||
return `<!doctype ${value ?? "html"}>`;
|
||||
}
|
||||
|
||||
function encodeAnyNode(node: AnyNode): string {
|
||||
return isTextNode(node)
|
||||
? encodeTextNode(node)
|
||||
: isFragment(node)
|
||||
? encodeHtmlFragment(node)
|
||||
: encodeHtmlElement(node);
|
||||
}
|
||||
|
||||
function encodeTextNode(node: TextNode): string {
|
||||
return node.innerText;
|
||||
}
|
||||
|
||||
function encodeHtmlFragment(node: Fragment): string {
|
||||
return concat(node.children.map(encodeAnyNode));
|
||||
}
|
||||
|
||||
function encodeHtmlElement(
|
||||
{ tagName, attrs, children }: Elem,
|
||||
): string {
|
||||
const open = `<${join(" ", [tagName, encodeAttrs(attrs)])}>`;
|
||||
if (isSelfClosedTagName(tagName)) return open;
|
||||
|
||||
const encodedChildren = children.map(encodeAnyNode);
|
||||
return `${open}${concat(encodedChildren)}</${tagName}>`;
|
||||
}
|
||||
|
||||
function encodeAttrs(attrs: Attrs): string {
|
||||
return join(
|
||||
" ",
|
||||
Object.entries(attrs).map(([key, value]) => encodeAttr(key, value)),
|
||||
);
|
||||
}
|
||||
|
||||
function encodeAttr(key: string, value: string): string {
|
||||
return `${key}="${value}"`;
|
||||
}
|
||||
|
||||
function isSelfClosedTagName(tagName: string): boolean {
|
||||
return SELF_CLOSED_HTML_TAG_NAMES.includes(tagName);
|
||||
}
|
||||
|
||||
const SELF_CLOSED_HTML_TAG_NAMES = [
|
||||
"area",
|
||||
"base",
|
||||
"br",
|
||||
"col",
|
||||
"embed",
|
||||
"hr",
|
||||
"img",
|
||||
"input",
|
||||
"link",
|
||||
"meta",
|
||||
"param",
|
||||
"source",
|
||||
"track",
|
||||
"wbr",
|
||||
"command",
|
||||
"keygen",
|
||||
"menuitem",
|
||||
];
|
5
ren/types.ts
Normal file
5
ren/types.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { AnyNode } from "../core/node.ts";
|
||||
|
||||
export interface Renderer<T> {
|
||||
render(node: AnyNode): T;
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
#!/bin/bash
|
||||
for f in lib/*.d.mts;
|
||||
do
|
||||
sed -i 's/.mjs"/.js"/' "$f"
|
||||
mv "$f" "${f//\.d\.mts/.d.ts}"
|
||||
done
|
|
@ -1,3 +0,0 @@
|
|||
export * from "./str.mjs";
|
||||
export * from "./nodes.mjs";
|
||||
export * from "./types.mjs";
|
21
src/lang.mts
21
src/lang.mts
|
@ -1,21 +0,0 @@
|
|||
export function isNil<T>(v: Nilable<T>): v is Nil {
|
||||
return v == null;
|
||||
}
|
||||
|
||||
export type Nullable<T> = T | null;
|
||||
|
||||
export type Nilable<T> = T | Nil;
|
||||
|
||||
export type Nil = null | undefined;
|
||||
|
||||
export function isBool(v: unknown): v is boolean {
|
||||
return typeof v === "boolean";
|
||||
}
|
||||
|
||||
export function isStr(v: unknown): v is string {
|
||||
return typeof v === "string";
|
||||
}
|
||||
|
||||
export function intoArr<T>(v: T | T[]): T[] {
|
||||
return Array.isArray(v) ? v : [v];
|
||||
}
|
125
src/nodes.mts
125
src/nodes.mts
|
@ -1,125 +0,0 @@
|
|||
import { intoArr, isNil, Nil, Nilable } from "./lang.mjs";
|
||||
|
||||
export function Et(tagName: string, ...texts: string[]): Elem {
|
||||
return new Elem(tagName).withText(texts);
|
||||
}
|
||||
|
||||
export function Ea(
|
||||
tagName: string,
|
||||
attrs?: ElemAttrs,
|
||||
children?: AnyNode | AnyNode[]
|
||||
): Elem {
|
||||
const el = new Elem(tagName);
|
||||
if (attrs) el.addAttrs(attrs);
|
||||
if (children) el.addChildren(children);
|
||||
return el;
|
||||
}
|
||||
|
||||
export function E(tagName: string, children: AnyNode | AnyNode[]): Elem {
|
||||
return new Elem(tagName).withChildren(children);
|
||||
}
|
||||
|
||||
export function F(...children: AnyNode[]): Frag {
|
||||
return new Frag().withChildren(children);
|
||||
}
|
||||
|
||||
export type AnyNode = TextNode | Elem | Frag | Nil | false;
|
||||
export type TextNode = string;
|
||||
|
||||
export class Frag {
|
||||
#children: Nilable<AnyNode[]>;
|
||||
|
||||
constructor() {
|
||||
this.#children = undefined;
|
||||
}
|
||||
|
||||
get children(): Nilable<AnyNode[]> {
|
||||
return this.#children;
|
||||
}
|
||||
|
||||
withText(texts: TextNode | TextNode[]): this {
|
||||
this.addText(texts);
|
||||
return this;
|
||||
}
|
||||
|
||||
addText(texts: TextNode | TextNode[]): void {
|
||||
this.addChildren(
|
||||
intoArr(texts)
|
||||
.map((t) => t.trim())
|
||||
.join(" ")
|
||||
);
|
||||
}
|
||||
|
||||
withChildren(nodes: AnyNode | AnyNode[]): this {
|
||||
this.addChildren(nodes);
|
||||
return this;
|
||||
}
|
||||
|
||||
addChildren(nodes: AnyNode | AnyNode[]): void {
|
||||
intoArr(nodes).forEach((n) => this.addChild(n));
|
||||
}
|
||||
|
||||
addChild(node: AnyNode): void {
|
||||
if (isNil(this.#children)) this.#children = [];
|
||||
this.#children.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
export type ElemAttrs = Record<string, unknown>;
|
||||
|
||||
export class Elem extends Frag {
|
||||
#tagName: string;
|
||||
#attrs: ElemAttrs;
|
||||
#isSelfClosed: boolean;
|
||||
|
||||
constructor(tagName: string) {
|
||||
super();
|
||||
this.#tagName = tagName;
|
||||
this.#attrs = {};
|
||||
this.#isSelfClosed = selfClosedTagNames.has(tagName);
|
||||
}
|
||||
|
||||
get tagName(): string {
|
||||
return this.#tagName;
|
||||
}
|
||||
|
||||
get attrs(): Record<string, unknown> {
|
||||
return this.#attrs;
|
||||
}
|
||||
|
||||
withAttrs(attrs: ElemAttrs): Elem {
|
||||
this.addAttrs(attrs);
|
||||
return this;
|
||||
}
|
||||
|
||||
addAttrs(attrs: ElemAttrs): void {
|
||||
Object.entries(attrs).forEach(([key, value]) => this.addAttr(key, value));
|
||||
}
|
||||
|
||||
addAttr(name: string, value: unknown): void {
|
||||
this.#attrs[name] = value;
|
||||
}
|
||||
|
||||
addChild(node: AnyNode): void {
|
||||
if (this.#isSelfClosed)
|
||||
throw new Error("You cannot add child to self closed element");
|
||||
super.addChild(node);
|
||||
}
|
||||
}
|
||||
|
||||
const selfClosedTagNames = new Set([
|
||||
"area",
|
||||
"base",
|
||||
"br",
|
||||
"col",
|
||||
"embed",
|
||||
"hr",
|
||||
"img",
|
||||
"input",
|
||||
"link",
|
||||
"meta",
|
||||
"param",
|
||||
"source",
|
||||
"track",
|
||||
"wbr",
|
||||
]);
|
63
src/str.mts
63
src/str.mts
|
@ -1,63 +0,0 @@
|
|||
import { Renderer } from "./types.mjs";
|
||||
import { isBool, isNil, isStr, Nilable, Nullable } from "./lang.mjs";
|
||||
import { AnyNode, Elem, ElemAttrs, Frag, TextNode } from "./nodes.mjs";
|
||||
|
||||
export class StrRenderer implements Renderer<string> {
|
||||
render(node: Elem): string {
|
||||
return encodeNode(node);
|
||||
}
|
||||
}
|
||||
|
||||
function encodeAnyNode(node: AnyNode): Nullable<string> {
|
||||
return !node ? null : isStr(node) ? encodeTextNode(node) : encodeNode(node);
|
||||
}
|
||||
|
||||
function encodeTextNode(node: TextNode): string {
|
||||
return String(node);
|
||||
}
|
||||
|
||||
function encodeNode(node: Elem | Frag): string {
|
||||
const encodedChildren = isNil(node.children)
|
||||
? undefined
|
||||
: node.children.map(encodeAnyNode);
|
||||
return node instanceof Elem
|
||||
? encodeHtmlElement(node.tagName, node.attrs, encodedChildren)
|
||||
: encodeHtmlFragment(encodedChildren);
|
||||
}
|
||||
|
||||
function encodeHtmlFragment(children?: Nilable<string>[]): string {
|
||||
return concat(children ?? []);
|
||||
}
|
||||
|
||||
function encodeHtmlElement(
|
||||
tagName: string,
|
||||
attrs?: ElemAttrs,
|
||||
children?: Nilable<string>[]
|
||||
): string {
|
||||
const open = `<${join(" ", [tagName, encodeAttrs(attrs)])}>`;
|
||||
if (isNil(children)) return open;
|
||||
return `${open}${concat(children)}</${tagName}>`;
|
||||
}
|
||||
|
||||
function encodeAttrs(attrs?: ElemAttrs): Nullable<string> {
|
||||
if (!attrs) return null;
|
||||
|
||||
return join(
|
||||
" ",
|
||||
Object.entries(attrs).map(([key, value]) => encodeAttr(key, value))
|
||||
);
|
||||
}
|
||||
|
||||
function encodeAttr(key: string, value: unknown): Nullable<string> {
|
||||
if (isNil(value)) return null;
|
||||
if (isBool(value)) return value ? key : null;
|
||||
return `${key}="${value}"`;
|
||||
}
|
||||
|
||||
function concat(arr: Nilable<string>[]): string {
|
||||
return join("", arr);
|
||||
}
|
||||
|
||||
function join(sep: string, arr: Nilable<string>[]): string {
|
||||
return arr.filter(Boolean).join(sep);
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
import { Elem } from "./nodes.mjs";
|
||||
|
||||
export interface Renderer<T> {
|
||||
render(node: Elem): T;
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "lib",
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"lib": [],
|
||||
"moduleResolution": "node",
|
||||
"declaration": true,
|
||||
"rootDir": "src",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"suppressImplicitAnyIndexErrors": true,
|
||||
"skipLibCheck": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitThis": true,
|
||||
"noImplicitAny": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.mts"
|
||||
],
|
||||
}
|
Reference in a new issue