ブログ的なもの作り-投稿・表示・編集 の履歴(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 表示は自分でやっても良いんだよね・・・