docs: validate hashes exist when using {% url %} tag helper

This commit is contained in:
Brian Mann
2017-06-09 12:44:59 -04:00
parent 2d8b578f10
commit 81e4cbf1b4
4 changed files with 154 additions and 31 deletions

View File

@@ -1,7 +1,11 @@
const _ = require("lodash")
const _ = require('lodash')
const fs = require('hexo-fs')
const url = require('url')
const path = require('path')
const cheerio = require('cheerio')
const Promise = require('bluebird')
const request = require('request-promise')
const errors = require('request-promise/errors')
const startsWithHttpRe = /^http/
const everythingAfterHashRe = /(#.+)/
@@ -10,21 +14,54 @@ function isExternalHref (str) {
return startsWithHttpRe.test(str)
}
function stripHashes (str) {
function stripHash (str) {
return str.replace(everythingAfterHashRe, '')
}
function extractHash (str) {
const matches = everythingAfterHashRe.exec(str)
// return the hash match or empty string
return (matches && matches[0]) || ''
}
function validateExternalUrl (href) {
function assertHashIsPresent (descriptor, source, hash, html) {
// verify that the hash is present on this page
const $ = cheerio.load(html)
// hash starts with a '#'
if (!$(hash).length) {
const truncated = _.truncate(html, { length: 200 }) || '""'
// if we dont have a hash
throw new Error(`Constructing {% url %} tag helper failed
> The source file was: ${source}
> You referenced a hash that does not exist at: ${descriptor}
> Expected to find an element matching the id: ${hash}
> The HTML response body was:
${truncated}
`)
}
}
function validateExternalUrl (href, source) {
const { hash } = url.parse(href)
return request(href)
.promise()
.return(href)
.catch((err) => {
.then((html) => {
// bail if we dont have a hash
if (!hash) {
return
}
assertHashIsPresent(href, source, hash, html)
})
.catch(errors.StatusCodeError, (err) => {
err.message = `Request to: ${href} failed. (Status Code ${err.statusCode})`
throw err
})
@@ -67,7 +104,7 @@ function normalizeNestedPaths (data) {
function findFileBySource (sidebar, href) {
const { expanded, flattened } = normalizeNestedPaths(sidebar)
href = stripHashes(href)
href = stripHash(href)
function property () {
// drill into the original sidebar object
@@ -79,31 +116,59 @@ function findFileBySource (sidebar, href) {
return flattened[href] || property()
}
function findUrlByFile (sidebar, href) {
function getLocalFile (sidebar, href) {
// get the resolve path to the file
// cypress-101 -> guides/core-concepts/cypress-101.html
const pathToFile = findFileBySource(sidebar, href)
const hash = extractHash(href)
if (pathToFile) {
// ensure this physically exists on disk
// inside of './source' folder
return fs.stat(
return fs.readFile(
path.resolve('source', pathToFile.replace('.html', '.md'))
)
.return(`/${pathToFile}${hash}`)
).then((str) => {
// return an array with
// path to file, and str bytes
return [pathToFile, str]
})
}
throw new Error(`Could not find a valid doc file in the sidebar.yml for: ${href}`)
return Promise.reject(
new Error(`Could not find a valid doc file in the sidebar.yml for: ${href}`)
)
}
function validateAndGetUrl (sidebar, href) {
function validateLocalFile (sidebar, href, source, render) {
const hash = extractHash(href)
return getLocalFile(sidebar, stripHash(href))
.spread((pathToFile, str) => {
if (hash) {
// if we have a hash then render
// the markdown contents so we can
// ensure it has the hash present!
return render(str)
.then((html) => {
assertHashIsPresent(pathToFile, source, hash, html)
return pathToFile
})
}
return pathToFile
})
.then((pathToFile) => {
return `/${pathToFile}${hash}`
})
}
function validateAndGetUrl (sidebar, href, source, render) {
if (isExternalHref(href)) {
return validateExternalUrl(href)
return validateExternalUrl(href, source)
.return(href)
}
return findUrlByFile(sidebar, href)
return validateLocalFile(sidebar, href, source, render)
}
module.exports = {
@@ -111,7 +176,9 @@ module.exports = {
findFileBySource,
findUrlByFile,
getLocalFile,
validateLocalFile,
validateAndGetUrl,
}

View File

@@ -25,6 +25,7 @@
},
"devDependencies": {
"chai": "^4.0.2",
"cheerio": "^1.0.0-rc.1",
"colors": "^1.1.2",
"fs-extra": "^3.0.1",
"glob": "^7.1.1",

View File

@@ -91,7 +91,13 @@ hexo.extend.tag.register('url', function (args) {
attrs.target = '_blank'
}
return urlGenerator.validateAndGetUrl(sidebar, attrs.href)
// onRender callback to generate
// the markdown for each internal document
const onRender = (text) => {
return hexo.render.render({ text, engine: 'markdown' })
}
return urlGenerator.validateAndGetUrl(sidebar, attrs.href, this.full_source, onRender)
.then((href) => {
attrs.href = href

View File

@@ -77,21 +77,22 @@ describe "lib/url_generator", ->
"guides/cypress-basics/overview.html"
)
context ".findUrlByFile", ->
context ".getLocalFile", ->
beforeEach ->
@sandbox.stub(fs, "stat").returns(Promise.resolve())
it "stats file", ->
urlGenerator.findUrlByFile(data, "as")
.then (pathToFile) ->
expect(fs.stat).to.be.calledWith(path.resolve("source/api/commands/as.md"))
expect(pathToFile).to.eq("/api/commands/as.html")
it "requests file", ->
urlGenerator.getLocalFile(data, "as")
.spread (pathToFile, str) ->
expect(pathToFile).to.eq("api/commands/as.html")
expect(str).to.be.a("string")
it "throws when cannot find file", ->
fn = ->
urlGenerator.findUrlByFile(data, "foo")
expect(fn).to.throw("Could not find a valid doc file in the sidebar.yml for: foo")
urlGenerator.getLocalFile(data, "foo")
.then ->
throw new Error("should have caught error")
.catch (err) ->
expect(err.message).to.include("Could not find a valid doc file in the sidebar.yml for: foo")
context ".validateAndGetUrl", ->
it "verifies external url", ->
@@ -114,7 +115,55 @@ describe "lib/url_generator", ->
.catch (err) ->
expect(err.message).to.include("Request to: https://www.google.com failed. (Status Code 500)")
it "returns absolute path to file", ->
urlGenerator.validateAndGetUrl(data, "and#notes")
it "verifies local file", ->
markdown = "## Notes\nfoobarbaz"
render = (str) ->
expect(str).to.eq(markdown)
return Promise.resolve("<html><div id='notes'>notes</div></html>")
@sandbox.stub(fs, "readFile").returns(Promise.resolve(markdown))
urlGenerator.validateAndGetUrl(data, "and#notes", "", render)
.then (pathToFile) ->
expect(pathToFile).to.eq("/api/commands/and.html#notes")
it "fails when hash is not present in response", ->
nock("https://www.google.com")
.get("/")
.reply(200, "<html></html>")
urlGenerator.validateAndGetUrl(data, "https://www.google.com/#foo", "bar.md")
.then ->
throw new Error("should have caught error")
.catch (err) ->
[
"Constructing {% url %} tag helper failed"
"The source file was: bar.md"
"You referenced a hash that does not exist at: https://www.google.com/",
"Expected to find an element matching the id: #foo"
"The HTML response body was:"
"<html></html>"
].forEach (msg) ->
expect(err.message).to.include(msg)
it "fails when hash is not present in local file", ->
render = (str) ->
return Promise.resolve("<html></html>")
@sandbox.stub(fs, "readFile").returns(Promise.resolve(""))
urlGenerator.validateAndGetUrl(data, "and#foo", "guides/core-concepts/bar.md", render)
.then ->
throw new Error("should have caught error")
.catch (err) ->
[
"Constructing {% url %} tag helper failed"
"The source file was: guides/core-concepts/bar.md"
"You referenced a hash that does not exist at: api/commands/and.html",
"Expected to find an element matching the id: #foo"
"The HTML response body was:"
"<html></html>"
].forEach (msg) ->
expect(err.message).to.include(msg)