diff --git a/CHANGELOG.md b/CHANGELOG.md index ec4ed36..2749112 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # `react-markdown-github` +### 3.0.0 + +- [#4] improving headline ID/anchor rendering to be more GH compliant - [#5] **BREAKING** Change `resolver` prop to `transformLinkUri` to be consistent with `react-markdown` - Create prop pass-through to provide props to `react-markdown` diff --git a/package-lock.json b/package-lock.json index 828ea8b..c4c5192 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7215,11 +7215,6 @@ "is-fullwidth-code-point": "2.0.0" } }, - "slugify": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.3.0.tgz", - "integrity": "sha512-vvz+9ANt7CtdTHwJpfrsHOnGkgxky+CUPnvtzDZBZYFo/H/CdZkd5lJL7z7RqtH/x9QW/ItYYfHlcGf38CBK1w==" - }, "sortobject": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/sortobject/-/sortobject-1.1.1.tgz", diff --git a/package.json b/package.json index 9e29f7f..7c973bd 100755 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "react-markdown-github", "description": "React component that renders Markdown similarly to Github's formatting", - "version": "2.0.0", - "main": "lib/index.js", - "browser": "lib/index.js", - "module": "src/index.js", + "version": "3.0.0", + "main": "./lib/index.js", + "browser": "./lib/index.js", + "module": "./src/index.js", "babel": { "plugins": [ "transform-object-rest-spread" @@ -16,7 +16,7 @@ }, "scripts": { "lint": "eslint-godaddy-react src/*.js test/*.js", - "prepublishOnly": "mkdir -p lib && babel -o lib/index.js src/index.js", + "prepublishOnly": "mkdir -p lib && babel -d ./lib src/*.js", "pretest": "npm run lint", "test": "nyc --reporter=text --reporter=json-summary npm run test:mocha", "test:mocha": "mocha --require test/setup ./test/*.test.js" @@ -60,7 +60,6 @@ }, "dependencies": { "react-markdown": "^3.3.0", - "slugify": "^1.2.9", "url-parse": "^1.4.0" } } diff --git a/src/component.js b/src/component.js new file mode 100755 index 0000000..e44156e --- /dev/null +++ b/src/component.js @@ -0,0 +1,197 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import ReactMarkdown from 'react-markdown'; +import GithubSlugify from './gh-slugify'; +import URL from 'url-parse'; + +const isHash = /^#/; + +/** + * A react component that wraps [react-markdown](react-markdown) that: + * - links all headers with an anchor link. + * - resolves all relative links to absolute Github URLs based on the sourceUri of the document. + * e.g. /foo/bar.md becomes https://github.mycorp.com/org/component/blob/master/foo/bar.md + * - allows the parent component to override the resolved url + * + * @class ReactMarkdownGithub + * @api public + */ +export default class ReactMarkdownGithub extends Component { + constructor() { + super(...arguments); + this.slugify = new GithubSlugify(); + this.transformLinkUri = this.transformLinkUri.bind(this); + this.renderHeading = this.renderHeading.bind(this); + this.transformImageUri = this.transformImageUri.bind(this); + + this.state = {}; + } + + /** + * Parses url into usable github components. + * @param {String} uri - a valid Github url. + * @returns {Object} { github, org, repo, filename, filepath } + * @api private + */ + static normalizeGithubUrl(uri) { + const { origin, pathname } = new URL(uri); + const parts = pathname.split('/'); + const [, org, repo] = parts; + const filepath = `/${parts.slice(5).join('/')}`; + const filename = parts[parts.length - 1]; + + return { + github: `${origin}/`, + filepath, + filename, + org, + repo + }; + } + + /** + * React lifecyle method to ensure that the github url prop is parsed each time + * it is updated. + * @param {Object} nextProps - new component props + * @param {Object} prevState - prior component state + * @returns {Object} returns new state or null if not modified. + * @api private + */ + static getDerivedStateFromProps({ sourceUri }, prevState) { + if (sourceUri !== prevState.sourceUri) { + return { + sourceUri: sourceUri, + ...ReactMarkdownGithub.normalizeGithubUrl(sourceUri) + }; + } + return null; + } + + /** + * Converts the passed url until an absolute url. If the passed URL is absolute + * it will be returned unmodified. If the URL is realitive then it will be + * merged with the current `sourceUri` property. + * + * @param {String} uri - absolute or realitive URL. + * @returns {url} - will return a absolute URL. + * @api private + */ + normalizeLinkUri(uri) { + // Do not attempt to parse "pure" hashes since they + // are not fully qualified URLs by definition. This will + // not work for querystring plus hash, but Github does not + // support querystring so this is by design. + if (isHash.test(uri)) { + return uri; + } + + const withinFile = new RegExp(`.?/?${this.state.filename}#(.*)$`, 'i'); + const parsed = new URL(uri, this.props.sourceUri); + const isWithinFile = withinFile.test(uri); + + return isWithinFile + ? parsed.hash + : parsed.href; + } + + /** + * The callback handler from `ReactMarkdown` . + * + * @param {String} uri - Markdown link URL. + * @param {Object} children - Child Elements of the link. + * @param {String} title - link title. + * @returns {url} - will return a absolute URL. + * @api private + */ + transformLinkUri(uri, children, title) { + const { transformLinkUri } = this.props; + const normalized = this.normalizeLinkUri(uri); + const opts = { ...this.state, uri: normalized, children, title }; + return transformLinkUri && transformLinkUri(opts) || normalized; + } + + /** + * The callback handler from `ReactMarkdown` . + * + * @param {String} uri - Markdown image URL. + * @returns {url} - will return a absolute URL. + * @api private + */ + transformImageUri(uri) { + const { transformImageUri } = this.props; + const opts = { ...this.state, uri }; + return transformImageUri && transformImageUri(opts) || uri; + } + + /** + * The callback handler from `ReactMarkdown` . Generates an `A` anchor link + * around the Header text + * + * @param {Object} props - properties passed from `ReactMarkdown` + * @param {Int} props.level - The level of the header to render. used for + * generating + * @param {Array} props.children - Array of strings from the heading + * @returns {Component} - A react component for the linked header. + * @api private + */ + renderHeading(props) { + let title = ''; + + props.children.forEach((child) => { + if (child.props && child.props.children) { + title += child.props.children; + } else { + title += child; + } + }); + + const uniqueSlug = this.slugify.slug(title); + + // eslint-disable-next-line react/no-children-prop + return React.createElement(`h${props.level}`, { + id: uniqueSlug, + className: 'headline-primary', + children: + { props.children } + + }); + } + + /** + * @returns {ReactMarkdown} react tree + * @api private + */ + render() { + const renderers = { + heading: this.renderHeading, + ...this.props.renderers + }; + + return ( + + ); + } +} + +ReactMarkdownGithub.propTypes = { + /** {source} The Markdown content to be rendered by `ReactMarkdown` */ + source: PropTypes.string, + /** {sourceUri} The absolute url to the Github source document. All + * relative urls will be assumed to be realitve to this file: + * e.g. https://github.mycorp.com/org/component/blob/master/README.md' + */ + sourceUri: PropTypes.string, + /** {transformLinkUri} The callback function executed for each found URL */ + transformLinkUri: PropTypes.func, + /** {transformImageUri} The callback function executed for each found image */ + transformImageUri: PropTypes.func, + /** {renderers} the collection of resolvers to pass to `ReactMarkdown` */ + renderers: PropTypes.object, + /** {className} the css class to to pass to `ReactMarkdown` */ + className: PropTypes.string +}; diff --git a/src/gh-slugify.js b/src/gh-slugify.js new file mode 100644 index 0000000..2496e55 --- /dev/null +++ b/src/gh-slugify.js @@ -0,0 +1,76 @@ +/** + * This RegEx is attempting to copy the Ruby pipeline filter GH uses to add unique ids to headings: + * https://github.com/jch/html-pipeline/blob/master/lib/html/pipeline/toc_filter.rb + * The replace isn't perfect as it doesn't correctly handle unicode characters. + * This is an area for improvement via future contribution. + */ + +const replace = /[^\w\- ]/g; +const whitespace = /\s/g; + +/** + * A utility class that is used to create + * a normalized ID from a string of text: + * + * `This is my headline` becomes `this-is-my-headline` + * + * This is a statefull object such that duplicate + * occurances of the same normalized string will have + * sequence number apended to them. + * + * Passing `This is my headline` a second time becomes `this-is-my-headline-1` + * + * The normalization process is meant to mimic the headline + * linking behavior GitHub provides when it renders markdown + * to html. + * + * @class GithubSlugify + * @api public + */ +export default class GithubSlugify { + + constructor() { + this.slugs = {}; + this.replacementChar = '-'; + } + + /** + * Convert the passed text into GH Slug. + * @api private + * @param {String} string - the txt to be converted to a slug. + * @returns {String} the text converted to a slug. + */ + replace(string) { + return string.toLowerCase().trim() + .replace(replace, '') + .replace(whitespace, this.replacementChar); + } + + /** + * Generates a GH style slug from the passed text. + * @api public + * @param {String} text - the txt to be converted to a slug. + * @returns {String} the text converted to a slug. + */ + slug(text) { + const slug = this.replace(text); + let uniqueSlug = slug; + + this.slugs[slug] = this.slugs[slug] || 0; + if (this.slugs[slug]) { + uniqueSlug = `${slug}-${this.slugs[slug]}`; + } + this.slugs[slug] += 1; + return uniqueSlug; + } + + /** + * Resets the state of this object including + * the tracking of duplicate slugs. + * @api public + */ + reset() { + this.slugs = {}; + } + +} diff --git a/src/index.js b/src/index.js old mode 100755 new mode 100644 index 10666f7..6aa0a44 --- a/src/index.js +++ b/src/index.js @@ -1,205 +1,7 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import ReactMarkdown from 'react-markdown'; -import slugify from 'slugify'; -import URL from 'url-parse'; +import ReactMarkdownGithub from './component.js'; +import GithubSlugify from './gh-slugify.js'; -const isHash = /^#/; - -/** - * A react component that wraps [react-markdown](react-markdown) that: - * - links all headers with an anchor link. - * - resolves all relative links to absolute Github URLs based on the sourceUri of the document. - * e.g. /foo/bar.md becomes https://github.mycorp.com/org/component/blob/master/foo/bar.md - * - allows the parent component to override the resolved url - * - * @class ReactMarkdownGithub - * @api public - */ -export default class ReactMarkdownGithub extends Component { - constructor() { - super(...arguments); - - this.transformLinkUri = this.transformLinkUri.bind(this); - this.renderHeading = this.renderHeading.bind(this); - this.transformImageUri = this.transformImageUri.bind(this); - this.slugs = {}; - this.state = {}; - } - - /** - * Parses url into usable github components. - * @param {String} uri - a valid Github url. - * @returns {Object} { github, org, repo, filename, filepath } - * @api private - */ - static normalizeGithubUrl(uri) { - const { origin, pathname } = new URL(uri); - const parts = pathname.split('/'); - const [, org, repo] = parts; - const filepath = `/${parts.slice(5).join('/')}`; - const filename = parts[parts.length - 1]; - - return { - github: `${origin}/`, - filepath, - filename, - org, - repo - }; - } - - /** - * React lifecyle method to ensure that the github url prop is parsed each time - * it is updated. - * @param {Object} nextProps - new component props - * @param {Object} prevState - prior component state - * @returns {Object} returns new state or null if not modified. - * @api private - */ - static getDerivedStateFromProps({ sourceUri }, prevState) { - if (sourceUri !== prevState.sourceUri) { - return { - sourceUri: sourceUri, - ...ReactMarkdownGithub.normalizeGithubUrl(sourceUri) - }; - } - return null; - } - - /** - * Converts the passed url until an absolute url. If the passed URL is absolute - * it will be returned unmodified. If the URL is realitive then it will be - * merged with the current `sourceUri` property. - * - * @param {String} uri - absolute or realitive URL. - * @returns {url} - will return a absolute URL. - * @api private - */ - normalizeLinkUri(uri) { - // Do not attempt to parse "pure" hashes since they - // are not fully qualified URLs by definition. This will - // not work for querystring plus hash, but Github does not - // support querystring so this is by design. - if (isHash.test(uri)) { - return uri; - } - - const withinFile = new RegExp(`.?/?${this.state.filename}#(.*)$`, 'i'); - const parsed = new URL(uri, this.props.sourceUri); - const isWithinFile = withinFile.test(uri); - - return isWithinFile - ? parsed.hash - : parsed.href; - } - - /** - * The callback handler from `ReactMarkdown` . - * - * @param {String} uri - Markdown link URL. - * @param {Object} children - Child Elements of the link. - * @param {String} title - link title. - * @returns {url} - will return a absolute URL. - * @api private - */ - transformLinkUri(uri, children, title) { - const { transformLinkUri } = this.props; - const normalized = this.normalizeLinkUri(uri); - const opts = { ...this.state, uri: normalized, children, title }; - return transformLinkUri && transformLinkUri(opts) || normalized; - } - - /** - * The callback handler from `ReactMarkdown` . - * - * @param {String} uri - Markdown image URL. - * @returns {url} - will return a absolute URL. - * @api private - */ - transformImageUri(uri) { - const { transformImageUri } = this.props; - const opts = { ...this.state, uri }; - return transformImageUri && transformImageUri(opts) || uri; - } - - /** - * The callback handler from `ReactMarkdown` . Generates an `A` anchor link - * around the Header text - * - * @param {Object} props - properties passed from `ReactMarkdown` - * @param {Int} props.level - The level of the header to render. used for - * generating - * @param {Array} props.children - Array of strings from the heading - * @returns {Component} - A react component for the linked header. - * @api private - */ - renderHeading(props) { - let title = ''; - - props.children.forEach((child) => { - if (child.props && child.props.children) { - title += child.props.children + ' '; - } else { - title += child; - } - }); - - const slug = slugify(title, { lower: true }); - let uniqueSlug = slug; - - this.slugs[slug] = this.slugs[slug] || 0; - if (this.slugs[slug]) { - uniqueSlug = `${slug}${this.slugs[slug]}`; - } - - this.slugs[slug] += 1; - - // eslint-disable-next-line react/no-children-prop - return React.createElement(`h${props.level}`, { - id: uniqueSlug, - className: 'headline-primary', - children: - { props.children } - - }); - } - - /** - * @returns {ReactMarkdown} react tree - * @api private - */ - render() { - const renderers = { - heading: this.renderHeading, - ...this.props.renderers - }; - - return ( - - ); - } -} - -ReactMarkdownGithub.propTypes = { - /** {source} The Markdown content to be rendered by `ReactMarkdown` */ - source: PropTypes.string, - /** {sourceUri} The absolute url to the Github source document. All - * relative urls will be assumed to be realitve to this file: - * e.g. https://github.mycorp.com/org/component/blob/master/README.md' - */ - sourceUri: PropTypes.string, - /** {transformLinkUri} The callback function executed for each found URL */ - transformLinkUri: PropTypes.func, - /** {transformImageUri} The callback function executed for each found image */ - transformImageUri: PropTypes.func, - /** {renderers} the collection of resolvers to pass to `ReactMarkdown` */ - renderers: PropTypes.object, - /** {className} the css class to to pass to `ReactMarkdown` */ - className: PropTypes.string +export { + ReactMarkdownGithub, + GithubSlugify }; diff --git a/test/index.test.js b/test/component.test.js similarity index 87% rename from test/index.test.js rename to test/component.test.js index 12ad9d8..0af779f 100755 --- a/test/index.test.js +++ b/test/component.test.js @@ -1,5 +1,5 @@ import React from 'react'; -import ReactMarkdownGithub from '../src'; +import { ReactMarkdownGithub } from '../src'; import { mount } from 'enzyme'; import assume from 'assume'; import assumeEnzyme from 'assume-enzyme'; @@ -146,24 +146,45 @@ Repeat Header`; assume(tree.find('#header').find('a').prop('href')).is.equal('#header'); // check duplicates - assume(tree.find('#header1')).to.have.length(1); - assume(tree.find('#header1').find('a').prop('href')).is.equal('#header1'); - assume(tree.find('#header2')).to.have.length(1); - assume(tree.find('#header2').find('a').prop('href')).is.equal('#header2'); + assume(tree.find('#header-1')).to.have.length(1); + assume(tree.find('#header-1').find('a').prop('href')).is.equal('#header-1'); + assume(tree.find('#header-2')).to.have.length(1); + assume(tree.find('#header-2').find('a').prop('href')).is.equal('#header-2'); assume(tree.find('#super-long-header')).to.have.length(1); assume(tree.find('#super-long-header').find('a').prop('href')).is.equal('#super-long-header'); assume(tree.find('#blort')).to.have.length(0); }); - it('A header with non text elements', () => { - const input = '### `codething` in the header `moreCode` txt'; + it('A header with code elements', () => { + const input = '### a `codething` in the header `moreCode` txt'; renderFullDom({ source: input }); - assume(tree.find('#codething-in-the-header-morecode-txt')).to.have.length(1); - assume(tree.find('#codething-in-the-header-morecode-txt') - .find('a').prop('href')).is.equal('#codething-in-the-header-morecode-txt'); + assume(tree.find('#a-codething-in-the-header-morecode-txt')).to.have.length(1); + assume(tree.find('#a-codething-in-the-header-morecode-txt') + .find('a').prop('href')).is.equal('#a-codething-in-the-header-morecode-txt'); + }); + + it('A header with bold element', () => { + const input = '### bold in the **bold** header '; + + renderFullDom({ source: input }); + + assume(tree.find('#bold-in-the-bold-header')).to.have.length(1); + assume(tree.find('#bold-in-the-bold-header') + .find('a').prop('href')).is.equal('#bold-in-the-bold-header'); + }); + + + it('A header with Italic element', () => { + const input = '### Italics in the header _Italics_'; + + renderFullDom({ source: input }); + + assume(tree.find('#italics-in-the-header-italics')).to.have.length(1); + assume(tree.find('#italics-in-the-header-italics') + .find('a').prop('href')).is.equal('#italics-in-the-header-italics'); }); }); diff --git a/test/slug.test.js b/test/slug.test.js new file mode 100644 index 0000000..4a731d3 --- /dev/null +++ b/test/slug.test.js @@ -0,0 +1,64 @@ +import assume from 'assume'; +import { GithubSlugify } from '../src'; + +describe('GithubSlugify', function () { + + it('Can create a GithubSlugify', () => { + const slug = new GithubSlugify(); + assume(slug).is.an('object'); + assume(slug.slug('this is neat')).equals('this-is-neat'); + }); + + + it('increments duplicates', () => { + const slug = new GithubSlugify(); + assume(slug.slug('this is neat')).equals('this-is-neat'); + assume(slug.slug('something else')).equals('something-else'); + assume(slug.slug('this is neat')).equals('this-is-neat-1'); + assume(slug.slug('this is neat')).equals('this-is-neat-2'); + }); + + it('trim white space', () => { + const slug = new GithubSlugify(); + assume(slug.slug(' lots of extra space ')).equals('lots-of-extra-space'); + }); + + it('non-text-chars', () => { + const slug = new GithubSlugify(); + assume(slug.slug(' a `code block` in the header')).equals('a-code-block-in-the-header'); + assume(slug.slug(' `codething` in the header `moreCode` txt')).equals('codething-in-the-header-morecode-txt'); + assume(slug.slug(' 1) numbers in the 345 header ')).equals('1-numbers-in-the-345-header'); + assume(slug.slug(' a question mark?')).equals('a-question-mark'); + assume(slug.slug('something & something else')).equals('something--something-else'); + assume(slug.slug('greek ∆ does something')).equals('greek--does-something'); + assume(slug.slug('copy ©')).equals('copy-'); + assume(slug.slug('Other punctuation, such as d.o.t.s, commas')).equals('other-punctuation-such-as-dots-commas'); + assume(slug.slug(' random _ in my header ')).equals('random-_-in-my-header'); + assume(slug.slug('Emdash –– and dash --')).equals('emdash--and-dash---'); + assume(slug.slug('Ampersand &')).equals('ampersand-'); + assume(slug.slug('Backslashes// or slashes\\')).equals('backslashes-or-slashes'); + assume(slug.slug('Complex code blocks like `/foo/bar/:bazz?buzz=foo`')).equals('complex-code-blocks-like-foobarbazzbuzzfoo'); + // In the order of operations Markdown formatting will be striped from the headline before generating the slug. + // assume(slug.slug(' Bold formatting **like this**')).equals('bold-formatting-like-this'); + // assume(slug.slug('Italic formatting _like this_')).equals('italic-formatting-like-this'); + assume(slug.slug(' All !@# the $%^ colors &*( of ){} the |~ punctuation < "\' rainbow += ')) + .equals('all--the--colors--of--the--punctuation---rainbow-'); + // We need full unicode support for these tests to match GH behavior. + // assume(slug.slug(' Seriously all of them ... Alt + [q-|] œ∑´®†¥¨ˆøπ“‘«')).equals('seriously-all-of-them--alt--q--œˆøπ'); + // assume(slug.slug('unicode ♥ is ☢')).equals('unicode--is-'); + }); + + + it('can reset unique counts', () => { + const slug = new GithubSlugify(); + assume(slug.slug('this is neat')).equals('this-is-neat'); + assume(slug.slug('something else')).equals('something-else'); + assume(slug.slug('this is neat')).equals('this-is-neat-1'); + assume(slug.slug('this is neat')).equals('this-is-neat-2'); + assume(slug.slug('something else')).equals('something-else-1'); + slug.reset(); + assume(slug.slug('this is neat')).equals('this-is-neat'); + assume(slug.slug('something else')).equals('something-else'); + }); + +}); diff --git a/test/test.md b/test/test.md new file mode 100644 index 0000000..1862795 --- /dev/null +++ b/test/test.md @@ -0,0 +1,42 @@ +# test +test + + +# test +second test + +# test? + +# a `code block` in the header + +# `codething` in the header `moreCode` txt + + +# something & something else + +# 2 starts with number + + +# unicode ♥ is ☢ + +# copy © + +# greek ∆ does something + +# Other punctuation, such as d.o.t.s, commas + +# Emdash –– and dash -- + +# Ampersand & + +# Backslashes// or slashes\\ + +# Complex code blocks like `/foo/bar/:bazz?buzz=foo` + +# Bold formatting **like this** + +# Italic formatting _like this_ + +# All !@# the $%^ colors &*( of ){} the |~ punctuation < "' rainbow += + +# Seriously all of them ... Alt + [q-|] œ∑´®†¥¨ˆøπ“‘« \ No newline at end of file