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(""),
+ engine.sanitize(""),
""
);
- 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!");
- assert.strictEqual(pt.sanitize("