diff --git a/bots/bugbuster/bot.definition.ts b/bots/bugbuster/bot.definition.ts index 5960e79ba56..6aedb184a1b 100644 --- a/bots/bugbuster/bot.definition.ts +++ b/bots/bugbuster/bot.definition.ts @@ -7,20 +7,6 @@ import telegram from './bp_modules/telegram' export default new sdk.BotDefinition({ states: { - recentlyLinted: { - type: 'bot', - schema: sdk.z.object({ - issues: sdk.z - .array( - sdk.z.object({ - id: sdk.z.string(), - lintedAt: sdk.z.string().datetime(), - }) - ) - .title('Recently Linted Issues') - .describe('List of recently linted issues'), - }), - }, watchedTeams: { type: 'bot', schema: sdk.z.object({ diff --git a/bots/bugbuster/src/bootstrap.ts b/bots/bugbuster/src/bootstrap.ts index 71ed0d55df3..21533d12808 100644 --- a/bots/bugbuster/src/bootstrap.ts +++ b/bots/bugbuster/src/bootstrap.ts @@ -10,7 +10,7 @@ export const bootstrap = (props: types.CommonHandlerProps) => { const linear = utils.linear.LinearApi.create() const teamsManager = new TeamsManager(linear, client, ctx.botId) - const recentlyLintedManager = new RecentlyLintedManager(client, ctx.botId) + const recentlyLintedManager = new RecentlyLintedManager(linear) const issueProcessor = new IssueProcessor(logger, linear, teamsManager) return { diff --git a/bots/bugbuster/src/handlers/linear-issue-updated.ts b/bots/bugbuster/src/handlers/linear-issue-updated.ts index c80aa559a75..d4a6081db89 100644 --- a/bots/bugbuster/src/handlers/linear-issue-updated.ts +++ b/bots/bugbuster/src/handlers/linear-issue-updated.ts @@ -17,14 +17,9 @@ export const handleLinearIssueUpdated: bp.EventHandlers['linear:issueUpdated'] = return } - const recentlyLinted = await recentlyLintedManager - .getRecentlyLinted() + const isRecentlyLinted = await recentlyLintedManager + .isRecentlyLinted(issue) .catch(_handleError('trying to get recently linted issues')) - const isRecentlyLinted = recentlyLinted.some(({ id: issueId }) => issue.id === issueId) - await issueProcessor.lintIssue(issue, isRecentlyLinted).catch(_handleError('trying to lint the updated Linear issue')) - await recentlyLintedManager - .setRecentlyLinted([...recentlyLinted, { id: issue.id, lintedAt: new Date().toISOString() }]) - .catch(_handleError('trying to update recently linted issues')) } diff --git a/bots/bugbuster/src/services/issue-processor/index.ts b/bots/bugbuster/src/services/issue-processor/index.ts index 159996352f3..9d2b4120b52 100644 --- a/bots/bugbuster/src/services/issue-processor/index.ts +++ b/bots/bugbuster/src/services/issue-processor/index.ts @@ -56,7 +56,7 @@ export class IssueProcessor { return { identifier: issue.identifier, result: 'ignored' } } - const errors = await lintIssue(issue, status) + const errors = lintIssue(issue, status) if (errors.length === 0) { this._logger.info(`Issue ${issue.identifier} passed all lint checks.`) diff --git a/bots/bugbuster/src/services/issue-processor/lint-issue.ts b/bots/bugbuster/src/services/issue-processor/lint-issue.ts index b46bd7c38e3..c2465e7f457 100644 --- a/bots/bugbuster/src/services/issue-processor/lint-issue.ts +++ b/bots/bugbuster/src/services/issue-processor/lint-issue.ts @@ -5,7 +5,7 @@ export type IssueLint = { message: string } -export const lintIssue = async (issue: lin.Issue, status: lin.StateKey): Promise => { +export const lintIssue = (issue: lin.Issue, status: lin.StateKey): IssueLint[] => { const lints: string[] = [] if (!_hasLabelOfCategory(issue, 'type')) { @@ -57,7 +57,7 @@ export const lintIssue = async (issue: lin.Issue, status: lin.StateKey): Promise ) } - const issueProject = await issue.project + const issueProject = issue.project if (issueProject && issueProject.completedAt) { lints.push( `Issue ${issue.identifier} is associated with a completed project (${issueProject.name}). Consider removing the project association.` diff --git a/bots/bugbuster/src/services/recently-linted-manager.ts b/bots/bugbuster/src/services/recently-linted-manager.ts index 5fe1eb067e7..bf882c32f16 100644 --- a/bots/bugbuster/src/services/recently-linted-manager.ts +++ b/bots/bugbuster/src/services/recently-linted-manager.ts @@ -1,42 +1,21 @@ -import * as bp from '.botpress' +import * as lin from '../utils/linear-utils' const RECENT_THRESHOLD: number = 1000 * 60 * 10 // 10 minutes -type IssueLintEntry = bp.states.recentlyLinted.RecentlyLinted['payload']['issues'][number] export class RecentlyLintedManager { - public constructor( - private _client: bp.Client, - private _botId: string - ) {} + public constructor(private _linear: lin.LinearApi) {} - public async getRecentlyLinted(): Promise { - const { - state: { - payload: { issues }, - }, - } = await this._client.getOrSetState({ - id: this._botId, - type: 'bot', - name: 'recentlyLinted', - payload: { issues: [] }, - }) - return issues.filter(this._isRecentlyLinted) - } - - public async setRecentlyLinted(issues: bp.states.recentlyLinted.RecentlyLinted['payload']['issues']): Promise { - await this._client.setState({ - id: this._botId, - type: 'bot', - name: 'recentlyLinted', - payload: { - issues: issues.filter(this._isRecentlyLinted), - }, - }) - } - - private _isRecentlyLinted = (issue: IssueLintEntry): boolean => { - const lintedAt = new Date(issue.lintedAt).getTime() + public async isRecentlyLinted(issue: lin.Issue): Promise { + const me = await this._linear.getMe() + const timestamps = issue.comments.nodes + .filter((comment) => comment.user.id === me.id) + .map((comment) => new Date(comment.createdAt).getTime()) const now = new Date().getTime() - return now - lintedAt < RECENT_THRESHOLD + for (const timestamp of timestamps) { + if (now - timestamp < RECENT_THRESHOLD) { + return true + } + } + return false } } diff --git a/bots/bugbuster/src/utils/linear-utils/client.ts b/bots/bugbuster/src/utils/linear-utils/client.ts index c127bd90e55..76fb5643575 100644 --- a/bots/bugbuster/src/utils/linear-utils/client.ts +++ b/bots/bugbuster/src/utils/linear-utils/client.ts @@ -40,6 +40,9 @@ export class LinearApi { } public async getMe(): Promise { + if (this._viewer) { + return this._viewer + } const me = await this._client.viewer if (!me) { throw new Error('Viewer not found. Please ensure you are authenticated.') diff --git a/bots/bugbuster/src/utils/linear-utils/graphql-queries.ts b/bots/bugbuster/src/utils/linear-utils/graphql-queries.ts index 0db1f9e1b4c..a2fba0ce1af 100644 --- a/bots/bugbuster/src/utils/linear-utils/graphql-queries.ts +++ b/bots/bugbuster/src/utils/linear-utils/graphql-queries.ts @@ -41,6 +41,7 @@ export type Issue = { nodes: { id: string resolvedAt: string | null + createdAt: string user: { id: string } @@ -95,7 +96,9 @@ export const GRAPHQL_QUERIES = { user { id }, - parentId + parentId, + resolvedAt, + createdAt } } } diff --git a/integrations/teams/integration.definition.ts b/integrations/teams/integration.definition.ts index 3c009e36c71..db9e70bab55 100644 --- a/integrations/teams/integration.definition.ts +++ b/integrations/teams/integration.definition.ts @@ -5,7 +5,7 @@ import { actions, channels, user, states } from 'definitions' export default new IntegrationDefinition({ name: 'teams', - version: '2.0.0', + version: '2.0.1', title: 'Microsoft Teams', description: 'Interact with users, deliver notifications, and perform actions within Microsoft Teams.', icon: 'icon.svg', diff --git a/integrations/teams/package.json b/integrations/teams/package.json index ce8db16a5fa..44214d5a510 100644 --- a/integrations/teams/package.json +++ b/integrations/teams/package.json @@ -11,6 +11,7 @@ "dependencies": { "@azure/identity": "^4.10.2", "@botpress/client": "workspace:*", + "@botpress/common": "workspace:*", "@botpress/sdk": "workspace:*", "@botpress/sdk-addons": "workspace:*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -26,7 +27,6 @@ }, "devDependencies": { "@botpress/cli": "workspace:*", - "@botpress/common": "workspace:*", "@botpress/sdk": "workspace:*", "@sentry/cli": "^2.39.1", "@types/jsonwebtoken": "^9.0.3", diff --git a/integrations/teams/src/channels/outbound.ts b/integrations/teams/src/channels/outbound.ts index 7c7452a5988..8cec60a6615 100644 --- a/integrations/teams/src/channels/outbound.ts +++ b/integrations/teams/src/channels/outbound.ts @@ -88,11 +88,29 @@ const _makeCard = (card: BotpressCard): Attachment => { return CardFactory.heroCard(title, images, buttons, { subtitle }) } -const _makeDropdownCard = (text: string, options: DropdownOption[]): Attachment => { - const choices = options.map((option: DropdownOption) => ({ - title: option.label, - value: option.value, - })) +const _distinctByValue = (choice: { value: string }, index: number, arr: { value: string }[]): boolean => { + return arr.findIndex((otherChoice) => otherChoice.value === choice.value) === index +} + +const _makeDropdownCard = (text: string, options: DropdownOption[], logger: bp.Logger): Attachment => { + const uniqueChoices = options + .map((option: DropdownOption) => ({ + title: option.label, + value: option.value, + })) + // Hotfix: This exists because some client's code was + // duplicating the options everytime the workflow was ran + .filter(_distinctByValue) + + if (uniqueChoices.length < options.length) { + // Normally, it's not the responsibility of the integration + // to warn the user of issues in their workflow. + logger + .forBot() + .warn( + `The dropdown options contained duplicates (This is likely due to a misconfiguration in the bot workflow).\nReduced from ${options.length} to ${uniqueChoices.length} unique options.` + ) + } return CardFactory.adaptiveCard({ // documentation here https://learn.microsoft.com/en-us/adaptive-cards/authoring-cards/text-features @@ -108,7 +126,7 @@ const _makeDropdownCard = (text: string, options: DropdownOption[]): Attachment type: 'Input.ChoiceSet', placeholder: 'Select...', style: 'compact', - choices, + choices: uniqueChoices, }, ], actions: [ @@ -202,7 +220,7 @@ const _handleDropdownMessage = async (props: MessageHandlerProps<'dropdown'>) => const { options, text } = props.payload const activity: Partial = { type: 'message', - attachments: [_makeDropdownCard(text, options)], + attachments: [_makeDropdownCard(text, options, props.logger)], } await _renderTeams(props, activity) } diff --git a/integrations/teams/src/markdown/markdown-to-teams-xml.ts b/integrations/teams/src/markdown/markdown-to-teams-xml.ts index 928bdbf25c4..d5bf62a2b0d 100644 --- a/integrations/teams/src/markdown/markdown-to-teams-xml.ts +++ b/integrations/teams/src/markdown/markdown-to-teams-xml.ts @@ -1,14 +1,13 @@ +import { transformMarkdown, type MarkdownHandlers } from '@botpress/common' import type { Parent } from 'mdast' -import { remark } from 'remark' -import remarkGfm from 'remark-gfm' import { escapeAndSanitizeHtml, isNaughtyUrl, sanitizeHtml } from './sanitize-utils' -import type { - DefinedLinkReference, - DefinitionNodeData, - MarkdownHandlers, - NodeHandler, - TableCellWithHeaderInfo, -} from './types' + +type DefinitionNodeData = { + identifier: string + url: string + label?: string | null + title?: string | null +} export const defaultHandlers: MarkdownHandlers = { blockquote: (node, visit) => `
\n\n${visit(node)}\n
`, @@ -38,8 +37,9 @@ export const defaultHandlers: MarkdownHandlers = { image: (node) => _createSanitizedImage(node), inlineCode: (node) => `${node.value}`, link: (node, visit) => _createSanitizedHyperlink(node, visit(node)), - linkReference: (node, visit) => { - const { linkDefinition } = node + linkReference: (node, visit, _parents, _handlers, data) => { + const linkDefinitions = (data.linkDefinitions ?? {}) as Record + const linkDefinition = linkDefinitions[node.identifier] const nodeContent = visit(node) if (!linkDefinition) { return nodeContent @@ -61,14 +61,7 @@ export const defaultHandlers: MarkdownHandlers = { }, paragraph: (node, visit, parents) => `${visit(node)}${parents.at(-1)?.type === 'root' ? '\n' : ''}`, strong: (node, visit) => `${visit(node)}`, - table: (node, visit) => { - const headerRow = node.children[0] - headerRow?.children.forEach((cell: TableCellWithHeaderInfo) => { - cell.isHeader = true - }) - - return `\n${visit(node)}
` - }, + table: (node, visit) => `\n${visit(node)}
`, tableRow: (node, visit) => `\n${visit(node)}\n`, tableCell: (node, visit) => { const tag = node.isHeader === true ? 'th' : 'td' @@ -109,8 +102,6 @@ const _createSanitizedImage = (props: ImageProps): string => { return `` } -const _isNodeType = (s: string, handlers: MarkdownHandlers): s is keyof MarkdownHandlers => s in handlers - const _extractDefinitions = (parentNode: Parent): Record => { let definitions: Record = {} for (const node of parentNode.children) { @@ -133,52 +124,11 @@ const _extractDefinitions = (parentNode: Parent): Record -): string => { - let tmp = '' - let footnoteTmp = '' - parents.push(tree) - - for (const node of tree.children) { - if (!_isNodeType(node.type, handlers)) { - throw new Error(`The Markdown node type [${node.type}] is not supported`) - } - - const handler = handlers[node.type] as NodeHandler - - if (node.type === 'linkReference') { - const linkReferenceNode = node as DefinedLinkReference - const def = definitions[node.identifier] - linkReferenceNode.linkDefinition = def - - tmp += handler( - linkReferenceNode, - (n) => _visitTree(n, handlers, parents, definitions), - parents, - handlers, - definitions - ) - continue - } - - if (node.type === 'footnoteDefinition') { - footnoteTmp += handler(node, (n) => _visitTree(n, handlers, parents, definitions), parents, handlers, definitions) - continue - } - tmp += handler(node, (n) => _visitTree(n, handlers, parents, definitions), parents, handlers, definitions) - } - parents.pop() - return `${tmp}${footnoteTmp}` -} - -export const transformMarkdownToTeamsXml = (markdown: string, handlers: MarkdownHandlers = defaultHandlers): string => { - const tree = remark().use(remarkGfm).parse(markdown) - const definitions = _extractDefinitions(tree) - let html = _visitTree(tree, handlers, [], definitions).trim() +export const transformMarkdownToTeamsXml = (markdown: string): string => { + let html = transformMarkdown(markdown, defaultHandlers, (root) => { + const linkDefinitions = _extractDefinitions(root) + return { linkDefinitions } + }).trim() _replacers.forEach((replacer) => { html = replacer(html) }) diff --git a/integrations/teams/src/markdown/types.ts b/integrations/teams/src/markdown/types.ts deleted file mode 100644 index cdf59c56295..00000000000 --- a/integrations/teams/src/markdown/types.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { Node, LinkReference, Parent, Nodes, Definition, TableCell } from 'mdast' - -export type Merge = Omit & R - -export type DefinitionNodeData = { - identifier: string - url: string - label?: string | null - title?: string | null -} - -export type DefinedLinkReference = LinkReference & { linkDefinition?: DefinitionNodeData } -export type TableCellWithHeaderInfo = TableCell & { isHeader?: boolean } - -export type NodeHandler = ( - node: Type extends Node ? Type : Extract, - visit: (node: Parent) => string, - parents: Parent[], - handlers: MarkdownHandlers, - definitions: Record -) => string - -export type MarkdownHandlers = Partial< - Merge< - { [Type in Nodes['type']]: NodeHandler }, - { - linkReference: NodeHandler - tableCell: NodeHandler - } - > -> - -type Is = A extends B ? (B extends A ? true : false) : false -type HasProperties = Is, Required>>> -type Expect<_T extends true> = void - -// This builds only if the Definition contains all properties of DefinitionNodeData -type _Test = Expect> diff --git a/packages/common/src/markdown-transformer/markdown-transformer.test.ts b/packages/common/src/markdown-transformer/markdown-transformer.test.ts index d1c859fd5ff..a0df6ab42e8 100644 --- a/packages/common/src/markdown-transformer/markdown-transformer.test.ts +++ b/packages/common/src/markdown-transformer/markdown-transformer.test.ts @@ -1,4 +1,4 @@ -import { test, expect } from 'vitest' +import { test, expect, describe } from 'vitest' import { transformMarkdown, stripAllHandlers } from './markdown-transformer' import { MarkdownHandlers } from './types' @@ -79,14 +79,6 @@ const stripAllTests: Test = { }, } -test.each(Object.entries(stripAllTests))( - 'Single line test %s', - (_testName: string, testValues: { input: string; expected: string }): void => { - const actual = transformMarkdown(testValues.input) - expect(actual).toBe(testValues.expected) - } -) - const bigInput = `# H1 ## H2 ### H3 @@ -164,61 +156,71 @@ emoji direct 😂 [1] the footnote ` -test('Multi-line multi markup test', () => { - const actual = transformMarkdown(bigInput) - expect(actual).toBe(expectedForBigInput) -}) - -test('Custom heading handler markup test', () => { - const expected = 'heading handled' - const customHandlers: MarkdownHandlers = { - ...stripAllHandlers, - heading: (_node, _visit) => { - return expected - }, - } - - const actual = transformMarkdown('# any heading`', customHandlers) - - expect(actual).toBe(expected) -}) - -test('Custom blockQuote handler markup test', () => { - const expected = 'quote handled' - const customHandlers: MarkdownHandlers = { - ...stripAllHandlers, - blockquote: (_node, _visit) => { - return expected - }, - } - - const actual = transformMarkdown('> any quote\n> 1\n> 2\n>3', customHandlers) - - expect(actual).toBe(expected) -}) - -test('Custom paragraph handler markup test', () => { - const expected = 'paragraph handled' - const customHandlers: MarkdownHandlers = { - ...stripAllHandlers, - paragraph: (_node, _visit) => expected, - } - - const actual = transformMarkdown('any paragraph', customHandlers) - - expect(actual).toBe(expected) -}) - -test('Escape character markup test', () => { - const input = '\\# not handled' - const expected = '# not handled' - const customHandlers: MarkdownHandlers = { - ...stripAllHandlers, - heading: (_node, _visit) => 'title handled', - paragraph: (_node, _visit) => _visit(_node), - } - - const actual = transformMarkdown(input, customHandlers) - - expect(actual).toBe(expected) +describe('Markdown Transformer', () => { + test.each(Object.entries(stripAllTests))( + 'Single line test %s', + (_testName: string, testValues: { input: string; expected: string }): void => { + const actual = transformMarkdown(testValues.input) + expect(actual).toBe(testValues.expected) + } + ) + + test('Multi-line multi markup test', () => { + const actual = transformMarkdown(bigInput) + expect(actual).toBe(expectedForBigInput) + }) + + test('Custom heading handler markup test', () => { + const expected = 'heading handled' + const customHandlers: MarkdownHandlers = { + ...stripAllHandlers, + heading: (_node, _visit) => { + return expected + }, + } + + const actual = transformMarkdown('# any heading`', customHandlers) + + expect(actual).toBe(expected) + }) + + test('Custom blockQuote handler markup test', () => { + const expected = 'quote handled' + const customHandlers: MarkdownHandlers = { + ...stripAllHandlers, + blockquote: (_node, _visit) => { + return expected + }, + } + + const actual = transformMarkdown('> any quote\n> 1\n> 2\n>3', customHandlers) + + expect(actual).toBe(expected) + }) + + test('Custom paragraph handler markup test', () => { + const expected = 'paragraph handled' + const customHandlers: MarkdownHandlers = { + ...stripAllHandlers, + paragraph: (_node, _visit) => expected, + } + + const actual = transformMarkdown('any paragraph', customHandlers) + + expect(actual).toBe(expected) + }) + + test('Escape character markup test', () => { + const input = '\\# not handled' + const expected = '# not handled' + const customHandlers: MarkdownHandlers = { + ...stripAllHandlers, + heading: (_node, _visit) => 'title handled', + paragraph: (_node, _visit) => _visit(_node), + } + + const actual = transformMarkdown(input, customHandlers) + + expect(actual).toBe(expected) + }) }) diff --git a/packages/common/src/markdown-transformer/markdown-transformer.ts b/packages/common/src/markdown-transformer/markdown-transformer.ts index f2879b803a0..38849658550 100644 --- a/packages/common/src/markdown-transformer/markdown-transformer.ts +++ b/packages/common/src/markdown-transformer/markdown-transformer.ts @@ -1,7 +1,10 @@ -import { List, Node, Table } from 'mdast' +import { List, Parent, Root, Table } from 'mdast' import { remark } from 'remark' import remarkGfm from 'remark-gfm' -import { MarkdownHandlers, NodeHandler, RootNodes } from './types' +import { ExtendedList, ExtendedListItem, ExtendedTableRow, MarkdownHandlers, NodeHandler } from './types' + +/** 'En space' yields better results for indentation in WhatsApp messages */ +const FIXED_SIZE_SPACE_CHAR = '\u2002' export const stripAllHandlers: MarkdownHandlers = { blockquote: (node, visit) => `Quote: “${visit(node)}”\n`, @@ -16,76 +19,119 @@ export const stripAllHandlers: MarkdownHandlers = { image: (node, _visit) => node.url, inlineCode: (node, _visit) => node.value, link: (node, _visit) => node.url, - list: (node, _visit, parents, handlers) => handleList(node, handlers, parents), + list: (node, visit) => { + return `${node.listLevel !== 1 ? '\n' : ''}${visit(node)}` + }, + listItem: (node, visit) => { + const { itemCount, checked, ownerList } = node + let prefix = FIXED_SIZE_SPACE_CHAR.repeat(ownerList.listLevel - 1) + + if (checked !== null) { + prefix += checked ? '☑︎ ' : '☐ ' + } else { + prefix += ownerList.ordered === true ? `${itemCount}. ` : '- ' + } + + const shouldBreak = ownerList.listLevel === 1 || itemCount < ownerList.children.length + return `${prefix}${visit(node)}${shouldBreak ? '\n' : ''}` + }, paragraph: (node, visit, parents) => `${visit(node)}${parents.at(-1)?.type === 'root' ? '\n' : ''}`, strong: (node, visit) => visit(node), - table: (node, _visit, parents, handlers) => handleTable(node, handlers, parents), + table: (node, visit) => visit(node), + tableRow: (node, visit) => `${visit(node)}\n`, + tableCell: (node, visit) => { + const prefix = node.isFirst ? '| ' : '' + const suffix = node.isLast ? ' |' : ' | ' + return `${prefix}${visit(node)}${suffix}` + }, text: (node, _visit) => node.value, thematicBreak: (_node, _visit) => '---\n', } -const FIXED_SIZE_SPACE_CHAR = '\u2002' // 'En space' yields better results for identation in WhatsApp messages - -export const handleList = (listNode: List, handlers: MarkdownHandlers, parents: RootNodes[]): string => { - parents.push(listNode) - const listLevel = parents.filter((parent) => parent.type === 'list').length - let listTmp = listLevel !== 1 ? '\n' : '' - let itemCount = 0 - for (const listItemNode of listNode.children) { - itemCount++ - let prefix = FIXED_SIZE_SPACE_CHAR.repeat(listLevel - 1) - if (listItemNode.checked !== undefined && listItemNode.checked !== null) { - prefix += listItemNode.checked ? '☑︎ ' : '☐ ' - } else { - prefix += listNode.ordered ? `${itemCount}. ` : '- ' - } - const shouldBreak = listLevel === 1 || itemCount !== listNode.children.length - listTmp = `${listTmp}${prefix}${visitTree(listItemNode, handlers, parents)}${shouldBreak ? '\n' : ''}` +const _applyListLevelAndItemIndices = (listNode: List, parents: Parent[]): ExtendedList => { + const extendedList = listNode as ExtendedList + + const listLevel = parents.filter((parent) => parent.type === 'list').length + 1 + extendedList.listLevel = listLevel + + let index = 0 + for (const item of listNode.children) { + index++ + const extendedItem = item as ExtendedListItem + extendedItem.ownerList = extendedList + extendedItem.itemCount = index } - parents.pop() - return listTmp + + return extendedList } -export const handleTable = (tableNode: Table, handlers: MarkdownHandlers, parents: RootNodes[]): string => { - parents.push(tableNode) - let tmpTable = '' - for (const tableRow of tableNode.children) { - let childrenCount = 0 - let tmpRow = '| ' - for (const tableCell of tableRow.children) { - tmpRow = `${tmpRow}${visitTree(tableCell, handlers, parents)}${childrenCount + 1 === tableRow.children.length ? ' |' : ' | '}` - childrenCount++ - } - tmpTable = `${tmpTable}${tmpRow}\n` - } - parents.pop() - return tmpTable +const _applyExtendedTableProps = (tableNode: Table): Table => { + tableNode.children.forEach((row, index) => { + const extendedRow = row as ExtendedTableRow + const isHeaderRow = index === 0 + extendedRow.isHeader = isHeaderRow + + const numOfCells = extendedRow.children.length + extendedRow.children.forEach((cell, index) => { + cell.isHeader = isHeaderRow + cell.isFirst = index === 0 + cell.isLast = index === numOfCells - 1 + }) + }) + + return tableNode } -const isNodeType = (s: string, handlers: MarkdownHandlers): s is keyof MarkdownHandlers => s in handlers +const _isNodeType = (s: string, handlers: MarkdownHandlers): s is keyof MarkdownHandlers => s in handlers -export const visitTree = (tree: RootNodes, handlers: MarkdownHandlers, parents: RootNodes[]): string => { +export const visitTree = ( + tree: Parent, + handlers: MarkdownHandlers, + parents: Parent[], + data: Record +): string => { let tmp = '' let footnoteTmp = '' parents.push(tree) for (const node of tree.children) { - if (!isNodeType(node.type, handlers)) { + if (!_isNodeType(node.type, handlers)) { throw new Error(`The Markdown node type [${node.type}] is not supported`) } - const handler = handlers[node.type] as NodeHandler + const handler = handlers[node.type] as NodeHandler + const visitHandler = (n: Parent) => visitTree(n, handlers, parents, data) - if (node.type === 'footnoteDefinition') { - footnoteTmp += handler(node, (n) => visitTree(n, handlers, parents), parents, handlers) - continue + switch (node.type) { + case 'list': + const listNode = _applyListLevelAndItemIndices(node, parents) + tmp += handler(listNode, visitHandler, parents, handlers, data) + break + case 'listItem': + node.checked = node.checked ?? null + tmp += handler(node, visitHandler, parents, handlers, data) + break + case 'table': + const extendedTableNode = _applyExtendedTableProps(node) + tmp += handler(extendedTableNode, visitHandler, parents, handlers, data) + break + case 'footnoteDefinition': + footnoteTmp += handler(node, visitHandler, parents, handlers, data) + break + default: + tmp += handler(node, visitHandler, parents, handlers, data) + break } - tmp += handler(node, (n) => visitTree(n, handlers, parents), parents, handlers) } parents.pop() return `${tmp}${footnoteTmp}` } -export const transformMarkdown = (markdown: string, handlers: MarkdownHandlers = stripAllHandlers): string => { +export const transformMarkdown = ( + markdown: string, + handlers: MarkdownHandlers = stripAllHandlers, + preProcessor?: (root: Root) => Record +): string => { const tree = remark().use(remarkGfm).parse(markdown) - return visitTree(tree, handlers, []) + const data = preProcessor?.(tree) ?? {} + return visitTree(tree, handlers, [], data) } diff --git a/packages/common/src/markdown-transformer/types.ts b/packages/common/src/markdown-transformer/types.ts index d95426bf83d..3c2d52eac7e 100644 --- a/packages/common/src/markdown-transformer/types.ts +++ b/packages/common/src/markdown-transformer/types.ts @@ -1,66 +1,52 @@ -import { - Blockquote, - Break, - Code, - Delete, - Emphasis, - FootnoteDefinition, - FootnoteReference, - Heading, - Html, - Image, - InlineCode, - Link, - List, - ListItem, - Node, - Paragraph, - Root, - Strong, - Table, - TableCell, - Text, - ThematicBreak, -} from 'mdast' +import { List, ListItem, Node, TableCell, Nodes, TableRow, Parent } from 'mdast' + +export type Merge = Omit & R -export type NodeHandler = ( - node: N, - visit: (node: RootNodes) => string, - parents: RootNodes[], - handlers: MarkdownHandlers +export type NodeHandler = ( + node: Type extends Node ? Type : Extract, + visit: (node: Parent) => string, + parents: Parent[], + handlers: MarkdownHandlers, + state: Record ) => string -export type MarkdownHandlers = { - blockquote: NodeHandler
- break: NodeHandler - code: NodeHandler - delete: NodeHandler - emphasis: NodeHandler - footnoteDefinition: NodeHandler - footnoteReference: NodeHandler - heading: NodeHandler - html: NodeHandler - image: NodeHandler - inlineCode: NodeHandler - link: NodeHandler - list: NodeHandler - paragraph: NodeHandler - strong: NodeHandler - table: NodeHandler - text: NodeHandler - thematicBreak: NodeHandler -} +export type MarkdownHandlers = Partial< + Merge< + { [Type in Nodes['type']]: NodeHandler }, + { + listItem: NodeHandler + list: NodeHandler + tableRow: NodeHandler + tableCell: NodeHandler + } + > +> -export type RootNodes = - | Blockquote - | Delete - | Emphasis - | FootnoteDefinition - | Heading - | List - | ListItem - | Paragraph - | Root - | Strong - | Table - | TableCell +// ===== Node Property Extensions ==== +export type ExtendedList = Merge< + List, + { + listLevel: number + children: ExtendedListItem[] + } +> +export type ExtendedListItem = ListItem & { + checked: boolean | null + /** One Based Index */ + itemCount: number + ownerList: ExtendedList +} +export type ExtendedTableRow = Merge< + TableRow, + { + isHeader?: boolean + children: ExtendedTableCell[] + } +> +export type ExtendedTableCell = TableCell & { + isHeader?: boolean + /** If it's the first cell in the row */ + isFirst: boolean + /** If it's the last cell in the row */ + isLast: boolean +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44e8228b6f3..22c4b68709a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1696,6 +1696,9 @@ importers: '@botpress/client': specifier: workspace:* version: link:../../packages/client + '@botpress/common': + specifier: workspace:* + version: link:../../packages/common '@botpress/sdk': specifier: workspace:* version: link:../../packages/sdk @@ -1736,9 +1739,6 @@ importers: '@botpress/cli': specifier: workspace:* version: link:../../packages/cli - '@botpress/common': - specifier: workspace:* - version: link:../../packages/common '@sentry/cli': specifier: ^2.39.1 version: 2.39.1