#!/usr/bin/env node /** * Render a {{variable}} prompt template with values from a JSON file, and fail * loudly when a variable is missing instead of sending "{{name}}" to a model. * * Usage: * node scripts/render-prompt.mjs TEMPLATE [VARS.json] [--check] [--allow-unused] [--out FILE] * node scripts/render-prompt.mjs prompts/release-notes.md vars.json > rendered.md * echo '{"product_name":"Acme"}' | node scripts/render-prompt.mjs prompts/x.md - * * TEMPLATE Text file using {{name}} placeholders (letters, digits, _ and .). * Dotted names read nested values: {{customer.name}}. * VARS.json JSON object with the values. "-" reads it from stdin. * Omit it together with --check to just list the variables. * --check Do not print the rendered text; report missing and unused * variables only. Without VARS.json, lists the template's variables. * --allow-unused Do not fail when VARS.json has keys the template never uses. * --out FILE Write the rendered text to FILE instead of stdout. * * Rules: * - A literal "{{" can be written as "\{{" and is output as "{{". * - Strings are inserted as-is. Numbers and booleans are converted with String(). * Arrays of strings become a "- item" list, one item per line. * Other objects and arrays are inserted as pretty-printed JSON. * - A YAML-style front matter block (--- ... ---) at the top is removed from the output. * * Requirements: Node.js 18+, no packages, no environment variables. * * Exit codes: * 0 rendered (or check passed) * 1 missing variables, or unused variables without --allow-unused * 2 usage error (file not found, invalid JSON, bad arguments) */ import { readFileSync, writeFileSync } from "node:fs"; const PLACEHOLDER = /(\\?)\{\{\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\s*\}\}/g; function fail(message, code = 2) { process.stderr.write(`error: ${message}\n`); process.exit(code); } function parseArgs(argv) { const opts = { positional: [], check: false, allowUnused: false, out: null }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (arg === "--check") opts.check = true; else if (arg === "--allow-unused") opts.allowUnused = true; else if (arg === "--out") { opts.out = argv[i + 1]; i += 1; if (!opts.out) fail("--out needs a file path"); } else if (arg === "-h" || arg === "--help") { process.stdout.write("usage: render-prompt.mjs TEMPLATE [VARS.json|-] [--check] [--allow-unused] [--out FILE]\n"); process.exit(0); } else if (arg.startsWith("--")) fail(`unknown option ${arg}`); else opts.positional.push(arg); } if (opts.positional.length < 1 || opts.positional.length > 2) fail("expected TEMPLATE and optional VARS.json"); return opts; } function readText(path) { try { return path === "-" ? readFileSync(0, "utf8") : readFileSync(path, "utf8"); } catch (err) { return fail(`cannot read ${path}: ${err.code ?? err.message}`); } } function stripFrontMatter(text) { const match = text.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/); return match ? text.slice(match[0].length) : text; } function lookup(vars, dotted) { let value = vars; for (const part of dotted.split(".")) { if (value === null || typeof value !== "object" || !Object.hasOwn(value, part)) return undefined; value = value[part]; } return value; } function format(value) { if (typeof value === "string") return value; if (Array.isArray(value) && value.every((item) => typeof item === "string")) { return value.map((item) => `- ${item}`).join("\n"); } if (typeof value === "object" && value !== null) return JSON.stringify(value, null, 2); return String(value); } const opts = parseArgs(process.argv.slice(2)); const [templatePath, varsPath] = opts.positional; const template = stripFrontMatter(readText(templatePath)); const names = new Set(); for (const match of template.matchAll(PLACEHOLDER)) { if (!match[1]) names.add(match[2]); } if (!varsPath) { if (!opts.check) fail("VARS.json is required unless --check is given"); process.stdout.write([...names].sort().join("\n") + (names.size ? "\n" : "")); process.exit(0); } let vars; try { vars = JSON.parse(readText(varsPath)); } catch (err) { fail(`${varsPath} is not valid JSON: ${err.message}`); } if (vars === null || typeof vars !== "object" || Array.isArray(vars)) fail(`${varsPath} must contain a JSON object`); const missing = [...names].filter((name) => lookup(vars, name) === undefined).sort(); const usedRoots = new Set([...names].map((name) => name.split(".")[0])); const unused = Object.keys(vars).filter((key) => !usedRoots.has(key)).sort(); if (missing.length) process.stderr.write(`missing variables: ${missing.join(", ")}\n`); if (unused.length) process.stderr.write(`${opts.allowUnused ? "note" : "error"}: unused variables: ${unused.join(", ")}\n`); const ok = missing.length === 0 && (opts.allowUnused || unused.length === 0); if (!ok) process.exit(1); if (opts.check) { process.stderr.write(`ok: ${names.size} variable(s) resolved\n`); process.exit(0); } const rendered = template.replace(PLACEHOLDER, (whole, escaped, name) => (escaped ? whole.slice(1) : format(lookup(vars, name)))); if (opts.out) writeFileSync(opts.out, rendered); else process.stdout.write(rendered);