diff --git a/.eslintrc.yml b/.eslintrc.yml
deleted file mode 100644
index f639d50..0000000
--- a/.eslintrc.yml
+++ /dev/null
@@ -1,18 +0,0 @@
-env:
- commonjs: true
- es6: true
- node: true
-extends:
- - airbnb-base
- - plugin:prettier/recommended
- - eslint:recommended
-globals:
- Atomics: readonly
- SharedArrayBuffer: readonly
-parserOptions:
- ecmaVersion: 2018
-plugins:
- - prettier
-rules:
- prettier/prettier: error
- semi: 0
diff --git a/package-lock.json b/package-lock.json
index ff51340..fefaaa9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -474,6 +474,15 @@
"defer-to-connect": "^1.0.1"
}
},
+ "@types/clean-css": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@types/clean-css/-/clean-css-4.2.1.tgz",
+ "integrity": "sha512-A1HQhQ0hkvqqByJMgg+Wiv9p9XdoYEzuwm11SVo1mX2/4PSdhjcrUlilJQoqLscIheC51t1D5g+EFWCXZ2VTQQ==",
+ "dev": true,
+ "requires": {
+ "@types/node": "*"
+ }
+ },
"@types/events": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz",
@@ -491,6 +500,17 @@
"@types/node": "*"
}
},
+ "@types/html-minifier": {
+ "version": "3.5.3",
+ "resolved": "https://registry.npmjs.org/@types/html-minifier/-/html-minifier-3.5.3.tgz",
+ "integrity": "sha512-j1P/4PcWVVCPEy5lofcHnQ6BtXz9tHGiFPWzqm7TtGuWZEfCHEP446HlkSNc9fQgNJaJZ6ewPtp2aaFla/Uerg==",
+ "dev": true,
+ "requires": {
+ "@types/clean-css": "*",
+ "@types/relateurl": "*",
+ "@types/uglify-js": "*"
+ }
+ },
"@types/minimatch": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
@@ -509,6 +529,35 @@
"integrity": "sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==",
"dev": true
},
+ "@types/parse5": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/@types/parse5/-/parse5-5.0.2.tgz",
+ "integrity": "sha512-BOl+6KDs4ItndUWUFchy3aEqGdHhw0BC4Uu+qoDonN/f0rbUnJbm71Ulj8Tt9jLFRaAxPLKvdS1bBLfx1qXR9g==",
+ "dev": true
+ },
+ "@types/relateurl": {
+ "version": "0.2.28",
+ "resolved": "https://registry.npmjs.org/@types/relateurl/-/relateurl-0.2.28.tgz",
+ "integrity": "sha1-a9p9uGU/piZD9e5p6facEaOS46Y=",
+ "dev": true
+ },
+ "@types/uglify-js": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.0.4.tgz",
+ "integrity": "sha512-SudIN9TRJ+v8g5pTG8RRCqfqTMNqgWCKKd3vtynhGzkIIjxaicNAMuY5TRadJ6tzDu3Dotf3ngaMILtmOdmWEQ==",
+ "dev": true,
+ "requires": {
+ "source-map": "^0.6.1"
+ },
+ "dependencies": {
+ "source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "dev": true
+ }
+ }
+ },
"aggregate-error": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.0.0.tgz",
diff --git a/package.json b/package.json
index 196f5a2..fd8c6af 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,8 @@
"parse5": "^5.1.0"
},
"devDependencies": {
+ "@types/html-minifier": "^3.5.3",
+ "@types/parse5": "^5.0.2",
"ava": "^2.3.0",
"husky": "^3.0.5",
"lint-staged": "^9.2.5",
@@ -40,8 +42,8 @@
]
},
"lint-staged": {
- "*.js": [
- "npm run lint:fix",
+ "*.ts": [
+ "npm run lint",
"git add"
]
},
diff --git a/src/Attribute.test.js b/src/Attribute.test.js
new file mode 100644
index 0000000..bae7871
--- /dev/null
+++ b/src/Attribute.test.js
@@ -0,0 +1,43 @@
+import test from 'ava';
+import Attribute from './Attribute';
+
+test('stringifies an ID', async t => {
+ const attr = new Attribute('id', 'foo');
+ t.is(attr.toString(), '#foo');
+});
+
+test('stringifies a single class selector', async t => {
+ const attr = new Attribute('class', 'foo');
+ t.is(attr.toString(), '.foo');
+});
+
+test('stringifies multiple class selectors', async t => {
+ const attr = new Attribute('class', 'foo bar baz qux');
+ t.is(attr.toString(), '.foo.bar.baz.qux');
+});
+
+test('stringifies a generic attribute', async t => {
+ const attr = new Attribute('type', 'number');
+ t.is(attr.toString(), "type='number'");
+});
+
+test('changes quote style', async t => {
+ const attr = new Attribute('quote-style', 'double', true);
+ t.is(attr.toString(), 'quote-style="double"');
+});
+
+test('escapes single quotes from values', async t => {
+ const attr = new Attribute(
+ 'style',
+ "background-image: url('/path/to/nowhere')",
+ );
+ t.is(
+ attr.toString(),
+ "style='background-image: url(\\'/path/to/nowhere\\')'",
+ );
+});
+
+test('omits value if blank', async t => {
+ const attr = new Attribute('required');
+ t.is(attr.toString(), 'required');
+});
diff --git a/src/Attribute.ts b/src/Attribute.ts
new file mode 100644
index 0000000..58932ac
--- /dev/null
+++ b/src/Attribute.ts
@@ -0,0 +1,53 @@
+/**
+ * Represents a single attribute on a [PugNode].
+ */
+class Attribute {
+ // Attribute name
+ public name: string;
+ // Attribute value
+ public value: string;
+ // Quote style for values
+ public doubleQuotes: boolean = false;
+
+ constructor(name: string, value: string, doubleQuotes?: boolean) {
+ this.name = name;
+ this.value = value;
+ if (doubleQuotes) {
+ this.doubleQuotes = doubleQuotes;
+ }
+ }
+
+ /**
+ * Returns a quote character based on quote style.
+ */
+ private get quote() {
+ return this.doubleQuotes ? '"' : '\'';
+ }
+
+ /**
+ * Creates a string representation of the attribute.
+ * e.g. key="value"
+ */
+ public toString(): string {
+ switch (this.name) {
+ case 'id':
+ return `#${this.value}`;
+
+ case 'class':
+ return `.${this.value.split(' ').join('.')}`;
+
+ default: {
+ // If value is blank, return just the name (shorthand)
+ if (!this.value) {
+ return this.name;
+ }
+ // Add escaped single quotes (\') to attribute values
+ // to allow for surrounding single quotes.
+ const safeValue: string = this.value.replace(/'/g, '\\\'');
+ return `${this.name}=${this.quote}${safeValue}${this.quote}`;
+ }
+ }
+ }
+}
+
+export default Attribute;
diff --git a/src/Options.ts b/src/Options.ts
new file mode 100644
index 0000000..d78433f
--- /dev/null
+++ b/src/Options.ts
@@ -0,0 +1,27 @@
+/**
+ * Defines all the options for html2pug.
+ */
+export interface Options {
+ caseSensitive: boolean;
+ collapseBooleanAttributes: boolean;
+ collapseWhitespace: boolean;
+ commas: boolean;
+ doubleQuotes: boolean;
+ fragment: boolean;
+ preserveLineBreaks: boolean;
+ removeEmptyAttributes: boolean;
+ tabs: boolean;
+}
+
+// Default options for html2pug.
+export const defaultOptions = {
+ caseSensitive: true,
+ collapseBooleanAttributes: true,
+ collapseWhitespace: true,
+ commas: true,
+ doubleQuotes: false,
+ fragment: false,
+ preserveLineBreaks: true,
+ removeEmptyAttributes: true,
+ tabs: false,
+};
diff --git a/src/Parser.test.js b/src/Parser.test.js
new file mode 100644
index 0000000..d0f1aa4
--- /dev/null
+++ b/src/Parser.test.js
@@ -0,0 +1,45 @@
+import test from 'ava';
+import Html2Pug from './index';
+
+test('converts a single HTML element', async t => {
+ const pug = Html2Pug('
Hello, world!
', {
+ fragment: true,
+ });
+ t.is(pug, 'h1.title Hello, world!');
+});
+
+test('converts a nested HTML fragment', async t => {
+ const pug = Html2Pug(
+ '',
+ {
+ fragment: true,
+ },
+ );
+ const expected = `ul#fruits.list
+ li.item Mango
+ li.item Apple`;
+ t.is(pug, expected);
+});
+
+test('removes whitespace between HTML elements', t => {
+ const pug = Html2Pug(
+ `
+ - one
+ - two
+
+ - three
+
+
+ - four
+
`,
+ { fragment: true },
+ );
+
+ const expected = `ul.list
+ li one
+ li two
+ li three
+ li four`;
+
+ t.is(pug, expected);
+});
diff --git a/src/Parser.ts b/src/Parser.ts
new file mode 100644
index 0000000..3ddd88b
--- /dev/null
+++ b/src/Parser.ts
@@ -0,0 +1,301 @@
+import {
+ DefaultTreeCommentNode,
+ DefaultTreeDocument,
+ DefaultTreeElement,
+ DefaultTreeNode,
+ DefaultTreeTextNode,
+} from 'parse5';
+import { Options } from './Options';
+import PugNode from './PugNode';
+
+// Defines the type of parsed HTML nodes.
+export enum Nodes {
+ Doctype = '#documentType',
+ Text = '#text',
+ Comment = '#comment',
+ Div = 'div',
+}
+
+/**
+ * Parses an HTML document and creates a Pug string.
+ */
+class Parser {
+ public pug: string = '';
+ public root: DefaultTreeDocument;
+ // Tabs vs Spaces
+ public tabs: boolean;
+ // Comma separate attributes
+ public commas: boolean;
+ // Use double quotes or single
+ public doubleQuotes: boolean;
+
+ constructor(root: DefaultTreeDocument, options: Options) {
+ this.root = root;
+
+ const {
+ commas,
+ doubleQuotes,
+ tabs,
+ }: {
+ commas: boolean;
+ doubleQuotes: boolean;
+ tabs: boolean;
+ } = options;
+
+ this.commas = commas;
+ this.doubleQuotes = doubleQuotes;
+ this.tabs = tabs;
+ }
+
+ public parse(): string {
+ const walk = this.walk(this.root.childNodes, 0);
+ let iterator;
+
+ do {
+ iterator = walk.next();
+ } while (!iterator.done);
+
+ return this.pug.substring(1);
+ }
+
+ /**
+ * DOM tree traversal
+ * Depth-first search (pre-order)
+ *
+ * @param {DefaultTreeNode[]} tree - DOM Node tree
+ * @param {Number} level - Current tree level
+ */
+ private *walk(
+ tree: DefaultTreeNode[],
+ indentLevel: number,
+ ): IterableIterator {
+ if (!tree.length) {
+ return;
+ }
+
+ for (const treeNode of tree) {
+ const node = treeNode as DefaultTreeElement;
+
+ const pugNode = this.parseHtmlNode(node, indentLevel);
+ if (pugNode) {
+ this.pug += `\n${pugNode.toString()}`;
+ }
+
+ if (
+ Array.isArray(node.childNodes) &&
+ node.childNodes.length &&
+ !hasOnlyTextChildNode(node)
+ ) {
+ yield* this.walk(node.childNodes, indentLevel + 1);
+ }
+ }
+ }
+ /**
+ * Creates a [PugNode] from a #documentType element.
+ *
+ * @param indentLevel
+ */
+ private createDoctypeNode = (indentLevel: number): PugNode =>
+ new PugNode(
+ Nodes.Doctype,
+ 'doctype html',
+ indentLevel,
+ this.tabs,
+ this.commas,
+ );
+
+ /**
+ * Creates a [PugNode] from a #comment element.
+ * Block comments in Pug don't require the dot '.' character.
+ *
+ * @param indentLevel
+ */
+ private createCommentNode = (
+ node: DefaultTreeCommentNode,
+ indentLevel: number,
+ ): PugNode =>
+ new PugNode(Nodes.Comment, node.data, indentLevel, this.tabs, this.commas);
+
+ /**
+ * Creates a [PugNode] from a #text element.
+ *
+ * A #text element containing only line breaks (\n) indicates
+ * unnecessary whitespace between elements that should be removed.
+ *
+ * Actual text in a single #text element has no significant
+ * whitespace and should be treated as inline text.
+ */
+ private createTextNode(
+ node: DefaultTreeTextNode,
+ indentLevel: number,
+ ): PugNode | void {
+ const value: string = node.value;
+ // Omit line breaks between HTML elements
+ if (/^[\n]+$/.test(value)) {
+ return;
+ }
+
+ return new PugNode(Nodes.Text, value, indentLevel, this.tabs, this.commas);
+ }
+
+ /**
+ * Converts an HTML element into a [PugNode].
+ *
+ * @param node
+ */
+ private createElementNode(
+ node: DefaultTreeElement,
+ indentLevel: number,
+ ): PugNode {
+ let value: string = '';
+ if (hasOnlyTextChildNode(node)) {
+ const textNode = node.childNodes[0] as DefaultTreeTextNode;
+ value = textNode.value;
+ }
+
+ const pugNode = new PugNode(
+ node.tagName,
+ value,
+ indentLevel,
+ this.tabs,
+ this.commas,
+ );
+ node.attrs.forEach(attr =>
+ pugNode.setAttribute(attr.name, attr.value, this.doubleQuotes),
+ );
+ return pugNode;
+ }
+
+ /**
+ * Parses the HTML node and converts it to a [PugNode].
+ *
+ * @param node
+ * @param indentLevel
+ */
+ private parseHtmlNode(
+ node: DefaultTreeNode,
+ indentLevel: number,
+ ): PugNode | void {
+ switch (node.nodeName) {
+ case Nodes.Doctype:
+ return this.createDoctypeNode(indentLevel);
+ case Nodes.Comment:
+ return this.createCommentNode(
+ node as DefaultTreeCommentNode,
+ indentLevel,
+ );
+ case Nodes.Text:
+ return this.createTextNode(node as DefaultTreeTextNode, indentLevel);
+ default:
+ return this.createElementNode(node as DefaultTreeElement, indentLevel);
+ }
+ }
+
+ /*
+ * formatPugNode applies the correct indent for the current line,
+ * and formats the value as either as a single or multiline string.
+ private formatPugNode(
+ node: PugNode,
+ value: string = '',
+ level: number,
+ blockChar: string = '.',
+ ): PugNode {
+ const indent = this.getIndent(level);
+ const result = `${indent}${node.toString()}`;
+
+ const lines = value.split('\n');
+
+ // Create an inline node
+ if (lines.length <= 1) {
+ return value.length ? `${result} ${value}` : result;
+ }
+
+ // Create a multiline node
+ const indentChild = this.getIndent(level + 1);
+ const multiline = lines.map(line => `${indentChild}${line}`).join('\n');
+
+ return `${result}${blockChar}\n${multiline}`;
+ }
+
+ /**
+ * createDoctype formats a #documentType element
+ private createDoctype(level: number) {
+ const indent = this.getIndent(level);
+ return `${indent}doctype html`;
+ }
+
+ /**
+ * createComment formats a #comment element.
+ *
+ * Block comments in Pug don't require the dot '.' character.
+ private createComment(node: DefaultTreeCommentNode, level: number) {
+ return this.formatPugNode(COMMENT_NODE_PUG, node.data, level, '');
+ }
+
+ /**
+ * createText formats a #text element.
+ *
+ * A #text element containing only line breaks (\n) indicates
+ * unnecessary whitespace between elements that should be removed.
+ *
+ * Actual text in a single #text element has no significant
+ * whitespace and should be treated as inline text.
+ private createText(node: DefaultTreeTextNode, level: number) {
+ const { value } = node;
+ const indent = this.getIndent(level);
+
+ // Omit line breaks between HTML elements
+ if (/^[\n]+$/.test(value)) {
+ return false;
+ }
+
+ return `${indent}| ${value}`;
+ }
+
+ /**
+ * createElement formats a generic HTML element.
+ private createElement(node: DefaultTreeElement, level: number) {
+ const pugNode: PugNode = this.createPugNode(node);
+
+ const value = hasSingleTextNodeChild(node)
+ ? node.childNodes[0].value
+ : node.value;
+
+ return this.formatPugNode(pugNode, value, level);
+ }
+
+ private parseNode(node: DefaultTreeNode, level: number) {
+ const { nodeName } = node;
+
+ switch (nodeName) {
+ case DOCUMENT_TYPE_NODE:
+ return this.createDoctype(level);
+
+ case COMMENT_NODE:
+ return this.createComment(node as DefaultTreeCommentNode, level);
+
+ case TEXT_NODE:
+ return this.createText(node as DefaultTreeTextNode, level);
+
+ default:
+ return this.createElement(node as DefaultTreeElement, level);
+ }
+ }
+ */
+}
+
+/**
+ * Checks whether a [node] only has a single text child node.
+ *
+ * @param node
+ */
+function hasOnlyTextChildNode(node: DefaultTreeElement): boolean {
+ if (Array.isArray(node.childNodes) && node.childNodes.length === 1) {
+ if (node.childNodes[0].nodeName === Nodes.Text) {
+ return true;
+ }
+ }
+ return false;
+}
+
+export default Parser;
diff --git a/src/PugNode.test.js b/src/PugNode.test.js
new file mode 100644
index 0000000..3cc2f40
--- /dev/null
+++ b/src/PugNode.test.js
@@ -0,0 +1,79 @@
+import test from 'ava';
+import PugNode from './PugNode';
+
+test('sets attributes', async t => {
+ const node = new PugNode('div');
+ t.falsy(node.attributes.length);
+ node.setAttribute('id', 'foo');
+ t.truthy(node.attributes.length);
+});
+
+test('stringifies div with standard form if no attributes', async t => {
+ const node = new PugNode('div', 'foo');
+ t.is(node.toString(), 'div foo');
+});
+
+test('stringifies using div shorthand', async t => {
+ const node = new PugNode('div');
+ node.setAttribute('id', 'foo');
+ node.setAttribute('class', 'bar');
+ t.is(node.toString(), '#foo.bar');
+});
+
+test('stringifies text nodes with no tag name', async t => {
+ const node = new PugNode('#text', 'foo');
+ t.is(node.toString(), 'foo');
+});
+
+test('stringifies using comment shorthand', async t => {
+ const node = new PugNode('#comment', 'foo');
+ t.is(node.toString(), '// foo');
+});
+
+test('stringifies attributes without comma', async t => {
+ const node = new PugNode('input');
+ node.setAttribute('type', 'number');
+ node.setAttribute('required', 'required');
+ t.is(node.toString(), "input(type='number' required='required')");
+});
+
+test('stringifies attributes with comma', async t => {
+ const node = new PugNode('input', '', 0, false, true);
+ node.setAttribute('type', 'number');
+ node.setAttribute('required', 'required');
+ t.is(node.toString(), "input(type='number', required='required')");
+});
+
+test('stringifies all types of attributes', async t => {
+ const node = new PugNode('input');
+ node.setAttribute('id', 'foo');
+ node.setAttribute('class', 'bar');
+ node.setAttribute('type', 'text');
+ node.setAttribute('required', 'required');
+ node.setAttribute('data-key', 'r4nd0m-k3y');
+ t.is(
+ node.toString(),
+ "input#foo.bar(type='text' required='required' data-key='r4nd0m-k3y')",
+ );
+});
+
+test('stringifies an inline value', async t => {
+ const node = new PugNode('h1', 'Hello, world!');
+ node.setAttribute('class', 'title');
+ t.is(node.toString(), 'h1.title Hello, world!');
+});
+
+test('formats a multi-line value', async t => {
+ const node = new PugNode('textarea', 'Hello, world!\nThis is a new line');
+ t.is(node.toString(), 'textarea.\n Hello, world!\nThis is a new line');
+});
+
+test('sets the appropriate indent', async t => {
+ const node = new PugNode('h1', 'Hello, world!', 2);
+ t.truthy(node.toString().startsWith(' '.repeat(4)));
+});
+
+test('uses tabs as indent style', async t => {
+ const node = new PugNode('h1', 'Hello, world!', 1, true);
+ t.truthy(node.toString().startsWith('\t'));
+});
diff --git a/src/PugNode.ts b/src/PugNode.ts
new file mode 100644
index 0000000..dce06ae
--- /dev/null
+++ b/src/PugNode.ts
@@ -0,0 +1,137 @@
+import Attribute from './Attribute';
+import { Nodes } from './Parser';
+
+/**
+ * Represents a Pug node element.
+ */
+class PugNode {
+ // The pug node name
+ public name: string;
+ // The node value
+ public value: string;
+ // List of node attributes
+ public attributes: Attribute[] = [];
+ // Indent level
+ public indentLevel: number;
+ // Option to comma-separate attributes
+ public commas: boolean = false;
+ // Indent style
+ public tabs: boolean = false;
+
+ constructor(
+ name: string,
+ value: string = '',
+ indentLevel: number = 0,
+ tabs?: boolean,
+ commas?: boolean,
+ ) {
+ this.name = name;
+ this.value = value;
+ this.indentLevel = indentLevel;
+ if (tabs) { this.tabs = tabs; }
+ if (commas) { this.commas = commas; }
+ }
+
+ /**
+ * Adds a new attribute to the attributes list.
+ *
+ * @param name
+ * @param value
+ */
+ public setAttribute(name: string, value: string, doubleQuotes?: boolean) {
+ this.attributes.push(new Attribute(name, value, doubleQuotes));
+ }
+
+ /**
+ * Creates a string representation of the Pug node.
+ */
+ public toString(): string {
+ // Construct the string starting with the tag name.
+ let str: string = this.tagName;
+
+ // Add the element ID
+ const id = this.attributes.find((attr: Attribute) => attr.name === 'id');
+ if (id) {
+ str += id.toString();
+ }
+
+ // Add the class names
+ const className = this.attributes.find(
+ (attr: Attribute) => attr.name === 'class',
+ );
+ if (className) {
+ str += className.toString();
+ }
+
+ // Add the rest of the attributes
+ const attrs = this.attributes
+ .filter((attr: Attribute) => !['id', 'class'].includes(attr.name))
+ .map((attr: Attribute) => attr.toString());
+ if (attrs.length) {
+ str += `(${attrs.join(this.commas ? ', ' : ' ')})`;
+ }
+
+ // Append the node value inline or as multi-line block.
+ if (this.value) {
+ if (this.isMultiLine) {
+ // TODO change block character
+ // TODO add indent
+ const childIndent = this.getIndent(this.indentLevel + 1);
+ str += `.\n${childIndent}${this.value}`;
+ } else {
+ // The following leading space is not an indent, but the
+ // standard single space between the node name and its value.
+ // Text nodes don't have tag names, so no space needed there.
+ str += str.length ? ` ${this.value}` : this.value;
+ }
+ }
+
+ const rootIndent = this.getIndent();
+ return `${rootIndent}${str}`;
+ }
+
+ /**
+ * Returns the tag name using appropriate short forms.
+ */
+ private get tagName(): string {
+ switch (this.name) {
+ case Nodes.Text:
+ return '';
+ case Nodes.Doctype:
+ return 'doctype';
+ case Nodes.Comment:
+ return '//';
+ case Nodes.Div: {
+ const hasClassOrId = this.attributes.some((attr: Attribute) =>
+ ['class', 'id'].includes(attr.name),
+ );
+ if (hasClassOrId) {
+ return '';
+ }
+ }
+ default:
+ return this.name;
+ }
+ }
+
+ /**
+ * Returns the indent based on indent level and indent style.
+ *
+ * @param level
+ */
+ private getIndent(level: number = this.indentLevel): string {
+ const indentStyle = this.tabs ? '\t' : ' ';
+ return indentStyle.repeat(level);
+ }
+
+ // Denotes whether the value is multi-line or not
+ private get isMultiLine(): boolean {
+ if (!this.value) {
+ return false;
+ }
+ const lines = this.value.split('\n');
+ return lines.length > 1;
+ }
+}
+
+export default PugNode;
diff --git a/src/index.ts b/src/index.ts
index 57a6b6a..20de773 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,35 +1,30 @@
-const { minify } = require('html-minifier')
-const { parse, parseFragment } = require('parse5')
-const Pugify = require('./parser')
+import { minify } from 'html-minifier';
+import { DefaultTreeDocument, parse, parseFragment } from 'parse5';
+import { defaultOptions, Options } from './Options';
+import Pugify from './Parser';
-const defaultOptions = {
- // html2pug options
- fragment: false,
- tabs: false,
- commas: true,
- doubleQuotes: false,
+export default (sourceHtml: string, options?: Options) => {
+ const opts: Options = options
+ ? { ...defaultOptions, ...options }
+ : defaultOptions;
- // html-minifier options
- caseSensitive: true,
- removeEmptyAttributes: true,
- collapseWhitespace: true,
- collapseBooleanAttributes: true,
- preserveLineBreaks: true,
-}
-
-module.exports = (sourceHtml, options = {}) => {
// Minify source HTML
- const opts = { ...defaultOptions, ...options }
- const html = minify(sourceHtml, opts)
-
- const { fragment, tabs, commas, doubleQuotes } = opts
+ const html: string = minify(sourceHtml, opts);
- // Parse HTML and convert to Pug
- const doc = fragment ? parseFragment(html) : parse(html)
- const pugify = new Pugify(doc, {
+ const {
+ fragment,
tabs,
commas,
doubleQuotes,
- })
- return pugify.parse()
-}
+ }: {
+ fragment: boolean;
+ tabs: boolean;
+ commas: boolean;
+ doubleQuotes: boolean;
+ } = opts;
+
+ // Parse HTML and convert to Pug
+ const doc = fragment ? parseFragment(html) : parse(html);
+ const pugify = new Pugify(doc as DefaultTreeDocument, opts);
+ return pugify.parse();
+};
diff --git a/src/parser.ts b/src/parser.ts
deleted file mode 100644
index b4642dd..0000000
--- a/src/parser.ts
+++ /dev/null
@@ -1,219 +0,0 @@
-const DOCUMENT_TYPE_NODE = '#documentType'
-const TEXT_NODE = '#text'
-const DIV_NODE = 'div'
-const COMMENT_NODE = '#comment'
-const COMMENT_NODE_PUG = '//'
-
-const hasSingleTextNodeChild = node => {
- return (
- node.childNodes &&
- node.childNodes.length === 1 &&
- node.childNodes[0].nodeName === TEXT_NODE
- )
-}
-
-class Parser {
- constructor(root, options = {}) {
- this.pug = ''
- this.root = root
-
- const { tabs, commas, doubleQuotes } = options
-
- // Tabs or spaces
- this.indentStyle = tabs ? '\t' : ' '
- // Comma separate attributes
- this.separatorStyle = commas ? ', ' : ' '
- // Single quotes or double
- this.quoteStyle = doubleQuotes ? '"' : "'"
- }
-
- getIndent(level = 0) {
- return this.indentStyle.repeat(level)
- }
-
- parse() {
- const walk = this.walk(this.root.childNodes, 0)
- let it
-
- do {
- it = walk.next()
- } while (!it.done)
-
- return this.pug.substring(1)
- }
-
- /**
- * DOM tree traversal
- * Depth-first search (pre-order)
- *
- * @param {DOM} tree - DOM tree or Node
- * @param {Number} level - Current tree level
- */
- *walk(tree, level) {
- if (!tree) {
- return
- }
-
- for (let i = 0; i < tree.length; i++) {
- const node = tree[i]
-
- const newline = this.parseNode(node, level)
- if (newline) {
- this.pug += `\n${newline}`
- }
-
- if (
- node.childNodes &&
- node.childNodes.length > 0 &&
- !hasSingleTextNodeChild(node)
- ) {
- yield* this.walk(node.childNodes, level + 1)
- }
- }
- }
-
- /*
- * Returns a Pug node name with all attributes set in parentheses.
- */
- getNodeWithAttributes(node) {
- const { tagName, attrs } = node
- const attributes = []
- let pugNode = tagName
-
- if (!attrs) {
- return pugNode
- }
-
- // Add CSS selectors to pug node and append any element attributes to it
- for (const attr of attrs) {
- const { name, value } = attr
-
- // Remove div tag if a selector is present (shorthand)
- // e.g. div#form() -> #form()
- const hasSelector = name === 'id' || name === 'class'
- if (tagName === DIV_NODE && hasSelector) {
- pugNode = pugNode.replace(DIV_NODE, '')
- }
-
- switch (name) {
- case 'id':
- pugNode += `#${value}`
- break
- case 'class':
- pugNode += `.${value.split(' ').join('.')}`
- break
- default: {
- // Add escaped single quotes (\') to attribute values
- const val = value.replace(/'/g, "\\'")
- const quote = this.quoteStyle
- attributes.push(val ? `${name}=${quote}${val}${quote}` : name)
- break
- }
- }
- }
-
- if (attributes.length) {
- pugNode += `(${attributes.join(this.separatorStyle)})`
- }
-
- return pugNode
- }
-
- /**
- * formatPugNode applies the correct indent for the current line,
- * and formats the value as either as a single or multiline string.
- *
- * @param {String} node - The pug node (e.g. header(class='foo'))
- * @param {String} value - The node's value
- * @param {Number} level - Current tree level to generate indent
- * @param {String} blockChar - The character used to denote a multiline value
- */
- formatPugNode(node, value = '', level, blockChar = '.') {
- const indent = this.getIndent(level)
- const result = `${indent}${node}`
-
- const lines = value.split('\n')
-
- // Create an inline node
- if (lines.length <= 1) {
- return value.length ? `${result} ${value}` : result
- }
-
- // Create a multiline node
- const indentChild = this.getIndent(level + 1)
- const multiline = lines.map(line => `${indentChild}${line}`).join('\n')
-
- return `${result}${blockChar}\n${multiline}`
- }
-
- /**
- * createDoctype formats a #documentType element
- */
- createDoctype(node, level) {
- const indent = this.getIndent(level)
- return `${indent}doctype html`
- }
-
- /**
- * createComment formats a #comment element.
- *
- * Block comments in Pug don't require the dot '.' character.
- */
- createComment(node, level) {
- return this.formatPugNode(COMMENT_NODE_PUG, node.data, level, '')
- }
-
- /**
- * createText formats a #text element.
- *
- * A #text element containing only line breaks (\n) indicates
- * unnecessary whitespace between elements that should be removed.
- *
- * Actual text in a single #text element has no significant
- * whitespace and should be treated as inline text.
- */
- createText(node, level) {
- const { value } = node
- const indent = this.getIndent(level)
-
- // Omit line breaks between HTML elements
- if (/^[\n]+$/.test(value)) {
- return false
- }
-
- return `${indent}| ${value}`
- }
-
- /**
- * createElement formats a generic HTML element.
- */
- createElement(node, level) {
- const pugNode = this.getNodeWithAttributes(node)
-
- const value = hasSingleTextNodeChild(node)
- ? node.childNodes[0].value
- : node.value
-
- return this.formatPugNode(pugNode, value, level)
- }
-
- parseNode(node, level) {
- const { nodeName } = node
-
- switch (nodeName) {
- case DOCUMENT_TYPE_NODE:
- return this.createDoctype(node, level)
-
- case COMMENT_NODE:
- return this.createComment(node, level)
-
- case TEXT_NODE:
- return this.createText(node, level)
-
- default:
- return this.createElement(node, level)
- }
- }
-}
-
-module.exports = Parser