Barazo lexicon schemas and TypeScript types barazo.forum
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(lexicons): AT Protocol schemas, types, and validation (Phase 2) (#4)

* feat(lexicons): add AT Protocol lexicon schemas, types, and validation

Phase 2 complete (M1-M4):

- 4 core lexicon JSON schemas (topic/post, topic/reply, interaction/reaction,
actor/preferences) plus shared defs and AT Protocol base lexicons
- Generated TypeScript types via @atproto/lex-cli with type guards and
validators, fixup script for NodeNext import extensions
- Hand-written Zod validation schemas mirroring all lexicon constraints
- 67 tests covering schema structure, Zod boundaries, and type exports
- CI workflow (lint, typecheck, test, build) and publish workflow
(GitHub Packages on tag push)
- Package configured as @atgora-forum/lexicons v0.1.0

* fix(ci): add packageManager field and drop frozen-lockfile

pnpm/action-setup@v4 requires pnpm version via packageManager field.
Workspace lockfile lives at the root, not per-package, so
--frozen-lockfile fails on standalone CI checkout.

* fix(ci): drop pnpm cache (no lockfile in standalone repo)

setup-node cache requires pnpm-lock.yaml which lives at the workspace
root, not in this package. Caching can be re-added if a standalone
lockfile is committed later.

* fix(ci): add @types/node for standalone CI type resolution

Test files use node:fs/promises and node:path which need @types/node.
Works locally via workspace hoisting but missing on standalone CI checkout.

authored by

Guido X Jansen and committed by
GitHub
083f6e37 c763027e

+2512 -113
+39
.github/workflows/ci.yml
··· 1 + name: CI 2 + 3 + on: 4 + push: 5 + branches: [main] 6 + pull_request: 7 + branches: [main] 8 + 9 + permissions: 10 + contents: read 11 + 12 + jobs: 13 + check: 14 + name: Lint, Typecheck, Test 15 + runs-on: ubuntu-latest 16 + 17 + steps: 18 + - uses: actions/checkout@v4 19 + 20 + - uses: pnpm/action-setup@v4 21 + 22 + - uses: actions/setup-node@v4 23 + with: 24 + node-version: 24 25 + 26 + - name: Install dependencies 27 + run: pnpm install 28 + 29 + - name: Lint 30 + run: pnpm lint 31 + 32 + - name: Typecheck 33 + run: pnpm typecheck 34 + 35 + - name: Test 36 + run: pnpm test 37 + 38 + - name: Build 39 + run: pnpm build
+33
.github/workflows/publish.yml
··· 1 + name: Publish Package 2 + 3 + on: 4 + push: 5 + tags: 6 + - "v*" 7 + 8 + permissions: 9 + contents: read 10 + packages: write 11 + 12 + jobs: 13 + publish: 14 + runs-on: ubuntu-latest 15 + steps: 16 + - uses: actions/checkout@v4 17 + 18 + - uses: pnpm/action-setup@v4 19 + 20 + - uses: actions/setup-node@v4 21 + with: 22 + node-version: "24" 23 + registry-url: "https://npm.pkg.github.com" 24 + 25 + - run: pnpm install 26 + 27 + - run: pnpm build 28 + 29 + - run: pnpm test 30 + 31 + - run: pnpm publish --no-git-checks 32 + env: 33 + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1
.npmrc
··· 1 + @atgora-forum:registry=https://npm.pkg.github.com
+55 -113
README.md
··· 1 - <div align="center"> 1 + # @atgora-forum/lexicons 2 2 3 - <picture> 4 - <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/atgora-forum/.github/main/assets/logo-dark.svg"> 5 - <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/atgora-forum/.github/main/assets/logo-light.svg"> 6 - <img alt="ATgora Logo" src="https://raw.githubusercontent.com/atgora-forum/.github/main/assets/logo-dark.svg" width="120"> 7 - </picture> 3 + AT Protocol lexicon schemas and generated TypeScript types for the ATgora forum platform. Defines the `forum.atgora.*` namespace with record types for topics, replies, reactions, and user preferences. 8 4 9 - # atgora-lexicons 5 + ## Installation 10 6 11 - **AT Protocol schemas for ATgora forums** 7 + For npm consumers outside the workspace, configure GitHub Packages access in `.npmrc`: 12 8 13 - [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 14 - [![npm](https://img.shields.io/badge/npm-%40atgora--forum%2Flexicons-blue)](https://github.com/atgora-forum/atgora-lexicons/packages) 15 - 16 - </div> 17 - [![npm](https://img.shields.io/badge/npm-%40atgora--forum%2Flexicons-blue)](https://github.com/atgora-forum/atgora-lexicons/packages) 18 - 19 - </div> 20 - 21 - --- 9 + ``` 10 + @atgora-forum:registry=https://npm.pkg.github.com 11 + ``` 22 12 23 - ## 🚧 Status: Pre-Alpha Development 13 + Then install: 24 14 25 - AT Protocol lexicon schemas for the ATgora forum platform. 15 + ```bash 16 + pnpm add @atgora-forum/lexicons 17 + ``` 26 18 27 - **Current phase:** Planning complete, schema design starting Q1 2026 19 + For workspace consumers (`atgora-api`, `atgora-web`): already linked via pnpm workspace. 28 20 29 - --- 21 + ## Usage 30 22 31 - ## What is this? 23 + ### Generated Types 32 24 33 - The atgora-lexicons repo defines the **data format** for forum content on the AT Protocol. It produces: 25 + ```typescript 26 + import { ForumAtgoraTopicPost } from "@atgora-forum/lexicons"; 34 27 35 - 1. **Lexicon JSON schemas** - AT Protocol record definitions 36 - 2. **TypeScript types** - Generated from schemas 37 - 3. **Zod validation schemas** - Runtime validation 38 - 4. **npm package** - `@atgora-forum/lexicons` used by atgora-api and atgora-web 28 + // Type for a topic post record 29 + type Post = ForumAtgoraTopicPost.Record; 39 30 40 - **This is the source of truth** for all `forum.atgora.*` record types. 31 + // Type guard 32 + if (ForumAtgoraTopicPost.isRecord(record)) { 33 + console.log(record.title); 34 + } 41 35 42 - --- 36 + // Validate against lexicon schema 37 + const result = ForumAtgoraTopicPost.validateRecord(record); 38 + ``` 43 39 44 - ## Record Types (MVP) 40 + ### Zod Validation 45 41 46 - **Core records:** 47 - - `forum.atgora.topic.post` - Forum topics 48 - - `forum.atgora.topic.reply` - Replies to topics 49 - - `forum.atgora.interaction.reaction` - Reactions (likes, etc.) 50 - - `forum.atgora.actor.preferences` - User moderation preferences (stored on user's PDS) 42 + ```typescript 43 + import { topicPostSchema } from "@atgora-forum/lexicons"; 51 44 52 - **Archive records (Phase 5):** 53 - - `forum.atgora.archivedPost` - Migrated content from legacy forums 45 + const result = topicPostSchema.safeParse(input); 46 + if (result.success) { 47 + // result.data is typed as TopicPostInput 48 + } 49 + ``` 54 50 55 - --- 51 + ### Lexicon IDs 56 52 57 - ## Installation 53 + ```typescript 54 + import { LEXICON_IDS, ids } from "@atgora-forum/lexicons"; 58 55 59 - ```bash 60 - # From GitHub Packages 61 - npm config set @atgora-forum:registry https://npm.pkg.github.com 62 - pnpm add @atgora-forum/lexicons 56 + LEXICON_IDS.TopicPost // "forum.atgora.topic.post" 57 + ids.ForumAtgoraTopicPost // "forum.atgora.topic.post" 63 58 ``` 64 59 65 - --- 66 - 67 - ## Usage 60 + ### Raw Lexicon Schemas 68 61 69 62 ```typescript 70 - import { TopicPost, topicPostSchema } from '@atgora-forum/lexicons' 71 - 72 - // TypeScript type 73 - const post: TopicPost = { 74 - $type: 'forum.atgora.topic.post', 75 - title: 'Hello World', 76 - content: 'This is a forum topic', 77 - forum: 'did:plc:abc123', 78 - category: 'general', 79 - createdAt: new Date().toISOString() 80 - } 81 - 82 - // Runtime validation 83 - const validated = topicPostSchema.parse(post) 63 + import { schemas } from "@atgora-forum/lexicons"; 64 + // Array of LexiconDoc objects for all forum.atgora.* schemas 84 65 ``` 85 66 86 - --- 67 + ## Record Types 87 68 88 - ## Development 69 + | Lexicon ID | Description | Key Type | 70 + |------------|-------------|----------| 71 + | `forum.atgora.topic.post` | Forum topic/thread (title, content, community, category, tags, labels) | `tid` | 72 + | `forum.atgora.topic.reply` | Reply to a topic (content, root ref, parent ref, community) | `tid` | 73 + | `forum.atgora.interaction.reaction` | Reaction to content (subject ref, type, community) | `tid` | 74 + | `forum.atgora.actor.preferences` | User preferences singleton (maturity level, muted words, blocked DIDs, cross-post defaults) | `literal:self` | 89 75 90 - **Prerequisites:** 91 - - Node.js 24 LTS 92 - - pnpm 76 + ## Development 93 77 94 - **Setup:** 95 78 ```bash 96 - git clone https://github.com/atgora-forum/atgora-lexicons.git 97 - cd atgora-lexicons 98 79 pnpm install 99 - ``` 100 - 101 - **Commands:** 102 - ```bash 103 - pnpm test # Validate schemas + run tests 104 - pnpm test:schemas # Validate lexicon JSON files 105 - pnpm test:types # Verify TypeScript generation 106 - pnpm build # Generate TypeScript types 80 + pnpm test # Run tests 81 + pnpm build # Compile TypeScript 82 + pnpm generate # Regenerate types from lexicon JSON 83 + pnpm lint # Lint 84 + pnpm typecheck # Type check 107 85 ``` 108 86 109 - --- 110 - 111 - ## Versioning 112 - 113 - **Independent semver** - Lexicons version separately from API/Web. 114 - 115 - - **MAJOR:** Breaking schema changes (rare - create new record type instead) 116 - - **MINOR:** Add optional fields 117 - - **PATCH:** Documentation, no schema changes 118 - 119 - **Breaking changes:** Create new lexicon ID (e.g., `forum.atgora.topic.postV2`) 120 - 121 - See [standards/shared.md](https://github.com/atgora-forum/atgora-forum/blob/main/standards/shared.md#versioning--release-strategy) for full versioning strategy. 122 - 123 - --- 124 - 125 87 ## License 126 88 127 - **MIT** - Maximum adoption. We want the `forum.atgora.*` namespace to become a true open standard. 128 - 129 - --- 130 - 131 - ## Related Repositories 132 - 133 - - **[atgora-api](https://github.com/atgora-forum/atgora-api)** - Consumes these types for validation 134 - - **[atgora-web](https://github.com/atgora-forum/atgora-web)** - Uses types for API responses 135 - - **[Organization](https://github.com/atgora-forum)** - All repos 136 - 137 - --- 138 - 139 - ## Community 140 - 141 - - 🌐 **Website:** [atgora.forum](https://atgora.forum) (coming soon) 142 - - 💬 **Discussions:** [GitHub Discussions](https://github.com/orgs/atgora-forum/discussions) 143 - - 📖 **AT Protocol Docs:** [atproto.com](https://atproto.com/) 144 - 145 - --- 146 - 147 - © 2026 ATgora. Licensed under MIT. 89 + MIT
+35
eslint.config.js
··· 1 + import tseslint from "typescript-eslint"; 2 + 3 + export default tseslint.config( 4 + ...tseslint.configs.strictTypeChecked, 5 + { 6 + rules: { 7 + "no-console": "error", 8 + "@typescript-eslint/no-explicit-any": "error", 9 + "@typescript-eslint/no-unused-vars": [ 10 + "error", 11 + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, 12 + ], 13 + "@typescript-eslint/consistent-type-imports": [ 14 + "error", 15 + { prefer: "type-imports" }, 16 + ], 17 + }, 18 + }, 19 + { 20 + ignores: ["dist/", "node_modules/", "*.config.*"], 21 + }, 22 + { 23 + languageOptions: { 24 + parserOptions: { 25 + projectService: { 26 + allowDefaultProject: ["tests/*.ts"], 27 + }, 28 + tsconfigRootDir: import.meta.dirname, 29 + }, 30 + }, 31 + }, 32 + { 33 + ignores: ["src/generated/**"], 34 + }, 35 + );
+156
lexicons/com/atproto/label/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.label.defs", 4 + "defs": { 5 + "label": { 6 + "type": "object", 7 + "description": "Metadata tag on an atproto resource (eg, repo or record).", 8 + "required": ["src", "uri", "val", "cts"], 9 + "properties": { 10 + "ver": { 11 + "type": "integer", 12 + "description": "The AT Protocol version of the label object." 13 + }, 14 + "src": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the actor who created this label." 18 + }, 19 + "uri": { 20 + "type": "string", 21 + "format": "uri", 22 + "description": "AT URI of the record, repository (account), or other resource that this label applies to." 23 + }, 24 + "cid": { 25 + "type": "string", 26 + "format": "cid", 27 + "description": "Optionally, CID specifying the specific version of 'uri' resource this label applies to." 28 + }, 29 + "val": { 30 + "type": "string", 31 + "maxLength": 128, 32 + "description": "The short string name of the value or type of this label." 33 + }, 34 + "neg": { 35 + "type": "boolean", 36 + "description": "If true, this is a negation label, overwriting a previous label." 37 + }, 38 + "cts": { 39 + "type": "string", 40 + "format": "datetime", 41 + "description": "Timestamp when this label was created." 42 + }, 43 + "exp": { 44 + "type": "string", 45 + "format": "datetime", 46 + "description": "Timestamp at which this label expires (no longer applies)." 47 + }, 48 + "sig": { 49 + "type": "bytes", 50 + "description": "Signature of dag-cbor encoded label." 51 + } 52 + } 53 + }, 54 + "selfLabels": { 55 + "type": "object", 56 + "description": "Metadata tags on an atproto record, published by the author within the record.", 57 + "required": ["values"], 58 + "properties": { 59 + "values": { 60 + "type": "array", 61 + "items": { "type": "ref", "ref": "#selfLabel" }, 62 + "maxLength": 10 63 + } 64 + } 65 + }, 66 + "selfLabel": { 67 + "type": "object", 68 + "description": "Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel.", 69 + "required": ["val"], 70 + "properties": { 71 + "val": { 72 + "type": "string", 73 + "maxLength": 128, 74 + "description": "The short string name of the value or type of this label." 75 + } 76 + } 77 + }, 78 + "labelValueDefinition": { 79 + "type": "object", 80 + "description": "Declares a label value and its expected interpretations and behaviors.", 81 + "required": ["identifier", "severity", "blurs", "locales"], 82 + "properties": { 83 + "identifier": { 84 + "type": "string", 85 + "description": "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).", 86 + "maxLength": 100, 87 + "maxGraphemes": 100 88 + }, 89 + "severity": { 90 + "type": "string", 91 + "description": "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.", 92 + "knownValues": ["inform", "alert", "none"] 93 + }, 94 + "blurs": { 95 + "type": "string", 96 + "description": "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.", 97 + "knownValues": ["content", "media", "none"] 98 + }, 99 + "defaultSetting": { 100 + "type": "string", 101 + "description": "The default setting for this label.", 102 + "knownValues": ["ignore", "warn", "hide"], 103 + "default": "warn" 104 + }, 105 + "adultOnly": { 106 + "type": "boolean", 107 + "description": "Does the user need to have adult content enabled in order to configure this label?" 108 + }, 109 + "locales": { 110 + "type": "array", 111 + "items": { "type": "ref", "ref": "#labelValueDefinitionStrings" } 112 + } 113 + } 114 + }, 115 + "labelValueDefinitionStrings": { 116 + "type": "object", 117 + "description": "Strings which describe the label in the UI, localized into a specific language.", 118 + "required": ["lang", "name", "description"], 119 + "properties": { 120 + "lang": { 121 + "type": "string", 122 + "description": "The code of the language these strings are written in.", 123 + "format": "language" 124 + }, 125 + "name": { 126 + "type": "string", 127 + "description": "A short human-readable name for the label.", 128 + "maxGraphemes": 64, 129 + "maxLength": 640 130 + }, 131 + "description": { 132 + "type": "string", 133 + "description": "A longer description of what the label means and why it might be applied.", 134 + "maxGraphemes": 10000, 135 + "maxLength": 100000 136 + } 137 + } 138 + }, 139 + "labelValue": { 140 + "type": "string", 141 + "knownValues": [ 142 + "!hide", 143 + "!no-promote", 144 + "!warn", 145 + "!no-unauthenticated", 146 + "dmca-violation", 147 + "doxxing", 148 + "porn", 149 + "sexual", 150 + "nudity", 151 + "nsfl", 152 + "gore" 153 + ] 154 + } 155 + } 156 + }
+15
lexicons/com/atproto/repo/strongRef.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.repo.strongRef", 4 + "description": "A URI with a content-hash fingerprint.", 5 + "defs": { 6 + "main": { 7 + "type": "object", 8 + "required": ["uri", "cid"], 9 + "properties": { 10 + "uri": { "type": "string", "format": "at-uri" }, 11 + "cid": { "type": "string", "format": "cid" } 12 + } 13 + } 14 + } 15 + }
+63
lexicons/forum/atgora/actor/preferences.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "forum.atgora.actor.preferences", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "User-level moderation and safety preferences. Portable across AppViews.", 8 + "key": "literal:self", 9 + "record": { 10 + "type": "object", 11 + "required": ["maturityLevel", "updatedAt"], 12 + "properties": { 13 + "maturityLevel": { 14 + "type": "string", 15 + "enum": ["safe", "mature", "all"], 16 + "description": "Maximum maturity tier to show. Default: 'safe'." 17 + }, 18 + "mutedWords": { 19 + "type": "array", 20 + "maxLength": 100, 21 + "items": { "type": "string", "maxLength": 1000, "maxGraphemes": 100 }, 22 + "description": "Global muted words (apply to all communities)." 23 + }, 24 + "blockedDids": { 25 + "type": "array", 26 + "maxLength": 1000, 27 + "items": { "type": "string", "format": "did" }, 28 + "description": "Blocked accounts (content hidden everywhere)." 29 + }, 30 + "mutedDids": { 31 + "type": "array", 32 + "maxLength": 1000, 33 + "items": { "type": "string", "format": "did" }, 34 + "description": "Muted accounts (content de-emphasized, collapsed but visible)." 35 + }, 36 + "crossPostDefaults": { 37 + "type": "ref", 38 + "ref": "#crossPostConfig", 39 + "description": "Per-service toggle for cross-posting new topics." 40 + }, 41 + "updatedAt": { 42 + "type": "string", 43 + "format": "datetime", 44 + "description": "Client-declared timestamp when preferences were last updated." 45 + } 46 + } 47 + } 48 + }, 49 + "crossPostConfig": { 50 + "type": "object", 51 + "properties": { 52 + "bluesky": { 53 + "type": "boolean", 54 + "description": "Cross-post new topics to Bluesky. Default: false." 55 + }, 56 + "frontpage": { 57 + "type": "boolean", 58 + "description": "Cross-post new topics to Frontpage. Default: false." 59 + } 60 + } 61 + } 62 + } 63 + }
+6
lexicons/forum/atgora/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "forum.atgora.defs", 4 + "description": "Shared type definitions for ATgora forum lexicons. Reserved for future reusable types.", 5 + "defs": {} 6 + }
+39
lexicons/forum/atgora/interaction/reaction.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "forum.atgora.interaction.reaction", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Record containing a reaction to a forum topic or reply.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["subject", "type", "community", "createdAt"], 12 + "properties": { 13 + "subject": { 14 + "type": "ref", 15 + "ref": "com.atproto.repo.strongRef", 16 + "description": "The topic or reply being reacted to." 17 + }, 18 + "type": { 19 + "type": "string", 20 + "minLength": 1, 21 + "maxLength": 300, 22 + "maxGraphemes": 30, 23 + "description": "Reaction type (e.g., 'like', 'heart', 'thumbsup'). Must match community's configured reaction set." 24 + }, 25 + "community": { 26 + "type": "string", 27 + "format": "did", 28 + "description": "DID of the community where this reaction was created. Immutable origin identifier." 29 + }, 30 + "createdAt": { 31 + "type": "string", 32 + "format": "datetime", 33 + "description": "Client-declared timestamp when this reaction was originally created." 34 + } 35 + } 36 + } 37 + } 38 + } 39 + }
+67
lexicons/forum/atgora/topic/post.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "forum.atgora.topic.post", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Record containing a forum topic post.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["title", "content", "community", "category", "createdAt"], 12 + "properties": { 13 + "title": { 14 + "type": "string", 15 + "minLength": 1, 16 + "maxLength": 2000, 17 + "maxGraphemes": 200, 18 + "description": "Topic title." 19 + }, 20 + "content": { 21 + "type": "string", 22 + "minLength": 1, 23 + "maxLength": 100000, 24 + "description": "Topic body in markdown." 25 + }, 26 + "contentFormat": { 27 + "type": "string", 28 + "enum": ["markdown"], 29 + "description": "Content format. Defaults to 'markdown' if omitted." 30 + }, 31 + "community": { 32 + "type": "string", 33 + "format": "did", 34 + "description": "DID of the community where this record was created. Immutable origin identifier for cross-community attribution." 35 + }, 36 + "category": { 37 + "type": "string", 38 + "maxLength": 640, 39 + "maxGraphemes": 64, 40 + "description": "Category rkey within the community." 41 + }, 42 + "tags": { 43 + "type": "array", 44 + "maxLength": 5, 45 + "items": { 46 + "type": "string", 47 + "minLength": 1, 48 + "maxLength": 300, 49 + "maxGraphemes": 30 50 + }, 51 + "description": "Topic tags. Lowercase alphanumeric + hyphens." 52 + }, 53 + "labels": { 54 + "type": "union", 55 + "description": "Self-label values for content maturity (e.g., sexual, nudity, graphic-media).", 56 + "refs": ["com.atproto.label.defs#selfLabels"] 57 + }, 58 + "createdAt": { 59 + "type": "string", 60 + "format": "datetime", 61 + "description": "Client-declared timestamp when this post was originally created." 62 + } 63 + } 64 + } 65 + } 66 + } 67 + }
+53
lexicons/forum/atgora/topic/reply.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "forum.atgora.topic.reply", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Record containing a reply to a forum topic or another reply.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["content", "root", "parent", "community", "createdAt"], 12 + "properties": { 13 + "content": { 14 + "type": "string", 15 + "minLength": 1, 16 + "maxLength": 50000, 17 + "description": "Reply body in markdown." 18 + }, 19 + "contentFormat": { 20 + "type": "string", 21 + "enum": ["markdown"], 22 + "description": "Content format. Defaults to 'markdown' if omitted." 23 + }, 24 + "root": { 25 + "type": "ref", 26 + "ref": "com.atproto.repo.strongRef", 27 + "description": "The original topic (AT URI of forum.atgora.topic.post)." 28 + }, 29 + "parent": { 30 + "type": "ref", 31 + "ref": "com.atproto.repo.strongRef", 32 + "description": "Direct parent (topic or reply). For top-level replies, parent == root." 33 + }, 34 + "community": { 35 + "type": "string", 36 + "format": "did", 37 + "description": "DID of the community where this reply was created. Immutable origin identifier." 38 + }, 39 + "labels": { 40 + "type": "union", 41 + "description": "Self-label values for content maturity.", 42 + "refs": ["com.atproto.label.defs#selfLabels"] 43 + }, 44 + "createdAt": { 45 + "type": "string", 46 + "format": "datetime", 47 + "description": "Client-declared timestamp when this reply was originally created." 48 + } 49 + } 50 + } 51 + } 52 + } 53 + }
+51
package.json
··· 1 + { 2 + "name": "@atgora-forum/lexicons", 3 + "version": "0.1.0", 4 + "description": "AT Protocol lexicon schemas and generated TypeScript types for the ATgora forum platform", 5 + "type": "module", 6 + "packageManager": "pnpm@10.29.2", 7 + "license": "MIT", 8 + "publishConfig": { 9 + "registry": "https://npm.pkg.github.com", 10 + "access": "public" 11 + }, 12 + "repository": { 13 + "type": "git", 14 + "url": "https://github.com/atgora-forum/atgora-lexicons.git" 15 + }, 16 + "exports": { 17 + ".": { 18 + "import": "./dist/index.js", 19 + "types": "./dist/index.d.ts" 20 + } 21 + }, 22 + "files": [ 23 + "dist", 24 + "lexicons" 25 + ], 26 + "scripts": { 27 + "build": "tsc", 28 + "typecheck": "tsc --noEmit", 29 + "lint": "eslint src/ tests/", 30 + "lint:fix": "eslint --fix src/ tests/", 31 + "test": "vitest run", 32 + "test:watch": "vitest", 33 + "test:coverage": "vitest run --coverage", 34 + "generate": "lex gen-server ./src/generated ./lexicons/**/*.json && node scripts/fixup-generated.js", 35 + "clean": "rm -rf dist" 36 + }, 37 + "dependencies": { 38 + "@atproto/lexicon": "^0.5.1", 39 + "multiformats": "^13.4.2", 40 + "zod": "^4.3.6" 41 + }, 42 + "devDependencies": { 43 + "@atproto/lex-cli": "^0.9.8", 44 + "@types/node": "^25.2.3", 45 + "@vitest/coverage-v8": "^4.0.18", 46 + "eslint": "^9.28.0", 47 + "typescript": "^5.9.3", 48 + "typescript-eslint": "^8.55.0", 49 + "vitest": "^4.0.18" 50 + } 51 + }
+73
scripts/fixup-generated.js
··· 1 + /** 2 + * Post-generation fixup script for lex-cli output. 3 + * 4 + * lex-cli generates TypeScript files with: 5 + * 1. Missing .js extensions on relative imports (incompatible with NodeNext) 6 + * 2. An XRPC server/client wrapper index.ts we don't need 7 + * 8 + * This script fixes the imports and replaces the generated index.ts 9 + * with a clean re-export file. 10 + */ 11 + import { readdir, readFile, writeFile } from "node:fs/promises"; 12 + import { join } from "node:path"; 13 + 14 + const GENERATED_DIR = new URL("../src/generated", import.meta.url).pathname; 15 + 16 + const REPLACEMENT_INDEX = `/** 17 + * GENERATED CODE - Re-exports only. 18 + * The XRPC server/client wrappers generated by lex-cli are replaced with 19 + * direct type re-exports since we use @atproto/api for PDS interactions. 20 + */ 21 + export * as ComAtprotoLabelDefs from "./types/com/atproto/label/defs.js"; 22 + export * as ComAtprotoRepoStrongRef from "./types/com/atproto/repo/strongRef.js"; 23 + export * as ForumAtgoraActorPreferences from "./types/forum/atgora/actor/preferences.js"; 24 + export * as ForumAtgoraDefs from "./types/forum/atgora/defs.js"; 25 + export * as ForumAtgoraInteractionReaction from "./types/forum/atgora/interaction/reaction.js"; 26 + export * as ForumAtgoraTopicPost from "./types/forum/atgora/topic/post.js"; 27 + export * as ForumAtgoraTopicReply from "./types/forum/atgora/topic/reply.js"; 28 + export { schemas, validate } from "./lexicons.js"; 29 + `; 30 + 31 + async function getTypeFiles(dir) { 32 + const entries = await readdir(dir, { withFileTypes: true, recursive: true }); 33 + return entries 34 + .filter((e) => e.isFile() && e.name.endsWith(".ts")) 35 + .map((e) => join(e.parentPath ?? e.path, e.name)); 36 + } 37 + 38 + async function fixImportExtensions(filePath) { 39 + let content = await readFile(filePath, "utf-8"); 40 + const original = content; 41 + 42 + // Fix relative imports missing .js extension 43 + // Match: from './foo' or from '../foo' but NOT from './foo.js' or from '@scope/pkg' 44 + content = content.replace( 45 + /from '(\.\.?\/[^']+?)(?<!\.js)'/g, 46 + "from '$1.js'", 47 + ); 48 + 49 + if (content !== original) { 50 + await writeFile(filePath, content); 51 + } 52 + } 53 + 54 + async function main() { 55 + // Replace the generated index.ts 56 + await writeFile(join(GENERATED_DIR, "index.ts"), REPLACEMENT_INDEX); 57 + 58 + // Fix import extensions in all generated type files 59 + const files = await getTypeFiles(join(GENERATED_DIR, "types")); 60 + for (const file of files) { 61 + await fixImportExtensions(file); 62 + } 63 + 64 + // Also fix lexicons.ts and util.ts 65 + await fixImportExtensions(join(GENERATED_DIR, "lexicons.ts")); 66 + await fixImportExtensions(join(GENERATED_DIR, "util.ts")); 67 + 68 + console.log( 69 + `Fixed ${files.length + 2} generated files (${files.length} types + lexicons.ts + util.ts)`, 70 + ); 71 + } 72 + 73 + main();
+13
src/generated/index.ts
··· 1 + /** 2 + * GENERATED CODE - Re-exports only. 3 + * The XRPC server/client wrappers generated by lex-cli are replaced with 4 + * direct type re-exports since we use @atproto/api for PDS interactions. 5 + */ 6 + export * as ComAtprotoLabelDefs from "./types/com/atproto/label/defs.js"; 7 + export * as ComAtprotoRepoStrongRef from "./types/com/atproto/repo/strongRef.js"; 8 + export * as ForumAtgoraActorPreferences from "./types/forum/atgora/actor/preferences.js"; 9 + export * as ForumAtgoraDefs from "./types/forum/atgora/defs.js"; 10 + export * as ForumAtgoraInteractionReaction from "./types/forum/atgora/interaction/reaction.js"; 11 + export * as ForumAtgoraTopicPost from "./types/forum/atgora/topic/post.js"; 12 + export * as ForumAtgoraTopicReply from "./types/forum/atgora/topic/reply.js"; 13 + export { schemas, validate } from "./lexicons.js";
+506
src/generated/lexicons.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { 5 + type LexiconDoc, 6 + Lexicons, 7 + ValidationError, 8 + type ValidationResult, 9 + } from '@atproto/lexicon' 10 + import { type $Typed, is$typed, maybe$typed } from './util.js' 11 + 12 + export const schemaDict = { 13 + ComAtprotoLabelDefs: { 14 + lexicon: 1, 15 + id: 'com.atproto.label.defs', 16 + defs: { 17 + label: { 18 + type: 'object', 19 + description: 20 + 'Metadata tag on an atproto resource (eg, repo or record).', 21 + required: ['src', 'uri', 'val', 'cts'], 22 + properties: { 23 + ver: { 24 + type: 'integer', 25 + description: 'The AT Protocol version of the label object.', 26 + }, 27 + src: { 28 + type: 'string', 29 + format: 'did', 30 + description: 'DID of the actor who created this label.', 31 + }, 32 + uri: { 33 + type: 'string', 34 + format: 'uri', 35 + description: 36 + 'AT URI of the record, repository (account), or other resource that this label applies to.', 37 + }, 38 + cid: { 39 + type: 'string', 40 + format: 'cid', 41 + description: 42 + "Optionally, CID specifying the specific version of 'uri' resource this label applies to.", 43 + }, 44 + val: { 45 + type: 'string', 46 + maxLength: 128, 47 + description: 48 + 'The short string name of the value or type of this label.', 49 + }, 50 + neg: { 51 + type: 'boolean', 52 + description: 53 + 'If true, this is a negation label, overwriting a previous label.', 54 + }, 55 + cts: { 56 + type: 'string', 57 + format: 'datetime', 58 + description: 'Timestamp when this label was created.', 59 + }, 60 + exp: { 61 + type: 'string', 62 + format: 'datetime', 63 + description: 64 + 'Timestamp at which this label expires (no longer applies).', 65 + }, 66 + sig: { 67 + type: 'bytes', 68 + description: 'Signature of dag-cbor encoded label.', 69 + }, 70 + }, 71 + }, 72 + selfLabels: { 73 + type: 'object', 74 + description: 75 + 'Metadata tags on an atproto record, published by the author within the record.', 76 + required: ['values'], 77 + properties: { 78 + values: { 79 + type: 'array', 80 + items: { 81 + type: 'ref', 82 + ref: 'lex:com.atproto.label.defs#selfLabel', 83 + }, 84 + maxLength: 10, 85 + }, 86 + }, 87 + }, 88 + selfLabel: { 89 + type: 'object', 90 + description: 91 + 'Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel.', 92 + required: ['val'], 93 + properties: { 94 + val: { 95 + type: 'string', 96 + maxLength: 128, 97 + description: 98 + 'The short string name of the value or type of this label.', 99 + }, 100 + }, 101 + }, 102 + labelValueDefinition: { 103 + type: 'object', 104 + description: 105 + 'Declares a label value and its expected interpretations and behaviors.', 106 + required: ['identifier', 'severity', 'blurs', 'locales'], 107 + properties: { 108 + identifier: { 109 + type: 'string', 110 + description: 111 + "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).", 112 + maxLength: 100, 113 + maxGraphemes: 100, 114 + }, 115 + severity: { 116 + type: 'string', 117 + description: 118 + "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.", 119 + knownValues: ['inform', 'alert', 'none'], 120 + }, 121 + blurs: { 122 + type: 'string', 123 + description: 124 + "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.", 125 + knownValues: ['content', 'media', 'none'], 126 + }, 127 + defaultSetting: { 128 + type: 'string', 129 + description: 'The default setting for this label.', 130 + knownValues: ['ignore', 'warn', 'hide'], 131 + default: 'warn', 132 + }, 133 + adultOnly: { 134 + type: 'boolean', 135 + description: 136 + 'Does the user need to have adult content enabled in order to configure this label?', 137 + }, 138 + locales: { 139 + type: 'array', 140 + items: { 141 + type: 'ref', 142 + ref: 'lex:com.atproto.label.defs#labelValueDefinitionStrings', 143 + }, 144 + }, 145 + }, 146 + }, 147 + labelValueDefinitionStrings: { 148 + type: 'object', 149 + description: 150 + 'Strings which describe the label in the UI, localized into a specific language.', 151 + required: ['lang', 'name', 'description'], 152 + properties: { 153 + lang: { 154 + type: 'string', 155 + description: 156 + 'The code of the language these strings are written in.', 157 + format: 'language', 158 + }, 159 + name: { 160 + type: 'string', 161 + description: 'A short human-readable name for the label.', 162 + maxGraphemes: 64, 163 + maxLength: 640, 164 + }, 165 + description: { 166 + type: 'string', 167 + description: 168 + 'A longer description of what the label means and why it might be applied.', 169 + maxGraphemes: 10000, 170 + maxLength: 100000, 171 + }, 172 + }, 173 + }, 174 + labelValue: { 175 + type: 'string', 176 + knownValues: [ 177 + '!hide', 178 + '!no-promote', 179 + '!warn', 180 + '!no-unauthenticated', 181 + 'dmca-violation', 182 + 'doxxing', 183 + 'porn', 184 + 'sexual', 185 + 'nudity', 186 + 'nsfl', 187 + 'gore', 188 + ], 189 + }, 190 + }, 191 + }, 192 + ComAtprotoRepoStrongRef: { 193 + lexicon: 1, 194 + id: 'com.atproto.repo.strongRef', 195 + description: 'A URI with a content-hash fingerprint.', 196 + defs: { 197 + main: { 198 + type: 'object', 199 + required: ['uri', 'cid'], 200 + properties: { 201 + uri: { 202 + type: 'string', 203 + format: 'at-uri', 204 + }, 205 + cid: { 206 + type: 'string', 207 + format: 'cid', 208 + }, 209 + }, 210 + }, 211 + }, 212 + }, 213 + ForumAtgoraActorPreferences: { 214 + lexicon: 1, 215 + id: 'forum.atgora.actor.preferences', 216 + defs: { 217 + main: { 218 + type: 'record', 219 + description: 220 + 'User-level moderation and safety preferences. Portable across AppViews.', 221 + key: 'literal:self', 222 + record: { 223 + type: 'object', 224 + required: ['maturityLevel', 'updatedAt'], 225 + properties: { 226 + maturityLevel: { 227 + type: 'string', 228 + enum: ['safe', 'mature', 'all'], 229 + description: "Maximum maturity tier to show. Default: 'safe'.", 230 + }, 231 + mutedWords: { 232 + type: 'array', 233 + maxLength: 100, 234 + items: { 235 + type: 'string', 236 + maxLength: 1000, 237 + maxGraphemes: 100, 238 + }, 239 + description: 'Global muted words (apply to all communities).', 240 + }, 241 + blockedDids: { 242 + type: 'array', 243 + maxLength: 1000, 244 + items: { 245 + type: 'string', 246 + format: 'did', 247 + }, 248 + description: 'Blocked accounts (content hidden everywhere).', 249 + }, 250 + mutedDids: { 251 + type: 'array', 252 + maxLength: 1000, 253 + items: { 254 + type: 'string', 255 + format: 'did', 256 + }, 257 + description: 258 + 'Muted accounts (content de-emphasized, collapsed but visible).', 259 + }, 260 + crossPostDefaults: { 261 + type: 'ref', 262 + ref: 'lex:forum.atgora.actor.preferences#crossPostConfig', 263 + description: 'Per-service toggle for cross-posting new topics.', 264 + }, 265 + updatedAt: { 266 + type: 'string', 267 + format: 'datetime', 268 + description: 269 + 'Client-declared timestamp when preferences were last updated.', 270 + }, 271 + }, 272 + }, 273 + }, 274 + crossPostConfig: { 275 + type: 'object', 276 + properties: { 277 + bluesky: { 278 + type: 'boolean', 279 + description: 'Cross-post new topics to Bluesky. Default: false.', 280 + }, 281 + frontpage: { 282 + type: 'boolean', 283 + description: 'Cross-post new topics to Frontpage. Default: false.', 284 + }, 285 + }, 286 + }, 287 + }, 288 + }, 289 + ForumAtgoraDefs: { 290 + lexicon: 1, 291 + id: 'forum.atgora.defs', 292 + description: 293 + 'Shared type definitions for ATgora forum lexicons. Reserved for future reusable types.', 294 + defs: {}, 295 + }, 296 + ForumAtgoraInteractionReaction: { 297 + lexicon: 1, 298 + id: 'forum.atgora.interaction.reaction', 299 + defs: { 300 + main: { 301 + type: 'record', 302 + description: 'Record containing a reaction to a forum topic or reply.', 303 + key: 'tid', 304 + record: { 305 + type: 'object', 306 + required: ['subject', 'type', 'community', 'createdAt'], 307 + properties: { 308 + subject: { 309 + type: 'ref', 310 + ref: 'lex:com.atproto.repo.strongRef', 311 + description: 'The topic or reply being reacted to.', 312 + }, 313 + type: { 314 + type: 'string', 315 + minLength: 1, 316 + maxLength: 300, 317 + maxGraphemes: 30, 318 + description: 319 + "Reaction type (e.g., 'like', 'heart', 'thumbsup'). Must match community's configured reaction set.", 320 + }, 321 + community: { 322 + type: 'string', 323 + format: 'did', 324 + description: 325 + 'DID of the community where this reaction was created. Immutable origin identifier.', 326 + }, 327 + createdAt: { 328 + type: 'string', 329 + format: 'datetime', 330 + description: 331 + 'Client-declared timestamp when this reaction was originally created.', 332 + }, 333 + }, 334 + }, 335 + }, 336 + }, 337 + }, 338 + ForumAtgoraTopicPost: { 339 + lexicon: 1, 340 + id: 'forum.atgora.topic.post', 341 + defs: { 342 + main: { 343 + type: 'record', 344 + description: 'Record containing a forum topic post.', 345 + key: 'tid', 346 + record: { 347 + type: 'object', 348 + required: ['title', 'content', 'community', 'category', 'createdAt'], 349 + properties: { 350 + title: { 351 + type: 'string', 352 + minLength: 1, 353 + maxLength: 2000, 354 + maxGraphemes: 200, 355 + description: 'Topic title.', 356 + }, 357 + content: { 358 + type: 'string', 359 + minLength: 1, 360 + maxLength: 100000, 361 + description: 'Topic body in markdown.', 362 + }, 363 + contentFormat: { 364 + type: 'string', 365 + enum: ['markdown'], 366 + description: "Content format. Defaults to 'markdown' if omitted.", 367 + }, 368 + community: { 369 + type: 'string', 370 + format: 'did', 371 + description: 372 + 'DID of the community where this record was created. Immutable origin identifier for cross-community attribution.', 373 + }, 374 + category: { 375 + type: 'string', 376 + maxLength: 640, 377 + maxGraphemes: 64, 378 + description: 'Category rkey within the community.', 379 + }, 380 + tags: { 381 + type: 'array', 382 + maxLength: 5, 383 + items: { 384 + type: 'string', 385 + minLength: 1, 386 + maxLength: 300, 387 + maxGraphemes: 30, 388 + }, 389 + description: 'Topic tags. Lowercase alphanumeric + hyphens.', 390 + }, 391 + labels: { 392 + type: 'union', 393 + description: 394 + 'Self-label values for content maturity (e.g., sexual, nudity, graphic-media).', 395 + refs: ['lex:com.atproto.label.defs#selfLabels'], 396 + }, 397 + createdAt: { 398 + type: 'string', 399 + format: 'datetime', 400 + description: 401 + 'Client-declared timestamp when this post was originally created.', 402 + }, 403 + }, 404 + }, 405 + }, 406 + }, 407 + }, 408 + ForumAtgoraTopicReply: { 409 + lexicon: 1, 410 + id: 'forum.atgora.topic.reply', 411 + defs: { 412 + main: { 413 + type: 'record', 414 + description: 415 + 'Record containing a reply to a forum topic or another reply.', 416 + key: 'tid', 417 + record: { 418 + type: 'object', 419 + required: ['content', 'root', 'parent', 'community', 'createdAt'], 420 + properties: { 421 + content: { 422 + type: 'string', 423 + minLength: 1, 424 + maxLength: 50000, 425 + description: 'Reply body in markdown.', 426 + }, 427 + contentFormat: { 428 + type: 'string', 429 + enum: ['markdown'], 430 + description: "Content format. Defaults to 'markdown' if omitted.", 431 + }, 432 + root: { 433 + type: 'ref', 434 + ref: 'lex:com.atproto.repo.strongRef', 435 + description: 436 + 'The original topic (AT URI of forum.atgora.topic.post).', 437 + }, 438 + parent: { 439 + type: 'ref', 440 + ref: 'lex:com.atproto.repo.strongRef', 441 + description: 442 + 'Direct parent (topic or reply). For top-level replies, parent == root.', 443 + }, 444 + community: { 445 + type: 'string', 446 + format: 'did', 447 + description: 448 + 'DID of the community where this reply was created. Immutable origin identifier.', 449 + }, 450 + labels: { 451 + type: 'union', 452 + description: 'Self-label values for content maturity.', 453 + refs: ['lex:com.atproto.label.defs#selfLabels'], 454 + }, 455 + createdAt: { 456 + type: 'string', 457 + format: 'datetime', 458 + description: 459 + 'Client-declared timestamp when this reply was originally created.', 460 + }, 461 + }, 462 + }, 463 + }, 464 + }, 465 + }, 466 + } as const satisfies Record<string, LexiconDoc> 467 + export const schemas = Object.values(schemaDict) satisfies LexiconDoc[] 468 + export const lexicons: Lexicons = new Lexicons(schemas) 469 + 470 + export function validate<T extends { $type: string }>( 471 + v: unknown, 472 + id: string, 473 + hash: string, 474 + requiredType: true, 475 + ): ValidationResult<T> 476 + export function validate<T extends { $type?: string }>( 477 + v: unknown, 478 + id: string, 479 + hash: string, 480 + requiredType?: false, 481 + ): ValidationResult<T> 482 + export function validate( 483 + v: unknown, 484 + id: string, 485 + hash: string, 486 + requiredType?: boolean, 487 + ): ValidationResult { 488 + return (requiredType ? is$typed : maybe$typed)(v, id, hash) 489 + ? lexicons.validate(`${id}#${hash}`, v) 490 + : { 491 + success: false, 492 + error: new ValidationError( 493 + `Must be an object with "${hash === 'main' ? id : `${id}#${hash}`}" $type property`, 494 + ), 495 + } 496 + } 497 + 498 + export const ids = { 499 + ComAtprotoLabelDefs: 'com.atproto.label.defs', 500 + ComAtprotoRepoStrongRef: 'com.atproto.repo.strongRef', 501 + ForumAtgoraActorPreferences: 'forum.atgora.actor.preferences', 502 + ForumAtgoraDefs: 'forum.atgora.defs', 503 + ForumAtgoraInteractionReaction: 'forum.atgora.interaction.reaction', 504 + ForumAtgoraTopicPost: 'forum.atgora.topic.post', 505 + ForumAtgoraTopicReply: 'forum.atgora.topic.reply', 506 + } as const
+146
src/generated/types/com/atproto/label/defs.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons.js' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util.js' 12 + 13 + const is$typed = _is$typed, 14 + validate = _validate 15 + const id = 'com.atproto.label.defs' 16 + 17 + /** Metadata tag on an atproto resource (eg, repo or record). */ 18 + export interface Label { 19 + $type?: 'com.atproto.label.defs#label' 20 + /** The AT Protocol version of the label object. */ 21 + ver?: number 22 + /** DID of the actor who created this label. */ 23 + src: string 24 + /** AT URI of the record, repository (account), or other resource that this label applies to. */ 25 + uri: string 26 + /** Optionally, CID specifying the specific version of 'uri' resource this label applies to. */ 27 + cid?: string 28 + /** The short string name of the value or type of this label. */ 29 + val: string 30 + /** If true, this is a negation label, overwriting a previous label. */ 31 + neg?: boolean 32 + /** Timestamp when this label was created. */ 33 + cts: string 34 + /** Timestamp at which this label expires (no longer applies). */ 35 + exp?: string 36 + /** Signature of dag-cbor encoded label. */ 37 + sig?: Uint8Array 38 + } 39 + 40 + const hashLabel = 'label' 41 + 42 + export function isLabel<V>(v: V) { 43 + return is$typed(v, id, hashLabel) 44 + } 45 + 46 + export function validateLabel<V>(v: V) { 47 + return validate<Label & V>(v, id, hashLabel) 48 + } 49 + 50 + /** Metadata tags on an atproto record, published by the author within the record. */ 51 + export interface SelfLabels { 52 + $type?: 'com.atproto.label.defs#selfLabels' 53 + values: SelfLabel[] 54 + } 55 + 56 + const hashSelfLabels = 'selfLabels' 57 + 58 + export function isSelfLabels<V>(v: V) { 59 + return is$typed(v, id, hashSelfLabels) 60 + } 61 + 62 + export function validateSelfLabels<V>(v: V) { 63 + return validate<SelfLabels & V>(v, id, hashSelfLabels) 64 + } 65 + 66 + /** Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel. */ 67 + export interface SelfLabel { 68 + $type?: 'com.atproto.label.defs#selfLabel' 69 + /** The short string name of the value or type of this label. */ 70 + val: string 71 + } 72 + 73 + const hashSelfLabel = 'selfLabel' 74 + 75 + export function isSelfLabel<V>(v: V) { 76 + return is$typed(v, id, hashSelfLabel) 77 + } 78 + 79 + export function validateSelfLabel<V>(v: V) { 80 + return validate<SelfLabel & V>(v, id, hashSelfLabel) 81 + } 82 + 83 + /** Declares a label value and its expected interpretations and behaviors. */ 84 + export interface LabelValueDefinition { 85 + $type?: 'com.atproto.label.defs#labelValueDefinition' 86 + /** The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+). */ 87 + identifier: string 88 + /** How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing. */ 89 + severity: 'inform' | 'alert' | 'none' | (string & {}) 90 + /** What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing. */ 91 + blurs: 'content' | 'media' | 'none' | (string & {}) 92 + /** The default setting for this label. */ 93 + defaultSetting: 'ignore' | 'warn' | 'hide' | (string & {}) 94 + /** Does the user need to have adult content enabled in order to configure this label? */ 95 + adultOnly?: boolean 96 + locales: LabelValueDefinitionStrings[] 97 + } 98 + 99 + const hashLabelValueDefinition = 'labelValueDefinition' 100 + 101 + export function isLabelValueDefinition<V>(v: V) { 102 + return is$typed(v, id, hashLabelValueDefinition) 103 + } 104 + 105 + export function validateLabelValueDefinition<V>(v: V) { 106 + return validate<LabelValueDefinition & V>(v, id, hashLabelValueDefinition) 107 + } 108 + 109 + /** Strings which describe the label in the UI, localized into a specific language. */ 110 + export interface LabelValueDefinitionStrings { 111 + $type?: 'com.atproto.label.defs#labelValueDefinitionStrings' 112 + /** The code of the language these strings are written in. */ 113 + lang: string 114 + /** A short human-readable name for the label. */ 115 + name: string 116 + /** A longer description of what the label means and why it might be applied. */ 117 + description: string 118 + } 119 + 120 + const hashLabelValueDefinitionStrings = 'labelValueDefinitionStrings' 121 + 122 + export function isLabelValueDefinitionStrings<V>(v: V) { 123 + return is$typed(v, id, hashLabelValueDefinitionStrings) 124 + } 125 + 126 + export function validateLabelValueDefinitionStrings<V>(v: V) { 127 + return validate<LabelValueDefinitionStrings & V>( 128 + v, 129 + id, 130 + hashLabelValueDefinitionStrings, 131 + ) 132 + } 133 + 134 + export type LabelValue = 135 + | '!hide' 136 + | '!no-promote' 137 + | '!warn' 138 + | '!no-unauthenticated' 139 + | 'dmca-violation' 140 + | 'doxxing' 141 + | 'porn' 142 + | 'sexual' 143 + | 'nudity' 144 + | 'nsfl' 145 + | 'gore' 146 + | (string & {})
+31
src/generated/types/com/atproto/repo/strongRef.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons.js' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util.js' 12 + 13 + const is$typed = _is$typed, 14 + validate = _validate 15 + const id = 'com.atproto.repo.strongRef' 16 + 17 + export interface Main { 18 + $type?: 'com.atproto.repo.strongRef' 19 + uri: string 20 + cid: string 21 + } 22 + 23 + const hashMain = 'main' 24 + 25 + export function isMain<V>(v: V) { 26 + return is$typed(v, id, hashMain) 27 + } 28 + 29 + export function validateMain<V>(v: V) { 30 + return validate<Main & V>(v, id, hashMain) 31 + }
+65
src/generated/types/forum/atgora/actor/preferences.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons.js' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util.js' 12 + 13 + const is$typed = _is$typed, 14 + validate = _validate 15 + const id = 'forum.atgora.actor.preferences' 16 + 17 + export interface Main { 18 + $type: 'forum.atgora.actor.preferences' 19 + /** Maximum maturity tier to show. Default: 'safe'. */ 20 + maturityLevel: 'safe' | 'mature' | 'all' 21 + /** Global muted words (apply to all communities). */ 22 + mutedWords?: string[] 23 + /** Blocked accounts (content hidden everywhere). */ 24 + blockedDids?: string[] 25 + /** Muted accounts (content de-emphasized, collapsed but visible). */ 26 + mutedDids?: string[] 27 + crossPostDefaults?: CrossPostConfig 28 + /** Client-declared timestamp when preferences were last updated. */ 29 + updatedAt: string 30 + [k: string]: unknown 31 + } 32 + 33 + const hashMain = 'main' 34 + 35 + export function isMain<V>(v: V) { 36 + return is$typed(v, id, hashMain) 37 + } 38 + 39 + export function validateMain<V>(v: V) { 40 + return validate<Main & V>(v, id, hashMain, true) 41 + } 42 + 43 + export { 44 + type Main as Record, 45 + isMain as isRecord, 46 + validateMain as validateRecord, 47 + } 48 + 49 + export interface CrossPostConfig { 50 + $type?: 'forum.atgora.actor.preferences#crossPostConfig' 51 + /** Cross-post new topics to Bluesky. Default: false. */ 52 + bluesky?: boolean 53 + /** Cross-post new topics to Frontpage. Default: false. */ 54 + frontpage?: boolean 55 + } 56 + 57 + const hashCrossPostConfig = 'crossPostConfig' 58 + 59 + export function isCrossPostConfig<V>(v: V) { 60 + return is$typed(v, id, hashCrossPostConfig) 61 + } 62 + 63 + export function validateCrossPostConfig<V>(v: V) { 64 + return validate<CrossPostConfig & V>(v, id, hashCrossPostConfig) 65 + }
+11
src/generated/types/forum/atgora/defs.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../lexicons.js' 7 + import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util.js' 8 + 9 + const is$typed = _is$typed, 10 + validate = _validate 11 + const id = 'forum.atgora.defs'
+44
src/generated/types/forum/atgora/interaction/reaction.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons.js' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util.js' 12 + import type * as ComAtprotoRepoStrongRef from '../../../com/atproto/repo/strongRef.js' 13 + 14 + const is$typed = _is$typed, 15 + validate = _validate 16 + const id = 'forum.atgora.interaction.reaction' 17 + 18 + export interface Main { 19 + $type: 'forum.atgora.interaction.reaction' 20 + subject: ComAtprotoRepoStrongRef.Main 21 + /** Reaction type (e.g., 'like', 'heart', 'thumbsup'). Must match community's configured reaction set. */ 22 + type: string 23 + /** DID of the community where this reaction was created. Immutable origin identifier. */ 24 + community: string 25 + /** Client-declared timestamp when this reaction was originally created. */ 26 + createdAt: string 27 + [k: string]: unknown 28 + } 29 + 30 + const hashMain = 'main' 31 + 32 + export function isMain<V>(v: V) { 33 + return is$typed(v, id, hashMain) 34 + } 35 + 36 + export function validateMain<V>(v: V) { 37 + return validate<Main & V>(v, id, hashMain, true) 38 + } 39 + 40 + export { 41 + type Main as Record, 42 + isMain as isRecord, 43 + validateMain as validateRecord, 44 + }
+52
src/generated/types/forum/atgora/topic/post.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons.js' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util.js' 12 + import type * as ComAtprotoLabelDefs from '../../../com/atproto/label/defs.js' 13 + 14 + const is$typed = _is$typed, 15 + validate = _validate 16 + const id = 'forum.atgora.topic.post' 17 + 18 + export interface Main { 19 + $type: 'forum.atgora.topic.post' 20 + /** Topic title. */ 21 + title: string 22 + /** Topic body in markdown. */ 23 + content: string 24 + /** Content format. Defaults to 'markdown' if omitted. */ 25 + contentFormat?: 'markdown' 26 + /** DID of the community where this record was created. Immutable origin identifier for cross-community attribution. */ 27 + community: string 28 + /** Category rkey within the community. */ 29 + category: string 30 + /** Topic tags. Lowercase alphanumeric + hyphens. */ 31 + tags?: string[] 32 + labels?: $Typed<ComAtprotoLabelDefs.SelfLabels> | { $type: string } 33 + /** Client-declared timestamp when this post was originally created. */ 34 + createdAt: string 35 + [k: string]: unknown 36 + } 37 + 38 + const hashMain = 'main' 39 + 40 + export function isMain<V>(v: V) { 41 + return is$typed(v, id, hashMain) 42 + } 43 + 44 + export function validateMain<V>(v: V) { 45 + return validate<Main & V>(v, id, hashMain, true) 46 + } 47 + 48 + export { 49 + type Main as Record, 50 + isMain as isRecord, 51 + validateMain as validateRecord, 52 + }
+49
src/generated/types/forum/atgora/topic/reply.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons.js' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util.js' 12 + import type * as ComAtprotoRepoStrongRef from '../../../com/atproto/repo/strongRef.js' 13 + import type * as ComAtprotoLabelDefs from '../../../com/atproto/label/defs.js' 14 + 15 + const is$typed = _is$typed, 16 + validate = _validate 17 + const id = 'forum.atgora.topic.reply' 18 + 19 + export interface Main { 20 + $type: 'forum.atgora.topic.reply' 21 + /** Reply body in markdown. */ 22 + content: string 23 + /** Content format. Defaults to 'markdown' if omitted. */ 24 + contentFormat?: 'markdown' 25 + root: ComAtprotoRepoStrongRef.Main 26 + parent: ComAtprotoRepoStrongRef.Main 27 + /** DID of the community where this reply was created. Immutable origin identifier. */ 28 + community: string 29 + labels?: $Typed<ComAtprotoLabelDefs.SelfLabels> | { $type: string } 30 + /** Client-declared timestamp when this reply was originally created. */ 31 + createdAt: string 32 + [k: string]: unknown 33 + } 34 + 35 + const hashMain = 'main' 36 + 37 + export function isMain<V>(v: V) { 38 + return is$typed(v, id, hashMain) 39 + } 40 + 41 + export function validateMain<V>(v: V) { 42 + return validate<Main & V>(v, id, hashMain, true) 43 + } 44 + 45 + export { 46 + type Main as Record, 47 + isMain as isRecord, 48 + validateMain as validateRecord, 49 + }
+82
src/generated/util.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + 5 + import { type ValidationResult } from '@atproto/lexicon' 6 + 7 + export type OmitKey<T, K extends keyof T> = { 8 + [K2 in keyof T as K2 extends K ? never : K2]: T[K2] 9 + } 10 + 11 + export type $Typed<V, T extends string = string> = V & { $type: T } 12 + export type Un$Typed<V extends { $type?: string }> = OmitKey<V, '$type'> 13 + 14 + export type $Type<Id extends string, Hash extends string> = Hash extends 'main' 15 + ? Id 16 + : `${Id}#${Hash}` 17 + 18 + function isObject<V>(v: V): v is V & object { 19 + return v != null && typeof v === 'object' 20 + } 21 + 22 + function is$type<Id extends string, Hash extends string>( 23 + $type: unknown, 24 + id: Id, 25 + hash: Hash, 26 + ): $type is $Type<Id, Hash> { 27 + return hash === 'main' 28 + ? $type === id 29 + : // $type === `${id}#${hash}` 30 + typeof $type === 'string' && 31 + $type.length === id.length + 1 + hash.length && 32 + $type.charCodeAt(id.length) === 35 /* '#' */ && 33 + $type.startsWith(id) && 34 + $type.endsWith(hash) 35 + } 36 + 37 + export type $TypedObject< 38 + V, 39 + Id extends string, 40 + Hash extends string, 41 + > = V extends { 42 + $type: $Type<Id, Hash> 43 + } 44 + ? V 45 + : V extends { $type?: string } 46 + ? V extends { $type?: infer T extends $Type<Id, Hash> } 47 + ? V & { $type: T } 48 + : never 49 + : V & { $type: $Type<Id, Hash> } 50 + 51 + export function is$typed<V, Id extends string, Hash extends string>( 52 + v: V, 53 + id: Id, 54 + hash: Hash, 55 + ): v is $TypedObject<V, Id, Hash> { 56 + return isObject(v) && '$type' in v && is$type(v.$type, id, hash) 57 + } 58 + 59 + export function maybe$typed<V, Id extends string, Hash extends string>( 60 + v: V, 61 + id: Id, 62 + hash: Hash, 63 + ): v is V & object & { $type?: $Type<Id, Hash> } { 64 + return ( 65 + isObject(v) && 66 + ('$type' in v ? v.$type === undefined || is$type(v.$type, id, hash) : true) 67 + ) 68 + } 69 + 70 + export type Validator<R = unknown> = (v: unknown) => ValidationResult<R> 71 + export type ValidatorParam<V extends Validator> = 72 + V extends Validator<infer R> ? R : never 73 + 74 + /** 75 + * Utility function that allows to convert a "validate*" utility function into a 76 + * type predicate. 77 + */ 78 + export function asPredicate<V extends Validator>(validate: V) { 79 + return function <T>(v: T): v is T & ValidatorParam<V> { 80 + return validate(v).success 81 + } 82 + }
+46
src/index.ts
··· 1 + /** 2 + * @atgora-forum/lexicons 3 + * 4 + * AT Protocol lexicon schemas and generated TypeScript types 5 + * for the ATgora forum platform. 6 + * 7 + * Namespace: forum.atgora.* 8 + */ 9 + 10 + // Generated types (from lex-cli) 11 + export { 12 + ForumAtgoraTopicPost, 13 + ForumAtgoraTopicReply, 14 + ForumAtgoraInteractionReaction, 15 + ForumAtgoraActorPreferences, 16 + ComAtprotoRepoStrongRef, 17 + ComAtprotoLabelDefs, 18 + } from "./generated/index.js"; 19 + 20 + // Generated lexicon schemas and validation (from lex-cli) 21 + export { schemas, validate } from "./generated/index.js"; 22 + export { ids } from "./generated/lexicons.js"; 23 + 24 + // Zod validation schemas (hand-written, mirrors lexicon constraints) 25 + export { 26 + topicPostSchema, 27 + topicReplySchema, 28 + reactionSchema, 29 + actorPreferencesSchema, 30 + crossPostConfigSchema, 31 + strongRefSchema, 32 + selfLabelsSchema, 33 + selfLabelSchema, 34 + type TopicPostInput, 35 + type TopicReplyInput, 36 + type ReactionInput, 37 + type ActorPreferencesInput, 38 + } from "./validation/index.js"; 39 + 40 + // Lexicon ID constants 41 + export const LEXICON_IDS = { 42 + TopicPost: "forum.atgora.topic.post", 43 + TopicReply: "forum.atgora.topic.reply", 44 + Reaction: "forum.atgora.interaction.reaction", 45 + ActorPreferences: "forum.atgora.actor.preferences", 46 + } as const;
+26
src/validation/actor-preferences.ts
··· 1 + import { z } from "zod"; 2 + 3 + const didRegex = /^did:[a-z]+:[a-zA-Z0-9._:%-]+$/; 4 + 5 + /** Cross-post configuration for new topics. */ 6 + export const crossPostConfigSchema = z.object({ 7 + bluesky: z.boolean().optional(), 8 + frontpage: z.boolean().optional(), 9 + }); 10 + 11 + /** 12 + * Zod schema for forum.atgora.actor.preferences records. 13 + * 14 + * Mirrors the lexicon constraints from prd-lexicons.md section 3.4. 15 + * Singleton record (key: literal:self). 16 + */ 17 + export const actorPreferencesSchema = z.object({ 18 + maturityLevel: z.enum(["safe", "mature", "all"]), 19 + mutedWords: z.array(z.string().max(1000)).max(100).optional(), 20 + blockedDids: z.array(z.string().regex(didRegex)).max(1000).optional(), 21 + mutedDids: z.array(z.string().regex(didRegex)).max(1000).optional(), 22 + crossPostDefaults: crossPostConfigSchema.optional(), 23 + updatedAt: z.iso.datetime(), 24 + }); 25 + 26 + export type ActorPreferencesInput = z.input<typeof actorPreferencesSchema>;
+10
src/validation/index.ts
··· 1 + export { topicPostSchema, type TopicPostInput } from "./topic-post.js"; 2 + export { topicReplySchema, type TopicReplyInput } from "./topic-reply.js"; 3 + export { reactionSchema, type ReactionInput } from "./reaction.js"; 4 + export { 5 + actorPreferencesSchema, 6 + crossPostConfigSchema, 7 + type ActorPreferencesInput, 8 + } from "./actor-preferences.js"; 9 + export { strongRefSchema } from "./strong-ref.js"; 10 + export { selfLabelsSchema, selfLabelSchema } from "./self-labels.js";
+18
src/validation/reaction.ts
··· 1 + import { z } from "zod"; 2 + import { strongRefSchema } from "./strong-ref.js"; 3 + 4 + const didRegex = /^did:[a-z]+:[a-zA-Z0-9._:%-]+$/; 5 + 6 + /** 7 + * Zod schema for forum.atgora.interaction.reaction records. 8 + * 9 + * Mirrors the lexicon constraints from prd-lexicons.md section 3.3. 10 + */ 11 + export const reactionSchema = z.object({ 12 + subject: strongRefSchema, 13 + type: z.string().min(1).max(300), 14 + community: z.string().regex(didRegex), 15 + createdAt: z.iso.datetime(), 16 + }); 17 + 18 + export type ReactionInput = z.input<typeof reactionSchema>;
+11
src/validation/self-labels.ts
··· 1 + import { z } from "zod"; 2 + 3 + /** Self-label value (matches com.atproto.label.defs#selfLabel). */ 4 + export const selfLabelSchema = z.object({ 5 + val: z.string().max(128), 6 + }); 7 + 8 + /** Self-labels object (matches com.atproto.label.defs#selfLabels). */ 9 + export const selfLabelsSchema = z.object({ 10 + values: z.array(selfLabelSchema).max(10), 11 + });
+7
src/validation/strong-ref.ts
··· 1 + import { z } from "zod"; 2 + 3 + /** AT Protocol strong reference (AT URI + CID pair). */ 4 + export const strongRefSchema = z.object({ 5 + uri: z.string().startsWith("at://"), 6 + cid: z.string().min(1), 7 + });
+30
src/validation/topic-post.ts
··· 1 + import { z } from "zod"; 2 + import { selfLabelsSchema } from "./self-labels.js"; 3 + 4 + /** DID format regex (simplified, catches obvious malformed DIDs). */ 5 + const didRegex = /^did:[a-z]+:[a-zA-Z0-9._:%-]+$/; 6 + 7 + /** 8 + * Zod schema for forum.atgora.topic.post records. 9 + * 10 + * Mirrors the lexicon constraints from prd-lexicons.md section 3.1. 11 + * Note: maxLength in lexicon = UTF-8 bytes, but Zod .max() counts 12 + * JS string length (UTF-16 code units). For ASCII-heavy content 13 + * these are equivalent; for full Unicode safety the AppView should 14 + * also validate byte length. 15 + */ 16 + export const topicPostSchema = z.object({ 17 + title: z.string().min(1).max(2000), 18 + content: z.string().min(1).max(100_000), 19 + contentFormat: z.literal("markdown").optional(), 20 + community: z.string().regex(didRegex), 21 + category: z.string().min(1).max(640), 22 + tags: z 23 + .array(z.string().min(1).max(300)) 24 + .max(5) 25 + .optional(), 26 + labels: selfLabelsSchema.optional(), 27 + createdAt: z.iso.datetime(), 28 + }); 29 + 30 + export type TopicPostInput = z.input<typeof topicPostSchema>;
+22
src/validation/topic-reply.ts
··· 1 + import { z } from "zod"; 2 + import { strongRefSchema } from "./strong-ref.js"; 3 + import { selfLabelsSchema } from "./self-labels.js"; 4 + 5 + const didRegex = /^did:[a-z]+:[a-zA-Z0-9._:%-]+$/; 6 + 7 + /** 8 + * Zod schema for forum.atgora.topic.reply records. 9 + * 10 + * Mirrors the lexicon constraints from prd-lexicons.md section 3.2. 11 + */ 12 + export const topicReplySchema = z.object({ 13 + content: z.string().min(1).max(50_000), 14 + contentFormat: z.literal("markdown").optional(), 15 + root: strongRefSchema, 16 + parent: strongRefSchema, 17 + community: z.string().regex(didRegex), 18 + labels: selfLabelsSchema.optional(), 19 + createdAt: z.iso.datetime(), 20 + }); 21 + 22 + export type TopicReplyInput = z.input<typeof topicReplySchema>;
+225
tests/lexicon-schemas.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { readFile, readdir } from "node:fs/promises"; 3 + import { join, resolve } from "node:path"; 4 + 5 + const LEXICONS_DIR = resolve(import.meta.dirname, "../lexicons/forum/atgora"); 6 + 7 + async function loadJson(path: string): Promise<unknown> { 8 + const content = await readFile(path, "utf-8"); 9 + return JSON.parse(content); 10 + } 11 + 12 + async function getAllLexiconFiles(dir: string): Promise<string[]> { 13 + const entries = await readdir(dir, { withFileTypes: true, recursive: true }); 14 + return entries 15 + .filter((e) => e.isFile() && e.name.endsWith(".json")) 16 + .map((e) => join(e.parentPath, e.name)); 17 + } 18 + 19 + describe("Lexicon JSON schema structure", () => { 20 + it("all lexicon files are valid JSON", async () => { 21 + const files = await getAllLexiconFiles(LEXICONS_DIR); 22 + expect(files.length).toBeGreaterThanOrEqual(4); 23 + for (const file of files) { 24 + await expect(loadJson(file)).resolves.toBeDefined(); 25 + } 26 + }); 27 + 28 + it("all lexicons have required top-level fields", async () => { 29 + const files = await getAllLexiconFiles(LEXICONS_DIR); 30 + for (const file of files) { 31 + const lexicon = (await loadJson(file)) as Record<string, unknown>; 32 + expect(lexicon).toHaveProperty("lexicon", 1); 33 + expect(lexicon).toHaveProperty("id"); 34 + expect(lexicon).toHaveProperty("defs"); 35 + expect(typeof lexicon["id"]).toBe("string"); 36 + } 37 + }); 38 + 39 + it("lexicon ids match file paths", async () => { 40 + const files = await getAllLexiconFiles(LEXICONS_DIR); 41 + for (const file of files) { 42 + const lexicon = (await loadJson(file)) as Record<string, unknown>; 43 + const id = lexicon["id"] as string; 44 + // Convert "forum.atgora.topic.post" to a path fragment "forum/atgora/topic/post.json" 45 + const expectedPath = id.replace(/\./g, "/") + ".json"; 46 + expect(file).toContain(expectedPath); 47 + } 48 + }); 49 + }); 50 + 51 + describe("forum.atgora.topic.post lexicon", () => { 52 + let schema: Record<string, unknown>; 53 + 54 + it("loads successfully", async () => { 55 + schema = (await loadJson( 56 + join(LEXICONS_DIR, "topic/post.json"), 57 + )) as Record<string, unknown>; 58 + expect(schema["id"]).toBe("forum.atgora.topic.post"); 59 + }); 60 + 61 + it("defines a record with key type tid", () => { 62 + const defs = schema["defs"] as Record<string, unknown>; 63 + const main = defs["main"] as Record<string, unknown>; 64 + expect(main["type"]).toBe("record"); 65 + expect(main["key"]).toBe("tid"); 66 + }); 67 + 68 + it("has required fields: title, content, community, category, createdAt", () => { 69 + const defs = schema["defs"] as Record<string, unknown>; 70 + const main = defs["main"] as Record<string, unknown>; 71 + const record = main["record"] as Record<string, unknown>; 72 + const required = record["required"] as string[]; 73 + expect(required).toContain("title"); 74 + expect(required).toContain("content"); 75 + expect(required).toContain("community"); 76 + expect(required).toContain("category"); 77 + expect(required).toContain("createdAt"); 78 + }); 79 + 80 + it("title has maxGraphemes: 200 and maxLength: 2000", () => { 81 + const defs = schema["defs"] as Record<string, unknown>; 82 + const main = defs["main"] as Record<string, unknown>; 83 + const record = main["record"] as Record<string, unknown>; 84 + const props = record["properties"] as Record<string, unknown>; 85 + const title = props["title"] as Record<string, unknown>; 86 + expect(title["maxGraphemes"]).toBe(200); 87 + expect(title["maxLength"]).toBe(2000); 88 + expect(title["minLength"]).toBe(1); 89 + }); 90 + 91 + it("community field uses DID format", () => { 92 + const defs = schema["defs"] as Record<string, unknown>; 93 + const main = defs["main"] as Record<string, unknown>; 94 + const record = main["record"] as Record<string, unknown>; 95 + const props = record["properties"] as Record<string, unknown>; 96 + const community = props["community"] as Record<string, unknown>; 97 + expect(community["type"]).toBe("string"); 98 + expect(community["format"]).toBe("did"); 99 + }); 100 + 101 + it("labels uses union with selfLabels ref", () => { 102 + const defs = schema["defs"] as Record<string, unknown>; 103 + const main = defs["main"] as Record<string, unknown>; 104 + const record = main["record"] as Record<string, unknown>; 105 + const props = record["properties"] as Record<string, unknown>; 106 + const labels = props["labels"] as Record<string, unknown>; 107 + expect(labels["type"]).toBe("union"); 108 + expect(labels["refs"]).toContain("com.atproto.label.defs#selfLabels"); 109 + }); 110 + 111 + it("tags is optional with max 5 items", () => { 112 + const defs = schema["defs"] as Record<string, unknown>; 113 + const main = defs["main"] as Record<string, unknown>; 114 + const record = main["record"] as Record<string, unknown>; 115 + const required = record["required"] as string[]; 116 + expect(required).not.toContain("tags"); 117 + const props = record["properties"] as Record<string, unknown>; 118 + const tags = props["tags"] as Record<string, unknown>; 119 + expect(tags["maxLength"]).toBe(5); 120 + }); 121 + }); 122 + 123 + describe("forum.atgora.topic.reply lexicon", () => { 124 + let schema: Record<string, unknown>; 125 + 126 + it("loads successfully", async () => { 127 + schema = (await loadJson( 128 + join(LEXICONS_DIR, "topic/reply.json"), 129 + )) as Record<string, unknown>; 130 + expect(schema["id"]).toBe("forum.atgora.topic.reply"); 131 + }); 132 + 133 + it("has required fields: content, root, parent, community, createdAt", () => { 134 + const defs = schema["defs"] as Record<string, unknown>; 135 + const main = defs["main"] as Record<string, unknown>; 136 + const record = main["record"] as Record<string, unknown>; 137 + const required = record["required"] as string[]; 138 + expect(required).toEqual( 139 + expect.arrayContaining([ 140 + "content", 141 + "root", 142 + "parent", 143 + "community", 144 + "createdAt", 145 + ]), 146 + ); 147 + }); 148 + 149 + it("root and parent use strongRef", () => { 150 + const defs = schema["defs"] as Record<string, unknown>; 151 + const main = defs["main"] as Record<string, unknown>; 152 + const record = main["record"] as Record<string, unknown>; 153 + const props = record["properties"] as Record<string, unknown>; 154 + const root = props["root"] as Record<string, unknown>; 155 + const parent = props["parent"] as Record<string, unknown>; 156 + expect(root["type"]).toBe("ref"); 157 + expect(root["ref"]).toBe("com.atproto.repo.strongRef"); 158 + expect(parent["type"]).toBe("ref"); 159 + expect(parent["ref"]).toBe("com.atproto.repo.strongRef"); 160 + }); 161 + }); 162 + 163 + describe("forum.atgora.interaction.reaction lexicon", () => { 164 + let schema: Record<string, unknown>; 165 + 166 + it("loads successfully", async () => { 167 + schema = (await loadJson( 168 + join(LEXICONS_DIR, "interaction/reaction.json"), 169 + )) as Record<string, unknown>; 170 + expect(schema["id"]).toBe("forum.atgora.interaction.reaction"); 171 + }); 172 + 173 + it("has required fields: subject, type, community, createdAt", () => { 174 + const defs = schema["defs"] as Record<string, unknown>; 175 + const main = defs["main"] as Record<string, unknown>; 176 + const record = main["record"] as Record<string, unknown>; 177 + const required = record["required"] as string[]; 178 + expect(required).toEqual( 179 + expect.arrayContaining(["subject", "type", "community", "createdAt"]), 180 + ); 181 + }); 182 + 183 + it("subject uses strongRef", () => { 184 + const defs = schema["defs"] as Record<string, unknown>; 185 + const main = defs["main"] as Record<string, unknown>; 186 + const record = main["record"] as Record<string, unknown>; 187 + const props = record["properties"] as Record<string, unknown>; 188 + const subject = props["subject"] as Record<string, unknown>; 189 + expect(subject["type"]).toBe("ref"); 190 + expect(subject["ref"]).toBe("com.atproto.repo.strongRef"); 191 + }); 192 + }); 193 + 194 + describe("forum.atgora.actor.preferences lexicon", () => { 195 + let schema: Record<string, unknown>; 196 + 197 + it("loads successfully", async () => { 198 + schema = (await loadJson( 199 + join(LEXICONS_DIR, "actor/preferences.json"), 200 + )) as Record<string, unknown>; 201 + expect(schema["id"]).toBe("forum.atgora.actor.preferences"); 202 + }); 203 + 204 + it("uses literal:self key (singleton record)", () => { 205 + const defs = schema["defs"] as Record<string, unknown>; 206 + const main = defs["main"] as Record<string, unknown>; 207 + expect(main["key"]).toBe("literal:self"); 208 + }); 209 + 210 + it("has maturityLevel enum with safe, mature, all", () => { 211 + const defs = schema["defs"] as Record<string, unknown>; 212 + const main = defs["main"] as Record<string, unknown>; 213 + const record = main["record"] as Record<string, unknown>; 214 + const props = record["properties"] as Record<string, unknown>; 215 + const ml = props["maturityLevel"] as Record<string, unknown>; 216 + expect(ml["enum"]).toEqual(["safe", "mature", "all"]); 217 + }); 218 + 219 + it("defines crossPostConfig as a separate def", () => { 220 + const defs = schema["defs"] as Record<string, unknown>; 221 + expect(defs["crossPostConfig"]).toBeDefined(); 222 + const cpc = defs["crossPostConfig"] as Record<string, unknown>; 223 + expect(cpc["type"]).toBe("object"); 224 + }); 225 + });
+119
tests/type-generation.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { 3 + ForumAtgoraTopicPost, 4 + ForumAtgoraTopicReply, 5 + ForumAtgoraInteractionReaction, 6 + ForumAtgoraActorPreferences, 7 + LEXICON_IDS, 8 + schemas, 9 + ids, 10 + } from "../src/index.js"; 11 + 12 + describe("generated type exports", () => { 13 + it("exports ForumAtgoraTopicPost with Record type and validators", () => { 14 + expect(ForumAtgoraTopicPost.isRecord).toBeTypeOf("function"); 15 + expect(ForumAtgoraTopicPost.validateRecord).toBeTypeOf("function"); 16 + }); 17 + 18 + it("exports ForumAtgoraTopicReply with Record type and validators", () => { 19 + expect(ForumAtgoraTopicReply.isRecord).toBeTypeOf("function"); 20 + expect(ForumAtgoraTopicReply.validateRecord).toBeTypeOf("function"); 21 + }); 22 + 23 + it("exports ForumAtgoraInteractionReaction with Record type and validators", () => { 24 + expect(ForumAtgoraInteractionReaction.isRecord).toBeTypeOf("function"); 25 + expect(ForumAtgoraInteractionReaction.validateRecord).toBeTypeOf( 26 + "function", 27 + ); 28 + }); 29 + 30 + it("exports ForumAtgoraActorPreferences with Record type and validators", () => { 31 + expect(ForumAtgoraActorPreferences.isRecord).toBeTypeOf("function"); 32 + expect(ForumAtgoraActorPreferences.validateRecord).toBeTypeOf("function"); 33 + }); 34 + }); 35 + 36 + describe("LEXICON_IDS constants", () => { 37 + it("has correct TopicPost ID", () => { 38 + expect(LEXICON_IDS.TopicPost).toBe("forum.atgora.topic.post"); 39 + }); 40 + 41 + it("has correct TopicReply ID", () => { 42 + expect(LEXICON_IDS.TopicReply).toBe("forum.atgora.topic.reply"); 43 + }); 44 + 45 + it("has correct Reaction ID", () => { 46 + expect(LEXICON_IDS.Reaction).toBe("forum.atgora.interaction.reaction"); 47 + }); 48 + 49 + it("has correct ActorPreferences ID", () => { 50 + expect(LEXICON_IDS.ActorPreferences).toBe( 51 + "forum.atgora.actor.preferences", 52 + ); 53 + }); 54 + }); 55 + 56 + describe("generated schemas", () => { 57 + it("exports schemas array", () => { 58 + expect(Array.isArray(schemas)).toBe(true); 59 + expect(schemas.length).toBeGreaterThan(0); 60 + }); 61 + 62 + it("schemas contain all ATgora lexicon IDs", () => { 63 + const schemaIds = schemas.map( 64 + (s: Record<string, unknown>) => s["id"] as string, 65 + ); 66 + expect(schemaIds).toContain("forum.atgora.topic.post"); 67 + expect(schemaIds).toContain("forum.atgora.topic.reply"); 68 + expect(schemaIds).toContain("forum.atgora.interaction.reaction"); 69 + expect(schemaIds).toContain("forum.atgora.actor.preferences"); 70 + }); 71 + }); 72 + 73 + describe("generated ids map", () => { 74 + it("maps ForumAtgoraTopicPost correctly", () => { 75 + expect(ids.ForumAtgoraTopicPost).toBe("forum.atgora.topic.post"); 76 + }); 77 + 78 + it("maps ForumAtgoraTopicReply correctly", () => { 79 + expect(ids.ForumAtgoraTopicReply).toBe("forum.atgora.topic.reply"); 80 + }); 81 + 82 + it("maps ForumAtgoraInteractionReaction correctly", () => { 83 + expect(ids.ForumAtgoraInteractionReaction).toBe( 84 + "forum.atgora.interaction.reaction", 85 + ); 86 + }); 87 + 88 + it("maps ForumAtgoraActorPreferences correctly", () => { 89 + expect(ids.ForumAtgoraActorPreferences).toBe( 90 + "forum.atgora.actor.preferences", 91 + ); 92 + }); 93 + }); 94 + 95 + describe("isRecord type guards", () => { 96 + it("ForumAtgoraTopicPost.isRecord identifies correct $type", () => { 97 + expect( 98 + ForumAtgoraTopicPost.isRecord({ 99 + $type: "forum.atgora.topic.post", 100 + title: "Test", 101 + }), 102 + ).toBe(true); 103 + }); 104 + 105 + it("ForumAtgoraTopicPost.isRecord rejects wrong $type", () => { 106 + expect( 107 + ForumAtgoraTopicPost.isRecord({ 108 + $type: "forum.atgora.topic.reply", 109 + content: "Test", 110 + }), 111 + ).toBe(false); 112 + }); 113 + 114 + it("ForumAtgoraTopicPost.isRecord rejects non-objects", () => { 115 + expect(ForumAtgoraTopicPost.isRecord("string")).toBe(false); 116 + expect(ForumAtgoraTopicPost.isRecord(null)).toBe(false); 117 + expect(ForumAtgoraTopicPost.isRecord(undefined)).toBe(false); 118 + }); 119 + });
+269
tests/zod-validation.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { 3 + topicPostSchema, 4 + topicReplySchema, 5 + reactionSchema, 6 + actorPreferencesSchema, 7 + } from "../src/validation/index.js"; 8 + 9 + const VALID_DID = "did:plc:abc123def456"; 10 + const VALID_DATETIME = "2026-02-12T10:00:00.000Z"; 11 + const VALID_STRONG_REF = { 12 + uri: "at://did:plc:abc123/forum.atgora.topic.post/3jzfcijpj2z2a", 13 + cid: "bafyreibouvacvqhc2vkwwtdkfynpcaoatmkde7uhrw47ne4gu63cnzc7yq", 14 + }; 15 + 16 + describe("topicPostSchema", () => { 17 + const validPost = { 18 + title: "Test Topic", 19 + content: "This is a test topic body.", 20 + community: VALID_DID, 21 + category: "general", 22 + createdAt: VALID_DATETIME, 23 + }; 24 + 25 + it("accepts a valid minimal post", () => { 26 + expect(topicPostSchema.safeParse(validPost).success).toBe(true); 27 + }); 28 + 29 + it("accepts a post with all optional fields", () => { 30 + const full = { 31 + ...validPost, 32 + contentFormat: "markdown" as const, 33 + tags: ["test", "poc"], 34 + labels: { values: [{ val: "sexual" }] }, 35 + }; 36 + expect(topicPostSchema.safeParse(full).success).toBe(true); 37 + }); 38 + 39 + it("rejects empty title", () => { 40 + expect( 41 + topicPostSchema.safeParse({ ...validPost, title: "" }).success, 42 + ).toBe(false); 43 + }); 44 + 45 + it("rejects title exceeding maxLength (2000 bytes)", () => { 46 + const longTitle = "x".repeat(2001); 47 + expect( 48 + topicPostSchema.safeParse({ ...validPost, title: longTitle }).success, 49 + ).toBe(false); 50 + }); 51 + 52 + it("rejects empty content", () => { 53 + expect( 54 + topicPostSchema.safeParse({ ...validPost, content: "" }).success, 55 + ).toBe(false); 56 + }); 57 + 58 + it("rejects content exceeding maxLength (100000)", () => { 59 + const longContent = "x".repeat(100_001); 60 + expect( 61 + topicPostSchema.safeParse({ ...validPost, content: longContent }).success, 62 + ).toBe(false); 63 + }); 64 + 65 + it("rejects invalid DID format for community", () => { 66 + expect( 67 + topicPostSchema.safeParse({ ...validPost, community: "not-a-did" }) 68 + .success, 69 + ).toBe(false); 70 + }); 71 + 72 + it("rejects more than 5 tags", () => { 73 + const tooManyTags = { 74 + ...validPost, 75 + tags: ["a", "b", "c", "d", "e", "f"], 76 + }; 77 + expect(topicPostSchema.safeParse(tooManyTags).success).toBe(false); 78 + }); 79 + 80 + it("rejects empty tag strings", () => { 81 + expect( 82 + topicPostSchema.safeParse({ ...validPost, tags: [""] }).success, 83 + ).toBe(false); 84 + }); 85 + 86 + it("rejects invalid datetime format", () => { 87 + expect( 88 + topicPostSchema.safeParse({ ...validPost, createdAt: "not-a-date" }) 89 + .success, 90 + ).toBe(false); 91 + }); 92 + 93 + it("rejects empty category", () => { 94 + expect( 95 + topicPostSchema.safeParse({ ...validPost, category: "" }).success, 96 + ).toBe(false); 97 + }); 98 + 99 + it("rejects missing required fields", () => { 100 + expect(topicPostSchema.safeParse({}).success).toBe(false); 101 + expect( 102 + topicPostSchema.safeParse({ title: "Test" }).success, 103 + ).toBe(false); 104 + }); 105 + 106 + it("rejects invalid contentFormat", () => { 107 + expect( 108 + topicPostSchema.safeParse({ ...validPost, contentFormat: "html" }) 109 + .success, 110 + ).toBe(false); 111 + }); 112 + 113 + it("rejects labels with more than 10 values", () => { 114 + const tooManyLabels = { 115 + ...validPost, 116 + labels: { 117 + values: Array.from({ length: 11 }, (_, i) => ({ val: `label-${String(i)}` })), 118 + }, 119 + }; 120 + expect(topicPostSchema.safeParse(tooManyLabels).success).toBe(false); 121 + }); 122 + }); 123 + 124 + describe("topicReplySchema", () => { 125 + const validReply = { 126 + content: "This is a reply.", 127 + root: VALID_STRONG_REF, 128 + parent: VALID_STRONG_REF, 129 + community: VALID_DID, 130 + createdAt: VALID_DATETIME, 131 + }; 132 + 133 + it("accepts a valid reply", () => { 134 + expect(topicReplySchema.safeParse(validReply).success).toBe(true); 135 + }); 136 + 137 + it("rejects missing root", () => { 138 + const { root: _, ...noRoot } = validReply; 139 + expect(topicReplySchema.safeParse(noRoot).success).toBe(false); 140 + }); 141 + 142 + it("rejects missing parent", () => { 143 + const { parent: _, ...noParent } = validReply; 144 + expect(topicReplySchema.safeParse(noParent).success).toBe(false); 145 + }); 146 + 147 + it("rejects invalid strongRef (missing cid)", () => { 148 + expect( 149 + topicReplySchema.safeParse({ 150 + ...validReply, 151 + root: { uri: "at://did:plc:abc/test/123" }, 152 + }).success, 153 + ).toBe(false); 154 + }); 155 + 156 + it("rejects content exceeding 50000 bytes", () => { 157 + expect( 158 + topicReplySchema.safeParse({ 159 + ...validReply, 160 + content: "x".repeat(50_001), 161 + }).success, 162 + ).toBe(false); 163 + }); 164 + }); 165 + 166 + describe("reactionSchema", () => { 167 + const validReaction = { 168 + subject: VALID_STRONG_REF, 169 + type: "like", 170 + community: VALID_DID, 171 + createdAt: VALID_DATETIME, 172 + }; 173 + 174 + it("accepts a valid reaction", () => { 175 + expect(reactionSchema.safeParse(validReaction).success).toBe(true); 176 + }); 177 + 178 + it("rejects empty type string", () => { 179 + expect( 180 + reactionSchema.safeParse({ ...validReaction, type: "" }).success, 181 + ).toBe(false); 182 + }); 183 + 184 + it("rejects type exceeding 300 bytes", () => { 185 + expect( 186 + reactionSchema.safeParse({ ...validReaction, type: "x".repeat(301) }) 187 + .success, 188 + ).toBe(false); 189 + }); 190 + 191 + it("rejects invalid subject (not a strongRef)", () => { 192 + expect( 193 + reactionSchema.safeParse({ 194 + ...validReaction, 195 + subject: { uri: "not-an-at-uri", cid: "test" }, 196 + }).success, 197 + ).toBe(false); 198 + }); 199 + }); 200 + 201 + describe("actorPreferencesSchema", () => { 202 + const validPrefs = { 203 + maturityLevel: "safe" as const, 204 + updatedAt: VALID_DATETIME, 205 + }; 206 + 207 + it("accepts valid minimal preferences", () => { 208 + expect(actorPreferencesSchema.safeParse(validPrefs).success).toBe(true); 209 + }); 210 + 211 + it("accepts all maturity levels", () => { 212 + for (const level of ["safe", "mature", "all"]) { 213 + expect( 214 + actorPreferencesSchema.safeParse({ 215 + ...validPrefs, 216 + maturityLevel: level, 217 + }).success, 218 + ).toBe(true); 219 + } 220 + }); 221 + 222 + it("rejects invalid maturity level", () => { 223 + expect( 224 + actorPreferencesSchema.safeParse({ 225 + ...validPrefs, 226 + maturityLevel: "extreme", 227 + }).success, 228 + ).toBe(false); 229 + }); 230 + 231 + it("accepts preferences with all optional fields", () => { 232 + const full = { 233 + ...validPrefs, 234 + mutedWords: ["spam", "crypto"], 235 + blockedDids: [VALID_DID], 236 + mutedDids: [VALID_DID], 237 + crossPostDefaults: { bluesky: true, frontpage: false }, 238 + }; 239 + expect(actorPreferencesSchema.safeParse(full).success).toBe(true); 240 + }); 241 + 242 + it("rejects more than 100 muted words", () => { 243 + const tooMany = { 244 + ...validPrefs, 245 + mutedWords: Array.from({ length: 101 }, (_, i) => `word-${String(i)}`), 246 + }; 247 + expect(actorPreferencesSchema.safeParse(tooMany).success).toBe(false); 248 + }); 249 + 250 + it("rejects more than 1000 blocked DIDs", () => { 251 + const tooMany = { 252 + ...validPrefs, 253 + blockedDids: Array.from( 254 + { length: 1001 }, 255 + (_, i) => `did:plc:blocked${String(i)}`, 256 + ), 257 + }; 258 + expect(actorPreferencesSchema.safeParse(tooMany).success).toBe(false); 259 + }); 260 + 261 + it("rejects invalid DID in blockedDids", () => { 262 + expect( 263 + actorPreferencesSchema.safeParse({ 264 + ...validPrefs, 265 + blockedDids: ["not-a-did"], 266 + }).success, 267 + ).toBe(false); 268 + }); 269 + });
+25
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "strict": true, 4 + "target": "ES2024", 5 + "module": "NodeNext", 6 + "moduleResolution": "NodeNext", 7 + "esModuleInterop": true, 8 + "skipLibCheck": true, 9 + "forceConsistentCasingInFileNames": true, 10 + "resolveJsonModule": true, 11 + "declaration": true, 12 + "declarationMap": true, 13 + "sourceMap": true, 14 + "noUncheckedIndexedAccess": true, 15 + "noImplicitReturns": true, 16 + "noFallthroughCasesInSwitch": true, 17 + "outDir": "dist", 18 + "rootDir": "src", 19 + "noUnusedLocals": false, 20 + "noUnusedParameters": false, 21 + "exactOptionalPropertyTypes": false 22 + }, 23 + "include": ["src"], 24 + "exclude": ["dist", "node_modules", "tests"] 25 + }
+19
vitest.config.ts
··· 1 + import { defineConfig } from "vitest/config"; 2 + 3 + export default defineConfig({ 4 + test: { 5 + globals: true, 6 + root: ".", 7 + coverage: { 8 + provider: "v8", 9 + reporter: ["text", "lcov"], 10 + thresholds: { 11 + statements: 80, 12 + branches: 80, 13 + functions: 80, 14 + lines: 80, 15 + }, 16 + }, 17 + testTimeout: 10_000, 18 + }, 19 + });