chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
#!/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();
|
||||
}
|
||||
Reference in New Issue
Block a user