Sfoglia il codice sorgente

feat: markdown editor renderer

NGPixel 2 anni fa
parent
commit
50054dfa4f

+ 1 - 1
server/package.json

@@ -8,7 +8,7 @@
   "dev": true,
   "scripts": {
     "start": "cd .. && node server",
-    "dev": "cd .. && nodemon server"
+    "dev": "cd .. && nodemon server --watch server --ext js,json,graphql,gql"
   },
   "repository": {
     "type": "git",

+ 174 - 1
ux/package-lock.json

@@ -50,9 +50,24 @@
         "graphql-tag": "2.12.6",
         "js-cookie": "3.0.1",
         "jwt-decode": "3.1.2",
+        "katex": "0.16.4",
         "lodash-es": "4.17.21",
         "lowlight": "2.8.1",
         "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-footnote": "3.0.3",
+        "markdown-it-imsize": "2.0.1",
+        "markdown-it-mark": "3.0.1",
+        "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",
+        "pako": "2.1.0",
         "pinia": "2.0.33",
         "prosemirror-commands": "1.5.1",
         "prosemirror-history": "1.3.0",
@@ -68,6 +83,7 @@
         "socket.io-client": "4.6.1",
         "tabulator-tables": "5.4.4",
         "tippy.js": "6.3.7",
+        "twemoji": "14.0.2",
         "uuid": "9.0.0",
         "v-network-graph": "0.9.1",
         "vue": "3.2.47",
@@ -4351,7 +4367,6 @@
     },
     "node_modules/graceful-fs": {
       "version": "4.2.10",
-      "dev": true,
       "license": "ISC"
     },
     "node_modules/grapheme-splitter": {
@@ -5149,6 +5164,29 @@
       "version": "3.1.2",
       "license": "MIT"
     },
+    "node_modules/katex": {
+      "version": "0.16.4",
+      "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.4.tgz",
+      "integrity": "sha512-WudRKUj8yyBeVDI4aYMNxhx5Vhh2PjpzQw1GRu/LVGqL4m1AxwD1GcUp0IMbdJaf5zsjtj8ghP0DOQRYhroNkw==",
+      "funding": [
+        "https://opencollective.com/katex",
+        "https://github.com/sponsors/katex"
+      ],
+      "dependencies": {
+        "commander": "^8.0.0"
+      },
+      "bin": {
+        "katex": "cli.js"
+      }
+    },
+    "node_modules/katex/node_modules/commander": {
+      "version": "8.3.0",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
+      "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
+      "engines": {
+        "node": ">= 12"
+      }
+    },
     "node_modules/kind-of": {
       "version": "6.0.3",
       "dev": true,
@@ -5260,6 +5298,11 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/lodash.repeat": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/lodash.repeat/-/lodash.repeat-4.1.0.tgz",
+      "integrity": "sha512-eWsgQW89IewS95ZOcr15HHCX6FVDxq3f2PNUIng3fyzsPev9imFQxIYdFZ6crl8L56UR6ZlGDLcEb3RZsCSSqw=="
+    },
     "node_modules/lodash.truncate": {
       "version": "4.4.2",
       "dev": true,
@@ -5361,6 +5404,75 @@
         "markdown-it": "bin/markdown-it.js"
       }
     },
+    "node_modules/markdown-it-abbr": {
+      "version": "1.0.4",
+      "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",
+      "integrity": "sha512-zLftSaNrKuYl0kR5zm4gxXjHaOI3FAOEaloKmRA5hijmJZvSjmxcokOLlzycb/HXlUFWzXqpIEoyEMCE4i9MvQ=="
+    },
+    "node_modules/markdown-it-expand-tabs": {
+      "version": "1.0.13",
+      "resolved": "https://registry.npmjs.org/markdown-it-expand-tabs/-/markdown-it-expand-tabs-1.0.13.tgz",
+      "integrity": "sha512-ODpk98FWkGIq2vkwm2NOLt4G6TRgy3M9eTa9SFm06pUyOd0zjjYAwkhsjiCDU42pzKuz0ChiwBO0utuOj3LNOA==",
+      "dependencies": {
+        "lodash.repeat": "^4.0.0"
+      }
+    },
+    "node_modules/markdown-it-footnote": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/markdown-it-footnote/-/markdown-it-footnote-3.0.3.tgz",
+      "integrity": "sha512-YZMSuCGVZAjzKMn+xqIco9d1cLGxbELHZ9do/TSYVzraooV8ypsppKNmUJ0fVH5ljkCInQAtFpm8Rb3eXSrt5w=="
+    },
+    "node_modules/markdown-it-imsize": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/markdown-it-imsize/-/markdown-it-imsize-2.0.1.tgz",
+      "integrity": "sha512-5SH90ademqcR8ifQCBXRCfIR4HGfZZOh5pO0j2TglulfSQH+SBXM4Iw/QlTUbSoUwVZArCYgECoMvktDS2kP3w=="
+    },
+    "node_modules/markdown-it-mark": {
+      "version": "3.0.1",
+      "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-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",
+      "resolved": "https://registry.npmjs.org/markdown-it-sub/-/markdown-it-sub-1.0.0.tgz",
+      "integrity": "sha512-z2Rm/LzEE1wzwTSDrI+FlPEveAAbgdAdPhdWarq/ZGJrGW/uCQbKAnhoCsE4hAbc3SEym26+W2z/VQB0cQiA9Q=="
+    },
+    "node_modules/markdown-it-sup": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/markdown-it-sup/-/markdown-it-sup-1.0.0.tgz",
+      "integrity": "sha512-E32m0nV9iyhRR7CrhnzL5msqic7rL1juWre6TQNxsnApg7Uf+F97JOKxUijg5YwXz86lZ0mqfOnutoryyNdntQ=="
+    },
+    "node_modules/markdown-it-task-lists": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/markdown-it-task-lists/-/markdown-it-task-lists-2.1.1.tgz",
+      "integrity": "sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA=="
+    },
     "node_modules/mdurl": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz",
@@ -5789,6 +5901,11 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/pako": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
+      "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="
+    },
     "node_modules/param-case": {
       "version": "2.1.1",
       "dev": true,
@@ -7237,6 +7354,62 @@
       "version": "2.3.1",
       "license": "0BSD"
     },
+    "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",
       "dev": true,

+ 16 - 0
ux/package.json

@@ -53,9 +53,24 @@
     "graphql-tag": "2.12.6",
     "js-cookie": "3.0.1",
     "jwt-decode": "3.1.2",
+    "katex": "0.16.4",
     "lodash-es": "4.17.21",
     "lowlight": "2.8.1",
     "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-footnote": "3.0.3",
+    "markdown-it-imsize": "2.0.1",
+    "markdown-it-mark": "3.0.1",
+    "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",
+    "pako": "2.1.0",
     "pinia": "2.0.33",
     "prosemirror-commands": "1.5.1",
     "prosemirror-history": "1.3.0",
@@ -71,6 +86,7 @@
     "socket.io-client": "4.6.1",
     "tabulator-tables": "5.4.4",
     "tippy.js": "6.3.7",
+    "twemoji": "14.0.2",
     "uuid": "9.0.0",
     "v-network-graph": "0.9.1",
     "vue": "3.2.47",

+ 17 - 5
ux/src/components/EditorMarkdown.vue

@@ -236,9 +236,10 @@
 import { reactive, ref, shallowRef, nextTick, onBeforeMount, onMounted, watch } from 'vue'
 import { useMeta, useQuasar, setCssVar } from 'quasar'
 import { useI18n } from 'vue-i18n'
-import { get, flatten, last, times, startsWith } from 'lodash-es'
+import { get, flatten, last, times, startsWith, debounce } from 'lodash-es'
 
 import { useEditorStore } from 'src/stores/editor'
+import { usePageStore } from 'src/stores/page'
 import { useSiteStore } from 'src/stores/site'
 
 // Code Mirror
@@ -260,6 +261,9 @@ import 'codemirror/addon/fold/foldcode.js'
 import 'codemirror/addon/fold/foldgutter.js'
 import 'codemirror/addon/fold/foldgutter.css'
 
+// Markdown Renderer
+import { MarkdownRenderer } from 'src/renderers/markdown'
+
 // QUASAR
 
 const $q = useQuasar()
@@ -267,6 +271,7 @@ const $q = useQuasar()
 // STORES
 
 const editorStore = useEditorStore()
+const pageStore = usePageStore()
 const siteStore = useSiteStore()
 
 // I18N
@@ -279,12 +284,13 @@ const cm = shallowRef(null)
 const cmRef = ref(null)
 
 const state = reactive({
-  content: '',
   previewShown: true,
   previewHTML: '',
   previewScrollSync: true
 })
 
+const md = new MarkdownRenderer({})
+
 // Platform detection
 const CtrlKey = /Mac/.test(navigator.platform) ? 'Cmd' : 'Ctrl'
 
@@ -396,6 +402,12 @@ function toggleMarkup ({ start, end }) {
   cm.value.doc.replaceSelections(cm.value.doc.getSelections().map(s => start + s + end))
 }
 
+const onCmInput = debounce(processContent, 600)
+
+function processContent (newContent) {
+  state.previewHTML = md.render(newContent)
+}
+
 // MOUNTED
 
 onMounted(async () => {
@@ -424,12 +436,12 @@ onMounted(async () => {
     gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter']
   })
 
-  cm.value.setValue(state.content)
+  cm.value.setValue(pageStore.content)
   cm.value.on('change', c => {
-    editorStore.$patch({
+    pageStore.$patch({
       content: c.getValue()
     })
-    // onCmInput(editorStore.content)
+    onCmInput(pageStore.content)
   })
 
   cm.value.setSize(null, '100%')

+ 10 - 0
ux/src/components/PageHeader.vue

@@ -199,6 +199,16 @@ const editUrl = computed(() => {
 // METHODS
 
 async function discardChanges () {
+  // Is it the home page in create mode?
+  if (editorStore.mode === 'create' && pageStore.path === '' && pageStore.locale === 'en') {
+    editorStore.$patch({
+      isActive: false,
+      editor: ''
+    })
+    siteStore.overlay = 'Welcome'
+    return
+  }
+
   $q.loading.show()
   try {
     editorStore.$patch({

+ 8 - 1
ux/src/components/WelcomeOverlay.vue

@@ -90,7 +90,14 @@ useMeta({
 
 function createHomePage (editor) {
   siteStore.overlay = ''
-  pageStore.pageCreate({ editor, locale: 'en', path: '' })
+  pageStore.pageCreate({
+    editor,
+    locale: 'en',
+    path: '',
+    title: t('welcome.homeDefault.title'),
+    description: t('welcome.homeDefault.description'),
+    content: t('welcome.homeDefault.content')
+  })
 }
 
 function loadAdmin () {

+ 3 - 0
ux/src/i18n/locales/en.json

@@ -1732,6 +1732,9 @@
   "tags.selectOneMoreTagsHint": "Select one or more tags on the left.",
   "welcome.admin": "Administration Area",
   "welcome.createHome": "Create the homepage",
+  "welcome.homeDefault.content": "Write some content here...",
+  "welcome.homeDefault.description": "Welcome to my wiki!",
+  "welcome.homeDefault.title": "Home",
   "welcome.subtitle": "Let's get started...",
   "welcome.title": "Welcome to Wiki.js!"
 }

+ 99 - 0
ux/src/renderers/markdown.js

@@ -0,0 +1,99 @@
+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'
+import 'katex/dist/contrib/mhchem'
+import twemoji from 'twemoji'
+import plantuml from './modules/plantuml'
+import katexHelper from './modules/katex'
+
+import { escape } from 'lodash-es'
+
+export class MarkdownRenderer {
+  constructor (conf = {}) {
+    this.md = new MarkdownIt({
+      html: true,
+      breaks: true,
+      linkify: true,
+      typography: true,
+      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 {
+          return `<pre class="line-numbers"><code class="language-${lang}">${escape(str)}</code></pre>`
+        }
+      }
+    })
+      .use(mdAttrs, {
+        allowedAttributes: ['id', 'class', 'target']
+      })
+      .use(mdDecorate)
+      .use(underline)
+      .use(mdEmoji)
+      .use(mdTaskLists, { label: false, labelAfter: false })
+      .use(mdExpandTabs)
+      .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, {})
+
+    // -> KATEX
+    const macros = {}
+    this.md.inline.ruler.after('escape', 'katex_inline', katexHelper.katexInline)
+    this.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
+      }
+    }
+    this.md.block.ruler.after('blockquote', 'katex_block', katexHelper.katexBlock, {
+      alt: ['paragraph', 'reference', 'blockquote', 'list']
+    })
+    this.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
+    this.md.renderer.rules.emoji = (token, idx) => {
+      return twemoji.parse(token[idx].content, {
+        callback (icon, opts) {
+          return `/_assets/svg/twemoji/${icon}.svg`
+        }
+      })
+    }
+  }
+
+  render (src) {
+    return this.md.render(src)
+  }
+}

+ 145 - 0
ux/src/renderers/modules/katex.js

@@ -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
+  }
+}

+ 12 - 0
ux/src/renderers/modules/markdown-it-underline.js

@@ -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
ux/src/renderers/modules/plantuml.js

@@ -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 '?'
+}

+ 4 - 4
ux/src/stores/page.js

@@ -173,7 +173,7 @@ export const usePageStore = defineStore('page', {
     /**
      * PAGE - CREATE
      */
-    pageCreate ({ editor, locale, path }) {
+    pageCreate ({ editor, locale, path, title = '', description = '', content = '' }) {
       const editorStore = useEditorStore()
 
       // if (['markdown', 'api'].includes(editor)) {
@@ -196,14 +196,14 @@ export const usePageStore = defineStore('page', {
       } else {
         this.path = this.path.length < 2 ? 'new-page' : `${this.path}/new-page`
       }
-      this.title = ''
-      this.description = ''
+      this.title = title ?? ''
+      this.description = description ?? ''
       this.icon = 'las la-file-alt'
       this.publishState = 'published'
       this.relations = []
       this.tags = []
 
-      this.content = ''
+      this.content = content ?? ''
       this.render = ''
 
       // -> View Mode