以前、このWebサイトのソースコードハイライト表示にWordpressプラグインのCrayon Syntax Highlighterを使っていましたが更新が止まっているようです。代わりのプラグインを探していたのですが、せっかくだから今勉強しているRust(wasm)で作ってみることにしました。現在稼働中です(2019/10/5)。
機能デモ
プラグイン仕様とか
サーバではなく、Webブラウザ(フロントエンド)の JavaScriptとWebAssembly で動作するプラグイン。 WebAssembly(wasm) はRustをコンパイルして生成する。HTMLのpreタグとcodeタグで囲まれた部分をハイライト処理。名前は、rshighliteです。
<pre><code>
<!--ここから-->
// JavaScript側の関数
extern "C" {
fn hello_js();
}
//JavaScriptから呼ばれる関数
#[no_mangle]
fn hello_rust() {
//JavaScriptの関数呼び出しは危険を伴うのでunsafe
unsafe {
hello_js();
}
}
<!--ここまで-->
</code></pre>
Rust(wasm)
JavaScriptからcodeタグで囲まれた部分をHTML文字列として受け取り、色付けする文字列をspanタグで囲みJavaScriptへ戻す。spanタグのclass名で色を指定する。セキュリティー上の安全を考えて、<br>以外のタグは削除する。
色付けのルールは専用の定義ファイル(構文木)で定義し、構文解析により色付けを実行する。UTF16⇔UTF8の変換はRustが行う。
JavaScript
全体の制御、wasmコード、定義ファイルの読み込み、Rust関数の呼び出しなど。ファイル読み込みなど準備が終了したら、codeタグで囲まれた部分をHTML文字列としてRust関数へ渡し、結果を受け取りページ内容を更新する。codeタグの内容取得と更新はDOM ElementのinnerHTMLプロパティで実装。
Rustでの構文解析とDOM更新は、時間の掛かる処理なので、1つのcodeタグを処理したらsetTimeoutを使って制御を他の処理に譲る。
色付けルールの選択
何も指定がなければ、指定されたものの中から 色が一番多く変わるものを調べて自動選択する。直接指定する場合は、codeタグのdata属性で指定する。<code data-rshl_lang=”rust”>
codeタグがない場合、wasmコード読み込みなどすべてキャンセルして何もしない。
Read more 続きを読む
以前は長いソースコードを表示する場合、ページ全体の高さを抑えるために表示高さを制限してスクロールバーを表示していましたが、マウスホイールやスワイプでの誤動作が気になっていました。解決策として、最初小さめ&スクロールバーなしで表示、(Read more)ボタンをクリックすると表示領域を拡大しスクロールバー表示、(Close)ボタンをクリックすると最初に戻るようにする。スクロールバーが出ない短いコードはそのまま表示。これで全体を見渡すときのスクロール誤爆がなくなるはず。Closeボタンについては快適な操作方法を模索中。
pub enum Token<'a> {
// 上から解析
Ret(&'a [char]), // 改行 '\r\n' 'n' 'r' <br>
Sp(&'a [char]), // 空白文字列 ' ' '\t'
// &???; // HTMLエスケープ Chに変換
Id(&'a [char]), // 識別子 [_a-zA-Z][_a-zA-Z0-9]*
Num(&'a [char]), // 数字 [0-9]+
NotA(&'a [char]), // アスキー以外の文字列
Ch(char), // 1文字
//SpCh(char), // 空白1文字
}
impl<'a> Token<'a> {
pub fn is_skip(&self, skip_ret : bool) -> bool {
match self {
Token::Ret(_s) => skip_ret,
Token::Sp(_s) => true,
_ => false,
}
}
pub fn len(&self) -> usize {
match self {
Token::Ret(s)
| Token::Sp(s)
| Token::Id(s)
| Token::Num(s)
| Token::NotA(s) => s.len(),
Token::Ch(_c) => 1,
}
}
pub fn to_string(&self) -> String {
match self {
Token::Ret(s)
| Token::Sp(s)
| Token::Id(s)
| Token::Num(s)
| Token::NotA(s) => s.into_iter().collect(),
Token::Ch(c) => c.to_string(),
}
}
pub fn to_vchar(&self) -> Vec<char> {
match self {
Token::Ret(s)
| Token::Sp(s)
| Token::Id(s)
| Token::Num(s)
| Token::NotA(s) => Vec::from(*s),
Token::Ch(c) => vec![*c],
}
}
pub fn to_utf16(&self) -> Vec<u16> {
match self {
Token::Ret(s)
| Token::Sp(s)
| Token::Id(s)
| Token::Num(s)
| Token::NotA(s) => chars_to_utf16(s),
Token::Ch(c) => char_to_utf16(*c),
}
}
}
const ESC_AMP : [u16;5] = [38,97,109,112,59];//"&"
const ESC_LT : [u16;4] = [38,108,116,59];//"<";
const ESC_GT : [u16;4] = [38,103,116,59];//">";
fn char_to_utf16(c : char) -> Vec<u16> {
if c == '&' { return Vec::from(&ESC_AMP[..]);}
if c == '<' { return Vec::from(&ESC_LT[..]);}
if c == '>' { return Vec::from(&ESC_GT[..]);}
let mut b = [0; 2];
Vec::from(c.encode_utf16(&mut b))
}
fn chars_to_utf16(vc : &[char]) -> Vec<u16> {
let mut b = [0; 2];
let mut u = Vec::<u16>::new();
for c in vc {
u.extend_from_slice(c.encode_utf16(&mut b));
}
u
}
enum Esc{
Lt, // < <
Gt, // > >
Amp, // & &
Unkwn,
}
pub struct Context<'ctx>{
chars : &'ctx [char],
}
impl<'ctx> Context<'ctx> {
pub fn new(chars : &'ctx [char]) -> Context {
Context {
chars : chars,
}
}
}
pub struct Reader<'read>{
ctx : &'read Context<'read>,
now : char,
now_pos : usize,
}
impl<'read> Reader<'read> {
pub fn new(ctx : &'read Context) -> Reader<'read> {
Reader{
ctx : ctx,
now : ' ',
now_pos : 0,
}
}
fn is_term(&self) -> bool {
self.now_pos >= self.ctx.chars.len()
}
fn next(&mut self) -> Option<char> {
if self.is_term() { return None;}
self.now = self.ctx.chars[self.now_pos];
self.now_pos += 1;
return Some(self.now);
}
fn getback(&mut self){
self.now_pos -= 1;
}
fn now(&self) -> char {
self.now
}
fn sub_start(&self) -> usize {
if self.now_pos == 0 { 0 }
else { self.now_pos - 1 }
}
fn sub_to_now(&self, start : usize) -> &'read [char] {
&self.ctx.chars[start..self.now_pos]
}
}
pub struct Lexer {
pub line_no : usize,
pub html_mode : bool,
}
impl Lexer {
pub fn new(html_mode : bool) -> Lexer {
Lexer{
line_no : 1,
html_mode : html_mode,
}
}
// コメント処理なし 次のphaseで
pub fn parse<'a>(&mut self, read : &mut Reader<'a>)
-> Vec<Token<'a>> {
let mut tks = Vec::<Token>::new();
while let Some(tk) = self.lex(read) {
tks.push(tk);
}
return tks;
}
fn is_space(ch : char) -> bool {
ch == ' ' || ch == '\t'
}
fn is_id_start(ch : char) -> bool {
ch.is_ascii_alphabetic() || ch =='_'
}
fn is_id_ch(ch : char) -> bool {
ch.is_ascii_alphanumeric() || ch =='_'
}
fn lex<'a>(&mut self, read : &mut Reader<'a>) -> Option<Token<'a>> {
while let Some(ch) = read.next() {
if ch == '\n' || ch == '\r' {
return Some(self.crlf(read));
}else if Self::is_space(ch) {
return Some(self.space(read));
}else if self.html_mode && ch == '&' {
if let Some(tk) = self.escape(read) {
return Some(tk);
}
}else if self.html_mode && ch == '<' {
if let Some(tk) = self.tag(read){
return Some(tk);
}
}else if self.html_mode && ch == '>' {
//不正タグ 処理終了
return None;
}else if Self::is_id_start(ch){
return self.identifier(read);
}else if ch.is_ascii_digit() {
return self.digit(read);
}else if !ch.is_ascii() {
return self.none_ascii(read);
}else{
return Some(Token::Ch(ch))
}
}
None
}
fn none_ascii<'a>(&mut self, read : &mut Reader<'a>)
-> Option<Token<'a>> {
let start = read.sub_start();
while let Some(ch) = read.next() {
if ch.is_ascii() {
read.getback();
break;
}
}
Some(Token::NotA(read.sub_to_now(start)))
}
fn digit<'a>(&mut self, read : &mut Reader<'a>)
-> Option<Token<'a>> {
let start = read.sub_start();
while let Some(ch) = read.next() {
if !ch.is_ascii_digit() {
read.getback();
break;
}
}
Some(Token::Num(read.sub_to_now(start)))
}
fn identifier<'a>(&mut self, read : &mut Reader<'a>)
-> Option<Token<'a>> {
let start = read.sub_start();
while let Some(ch) = read.next() {
if !Self::is_id_ch(ch) {
read.getback();
break;
}
}
Some(Token::Id(read.sub_to_now(start)))
}
fn tag<'a>(&mut self, read : &mut Reader<'a>)
-> Option<Token<'a>> {
let start = read.sub_start();
let mut tn = String::new();
while let Some(ch) = read.next() {
if Self::is_space(ch) { read.getback();break; }
if ch == '>' { break; }
tn.push(ch);
}
if read.now() != '>' {
while let Some(ch) = read.next() {
if ch == '>' { break; }
}
}
if tn == "br" || tn == "br/" {
self.line_no += 1;
Some(Token::Ret(read.sub_to_now(start)))
}else{
None// <br>以外は削除
}
}
fn esc_next<'a>(&mut self, read : &mut Reader<'a>) -> Esc {
let mut r = String::new();
r.push('&');
while let Some(ch) = read.next() {
r.push(ch);
if ch == ';' {break; }
}
let r = r.to_lowercase();
if r == "&" { Esc::Amp }
else if r == "<" { Esc::Lt }
else if r == ">" { Esc::Gt }
else { Esc::Unkwn }
}
fn escape<'a>(&mut self, read : &mut Reader<'a>)
-> Option<Token<'a>> {
let esc = self.esc_next(read);
match esc {
Esc::Amp => Some(Token::Ch('&')),
Esc::Lt => Some(Token::Ch('<')),
Esc::Gt => Some(Token::Ch('>')),
_ => None,
}
}
fn space<'a>(&mut self, read : &mut Reader<'a>) -> Token<'a> {
let start = read.sub_start();
while let Some(ch) = read.next() {
if !Self::is_space(ch) {
read.getback();
break;
}
}
Token::Sp(read.sub_to_now(start))
}
fn crlf<'a>(&mut self, read : &mut Reader<'a>) -> Token<'a> {
let start = read.sub_start();
let ch = read.now();
if ch == '\r' {
if let Some(ch) = read.next() {
if ch != '\n' {
read.getback();//CR
}//else CRLF
}//else // CR[eof]
}//else LF
self.line_no += 1;
Token::Ret(read.sub_to_now(start))
}
}
ソースコード抜粋
今回作成したソースコードから重要、便利なものをいくつか紹介します。
JS preタグ以下のcodeタグの取得、書き換え
getElementsByTagNameを使用して指定したタグのDOMノード(HTMLElement)を取得します。対象が複数となるため配列が返ってきます。内容の書き換えは、ノードのinnerHTMLを使えば、ノード操作なしに内容を取得、更新できます。 取得(get)した場合、DOMノードをHTML文字列に変換し、書き込み(set)すると、逆に HTML文字列 をDOMノードに変換してくれます。ノード数が多いと結構時間がかかります。なので実際はsetTomeoutを使って、処理を分散するようにしてます。
// preタグのノード取得(配列)
let pres = document.getElementsByTagName('pre');
for(let p of pres) {//Edge17ではエラー
let codes = p.getElementsByTagName('code');
for(let c of codes) {
// codeノード以下をHTMLとして取得
let html_str = c.innerHTML;
// ハイライト処理 <span>タグ追加
let hilite_str = ハイライト処理();
// codeノードに書き戻し HTMLをノードに変換
c.innerHTML = hilite_str;
}
}
ページ読み込み後に処理開始
方法を調べるとonloadイベントで、という説明が多いのですが、今回は画像読み込みを待つ必要がなく、HTMLテキストが読み込まれた時点で実行可能なので、 DOMContentLoadedイベントを使用しました。 DOMの読み込み完了後にスクリプトを実行するとDOMContentLoadedイベントが発生しません。その場合 document.readyState で状態を調べ、 “loading” (読み込み中)ならイベント登録、終了していれば即時処理を実行するようにします。
document.addEventListener( 'DOMContentLoaded',
function() {
//DOMの読み込み完了時に呼ばれる
//初期化処理
}
);
// DOM読み込み状況確認
if(document.readyState == "loading"){
document.addEventListener('DOMContentLoaded', function() {
//DOMの読み込み完了時に呼ばれる
//初期化処理
});
}else{
// すでに読み込み済み DOMContentLoadedが発生しない
//初期化処理
}
type=”module”でのJavaScript読み込み
JavaScriptのtypeをmoduleにするとimportなどのモジュール機能が使用可能になります。これでグローバル変数名の衝突を気にする必要がなくなります。新しい機能なので古いブラウザでは使えませんが、使えないような古いブラウザはwasmにも対応していないので問題なし(?)。
機能demoでは以下のようなhtmlと起動用JavaScriptを使って、ハイライト処理を行っています。
index.html<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" type="text/css" href="./index.css" />
<link rel="stylesheet" type="text/css" href="./rshighlite/rshighlite.css" />
<!-- 起動用jsの読み込みと実行 -->
<script type='module' src = "./rshl_open.js"></script>
<title>rshighlite</title>
</head>
<body>
<pre><code>
ソースコード
</code></pre>
</body>
</html>
rshl_open.js// ハイライト処理のインポート
import * as rshighlite from "./rshighlite/rshighlite.js";
let root_path = "./rshighlite";
// 設定
let config = {
code_filter : function(code){
if("rshl_lang" in code.dataset){
if(code.dataset.rshl_lang == "nohl"){
return false;//処理しない
}
}
return true;
},
wasm_file : root_path + "/rshighlite.wasm",
default_rule : {
name : "default",
path : root_path + "/default.rshl",
},
// ファイルを1つにまとめる&バイナリー化予定
// メインルール
lang_rules : [
{
name : "cpp",
alias : ["c", "c++", ],
path : root_path + "/cpp.rshl",
},
{
name : "rust",
alias : ["rs", ],
path : root_path + "/rust.rshl",
},
{
name : "js",
alias : ["javascript", ],
path : root_path + "/javascript.rshl",
},
],
// サブルール 自動選択対象外
// <code data-lang="lang">があり、メインになければ読み込み
sub_lang_rules : [
{ name : "bat",
alias : ["cmd",],
path : root_path + "/bat.rshl",
css : {
code : "rshl-window-bat",
}
},
{ name : "toml",
alias : [],
path : root_path + "/toml.rshl",
},
{ name : "html",
alias : [],
path : root_path + "/html.rshl",
},
],
};
//初期化
rshighlite.Init(config);
//実行
rshighlite.Cook();
WordPressでtype=”module”
WordPressでJavaScriptを読み込む場合、functions.phpで読み込むファイルを登録しますが、そのままだとtype=”text/javascript”になりモジュール機能を使うとエラーになります。typeをmoduleにするには、同じくfunnctions.phpにフィルター処理を追加してスクリプトタグの内容を差し替えます。子テーマの使用をお勧めします。
function.phpに追加function rshighlite_load() {
if ( is_singular() ) {
wp_enqueue_style( 'rshighlite-style',
get_stylesheet_directory_uri() . '/rshighlite/rshighlite.css' );
wp_enqueue_script( 'rshighlite-script',
get_stylesheet_directory_uri() . '/rshl_open.js', array(), null, true );
}
}
add_action( 'wp_enqueue_scripts', 'rshighlite_load' );
add_filter( 'script_loader_tag', 'change_to_type_module', 10, 3 );
function change_to_type_module( $tag, $handle, $src ) {
if ( 'rshighlite-script' === $handle ) {
$tag = '<script type="module" src="' . esc_url( $src ) . '"></script>';
}
return $tag;
}
構文木と構文解析
当初の予定は、コメントや文字列の書式指定、特定キーワードの色付けなど簡単な仕様にするつもりでしたが、だんだん欲が出てきて汎用性があるものに作り変えました。boost spiritの悪夢(コンパイルが遅い、実行速度はもっと遅い)を以前体験したので、ライブラリは使わず自作、下記のような構文木を定義するスクリプトができました。大変でしたが良いものに仕上がったと思います。 EBNF(拡張バッカス・ナウア記法)の機能制限と拡張と表現を変えたものといっていいのか?
構文解析は、構文木のノードに文字列(spanのclass名=色)を割り当て、字句がどのノードに対応するか解析することでソースコードの色付けを行う仕様です。 ノードは自己参照、循環参照ができるので再帰処理も可能です。言語仕様を正確に記述すると処理が重くなるし大変なので、ハイライト処理に特化した緩い文法にしています。
htmlハイライト構文木(作成中)ident = Seq(
AnyId(hl_class),
M0_( Seq(
Ch(hl_class,'-'),
AnyId(hl_class),
)),
);
skp0_ = SKP0_(nohl);
skp1_ = SKP1_(nohl);
ident_tag = Seq(
AnyId(hl_kwd),
M0_( Seq(
Ch(hl_kwd,'-'),
AnyId(hl_kwd),
)),
);
tag = Seq(
Ch(nohl, '<'),
M0_1( Or(
Ch(nohl, '/'), Ch(nohl, '!'),
) ),
skp0_,
ident_tag,
M0_( Seq(
skp1_, ident,
skp0_, Ch(nohl, '='), skp0_,
Or(qout_string, apos_string )
)),
skp0_,
M0_1(Ch(nohl, '/')),
Ch(nohl, '>'),
);
doctype = Seq(
Chs(nohl, "<!"),
AnyId(hl_kwd),
M0_( Seq (
skp1_,
Or( ident, qout_string, apos_string ),
) ),
skp0_,
Ch(nohl, '>'),
);
html_comment = FROM_TO(
hl_cmnt,
Chs(hl_cmnt, "<!--"),
Chs(hl_cmnt, "-->"),
);
escape = Seq(
Ch(hl_kwd,'&'),
AnyId(hl_kwd),
Ch(hl_kwd,';'),
);
ROOT = IF_ELSE(
IF( AnyCh(nohl), Or(
doctype,
tag,
html_comment,
escape,
qout_string, apos_string,
),),
IF( AnyNum(nohl), loose_number ),
);
ダウンロード
Rustのソースコードがまだ中途半端なので、機能デモの内容をまとめたファイル(Javascriptとwasmバイナリ)だけの公開です。
“rshighlite ソースコードのハイライト表示プラグインデモ” をダウンロード rshighlite_demo.zip – 66 回のダウンロード – 60 KB
今後の目標
GitHub
動作確認とソースコードの整理をしてから正式な公開
構文木のスクリプトをバイナリ化
コンパイルした結果を読み込みことで、高速化とコンパイル関連の削除によるコードサイズ削減。wasmファイル70~80kbを目標。あとスクリプトファイルが増えてきたのでバイナリ化して1つにまとめる。
構文木と構文解析のクレート作成、公開
もう少し改良してからRustのクレートとして公開。
WebGLのほうも進めたいのでこのサイトで動作確認しながら、余裕ができたら取り掛かる予定です。