renderer.mjs 8.2 KB

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