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

更新

[[プログラミング/svelte]]

* ブログ的なものを作ってみる [#x6384456]

#contents

** データベースの準備 [#ua1c2e03]

prisma/schema.prisma
   model AuthUser {
     ...
 
     roles         Role[]
 +   articles      Article[]
     @@map("auth_user")
   }
   ...
 
 + model Article {
 +   id String @id @default(uuid())
 +   author AuthUser @relation(fields: [authorId], references: [id]) 
 +   authorId String @map("author_id")
 +   title String
 +   slug String? @unique
 +   body String
 +   newRevisionId String? @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? @default(now()) @map("deleted_at")
 +   attachments Attachment[]
 + }
 +
 + model Attachment {
 +   id String @ id @default(uuid())
 +   article Article @relation(fields: [articleId], references: [id])
 +   articleId String
 +   body Bytes
 +   createdAt DateTime @default(now()) @map("created_at")
 +   modifiedAt DateTime @default(now()) @map("modified_at")
 + }

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

最も新しいものだけに slug を付与する。

- slug 化について~
https://qiita.com/Yarakashi_Kikohshi/items/af0b334af1a1e73a5661#-url-%E3%83%95%E3%83%A9%E3%82%B0%E3%83%A1%E3%83%B3%E3%83%88%E8%AD%98%E5%88%A5%E5%AD%90
- https://www.npmjs.com/package/slugify

ただし、古い版から slug が変化する場合には古い版の slug も残しておき、
そこから最新版へリダイレクトする。

articles/123 のように数字でアクセスした場合には id で検索して
その最新版へリダイレクトする。

StackOverflow などでは /questions/[id]/[slug] としていて、
[slug] の有無にかかわらず [id] まででアクセスできるみたい。
[slug] は任意の文字列で構わない。

SEO 的に [slug] が付いていることだけが重要なのだとすればこれが一番いい。
古い番号からは 301 Moved Permanently で新しい番号に飛べばいい。

その方針なら slug をデータベースに持たせる必要はないのか。

prisma/schema.prisma
   model Article {
     ...
 
 -   slug String? @unique

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

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

 LANG: console
 $ pnpm prisma migrate dev --name "add Article and Attachment"
  Environment variables loaded from .env
  Prisma schema loaded from prisma\schema.prisma
  Datasource "db": SQLite database "dev.db" at "file:dev.db"
  
  Applying migration `20231127041011_add_article_and_attachment`
  
  The following migration(s) have been created and applied from new schema changes:
  
  migrations/
    └─ 20231127041011_add_article_and_attachment/
      └─ 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 346ms

** routes [#p0f2dd8e]

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

** 投稿画面 [#t7c8104d]

新たに LabeledTextArea を作成した。

その中で editorProps というのを新しく作って、TextArea にそのまま渡すようにした。

https://stackoverflow.com/questions/76285794/svelte-components-intrinsicelements-or-sveltehtmlelements-for-extending-with-ht

src/lib/components/LabeledTextArea.svelte
 LANG: ts
 <script lang="ts">
   import type { Writable } from 'svelte/store';
   import type { SvelteHTMLElements } from 'svelte/elements';
 
   export let name: string;
   export let label: string;
   export let type = 'text';
   export let value: string;
   export let disabled = false;
   export let errors: Writable<{}> | undefined = undefined;
 
   export let editorProps: SvelteHTMLElements['textarea'] | undefined = undefined;;
 
   let key = name as keyof typeof errors;
 </script>
 
 <label for={name} class="label"><span class="text-base label-text">{label}</span></label>
 <textarea {...{ name, type, ...editorProps }} bind:value disabled={disabled} on:input
 class="w-full input input-bordered input-primary" class:input-error={errors && $errors && $errors[key]} />
 {#if errors && $errors && $errors[key]}<span class="text-xs text-red-600">{$errors[key]}</span>{/if}

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

(loggedIn)/articles/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 LabeledInput from '$lib/components/LabeledInput.svelte';
   import LabeledTextArea from '$lib/components/LabeledTextArea.svelte';
   import SubmitButton from '$lib/components/SubmitButton.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}>
     <LabeledInput name="title" label="タイトル" bind:value={$form.title} disabled={$submitting} {errors} />
 
     <LabeledTextArea name="body" label="本文" bind:value={$form.body} disabled={$submitting} {errors} 
       editorProps={{style: 'height: 20em; overflow-y: scroll'}}/>
 
     <SubmitButton disabled={$submitting}>記事を投稿</SubmitButton>
     <div>
       <a class="text-sm text-blue-600 hover:underline hover:text-blue-400" href="/">トップへ戻る</a>
     </div>
   </Form>
 </div>

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

(loggedIn)/articles/new/+page.server.ts
 LANG: ts
 import type { Actions, PageServerLoad } from './$types';
 import { schema } from '$lib/formSchemas/article';
 import { superValidate } from 'sveltekit-superforms/server';
 import { fail } from '@sveltejs/kit';
 import { redirect } from 'sveltekit-flash-message/server';
 
 import { db } from '$lib/server/db';
 
 export const load = (async () => {
   const form = await superValidate(schema);
   return { form };
 }) satisfies PageServerLoad;
 
 export const actions: Actions = {
   default: async (event) => {
     // フォームデータのバリデーション
     const form = await superValidate(event, schema);
     if (!form.valid) {
       return fail(400, { form });
     }
 
     // 記事を投稿する
     const article = await db.article.create({data:{
       authorId: event.locals.session.user.id,
       ...form.data,
     }})
 
     return redirect(302, `/articles/${article.id}`, {type: 'success', message: '記事を投稿しました'}, event);
   }
 };


** 記事の表示 [#e2289487]

src/routes/articles/+page.server.ts
 LANG: ts
 import type { PageServerLoad } from './$types';
 
 import { db } from '$lib/server/db';
    
 export const load = (async () => {
   const articles = await db.article.findMany({
     where: {deletedAt: null, newRevisionId: null},
     orderBy: {createdAt: 'desc'},
     take: 20,
     include: {author: true}
   })
   return { articles };
 }) satisfies PageServerLoad;

src/routes/articles/+page.svelte
 <script lang="ts">
   import type { PageData } from './$types';
 
   export let data: PageData;
 </script>
 
 <div><h1>最新の記事</h1>
   {#each data.articles as article}
     <div>
       <div>{article.title}</div>
       <div><span>{article.author.name}</span><span>{article.createdAt.toString()}</span></div>
       <div>
         {article.body}
       </div>
     </div>
   {/each}
 </div>

** markdown を解釈 [#tb240725]

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

 LANG: console
 $ npm install marked highlight.js katex
 Packages: +4
 ++++
 Progress: resolved 388, reused 362, downloaded 4, 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 389, reused 366, downloaded 1, added 1, done
 
 devDependencies:
 + @types/katex 0.16.7
 
 Done in 9.1s


- https://note.com/taatn0te/n/n17db53037ade
- https://katex.org/docs/node

src/routes/articles/+page.svelte
     ...
 
 +   import { marked } from 'marked';
 +   import hljs from 'highlight.js';
 +   import renderMathInElement from 'katex/contrib/auto-render';
   
     export let data: PageData;
     let articles: 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 を読み込む [#h51651b5]

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 を切り出す [#y03cda36]

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 というのが良いのかも? [#k4065edf]

最近は 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