Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ To be released.

### @fedify/vocab-runtime

- Added `Decimal`, a branded string type for exact `xsd:decimal` values,
along with `isDecimal()` and `parseDecimal()` for checking and validating
XML Schema decimal lexical forms without introducing a decimal arithmetic
dependency. This lays the runtime groundwork for precision-safe
marketplace and measurement values such as those needed by [FEP-0837].
[[#617], [#640]]

- Updated the preloaded <https://gotosocial.org/ns> JSON-LD context to
match the current [GoToSocial] v0.21+ namespace, adding new type terms
(`LikeRequest`, `LikeAuthorization`, etc.) and property terms
Expand All @@ -66,9 +73,12 @@ To be released.
APIs to distinguish transport failures from specific HTTP fetch failures.
[[#611]]

[FEP-0837]: https://w3id.org/fep/0837
[GoToSocial]: https://gotosocial.org/
[#453]: https://github.com/fedify-dev/fedify/issues/453
[#617]: https://github.com/fedify-dev/fedify/issues/617
[#622]: https://github.com/fedify-dev/fedify/pull/622
[#640]: https://github.com/fedify-dev/fedify/pull/640

### @fedify/cli

Expand Down Expand Up @@ -134,6 +144,11 @@ To be released.

### @fedify/vocab-tools

- Added `xsd:decimal` support to the vocabulary code generator. Properties
with that range are now generated as `Decimal` in TypeScript, serialized
as `xsd:decimal` JSON-LD literals, and validated through
`parseDecimal()` when decoded. [[#617], [#640]]

- Added `typeless` field to the type YAML schema. When set to `true`,
the generated `toJsonLd()` method does not emit `@type` (or `type` in
compact form) in the serialized JSON-LD. This is useful for types
Expand Down
16 changes: 16 additions & 0 deletions docs/manual/vocab.md
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,7 @@ corresponding TypeScript types:
| `xsd:integer` | `number` |
| `xsd:nonNegativeInteger` | `number` |
| `xsd:float` | `number` |
| `xsd:decimal` | `Decimal` |
| `xsd:string` | `string` |
| `xsd:anyURI` | [`URL`] |
| `xsd:dateTime` | [`Temporal.Instant`] |
Expand All @@ -521,6 +522,21 @@ corresponding TypeScript types:
| Proof purpose | `"assertionMethod" \| "authentication" \| "capabilityInvocation" \| "capabilityDelegation" \| "keyAgreement"` |
| Units | `"cm" \| "feet" \| "inches" \| "km" \| "m" \| "miles" \| URL` |

`Decimal` values come from `@fedify/vocab-runtime` as a branded string type.
Use `parseDecimal()` to validate a string against the `xsd:decimal` lexical
form before passing it to generated vocabulary APIs:

~~~~ typescript twoslash
import type { Decimal } from "@fedify/vocab-runtime";
import { parseDecimal } from "@fedify/vocab-runtime";

const price: Decimal = parseDecimal("12.50");
~~~~

`Decimal` keeps the original string at runtime instead of converting it to
JavaScript `number`, which avoids floating-point precision loss for exact
decimal values such as prices and measurements.

[`Temporal.Instant`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/Instant
[`Temporal.Duration`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/Duration
[`Uint8Array`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array
Expand Down
66 changes: 66 additions & 0 deletions packages/vocab-runtime/src/decimal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { deepStrictEqual, throws } from "node:assert";
import { test } from "node:test";
import { isDecimal, parseDecimal } from "./decimal.ts";
import {
isDecimal as isDecimalFromModule,
parseDecimal as parseDecimalFromModule,
} from "./mod.ts";

test("parseDecimal() accepts valid xsd:decimal lexical forms", () => {
const values = [
"-1.23",
"12678967.543233",
"+100000.00",
"210",
".5",
"5.",
"0",
"-0.0",
];

for (const value of values) {
deepStrictEqual(parseDecimal(value), value);
}
});

test("isDecimal() reports valid xsd:decimal lexical forms", () => {
deepStrictEqual(isDecimal("12.50"), true);
deepStrictEqual(isDecimal(".5"), true);
deepStrictEqual(isDecimal("1e3"), false);
deepStrictEqual(isDecimal(" 12.50 "), false);
});

test("parseDecimal() rejects invalid xsd:decimal lexical forms", () => {
const values = [
"",
".",
"+",
"-",
"1e3",
"NaN",
"INF",
" 1.2 ",
"1,2",
"1..2",
];

for (const value of values) {
throws(
() => parseDecimal(value),
{
name: "TypeError",
message: `${
JSON.stringify(value)
} is not a valid xsd:decimal lexical form.`,
},
);
}
});

test("parseDecimal() is exported from the package root", () => {
deepStrictEqual(parseDecimalFromModule("12.50"), "12.50");
});

test("isDecimal() is exported from the package root", () => {
deepStrictEqual(isDecimalFromModule("12.50"), true);
});
79 changes: 79 additions & 0 deletions packages/vocab-runtime/src/decimal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
const DECIMAL_PATTERN = /^(\+|-)?([0-9]+(\.[0-9]*)?|\.[0-9]+)$/;

/**
* A branded string representing an `xsd:decimal` value.
*
* Unlike JavaScript's `number`, `xsd:decimal` is intended for exact decimal
* values such as prices, quantities, and measurements where binary
* floating-point rounding would be inappropriate. Fedify therefore represents
* these values as validated strings at runtime while preserving a distinct
* TypeScript type.
*
* Values of this type must be created through {@link parseDecimal}, which
* validates that the string matches the XML Schema `xsd:decimal` lexical form.
*
* The runtime representation is still a plain string. The brand exists only
* at the type level so APIs can distinguish arbitrary strings from validated
* decimal literals without introducing a decimal arithmetic dependency.
*
* Supported lexical forms include signed and unsigned integers and decimal
* fractions such as `"-1.23"`, `"+100000.00"`, `"210"`, `".5"`, and `"5."`.
* Scientific notation such as `"1e3"`, special values like `"NaN"`, and
* strings with surrounding whitespace are rejected.
*
* This representation is designed to be forward-compatible with a future
* native decimal type if JavaScript eventually gains one, while keeping the
* public API semantically precise today.
*
* @since 2.1.0
*/
export type Decimal = string & { readonly __brand: "Decimal" };

/**
* Checks whether a string is a valid `xsd:decimal` lexical form.
*
* This predicate performs the same validation as {@link parseDecimal}
* without throwing an exception. It is useful for generated guards and other
* boolean validation paths where callers need to branch instead of handling an
* exception.
*
* @param value A candidate `xsd:decimal` lexical form.
* @returns `true` if the string matches the XML Schema `xsd:decimal` lexical
* form, or `false` otherwise.
* @since 2.1.0
*/
export function isDecimal(value: string): value is Decimal {
return DECIMAL_PATTERN.test(value);
Comment thread
dahlia marked this conversation as resolved.
}

/**
* Parses a string as an `xsd:decimal` lexical form and returns it as a
* branded {@link Decimal}.
*
* This function validates the input against the XML Schema `xsd:decimal`
* lexical space and returns the original string unchanged when it is valid.
* It does not trim whitespace, collapse spaces, or canonicalize the decimal
* representation.
*
* @param value A candidate `xsd:decimal` lexical form.
* @returns The original string branded as {@link Decimal}.
* @throws {TypeError} Thrown when the value is not a valid `xsd:decimal`
* lexical form.
* @example
* ```typescript
* const price = parseDecimal("12.50");
* ```
* @example
* ```typescript
* parseDecimal("1e3"); // throws TypeError
* ```
* @since 2.1.0
*/
export function parseDecimal(value: string): Decimal {
if (!isDecimal(value)) {
throw new TypeError(
`${JSON.stringify(value)} is not a valid xsd:decimal lexical form.`,
);
}
return value as Decimal;
}
1 change: 1 addition & 0 deletions packages/vocab-runtime/src/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export {
importPkcs1,
importSpki,
} from "./key.ts";
export { type Decimal, isDecimal, parseDecimal } from "./decimal.ts";
export { LanguageString } from "./langstr.ts";
export {
decodeMultibase,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,18 @@ import { type Span, SpanStatusCode, type TracerProvider, trace }
from \\"@opentelemetry/api\\";
import {
decodeMultibase,
type Decimal,
type DocumentLoader,
encodeMultibase,
exportMultibaseKey,
exportSpki,
getDocumentLoader,
importMultibaseKey,
importPem,
isDecimal,
LanguageString,
type RemoteDocument,
parseDecimal,
type RemoteDocument
} from \\"@fedify/vocab-runtime\\";


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,18 @@ import { type Span, SpanStatusCode, type TracerProvider, trace }
from \\"@opentelemetry/api\\";
import {
decodeMultibase,
type Decimal,
type DocumentLoader,
encodeMultibase,
exportMultibaseKey,
exportSpki,
getDocumentLoader,
importMultibaseKey,
importPem,
isDecimal,
LanguageString,
type RemoteDocument,
parseDecimal,
type RemoteDocument
} from \\"@fedify/vocab-runtime\\";


Expand Down
5 changes: 4 additions & 1 deletion packages/vocab-tools/src/__snapshots__/class.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,18 @@ import { type Span, SpanStatusCode, type TracerProvider, trace }
from "@opentelemetry/api";
import {
decodeMultibase,
type Decimal,
type DocumentLoader,
encodeMultibase,
exportMultibaseKey,
exportSpki,
getDocumentLoader,
importMultibaseKey,
importPem,
isDecimal,
LanguageString,
type RemoteDocument,
parseDecimal,
type RemoteDocument
} from "@fedify/vocab-runtime";


Expand Down
37 changes: 36 additions & 1 deletion packages/vocab-tools/src/class.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { basename, dirname, extname, join } from "node:path";
import { test } from "node:test";
import metadata from "../deno.json" with { type: "json" };
import { generateClasses, sortTopologically } from "./class.ts";
import { loadSchemaFiles } from "./schema.ts";
import { loadSchemaFiles, type TypeSchema } from "./schema.ts";

test("sortTopologically()", () => {
const sorted = sortTopologically({
Expand Down Expand Up @@ -69,6 +69,16 @@ test("generateClasses() imports the browser-safe jsonld entrypoint", async () =>
match(entireCode, /import jsonld from "@fedify\/vocab-runtime\/jsonld";/);
});

test("generateClasses() imports Decimal helpers for xsd:decimal", async () => {
const entireCode = await getDecimalFixtureCode();
match(entireCode, /isDecimal,/);
match(entireCode, /type Decimal,/);
match(entireCode, /parseDecimal/);
match(entireCode, /amount\?: Decimal \| null;/);
match(entireCode, /isDecimal\(values\.amount\)/);
match(entireCode, /parseDecimal\(v\["@value"\]\)/);
});

if ("Deno" in globalThis) {
const { assertSnapshot } = await import("@std/testing/snapshot");
Deno.test("generateClasses()", async (t) => {
Expand Down Expand Up @@ -101,6 +111,31 @@ async function getEntireCode() {
return entireCode;
}

async function getDecimalFixtureCode() {
const types: Record<string, TypeSchema> = {
"https://example.com/measure": {
name: "Measure",
uri: "https://example.com/measure",
compactName: "Measure",
entity: false,
description: "A measure.",
properties: [
{
singularName: "amount",
functional: true,
compactName: "amount",
uri: "https://example.com/amount",
description: "An exact decimal amount.",
range: ["http://www.w3.org/2001/XMLSchema#decimal"],
},
],
defaultContext:
"https://example.com/context" as TypeSchema["defaultContext"],
},
};
return (await Array.fromAsync(generateClasses(types))).join("");
}

async function changeNodeSnapshotPath() {
const { snapshot } = await import("node:test");
snapshot.setResolveSnapshotPath(
Expand Down
30 changes: 18 additions & 12 deletions packages/vocab-tools/src/class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,23 +117,29 @@ async function* generateClass(
export async function* generateClasses(
types: Record<string, TypeSchema>,
): AsyncIterable<string> {
const runtimeImports = [
"decodeMultibase",
"type Decimal",
"type DocumentLoader",
Comment thread
dahlia marked this conversation as resolved.
"encodeMultibase",
"exportMultibaseKey",
"exportSpki",
"getDocumentLoader",
"importMultibaseKey",
"importPem",
"isDecimal",
"LanguageString",
"parseDecimal",
Comment thread
dahlia marked this conversation as resolved.
"type RemoteDocument",
];
yield "// deno-lint-ignore-file ban-unused-ignore prefer-const\n";
yield 'import jsonld from "@fedify/vocab-runtime/jsonld";\n';
yield 'import { getLogger } from "@logtape/logtape";\n';
yield `import { type Span, SpanStatusCode, type TracerProvider, trace }
from "@opentelemetry/api";\n`;
yield `import {
decodeMultibase,
type DocumentLoader,
encodeMultibase,
exportMultibaseKey,
exportSpki,
getDocumentLoader,
importMultibaseKey,
importPem,
LanguageString,
type RemoteDocument,
} from "@fedify/vocab-runtime";\n`;
yield `import {\n ${
runtimeImports.join(",\n ")
}\n} from "@fedify/vocab-runtime";\n`;
yield "\n\n";
const sorted = sortTopologically(types);
for (const typeUri of sorted) {
Expand Down
Loading
Loading