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

pull/6775/head
NGPixel 2 years ago
parent c0df6d5b69
commit aeef7b1e53
No known key found for this signature in database
GPG Key ID: B755FB6870B30F63

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

@ -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 {

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

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

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

@ -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')
}

@ -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) {

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

@ -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,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: {}

@ -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: {}

@ -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: {}

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

@ -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,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: {}

@ -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,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: {}

@ -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: {}

@ -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: {}

@ -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:

@ -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: {}

@ -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: {}

@ -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",

@ -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",

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

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

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

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

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

@ -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,

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

After

Width:  |  Height:  |  Size: 1.0 KiB

@ -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(() => {

@ -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',

@ -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.')

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

@ -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 () {

@ -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({

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

@ -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 () {

@ -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)",

@ -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">

@ -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: {

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

@ -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) {

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

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

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

@ -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',

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

Loading…
Cancel
Save