Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ declare module '@vue/runtime-core' {
IconMdiChevronRight: typeof import('~icons/mdi/chevron-right')['default']
IconMdiClose: typeof import('~icons/mdi/close')['default']
IconMdiContentCopy: typeof import('~icons/mdi/content-copy')['default']
IconMdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default']
IconMdiEye: typeof import('~icons/mdi/eye')['default']
IconMdiEyeOff: typeof import('~icons/mdi/eye-off')['default']
IconMdiHeart: typeof import('~icons/mdi/heart')['default']
Expand All @@ -121,6 +122,7 @@ declare module '@vue/runtime-core' {
LoremIpsumGenerator: typeof import('./src/tools/lorem-ipsum-generator/lorem-ipsum-generator.vue')['default']
MacAddressGenerator: typeof import('./src/tools/mac-address-generator/mac-address-generator.vue')['default']
MacAddressLookup: typeof import('./src/tools/mac-address-lookup/mac-address-lookup.vue')['default']
MarkdownTableGenerator: typeof import('./src/tools/markdown-table-generator/markdown-table-generator.vue')['default']
MarkdownToHtml: typeof import('./src/tools/markdown-to-html/markdown-to-html.vue')['default']
MathEvaluator: typeof import('./src/tools/math-evaluator/math-evaluator.vue')['default']
MenuBar: typeof import('./src/tools/html-wysiwyg-editor/editor/menu-bar.vue')['default']
Expand All @@ -135,12 +137,14 @@ declare module '@vue/runtime-core' {
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDivider: typeof import('naive-ui')['NDivider']
NEllipsis: typeof import('naive-ui')['NEllipsis']
NFormItem: typeof import('naive-ui')['NFormItem']
NH1: typeof import('naive-ui')['NH1']
NH3: typeof import('naive-ui')['NH3']
NIcon: typeof import('naive-ui')['NIcon']
NLayout: typeof import('naive-ui')['NLayout']
NLayoutSider: typeof import('naive-ui')['NLayoutSider']
NMenu: typeof import('naive-ui')['NMenu']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSpace: typeof import('naive-ui')['NSpace']
NTable: typeof import('naive-ui')['NTable']
NumeronymGenerator: typeof import('./src/tools/numeronym-generator/numeronym-generator.vue')['default']
Expand Down
2 changes: 2 additions & 0 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { tool as jsonToXml } from './json-to-xml';
import { tool as regexTester } from './regex-tester';
import { tool as regexMemo } from './regex-memo';
import { tool as markdownToHtml } from './markdown-to-html';
import { tool as markdownTableGenerator } from './markdown-table-generator';
import { tool as pdfSignatureChecker } from './pdf-signature-checker';
import { tool as numeronymGenerator } from './numeronym-generator';
import { tool as macAddressGenerator } from './mac-address-generator';
Expand Down Expand Up @@ -152,6 +153,7 @@ export const toolsByCategory: ToolCategory[] = [
jsonViewer,
jsonMinify,
jsonToCsv,
markdownTableGenerator,
sqlPrettify,
chmodCalculator,
dockerRunToDockerComposeConverter,
Expand Down
12 changes: 12 additions & 0 deletions src/tools/markdown-table-generator/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Table } from '@vicons/tabler';
import { defineTool } from '../tool';

export const tool = defineTool({
name: 'Markdown Table Generator',
path: '/markdown-table-generator',
description: 'Create GitHub-flavored Markdown tables with a visual editor.',
keywords: ['markdown', 'table', 'generator', 'gfm', 'github'],
component: () => import('./markdown-table-generator.vue'),
icon: Table,
createdAt: new Date('2026-05-30'),
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import { createMarkdownTable, escapeMarkdownTableCell, generateMarkdownTable } from './markdown-table-generator.service';

describe('markdown-table-generator service', () => {
describe('createMarkdownTable', () => {
it('creates a table with default headers and empty rows', () => {
expect(createMarkdownTable({ rows: 1, columns: 2 })).toEqual({
headers: ['Column 1', 'Column 2'],
alignments: ['left', 'left'],
rows: [['', '']],
});
});
});

describe('escapeMarkdownTableCell', () => {
it('escapes pipes and converts line breaks to br tags', () => {
expect(escapeMarkdownTableCell(' foo | bar\nbaz ')).toBe('foo \\| bar<br>baz');

Check warning on line 17 in src/tools/markdown-table-generator/markdown-table-generator.service.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

`String.raw` should be used to avoid escaping `\`.

See more on https://sonarcloud.io/project/issues?id=CorentinTh_it-tools&issues=AZ53ByqUpKHRxUe1y7H_&open=AZ53ByqUpKHRxUe1y7H_&pullRequest=1807
});
});

describe('generateMarkdownTable', () => {
it('generates a markdown table with column alignment', () => {
expect(generateMarkdownTable({
headers: ['Name', 'Count', 'Notes'],
alignments: ['left', 'right', 'center'],
rows: [
['Alpha', '10', 'Ready'],
['Beta', '3', 'Needs review'],
],
})).toMatchInlineSnapshot(`
"| Name | Count | Notes |
| :--- | ---: | :---: |
| Alpha | 10 | Ready |
| Beta | 3 | Needs review |"
`);
});

it('pads uneven rows to keep the table shape valid', () => {
expect(generateMarkdownTable({
headers: ['Name'],
alignments: ['left'],
rows: [['Alpha', 'Extra']],
})).toMatchInlineSnapshot(`
"| Name | |
| :--- | :--- |
| Alpha | Extra |"
`);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
export type MarkdownTableAlignment = 'left' | 'center' | 'right';

export interface MarkdownTable {
headers: string[]
alignments: MarkdownTableAlignment[]
rows: string[][]
}

const alignmentSeparators: Record<MarkdownTableAlignment, string> = {
left: ':---',
center: ':---:',
right: '---:',
};

export function createMarkdownTable({ rows = 2, columns = 3 }: { rows?: number; columns?: number } = {}): MarkdownTable {
return {
headers: Array.from({ length: columns }, (_, index) => `Column ${index + 1}`),
alignments: Array.from({ length: columns }, () => 'left'),
rows: Array.from({ length: rows }, () => Array.from({ length: columns }, () => '')),
};
}

export function escapeMarkdownTableCell(value: string): string {
return value
.replace(/\|/g, '\\|')

Check warning on line 25 in src/tools/markdown-table-generator/markdown-table-generator.service.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

`String.raw` should be used to avoid escaping `\`.

See more on https://sonarcloud.io/project/issues?id=CorentinTh_it-tools&issues=AZ53ByshpKHRxUe1y7ID&open=AZ53ByshpKHRxUe1y7ID&pullRequest=1807

Check warning on line 25 in src/tools/markdown-table-generator/markdown-table-generator.service.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `String#replaceAll()` over `String#replace()`.

See more on https://sonarcloud.io/project/issues?id=CorentinTh_it-tools&issues=AZ53ByshpKHRxUe1y7IC&open=AZ53ByshpKHRxUe1y7IC&pullRequest=1807
.replace(/\r\n|\r|\n/g, '<br>')
.trim();
}

function getColumnCount(table: MarkdownTable): number {
return Math.max(
table.headers.length,
table.alignments.length,
...table.rows.map(row => row.length),
);
}

function getCells(cells: string[], columns: number): string[] {
return Array.from({ length: columns }, (_, index) => escapeMarkdownTableCell(cells[index] ?? ''));
}

function formatRow(cells: string[]): string {
return `| ${cells.join(' | ')} |`;
}

export function generateMarkdownTable(table: MarkdownTable): string {
const columns = getColumnCount(table);

if (columns === 0) {
return '';
}

const headers = getCells(table.headers, columns);
const separators = Array.from(
{ length: columns },
(_, index) => alignmentSeparators[table.alignments[index] ?? 'left'],
);
const rows = table.rows.map(row => formatRow(getCells(row, columns)));

return [
formatRow(headers),
formatRow(separators),
...rows,
].join('\n');
}
129 changes: 129 additions & 0 deletions src/tools/markdown-table-generator/markdown-table-generator.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<script setup lang="ts">
import {
type MarkdownTableAlignment,
createMarkdownTable,
generateMarkdownTable,
} from './markdown-table-generator.service';
import TextareaCopyable from '@/components/TextareaCopyable.vue';

const table = ref(createMarkdownTable());
const alignmentOptions: { label: string; value: MarkdownTableAlignment }[] = [
{ label: 'Left', value: 'left' },
{ label: 'Center', value: 'center' },
{ label: 'Right', value: 'right' },
];

const output = computed(() => generateMarkdownTable(table.value));

function addColumn() {
const columnNumber = table.value.headers.length + 1;
table.value.headers.push(`Column ${columnNumber}`);
table.value.alignments.push('left');
table.value.rows.forEach(row => row.push(''));
}

function removeColumn(index: number) {
if (table.value.headers.length <= 1) {
return;
}

table.value.headers.splice(index, 1);
table.value.alignments.splice(index, 1);
table.value.rows.forEach(row => row.splice(index, 1));
}

function addRow() {
table.value.rows.push(Array.from({ length: table.value.headers.length }, () => ''));
}

function removeRow(index: number) {
if (table.value.rows.length <= 1) {
return;
}

table.value.rows.splice(index, 1);
}
</script>

<template>
<div>
<c-card>
<div mb-4 flex flex-wrap items-center gap-2>
<c-button @click="addRow">
Add row
</c-button>
<c-button @click="addColumn">
Add column
</c-button>
</div>

<n-scrollbar x-scrollable>
<n-table :bordered="false" :single-line="false" min-w-700px>
<thead>
<tr>
<th v-for="(_, columnIndex) of table.headers" :key="columnIndex" scope="col" min-w-170px>
<div flex flex-col gap-2>
<c-input-text
v-model:value="table.headers[columnIndex]"
raw-text
:placeholder="`Column ${columnIndex + 1}`"
:test-id="`header-${columnIndex}`"
/>
<div flex items-center gap-2>
<c-select
v-model:value="table.alignments[columnIndex]"
:options="alignmentOptions"
size="small"
flex-1
/>
<c-button
circle
size="small"
variant="text"
:disabled="table.headers.length <= 1"
@click="removeColumn(columnIndex)"
>
<icon-mdi-delete-outline />
</c-button>
</div>
</div>
</th>
<th scope="col" w-45px />
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) of table.rows" :key="rowIndex">
<td v-for="(_, columnIndex) of table.headers" :key="columnIndex">
<c-input-text
v-model:value="row[columnIndex]"
raw-text
multiline
rows="2"
:placeholder="`Row ${rowIndex + 1}, column ${columnIndex + 1}`"
:test-id="`cell-${rowIndex}-${columnIndex}`"
/>
</td>
<td text-center>
<c-button
circle
size="small"
variant="text"
:disabled="table.rows.length <= 1"
@click="removeRow(rowIndex)"
>
<icon-mdi-delete-outline />
</c-button>
</td>
</tr>
</tbody>
</n-table>
</n-scrollbar>
</c-card>

<n-divider />

<n-form-item label="Generated Markdown table:">
<TextareaCopyable :value="output" language="markdown" copy-placement="outside" />
</n-form-item>
</div>
</template>
Loading