mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
fix: pretty-print JSON file previews (#1206)
* fix: pretty-print JSON file previews * fix: avoid formatting JSON with unsafe numbers * fix: preserve precision-sensitive JSON previews * fix: preserve signed zero in JSON previews * fix: scan JSON numbers without repeated slicing --------- Co-authored-by: Kael S <YOUR_GITHUB_EMAIL_HERE>
This commit is contained in:
parent
a0316d2599
commit
3524a43d18
2 changed files with 241 additions and 4 deletions
|
|
@ -5640,7 +5640,11 @@ function TextViewer({
|
|||
}
|
||||
}
|
||||
|
||||
const lineCount = text ? text.split('\n').length : 0;
|
||||
const displayText = useMemo(
|
||||
() => (text == null ? null : formatJsonFileTextForDisplay(file, text)),
|
||||
[file.name, file.mime, text],
|
||||
);
|
||||
const lineCount = displayText ? displayText.split('\n').length : 0;
|
||||
|
||||
return (
|
||||
<div className="viewer text-viewer">
|
||||
|
|
@ -5679,16 +5683,119 @@ function TextViewer({
|
|||
<div className="viewer-body">
|
||||
{text === null ? (
|
||||
<div className="viewer-empty">{t('fileViewer.loading')}</div>
|
||||
) : lineCount > 0 ? (
|
||||
<CodeWithLines text={text} />
|
||||
) : displayText !== null && lineCount > 0 ? (
|
||||
<CodeWithLines text={displayText} />
|
||||
) : (
|
||||
<pre className="viewer-source">{text}</pre>
|
||||
<pre className="viewer-source">{displayText}</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatJsonFileTextForDisplay(file: ProjectFile, text: string): string {
|
||||
if (!isJsonFile(file)) return text;
|
||||
try {
|
||||
if (hasPrecisionSensitiveJsonNumberText(text)) return text;
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (hasUnsafeJsonNumber(parsed)) return text;
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function hasPrecisionSensitiveJsonNumberText(text: string): boolean {
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
const numberTokenPattern = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
|
||||
for (let i = 0; i < text.length;) {
|
||||
const char = text[i];
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (char === '\\') {
|
||||
escaped = true;
|
||||
} else if (char === '"') {
|
||||
inString = false;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
inString = true;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
numberTokenPattern.lastIndex = i;
|
||||
const match = numberTokenPattern.exec(text);
|
||||
if (!match) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const token = match[0];
|
||||
if (isSignedNegativeZeroJsonNumberToken(token)) return true;
|
||||
if (/[.eE]/.test(token) && isPrecisionSensitiveJsonNumberToken(token)) return true;
|
||||
i = numberTokenPattern.lastIndex;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isSignedNegativeZeroJsonNumberToken(token: string): boolean {
|
||||
return /^-0(?:\.0+)?(?:[eE][+-]?\d+)?$/.test(token);
|
||||
}
|
||||
|
||||
function isPrecisionSensitiveJsonNumberToken(token: string): boolean {
|
||||
const parsed = Number(token);
|
||||
if (!Number.isFinite(parsed)) return true;
|
||||
const rendered = JSON.stringify(parsed);
|
||||
if (!rendered) return true;
|
||||
const originalValue = parseJsonNumberTokenAsDecimal(token);
|
||||
const renderedValue = parseJsonNumberTokenAsDecimal(rendered);
|
||||
return (
|
||||
!originalValue ||
|
||||
!renderedValue ||
|
||||
originalValue.coefficient !== renderedValue.coefficient ||
|
||||
originalValue.exponent !== renderedValue.exponent
|
||||
);
|
||||
}
|
||||
|
||||
function parseJsonNumberTokenAsDecimal(token: string): { coefficient: bigint; exponent: number } | null {
|
||||
const match = /^(-)?(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(token);
|
||||
if (!match) return null;
|
||||
const [, sign, integerPart, fractionPart = '', exponentPart = '0'] = match;
|
||||
const coefficient = BigInt(`${sign ?? ''}${integerPart}${fractionPart}`);
|
||||
const exponent = Number(exponentPart) - fractionPart.length;
|
||||
return normalizeDecimalParts(coefficient, exponent);
|
||||
}
|
||||
|
||||
function normalizeDecimalParts(coefficient: bigint, exponent: number): { coefficient: bigint; exponent: number } {
|
||||
if (coefficient === 0n) return { coefficient: 0n, exponent: 0 };
|
||||
let normalizedCoefficient = coefficient;
|
||||
let normalizedExponent = exponent;
|
||||
while (normalizedCoefficient % 10n === 0n) {
|
||||
normalizedCoefficient /= 10n;
|
||||
normalizedExponent += 1;
|
||||
}
|
||||
return { coefficient: normalizedCoefficient, exponent: normalizedExponent };
|
||||
}
|
||||
|
||||
function hasUnsafeJsonNumber(value: unknown): boolean {
|
||||
if (typeof value === 'number') {
|
||||
return !Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value));
|
||||
}
|
||||
if (Array.isArray(value)) return value.some(hasUnsafeJsonNumber);
|
||||
if (value && typeof value === 'object') return Object.values(value).some(hasUnsafeJsonNumber);
|
||||
return false;
|
||||
}
|
||||
|
||||
function isJsonFile(file: ProjectFile): boolean {
|
||||
return file.name.toLowerCase().endsWith('.json') || file.mime.toLowerCase().startsWith('application/json');
|
||||
}
|
||||
|
||||
function MarkdownViewer({
|
||||
projectId,
|
||||
file,
|
||||
|
|
|
|||
|
|
@ -59,6 +59,136 @@ function deferredResponse() {
|
|||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe('FileViewer JSON artifacts', () => {
|
||||
it('pretty-prints valid JSON in the text viewer', async () => {
|
||||
const file = baseFile({
|
||||
name: 'data.json',
|
||||
path: 'data.json',
|
||||
kind: 'code',
|
||||
mime: 'application/json',
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
|
||||
if (url === '/api/projects/project-1/raw/data.json') {
|
||||
return new Response('{"title":"Launch Metrics","stats":{"views":42,"active":true}}');
|
||||
}
|
||||
return new Response('', { status: 404 });
|
||||
}));
|
||||
|
||||
const { container } = render(<FileViewer projectId="project-1" file={file} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('.lines')?.textContent).toBe(
|
||||
'{\n "title": "Launch Metrics",\n "stats": {\n "views": 42,\n "active": true\n }\n}',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps raw JSON when pretty-printing would round an unsafe integer', async () => {
|
||||
const file = baseFile({
|
||||
name: 'data.json',
|
||||
path: 'data.json',
|
||||
kind: 'code',
|
||||
mime: 'application/json',
|
||||
});
|
||||
const rawJson = '{"id":9007199254740993,"name":"large"}';
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
|
||||
if (url === '/api/projects/project-1/raw/data.json') {
|
||||
return new Response(rawJson);
|
||||
}
|
||||
return new Response('', { status: 404 });
|
||||
}));
|
||||
|
||||
const { container } = render(<FileViewer projectId="project-1" file={file} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const displayedText = container.querySelector('.lines')?.textContent ?? '';
|
||||
expect(displayedText).toBe(rawJson);
|
||||
expect(displayedText).toContain('9007199254740993');
|
||||
expect(displayedText).not.toContain('9007199254740992');
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps raw JSON when pretty-printing would round a high-precision decimal', async () => {
|
||||
const file = baseFile({
|
||||
name: 'data.json',
|
||||
path: 'data.json',
|
||||
kind: 'code',
|
||||
mime: 'application/json',
|
||||
});
|
||||
const rawJson = '{"ratio":0.1234567890123456789,"name":"precise"}';
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
|
||||
if (url === '/api/projects/project-1/raw/data.json') {
|
||||
return new Response(rawJson);
|
||||
}
|
||||
return new Response('', { status: 404 });
|
||||
}));
|
||||
|
||||
const { container } = render(<FileViewer projectId="project-1" file={file} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const displayedText = container.querySelector('.lines')?.textContent ?? '';
|
||||
expect(displayedText).toBe(rawJson);
|
||||
expect(displayedText).toContain('0.1234567890123456789');
|
||||
expect(displayedText).not.toContain('0.12345678901234568');
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps raw JSON when pretty-printing would round a high-precision exponent', async () => {
|
||||
const file = baseFile({
|
||||
name: 'data.json',
|
||||
path: 'data.json',
|
||||
kind: 'code',
|
||||
mime: 'application/json',
|
||||
});
|
||||
const rawJson = '{"ratio":1.234567890123456789e2,"name":"precise"}';
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
|
||||
if (url === '/api/projects/project-1/raw/data.json') {
|
||||
return new Response(rawJson);
|
||||
}
|
||||
return new Response('', { status: 404 });
|
||||
}));
|
||||
|
||||
const { container } = render(<FileViewer projectId="project-1" file={file} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const displayedText = container.querySelector('.lines')?.textContent ?? '';
|
||||
expect(displayedText).toBe(rawJson);
|
||||
expect(displayedText).toContain('1.234567890123456789e2');
|
||||
expect(displayedText).not.toContain('123.45678901234568');
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps raw JSON when pretty-printing would erase signed negative zero', async () => {
|
||||
const file = baseFile({
|
||||
name: 'data.json',
|
||||
path: 'data.json',
|
||||
kind: 'code',
|
||||
mime: 'application/json',
|
||||
});
|
||||
const rawJson = '{"delta":-0}';
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
|
||||
if (url === '/api/projects/project-1/raw/data.json') {
|
||||
return new Response(rawJson);
|
||||
}
|
||||
return new Response('', { status: 404 });
|
||||
}));
|
||||
|
||||
const { container } = render(<FileViewer projectId="project-1" file={file} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const displayedText = container.querySelector('.lines')?.textContent ?? '';
|
||||
expect(displayedText).toBe(rawJson);
|
||||
expect(displayedText).toContain('-0');
|
||||
expect(displayedText).not.toContain('{"delta":0}');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FileViewer SVG artifacts', () => {
|
||||
it('routes SVG artifacts to the SVG viewer instead of the generic image viewer', () => {
|
||||
const file = baseFile({
|
||||
|
|
|
|||
Loading…
Reference in a new issue