185 lines
4.7 KiB
JavaScript
185 lines
4.7 KiB
JavaScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
function printUsage() {
|
|
console.log(
|
|
"Usage: pnpm new-content <target> [--title <title>] [--date <date>] [--author <author>] [--force]",
|
|
);
|
|
console.log("Examples:");
|
|
console.log(" pnpm new-content events/new-event");
|
|
console.log(
|
|
' pnpm new-content content/projects/new-tool.md --title "New Tool"',
|
|
);
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const args = argv.slice(2);
|
|
|
|
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
|
printUsage();
|
|
process.exit(0);
|
|
}
|
|
|
|
const options = {
|
|
target: null,
|
|
title: null,
|
|
date: null,
|
|
author: null,
|
|
force: false,
|
|
};
|
|
|
|
let i = 0;
|
|
while (i < args.length) {
|
|
const current = args[i];
|
|
|
|
if (!current.startsWith("--") && options.target === null) {
|
|
options.target = current;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (current === "--force") {
|
|
options.force = true;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
current === "--title" ||
|
|
current === "--date" ||
|
|
current === "--author"
|
|
) {
|
|
const value = args[i + 1];
|
|
if (!value || value.startsWith("--")) {
|
|
throw new Error(`Missing value for ${current}`);
|
|
}
|
|
|
|
if (current === "--title") options.title = value;
|
|
if (current === "--date") options.date = value;
|
|
if (current === "--author") options.author = value;
|
|
i += 2;
|
|
continue;
|
|
}
|
|
|
|
throw new Error(`Unknown argument: ${current}`);
|
|
}
|
|
|
|
if (!options.target) {
|
|
throw new Error("Missing target path");
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
function toLocalIsoWithTimezone(date = new Date()) {
|
|
const pad = (n) => String(n).padStart(2, "0");
|
|
|
|
const year = date.getFullYear();
|
|
const month = pad(date.getMonth() + 1);
|
|
const day = pad(date.getDate());
|
|
const hours = pad(date.getHours());
|
|
const minutes = pad(date.getMinutes());
|
|
const seconds = pad(date.getSeconds());
|
|
|
|
const tzMinutes = -date.getTimezoneOffset();
|
|
const sign = tzMinutes >= 0 ? "+" : "-";
|
|
const tzAbs = Math.abs(tzMinutes);
|
|
const tzHours = pad(Math.floor(tzAbs / 60));
|
|
const tzMins = pad(tzAbs % 60);
|
|
|
|
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}${sign}${tzHours}:${tzMins}`;
|
|
}
|
|
|
|
function slugToTitle(filePath) {
|
|
const slug = path.basename(filePath, ".md");
|
|
return slug
|
|
.replace(/[-_]+/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim()
|
|
.replace(/\b\w/g, (ch) => ch.toUpperCase());
|
|
}
|
|
|
|
function escapeYamlString(value) {
|
|
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
}
|
|
|
|
function resolveTargetPath(targetArg, contentRoot) {
|
|
let normalized = targetArg.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
|
|
if (normalized.startsWith("content/")) {
|
|
normalized = normalized.slice("content/".length);
|
|
}
|
|
|
|
if (!normalized || normalized.endsWith("/")) {
|
|
throw new Error(
|
|
"Target must be a markdown file path, for example: events/my-event.md",
|
|
);
|
|
}
|
|
|
|
if (!normalized.toLowerCase().endsWith(".md")) {
|
|
normalized = `${normalized}.md`;
|
|
}
|
|
|
|
const absolute = path.resolve(contentRoot, normalized);
|
|
const contentPrefix = contentRoot.endsWith(path.sep)
|
|
? contentRoot
|
|
: `${contentRoot}${path.sep}`;
|
|
|
|
if (absolute !== contentRoot && !absolute.startsWith(contentPrefix)) {
|
|
throw new Error("Target path must stay inside content/");
|
|
}
|
|
|
|
return absolute;
|
|
}
|
|
|
|
async function main() {
|
|
const { target, title, date, author, force } = parseArgs(process.argv);
|
|
|
|
const repoRoot = process.cwd();
|
|
const contentRoot = path.resolve(repoRoot, "content");
|
|
const archetypePath = path.resolve(repoRoot, "archetypes", "default.md");
|
|
const targetPath = resolveTargetPath(target, contentRoot);
|
|
|
|
const resolvedTitle = title ?? slugToTitle(targetPath);
|
|
const resolvedDate = date ?? toLocalIsoWithTimezone(new Date());
|
|
const resolvedAuthor = author ?? "ADMStaff";
|
|
|
|
const [template, existing] = await Promise.all([
|
|
fs.readFile(archetypePath, "utf8"),
|
|
fs
|
|
.access(targetPath)
|
|
.then(() => true)
|
|
.catch(() => false),
|
|
]);
|
|
|
|
if (existing && !force) {
|
|
throw new Error(
|
|
`File already exists: ${path.relative(repoRoot, targetPath)} (use --force to overwrite)`,
|
|
);
|
|
}
|
|
|
|
let output = template;
|
|
output = output.replace(
|
|
/^title:\s*.*$/m,
|
|
`title: "${escapeYamlString(resolvedTitle)}"`,
|
|
);
|
|
output = output.replace(/^date:\s*.*$/m, `date: ${resolvedDate}`);
|
|
output = output.replace(
|
|
/^author:\s*.*$/m,
|
|
`author: "${escapeYamlString(resolvedAuthor)}"`,
|
|
);
|
|
|
|
if (!output.endsWith("\n")) {
|
|
output += "\n";
|
|
}
|
|
|
|
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
await fs.writeFile(targetPath, output, "utf8");
|
|
|
|
console.log(`Created: ${path.relative(repoRoot, targetPath)}`);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(`Error: ${error.message}`);
|
|
process.exit(1);
|
|
});
|