Jelajahi Sumber

feat: markdown server rendering + use editor config + change password

NGPixel 2 tahun lalu
induk
melakukan
aeef7b1e53
59 mengubah file dengan 1531 tambahan dan 446 penghapusan
  1. 1 0
      server/db/migrations/3.0.0.mjs
  2. 28 4
      server/graph/resolvers/user.mjs
  3. 1 2
      server/graph/schemas/page.graphql
  4. 2 0
      server/graph/schemas/site.graphql
  5. 4 3
      server/graph/schemas/user.graphql
  6. 0 4
      server/models/pages.mjs
  7. 2 2
      server/models/renderers.mjs
  8. 1 0
      server/models/sites.mjs
  9. 11 23
      server/models/users.mjs
  10. 1 2
      server/modules/rendering/asciinema/definition.yml
  11. 0 0
      server/modules/rendering/asciinema/renderer.mjs
  12. 1 2
      server/modules/rendering/blockquotes/definition.yml
  13. 0 0
      server/modules/rendering/blockquotes/renderer.mjs
  14. 1 2
      server/modules/rendering/codehighlighter/definition.yml
  15. 0 0
      server/modules/rendering/codehighlighter/renderer.mjs
  16. 3 5
      server/modules/rendering/core/definition.yml
  17. 286 0
      server/modules/rendering/core/renderer.mjs
  18. 1 2
      server/modules/rendering/diagram/definition.yml
  19. 0 0
      server/modules/rendering/diagram/renderer.mjs
  20. 0 288
      server/modules/rendering/html-core/renderer.mjs
  21. 1 2
      server/modules/rendering/image-prefetch/definition.yml
  22. 0 0
      server/modules/rendering/image-prefetch/renderer.mjs
  23. 1 2
      server/modules/rendering/mediaplayers/definition.yml
  24. 0 0
      server/modules/rendering/mediaplayers/renderer.mjs
  25. 1 2
      server/modules/rendering/mermaid/definition.yml
  26. 0 0
      server/modules/rendering/mermaid/renderer.mjs
  27. 1 2
      server/modules/rendering/security/definition.yml
  28. 0 0
      server/modules/rendering/security/renderer.mjs
  29. 1 2
      server/modules/rendering/tabset/definition.yml
  30. 0 0
      server/modules/rendering/tabset/renderer.mjs
  31. 1 2
      server/modules/rendering/twemoji/definition.yml
  32. 0 0
      server/modules/rendering/twemoji/renderer.mjs
  33. 80 11
      server/package-lock.json
  34. 4 2
      server/package.json
  35. 157 0
      server/renderers/markdown.mjs
  36. 145 0
      server/renderers/modules/katex.mjs
  37. 143 0
      server/renderers/modules/kroki.mjs
  38. 12 0
      server/renderers/modules/markdown-it-underline.mjs
  39. 187 0
      server/renderers/modules/plantuml.mjs
  40. 24 8
      server/tasks/workers/render-page.mjs
  41. 1 0
      ux/public/_assets/icons/ultraviolet-lowercase.svg
  42. 1 2
      ux/src/components/AuthLoginPanel.vue
  43. 3 2
      ux/src/components/EditorMarkdown.vue
  44. 4 1
      ux/src/components/EditorMarkdownConfigOverlay.vue
  45. 1 6
      ux/src/components/PageHeader.vue
  46. 6 2
      ux/src/components/PageNewMenu.vue
  47. 10 10
      ux/src/components/UserChangePwdDialog.vue
  48. 27 5
      ux/src/components/UserEditOverlay.vue
  49. 4 2
      ux/src/components/WelcomeOverlay.vue
  50. 6 2
      ux/src/i18n/locales/en.json
  51. 15 0
      ux/src/layouts/ProfileLayout.vue
  52. 37 20
      ux/src/pages/AdminGeneral.vue
  53. 0 2
      ux/src/pages/AdminStorage.vue
  54. 6 1
      ux/src/pages/AdminUsers.vue
  55. 71 12
      ux/src/renderers/markdown.js
  56. 143 0
      ux/src/renderers/modules/kroki.mjs
  57. 57 1
      ux/src/stores/editor.js
  58. 23 5
      ux/src/stores/page.js
  59. 15 1
      ux/src/stores/user.js

+ 1 - 0
server/db/migrations/3.0.0.mjs

@@ -543,6 +543,7 @@ export async function up (knex) {
       contentLicense: '',
       footerExtra: '',
       pageExtensions: ['md', 'html', 'txt'],
+      pageCasing: true,
       defaults: {
         tocDepth: {
           min: 1,

+ 28 - 4
server/graph/resolvers/user.mjs

@@ -1,5 +1,5 @@
 import { generateError, generateSuccess } from '../../helpers/graph.mjs'
-import _ from 'lodash-es'
+import _, { isNil } from 'lodash-es'
 import path from 'node:path'
 import fs from 'fs-extra'
 
@@ -139,7 +139,6 @@ export default {
     },
     async updateUser (obj, args) {
       try {
-        console.info(args.id)
         await WIKI.db.users.updateUser(args.id, args.patch)
 
         return {
@@ -210,8 +209,33 @@ export default {
         return generateError(err)
       }
     },
-    resetUserPassword (obj, args) {
-      return false
+    async changeUserPassword (obj, args, context) {
+      try {
+        if (args.newPassword?.length < 6) {
+          throw new Error('ERR_PASSWORD_TOO_SHORT')
+        }
+
+        const usr = await WIKI.db.users.query().findById(args.id)
+        if (!usr) {
+          throw new Error('ERR_USER_NOT_FOUND')
+        }
+        const localAuth = await WIKI.db.authentication.getStrategy('local')
+
+        usr.auth[localAuth.id].password = await bcrypt.hash(args.newPassword, 12)
+        if (!isNil(args.mustChangePassword)) {
+          usr.auth[localAuth.id].mustChangePwd = args.mustChangePassword
+        }
+
+        await WIKI.db.users.query().patch({
+          auth: usr.auth
+        }).findById(args.id)
+
+        return {
+          operation: generateSuccess('User password updated successfully')
+        }
+      } catch (err) {
+        return generateError(err)
+      }
     },
     async updateProfile (obj, args, context) {
       try {

+ 1 - 2
server/graph/schemas/page.graphql

@@ -85,7 +85,6 @@ extend type Mutation {
     publishEndDate: Date
     publishStartDate: Date
     relations: [PageRelationInput!]
-    render: String
     scriptCss: String
     scriptJsLoad: String
     scriptJsUnload: String
@@ -343,8 +342,8 @@ input PageUpdateInput {
   publishEndDate: Date
   publishStartDate: Date
   publishState: PagePublishState
+  reasonForChange: String
   relations: [PageRelationInput!]
-  render: String
   scriptJsLoad: String
   scriptJsUnload: String
   scriptCss: String

+ 2 - 0
server/graph/schemas/site.graphql

@@ -60,6 +60,7 @@ type Site {
   contentLicense: String
   footerExtra: String
   pageExtensions: String
+  pageCasing: Boolean
   logoText: Boolean
   sitemap: Boolean
   robots: SiteRobots
@@ -177,6 +178,7 @@ input SiteUpdateInput {
   contentLicense: String
   footerExtra: String
   pageExtensions: String
+  pageCasing: Boolean
   logoText: Boolean
   sitemap: Boolean
   robots: SiteRobotsInput

+ 4 - 3
server/graph/schemas/user.graphql

@@ -68,8 +68,10 @@ extend type Mutation {
     id: UUID!
   ): DefaultResponse
 
-  resetUserPassword(
-    id: Int!
+  changeUserPassword(
+    id: UUID!
+    newPassword: String!
+    mustChangePassword: Boolean
   ): DefaultResponse
 
   updateProfile(
@@ -186,7 +188,6 @@ enum UserCvdChoices {
 input UserUpdateInput {
   email: String
   name: String
-  newPassword: String
   groups: [UUID!]
   isActive: Boolean
   isVerified: Boolean

+ 0 - 4
server/models/pages.mjs

@@ -327,7 +327,6 @@ export class Page extends Model {
       publishEndDate: opts.publishEndDate?.toISO(),
       publishStartDate: opts.publishStartDate?.toISO(),
       relations: opts.relations ?? [],
-      render: opts.render ?? '',
       siteId: opts.siteId,
       title: opts.title,
       toc: '[]',
@@ -452,9 +451,6 @@ export class Page extends Model {
 
     if ('content' in opts.patch) {
       patch.content = opts.patch.content
-      if ('render' in opts.patch) {
-        patch.render = opts.patch.render
-      }
       historyData.affectedFields.push('content')
     }
 

+ 2 - 2
server/models/renderers.mjs

@@ -99,8 +99,8 @@ export class Renderer extends Model {
       // -> Delete removed Renderers
       for (const renderer of dbRenderers) {
         if (!some(WIKI.data.renderers, ['key', renderer.module])) {
-          await WIKI.db.renderers.query().where('module', renderer.key).del()
-          WIKI.logger.info(`Removed renderer ${renderer.key} because it is no longer present in the modules folder: [ OK ]`)
+          await WIKI.db.renderers.query().where('module', renderer.module).del()
+          WIKI.logger.info(`Removed renderer ${renderer.module} because it is no longer present in the modules folder: [ OK ]`)
         }
       }
     } catch (err) {

+ 1 - 0
server/models/sites.mjs

@@ -57,6 +57,7 @@ export class Site extends Model {
         contentLicense: '',
         footerExtra: '',
         pageExtensions: ['md', 'html', 'txt'],
+        pageCasing: true,
         defaults: {
           tocDepth: {
             min: 1,

+ 11 - 23
server/models/users.mjs

@@ -591,7 +591,7 @@ export class User extends Model {
         timezone: WIKI.config.userDefaults.timezone || 'America/New_York',
         appearance: 'site',
         dateFormat: WIKI.config.userDefaults.dateFormat || 'YYYY-MM-DD',
-        timeFormat: WIKI.config.userDefaults.timeFormat || '12h'
+        timeFormat: WIKI.config.userDefaults.timeFormat || '12h'
       }
     })
 
@@ -623,15 +623,12 @@ export class User extends Model {
    *
    * @param {Object} param0 User ID and fields to update
    */
-  static async updateUser (id, { email, name, newPassword, groups, location, jobTitle, timezone, dateFormat, appearance }) {
+  static async updateUser (id, { email, name, groups, isVerified, isActive, meta, prefs }) {
     const usr = await WIKI.db.users.query().findById(id)
     if (usr) {
       let usrData = {}
       if (!isEmpty(email) && email !== usr.email) {
-        const dupUsr = await WIKI.db.users.query().select('id').where({
-          email,
-          providerKey: usr.providerKey
-        }).first()
+        const dupUsr = await WIKI.db.users.query().select('id').where({ email }).first()
         if (dupUsr) {
           throw new WIKI.Error.AuthAccountAlreadyExists()
         }
@@ -640,12 +637,6 @@ export class User extends Model {
       if (!isEmpty(name) && name !== usr.name) {
         usrData.name = name.trim()
       }
-      if (!isEmpty(newPassword)) {
-        if (newPassword.length < 6) {
-          throw new WIKI.Error.InputInvalid('Password must be at least 6 characters!')
-        }
-        usrData.password = newPassword
-      }
       if (isArray(groups)) {
         const usrGroupsRaw = await usr.$relatedQuery('groups')
         const usrGroups = usrGroupsRaw.map(g => g.id)
@@ -660,20 +651,17 @@ export class User extends Model {
           await usr.$relatedQuery('groups').unrelate().where('groupId', grp)
         }
       }
-      if (!isEmpty(location) && location !== usr.location) {
-        usrData.location = location.trim()
-      }
-      if (!isEmpty(jobTitle) && jobTitle !== usr.jobTitle) {
-        usrData.jobTitle = jobTitle.trim()
+      if (!isNil(isVerified)) {
+        usrData.isVerified = isVerified
       }
-      if (!isEmpty(timezone) && timezone !== usr.timezone) {
-        usrData.timezone = timezone
+      if (!isNil(isActive)) {
+        usrData.isVerified = isActive
       }
-      if (!isNil(dateFormat) && dateFormat !== usr.dateFormat) {
-        usrData.dateFormat = dateFormat
+      if (!isEmpty(meta)) {
+        usrData.meta = meta
       }
-      if (!isNil(appearance) && appearance !== usr.appearance) {
-        usrData.appearance = appearance
+      if (!isEmpty(prefs)) {
+        usrData.prefs = prefs
       }
       await WIKI.db.users.query().patch(usrData).findById(id)
     } else {

+ 1 - 2
server/modules/rendering/html-asciinema/definition.yml → server/modules/rendering/asciinema/definition.yml

@@ -1,8 +1,7 @@
-key: htmlAsciinema
 title: Asciinema
 description: Embed asciinema players from compatible links
 author: requarks.io
 icon: mdi-theater
 enabledDefault: false
-dependsOn: html-core
+dependsOn: core
 props: {}

+ 0 - 0
server/modules/rendering/html-asciinema/renderer.js → server/modules/rendering/asciinema/renderer.mjs


+ 1 - 2
server/modules/rendering/html-blockquotes/definition.yml → server/modules/rendering/blockquotes/definition.yml

@@ -1,8 +1,7 @@
-key: htmlBlockquotes
 title: Blockquotes
 description: Parse blockquotes box styling
 author: requarks.io
 icon: mdi-alpha-t-box-outline
 enabledDefault: true
-dependsOn: html-core
+dependsOn: core
 props: {}

+ 0 - 0
server/modules/rendering/html-blockquotes/renderer.js → server/modules/rendering/blockquotes/renderer.mjs


+ 1 - 2
server/modules/rendering/html-codehighlighter/definition.yml → server/modules/rendering/codehighlighter/definition.yml

@@ -1,9 +1,8 @@
-key: htmlCodehighlighter
 title: Code Highlighting Post-Processor
 description: Syntax detector for programming code
 author: requarks.io
 icon: mdi-code-braces
 enabledDefault: true
-dependsOn: html-core
+dependsOn: core
 step: pre
 props: {}

+ 0 - 0
server/modules/rendering/html-codehighlighter/renderer.js → server/modules/rendering/codehighlighter/renderer.mjs


+ 3 - 5
server/modules/rendering/html-core/definition.yml → server/modules/rendering/core/definition.yml

@@ -1,9 +1,7 @@
-key: html-core
+key: core
 title: Core
 description: Basic HTML Parser
 author: requarks.io
-input: html
-output: html
 icon: mdi-language-html5
 props:
   absoluteLinks:
@@ -25,5 +23,5 @@ props:
     hint: External links with _blank attribute will have an additional rel attribute.
     order: 3
     enum:
-        - noreferrer
-        - noopener
+      - noreferrer
+      - noopener

+ 286 - 0
server/modules/rendering/core/renderer.mjs

@@ -0,0 +1,286 @@
+import { reject } from 'lodash-es'
+import * as cheerio from 'cheerio'
+import uslug from 'uslug'
+import pageHelper from '../../../helpers/page'
+import { URL } from 'node:url'
+
+const mustacheRegExp = /(\{|&#x7b;?){2}(.+?)(\}|&#x7d;?){2}/i
+
+export async function render () {
+  const $ = cheerio.load(this.input, {
+    decodeEntities: true
+  })
+
+  if ($.root().children().length < 1) {
+    return ''
+  }
+
+  // --------------------------------
+  // STEP: PRE
+  // --------------------------------
+
+  for (const child of reject(this.children, ['step', 'post'])) {
+    const renderer = (await import(`../${kebabCase(child.key)}/renderer.mjs`)).render
+    await renderer($, child.config)
+  }
+
+  // --------------------------------
+  // Detect internal / external links
+  // --------------------------------
+
+  let internalRefs = []
+  const reservedPrefixes = /^\/[a-z]\//i
+  const exactReservedPaths = /^\/[a-z]$/i
+
+  const hasHostname = this.site.hostname !== '*'
+
+  $('a').each((i, elm) => {
+    let href = $(elm).attr('href')
+
+    // -> Ignore empty / anchor links, e-mail addresses, and telephone numbers
+    if (!href || href.length < 1 || href.indexOf('#') === 0 ||
+      href.indexOf('mailto:') === 0 || href.indexOf('tel:') === 0) {
+      return
+    }
+
+    // -> Strip host from local links
+    if (hasHostname && href.indexOf(`${this.site.hostname}/`) === 0) {
+      href = href.replace(this.site.hostname, '')
+    }
+
+    // -> Assign local / external tag
+    if (href.indexOf('://') < 0) {
+      // -> Remove trailing slash
+      if (_.endsWith('/')) {
+        href = href.slice(0, -1)
+      }
+
+      // -> Check for system prefix
+      if (reservedPrefixes.test(href) || exactReservedPaths.test(href)) {
+        $(elm).addClass(`is-system-link`)
+      } else if (href.indexOf('.') >= 0) {
+        $(elm).addClass(`is-asset-link`)
+      } else {
+        let pagePath = null
+
+        // -> Add locale prefix if using namespacing
+        if (this.site.config.localeNamespacing) {
+          // -> Reformat paths
+          if (href.indexOf('/') !== 0) {
+            if (this.config.absoluteLinks) {
+              href = `/${this.page.localeCode}/${href}`
+            } else {
+              href = (this.page.path === 'home') ? `/${this.page.localeCode}/${href}` : `/${this.page.localeCode}/${this.page.path}/${href}`
+            }
+          } else if (href.charAt(3) !== '/') {
+            href = `/${this.page.localeCode}${href}`
+          }
+
+          try {
+            const parsedUrl = new URL(`http://x${href}`)
+            pagePath = pageHelper.parsePath(parsedUrl.pathname)
+          } catch (err) {
+            return
+          }
+        } else {
+          // -> Reformat paths
+          if (href.indexOf('/') !== 0) {
+            if (this.config.absoluteLinks) {
+              href = `/${href}`
+            } else {
+              href = (this.page.path === 'home') ? `/${href}` : `/${this.page.path}/${href}`
+            }
+          }
+
+          try {
+            const parsedUrl = new URL(`http://x${href}`)
+            pagePath = pageHelper.parsePath(parsedUrl.pathname)
+          } catch (err) {
+            return
+          }
+        }
+        // -> Save internal references
+        internalRefs.push({
+          localeCode: pagePath.locale,
+          path: pagePath.path
+        })
+
+        $(elm).addClass(`is-internal-link`)
+      }
+    } else {
+      $(elm).addClass(`is-external-link`)
+      if (this.config.openExternalLinkNewTab) {
+        $(elm).attr('target', '_blank')
+        $(elm).attr('rel', this.config.relAttributeExternalLink)
+      }
+    }
+
+    // -> Update element
+    $(elm).attr('href', href)
+  })
+
+  // --------------------------------
+  // Detect internal link states
+  // --------------------------------
+
+  const pastLinks = await this.page.$relatedQuery('links')
+
+  if (internalRefs.length > 0) {
+    // -> Find matching pages
+    const results = await WIKI.db.pages.query().column('id', 'path', 'localeCode').where(builder => {
+      internalRefs.forEach((ref, idx) => {
+        if (idx < 1) {
+          builder.where(ref)
+        } else {
+          builder.orWhere(ref)
+        }
+      })
+    })
+
+    // -> Apply tag to internal links for found pages
+    $('a.is-internal-link').each((i, elm) => {
+      const href = $(elm).attr('href')
+      let hrefObj = {}
+      try {
+        const parsedUrl = new URL(`http://x${href}`)
+        hrefObj = pageHelper.parsePath(parsedUrl.pathname)
+      } catch (err) {
+        return
+      }
+      if (_.some(results, r => {
+        return r.localeCode === hrefObj.locale && r.path === hrefObj.path
+      })) {
+        $(elm).addClass(`is-valid-page`)
+      } else {
+        $(elm).addClass(`is-invalid-page`)
+      }
+    })
+
+    // -> Add missing links
+    const missingLinks = _.differenceWith(internalRefs, pastLinks, (nLink, pLink) => {
+      return nLink.localeCode === pLink.localeCode && nLink.path === pLink.path
+    })
+    if (missingLinks.length > 0) {
+      if (WIKI.config.db.type === 'postgres') {
+        await WIKI.db.pageLinks.query().insert(missingLinks.map(lnk => ({
+          pageId: this.page.id,
+          path: lnk.path,
+          localeCode: lnk.localeCode
+        })))
+      } else {
+        for (const lnk of missingLinks) {
+          await WIKI.db.pageLinks.query().insert({
+            pageId: this.page.id,
+            path: lnk.path,
+            localeCode: lnk.localeCode
+          })
+        }
+      }
+    }
+  }
+
+  // -> Remove outdated links
+  if (pastLinks) {
+    const outdatedLinks = _.differenceWith(pastLinks, internalRefs, (nLink, pLink) => {
+      return nLink.localeCode === pLink.localeCode && nLink.path === pLink.path
+    })
+    if (outdatedLinks.length > 0) {
+      await WIKI.db.pageLinks.query().delete().whereIn('id', _.map(outdatedLinks, 'id'))
+    }
+  }
+
+  // --------------------------------
+  // Add header handles
+  // --------------------------------
+
+  let headers = []
+  $('h1,h2,h3,h4,h5,h6').each((i, elm) => {
+    let headerSlug = uslug($(elm).text())
+    // -> If custom ID is defined, try to use that instead
+    if ($(elm).attr('id')) {
+      headerSlug = $(elm).attr('id')
+    }
+
+    // -> Cannot start with a number (CSS selector limitation)
+    if (headerSlug.match(/^\d/)) {
+      headerSlug = `h-${headerSlug}`
+    }
+
+    // -> Make sure header is unique
+    if (headers.indexOf(headerSlug) >= 0) {
+      let isUnique = false
+      let hIdx = 1
+      while (!isUnique) {
+        const headerSlugTry = `${headerSlug}-${hIdx}`
+        if (headers.indexOf(headerSlugTry) < 0) {
+          isUnique = true
+          headerSlug = headerSlugTry
+        }
+        hIdx++
+      }
+    }
+
+    // -> Add anchor
+    $(elm).attr('id', headerSlug).addClass('toc-header')
+    $(elm).prepend(`<a class="toc-anchor" href="#${headerSlug}">&#xB6;</a> `)
+
+    headers.push(headerSlug)
+  })
+
+  // --------------------------------
+  // Wrap non-empty root text nodes
+  // --------------------------------
+
+  $('body').contents().toArray().forEach(item => {
+    if (item && item.type === 'text' && item.parent.name === 'body' && item.data !== `\n` && item.data !== `\r`) {
+      $(item).wrap('<div></div>')
+    }
+  })
+
+  // --------------------------------
+  // Escape mustache expresions
+  // --------------------------------
+
+  function iterateMustacheNode (node) {
+    const list = $(node).contents().toArray()
+    list.forEach(item => {
+      if (item && item.type === 'text') {
+        const rawText = $(item).text().replace(/\r?\n|\r/g, '')
+        if (mustacheRegExp.test(rawText)) {
+          $(item).parent().attr('v-pre', true)
+        }
+      } else {
+        iterateMustacheNode(item)
+      }
+    })
+  }
+  iterateMustacheNode($.root())
+
+  $('pre').each((idx, elm) => {
+    $(elm).attr('v-pre', true)
+  })
+
+  // --------------------------------
+  // STEP: POST
+  // --------------------------------
+
+  let output = decodeEscape($.html('body').replace('<body>', '').replace('</body>', ''))
+
+  for (let child of _.sortBy(_.filter(this.children, ['step', 'post']), ['order'])) {
+    const renderer = require(`../${_.kebabCase(child.key)}/renderer.js`)
+    output = await renderer.init(output, child.config)
+  }
+
+  return output
+}
+
+function decodeEscape (string) {
+  return string.replace(/&#x([0-9a-f]{1,6});/ig, (entity, code) => {
+    code = parseInt(code, 16)
+
+    // Don't unescape ASCII characters, assuming they're encoded for a good reason
+    if (code < 0x80) return entity
+
+    return String.fromCodePoint(code)
+  })
+}

+ 1 - 2
server/modules/rendering/html-diagram/definition.yml → server/modules/rendering/diagram/definition.yml

@@ -1,8 +1,7 @@
-key: htmlDiagram
 title: Diagrams Post-Processor
 description: HTML Processing for diagrams (draw.io)
 author: requarks.io
 icon: mdi-chart-multiline
 enabledDefault: true
-dependsOn: html-core
+dependsOn: core
 props: {}

+ 0 - 0
server/modules/rendering/html-diagram/renderer.js → server/modules/rendering/diagram/renderer.mjs


+ 0 - 288
server/modules/rendering/html-core/renderer.mjs

@@ -1,288 +0,0 @@
-const _ = require('lodash')
-const cheerio = require('cheerio')
-const uslug = require('uslug')
-const pageHelper = require('../../../helpers/page')
-const URL = require('url').URL
-
-const mustacheRegExp = /(\{|&#x7b;?){2}(.+?)(\}|&#x7d;?){2}/i
-
-export default {
-  async render() {
-    const $ = cheerio.load(this.input, {
-      decodeEntities: true
-    })
-
-    if ($.root().children().length < 1) {
-      return ''
-    }
-
-    // --------------------------------
-    // STEP: PRE
-    // --------------------------------
-
-    for (let child of _.reject(this.children, ['step', 'post'])) {
-      const renderer = require(`../${child.key}/renderer.mjs`)
-      await renderer.init($, child.config)
-    }
-
-    // --------------------------------
-    // Detect internal / external links
-    // --------------------------------
-
-    let internalRefs = []
-    const reservedPrefixes = /^\/[a-z]\//i
-    const exactReservedPaths = /^\/[a-z]$/i
-
-    const hasHostname = this.site.hostname !== '*'
-
-    $('a').each((i, elm) => {
-      let href = $(elm).attr('href')
-
-      // -> Ignore empty / anchor links, e-mail addresses, and telephone numbers
-      if (!href || href.length < 1 || href.indexOf('#') === 0 ||
-        href.indexOf('mailto:') === 0 || href.indexOf('tel:') === 0) {
-        return
-      }
-
-      // -> Strip host from local links
-      if (hasHostname && href.indexOf(`${this.site.hostname}/`) === 0) {
-        href = href.replace(this.site.hostname, '')
-      }
-
-      // -> Assign local / external tag
-      if (href.indexOf('://') < 0) {
-        // -> Remove trailing slash
-        if (_.endsWith('/')) {
-          href = href.slice(0, -1)
-        }
-
-        // -> Check for system prefix
-        if (reservedPrefixes.test(href) || exactReservedPaths.test(href)) {
-          $(elm).addClass(`is-system-link`)
-        } else if (href.indexOf('.') >= 0) {
-          $(elm).addClass(`is-asset-link`)
-        } else {
-          let pagePath = null
-
-          // -> Add locale prefix if using namespacing
-          if (this.site.config.localeNamespacing) {
-            // -> Reformat paths
-            if (href.indexOf('/') !== 0) {
-              if (this.config.absoluteLinks) {
-                href = `/${this.page.localeCode}/${href}`
-              } else {
-                href = (this.page.path === 'home') ? `/${this.page.localeCode}/${href}` : `/${this.page.localeCode}/${this.page.path}/${href}`
-              }
-            } else if (href.charAt(3) !== '/') {
-              href = `/${this.page.localeCode}${href}`
-            }
-
-            try {
-              const parsedUrl = new URL(`http://x${href}`)
-              pagePath = pageHelper.parsePath(parsedUrl.pathname)
-            } catch (err) {
-              return
-            }
-          } else {
-            // -> Reformat paths
-            if (href.indexOf('/') !== 0) {
-              if (this.config.absoluteLinks) {
-                href = `/${href}`
-              } else {
-                href = (this.page.path === 'home') ? `/${href}` : `/${this.page.path}/${href}`
-              }
-            }
-
-            try {
-              const parsedUrl = new URL(`http://x${href}`)
-              pagePath = pageHelper.parsePath(parsedUrl.pathname)
-            } catch (err) {
-              return
-            }
-          }
-          // -> Save internal references
-          internalRefs.push({
-            localeCode: pagePath.locale,
-            path: pagePath.path
-          })
-
-          $(elm).addClass(`is-internal-link`)
-        }
-      } else {
-        $(elm).addClass(`is-external-link`)
-        if (this.config.openExternalLinkNewTab) {
-          $(elm).attr('target', '_blank')
-          $(elm).attr('rel', this.config.relAttributeExternalLink)
-        }
-      }
-
-      // -> Update element
-      $(elm).attr('href', href)
-    })
-
-    // --------------------------------
-    // Detect internal link states
-    // --------------------------------
-
-    const pastLinks = await this.page.$relatedQuery('links')
-
-    if (internalRefs.length > 0) {
-      // -> Find matching pages
-      const results = await WIKI.db.pages.query().column('id', 'path', 'localeCode').where(builder => {
-        internalRefs.forEach((ref, idx) => {
-          if (idx < 1) {
-            builder.where(ref)
-          } else {
-            builder.orWhere(ref)
-          }
-        })
-      })
-
-      // -> Apply tag to internal links for found pages
-      $('a.is-internal-link').each((i, elm) => {
-        const href = $(elm).attr('href')
-        let hrefObj = {}
-        try {
-          const parsedUrl = new URL(`http://x${href}`)
-          hrefObj = pageHelper.parsePath(parsedUrl.pathname)
-        } catch (err) {
-          return
-        }
-        if (_.some(results, r => {
-          return r.localeCode === hrefObj.locale && r.path === hrefObj.path
-        })) {
-          $(elm).addClass(`is-valid-page`)
-        } else {
-          $(elm).addClass(`is-invalid-page`)
-        }
-      })
-
-      // -> Add missing links
-      const missingLinks = _.differenceWith(internalRefs, pastLinks, (nLink, pLink) => {
-        return nLink.localeCode === pLink.localeCode && nLink.path === pLink.path
-      })
-      if (missingLinks.length > 0) {
-        if (WIKI.config.db.type === 'postgres') {
-          await WIKI.db.pageLinks.query().insert(missingLinks.map(lnk => ({
-            pageId: this.page.id,
-            path: lnk.path,
-            localeCode: lnk.localeCode
-          })))
-        } else {
-          for (const lnk of missingLinks) {
-            await WIKI.db.pageLinks.query().insert({
-              pageId: this.page.id,
-              path: lnk.path,
-              localeCode: lnk.localeCode
-            })
-          }
-        }
-      }
-    }
-
-    // -> Remove outdated links
-    if (pastLinks) {
-      const outdatedLinks = _.differenceWith(pastLinks, internalRefs, (nLink, pLink) => {
-        return nLink.localeCode === pLink.localeCode && nLink.path === pLink.path
-      })
-      if (outdatedLinks.length > 0) {
-        await WIKI.db.pageLinks.query().delete().whereIn('id', _.map(outdatedLinks, 'id'))
-      }
-    }
-
-    // --------------------------------
-    // Add header handles
-    // --------------------------------
-
-    let headers = []
-    $('h1,h2,h3,h4,h5,h6').each((i, elm) => {
-      let headerSlug = uslug($(elm).text())
-      // -> If custom ID is defined, try to use that instead
-      if ($(elm).attr('id')) {
-        headerSlug = $(elm).attr('id')
-      }
-
-      // -> Cannot start with a number (CSS selector limitation)
-      if (headerSlug.match(/^\d/)) {
-        headerSlug = `h-${headerSlug}`
-      }
-
-      // -> Make sure header is unique
-      if (headers.indexOf(headerSlug) >= 0) {
-        let isUnique = false
-        let hIdx = 1
-        while (!isUnique) {
-          const headerSlugTry = `${headerSlug}-${hIdx}`
-          if (headers.indexOf(headerSlugTry) < 0) {
-            isUnique = true
-            headerSlug = headerSlugTry
-          }
-          hIdx++
-        }
-      }
-
-      // -> Add anchor
-      $(elm).attr('id', headerSlug).addClass('toc-header')
-      $(elm).prepend(`<a class="toc-anchor" href="#${headerSlug}">&#xB6;</a> `)
-
-      headers.push(headerSlug)
-    })
-
-    // --------------------------------
-    // Wrap non-empty root text nodes
-    // --------------------------------
-
-    $('body').contents().toArray().forEach(item => {
-      if (item && item.type === 'text' && item.parent.name === 'body' && item.data !== `\n` && item.data !== `\r`) {
-        $(item).wrap('<div></div>')
-      }
-    })
-
-    // --------------------------------
-    // Escape mustache expresions
-    // --------------------------------
-
-    function iterateMustacheNode (node) {
-      const list = $(node).contents().toArray()
-      list.forEach(item => {
-        if (item && item.type === 'text') {
-          const rawText = $(item).text().replace(/\r?\n|\r/g, '')
-          if (mustacheRegExp.test(rawText)) {
-            $(item).parent().attr('v-pre', true)
-          }
-        } else {
-          iterateMustacheNode(item)
-        }
-      })
-    }
-    iterateMustacheNode($.root())
-
-    $('pre').each((idx, elm) => {
-      $(elm).attr('v-pre', true)
-    })
-
-    // --------------------------------
-    // STEP: POST
-    // --------------------------------
-
-    let output = decodeEscape($.html('body').replace('<body>', '').replace('</body>', ''))
-
-    for (let child of _.sortBy(_.filter(this.children, ['step', 'post']), ['order'])) {
-      const renderer = require(`../${_.kebabCase(child.key)}/renderer.js`)
-      output = await renderer.init(output, child.config)
-    }
-
-    return output
-  }
-}
-
-function decodeEscape (string) {
-  return string.replace(/&#x([0-9a-f]{1,6});/ig, (entity, code) => {
-    code = parseInt(code, 16)
-
-    // Don't unescape ASCII characters, assuming they're encoded for a good reason
-    if (code < 0x80) return entity
-
-    return String.fromCodePoint(code)
-  })
-}

+ 1 - 2
server/modules/rendering/html-image-prefetch/definition.yml → server/modules/rendering/image-prefetch/definition.yml

@@ -1,8 +1,7 @@
-key: htmlImagePrefetch
 title: Image Prefetch
 description: Prefetch remotely rendered images (korki/plantuml)
 author: requarks.io
 icon: mdi-cloud-download-outline
 enabledDefault: false
-dependsOn: html-core
+dependsOn: core
 props: {}

+ 0 - 0
server/modules/rendering/html-image-prefetch/renderer.js → server/modules/rendering/image-prefetch/renderer.mjs


+ 1 - 2
server/modules/rendering/html-mediaplayers/definition.yml → server/modules/rendering/mediaplayers/definition.yml

@@ -1,8 +1,7 @@
-key: htmlMediaplayers
 title: Media Players
 description: Embed players such as Youtube, Vimeo, Soundcloud, etc.
 author: requarks.io
 icon: mdi-video
 enabledDefault: true
-dependsOn: html-core
+dependsOn: core
 props: {}

+ 0 - 0
server/modules/rendering/html-mediaplayers/renderer.js → server/modules/rendering/mediaplayers/renderer.mjs


+ 1 - 2
server/modules/rendering/html-mermaid/definition.yml → server/modules/rendering/mermaid/definition.yml

@@ -1,8 +1,7 @@
-key: htmlMermaid
 title: Mermaid
 description: Generate flowcharts from Mermaid syntax
 author: requarks.io
 icon: mdi-arrow-decision-outline
 enabledDefault: true
-dependsOn: html-core
+dependsOn: core
 props: {}

+ 0 - 0
server/modules/rendering/html-mermaid/renderer.js → server/modules/rendering/mermaid/renderer.mjs


+ 1 - 2
server/modules/rendering/html-security/definition.yml → server/modules/rendering/security/definition.yml

@@ -1,10 +1,9 @@
-key: htmlSecurity
 title: Security
 description: Filter and strips potentially dangerous content
 author: requarks.io
 icon: mdi-fire
 enabledDefault: true
-dependsOn: html-core
+dependsOn: core
 step: post
 order: 99999
 props:

+ 0 - 0
server/modules/rendering/html-security/renderer.js → server/modules/rendering/security/renderer.mjs


+ 1 - 2
server/modules/rendering/html-tabset/definition.yml → server/modules/rendering/tabset/definition.yml

@@ -1,8 +1,7 @@
-key: htmlTabset
 title: Tabsets
 description: Transform headers into tabs
 author: requarks.io
 icon: mdi-tab
 enabledDefault: true
-dependsOn: html-core
+dependsOn: core
 props: {}

+ 0 - 0
server/modules/rendering/html-tabset/renderer.js → server/modules/rendering/tabset/renderer.mjs


+ 1 - 2
server/modules/rendering/html-twemoji/definition.yml → server/modules/rendering/twemoji/definition.yml

@@ -1,10 +1,9 @@
-key: htmlTwemoji
 title: Twemoji
 description: Apply Twitter Emojis to all Unicode emojis
 author: requarks.io
 icon: mdi-emoticon-happy-outline
 enabledDefault: true
-dependsOn: html-core
+dependsOn: core
 step: post
 order: 10
 props: {}

+ 0 - 0
server/modules/rendering/html-twemoji/renderer.js → server/modules/rendering/twemoji/renderer.mjs


+ 80 - 11
server/package-lock.json

@@ -76,13 +76,14 @@
         "luxon": "3.3.0",
         "markdown-it": "13.0.1",
         "markdown-it-abbr": "1.0.4",
+        "markdown-it-attrs": "4.1.6",
+        "markdown-it-decorate": "1.2.2",
         "markdown-it-emoji": "2.0.2",
         "markdown-it-expand-tabs": "1.0.13",
-        "markdown-it-external-links": "0.0.6",
         "markdown-it-footnote": "3.0.3",
         "markdown-it-imsize": "2.0.1",
         "markdown-it-mark": "3.0.1",
-        "markdown-it-mathjax": "2.0.0",
+        "markdown-it-multimd-table": "4.2.1",
         "markdown-it-sub": "1.0.0",
         "markdown-it-sup": "1.0.0",
         "markdown-it-task-lists": "2.1.1",
@@ -142,6 +143,7 @@
         "striptags": "3.2.0",
         "tar-fs": "2.1.1",
         "turndown": "7.1.2",
+        "twemoji": "14.0.2",
         "uslug": "1.0.4",
         "uuid": "9.0.0",
         "validate.js": "0.13.1",
@@ -5895,6 +5897,22 @@
       "resolved": "https://registry.npmjs.org/markdown-it-abbr/-/markdown-it-abbr-1.0.4.tgz",
       "integrity": "sha512-ZeA4Z4SaBbYysZap5iZcxKmlPL6bYA8grqhzJIHB1ikn7njnzaP8uwbtuXc4YXD5LicI4/2Xmc0VwmSiFV04gg=="
     },
+    "node_modules/markdown-it-attrs": {
+      "version": "4.1.6",
+      "resolved": "https://registry.npmjs.org/markdown-it-attrs/-/markdown-it-attrs-4.1.6.tgz",
+      "integrity": "sha512-O7PDKZlN8RFMyDX13JnctQompwrrILuz2y43pW2GagcwpIIElkAdfeek+erHfxUOlXWPsjFeWmZ8ch1xtRLWpA==",
+      "engines": {
+        "node": ">=6"
+      },
+      "peerDependencies": {
+        "markdown-it": ">= 9.0.0"
+      }
+    },
+    "node_modules/markdown-it-decorate": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/markdown-it-decorate/-/markdown-it-decorate-1.2.2.tgz",
+      "integrity": "sha512-7BFWJ97KBXgkaPVjKHISQnhSW8RWQ7yRNXpr8pPUV2Rw4GHvGrgb6CelKCM+GSijP0uSLCAVfc/knWIz+2v/Sw=="
+    },
     "node_modules/markdown-it-emoji": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/markdown-it-emoji/-/markdown-it-emoji-2.0.2.tgz",
@@ -5908,11 +5926,6 @@
         "lodash.repeat": "^4.0.0"
       }
     },
-    "node_modules/markdown-it-external-links": {
-      "version": "0.0.6",
-      "resolved": "https://registry.npmjs.org/markdown-it-external-links/-/markdown-it-external-links-0.0.6.tgz",
-      "integrity": "sha512-b0fsXGsf0higb7DEthr8ZKYErRB4ftIw1iklUZz0iN3leZxIZMEiFspfFdKrzpfblleZMnitz6ETTDgphjCSuA=="
-    },
     "node_modules/markdown-it-footnote": {
       "version": "3.0.3",
       "resolved": "https://registry.npmjs.org/markdown-it-footnote/-/markdown-it-footnote-3.0.3.tgz",
@@ -5928,10 +5941,10 @@
       "resolved": "https://registry.npmjs.org/markdown-it-mark/-/markdown-it-mark-3.0.1.tgz",
       "integrity": "sha512-HyxjAu6BRsdt6Xcv6TKVQnkz/E70TdGXEFHRYBGLncRE9lBFwDNLVtFojKxjJWgJ+5XxUwLaHXy+2sGBbDn+4A=="
     },
-    "node_modules/markdown-it-mathjax": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/markdown-it-mathjax/-/markdown-it-mathjax-2.0.0.tgz",
-      "integrity": "sha512-Fafv7TnMENccWYTNjMZzV4BzONPxpK9Mknr1iMEK6m7PI5a5UTCOFctPzx7Nhv81fFzYEY8WHDkSu9n43fTV9g=="
+    "node_modules/markdown-it-multimd-table": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/markdown-it-multimd-table/-/markdown-it-multimd-table-4.2.1.tgz",
+      "integrity": "sha512-0WEkr2Siw1I9TFaKEHwCXDRxIXWmuzht496Mb8yCkFnK+OVDqMSN6k5/FwyKlZIMtYNOK02e8o0uh3H0WMqstQ=="
     },
     "node_modules/markdown-it-sub": {
       "version": "1.0.0",
@@ -9167,6 +9180,62 @@
       "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
       "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="
     },
+    "node_modules/twemoji": {
+      "version": "14.0.2",
+      "resolved": "https://registry.npmjs.org/twemoji/-/twemoji-14.0.2.tgz",
+      "integrity": "sha512-BzOoXIe1QVdmsUmZ54xbEH+8AgtOKUiG53zO5vVP2iUu6h5u9lN15NcuS6te4OY96qx0H7JK9vjjl9WQbkTRuA==",
+      "dependencies": {
+        "fs-extra": "^8.0.1",
+        "jsonfile": "^5.0.0",
+        "twemoji-parser": "14.0.0",
+        "universalify": "^0.1.2"
+      }
+    },
+    "node_modules/twemoji-parser": {
+      "version": "14.0.0",
+      "resolved": "https://registry.npmjs.org/twemoji-parser/-/twemoji-parser-14.0.0.tgz",
+      "integrity": "sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA=="
+    },
+    "node_modules/twemoji/node_modules/fs-extra": {
+      "version": "8.1.0",
+      "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
+      "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
+      "dependencies": {
+        "graceful-fs": "^4.2.0",
+        "jsonfile": "^4.0.0",
+        "universalify": "^0.1.0"
+      },
+      "engines": {
+        "node": ">=6 <7 || >=8"
+      }
+    },
+    "node_modules/twemoji/node_modules/fs-extra/node_modules/jsonfile": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
+      "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
+      "optionalDependencies": {
+        "graceful-fs": "^4.1.6"
+      }
+    },
+    "node_modules/twemoji/node_modules/jsonfile": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-5.0.0.tgz",
+      "integrity": "sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w==",
+      "dependencies": {
+        "universalify": "^0.1.2"
+      },
+      "optionalDependencies": {
+        "graceful-fs": "^4.1.6"
+      }
+    },
+    "node_modules/twemoji/node_modules/universalify": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
+      "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
+      "engines": {
+        "node": ">= 4.0.0"
+      }
+    },
     "node_modules/type-check": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

+ 4 - 2
server/package.json

@@ -101,13 +101,14 @@
     "luxon": "3.3.0",
     "markdown-it": "13.0.1",
     "markdown-it-abbr": "1.0.4",
+    "markdown-it-attrs": "4.1.6",
+    "markdown-it-decorate": "1.2.2",
     "markdown-it-emoji": "2.0.2",
     "markdown-it-expand-tabs": "1.0.13",
-    "markdown-it-external-links": "0.0.6",
     "markdown-it-footnote": "3.0.3",
     "markdown-it-imsize": "2.0.1",
     "markdown-it-mark": "3.0.1",
-    "markdown-it-mathjax": "2.0.0",
+    "markdown-it-multimd-table": "4.2.1",
     "markdown-it-sub": "1.0.0",
     "markdown-it-sup": "1.0.0",
     "markdown-it-task-lists": "2.1.1",
@@ -167,6 +168,7 @@
     "striptags": "3.2.0",
     "tar-fs": "2.1.1",
     "turndown": "7.1.2",
+    "twemoji": "14.0.2",
     "uslug": "1.0.4",
     "uuid": "9.0.0",
     "validate.js": "0.13.1",

+ 157 - 0
server/renderers/markdown.mjs

@@ -0,0 +1,157 @@
+import MarkdownIt from 'markdown-it'
+import mdAttrs from 'markdown-it-attrs'
+import mdDecorate from 'markdown-it-decorate'
+import mdEmoji from 'markdown-it-emoji'
+import mdTaskLists from 'markdown-it-task-lists'
+import mdExpandTabs from 'markdown-it-expand-tabs'
+import mdAbbr from 'markdown-it-abbr'
+import mdSup from 'markdown-it-sup'
+import mdSub from 'markdown-it-sub'
+import mdMark from 'markdown-it-mark'
+import mdMultiTable from 'markdown-it-multimd-table'
+import mdFootnote from 'markdown-it-footnote'
+// import mdImsize from 'markdown-it-imsize'
+import katex from 'katex'
+import underline from './modules/markdown-it-underline.mjs'
+// import 'katex/dist/contrib/mhchem'
+import twemoji from 'twemoji'
+import plantuml from './modules/plantuml.mjs'
+import kroki from './modules/kroki.mjs'
+import katexHelper from './modules/katex.mjs'
+
+import hljs from 'highlight.js'
+
+import { escape, times } from 'lodash-es'
+
+const quoteStyles = {
+  chinese: '””‘’',
+  english: '“”‘’',
+  french: ['«\xA0', '\xA0»', '‹\xA0', '\xA0›'],
+  german: '„“‚‘',
+  greek: '«»‘’',
+  japanese: '「」「」',
+  hungarian: '„”’’',
+  polish: '„”‚‘',
+  portuguese: '«»‘’',
+  russian: '«»„“',
+  spanish: '«»‘’',
+  swedish: '””’’'
+}
+
+export async function render (input, config) {
+  const md = new MarkdownIt({
+    html: config.allowHTML,
+    breaks: config.lineBreaks,
+    linkify: config.linkify,
+    typography: config.typographer,
+    quotes: quoteStyles[config.quotes] ?? quoteStyles.english,
+    highlight (str, lang) {
+      if (lang === 'diagram') {
+        return `<pre class="diagram">${Buffer.from(str, 'base64').toString()}</pre>`
+      } else if (['mermaid', 'plantuml'].includes(lang)) {
+        return `<pre class="codeblock-${lang}"><code>${escape(str)}</code></pre>`
+      } else {
+        const highlighted = lang ? hljs.highlight(str, { language: lang, ignoreIllegals: true }) : hljs.highlightAuto(str)
+        const lineCount = highlighted.value.match(/\n/g).length
+        const lineNums = lineCount > 1 ? `<span aria-hidden="true" class="line-numbers-rows">${times(lineCount, n => '<span></span>').join('')}</span>` : ''
+        return `<pre class="codeblock ${lineCount > 1 && 'line-numbers'}"><code class="language-${lang}">${highlighted.value}${lineNums}</code></pre>`
+      }
+    }
+    })
+    .use(mdAttrs, {
+      allowedAttributes: ['id', 'class', 'target']
+    })
+    .use(mdDecorate)
+    .use(mdEmoji)
+    .use(mdTaskLists, { label: false, labelAfter: false })
+    .use(mdExpandTabs, { tabWidth: config.tabWidth })
+    .use(mdAbbr)
+    .use(mdSup)
+    .use(mdSub)
+    .use(mdMark)
+    .use(mdFootnote)
+    // .use(mdImsize)
+
+  if (config.underline) {
+    md.use(underline)
+  }
+
+  if (config.mdmultiTable) {
+    md.use(mdMultiTable, { multiline: true, rowspan: true, headerless: true })
+  }
+
+  // --------------------------------
+  // PLANTUML
+  // --------------------------------
+
+  if (config.plantuml) {
+    plantuml.init(md, { server: config.plantumlServerUrl })
+  }
+
+  // --------------------------------
+  // KROKI
+  // --------------------------------
+
+  if (config.kroki) {
+    kroki.init(md, { server: config.krokiServerUrl })
+  }
+
+  // --------------------------------
+  // KATEX
+  // --------------------------------
+
+  const macros = {}
+
+  // TODO: Add mhchem (needs esm conversion)
+  // Add \ce, \pu, and \tripledash to the KaTeX macros.
+  // katex.__defineMacro('\\ce', function (context) {
+  //   return chemParse(context.consumeArgs(1)[0], 'ce')
+  // })
+  // katex.__defineMacro('\\pu', function (context) {
+  //   return chemParse(context.consumeArgs(1)[0], 'pu')
+  // })
+
+  //  Needed for \bond for the ~ forms
+  //  Raise by 2.56mu, not 2mu. We're raising a hyphen-minus, U+002D, not
+  //  a mathematical minus, U+2212. So we need that extra 0.56.
+  katex.__defineMacro('\\tripledash', '{\\vphantom{-}\\raisebox{2.56mu}{$\\mkern2mu' + '\\tiny\\text{-}\\mkern1mu\\text{-}\\mkern1mu\\text{-}\\mkern2mu$}}')
+
+  md.inline.ruler.after('escape', 'katex_inline', katexHelper.katexInline)
+  md.renderer.rules.katex_inline = (tokens, idx) => {
+    try {
+      return katex.renderToString(tokens[idx].content, {
+        displayMode: false, macros
+      })
+    } catch (err) {
+      console.warn(err)
+      return tokens[idx].content
+    }
+  }
+  md.block.ruler.after('blockquote', 'katex_block', katexHelper.katexBlock, {
+    alt: ['paragraph', 'reference', 'blockquote', 'list']
+  })
+  md.renderer.rules.katex_block = (tokens, idx) => {
+    try {
+      return '<p>' + katex.renderToString(tokens[idx].content, {
+        displayMode: true, macros
+      }) + '</p>'
+    } catch (err) {
+      console.warn(err)
+      return tokens[idx].content
+    }
+  }
+
+  // --------------------------------
+  // TWEMOJI
+  // --------------------------------
+
+  md.renderer.rules.emoji = (token, idx) => {
+    return twemoji.parse(token[idx].content, {
+      callback (icon, opts) {
+        return `/_assets/svg/twemoji/${icon}.svg`
+      }
+    })
+  }
+
+  return md.render(input)
+}

+ 145 - 0
server/renderers/modules/katex.mjs

@@ -0,0 +1,145 @@
+// Test if potential opening or closing delimieter
+// Assumes that there is a "$" at state.src[pos]
+function isValidDelim (state, pos) {
+  const max = state.posMax
+  let canOpen = true
+  let canClose = true
+
+  const prevChar = pos > 0 ? state.src.charCodeAt(pos - 1) : -1
+  const nextChar = pos + 1 <= max ? state.src.charCodeAt(pos + 1) : -1
+
+  // Check non-whitespace conditions for opening and closing, and
+  // check that closing delimeter isn't followed by a number
+  if (prevChar === 0x20/* " " */ || prevChar === 0x09/* \t */ ||
+          (nextChar >= 0x30/* "0" */ && nextChar <= 0x39/* "9" */)) {
+    canClose = false
+  }
+  if (nextChar === 0x20/* " " */ || nextChar === 0x09/* \t */) {
+    canOpen = false
+  }
+
+  return {
+    canOpen,
+    canClose
+  }
+}
+
+export default {
+  katexInline (state, silent) {
+    let match, token, res, pos
+
+    if (state.src[state.pos] !== '$') { return false }
+
+    res = isValidDelim(state, state.pos)
+    if (!res.canOpen) {
+      if (!silent) { state.pending += '$' }
+      state.pos += 1
+      return true
+    }
+
+    // First check for and bypass all properly escaped delimieters
+    // This loop will assume that the first leading backtick can not
+    // be the first character in state.src, which is known since
+    // we have found an opening delimieter already.
+    const start = state.pos + 1
+    match = start
+    while ((match = state.src.indexOf('$', match)) !== -1) {
+      // Found potential $, look for escapes, pos will point to
+      // first non escape when complete
+      pos = match - 1
+      while (state.src[pos] === '\\') { pos -= 1 }
+
+      // Even number of escapes, potential closing delimiter found
+      if (((match - pos) % 2) === 1) { break }
+      match += 1
+    }
+
+    // No closing delimter found.  Consume $ and continue.
+    if (match === -1) {
+      if (!silent) { state.pending += '$' }
+      state.pos = start
+      return true
+    }
+
+    // Check if we have empty content, ie: $$.  Do not parse.
+    if (match - start === 0) {
+      if (!silent) { state.pending += '$$' }
+      state.pos = start + 1
+      return true
+    }
+
+    // Check for valid closing delimiter
+    res = isValidDelim(state, match)
+    if (!res.canClose) {
+      if (!silent) { state.pending += '$' }
+      state.pos = start
+      return true
+    }
+
+    if (!silent) {
+      token = state.push('katex_inline', 'math', 0)
+      token.markup = '$'
+      token.content = state.src
+        // Extract the math part without the $
+        .slice(start, match)
+        // Escape the curly braces since they will be interpreted as
+        // attributes by markdown-it-attrs (the "curly_attributes"
+        // core rule)
+        .replaceAll('{', '{{')
+        .replaceAll('}', '}}')
+    }
+
+    state.pos = match + 1
+    return true
+  },
+
+  katexBlock (state, start, end, silent) {
+    let firstLine; let lastLine; let next; let lastPos; let found = false
+    let pos = state.bMarks[start] + state.tShift[start]
+    let max = state.eMarks[start]
+
+    if (pos + 2 > max) { return false }
+    if (state.src.slice(pos, pos + 2) !== '$$') { return false }
+
+    pos += 2
+    firstLine = state.src.slice(pos, max)
+
+    if (silent) { return true }
+    if (firstLine.trim().slice(-2) === '$$') {
+      // Single line expression
+      firstLine = firstLine.trim().slice(0, -2)
+      found = true
+    }
+
+    for (next = start; !found;) {
+      next++
+
+      if (next >= end) { break }
+
+      pos = state.bMarks[next] + state.tShift[next]
+      max = state.eMarks[next]
+
+      if (pos < max && state.tShift[next] < state.blkIndent) {
+        // non-empty line with negative indent should stop the list:
+        break
+      }
+
+      if (state.src.slice(pos, max).trim().slice(-2) === '$$') {
+        lastPos = state.src.slice(0, max).lastIndexOf('$$')
+        lastLine = state.src.slice(pos, lastPos)
+        found = true
+      }
+    }
+
+    state.line = next + 1
+
+    const token = state.push('katex_block', 'math', 0)
+    token.block = true
+    token.content = (firstLine && firstLine.trim() ? firstLine + '\n' : '') +
+    state.getLines(start + 1, next, state.tShift[start], true) +
+    (lastLine && lastLine.trim() ? lastLine : '')
+    token.map = [start, state.line]
+    token.markup = '$$'
+    return true
+  }
+}

+ 143 - 0
server/renderers/modules/kroki.mjs

@@ -0,0 +1,143 @@
+import pako from 'pako'
+
+// ------------------------------------
+// Markdown - PlantUML Preprocessor
+// ------------------------------------
+
+export default {
+  init (mdinst, conf) {
+    mdinst.use((md, opts) => {
+      const openMarker = opts.openMarker || '```kroki'
+      const openChar = openMarker.charCodeAt(0)
+      const closeMarker = opts.closeMarker || '```'
+      const closeChar = closeMarker.charCodeAt(0)
+      const server = opts.server || 'https://kroki.io'
+
+      md.block.ruler.before('fence', 'kroki', (state, startLine, endLine, silent) => {
+        let nextLine
+        let markup
+        let params
+        let token
+        let i
+        let autoClosed = false
+        let start = state.bMarks[startLine] + state.tShift[startLine]
+        let max = state.eMarks[startLine]
+
+        // Check out the first character quickly,
+        // this should filter out most of non-uml blocks
+        //
+        if (openChar !== state.src.charCodeAt(start)) { return false }
+
+        // Check out the rest of the marker string
+        //
+        for (i = 0; i < openMarker.length; ++i) {
+          if (openMarker[i] !== state.src[start + i]) { return false }
+        }
+
+        markup = state.src.slice(start, start + i)
+        params = state.src.slice(start + i, max)
+
+        // Since start is found, we can report success here in validation mode
+        //
+        if (silent) { return true }
+
+        // Search for the end of the block
+        //
+        nextLine = startLine
+
+        for (;;) {
+          nextLine++
+          if (nextLine >= endLine) {
+            // unclosed block should be autoclosed by end of document.
+            // also block seems to be autoclosed by end of parent
+            break
+          }
+
+          start = state.bMarks[nextLine] + state.tShift[nextLine]
+          max = state.eMarks[nextLine]
+
+          if (start < max && state.sCount[nextLine] < state.blkIndent) {
+            // non-empty line with negative indent should stop the list:
+            // - ```
+            //  test
+            break
+          }
+
+          if (closeChar !== state.src.charCodeAt(start)) {
+            // didn't find the closing fence
+            continue
+          }
+
+          if (state.sCount[nextLine] > state.sCount[startLine]) {
+            // closing fence should not be indented with respect of opening fence
+            continue
+          }
+
+          let closeMarkerMatched = true
+          for (i = 0; i < closeMarker.length; ++i) {
+            if (closeMarker[i] !== state.src[start + i]) {
+              closeMarkerMatched = false
+              break
+            }
+          }
+
+          if (!closeMarkerMatched) {
+            continue
+          }
+
+          // make sure tail has spaces only
+          if (state.skipSpaces(start + i) < max) {
+            continue
+          }
+
+          // found!
+          autoClosed = true
+          break
+        }
+
+        let contents = state.src
+          .split('\n')
+          .slice(startLine + 1, nextLine)
+          .join('\n')
+
+        // We generate a token list for the alt property, to mimic what the image parser does.
+        let altToken = []
+        // Remove leading space if any.
+        let alt = params ? params.slice(1) : 'uml diagram'
+        state.md.inline.parse(
+          alt,
+          state.md,
+          state.env,
+          altToken
+        )
+
+        let firstlf = contents.indexOf('\n')
+        if (firstlf === -1) firstlf = undefined
+        let diagramType = contents.substring(0, firstlf)
+        contents = contents.substring(firstlf + 1)
+
+        const result = pako.deflate(contents).toString('base64').replace(/\+/g, '-').replace(/\//g, '_')
+
+        token = state.push('kroki', 'img', 0)
+        // alt is constructed from children. No point in populating it here.
+        token.attrs = [ [ 'src', `${server}/${diagramType}/svg/${result}` ], [ 'alt', '' ], ['class', 'uml-diagram prefetch-candidate'] ]
+        token.block = true
+        token.children = altToken
+        token.info = params
+        token.map = [ startLine, nextLine ]
+        token.markup = markup
+
+        state.line = nextLine + (autoClosed ? 1 : 0)
+
+        return true
+      }, {
+        alt: [ 'paragraph', 'reference', 'blockquote', 'list' ]
+      })
+      md.renderer.rules.kroki = md.renderer.rules.image
+    }, {
+      openMarker: conf.openMarker,
+      closeMarker: conf.closeMarker,
+      server: conf.server
+    })
+  }
+}

+ 12 - 0
server/renderers/modules/markdown-it-underline.mjs

@@ -0,0 +1,12 @@
+function renderEm (tokens, idx, opts, env, slf) {
+  const token = tokens[idx]
+  if (token.markup === '_') {
+    token.tag = 'u'
+  }
+  return slf.renderToken(tokens, idx, opts)
+}
+
+export default (md) => {
+  md.renderer.rules.em_open = renderEm
+  md.renderer.rules.em_close = renderEm
+}

+ 187 - 0
server/renderers/modules/plantuml.mjs

@@ -0,0 +1,187 @@
+import pako from 'pako'
+
+// ------------------------------------
+// Markdown - PlantUML Preprocessor
+// ------------------------------------
+
+export default {
+  init (mdinst, conf) {
+    mdinst.use((md, opts) => {
+      const openMarker = opts.openMarker || '```plantuml'
+      const openChar = openMarker.charCodeAt(0)
+      const closeMarker = opts.closeMarker || '```'
+      const closeChar = closeMarker.charCodeAt(0)
+      const imageFormat = opts.imageFormat || 'svg'
+      const server = opts.server || 'https://plantuml.requarks.io'
+
+      md.block.ruler.before('fence', 'uml_diagram', (state, startLine, endLine, silent) => {
+        let nextLine
+        let i
+        let autoClosed = false
+        let start = state.bMarks[startLine] + state.tShift[startLine]
+        let max = state.eMarks[startLine]
+
+        // Check out the first character quickly,
+        // this should filter out most of non-uml blocks
+        //
+        if (openChar !== state.src.charCodeAt(start)) { return false }
+
+        // Check out the rest of the marker string
+        //
+        for (i = 0; i < openMarker.length; ++i) {
+          if (openMarker[i] !== state.src[start + i]) { return false }
+        }
+
+        const markup = state.src.slice(start, start + i)
+        const params = state.src.slice(start + i, max)
+
+        // Since start is found, we can report success here in validation mode
+        //
+        if (silent) { return true }
+
+        // Search for the end of the block
+        //
+        nextLine = startLine
+
+        for (;;) {
+          nextLine++
+          if (nextLine >= endLine) {
+            // unclosed block should be autoclosed by end of document.
+            // also block seems to be autoclosed by end of parent
+            break
+          }
+
+          start = state.bMarks[nextLine] + state.tShift[nextLine]
+          max = state.eMarks[nextLine]
+
+          if (start < max && state.sCount[nextLine] < state.blkIndent) {
+            // non-empty line with negative indent should stop the list:
+            // - ```
+            //  test
+            break
+          }
+
+          if (closeChar !== state.src.charCodeAt(start)) {
+            // didn't find the closing fence
+            continue
+          }
+
+          if (state.sCount[nextLine] > state.sCount[startLine]) {
+            // closing fence should not be indented with respect of opening fence
+            continue
+          }
+
+          let closeMarkerMatched = true
+          for (i = 0; i < closeMarker.length; ++i) {
+            if (closeMarker[i] !== state.src[start + i]) {
+              closeMarkerMatched = false
+              break
+            }
+          }
+
+          if (!closeMarkerMatched) {
+            continue
+          }
+
+          // make sure tail has spaces only
+          if (state.skipSpaces(start + i) < max) {
+            continue
+          }
+
+          // found!
+          autoClosed = true
+          break
+        }
+
+        const contents = state.src
+          .split('\n')
+          .slice(startLine + 1, nextLine)
+          .join('\n')
+
+        // We generate a token list for the alt property, to mimic what the image parser does.
+        const altToken = []
+        // Remove leading space if any.
+        const alt = params ? params.slice(1) : 'uml diagram'
+        state.md.inline.parse(
+          alt,
+          state.md,
+          state.env,
+          altToken
+        )
+
+        const zippedCode = encode64(pako.deflate('@startuml\n' + contents + '\n@enduml', { to: 'string' }))
+
+        const token = state.push('uml_diagram', 'img', 0)
+        // alt is constructed from children. No point in populating it here.
+        token.attrs = [['src', `${server}/${imageFormat}/${zippedCode}`], ['alt', ''], ['class', 'uml-diagram']]
+        token.block = true
+        token.children = altToken
+        token.info = params
+        token.map = [startLine, nextLine]
+        token.markup = markup
+
+        state.line = nextLine + (autoClosed ? 1 : 0)
+
+        return true
+      }, {
+        alt: ['paragraph', 'reference', 'blockquote', 'list']
+      })
+      md.renderer.rules.uml_diagram = md.renderer.rules.image
+    }, {
+      openMarker: conf.openMarker,
+      closeMarker: conf.closeMarker,
+      imageFormat: conf.imageFormat,
+      server: conf.server
+    })
+  }
+}
+
+function encode64 (data) {
+  let r = ''
+  for (let i = 0; i < data.length; i += 3) {
+    if (i + 2 === data.length) {
+      r += append3bytes(data.charCodeAt(i), data.charCodeAt(i + 1), 0)
+    } else if (i + 1 === data.length) {
+      r += append3bytes(data.charCodeAt(i), 0, 0)
+    } else {
+      r += append3bytes(data.charCodeAt(i), data.charCodeAt(i + 1), data.charCodeAt(i + 2))
+    }
+  }
+  return r
+}
+
+function append3bytes (b1, b2, b3) {
+  const c1 = b1 >> 2
+  const c2 = ((b1 & 0x3) << 4) | (b2 >> 4)
+  const c3 = ((b2 & 0xF) << 2) | (b3 >> 6)
+  const c4 = b3 & 0x3F
+  let r = ''
+  r += encode6bit(c1 & 0x3F)
+  r += encode6bit(c2 & 0x3F)
+  r += encode6bit(c3 & 0x3F)
+  r += encode6bit(c4 & 0x3F)
+  return r
+}
+
+function encode6bit (raw) {
+  let b = raw
+  if (b < 10) {
+    return String.fromCharCode(48 + b)
+  }
+  b -= 10
+  if (b < 26) {
+    return String.fromCharCode(65 + b)
+  }
+  b -= 26
+  if (b < 26) {
+    return String.fromCharCode(97 + b)
+  }
+  b -= 26
+  if (b === 0) {
+    return '-'
+  }
+  if (b === 1) {
+    return '_'
+  }
+  return '?'
+}

+ 24 - 8
server/tasks/workers/render-page.mjs

@@ -14,19 +14,35 @@ export async function task ({ payload }) {
 
     const site = await WIKI.db.sites.query().findById(page.siteId)
 
-    await WIKI.db.renderers.fetchDefinitions()
-
-    const pipeline = await WIKI.db.renderers.getRenderingPipeline(page.contentType)
+    let output = page.content
 
-    let output = page.render
-
-    if (isEmpty(page.content)) {
+    // Empty content?
+    if (isEmpty(output)) {
       WIKI.logger.warn(`Failed to render page ID ${payload.id} because content was empty: [ FAILED ]`)
+      throw new Error(`Failed to render page ID ${payload.id} because content was empty.`)
     }
 
+    // Parse to HTML
+    switch (page.contentType) {
+      case 'asciidoc': {
+        const { render } = await import('../../renderers/asciidoc.mjs')
+        output = await render(output, site.config?.editors?.asciidoc?.config ?? {})
+        break
+      }
+      case 'markdown': {
+        const { render } = await import('../../renderers/markdown.mjs')
+        output = await render(output, site.config?.editors?.markdown?.config ?? {})
+        break
+      }
+    }
+
+    // Render HTML
+    await WIKI.db.renderers.fetchDefinitions()
+    const pipeline = await WIKI.db.renderers.getRenderingPipeline(page.contentType)
+
     for (const core of pipeline) {
-      const renderer = (await import(`../../modules/rendering/${core.key}/renderer.mjs`)).default
-      output = await renderer.render.call({
+      const { render } = (await import(`../../modules/rendering/${core.key}/renderer.mjs`))
+      output = await render.call({
         config: core.config,
         children: core.children,
         page,

+ 1 - 0
ux/public/_assets/icons/ultraviolet-lowercase.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 40 40" width="80px" height="80px"><path fill="#dff0fe" d="M6.5 33.5L6.5 31.96 9.5 31.21 9.5 9.5 3.766 9.5 2.623 13.5 1.5 13.5 1.5 6.5 22.5 6.5 22.5 13.5 21.377 13.5 20.234 9.5 14.5 9.5 14.5 31.21 17.5 31.96 17.5 33.5z"/><path fill="#4788c7" d="M22,7v6h-0.246l-0.936-3.275L20.611,9h-0.754H15h-1v1v20.819V31.6l0.757,0.189L17,32.35V33H7v-0.65 l2.243-0.561L10,31.6v-0.781V10V9H9H4.143H3.389L3.181,9.725L2.246,13H2V7H22 M23,6H1v8h2l1.143-4H9v20.819l-3,0.75V34h12v-2.431 l-3-0.75V10h4.857L21,14h2V6L23,6z"/><g><path fill="#dff0fe" d="M26.5 33.5L26.5 31.93 29.5 30.93 29.5 19.5 25.461 19.5 24.661 21.5 23.5 21.5 23.5 16.5 38.5 16.5 38.5 21.5 37.339 21.5 36.539 19.5 32.5 19.5 32.5 30.93 35.5 31.93 35.5 33.5z"/><path fill="#4788c7" d="M38,17v4h-0.323l-0.549-1.371L36.877,19H36.2H33h-1v1v10.569v0.721l0.684,0.228L35,32.29V33h-8 v-0.71l2.316-0.772L30,31.29v-0.721V20v-1h-1h-3.2h-0.677l-0.251,0.629L24.323,21H24v-4H38 M39,16H23v6h2l0.8-2H29v10.569l-3,1V34 h10v-2.431l-3-1V20h3.2l0.8,2h2V16L39,16z"/></g></svg>

+ 1 - 2
ux/src/components/AuthLoginPanel.vue

@@ -500,8 +500,7 @@ async function handleLoginResponse (resp) {
     $q.loading.hide()
   } else {
     $q.loading.show({
-      message: t('auth.loginSuccess'),
-      backgroundColor: 'green'
+      message: t('auth.loginSuccess')
     })
     Cookies.set('jwt', resp.jwt, { expires: 365 })
     setTimeout(() => {

+ 3 - 2
ux/src/components/EditorMarkdown.vue

@@ -257,6 +257,7 @@ const { t } = useI18n()
 // STATE
 
 let editor
+let md
 const monacoRef = ref(null)
 const editorPreviewContainerRef = ref(null)
 
@@ -265,8 +266,6 @@ const state = reactive({
   previewScrollSync: true
 })
 
-const md = new MarkdownRenderer({})
-
 // METHODS
 
 function insertAssets () {
@@ -458,6 +457,8 @@ onMounted(async () => {
     hideSideNav: true
   })
 
+  md = new MarkdownRenderer(editorStore.editors.markdown)
+
   // -> Define Monaco Theme
   monaco.editor.defineTheme('wikijs', {
     base: 'vs-dark',

+ 4 - 1
ux/src/components/EditorMarkdownConfigOverlay.vue

@@ -259,7 +259,8 @@ import { onMounted, reactive } from 'vue'
 import gql from 'graphql-tag'
 import { cloneDeep } from 'lodash-es'
 
-import { useAdminStore } from '../stores/admin'
+import { useAdminStore } from 'src/stores/admin'
+import { useEditorStore } from 'src/stores/editor'
 import { useSiteStore } from 'src/stores/site'
 
 // QUASAR
@@ -269,6 +270,7 @@ const $q = useQuasar()
 // STORES
 
 const adminStore = useAdminStore()
+const editorStore = useEditorStore()
 const siteStore = useSiteStore()
 
 // I18N
@@ -393,6 +395,7 @@ async function save () {
         type: 'positive',
         message: t('admin.editors.markdown.saveSuccess')
       })
+      editorStore.$patch({ configIsLoaded: false })
       close()
     } else {
       throw new Error(respRaw?.data?.updateSite?.operation?.message || 'An unexpected error occured.')

+ 1 - 6
ux/src/components/PageHeader.vue

@@ -355,12 +355,7 @@ async function createPage () {
 
 async function editPage () {
   $q.loading.show()
-  await pageStore.pageLoad({ id: pageStore.id, withContent: true })
-  editorStore.$patch({
-    isActive: true,
-    mode: 'edit',
-    editor: pageStore.editor
-  })
+  await pageStore.pageEdit()
   $q.loading.hide()
 }
 </script>

+ 6 - 2
ux/src/components/PageNewMenu.vue

@@ -73,6 +73,7 @@ q-menu.translucent-menu(
 import { useI18n } from 'vue-i18n'
 import { useQuasar } from 'quasar'
 
+import { useEditorStore } from 'src/stores/editor'
 import { usePageStore } from 'src/stores/page'
 import { useSiteStore } from 'src/stores/site'
 import { useFlagsStore } from 'src/stores/flags'
@@ -100,6 +101,7 @@ const $q = useQuasar()
 
 // STORES
 
+const editorStore = useEditorStore()
 const flagsStore = useFlagsStore()
 const pageStore = usePageStore()
 const siteStore = useSiteStore()
@@ -110,8 +112,10 @@ const { t } = useI18n()
 
 // METHODS
 
-function create (editor) {
-  pageStore.pageCreate({ editor })
+async function create (editor) {
+  $q.loading.show()
+  await pageStore.pageCreate({ editor })
+  $q.loading.hide()
 }
 
 function openFileManager () {

+ 10 - 10
ux/src/components/UserChangePwdDialog.vue

@@ -176,11 +176,13 @@ async function save () {
       mutation: gql`
         mutation adminUpdateUserPwd (
           $id: UUID!
-          $patch: UserUpdateInput!
+          $newPassword: String!
+          $mustChangePassword: Boolean
           ) {
-          updateUser (
+          changeUserPassword (
             id: $id
-            patch: $patch
+            newPassword: $newPassword
+            mustChangePassword: $mustChangePassword
             ) {
             operation {
               succeeded
@@ -191,22 +193,20 @@ async function save () {
       `,
       variables: {
         id: props.userId,
-        patch: {
-          newPassword: state.userPassword,
-          mustChangePassword: state.userMustChangePassword
-        }
+        newPassword: state.userPassword,
+        mustChangePassword: state.userMustChangePassword
       }
     })
-    if (resp?.data?.updateUser?.operation?.succeeded) {
+    if (resp?.data?.changeUserPassword?.operation?.succeeded) {
       $q.notify({
         type: 'positive',
-        message: t('admin.users.createSuccess')
+        message: t('admin.users.changePasswordSuccess')
       })
       onDialogOK({
         mustChangePassword: state.userMustChangePassword
       })
     } else {
-      throw new Error(resp?.data?.updateUser?.operation?.message || 'An unexpected error occured.')
+      throw new Error(resp?.data?.changeUserPassword?.operation?.message || 'An unexpected error occured.')
     }
   } catch (err) {
     $q.notify({

+ 27 - 5
ux/src/components/UserEditOverlay.vue

@@ -215,6 +215,26 @@ q-layout(view='hHh lpR fFf', container)
                       { label: t('profile.appearanceDark'), value: 'dark' }
                     ]`
                   )
+              q-separator.q-my-sm(inset)
+              q-item
+                blueprint-icon(icon='visualy-impaired')
+                q-item-section
+                  q-item-label {{t(`profile.cvd`)}}
+                  q-item-label(caption) {{t(`profile.cvdHint`)}}
+                q-item-section.col-auto
+                  q-btn-toggle(
+                    v-model='state.user.prefs.cvd'
+                    push
+                    glossy
+                    no-caps
+                    toggle-color='primary'
+                    :options=`[
+                      { value: 'none', label: t('profile.cvdNone') },
+                      { value: 'protanopia', label: t('profile.cvdProtanopia') },
+                      { value: 'deuteranopia', label: t('profile.cvdDeuteranopia') },
+                      { value: 'tritanopia', label: t('profile.cvdTritanopia') }
+                    ]`
+                  )
 
           .col-12.col-lg-4
             q-card.shadow-1.q-pb-sm
@@ -230,19 +250,19 @@ q-layout(view='hHh lpR fFf', container)
                 blueprint-icon(icon='calendar-plus', :hue-rotate='-45')
                 q-item-section
                   q-item-label {{t(`common.field.createdOn`)}}
-                  q-item-label: strong {{humanizeDate(state.user.createdAt)}}
+                  q-item-label: strong {{formattedDate(state.user.createdAt)}}
               q-separator.q-my-sm(inset)
               q-item
                 blueprint-icon(icon='summertime', :hue-rotate='-45')
                 q-item-section
                   q-item-label {{t(`common.field.lastUpdated`)}}
-                  q-item-label: strong {{humanizeDate(state.user.updatedAt)}}
+                  q-item-label: strong {{formattedDate(state.user.updatedAt)}}
               q-separator.q-my-sm(inset)
               q-item
                 blueprint-icon(icon='enter', :hue-rotate='-45')
                 q-item-section
                   q-item-label {{t(`admin.users.lastLoginAt`)}}
-                  q-item-label: strong {{humanizeDate(state.user.lastLoginAt)}}
+                  q-item-label: strong {{formattedDate(state.user.lastLoginAt)}}
 
             q-card.shadow-1.q-pb-sm.q-mt-md(v-if='state.user.meta')
               q-card-section
@@ -519,6 +539,7 @@ import { useRouter, useRoute } from 'vue-router'
 
 import { useAdminStore } from 'src/stores/admin'
 import { useFlagsStore } from 'src/stores/flags'
+import { useUserStore } from 'src/stores/user'
 
 import UserChangePwdDialog from './UserChangePwdDialog.vue'
 import UtilCodeEditor from './UtilCodeEditor.vue'
@@ -531,6 +552,7 @@ const $q = useQuasar()
 
 const adminStore = useAdminStore()
 const flagsStore = useFlagsStore()
+const userStore = useUserStore()
 
 // ROUTER
 
@@ -650,7 +672,7 @@ async function fetchUser () {
       },
       fetchPolicy: 'network-only'
     })
-    state.groups = resp?.data?.groups?.filter(g => g.id !== '10000000-0000-4000-0000-000000000001') ?? []
+    state.groups = resp?.data?.groups?.filter(g => g.id !== '10000000-0000-4000-8000-000000000001') ?? []
     if (resp?.data?.userById) {
       state.user = cloneDeep(resp.data.userById)
     } else {
@@ -679,7 +701,7 @@ function checkRoute () {
   }
 }
 
-function humanizeDate (val) {
+function formattedDate (val) {
   if (!val) { return '---' }
   return DateTime.fromISO(val).toLocaleString(DateTime.DATETIME_FULL)
 }

+ 4 - 2
ux/src/components/WelcomeOverlay.vue

@@ -88,9 +88,10 @@ useMeta({
 
 // METHODS
 
-function createHomePage (editor) {
+async function createHomePage (editor) {
+  $q.loading.show()
   siteStore.overlay = ''
-  pageStore.pageCreate({
+  await pageStore.pageCreate({
     editor,
     locale: 'en',
     path: 'home',
@@ -98,6 +99,7 @@ function createHomePage (editor) {
     description: t('welcome.homeDefault.description'),
     content: t('welcome.homeDefault.content')
   })
+  $q.loading.hide()
 }
 
 function loadAdmin () {

+ 6 - 2
ux/src/i18n/locales/en.json

@@ -241,6 +241,8 @@
   "admin.general.logoUpl": "Site Logo",
   "admin.general.logoUplHint": "Logo image file, in SVG, PNG, JPG, WEBP or GIF format.",
   "admin.general.logoUploadSuccess": "Site logo uploaded successfully.",
+  "admin.general.pageCasing": "Case Sensitive Paths",
+  "admin.general.pageCasingHint": "Treat paths with different casing as distinct pages.",
   "admin.general.pageExtensions": "Page Extensions",
   "admin.general.pageExtensionsHint": "A comma-separated list of URL extensions that will be treated as pages. For example, adding md will treat /foobar.md the same as /foobar.",
   "admin.general.ratingsOff": "Off",
@@ -873,11 +875,12 @@
   "admin.users.basicInfo": "Basic Info",
   "admin.users.changePassword": "Change Password",
   "admin.users.changePasswordHint": "Change the user password. Note that the current password cannot be recovered.",
+  "admin.users.changePasswordSuccess": "User password was updated successfully.",
   "admin.users.create": "Create User",
   "admin.users.createInvalidData": "Cannot create user as some fields are invalid or missing.",
   "admin.users.createKeepOpened": "Keep dialog opened after create",
   "admin.users.createSuccess": "User created successfully!",
-  "admin.users.createdAt": "Created {date}",
+  "admin.users.createdAt": "Created on {date}",
   "admin.users.darkMode": "Dark Mode",
   "admin.users.darkModeHint": "Display the user interface using dark mode.",
   "admin.users.dateFormat": "Date Format",
@@ -911,7 +914,7 @@
   "admin.users.jobTitle": "Job Title",
   "admin.users.jobTitleHint": "The job title of the user.",
   "admin.users.joined": "Joined",
-  "admin.users.lastLoginAt": "Last login",
+  "admin.users.lastLoginAt": "Last login {date}",
   "admin.users.lastUpdated": "Last Updated",
   "admin.users.linkedAccounts": "Linked Accounts",
   "admin.users.linkedProviders": "Linked Providers",
@@ -1233,6 +1236,7 @@
   "common.comments.updateComment": "Update Comment",
   "common.comments.updateSuccess": "Comment was updated successfully.",
   "common.comments.viewDiscussion": "View Discussion",
+  "common.datetime": "{date} 'at' {time}",
   "common.duration.days": "Day(s)",
   "common.duration.every": "Every",
   "common.duration.hours": "Hour(s)",

+ 15 - 0
ux/src/layouts/ProfileLayout.vue

@@ -47,6 +47,7 @@ q-layout(view='hHh Lpr lff')
 import { useI18n } from 'vue-i18n'
 import { useMeta, useQuasar } from 'quasar'
 import { onMounted, reactive, watch } from 'vue'
+import { useRouter, useRoute } from 'vue-router'
 
 import { useFlagsStore } from 'src/stores/flags'
 import { useSiteStore } from 'src/stores/site'
@@ -66,6 +67,11 @@ const flagsStore = useFlagsStore()
 const siteStore = useSiteStore()
 const userStore = useUserStore()
 
+// ROUTER
+
+const router = useRouter()
+const route = useRoute()
+
 // I18N
 
 const { t } = useI18n()
@@ -118,6 +124,15 @@ const sidenav = [
     disabled: true
   }
 ]
+
+// WATCHERS
+
+watch(() => route.path, async (newValue) => {
+  if (!newValue.startsWith('/_profile')) { return }
+  if (!userStore.authenticated) {
+    router.replace('/login')
+  }
+}, { immediate: true })
 </script>
 
 <style lang="scss">

+ 37 - 20
ux/src/pages/AdminGeneral.vue

@@ -213,7 +213,7 @@ q-page.admin-general
               :aria-label='t(`admin.general.allowSearch`)'
               )
         q-separator.q-my-sm(inset)
-        q-item(tag='label')
+        q-item
           blueprint-icon(icon='confusion')
           q-item-section
             q-item-label {{t(`admin.general.reasonForChange`)}}
@@ -228,25 +228,6 @@ q-page.admin-general
               :options='reasonForChangeModes'
             )
 
-      //- -----------------------
-      //- URL Handling
-      //- -----------------------
-      q-card.q-pb-sm.q-mt-md
-        q-card-section
-          .text-subtitle1 {{t('admin.general.urlHandling')}}
-        q-item
-          blueprint-icon(icon='sort-by-follow-up-date')
-          q-item-section
-            q-item-label {{t(`admin.general.pageExtensions`)}}
-            q-item-label(caption) {{t(`admin.general.pageExtensionsHint`)}}
-          q-item-section
-            q-input(
-              outlined
-              v-model='state.config.pageExtensions'
-              dense
-              :aria-label='t(`admin.general.pageExtensions`)'
-              )
-
     .col-12.col-lg-5
       //- -----------------------
       //- Logo
@@ -399,6 +380,39 @@ q-page.admin-general
               :aria-label='t(`admin.general.uploadNormalizeFilename`)'
               )
 
+      //- -----------------------
+      //- URL Handling
+      //- -----------------------
+      q-card.q-pb-sm.q-mt-md
+        q-card-section
+          .text-subtitle1 {{t('admin.general.urlHandling')}}
+        q-item
+          blueprint-icon(icon='sort-by-follow-up-date')
+          q-item-section
+            q-item-label {{t(`admin.general.pageExtensions`)}}
+            q-item-label(caption) {{t(`admin.general.pageExtensionsHint`)}}
+          q-item-section
+            q-input(
+              outlined
+              v-model='state.config.pageExtensions'
+              dense
+              :aria-label='t(`admin.general.pageExtensions`)'
+              )
+        q-separator.q-my-sm(inset)
+        q-item(tag='label')
+          blueprint-icon(icon='lowercase')
+          q-item-section
+            q-item-label {{t(`admin.general.pageCasing`)}}
+            q-item-label(caption) {{t(`admin.general.pageCasingHint`)}}
+          q-item-section(avatar)
+            q-toggle(
+              v-model='state.config.pageCasing'
+              color='primary'
+              checked-icon='las la-check'
+              unchecked-icon='las la-times'
+              :aria-label='t(`admin.general.pageCasing`)'
+              )
+
       //- -----------------------
       //- SEO
       //- -----------------------
@@ -491,6 +505,7 @@ const state = reactive({
     contentLicense: '',
     footerExtra: '',
     pageExtensions: '',
+    pageCasing: false,
     logoText: false,
     ratings: {
       index: false,
@@ -583,6 +598,7 @@ async function load () {
           contentLicense
           footerExtra
           pageExtensions
+          pageCasing
           logoText
           sitemap
           uploads {
@@ -652,6 +668,7 @@ async function save () {
           contentLicense: state.config.contentLicense ?? '',
           footerExtra: state.config.footerExtra ?? '',
           pageExtensions: state.config.pageExtensions ?? '',
+          pageCasing: state.config.pageCasing ?? false,
           logoText: state.config.logoText ?? false,
           sitemap: state.config.sitemap ?? false,
           uploads: {

+ 0 - 2
ux/src/pages/AdminStorage.vue

@@ -1233,8 +1233,6 @@ function generateGraph () {
       state.deliveryPaths.push({ edges: [`${tp.key}_db_in`], color: '#f03a4755' })
     }
   }
-
-  console.info(state.deliveryEdges)
 }
 
 // MOUNTED

+ 6 - 1
ux/src/pages/AdminUsers.vue

@@ -89,7 +89,7 @@ q-page.admin-groups
             q-td(:props='props')
               i18n-t.text-caption(keypath='admin.users.createdAt', tag='div')
                 template(#date)
-                  strong {{ humanizeDate(props.value) }}
+                  strong {{ formattedDate(props.value) }}
               i18n-t.text-caption(
                 v-if='props.row.lastLoginAt'
                 keypath='admin.users.lastLoginAt'
@@ -128,6 +128,7 @@ import { useRouter, useRoute } from 'vue-router'
 
 import { useAdminStore } from 'src/stores/admin'
 import { useSiteStore } from 'src/stores/site'
+import { useUserStore } from 'src/stores/user'
 
 import UserCreateDialog from '../components/UserCreateDialog.vue'
 import UserDefaultsMenu from 'src/components/UserDefaultsMenu.vue'
@@ -140,6 +141,7 @@ const $q = useQuasar()
 
 const adminStore = useAdminStore()
 const siteStore = useSiteStore()
+const userStore = useUserStore()
 
 // ROUTER
 
@@ -242,6 +244,9 @@ async function load () {
 function humanizeDate (val) {
   return DateTime.fromISO(val).toRelative()
 }
+function formattedDate (val) {
+  return userStore.formatDateTime(t, val)
+}
 
 function checkOverlay () {
   if (route.params?.id) {

+ 71 - 12
ux/src/renderers/markdown.js

@@ -16,19 +16,36 @@ import underline from './modules/markdown-it-underline'
 import 'katex/dist/contrib/mhchem'
 import twemoji from 'twemoji'
 import plantuml from './modules/plantuml'
+import kroki from './modules/kroki.mjs'
 import katexHelper from './modules/katex'
 
 import hljs from 'highlight.js'
 
 import { escape, findLast, times } from 'lodash-es'
 
+const quoteStyles = {
+  chinese: '””‘’',
+  english: '“”‘’',
+  french: ['«\xA0', '\xA0»', '‹\xA0', '\xA0›'],
+  german: '„“‚‘',
+  greek: '«»‘’',
+  japanese: '「」「」',
+  hungarian: '„”’’',
+  polish: '„”‚‘',
+  portuguese: '«»‘’',
+  russian: '«»„“',
+  spanish: '«»‘’',
+  swedish: '””’’'
+}
+
 export class MarkdownRenderer {
-  constructor (conf = {}) {
+  constructor (config = {}) {
     this.md = new MarkdownIt({
-      html: true,
-      breaks: true,
-      linkify: true,
-      typography: true,
+      html: config.allowHTML,
+      breaks: config.lineBreaks,
+      linkify: config.linkify,
+      typography: config.typographer,
+      quotes: quoteStyles[config.quotes] ?? quoteStyles.english,
       highlight (str, lang) {
         if (lang === 'diagram') {
           return `<pre class="diagram">${Buffer.from(str, 'base64').toString()}</pre>`
@@ -46,23 +63,59 @@ export class MarkdownRenderer {
         allowedAttributes: ['id', 'class', 'target']
       })
       .use(mdDecorate)
-      .use(underline)
       .use(mdEmoji)
       .use(mdTaskLists, { label: false, labelAfter: false })
-      .use(mdExpandTabs)
+      .use(mdExpandTabs, { tabWidth: config.tabWidth })
       .use(mdAbbr)
       .use(mdSup)
       .use(mdSub)
-      .use(mdMultiTable, { multiline: true, rowspan: true, headerless: true })
       .use(mdMark)
       .use(mdFootnote)
       // .use(mdImsize)
 
-    // -> PLANTUML
-    plantuml.init(this.md, {})
+    if (config.underline) {
+      this.md.use(underline)
+    }
+
+    if (config.mdmultiTable) {
+      this.md.use(mdMultiTable, { multiline: true, rowspan: true, headerless: true })
+    }
+
+    // --------------------------------
+    // PLANTUML
+    // --------------------------------
+
+    if (config.plantuml) {
+      plantuml.init(this.md, { server: config.plantumlServerUrl })
+    }
+
+    // --------------------------------
+    // KROKI
+    // --------------------------------
+
+    if (config.kroki) {
+      kroki.init(this.md, { server: config.krokiServerUrl })
+    }
+
+    // --------------------------------
+    // KATEX
+    // --------------------------------
 
-    // -> KATEX
     const macros = {}
+
+    // TODO: Add mhchem (needs esm conversion)
+    // Add \ce, \pu, and \tripledash to the KaTeX macros.
+    // katex.__defineMacro('\\ce', function (context) {
+    //   return chemParse(context.consumeArgs(1)[0], 'ce')
+    // })
+    // katex.__defineMacro('\\pu', function (context) {
+    //   return chemParse(context.consumeArgs(1)[0], 'pu')
+    // })
+
+    //  Needed for \bond for the ~ forms
+    //  Raise by 2.56mu, not 2mu. We're raising a hyphen-minus, U+002D, not
+    //  a mathematical minus, U+2212. So we need that extra 0.56.
+    katex.__defineMacro('\\tripledash', '{\\vphantom{-}\\raisebox{2.56mu}{$\\mkern2mu' + '\\tiny\\text{-}\\mkern1mu\\text{-}\\mkern1mu\\text{-}\\mkern2mu$}}')
     this.md.inline.ruler.after('escape', 'katex_inline', katexHelper.katexInline)
     this.md.renderer.rules.katex_inline = (tokens, idx) => {
       try {
@@ -88,7 +141,10 @@ export class MarkdownRenderer {
       }
     }
 
-    // -> TWEMOJI
+    // --------------------------------
+    // TWEMOJI
+    // --------------------------------
+
     this.md.renderer.rules.emoji = (token, idx) => {
       return twemoji.parse(token[idx].content, {
         callback (icon, opts) {
@@ -97,7 +153,10 @@ export class MarkdownRenderer {
       })
     }
 
+    // --------------------------------
     // Inject line numbers for preview scroll sync
+    // --------------------------------
+
     this.linesMap = []
     const injectLineNumbers = (tokens, idx, options, env, slf) => {
       let line

+ 143 - 0
ux/src/renderers/modules/kroki.mjs

@@ -0,0 +1,143 @@
+import pako from 'pako'
+
+// ------------------------------------
+// Markdown - PlantUML Preprocessor
+// ------------------------------------
+
+export default {
+  init (mdinst, conf) {
+    mdinst.use((md, opts) => {
+      const openMarker = opts.openMarker || '```kroki'
+      const openChar = openMarker.charCodeAt(0)
+      const closeMarker = opts.closeMarker || '```'
+      const closeChar = closeMarker.charCodeAt(0)
+      const server = opts.server || 'https://kroki.io'
+
+      md.block.ruler.before('fence', 'kroki', (state, startLine, endLine, silent) => {
+        let nextLine
+        let markup
+        let params
+        let token
+        let i
+        let autoClosed = false
+        let start = state.bMarks[startLine] + state.tShift[startLine]
+        let max = state.eMarks[startLine]
+
+        // Check out the first character quickly,
+        // this should filter out most of non-uml blocks
+        //
+        if (openChar !== state.src.charCodeAt(start)) { return false }
+
+        // Check out the rest of the marker string
+        //
+        for (i = 0; i < openMarker.length; ++i) {
+          if (openMarker[i] !== state.src[start + i]) { return false }
+        }
+
+        markup = state.src.slice(start, start + i)
+        params = state.src.slice(start + i, max)
+
+        // Since start is found, we can report success here in validation mode
+        //
+        if (silent) { return true }
+
+        // Search for the end of the block
+        //
+        nextLine = startLine
+
+        for (;;) {
+          nextLine++
+          if (nextLine >= endLine) {
+            // unclosed block should be autoclosed by end of document.
+            // also block seems to be autoclosed by end of parent
+            break
+          }
+
+          start = state.bMarks[nextLine] + state.tShift[nextLine]
+          max = state.eMarks[nextLine]
+
+          if (start < max && state.sCount[nextLine] < state.blkIndent) {
+            // non-empty line with negative indent should stop the list:
+            // - ```
+            //  test
+            break
+          }
+
+          if (closeChar !== state.src.charCodeAt(start)) {
+            // didn't find the closing fence
+            continue
+          }
+
+          if (state.sCount[nextLine] > state.sCount[startLine]) {
+            // closing fence should not be indented with respect of opening fence
+            continue
+          }
+
+          let closeMarkerMatched = true
+          for (i = 0; i < closeMarker.length; ++i) {
+            if (closeMarker[i] !== state.src[start + i]) {
+              closeMarkerMatched = false
+              break
+            }
+          }
+
+          if (!closeMarkerMatched) {
+            continue
+          }
+
+          // make sure tail has spaces only
+          if (state.skipSpaces(start + i) < max) {
+            continue
+          }
+
+          // found!
+          autoClosed = true
+          break
+        }
+
+        let contents = state.src
+          .split('\n')
+          .slice(startLine + 1, nextLine)
+          .join('\n')
+
+        // We generate a token list for the alt property, to mimic what the image parser does.
+        let altToken = []
+        // Remove leading space if any.
+        let alt = params ? params.slice(1) : 'uml diagram'
+        state.md.inline.parse(
+          alt,
+          state.md,
+          state.env,
+          altToken
+        )
+
+        let firstlf = contents.indexOf('\n')
+        if (firstlf === -1) firstlf = undefined
+        let diagramType = contents.substring(0, firstlf)
+        contents = contents.substring(firstlf + 1)
+
+        const result = pako.deflate(contents).toString('base64').replace(/\+/g, '-').replace(/\//g, '_')
+
+        token = state.push('kroki', 'img', 0)
+        // alt is constructed from children. No point in populating it here.
+        token.attrs = [ [ 'src', `${server}/${diagramType}/svg/${result}` ], [ 'alt', '' ], ['class', 'uml-diagram prefetch-candidate'] ]
+        token.block = true
+        token.children = altToken
+        token.info = params
+        token.map = [ startLine, nextLine ]
+        token.markup = markup
+
+        state.line = nextLine + (autoClosed ? 1 : 0)
+
+        return true
+      }, {
+        alt: [ 'paragraph', 'reference', 'blockquote', 'list' ]
+      })
+      md.renderer.rules.kroki = md.renderer.rules.image
+    }, {
+      openMarker: conf.openMarker,
+      closeMarker: conf.closeMarker,
+      server: conf.server
+    })
+  }
+}

+ 57 - 1
ux/src/stores/editor.js

@@ -1,4 +1,8 @@
 import { defineStore } from 'pinia'
+import gql from 'graphql-tag'
+import { clone } from 'lodash-es'
+
+import { useSiteStore } from './site'
 
 export const useEditorStore = defineStore('editor', {
   state: () => ({
@@ -18,6 +22,7 @@ export const useEditorStore = defineStore('editor', {
     lastSaveTimestamp: null,
     lastChangeTimestamp: null,
     editors: {},
+    configIsLoaded: false,
     reasonForChange: ''
   }),
   getters: {
@@ -25,5 +30,56 @@ export const useEditorStore = defineStore('editor', {
       return state.lastSaveTimestamp && state.lastSaveTimestamp !== state.lastChangeTimestamp
     }
   },
-  actions: {}
+  actions: {
+    async fetchConfigs () {
+      const siteStore = useSiteStore()
+      try {
+        if (!siteStore.id) {
+          throw new Error('Cannot fetch editors config: Missing Site ID')
+        }
+        const resp = await APOLLO_CLIENT.query({
+          query: gql`
+            query fetchEditorConfigs (
+              $id: UUID!
+            ) {
+              siteById(
+                id: $id
+              ) {
+                id
+                editors {
+                  asciidoc {
+                    isActive
+                    config
+                  }
+                  markdown {
+                    isActive
+                    config
+                  }
+                  wysiwyg {
+                    isActive
+                    config
+                  }
+                }
+              }
+            }
+          `,
+          variables: {
+            id: siteStore.id
+          },
+          fetchPolicy: 'network-only'
+        })
+        this.$patch({
+          editors: {
+            asciidoc: resp?.data?.siteById?.editors?.asciidoc?.config,
+            markdown: resp?.data?.siteById?.editors?.markdown?.config,
+            wysiwyg: resp?.data?.siteById?.editors?.wysiwyg?.config
+          },
+          configIsLoaded: true
+        })
+      } catch (err) {
+        console.warn(err)
+        throw err
+      }
+    }
+  }
 })

+ 23 - 5
ux/src/stores/page.js

@@ -126,7 +126,6 @@ const gqlMutations = {
       $publishEndDate: Date
       $publishStartDate: Date
       $relations: [PageRelationInput!]
-      $render: String
       $scriptCss: String
       $scriptJsLoad: String
       $scriptJsUnload: String
@@ -153,7 +152,6 @@ const gqlMutations = {
         publishEndDate: $publishEndDate
         publishStartDate: $publishStartDate
         relations: $relations
-        render: $render
         scriptCss: $scriptCss
         scriptJsLoad: $scriptJsLoad
         scriptJsUnload: $scriptJsUnload
@@ -277,9 +275,13 @@ export const usePageStore = defineStore('page', {
     /**
      * PAGE - CREATE
      */
-    pageCreate ({ editor, locale, path, title = '', description = '', content = '' }) {
+    async pageCreate ({ editor, locale, path, title = '', description = '', content = '' }) {
       const editorStore = useEditorStore()
 
+      if (!editorStore.configIsLoaded) {
+        await editorStore.fetchConfigs()
+      }
+
       // -> Init editor
       editorStore.$patch({
         originPageId: editorStore.isActive ? editorStore.originPageId : this.id, // Don't replace if already in edit mode
@@ -313,6 +315,24 @@ export const usePageStore = defineStore('page', {
 
       this.router.push('/_create')
     },
+    /**
+     * PAGE - EDIT
+     */
+    async pageEdit () {
+      const editorStore = useEditorStore()
+
+      await this.pageLoad({ id: this.id, withContent: true })
+
+      if (!editorStore.configIsLoaded) {
+        await editorStore.fetchConfigs()
+      }
+
+      editorStore.$patch({
+        isActive: true,
+        mode: 'edit',
+        editor: this.editor
+      })
+    },
     /**
      * PAGE SAVE
      */
@@ -339,7 +359,6 @@ export const usePageStore = defineStore('page', {
                 'publishStartDate',
                 'publishState',
                 'relations',
-                'render',
                 'scriptJsLoad',
                 'scriptJsUnload',
                 'scriptCss',
@@ -410,7 +429,6 @@ export const usePageStore = defineStore('page', {
                   'publishStartDate',
                   'publishState',
                   'relations',
-                  'render',
                   'scriptJsLoad',
                   'scriptJsUnload',
                   'scriptCss',

+ 15 - 1
ux/src/stores/user.js

@@ -27,7 +27,18 @@ export const useUserStore = defineStore('user', {
     token: '',
     profileLoaded: false
   }),
-  getters: {},
+  getters: {
+    preferredDateFormat: (state) => {
+      if (!state.dateFormat) {
+        return 'D'
+      } else {
+        return state.dateFormat.replaceAll('Y', 'y').replaceAll('D', 'd')
+      }
+    },
+    preferredTimeFormat: (state) => {
+      return state.timeFormat === '24h' ? 'T' : 't'
+    }
+  },
   actions: {
     async refreshAuth () {
       if (this.exp && this.exp < DateTime.now()) {
@@ -163,6 +174,9 @@ export const useUserStore = defineStore('user', {
       } catch (err) {
         console.warn(`Failed to fetch page permissions at path ${path}!`)
       }
+    },
+    formatDateTime (t, date) {
+      return (typeof date === 'string' ? DateTime.fromISO(date) : date).toFormat(t('common.datetime', { date: this.preferredDateFormat, time: this.preferredTimeFormat }))
     }
   }
 })