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: add forum.barazo.actor.signature lexicon (#53)

* feat(lexicon): add forum.barazo.actor.signature record type

Singleton record (key: self) for user forum signatures.
Portable across all Barazo instances via AT Protocol PDS.
Max 300 graphemes. Used by @barazo/plugin-signatures.

* feat(validation): add Zod schema for actor.signature

TDD: tests written first, then schema implemented.
Uses z.iso.datetime() consistent with existing schemas.

* feat(types): generate TypeScript types for actor.signature

Add ForumBarazoActorSignature to generated types, lexicon schemas,
main exports, LEXICON_IDS, and fixup script.

* fix: make pnpm generate work reliably

Replace broken glob-based generate script with node wrapper that:
- Discovers lexicon files via filesystem (pnpm doesn't expand globs)
- Excludes authForumAccess.json (permission-set type unsupported by lex-cli)
- Auto-confirms lex-cli prompt
- Runs fixup afterward

Make fixup-generated.js dynamic:
- Build index.ts from discovered type files (no hardcoded list)
- Inject excluded lexicons into schemaDict and ids after codegen

Both pre-existing issues (broken glob, lex-cli incompatibility) are now
handled automatically. Adding new lexicons no longer requires manual
script updates.

* chore: bump version to 0.2.0

New lexicon: forum.barazo.actor.signature

authored by

Guido X Jansen and committed by
GitHub
2106eaa6 0fd3d281

+321 -60
+27
lexicons/forum/barazo/actor/signature.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "forum.barazo.actor.signature", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A user's forum signature, displayed below their posts. Singleton record (one per user).", 8 + "key": "literal:self", 9 + "record": { 10 + "type": "object", 11 + "required": ["text", "createdAt"], 12 + "properties": { 13 + "text": { 14 + "type": "string", 15 + "description": "Signature content. Plain text or markdown depending on forum configuration.", 16 + "maxGraphemes": 300, 17 + "maxLength": 3000 18 + }, 19 + "createdAt": { 20 + "type": "string", 21 + "format": "datetime" 22 + } 23 + } 24 + } 25 + } 26 + } 27 + }
+2 -2
package.json
··· 1 1 { 2 2 "name": "@singi-labs/lexicons", 3 - "version": "0.1.2", 3 + "version": "0.2.0", 4 4 "description": "AT Protocol lexicon schemas and generated TypeScript types for the Barazo forum platform", 5 5 "type": "module", 6 6 "packageManager": "pnpm@10.29.2", ··· 32 32 "test:watch": "vitest", 33 33 "test:compat": "vitest run tests/backward-compatibility.test.ts", 34 34 "test:coverage": "vitest run --coverage", 35 - "generate": "lex gen-server ./src/generated ./lexicons/**/*.json && node scripts/fixup-generated.js", 35 + "generate": "node scripts/generate.js", 36 36 "clean": "rm -rf dist", 37 37 "format": "prettier --write .", 38 38 "format:check": "prettier --check .",
+81 -30
scripts/fixup-generated.js
··· 5 5 * 1. Missing .js extensions on relative imports (incompatible with NodeNext) 6 6 * 2. An XRPC server/client wrapper index.ts we don't need 7 7 * 8 - * This script fixes the imports and replaces the generated index.ts 9 - * with a clean re-export file. 8 + * Additionally, some lexicons use types lex-cli can't process (e.g. permission-set). 9 + * These are excluded from codegen but injected into lexicons.ts schemaDict and ids 10 + * so runtime validation and ID lookup still work. 11 + * 12 + * This script: 13 + * - Builds a clean index.ts from discovered type files (no hardcoded list) 14 + * - Fixes missing .js import extensions 15 + * - Injects excluded lexicons into schemaDict and ids 10 16 */ 11 17 import { readdir, readFile, writeFile } from 'node:fs/promises' 12 - import { join } from 'node:path' 18 + import { join, relative } from 'node:path' 13 19 14 20 const GENERATED_DIR = new URL('../src/generated', import.meta.url).pathname 21 + const TYPES_DIR = join(GENERATED_DIR, 'types') 22 + const LEXICONS_DIR = new URL('../lexicons', import.meta.url).pathname 15 23 16 - const REPLACEMENT_INDEX = `/** 24 + // Lexicons excluded from lex-cli codegen but needing schemaDict/ids entries. 25 + // Each entry: { file: path relative to lexicons/, dictKey: schemaDict key } 26 + const EXCLUDED_LEXICONS = [ 27 + { file: 'forum/barazo/authForumAccess.json', dictKey: 'ForumBarazoAuthForumAccess' }, 28 + ] 29 + 30 + async function getTypeFiles(dir) { 31 + const entries = await readdir(dir, { withFileTypes: true, recursive: true }) 32 + return entries 33 + .filter((e) => e.isFile() && e.name.endsWith('.ts')) 34 + .map((e) => join(e.parentPath ?? e.path, e.name)) 35 + } 36 + 37 + function toExportName(filePath) { 38 + const rel = relative(TYPES_DIR, filePath).replace(/\.ts$/, '') 39 + return rel 40 + .split('/') 41 + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) 42 + .join('') 43 + } 44 + 45 + async function buildReplacementIndex(typeFiles) { 46 + const exports = typeFiles 47 + .map((file) => { 48 + const name = toExportName(file) 49 + const relPath = './' + relative(GENERATED_DIR, file).replace(/\.ts$/, '.js') 50 + return `export * as ${name} from "${relPath}";` 51 + }) 52 + .sort() 53 + 54 + return `/** 17 55 * GENERATED CODE - Re-exports only. 18 56 * The XRPC server/client wrappers generated by lex-cli are replaced with 19 57 * direct type re-exports since we use @atproto/api for PDS interactions. 20 58 */ 21 - export * as AppBskyRichtextFacet from "./types/app/bsky/richtext/facet.js"; 22 - export * as ComAtprotoLabelDefs from "./types/com/atproto/label/defs.js"; 23 - export * as ComAtprotoRepoStrongRef from "./types/com/atproto/repo/strongRef.js"; 24 - export * as ForumBarazoActorPreferences from "./types/forum/barazo/actor/preferences.js"; 25 - export * as ForumBarazoDefs from "./types/forum/barazo/defs.js"; 26 - export * as ForumBarazoInteractionReaction from "./types/forum/barazo/interaction/reaction.js"; 27 - export * as ForumBarazoInteractionVote from "./types/forum/barazo/interaction/vote.js"; 28 - export * as ForumBarazoTopicPost from "./types/forum/barazo/topic/post.js"; 29 - export * as ForumBarazoTopicReply from "./types/forum/barazo/topic/reply.js"; 59 + ${exports.join('\n')} 30 60 export { schemas, validate } from "./lexicons.js"; 31 61 ` 32 - 33 - async function getTypeFiles(dir) { 34 - const entries = await readdir(dir, { withFileTypes: true, recursive: true }) 35 - return entries 36 - .filter((e) => e.isFile() && e.name.endsWith('.ts')) 37 - .map((e) => join(e.parentPath ?? e.path, e.name)) 38 62 } 39 63 40 64 async function fixImportExtensions(filePath) { 41 65 let content = await readFile(filePath, 'utf-8') 42 66 const original = content 43 - 44 - // Fix relative imports missing .js extension 45 - // Match: from './foo' or from '../foo' but NOT from './foo.js' or from '@scope/pkg' 46 67 content = content.replace(/from '(\.\.?\/[^']+?)(?<!\.js)'/g, "from '$1.js'") 47 - 48 68 if (content !== original) { 49 69 await writeFile(filePath, content) 50 70 } 51 71 } 52 72 73 + async function injectExcludedLexicons(lexiconsFile) { 74 + let content = await readFile(lexiconsFile, 'utf-8') 75 + 76 + for (const { file, dictKey } of EXCLUDED_LEXICONS) { 77 + // Skip if already present (idempotent) 78 + if (content.includes(` ${dictKey}:`)) continue 79 + 80 + const lexiconJson = JSON.parse(await readFile(join(LEXICONS_DIR, file), 'utf-8')) 81 + 82 + // Insert into schemaDict (before the closing `} as const satisfies`) 83 + const schemaDictEntry = ` ${dictKey}: ${JSON.stringify(lexiconJson, null, 4).replace(/\n/g, '\n ')},\n` 84 + content = content.replace( 85 + '} as const satisfies Record<string, LexiconDoc>', 86 + `${schemaDictEntry}} as const satisfies Record<string, LexiconDoc>` 87 + ) 88 + 89 + // Insert into ids (before the closing `} as const`) 90 + // Find the ids block and insert alphabetically 91 + const idsEntry = ` ${dictKey}: '${lexiconJson.id}',\n` 92 + const idsMatch = content.match(/export const ids = \{[\s\S]*?\} as const/) 93 + if (idsMatch && !idsMatch[0].includes(dictKey)) { 94 + content = content.replace(/(\} as const)$/m, `${idsEntry}$1`) 95 + } 96 + 97 + console.log(`Injected excluded lexicon: ${dictKey} (${lexiconJson.id})`) 98 + } 99 + 100 + await writeFile(lexiconsFile, content) 101 + } 102 + 53 103 async function main() { 54 - // Replace the generated index.ts 55 - await writeFile(join(GENERATED_DIR, 'index.ts'), REPLACEMENT_INDEX) 104 + const typeFiles = await getTypeFiles(TYPES_DIR) 105 + const indexContent = await buildReplacementIndex(typeFiles) 106 + await writeFile(join(GENERATED_DIR, 'index.ts'), indexContent) 56 107 57 - // Fix import extensions in all generated type files 58 - const files = await getTypeFiles(join(GENERATED_DIR, 'types')) 59 - for (const file of files) { 108 + for (const file of typeFiles) { 60 109 await fixImportExtensions(file) 61 110 } 62 111 63 - // Also fix lexicons.ts and util.ts 64 112 await fixImportExtensions(join(GENERATED_DIR, 'lexicons.ts')) 65 113 await fixImportExtensions(join(GENERATED_DIR, 'util.ts')) 66 114 115 + // Inject lexicons that lex-cli can't process 116 + await injectExcludedLexicons(join(GENERATED_DIR, 'lexicons.ts')) 117 + 67 118 console.log( 68 - `Fixed ${files.length + 2} generated files (${files.length} types + lexicons.ts + util.ts)` 119 + `Fixed ${typeFiles.length + 2} generated files (${typeFiles.length} types + lexicons.ts + util.ts)` 69 120 ) 70 121 } 71 122
+47
scripts/generate.js
··· 1 + /** 2 + * Wrapper around lex-cli gen-server that handles: 3 + * 1. File discovery (pnpm doesn't expand globs in scripts) 4 + * 2. Excluding lexicons with types lex-cli can't process (permission-set) 5 + * 3. Auto-confirming the lex-cli prompt 6 + * 4. Running the fixup script afterward 7 + * 8 + * Usage: node scripts/generate.js 9 + */ 10 + import { readdirSync } from 'node:fs' 11 + import { join } from 'node:path' 12 + import { execSync } from 'node:child_process' 13 + 14 + const ROOT = new URL('..', import.meta.url).pathname 15 + const LEXICONS_DIR = join(ROOT, 'lexicons') 16 + const OUTPUT_DIR = join(ROOT, 'src', 'generated') 17 + 18 + // Lexicons that use types lex-cli cannot process (e.g. permission-set). 19 + // These are manually maintained in lexicons.ts instead. 20 + const EXCLUDED_FILES = ['authForumAccess.json'] 21 + 22 + function findJsonFiles(dir) { 23 + const results = [] 24 + for (const entry of readdirSync(dir, { withFileTypes: true })) { 25 + const fullPath = join(dir, entry.name) 26 + if (entry.isDirectory()) { 27 + results.push(...findJsonFiles(fullPath)) 28 + } else if (entry.name.endsWith('.json') && !EXCLUDED_FILES.includes(entry.name)) { 29 + results.push(fullPath) 30 + } 31 + } 32 + return results 33 + } 34 + 35 + const files = findJsonFiles(LEXICONS_DIR) 36 + if (files.length === 0) { 37 + console.error('No lexicon files found in', LEXICONS_DIR) 38 + process.exit(1) 39 + } 40 + 41 + console.log(`Found ${files.length} lexicon files (excluded: ${EXCLUDED_FILES.join(', ')})`) 42 + 43 + const cmd = `echo y | pnpm exec lex gen-server ${OUTPUT_DIR} ${files.join(' ')}` 44 + execSync(cmd, { cwd: ROOT, stdio: 'inherit' }) 45 + 46 + console.log('Running fixup script...') 47 + execSync('node scripts/fixup-generated.js', { cwd: ROOT, stdio: 'inherit' })
+1
src/generated/index.ts
··· 7 7 export * as ComAtprotoLabelDefs from "./types/com/atproto/label/defs.js"; 8 8 export * as ComAtprotoRepoStrongRef from "./types/com/atproto/repo/strongRef.js"; 9 9 export * as ForumBarazoActorPreferences from "./types/forum/barazo/actor/preferences.js"; 10 + export * as ForumBarazoActorSignature from "./types/forum/barazo/actor/signature.js"; 10 11 export * as ForumBarazoDefs from "./types/forum/barazo/defs.js"; 11 12 export * as ForumBarazoInteractionReaction from "./types/forum/barazo/interaction/reaction.js"; 12 13 export * as ForumBarazoInteractionVote from "./types/forum/barazo/interaction/vote.js";
+56 -28
src/generated/lexicons.ts
··· 291 291 }, 292 292 }, 293 293 }, 294 - ForumBarazoAuthForumAccess: { 295 - lexicon: 1, 296 - id: 'forum.barazo.authForumAccess', 297 - description: 298 - 'Permission set for Barazo forum access. Grants ability to create topics, replies, and reactions, and manage user preferences.', 299 - defs: { 300 - main: { 301 - type: 'permission-set', 302 - title: 'Barazo Forum', 303 - detail: 304 - 'Create topics, replies, and reactions. Manage your forum preferences.', 305 - permissions: [ 306 - { 307 - type: 'permission', 308 - resource: 'repo', 309 - collection: [ 310 - 'forum.barazo.topic.post', 311 - 'forum.barazo.topic.reply', 312 - 'forum.barazo.interaction.reaction', 313 - 'forum.barazo.interaction.vote', 314 - 'forum.barazo.actor.preferences', 315 - ], 316 - }, 317 - ], 318 - }, 319 - }, 320 - }, 321 294 ForumBarazoActorPreferences: { 322 295 lexicon: 1, 323 296 id: 'forum.barazo.actor.preferences', ··· 391 364 frontpage: { 392 365 type: 'boolean', 393 366 description: 'Cross-post new topics to Frontpage. Default: false.', 367 + }, 368 + }, 369 + }, 370 + }, 371 + }, 372 + ForumBarazoActorSignature: { 373 + lexicon: 1, 374 + id: 'forum.barazo.actor.signature', 375 + defs: { 376 + main: { 377 + type: 'record', 378 + description: 379 + "A user's forum signature, displayed below their posts. Singleton record (one per user).", 380 + key: 'literal:self', 381 + record: { 382 + type: 'object', 383 + required: ['text', 'createdAt'], 384 + properties: { 385 + text: { 386 + type: 'string', 387 + description: 388 + 'Signature content. Plain text or markdown depending on forum configuration.', 389 + maxGraphemes: 300, 390 + maxLength: 3000, 391 + }, 392 + createdAt: { 393 + type: 'string', 394 + format: 'datetime', 395 + }, 394 396 }, 395 397 }, 396 398 }, ··· 690 692 }, 691 693 }, 692 694 }, 695 + ForumBarazoAuthForumAccess: { 696 + "lexicon": 1, 697 + "id": "forum.barazo.authForumAccess", 698 + "description": "Permission set for Barazo forum access. Grants ability to create topics, replies, and reactions, and manage user preferences.", 699 + "defs": { 700 + "main": { 701 + "type": "permission-set", 702 + "title": "Barazo Forum", 703 + "detail": "Create topics, replies, and reactions. Manage your forum preferences.", 704 + "permissions": [ 705 + { 706 + "type": "permission", 707 + "resource": "repo", 708 + "collection": [ 709 + "forum.barazo.topic.post", 710 + "forum.barazo.topic.reply", 711 + "forum.barazo.interaction.reaction", 712 + "forum.barazo.interaction.vote", 713 + "forum.barazo.actor.preferences" 714 + ] 715 + } 716 + ] 717 + } 718 + } 719 + }, 693 720 } as const satisfies Record<string, LexiconDoc> 694 721 export const schemas = Object.values(schemaDict) satisfies LexiconDoc[] 695 722 export const lexicons: Lexicons = new Lexicons(schemas) ··· 727 754 ComAtprotoLabelDefs: 'com.atproto.label.defs', 728 755 ComAtprotoRepoStrongRef: 'com.atproto.repo.strongRef', 729 756 ForumBarazoActorPreferences: 'forum.barazo.actor.preferences', 730 - ForumBarazoAuthForumAccess: 'forum.barazo.authForumAccess', 757 + ForumBarazoActorSignature: 'forum.barazo.actor.signature', 731 758 ForumBarazoDefs: 'forum.barazo.defs', 732 759 ForumBarazoInteractionReaction: 'forum.barazo.interaction.reaction', 733 760 ForumBarazoInteractionVote: 'forum.barazo.interaction.vote', 734 761 ForumBarazoTopicPost: 'forum.barazo.topic.post', 735 762 ForumBarazoTopicReply: 'forum.barazo.topic.reply', 763 + ForumBarazoAuthForumAccess: 'forum.barazo.authForumAccess', 736 764 } as const
+39
src/generated/types/forum/barazo/actor/signature.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.barazo.actor.signature' 16 + 17 + export interface Main { 18 + $type: 'forum.barazo.actor.signature' 19 + /** Signature content. Plain text or markdown depending on forum configuration. */ 20 + text: string 21 + createdAt: string 22 + [k: string]: unknown 23 + } 24 + 25 + const hashMain = 'main' 26 + 27 + export function isMain<V>(v: V) { 28 + return is$typed(v, id, hashMain) 29 + } 30 + 31 + export function validateMain<V>(v: V) { 32 + return validate<Main & V>(v, id, hashMain, true) 33 + } 34 + 35 + export { 36 + type Main as Record, 37 + isMain as isRecord, 38 + validateMain as validateRecord, 39 + }
+4
src/index.ts
··· 14 14 ForumBarazoInteractionReaction, 15 15 ForumBarazoInteractionVote, 16 16 ForumBarazoActorPreferences, 17 + ForumBarazoActorSignature, 17 18 ForumBarazoDefs, 18 19 ComAtprotoRepoStrongRef, 19 20 ComAtprotoLabelDefs, ··· 35 36 selfLabelsSchema, 36 37 selfLabelSchema, 37 38 facetSchema, 39 + actorSignatureSchema, 38 40 communityRefSchema, 39 41 didRegex, 40 42 type TopicPostInput, ··· 42 44 type ReactionInput, 43 45 type VoteInput, 44 46 type ActorPreferencesInput, 47 + type ActorSignatureInput, 45 48 type CommunityRefInput, 46 49 } from './validation/index.js' 47 50 ··· 52 55 Reaction: 'forum.barazo.interaction.reaction', 53 56 Vote: 'forum.barazo.interaction.vote', 54 57 ActorPreferences: 'forum.barazo.actor.preferences', 58 + ActorSignature: 'forum.barazo.actor.signature', 55 59 AuthForumAccess: 'forum.barazo.authForumAccess', 56 60 } as const
+14
src/validation/actor-signature.ts
··· 1 + import { z } from 'zod' 2 + 3 + /** 4 + * Zod schema for forum.barazo.actor.signature records. 5 + * 6 + * Mirrors the lexicon constraints from the signature lexicon. 7 + * Singleton record (key: literal:self). 8 + */ 9 + export const actorSignatureSchema = z.object({ 10 + text: z.string().min(1).max(3000), 11 + createdAt: z.iso.datetime(), 12 + }) 13 + 14 + export type ActorSignatureInput = z.input<typeof actorSignatureSchema>
+1
src/validation/index.ts
··· 11 11 export { selfLabelsSchema, selfLabelSchema } from './self-labels.js' 12 12 export { facetSchema } from './facet.js' 13 13 export { communityRefSchema, type CommunityRefInput } from './community-ref.js' 14 + export { actorSignatureSchema, type ActorSignatureInput } from './actor-signature.js' 14 15 export { didRegex } from './patterns.js'
+49
tests/zod-validation.test.ts
··· 6 6 voteSchema, 7 7 actorPreferencesSchema, 8 8 communityRefSchema, 9 + actorSignatureSchema, 9 10 } from '../src/validation/index.js' 10 11 11 12 const VALID_DID = 'did:plc:abc123def456' ··· 359 360 expect(communityRefSchema.safeParse({}).success).toBe(false) 360 361 }) 361 362 }) 363 + 364 + describe('actorSignatureSchema', () => { 365 + it('validates a valid signature', () => { 366 + const result = actorSignatureSchema.safeParse({ 367 + text: 'Building forums for the open web', 368 + createdAt: '2026-03-04T12:00:00.000Z', 369 + }) 370 + expect(result.success).toBe(true) 371 + }) 372 + 373 + it('rejects empty text', () => { 374 + const result = actorSignatureSchema.safeParse({ 375 + text: '', 376 + createdAt: '2026-03-04T12:00:00.000Z', 377 + }) 378 + expect(result.success).toBe(false) 379 + }) 380 + 381 + it('rejects text exceeding 3000 bytes', () => { 382 + const result = actorSignatureSchema.safeParse({ 383 + text: 'a'.repeat(3001), 384 + createdAt: '2026-03-04T12:00:00.000Z', 385 + }) 386 + expect(result.success).toBe(false) 387 + }) 388 + 389 + it('rejects missing createdAt', () => { 390 + const result = actorSignatureSchema.safeParse({ 391 + text: 'Hello', 392 + }) 393 + expect(result.success).toBe(false) 394 + }) 395 + 396 + it('rejects invalid datetime format', () => { 397 + const result = actorSignatureSchema.safeParse({ 398 + text: 'Hello', 399 + createdAt: 'not-a-date', 400 + }) 401 + expect(result.success).toBe(false) 402 + }) 403 + 404 + it('rejects missing text', () => { 405 + const result = actorSignatureSchema.safeParse({ 406 + createdAt: '2026-03-04T12:00:00.000Z', 407 + }) 408 + expect(result.success).toBe(false) 409 + }) 410 + })