ブログ的なもの作り-投稿・表示・編集

(144d) 更新


プログラミング/svelte

ブログ的なものを作ってみる

データベースの準備

編集履歴をすべて残すため、記事が書き換えられた場合にも新しいレコードを追加するのみ。

  • 古いレコードの newRevisionId に新しいレコードの id を持たせる
    • newRevisionId != null の記事は古いものということ
  • 削除済みの記事には deletedAt に日付が入る

prisma/schema.prisma

  model User {
    ...

    roles         Role[]
+   articles      Article[]
  }
  ...

+ model Article {
+   id Int @id @default(autoincrement())
+   author User @relation(fields: [authorId], references: [id]) 
+   authorId String @map("author_id")
+   title String
+   body String
+   newRevisionId Int? @unique @map("new_revision_id")
+   oldRevision Article? @relation("ArticleRevision", fields: [newRevisionId], references: [id])
+   newRevision Article? @relation("ArticleRevision")
+   createdAt DateTime @default(now()) @map("created_at")
+   deletedAt DateTime? @map("deleted_at")
+   attachments Attachment[]
+ 
+   @@index([deletedAt, title, createdAt])
+   @@index([newRevisionId, deletedAt, title])
+   @@index([newRevisionId, deletedAt, createdAt])
+ }
+ 
+ model Attachment {
+   id String @ id @default(uuid())
+   article Article @relation(fields: [articleId], references: [id])
+   articleId Int
+   body Bytes
+   createdAt DateTime @default(now()) @map("created_at")
+   modifiedAt DateTime @default(now()) @map("modified_at")
+ 
+   @@index([articleId])
+ }

newRevisionId がなく deletedAt もないものが最新の記事ということになる

newRevision の投稿時に Attachment をどのように引き継ぐかが問題?

どうせ表示の時にしか必要にならないので、表示の際に古いものも含めて全部列挙すればそれでよいかも?

LANG: console
$ pnpm prisma migrate dev --name "add Article and Attachment tables"
 Environment variables loaded from .env
 Prisma schema loaded from prisma\schema.prisma
 Datasource "db": SQLite database "dev.db" at "file:./dev.db"
 
 Applying migration `20231130003510_add_article_and_attachment_tables`
 
 The following migration(s) have been created and applied from new schema changes:
 
 migrations/
   └─ 20231130003510_add_article_and_attachment_tables/
     └─ migration.sql
 
 Your database is now in sync with your schema.
 
 ✔ Generated Prisma Client (v5.6.0) to .\node_modules\.pnpm\@prisma+client@5.6.0_prisma@5.6.0\node_modules\@prisma\client in 199ms

routes

  • 投稿 /articles/(loggedIn)/new
  • 一覧表示 /articles
  • 記事表示 /articles/[...titleOrId]
  • 編集 /articles/[id]/(loggedIn)/edit
  • 削除 /articles/[id]/(loggedIn)/delete

投稿画面

新たに TextArea を作成した。

src/lib/components/TextArea.svelte

LANG: ts
<script lang="ts">
  import type { Writable } from 'svelte/store';

  export let name: string;
  export let label: string;
  export let labelAlt: string = '';
  export let value: string;
  export let disabled = false;
  export let errors: Writable<{}> | undefined = undefined;
  export let props: svelteHTML.IntrinsicElements['textarea'] | undefined = undefined;

  let key = name as keyof typeof errors;
</script>

<div class="form-control w-full">
  <label for={name} class="label">
    <span class="label-text">{label}</span>
    {#if labelAlt}<span class="label-text-alt">{labelAlt}</span>{/if}
  </label>
  <textarea
    {...{ name, ...props }}
    bind:value
    {disabled}
    on:input
    class="w-full textarea textarea-bordered textarea-primary"
    class:input-error={errors && $errors && $errors[key]}
  />
  {#if errors && $errors && $errors[key]}
    <label class="label" for={name}>
      <span class="label-text-alt text-error">{$errors[key]}</span>
    </label>
  {/if}
</div>

後で見た目をもっと凝ったものにしたいけど今はこれで。

src/routes/articles/(login)/new/+page.svelte

LANG: html
<script lang="ts">
  import type { PageData } from './$types';
  import { superForm } from 'sveltekit-superforms/client';
  import Form from '$lib/components/Form.svelte';
  import InputText from '$lib/components/InputText.svelte';
  import TextArea from '$lib/components/TextArea.svelte';
  import Button from '$lib/components/Button.svelte';

  export let data: PageData;
  const { form, message, errors, submitting, capture, restore, enhance } = superForm(data.form, {
    taintedMessage: false,
  });
  export const snapshot = { capture, restore };
</script>

<div>
  <h1>記事の投稿</h1>
  <Form message={$message} {enhance}>
    <InputText
      name="title"
      label="タイトル"
      bind:value={$form.title}
      disabled={$submitting}
      {errors}
    />

    <TextArea
      name="body"
      label="本文"
      bind:value={$form.body}
      disabled={$submitting}
      {errors}
      props={{ style: 'height: 30em; overflow-y: scroll' }}
    />

    <Button disabled={$submitting}>記事を投稿</Button>
    <div>
      <!-- svelte-ignore a11y-invalid-attribute -->
      <a class="link" href="javascript:history.back()">トップへ戻る</a>
    </div>
  </Form>
</div>

src/lib/zod/articles/new.ts

LANG: ts
import { z } from 'zod';

export const schema = z.object({
  title: z.string().regex(/[^0-9]/, {
    message: 'タイトルが空あるいは数値のページは作成できません',
  }),
  body: z.string(),
});

タイトルと本文と作者を設定してレコードを作成する。

src/routes/articles/(login)/new/+page.server.ts

LANG: ts
import type { Actions, PageServerLoad } from './$types';
import { schema } from '$lib/zod/articles/new';
import { superValidate } from 'sveltekit-superforms/server';
import { fail, redirect } from '@sveltejs/kit';
import { setFlash } from 'sveltekit-flash-message/server';
import { path, addErrorToForm } from '$lib/server';
import { db } from '$lib/server/db';

export const load = (async () => {
  const form = await superValidate(schema);
  return { form };
}) satisfies PageServerLoad;

export const actions = {
  default: async (event) => {
    // フォームデータのバリデーション
    const form = await superValidate(event, schema);
    if (await db.article.findFirst({ where: { title: form.data.title } })) {
      addErrorToForm(form, 'title', '既存のページと重複しています');
    }
    if (!form.valid) {
      return fail(400, { form });
    }

    // 記事を投稿する
    const article = await db.article.create({
      data: {
        authorId: event.locals.session!.user.userId,
        ...form.data,
      },
    });

    setFlash({ type: 'success', message: '記事を投稿しました' }, event);
    throw redirect(302, path(article));
  },
} satisfies Actions;
LANG: console
$ git add . && git commit -m "Article の投稿を可能にした"

記事表示用のアドレス

実は上のコードの最後は path(article) という呼び出しを行っていて、 これがまだ実装されていない。

個々の記事を表示するためのアドレスはこの形にしたい。

/articles/[...titleOrId]

[...titleOrId] には記事番号あるいは encodeURI されたタイトルが入る。

スプレッド構文的に見えるため params.titleOrId は文字列配列になるのかと思ったけど、 実際に渡されるのは "/" を含む可能性のある「文字列」だった。

params.titleOrId が

  1. 数字だけを含むなら記事番号(id)と解釈する
  2. それ以外ならタイトルと解釈する

2. を正式なアドレスとしたいので、数字でアクセスした場合にも タイトル表記のアドレスへリダイレクトする

記事のタイトルが変更された場合に古いタイトルでアクセスしてきたなら 最新のページにリダイレクトする

タイトルのエンコード・デコード

さて、タイトルには特殊文字が含まれるのでそのまま URL としては使えない

英語が主に使われている場所では slug 化してアドレスに直すことが多いのだけれど、 今の用途では title は日本語であることが多いのでそのまま encodeURI してしまおう。

  • encodeURI
    • UTF8ベースのエンコード
    • その名の通り、URIそのものを渡しエンコードしてもらうもの
    • そのため、URI内で意味をもつ #$&+,/:;=?@ の文字は変換されない
    • スペースのエンコードは+ではなく%20
  • encodeURIComponent
    • UTF-8ベースのエンコード
    • その名の通り、URIを構成する一部分を渡しエンコードしてもらうもの
    • URI内で意味を持つ #$&+,/:;=?@ もエンコードする
    • encodeURIとの差は #$&+,/:;=?@ をエンコードするか否か
    • URI全体をこの関数でエンコードすると、URIとして機能しなくなる

encodeURI だけだと ?&# などパスに含まれちゃいけない文字がそのまま残るので、 "/" でスプリットして個別に encodeURIComponent/decode するのが正しい。

一方、そのまま encodeURIComponent を使うと スペースが "%20" になってしまって不格好なので、 最後に "%20" を "+" に変換する。

元々の % は %25 にエンコードされるので、何も考えず "%20" を "+" に置き換えて構わない。

LANG: ts
title.split('/')
     .map(str=> encodeURIComponent(str).replaceAll('%20', '+'))
     .join('/')

戻すときは "+" を "%20" にしてから decodeURIComponent する。

LANG: ts
encoded.split('/')
       .map(str=> decodeURIComponent(str.replaceAll('+', '%20')))
       .join('/')

ユーティリティ関数

以上を実現するのに使うユーティリティ関数を次のように実装した。

  • articleTitleEncode(title) エンコードする
  • articleTitleDecode(encoded) デコードする
  • newestArticle(article) 最新版を探して返す

src/lib/server/db.ts

LANG: ts
  import { PrismaClient, type Article, type User } from '@prisma/client';
  ...

  articleTitleEncode(title: string) {
    return title.split('/')
                .map(str=> encodeURIComponent(str).replaceAll('%20', '+'))
                .join('/');
  }

  articleTitleDecode(encoded: string) {
    return encoded.split('/')
                  .map(str=> decodeURIComponent(str.replaceAll('+', '%20')))
                  .join('/');
  }

  async newestArticle(article: (Article & { author: User }) | number | null) {
    if (!article) { return null; }
    while (typeof article == 'number' || article?.newRevisionId) {
      article = await this.article.findUnique({
        where: {
          id: typeof article == 'number' ? article : article.newRevisionId!,
          deletedAt: null,
        },
        include: { author: true },
      });
    }
    return article;
  }

これを使って path 関数に Article を指定可能にした。

src/lib/server/index.ts

LANG: ts
import type { Article } from '@prisma/client';
import { db } from '$lib/server/db';

export const urlRoot = process.env['URL_ROOT'] || '';
export function path(relative: string | Article) {
  if (typeof relative == 'string') {
    return urlRoot + relative;
  } else {
    // if (Object.hasOwn(relative, 'newRevisionId')) {
    return urlRoot + '/articles/' + db.articleTitleEncode(relative.title);
  }
}
LANG: console
$ git add . && git commit -m "Article 記事の URL 生成ヘルパー"

記事の簡易表示

src/routes/articles/[...titleOrId]/+page.server.ts

LANG: ts
import type { PageServerLoad } from './$types';
import { error, redirect } from '@sveltejs/kit';
import { path } from '$lib/server';

import { db } from '$lib/server/db';

export const load = (async (event) => {
  if (event.params.titleOrId.match(/^\d+$/)) {
    // 数値で指定なら該当記事にリダイレクト
    const article = await db.newestArticle(Number(event.params.titleOrId));
    if (!article) throw error(404);
    throw redirect(302, path(article));
  }
  // タイトルで指定
  const article = await db.article.findFirst({
    where: {
      deletedAt: null,
      title: decodeURI(event.params.titleOrId),
    },
    orderBy: {
      createdAt: 'desc',
    },
    include: { author: true },
  });
  if (!article) throw error(404);

  // 最新版があればリダイレクト
  const newest = await db.newestArticle(article);
  if (newest?.id != article.id) {
    throw redirect(302, path(newest!));
  }
  return { article };
}) satisfies PageServerLoad;

表示はとりあえず <pre></pre> にソーステキストをそのまま貼っておく

tailwindcss はそのまま使うと何もかもがリセットされていてアレなのだけれど、 tailwindcss/typography を入れてあるので class="prose" で囲った領域は まともな表示(例えば h1 が大きくなるなど)になる。ただし maxWidth が 65ch に設定されてしまうため min-w-full でキャンセルしておく。

src/routes/articles/[...titleOrId]/+page.svelte

LANG: html
<script lang="ts">
  import type { PageData } from './$types';

  export let data: PageData;
</script>

<article class="prose min-w-full p-4">
  <h1>{data.article.title}</h1>
  <pre>
    {data.article.body}
  </pre>
</article>
LANG: console
$ git add . && git commit -m "Article の簡易表示ページを作成"

markdown を解釈

記事は markedown で書いて marked で html に直して表示する。

https://marked.js.org/using_advanced#extensions

を見るといろいろ Extention があって、それらを入れるだけで highlight や katex も処理できるみたい。

何それすごい。

気になるのはいっぱいあるけど、さしあたり base-url, linkify-it, highlight, katex を入れてみる。

LANG: console
$ pnpm add marked marked-base-url marked-linkify-it marked-highlight marked-katex-extension
 Packages: +11
 +++++++++++
 Progress: resolved 390, reused 360, downloaded 7, added 7, done
 
 dependencies:
 + marked 11.0.0
 + marked-base-url 1.1.2
 + marked-highlight 2.0.8
 + marked-katex-extension 4.0.5
 + marked-linkify-it 3.1.7
 
  WARN  Issues with peer dependencies found
 .
 └─┬ marked-linkify-it 3.1.7
     └── ✕ unmet peer marked@">=4 <11": found 11.0.0
 
 Done in 4.5s

linkify-it が marked 11 には対応していないと言っている。

https://github.com/UziTech/marked-linkify-it/commit/08019477c049181e14e9c83bc40171270b4c46dc

ここに v11 を許すようにしたというコミットがあった。

不具合があれば marked のバージョンを落とそう。

別途 highlight.js を入れる必要があった。

src/app.html に1行追加は katex のため:

LANG: html
+     <link rel="stylesheet" 
+           href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" 
+           integrity="sha384-n8MVd4RsNIU0tAv4ct0nTaAbDJwPJzDEaqSD1odI+WdtXRGWt2kTvGFasHpSy3SV"
+           crossorigin="anonymous">

content="module" で marked の初期化をしたら、

{@html marked.parse(data.article.body)}

だけで変換が完了する。

src/routes/articles/[...titleOrId]/+page.svelte

LANG: html
<script lang="ts" context="module">
  import { marked } from "marked";
  import { baseUrl } from "marked-base-url";
  import markedLinkifyIt from "marked-linkify-it";
  import { markedHighlight } from "marked-highlight";
  import hljs from 'highlight.js';
  import markedKatex from "marked-katex-extension";

  import 'highlight.js/styles/stackoverflow-dark.min.css';

  marked.use(baseUrl("https://example.com/folder/"));
  const linkifyItSchemas = {};
  const linkifyItOptions = {};
  marked.use(markedLinkifyIt(linkifyItSchemas, linkifyItOptions));
  marked.use(markedHighlight({
    langPrefix: 'hljs language-',
    highlight(code, lang) {
      const language = hljs.getLanguage(lang) ? lang : 'plaintext';
      return hljs.highlight(code, { language }).value;
    }
  }));
  marked.use(markedKatex({
    throwOnError: false
  }));
</script>

<script lang="ts">
  import type { PageData } from './$types';
  export let data: PageData;
</script>

<article class="prose max-w-full p-4">
  <h1>{data.article.title}</h1>
  <div>
    <span>{data.article.author.name}</span>
    <span>{data.article.createdAt.toLocaleString()}</span>
  </div>
  <content>
    {@html marked.parse(data.article.body)}
  </content>
</article>

marked-highlight は言語指定がちゃんとしてないと働かないようで、 ちょっと思ったのと違いそう?

後で調整することにして、とりあえず編集機能を先に実装しよう。

LANG: console
$ git add . && git commit -m "Article の表示用に marked を入れた"

編集機能を実装

ログインユーザーのみが

/articles/[...titleOrId]/edit

というアドレスで編集できるようにする。

表示ページに編集へのリンクを付ける:
現在の URL を取得するには import { page } from '$app/stores'; を使う

src/routes/articles/[...titleOrId]/+page.svelte

LANG: html
+   import { page } from '$app/stores';
  ...
 
  <article class="prose max-w-full p-4">
    <h1>{data.article.title}</h1>
    <div>
      <span>{data.article.author.name}</span>
      <span>{data.article.createdAt.toLocaleString()}</span>
+     <span><a href={$page.url + '/edit'}>編集</a></span>

ページタイトルの末尾に /edit などの動詞が含まれるとダメなので排除する。

src/lib/zod/articles/new.ts

LANG: ts
    title: z.string().regex(/[^0-9]/, {
      message: 'タイトルが空あるいは数値のページは作成できません',
+   }).regex(/(?<!\/(edit|rename|delete)$)/, {
+     message: 'タイトルが /edit, /rename, /delete で終わるページは作成できません'
    }),

titleOrId から Article を探す部分を切り出す

src/routes/articles/[...titleOrId]/articleFromTitleOrId.ts

LANG: ts
import { error } from '@sveltejs/kit';
import { db } from '$lib/server/db';

export async function articleFromTitleOrId(titleOrId: string) {
  // 数値で指定なら該当記事にリダイレクト
  if (titleOrId.match(/^\d+$/)) {
    const article = await db.newestArticle(Number(titleOrId));
    if (!article) throw error(404);
    return { article, needRedirect: true };
  }
  // タイトルで指定
  const article = await db.article.findFirst({
    where: {
      deletedAt: null,
      title: decodeURI(titleOrId),
    },
    orderBy: {
      createdAt: 'desc',
    },
    include: { author: true },
  });
  if (!article) throw error(404);

  // 最新版があればリダイレクト
  const newest = await db.newestArticle(article);
  return { article, needRedirect: newest?.id != article.id };
}

src/routes/articles/[...titleOrId]/+page.server.ts

LANG: ts
  import type { PageServerLoad } from './$types';
  import { redirect } from '@sveltejs/kit';
  import { path } from '$lib/server';
* import { articleFromTitleOrId } from './articleFromTitleOrId';

  export const load = (async (event) => {
*   const { article, needRedirect } = 
*     await articleFromTitleOrId(event.params.titleOrId);
*   if(needRedirect) {
*     throw redirect(302, path(article));
*   }
    return { article };
  }) satisfies PageServerLoad;

これと articles/new/+page.server.ts からコピペしてちょちょっと直す。

新しいページを投稿してその値を newRevisionId に入れるには、 普通ならトランザクションを使うのだけれど Prisma では nested-write を使うのが筋みたい。

ただ、なぜか思った通りの動作にならない

LANG: ts
   const oldArticle = await db.article.update({
     where: { id: article.id },
     data: {
       newRevision: {
         create: {
           authorId: event.locals.session!.user.userId,
           ...form.data,
         },
       },
     },
     include: { newRevision: true },
   });

これで article.id の記事の newRevision に新しい記事のが入ることを期待したのだけれど、 逆に新しい記事の newRevision に古い記事が入ってしまう。わけわからん。たぶんバグ???

include がまずいのかと思って外してみたけど変わらない。

仕方がないのでアトミックじゃない書き方で我慢した。

src/routes/articles/[...titleOrId]/(login)/edit/+page.server.ts

LANG: ts
import type { Actions, PageServerLoad } from './$types';
import { schema } from '$lib/zod/articles/new';
import { superValidate } from 'sveltekit-superforms/server';
import { fail, redirect } from '@sveltejs/kit';
import { setFlash } from 'sveltekit-flash-message/server';
import { path, addErrorToForm } from '$lib/server';
import { db } from '$lib/server/db';
import { articleFromTitleOrId } from '../../articleFromTitleOrId';

export const load = (async (event) => {
  // article を取り出して、必要なら最新版のページへ飛ぶ
  const { article, needRedirect } = await articleFromTitleOrId(event.params.titleOrId);
  if (needRedirect) {
    throw redirect(302, path(article) + '/edit');
  }

  const form = await superValidate(schema);
  form.data.title = article.title;
  form.data.body = article.body;
  return { form };
}) satisfies PageServerLoad;

export const actions = {
  default: async (event) => {
    // フォームデータのバリデーション
    const form = await superValidate(event, schema);

    const { article, needRedirect } = await articleFromTitleOrId(event.params.titleOrId);
    if (needRedirect) {
      form.message = '編集が衝突しました。最新版のページからやり直してください。';
      form.valid = false;
    }

    const pageWithSameTitle = await db.article.findFirst({
      where: { title: form.data.title, newRevisionId: null, deletedAt: null },
      orderBy: { createdAt: 'desc' },
    });
    if (pageWithSameTitle && pageWithSameTitle.id != article.id) {
      addErrorToForm(form, 'title', '既存のページと重複しています');
    }

    if (!form.valid) {
      return fail(400, { form });
    }

    if (form.data.title == article.title && form.data.body == article.body) {
      setFlash({type: 'error', message: '何も変更されませんでした'}, event);
      throw redirect(302, event.url.toString().replace(/\/edit$/, ''));
    }

    // 記事を投稿して古い記事の newRevision に入れる
    // const newArticle = await db.article.create({
    //   data: {
    //     authorId: event.locals.session!.user.userId,
    //     oldRevision: article,
    //     ...form.data,
    //   }
    // });
    // oldRevision には代入できないみたいだ

    // これで良さそうなのだけれどなぜか新しくできた方の
    // newRevision に古い方が入ってしまう。えー。
    //
    // const oldArticle = await db.article.update({
    //   where: { id: article.id },
    //   data: {
    //     newRevision: {
    //       create: {
    //         authorId: event.locals.session!.user.userId,
    //         ...form.data,
    //       },
    //     },
    //   },
    //   include: { newRevision: true },
    // });

    // 仕方がないのでデータの整合性が失われる可能性のある書き方で我慢

    const newArticle = await db.article.create({
      data: {
        authorId: event.locals.session!.user.userId,
        ...form.data,
      }
    });

    await db.article.update({
      where: { id: article.id },
      data: { newRevisionId: newArticle.id },
    });

    setFlash({ type: 'success', message: '編集を反映しました' }, event);
    throw redirect(302, path(newArticle));
  },
} satisfies Actions;

新規投稿フォームから ArticleEditor を切り出す。

src/routes/articles/ArticleEditor.svelte

LANG: ts
<script lang="ts">
  import type { Writable } from 'svelte/store';
  import Form from '$lib/components/Form.svelte';
  import InputText from '$lib/components/InputText.svelte';
  import TextArea from '$lib/components/TextArea.svelte';
  import Button from '$lib/components/Button.svelte';

  export let message = '';
  export let enhance: (el: HTMLFormElement) => object = () => {
    return {};
  };
  export let title: string;
  export let body: string;
  export let disabled: boolean;
  export let errors: Writable<{}> | undefined = undefined;
</script>

<div>
  <h1>記事の編集</h1>
  <Form message={message} {enhance}>
    <InputText
      name="title"
      label="タイトル"
      bind:value={title}
      disabled={disabled}
      {errors}
    />

    <TextArea
      name="body"
      label="本文"
      bind:value={body}
      disabled={disabled}
      {errors}
      props={{ style: 'height: 30em; overflow-y: scroll' }}
    />

    <Button disabled={disabled}>記事に反映</Button>
    <div>
      <!-- svelte-ignore a11y-invalid-attribute -->
      <a class="link" href="javascript:history.back()">戻る</a>
    </div>
  </Form>
</div>

src/routes/articles/(login)/new/+page.svelte

LANG: html
<script lang="ts">
  import type { PageData } from './$types';
  import { superForm } from 'sveltekit-superforms/client';
  import ArticleEditor from '../../ArticleEditor.svelte';

  export let data: PageData;
  const { form, message, errors, submitting, capture, restore, enhance } = superForm(data.form, {
    taintedMessage: false,
  });
  export const snapshot = { capture, restore };
</script>

<ArticleEditor
  formTitle="記事の投稿" submitTitle="記事を投稿"
  {...{ message: $message, enhance, disabled: $submitting, errors }}
  bind:title={$form.title} bind:body={$form.body}
/>

src/routes/articles/[...titleOrId]/(login)/edit/+page.svelte

LANG: html
<script lang="ts">
  import type { PageData } from './$types';
  import { superForm } from 'sveltekit-superforms/client';
  import ArticleEditor from '../../../ArticleEditor.svelte';

  export let data: PageData;
  const { form, message, errors, submitting, capture, restore, enhance } = superForm(data.form, {
    taintedMessage: false,
  });
  export const snapshot = { capture, restore };
</script>

<ArticleEditor
  formTitle="記事を編集" submitTitle="編集を反映"
  {...{ message: $message, enhance, disabled: $submitting, errors }}
  bind:title={$form.title} bind:body={$form.body}
/>

編集できるようになった。

LANG: console
$ git add . && git commit -m "Article の編集機能を実装した"

marked-directive も必要だ

pukiwiki の plugin を移植すると使いやすそう。

LANG: console
$ npm i marked-directive
Packages: +4
++++
Progress: resolved 394, reused 367, downloaded 4, added 4, done

dependencies:
+ marked-directive 1.0.3
LANG: ts
import { createDirectives, presetDirectiveConfigs, type DirectiveConfig } from 'marked-directive';
  ...
  marked.use(
    createDirectives([
      ...presetDirectiveConfigs,
      {
        level: 'inline',
        marker: ':',
        renderer(token) {
          if (token.meta.name === 'ruby') {
            return (
              `<ruby>${token.text}<rp> (</rp>` +
              `<rt>${token.attrs?.getTokens()}</rt><rp>)</rp></ruby>`
            );
          }
        }
      }
    ])
  )

みたいに書ける。

marked-katex-extension だけだと痒いところに手が届かなかった

手作業での変換を減らすためにかなり細かく調整したくなる。

普通の $ と数式の $ とをどのように見分けるかがキモ。

まだいろいろ調整中

LANG: ts
  const katexRenderer = (text: string, displayMode: boolean) => {
    return katex.renderToString(text, {
      displayMode,
      throwOnError: false,
      fleqn: true,
      strict: 'warn',
      macros: {
        '\\hbar': 'h\\mathllap{{}^-}',
        '\\set': '\\left\\{#1\\right\\}',
        '\\setm': '\\left\\{#1\\ \\middle\\vert\\ #2\\right\\}',
        '\\bra': '\\left\\langle#1\\right|',
        '\\ket': '\\left|#1\\right\\rangle',
        '\\braket': '\\left\\langle#1\\middle|#2\\right\\rangle',
        '\\braketm': '\\left\\langle#1\\middle|#2\\middle|#3\\right\\rangle',
        '\\mathrm': '\\text{#1}',
        '\\DIV': '\\,\\text{div}\\,',
        '\\grad': '\\,\\text{grad}\\,',
        '\\rot': '\\,\\text{rot}\\,',
        '\\curl': '\\,\\text{curl}\\,',
        '\\PD': '\\partial',
        '\\ne': '\\,\\mathrlap{\\,/}{=}\\,',
        '\\MARU': '\\hbox{\\textcircled{\\scriptsize{#1}}}',
      },
    });
  };

  marked.use({
    extensions: [
      {
        name: 'blockKatex',
        level: 'block',
        tokenizer(src) {
          const match = src.match(/^\$\$((?:[^$]|\$(?!\$)|\n)+?)\$\$\s*(?=\n|$)/);
          if (match) {
            return {
              type: 'blockKatex',
              raw: match[0],
              text: match[1].trim(),
            };
          }
        },
        renderer: (token) => {
          // < > のエスケープを外す
          return katexRenderer(token.text.replaceAll(/\\(?=[<>])/g, ''), true);
        },
      },
      {
        name: 'inlineKatex',
        level: 'inline',
        start(src) {
          let index;
          let indexSrc = src;

          while (indexSrc) {
            index = indexSrc.indexOf('$');
            if (index < 0) return;

            if (index === 0 || indexSrc[index - 1].match(/^[\s\n::,、,(()。「・]$/)) {
              const possibleKatex = indexSrc.substring(index);
              if (possibleKatex.match(/^\$[^$]+?\$(?=[\s,、,)) 」。((・~\|]|\n|$)/)) {
                return index;
              }
            }
            indexSrc = indexSrc.substring(index + 1).replace(/^\$+/, '');
          }
        },
        tokenizer(src) {
          const match = src.match(/^(\${1,2})(.+?)\1/);
          if (match) {
            return {
              type: 'inlineKatex',
              raw: match[0],
              text: match[2].trim(),
            };
          }
        },
        renderer: (token) => {
          // < > のエスケープを外す
          return katexRenderer(token.text.replaceAll(/\\(?=[<>])/g, ''), false);
        },
      },
    ],
  });

Counter: 751 (from 2010/06/03), today: 3, yesterday: 0