Skip to content

Commit

Permalink
feat(examples): add value coercions & re-formatter (xml-converter)
Browse files Browse the repository at this point in the history
  • Loading branch information
postspectacular committed Sep 25, 2018
1 parent 0cce048 commit 79cf49e
Show file tree
Hide file tree
Showing 2 changed files with 198 additions and 59 deletions.
16 changes: 9 additions & 7 deletions examples/xml-converter/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,17 @@
<body class="ma0 pa2 sans-serif">
<div id="app"></div>
<div>
<h3>Current limitations</h3>
<h3>Current features</h3>
<ul>
<li>Nicer output formatting needed</li>
<li>No HTML quirks supported (well formed XML only)</li>
<li><del>No HTML boolean attribs</del></li>
<li>Probably many more... :)</li>
<li>See issue <a href="https://github.com/thi-ng/umbrella/issues/48">#48</a></li>
<li>HTML boolean attribs</li>
<li>Numeric & boolean attrib value parsing</li>
<li><code>style</code> attrib conversion</li>
<li>Standard HTML entity unescaping</li>
<li>Input must be well formed XML</li>
<li>Hiccup specific (more compact) JSON formatting</li>
<li>See issue <a class="link blue" href="https://github.com/thi-ng/umbrella/issues/48">#48</a></li>
</ul>
<a class="link" href="https://github.com/thi-ng/umbrella/tree/master/examples/xml-converter">Source code</a>
<a class="link blue" href="https://github.com/thi-ng/umbrella/tree/master/examples/xml-converter">Source code</a>
</div>
<script type="text/javascript" src="./src/index.ts"></script>
</body>
Expand Down
241 changes: 189 additions & 52 deletions examples/xml-converter/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import { stream } from "@thi.ng/rstream/stream";
import { isArray } from "@thi.ng/checks/is-array";
import { isBoolean } from "@thi.ng/checks/is-boolean";
import { isNumber } from "@thi.ng/checks/is-number";
import { isPlainObject } from "@thi.ng/checks/is-plain-object";
import { DEFAULT, defmulti } from "@thi.ng/defmulti";
import { stream, Stream } from "@thi.ng/rstream/stream";
import { parse, Type } from "@thi.ng/sax";
import { splice } from "@thi.ng/strings/splice";
import { maybeParseFloat } from "@thi.ng/strings/parse";
import { repeat } from "@thi.ng/strings/repeat";
import { updateDOM } from "@thi.ng/transducers-hdom";
import { comp } from "@thi.ng/transducers/func/comp";
import { identity } from "@thi.ng/transducers/func/identity";
import { peek } from "@thi.ng/transducers/func/peek";
import { pairs } from "@thi.ng/transducers/iter/pairs";
import { assocObj } from "@thi.ng/transducers/rfn/assoc-obj";
import { last } from "@thi.ng/transducers/rfn/last";
Expand All @@ -13,34 +21,53 @@ import { filter } from "@thi.ng/transducers/xform/filter";
import { map } from "@thi.ng/transducers/xform/map";
import { multiplex } from "@thi.ng/transducers/xform/multiplex";

// parses given XMLish string using @thi.ng/sax transducer into a tree
// sequence of parse events. we only care about the final (or error)
// event, which will be related to the final close tag and contains the
// entire tree
const parseXML = (src: string) =>
transduce(
comp(
parse({ trim: true, boolean: true }),
parse({ trim: true, boolean: true, entities: true }),
filter((e) => e.type === Type.ELEM_END || e.type === Type.ERROR)
),
last(),
src
);

// transforms string of CSS properties into a plain object
const transformCSS = (css: string) =>
css.split(";").reduce(
(acc, p) => {
const [k, v] = p.split(":");
acc[k.trim()] = v.trim();
return acc;
},
{}
);

// takes attrib key-value pair and attempts to coerce / transform its
// value. returns updated pair.
const parseAttrib = ([k, v]: string[]) =>
k === "style" ?
[k, transformCSS(v)] :
v === "true" ?
[k, true] :
v === "false" ?
[k, false] :
[k, maybeParseFloat(v, v)];

// transforms an entire object of attributes
const transformAttribs = (attribs: any) =>
transduce(
comp(
map(([k, v]) => [k, maybeParseFloat(v, null)]),
filter(([_, v]) => v !== null),
),
map(parseAttrib),
assocObj(),
attribs,
pairs(attribs)
pairs<string>(attribs)
);

const transformTree = (tree) => {
if (tree.type === Type.ERROR) {
return ["error", tree.body];
}
const res: any[] = [];
const attribs = transformAttribs(tree.attribs);
let tag = tree.tag;
// transforms element name by attempting to form Emmet-like tags
const transformTag = (tag: string, attribs: any) => {
if (attribs.id) {
tag += "#" + attribs.id;
delete attribs.id;
Expand All @@ -49,7 +76,16 @@ const transformTree = (tree) => {
tag += "." + attribs.class.replace(/\s+/g, ".");
delete attribs.class;
}
res.push(tag);
return tag;
};

// recursively transforms entire parse tree
const transformTree = (tree: any) => {
if (tree.type === Type.ERROR) {
return ["error", tree.body];
}
const attribs = transformAttribs(tree.attribs);
const res: any[] = [transformTag(tree.tag, attribs)];
if (Object.keys(attribs).length) {
res.push(attribs);
}
Expand All @@ -62,55 +98,156 @@ const transformTree = (tree) => {
return res;
};

const app = ([html, hiccup]) =>
["div.flex",
["div",
["h3", "XML/HTML source",
["small.fw1.ml2", "(must be well formed!)"]],
["textarea.mr2.f7.code.bg-light-yellow",
{
cols: 72,
rows: 25,
oninput: (e) => src.next(e.target.value),
value: html
}]
],
["div",
["h3", "Parsed Hiccup / JSON"],
["textarea.f7.code",
{
cols: 72,
rows: 25,
disabled: true
},
JSON.stringify(hiccup, null, 2)
// dispatch helper function for the `format` defmulti below
const classify = (x: any) =>
isArray(x) ? "array" : isPlainObject(x) ? "obj" : DEFAULT;

// wraps attrib name in quotes if needed
const formatAttrib = (x: string) =>
/^[a-z]+$/i.test(x) ? x : `"${x}"`;

// attrib or body value formatter
const formatVal = (x: any, indent: number, istep: number) =>
isNumber(x) || isBoolean(x) ?
x :
isPlainObject(x) ?
format(x, "", indent + istep, istep) :
`"${x}"`;

// attrib key-value pair formatter w/ indentation
const formatPair = (x: any, k: string, indent: number, istep: number) =>
`${spaces(indent)}${formatAttrib(k)}: ${formatVal(x[k], indent, istep)}`;

// memoized indentations
const spaces = (n: number) => repeat(" ", n);

// multiply dispatch function to format the transformed tree (hiccup
// structure) into a more compact & legible format than produced by
// standard: `JSON.stringify(tree, null, 4)`
const format = defmulti<any, string, number, number, string>(classify);

// implementation for array values
format.add("array", (x, res, indent, istep) => {
const hasAttribs = isPlainObject(x[1]);
let attribs = hasAttribs ? Object.keys(x[1]) : [];
res += `${spaces(indent)}["${x[0]}"`;
if (hasAttribs) {
res += ", ";
res = format(x[1], res, indent + istep, istep);
}
// single line if none or only single child
// and if max. 1 CSS prop
if (x.length === (hasAttribs ? 3 : 2) &&
attribs.length < 2 &&
attribs[0] !== "style" &&
classify(peek(x)) === DEFAULT) {
return format(peek(x), res += ", ", 0, istep) + "]";
}
// default format if more children
for (let i = hasAttribs ? 2 : 1; i < x.length; i++) {
res += ",\n";
res = format(x[i], res, indent + istep, istep);
}
res += "]";
return res;
});

// implementation for object values (i.e. attributes in this case)
format.add("obj", (x, res, indent, istep) => {
const keys = Object.keys(x);
if (keys.length === 1 &&
(keys[0] !== "style" || Object.keys(x.style).length == 1)) {
res += `{ ${formatPair(x, keys[0], 0, istep)} }`;
} else {
const outer = spaces(indent);
res += `\n${outer}{\n`;
for (let k in x) {
res += formatPair(x, k, indent + istep, istep) + ",\n";
}
res += outer + "}";
}
return res;
});

// implementation for other values
format.add(DEFAULT, (x, res, indent, istep) =>
res += spaces(indent) + formatVal(x, indent, istep));

// hdom UI root component receives tuple of xml & formatted hiccup
// strings. defined as closure purely for demonstration purposes and to
// avoid app using global vars
const app = (src: Stream<string>) =>
([xml, hiccup]: string[]) =>
["div.flex",
["div",
["h3", "XML/HTML source",
["small.fw1.ml2", "(must be well formed!)"]],
["textarea.mr2.f7.code.bg-light-yellow",
{
cols: 72,
rows: 25,
autofocus: true,
onkeydown: (e: KeyboardEvent) => {
// override tab to insert spaces at edit pos
if (e.key === "Tab") {
e.preventDefault();
src.next(
splice(xml, spaces(4), (<any>e.target).selectionStart)
);
}
},
// emitting a new value to the stream will
// re-trigger UI update
oninput: (e) => src.next(e.target.value),
value: xml
}]
],
["div",
["h3", "Parsed Hiccup / JSON"],
["textarea.f7.code",
{
cols: 72,
rows: 25,
disabled: true,
value: hiccup
},
]
]
]
];

const src = stream<string>()
.transform(
multiplex(
map(identity),
comp(
map(parseXML),
map(transformTree)
)
),
map(app),
updateDOM()
);
];

// create a stream which transforms input values (xml strings) parses,
// transforms and formats them and then forms a tuple of:
// `[orig, formatted]` to pass to the root component function and
// finally updates the DOM
const src = stream<string>();
src.transform(
multiplex(
map(identity),
comp(
map(parseXML),
map(transformTree),
map((tree) => format(tree, "", 0, 4))
)
),
map(app(src)),
updateDOM()
);

// seed input and kick off UI/app
src.next(`<html lang="en">
<head>
<title>foo</title>
</head>
<body class="foo bar">
<h1 style="color:red">
HTML &amp; Hiccup walk into a bar...
</h1>
<div id="app"></div>
<input disabled value="42"/>
</body>
</html>`);

// ParcelJS HMR handling
if (process.env.NODE_ENV !== "production") {
const hot = (<any>module).hot;
hot && hot.dispose(() => src.done());
Expand Down

0 comments on commit 79cf49e

Please sign in to comment.