QwikCityでブログを作り直した
QwikCityによるJamStack構成のブログの構築
ブログの書き直し自体がもはや一つの独立した趣味となりつつある。
- https://www.namachan10777.dev/blog/blog-on-nextjs
- https://www.namachan10777.dev/blog/blog-on-rust
- https://www.namachan10777.dev/blog/blog-on-astrojs
Astroは十分に快適だった。key=も要らない。タグで書けば良い。
問題があるとすれば、現代の多くのWebページは静的なものであってもclient:loadすれば済む。document.getElementByIdでイベントを相互に送信し合う迷路のような依存関係が生まれるか、
そもそもとして一つでも別のUIフレームワークを用いた時点でバンドルツリーにはそれのランタイムが紛れ込む。
Markdownの扱いと画像最適化
QwikはイマドキのWebフレームワークとしてSSG機能を備えるし、import.meta.globは使えるが、これだけでは少し不便だ。
content-collectionsを使えばコンテンツ管理の問題は一部解決する。
ただ、content-collectionsはAstroの同名の機能ほどにはViteと連携して動かないので
MarkdownのパースをUIフレームワーク(というよりVite)と連携できないことの問題が最もよく現れるのはunifiedのエコシステムでMarkdownをhtmlに変換するだけではにwidthとheightを付与し、loading="lazy"を指定して最適な幅の画像を自動で読み込ませる ――は実現できない。contents-collectionのコンテンツをビルド時に変換する機能を使い、remarkでMarkdownをパースして画像をビルド時に最適化してしまい、mdastに埋め込む。
無理やりas unknown as Tを使って誤魔化しているが、mdastを定義し直すのはかなり面倒くさい。
import { defineConfig, defineCollection } from "@content-collections/core";
import crypto from "crypto";
import * as fs from "fs/promises";
import { Image, Root, RootContent } from "mdast";
import * as path from "path";
import remarkGfm from "remark-gfm";
import remarkMdx from "remark-mdx";
import remarkParse from "remark-parse";
import sharp from "sharp";
import { unified } from "unified";
export type TransformedImage = {
path: string;
dim: {
w: number;
h: number;
};
};
export type WithTransformedImage = {
transformed?: TransformedImage[];
};
export type ImageTransformationConfig = {
readonly outputRoot: string;
readonly outputSubDir: string;
readonly sourceBaseDir: string;
readonly scaling: number;
};
type TransformContext = {
readonly filePath: string;
};
function isAbslutePath(imgUrl: string): boolean {
return /^https?:\/\//.test(imgUrl) || imgUrl.startsWith("/");
}
function srcImgPath(
config: ImageTransformationConfig,
ctx: TransformContext,
imgUrl: string,
): string {
if (isAbslutePath(imgUrl)) {
return imgUrl;
}
const contentDir = /^(.+)\/?$/.exec(config.sourceBaseDir)?.[0];
if (contentDir == undefined) {
return imgUrl;
}
const srcPath = path.parse(ctx.filePath);
if (srcPath.dir === "") {
return `/`;
} else {
return `//`;
}
}
// Assume the output format is WebP
function generateImgDistFileName(
config: ImageTransformationConfig,
imgUrl: string,
width: number,
height: number,
) {
if (isAbslutePath(imgUrl)) {
return imgUrl;
}
const baseNameHash = crypto
.createHash("sha256")
.update(imgUrl)
.digest("base64")
.slice(0, 8);
const baseName = path.parse(imgUrl).base;
return `--x.webp`;
}
async function traverseMdAst<T extends RootContent>(
config: ImageTransformationConfig,
ctx: TransformContext,
ast: T,
) {
switch (ast.type) {
case "break":
case "code":
case "definition":
case "html":
case "footnoteReference":
case "imageReference":
case "inlineCode":
case "text":
case "thematicBreak":
case "yaml":
case "mdxTextExpression":
case "mdxFlowExpression":
case "mdxjsEsm":
return;
case "image":
if (
ast.url.startsWith("https://") ||
ast.url.startsWith("http://") ||
ast.url.startsWith("/")
) {
return;
}
const buffer = await fs.readFile(srcImgPath(config, ctx, ast.url));
const image = sharp(buffer);
let { width, height } = await image.metadata();
if (!(width && height)) return;
const images: TransformedImage[] = [];
while (width > 300) {
const resized = await image
.resize(Math.round(width), Math.round(height))
.toBuffer();
const fileName = generateImgDistFileName(
config,
ast.url,
width,
height,
);
console.log(`INFO: transformed markdown image: `);
const distPath = `/`;
await fs.writeFile(`/`, resized);
images.push({
path: distPath,
dim: {
w: width,
h: height,
},
});
width *= config.scaling;
height *= config.scaling;
}
if (ast.data) {
(ast.data as unknown as WithTransformedImage).transformed = images;
} else {
(ast as unknown as { data: WithTransformedImage }).data = {
transformed: images,
};
}
return;
default:
await Promise.all(
ast.children.map((child) => traverseMdAst(config, ctx, child)),
);
}
}
async function generateImages(
config: ImageTransformationConfig,
ctx: TransformContext,
ast: Root,
) {
await fs.mkdir(`/`, {
recursive: true,
});
await Promise.all(
ast.children.map((child) => traverseMdAst(config, ctx, child)),
);
}
const blog = defineCollection({
name: "blog",
directory: "src/content/blog",
include: "**/*.mdx",
schema: (z) => ({
title: z.string(),
date: z.string(),
tags: z.array(z.string()),
publish: z.boolean(),
description: z.string(),
}),
transform: async (document) => {
const mdast = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkMdx)
.parse(document.content);
const config = {
outputRoot: "public",
outputSubDir: "img",
scaling: 0.7,
sourceBaseDir: "src/content/blog",
};
const ctx = {
filePath: document._meta.filePath,
};
await generateImages(config, ctx, mdast);
return {
...document,
mdast: mdast as any,
};
},
});
export default defineConfig({
collections: [blog],
});
OGP
satoriで生成したSVGをsharpでWebPに変換して配信する。onStaticGenerate関数を書けばGETエンドポイントも静的にビルドされるので便利だ。dependenciesに書かれたqwikコンポーネントは静的ビルドされたHTMLに含まれない(これは仕様のようだ)。devDependenciesに書いて静的にビルドさせる必要がある。devDependenciesだと解決に失敗する。dependenciesに書く。
一見すると直感的には逆なように感じるが、QwikCityでのビルドはWebサーバーのビルドだと考えると
Pagefind
まずViteは動的インポートでも素直にECMAScriptの仕様通りの動きはさせない。import('/pagefind/pagefind.js?url')のように?urlを付けるだけでバンドルを防止出来る。?urlというクエリパラメータ込みでリクエストをしてしまう。
QwikCityは静的ビルド時は?urlを無視してチャンク分割を試みるので?urlが必要なくなったりはしない。
export async function loadPagefind(): Promise<PagefindApi> {
const path = "/pagefind/pagefind.js";
const module = import.meta.env.DEV
? await import("/pagefind/pagefind.js?url")
: await import(path);
return module as unknown as PagefindApi;
}
NextやAstroならこれで解決だが、QwikはクライアントサイドのJSを細切れに分割しJSONシリアライズして通信する仕様上、pagefind.jsはSSRには関与せずクライアントだけでimportするのでuseVisibleTask$を使いたい。useStoreで作ったデータ置き場にnoSerializeでモジュールを配置する。
Qwikのコードはあまり読めていないのでnoSerializeでどうしてうまく動くのかはあまり分からない。useVisibleTask$で取ってきたモジュールを保存しておく、
import type { NoSerialize, QRL, TaskFn } from "@builder.io/qwik";
import {
noSerialize,
useStore,
useVisibleTask$,
implicit$FirstArg,
} from "@builder.io/qwik";
import { type Options, type PagefindApi, loadPagefind } from "~/lib/pagefind";
export function usePagefind(options?: Options): {
api: NoSerialize<PagefindApi> | null;
} {
const store = useStore<{ api: NoSerialize<PagefindApi> | null }>({
api: null,
});
// eslint-disable-next-line qwik/no-use-visible-task
useVisibleTask$(async () => {
const api = await loadPagefind();
if (options) {
await api.options(options);
}
await api.init();
store.api = noSerialize(api);
});
return store;
}
export function useDebounceQrl(qrl: QRL<TaskFn>, debounce: number) {
const state = useStore<{
timeoutHandler: null | number;
lastExecuted: number;
}>({
timeoutHandler: null,
lastExecuted: 0,
});
// eslint-disable-next-line qwik/no-use-visible-task
useVisibleTask$((ctx) => {
if (state.timeoutHandler) {
clearTimeout(state.timeoutHandler);
state.timeoutHandler = null;
}
if (Date.now() > state.lastExecuted + debounce) {
state.lastExecuted = Date.now();
qrl(ctx);
} else {
state.timeoutHandler = setTimeout(() => {
state.lastExecuted = Date.now();
qrl(ctx);
}, debounce) as unknown as number;
}
});
}
export const useDebounce$ = /*#__PURE__*/ implicit$FirstArg(useDebounceQrl);
QwikCity雑感
Pros
確かにパフォーマンスの良さは感じる。大抵のことは実現できるように作られているし、
仮想DOMとローダーの綺麗な世界だけではうまく実現できないようなこと
Astroは良かったが、致命的に動的なイベントハンドリングが弱い、というか無い。document.getElementByIdを書くことになり
あとSFCはコンポーネント分割の体験があまり良くない。のサブコンポーネントとしてしか存在しないを
Cons
hooksを一から書くのがかなりキツい。useSignalなどのコードをちらっと見たが、Qwik内部のシステムと強固に繋がっている。を使ったらかなり面倒そうなエラーが発生した。
総評
実務で使うには流石に怖い。