mirror of
https://github.com/CorentinTh/it-tools.git
synced 2026-10-21 22:01:16 +00:00
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:
parent
606a41ddd1
commit
4e2e71af84
7 changed files with 278 additions and 0 deletions
36
components.d.ts
vendored
36
components.d.ts
vendored
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
21
src/tools/markdown-table-generator/index.ts
Normal file
21
src/tools/markdown-table-generator/index.ts
Normal 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,
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
}
|
||||
116
src/tools/markdown-table-generator/markdown-table-generator.vue
Normal file
116
src/tools/markdown-table-generator/markdown-table-generator.vue
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue