file-based-routing-for-static-sites について
ファイルベースのルーティングで複数ページの静的サイトを生成する手順と実装例です。MarkdownやHTMLでサイトを作る開発者向けで、変更を監視し、影響を受けた出力ファイルだけを更新します。
主な機能
- 01ファイルベースのページルーティング
- 02MarkdownからHTMLへの変換
- 03静的アセットのコピー
- 04ビルドと監視モード
- 05変更対象ファイルのみ更新
活用シーン
- 01Markdownファイルから複数ページのサイトを生成
- 02ローカル開発中のサイト変更の確認
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ rodydavis/ skills --skill file-based-routing-for-static-sitesインストール後、Claude Code で/file-based-routing-for-static-sitesを入力して読み込まれたか確認します。
詳細
静的サイトのファイルベースルーティング
この記事では、ファイルベースルーティングを使用して、複数ページのアプリケーションを静的サイトとして出力する方法を説明します。
ステップ 1
「static-site-file-based-routing」という名前の新しいフォルダーを作成し、VSCode で開きます。
mkdir static-site-file-based-routing
cd static-site-file-based-routing
code .
ステップ 2
tsconfig.json を作成し、以下の内容に置き換えます。
{
"compilerOptions": {
"incremental": true,
"target": "es5",
"module": "es2020",
"outDir": "dist",
"strict": true,
"moduleResolution": "node",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"typeRoots": [
"node_modules/@types",
"src/@types"
]
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist"
]
}
ステップ 3
package.json を作成し、以下の内容に更新します。
{
"name": "static-site-file-based-routing",
"version": "1.0.0",
"description": "File based routing for HTML MPA",
"type": "module",
"scripts": {
"start": "wds --node-resolve --root-dir build --base-path /static-site-file-based-routing --open --watch",
"postinstall": "npm run tsc",
"build": "node dist/main.js --inputDir ./example --outputDir ./build",
"dev": "node dist/main.js --inputDir ./example --outputDir ./build -w",
"tsc": "tsc",
"tsc:watch": "tsc -w"
},
"devDependencies": {
"@pkgjs/parseargs": "^0.10.0",
"@types/markdown-it": "^12.2.3",
"@types/node": "^18.7.23",
"@web/dev-server": "^0.1.34",
"chokidar": "^3.5.3",
"highlight.js": "^11.6.0",
"html-format": "^1.0.2",
"markdown-it": "^13.0.1",
"parse5": "^7.1.1",
"typescript": "^4.8.4"
}
}
次に、npm install を実行してすべての依存関係をインストールします。
サイトをローカルで配信するために、
@web/dev-serverを使用しています。任意のサーバーを使用できます。
これらの依存関係は、Markdown から HTML への変換、HTML の整形、ファイルの監視など、さまざまなファイル変換に使用します。
ステップ 4
src フォルダーを作成し、4 つのファイルを追加します。
src/build.ts
import * as fs from "fs";
import { compileDir, compileTarget } from "./compile.js";
import * as path from "path";
import chokidar from "chokidar";
import { publicDirectory } from "./static.js";
interface Options {
inputDir?: string;
outputDir?: string;
publicDir?: string;
watch?: boolean;
clean?: boolean;
}
export default async function build(options: Options) {
const inputDir = options.inputDir || "www";
const outputDir = options.outputDir || "build";
const publicDir = options.publicDir || "public";
const watch = options.watch || false;
const clean = options.clean || false;
if (!fs.existsSync(inputDir)) {
throw new Error(`Input directory ${inputDir} does not exist`);
}
if (clean) {
if (fs.existsSync(outputDir)) {
fs.rmdirSync(outputDir, { recursive: true });
}
}
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
if (watch) {
console.log("Watching for changes...");
chokidar.watch(inputDir).on("all", async (event, inputFile) => {
console.log(event, inputFile);
if (fs.existsSync(inputFile)) {
const relativePath = path.relative(inputDir, inputFile);
const outputFile = `${outputDir}/${relativePath}`;
const stat = fs.statSync(inputFile);
if (stat.isDirectory()) {
await compileDir(inputFile, outputFile);
} else if (stat.isFile()) {
const filename = path.basename(inputFile);
if (filename === "layout.html") {
// Rebuild all related directories
const dir = path.dirname(inputFile);
const inDir = path.relative(inputDir, dir);
const outDir = `${outputDir}/${inDir}`;
await compileDir(dir, outDir);
} else {
await compileTarget(inputFile, outputFile);
}
}
}
});
} else {
await compileDir(inputDir, outputDir);
}
if (publicDir.split(',').length > 1) {
for (const dir of publicDir.split(',')) {
publicDirectory(dir, outputDir, watch);
}
} else {
publicDirectory(publicDir, outputDir, watch);
}
}
src/compile.ts
import * as fs from "fs";
import * as path from "path";
import MarkdownIt from "markdown-it";
import hljs from "highlight.js";
import * as parse5 from "parse5";
import type { Document } from "parse5/dist/tree-adapters/default.js";
import format from 'html-format';
function compile(file: string) {
const raw = fs.readFileSync(file, "utf-8");
const ext = path.extname(file);
switch (ext) {
case ".md":
case ".markdown":
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return (
'<pre class="hljs"><code>' +
hljs.highlight(str, { language: lang, ignoreIllegals: true })
.value +
"</code></pre>"
);
} catch (__) {
console.error(__);
}
}
return "";
},
});
return parse5.parse(md.render(raw));
case ".html":
return parse5.parse(raw);
default:
break;
}
return raw;
}
function createHtml(options?: { head?: string; body?: string; }) {
return `
<!DOCTYPE html>
<html lang="en">
<head>
${options?.head ?? ""}
</head>
<body>
${options?.body ?? "<slot></slot>"}
</body>
</html>
`;
}
export async function compileFile(file: string, target: string) {
// Use regex to check if ends with index.*
const isIndex = /index\.([a-z]+)$/i.test(file);
if (!isIndex && !fs.statSync(file).isDirectory()) {
// Skipping for nested layouts
return;
}
const parent = path.dirname(target);
// Render up the directory tree until we hit the root directory
const files: string[] = [file];
let filePath = file;
while (filePath !== parent) {
filePath = path.dirname(filePath);
// Check for root level layout
const layout = path.join(filePath, "layout.html");
if (fs.existsSync(layout)) {
files.unshift(layout);
}
if (filePath === ".") break;
}
// Check for root level index markdown or html
const layout = path.join(parent, "layout.html");
if (fs.existsSync(layout)) {
if (
fs.existsSync(path.join(parent, "index.html")) ||
fs.existsSync(path.join(parent, "index.md")) ||
fs.existsSync(path.join(parent, "index.markdown"))
) {
files.unshift(layout);
}
}
let output = createHtml();
for (const item of files) {
const doc = compile(item);
if (typeof doc !== 'string') {
const content = parse5.serialize(doc);
output = mergeDocuments(output, content);
}
}
// Replace extension
const ext = path.extname(file);
const newFile = target.replace(ext, '.html');
// Check if parent directory exists
const parentDir = path.dirname(newFile);
if (!fs.existsSync(parentDir)) {
fs.mkdirSync(parentDir, { recursive: true });
}
fs.writeFileSync(newFile, output);
console.log(`--> ${newFile}`);
}
function mergeDocuments(current: string, source: string) {
let raw = current;
// Merge body
const html = extractDoc(parse5.parse(source));
// Check for <slot></slot>
const hasSlot = raw.includes("<slot></slot>");
if (hasSlot) {
raw = raw.replace("<slot></slot>", parse5.serialize(html.body));
} else {
// Append to body
const endBodyIdx = raw.lastIndexOf("</body>");
const start = raw.slice(0, endBodyIdx);
const end = raw.slice(endBodyIdx);
const body = parse5.serialize(html.body);
raw = start + body + end;
}
// Merge head
const endHeadIdx = raw.lastIndexOf("</head>");
const start = raw.slice(0, endHeadIdx);
const end = raw.slice(endHeadIdx);
const head = parse5.serialize(html.head);
raw = start + head + end;
// Format
raw = format(raw);
// Remove duplicate title tags
const lastTitle = raw.lastIndexOf("<title>");
const lastTitleEnd = raw.lastIndexOf("</title>");
const title = raw.slice(lastTitle, lastTitleEnd + 8);
raw = raw.replace(/<title>.*<\/title>/, "");
raw = raw.replace("</head>", title + "</head>");
return raw;
}
function extractDoc(doc: Document) {
const html = (doc.childNodes[1] ?? doc.childNodes[0]) as unknown as Document;
const head = html.childNodes.find(
(node) => node.nodeName === "head"
) as unknown as Document;
const body = html.childNodes.find(
(node) => node.nodeName === "body"
) as unknown as Document;
return { head, body };
}
export async function compileDir(inputDir: string, outputDir: string) {
const files = fs.readdirSync(inputDir);
for (const file of files) {
const inputFile = `${inputDir}/${file}`;
const outputFile = `${outputDir}/${file}`;
await compileTarget(inputFile, outputFile);
}
}
export async function compileTarget(input: string, output: string) {
const stat = fs.statSync(input);
if (stat.isDirectory()) {
if (!fs.existsSync(output)) {
fs.mkdirSync(output, { recursive: true });
}
await compileDir(input, output);
} else if (stat.isFile()) {
const ext = path.extname(input);
if (['.html', '.md', '.markdown'].includes(ext)) {
await compileFile(input, output);
} else {
const current = fs.readFileSync(input);
const parentDir = path.dirname(output);
if (!fs.existsSync(parentDir)) {
fs.mkdirSync(parentDir, { recursive: true });
}
if (fs.existsSync(output)) {
// Check if content is the same
const previous = fs.readFileSync(output);
if (Buffer.compare(current, previous) !== 0) {
fs.writeFileSync(output, current);
}
} else {
// Copy the file
fs.copyFileSync(input, output);
}
}
}
}
src/static.ts
import * as fs from "fs";
import * as path from "path";
import chokidar from "chokidar";
export function publicDirectory(publicDir: string, outputDir: string, watch: boolean) {
if (watch) {
if (fs.existsSync(publicDir)) {
chokidar.watch(publicDir).on("all", (event, inputFile) => {
console.log(event, inputFile);
if (fs.existsSync(inputFile)) {
const relativePath = path.relative(publicDir, inputFile);
const outputFile = `${outputDir}/${relativePath}`;
const stat = fs.statSync(inputFile);
if (stat.isDirectory()) {
copyStaticFiles(inputFile, outputFile);
} else if (stat.isFile()) {
fs.copyFileSync(inputFile, outputFile);
}
}
});
}
} else {
// Copy static files
if (fs.existsSync(publicDir)) {
copyStaticFiles(publicDir, outputDir);
}
}
}
function copyStaticFiles(inDir: string, outDir: string) {
const files = fs.readdirSync(inDir);
for (const file of files) {
const inputFile = `${inDir}/${file}`;
const outputFile = `${outDir}/${file}`;
const stat = fs.statSync(inputFile);
if (stat.isDirectory()) {
if (!fs.existsSync(outputFile)) {
fs.mkdirSync(outputFile, { recursive: true });
}
copyStaticFiles(inputFile, outputFile);
} else if (stat.isFile()) {
fs.copyFileSync(inputFile, outputFile);
}
}
}
src/main.ts
#!/usr/bin/env node
// @ts-ignore
import { parseArgs } from "@pkgjs/parseargs";
import build from "./build.js";
export async function main() {
const {
values: { inputDir, outputDir, watch },
} = parseArgs({
options: {
inputDir: {
type: "string",
short: "i",
},
outputDir: {
type: "string",
short: "o",
},
watch: {
type: "boolean",
short: "w",
},
},
allowPositional: true,
});
if (inputDir === undefined || outputDir === undefined) {
console.log("Usage: build -i <inputDir> -o <outputDir> [-w]");
return;
}
await build({
inputDir,
outputDir,
watch,
});
}
main();
ステップ 5
プロジェクトのセットアップが完了したので、TypeScript コンパイラーをウォッチモードで起動します。
npm run ts:watch
ステップ 6
次に、ウェブサイトのソースファイルを格納するフォルダーを作成します。
example/index.md
# Hello World
This is a test
example/style.css
body {
background-color: #000;
color: #fff;
}
example/layout.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Example</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<slot></slot>
</body>
</html>
ステップ 7
ソースファイルを作成したので、ビルドスクリプトを実行できます。
一度だけビルドする場合は、次を実行します。
npm run build
ウォッチモードで実行する場合は、次を実行します。
npm run dev
次に、HTTP サーバーを起動します。
npm run start
まとめ
変更を加えると、影響を受けるファイルのみが更新されるため、とても高速に更新できます。
JavaScript はバンドルされません。また、いずれかのファイルで
node_modulesを使用する場合は、ご自身で対応する必要があります(リポジトリ内のサンプルでは、UNPKG の使用方法を紹介しています)。
ソースコードを確認したい場合はこちらをご覧ください。お読みいただきありがとうございました。ご質問があれば、ぜひお知らせください。