In this article I will go over how to use file based routing to output as a static site multi page application.
> **TLDR** The final source [here](https://github.com/rodydavis/static-site-file-based-routing) and an online [demo](https://rodydavis.github.io/static-site-file-based-routing/).
## Step 1
Create a new folder called “static-site-file-based-routing” and open it up in VSCode.
```bash
mkdir static-site-file-based-routing
cd static-site-file-based-routing
code .
```
## Step 2
Create a `tsconfig.json` and replace it with the following:
```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"
]
}
```
## Step 3
Create a `package.json` and update it with the following:
```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"
}
}
```
Then run `npm install` to install all the dependencies.
> Note: I am using `@web/dev-server` to serve the site locally. You can use any server you want.
These dependencies are used for various file transformations such as markdown to HTML, HTML formatting, and file watching.
## Step 4
Create a `src` folder and add 4 files:
### `src/build.ts`
```javascript
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`
```javascript
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`
```javascript
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`
```javascript
#!/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();
```
## Step 5
Now that the project is setup we can start the typescript compiler in watch mode.
```bash
npm run ts:watch
```
## Step 6
Now create a folder that will contain the source files for the website.
### `example/index.md`
```md
# Hello World
This is a test
```
### `example/style.css`
```css
body {
background-color: #000;
color: #fff;
}
```
### `example/layout.html`
```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>
```
## Step 7
With the source files created we can now run the build script.
For a one time build run:
```bash
npm run build
```
For a watch mode run:
```bash
npm run dev
```
Now start the http server:
```bash
npm run start
```
## Conclusion
As you make changes it will only update affected files and be very fast to update.
> Note that this does not bundle the javascript and will be up to you if you are using `node_modules` in any files (for the example in the repo I show how to use **UNPKG**).
If you want to find the source code you can check it out [here](https://github.com/rodydavis/static-site-file-based-routing) otherwise thanks for reading and let me know if you have any questions!