Archived
1
0
Fork 0

Compare commits

...

5 commits

6 changed files with 146 additions and 34 deletions

3
.gitignore vendored
View file

@ -1,8 +1,5 @@
/* /*
# editors
!/.vscode
# makefile # makefile
!/makefile !/makefile

View file

@ -1,3 +0,0 @@
{
"editor.tabSize": 2
}

View file

@ -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 {

View file

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

View file

@ -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>',
);
},
});

View file

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