たくさんの自由帳
Androidのお話
たくさんの自由帳
投稿日 : | 0 日前
文字数(だいたい) : 12309
目次
本題
Tailwind CSS 4 系に
resolveConfig がなくなった
resolveConfig がなくなった(DOM なし)
Tailwind CSS の apply が効かない
React 19 の use Hook を使う
検索画面が戻れるように
自力で HTML を組み立てる
今回の作戦
remark rehype の話
嬉しいところ
つらいところ
事件簿
書き直しで不要になったライブラリ削除
シンタックスハイライト
heading の id 属性
リストのドットの位置
リンクカード
等幅フォント
script
テストコード
VRT
関連記事
前後の記事
max-width
余ったので
記事に戻るボタン
ダークモード切り替えにデバイス設定に従うを追加
タグ記事一覧画面でもページネーション
img を押したら別のタブで画像を開く
next.config.ts TypeScript にした
デプロイする
Please install @types/node by running
ローカルだと動いてたのはなぜ~
おわりに
おわりに2
どうもこんばんわ。
シークレットラブ(仮)純愛アフターストーリー 攻略しました、
こころなしか、効果音が減った気がする。気のせいかも。
今作はこれ!!!!です
わたし的には本編よりもおもしろかった!!かも!ヨカッタ
ちーちゃん良すぎる
、、、、
あとここの声すき
ここも
よかった!!!
やりたいこと一覧。たまってきたので一気にやります。
next/link
の画面遷移だと<script>
が実行されない<img>
を遅延読み込みTailwind CSS
をv4
にuse Hook
と<Suspence>
で書き直したいThe config property experimental.turbo is deprecated
を直したいsitemap.xml
、日付new Date()
じゃだめでは以下のコマンドを叩くと、できる限り自動で移行されるそうです。
npx @tailwindcss/upgrade
一部のユーティリティ名が置き換わった。
- className="grow focus:outline-none bg-transparent py-1 text-content-text-light dark:text-content-text-dark"
+ className="grow focus:outline-hidden bg-transparent py-1 text-content-text-light dark:text-content-text-dark"
また、tailwind.config.ts
が解体されたそうです。
今までユーティリティ名捜査対象のファイル(.tsx
)とかを指定していましたが、なんか自動でやってくれるそうなので不要になった。
そして、最大の変更点である、自前の色設定が.ts
から.css
の変数に移動しました。
よってこれが、
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class',
content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
// コピーボタンを差し込むので
"./src/transformShikiCodeBlockCopyButton.ts"
],
theme: {
// 既存の色を拡張する(プライマリカラー等を追加する)
extend: {
// ネストできるので、テーマ別にそれぞれ
colors: {
// コンテンツで使う色
content: {
// プライマリーカラー
primary: {
// md_theme_light_primary
light: '#4A58A9',
dark: '#BBC3FF'
},
// セカンダリーカラー
secondary: {
// md_theme_light_secondary
light: '#974068',
dark: '#974068'
},
// 文字
text: {
// md_theme_dark_background : md_theme_light_background
light: '#1B1B1F',
dark: '#FEFBFF'
}
},
// コンテナの色。コンテンツの色の下に敷く
container: {
// プライマリーカラー
primary: {
// md_theme_light_surface : md_theme_dark_surface
light: '#FFFBFF',
dark: '#1B1B1F'
},
// セカンダリーカラー
secondary: {
// md_theme_light_surface : md_theme_dark_surface の RGB それぞれに 0.95 倍したもの。カラーコード 明るさ とかで検索
light: '#f2eef2',
dark: '#19191d'
}
},
// Error ?
error: {
// md_theme_light_error
light: '#BA1A1A',
dark: '#BA1A1A'
},
// 背景色
background: {
// md_theme_light_primaryContainer
light: '#DEE0FF',
dark: '#000000'
},
// 選択時の色(ホバー)
hover: {
// md_theme_light_primary の 25% の色。16進数なので 40 です(RGBA)
light: '#4A58A940',
dark: '#BBC3FF40'
},
},
fontFamily: {
// next/font で読み込んだやつ
'body': ['var(--koruri-font)'],
}
},
},
plugins: [],
}
こうなった。確かにCSS
変数になった。import
も減った。
これでスクロールバーの色をCSS
で付けていたのですが、つける色がts
に書かれているでコピペしていた。
が、Tailwind CSS
の色が変数になったので、var()
で参照すれば良くなった。
@import 'tailwindcss';
@custom-variant dark (&:is(.dark *));
@theme {
--color-content-primary-light: #4a58a9;
--color-content-primary-dark: #bbc3ff;
--color-content-secondary-light: #974068;
--color-content-secondary-dark: #974068;
--color-content-text-light: #1b1b1f;
--color-content-text-dark: #fefbff;
--color-container-primary-light: #fffbff;
--color-container-primary-dark: #1b1b1f;
--color-container-secondary-light: #f2eef2;
--color-container-secondary-dark: #19191d;
--color-error-light: #ba1a1a;
--color-error-dark: #ba1a1a;
--color-background-light: #dee0ff;
--color-background-dark: #000000;
--color-hover-light: #4a58a940;
--color-hover-dark: #bbc3ff40;
--font-body: var(--koruri-font);
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
tailwind.config.ts
から色を取り出すresolveConfig()
が、CSS 変数
の移行でなくなった?
何に使っていたかというとAndroid Chrome
のタブの色を変えるのに使ってた。
const colors = resolveConfig(tailwindConfig).theme?.colors
if (colors) {
const backgroundColor = isDarkmode ? colors['background']['dark'] : colors['background']['light']
document.querySelector("meta[name='theme-color']")?.setAttribute('content', backgroundColor)
}
ただ、DOM API (document / window)
触れるなら、getPropertyValue()
でCSS 変数
アクセスできるので、とりあえず解決。
const styles = getComputedStyle(document.documentElement)
const backgroundColor = styles.getPropertyValue(isDarkmode ? "--color-background-dark" : "--color-background-light")
document.querySelector("meta[name='theme-color']")?.setAttribute('content', backgroundColor)
OGP
を作っている箇所はブラウザではなく、Node.js (サーバー側)
なので、上のDOM API
を使う方法では解決できない。
まじでCSS
を読み出して正規表現で取り出すくらいしか思いつかないんだけど
強引にファイルを読み出しててもやりたい場合はこんな感じ。Tailwind CSS
が使っているPostCSS
を、CSS
パーサーとして利用する。CSS
ファイルを読み出して、PostCSS
に入れると、CSS
の構造通りにネストされたJSON
が返ってくる。ので、あとは名前を一個一個確認してfilter()
していくとCSS
変数にたどり着ける。
// @thene { } を解析
const themeBlock = cssParse.root.nodes
.filter((node) => node.type === 'atrule')
.find((node) => node.name === 'theme')
// 各 CSS 変数を取得。object に css 変数の key がある
const cssVariableList = Object.fromEntries(
themeBlock
?.nodes
?.filter((node) => node.type === 'decl')
?.map((node) => ([node.prop, node.value])) || []
)
// 例
const backgroundColor = cssVariableList['--color-background-light']
これが効いてない...
.content_div {
/* リンクがはみ出るので */
overflow-wrap: break-word;
/** 文字の色 */
@apply text-content-text-light dark:text-content-text-dark;
}
修正は、@reference
を使うか、css
変数に書き直すか。らしい。
取り急ぎ@reference
を使うように直してみた。こんな感じかな。
@reference "../../styles/css/global.css";
.content_div {
/* リンクがはみ出るので */
overflow-wrap: break-word;
/** 文字の色 */
@apply text-content-text-light dark:text-content-text-dark;
}
クライアントコンポーネントでは、async function
でJSX
を作ることが出来ません。RSC
の特権のようです。
じゃあ我々は今でもuseEffect()
でデータ取得しなくちゃいけないのかというと、そうでもなく。
React v19 – React
The library for web and native user interfaces
https://ja.react.dev/blog/2024/12/05/react-19
use()
と<Suspence>
がこれを解決します。use()
を使うことで、Promise
の中身をいい感じに取得できます。取得できるまでの間は、<Suspence fallback={}>
でフォールバックUI
が表示できます。use()
を呼び出たコンポーネントから、一番近い<Suspence>
が使われるそうです。一点、use()
フックに渡すPromise
は、レンダリング中に作るとだめらしい。
というか、Promise
オブジェクトは多分使い回す必要があります。とりあえずuseMemo
に入れました。
つまり、use()
の中でPromise
オブジェクト(インスタンス?)を作ると怒られるので、いい感じに外から渡すようにしないといけない?<Suspence>
はPromise
が完了したときに、もう一回描画されるらしい。なので、use()
呼び出しの中で毎回あたらしくPromise
を作ると、Promise 誕生 → use() Promise開始 → Promise完了 → <Suspence> 再レンダリング → Promise 誕生 → use() Promise開始 ...
になる?
/** 検索する */
async function executeSearchFromQueryParams(searchWord: string) {
// pagefind で検索する
// 省略...
}
export default function PagefindSearch() {
// クエリパラメータから取り出す
const searchParams = useSearchParams()
const searchWord = searchParams.get('q') ?? ''
// 検索する Promise。検索結果に渡す
// 多分 Promise オブジェクトは使い回す必要があるので、useMemo()
const promiseObject = useMemo(() => executeSearchFromQueryParams(searchWord), [searchWord])
return(
<Suspense fallback={<CircleLoading />}>
<SearchResult resultListPromise={promiseObject} />
</Suspense>
)
}
type SearchResultProps = {
resultListPromise: Promise<PagefindSearchFragment[]>
}
function SearchResult({ resultListPromise }: SearchResultProps) {
// Promise を待つ
const resultList = use(resultListPromise)
resultList.map(...)
}
今までは検索ワードをuseState()
に入れて、Enter
を押したらpagefind
で検索していました。
が、これだと戻れません。戻るを押すと検索画面より前まで戻ってしまします。
というわけで、URL
にクエリパラメータを付与して、画面遷移するように。
検索画面は、パラメーターが付いていれば検索を実行するようにしました。
/** 検索画面 */
export default function PagefindSearch() {
// クエリパラメータから取り出す
const searchParams = useSearchParams()
const searchWord = searchParams.get('q') ?? ''
// 以下省略
}
URL
を作って画面遷移する部分はこう、<form>
でURL
移動するやつですね。<form>
のNext.js
版のnext/form
を使いました。最近?のバージョンから使えるようになったはず。<form>
と違ってnext/form
はURL
移動がnext/link
を使うようになっています!!
import Form from 'next/form'
export default function SearchForm({ searchWord }: SearchFormProps) {
// method="get" なので
// form 確定したら /search/q={検索ワード} のページに遷移する
return (
<Form
className="search-form flex flex-row w-full space-x-2 py-2 px-4 rounded-full bg-container-primary-light dark:bg-container-primary-dark"
action="/search/"
>
<input
className="grow focus:outline-hidden bg-transparent py-1 text-content-text-light dark:text-content-text-dark"
type="input"
placeholder="検索ワード"
name="q"
defaultValue={searchWord} />
</Form>
)
}
いままではunified
のremark / rehpye
を通じてHTML
を作っています。HTML
を作るのをunified
に一任している感じです。
が、自分でHTML
を書きたい場合がチラホラ出て、
しかも結構あってunified
に一任を辞めたくなってきた。理由は先述したとおりですが再掲。
<img>
の読み込みを、画面内に入るまで遅延して欲しいS3 + CloudFront
で画像を配信するようにしたためnext/link
のSPA
な画面遷移では表示されないnext/link
のSPA
画面遷移では読み込まれない、、、先述の通り、<img>
を遅延読み込みしたいとか、リンクカード作りたいとか、で必要なHTML タグ
のみ自分で描画する。
できれば興味ない、というか今まで使ったことないHTML
タグは、これまで通りunified
で作ってほしい。
自前でJSX
を書いているタグ、というかこのブログで使っているタグは以下で、
仮にこれ以外が来た場合は一律unified
を使ったHTML
作成にフォールバックしています。
const ReBuildHtmlElementTagNames = [
// グループ
"div",
// 改行
"br",
// 文字
// 文章、文字、太字、右上につける文字、斜め、打ち消し線
"p", "span", "strong", "sup", "em", "del",
// リンク
"a",
// セクション
"section",
// 引用
"blockquote",
// 折りたたみ要素
"details", "summary",
// 区切り線
"hr",
// 画像
"img",
// 箇条書き
"ol", "ul", "li",
// 表
"table", "thead", "tbody", "tr", "td", "th",
// 見出し
"h1", "h2", "h3", "h4", "h5", "h6",
// コード・コードブロック
"pre", "code",
// スクリプト
"script", "noscript",
// iframe
"iframe"
] as const
今回、自前でJSX
を書いて本文を描画するわけです。が、Markdown
をパースしてHTML
にする直前まではunified
のremark / rehype
がやっています。
remark
が、Markdown
処理系で、rehype
がHTML
処理系です。
今までみたいに、一発でMarkdown
からHTML
にすることもできます。
それに加えて、間に入ることもできます。Markdown
のパース結果がオブジェクト
でもらえるので、編集したりなんかができます。
編集したオブジェクト
をrehype
に投げればHTML
に出来るって寸法。
Markdown
のパース結果のオブジェクトをmdast (Markdown AST)
、HTML
のをhast (HTML AST)
と呼んでます。
今回は間に入り、最終的にHTML
を作る部分は自前で実装。Markdown
からHTML
のオブジェクト、hast
を受け取るところまでunified
にお願いして言います。
実際にMarkdown -> mdast -> hast
にしているコードです。
/**
* Markdown から unified の HTML AST を取得する
*
* @param markdown Markdown 本文
* @returns hast (unified HTML AST)
*/
static async parseMarkdownToHtmlAst(markdown: string) {
const remarkProcessor = unified()
.use(remarkParse)
.use(remarkGfm)
const rephypeProsessor = unified()
.use(remarkRehype, { allowDangerousHtml: true })
// Markdown AST (mdast)
const mdast = remarkProcessor.parse(markdown)
// mdast -> HTML AST (hast)
const hast = await rephypeProsessor.run(mdast)
return hast
}
返り値がこんな感じ。あとはtagName
を見てJSX
を組み立てているって感じ。
リンクカードを作りたければ<a>
を組み立てる際に介入すればOKだし。
{
"type": "root",
"children": [
{
"type": "element",
"tagName": "p",
"properties": {},
"children": [
{
"type": "text",
"value": "どうもこんばんわ。",
"position": {
"start": {
"line": 1,
"column": 1,
"offset": 0
},
"end": {
"line": 1,
"column": 10,
"offset": 9
}
}
}
],
"position": {
"start": {
"line": 1,
"column": 1,
"offset": 0
},
"end": {
"line": 1,
"column": 12,
"offset": 11
}
}
}
],
"position": {
"start": {
"line": 1,
"column": 1,
"offset": 0
},
"end": {
"line": 7,
"column": 1,
"offset": 58
}
}
}
children
は、"type": "element"
以外の場合があるので、まずfilter()
する必要があります。union
的なのをfilter()
で仕分けるのってTypeScript
できたっけ、、最近できた?
element.children.filter((node) => node.type === "element").map((element) => element.tagName)
Tailwind CSS
がそのまま当てられるclassName
に渡せますからimg
に遅延読み込みが指定できたrephype
プラグインはほとんど消し去ったremark
はMarkdown
パース系なので残るで正解unified
によるフォールバックはあんまり使えない<p>
の中にdiv
入れられないので、<p>
傘下は全部自前で、、みたいな感じunified
が作ったHTML
をjs-dom
に渡してquerySelector(h1)
みたいにしていたunified
のノード捜索ライブラリに置き換えできた。npx depcheck
すると、未利用のライブラリを探してくれるらしいです。
というわけでグローバルインストールして、
npm install -g depcheck
Next.js
プロジェクト内でターミナルを開いて以下を実行。
npx depcheck
こんな感じ!!!
PS C:\Users\takusan23\Desktop\Dev\NextJS\ziyuutyou-next> npx depcheck
Unused dependencies
* jsdom
* rehype-pretty-code
* rehype-slug
今までrehype
がやっていたのを、自分でshiki
を使うようにするだけ。
自分で作れるようになったので、コピーボタンを差し込む方法も簡単です。
import { codeToHtml } from "shiki"
/** ShikiCodeBlockRender へ渡す Props */
type ShikiCodeBlockRenderProps = {
/** コード本文 */
code: string
/** 言語。未指定の場合は plaintext */
language?: string
}
/** shiki を使ってシンタックスハイライトした後描画するコードブロック */
export default async function ShikiCodeBlockRender({ code, language }: ShikiCodeBlockRenderProps) {
const syntaxHighlightingCode = await codeToHtml(
code.trimEnd(),
{
lang: language ?? 'plaintext',
theme: 'dark-plus'
}
)
// この pre にスクロールバーと padding
return <div className="[&>pre]:overflow-x-scroll [&>pre]:p-4" dangerouslySetInnerHTML={{ __html: syntaxHighlightingCode }} />
}
一点、何故かビルドするとたまによくエラーになる時があった。もう一回コマンドを叩くと、今度は成功するしで、再現性が。。
そもそも try-catch したので、例外は catch されるはずなんですが、何故か例外でnext export
が失敗してしまう。
ShikiError: Language `gradle` is not included in this bundle. You may want to load it from external source.
成功するときは成功するので謎、、
とりあえず、言語がロードされてないってことだったので、全部ロードするように。
/**
* シングルトンにする。
* よく分からないけど、明示的にすべて読み込むようにしないと、たまによくビルドがが成功しない。
* https://shiki.style/guide/install#highlighter-usage
*/
const highlighterPromise = createHighlighter({
themes: Object.keys(bundledThemes),
langs: Object.keys(bundledLanguages)
})
/** shiki を使ってシンタックスハイライトした後描画するコードブロック */
export default async function ShikiCodeBlockRender({ code, language }: ShikiCodeBlockRenderProps) {
const trimCode = code.trimEnd()
const option = {
lang: language ?? 'plaintext',
theme: 'dark-plus'
}
const highlighter = await highlighterPromise
let syntaxHighlightingCode: string
try {
// Markdown のコードブロックの言語を尊重する
syntaxHighlightingCode = highlighter.codeToHtml(trimCode, option)
} catch (e) {
// 失敗したら plaintext で再試行
console.log(`言語 ${option.lang} のシンタックスハイライトに失敗しました。plaintext にします。`)
syntaxHighlightingCode = highlighter.codeToHtml(trimCode, { ...option, lang: 'plaintext' })
}
return (
<div className="relative group">
<div className="[&>pre]:overflow-x-scroll [&>pre]:p-4 [&>pre]:my-4" dangerouslySetInnerHTML={{ __html: syntaxHighlightingCode }} />
</div>
)
}
rehype
時代は、rehype-slug
を使うことで、h1 等
を作る際に自動的にid
属性を付与してくれてました。
が、自前で描画するする場合は利用できません。頑張りましょう。
今まで使ってたやつと同じようにh1/h2
からid
を作りたいので、何を使っているか見てきました。hast-util-to-string
したものを、github-slugger
に渡してそうです。
というわけで、インストールして、
npm i hast-util-to-string
npm i github-slugger
h1
たちを描画しているクラスでこんな感じです。
インスタンスを作って、slugger.slug()
を使う。
import { ReactNode } from "react"
import GithubSlugger from "github-slugger"
import { toString } from "hast-util-to-string"
import type { Element } from "hast"
/** 見出しの id 属性作成 */
const slugger = new GithubSlugger()
/** HeadingElement に渡す Props */
type HeadingElementProps = {
/** h1 ~ h6 のどれか */
tagName: "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
/** id 属性作成のために h1 等のそれ自身を */
element: Element
/** 子。文字だと思う */
children: ReactNode
}
/** h1 から h6 までを描画する。見出しはこっちで付与します。rehype-slug 実装相当です。 */
export default function HeadingElement({ tagName, element, children }: HeadingElementProps) {
const id = slugger.slug(toString(element))
switch (tagName) {
case "h1":
return <h1 id={id} className="text-2xl text-content-primary-light">{children}</h1>
case "h2":
return <h2 id={id} className="text-xl text-content-primary-light">{children}</h2>
case "h3":
return <h3 id={id} className="text-lg text-content-primary-light">{children}</h3>
case "h4":
return <h4 id={id} className="text-content-primary-light">{children}</h4>
case "h5":
return <h5 id={id} className="text-content-primary-light">{children}</h5>
case "h6":
return <h6 id={id} className="text-content-primary-light">{children}</h6>
}
}
ドットの下に改行されてほしくない。
で、色々見てみたけど、よく分からなかったため、m-[revert] p-[revert]
しました、、
// 箇条書き
case "ul":
return <ul className="list-disc m-[revert] p-[revert]">{childrenHtml}</ul>
case "li":
return <li>{childrenHtml}</li>
リンクカードを作りました。じゃーん!!
実装は、静的サイト書き出し時
にリンクカードのリンクのHTML
取得し、OGP
を取り出してブログ記事に埋め込んでいます。
なのでリンクカードは埋め込んだ状態でHTML
ができます。
書き出し時に取得するため時間がかかるのと、相手のサイトに若干迷惑かも。
html = await fetch(
url,
{
headers: [
['User-Agent', `GET_LINKCARD_${EnvironmentTool.BASE_URL}`]
],
cache: 'force-cache',
next: { revalidate: false }
}
).then((res) => res.text())
Next.js
がfetch() API
を独自に拡張しているのを思い出したので、引数に永遠にキャッシュするように指示してみました。
あとUser-Agent
も適当に自前のを。開発中はマシになってるはず・・?
多分この指定でいいんじゃないかなあ、、、
(v13あたりでデフォキャッシュだったのが、反発あったのかもとに戻ったんですね)
HTML
のパースですが、unified
のrehype
のrehype-parse
ライブラリを使いました。jsdom
でブラウザ JS
のquerySelector()
とかを使って辿っていく方法もあると思います、が、unified
入れてるならこれでいいかって。
/**
* 文字列の HTML から unified HTML AST を作成する
*
* @param html HTML
* @returns 要素の配列
*/
static parseHtmlAstFromHtmlString(html: string) {
// fragment: true で html/head/body が生成されないように
const rephypeProsessor = unified()
.use(rehypeParse, { fragment: true })
const hast = rephypeProsessor.parse(html)
return hast.children
}
返り値、HTML
をオブジェクトで表現して返してくれるので、filter()
とかで探していけばよいはず。
const hast = MarkdownParser.parseHtmlAstFromHtmlString(html)
const metaElementList = hast
.filter((element) => element.type === "element")
.filter((element) => element.tagName === "meta")
// それぞれ取り出す
const ogTitle = metaElementList.find((element) => element.properties['property'] === 'og:title')?.properties['content']?.toString()
const ogDescription = metaElementList.find((element) => element.properties['property'] === 'og:description')?.properties['content']?.toString()
実際に描画している箇所はLinkCardRender.tsx
です。
コードブロック用に等幅フォントを用意しました。これでコンソールの結果を貼り付けたときに綺麗に整列するはず。Web フォント
をこれ用に読み込むことになるので、、すいません。
使わせていただいているフォントはKosugi Maru
です、
昔からのAndroid
ユーザーですか?モトヤマルベリ
ってフォントです。
<script>
をMarkdown
に貼り付けても動くようになりました。
やっていることは先駆者さんのマネです。先述の通り今回自前でJSX
使って書いてるので、先駆者さんの技が使えるようになりました。
export default function ClientScriptRender({ src, type, children }: ScriptRenderProps) {
// クライアントで描画されたときに src / type をセットする
// next/link の画面遷移では <script> のスクリプトが起動しない
const divRef = createRef<HTMLDivElement>()
useEffect(() => {
// すでに div に追加していれば何もしない
if (divRef.current?.querySelector('script')) return
// 作成
const scriptElement = document.createElement('script')
scriptElement.src = src ?? ""
scriptElement.type = type ?? ""
// 追加
divRef.current?.append(scriptElement)
// 一応消しておく
return () => { divRef.current?.removeChild(scriptElement) }
}, [])
return (
<div ref={divRef}>
{children}
</div>
)
}
あれもこれも欲しい!!!って自前で描画しているので、本当に期待通りに動いているか心配になってきます。
というわけで、テストコードがついに導入されました。
unified
を信じていたので今まではテストコードありませんでした。 🦁 !!!!!!!!!!
が、不安しか無い + 数日後の私は覚えてないので、期待通りに動いているかをコマンド一発で確認できるようにしました。 🦁...
テスト実行はjest
かvitest
の2つがあるそうですが、ES Modules
に対応しているvitest
でやりました。
もう我々のユーザーランドから見て直せねえだろてエラーまいった。
ところで、このマークダウンを自前で描画しているコンポーネント
、async function()
なんですよね。出来るか・・?
って思って色々やってたら出来ました。いや私のがたまたま動いただけかもしれない。どっちにしろエラー消えないけど、
/** npm run test で実行できます */
describe('<MarkdownRender /> のテスト', () => {
// テスト実行で一回だけ
beforeAll(() => {
// next/font がエラーになるのでモック
vi.mock('next/font/local', () => ({
default: () => ({ className: '', style: { fontFamily: '' }, variable: '' })
}))
})
// 前回の render() 結果が消えるように
afterEach(() => {
cleanup()
});
test('文字が描画できる', async () => {
// よく分からないですが、act() + <Suspense> で async function もテストできた
// https://github.com/testing-library/react-testing-library/issues/1209
await act(async () => {
render(
<Suspense>
<MarkdownRender markdown='text' />
</Suspense>
)
})
expect(screen.getByText('text')).toBeDefined()
expect(screen.getByText('text').tagName).toBe('P')
})
})
A component was suspended by an uncached promise. Creating promises inside a Client Component or hook is not yet supported, except via a Suspense-compatible library or framework.
await act()
で囲えって言われたので囲った。<Suspence>
はIssue
がそうしていたので真似した。これをやっても謎のエラーが出る。Support for React Server Components · Issue #1209 · testing-library/react-testing-library
Describe the feature you'd like: As a user of react 18 with NextJS (with app directory), I would like to render async server components example: // Page.tsx const Page = async ({ params, searchPara...
https://github.com/testing-library/react-testing-library/issues/1209
謎のエラーがでていますが、一応動いているので、まあ良いか。
stderr | __test__/MarkdownRender.test.tsx > <MarkdownRender /> のテスト > HTML に style が書かれていたら自分で描画するのを辞める
A component was suspended by an uncached promise. Creating promises inside a Client Component or hook is not yet supported, except via a Suspense-compatible library or framework.
✓ __test__/MarkdownRender.test.tsx (25 tests) 365ms
✓ <MarkdownRender /> のテスト > 文字が描画できる 47ms
✓ <MarkdownRender /> のテスト > 太字が描画できる 55ms
✓ <MarkdownRender /> のテスト > 打ち消し線が描画出来る 9ms
✓ <MarkdownRender /> のテスト > 斜線が描画できる 8ms
✓ <MarkdownRender /> のテスト > <p> が描画できる 12ms
✓ <MarkdownRender /> のテスト > <span> が描画できる 13ms
✓ <MarkdownRender /> のテスト > <sup> が描画できる 7ms
✓ <MarkdownRender /> のテスト > 改行できる 7ms
✓ <MarkdownRender /> のテスト > リンクが描画できる 19ms
✓ <MarkdownRender /> のテスト > リンクカードの取得に失敗した 17ms
✓ <MarkdownRender /> のテスト > リンクカードの取得に成功した 23ms
✓ <MarkdownRender /> のテスト > <section> を描画できる 6ms
✓ <MarkdownRender /> のテスト > 引用できる 7ms
✓ <MarkdownRender /> のテスト > 折りたたみ要素が描画できる 9ms
✓ <MarkdownRender /> のテスト > 区切り線が描画出来る 6ms
✓ <MarkdownRender /> のテスト > 画像が描画できる 8ms
✓ <MarkdownRender /> のテスト > 箇条書きが描画できる 15ms
✓ <MarkdownRender /> のテスト > テーブルが描画できる 13ms
✓ <MarkdownRender /> のテスト > 見出しが描画できる 25ms
✓ <MarkdownRender /> のテスト > コードブロックが描画出来る 16ms
✓ <MarkdownRender /> のテスト > <code> が描画できる 8ms
✓ <MarkdownRender /> のテスト > <script> が挿入される 5ms
✓ <MarkdownRender /> のテスト > <iframe> が挿入される 15ms
✓ <MarkdownRender /> のテスト > 自前で描画しないタグも描画できる 6ms
✓ <MarkdownRender /> のテスト > HTML に style が書かれていたら自分で描画するのを辞める 7ms
Test Files 1 passed (1)
Tests 25 passed (25)
Start at 01:11:34
Duration 8.24s
PASS Waiting for file changes...
press h to show help, press q to quit
そういえば、App Router
ならテストファイルを実際のコンポーネントのファイルの近く(コロケーション
と呼ぶ考えらしいです)に置けるのですが、忘れてました。
一度きりだけですが、Playwright
を使って一応スクリーンショットテストを前と後でやりました。
全部は見れてないですが、大きく崩れていないことは確認しています、、
実行する前にGoogle アナリティクス
など、集計に影響が出ないか皆さん確認しましょうね。
こんな感じにスクショの差分がでてくる。
細かいpadding
とかが影響してるっぽかった。
話変わって。
関連する記事を記事本文の末尾に表示するようにしてみました。
ぜひ見ていってください。
関連記事の検索ですが、タグを基準にしています。
今の記事についているタグが含まれている数順で表示しています。tagNameList.includes()
の部分で同じタグの数を数えて、多いのが上に来るように並び替え。
/**
* 関連する記事を取得する
* タグを基準に、新しい順で。
*
* @param excludeUrl 除外する URL。自分のこと。
* @param tagNameList タグの配列
* @param maxSize 最大件数
* @returns BlogItem[]
*/
static async findRelatedBlogItemList(excludeUrl: string, tagNameList: string[], maxSize: number) {
const blogList = await this.getBlogItemList()
// 関連しているかの判断は、引数に渡したタグが、何個一致しているか
// 記事は新しい順
const relatedBlogItemList = blogList
// タグ無いとかは弾いておく
.filter((blogItem) => blogItem.link !== excludeUrl && blogItem.tags.length !== 0)
// 関係ない記事が出そうなので、2つ以上一致しているとき
// 一旦件数と Pair する
.map((blogItem) => {
const containsTagCount = blogItem.tags.filter((tagName) => tagNameList.includes(tagName)).length
return { containsTagCount, blogItem }
})
.filter((pair) => 2 <= pair.containsTagCount)
// 一致している順
.sort((a, b) => b.containsTagCount - a.containsTagCount)
// 返すときは BlogItem[] に戻す
.map((pair) => pair.blogItem)
.splice(0, maxSize)
return relatedBlogItemList
}
↑に関連していますが、前後の記事を表示するようにしました。
本当は関連記事だけで良いかな~~って思ってたんですが、関連記事のリストを幅いっぱいに引き伸ばしたらイマイチだった。
というわけで、関連記事のリストを50%
、で、余った残り50%
は・・?
で色々何が出来るかFigma
でお絵かきしてみました。
この時点でよくある、前後の記事を表示する案はもちろんあったのですが、良いデザインが思いつかなった。
残り何も書かないのはやっぱ嫌だなあ・・・って考えてみた結果。
前後・・・矢印・・・左右・・・道路標識!?!??!?!(←?)
というわけで道案内 UI
的なのが誕生し、なんかいい感じだったので採用。
ただ前後の記事をボタンで表示するのは・・やだなーって感じだったので良かったかも。
あんまり全画面にしないので分からなかったんですが、全画面で見ると、引き伸ばされてイマイチ・・・?
なので、よくあるmax-width
を設定するようにしました。
コードブロックがはみ出たので、w-full
をmax-width
と一緒に付与する必要があるかもです。
落書きした。パソコンみたいに幅が広くないとでません。
CSS
でアニメーション入れてる。かえって気になっちゃうようなら消そうかな。
スマホだとハンバーガーメニューを押して、記事一覧を押す必要があった。
もっと手軽に一覧に移動できるようにした。
特に言うことはないですが、追加しました!
Android
の記事増えてきたのでいい加減やりました。
ページは配列の配列で表現しているのですが、Kotlin
のCollection#chunked
みたいな、指定した数で配列の配列を作る関数が欲しかった。
調べたらStackoverflow
にドンピシャのがあったので使わせてもらってます。
private static chunkedPage<T>(origin: T[], size: number) {
return origin
.map((_, i) => i % size === 0 ? origin.slice(i, i + size) : null)
.filter((nullabeList) => nullabeList !== null)
}
は、loading="lazy"
と一緒に対応したのですが、また動いてません!!!
最近の画像を配信しているS3+CloudFront
の手直しが必要そうでした!!!
Configuration: TypeScript | Next.js
Next.js provides a TypeScript-first development experience for building your React application.
CJS
からESM
の書き方になっていたので、追従することに人がいないタイミングを狙います。
日曜の深夜とか良いんじゃねって思ったんですが普通に頭痛が痛くて断念(Pull Request
をマージするだけ)
まじで今まで見たこと無いエラーだ。
Please install @types/node by running:
npm install --save-dev @types/node
If you are not trying to use TypeScript, please remove the tsconfig.json file from your package root (and any TypeScript files in your app and pages directories).
で、なんでこのエラーが出たかというと、process.env.NODE_ENV
を利用したから。らしい。
解決方法は書いてあるとおりで、@types/node
を入れればよいです。
@types/node がグローバルインストールされたてた
グローバルインストールのnode_modules
を消したらちゃんとエラーになりました。(エラー出て喜んでるの草)
ちなみにNext.js
だけかも?ですが、
自分で入れなくてもnpm run dev
したら勝手に追加されました。私の場合はグローバルインストールされてて追加してくれませんでしたが。
Next.js
もReact
も、結構親切なエラーメッセージを出してくれます。
が、たまに内容 0 のエラーが表示されるときがあります。
これは多分Turbopack
が原因っぽいので、Turbopack
無しで開発サーバーを起動すればよいと思います。
webpack
だとこう
おわり!!!
Turbopack
が開発時(開発サーバー)以外にも、本番の静的書き出し(ビルド)が出来るようになったそうです。
ただ、このブログでは以下のIssue
と同じ理由でまだ使えません。。