Support show last change user with profile and support YAML config inside the note with robots, lang, dir, breaks options

This commit is contained in:
Wu Cheng-Han 2016-01-12 08:01:42 -06:00
parent 1672df3dce
commit 2ecec3b59a
18 changed files with 546 additions and 167 deletions

View file

@ -29,6 +29,7 @@
"handlebars": "~4.0.5", "handlebars": "~4.0.5",
"js-url": "~2.0.2", "js-url": "~2.0.2",
"socket.io-client": "~1.3.7", "socket.io-client": "~1.3.7",
"viz.js": "~1.3.0" "viz.js": "~1.3.0",
"js-yaml": "~3.4.6"
} }
} }

View file

@ -26,6 +26,10 @@ var model = mongoose.model('note', {
type: String, type: String,
enum: permissionTypes enum: permissionTypes
}, },
lastchangeuser: {
type: Schema.Types.ObjectId,
ref: 'user'
},
viewcount: { viewcount: {
type: Number, type: Number,
default: 0 default: 0
@ -45,7 +49,8 @@ var note = {
getNoteTitle: getNoteTitle, getNoteTitle: getNoteTitle,
generateWebTitle: generateWebTitle, generateWebTitle: generateWebTitle,
increaseViewCount: increaseViewCount, increaseViewCount: increaseViewCount,
updatePermission: updatePermission updatePermission: updatePermission,
updateLastChangeUser: updateLastChangeUser
}; };
function checkNoteIdValid(noteId) { function checkNoteIdValid(noteId) {
@ -198,4 +203,18 @@ function updatePermission(note, permission, callback) {
}); });
} }
function updateLastChangeUser(note, lastchangeuser, callback) {
note.lastchangeuser = lastchangeuser;
note.updated = Date.now();
note.save(function (err) {
if (err) {
logger.error('update note lastchangeuser failed: ' + err);
callback(err, null);
} else {
logger.info("update note lastchangeuser success: " + note.id);
callback(null, note);
};
});
}
module.exports = note; module.exports = note;

View file

@ -9,7 +9,6 @@ var shortId = require('shortid');
var randomcolor = require("randomcolor"); var randomcolor = require("randomcolor");
var Chance = require('chance'), var Chance = require('chance'),
chance = new Chance(); chance = new Chance();
var md5 = require("blueimp-md5").md5;
var moment = require('moment'); var moment = require('moment');
//core //core
@ -68,7 +67,9 @@ function secure(socket, next) {
function emitCheck(note) { function emitCheck(note) {
var out = { var out = {
updatetime: note.updatetime updatetime: note.updatetime,
lastchangeuser: note.lastchangeuser,
lastchangeuserprofile: note.lastchangeuserprofile
}; };
realtime.io.to(note.id).emit('check', out); realtime.io.to(note.id).emit('check', out);
/* /*
@ -89,18 +90,52 @@ var updater = setInterval(function () {
if (note.server.isDirty) { if (note.server.isDirty) {
if (config.debug) if (config.debug)
logger.info("updater found dirty note: " + key); logger.info("updater found dirty note: " + key);
var body = note.server.document; Note.findNote(note.id, function (err, _note) {
var title = Note.getNoteTitle(body); if (err || !_note) return callback(err, null);
title = LZString.compressToBase64(title); //mongo update
body = LZString.compressToBase64(body); if (note.lastchangeuser && _note.lastchangeuser != note.lastchangeuser) {
db.saveToDB(key, title, body, function (err, result) { var lastchangeuser = note.lastchangeuser;
if (err) return; var lastchangeuserprofile = null;
note.server.isDirty = false; User.findUser(lastchangeuser, function (err, user) {
note.updatetime = Date.now(); if (err) return callback(err, null);
emitCheck(note); if (user && user.profile) {
var profile = JSON.parse(user.profile);
if (profile) {
lastchangeuserprofile = {
name: profile.displayName || profile.username,
photo: User.parsePhotoByProfile(profile)
}
note.lastchangeuser = lastchangeuser;
note.lastchangeuserprofile = lastchangeuserprofile;
Note.updateLastChangeUser(_note, lastchangeuser, function (err, result) {
if (err) return callback(err, null);
});
}
}
});
} else {
note.lastchangeuser = null;
note.lastchangeuserprofile = null;
Note.updateLastChangeUser(_note, null, function (err, result) {
if (err) return callback(err, null);
});
}
//postgres update
var body = note.server.document;
var title = Note.getNoteTitle(body);
title = LZString.compressToBase64(title);
body = LZString.compressToBase64(body);
db.saveToDB(key, title, body, function (err, result) {
if (err) return callback(err, null);
note.server.isDirty = false;
note.updatetime = Date.now();
emitCheck(note);
callback(null, null);
});
}); });
} else {
callback(null, null);
} }
callback();
}, function (err) { }, function (err) {
if (err) return logger.error('updater error', err); if (err) return logger.error('updater error', err);
}); });
@ -121,7 +156,7 @@ var cleaner = setInterval(function () {
disconnectSocketQueue.push(socket); disconnectSocketQueue.push(socket);
disconnect(socket); disconnect(socket);
} }
callback(); callback(null, null);
}, function (err) { }, function (err) {
if (err) return logger.error('cleaner error', err); if (err) return logger.error('cleaner error', err);
}); });
@ -250,7 +285,11 @@ function emitRefresh(socket) {
socket.emit('refresh', { socket.emit('refresh', {
docmaxlength: config.documentmaxlength, docmaxlength: config.documentmaxlength,
owner: note.owner, owner: note.owner,
ownerprofile: note.ownerprofile,
lastchangeuser: note.lastchangeuser,
lastchangeuserprofile: note.lastchangeuserprofile,
permission: note.permission, permission: note.permission,
createtime: note.createtime,
updatetime: note.updatetime updatetime: note.updatetime
}); });
} }
@ -321,11 +360,15 @@ function startConnection(socket) {
isConnectionBusy = false; isConnectionBusy = false;
return logger.error(err); return logger.error(err);
} }
var owner = data.rows[0].owner; var owner = data.rows[0].owner;
var ownerprofile = null;
var permission = "freely"; var permission = "freely";
if (owner && owner != "null") { if (owner && owner != "null") {
permission = "editable"; permission = "editable";
} }
//find or new note
Note.findOrNewNote(notename, permission, function (err, note) { Note.findOrNewNote(notename, permission, function (err, note) {
if (err) { if (err) {
responseError(res, "404", "Not Found", "oops."); responseError(res, "404", "Not Found", "oops.");
@ -333,20 +376,64 @@ function startConnection(socket) {
isConnectionBusy = false; isConnectionBusy = false;
return; return;
} }
var body = LZString.decompressFromBase64(data.rows[0].content); var body = LZString.decompressFromBase64(data.rows[0].content);
//body = LZString.compressToUTF16(body); //body = LZString.compressToUTF16(body);
var createtime = data.rows[0].create_time;
var updatetime = data.rows[0].update_time; var updatetime = data.rows[0].update_time;
var server = new ot.EditorSocketIOServer(body, [], notename, ifMayEdit); var server = new ot.EditorSocketIOServer(body, [], notename, ifMayEdit);
var lastchangeuser = note.lastchangeuser || null;
var lastchangeuserprofile = null;
notes[notename] = { notes[notename] = {
id: notename, id: notename,
owner: owner, owner: owner,
ownerprofile: ownerprofile,
permission: note.permission, permission: note.permission,
lastchangeuser: lastchangeuser,
lastchangeuserprofile: lastchangeuserprofile,
socks: [], socks: [],
users: {}, users: {},
createtime: moment(createtime).valueOf(),
updatetime: moment(updatetime).valueOf(), updatetime: moment(updatetime).valueOf(),
server: server server: server
}; };
finishConnection(socket, notes[notename], users[socket.id]);
if (lastchangeuser) {
//find last change user profile if lastchangeuser exists
User.findUser(lastchangeuser, function (err, user) {
if (!err && user && user.profile) {
var profile = JSON.parse(user.profile);
if (profile) {
lastchangeuserprofile = {
name: profile.displayName || profile.username,
photo: User.parsePhotoByProfile(profile)
}
notes[notename].lastchangeuserprofile = lastchangeuserprofile;
}
}
});
}
if (owner && owner != "null") {
//find owner profile if owner exists
User.findUser(owner, function (err, user) {
if (!err && user && user.profile) {
var profile = JSON.parse(user.profile);
if (profile) {
ownerprofile = {
name: profile.displayName || profile.username,
photo: User.parsePhotoByProfile(profile)
}
notes[notename].ownerprofile = ownerprofile;
}
}
finishConnection(socket, notes[notename], users[socket.id]);
});
} else {
finishConnection(socket, notes[notename], users[socket.id]);
}
}); });
}); });
} else { } else {
@ -433,23 +520,7 @@ function updateUserData(socket, user) {
//retrieve user data from passport //retrieve user data from passport
if (socket.request.user && socket.request.user.logged_in) { if (socket.request.user && socket.request.user.logged_in) {
var profile = JSON.parse(socket.request.user.profile); var profile = JSON.parse(socket.request.user.profile);
var photo = null; user.photo = User.parsePhotoByProfile(profile);
switch (profile.provider) {
case "facebook":
photo = 'https://graph.facebook.com/' + profile.id + '/picture';
break;
case "twitter":
photo = profile.photos[0].value;
break;
case "github":
photo = 'https://avatars.githubusercontent.com/u/' + profile.id + '?s=48';
break;
case "dropbox":
//no image api provided, use gravatar
photo = 'https://www.gravatar.com/avatar/' + md5(profile.emails[0].value);
break;
}
user.photo = photo;
user.name = profile.displayName || profile.username; user.name = profile.displayName || profile.username;
user.userid = socket.request.user._id; user.userid = socket.request.user._id;
user.login = true; user.login = true;
@ -466,19 +537,28 @@ function ifMayEdit(socket, callback) {
var note = notes[notename]; var note = notes[notename];
var mayEdit = true; var mayEdit = true;
switch (note.permission) { switch (note.permission) {
case "freely": case "freely":
//not blocking anyone //not blocking anyone
break; break;
case "editable": case "editable":
//only login user can change //only login user can change
if (!socket.request.user || !socket.request.user.logged_in) if (!socket.request.user || !socket.request.user.logged_in)
mayEdit = false; mayEdit = false;
break; break;
case "locked": case "locked":
//only owner can change //only owner can change
if (note.owner != socket.request.user._id) if (note.owner != socket.request.user._id)
mayEdit = false; mayEdit = false;
break; break;
}
//if user may edit and this note have owner (not anonymous usage)
if (mayEdit && note.owner && note.owner != "null") {
//save for the last change user id
if (socket.request.user && socket.request.user.logged_in) {
note.lastchangeuser = socket.request.user._id;
} else {
note.lastchangeuser = null;
}
} }
callback(mayEdit); callback(mayEdit);
} }

View file

@ -8,6 +8,7 @@ var markdownpdf = require("markdown-pdf");
var LZString = require('lz-string'); var LZString = require('lz-string');
var S = require('string'); var S = require('string');
var shortId = require('shortid'); var shortId = require('shortid');
var metaMarked = require('meta-marked');
//core //core
var config = require("../config.js"); var config = require("../config.js");
@ -15,6 +16,7 @@ var config = require("../config.js");
//others //others
var db = require("./db.js"); var db = require("./db.js");
var Note = require("./note.js"); var Note = require("./note.js");
var User = require("./user.js");
//slides //slides
var md = require('reveal.js/plugin/markdown/markdown'); var md = require('reveal.js/plugin/markdown/markdown');
@ -104,6 +106,13 @@ function responseHackMD(res, noteId) {
responseError(res, "404", "Not Found", "oops."); responseError(res, "404", "Not Found", "oops.");
return; return;
} }
var body = LZString.decompressFromBase64(data.rows[0].content);
var meta = null;
try {
meta = metaMarked(body).meta;
} catch(err) {
//na
}
var title = data.rows[0].title; var title = data.rows[0].title;
var decodedTitle = LZString.decompressFromBase64(title); var decodedTitle = LZString.decompressFromBase64(title);
if (decodedTitle) title = decodedTitle; if (decodedTitle) title = decodedTitle;
@ -116,7 +125,8 @@ function responseHackMD(res, noteId) {
var compiled = ejs.compile(fs.readFileSync(template, 'utf8'), options); var compiled = ejs.compile(fs.readFileSync(template, 'utf8'), options);
var html = compiled({ var html = compiled({
title: title, title: title,
useCDN: config.usecdn useCDN: config.usecdn,
robots: (meta && meta.robots) || false //default allow robots
}); });
var buf = html; var buf = html;
res.writeHead(200, { res.writeHead(200, {
@ -192,34 +202,47 @@ function showPublishNote(req, res, next) {
return; return;
} }
var body = LZString.decompressFromBase64(data.rows[0].content); var body = LZString.decompressFromBase64(data.rows[0].content);
var meta = null;
try {
meta = metaMarked(body).meta;
} catch(err) {
//na
}
var updatetime = data.rows[0].update_time; var updatetime = data.rows[0].update_time;
var text = S(body).escapeHTML().s; var text = S(body).escapeHTML().s;
var title = data.rows[0].title; var title = data.rows[0].title;
var decodedTitle = LZString.decompressFromBase64(title); var decodedTitle = LZString.decompressFromBase64(title);
if (decodedTitle) title = decodedTitle; if (decodedTitle) title = decodedTitle;
title = Note.generateWebTitle(title); title = Note.generateWebTitle(title);
var template = config.prettypath;
var options = {
cache: !config.debug,
filename: template
};
var compiled = ejs.compile(fs.readFileSync(template, 'utf8'), options);
var origin = config.getserverurl(); var origin = config.getserverurl();
var html = compiled({ var data = {
title: title, title: title,
viewcount: note.viewcount, viewcount: note.viewcount,
updatetime: updatetime, updatetime: updatetime,
url: origin, url: origin,
body: text, body: text,
useCDN: config.usecdn useCDN: config.usecdn,
}); lastchangeuserprofile: null,
var buf = html; robots: (meta && meta.robots) || false //default allow robots
res.writeHead(200, { };
'Content-Type': 'text/html; charset=UTF-8', if (note.lastchangeuser) {
'Cache-Control': 'private', //find last change user profile if lastchangeuser exists
'Content-Length': buf.length User.findUser(note.lastchangeuser, function (err, user) {
}); if (!err && user && user.profile) {
res.end(buf); var profile = JSON.parse(user.profile);
if (profile) {
data.lastchangeuserprofile = {
name: profile.displayName || profile.username,
photo: User.parsePhotoByProfile(profile)
}
renderPublish(data, res);
}
}
});
} else {
renderPublish(data, res);
}
}); });
}); });
}); });
@ -228,6 +251,23 @@ function showPublishNote(req, res, next) {
} }
} }
function renderPublish(data, res) {
var template = config.prettypath;
var options = {
cache: !config.debug,
filename: template
};
var compiled = ejs.compile(fs.readFileSync(template, 'utf8'), options);
var html = compiled(data);
var buf = html;
res.writeHead(200, {
'Content-Type': 'text/html; charset=UTF-8',
'Cache-Control': 'private',
'Content-Length': buf.length
});
res.end(buf);
}
function actionPublish(req, res, noteId) { function actionPublish(req, res, noteId) {
db.readFromDB(noteId, function (err, data) { db.readFromDB(noteId, function (err, data) {
if (err) { if (err) {
@ -269,36 +309,6 @@ function actionSlide(req, res, noteId) {
}); });
}); });
} }
//pretty api is deprecated
function actionPretty(req, res, noteId) {
db.readFromDB(noteId, function (err, data) {
if (err) {
responseError(res, "404", "Not Found", "oops.");
return;
}
var body = LZString.decompressFromBase64(data.rows[0].content);
var text = S(body).escapeHTML().s;
var title = data.rows[0].title;
var decodedTitle = LZString.decompressFromBase64(title);
if (decodedTitle) title = decodedTitle;
title = Note.generateWebTitle(title);
var template = config.prettypath;
var compiled = ejs.compile(fs.readFileSync(template, 'utf8'));
var origin = config.getserverurl();
var html = compiled({
title: title,
url: origin,
body: text
});
var buf = html;
res.writeHead(200, {
'Content-Type': 'text/html; charset=UTF-8',
'Cache-Control': 'private',
'Content-Length': buf.length
});
res.end(buf);
});
}
function actionDownload(req, res, noteId) { function actionDownload(req, res, noteId) {
db.readFromDB(noteId, function (err, data) { db.readFromDB(noteId, function (err, data) {
@ -325,6 +335,11 @@ function actionPDF(req, res, noteId) {
return; return;
} }
var body = LZString.decompressFromBase64(data.rows[0].content); var body = LZString.decompressFromBase64(data.rows[0].content);
try {
body = metaMarked(body).markdown;
} catch(err) {
//na
}
var title = Note.getNoteTitle(body); var title = Note.getNoteTitle(body);
if (!fs.existsSync(config.tmppath)) { if (!fs.existsSync(config.tmppath)) {
@ -361,46 +376,46 @@ function noteActions(req, res, next) {
} }
var action = req.params.action; var action = req.params.action;
switch (action) { switch (action) {
case "publish": case "publish":
case "pretty": //pretty deprecated case "pretty": //pretty deprecated
actionPublish(req, res, noteId); actionPublish(req, res, noteId);
break; break;
case "slide": case "slide":
actionSlide(req, res, noteId); actionSlide(req, res, noteId);
break; break;
case "download": case "download":
actionDownload(req, res, noteId); actionDownload(req, res, noteId);
break; break;
case "pdf": case "pdf":
actionPDF(req, res, noteId); actionPDF(req, res, noteId);
break; break;
default: default:
if (noteId != config.featuresnotename) if (noteId != config.featuresnotename)
res.redirect('/' + LZString.compressToBase64(noteId)); res.redirect('/' + LZString.compressToBase64(noteId));
else else
res.redirect('/' + noteId); res.redirect('/' + noteId);
break; break;
} }
} }
function publishNoteActions(req, res, next) { function publishNoteActions(req, res, next) {
var action = req.params.action; var action = req.params.action;
switch (action) { switch (action) {
case "edit": case "edit":
var shortid = req.params.shortid; var shortid = req.params.shortid;
if (shortId.isValid(shortid)) { if (shortId.isValid(shortid)) {
Note.findNote(shortid, function (err, note) { Note.findNote(shortid, function (err, note) {
if (err || !note) { if (err || !note) {
responseError(res, "404", "Not Found", "oops."); responseError(res, "404", "Not Found", "oops.");
return; return;
} }
if (note.id != config.featuresnotename) if (note.id != config.featuresnotename)
res.redirect('/' + LZString.compressToBase64(note.id)); res.redirect('/' + LZString.compressToBase64(note.id));
else else
res.redirect('/' + note.id); res.redirect('/' + note.id);
}); });
} }
break; break;
} }
} }
@ -424,6 +439,11 @@ function showPublishSlide(req, res, next) {
return; return;
} }
var body = LZString.decompressFromBase64(data.rows[0].content); var body = LZString.decompressFromBase64(data.rows[0].content);
try {
body = metaMarked(body).markdown;
} catch(err) {
//na
}
var title = data.rows[0].title; var title = data.rows[0].title;
var decodedTitle = LZString.decompressFromBase64(title); var decodedTitle = LZString.decompressFromBase64(title);
if (decodedTitle) title = decodedTitle; if (decodedTitle) title = decodedTitle;

View file

@ -1,6 +1,7 @@
//user //user
//external modules //external modules
var mongoose = require('mongoose'); var mongoose = require('mongoose');
var md5 = require("blueimp-md5").md5;
//core //core
var config = require("../config.js"); var config = require("../config.js");
@ -20,9 +21,30 @@ var user = {
findUser: findUser, findUser: findUser,
newUser: newUser, newUser: newUser,
findOrNewUser: findOrNewUser, findOrNewUser: findOrNewUser,
getUserCount: getUserCount getUserCount: getUserCount,
parsePhotoByProfile: parsePhotoByProfile
}; };
function parsePhotoByProfile(profile) {
var photo = null;
switch (profile.provider) {
case "facebook":
photo = 'https://graph.facebook.com/' + profile.id + '/picture';
break;
case "twitter":
photo = profile.photos[0].value;
break;
case "github":
photo = 'https://avatars.githubusercontent.com/u/' + profile.id + '?s=48';
break;
case "dropbox":
//no image api provided, use gravatar
photo = 'https://www.gravatar.com/avatar/' + md5(profile.emails[0].value);
break;
}
return photo;
}
function getUserCount(callback) { function getUserCount(callback) {
model.count(function(err, count){ model.count(function(err, count){
if(err) callback(err, null); if(err) callback(err, null);
@ -31,9 +53,13 @@ function getUserCount(callback) {
} }
function findUser(id, callback) { function findUser(id, callback) {
model.findOne({ var rule = {};
id: id var checkForHexRegExp = new RegExp("^[0-9a-fA-F]{24}$");
}, function (err, user) { if (checkForHexRegExp.test(id))
rule._id = id;
else
rule.id = id;
model.findOne(rule, function (err, user) {
if (err) { if (err) {
logger.error('find user failed: ' + err); logger.error('find user failed: ' + err);
callback(err, null); callback(err, null);

View file

@ -28,6 +28,7 @@
"lz-string": "1.4.4", "lz-string": "1.4.4",
"markdown-pdf": "^6.0.0", "markdown-pdf": "^6.0.0",
"marked": "^0.3.5", "marked": "^0.3.5",
"meta-marked": "^0.4.0",
"method-override": "^2.3.5", "method-override": "^2.3.5",
"moment": "^2.10.6", "moment": "^2.10.6",
"mongoose": "^4.3.1", "mongoose": "^4.3.1",

View file

@ -56,6 +56,7 @@ h6:hover .header-link {
.header-link { .header-link {
position: relative; position: relative;
left: 0.5em; left: 0.5em;
right: 0.5em;
opacity: 0; opacity: 0;
font-size: 0.8em; font-size: 0.8em;
-webkit-transition: opacity 0.2s ease-in-out 0.1s; -webkit-transition: opacity 0.2s ease-in-out 0.1s;
@ -114,6 +115,12 @@ h6:hover .header-link {
width: 25vw; width: 25vw;
max-height: 65vh; max-height: 65vh;
overflow: auto; overflow: auto;
text-align: inherit;
}
.ui-toc-dropdown[dir='rtl'] .nav {
padding-right: 0;
letter-spacing: 0.0029em;
} }
.ui-toc-dropdown a { .ui-toc-dropdown a {
@ -138,6 +145,12 @@ h6:hover .header-link {
border-left: 1px solid black; border-left: 1px solid black;
} }
.ui-toc-dropdown[dir='rtl'] .nav>li>a:focus,.ui-toc-dropdown[dir='rtl'] .nav>li>a:hover {
padding-right: 19px;
border-left: none;
border-right: 1px solid black;
}
.ui-toc-dropdown .nav>.active:focus>a,.ui-toc-dropdown .nav>.active:hover>a,.ui-toc-dropdown .nav>.active>a { .ui-toc-dropdown .nav>.active:focus>a,.ui-toc-dropdown .nav>.active:hover>a,.ui-toc-dropdown .nav>.active>a {
padding-left: 18px; padding-left: 18px;
font-weight: 700; font-weight: 700;
@ -146,6 +159,12 @@ h6:hover .header-link {
border-left: 2px solid black; border-left: 2px solid black;
} }
.ui-toc-dropdown[dir='rtl'] .nav>.active:focus>a,.ui-toc-dropdown[dir='rtl'] .nav>.active:hover>a,.ui-toc-dropdown[dir='rtl'] .nav>.active>a {
padding-right: 18px;
border-left: none;
border-right: 2px solid black;
}
.ui-toc-dropdown .nav .nav { .ui-toc-dropdown .nav .nav {
display: none; display: none;
padding-bottom: 10px; padding-bottom: 10px;
@ -163,6 +182,10 @@ h6:hover .header-link {
font-weight: 400; font-weight: 400;
} }
.ui-toc-dropdown[dir='rtl'] .nav .nav>li>a {
padding-right: 30px;
}
.ui-toc-dropdown .nav .nav>li>ul>li>a { .ui-toc-dropdown .nav .nav>li>ul>li>a {
padding-top: 1px; padding-top: 1px;
padding-bottom: 1px; padding-bottom: 1px;
@ -171,24 +194,44 @@ h6:hover .header-link {
font-weight: 400; font-weight: 400;
} }
.ui-toc-dropdown[dir='rtl'] .nav .nav>li>ul>li>a {
padding-right: 40px;
}
.ui-toc-dropdown .nav .nav>li>a:focus,.ui-toc-dropdown .nav .nav>li>a:hover { .ui-toc-dropdown .nav .nav>li>a:focus,.ui-toc-dropdown .nav .nav>li>a:hover {
padding-left: 29px; padding-left: 29px;
} }
.ui-toc-dropdown[dir='rtl'] .nav .nav>li>a:focus,.ui-toc-dropdown[dir='rtl'] .nav .nav>li>a:hover {
padding-right: 29px;
}
.ui-toc-dropdown .nav .nav>li>ul>li>a:focus,.ui-toc-dropdown .nav .nav>li>ul>li>a:hover { .ui-toc-dropdown .nav .nav>li>ul>li>a:focus,.ui-toc-dropdown .nav .nav>li>ul>li>a:hover {
padding-left: 39px; padding-left: 39px;
} }
.ui-toc-dropdown[dir='rtl'] .nav .nav>li>ul>li>a:focus,.ui-toc-dropdown[dir='rtl'] .nav .nav>li>ul>li>a:hover {
padding-right: 39px;
}
.ui-toc-dropdown .nav .nav>.active:focus>a,.ui-toc-dropdown .nav .nav>.active:hover>a,.ui-toc-dropdown .nav .nav>.active>a { .ui-toc-dropdown .nav .nav>.active:focus>a,.ui-toc-dropdown .nav .nav>.active:hover>a,.ui-toc-dropdown .nav .nav>.active>a {
padding-left: 28px; padding-left: 28px;
font-weight: 500; font-weight: 500;
} }
.ui-toc-dropdown[dir='rtl'] .nav .nav>.active:focus>a,.ui-toc-dropdown[dir='rtl'] .nav .nav>.active:hover>a,.ui-toc-dropdown[dir='rtl'] .nav .nav>.active>a {
padding-right: 28px;
}
.ui-toc-dropdown .nav .nav>.active>.nav>.active:focus>a,.ui-toc-dropdown .nav .nav>.active>.nav>.active:hover>a,.ui-toc-dropdown .nav .nav>.active>.nav>.active>a { .ui-toc-dropdown .nav .nav>.active>.nav>.active:focus>a,.ui-toc-dropdown .nav .nav>.active>.nav>.active:hover>a,.ui-toc-dropdown .nav .nav>.active>.nav>.active>a {
padding-left: 38px; padding-left: 38px;
font-weight: 500; font-weight: 500;
} }
.ui-toc-dropdown[dir='rtl'] .nav .nav>.active>.nav>.active:focus>a,.ui-toc-dropdown[dir='rtl'] .nav .nav>.active>.nav>.active:hover>a,.ui-toc-dropdown[dir='rtl'] .nav .nav>.active>.nav>.active>a {
padding-right: 38px;
}
.ui-affix-toc { .ui-affix-toc {
position: fixed; position: fixed;
top: 0; top: 0;
@ -216,6 +259,26 @@ h6:hover .header-link {
margin-top: 0; margin-top: 0;
} }
.ui-user-icon {
width: 20px;
height: 20px;
display: block;
border-radius: 3px;
margin-top: 2px;
margin-bottom: 2px;
margin-right: 5px;
background-position: center center;
background-repeat: no-repeat;
background-size: contain;
}
.ui-user-icon.small {
width: 18px;
height: 18px;
display: inline-block;
vertical-align: middle;
margin: 0 0 0.2em 0;
}
small span { small span {
line-height: 22px; line-height: 22px;
} }

File diff suppressed because one or more lines are too long

View file

@ -156,18 +156,7 @@ body {
.ui-user-name { .ui-user-name {
margin-top: 2px; margin-top: 2px;
} }
.ui-user-icon {
width: 20px;
height: 20px;
display: block;
border-radius: 3px;
margin-top: 2px;
margin-bottom: 2px;
margin-right: 5px;
background-position: center center;
background-repeat: no-repeat;
background-size: contain;
}
.ui-user-status { .ui-user-status {
margin-top: 5px; margin-top: 5px;
} }
@ -213,7 +202,6 @@ body {
width: 0px; width: 0px;
position: absolute; position: absolute;
border-right: none; border-right: none;
transition: left 0.1s, top 0.1s;
} }
.dropdown-menu.other-cursor { .dropdown-menu.other-cursor {
transition: none; transition: none;
@ -243,6 +231,10 @@ div[contenteditable]:empty:not(:focus):before{
max-height: 80vh; max-height: 80vh;
overflow: auto; overflow: auto;
} }
.dropdown-menu.list.small {
max-height: 40vh;
overflow: auto;
}
.dropdown-menu.list::-webkit-scrollbar { .dropdown-menu.list::-webkit-scrollbar {
display: none; display: none;
} }
@ -279,6 +271,25 @@ div[contenteditable]:empty:not(:focus):before{
display: block; display: block;
} }
.info-label {
width: 36%;
text-align: right;
position: relative;
display: inline-block;
margin-right: 6px;
}
.popover {
width: 100%;
font-family: inherit !important;
line-height: 25px;
}
.text-ellipsis {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.cm-trailing-space-a:before, .cm-trailing-space-a:before,
.cm-trailing-space-b:before, .cm-trailing-space-b:before,
.cm-trailing-space-new-line:before { .cm-trailing-space-new-line:before {

View file

@ -36,7 +36,6 @@
text-align: right; text-align: right;
position: relative; position: relative;
display: inline-block; display: inline-block;
float: right;
cursor: default; cursor: default;
z-index: 4; z-index: 4;
padding: 0 8px 0 0; padding: 0 8px 0 0;
@ -89,6 +88,18 @@
.markdown-body .flow-chart { .markdown-body .flow-chart {
margin-bottom: 40px; margin-bottom: 40px;
} }
/*fixed style for rtl in pre and code*/
.markdown-body[dir='rtl'] pre {
direction: ltr;
}
.markdown-body[dir='rtl'] code {
direction: ltr;
unicode-bidi: embed;
}
svg { svg {
width: 100%; width: 100%;
max-height: 70vh; max-height: 70vh;

View file

@ -1,15 +1,32 @@
//auto update last change //auto update last change
var lastchangetime = null; var lastchangetime = null;
var lastchangeui = null; var lastchangeui = {
time: $(".ui-lastchange"),
user: $(".ui-lastchangeuser"),
nouser: $(".ui-no-lastchangeuser")
}
function updateLastChange() { function updateLastChange() {
if (lastchangetime && lastchangeui) { if (lastchangetime && lastchangeui) {
lastchangeui.html('&nbsp;<i class="fa fa-clock-o"></i> change ' + moment(lastchangetime).fromNow()); lastchangeui.time.html(moment(lastchangetime).fromNow());
lastchangeui.attr('title', moment(lastchangetime).format('llll')); lastchangeui.time.attr('title', moment(lastchangetime).format('llll'));
} }
} }
setInterval(updateLastChange, 60000); setInterval(updateLastChange, 60000);
function updateLastChangeUser(data) {
if (data.lastchangeuserprofile) {
var icon = lastchangeui.user.children('i');
icon.attr('title', data.lastchangeuserprofile.name).tooltip('fixTitle');
icon.attr('style', 'background-image:url(' + data.lastchangeuserprofile.photo + ')');
lastchangeui.user.show();
lastchangeui.nouser.hide();
} else {
lastchangeui.user.hide();
lastchangeui.nouser.show();
}
}
//get title //get title
function getTitle(view) { function getTitle(view) {
var h1s = view.find("h1"); var h1s = view.find("h1");
@ -48,6 +65,57 @@ function slugifyWithUTF8(text) {
return newText; return newText;
} }
//parse meta
function parseMeta(md, view, toc, tocAffix) {
var robots = null;
var lang = null;
var dir = null;
var breaks = true;
if (md && md.meta) {
var meta = md.meta;
robots = meta.robots;
lang = meta.lang;
dir = meta.dir;
breaks = meta.breaks;
}
//robots meta
var robotsMeta = $('meta[name=robots]');
if (robots) {
if (robotsMeta.length > 0)
robotsMeta.attr('content', robots);
else
$('head').prepend('<meta name="robots" content="' + robots + '">')
}
else
robotsMeta.remove();
//text language
if (lang) {
view.attr('lang', lang);
toc.attr('lang', lang);
tocAffix.attr('lang', lang);
} else {
view.removeAttr('lang');
toc.removeAttr('lang');
tocAffix.removeAttr('lang');
}
//text direction
if (dir) {
view.attr('dir', dir);
toc.attr('dir', dir);
tocAffix.attr('dir', dir);
} else {
view.removeAttr('dir');
toc.removeAttr('dir');
tocAffix.removeAttr('dir');
}
//breaks
if (typeof breaks === 'boolean' && !breaks) {
md.options.breaks = false;
} else {
md.options.breaks = true;
}
}
var viewAjaxCallback = null; var viewAjaxCallback = null;
//regex for extra tags //regex for extra tags
@ -329,7 +397,10 @@ function exportToHTML(view) {
css: css, css: css,
html: src[0].outerHTML, html: src[0].outerHTML,
toc: toc.html(), toc: toc.html(),
'toc-affix': tocAffix.html() 'toc-affix': tocAffix.html(),
robots: (md && md.meta && md.meta.robots) ? '<meta name="robots" content="' + md.meta.robots + '">' : null,
lang: (md && md.meta && md.meta.lang) ? 'lang="' + md.meta.lang + '"' : null,
dir: (md && md.meta && md.meta.dir) ? 'dir="' + md.meta.dir + '"' : null
}; };
var html = template(context); var html = template(context);
// console.log(html); // console.log(html);
@ -737,6 +808,49 @@ var speakerdeckPlugin = new Plugin(
return div[0].outerHTML; return div[0].outerHTML;
} }
); );
//yaml meta, from https://github.com/eugeneware/remarkable-meta
function get(state, line) {
var pos = state.bMarks[line];
var max = state.eMarks[line];
return state.src.substr(pos, max - pos);
}
function meta(state, start, end, silent) {
if (start !== 0 || state.blkIndent !== 0) return false;
if (state.tShift[start] < 0) return false;
if (!get(state, start).match(/^---$/)) return false;
var data = [];
for (var line = start + 1; line < end; line++) {
var str = get(state, line);
if (str.match(/^(\.{3}|-{3})$/)) break;
if (state.tShift[line] < 0) break;
data.push(str);
}
if (line >= end) return false;
try {
md.meta = jsyaml.safeLoad(data.join('\n')) || {};
} catch(err) {
console.error(err);
return false;
}
state.line = line + 1;
return true;
}
function metaPlugin(md) {
md.meta = md.meta || {};
md.block.ruler.before('code', 'meta', meta, {
alt: []
});
}
md.use(metaPlugin);
md.use(youtubePlugin); md.use(youtubePlugin);
md.use(vimeoPlugin); md.use(vimeoPlugin);
md.use(gistPlugin); md.use(gistPlugin);

View file

@ -320,6 +320,8 @@ var ui = {
}, },
infobar: { infobar: {
lastchange: $(".ui-lastchange"), lastchange: $(".ui-lastchange"),
lastchangeuser: $(".ui-lastchangeuser"),
nolastchangeuser: $(".ui-no-lastchangeuser"),
permission: { permission: {
permission: $(".ui-permission"), permission: $(".ui-permission"),
label: $(".ui-permission-label"), label: $(".ui-permission-label"),
@ -387,9 +389,9 @@ function setHaveUnreadChanges(bool) {
function updateTitleReminder() { function updateTitleReminder() {
if (!loaded) return; if (!loaded) return;
if (haveUnreadChanges) { if (haveUnreadChanges) {
document.title = '• ' + renderTitle(ui.area.view); document.title = '• ' + renderTitle(ui.area.markdown);
} else { } else {
document.title = renderTitle(ui.area.view); document.title = renderTitle(ui.area.markdown);
} }
} }
@ -465,6 +467,8 @@ $(document).ready(function () {
upClass: 'navbar-hide', upClass: 'navbar-hide',
downClass: 'navbar-show' downClass: 'navbar-show'
}); });
//tooltip
$('[data-toggle="tooltip"]').tooltip();
}); });
//when page resize //when page resize
$(window).resize(function () { $(window).resize(function () {
@ -1165,8 +1169,8 @@ socket.on('version', function (data) {
}); });
socket.on('check', function (data) { socket.on('check', function (data) {
lastchangetime = data.updatetime; lastchangetime = data.updatetime;
lastchangeui = ui.infobar.lastchange;
updateLastChange(); updateLastChange();
updateLastChangeUser(data);
}); });
socket.on('permission', function (data) { socket.on('permission', function (data) {
updatePermission(data.permission); updatePermission(data.permission);
@ -1182,8 +1186,8 @@ socket.on('refresh', function (data) {
owner = data.owner; owner = data.owner;
updatePermission(data.permission); updatePermission(data.permission);
lastchangetime = data.updatetime; lastchangetime = data.updatetime;
lastchangeui = ui.infobar.lastchange;
updateLastChange(); updateLastChange();
updateLastChangeUser(data);
if (!loaded) { if (!loaded) {
changeMode(currentMode); changeMode(currentMode);
loaded = true; loaded = true;
@ -1884,15 +1888,18 @@ var lastResult = null;
function updateViewInner() { function updateViewInner() {
if (currentMode == modeType.edit || !isDirty) return; if (currentMode == modeType.edit || !isDirty) return;
var value = editor.getValue(); var value = editor.getValue();
md.meta = {};
md.render(value); //only for get meta
parseMeta(md, ui.area.markdown, $('#toc'), $('#toc-affix'));
var result = postProcess(md.render(value)).children().toArray(); var result = postProcess(md.render(value)).children().toArray();
partialUpdate(result, lastResult, ui.area.markdown.children().toArray()); partialUpdate(result, lastResult, ui.area.markdown.children().toArray());
if (result && lastResult && result.length != lastResult.length) if (result && lastResult && result.length != lastResult.length)
updateDataAttrs(result, ui.area.markdown.children().toArray()); updateDataAttrs(result, ui.area.markdown.children().toArray());
lastResult = $(result).clone(); lastResult = $(result).clone();
finishView(ui.area.view); finishView(ui.area.markdown);
autoLinkify(ui.area.view); autoLinkify(ui.area.markdown);
deduplicatedHeaderId(ui.area.view); deduplicatedHeaderId(ui.area.markdown);
renderTOC(ui.area.view); renderTOC(ui.area.markdown);
generateToc('toc'); generateToc('toc');
generateToc('toc-affix'); generateToc('toc-affix');
generateScrollspy(); generateScrollspy();

View file

@ -1,5 +1,8 @@
var markdown = $(".markdown-body"); var markdown = $(".markdown-body");
var text = $('<textarea/>').html(markdown.html()).text(); var text = $('<textarea/>').html(markdown.html()).text();
md.meta = {};
md.render(text); //only for get meta
parseMeta(md, markdown, $('#toc'), $('#toc-affix'));
var result = postProcess(md.render(text)); var result = postProcess(md.render(text));
markdown.html(result.html()); markdown.html(result.html());
$(document.body).show(); $(document.body).show();
@ -10,8 +13,7 @@ renderTOC(markdown);
generateToc('toc'); generateToc('toc');
generateToc('toc-affix'); generateToc('toc-affix');
smoothHashScroll(); smoothHashScroll();
lastchangetime = $('.ui-lastchange').text(); lastchangetime = lastchangeui.time.text();
lastchangeui = $('.ui-lastchange');
updateLastChange(); updateLastChange();
var url = window.location.pathname; var url = window.location.pathname;
$('.ui-edit').attr('href', url + '/edit'); $('.ui-edit').attr('href', url + '/edit');
@ -68,6 +70,8 @@ $(window).resize(function () {
$(document).ready(function () { $(document).ready(function () {
windowResize(); windowResize();
generateScrollspy(); generateScrollspy();
//tooltip
$('[data-toggle="tooltip"]').tooltip();
}); });
function scrollToTop() { function scrollToTop() {

View file

@ -5,7 +5,12 @@
<div class="ui-view-area"> <div class="ui-view-area">
<div class="ui-infobar container-fluid unselectable hidden-print"> <div class="ui-infobar container-fluid unselectable hidden-print">
<small> <small>
<span class="ui-lastchange text-uppercase"></span> <span>
<span class="ui-lastchangeuser">&thinsp;<i class="ui-user-icon small" data-toggle="tooltip" data-placement="right"></i></span>
<span class="ui-no-lastchangeuser">&thinsp;<i class="fa fa-clock-o"></i></span>
&nbsp;<span class="text-uppercase">changed</span>
<span class="ui-lastchange text-uppercase"></span>
</span>
<span class="ui-permission dropdown pull-right"> <span class="ui-permission dropdown pull-right">
<a id="permissionLabel" class="ui-permission-label text-uppercase" data-target="#" data-toggle="dropdown" href="#" role="button" aria-haspopup="true" aria-expanded="false"> <a id="permissionLabel" class="ui-permission-label text-uppercase" data-target="#" data-toggle="dropdown" href="#" role="button" aria-haspopup="true" aria-expanded="false">
</a> </a>

View file

@ -6,6 +6,7 @@
<script src="//cdnjs.cloudflare.com/ajax/libs/gsap/1.18.0/TweenMax.min.js" defer></script> <script src="//cdnjs.cloudflare.com/ajax/libs/gsap/1.18.0/TweenMax.min.js" defer></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/gsap/1.18.0/jquery.gsap.min.js" defer></script> <script src="//cdnjs.cloudflare.com/ajax/libs/gsap/1.18.0/jquery.gsap.min.js" defer></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/socket.io/1.3.7/socket.io.min.js" defer></script> <script src="//cdnjs.cloudflare.com/ajax/libs/socket.io/1.3.7/socket.io.min.js" defer></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/js-yaml/3.4.6/js-yaml.min.js" defer></script>
<% } else { %> <% } else { %>
<script src="/vendor/spin.js/spin.min.js" defer></script> <script src="/vendor/spin.js/spin.min.js" defer></script>
<script src="/vendor/jquery/dist/jquery.min.js"></script> <script src="/vendor/jquery/dist/jquery.min.js"></script>
@ -14,6 +15,7 @@
<script src="/vendor/gsap/src/minified/TweenMax.min.js" defer></script> <script src="/vendor/gsap/src/minified/TweenMax.min.js" defer></script>
<script src="/vendor/gsap/src/minified/jquery.gsap.min.js" defer></script> <script src="/vendor/gsap/src/minified/jquery.gsap.min.js" defer></script>
<script src="/vendor/socket.io-client/socket.io.js" defer></script> <script src="/vendor/socket.io-client/socket.io.js" defer></script>
<script src="/vendor/js-yaml/dist/js-yaml.min.js" defer></script>
<% } %> <% } %>
<script src="/vendor/jquery-ui/jquery-ui.min.js" defer></script> <script src="/vendor/jquery-ui/jquery-ui.min.js" defer></script>
<!--codemirror--> <!--codemirror-->

View file

@ -4,6 +4,9 @@
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black"> <meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<% if(typeof robots !== 'undefined' && robots) { %>
<meta name="robots" content="<%- robots %>">
<% } %>
<title><%- title %></title> <title><%- title %></title>
<link rel="icon" type="image/png" href="/favicon.png"> <link rel="icon" type="image/png" href="/favicon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/apple-touch-icon.png">

View file

@ -9,6 +9,7 @@
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black"> <meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
{{{robots}}}
<title> <title>
{{title}} {{title}}
</title> </title>
@ -42,7 +43,7 @@
</ul> </ul>
</div> </div>
</div> </div>
<div id="toc-affix" class="ui-affix-toc ui-toc-dropdown unselectable hidden-print" data-spy="affix" style="top:17px;display:none;"> <div id="toc-affix" class="ui-affix-toc ui-toc-dropdown unselectable hidden-print" data-spy="affix" style="top:17px;display:none;" {{{lang}}} {{{dir}}}>
{{{toc-affix}}} {{{toc-affix}}}
</div> </div>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.3/jquery.min.js"></script> <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.3/jquery.min.js"></script>

View file

@ -8,6 +8,9 @@
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black"> <meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<% if(typeof robots !== 'undefined' && robots) { %>
<meta name="robots" content="<%- robots %>">
<% } %>
<title><%- title %></title> <title><%- title %></title>
<link rel="icon" type="image/png" href="<%- url %>/favicon.png"> <link rel="icon" type="image/png" href="<%- url %>/favicon.png">
<link rel="apple-touch-icon" href="<%- url %>/apple-touch-icon.png"> <link rel="apple-touch-icon" href="<%- url %>/apple-touch-icon.png">
@ -37,13 +40,19 @@
<body style="display:none;"> <body style="display:none;">
<div class="ui-infobar container-fluid unselectable hidden-print"> <div class="ui-infobar container-fluid unselectable hidden-print">
<small> <small>
<span class="ui-lastchange text-uppercase"><%- updatetime %></span> <span>
<span class="pull-right"><%- viewcount %> views <a href="#" class="ui-edit" title="Edit this note"><i class="fa fa-pencil"></i></a></span> <% if(lastchangeuserprofile) { %>
<span class="ui-lastchangeuser">&thinsp;<i class="ui-user-icon small" style="background-image: url(<%- lastchangeuserprofile.photo %>);" data-toggle="tooltip" data-placement="right" title="<%- lastchangeuserprofile.name %>"></i></span>
<% } else { %>
<span class="ui-no-lastchangeuser">&thinsp;<i class="fa fa-clock-o"></i></span>
<% } %>
&nbsp;<span class="text-uppercase">changed</span>
<span class="ui-lastchange text-uppercase"><%- updatetime %></span>
</span>
<span class="pull-right"><%- viewcount %> views <a href="#" class="ui-edit" title="Edit this note"><i class="fa fa-fw fa-pencil"></i></a></span>
</small> </small>
</div> </div>
<div id="doc" class="container markdown-body"> <div id="doc" class="container markdown-body"><%- body %></div>
<%- body %>
</div>
<div class="ui-toc dropup unselectable hidden-print" style="display:none;"> <div class="ui-toc dropup unselectable hidden-print" style="display:none;">
<div class="pull-right dropdown"> <div class="pull-right dropdown">
<a id="tocLabel" class="ui-toc-label btn btn-default" data-target="#" data-toggle="dropdown" href="#" role="button" aria-haspopup="true" aria-expanded="false" title="Table of content"> <a id="tocLabel" class="ui-toc-label btn btn-default" data-target="#" data-toggle="dropdown" href="#" role="button" aria-haspopup="true" aria-expanded="false" title="Table of content">
@ -60,9 +69,11 @@
<% if(useCDN) { %> <% if(useCDN) { %>
<script src="https://code.jquery.com/jquery-1.11.3.min.js"></script> <script src="https://code.jquery.com/jquery-1.11.3.min.js"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/js/bootstrap.min.js" defer></script> <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/js/bootstrap.min.js" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-yaml/3.4.6/js-yaml.min.js" defer></script>
<% } else { %> <% } else { %>
<script src="<%- url %>/vendor/jquery/dist/jquery.min.js"></script> <script src="<%- url %>/vendor/jquery/dist/jquery.min.js"></script>
<script src="<%- url %>/vendor/bootstrap/dist/js/bootstrap.min.js" defer></script> <script src="<%- url %>/vendor/bootstrap/dist/js/bootstrap.min.js" defer></script>
<script src="<%- url %>/vendor/js-yaml/dist/js-yaml.min.js" defer></script>
<% } %> <% } %>
<script src="<%- url %>/vendor/lz-string.min.js" defer></script> <script src="<%- url %>/vendor/lz-string.min.js" defer></script>
<script src="<%- url %>/vendor/remarkable.min.js" defer></script> <script src="<%- url %>/vendor/remarkable.min.js" defer></script>