feat: add core registry schemas
This commit is contained in:
@@ -16,7 +16,7 @@ function renderCanonical(value: JsonValue): string {
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return JSON.stringify(value);
|
||||
return renderCanonicalNumber(value);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
@@ -37,7 +37,17 @@ function assertJsonValue(value: unknown): JsonValue {
|
||||
Array.isArray(value)
|
||||
) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => assertJsonValue(item));
|
||||
assertOnlyArrayIndexKeys(value);
|
||||
const arrayValue: JsonValue[] = [];
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
if (!(index in value)) {
|
||||
throw new TypeError(`Cannot canonicalize sparse array at index ${index}`);
|
||||
}
|
||||
|
||||
arrayValue.push(assertJsonValue(value[index]));
|
||||
}
|
||||
|
||||
return arrayValue;
|
||||
}
|
||||
|
||||
return value;
|
||||
@@ -57,7 +67,8 @@ function assertJsonValue(value: unknown): JsonValue {
|
||||
throw new TypeError("Cannot canonicalize non-plain object");
|
||||
}
|
||||
|
||||
const objectValue: Record<string, JsonValue> = {};
|
||||
assertOnlyEnumerableStringKeys(value);
|
||||
const objectValue: Record<string, JsonValue> = Object.create(null) as Record<string, JsonValue>;
|
||||
|
||||
for (const [key, childValue] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (childValue === undefined) {
|
||||
@@ -72,3 +83,183 @@ function assertJsonValue(value: unknown): JsonValue {
|
||||
|
||||
throw new TypeError(`Cannot canonicalize ${typeof value}`);
|
||||
}
|
||||
|
||||
function assertOnlyEnumerableStringKeys(value: object) {
|
||||
const enumerableStringKeys = Object.keys(value);
|
||||
const ownKeys = Reflect.ownKeys(value);
|
||||
const hasOnlyEnumerableStringKeys =
|
||||
ownKeys.length === enumerableStringKeys.length &&
|
||||
ownKeys.every(
|
||||
(key) => typeof key === "string" && Object.prototype.propertyIsEnumerable.call(value, key),
|
||||
);
|
||||
|
||||
if (!hasOnlyEnumerableStringKeys) {
|
||||
throw new TypeError("Cannot canonicalize non-enumerable or symbol object keys");
|
||||
}
|
||||
}
|
||||
|
||||
function assertOnlyArrayIndexKeys(value: unknown[]) {
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
if (key === "length") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof key !== "string" || !Object.prototype.propertyIsEnumerable.call(value, key)) {
|
||||
throw new TypeError("Cannot canonicalize non-index array object keys");
|
||||
}
|
||||
|
||||
const index = Number(key);
|
||||
if (!Number.isInteger(index) || index < 0 || index >= value.length || String(index) !== key) {
|
||||
throw new TypeError("Cannot canonicalize non-index array object keys");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderCanonicalNumber(value: number): string {
|
||||
if (Object.is(value, -0) || value === 0) {
|
||||
return "0";
|
||||
}
|
||||
|
||||
const candidates = new Set<string>();
|
||||
addNumberCandidate(candidates, value, value.toString());
|
||||
const jsonCandidate = JSON.stringify(value);
|
||||
if (jsonCandidate !== undefined) {
|
||||
addNumberCandidate(candidates, value, jsonCandidate);
|
||||
}
|
||||
|
||||
for (let precision = 1; precision <= 17; precision += 1) {
|
||||
addNumberCandidate(candidates, value, value.toPrecision(precision));
|
||||
addNumberCandidate(candidates, value, value.toExponential(precision - 1));
|
||||
}
|
||||
|
||||
const [best] = [...candidates].sort(
|
||||
(left, right) => left.length - right.length || compareCodeUnits(left, right),
|
||||
);
|
||||
if (!best) {
|
||||
throw new TypeError(`Cannot canonicalize number ${value}`);
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
function addNumberCandidate(candidates: Set<string>, value: number, raw: string) {
|
||||
const candidate = normalizeNumberLiteral(raw);
|
||||
for (const equivalent of expandNumberLiteral(candidate)) {
|
||||
if (Number(equivalent) === value) {
|
||||
candidates.add(equivalent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function compareCodeUnits(left: string, right: string) {
|
||||
if (left < right) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (left > right) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function normalizeNumberLiteral(raw: string): string {
|
||||
const [mantissaText, exponentText] = raw.toLowerCase().split("e");
|
||||
const mantissa = normalizeDecimal(mantissaText ?? "");
|
||||
if (exponentText === undefined) {
|
||||
return mantissa;
|
||||
}
|
||||
|
||||
const exponent = normalizeExponent(exponentText);
|
||||
if (exponent === "0") {
|
||||
return mantissa;
|
||||
}
|
||||
|
||||
return `${mantissa}e${exponent}`;
|
||||
}
|
||||
|
||||
function normalizeDecimal(raw: string): string {
|
||||
if (!raw.includes(".")) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
const trimmed = raw.replace(/0+$/, "").replace(/\.$/, "");
|
||||
if (trimmed === "-0") {
|
||||
return "0";
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function normalizeExponent(raw: string): string {
|
||||
const sign = raw.startsWith("-") ? "-" : "";
|
||||
const unsigned = raw.replace(/^[+-]/, "").replace(/^0+/, "");
|
||||
if (unsigned === "") {
|
||||
return "0";
|
||||
}
|
||||
|
||||
return `${sign}${unsigned}`;
|
||||
}
|
||||
|
||||
function expandNumberLiteral(raw: string): string[] {
|
||||
const parsed = parseNumberLiteral(raw);
|
||||
if (!parsed) {
|
||||
return [raw];
|
||||
}
|
||||
|
||||
const plain = renderPlainDecimal(parsed);
|
||||
const candidates = new Set([plain]);
|
||||
|
||||
for (let integerDigits = 1; integerDigits <= parsed.digits.length; integerDigits += 1) {
|
||||
const exponent = parsed.power + parsed.digits.length - integerDigits;
|
||||
if (exponent === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mantissa =
|
||||
integerDigits === parsed.digits.length
|
||||
? parsed.digits
|
||||
: `${parsed.digits.slice(0, integerDigits)}.${parsed.digits.slice(integerDigits)}`;
|
||||
candidates.add(`${parsed.sign}${mantissa}e${exponent}`);
|
||||
}
|
||||
|
||||
return [...candidates];
|
||||
}
|
||||
|
||||
function parseNumberLiteral(raw: string) {
|
||||
const match = raw.match(/^(-?)(\d+)(?:\.(\d+))?(?:e(-?\d+))?$/);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [, sign, integerPart, fractionalPart = "", exponentPart] = match;
|
||||
let digits = `${integerPart}${fractionalPart}`;
|
||||
let power = (exponentPart === undefined ? 0 : Number(exponentPart)) - fractionalPart.length;
|
||||
digits = digits.replace(/^0+/, "");
|
||||
if (digits === "") {
|
||||
return { sign: "", digits: "0", power: 0 };
|
||||
}
|
||||
|
||||
const lengthBeforeTrailingTrim = digits.length;
|
||||
digits = digits.replace(/0+$/, "");
|
||||
power += lengthBeforeTrailingTrim - digits.length;
|
||||
|
||||
return { sign: sign ?? "", digits, power };
|
||||
}
|
||||
|
||||
function renderPlainDecimal(parsed: { sign: string; digits: string; power: number }) {
|
||||
if (parsed.digits === "0") {
|
||||
return "0";
|
||||
}
|
||||
|
||||
if (parsed.power >= 0) {
|
||||
return `${parsed.sign}${parsed.digits}${"0".repeat(parsed.power)}`;
|
||||
}
|
||||
|
||||
const pointIndex = parsed.digits.length + parsed.power;
|
||||
if (pointIndex > 0) {
|
||||
return `${parsed.sign}${parsed.digits.slice(0, pointIndex)}.${parsed.digits.slice(pointIndex)}`;
|
||||
}
|
||||
|
||||
return `${parsed.sign}0.${"0".repeat(-pointIndex)}${parsed.digits}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user