feat: add MIME plumbing for markdown (.md) format

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 08:29:55 +02:00
co-authored by Claude Sonnet 5
parent 7d61209bd8
commit 28b6edac82
4 changed files with 132 additions and 0 deletions
+116
View File
@@ -16,6 +16,7 @@
"express-rate-limit": "^8.6.1", "express-rate-limit": "^8.6.1",
"file-type": "^22.0.1", "file-type": "^22.0.1",
"mammoth": "^1.12.0", "mammoth": "^1.12.0",
"markdown-it": "^15.0.0",
"multer": "^2.2.0", "multer": "^2.2.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pdfjs-dist": "^6.2.108", "pdfjs-dist": "^6.2.108",
@@ -24,6 +25,7 @@
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"sharp": "^0.35.3", "sharp": "^0.35.3",
"turndown": "^7.2.4",
"uuid": "^14.0.1" "uuid": "^14.0.1"
}, },
"devDependencies": { "devDependencies": {
@@ -674,6 +676,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@mixmark-io/domino": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
"license": "BSD-2-Clause"
},
"node_modules/@napi-rs/canvas": { "node_modules/@napi-rs/canvas": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.3.tgz",
@@ -2557,6 +2565,18 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": { "node_modules/es-define-property": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -3663,6 +3683,25 @@
"url": "https://github.com/sponsors/antonk52" "url": "https://github.com/sponsors/antonk52"
} }
}, },
"node_modules/linkify-it": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-6.1.0.tgz",
"integrity": "sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"license": "MIT",
"dependencies": {
"uc.micro": "^3.0.0"
}
},
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.18.1", "version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@@ -3714,6 +3753,49 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/markdown-it": {
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.0.tgz",
"integrity": "sha512-Lf8ajvVNdRpzSNB4VegxNy7gjs8gU35l4b4+ET49LrQC5PKYwLZ72u60LeJ9gv3qiaesuYjJWCyVeQmv/QWKQw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^3.0.0",
"entities": "^8.0.0",
"linkify-it": "^6.0.0",
"mdurl": "^2.1.0",
"punycode.js": "^2.3.1",
"uc.micro": "^3.0.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/markdown-it/node_modules/argparse": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.0.tgz",
"integrity": "sha512-BOp5NMrHqKxmq/OLr+clzzrRxgOKSLkcjmkWuChp7Irqwn4s74WjOBPIgWfA/HMcBnVkZ5XEuf9uUqzlpfCQ6A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "Python-2.0"
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -3723,6 +3805,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/mdurl": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz",
"integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==",
"license": "MIT"
},
"node_modules/media-typer": { "node_modules/media-typer": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
@@ -4488,6 +4576,15 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/puppeteer": { "node_modules/puppeteer": {
"version": "25.4.0", "version": "25.4.0",
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.4.0.tgz", "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.4.0.tgz",
@@ -5226,6 +5323,19 @@
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/turndown": {
"version": "7.2.4",
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz",
"integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==",
"license": "MIT",
"dependencies": {
"@mixmark-io/domino": "^2.2.0"
},
"engines": {
"node": ">=18",
"npm": ">=9"
}
},
"node_modules/tv4": { "node_modules/tv4": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/tv4/-/tv4-1.3.0.tgz", "resolved": "https://registry.npmjs.org/tv4/-/tv4-1.3.0.tgz",
@@ -5296,6 +5406,12 @@
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/uc.micro": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-3.0.0.tgz",
"integrity": "sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==",
"license": "MIT"
},
"node_modules/uint8array-extras": { "node_modules/uint8array-extras": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
+2
View File
@@ -19,6 +19,7 @@
"express-rate-limit": "^8.6.1", "express-rate-limit": "^8.6.1",
"file-type": "^22.0.1", "file-type": "^22.0.1",
"mammoth": "^1.12.0", "mammoth": "^1.12.0",
"markdown-it": "^15.0.0",
"multer": "^2.2.0", "multer": "^2.2.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pdfjs-dist": "^6.2.108", "pdfjs-dist": "^6.2.108",
@@ -27,6 +28,7 @@
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"sharp": "^0.35.3", "sharp": "^0.35.3",
"turndown": "^7.2.4",
"uuid": "^14.0.1" "uuid": "^14.0.1"
}, },
"devDependencies": { "devDependencies": {
+2
View File
@@ -13,6 +13,7 @@ const OUTPUT_MIME_TYPES = {
html: 'text/html', html: 'text/html',
txt: 'text/plain', txt: 'text/plain',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
md: 'text/markdown',
}; };
export async function detectInputMime(filePath) { export async function detectInputMime(filePath) {
@@ -31,6 +32,7 @@ export function outputMimeType(targetFormat) {
const UNDETECTABLE_TEXT_FORMATS = { const UNDETECTABLE_TEXT_FORMATS = {
txt: 'text/plain', txt: 'text/plain',
html: 'text/html', html: 'text/html',
md: 'text/markdown',
}; };
function normalizeFormat(format) { function normalizeFormat(format) {
+12
View File
@@ -20,6 +20,7 @@ describe('outputMimeType', () => {
expect(outputMimeType('docx')).toBe( expect(outputMimeType('docx')).toBe(
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
); );
expect(outputMimeType('md')).toBe('text/markdown');
}); });
it('throws for an unknown target format', () => { it('throws for an unknown target format', () => {
@@ -61,6 +62,17 @@ describe('resolveInputFormat', () => {
await fs.unlink(fixturePath); await fs.unlink(fixturePath);
}); });
it('trusts the declared format for undetectable md files', async () => {
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.md');
await fs.writeFile(fixturePath, '# plain markdown, no magic bytes');
const result = await resolveInputFormat(fixturePath, 'md');
expect(result).toEqual({ mime: 'text/markdown', valid: true });
await fs.unlink(fixturePath);
});
it('rejects an undetectable file declared as a binary format', async () => { it('rejects an undetectable file declared as a binary format', async () => {
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-fake.png'); const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-fake.png');
await fs.writeFile(fixturePath, 'this is not really a PNG'); await fs.writeFile(fixturePath, 'this is not really a PNG');