feat: add plain text copy functionality for messages and topics. 添加了复制纯文本的功能(去除Markdown格式符号) (#5965)

* feat: add plain text copy functionality for messages and topics.

* refactor: move minapp settings to minapp page

* fix: add success message after copying topic and message as text

* fix: refactor test imports and add mocks for translation and window.message

---------

Co-authored-by: Guscccc <Augustus.Li@outlook.com>
Co-authored-by: kangfenmao <kangfenmao@qq.com>
Co-authored-by: 自由的世界人 <3196812536@qq.com>
This commit is contained in:
Guscccc 2025-06-11 17:23:35 +08:00 committed by GitHub
parent 5c76d398c5
commit 7e54c465b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 415 additions and 9 deletions

View File

@ -94,6 +94,7 @@
"officeparser": "^4.1.1",
"os-proxy-config": "^1.1.2",
"proxy-agent": "^6.5.0",
"remove-markdown": "^0.6.2",
"selection-hook": "^0.9.23",
"tar": "^7.4.3",
"turndown": "^7.2.0",

View File

@ -261,6 +261,7 @@
"topics.clear.title": "Clear Messages",
"topics.copy.image": "Copy as image",
"topics.copy.md": "Copy as markdown",
"topics.copy.plain_text": "Copy as plain text (remove Markdown)",
"topics.copy.title": "Copy",
"topics.delete.shortcut": "Hold {{key}} to delete directly",
"topics.edit.placeholder": "Enter new name",

View File

@ -261,6 +261,7 @@
"topics.clear.title": "メッセージをクリア",
"topics.copy.image": "画像としてコピー",
"topics.copy.md": "Markdownとしてコピー",
"topics.copy.plain_text": "プレーンテキストとしてコピーMarkdownを除去",
"topics.copy.title": "コピー",
"topics.delete.shortcut": "{{key}}キーを押しながらで直接削除",
"topics.edit.placeholder": "新しい名前を入力",

View File

@ -261,6 +261,7 @@
"topics.clear.title": "Очистить сообщения",
"topics.copy.image": "Скопировать как изображение",
"topics.copy.md": "Скопировать как Markdown",
"topics.copy.plain_text": "Копировать как обычный текст (удалить Markdown)",
"topics.copy.title": "Скопировать",
"topics.delete.shortcut": "Удерживайте {{key}} для мгновенного удаления",
"topics.edit.placeholder": "Введите новый заголовок",

View File

@ -279,6 +279,7 @@
"topics.clear.title": "清空消息",
"topics.copy.image": "复制为图片",
"topics.copy.md": "复制为 Markdown",
"topics.copy.plain_text": "复制为纯文本(去除 Markdown",
"topics.copy.title": "复制",
"topics.delete.shortcut": "按住 {{key}} 可直接删除",
"topics.edit.placeholder": "输入新名称",

View File

@ -261,6 +261,7 @@
"topics.clear.title": "清空訊息",
"topics.copy.image": "複製為圖片",
"topics.copy.md": "複製為 Markdown",
"topics.copy.plain_text": "複製為純文字(移除 Markdown",
"topics.copy.title": "複製",
"topics.delete.shortcut": "按住 {{key}} 可直接刪除",
"topics.edit.placeholder": "輸入新名稱",

View File

@ -198,6 +198,7 @@
"topics.clear.title": "Καθαρισμός μηνυμάτων",
"topics.copy.image": "Αντιγραφή ως εικόνα",
"topics.copy.md": "Αντιγραφή ως Markdown",
"topics.copy.plain_text": "Αντιγραφή ως απλό κείμενο (αφαίρεση Markdown)",
"topics.copy.title": "Αντιγραφή",
"topics.delete.shortcut": "Πατήστε {{key}} για να διαγράψετε αμέσως",
"topics.edit.placeholder": "Εισαγάγετε το νέο όνομα",

View File

@ -199,6 +199,7 @@
"topics.clear.title": "Limpiar mensajes",
"topics.copy.image": "Copiar como imagen",
"topics.copy.md": "Copiar como Markdown",
"topics.copy.plain_text": "Copiar como texto sin formato (eliminar Markdown)",
"topics.copy.title": "Copiar",
"topics.delete.shortcut": "Mantén presionada {{key}} para eliminar directamente",
"topics.edit.placeholder": "Introduce nuevo nombre",

View File

@ -198,6 +198,7 @@
"topics.clear.title": "Effacer le message",
"topics.copy.image": "Copier sous forme d'image",
"topics.copy.md": "Copier sous forme de Markdown",
"topics.copy.plain_text": "Copier en tant que texte brut (supprimer Markdown)",
"topics.copy.title": "Copier",
"topics.delete.shortcut": "Maintenez {{key}} pour supprimer directement",
"topics.edit.placeholder": "Entrez un nouveau nom",

View File

@ -199,6 +199,7 @@
"topics.clear.title": "Limpar mensagens",
"topics.copy.image": "Copiar como imagem",
"topics.copy.md": "Copiar como Markdown",
"topics.copy.plain_text": "Copiar como texto simples (remover Markdown)",
"topics.copy.title": "Copiar",
"topics.delete.shortcut": "Pressione {{key}} para deletar diretamente",
"topics.edit.placeholder": "Digite novo nome",

View File

@ -23,6 +23,7 @@ import {
exportMessageToNotion,
messageToMarkdown
} from '@renderer/utils/export'
import { copyMessageAsPlainText } from '@renderer/utils/copy'
// import { withMessageThought } from '@renderer/utils/formats'
import { removeTrailingDoubleSpaces } from '@renderer/utils/markdown'
import { findMainTextBlocks, findTranslationBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find'
@ -201,6 +202,11 @@ const MessageMenubar: FC<Props> = (props) => {
key: 'export',
icon: <Share size={16} color="var(--color-icon)" style={{ marginTop: 3 }} />,
children: [
{
label: t('chat.topics.copy.plain_text'),
key: 'copy_message_plain_text',
onClick: () => copyMessageAsPlainText(message)
},
exportMenuOptions.image && {
label: t('chat.topics.copy.image'),
key: 'img',

View File

@ -26,7 +26,7 @@ import { RootState } from '@renderer/store'
import { setGenerating } from '@renderer/store/runtime'
import { Assistant, Topic } from '@renderer/types'
import { removeSpecialCharactersForFileName } from '@renderer/utils'
import { copyTopicAsMarkdown } from '@renderer/utils/copy'
import { copyTopicAsMarkdown, copyTopicAsPlainText } from '@renderer/utils/copy'
import {
exportMarkdownToJoplin,
exportMarkdownToSiyuan,
@ -280,6 +280,11 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
label: t('chat.topics.copy.md'),
key: 'md',
onClick: () => copyTopicAsMarkdown(topic)
},
{
label: t('chat.topics.copy.plain_text'),
key: 'plain_text',
onClick: () => copyTopicAsPlainText(topic)
}
]
},

View File

@ -705,3 +705,4 @@ export interface StoreSyncAction {
export type OpenAISummaryText = 'auto' | 'concise' | 'detailed' | 'off'
export type OpenAIServiceTier = 'auto' | 'default' | 'flex'
export type { Message } from './newMessage'

View File

@ -1,7 +1,7 @@
// Import Message, MessageBlock, and necessary enums
import type { Message, MessageBlock } from '@renderer/types/newMessage'
import { AssistantMessageStatus, MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// --- Mocks Setup ---
@ -36,8 +36,36 @@ vi.mock('@renderer/utils/messageUtils/find', () => ({
})
}))
vi.mock('@renderer/databases', () => ({
default: {
topics: {
get: vi.fn()
}
}
}))
vi.mock('@renderer/utils/markdown', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as any),
markdownToPlainText: vi.fn((str) => str) // Simple pass-through for testing export logic
}
})
// Import the functions to test AFTER setting up mocks
import { getTitleFromString, messagesToMarkdown, messageToMarkdown, messageToMarkdownWithReasoning } from '../export'
import db from '@renderer/databases'
import { Topic } from '@renderer/types'
import { markdownToPlainText } from '@renderer/utils/markdown'
import { copyMessageAsPlainText } from '../copy'
import {
getTitleFromString,
messagesToMarkdown,
messageToMarkdown,
messageToMarkdownWithReasoning,
messageToPlainText,
topicToPlainText
} from '../export'
// --- Helper Functions for Test Data ---
@ -135,6 +163,13 @@ beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
// Mock i18next translation function
vi.mock('i18next', () => ({
default: {
t: vi.fn((key) => key)
}
}))
// Mock store - primarily for settings
vi.doMock('@renderer/store', () => ({
default: {
@ -344,4 +379,210 @@ describe('export', () => {
expect(markdown.split('\n\n---\n\n').length).toBe(1)
})
})
describe('formatMessageAsPlainText (via topicToPlainText)', () => {
it('should format user and assistant messages correctly to plain text with roles', async () => {
const userMsg = createMessage({ role: 'user', id: 'u_plain_formatted' }, [
{ type: MessageBlockType.MAIN_TEXT, content: '# User Content Formatted' }
])
const assistantMsg = createMessage({ role: 'assistant', id: 'a_plain_formatted' }, [
{ type: MessageBlockType.MAIN_TEXT, content: '*Assistant Content Formatted*' }
])
const testTopic: Topic = {
id: 't_plain_formatted',
name: 'Formatted Plain Topic',
assistantId: 'asst_test_formatted',
messages: [userMsg, assistantMsg] as any,
createdAt: '',
updatedAt: ''
}
;(db.topics.get as any).mockResolvedValue({ messages: [userMsg, assistantMsg] })
// Specific mock for this test to check formatting
;(markdownToPlainText as any).mockImplementation((str) => str.replace(/[#*]/g, ''))
const plainText = await topicToPlainText(testTopic)
expect(plainText).toContain('User:\nUser Content Formatted')
expect(plainText).toContain('Assistant:\nAssistant Content Formatted')
expect(markdownToPlainText).toHaveBeenCalledWith('# User Content Formatted')
expect(markdownToPlainText).toHaveBeenCalledWith('*Assistant Content Formatted*')
expect(markdownToPlainText).toHaveBeenCalledWith('Formatted Plain Topic')
})
})
describe('messageToPlainText', () => {
it('should convert a single message content to plain text without role prefix', () => {
const testMessage = createMessage({ role: 'user', id: 'single_msg_plain' }, [
{ type: MessageBlockType.MAIN_TEXT, content: '### Single Message Content' }
])
;(markdownToPlainText as any).mockImplementation((str) => str.replace(/[#*_]/g, ''))
const result = messageToPlainText(testMessage)
expect(result).toBe('Single Message Content')
expect(markdownToPlainText).toHaveBeenCalledWith('### Single Message Content')
})
it('should return empty string for message with no main text', () => {
const testMessage = createMessage({ role: 'user', id: 'empty_msg_plain' }, [])
;(markdownToPlainText as any).mockReturnValue('') // Mock to return empty for empty input
const result = messageToPlainText(testMessage)
expect(result).toBe('')
expect(markdownToPlainText).toHaveBeenCalledWith('')
})
})
describe('messagesToPlainText (via topicToPlainText)', () => {
it('should join multiple formatted plain text messages with double newlines', async () => {
const msg1 = createMessage({ role: 'user', id: 'm_plain1_formatted' }, [
{ type: MessageBlockType.MAIN_TEXT, content: 'Msg1 Formatted' }
])
const msg2 = createMessage({ role: 'assistant', id: 'm_plain2_formatted' }, [
{ type: MessageBlockType.MAIN_TEXT, content: 'Msg2 Formatted' }
])
const testTopic: Topic = {
id: 't_multi_plain_formatted',
name: 'Multi Plain Formatted',
assistantId: 'asst_test_multi_formatted',
messages: [msg1, msg2] as any,
createdAt: '',
updatedAt: ''
}
;(db.topics.get as any).mockResolvedValue({ messages: [msg1, msg2] })
;(markdownToPlainText as any).mockImplementation((str) => str) // Pass-through
const plainText = await topicToPlainText(testTopic)
expect(plainText).toBe('Multi Plain Formatted\n\nUser:\nMsg1 Formatted\n\nAssistant:\nMsg2 Formatted')
})
})
describe('topicToPlainText', () => {
beforeEach(() => {
vi.clearAllMocks() // Clear mocks before each test in this suite
// Mock store for settings if not already done globally or if specific settings are needed
vi.doMock('@renderer/store', () => ({
default: {
getState: () => ({
settings: { forceDollarMathInMarkdown: false } // Default or specific settings
})
}
}))
})
it('should return plain text for a topic with messages', async () => {
const msg1 = createMessage({ role: 'user', id: 'tp_u1' }, [
{ type: MessageBlockType.MAIN_TEXT, content: '**Hello**' }
])
const msg2 = createMessage({ role: 'assistant', id: 'tp_a1' }, [
{ type: MessageBlockType.MAIN_TEXT, content: '_World_' }
])
const testTopic: Topic = {
id: 'topic1_plain',
name: '# Topic One',
assistantId: 'asst_test',
messages: [msg1, msg2] as any,
createdAt: '',
updatedAt: ''
}
;(db.topics.get as any).mockResolvedValue({ messages: [msg1, msg2] })
;(markdownToPlainText as any).mockImplementation((str) => str.replace(/[#*_]/g, ''))
const result = await topicToPlainText(testTopic)
expect(db.topics.get).toHaveBeenCalledWith('topic1_plain')
expect(markdownToPlainText).toHaveBeenCalledWith('# Topic One')
expect(markdownToPlainText).toHaveBeenCalledWith('**Hello**')
expect(markdownToPlainText).toHaveBeenCalledWith('_World_')
expect(result).toBe('Topic One\n\nUser:\nHello\n\nAssistant:\nWorld')
})
it('should return only topic name if topic has no messages', async () => {
const testTopic: Topic = {
id: 'topic_empty_plain',
name: '## Empty Topic',
assistantId: 'asst_test',
messages: [] as any,
createdAt: '',
updatedAt: ''
}
;(db.topics.get as any).mockResolvedValue({ messages: [] })
;(markdownToPlainText as any).mockImplementation((str) => str.replace(/[#*_]/g, ''))
const result = await topicToPlainText(testTopic)
expect(result).toBe('Empty Topic')
expect(markdownToPlainText).toHaveBeenCalledWith('## Empty Topic')
})
it('should return empty string if topicMessages is null', async () => {
const testTopic: Topic = {
id: 'topic_null_msgs_plain',
name: 'Null Messages Topic',
assistantId: 'asst_test',
messages: null as any,
createdAt: '',
updatedAt: ''
}
;(db.topics.get as any).mockResolvedValue(null)
const result = await topicToPlainText(testTopic)
expect(result).toBe('')
})
})
describe('copyMessageAsPlainText', () => {
// Mock navigator.clipboard.writeText
const writeTextMock = vi.fn()
beforeEach(() => {
vi.stubGlobal('navigator', {
clipboard: {
writeText: writeTextMock
}
})
// Mock window.message methods
vi.stubGlobal('window', {
message: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn()
}
})
// Mock i18next translation function
vi.mock('i18next', () => ({
default: {
t: vi.fn((key) => key)
}
}))
writeTextMock.mockReset()
// Ensure markdownToPlainText mock is set
;(markdownToPlainText as any).mockImplementation((str) => str.replace(/[#*_]/g, ''))
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('should call messageToPlainText and copy its result to clipboard', async () => {
const testMessage = createMessage({ role: 'user', id: 'copy_msg_plain' }, [
{ type: MessageBlockType.MAIN_TEXT, content: '**Copy This Plain**' }
])
await copyMessageAsPlainText(testMessage)
expect(markdownToPlainText).toHaveBeenCalledWith('**Copy This Plain**')
expect(writeTextMock).toHaveBeenCalledWith('Copy This Plain')
})
it('should handle empty message content', async () => {
const testMessage = createMessage({ role: 'user', id: 'copy_empty_msg_plain' }, [])
;(markdownToPlainText as any).mockReturnValue('')
await copyMessageAsPlainText(testMessage)
expect(markdownToPlainText).toHaveBeenCalledWith('')
expect(writeTextMock).toHaveBeenCalledWith('')
})
})
})

View File

@ -8,7 +8,8 @@ import {
findCitationInChildren,
getCodeBlockId,
removeTrailingDoubleSpaces,
updateCodeBlock
updateCodeBlock,
markdownToPlainText
} from '../markdown'
describe('markdown', () => {
@ -184,7 +185,7 @@ describe('markdown', () => {
// function getAllCodeBlockIds(markdown: string): { [content: string]: string } {
// const result: { [content: string]: string } = {}
// const tree = unified().use(remarkParse).parse(markdown)
//
// visit(tree, 'code', (node) => {
// const id = getCodeBlockId(node.position?.start)
// if (id) {
@ -192,7 +193,7 @@ describe('markdown', () => {
// console.log(`Code Block ID: "${id}" for content: "${node.value}" lang: "${node.lang}"`)
// }
// })
//
// return result
// }
@ -323,4 +324,79 @@ describe('markdown', () => {
expect(result).toBe(expectedResult)
})
})
describe('markdownToPlainText', () => {
it('should return an empty string if input is null or empty', () => {
expect(markdownToPlainText(null as any)).toBe('')
expect(markdownToPlainText('')).toBe('')
})
it('should remove headers', () => {
expect(markdownToPlainText('# Header 1')).toBe('Header 1')
expect(markdownToPlainText('## Header 2')).toBe('Header 2')
expect(markdownToPlainText('### Header 3')).toBe('Header 3')
})
it('should remove bold and italic', () => {
expect(markdownToPlainText('**bold**')).toBe('bold')
expect(markdownToPlainText('*italic*')).toBe('italic')
expect(markdownToPlainText('***bolditalic***')).toBe('bolditalic')
expect(markdownToPlainText('__bold__')).toBe('bold')
expect(markdownToPlainText('_italic_')).toBe('italic')
expect(markdownToPlainText('___bolditalic___')).toBe('bolditalic')
})
it('should remove strikethrough', () => {
expect(markdownToPlainText('~~strikethrough~~')).toBe('strikethrough')
})
it('should remove links, keeping the text', () => {
expect(markdownToPlainText('[link text](http://example.com)')).toBe('link text')
expect(markdownToPlainText('[link text with title](http://example.com "title")')).toBe('link text with title')
})
it('should remove images, keeping the alt text', () => {
expect(markdownToPlainText('![alt text](http://example.com/image.png)')).toBe('alt text')
})
it('should remove inline code', () => {
expect(markdownToPlainText('`inline code`')).toBe('inline code')
})
it('should remove code blocks', () => {
const codeBlock = '```javascript\nconst x = 1;\n```'
expect(markdownToPlainText(codeBlock)).toBe('const x = 1;') // remove-markdown keeps code content
})
it('should remove blockquotes', () => {
expect(markdownToPlainText('> blockquote')).toBe('blockquote')
})
it('should remove unordered lists', () => {
const list = '* item 1\n* item 2'
expect(markdownToPlainText(list).replace(/\n+/g, ' ')).toBe('item 1 item 2')
})
it('should remove ordered lists', () => {
const list = '1. item 1\n2. item 2'
expect(markdownToPlainText(list).replace(/\n+/g, ' ')).toBe('item 1 item 2')
})
it('should remove horizontal rules', () => {
expect(markdownToPlainText('---')).toBe('')
expect(markdownToPlainText('***')).toBe('')
expect(markdownToPlainText('___')).toBe('')
})
it('should handle a mix of markdown elements', () => {
const mixed = '# Title\nSome **bold** and *italic* text.\n[link](url)\n`code`\n> quote\n* list item'
const expected = 'Title\nSome bold and italic text.\nlink\ncode\nquote\nlist item'
const normalize = (str: string) => str.replace(/\s+/g, ' ').trim()
expect(normalize(markdownToPlainText(mixed))).toBe(normalize(expected))
})
it('should keep plain text unchanged', () => {
expect(markdownToPlainText('This is plain text.')).toBe('This is plain text.')
})
})
})

View File

@ -1,8 +1,22 @@
import { Topic } from '@renderer/types'
import { Message, Topic } from '@renderer/types'
import i18next from 'i18next'
import { topicToMarkdown } from './export'
import { messageToPlainText, topicToMarkdown, topicToPlainText } from './export'
export const copyTopicAsMarkdown = async (topic: Topic) => {
const markdown = await topicToMarkdown(topic)
await navigator.clipboard.writeText(markdown)
window.message.success(i18next.t('message.copy.success'))
}
export const copyTopicAsPlainText = async (topic: Topic) => {
const plainText = await topicToPlainText(topic)
await navigator.clipboard.writeText(plainText)
window.message.success(i18next.t('message.copy.success'))
}
export const copyMessageAsPlainText = async (message: Message) => {
const plainText = messageToPlainText(message)
await navigator.clipboard.writeText(plainText)
window.message.success(i18next.t('message.copy.success'))
}

View File

@ -7,7 +7,7 @@ import { setExportState } from '@renderer/store/runtime'
import type { Topic } from '@renderer/types'
import type { Message } from '@renderer/types/newMessage'
import { removeSpecialCharactersForFileName } from '@renderer/utils/file'
import { convertMathFormula } from '@renderer/utils/markdown'
import { convertMathFormula, markdownToPlainText } from '@renderer/utils/markdown'
import { getCitationContent, getMainTextContent, getThinkingContent } from '@renderer/utils/messageUtils/find'
import { markdownToBlocks } from '@tryfabric/martian'
import dayjs from 'dayjs'
@ -124,6 +124,22 @@ export const messagesToMarkdown = (messages: Message[], exportReasoning?: boolea
.join('\n\n---\n\n')
}
const formatMessageAsPlainText = (message: Message): string => {
const roleText = message.role === 'user' ? 'User:' : 'Assistant:'
const content = getMainTextContent(message)
const plainTextContent = markdownToPlainText(content).trim()
return `${roleText}\n${plainTextContent}`
}
export const messageToPlainText = (message: Message): string => {
const content = getMainTextContent(message)
return markdownToPlainText(content).trim()
}
const messagesToPlainText = (messages: Message[]): string => {
return messages.map(formatMessageAsPlainText).join('\n\n')
}
export const topicToMarkdown = async (topic: Topic, exportReasoning?: boolean) => {
const topicName = `# ${topic.name}`
const topicMessages = await db.topics.get(topic.id)
@ -135,6 +151,21 @@ export const topicToMarkdown = async (topic: Topic, exportReasoning?: boolean) =
return ''
}
export const topicToPlainText = async (topic: Topic): Promise<string> => {
const topicName = markdownToPlainText(topic.name).trim()
const topicMessages = await db.topics.get(topic.id)
if (topicMessages && topicMessages.messages.length > 0) {
return topicName + '\n\n' + messagesToPlainText(topicMessages.messages)
}
if (topicMessages && topicMessages.messages.length === 0) {
return topicName
}
return ''
}
export const exportTopicAsMarkdown = async (topic: Topic, exportReasoning?: boolean) => {
const { markdownExportPath } = store.getState().settings
if (!markdownExportPath) {

View File

@ -2,6 +2,7 @@ import remarkParse from 'remark-parse'
import remarkStringify from 'remark-stringify'
import { unified } from 'unified'
import { visit } from 'unist-util-visit'
import removeMarkdown from 'remove-markdown'
/**
*
@ -100,3 +101,16 @@ export function isValidPlantUML(code: string | null): boolean {
return diagramType !== undefined && code.search(`@end${diagramType}`) !== -1
}
/**
* Markdown
* @param markdown Markdown
* @returns
*/
export const markdownToPlainText = (markdown: string): string => {
if (!markdown) {
return ''
}
// 直接用 remove-markdown 库,使用默认的 removeMarkdown 参数
return removeMarkdown(markdown)
}

View File

@ -5707,6 +5707,7 @@ __metadata:
remark-cjk-friendly: "npm:^1.1.0"
remark-gfm: "npm:^4.0.0"
remark-math: "npm:^6.0.0"
remove-markdown: "npm:^0.6.2"
rollup-plugin-visualizer: "npm:^5.12.0"
sass: "npm:^1.88.0"
selection-hook: "npm:^0.9.23"
@ -16016,6 +16017,13 @@ __metadata:
languageName: node
linkType: hard
"remove-markdown@npm:^0.6.2":
version: 0.6.2
resolution: "remove-markdown@npm:0.6.2"
checksum: 10c0/d26fb020b202f227877a29701fcbc96980af3c17001ae46f3253ba21307babcfa424006a207161f83ed04efe19fc9be6b084bd1308ef0b2217c59139e6ef6eb4
languageName: node
linkType: hard
"repeat-string@npm:^1.0.0":
version: 1.6.1
resolution: "repeat-string@npm:1.6.1"