Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477import fs from 'node:fs'import path from 'node:path'import ts from 'typescript'
const projectRoot = path.resolve(import.meta.dirname, '..')const upstreamPath = path.join(projectRoot, 'vendor/obsidian-upstream.d.ts')const localEntry = path.join(projectRoot, 'src/plugin/obsidian-compat/index.ts')const tempDir = path.join(projectRoot, 'node_modules/.tmp/obsidian-api-contract')const emittedLocalPath = path.join(tempDir, 'src/plugin/obsidian-compat/index.d.ts')const reportPath = path.join(tempDir, 'report.json')
const compareTypeFormatFlags = ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope | ts.TypeFormatFlags.WriteArrowStyleSignature | ts.TypeFormatFlags.UseFullyQualifiedType
function normalizeText(text) { return text .replace(/\r\n/g, '\n') .replace(/import\([^)]*\)\./g, '') .replace(/typeof import\([^)]*\)\./g, '') .replace(/\s+/g, ' ') .replace(/\s*([{}()[\]<>,:;|&=?])\s*/g, '$1') .trim()}
function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true })}
function readTsConfigOptions() { const tsconfigPath = path.join(projectRoot, 'tsconfig.json') const config = ts.readConfigFile(tsconfigPath, ts.sys.readFile) if (config.error) { throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n')) }
return ts.parseJsonConfigFileContent(config.config, ts.sys, projectRoot).options}
function emitLocalDeclarations() { fs.rmSync(tempDir, { recursive: true, force: true }) ensureDir(tempDir)
const baseOptions = readTsConfigOptions() const compilerOptions = { ...baseOptions, declaration: true, emitDeclarationOnly: true, noEmit: false, noEmitOnError: false, declarationMap: false, outDir: tempDir, rootDir: projectRoot, noUnusedLocals: false, noUnusedParameters: false, tsBuildInfoFile: undefined, // Strip @internal-marked members from emitted declarations. // This allows Reams-internal methods to remain accessible at runtime // while not appearing in the public Obsidian-compatible contract. stripInternal: true, }
const program = ts.createProgram([localEntry], compilerOptions) const emitResult = program.emit() if (!fs.existsSync(emittedLocalPath)) { const diagnostics = ts.getPreEmitDiagnostics(program).concat(emitResult.diagnostics) const rendered = diagnostics .map(diagnostic => ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')) .join('\n\n') throw new Error(`Expected emitted declaration at ${emittedLocalPath}\n\n${rendered}`) }}
function createCompareProgram() { const compilerOptions = { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler, lib: ['lib.es2020.d.ts', 'lib.dom.d.ts', 'lib.dom.iterable.d.ts'], skipLibCheck: true, strict: false, baseUrl: projectRoot, allowJs: false, noEmit: true, }
return ts.createProgram([upstreamPath, emittedLocalPath], compilerOptions)}
function getModuleSymbol(sourceFile) { if (!sourceFile?.symbol) { throw new Error(`Could not resolve module symbol for ${sourceFile?.fileName ?? 'unknown file'}`) }
return sourceFile.symbol}
function resolveSymbol(symbol, checker) { if (symbol.flags & ts.SymbolFlags.Alias) { return checker.getAliasedSymbol(symbol) }
return symbol}
function getNodeName(node) { if ('name' in node && node.name) { if (ts.isIdentifier(node.name) || ts.isPrivateIdentifier(node.name)) { return node.name.text } return node.name.getText() }
return null}
function typeTextForNode(node, checker) { if (node.type) { return normalizeText(checker.typeToString(checker.getTypeFromTypeNode(node.type), node, compareTypeFormatFlags)) }
return normalizeText(checker.typeToString(checker.getTypeAtLocation(node), node, compareTypeFormatFlags))}
function serializeSignatureFromDeclaration(declaration, checker) { const signature = checker.getSignatureFromDeclaration(declaration) if (!signature) { return { parameters: [], returnType: 'unknown', typeParameters: [], } }
return { typeParameters: declaration.typeParameters?.map(param => ({ name: param.name.text, constraint: param.constraint ? normalizeText(param.constraint.getText()) : null, default: param.default ? normalizeText(param.default.getText()) : null, })) ?? [], parameters: declaration.parameters.map(param => ({ optional: Boolean(param.questionToken || param.initializer), rest: Boolean(param.dotDotDotToken), type: typeTextForNode(param, checker), })), returnType: normalizeText(checker.typeToString(signature.getReturnType(), declaration, compareTypeFormatFlags)), }}
function serializeFunctionLikeDeclarations(declarations, checker) { return declarations .map(declaration => serializeSignatureFromDeclaration(declaration, checker)) .sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)))}
function serializeAccessorEntry(existing, kind, declaration, checker) { const next = existing ?? { kind: 'accessor', getterType: null, setterParameters: [], static: false, }
next.static ||= Boolean(ts.getModifiers(declaration)?.some(modifier => modifier.kind === ts.SyntaxKind.StaticKeyword)) if (kind === 'get') { next.getterType = typeTextForNode(declaration, checker) } else { next.setterParameters = declaration.parameters.map(param => ({ optional: Boolean(param.questionToken || param.initializer), rest: Boolean(param.dotDotDotToken), type: typeTextForNode(param, checker), })) }
return next}
function serializeMembers(typeLikeDeclarations, checker) { const members = new Map()
for (const declaration of typeLikeDeclarations) { const heritage = declaration.heritageClauses ?? [] for (const clause of heritage) { const clauseKey = clause.token === ts.SyntaxKind.ExtendsKeyword ? '__extends__' : '__implements__' const existing = members.get(clauseKey) ?? [] for (const typeNode of clause.types) { existing.push(normalizeText(typeNode.getText())) } members.set(clauseKey, existing) }
for (const member of declaration.members) { if (ts.isConstructorDeclaration(member)) { // Skip private/protected constructors — they're implementation details, not public API. // TypeScript emits private constructors with empty signatures in .d.ts files, but // upstream Obsidian's hand-written .d.ts omits them entirely. Skipping them here // ensures we compare only the public contract surface. const modifiers = ts.getModifiers(member) ?? [] const isPrivate = modifiers.some(m => m.kind === ts.SyntaxKind.PrivateKeyword) const isProtected = modifiers.some(m => m.kind === ts.SyntaxKind.ProtectedKeyword) if (isPrivate || isProtected) { continue } const existing = members.get('constructor') ?? { kind: 'constructor', signatures: [] } existing.signatures.push(serializeSignatureFromDeclaration(member, checker)) members.set('constructor', existing) continue }
if (ts.isIndexSignatureDeclaration(member)) { const key = `__index__:${members.size}` members.set(key, { kind: 'index', signatures: [serializeSignatureFromDeclaration(member, checker)], readonly: Boolean(ts.getModifiers(member)?.some(modifier => modifier.kind === ts.SyntaxKind.ReadonlyKeyword)), }) continue }
if (ts.isCallSignatureDeclaration(member)) { const key = `__call__:${members.size}` members.set(key, { kind: 'call', signatures: [serializeSignatureFromDeclaration(member, checker)], }) continue }
if (ts.isConstructSignatureDeclaration(member)) { const key = `__construct__:${members.size}` members.set(key, { kind: 'construct-signature', signatures: [serializeSignatureFromDeclaration(member, checker)], }) continue }
const memberName = getNodeName(member) if (!memberName) continue // Skip ES private fields (e.g. #private) - they are implementation details, not public API if (memberName.startsWith('#')) continue
// Skip private/protected members — they're implementation details, not public API. // TypeScript emits private/protected members in .d.ts files, but they're not part // of the Obsidian-compatible contract surface. const modifiers = ts.getModifiers(member) ?? [] const isPrivate = modifiers.some(m => m.kind === ts.SyntaxKind.PrivateKeyword) const isProtected = modifiers.some(m => m.kind === ts.SyntaxKind.ProtectedKeyword) if (isPrivate || isProtected) continue
const staticMember = Boolean(modifiers.some(modifier => modifier.kind === ts.SyntaxKind.StaticKeyword)) const key = `${staticMember ? 'static:' : 'instance:'}${memberName}`
if (ts.isMethodDeclaration(member) || ts.isMethodSignature(member)) { const existing = members.get(key) ?? { kind: 'method', signatures: [], static: staticMember } existing.signatures.push(serializeSignatureFromDeclaration(member, checker)) members.set(key, existing) continue }
if (ts.isPropertyDeclaration(member) || ts.isPropertySignature(member)) { members.set(key, { kind: 'property', optional: Boolean(member.questionToken), readonly: Boolean(ts.getModifiers(member)?.some(modifier => modifier.kind === ts.SyntaxKind.ReadonlyKeyword)), static: staticMember, type: typeTextForNode(member, checker), }) continue }
if (ts.isGetAccessorDeclaration(member)) { members.set(key, serializeAccessorEntry(members.get(key), 'get', member, checker)) continue }
if (ts.isSetAccessorDeclaration(member)) { members.set(key, serializeAccessorEntry(members.get(key), 'set', member, checker)) } } }
const normalizedEntries = [...members.entries()].map(([name, value]) => { if (Array.isArray(value)) { return [name, value.sort()] }
if (value?.signatures) { value.signatures.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))) }
return [name, value] })
normalizedEntries.sort(([left], [right]) => left.localeCompare(right)) return Object.fromEntries(normalizedEntries)}
function serializeSymbol(symbol, checker) { const target = resolveSymbol(symbol, checker) const declarations = target.getDeclarations() ?? [] const classDeclarations = declarations.filter(ts.isClassDeclaration) const interfaceDeclarations = declarations.filter(ts.isInterfaceDeclaration) const typeAliasDeclaration = declarations.find(ts.isTypeAliasDeclaration) const functionDeclarations = declarations.filter(ts.isFunctionDeclaration) const variableDeclaration = declarations.find(ts.isVariableDeclaration) const enumDeclaration = declarations.find(ts.isEnumDeclaration)
if (classDeclarations.length > 0) { return { kind: 'class', abstract: classDeclarations.some(declaration => ts.getModifiers(declaration)?.some(modifier => modifier.kind === ts.SyntaxKind.AbstractKeyword) ), members: serializeMembers(classDeclarations, checker), } }
if (interfaceDeclarations.length > 0) { return { kind: 'interface', members: serializeMembers(interfaceDeclarations, checker), } }
if (typeAliasDeclaration) { return { kind: 'type-alias', type: typeTextForNode(typeAliasDeclaration, checker), } }
if (functionDeclarations.length > 0) { return { kind: 'function', signatures: serializeFunctionLikeDeclarations(functionDeclarations, checker), } }
if (variableDeclaration) { return { kind: 'variable', type: typeTextForNode(variableDeclaration, checker), } }
if (enumDeclaration) { return { kind: 'enum', members: enumDeclaration.members.map(member => ({ name: getNodeName(member), value: member.initializer ? normalizeText(member.initializer.getText()) : null, })), } }
return { kind: declarations[0]?.kind ?? 'unknown', declarations: declarations.map(declaration => declaration.getText()).sort(), }}
function collectModuleExports(sourceFile, checker) { const moduleSymbol = getModuleSymbol(sourceFile) const exports = new Map()
for (const exportedSymbol of checker.getExportsOfModule(moduleSymbol)) { if (exportedSymbol.name === 'default') continue exports.set(exportedSymbol.name, serializeSymbol(exportedSymbol, checker)) }
return exports}
function diffExports(upstreamExports, localExports) { const missing = [] const extra = [] const mismatched = []
for (const [name, upstreamValue] of upstreamExports) { if (!localExports.has(name)) { missing.push(name) continue }
const localValue = localExports.get(name) if (JSON.stringify(upstreamValue) !== JSON.stringify(localValue)) { mismatched.push({ name, upstream: upstreamValue, local: localValue, }) } }
for (const name of localExports.keys()) { if (!upstreamExports.has(name)) { extra.push(name) } }
missing.sort() extra.sort() mismatched.sort((left, right) => left.name.localeCompare(right.name))
return { missing, extra, mismatched }}
function printSection(title, items, formatter = value => value) { if (items.length === 0) { console.log(`${title}: 0`) return }
console.log(`${title}: ${items.length}`) for (const item of items) { console.log(`- ${formatter(item)}`) }}
function main() { if (!fs.existsSync(upstreamPath)) { throw new Error(`Missing upstream d.ts at ${upstreamPath}`) }
emitLocalDeclarations() const program = createCompareProgram() const checker = program.getTypeChecker() const upstreamSource = program.getSourceFile(upstreamPath) const localSource = program.getSourceFile(emittedLocalPath)
if (!upstreamSource || !localSource) { throw new Error('Could not load upstream and local declaration files') }
const upstreamExports = collectModuleExports(upstreamSource, checker) const localExports = collectModuleExports(localSource, checker) const diff = diffExports(upstreamExports, localExports)
ensureDir(tempDir) fs.writeFileSync( reportPath, JSON.stringify( { upstreamExportCount: upstreamExports.size, localExportCount: localExports.size, ...diff, }, null, 2 ) )
console.log('== Obsidian public API contract report ==') console.log(`Upstream source: vendor/obsidian-upstream.d.ts`) console.log(`Local source: ${path.relative(projectRoot, emittedLocalPath)}`) console.log(`Report: ${path.relative(projectRoot, reportPath)}`) console.log(`Upstream exports: ${upstreamExports.size}`) console.log(`Local exports: ${localExports.size}`) console.log(`Missing exports: ${diff.missing.length}`) console.log(`Extra exports: ${diff.extra.length}`) console.log(`Contract mismatches: ${diff.mismatched.length}`)
printSection('Missing', diff.missing) printSection('Extra', diff.extra) printSection('Mismatched', diff.mismatched, item => item.name)
if (diff.missing.length > 0 || diff.extra.length > 0 || diff.mismatched.length > 0) { process.exitCode = 1 }}
main()