Skip to content

Commit fb23b8b

Browse files
committed
feat: more compatible markdown syntax
1 parent 4694505 commit fb23b8b

File tree

3 files changed

+4252
-4088
lines changed

3 files changed

+4252
-4088
lines changed

src/utils/json-to-markdown.ts

Lines changed: 113 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,36 @@ function isNotEmpty(json: JSON): boolean {
1212
return !isEmpty(json);
1313
}
1414

15+
function typeOfJson(json: JSON): 'string' | 'number' | 'boolean' | 'object' | 'array-simple' | 'array-object' | 'array-mixed' | 'null' {
16+
if (Array.isArray(json)) {
17+
if (json.every((item) => typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean' || item === null)) {
18+
return 'array-simple';
19+
}
20+
if (json.every((item) => typeof item === 'object' && item !== null && !Array.isArray(item))) {
21+
return 'array-object';
22+
}
23+
return 'array-mixed';
24+
}
25+
if (json === null) {
26+
return 'null';
27+
}
28+
return typeof json as 'string' | 'number' | 'boolean' | 'object' | 'null';
29+
}
30+
31+
function isOneLiner(json: JSON): boolean {
32+
const type = typeOfJson(json);
33+
return type === 'string' || type === 'number' || type === 'boolean' || type === 'array-simple';
34+
}
35+
36+
function getIndent(pad: number, withBullet: boolean): string {
37+
return ' '.repeat((pad + 1 - (withBullet ? 1 : 0)) * 2) + (withBullet ? '- ' : '');
38+
}
39+
1540
export function jsonToMarkdown(json: Readonly<JSON>): string {
1641
const cloned = structuredClone(json) as JSON; // Copy data to avoid mutating the original object
1742
const simplified = simplifyJson(cloned);
18-
return serializeJsonToMarkdown(simplified, 0);
43+
// TODO: clear null values
44+
return serializeJsonTopLevel(simplified);
1945
}
2046

2147
function simplifyJson(json: JSON): JSON {
@@ -46,71 +72,96 @@ function simplifyJson(json: JSON): JSON {
4672
return result;
4773
}
4874

49-
function serializeJsonToMarkdown(json: JSON, pad = 0): string {
50-
if (typeof json === 'string' || typeof json === 'number' || typeof json === 'boolean') {
51-
return String(json);
52-
}
75+
function serializeJsonTopLevel(json: JSON): string {
76+
switch (typeOfJson(json)) {
77+
case 'string':
78+
case 'number':
79+
case 'boolean':
80+
return String(json);
81+
case 'null':
82+
return '';
83+
case 'object':
84+
return serializeJson(json, 0);
85+
case 'array-simple':
86+
case 'array-mixed':
87+
return serializeJson(json, 0);
88+
case 'array-object':
89+
return (json as JSON[]).map((unknownItem, index) => {
90+
const item = unknownItem as Record<string, object>;
91+
let title;
92+
if (item.title) {
93+
title = `${index + 1}. ${item.title}`;
94+
delete item.title;
95+
} else if (item.name) {
96+
title = `${index + 1}. ${item.name}`;
97+
delete item.name;
98+
} else {
99+
title = `${index + 1}. Item`;
100+
}
53101

54-
if (json === null) {
55-
return ''; // Ignore null
102+
let result = '';
103+
result += `## ${title}\n`;
104+
result += serializeJson(unknownItem, 0);
105+
return result;
106+
}).join('\n\n');
107+
default:
108+
return serializeJson(json, 0);
56109
}
110+
}
57111

58-
// Trivial array will be just list like 1, 2, 3
59-
if (Array.isArray(json)) {
60-
if (json.length === 0) {
61-
return ''; // Ignore empty arrays
62-
}
63-
if (json.every((item) => typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean' || item === null)) {
64-
// Null in array is ignored
65-
return json.filter(isNotEmpty).join(', ');
66-
}
67-
68-
// Advanced array will use bullets
69-
const indent = ' '.repeat(pad * 2);
70-
const singleLine = json.length === 1 && json.every((item) => {
71-
const content = serializeJsonToMarkdown(item, 0);
72-
return !content.includes('\n');
73-
});
74-
if (singleLine) {
75-
// For single-item arrays with simple content, don't add indent
76-
return json.filter(isNotEmpty)
77-
.map((value) => {
78-
const content = serializeJsonToMarkdown(value, 0);
79-
return `- ${content}`;
112+
function serializeJson(json: JSON, pad: number): string {
113+
switch (typeOfJson(json)) {
114+
case 'string':
115+
case 'number':
116+
case 'boolean':
117+
return pad === 0 ? getIndent(pad, true) + String(json) : String(json);
118+
case 'object':
119+
return Object.entries(json as Record<string, JSON>)
120+
.filter(([key, value]) => !isEmpty(value))
121+
.map(([key, value], index) => {
122+
const indentLevel = pad === 0 ? 0 : 1;
123+
const prefix = `${getIndent(indentLevel, true)}${key}:`;
124+
if (isOneLiner(value)) {
125+
return `${prefix} ${serializeJson(value, -1)}`;
126+
}
127+
return `${prefix}\n${serializeJson(value, pad + 1)}`;
80128
})
81-
.join(' ');
82-
}
83-
return json.filter(isNotEmpty)
84-
.map((value, index) => {
85-
const content = serializeJsonToMarkdown(value, 0);
86-
const lines = content.split('\n');
87-
if (lines.length === 1) {
88-
return `${indent}- ${lines[0]}`;
129+
.join('\n');
130+
case 'array-simple':
131+
return `${(json as JSON[]).filter(isNotEmpty).join(', ')}`;
132+
case 'array-mixed':
133+
return (json as JSON[]).filter(isNotEmpty).map((unknownItem) => {
134+
const itemType = typeOfJson(unknownItem);
135+
if (itemType === 'array-simple' || itemType === 'array-object') {
136+
return `- ${serializeJson(unknownItem, -1)}`;
89137
}
90-
// Special case for top-level arrays to match expected inconsistent indentation
91-
const nestedIndent = pad === 0 ? ' '.repeat(index === 0 ? 3 : 2) : ' '.repeat(pad * 2 + 2);
92-
return `${indent}- ${lines[0]}\n${lines.slice(1).map((line) => nestedIndent + line).join('\n')}`;
93-
})
94-
.join('\n');
138+
if (itemType === 'object') {
139+
return Object.entries(unknownItem as Record<string, JSON>)
140+
.filter(([key, value]) => !isEmpty(value))
141+
.map(([key, value], index) => {
142+
const prefix = `${getIndent(pad, index === 0)}${key}:`;
143+
if (isOneLiner(value)) {
144+
return `${prefix} ${serializeJson(value, -1)}`;
145+
}
146+
return `${prefix}\n${serializeJson(value, pad + 1)}`;
147+
})
148+
.join('\n');
149+
}
150+
return serializeJson(unknownItem, pad);
151+
}).join('\n');
152+
case 'array-object':
153+
return (json as JSON[]).filter(isNotEmpty).map((unknownItem) => {
154+
return Object.entries(unknownItem as Record<string, JSON>)
155+
.filter(([key, value]) => !isEmpty(value))
156+
.map(([key, value], index) => {
157+
const indentLevel = pad === 1 ? 1 : pad;
158+
const withBullet = pad === 1 ? index === 0 : true;
159+
return `${getIndent(indentLevel, withBullet)}${key}: ${serializeJson(value, -1)}`;
160+
}).join('\n');
161+
}).join('\n');
162+
case 'null':
163+
return '';
164+
default:
165+
throw new Error(`Unknown type: ${typeof json}`);
95166
}
96-
97-
const indent = ' '.repeat(pad * 2);
98-
99-
// Objects will be like key: value
100-
return Object.entries(json)
101-
.filter(([_, value]) => isNotEmpty(value))
102-
.map(([key, value]) => {
103-
const valueStr = serializeJsonToMarkdown(value, pad + 1);
104-
if ((Array.isArray(value) && valueStr.includes('\n'))
105-
|| (!Array.isArray(value) && typeof value === 'object' && value !== null && valueStr.includes('\n'))) {
106-
// Multi-line arrays or objects in objects should be on new lines with proper indentation
107-
return `${indent}${key}:\n${valueStr}`;
108-
}
109-
// For inline values, don't add indent if we're in a nested context or if current object has single property with simple value
110-
const currentObjectHasSingleProperty = Object.keys(json).length === 1;
111-
const valueIsSimple = typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean';
112-
const keyIndent = (pad > 0 && ((typeof value === 'object' && value !== null) || (currentObjectHasSingleProperty && valueIsSimple))) ? '' : indent;
113-
return `${keyIndent}${key}: ${valueStr}`;
114-
})
115-
.join('\n');
116167
}

0 commit comments

Comments
 (0)