├── logos ├── logo-box-builtby.png └── logo-box-madefor.png ├── .gitignore ├── CHANGELOG.md ├── package.json ├── index.js ├── README.md └── test └── test.js /logos/logo-box-builtby.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apostrophecms/absolution/main/logos/logo-box-builtby.png -------------------------------------------------------------------------------- /logos/logo-box-madefor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apostrophecms/absolution/main/logos/logo-box-madefor.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | npm-debug.log 2 | *.DS_Store 3 | node_modules 4 | # We do not commit CSS, only LESS 5 | public/css/*.css 6 | package-lock.json 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.1.0 (2024-01-25) 2 | 3 | * Added a fix for srcset in img tag. Thanks to [Gauav Kumar](https://github.com/gkumar9891) for this contribution! 4 | 5 | ## 1.0.4 (2023-05-26) 6 | 7 | * Add test and documentation about adding custom self-closing tags. 8 | 9 | ## 1.0.3 (2023-02-13) 10 | 11 | * Although attributes are already escaped, we still have to output them 12 | using quotation marks that are compatible with the escaped values, e.g. 13 | if the escaped values contain unescaped double-quotes, then we must 14 | single-quote the attribute, and vice versa. Note that it is not the task 15 | of `absolution` to verify that the escapes are valid overall, only to 16 | do no harm when transforming the document's URLs. 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "absolution", 3 | "version": "1.1.0", 4 | "description": "absolution accepts HTML and a base URL, and returns HTML with absolute URLs. Great for generating valid RSS feeds.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "mocha" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/apostrophecms/absolution.git" 12 | }, 13 | "keywords": [ 14 | "url", 15 | "urls", 16 | "resolver", 17 | "url", 18 | "resolver", 19 | "html", 20 | "resolve", 21 | "urls", 22 | "in", 23 | "html", 24 | "rss", 25 | "feed" 26 | ], 27 | "author": "Apostrophe Technologies", 28 | "license": "MIT", 29 | "readmeFilename": "README.md", 30 | "dependencies": { 31 | "htmlparser2": "~3.3.0", 32 | "lodash": "~4.17.15" 33 | }, 34 | "devDependencies": { 35 | "mocha": "^7.2.0" 36 | } 37 | } -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var htmlparser = require('htmlparser2'); 2 | var _ = require('lodash'); 3 | var url = require('url'); 4 | 5 | var absolution = module.exports = function(input, base, options) { 6 | if (!options) { 7 | options = absolution.defaults; 8 | } else { 9 | _.defaults(options, absolution.defaults); 10 | } 11 | 12 | var selfClosingMap = {}; 13 | _.forEach(options.selfClosing, function(tag) { 14 | selfClosingMap[tag] = true; 15 | }); 16 | 17 | var result = ''; 18 | 19 | var parser = new htmlparser.Parser({ 20 | onopentag: function(name, attribs) { 21 | _.forEach(options.urlAttributes, function(attr) { 22 | if (_.has(attribs, attr) && attribs[attr].trim()) { 23 | if (attr === 'srcset') { 24 | let strings = _.split(attribs[attr], ","); 25 | 26 | _.forEach(strings, function(str, index) { 27 | str = str.trim(); 28 | strings[index] = _.replace(str, _.split(str, " ")[0], url.resolve(base, _.split(str, " ")[0])) 29 | }) 30 | 31 | strings = strings.join(", "); 32 | attribs[attr] = strings; 33 | 34 | } else { 35 | attribs[attr] = url.resolve(base, attribs[attr]); 36 | } 37 | 38 | if (options.decorator) { 39 | attribs[attr] = options.decorator(attribs[attr]); 40 | } 41 | } 42 | }); 43 | result += '<' + name; 44 | _.forEach(attribs, function(value, a) { 45 | result += ' ' + a; 46 | if (value.length) { 47 | // Values are ALREADY escaped, calling escapeHtml here 48 | // results in double escapes 49 | if (value.includes('"')) { 50 | // Since htmlparser2 only gives us back valid attributes, 51 | // we can assume any value with double quotes should be a 52 | // single-quoted attribute 53 | result += "='" + value + "'"; 54 | } else { 55 | result += '="' + value + '"'; 56 | } 57 | } 58 | }); 59 | if (_.has(selfClosingMap, name)) { 60 | result += " />"; 61 | } else { 62 | result += ">"; 63 | } 64 | }, 65 | ontext: function(text) { 66 | // It is NOT actually raw text, entities are already escaped. 67 | // If we call escapeHtml here we wind up double-escaping. 68 | result += text; 69 | }, 70 | onclosetag: function(name) { 71 | if (_.has(selfClosingMap, name)) { 72 | // Already output /> 73 | return; 74 | } 75 | result += ""; 76 | } 77 | }); 78 | parser.write(input); 79 | parser.end(); 80 | return result; 81 | }; 82 | 83 | absolution.defaults = { 84 | urlAttributes: [ 'href', 'src', 'action', 'srcset' ], 85 | selfClosing: [ 'img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta' ] 86 | }; 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # absolution 2 | 3 | 4 | 5 | `absolution` accepts HTML and a base URL, and returns HTML with absolute URLs. Great for generating valid RSS feeds. 6 | 7 | `absolution` is not too picky about your HTML. 8 | 9 | ## Requirements 10 | 11 | `absolution` is intended for use with Node. That's pretty much it. All of its npm dependencies are pure JavaScript. `absolution` is built on the excellent `htmlparser2` module. 12 | 13 | ## How to use 14 | 15 | `npm install absolution` 16 | 17 | ```javascript 18 | var absolution = require('absolution'); 19 | 20 | var dirty = 'Foo!'; 21 | var clean = absolution(dirty, 'http://example.com'); 22 | 23 | // clean is now: 24 | // Foo! 25 | ``` 26 | 27 | Boom! 28 | 29 | If you want to do further processing of each absolute URL, you can also pass a decorator function: 30 | 31 | ```javascript 32 | var clean = absolution(dirty, 'http://example.com', { 33 | decorator: function(url) { 34 | return 'http://mycoolthing.com?url=' + encodeURIComponent(url); 35 | } 36 | }); 37 | ``` 38 | 39 | ## Having issues with SVG markup? 40 | 41 | > How can I keep SVG self-closing tags intact? 42 | 43 | You can add custom self-closing tags via the `selfClosing` option: 44 | 45 | ```javascript 46 | var absolution = require('absolution'); 47 | 48 | var dirty = ` 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | `; 57 | var clean = absolution(dirty, 'http://example.com', { 58 | selfClosing: [ 59 | // keep default `selfClosing` tags: 60 | ...absolution.defaults.selfClosing, 61 | 62 | // add custom tags: 63 | 'path', 64 | 'circle' 65 | ] 66 | }); 67 | 68 | // clean is now: 69 | // 70 | // 71 | // 72 | // 73 | // 74 | // 75 | // 76 | ``` 77 | 78 | ## Changelog 79 | 80 | 1.0.2: Updates to lodash v4 and mocha v7 for security vulnerability fixes. Also update package metadata. 81 | 82 | 1.0.0: no new changes; declared stable as with the addition of the decorator option there's little left to do, and all tests are passing nicely. 83 | 84 | 0.2.0: decorator option added. 85 | 86 | 0.1.0: initial release. 87 | 88 | ## About P'unk Avenue and Apostrophe 89 | 90 | `absolution` was created at [P'unk Avenue](http://punkave.com) for use in Apostrophe, an open-source content management system built on node.js. If you like `absolution` you should definitely [check out apostrophenow.org](http://apostrophenow.org). Also be sure to visit us on [github](http://github.com/punkave). 91 | 92 | ## Support 93 | 94 | Feel free to open issues on [github](http://github.com/punkave/absolution). 95 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | var assert = require("assert"); 2 | var absolution = require('../index.js'); 3 | 4 | describe('absolution', function() { 5 | it('should pass through unrelated markup unaltered', function() { 6 | assert.equal(absolution('

Hello there

', 'http://example.com/child/'), '

Hello there

'); 7 | }); 8 | it('should respect text nodes at top level', function() { 9 | assert.equal(absolution('Blah blah blah

Whee!

', 'http://example.com/child/'), 'Blah blah blah

Whee!

'); 10 | }); 11 | it('should respect absolute URLs', function() { 12 | assert.equal(absolution('Test', 'http://example.com/child/'), 'Test'); 13 | }); 14 | it('should not bollux up empty tags', function() { 15 | assert.equal(absolution('


', 'http://example.com/child/'), '


'); 16 | }); 17 | it('should ignore unrelated URL schemes', function() { 18 | assert.equal(absolution('Test', 'http://example.com/child/'), 'Test'); 19 | }); 20 | it('should preserve entities as such', function() { 21 | assert.equal(absolution('<Kapow!>'), '<Kapow!>'); 22 | }); 23 | // Note that quotation marks are removed in the below test because all attributes are reconstructed 24 | // and there is no need for quotation marks without a value 25 | it('should keep empty url attributes empty', function() { 26 | assert.equal(absolution('', 'http://localhost:8000/index.html'), ''); 27 | }); 28 | // Finally the cool thing! 29 | it('should resolve relative URLs ("file" in same "folder")', function() { 30 | assert.equal(absolution('Test', 'http://example.com/child/'), 'Test'); 31 | }); 32 | it('should resolve relative URLs (../)', function() { 33 | assert.equal(absolution('Test', 'http://example.com/child/'), 'Test'); 34 | }); 35 | it('should not panic if ../ is overused', function() { 36 | assert.equal(absolution('Test', 'http://example.com/child/'), 'Test'); 37 | }); 38 | it('should support the decorator option', function() { 39 | assert.equal(absolution('Test', 'http://example.com/child/', { 40 | decorator: function(url) { 41 | return 'http://test.com?url=' + encodeURIComponent(url); 42 | } 43 | }), 'Test'); 44 | }); 45 | it('double quotes get single quoted', function() { 46 | assert.equal(absolution(`quote test`, 'http://example.com'), `quote test`); 47 | }); 48 | it('single quotes get double quoted', function() { 49 | assert.equal(absolution(`quote test`, 'http://example.com'), `quote test`); 50 | }); 51 | it('other entity escapes are preserved', function() { 52 | assert.equal(absolution(`quote test`, 'http://example.com'), `quote test`); 53 | }); 54 | it('should use single quotes for data attributes that contain JSON', function() { 55 | const result = absolution(`
Test
`, 'http://example.com/child/'); 56 | const expected = `
Test
`; 57 | assert.equal(result, expected); 58 | }); 59 | it('should handle custom closing tags', function() { 60 | const svg = ` 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | `; 73 | 74 | const expected = ` 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | `; 87 | 88 | const result = absolution(svg, 'http://example.com', { 89 | selfClosing: [ 90 | ...absolution.defaults.selfClosing, 91 | 'path', 92 | 'circle' 93 | ] 94 | }); 95 | assert.equal(result, expected); 96 | }); 97 | 98 | it('should handle the srcset in img', function() { 99 | const result = absolution(`cat`, 'http://example.com/'); 100 | 101 | const expected = `cat` 102 | 103 | assert.equal(result, expected); 104 | }) 105 | 106 | it('should handle the srcset if srcset configured correct in img', function() { 107 | const result = absolution(`cat`, 'http://example.com/'); 108 | const expected = `cat` 109 | 110 | assert.equal(result, expected) 111 | }) 112 | 113 | 114 | it('should handle the srcset in picture', function() { 115 | const picture = ` 116 | 117 | 118 | 119 | Flowers 120 | 121 | `; 122 | 123 | const expected = ` 124 | 125 | 126 | 127 | Flowers 128 | 129 | `; 130 | 131 | const result = absolution(picture, 'http://example.com/', { 132 | selfClosing: [ 133 | ...absolution.defaults.selfClosing, 134 | 'source' 135 | ] 136 | }); 137 | assert.equal(result, expected); 138 | }) 139 | 140 | it('should handle the srcset if srcset configured correct in picture', function() { 141 | const picture = ` 142 | 143 | 144 | 145 | Flowers 146 | 147 | `; 148 | const expected = ` 149 | 150 | 151 | 152 | Flowers 153 | 154 | `; 155 | 156 | const result = absolution(picture, 'http://example.com/', { 157 | selfClosing: [ 158 | ...absolution.defaults.selfClosing, 159 | 'source' 160 | ] 161 | }); 162 | assert.equal(result, expected); 163 | }) 164 | 165 | }); 166 | 167 | 168 | --------------------------------------------------------------------------------