From aeef7b1e534cae8c3fb4aee0516de3e28df578ea Mon Sep 17 00:00:00 2001 From: NGPixel Date: Mon, 8 May 2023 02:54:52 +0000 Subject: [PATCH] feat: markdown server rendering + use editor config + change password --- server/db/migrations/3.0.0.mjs | 1 + server/graph/resolvers/user.mjs | 32 +- server/graph/schemas/page.graphql | 3 +- server/graph/schemas/site.graphql | 2 + server/graph/schemas/user.graphql | 7 +- server/models/pages.mjs | 4 - server/models/renderers.mjs | 4 +- server/models/sites.mjs | 1 + server/models/users.mjs | 34 +-- .../definition.yml | 3 +- .../renderer.js => asciinema/renderer.mjs} | 0 .../definition.yml | 3 +- .../renderer.js => blockquotes/renderer.mjs} | 0 .../definition.yml | 3 +- .../renderer.mjs} | 0 .../{html-core => core}/definition.yml | 8 +- server/modules/rendering/core/renderer.mjs | 286 +++++++++++++++++ .../{html-diagram => diagram}/definition.yml | 3 +- .../renderer.js => diagram/renderer.mjs} | 0 .../modules/rendering/html-core/renderer.mjs | 288 ------------------ .../definition.yml | 3 +- .../renderer.mjs} | 0 .../definition.yml | 3 +- .../renderer.js => mediaplayers/renderer.mjs} | 0 .../{html-mermaid => mermaid}/definition.yml | 3 +- .../renderer.js => mermaid/renderer.mjs} | 0 .../definition.yml | 3 +- .../renderer.js => security/renderer.mjs} | 0 .../{html-tabset => tabset}/definition.yml | 3 +- .../renderer.js => tabset/renderer.mjs} | 0 .../{html-twemoji => twemoji}/definition.yml | 3 +- .../renderer.js => twemoji/renderer.mjs} | 0 server/package-lock.json | 91 +++++- server/package.json | 6 +- server/renderers/markdown.mjs | 157 ++++++++++ server/renderers/modules/katex.mjs | 145 +++++++++ server/renderers/modules/kroki.mjs | 143 +++++++++ .../modules/markdown-it-underline.mjs | 12 + server/renderers/modules/plantuml.mjs | 187 ++++++++++++ server/tasks/workers/render-page.mjs | 32 +- .../_assets/icons/ultraviolet-lowercase.svg | 1 + ux/src/components/AuthLoginPanel.vue | 3 +- ux/src/components/EditorMarkdown.vue | 5 +- .../EditorMarkdownConfigOverlay.vue | 5 +- ux/src/components/PageHeader.vue | 7 +- ux/src/components/PageNewMenu.vue | 8 +- ux/src/components/UserChangePwdDialog.vue | 20 +- ux/src/components/UserEditOverlay.vue | 32 +- ux/src/components/WelcomeOverlay.vue | 6 +- ux/src/i18n/locales/en.json | 8 +- ux/src/layouts/ProfileLayout.vue | 15 + ux/src/pages/AdminGeneral.vue | 57 ++-- ux/src/pages/AdminStorage.vue | 2 - ux/src/pages/AdminUsers.vue | 7 +- ux/src/renderers/markdown.js | 83 ++++- ux/src/renderers/modules/kroki.mjs | 143 +++++++++ ux/src/stores/editor.js | 58 +++- ux/src/stores/page.js | 28 +- ux/src/stores/user.js | 16 +- 59 files changed, 1531 insertions(+), 446 deletions(-) rename server/modules/rendering/{html-asciinema => asciinema}/definition.yml (78%) rename server/modules/rendering/{html-asciinema/renderer.js => asciinema/renderer.mjs} (100%) rename server/modules/rendering/{html-blockquotes => blockquotes}/definition.yml (77%) rename server/modules/rendering/{html-blockquotes/renderer.js => blockquotes/renderer.mjs} (100%) rename server/modules/rendering/{html-codehighlighter => codehighlighter}/definition.yml (78%) rename server/modules/rendering/{html-codehighlighter/renderer.js => codehighlighter/renderer.mjs} (100%) rename server/modules/rendering/{html-core => core}/definition.yml (90%) create mode 100644 server/modules/rendering/core/renderer.mjs rename server/modules/rendering/{html-diagram => diagram}/definition.yml (80%) rename server/modules/rendering/{html-diagram/renderer.js => diagram/renderer.mjs} (100%) delete mode 100644 server/modules/rendering/html-core/renderer.mjs rename server/modules/rendering/{html-image-prefetch => image-prefetch}/definition.yml (79%) rename server/modules/rendering/{html-image-prefetch/renderer.js => image-prefetch/renderer.mjs} (100%) rename server/modules/rendering/{html-mediaplayers => mediaplayers}/definition.yml (78%) rename server/modules/rendering/{html-mediaplayers/renderer.js => mediaplayers/renderer.mjs} (100%) rename server/modules/rendering/{html-mermaid => mermaid}/definition.yml (80%) rename server/modules/rendering/{html-mermaid/renderer.js => mermaid/renderer.mjs} (100%) rename server/modules/rendering/{html-security => security}/definition.yml (95%) rename server/modules/rendering/{html-security/renderer.js => security/renderer.mjs} (100%) rename server/modules/rendering/{html-tabset => tabset}/definition.yml (76%) rename server/modules/rendering/{html-tabset/renderer.js => tabset/renderer.mjs} (100%) rename server/modules/rendering/{html-twemoji => twemoji}/definition.yml (82%) rename server/modules/rendering/{html-twemoji/renderer.js => twemoji/renderer.mjs} (100%) create mode 100644 server/renderers/markdown.mjs create mode 100644 server/renderers/modules/katex.mjs create mode 100644 server/renderers/modules/kroki.mjs create mode 100644 server/renderers/modules/markdown-it-underline.mjs create mode 100644 server/renderers/modules/plantuml.mjs create mode 100644 ux/public/_assets/icons/ultraviolet-lowercase.svg create mode 100644 ux/src/renderers/modules/kroki.mjs diff --git a/server/db/migrations/3.0.0.mjs b/server/db/migrations/3.0.0.mjs index 1adbe492..9ac58824 100644 --- a/server/db/migrations/3.0.0.mjs +++ b/server/db/migrations/3.0.0.mjs @@ -543,6 +543,7 @@ export async function up (knex) { contentLicense: '', footerExtra: '', pageExtensions: ['md', 'html', 'txt'], + pageCasing: true, defaults: { tocDepth: { min: 1, diff --git a/server/graph/resolvers/user.mjs b/server/graph/resolvers/user.mjs index fba9a17d..6982132f 100644 --- a/server/graph/resolvers/user.mjs +++ b/server/graph/resolvers/user.mjs @@ -1,5 +1,5 @@ import { generateError, generateSuccess } from '../../helpers/graph.mjs' -import _ from 'lodash-es' +import _, { isNil } from 'lodash-es' import path from 'node:path' import fs from 'fs-extra' @@ -139,7 +139,6 @@ export default { }, async updateUser (obj, args) { try { - console.info(args.id) await WIKI.db.users.updateUser(args.id, args.patch) return { @@ -210,8 +209,33 @@ export default { return generateError(err) } }, - resetUserPassword (obj, args) { - return false + async changeUserPassword (obj, args, context) { + try { + if (args.newPassword?.length < 6) { + throw new Error('ERR_PASSWORD_TOO_SHORT') + } + + const usr = await WIKI.db.users.query().findById(args.id) + if (!usr) { + throw new Error('ERR_USER_NOT_FOUND') + } + const localAuth = await WIKI.db.authentication.getStrategy('local') + + usr.auth[localAuth.id].password = await bcrypt.hash(args.newPassword, 12) + if (!isNil(args.mustChangePassword)) { + usr.auth[localAuth.id].mustChangePwd = args.mustChangePassword + } + + await WIKI.db.users.query().patch({ + auth: usr.auth + }).findById(args.id) + + return { + operation: generateSuccess('User password updated successfully') + } + } catch (err) { + return generateError(err) + } }, async updateProfile (obj, args, context) { try { diff --git a/server/graph/schemas/page.graphql b/server/graph/schemas/page.graphql index 5f8ab204..92025c68 100644 --- a/server/graph/schemas/page.graphql +++ b/server/graph/schemas/page.graphql @@ -85,7 +85,6 @@ extend type Mutation { publishEndDate: Date publishStartDate: Date relations: [PageRelationInput!] - render: String scriptCss: String scriptJsLoad: String scriptJsUnload: String @@ -343,8 +342,8 @@ input PageUpdateInput { publishEndDate: Date publishStartDate: Date publishState: PagePublishState + reasonForChange: String relations: [PageRelationInput!] - render: String scriptJsLoad: String scriptJsUnload: String scriptCss: String diff --git a/server/graph/schemas/site.graphql b/server/graph/schemas/site.graphql index db87eef2..e149df66 100644 --- a/server/graph/schemas/site.graphql +++ b/server/graph/schemas/site.graphql @@ -60,6 +60,7 @@ type Site { contentLicense: String footerExtra: String pageExtensions: String + pageCasing: Boolean logoText: Boolean sitemap: Boolean robots: SiteRobots @@ -177,6 +178,7 @@ input SiteUpdateInput { contentLicense: String footerExtra: String pageExtensions: String + pageCasing: Boolean logoText: Boolean sitemap: Boolean robots: SiteRobotsInput diff --git a/server/graph/schemas/user.graphql b/server/graph/schemas/user.graphql index 3d85b4b0..60f4e1a9 100644 --- a/server/graph/schemas/user.graphql +++ b/server/graph/schemas/user.graphql @@ -68,8 +68,10 @@ extend type Mutation { id: UUID! ): DefaultResponse - resetUserPassword( - id: Int! + changeUserPassword( + id: UUID! + newPassword: String! + mustChangePassword: Boolean ): DefaultResponse updateProfile( @@ -186,7 +188,6 @@ enum UserCvdChoices { input UserUpdateInput { email: String name: String - newPassword: String groups: [UUID!] isActive: Boolean isVerified: Boolean diff --git a/server/models/pages.mjs b/server/models/pages.mjs index febfd310..76bd4dd9 100644 --- a/server/models/pages.mjs +++ b/server/models/pages.mjs @@ -327,7 +327,6 @@ export class Page extends Model { publishEndDate: opts.publishEndDate?.toISO(), publishStartDate: opts.publishStartDate?.toISO(), relations: opts.relations ?? [], - render: opts.render ?? '', siteId: opts.siteId, title: opts.title, toc: '[]', @@ -452,9 +451,6 @@ export class Page extends Model { if ('content' in opts.patch) { patch.content = opts.patch.content - if ('render' in opts.patch) { - patch.render = opts.patch.render - } historyData.affectedFields.push('content') } diff --git a/server/models/renderers.mjs b/server/models/renderers.mjs index 5be8cb3c..58401ed0 100644 --- a/server/models/renderers.mjs +++ b/server/models/renderers.mjs @@ -99,8 +99,8 @@ export class Renderer extends Model { // -> Delete removed Renderers for (const renderer of dbRenderers) { if (!some(WIKI.data.renderers, ['key', renderer.module])) { - await WIKI.db.renderers.query().where('module', renderer.key).del() - WIKI.logger.info(`Removed renderer ${renderer.key} because it is no longer present in the modules folder: [ OK ]`) + await WIKI.db.renderers.query().where('module', renderer.module).del() + WIKI.logger.info(`Removed renderer ${renderer.module} because it is no longer present in the modules folder: [ OK ]`) } } } catch (err) { diff --git a/server/models/sites.mjs b/server/models/sites.mjs index 26be4363..071e8894 100644 --- a/server/models/sites.mjs +++ b/server/models/sites.mjs @@ -57,6 +57,7 @@ export class Site extends Model { contentLicense: '', footerExtra: '', pageExtensions: ['md', 'html', 'txt'], + pageCasing: true, defaults: { tocDepth: { min: 1, diff --git a/server/models/users.mjs b/server/models/users.mjs index 0140e542..15040df6 100644 --- a/server/models/users.mjs +++ b/server/models/users.mjs @@ -591,7 +591,7 @@ export class User extends Model { timezone: WIKI.config.userDefaults.timezone || 'America/New_York', appearance: 'site', dateFormat: WIKI.config.userDefaults.dateFormat || 'YYYY-MM-DD', - timeFormat: WIKI.config.userDefaults.timeFormat || '12h' + timeFormat: WIKI.config.userDefaults.timeFormat || '12h' } }) @@ -623,15 +623,12 @@ export class User extends Model { * * @param {Object} param0 User ID and fields to update */ - static async updateUser (id, { email, name, newPassword, groups, location, jobTitle, timezone, dateFormat, appearance }) { + static async updateUser (id, { email, name, groups, isVerified, isActive, meta, prefs }) { const usr = await WIKI.db.users.query().findById(id) if (usr) { let usrData = {} if (!isEmpty(email) && email !== usr.email) { - const dupUsr = await WIKI.db.users.query().select('id').where({ - email, - providerKey: usr.providerKey - }).first() + const dupUsr = await WIKI.db.users.query().select('id').where({ email }).first() if (dupUsr) { throw new WIKI.Error.AuthAccountAlreadyExists() } @@ -640,12 +637,6 @@ export class User extends Model { if (!isEmpty(name) && name !== usr.name) { usrData.name = name.trim() } - if (!isEmpty(newPassword)) { - if (newPassword.length < 6) { - throw new WIKI.Error.InputInvalid('Password must be at least 6 characters!') - } - usrData.password = newPassword - } if (isArray(groups)) { const usrGroupsRaw = await usr.$relatedQuery('groups') const usrGroups = usrGroupsRaw.map(g => g.id) @@ -660,20 +651,17 @@ export class User extends Model { await usr.$relatedQuery('groups').unrelate().where('groupId', grp) } } - if (!isEmpty(location) && location !== usr.location) { - usrData.location = location.trim() - } - if (!isEmpty(jobTitle) && jobTitle !== usr.jobTitle) { - usrData.jobTitle = jobTitle.trim() + if (!isNil(isVerified)) { + usrData.isVerified = isVerified } - if (!isEmpty(timezone) && timezone !== usr.timezone) { - usrData.timezone = timezone + if (!isNil(isActive)) { + usrData.isVerified = isActive } - if (!isNil(dateFormat) && dateFormat !== usr.dateFormat) { - usrData.dateFormat = dateFormat + if (!isEmpty(meta)) { + usrData.meta = meta } - if (!isNil(appearance) && appearance !== usr.appearance) { - usrData.appearance = appearance + if (!isEmpty(prefs)) { + usrData.prefs = prefs } await WIKI.db.users.query().patch(usrData).findById(id) } else { diff --git a/server/modules/rendering/html-asciinema/definition.yml b/server/modules/rendering/asciinema/definition.yml similarity index 78% rename from server/modules/rendering/html-asciinema/definition.yml rename to server/modules/rendering/asciinema/definition.yml index bda5496a..e1bba61c 100644 --- a/server/modules/rendering/html-asciinema/definition.yml +++ b/server/modules/rendering/asciinema/definition.yml @@ -1,8 +1,7 @@ -key: htmlAsciinema title: Asciinema description: Embed asciinema players from compatible links author: requarks.io icon: mdi-theater enabledDefault: false -dependsOn: html-core +dependsOn: core props: {} diff --git a/server/modules/rendering/html-asciinema/renderer.js b/server/modules/rendering/asciinema/renderer.mjs similarity index 100% rename from server/modules/rendering/html-asciinema/renderer.js rename to server/modules/rendering/asciinema/renderer.mjs diff --git a/server/modules/rendering/html-blockquotes/definition.yml b/server/modules/rendering/blockquotes/definition.yml similarity index 77% rename from server/modules/rendering/html-blockquotes/definition.yml rename to server/modules/rendering/blockquotes/definition.yml index 5c43f825..c2a250db 100644 --- a/server/modules/rendering/html-blockquotes/definition.yml +++ b/server/modules/rendering/blockquotes/definition.yml @@ -1,8 +1,7 @@ -key: htmlBlockquotes title: Blockquotes description: Parse blockquotes box styling author: requarks.io icon: mdi-alpha-t-box-outline enabledDefault: true -dependsOn: html-core +dependsOn: core props: {} diff --git a/server/modules/rendering/html-blockquotes/renderer.js b/server/modules/rendering/blockquotes/renderer.mjs similarity index 100% rename from server/modules/rendering/html-blockquotes/renderer.js rename to server/modules/rendering/blockquotes/renderer.mjs diff --git a/server/modules/rendering/html-codehighlighter/definition.yml b/server/modules/rendering/codehighlighter/definition.yml similarity index 78% rename from server/modules/rendering/html-codehighlighter/definition.yml rename to server/modules/rendering/codehighlighter/definition.yml index 075a56b8..55480e41 100644 --- a/server/modules/rendering/html-codehighlighter/definition.yml +++ b/server/modules/rendering/codehighlighter/definition.yml @@ -1,9 +1,8 @@ -key: htmlCodehighlighter title: Code Highlighting Post-Processor description: Syntax detector for programming code author: requarks.io icon: mdi-code-braces enabledDefault: true -dependsOn: html-core +dependsOn: core step: pre props: {} diff --git a/server/modules/rendering/html-codehighlighter/renderer.js b/server/modules/rendering/codehighlighter/renderer.mjs similarity index 100% rename from server/modules/rendering/html-codehighlighter/renderer.js rename to server/modules/rendering/codehighlighter/renderer.mjs diff --git a/server/modules/rendering/html-core/definition.yml b/server/modules/rendering/core/definition.yml similarity index 90% rename from server/modules/rendering/html-core/definition.yml rename to server/modules/rendering/core/definition.yml index 3ef3dd1f..c8df5991 100644 --- a/server/modules/rendering/html-core/definition.yml +++ b/server/modules/rendering/core/definition.yml @@ -1,9 +1,7 @@ -key: html-core +key: core title: Core description: Basic HTML Parser author: requarks.io -input: html -output: html icon: mdi-language-html5 props: absoluteLinks: @@ -25,5 +23,5 @@ props: hint: External links with _blank attribute will have an additional rel attribute. order: 3 enum: - - noreferrer - - noopener + - noreferrer + - noopener diff --git a/server/modules/rendering/core/renderer.mjs b/server/modules/rendering/core/renderer.mjs new file mode 100644 index 00000000..87f4d834 --- /dev/null +++ b/server/modules/rendering/core/renderer.mjs @@ -0,0 +1,286 @@ +import { reject } from 'lodash-es' +import * as cheerio from 'cheerio' +import uslug from 'uslug' +import pageHelper from '../../../helpers/page' +import { URL } from 'node:url' + +const mustacheRegExp = /(\{|{?){2}(.+?)(\}|}?){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(` `) + + 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('
') + } + }) + + // -------------------------------- + // 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('', '').replace('', '')) + + 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) + }) +} diff --git a/server/modules/rendering/html-diagram/definition.yml b/server/modules/rendering/diagram/definition.yml similarity index 80% rename from server/modules/rendering/html-diagram/definition.yml rename to server/modules/rendering/diagram/definition.yml index 2c7eeaf9..bdf9bf2b 100644 --- a/server/modules/rendering/html-diagram/definition.yml +++ b/server/modules/rendering/diagram/definition.yml @@ -1,8 +1,7 @@ -key: htmlDiagram title: Diagrams Post-Processor description: HTML Processing for diagrams (draw.io) author: requarks.io icon: mdi-chart-multiline enabledDefault: true -dependsOn: html-core +dependsOn: core props: {} diff --git a/server/modules/rendering/html-diagram/renderer.js b/server/modules/rendering/diagram/renderer.mjs similarity index 100% rename from server/modules/rendering/html-diagram/renderer.js rename to server/modules/rendering/diagram/renderer.mjs diff --git a/server/modules/rendering/html-core/renderer.mjs b/server/modules/rendering/html-core/renderer.mjs deleted file mode 100644 index 73eefb5a..00000000 --- a/server/modules/rendering/html-core/renderer.mjs +++ /dev/null @@ -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 = /(\{|{?){2}(.+?)(\}|}?){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(` `) - - 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('
') - } - }) - - // -------------------------------- - // 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('', '').replace('', '')) - - 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) - }) -} diff --git a/server/modules/rendering/html-image-prefetch/definition.yml b/server/modules/rendering/image-prefetch/definition.yml similarity index 79% rename from server/modules/rendering/html-image-prefetch/definition.yml rename to server/modules/rendering/image-prefetch/definition.yml index 09ebb028..8b456a6a 100644 --- a/server/modules/rendering/html-image-prefetch/definition.yml +++ b/server/modules/rendering/image-prefetch/definition.yml @@ -1,8 +1,7 @@ -key: htmlImagePrefetch title: Image Prefetch description: Prefetch remotely rendered images (korki/plantuml) author: requarks.io icon: mdi-cloud-download-outline enabledDefault: false -dependsOn: html-core +dependsOn: core props: {} diff --git a/server/modules/rendering/html-image-prefetch/renderer.js b/server/modules/rendering/image-prefetch/renderer.mjs similarity index 100% rename from server/modules/rendering/html-image-prefetch/renderer.js rename to server/modules/rendering/image-prefetch/renderer.mjs diff --git a/server/modules/rendering/html-mediaplayers/definition.yml b/server/modules/rendering/mediaplayers/definition.yml similarity index 78% rename from server/modules/rendering/html-mediaplayers/definition.yml rename to server/modules/rendering/mediaplayers/definition.yml index e37ae900..9839c0fb 100644 --- a/server/modules/rendering/html-mediaplayers/definition.yml +++ b/server/modules/rendering/mediaplayers/definition.yml @@ -1,8 +1,7 @@ -key: htmlMediaplayers title: Media Players description: Embed players such as Youtube, Vimeo, Soundcloud, etc. author: requarks.io icon: mdi-video enabledDefault: true -dependsOn: html-core +dependsOn: core props: {} diff --git a/server/modules/rendering/html-mediaplayers/renderer.js b/server/modules/rendering/mediaplayers/renderer.mjs similarity index 100% rename from server/modules/rendering/html-mediaplayers/renderer.js rename to server/modules/rendering/mediaplayers/renderer.mjs diff --git a/server/modules/rendering/html-mermaid/definition.yml b/server/modules/rendering/mermaid/definition.yml similarity index 80% rename from server/modules/rendering/html-mermaid/definition.yml rename to server/modules/rendering/mermaid/definition.yml index a0608dfa..e5c5ce83 100644 --- a/server/modules/rendering/html-mermaid/definition.yml +++ b/server/modules/rendering/mermaid/definition.yml @@ -1,8 +1,7 @@ -key: htmlMermaid title: Mermaid description: Generate flowcharts from Mermaid syntax author: requarks.io icon: mdi-arrow-decision-outline enabledDefault: true -dependsOn: html-core +dependsOn: core props: {} diff --git a/server/modules/rendering/html-mermaid/renderer.js b/server/modules/rendering/mermaid/renderer.mjs similarity index 100% rename from server/modules/rendering/html-mermaid/renderer.js rename to server/modules/rendering/mermaid/renderer.mjs diff --git a/server/modules/rendering/html-security/definition.yml b/server/modules/rendering/security/definition.yml similarity index 95% rename from server/modules/rendering/html-security/definition.yml rename to server/modules/rendering/security/definition.yml index bcfcd7bb..9beb29c9 100644 --- a/server/modules/rendering/html-security/definition.yml +++ b/server/modules/rendering/security/definition.yml @@ -1,10 +1,9 @@ -key: htmlSecurity title: Security description: Filter and strips potentially dangerous content author: requarks.io icon: mdi-fire enabledDefault: true -dependsOn: html-core +dependsOn: core step: post order: 99999 props: diff --git a/server/modules/rendering/html-security/renderer.js b/server/modules/rendering/security/renderer.mjs similarity index 100% rename from server/modules/rendering/html-security/renderer.js rename to server/modules/rendering/security/renderer.mjs diff --git a/server/modules/rendering/html-tabset/definition.yml b/server/modules/rendering/tabset/definition.yml similarity index 76% rename from server/modules/rendering/html-tabset/definition.yml rename to server/modules/rendering/tabset/definition.yml index 281a8074..c6c82c51 100644 --- a/server/modules/rendering/html-tabset/definition.yml +++ b/server/modules/rendering/tabset/definition.yml @@ -1,8 +1,7 @@ -key: htmlTabset title: Tabsets description: Transform headers into tabs author: requarks.io icon: mdi-tab enabledDefault: true -dependsOn: html-core +dependsOn: core props: {} diff --git a/server/modules/rendering/html-tabset/renderer.js b/server/modules/rendering/tabset/renderer.mjs similarity index 100% rename from server/modules/rendering/html-tabset/renderer.js rename to server/modules/rendering/tabset/renderer.mjs diff --git a/server/modules/rendering/html-twemoji/definition.yml b/server/modules/rendering/twemoji/definition.yml similarity index 82% rename from server/modules/rendering/html-twemoji/definition.yml rename to server/modules/rendering/twemoji/definition.yml index fe96ddc3..49d8daaa 100644 --- a/server/modules/rendering/html-twemoji/definition.yml +++ b/server/modules/rendering/twemoji/definition.yml @@ -1,10 +1,9 @@ -key: htmlTwemoji title: Twemoji description: Apply Twitter Emojis to all Unicode emojis author: requarks.io icon: mdi-emoticon-happy-outline enabledDefault: true -dependsOn: html-core +dependsOn: core step: post order: 10 props: {} diff --git a/server/modules/rendering/html-twemoji/renderer.js b/server/modules/rendering/twemoji/renderer.mjs similarity index 100% rename from server/modules/rendering/html-twemoji/renderer.js rename to server/modules/rendering/twemoji/renderer.mjs diff --git a/server/package-lock.json b/server/package-lock.json index 56dcbbc9..316f3c21 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -76,13 +76,14 @@ "luxon": "3.3.0", "markdown-it": "13.0.1", "markdown-it-abbr": "1.0.4", + "markdown-it-attrs": "4.1.6", + "markdown-it-decorate": "1.2.2", "markdown-it-emoji": "2.0.2", "markdown-it-expand-tabs": "1.0.13", - "markdown-it-external-links": "0.0.6", "markdown-it-footnote": "3.0.3", "markdown-it-imsize": "2.0.1", "markdown-it-mark": "3.0.1", - "markdown-it-mathjax": "2.0.0", + "markdown-it-multimd-table": "4.2.1", "markdown-it-sub": "1.0.0", "markdown-it-sup": "1.0.0", "markdown-it-task-lists": "2.1.1", @@ -142,6 +143,7 @@ "striptags": "3.2.0", "tar-fs": "2.1.1", "turndown": "7.1.2", + "twemoji": "14.0.2", "uslug": "1.0.4", "uuid": "9.0.0", "validate.js": "0.13.1", @@ -5895,6 +5897,22 @@ "resolved": "https://registry.npmjs.org/markdown-it-abbr/-/markdown-it-abbr-1.0.4.tgz", "integrity": "sha512-ZeA4Z4SaBbYysZap5iZcxKmlPL6bYA8grqhzJIHB1ikn7njnzaP8uwbtuXc4YXD5LicI4/2Xmc0VwmSiFV04gg==" }, + "node_modules/markdown-it-attrs": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/markdown-it-attrs/-/markdown-it-attrs-4.1.6.tgz", + "integrity": "sha512-O7PDKZlN8RFMyDX13JnctQompwrrILuz2y43pW2GagcwpIIElkAdfeek+erHfxUOlXWPsjFeWmZ8ch1xtRLWpA==", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "markdown-it": ">= 9.0.0" + } + }, + "node_modules/markdown-it-decorate": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/markdown-it-decorate/-/markdown-it-decorate-1.2.2.tgz", + "integrity": "sha512-7BFWJ97KBXgkaPVjKHISQnhSW8RWQ7yRNXpr8pPUV2Rw4GHvGrgb6CelKCM+GSijP0uSLCAVfc/knWIz+2v/Sw==" + }, "node_modules/markdown-it-emoji": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/markdown-it-emoji/-/markdown-it-emoji-2.0.2.tgz", @@ -5908,11 +5926,6 @@ "lodash.repeat": "^4.0.0" } }, - "node_modules/markdown-it-external-links": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/markdown-it-external-links/-/markdown-it-external-links-0.0.6.tgz", - "integrity": "sha512-b0fsXGsf0higb7DEthr8ZKYErRB4ftIw1iklUZz0iN3leZxIZMEiFspfFdKrzpfblleZMnitz6ETTDgphjCSuA==" - }, "node_modules/markdown-it-footnote": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/markdown-it-footnote/-/markdown-it-footnote-3.0.3.tgz", @@ -5928,10 +5941,10 @@ "resolved": "https://registry.npmjs.org/markdown-it-mark/-/markdown-it-mark-3.0.1.tgz", "integrity": "sha512-HyxjAu6BRsdt6Xcv6TKVQnkz/E70TdGXEFHRYBGLncRE9lBFwDNLVtFojKxjJWgJ+5XxUwLaHXy+2sGBbDn+4A==" }, - "node_modules/markdown-it-mathjax": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/markdown-it-mathjax/-/markdown-it-mathjax-2.0.0.tgz", - "integrity": "sha512-Fafv7TnMENccWYTNjMZzV4BzONPxpK9Mknr1iMEK6m7PI5a5UTCOFctPzx7Nhv81fFzYEY8WHDkSu9n43fTV9g==" + "node_modules/markdown-it-multimd-table": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/markdown-it-multimd-table/-/markdown-it-multimd-table-4.2.1.tgz", + "integrity": "sha512-0WEkr2Siw1I9TFaKEHwCXDRxIXWmuzht496Mb8yCkFnK+OVDqMSN6k5/FwyKlZIMtYNOK02e8o0uh3H0WMqstQ==" }, "node_modules/markdown-it-sub": { "version": "1.0.0", @@ -9167,6 +9180,62 @@ "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==" }, + "node_modules/twemoji": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/twemoji/-/twemoji-14.0.2.tgz", + "integrity": "sha512-BzOoXIe1QVdmsUmZ54xbEH+8AgtOKUiG53zO5vVP2iUu6h5u9lN15NcuS6te4OY96qx0H7JK9vjjl9WQbkTRuA==", + "dependencies": { + "fs-extra": "^8.0.1", + "jsonfile": "^5.0.0", + "twemoji-parser": "14.0.0", + "universalify": "^0.1.2" + } + }, + "node_modules/twemoji-parser": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/twemoji-parser/-/twemoji-parser-14.0.0.tgz", + "integrity": "sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA==" + }, + "node_modules/twemoji/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/twemoji/node_modules/fs-extra/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/twemoji/node_modules/jsonfile": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-5.0.0.tgz", + "integrity": "sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w==", + "dependencies": { + "universalify": "^0.1.2" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/twemoji/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/server/package.json b/server/package.json index 4b80b1d2..12c04b71 100644 --- a/server/package.json +++ b/server/package.json @@ -101,13 +101,14 @@ "luxon": "3.3.0", "markdown-it": "13.0.1", "markdown-it-abbr": "1.0.4", + "markdown-it-attrs": "4.1.6", + "markdown-it-decorate": "1.2.2", "markdown-it-emoji": "2.0.2", "markdown-it-expand-tabs": "1.0.13", - "markdown-it-external-links": "0.0.6", "markdown-it-footnote": "3.0.3", "markdown-it-imsize": "2.0.1", "markdown-it-mark": "3.0.1", - "markdown-it-mathjax": "2.0.0", + "markdown-it-multimd-table": "4.2.1", "markdown-it-sub": "1.0.0", "markdown-it-sup": "1.0.0", "markdown-it-task-lists": "2.1.1", @@ -167,6 +168,7 @@ "striptags": "3.2.0", "tar-fs": "2.1.1", "turndown": "7.1.2", + "twemoji": "14.0.2", "uslug": "1.0.4", "uuid": "9.0.0", "validate.js": "0.13.1", diff --git a/server/renderers/markdown.mjs b/server/renderers/markdown.mjs new file mode 100644 index 00000000..7c7dff28 --- /dev/null +++ b/server/renderers/markdown.mjs @@ -0,0 +1,157 @@ +import MarkdownIt from 'markdown-it' +import mdAttrs from 'markdown-it-attrs' +import mdDecorate from 'markdown-it-decorate' +import mdEmoji from 'markdown-it-emoji' +import mdTaskLists from 'markdown-it-task-lists' +import mdExpandTabs from 'markdown-it-expand-tabs' +import mdAbbr from 'markdown-it-abbr' +import mdSup from 'markdown-it-sup' +import mdSub from 'markdown-it-sub' +import mdMark from 'markdown-it-mark' +import mdMultiTable from 'markdown-it-multimd-table' +import mdFootnote from 'markdown-it-footnote' +// import mdImsize from 'markdown-it-imsize' +import katex from 'katex' +import underline from './modules/markdown-it-underline.mjs' +// import 'katex/dist/contrib/mhchem' +import twemoji from 'twemoji' +import plantuml from './modules/plantuml.mjs' +import kroki from './modules/kroki.mjs' +import katexHelper from './modules/katex.mjs' + +import hljs from 'highlight.js' + +import { escape, times } from 'lodash-es' + +const quoteStyles = { + chinese: '””‘’', + english: '“”‘’', + french: ['«\xA0', '\xA0»', '‹\xA0', '\xA0›'], + german: '„“‚‘', + greek: '«»‘’', + japanese: '「」「」', + hungarian: '„”’’', + polish: '„”‚‘', + portuguese: '«»‘’', + russian: '«»„“', + spanish: '«»‘’', + swedish: '””’’' +} + +export async function render (input, config) { + const md = new MarkdownIt({ + html: config.allowHTML, + breaks: config.lineBreaks, + linkify: config.linkify, + typography: config.typographer, + quotes: quoteStyles[config.quotes] ?? quoteStyles.english, + highlight (str, lang) { + if (lang === 'diagram') { + return `
${Buffer.from(str, 'base64').toString()}
` + } else if (['mermaid', 'plantuml'].includes(lang)) { + return `
${escape(str)}
` + } 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 ? `` : '' + return `
${highlighted.value}${lineNums}
` + } + } + }) + .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 '

' + katex.renderToString(tokens[idx].content, { + displayMode: true, macros + }) + '

' + } 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) +} diff --git a/server/renderers/modules/katex.mjs b/server/renderers/modules/katex.mjs new file mode 100644 index 00000000..74a096db --- /dev/null +++ b/server/renderers/modules/katex.mjs @@ -0,0 +1,145 @@ +// Test if potential opening or closing delimieter +// Assumes that there is a "$" at state.src[pos] +function isValidDelim (state, pos) { + const max = state.posMax + let canOpen = true + let canClose = true + + const prevChar = pos > 0 ? state.src.charCodeAt(pos - 1) : -1 + const nextChar = pos + 1 <= max ? state.src.charCodeAt(pos + 1) : -1 + + // Check non-whitespace conditions for opening and closing, and + // check that closing delimeter isn't followed by a number + if (prevChar === 0x20/* " " */ || prevChar === 0x09/* \t */ || + (nextChar >= 0x30/* "0" */ && nextChar <= 0x39/* "9" */)) { + canClose = false + } + if (nextChar === 0x20/* " " */ || nextChar === 0x09/* \t */) { + canOpen = false + } + + return { + canOpen, + canClose + } +} + +export default { + katexInline (state, silent) { + let match, token, res, pos + + if (state.src[state.pos] !== '$') { return false } + + res = isValidDelim(state, state.pos) + if (!res.canOpen) { + if (!silent) { state.pending += '$' } + state.pos += 1 + return true + } + + // First check for and bypass all properly escaped delimieters + // This loop will assume that the first leading backtick can not + // be the first character in state.src, which is known since + // we have found an opening delimieter already. + const start = state.pos + 1 + match = start + while ((match = state.src.indexOf('$', match)) !== -1) { + // Found potential $, look for escapes, pos will point to + // first non escape when complete + pos = match - 1 + while (state.src[pos] === '\\') { pos -= 1 } + + // Even number of escapes, potential closing delimiter found + if (((match - pos) % 2) === 1) { break } + match += 1 + } + + // No closing delimter found. Consume $ and continue. + if (match === -1) { + if (!silent) { state.pending += '$' } + state.pos = start + return true + } + + // Check if we have empty content, ie: $$. Do not parse. + if (match - start === 0) { + if (!silent) { state.pending += '$$' } + state.pos = start + 1 + return true + } + + // Check for valid closing delimiter + res = isValidDelim(state, match) + if (!res.canClose) { + if (!silent) { state.pending += '$' } + state.pos = start + return true + } + + if (!silent) { + token = state.push('katex_inline', 'math', 0) + token.markup = '$' + token.content = state.src + // Extract the math part without the $ + .slice(start, match) + // Escape the curly braces since they will be interpreted as + // attributes by markdown-it-attrs (the "curly_attributes" + // core rule) + .replaceAll('{', '{{') + .replaceAll('}', '}}') + } + + state.pos = match + 1 + return true + }, + + katexBlock (state, start, end, silent) { + let firstLine; let lastLine; let next; let lastPos; let found = false + let pos = state.bMarks[start] + state.tShift[start] + let max = state.eMarks[start] + + if (pos + 2 > max) { return false } + if (state.src.slice(pos, pos + 2) !== '$$') { return false } + + pos += 2 + firstLine = state.src.slice(pos, max) + + if (silent) { return true } + if (firstLine.trim().slice(-2) === '$$') { + // Single line expression + firstLine = firstLine.trim().slice(0, -2) + found = true + } + + for (next = start; !found;) { + next++ + + if (next >= end) { break } + + pos = state.bMarks[next] + state.tShift[next] + max = state.eMarks[next] + + if (pos < max && state.tShift[next] < state.blkIndent) { + // non-empty line with negative indent should stop the list: + break + } + + if (state.src.slice(pos, max).trim().slice(-2) === '$$') { + lastPos = state.src.slice(0, max).lastIndexOf('$$') + lastLine = state.src.slice(pos, lastPos) + found = true + } + } + + state.line = next + 1 + + const token = state.push('katex_block', 'math', 0) + token.block = true + token.content = (firstLine && firstLine.trim() ? firstLine + '\n' : '') + + state.getLines(start + 1, next, state.tShift[start], true) + + (lastLine && lastLine.trim() ? lastLine : '') + token.map = [start, state.line] + token.markup = '$$' + return true + } +} diff --git a/server/renderers/modules/kroki.mjs b/server/renderers/modules/kroki.mjs new file mode 100644 index 00000000..43e102bc --- /dev/null +++ b/server/renderers/modules/kroki.mjs @@ -0,0 +1,143 @@ +import pako from 'pako' + +// ------------------------------------ +// Markdown - PlantUML Preprocessor +// ------------------------------------ + +export default { + init (mdinst, conf) { + mdinst.use((md, opts) => { + const openMarker = opts.openMarker || '```kroki' + const openChar = openMarker.charCodeAt(0) + const closeMarker = opts.closeMarker || '```' + const closeChar = closeMarker.charCodeAt(0) + const server = opts.server || 'https://kroki.io' + + md.block.ruler.before('fence', 'kroki', (state, startLine, endLine, silent) => { + let nextLine + let markup + let params + let token + let i + let autoClosed = false + let start = state.bMarks[startLine] + state.tShift[startLine] + let max = state.eMarks[startLine] + + // Check out the first character quickly, + // this should filter out most of non-uml blocks + // + if (openChar !== state.src.charCodeAt(start)) { return false } + + // Check out the rest of the marker string + // + for (i = 0; i < openMarker.length; ++i) { + if (openMarker[i] !== state.src[start + i]) { return false } + } + + markup = state.src.slice(start, start + i) + params = state.src.slice(start + i, max) + + // Since start is found, we can report success here in validation mode + // + if (silent) { return true } + + // Search for the end of the block + // + nextLine = startLine + + for (;;) { + nextLine++ + if (nextLine >= endLine) { + // unclosed block should be autoclosed by end of document. + // also block seems to be autoclosed by end of parent + break + } + + start = state.bMarks[nextLine] + state.tShift[nextLine] + max = state.eMarks[nextLine] + + if (start < max && state.sCount[nextLine] < state.blkIndent) { + // non-empty line with negative indent should stop the list: + // - ``` + // test + break + } + + if (closeChar !== state.src.charCodeAt(start)) { + // didn't find the closing fence + continue + } + + if (state.sCount[nextLine] > state.sCount[startLine]) { + // closing fence should not be indented with respect of opening fence + continue + } + + let closeMarkerMatched = true + for (i = 0; i < closeMarker.length; ++i) { + if (closeMarker[i] !== state.src[start + i]) { + closeMarkerMatched = false + break + } + } + + if (!closeMarkerMatched) { + continue + } + + // make sure tail has spaces only + if (state.skipSpaces(start + i) < max) { + continue + } + + // found! + autoClosed = true + break + } + + let contents = state.src + .split('\n') + .slice(startLine + 1, nextLine) + .join('\n') + + // We generate a token list for the alt property, to mimic what the image parser does. + let altToken = [] + // Remove leading space if any. + let alt = params ? params.slice(1) : 'uml diagram' + state.md.inline.parse( + alt, + state.md, + state.env, + altToken + ) + + let firstlf = contents.indexOf('\n') + if (firstlf === -1) firstlf = undefined + let diagramType = contents.substring(0, firstlf) + contents = contents.substring(firstlf + 1) + + const result = pako.deflate(contents).toString('base64').replace(/\+/g, '-').replace(/\//g, '_') + + token = state.push('kroki', 'img', 0) + // alt is constructed from children. No point in populating it here. + token.attrs = [ [ 'src', `${server}/${diagramType}/svg/${result}` ], [ 'alt', '' ], ['class', 'uml-diagram prefetch-candidate'] ] + token.block = true + token.children = altToken + token.info = params + token.map = [ startLine, nextLine ] + token.markup = markup + + state.line = nextLine + (autoClosed ? 1 : 0) + + return true + }, { + alt: [ 'paragraph', 'reference', 'blockquote', 'list' ] + }) + md.renderer.rules.kroki = md.renderer.rules.image + }, { + openMarker: conf.openMarker, + closeMarker: conf.closeMarker, + server: conf.server + }) + } +} diff --git a/server/renderers/modules/markdown-it-underline.mjs b/server/renderers/modules/markdown-it-underline.mjs new file mode 100644 index 00000000..b898d1ce --- /dev/null +++ b/server/renderers/modules/markdown-it-underline.mjs @@ -0,0 +1,12 @@ +function renderEm (tokens, idx, opts, env, slf) { + const token = tokens[idx] + if (token.markup === '_') { + token.tag = 'u' + } + return slf.renderToken(tokens, idx, opts) +} + +export default (md) => { + md.renderer.rules.em_open = renderEm + md.renderer.rules.em_close = renderEm +} diff --git a/server/renderers/modules/plantuml.mjs b/server/renderers/modules/plantuml.mjs new file mode 100644 index 00000000..9d5e3ea5 --- /dev/null +++ b/server/renderers/modules/plantuml.mjs @@ -0,0 +1,187 @@ +import pako from 'pako' + +// ------------------------------------ +// Markdown - PlantUML Preprocessor +// ------------------------------------ + +export default { + init (mdinst, conf) { + mdinst.use((md, opts) => { + const openMarker = opts.openMarker || '```plantuml' + const openChar = openMarker.charCodeAt(0) + const closeMarker = opts.closeMarker || '```' + const closeChar = closeMarker.charCodeAt(0) + const imageFormat = opts.imageFormat || 'svg' + const server = opts.server || 'https://plantuml.requarks.io' + + md.block.ruler.before('fence', 'uml_diagram', (state, startLine, endLine, silent) => { + let nextLine + let i + let autoClosed = false + let start = state.bMarks[startLine] + state.tShift[startLine] + let max = state.eMarks[startLine] + + // Check out the first character quickly, + // this should filter out most of non-uml blocks + // + if (openChar !== state.src.charCodeAt(start)) { return false } + + // Check out the rest of the marker string + // + for (i = 0; i < openMarker.length; ++i) { + if (openMarker[i] !== state.src[start + i]) { return false } + } + + const markup = state.src.slice(start, start + i) + const params = state.src.slice(start + i, max) + + // Since start is found, we can report success here in validation mode + // + if (silent) { return true } + + // Search for the end of the block + // + nextLine = startLine + + for (;;) { + nextLine++ + if (nextLine >= endLine) { + // unclosed block should be autoclosed by end of document. + // also block seems to be autoclosed by end of parent + break + } + + start = state.bMarks[nextLine] + state.tShift[nextLine] + max = state.eMarks[nextLine] + + if (start < max && state.sCount[nextLine] < state.blkIndent) { + // non-empty line with negative indent should stop the list: + // - ``` + // test + break + } + + if (closeChar !== state.src.charCodeAt(start)) { + // didn't find the closing fence + continue + } + + if (state.sCount[nextLine] > state.sCount[startLine]) { + // closing fence should not be indented with respect of opening fence + continue + } + + let closeMarkerMatched = true + for (i = 0; i < closeMarker.length; ++i) { + if (closeMarker[i] !== state.src[start + i]) { + closeMarkerMatched = false + break + } + } + + if (!closeMarkerMatched) { + continue + } + + // make sure tail has spaces only + if (state.skipSpaces(start + i) < max) { + continue + } + + // found! + autoClosed = true + break + } + + const contents = state.src + .split('\n') + .slice(startLine + 1, nextLine) + .join('\n') + + // We generate a token list for the alt property, to mimic what the image parser does. + const altToken = [] + // Remove leading space if any. + const alt = params ? params.slice(1) : 'uml diagram' + state.md.inline.parse( + alt, + state.md, + state.env, + altToken + ) + + const zippedCode = encode64(pako.deflate('@startuml\n' + contents + '\n@enduml', { to: 'string' })) + + const token = state.push('uml_diagram', 'img', 0) + // alt is constructed from children. No point in populating it here. + token.attrs = [['src', `${server}/${imageFormat}/${zippedCode}`], ['alt', ''], ['class', 'uml-diagram']] + token.block = true + token.children = altToken + token.info = params + token.map = [startLine, nextLine] + token.markup = markup + + state.line = nextLine + (autoClosed ? 1 : 0) + + return true + }, { + alt: ['paragraph', 'reference', 'blockquote', 'list'] + }) + md.renderer.rules.uml_diagram = md.renderer.rules.image + }, { + openMarker: conf.openMarker, + closeMarker: conf.closeMarker, + imageFormat: conf.imageFormat, + server: conf.server + }) + } +} + +function encode64 (data) { + let r = '' + for (let i = 0; i < data.length; i += 3) { + if (i + 2 === data.length) { + r += append3bytes(data.charCodeAt(i), data.charCodeAt(i + 1), 0) + } else if (i + 1 === data.length) { + r += append3bytes(data.charCodeAt(i), 0, 0) + } else { + r += append3bytes(data.charCodeAt(i), data.charCodeAt(i + 1), data.charCodeAt(i + 2)) + } + } + return r +} + +function append3bytes (b1, b2, b3) { + const c1 = b1 >> 2 + const c2 = ((b1 & 0x3) << 4) | (b2 >> 4) + const c3 = ((b2 & 0xF) << 2) | (b3 >> 6) + const c4 = b3 & 0x3F + let r = '' + r += encode6bit(c1 & 0x3F) + r += encode6bit(c2 & 0x3F) + r += encode6bit(c3 & 0x3F) + r += encode6bit(c4 & 0x3F) + return r +} + +function encode6bit (raw) { + let b = raw + if (b < 10) { + return String.fromCharCode(48 + b) + } + b -= 10 + if (b < 26) { + return String.fromCharCode(65 + b) + } + b -= 26 + if (b < 26) { + return String.fromCharCode(97 + b) + } + b -= 26 + if (b === 0) { + return '-' + } + if (b === 1) { + return '_' + } + return '?' +} diff --git a/server/tasks/workers/render-page.mjs b/server/tasks/workers/render-page.mjs index 66811b46..b8014164 100644 --- a/server/tasks/workers/render-page.mjs +++ b/server/tasks/workers/render-page.mjs @@ -14,19 +14,35 @@ export async function task ({ payload }) { const site = await WIKI.db.sites.query().findById(page.siteId) - await WIKI.db.renderers.fetchDefinitions() - - const pipeline = await WIKI.db.renderers.getRenderingPipeline(page.contentType) + let output = page.content - let output = page.render - - if (isEmpty(page.content)) { + // Empty content? + if (isEmpty(output)) { WIKI.logger.warn(`Failed to render page ID ${payload.id} because content was empty: [ FAILED ]`) + throw new Error(`Failed to render page ID ${payload.id} because content was empty.`) } + // Parse to HTML + switch (page.contentType) { + case 'asciidoc': { + const { render } = await import('../../renderers/asciidoc.mjs') + output = await render(output, site.config?.editors?.asciidoc?.config ?? {}) + break + } + case 'markdown': { + const { render } = await import('../../renderers/markdown.mjs') + output = await render(output, site.config?.editors?.markdown?.config ?? {}) + break + } + } + + // Render HTML + await WIKI.db.renderers.fetchDefinitions() + const pipeline = await WIKI.db.renderers.getRenderingPipeline(page.contentType) + for (const core of pipeline) { - const renderer = (await import(`../../modules/rendering/${core.key}/renderer.mjs`)).default - output = await renderer.render.call({ + const { render } = (await import(`../../modules/rendering/${core.key}/renderer.mjs`)) + output = await render.call({ config: core.config, children: core.children, page, diff --git a/ux/public/_assets/icons/ultraviolet-lowercase.svg b/ux/public/_assets/icons/ultraviolet-lowercase.svg new file mode 100644 index 00000000..a0e4ef75 --- /dev/null +++ b/ux/public/_assets/icons/ultraviolet-lowercase.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ux/src/components/AuthLoginPanel.vue b/ux/src/components/AuthLoginPanel.vue index c95a9b0d..db1a5075 100644 --- a/ux/src/components/AuthLoginPanel.vue +++ b/ux/src/components/AuthLoginPanel.vue @@ -500,8 +500,7 @@ async function handleLoginResponse (resp) { $q.loading.hide() } else { $q.loading.show({ - message: t('auth.loginSuccess'), - backgroundColor: 'green' + message: t('auth.loginSuccess') }) Cookies.set('jwt', resp.jwt, { expires: 365 }) setTimeout(() => { diff --git a/ux/src/components/EditorMarkdown.vue b/ux/src/components/EditorMarkdown.vue index 9f4c72d3..c5082bbd 100644 --- a/ux/src/components/EditorMarkdown.vue +++ b/ux/src/components/EditorMarkdown.vue @@ -257,6 +257,7 @@ const { t } = useI18n() // STATE let editor +let md const monacoRef = ref(null) const editorPreviewContainerRef = ref(null) @@ -265,8 +266,6 @@ const state = reactive({ previewScrollSync: true }) -const md = new MarkdownRenderer({}) - // METHODS function insertAssets () { @@ -458,6 +457,8 @@ onMounted(async () => { hideSideNav: true }) + md = new MarkdownRenderer(editorStore.editors.markdown) + // -> Define Monaco Theme monaco.editor.defineTheme('wikijs', { base: 'vs-dark', diff --git a/ux/src/components/EditorMarkdownConfigOverlay.vue b/ux/src/components/EditorMarkdownConfigOverlay.vue index 8200dd59..b9c989cd 100644 --- a/ux/src/components/EditorMarkdownConfigOverlay.vue +++ b/ux/src/components/EditorMarkdownConfigOverlay.vue @@ -259,7 +259,8 @@ import { onMounted, reactive } from 'vue' import gql from 'graphql-tag' import { cloneDeep } from 'lodash-es' -import { useAdminStore } from '../stores/admin' +import { useAdminStore } from 'src/stores/admin' +import { useEditorStore } from 'src/stores/editor' import { useSiteStore } from 'src/stores/site' // QUASAR @@ -269,6 +270,7 @@ const $q = useQuasar() // STORES const adminStore = useAdminStore() +const editorStore = useEditorStore() const siteStore = useSiteStore() // I18N @@ -393,6 +395,7 @@ async function save () { type: 'positive', message: t('admin.editors.markdown.saveSuccess') }) + editorStore.$patch({ configIsLoaded: false }) close() } else { throw new Error(respRaw?.data?.updateSite?.operation?.message || 'An unexpected error occured.') diff --git a/ux/src/components/PageHeader.vue b/ux/src/components/PageHeader.vue index 2d266276..86318fbe 100644 --- a/ux/src/components/PageHeader.vue +++ b/ux/src/components/PageHeader.vue @@ -355,12 +355,7 @@ async function createPage () { async function editPage () { $q.loading.show() - await pageStore.pageLoad({ id: pageStore.id, withContent: true }) - editorStore.$patch({ - isActive: true, - mode: 'edit', - editor: pageStore.editor - }) + await pageStore.pageEdit() $q.loading.hide() } diff --git a/ux/src/components/PageNewMenu.vue b/ux/src/components/PageNewMenu.vue index 7607846d..a9e1c42d 100644 --- a/ux/src/components/PageNewMenu.vue +++ b/ux/src/components/PageNewMenu.vue @@ -73,6 +73,7 @@ q-menu.translucent-menu( import { useI18n } from 'vue-i18n' import { useQuasar } from 'quasar' +import { useEditorStore } from 'src/stores/editor' import { usePageStore } from 'src/stores/page' import { useSiteStore } from 'src/stores/site' import { useFlagsStore } from 'src/stores/flags' @@ -100,6 +101,7 @@ const $q = useQuasar() // STORES +const editorStore = useEditorStore() const flagsStore = useFlagsStore() const pageStore = usePageStore() const siteStore = useSiteStore() @@ -110,8 +112,10 @@ const { t } = useI18n() // METHODS -function create (editor) { - pageStore.pageCreate({ editor }) +async function create (editor) { + $q.loading.show() + await pageStore.pageCreate({ editor }) + $q.loading.hide() } function openFileManager () { diff --git a/ux/src/components/UserChangePwdDialog.vue b/ux/src/components/UserChangePwdDialog.vue index 4fb27853..5d4e5253 100644 --- a/ux/src/components/UserChangePwdDialog.vue +++ b/ux/src/components/UserChangePwdDialog.vue @@ -176,11 +176,13 @@ async function save () { mutation: gql` mutation adminUpdateUserPwd ( $id: UUID! - $patch: UserUpdateInput! + $newPassword: String! + $mustChangePassword: Boolean ) { - updateUser ( + changeUserPassword ( id: $id - patch: $patch + newPassword: $newPassword + mustChangePassword: $mustChangePassword ) { operation { succeeded @@ -191,22 +193,20 @@ async function save () { `, variables: { id: props.userId, - patch: { - newPassword: state.userPassword, - mustChangePassword: state.userMustChangePassword - } + newPassword: state.userPassword, + mustChangePassword: state.userMustChangePassword } }) - if (resp?.data?.updateUser?.operation?.succeeded) { + if (resp?.data?.changeUserPassword?.operation?.succeeded) { $q.notify({ type: 'positive', - message: t('admin.users.createSuccess') + message: t('admin.users.changePasswordSuccess') }) onDialogOK({ mustChangePassword: state.userMustChangePassword }) } else { - throw new Error(resp?.data?.updateUser?.operation?.message || 'An unexpected error occured.') + throw new Error(resp?.data?.changeUserPassword?.operation?.message || 'An unexpected error occured.') } } catch (err) { $q.notify({ diff --git a/ux/src/components/UserEditOverlay.vue b/ux/src/components/UserEditOverlay.vue index 8ea56ad6..b3a88ee2 100644 --- a/ux/src/components/UserEditOverlay.vue +++ b/ux/src/components/UserEditOverlay.vue @@ -215,6 +215,26 @@ q-layout(view='hHh lpR fFf', container) { label: t('profile.appearanceDark'), value: 'dark' } ]` ) + q-separator.q-my-sm(inset) + q-item + blueprint-icon(icon='visualy-impaired') + q-item-section + q-item-label {{t(`profile.cvd`)}} + q-item-label(caption) {{t(`profile.cvdHint`)}} + q-item-section.col-auto + q-btn-toggle( + v-model='state.user.prefs.cvd' + push + glossy + no-caps + toggle-color='primary' + :options=`[ + { value: 'none', label: t('profile.cvdNone') }, + { value: 'protanopia', label: t('profile.cvdProtanopia') }, + { value: 'deuteranopia', label: t('profile.cvdDeuteranopia') }, + { value: 'tritanopia', label: t('profile.cvdTritanopia') } + ]` + ) .col-12.col-lg-4 q-card.shadow-1.q-pb-sm @@ -230,19 +250,19 @@ q-layout(view='hHh lpR fFf', container) blueprint-icon(icon='calendar-plus', :hue-rotate='-45') q-item-section q-item-label {{t(`common.field.createdOn`)}} - q-item-label: strong {{humanizeDate(state.user.createdAt)}} + q-item-label: strong {{formattedDate(state.user.createdAt)}} q-separator.q-my-sm(inset) q-item blueprint-icon(icon='summertime', :hue-rotate='-45') q-item-section q-item-label {{t(`common.field.lastUpdated`)}} - q-item-label: strong {{humanizeDate(state.user.updatedAt)}} + q-item-label: strong {{formattedDate(state.user.updatedAt)}} q-separator.q-my-sm(inset) q-item blueprint-icon(icon='enter', :hue-rotate='-45') q-item-section q-item-label {{t(`admin.users.lastLoginAt`)}} - q-item-label: strong {{humanizeDate(state.user.lastLoginAt)}} + q-item-label: strong {{formattedDate(state.user.lastLoginAt)}} q-card.shadow-1.q-pb-sm.q-mt-md(v-if='state.user.meta') q-card-section @@ -519,6 +539,7 @@ import { useRouter, useRoute } from 'vue-router' import { useAdminStore } from 'src/stores/admin' import { useFlagsStore } from 'src/stores/flags' +import { useUserStore } from 'src/stores/user' import UserChangePwdDialog from './UserChangePwdDialog.vue' import UtilCodeEditor from './UtilCodeEditor.vue' @@ -531,6 +552,7 @@ const $q = useQuasar() const adminStore = useAdminStore() const flagsStore = useFlagsStore() +const userStore = useUserStore() // ROUTER @@ -650,7 +672,7 @@ async function fetchUser () { }, fetchPolicy: 'network-only' }) - state.groups = resp?.data?.groups?.filter(g => g.id !== '10000000-0000-4000-0000-000000000001') ?? [] + state.groups = resp?.data?.groups?.filter(g => g.id !== '10000000-0000-4000-8000-000000000001') ?? [] if (resp?.data?.userById) { state.user = cloneDeep(resp.data.userById) } else { @@ -679,7 +701,7 @@ function checkRoute () { } } -function humanizeDate (val) { +function formattedDate (val) { if (!val) { return '---' } return DateTime.fromISO(val).toLocaleString(DateTime.DATETIME_FULL) } diff --git a/ux/src/components/WelcomeOverlay.vue b/ux/src/components/WelcomeOverlay.vue index b89fdd72..57903f4e 100644 --- a/ux/src/components/WelcomeOverlay.vue +++ b/ux/src/components/WelcomeOverlay.vue @@ -88,9 +88,10 @@ useMeta({ // METHODS -function createHomePage (editor) { +async function createHomePage (editor) { + $q.loading.show() siteStore.overlay = '' - pageStore.pageCreate({ + await pageStore.pageCreate({ editor, locale: 'en', path: 'home', @@ -98,6 +99,7 @@ function createHomePage (editor) { description: t('welcome.homeDefault.description'), content: t('welcome.homeDefault.content') }) + $q.loading.hide() } function loadAdmin () { diff --git a/ux/src/i18n/locales/en.json b/ux/src/i18n/locales/en.json index cb87fa0a..5e301ad2 100644 --- a/ux/src/i18n/locales/en.json +++ b/ux/src/i18n/locales/en.json @@ -241,6 +241,8 @@ "admin.general.logoUpl": "Site Logo", "admin.general.logoUplHint": "Logo image file, in SVG, PNG, JPG, WEBP or GIF format.", "admin.general.logoUploadSuccess": "Site logo uploaded successfully.", + "admin.general.pageCasing": "Case Sensitive Paths", + "admin.general.pageCasingHint": "Treat paths with different casing as distinct pages.", "admin.general.pageExtensions": "Page Extensions", "admin.general.pageExtensionsHint": "A comma-separated list of URL extensions that will be treated as pages. For example, adding md will treat /foobar.md the same as /foobar.", "admin.general.ratingsOff": "Off", @@ -873,11 +875,12 @@ "admin.users.basicInfo": "Basic Info", "admin.users.changePassword": "Change Password", "admin.users.changePasswordHint": "Change the user password. Note that the current password cannot be recovered.", + "admin.users.changePasswordSuccess": "User password was updated successfully.", "admin.users.create": "Create User", "admin.users.createInvalidData": "Cannot create user as some fields are invalid or missing.", "admin.users.createKeepOpened": "Keep dialog opened after create", "admin.users.createSuccess": "User created successfully!", - "admin.users.createdAt": "Created {date}", + "admin.users.createdAt": "Created on {date}", "admin.users.darkMode": "Dark Mode", "admin.users.darkModeHint": "Display the user interface using dark mode.", "admin.users.dateFormat": "Date Format", @@ -911,7 +914,7 @@ "admin.users.jobTitle": "Job Title", "admin.users.jobTitleHint": "The job title of the user.", "admin.users.joined": "Joined", - "admin.users.lastLoginAt": "Last login", + "admin.users.lastLoginAt": "Last login {date}", "admin.users.lastUpdated": "Last Updated", "admin.users.linkedAccounts": "Linked Accounts", "admin.users.linkedProviders": "Linked Providers", @@ -1233,6 +1236,7 @@ "common.comments.updateComment": "Update Comment", "common.comments.updateSuccess": "Comment was updated successfully.", "common.comments.viewDiscussion": "View Discussion", + "common.datetime": "{date} 'at' {time}", "common.duration.days": "Day(s)", "common.duration.every": "Every", "common.duration.hours": "Hour(s)", diff --git a/ux/src/layouts/ProfileLayout.vue b/ux/src/layouts/ProfileLayout.vue index 1cce67a4..f330d226 100644 --- a/ux/src/layouts/ProfileLayout.vue +++ b/ux/src/layouts/ProfileLayout.vue @@ -47,6 +47,7 @@ q-layout(view='hHh Lpr lff') import { useI18n } from 'vue-i18n' import { useMeta, useQuasar } from 'quasar' import { onMounted, reactive, watch } from 'vue' +import { useRouter, useRoute } from 'vue-router' import { useFlagsStore } from 'src/stores/flags' import { useSiteStore } from 'src/stores/site' @@ -66,6 +67,11 @@ const flagsStore = useFlagsStore() const siteStore = useSiteStore() const userStore = useUserStore() +// ROUTER + +const router = useRouter() +const route = useRoute() + // I18N const { t } = useI18n() @@ -118,6 +124,15 @@ const sidenav = [ disabled: true } ] + +// WATCHERS + +watch(() => route.path, async (newValue) => { + if (!newValue.startsWith('/_profile')) { return } + if (!userStore.authenticated) { + router.replace('/login') + } +}, { immediate: true })