This repository was archived by the owner on May 29, 2025. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 10
improving headline slugging to be more GH compliant. #4
Merged
Merged
Changes from all commits
Commits
Show all changes
21 commits
Select commit
Hold shift + click to select a range
581008d
slugging is its own class, tests, added - before count per GH behavior
clinejm 51681f0
markdown header reference file
clinejm 7a7deaa
adding a number of failing tests
clinejm ad3aeb6
fixed text string
clinejm 3efecb9
update test text
clinejm 89a8004
more tests
clinejm 110b8e5
replaced slugify with regex similar to GH code.
clinejm f266269
linter fixes
clinejm 4bf022c
docs and cleanup
clinejm ab2187a
change regex to match GH, added tests for _ and removed invalid tests
clinejm 883fa49
added changelog
clinejm c2c4f6c
linter
clinejm 3ef07ce
fixed typo
clinejm 53b1105
Merge branch 'master' into slug-fixes
clinejm 871310d
remove slugify
clinejm c976d26
exporting GithubSlugify
clinejm 65d3dc6
Move to canonical src/ style structure (#6)
indexzero e88b9a9
fixed header spacing with non-text rendering. Now matches GH output.
clinejm f17fb31
tests now use index. fixed import path.
clinejm a4554c5
path nits
clinejm 87ceb23
bump to 3.0
clinejm File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| return uniqueSlug; | ||
| } | ||
|
|
||
| /** | ||
| * Resets the state of this object including | ||
| * the tracking of duplicate slugs. | ||
| * @api public | ||
| */ | ||
| reset() { | ||
| this.slugs = {}; | ||
| } | ||
|
|
||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 localconst? I think it would allow it to read cleaner.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And maybe change
slugstoslugCounts?