362 lines
11 KiB
JavaScript
362 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
copyFile,
|
|
mkdir,
|
|
readFile,
|
|
readdir,
|
|
rm,
|
|
rmdir,
|
|
stat,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
const TOP_LEVEL_KEYS = [
|
|
"id",
|
|
"name",
|
|
"description",
|
|
"thumbnail",
|
|
"merged_components",
|
|
"layouts",
|
|
"fonts",
|
|
];
|
|
|
|
const REPLACEABLE_IMAGE = "/static/images/replaceable_template_image.png";
|
|
const ICON_PLACEHOLDER = "/static/icons/placeholder.svg";
|
|
|
|
function usage() {
|
|
return `Usage: node scripts/convert-template.mjs <input.json> [options]
|
|
|
|
Options:
|
|
--output <path> Output JSON path (default: overwrite the input)
|
|
--app-data <dir> App-data root (default: APP_DATA_DIRECTORY or ./app_data)
|
|
--help Show this help
|
|
`;
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
let input;
|
|
let output;
|
|
let appData;
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const argument = argv[index];
|
|
if (argument === "--help" || argument === "-h") return { help: true };
|
|
if (argument === "--output" || argument === "-o") {
|
|
output = argv[++index];
|
|
if (!output) throw new Error(`${argument} requires a path`);
|
|
continue;
|
|
}
|
|
if (argument === "--app-data") {
|
|
appData = argv[++index];
|
|
if (!appData) throw new Error(`${argument} requires a directory`);
|
|
continue;
|
|
}
|
|
if (argument.startsWith("-")) throw new Error(`Unknown option: ${argument}`);
|
|
if (input) throw new Error("Only one input JSON file may be provided");
|
|
input = argument;
|
|
}
|
|
|
|
if (!input) throw new Error("An input JSON file is required");
|
|
return { input, output, appData, help: false };
|
|
}
|
|
|
|
function unwrapArray(value, wrapperKey, fieldName) {
|
|
const unwrapped = Array.isArray(value) ? value : value?.[wrapperKey];
|
|
if (!Array.isArray(unwrapped)) {
|
|
throw new Error(
|
|
`Top-level ${fieldName} must be an array or an object containing an array at .${wrapperKey}`,
|
|
);
|
|
}
|
|
return unwrapped;
|
|
}
|
|
|
|
function firstNonEmptyString(...values) {
|
|
return values.find((value) => typeof value === "string" && value.trim())?.trim();
|
|
}
|
|
|
|
function buildTargetShape(raw, outputPath) {
|
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
throw new Error("The input template must be a JSON object");
|
|
}
|
|
|
|
const id = firstNonEmptyString(raw.id, path.basename(path.dirname(outputPath)));
|
|
const name = firstNonEmptyString(raw.name, id);
|
|
const previewImages = Array.isArray(raw.assets?.slide_image_urls)
|
|
? raw.assets.slide_image_urls
|
|
: [];
|
|
const thumbnailSource = firstNonEmptyString(raw.thumbnail, ...previewImages) ?? "";
|
|
const fonts =
|
|
raw.assets?.fonts && typeof raw.assets.fonts === "object" && !Array.isArray(raw.assets.fonts)
|
|
? raw.assets.fonts
|
|
: raw.fonts && typeof raw.fonts === "object" && !Array.isArray(raw.fonts)
|
|
? raw.fonts
|
|
: {};
|
|
|
|
return {
|
|
template: {
|
|
id,
|
|
name,
|
|
description: typeof raw.description === "string" ? raw.description : "",
|
|
thumbnail: thumbnailSource,
|
|
merged_components:
|
|
raw.merged_components == null
|
|
? []
|
|
: unwrapArray(raw.merged_components, "components", "merged_components"),
|
|
layouts: unwrapArray(raw.layouts, "layouts", "layouts"),
|
|
fonts,
|
|
},
|
|
thumbnailSource,
|
|
};
|
|
}
|
|
|
|
function replaceEditableImages(value) {
|
|
if (Array.isArray(value)) {
|
|
return value.map(replaceEditableImages);
|
|
}
|
|
if (!value || typeof value !== "object") return value;
|
|
|
|
const converted = Object.fromEntries(
|
|
Object.entries(value).map(([key, child]) => [key, replaceEditableImages(child)]),
|
|
);
|
|
if (value.type !== "image") return converted;
|
|
if (value.is_icon === true) {
|
|
return { ...converted, data: ICON_PLACEHOLDER };
|
|
}
|
|
if (value.decorative !== true) {
|
|
return { ...converted, data: REPLACEABLE_IMAGE };
|
|
}
|
|
return converted;
|
|
}
|
|
|
|
function collectStrings(value, result = new Set()) {
|
|
if (typeof value === "string") {
|
|
result.add(value);
|
|
} else if (Array.isArray(value)) {
|
|
for (const child of value) collectStrings(child, result);
|
|
} else if (value && typeof value === "object") {
|
|
for (const child of Object.values(value)) collectStrings(child, result);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function appDataRelativePath(value) {
|
|
let pathname = value;
|
|
try {
|
|
pathname = decodeURIComponent(new URL(value).pathname);
|
|
} catch {
|
|
// Plain filesystem and template-relative paths are handled below.
|
|
}
|
|
|
|
const normalized = pathname.replaceAll("\\", "/");
|
|
const marker = "/app_data/";
|
|
if (normalized.startsWith(marker)) return normalized.slice(marker.length);
|
|
if (normalized.startsWith("app_data/")) return normalized.slice("app_data/".length);
|
|
return null;
|
|
}
|
|
|
|
function assertInside(root, candidate, label) {
|
|
const relative = path.relative(root, candidate);
|
|
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
throw new Error(`${label} resolves outside ${root}`);
|
|
}
|
|
}
|
|
|
|
async function isFile(filePath) {
|
|
try {
|
|
return (await stat(filePath)).isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function contentHash(filePath) {
|
|
return createHash("sha256").update(await readFile(filePath)).digest("hex");
|
|
}
|
|
|
|
async function resolveAsset(value, context) {
|
|
const appDataRelative = appDataRelativePath(value);
|
|
if (appDataRelative != null) {
|
|
const source = path.resolve(context.appDataRoot, appDataRelative);
|
|
assertInside(context.appDataRoot, source, value);
|
|
return { source, preferredName: path.basename(source) };
|
|
}
|
|
|
|
if (value.startsWith("static/")) {
|
|
const relative = value.slice("static/".length);
|
|
const source = path.resolve(context.inputDirectory, "static", relative);
|
|
assertInside(path.join(context.inputDirectory, "static"), source, value);
|
|
return { source, preferredName: relative };
|
|
}
|
|
|
|
const absolute = path.resolve(value);
|
|
if (path.isAbsolute(value) && absolute.startsWith(`${context.appDataRoot}${path.sep}`)) {
|
|
return { source: absolute, preferredName: path.basename(absolute) };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function planAssets(template, thumbnailSource, context) {
|
|
const plans = new Map();
|
|
const plansBySource = new Map();
|
|
const claimedNames = new Map();
|
|
|
|
for (const value of collectStrings(template)) {
|
|
const asset = await resolveAsset(value, context);
|
|
if (!asset) continue;
|
|
if (!(await isFile(asset.source))) {
|
|
throw new Error(`Referenced asset does not exist: ${asset.source} (from ${value})`);
|
|
}
|
|
|
|
const sourceKey = path.resolve(asset.source);
|
|
const existing = plansBySource.get(sourceKey);
|
|
if (existing) {
|
|
plans.set(value, existing);
|
|
continue;
|
|
}
|
|
|
|
let targetName =
|
|
value === thumbnailSource
|
|
? `thumbnail${path.extname(asset.preferredName) || ".png"}`
|
|
: asset.preferredName;
|
|
targetName = targetName.replaceAll("\\", "/").replace(/^\/+/, "");
|
|
assertInside(
|
|
context.staticDirectory,
|
|
path.resolve(context.staticDirectory, targetName),
|
|
targetName,
|
|
);
|
|
|
|
const claimed = claimedNames.get(targetName);
|
|
if (claimed && claimed.sourceKey !== sourceKey) {
|
|
const extension = path.extname(targetName);
|
|
const stem = extension ? targetName.slice(0, -extension.length) : targetName;
|
|
targetName = `${stem}-${(await contentHash(asset.source)).slice(0, 12)}${extension}`;
|
|
}
|
|
|
|
const plan = {
|
|
source: asset.source,
|
|
sourceKey,
|
|
target: path.resolve(context.staticDirectory, targetName),
|
|
outputValue: `static/${targetName}`,
|
|
};
|
|
claimedNames.set(targetName, plan);
|
|
plansBySource.set(sourceKey, plan);
|
|
plans.set(value, plan);
|
|
}
|
|
|
|
return plans;
|
|
}
|
|
|
|
function rewriteStrings(value, plans) {
|
|
if (typeof value === "string") return plans.get(value)?.outputValue ?? value;
|
|
if (Array.isArray(value)) return value.map((child) => rewriteStrings(child, plans));
|
|
if (value && typeof value === "object") {
|
|
return Object.fromEntries(
|
|
Object.entries(value).map(([key, child]) => [key, rewriteStrings(child, plans)]),
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
async function cleanStaticDirectory(directory, retainedTargets) {
|
|
async function clean(currentDirectory) {
|
|
let entries;
|
|
try {
|
|
entries = await readdir(currentDirectory, { withFileTypes: true });
|
|
} catch (error) {
|
|
if (error.code === "ENOENT") return;
|
|
throw error;
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
const entryPath = path.join(currentDirectory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
await clean(entryPath);
|
|
try {
|
|
await rmdir(entryPath);
|
|
} catch (error) {
|
|
if (error.code !== "ENOTEMPTY" && error.code !== "EEXIST") throw error;
|
|
}
|
|
} else if (!retainedTargets.has(path.resolve(entryPath))) {
|
|
await rm(entryPath, { force: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
await clean(directory);
|
|
}
|
|
|
|
export async function convertTemplateData({ raw, input, output = input, appData } = {}) {
|
|
if (!input) throw new Error("input is required");
|
|
if (raw === undefined) throw new Error("raw template data is required");
|
|
|
|
const inputPath = path.resolve(input);
|
|
const outputPath = path.resolve(output);
|
|
const appDataRoot = path.resolve(
|
|
appData ?? process.env.APP_DATA_DIRECTORY ?? path.join(process.cwd(), "app_data"),
|
|
);
|
|
const inputDirectory = path.dirname(inputPath);
|
|
const outputDirectory = path.dirname(outputPath);
|
|
const staticDirectory = path.join(outputDirectory, "static");
|
|
const { template: targetShape, thumbnailSource } = buildTargetShape(raw, outputPath);
|
|
const template = replaceEditableImages(targetShape);
|
|
const plans = await planAssets(template, thumbnailSource, {
|
|
appDataRoot,
|
|
inputDirectory,
|
|
staticDirectory,
|
|
});
|
|
const converted = rewriteStrings(template, plans);
|
|
|
|
await mkdir(outputDirectory, { recursive: true });
|
|
for (const plan of new Set(plans.values())) {
|
|
await mkdir(path.dirname(plan.target), { recursive: true });
|
|
if (path.resolve(plan.source) !== path.resolve(plan.target)) {
|
|
await copyFile(plan.source, plan.target);
|
|
}
|
|
}
|
|
await cleanStaticDirectory(
|
|
staticDirectory,
|
|
new Set([...plans.values()].map((plan) => path.resolve(plan.target))),
|
|
);
|
|
await writeFile(outputPath, `${JSON.stringify(converted, null, 2)}\n`, "utf8");
|
|
|
|
return {
|
|
outputPath,
|
|
assetCount: new Set([...plans.values()].map((plan) => plan.target)).size,
|
|
topLevelKeys: TOP_LEVEL_KEYS,
|
|
};
|
|
}
|
|
|
|
export async function convertTemplate({ input, output = input, appData } = {}) {
|
|
if (!input) throw new Error("input is required");
|
|
|
|
const inputPath = path.resolve(input);
|
|
const raw = JSON.parse(await readFile(inputPath, "utf8"));
|
|
return convertTemplateData({ raw, input: inputPath, output, appData });
|
|
}
|
|
|
|
async function main() {
|
|
try {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
if (options.help) {
|
|
process.stdout.write(usage());
|
|
return;
|
|
}
|
|
const result = await convertTemplate(options);
|
|
process.stdout.write(
|
|
`Converted ${result.outputPath}\nPackaged ${result.assetCount} referenced asset(s).\n`,
|
|
);
|
|
} catch (error) {
|
|
process.stderr.write(`Template conversion failed: ${error.message}\n\n${usage()}`);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
|
|
await main();
|
|
}
|