ブログ的なもの作り-投稿・表示・編集 の履歴(No.3)
更新ブログ的なものを作ってみる†
データベースの準備†
編集履歴をすべて残すため、記事が書き換えられた場合にも新しいレコードを追加するのみ。
- 古いレコードの 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 } 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;
記事の表示†
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> にソーステキストをそのまま貼ってみる。
src/routes/articles/[...titleOrId]/+page.svelte
LANG: html <script lang="ts"> import type { PageData } from './$types'; export let data: PageData; </script> <article class="prose p-4"> <h1>{data.article.title}</h1> <pre> {data.article.body} </pre> </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
src/routes/articles/+page.svelte
... + import { afterUpdate } from 'svelte'; + import { marked } from 'marked'; + import hljs from 'highlight.js'; + import renderMathInElement from 'katex/contrib/auto-render'; export let data: PageData; + let article: HTMLElement; + + afterUpdate(()=>{ + console.log('rendering...', articles); + renderMathInElement(articles, { + // 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.body} + {@html marked.parse(article.body)}
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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; 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 表示は自分でやっても良いんだよね・・・