2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

自作言語のオンラインチュートリアルを作ってみた【Pangaea Travel Guide】

Posted at

TL;DR

  • 自作プログラミング言語 Pangaea のチュートリアルを作成
  • UIはSvelteとsvelte-routingでSPA化
  • ソースコードを実際に書きながら文法が学べる!
    • 覚えても使う場面無くない?

はじめに

新しい言語を触るとき、ただ眺めるより実際にコードを書いた方が文法を覚えやすいですよね。特に、手を動かしながら学べるチュートリアルがあると、言語がぐっと身近になります。

(いつもお世話になってます)

というわけで、自作言語でもチュートリアルを作ってみました。これで布教しやすくなったぜ

デモ

ページごとに1つ文法要素を紹介し、サンプルコードをいじりながら実行できるようになっています。

pangaea_travel_guide.gif

Pangaea言語自体については過去の記事をご覧ください。

構成

  • Pangaeaのインタープリター: WebAssembly (Goで記述)
  • UI: Svelte
  • Deploy: GitHub Pages

インタープリターをwasmにすることでサーバーサイドの処理が不要になり、静的サイトとしてデプロイ可能になっています。

コードの実行

GoコードをWebAssemblyにコンパイルして読み込んでいます。こちらは過去に作ったオンライン実行環境 Pangaea Playground から流用しています。

ただし、上記の方法ではインタープリター実行関数をglobal変数pangaeaに定義してしまっているので、こちらでは改めてラッパー関数を作成しています。

src/pangaea/pangaea.ts
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化して、パスに応じて説明文とサンプルコードだけ差し替えています。
(デザイン等の共通要素をページごとにコピペしなくて良いのもメリットです)

src/App.svelte
<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要素として渡しています。

src/Explanation.svelte
<!-- 説明文の枠組み -->
<div>
  <!-- ここに<Explanation>の子要素が入る -->
  <slot />
</div>

<style>
  /* スタイルはこちらで一括定義。各ページのコンポーネントでは指定不要 */
</style>
src/pages/Introduction.svelte
<!-- イントロダクションページ -->
<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をグローバル変数的に使っているので、ちょっとお行儀が悪いかもしれませんね... :sweat:

src/pages/Introduction.svelte
<!-- 各ページ読み込み時に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>

<!-- 以下説明文 -->
src/pages/codestore.ts
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();
src/CodeArea.svelte
<!-- コード表示/入力エリア -->
<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ボタン

チュートリアルに「次のページ」「前のページ」のリンクは欠かせません。しかし、これらのリンク先は状態を持つため、動的に指定する必要があります。上手い方法が思いつかなかったので、ページの順序を定義した配列を用意し前後のページを計算しています。

src/pages/pagelinks.ts
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;
  }
}
src/Header.svelte
<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文字列に変換して読み込んでいるようです(まだコード追い切れていない)。

ロゴ

以下のサイトを使用させていただきました。

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も使ってみたいと思います。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?