🗂

[Astro] ライブラリを本番環境でのみCDNから読み込む

に公開

ライブラリを本番環境でのみCDNから読み込みたい

モチベ

開発はオフラインでしたいけど、本番環境ではブラウザキャッシュを効かせるためにCDNからライブラリをロードしたい。

既成ライブラリ

vite-plugin-cdn-importなるものがあるけれど、AstroではviteプラグインのtransformIndexHtmlが使えないので動かなかった。
https://www.npmjs.com/package/vite-plugin-cdn-import

ないなら作りましょう

ってことでいろいろAIに手伝ってもらいながら作りました。
30%自分70%AIが書いたのでクソコードです。
AstroとSvelteとCSSしか対応させてないです。
近々JS対応させたい。

Svelteのintegrationより前に入れないと、動きません。
ただ、180行目くらいのコメントアウトしてるのを戻して、上の行をコメントアウトするとSvelteの後でも動きます。

// astro.config.ts
import cdn from "@/integrations/cdn-loader";
import svelte from "@astrojs/svelte";
import { defineConfig } from "astro/config";

// https://astro.build/config
export default defineConfig({
    integrations: [
        cdn({
            prodURL: "https://cdn.jsdelivr.net/npm/{pkg}@{ver}/{path}",
            modules: [
                {
                    name: "@fontsource/*",
                    path: "{}",
                },
                {
                    name: "@fontsource-variable/*",
                    path: "{}",
                },
            ],
        }),
        svelte(),
    ],
});
// ./src/integration/cdn-loader.ts
import type { AstroIntegration } from "astro";
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import type { Plugin } from "vite";

interface ModuleConfig {
    name: string;
    /** パス変換ルール("{}"は元のパスをそのまま使用) */
    path?: string;
}

interface CDNPluginOptions {
    /** CDN URLテンプレート({pkg}, {ver}, {path}を置換) */
    prodURL: string;
    /** ローカルにインストールされたバージョンを使用するか */
    lockVersion?: boolean;
    /** 対象モジュール設定 */
    modules?: ModuleConfig[];
}

/**
 * パッケージのバージョンを取得
 */
function resolvePkgVersion(pkgName: string): string | null {
    try {
        const req = createRequire(import.meta.url);
        const pkgJsonPath = req.resolve(`${pkgName}/package.json`);
        const json = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
        return json.version || null;
    } catch {
        return null;
    }
}

/**
 * ワイルドカードパターンを正規表現に変換
 */
function wildcardToRegex(pattern: string): RegExp {
    const escaped = pattern
        .replace(/[.+?^${}()|[\]\\]/g, "\\$&") // 特殊文字をエスケープ
        .replace(/\*/g, "([^/]+)"); // * を単一セグメントマッチに変換
    return new RegExp(`^${escaped}(?:/(.*))?$`);
}

/**
 * モジュール名がパターンにマッチするかチェックし、マッチ結果を返す
 */
function matchModule(
    moduleName: string,
    config: ModuleConfig,
): { pkg: string; subpath: string } | null {
    const regex = wildcardToRegex(config.name);
    const match = moduleName.match(regex);

    if (!match) return null;

    // パッケージ名を構築(ワイルドカード部分を含む)
    let pkg = config.name;
    if (match[1]) {
        pkg = pkg.replace("*", match[1]);
    }

    const subpath = match[2] || "";

    return { pkg, subpath };
}

/**
 * パステンプレートを処理
 */
function processPathTemplate(
    template: string | undefined,
    originalPath: string,
): string {
    if (!template || template === "{}") {
        return originalPath;
    }

    // テンプレート処理を拡張可能にする
    return template.replace("{}", originalPath);
}

function buildCDN_URL(
    template: string,
    pkg: string,
    path: string,
    version: string | null,
    lockVersion: boolean,
): string {
    let url = template.replace("{pkg}", pkg).replace("{path}", path);

    if (lockVersion && version) {
        url = url.replace("{ver}", version);
    } else {
        // バージョンを指定しない場合は@{ver}部分を削除
        url = url.replace("@{ver}", "");
    }

    return url;
}

function VitePluginCDNLoader(options: CDNPluginOptions): Plugin {
    const { prodURL, lockVersion = true, modules = [] } = options;

    return {
        name: "vite:cdn-loader",
        enforce: "pre",
        apply: "build", // 本番ビルド時のみ有効

        transform(code, id) {
            if (id.endsWith(".astro") || id.endsWith(".svelte")) {
                let transformedCode = code;
                for (const moduleConfig of modules) {
                    const regex = new RegExp(
                        `import\\s+[^'"]*['"](${moduleConfig.name.replace(
                            "*",
                            "([^'\"/]+)",
                        )}(?:/[^'"]*)?)['"];?`,
                        "g",
                    );
                    const matches = [...transformedCode.matchAll(regex)];
                    for (const match of matches) {
                        const fullImport = match[0];
                        const importPath = match[1];

                        const matched = matchModule(importPath, moduleConfig);
                        if (!matched) continue;

                        // console.log({ match, importPath, matched });

                        const { pkg, subpath } = matched;
                        // パスを処理(デフォルトは index.css)
                        let finalPath = subpath || "index.css";
                        if (finalPath && !finalPath.endsWith(".css")) {
                            finalPath += ".css";
                        }
                        // パステンプレート処理
                        finalPath = processPathTemplate(
                            moduleConfig.path,
                            finalPath,
                        );
                        // バージョン取得
                        const version = lockVersion
                            ? resolvePkgVersion(pkg)
                            : null;
                        // CDN URL生成
                        const cdnUrl = buildCDN_URL(
                            prodURL,
                            pkg,
                            finalPath,
                            version,
                            lockVersion,
                        );

                        transformedCode = transformedCode.replace(
                            fullImport,
                            "",
                        );

                        if (id.endsWith(".astro")) {
                            const match2 = /\$\$render`(.*)`/g.exec(
                                transformedCode,
                            );
                            if (match2) {
                                const insertPos =
                                    match2.index + match2[0].length - 1;
                                transformedCode =
                                    transformedCode.slice(0, insertPos) +
                                    `/* <link rel="stylesheet" href="${cdnUrl}"/> */` +
                                    transformedCode.slice(insertPos);
                            } else {
                                console.log({ transformedCode });
                                throw new Error(
                                    "Failed to inject CDN link: render function not found.",
                                );
                            }
                        } else if (id.endsWith(".svelte")) {
                            transformedCode += `\n/* <link rel="stylesheet" href="${cdnUrl}"/> */\n`;

                            // コンパイル後のコードに挿入するコードを書いたけど、
                            // よく考えたらSvelteIntegrationよりも前にこのIntegrationを挿入すれば
                            // コンパイル前のファイルに直接挿入できるので、そちらの方が良さそう。

                            // let match2 =
                            //     /\$\$payload\.out\.push\(\`(.+)\`/g.exec(
                            //         transformedCode,
                            //     );
                            // if (!match2) {
                            //     match2 = /\$\.from_html\(`(.+)`/g.exec(
                            //         transformedCode,
                            //     );
                            // }
                            // if (match2) {
                            //     const insertPos =
                            //         match2.index + match2[0].length - 1;
                            //     transformedCode =
                            //         transformedCode.slice(0, insertPos) +
                            //         `/* link rel="stylesheet" href="${cdnUrl}" */` +
                            //         transformedCode.slice(insertPos);
                            // } else {
                            //     console.log({ transformedCode });
                            //     throw new Error(
                            //         "Failed to inject CDN link: HTML output function not found.",
                            //     );
                            // }
                        }
                    }
                }

                if (transformedCode !== code) {
                    // console.log("Transformed code:", { id, transformedCode });
                    return {
                        code: transformedCode,
                    };
                }
            }
        },

        async generateBundle(_, bundle) {
            // jsのコメント化されたタグを削除する
            for (const [fileName, file] of Object.entries(bundle)) {
                if (file.type === "chunk" && file.isEntry) {
                    const code = file.code;
                    const regex = /\s*\/\*\s*(<[^*]+>)\s*\*\/\s*/g;
                    file.code = code.replace(regex, "");
                }
            }
        }
    };
}

export default function AstroIntegrationCDNLoader(
    options: CDNPluginOptions,
): AstroIntegration {
    return {
        name: "astro:cdn-loader",
        hooks: {
            "astro:config:setup": ({ updateConfig }) => {
                updateConfig({
                    vite: {
                        plugins: [VitePluginCDNLoader(options)],
                    },
                });
            },
            "astro:build:generated": ({ dir, logger }) => {
                // 生成されたHTMLファイルを走査し、htmlのコメント化されたタグを復元して保存
                const files: string[] = [];

                function walkDir(currentDir: string) {
                    const entries = readdirSync(currentDir, {
                        withFileTypes: true,
                    });
                    for (const entry of entries) {
                        const fullPath = path.join(currentDir, entry.name);
                        if (entry.isDirectory()) {
                            walkDir(fullPath);
                        } else if (
                            entry.isFile() &&
                            entry.name.endsWith(".html")
                        ) {
                            files.push(fullPath);
                        }
                    }
                }

                walkDir(dir.pathname);

                for (const filePath of files) {
                    let _content = readFileSync(filePath, "utf8");
                    let content = _content;
                    const regex = /\s*\/\*\s*(<[^*]+>)\s*\*\/\s*/g;
                    const tags = new Set<string>();

                    let match;
                    while ((match = regex.exec(_content)) !== null) {
                        content = content.replace(match[0], "");
                        tags.add(match[1]);
                    }

                    const sitePath = path.relative(dir.pathname, filePath);

                    logger.debug(`Found ${tags.size} CDN link tags in ${sitePath}.`);

                    if (tags.size > 0) {
                        const insertPos = content.indexOf("</head>");
                        if (insertPos !== -1) {
                            content =
                                content.slice(0, insertPos) +
                                Array.from(tags).join("") +
                                content.slice(insertPos);
                        } else {
                            logger.error(
                                `</head> tag not found in ${sitePath}, cannot insert CDN links.`,
                            );
                            continue;
                        }
                    }

                    if (content !== _content) {
                        writeFileSync(filePath, content, "utf8");
                    }
                    logger.info(`Processed CDN links in: ${sitePath}`);
                }
            },
        },
    };
}

Discussion