TL;DR
- 自作プログラミング言語 Pangaea のチュートリアルを作成
- UIはSvelteとsvelte-routingでSPA化
- ソースコードを実際に書きながら文法が学べる!
覚えても使う場面無くない?
はじめに
新しい言語を触るとき、ただ眺めるより実際にコードを書いた方が文法を覚えやすいですよね。特に、手を動かしながら学べるチュートリアルがあると、言語がぐっと身近になります。
(いつもお世話になってます)
というわけで、自作言語でもチュートリアルを作ってみました。これで布教しやすくなったぜ
デモ
ページごとに1つ文法要素を紹介し、サンプルコードをいじりながら実行できるようになっています。
Pangaea言語自体については過去の記事をご覧ください。
構成
- Pangaeaのインタープリター: WebAssembly (Goで記述)
- UI: Svelte
- svelte-routingを使いSPA化
- Deploy: GitHub Pages
インタープリターをwasmにすることでサーバーサイドの処理が不要になり、静的サイトとしてデプロイ可能になっています。
コードの実行
GoコードをWebAssemblyにコンパイルして読み込んでいます。こちらは過去に作ったオンライン実行環境 Pangaea Playground から流用しています。
ただし、上記の方法ではインタープリター実行関数をglobal変数pangaea
に定義してしまっているので、こちらでは改めてラッパー関数を作成しています。
type PangaeaResult = {
res: string;
stdout: string;
errmsg: string;
};
// 処理結果の文字列を返す
export function run(source: string, input: string): string {
// NOTE: global object `pangaea` は main.wasm によって作成される
const res: PangaeaResult = pangaea.execute(source, input);
if (res.errmsg !== '') {
return res.errmsg;
} else {
return res.stdout;
}
}
ページ
チュートリアルには、ページごとに説明文とサンプルコードが必要です。一方、Pangaeaのインタープリターは起動に3~4秒かかってしまうので、ページ遷移の度にリロードするのはストレスが溜まります。
そこで、svelte-routingでwebサイトをSPA化して、パスに応じて説明文とサンプルコードだけ差し替えています。
(デザイン等の共通要素をページごとにコピペしなくて良いのもメリットです)
<script lang="ts">
import {Router, Route} from 'svelte-routing';
import Codearea from './Codearea.svelte';
import Explanation from './Explanation.svelte';
import Header from './Header.svelte';
import IntroductionPage from './pages/Introduction.svelte';
import HelloWorldPage from './pages/HelloWorld.svelte';
// ...他のページもimport
import {BASEPATH} from './consts.js';
</script>
<!-- Routerコンポーネントは、pathが一致するRouteコンポーネントのみレンダリング -->
<Router basepath={BASEPATH}>
<main>
<Header />
<div class="flex">
<Explanation>
<!-- path `/{BASEPATH}/`のとき、トップページIntroductionPage表示 -->
<Route path="" component={IntroductionPage} />
<!-- path `/{BASEPATH}/helloworld`のとき、 helloworldのページHelloWorldPage表示 -->
<Route path="helloworld" component={HelloWorldPage} />
<!-- ... (以下他のページも) -->
</Explanation>
<Codearea />
</div>
</main>
</Router>
パスは実際にリクエストされるわけではなく、History APIでページ移動しています(全ページindex.html
で完結)。
参考
説明文の流し込み
上記 App.svelte
のように、各ページのRoute
コンポーネントを説明文の枠組みのExplanation
コンポーネントのslot
要素として渡しています。
<!-- 説明文の枠組み -->
<div>
<!-- ここに<Explanation>の子要素が入る -->
<slot />
</div>
<style>
/* スタイルはこちらで一括定義。各ページのコンポーネントでは指定不要 */
</style>
<!-- イントロダクションページ -->
<h1>Introduction</h1>
<p>
Welcome to <i>Pangaea Travel Guide!</i><br />
</p>
<p>
This is a hands-on tutorial website for Pangaea programming language. You can
edit <strong>source code</strong> and <strong>input</strong> area on the right
side, and run them by <strong>run</strong> button. They are evaluated locally by
the Pangaea interpreter written in WebAssembly.
</p>
<p>
If you want to run your own codes freely, try <a
href="https://syuparn.github.io/Pangaea/">Pangaea Playground</a
>
instead. Also, you can download a Pangaea binary from
<a href="https://github.com/Syuparn/Pangaea">the language repository</a>.
</p>
注意点として、slot
内の子コンポーネントにstyleを適用する場合は :global(...)
modifierを使う必要があります(Svelteでは、コンポーネントごとに独立したstyleを持っているため)。
コードの流し込み
コードも説明文のように流し込もうと思ったのですが、
- コード実行画面は説明文と別コンポーネントなので、直接
Route
を入れられない - htmlではなく文字列を渡したい
という理由からstoreを使用しました。
storeは普通 on:click
等のイベントで更新しますが、ここではscript
タグ内に更新用関数を書くことでコンポーネント読み込み時(=ページ移動時)に更新しています。
(storeをグローバル変数的に使っているので、ちょっとお行儀が悪いかもしれませんね... )
<!-- 各ページ読み込み時にstore更新 -->
<script lang="ts">
import dedent from 'ts-dedent';
import {code} from './codestore.js';
// 説明文に合わせたコードに差し替え
code.insert(
// source
dedent`
# you can see and edit source code here
"Hello, world!".p
`,
// input
`some input to read`
);
</script>
<!-- 以下説明文 -->
import {writable} from 'svelte/store';
import {run} from '../pangaea/pangaea.js';
type Code = {
source: string;
input: string;
output: string;
};
function createCode() {
const {subscribe, set, update} = writable<Code>({
source: '',
input: '',
output: '',
});
return {
subscribe,
// コード、入力、出力の文字列を差し替え
insert: (source: string, input: string) => set({source, input, output: ''}),
// Pangaeaインタープリターを実行し、その結果をoutputに格納
run: () =>
update(({source, input}) => ({
source,
input,
output: run(source, input),
})),
};
}
export const code = createCode();
<!-- コード表示/入力エリア -->
<script lang="ts">
import Input from './Input.svelte';
import Output from './Output.svelte';
import RunButton from './RunButton.svelte';
import {code} from './pages/codestore.js';
</script>
<div id="container">
<!-- storeをsubscribeすることでコード更新を逐次反映
(bindすることで、ユーザーがtextareaを書き換えた内容もstoreに反映している) -->
<p class="title">source code</p>
<Input rows={10} bind:text={$code.source} />
<p class="title">input</p>
<Input rows={1} bind:text={$code.input} />
<p class="button-row"><RunButton on:click={code.run} /></p>
<Output text={$code.output} />
</div>
next, backボタン
チュートリアルに「次のページ」「前のページ」のリンクは欠かせません。しかし、これらのリンク先は状態を持つため、動的に指定する必要があります。上手い方法が思いつかなかったので、ページの順序を定義した配列を用意し前後のページを計算しています。
import {BASEPATH} from '../consts.js';
// ページのパスをチュートリアル進行順に格納
const pages = [
'',
'helloworld',
'objects',
// ...
];
class Page {
constructor(private _page: string) {}
next(): Page {
const i = pages.indexOf(this._page);
if (i === -1 || i === pages.length - 1) {
return new Page('');
}
return new Page(pages[i + 1]);
}
back(): Page {
// next同様
}
page(): string {
return this._page;
}
}
<script lang="ts">
import {Link} from 'svelte-routing';
import LinkButton from './LinkButton.svelte';
import {pageLink} from './pages/pagelinkstore.js'; // Pageのstore
</script>
<header>
<LinkButton link={$pageLink.back().page()} text="back" />
<LinkButton link={$pageLink.next().page()} text="next" />
</header>
現在のパスを location.pathname
で取得して、そこからnext,backのパスを生成しています。svelte-routingの機能でも現在のパスを取得できるようなのですが、上手く動きませんでした。
流石にごり押しが過ぎたので、SvelteKitやSapperなどを入れて管理した方が良いですね...
参考: Svelte Tutorialはどうやってページを切り替えている?
どうやらmarkdownファイル群をhtml文字列に変換して読み込んでいるようです(まだコード追い切れていない)。
-
content/tutorial
配下のmarkdownを読み込み、html文字列に変換して1つのjsonにまとめる - そのjsonを読み込み
tutorial
コンテキストにセット -
tutorial
コンテキストを取り出しマップに格納 - ページ名をキーに内容をマップから取得
ロゴ
以下のサイトを使用させていただきました。
Google Fontsを使ったsvg形式のロゴ画像を出力可能です。(svgなので拡大してもにじみません!)
travel guideでは「Oleo Script」を使用しました。
はまったところ
トップページ以外に直接アクセスするとNotFoundになる
SPAなのでよく考えたら当然です。パスはsvelte-routingがHistory APIを使って見せているにすぎず、実際の静的サイトは /index.html
にしか存在しません。
そこで、GitHub PagesのNotFoundページに以下のような細工をすることでページアクセスできるようにしました。
- NotFoundページ:パスをクエリに詰め直してトップページに移動
- トップページ:クエリをパスに戻してsvelte-routingで所定のページを表示
(アイデアはこちらの記事を参考にさせていただきました)
試しに https://syuparn.github.io/pangaea-travel-guide/helloworld に繋いでみると、一瞬だけクエリパラメータが表示されると思います。
code
タグを使うと警告が出る
<code> will be treated as an HTML element unless it begins with a capital letter
という警告が出てしまいました。
たまたまscript
タグ内でもcode
という変数をimportしていたため、「コンポーネント Code
とタイポしてない?」(コンポーネントタグは普通のhtmlタグと区別するため大文字始まり必須)と教えてくれているのですが、code
タグを使うたびに出ると大事な警告を見落としてしまいます。
issueも上がっていて、現在対応中のようです。
さしあたり<code>
を<span class="code">
に置き換えて対処しています。
おわりに
Svelteを使うのは初めてだったのですが、構文がシンプルですぐに書き始めることができました。
ページの管理が煩雑になってきたので、今後はSvelteKitも使ってみたいと思います。