2 |
3 |
4 | React components markdown
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/config/jsdom_setup.js:
--------------------------------------------------------------------------------
1 | const jsdom = require('jsdom').jsdom;
2 |
3 | const exposedProperties = ['window', 'navigator', 'document'];
4 |
5 | global.document = jsdom('');
6 | global.window = document.defaultView;
7 |
8 | Object.keys(document.defaultView).forEach((property) => {
9 | if (typeof global[property] === 'undefined') {
10 | exposedProperties.push(property);
11 | global[property] = document.defaultView[property];
12 | }
13 | });
14 |
15 | global.navigator = {
16 | userAgent: 'node.js',
17 | };
18 |
--------------------------------------------------------------------------------
/config/lib.webpack.config.js:
--------------------------------------------------------------------------------
1 | var path = require('path'); // eslint-disable-line no-var
2 | var autoprefixer = require('autoprefixer'); // eslint-disable-line no-var
3 | var ExtractTextPlugin = require('extract-text-webpack-plugin'); // eslint-disable-line no-var
4 |
5 | module.exports = {
6 | output: {
7 | libraryTarget: 'commonjs2',
8 | path: path.join(__dirname, '../lib'),
9 | },
10 | postcss: [
11 | autoprefixer({ browsers: ['last 2 versions'] }),
12 | ],
13 | plugins: [
14 | new ExtractTextPlugin(`${path.parse(process.argv[2]).name}.css`),
15 | ],
16 | module: {
17 | loaders: [
18 | {
19 | test: /\.css$/,
20 | loader: ExtractTextPlugin.extract(
21 | 'style-loader',
22 | [
23 | 'css-loader?modules&importLoaders=1&localIdentName=[name]__[local]__[hash:5]',
24 | 'postcss-loader',
25 | ]
26 | ),
27 | },
28 | ],
29 | },
30 | };
31 |
--------------------------------------------------------------------------------
/config/loaders.js:
--------------------------------------------------------------------------------
1 | var path = require('path'); // eslint-disable-line no-var
2 | var autoprefixer = require('autoprefixer'); // eslint-disable-line no-var
3 | var webpack = require('webpack'); // eslint-disable-line no-var
4 |
5 | var envObj = Object.keys(process.env) // eslint-disable-line no-var
6 | .reduce((r, k) => Object.assign({}, r, {
7 | [k]: JSON.stringify(process.env[k]),
8 | }), {});
9 |
10 | var alias = { // eslint-disable-line no-var
11 | 'react-components-markdown': path.join(__dirname, '../src'),
12 | };
13 |
14 | module.exports = {
15 | resolve: {
16 | alias: alias, // eslint-disable-line
17 | },
18 | postcss: [
19 | autoprefixer({ browsers: ['last 2 versions'] }),
20 | ],
21 | plugins: [
22 | new webpack.DefinePlugin({
23 | 'process.env': envObj,
24 | }),
25 | ],
26 | module: {
27 | loaders: [
28 | {
29 | test: /\.sass$/,
30 | loaders: [
31 | 'style-loader',
32 | 'css-loader?modules&importLoaders=2&localIdentName=[name]__[local]__[hash:5]',
33 | 'postcss-loader',
34 | 'sass-loader?precision=10&indentedSyntax=sass',
35 | ],
36 | },
37 | {
38 | test: /\.css$/,
39 | loaders: [
40 | 'style-loader',
41 | 'css-loader?modules&importLoaders=1&localIdentName=[name]__[local]__[hash:5]',
42 | 'postcss-loader',
43 | ],
44 | },
45 | {
46 | test: /\.svg$/,
47 | loaders: ['url-loader?limit=7000'],
48 | },
49 | {
50 | test: /\.md$/,
51 | loaders: ['raw-loader'],
52 | },
53 | ],
54 | },
55 | };
56 |
--------------------------------------------------------------------------------
/config/tests.webpack.config.babel.js:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import autoprefixer from 'autoprefixer';
3 |
4 | const alias = {
5 | 'react-components-markdown': path.join(__dirname, '../src'),
6 | };
7 |
8 | export default {
9 | output: {
10 | libraryTarget: 'commonjs2',
11 | },
12 | resolve: {
13 | alias,
14 | },
15 | postcss: [
16 | autoprefixer({ browsers: ['last 2 versions'] }),
17 | ],
18 | module: {
19 | loaders: [
20 | {
21 | test: /\.css$/,
22 | loaders: [
23 | 'style-loader',
24 | 'css-loader?modules&importLoaders=1&localIdentName=[name]__[local]__[hash:5]',
25 | 'postcss-loader',
26 | ],
27 | },
28 | ],
29 | },
30 | };
31 |
--------------------------------------------------------------------------------
/example/Main.js:
--------------------------------------------------------------------------------
1 | // file: main.jsx
2 | // import 'babel-polyfill';
3 | import React from 'react';
4 | import { render } from 'react-dom';
5 | import App from './components/App';
6 |
7 | const mountNode = document.getElementById('app');
8 |
9 | render(, mountNode);
10 |
--------------------------------------------------------------------------------
/example/components/App.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 |
3 | import 'normalize.css/normalize.css';
4 | import './App.sass';
5 |
6 | import Layout from './Layout.js';
7 | import Page from './Page.js';
8 |
9 | // This class is needed for hmr
10 | export default class App extends Component {
11 | render() {
12 | return (
13 |
14 | );
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/example/components/App.sass:
--------------------------------------------------------------------------------
1 | :global(#app)
2 | height: 100%
3 |
4 | html, body
5 | height: 100%
6 | font-size: 14px
7 |
8 | html
9 | box-sizing: border-box
10 |
11 | *, *:before, *:after
12 | box-sizing: inherit
13 |
--------------------------------------------------------------------------------
/example/components/Layout.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import compose from 'recompose/compose';
3 | import defaultProps from 'recompose/defaultProps';
4 | import layoutStyles from './Layout.sass';
5 |
6 | // for hmr to work I need the first class to extend Component
7 | export const layoutComp = ({
8 | styles: { layout, header, main, footer, logo },
9 | children,
10 | }) => (
11 |
12 |
13 |
14 | React Components Markdown
15 |
16 |
21 |
22 |
23 | {children}
24 |
25 |
38 |
39 | );
40 |
41 | export const layoutHOC = compose(
42 | defaultProps({
43 | styles: layoutStyles,
44 | })
45 | );
46 |
47 | export default layoutHOC(layoutComp);
48 |
--------------------------------------------------------------------------------
/example/components/Layout.sass:
--------------------------------------------------------------------------------
1 | .layout
2 | display: flex
3 | min-height: 100vh
4 | flex-direction: column
5 | margin: 0 1px 0 1px
6 |
7 | .header
8 | height: 3em
9 | // background-color: #004336
10 | border-bottom: 1px solid #ccc
11 | color: #333
12 | display: flex
13 | align-items: center
14 | justify-content: space-between
15 | padding: 0 10px 0 10px
16 | a
17 | color: #333
18 |
19 | .logo
20 | width: 1.3em
21 | height: 1.3em
22 | margin: 0.3em
23 | background-size: contain
24 | background-repeat: no-repeat
25 | background-image: url('https://avatars2.githubusercontent.com/u/5077042?v=3&s=40')
26 |
27 | .main
28 | flex: 1
29 | // padding: 20px
30 | display: flex
31 | justify-content: center
32 |
33 | .footer
34 | height: 3em
35 | border-top: 1px solid #ccc
36 | // background-color: #004336
37 | color: #333
38 | display: flex
39 | align-items: center
40 | justify-content: center
41 | a
42 | color: #333
43 |
--------------------------------------------------------------------------------
/example/components/Page.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import compose from 'recompose/compose';
3 | import defaultProps from 'recompose/defaultProps';
4 | import Markdown from 'react-components-markdown';
5 | import pageStyles from './Page.sass';
6 | import mdContent from '../../README.md';
7 | import SuperComponent from './SuperComponent';
8 |
9 | export const page = ({ styles, content }) => (
10 |
11 |
14 | HELLO AFRICA
15 |
16 | }
17 | exampleSecondary={
18 |
19 | }
20 | >
21 | {content}
22 |
23 |
24 | );
25 |
26 | export const pageHOC = compose(
27 | defaultProps({
28 | styles: pageStyles,
29 | content: mdContent,
30 | }),
31 | );
32 |
33 | export default pageHOC(page);
34 |
--------------------------------------------------------------------------------
/example/components/Page.sass:
--------------------------------------------------------------------------------
1 | .main
2 | flex-basis: 1024px
3 | padding: 30px
4 | border-left: 1px solid #ccc
5 | border-right: 1px solid #ccc
6 |
7 | .bigMargin
8 | margin-top: 80px
9 | margin-bottom: 80px
10 |
--------------------------------------------------------------------------------
/example/components/SuperComponent/SuperComponent.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import superComponentStyles from './SuperComponent.sass';
3 |
4 | export default ({ children = 'DEFAULT TEXT', styles = superComponentStyles }) => (
5 |
6 |
7 | This is my super component
8 |
9 | {children}
10 |
11 | );
12 |
--------------------------------------------------------------------------------
/example/components/SuperComponent/SuperComponent.sass:
--------------------------------------------------------------------------------
1 | .main
2 | margin: 20px
3 | border: 5px solid red
4 | width: 300px
5 | height: 60px
6 | display: flex
7 | justify-content: center
8 | align-items: center
9 | flex-direction: column
10 | font-size: 24px
11 |
12 | .title
13 | font-size: 12px
14 |
--------------------------------------------------------------------------------
/example/components/SuperComponent/index.js:
--------------------------------------------------------------------------------
1 | export default from './SuperComponent';
2 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-components-markdown",
3 | "version": "0.2.0",
4 | "description": "",
5 | "main": "lib/index.js",
6 | "scripts": {
7 | "start": "kotatsu serve --port 4000 --config ./config/loaders.js --presets es2015,stage-0,react,react-hmre ./example/Main.js",
8 | "example-build": "NODE_ENV=production kotatsu build client --minify --config ./config/loaders.js --presets es2015,stage-0,react ./example/Main.js -o build",
9 | "concat-css": "rimraf ./lib/markdown.css && cat ./lib/*.css > ./lib/markdown.css",
10 | "build-lib": "BABEL_DISABLE_CACHE=1 NODE_ENV=LIB babel ./src --ignore __tests__ --out-dir lib",
11 | "build": "rimraf ./lib && npm run build-lib && npm run concat-css",
12 | "prepublish": "npm run build",
13 | "lint": "eslint ./src ./example",
14 | "src-test": "BABEL_DISABLE_CACHE=1 NODE_ENV=TESTS mocha --compilers js:babel-register --require ./config/jsdom_setup.js --recursive './src/**/*.spec.js'",
15 | "test": "npm run src-test"
16 | },
17 | "repository": {
18 | "type": "git",
19 | "url": "git+https://github.com/istarkov/react-components-markdown.git"
20 | },
21 | "author": "istarkov https://github.com/istarkov",
22 | "license": "MIT",
23 | "bugs": {
24 | "url": "https://github.com/istarkov/react-components-markdown/issues"
25 | },
26 | "homepage": "https://github.com/istarkov/react-components-markdown#readme",
27 | "peerDependencies": {
28 | "react": "^0.14.0 || ^15.0.0"
29 | },
30 | "devDependencies": {
31 | "autoprefixer": "^6.3.1",
32 | "babel-cli": "^6.4.5",
33 | "babel-eslint": "^6.0.0-beta.6",
34 | "babel-plugin-webpack-loaders": "^0.4.0",
35 | "babel-polyfill": "^6.5.0",
36 | "babel-preset-es2015": "^6.3.13",
37 | "babel-preset-react": "^6.3.13",
38 | "babel-preset-react-hmre": "^1.0.1",
39 | "babel-preset-stage-0": "^6.3.13",
40 | "babel-register": "^6.6.0",
41 | "classnames": "^2.2.3",
42 | "core-js": "^2.1.0",
43 | "css-loader": "^0.23.1",
44 | "enzyme": "^2.0.0",
45 | "eslint": "^2.4.0",
46 | "eslint-config-airbnb": "^6.2.0",
47 | "eslint-plugin-react": "^5.0.0",
48 | "expect": "^1.14.0",
49 | "extract-text-webpack-plugin": "^1.0.1",
50 | "file-loader": "^0.8.5",
51 | "github-markdown-css": "^2.2.1",
52 | "jsdom": "^8.1.0",
53 | "kotatsu": "^0.13.0",
54 | "mocha": "^2.4.5",
55 | "node-sass": "^3.4.2",
56 | "normalize.css": "^4.0.0",
57 | "postcss-loader": "^0.8.0",
58 | "raw-loader": "^0.5.1",
59 | "react": "^15.0.0",
60 | "react-addons-test-utils": "^15.0.0",
61 | "react-dom": "^15.0.0",
62 | "react-motion": "^0.4.2",
63 | "rimraf": "^2.5.1",
64 | "sass-loader": "^4.0.0",
65 | "style-loader": "^0.13.0",
66 | "url-loader": "^0.5.7",
67 | "webpack": "^1.12.13"
68 | },
69 | "dependencies": {
70 | "highlight.js": "^9.1.0",
71 | "lodash": "^4.3.0",
72 | "markdown-it": "^6.0.0",
73 | "recompose": "^0.15.0"
74 | },
75 | "optionalDependencies": {
76 | "fsevents": "^1.0.8"
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/MarkdownIt.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import compose from 'recompose/compose';
3 | import defaultProps from 'recompose/defaultProps';
4 | import mapPropsOnChange from 'recompose/mapPropsOnChange';
5 | import mapProps from 'recompose/mapProps';
6 | import pure from 'recompose/pure';
7 | import parsemd from './utils/parsemd';
8 | import MarkdownPart from './MarkdownPart';
9 |
10 | export const markdownIt = ({ mdAndComps, wrap, styles }) => (
11 |
12 | {
13 | mdAndComps
14 | .map(({ md, component, componentKey }) => ([
15 | md && {md},
16 | wrap({ component, componentKey }),
17 | ]))
18 | }
19 |
20 | );
21 |
22 | export const markdownItHOC = compose(
23 | defaultProps({
24 | anchorOffset: -90,
25 | componentRe: /\[[^\]]*\][^\(]*\(.*github\.io.*#([\w]+)\)/,
26 | }),
27 | mapPropsOnChange(
28 | ['anchorOffset'],
29 | ({ anchorOffset }) => ({
30 | wrap: ({ component, componentKey }) => (
31 | [
32 | ,
41 | component,
42 | ]
43 | ),
44 | })
45 | ),
46 | mapPropsOnChange(
47 | ['children'],
48 | ({ children, componentRe }) => ({
49 | mdAndCompKeys: parsemd(componentRe, children),
50 | })
51 | ),
52 | pure,
53 | mapProps(({ ...props, mdAndCompKeys }) => ({
54 | ...props,
55 | mdAndComps: mdAndCompKeys
56 | .map(({ md, componentKey }) => ({
57 | md,
58 | componentKey,
59 | component: props[componentKey],
60 | })),
61 | }))
62 | );
63 |
64 | export default markdownItHOC(markdownIt);
65 |
--------------------------------------------------------------------------------
/src/MarkdownPart.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import compose from 'recompose/compose';
3 | import defaultProps from 'recompose/defaultProps';
4 | import mapPropsOnChange from 'recompose/mapPropsOnChange';
5 |
6 | import githubCss from 'github-markdown-css/github-markdown.css';
7 | import hlJsCss from 'highlight.js/styles/github.css';
8 |
9 | import markdown from './utils/markdown';
10 |
11 | export const markdownComp = ({ html, styles }) => (
12 |
16 | );
17 |
18 | const re = /(<.+?class=)"([^"]+)"/gi;
19 |
20 | export const markdownHOC = compose(
21 | defaultProps({
22 | styles: {
23 | ...githubCss,
24 | ...hlJsCss,
25 | },
26 | }),
27 | mapPropsOnChange(
28 | ['children'],
29 | ({ children }) => ({
30 | html: markdown(children),
31 | })
32 | ),
33 | mapPropsOnChange(
34 | ['html', 'styles'],
35 | ({ ...props, html, styles }) => ({
36 | ...props,
37 | html: html
38 | .replace(
39 | re,
40 | (match, p1, className) => `${p1}"${styles[className] || className}"`
41 | ),
42 | })
43 | )
44 | );
45 |
46 | export default markdownHOC(markdownComp);
47 |
--------------------------------------------------------------------------------
/src/__tests__/MarkdownIt.spec.js:
--------------------------------------------------------------------------------
1 | import expect from 'expect';
2 | import React from 'react';
3 | import { mount } from 'enzyme';
4 | import Markdown from '../index';
5 |
6 | describe('Markdown test', () => {
7 | it('should render Components and Markdown', () => {
8 | const component = mount(
9 | EXAMPLE}
12 | >
13 | {`
14 | # hello
15 | [example](http://blablabla.github.io/react-components-markdown/#example)
16 | world
17 | `}
18 |
19 | );
20 |
21 | const exampleComponent = component.find('.example').first();
22 | expect(exampleComponent.text()).toEqual('EXAMPLE');
23 |
24 | const mds = component.find('.md-body').map((c) => c.html());
25 | expect(mds).toEqual([
26 | 'hello
\n',
27 | '',
28 | ]);
29 | });
30 |
31 | it('should update components on prop changes', () => {
32 | const component = mount(
33 | EXAMPLE}
35 | >
36 | {`
37 | # hello
38 | [example](http://blablabla.github.io/react-components-markdown/#example)
39 | world
40 | `}
41 |
42 | );
43 |
44 | component.setProps({ example: CHANGED
});
45 | const changedComponent = component.find('.changed').first();
46 | expect(changedComponent.text()).toEqual('CHANGED');
47 | });
48 |
49 | it('should allow different regexes', () => {
50 | const component = mount(
51 | EXAMPLE}
53 | componentRe={/---([\w]+)/}
54 | >
55 | {`
56 | # hello
57 | ---example
58 | world
59 | `}
60 |
61 | );
62 |
63 | const exampleComponent = component.find('.example').first();
64 | expect(exampleComponent.text()).toEqual('EXAMPLE');
65 | });
66 | });
67 |
--------------------------------------------------------------------------------
/src/__tests__/MarkdownPart.spec.js:
--------------------------------------------------------------------------------
1 | import expect from 'expect';
2 | import React from 'react';
3 | import { mount, render } from 'enzyme';
4 | import MarkdownPart from '../MarkdownPart';
5 | import hlJsCss from 'highlight.js/styles/github.css';
6 |
7 | describe('MarkdownPart test', () => {
8 | // ------------------------------------------------------------------------------------
9 | // Use jsdom rendering
10 | // ------------------------------------------------------------------------------------
11 |
12 | it('should render MarkdownPart', () => {
13 | const component = mount(
14 | {'# header'}
15 | );
16 |
17 | const innerDiv = component.find('div').first();
18 | // can't use contains here because of dangerouslySetInnerHTML
19 | expect(innerDiv.html()).toEqual('header
\n');
20 | });
21 |
22 | it('should update content', () => {
23 | const component = mount(
24 | {'# header'}
25 | );
26 |
27 | component.setProps({ children: 'bar' });
28 |
29 | const innerDiv = component.find('div').first();
30 | expect(innerDiv.html()).toEqual('');
31 | });
32 |
33 | it('should update content on style changes', () => {
34 | const component = mount(
35 | {'# header'}
36 | );
37 |
38 | component.setProps({ styles: { 'markdown-body': 'md-body-new' } });
39 |
40 | const innerDiv = component.find('div').first();
41 | expect(innerDiv.html()).toEqual('header
\n');
42 | });
43 |
44 | // ------------------------------------------------------------------------------------
45 | // Use html rendering
46 | // ------------------------------------------------------------------------------------
47 |
48 | it('should draw code', () => {
49 | const component = render(
50 | {`
51 | \`\`\`javascript
52 | const x = 'Hello World';
53 | \`\`\`
54 | `}
55 |
56 | );
57 |
58 | const innerDiv = component.find(`.${hlJsCss['hljs-string']}`);
59 | expect(innerDiv.html()).toEqual(''Hello World'');
60 | });
61 |
62 | it('should set default classNames if style is empty object', () => {
63 | const component = render(
64 | {`
65 | \`\`\`javascript
66 | const x = 'Hello World';
67 | \`\`\`
68 | `}
69 |
70 | );
71 |
72 | const innerDiv = component.find('.hljs-string');
73 | expect(innerDiv.html()).toEqual(''Hello World'');
74 | });
75 | });
76 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | export default from './MarkdownIt';
2 |
--------------------------------------------------------------------------------
/src/utils/markdown.js:
--------------------------------------------------------------------------------
1 | import hljs from 'highlight.js';
2 | import markdownIt from 'markdown-it';
3 |
4 | const markdown = (() => {
5 | const md = markdownIt({
6 | highlight(str, lang) {
7 | if (lang && hljs.getLanguage(lang)) {
8 | try {
9 | return hljs.highlight(lang, str, true).value;
10 | } catch (__) {/* */}
11 | }
12 | return (
13 | `${md.utils.escapeHtml(str)}`
14 | );
15 | },
16 | });
17 |
18 | return (src) => md.render(src);
19 | })();
20 |
21 | export default markdown;
22 |
--------------------------------------------------------------------------------
/src/utils/parsemd.js:
--------------------------------------------------------------------------------
1 | import RegExp from 'core-js/fn/regexp/constructor';
2 | import flatMap from 'lodash/flatMap';
3 | import zipWith from 'lodash/zipWith';
4 |
5 | // TODO rewrite on md ast, this is wrong in some situations
6 | export default (componentRe, str) => {
7 | const regexpG = new RegExp(componentRe, 'g');
8 |
9 | const strArray = str.split('```');
10 |
11 | const keys = strArray
12 | .reduce(
13 | (r, s, index) => ([
14 | ...r,
15 | ...(
16 | index % 2 === 0
17 | ? s.match(regexpG) || []
18 | : []
19 | ),
20 | ]),
21 | []
22 | );
23 |
24 | const data = keys.reduce(
25 | (r, key) => flatMap(r, (v) => v.split(key)),
26 | [str]
27 | );
28 |
29 | return zipWith(
30 | data,
31 | keys
32 | .map(key => key.match(componentRe)[1]),
33 | (md, componentKey) => ({ md, componentKey })
34 | );
35 | };
36 |
--------------------------------------------------------------------------------