diff --git a/package.json b/package.json index e557083..3ab9406 100644 --- a/package.json +++ b/package.json @@ -25,9 +25,13 @@ "dependencies": { "chokidar": "^2.1.5", "connect": "^3.6.6", + "css-select": "^2.1.0", + "domutils": "^2.0.0", "dotenv": "^7.0.0", + "htmlparser2": "^4.1.0", "marked": "^0.6.2", - "serve-static": "^1.13.2" + "serve-static": "^1.13.2", + "uid": "^1.0.0" }, "devDependencies": { "jest": "^24.7.1" diff --git a/src/index.js b/src/index.js index 0fa33ea..6d3ed1b 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,15 @@ #!/usr/bin/env node const fs = require('fs'); const { performance } = require('perf_hooks'); +const domutils = require('domutils'); const marked = require('marked'); +const { + html: { + nodes: { queryNodesByHTML }, + changeTag, + prepareHTML, + }, +} = require('./lib'); require('dotenv').config(); /** @@ -52,14 +60,10 @@ const excludedFolders = [ const patterns = { whitespace: /^\s+|\s+$/g, - templates: /(.*?)<\/sergey-template>/gms, - complexNamedSlots: /(.*?)<\/sergey-slot>/gms, - simpleNamedSlots: //gm, - complexDefaultSlots: /(.*?)<\/sergey-slot>/gms, - simpleDefaultSlots: //gm, - complexImports: /(.*?)<\/sergey-import>/gms, - simpleImports: //gm, - links: /(.*?)<\/sergey-link>/gms + template: 'sergey-template', + slot: 'sergey-slot', + import: 'sergey-import', + link: 'sergey-link', }; /** @@ -188,8 +192,6 @@ const getKey = (key, ext = '.html', folder = '') => { const file = key.endsWith(ext) ? key : `${key}${ext}`; return `${folder}${file}`; }; -const hasImports = x => x.includes(' x.includes(' { if (!excludedFolders.includes(name)) { excludedFolders.push(name); @@ -210,22 +212,25 @@ const prepareImports = async folder => { }; const primeImport = (path, body) => { - cachedImports[path] = body; + cachedImports[path] = path.endsWith('.html') ? prepareHTML(body) : body; }; -const getSlots = content => { +const getSlots = (content) => { // Extract templates first const slots = { - default: formatContent(content) || '' + default: formatContent(content) || '', }; // Search content for templates - while ((m = patterns.templates.exec(content)) !== null) { - if (m.index === patterns.templates.lastIndex) { - patterns.templates.lastIndex++; - } + const { nodes } = queryNodesByHTML({ + html: content, + selector: patterns.template, + }); + nodes.forEach((node) => { + const find = domutils.getOuterHTML(node); + const name = domutils.getAttributeValue(node, 'name'); + const data = domutils.getInnerHTML(node); - const [find, name, data] = m; if (name !== 'default') { // Remove it from the default content slots.default = slots.default.replace(find, ''); @@ -233,68 +238,33 @@ const getSlots = content => { // Add it as a named slot slots[name] = formatContent(data); - } + }); slots.default = formatContent(slots.default); return slots; }; -const compileSlots = (body, slots) => { - let m; - let copy; - - // Complex named slots - copy = body; - while ((m = patterns.complexNamedSlots.exec(body)) !== null) { - if (m.index === patterns.complexNamedSlots.lastIndex) { - patterns.complexNamedSlots.lastIndex++; - } - - const [find, name, fallback] = m; - copy = copy.replace(find, slots[name] || fallback || ''); - } - body = copy; - - // Simple named slots - while ((m = patterns.simpleNamedSlots.exec(body)) !== null) { - if (m.index === patterns.simpleNamedSlots.lastIndex) { - patterns.simpleNamedSlots.lastIndex++; - } - - const [find, name] = m; - copy = copy.replace(find, slots[name] || ''); - } - body = copy; - - // Complex Default slots - while ((m = patterns.complexDefaultSlots.exec(body)) !== null) { - if (m.index === patterns.complexDefaultSlots.lastIndex) { - patterns.complexDefaultSlots.lastIndex++; - } +const compileSlots = (body_, slots) => { + let body = body_; - const [find, fallback] = m; - copy = copy.replace(find, slots.default || fallback || ''); - } - body = copy; - - // Simple default slots - body = body.replace(patterns.simpleDefaultSlots, slots.default); + body = changeTag.main({ html: body, selector: patterns.slot }, (node) => { + const name = domutils.getAttributeValue(node, 'name') || 'default'; + const fallback = domutils.getInnerHTML(node); + return slots[name] || fallback || ''; + }); return body; }; -const compileImport = (body, pattern) => { - let m; - // Simple imports - while ((m = pattern.exec(body)) !== null) { - if (m.index === pattern.lastIndex) { - pattern.lastIndex++; - } +const compileImport = (body_) => { + let body = body_; + body = changeTag.main({ html: body, selector: patterns.import }, (node) => { + let key = domutils.getAttributeValue(node, 'src'); + let htmlAs = domutils.getAttributeValue(node, 'as') || ''; + let content = domutils.getInnerHTML(node) || ''; - let [find, key, htmlAs = '', content = ''] = m; let replace = ''; - if (htmlAs === 'markdown') { replace = formatContent( marked(cachedImports[getKey(key, '.md', CONTENT)] || '') @@ -307,63 +277,43 @@ const compileImport = (body, pattern) => { // Recurse replace = compileTemplate(replace, slots); - body = body.replace(find, replace); - } + return replace; + }); return body; }; -const compileTemplate = (body, slots = { default: '' }) => { +const compileTemplate = (body_, slots = { default: '' }) => { + let body = prepareHTML(body_); body = compileSlots(body, slots); - - if (!hasImports(body)) { - return body; - } - - body = compileImport(body, patterns.simpleImports); - body = compileImport(body, patterns.complexImports); - + body = compileImport(body); return body; }; -const compileLinks = (body, path) => { - let m; - let copy; +const compileLinks = (body_, path) => { + let body = body_; + body = changeTag.main({ html: body, selector: patterns.link }, (node) => { + const arrTo = ['to', 'href'].filter((i) => domutils.hasAttrib(node, i))[0]; - if (!hasLinks(body)) { - return body; - } - - copy = body; - while ((m = patterns.links.exec(body)) !== null) { - if (m.index === patterns.links.lastIndex) { - patterns.links.lastIndex++; - } - - let [find, attr1 = '', to, attr2 = '', content] = m; - let replace = ''; - let attributes = [`href="${to}"`, attr1, attr2] - .map(x => x.trim()) - .filter(Boolean) - .join(' '); + const to = arrTo ? domutils.getAttributeValue(node, arrTo) : ''; + arrTo && delete node.attribs[arrTo]; const isCurrent = isCurrentPage(to, path); if (isCurrent || isParentPage(to, path)) { - if (attributes.includes('class="')) { - attributes = attributes.replace('class="', `class="${ACTIVE_CLASS} `); - } else { - attributes += ` class="${ACTIVE_CLASS}"`; - } + const currClass = domutils.getAttributeValue(node, 'class') || ''; + node.attribs['class'] = `${ACTIVE_CLASS} ${currClass.trimLeft()}`.trim(); if (isCurrent) { - attributes += ' aria-current="page"'; + node.attribs['aria-current'] = 'page'; } } - replace = `${content}`; - copy = copy.replace(find, replace); - } - body = copy; + return domutils + .getOuterHTML(node) + .replace(/^', '') + .replace(/^) + * Extends `changeItemsByHTML`. + * Can match tags inside tags + * @author Gabriel Rodrigues + */ +module.exports = (options) => { + let { html, selector } = options; + + if (html.includes(``)) { + const regexpRangeTag = selector.replace('-', '\\-'); + const remaingTags = + html.match( + new RegExp( + `<${selector}[^<]*>[^<${regexpRangeTag}]*<\\/${selector}>`, + 'g' + ) + ) || []; + + const foundAs = remaingTags.join(''); + html = html.replace( + foundAs, + changeItemsByHTML(Object.assign({}, options, { html: foundAs })) + ); + } + return html; +}; diff --git a/src/lib/html/change-tag/change-items-by-html-fallback.test.js b/src/lib/html/change-tag/change-items-by-html-fallback.test.js new file mode 100644 index 0000000..c3bc046 --- /dev/null +++ b/src/lib/html/change-tag/change-items-by-html-fallback.test.js @@ -0,0 +1,48 @@ +const prepareHTML = require('../prepare-html'); +const { changeItemsByHTML, changeItemsByHTMLFallback } = require('./index'); +const getOptions = (option, html_) => + Object.assign({}, option, { html: html_ }); + +const options = [ + { + selector: 'get-title', + changeItem: () => { + return '...'; + }, + }, + { + selector: 'get-text', + changeItem: () => { + return 'text'; + }, + }, +]; + +const input = prepareHTML( + '

' +); + +test('Shoud fail to match tag with changeItemsByHTML', () => { + const expected = prepareHTML('

text

'); + + let output = input; + output = changeItemsByHTML(getOptions(options[0], output)); + output = changeItemsByHTML(getOptions(options[1], output)); + + expect(output).toBe(expected); +}); + +test('Change HTML tags with changeItemsByHTMLFallback', () => { + const expected = prepareHTML('

text

'); + + let output = input; + + // get-title + output = changeItemsByHTML(getOptions(options[0], output)); + output = changeItemsByHTMLFallback(getOptions(options[0], output)); + // get-text + output = changeItemsByHTML(getOptions(options[1], output)); + output = changeItemsByHTMLFallback(getOptions(options[1], output)); + + expect(output).toBe(expected); +}); diff --git a/src/lib/html/change-tag/change-items-by-html.js b/src/lib/html/change-tag/change-items-by-html.js new file mode 100644 index 0000000..9802638 --- /dev/null +++ b/src/lib/html/change-tag/change-items-by-html.js @@ -0,0 +1,33 @@ +const domutils = require('domutils'); +const { queryNodesByHTML } = require('../nodes'); + +const defaultModes = { + innerHTML: domutils.getInnerHTML, + outerHTML: domutils.getOuterHTML, +}; + +/** + * Core function to change tags + * @author Gabriel Rodrigues + */ +module.exports = ({ + html, + selector, + changeItem, + mode = 'outerHTML', + modes = defaultModes, + base: base_, +}) => { + let base = html || base_ || ''; + + const { nodes: selectedNodes } = queryNodesByHTML({ html: base, selector }); + selectedNodes.forEach((i) => { + const oldContent = modes[mode](i); + const newContent = changeItem(i, oldContent); + + if(newContent !== false) { + base = base.replace(oldContent, newContent); + } + }); + return base; +}; diff --git a/src/lib/html/change-tag/change-items-by-html.test.js b/src/lib/html/change-tag/change-items-by-html.test.js new file mode 100644 index 0000000..dac1c73 --- /dev/null +++ b/src/lib/html/change-tag/change-items-by-html.test.js @@ -0,0 +1,28 @@ +const { getInnerHTML, getOuterHTML } = require('domutils'); +const { changeItemsByHTML } = require('./index'); + +test('Change HTML tags', () => { + const input = + '

KEEP INNER ONLY

attrb

'; + const expected = + '
KEEP INNER ONLY

attrb

'; + + let output = input; + output = changeItemsByHTML({ + html: output, + selector: '.inner-only', + changeItem: (node) => { + return getInnerHTML(node); + }, + }); + output = changeItemsByHTML({ + html: output, + selector: '.attrb', + changeItem: (node) => { + node.attribs['data-foo'] = 'baz'; + return getOuterHTML(node); + }, + }); + + expect(output).toBe(expected); +}); diff --git a/src/lib/html/change-tag/index.js b/src/lib/html/change-tag/index.js new file mode 100644 index 0000000..43d4a00 --- /dev/null +++ b/src/lib/html/change-tag/index.js @@ -0,0 +1,36 @@ +const uid = require('uid'); +const domutils = require('domutils'); +const changeItemsByHTML = require('./change-items-by-html'); +const changeItemsByHTMLFallback = require('./change-items-by-html-fallback'); + +const returnModes = { + outerHTML: domutils.getOuterHTML, + innerHTML: domutils.getInnerHTML, +}; + +/** + * @author Gabriel Rodrigues + */ +function main(options, fn) { + const { html: html_, selector, mode = 'outerHTML' } = options; + let html = html_; + + const changeItemNodeId = `sergey-node_id-${uid(6)}`; + const changeItem = (node, ...args) => { + if (typeof node.attribs[changeItemNodeId] === 'undefined') { + node.attribs[changeItemNodeId] = ''; + return fn(node, ...args); + } + return false; + }; + + html = changeItemsByHTML(Object.assign({}, options, { html, changeItem })); + html = changeItemsByHTMLFallback( + Object.assign({}, options, { html, changeItem }) + ); + + html = html.replace(new RegExp(` ?${changeItemNodeId}`, 'g'), ''); + return html; +} + +module.exports = { main, changeItemsByHTML, changeItemsByHTMLFallback }; diff --git a/src/lib/html/change-tag/index.test.js b/src/lib/html/change-tag/index.test.js new file mode 100644 index 0000000..eac07f9 --- /dev/null +++ b/src/lib/html/change-tag/index.test.js @@ -0,0 +1,20 @@ +const { getInnerHTML, getOuterHTML } = require('domutils'); +const changeTag = require('./index'); + +test('Change HTML tags', () => { + const input = + '

KEEP INNER ONLY

attrb

'; + const expected = + '
KEEP INNER ONLY

attrb

'; + + let output = input; + output = changeTag.main({ html: output, selector: '.inner-only' }, (node) => { + return getInnerHTML(node); + }); + output = changeTag.main({ html: output, selector: '.attrb' }, (node) => { + node.attribs['data-foo'] = 'baz'; + return getOuterHTML(node); + }); + + expect(output).toBe(expected); +}); diff --git a/src/lib/html/index.js b/src/lib/html/index.js new file mode 100644 index 0000000..8725b93 --- /dev/null +++ b/src/lib/html/index.js @@ -0,0 +1,9 @@ +const nodes = require('./nodes'); +const changeTag = require('./change-tag'); +const prepareHTML = require('./prepare-html'); + +module.exports = { + nodes, + changeTag, + prepareHTML, +}; diff --git a/src/lib/html/nodes/get-nodes.js b/src/lib/html/nodes/get-nodes.js new file mode 100644 index 0000000..d2dbff5 --- /dev/null +++ b/src/lib/html/nodes/get-nodes.js @@ -0,0 +1,5 @@ +const { parseDOM } = require('htmlparser2'); + +const getNodes = ({ html }) => parseDOM(html); + +module.exports = getNodes; diff --git a/src/lib/html/nodes/get-nodes.test.js b/src/lib/html/nodes/get-nodes.test.js new file mode 100644 index 0000000..dfded2e --- /dev/null +++ b/src/lib/html/nodes/get-nodes.test.js @@ -0,0 +1,18 @@ +const { getNodes } = require('./index'); +const { getOuterHTML } = require('domutils'); + +test('Return HTML (XML) nodes', () => { + const input = '
first

second

'; + const output = getNodes({ html: input }).length; + const expected = 2; + + expect(output).toBe(expected); +}); + +test('Return HTML (XML) nodes', () => { + const input = '
first

second

'; + const output = getOuterHTML(getNodes({ html: input })); + const expected = input; + + expect(output).toBe(expected); +}); diff --git a/src/lib/html/nodes/index.js b/src/lib/html/nodes/index.js new file mode 100644 index 0000000..9ef2ce6 --- /dev/null +++ b/src/lib/html/nodes/index.js @@ -0,0 +1,9 @@ +const getNodes = require('./get-nodes'); +const queryNodes = require('./query-nodes'); +const queryNodesByHTML = require('./query-nodes-by-html'); + +module.exports = { + getNodes, + queryNodes, + queryNodesByHTML, +}; diff --git a/src/lib/html/nodes/query-nodes-by-html.js b/src/lib/html/nodes/query-nodes-by-html.js new file mode 100644 index 0000000..12679d0 --- /dev/null +++ b/src/lib/html/nodes/query-nodes-by-html.js @@ -0,0 +1,14 @@ +const getNodes = require('./get-nodes'); +const queryNodes = require('./query-nodes'); + +const queryNodesByHTML = ({ html, selector }) => { + const rootNodes = getNodes({ html }); + const nodes = queryNodes({ nodes: rootNodes, selector }); + + return { + rootNodes, + nodes, + }; +}; + +module.exports = queryNodesByHTML; diff --git a/src/lib/html/nodes/query-nodes-by-html.test.js b/src/lib/html/nodes/query-nodes-by-html.test.js new file mode 100644 index 0000000..02d7385 --- /dev/null +++ b/src/lib/html/nodes/query-nodes-by-html.test.js @@ -0,0 +1,9 @@ +const queryNodesByHTML = require('./query-nodes-by-html'); + +test('Get nodes from select query by using HTML', () => { + const input = '
first
second
first
third
'; + const output = queryNodesByHTML({ html: input, selector: 'div' }).nodes.length; + const expected = 3; + + expect(output).toBe(expected); +}); diff --git a/src/lib/html/nodes/query-nodes.js b/src/lib/html/nodes/query-nodes.js new file mode 100644 index 0000000..f36c27e --- /dev/null +++ b/src/lib/html/nodes/query-nodes.js @@ -0,0 +1,5 @@ +const { selectAll } = require('css-select'); + +const queryNodes = ({ nodes, selector }) => selectAll(selector, nodes); + +module.exports = queryNodes; diff --git a/src/lib/html/nodes/query-nodes.test.js b/src/lib/html/nodes/query-nodes.test.js new file mode 100644 index 0000000..2907a2c --- /dev/null +++ b/src/lib/html/nodes/query-nodes.test.js @@ -0,0 +1,11 @@ +const queryNodes = require('./query-nodes'); +const getNodes = require('./get-nodes'); + +test('Get nodes from select query', () => { + const input = '
first
second
first
third
'; + const nodes = getNodes({ html: input }); + const output = queryNodes({ nodes, selector: 'div' }).length; + const expected = 3; + + expect(output).toBe(expected); +}); diff --git a/src/lib/html/prepare-html.js b/src/lib/html/prepare-html.js new file mode 100644 index 0000000..781e8e9 --- /dev/null +++ b/src/lib/html/prepare-html.js @@ -0,0 +1,30 @@ +const { getNodes } = require('./nodes'); +const { getOuterHTML } = require('domutils'); +const VOID_ELEMENTS = require('./voidelements.json'); + +// `` -> `` +// the function ignores void elements like `` +module.exports = (html_) => { + let html = html_ || ''; + if (!html.trim()) return html; + + (html.match(/<[^<|\/>]+\/>/g) || []) + .map((original) => { + const def = original.slice(1, -2).trim(); + const tagName = def.split(' ')[0].trim(); + + return { original, def, tagName }; + }) + .forEach(({ original, def, tagName }) => { + let newTagContent = def; + + newTagContent = VOID_ELEMENTS.includes(tagName) + ? `<${newTagContent.replace(/[\r|\n]/g, '')}>` + : `<${newTagContent}>`; + + html = html.replace(original, newTagContent); + }); + + html = getOuterHTML(getNodes({ html })); + return html; +}; diff --git a/src/lib/html/prepare-html.test.js b/src/lib/html/prepare-html.test.js new file mode 100644 index 0000000..6f859f1 --- /dev/null +++ b/src/lib/html/prepare-html.test.js @@ -0,0 +1,22 @@ +const { prepareHTML } = require('./index'); + +test('Prepare HTML for self-closing tags', () => { + const input = '
'; + const expected = '
'; + const output = prepareHTML(input); + + expect(output).toBe(expected); +}); + + +test('Prepare HTML for multiline tags', () => { + const input = `
...
`; + + const expected = '
...
'; + const output = prepareHTML(input); + + expect(output).toBe(expected); +}); + diff --git a/src/lib/html/voidelements.json b/src/lib/html/voidelements.json new file mode 100644 index 0000000..48427f8 --- /dev/null +++ b/src/lib/html/voidelements.json @@ -0,0 +1,16 @@ +[ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr" +] diff --git a/src/lib/index.js b/src/lib/index.js new file mode 100644 index 0000000..f1dcfdb --- /dev/null +++ b/src/lib/index.js @@ -0,0 +1,3 @@ +const html = require('./html'); + +module.exports = { html };