markdown: Add helper configuration for mobile.

This refactoring is the first step toward sharing
our markdown code with mobile.  This focuses on
the Zulip layer, not the underlying third party `marked`
library.

In this commit we do a one-time initialization to
wire up the markdown functions, but after further
discussions with Greg, it might make more sense
to just pass in helpers on every use of markdown
(which is generally only once per sent message).
I'll address that in follow-up commits.

Even though it looks like a pretty invasive change,
you will note that we barely needed to modify the
node tests to make this pass.  And we have pretty
decent test coverage here.

All of the places where we used to depend on
other Zulip modules now use helper functions that
any client (e.g. mobile) can configure themselves.
Or course, in the webapp, we configure these from
modules like people/stream_data/hash_util/etc.

Even in places where markdown used to deal directly with
data structures from other modules, we now use functions.
We may revisit this in a future commit, and we might
just pass data directly for certain things.

I decided to keep the helpers data structure completely flat,
so we don't have ugly nested names like
`helpers.emoji.get_emoji_codepoint`.  Because of this,
some of the names aren't 1:1, which I think is fine.

For example, we map `user_groups.is_member_of` to
`is_member_of_user_group`.

It's likely that mobile already has different names
for their versions of these functions, so trying for
fake consistency would only help the webapp.  In some
cases, I think the webapp functions have names that
could be improved, but we can clean that up in future
commits, and since the names aren't coupled to markdown
itself (i.e. only the config), we will be less
constrained.

It's worth noting that `marked` has an `options`
data structure that it uses for configuration, but
I didn't piggyback onto it, since the `marked`
options are more at the lexing/parsing layer vs.
the app-data layer stuff that our helpers mostly
help with.

Hopefully it's obvious why I just put helpers in
the top-level namespace for the module rather than
passing it around through multiple layers of the
parser.

There were a couple places in markdown where we
were doing awkward `hasOwnProperty` checks for
emoji-related stuff.  Now we use the Python
principle of ask-forgiveness-not-permission and
just handle the getters returning falsy data.  (It
should be `undefined`, but any falsy value is
unworkable in the places I changed, so I use
the simpler, less brittle form.)

We also break our direct dependency on
`emoji_codes.json` (with some help from the
prior commit).

In one place I rename streamName to stream_name,
fixing up an ancient naming violation that goes
way back to before this code was even extracted
away from echo.js.  I didn't bother to split this
out into a separate commit, since 2 of the 4
lines would be immediately re-modified in the
subsequent commit.

Note that we still depend on `fenced_code`
via the global namespace, instead of simply
requiring it directly or injecting it.  The
reason I'm postponing any action there is that
we'll have to change things once we move
markdown into a shared library.  (The most
likely outcome is that we'll rename/move both files
at the same time and fix the namespace/require
details as part of that commit.)

Also the markdown code still relies on `_` being
available in the global namespace.  We aren't
quite ready to share code with mobile yet, but the
underscore dependency should not be problematic,
since mobile already uses underscore to use the
webapp's shared typing_status module.
This commit is contained in:
Steve Howell
2020-02-13 12:54:11 +00:00
committed by Tim Abbott
parent e8de4abb0e
commit b55d2bc256
5 changed files with 86 additions and 20 deletions

View File

@@ -10,6 +10,7 @@ zrequire('user_groups');
const emoji_codes = zrequire('emoji_codes', 'generated/emoji/emoji_codes.json'); const emoji_codes = zrequire('emoji_codes', 'generated/emoji/emoji_codes.json');
zrequire('emoji'); zrequire('emoji');
zrequire('message_store'); zrequire('message_store');
const markdown_config = zrequire('markdown_config');
zrequire('markdown'); zrequire('markdown');
set_global('location', { set_global('location', {
@@ -190,7 +191,10 @@ run_test('fenced_block_defaults', () => {
assert.equal(output, expected); assert.equal(output, expected);
}); });
markdown.initialize(page_params.realm_filters); markdown.initialize(
page_params.realm_filters,
markdown_config.get_helpers()
);
const bugdown_data = global.read_fixture_data('markdown_test_cases.json'); const bugdown_data = global.read_fixture_data('markdown_test_cases.json');

View File

@@ -74,6 +74,7 @@ util.is_mobile = () => false;
global.stub_templates(() => 'some-html'); global.stub_templates(() => 'some-html');
ui.get_scroll_element = element => element; ui.get_scroll_element = element => element;
zrequire('hash_util');
zrequire('echo'); zrequire('echo');
zrequire('colorspace'); zrequire('colorspace');
zrequire('stream_color'); zrequire('stream_color');

View File

@@ -6,6 +6,12 @@
// Docs: https://zulip.readthedocs.io/en/latest/subsystems/markdown.html // Docs: https://zulip.readthedocs.io/en/latest/subsystems/markdown.html
// This should be initialized with a struct
// similar to markdown_config.get_helpers().
// See the call to markdown.initialize() in ui_init
// for example usage.
let helpers;
const realm_filter_map = new Map(); const realm_filter_map = new Map();
let realm_filter_list = []; let realm_filter_list = [];
@@ -58,7 +64,7 @@ exports.translate_emoticons_to_names = (text) => {
return match; return match;
}; };
for (const translation of emoji.get_emoticon_translations()) { for (const translation of helpers.get_emoticon_translations()) {
// We can't pass replacement_text directly into // We can't pass replacement_text directly into
// emoticon_replacer, because emoticon_replacer is // emoticon_replacer, because emoticon_replacer is
// a callback for `replace()`. Instead we just mutate // a callback for `replace()`. Instead we just mutate
@@ -123,7 +129,7 @@ exports.apply_markdown = function (message) {
full_name = match[1]; full_name = match[1];
user_id = parseInt(match[2], 10); user_id = parseInt(match[2], 10);
if (!people.is_valid_full_name_and_user_id(full_name, user_id)) { if (!helpers.is_valid_full_name_and_user_id(full_name, user_id)) {
user_id = undefined; user_id = undefined;
full_name = undefined; full_name = undefined;
} }
@@ -132,7 +138,7 @@ exports.apply_markdown = function (message) {
if (user_id === undefined) { if (user_id === undefined) {
// Handle normal syntax // Handle normal syntax
full_name = mention; full_name = mention;
user_id = people.get_user_id_from_name(full_name); user_id = helpers.get_user_id_from_name(full_name);
} }
if (user_id === undefined) { if (user_id === undefined) {
@@ -147,7 +153,7 @@ exports.apply_markdown = function (message) {
// flags on the message itself that get used by the message // flags on the message itself that get used by the message
// view code and possibly our filtering code. // view code and possibly our filtering code.
if (people.my_current_user_id() === user_id && !silently) { if (helpers.my_user_id() === user_id && !silently) {
message.mentioned = true; message.mentioned = true;
message.mentioned_me_directly = true; message.mentioned_me_directly = true;
} }
@@ -160,13 +166,13 @@ exports.apply_markdown = function (message) {
// If I mention "@aLiCe sMITH", I still want "Alice Smith" to // If I mention "@aLiCe sMITH", I still want "Alice Smith" to
// show in the pill. // show in the pill.
const actual_full_name = people.get_actual_name_from_user_id(user_id); const actual_full_name = helpers.get_actual_name_from_user_id(user_id);
return str + _.escape(actual_full_name) + '</span>'; return str + _.escape(actual_full_name) + '</span>';
}, },
groupMentionHandler: function (name) { groupMentionHandler: function (name) {
const group = user_groups.get_user_group_from_name(name); const group = helpers.get_user_group_from_name(name);
if (group !== undefined) { if (group !== undefined) {
if (user_groups.is_member_of(group.id, people.my_current_user_id())) { if (helpers.is_member_of_user_group(group.id, helpers.my_user_id())) {
message.mentioned = true; message.mentioned = true;
} }
return '<span class="user-group-mention" data-user-group-id="' + group.id + '">' + return '<span class="user-group-mention" data-user-group-id="' + group.id + '">' +
@@ -247,12 +253,14 @@ function make_emoji_span(codepoint, title, alt_text) {
function handleUnicodeEmoji(unicode_emoji) { function handleUnicodeEmoji(unicode_emoji) {
const codepoint = unicode_emoji.codePointAt(0).toString(16); const codepoint = unicode_emoji.codePointAt(0).toString(16);
const emoji_name = emoji.get_emoji_name(codepoint); const emoji_name = helpers.get_emoji_name(codepoint);
if (emoji_name) { if (emoji_name) {
const alt_text = ':' + emoji_name + ':'; const alt_text = ':' + emoji_name + ':';
const title = emoji_name.split("_").join(" "); const title = emoji_name.split("_").join(" ");
return make_emoji_span(codepoint, title, alt_text); return make_emoji_span(codepoint, title, alt_text);
} }
return unicode_emoji; return unicode_emoji;
} }
@@ -267,7 +275,7 @@ function handleEmoji(emoji_name) {
// Otherwise we'll look at unicode emoji to render with an emoji // Otherwise we'll look at unicode emoji to render with an emoji
// span using the spritesheet; and if it isn't one of those // span using the spritesheet; and if it isn't one of those
// either, we pass through the plain text syntax unmodified. // either, we pass through the plain text syntax unmodified.
const emoji_url = emoji.get_realm_emoji_url(emoji_name); const emoji_url = helpers.get_realm_emoji_url(emoji_name);
if (emoji_url) { if (emoji_url) {
return '<img alt="' + alt_text + '"' + return '<img alt="' + alt_text + '"' +
@@ -275,7 +283,7 @@ function handleEmoji(emoji_name) {
' title="' + title + '">'; ' title="' + title + '">';
} }
const codepoint = emoji.get_emoji_codepoint(emoji_name); const codepoint = helpers.get_emoji_codepoint(emoji_name);
if (codepoint) { if (codepoint) {
return make_emoji_span(codepoint, title, alt_text); return make_emoji_span(codepoint, title, alt_text);
} }
@@ -289,23 +297,23 @@ function handleAvatar(email) {
' title="' + email + '">'; ' title="' + email + '">';
} }
function handleStream(streamName) { function handleStream(stream_name) {
const stream = stream_data.get_sub(streamName); const stream = helpers.get_stream_by_name(stream_name);
if (stream === undefined) { if (stream === undefined) {
return; return;
} }
const href = hash_util.by_stream_uri(stream.stream_id); const href = helpers.stream_hash(stream.stream_id);
return '<a class="stream" data-stream-id="' + stream.stream_id + '" ' + return '<a class="stream" data-stream-id="' + stream.stream_id + '" ' +
'href="/' + href + '"' + 'href="/' + href + '"' +
'>' + '#' + _.escape(stream.name) + '</a>'; '>' + '#' + _.escape(stream.name) + '</a>';
} }
function handleStreamTopic(streamName, topic) { function handleStreamTopic(stream_name, topic) {
const stream = stream_data.get_sub(streamName); const stream = helpers.get_stream_by_name(stream_name);
if (stream === undefined || !topic) { if (stream === undefined || !topic) {
return; return;
} }
const href = hash_util.by_stream_topic_uri(stream.stream_id, topic); const href = helpers.stream_topic_hash(stream.stream_id, topic);
const text = '#' + _.escape(stream.name) + ' > ' + _.escape(topic); const text = '#' + _.escape(stream.name) + ' > ' + _.escape(topic);
return '<a class="stream-topic" data-stream-id="' + stream.stream_id + '" ' + return '<a class="stream-topic" data-stream-id="' + stream.stream_id + '" ' +
'href="/' + href + '"' + '>' + text + '</a>'; 'href="/' + href + '"' + '>' + text + '</a>';
@@ -416,7 +424,8 @@ exports.update_realm_filter_rules = function (realm_filters) {
marked.InlineLexer.rules.zulip.realm_filters = marked_rules; marked.InlineLexer.rules.zulip.realm_filters = marked_rules;
}; };
exports.initialize = function (realm_filters) { exports.initialize = function (realm_filters, helper_config) {
helpers = helper_config;
function disable_markdown_regex(rules, name) { function disable_markdown_regex(rules, name) {
rules[name] = {exec: function () { rules[name] = {exec: function () {
@@ -456,7 +465,7 @@ exports.initialize = function (realm_filters) {
} }
function preprocess_translate_emoticons(src) { function preprocess_translate_emoticons(src) {
if (!page_params.translate_emoticons) { if (!helpers.should_translate_emoticons()) {
return src; return src;
} }

View File

@@ -0,0 +1,47 @@
/*
This config is in a separate file for partly
tactical reasons. We want the webapp to
configure this one way, but we don't want to
share this code with mobile.
I also wanted to make some diffs clear before
doing any major file moves.
Also, I want the unit tests for markdown to
be able to reuse this code easily (and therefore
didn't just put this in ui_init.js).
Once the first steps of making markdown be a
shared library are complete, we may tweak
the file organization a bit.
Most functions here that are looking up data
follow the convention of returning `undefined`
when the lookups fail.
*/
exports.get_helpers = () => ({
// user stuff
get_actual_name_from_user_id: people.get_actual_name_from_user_id,
get_user_id_from_name: people.get_user_id_from_name,
is_valid_full_name_and_user_id: people.is_valid_full_name_and_user_id,
my_user_id: people.my_current_user_id,
// user groups
get_user_group_from_name: user_groups.get_user_group_from_name,
is_member_of_user_group: user_groups.is_member_of,
// emojis
get_realm_emoji_url: emoji.get_realm_emoji_url,
get_emoji_name: emoji.get_emoji_name,
get_emoji_codepoint: emoji.get_emoji_codepoint,
get_emoticon_translations: emoji.get_emoticon_translations,
// stream hashes
get_stream_by_name: stream_data.get_sub,
stream_hash: hash_util.by_stream_uri,
stream_topic_hash: hash_util.by_stream_topic_uri,
// settings
should_translate_emoticons: () => page_params.translate_emoticons,
});

View File

@@ -1,3 +1,5 @@
const markdown_config = require('./markdown_config');
// This is where most of our initialization takes place. // This is where most of our initialization takes place.
// TODO: Organize it a lot better. In particular, move bigger // TODO: Organize it a lot better. In particular, move bigger
// functions to other modules. // functions to other modules.
@@ -326,7 +328,10 @@ exports.initialize_everything = function () {
message_fetch.initialize(); message_fetch.initialize();
message_scroll.initialize(); message_scroll.initialize();
emoji.initialize(); emoji.initialize();
markdown.initialize(page_params.realm_filters); // Must happen after emoji.initialize() markdown.initialize(
page_params.realm_filters,
markdown_config.get_helpers()
);
compose.initialize(); compose.initialize();
composebox_typeahead.initialize(); // Must happen after compose.initialize() composebox_typeahead.initialize(); // Must happen after compose.initialize()
search.initialize(); search.initialize();