diff --git a/client/src/app/core/renderer/html-renderer.service.ts b/client/src/app/core/renderer/html-renderer.service.ts index 418d8603e..d158519f8 100644 --- a/client/src/app/core/renderer/html-renderer.service.ts +++ b/client/src/app/core/renderer/html-renderer.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core' +import { getCustomMarkupSanitizeOptions, getDefaultSanitizeOptions } from '@shared/core-utils/renderer/html' import { LinkifierService } from './linkifier.service' -import { getCustomMarkupSanitizeOptions, getSanitizeOptions } from '@shared/core-utils/renderer/html' @Injectable() export class HtmlRendererService { @@ -30,7 +30,7 @@ export class HtmlRendererService { const options = additionalAllowedTags.length !== 0 ? getCustomMarkupSanitizeOptions(additionalAllowedTags) - : getSanitizeOptions() + : getDefaultSanitizeOptions() return this.sanitizeHtml(html, options) } diff --git a/server/controllers/feeds.ts b/server/controllers/feeds.ts index 3c8680ca4..e6cdaf94b 100644 --- a/server/controllers/feeds.ts +++ b/server/controllers/feeds.ts @@ -1,6 +1,6 @@ import express from 'express' import Feed from 'pfeed' -import { mdToPlainText, toSafeHtml } from '@server/helpers/markdown' +import { mdToOneLinePlainText, toSafeHtml } from '@server/helpers/markdown' import { getServerActor } from '@server/models/application/application' import { getCategoryLabel } from '@server/models/video/formatter/video-format-utils' import { VideoInclude } from '@shared/models' @@ -236,7 +236,7 @@ function initFeed (parameters: { return new Feed({ title: name, - description: mdToPlainText(description), + description: mdToOneLinePlainText(description), // updated: TODO: somehowGetLatestUpdate, // optional, default = today id: webserverUrl, link: webserverUrl, @@ -299,7 +299,7 @@ function addVideosToFeed (feed, videos: VideoModel[]) { title: video.name, id: video.url, link: WEBSERVER.URL + video.getWatchStaticPath(), - description: mdToPlainText(video.getTruncatedDescription()), + description: mdToOneLinePlainText(video.getTruncatedDescription()), content: toSafeHtml(video.description), author: [ { diff --git a/server/helpers/markdown.ts b/server/helpers/markdown.ts index 0b8c2fabc..25685ec6d 100644 --- a/server/helpers/markdown.ts +++ b/server/helpers/markdown.ts @@ -1,14 +1,14 @@ -import { getSanitizeOptions, TEXT_WITH_HTML_RULES } from '@shared/core-utils' +import { getDefaultSanitizeOptions, getTextOnlySanitizeOptions, TEXT_WITH_HTML_RULES } from '@shared/core-utils' -const sanitizeOptions = getSanitizeOptions() +const defaultSanitizeOptions = getDefaultSanitizeOptions() +const textOnlySanitizeOptions = getTextOnlySanitizeOptions() const sanitizeHtml = require('sanitize-html') const markdownItEmoji = require('markdown-it-emoji/light') const MarkdownItClass = require('markdown-it') -const markdownIt = new MarkdownItClass('default', { linkify: true, breaks: true, html: true }) -markdownIt.enable(TEXT_WITH_HTML_RULES) -markdownIt.use(markdownItEmoji) +const markdownItWithHTML = new MarkdownItClass('default', { linkify: true, breaks: true, html: true }) +const markdownItWithoutHTML = new MarkdownItClass('default', { linkify: true, breaks: true, html: false }) const toSafeHtml = (text: string) => { if (!text) return '' @@ -17,29 +17,65 @@ const toSafeHtml = (text: string) => { const textWithLineFeed = text.replace(//g, '\r\n') // Convert possible markdown (emojis, emphasis and lists) to html - const html = markdownIt.render(textWithLineFeed) + const html = markdownItWithHTML.enable(TEXT_WITH_HTML_RULES) + .use(markdownItEmoji) + .render(textWithLineFeed) // Convert to safe Html - return sanitizeHtml(html, sanitizeOptions) + return sanitizeHtml(html, defaultSanitizeOptions) } -const mdToPlainText = (text: string) => { +const mdToOneLinePlainText = (text: string) => { if (!text) return '' - // Convert possible markdown (emojis, emphasis and lists) to html - const html = markdownIt.render(text) + markdownItWithoutHTML.use(markdownItEmoji) + .use(plainTextPlugin) + .render(text) // Convert to safe Html - const safeHtml = sanitizeHtml(html, sanitizeOptions) - - return safeHtml.replace(/<[^>]+>/g, '') - .replace(/\n$/, '') - .replace(/\n/g, ', ') + return sanitizeHtml(markdownItWithoutHTML.plainText, textOnlySanitizeOptions) } // --------------------------------------------------------------------------- export { toSafeHtml, - mdToPlainText + mdToOneLinePlainText +} + +// --------------------------------------------------------------------------- + +// Thanks: https://github.com/wavesheep/markdown-it-plain-text +function plainTextPlugin (markdownIt: any) { + let lastSeparator = '' + + function plainTextRule (state: any) { + const text = scan(state.tokens) + + markdownIt.plainText = text.replace(/\s+/g, ' ') + } + + function scan (tokens: any[]) { + let text = '' + + for (const token of tokens) { + if (token.children !== null) { + text += scan(token.children) + continue + } + + if (token.type === 'list_item_close') { + lastSeparator = ', ' + } else if (/[a-zA-Z]+_close/.test(token.type)) { + lastSeparator = ' ' + } else if (token.content) { + text += lastSeparator + text += token.content + } + } + + return text + } + + markdownIt.core.ruler.push('plainText', plainTextRule) } diff --git a/server/lib/client-html.ts b/server/lib/client-html.ts index 74788af52..19354ab70 100644 --- a/server/lib/client-html.ts +++ b/server/lib/client-html.ts @@ -12,7 +12,7 @@ import { HttpStatusCode } from '../../shared/models/http/http-error-codes' import { VideoPlaylistPrivacy, VideoPrivacy } from '../../shared/models/videos' import { isTestInstance } from '../helpers/core-utils' import { logger } from '../helpers/logger' -import { mdToPlainText } from '../helpers/markdown' +import { mdToOneLinePlainText } from '../helpers/markdown' import { CONFIG } from '../initializers/config' import { ACCEPT_HEADERS, @@ -103,7 +103,7 @@ class ClientHtml { res.status(HttpStatusCode.NOT_FOUND_404) return html } - const description = mdToPlainText(video.description) + const description = mdToOneLinePlainText(video.description) let customHtml = ClientHtml.addTitleTag(html, video.name) customHtml = ClientHtml.addDescriptionTag(customHtml, description) @@ -164,7 +164,7 @@ class ClientHtml { return html } - const description = mdToPlainText(videoPlaylist.description) + const description = mdToOneLinePlainText(videoPlaylist.description) let customHtml = ClientHtml.addTitleTag(html, videoPlaylist.name) customHtml = ClientHtml.addDescriptionTag(customHtml, description) @@ -263,7 +263,7 @@ class ClientHtml { return ClientHtml.getIndexHTML(req, res) } - const description = mdToPlainText(entity.description) + const description = mdToOneLinePlainText(entity.description) let customHtml = ClientHtml.addTitleTag(html, entity.getDisplayName()) customHtml = ClientHtml.addDescriptionTag(customHtml, description) diff --git a/server/tests/helpers/index.ts b/server/tests/helpers/index.ts index 66db93c99..91d11e25d 100644 --- a/server/tests/helpers/index.ts +++ b/server/tests/helpers/index.ts @@ -1,4 +1,5 @@ import './image' import './core-utils' import './comment-model' +import './markdown' import './request' diff --git a/server/tests/helpers/markdown.ts b/server/tests/helpers/markdown.ts new file mode 100644 index 000000000..0488a1a05 --- /dev/null +++ b/server/tests/helpers/markdown.ts @@ -0,0 +1,34 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import 'mocha' +import { mdToOneLinePlainText } from '@server/helpers/markdown' +import { expect } from 'chai' + +describe('Markdown helpers', function () { + + describe('Plain text', function () { + + it('Should convert a list to plain text', function () { + const result = mdToOneLinePlainText(`* list 1 +* list 2 +* list 3`) + + expect(result).to.equal('list 1, list 2, list 3') + }) + + it('Should convert a list with indentation to plain text', function () { + const result = mdToOneLinePlainText(`Hello: + * list 1 + * list 2 + * list 3`) + + expect(result).to.equal('Hello: list 1, list 2, list 3') + }) + + it('Should convert HTML to plain text', function () { + const result = mdToOneLinePlainText(`**Hello** coucou`) + + expect(result).to.equal('Hello coucou') + }) + }) +}) diff --git a/shared/core-utils/renderer/html.ts b/shared/core-utils/renderer/html.ts index c9757be85..502308979 100644 --- a/shared/core-utils/renderer/html.ts +++ b/shared/core-utils/renderer/html.ts @@ -1,4 +1,4 @@ -export function getSanitizeOptions () { +export function getDefaultSanitizeOptions () { return { allowedTags: [ 'a', 'p', 'span', 'br', 'strong', 'em', 'ul', 'ol', 'li' ], allowedSchemes: [ 'http', 'https' ], @@ -23,8 +23,14 @@ export function getSanitizeOptions () { } } +export function getTextOnlySanitizeOptions () { + return { + allowedTags: [] as string[] + } +} + export function getCustomMarkupSanitizeOptions (additionalAllowedTags: string[] = []) { - const base = getSanitizeOptions() + const base = getDefaultSanitizeOptions() return { allowedTags: [