プログラミング/svelte のバックアップソース(No.21)

更新

[[公開メモ]]

* svelte や svelte kit を使いこなしたい [#o86663ca]

VS Code ありきで作られた最新のエコシステムの恩恵を受けるために勉強してみよう。

#contents

** いろいろ読んで勉強中 [#o6d339e1]

- SvelteKit+Superforms+Prisma+Luciaでログイン機能を爆速で実装する~
https://zenn.dev/gawarago/articles/f75f5113a3803d
- SvelteKitが正式リリースされたのでtRPCとPrismaを使ってWebアプリを開発してみた~
https://zenn.dev/kosei28/articles/d965f221a656fd
- Vitest / Playwrightを使ってSvelteのコンポーネントをテストする~
https://qiita.com/oekazuma/items/925ddbf48870fb999c19#vitest%E3%81%A8%E3%81%AF
- TypeScript 4.9のas const satisfiesが便利。型チェックとwidening防止を同時に行う~
https://zenn.dev/moneyforward/articles/typescript-as-const-satisfies
- コーディング不要でGraphQLサーバが作れるPrismaを触ってみて可能性を感じた - SMARTCAMP Engineer Blog~
https://tech.smartcamp.co.jp/entry/started-prisma
- worst password list~
https://github.com/danielmiessler/SecLists/blob/master/Passwords/Common-Credentials/10-million-password-list-top-100000.txt
- 5歳娘「パパ、余分なpropsいっぱい書くんだね!」~
https://qiita.com/Yametaro/items/814f40d08e9d30584e20#5%E6%AD%B3%E5%A8%98%E3%83%91%E3%83%91%E4%BD%99%E5%88%86%E3%81%AAprops%E3%81%84%E3%81%A3%E3%81%B1%E3%81%84%E6%9B%B8%E3%81%8F%E3%82%93%E3%81%A0%E3%81%AD
-- svelte での書き方 → https://stackoverflow.com/questions/76285794/svelte-components-intrinsicelements-or-sveltehtmlelements-for-extending-with-ht

* VS Code + Git Bash + nvm のインストール [#d5c705f1]

https://blog.css-net.co.jp/entry/dev-environment-windows#2-2-Git-for-Windows

に従った。

** VS Code 上の GitBash で nvm が動かない [#tdc757ef]

VS Code を起動中に nvm をインストールすると、

 LANG: console
 $ nvm
 bash: nvm: command not found

とか、

 LANG: console
 $ nvm install lts
 ERROR open \settings.txt: The system cannot find the file specified.

となる。

これは環境変数の更新が VS Code に認識されていないためなので、
VS Code を一旦落として立ち上げなおすと動くようになる。

 LANG: console
 $ node
 bash: node: command not found
 $ nvm list
 
    18.16.0

のような場合は、

 LANG: console
 $ nvm use 18.16.0
 Now using node v18.16.0 (64-bit)
 
 $ nvm list
 
  * 18.16.0 (Currently using 64-bit executable)
 
 $ node
  Welcome to Node.js v18.16.0.

のように nvm use する。

** pnpm を入れる [#dd20e999]

良いうわさを聞くので。

- https://pnpm.io/ja/feature-comparison
- タブ補間~
https://pnpm.io/ja/completion
- npx の代わりに pnpm dlx~
https://polidog.jp/2023/01/25/pnpm_dlx/
- pnpmだけを使うようにする~
https://pnpm.io/ja/only-allow-pnpm

 LANG: console
 $ 'npm' install -g pnpm
 
 added 1 package in 6s
 
 $ pnpm install-completion bash
  => Added tabtab source line in "~\.config\tabtab\bash\__tabtab.bash" file
  => Added tabtab source line in "~/.bashrc" file
  => Wrote completion script to C:\Users\osamu\.config\tabtab\bash\pnpm.bash file
  
      => Tabtab source line added to ~/.bashrc for pnpm package.
  
      Make sure to reload your SHELL.

そして ~/.bashrc に、

 alias npm=pnpm

としてしまう。

** VS Code 設定 [#u7dae213]

- フォント Myrica~
https://www.sejuku.net/blog/60345 を参考にした
- テーマファイルについて~
https://coliss.com/articles/build-websites/operation/work/best-of-visual-studio-code-themes.html
- アドオン
-- Japanese Language Pack for Visual Studio Code~
https://marketplace.visualstudio.com/items?itemName=MS-CEINTL.vscode-language-pack-ja
-- Keyboard Macro Beta~
https://marketplace.visualstudio.com/items?itemName=tshino.kb-macro
-- macro-commander (Command Runner)~
https://marketplace.visualstudio.com/items?itemName=jeff-hykin.macro-commander
-- GLSL Lint (WebGL をやる場合)~
https://marketplace.visualstudio.com/items?itemName=dtoplak.vscode-glsllint
-- Shader languages support for VS Code (WebGL をやる場合)~
https://marketplace.visualstudio.com/items?itemName=slevesque.shader
-- ESLint~
https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint
-- GitLens — Git supercharged~
https://marketplace.visualstudio.com/items?itemName=eamodio.gitlens
-- npm Intellisense~
https://marketplace.visualstudio.com/items?itemName=christian-kohler.npm-intellisense
-- Playwright Test for VSCode~
https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright
-- Prettier - Code formatter~
https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode
-- Pretty TypeScript Errors~
https://marketplace.visualstudio.com/items?itemName=yoavbls.pretty-ts-errors
-- Prisma~
https://marketplace.visualstudio.com/items?itemName=Prisma.prisma
-- Svelte for VS Code~
https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode

* テスト環境の確認 [#f1f86045]

https://qiita.com/oekazuma/items/925ddbf48870fb999c19#vitest%E3%81%A8%E3%81%AF

 LANG: console
 $ pnpm create svelte@latest testenv
 .../Local/pnpm/store/v3/tmp/dlx-41072    |   +6 +
 Packages are hard linked from the content-addressable store to the virtual store.
   Content-addressable store is at: C:\Users\osamu\AppData\Local\pnpm\store\v3
   Virtual store is at:             ../../Users/osamu/AppData/Local/pnpm/store/v3/tmp/dlx-41072/node_modules/.pnpm
 .../Local/pnpm/store/v3/tmp/dlx-41072    | Progress: resolved 6, reused 0, downloaded 6, added 6, done
 
 create-svelte version 4.2.0
 
 ┌  Welcome to SvelteKit!
 │
 ◇  Which Svelte app template?
 │  Skeleton project
 │
 ◇  Add type checking with TypeScript?
 │  Yes, using TypeScript syntax
 │
 ◇  Select additional options (use arrow keys/space bar)
 │  Add ESLint for code linting, Add Prettier for code formatting, Add Playwright for browser testing, Add Vitest for unit testing
 │
 └  Your project is ready!
 
 ✔ Typescript
   Inside Svelte components, use <script lang="ts">
 
 ✔ ESLint
   https://github.com/sveltejs/eslint-plugin-svelte
 
 ✔ Prettier
   https://prettier.io/docs/en/options.html
   https://github.com/sveltejs/prettier-plugin-svelte#options
 
 ✔ Playwright
   https://playwright.dev
 
 ✔ Vitest
   https://vitest.dev
 
 Install community-maintained integrations:
   https://github.com/svelte-add/svelte-add
 
 Next steps:
   1: cd testenv
   2: npm install (or pnpm install, etc)
   3: git init && git add -A && git commit -m "Initial commit" (optional)
   4: npm run dev -- --open
 
 To close the dev server, hit Ctrl-C
 
 Stuck? Visit us at https://svelte.dev/chat
 $ cd testenv
 $ pnpm install
 $ git init && git add -A && git commit -m "Initial commit"
 $ pnpm run dev
 
 > testenv@0.0.1 dev (home)\svelte\testenv
 > vite dev
 
 
 Forced re-optimization of dependencies
 
   VITE v4.3.9  ready in 1901 ms
 
   ➜  Local:   http://localhost:5173/
   ➜  Network: use --host to expose
   ➜  press h to show help
 
   Shortcuts
   press r to restart the server
   press u to show server url
   press o to open in browser
   press c to clear console
   press q to quit

ここで o を押すとブラウザが立ち上がって Welcome to SvelteKit が表示された。

&ref(vite-dev.png);

^C で抜けて、

 LANG: console
 $ cat package.json 
 {
         "name": "testenv",
         "version": "0.0.1",
         "private": true,
         "scripts": {
                 "dev": "vite dev",
                 "build": "vite build",
                 "preview": "vite preview",
                 "test": "playwright test",
                 "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
                 "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
                 "test:unit": "vitest",
                 "lint": "prettier --plugin-search-dir . --check . && eslint .",
                 "format": "prettier --plugin-search-dir . --write ."
         },
         "devDependencies": {
                 "@playwright/test": "^1.28.1",
                 "@sveltejs/adapter-auto": "^2.0.0",
                 "@sveltejs/kit": "^1.5.0",
                 "@typescript-eslint/eslint-plugin": "^5.45.0",
                 "@typescript-eslint/parser": "^5.45.0",
                 "eslint": "^8.28.0",
                 "eslint-config-prettier": "^8.5.0",
                 "eslint-plugin-svelte": "^2.26.0",
                 "prettier": "^2.8.0",
                 "prettier-plugin-svelte": "^2.8.1",
                 "svelte": "^3.54.0",
                 "svelte-check": "^3.0.1",
                 "tslib": "^2.4.1",
                 "typescript": "^5.0.0",
                 "vite": "^4.3.0",
                 "vitest": "^0.25.3"
         },
         "type": "module"
 }

** vitest [#seaa70b8]

 LANG: console
 $ pnpm run test:unit
 
 > testenv@0.0.1 test:unit (home)\svelte\testenv
 > vitest
 
 
  DEV  v0.25.8 ~/svelte/testenv
 
  ✓ src/index.test.ts (1)
 
  Test Files  1 passed (1)
       Tests  1 passed (1)
    Start at  12:11:24
    Duration  2.21s (transform 720ms, setup 0ms, collect 77ms, tests 6ms)
 
 
  PASS  Waiting for file changes...
        press h to show help, press q to quit
 
   Watch Usage
   press a to rerun all tests
   press f to rerun only failed tests
   press u to update snapshot
   press p to filter by a filename
   press t to filter by a test name regex pattern
   press q to quit

別の terminal から、

 LANG: console
 $ touch src/index.test.ts

すると、再度テストが実行される。

 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 がテストの対象になる。

** PlayWright [#n96c68b1]

 LANG: console
 $ pnpm run test
 
 > testenv@0.0.1 test (home)\svelte\testenv
 > playwright test
 
 
 Running 1 test using 1 worker
 
 [WebServer]
 [WebServer]
 [WebServer]
   ✘  1 test.ts:3:1 › index page has expected h1 (58ms)
 
 
   1) test.ts:3:1 › index page has expected h1  ──────────────────────────────────────────────────────
 
     Error: browserType.launch: Executable doesn't exist at C:\Users\osamu\AppData\Local\ms-playwright\chromium-1064\chrome-win\chrome.exe
     ╔═════════════════════════════════════════════════════════════════════════╗
     ║ Looks like Playwright Test or Playwright was just installed or updated. ║
     ║ Please run the following command to download new browsers:              ║
     ║                                                                         ║
     ║     npx playwright install                                              ║
     ║                                                                         ║
     ║ <3 Playwright Team                                                      ║
     ╚═════════════════════════════════════════════════════════════════════════╝
 
 
 
 
   1 failed
     test.ts:3:1 › index page has expected h1 
 ───────────────────────────────────────────────────────
  ELIFECYCLE  Test failed. See above for more details.

ふむ。言われた通りにしよう。

 LANG: console
 $ npx playwright install
 Downloading Chromium 114.0.5735.35 (playwright build v1064) from https://playwright.azureedge.net/builds/chromium/1064/chromium-win64.zip
 113.5 Mb [====================] 100% 0.0s
 Chromium 114.0.5735.35 (playwright build v1064) downloaded to C:\Users\osamu\AppData\Local\ms-playwright\chromium-1064
 Downloading FFMPEG playwright build v1009 from https://playwright.azureedge.net/builds/ffmpeg/1009/ffmpeg-win64.zip
 1.4 Mb [====================] 100% 0.0s
 FFMPEG playwright build v1009 downloaded to C:\Users\osamu\AppData\Local\ms-playwright\ffmpeg-1009
 Downloading Firefox 113.0 (playwright build v1408) from https://playwright.azureedge.net/builds/firefox/1408/firefox-win64.zip79.7 Mb [====================] 100% 0.0s
 Firefox 113.0 (playwright build v1408) downloaded to C:\Users\osamu\AppData\Local\ms-playwright\firefox-1408
 Downloading Webkit 16.4 (playwright build v1848) from https://playwright.azureedge.net/builds/webkit/1848/webkit-win64.zip
 45.2 Mb [====================] 100% 0.0s
 Webkit 16.4 (playwright build v1848) downloaded to C:\Users\osamu\AppData\Local\ms-playwright\webkit-1848
 $ pnpm run test
 > testenv@0.0.1 test (home)\svelte\testenv
 > playwright test
 
 
 Running 1 test using 1 worker
 
 [WebServer]
 [WebServer]
 [WebServer]
   ✓  1 test.ts:3:1 › index page has expected h1 (967ms)
 
   1 passed (4.0s)
 $ ls -a test-results/
 ./  ../

かなり時間がかかるけど、ちゃんと動いたと言っている。
test-results/ には何も入っていない。

上では 4.0s かかったと言っているけど実際にはテストが走り始めるまでに
20秒近くかかっていて、そっちの方がずっと長い。

 LANG: console
 $ time node_modules/.bin/playwright test
 
 Running 1 test using 1 worker
 
 [WebServer]
 [WebServer]
 [WebServer]
   ✓  1 test.ts:3:1 › index page has expected h1 (473ms)
 
   1 passed (3.0s)
 
 real    0m22.550s
 user    0m0.076s
 sys     0m0.247s

gui を使うことにして立ち上げっぱなしにすると、起動にかかる時間を待たずに済む。

 LANG: console
 $ node_modules/.bin/playwright test --ui
 
&ref(playwright-gui.png,,50%);

うまく行きそうだ。

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

tests/ フォルダの *.test.ts のようなファイルがテスト対象になる。

あーと、これを見ると ファイルを更新した場合には npm run build をし直さなければならないっぽいのでその点には注意が必要だ。

 LANG: console
 $ git status
 On branch master
 Untracked files:
   (use "git add <file>..." to include in what will be committed)
         test-results/
 
 nothing added to commit but untracked files present (use "git add" to track)

となってしまうので、.gitignore に /test-results を追加することも必要。

 LANG: console
 $ echo /test-results >> .gitignore
 $ git status
 On branch master
 Changes not staged for commit:
   (use "git add <file>..." to update what will be committed)
   (use "git restore <file>..." to discard changes in working directory)
         modified:   .gitignore
 
 no changes added to commit (use "git add" and/or "git commit -a")
 $ git commit -a -m "add /test-results to .gitignore"

何にしても、ユニットテストばかりでなく E2E テストまで Out of the box で簡単に行えるのはすごい。~
apache2 や nginx どころか WSL すらセットアップの必要がないとは。。。

* Prisma と Lucia を使った認証システムを作ってみる [#a42dca10]

SvelteKit+Superforms+Prisma+Luciaでログイン機能を爆速で実装する~
https://zenn.dev/gawarago/articles/f75f5113a3803d

を参考に以下を使ったサイトを作る基本を身に着けたいなと。

- Svelte
- SvelteKit
- SuperForms : フォームバリデーション
- Prisma : データベースアクセス
- Lucia : 認証ライブラリ
- sveltekit-flash-message : flash メッセージの管理
- svelte-french-toast : flash メッセージ(など)を一定時間だけ表示する

** スケルトンプロジェクトを作成 [#m0ce9b51]

 LANG:console
 $ pnpm create svelte@latest authtest
  .../Local/pnpm/store/v3/tmp/dlx-26152    |   +6 +
  Packages are hard linked from the content-addressable store to the virtual store.
    Content-addressable store is at: C:\Users\osamu\AppData\Local\pnpm\store\v3
    Virtual store is at:             ../../AppData/Local/pnpm/store/v3/tmp/dlx-26152/node_modules/.pnpm
  .../Local/pnpm/store/v3/tmp/dlx-26152    | Progress: resolved 6, reused 6, downloaded 0, added 6, done
  
  create-svelte version 5.3.1
  
  ┌  Welcome to SvelteKit!
  │
  ◇  Which Svelte app template?
  │  Skeleton project
  │
  ◇  Add type checking with TypeScript?
  │  Yes, using TypeScript syntax
  │
  ◇  Select additional options (use arrow keys/space bar)
  │  Add ESLint for code linting, Add Prettier for code formatting, Add Playwright for browser testing, Add Vitest for unit testing
  │
  └  Your project is ready!
  
  ✔ Typescript
    Inside Svelte components, use <script lang="ts">
  
  ✔ ESLint
    https://github.com/sveltejs/eslint-plugin-svelte
  
  ✔ Prettier
    https://prettier.io/docs/en/options.html
    https://github.com/sveltejs/prettier-plugin-svelte#options
  
  ✔ Playwright
    https://playwright.dev
  
  ✔ Vitest
    https://vitest.dev
  
  Install community-maintained integrations:
    https://github.com/svelte-add/svelte-add
  
  Next steps:
    1: cd authtest
    2: pnpm install
    3: git init && git add -A && git commit -m "Initial commit" (optional)
    4: pnpm run dev -- --open
  
  To close the dev server, hit Ctrl-C
  
  Stuck? Visit us at https://svelte.dev/chat
 
 $ cd authtest
 
 $ pnpm install
  Packages: +271
  +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++  
  Packages are hard linked from the content-addressable store to the virtual store.
    Content-addressable store is at: C:\Users\osamu\AppData\Local\pnpm\store\v3    
    Virtual store is at:             node_modules/.pnpm
  Progress: resolved 293, reused 267, downloaded 4, added 271, done
  node_modules/.pnpm/svelte-preprocess@5.1.0_postcss@8.4.31_svelte@4.0.5_typescript@5.3.2/node_modules/svelte-preprocess: Running postinstall script, done in 57ms
  node_modules/.pnpm/@sveltejs+kit@1.27.4_svelte@4.0.5_vite@4.4.2/node_modules/@sveltejs/kit: Running postinstall script, done in 6.4s
  
  devDependencies:
  + @playwright/test 1.28.1 (1.34.3 is available)
  + @sveltejs/adapter-auto 2.0.0 (2.1.0 is available)
  + @sveltejs/kit 1.27.4 (1.27.6 is available)
  + @typescript-eslint/eslint-plugin 6.0.0 (6.11.0 is available)
  + @typescript-eslint/parser 6.0.0 (6.11.0 is available)
  + eslint 8.28.0 (8.41.0 is available)
  + eslint-config-prettier 9.0.0
  + eslint-plugin-svelte 2.30.0 (2.35.0 is available)
  + prettier 3.0.0 (3.1.0 is available)
  + prettier-plugin-svelte 3.0.0 (3.1.0 is available)
  + svelte 4.0.5 (4.2.5 is available)
  + svelte-check 3.6.0
  + tslib 2.4.1 (2.5.2 is available)
  + typescript 5.0.2 (5.3.2 is available)
  + vite 4.4.2 (5.0.0 is available)
  + vitest 0.32.2 (0.34.6 is available)
  
  Done in 21.5s
  
 $ git init && git add -A && git commit -m "Initial commit"
  Initialized empty Git repository in C:/Users/osamu/Desktop/svelte/authtest/.git/
  [master (root-commit) ab94078] Initial commit
   20 files changed, 2586 insertions(+)
   create mode 100644 .eslintignore
   create mode 100644 .eslintrc.cjs
   create mode 100644 .gitignore
   create mode 100644 .npmrc
   create mode 100644 .prettierignore
   create mode 100644 .prettierrc
   create mode 100644 README.md
   create mode 100644 package.json
   create mode 100644 playwright.config.ts
   create mode 100644 pnpm-lock.yaml
   create mode 100644 src/app.d.ts
   create mode 100644 src/app.html
   create mode 100644 src/index.test.ts
   create mode 100644 src/lib/index.ts
   create mode 100644 src/routes/+page.svelte
   create mode 100644 static/favicon.png
   create mode 100644 svelte.config.js
   create mode 100644 tests/test.ts
   create mode 100644 tsconfig.json
   create mode 100644 vite.config.ts
 $ pnpm run dev -- --open
 
 > authtest@0.0.1 dev C:\Users\osamu\Desktop\svelte\authtest
 > vite dev "--" "--open"
 
   VITE v4.4.2  ready in 3242 ms
 
   ➜  Local:   http://localhost:5173/
   ➜  Network: use --host to expose  
   ➜  press h to show help

これで http://localhost:5173/ に Welcome to SvelteKit が表示された。

** サインアップフォームを作成 [#v0f7cb3f]

src/routes/(loggedOut)/signup/+page.svelte
 LANG: html
 <div>
 	<h1>サインアップ</h1>
 	<form method="POST">
 		<label for="name">ユーザー名</label>
 		<input type="text" name="name" />
 
 		<label for="email">メールアドレス</label>
 		<input type="text" name="email" />
 
 		<label for="password">パスワード</label>
 		<input type="password" name="password" />
 
 		<label for="confirmPassword">パスワード(確認)</label>
 		<input type="password" name="confirm"/>
 		
 		<div><button>サインアップ</button></div>
 		<a href="/login">ログイン</a>
 	</form>
 </div>

http://localhost:5173/signup でフォームを表示可能なことを確認

POST に対応する Action を書いていないため「ログイン」を押すと "405 POST method not allowed. No actions exist for this page" になることを確認。

src/routes/(loggedOut)/signup/+page.server.ts
 LANG: ts
 import type { Actions } from './$types';
 import { fail } from '@sveltejs/kit';
 
 export const actions: Actions = {
   default: async () => {
 
     return fail(400, {message: 'サインアップ処理が未実装'})
 
   }
 };

これで「ログイン」を押してもエラーは出なくなった一方、エラーメッセージも表示されない。

src/routes/(loggedOut)/signup/+page.svelte の冒頭と末尾を下記のように変更
 LANG: html
 <script lang="ts">
   import type { ActionData } from './$types';
   export let form: ActionData;
 </script>
 
 <div>
   <h1>サインアップ</h1>
 
   {#if form?.message}<span class="invalid">{form.message}</span>{/if}
   ...
 
 </div>
 <style>
   .invalid {
     color: red;
   }
 </style>

これでエラーメッセージが表示されるようになった。

 LANG:console
 $ git add .
  
 $ git commit -m "signup ページを作成"
  [master e758e44] signup ページを作成
   2 files changed, 36 insertions(+)
   create mode 100644 src/routes/(loggedOut)/signup/+page.server.ts
   create mode 100644 src/routes/(loggedOut)/signup/+page.svelte

** 入力内容の validation を行うために SuperForms を入れる [#o1950c2d]

 LANG:console
 $ pnpm i -D sveltekit-superforms zod
  Packages: +2
  ++
  Progress: resolved 295, reused 273, downloaded 0, added 2, done
  
  devDependencies:
  + sveltekit-superforms 1.10.2
  + zod 3.22.4
  
  Done in 3.6s

*** signup フォーム用のスキーマを追加 [#ud9711bc]

src/lib/formSchemas/signup.ts
 LANG:ts
 import { z } from 'zod';
 
 // メールアドレスを表す現実的な正規表現 by @sakuro 
 // https://qiita.com/sakuro/items/1eaa307609ceaaf51123
 const emailRegexp = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/;
 
 export const schema = z
   .object({
     name: z.string().min(3, { message: 'ユーザー名は3文字以上で入力してください' }),
     email: z.string().regex(emailRegexp, { message: 'メールアドレスが不正です' }),
     password: z
       .string()
       .regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9]).{8,}$/, {
         message: 'パスワードは小文字・大文字・数字を一文字以上含めて8文字以上で入力してください'
       }),
     confirm: z.string(),
   })
   .refine((data) => data.password === data.confirm, {
     message: '確認用パスワードが一致しません',
     path: ['confirm']
   });


*** バリデーションを行う [#c81e3fa9]

src/routes/(loggedOut)/signup/+page.server.ts
 LANG:ts
 import type { Actions, PageServerLoad } from './$types';
 import { schema } from '$lib/formSchemas/signup';
 import { superValidate } from 'sveltekit-superforms/server';
 import { fail } from '@sveltejs/kit';
 
 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 });
 		}
 
 		// TODO: サインアップ処理をここに
 
 		return { form };
 	}
 };

*** エラーメッセージを表示するためのフィールドを用意する [#vd7d8e82]

src/routes/(loggedOut)/signup/+page.svelte
 LANG:html
 <script lang="ts">
   import type { PageData } from './$types';
   import { superForm } from 'sveltekit-superforms/client';
 
   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>
   {#if $message}<span class="invalid">{$message}</span>{/if}
   <form method="POST" use:enhance>
     <label for="name">ユーザー名</label>
     <input type="text" name="name" bind:value={$form.name} disabled={$submitting} />
     {#if $errors.name}<span class="invalid">{$errors.name}</span>{/if}
 
     <label for="password">パスワード</label>
     <input type="password" name="password" bind:value={$form.password} disabled={$submitting} />
     {#if $errors.password}<span class="invalid">{$errors.password}</span>{/if}
 
     <label for="confirmPassword">パスワード(確認)</label>
     <input type="password" name="confirm" bind:value={$form.confirm} disabled={$submitting} />
     {#if $errors.confirm}<span class="invalid">{$errors.confirm}</span>{/if}
 
     <div><button disabled={$submitting}>サインアップ</button></div>
     <a href="/login">ログイン</a>
   </form>
 </div>
 
 <style>
   .invalid {
     color: red;
   }
 </style>

これで正しくエラーが検出されるようになった。

** Prisma の導入 [#qcf227f1]

https://www.prisma.io/docs/getting-started/quickstart に従って
さしあたり `sqlite` を使うよう初期化

 LANG:console
 $ pnpm i -D prisma && pnpm i @prisma/client
  Packages: +2
  ++
  Progress: resolved 297, reused 275, downloaded 0, added 2, done
  
  devDependencies:
  + prisma 5.6.0
 
  Done in 5.4s
  Packages: +2
  ++
  Progress: resolved 299, reused 277, downloaded 0, added 2, done
  
  dependencies:
  + @prisma/client 5.6.0
  
  Done in 8.5s
 
 $ npx prisma init --datasource-provider sqlite
  
  ✔ Your Prisma schema was created at prisma/schema.prisma
    You can now open it in your favorite editor.
  
  warn You already have a .gitignore file. Don't forget to add `.env` in it to not commit any private information.
  
  Next steps:
  1. Set the DATABASE_URL in the .env file to point to your existing database. If your database has no tables yet, read https://pris.ly/d/getting-started
  2. Run prisma db pull to turn your database schema into a Prisma schema.
  3. Run prisma generate to generate the Prisma Client. You can then start querying your database.
  
  More information in our documentation:
  https://pris.ly/d/getting-started
 
 $ cat .env
  ...
  DATABASE_URL="file:./dev.db"

データベースは `(app)/prisma/dev.db` に作成される。

コミットされないよう .gitignore に追加しておく

 LANG: console
 $ echo "/prisma/*.db" >> .gitignore
 $ echo "/prisma/*.db-journal" >> .gitignore

*** Lucia に対応したテーブルを定義 [#a634da69]

prisma/schema.prisma
 LANG: prisma
 model AuthUser {
   id           String        @id @unique
   auth_session AuthSession[]
   auth_key     AuthKey[]
 
   name     String       @unique
   email     String       @unique
 
   @@map("auth_user")
 }
 
 model AuthSession {
   id             String   @id @unique
   user_id        String
   active_expires BigInt
   idle_expires   BigInt
   auth_user      AuthUser @relation(references: [id], fields: [user_id], onDelete: Cascade)
 
   @@index([user_id])
   @@map("auth_session")
 }
 
 model AuthKey {
   id              String   @id @unique
   hashed_password String?
   user_id         String
   primary_key     Boolean
   expires         BigInt?
   auth_user       AuthUser @relation(references: [id], fields: [user_id], onDelete: Cascade)
 
   @@index([user_id])
   @@map("auth_key")
 }

*** migrate する [#zcaba667]

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

*** ライブラリに追加 [#u2aec515]

サーバー上でしか使わないので lib/server の下に置く

src/lib/server/db.ts
 LANG: ts
 import { PrismaClient } from '@prisma/client';
 
 export const db = new PrismaClient();

*** commit [#hdfc72da]

 LANG: console
 $ git add .
 
 $ git status
  On branch master
  Changes to be committed:
    (use "git restore --staged <file>..." to unstage)
          modified:   .gitignore
          modified:   package.json
          modified:   pnpm-lock.yaml
          new file:   prisma/dev.db-journal
          new file:   prisma/migrations/20231121075732_add_tables_for_lucia/migration.sql
          new file:   prisma/migrations/migration_lock.toml
          new file:   prisma/schema.prisma
 
 $ git commit -m "Prisma を使い Lucia 用のテーブルを準備"

** Lucia を導入 [#p8368e9f]

Lucia は認証やセッション管理を行うライブラリ。

 LANG: console
 $ pnpm i lucia-auth @lucia-auth/adapter-prisma
   WARN  deprecated lucia-auth@1.8.0: Deprecated - see the docs for migrating to v2
  Packages: +3
  +++
  Progress: resolved 302, reused 280, downloaded 0, added 0, done
  
  dependencies:
  + @lucia-auth/adapter-prisma 3.0.2
  + lucia-auth 1.8.0 deprecated
  
  Done in 3s

んー、1.8.0 は deprecated だから v2 を使えと言われる?

じゃあ https://lucia-auth.com/getting-started/sveltekit/ を見ながら v2 を使おう

 LANG: console
 $ pnpm rm lucia-auth @lucia-auth/adapter-prisma
  Packages: -3
  ---
  Progress: resolved 299, reused 277, downloaded 0, added 0, done
  
  dependencies:
  - @lucia-auth/adapter-prisma 3.0.2
  - lucia-auth 1.8.0
  
  Done in 1.9s
  
 $ pnpm i lucia @lucia-auth/adapter-prisma
  Packages: +2
  ++
  Progress: resolved 301, reused 279, downloaded 0, added 0, done
  
  dependencies:
  + @lucia-auth/adapter-prisma 3.0.2
  + lucia 2.7.4
  
  Done in 3.2s

*** Lucia v2 に対応してテーブル定義を変更 [#uc08b5db]

AuthKey から primary_key と expires を削除

prisma/schema.prisma
   model AuthKey {
      id              String   @id @unique
      hashed_password String?
      user_id         String
 -    primary_key     Boolean
 -    expires         BigInt?
      auth_user       AuthUser @relation(references: [id], fields: [user_id], onDelete: Cascade)
  
     @@index([user_id])
     @@map("auth_key")
  }

データベースに反映

 LANG: console
 $ npx prisma migrate dev --name "alter tables for lucia v2"

*** Lucia の初期化 [#qe9c6371]

テーブルの対応や User テーブルに追加した追加の属性をライブラリに教える。

src/lib/server/lucia.ts
 LANG: ts
 import { lucia } from "lucia";
 import { sveltekit } from "lucia/middleware";
 import { dev } from "$app/environment";
 import { prisma } from "@lucia-auth/adapter-prisma";
 import { db } from "$lib/server/db";
 
 export const auth = lucia({
   env: dev ? "DEV" : "PROD",
   middleware: sveltekit(),
   adapter: prisma(db, {
     user: "authUser",
     key: "authKey",
     session: "authSession"
   }),
   getUserAttributes: (data) => {
     return {
       // IMPORTANT!!!!
       // `userId` included by default!!
       name: data.name,
       email: data.email
     };
   },
 });
 
 export type Auth = typeof auth;

*** locals.auth を追加 [#gb60f175]

User および Session に追加したフィールドの型定義を与えるとともに、

locals.auth に認証情報を持てるようにする

src/app.d.ts
 LANG: ts
 // See https://kit.svelte.dev/docs/types#app
 // for information about these interfaces
 
 declare global {
   declare namespace Lucia {
     type Auth = import('$lib/server/lucia').Auth;
     type DatabaseUserAttributes = {name: string, email: string};
     type DatabaseSessionAttributes = object;
   }
 
   namespace App {
     // interface Error {}
     interface Locals {
       auth: import('lucia').AuthRequest;
     }
     // interface PageData {}
     // interface Platform {}
    }
 }
 
 export {};

*** hook で認証情報を読み込む [#m4cb4b20]

https://lucia-auth.com/basics/handle-requests/#sveltekit の通り、
hooks で event から認証情報を得て locals.auth へ入れる。

src/hooks.server.ts
 LANG: ts
 import { auth } from '$lib/server/lucia';
 import type { Handle } from '@sveltejs/kit';
 import { sequence } from '@sveltejs/kit/hooks';
 
 const authHandler: Handle = async ({ event, resolve }) => {
   event.locals.auth = auth.handleRequest(event);
 
   return await resolve(event);
 };
 
 export const handle = sequence(authHandler);

** サインアップ処理 [#jb708cb7]

v2 では createUser にて primaryKey の代わりに key とする。

src/routes/(loggedOut)/signup/+page.server.ts
 LANG: ts
 // 冒頭に追加
 import { auth } from '$lib/server/lucia';
 import { LuciaError } from 'lucia';
 ...
     // サインアップ処理
     try {
       const user = await auth.createUser({
         key: {
           providerId: 'email',
           providerUserId: form.data.email,
           password: form.data.password
         },
         attributes: {
           name: form.data.name,
           email: form.data.email,
         }
       });
 
       // そのままログイン状態にする
       const session = await auth.createSession({userId: user.userId, attributes: {}});
       event.locals.auth.setSession(session);
     } catch  (e) {
       if (e instanceof LuciaError && e.message === `AUTH_DUPLICATE_KEY_ID`) {
         return fail(400, { form: { ...form, message: '名前またはメールアドレスが既存のアカウントと重複しています' } });
       }
       // provided user attributes violates database rules (e.g. unique constraint)
       // or unexpected database errors
       return fail(400, { form: { ...form, message: 'サインアップエラー' } });
     }
 
     throw redirect(302, '/');

これでサインアップできるようになった。

ここではそのままログイン状態にしてしまっている。

メールアドレスの確認などを行うならログイン処理はせず /login へリダイレクトするのが良い。

*** データベースを確認 [#v8c34fa4]

テスト用にサインアップをしてみた後、

 LANG: console
 $ pnpm prisma studio

とすると Prisma Studio が立ち上がるので、AuthUser テーブルや AuthKey テーブルに正しく情報が登録していることを確認する。

*** ログイン状態を確認 [#k63bf4ed]

上記のコードではサインアップと同時にログインしているので、
hooks により locals.auth にログイン情報が入っているはず。

そこで、トップページでログイン情報を参照してみることにする。

src/routes/+page.server.ts
 LANG: ts
 import type { PageServerLoad } from "./$types";
 
 export const load: PageServerLoad = async ({locals}) => {
   const session = await locals.auth.validate();
   return {user: session?.user}
 }

session.user に格納されるログインユーザーの情報をページに渡す。

ページ側でそれを受け取って、

src/routes/+page.svelte
 LANG: html
 <script lang="ts">
   import type { PageData } from './$types';
   export let data: PageData;
 </script>
 
 <h1>Welcome {data.user ? data.user.name : 'to Svelte'}</h1>

とすれば、ログインしている場合に "Welcome (ユーザー名)" と表示されるはずで・・・

試したところうまく行ってるようだった。

** ログアウト処理 [#rbc8d465]

locals.auth の持つ validate() 結果 (session 情報) の内部キャッシュを無効化し、
それを参照するクッキーも削除する。

src/routes/(loggedIn)/logout/+server.ts
 LANG: ts
 import { redirect } from '@sveltejs/kit';
 import type { RequestHandler } from './$types';
 
 export const GET: RequestHandler = async ({ locals }) => {
   // 読み出し済みセッション情報の無効化
   locals.auth.invalidate();
   // クッキーからセッションID削除
   locals.auth.setSession(null);
   throw redirect(302, '/');
 };

確かにログアウトできた。

locals.auth は読み出し済みのセッション情報のキャッシュを消すだけで
データベース上のセッションレコードは消さないみたいなのだけれどそれでいいのかしら???

** ログイン処理 [#lda79f99]

フォームのバリデーション

メールアドレスやパスワードが正しいかどうかはログイン処理で確認するので
ここでは1文字でも入力されていれば良いことにする。

src/lib/formSchemas/login.ts
 LANG: ts
 import { z } from 'zod';
 
 export const schema = z
   .object({
     email: z.string().min(1, 'メールアドレスを入力して下さい'),
     password: z.string().min(1, 'パスワードを入力して下さい'),
   });

ログインフォーム

src/routes/(loggedOut)/login/+page.svelte
 LANG: html
 <script lang="ts">
   import type { PageData } from './$types';
   import { superForm } from 'sveltekit-superforms/client';
 
   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>
   {#if $message}<span class="invalid">{$message}</span>{/if}
   <form method="POST" use:enhance>
     <label for="email">ユーザー名</label>
     <input type="text" name="email" bind:value={$form.email} disabled={$submitting} />
     {#if $errors.email}<span class="invalid">{$errors.email[0]}</span>{/if}
 
     <label for="password">パスワード</label>
     <input type="password" name="password" bind:value={$form.password} disabled={$submitting} />
     {#if $errors.password}<span class="invalid">{$errors.password[0]}</span>{/if}
 
     <div><button disabled={$submitting}>ログイン</button></div>
     <a href="/signup">サインアップ</a>
   </form>
 </div>
 
 <style>
   .invalid {
     color: red;
   }
 </style>

ログイン処理

src/routes/(loggedOut)/login/+page.server.ts
 LANG: ts
 import type { Actions, PageServerLoad } from './$types';
 import { schema } from '$lib/formSchemas/login';
 import { superValidate } from 'sveltekit-superforms/server';
 import { fail } from '@sveltejs/kit';
 
 import { auth } from '$lib/server/lucia';
 
 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 });
     }
 
     // ログイン処理
     try {
       const key = await auth.useKey('email', form.data.email, form.data.password);
       const session = await auth.createSession({userId: key.userId, attributes: {}});
       event.locals.auth.setSession(session);
     } catch {
       return fail(400, { form: { ...form, message: 'ログインエラー' } });
     }
 
     throw redirect(302, '/');
   }
 };

ログインもできるようになった

** ログインあり・なしで閲覧制限する [#l79649c5]

src/hooks.server.ts
 LANG: ts
 const authHandler: Handle = async ({ event, resolve }) => {
   event.locals.auth = auth.handleRequest(event);
   const session = await event.locals.auth.validate();
 
   if (event.route.id?.startsWith('/(loggedIn)') && !session?.user) {
     return Response.redirect(`${event.url.origin}/login`, 302);
   }
 
   if (event.route.id?.startsWith('/(loggedOut)') && session?.user) {
     return Response.redirect(`${event.url.origin}/`, 302);
   }
 
   return await resolve(event);
 };

** 権限の管理を与える [#a538926a]

AuthUser と 多対多 関係を持つ Role を導入する。

prisma/schema.prisma
   model AuthUser {
   ...
 +   roles         Role[]
   }
  
 + model Role {
 +   id             String   @id @default(uuid())
 +   name           String   @unique
 + 
 +   users          AuthUser[]
 + }

マイグレーションする。

 LANG: console
 $ npx prisma migrate dev --name "add Role"

Prisma で多対多関係を扱うためのコードは結構煩雑になるようなので、
Role を使うためのユーティリティ関数を db に持たせる。

このやり方が良いのかは疑問が残る?

src/lib/server/db.ts
 LANG: ts
 import { PrismaClient } from '@prisma/client';
 
 export class ExtendedPrismaClient extends PrismaClient {
   constructor() { super() }
 
   // userId が undefined なら権限は空とみなされる
   async getRoles(userId: string | undefined) {
     if(!userId) {
       return []
     }
     return (await this.authUser.findUnique({
       where: {id: userId},
       select: {roles: true}
     }))?.roles || []
   }
 
   async getRolesString(userId: string | undefined) {
     return (await this.getRoles(userId)).map((role)=> role.name);
   }
 
   async hasRole(userId: string | undefined, role: string) {
     return (await this.getRolesString(userId)).includes(role);
   }
 
   async addRoles(userId: string, ...roles: string[]) {
     await this.authUser.update({
       where: { id: userId },
       data: {
         roles: {
           connectOrCreate: roles.map(role=>
             ({where: {name: role}, create: {name: role}})
           )
         },
       },
     })
   }
 
   async removeRoles(userId: string, ...roles: string[]) {
     await this.authUser.update({
       where: { id: userId },
       data: {
         roles: {
           deleteMany: roles.map(role=>({name: role}))
         },
       },
     })
   }
 }
 
 export const db = new ExtendedPrismaClient();

Prisma の言語仕様を見ていると TypeScript と VSCode の存在がいかに偉大か実感する。
こんな DSL はエディタ上でエラー検出してくれなきゃ、とてもじゃないけど書けない。

ところで、
 LANG: ts
 roles.map(role=>({name: role}))

などという記述に注意が必要。これを

 LANG: ts
 roles.map(role=>{name: role})

と書いてしまうと role=>{ の { がブロックの開始として解釈されてしまい、うまく行かない。
(その場合 name: はラベルとして解釈される)

JavaScriptのアロー関数でオブジェクトを返す方法~
https://dev.classmethod.jp/articles/arrow-func-return-object/

そこで一見すると無駄に見える括弧で括っている。

サインアップ時、最初のユーザーには admin 権限を持たせることにする。

src/routes/(loggedOut)/signup/+page.server.ts

 + import { db } from '$lib/server/db'
 ...
 
 +       // 最初のユーザーには admin 権限を持たせる
 +       if(await db.authUser.count() == 1) {
 +         await db.addRoles(user.userId, 'admin')
 +       }

/(admin) あるいは /admin から始まるパスには admin 権限を持っているユーザーしかアクセスできない。

src/hooks.server.ts
 LANG:ts
 +  if (event.route.id?.startsWith('/(admin)') || event.route.id?.startsWith('/admin')) {
 +     if (!await db.hasRole(session?.user?.userId, 'admin')) {
 +       return Response.redirect(`${event.url.origin}/`, 302);
 +     }
 +  }

** flash メッセージ [#mb60d486]

redirect による遷移先で表示するメッセージをページ間で引き継ぐためのライブラリ。

http では遷移元と遷移先のページの表示は独立した処理になるため、
遷移元ページの処理結果に応じて遷移先ページにメッセージを出すには工夫がいる。
それを担うのがこのライブラリ。

もっともよくある使い方としては、フォームの POST が成功した場合に redirect で
別ページに移るのが良い作りとされているので、
その遷移先で送信成功を示すメッセージを表示するのにこのライブラリを使える。

https://www.npmjs.com/package/sveltekit-flash-message の通りの手順で、

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

src/app.d.ts
 LANG: ts
 declare namespace App {
   ...
 
   interface PageData {
     flash?: { type: 'success' | 'error'; message: string };
   }
   ...
 }

src/routes/+layout.server.ts
 LANG: ts
 export { load } from 'sveltekit-flash-message/server';

src/routes/+layout.svelte
 LANG: html
 <script lang="ts">
   import { getFlash } from 'sveltekit-flash-message';
   import { page } from '$app/stores';
 
   const flash = getFlash(page);
 </script>
 
 {#if $flash}
   {@const bg = $flash.type == 'success' ? '#3D9970' : '#FF4136'}
   <div style:background-color={bg} class="flash">{$flash.message}</div>
 {/if}
 
 <slot />

これで準備完了。

 LANG: console
 $ git commit -m "sveltekit-flash-message を導入"

*** redirect しているところに片っ端からメッセージを付ける (server 側) [#u39a8996]

@sveltejs/kit の redirect の代わりに~
sveltekit-flash-message/server の redirect を使いメッセージを追加する。

src/routes/(loggedOut)/signup/+page.svelte
 LANG: ts
 - import { fail, redirect } from '@sveltejs/kit';
 + import { fail } from '@sveltejs/kit';
 + import { redirect } from 'sveltekit-flash-message/server';
 ...
 -     throw redirect(302, '/');
 +     throw redirect(302, '/', { type: 'success', message: 'サインアップしました'}, event);

みたいな感じ。

src/routes/(loggedOut)/login/+page.svelte
 LANG: ts
     throw redirect(302, '/', { type: 'success', message: 'ログインしました'}, event);

も問題ない。

ところが +server.ts の RequestHandler からだと event が手に入らないため困ってしまう。

この場合どうしたらいいんだろう???

"sveltekit-flash-message" "RequestHandler" で検索しても何も出ないのだけれど...

無理やりダミーの RequestEvent を作ればなんとかはなる。でもこうするしかないのかどうか???

 LANG: ts
 export const GET: RequestHandler = async ({ locals, cookies }) => {
   // 読み出し済みセッション情報の無効化
   locals.auth.invalidate();
   // クッキーからセッションID削除
   locals.auth.setSession(null);
   
   const dummyEvent = {cookies} as RequestEvent;
   throw redirect(302, '/', { type: 'success', message: 'ログアウトしました'}, dummyEvent);
 };

src/hooks.server.ts については event は手に入るけど redirect の仕方が違うので、
redirect と独立して flash メッセージを設定するため setFlash を使う?

と思って

src/hooks.server.ts
 + import { setFlash } from 'sveltekit-flash-message/server';
 
 ...
 
     if (event.route.id?.startsWith('/(loggedOut)') && session?.user) {
 +     setFlash({ type: 'error', message: 'すでにログインしています' }, event);
       return Response.redirect(`${event.url.origin}/`, 302);
     }

のようにしてみたけどうまく行かなかった。

event.cookies.set を呼んでももそれは event の中を書き換えるだけで、
Response.redirect が event を参照しないから何の意味もない。

ここでも throw redirect に event を与える形でリダイレクトするのが正解らしい。

 LANG: ts
     if (event.route.id?.startsWith('/(loggedOut)') && session?.user) {
 -     return Response.redirect(`${event.url.origin}/`, 302);
 +        throw redirect(302, '/', { type: 'error', message: 'すでにログインしています' }, event);
     }


src/hooks.server.ts
 LANG: ts
 + import { redirect } from 'sveltekit-flash-message/server';
 
 ...
 
   const authHandler: Handle = async ({ event, resolve }) => {
     event.locals.auth = auth.handleRequest(event);
     const session = await event.locals.auth.validate();
   
     if (event.route.id?.startsWith('/(admin)') || event.route.id?.startsWith('/admin')) {
        if (!await db.hasRole(session?.user?.userId, 'admin')) {
 -        return Response.redirect(`${event.url.origin}/`, 302);
 +        throw redirect(302, '/', { type: 'error', message: '管理者のみアクセス可能です' }, event);
        }
     }
   
     if (event.route.id?.startsWith('/(loggedIn)') && !session?.user) {
 -     return Response.redirect(`${event.url.origin}/login`, 302);
 +        throw redirect(302, '/', { type: 'error', message: 'ログインユーザーのみアクセス可能です' }, event);
     }
   
     if (event.route.id?.startsWith('/(loggedOut)') && session?.user) {
 -     return Response.redirect(`${event.url.origin}/`, 302);
 +        throw redirect(302, '/', { type: 'error', message: 'すでにログインしています' }, event);
     }
   
     return await resolve(event);
   };

としたらうまく行った。

*** 見せ方 [#f2e8d67c]

特にクライアントサイドから表示した場合には
画面上部に出るだけだと気づけない場合もあるので、~
「表示領域の上部に出て、クリックされたら、あるいはしばらくしたら、引っ込む」~
みたいな動作が望ましい気がする。

https://github.com/ciscoheat/sveltekit-flash-message#toast-messages-event-style

によれば svelte-french-toast を使うと良いらしい。

 LANG: console
 $ pnpm i svelte-french-toast
 Packages: +2
 ++
 Progress: resolved 304, reused 280, downloaded 2, added 2, done
 
 dependencies:
 + svelte-french-toast 1.2.0
 
 Done in 4.9s

src/routes/+layout.svelte
 LANG: html
 <script lang="ts">
   import { getFlash } from 'sveltekit-flash-message';
   import { page } from '$app/stores';
   import toast, { Toaster } from 'svelte-french-toast';
 
   const flash = getFlash(page);
 
   flash.subscribe(($flash) => {
     if (!$flash) return;
 
     toast($flash.message, {
       icon: $flash.type == 'success' ? '✅' : '❌'
     });
 
     // Clearing the flash message could sometimes
     // be required here to avoid double-toasting.
     flash.set(undefined);
   });
 </script>
 
 <Toaster />
 <slot />

これでうまく行った。

*** クライアントサイドで使う例も後で勉強すべき [#l5add4ea]

https://www.npmjs.com/package/sveltekit-flash-message#client-side

どういう風に実装されているかまで理解しないと
どうしてこういう処理が必要になるのか納得しづらいかも。

** サインアップ時にメールアドレスの確認を行う [#ufa1af42]

いきなりサインアップするのではなく、入力されたメールアドレスに確認リンク入りのメールを送って
メールアドレスの確認が取れたらサインアップを行う、という動作をしたい。

https://lucia-auth.com/guidebook/email-verification-links/

によると 

- authUser テーブルに email_verified フィールドを追加 
- emailVerification テーブルを追加

という形で行う形が紹介されているけれど、
user テーブルに email_verified フィールドを追加する代わりに、
そもそも email を verify しないと signup ページに
たどり着けない形にした方が間違いが生じない気がする?

+ /signup でメールアドレスのみ入力
+ メールが届く
+ メールに記載のリンク先で名前やパスワードなどメールアドレス以外の情報を入力
-- /signup/xxxxxxxx

のような。

これを実現するため emailVerification テーブルには以下のフィールドを定義する。

- token @id @default(uuid())
- email
- createdAt

流れはこんな感じ

&uml(
skinparam handwritten true
actor User
participant System
database Database
User -> System : /signup へアクセス
System --> User : メールアドレス入力フォームを表示
User -> System : メールアドレスを入力
System -> Database : トークンとメールアドレスを保存
System --> User : トークン入りの URL をメールで通知
User -> System : トークン入りの URL へアクセス
System -> Database : トークンを確認
Database --> System : メールアドレスを返す
System --> User : サインアップフォームを表示
User -> System : サインアップフォームを送信
System -> Database : トークンを確認
Database --> System : メールアドレスを返す
System -> Database : ユーザー登録
System -> Database : トークンを削除
System --> User : サインアップ完了を表示
);

*** パスワードリセットもできるようにしたい [#w3f0baa5]

サインアップと同様に以下の流れで行う。

+ /reset-password にメールアドレスだけを入力
+ メールが届く
+ メールに記載のリンク先で新しいパスワードを入力
-- /reset-password/xxxxxxxx

&uml(
skinparam handwritten true
actor User
participant System
database Database
User -> System : /reset-password へアクセス
System --> User : メールアドレス入力フォームを表示
User -> System : メールアドレスを入力
System -> Database : トークンとメールアドレスを保存
System --> User : トークン入りの URL をメールで通知
User -> System : トークン入りの URL へアクセス
System -> Database : トークンを確認
Database --> System : メールアドレスを返す
System --> User : パスワード入力フォームを表示
User -> System : パスワード入力フォームを送信
System -> Database : トークンを確認
Database --> System : メールアドレスを返す
System -> Database : パスワード変更
System -> Database : トークンを削除
System --> User : パスワード変更完了を表示
);

*** emailVerification テーブル [#gc99d89b]

prisma/schema.prisma
 + model emailVerification {
 +   id String @id @default(uuid())
 +   email String @unique
 +   createdAt DateTime @default(now()) @map("created_at")
 + }

id を秘密のトークンとして使う

 LANG:console
 $ npx prisma migrate dev --name "add emailVerification table"

*** メールアドレス入力フォーム [#j7548045]

メールアドレス確認用の正規表現を使いまわせるようライブラリに切り出す

src/lib/emailRegexp.ts
 LANG: ts
 // https://qiita.com/sakuro/items/1eaa307609ceaaf51123
 export const emailRegexp = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/;

メールアドレスのチェックを行うスキーマを用意

src/lib/formSchemas/emailVerification.ts
 LANG: ts
 import { z } from 'zod';
 import { emailRegexp } from '$lib/emailRegexp'
 
 export const schema = z
   .object({
     email: z.string().regex(emailRegexp, { message: 'メールアドレスが不正です' }),
   })

サインアップとパスワードリセットの両方を扱えるようにするため
matcher を使ったルーティングを行う。

https://kit.svelte.jp/docs/advanced-routing#matching

src/lib/emailVerificationPurposes.ts
 LANG: ts
 export const purposes = {
   signup: 'サインアップ', 
   'reset-password': 'パスワードリセット' 
 };

src/params/emailVerificationPurpose.ts
 LANG: ts
 import type { ParamMatcher } from '@sveltejs/kit';
 import { purposes } from '$lib/emailVerificationPurposes';
 
 export const match: ParamMatcher = (param) => {
   return Object.hasOwn(purposes, param);
 };

src/routes/(loggedOut)/[purpose=emailVerificationPurpose]/+page.svelte
 LANG: html
 <script lang="ts">
   import type { PageData } from './$types';
   import { superForm } from 'sveltekit-superforms/client';
   import { purposes } from '$lib/emailVerificationPurposes';
 
   export let data: PageData;
   const { form, message, errors, submitting, capture, restore, enhance } = superForm(data.form, {
     taintedMessage: false
   });
   export const snapshot = { capture, restore };
 
   const purpose = purposes[data.purpose];
 </script>
 
 <div>
   <h1>メールアドレスを入力して下さい</h1>
   <p>入力されたアドレスへ {purpose} を続けるためのリンクを含むメールを送信します<br />
     そちらのリンクから {purpose} 手続きを進めて下さい</p>
   {#if $message}<span class="invalid">{$message}</span>{/if}
   <form method="POST" use:enhance>
     <label for="email">メールアドレス</label>
     <input type="text" name="email" bind:value={$form.email} disabled={$submitting} />
     {#if $errors.email}<span class="invalid">{$errors.email}</span>{/if}
 
     <div><button disabled={$submitting}>メールを送信</button></div>
   </form>
 </div>
 
 <style>
   .invalid {
     color: red;
   }
 </style>

src/routes/(loggedOut)/[purpose=emailVerificationPurpose]/+page.server.ts
 LANG: ts
 import type { Actions, PageServerLoad, PageServerLoadEvent, RequestEvent } from './$types';
 import { schema } from '$lib/formSchemas/emailVerification';
 import { superValidate } from 'sveltekit-superforms/server';
 import { fail } from '@sveltejs/kit';
 import { redirect } from 'sveltekit-flash-message/server';
 import { purposes } from '$lib/emailVerificationPurposes';
 import { transporter } from '$lib/server/transporter';
 
 import { db } from '$lib/server/db'
 
 async function getPurpose(event: PageServerLoadEvent | RequestEvent) {
   const purpose = event.params.purpose as keyof typeof purposes;
   return purpose;
 }
 
 export const load = (async (event) => {
   const form = await superValidate(schema);
   const purpose = await getPurpose(event);
   return { form, purpose };
 }) satisfies PageServerLoad;
 
 export const actions: Actions = {
   default: async (event) => {
     
     // フォームデータのバリデーション
     const form = await superValidate(event, schema);
     const purpose = await getPurpose(event);
     if (!form.valid) {
       return fail(400, { form, purpose });
     }
 
     // TODO: emailVerification レコードを作成
 
     // TODO: メールを送信
 
     throw redirect(302, '/', { type: 'success', message: 'メールを送信しました'}, event);
   }
 };

*** emailVerification レコードを作成 [#z328139c]

一定時間以内に作成されたものがすでに存在すればエラーにする

src/routes/(loggedOut)/[purpose=emailVerificationPurpose]/+page.server.ts
 LANG: ts
     // emailVerification レコードが既に存在するか?
     const existing = await db.emailVerification.findUnique({where: {email: form.data.email}})
     if(existing) {
       // 2時間以内に送っていればエラーにする
       if(existing.createdAt.getTime() > Date.now() - (1000 * 60 * 60 * 2)) {
         throw redirect(302, '/', { type: 'error', message: '先ほどメールを送信しましたのでしばらく経ってから試してください'}, event);
       }
 
       // 既存のものを削除
       await db.emailVerification.delete({where: {email: form.data.email}});
     }
 
     // レコードを作成する
     const record = await db.emailVerification.create({data: {email: form.data.email}});
     const token = record.id;

*** メールを送信 [#hb25fe9c]

アドレスは /signup/[token] になる

https://shinobiworks.com/blog/385/ のように nodemailer を使う

 LANG: console
 $ npm i nodemailer && npm i --save-dev @types/nodemailer

src/lib/server/transporter.ts
 LANG: ts
 import { createTransport } from "nodemailer";
 
 export const transporter = createTransport({
   host: "localhost",
   port: 25,
 });

src/routes/(loggedOut)/[purpose=emailVerificationPurpose]/+page.server.ts
 LANG: ts
 import { transporter } from '$lib/server/transporter';
 ...
 
     // メールを送信
 
     const url = `${event.url.origin}/${purpose}/${token}`
 
     try {
       await transporter.sendMail({
         from: `"authtest system" <authtest@my.server.net>`,
         to: form.data.email,
         subject: `[authtest] ${purposes[purpose]} 用のリンクをお送りします`,
         text: `次の URL より ${purposes[purpose]} の手続きをお勧めください\n${url}`
       });
     } catch (error) {
       throw redirect(302, event.url, { type: 'error', message: 'メールを送信できませんでした'}, event);
     }

*** サインアップフォーム [#j99985ea]

フォームからメールアドレス欄を除く

src/lib/formSchemas/signup.ts
 LANG: ts
   import { z } from 'zod';
 
 - // https://qiita.com/sakuro/items/1eaa307609ceaaf51123
 - const emailRegexp = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/;
 -
   export const schema = z
     .object({
       name: z.string().min(3, { message: 'ユーザー名は3文字以上で入力してください' }),
 -     email: z.string().regex(emailRegexp, { message: 'メールアドレスが不正です' }),
       password: z
         .string()
         .regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9]).{8,}$/, {
           message: 'パスワードは小文字・大文字・数字を一文字以上含めて8文字以上で入力してください'
         }),
       confirm: z.string(),
     })
     .refine((data) => data.password === data.confirm, {
       message: '確認用パスワードが一致しません',
       path: ['confirm']
     });

disabled にしたフィールドは表示専用になりフォーム送信時にデータが送られない。

src/routes/(loggedOut)/signup/[token]/+page.svelte
 LANG: html
       <label for="email">メールアドレス</label>
 +     <input type="text" name="email" bind:value={data.email} disabled />
 -     <input type="text" name="email" bind:value={$form.email} disabled={$submitting} />
 -     {#if $errors.email}<span class="invalid">{$errors.email}</span>{/if}

以前の src/routes/(loggedOut)/signup/+page.* を
src/routes/(loggedOut)/signup/[token]/+page.* に移す。

form と共に email アドレスを返す。

src/routes/(loggedOut)/signup/[token]/+page.server.ts
 LANG: ts
 - import type { Actions, PageServerLoad } from './$types';
 + import type { Actions, PageServerLoad, PageServerLoadEvent, RequestEvent } from './$types';
   ...
 
 + async function getEmail(event: PageServerLoadEvent | RequestEvent) {
 +   const record = await db.emailVerification.findUnique({where: {id: event.params.token}})
 +   if(!record || Date.now() - (1000 * 60 * 60 * 2) > record.createdAt.getTime()) {
 +     // 見つからないあるいは古すぎる
 +     throw redirect(302, '/', {type: 'error', message: '無効なトークンです'}, event);
 +   }
 +   return record.email;
 + }
 
 - export const load = (async () => {
 + export const load = (async (event) => {
     const form = await superValidate(schema);
 -   return { form };
 +   const email = await getEmail(event)
 +   return { form, email };
   }) satisfies PageServerLoad;
   
   export const actions: Actions = {
     default: async (event) => {
     
       // フォームデータのバリデーション
       const form = await superValidate(event, schema);
 +     const email = await getEmail(event)
       if (!form.valid) {
         return fail(400, { form, email });
       }
     
       // サインアップ処理
       try {
         const user = await auth.createUser({
           key: {
             providerId: 'email',
 -           providerUserId: form.data.email,
 +           providerUserId: email,
             password: form.data.password
           },
           attributes: {
             name: form.data.name,
 -           email: form.data.email,
 +           email: email,
           }
         });
       ...
 
 -         return fail(400, { form: { ...form, message: '名前またはメールアドレスが既存のアカウントと重複しています' } });
 +         return fail(400, { form: { ...form, message: '名前またはメールアドレスが既存のアカウントと重複しています' }, email });
 
       ...
 
 -       return fail(400, { form: { ...form, message: 'サインアップエラー' } });
 +       return fail(400, { form: { ...form, message: 'サインアップエラー' }, email });
       ...
 
 +    // レコードを消去
 +     await db.emailVerification.delete({where: {id: event.params.token}})
 
       throw redirect(302, '/', { type: 'success', message: 'サインアップしました'}, event);
     }
   };

これで大丈夫そう。

*** パスワードリセット [#l485ccd3]

メール送信フォームでそのメールアドレスを持つアカウントの存在を確認する

src/routes/(loggedOut)/[purpose=emailVerificationPurpose]/+page.server.ts
 LANG: ts
      // フォームデータのバリデーション
      const form = await superValidate(event, schema);
  
 +    // パスワードリセットは既存のアカウントがなければエラー
 +    if(purpose == 'reset-password') {
 +      if(!await db.authUser.findUnique({where: {email: form.data.email}})){
 +        form.errors.email = [...(form.errors.email || []), '登録されていません'];
 +        form.valid = false;
 +      }
 +    }
 
      if (!form.valid) {
        return fail(400, { form, purpose });
      }

フォーム検証スキーム

authtest/src/lib/formSchemas/resetPassword.ts
 LANG: ts
 import { z } from 'zod';
 
 export const schema = z
   .object({
     password: z
       .string()
       .regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9]).{8,}$/, {
         message: 'パスワードは小文字・大文字・数字を一文字以上含めて8文字以上で入力してください'
       }),
     confirm: z.string(),
   })
   .refine((data) => data.password === data.confirm, {
     message: '確認用パスワードが一致しません',
     path: ['confirm']
   });

ユーザー名とメールアドレスは変更不可

フォームに表示はするが disable なので値は POST されない。

src/routes/(loggedOut)/reset-password/[token]/+page.svelte
 LANG: html
 <script lang="ts">
   import type { PageData } from './$types';
   import { superForm } from 'sveltekit-superforms/client';
 
   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>
   {#if $message}<span class="invalid">{$message}</span>{/if}
   <form method="POST" use:enhance>
     <label for="name">ユーザー名</label>
     <input type="text" name="name" bind:value={data.user.name} disabled />
 
     <label for="email">メールアドレス</label>
     <input type="text" name="email" bind:value={data.user.email} disabled />
 
     <label for="password">パスワード</label>
     <input type="password" name="password" bind:value={$form.password} disabled={$submitting} />
     {#if $errors.password}<span class="invalid">{$errors.password}</span>{/if}
 
     <label for="confirm">パスワード(確認)</label>
     <input
       type="password"
       name="confirm"
       bind:value={$form.confirm}
       disabled={$submitting}
     />
     {#if $errors.confirm}<span class="invalid">{$errors.confirm}</span>{/if}
 
     <div><button disabled={$submitting}>パスワードを変更</button></div>
   </form>
 </div>
 
 <style>
   .invalid {
     color: red;
   }
 </style>

src/routes/(loggedOut)/reset-password/[token]/+page.server.ts
 LANG: ts
 import type { Actions, PageServerLoad, PageServerLoadEvent, RequestEvent } from './$types';
 import { schema } from '$lib/formSchemas/signup';
 import { superValidate } from 'sveltekit-superforms/server';
 import { fail } from '@sveltejs/kit';
 import { redirect } from 'sveltekit-flash-message/server';
 import { auth } from '$lib/server/lucia';
 import { db } from '$lib/server/db'
 
 async function getUser(event: PageServerLoadEvent | RequestEvent) {
   const record = await db.emailVerification.findUnique({where: {id: event.params.token}});
   if(!record || Date.now() - (1000 * 60 * 60 * 2) > record.createdAt.getTime()) {
     throw redirect(302, '/', {type: 'error', message: '無効なトークンです'}, event);
   }
 
   const user = await db.authUser.findUnique({where: {email: record.email}});
   if (!user) {
     throw redirect(302, '/', {type: 'error', message: '無効なトークンです'}, event);
   }
   return user;
 }
 
 export const load = (async (event) => {
   const form = await superValidate(schema);
   const user = await getUser(event);
   return { form, user };
 }) satisfies PageServerLoad;
 
 export const actions: Actions = {
   default: async (event) => {
     // フォームデータのバリデーション
     const form = await superValidate(event, schema);
     const user = await getUser(event);
     if (!form.valid) {
       return fail(400, { form, user });
     }
     
     // パスワードを変更
     await auth.updateKeyPassword(
       'email', user.email, form.data.password
     )
 
     // 直接ログインする
     const session = await auth.createSession({userId: user.id, attributes: {}});
     event.locals.auth.setSession(session);
 
     // レコードを消去
     await db.emailVerification.delete({where: {id: event.params.token}});
 
     throw redirect(302, '/', { type: 'success', message: 'パスワードを変更してログインしました'}, event);
   }
 };

** メールアドレス変更 [#g1870852]

これもメールアドレスを確認後、飛び先で確認ボタンを押したら変更という形にすべき。

'chage-email' というパスを用意

src/lib/emailVerificationPurposes.ts
 LANG: ts
   export const purposes = {
     signup: 'サインアップ', 
     'reset-password': 'パスワードリセット',
 +   'change-email': 'メールアドレス変更',
   };

signup や reset-password はログアウト状態で行う処理だったのに対して
この作業はログイン状態で行うので、

src/routes/(loggedOut)/[purpose=emailVerificationPurpose] を~
src/routes/[purpose=emailVerificationPurpose] へ移動した上で、

コード上でログイン状態を確認することにした。

|>||>|!!session?.user|
|~|~|true|false|
|purpose == 'change-email'|true|OK|Error|
|~|false|Error|OK|

なので (purpose == 'change-email') !== (!! session?.user) で判断すればいい。

src/routes/[purpose=emailVerificationPurpose]/+page.server.ts
 LANG: ts
   async function getPurpose(event: PageServerLoadEvent | RequestEvent) {
     const purpose = event.params.purpose as keyof typeof purposes;
 +   const session = await event.locals.auth.validate();
 +   if((purpose == 'change-email') !== (!! session?.user)) {
 +     throw redirect(302, '/', { type: 'error', message: 'ログイン状態が無効です'}, event);
 +   }
     return purpose;
   }
       ...
 
 +     // メールアドレス変更は既存のアカウントがあければエラー
 +     if(purpose == 'change-email') {
 +       if(await db.authUser.findUnique({where: {email: form.data.email}})){
 +         form.errors.email = [...(form.errors.email || []), '既存のアカウントと重複しています'];
 +         form.valid = false;
 +       }
 +     }

フォームは単なる確認用なのでデータは何も受け取らない

したがって、バリデーション用スキームも必要ない

src/routes/(loggedIn)/change-email/[token]/+page.svelte
 LANG: html
 <script lang="ts">
   import type { PageData } from './$types';
   export let data: PageData;
 </script>
 
 <div>
   <h1>メールアドレスの変更</h1>
   <form method="POST">
     <label for="name">ユーザー名</label>
     <input type="text" name="name" value={data.user.name} disabled />
 
     <label for="email">古いメールアドレス</label>
     <input type="text" name="email" value={data.user.email} disabled />
 
     <label for="email">新しいメールアドレス</label>
     <input type="text" name="email" value={data.email} disabled />
 
     <div><button>メールアドレスを変更</button></div>
   </form>
 </div>

認証キー(ここではメールアドレス)を変更する機能は Lucia には備わっていない?

見つけられなかったのでデータベースを直接書き換えている。

src/routes/(loggedIn)/change-email/[token]/+page.server.ts
 LANG: ts
 import type { Actions, PageServerLoad, PageServerLoadEvent, RequestEvent } from './$types';
 import { redirect } from 'sveltekit-flash-message/server';
 import { db } from '$lib/server/db'
 
 async function getEmailAndUser(event: PageServerLoadEvent | RequestEvent) {
   const record = await db.emailVerification.findUnique({where: {id: event.params.token}});
   if(!record || Date.now() - (1000 * 60 * 60 * 2) > record.createdAt.getTime()) {
     throw redirect(302, '/', {type: 'error', message: '無効なトークンです'}, event);
   }
 
   const session = await event.locals.auth.validate();
   if(!session?.user) {
     throw redirect(302, '/login', {type: 'error', message: '旧メールアドレスでログインしてください'}, event);
   }
 
   return {email: record.email, user: session.user};
 }
 
 export const load = (async (event) => {
   return await getEmailAndUser(event);
 }) satisfies PageServerLoad;
 
 export const actions: Actions = {
   default: async (event) => {
     const {email, user} = await getEmailAndUser(event);
     
     // メールアドレスを変更
     await db.$transaction([
       db.authUser.update({
         where: {id: user.userId}, 
         data: {email},
       }),
       db.authKey.update({
         where: {id: `email:${user.email}`, user_id: user.userId}, 
         data: {id: `email:${email}`},
       }),
     ])
 
     // レコードを消去
     await db.emailVerification.delete({where: {id: event.params.token}});
 
     throw redirect(302, '/', { type: 'success', message: 'メールアドレスを変更しました'}, event);
   }
 };

** ユーザー情報変更 [#ea677d6d]

メールアドレス以外を変更できる

パスワードを変更しない場合には空にしておく

src/lib/formSchemas/changeProfile.ts
 LANG: ts
 import { z } from 'zod';
 
 export const schema = z
   .object({
     name: z.string().min(3, { message: 'ユーザー名は3文字以上で入力してください' }),
     password: z
       .string()
       .regex(/^$|^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9]).{8,}$/, {
         message: 'パスワードは小文字・大文字・数字を一文字以上含めて8文字以上で入力してください'
       }),
     confirm: z.string(),
   })
   .refine((data) => data.password === data.confirm, {
     message: '確認用パスワードが一致しません',
     path: ['confirm']
   });

src/routes/(loggedIn)/change-profile/+page.svelte
 LANG: html
 <script lang="ts">
   import type { PageData } from './$types';
   import { superForm } from 'sveltekit-superforms/client';
 
   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>
   {#if $message}<span class="invalid">{$message}</span>{/if}
   <form method="POST" use:enhance>
     <label for="name">ユーザー名</label>
     <input type="text" name="name" bind:value={$form.name} disabled={$submitting} />
     {#if $errors.name}<span class="invalid">{$errors.name}</span>{/if}
 
     <label for="email">メールアドレス</label>
     <input type="text" name="email" bind:value={data.user.email} disabled />
 
     <label for="password">パスワード(変更しないなら何も入力しない)</label>
     <input type="password" name="password" bind:value={$form.password} disabled={$submitting} />
     {#if $errors.password}<span class="invalid">{$errors.password}</span>{/if}
 
     <label for="confirm">パスワード(確認)</label>
     <input
       type="password"
       name="confirm"
       bind:value={$form.confirm}
       disabled={$submitting}
     />
     {#if $errors.confirm}<span class="invalid">{$errors.confirm}</span>{/if}
 
     <div><button disabled={$submitting}>ユーザー情報を変更</button></div>
   </form>
 </div>
 
 <style>
   .invalid {
     color: red;
   }
 </style>

src/routes/(loggedIn)/change-profile/+page.server.ts
 LANG: ts
 import type { Actions, PageServerLoad, PageServerLoadEvent, RequestEvent } from './$types';
 import { schema } from '$lib/formSchemas/changeProfile';
 import { superValidate } from 'sveltekit-superforms/server';
 import { fail } from '@sveltejs/kit';
 import { redirect } from 'sveltekit-flash-message/server';
 
 import { auth } from '$lib/server/lucia';
 import { db } from '$lib/server/db'
 
 async function getUser(event: PageServerLoadEvent | RequestEvent) {
   const session = await event.locals.auth.validate();
   return session!.user;
 }
 
 export const load = (async (event) => {
   const form = await superValidate(schema);
   const user = await getUser(event);
   form.data.name = user.name;
   return { form, user };
 }) satisfies PageServerLoad;
 
 export const actions: Actions = {
   default: async (event) => {
   
     // フォームデータのバリデーション
     const form = await superValidate(event, schema);
     const user = await getUser(event);
     if (!form.valid) {
       return fail(400, { form, user });
     }
   
     const changed = [] as string[];
     if(form.data.password) {
       await auth.updateKeyPassword('email', user.email, form.data.password);
       changed.push('パスワード');
     }
     if(form.data.name != user.name) {
       db.authUser.update({where: {id: user.userId}, data: {name: user.name}});
       changed.push('名前');
     }
     
     let message: string;
     if(changed.length == 0) {
       message = '何も変更されませんでした';
     } else {
       message = changed.join('と') + 'が変更されました';
     }
     throw redirect(302, '/', { type: 'success', message}, event);
   }
 };

** Node サーバーを作成してみる [#sfd1def2]

デプロイ手順を見てみる

https://kit.svelte.jp/docs/adapter-node

 LANG: console
 $ npm i -D @sveltejs/adapter-node
  Packages: +21
  +++++++++++++++++++++
  Progress: resolved 327, reused 284, downloaded 21, added 21, done
  
  devDependencies:
  + @sveltejs/adapter-node 1.3.1
  
  Done in 7s

svelte.config.js
 LANG: js
 - import adapter from '@sveltejs/adapter-auto';
 + // import adapter from '@sveltejs/adapter-auto';
 + import adapter from '@sveltejs/adapter-node';

あとは npm で build すれば node build/index.js で立ち上がる?

 LANG: console
 $ pnpm build
  
  > authtest@0.0.1 build C:\Users\osamu\Desktop\svelte\authtest
  > vite build
  
  
  vite v4.4.2 building SSR bundle for production...
  ✓ 159 modules transformed.
  
  vite v4.4.2 building for production...
  ✓ 122 modules transformed.
  .svelte-kit/output/client/_app/version.json                                             0.03 kB │ gzip:  0.05 kB
  .svelte-kit/output/client/.vite/manifest.json                                           7.03 kB │ gzip:  0.78 kB
  .svelte-kit/output/client/_app/immutable/assets/4.ff1cfcc4.css                          0.04 kB │ gzip:  0.06 kB
  .svelte-kit/output/client/_app/immutable/assets/0.3a6d0da3.css                          4.42 kB │ gzip:  1.20 kB
  .svelte-kit/output/client/_app/immutable/chunks/emailVerificationPurposes.c76ea6d4.js   0.10 kB │ gzip:  0.15 kB
  .svelte-kit/output/client/_app/immutable/chunks/navigation.69a4676d.js                  0.16 kB │ gzip:  0.14 kB
  .svelte-kit/output/client/_app/immutable/chunks/stores.d869250f.js                      0.24 kB │ gzip:  0.17 kB
  .svelte-kit/output/client/_app/immutable/nodes/2.02fcdf6e.js                            0.66 kB │ gzip:  0.43 kB
  .svelte-kit/output/client/_app/immutable/nodes/1.43b0e5d2.js                            0.84 kB │ gzip:  0.52 kB
  .svelte-kit/output/client/_app/immutable/chunks/parse.7d180a0f.js                       1.31 kB │ gzip:  0.63 kB
  .svelte-kit/output/client/_app/immutable/nodes/3.f8093b5c.js                            2.06 kB │ gzip:  1.05 kB
  .svelte-kit/output/client/_app/immutable/chunks/scheduler.690599bf.js                   2.33 kB │ gzip:  1.08 kB
  .svelte-kit/output/client/_app/immutable/nodes/8.0a6a485a.js                            3.19 kB │ gzip:  1.73 kB
  .svelte-kit/output/client/_app/immutable/chunks/singletons.0f20aa31.js                  3.40 kB │ gzip:  1.76 kB
  .svelte-kit/output/client/_app/immutable/nodes/5.328fded3.js                            3.77 kB │ gzip:  1.71 kB
  .svelte-kit/output/client/_app/immutable/nodes/6.7265cad3.js                            4.53 kB │ gzip:  1.93 kB
  .svelte-kit/output/client/_app/immutable/nodes/4.41943ab9.js                            4.99 kB │ gzip:  2.06 kB
  .svelte-kit/output/client/_app/immutable/nodes/7.33022ab8.js                            5.13 kB │ gzip:  2.09 kB
  .svelte-kit/output/client/_app/immutable/chunks/index.ee4472c9.js                       5.90 kB │ gzip:  2.51 kB
  .svelte-kit/output/client/_app/immutable/entry/app.2b7fc7d3.js                          8.33 kB │ gzip:  2.67 kB
  .svelte-kit/output/client/_app/immutable/nodes/0.9f73b240.js                           23.26 kB │ gzip:  7.68 kB
  .svelte-kit/output/client/_app/immutable/entry/start.cf548541.js                       23.73 kB │ gzip:  9.38 kB
  .svelte-kit/output/client/_app/immutable/chunks/index.e68bcff4.js                      27.10 kB │ gzip: 10.20 kB
  ✓ built in 3.24s
  .svelte-kit/output/server/.vite/manifest.json                                                    9.26 kB
  .svelte-kit/output/server/_app/immutable/assets/_page.ff1cfcc4.css                               0.04 kB
  .svelte-kit/output/server/_app/immutable/assets/_layout.3a6d0da3.css                             4.42 kB
  .svelte-kit/output/server/entries/pages/_layout.server.ts.js                                     0.08 kB
  .svelte-kit/output/server/chunks/emailVerificationPurposes.js                                    0.13 kB
  .svelte-kit/output/server/entries/pages/_page.server.ts.js                                       0.14 kB
  .svelte-kit/output/server/entries/matchers/emailVerificationPurpose.js                           0.17 kB
  .svelte-kit/output/server/internal.js                                                            0.19 kB
  .svelte-kit/output/server/entries/endpoints/(loggedIn)/logout/_server.ts.js                      0.30 kB
  .svelte-kit/output/server/entries/pages/_page.svelte.js                                          0.39 kB
  .svelte-kit/output/server/entries/fallbacks/error.svelte.js                                      0.47 kB
  .svelte-kit/output/server/chunks/stores.js                                                       0.52 kB
  .svelte-kit/output/server/chunks/lucia.js                                                        0.52 kB
  .svelte-kit/output/server/entries/pages/(loggedIn)/change-email/_token_/_page.svelte.js          0.95 kB
  .svelte-kit/output/server/entries/pages/(loggedOut)/login/_page.server.ts.js                     1.09 kB
  .svelte-kit/output/server/chunks/db.js                                                           1.14 kB
  .svelte-kit/output/server/entries/pages/(loggedIn)/change-email/_token_/_page.server.ts.js       1.31 kB
  .svelte-kit/output/server/entries/pages/(loggedIn)/change-profile/_page.server.ts.js             1.76 kB
  .svelte-kit/output/server/entries/pages/(loggedOut)/reset-password/_token_/_page.server.ts.js    1.90 kB
  .svelte-kit/output/server/chunks/_layout.server.js                                               2.02 kB
  .svelte-kit/output/server/entries/pages/_purpose_emailVerificationPurpose_/_page.svelte.js       2.15 kB
  .svelte-kit/output/server/chunks/hooks.server.js                                                 2.22 kB
  .svelte-kit/output/server/entries/pages/(loggedOut)/login/_page.svelte.js                        2.27 kB
  .svelte-kit/output/server/chunks/index.js                                                        2.29 kB
  .svelte-kit/output/server/entries/pages/(loggedOut)/signup/_token_/_page.server.ts.js            2.40 kB
  .svelte-kit/output/server/entries/pages/(loggedOut)/reset-password/_token_/_page.svelte.js       2.54 kB
  .svelte-kit/output/server/entries/pages/_purpose_emailVerificationPurpose_/_page.server.ts.js    2.61 kB
  .svelte-kit/output/server/chunks/index3.js                                                       2.65 kB
  .svelte-kit/output/server/entries/pages/(loggedIn)/change-profile/_page.svelte.js                2.67 kB
  .svelte-kit/output/server/entries/pages/(loggedOut)/signup/_token_/_page.svelte.js               2.70 kB
  .svelte-kit/output/server/chunks/navigation.js                                                   2.72 kB
  .svelte-kit/output/server/chunks/internal.js                                                     5.44 kB
  .svelte-kit/output/server/chunks/stringify.js                                                    6.44 kB
  .svelte-kit/output/server/chunks/ssr.js                                                          7.72 kB
  .svelte-kit/output/server/chunks/superValidate.js                                                8.93 kB
  .svelte-kit/output/server/chunks/errors.js                                                      18.22 kB
  .svelte-kit/output/server/entries/pages/_layout.svelte.js                                       26.58 kB
  .svelte-kit/output/server/chunks/index2.js                                                      44.74 kB
  .svelte-kit/output/server/index.js                                                             102.76 kB
  
  Run npm run preview to preview your production build locally.
  
  > Using @sveltejs/adapter-node
    ✔ done
  ✓ built in 19.50s
 $ ls build
  client/  env.js  handler.js  index.js  server/  shims.js
 $ HOST=127.0.0.1 PORT=4000 node build

ちゃんと build/ にファイルは作成され、
node build でサーバーは立ち上がるのだけれど、
アクセスするとデータベースが読めないという 500 エラーが出る。

 LANG: console
 PrismaClientInitializationError: 
 Invalid `prisma.authSession.findUnique()` invocation:
 
 Error querying the database: Error code 14: Unable to open the database file
     at ni.handleRequestError (C:\Users\osamu\Desktop\svelte\authtest\node_modules\.pnpm\@prisma+client@5.6.0_prisma@5.6.0\node_modules\@prisma\client\runtime\library.js:124:7090)
     at ni.handleAndLogRequestError (C:\Users\osamu\Desktop\svelte\authtest\node_modules\.pnpm\@prisma+client@5.6.0_prisma@5.6.0\node_modules\@prisma\client\runtime\library.js:124:6206)   
 ...

データベース関連の設定をするにはどうしたら???

https://www.reddit.com/r/sveltejs/comments/14xxgt5/deploy_sveltekit_with_prisma_to_node_server_with/

- npx prisma migrate deploy
- npx prisma generate 

が必要らしい。

 LANG: console
 $ mkdir build/db
 $ DATABASE_URL="file:/c/Users/osamu/Desktop/svelte/authtest/build/db/prod.db" npx prisma migrate deploy
  Environment variables loaded from .env
  Prisma schema loaded from prisma\schema.prisma
  Datasource "db": SQLite database "prod.db" at "file:/c/Users/osamu/Desktop/svelte/authtest/build/db/prod.db"
  
  SQLite database prod.db created at file:/c/Users/osamu/Desktop/svelte/authtest/build/db/prod.db
  
  5 migrations found in prisma/migrations
  
  Applying migration `20231121075732_add_tables_for_lucia`
  Applying migration `20231121093844_alter_tables_for_lucia_v2_again`
  Applying migration `20231121112232_add_roles`
  Applying migration `20231121112522_alter_roles`
  Applying migration `20231122211319_add_email_verification_table`
  
  The following migrations have been applied:
  
  migrations/
    └─ 20231121075732_add_tables_for_lucia/
      └─ migration.sql
    └─ 20231121093844_alter_tables_for_lucia_v2_again/
      └─ migration.sql
    └─ 20231121112232_add_roles/
      └─ migration.sql
    └─ 20231121112522_alter_roles/
      └─ migration.sql
    └─ 20231122211319_add_email_verification_table/
      └─ migration.sql
  
 $ DATABASE_URL="file:/c/Users/osamu/Desktop/svelte/authtest/build/db/prod.db" npx prisma generate 
  Environment variables loaded from .env
  Prisma schema loaded from prisma\schema.prisma
  
  ✔ Generated Prisma Client (v5.6.0) to .\node_modules\.pnpm\@prisma+client@5.6.0_prisma@5.6.0\node_modules\@prisma\client in 308ms
  
  Start using Prisma Client in Node.js (See: https://pris.ly/d/client)
  ```
  import { PrismaClient } from '@prisma/client'
  const prisma = new PrismaClient()
  ```
  or start using Prisma Client at the edge (See: https://pris.ly/d/accelerate)
  ```
  import { PrismaClient } from '@prisma/client/edge'
  const prisma = new PrismaClient()
  ```
  
  See other ways of importing Prisma Client: http://pris.ly/d/importing-client
 $ DATABASE_URL="file:/c/Users/osamu/Desktop/svelte/authtest/build/db/prod.db" HOST=127.0.0.1 PORT=4000 node build

これでデータベースには接続できた。

ただ form を POST すると 403 Forbidden になる。~
strict-origin-when-cross-origin のせいっぽい。

起動時に ORIGIN を指定してやれば大丈夫だった。

 LANG: console
 $ DATABASE_URL="file:/c/Users/osamu/Desktop/svelte/authtest/build/db/prod.db" HOST=127.0.0.1 PORT=4000 ORIGIN="http://localhost:4000" node build

ん?

動くには動くのだけれど build/db/prod.db にファイルが見つからない???

データベース本体はどこに作られているのか?!

うわぁ、ここにあった。

 C:\c\Users\osamu\Desktop\svelte\authtest\build\db

これは MingW64 限定の問題だけど、データベースファイル位置をフルパスで指定するには

 DATABASE_URL="file:/c/Users/osamu/Desktop/svelte/authtest/build/db/prod.db"

ではなく、

 DATABASE_URL="file:/Users/osamu/Desktop/svelte/authtest/build/db/prod.db"

としないとダメみたいだった。

** ダメなパスワードを検出してエラーにする [#gb55701b]

- worst password list~
https://github.com/danielmiessler/SecLists/blob/master/Passwords/Common-Credentials/10-million-password-list-top-100000.txt

をデータベースに入れておき、ここに載っていたら弾く。

*** Prisma への seeding [#w3deb79e]

そのために上記のリストを Prisma のデータベースに入れたい。

https://m-shige1979.hatenablog.com/entry/2021/11/20/213051

あたりを参考にしつつ、

prisma/seed.ts
 LANG: ts
 import { db } from '../src/lib/server/db';
 import * as fs from 'fs';
 
 async function main() {
   await db.worstPassword.deleteMany();
   const text = fs.readFileSync('./prisma/seed/10-million-password-list-top-1000000.txt', 'utf-8');
   text.split(/\n\r?|\r/).forEach(async (value, rank)=> {
     await db.worstPassword.create({data:{value, rank}})
   })
 }
 
 main()    
   .catch((e) => {
     console.error(e)
     process.exit(1)
   })
   .finally(async () => {
     await db.$disconnect()
   })

これを実行するために以下を行う

- prisma/seed に 10-million-password-list-top-100000.txt をダウンロード
- ts-node を入れて .ts ファイルを直接実行可能にする
-- 参照 https://qiita.com/mangano-ito/items/75e65071c9c482ddc335
- そして ./node_modules/.bin/ts-node prisma/seed.ts

 LANG: console
 $ mkdir prisma/seed
 $ curl https://raw.githubusercontent.com/danielmiessler/SecLists/master/Passwords/Common-Credentials/10-million-password-list-top-100000.txt > prisma/seed/10-million-password-list-top-100000.txt
 $ npm install --save typescript ts-node
 $ ./node_modules/.bin/ts-node prisma/seed.ts
  TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" for C:\Users\osamu\Desktop\svelte\authtest\prisma\seed.ts
      at new NodeError (node:internal/errors:399:5)
      at Object.getFileProtocolModuleFormat [as file:] (node:internal/modules/esm/get_format:79:11)
      at defaultGetFormat (node:internal/modules/esm/get_format:121:38)
      at defaultLoad (node:internal/modules/esm/load:81:20)
      at nextLoad (node:internal/modules/esm/loader:163:28)
      at ESMLoader.load (node:internal/modules/esm/loader:605:26)
      at ESMLoader.moduleProvider (node:internal/modules/esm/loader:457:22)
      at new ModuleJob (node:internal/modules/esm/module_job:64:26)
      at ESMLoader.#createModuleJob (node:internal/modules/esm/loader:480:17)
      at ESMLoader.getModuleJob (node:internal/modules/esm/loader:434:34) {
    code: 'ERR_UNKNOWN_FILE_EXTENSION'
  }

エラーが出た。

https://qiita.com/nyanchu/items/82903e0463fa9d558639 を参考にして tsconfig.json を書き換える。

tsconfig.json
 LANG: json
   {
     "extends": "./.svelte-kit/tsconfig.json",
 +   "ts-node": {
 +     "esm": true,
 +     "experimentalSpecifierResolution": "node"
 +   },
     ...

これで実行はされるようになった・・・が、

 LANG: console
 $ ./node_modules/.bin/ts-node prisma/seed.ts
  
  <--- Last few GCs --->
  
  [54920:00000183318518E0]    42008 ms: Mark-sweep (reduce) 2047.2 (2083.1) -> 2046.6 (2083.6) MB, 765.8 / 0.0 ms  (+ 46.6 ms in 14 steps since start of marking, biggest step 4.6 ms, walltime since start of marking 828 ms) (average mu = 0.382, current mu = [54920:00000183318518E0]    44566 ms: Mark-sweep (reduce) 2047.7 (2083.6) -> 2047.3 (2084.4) MB, 2164.6 / 0.0 ms  (+ 58.2 ms in 12 steps since start of marking, biggest step 7.2 ms, walltime since start of marking 2238 ms) (average mu = 0.228, current mu
  
  <--- JS stacktrace --->
  
  FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
  ...

メモリ不足で落ちた。

prisma/seed.ts
 LANG: ts
 -   text.split(/\n\r?|\r/).forEach(async (value, rank)=> {
 -     await db.worstPassword.create({data:{value, rank}})
 -   })
 +   const items = text.split(/\n\r?|\r/);
 +   for(let rank = 0; rank < items.length; rank++) {
 +     const value = items[rank];
 +     await db.worstPassword.create({data:{value, rank}})
 +   }

あー、考えてみれば元のコードでは forEach に与えてる async 関数を誰も await 
していなかったのでひたすら Promise の山が生成されてしまっていた。

素人丸出しのダメコードだった。

1つ1つの create を await するようにしたところ、時間はかかったものの、
ちゃんとデータが追加された。

*** npm で動かせるようにする [#of203763]

package.json
 LANG: json
      "dependencies": {
       "@lucia-auth/adapter-prisma": "^3.0.2",
       "@prisma/client": "^5.6.0",
       "lucia": "^2.7.4",
       "nodemailer": "^6.9.7",
       "svelte-french-toast": "^1.2.0",
       "ts-node": "^10.9.1"
     },
 +   "prisma": {
 +     "seed": "ts-node prisma/seed.ts"
 +   }
   }

これで、

 LANG: console
 $ npm prisma db seed
  Environment variables loaded from .env
  Running seed command `ts-node prisma/seed.ts` ...

の形で seeding が行える。

ただ、 https://github.com/prisma/prisma/discussions/18057 などによると
seeding というのはあくまで開発途上でデータベースにテストデータを入れたりするのに
使われるものなので、今回の用途のように本番環境でも使うようなのはいわゆる seeding 
の用途とは異なるようだ。

上記の処理は非常に長い時間もかかるので seed コマンドとは異なる形で実行できるように
しておくべきっぽい。

どうするべきか要検討。

*** パスワードを検証するのに使う [#g91c4d92]

src/lib/formSchemas/changeProfile.ts
 LANG: ts
   import { z } from 'zod';
 + import { db } from '$lib/server/db';
   
   export const schema = z
     .object({
       name: z.string().min(3, { message: 'ユーザー名は3文字以上で入力してください' }),
       password: z.string().regex(/^$|^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9]).{8,}$/, {
         message: 'パスワードは小文字・大文字・数字を一文字以上含めて8文字以上で入力してください'
       }),
       confirm: z.string()
     })
     .refine((data) => data.password === data.confirm, {
       message: '確認用パスワードが一致しません',
       path: ['confirm']
 +   })
 +   .superRefine(async (data, ctx) => {
 +     const record = await db.worstPassword.findUnique({where: {value: data.password}, select: {rank: true}});
 +     if(record) {
 +       ctx.addIssue({
 +         code: z.ZodIssueCode.custom,
 +         message: `容易に推測可能なパスワードです (rank = ${record.rank})`,
 +         fatal: true,
 +         path: ['password'],
 +       });
 +       return z.NEVER;
 +     }
     });

うまく判定されるようになった。

src/lib/formSchemas/resetPassword.ts や src/lib/formSchemas/signup.ts も同様に変更するため、
判定部分を切り出した。

authtest\src\lib\formSchemas\superRefine\password.ts
 LANG: ts
 import { z } from 'zod';
 import { db } from '$lib/server/db';
 
 export async function refinerForPassword(data: {password: string}, ctx: z.RefinementCtx) {
   const record = await db.worstPassword.findUnique({where: {value: data.password}, select: {rank: true}});
   if(record) {
     ctx.addIssue({
       code: z.ZodIssueCode.custom,
       message: `容易に推測可能なパスワードです (rank = ${record.rank})`,
       fatal: true,
       path: ['password'],
     });
     return z.NEVER;
   }
 }

これを使うと個々の schema では1行追加するだけで済む。

  .superRefine(refinerForPassword);

src/lib/formSchemas/changeProfile.ts
 LANG: ts
   import { z } from 'zod';
 * import { refinerForPassword } from './superRefine/password'
 
   export const schema = z
     .object({
       name: z.string().min(3, { message: 'ユーザー名は3文字以上で入力してください' }),
       password: z.string().regex(/^$|^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9]).{8,}$/, {
         message: 'パスワードは小文字・大文字・数字を一文字以上含めて8文字以上で入力してください'
       }),
       confirm: z.string()
     })
     .refine((data) => data.password === data.confirm, {
       message: '確認用パスワードが一致しません',
       path: ['confirm']
     })
 *   .superRefine(refinerForPassword);

src/lib/formSchemas/resetPassword.ts

src/lib/formSchemas/signup.ts 

も同様にする。

*** パスワード検証 API を用意する [#oec090e8]

サブミットしてみなくても脆弱性が分かるよう、
クライアントサイドから確認するための API を用意する。

src/routes/password-ranking/+server.ts
 LANG: ts
 import { json } from '@sveltejs/kit';
 import { db } from '$lib/server/db'
 
 export async function POST({ request }) {
   const { password } = await request.json();
   const record = await db.worstPassword.findUnique({where: {value: password}, select: {rank: true}});
   if(record) {
     return json(record.rank + 1);
   }
   return json(0);
 }

*** パスワード入力フォームから使う [#d9089ba2]

 LANG: html
     <input type="password" name="password" bind:value={$form.password} disabled={$submitting} on:input={checkPassword} />

のように onInput イベントを拾って、

 LANG: ts
   let timer: NodeJS.Timeout;
   function checkPassword(e: Event) {
     const password = (e.target as HTMLInputElement).value;
     if (password && password.length < 8) {
       errors.update((e)=>{e.password = ['パスワードは小文字・大文字・数字を一文字以上含めて8文字以上で入力してください']; return e});
       return;
     }
 
     // 一旦エラーをキャンセル
     errors.update((e)=>{e.password = undefined; return e});
 
     // 設定待ちがあればキャンセル
     clearTimeout(timer);
 
     // 500 ms 後に設定
     timer = setTimeout(()=> checkPasswordSub(password), 500);
   }
 
   function checkPasswordSub(password: string) {
     if(!password.match(/^$|^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9]).{8,}$/)) {
       errors.update((e)=>{e.password = ['パスワードは小文字・大文字・数字を一文字以上含めて8文字以上で入力してください']; return e});
     } else {
       const method = "POST";
       const body = JSON.stringify({ password });
       const headers = {
         'Accept': 'application/json',
         'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8'
       };
       fetch("/password-ranking", {method, headers, body})
         .then((res)=> res.json())
         .then((rank) => {
           if(rank) {
             errors.update((e)=>{e.password = [`容易に推測可能なパスワードです (rank = ${rank})`]; return e});
           } else {
             errors.update((e)=>{e.password = undefined; return e});
           }
         })
     }
   }

のようにエラーチェックする。

他でも使えるようこれをコンポーネント化したい。

*** コンポーネント化する [#f33ce260]

ラベル + Input + エラー表示をひとまとめにする。

type を動的に変更すると文句を言われるので、

 LANG: html
 <input {...{ name, type }}

の形にしている。

src/lib/components/LabeledInput.svelte
 LANG: html
 <script lang="ts">
   import type { Writable } from 'svelte/store';
 
   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;
 
   let key = name as keyof typeof errors;
 </script>
 
 <label for={name}>{label}</label>
 <input {...{ name, type }} bind:value disabled={disabled} on:input />
 {#if errors && $errors[key]}<span class="invalid">{$errors[key]}</span>{/if}
 
 <style>
   .invalid {
     color: red;
   }
 </style>

パスワードに特化してエラーチェックを行うのがこちら。

src/lib/components/PasswordInput.svelte
 LANG: html
 <script lang="ts">
   import type { Writable } from 'svelte/store';
   import LabeledInput from '$lib/components/LabeledInput.svelte';
 
   export let name: string;
   export let label: string;
   export let value: string;
   export let disabled = false;
   export let errors: Writable<{password?:string[]}> | undefined = undefined;
 
   function checkPasswordSub(password: string) {
     if(!password.match(/^$|^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9]).{8,}$/)) {
       errors?.update((e)=>{e.password = ['パスワードは小文字・大文字・数字を一文字以上含めて8文字以上で入力してください']; return e});
     } else {
       const method = "POST";
       const body = JSON.stringify({ password });
       const headers = {
         'Accept': 'application/json',
         'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8'
       };
       fetch("/password-ranking", {method, headers, body})
         .then((res)=> res.json())
         .then((rank) => {
           if(rank) {
             errors?.update((e)=>{e.password = [`容易に推測可能なパスワードです (rank = ${rank})`]; return e});
           } else {
             errors?.update((e)=>{e.password = undefined; return e});
           }
         })
     }
   }
 
   let timer: NodeJS.Timeout;
   function checkPassword(e: Event) {
     console.log('ok');
     const password = (e.target as HTMLInputElement).value;
     if (password && password.length < 8) {
       errors?.update((e)=>{e.password = ['パスワードは小文字・大文字・数字を一文字以上含めて8文字以上で入力してください']; return e});
       return;
     }
 
     // 一旦エラーをキャンセル
     errors?.update((e)=>{e.password = undefined; return e});
 
     // 設定待ちがあればキャンセル
     clearTimeout(timer);
 
     // 500 ms 後に設定
     timer = setTimeout(()=> checkPasswordSub(password), 500);
   }
 </script>
 
 <LabeledInput {...{name, label, disabled, errors}} type="password" bind:value on:input={checkPassword} />

これらを使うとフォーム定義がすっきりする。

src/routes/(loggedIn)/change-profile/+page.svelte
 LANG: html
 <script lang="ts">
   import type { PageData } from './$types';
   import { superForm } from 'sveltekit-superforms/client';
   import LabeledInput from '$lib/components/LabeledInput.svelte';
   import PasswordInput from '$lib/components/PasswordInput.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>
   {#if $message}<span class="invalid">{$message}</span>{/if}
   <form method="POST" use:enhance>
 
     <LabeledInput name="name" label="ユーザー名" bind:value={$form.name} disabled={$submitting} {errors} />
 
     <LabeledInput name="email" label="メールアドレス" value={data.user.email} disabled={true} />
 
     <PasswordInput name="password" label="パスワード(変更しないなら何も入力しない)" value={$form.password} disabled={$submitting} errors={errors} />
 
     <LabeledInput name="confirm" label="パスワード(確認)" type="password" value={$form.confirm} disabled={$submitting} {errors} />
 
     <div><button disabled={$submitting}>ユーザー情報を変更</button></div>
   </form>
 </div>

他の form もこれに準じて直す。

** いまさらテストを追加 [#iac1c315]

サーバーの起動とテストの実行を切り離した方が使いやすそう?

*** サーバーの起動 [#r621d0b1]

デバッグサーバーでテストするならこう

 LANG: console
 $ DATABASE_URL="file:test.db" bash -c 'prisma migrate reset -f && pnpm dev --port 4173'

ビルド済みでテストするならこう

 LANG: console
 $ DATABASE_URL="file:test.db" bash -c 'prisma migrate reset -f && pnpm build && pnpm preview --port 4173'

これを package.json に入れる方法が良く分からない?

https://qiita.com/riversun/items/d45b26f4a7aad6e51b69 によると、

 LANG: console
 $ npm install --save-dev cross-env

しておいて、

package.json
 +    "test-dev": "cross-env DATABASE_URL='file:test.db' prisma migrate reset -f && cross-env DATABASE_URL='file:test.db' vite dev --port 4173",
 +    "test-preview": "cross-env DATABASE_URL='file:test.db' prisma migrate reset -f && cross-env DATABASE_URL='file:test.db' vite build && cross-env DATABASE_URL='file:test.db' vite preview --port 4173"

こうかな?

*** テストの実行 [#o5d2e777]

上記のコマンドでサーバーを起動しておいて、

playwright.config.ts
 LANG: ts
 import type { PlaywrightTestConfig } from '@playwright/test';
 
 const config: PlaywrightTestConfig = {
   webServer: {
     command: "",
     port: 4173,
     reuseExistingServer: !process.env.CI,
   },
   testDir: 'tests',
   testMatch: /(.+\.)?(test|spec)\.[jt]s/,
 };
 
 export default config;

および

package.json
 -    "test:integration": "playwright test",
 +    "test:integration": "cross-env DATABASE_URL='file:test.db' playwright test",

の設定で


 LANG: console
 $ pnpm test:integration

とすればいい。

*** テスト時のメールは実際に送信する代わりにファイルに保存する [#l8b2cdac]

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,
 });
 
 if(env.DATABASE_URL=='file:dev.db' || env.DATABASE_URL=='file:test.db') {
   transporter.sendMail = async (mailOptions: SendMailOptions) => {
     console.log(process.cwd());
     console.log(mailOptions);
     fs.writeFileSync('test-results/mailOptions.txt', JSON.stringify(mailOptions));
     return null as SentMessageInfo;
   };
 }

*** テストを書く [#i8e7b89e]

https://playwright.dev/docs/api/class-test

例えばこんな風に書ける。

 LANG: ts
 test('/signup へフォームを送信', async ({ page }) => {
  await page.goto('/signup', {waitUntil: 'load'});
 
  await page.locator('input[name="email"]').fill('test@example.com');
  Promise.all([
    page.locator('input[name="email"]').press('Enter'),
    page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'})
  ]);
  await expect(page.getByRole('heading', { name: 'Welcome to Svelte' })).toBeVisible();
 });

でもこれを、

 LANG: ts
   await page.locator('input[name="email"]').fill('test@example.com');
   await page.locator('form button').click();

とすると email フィールドへの入力が送信データに反映されないケースがあったり(svelte による reactivity が発揮される前にフォームが送信されてしまうため?)、

なぜか fill を2回繰り返さないと書き込み自体を行えないケースがあったり、

 LANG: ts
   await page.locator('input[name="email"]').fill('test@example.com');
   await page.locator('input[name="email"]').fill('test@example.com'); // 念のためもう一度 orz

なかなかに落とし穴も多そうな雰囲気だ?

- locator と WaitForSelector, $, $$ の違い https://qiita.com/ko-he-8/items/85116e1d99ed4b176657
- npm playwright codegen で操作手順の記録ができる https://playwright.dev/docs/codegen-intro
- 以前 --ui オプションで GUI が立ち上がるのを確認した記録があるのだけれど、このプロジェクトにインストールされているバージョンだとそんなオプションないと言われる?
- https://www.summerbud.org/dev-notes/playwright-tips-that-will-make-your-life-easier 
-- .fill などは readonly や disable の間はそうじゃなくなるまで待つらしい
-- race condition を避けるために Promise.all を使う
- DEBUG=pw:api pnpm test:integration で verbose な出力が得られる
- テストは test( を test.skip( にするとスキップできる

tests/auth.test.ts
 LANG: ts
 import { expect, test, type Page, type TestInfo } from '@playwright/test';
 import type { SendMailOptions } from "nodemailer";
 import * as fs from "fs";
 import { db } from '../src/lib/server/db.js';
 
 test.setTimeout(5000);
 
 let testId = 0;
 let screenshotId = 0;
 let screenshotTitle = "";
 async function screenshot(page: Page, info: TestInfo) {
   if(screenshotTitle != info.title) {
     testId++;
     screenshotTitle = info.title;
     screenshotId = 0;
   }
   await page.screenshot({path: `test-results/ss${testId.toString().padStart(4, '0')}-${info.title.replaceAll("/","_")}${++screenshotId}.png`})
 }
 
 test('データベースをクリアする', async () => {
   await db.emailVerification.deleteMany()
   await db.authUser.deleteMany();
 
   expect(await db.emailVerification.count()).toBe(0);
   expect(await db.authUser.count()).toBe(0);
 });
 
 test('インデックスページを表示できる', async ({ page }, info) => {
   await page.goto('/', {waitUntil: 'load'});
   await expect(page.getByRole('heading', { name: 'Welcome to Svelte' })).toBeVisible();
   await screenshot(page, info);
 });
 
 test('/signup のメールアドレスの検証', async ({ page }, info) => {
   await page.goto('/signup', {waitUntil: 'load'});
   
   await page.locator('input[name="email"]').fill('');
   await page.locator('input[name="email"]').press('Enter');
   await expect(page.locator('form button')).not.toHaveProperty('disabled');
   await expect(page.locator('input[name="email"]+.invalid')).toContainText('メールアドレスが不正です');
 
   await page.locator('input[name="email"]').fill('abcd@');
   await page.locator('input[name="email"]').press('Enter');
   await expect(page.locator('form button')).not.toHaveProperty('disabled');
   await expect(page.locator('input[name="email"]+.invalid')).toContainText('メールアドレスが不正です');
 
   await page.locator('input[name="email"]').fill('@abcc');
   await page.locator('input[name="email"]').press('Enter');
   await expect(page.locator('form button')).not.toHaveProperty('disabled');
   await expect(page.locator('input[name="email"]+.invalid')).toContainText('メールアドレスが不正です');
 
   await screenshot(page, info);
 });
 
 test('メールアドレスを入力する /signup', async ({ page }, info) => {
   await page.goto('/signup', {waitUntil: 'load'});
 
   await page.locator('input[name="email"]').fill('test@example.com');
   await page.locator('input[name="email"]').press('Enter');
 
   await expect(page.locator('form button')).not.toBeVisible();
   await page.waitForLoadState('load');
   await expect(page.url()).toBe('http://localhost:4173/');
 
   await expect(page.locator('.toaster .message')).toHaveText('メールを送信しました')
   await page.waitForTimeout(200);
 
   await screenshot(page, info);
 });
 
 test('連続して送ろうとするとエラーになる', async ({ page }, info) => {
   await page.goto('/signup', {waitUntil: 'load'});
 
   await page.locator('input[name="email"]').fill('test@example.com');
   await page.locator('input[name="email"]').press('Enter');
   await expect(page.locator('form button')).not.toHaveProperty('disabled');
 
   await expect(page.locator('input[name="email"]+.invalid')).toContainText('先ほどメールを送信しましたのでしばらく経ってから試してください');
   await screenshot(page, info);
 });
 
 test('サインアップを続ける', async ({ page }, info) => {
   const mailOptions = JSON.parse(fs.readFileSync('test-results/mailOptions.txt', 'utf-8')) as SendMailOptions;
   await expect(mailOptions.text).toContain('http');
   await page.goto((mailOptions.text as string).split(/\n/)[1], {waitUntil: 'load'});
   await screenshot(page, info);
 
   await page.click('form button');
   await expect(page.locator('form button')).not.toHaveProperty('disabled');
   await expect(page.locator('input[name="name"]+.invalid')).toContainText('文字');
 
   await expect(page.locator('input[name="name"]')).not.toHaveProperty('disabled');
   await page.locator('input[name="name"]').fill('First Family');
   await page.locator('input[name="name"]').fill('First Family'); // 1回だと失敗することがある???
   await page.locator('input[name="name"]').press('Enter');
   await screenshot(page, info);
   await expect(page.locator('form button')).not.toHaveProperty('disabled');
   await expect(page.locator('input[name="email"]+.invalid')).not.toHaveProperty('disabled');
   await screenshot(page, info);
   await expect(page.locator('input[name="name"]+.invalid')).not.toBeVisible();
   await expect(page.locator('input[name="password"]+.invalid')).toContainText('文字');
 
   await page.fill('input[name="password"]', 'aaaaaaaa');
   await page.click('form button');
   await expect(page.locator('form button')).not.toHaveProperty('disabled');
   await expect(page.locator('input[name="email"]+.invalid')).not.toHaveProperty('disabled');
   await expect(page.locator('input[name="password"]+.invalid')).toContainText('文字');
 
   await page.fill('input[name="password"]', 'Aa1aaaaa');
   await page.click('form button');
   await expect(page.locator('form button')).not.toHaveProperty('disabled');
   await expect(page.locator('input[name="email"]+.invalid')).not.toHaveProperty('disabled');
   await expect(page.locator('input[name="password"]+.invalid')).not.toBeVisible();
   await expect(page.locator('input[name="confirm"]+.invalid')).toContainText('一致しません');
 
   await page.fill('input[name="confirm"]', 'Aa1aaaaa');
   await screenshot(page, info);
 
   await page.click('form button');
   await expect(page.locator('form button')).not.toHaveProperty('disabled');
 
   await page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'});
   await expect(page.locator('.toaster .message').nth(0)).toHaveText('サインアップしました')
   await page.waitForTimeout(200);
   await screenshot(page, info);
 
   // ログアウトする
   await logout(page, info);
 
   await page.waitForTimeout(200);
   await screenshot(page, info);
 });
 
 async function logout(page: Page, info: TestInfo) {
   Promise.all([
     page.goto('/logout', {waitUntil: 'load'}),
     page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'})
   ]);
   await expect(page.locator('.toaster .message').nth(0)).toHaveText('ログアウトしました')
   await page.waitForTimeout(200);
   await screenshot(page, info);
 }
 
 async function login(page: Page, email='test@example.com', password='Aa1aaaaa') {
   await page.goto('/login', {waitUntil: 'load'});
   await page.locator('input[name="email"]').fill(email);
   await page.locator('input[name="password"]').fill(password);
   await page.locator('input[name="password"]').press('Enter');
 
   await page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'});
   await expect(page.locator('.toaster .message').nth(0)).toHaveText('ログインしました');
 }
 
 test('ログインしていないのにログアウトする', async ({ page }, info) => {
   await page.goto('/logout', {waitUntil: 'load'});
 
   await page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'});
   await expect(page.locator('.toaster .message').nth(0)).toHaveText('ログインユーザーのみアクセス可能です')
   await page.waitForTimeout(200);
   await screenshot(page, info);
 });
 
 test('ログインする', async ({ page }, info) => {
   // ログインする
 
   await page.goto('/login', {waitUntil: 'load'});
   await screenshot(page, info);
 
   await page.locator('input[name="email"]').press('Enter');
   await expect(page.locator('input[name="email"]')).not.toHaveProperty('disabled');
   await expect(page.locator('input[name="email"]+.invalid')).toContainText('入力して下さい');
 
   await page.locator('input[name="email"]').fill('test@example.com');
   await page.locator('input[name="email"]').fill('test@example.com');
   await page.locator('input[name="email"]').press('Enter');
   await expect(page.locator('input[name="email"]')).not.toHaveProperty('disabled');
   await expect(page.locator('input[name="password"]+.invalid')).toContainText('入力して下さい');
 
   await page.locator('input[name="password"]').fill('Aa1aaaaa');
   await screenshot(page, info);
   await page.locator('input[name="password"]').press('Enter');
 
   await page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'});
   await expect(page.locator('.toaster .message').nth(0)).toHaveText('ログインしました');
   await page.waitForTimeout(200);
   await screenshot(page, info);
 });
 
 test('名前やパスワードを変更する', async ({ page }, info)=> {
   // ログイン
   await login(page);
 
   // 名前・パスワードを変更する(実際にはしない)
   await page.goto('/change-profile', {waitUntil: 'load'});
   await screenshot(page, info);
 
   await page.locator('input[name="name"]').press('Enter');
   await page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'});
   await expect(page.locator('.toaster .message').nth(0)).toContainText('変更されませんでした');
   await page.waitForTimeout(200);
   await screenshot(page, info);
   
   // 名前を変更する
   await page.goto('/change-profile', {waitUntil: 'load'});
 
   await page.locator('input[name="name"]').fill('First Middle Family');
   await page.locator('input[name="name"]').fill('First Middle Family');
   await page.locator('input[name="name"]').press('Enter');
   await page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'});
   await expect(page.locator('.toaster .message').nth(0)).toContainText('名前が変更されました');
   await page.waitForTimeout(200);
   await screenshot(page, info);
   
   // パスワードを変更する
   await page.goto('/change-profile', {waitUntil: 'load'});
   await screenshot(page, info);
 
   await page.locator('input[name="password"]').fill('Aa1aaaa');
   await page.locator('input[name="password"]').fill('Aa1aaaa');
   await page.locator('input[name="password"]').press('Enter');
   await expect(page.locator('input[name="password"]')).not.toHaveProperty('disabled');
   await expect(page.locator('input[name="password"]+.invalid')).toContainText('文字');
   
   await page.locator('input[name="password"]').fill('Aa1aaaab');
   await page.locator('input[name="password"]').fill('Aa1aaaab');
   await page.locator('input[name="password"]').press('Enter');
   await expect(page.locator('input[name="password"]')).not.toHaveProperty('disabled');
   await expect(page.locator('input[name="confirm"]+.invalid')).toContainText('一致しません');
   
   await page.locator('input[name="confirm"]').fill('Aa1aaaab');
   await page.locator('input[name="confirm"]').fill('Aa1aaaab');
   await page.locator('input[name="confirm"]').press('Enter');
   
   await page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'});
   await expect(page.locator('.toaster .message').nth(0)).toContainText('パスワードが変更されました');
   await page.waitForTimeout(200);
   await screenshot(page, info);
   
   // 名前とパスワードを変更する
   await page.goto('/change-profile', {waitUntil: 'load'});
   await screenshot(page, info);
   await expect(page.locator('input[name="name"]')).toHaveValue('First Middle Family');
 
   await page.locator('input[name="name"]').fill('First Family');
   await page.locator('input[name="name"]').fill('First Family');
  
   await page.locator('input[name="password"]').fill('Aa1aaaaa');
   await page.locator('input[name="password"]').fill('Aa1aaaaa');
   
   await page.locator('input[name="confirm"]').fill('Aa1aaaaa');
   await page.locator('input[name="confirm"]').fill('Aa1aaaaa');
   await screenshot(page, info);
 
   await page.locator('input[name="confirm"]').press('Enter');
   
   await page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'});
   await expect(page.locator('.toaster .message').nth(0)).toContainText('パスワードと名前が変更されました');
   await page.waitForTimeout(200);
   await screenshot(page, info);
   
   await logout(page, info);
 });
 
 test('メールアドレスを変更する', async ({ page }, info)=> {
   // ログイン
   await login(page);
 
   await page.goto('/change-email', {waitUntil: 'load'});
   await screenshot(page, info);
 
   await page.locator('input[name="email"]').press('Enter');
   await expect(page.locator('input[name="email"]+.invalid')).toContainText('不正です');
 
   await page.locator('input[name="email"]').fill('changed@example.com');
   await page.locator('input[name="email"]').press('Enter');
   
   await page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'});
   await expect(page.locator('.toaster .message').nth(0)).toContainText('送信しました');
   await page.waitForTimeout(200);
   await screenshot(page, info);
 
   const mailOptions = JSON.parse(fs.readFileSync('test-results/mailOptions.txt', 'utf-8')) as SendMailOptions;
   await expect(mailOptions.text).toContain('http');
   await page.goto((mailOptions.text as string).split(/\n/)[1], {waitUntil: 'load'});
   await screenshot(page, info);
 
   await expect(page.locator('input[name="name"]')).toHaveValue('First Family');
   await expect(page.locator('input[name="email-old"]')).toHaveValue('test@example.com');
   await expect(page.locator('input[name="email-new"]')).toHaveValue('changed@example.com');
 
   Promise.all([
     page.locator('form button').click(),
     page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'})
   ]);
 
   await expect(page.locator('.toaster .message').nth(0)).toContainText('変更しました');
   await page.waitForTimeout(200);
   await screenshot(page, info);
 
   // ログアウト
   await logout(page, info);
 
 });
 
 test('新しいメールアドレスでログインしてから元に戻す', async ({ page }, info)=> {
   
   await login(page, 'changed@example.com');
 
   await page.goto('/change-email', {waitUntil: 'load'});
   await page.locator('input[name="email"]').fill('test@example.com');
   await page.locator('input[name="email"]').fill('test@example.com');
   await Promise.all([
     page.locator('input[name="email"]').press('Enter'),
     page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'})
   ]);
   await expect(page.locator('.toaster .message').nth(0)).toContainText('送信しました');
 
   const mailOptions2 = JSON.parse(fs.readFileSync('test-results/mailOptions.txt', 'utf-8')) as SendMailOptions;
   await expect(mailOptions2.text).toContain('http');
   await page.goto((mailOptions2.text as string).split(/\n/)[1], {waitUntil: 'load'});
   await page.locator('form button').click();
 
   await page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'});
   await expect(page.locator('.toaster .message').nth(0)).toContainText('変更しました');
 
   await logout(page, info);
 });
 
 test('パスワードのリセットを行う', async ({ page }, info) => {
   await page.goto('/reset-password', {waitUntil: 'load'});
   await screenshot(page, info);
 
   await page.locator('input[name="email"]').press('Enter');
   await expect(page.locator('input[name="email"]+.invalid')).toContainText('不正です');
 
   await page.locator('input[name="email"]').fill('notfound@example.com');
   await page.locator('input[name="email"]').press('Enter');
   await expect(page.locator('input[name="email"]+.invalid')).toContainText('登録されていません');
   
   await page.locator('input[name="email"]').fill('test@example.com');
   Promise.all([
     page.locator('input[name="email"]').press('Enter'),
     page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'})
   ]);
   
   await expect(page.locator('.toaster .message').nth(0)).toContainText('送信しました');
   await page.waitForTimeout(200);
   await screenshot(page, info);
 
   const mailOptions = JSON.parse(fs.readFileSync('test-results/mailOptions.txt', 'utf-8')) as SendMailOptions;
   await expect(mailOptions.text).toContain('http');
   await page.goto((mailOptions.text as string).split(/\n/)[1], {waitUntil: 'load'});
   await screenshot(page, info);
 
   await expect(page.locator('input[name="name"]')).toHaveValue('First Family');
   await expect(page.locator('input[name="email"]')).toHaveValue('test@example.com');
 
   await page.locator('input[name="password"]').press('Enter');
   await expect(page.locator('input[name="password"]+.invalid')).toContainText('文字');
 
   await page.locator('input[name="password"]').fill('Aa1aaaaa');
   await page.locator('input[name="confirm"]').fill('Aa1aaaaa');
   await screenshot(page, info);
   await page.locator('input[name="confirm"]').press('Enter');
 
   await page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'});
   await expect(page.locator('.toaster .message').nth(0)).toContainText('パスワードを変更してログインしました');
   await page.waitForTimeout(200);
   await screenshot(page, info);
 
 });

 LANG: console
 $ pnpm test:integration
 
 > authtest@0.0.1 test:integration C:\Users\osamu\Desktop\svelte\authtest
 > cross-env DATABASE_URL='file:test.db'; playwright test
 
 Running 12 tests using 1 worker
 
   ✓  1 auth.test.ts:13:1 › データベースをクリアする (44ms)
   ✓  2 auth.test.ts:21:1 › インデックスページを表示できる (351ms)
   ✓  3 auth.test.ts:27:1 › /signup のメールアドレスの検証 (580ms)
   ✓  4 auth.test.ts:48:1 › メールアドレスを入力する /signup (749ms)
   ✓  5 auth.test.ts:64:1 › 連続して送ろうとするとエラーになる (509ms)
   ✓  6 auth.test.ts:75:1 › サインアップを続ける (2s)
   ✓  7 auth.test.ts:146:1 › ログインしていないのにログアウトする (569ms)
   ✓  8 auth.test.ts:155:1 › ログインする (1s)
   ✓  9 auth.test.ts:181:1 › 名前やパスワードを変更する (4s)
   ✓  10 auth.test.ts:256:1 › メールアドレスを変更する (2s)
   ✓  11 auth.test.ts:297:1 › 新しいメールアドレスでログインしてから元に戻す (1s)
   ✓  12 auth.test.ts:321:1 › パスワードのリセットを行う (2s)
 
   Slow test file: auth.test.ts (16s)
   Consider splitting slow test files to speed up parallel execution
 
   12 passed (17s)

** 見た目を整える [#ff83ba55]

DaisyUI を入れてみる

 LANG: console
 $ pnpm svelte-add@latest tailwindcss
  .../Local/pnpm/store/v3/tmp/dlx-9408     | +121 ++++++++++++
  .../Local/pnpm/store/v3/tmp/dlx-9408     | Progress: resolved 121, reused 71, downloaded 50, added 121, done
  ➕ Svelte Add (Version 2023.11.191.00)
  The project directory you're giving to this command cannot be determined to be guaranteed fresh — maybe it is, maybe it isn't. If any issues arise after running this command, please try again, making sure you've run it on a freshly initialized SvelteKit or Vite–Svelte app template.
  =============
  
  WARNING: You are currently running a version of TypeScript which is not officially supported by @typescript-eslint/typescript-estree.
  
  You may find that it works just fine, or you may not.
  
  SUPPORTED TYPESCRIPT VERSIONS: >=4.3.5 <5.3.0
  
  YOUR TYPESCRIPT VERSION: 5.3.2
  
  Please only submit bug reports when using the officially supported version.
  
  =============
  {
      "type": "Script",
      "start": 0,
      "end": 699,
      "context": "default",
      "content": {
          "type": "Program",
          "start": 688,
          "end": 690,
          "loc": {
              "start": {
                  "line": 1,
                  "column": 0
              },
              "end": {
                  "line": 1,
                  "column": 690
              }
          },
          "body": [
              {
                  "type": "BlockStatement",
                  "start": 688,
                  "end": 690,
                  "loc": {
                      "start": {
                          "line": 1,
                          "column": 688
                      },
                      "end": {
                          "line": 1,
                          "column": 690
                      }
                  },
                  "body": []
              }
          ],
          "sourceType": "module"
      }
  }
  {
      "type": "Script",
      "start": 0,
      "end": 699,
      "context": "default",
      "content": {
          "type": "Program",
          "start": 688,
          "end": 690,
          "loc": {
              "start": {
                  "line": 1,
                  "column": 0
              },
              "end": {
                  "line": 1,
                  "column": 690
              }
          },
          "body": [
              {
                  "type": "BlockStatement",
                  "start": 688,
                  "end": 690,
                  "loc": {
                      "start": {
                          "line": 1,
                          "column": 688
                      },
                      "end": {
                          "line": 1,
                          "column": 690
                      }
                  },
                  "body": []
              }
          ],
          "sourceType": "module"
      }
  }
  
  PostCSS
   ✅ successfully set up and repaired (it looks like it was in a broken setup before this command was run)!
  Create or find an existing issue at https://github.com/svelte-add/svelte-add/issues if this is wrong.
  
  Tailwind CSS
   ✅ successfully set up and repaired (it looks like it was in a broken setup before this command was run)!
  Create or find an existing issue at https://github.com/svelte-add/svelte-add/issues if this is wrong.
  
  Run pnpm install to install new dependencies, and then reload your IDE before starting your app.

言われた通りに、

 LANG: console
 $ pnpm install
  
  Packages: +42 -3
  ++++++++++++++++++++++++++++++++++++++++++---
  Progress: resolved 380, reused 320, downloaded 38, added 42, done
  node_modules/.pnpm/svelte-preprocess@5.1.0_postcss-load-config@4.0.1_postcss@8.4.31_svelte@4.0.5_typescript@5.3.2/node_modules/svelte-preprocess: Running postinstall script, done in 37ms
  
  devDependencies:
  + autoprefixer 10.4.14 (10.4.16 is available)
  + postcss 8.4.31
  + postcss-load-config 4.0.1 (4.0.2 is available)        
  - prettier-plugin-svelte 3.0.0
  + prettier-plugin-tailwindcss 0.4.1 (0.5.7 is available)
  + tailwindcss 3.3.2 (3.3.5 is available)
  
  Done in 12.6s
 $ pnpm install daisyui
  Packages: +4
  ++++
  Progress: resolved 384, reused 358, downloaded 4, added 4, done
  
  dependencies:
  + daisyui 4.4.9
  
  Done in 6.6s


TailwindCSS を使うと、

- @apply が使える https://tailwindcss.com/docs/reusing-styles#extracting-classes-with-apply
-- class の add/remove を css だけでできる感じになる

*** コンポーネントの準備 [#g6e8a392]

src/lib/components/DialogPage.svelte
 LANG: html
 <script lang="ts">
   export let title: string;
 </script>
 
 <div class="relative flex flex-col items-center justify-center h-screen overflow-hidden">
   <div class="w-full lg:max-w-lg p-6 m-auto rounded-md shadow-2xl">
     <h1 class="text-3xl font-semibold text-center text-primary">{title}</h1>
     
     <slot />
     
   </div>
 </div>

src/lib/components/Form.svelte
 LANG: html
 <script lang="ts">
   import { afterUpdate } from 'svelte';
   export let message= '';
   export let enhance: ((el: HTMLFormElement) => object) = ()=>{return {}};
 
   let form: HTMLFormElement;
 
   afterUpdate(()=> {
     if(message) {
       for(const elem of form.querySelectorAll('input')){
         elem.classList.add('input-error')
       }
     }
   })
 </script>
 
 {#if message}<span class="text-sm text-red-600">{message}</span>{/if}
 <form bind:this={form} class="space-y-4" method="POST" use:enhance>
   <slot />
 </form>

src/lib/components/LabeledInput.svelte
 LANG: html
 <label for={name} class="label"><span class="text-base label-text">{label}</span></label>
 <input {...{ name, type }} 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}

src/lib/components/SubmitButton.svelte
 LANG: html
 <script lang="ts">
   export let disabled = false;
 </script>
 
 <div><button disabled={disabled} class="btn btn-block btn-primary text-white"><slot /></button></div>

*** 書き換える [#red8afd3]

こんな風に書ける

src/routes/[purpose=emailVerificationPurpose]/+page.svelte
 LANG: html
 <DialogPage title={purpose}>
   <Form meesage={$message} {enhance}>
     <LabeledInput name="email" label="メールアドレス" bind:value={$form.email} disabled={$submitting} errors={errors} />
     <p>{purpose} を続けるためのリンクを含むメールを送信します<br />
       そちらのリンクから {purpose} 手続きを進めて下さい</p>
     <SubmitButton disabled={$submitting}>メールを送信</SubmitButton>
     <div>
       <a class="text-sm text-blue-600 hover:underline hover:text-blue-400" href="/">トップへ戻る</a>
     </div>
 </Form>
 </DialogPage>

&ref(emailVerificationForm.png,,50%);

** コミット時に自動的に prettier を通す [#ae385321]

https://prettier.io/docs/en/precommit.html

 LANG: console
 $ pnpm dlx mrm@2 lint-staged
  .../Local/pnpm/store/v3/tmp/dlx-29384    | +234 +++++++++++++++++++++++
  .../Local/pnpm/store/v3/tmp/dlx-29384    | Progress: resolved 234, reused 234, downloaded 0, added 234, done
  Running lint-staged...
  Installing lint-staged and husky...
  npm ERR! Cannot read properties of null (reading 'matches')
  
  npm ERR! A complete log of this run can be found in:
  npm ERR!     C:\Users\osamu\AppData\Local\npm-cache\_logs\2023-11-26T16_26_28_836Z-debug-0.log
  husky - Git hooks installed
  husky - updated .husky/pre-commit

あれ、素直に npx を使うべきだった?

 LANG: console
 $ npx mrm@2 lint-staged
  Running lint-staged...
  Installing lint-staged and husky...
  ...
  [#.................] / idealTree:@jridgewell/sourcemap-codec: timing idealTree:node_modules/.pnpm/@jridgewell+sourcemap-codec@1.4.15/node_modules/@jridgewell/sourcemap-codec Completed in 
  npm ERR! Cannot read properties of null (reading 'matches')
  
  npm ERR! A complete log of this run can be found in:
  npm ERR!     C:\Users\osamu\AppData\Local\npm-cache\_logs\2023-11-27T03_07_53_172Z-debug-0.log
  husky - Git hooks installed
  husky - updated .husky/pre-commit

うまく行かない。

後回しにしよう。。。

っと思ったら中途半端に変更されてて commit でエラーになる。

 LANG: console
 $ git commit
 node:internal/modules/cjs/loader:1078
   throw err;
   ^
 
 Error: Cannot find module 'C:\Users\osamu\Desktop\svelte\authtest\node_modules\lint-staged\bin\lint-staged.js'
     at Module._resolveFilename (node:internal/modules/cjs/loader:1075:15)
     at Module._load (node:internal/modules/cjs/loader:920:27)
     at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:81:12)
     at node:internal/main/run_main_module:23:47 {
   code: 'MODULE_NOT_FOUND',
   requireStack: []
 }
 
 Node.js v18.16.0
 husky - pre-commit hook exited with code 1 (error)
 
 $ cat .git/config 
  [core]
          repositoryformatversion = 0
          filemode = false
          bare = false
          logallrefupdates = true
          symlinks = false
          ignorecase = true
          hooksPath = .husky
  
 $ ls .husky/*
  .husky/pre-commit*
  
  .husky/_:
  husky.sh*
 $ cat .husky/pre-commit 
  #!/bin/sh
  . "$(dirname "$0")/_/husky.sh"
  
  npx lint-staged
 $ pnpm lint-staged
  node:internal/modules/cjs/loader:1078
    throw err;
    ^
  
  Error: Cannot find module 'C:\Users\osamu\Desktop\svelte\authtest\node_modules\lint-staged\bin\lint-staged.js'
      at Module._resolveFilename (node:internal/modules/cjs/loader:1075:15)
      at Module._load (node:internal/modules/cjs/loader:920:27)
      at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:81:12)
      at node:internal/main/run_main_module:23:47 {
    code: 'MODULE_NOT_FOUND',
    requireStack: []
  }
  
  Node.js v18.16.0
   ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL  Command failed with exit code 1: lint-staged
  
  Command "lint-staged" not found.

あれ? さしあたり lint-staged が入ってなきゃうまく行くわけない感じ???~
たぶん husky もなんじゃ???

 LANG: console
 $ pnpm i -D lint-staged husky
 $ pnpm up

これで git commit で prettier が走るようになった。

** routes を整理する? [#n6346265]

- /account/(loggedOut)/new アカウント登録
- /account/(loggedOut)/new/[token] アカウント登録
- /account/(loggedOut)/reset パスワードリセット
- /account/(loggedOut)/reset/[token] パスワードリセット
- /account/(loggedIn)/edit 登録情報編集
- /account/(loggedIn)/delete 登録抹消
- /account/(loggedIn)/email メールアドレス変更
- /account/(loggedIn)/email/[token] メールアドレス変更
- /session/(loggedOut)/new ログイン
- /session/(loggedIn)/delete ログアウト

とか。

検討中。

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

上の内容の続き。

** データベースの準備 [#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 表示は自分でやっても良いんだよね・・・


** style の :global について [#xfd2fae0]

svelte の style 定義には自動的にコンポーネントインスタンスの id が追加されるため、
そのコンポーネントに直接含まれる(子コンポーネントは除外)エレメントにしか効果を及ぼさない。

コンポーネントを超えて効果を期待する場合には :global を付ける。

https://svelte.jp/docs/svelte-components#style
 LANG: html
 <style>
 	:global(strong) {
 		/* これはすべての <strong> に適用されます */
 		margin: 0;
 	}
 
 	div :global(strong) {
 		/* これは「このコンポーネント内の <div> 要素」の中にある
 			 「すべての <strong> 要素」に
 			 適用されます */
 		color: goldenrod;
 	}
 </style>

上記の例では div に :global がかかっていないため、div にはコンポーネントインスタンスの 
ID が付き、その下にあることが条件になっている。

「このコンポーネントの子供」に適用したい場合にはこのテクニックが役に立ちそう。

Counter: 1184 (from 2010/06/03), today: 1, yesterday: 0