renderer.js 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. const _ = require('lodash')
  2. const cheerio = require('cheerio')
  3. const uslug = require('uslug')
  4. const pageHelper = require('../../../helpers/page')
  5. const URL = require('url').URL
  6. const mustacheRegExp = /(\{|{?){2}(.+?)(\}|}?){2}/i
  7. /* global WIKI */
  8. module.exports = {
  9. async render() {
  10. const $ = cheerio.load(this.input, {
  11. decodeEntities: true
  12. })
  13. if ($.root().children().length < 1) {
  14. return ''
  15. }
  16. // --------------------------------
  17. // STEP: PRE
  18. // --------------------------------
  19. for (let child of _.reject(this.children, ['step', 'post'])) {
  20. const renderer = require(`../${_.kebabCase(child.key)}/renderer.js`)
  21. await renderer.init($, child.config)
  22. }
  23. // --------------------------------
  24. // Detect internal / external links
  25. // --------------------------------
  26. let internalRefs = []
  27. const reservedPrefixes = /^\/[a-z]\//i
  28. const exactReservedPaths = /^\/[a-z]$/i
  29. const isHostSet = WIKI.config.host.length > 7 && WIKI.config.host !== 'http://'
  30. if (!isHostSet) {
  31. WIKI.logger.warn('Host is not set. You must set the Site Host under General in the Administration Area!')
  32. }
  33. $('a').each((i, elm) => {
  34. let href = $(elm).attr('href')
  35. // -> Ignore empty / anchor links, e-mail addresses, and telephone numbers
  36. if (!href || href.length < 1 || href.indexOf('#') === 0 ||
  37. href.indexOf('mailto:') === 0 || href.indexOf('tel:') === 0) {
  38. return
  39. }
  40. // -> Strip host from local links
  41. if (isHostSet && href.indexOf(`${WIKI.config.host}/`) === 0) {
  42. href = href.replace(WIKI.config.host, '')
  43. }
  44. // -> Assign local / external tag
  45. if (href.indexOf('://') < 0) {
  46. // -> Remove trailing slash
  47. if (_.endsWith('/')) {
  48. href = href.slice(0, -1)
  49. }
  50. // -> Check for system prefix
  51. if (reservedPrefixes.test(href) || exactReservedPaths.test(href)) {
  52. $(elm).addClass(`is-system-link`)
  53. } else if (href.indexOf('.') >= 0) {
  54. $(elm).addClass(`is-asset-link`)
  55. } else {
  56. let pagePath = null
  57. // -> Add locale prefix if using namespacing
  58. if (WIKI.config.lang.namespacing) {
  59. // -> Reformat paths
  60. if (href.indexOf('/') !== 0) {
  61. if (this.config.absoluteLinks) {
  62. href = `/${this.page.localeCode}/${href}`
  63. } else {
  64. href = (this.page.path === 'home') ? `/${this.page.localeCode}/${href}` : `/${this.page.localeCode}/${this.page.path}/${href}`
  65. }
  66. } else if (href.charAt(3) !== '/') {
  67. href = `/${this.page.localeCode}${href}`
  68. }
  69. try {
  70. const parsedUrl = new URL(`http://x${href}`)
  71. pagePath = pageHelper.parsePath(parsedUrl.pathname)
  72. } catch (err) {
  73. return
  74. }
  75. } else {
  76. // -> Reformat paths
  77. if (href.indexOf('/') !== 0) {
  78. if (this.config.absoluteLinks) {
  79. href = `/${href}`
  80. } else {
  81. href = (this.page.path === 'home') ? `/${href}` : `/${this.page.path}/${href}`
  82. }
  83. }
  84. try {
  85. const parsedUrl = new URL(`http://x${href}`)
  86. pagePath = pageHelper.parsePath(parsedUrl.pathname)
  87. } catch (err) {
  88. return
  89. }
  90. }
  91. // -> Save internal references
  92. internalRefs.push({
  93. localeCode: pagePath.locale,
  94. path: pagePath.path
  95. })
  96. $(elm).addClass(`is-internal-link`)
  97. }
  98. } else {
  99. $(elm).addClass(`is-external-link`)
  100. if (this.config.openExternalLinkNewTab) {
  101. $(elm).attr('target', '_blank')
  102. $(elm).attr('rel', this.config.relAttributeExternalLink)
  103. }
  104. }
  105. // -> Update element
  106. $(elm).attr('href', href)
  107. })
  108. // --------------------------------
  109. // Detect internal link states
  110. // --------------------------------
  111. const pastLinks = await this.page.$relatedQuery('links')
  112. if (internalRefs.length > 0) {
  113. // -> Find matching pages
  114. const results = await WIKI.models.pages.query().column('id', 'path', 'localeCode').where(builder => {
  115. internalRefs.forEach((ref, idx) => {
  116. if (idx < 1) {
  117. builder.where(ref)
  118. } else {
  119. builder.orWhere(ref)
  120. }
  121. })
  122. })
  123. // -> Apply tag to internal links for found pages
  124. $('a.is-internal-link').each((i, elm) => {
  125. const href = $(elm).attr('href')
  126. let hrefObj = {}
  127. try {
  128. const parsedUrl = new URL(`http://x${href}`)
  129. hrefObj = pageHelper.parsePath(parsedUrl.pathname)
  130. } catch (err) {
  131. return
  132. }
  133. if (_.some(results, r => {
  134. return r.localeCode === hrefObj.locale && r.path === hrefObj.path
  135. })) {
  136. $(elm).addClass(`is-valid-page`)
  137. } else {
  138. $(elm).addClass(`is-invalid-page`)
  139. }
  140. })
  141. // -> Add missing links
  142. const missingLinks = _.differenceWith(internalRefs, pastLinks, (nLink, pLink) => {
  143. return nLink.localeCode === pLink.localeCode && nLink.path === pLink.path
  144. })
  145. if (missingLinks.length > 0) {
  146. if (WIKI.config.db.type === 'postgres') {
  147. await WIKI.models.pageLinks.query().insert(missingLinks.map(lnk => ({
  148. pageId: this.page.id,
  149. path: lnk.path,
  150. localeCode: lnk.localeCode
  151. })))
  152. } else {
  153. for (const lnk of missingLinks) {
  154. await WIKI.models.pageLinks.query().insert({
  155. pageId: this.page.id,
  156. path: lnk.path,
  157. localeCode: lnk.localeCode
  158. })
  159. }
  160. }
  161. }
  162. }
  163. // -> Remove outdated links
  164. if (pastLinks) {
  165. const outdatedLinks = _.differenceWith(pastLinks, internalRefs, (nLink, pLink) => {
  166. return nLink.localeCode === pLink.localeCode && nLink.path === pLink.path
  167. })
  168. if (outdatedLinks.length > 0) {
  169. await WIKI.models.pageLinks.query().delete().whereIn('id', _.map(outdatedLinks, 'id'))
  170. }
  171. }
  172. // --------------------------------
  173. // Add header handles
  174. // --------------------------------
  175. let headers = []
  176. $('h1,h2,h3,h4,h5,h6').each((i, elm) => {
  177. let headerSlug = uslug($(elm).text())
  178. // -> If custom ID is defined, try to use that instead
  179. if ($(elm).attr('id')) {
  180. headerSlug = $(elm).attr('id')
  181. }
  182. // -> Cannot start with a number (CSS selector limitation)
  183. if (headerSlug.match(/^\d/)) {
  184. headerSlug = `h-${headerSlug}`
  185. }
  186. // -> Make sure header is unique
  187. if (headers.indexOf(headerSlug) >= 0) {
  188. let isUnique = false
  189. let hIdx = 1
  190. while (!isUnique) {
  191. const headerSlugTry = `${headerSlug}-${hIdx}`
  192. if (headers.indexOf(headerSlugTry) < 0) {
  193. isUnique = true
  194. headerSlug = headerSlugTry
  195. }
  196. hIdx++
  197. }
  198. }
  199. // -> Add anchor
  200. $(elm).attr('id', headerSlug).addClass('toc-header')
  201. $(elm).prepend(`<a class="toc-anchor" href="#${headerSlug}">&#xB6;</a> `)
  202. headers.push(headerSlug)
  203. })
  204. // --------------------------------
  205. // Wrap non-empty root text nodes
  206. // --------------------------------
  207. $('body').contents().toArray().forEach(item => {
  208. if (item && item.type === 'text' && item.parent.name === 'body' && item.data !== `\n` && item.data !== `\r`) {
  209. $(item).wrap('<div></div>')
  210. }
  211. })
  212. // --------------------------------
  213. // Escape mustache expresions
  214. // --------------------------------
  215. function iterateMustacheNode (node) {
  216. const list = $(node).contents().toArray()
  217. list.forEach(item => {
  218. if (item && item.type === 'text') {
  219. const rawText = $(item).text().replace(/\r?\n|\r/g, '')
  220. if (mustacheRegExp.test(rawText)) {
  221. $(item).parent().attr('v-pre', true)
  222. }
  223. } else {
  224. iterateMustacheNode(item)
  225. }
  226. })
  227. }
  228. iterateMustacheNode($.root())
  229. $('pre').each((idx, elm) => {
  230. $(elm).attr('v-pre', true)
  231. })
  232. // --------------------------------
  233. // STEP: POST
  234. // --------------------------------
  235. let output = decodeEscape($.html('body').replace('<body>', '').replace('</body>', ''))
  236. for (let child of _.sortBy(_.filter(this.children, ['step', 'post']), ['order'])) {
  237. const renderer = require(`../${_.kebabCase(child.key)}/renderer.js`)
  238. output = await renderer.init(output, child.config)
  239. }
  240. return output
  241. }
  242. }
  243. function decodeEscape (string) {
  244. return string.replace(/&#x([0-9a-f]{1,6});/ig, (entity, code) => {
  245. code = parseInt(code, 16)
  246. // Don't unescape ASCII characters, assuming they're encoded for a good reason
  247. if (code < 0x80) return entity
  248. return String.fromCodePoint(code)
  249. })
  250. }