diff --git a/.changeset/add-pub-leaflet-lexicons.md b/.changeset/add-pub-leaflet-lexicons.md new file mode 100644 index 00000000..857926c2 --- /dev/null +++ b/.changeset/add-pub-leaflet-lexicons.md @@ -0,0 +1,5 @@ +--- +"@hypercerts-org/lexicon": minor +--- + +Vendor pub.leaflet and app.bsky.richtext.facet lexicon JSONs to enable runtime validation of records with description and facet fields. Previously, these external schemas were only shimmed at the TypeScript type level via @atcute packages, causing LexiconDefNotFoundError at runtime when validating records that populated optional description or facet fields. Removes the now-unnecessary create-shims.sh script and @atcute/leaflet + @atcute/bluesky dependencies. diff --git a/AGENTS.md b/AGENTS.md index 5599c0a9..fa8c2d54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,9 +97,9 @@ npm run gen-api This runs `lex gen-api` on all lexicon JSON files and: -1. Generates TypeScript types in `generated/` -2. Creates type shims for external lexicons -3. Auto-generates `generated/exports.ts` with clean exports +1. Generates TypeScript types in `generated/` (including vendored + external lexicons from `lexicons/pub/` and `lexicons/app/bsky/`) +2. Auto-generates `generated/exports.ts` with clean exports Then to build the distributable bundles: @@ -231,8 +231,7 @@ scripts/ Build and codegen scripts structure 2. Run `npm run gen-api` to: - - Generate types in `generated/` - - Create type shims for external lexicons + - Generate types in `generated/` (including vendored external lexicons) - Auto-generate `generated/exports.ts` with all exports 3. Update `ERD.puml` as appropriate: diff --git a/SCHEMAS.md b/SCHEMAS.md index d885a29e..4cce8ea8 100644 --- a/SCHEMAS.md +++ b/SCHEMAS.md @@ -364,7 +364,7 @@ A location represented as a string, e.g. coordinates or a small GeoJSON string. | ---------------- | -------- | -------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | `badgeType` | `string` | ✅ | Category of the badge. Values beyond the known set are permitted. | maxLength: 100, Known values: `endorsement`, `verification`, `participation`, `certification`, `affiliation`, `recognition` | | `title` | `string` | ✅ | Human-readable title of the badge. | maxLength: 256 | -| `icon` | `blob` | ❌ | Icon representing the badge, stored as a blob for compact visual display. | maxSize: 1048576, accepts: image/png, image/jpeg, image/webp, image/svg+xml | +| `icon` | `blob` | ❌ | Icon representing the badge, stored as a blob for compact visual display. | maxSize: 1048576, accepts: `image/png`, `image/jpeg`, `image/webp`, `image/svg+xml` | | `description` | `string` | ❌ | Optional short statement describing what the badge represents. | maxLength: 5000, maxGraphemes: 500 | | `allowedIssuers` | `ref[]` | ❌ | Optional allowlist of DIDs allowed to issue this badge. If omitted, anyone may issue it. | maxLength: 100 | | `createdAt` | `string` | ✅ | Client-declared timestamp when this record was originally created | | @@ -574,6 +574,54 @@ Object containing a large image External lexicons from other protocols and systems. +### `app.bsky.richtext.facet` + +**Description:** Annotation of a sub-string within rich text. + +#### Properties + +| Property | Type | Required | Description | +| ---------- | --------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `index` | `ref` | ✅ | Specifies the sub-string range a facet feature applies to. Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text. NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets. | +| `features` | `union[]` | ✅ | | + +#### Defs + +##### `app.bsky.richtext.facet#mention` + +Facet feature for mention of another account. The text is usually a handle, including a '@' prefix, but the facet reference is a DID. + +| Property | Type | Required | Description | +| -------- | -------- | -------- | ----------- | +| `did` | `string` | ✅ | | + +##### `app.bsky.richtext.facet#link` + +Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL. + +| Property | Type | Required | Description | +| -------- | -------- | -------- | ----------- | +| `uri` | `string` | ✅ | | + +##### `app.bsky.richtext.facet#tag` + +Facet feature for a hashtag. The text usually includes a '#' prefix, but the facet reference should not (except in the case of 'double hash tags'). + +| Property | Type | Required | Description | +| -------- | -------- | -------- | ----------- | +| `tag` | `string` | ✅ | | + +##### `app.bsky.richtext.facet#byteSlice` + +Specifies the sub-string range a facet feature applies to. Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text. NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets. + +| Property | Type | Required | Description | +| ----------- | --------- | -------- | ----------- | +| `byteStart` | `integer` | ✅ | | +| `byteEnd` | `integer` | ✅ | | + +--- + ### `com.atproto.repo.strongRef` #### Properties @@ -656,6 +704,323 @@ Configuration for a specific contributor within a board. Values serve as fallbac --- +### `pub.leaflet.blocks.blockquote` + +#### Properties + +| Property | Type | Required | Description | +| ----------- | -------- | -------- | ----------- | +| `plaintext` | `string` | ✅ | | +| `facets` | `ref[]` | ❌ | | + +--- + +### `pub.leaflet.blocks.bskyPost` + +#### Properties + +| Property | Type | Required | Description | +| ------------ | -------- | -------- | ----------- | +| `postRef` | `ref` | ✅ | | +| `clientHost` | `string` | ❌ | | + +--- + +### `pub.leaflet.blocks.button` + +#### Properties + +| Property | Type | Required | Description | +| -------- | -------- | -------- | ----------- | +| `text` | `string` | ✅ | | +| `url` | `string` | ✅ | | + +--- + +### `pub.leaflet.blocks.code` + +#### Properties + +| Property | Type | Required | Description | +| ------------------------- | -------- | -------- | ----------- | +| `plaintext` | `string` | ✅ | | +| `language` | `string` | ❌ | | +| `syntaxHighlightingTheme` | `string` | ❌ | | + +--- + +### `pub.leaflet.blocks.header` + +#### Properties + +| Property | Type | Required | Description | +| ----------- | --------- | -------- | ----------- | +| `level` | `integer` | ❌ | | +| `plaintext` | `string` | ✅ | | +| `facets` | `ref[]` | ❌ | | + +--- + +### `pub.leaflet.blocks.horizontalRule` + +#### Properties + +--- + +### `pub.leaflet.blocks.iframe` + +#### Properties + +| Property | Type | Required | Description | +| -------- | --------- | -------- | ----------- | +| `url` | `string` | ✅ | | +| `height` | `integer` | ❌ | | + +--- + +### `pub.leaflet.blocks.image` + +#### Properties + +| Property | Type | Required | Description | Comments | +| ------------- | -------- | -------- | ----------------------------------------------------- | ------------------------------------ | +| `image` | `blob` | ✅ | | maxSize: 1000000, accepts: `image/*` | +| `alt` | `string` | ❌ | Alt text description of the image, for accessibility. | | +| `aspectRatio` | `ref` | ✅ | | | + +#### Defs + +##### `pub.leaflet.blocks.image#aspectRatio` + +| Property | Type | Required | Description | +| -------- | --------- | -------- | ----------- | +| `width` | `integer` | ✅ | | +| `height` | `integer` | ✅ | | + +--- + +### `pub.leaflet.blocks.math` + +#### Properties + +| Property | Type | Required | Description | +| -------- | -------- | -------- | ----------- | +| `tex` | `string` | ✅ | | + +--- + +### `pub.leaflet.blocks.orderedList` + +#### Properties + +| Property | Type | Required | Description | +| ------------ | --------- | -------- | -------------------------------------------------------------------------- | +| `startIndex` | `integer` | ❌ | The starting number for this ordered list. Defaults to 1 if not specified. | +| `children` | `ref[]` | ✅ | | + +#### Defs + +##### `pub.leaflet.blocks.orderedList#listItem` + +| Property | Type | Required | Description | +| ----------------------- | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------- | +| `checked` | `boolean` | ❌ | If present, this item is a checklist item. true = checked, false = unchecked. If absent, this is a normal list item. | +| `content` | `union` | ✅ | | +| `children` | `ref[]` | ❌ | Nested ordered list items. Mutually exclusive with unorderedListChildren; if both are present, children takes precedence. | +| `unorderedListChildren` | `ref` | ❌ | A nested unordered list. Mutually exclusive with children; if both are present, children takes precedence. | + +--- + +### `pub.leaflet.blocks.page` + +#### Properties + +| Property | Type | Required | Description | +| -------- | -------- | -------- | ----------- | +| `id` | `string` | ✅ | | + +--- + +### `pub.leaflet.blocks.poll` + +#### Properties + +| Property | Type | Required | Description | +| --------- | ----- | -------- | ----------- | +| `pollRef` | `ref` | ✅ | | + +--- + +### `pub.leaflet.blocks.text` + +#### Properties + +| Property | Type | Required | Description | +| ----------- | -------- | -------- | ----------- | +| `plaintext` | `string` | ✅ | | +| `textSize` | `string` | ❌ | | +| `facets` | `ref[]` | ❌ | | + +--- + +### `pub.leaflet.blocks.unorderedList` + +#### Properties + +| Property | Type | Required | Description | +| ---------- | ------- | -------- | ----------- | +| `children` | `ref[]` | ✅ | | + +#### Defs + +##### `pub.leaflet.blocks.unorderedList#listItem` + +| Property | Type | Required | Description | +| --------------------- | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------- | +| `checked` | `boolean` | ❌ | If present, this item is a checklist item. true = checked, false = unchecked. If absent, this is a normal list item. | +| `content` | `union` | ✅ | | +| `children` | `ref[]` | ❌ | Nested unordered list items. Mutually exclusive with orderedListChildren; if both are present, children takes precedence. | +| `orderedListChildren` | `ref` | ❌ | Nested ordered list items. Mutually exclusive with children; if both are present, children takes precedence. | + +--- + +### `pub.leaflet.blocks.website` + +#### Properties + +| Property | Type | Required | Description | Comments | +| -------------- | -------- | -------- | ----------- | ------------------------------------ | +| `previewImage` | `blob` | ❌ | | maxSize: 1000000, accepts: `image/*` | +| `title` | `string` | ❌ | | | +| `description` | `string` | ❌ | | | +| `src` | `string` | ✅ | | | + +--- + +### `pub.leaflet.pages.linearDocument` + +#### Properties + +| Property | Type | Required | Description | +| -------- | -------- | -------- | ----------- | +| `id` | `string` | ❌ | | +| `blocks` | `ref[]` | ✅ | | + +#### Defs + +##### `pub.leaflet.pages.linearDocument#block` + +| Property | Type | Required | Description | +| ----------- | -------- | -------- | ----------- | +| `block` | `union` | ✅ | | +| `alignment` | `string` | ❌ | | + +##### `pub.leaflet.pages.linearDocument#quote` + +| Property | Type | Required | Description | +| -------- | ----- | -------- | ----------- | +| `start` | `ref` | ✅ | | +| `end` | `ref` | ✅ | | + +##### `pub.leaflet.pages.linearDocument#position` + +| Property | Type | Required | Description | +| -------- | ----------- | -------- | ----------- | +| `block` | `integer[]` | ✅ | | +| `offset` | `integer` | ✅ | | + +--- + +### `pub.leaflet.richtext.facet` + +**Description:** Annotation of a sub-string within rich text. + +#### Properties + +| Property | Type | Required | Description | +| ---------- | --------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `index` | `ref` | ✅ | Specifies the sub-string range a facet feature applies to. Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text. NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets. | +| `features` | `union[]` | ✅ | | + +#### Defs + +##### `pub.leaflet.richtext.facet#byteSlice` + +Specifies the sub-string range a facet feature applies to. Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text. NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets. + +| Property | Type | Required | Description | +| ----------- | --------- | -------- | ----------- | +| `byteStart` | `integer` | ✅ | | +| `byteEnd` | `integer` | ✅ | | + +##### `pub.leaflet.richtext.facet#link` + +Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL. + +| Property | Type | Required | Description | +| -------- | -------- | -------- | ----------- | +| `uri` | `string` | ✅ | | + +##### `pub.leaflet.richtext.facet#didMention` + +Facet feature for mentioning a did. + +| Property | Type | Required | Description | +| -------- | -------- | -------- | ----------- | +| `did` | `string` | ✅ | | + +##### `pub.leaflet.richtext.facet#atMention` + +Facet feature for mentioning an AT URI. + +| Property | Type | Required | Description | +| -------- | -------- | -------- | ----------- | +| `atURI` | `string` | ✅ | | + +##### `pub.leaflet.richtext.facet#code` + +Facet feature for inline code. + +##### `pub.leaflet.richtext.facet#highlight` + +Facet feature for highlighted text. + +##### `pub.leaflet.richtext.facet#underline` + +Facet feature for underline markup + +##### `pub.leaflet.richtext.facet#strikethrough` + +Facet feature for strikethrough markup + +##### `pub.leaflet.richtext.facet#id` + +Facet feature for an identifier. Used for linking to a segment + +| Property | Type | Required | Description | +| -------- | -------- | -------- | ----------- | +| `id` | `string` | ❌ | | + +##### `pub.leaflet.richtext.facet#bold` + +Facet feature for bold text + +##### `pub.leaflet.richtext.facet#italic` + +Facet feature for italic text + +##### `pub.leaflet.richtext.facet#footnote` + +Facet feature for a footnote reference + +| Property | Type | Required | Description | +| ------------------ | -------- | -------- | ----------- | +| `footnoteId` | `string` | ✅ | | +| `contentPlaintext` | `string` | ✅ | | +| `contentFacets` | `ref[]` | ❌ | | + +--- + ## Notes - All timestamps use the `datetime` format (ISO 8601) diff --git a/docs/AUTO_GENERATION.md b/docs/AUTO_GENERATION.md index 7ecbf817..acfa6ed2 100644 --- a/docs/AUTO_GENERATION.md +++ b/docs/AUTO_GENERATION.md @@ -8,9 +8,8 @@ The `generated/exports.ts` file is **automatically generated** from lexicon JSON When you run `npm run gen-api`, the build process: -1. Runs `lex gen-api` to generate TypeScript types in `generated/` -2. Runs `scripts/create-shims.sh` to create type shims for external lexicons -3. Runs `scripts/generate-exports.js` to auto-generate `generated/exports.ts` +1. Runs `lex gen-api` to generate TypeScript types in `generated/` (including vendored external lexicons from `lexicons/pub/` and `lexicons/app/bsky/`) +2. Runs `scripts/generate-exports.js` to auto-generate `generated/exports.ts` ## The Generation Script diff --git a/lexicons/app/bsky/richtext/facet.json b/lexicons/app/bsky/richtext/facet.json new file mode 100644 index 00000000..388a3a5e --- /dev/null +++ b/lexicons/app/bsky/richtext/facet.json @@ -0,0 +1,51 @@ +{ + "lexicon": 1, + "id": "app.bsky.richtext.facet", + "defs": { + "main": { + "type": "object", + "description": "Annotation of a sub-string within rich text.", + "required": ["index", "features"], + "properties": { + "index": { "type": "ref", "ref": "#byteSlice" }, + "features": { + "type": "array", + "items": { "type": "union", "refs": ["#mention", "#link", "#tag"] } + } + } + }, + "mention": { + "type": "object", + "description": "Facet feature for mention of another account. The text is usually a handle, including a '@' prefix, but the facet reference is a DID.", + "required": ["did"], + "properties": { + "did": { "type": "string", "format": "did" } + } + }, + "link": { + "type": "object", + "description": "Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL.", + "required": ["uri"], + "properties": { + "uri": { "type": "string", "format": "uri" } + } + }, + "tag": { + "type": "object", + "description": "Facet feature for a hashtag. The text usually includes a '#' prefix, but the facet reference should not (except in the case of 'double hash tags').", + "required": ["tag"], + "properties": { + "tag": { "type": "string", "maxLength": 640, "maxGraphemes": 64 } + } + }, + "byteSlice": { + "type": "object", + "description": "Specifies the sub-string range a facet feature applies to. Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text. NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets.", + "required": ["byteStart", "byteEnd"], + "properties": { + "byteStart": { "type": "integer", "minimum": 0 }, + "byteEnd": { "type": "integer", "minimum": 0 } + } + } + } +} diff --git a/lexicons/pub/leaflet/blocks/blockquote.json b/lexicons/pub/leaflet/blocks/blockquote.json new file mode 100644 index 00000000..1f033a3d --- /dev/null +++ b/lexicons/pub/leaflet/blocks/blockquote.json @@ -0,0 +1,22 @@ +{ + "lexicon": 1, + "id": "pub.leaflet.blocks.blockquote", + "defs": { + "main": { + "type": "object", + "required": ["plaintext"], + "properties": { + "plaintext": { + "type": "string" + }, + "facets": { + "type": "array", + "items": { + "type": "ref", + "ref": "pub.leaflet.richtext.facet" + } + } + } + } + } +} diff --git a/lexicons/pub/leaflet/blocks/bskyPost.json b/lexicons/pub/leaflet/blocks/bskyPost.json new file mode 100644 index 00000000..691aa76a --- /dev/null +++ b/lexicons/pub/leaflet/blocks/bskyPost.json @@ -0,0 +1,19 @@ +{ + "lexicon": 1, + "id": "pub.leaflet.blocks.bskyPost", + "defs": { + "main": { + "type": "object", + "required": ["postRef"], + "properties": { + "postRef": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + }, + "clientHost": { + "type": "string" + } + } + } + } +} diff --git a/lexicons/pub/leaflet/blocks/button.json b/lexicons/pub/leaflet/blocks/button.json new file mode 100644 index 00000000..96a31cf5 --- /dev/null +++ b/lexicons/pub/leaflet/blocks/button.json @@ -0,0 +1,19 @@ +{ + "lexicon": 1, + "id": "pub.leaflet.blocks.button", + "defs": { + "main": { + "type": "object", + "required": ["text", "url"], + "properties": { + "text": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + } + } + } +} diff --git a/lexicons/pub/leaflet/blocks/code.json b/lexicons/pub/leaflet/blocks/code.json new file mode 100644 index 00000000..c0e1235b --- /dev/null +++ b/lexicons/pub/leaflet/blocks/code.json @@ -0,0 +1,21 @@ +{ + "lexicon": 1, + "id": "pub.leaflet.blocks.code", + "defs": { + "main": { + "type": "object", + "required": ["plaintext"], + "properties": { + "plaintext": { + "type": "string" + }, + "language": { + "type": "string" + }, + "syntaxHighlightingTheme": { + "type": "string" + } + } + } + } +} diff --git a/lexicons/pub/leaflet/blocks/header.json b/lexicons/pub/leaflet/blocks/header.json new file mode 100644 index 00000000..961d61ce --- /dev/null +++ b/lexicons/pub/leaflet/blocks/header.json @@ -0,0 +1,27 @@ +{ + "lexicon": 1, + "id": "pub.leaflet.blocks.header", + "defs": { + "main": { + "type": "object", + "required": ["plaintext"], + "properties": { + "level": { + "type": "integer", + "minimum": 1, + "maximum": 6 + }, + "plaintext": { + "type": "string" + }, + "facets": { + "type": "array", + "items": { + "type": "ref", + "ref": "pub.leaflet.richtext.facet" + } + } + } + } + } +} diff --git a/lexicons/pub/leaflet/blocks/horizontalRule.json b/lexicons/pub/leaflet/blocks/horizontalRule.json new file mode 100644 index 00000000..c1941409 --- /dev/null +++ b/lexicons/pub/leaflet/blocks/horizontalRule.json @@ -0,0 +1,11 @@ +{ + "lexicon": 1, + "id": "pub.leaflet.blocks.horizontalRule", + "defs": { + "main": { + "type": "object", + "required": [], + "properties": {} + } + } +} diff --git a/lexicons/pub/leaflet/blocks/iframe.json b/lexicons/pub/leaflet/blocks/iframe.json new file mode 100644 index 00000000..7b3e8b24 --- /dev/null +++ b/lexicons/pub/leaflet/blocks/iframe.json @@ -0,0 +1,21 @@ +{ + "lexicon": 1, + "id": "pub.leaflet.blocks.iframe", + "defs": { + "main": { + "type": "object", + "required": ["url"], + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "height": { + "type": "integer", + "minimum": 16, + "maximum": 1600 + } + } + } + } +} diff --git a/lexicons/pub/leaflet/blocks/image.json b/lexicons/pub/leaflet/blocks/image.json new file mode 100644 index 00000000..88ad667a --- /dev/null +++ b/lexicons/pub/leaflet/blocks/image.json @@ -0,0 +1,37 @@ +{ + "lexicon": 1, + "id": "pub.leaflet.blocks.image", + "defs": { + "main": { + "type": "object", + "required": ["image", "aspectRatio"], + "properties": { + "image": { + "type": "blob", + "accept": ["image/*"], + "maxSize": 1000000 + }, + "alt": { + "type": "string", + "description": "Alt text description of the image, for accessibility." + }, + "aspectRatio": { + "type": "ref", + "ref": "#aspectRatio" + } + } + }, + "aspectRatio": { + "type": "object", + "required": ["width", "height"], + "properties": { + "width": { + "type": "integer" + }, + "height": { + "type": "integer" + } + } + } + } +} diff --git a/lexicons/pub/leaflet/blocks/math.json b/lexicons/pub/leaflet/blocks/math.json new file mode 100644 index 00000000..1e50f0e3 --- /dev/null +++ b/lexicons/pub/leaflet/blocks/math.json @@ -0,0 +1,15 @@ +{ + "lexicon": 1, + "id": "pub.leaflet.blocks.math", + "defs": { + "main": { + "type": "object", + "required": ["tex"], + "properties": { + "tex": { + "type": "string" + } + } + } + } +} diff --git a/lexicons/pub/leaflet/blocks/orderedList.json b/lexicons/pub/leaflet/blocks/orderedList.json new file mode 100644 index 00000000..48bd35b0 --- /dev/null +++ b/lexicons/pub/leaflet/blocks/orderedList.json @@ -0,0 +1,54 @@ +{ + "lexicon": 1, + "id": "pub.leaflet.blocks.orderedList", + "defs": { + "main": { + "type": "object", + "required": ["children"], + "properties": { + "startIndex": { + "type": "integer", + "description": "The starting number for this ordered list. Defaults to 1 if not specified." + }, + "children": { + "type": "array", + "items": { + "type": "ref", + "ref": "#listItem" + } + } + } + }, + "listItem": { + "type": "object", + "required": ["content"], + "properties": { + "checked": { + "type": "boolean", + "description": "If present, this item is a checklist item. true = checked, false = unchecked. If absent, this is a normal list item." + }, + "content": { + "type": "union", + "refs": [ + "pub.leaflet.blocks.text", + "pub.leaflet.blocks.header", + "pub.leaflet.blocks.image" + ] + }, + "children": { + "type": "array", + "description": "Nested ordered list items. Mutually exclusive with unorderedListChildren; if both are present, children takes precedence.", + "items": { + "type": "ref", + "ref": "#listItem" + } + }, + "unorderedListChildren": { + "type": "ref", + "description": "A nested unordered list. Mutually exclusive with children; if both are present, children takes precedence.", + "ref": "pub.leaflet.blocks.unorderedList" + } + } + } + } +} diff --git a/lexicons/pub/leaflet/blocks/page.json b/lexicons/pub/leaflet/blocks/page.json new file mode 100644 index 00000000..40c22ee8 --- /dev/null +++ b/lexicons/pub/leaflet/blocks/page.json @@ -0,0 +1,15 @@ +{ + "lexicon": 1, + "id": "pub.leaflet.blocks.page", + "defs": { + "main": { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "string" + } + } + } + } +} diff --git a/lexicons/pub/leaflet/blocks/poll.json b/lexicons/pub/leaflet/blocks/poll.json new file mode 100644 index 00000000..f0dce1d3 --- /dev/null +++ b/lexicons/pub/leaflet/blocks/poll.json @@ -0,0 +1,16 @@ +{ + "lexicon": 1, + "id": "pub.leaflet.blocks.poll", + "defs": { + "main": { + "type": "object", + "required": ["pollRef"], + "properties": { + "pollRef": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + } + } + } + } +} diff --git a/lexicons/pub/leaflet/blocks/text.json b/lexicons/pub/leaflet/blocks/text.json new file mode 100644 index 00000000..67fcdd18 --- /dev/null +++ b/lexicons/pub/leaflet/blocks/text.json @@ -0,0 +1,26 @@ +{ + "lexicon": 1, + "id": "pub.leaflet.blocks.text", + "defs": { + "main": { + "type": "object", + "required": ["plaintext"], + "properties": { + "plaintext": { + "type": "string" + }, + "textSize": { + "type": "string", + "enum": ["default", "small", "large"] + }, + "facets": { + "type": "array", + "items": { + "type": "ref", + "ref": "pub.leaflet.richtext.facet" + } + } + } + } + } +} diff --git a/lexicons/pub/leaflet/blocks/unorderedList.json b/lexicons/pub/leaflet/blocks/unorderedList.json new file mode 100644 index 00000000..9e03d7f5 --- /dev/null +++ b/lexicons/pub/leaflet/blocks/unorderedList.json @@ -0,0 +1,50 @@ +{ + "lexicon": 1, + "id": "pub.leaflet.blocks.unorderedList", + "defs": { + "main": { + "type": "object", + "required": ["children"], + "properties": { + "children": { + "type": "array", + "items": { + "type": "ref", + "ref": "#listItem" + } + } + } + }, + "listItem": { + "type": "object", + "required": ["content"], + "properties": { + "checked": { + "type": "boolean", + "description": "If present, this item is a checklist item. true = checked, false = unchecked. If absent, this is a normal list item." + }, + "content": { + "type": "union", + "refs": [ + "pub.leaflet.blocks.text", + "pub.leaflet.blocks.header", + "pub.leaflet.blocks.image" + ] + }, + "children": { + "type": "array", + "description": "Nested unordered list items. Mutually exclusive with orderedListChildren; if both are present, children takes precedence.", + "items": { + "type": "ref", + "ref": "#listItem" + } + }, + "orderedListChildren": { + "type": "ref", + "description": "Nested ordered list items. Mutually exclusive with children; if both are present, children takes precedence.", + "ref": "pub.leaflet.blocks.orderedList" + } + } + } + } +} diff --git a/lexicons/pub/leaflet/blocks/website.json b/lexicons/pub/leaflet/blocks/website.json new file mode 100644 index 00000000..3308b5ff --- /dev/null +++ b/lexicons/pub/leaflet/blocks/website.json @@ -0,0 +1,27 @@ +{ + "lexicon": 1, + "id": "pub.leaflet.blocks.website", + "defs": { + "main": { + "type": "object", + "required": ["src"], + "properties": { + "previewImage": { + "type": "blob", + "accept": ["image/*"], + "maxSize": 1000000 + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "src": { + "type": "string", + "format": "uri" + } + } + } + } +} diff --git a/lexicons/pub/leaflet/pages/linearDocument.json b/lexicons/pub/leaflet/pages/linearDocument.json new file mode 100644 index 00000000..a8092083 --- /dev/null +++ b/lexicons/pub/leaflet/pages/linearDocument.json @@ -0,0 +1,98 @@ +{ + "lexicon": 1, + "id": "pub.leaflet.pages.linearDocument", + "defs": { + "main": { + "type": "object", + "required": ["blocks"], + "properties": { + "id": { + "type": "string" + }, + "blocks": { + "type": "array", + "items": { + "type": "ref", + "ref": "#block" + } + } + } + }, + "block": { + "type": "object", + "required": ["block"], + "properties": { + "block": { + "type": "union", + "refs": [ + "pub.leaflet.blocks.iframe", + "pub.leaflet.blocks.text", + "pub.leaflet.blocks.blockquote", + "pub.leaflet.blocks.header", + "pub.leaflet.blocks.image", + "pub.leaflet.blocks.unorderedList", + "pub.leaflet.blocks.orderedList", + "pub.leaflet.blocks.website", + "pub.leaflet.blocks.math", + "pub.leaflet.blocks.code", + "pub.leaflet.blocks.horizontalRule", + "pub.leaflet.blocks.bskyPost", + "pub.leaflet.blocks.page", + "pub.leaflet.blocks.poll", + "pub.leaflet.blocks.button" + ] + }, + "alignment": { + "type": "string", + "knownValues": [ + "#textAlignLeft", + "#textAlignCenter", + "#textAlignRight", + "#textAlignJustify" + ] + } + } + }, + "textAlignLeft": { + "type": "token" + }, + "textAlignCenter": { + "type": "token" + }, + "textAlignRight": { + "type": "token" + }, + "textAlignJustify": { + "type": "token" + }, + "quote": { + "type": "object", + "required": ["start", "end"], + "properties": { + "start": { + "type": "ref", + "ref": "#position" + }, + "end": { + "type": "ref", + "ref": "#position" + } + } + }, + "position": { + "type": "object", + "required": ["block", "offset"], + "properties": { + "block": { + "type": "array", + "items": { + "type": "integer" + } + }, + "offset": { + "type": "integer" + } + } + } + } +} diff --git a/lexicons/pub/leaflet/richtext/facet.json b/lexicons/pub/leaflet/richtext/facet.json new file mode 100644 index 00000000..62433b09 --- /dev/null +++ b/lexicons/pub/leaflet/richtext/facet.json @@ -0,0 +1,149 @@ +{ + "lexicon": 1, + "id": "pub.leaflet.richtext.facet", + "defs": { + "main": { + "type": "object", + "description": "Annotation of a sub-string within rich text.", + "required": ["index", "features"], + "properties": { + "index": { + "type": "ref", + "ref": "#byteSlice" + }, + "features": { + "type": "array", + "items": { + "type": "union", + "refs": [ + "#link", + "#didMention", + "#atMention", + "#code", + "#highlight", + "#underline", + "#strikethrough", + "#id", + "#bold", + "#italic", + "#footnote" + ] + } + } + } + }, + "byteSlice": { + "type": "object", + "description": "Specifies the sub-string range a facet feature applies to. Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text. NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets.", + "required": ["byteStart", "byteEnd"], + "properties": { + "byteStart": { + "type": "integer", + "minimum": 0 + }, + "byteEnd": { + "type": "integer", + "minimum": 0 + } + } + }, + "link": { + "type": "object", + "description": "Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL.", + "required": ["uri"], + "properties": { + "uri": { + "type": "string" + } + } + }, + "didMention": { + "type": "object", + "description": "Facet feature for mentioning a did.", + "required": ["did"], + "properties": { + "did": { + "type": "string", + "format": "did" + } + } + }, + "atMention": { + "type": "object", + "description": "Facet feature for mentioning an AT URI.", + "required": ["atURI"], + "properties": { + "atURI": { + "type": "string", + "format": "uri" + } + } + }, + "code": { + "type": "object", + "description": "Facet feature for inline code.", + "required": [], + "properties": {} + }, + "highlight": { + "type": "object", + "description": "Facet feature for highlighted text.", + "required": [], + "properties": {} + }, + "underline": { + "type": "object", + "description": "Facet feature for underline markup", + "required": [], + "properties": {} + }, + "strikethrough": { + "type": "object", + "description": "Facet feature for strikethrough markup", + "required": [], + "properties": {} + }, + "id": { + "type": "object", + "description": "Facet feature for an identifier. Used for linking to a segment", + "required": [], + "properties": { + "id": { + "type": "string" + } + } + }, + "bold": { + "type": "object", + "description": "Facet feature for bold text", + "required": [], + "properties": {} + }, + "italic": { + "type": "object", + "description": "Facet feature for italic text", + "required": [], + "properties": {} + }, + "footnote": { + "type": "object", + "description": "Facet feature for a footnote reference", + "required": ["footnoteId", "contentPlaintext"], + "properties": { + "footnoteId": { + "type": "string" + }, + "contentPlaintext": { + "type": "string" + }, + "contentFacets": { + "type": "array", + "items": { + "type": "ref", + "ref": "#main" + } + } + } + } + } +} diff --git a/package-lock.json b/package-lock.json index 7bbe2b2c..1cbf3660 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,6 @@ "version": "0.10.0", "license": "MIT", "dependencies": { - "@atcute/bluesky": "^3.2.15", - "@atcute/leaflet": "^1.0.15", "@atproto/lexicon": "^0.6.0", "multiformats": "^13.3.6" }, @@ -47,62 +45,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@atcute/atproto": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/@atcute/atproto/-/atproto-3.1.10.tgz", - "integrity": "sha512-+GKZpOc0PJcdWMQEkTfg/rSNDAAHxmAUGBl60g2az15etqJn5WaUPNGFE2sB7hKpwi5Ue2h/L0OacINcE/JDDQ==", - "license": "0BSD", - "dependencies": { - "@atcute/lexicons": "^1.2.6" - } - }, - "node_modules/@atcute/bluesky": { - "version": "3.2.15", - "resolved": "https://registry.npmjs.org/@atcute/bluesky/-/bluesky-3.2.15.tgz", - "integrity": "sha512-H4RW3WffjfdKvOZ9issEUQnuSR4KfuAwwJnYu0fclA9VDa99JTJ+pa8tTl9lFeBV9DINtWJAx7rdIbICoVCstQ==", - "license": "0BSD", - "dependencies": { - "@atcute/atproto": "^3.1.10", - "@atcute/lexicons": "^1.2.6" - } - }, - "node_modules/@atcute/leaflet": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/@atcute/leaflet/-/leaflet-1.0.15.tgz", - "integrity": "sha512-soA4jGsY0UxTar8rhtfQNYFXPI+qoQGgjpeMKJaO8ZCBBMRoo4OU9gsFJpUm/BYSocOBvvWSq2GV9pYEgUehTw==", - "license": "0BSD", - "dependencies": { - "@atcute/atproto": "^3.1.10", - "@atcute/lexicons": "^1.2.6" - } - }, - "node_modules/@atcute/lexicons": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@atcute/lexicons/-/lexicons-1.2.6.tgz", - "integrity": "sha512-s76UQd8D+XmHIzrjD9CJ9SOOeeLPHc+sMmcj7UFakAW/dDFXc579fcRdRfuUKvXBL5v1Gs2VgDdlh/IvvQZAwA==", - "license": "0BSD", - "dependencies": { - "@atcute/uint8array": "^1.0.6", - "@atcute/util-text": "^0.0.1", - "@standard-schema/spec": "^1.1.0", - "esm-env": "^1.2.2" - } - }, - "node_modules/@atcute/uint8array": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@atcute/uint8array/-/uint8array-1.0.6.tgz", - "integrity": "sha512-ucfRBQc7BFT8n9eCyGOzDHEMKF/nZwhS2pPao4Xtab1ML3HdFYcX2DM1tadCzas85QTGxHe5urnUAAcNKGRi9A==", - "license": "0BSD" - }, - "node_modules/@atcute/util-text": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/@atcute/util-text/-/util-text-0.0.1.tgz", - "integrity": "sha512-t1KZqvn0AYy+h2KcJyHnKF9aEqfRfMUmyY8j1ELtAEIgqN9CxINAjxnoRCJIFUlvWzb+oY3uElQL/Vyk3yss0g==", - "license": "0BSD", - "dependencies": { - "unicode-segmenter": "^0.14.4" - } - }, "node_modules/@atproto/common-web": { "version": "0.4.9", "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.9.tgz", @@ -1906,6 +1848,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, "license": "MIT" }, "node_modules/@ts-morph/common": { @@ -2947,12 +2890,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/esm-env": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", - "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "license": "MIT" - }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", diff --git a/package.json b/package.json index 96e9fa99..2877813b 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "check": "npm run gen-api && npm run lint && npm run typecheck && npm run build && npm run test", "build": "rollup -c && npm run build:types", "build:types": "tsc --project tsconfig.build.json", - "gen-api": "find ./lexicons -name '*.json' | xargs lex gen-api --yes ./generated && ./scripts/create-shims.sh && npm run gen-index", + "gen-api": "find ./lexicons -name '*.json' | xargs lex gen-api --yes ./generated && npm run gen-index", "gen-md": "find ./lexicons -name '*.json' | xargs lex gen-md --yes ./lexicons.md", "gen-schemas-md": "node ./scripts/generate-schemas.js", "//gen-ts": "UNUSED - use gen-api instead", @@ -65,8 +65,6 @@ "prepare": "husky && ./scripts/setup-merge-driver.sh" }, "dependencies": { - "@atcute/bluesky": "^3.2.15", - "@atcute/leaflet": "^1.0.15", "@atproto/lexicon": "^0.6.0", "multiformats": "^13.3.6" }, diff --git a/scripts/create-shims.sh b/scripts/create-shims.sh deleted file mode 100755 index 265666d7..00000000 --- a/scripts/create-shims.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/sh -# Create type shims for external lexicon references - -# @atcute/leaflet - Leaflet document types -mkdir -p ./generated/types/pub/leaflet/pages -cat >./generated/types/pub/leaflet/pages/linearDocument.ts <<'EOF' -// Type shim for @atcute/leaflet external lexicons -// This allows our generated code to resolve Leaflet type references -export * from '@atcute/leaflet/types/pages/linearDocument'; -EOF - -# app.bsky.richtext.facet - ATProto richtext facet types -mkdir -p ./generated/types/app/bsky/richtext -cat >./generated/types/app/bsky/richtext/facet.ts <<'EOF' -// Type shim for app.bsky.richtext.facet lexicon -// Re-exports from @atcute/bluesky which provides ATProto standard lexicon types -import type { AppBskyRichtextFacet } from '@atcute/bluesky'; -export type { AppBskyRichtextFacet }; -export type Main = AppBskyRichtextFacet.Main; -export type ByteSlice = AppBskyRichtextFacet.ByteSlice; -export type Link = AppBskyRichtextFacet.Link; -export type Mention = AppBskyRichtextFacet.Mention; -export type Tag = AppBskyRichtextFacet.Tag; -EOF - -echo "Created type shims for external lexicons" diff --git a/scripts/generate-exports.js b/scripts/generate-exports.js index 0f167a47..34498011 100644 --- a/scripts/generate-exports.js +++ b/scripts/generate-exports.js @@ -68,9 +68,24 @@ function pathToImportName(filePath) { return `${parentName.toUpperCase()}_DEFS_LEXICON_JSON`; } - // For external lexicons (com.atproto.*), use just the base name - if (filePath.startsWith("com/")) { - return `${splitCamelCase(baseName).toUpperCase()}_LEXICON_JSON`; + // For external lexicons (com.atproto.*, app.bsky.*, pub.leaflet.*), + // use enough path segments to avoid collisions between namespaces + if ( + filePath.startsWith("com/") || + filePath.startsWith("app/bsky/") || + filePath.startsWith("pub/") + ) { + const parts = parentDir.split("/"); + // Use last two meaningful path segments + baseName for disambiguation + // e.g., app/bsky/richtext/facet -> BSKY_RICHTEXT_FACET + // pub/leaflet/richtext/facet -> LEAFLET_RICHTEXT_FACET + // pub/leaflet/blocks/text -> LEAFLET_BLOCKS_TEXT + // com/atproto/repo/strongRef -> STRONG_REF (keep existing behavior) + if (filePath.startsWith("com/")) { + return `${splitCamelCase(baseName).toUpperCase()}_LEXICON_JSON`; + } + const prefix = parts.slice(1).join("_"); // skip top-level (app/pub/com) + return `${prefix.toUpperCase()}_${splitCamelCase(baseName).toUpperCase()}_LEXICON_JSON`; } // For files in subdirectories, include the subdirectory name diff --git a/scripts/generate-schemas.js b/scripts/generate-schemas.js index 1fb7e4d1..3598ea7e 100644 --- a/scripts/generate-schemas.js +++ b/scripts/generate-schemas.js @@ -53,7 +53,7 @@ function getComments(prop) { if (prop.maxGraphemes) comments.push(`maxGraphemes: ${prop.maxGraphemes}`); if (prop.maxSize) comments.push(`maxSize: ${prop.maxSize}`); if (prop.accept?.length > 0) - comments.push(`accepts: ${prop.accept.join(", ")}`); + comments.push(`accepts: ${prop.accept.map((a) => `\`${a}\``).join(", ")}`); if (prop.knownValues) { comments.push( `Known values: ${prop.knownValues.map((v) => `\`${v}\``).join(", ")}`, @@ -252,18 +252,20 @@ function generateMainSection(mainDef, lexicon) { const propsSource = mainDef.record || mainDef; const hasProperties = propsSource.properties !== undefined; + let hasPropertyRows = false; if (hasProperties) { - output.push("#### Properties", ""); const required = propsSource.required || []; const rows = extractPropertyRows(propsSource, required, lexicon.data.defs); + output.push("#### Properties", ""); if (rows.length > 0) { + hasPropertyRows = true; const hasComments = rows.some((r) => r.comments); output.push(...renderTable(rows, hasComments)); } } - return { output, hasProperties }; + return { output, hasProperties, hasPropertyRows }; } function generateAdditionalDefsSection(lexicon, hasPropertiesBefore = false) { @@ -313,10 +315,12 @@ function generateLexiconSection(lexicon, isFirst = false) { // Use lexicon description if no main, otherwise use main's description let hasProperties = false; + let hasPropertyRows = false; if (mainDef) { const mainResult = generateMainSection(mainDef, lexicon); output.push(...mainResult.output); hasProperties = mainResult.hasProperties; + hasPropertyRows = mainResult.hasPropertyRows; } else { output.push(...generateDescription(lexicon.data.description)); } @@ -327,7 +331,7 @@ function generateLexiconSection(lexicon, isFirst = false) { // Add trailing blank unless we end with just description/key (no tables) const hasAdditionalDefs = additionalDefs.length > 0; - if (hasProperties || hasAdditionalDefs) { + if (hasPropertyRows || hasAdditionalDefs) { output.push(""); } diff --git a/tests/validate-external-lexicons.test.ts b/tests/validate-external-lexicons.test.ts new file mode 100644 index 00000000..9ce32d1e --- /dev/null +++ b/tests/validate-external-lexicons.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect } from "vitest"; +import { validate, ids } from "../generated/lexicons.js"; +import * as Activity from "../generated/types/org/hypercerts/claim/activity.js"; +import * as Collection from "../generated/types/org/hypercerts/collection.js"; +import * as Attachment from "../generated/types/org/hypercerts/context/attachment.js"; +import * as Measurement from "../generated/types/org/hypercerts/context/measurement.js"; + +/** + * Minimal valid Leaflet richtext facet (bold annotation). + * Exercises pub.leaflet.richtext.facet at runtime. + */ +const leafletFacet = { + index: { byteStart: 0, byteEnd: 9 }, + features: [{ $type: "pub.leaflet.richtext.facet#bold" as const }], +}; + +/** + * Minimal valid Leaflet linearDocument (a single text block with a facet). + */ +const leafletDescription = { + $type: "pub.leaflet.pages.linearDocument" as const, + blocks: [ + { + $type: "pub.leaflet.pages.linearDocument#block" as const, + block: { + $type: "pub.leaflet.blocks.text" as const, + plaintext: "A rich-text description for testing.", + facets: [leafletFacet], + }, + }, + ], +}; + +/** + * Minimal valid Bluesky richtext facet (a link annotation). + */ +const bskyFacets = [ + { + index: { byteStart: 0, byteEnd: 4 }, + features: [ + { + $type: "app.bsky.richtext.facet#link" as const, + uri: "https://example.com", + }, + ], + }, +]; + +describe("external lexicon validation: pub.leaflet + app.bsky.richtext", () => { + describe("org.hypercerts.claim.activity", () => { + it("validates with a Leaflet description", () => { + const result = Activity.validateMain({ + $type: ids.OrgHypercertsClaimActivity, + title: "Reforestation 2024", + shortDescription: "Test activity", + createdAt: new Date().toISOString(), + description: leafletDescription, + }); + expect(result.success).toBe(true); + }); + + it("validates with Bluesky richtext facets", () => { + const result = Activity.validateMain({ + $type: ids.OrgHypercertsClaimActivity, + title: "Reforestation 2024", + shortDescription: "Link here and more", + createdAt: new Date().toISOString(), + shortDescriptionFacets: bskyFacets, + }); + expect(result.success).toBe(true); + }); + + it("validates with both description and facets", () => { + const result = Activity.validateMain({ + $type: ids.OrgHypercertsClaimActivity, + title: "Reforestation 2024", + shortDescription: "Link here and more", + createdAt: new Date().toISOString(), + description: leafletDescription, + shortDescriptionFacets: bskyFacets, + }); + expect(result.success).toBe(true); + }); + + it("rejects an invalid Leaflet description (missing blocks)", () => { + const result = validate( + { + $type: ids.OrgHypercertsClaimActivity, + title: "Test", + shortDescription: "Test", + createdAt: new Date().toISOString(), + description: { + $type: "pub.leaflet.pages.linearDocument", + // blocks is required but missing + }, + }, + ids.OrgHypercertsClaimActivity, + "main", + true, + ); + expect(result.success).toBe(false); + }); + }); + + describe("org.hypercerts.collection", () => { + it("validates with a Leaflet description", () => { + const result = Collection.validateMain({ + $type: ids.OrgHypercertsCollection, + title: "Test Collection", + createdAt: new Date().toISOString(), + description: leafletDescription, + }); + expect(result.success).toBe(true); + }); + + it("validates with Bluesky richtext facets", () => { + const result = Collection.validateMain({ + $type: ids.OrgHypercertsCollection, + title: "Test Collection", + shortDescription: "Link here", + createdAt: new Date().toISOString(), + shortDescriptionFacets: bskyFacets, + }); + expect(result.success).toBe(true); + }); + }); + + describe("org.hypercerts.context.attachment", () => { + it("validates with a Leaflet description", () => { + const result = Attachment.validateMain({ + $type: ids.OrgHypercertsContextAttachment, + title: "Evidence document", + createdAt: new Date().toISOString(), + description: leafletDescription, + }); + expect(result.success).toBe(true); + }); + + it("validates with Bluesky richtext facets", () => { + const result = Attachment.validateMain({ + $type: ids.OrgHypercertsContextAttachment, + title: "Evidence document", + shortDescription: "Link here", + createdAt: new Date().toISOString(), + shortDescriptionFacets: bskyFacets, + }); + expect(result.success).toBe(true); + }); + }); + + describe("org.hypercerts.context.measurement", () => { + it("validates with Bluesky richtext comment facets", () => { + const result = Measurement.validateMain({ + $type: ids.OrgHypercertsContextMeasurement, + metric: "carbon_sequestered", + unit: "tonnes_co2e", + value: "150.5", + createdAt: new Date().toISOString(), + comment: "Link here for details", + commentFacets: bskyFacets, + }); + expect(result.success).toBe(true); + }); + }); +});