UNPKG

@mintlify/prebuild

Version:

Helpful functions for Mintlify's prebuild step

164 lines (163 loc) 7.41 kB
import { CircularRefError, PathTraversalError, RefInvalidJsonError, RefNotFoundError, resolveRefs, } from '@mintlify/validation'; import { mkdtemp, writeFile, mkdir, rm } from 'fs/promises'; import { readFile } from 'fs/promises'; import { tmpdir } from 'os'; import path from 'path'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; describe('resolveRefs', () => { let tempDir; let reader; beforeEach(async () => { tempDir = await mkdtemp(path.join(tmpdir(), 'resolve-refs-')); reader = (relPath) => readFile(path.join(tempDir, relPath), 'utf-8'); }); afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); }); const writeJson = async (relativePath, value) => { await writeFile(path.join(tempDir, relativePath), JSON.stringify(value)); }; it('passes through values with no refs unchanged', async () => { const input = { name: 'My Docs', navigation: [{ group: 'Guide', pages: ['intro'] }] }; const { resolved, referencedFiles } = await resolveRefs(input, reader); expect(resolved).toEqual(input); expect(referencedFiles).toEqual([]); }); it('resolves a basic single $ref', async () => { await writeJson('nav.json', ['intro', 'quickstart']); const input = { name: 'Docs', pages: { $ref: './nav.json' } }; const { resolved, referencedFiles } = await resolveRefs(input, reader); expect(resolved).toEqual({ name: 'Docs', pages: ['intro', 'quickstart'] }); expect(referencedFiles).toEqual(['nav.json']); }); it('resolves nested refs (A → B → C)', async () => { await mkdir(path.join(tempDir, 'nav')); await writeJson('nav/tabs.json', { items: { $ref: './items.json' } }); await writeJson('nav/items.json', ['page1', 'page2']); const input = { tabs: { $ref: './nav/tabs.json' } }; const { resolved, referencedFiles } = await resolveRefs(input, reader); expect(resolved).toEqual({ tabs: { items: ['page1', 'page2'] } }); expect(referencedFiles).toContain('nav/tabs.json'); expect(referencedFiles).toContain('nav/items.json'); }); it('resolves $ref inside array elements', async () => { await writeJson('group1.json', { group: 'API', pages: ['api/get', 'api/post'] }); await writeJson('group2.json', { group: 'Guides', pages: ['guide/start'] }); const input = { navigation: [{ $ref: './group1.json' }, { $ref: './group2.json' }] }; const { resolved } = await resolveRefs(input, reader); expect(resolved).toEqual({ navigation: [ { group: 'API', pages: ['api/get', 'api/post'] }, { group: 'Guides', pages: ['guide/start'] }, ], }); }); it('handles mixed refs and inline values', async () => { await writeJson('api-nav.json', ['api/list', 'api/create']); const input = { name: 'Docs', navigation: [ { group: 'Getting Started', pages: ['intro'] }, { group: 'API', pages: { $ref: './api-nav.json' } }, ], }; const { resolved } = await resolveRefs(input, reader); expect(resolved).toEqual({ name: 'Docs', navigation: [ { group: 'Getting Started', pages: ['intro'] }, { group: 'API', pages: ['api/list', 'api/create'] }, ], }); }); it('merges sibling keys on top of $ref when resolved value is an object', async () => { await writeJson('group.json', { group: 'Authentication', pages: ['api/login', 'api/logout'], }); const input = { navigation: [ { $ref: './group.json', icon: 'lock', tag: 'Beta', }, ], }; const { resolved } = await resolveRefs(input, reader); expect(resolved).toEqual({ navigation: [ { group: 'Authentication', pages: ['api/login', 'api/logout'], icon: 'lock', tag: 'Beta', }, ], }); }); it('sibling keys override matching keys from $ref', async () => { await writeJson('group.json', { group: 'Auth', pages: ['api/login', 'api/logout'], icon: 'key', }); const input = { navigation: [ { $ref: './group.json', icon: 'lock', }, ], }; const { resolved } = await resolveRefs(input, reader); expect(resolved).toEqual({ navigation: [ { group: 'Auth', pages: ['api/login', 'api/logout'], icon: 'lock', }, ], }); }); it('detects circular references (A → B → A)', async () => { await writeJson('a.json', { next: { $ref: './b.json' } }); await writeJson('b.json', { next: { $ref: './a.json' } }); const input = { data: { $ref: './a.json' } }; await expect(resolveRefs(input, reader)).rejects.toThrow(CircularRefError); await expect(resolveRefs(input, reader)).rejects.toThrow(/Circular reference detected/); }); it('throws RefNotFoundError for missing files', async () => { const input = { data: { $ref: './nonexistent.json' } }; await expect(resolveRefs(input, reader)).rejects.toThrow(RefNotFoundError); await expect(resolveRefs(input, reader)).rejects.toThrow(/does not exist/); }); it('throws RefInvalidJsonError for invalid JSON in referenced file', async () => { await writeFile(path.join(tempDir, 'bad.json'), '{ invalid json }'); const input = { data: { $ref: './bad.json' } }; await expect(resolveRefs(input, reader)).rejects.toThrow(RefInvalidJsonError); await expect(resolveRefs(input, reader)).rejects.toThrow(/contains invalid JSON/); }); it('throws PathTraversalError for paths outside content root', async () => { const input = { data: { $ref: '../../etc/passwd' } }; await expect(resolveRefs(input, reader)).rejects.toThrow(PathTraversalError); await expect(resolveRefs(input, reader)).rejects.toThrow(/outside the project directory/); }); it('allows diamond dependencies (same file referenced from two places)', async () => { await writeJson('shared.json', { shared: true }); const input = { a: { $ref: './shared.json' }, b: { $ref: './shared.json' }, }; const { resolved, referencedFiles } = await resolveRefs(input, reader); expect(resolved).toEqual({ a: { shared: true }, b: { shared: true } }); expect(referencedFiles.filter((f) => f.endsWith('shared.json'))).toHaveLength(2); }); it('ignores sibling keys when the referenced value is not an object', async () => { await writeJson('pages.json', ['intro', 'quickstart']); const input = { $ref: './pages.json', extra: 'value' }; const { resolved } = await resolveRefs(input, reader); expect(resolved).toEqual(['intro', 'quickstart']); }); });