diff --git a/components.d.ts b/components.d.ts index 3e65c3cc..455f5c60 100644 --- a/components.d.ts +++ b/components.d.ts @@ -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'] diff --git a/locales/en.yml b/locales/en.yml index 17011364..2ed59a7b 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -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 diff --git a/src/tools/index.ts b/src/tools/index.ts index afc5290d..dd86a4ea 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -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, ], }, { diff --git a/src/tools/markdown-table-generator/index.ts b/src/tools/markdown-table-generator/index.ts new file mode 100644 index 00000000..156e4523 --- /dev/null +++ b/src/tools/markdown-table-generator/index.ts @@ -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, +}); diff --git a/src/tools/markdown-table-generator/markdown-table-generator.models.test.ts b/src/tools/markdown-table-generator/markdown-table-generator.models.test.ts new file mode 100644 index 00000000..fa182ad4 --- /dev/null +++ b/src/tools/markdown-table-generator/markdown-table-generator.models.test.ts @@ -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 ', () => { + expect(escapeCell('line1\nline2')).toBe('line1line2'); + }); +}); diff --git a/src/tools/markdown-table-generator/markdown-table-generator.models.ts b/src/tools/markdown-table-generator/markdown-table-generator.models.ts new file mode 100644 index 00000000..f81a6547 --- /dev/null +++ b/src/tools/markdown-table-generator/markdown-table-generator.models.ts @@ -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, ''); +} + +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'); +} diff --git a/src/tools/markdown-table-generator/markdown-table-generator.vue b/src/tools/markdown-table-generator/markdown-table-generator.vue new file mode 100644 index 00000000..f4c2e7af --- /dev/null +++ b/src/tools/markdown-table-generator/markdown-table-generator.vue @@ -0,0 +1,116 @@ + + + + + + + + Row + + + + Column + + + + + + + + + + + + + ✕ + + + + + + + ✕ + + + + + + + + + + + + + + Copy Markdown to clipboard + + + +