Skip to content

Commit 0220a37

Browse files
authored
feat(exporters): Markdown export of generated artifact (#66)
* feat(exporters): add Markdown export with simple HTML→MD conversion Adds a tier-1 Markdown exporter (.md with YAML frontmatter carrying schemaVersion: 1). Conversion is a small set of regex passes covering h1..h6, p, a, img, ul/ol, strong/em, code/pre — anything else is stripped. Zero new runtime deps. Wired through the existing exporter IPC + Preview toolbar Export menu, with en + zh-CN i18n strings. Compatibility ✅ Upgradeability ✅ No bloat ✅ Elegance ✅ Signed-off-by: hqhq1025 <1506751656@qq.com> * fix(exporters): allowlist URL schemes in markdown export Sanitize <a href> and <img src> during htmlToMarkdown so unsafe schemes (javascript:, vbscript:, file:, non-image data:, etc.) cannot ride into the exported .md and execute via downstream renderers. Allow http(s), mailto, relative URLs, fragments; permit data:image/* only on <img>. Unsafe links collapse to plain text, unsafe images are dropped. Also fix the IPC default extension for the markdown format (`design.md` instead of `design.markdown`) when no defaultFilename is provided. Addresses codex review on PR #66. Signed-off-by: hqhq1025 <1506751656@qq.com> * fix(exporters): close encoded-scheme bypass in markdown sanitizeUrl Decode HTML entities (named, hex, decimal), URL %escapes, and strip control characters (TAB/CR/LF/etc.) before scheme allowlisting so payloads like &#x6A;avascript:, %6Aavascript:, JavaScript:, and tab- prefixed schemes can no longer slip through the link/image sanitizer. Adds regression tests covering each bypass vector. Signed-off-by: hqhq1025 <1506751656@qq.com> * fix(exporters): only decode scheme prefix in sanitizeUrl The previous follow-up ran decodeURIComponent on the entire URL which both threw on legitimate inputs containing literal `%` (dropping the link entirely) and rewrote percent-escaped path/query characters such as %2F or %C3%A9. Restrict entity + percent decoding to the scheme portion (before the first colon) used solely for the safety check, and emit the original (control-stripped, trimmed) URL when the scheme is allowlisted. Adds regression tests for %2F in query, UTF-8 percent-escapes in path, literal trailing %, and confirms the existing entity / %-encoded javascript bypass guards still strip dangerous schemes. Signed-off-by: hqhq1025 <1506751656@qq.com> --------- Signed-off-by: hqhq1025 <1506751656@qq.com>
1 parent 1b3d7d9 commit 0220a37

10 files changed

Lines changed: 438 additions & 6 deletions

File tree

‎apps/desktop/src/main/exporter-ipc.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const FORMAT_FILTERS: Record<ExporterFormat, Electron.FileFilter[]> = {
88
pdf: [{ name: 'PDF', extensions: ['pdf'] }],
99
pptx: [{ name: 'PowerPoint', extensions: ['pptx'] }],
1010
zip: [{ name: 'ZIP archive', extensions: ['zip'] }],
11+
markdown: [{ name: 'Markdown', extensions: ['md'] }],
1112
};
1213

1314
export interface ExportRequest {
@@ -30,7 +31,13 @@ export function parseRequest(raw: unknown): ExportRequest {
3031
const format = r['format'];
3132
const html = r['htmlContent'];
3233
const defaultFilename = r['defaultFilename'];
33-
if (format !== 'html' && format !== 'pdf' && format !== 'pptx' && format !== 'zip') {
34+
if (
35+
format !== 'html' &&
36+
format !== 'pdf' &&
37+
format !== 'pptx' &&
38+
format !== 'zip' &&
39+
format !== 'markdown'
40+
) {
3441
throw new CodesignError(`Unknown export format: ${String(format)}`, 'EXPORTER_UNKNOWN');
3542
}
3643
if (typeof html !== 'string' || html.length === 0) {
@@ -47,9 +54,10 @@ export function registerExporterIpc(getWindow: () => BrowserWindow | null): void
4754
ipcMain.handle('codesign:export', async (_evt, raw: unknown): Promise<ExportResponse> => {
4855
const req = parseRequest(raw);
4956
const win = getWindow();
57+
const defaultExt = req.format === 'markdown' ? 'md' : req.format;
5058
const opts: Electron.SaveDialogOptions = {
5159
title: `Export design as ${req.format.toUpperCase()}`,
52-
defaultPath: req.defaultFilename ?? `design.${req.format}`,
60+
defaultPath: req.defaultFilename ?? `design.${defaultExt}`,
5361
filters: FORMAT_FILTERS[req.format],
5462
};
5563
const picked = win ? await dialog.showSaveDialog(win, opts) : await dialog.showSaveDialog(opts);

‎apps/desktop/src/preload/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export interface ValidateKeyError {
3030
message: string;
3131
}
3232

33-
export type ExportFormat = 'html' | 'pdf' | 'pptx' | 'zip';
33+
export type ExportFormat = 'html' | 'pdf' | 'pptx' | 'zip' | 'markdown';
3434
export interface ExportInvokeResponse {
3535
status: 'saved' | 'cancelled';
3636
path?: string;

‎apps/desktop/src/renderer/src/components/PreviewToolbar.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ export function PreviewToolbar(): ReactElement {
6262
ready: true,
6363
hint: t('export.items.zip.hint'),
6464
},
65+
{
66+
format: 'markdown',
67+
label: t('export.items.markdown.label'),
68+
ready: true,
69+
hint: t('export.items.markdown.hint'),
70+
},
6571
];
6672

6773
return (

‎apps/desktop/src/renderer/src/store.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -575,10 +575,11 @@ export const useCodesignStore = create<CodesignState>((set, get) => ({
575575
}
576576
try {
577577
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
578+
const ext = format === 'markdown' ? 'md' : format;
578579
const res = await window.codesign.export({
579580
format,
580581
htmlContent: html,
581-
defaultFilename: `codesign-${stamp}.${format}`,
582+
defaultFilename: `codesign-${stamp}.${ext}`,
582583
});
583584
if (res.status === 'saved' && res.path) {
584585
set({ toastMessage: tr('notifications.exportedTo', { path: res.path }) });

‎packages/exporters/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
"./html": "./src/html.ts",
1111
"./pdf": "./src/pdf.ts",
1212
"./pptx": "./src/pptx.ts",
13-
"./zip": "./src/zip.ts"
13+
"./zip": "./src/zip.ts",
14+
"./markdown": "./src/markdown.ts"
1415
},
1516
"scripts": {
1617
"typecheck": "tsc --noEmit",

‎packages/exporters/src/index.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
import { CodesignError } from '@open-codesign/shared';
1111

12-
export const EXPORTER_FORMATS = ['html', 'pdf', 'pptx', 'zip'] as const;
12+
export const EXPORTER_FORMATS = ['html', 'pdf', 'pptx', 'zip', 'markdown'] as const;
1313
export type ExporterFormat = (typeof EXPORTER_FORMATS)[number];
1414

1515
export interface ExportOptions {
@@ -30,6 +30,8 @@ export type { ExportHtmlOptions } from './html';
3030
export type { ExportPdfOptions } from './pdf';
3131
export type { ExportPptxOptions } from './pptx';
3232
export type { ExportZipOptions, ZipAsset } from './zip';
33+
export type { ExportMarkdownOptions, MarkdownMeta } from './markdown';
34+
export { htmlToMarkdown } from './markdown';
3335

3436
export async function exportHtml(
3537
htmlContent: string,
@@ -60,5 +62,9 @@ export async function exportArtifact(
6062
const mod = await import('./zip');
6163
return mod.exportZip(htmlContent, destinationPath);
6264
}
65+
if (format === 'markdown') {
66+
const mod = await import('./markdown');
67+
return mod.exportMarkdown(htmlContent, destinationPath);
68+
}
6369
throw new CodesignError(`Unknown exporter format: ${format as string}`, 'EXPORTER_UNKNOWN');
6470
}
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { htmlToMarkdown, sanitizeUrl } from './markdown';
3+
4+
const META = { title: 'Demo', schemaVersion: 1 as const };
5+
6+
describe('htmlToMarkdown', () => {
7+
it('writes a YAML frontmatter with title and schemaVersion', () => {
8+
const out = htmlToMarkdown('<p>hi</p>', META);
9+
expect(out.startsWith('---\ntitle: Demo\nschemaVersion: 1\n---\n')).toBe(true);
10+
expect(out).toContain('hi');
11+
});
12+
13+
it('converts headings h1..h6', () => {
14+
const html = '<h1>One</h1><h2>Two</h2><h3>Three</h3><h6>Six</h6>';
15+
const out = htmlToMarkdown(html, META);
16+
expect(out).toContain('# One');
17+
expect(out).toContain('## Two');
18+
expect(out).toContain('### Three');
19+
expect(out).toContain('###### Six');
20+
});
21+
22+
it('converts paragraphs to blank-line wrapped text', () => {
23+
const out = htmlToMarkdown('<p>First</p><p>Second</p>', META);
24+
expect(out).toMatch(/First\n\nSecond/);
25+
});
26+
27+
it('converts links and images', () => {
28+
const out = htmlToMarkdown(
29+
'<a href="https://example.com">site</a><img src="/a.png" alt="logo" />',
30+
META,
31+
);
32+
expect(out).toContain('[site](https://example.com)');
33+
expect(out).toContain('![logo](/a.png)');
34+
});
35+
36+
it('converts unordered and ordered lists', () => {
37+
const ul = htmlToMarkdown('<ul><li>a</li><li>b</li></ul>', META);
38+
expect(ul).toContain('- a');
39+
expect(ul).toContain('- b');
40+
const ol = htmlToMarkdown('<ol><li>x</li><li>y</li></ol>', META);
41+
expect(ol).toContain('1. x');
42+
expect(ol).toContain('2. y');
43+
});
44+
45+
it('converts strong/em/code/pre', () => {
46+
const out = htmlToMarkdown(
47+
'<p><strong>bold</strong> and <em>italic</em> with <code>x</code></p><pre>line1\nline2</pre>',
48+
META,
49+
);
50+
expect(out).toContain('**bold**');
51+
expect(out).toContain('*italic*');
52+
expect(out).toContain('`x`');
53+
expect(out).toContain('```\nline1\nline2\n```');
54+
});
55+
56+
it('strips script/style/head and unknown tags', () => {
57+
const out = htmlToMarkdown(
58+
'<head><title>x</title></head><script>evil()</script><style>.a{}</style><div><p>kept</p></div>',
59+
META,
60+
);
61+
expect(out).not.toContain('evil');
62+
expect(out).not.toContain('.a{}');
63+
expect(out).toContain('kept');
64+
});
65+
66+
it('decodes entities', () => {
67+
const out = htmlToMarkdown('<p>A &amp; B &lt; C</p>', META);
68+
expect(out).toContain('A & B < C');
69+
});
70+
71+
it('handles empty input gracefully', () => {
72+
const out = htmlToMarkdown('', META);
73+
expect(out).toContain('schemaVersion: 1');
74+
});
75+
76+
it('handles malformed HTML without throwing', () => {
77+
const out = htmlToMarkdown('<p>open<strong>bold<p>next', META);
78+
expect(typeof out).toBe('string');
79+
expect(out.length).toBeGreaterThan(0);
80+
});
81+
82+
it('strips javascript: links but keeps the visible text', () => {
83+
const out = htmlToMarkdown('<p><a href="javascript:alert(1)">x</a></p>', META);
84+
expect(out).toContain('x');
85+
expect(out).not.toContain('javascript:');
86+
expect(out).not.toContain('](');
87+
});
88+
89+
it('keeps https links untouched', () => {
90+
const out = htmlToMarkdown('<a href="https://x.test">x</a>', META);
91+
expect(out).toContain('[x](https://x.test)');
92+
});
93+
94+
it('allows mailto and relative link schemes', () => {
95+
const out = htmlToMarkdown(
96+
'<a href="mailto:a@b.test">mail</a><a href="/foo">rel</a><a href="#anchor">anc</a>',
97+
META,
98+
);
99+
expect(out).toContain('[mail](mailto:a@b.test)');
100+
expect(out).toContain('[rel](/foo)');
101+
expect(out).toContain('[anc](#anchor)');
102+
});
103+
104+
it('keeps inline data:image/* sources', () => {
105+
const src = 'data:image/png;base64,iVBORw0KGgo=';
106+
const out = htmlToMarkdown(`<img src="${src}" alt="px" />`, META);
107+
expect(out).toContain(`![px](${src})`);
108+
});
109+
110+
it('strips non-image data: URLs from images', () => {
111+
const out = htmlToMarkdown('<img src="data:text/html,<script>x</script>" alt="bad" />', META);
112+
expect(out).not.toContain('data:text/html');
113+
expect(out).not.toContain('![bad]');
114+
});
115+
116+
it('strips other dangerous schemes (vbscript:, file:)', () => {
117+
const out = htmlToMarkdown(
118+
'<a href="vbscript:msgbox">v</a><a href="file:///etc/passwd">f</a>',
119+
META,
120+
);
121+
expect(out).not.toContain('vbscript:');
122+
expect(out).not.toContain('file:');
123+
expect(out).toContain('v');
124+
expect(out).toContain('f');
125+
});
126+
});
127+
128+
describe('sanitizeUrl encoded-scheme bypass guard', () => {
129+
it('rejects HTML hex-entity encoded javascript scheme', () => {
130+
expect(sanitizeUrl('&#x6A;avascript:alert(1)', 'link')).toBeNull();
131+
});
132+
133+
it('rejects HTML decimal-entity encoded javascript scheme', () => {
134+
expect(sanitizeUrl('&#106;avascript:alert(1)', 'link')).toBeNull();
135+
});
136+
137+
it('rejects entity-encoded colon javascript scheme', () => {
138+
expect(sanitizeUrl('javascript&#58;alert(1)', 'link')).toBeNull();
139+
});
140+
141+
it('rejects URL percent-encoded javascript scheme', () => {
142+
expect(sanitizeUrl('%6Aavascript:alert(1)', 'link')).toBeNull();
143+
});
144+
145+
it('rejects javascript scheme with leading whitespace', () => {
146+
expect(sanitizeUrl(' javascript:alert(1)', 'link')).toBeNull();
147+
});
148+
149+
it('rejects javascript scheme with leading tab', () => {
150+
expect(sanitizeUrl('\tjavascript:alert(1)', 'link')).toBeNull();
151+
});
152+
153+
it('rejects javascript scheme with embedded tab/newline before colon', () => {
154+
expect(sanitizeUrl('java\tscript:alert(1)', 'link')).toBeNull();
155+
expect(sanitizeUrl('java\nscript:alert(1)', 'link')).toBeNull();
156+
});
157+
158+
it('rejects mixed-case javascript scheme', () => {
159+
expect(sanitizeUrl('JavaScript:alert(1)', 'link')).toBeNull();
160+
});
161+
162+
it('still permits safe http/mailto/relative URLs after decoding', () => {
163+
expect(sanitizeUrl('https://x.test', 'link')).toBe('https://x.test');
164+
expect(sanitizeUrl('mailto:a@b.test', 'link')).toBe('mailto:a@b.test');
165+
expect(sanitizeUrl('/foo/bar', 'link')).toBe('/foo/bar');
166+
expect(sanitizeUrl('#anchor', 'link')).toBe('#anchor');
167+
});
168+
169+
it('strips entity-encoded javascript when used inside markdown link conversion', () => {
170+
const out = htmlToMarkdown('<p><a href="&#x6A;avascript:alert(1)">x</a></p>', {
171+
title: 'Demo',
172+
schemaVersion: 1,
173+
});
174+
expect(out).toContain('x');
175+
expect(out).not.toContain('javascript:');
176+
expect(out).not.toContain('](');
177+
});
178+
179+
it('keeps URLs containing percent-encoded path/query characters as-is', () => {
180+
expect(sanitizeUrl('https://example.com/path?q=%2F', 'link')).toBe(
181+
'https://example.com/path?q=%2F',
182+
);
183+
expect(sanitizeUrl('https://example.com/p%C3%A9', 'link')).toBe('https://example.com/p%C3%A9');
184+
});
185+
186+
it('keeps URLs with stray literal % that would break decodeURIComponent', () => {
187+
expect(sanitizeUrl('https://x.test/?q=100%', 'link')).toBe('https://x.test/?q=100%');
188+
});
189+
});

0 commit comments

Comments
 (0)