From 9a1695ccc1bcfb8bcbdc0512fff28a83500029e0 Mon Sep 17 00:00:00 2001 From: Godfrey Chan Date: Mon, 6 Nov 2023 08:59:49 -0800 Subject: [PATCH] DEV: remove markdown-it-bundle and custom build code (#23859) With Embroider, we can rely on async `import()` to do the splitting for us. This commit extracts from `pretty-text` all the parts that are meant to be loaded async into a new `discourse-markdown-it` package that is also a V2 addon (meaning that all files are presumed unused until they are imported, aka "static"). Mostly I tried to keep the very discourse specific stuff (accessing site settings and loading plugin features) inside discourse proper, while the new package aims to have some resembalance of a general purpose library, a MarkdownIt++ if you will. It is far from perfect because of how all the "options" stuff work but I think it's a good start for more refactorings (clearing up the interfaces) to happen later. With this, pretty-text and app/lib/text are mostly a kitchen sink of loosely related text processing utilities. After the refactor, a lot more code related to setting up the engine are now loaded lazily, which should be a pretty nice win. I also noticed that we are currently pulling in the `xss` library at initial load to power the "sanitize" stuff, but I suspect with a similar refactoring effort those usages can be removed too. (See also #23790). This PR does not attempt to fix the sanitize issue, but I think it sets things up on the right trajectory for that to happen later. Co-authored-by: David Taylor --- .../discourse-markdown-it/addon-main.cjs | 4 + .../discourse-markdown-it/package.json | 45 ++ .../discourse-markdown-it/src/engine.js | 348 ++++++++++ .../src/features}/anchor.js | 0 .../src/features}/bbcode-block.js | 0 .../src/features}/bbcode-inline.js | 2 +- .../src/features}/censored.js | 0 .../src/features}/code.js | 0 .../custom-typographer-replacements.js | 0 .../src/features}/d-wrap.js | 2 +- .../src/features}/emoji.js | 0 .../src/features}/hashtag-autocomplete.js | 0 .../src/features}/html-img.js | 0 .../src/features}/image-controls.js | 0 .../src/features}/image-grid.js | 0 .../src/features/index.js | 49 ++ .../src/features}/mentions.js | 0 .../src/features}/newline.js | 0 .../src/features}/onebox.js | 0 .../src/features}/paragraph.js | 0 .../src/features}/quotes.js | 0 .../src/features}/table.js | 0 .../src/features}/text-post-process.js | 0 .../src/features}/upload-protocol.js | 0 .../src/features}/watched-words.js | 0 .../discourse-markdown-it/src/index.js | 74 +++ .../discourse-markdown-it/src/options.js | 83 +++ .../discourse-markdown-it/src/setup.js | 359 +++++++++++ .../javascripts/discourse/app/lib/text.js | 97 +-- .../app/static/markdown-it/features.js | 25 + .../discourse/app/static/markdown-it/index.js | 60 ++ .../markdown-it}/mentions-parser.js | 8 +- .../app/static/markdown-it/options.js | 21 + .../static/markdown-it/parse-bbcode-tag.js | 1 + .../javascripts/discourse/ember-cli-build.js | 14 +- app/assets/javascripts/discourse/package.json | 1 + .../javascripts/discourse/tests/index.html | 1 - .../discourse/tests/test-helper.js | 2 +- .../tests/unit/lib/build-quote-test.js | 4 +- .../tests/unit/lib/parse-bbcode-tag-test.js | 2 +- .../tests/unit/lib/pretty-text-test.js | 20 +- .../tests/unit/lib/sanitizer-test.js | 90 +-- app/assets/javascripts/package.json | 1 + .../addon/engines/discourse-markdown-it.js | 601 ------------------ .../pretty-text/addon/pretty-text.js | 123 ---- .../helpers.js => addon/text-replace.js} | 2 - app/assets/javascripts/pretty-text/index.js | 36 -- .../javascripts/pretty-text/package.json | 3 +- app/helpers/application_helper.rb | 1 - lib/pretty_text.rb | 111 ++-- lib/pretty_text/shims.js | 24 +- lib/pretty_text/vendor-shims.js | 4 + .../javascripts/lib/details-cooked-test.js | 21 +- 53 files changed, 1249 insertions(+), 990 deletions(-) create mode 100644 app/assets/javascripts/discourse-markdown-it/addon-main.cjs create mode 100644 app/assets/javascripts/discourse-markdown-it/package.json create mode 100644 app/assets/javascripts/discourse-markdown-it/src/engine.js rename app/assets/javascripts/{pretty-text/engines/discourse-markdown => discourse-markdown-it/src/features}/anchor.js (100%) rename app/assets/javascripts/{pretty-text/engines/discourse-markdown => discourse-markdown-it/src/features}/bbcode-block.js (100%) rename app/assets/javascripts/{pretty-text/engines/discourse-markdown => discourse-markdown-it/src/features}/bbcode-inline.js (98%) rename app/assets/javascripts/{pretty-text/engines/discourse-markdown => discourse-markdown-it/src/features}/censored.js (100%) rename app/assets/javascripts/{pretty-text/engines/discourse-markdown => discourse-markdown-it/src/features}/code.js (100%) rename app/assets/javascripts/{pretty-text/engines/discourse-markdown => discourse-markdown-it/src/features}/custom-typographer-replacements.js (100%) rename app/assets/javascripts/{pretty-text/engines/discourse-markdown => discourse-markdown-it/src/features}/d-wrap.js (95%) rename app/assets/javascripts/{pretty-text/engines/discourse-markdown => discourse-markdown-it/src/features}/emoji.js (100%) rename app/assets/javascripts/{pretty-text/engines/discourse-markdown => discourse-markdown-it/src/features}/hashtag-autocomplete.js (100%) rename app/assets/javascripts/{pretty-text/engines/discourse-markdown => discourse-markdown-it/src/features}/html-img.js (100%) rename app/assets/javascripts/{pretty-text/engines/discourse-markdown => discourse-markdown-it/src/features}/image-controls.js (100%) rename app/assets/javascripts/{pretty-text/engines/discourse-markdown => discourse-markdown-it/src/features}/image-grid.js (100%) create mode 100644 app/assets/javascripts/discourse-markdown-it/src/features/index.js rename app/assets/javascripts/{pretty-text/engines/discourse-markdown => discourse-markdown-it/src/features}/mentions.js (100%) rename app/assets/javascripts/{pretty-text/engines/discourse-markdown => discourse-markdown-it/src/features}/newline.js (100%) rename app/assets/javascripts/{pretty-text/engines/discourse-markdown => discourse-markdown-it/src/features}/onebox.js (100%) rename app/assets/javascripts/{pretty-text/engines/discourse-markdown => discourse-markdown-it/src/features}/paragraph.js (100%) rename app/assets/javascripts/{pretty-text/engines/discourse-markdown => discourse-markdown-it/src/features}/quotes.js (100%) rename app/assets/javascripts/{pretty-text/engines/discourse-markdown => discourse-markdown-it/src/features}/table.js (100%) rename app/assets/javascripts/{pretty-text/engines/discourse-markdown => discourse-markdown-it/src/features}/text-post-process.js (100%) rename app/assets/javascripts/{pretty-text/engines/discourse-markdown => discourse-markdown-it/src/features}/upload-protocol.js (100%) rename app/assets/javascripts/{pretty-text/engines/discourse-markdown => discourse-markdown-it/src/features}/watched-words.js (100%) create mode 100644 app/assets/javascripts/discourse-markdown-it/src/index.js create mode 100644 app/assets/javascripts/discourse-markdown-it/src/options.js create mode 100644 app/assets/javascripts/discourse-markdown-it/src/setup.js create mode 100644 app/assets/javascripts/discourse/app/static/markdown-it/features.js create mode 100644 app/assets/javascripts/discourse/app/static/markdown-it/index.js rename app/assets/javascripts/discourse/app/{lib => static/markdown-it}/mentions-parser.js (83%) create mode 100644 app/assets/javascripts/discourse/app/static/markdown-it/options.js create mode 100644 app/assets/javascripts/discourse/app/static/markdown-it/parse-bbcode-tag.js delete mode 100644 app/assets/javascripts/pretty-text/addon/engines/discourse-markdown-it.js rename app/assets/javascripts/pretty-text/{engines/discourse-markdown/helpers.js => addon/text-replace.js} (98%) diff --git a/app/assets/javascripts/discourse-markdown-it/addon-main.cjs b/app/assets/javascripts/discourse-markdown-it/addon-main.cjs new file mode 100644 index 00000000000..f868d6b91ec --- /dev/null +++ b/app/assets/javascripts/discourse-markdown-it/addon-main.cjs @@ -0,0 +1,4 @@ +'use strict'; + +const { addonV1Shim } = require('@embroider/addon-shim'); +module.exports = addonV1Shim(__dirname); diff --git a/app/assets/javascripts/discourse-markdown-it/package.json b/app/assets/javascripts/discourse-markdown-it/package.json new file mode 100644 index 00000000000..ffb10375c9e --- /dev/null +++ b/app/assets/javascripts/discourse-markdown-it/package.json @@ -0,0 +1,45 @@ +{ + "name": "discourse-markdown-it", + "version": "1.0.0", + "private": true, + "description": "Discourse's markdown-it features", + "author": "Discourse ", + "license": "GPL-2.0-only", + "keywords": [ + "ember-addon" + ], + "exports": { + ".": "./src/index.js", + "./*": "./src/*.js", + "./addon-main.js": "./addon-main.cjs" + }, + "files": [ + "addon-main.cjs", + "src" + ], + "dependencies": { + "@embroider/addon-shim": "^1.0.0", + "discourse-common": "1.0.0", + "ember-auto-import": "^2.6.3", + "markdown-it": "^13.0.2" + }, + "peerDependencies": { + "discourse-i18n": "1.0.0", + "pretty-text": "1.0.0", + "xss": "*" + }, + "engines": { + "node": "16.* || >= 18", + "npm": "please-use-yarn", + "yarn": ">= 1.21.1" + }, + "ember": { + "edition": "octane" + }, + "ember-addon": { + "version": 2, + "type": "addon", + "main": "addon-main.cjs", + "app-js": {} + } +} diff --git a/app/assets/javascripts/discourse-markdown-it/src/engine.js b/app/assets/javascripts/discourse-markdown-it/src/engine.js new file mode 100644 index 00000000000..4d5697c1fa3 --- /dev/null +++ b/app/assets/javascripts/discourse-markdown-it/src/engine.js @@ -0,0 +1,348 @@ +import markdownit from "markdown-it"; +import AllowLister from "pretty-text/allow-lister"; +import guid from "pretty-text/guid"; +import { sanitize } from "pretty-text/sanitizer"; +import { TextPostProcessRuler } from "./features/text-post-process"; + +// note, this will mutate options due to the way the API is designed +// may need a refactor +export default function makeEngine( + options, + markdownItOptions, + markdownItRules +) { + const engine = makeMarkdownIt(markdownItOptions, markdownItRules); + + const quotes = + options.discourse.limitedSiteSettings.markdownTypographerQuotationMarks; + + if (quotes) { + engine.options.quotes = quotes.split("|"); + } + + const tlds = options.discourse.limitedSiteSettings.markdownLinkifyTlds || ""; + engine.linkify.tlds(tlds.split("|")); + + setupUrlDecoding(engine); + setupHoister(engine); + setupImageAndPlayableMediaRenderer(engine); + setupAttachments(engine); + setupBlockBBCode(engine); + setupInlineBBCode(engine); + setupTextPostProcessRuler(engine); + + options.engine = engine; + + for (const [feature, callback] of options.pluginCallbacks) { + if (options.discourse.features[feature]) { + if (callback === null || callback === undefined) { + // eslint-disable-next-line no-console + console.log("BAD MARKDOWN CALLBACK FOUND"); + // eslint-disable-next-line no-console + console.log(`FEATURE IS: ${feature}`); + } + engine.use(callback); + } + } + + // top level markdown it notifier + options.markdownIt = true; + options.setup = true; + + if (!options.discourse.sanitizer || !options.sanitizer) { + const allowLister = new AllowLister(options.discourse); + + options.allowListed.forEach(([feature, info]) => { + allowLister.allowListFeature(feature, info); + }); + + options.sanitizer = options.discourse.sanitizer = !!options.discourse + .sanitize + ? (a) => sanitize(a, allowLister) + : (a) => a; + } +} + +export function cook(raw, options) { + // we still have to hoist html_raw nodes so they bypass the allowlister + // this is the case for oneboxes and also certain plugins that require + // raw HTML rendering within markdown bbcode rules + options.discourse.hoisted ??= {}; + + const rendered = options.engine.render(raw); + let cooked = options.discourse.sanitizer(rendered).trim(); + + // opts.discourse.hoisted guid keys will be deleted within here to + // keep the object empty + cooked = unhoistForCooked(options.discourse.hoisted, cooked); + + return cooked; +} + +function makeMarkdownIt(markdownItOptions, markdownItRules) { + if (markdownItRules) { + // Preset for "zero", https://github.com/markdown-it/markdown-it/blob/master/lib/presets/zero.js + return markdownit("zero", markdownItOptions).enable(markdownItRules); + } else { + return markdownit(markdownItOptions); + } +} + +function setupUrlDecoding(engine) { + // this fixed a subtle issue where %20 is decoded as space in + // automatic urls + engine.utils.lib.mdurl.decode.defaultChars = ";/?:@&=+$,# "; +} + +// hoists html_raw tokens out of the render flow and replaces them +// with a GUID. this GUID is then replaced with the final raw HTML +// content in unhoistForCooked +function renderHoisted(tokens, idx, options) { + const content = tokens[idx].content; + if (content && content.length > 0) { + let id = guid(); + options.discourse.hoisted[id] = content; + return id; + } else { + return ""; + } +} + +function unhoistForCooked(hoisted, cooked) { + const keys = Object.keys(hoisted); + if (keys.length) { + let found = true; + + const unhoist = function (key) { + cooked = cooked.replace(new RegExp(key, "g"), function () { + found = true; + return hoisted[key]; + }); + delete hoisted[key]; + }; + + while (found) { + found = false; + keys.forEach(unhoist); + } + } + + return cooked; +} + +// html_raw tokens, funnily enough, render raw HTML via renderHoisted and +// unhoistForCooked +function setupHoister(engine) { + engine.renderer.rules.html_raw = renderHoisted; +} + +// exported for test only +export function extractDataAttribute(str) { + let sep = str.indexOf("="); + if (sep === -1) { + return null; + } + + const key = `data-${str.slice(0, sep)}`.toLowerCase(); + if (!/^[A-Za-z]+[\w\-\:\.]*$/.test(key)) { + return null; + } + + const value = str.slice(sep + 1); + return [key, value]; +} + +// videoHTML and audioHTML follow the same HTML syntax +// as oneboxer.rb when dealing with these formats +function videoHTML(token) { + const src = token.attrGet("src"); + const origSrc = token.attrGet("data-orig-src"); + const dataOrigSrcAttr = origSrc !== null ? `data-orig-src="${origSrc}"` : ""; + return `
+
`; +} + +function audioHTML(token) { + const src = token.attrGet("src"); + const origSrc = token.attrGet("data-orig-src"); + const dataOrigSrcAttr = origSrc !== null ? `data-orig-src="${origSrc}"` : ""; + return ``; +} + +const IMG_SIZE_REGEX = + /^([1-9]+[0-9]*)x([1-9]+[0-9]*)(\s*,\s*(x?)([1-9][0-9]{0,2}?)([%x]?))?$/; +function renderImageOrPlayableMedia(tokens, idx, options, env, slf) { + const token = tokens[idx]; + const alt = slf.renderInlineAsText(token.children, options, env); + const split = alt.split("|"); + const altSplit = [split[0]]; + + // markdown-it supports returning HTML instead of continuing to render the current token + // see https://github.com/markdown-it/markdown-it/blob/master/docs/architecture.md#renderer + // handles |video and |audio alt transformations for image tags + if (split[1] === "video") { + if ( + options.discourse.previewing && + !options.discourse.limitedSiteSettings.enableDiffhtmlPreview + ) { + return `
+ +
`; + } else { + return videoHTML(token); + } + } else if (split[1] === "audio") { + return audioHTML(token); + } + + // parsing ![myimage|500x300]() or ![myimage|75%]() or ![myimage|500x300, 75%] + for (let i = 1, match, data; i < split.length; ++i) { + if ((match = split[i].match(IMG_SIZE_REGEX)) && match[1] && match[2]) { + let width = match[1]; + let height = match[2]; + + // calculate using percentage + if (match[5] && match[6] && match[6] === "%") { + let percent = parseFloat(match[5]) / 100.0; + width = parseInt(width * percent, 10); + height = parseInt(height * percent, 10); + } + + // calculate using only given width + if (match[5] && match[6] && match[6] === "x") { + let wr = parseFloat(match[5]) / width; + width = parseInt(match[5], 10); + height = parseInt(height * wr, 10); + } + + // calculate using only given height + if (match[5] && match[4] && match[4] === "x" && !match[6]) { + let hr = parseFloat(match[5]) / height; + height = parseInt(match[5], 10); + width = parseInt(width * hr, 10); + } + + if (token.attrIndex("width") === -1) { + token.attrs.push(["width", width]); + } + + if (token.attrIndex("height") === -1) { + token.attrs.push(["height", height]); + } + + if ( + options.discourse.previewing && + match[6] !== "x" && + match[4] !== "x" + ) { + token.attrs.push(["class", "resizable"]); + } + } else if ((data = extractDataAttribute(split[i]))) { + token.attrs.push(data); + } else if (split[i] === "thumbnail") { + token.attrs.push(["data-thumbnail", "true"]); + } else { + altSplit.push(split[i]); + } + } + + const altValue = altSplit.join("|").trim(); + if (altValue === "") { + token.attrSet("role", "presentation"); + } else { + token.attrSet("alt", altValue); + } + + return slf.renderToken(tokens, idx, options); +} + +// we have taken over the ![]() syntax in markdown to +// be able to render a video or audio URL as well as the +// image using |video and |audio in the text inside [] +function setupImageAndPlayableMediaRenderer(engine) { + engine.renderer.rules.image = renderImageOrPlayableMedia; +} + +// discourse-encrypt wants this? +export const ATTACHMENT_CSS_CLASS = "attachment"; + +function renderAttachment(tokens, idx, options, env, slf) { + const linkToken = tokens[idx]; + const textToken = tokens[idx + 1]; + + const split = textToken.content.split("|"); + const contentSplit = []; + + for (let i = 0, data; i < split.length; ++i) { + if (split[i] === ATTACHMENT_CSS_CLASS) { + linkToken.attrs.unshift(["class", split[i]]); + } else if ((data = extractDataAttribute(split[i]))) { + linkToken.attrs.push(data); + } else { + contentSplit.push(split[i]); + } + } + + if (contentSplit.length > 0) { + textToken.content = contentSplit.join("|"); + } + + return slf.renderToken(tokens, idx, options); +} + +function setupAttachments(engine) { + engine.renderer.rules.link_open = renderAttachment; +} + +// TODO we may just use a proper ruler from markdown it... this is a basic proxy +class Ruler { + constructor() { + this.rules = []; + } + + getRules() { + return this.rules; + } + + getRuleForTag(tag) { + this.ensureCache(); + if (this.cache.hasOwnProperty(tag)) { + return this.cache[tag]; + } + } + + ensureCache() { + if (this.cache) { + return; + } + + this.cache = {}; + for (let i = this.rules.length - 1; i >= 0; i--) { + let info = this.rules[i]; + this.cache[info.rule.tag] = info; + } + } + + push(name, rule) { + this.rules.push({ name, rule }); + this.cache = null; + } +} + +// block bb code ruler for parsing of quotes / code / polls +function setupBlockBBCode(engine) { + engine.block.bbcode = { ruler: new Ruler() }; +} + +// inline bbcode ruler for parsing of spoiler tags, discourse-chart etc +function setupInlineBBCode(engine) { + engine.inline.bbcode = { ruler: new Ruler() }; +} + +// rule for text replacement via regex, used for @mentions, category hashtags, etc. +function setupTextPostProcessRuler(engine) { + engine.core.textPostProcess = { ruler: new TextPostProcessRuler() }; +} diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/anchor.js b/app/assets/javascripts/discourse-markdown-it/src/features/anchor.js similarity index 100% rename from app/assets/javascripts/pretty-text/engines/discourse-markdown/anchor.js rename to app/assets/javascripts/discourse-markdown-it/src/features/anchor.js diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/bbcode-block.js b/app/assets/javascripts/discourse-markdown-it/src/features/bbcode-block.js similarity index 100% rename from app/assets/javascripts/pretty-text/engines/discourse-markdown/bbcode-block.js rename to app/assets/javascripts/discourse-markdown-it/src/features/bbcode-block.js diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/bbcode-inline.js b/app/assets/javascripts/discourse-markdown-it/src/features/bbcode-inline.js similarity index 98% rename from app/assets/javascripts/pretty-text/engines/discourse-markdown/bbcode-inline.js rename to app/assets/javascripts/discourse-markdown-it/src/features/bbcode-inline.js index 681dadf943a..da75d5ad8e3 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/bbcode-inline.js +++ b/app/assets/javascripts/discourse-markdown-it/src/features/bbcode-inline.js @@ -1,4 +1,4 @@ -import { parseBBCodeTag } from "pretty-text/engines/discourse-markdown/bbcode-block"; +import { parseBBCodeTag } from "./bbcode-block"; function tokenizeBBCode(state, silent, ruler) { let pos = state.pos; diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/censored.js b/app/assets/javascripts/discourse-markdown-it/src/features/censored.js similarity index 100% rename from app/assets/javascripts/pretty-text/engines/discourse-markdown/censored.js rename to app/assets/javascripts/discourse-markdown-it/src/features/censored.js diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/code.js b/app/assets/javascripts/discourse-markdown-it/src/features/code.js similarity index 100% rename from app/assets/javascripts/pretty-text/engines/discourse-markdown/code.js rename to app/assets/javascripts/discourse-markdown-it/src/features/code.js diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/custom-typographer-replacements.js b/app/assets/javascripts/discourse-markdown-it/src/features/custom-typographer-replacements.js similarity index 100% rename from app/assets/javascripts/pretty-text/engines/discourse-markdown/custom-typographer-replacements.js rename to app/assets/javascripts/discourse-markdown-it/src/features/custom-typographer-replacements.js diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/d-wrap.js b/app/assets/javascripts/discourse-markdown-it/src/features/d-wrap.js similarity index 95% rename from app/assets/javascripts/pretty-text/engines/discourse-markdown/d-wrap.js rename to app/assets/javascripts/discourse-markdown-it/src/features/d-wrap.js index 091ec4c0097..c459189285e 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/d-wrap.js +++ b/app/assets/javascripts/discourse-markdown-it/src/features/d-wrap.js @@ -1,4 +1,4 @@ -import { parseBBCodeTag } from "pretty-text/engines/discourse-markdown/bbcode-block"; +import { parseBBCodeTag } from "./bbcode-block"; const WRAP_CLASS = "d-wrap"; diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/emoji.js b/app/assets/javascripts/discourse-markdown-it/src/features/emoji.js similarity index 100% rename from app/assets/javascripts/pretty-text/engines/discourse-markdown/emoji.js rename to app/assets/javascripts/discourse-markdown-it/src/features/emoji.js diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/hashtag-autocomplete.js b/app/assets/javascripts/discourse-markdown-it/src/features/hashtag-autocomplete.js similarity index 100% rename from app/assets/javascripts/pretty-text/engines/discourse-markdown/hashtag-autocomplete.js rename to app/assets/javascripts/discourse-markdown-it/src/features/hashtag-autocomplete.js diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/html-img.js b/app/assets/javascripts/discourse-markdown-it/src/features/html-img.js similarity index 100% rename from app/assets/javascripts/pretty-text/engines/discourse-markdown/html-img.js rename to app/assets/javascripts/discourse-markdown-it/src/features/html-img.js diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/image-controls.js b/app/assets/javascripts/discourse-markdown-it/src/features/image-controls.js similarity index 100% rename from app/assets/javascripts/pretty-text/engines/discourse-markdown/image-controls.js rename to app/assets/javascripts/discourse-markdown-it/src/features/image-controls.js diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/image-grid.js b/app/assets/javascripts/discourse-markdown-it/src/features/image-grid.js similarity index 100% rename from app/assets/javascripts/pretty-text/engines/discourse-markdown/image-grid.js rename to app/assets/javascripts/discourse-markdown-it/src/features/image-grid.js diff --git a/app/assets/javascripts/discourse-markdown-it/src/features/index.js b/app/assets/javascripts/discourse-markdown-it/src/features/index.js new file mode 100644 index 00000000000..f9e7ecf1915 --- /dev/null +++ b/app/assets/javascripts/discourse-markdown-it/src/features/index.js @@ -0,0 +1,49 @@ +import * as anchor from "./anchor"; +import * as bbcodeBlock from "./bbcode-block"; +import * as bbcodeInline from "./bbcode-inline"; +import * as censored from "./censored"; +import * as code from "./code"; +import * as customTypographerReplacements from "./custom-typographer-replacements"; +import * as dWrap from "./d-wrap"; +import * as emoji from "./emoji"; +import * as hashtagAutocomplete from "./hashtag-autocomplete"; +import * as htmlImg from "./html-img"; +import * as imageControls from "./image-controls"; +import * as imageGrid from "./image-grid"; +import * as mentions from "./mentions"; +import * as newline from "./newline"; +import * as onebox from "./onebox"; +import * as paragraph from "./paragraph"; +import * as quotes from "./quotes"; +import * as table from "./table"; +import * as textPostProcess from "./text-post-process"; +import * as uploadProtocol from "./upload-protocol"; +import * as watchedWords from "./watched-words"; + +export default [ + feature("watched-words", watchedWords), + feature("upload-protocol", uploadProtocol), + feature("text-post-process", textPostProcess), + feature("table", table), + feature("quotes", quotes), + feature("paragraph", paragraph), + feature("onebox", onebox), + feature("newline", newline), + feature("mentions", mentions), + feature("image-grid", imageGrid), + feature("image-controls", imageControls), + feature("html-img", htmlImg), + feature("hashtag-autocomplete", hashtagAutocomplete), + feature("emoji", emoji), + feature("d-wrap", dWrap), + feature("custom-typographer-replacements", customTypographerReplacements), + feature("code", code), + feature("censored", censored), + feature("bbcode-inline", bbcodeInline), + feature("bbcode-block", bbcodeBlock), + feature("anchor", anchor), +]; + +function feature(id, { setup, priority = 0 }) { + return { id, setup, priority }; +} diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/mentions.js b/app/assets/javascripts/discourse-markdown-it/src/features/mentions.js similarity index 100% rename from app/assets/javascripts/pretty-text/engines/discourse-markdown/mentions.js rename to app/assets/javascripts/discourse-markdown-it/src/features/mentions.js diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/newline.js b/app/assets/javascripts/discourse-markdown-it/src/features/newline.js similarity index 100% rename from app/assets/javascripts/pretty-text/engines/discourse-markdown/newline.js rename to app/assets/javascripts/discourse-markdown-it/src/features/newline.js diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/onebox.js b/app/assets/javascripts/discourse-markdown-it/src/features/onebox.js similarity index 100% rename from app/assets/javascripts/pretty-text/engines/discourse-markdown/onebox.js rename to app/assets/javascripts/discourse-markdown-it/src/features/onebox.js diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/paragraph.js b/app/assets/javascripts/discourse-markdown-it/src/features/paragraph.js similarity index 100% rename from app/assets/javascripts/pretty-text/engines/discourse-markdown/paragraph.js rename to app/assets/javascripts/discourse-markdown-it/src/features/paragraph.js diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/quotes.js b/app/assets/javascripts/discourse-markdown-it/src/features/quotes.js similarity index 100% rename from app/assets/javascripts/pretty-text/engines/discourse-markdown/quotes.js rename to app/assets/javascripts/discourse-markdown-it/src/features/quotes.js diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/table.js b/app/assets/javascripts/discourse-markdown-it/src/features/table.js similarity index 100% rename from app/assets/javascripts/pretty-text/engines/discourse-markdown/table.js rename to app/assets/javascripts/discourse-markdown-it/src/features/table.js diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/text-post-process.js b/app/assets/javascripts/discourse-markdown-it/src/features/text-post-process.js similarity index 100% rename from app/assets/javascripts/pretty-text/engines/discourse-markdown/text-post-process.js rename to app/assets/javascripts/discourse-markdown-it/src/features/text-post-process.js diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/upload-protocol.js b/app/assets/javascripts/discourse-markdown-it/src/features/upload-protocol.js similarity index 100% rename from app/assets/javascripts/pretty-text/engines/discourse-markdown/upload-protocol.js rename to app/assets/javascripts/discourse-markdown-it/src/features/upload-protocol.js diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/watched-words.js b/app/assets/javascripts/discourse-markdown-it/src/features/watched-words.js similarity index 100% rename from app/assets/javascripts/pretty-text/engines/discourse-markdown/watched-words.js rename to app/assets/javascripts/discourse-markdown-it/src/features/watched-words.js diff --git a/app/assets/javascripts/discourse-markdown-it/src/index.js b/app/assets/javascripts/discourse-markdown-it/src/index.js new file mode 100644 index 00000000000..c7e9cde1ac7 --- /dev/null +++ b/app/assets/javascripts/discourse-markdown-it/src/index.js @@ -0,0 +1,74 @@ +import { cook as cookIt } from "./engine"; +import DEFAULT_FEATURES from "./features"; +import buildOptions from "./options"; +import setup from "./setup"; + +function NOOP(ident) { + return ident; +} + +export default class DiscourseMarkdownIt { + static withDefaultFeatures() { + return this.withFeatures(DEFAULT_FEATURES); + } + + static withCustomFeatures(features) { + return this.withFeatures([...DEFAULT_FEATURES, ...features]); + } + + static withFeatures(features) { + const withOptions = (options) => this.withOptions(features, options); + return { withOptions }; + } + + static withOptions(features, rawOptions) { + const { options, siteSettings, state } = buildOptions(rawOptions); + + // note, this will mutate options due to the way the API is designed + // may need a refactor + setup(features, options, siteSettings, state); + + return new DiscourseMarkdownIt(options); + } + + static minimal() { + return this.withFeatures([]).withOptions({ siteSettings: {} }); + } + + constructor(options) { + if (!options.setup) { + throw new Error( + "Cannot construct DiscourseMarkdownIt from raw options, " + + "use DiscourseMarkdownIt.withOptions() instead" + ); + } + + this.options = options; + } + + disableSanitizer() { + this.options.sanitizer = this.options.discourse.sanitizer = NOOP; + } + + cook(raw) { + if (!raw || raw.length === 0) { + return ""; + } + + let result; + result = cookIt(raw, this.options); + return result ? result : ""; + } + + parse(markdown, env = {}) { + return this.options.engine.parse(markdown, env); + } + + sanitize(html) { + return this.options.sanitizer(html).trim(); + } + + get linkify() { + return this.options.engine.linkify; + } +} diff --git a/app/assets/javascripts/discourse-markdown-it/src/options.js b/app/assets/javascripts/discourse-markdown-it/src/options.js new file mode 100644 index 00000000000..272c72da816 --- /dev/null +++ b/app/assets/javascripts/discourse-markdown-it/src/options.js @@ -0,0 +1,83 @@ +import { deepMerge } from "discourse-common/lib/object"; + +// the options are passed here and must be explicitly allowed with +// the const options & state below +export default function buildOptions(state) { + const { + siteSettings, + getURL, + lookupAvatar, + lookupPrimaryUserGroup, + getTopicInfo, + topicId, + forceQuoteLink, + userId, + getCurrentUser, + currentUser, + lookupAvatarByPostNumber, + lookupPrimaryUserGroupByPostNumber, + formatUsername, + emojiUnicodeReplacer, + lookupUploadUrls, + previewing, + censoredRegexp, + disableEmojis, + customEmojiTranslation, + watchedWordsReplace, + watchedWordsLink, + emojiDenyList, + featuresOverride, + markdownItRules, + additionalOptions, + hashtagTypesInPriorityOrder, + hashtagIcons, + hashtagLookup, + } = state; + + let features = {}; + + if (state.features) { + features = deepMerge(features, state.features); + } + + const options = { + sanitize: true, + getURL, + features, + lookupAvatar, + lookupPrimaryUserGroup, + getTopicInfo, + topicId, + forceQuoteLink, + userId, + getCurrentUser, + currentUser, + lookupAvatarByPostNumber, + lookupPrimaryUserGroupByPostNumber, + formatUsername, + emojiUnicodeReplacer, + lookupUploadUrls, + censoredRegexp, + customEmojiTranslation, + allowedHrefSchemes: siteSettings.allowed_href_schemes + ? siteSettings.allowed_href_schemes.split("|") + : null, + allowedIframes: siteSettings.allowed_iframes + ? siteSettings.allowed_iframes.split("|") + : [], + markdownIt: true, + previewing, + disableEmojis, + watchedWordsReplace, + watchedWordsLink, + emojiDenyList, + featuresOverride, + markdownItRules, + additionalOptions, + hashtagTypesInPriorityOrder, + hashtagIcons, + hashtagLookup, + }; + + return { options, siteSettings, state }; +} diff --git a/app/assets/javascripts/discourse-markdown-it/src/setup.js b/app/assets/javascripts/discourse-markdown-it/src/setup.js new file mode 100644 index 00000000000..e420b36d06d --- /dev/null +++ b/app/assets/javascripts/discourse-markdown-it/src/setup.js @@ -0,0 +1,359 @@ +import { textReplace } from "pretty-text/text-replace"; +import deprecated from "discourse-common/lib/deprecated"; +import { cloneJSON } from "discourse-common/lib/object"; +import makeEngine, { cook } from "./engine"; + +// note, this will mutate options due to the way the API is designed +// may need a refactor +export default function setupIt(features, options, siteSettings, state) { + Setup.run(features, options, siteSettings, state); +} + +class Setup { + static run(features, options, siteSettings, state) { + if (options.setup) { + // Already setup + return; + } + + const setup = new Setup(options); + + features.sort((a, b) => a.priority - b.priority); + + for (const feature of features) { + setup.#setupFeature(feature.id, feature.setup); + } + + for (const entry of Object.entries(state.allowListed ?? {})) { + setup.allowList(entry); + } + + setup.#runOptionsCallbacks(siteSettings, state); + + setup.#enableMarkdownFeatures(); + + setup.#finalizeGetOptions(siteSettings); + + setup.#makeEngine(); + + setup.#buildCookFunctions(); + } + + #context; + #options; + + #allowListed = []; + #customMarkdownCookFunctionCallbacks = []; + #loadedFeatures = []; + #optionCallbacks = []; + #pluginCallbacks = []; + + constructor(options) { + options.markdownIt = true; + + this.#options = options; + + // hack to allow moving of getOptions – see #finalizeGetOptions + this.#context = { options }; + } + + allowList(entry) { + this.#allowListed.push(entry); + } + + registerOptions(entry) { + this.#optionCallbacks.push(entry); + } + + registerPlugin(entry) { + this.#pluginCallbacks.push(entry); + } + + buildCookFunction(entry) { + this.#customMarkdownCookFunctionCallbacks.push(entry); + } + + #setupFeature(featureName, callback) { + // When we provide the API object to the setup callback, we expect them to + // make use of it synchronously. However, it is possible that the could + // close over the API object, intentionally or unintentionally, and cause + // memory leaks or unexpectedly call API methods at a later time with + // unpredictable results. This make sure to "gut" the API object after the + // callback is executed so that it cannot leak memory or be used later. + let loaned = this; + + const doSetup = (methodName, ...args) => { + if (loaned === null) { + throw new Error( + `${featureName}: ${methodName} can only be called during setup()!` + ); + } + + if (loaned[methodName]) { + return loaned[methodName](...args); + } + }; + + callback(new API(featureName, this.#context, doSetup)); + + this.#loadedFeatures.push(featureName); + + // revoke access to the Setup object + loaned = null; + } + + #runOptionsCallbacks(siteSettings, state) { + this.#drain(this.#optionCallbacks, ([, callback]) => + callback(this.#options, siteSettings, state) + ); + } + + #enableMarkdownFeatures({ features, featuresOverride } = this.#options) { + // TODO: `options.features` could in theory contain additional keys for + // features that aren't loaded. The way the previous code was written + // incidentally means we would iterate over a super set of both. To be + // pedantic we kept that behavior here, but I'm not sure if that's really + // necessary. + const allFeatures = new Set([ + ...this.#drain(this.#loadedFeatures), + ...Object.keys(features), + ]); + + if (featuresOverride) { + for (const feature of allFeatures) { + features[feature] = featuresOverride.includes(feature); + } + } else { + // enable all features by default + for (let feature of allFeatures) { + features[feature] ??= true; + } + } + } + + #finalizeGetOptions(siteSettings) { + // This is weird but essentially we want to remove `options.*` in-place + // into `options.discourse.*`, then, we want to change `context.options` + // to point at `options.discourse`. This ensures features that held onto + // the API object during setup will continue to get the right stuff when + // they call `getOptions()`. + const options = this.#options; + const discourse = {}; + + for (const [key, value] of Object.entries(options)) { + discourse[key] = value; + delete options[key]; + } + + discourse.helpers = { textReplace }; + + discourse.limitedSiteSettings = { + secureUploads: siteSettings.secure_uploads, + enableDiffhtmlPreview: siteSettings.enable_diffhtml_preview, + traditionalMarkdownLinebreaks: + siteSettings.traditional_markdown_linebreaks, + enableMarkdownLinkify: siteSettings.enable_markdown_linkify, + enableMarkdownTypographer: siteSettings.enable_markdown_typographer, + markdownTypographerQuotationMarks: + siteSettings.markdown_typographer_quotation_marks, + markdownLinkifyTlds: siteSettings.markdown_linkify_tlds, + }; + + this.#context.options = options.discourse = discourse; + } + + #makeEngine() { + const options = this.#options; + const { discourse } = options; + const { markdownItRules, limitedSiteSettings } = discourse; + const { + enableMarkdownLinkify, + enableMarkdownTypographer, + traditionalMarkdownLinebreaks, + } = limitedSiteSettings; + + options.allowListed = this.#drain(this.#allowListed); + options.pluginCallbacks = this.#drain(this.#pluginCallbacks); + + const markdownItOptions = { + discourse, + html: true, + breaks: !traditionalMarkdownLinebreaks, + xhtmlOut: false, + linkify: enableMarkdownLinkify, + typographer: enableMarkdownTypographer, + }; + + makeEngine(options, markdownItOptions, markdownItRules); + } + + #buildCookFunctions() { + const options = this.#options; + + // the callback argument we pass to the callbacks + let callbackArg = (engineOptions, afterBuild) => + afterBuild(this.#buildCookFunction(engineOptions, options)); + + this.#drain(this.#customMarkdownCookFunctionCallbacks, ([, callback]) => { + callback(options, callbackArg); + }); + } + + #buildCookFunction(engineOptions, defaultOptions) { + // everything except the engine for opts can just point to the other + // opts references, they do not change and we don't need to worry about + // mutating them. note that this may need to be updated when additional + // opts are added to the pipeline + const options = {}; + options.allowListed = defaultOptions.allowListed; + options.pluginCallbacks = defaultOptions.pluginCallbacks; + options.sanitizer = defaultOptions.sanitizer; + + // everything from the discourse part of defaultOptions can be cloned except + // the features, because these can be a limited subset and we don't want to + // change the original object reference + const features = cloneJSON(defaultOptions.discourse.features); + options.discourse = { + ...defaultOptions.discourse, + features, + }; + + this.#enableMarkdownFeatures({ + features, + featuresOverride: engineOptions.featuresOverride, + }); + + const markdownItOptions = { + discourse: options.discourse, + html: defaultOptions.engine.options.html, + breaks: defaultOptions.engine.options.breaks, + xhtmlOut: defaultOptions.engine.options.xhtmlOut, + linkify: defaultOptions.engine.options.linkify, + typographer: defaultOptions.engine.options.typographer, + }; + + makeEngine(options, markdownItOptions, engineOptions.markdownItRules); + + return function customCookFunction(raw) { + return cook(raw, options); + }; + } + + #drain(items, callback) { + if (callback) { + let item = items.shift(); + + while (item) { + callback(item); + item = items.shift(); + } + } else { + const cloned = [...items]; + items.length = 0; + return cloned; + } + } +} + +class API { + #name; + #context; + #setup; + #deprecate; + + constructor(featureName, context, setup) { + this.#name = featureName; + this.#context = context; + this.#setup = setup; + this.#deprecate = (methodName, ...args) => { + if (window.console && window.console.log) { + window.console.log( + featureName + + ": " + + methodName + + " is deprecated, please use the new markdown it APIs" + ); + } + + return setup(methodName, ...args); + }; + } + + get markdownIt() { + return true; + } + + // this the only method we expect to be called post-setup() + getOptions() { + return this.#context.options; + } + + allowList(info) { + this.#setup("allowList", [this.#name, info]); + } + + whiteList(info) { + deprecated("`whiteList` has been replaced with `allowList`", { + since: "2.6.0.beta.4", + dropFrom: "2.7.0", + id: "discourse.markdown-it.whitelist", + }); + + this.allowList(info); + } + + registerOptions(callback) { + this.#setup("registerOptions", [this.#name, callback]); + } + + registerPlugin(callback) { + this.#setup("registerPlugin", [this.#name, callback]); + } + + buildCookFunction(callback) { + this.#setup("buildCookFunction", [this.#name, callback]); + } + + // deprecate methods – "deprecate" is a bit of a misnomer here since the + // methods don't actually do anything anymore + + registerInline() { + this.#deprecate("registerInline"); + } + + replaceBlock() { + this.#deprecate("replaceBlock"); + } + + addPreProcessor() { + this.#deprecate("addPreProcessor"); + } + + inlineReplace() { + this.#deprecate("inlineReplace"); + } + + postProcessTag() { + this.#deprecate("postProcessTag"); + } + + inlineRegexp() { + this.#deprecate("inlineRegexp"); + } + + inlineBetween() { + this.#deprecate("inlineBetween"); + } + + postProcessText() { + this.#deprecate("postProcessText"); + } + + onParseNode() { + this.#deprecate("onParseNode"); + } + + registerBlock() { + this.#deprecate("registerBlock"); + } +} diff --git a/app/assets/javascripts/discourse/app/lib/text.js b/app/assets/javascripts/discourse/app/lib/text.js index 2745cf3aacf..3de5338080e 100644 --- a/app/assets/javascripts/discourse/app/lib/text.js +++ b/app/assets/javascripts/discourse/app/lib/text.js @@ -1,44 +1,17 @@ -import { htmlSafe } from "@ember/template"; import AllowLister from "pretty-text/allow-lister"; import { buildEmojiUrl, performEmojiUnescape } from "pretty-text/emoji"; -import PrettyText, { buildOptions } from "pretty-text/pretty-text"; import { sanitize as textSanitize } from "pretty-text/sanitizer"; -import { Promise } from "rsvp"; -import loadScript from "discourse/lib/load-script"; -import { MentionsParser } from "discourse/lib/mentions-parser"; -import { formatUsername } from "discourse/lib/utilities"; -import Session from "discourse/models/session"; import deprecated from "discourse-common/lib/deprecated"; import { getURLWithCDN } from "discourse-common/lib/get-url"; import { helperContext } from "discourse-common/lib/helpers"; -function getOpts(opts) { - let context = helperContext(); - - opts = Object.assign( - { - getURL: getURLWithCDN, - currentUser: context.currentUser, - censoredRegexp: context.site.censored_regexp, - customEmojiTranslation: context.site.custom_emoji_translation, - emojiDenyList: context.site.denied_emojis, - siteSettings: context.siteSettings, - formatUsername, - watchedWordsReplace: context.site.watched_words_replace, - watchedWordsLink: context.site.watched_words_link, - additionalOptions: context.site.markdown_additional_options, - }, - opts - ); - - return buildOptions(opts); +async function withEngine(name, ...args) { + const engine = await import("discourse/static/markdown-it"); + return engine[name](...args); } -export function cook(text, options) { - return loadMarkdownIt().then(() => { - const cooked = createPrettyText(options).cook(text); - return htmlSafe(cooked); - }); +export async function cook(text, options) { + return await withEngine("cook", text, options); } // todo drop this function after migrating everything to cook() @@ -48,66 +21,38 @@ export function cookAsync(text, options) { dropFrom: "3.2.0.beta5", id: "discourse.text.cook-async", }); + return cook(text, options); } -// Warm up pretty text with a set of options and return a function -// which can be used to cook without rebuilding pretty-text every time -export function generateCookFunction(options) { - return loadMarkdownIt().then(() => { - const prettyText = createPrettyText(options); - return (text) => prettyText.cook(text); - }); +// Warm up the engine with a set of options and return a function +// which can be used to cook without rebuilding the engine every time +export async function generateCookFunction(options) { + return await withEngine("generateCookFunction", options); } -export function generateLinkifyFunction(options) { - return loadMarkdownIt().then(() => { - const prettyText = createPrettyText(options); - return prettyText.opts.engine.linkify; - }); +export async function generateLinkifyFunction(options) { + return await withEngine("generateLinkifyFunction", options); } +// TODO: this one is special, it attempts to do something even without +// the engine loaded. Currently, this is what is forcing the xss library +// to be included on initial page load. The API/behavior also seems a bit +// different than the async version. export function sanitize(text, options) { return textSanitize(text, new AllowLister(options)); } -export function sanitizeAsync(text, options) { - return loadMarkdownIt().then(() => { - return createPrettyText(options).sanitize(text); - }); +export async function sanitizeAsync(text, options) { + return await withEngine("sanitize", text, options); } -export function parseAsync(md, options = {}, env = {}) { - return loadMarkdownIt().then(() => { - return createPrettyText(options).parse(md, env); - }); +export async function parseAsync(md, options = {}, env = {}) { + return await withEngine("parse", md, options, env); } export async function parseMentions(markdown, options) { - await loadMarkdownIt(); - const prettyText = createPrettyText(options); - const mentionsParser = new MentionsParser(prettyText); - return mentionsParser.parse(markdown); -} - -function loadMarkdownIt() { - return new Promise((resolve) => { - let markdownItURL = Session.currentProp("markdownItURL"); - if (markdownItURL) { - loadScript(markdownItURL) - .then(() => resolve()) - .catch((e) => { - // eslint-disable-next-line no-console - console.error(e); - }); - } else { - resolve(); - } - }); -} - -function createPrettyText(options) { - return new PrettyText(getOpts(options)); + return await withEngine("parseMentions", markdown, options); } function emojiOptions() { diff --git a/app/assets/javascripts/discourse/app/static/markdown-it/features.js b/app/assets/javascripts/discourse/app/static/markdown-it/features.js new file mode 100644 index 00000000000..2d62b772b40 --- /dev/null +++ b/app/assets/javascripts/discourse/app/static/markdown-it/features.js @@ -0,0 +1,25 @@ +export default function loadPluginFeatures() { + const features = []; + + for (let moduleName of Object.keys(requirejs.entries)) { + if (moduleName.startsWith("discourse/plugins/")) { + // all of the modules under discourse-markdown or markdown-it + // directories are considered additional markdown "features" which + // may define their own rules + if ( + moduleName.includes("/discourse-markdown/") || + moduleName.includes("/markdown-it/") + ) { + const module = requirejs(moduleName); + + if (module && module.setup) { + const id = moduleName.split("/").reverse()[0]; + const { setup, priority = 0 } = module; + features.unshift({ id, setup, priority }); + } + } + } + } + + return features; +} diff --git a/app/assets/javascripts/discourse/app/static/markdown-it/index.js b/app/assets/javascripts/discourse/app/static/markdown-it/index.js new file mode 100644 index 00000000000..2c291c5f2f2 --- /dev/null +++ b/app/assets/javascripts/discourse/app/static/markdown-it/index.js @@ -0,0 +1,60 @@ +import { htmlSafe } from "@ember/template"; +import { importSync } from "@embroider/macros"; +import loaderShim from "discourse-common/lib/loader-shim"; +import DiscourseMarkdownIt from "discourse-markdown-it"; +import loadPluginFeatures from "./features"; +import MentionsParser from "./mentions-parser"; +import buildOptions from "./options"; + +// Shims the `parseBBCodeTag` utility function back to its old location. For +// now, there is no deprecation with this as we don't have a new location for +// them to import from (well, we do, but we don't want to expose the new code +// to loader.js and we want to make sure the code is loaded lazily). +// +// TODO: find a new home for this – the code is rather small so we could just +// throw it into the synchronous pretty-text package and call it good, but we +// should probably look into why plugins are needing to call this utility in +// the first place, and provide better infrastructure for registering bbcode +// additions instead. +loaderShim("pretty-text/engines/discourse-markdown/bbcode-block", () => + importSync("./parse-bbcode-tag") +); + +function buildEngine(options) { + return DiscourseMarkdownIt.withCustomFeatures( + loadPluginFeatures() + ).withOptions(buildOptions(options)); +} + +// Use this to easily create an instance with proper options +export function cook(text, options) { + return htmlSafe(buildEngine(options).cook(text)); +} + +// Warm up the engine with a set of options and return a function +// which can be used to cook without rebuilding the engine every time +export function generateCookFunction(options) { + const engine = buildEngine(options); + return (text) => engine.cook(text); +} + +export function generateLinkifyFunction(options) { + const engine = buildEngine(options); + return engine.linkify; +} + +export function sanitize(text, options) { + const engine = buildEngine(options); + return engine.sanitize(text); +} + +export function parse(md, options = {}, env = {}) { + const engine = buildEngine(options); + return engine.parse(md, env); +} + +export function parseMentions(markdown, options) { + const engine = buildEngine(options); + const mentionsParser = new MentionsParser(engine); + return mentionsParser.parse(markdown); +} diff --git a/app/assets/javascripts/discourse/app/lib/mentions-parser.js b/app/assets/javascripts/discourse/app/static/markdown-it/mentions-parser.js similarity index 83% rename from app/assets/javascripts/discourse/app/lib/mentions-parser.js rename to app/assets/javascripts/discourse/app/static/markdown-it/mentions-parser.js index 757b7aad801..0d9c28b37a0 100644 --- a/app/assets/javascripts/discourse/app/lib/mentions-parser.js +++ b/app/assets/javascripts/discourse/app/static/markdown-it/mentions-parser.js @@ -1,10 +1,10 @@ -export class MentionsParser { - constructor(prettyText) { - this.prettyText = prettyText; +export default class MentionsParser { + constructor(engine) { + this.engine = engine; } parse(markdown) { - const tokens = this.prettyText.parse(markdown); + const tokens = this.engine.parse(markdown); const mentions = this.#parse(tokens); return [...new Set(mentions)]; } diff --git a/app/assets/javascripts/discourse/app/static/markdown-it/options.js b/app/assets/javascripts/discourse/app/static/markdown-it/options.js new file mode 100644 index 00000000000..e269907c34f --- /dev/null +++ b/app/assets/javascripts/discourse/app/static/markdown-it/options.js @@ -0,0 +1,21 @@ +import { formatUsername } from "discourse/lib/utilities"; +import { getURLWithCDN } from "discourse-common/lib/get-url"; +import { helperContext } from "discourse-common/lib/helpers"; + +export default function buildOptions(options) { + let context = helperContext(); + + return { + getURL: getURLWithCDN, + currentUser: context.currentUser, + censoredRegexp: context.site.censored_regexp, + customEmojiTranslation: context.site.custom_emoji_translation, + emojiDenyList: context.site.denied_emojis, + siteSettings: context.siteSettings, + formatUsername, + watchedWordsReplace: context.site.watched_words_replace, + watchedWordsLink: context.site.watched_words_link, + additionalOptions: context.site.markdown_additional_options, + ...options, + }; +} diff --git a/app/assets/javascripts/discourse/app/static/markdown-it/parse-bbcode-tag.js b/app/assets/javascripts/discourse/app/static/markdown-it/parse-bbcode-tag.js new file mode 100644 index 00000000000..2f3d4b51eee --- /dev/null +++ b/app/assets/javascripts/discourse/app/static/markdown-it/parse-bbcode-tag.js @@ -0,0 +1 @@ +export { parseBBCodeTag } from "discourse-markdown-it/features/bbcode-block"; diff --git a/app/assets/javascripts/discourse/ember-cli-build.js b/app/assets/javascripts/discourse/ember-cli-build.js index 79172611050..d22036ad697 100644 --- a/app/assets/javascripts/discourse/ember-cli-build.js +++ b/app/assets/javascripts/discourse/ember-cli-build.js @@ -93,10 +93,6 @@ module.exports = function (defaults) { const wizardTree = app.project.findAddonByName("wizard").treeForAddonBundle(); - const markdownItBundleTree = app.project - .findAddonByName("pretty-text") - .treeForMarkdownItBundle(); - const testStylesheetTree = mergeTrees([ discourseScss(`${discourseRoot}/app/assets/stylesheets`, "qunit.scss"), discourseScss( @@ -126,19 +122,19 @@ module.exports = function (defaults) { inputFiles: ["**/*.js"], outputFile: `assets/wizard.js`, }), - concat(markdownItBundleTree, { - inputFiles: ["**/*.js"], - outputFile: `assets/markdown-it-bundle.js`, - }), generateScriptsTree(app), discoursePluginsTree, testStylesheetTree, ]; const appTree = compatBuild(app, Webpack, { + staticAppPaths: ["static"], packagerOptions: { webpackConfig: { devtool: "source-map", + output: { + publicPath: "auto", + }, externals: [ function ({ request }, callback) { if ( @@ -147,8 +143,6 @@ module.exports = function (defaults) { (request === "jquery" || request.startsWith("admin/") || request.startsWith("wizard/") || - (request.startsWith("pretty-text/engines/") && - request !== "pretty-text/engines/discourse-markdown-it") || request.startsWith("discourse/plugins/") || request.startsWith("discourse/theme-")) ) { diff --git a/app/assets/javascripts/discourse/package.json b/app/assets/javascripts/discourse/package.json index e74d5cc8490..c58764c14c5 100644 --- a/app/assets/javascripts/discourse/package.json +++ b/app/assets/javascripts/discourse/package.json @@ -84,6 +84,7 @@ "dialog-holder": "1.0.0", "discourse-common": "1.0.0", "discourse-i18n": "1.0.0", + "discourse-markdown-it": "1.0.0", "discourse-plugins": "1.0.0", "ember-auto-import": "^2.6.3", "ember-buffered-proxy": "^2.1.1", diff --git a/app/assets/javascripts/discourse/tests/index.html b/app/assets/javascripts/discourse/tests/index.html index 0514515915e..625754161b3 100644 --- a/app/assets/javascripts/discourse/tests/index.html +++ b/app/assets/javascripts/discourse/tests/index.html @@ -61,7 +61,6 @@ - diff --git a/app/assets/javascripts/discourse/tests/test-helper.js b/app/assets/javascripts/discourse/tests/test-helper.js index 6f3b3b60a7a..d24afddcf77 100644 --- a/app/assets/javascripts/discourse/tests/test-helper.js +++ b/app/assets/javascripts/discourse/tests/test-helper.js @@ -1 +1 @@ -// We don't currently use this file, but it is require'd by ember-cli's test bundle +import "discourse/static/markdown-it"; diff --git a/app/assets/javascripts/discourse/tests/unit/lib/build-quote-test.js b/app/assets/javascripts/discourse/tests/unit/lib/build-quote-test.js index 91d39b437f2..c1bc263d606 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/build-quote-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/build-quote-test.js @@ -1,8 +1,8 @@ import { getOwner } from "@ember/application"; import { setupTest } from "ember-qunit"; -import PrettyText from "pretty-text/pretty-text"; import { module, test } from "qunit"; import { buildQuote } from "discourse/lib/quote"; +import DiscourseMarkdownIt from "discourse-markdown-it"; module("Unit | Utility | build-quote", function (hooks) { setupTest(hooks); @@ -60,7 +60,7 @@ module("Unit | Utility | build-quote", function (hooks) { test("quoting a quote", function (assert) { const store = getOwner(this).lookup("service:store"); const post = store.createRecord("post", { - cooked: new PrettyText().cook( + cooked: DiscourseMarkdownIt.minimal().cook( '[quote="sam, post:1, topic:1, full:true"]\nhello\n[/quote]\n*Test*' ), username: "eviltrout", diff --git a/app/assets/javascripts/discourse/tests/unit/lib/parse-bbcode-tag-test.js b/app/assets/javascripts/discourse/tests/unit/lib/parse-bbcode-tag-test.js index 04a28fb638f..2cb83fce35a 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/parse-bbcode-tag-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/parse-bbcode-tag-test.js @@ -1,6 +1,6 @@ import { setupTest } from "ember-qunit"; -import { parseBBCodeTag } from "pretty-text/engines/discourse-markdown/bbcode-block"; import { module, test } from "qunit"; +import { parseBBCodeTag } from "discourse-markdown-it/features/bbcode-block"; module("Unit | Utility | parseBBCodeTag", function (hooks) { setupTest(hooks); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/pretty-text-test.js b/app/assets/javascripts/discourse/tests/unit/lib/pretty-text-test.js index 27b301f559b..fc7cb283419 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/pretty-text-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/pretty-text-test.js @@ -1,14 +1,14 @@ import { setupTest } from "ember-qunit"; import { registerEmoji } from "pretty-text/emoji"; import { IMAGE_VERSION as v } from "pretty-text/emoji/version"; -import { extractDataAttribute } from "pretty-text/engines/discourse-markdown-it"; import { applyCachedInlineOnebox, deleteCachedInlineOnebox, } from "pretty-text/inline-oneboxer"; -import PrettyText, { buildOptions } from "pretty-text/pretty-text"; import QUnit, { module, test } from "qunit"; import { deepMerge } from "discourse-common/lib/object"; +import DiscourseMarkdownIt from "discourse-markdown-it"; +import { extractDataAttribute } from "discourse-markdown-it/engine"; const rawOpts = { siteSettings: { @@ -27,10 +27,12 @@ const rawOpts = { getURL: (url) => url, }; -const defaultOpts = buildOptions(rawOpts); +function build(options = rawOpts) { + return DiscourseMarkdownIt.withDefaultFeatures().withOptions(options); +} QUnit.assert.cooked = function (input, expected, message) { - const actual = new PrettyText(defaultOpts).cook(input); + const actual = build().cook(input); this.pushResult({ result: actual === expected.replace(/\/>/g, ">"), actual, @@ -41,7 +43,7 @@ QUnit.assert.cooked = function (input, expected, message) { QUnit.assert.cookedOptions = function (input, opts, expected, message) { const merged = deepMerge({}, rawOpts, opts); - const actual = new PrettyText(buildOptions(merged)).cook(input); + const actual = build(merged).cook(input); this.pushResult({ result: actual === expected, actual, @@ -59,12 +61,12 @@ module("Unit | Utility | pretty-text", function (hooks) { test("buildOptions", function (assert) { assert.ok( - buildOptions({ siteSettings: { enable_emoji: true } }).discourse.features + build({ siteSettings: { enable_emoji: true } }).options.discourse.features .emoji, "emoji enabled" ); assert.ok( - !buildOptions({ siteSettings: { enable_emoji: false } }).discourse + !build({ siteSettings: { enable_emoji: false } }).options.discourse .features.emoji, "emoji disabled" ); @@ -733,7 +735,7 @@ eviltrout

test("Oneboxing", function (assert) { function matches(input, regexp) { - return new PrettyText(defaultOpts).cook(input).match(regexp); + return build().cook(input).match(regexp); } assert.ok( @@ -1338,7 +1340,7 @@ var bar = 'bar'; }); test("quotes with trailing formatting", function (assert) { - const result = new PrettyText(defaultOpts).cook( + const result = build().cook( '[quote="EvilTrout, post:123, topic:456, full:true"]\nhello\n[/quote]\n*Test*' ); assert.strictEqual( diff --git a/app/assets/javascripts/discourse/tests/unit/lib/sanitizer-test.js b/app/assets/javascripts/discourse/tests/unit/lib/sanitizer-test.js index 36f1dfb2cae..04a3935726c 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/sanitizer-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/sanitizer-test.js @@ -1,38 +1,44 @@ import { setupTest } from "ember-qunit"; import AllowLister from "pretty-text/allow-lister"; -import PrettyText, { buildOptions } from "pretty-text/pretty-text"; import { hrefAllowed, sanitize } from "pretty-text/sanitizer"; import { module, test } from "qunit"; +import DiscourseMarkdownIt from "discourse-markdown-it"; + +function build(options) { + return DiscourseMarkdownIt.withDefaultFeatures().withOptions(options); +} module("Unit | Utility | sanitizer", function (hooks) { setupTest(hooks); test("sanitize", function (assert) { - const pt = new PrettyText( - buildOptions({ - siteSettings: { - allowed_iframes: - "https://www.google.com/maps/embed?|https://www.openstreetmap.org/export/embed.html?", - }, - }) - ); + const engine = build({ + siteSettings: { + allowed_iframes: + "https://www.google.com/maps/embed?|https://www.openstreetmap.org/export/embed.html?", + }, + }); const cooked = (input, expected, text) => - assert.strictEqual(pt.cook(input), expected.replace(/\/>/g, ">"), text); + assert.strictEqual( + engine.cook(input), + expected.replace(/\/>/g, ">"), + text + ); assert.strictEqual( - pt.sanitize('bug'), + engine.sanitize('bug'), "bug" ); assert.strictEqual( - pt.sanitize("
"), + engine.sanitize("
"), "
" ); assert.strictEqual( - pt.sanitize("

hello

"), + engine.sanitize("

hello

"), "

hello

" ); - assert.strictEqual(pt.sanitize("<3 <3"), "<3 <3"); - assert.strictEqual(pt.sanitize("<_<"), "<_<"); + assert.strictEqual(engine.sanitize("<3 <3"), "<3 <3"); + assert.strictEqual(engine.sanitize("<_<"), "<_<"); cooked( "hello", @@ -71,10 +77,16 @@ module("Unit | Utility | sanitizer", function (hooks) { "it allows iframe to OpenStreetMap" ); - assert.strictEqual(pt.sanitize(""), "hullo"); - assert.strictEqual(pt.sanitize(""), "press me!"); - assert.strictEqual(pt.sanitize("draw me!"), "draw me!"); - assert.strictEqual(pt.sanitize("hello"), "hello"); + assert.strictEqual(engine.sanitize(""), "hullo"); + assert.strictEqual( + engine.sanitize(""), + "press me!" + ); + assert.strictEqual( + engine.sanitize("draw me!"), + "draw me!" + ); + assert.strictEqual(engine.sanitize("hello"), "hello"); cooked( "[the answer](javascript:alert(42))", @@ -148,62 +160,62 @@ module("Unit | Utility | sanitizer", function (hooks) { }); test("ids on headings", function (assert) { - const pt = new PrettyText(buildOptions({ siteSettings: {} })); + const engine = build({ siteSettings: {} }); assert.strictEqual( - pt.sanitize("

Test Heading

"), + engine.sanitize("

Test Heading

"), "

Test Heading

" ); assert.strictEqual( - pt.sanitize(`

Test Heading

`), + engine.sanitize(`

Test Heading

`), `

Test Heading

` ); assert.strictEqual( - pt.sanitize(`

Test Heading

`), + engine.sanitize(`

Test Heading

`), `

Test Heading

` ); assert.strictEqual( - pt.sanitize(`

Test Heading

`), + engine.sanitize(`

Test Heading

`), `

Test Heading

` ); assert.strictEqual( - pt.sanitize(`

Test Heading

`), + engine.sanitize(`

Test Heading

`), `

Test Heading

` ); assert.strictEqual( - pt.sanitize(`
Test Heading
`), + engine.sanitize(`
Test Heading
`), `
Test Heading
` ); assert.strictEqual( - pt.sanitize(`
Test Heading
`), + engine.sanitize(`
Test Heading
`), `
Test Heading
` ); }); test("autoplay videos must be muted", function (assert) { - let pt = new PrettyText(buildOptions({ siteSettings: {} })); + let engine = build({ siteSettings: {} }); assert.ok( - pt + engine .sanitize( `

Hey