UNPKG

fomod

Version:

A library for creating, parsing, editing, and validating XML-based Fomod installers, widely popularized in the Bethesda modding scene

527 lines (407 loc) 24.2 kB
import { ensureXmlDoctype, getOrCreateElementByTagNameSafe } from "../../DomUtils"; import { FlagDependency } from './dependencies/FlagDependency'; import { DependenciesGroup, } from './dependencies/DependenciesGroup'; import { FlagInstance, FlagInstances, FlagInstanceStoresByDocument } from "../lib/FlagInstance"; import { Install, InstallPattern } from "./Install"; import { InvalidityReason, InvalidityReport } from "../lib/InvalidityReporting"; import { ElementObjectMap, Verifiable, XmlRepresentation } from "../lib/XmlRepresentation"; import { AttributeName, OptionType, TagName } from "../Enums"; import { DefaultFomodDocumentConfig, FomodDocumentConfig } from "../lib/FomodDocumentConfig"; import type { MaybeStrictString } from "../../TypeUtils"; /*** * $$$$$$\ $$\ $$\ $$$$$$$\ $$\ * $$ __$$\ $$ | \__| $$ __$$\ $$ | * $$ / $$ | $$$$$$\ $$$$$$\ $$\ $$$$$$\ $$$$$$$\ $$ | $$ | $$$$$$\ $$$$$$$ |$$\ $$\ * $$ | $$ |$$ __$$\ \_$$ _| $$ |$$ __$$\ $$ __$$\ $$$$$$$\ |$$ __$$\ $$ __$$ |$$ | $$ | * $$ | $$ |$$ / $$ | $$ | $$ |$$ / $$ |$$ | $$ | $$ __$$\ $$ / $$ |$$ / $$ |$$ | $$ | * $$ | $$ |$$ | $$ | $$ |$$\ $$ |$$ | $$ |$$ | $$ | $$ | $$ |$$ | $$ |$$ | $$ |$$ | $$ | * $$$$$$ |$$$$$$$ | \$$$$ |$$ |\$$$$$$ |$$ | $$ | $$$$$$$ |\$$$$$$ |\$$$$$$$ |\$$$$$$$ | * \______/ $$ ____/ \____/ \__| \______/ \__| \__| \_______/ \______/ \_______| \____$$ | * $$ | $$\ $$ | * $$ | \$$$$$$ | * \__| \______/ */ /** A single option (or "plugin") for a Fomod. These are typically presented as checkboxes or radio buttons. */ export class Option<TStrict extends boolean> extends XmlRepresentation<TStrict> { static override readonly tagName = TagName.Plugin; readonly tagName = TagName.Plugin; constructor( public name: string = '', public description: string = '', public image: string|null = null, public typeDescriptor: TypeDescriptor<TStrict> = new TypeDescriptor(), public flagsToSet: Set<FlagSetter> = new Set(), public installsToSet: InstallPattern<TStrict> = new InstallPattern(), ) { super(); } asElement(document: Document, config: FomodDocumentConfig = {}, knownOptions: Option<boolean>[] = []): Element { const element = this.getElementForDocument(document); this.associateWithDocument(document); if (config.generateNewOptionFlagNames ?? DefaultFomodDocumentConfig.generateNewOptionFlagNames) { for (const option of knownOptions) { option.existingOptionFlagSetterByDocument.get(document)?.decommission(); option.existingOptionFlagSetterByDocument.delete(document); } config = Object.assign({}, config, {generateNewOptionFlagNames: false}); } element.setAttribute('name', this.name); const description = getOrCreateElementByTagNameSafe(element, TagName.Description); description.textContent = this.description; element.appendChild(description); if (this.image === null) element.getElementsByTagName(TagName.Image)[0]?.remove(); else { const image = getOrCreateElementByTagNameSafe(element, TagName.Image); image.setAttribute(AttributeName.Path, this.image); element.appendChild(image); } const selfFlag = this.getOptionFlagSetter(document, config, knownOptions); this.flagsToSet.add(selfFlag); if (this.flagsToSet.size > 0) { const flagsElement = getOrCreateElementByTagNameSafe(element, TagName.ConditionFlags); for (const flag of this.flagsToSet) flagsElement.appendChild(flag.asElement(document, config)); element.appendChild(flagsElement); } else { element.getElementsByTagName(TagName.ConditionFlags)[0]?.remove(); } this.flagsToSet.delete(selfFlag); if (this.installsToSet.filesWrapper.installs.size > 0) { const filesElement = getOrCreateElementByTagNameSafe(element, TagName.Files); for (const install of this.installsToSet.filesWrapper.installs) filesElement.appendChild(install.asElement(document, config)); element.appendChild(filesElement); }else if (this.flagsToSet.size === 0) { // Create an empty `files` element const filesElement = getOrCreateElementByTagNameSafe(element, TagName.Files); filesElement.replaceChildren(); element.appendChild(filesElement); } else { element.getElementsByTagName(TagName.Files)[0]?.remove(); } element.appendChild(this.typeDescriptor.asElement(document, config)); return element; } isValid(): this is Option<true> { return this.typeDescriptor.isValid(); } reasonForInvalidity(...tree: Omit<Verifiable<false>, "isValid" | "reasonForInvalidity">[]): InvalidityReport | null { tree.push(this); return this.typeDescriptor.reasonForInvalidity(tree, this); } existingOptionFlagSetterByDocument = new WeakMap<Document, FlagSetter>(); getOptionFlagSetter(this: Option<boolean>, document: Document, config: FomodDocumentConfig = {}, knownOptions: Option<boolean>[] = []): FlagSetter { const existing = this.existingOptionFlagSetterByDocument.get(document); if (existing) return existing; const flagInstances = FlagInstanceStoresByDocument.get(document) ?? {all: new Set(), byName: new Map()} as FlagInstances; const baseName = 'OPTION_' + this.name.replace(/[^a-zA-Z0-9]/g, '_'); let thisIndex: number | undefined = undefined; const existingFlagNames = new Set(Array.from(flagInstances.byName.keys()).map(nameOrOption => { if (typeof nameOrOption === 'string') return nameOrOption; // We only care about the flag of the option if it appears in the installer before we do (saves performance and prevents infinite recursion) thisIndex ??= knownOptions.indexOf(this); if (thisIndex === -1 || knownOptions.indexOf(nameOrOption) >= thisIndex) return undefined; nameOrOption.getOptionFlagSetter(document, config, knownOptions).name; })); const option = this; // Declared as const largely so TypeScript accepts the narrowed types // Yes, I understand that, because functions are hoisted, we couldn't ensure the narrowed state (i.e. TS's behavior is correct), but I don't write code like that. // I also can't be bothered to explain that to TypeScript so here we are. Const declaration it is. const tryNextName = function tryNextName(suffix: bigint): FlagSetter { const name = baseName + `--${suffix}`; if (!existingFlagNames.has(name)) { const flagInstance = new FlagInstance(name, config.optionSelectedValue ?? DefaultFomodDocumentConfig.optionSelectedValue, true, document); flagInstance.optionFlagOptionByDocument.set(document, option); return new FlagSetter(flagInstance); } return tryNextName(suffix + 1n); }; const setter = tryNextName(1n); this.existingOptionFlagSetterByDocument.set(document, setter); return setter; } static override parse(element: Element, config: FomodDocumentConfig = {}): Option<boolean> | null { const existing = ElementObjectMap.get(element); if (existing && existing instanceof this) return existing; const name = element.getAttribute(AttributeName.Name); if (name === null) return null; const description = element.getElementsByTagName(TagName.Description)[0]?.textContent ?? ''; const image = element.getElementsByTagName(TagName.Image)[0]?.getAttribute(AttributeName.Path) ?? null; const typeDescriptorElement = element.getElementsByTagName(TagName.TypeDescriptor)[0]; const typeDescriptor = typeDescriptorElement ? TypeDescriptor.parse(typeDescriptorElement, config) : undefined; const option = new Option<false>(name, description, image, typeDescriptor); option.assignElement(element); const dependencyOnThis = new FlagDependency(option, true); const flagsToSet = new Set<FlagSetter>(); const conditionFlagElements = element.getElementsByTagName(TagName.ConditionFlags)[0]; if (conditionFlagElements) { for (const flagElement of conditionFlagElements.getElementsByTagName(TagName.Flag)) { const flag = FlagSetter.parse(flagElement, config); if (flag) flagsToSet.add(flag); } } option.flagsToSet = flagsToSet; const installsToSet = new InstallPattern(); const filesElement = element.getElementsByTagName(TagName.Files)[0]; if (filesElement) { for (const flagElement of filesElement.getElementsByTagName(TagName.File)) { const install = Install.parse(flagElement, config); if (install) installsToSet.filesWrapper.installs.add(install); } for (const flagElement of filesElement.getElementsByTagName(TagName.Folder)) { const install = Install.parse(flagElement, config); if (install) installsToSet.filesWrapper.installs.add(install); } } installsToSet.dependencies?.dependencies.add(dependencyOnThis); option.installsToSet = installsToSet; return option; } override decommission(currentDocument?: Document) { this.flagsToSet.forEach(flag => flag.decommission?.(currentDocument)); this.installsToSet.decommission?.(currentDocument); this.typeDescriptor.decommission?.(currentDocument); } override associateWithDocument(document: Document) { this.flagsToSet.forEach(flag => flag.associateWithDocument(document)); this.installsToSet.associateWithDocument(document); this.typeDescriptor.associateWithDocument(document); } } export class FlagSetter extends XmlRepresentation<true> { static override readonly tagName = TagName.Flag; readonly tagName = TagName.Flag; get name() { return this.flagInstance.name; } set name(name: string) { this.flagInstance.name = name; } get value() { return this.flagInstance.usedValue; } set value(value: string) { this.flagInstance.usedValue = value; } constructor(public readonly flagInstance: FlagInstance<false, true>) { super(); } asElement(document: Document, config: FomodDocumentConfig = {}): Element { const element = this.getElementForDocument(document); this.associateWithDocument(document); element.setAttribute(AttributeName.Name, this.flagInstance.name); element.textContent = this.flagInstance.usedValue; return element; } isValid(): this is FlagSetter { return true; } reasonForInvalidity(): null { return null; } static override parse(element: Element, config: FomodDocumentConfig = {}): FlagSetter | null { const existing = ElementObjectMap.get(element); if (existing && existing instanceof this) return existing; const flagName = element.getAttribute(AttributeName.Name); if (flagName === null) return null; const obj = new FlagSetter(new FlagInstance(flagName, element.textContent ?? '', true, element.ownerDocument)); obj.assignElement(element); return obj; } override decommission(currentDocument?: Document) { this.flagInstance.decommission(currentDocument); } override associateWithDocument(document: Document) { this.flagInstance.associateWithDocument(document); } } /*** * $$$$$$$$\ * \__$$ __| * $$ | $$\ $$\ $$$$$$\ $$$$$$\ * $$ | $$ | $$ |$$ __$$\ $$ __$$\ * $$ | $$ | $$ |$$ / $$ |$$$$$$$$ | * $$ | $$ | $$ |$$ | $$ |$$ ____| * $$ | \$$$$$$$ |$$$$$$$ |\$$$$$$$\ * \__| \____$$ |$$ ____/ \_______| * $$\ $$ |$$ | * \$$$$$$ |$$ | * \______/ \__| * $$$$$$$\ $$\ $$\ * $$ __$$\ \__| $$ | * $$ | $$ | $$$$$$\ $$$$$$$\ $$$$$$$\ $$$$$$\ $$\ $$$$$$\ $$$$$$\ $$$$$$\ $$$$$$\ * $$ | $$ |$$ __$$\ $$ _____|$$ _____|$$ __$$\ $$ |$$ __$$\ \_$$ _| $$ __$$\ $$ __$$\ * $$ | $$ |$$$$$$$$ |\$$$$$$\ $$ / $$ | \__|$$ |$$ / $$ | $$ | $$ / $$ |$$ | \__| * $$ | $$ |$$ ____| \____$$\ $$ | $$ | $$ |$$ | $$ | $$ |$$\ $$ | $$ |$$ | * $$$$$$$ |\$$$$$$$\ $$$$$$$ |\$$$$$$$\ $$ | $$ |$$$$$$$ | \$$$$ |\$$$$$$ |$$ | * \_______/ \_______|\_______/ \_______|\__| \__|$$ ____/ \____/ \______/ \__| * $$ | * $$ | * \__| */ /** Describes the desired `OptionType` for an option * * Supports setting a default type and an optional list of conditions ('patterns') that can change the type. * * @see `OptionType` */ export class TypeDescriptor<TStrict extends boolean> extends XmlRepresentation<TStrict> { static override readonly tagName = TagName.TypeDescriptor; readonly tagName = TagName.TypeDescriptor; constructor( /** The type name descriptor */ public defaultTypeNameDescriptor: TypeNameDescriptor<TypeDescriptorTagName, TStrict, false> = new TypeNameDescriptor(TagName.DefaultType, OptionType.Optional, false), public patterns: TypeDescriptorPattern<TStrict>[] = [], ) { super(); } asElement(document: Document, config: FomodDocumentConfig = {}): Element { const element = this.getElementForDocument(document); this.associateWithDocument(document); if (this.patterns.length === 0) { this.defaultTypeNameDescriptor.tagName = TagName.Type; element.appendChild(this.defaultTypeNameDescriptor.asElement(document, config)); return element; } this.defaultTypeNameDescriptor.tagName = TagName.DefaultType; const patternsContainer = getOrCreateElementByTagNameSafe(element, TagName.Patterns); for (const pattern of this.patterns) patternsContainer.appendChild(pattern.asElement(document, config)); return element; } static override parse(element: Element, config: FomodDocumentConfig = {}): TypeDescriptor<boolean> { const existing = ElementObjectMap.get(element); if (existing && existing instanceof this) return existing; const typeDescriptor = new TypeDescriptor(); typeDescriptor.assignElement(element); const dependencyTypeEl = element.querySelector(`:scope > ${TagName.DependencyType}`); if (!dependencyTypeEl) { const typeElement = element.querySelector(`:scope > ${TagName.Type}`); if (typeElement) typeDescriptor.defaultTypeNameDescriptor = TypeNameDescriptor.parse(typeElement, config); } else { const defaultTypeNameDescriptorElement = dependencyTypeEl.querySelector(`:scope > ${TagName.DefaultType}`); if (defaultTypeNameDescriptorElement) typeDescriptor.defaultTypeNameDescriptor = TypeNameDescriptor.parse(defaultTypeNameDescriptorElement, config); for (const patternElement of dependencyTypeEl.querySelectorAll(`:scope > ${TagName.Pattern}`)) typeDescriptor.patterns.push(TypeDescriptorPattern.parse(patternElement, config)); } return typeDescriptor; } isValid(): this is TypeDescriptor<true> { return this.defaultTypeNameDescriptor.isValid() && this.patterns.every(p => p.isValid()); } reasonForInvalidity(...tree: Omit<Verifiable<false>, "isValid" | "reasonForInvalidity">[]): InvalidityReport | null { tree.push(this); const defaultTypeNameDescriptorInvalidityReason = this.defaultTypeNameDescriptor.reasonForInvalidity(...tree); if (defaultTypeNameDescriptorInvalidityReason) return defaultTypeNameDescriptorInvalidityReason; const patternInvalidityReason = this.patterns.map(pattern => pattern.reasonForInvalidity(...tree)).find(reason => reason !== null); if (patternInvalidityReason) return patternInvalidityReason; return null; } override decommission(currentDocument?: Document) { this.defaultTypeNameDescriptor.decommission?.(currentDocument); this.patterns.forEach(pattern => pattern.decommission?.(currentDocument)); } override associateWithDocument(document: Document) { this.defaultTypeNameDescriptor.associateWithDocument(document); this.patterns.forEach(pattern => pattern.associateWithDocument(document)); } } export class TypeDescriptorPattern<TStrict extends boolean> extends XmlRepresentation<TStrict> { static override readonly tagName = TagName.Pattern; readonly tagName = TagName.Pattern; constructor( public typeNameDescriptor: TypeNameDescriptor<TagName.Type, TStrict, true> = new TypeNameDescriptor(TagName.Type, OptionType.Optional, true), public dependencies: DependenciesGroup<TagName.Dependencies, TStrict> = new DependenciesGroup(TagName.Dependencies), ) { super(); } asElement(document: Document, config: FomodDocumentConfig = {}): Element { const element = this.getElementForDocument(document); this.associateWithDocument(document); element.appendChild(this.typeNameDescriptor.asElement(document, config)); element.appendChild(this.dependencies.asElement(document, config)); return element; } static override parse(element: Element, config: FomodDocumentConfig = {}): TypeDescriptorPattern<boolean> { const existing = ElementObjectMap.get(element); if (existing && existing instanceof this) return existing; const typeDescriptorPattern = new TypeDescriptorPattern<false>(); typeDescriptorPattern.assignElement(element); const typeNameDescriptorElement = element.querySelector(`:scope > ${TagName.Type}`); if (typeNameDescriptorElement) typeDescriptorPattern.typeNameDescriptor = TypeNameDescriptor.parse(typeNameDescriptorElement, config, true); const dependenciesElement = element.querySelector(`:scope > ${TagName.Dependencies}`); if (dependenciesElement) typeDescriptorPattern.dependencies = DependenciesGroup.parse<TagName.Dependencies>(dependenciesElement); return typeDescriptorPattern; } isValid(): this is TypeDescriptorPattern<true> { return this.typeNameDescriptor.isValid() && this.dependencies.isValid(); } reasonForInvalidity(...tree: Omit<Verifiable<false>, "isValid" | "reasonForInvalidity">[]): InvalidityReport | null { tree.push(this); const typeNameDescriptorInvalidityReason = this.typeNameDescriptor.reasonForInvalidity(...tree); if (typeNameDescriptorInvalidityReason) return typeNameDescriptorInvalidityReason; const dependenciesInvalidityReason = this.dependencies.reasonForInvalidity(...tree); if (dependenciesInvalidityReason) return dependenciesInvalidityReason; return null; } override decommission(currentDocument?: Document) { this.typeNameDescriptor.decommission?.(currentDocument); this.dependencies.decommission?.(currentDocument); } override associateWithDocument(document: Document) { this.typeNameDescriptor.associateWithDocument(document); this.dependencies.associateWithDocument(document); } } type TypeDescriptorTagName = TagName.Type|TagName.DefaultType; export class TypeNameDescriptor<TTagName extends TypeDescriptorTagName, TStrict extends boolean, TTagNameIsReadOnly extends boolean = true> extends XmlRepresentation<TStrict> { static override readonly tagName = [TagName.Type, TagName.DefaultType] as [TagName.Type, TagName.DefaultType]; get tagName() { return this._tagName; } set tagName(tagName: TTagNameIsReadOnly extends true ? TTagName : TypeDescriptorTagName) { if (!this.tagNameIsReadonly) this._tagName = tagName; else throw new Error(`Attempted to set read-only property 'tagName' on a TypeNameDescriptor instance`); } constructor( private _tagName: TTagNameIsReadOnly extends true ? TTagName : TypeDescriptorTagName, public targetType: MaybeStrictString<OptionType, TStrict> = OptionType.Optional, public tagNameIsReadonly: TTagNameIsReadOnly ) { super(); } asElement(document: Document, config: FomodDocumentConfig = {}): Element { const element = this.getElementForDocument(document); this.associateWithDocument(document); element.setAttribute(AttributeName.Name, this.targetType); return element; } static override parse<TTagName extends TypeDescriptorTagName = TypeDescriptorTagName, TTagNameIsReadOnly extends boolean = false>(element: Element, config: FomodDocumentConfig = {}, tagNameIsReadonly?: TTagNameIsReadOnly): TypeNameDescriptor<TTagName, boolean, TTagNameIsReadOnly> { const existing = ElementObjectMap.get(element); if (existing && existing instanceof this) return existing; const typeDescriptorName = new TypeNameDescriptor<TTagName, boolean, TTagNameIsReadOnly>(element.tagName as TTagName, 'Optional', tagNameIsReadonly as TTagNameIsReadOnly); typeDescriptorName.assignElement(element); typeDescriptorName.targetType = element.getAttribute(AttributeName.Name) ?? OptionType.Optional; return typeDescriptorName; } isValid(): this is TypeNameDescriptor<TTagName, true, TTagNameIsReadOnly> { return Object.values(OptionType).includes(this.targetType as any); } reasonForInvalidity(...tree: Omit<Verifiable<false>, "isValid" | "reasonForInvalidity">[]): InvalidityReport | null { tree.push(this); if (!Object.values(OptionType).includes(this.targetType as any)) return {offendingValue: this.targetType, tree, reason: InvalidityReason.OptionTypeDescriptorUnknownOptionType}; return null; } // Overwrite to handle the interchangeable `file` and `folder` tags override getElementForDocument(document: Document): Element { ensureXmlDoctype(document); const existingElement = super.getElementForDocument(document); if (existingElement.tagName === this.tagName) return existingElement; this.documentMap.delete(document); const newElement = this.getElementForDocument(document); for (let i = 0; i < existingElement.attributes.length; i++) newElement.setAttributeNode(existingElement.attributes[i]!.cloneNode(true) as Attr); newElement.replaceChildren(...existingElement.children); existingElement.replaceWith(newElement); return newElement; } // Overwrite to handle the interchangeable `file` and `folder` tags override assignElement(element: Element) { ensureXmlDoctype(element.ownerDocument); // @ts-ignore: The logic is fine but TypeScript can't figure it out if (!this.tagNameIsReadonly && (element.tagName === TagName.Type || element.tagName === TagName.DefaultType)) this.tagName = element.tagName; super.assignElement(element); } override decommission(currentDocument?: Document) { // } override associateWithDocument(document: Document) { // } }