mirror of
https://github.com/renbaoshuo/S2OJ.git
synced 2024-12-22 14:21:52 +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');
|
||||
|
||||
// Apps
|
||||
Route::any('/image_hosting', '/image_hosting/index.php');
|
||||
Route::get('/image_hosting/{image_name}.png', '/image_hosting/get_image.php');
|
||||
Route::any('/html2markdown', '/html2markdown.php');
|
||||
Route::any('/image_hosting', '/app/image_hosting/index.php');
|
||||
Route::get('/image_hosting/{image_name}.png', '/app/image_hosting/get_image.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