Compare commits
5 commits
f1d6a2e989
...
1a67959b1a
Author | SHA1 | Date | |
---|---|---|---|
1a67959b1a | |||
6a03f421b3 | |||
d39979645d | |||
0a70e34a13 | |||
c3014d690b |
6 changed files with 146 additions and 34 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1,8 +1,5 @@
|
||||||
/*
|
/*
|
||||||
|
|
||||||
# editors
|
|
||||||
!/.vscode
|
|
||||||
|
|
||||||
# makefile
|
# makefile
|
||||||
!/makefile
|
!/makefile
|
||||||
|
|
||||||
|
|
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
@ -1,3 +0,0 @@
|
||||||
{
|
|
||||||
"editor.tabSize": 2
|
|
||||||
}
|
|
|
@ -1,6 +1,8 @@
|
||||||
import { Nilable } from "./utils.ts";
|
import { Nilable } from "./utils.ts";
|
||||||
|
|
||||||
export type Attrs = Record<string, string>;
|
export type Attrs = Record<string, AttrVal>;
|
||||||
|
export type AttrEntry = [key: string, value: AttrVal];
|
||||||
|
export type AttrVal = boolean | string;
|
||||||
export type AnyNode = Fragment | FragmentNode;
|
export type AnyNode = Fragment | FragmentNode;
|
||||||
|
|
||||||
export function isTextNode(val: unknown): val is TextNode {
|
export function isTextNode(val: unknown): val is TextNode {
|
||||||
|
|
12
makefile
12
makefile
|
@ -1,12 +1,8 @@
|
||||||
|
|
||||||
build:
|
test-w:
|
||||||
npx tsc && make fix-decl
|
deno test --watch
|
||||||
|
|
||||||
watch:
|
test:
|
||||||
npx tsc-watch --onSuccess "make fix-decl"
|
deno test
|
||||||
|
|
||||||
fix-decl:
|
|
||||||
./scripts/fix_decl.sh
|
|
||||||
|
|
||||||
clean:
|
|
||||||
rm -rf lib
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { assertEquals } from "testing/asserts.ts";
|
import { assertEquals } from "testing/asserts.ts";
|
||||||
import { E, F, TextNode } from "../core/node.ts";
|
import { TextNode } from "../core/node.ts";
|
||||||
|
import { E, F } from "./node.ts";
|
||||||
|
|
||||||
import { StrRenderer } from "./str.ts";
|
import { StrRenderer } from "./str.ts";
|
||||||
|
|
||||||
|
@ -131,3 +132,85 @@ Deno.test({
|
||||||
assertEquals(res, "<!doctype html><body></body>");
|
assertEquals(res, "<!doctype html><body></body>");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test({
|
||||||
|
name: "should wrap node",
|
||||||
|
fn: () => {
|
||||||
|
const layout = E("body", [], []);
|
||||||
|
|
||||||
|
const ren = new StrRenderer({ wrapNode: (node) => E("html", [], [node]) });
|
||||||
|
const res = ren.render(layout);
|
||||||
|
|
||||||
|
assertEquals(res, "<!doctype html><html><body></body></html>");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test({
|
||||||
|
name: "should change attr key",
|
||||||
|
fn: () => {
|
||||||
|
const layout = E("a", {
|
||||||
|
target: "_blank",
|
||||||
|
href: "/hello/world",
|
||||||
|
rel: "nofollow noopener",
|
||||||
|
}, "hello world");
|
||||||
|
|
||||||
|
const ren = new StrRenderer({
|
||||||
|
onVisitAttr: ([key, val]) => [`data-${key}`, val],
|
||||||
|
});
|
||||||
|
const res = ren.render(layout);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
res,
|
||||||
|
'<a data-target="_blank" data-href="/hello/world" data-rel="nofollow noopener">hello world</a>',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test({
|
||||||
|
name: "should change attr value",
|
||||||
|
fn: () => {
|
||||||
|
const layout = E("a", {
|
||||||
|
href: "/hello/world",
|
||||||
|
}, "hello world");
|
||||||
|
|
||||||
|
const ren = new StrRenderer({
|
||||||
|
onVisitAttr: ([key, val]) => [key, "/eng" + val],
|
||||||
|
});
|
||||||
|
const res = ren.render(layout);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
res,
|
||||||
|
'<a href="/eng/hello/world">hello world</a>',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test({
|
||||||
|
name: "should filter attr",
|
||||||
|
fn: () => {
|
||||||
|
const layout = E("input", { type: "number", disabled: false });
|
||||||
|
|
||||||
|
const ren = new StrRenderer();
|
||||||
|
const res = ren.render(layout);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
res,
|
||||||
|
'<input type="number">',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test({
|
||||||
|
name: "should render boolean attr",
|
||||||
|
fn: () => {
|
||||||
|
const layout = E("input", { type: "number", disabled: true });
|
||||||
|
|
||||||
|
const ren = new StrRenderer();
|
||||||
|
const res = ren.render(layout);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
res,
|
||||||
|
'<input type="number" disabled>',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
73
ren/str.ts
73
ren/str.ts
|
@ -1,6 +1,8 @@
|
||||||
import {
|
import {
|
||||||
AnyNode,
|
AnyNode,
|
||||||
|
AttrEntry,
|
||||||
Attrs,
|
Attrs,
|
||||||
|
AttrVal,
|
||||||
Elem,
|
Elem,
|
||||||
Fragment,
|
Fragment,
|
||||||
isElem,
|
isElem,
|
||||||
|
@ -14,63 +16,98 @@ import { Renderer } from "./types.ts";
|
||||||
interface StrRendererOpts {
|
interface StrRendererOpts {
|
||||||
doctype?: string;
|
doctype?: string;
|
||||||
forceRenderDoctype?: boolean;
|
forceRenderDoctype?: boolean;
|
||||||
|
wrapNode?: (node: AnyNode) => AnyNode;
|
||||||
|
onVisitAttr?: (entry: AttrEntry, params: OnVisitAttrParams) => AttrEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StrRendererHooks {
|
||||||
|
onVisitAttr: (entry: AttrEntry, params: OnVisitAttrParams) => AttrEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnVisitAttrParams {
|
||||||
|
readonly tagName: string;
|
||||||
|
readonly attrs: Attrs;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class StrRenderer implements Renderer<string> {
|
export class StrRenderer implements Renderer<string> {
|
||||||
#opts: StrRendererOpts;
|
#doctype: string;
|
||||||
|
#forceRenderDoctype: boolean;
|
||||||
|
#wrapNode: (node: AnyNode) => AnyNode;
|
||||||
|
#hooks: StrRendererHooks;
|
||||||
|
|
||||||
constructor(opts?: StrRendererOpts) {
|
constructor(opts?: StrRendererOpts) {
|
||||||
this.#opts = opts ?? {};
|
this.#doctype = opts?.doctype ?? "html";
|
||||||
|
this.#forceRenderDoctype = opts?.forceRenderDoctype ?? false;
|
||||||
|
this.#wrapNode = opts?.wrapNode ?? identity;
|
||||||
|
this.#hooks = {
|
||||||
|
onVisitAttr: opts?.onVisitAttr ?? identity,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
render(node: AnyNode): string {
|
render(node: AnyNode): string {
|
||||||
const shouldRenderDoctype = this.#opts.forceRenderDoctype ||
|
const wrappedNode = this.#wrapNode(node);
|
||||||
(isElem(node) && node.tagName === "html");
|
const shouldRenderDoctype = this.#forceRenderDoctype ||
|
||||||
|
(isElem(wrappedNode) && wrappedNode.tagName === "html");
|
||||||
return concat([
|
return concat([
|
||||||
shouldRenderDoctype && encodeDoctype(this.#opts.doctype),
|
shouldRenderDoctype && encodeDoctype(this.#doctype),
|
||||||
encodeAnyNode(node),
|
encodeAnyNode(wrappedNode, this.#hooks),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function encodeDoctype(value?: string): string {
|
function identity<T>(val: T): T {
|
||||||
return `<!doctype ${value ?? "html"}>`;
|
return val;
|
||||||
}
|
}
|
||||||
|
|
||||||
function encodeAnyNode(node: AnyNode): string {
|
function encodeDoctype(value: string): string {
|
||||||
|
return `<!doctype ${value}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeAnyNode(node: AnyNode, hooks: StrRendererHooks): string {
|
||||||
return isTextNode(node)
|
return isTextNode(node)
|
||||||
? encodeTextNode(node)
|
? encodeTextNode(node)
|
||||||
: isFragment(node)
|
: isFragment(node)
|
||||||
? encodeHtmlFragment(node)
|
? encodeHtmlFragment(node, hooks)
|
||||||
: encodeHtmlElement(node);
|
: encodeHtmlElement(node, hooks);
|
||||||
}
|
}
|
||||||
|
|
||||||
function encodeTextNode(node: TextNode): string {
|
function encodeTextNode(node: TextNode): string {
|
||||||
return node.innerText;
|
return node.innerText;
|
||||||
}
|
}
|
||||||
|
|
||||||
function encodeHtmlFragment(node: Fragment): string {
|
function encodeHtmlFragment(node: Fragment, hooks: StrRendererHooks): string {
|
||||||
return concat(node.children.map(encodeAnyNode));
|
return concat(node.children.map((ch) => encodeAnyNode(ch, hooks)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function encodeHtmlElement(
|
function encodeHtmlElement(
|
||||||
{ tagName, attrs, children }: Elem,
|
{ tagName, attrs, children }: Elem,
|
||||||
|
hooks: StrRendererHooks,
|
||||||
): string {
|
): string {
|
||||||
const open = `<${join(" ", [tagName, encodeAttrs(attrs)])}>`;
|
const open = `<${join(" ", [tagName, encodeAttrs(tagName, attrs, hooks)])}>`;
|
||||||
if (isSelfClosedTagName(tagName)) return open;
|
if (isSelfClosedTagName(tagName)) return open;
|
||||||
|
|
||||||
const encodedChildren = children.map(encodeAnyNode);
|
const encodedChildren = children.map((ch) => encodeAnyNode(ch, hooks));
|
||||||
return `${open}${concat(encodedChildren)}</${tagName}>`;
|
return `${open}${concat(encodedChildren)}</${tagName}>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function encodeAttrs(attrs: Attrs): string {
|
function encodeAttrs(
|
||||||
|
tagName: string,
|
||||||
|
attrs: Attrs,
|
||||||
|
hooks: StrRendererHooks,
|
||||||
|
): string {
|
||||||
return join(
|
return join(
|
||||||
" ",
|
" ",
|
||||||
Object.entries(attrs).map(([key, value]) => encodeAttr(key, value)),
|
Object.entries(attrs).map((entry) => {
|
||||||
|
const [key, value] = hooks.onVisitAttr(entry, { tagName, attrs });
|
||||||
|
return encodeAttr(key, value);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function encodeAttr(key: string, value: string): string {
|
function encodeAttr(key: string, value: AttrVal): string {
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return value ? key : "";
|
||||||
|
}
|
||||||
return `${key}="${value}"`;
|
return `${key}="${value}"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Reference in a new issue