renderer.js 8.3 KB

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