├── .gitattributes ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── bench ├── _run.js ├── cnnlite.html ├── hstream.js ├── hyperstream.js └── npmjs.html ├── eslint.config.js ├── index.js ├── package.json ├── selector.js └── test ├── attrs.js ├── doctype.html ├── fun-times.js ├── html.js ├── index.js ├── selectors.js ├── self-closing.js └── streams.js /.gitattributes: -------------------------------------------------------------------------------- 1 | bench/*.html linguist-vendored 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | name: Run tests 8 | strategy: 9 | matrix: 10 | node-version: 11 | - '8.x' 12 | - '10.x' 13 | - '12.x' 14 | - '14.x' 15 | - '15.x' 16 | - '16.x' 17 | - '17.x' 18 | - '18.x' 19 | - '19.x' 20 | - '20.x' 21 | - '21.x' 22 | - '22.x' 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Checkout sources 26 | uses: actions/checkout@v4 27 | - name: Install Node.js ${{matrix.node-version}} 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: ${{matrix.node-version}} 31 | - name: Install dependencies 32 | run: npm install 33 | - name: Run tests 34 | run: npm test 35 | 36 | lint: 37 | name: Standard Style 38 | runs-on: ubuntu-latest 39 | steps: 40 | - name: Checkout sources 41 | uses: actions/checkout@v4 42 | - name: Install Node.js 43 | uses: actions/setup-node@v4 44 | with: 45 | node-version: 'lts/*' 46 | - name: Install dependencies 47 | run: npm install 48 | - name: Check style 49 | run: npm run lint 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # hstream change log 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | This project adheres to [Semantic Versioning](http://semver.org/). 6 | 7 | ## 3.1.1 8 | 9 | * Update `css-what`, resolving denial of service warning 10 | 11 | ## 3.1.0 12 | 13 | * Add support for partial class matching, e.g. `.foo` -> `
` 14 | * Fix complex attribute selectors, e.g. `[class^="hello"]` 15 | * Fix crash on attributes with newline characters 16 | 17 | ## 3.0.0 18 | 19 | * Update dependencies. This major version bump is out of caution in case the parsing for the underlying HTML or CSS selector libraries changed. 20 | 21 | ## 2.0.0 22 | 23 | * Update dependencies. hstream now requires Node.js 8 or up. 24 | 25 | ## 1.2.0 26 | 27 | * Add ability to append or prepend to attributes (https://github.com/stackhtml/hstream/commit/e9b71c39d5a08d27b2ee09ae3043abcecd57b3db) 28 | 29 | ```js 30 | hstream({ 31 | '#app': { 32 | class: { prepend: 'beep ', append: ' boop' } 33 | } 34 | }) 35 | ``` 36 | 37 | * Remove attributes by setting `attrName: null` (https://github.com/stackhtml/hstream/commit/32480ba33327b1f32b16a91dc6752b4ecb5b8cec) 38 | * Edit attributes by passing a function (https://github.com/stackhtml/hstream/commit/32480ba33327b1f32b16a91dc6752b4ecb5b8cec) 39 | 40 | ```js 41 | hstream({ 42 | '#app': { 43 | title: function (prev) { return prev.toUpperCase() } 44 | } 45 | }) 46 | ``` 47 | 48 | * Edit html contents by passing a function (https://github.com/stackhtml/hstream/commit/b562c5ff1a644893093dda1c99558dded71fb422) 49 | 50 | ```js 51 | hstream({ 52 | 'code': { 53 | _html: function (source) { 54 | return highlightHTML(source) 55 | } 56 | } 57 | }) 58 | ``` 59 | 60 | ## 1.1.0 61 | 62 | * Add `_replaceHtml` option that replaces the outer html of an element 63 | 64 | ## 1.0.1 65 | 66 | * Pass through DOCTYPE and HTML comments untouched. 67 | 68 | ## 1.0.0 69 | 70 | * Initial release. 71 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # [Apache License 2.0](https://spdx.org/licenses/Apache-2.0) 2 | 3 | Copyright 2018 Renée Kooi 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | > http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hstream 2 | 3 | streaming html templates 4 | 5 | like [hyperstream](https://github.com/substack/hyperstream), but faster. it does not support all hyperstream features. 6 | 7 | currently unsupported: 8 | 9 | - inserting text; only html is supported 10 | - prepending or appending to attributes 11 | 12 | [![npm][npm-image]][npm-url] 13 | [![actions][actions-image]][actions-url] 14 | [![standard][standard-image]][standard-url] 15 | 16 | [npm-image]: https://img.shields.io/npm/v/hstream.svg?style=flat-square 17 | [npm-url]: https://www.npmjs.com/package/hstream 18 | [actions-image]: https://github.com/stackhtml/hstream/workflows/CI/badge.svg 19 | [actions-url]: https://github.com/stackhtml/hstream/actions?query=workflow%3ACI 20 | [standard-image]: https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat-square 21 | [standard-url]: http://npm.im/standard 22 | 23 | ## Install 24 | 25 | ``` 26 | npm install hstream 27 | ``` 28 | 29 | ## Usage 30 | 31 | ```js 32 | var hstream = require('hstream') 33 | 34 | hstream({ 35 | 'div > .x[attr="value"]': fs.createReadStream('./xyz.html') 36 | }) 37 | ``` 38 | 39 | ## API 40 | 41 | ### `hstream(updates)` 42 | 43 | Create a through stream that applies `updates`. `updates` is an object with CSS 44 | selectors for keys. Values can be different types depending on what sort of 45 | update you want to do. 46 | 47 | Selectors support the most common CSS features, like matching tag names, 48 | classes, IDs, attributes. Pseudo selectors are not supported, but PRs are 49 | welcome. 50 | 51 | Pass a stream or string to replace the matching element's contents with some 52 | HTML. Pass an object to set attributes on the matching element or do some 53 | special operations. When passing an object, you can use keys prefixed with `_` 54 | for the following special operations: 55 | 56 | - `_html` - Replace the matching element's contents with some HTML 57 | - `_prependHtml` - Prepend some HTML to the matching element 58 | - `_appendHtml` - Append some HTML to the matching element 59 | - `_replaceHtml` - Replace the entire element with some HTML 60 | 61 | All properties accept streams and strings. 62 | 63 | `_html` and `_replaceHtml` can also be a function. Then they are called with 64 | the html contents of the element being replaced, and should return a stream or 65 | a string. 66 | 67 | When setting attributes, you can also use a function that receives the value of 68 | the attribute as the only parameter and that returns a stream or string with 69 | the new contents. 70 | 71 | ```js 72 | hstream({ 73 | '#a': someReadableStream(), // replace content with a stream 74 | '#b': 'a string value', // replace content with a string 75 | // prepend and append some html 76 | '#c': { _prependHtml: 'here comes the content: ', _appendHtml: ' …that\'s all folks!' }, 77 | // replace content with a stream and set an attribute `attr="value"` 78 | '#d': { _html: someReadableStream(), 'attr': 'value' }, 79 | // set an attribute `data-whatever` to a streamed value 80 | '#e': { 'data-whatever': someReadableStream() }, 81 | // replace an element with something that depends on the current value 82 | '#f': { _html: function (input) { return input.toUpperCase() } }, 83 | // replace an attribute with something that depends on its current value 84 | '#g': { class: function (current) { return cx(current, 'other-class') } } 85 | }) 86 | ``` 87 | 88 | ## Benchmarks 89 | 90 | Run `npm run bench`. 91 | 92 | hstream: 93 | 94 | ``` 95 | NANOBENCH version 2 96 | > /usr/bin/node bench/hstream.js 97 | 98 | # 10× single transform 99 | ok ~233 ms (0 s + 232898600 ns) 100 | 101 | # many transforms 102 | ok ~159 ms (0 s + 158674007 ns) 103 | 104 | # small file 105 | ok ~11 ms (0 s + 11377188 ns) 106 | 107 | all benchmarks completed 108 | ok ~403 ms (0 s + 402949795 ns) 109 | ``` 110 | 111 | hyperstream: 112 | 113 | ``` 114 | NANOBENCH version 2 115 | > /usr/bin/node bench/hyperstream.js 116 | 117 | # 10× single transform 118 | ok ~1.84 s (1 s + 841403862 ns) 119 | 120 | # many transforms 121 | ok ~1.69 s (1 s + 694201406 ns) 122 | 123 | # small file 124 | ok ~101 ms (0 s + 101124108 ns) 125 | 126 | all benchmarks completed 127 | ok ~3.64 s (3 s + 636729376 ns) 128 | ``` 129 | 130 | ## License 131 | 132 | [Apache-2.0](LICENSE.md) 133 | -------------------------------------------------------------------------------- /bench/_run.js: -------------------------------------------------------------------------------- 1 | var Writable = require('stream').Writable 2 | var bench = require('nanobench') 3 | var fs = require('fs') 4 | var path = require('path') 5 | 6 | var npmjs = path.join(__dirname, 'npmjs.html') 7 | var cnn = path.join(__dirname, 'cnnlite.html') 8 | 9 | module.exports = function (NAME, hyperstream) { 10 | bench('10× single transform', function (b) { 11 | var left = 10 12 | 13 | b.start() 14 | next() 15 | 16 | function next () { 17 | if (left-- === 0) return b.end() 18 | fs.createReadStream(npmjs) 19 | .pipe(hyperstream({ '#npm-expansion': 'benchmark' })) 20 | .pipe(sink(next)) 21 | } 22 | }) 23 | 24 | bench('many transforms', function (b) { 25 | var left = 10 26 | 27 | b.start() 28 | var stream = fs.createReadStream(npmjs) 29 | while (left-- > 0) { 30 | stream = stream.pipe(hyperstream({ '#npm-expansion': 'benchmark: ' + left + ' left' })) 31 | } 32 | stream.pipe(sink(b.end)) 33 | }) 34 | 35 | bench('small file', function (b) { 36 | var left = 10 37 | 38 | b.start() 39 | next() 40 | 41 | function next () { 42 | if (left-- === 0) return b.end() 43 | fs.createReadStream(cnn) 44 | .pipe(hyperstream({ '[data-react-helmet]': 'benchmark' })) 45 | .pipe(sink(next)) 46 | } 47 | }) 48 | } 49 | 50 | function sink (cb) { 51 | return new Writable({ 52 | write: function (chunk, enc, next) { next() }, 53 | final: function (done) { done(); cb() } 54 | }) 55 | } 56 | -------------------------------------------------------------------------------- /bench/cnnlite.html: -------------------------------------------------------------------------------- 1 | 2 | CNN - Breaking News, Latest News and Videos
CNN | 1/21/2018 | English | Listen

Main Stories

© 2017 Cable News Network. Turner Broadcasting System, Inc. All Rights Reserved.

Listen to CNN (low-bandwidth usage)

Go to the full CNN experience

-------------------------------------------------------------------------------- /bench/hstream.js: -------------------------------------------------------------------------------- 1 | require('./_run')('hstream', require('../')) 2 | -------------------------------------------------------------------------------- /bench/hyperstream.js: -------------------------------------------------------------------------------- 1 | require('./_run')('hyperstream', require('hyperstream')) 2 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = require('neostandard')().concat([ 4 | { 5 | rules: { 6 | 'no-var': 'off', 7 | 'object-shorthand': ['error', 'never'], 8 | }, 9 | }, 10 | ]) 11 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var HTMLParser = require('htmlparser2').Parser 2 | var through = require('through2') 3 | var parseSelector = require('./selector') 4 | 5 | module.exports = function hstream (updates) { 6 | if (typeof updates !== 'object') throw new TypeError('hstream: updates must be object') 7 | 8 | var parser = new HTMLParser({ 9 | onopentag: onopentag, 10 | onprocessinginstruction: onprocessinginstruction, 11 | oncomment: oncomment, 12 | ontext: ontext, 13 | onclosetag: onclosetag, 14 | onend: onparseend, 15 | onerror: onerror 16 | }, { lowerCaseTags: true, decodeEntities: true }) 17 | 18 | var matchers = buildMatchers(updates) 19 | 20 | // original html source, so we can slice from it 21 | var source = '' 22 | var savedIndex = 0 23 | 24 | // parsed element stack 25 | var stack = [] 26 | var selfClosingIndex = 0 27 | 28 | // output chunks that were not yet written 29 | var queued = [] 30 | // whether new output chunks should be queued 31 | var queueWaiting = false 32 | // true when we are replacing element contents 33 | // → we should ignore results from the parser 34 | var replacing = false 35 | 36 | var stream = through(onchunk, onend) 37 | return stream 38 | 39 | function onqueueready () { 40 | queueWaiting = false 41 | push() 42 | } 43 | function onsourceforward (chunk) { stream.push(chunk) } 44 | function push () { 45 | if (queued.length === 0) return 46 | var next = queued.shift() 47 | if (isStream(next)) { 48 | queueWaiting = true 49 | next.on('end', onqueueready) 50 | next.on('data', onsourceforward) 51 | next.on('error', onerror) 52 | next.resume() 53 | } else { 54 | stream.push(next) 55 | push() 56 | } 57 | } 58 | function queue (val) { 59 | // tack this on to another queued string if it's there to save some `.push()` calls 60 | if (typeof val === 'string' && queued.length > 0 && typeof queued[queued.length - 1] === 'string') { 61 | queued[queued.length - 1] += val 62 | } else { 63 | // pause streams; so we don't miss any data events once we are ready 64 | if (isStream(val)) { 65 | // `.pause()` is advisory in node streams, so pipe it through `through()` which 66 | // will always buffer 67 | val = val.pipe(through()) 68 | val.pause() 69 | } 70 | queued.push(val) 71 | // defer calling `push` until the end of this parse tick; 72 | // this way a lot more strings can end up concatenated into one 73 | if (!queueWaiting) { 74 | queueWaiting = true 75 | process.nextTick(onqueueready) 76 | } 77 | } 78 | } 79 | 80 | // Get the original source for the thing being parsed right now 81 | function slice () { 82 | source = source.slice(parser.startIndex - savedIndex) 83 | savedIndex = parser.startIndex 84 | return source.slice(0, parser.endIndex + 1 - savedIndex) 85 | } 86 | function sliceReplaced (start, end) { 87 | var result = source.slice(start - savedIndex, end - savedIndex) 88 | source = source.slice(end - savedIndex) 89 | savedIndex = end 90 | return result 91 | } 92 | 93 | // Check if the current element stack matches a selector 94 | // If it does, return the update object for the matching selector 95 | function matches () { 96 | return matchers.find(function (o) { 97 | return o.matches(stack) 98 | }) 99 | } 100 | 101 | function onchunk (chunk, enc, cb) { 102 | source += chunk.toString() 103 | parser.write(chunk.toString()) 104 | cb() 105 | } 106 | 107 | function onend () { 108 | parser.end() 109 | } 110 | 111 | function onprocessinginstruction (name, data) { 112 | if (replacing) return 113 | // HACK to force htmlparser2 to update its startIndex and endIndex 114 | // Hopefully this check is good enough to be future proof 115 | if (parser.endIndex === null) parser.updatePosition(2) 116 | queue(slice()) 117 | } 118 | 119 | function onopentag (name, attrs) { 120 | var el = { tagName: name, attrs: attrs } 121 | stack.push(el) 122 | selfClosingIndex = parser.startIndex 123 | if (replacing) return 124 | 125 | var match = matches() 126 | var tag = slice() 127 | if (match) { 128 | // store the update object so we can use it in the close tag handler 129 | el.update = match.update 130 | 131 | // replacing the entire element; don't push the open tag 132 | if (match.update._replaceHtml) { 133 | replacing = true 134 | el.replaceIndex = parser.startIndex 135 | el.replaceOuter = true 136 | return 137 | } 138 | 139 | if (hasAttrs(match.update)) { 140 | addAttrs(tag, attrs, match.update).forEach(queue) 141 | } else { 142 | queue(tag) 143 | } 144 | 145 | if (match.update._prependHtml) { 146 | queue(match.update._prependHtml) 147 | } 148 | 149 | if (match.update._html) { 150 | replacing = true 151 | el.replaceIndex = parser.endIndex + 1 152 | el.replaceContents = true 153 | } 154 | } else { 155 | queue(tag) 156 | } 157 | } 158 | 159 | function oncomment (text) { 160 | if (replacing) return 161 | // just pass comments through unchanged 162 | queue(slice()) 163 | } 164 | 165 | function ontext (text) { 166 | if (replacing) return 167 | // just pass text through unchanged 168 | queue(slice()) 169 | } 170 | 171 | function onclosetag (name) { 172 | var el = stack.pop() 173 | // replaced the entire element; don't push the closing tag 174 | if (el.replaceOuter) { 175 | replacing = false 176 | var replaceHtml = el.update._replaceHtml 177 | if (typeof replaceHtml === 'function') { 178 | replaceHtml = replaceHtml(sliceReplaced(el.replaceIndex, parser.endIndex + 1)) 179 | } 180 | queue(replaceHtml) 181 | return 182 | } 183 | if (el.replaceContents) { 184 | replacing = false // stop replacing 185 | var html = el.update._html 186 | if (typeof html === 'function') { 187 | html = html(sliceReplaced(el.replaceIndex, parser.startIndex)) 188 | } 189 | queue(html) 190 | } 191 | 192 | if (selfClosingIndex === parser.startIndex) return 193 | if (replacing) return 194 | 195 | if (el.update) { 196 | if (el.update._appendHtml) { 197 | queue(el.update._appendHtml) 198 | } 199 | } 200 | queue(slice()) 201 | } 202 | 203 | function onparseend () { 204 | // close the output stream 205 | queue(null) 206 | } 207 | 208 | function onerror (error) { 209 | stream.emit('error', error) 210 | } 211 | } 212 | 213 | function buildMatchers (updates) { 214 | var selectors = Object.keys(updates) 215 | var matchers = [] 216 | for (var i = 0; i < selectors.length; i++) { 217 | var update = updates[selectors[i]] 218 | if (isStream(update) || typeof update !== 'object') update = { _html: update } 219 | matchers.push({ 220 | matches: parseSelector(selectors[i]), 221 | update: update 222 | }) 223 | } 224 | return matchers 225 | } 226 | 227 | // check if an update object has any attributes 228 | // (properties starting with _ are not attributes) 229 | function hasAttrs (update) { 230 | var k = Object.keys(update) 231 | for (var i = 0; i < k.length; i++) { 232 | if (k[i][0] !== '_') return true 233 | } 234 | return false 235 | } 236 | // insert attributes into an html open tag string 237 | // 238 | // addAttrs('
', { a: 'b' }, { c: 'd' }) 239 | // →
240 | function addAttrs (str, existing, update) { 241 | var attrs = [] 242 | 243 | // split the tag into two parts: `` (or `/>` for self closing) 244 | var tagParts = str.match(/^(<\S+)(?:[\s\S]*?)(\/?>)$/) 245 | attrs.push(tagParts[1]) 246 | 247 | var newAttrs = Object.assign({}, existing, update) 248 | var k = Object.keys(newAttrs) 249 | for (var i = 0; i < k.length; i++) { 250 | if (k[i][0] === '_') continue 251 | var attr = k[i] 252 | 253 | var value = newAttrs[attr] 254 | if (typeof value === 'function') value = value(existing[attr] || '') 255 | 256 | if (value == null) continue 257 | 258 | attrs.push(' ' + attr + '="') 259 | if (typeof value === 'object' && !isStream(value)) { 260 | if (value.prepend) attrs.push(value.prepend) 261 | attrs.push(existing[attr]) 262 | if (value.append) attrs.push(value.append) 263 | } else { 264 | attrs.push(value) 265 | } 266 | attrs.push('"') 267 | } 268 | attrs.push(tagParts[2]) 269 | return attrs 270 | } 271 | 272 | function isStream (o) { return Boolean(typeof o === 'object' && o && o.pipe) } 273 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hstream", 3 | "description": "streaming html templates", 4 | "version": "3.1.1", 5 | "author": "Renée Kooi ", 6 | "bugs": { 7 | "url": "https://github.com/stackhtml/hstream/issues" 8 | }, 9 | "dependencies": { 10 | "css-what": "^5.0.1", 11 | "htmlparser2": "^6.0.0", 12 | "through2": "^4.0.2" 13 | }, 14 | "devDependencies": { 15 | "dedent": "^1.5.3", 16 | "eslint": "^9.12.0", 17 | "hyperstream": "^1.2.2", 18 | "nanobench": "^3.0.0", 19 | "neostandard": "^0.11.6", 20 | "rimraf": "^3.0.0", 21 | "simple-concat": "^1.0.0", 22 | "tape": "^5.0.0" 23 | }, 24 | "engines": { 25 | "node": ">= 8" 26 | }, 27 | "homepage": "https://github.com/stackhtml/hstream", 28 | "keywords": [ 29 | "html", 30 | "stream", 31 | "template" 32 | ], 33 | "license": "Apache-2.0", 34 | "main": "index.js", 35 | "repository": { 36 | "type": "git", 37 | "url": "https://github.com/stackhtml/hstream.git" 38 | }, 39 | "scripts": { 40 | "bench": "node bench/hstream.js", 41 | "compare": "node bench/hyperstream.js > bench/hyperstream.txt && node bench/hstream.js > bench/hstream.txt && nanobench-compare bench/*.txt; rimraf bench/*.txt", 42 | "lint": "eslint .", 43 | "test": "node test/index.js" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /selector.js: -------------------------------------------------------------------------------- 1 | var csswhat = require('css-what').parse 2 | 3 | module.exports = createMatcher 4 | 5 | function createMatcher (sel) { 6 | var selectors = csswhat(sel) 7 | return function (stack) { 8 | return selectors.some(function (parts) { 9 | return match(stack, parts) 10 | }) 11 | } 12 | } 13 | 14 | // check if a parsed selector `parts` matches the element ancestor list `stack`. 15 | // `stack` elements are objects with at least a `.tagName`, and an object `.attrs` 16 | function match (stack, parts) { 17 | var si = stack.length - 1 18 | for (var i = parts.length - 1; i >= 0; i--) { 19 | if (!test()) return false 20 | } 21 | return true 22 | 23 | function test () { 24 | var part = parts[i] 25 | var el = stack[si] 26 | if (part.type === 'universal') { 27 | return true 28 | } if (part.type === 'attribute') { 29 | return checkAttr(el, part) 30 | } else if (part.type === 'tag') { 31 | return checkTag(el, part) 32 | } else if (part.type === 'child') { 33 | si-- 34 | return true 35 | } else if (part.type === 'descendant') { 36 | // Move to the parent selector, 37 | i-- 38 | // and keep walking up the DOM tree to check if it matches 39 | si-- 40 | while (si >= 0) { 41 | if (test()) return true 42 | si-- 43 | } 44 | return false 45 | } else { 46 | throw new Error('unknown selector: ' + JSON.stringify(part)) 47 | } 48 | } 49 | } 50 | 51 | function checkAttr (el, part) { 52 | if (part.action === 'exists') { 53 | return part.name in el.attrs 54 | } 55 | 56 | var attr = el.attrs[part.name] 57 | if (!attr) { 58 | return false 59 | } else if (part.action === 'start') { 60 | attr = attr.slice(0, part.value.length) 61 | } else if (part.action === 'end') { 62 | attr = attr.slice(-part.value.length) 63 | } 64 | 65 | var value = part.value 66 | if (part.ignoreCase) { 67 | attr = attr.toLowerCase() 68 | value = value.toLowerCase() 69 | } 70 | 71 | return part.name === 'class' ? attr.split(' ').includes(value) : attr === value 72 | } 73 | 74 | function checkTag (el, part) { 75 | return el.tagName === part.name 76 | } 77 | -------------------------------------------------------------------------------- /test/attrs.js: -------------------------------------------------------------------------------- 1 | var test = require('tape') 2 | var concat = require('simple-concat') 3 | var through = require('through2') 4 | var hyperstream = require('../') 5 | 6 | test('add an attribute', function (t) { 7 | var hs = hyperstream({ 8 | '#a': { class: 'it worked' } 9 | }) 10 | concat(hs, function (err, result) { 11 | t.ifError(err) 12 | t.equal(result + '', '
') 13 | t.end() 14 | }) 15 | hs.end('
') 16 | }) 17 | 18 | test('replace an attribute', function (t) { 19 | var hs = hyperstream({ 20 | '#a': { class: 'it worked' } 21 | }) 22 | concat(hs, function (err, result) { 23 | t.ifError(err) 24 | t.equal(result + '', '
') 25 | t.end() 26 | }) 27 | hs.end('
') 28 | }) 29 | 30 | test('remove attribute', function (t) { 31 | var hs = hyperstream({ 32 | '#a': { class: null } 33 | }) 34 | concat(hs, function (err, result) { 35 | t.ifError(err) 36 | t.equal(result + '', '
') 37 | t.end() 38 | }) 39 | hs.end('
') 40 | }) 41 | 42 | test.skip('prepend to attribute', function (t) { 43 | var hs = hyperstream({ 44 | '#a': { class: { prepend: 'it ' } } 45 | }) 46 | concat(hs, function (err, result) { 47 | t.ifError(err) 48 | t.equal(result + '', '
') 49 | t.end() 50 | }) 51 | hs.end('
') 52 | }) 53 | 54 | test.skip('append to attribute', function (t) { 55 | var hs = hyperstream({ 56 | '#a': { class: { append: ' worked' } } 57 | }) 58 | concat(hs, function (err, result) { 59 | t.ifError(err) 60 | t.equal(result + '', '
') 61 | t.end() 62 | }) 63 | hs.end('
') 64 | }) 65 | 66 | test('edit attribute', function (t) { 67 | var hs = hyperstream({ 68 | '#a': { 69 | class: function (original) { 70 | return original.toUpperCase() 71 | } 72 | } 73 | }) 74 | concat(hs, function (err, result) { 75 | t.ifError(err) 76 | t.equal(result + '', '
') 77 | t.end() 78 | }) 79 | hs.end('
') 80 | }) 81 | 82 | test('edit attribute with streams', function (t) { 83 | var hs = hyperstream({ 84 | '#a': { 85 | class: function (initial) { 86 | var stream = through(function (chunk, enc, cb) { 87 | cb() 88 | }, function (cb) { 89 | cb(null, 'beep boop') 90 | }) 91 | stream.end(initial) 92 | return stream 93 | } 94 | } 95 | }) 96 | concat(hs, function (err, result) { 97 | t.ifError(err) 98 | t.equal(result + '', '
') 99 | t.end() 100 | }) 101 | hs.end('
') 102 | }) 103 | -------------------------------------------------------------------------------- /test/doctype.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | üWave 7 | 8 | 9 | 10 |
11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /test/fun-times.js: -------------------------------------------------------------------------------- 1 | var test = require('tape') 2 | var fs = require('fs') 3 | var path = require('path') 4 | var hstream = require('../') 5 | var dedent = require('dedent') 6 | var concat = require('simple-concat') 7 | 8 | test('doctype and comments', function (t) { 9 | var input = fs.readFileSync(path.join(__dirname, './doctype.html')) 10 | var transform = hstream({}) 11 | concat(transform, function (err, contents) { 12 | t.ifError(err) 13 | t.equal(contents.toString(), input.toString()) 14 | t.end() 15 | }) 16 | 17 | transform.end(input) 18 | }) 19 | 20 | test('doctype', function (t) { 21 | var transform = hstream({ title: 'abc' }) 22 | concat(transform, function (err, contents) { 23 | t.ifError(err) 24 | t.equal(contents.toString(), dedent` 25 | 26 | 27 | 28 | abc 29 | 30 | 31 | `) 32 | t.end() 33 | }) 34 | transform.end(dedent` 35 | 36 | 37 | 38 | def 39 | 40 | 41 | `) 42 | }) 43 | -------------------------------------------------------------------------------- /test/html.js: -------------------------------------------------------------------------------- 1 | var test = require('tape') 2 | var concat = require('simple-concat') 3 | var hyperstream = require('../') 4 | 5 | test('replace contents with a string', function (t) { 6 | var hs = hyperstream({ 7 | '#a': { _html: 'it worked' } 8 | }) 9 | concat(hs, function (err, result) { 10 | t.ifError(err) 11 | t.equal(result + '', '
it worked
') 12 | t.end() 13 | }) 14 | hs.end('
it did not work
') 15 | }) 16 | 17 | test('replace contents with a bare string', function (t) { 18 | var hs = hyperstream({ 19 | '#a': 'it worked' 20 | }) 21 | concat(hs, function (err, result) { 22 | t.ifError(err) 23 | t.equal(result + '', '
it worked
') 24 | t.end() 25 | }) 26 | hs.end('
it did not work
') 27 | }) 28 | 29 | test('replace contents with a stream', function (t) { 30 | var hs = hyperstream({ 31 | '#a': { _html: 'it worked' } 32 | }) 33 | concat(hs, function (err, result) { 34 | t.ifError(err) 35 | t.equal(result + '', '
it worked
') 36 | t.end() 37 | }) 38 | hs.end('
it did not work
') 39 | }) 40 | 41 | test('append html', function (t) { 42 | var hs = hyperstream({ 43 | '#a': { _appendHtml: ' worked' } 44 | }) 45 | concat(hs, function (err, result) { 46 | t.ifError(err) 47 | t.equal(result + '', '
it worked
') 48 | t.end() 49 | }) 50 | hs.end('
it
') 51 | }) 52 | 53 | test('prepend html', function (t) { 54 | var hs = hyperstream({ 55 | '#a': { _prependHtml: 'it ' } 56 | }) 57 | concat(hs, function (err, result) { 58 | t.ifError(err) 59 | t.equal(result + '', '
it worked
') 60 | t.end() 61 | }) 62 | hs.end('
worked
') 63 | }) 64 | 65 | test('prepend and append html', function (t) { 66 | var hs = hyperstream({ 67 | '#a': { _prependHtml: 'abc', _appendHtml: 'ghi' } 68 | }) 69 | concat(hs, function (err, result) { 70 | t.ifError(err) 71 | t.equal(result + '', '
abcdefghi
') 72 | t.end() 73 | }) 74 | hs.end('
def
') 75 | }) 76 | 77 | test('replace element', function (t) { 78 | var hs = hyperstream({ 79 | '#slot': { _replaceHtml: '
new element
' } 80 | }) 81 | concat(hs, function (err, result) { 82 | t.ifError(err) 83 | t.equal(result + '', '
new element
') 84 | t.end() 85 | }) 86 | hs.end('
template slot
') 87 | }) 88 | 89 | test('edit html', function (t) { 90 | var hs = hyperstream({ 91 | '#test': function (contents) { 92 | return contents.toUpperCase() 93 | } 94 | }) 95 | concat(hs, function (err, result) { 96 | t.ifError(err) 97 | t.equal(result + '', '
THIS IS UPPERCASE
') 98 | t.end() 99 | }) 100 | hs.end('
this is uppercase
') 101 | }) 102 | 103 | test('edit outer html', function (t) { 104 | var hs = hyperstream({ 105 | '#slot': { 106 | _replaceHtml: function (input) { 107 | return '
' + input.replace(/' 108 | } 109 | } 110 | }) 111 | concat(hs, function (err, result) { 112 | t.ifError(err) 113 | t.equal(result + '', '
<x id="slot">template slot</x>
') 114 | t.end() 115 | }) 116 | hs.end('
template slot
') 117 | }) 118 | 119 | test('append and set attribute on child', function (t) { 120 | var hs = hyperstream({ 121 | '.row': { _appendHtml: 'wow' }, 122 | '.row i': { name: 'foo' } 123 | }) 124 | concat(hs, function (err, body) { 125 | t.ifError(err) 126 | t.equal(body + '', '
so wow
') 127 | t.end() 128 | }) 129 | hs.end('
so
') 130 | }) 131 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | require('./html') 2 | require('./streams') 3 | require('./self-closing') 4 | require('./fun-times') 5 | require('./selectors') 6 | require('./attrs') 7 | -------------------------------------------------------------------------------- /test/selectors.js: -------------------------------------------------------------------------------- 1 | var test = require('tape') 2 | var concat = require('simple-concat') 3 | var hyperstream = require('../') 4 | 5 | test('complex attribute selector', function (t) { 6 | var hs = hyperstream({ 7 | 'div[class^="it"][class$="work"]': { class: 'it worked' } 8 | }) 9 | concat(hs, function (err, result) { 10 | t.ifError(err) 11 | t.equal(result + '', '
') 12 | t.end() 13 | }) 14 | hs.end('
') 15 | }) 16 | 17 | test('attributes w/ newlines', function (t) { 18 | var hs = hyperstream({ 19 | '#a': { class: 'it worked' } 20 | }) 21 | concat(hs, function (err, result) { 22 | t.ifError(err) 23 | t.equal(result + '', '
') 24 | t.end() 25 | }) 26 | hs.end(`
`) 29 | }) 30 | 31 | test('match multiple classes', function (t) { 32 | var hs = hyperstream({ 33 | '.first.second': { class: 'first second third' } 34 | }) 35 | concat(hs, function (err, result) { 36 | t.ifError(err) 37 | t.equal(result + '', '
') 38 | t.end() 39 | }) 40 | hs.end('
') 41 | }) 42 | 43 | test('match single class against several', function (t) { 44 | var hs = hyperstream({ 45 | '.second': { class: 'first second third' } 46 | }) 47 | concat(hs, function (err, result) { 48 | t.ifError(err) 49 | t.equal(result + '', '
') 50 | t.end() 51 | }) 52 | hs.end('
') 53 | }) 54 | 55 | test('match mixed attributes', function (t) { 56 | var hs = hyperstream({ 57 | 'div#a.b[c="d"]': { _prependHtml: 'matched' } 58 | }) 59 | concat(hs, function (err, result) { 60 | t.ifError(err) 61 | t.equal(result + '', '
matched
') 62 | t.end() 63 | }) 64 | hs.end('
') 65 | }) 66 | -------------------------------------------------------------------------------- /test/self-closing.js: -------------------------------------------------------------------------------- 1 | var test = require('tape') 2 | var concat = require('simple-concat') 3 | var hyperstream = require('../') 4 | 5 | test('self closing tags', function (t) { 6 | var pass = hyperstream({}) 7 | var source = '
nothing


' 8 | concat(pass, function (err, result) { 9 | t.ifError(err) 10 | t.equal(result.toString(), source) 11 | t.end() 12 | }) 13 | 14 | pass.end(source) 15 | }) 16 | -------------------------------------------------------------------------------- /test/streams.js: -------------------------------------------------------------------------------- 1 | var test = require('tape') 2 | var through = require('through2') 3 | var concat = require('simple-concat') 4 | var hyperstream = require('../') 5 | 6 | test('multiple streams in order', function (t) { 7 | var hs = hyperstream({ 8 | '#a': makeStream(), 9 | '#b': makeStream(), 10 | '#c': 'whatever', 11 | '#d': makeStream() 12 | }) 13 | concat(hs, function (err, result) { 14 | t.ifError(err) 15 | t.equal(result.toString(), ` 16 | 17 | abcdefghijklmnopqrstuvwxyz 18 |

whatever

19 | abcdefghijklmnopqrstuvwxyz 20 |
abcdefghijklmnopqrstuvwxyz
21 | 22 | `.replace(/\s{2,}/g, '')) 23 | t.end() 24 | }) 25 | hs.end('

') 26 | }) 27 | 28 | function makeStream () { 29 | var s = through() 30 | var list = 'abcdefghijklmnopqrstuvwxyz'.split('') 31 | next() 32 | return s 33 | function next () { 34 | var i = list.shift() 35 | if (i) { 36 | s.push(i) 37 | setTimeout(next, 20) 38 | } else s.end() 39 | } 40 | } 41 | --------------------------------------------------------------------------------