feat(new tool): markdown table generator

Adds a Markdown table generator tool, resolving #542.

- Visual grid editor: add/remove rows and columns, edit cells inline
- Real-time GFM (GitHub Flavored Markdown) output with column
  alignment padding for readability
- Escapes pipe characters in cells and converts newlines to <br>
- Includes unit tests for the generation models (6 tests, all passing)

Closes #542
This commit is contained in:
hardihardi 2026-10-20 10:33:14 +08:00
parent 606a41ddd1
commit 4e2e71af84
7 changed files with 278 additions and 0 deletions

36
components.d.ts vendored
View file

@ -64,6 +64,7 @@ declare module '@vue/runtime-core' {
'CTextCopyable.demo': typeof import('./src/ui/c-text-copyable/c-text-copyable.demo.vue')['default']
CTooltip: typeof import('./src/ui/c-tooltip/c-tooltip.vue')['default']
'CTooltip.demo': typeof import('./src/ui/c-tooltip/c-tooltip.demo.vue')['default']
DataStorageConverter: typeof import('./src/tools/data-storage-converter/data-storage-converter.vue')['default']
DateTimeConverter: typeof import('./src/tools/date-time-converter/date-time-converter.vue')['default']
'DemoHome.page': typeof import('./src/ui/demo/demo-home.page.vue')['default']
DemoWrapper: typeof import('./src/ui/demo/demo-wrapper.vue')['default']
@ -90,17 +91,28 @@ declare module '@vue/runtime-core' {
HttpStatusCodes: typeof import('./src/tools/http-status-codes/http-status-codes.vue')['default']
IbanValidatorAndParser: typeof import('./src/tools/iban-validator-and-parser/iban-validator-and-parser.vue')['default']
'IconMdi:brushVariant': typeof import('~icons/mdi/brush-variant')['default']
'IconMdi:contentCopy': typeof import('~icons/mdi/content-copy')['default']
'IconMdi:kettleSteamOutline': typeof import('~icons/mdi/kettle-steam-outline')['default']
IconMdiArrowDown: typeof import('~icons/mdi/arrow-down')['default']
IconMdiArrowRightBottom: typeof import('~icons/mdi/arrow-right-bottom')['default']
IconMdiCamera: typeof import('~icons/mdi/camera')['default']
IconMdiChevronDown: typeof import('~icons/mdi/chevron-down')['default']
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']
IconMdiDownload: typeof import('~icons/mdi/download')['default']
IconMdiEye: typeof import('~icons/mdi/eye')['default']
IconMdiEyeOff: typeof import('~icons/mdi/eye-off')['default']
IconMdiHeart: typeof import('~icons/mdi/heart')['default']
IconMdiPause: typeof import('~icons/mdi/pause')['default']
IconMdiPlay: typeof import('~icons/mdi/play')['default']
IconMdiRecord: typeof import('~icons/mdi/record')['default']
IconMdiRefresh: typeof import('~icons/mdi/refresh')['default']
IconMdiSearch: typeof import('~icons/mdi/search')['default']
IconMdiTranslate: typeof import('~icons/mdi/translate')['default']
IconMdiTriangleDown: typeof import('~icons/mdi/triangle-down')['default']
IconMdiVideo: typeof import('~icons/mdi/video')['default']
InputCopyable: typeof import('./src/components/InputCopyable.vue')['default']
IntegerBaseConverter: typeof import('./src/tools/integer-base-converter/integer-base-converter.vue')['default']
Ipv4AddressConverter: typeof import('./src/tools/ipv4-address-converter/ipv4-address-converter.vue')['default']
@ -121,6 +133,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']
@ -129,20 +142,43 @@ declare module '@vue/runtime-core' {
MenuLayout: typeof import('./src/components/MenuLayout.vue')['default']
MetaTagGenerator: typeof import('./src/tools/meta-tag-generator/meta-tag-generator.vue')['default']
MimeTypes: typeof import('./src/tools/mime-types/mime-types.vue')['default']
NAlert: typeof import('naive-ui')['NAlert']
NavbarButtons: typeof import('./src/components/NavbarButtons.vue')['default']
NButton: typeof import('naive-ui')['NButton']
NCheckbox: typeof import('naive-ui')['NCheckbox']
NCode: typeof import('naive-ui')['NCode']
NCollapseTransition: typeof import('naive-ui')['NCollapseTransition']
NColorPicker: typeof import('naive-ui')['NColorPicker']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDatePicker: typeof import('naive-ui')['NDatePicker']
NDivider: typeof import('naive-ui')['NDivider']
NDynamicInput: typeof import('naive-ui')['NDynamicInput']
NEllipsis: typeof import('naive-ui')['NEllipsis']
NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem']
NGi: typeof import('naive-ui')['NGi']
NGrid: typeof import('naive-ui')['NGrid']
NH1: typeof import('naive-ui')['NH1']
NH2: typeof import('naive-ui')['NH2']
NH3: typeof import('naive-ui')['NH3']
NIcon: typeof import('naive-ui')['NIcon']
NImage: typeof import('naive-ui')['NImage']
NInput: typeof import('naive-ui')['NInput']
NInputGroup: typeof import('naive-ui')['NInputGroup']
NInputGroupLabel: typeof import('naive-ui')['NInputGroupLabel']
NInputNumber: typeof import('naive-ui')['NInputNumber']
NLayout: typeof import('naive-ui')['NLayout']
NLayoutSider: typeof import('naive-ui')['NLayoutSider']
NMenu: typeof import('naive-ui')['NMenu']
NProgress: typeof import('naive-ui')['NProgress']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSlider: typeof import('naive-ui')['NSlider']
NSpace: typeof import('naive-ui')['NSpace']
NSpin: typeof import('naive-ui')['NSpin']
NStatistic: typeof import('naive-ui')['NStatistic']
NSwitch: typeof import('naive-ui')['NSwitch']
NTable: typeof import('naive-ui')['NTable']
NTag: typeof import('naive-ui')['NTag']
NumeronymGenerator: typeof import('./src/tools/numeronym-generator/numeronym-generator.vue')['default']
OtpCodeGeneratorAndValidator: typeof import('./src/tools/otp-code-generator-and-validator/otp-code-generator-and-validator.vue')['default']
PasswordStrengthAnalyser: typeof import('./src/tools/password-strength-analyser/password-strength-analyser.vue')['default']

View file

@ -249,6 +249,10 @@ tools:
title: TOML to JSON
description: Parse and convert TOML to JSON.
markdown-table-generator:
title: Markdown table generator
description: Build a table with a visual editor and generate its GitHub Flavored Markdown (GFM) representation.
lorem-ipsum-generator:
title: Lorem ipsum generator
description: Lorem ipsum is a placeholder text commonly used to demonstrate the visual form of a document or a typeface without relying on meaningful content

View file

@ -79,6 +79,7 @@ import { tool as sqlPrettify } from './sql-prettify';
import { tool as svgPlaceholderGenerator } from './svg-placeholder-generator';
import { tool as temperatureConverter } from './temperature-converter';
import { tool as dataStorageConverter } from './data-storage-converter';
import { tool as markdownTableGenerator } from './markdown-table-generator';
import { tool as textStatistics } from './text-statistics';
import { tool as tokenGenerator } from './token-generator';
import type { ToolCategory } from './tools.types';
@ -117,6 +118,7 @@ export const toolsByCategory: ToolCategory[] = [
xmlToJson,
jsonToXml,
markdownToHtml,
markdownTableGenerator,
],
},
{

View file

@ -0,0 +1,21 @@
import { Table } from '@vicons/tabler';
import { defineTool } from '../tool';
import { translate } from '@/plugins/i18n.plugin';
export const tool = defineTool({
name: translate('tools.markdown-table-generator.title'),
path: '/markdown-table-generator',
description: translate('tools.markdown-table-generator.description'),
keywords: [
'markdown',
'table',
'generator',
'gfm',
'github',
'md',
'editor',
'grid',
],
component: () => import('./markdown-table-generator.vue'),
icon: Table,
});

View file

@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { escapeCell, generateMarkdownTable } from './markdown-table-generator.models';
describe('markdown-table-generator models', () => {
it('generates a basic GFM table with header and separator', () => {
const md = generateMarkdownTable([
['Header 1', 'Header 2'],
['a', 'b'],
]);
expect(md.split('\n')).toEqual([
'| Header 1 | Header 2 |',
'| -------- | -------- |',
'| a | b |',
]);
});
it('escapes pipes in cell content', () => {
const md = generateMarkdownTable([['H'], ['a|b']]);
expect(md).toContain('a\\|b');
});
it('normalizes ragged rows to the widest row', () => {
const md = generateMarkdownTable([
['H1', 'H2', 'H3'],
['only'],
]);
const lines = md.split('\n');
expect(lines[2]).toBe('| only | | |');
});
it('omits the separator row when asked', () => {
const md = generateMarkdownTable([['H'], ['x']], { includeSeparator: false });
expect(md.split('\n')).toHaveLength(2);
expect(md).not.toContain('---');
});
it('handles empty input', () => {
expect(generateMarkdownTable([])).toBe('| | |\n| --- | --- |');
});
it('escapeCell converts newlines to <br>', () => {
expect(escapeCell('line1\nline2')).toBe('line1<br>line2');
});
});

View file

@ -0,0 +1,55 @@
/**
* Escape pipe characters in cell content so they don't break the table structure.
*/
export function escapeCell(value: string): string {
return value.replace(/\|/g, '\\|').replace(/\n/g, '<br>');
}
export type MarkdownTableOptions = {
/** include the `| --- |` separator row */
includeSeparator?: boolean;
/** pad cells to equal width for readability */
alignColumns?: boolean;
};
/**
* Convert a 2D array of cells into a GFM (GitHub Flavored Markdown) table.
* The first row is treated as the header.
*/
export function generateMarkdownTable(rows: string[][], options: MarkdownTableOptions = {}): string {
const { includeSeparator = true, alignColumns = true } = options;
const cleaned = rows.length > 0 ? rows : [['', '']];
const escaped = cleaned.map(row => row.map(escapeCell));
const columnCount = Math.max(...escaped.map(row => row.length));
const normalized = escaped.map(row => {
const copy = [...row];
while (copy.length < columnCount) {
copy.push('');
}
return copy;
});
const widths = Array.from({ length: columnCount }, (_, col) =>
Math.max(3, ...normalized.map(row => row[col].length)),
);
const formatRow = (row: string[]) =>
`| ${row.map((cell, col) => (alignColumns ? cell.padEnd(widths[col]) : cell)).join(' | ')} |`;
const separator = `| ${widths.map(width => '-'.repeat(alignColumns ? width : 3)).join(' | ')} |`;
const lines = [formatRow(normalized[0])];
if (includeSeparator) {
lines.push(separator);
}
for (const row of normalized.slice(1)) {
lines.push(formatRow(row));
}
return lines.join('\n');
}

View file

@ -0,0 +1,116 @@
<script setup lang="ts">
import { generateMarkdownTable } from './markdown-table-generator.models';
import { useCopy } from '@/composable/copy';
const rows = ref<string[][]>([
['Header 1', 'Header 2', 'Header 3'],
['Cell 1', 'Cell 2', 'Cell 3'],
['Cell 4', 'Cell 5', 'Cell 6'],
]);
const columnCount = computed(() => Math.max(...rows.value.map(row => row.length), 1));
const markdown = computed(() => generateMarkdownTable(rows.value));
const { copy } = useCopy({ source: markdown });
function addRow() {
rows.value.push(Array.from({ length: columnCount.value }, () => ''));
}
function addColumn() {
for (const row of rows.value) {
row.push('');
}
}
function removeRow(index: number) {
if (rows.value.length > 1) {
rows.value.splice(index, 1);
}
}
function removeColumn(index: number) {
if (columnCount.value > 1) {
for (const row of rows.value) {
if (row.length > index) {
row.splice(index, 1);
}
}
}
}
</script>
<template>
<c-card title="Table editor" mb-3>
<div mb-3 flex gap-2>
<c-button size="small" @click="addRow()">
+ Row
</c-button>
<c-button size="small" @click="addColumn()">
+ Column
</c-button>
</div>
<div overflow-x-auto>
<table border-collapse>
<tbody>
<tr v-for="(row, rowIndex) in rows" :key="rowIndex">
<td
v-for="(_, colIndex) in row"
:key="colIndex"
>
<n-input
v-model:value="rows[rowIndex][colIndex]"
:placeholder="rowIndex === 0 ? `Header ${colIndex + 1}` : ''"
size="small"
:test-id="`cell-${rowIndex}-${colIndex}`"
/>
</td>
<td>
<c-button
size="small"
variant="text"
:disabled="rows.length <= 1"
data-test-id="remove-row"
@click="removeRow(rowIndex)"
>
✕
</c-button>
</td>
</tr>
<tr>
<td v-for="colIndex in columnCount" :key="colIndex">
<c-button
size="small"
variant="text"
:disabled="columnCount <= 1"
:data-test-id="`remove-column-${colIndex}`"
@click="removeColumn(colIndex - 1)"
>
✕
</c-button>
</td>
<td />
</tr>
</tbody>
</table>
</div>
</c-card>
<c-card title="Markdown output">
<c-input-text
:value="markdown"
label="GFM Markdown table"
multiline
readonly
raw-text
autosize
test-id="markdown-output"
/>
<div mt-2 flex justify-center>
<c-button :disabled="!markdown" @click="copy()">
Copy Markdown to clipboard
</c-button>
</div>
</c-card>
</template>