🗂
[Astro] ライブラリを本番環境でのみCDNから読み込む
ライブラリを本番環境でのみCDNから読み込みたい
モチベ
開発はオフラインでしたいけど、本番環境ではブラウザキャッシュを効かせるためにCDNからライブラリをロードしたい。
既成ライブラリ
vite-plugin-cdn-importなるものがあるけれど、AstroではviteプラグインのtransformIndexHtmlが使えないので動かなかった。
ないなら作りましょう
ってことでいろいろ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