Skip to content

Commit

Permalink
Add JSDoc based types
Browse files Browse the repository at this point in the history
  • Loading branch information
wooorm committed Aug 2, 2021
1 parent f684436 commit 779dc5a
Show file tree
Hide file tree
Showing 9 changed files with 146 additions and 138 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
coverage/
node_modules/
.DS_Store
*.d.ts
*.log
yarn.lock
106 changes: 85 additions & 21 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,94 +1,158 @@
/**
* @typedef {import('hast').Root} Root
* @typedef {import('hast').Parent} Parent
* @typedef {import('hast').Element} Element
* @typedef {Element['children'][number]} ElementChild
* @typedef {import('hast').Properties} Properties
*
* @typedef {'prepend'|'append'|'wrap'|'before'|'after'} Behavior
*
* @callback Build
* @param {Element} node
* @returns {ElementChild|ElementChild[]}
*
* @typedef Options
* Configuration.
* @property {Behavior} [behavior='prepend']
* How to create links.
* @property {Behavior} [behaviour]
* Please use `behavior` instead
* @property {Properties} [properties]
* Extra properties to set on the link when injecting.
* Defaults to `{ariaHidden: true, tabIndex: -1}` when `'prepend'` or
* `'append'`.
* @property {ElementChild|ElementChild[]|Build} [content={type: 'element', tagName: 'span', properties: {className: ['icon', 'icon-link']}, children: []}]
* hast nodes to insert in the link.
* @property {ElementChild|ElementChild[]|Build} [group]
* hast node to wrap the heading and link with, if `behavior` is `'before'` or
* `'after'`.
* There is no default.
*/

import extend from 'extend'
import {hasProperty} from 'hast-util-has-property'
import {headingRank} from 'hast-util-heading-rank'
import {visit} from 'unist-util-visit'
import {visit, SKIP} from 'unist-util-visit'

/** @type {Element} */
const contentDefaults = {
type: 'element',
tagName: 'span',
properties: {className: ['icon', 'icon-link']},
children: []
}

/**
* Plugin to automatically add links to headings (h1-h6).
*
* @type {import('unified').Plugin<[Options?]|void[], Root>}
*/
export default function rehypeAutolinkHeadings(options = {}) {
let props = options.properties
const behavior = options.behaviour || options.behavior || 'prepend'
const content = options.content || contentDefaults
const group = options.group

/** @type {import('unist-util-visit').Visitor<Element>} */
let method

if (behavior === 'wrap') {
method = wrap
} else if (behavior === 'before' || behavior === 'after') {
method = around
} else {
method = inject

if (!props) {
props = {ariaHidden: 'true', tabIndex: -1}
}
}

return transformer

function transformer(tree) {
visit(tree, 'element', visitor)
method = inject
}

function visitor(node, index, parent) {
if (headingRank(node) && hasProperty(node, 'id')) {
return method(node, index, parent)
}
return (tree) => {
visit(tree, 'element', (node, index, parent) => {
if (headingRank(node) && hasProperty(node, 'id')) {
return method(node, index, parent)
}
})
}

/** @type {import('unist-util-visit').Visitor<Element>} */
function inject(node) {
node.children[behavior === 'prepend' ? 'unshift' : 'push'](
create(node, extend(true, {}, props), toChildren(content, node))
)

return [visit.SKIP]
return [SKIP]
}

/** @type {import('unist-util-visit').Visitor<Element>} */
function around(node, index, parent) {
// Uncommon.
/* c8 ignore next */
if (typeof index !== 'number' || !parent) return

const link = create(
node,
extend(true, {}, props),
toChildren(content, node)
)
let nodes = behavior === 'before' ? [link, node] : [node, link]
const grouping = group && toNode(group, node)

if (grouping) {
grouping.children = nodes
nodes = [grouping]
if (group) {
const grouping = toNode(group, node)

if (grouping && !Array.isArray(grouping) && grouping.type === 'element') {
grouping.children = nodes
nodes = [grouping]
}
}

parent.children.splice(index, 1, ...nodes)

return [visit.SKIP, index + nodes.length]
return [SKIP, index + nodes.length]
}

/** @type {import('unist-util-visit').Visitor<Element>} */
function wrap(node) {
node.children = [create(node, extend(true, {}, props), node.children)]

return [visit.SKIP]
return [SKIP]
}

/**
* @param {ElementChild|ElementChild[]|Build} value
* @param {Element} node
* @returns {ElementChild[]}
*/
function toChildren(value, node) {
const result = toNode(value, node)
return Array.isArray(result) ? result : [result]
}

/**
* @param {ElementChild|ElementChild[]|Build} value
* @param {Element} node
* @returns {ElementChild|ElementChild[]}
*/
function toNode(value, node) {
if (typeof value === 'function') return value(node)
return extend(true, Array.isArray(value) ? [] : {}, value)
}

/**
* @param {Element} node
* @param {Properties} props
* @param {ElementChild[]} children
* @returns {Element}
*/
function create(node, props, children) {
return {
type: 'element',
tagName: 'a',
properties: Object.assign({}, props, {href: '#' + node.properties.id}),
properties: Object.assign({}, props, {
// Fix hast types and make them required.
/* c8 ignore next */
href: '#' + (node.properties || {}).id
}),
children
}
}
Expand Down
20 changes: 17 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,34 +25,42 @@
"sideEffects": false,
"type": "module",
"main": "index.js",
"types": "index.d.ts",
"files": [
"index.d.ts",
"index.js"
],
"dependencies": {
"@types/hast": "^2.0.0",
"extend": "^3.0.0",
"hast-util-has-property": "^2.0.0",
"hast-util-heading-rank": "^2.0.0",
"unified": "^10.0.0",
"unist-util-visit": "^4.0.0"
},
"devDependencies": {
"@types/hast": "^2.0.0",
"@types/extend": "^3.0.1",
"@types/tape": "^4.0.0",
"bail": "^2.0.0",
"c8": "^7.0.0",
"is-hidden": "^2.0.0",
"prettier": "^2.0.0",
"rehype": "^12.0.0",
"remark-cli": "^9.0.0",
"remark-preset-wooorm": "^8.0.0",
"rimraf": "^3.0.0",
"tape": "^5.0.0",
"to-vfile": "^7.0.0",
"unified": "^10.0.0",
"type-coverage": "^2.0.0",
"typescript": "^4.0.0",
"xo": "^0.38.0"
},
"scripts": {
"build": "rimraf \"*.d.ts\" \"test/**/*.d.ts\" && tsc && type-coverage",
"format": "remark . -qfo && prettier . -w --loglevel warn && xo --fix",
"test-api": "node --conditions development test/index.js",
"test-coverage": "c8 --check-coverage --branches 100 --functions 100 --lines 100 --statements 100 --reporter lcov npm run test-api",
"test": "npm run format && npm run test-coverage"
"test": "npm run build && npm run format && npm run test-coverage"
},
"prettier": {
"tabWidth": 2,
Expand All @@ -72,5 +80,11 @@
"plugins": [
"preset-wooorm"
]
},
"typeCoverage": {
"atLeast": 100,
"detail": true,
"strict": true,
"ignoreCatch": true
}
}
35 changes: 27 additions & 8 deletions test/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
/**
* @typedef {import('../index.js').Options} Options
*/

import fs from 'fs'
import path from 'path'
import {bail} from 'bail'
Expand All @@ -13,25 +17,32 @@ test('rehypeAutolinkHeadings', (t) => {
t.test('fixtures', (t) => {
fs.readdir(root, (error, files) => {
bail(error)
files = files.filter((d) => !isHidden(d))

t.plan(files.length)
const visible = files.filter((d) => !isHidden(d))

t.plan(visible.length)

let index = -1

while (++index < files.length) {
one(files[index])
while (++index < visible.length) {
one(visible[index])
}
})

/**
* @param {string} fixture
*/
function one(fixture) {
const base = path.join(root, fixture)
const input = readSync(path.join(base, 'input.html'))
const output = readSync(path.join(base, 'output.html'))
/** @type {Options|undefined} */
let config

try {
config = JSON.parse(fs.readFileSync(path.join(base, 'config.json')))
config = JSON.parse(
String(fs.readFileSync(path.join(base, 'config.json')))
)
} catch {}

t.test(fixture, (t) => {
Expand All @@ -56,17 +67,25 @@ test('rehypeAutolinkHeadings', (t) => {
.use(rehypeAutolinkHeadings, {
behavior: 'after',
group: (node) => {
t.equal(node.properties.id, 'a', 'should pass `node` to `group`')
t.equal(
node.properties && node.properties.id,
'a',
'should pass `node` to `group`'
)
return {type: 'element', tagName: 'div', properties: {}, children: []}
},
content: (node) => {
t.equal(node.properties.id, 'a', 'should pass `node` to `content`')
t.equal(
node.properties && node.properties.id,
'a',
'should pass `node` to `content`'
)
return {type: 'element', tagName: 'i', properties: {}, children: []}
}
})
.process('<h1 id=a>b</h1>', (error, file) => {
t.deepEqual(
[error, file.messages.length, String(file)],
[error, (file || {messages: []}).messages.length, String(file)],
[null, 0, '<div><h1 id="a">b</h1><a href="#a"><i></i></a></div>']
)
})
Expand Down
16 changes: 16 additions & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"include": ["*.js", "test/**/*.js"],
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"module": "ES2020",
"moduleResolution": "node",
"allowJs": true,
"checkJs": true,
"declaration": true,
"emitDeclarationOnly": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"strict": true
}
}
41 changes: 0 additions & 41 deletions types/index.d.ts

This file was deleted.

Loading

0 comments on commit 779dc5a

Please sign in to comment.