diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 5437a5f45b7..7b3718eabe0 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -18056,6 +18056,9 @@ importers: '@hcengineering/text-editor-resources': specifier: workspace:^0.7.0 version: link:../text-editor-resources + '@hcengineering/text-markdown': + specifier: workspace:^0.7.21 + version: link:../../foundations/core/packages/text-markdown '@hcengineering/ui': specifier: workspace:^0.7.0 version: link:../../packages/ui diff --git a/plugins/chunter-resources/package.json b/plugins/chunter-resources/package.json index 05123872f19..c290d547156 100644 --- a/plugins/chunter-resources/package.json +++ b/plugins/chunter-resources/package.json @@ -57,6 +57,7 @@ "@hcengineering/preference": "workspace:^0.7.0", "@hcengineering/presentation": "workspace:^0.7.0", "@hcengineering/text": "workspace:^0.7.19", + "@hcengineering/text-markdown": "workspace:^0.7.21", "@hcengineering/ui": "workspace:^0.7.0", "@hcengineering/view": "workspace:^0.7.0", "@hcengineering/view-resources": "workspace:^0.7.0", diff --git a/plugins/chunter-resources/src/__tests__/markdown.test.ts b/plugins/chunter-resources/src/__tests__/markdown.test.ts new file mode 100644 index 00000000000..9d45cec3470 --- /dev/null +++ b/plugins/chunter-resources/src/__tests__/markdown.test.ts @@ -0,0 +1,128 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { type Markup } from '@hcengineering/core' +import { jsonToMarkup, MarkupMarkType, MarkupNodeType, markupToJSON, type MarkupNode } from '@hcengineering/text' + +import { toChatDisplayMarkup } from '../markdown' + +describe('toChatDisplayMarkup', () => { + it('renders plain markdown lists as structured markup', () => { + const markup = toChatDisplayMarkup('Branch Name:\n\n- `project-123-chat-markdown`' as Markup) + const node = markupToJSON(markup) + + expect(node.content?.[1]?.type).toBe(MarkupNodeType.bullet_list) + + const itemText = node.content?.[1]?.content?.[0]?.content?.[0]?.content?.[0] + expect(itemText?.type).toBe(MarkupNodeType.text) + expect(itemText?.marks?.[0]?.type).toBe(MarkupMarkType.code) + }) + + it('leaves plain text without markdown markers unchanged', () => { + const text = 'Deploy frontend v1.0.0 and backend v2.2.1' as Markup + + expect(toChatDisplayMarkup(text)).toBe(text) + }) + + it('links standalone tracker issue identifiers from plain chat text', () => { + const markup = toChatDisplayMarkup('Ticket(s): APP-2291, ops_qa-8 and ENG-389' as Markup, { + issueHrefProvider: (identifier) => `/workbench/acme/tracker/${identifier}` + }) + const links = collectTextNodes(markupToJSON(markup)).filter((node) => + node.marks?.some((mark) => mark.type === MarkupMarkType.link) + ) + + expect(links.map((node) => node.text)).toEqual(['APP-2291', 'ops_qa-8', 'ENG-389']) + expect(links.map((node) => node.marks?.find((mark) => mark.type === MarkupMarkType.link)?.attrs?.href)).toEqual([ + '/workbench/acme/tracker/APP-2291', + '/workbench/acme/tracker/OPS_QA-8', + '/workbench/acme/tracker/ENG-389' + ]) + }) + + it('links issue identifiers after markdown formatting is parsed', () => { + const markup = toChatDisplayMarkup('1. Work on APP-2291' as Markup, { + issueHrefProvider: (identifier) => `/workbench/acme/tracker/${identifier}` + }) + const links = collectTextNodes(markupToJSON(markup)).filter((node) => + node.marks?.some((mark) => mark.type === MarkupMarkType.link) + ) + + expect(markupToJSON(markup).content?.[0]?.type).toBe(MarkupNodeType.ordered_list) + expect(links.map((node) => node.text)).toEqual(['APP-2291']) + }) + + it('does not link issue-like text inside branch names, urls, or existing code marks', () => { + const markup = toChatDisplayMarkup( + 'See https://example.test/workbench/acme/tracker/APP-2291?related=OPS-2398 and feature/app-2291-chat plus `APP-2398`' as Markup, + { + issueHrefProvider: (identifier) => `/workbench/acme/tracker/${identifier}` + } + ) + const links = collectTextNodes(markupToJSON(markup)).filter((node) => + node.marks?.some((mark) => mark.type === MarkupMarkType.link) + ) + + expect(links).toHaveLength(0) + }) + + it('does not reparse existing rich editor markup', () => { + const richMarkup = jsonToMarkup({ + type: MarkupNodeType.doc, + content: [ + { + type: MarkupNodeType.paragraph, + content: [ + { + type: MarkupNodeType.text, + text: '**already rich**', + marks: [{ type: MarkupMarkType.bold }] + } + ] + } + ] + }) + + expect(toChatDisplayMarkup(richMarkup)).toBe(richMarkup) + }) + + it('keeps checklist markers visible when markdown is parsed for chat display', () => { + const markup = toChatDisplayMarkup('- [x] done' as Markup) + const node = markupToJSON(markup) + + const firstItem = node.content?.[0]?.content?.[0] + expect(firstItem?.type).toBe(MarkupNodeType.list_item) + expect(collectText(firstItem)).toBe('[x] done') + }) +}) + +function collectText (node: MarkupNode | undefined): string { + if (node === undefined) { + return '' + } + + return `${node.text ?? ''}${(node.content ?? []).map(collectText).join('')}` +} + +function collectTextNodes (node: MarkupNode | undefined): MarkupNode[] { + if (node === undefined) { + return [] + } + + return [ + ...(node.type === MarkupNodeType.text ? [node] : []), + ...(node.content ?? []).flatMap((child) => collectTextNodes(child)) + ] +} diff --git a/plugins/chunter-resources/src/components/chat-message/ChatMessagePresenter.svelte b/plugins/chunter-resources/src/components/chat-message/ChatMessagePresenter.svelte index b6615b31271..360ad9ae57f 100644 --- a/plugins/chunter-resources/src/components/chat-message/ChatMessagePresenter.svelte +++ b/plugins/chunter-resources/src/components/chat-message/ChatMessagePresenter.svelte @@ -32,6 +32,7 @@ import view from '@hcengineering/view' import { getDocLinkTitle } from '@hcengineering/view-resources' + import { toChatDisplayMarkup } from '../../markdown' import { shownTranslatedMessagesStore, translatedMessagesStore, translatingMessagesStore } from '../../stores' import ChatMessageHeader from './ChatMessageHeader.svelte' import ChatMessageInput from './ChatMessageInput.svelte' @@ -223,9 +224,9 @@ let displayText: Markup = value?.message ?? EmptyMarkup $: if (value && $shownTranslatedMessagesStore.has(value._id)) { - displayText = $translatedMessagesStore.get(value._id) ?? value?.message ?? EmptyMarkup + displayText = toChatDisplayMarkup($translatedMessagesStore.get(value._id) ?? value?.message ?? EmptyMarkup) } else { - displayText = value?.message ?? EmptyMarkup + displayText = toChatDisplayMarkup(value?.message ?? EmptyMarkup) } diff --git a/plugins/chunter-resources/src/markdown.ts b/plugins/chunter-resources/src/markdown.ts new file mode 100644 index 00000000000..d0e08cc5256 --- /dev/null +++ b/plugins/chunter-resources/src/markdown.ts @@ -0,0 +1,283 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { concatLink, type Markup } from '@hcengineering/core' +import { + EmptyMarkup, + jsonToMarkup, + MarkupMarkType, + markupToJSON, + MarkupNodeType, + type MarkupNode +} from '@hcengineering/text' +import { markdownToMarkup } from '@hcengineering/text-markdown' + +const markdownSyntaxPatterns = [ + /^\s{0,3}(#{1,6}\s+\S|[-*+]\s+\S|\d+[.)]\s+\S|>\s+\S|```|~~~)/m, + /^\s{0,3}([-*_]\s*){3,}$/m, + /`[^`\n]+`/, + /(\*\*|__)[^\n]+(\*\*|__)/, + /(^|[^*])\*[^*\n]+\*(?!\*)/, + /(^|[^_])_[^_\n]+_(?!_)/, + /~~[^~\n]+~~/, + /\[[^\]\n]+\]\([^)]+\)/ +] + +const plainNodeTypes = new Set([ + MarkupNodeType.doc, + MarkupNodeType.paragraph, + MarkupNodeType.text, + MarkupNodeType.hard_break +]) + +const issueIdentifierPattern = /(^|[^A-Za-z0-9_/-])([A-Za-z][A-Za-z0-9_]{1,9}-\d+)(?=$|[^A-Za-z0-9_/-])/gi +const urlPattern = /\bhttps?:\/\/[^\s<>()]+/gi +const workbenchAppId = 'workbench' +const trackerAppId = 'tracker' + +interface ChatDisplayMarkupOptions { + issueHrefProvider?: (identifier: string) => string | undefined +} + +export function toChatDisplayMarkup (markup: Markup | undefined, options: ChatDisplayMarkupOptions = {}): Markup { + if (markup === undefined || markup === null || markup === '') { + return EmptyMarkup + } + + const node = markupToJSON(markup) + if (!isPlainMarkup(node)) { + return markup + } + + const text = plainTextFromNode(node) + const hasMarkdown = hasMarkdownSyntax(text) + const hasIssueIdentifiers = hasIssueIdentifier(text) + if (!hasMarkdown && !hasIssueIdentifiers) { + return markup + } + + try { + const displayNode = hasMarkdown ? normalizeChatMarkdownNodes(markdownToMarkup(text)) : node + return jsonToMarkup(linkIssueIdentifiers(displayNode, options.issueHrefProvider ?? getIssueHref)) + } catch { + return markup + } +} + +function hasMarkdownSyntax (text: string): boolean { + return markdownSyntaxPatterns.some((pattern) => pattern.test(text)) +} + +function hasIssueIdentifier (text: string): boolean { + issueIdentifierPattern.lastIndex = 0 + const result = issueIdentifierPattern.test(text) + issueIdentifierPattern.lastIndex = 0 + return result +} + +function isPlainMarkup (node: MarkupNode): boolean { + if (!plainNodeTypes.has(node.type)) { + return false + } + + if ((node.marks?.length ?? 0) > 0) { + return false + } + + return (node.content ?? []).every(isPlainMarkup) +} + +function plainTextFromNode (node: MarkupNode): string { + if (node.type === MarkupNodeType.text) { + return node.text ?? '' + } + + if (node.type === MarkupNodeType.hard_break) { + return '\n' + } + + const fragments = (node.content ?? []).map(plainTextFromNode) + if (node.type === MarkupNodeType.doc) { + return fragments.join('\n\n').trim() + } + + if (node.type === MarkupNodeType.paragraph) { + return fragments.join('') + } + + return fragments.join('') +} + +function linkIssueIdentifiers ( + node: MarkupNode, + issueHrefProvider: (identifier: string) => string | undefined +): MarkupNode { + const content = node.content + ?.flatMap((child) => linkIssueIdentifierNodes(child, issueHrefProvider)) + .filter((child) => child.text !== '') + + return content !== undefined ? { ...node, content } : node +} + +function linkIssueIdentifierNodes ( + node: MarkupNode, + issueHrefProvider: (identifier: string) => string | undefined +): MarkupNode[] { + if (node.type !== MarkupNodeType.text || node.text === undefined || shouldSkipIssueLinking(node)) { + return [linkIssueIdentifiers(node, issueHrefProvider)] + } + + return issueIdentifierTextNodes(node, issueHrefProvider) +} + +function issueIdentifierTextNodes ( + node: MarkupNode, + issueHrefProvider: (identifier: string) => string | undefined +): MarkupNode[] { + const text = node.text ?? '' + const nodes: MarkupNode[] = [] + const urlRanges = textRanges(text, urlPattern) + let lastIndex = 0 + + issueIdentifierPattern.lastIndex = 0 + for (let match = issueIdentifierPattern.exec(text); match !== null; match = issueIdentifierPattern.exec(text)) { + const leading = match[1] ?? '' + const identifier = match[2] + const identifierStart = match.index + leading.length + + if (identifier === undefined) { + continue + } + + if (isInTextRanges(identifierStart, urlRanges)) { + continue + } + + appendTextNode(nodes, node, text.slice(lastIndex, identifierStart)) + + const normalizedIdentifier = identifier.toUpperCase() + const href = issueHrefProvider(normalizedIdentifier) + appendTextNode(nodes, node, identifier, href, normalizedIdentifier) + + lastIndex = identifierStart + identifier.length + } + issueIdentifierPattern.lastIndex = 0 + + appendTextNode(nodes, node, text.slice(lastIndex)) + return nodes.length > 0 ? nodes : [node] +} + +function textRanges (text: string, pattern: RegExp): Array<[number, number]> { + pattern.lastIndex = 0 + const ranges: Array<[number, number]> = [] + + for (let match = pattern.exec(text); match !== null; match = pattern.exec(text)) { + ranges.push([match.index, match.index + match[0].length]) + } + pattern.lastIndex = 0 + + return ranges +} + +function isInTextRanges (index: number, ranges: Array<[number, number]>): boolean { + return ranges.some(([start, end]) => index >= start && index < end) +} + +function appendTextNode ( + nodes: MarkupNode[], + source: MarkupNode, + text: string, + href?: string, + title?: string +): void { + if (text === '') { + return + } + + const marks = source.marks ?? [] + nodes.push({ + ...source, + text, + marks: + href !== undefined && href !== '' + ? [...marks, { type: MarkupMarkType.link, attrs: { href, title: title ?? text } }] + : marks + }) +} + +function shouldSkipIssueLinking (node: MarkupNode): boolean { + return (node.marks ?? []).some((mark) => mark.type === MarkupMarkType.link || mark.type === MarkupMarkType.code) +} + +function getIssueHref (identifier: string): string | undefined { + if (typeof window === 'undefined') { + return undefined + } + + const workspace = getCurrentWorkspace() + if (workspace === undefined || workspace === '') { + return undefined + } + + const protocolAndHost = `${window.location.protocol}//${window.location.host}` + const path = [workbenchAppId, workspace, trackerAppId, identifier].map(encodeURIComponent).join('/') + return concatLink(protocolAndHost, path) +} + +function getCurrentWorkspace (): string | undefined { + return window.location.pathname.split('/').filter(Boolean).map(decodeURIComponent)[1] +} + +function normalizeChatMarkdownNodes (node: MarkupNode): MarkupNode { + const content = node.content?.map(normalizeChatMarkdownNodes) + + if (node.type === MarkupNodeType.todoList || node.type === MarkupNodeType.taskList) { + return { + type: MarkupNodeType.bullet_list, + attrs: { bullet: '-' }, + content + } + } + + if (node.type === MarkupNodeType.todoItem || node.type === MarkupNodeType.taskItem) { + return withChecklistMarker(node, content) + } + + return content !== undefined ? { ...node, content } : node +} + +function withChecklistMarker (node: MarkupNode, content: MarkupNode[] | undefined): MarkupNode { + const marker = node.attrs?.checked === true ? '[x] ' : '[ ] ' + const nodes = content ?? [] + const first = nodes[0] + + if (first?.type === MarkupNodeType.paragraph) { + return { + type: MarkupNodeType.list_item, + content: [ + { + ...first, + content: [{ type: MarkupNodeType.text, text: marker }, ...(first.content ?? [])] + }, + ...nodes.slice(1) + ] + } + } + + return { + type: MarkupNodeType.list_item, + content: [{ type: MarkupNodeType.paragraph, content: [{ type: MarkupNodeType.text, text: marker }] }, ...nodes] + } +}