@zkochan/pnpm
Version:
A fast implementation of npm install
225 lines (190 loc) • 5.79 kB
JavaScript
'use strict'
/* eslint-disable no-cond-assign */
const marked = require('marked')
const normalize = require('path').normalize
const stripMarkdown = require('./helpers/strip_markdown')
const assign = Object.assign
const slugify = require('./slugify')
const tocifyPage = require('./tocify_page')
/**
* Internal: builds the table of contents out of `markdown` text.
* It is also responsible for determining the final name of the `.md` files.
*
* `files` is needed to set `headings`.
* `docs` is used to strip out paths.
*
* markdown = '* [Docpress](../README.md)'
*
* tocify(markdown, files, { docs: 'docs' })
* => { sections:
* [ { title: 'Docpress',
* source: 'README.md',
* url: 'index.html',
* slug: 'index',
* headings: headings,
* expand: false|true, // true if bold
* sections: [ ... ] } ]
*
* _.sections[0].headings
* => [ { title: 'Usage', depth: 2, id: 'usage' },
* { title: 'Installation', depth: 2, id: 'installation', headings: [
* { title: 'via npm', depth: 3, id: 'via-npm' },
* { title: 'via Bower', depth: 3, id: 'via-bower' }
* ]},
* { title: 'Thanks', depth: 2, id: 'thanks' } ]
*
*/
module.exports = function tocify (markdown, files, options) {
let t = new Tocify(markdown, files, options)
return t.run()
}
/**
* Internal: delegate of tocify()
*/
class Tocify {
constructor (markdown, files, options) {
this.files = files
this.options = options || {}
this.tokens = marked.lexer(markdown)
this.docs = this.options.docs || 'docs'
this.docsExpr = new RegExp('^' + this.docs + '/')
this.urls = {}
this.slugs = {}
this.sources = {}
}
run () {
var re = { sections: [] }
var crumbs = [scope]
var current = re
var scope
var i = 0
this.tokens.forEach((token) => {
switch (token.type) {
case 'list_start':
scope = current.sections = []
crumbs.push(scope)
break
case 'text':
current = this.itemify(token.text, i++)
scope.push(current)
this.urls[current.url] = current
this.sources[current.source] = current
this.slugs[current.slug] = current
break
case 'list_end':
crumbs.pop()
scope = crumbs[crumbs.length - 1]
break
}
})
return re
}
/**
* Parses Markdown text into `title` and `source`
*
* '[page](README.md)' => { title: 'page', source: 'README.md' }
*/
parseText (text) {
let m
if (m = text.match(/^\[([^\]]*)\]\((.*)\)$/)) {
return { title: stripMarkdown(m[1]), source: m[2] }
} else if (m = text.match(/^(?:__|\*\*)\[([^\]]*)\]\((.*)\)(?:__|\*\*)$/)) {
return { title: stripMarkdown(m[1]), source: m[2], expand: true }
} else {
return { title: stripMarkdown(text) }
}
}
/**
* Internal: turns a token text (like `[README](../README.md)`) into an item in
* the table of contents. Used by `tocify()`.
*
* Sets:
*
* - `title`
* - `source`
* - `expand`
* - `anchor`
*/
itemify (text, i) {
const item = {}
let unique = true
// Add `source`, `title`, `expand`
assign(item, this.parseText(text))
if (!item.source) return item
// `anchor`: Adds anchor (if needed)
assign(item, anchorize(item.source))
// `source`: Takes care of relative (../) paths
assign(item, absolutify(item.source, this.docs))
if (this.sources[item.source]) {
// If this same item exists before, reuse its URL and stuff
let previous = this.sources[item.source]
assign(item, { url: previous.url })
unique = false
} else {
// set `url`
item.url = this.urlify(item.source, i)
item.url = declash(item.url, this.urls, '.html')
}
// set `slug`
item.slug = slugify(item.url)
item.slug = declash(item.slug, this.slugs, '')
// set `headings`
if (unique && this.files && this.files[item.source]) {
const headings = tocifyPage(this.files[item.source].contents)
if (headings) item.headings = headings
}
return item
}
/**
* Converts a source filename to a URL.
*
* "docs/foo/x.md" => "foo/x.html"
*/
urlify (source, i) {
// Use 'index.html' for the first thing on the menu, always.
if (i === 0) return 'index.html'
return source.replace(/\.md$/, '.html')
.replace(/README\.html$/, 'index.html')
.replace(this.docsExpr, '')
}
}
/**
* Internal: return a URL based from `baseUrl` that isn't in URLs.
*
* declash('hi.html', { 'index.html': 1 }, '.html') //=> 'hi.html'
* declash('index.html', { 'index.html': 1 }, '.html') //=> 'index-2.html'
*/
function declash (baseUrl, urls, extension) {
if (!urls[baseUrl]) return baseUrl
// Separate into `basename` / `extension`
let basename = baseUrl.substr(0, baseUrl.length - extension.length)
let url = baseUrl
for (let i = 2; urls[url]; i++) { url = `${basename}-${i}${extension}` }
return url
}
/**
* Internal: separates a link URL to URL (`source`) and `anchor`.
*
* "foo.md#top" => { source: "foo.md", anchor: "#top" }
*/
function anchorize (source) {
let m
if (m = source.match(/^([^#]*)(#.*)$/)) {
return { source: m[1], anchor: m[2] }
} else {
return {}
}
}
/*
* Internal: takes care of relative paths.
*
* absolutify("../README.md", "docs") => "README.md"
* absolutify("/README.md", "docs") => "README.md"
*/
function absolutify (source, root) {
if (source.substr(0, 1) !== '/') {
source = normalize(root + '/' + source)
}
source = source.replace(/^\//, '')
return { source }
}