@mintlify/prebuild
Version:
Helpful functions for Mintlify's prebuild step
164 lines (163 loc) • 7.41 kB
JavaScript
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']);
});
});