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( + ``, + { 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