a collection of lightweight TypeScript packages for AT Protocol, the protocol powering Bluesky
atproto bluesky typescript npm
101
fork

Configure Feed

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

feat(bluesky): expose record and embed limits as constants

closes https://github.com/mary-ext/atcute/issues/62

Mary 962ef11f e223fbd7

+376
+17
.changeset/swift-crews-work.md
··· 1 + --- 2 + '@atcute/bluesky': minor 3 + --- 4 + 5 + expose record and embed limits as constants 6 + 7 + this is a bit of an experiment, the library now exposes the limits set by records and interfaces and 8 + exposes them as constants that you could easily pull in to your clients. 9 + 10 + ```ts 11 + import { feedPost } from '@atcute/bluesky/limits'; 12 + 13 + // check if post text exceeds the limit 14 + if (getGraphemeLength(text) > feedPost.text.maxGraphemes) { 15 + // text is too long 16 + } 17 + ```
+91
packages/definitions/bluesky/lib/limits.ts
··· 1 + // this file is generated by scripts/generate-limits.js 2 + // do not edit manually 3 + 4 + /** limits for `app.bsky.feed.post` */ 5 + export const feedPost = { 6 + text: { maxGraphemes: 300, maxLength: 3_000 }, 7 + langs: { maxItems: 3 }, 8 + tags: { 9 + maxItems: 8, 10 + item: { maxGraphemes: 64, maxLength: 640 }, 11 + }, 12 + } as const; 13 + 14 + /** limits for `app.bsky.actor.profile` */ 15 + export const actorProfile = { 16 + displayName: { maxGraphemes: 64, maxLength: 640 }, 17 + description: { maxGraphemes: 256, maxLength: 2_560 }, 18 + pronouns: { maxGraphemes: 20, maxLength: 200 }, 19 + avatar: { maxSize: 1_000_000 }, 20 + banner: { maxSize: 1_000_000 }, 21 + } as const; 22 + 23 + /** limits for `app.bsky.feed.generator` */ 24 + export const feedGenerator = { 25 + displayName: { maxGraphemes: 24, maxLength: 240 }, 26 + description: { maxGraphemes: 300, maxLength: 3_000 }, 27 + avatar: { maxSize: 1_000_000 }, 28 + } as const; 29 + 30 + /** limits for `app.bsky.feed.threadgate` */ 31 + export const feedThreadgate = { 32 + allow: { maxItems: 5 }, 33 + hiddenReplies: { maxItems: 300 }, 34 + } as const; 35 + 36 + /** limits for `app.bsky.feed.postgate` */ 37 + export const feedPostgate = { 38 + detachedEmbeddingUris: { maxItems: 50 }, 39 + embeddingRules: { maxItems: 5 }, 40 + } as const; 41 + 42 + /** limits for `app.bsky.graph.list` */ 43 + export const graphList = { 44 + name: { maxLength: 64, minLength: 1 }, 45 + description: { maxGraphemes: 300, maxLength: 3_000 }, 46 + avatar: { maxSize: 1_000_000 }, 47 + } as const; 48 + 49 + /** limits for `app.bsky.graph.starterpack` */ 50 + export const graphStarterpack = { 51 + name: { maxGraphemes: 50, maxLength: 500, minLength: 1 }, 52 + description: { maxGraphemes: 300, maxLength: 3_000 }, 53 + feeds: { maxItems: 3 }, 54 + } as const; 55 + 56 + /** limits for `app.bsky.embed.images` */ 57 + export const embedImages = { 58 + images: { maxItems: 4 }, 59 + image: { 60 + image: { maxSize: 1_000_000 }, 61 + }, 62 + } as const; 63 + 64 + /** limits for `app.bsky.embed.video` */ 65 + export const embedVideo = { 66 + video: { maxSize: 100_000_000 }, 67 + captions: { maxItems: 20 }, 68 + alt: { maxGraphemes: 1_000, maxLength: 10_000 }, 69 + caption: { 70 + file: { maxSize: 20_000 }, 71 + }, 72 + } as const; 73 + 74 + /** limits for `app.bsky.embed.external` */ 75 + export const embedExternal = { 76 + external: { 77 + thumb: { maxSize: 1_000_000 }, 78 + }, 79 + } as const; 80 + 81 + /** limits for `app.bsky.richtext.facet` */ 82 + export const richtextFacet = { 83 + tag: { 84 + tag: { maxGraphemes: 64, maxLength: 640 }, 85 + }, 86 + } as const; 87 + 88 + /** limits for `chat.bsky.convo.defs` */ 89 + export const convoMessage = { 90 + text: { maxGraphemes: 1_000, maxLength: 10_000 }, 91 + } as const;
+2
packages/definitions/bluesky/package.json
··· 21 21 "type": "module", 22 22 "exports": { 23 23 ".": "./dist/index.js", 24 + "./limits": "./dist/limits.js", 24 25 "./types/app/*": "./dist/lexicons/types/app/bsky/*.js", 25 26 "./types/chat/*": "./dist/lexicons/types/chat/bsky/*.js" 26 27 }, ··· 32 33 "pull": "lex-cli pull", 33 34 "test": "vitest", 34 35 "generate": "rm -r ./lib/lexicons/; lex-cli generate", 36 + "generate:limits": "node scripts/generate-limits.js", 35 37 "prepublish": "rm -rf dist; pnpm run build" 36 38 }, 37 39 "dependencies": {
+266
packages/definitions/bluesky/scripts/generate-limits.js
··· 1 + import * as fs from 'node:fs/promises'; 2 + 3 + const LEXICONS_DIR = new URL('../lexicons/', import.meta.url); 4 + const OUTPUT_PATH = new URL('../lib/limits.ts', import.meta.url); 5 + 6 + // #region configuration 7 + 8 + /** 9 + * @typedef Target 10 + * @property {string} nsid lexicon NSID 11 + * @property {string} name export name for the limits constant 12 + * @property {string} [def] specific def to use as root (defaults to 'main'), disables sub-def discovery 13 + */ 14 + 15 + /** @type {Target[]} lexicons to generate limits for, in output order */ 16 + const TARGETS = [ 17 + // records 18 + { nsid: 'app.bsky.feed.post', name: 'feedPost' }, 19 + { nsid: 'app.bsky.actor.profile', name: 'actorProfile' }, 20 + { nsid: 'app.bsky.feed.generator', name: 'feedGenerator' }, 21 + { nsid: 'app.bsky.feed.threadgate', name: 'feedThreadgate' }, 22 + { nsid: 'app.bsky.feed.postgate', name: 'feedPostgate' }, 23 + { nsid: 'app.bsky.graph.list', name: 'graphList' }, 24 + { nsid: 'app.bsky.graph.starterpack', name: 'graphStarterpack' }, 25 + 26 + // embeds 27 + { nsid: 'app.bsky.embed.images', name: 'embedImages' }, 28 + { nsid: 'app.bsky.embed.video', name: 'embedVideo' }, 29 + { nsid: 'app.bsky.embed.external', name: 'embedExternal' }, 30 + 31 + // richtext 32 + { nsid: 'app.bsky.richtext.facet', name: 'richtextFacet' }, 33 + 34 + // chat 35 + { nsid: 'chat.bsky.convo.defs', name: 'convoMessage', def: 'messageInput' }, 36 + ]; 37 + 38 + // #endregion 39 + 40 + // #region lexicon processing 41 + 42 + /** 43 + * @typedef {Record<string, number | Limits>} Limits 44 + */ 45 + 46 + /** @param {string} nsid */ 47 + const nsidToPath = (nsid) => { 48 + return new URL(nsid.replaceAll('.', '/') + '.json', LEXICONS_DIR); 49 + }; 50 + 51 + /** 52 + * @param {string} name 53 + * @param {any} def 54 + */ 55 + const isDefExcluded = (name, def) => { 56 + if (name === 'main') { 57 + return false; 58 + } 59 + if (/^view/i.test(name)) { 60 + return true; 61 + } 62 + if (def.description && /\bdeprecated\b/i.test(def.description)) { 63 + return true; 64 + } 65 + return false; 66 + }; 67 + 68 + /** 69 + * @param {any} prop 70 + * @returns {Limits | null} 71 + */ 72 + const extractPropertyLimits = (prop) => { 73 + /** @type {Limits} */ 74 + const limits = {}; 75 + 76 + switch (prop.type) { 77 + case 'string': { 78 + if (prop.maxGraphemes != null) { 79 + limits.maxGraphemes = prop.maxGraphemes; 80 + } 81 + if (prop.maxLength != null) { 82 + limits.maxLength = prop.maxLength; 83 + } 84 + if (prop.minGraphemes != null && prop.minGraphemes > 0) { 85 + limits.minGraphemes = prop.minGraphemes; 86 + } 87 + if (prop.minLength != null && prop.minLength > 0) { 88 + limits.minLength = prop.minLength; 89 + } 90 + break; 91 + } 92 + case 'integer': { 93 + if (prop.minimum != null && prop.minimum > 0) { 94 + limits.minimum = prop.minimum; 95 + } 96 + if (prop.maximum != null) { 97 + limits.maximum = prop.maximum; 98 + } 99 + break; 100 + } 101 + case 'blob': { 102 + if (prop.maxSize != null) { 103 + limits.maxSize = prop.maxSize; 104 + } 105 + break; 106 + } 107 + case 'array': { 108 + if (prop.maxLength != null) { 109 + limits.maxItems = prop.maxLength; 110 + } 111 + if (prop.minLength != null && prop.minLength > 0) { 112 + limits.minItems = prop.minLength; 113 + } 114 + // extract inline item constraints (non-ref, non-union items) 115 + if (prop.items && prop.items.type !== 'ref' && prop.items.type !== 'union') { 116 + const itemLimits = extractPropertyLimits(prop.items); 117 + if (itemLimits) { 118 + limits.item = itemLimits; 119 + } 120 + } 121 + break; 122 + } 123 + } 124 + 125 + return Object.keys(limits).length > 0 ? limits : null; 126 + }; 127 + 128 + /** 129 + * @param {any} def 130 + * @returns {Limits | null} 131 + */ 132 + const extractDefLimits = (def) => { 133 + /** @type {Record<string, any> | undefined} */ 134 + const properties = def.type === 'record' ? def.record?.properties : def.properties; 135 + 136 + if (!properties) { 137 + return null; 138 + } 139 + 140 + /** @type {Limits} */ 141 + const limits = {}; 142 + 143 + for (const [name, prop] of Object.entries(properties)) { 144 + const propLimits = extractPropertyLimits(prop); 145 + if (propLimits) { 146 + limits[name] = propLimits; 147 + } 148 + } 149 + 150 + return Object.keys(limits).length > 0 ? limits : null; 151 + }; 152 + 153 + /** 154 + * @param {Target} target 155 + * @returns {Promise<{ name: string, nsid: string, limits: Limits } | null>} 156 + */ 157 + const processTarget = async (target) => { 158 + const content = await fs.readFile(nsidToPath(target.nsid), 'utf-8'); 159 + const doc = JSON.parse(content); 160 + 161 + const rootName = target.def ?? 'main'; 162 + const rootDef = doc.defs[rootName]; 163 + 164 + if (!rootDef) { 165 + console.warn(`warning: def '${rootName}' not found in ${target.nsid}`); 166 + return null; 167 + } 168 + 169 + /** @type {Limits} */ 170 + const limits = {}; 171 + 172 + // extract from root def 173 + const rootLimits = extractDefLimits(rootDef); 174 + if (rootLimits) { 175 + Object.assign(limits, rootLimits); 176 + } 177 + 178 + // auto-discover sub-defs (only when no specific def is requested) 179 + if (!target.def) { 180 + for (const [name, def] of Object.entries(doc.defs)) { 181 + if (name === 'main' || isDefExcluded(name, def)) { 182 + continue; 183 + } 184 + 185 + const defLimits = extractDefLimits(def); 186 + if (defLimits) { 187 + limits[name] = defLimits; 188 + } 189 + } 190 + } 191 + 192 + if (Object.keys(limits).length === 0) { 193 + console.warn(`warning: no limits found for ${target.nsid}`); 194 + return null; 195 + } 196 + 197 + return { name: target.name, nsid: target.nsid, limits }; 198 + }; 199 + 200 + // #endregion 201 + 202 + // #region code generation 203 + 204 + /** @param {number} n */ 205 + const formatNumber = (n) => { 206 + if (n >= 1_000) { 207 + return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, '_'); 208 + } 209 + return n.toString(); 210 + }; 211 + 212 + /** 213 + * @param {Limits} obj 214 + * @param {number} indent 215 + */ 216 + const serializeLimits = (obj, indent) => { 217 + const entries = Object.entries(obj); 218 + const allNumeric = entries.every(([, v]) => typeof v === 'number'); 219 + 220 + // inline short all-numeric objects 221 + if (allNumeric && entries.length <= 3) { 222 + const pairs = entries.map(([k, v]) => `${k}: ${formatNumber(/** @type {number} */ (v))}`); 223 + return `{ ${pairs.join(', ')} }`; 224 + } 225 + 226 + const tab = '\t'.repeat(indent); 227 + const lines = ['{']; 228 + 229 + for (const [key, value] of entries) { 230 + if (typeof value === 'number') { 231 + lines.push(`${tab}${key}: ${formatNumber(value)},`); 232 + } else { 233 + lines.push(`${tab}${key}: ${serializeLimits(/** @type {Limits} */ (value), indent + 1)},`); 234 + } 235 + } 236 + 237 + lines.push(`${'\t'.repeat(indent - 1)}}`); 238 + return lines.join('\n'); 239 + }; 240 + 241 + const generate = async () => { 242 + /** @type {{ name: string, nsid: string, limits: Limits }[]} */ 243 + const results = []; 244 + 245 + for (const target of TARGETS) { 246 + const result = await processTarget(target); 247 + if (result) { 248 + results.push(result); 249 + } 250 + } 251 + 252 + const output = ['// this file is generated by scripts/generate-limits.js', '// do not edit manually', '']; 253 + 254 + for (const { name, nsid, limits } of results) { 255 + output.push(`/** limits for \`${nsid}\` */`); 256 + output.push(`export const ${name} = ${serializeLimits(limits, 1)} as const;`); 257 + output.push(''); 258 + } 259 + 260 + await fs.writeFile(OUTPUT_PATH, output.join('\n')); 261 + console.log(`wrote ${results.length} limit exports to lib/limits.ts`); 262 + }; 263 + 264 + generate(); 265 + 266 + // #endregion