refactor: clean up gallery tooling and document the workflow
All checks were successful
Deploy on push / deploy (push) Has been skipped
All checks were successful
Deploy on push / deploy (push) Has been skipped
This commit is contained in:
37
README.md
37
README.md
@@ -16,11 +16,47 @@ The site is based on the HTML5 UP Lens template and currently ships as a plain s
|
|||||||
- `data/meals.json`: source of truth for gallery entries
|
- `data/meals.json`: source of truth for gallery entries
|
||||||
- `data/elo.json`: Elo ratings, record totals, and ranking settings
|
- `data/elo.json`: Elo ratings, record totals, and ranking settings
|
||||||
- `scripts/build.js`: renders static pages from templates and data
|
- `scripts/build.js`: renders static pages from templates and data
|
||||||
|
- `scripts/check.js`: validates data, image assets, and generated pages
|
||||||
- `scripts/generate-thumbnails.js`: regenerates thumbnails from the full-size images
|
- `scripts/generate-thumbnails.js`: regenerates thumbnails from the full-size images
|
||||||
- `scripts/ingest-meal.js`: ingests a new meal image and metadata in one command
|
- `scripts/ingest-meal.js`: ingests a new meal image and metadata in one command
|
||||||
|
- `scripts/serve.js`: serves the generated site locally with a small static file server
|
||||||
- `scripts/lib/elo.js`: validates and syncs Elo data against the meal list
|
- `scripts/lib/elo.js`: validates and syncs Elo data against the meal list
|
||||||
- `package.json`: minimal Node build entrypoint
|
- `package.json`: minimal Node build entrypoint
|
||||||
|
|
||||||
|
## Run Locally
|
||||||
|
|
||||||
|
Install dependencies:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Build the site and validate the generated output:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Serve it locally:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run serve
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `http://127.0.0.1:4321`.
|
||||||
|
|
||||||
|
If you want a single command that builds and serves, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
To validate the repo state without rebuilding thumbnails or pages, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run check
|
||||||
|
```
|
||||||
|
|
||||||
## Content Workflow
|
## Content Workflow
|
||||||
|
|
||||||
Gallery entries live in `data/meals.json`, and the build generates both `index.html` and `rankings.html` from the template and data files.
|
Gallery entries live in `data/meals.json`, and the build generates both `index.html` and `rankings.html` from the template and data files.
|
||||||
@@ -99,4 +135,3 @@ The `x` and `y` values are normalized from `0` to `1`, where `0.5, 0.5` is the c
|
|||||||
## Planned Features
|
## Planned Features
|
||||||
|
|
||||||
1. Optional shared sync or export/import for rankings if browser-local persistence becomes too limiting.
|
1. Optional shared sync or export/import for rankings if browser-local persistence becomes too limiting.
|
||||||
2. General cleanup and history cleanup once the bigger structural changes are in place.
|
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
"name": "gallery",
|
"name": "gallery",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run build:thumbs && npm run build:pages",
|
"build": "npm run build:thumbs && npm run build:pages && npm run check",
|
||||||
"ingest": "node scripts/ingest-meal.js",
|
"ingest": "node scripts/ingest-meal.js",
|
||||||
|
"check": "node scripts/check.js",
|
||||||
"build:pages": "node scripts/build.js",
|
"build:pages": "node scripts/build.js",
|
||||||
"build:thumbs": "node scripts/generate-thumbnails.js",
|
"build:thumbs": "node scripts/generate-thumbnails.js",
|
||||||
"build:thumbs:force": "node scripts/generate-thumbnails.js --force"
|
"build:thumbs:force": "node scripts/generate-thumbnails.js --force",
|
||||||
|
"serve": "node scripts/serve.js",
|
||||||
|
"start": "npm run build && npm run serve"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"sharp": "^0.34.5"
|
"sharp": "^0.34.5"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const fs = require("fs");
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
const { getRankedMeals, syncEloWithMeals } = require("./lib/elo");
|
const { getRankedMeals, syncEloWithMeals } = require("./lib/elo");
|
||||||
const { loadMeals, repoRoot } = require("./lib/meals");
|
const { loadMeals, repoRoot, validateMealAssets } = require("./lib/meals");
|
||||||
|
|
||||||
const indexTemplatePath = path.join(repoRoot, "templates", "index.html");
|
const indexTemplatePath = path.join(repoRoot, "templates", "index.html");
|
||||||
const indexOutputPath = path.join(repoRoot, "index.html");
|
const indexOutputPath = path.join(repoRoot, "index.html");
|
||||||
@@ -126,6 +126,7 @@ function replaceBlock(template, token, replacement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildIndex(meals = loadMeals()) {
|
function buildIndex(meals = loadMeals()) {
|
||||||
|
validateMealAssets(meals);
|
||||||
const template = fs.readFileSync(indexTemplatePath, "utf8");
|
const template = fs.readFileSync(indexTemplatePath, "utf8");
|
||||||
const eol = detectEol(template);
|
const eol = detectEol(template);
|
||||||
|
|
||||||
@@ -136,6 +137,7 @@ function buildRankings(
|
|||||||
meals = loadMeals(),
|
meals = loadMeals(),
|
||||||
eloData = syncEloWithMeals(meals)
|
eloData = syncEloWithMeals(meals)
|
||||||
) {
|
) {
|
||||||
|
validateMealAssets(meals);
|
||||||
const template = fs.readFileSync(rankingsTemplatePath, "utf8");
|
const template = fs.readFileSync(rankingsTemplatePath, "utf8");
|
||||||
const eol = detectEol(template);
|
const eol = detectEol(template);
|
||||||
const rankedMeals = getRankedMeals(meals, eloData);
|
const rankedMeals = getRankedMeals(meals, eloData);
|
||||||
|
|||||||
151
scripts/check.js
Normal file
151
scripts/check.js
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const { getEloAlignmentReport, loadEloData } = require("./lib/elo");
|
||||||
|
const {
|
||||||
|
fullsDir,
|
||||||
|
loadMeals,
|
||||||
|
repoRoot,
|
||||||
|
thumbsDir,
|
||||||
|
validateMealAssets,
|
||||||
|
} = require("./lib/meals");
|
||||||
|
|
||||||
|
const indexPath = path.join(repoRoot, "index.html");
|
||||||
|
const rankingsPath = path.join(repoRoot, "rankings.html");
|
||||||
|
|
||||||
|
function listJpgIds(directoryPath) {
|
||||||
|
if (!fs.existsSync(directoryPath)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs
|
||||||
|
.readdirSync(directoryPath, { withFileTypes: true })
|
||||||
|
.filter(
|
||||||
|
(entry) => entry.isFile() && path.extname(entry.name).toLowerCase() === ".jpg"
|
||||||
|
)
|
||||||
|
.map((entry) => path.basename(entry.name, ".jpg"))
|
||||||
|
.sort((left, right) => left.localeCompare(right, undefined, { numeric: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUnexpectedIds(directoryPath, expectedIds) {
|
||||||
|
return listJpgIds(directoryPath).filter((id) => !expectedIds.has(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function countMatches(text, pattern) {
|
||||||
|
return (text.match(pattern) || []).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRankingsSeedData(rankingsHtml) {
|
||||||
|
const match = rankingsHtml.match(
|
||||||
|
/<script id="rankings-seed-data" type="application\/json">([\s\S]*?)<\/script>/
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
throw new Error("Generated rankings.html is missing embedded rankings seed data");
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateGeneratedPages(meals, eloData) {
|
||||||
|
if (!fs.existsSync(indexPath)) {
|
||||||
|
throw new Error("Generated index.html is missing; run npm run build");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(rankingsPath)) {
|
||||||
|
throw new Error("Generated rankings.html is missing; run npm run build");
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexHtml = fs.readFileSync(indexPath, "utf8");
|
||||||
|
const rankingsHtml = fs.readFileSync(rankingsPath, "utf8");
|
||||||
|
const galleryArticleCount = countMatches(indexHtml, /<article>/g);
|
||||||
|
const rankingCardCount = countMatches(rankingsHtml, /class="ranking-card"/g);
|
||||||
|
|
||||||
|
if (galleryArticleCount !== meals.length) {
|
||||||
|
throw new Error(
|
||||||
|
`Generated index.html is out of sync: expected ${meals.length} gallery entries, found ${galleryArticleCount}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rankingCardCount !== meals.length) {
|
||||||
|
throw new Error(
|
||||||
|
`Generated rankings.html is out of sync: expected ${meals.length} ranking cards, found ${rankingCardCount}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rankingsHtml.includes('src="assets/js/rankings.js"')) {
|
||||||
|
throw new Error("Generated rankings.html is missing the interactive rankings script");
|
||||||
|
}
|
||||||
|
|
||||||
|
const seedData = parseRankingsSeedData(rankingsHtml);
|
||||||
|
|
||||||
|
if (!Array.isArray(seedData.meals) || seedData.meals.length !== meals.length) {
|
||||||
|
throw new Error("Generated rankings.html has stale embedded meal seed data");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!seedData.elo ||
|
||||||
|
!Array.isArray(seedData.elo.entries) ||
|
||||||
|
seedData.elo.entries.length !== eloData.entries.length
|
||||||
|
) {
|
||||||
|
throw new Error("Generated rankings.html has stale embedded Elo seed data");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const meals = loadMeals();
|
||||||
|
const eloData = loadEloData();
|
||||||
|
const expectedIds = new Set(meals.map((meal) => meal.id));
|
||||||
|
const alignment = getEloAlignmentReport(meals, eloData);
|
||||||
|
|
||||||
|
validateMealAssets(meals);
|
||||||
|
|
||||||
|
if (alignment.missingEntryIds.length > 0 || alignment.unexpectedEntryIds.length > 0) {
|
||||||
|
const messages = [];
|
||||||
|
|
||||||
|
if (alignment.missingEntryIds.length > 0) {
|
||||||
|
messages.push(
|
||||||
|
`Missing Elo entries for meal ids: ${alignment.missingEntryIds.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alignment.unexpectedEntryIds.length > 0) {
|
||||||
|
messages.push(
|
||||||
|
`Unexpected Elo entries with no meal: ${alignment.unexpectedEntryIds.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(messages.join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const unexpectedFulls = getUnexpectedIds(fullsDir, expectedIds);
|
||||||
|
|
||||||
|
if (unexpectedFulls.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Unexpected full-size image files with no meal entry: ${unexpectedFulls.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unexpectedThumbs = getUnexpectedIds(thumbsDir, expectedIds);
|
||||||
|
|
||||||
|
if (unexpectedThumbs.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Unexpected thumbnail files with no meal entry: ${unexpectedThumbs.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
validateGeneratedPages(meals, eloData);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Validation passed: ${meals.length} meals, ${eloData.entries.length} Elo entries, generated pages and image assets are in sync.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
try {
|
||||||
|
main();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,6 +99,20 @@ function syncEloWithMeals(meals) {
|
|||||||
return syncedData;
|
return syncedData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getEloAlignmentReport(meals, eloData) {
|
||||||
|
const mealIds = new Set(meals.map((meal) => meal.id));
|
||||||
|
const eloIds = new Set(eloData.entries.map((entry) => entry.id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
missingEntryIds: meals
|
||||||
|
.map((meal) => meal.id)
|
||||||
|
.filter((mealId) => !eloIds.has(mealId)),
|
||||||
|
unexpectedEntryIds: eloData.entries
|
||||||
|
.map((entry) => entry.id)
|
||||||
|
.filter((entryId) => !mealIds.has(entryId)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function compareRankedMeals(left, right) {
|
function compareRankedMeals(left, right) {
|
||||||
if (right.rating !== left.rating) {
|
if (right.rating !== left.rating) {
|
||||||
return right.rating - left.rating;
|
return right.rating - left.rating;
|
||||||
@@ -132,6 +146,7 @@ function getRankedMeals(meals, eloData) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
eloPath,
|
eloPath,
|
||||||
|
getEloAlignmentReport,
|
||||||
getRankedMeals,
|
getRankedMeals,
|
||||||
loadEloData,
|
loadEloData,
|
||||||
saveEloData,
|
saveEloData,
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ const path = require("path");
|
|||||||
|
|
||||||
const repoRoot = path.resolve(__dirname, "..", "..");
|
const repoRoot = path.resolve(__dirname, "..", "..");
|
||||||
const mealsPath = path.join(repoRoot, "data", "meals.json");
|
const mealsPath = path.join(repoRoot, "data", "meals.json");
|
||||||
|
const fullsDir = path.join(repoRoot, "images", "fulls");
|
||||||
|
const thumbsDir = path.join(repoRoot, "images", "thumbs");
|
||||||
|
|
||||||
|
function isNonEmptyString(value) {
|
||||||
|
return typeof value === "string" && value.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
function validateThumbnail(meal, index) {
|
function validateThumbnail(meal, index) {
|
||||||
if (meal.thumbnail === undefined) {
|
if (meal.thumbnail === undefined) {
|
||||||
@@ -55,7 +61,7 @@ function validateMeals(meals) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const field of ["id", "title", "description"]) {
|
for (const field of ["id", "title", "description"]) {
|
||||||
if (typeof meal[field] !== "string" || meal[field].length === 0) {
|
if (!isNonEmptyString(meal[field])) {
|
||||||
throw new Error(`Meal ${index} is missing required string field "${field}"`);
|
throw new Error(`Meal ${index} is missing required string field "${field}"`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,8 +70,8 @@ function validateMeals(meals) {
|
|||||||
throw new Error(`Meal ${index} has a non-numeric id "${meal.id}"`);
|
throw new Error(`Meal ${index} has a non-numeric id "${meal.id}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (meal.position !== undefined && typeof meal.position !== "string") {
|
if (meal.position !== undefined && !isNonEmptyString(meal.position)) {
|
||||||
throw new Error(`Meal ${index} has a non-string "position" value`);
|
throw new Error(`Meal ${index} has an invalid "position" value`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ids.has(meal.id)) {
|
if (ids.has(meal.id)) {
|
||||||
@@ -91,6 +97,49 @@ function saveMeals(meals) {
|
|||||||
fs.writeFileSync(mealsPath, `${JSON.stringify(meals, null, 2)}\n`);
|
fs.writeFileSync(mealsPath, `${JSON.stringify(meals, null, 2)}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getMealImagePaths(mealOrId) {
|
||||||
|
const mealId =
|
||||||
|
typeof mealOrId === "string" ? mealOrId : mealOrId && typeof mealOrId.id === "string" ? mealOrId.id : null;
|
||||||
|
|
||||||
|
if (!mealId) {
|
||||||
|
throw new Error("Expected a meal object or meal id string");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fullPath: path.join(fullsDir, `${mealId}.jpg`),
|
||||||
|
thumbPath: path.join(thumbsDir, `${mealId}.jpg`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateMealAssets(meals, options = {}) {
|
||||||
|
const settings = {
|
||||||
|
requireFull: true,
|
||||||
|
requireThumb: true,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
const missingAssets = [];
|
||||||
|
|
||||||
|
for (const meal of meals) {
|
||||||
|
const { fullPath, thumbPath } = getMealImagePaths(meal);
|
||||||
|
|
||||||
|
if (settings.requireFull && !fs.existsSync(fullPath)) {
|
||||||
|
missingAssets.push(
|
||||||
|
`Meal ${meal.id} is missing full-size image: ${path.relative(repoRoot, fullPath)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.requireThumb && !fs.existsSync(thumbPath)) {
|
||||||
|
missingAssets.push(
|
||||||
|
`Meal ${meal.id} is missing thumbnail image: ${path.relative(repoRoot, thumbPath)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingAssets.length > 0) {
|
||||||
|
throw new Error(`Missing image assets:\n${missingAssets.join("\n")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getNextMealId(meals) {
|
function getNextMealId(meals) {
|
||||||
if (meals.length === 0) {
|
if (meals.length === 0) {
|
||||||
return "01";
|
return "01";
|
||||||
@@ -108,9 +157,13 @@ function getNextMealId(meals) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
fullsDir,
|
||||||
getNextMealId,
|
getNextMealId,
|
||||||
|
getMealImagePaths,
|
||||||
loadMeals,
|
loadMeals,
|
||||||
mealsPath,
|
mealsPath,
|
||||||
repoRoot,
|
repoRoot,
|
||||||
saveMeals,
|
saveMeals,
|
||||||
|
thumbsDir,
|
||||||
|
validateMealAssets,
|
||||||
};
|
};
|
||||||
|
|||||||
131
scripts/serve.js
Normal file
131
scripts/serve.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const http = require("http");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const { repoRoot } = require("./lib/meals");
|
||||||
|
|
||||||
|
const DEFAULT_HOST = "127.0.0.1";
|
||||||
|
const DEFAULT_PORT = 4321;
|
||||||
|
const MIME_TYPES = {
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".js": "application/javascript; charset=utf-8",
|
||||||
|
".json": "application/json; charset=utf-8",
|
||||||
|
".map": "application/json; charset=utf-8",
|
||||||
|
".mp3": "audio/mpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".ttf": "font/ttf",
|
||||||
|
".txt": "text/plain; charset=utf-8",
|
||||||
|
".webmanifest": "application/manifest+json; charset=utf-8",
|
||||||
|
".woff": "font/woff",
|
||||||
|
".woff2": "font/woff2",
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const options = {
|
||||||
|
host: process.env.HOST || DEFAULT_HOST,
|
||||||
|
port: Number.parseInt(process.env.PORT || String(DEFAULT_PORT), 10),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const arg = argv[index];
|
||||||
|
const value = argv[index + 1];
|
||||||
|
|
||||||
|
if (arg === "--host" && value) {
|
||||||
|
options.host = value;
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === "--port" && value) {
|
||||||
|
options.port = Number.parseInt(value, 10);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(options.port) || options.port <= 0 || options.port > 65535) {
|
||||||
|
throw new Error("Port must be an integer between 1 and 65535");
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContentType(filePath) {
|
||||||
|
return MIME_TYPES[path.extname(filePath).toLowerCase()] || "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRequestPath(requestUrl) {
|
||||||
|
const url = new URL(requestUrl, "http://localhost");
|
||||||
|
const pathname = decodeURIComponent(url.pathname);
|
||||||
|
const requestedPath = pathname === "/" ? "/index.html" : pathname;
|
||||||
|
const absolutePath = path.resolve(repoRoot, `.${requestedPath}`);
|
||||||
|
const withinRepo =
|
||||||
|
absolutePath === repoRoot || absolutePath.startsWith(`${repoRoot}${path.sep}`);
|
||||||
|
|
||||||
|
if (!withinRepo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(absolutePath) && fs.statSync(absolutePath).isDirectory()) {
|
||||||
|
return path.join(absolutePath, "index.html");
|
||||||
|
}
|
||||||
|
|
||||||
|
return absolutePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendFile(response, filePath) {
|
||||||
|
const stream = fs.createReadStream(filePath);
|
||||||
|
|
||||||
|
response.writeHead(200, {
|
||||||
|
"Content-Type": getContentType(filePath),
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.pipe(response);
|
||||||
|
stream.on("error", () => {
|
||||||
|
response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
||||||
|
response.end("Failed to read file");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createServer() {
|
||||||
|
return http.createServer((request, response) => {
|
||||||
|
const filePath = resolveRequestPath(request.url || "/");
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
response.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
|
||||||
|
response.end("Forbidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
||||||
|
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||||
|
response.end("Not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendFile(response, filePath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const options = parseArgs(process.argv.slice(2));
|
||||||
|
const server = createServer();
|
||||||
|
|
||||||
|
server.listen(options.port, options.host, () => {
|
||||||
|
console.log(`Serving ${repoRoot} at http://${options.host}:${options.port}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
try {
|
||||||
|
main();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user