Merge pull request #1 from RiccardoSenica/online

feat: online converter
This commit is contained in:
Riccardo Senica
2025-02-15 15:32:12 +01:00
committed by GitHub
16 changed files with 3427 additions and 1238 deletions

72
.eslintrc.js Normal file
View File

@@ -0,0 +1,72 @@
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:import/recommended',
'plugin:import/typescript',
'next/core-web-vitals',
'prettier',
],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 12,
sourceType: 'module',
},
plugins: ['@typescript-eslint', 'react', 'import'],
settings: {
react: {
version: 'detect',
},
'import/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx'],
},
'import/resolver': {
typescript: {
alwaysTryTypes: true,
},
},
},
rules: {
'react/react-in-jsx-scope': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'import/order': [
'error',
{
groups: [
'builtin',
'external',
'internal',
'parent',
'sibling',
'index',
],
'newlines-between': 'always',
alphabetize: {
order: 'asc',
caseInsensitive: true,
},
},
],
'import/no-duplicates': 'error',
'import/no-unresolved': 'error',
'sort-imports': [
'error',
{
ignoreCase: true,
ignoreDeclarationSort: true,
},
],
},
};

4
.gitattributes vendored
View File

@@ -1,4 +0,0 @@
/.yarn/** linguist-vendored
/.yarn/releases/* binary
/.yarn/plugins/**/* binary
/.pnp.* binary linguist-generated

10
.prettierrc Normal file
View File

@@ -0,0 +1,10 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"tabWidth": 2,
"useTabs": false,
"printWidth": 80,
"bracketSpacing": true,
"arrowParens": "always"
}

View File

@@ -1,22 +1,63 @@
# markdown2png
A simple TypeScript tool that converts Markdown files to PNG images using Puppeteer.
A web-based tool built with Next.js that converts Markdown files to PNG images with GitHub-style formatting.
## Features
- Browser-based Markdown to PNG conversion
- GitHub-style syntax highlighting
- Live preview
- High-quality PNG output
## Tech Stack
- Next.js 13.5
- React 18
- TypeScript
- TailwindCSS
- marked (for Markdown parsing)
- highlight.js (for syntax highlighting)
- html-to-image (for PNG conversion)
## Requirements
- Node
- Node.js
- yarn
## Installation
1. Clone the repository:
```bash
git clone https://github.com/riccardosenica/markdown2png.git
cd markdown2png
```
2. Install dependencies:
```bash
yarn
```
## Commands
## Development
To run the development server:
```bash
yarn dev PATH_TO_FILE.md
yarn dev
```
The output PNG file is in the `output` folder.
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
## Available Scripts
- `yarn dev` - Runs the development server
- `yarn build` - Creates a production build
- `yarn start` - Runs the production server
- `yarn lint` - Runs ESLint
- `yarn format` - Formats code with Prettier
- `yarn typecheck` - Runs TypeScript type checking
## Usage
1. Upload your Markdown (.md) file using the file input
2. Preview your formatted Markdown with syntax highlighting
3. Click "Convert to PNG" to download your image

24
app/globals.css Normal file
View File

@@ -0,0 +1,24 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
.btn-primary {
@apply inline-flex items-center px-6 py-3
bg-blue-600 text-white font-medium rounded-md
hover:bg-blue-700 focus:outline-none focus:ring-2
focus:ring-offset-2 focus:ring-blue-500
disabled:opacity-50 disabled:cursor-not-allowed
transition-colors duration-200;
}
.file-input {
@apply block w-full text-sm text-gray-500
file:mr-4 file:py-2 file:px-4
file:rounded-md file:border-0
file:text-sm file:font-medium
file:bg-blue-50 file:text-blue-700
hover:file:bg-blue-100
cursor-pointer;
}
}

19
app/layout.tsx Normal file
View File

@@ -0,0 +1,19 @@
import './globals.css';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Markdown to PNG Converter',
description: 'Convert Markdown files to PNG images',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}

151
app/page.tsx Normal file
View File

@@ -0,0 +1,151 @@
'use client';
import hljs from 'highlight.js';
import { toPng } from 'html-to-image';
import { AlertCircle, Loader2 } from 'lucide-react';
import { marked } from 'marked';
import React, { useRef, useState } from 'react';
import 'highlight.js/styles/github.css';
marked.setOptions({
highlight: function (code, lang) {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value;
}
return code;
},
});
export default function Home() {
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [preview, setPreview] = useState<string>('');
const previewRef = useRef<HTMLDivElement>(null);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const content = await file.text();
const htmlContent = marked(content);
setPreview(htmlContent);
setFile(file);
setError(null);
} catch (err) {
setError('Failed to read file');
console.error(err);
}
};
const handleConvert = async () => {
if (!previewRef.current) return;
setLoading(true);
setError(null);
try {
const dataUrl = await toPng(previewRef.current, {
quality: 1.0,
pixelRatio: 2,
style: { margin: '20px' },
});
const link = document.createElement('a');
link.download = `${file?.name.replace('.md', '')}.png` || 'markdown.png';
link.href = dataUrl;
link.click();
} catch (err) {
setError('Failed to convert to image.');
console.error(err);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white border-b border-gray-200">
<div className="max-w-5xl mx-auto p-6">
<h1 className="text-3xl font-bold text-gray-900">
Markdown to PNG Converter
</h1>
<p className="mt-2 text-sm text-gray-600">
Convert your Markdown files into PNG images with GitHub-style
formatting.
</p>
</div>
</header>
<main className="max-w-5xl mx-auto p-6">
<div className="bg-white shadow rounded-lg p-6">
<div className="mb-8 bg-blue-50 rounded-lg p-4">
<h2 className="text-lg font-medium text-blue-800 mb-2">
How to use:
</h2>
<ol className="list-decimal ml-4 text-sm text-blue-700 space-y-1">
<li>Upload your Markdown (.md) file using the button below</li>
<li>Preview your formatted Markdown</li>
<li>Click &quot;Convert to PNG&quot; to download your image</li>
</ol>
</div>
<div className="space-y-4">
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Upload Markdown File
</label>
<input
type="file"
accept=".md"
onChange={handleFileChange}
className="file-input"
/>
</div>
<div className="flex justify-center">
<button
onClick={handleConvert}
disabled={!preview || loading}
className="btn-primary"
>
{loading ? (
<>
<Loader2 className="animate-spin h-5 w-5 mr-2" />
Converting...
</>
) : (
'Convert to PNG'
)}
</button>
</div>
{error && (
<div className="bg-red-50 p-4 rounded-md flex items-start">
<AlertCircle className="h-5 w-5 text-red-400 mt-0.5" />
<p className="ml-3 text-sm text-red-700">{error}</p>
</div>
)}
</div>
{preview && (
<div className="mt-8">
<h2 className="text-xl font-semibold text-gray-900 mb-4">
Preview
</h2>
<div className="border border-gray-200 rounded-lg">
<div ref={previewRef} className="bg-white p-8">
<div
dangerouslySetInnerHTML={{ __html: preview }}
className="prose max-w-none"
/>
</div>
</div>
</div>
)}
</div>
</main>
</div>
);
}

5
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

8
next.config.js Normal file
View File

@@ -0,0 +1,8 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
unoptimized: true,
},
};
module.exports = nextConfig;

View File

@@ -1,19 +1,40 @@
{
"name": "markdown2png",
"version": "1.0.0",
"description": "Convert Markdown files to PNG",
"version": "2.0.0",
"description": "PNG converter for Markdown files.",
"author": "riccardo@frompixels.com",
"scripts": {
"dev": "ts-node src/index.ts"
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint --fix",
"format": "prettier --write .",
"typecheck": "tsc --noEmit"
},
"license": "MIT",
"dependencies": {
"highlight.js": "^11.8.0",
"html-to-image": "^1.11.11",
"lucide-react": "^0.475.0",
"marked": "^4.3.0",
"puppeteer": "^21.0.0"
"next": "13.5.4",
"react": "18.2.0",
"react-dom": "18.2.0"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.10",
"@types/marked": "^4.0.8",
"@types/node": "^18.15.11",
"ts-node": "^10.9.1",
"typescript": "^5.0.3"
"@types/node": "^20.8.3",
"@types/react": "^18.2.25",
"@types/react-dom": "^18.2.11",
"@typescript-eslint/eslint-plugin": "^8.24.0",
"autoprefixer": "^10.4.16",
"eslint": "^8.40.0",
"eslint-config-next": "13.5.4",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.3",
"postcss": "^8.4.31",
"prettier": "^3.5.1",
"tailwindcss": "^3.3.3",
"typescript": "^5.2.2"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -1,109 +0,0 @@
import puppeteer from 'puppeteer';
import { marked } from 'marked';
import { promises as fs } from 'fs';
import path from 'path';
interface PageDimensions {
width: number;
height: number;
}
async function convertMarkdownToImage(markdownPath: string): Promise<void> {
try {
const markdownContent = await fs.readFile(markdownPath, 'utf-8');
const htmlContent = marked(markdownContent);
const fullHtml = `
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
padding: 20px;
max-width: 900px;
margin: 0 auto;
background: white;
color: #24292e;
}
pre {
background-color: #f6f8fa;
padding: 16px;
border-radius: 6px;
overflow-x: auto;
}
code {
font-family: SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 85%;
}
img {
max-width: 100%;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
}
</style>
</head>
<body>
${htmlContent}
</body>
</html>
`;
await fs.mkdir('output', { recursive: true });
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setContent(fullHtml, { waitUntil: 'networkidle0' });
const dimensions = await page.evaluate((): PageDimensions => {
return {
width: document.documentElement.offsetWidth,
height: document.documentElement.offsetHeight
};
});
await page.setViewport({
width: dimensions.width,
height: dimensions.height,
deviceScaleFactor: 2
});
const outputPath = path.join(
'output',
`${path.basename(markdownPath, '.md')}.png`
);
await page.screenshot({
path: outputPath,
fullPage: true,
type: 'png'
});
await browser.close();
console.log(`Successfully converted ${markdownPath} to ${outputPath}`);
} catch (error) {
console.error('Conversion error:', error);
throw error;
}
}
async function main(): Promise<void> {
const inputFile = process.argv[2];
if (!inputFile) {
console.log('Usage: npm start <markdown-file>');
console.log('Example: npm start input/document.md');
return;
}
await convertMarkdownToImage(inputFile);
}
main().catch(console.error);

30
tailwind.config.js Normal file
View File

@@ -0,0 +1,30 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./app/**/*.{js,ts,jsx,tsx,mdx}'],
theme: {
extend: {
typography: {
DEFAULT: {
css: {
pre: {
backgroundColor: '#f6f8fa',
padding: '1rem',
borderRadius: '0.375rem',
overflowX: 'auto',
},
code: {
backgroundColor: '#f6f8fa',
padding: '0.25rem 0.375rem',
borderRadius: '0.25rem',
fontFamily: 'ui-monospace, monospace',
fontSize: '0.875em',
},
'code::before': { content: '""' },
'code::after': { content: '""' },
},
},
},
},
},
plugins: [require('@tailwindcss/typography')],
};

View File

@@ -1,19 +1,35 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020", "DOM"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"sourceMap": true,
"declaration": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"tailwind.config.js"
],
"exclude": ["node_modules"]
}

42
utils/htmlTemplate.ts Normal file
View File

@@ -0,0 +1,42 @@
export function getHtmlContent(content: string): string {
return `
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
padding: 20px;
max-width: 900px;
margin: 0 auto;
background: white;
color: #24292e;
}
pre {
background-color: #f6f8fa;
padding: 16px;
border-radius: 6px;
overflow-x: auto;
}
code {
font-family: SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 85%;
}
img {
max-width: 100%;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
}
</style>
</head>
<body>
${content}
</body>
</html>
`;
}

4045
yarn.lock

File diff suppressed because it is too large Load Diff