scrapbox-preview-box/parser
要件
タイトルは予め削って渡す
行idも付与して返す
2021-03-18 07:07:56 実装した
2021-04-09 17:58:13 引用記法中のnodesが空なときにバグるのを直した
既知の問題
行idとの対応が少しずれる時がある
原因は今のところ不明のまま
代入もしていないのに突然objectがundefinedになるホラーが発生している
ただのtable のparserミスだった
cells[]が空の場合を想定していなかった
実装したいこと
/icons/done.icon直接DOMを組み立てる
理由
文字列の欠点
DOMの利点
event listenerを登録できる
/icons/done.iconcodeのcopy buttonをつける
htmlとcssはひとまとめにしておきたい
ついでにcssもまとめてしまおう
code:script.js
const css =`
a {
background-color: transparent;
text-decoration: none;
cursor: pointer;
}
img {
display: inline-block;
max-width: 100%;
max-height: 100px;
}
code {
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
font-size: 90%;
background-color: var(--code-bg, rgba(0,0,0,0.04));
padding: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
blockquote {
background-color: var(--quote-bg-color, rgba(0,0,0,0.05));
display: block;
padding-left: 4px;
margin: 0px;
}
strong {
font-weight: bold;
}
iframe {
margin: 3px 0;
vertical-align: middle;
max-width: 100%;
width: 640px;
height: 360px;
display: inline-block;
border: 0;
}
audio {
vertical-align: middle;
white-space: initial;
max-width: 100%;
}
.formula {
margin: auto 6px;
}
.cli {
border-radius: 4px;
}
.cli .prefix {
}
.helpfeel {
border-radius: 4px;
padding: 3px !important;
}
.helpfeel .prefix {
}
.helpfeel .entry {
}
.code-block {
display: block;
line-height: 1.7em;
background-color: var(--code-bg, rgba(0,0,0,0.04));
}
.code-block-start {
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
font-size: .9em;
padding: 1px 2px;
}
.code-block-start a {
text-decoration: underline;
}
.copy {
font-family: 'FontAwesome';
cursor: pointer;
}
.table-block {
white-space: nowrap;
}
.table-block-start {
padding: 1px 2px;
font-size: .9em;
}
.table-block-start a {
text-decoration: underline;
}
.cell {
margin: 0;
padding: 0 2px 0 8px;
box-sizing: content-box;
display: inline-block;
white-space: pre;
}
.cell:nth-child(2n+1) {
background-color: rgba(0,0,0,0.04);
}
.cell:nth-child(2n) {
background-color: rgba(0,0,0,0.06);
}
.strong-image {
max-height: 100%;
}
.icon {
height: 11px;
vertical-align: middle;
}
.strong-icon {
height: calc(11px * 1.2);
}
.deco-\\/ {font-style: italic;}
.deco-\\*-1 {font-weight: bold;}
.deco-\\*-2 {font-weight: bold; font-size: 1.20em;}
.deco-\\*-3 {font-weight: bold; font-size: 1.44em;}
.deco-\\*-4 {font-weight: bold; font-size: 1.73em;}
.deco-\\*-5 {font-weight: bold; font-size: 2.07em;}
.deco-\\*-6 {font-weight: bold; font-size: 2.49em;}
.deco-\\*-7 {font-weight: bold; font-size: 3.00em;}
.deco-\\*-8 {font-weight: bold; font-size: 3.58em;}
.deco-\\*-9 {font-weight: bold; font-size: 4.30em;}
.deco-\\*-10 {font-weight: bold; font-size: 5.16em;}
.deco-\\- {text-decoration: line-through;}
.deco-_ {text-decoration: underline;}
.page-link {color: var(--page-link-color, #5e8af7);} .page-link:hover {color: var(--page-link-hover-color, #2d67f5);} .empty-page-link {color: :var(--empty-page-link-color, #fd7373);} .empty-page-link:hover {color: :var(--empty-page-link-hover-color, #fd7373);} .link {
text-decoration: underline;
}
.link:hover {color: var(--page-link-color-hover-color, #2d67f5);} .permalink {
background-color: var(--line-permalink-color, rgba(234,218,74,0.75));
}
`;
code:script.js
import {ScrapboxParser} from '../scrapbox-parser.min.js/script.js';
import {
div, span, code, blockquote,
strong, a, img,
iframe, audio, fragment, style
} from '../easyDOMgenerator/script.js';
/*
type Lines = {
id: string;
text: string;
updated: number;
created: number;
userId: string;
}[];
*/
class Parser {
constructor(lines, project, title) {
const text = lines.map(({text}) => text).join('\n');
this._blocks = ScrapboxParser.parse(text, {hasTitle: false});
this.project = project;
this.title = title;
this._lineIds = lines.map(({id}) => id);
}
parse() {
const lines = this._blocks.flatMap(block => {
switch (block.type) {
case 'title':
return '';
case 'codeBlock':
return this._codeBlock(block);
case 'table':
return this._table(block);
case 'line':
return this._line(block);
}
})
.map((line, index) => div({className: 'line', id: L${this._lineIds[index]}}, line));
}
_table({fileName, cells}) {
const href = /api/table/${this.project}/${this.title}/${fileName}.csv;
const head =
span({className: 'table-block'},
span({className: 'table-block-start'},
a({href, target: '_blank'}, fileName)
)
);
const lines = cells.map(cell =>
span(
{className: 'table-block table-block-row'},
...cell.map((row, index) => span({className: cell col-${index}}, row0?.text ?? '')) )
);
}
_codeBlock({fileName, content}) {
const href = /api/code/${this.project}/${this.title}/${fileName};
const head =
span({className: 'code-block'},
span({className: 'code-block-start'},
fileName.includes('.') ?
a({href, target: '_blank'}, fileName) :
fileName,
span({
className: 'copy',
title: 'Copy',
onclick: async ({target}) => {
navigator.clipboard.writeText(content);
const mark = target.textContent;
target.textContent = 'Copied';
setTimeout(() => target.textContent = mark, 1000);
},
}, '\uf0c5'),
)
);
const lines = content.split('\n').map(line => code({className: 'code-block'}, line));
}
_line({nodes}) {
nodes = nodes.map(node => this._node(node));
}
_node(node) {
switch (node.type) {
case 'code':
return code({className: 'code'}, node.text);
case 'formula':
return code({className: 'formula'}, node.formula);
case 'commandLine':
return code({className: 'cli'},
span({className: 'prefix'}, node.symbol),
span(' '),
span({className: 'command'}, node.text),
);
case 'helpfeel':
return code({className: 'helpfeel'},
span({className: 'prefix'}, '?'),
span(' '),
span({className: 'entry'}, node.text),
);
case 'quote':
return blockquote({className: 'quote'},
...(
node.nodes.length !== 1 ?
node.nodes.map(node_ => span(this._node(node_))) :
[this._node(node.nodes0)] )
);
case 'strongIcon':
return this._icon(node, {isStrong: true});
case 'icon':
return this._icon(node);
case 'strongImage':
return this._image(node, {isStrong: true});
case 'image':
return this._image(node);
case 'strong':
return strong(
...(
node.nodes.length > 1 ?
node.nodes.map(node_ => span(this._node(node_))) :
[this._node(node.nodes0)] )
);
case 'decoration':
return span({className: node.decos.map(deco => deco-${deco}).join(' ')},
...(
node.nodes.length > 1 ?
node.nodes.map(node_ => span(this._node(node_))) :
[this._node(node.nodes0)] )
);
case 'link':
return this._link(node);
case 'hashTag':
return a({
type: 'hashTag', className: 'page-link', rel: 'route',
href: /${this.project}/${node.href}
},
#${node.href},
);
case 'googleMap':
return span({className: 'pointing-device-map'},
a({href: https://www.google.com/maps/search/${node.place}/@${node.latitude},${node.longitude},${node.zoom}z},
img({
className: 'google-map',
src: /api/google-map/static-map?center=${node.latitude}%2C${node.longitude}&markers=${node.place}&zoom=${node.zoom}&_csrf=${window._csrf},
})
)
);
case 'plain':
case 'blank':
return node.text;
}
}
_image({link, src}, {isStrong = false} ={}) {
const href = link || (/https:\/\/gyazo\.com\/^\/+\/thumb\/1000/.test(src) ? src.slice(0, -'/thumb/1000'.length) : src);
return a({
...(link ? {className: 'link'} : {}),
href, rel: 'noopener noreferrer', target: '_blank',
},
img({src, className: (!isStrong ? 'image' : 'image strong-image'),}),
);
}
_icon({pathType, path}, {isStrong = false} ={}) {
const project = pathType === 'relative' ? this.project : path.match(/\/(\w\-+)/)1; const title = pathType === 'relative' ? path: path.match(/\/\w\-+\/(.+)$/)1; return a({href: path},
img({
className: (!isStrong ? 'icon' : 'icon strong-icon'),
src: /api/pages/${project}/${title}/icon,
alt: title, title,
})
);
}
_link({pathType, href, content}) {
switch (pathType) {
case 'root':
return a({className: 'page-link', href}, href);
case 'relative':
return a({className: 'page-link', href: /${this.project}/${encodeURIComponent(href)}}, href);
case 'absolute':
// コードが適当なのは後で直す
// 動画と画像の識別を行う
const url = new URL(href);
switch (url.hostname) {
case 'www.youtube.com':
const videoId = url.searchParams.get('v');
return iframe({
src: https://www.youtube.com/embed/${videoId}?autoplay=0&start=0,
type: 'text/html',
allowfullscreen: '',
});
case 'vimeo.com':
const vimeoId = url.pathname.slice(1);
return iframe({
src: https://player.vimeo.com/video/${vimeoId},
type: 'text/html',
allowfullscreen: '',
});
default:
break;
}
if (/\.(?:mp3|ogg|wav)/.test(href) ?? content === '')
return audio({controls: '', preload: 'none', src: href});
return a({className: 'link', href, rel: 'noopener noreferrer', target: '_blank'},
// contentが空のときはundefinedではなく''になるので、
// ??ではなく||でfallback処理をする必要がある
content || href
);
}
}
}
export function parse(lines, project, title) {
const parser = new Parser(lines, project, title);
return parser.parse();
}