mcpField
F
file-based-routing-for-static-sites

file-based-routing-for-static-sites

github.com/rodydavis開発6046 回インストール2 週間前に更新
リポジトリ

file-based-routing-for-static-sites について

ファイルベースのルーティングで複数ページの静的サイトを生成する手順と実装例です。MarkdownやHTMLでサイトを作る開発者向けで、変更を監視し、影響を受けた出力ファイルだけを更新します。

主な機能

  1. 01ファイルベースのページルーティング
  2. 02MarkdownからHTMLへの変換
  3. 03静的アセットのコピー
  4. 04ビルドと監視モード
  5. 05変更対象ファイルのみ更新

活用シーン

  1. 01Markdownファイルから複数ページのサイトを生成
  2. 02ローカル開発中のサイト変更の確認

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/rodydavis/skills --skill file-based-routing-for-static-sites

インストール後、Claude Code で/file-based-routing-for-static-sitesを入力して読み込まれたか確認します。

詳細

静的サイトのファイルベースルーティング

この記事では、ファイルベースルーティングを使用して、複数ページのアプリケーションを静的サイトとして出力する方法を説明します。

TLDR 最終的なソースコードはこちら、オンラインのデモもご覧ください。

ステップ 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 の使用方法を紹介しています)。

ソースコードを確認したい場合はこちらをご覧ください。お読みいただきありがとうございました。ご質問があれば、ぜひお知らせください。

コメント