Shared lexicon schemas for long-form publishing on AT Protocol. Uses typescript to json via prototypey.

Add linting script to remove `required` properties

aka.dad 65ee2c1d 91698647

verified
+138 -1
+2 -1
package.json
··· 4 4 "type": "module", 5 5 "private": true, 6 6 "scripts": { 7 - "lexicon:emit": "bunx prototypey gen-emit ./out ./src/lexicons/**/*.ts", 7 + "lexicon:emit": "bunx prototypey gen-emit ./out ./src/lexicons/**/*.ts && bun run scripts/lint.ts", 8 8 "lexicon:import": "bunx prototypey gen-from-json ./src/lexicons ./out/**/*.json", 9 + "lexicon:lint": "bun run scripts/lint.ts", 9 10 "lexicon:publish": "bun run scripts/publish.ts" 10 11 }, 11 12 "devDependencies": {
+136
scripts/lint.ts
··· 1 + import * as fs from 'fs' 2 + import * as path from 'path' 3 + import { glob } from 'tinyglobby' 4 + 5 + export type LexiconPatches = Record<string, Record<string, unknown>> 6 + 7 + /** 8 + * Get a nested value from an object using a dot-separated path. 9 + */ 10 + function getPath(obj: Record<string, unknown>, pathStr: string): unknown { 11 + return pathStr.split('.').reduce((acc: unknown, key) => { 12 + if (acc && typeof acc === 'object') { 13 + return (acc as Record<string, unknown>)[key] 14 + } 15 + return undefined 16 + }, obj) 17 + } 18 + 19 + /** 20 + * Load patches from lexicon source files. 21 + */ 22 + async function loadPatches(): Promise<Record<string, LexiconPatches>> { 23 + const srcDir = path.join(process.cwd(), 'src/lexicons') 24 + const files = await glob('**/*.ts', { cwd: srcDir, absolute: true }) 25 + 26 + const allPatches: Record<string, LexiconPatches> = {} 27 + 28 + for (const file of files) { 29 + try { 30 + const module = await import(file) 31 + if (!module.patches) continue 32 + 33 + const lexiconId = Object.values(module) 34 + .find((v): v is { json: { id: string } } => 35 + v !== null && typeof v === 'object' && 'json' in v && typeof (v as { 36 + json?: { id?: string } 37 + }).json?.id === 'string' 38 + )?.json.id 39 + 40 + if (!lexiconId) continue 41 + 42 + allPatches[lexiconId] = module.patches 43 + } catch { 44 + // Skip files that can't be imported 45 + } 46 + } 47 + 48 + return allPatches 49 + } 50 + 51 + /** 52 + * Apply patches to a lexicon object. 53 + */ 54 + function applyPatches(lexicon: Record<string, unknown>, patches: Record<string, LexiconPatches>): boolean { 55 + const id = lexicon.id as string 56 + const lexiconPatches = patches[id] 57 + if (!lexiconPatches) return false 58 + 59 + let applied = false 60 + for (const [pathStr, fields] of Object.entries(lexiconPatches)) { 61 + const target = getPath(lexicon, pathStr) as Record<string, unknown> | undefined 62 + if (!target || typeof target !== 'object') continue 63 + 64 + for (const [field, value] of Object.entries(fields)) { 65 + if (target[field] === value) continue 66 + 67 + target[field] = value 68 + applied = true 69 + } 70 + } 71 + return applied 72 + } 73 + 74 + /** 75 + * Recursively removes `"required": true` (boolean) from an object, 76 + * while preserving `"required": [...]` (arrays). 77 + */ 78 + function removeRequiredBooleans(obj: unknown): unknown { 79 + if (Array.isArray(obj)) { 80 + return obj.map(removeRequiredBooleans) 81 + } 82 + 83 + if (obj !== null && typeof obj === 'object') { 84 + const result: Record<string, unknown> = {} 85 + 86 + for (const [key, value] of Object.entries(obj)) { 87 + // Skip "required" if it's a boolean 88 + if (key === 'required' && typeof value === 'boolean') { 89 + continue 90 + } 91 + result[key] = removeRequiredBooleans(value) 92 + } 93 + 94 + return result 95 + } 96 + 97 + return obj 98 + } 99 + 100 + /** 101 + * Lint all JSON files in the out directory. 102 + */ 103 + async function lintLexicons() { 104 + const outDir = path.join(process.cwd(), 'out') 105 + 106 + const files = fs.readdirSync(outDir).filter((f) => f.endsWith('.json')) 107 + const patches = await loadPatches() 108 + 109 + let totalFixed = 0 110 + 111 + for (const file of files) { 112 + const filePath = path.join(outDir, file) 113 + const content = fs.readFileSync(filePath, 'utf8') 114 + const original = JSON.parse(content) 115 + const cleaned = removeRequiredBooleans(original) as Record<string, unknown> 116 + 117 + const originalStr = JSON.stringify(original) 118 + 119 + // Apply patches for features prototypey doesn't support 120 + applyPatches(cleaned, patches) 121 + 122 + const cleanedStr = JSON.stringify(cleaned, null, '\t') 123 + 124 + if (originalStr !== JSON.stringify(cleaned)) { 125 + fs.writeFileSync(filePath, cleanedStr + '\n') 126 + console.log(`Fixed: ${file}`) 127 + totalFixed++ 128 + } else { 129 + console.log(`OK: ${file}`) 130 + } 131 + } 132 + 133 + console.log(`\nLinted ${files.length} files, fixed ${totalFixed}`) 134 + } 135 + 136 + lintLexicons()