@bscotch/yy
Version:
Stringify, parse, read, and write GameMaker yy and yyp files.
252 lines • 9.01 kB
JavaScript
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