├── .gitignore ├── .jshintrc ├── .npmignore ├── LICENSE ├── Makefile ├── README.md ├── example ├── client.js └── server.js ├── index.js ├── package.json └── spec.js /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | 10 | pids 11 | logs 12 | results 13 | 14 | npm-debug.log 15 | node_modules 16 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "undef": true, 3 | "unused": "vars", 4 | "asi": false, 5 | "expr": true, 6 | "globalstrict": true, 7 | "newcap": false, 8 | "globals": { 9 | "window": false, 10 | "require": false, 11 | "process": false, 12 | "module": false, 13 | "exports": false, 14 | "console": false, 15 | "document": false, 16 | "setTimeout": false, 17 | "clearTimeout": false, 18 | "setInterval": false, 19 | "clearInterval": false, 20 | "alert": false, 21 | "__dirname": false 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .jshintrc 2 | Makefile 3 | spec.js 4 | LICENSE 5 | example 6 | .npmignore 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Martin Andert 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BIN = ./node_modules/.bin 2 | 3 | test: lint 4 | @$(BIN)/mocha -t 5000 -b -R spec spec.js 5 | 6 | lint: 7 | @$(BIN)/jsxhint index.js example/ 8 | 9 | install: 10 | npm install 11 | 12 | example:: 13 | @$(BIN)/node-dev example/server.js 14 | 15 | release-patch: test 16 | @$(call release,patch) 17 | 18 | release-minor: test 19 | @$(call release,minor) 20 | 21 | release-major: test 22 | @$(call release,major) 23 | 24 | publish: 25 | git push --tags origin HEAD:master 26 | npm publish 27 | 28 | define release 29 | npm version $(1) -m "release v%s" 30 | endef 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Interpolate Component 2 | 3 | A component for [React][1] that renders elements into a format string containing replacement fields. It comes in handy when working with dynamic text elements like localized strings of a translation library. 4 | 5 | 6 | ## Installation 7 | 8 | Install via npm: 9 | 10 | ```bash 11 | % npm install react-interpolate-component 12 | ``` 13 | 14 | 15 | ## Usage 16 | 17 | The Interpolate component expects as only child a format string containing the placeholders to be interpolated. Like the format syntax of `sprintf` with named arguments, a placeholder is depicted as `'%(' + placeholder_name + ')s'`. 18 | 19 | The actual substitution elements are provided via the `with` prop. Values can be strings, numbers, dates, and even React components. 20 | 21 | Here is a small exemplification: 22 | 23 | ```jsx 24 | var React = require('react'); 25 | var Interpolate = require('react-interpolate-component'); 26 | 27 | class MyApp extends React.Component { 28 | render() { 29 | const props = { 30 | with: { 31 | firstName: Paul, 32 | age: 13, 33 | unit: 'years' 34 | }, 35 | component: 'p', // default is a 36 | className: 'foo' 37 | }; 38 | 39 | return ( 40 |
41 | 42 | %(firstName)s is %(age)s %(unit)s old. 43 | 44 |
45 | ); 46 | } 47 | } 48 | ``` 49 | 50 | The MyApp component shown above renders the following (simplified) HTML: 51 | 52 | ```html 53 |
54 |

55 | Paul is 13 years old. 56 |

57 |
58 | ``` 59 | 60 | All props that are not interpolation arguments get transferred to Interpolate's container component (which is a `` by default). 61 | 62 | Alternatively to providing the format string as child, you can also set the `format` prop to the desired format: 63 | 64 | ```html 65 | 66 | ``` 67 | 68 | For security reasons, all HTML markup present in the format string will be escaped. You can undermine this by providing a prop named "unsafe" which is set to `true`. There's one caveat when allowing unsafe format strings: You cannot use other React components as interpolation values. 69 | 70 | 71 | ## Example 72 | 73 | The examples code is located at `example` directory. You can clone this repository and run `make install example` and point your web browser to 74 | `http://localhost:3000`. 75 | 76 | 77 | ## Contributing 78 | 79 | Here's a quick guide: 80 | 81 | 1. Fork the repo and `make install`. 82 | 83 | 2. Run the tests. We only take pull requests with passing tests, and it's great to know that you have a clean slate: `make test`. 84 | 85 | 3. Add a test for your change. Only refactoring and documentation changes require no new tests. If you are adding functionality or are fixing a bug, we need a test! 86 | 87 | 4. Make the test pass. 88 | 89 | 5. Push to your fork and submit a pull request. 90 | 91 | 92 | ## Licence 93 | 94 | Released under The MIT License. 95 | 96 | 97 | 98 | [1]: http://facebook.github.io/react/ 99 | -------------------------------------------------------------------------------- /example/client.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var React = require('react'); 4 | var ReactDOM = require('react-dom'); 5 | var createReactClass = require('create-react-class'); 6 | var Interpolate = require('../'); 7 | 8 | var PersonName = createReactClass({ 9 | handleClick: function(e) { 10 | alert('You clicked on: ' + this.props.name); 11 | }, 12 | 13 | render: function() { 14 | return {this.props.name}; 15 | } 16 | }); 17 | 18 | var PeopleList = createReactClass({ 19 | render: function() { 20 | var items = this.props.people.map(function(person, i) { 21 | var name = ; 22 | 23 | return {this.props.format}; 24 | }.bind(this)); 25 | 26 | return ( 27 |
28 |

List of People

29 |
    {items}
30 |
31 | ); 32 | } 33 | }); 34 | 35 | var App = createReactClass({ 36 | render: function() { 37 | var people = [ 38 | { name: 'Peter', age: 21 }, 39 | { name: 'Paula', age: 47 }, 40 | { name: 'Frank', age: 33 } 41 | ]; 42 | 43 | var personFormat = '%(firstName)s is %(age)s years old.'; 44 | var unsafeFormat = 'In this interpolated sentence, some %(what)s has been used as format.'; 45 | 46 | return ( 47 | 48 | 49 | 50 | React Interpolate Component 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | ); 61 | } 62 | }); 63 | 64 | if (typeof window !== 'undefined') { 65 | window.onload = function() { 66 | ReactDOM.render(, document); 67 | }; 68 | } 69 | 70 | module.exports = App; 71 | -------------------------------------------------------------------------------- /example/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var express = require('express'); 4 | var browserify = require('connect-browserify'); 5 | var reactify = require('reactify'); 6 | var React = require('react'); 7 | var ReactDOM = require('react-dom/server'); 8 | 9 | require('node-jsx').install(); 10 | 11 | var App = React.createFactory(require('./client')); 12 | 13 | express() 14 | .use('/bundle.js', browserify.serve({ 15 | entry: __dirname + '/client', 16 | debug: true, watch: true, 17 | transforms: [reactify] 18 | })) 19 | .get('/', function(req, res, next) { 20 | res.send(ReactDOM.renderToString(App())); 21 | }) 22 | .listen(3000, function() { 23 | console.log('Point your browser to http://localhost:3000'); 24 | }); 25 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var React = require('react'); 4 | var invariant = require('invariant'); 5 | var except = require('except'); 6 | var extend = require('object-assign'); 7 | var createReactClass = require('create-react-class'); 8 | 9 | function isString(object) { 10 | return Object.prototype.toString.call(object) === '[object String]'; 11 | } 12 | 13 | var REGEXP = /\%\((.+?)\)s/; 14 | var OMITTED_PROPS = ['children', 'format', 'component', 'unsafe', 'with']; 15 | 16 | var Interpolate = createReactClass({ 17 | displayName: 'Interpolate', 18 | 19 | getDefaultProps: function() { 20 | return { component: 'span' }; 21 | }, 22 | 23 | render: function() { 24 | var format = this.props.children; 25 | var parent = this.props.component; 26 | var unsafe = this.props.unsafe === true; 27 | var interpolations = extend({}, this.props, this.props.with); 28 | var props = except(this.props, OMITTED_PROPS); 29 | 30 | var matches = []; 31 | var children = []; 32 | 33 | if (!isString(format)) { 34 | format = this.props.format; 35 | } 36 | 37 | invariant(isString(format), 'Interpolate expects either a format string as only child or a `format` prop with a string value'); 38 | 39 | if (unsafe) { 40 | var content = format.split(REGEXP).reduce(function(memo, match, index) { 41 | var html; 42 | 43 | if (index % 2 === 0) { 44 | html = match; 45 | } else { 46 | html = interpolations[match]; 47 | matches.push(match); 48 | } 49 | 50 | if (React.isValidElement(html)) { 51 | throw new Error('cannot interpolate a React component into unsafe text'); 52 | } 53 | 54 | memo += html; 55 | 56 | return memo; 57 | }, ''); 58 | 59 | props.dangerouslySetInnerHTML = { __html: content }; 60 | } else { 61 | format.split(REGEXP).reduce(function(memo, match, index) { 62 | var child; 63 | 64 | if (index % 2 === 0) { 65 | if (match.length === 0) { 66 | return memo; 67 | } 68 | 69 | child = match; 70 | } else { 71 | child = interpolations[match]; 72 | matches.push(match); 73 | } 74 | 75 | memo.push(child); 76 | 77 | return memo; 78 | }, children); 79 | } 80 | 81 | props = except(props, matches); 82 | 83 | return React.createElement.apply(this, [parent, props].concat(children)); 84 | } 85 | }); 86 | 87 | module.exports = Interpolate; 88 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-interpolate-component", 3 | "version": "0.12.0", 4 | "description": "A component for React that renders elements into a format string containing replacement fields", 5 | "main": "index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/martinandert/react-interpolate-component.git" 9 | }, 10 | "keywords": [ 11 | "react", 12 | "react-component", 13 | "interpolations", 14 | "interpolate", 15 | "substitution", 16 | "substitute", 17 | "sprintf", 18 | "named-arguments", 19 | "format" 20 | ], 21 | "author": { 22 | "name": "Martin Andert", 23 | "email": "mandert@gmail.com" 24 | }, 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/martinandert/react-interpolate-component/issues" 28 | }, 29 | "homepage": "https://github.com/martinandert/react-interpolate-component", 30 | "dependencies": { 31 | "create-react-class": "^15.5.2", 32 | "except": "^0.1.3", 33 | "invariant": "^2.2.2", 34 | "object-assign": "^4.1.1" 35 | }, 36 | "peerDependencies": { 37 | "react": "^16.2.0" 38 | }, 39 | "devDependencies": { 40 | "connect-browserify": "^4.0.0", 41 | "express": "^4.12.3", 42 | "jsxhint": "^0.15.1", 43 | "mocha": "^2.2.1", 44 | "node-dev": "^3.1.3", 45 | "node-jsx": "^0.13.3", 46 | "react": "^16.2.0", 47 | "react-dom": "^16.2.0", 48 | "reactify": "^1.1.0", 49 | "semver": "^5.0.3" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /spec.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | var React = require('react'); 3 | var ReactDOM = require('react-dom/server'); 4 | var Interpolate = React.createFactory(require('./')); 5 | var render = ReactDOM.renderToString; 6 | var createReactClass = require('create-react-class'); 7 | 8 | // hack: raise React console warnings as failed assertions 9 | console.error = function(message) { 10 | assert(false, message); 11 | }; 12 | 13 | assert.matches = function(regexp, value, message) { 14 | if (!regexp.test(value)) { 15 | assert.fail(value, regexp, message, '=~'); 16 | } 17 | }; 18 | 19 | assert.doesNotMatch = function(regexp, value, message) { 20 | if (regexp.test(value)) { 21 | assert.fail(value, regexp, message, '!~'); 22 | } 23 | }; 24 | 25 | describe('The Interpolate component', function() { 26 | it('does not mutate props', function() { 27 | var props = { className: 'foo', with: { name: 'bar', value: 'baz' }, children: '%(name)s: %(value)s' }; 28 | var markup = render(Interpolate(props)); 29 | 30 | assert.deepEqual(props, { className: 'foo', with: { name: 'bar', value: 'baz' }, children: '%(name)s: %(value)s' }); 31 | 32 | props.unsafe = true; 33 | markup = render(Interpolate(props)); 34 | 35 | assert.deepEqual(props, { className: 'foo', with: { name: 'bar', value: 'baz' }, children: '%(name)s: %(value)s', unsafe: true }); 36 | }); 37 | 38 | it('transfers those props to the container component that are not interpolation arguments', function() { 39 | var props = { className: 'foo', with: { name: 'bar', value: 'baz' } }; 40 | var format = '%(name)s: %(value)s'; 41 | var markup = render(Interpolate(props, format)); 42 | 43 | assert.matches(/^]*?class="foo"/, markup); 44 | assert.doesNotMatch(/\sname="/, markup); 45 | assert.doesNotMatch(/\svalue="/, markup); 46 | 47 | props.unsafe = true; 48 | markup = render(Interpolate(props, format)); 49 | 50 | assert.matches(/^]*?class="foo"/, markup); 51 | assert.doesNotMatch(/\sname="/, markup); 52 | assert.doesNotMatch(/\svalue="/, markup); 53 | }); 54 | 55 | it('renders a `span` HTML element as container by default', function() { 56 | var markup = render(Interpolate(null, 'bar')); 57 | assert.matches(/^ 98 | className: 'foo' 99 | }; 100 | 101 | var format = '%(firstName)s is %(age)s %(unit)s old.'; 102 | 103 | return React.createElement('div', null, Interpolate(props, format)); 104 | } 105 | }); 106 | 107 | var markup = render(React.createFactory(MyApp)()); 108 | assert.matches( 109 | /]+>]*>Paul<\/strong>.*? is .*?13.*? .*?years.*? old\..*?<\/p><\/div>/, 110 | markup 111 | ); 112 | }); 113 | 114 | it('rejects everything as format that is not a string', function() { 115 | // How can something like this be properly testet? 116 | [undefined, null, {}, [], function() {}, new Date, true, 123].forEach(function(object) { 117 | assert.throws(function() { render(Interpolate(null, object)); }, /invariant/i); 118 | assert.throws(function() { render(Interpolate({ format: object })); }, /invariant/i); 119 | }); 120 | }); 121 | 122 | describe('with format set as child', function() { 123 | it('interpolates properly', function() { 124 | var props = { 125 | with: { foo: 'bar', number: 42, comp: React.createElement('i', null, 'baz') } 126 | }; 127 | var format = 'lala %(foo)s lulu %(comp)s lili %(number)s lele'; 128 | var markup = render(Interpolate(props, format)); 129 | 130 | assert.matches(/lala .*?bar.*? lulu .*?baz.*? lili .*?42.*? lele/, markup); 131 | assert.doesNotMatch(/%\(|\)s|foo|comp|number/, markup); 132 | }); 133 | 134 | it('interpolates properly when child is an empty string', function() { 135 | var props = { component: 'div' }; 136 | var markup = render(Interpolate(props, '')); 137 | 138 | assert.matches(/]*><\/div>/, markup); 139 | }); 140 | }); 141 | 142 | describe('with format set as prop', function() { 143 | it('interpolates properly', function() { 144 | var props = { 145 | with: { foo: 'bar', number: 42, comp: React.createElement('i', null, 'baz') }, 146 | format: 'lala %(foo)s lulu %(comp)s lili %(number)s lele' 147 | }; 148 | var markup = render(Interpolate(props)); 149 | 150 | assert.matches(/lala .*?bar.*? lulu .*?baz.*? lili .*?42.*? lele/, markup); 151 | assert.doesNotMatch(/%\(|\)s|foo|comp|number/, markup); 152 | }); 153 | 154 | it('interpolates properly when prop is an empty string', function() { 155 | var props = { component: 'div', format: '' }; 156 | var markup = render(Interpolate(props)); 157 | 158 | assert.matches(/]*><\/div>/, markup); 159 | }); 160 | }); 161 | 162 | it('escapes HTML markup in the format string by default', function() { 163 | var format = 'foo bar'; 164 | var markup = render(Interpolate(null, format)); 165 | 166 | assert.doesNotMatch(/<\/?script>/, markup); 167 | }); 168 | 169 | describe('when providing an `unsafe` prop set to `true`', function() { 170 | it('renders HTML markup present in the format string', function() { 171 | var format = 'foo bar'; 172 | var markup = render(Interpolate({ unsafe: true, alert: 'Danger!' }, format)); 173 | 174 | assert.matches(/