mirror of
https://github.com/renbaoshuo/S2OJ.git
synced 2024-11-25 21:58:41 +00:00
refactor(web/html2markdown): use turndown
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
781d1183ba
commit
17fbb2c910
105
web/app/controllers/app/html2markdown.php
Normal file
105
web/app/controllers/app/html2markdown.php
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
<?php requireLib('bootstrap5') ?>
|
||||||
|
|
||||||
|
<?php echoUOJPageHeader(UOJLocale::get('html to markdown')) ?>
|
||||||
|
|
||||||
|
<h1>
|
||||||
|
<?= UOJLocale::get('html to markdown') ?>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#html,
|
||||||
|
#markdown {
|
||||||
|
font-family: Cascadia Mono, Ubuntu Mono, Roboto Mono, Jetbrains Mono, Fira Code, Consolas, '思源黑体 Regular', '思源宋体 Light', '宋体', 'Courier New', monospace;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row row-cols-1 row-cols-md-2">
|
||||||
|
<div class="col">
|
||||||
|
<textarea class="form-control" id="html" placeholder="input html here"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<textarea data-no-autosize readonly class="form-control" id="markdown" placeholder="output markdown here" style="height: 100%"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-transparent text-end">
|
||||||
|
<a href="https://s2oj.github.io/#/user/apps/html2markdown" target="_blank">使用教程</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?= HTML::js_src('/js/turndown.js') ?>
|
||||||
|
<?= HTML::js_src('/js/turndown-plugin-gfm.js') ?>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function mathjaxScriptBlockType(node) {
|
||||||
|
if (node.nodeName !== 'SCRIPT') return null;
|
||||||
|
|
||||||
|
const a = node.getAttribute('type');
|
||||||
|
if (!a || a.indexOf('math/tex') < 0) return null;
|
||||||
|
|
||||||
|
return a.indexOf('display') >= 0 ? 'block' : 'inline';
|
||||||
|
}
|
||||||
|
|
||||||
|
var turndownService = new TurndownService({
|
||||||
|
headingStyle: 'atx',
|
||||||
|
hr: '---',
|
||||||
|
bulletListMarker: '-',
|
||||||
|
codeBlockStyle: 'fenced',
|
||||||
|
fence: '```',
|
||||||
|
emDelimiter: '_',
|
||||||
|
strongDelimiter: '**',
|
||||||
|
linkStyle: 'inlined',
|
||||||
|
linkReferenceStyle: 'full',
|
||||||
|
preformattedCode: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
turndownService.use(turndownPluginGfm.gfm);
|
||||||
|
turndownService.addRule('mathjaxRendered', {
|
||||||
|
filter: function(node) {
|
||||||
|
return node.nodeName === 'SPAN' && node.getAttribute('class') === 'MathJax';
|
||||||
|
},
|
||||||
|
replacement: function(content) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
turndownService.addRule('mathjaxScriptInline', {
|
||||||
|
filter: function(node) {
|
||||||
|
return mathjaxScriptBlockType(node) === 'inline';
|
||||||
|
},
|
||||||
|
|
||||||
|
escapeContent: function() {
|
||||||
|
// We want the raw unescaped content since this is what Katex will need to render
|
||||||
|
// If we escape, it will double the \\ in particular.
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
replacement: function(content, node, options) {
|
||||||
|
return '$' + content + '$';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
turndownService.addRule('mathjaxScriptBlock', {
|
||||||
|
filter: function(node) {
|
||||||
|
return mathjaxScriptBlockType(node) === 'block';
|
||||||
|
},
|
||||||
|
|
||||||
|
escapeContent: function() {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
replacement: function(content, node, options) {
|
||||||
|
return '$$\n' + content + '\n$$';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).ready(function() {
|
||||||
|
$('#html').on('input', function() {
|
||||||
|
$('#markdown').val(turndownService.turndown($('#html').val()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<?php echoUOJPageFooter() ?>
|
490
web/app/controllers/app/image_hosting/index.php
Normal file
490
web/app/controllers/app/image_hosting/index.php
Normal file
@ -0,0 +1,490 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Gregwar\Captcha\PhraseBuilder;
|
||||||
|
|
||||||
|
requirePHPLib('form');
|
||||||
|
requireLib('bootstrap5');
|
||||||
|
|
||||||
|
Auth::check() || redirectToLogin();
|
||||||
|
UOJUser::checkPermission(Auth::user(), 'users.upload_image') || UOJResponse::page403();
|
||||||
|
|
||||||
|
$extra = UOJUser::getExtra($user);
|
||||||
|
$limit = $extra['image_hosting']['total_size_limit'];
|
||||||
|
$used = DB::selectSingle([
|
||||||
|
"select sum(size)",
|
||||||
|
"from users_images",
|
||||||
|
"where", [
|
||||||
|
"uploader" => Auth::id(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$count = DB::selectCount([
|
||||||
|
"select count(*)",
|
||||||
|
"from users_images",
|
||||||
|
"where", [
|
||||||
|
"uploader" => Auth::id(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
function throwError($msg) {
|
||||||
|
dieWithJsonData(['status' => 'error', 'message' => $msg]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowedTypes = [IMAGETYPE_PNG, IMAGETYPE_JPEG, IMAGETYPE_WEBP];
|
||||||
|
if ($_POST['image_upload_file_submit'] == 'submit') {
|
||||||
|
if (!crsf_check()) {
|
||||||
|
throwError('expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($_SESSION['phrase']) || !PhraseBuilder::comparePhrases($_SESSION['phrase'], $_POST['captcha'])) {
|
||||||
|
throwError("bad_captcha");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_FILES["image_upload_file"]["error"] > 0) {
|
||||||
|
throwError($_FILES["image_upload_file"]["error"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_FILES["image_upload_file"]["size"] > 5242880) { // 5 MB
|
||||||
|
throwError('too_large');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($used + $_FILES["image_upload_file"]["size"] > $limit) {
|
||||||
|
throwError('storage_limit_exceeded');
|
||||||
|
}
|
||||||
|
|
||||||
|
$size = getimagesize($_FILES['image_upload_file']['tmp_name']);
|
||||||
|
|
||||||
|
if (!$size || !in_array($size[2], $allowedTypes)) {
|
||||||
|
throwError('not_a_image');
|
||||||
|
}
|
||||||
|
|
||||||
|
list($width, $height, $type) = $size;
|
||||||
|
$hash = hash_file("sha256", $_FILES['image_upload_file']['tmp_name']) . Auth::id();
|
||||||
|
$scale = ceil($width / 600.0);
|
||||||
|
|
||||||
|
$watermark_text = UOJConfig::$data['profile']['oj-name-short'];
|
||||||
|
if (isSuperUser(Auth::user()) && $_POST['watermark'] == 'no_watermark') {
|
||||||
|
$watermark_text = "";
|
||||||
|
$hash .= "__no_watermark";
|
||||||
|
} elseif ($_POST['watermark'] == 'site_shortname_and_username') {
|
||||||
|
$watermark_text .= ' @' . Auth::id();
|
||||||
|
$hash .= "__id";
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing_image = DB::selectFirst("SELECT * FROM users_images WHERE `hash` = '$hash'");
|
||||||
|
|
||||||
|
if ($existing_image) {
|
||||||
|
dieWithJsonData(['status' => 'success', 'path' => $existing_image['path']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$image = new Imagick($_FILES["image_upload_file"]["tmp_name"]);
|
||||||
|
$draw = new ImagickDraw();
|
||||||
|
$draw->setFont(UOJContext::documentRoot() . '/fonts/roboto-mono/RobotoMono-Bold.ttf');
|
||||||
|
$draw->setFontSize($scale * 14);
|
||||||
|
$draw->setGravity(Imagick::GRAVITY_SOUTHEAST);
|
||||||
|
$draw->setFillColor("rgba(100,100,100,0.5)");
|
||||||
|
$image->annotateImage($draw, 15, 10, 0, $watermark_text);
|
||||||
|
$draw->setFillColor("rgba(255,255,255,0.65)");
|
||||||
|
$image->annotateImage($draw, 15 + $scale, 10 + $scale, 0, $watermark_text);
|
||||||
|
$image->setImageFormat('png');
|
||||||
|
$image->writeImage();
|
||||||
|
|
||||||
|
if (($size = filesize($_FILES["image_upload_file"]["tmp_name"])) > 5242880) { // 5 MB
|
||||||
|
throwError('too_large');
|
||||||
|
}
|
||||||
|
|
||||||
|
$filename = uojRandAvaiableFileName('/image_hosting/', 10, '.png');
|
||||||
|
if (!move_uploaded_file($_FILES["image_upload_file"]["tmp_name"], UOJContext::storagePath() . $filename)) {
|
||||||
|
throwError('unknown error');
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::insert([
|
||||||
|
"insert into users_images",
|
||||||
|
DB::bracketed_fields(["path", "uploader", "width", "height", "upload_time", "size", "hash"]),
|
||||||
|
"values", DB::tuple([
|
||||||
|
$filename,
|
||||||
|
Auth::id(),
|
||||||
|
$width,
|
||||||
|
$height,
|
||||||
|
DB::now(),
|
||||||
|
$_FILES["image_upload_file"]["size"],
|
||||||
|
$hash,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
dieWithJsonData(['status' => 'success', 'path' => $filename]);
|
||||||
|
} elseif ($_POST['image_delete_submit'] == 'submit') {
|
||||||
|
crsf_defend();
|
||||||
|
|
||||||
|
$id = $_POST['image_delete_id'];
|
||||||
|
if (!validateUInt($id)) {
|
||||||
|
becomeMsgPage('ID 不合法。<a href="' . UOJContext::requestURI() . '">返回</a>');
|
||||||
|
} else {
|
||||||
|
$result = DB::selectFirst("SELECT * from users_images WHERE id = $id");
|
||||||
|
if (!$result) {
|
||||||
|
becomeMsgPage('图片不存在。<a href="' . UOJContext::requestURI() . '">返回</a>');
|
||||||
|
} else {
|
||||||
|
unlink(UOJContext::storagePath() . $result['path']);
|
||||||
|
DB::delete("DELETE FROM users_images WHERE id = $id");
|
||||||
|
|
||||||
|
header("Location: " . UOJContext::requestURI());
|
||||||
|
die();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
||||||
|
<?php echoUOJPageHeader(UOJLocale::get('image hosting')) ?>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.drop {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-self: center;
|
||||||
|
flex-grow: 0 !important;
|
||||||
|
width: 9em;
|
||||||
|
height: 8.75em;
|
||||||
|
user-select: none;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 0;
|
||||||
|
background: #fafafa;
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop:hover {
|
||||||
|
border-color: #89d1f5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<h1>
|
||||||
|
<?= UOJLocale::get('image hosting') ?>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div class="card card-default">
|
||||||
|
<div class="card-body">
|
||||||
|
<form class="row m-0" id="image-upload-form" method="post" enctype="multipart/form-data">
|
||||||
|
<div class="col-12 col-md-3 col-lg-3 order-1 drop mx-auto mx-md-0" id="image-upload-form-drop">
|
||||||
|
<svg aria-hidden="true" focusable="false" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512" width="56" class="mb-2">
|
||||||
|
<g>
|
||||||
|
<path fill="#3498db" d="M424.49 120.48a12 12 0 0 0-17 0L272 256l-39.51-39.52a12 12 0 0 0-17 0L160 272v48h352V208zM64 336V128H48a48 48 0 0 0-48 48v256a48 48 0 0 0 48 48h384a48 48 0 0 0 48-48v-16H144a80.09 80.09 0 0 1-80-80z"></path>
|
||||||
|
<path fill="#89d1f5" d="M528 32H144a48 48 0 0 0-48 48v256a48 48 0 0 0 48 48h384a48 48 0 0 0 48-48V80a48 48 0 0 0-48-48zM208 80a48 48 0 1 1-48 48 48 48 0 0 1 48-48zm304 240H160v-48l55.52-55.52a12 12 0 0 1 17 0L272 256l135.52-135.52a12 12 0 0 1 17 0L512 208z"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<span id="select-image-text" class="small">点击此处选择图片</span>
|
||||||
|
</div>
|
||||||
|
<input id="image_upload_file" name="image_upload_file" type="file" accept="image/*" style="display: none;" />
|
||||||
|
<div class="modal fade" id="image-upload-modal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h1 class="modal-title fs-5" id="exampleModalLabel">上传图片</h1>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
您确定要上传图片吗?
|
||||||
|
</div>
|
||||||
|
<div class="mb-3" id="modal-file-info"></div>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" id="input-captcha" name="captcha" placeholder="<?= UOJLocale::get('enter verification code') ?>" maxlength="20" />
|
||||||
|
<span class="input-group-text p-0 overflow-hidden rounded-0" style="border-bottom-right-radius: var(--bs-border-radius) !important">
|
||||||
|
<img id="captcha" class="col w-100 h-100" src="/captcha">
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3" id="modal-help-message" style="display: none"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" id="cancel-upload">取消</button>
|
||||||
|
<button type="submit" class="btn btn-primary">确定</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4 col-lg-2 order-2 mt-3 mt-md-0 ms-md-2">
|
||||||
|
<h2 class="h3">水印</h2>
|
||||||
|
<?php if (isSuperUser($myUser)) : ?>
|
||||||
|
<div class="form-check d-inline-block d-md-block me-2">
|
||||||
|
<input class="form-check-input" type="radio" name="watermark" id="watermark-no_watermark" data-value="no_watermark">
|
||||||
|
<label class="form-check-label" for="watermark-no_watermark">
|
||||||
|
无水印
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<?php endif ?>
|
||||||
|
<div class="form-check d-inline-block d-md-block me-2">
|
||||||
|
<input class="form-check-input" type="radio" name="watermark" id="watermark-site_shortname" data-value="site_shortname" checked>
|
||||||
|
<label class="form-check-label" for="watermark-site_shortname">
|
||||||
|
<?= UOJConfig::$data['profile']['oj-name-short'] ?>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check d-inline-block d-md-block me-2">
|
||||||
|
<input class="form-check-input" type="radio" name="watermark" id="watermark-site_shortname_and_username" data-value="site_shortname_and_username">
|
||||||
|
<label class="form-check-label" for="watermark-site_shortname_and_username">
|
||||||
|
<?= UOJConfig::$data['profile']['oj-name-short'] ?> @<?= Auth::id() ?>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col order-3 order-md-4 order-lg-3 mt-3 mt-lg-0 ms-lg-2">
|
||||||
|
<h2 class="h3">上传须知</h2>
|
||||||
|
<ul>
|
||||||
|
<li>上传的图片必须符合法律与社会道德;</li>
|
||||||
|
<li>图床仅供 S2OJ 站内使用,校外用户无法查看;</li>
|
||||||
|
<li>图片上传后会被自动转码为 PNG 格式;</li>
|
||||||
|
<li>在合适的地方插入图片即可引用。</li>
|
||||||
|
</ul>
|
||||||
|
<p class="small">更多信息可以查看 <a href="https://s2oj.github.io/#/user/apps/image_hosting" target="_blank">使用文档</a>。</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-5 col-lg-3 order-4 order-md-3 order-lg-4 mt-3 mt-md-0 ms-md-2">
|
||||||
|
<h2 class="h3">使用统计</h2>
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<span class="small">已用空间</span>
|
||||||
|
<span><?= round($used * 1.0 / 1024 / 1024, 2) ?> MB / <?= round($limit * 1.0 / 1024 / 1024, 2) ?> MB</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<span class="small">上传总数</span>
|
||||||
|
<span><?= $count ?> 张</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="toast-container position-fixed bottom-0 start-0 ms-3 mb-4" style="z-index: 999999">
|
||||||
|
<div id="image-upload-toast" class="toast text-bg-danger align-items-center border-0" role="alert" aria-live="assertive" aria-atomic="true">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="toast-body">
|
||||||
|
文件非法:不是有效的图片格式。
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
var image_upload_modal = new bootstrap.Modal('#image-upload-modal');
|
||||||
|
var image_upload_toast = new bootstrap.Toast('#image-upload-toast', {
|
||||||
|
delay: 2000
|
||||||
|
});
|
||||||
|
var droppedFiles = false;
|
||||||
|
|
||||||
|
function refreshCaptcha() {
|
||||||
|
var timestamp = new Date().getTime();
|
||||||
|
$("#captcha").attr("src", "/captcha" + '?' + timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
$("#captcha").click(function(e) {
|
||||||
|
refreshCaptcha();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#image-upload-form').submit(function(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
var data = new FormData();
|
||||||
|
data.append('_token', "<?= crsf_token() ?>");
|
||||||
|
data.append('image_upload_file_submit', 'submit');
|
||||||
|
data.append('image_upload_file', $('#image_upload_file').prop('files')[0]);
|
||||||
|
data.append('watermark', $('input[name=watermark]:checked', this).data('value'));
|
||||||
|
data.append('captcha', $('#input-captcha').val());
|
||||||
|
|
||||||
|
if ($('#image_upload_file').prop('files')[0].size > 5242880) {
|
||||||
|
$('#modal-help-message').html('图片大小不能超过 5 MB。').show();
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#modal-help-message').html('上传中...').show();
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
method: 'POST',
|
||||||
|
processData: false,
|
||||||
|
contentType: false,
|
||||||
|
data: data,
|
||||||
|
success: function(data) {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
image_upload_modal.hide();
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
if (data.message === 'bad_captcha') {
|
||||||
|
refreshCaptcha();
|
||||||
|
$('#modal-help-message').html('验证码错误。').show();
|
||||||
|
} else if (data.message === 'expired') {
|
||||||
|
$('#modal-help-message').html('页面过期,请刷新重试。').show();
|
||||||
|
} else if (data.message === 'storage_limit_exceeded') {
|
||||||
|
$('#modal-help-message').html('存储超限,请联系管理员提升限制。').show();
|
||||||
|
} else if (data.message === 'not_a_image') {
|
||||||
|
$('#modal-help-message').html('文件格式不受支持。').show();
|
||||||
|
} else if (data.message === 'too_large') {
|
||||||
|
$('#modal-help-message').html('图片大小不能超过 5 MB。').show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function() {
|
||||||
|
$('#modal-help-message').html('上传失败,请刷新页面重试。').addClass('text-danger').show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
$('#image-upload-form-drop').click(function() {
|
||||||
|
$('#image_upload_file').click();
|
||||||
|
});
|
||||||
|
$('#image-upload-form-drop').on('drag dragstart dragend dragover dragenter dragleave drop', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}).on('dragover dragenter', function() {
|
||||||
|
$('#select-image-text').html('松开以上传');
|
||||||
|
}).on('dragleave dragend drop', function() {
|
||||||
|
$('#select-image-text').html('点击此处选择图片');
|
||||||
|
}).on('drop', function(e) {
|
||||||
|
$('#image_upload_file').prop('files', e.originalEvent.dataTransfer.files);
|
||||||
|
$('#image_upload_file').trigger('change');
|
||||||
|
});
|
||||||
|
$(document).on('paste', function(e) {
|
||||||
|
if (e.originalEvent.clipboardData) {
|
||||||
|
$('#image_upload_file').prop('files', e.originalEvent.clipboardData.files);
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#image_upload_file').trigger('change');
|
||||||
|
});
|
||||||
|
$('#image-upload-modal').on('hide.bs.modal', function() {
|
||||||
|
$('#image-upload-form').trigger('reset');
|
||||||
|
});
|
||||||
|
$('#image_upload_file').change(function() {
|
||||||
|
var items = $(this).prop('files');
|
||||||
|
var file = null;
|
||||||
|
|
||||||
|
if (items && items.length) {
|
||||||
|
for (var i = 0; i < items.length; i++) {
|
||||||
|
if (items[i].type.indexOf('image') !== -1) {
|
||||||
|
file = items[i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
refreshCaptcha();
|
||||||
|
var watermark_type = $('input[name=watermark]:checked', '#image-upload-form').data('value');
|
||||||
|
var html = '';
|
||||||
|
|
||||||
|
html += '<p><img src="' + URL.createObjectURL(file) + '" height="150" style="object-fit: contain"></p>';
|
||||||
|
html += '<p class="small">大小:<b>' + (file.size / 1024).toFixed(2) + '</b> KB。';
|
||||||
|
|
||||||
|
if (watermark_type === 'no_watermark') {
|
||||||
|
html += '不添加水印。';
|
||||||
|
} else if (watermark_type === 'site_shortname_and_username') {
|
||||||
|
html += '使用水印:<?= UOJConfig::$data['profile']['oj-name-short'] ?> @<?= Auth::id() ?>。';
|
||||||
|
} else {
|
||||||
|
html += '使用水印:<?= UOJConfig::$data['profile']['oj-name-short'] ?>。';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</p>';
|
||||||
|
|
||||||
|
$('#modal-file-info').html(html);
|
||||||
|
$('#modal-help-message').html('').hide();
|
||||||
|
image_upload_modal.show();
|
||||||
|
} else {
|
||||||
|
image_upload_toast.show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$pag_config = [
|
||||||
|
'page_len' => 40,
|
||||||
|
'col_names' => ['*'],
|
||||||
|
'table_name' => 'users_images',
|
||||||
|
'cond' => "uploader = '{$myUser['username']}'",
|
||||||
|
'tail' => 'order by upload_time desc',
|
||||||
|
];
|
||||||
|
$pag = new Paginator($pag_config);
|
||||||
|
?>
|
||||||
|
|
||||||
|
<h2 class="mt-4 mb-3">
|
||||||
|
我的图片
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-4">
|
||||||
|
<?php foreach ($pag->get() as $idx => $row) : ?>
|
||||||
|
<div class="col">
|
||||||
|
<div class="card">
|
||||||
|
<img src="<?= $row['path'] ?>" class="card-img-top" height="200" style="object-fit: contain">
|
||||||
|
<div class="card-footer bg-transparent small px-2">
|
||||||
|
<div class="d-flex flex-wrap justify-content-between">
|
||||||
|
<time><?= $row['upload_time'] ?></time>
|
||||||
|
<span>
|
||||||
|
<?php if ($row['size'] < 1024 * 512) : ?>
|
||||||
|
<?= round($row['size'] * 1.0 / 1024, 1) ?> KB
|
||||||
|
<?php else : ?>
|
||||||
|
<?= round($row['size'] * 1.0 / 1024 / 1024, 1) ?> MB
|
||||||
|
<?php endif ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap justify-content-between mt-2">
|
||||||
|
<form method="post" onsubmit="return confirm('您确定要删除这张图片吗?');">
|
||||||
|
<input type="hidden" name="image_delete_submit" value="submit">
|
||||||
|
<input type="hidden" name="image_delete_id" value="<?= $row['id'] ?>">
|
||||||
|
<input type="hidden" name="_token" value="<?= crsf_token() ?>">
|
||||||
|
<button class="btn btn-sm btn-outline-danger image-delete-button" data-bs-toggle="tooltip" data-bs-placement="bottom" data-bs-title="删除">
|
||||||
|
<i class="bi bi-trash3"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary image-copy-url-button" data-bs-toggle="tooltip" data-bs-placement="bottom" data-bs-title="复制链接" data-image-path="<?= $row['path'] ?>">
|
||||||
|
<i class="bi bi-clipboard"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary image-copy-md-button" data-bs-toggle="tooltip" data-bs-placement="bottom" data-bs-title="复制 Markdown 源码" data-image-path="<?= $row['path'] ?>">
|
||||||
|
<i class="bi bi-markdown"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($pag->isEmpty()) : ?>
|
||||||
|
<div class="mt-4 text-muted">
|
||||||
|
<?= UOJLocale::get('none') ?>
|
||||||
|
</div>
|
||||||
|
<?php endif ?>
|
||||||
|
|
||||||
|
<div class="text-end">
|
||||||
|
<?= $pag->pagination() ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast-container position-fixed bottom-0 start-0 ms-3 mb-4">
|
||||||
|
<div id="copy-url-toast" class="toast text-bg-success align-items-center border-0" role="alert" aria-live="assertive" aria-atomic="true">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="toast-body">
|
||||||
|
复制成功!
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
$(document).ready(function() {
|
||||||
|
[...document.querySelectorAll('[data-bs-toggle="tooltip"]')].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
|
||||||
|
});
|
||||||
|
|
||||||
|
var copy_url_toast = new bootstrap.Toast('#copy-url-toast', {
|
||||||
|
delay: 2000
|
||||||
|
});
|
||||||
|
|
||||||
|
$('.image-copy-url-button').click(function() {
|
||||||
|
var url = new URL($(this).data('image-path'), location.origin);
|
||||||
|
navigator.clipboard.writeText(url);
|
||||||
|
copy_url_toast.show();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('.image-copy-md-button').click(function() {
|
||||||
|
var url = new URL($(this).data('image-path'), location.origin);
|
||||||
|
navigator.clipboard.writeText('![](' + url + ')');
|
||||||
|
copy_url_toast.show();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<?php echoUOJPageFooter() ?>
|
@ -1,46 +0,0 @@
|
|||||||
<?php requireLib('bootstrap5') ?>
|
|
||||||
|
|
||||||
<?php echoUOJPageHeader(UOJLocale::get('html to markdown')) ?>
|
|
||||||
|
|
||||||
<h1>
|
|
||||||
<?= UOJLocale::get('html to markdown') ?>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
#html,
|
|
||||||
#markdown {
|
|
||||||
font-family: Cascadia Mono, Ubuntu Mono, Roboto Mono, Jetbrains Mono, Fira Code, Consolas, '思源黑体 Regular', '思源宋体 Light', '宋体', 'Courier New', monospace;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 300px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row row-cols-1 row-cols-md-2">
|
|
||||||
<div class="col">
|
|
||||||
<textarea class="form-control" id="html" placeholder="input html here"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<textarea data-no-autosize readonly class="form-control" id="markdown" placeholder="output markdown here" style="height: 100%"></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer bg-transparent text-end">
|
|
||||||
<a href="https://s2oj.github.io/#/user/apps/html2markdown" target="_blank">使用教程</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?= HTML::js_src('/js/h2m.js') ?>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
$(document).ready(function() {
|
|
||||||
$('#html').on('input', function() {
|
|
||||||
$('#markdown').val(h2m($('#html').val(), {
|
|
||||||
converter: 'Gfm'
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<?php echoUOJPageFooter() ?>
|
|
@ -92,9 +92,9 @@ Route::group(
|
|||||||
Route::any('/click-zan', '/click_zan.php');
|
Route::any('/click-zan', '/click_zan.php');
|
||||||
|
|
||||||
// Apps
|
// Apps
|
||||||
Route::any('/image_hosting', '/image_hosting/index.php');
|
Route::any('/image_hosting', '/app/image_hosting/index.php');
|
||||||
Route::get('/image_hosting/{image_name}.png', '/image_hosting/get_image.php');
|
Route::get('/image_hosting/{image_name}.png', '/app/image_hosting/get_image.php');
|
||||||
Route::any('/html2markdown', '/html2markdown.php');
|
Route::any('/html2markdown', '/app/html2markdown.php');
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
File diff suppressed because one or more lines are too long
166
web/js/turndown-plugin-gfm.js
Normal file
166
web/js/turndown-plugin-gfm.js
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
/*! turndown-plugin-gfm | github.com/mixmark-io/turndown-plugin-gfm */
|
||||||
|
var turndownPluginGfm = (function (exports) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var highlightRegExp = /highlight-(?:text|source)-([a-z0-9]+)/;
|
||||||
|
|
||||||
|
function highlightedCodeBlock (turndownService) {
|
||||||
|
turndownService.addRule('highlightedCodeBlock', {
|
||||||
|
filter: function (node) {
|
||||||
|
var firstChild = node.firstChild;
|
||||||
|
return (
|
||||||
|
node.nodeName === 'DIV' &&
|
||||||
|
highlightRegExp.test(node.className) &&
|
||||||
|
firstChild &&
|
||||||
|
firstChild.nodeName === 'PRE'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
replacement: function (content, node, options) {
|
||||||
|
var className = node.className || '';
|
||||||
|
var language = (className.match(highlightRegExp) || [null, ''])[1];
|
||||||
|
|
||||||
|
return (
|
||||||
|
'\n\n' + options.fence + language + '\n' +
|
||||||
|
node.firstChild.textContent +
|
||||||
|
'\n' + options.fence + '\n\n'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function strikethrough (turndownService) {
|
||||||
|
turndownService.addRule('strikethrough', {
|
||||||
|
filter: ['del', 's', 'strike'],
|
||||||
|
replacement: function (content) {
|
||||||
|
return '~' + content + '~'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var indexOf = Array.prototype.indexOf;
|
||||||
|
var every = Array.prototype.every;
|
||||||
|
var rules = {};
|
||||||
|
|
||||||
|
rules.tableCell = {
|
||||||
|
filter: ['th', 'td'],
|
||||||
|
replacement: function (content, node) {
|
||||||
|
return cell(content, node)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rules.tableRow = {
|
||||||
|
filter: 'tr',
|
||||||
|
replacement: function (content, node) {
|
||||||
|
var borderCells = '';
|
||||||
|
var alignMap = { left: ':--', right: '--:', center: ':-:' };
|
||||||
|
|
||||||
|
if (isHeadingRow(node)) {
|
||||||
|
for (var i = 0; i < node.childNodes.length; i++) {
|
||||||
|
var border = '---';
|
||||||
|
var align = (
|
||||||
|
node.childNodes[i].getAttribute('align') || ''
|
||||||
|
).toLowerCase();
|
||||||
|
|
||||||
|
if (align) border = alignMap[align] || border;
|
||||||
|
|
||||||
|
borderCells += cell(border, node.childNodes[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '\n' + content + (borderCells ? '\n' + borderCells : '')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rules.table = {
|
||||||
|
// Only convert tables with a heading row.
|
||||||
|
// Tables with no heading row are kept using `keep` (see below).
|
||||||
|
filter: function (node) {
|
||||||
|
return node.nodeName === 'TABLE' && isHeadingRow(node.rows[0])
|
||||||
|
},
|
||||||
|
|
||||||
|
replacement: function (content) {
|
||||||
|
// Ensure there are no blank lines
|
||||||
|
content = content.replace('\n\n', '\n');
|
||||||
|
return '\n\n' + content + '\n\n'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rules.tableSection = {
|
||||||
|
filter: ['thead', 'tbody', 'tfoot'],
|
||||||
|
replacement: function (content) {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// A tr is a heading row if:
|
||||||
|
// - the parent is a THEAD
|
||||||
|
// - or if its the first child of the TABLE or the first TBODY (possibly
|
||||||
|
// following a blank THEAD)
|
||||||
|
// - and every cell is a TH
|
||||||
|
function isHeadingRow (tr) {
|
||||||
|
var parentNode = tr.parentNode;
|
||||||
|
return (
|
||||||
|
parentNode.nodeName === 'THEAD' ||
|
||||||
|
(
|
||||||
|
parentNode.firstChild === tr &&
|
||||||
|
(parentNode.nodeName === 'TABLE' || isFirstTbody(parentNode)) &&
|
||||||
|
every.call(tr.childNodes, function (n) { return n.nodeName === 'TH' })
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFirstTbody (element) {
|
||||||
|
var previousSibling = element.previousSibling;
|
||||||
|
return (
|
||||||
|
element.nodeName === 'TBODY' && (
|
||||||
|
!previousSibling ||
|
||||||
|
(
|
||||||
|
previousSibling.nodeName === 'THEAD' &&
|
||||||
|
/^\s*$/i.test(previousSibling.textContent)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cell (content, node) {
|
||||||
|
var index = indexOf.call(node.parentNode.childNodes, node);
|
||||||
|
var prefix = ' ';
|
||||||
|
if (index === 0) prefix = '| ';
|
||||||
|
return prefix + content + ' |'
|
||||||
|
}
|
||||||
|
|
||||||
|
function tables (turndownService) {
|
||||||
|
turndownService.keep(function (node) {
|
||||||
|
return node.nodeName === 'TABLE' && !isHeadingRow(node.rows[0])
|
||||||
|
});
|
||||||
|
for (var key in rules) turndownService.addRule(key, rules[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskListItems (turndownService) {
|
||||||
|
turndownService.addRule('taskListItems', {
|
||||||
|
filter: function (node) {
|
||||||
|
return node.type === 'checkbox' && node.parentNode.nodeName === 'LI'
|
||||||
|
},
|
||||||
|
replacement: function (content, node) {
|
||||||
|
return (node.checked ? '[x]' : '[ ]') + ' '
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function gfm (turndownService) {
|
||||||
|
turndownService.use([
|
||||||
|
highlightedCodeBlock,
|
||||||
|
strikethrough,
|
||||||
|
tables,
|
||||||
|
taskListItems
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.gfm = gfm;
|
||||||
|
exports.highlightedCodeBlock = highlightedCodeBlock;
|
||||||
|
exports.strikethrough = strikethrough;
|
||||||
|
exports.tables = tables;
|
||||||
|
exports.taskListItems = taskListItems;
|
||||||
|
|
||||||
|
return exports;
|
||||||
|
|
||||||
|
}({}));
|
974
web/js/turndown.js
Normal file
974
web/js/turndown.js
Normal file
@ -0,0 +1,974 @@
|
|||||||
|
/*! Turndown v7.1.1 | github.com/mixmark-io/turndown | Modified by S2OJ ( https://github.com/laurent22/joplin-turndown/commit/456d585db54f6d667970b9514bd697f57b92301c.diff ) */
|
||||||
|
var TurndownService = (function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function extend (destination) {
|
||||||
|
for (var i = 1; i < arguments.length; i++) {
|
||||||
|
var source = arguments[i];
|
||||||
|
for (var key in source) {
|
||||||
|
if (source.hasOwnProperty(key)) destination[key] = source[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return destination
|
||||||
|
}
|
||||||
|
|
||||||
|
function repeat (character, count) {
|
||||||
|
return Array(count + 1).join(character)
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimLeadingNewlines (string) {
|
||||||
|
return string.replace(/^\n*/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimTrailingNewlines (string) {
|
||||||
|
// avoid match-at-end regexp bottleneck, see #370
|
||||||
|
var indexEnd = string.length;
|
||||||
|
while (indexEnd > 0 && string[indexEnd - 1] === '\n') indexEnd--;
|
||||||
|
return string.substring(0, indexEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
var blockElements = [
|
||||||
|
'ADDRESS', 'ARTICLE', 'ASIDE', 'AUDIO', 'BLOCKQUOTE', 'BODY', 'CANVAS',
|
||||||
|
'CENTER', 'DD', 'DIR', 'DIV', 'DL', 'DT', 'FIELDSET', 'FIGCAPTION', 'FIGURE',
|
||||||
|
'FOOTER', 'FORM', 'FRAMESET', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER',
|
||||||
|
'HGROUP', 'HR', 'HTML', 'ISINDEX', 'LI', 'MAIN', 'MENU', 'NAV', 'NOFRAMES',
|
||||||
|
'NOSCRIPT', 'OL', 'OUTPUT', 'P', 'PRE', 'SECTION', 'TABLE', 'TBODY', 'TD',
|
||||||
|
'TFOOT', 'TH', 'THEAD', 'TR', 'UL'
|
||||||
|
];
|
||||||
|
|
||||||
|
function isBlock (node) {
|
||||||
|
return is(node, blockElements)
|
||||||
|
}
|
||||||
|
|
||||||
|
var voidElements = [
|
||||||
|
'AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT',
|
||||||
|
'KEYGEN', 'LINK', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR'
|
||||||
|
];
|
||||||
|
|
||||||
|
function isVoid (node) {
|
||||||
|
return is(node, voidElements)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasVoid (node) {
|
||||||
|
return has(node, voidElements)
|
||||||
|
}
|
||||||
|
|
||||||
|
var meaningfulWhenBlankElements = [
|
||||||
|
'A', 'TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TH', 'TD', 'IFRAME', 'SCRIPT',
|
||||||
|
'AUDIO', 'VIDEO'
|
||||||
|
];
|
||||||
|
|
||||||
|
function isMeaningfulWhenBlank (node) {
|
||||||
|
return is(node, meaningfulWhenBlankElements)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMeaningfulWhenBlank (node) {
|
||||||
|
return has(node, meaningfulWhenBlankElements)
|
||||||
|
}
|
||||||
|
|
||||||
|
function is (node, tagNames) {
|
||||||
|
return tagNames.indexOf(node.nodeName) >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function has (node, tagNames) {
|
||||||
|
return (
|
||||||
|
node.getElementsByTagName &&
|
||||||
|
tagNames.some(function (tagName) {
|
||||||
|
return node.getElementsByTagName(tagName).length
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rules = {};
|
||||||
|
|
||||||
|
rules.paragraph = {
|
||||||
|
filter: 'p',
|
||||||
|
|
||||||
|
replacement: function (content) {
|
||||||
|
return '\n\n' + content + '\n\n'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rules.lineBreak = {
|
||||||
|
filter: 'br',
|
||||||
|
|
||||||
|
replacement: function (content, node, options) {
|
||||||
|
return options.br + '\n'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rules.heading = {
|
||||||
|
filter: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
|
||||||
|
|
||||||
|
replacement: function (content, node, options) {
|
||||||
|
var hLevel = Number(node.nodeName.charAt(1));
|
||||||
|
|
||||||
|
if (options.headingStyle === 'setext' && hLevel < 3) {
|
||||||
|
var underline = repeat((hLevel === 1 ? '=' : '-'), content.length);
|
||||||
|
return (
|
||||||
|
'\n\n' + content + '\n' + underline + '\n\n'
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return '\n\n' + repeat('#', hLevel) + ' ' + content + '\n\n'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rules.blockquote = {
|
||||||
|
filter: 'blockquote',
|
||||||
|
|
||||||
|
replacement: function (content) {
|
||||||
|
content = content.replace(/^\n+|\n+$/g, '');
|
||||||
|
content = content.replace(/^/gm, '> ');
|
||||||
|
return '\n\n' + content + '\n\n'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rules.list = {
|
||||||
|
filter: ['ul', 'ol'],
|
||||||
|
|
||||||
|
replacement: function (content, node) {
|
||||||
|
var parent = node.parentNode;
|
||||||
|
if (parent.nodeName === 'LI' && parent.lastElementChild === node) {
|
||||||
|
return '\n' + content
|
||||||
|
} else {
|
||||||
|
return '\n\n' + content + '\n\n'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rules.listItem = {
|
||||||
|
filter: 'li',
|
||||||
|
|
||||||
|
replacement: function (content, node, options) {
|
||||||
|
content = content
|
||||||
|
.replace(/^\n+/, '') // remove leading newlines
|
||||||
|
.replace(/\n+$/, '\n') // replace trailing newlines with just a single one
|
||||||
|
.replace(/\n/gm, '\n '); // indent
|
||||||
|
var prefix = options.bulletListMarker + ' ';
|
||||||
|
var parent = node.parentNode;
|
||||||
|
if (parent.nodeName === 'OL') {
|
||||||
|
var start = parent.getAttribute('start');
|
||||||
|
var index = Array.prototype.indexOf.call(parent.children, node);
|
||||||
|
prefix = (start ? Number(start) + index : index + 1) + '. ';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
prefix + content + (node.nextSibling && !/\n$/.test(content) ? '\n' : '')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rules.indentedCodeBlock = {
|
||||||
|
filter: function (node, options) {
|
||||||
|
return (
|
||||||
|
options.codeBlockStyle === 'indented' &&
|
||||||
|
node.nodeName === 'PRE' &&
|
||||||
|
node.firstChild &&
|
||||||
|
node.firstChild.nodeName === 'CODE'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
replacement: function (content, node, options) {
|
||||||
|
return (
|
||||||
|
'\n\n ' +
|
||||||
|
node.firstChild.textContent.replace(/\n/g, '\n ') +
|
||||||
|
'\n\n'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rules.fencedCodeBlock = {
|
||||||
|
filter: function (node, options) {
|
||||||
|
return (
|
||||||
|
options.codeBlockStyle === 'fenced' &&
|
||||||
|
node.nodeName === 'PRE' &&
|
||||||
|
node.firstChild &&
|
||||||
|
node.firstChild.nodeName === 'CODE'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
replacement: function (content, node, options) {
|
||||||
|
var className = node.firstChild.getAttribute('class') || '';
|
||||||
|
var language = (className.match(/language-(\S+)/) || [null, ''])[1];
|
||||||
|
var code = node.firstChild.textContent;
|
||||||
|
|
||||||
|
var fenceChar = options.fence.charAt(0);
|
||||||
|
var fenceSize = 3;
|
||||||
|
var fenceInCodeRegex = new RegExp('^' + fenceChar + '{3,}', 'gm');
|
||||||
|
|
||||||
|
var match;
|
||||||
|
while ((match = fenceInCodeRegex.exec(code))) {
|
||||||
|
if (match[0].length >= fenceSize) {
|
||||||
|
fenceSize = match[0].length + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var fence = repeat(fenceChar, fenceSize);
|
||||||
|
|
||||||
|
return (
|
||||||
|
'\n\n' + fence + language + '\n' +
|
||||||
|
code.replace(/\n$/, '') +
|
||||||
|
'\n' + fence + '\n\n'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rules.horizontalRule = {
|
||||||
|
filter: 'hr',
|
||||||
|
|
||||||
|
replacement: function (content, node, options) {
|
||||||
|
return '\n\n' + options.hr + '\n\n'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rules.inlineLink = {
|
||||||
|
filter: function (node, options) {
|
||||||
|
return (
|
||||||
|
options.linkStyle === 'inlined' &&
|
||||||
|
node.nodeName === 'A' &&
|
||||||
|
node.getAttribute('href')
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
replacement: function (content, node) {
|
||||||
|
var href = node.getAttribute('href');
|
||||||
|
var title = cleanAttribute(node.getAttribute('title'));
|
||||||
|
if (title) title = ' "' + title + '"';
|
||||||
|
return '[' + content + '](' + href + title + ')'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rules.referenceLink = {
|
||||||
|
filter: function (node, options) {
|
||||||
|
return (
|
||||||
|
options.linkStyle === 'referenced' &&
|
||||||
|
node.nodeName === 'A' &&
|
||||||
|
node.getAttribute('href')
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
replacement: function (content, node, options) {
|
||||||
|
var href = node.getAttribute('href');
|
||||||
|
var title = cleanAttribute(node.getAttribute('title'));
|
||||||
|
if (title) title = ' "' + title + '"';
|
||||||
|
var replacement;
|
||||||
|
var reference;
|
||||||
|
|
||||||
|
switch (options.linkReferenceStyle) {
|
||||||
|
case 'collapsed':
|
||||||
|
replacement = '[' + content + '][]';
|
||||||
|
reference = '[' + content + ']: ' + href + title;
|
||||||
|
break
|
||||||
|
case 'shortcut':
|
||||||
|
replacement = '[' + content + ']';
|
||||||
|
reference = '[' + content + ']: ' + href + title;
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
var id = this.references.length + 1;
|
||||||
|
replacement = '[' + content + '][' + id + ']';
|
||||||
|
reference = '[' + id + ']: ' + href + title;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.references.push(reference);
|
||||||
|
return replacement
|
||||||
|
},
|
||||||
|
|
||||||
|
references: [],
|
||||||
|
|
||||||
|
append: function (options) {
|
||||||
|
var references = '';
|
||||||
|
if (this.references.length) {
|
||||||
|
references = '\n\n' + this.references.join('\n') + '\n\n';
|
||||||
|
this.references = []; // Reset references
|
||||||
|
}
|
||||||
|
return references
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rules.emphasis = {
|
||||||
|
filter: ['em', 'i'],
|
||||||
|
|
||||||
|
replacement: function (content, node, options) {
|
||||||
|
if (!content.trim()) return ''
|
||||||
|
return options.emDelimiter + content + options.emDelimiter
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rules.strong = {
|
||||||
|
filter: ['strong', 'b'],
|
||||||
|
|
||||||
|
replacement: function (content, node, options) {
|
||||||
|
if (!content.trim()) return ''
|
||||||
|
return options.strongDelimiter + content + options.strongDelimiter
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rules.code = {
|
||||||
|
filter: function (node) {
|
||||||
|
var hasSiblings = node.previousSibling || node.nextSibling;
|
||||||
|
var isCodeBlock = node.parentNode.nodeName === 'PRE' && !hasSiblings;
|
||||||
|
|
||||||
|
return node.nodeName === 'CODE' && !isCodeBlock
|
||||||
|
},
|
||||||
|
|
||||||
|
replacement: function (content) {
|
||||||
|
if (!content) return ''
|
||||||
|
content = content.replace(/\r?\n|\r/g, ' ');
|
||||||
|
|
||||||
|
var extraSpace = /^`|^ .*?[^ ].* $|`$/.test(content) ? ' ' : '';
|
||||||
|
var delimiter = '`';
|
||||||
|
var matches = content.match(/`+/gm) || [];
|
||||||
|
while (matches.indexOf(delimiter) !== -1) delimiter = delimiter + '`';
|
||||||
|
|
||||||
|
return delimiter + extraSpace + content + extraSpace + delimiter
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rules.image = {
|
||||||
|
filter: 'img',
|
||||||
|
|
||||||
|
replacement: function (content, node) {
|
||||||
|
var alt = cleanAttribute(node.getAttribute('alt'));
|
||||||
|
var src = node.getAttribute('src') || '';
|
||||||
|
var title = cleanAttribute(node.getAttribute('title'));
|
||||||
|
var titlePart = title ? ' "' + title + '"' : '';
|
||||||
|
return src ? '![' + alt + ']' + '(' + src + titlePart + ')' : ''
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function cleanAttribute (attribute) {
|
||||||
|
return attribute ? attribute.replace(/(\n+\s*)+/g, '\n') : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages a collection of rules used to convert HTML to Markdown
|
||||||
|
*/
|
||||||
|
|
||||||
|
function Rules (options) {
|
||||||
|
this.options = options;
|
||||||
|
this._keep = [];
|
||||||
|
this._remove = [];
|
||||||
|
|
||||||
|
this.blankRule = {
|
||||||
|
replacement: options.blankReplacement
|
||||||
|
};
|
||||||
|
|
||||||
|
this.keepReplacement = options.keepReplacement;
|
||||||
|
|
||||||
|
this.defaultRule = {
|
||||||
|
replacement: options.defaultReplacement
|
||||||
|
};
|
||||||
|
|
||||||
|
this.array = [];
|
||||||
|
for (var key in options.rules) this.array.push(options.rules[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Rules.prototype = {
|
||||||
|
add: function (key, rule) {
|
||||||
|
this.array.unshift(rule);
|
||||||
|
},
|
||||||
|
|
||||||
|
keep: function (filter) {
|
||||||
|
this._keep.unshift({
|
||||||
|
filter: filter,
|
||||||
|
replacement: this.keepReplacement
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
remove: function (filter) {
|
||||||
|
this._remove.unshift({
|
||||||
|
filter: filter,
|
||||||
|
replacement: function () {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
forNode: function (node) {
|
||||||
|
if (node.isBlank) return this.blankRule
|
||||||
|
var rule;
|
||||||
|
|
||||||
|
if ((rule = findRule(this.array, node, this.options))) return rule
|
||||||
|
if ((rule = findRule(this._keep, node, this.options))) return rule
|
||||||
|
if ((rule = findRule(this._remove, node, this.options))) return rule
|
||||||
|
|
||||||
|
return this.defaultRule
|
||||||
|
},
|
||||||
|
|
||||||
|
forEach: function (fn) {
|
||||||
|
for (var i = 0; i < this.array.length; i++) fn(this.array[i], i);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function findRule (rules, node, options) {
|
||||||
|
for (var i = 0; i < rules.length; i++) {
|
||||||
|
var rule = rules[i];
|
||||||
|
if (filterValue(rule, node, options)) return rule
|
||||||
|
}
|
||||||
|
return void 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterValue (rule, node, options) {
|
||||||
|
var filter = rule.filter;
|
||||||
|
if (typeof filter === 'string') {
|
||||||
|
if (filter === node.nodeName.toLowerCase()) return true
|
||||||
|
} else if (Array.isArray(filter)) {
|
||||||
|
if (filter.indexOf(node.nodeName.toLowerCase()) > -1) return true
|
||||||
|
} else if (typeof filter === 'function') {
|
||||||
|
if (filter.call(rule, node, options)) return true
|
||||||
|
} else {
|
||||||
|
throw new TypeError('`filter` needs to be a string, array, or function')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The collapseWhitespace function is adapted from collapse-whitespace
|
||||||
|
* by Luc Thevenard.
|
||||||
|
*
|
||||||
|
* The MIT License (MIT)
|
||||||
|
*
|
||||||
|
* Copyright (c) 2014 Luc Thevenard <lucthevenard@gmail.com>
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in
|
||||||
|
* all copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
* THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* collapseWhitespace(options) removes extraneous whitespace from an the given element.
|
||||||
|
*
|
||||||
|
* @param {Object} options
|
||||||
|
*/
|
||||||
|
function collapseWhitespace (options) {
|
||||||
|
var element = options.element;
|
||||||
|
var isBlock = options.isBlock;
|
||||||
|
var isVoid = options.isVoid;
|
||||||
|
var isPre = options.isPre || function (node) {
|
||||||
|
return node.nodeName === 'PRE'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!element.firstChild || isPre(element)) return
|
||||||
|
|
||||||
|
var prevText = null;
|
||||||
|
var keepLeadingWs = false;
|
||||||
|
|
||||||
|
var prev = null;
|
||||||
|
var node = next(prev, element, isPre);
|
||||||
|
|
||||||
|
while (node !== element) {
|
||||||
|
if (node.nodeType === 3 || node.nodeType === 4) { // Node.TEXT_NODE or Node.CDATA_SECTION_NODE
|
||||||
|
var text = node.data.replace(/[ \r\n\t]+/g, ' ');
|
||||||
|
|
||||||
|
if ((!prevText || / $/.test(prevText.data)) &&
|
||||||
|
!keepLeadingWs && text[0] === ' ') {
|
||||||
|
text = text.substr(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `text` might be empty at this point.
|
||||||
|
if (!text) {
|
||||||
|
node = remove(node);
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
node.data = text;
|
||||||
|
|
||||||
|
prevText = node;
|
||||||
|
} else if (node.nodeType === 1) { // Node.ELEMENT_NODE
|
||||||
|
if (isBlock(node) || node.nodeName === 'BR') {
|
||||||
|
if (prevText) {
|
||||||
|
prevText.data = prevText.data.replace(/ $/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
prevText = null;
|
||||||
|
keepLeadingWs = false;
|
||||||
|
} else if (isVoid(node) || isPre(node)) {
|
||||||
|
// Avoid trimming space around non-block, non-BR void elements and inline PRE.
|
||||||
|
prevText = null;
|
||||||
|
keepLeadingWs = true;
|
||||||
|
} else if (prevText) {
|
||||||
|
// Drop protection if set previously.
|
||||||
|
keepLeadingWs = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
node = remove(node);
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var nextNode = next(prev, node, isPre);
|
||||||
|
prev = node;
|
||||||
|
node = nextNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevText) {
|
||||||
|
prevText.data = prevText.data.replace(/ $/, '');
|
||||||
|
if (!prevText.data) {
|
||||||
|
remove(prevText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* remove(node) removes the given node from the DOM and returns the
|
||||||
|
* next node in the sequence.
|
||||||
|
*
|
||||||
|
* @param {Node} node
|
||||||
|
* @return {Node} node
|
||||||
|
*/
|
||||||
|
function remove (node) {
|
||||||
|
var next = node.nextSibling || node.parentNode;
|
||||||
|
|
||||||
|
node.parentNode.removeChild(node);
|
||||||
|
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* next(prev, current, isPre) returns the next node in the sequence, given the
|
||||||
|
* current and previous nodes.
|
||||||
|
*
|
||||||
|
* @param {Node} prev
|
||||||
|
* @param {Node} current
|
||||||
|
* @param {Function} isPre
|
||||||
|
* @return {Node}
|
||||||
|
*/
|
||||||
|
function next (prev, current, isPre) {
|
||||||
|
if ((prev && prev.parentNode === current) || isPre(current)) {
|
||||||
|
return current.nextSibling || current.parentNode
|
||||||
|
}
|
||||||
|
|
||||||
|
return current.firstChild || current.nextSibling || current.parentNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Set up window for Node.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
var root = (typeof window !== 'undefined' ? window : {});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Parsing HTML strings
|
||||||
|
*/
|
||||||
|
|
||||||
|
function canParseHTMLNatively () {
|
||||||
|
var Parser = root.DOMParser;
|
||||||
|
var canParse = false;
|
||||||
|
|
||||||
|
// Adapted from https://gist.github.com/1129031
|
||||||
|
// Firefox/Opera/IE throw errors on unsupported types
|
||||||
|
try {
|
||||||
|
// WebKit returns null on unsupported types
|
||||||
|
if (new Parser().parseFromString('', 'text/html')) {
|
||||||
|
canParse = true;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
return canParse
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHTMLParser () {
|
||||||
|
var Parser = function () {};
|
||||||
|
|
||||||
|
{
|
||||||
|
if (shouldUseActiveX()) {
|
||||||
|
Parser.prototype.parseFromString = function (string) {
|
||||||
|
var doc = new window.ActiveXObject('htmlfile');
|
||||||
|
doc.designMode = 'on'; // disable on-page scripts
|
||||||
|
doc.open();
|
||||||
|
doc.write(string);
|
||||||
|
doc.close();
|
||||||
|
return doc
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
Parser.prototype.parseFromString = function (string) {
|
||||||
|
var doc = document.implementation.createHTMLDocument('');
|
||||||
|
doc.open();
|
||||||
|
doc.write(string);
|
||||||
|
doc.close();
|
||||||
|
return doc
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Parser
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldUseActiveX () {
|
||||||
|
var useActiveX = false;
|
||||||
|
try {
|
||||||
|
document.implementation.createHTMLDocument('').open();
|
||||||
|
} catch (e) {
|
||||||
|
if (window.ActiveXObject) useActiveX = true;
|
||||||
|
}
|
||||||
|
return useActiveX
|
||||||
|
}
|
||||||
|
|
||||||
|
var HTMLParser = canParseHTMLNatively() ? root.DOMParser : createHTMLParser();
|
||||||
|
|
||||||
|
function RootNode (input, options) {
|
||||||
|
var root;
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
var doc = htmlParser().parseFromString(
|
||||||
|
// DOM parsers arrange elements in the <head> and <body>.
|
||||||
|
// Wrapping in a custom element ensures elements are reliably arranged in
|
||||||
|
// a single element.
|
||||||
|
'<x-turndown id="turndown-root">' + input + '</x-turndown>',
|
||||||
|
'text/html'
|
||||||
|
);
|
||||||
|
root = doc.getElementById('turndown-root');
|
||||||
|
} else {
|
||||||
|
root = input.cloneNode(true);
|
||||||
|
}
|
||||||
|
collapseWhitespace({
|
||||||
|
element: root,
|
||||||
|
isBlock: isBlock,
|
||||||
|
isVoid: isVoid,
|
||||||
|
isPre: options.preformattedCode ? isPreOrCode : null
|
||||||
|
});
|
||||||
|
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
var _htmlParser;
|
||||||
|
function htmlParser () {
|
||||||
|
_htmlParser = _htmlParser || new HTMLParser();
|
||||||
|
return _htmlParser
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPreOrCode (node) {
|
||||||
|
return node.nodeName === 'PRE' || node.nodeName === 'CODE'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Node (node, options) {
|
||||||
|
node.isBlock = isBlock(node);
|
||||||
|
node.isCode = node.nodeName === 'CODE' || node.parentNode.isCode;
|
||||||
|
node.isBlank = isBlank(node);
|
||||||
|
node.flankingWhitespace = flankingWhitespace(node, options);
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlank (node) {
|
||||||
|
return (
|
||||||
|
!isVoid(node) &&
|
||||||
|
!isMeaningfulWhenBlank(node) &&
|
||||||
|
/^\s*$/i.test(node.textContent) &&
|
||||||
|
!hasVoid(node) &&
|
||||||
|
!hasMeaningfulWhenBlank(node)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function flankingWhitespace (node, options) {
|
||||||
|
if (node.isBlock || (options.preformattedCode && node.isCode)) {
|
||||||
|
return { leading: '', trailing: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
var edges = edgeWhitespace(node.textContent);
|
||||||
|
|
||||||
|
// abandon leading ASCII WS if left-flanked by ASCII WS
|
||||||
|
if (edges.leadingAscii && isFlankedByWhitespace('left', node, options)) {
|
||||||
|
edges.leading = edges.leadingNonAscii;
|
||||||
|
}
|
||||||
|
|
||||||
|
// abandon trailing ASCII WS if right-flanked by ASCII WS
|
||||||
|
if (edges.trailingAscii && isFlankedByWhitespace('right', node, options)) {
|
||||||
|
edges.trailing = edges.trailingNonAscii;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { leading: edges.leading, trailing: edges.trailing }
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgeWhitespace (string) {
|
||||||
|
var m = string.match(/^(([ \t\r\n]*)(\s*))[\s\S]*?((\s*?)([ \t\r\n]*))$/);
|
||||||
|
return {
|
||||||
|
leading: m[1], // whole string for whitespace-only strings
|
||||||
|
leadingAscii: m[2],
|
||||||
|
leadingNonAscii: m[3],
|
||||||
|
trailing: m[4], // empty for whitespace-only strings
|
||||||
|
trailingNonAscii: m[5],
|
||||||
|
trailingAscii: m[6]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFlankedByWhitespace (side, node, options) {
|
||||||
|
var sibling;
|
||||||
|
var regExp;
|
||||||
|
var isFlanked;
|
||||||
|
|
||||||
|
if (side === 'left') {
|
||||||
|
sibling = node.previousSibling;
|
||||||
|
regExp = / $/;
|
||||||
|
} else {
|
||||||
|
sibling = node.nextSibling;
|
||||||
|
regExp = /^ /;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sibling) {
|
||||||
|
if (sibling.nodeType === 3) {
|
||||||
|
isFlanked = regExp.test(sibling.nodeValue);
|
||||||
|
} else if (options.preformattedCode && sibling.nodeName === 'CODE') {
|
||||||
|
isFlanked = false;
|
||||||
|
} else if (sibling.nodeType === 1 && !isBlock(sibling)) {
|
||||||
|
isFlanked = regExp.test(sibling.textContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return isFlanked
|
||||||
|
}
|
||||||
|
|
||||||
|
var reduce = Array.prototype.reduce;
|
||||||
|
var escapes = [
|
||||||
|
[/\\/g, '\\\\'],
|
||||||
|
[/\*/g, '\\*'],
|
||||||
|
[/^-/g, '\\-'],
|
||||||
|
[/^\+ /g, '\\+ '],
|
||||||
|
[/^(=+)/g, '\\$1'],
|
||||||
|
[/^(#{1,6}) /g, '\\$1 '],
|
||||||
|
[/`/g, '\\`'],
|
||||||
|
[/^~~~/g, '\\~~~'],
|
||||||
|
[/\[/g, '\\['],
|
||||||
|
[/\]/g, '\\]'],
|
||||||
|
[/^>/g, '\\>'],
|
||||||
|
[/_/g, '\\_'],
|
||||||
|
[/^(\d+)\. /g, '$1\\. ']
|
||||||
|
];
|
||||||
|
|
||||||
|
function TurndownService (options) {
|
||||||
|
if (!(this instanceof TurndownService)) return new TurndownService(options)
|
||||||
|
|
||||||
|
var defaults = {
|
||||||
|
rules: rules,
|
||||||
|
headingStyle: 'setext',
|
||||||
|
hr: '* * *',
|
||||||
|
bulletListMarker: '*',
|
||||||
|
codeBlockStyle: 'indented',
|
||||||
|
fence: '```',
|
||||||
|
emDelimiter: '_',
|
||||||
|
strongDelimiter: '**',
|
||||||
|
linkStyle: 'inlined',
|
||||||
|
linkReferenceStyle: 'full',
|
||||||
|
br: ' ',
|
||||||
|
preformattedCode: false,
|
||||||
|
blankReplacement: function (content, node) {
|
||||||
|
return node.isBlock ? '\n\n' : ''
|
||||||
|
},
|
||||||
|
keepReplacement: function (content, node) {
|
||||||
|
return node.isBlock ? '\n\n' + node.outerHTML + '\n\n' : node.outerHTML
|
||||||
|
},
|
||||||
|
defaultReplacement: function (content, node) {
|
||||||
|
return node.isBlock ? '\n\n' + content + '\n\n' : content
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.options = extend({}, defaults, options);
|
||||||
|
this.rules = new Rules(this.options);
|
||||||
|
}
|
||||||
|
|
||||||
|
TurndownService.prototype = {
|
||||||
|
/**
|
||||||
|
* The entry point for converting a string or DOM node to Markdown
|
||||||
|
* @public
|
||||||
|
* @param {String|HTMLElement} input The string or DOM node to convert
|
||||||
|
* @returns A Markdown representation of the input
|
||||||
|
* @type String
|
||||||
|
*/
|
||||||
|
|
||||||
|
turndown: function (input) {
|
||||||
|
if (!canConvert(input)) {
|
||||||
|
throw new TypeError(
|
||||||
|
input + ' is not a string, or an element/document/fragment node.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input === '') return ''
|
||||||
|
|
||||||
|
var output = process.call(this, new RootNode(input, this.options));
|
||||||
|
return postProcess.call(this, output)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add one or more plugins
|
||||||
|
* @public
|
||||||
|
* @param {Function|Array} plugin The plugin or array of plugins to add
|
||||||
|
* @returns The Turndown instance for chaining
|
||||||
|
* @type Object
|
||||||
|
*/
|
||||||
|
|
||||||
|
use: function (plugin) {
|
||||||
|
if (Array.isArray(plugin)) {
|
||||||
|
for (var i = 0; i < plugin.length; i++) this.use(plugin[i]);
|
||||||
|
} else if (typeof plugin === 'function') {
|
||||||
|
plugin(this);
|
||||||
|
} else {
|
||||||
|
throw new TypeError('plugin must be a Function or an Array of Functions')
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a rule
|
||||||
|
* @public
|
||||||
|
* @param {String} key The unique key of the rule
|
||||||
|
* @param {Object} rule The rule
|
||||||
|
* @returns The Turndown instance for chaining
|
||||||
|
* @type Object
|
||||||
|
*/
|
||||||
|
|
||||||
|
addRule: function (key, rule) {
|
||||||
|
this.rules.add(key, rule);
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep a node (as HTML) that matches the filter
|
||||||
|
* @public
|
||||||
|
* @param {String|Array|Function} filter The unique key of the rule
|
||||||
|
* @returns The Turndown instance for chaining
|
||||||
|
* @type Object
|
||||||
|
*/
|
||||||
|
|
||||||
|
keep: function (filter) {
|
||||||
|
this.rules.keep(filter);
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a node that matches the filter
|
||||||
|
* @public
|
||||||
|
* @param {String|Array|Function} filter The unique key of the rule
|
||||||
|
* @returns The Turndown instance for chaining
|
||||||
|
* @type Object
|
||||||
|
*/
|
||||||
|
|
||||||
|
remove: function (filter) {
|
||||||
|
this.rules.remove(filter);
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escapes Markdown syntax
|
||||||
|
* @public
|
||||||
|
* @param {String} string The string to escape
|
||||||
|
* @returns A string with Markdown syntax escaped
|
||||||
|
* @type String
|
||||||
|
*/
|
||||||
|
|
||||||
|
escape: function (string) {
|
||||||
|
return escapes.reduce(function (accumulator, escape) {
|
||||||
|
return accumulator.replace(escape[0], escape[1])
|
||||||
|
}, string)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduces a DOM node down to its Markdown string equivalent
|
||||||
|
* @private
|
||||||
|
* @param {HTMLElement} parentNode The node to convert
|
||||||
|
* @returns A Markdown representation of the node
|
||||||
|
* @type String
|
||||||
|
*/
|
||||||
|
|
||||||
|
function process (parentNode, escapeContent = 'auto') {
|
||||||
|
var self = this;
|
||||||
|
return reduce.call(parentNode.childNodes, function (output, node) {
|
||||||
|
node = new Node(node, self.options);
|
||||||
|
|
||||||
|
var replacement = '';
|
||||||
|
if (node.nodeType === 3) {
|
||||||
|
replacement = node.isCode || escapeContent === false ? node.nodeValue : self.escape(node.nodeValue);
|
||||||
|
} else if (node.nodeType === 1) {
|
||||||
|
replacement = replacementForNode.call(self, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
return join(output, replacement)
|
||||||
|
}, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends strings as each rule requires and trims the output
|
||||||
|
* @private
|
||||||
|
* @param {String} output The conversion output
|
||||||
|
* @returns A trimmed version of the ouput
|
||||||
|
* @type String
|
||||||
|
*/
|
||||||
|
|
||||||
|
function postProcess (output) {
|
||||||
|
var self = this;
|
||||||
|
this.rules.forEach(function (rule) {
|
||||||
|
if (typeof rule.append === 'function') {
|
||||||
|
output = join(output, rule.append(self.options));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return output.replace(/^[\t\r\n]+/, '').replace(/[\t\r\n\s]+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an element node to its Markdown equivalent
|
||||||
|
* @private
|
||||||
|
* @param {HTMLElement} node The node to convert
|
||||||
|
* @returns A Markdown representation of the node
|
||||||
|
* @type String
|
||||||
|
*/
|
||||||
|
|
||||||
|
function replacementForNode (node) {
|
||||||
|
var rule = this.rules.forNode(node);
|
||||||
|
var content = process.call(this, node, rule.escapeContent ? rule.escapeContent() : 'auto');
|
||||||
|
var whitespace = node.flankingWhitespace;
|
||||||
|
if (whitespace.leading || whitespace.trailing) content = content.trim();
|
||||||
|
return (
|
||||||
|
whitespace.leading +
|
||||||
|
rule.replacement(content, node, this.options) +
|
||||||
|
whitespace.trailing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Joins replacement to the current output with appropriate number of new lines
|
||||||
|
* @private
|
||||||
|
* @param {String} output The current conversion output
|
||||||
|
* @param {String} replacement The string to append to the output
|
||||||
|
* @returns Joined output
|
||||||
|
* @type String
|
||||||
|
*/
|
||||||
|
|
||||||
|
function join (output, replacement) {
|
||||||
|
var s1 = trimTrailingNewlines(output);
|
||||||
|
var s2 = trimLeadingNewlines(replacement);
|
||||||
|
var nls = Math.max(output.length - s1.length, replacement.length - s2.length);
|
||||||
|
var separator = '\n\n'.substring(0, nls);
|
||||||
|
|
||||||
|
return s1 + separator + s2
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether an input can be converted
|
||||||
|
* @private
|
||||||
|
* @param {String|HTMLElement} input Describe this parameter
|
||||||
|
* @returns Describe what it returns
|
||||||
|
* @type String|Object|Array|Boolean|Number
|
||||||
|
*/
|
||||||
|
|
||||||
|
function canConvert (input) {
|
||||||
|
return (
|
||||||
|
input != null && (
|
||||||
|
typeof input === 'string' ||
|
||||||
|
(input.nodeType && (
|
||||||
|
input.nodeType === 1 || input.nodeType === 9 || input.nodeType === 11
|
||||||
|
))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return TurndownService;
|
||||||
|
|
||||||
|
}());
|
Loading…
Reference in New Issue
Block a user