UNPKG

@bscotch/yy

Version:

Stringify, parse, read, and write GameMaker yy and yyp files.

252 lines 9.01 kB
import { ok } from 'assert'; import fs from 'fs'; import fsp from 'fs/promises'; import path from 'path'; import { z } from 'zod'; import { parseYy } from './Yy.parse.js'; import { stringifyYy } from './Yy.stringify.js'; import { yyExtensionSchema } from './types/YyExtension.js'; import { yyObjectSchema } from './types/YyObject.js'; import { yyRoomSchema } from './types/YyRoom.js'; import { yyRoomUISchema } from './types/YyRoomUI.js'; import { yyScriptSchema } from './types/YyScript.js'; import { yyShaderSchema } from './types/YyShader.js'; import { yySoundSchema } from './types/YySound.js'; import { yySpriteSchema } from './types/YySprite.js'; import { yypSchema } from './types/Yyp.js'; const anyObject = z.looseObject({ ['%Name']: z.string().optional(), }); export const yySchemas = { project: yypSchema, animcurves: anyObject, extensions: yyExtensionSchema, fonts: anyObject, notes: anyObject, objects: yyObjectSchema, particles: anyObject, paths: anyObject, rooms: yyRoomSchema, roomui: yyRoomUISchema, scripts: yyScriptSchema, sequences: anyObject, shaders: yyShaderSchema, sounds: yySoundSchema, sprites: yySpriteSchema, tilesets: anyObject, timelines: anyObject, }; Object.freeze(yySchemas); Object.seal(yySchemas); export class Yy { // Hide the constructor since it's not meant to be used. constructor() { } static schemas = yySchemas; static getSchema(ref) { const schema = typeof ref === 'string' ? Yy.schemas[ref] : ref; ok(schema, `No schema found for ${ref}`); return schema; } /** * Stringify an object into a Yy-formatted string, * including trailing commas. If a schema is provided, * it will be used to validate and populate defaults before * stringifying. */ static stringify(yyObject, schema, yyp) { if (typeof schema === 'string') { schema = Yy.schemas[schema]; } return stringifyYy(schema ? schema.parse(yyObject) : yyObject, yyp); } static parse(yyString, schema) { return parseYy(yyString, schema && Yy.getSchema(schema)); } static async read(filePath, schema) { try { return Yy.parse(await fsp.readFile(filePath, 'utf8'), schema); } catch (err) { console.log(err); const error = new Error(`Error reading file: ${filePath}\n${err && err instanceof Error && err.message}`); error.cause = err; throw error; } } static readSync(filePath, schema) { return Yy.parse(fs.readFileSync(filePath, 'utf8'), schema); } /** * If the file already exists * its contents will be read first and the * new content will only be written if it * is different. This is to reduce file-watcher * noise, since excess file-write events can * cause problems with GameMaker. * * If the file already exists, the new file will * have its keys sorted to match it (also to * reduce file-watcher and Git noise). * * Calls that result in a no-op because the existing * file matches return `false`, while calls that *do* * write to disk return `true`. * * @param yyp If provided, the yyp will be used to determine format information */ static async write(filePath, yyData, schema, yyp) { let populated = schema ? Yy.populate(yyData, schema) : yyData; const stringified = Yy.stringify(populated, schema, yyp); await fsp.mkdir(path.dirname(filePath), { recursive: true }); // Only clobber if the target is a file with different // contents. This is to prevent file-watcher triggers from // creating noise. if (await exists(filePath)) { const currentRawContent = await fsp.readFile(filePath, 'utf8'); if (currentRawContent === stringified) { return false; } } await fsp.writeFile(filePath, stringified); return true; } /** * Synchronous version of {@link Yy.write}. * * @param yyp If provided, the yyp will be used to determine format information */ static writeSync(filePath, yyData, schema, yyp) { let populated = schema ? Yy.populate(yyData, schema) : yyData; const stringified = Yy.stringify(populated, schema, yyp); fs.mkdirSync(path.dirname(filePath), { recursive: true }); if (existsSync(filePath)) { const currentRawContent = fs.readFileSync(filePath, 'utf8'); if (currentRawContent === stringified) { return false; } } fs.writeFileSync(filePath, stringified); return true; } static populate(yyData, schema) { const foundSchema = Yy.getSchema(schema); const populated = foundSchema.parse(yyData); return populated; } static diff(firstYy, secondYy) { const diff = {}; function normalize(value) { if (value !== null && typeof value === 'object' && Symbol.toPrimitive in value && typeof value[Symbol.toPrimitive] === 'function') { // @ts-expect-error value = value[Symbol.toPrimitive]('default'); } return value; } function recurse(left, right, path) { // Convert to primitives if necessary left = normalize(left); right = normalize(right); if (left === right) { return; } if (left === null || right === null) { diff[path] = { left, right }; return; } if (Array.isArray(left) && Array.isArray(right)) { for (let i = 0; i < Math.max(left.length, right.length); i++) { recurse(left[i], right[i], `${path}/${i}`); } return; } if (typeof left === 'object' && typeof right === 'object') { const keys = new Set([...Object.keys(left), ...Object.keys(right)]); for (const key of keys) { if (typeof key !== 'string') { continue; } // @ts-expect-error recurse(left[key], right[key], `${path}/${key}`); } return; } // If we get here, then the values are different. diff[path] = { left, right }; return; } recurse(firstYy, secondYy, ''); return diff; } /** * Check for functional equality between two Yy objects. */ static areEqual(firstYy, secondYy) { if (firstYy != secondYy) { if (typeof firstYy !== typeof secondYy) { return false; } // If they are objects/array, then we need to do a deep comparison. if (Array.isArray(firstYy) && Array.isArray(secondYy)) { if (firstYy.length !== secondYy.length) { return false; } for (let i = 0; i < firstYy.length; i++) { if (!Yy.areEqual(firstYy[i], secondYy[i])) { return false; } } return true; } if (firstYy && secondYy && typeof firstYy === 'object' && typeof secondYy === 'object') { // If both have primitive versions, compare those const asPrimitives = [firstYy, secondYy].map((obj) => obj[Symbol.toPrimitive]?.('default')); if (asPrimitives[0] !== undefined && asPrimitives[1] == asPrimitives[0]) { return true; } const firstKeys = Object.keys(firstYy); const secondKeys = Object.keys(secondYy); // There could be different number of keys despite functional equality, // if any keys have undefined values. for (const key of firstKeys) { if (!Yy.areEqual(firstYy[key], secondYy[key])) { return false; } } for (const key of secondKeys) { if (!Yy.areEqual(firstYy[key], secondYy[key])) { return false; } } return true; } return false; } return true; } } async function exists(filepath) { try { await fsp.stat(filepath); return true; } catch { return false; } } function existsSync(filepath) { try { fs.statSync(filepath); return true; } catch { return false; } } //# sourceMappingURL=Yy.js.map