Skip to content
This repository was archived by the owner on May 29, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`

Expand Down
5 changes: 0 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 5 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -60,7 +60,6 @@
},
"dependencies": {
"react-markdown": "^3.3.0",
"slugify": "^1.2.9",
"url-parse": "^1.4.0"
}
}
197 changes: 197 additions & 0 deletions src/component.js
Original file line number Diff line number Diff line change
@@ -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 <h{1-n}>
* @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: <a href={ `#${uniqueSlug}` }>
{ props.children }
</a>
});
}

/**
* @returns {ReactMarkdown} react tree
* @api private
*/
render() {
const renderers = {
heading: this.renderHeading,
...this.props.renderers
};

return (
<ReactMarkdown
{ ...this.props }
renderers={ renderers }
transformLinkUri={ this.transformLinkUri }
transformImageUri={ this.transformImageUri }
/>
);
}
}

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
};
76 changes: 76 additions & 0 deletions src/gh-slugify.js
Original file line number Diff line number Diff line change
@@ -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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we save this.slugs[slug] in a local const? I think it would allow it to read cleaner.

const numOccurances = this.slugs[slug] || 0;
if (numOccurances) {
  uniqueSlug = `${slug}-${numOccurances}`;
}
this.slugs[slug] += 1;

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And maybe change slugs to slugCounts?

return uniqueSlug;
}

/**
* Resets the state of this object including
* the tracking of duplicate slugs.
* @api public
*/
reset() {
this.slugs = {};
}

}
Loading