ブログ的なもの作り-投稿・表示・編集 のバックアップ(No.4)

更新


プログラミング/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 を解釈

さしあたり marked, highlight, katex を入れてみる。

LANG: console
$ npm install marked highlight.js katex
Packages: +4
++++
Progress: resolved 383, reused 359, downloaded 1, added 4, done
 
dependencies:
+ highlight.js ^11.9.0
+ katex ^0.16.9
+ marked ^10.0.0

Done in 3s
$ npm i --save-dev @types/katex
Packages: +1
+
Progress: resolved 384, reused 361, downloaded 0, added 1, done

devDependencies:
+ @types/katex 0.16.7

Done in 9.1s

あたりを見ながら、

  • marked.parse で Markdown を html にできる

src/routes/articles/+page.svelte

LANG: ts
<script lang="ts">
  import type { PageData } from './$types';
  import { afterUpdate } from 'svelte';
  import { marked } from 'marked';
  import hljs from 'highlight.js';
  import renderMathInElement from 'katex/contrib/auto-render';
  import 'highlight.js/styles/stackoverflow-dark.min.css';
  import 'katex/dist/katex.min.css';

  export let data: PageData;
  let article: HTMLElement;

  afterUpdate(()=>{
    console.log('rendering...', article);
    renderMathInElement(article, {
      // customised options
      // • auto-render specific keys, e.g.:
      delimiters: [
          {left: '$$', right: '$$', display: true},
          {left: '$', right: '$', display: false},
          {left: '\\(', right: '\\)', display: false},
          {left: '\\[', right: '\\]', display: true}
      ],
      // • rendering keys, e.g.:
      throwOnError : false
    });
    hljs.highlightAll();
  })
</script>

<article bind:this={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>

これである程度まともに表示されるようになった。

LANG: console
$ git add . && git commit -m "marked, highlightjs, katex を入れた"

css を読み込む

node_modules に含まれるものは、

src/routes/articles/+page.svelte

  <script>
+   import 'katex/dist/katex.min.css';
+   import 'highlight.js/styles/arduino-light.min.css';

のように import すればいいらしい。

tailwind を使うときは、

src/routes/articles/+page.svelte

+ <style lang="postcss">
+   article :global(h1) {
+     @apply text-3xl font-bold;
+   }
+ </style>

みたいに style に lang="postcss" をつける。

Artile を切り出す

highlight.js によるマークアップが結構大変なのでそれも切り出す。

src/lib/components/Article.svelte

LANG: html
<script lang="ts">
  import { afterUpdate } from 'svelte';
  import { marked } from 'marked';
  import renderMathInElement from 'katex/contrib/auto-render';
  import { codeRenderer } from '$lib/markedCodeRenderer';
  import 'katex/dist/katex.min.css';
  import 'highlight.js/styles/arduino-light.min.css';

  import type { Article, AuthUser } from '@prisma/client'
  export let article: Article & {author: AuthUser};

  let element: HTMLElement;
  afterUpdate(()=>{
    renderMathInElement(element, {
      // customised options
      // • auto-render specific keys, e.g.:
      delimiters: [
          {left: '$$', right: '$$', display: true},
          {left: '$', right: '$', display: false},
          {left: '\\(', right: '\\)', display: false},
          {left: '\\[', right: '\\]', display: true}
      ],
      // • rendering keys, e.g.:
      throwOnError : false,
    });
  })

  // code ブロックの処理を追加
  const renderer = new marked.Renderer();
  renderer.code = codeRenderer;
  const originalHeadingRenderer = renderer.heading;
  renderer.heading = (text: string, level: number, raw: string) => 
    originalHeadingRenderer(text, level + 1, raw);  // 1つレベルを下げる
  marked.setOptions({ renderer });

  function convert(body: string) {
    return marked.parse(body);
  }

</script>

<article class="article" bind:this={ element }>
  <h1>{article.title}</h1>
  <div><span>{article.author.name}</span> <span>{article.createdAt.toLocaleString()}</span></div>
  <div>
    {@html convert(article.body)}
  </div>
</article>

<style lang="postcss">
  article :global(h1) {
    @apply text-2xl font-bold;
  }
  article :global(h2) {
    @apply text-xl font-bold;
  }
  article :global(h3) {
    @apply text-lg font-bold;
  }
  article :global(ul) {
    @apply list-disc;
    margin-left: 2em;
  }
  article :global(ol) {
    @apply list-decimal;
    margin-left: 2em;
  }
</style>

src/lib/markedCodeRenderer.ts

LANG: ts
import hljs from 'highlight.js';

// code を言語 lang として highlight する
const highlight = function (code: string, lang?: string) {
  if (lang) {
    return hljs.highlight(code, { language: lang, ignoreIllegals: true }).value;
  } else {
    return hljs.highlightAuto(code).value;
  }
};

// highlight.js の言語名あるいはエイリアス名から正式な言語名を調べる

const aliasToLang: { [alias: string]: string } = {};
hljs.listLanguages().forEach((name) => {
  const lang = hljs.getLanguage(name);
  aliasToLang[name] = name;
  lang?.aliases?.forEach((alias) => {
    aliasToLang[alias] = name;
  });
});

// HTML の特殊文字をエスケープ
// https://stackoverflow.com/questions/1787322/what-is-the-htmlspecialchars-equivalent-in-javascript
function escapeHtml(text: string) {
  const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' };
  return text.replace(/[&<>"']/g, (c) => map[c as keyof typeof map]);
}

// code ブロックをレンダリングする
const codeRenderer = (code: string, fileInfo = '', escaped: boolean) => {
  const info = fileInfo.split(':');
  if (info[0]) {
    if (!aliasToLang[info[0]]) {
      const ext = info[0].split('.').findLast(() => true);
      if (ext && aliasToLang[ext]) {
        // 拡張子
        info.unshift(aliasToLang[ext]);
      } else {
        // 言語指定なし
        info.unshift('');
      }
    } else {
      info[0] = aliasToLang[info[0]];
    }
  }
  const [lang, fileName] = [...info, '', ''];

  const out = highlight(code, lang);
  if (out != null && out !== code) {
    escaped = true;
    code = out;
  }

  const fileTag = fileName ? `<span class="filename">${escapeHtml(fileName)}</span>` : '';
  const langClass = lang ? ` language-${escapeHtml(lang)}` : '';

  return (
    `<pre>${fileTag}<code class="hljs${langClass}">` +
    (escaped === false ? escapeHtml(code) : code) +
    '\n</code></pre>'
  );
};

export { codeRenderer };

使う方はとてもすっきりする

src/routes/articles/+page.svelte

LANG: html
<script lang="ts">
  import type { PageData } from './$types';
  import Article from '$lib/components/Article.svelte';
  export let data: PageData;
</script>

<div><h1>最新の記事</h1>
  <div class="articles">
    {#each data.articles as article}
      <Article { article } />
    {/each}
  </div>
</div>

highlight.js より prismjs というのが良いのかも?

最近は prismjs の方が人気がありそうかも?
https://npmtrends.com/highlight.js-vs-prismjs

特に diff 表示ができるのはうれしい:
https://qiita.com/suin/items/0303f5f121d836061bc8

svelte には対応していない
https://prismjs.com/index.html#supported-languages

まあ diff 表示は自分でやっても良いんだよね・・・


Counter: 794 (from 2010/06/03), today: 2, yesterday: 0