renderer.mjs 8.7 KB

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