プログラミング/svelte/svelte5手順覚書 の履歴(No.8)

更新


プログラミング/svelte

概要

あとで使えるように、いろいろな手順を書いておく。

開発環境の整備

git alias

~/.gitconfig

[alias]
	ss = status
	br = branch
	co = checkout
	com = commit
	comm = commit -m
	log1 = log -1
	log2 = log -2
	log3 = log -3
	logo = log --oneline --pretty=format:'%C(Yellow)%h%Creset %ad %C(Green)%s%Creset' --date=short
	logn = log --name-status --oneline --pretty=format:'%C(Yellow)%h%Creset %ad %C(Green)%s%Creset' --date=short

peco

ソフトウェア/peco で紹介したもの。

インストールは https://github.com/peco/peco/releases から peco_windows_amd64.zip を落としてきて、中の peco.exe を ~/bin に置くだけ。

~/.bash_profile に以下を追加して

export PATH=$PATH:~/bin

ログインしなおして、

LANG:console
$ ls | peco

でファイル一覧からの選択画面が表示されたら成功。

~/.gitconfig

[alias]
  # SYNTAX: git d
  #
  # 履歴一覧を表示して選択した履歴との diff を取る
  # 初期状態のまま Enter を押せば単に git diff と打ったのと同様に HEAD と working copy との間の差分をとる
  # 履歴を1つ選んで Enter を押せば、その履歴と working copy との差分を取る
  # Ctrl+space で2つ選べば、その間の差分を取る
  #
  d = !git diff --minimal --ignore-all-space `git log --oneline --branches | peco | ruby -e 'STDIN.readlines.reverse.each{|s| puts s[0..6]}'`

  # SYNTAX: git addq
  #
  # git add するファイルを peco で選択する
  addq= "!if [ \"`git status --porcelain | grep -E '^.[^ ]'`\" != \"\" ]; then git add `git status --porcelain | grep -E '^.[^ ]' | peco | sed -e 's/.* //'`; fi"

  # SYNTAX: git rmq
  #
  # git rmするファイルを peco で選択する
  rmq= "!if [ \"`git status --porcelain | grep -E '^.[^ ]'`\" != \"\" ]; then git rm `git status --porcelain | grep -E '^.[^ ]' | peco | sed -e 's/.* //'`; fi"

  # SYNTAX: git resetq
  #
  # git reset するファイルを peco で選択する
  resetq = "!if [ \"`git status --porcelain | grep -E '^[^\\? ]'`\" != \"\" ]; then git reset `git status --porcelain | grep -E '^[^\\? ]' | peco | sed -e 's/.* //'`; fi"

VSCode

  • ターミナルから code <file名> とするとエディタ上にファイルを開ける

プロジェクトの作成

LANG: console
$ pnpm create svelte@latest <projectname>
$ cd <projectname>
$ pnpm install && git init && git add -A && git commit -m "Initial commit"
$ pnpm test
$ pnpm dev

git add -A と git add . の違いは、git add -Aはレポジトリ内のどこで実行してもレポジトリ全体を処理するが、git add .はカレントディレクトリ以下のみを処理する、とのこと。 ← https://note.nkmk.me/git-add-u-a-period/

コンポーネントのアップデート

LANG: console
$ pnpm update && \
  git add -A && git commit -m "pnpm update"

prettier

.prettierrc

  {
-   "useTabs": true,
+   "useTabs": false,
    "singleQuote": true,
-   "trailingComma": "none",
+   "trailingComma": "es5",
+   "quoteProps": "consistent",
    "printWidth": 100,
    "plugins": ["prettier-plugin-svelte"],
    "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
  }
LANG: console
$ pnpm format && \
  git add -A && \
  git commit -m "prettier を通した"

prettier の自動実行

LANG: console
$ cat > .git/hooks/pre-commit
#!/bin/sh
FILES=$(git diff --cached --name-only --diff-filter=ACMR | sed 's| |\\ |g')
[ -z "$FILES" ] && exit 0

# Prettify all selected files
echo "$FILES" | xargs ./node_modules/.bin/prettier --ignore-unknown --write

# Add back the modified/prettified files to staging
echo "$FILES" | xargs git add

exit 0
^D
$ chmod u+x .git/hooks/pre-commit

pnpm

  • run を付ける必要がない
  • npx の代わりに pnpm dlx とする

テスト環境

LANG: console
$ pnpm test:integration     # playwright による結合テスト
$ pnpm test:unit            # vitest でユニットテストを行い、自動実行用に待機する
$ pnpm test                 # 上の2つを続けて行い、vitest で待機する
$ pnpm playwright test --ui # playwright の gui を立ち上げる

[vitest]

LANG: console
$ cat vite.config.ts 
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config';

export default defineConfig({
        plugins: [sveltekit()],
        test: {
                include: ['src/**/*.{test,spec}.{js,ts}']
        }
});

となっているので、src/ の下のすべての .test.ts および .spec.ts がテストの対象になる。

[playwrite インストール]

LANG: console
$ pnpm dlx playwright install
$ echo /test-results >> .gitignore
$ cat playwright.config.ts 
import type { PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = {
        webServer: {
                command: 'pnpm build && pnpm preview', 
                port: 4173
        },
        testDir: 'tests',
        testMatch: /(.+\.)?(test|spec)\.[jt]s/
};

tests/ フォルダの *test.ts のようなファイルがテスト対象になる(test の前に . はなくてもいい)。

pnpm dev していない状況では、ファイルを更新した場合にはテスト前に pnpm build し直さなければならないっぽいのでその点には注意が必要だ。

tsx

tsx を使うと Type Script で書かれたユーティリティスクリプトをトランススクリプトせずコマンドラインから直接実行することができる。

LANG: console
$ pnpm i -D tsx

とすると入れられて、プロジェクトの tsconfig.json を食わせて実行すれば大抵のものはそのまま動くみたい。

LANG: consle
$ ./node_modules/.bin/tsx --tsconfig tsconfig.json path/to/script.ts

プロジェクト

css

DaisyUI を入れる

https://daisyui.com/

LANG: console
$ pnpm dlx svelte-add@latest tailwindcss
◇  Do you want to use typography plugin?
│  Yes
$ pnpm add -D daisyui@latest

https://daisyui.com/docs/layout-and-typography/#-1 を参考に、

tailwind.config.ts

LANG: ts
+ import TailwindcssTypography from '@tailwindcss/typography';
+ import DaisyUI from 'daisyui';
   
  export default {
     //...
-   plugins: [],
+   plugins: [
+     TailwindcssTypography,
+     DaisyUI,
+   ],
+ 
+   daisyui: {
+     themes: ['light']
+   },
  } as Config;

データベース prisma

インストール

LANG: console
$ pnpm add -D prisma && pnpm add @prisma/client
$ pnpm dlx prisma init --datasource-provider sqlite && cat .env
$ echo "/prisma/*.db" >> .gitignore         # db がコミットされないようにする
$ echo "/prisma/*.db-journal" >> .gitignore
$ git add . && git commit -m "Prisma をインストールした"

サーバーで使うためのコードを追加

src/lib/server/db.ts

import { PrismaClient } from '@prisma/client';

export const db = new PrismaClient();

Prisma チートシート:

prisma/schema.prisma を編集してマイグレートする

$ pnpm prisma migrate dev --name "name for migration"

ユーザー認証 Lucia

を見ながら

LANG: console
$ pnpm add lucia @lucia-auth/adapter-prisma

app.d.ts

LANG: ts
declare global {
  namespace App {
    ...
    // Locals を使うことで +page.server.ts 内で load から action へデータを渡せる
    interface Locals {
      user: import('lucia').User | null;
      session: import('lucia').Session | null;
    }
    interface PageData {
      user: import('lucia').User | null;
      session: import('lucia').Session | null;

src/lib/server/lucia.ts

LANG: ts
import { Lucia } from "lucia";
import { dev } from "$app/environment";
import { PrismaAdapter } from "@lucia-auth/adapter-prisma";
import { db } from "$lib/server/db";
import type { RequestEvent } from '@sveltejs/kit';

const adapter = new PrismaAdapter(db.session, db.user);

export const lucia = new Lucia(adapter, {
  sessionCookie: {
    attributes: {
      // set to `true` when using HTTPS
      secure: !dev
    }
  }
});

// ログインしてるなら cookie から session, user を返す
// src/hooks.server.ts で呼び出される
export async function findUserAndSession(event: RequestEvent) {
  const sessionId = event.cookies.get(lucia.sessionCookieName);
  if (!sessionId) {
    return { session: null, user: null };
  }
  const { session, user } = await lucia.validateSession(sessionId);
  if (session && session.fresh) {
    const sessionCookie = lucia.createSessionCookie(session.id);
    // sveltekit types deviates from the de-facto standard
    // you can use 'as any' too
    event.cookies.set(sessionCookie.name, sessionCookie.value, {
      path: ".",
      ...sessionCookie.attributes
    });
  }
  if (!session) {
    const sessionCookie = lucia.createBlankSessionCookie();
    event.cookies.set(sessionCookie.name, sessionCookie.value, {
      path: ".",
      ...sessionCookie.attributes
    });
  }
  return { session, user };
}

declare module "lucia" {
  interface Register {
    Lucia: typeof lucia;
    DatabaseUserAttributes: DatabaseUserAttributes;
  }
}

interface DatabaseUserAttributes {
  name: string,
  email: string,
}

フォームのバリデーション sveltekit-superforms

LANG: console
$ pnpm add -D sveltekit-superforms zod

使い方はこの辺り :

LANG: ts
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { superValidate } from 'sveltekit-superforms';
import { zod } from 'sveltekit-superforms/adapters';

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

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

src/lib/server/index.ts

// superforms の form コントロールにエラーメッセージを追加する
// @example ```addErrorsToSuperValidated(form, 'email', 'そのアドレスは使えません');```
export function addErrorsToSuperValidated<
  KEYS extends string,
  FORM extends {
    valid: boolean;
    errors: { [key in KEYS]?: string[] };
  },
>(form: FORM, item: KEYS, message: string) {
  form.errors[item] = [...(form.errors[item] || []), message];
  form.valid = false;
}

フラッシュメッセージ sveltekit-flash-message

https://github.com/ciscoheat/sveltekit-flash-message

LANG: console
$ pnpm add -D sveltekit-flash-message

app.d.ts

LANG: ts
declare global {
  namespace App {
    ...
    interface PageData {
      ...
      // https://github.com/ciscoheat/sveltekit-flash-message
      flash?: { type: 'success' | 'error'; message: string };

src\routes\+layout.server.ts

LANG: ts
import { loadFlash } from 'sveltekit-flash-message/server';

export const load = loadFlash(async (event) => {
  // その他の処理
 
  return { someData: 'data'  } // $page.data に受け渡すデータ
});

src\routes\+layout.svelte

LANG: ts
<script lang="ts">
  import { getFlash } from 'sveltekit-flash-message';
  import { page } from '$app/stores';

  const flash = getFlash(page);
  const { children } = $props();
</script>

{#if $flash}
  {@const bg = $flash.type == 'success' ? '#3D9970' : '#FF4136'}
  <div style:background-color={bg} class="flash">{$flash.message}</div>
{/if}

{@render children()}

もう少し凝りたければ、

flash-toast.svelte

LANG: html
<script lang="ts">
  /**
   * TODO: 複数のメッセージを同時に表示できるようにしたい
   */
  import { getFlash } from 'sveltekit-flash-message';
  import { page } from '$app/stores';

  let flashMessage = $state('' as string);
  let flashType = $state('success' as 'success' | 'error');
  let show = $state(false as boolean);

  // メッセージがセットされれば表示する
  const flash = getFlash(page);

  // メッセージがあれば初回表示時に表示する
  let firstTime = true;
  $effect(() => {
    if (firstTime) {
      firstTime = false;
      flash.subscribe(($flash) => {
        if ($flash) {
          let icon = $flash.type == 'success' ? '&#9989;' : '&#10060;';
          flashMessage = icon + ' ' + $flash.message;
          flashType = $flash.type;
          show = true;

          flash.set(undefined);
        }
      });
    }
  });

  function onanimationend() {
    show = false;
  }
</script>

<div class="flash-toast {flashType}" class:is-show={show} {onanimationend}>
  <!-- メッセージ部分 -->
  <div>{@html flashMessage}</div>
  <!-- 吹き出し部分 -->
  <div></div>
</div>

<!-- svelte-ignore css_unused_selector -->
<style type="postcss">
  .flash-toast {
    &.success > div {
      --color-back: #9de8af;
    }

    &.error > div {
      --color-back: #f1aeb5;
    }

    /* https://codepen.io/kandai/pen/qBEbgQv を参考にした */
    box-sizing: border-box;
    position: fixed;
    top: 10px;
    right: 0px;
    /* right: -220px; */
    transform: translateX(calc(100% + 10px));
    display: flex;
    align-items: start;

    /* メッセージ部分 */
    & > div:first-child {
      max-width: 25vw;
      color: #000;
      padding: 4px;
      border: 8px solid;
      border-radius: 8px 0px 8px 8px;
      background-color: var(--color-back);
      border-color: var(--color-back);
    }
    /* 吹き出しの三角形 */
    & > div:nth-child(2) {
      border: solid transparent;
      content: '';
      height: 0;
      width: 0;
      pointer-events: none;
      position: relative;
      border-top-width: 0px;
      border-bottom-width: 10px;
      border-left-width: 10px;
      border-right-width: 10px;
      border-radius: 0;
      border-left-color: var(--color-back);
    }

    &.is-show {
      animation: anime 4s ease 1 normal;
    }
  }

  @keyframes anime {
    /* 全部で 4s */
    12.5% {
      transform: translateX(0);
    } /* 0.5s で飛び出す */
    87.5% {
      transform: translateX(0);
    } /* 0.5s で引っ込む */
  }
</style>

のようなのを作っておいて、

src\routes\+layout.svelte

LANG: ts
<script lang="ts">
  import '../../app.css';
  import FlashToast from './flash-toast.svelte';

  const { children } = $props();
</script>

<FlashToast />
<div class="container">
  {@render children()}
</div>

のようにするとか。

メール送信 nodemailer

https://nodemailer.com/

LANG: console
$ pnpm add nodemailer && pnpm add -D @types/nodemailer

src/lib/server/transporter.ts

LANG:ts
import { createTransport, type SentMessageInfo, type SendMailOptions } from 'nodemailer';
import { env } from '$env/dynamic/private';
import * as fs from 'fs';

export const transporter = createTransport({
  host: 'localhost',
  port: 25,
});

// 開発環境やテスト環境なら実際にはメールを送らずに
// コンソールへ表示 & test-results/mail-sent.txt へ保存
if (env.DATABASE_URL.match(/\b(dev|test)\.db$/)) {
  transporter.sendMail = async (mailOptions: SendMailOptions) => {
    console.log(mailOptions);
    if (!fs.existsSync('test-results')) {
      fs.mkdirSync('test-results', { mode: 0o755 });
    }
    fs.writeFileSync('test-results/mail-sent.txt', JSON.stringify(mailOptions));
    return null as SentMessageInfo;
  };
}

困ったとき

Playwright による integration test で Error: Process from config.webServer was not able to start. Exit code: 1

TEST RESULT ウィンドウの結果が、

LANG:console
Running global setup if any…
Error: Process from config.webServer was not able to start. Exit code: 1

となってエラーの詳細が表示されない。

大抵は pnpm build で失敗しているということなので Terminal から

LANG:console
$ pnpm build

してエラーメッセージを確認する。

satisfies PageServerLoad がエラーになる

page.server.ts 内の記述が間違っていないのにここがエラーになるのとすれば、 その上の階層の layout.server.ts において必要な PageData が正しく受け渡せていない可能性があるのでチェックする。

https://github.com/nextauthjs/next-auth/issues/7543

svelte ファイルをテンプレートエンジンとして使う

svelte5 の場合 svelte/server に含まれる render という関数を使うことで文字列化(hydrate)が可能。

https://svelte-5-preview.vercel.app/docs/breaking-changes#components-are-no-longer-classes-server-api-changes

LANG: ts
import { render } from 'svelte/server';
import Template from './my-template.svelte';

const text = render(Template, {
  props: {
    prop1: value1,
    prop2: value2,
  },
}).body
  .replaceAll(/<!--[^>]*-->/g,'')
  .replaceAll(/^\n*<[^>]+>\n?|<\/[^<]+>\n*$/g, '');

dev モードでは自動生成されるコメントタグがたくさん入るため、後から replaceAll で消す。 pnpm build 後はコメントは入らないので無駄になるけどテストのためにもこのコードは必要。

my-template.svelte

LANG: html
<script lang="ts">
  let { title, body }: { title: string, body: string } = $props();
</script>

<!-- prettier-ignore -->
<pre>
* {title}
{body}
</pre>

出力結果を html ではなくプレーンテキストとして用いる場合、 テンプレートは例えば <pre> タグで全体を括って <!-- prettier-ignore --> を付けておく。

そうしないと prettier が勝手なところで改行したり、空白を入れたりしてしまう。

https://github.com/sveltejs/prettier-plugin-svelte/issues/59#issuecomment-683425785

prettier-ignore はタグ単位で効果を発揮するので、上記のように何らかのタグで括る必要がある。

邪魔なタグは後から .replaceAll(/^\n*<[^>]+>\n?|<\/[^<]+>\n*$/g, ''); で消している。


Counter: 453 (from 2010/06/03), today: 4, yesterday: 5