├── .gitignore ├── .npmrc ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── index.d.ts ├── index.js ├── package.json └── test └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | package-lock.json 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '4' 4 | - '6' 5 | cache: 6 | directories: 7 | - node_modules 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # markdown-it-github-headings change log 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/). 7 | 8 | 9 | ## 2.0.1 2022-06-09 10 | 11 | - Add TypeScript Declaration #19 Thank you [@ashaifarhan](https://github.com/ashalfarhan) for contributing! 12 | - Updated dev dependencies to squelch audit warnings. 13 | 14 | ## 2.0.0 2020-01-14 15 | 16 | - BREAKING: add resetSlugger option and default to true 17 | - This change avoids needlessly incrementing the duplicate slug counter between .render() calls. It is now the default behavior. 18 | - set the option `resetSlugger` to `false` to switch back to the old behavior if desired. 19 | 20 | ## 1.1.2 2019-09-23 21 | 22 | - Update dependencies per `npm audit` 23 | 24 | ## 1.1.1 2018-02-27 25 | 26 | - Removed dependency on `fs` for browser compatibility. Thanks [@imcuttle](https://github.com/imcuttle)! 27 | 28 | # 1.1.0 2017-11-18 29 | - Added option to allow a custom svg icon for links (Thanks @adam-lynch!). 30 | 31 | ## 1.0.0 2017-04-28 32 | 33 | Released! 34 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Contributions welcome! 4 | 5 | **Before spending lots of time on something, ask for feedback on your idea first!** 6 | 7 | Please search issues and pull requests before adding something new to avoid duplicating efforts and conversations. 8 | 9 | This project welcomes non-code contributions, too! The following types of contributions are welcome: 10 | 11 | - **Ideas**: participate in an issue thread or start your own to have your voice heard. 12 | - **Writing**: contribute your expertise in an area by helping expand the included content. 13 | - **Copy editing**: fix typos, clarify language, and generally improve the quality of the content. 14 | - **Formatting**: help keep content easy to read with consistent formatting. 15 | 16 | ## Code Style 17 | 18 | [![standard][standard-image]][standard-url] 19 | 20 | This repository uses [`standard`][standard-url] to maintain code style and consistency and avoid style arguments. `npm test` runs `standard` so you don't have to! 21 | 22 | [standard-image]: https://cdn.rawgit.com/feross/standard/master/badge.svg 23 | [standard-url]: https://github.com/feross/standard 24 | [semistandard-image]: https://cdn.rawgit.com/flet/semistandard/master/badge.svg 25 | [semistandard-url]: https://github.com/Flet/semistandard 26 | 27 | # Project Governance 28 | 29 | **This is an [OPEN Open Source Project](http://openopensource.org/).** 30 | 31 | Individuals making significant and valuable contributions are given commit access to the project to contribute as they see fit. This project is more like an open wiki than a standard guarded open source project. 32 | 33 | ## Rules 34 | 35 | There are a few basic ground rules for collaborators: 36 | 37 | 1. **No `--force` pushes** or modifying the Git history in any way. 38 | 1. **Non-master branches** ought to be used for ongoing work. 39 | 1. **External API changes and significant modifications** ought to be subject to an **internal pull request** to solicit feedback from other contributors. 40 | 1. Internal pull requests to solicit feedback are *encouraged* for any other non-trivial contribution but left to the discretion of the contributor. 41 | 1. Contributors should attempt to adhere to the prevailing code style. 42 | 43 | ## Releases 44 | 45 | Declaring formal releases remains the prerogative of the project maintainer. 46 | 47 | ## Changes to this arrangement 48 | 49 | This is an experiment and feedback is welcome! This document may also be subject to pull requests or changes by contributors where you believe you have something valuable to add or change. 50 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # [ISC License](https://spdx.org/licenses/ISC) 2 | 3 | Copyright (c) 2017, Dan Flettre 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # markdown-it-github-headings 2 | 3 | Add GitHub style anchor tags to headers 4 | 5 | [![npm][npm-image]][npm-url] 6 | [![travis][travis-image]][travis-url] 7 | [![standard][standard-image]][standard-url] 8 | 9 | [npm-image]: https://img.shields.io/npm/v/markdown-it-github-headings.svg?style=flat-square 10 | [npm-url]: https://www.npmjs.com/package/markdown-it-github-headings 11 | [travis-image]: https://img.shields.io/travis/Flet/markdown-it-github-headings.svg?style=flat-square 12 | [travis-url]: https://travis-ci.org/Flet/markdown-it-github-headings 13 | [standard-image]: https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat-square 14 | [standard-url]: http://npm.im/standard 15 | 16 | ## Install 17 | 18 | ``` 19 | npm install markdown-it-github-headings 20 | ``` 21 | 22 | ## Usage 23 | 24 | ```js 25 | var md = require('markdown-it')() 26 | .use(require('markdown-it-github-headings'), options) 27 | 28 | ``` 29 | 30 | ## Options and Defaults 31 | 32 | The defaults will make the heading anchors behave as close to how GitHub behaves as possible. 33 | 34 | Name | Description | Default 35 | ------------------|----------------------------------------------------------------|----------------------------------- 36 | `className` | name of the class that will be added to the anchor tag | `anchor` 37 | `prefixHeadingIds` | add a prefix to each heading ID. *(see security note below)* | `true` 38 | `prefix` | if `prefixHeadingIds` is true, use this string to prefix each ID. | `user-content-` 39 | `enableHeadingLinkIcons` | Adds the icon next to each heading | `true` 40 | `linkIcon` | If `enableHeadingLinkIcons` is true, use this to supply a custom icon (or anything really) | 41 | `resetSlugger` | reset the slugger counter between .render calls for duplicate headers. (See tests for example) | true 42 | 43 | ## Why should I prefix heading IDs? 44 | When using user generated content, its possible to run into **DOM Clobbering** when heading IDs are generated. Since IDs are used by JavaScript and CSS, a user could craft a page that breaks functionality or styles. A good way to avoid clobbering is to add a prefix to every generated ID to ensure they cannot overlap with existing IDs. 45 | 46 | If you have full control over the content, there is less of a risk, but be aware that strange bugs related to DOM Clobbering are still possible! 47 | 48 | For more information, here are some good resources on the topic: 49 | - [User-generated content and DOM clobbering](http://opensoul.org/2014/09/05/dom-clobbering/) 50 | - [In the DOM, no one will hear you scream](https://www.slideshare.net/x00mario/in-the-dom-no-one-will-hear-you-scream) 51 | - [A discussion about GitHub implementation](https://github.com/jch/html-pipeline/pull/111#issuecomment-34369984) 52 | - [An open issue on markdown-it repo](https://github.com/markdown-it/markdown-it/issues/28) 53 | 54 | ### But the prefixes make links look real gross. 55 | One solution is to write some client side JavaScript to force non-prefixed hashes to jump to prefixed anchors. This is how its handled on GitHub and npmjs.com. 56 | 57 | Check out [marky-deep-links](https://github.com/Flet/marky-deep-links) for an example (works great with browserify or webpack). 58 | 59 | 60 | ## Contributing 61 | 62 | Contributions welcome! Please read the [contributing guidelines](CONTRIBUTING.md) first. 63 | 64 | ## License 65 | 66 | [ISC](LICENSE.md) 67 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'markdown-it-github-headings' { 2 | import type { PluginWithOptions } from 'markdown-it'; 3 | type GithubHeadingsPluginOptions = { 4 | /** 5 | * Name of the class that will be added to the anchor tag. 6 | * @default "anchor" 7 | */ 8 | className?: string; 9 | /** 10 | * Add a prefix to each heading ID. 11 | * @see https://github.com/Flet/markdown-it-github-headings#why-should-i-prefix-heading-ids 12 | * @default true 13 | */ 14 | prefixHeadingIds?: boolean; 15 | /** 16 | * If `prefixHeadingIds` is true, use this string to prefix each ID. 17 | * @default "user-content" 18 | */ 19 | prefix?: string; 20 | /** 21 | * Adds the icon next to each heading. 22 | * @default true 23 | */ 24 | enableHeadingLinkIcons?: boolean; 25 | /** 26 | * If `enableHeadingLinkIcons` is true, use this to supply a custom icon (or anything really). 27 | */ 28 | linkIcon?: string; 29 | /** 30 | * Reset the slugger counter between .render calls for duplicate headers. (See tests for example). 31 | * @default true 32 | */ 33 | resetSlugger?: boolean; 34 | }; 35 | const markdownItGithubHeadings: PluginWithOptions; 36 | export = markdownItGithubHeadings; 37 | } 38 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = plugin 2 | 3 | const GithubSlugger = require('github-slugger') 4 | const innertext = require('innertext') 5 | const defaultOptions = { 6 | enableHeadingLinkIcons: true, 7 | prefixHeadingIds: true, 8 | prefix: 'user-content-', 9 | className: 'anchor', 10 | // shamelessly borrowed from GitHub, thanks y'all 11 | linkIcon: '', 12 | resetSlugger: true 13 | } 14 | 15 | function plugin (md, _opts) { 16 | const options = Object.assign({}, defaultOptions, _opts) 17 | 18 | if (!options.prefixHeadingIds) options.prefix = '' 19 | 20 | const slugger = new GithubSlugger() 21 | let Token 22 | 23 | md.core.ruler.push('headingLinks', function (state) { 24 | if (options.resetSlugger) { 25 | slugger.reset() 26 | } 27 | 28 | // save the Token constructor because we'll be building a few instances at render 29 | // time; that's sort of outside the intended markdown-it parsing sequence, but 30 | // since we have tight control over what we're creating (a link), we're safe 31 | if (!Token) { 32 | Token = state.Token 33 | } 34 | }) 35 | 36 | md.renderer.rules.heading_open = function (tokens, idx, opts, env, self) { 37 | const children = tokens[idx + 1].children 38 | // make sure heading is not empty 39 | if (children && children.length) { 40 | // Generate an ID based on the heading's innerHTML; first, render without 41 | // converting gemoji strings to unicode emoji characters 42 | const unemojiWithToken = unemoji.bind(null, Token) 43 | const rendered = md.renderer.renderInline(children.map(unemojiWithToken), opts, env) 44 | const postfix = slugger.slug( 45 | innertext(rendered) 46 | .replace(/[<>]/g, '') // In case the heading contains `` 47 | .toLowerCase() // because `slug` doesn't lowercase 48 | ) 49 | 50 | // add 3 new token objects link_open, text, link_close 51 | const linkOpen = new Token('link_open', 'a', 1) 52 | const text = new Token('html_inline', '', 0) 53 | if (options.enableHeadingLinkIcons) { 54 | text.content = options.linkIcon 55 | } 56 | const linkClose = new Token('link_close', 'a', -1) 57 | 58 | // add some link attributes 59 | linkOpen.attrSet('id', options.prefix + postfix) 60 | linkOpen.attrSet('class', options.className) 61 | linkOpen.attrSet('href', '#' + postfix) 62 | linkOpen.attrSet('aria-hidden', 'true') 63 | 64 | // add new token objects as children of heading 65 | children.unshift(linkClose) 66 | children.unshift(text) 67 | children.unshift(linkOpen) 68 | } 69 | 70 | return md.renderer.renderToken(tokens, idx, options, env, self) 71 | } 72 | } 73 | 74 | function unemoji (TokenConstructor, token) { 75 | if (token.type === 'emoji') { 76 | return Object.assign(new TokenConstructor(), token, { content: token.markup }) 77 | } 78 | return token 79 | } 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "markdown-it-github-headings", 3 | "description": "Add GitHub style anchor tags to headers", 4 | "version": "2.0.1", 5 | "author": "Dan Flettre ", 6 | "bugs": { 7 | "url": "https://github.com/Flet/markdown-it-github-headings/issues" 8 | }, 9 | "devDependencies": { 10 | "markdown-it": "^13.0.1", 11 | "standard": "*", 12 | "tap-arc": "^0.3.4", 13 | "tape": "^5.5.3" 14 | }, 15 | "homepage": "https://github.com/Flet/markdown-it-github-headings", 16 | "keywords": [ 17 | "anchor", 18 | "github", 19 | "headers", 20 | "links", 21 | "markdown", 22 | "markdown-it-plugin" 23 | ], 24 | "license": "ISC", 25 | "main": "index.js", 26 | "types": "index.d.ts", 27 | "repository": { 28 | "type": "git", 29 | "url": "https://github.com/Flet/markdown-it-github-headings.git" 30 | }, 31 | "scripts": { 32 | "test": "standard && tape test/*.js | tap-arc" 33 | }, 34 | "dependencies": { 35 | "github-slugger": "^1.1.1", 36 | "innertext": "^1.0.1" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | const test = require('tape') 2 | 3 | const md = require('markdown-it') 4 | const anchor = require('..') 5 | const svg = '' 6 | 7 | test('works as expected', function (t) { 8 | const result = md().use(anchor).render('# Hello') 9 | const expectedResult = `

Hello

\n` 10 | t.equals(result, expectedResult, 'works') 11 | t.end() 12 | }) 13 | 14 | test('resets slugger on every run', function (t) { 15 | const instance = md().use(anchor) 16 | 17 | let result = instance.render('# Hello') 18 | let expectedResult = `

Hello

\n` 19 | t.equals(result, expectedResult, 'works') 20 | 21 | result = instance.render('# Hello') 22 | expectedResult = `

Hello

\n` 23 | t.equals(result, expectedResult, 'works') 24 | 25 | t.end() 26 | }) 27 | 28 | test('resetSlugger false does not reset on every run', function (t) { 29 | const instance = md().use(anchor, { resetSlugger: false }) 30 | 31 | const result = instance.render('# Hello') 32 | const expectedResult = `

Hello

\n` 33 | t.equals(result, expectedResult, 'works') 34 | 35 | const result2 = instance.render('# Hello') 36 | const expectedResult2 = `

Hello

\n` 37 | t.equals(result2, expectedResult2, 'works') 38 | 39 | t.end() 40 | }) 41 | 42 | test('multiple headers', function (t) { 43 | const input = ` 44 | ## Hello 45 | ### World 46 | ` 47 | 48 | const expectedResult = `

Hello

49 |

World

50 | ` 51 | const result = md().use(anchor).render(input) 52 | t.equals(result, expectedResult, 'works') 53 | t.end() 54 | }) 55 | 56 | test('duplicate headers', function (t) { 57 | const input = ` 58 | # Hello 59 | ## Hello 60 | ### Hello 61 | ` 62 | 63 | const expectedResult = `

Hello

64 |

Hello

65 |

Hello

66 | ` 67 | const result = md().use(anchor).render(input) 68 | t.equals(result, expectedResult, 'works') 69 | t.end() 70 | }) 71 | 72 | test('prefix: foo-', function (t) { 73 | const result = md().use(anchor, { prefix: 'foo-' }).render('# Hello') 74 | const expectedResult = `

Hello

\n` 75 | t.equals(result, expectedResult, 'works') 76 | t.end() 77 | }) 78 | 79 | test('prefixHeadingIds: false', function (t) { 80 | const result = md().use(anchor, { prefixHeadingIds: false }).render('# Hello') 81 | const expectedResult = `

Hello

\n` 82 | t.equals(result, expectedResult, 'works') 83 | t.end() 84 | }) 85 | 86 | test('enableHeadingLinkIcons: false', function (t) { 87 | const result = md().use(anchor, { enableHeadingLinkIcons: false }).render('# Hello') 88 | const expectedResult = '

Hello

\n' 89 | t.equals(result, expectedResult, 'works') 90 | t.end() 91 | }) 92 | 93 | test('custom linkIcon', function (t) { 94 | const customContents = '

HelloWorld

' 95 | const result = md().use(anchor, { linkIcon: customContents }).render('# Hello') 96 | const expectedResult = `

Hello

\n` 97 | t.equals(result, expectedResult, 'works') 98 | t.end() 99 | }) 100 | --------------------------------------------------------------------------------