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: align content model with site.standard for interop (#54)

* chore: add .worktrees/ to .gitignore

* feat: align content model with site.standard for interop

Three schema changes for standard.site compatibility:

1. Content union: topic.post and topic.reply `content` fields
change from bare string to open union with `$type` discriminator.
Initial type: `forum.barazo.richtext#markdown`. Enables future
content format extensibility.

2. Site field: optional `site` string on topic.post for linking
to a site.standard.publication record (at:// URI or https://).

3. Rename createdAt → publishedAt on topic.post to match
site.standard.document semantics.

New lexicon: forum.barazo.richtext (defines #markdown content type).
New Zod schema: markdownContentSchema with matching validation.

Closes singi-labs/barazo-workspace#80

BREAKING CHANGE: topic.post.content and topic.reply.content are now
union objects with $type discriminator instead of bare strings.
topic.post.createdAt renamed to publishedAt.

authored by

Guido X Jansen and committed by
GitHub
fe1bd7be 2106eaa6

+276 -107
+3
.gitignore
··· 1 1 # Claude Code 2 2 .claude/ 3 3 4 + # Git worktrees 5 + .worktrees/ 6 + 4 7 # Logs 5 8 logs 6 9 *.log
+20
lexicons/forum/barazo/richtext.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "forum.barazo.richtext", 4 + "description": "Content format definitions for forum post and reply bodies.", 5 + "defs": { 6 + "markdown": { 7 + "type": "object", 8 + "description": "Markdown-formatted text content.", 9 + "required": ["value"], 10 + "properties": { 11 + "value": { 12 + "type": "string", 13 + "minLength": 1, 14 + "maxLength": 100000, 15 + "description": "Markdown-formatted text." 16 + } 17 + } 18 + } 19 + } 20 + }
+11 -12
lexicons/forum/barazo/topic/post.json
··· 9 9 "key": "tid", 10 10 "record": { 11 11 "type": "object", 12 - "required": ["title", "content", "community", "category", "createdAt"], 12 + "required": ["title", "content", "community", "category", "publishedAt"], 13 13 "properties": { 14 14 "title": { 15 15 "type": "string", ··· 19 19 "description": "Topic title." 20 20 }, 21 21 "content": { 22 - "type": "string", 23 - "minLength": 1, 24 - "maxLength": 100000, 25 - "description": "Topic body in markdown." 26 - }, 27 - "contentFormat": { 28 - "type": "string", 29 - "knownValues": ["markdown"], 30 - "description": "Content format. Defaults to 'markdown' if omitted." 22 + "type": "union", 23 + "description": "Post body content. Open union for extensible content formats.", 24 + "refs": ["forum.barazo.richtext#markdown"] 31 25 }, 32 26 "community": { 33 27 "type": "string", ··· 40 34 "maxLength": 640, 41 35 "maxGraphemes": 64, 42 36 "description": "Category record key (slug) within the community. Follows AT Protocol record key syntax." 37 + }, 38 + "site": { 39 + "type": "string", 40 + "maxLength": 5000, 41 + "description": "Reference to a site.standard.publication record (at:// URI) or publication URL (https://). Enables cross-app content discovery." 43 42 }, 44 43 "tags": { 45 44 "type": "array", ··· 74 73 "description": "Self-label values for content maturity (e.g., sexual, nudity, graphic-media).", 75 74 "refs": ["com.atproto.label.defs#selfLabels"] 76 75 }, 77 - "createdAt": { 76 + "publishedAt": { 78 77 "type": "string", 79 78 "format": "datetime", 80 - "description": "Client-declared timestamp when this post was originally created." 79 + "description": "Client-declared timestamp when this post was originally published." 81 80 } 82 81 } 83 82 }
+3 -9
lexicons/forum/barazo/topic/reply.json
··· 12 12 "required": ["content", "root", "parent", "community", "createdAt"], 13 13 "properties": { 14 14 "content": { 15 - "type": "string", 16 - "minLength": 1, 17 - "maxLength": 50000, 18 - "description": "Reply body in markdown." 19 - }, 20 - "contentFormat": { 21 - "type": "string", 22 - "knownValues": ["markdown"], 23 - "description": "Content format. Defaults to 'markdown' if omitted." 15 + "type": "union", 16 + "description": "Reply body content. Open union for extensible content formats.", 17 + "refs": ["forum.barazo.richtext#markdown"] 24 18 }, 25 19 "root": { 26 20 "type": "ref",
+1 -1
package.json
··· 1 1 { 2 2 "name": "@singi-labs/lexicons", 3 - "version": "0.2.0", 3 + "version": "0.3.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",
+1
src/generated/index.ts
··· 11 11 export * as ForumBarazoDefs from "./types/forum/barazo/defs.js"; 12 12 export * as ForumBarazoInteractionReaction from "./types/forum/barazo/interaction/reaction.js"; 13 13 export * as ForumBarazoInteractionVote from "./types/forum/barazo/interaction/vote.js"; 14 + export * as ForumBarazoRichtext from "./types/forum/barazo/richtext.js"; 14 15 export * as ForumBarazoTopicPost from "./types/forum/barazo/topic/post.js"; 15 16 export * as ForumBarazoTopicReply from "./types/forum/barazo/topic/reply.js"; 16 17 export { schemas, validate } from "./lexicons.js";
+44 -21
src/generated/lexicons.ts
··· 520 520 }, 521 521 }, 522 522 }, 523 + ForumBarazoRichtext: { 524 + lexicon: 1, 525 + id: 'forum.barazo.richtext', 526 + description: 'Content format definitions for forum post and reply bodies.', 527 + defs: { 528 + markdown: { 529 + type: 'object', 530 + description: 'Markdown-formatted text content.', 531 + required: ['value'], 532 + properties: { 533 + value: { 534 + type: 'string', 535 + minLength: 1, 536 + maxLength: 100000, 537 + description: 'Markdown-formatted text.', 538 + }, 539 + }, 540 + }, 541 + }, 542 + }, 523 543 ForumBarazoTopicPost: { 524 544 lexicon: 1, 525 545 id: 'forum.barazo.topic.post', ··· 532 552 key: 'tid', 533 553 record: { 534 554 type: 'object', 535 - required: ['title', 'content', 'community', 'category', 'createdAt'], 555 + required: [ 556 + 'title', 557 + 'content', 558 + 'community', 559 + 'category', 560 + 'publishedAt', 561 + ], 536 562 properties: { 537 563 title: { 538 564 type: 'string', ··· 542 568 description: 'Topic title.', 543 569 }, 544 570 content: { 545 - type: 'string', 546 - minLength: 1, 547 - maxLength: 100000, 548 - description: 'Topic body in markdown.', 549 - }, 550 - contentFormat: { 551 - type: 'string', 552 - knownValues: ['markdown'], 553 - description: "Content format. Defaults to 'markdown' if omitted.", 571 + type: 'union', 572 + description: 573 + 'Post body content. Open union for extensible content formats.', 574 + refs: ['lex:forum.barazo.richtext#markdown'], 554 575 }, 555 576 community: { 556 577 type: 'string', ··· 565 586 maxGraphemes: 64, 566 587 description: 567 588 'Category record key (slug) within the community. Follows AT Protocol record key syntax.', 589 + }, 590 + site: { 591 + type: 'string', 592 + maxLength: 5000, 593 + description: 594 + 'Reference to a site.standard.publication record (at:// URI) or publication URL (https://). Enables cross-app content discovery.', 568 595 }, 569 596 tags: { 570 597 type: 'array', ··· 602 629 'Self-label values for content maturity (e.g., sexual, nudity, graphic-media).', 603 630 refs: ['lex:com.atproto.label.defs#selfLabels'], 604 631 }, 605 - createdAt: { 632 + publishedAt: { 606 633 type: 'string', 607 634 format: 'datetime', 608 635 description: 609 - 'Client-declared timestamp when this post was originally created.', 636 + 'Client-declared timestamp when this post was originally published.', 610 637 }, 611 638 }, 612 639 }, ··· 629 656 required: ['content', 'root', 'parent', 'community', 'createdAt'], 630 657 properties: { 631 658 content: { 632 - type: 'string', 633 - minLength: 1, 634 - maxLength: 50000, 635 - description: 'Reply body in markdown.', 636 - }, 637 - contentFormat: { 638 - type: 'string', 639 - knownValues: ['markdown'], 640 - description: "Content format. Defaults to 'markdown' if omitted.", 659 + type: 'union', 660 + description: 661 + 'Reply body content. Open union for extensible content formats.', 662 + refs: ['lex:forum.barazo.richtext#markdown'], 641 663 }, 642 664 root: { 643 665 type: 'ref', ··· 758 780 ForumBarazoDefs: 'forum.barazo.defs', 759 781 ForumBarazoInteractionReaction: 'forum.barazo.interaction.reaction', 760 782 ForumBarazoInteractionVote: 'forum.barazo.interaction.vote', 783 + ForumBarazoRichtext: 'forum.barazo.richtext', 761 784 ForumBarazoTopicPost: 'forum.barazo.topic.post', 762 785 ForumBarazoTopicReply: 'forum.barazo.topic.reply', 763 786 ForumBarazoAuthForumAccess: 'forum.barazo.authForumAccess',
+28
src/generated/types/forum/barazo/richtext.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.barazo.richtext' 12 + 13 + /** Markdown-formatted text content. */ 14 + export interface Markdown { 15 + $type?: 'forum.barazo.richtext#markdown' 16 + /** Markdown-formatted text. */ 17 + value: string 18 + } 19 + 20 + const hashMarkdown = 'markdown' 21 + 22 + export function isMarkdown<V>(v: V) { 23 + return is$typed(v, id, hashMarkdown) 24 + } 25 + 26 + export function validateMarkdown<V>(v: V) { 27 + return validate<Markdown & V>(v, id, hashMarkdown) 28 + }
+6 -6
src/generated/types/forum/barazo/topic/post.ts
··· 9 9 is$typed as _is$typed, 10 10 type OmitKey, 11 11 } from '../../../../util.js' 12 + import type * as ForumBarazoRichtext from '../richtext.js' 12 13 import type * as AppBskyRichtextFacet from '../../../app/bsky/richtext/facet.js' 13 14 import type * as ComAtprotoLabelDefs from '../../../com/atproto/label/defs.js' 14 15 ··· 20 21 $type: 'forum.barazo.topic.post' 21 22 /** Topic title. */ 22 23 title: string 23 - /** Topic body in markdown. */ 24 - content: string 25 - /** Content format. Defaults to 'markdown' if omitted. */ 26 - contentFormat?: 'markdown' | (string & {}) 24 + content: $Typed<ForumBarazoRichtext.Markdown> | { $type: string } 27 25 /** DID of the community where this record was created. Immutable origin identifier for cross-community attribution. */ 28 26 community: string 29 27 /** Category record key (slug) within the community. Follows AT Protocol record key syntax. */ 30 28 category: string 29 + /** Reference to a site.standard.publication record (at:// URI) or publication URL (https://). Enables cross-app content discovery. */ 30 + site?: string 31 31 /** Topic tags. Lowercase alphanumeric + hyphens. */ 32 32 tags?: string[] 33 33 /** Annotations of text (mentions, URLs, hashtags, etc). */ ··· 35 35 /** BCP 47 language tags indicating the primary language(s) of the content. */ 36 36 langs?: string[] 37 37 labels?: $Typed<ComAtprotoLabelDefs.SelfLabels> | { $type: string } 38 - /** Client-declared timestamp when this post was originally created. */ 39 - createdAt: string 38 + /** Client-declared timestamp when this post was originally published. */ 39 + publishedAt: string 40 40 [k: string]: unknown 41 41 } 42 42
+2 -4
src/generated/types/forum/barazo/topic/reply.ts
··· 9 9 is$typed as _is$typed, 10 10 type OmitKey, 11 11 } from '../../../../util.js' 12 + import type * as ForumBarazoRichtext from '../richtext.js' 12 13 import type * as ComAtprotoRepoStrongRef from '../../../com/atproto/repo/strongRef.js' 13 14 import type * as AppBskyRichtextFacet from '../../../app/bsky/richtext/facet.js' 14 15 import type * as ComAtprotoLabelDefs from '../../../com/atproto/label/defs.js' ··· 19 20 20 21 export interface Main { 21 22 $type: 'forum.barazo.topic.reply' 22 - /** Reply body in markdown. */ 23 - content: string 24 - /** Content format. Defaults to 'markdown' if omitted. */ 25 - contentFormat?: 'markdown' | (string & {}) 23 + content: $Typed<ForumBarazoRichtext.Markdown> | { $type: string } 26 24 root: ComAtprotoRepoStrongRef.Main 27 25 parent: ComAtprotoRepoStrongRef.Main 28 26 /** DID of the community where this reply was created. Immutable origin identifier. */
+1
src/validation/index.ts
··· 12 12 export { facetSchema } from './facet.js' 13 13 export { communityRefSchema, type CommunityRefInput } from './community-ref.js' 14 14 export { actorSignatureSchema, type ActorSignatureInput } from './actor-signature.js' 15 + export { markdownContentSchema, type MarkdownContentInput } from './richtext.js' 15 16 export { didRegex } from './patterns.js'
+14
src/validation/richtext.ts
··· 1 + import { z } from 'zod' 2 + 3 + /** 4 + * Zod schema for forum.barazo.richtext#markdown content. 5 + * 6 + * Content is wrapped in a union discriminator object with $type. 7 + * The value field contains the actual markdown text. 8 + */ 9 + export const markdownContentSchema = z.object({ 10 + $type: z.literal('forum.barazo.richtext#markdown'), 11 + value: z.string().min(1).max(100_000), 12 + }) 13 + 14 + export type MarkdownContentInput = z.input<typeof markdownContentSchema>
+4 -4
src/validation/topic-post.ts
··· 2 2 import { selfLabelsSchema } from './self-labels.js' 3 3 import { facetSchema } from './facet.js' 4 4 import { didRegex, recordKeyRegex } from './patterns.js' 5 + import { markdownContentSchema } from './richtext.js' 5 6 6 7 /** 7 8 * Zod schema for forum.barazo.topic.post records. 8 9 * 9 - * Mirrors the lexicon constraints from prd-lexicons.md section 3.1. 10 10 * Note: maxLength in lexicon = UTF-8 bytes, but Zod .max() counts 11 11 * JS string length (UTF-16 code units). For ASCII-heavy content 12 12 * these are equivalent; for full Unicode safety the AppView should ··· 14 14 */ 15 15 export const topicPostSchema = z.object({ 16 16 title: z.string().min(1).max(2000), 17 - content: z.string().min(1).max(100_000), 18 - contentFormat: z.literal('markdown').optional(), 17 + content: markdownContentSchema, 19 18 community: z.string().regex(didRegex), 20 19 category: z.string().regex(recordKeyRegex).max(640), 20 + site: z.string().max(5000).optional(), 21 21 tags: z.array(z.string().min(1).max(300)).max(25).optional(), 22 22 facets: z.array(facetSchema).optional(), 23 23 langs: z.array(z.string().min(1)).max(3).optional(), 24 24 labels: selfLabelsSchema.optional(), 25 - createdAt: z.iso.datetime(), 25 + publishedAt: z.iso.datetime(), 26 26 }) 27 27 28 28 export type TopicPostInput = z.input<typeof topicPostSchema>
+2 -4
src/validation/topic-reply.ts
··· 3 3 import { selfLabelsSchema } from './self-labels.js' 4 4 import { facetSchema } from './facet.js' 5 5 import { didRegex } from './patterns.js' 6 + import { markdownContentSchema } from './richtext.js' 6 7 7 8 /** 8 9 * Zod schema for forum.barazo.topic.reply records. 9 - * 10 - * Mirrors the lexicon constraints from prd-lexicons.md section 3.2. 11 10 */ 12 11 export const topicReplySchema = z.object({ 13 - content: z.string().min(1).max(50_000), 14 - contentFormat: z.literal('markdown').optional(), 12 + content: markdownContentSchema, 15 13 root: strongRefSchema, 16 14 parent: strongRefSchema, 17 15 community: z.string().regex(didRegex),
+7 -9
tests/backward-compatibility.test.ts
··· 82 82 83 83 const SCHEMA_SNAPSHOTS = { 84 84 'forum.barazo.topic.post': { 85 - requiredFields: ['title', 'content', 'community', 'category', 'createdAt'], 85 + requiredFields: ['title', 'content', 'community', 'category', 'publishedAt'], 86 86 allProperties: [ 87 87 'title', 88 88 'content', 89 - 'contentFormat', 90 89 'community', 91 90 'category', 91 + 'site', 92 92 'tags', 93 93 'facets', 94 94 'langs', 95 95 'labels', 96 - 'createdAt', 96 + 'publishedAt', 97 97 ], 98 98 }, 99 99 'forum.barazo.topic.reply': { 100 100 requiredFields: ['content', 'root', 'parent', 'community', 'createdAt'], 101 101 allProperties: [ 102 102 'content', 103 - 'contentFormat', 104 103 'root', 105 104 'parent', 106 105 'community', ··· 429 428 path: 'forum/barazo/topic/post.json', 430 429 types: { 431 430 title: 'string', 432 - content: 'string', 433 - contentFormat: 'string', 431 + content: 'union', 434 432 community: 'string', 435 433 category: 'string', 434 + site: 'string', 436 435 tags: 'array', 437 436 facets: 'array', 438 437 langs: 'array', 439 438 labels: 'union', 440 - createdAt: 'string', 439 + publishedAt: 'string', 441 440 }, 442 441 }, 443 442 'forum.barazo.topic.reply': { 444 443 path: 'forum/barazo/topic/reply.json', 445 444 types: { 446 - content: 'string', 447 - contentFormat: 'string', 445 + content: 'union', 448 446 root: 'ref', 449 447 parent: 'ref', 450 448 community: 'string',
+20 -18
tests/fixtures/baseline-records.ts
··· 1 1 /** 2 - * Baseline records representing data already stored on user PDSes. 3 - * 4 - * These fixtures simulate records created with the current schema version. 5 - * When schemas evolve (new optional fields, description changes, etc.), 6 - * these records MUST continue to validate. If any baseline record fails 7 - * validation after a schema change, the change is backward-incompatible 8 - * and must be reverted or handled via a new lexicon ID. 2 + * Baseline records representing the current schema version. 9 3 * 10 4 * Each record type has: 11 5 * - A minimal record (only required fields) 12 6 * - A full record (all optional fields populated) 13 7 * 14 - * DO NOT modify existing records in this file when adding new optional fields 15 - * to schemas. Instead, create new fixture variants that include the new fields. 16 - * The existing fixtures must remain unchanged to prove backward compatibility. 8 + * Updated for v0.3.0: content union, publishedAt, site field. 17 9 */ 18 10 19 11 const VALID_DID = 'did:plc:abc123def456' ··· 23 15 cid: 'bafyreibouvacvqhc2vkwwtdkfynpcaoatmkde7uhrw47ne4gu63cnzc7yq', 24 16 } 25 17 18 + const MARKDOWN_CONTENT = { 19 + $type: 'forum.barazo.richtext#markdown' as const, 20 + value: 'Hello, this is the body of my first topic post.', 21 + } 22 + 26 23 // ── topic.post ────────────────────────────────────────────────────── 27 24 28 25 export const topicPostMinimal = { 29 26 title: 'My First Topic', 30 - content: 'Hello, this is the body of my first topic post.', 27 + content: MARKDOWN_CONTENT, 31 28 community: VALID_DID, 32 29 category: 'general', 33 - createdAt: VALID_DATETIME, 30 + publishedAt: VALID_DATETIME, 34 31 } 35 32 36 33 export const topicPostFull = { 37 34 title: 'Full Featured Topic', 38 - content: 'This topic includes every optional field available at v0.1.0.', 39 - contentFormat: 'markdown' as const, 35 + content: { 36 + $type: 'forum.barazo.richtext#markdown' as const, 37 + value: 'This topic includes every optional field.', 38 + }, 40 39 community: VALID_DID, 41 40 category: 'announcements', 41 + site: 'at://did:plc:abc123/site.standard.publication/3lwafzkjqm25s', 42 42 tags: ['release', 'v1'], 43 43 labels: { 44 44 $type: 'com.atproto.label.defs#selfLabels', 45 45 values: [{ val: 'nudity' }], 46 46 }, 47 - createdAt: VALID_DATETIME, 47 + publishedAt: VALID_DATETIME, 48 48 } 49 49 50 50 // ── topic.reply ───────────────────────────────────────────────────── 51 51 52 52 export const topicReplyMinimal = { 53 - content: 'Great post, thanks for sharing!', 53 + content: MARKDOWN_CONTENT, 54 54 root: VALID_STRONG_REF, 55 55 parent: VALID_STRONG_REF, 56 56 community: VALID_DID, ··· 58 58 } 59 59 60 60 export const topicReplyFull = { 61 - content: 'This reply uses all optional fields available at v0.1.0.', 62 - contentFormat: 'markdown' as const, 61 + content: { 62 + $type: 'forum.barazo.richtext#markdown' as const, 63 + value: 'This reply uses all optional fields.', 64 + }, 63 65 root: VALID_STRONG_REF, 64 66 parent: VALID_STRONG_REF, 65 67 community: VALID_DID,
+35 -3
tests/lexicon-schemas.test.ts
··· 19 19 describe('Lexicon JSON schema structure', () => { 20 20 it('all lexicon files are valid JSON', async () => { 21 21 const files = await getAllLexiconFiles(LEXICONS_DIR) 22 - expect(files.length).toBeGreaterThanOrEqual(5) 22 + expect(files.length).toBeGreaterThanOrEqual(6) 23 23 for (const file of files) { 24 24 await expect(loadJson(file)).resolves.toBeDefined() 25 25 } ··· 63 63 expect(main['key']).toBe('tid') 64 64 }) 65 65 66 - it('has required fields: title, content, community, category, createdAt', () => { 66 + it('has required fields: title, content, community, category, publishedAt', () => { 67 67 const defs = schema['defs'] as Record<string, unknown> 68 68 const main = defs['main'] as Record<string, unknown> 69 69 const record = main['record'] as Record<string, unknown> ··· 72 72 expect(required).toContain('content') 73 73 expect(required).toContain('community') 74 74 expect(required).toContain('category') 75 - expect(required).toContain('createdAt') 75 + expect(required).toContain('publishedAt') 76 + }) 77 + 78 + it('content is a union referencing forum.barazo.richtext#markdown', () => { 79 + const defs = schema['defs'] as Record<string, unknown> 80 + const main = defs['main'] as Record<string, unknown> 81 + const record = main['record'] as Record<string, unknown> 82 + const props = record['properties'] as Record<string, unknown> 83 + const content = props['content'] as Record<string, unknown> 84 + expect(content['type']).toBe('union') 85 + expect(content['refs']).toContain('forum.barazo.richtext#markdown') 86 + }) 87 + 88 + it('has optional site field', () => { 89 + const defs = schema['defs'] as Record<string, unknown> 90 + const main = defs['main'] as Record<string, unknown> 91 + const record = main['record'] as Record<string, unknown> 92 + const required = record['required'] as string[] 93 + expect(required).not.toContain('site') 94 + const props = record['properties'] as Record<string, unknown> 95 + const site = props['site'] as Record<string, unknown> 96 + expect(site['type']).toBe('string') 97 + expect(site['maxLength']).toBe(5000) 76 98 }) 77 99 78 100 it('title has maxGraphemes: 200 and maxLength: 2000', () => { ··· 173 195 expect(required).toEqual( 174 196 expect.arrayContaining(['content', 'root', 'parent', 'community', 'createdAt']) 175 197 ) 198 + }) 199 + 200 + it('content is a union referencing forum.barazo.richtext#markdown', () => { 201 + const defs = schema['defs'] as Record<string, unknown> 202 + const main = defs['main'] as Record<string, unknown> 203 + const record = main['record'] as Record<string, unknown> 204 + const props = record['properties'] as Record<string, unknown> 205 + const content = props['content'] as Record<string, unknown> 206 + expect(content['type']).toBe('union') 207 + expect(content['refs']).toContain('forum.barazo.richtext#markdown') 176 208 }) 177 209 178 210 it('root and parent use strongRef', () => {
+74 -16
tests/zod-validation.test.ts
··· 7 7 actorPreferencesSchema, 8 8 communityRefSchema, 9 9 actorSignatureSchema, 10 + markdownContentSchema, 10 11 } from '../src/validation/index.js' 11 12 12 13 const VALID_DID = 'did:plc:abc123def456' ··· 15 16 uri: 'at://did:plc:abc123/forum.barazo.topic.post/3jzfcijpj2z2a', 16 17 cid: 'bafyreibouvacvqhc2vkwwtdkfynpcaoatmkde7uhrw47ne4gu63cnzc7yq', 17 18 } 19 + const VALID_CONTENT = { 20 + $type: 'forum.barazo.richtext#markdown' as const, 21 + value: 'This is a test topic body.', 22 + } 23 + 24 + describe('markdownContentSchema', () => { 25 + it('accepts valid markdown content', () => { 26 + expect(markdownContentSchema.safeParse(VALID_CONTENT).success).toBe(true) 27 + }) 28 + 29 + it('rejects missing $type', () => { 30 + expect(markdownContentSchema.safeParse({ value: 'hello' }).success).toBe(false) 31 + }) 32 + 33 + it('rejects wrong $type', () => { 34 + expect( 35 + markdownContentSchema.safeParse({ $type: 'forum.barazo.richtext#html', value: 'hello' }) 36 + .success 37 + ).toBe(false) 38 + }) 39 + 40 + it('rejects empty value', () => { 41 + expect( 42 + markdownContentSchema.safeParse({ $type: 'forum.barazo.richtext#markdown', value: '' }) 43 + .success 44 + ).toBe(false) 45 + }) 46 + 47 + it('rejects value exceeding maxLength (100000)', () => { 48 + expect( 49 + markdownContentSchema.safeParse({ 50 + $type: 'forum.barazo.richtext#markdown', 51 + value: 'x'.repeat(100_001), 52 + }).success 53 + ).toBe(false) 54 + }) 55 + 56 + it('rejects bare string content', () => { 57 + expect(markdownContentSchema.safeParse('This is a string').success).toBe(false) 58 + }) 59 + }) 18 60 19 61 describe('topicPostSchema', () => { 20 62 const validPost = { 21 63 title: 'Test Topic', 22 - content: 'This is a test topic body.', 64 + content: VALID_CONTENT, 23 65 community: VALID_DID, 24 66 category: 'general', 25 - createdAt: VALID_DATETIME, 67 + publishedAt: VALID_DATETIME, 26 68 } 27 69 28 70 it('accepts a valid minimal post', () => { ··· 32 74 it('accepts a post with all optional fields', () => { 33 75 const full = { 34 76 ...validPost, 35 - contentFormat: 'markdown' as const, 77 + site: 'at://did:plc:abc123/site.standard.publication/3lwafzkjqm25s', 36 78 tags: ['test', 'poc'], 37 79 facets: [ 38 80 { ··· 46 88 expect(topicPostSchema.safeParse(full).success).toBe(true) 47 89 }) 48 90 91 + it('accepts a post with https:// site URL', () => { 92 + const withSite = { ...validPost, site: 'https://example.com' } 93 + expect(topicPostSchema.safeParse(withSite).success).toBe(true) 94 + }) 95 + 49 96 it('rejects empty title', () => { 50 97 expect(topicPostSchema.safeParse({ ...validPost, title: '' }).success).toBe(false) 51 98 }) ··· 55 102 expect(topicPostSchema.safeParse({ ...validPost, title: longTitle }).success).toBe(false) 56 103 }) 57 104 58 - it('rejects empty content', () => { 59 - expect(topicPostSchema.safeParse({ ...validPost, content: '' }).success).toBe(false) 105 + it('rejects bare string content', () => { 106 + expect(topicPostSchema.safeParse({ ...validPost, content: 'bare string' }).success).toBe(false) 60 107 }) 61 108 62 - it('rejects content exceeding maxLength (100000)', () => { 63 - const longContent = 'x'.repeat(100_001) 64 - expect(topicPostSchema.safeParse({ ...validPost, content: longContent }).success).toBe(false) 109 + it('rejects content with empty value', () => { 110 + expect( 111 + topicPostSchema.safeParse({ 112 + ...validPost, 113 + content: { $type: 'forum.barazo.richtext#markdown', value: '' }, 114 + }).success 115 + ).toBe(false) 65 116 }) 66 117 67 118 it('rejects invalid DID format for community', () => { ··· 81 132 }) 82 133 83 134 it('rejects invalid datetime format', () => { 84 - expect(topicPostSchema.safeParse({ ...validPost, createdAt: 'not-a-date' }).success).toBe(false) 135 + expect(topicPostSchema.safeParse({ ...validPost, publishedAt: 'not-a-date' }).success).toBe( 136 + false 137 + ) 85 138 }) 86 139 87 140 it('rejects empty category', () => { ··· 105 158 expect(topicPostSchema.safeParse({ title: 'Test' }).success).toBe(false) 106 159 }) 107 160 108 - it('rejects invalid contentFormat', () => { 109 - expect(topicPostSchema.safeParse({ ...validPost, contentFormat: 'html' }).success).toBe(false) 110 - }) 111 - 112 161 it('accepts post with facets', () => { 113 162 const withFacets = { 114 163 ...validPost, ··· 153 202 154 203 describe('topicReplySchema', () => { 155 204 const validReply = { 156 - content: 'This is a reply.', 205 + content: VALID_CONTENT, 157 206 root: VALID_STRONG_REF, 158 207 parent: VALID_STRONG_REF, 159 208 community: VALID_DID, ··· 183 232 ).toBe(false) 184 233 }) 185 234 186 - it('rejects content exceeding 50000 bytes', () => { 235 + it('rejects content with value exceeding 100000', () => { 187 236 expect( 188 237 topicReplySchema.safeParse({ 189 238 ...validReply, 190 - content: 'x'.repeat(50_001), 239 + content: { 240 + $type: 'forum.barazo.richtext#markdown' as const, 241 + value: 'x'.repeat(100_001), 242 + }, 191 243 }).success 192 244 ).toBe(false) 245 + }) 246 + 247 + it('rejects bare string content', () => { 248 + expect(topicReplySchema.safeParse({ ...validReply, content: 'bare string' }).success).toBe( 249 + false 250 + ) 193 251 }) 194 252 }) 195 253