@fontoxml/fontoxml-development-tools
Version:
Development tools for Fonto.
554 lines (516 loc) • 15.4 kB
JavaScript
import semver from 'semver';
/**
* @typedef {'full' | 'build' | 'prerelease' | 'core' | 'patch' | 'minor' | 'major'} CompareScope
*/
const COMPARE_SCOPES = [
'full',
'build',
'prerelease',
'core',
'patch',
'minor',
'major',
];
export default class Version {
/**
* @template {boolean} IsUnparsedLiteral
* @template {IsUnparsedLiteral extends false ? false : boolean} IsUnversionedNightly
*
* @param {string|Version} version
* @param {boolean} [allowUnparsedLiteral=false]
*/
constructor(version, allowUnparsedLiteral = false) {
if (
(typeof version !== 'string' || !version.length) &&
!(version instanceof this.constructor)
) {
throw new Error(`Could not parse version "${version}".`);
}
// Sanitize version.
version =
version instanceof this.constructor
? version.format()
: String(version);
/**
* @type {string}
*
* @private
*/
this._rawVersion = version;
/**
* When set to true, the major.minor.patch-prerelease+build properties are set to null.
* @type {IsUnparsedLiteral}
*/
this.isUnparsedLiteral = false;
/**
* When set to true, the major.minor.patch-prerelease+build properties are set to null.
* @type {IsUnversionedNightly}
*/
this.isUnversionedNightly = false;
/** @type {IsUnversionedNightly extends true ? true : boolean} */
this.isNightly = false;
/** @type {IsUnversionedNightly extends true ? Date | null : null} */
this.nightlyDate = null;
/** @type {IsUnversionedNightly extends true ? number : null} */
this.major = null;
/** @type {IsUnversionedNightly extends true ? number : null} */
this.minor = null;
/** @type {IsUnversionedNightly extends true ? number : null} */
this.patch = null;
/** @type {IsUnversionedNightly extends true ? string | null : null} */
this.prerelease = null;
/** @type {IsUnversionedNightly extends true ? string | null : null} */
this.build = null;
if (version === 'nightly') {
this.isNightly = true;
this.isUnversionedNightly = true;
} else if (version.startsWith('Nightlies for ')) {
this.isNightly = true;
this.isUnversionedNightly = true;
const date = version.substring(14);
if (
!date.match(
/^(\d+)-(\d+)-(\d+)T(\d+):(\d{2}):(\d{2})\.(\d{3})Z$/
)
) {
throw new Error(
`Could not parse nightly version with date "${version}".`
);
}
this.nightlyDate = new Date(date);
} else {
const versionParts = semver.parse(version);
if (!versionParts) {
if (!allowUnparsedLiteral) {
throw new Error(`Could not parse version "${version}".`);
}
this.isUnparsedLiteral = true;
} else {
this.major = +versionParts.major;
this.minor = +versionParts.minor;
this.patch = +versionParts.patch;
this.prerelease = versionParts.prerelease.length
? versionParts.prerelease.join('.')
: null;
this.build = versionParts.build.length
? versionParts.build.join('.')
: null;
// Support a versioned nightly.
if (
this.prerelease &&
this.prerelease.match(/^nightly(\.|$)/)
) {
const utcDate = this.prerelease.substring(8);
const utcDateParts = utcDate.match(
/^(?<year>\d{4,})(?<month>\d{2})(?<day>\d{2})(?<hour>\d{2})(?<minute>\d{2})(?<second>\d{2})$/
);
if (!utcDateParts || !utcDateParts.groups) {
throw new Error(
`Could not parse nightly version with date "${version}".`
);
}
this.isNightly = true;
this.isUnversionedNightly = false;
this.nightlyDate = new Date(
Date.UTC(
parseInt(utcDateParts.groups.year, 10),
parseInt(utcDateParts.groups.month, 10) - 1,
parseInt(utcDateParts.groups.day, 10),
parseInt(utcDateParts.groups.hour, 10),
parseInt(utcDateParts.groups.minute, 10),
parseInt(utcDateParts.groups.second, 10),
0
)
);
}
}
}
/** @type {IsUnversionedNightly extends true ? false : IsUnparsedLiteral extends true ? false : boolean} */
this.isStable =
!this.isUnparsedLiteral &&
!this.isNightly &&
!this.prerelease &&
!this.build;
/** @type {IsUnversionedNightly extends true ? true : IsUnparsedLiteral extends true : true : false} */
this.isUnversioned =
!!this.isUnparsedLiteral || !!this.isUnversionedNightly;
/**
* Is used to rewrite add-ons on upgrade from 6.x.x to 7.x.x.
*
* @type {boolean}
*/
this.isPre7_0_0 = !this.isUnversioned && this.major < 7;
/**
* Is used to switch between the different FDT editor modules switching the build system,
* doing an npm install or not, and converting pre 7.7.0 code from AMD to ESM.
*
* @type {boolean}
*/
this.isPre7_7_0 = this.nightlyDate
? this.nightlyDate < new Date('2019-05-17T12:00:00.000Z')
: semver.satisfies(this._rawVersion, '<=7.6.x', {
includePrerelease: true,
});
/**
* Is used to check if changing add-ons is supported, which was not supported before 7.8.2
* for which the SDK portal was still used.
*
* @type {boolean}
*/
this.isPre7_8_2 = this.nightlyDate
? this.nightlyDate < new Date('2019-11-21T12:00:00.000Z')
: semver.satisfies(this._rawVersion, '<=7.8.1', {
includePrerelease: true,
});
/**
* Is used to determine if `xq` should be used for selectors in the code generated by the
* editor init command.
*
* @type {boolean}
*/
this.isPre7_17_0 = this.nightlyDate
? this.nightlyDate < new Date('2021-12-23T12:00:00.000Z')
: semver.satisfies(this._rawVersion, '<=7.16.x', {
includePrerelease: true,
});
/**
* Is used to determine if the `enable-experiment/correct-selection-after-enter`
* configuration should be used in the code generated by the editor init command.
*
* @type {boolean}
*/
this.isPre8_1_0 = this.nightlyDate
? this.nightlyDate < new Date('2022-05-30T12:00:00.000Z')
: semver.satisfies(this._rawVersion, '<=8.0.x', {
includePrerelease: true,
});
/**
* Is used to determine if the review backend app should require sx.xml configuration.
*
* @type {boolean}
*/
this.isPre8_15_0 = this.nightlyDate
? this.nightlyDate < new Date('2025-11-17T12:00:00.000Z')
: semver.satisfies(this._rawVersion, '<=8.14.x', {
includePrerelease: true,
});
}
/**
* Format the version as a string.
*
* @param {'full' | 'build' | 'prerelease' | 'core' | 'patch' | 'minor' | 'major'} format
*
* @throws When the version `isUnversioned` is true (e.g. due to `isUnversionedNightly`, or
* `isUnparsedLiteral`). Be sure to check on `isUnversioned` before using one of those
* formats, which includes 'build', 'prerelease', 'patch', 'minor', 'major'.
* @return {string}
*/
format(format = 'full') {
const parts = [
`${this.major}`,
`.${this.minor}`,
`.${this.patch}`,
this.prerelease ? `-${this.prerelease}` : undefined,
this.build ? `+${this.build}` : undefined,
];
switch (format) {
case 'full':
if (this.isUnversioned) {
return this._rawVersion;
}
return parts.slice(0, 5).join('');
case 'build':
if (this.isUnversionedNightly) {
throw new Error(
`Cannot use "${format}" formatter on a nightly version.`
);
}
if (this.isUnparsedLiteral) {
throw new Error(
`Cannot use "${format}" formatter on an unparsed literal version.`
);
}
return parts.slice(0, 5).join('');
case 'prerelease':
if (this.isUnversionedNightly) {
throw new Error(
`Cannot use "${format}" formatter on a nightly version.`
);
}
if (this.isUnparsedLiteral) {
throw new Error(
`Cannot use "${format}" formatter on an unparsed literal version.`
);
}
return parts.slice(0, 4).join('');
case 'core':
if (this.isUnversionedNightly) {
return 'nightly';
}
if (this.isUnparsedLiteral) {
return this._rawVersion;
}
return parts.slice(0, 3).join('');
case 'patch':
if (this.isUnversionedNightly) {
throw new Error(
`Cannot use "${format}" formatter on a nightly version.`
);
}
if (this.isUnparsedLiteral) {
throw new Error(
`Cannot use "${format}" formatter on an unparsed literal version.`
);
}
return parts.slice(0, 3).join('');
case 'minor':
if (this.isUnversionedNightly) {
throw new Error(
`Cannot use "${format}" formatter on a nightly version.`
);
}
if (this.isUnparsedLiteral) {
throw new Error(
`Cannot use "${format}" formatter on an unparsed literal version.`
);
}
return parts.slice(0, 2).join('');
case 'major':
if (this.isUnversionedNightly) {
throw new Error(
`Cannot use "${format}" formatter on a nightly version.`
);
}
if (this.isUnparsedLiteral) {
throw new Error(
`Cannot use "${format}" formatter on an unparsed literal version.`
);
}
return parts.slice(0, 1).join('');
default:
throw new Error(`Invalid format "${format}" for version.`);
}
}
/**
* WARNING: Use .format() instead of .toString().
*/
toString() {
throw new Error(`Use .format() instead of .toString() for Version.`);
}
/**
* Compare/sort versions, oldest first.
*
* Sort order:
* - '8.1.0'
* - '8.2.0-rc.1'
* - '8.2.0'
* - 'Nightlies for <date>'
* - 'nightly'
* - 'literal'
*
* @param {Version} versionA
* @param {Version} versionB
* @param {CompareScope} [scope='full']
*
* @return {-1 | 0 | 1} `0` if `versionA` == `versionB`, `1` if `versionA` is greater, `-1` if `versionB` is greater.
*/
static compare(versionA, versionB, scope = 'full') {
// Unparsed literal versions are sorted after versions and nightlies.
if (versionA.isUnparsedLiteral && versionB.isUnparsedLiteral) {
return versionA._rawVersion.localeCompare(versionB._rawVersion);
}
if (versionA.isUnparsedLiteral) {
return 1;
}
if (versionB.isUnparsedLiteral) {
return -1;
}
if (
versionA.isUnversionedNightly &&
versionB.isUnversionedNightly &&
versionA.nightlyDate &&
versionB.nightlyDate
) {
return versionA.nightlyDate.getTime() ===
versionB.nightlyDate.getTime()
? 0
: versionA.nightlyDate > versionB.nightlyDate
? 1
: -1;
}
if (versionA.isUnversionedNightly && versionA.nightlyDate) {
return versionB.isUnversionedNightly ? -1 : 1;
}
if (versionB.isUnversionedNightly && versionB.nightlyDate) {
return versionA.isUnversionedNightly ? 1 : -1;
}
if (versionA.isUnversionedNightly && versionB.isUnversionedNightly) {
return 0;
}
if (versionA.isUnversionedNightly) {
return 1;
}
if (versionB.isUnversionedNightly) {
return -1;
}
if (!COMPARE_SCOPES.includes(scope)) {
throw new Error(`Invalid scope "${scope}" for version compare.`);
}
const shouldCoerce = scope === 'minor' || scope === 'major';
const scopedVersionA = shouldCoerce
? semver.coerce(versionA.format(scope))
: versionA.format(scope);
const scopedVersionB = shouldCoerce
? semver.coerce(versionB.format(scope))
: versionB.format(scope);
return semver.compareBuild(scopedVersionA, scopedVersionB);
}
/**
* Reverse compare/sort versions, newest first.
*
* Sort order:
* - 'literal'
* - 'nightly'
* - 'Nightlies for <date>'
* - '8.2.0'
* - '8.2.0-rc.1'
* - '8.1.0'
*
* @param {Version} versionA
* @param {Version} versionB
* @param {CompareScope} [scope='full']
*
* @return {-1 | 0 | 1} `0` if `versionA` == `versionB`, `1` if `versionA` is greater, `-1` if `versionB` is greater.
*/
static compareReverse(versionA, versionB, scope = 'full') {
return Version.compare(versionB, versionA, scope);
}
/**
* Check whether the provided version is newer, older, or the same.
*
* @param {Version} version
* @param {CompareScope} [scope='full']
*
* @return {-1 | 0 | 1} `0` if `this` == `version`, `1` if `this` is greater, `-1` if `version` is greater.
*/
compare(version, scope = 'full') {
return this.constructor.compare(this, version, scope);
}
/**
* @param {Version} versionA
* @param {Version} versionB
* @param {CompareScope | 'upgrade_minor'} [scope='full']
* @param {Version[] | null} [upgradeSupportedVersions=null] Only needed when `Scope` is one of `upgrade_*`.
*
* @return {boolean | 'nightly_a' | 'nightly_b' | 'unparsed_literal_a' | 'unparsed_literal_b'}
*/
static checkCompatibility(
versionA,
versionB,
scope = 'full',
upgradeSupportedVersions = null
) {
if (
!versionA ||
!(versionA instanceof this) ||
!versionB ||
!(versionB instanceof this)
) {
const versionAString =
versionA && versionA instanceof this
? versionA.format()
: String(versionA);
const versionBString =
versionB && versionB instanceof this
? versionB.format()
: String(versionB);
throw new Error(
`Could not check version compatibility between "${versionAString}" and "${versionBString}".`
);
}
if (versionA.isUnversionedNightly) {
return 'nightly_a';
}
if (versionB.isUnversionedNightly) {
return versionA.isNightly ? 'nightly_a' : 'nightly_b';
}
if (versionA.isUnparsedLiteral) {
return 'unparsed_literal_a';
}
if (versionB.isUnparsedLiteral) {
return 'unparsed_literal_b';
}
switch (scope) {
case 'full':
case 'build':
case 'prerelease':
case 'core':
case 'patch':
case 'minor':
case 'major': {
const sameScopedVersion =
versionA.format(scope) === versionB.format(scope);
if (!sameScopedVersion) {
return false;
}
return versionA.isNightly
? 'nightly_a'
: versionB.isNightly
? 'nightly_b'
: true;
}
case 'upgrade_minor': {
// - `versionA` is the version to upgrade to.
// - `versionB` should either be the same minor or one of the
// supported `upgradeSupportedVersions` minors.
// Downgrade is not allowed.
if (versionA.compare(versionB, 'minor') <= -1) {
return false;
}
// Check against the previous minor versions.
if (!upgradeSupportedVersions) {
throw new Error(
`Could not determine version compatibility for upgrade due to not knowing the previous version for "${versionA.format()}".`
);
}
const samePreviousVersion = upgradeSupportedVersions.some(
(previousVersion) =>
!previousVersion.isUnversioned &&
previousVersion.format('minor') ===
versionB.format('minor')
);
if (samePreviousVersion) {
return versionA.isNightly
? 'nightly_a'
: versionB.isNightly
? 'nightly_b'
: true;
}
return false;
}
default:
throw new Error(
`Invalid scope "${scope}" for version compatibility check.`
);
}
}
/**
* @param {Version} version
* @param {CompareScope | 'upgrade_minor'} [scope='full']
* @param {Version[] | null} [upgradeSupportedVersions=null] Only needed when `Scope` is one of `upgrade_*`.
*
* @return {boolean | 'nightly_a' | 'nightly_b' | 'unparsed_literal_a' | 'unparsed_literal_b'}
*/
checkCompatibility(
version,
scope = 'full',
upgradeSupportedVersions = null
) {
return this.constructor.checkCompatibility(
this,
version,
scope,
upgradeSupportedVersions
);
}
}