diff --git a/projects/ui/src/lib/components/po-helper/index.ts b/projects/ui/src/lib/components/po-helper/index.ts index 2bdd85eddc..c01fcc4a3e 100644 --- a/projects/ui/src/lib/components/po-helper/index.ts +++ b/projects/ui/src/lib/components/po-helper/index.ts @@ -1,5 +1,3 @@ export * from './po-helper.module'; export * from './po-helper.component'; export * from './interfaces/po-helper.interface'; -export * from './po-helper-content-utils'; -export * from './po-helper-content.pipe'; diff --git a/projects/ui/src/lib/components/po-helper/po-helper-content-utils.ts b/projects/ui/src/lib/components/po-helper/po-helper-content-utils.ts deleted file mode 100644 index c4ba8f2cab..0000000000 --- a/projects/ui/src/lib/components/po-helper/po-helper-content-utils.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * @description - * - * Utilitário de "Safe Parser" para o componente po-helper. - * - * Permite o uso de tags de formatação básica (``, ``, ``, ``, ``) no conteúdo do helper, sem - * utilizar `innerHTML`, garantindo proteção contra ataques XSS. - * - * Qualquer tag HTML que não esteja na whitelist é sanitizada (removida), preservando apenas o texto interno. - */ - -/** - * Representa um fragmento de texto com suas propriedades de formatação. - */ -export interface PoHelperTextFragment { - /** Conteúdo textual do fragmento */ - text: string; - /** Indica se o fragmento deve ser exibido em negrito */ - bold: boolean; - /** Indica se o fragmento deve ser exibido em itálico */ - italic: boolean; - /** Indica se o fragmento deve ser exibido com sublinhado */ - underline: boolean; -} - -/** Tags permitidas pelo safe parser */ -const ALLOWED_TAGS = ['b', 'i', 'u', 'strong', 'em']; - -/** Mapa de normalização: tags semânticas são convertidas para suas equivalentes de formatação */ -const TAG_NORMALIZE_MAP: Record = { - strong: 'b', - em: 'i' -}; - -/** - * Regex para capturar tags HTML (abertura e fechamento). - * Captura: tag name e se é fechamento (/). - */ -const TAG_REGEX = /<(\/?)(\w+)(?:\s[^>]*)?\/?>/gi; - -/** - * Remove todas as tags HTML que não estão na whitelist, preservando o texto interno. - * - * @param input String com possíveis tags HTML - * @returns String com apenas as tags permitidas - */ -function sanitizeContent(input: string): string { - return input.replace(TAG_REGEX, (_match, closing, tagName) => { - const normalizedTag = tagName.toLowerCase(); - if (ALLOWED_TAGS.includes(normalizedTag)) { - const outputTag = TAG_NORMALIZE_MAP[normalizedTag] || normalizedTag; - return closing ? `` : `<${outputTag}>`; - } - return ''; - }); -} - -/** Mapa de contadores por tag */ -interface TagCounters { - b: number; - i: number; - u: number; -} - -/** - * Incrementa o contador da tag de abertura. - */ -function incrementTagCounter(counters: TagCounters, tag: string): void { - counters[tag]++; -} - -/** - * Decrementa o contador da tag de fechamento. - */ -function decrementTagCounter(counters: TagCounters, tag: string): void { - counters[tag] = Math.max(0, counters[tag] - 1); -} - -/** - * Faz o parsing de uma string com tags de formatação permitidas (``, ``, ``) - * e retorna um array de fragmentos com as propriedades de formatação aplicadas. - * - * Tags não permitidas são removidas (sanitizadas). Tags aninhadas são suportadas. - * - * @param content String de conteúdo com possíveis tags de formatação - * @returns Array de fragmentos de texto com informações de formatação - */ -export function parseHelperContent(content: string): Array { - if (!content) { - return []; - } - - const sanitized = sanitizeContent(content); - const fragments: Array = []; - const counters: TagCounters = { b: 0, i: 0, u: 0 }; - - const splitRegex = /(<\/?[biu]>)/gi; - const parts = sanitized.split(splitRegex); - - for (const part of parts) { - if (!part) { - continue; - } - - const tagMatch = /^<(\/?)([biu])>$/i.exec(part); - - if (tagMatch) { - const isClosing = tagMatch[1] === '/'; - const tag = tagMatch[2].toLowerCase(); - if (isClosing) { - decrementTagCounter(counters, tag); - } else { - incrementTagCounter(counters, tag); - } - } else { - fragments.push({ - text: part, - bold: counters.b > 0, - italic: counters.i > 0, - underline: counters.u > 0 - }); - } - } - - return fragments; -} diff --git a/projects/ui/src/lib/components/po-helper/po-helper-content.pipe.spec.ts b/projects/ui/src/lib/components/po-helper/po-helper-content.pipe.spec.ts deleted file mode 100644 index f40aa14994..0000000000 --- a/projects/ui/src/lib/components/po-helper/po-helper-content.pipe.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { PoHelperContentPipe } from './po-helper-content.pipe'; - -describe('PoHelperContentPipe', () => { - let pipe: PoHelperContentPipe; - - beforeEach(() => { - pipe = new PoHelperContentPipe(); - }); - - it('should create an instance', () => { - expect(pipe).toBeTruthy(); - }); - - it('should return empty array for null content', () => { - expect(pipe.transform(null)).toEqual([]); - }); - - it('should return empty array for undefined content', () => { - expect(pipe.transform(undefined)).toEqual([]); - }); - - it('should return empty array for empty string', () => { - expect(pipe.transform('')).toEqual([]); - }); - - it('should return single fragment for plain text', () => { - const result = pipe.transform('Texto simples'); - expect(result).toEqual([{ text: 'Texto simples', bold: false, italic: false, underline: false }]); - }); - - it('should parse bold tag', () => { - const result = pipe.transform('Texto negrito'); - expect(result[1]).toEqual({ text: 'negrito', bold: true, italic: false, underline: false }); - }); - - it('should parse italic tag', () => { - const result = pipe.transform('Texto itálico'); - expect(result[1]).toEqual({ text: 'itálico', bold: false, italic: true, underline: false }); - }); - - it('should parse underline tag', () => { - const result = pipe.transform('Texto sublinhado'); - expect(result[1]).toEqual({ text: 'sublinhado', bold: false, italic: false, underline: true }); - }); - - it('should normalize strong to bold', () => { - const result = pipe.transform('bold'); - expect(result[0]).toEqual({ text: 'bold', bold: true, italic: false, underline: false }); - }); - - it('should normalize em to italic', () => { - const result = pipe.transform('italic'); - expect(result[0]).toEqual({ text: 'italic', bold: false, italic: true, underline: false }); - }); - - it('should sanitize script tags', () => { - const result = pipe.transform('safe'); - const allText = result.map(f => f.text).join(''); - expect(allText).not.toContain(' seguro'; + expect(component.subtitleFragments).toEqual([ + { text: 'alert("xss") seguro', bold: false, italic: false, underline: false } + ]); + }); + + it('should return empty array for undefined subtitle', () => { + component.subtitle = undefined; + expect(component.subtitleFragments).toEqual([]); + }); + + it('should return empty array for empty string subtitle', () => { + component.subtitle = ''; + expect(component.subtitleFragments).toEqual([]); + }); + }); }); diff --git a/projects/ui/src/lib/components/po-page/po-page-header/po-page-header-base.component.ts b/projects/ui/src/lib/components/po-page/po-page-header/po-page-header-base.component.ts index c3b6d6daaf..c01d9eb9df 100644 --- a/projects/ui/src/lib/components/po-page/po-page-header/po-page-header-base.component.ts +++ b/projects/ui/src/lib/components/po-page/po-page-header/po-page-header-base.component.ts @@ -2,6 +2,10 @@ import { Directive, Input, input } from '@angular/core'; import { PoBreadcrumb } from '../../po-breadcrumb/po-breadcrumb.interface'; import { PoHelperOptions } from '../../po-helper/interfaces/po-helper.interface'; +import { parseSafeText, PoFormattingTag, PoTextFragment } from '../../../utils/safe-text-parser'; + +/** Tags aceitas pelo subtítulo do po-page-header. */ +const PAGE_SUBTITLE_ALLOWED_TAGS: Array = ['b', 'i', 'u', 'strong', 'em']; /** * @docsPrivate @@ -22,13 +26,12 @@ export class PoPageHeaderBaseComponent { /** Define o tamanho dos componentes no header. */ @Input('p-size') size: string; - /** Subtítulo da página. */ - @Input('p-subtitle') subtitle: string; - /** Define o tipo de header: `primary`, `secondary` ou `tertiary`. */ @Input('p-type') type: string = 'primary'; private _breadcrumb: PoBreadcrumb; + private _subtitle: string; + private _subtitleFragments: Array = []; /** Objeto com propriedades do breadcrumb. */ @@ -38,4 +41,18 @@ export class PoPageHeaderBaseComponent { get breadcrumb(): PoBreadcrumb { return this._breadcrumb; } + + /** Subtítulo da página. */ + @Input('p-subtitle') set subtitle(value: string) { + this._subtitle = value; + this._subtitleFragments = parseSafeText(value, PAGE_SUBTITLE_ALLOWED_TAGS); + } + + get subtitle(): string { + return this._subtitle; + } + + get subtitleFragments(): Array { + return this._subtitleFragments; + } } diff --git a/projects/ui/src/lib/components/po-page/po-page-header/po-page-header.component.html b/projects/ui/src/lib/components/po-page/po-page-header/po-page-header.component.html index da787d014c..30b5143040 100644 --- a/projects/ui/src/lib/components/po-page/po-page-header/po-page-header.component.html +++ b/projects/ui/src/lib/components/po-page/po-page-header/po-page-header.component.html @@ -36,7 +36,16 @@

} @if (title && subtitle) {
- {{ subtitle }} + + @for (fragment of subtitleFragments; track $index) { + {{ fragment.text }} + } + @if (helper()) { } diff --git a/projects/ui/src/lib/components/po-page/po-page-header/po-page-header.component.spec.ts b/projects/ui/src/lib/components/po-page/po-page-header/po-page-header.component.spec.ts index 98861857f4..143f73b8eb 100644 --- a/projects/ui/src/lib/components/po-page/po-page-header/po-page-header.component.spec.ts +++ b/projects/ui/src/lib/components/po-page/po-page-header/po-page-header.component.spec.ts @@ -121,4 +121,73 @@ describe('PoPageHeaderComponent:', () => { expect(nativeElement.querySelector('po-helper')).toBeTruthy(); }); }); + + describe('Subtitle formatting:', () => { + it('should render subtitle with bold formatting when tag is used', () => { + component.title = 'Title'; + component.subtitle = 'Texto negrito normal'; + + fixture.detectChanges(); + + const boldSpan = nativeElement.querySelector('.po-page-header-subtitle .po-text-bold'); + expect(boldSpan).toBeTruthy(); + expect(boldSpan.textContent).toBe('negrito'); + }); + + it('should render subtitle with italic formatting when tag is used', () => { + component.title = 'Title'; + component.subtitle = 'Texto itálico normal'; + + fixture.detectChanges(); + + const italicSpan = nativeElement.querySelector('.po-page-header-subtitle .po-text-italic'); + expect(italicSpan).toBeTruthy(); + expect(italicSpan.textContent).toBe('itálico'); + }); + + it('should render subtitle with underline formatting when tag is used', () => { + component.title = 'Title'; + component.subtitle = 'Texto sublinhado normal'; + + fixture.detectChanges(); + + const underlineSpan = nativeElement.querySelector('.po-page-header-subtitle .po-text-underline'); + expect(underlineSpan).toBeTruthy(); + expect(underlineSpan.textContent).toBe('sublinhado'); + }); + + it('should render subtitle plain text without formatting classes', () => { + component.title = 'Title'; + component.subtitle = 'Texto simples'; + + fixture.detectChanges(); + + const subtitleText = nativeElement.querySelector('.po-page-header-subtitle-text'); + expect(subtitleText).toBeTruthy(); + expect(subtitleText.textContent).toBe('Texto simples'); + expect(nativeElement.querySelector('.po-text-bold')).toBeFalsy(); + expect(nativeElement.querySelector('.po-text-italic')).toBeFalsy(); + expect(nativeElement.querySelector('.po-text-underline')).toBeFalsy(); + }); + + it('should sanitize script tags in subtitle', () => { + component.title = 'Title'; + component.subtitle = 'seguro'; + + fixture.detectChanges(); + + const subtitleText = nativeElement.querySelector('.po-page-header-subtitle-text'); + expect(subtitleText.textContent).toBe('alert("xss")seguro'); + }); + + it('should render subtitle with combined formatting', () => { + component.title = 'Title'; + component.subtitle = 'negrito e itálico'; + + fixture.detectChanges(); + + expect(nativeElement.querySelector('.po-text-bold').textContent).toBe('negrito'); + expect(nativeElement.querySelector('.po-text-italic').textContent).toBe('itálico'); + }); + }); }); diff --git a/projects/ui/src/lib/components/po-page/po-page-list/po-page-list-base.component.ts b/projects/ui/src/lib/components/po-page/po-page-list/po-page-list-base.component.ts index 649a850eb5..d86279a79f 100644 --- a/projects/ui/src/lib/components/po-page/po-page-list/po-page-list-base.component.ts +++ b/projects/ui/src/lib/components/po-page/po-page-list/po-page-list-base.component.ts @@ -221,7 +221,17 @@ export abstract class PoPageListBaseComponent { * * @description * - * Subtitulo do Header da página + * Subtitulo do Header da página. + * + * Suporta formatação básica com as tags `` (negrito), `` (negrito), `` (itálico), `` (itálico) e + * `` (sublinhado). + * + * Exemplo: + * ```typescript + * subtitle = 'Manage active and pending processes'; + * ``` + * + * > Requer que `p-title` esteja definido. */ @Input('p-subtitle') subtitle: string; diff --git a/projects/ui/src/lib/components/po-page/po-page-list/samples/sample-po-page-list-hiring-processes/sample-po-page-list-hiring-processes.component.html b/projects/ui/src/lib/components/po-page/po-page-list/samples/sample-po-page-list-hiring-processes/sample-po-page-list-hiring-processes.component.html index 00a79c629f..3a924680b0 100644 --- a/projects/ui/src/lib/components/po-page/po-page-list/samples/sample-po-page-list-hiring-processes/sample-po-page-list-hiring-processes.component.html +++ b/projects/ui/src/lib/components/po-page/po-page-list/samples/sample-po-page-list-hiring-processes/sample-po-page-list-hiring-processes.component.html @@ -1,6 +1,7 @@ { - describe('parseHelperContent', () => { +describe('parseSafeText', () => { + const allTags: Array = ['b', 'i', 'u', 'strong', 'em']; + + describe('with all tags allowed', () => { it('should return empty array for null/undefined/empty content', () => { - expect(parseHelperContent(null)).toEqual([]); - expect(parseHelperContent(undefined)).toEqual([]); - expect(parseHelperContent('')).toEqual([]); + expect(parseSafeText(null, allTags)).toEqual([]); + expect(parseSafeText(undefined, allTags)).toEqual([]); + expect(parseSafeText('', allTags)).toEqual([]); }); it('should return single fragment for plain text without tags', () => { - const result = parseHelperContent('Texto simples'); + const result = parseSafeText('Texto simples', allTags); expect(result).toEqual([{ text: 'Texto simples', bold: false, italic: false, underline: false }]); }); it('should parse bold tag correctly', () => { - const result = parseHelperContent('Texto negrito normal'); + const result = parseSafeText('Texto negrito normal', allTags); expect(result).toEqual([ { text: 'Texto ', bold: false, italic: false, underline: false }, { text: 'negrito', bold: true, italic: false, underline: false }, @@ -23,7 +25,7 @@ describe('PoHelperSafeParser', () => { }); it('should parse italic tag correctly', () => { - const result = parseHelperContent('Texto itálico normal'); + const result = parseSafeText('Texto itálico normal', allTags); expect(result).toEqual([ { text: 'Texto ', bold: false, italic: false, underline: false }, { text: 'itálico', bold: false, italic: true, underline: false }, @@ -32,7 +34,7 @@ describe('PoHelperSafeParser', () => { }); it('should parse underline tag correctly', () => { - const result = parseHelperContent('Texto sublinhado normal'); + const result = parseSafeText('Texto sublinhado normal', allTags); expect(result).toEqual([ { text: 'Texto ', bold: false, italic: false, underline: false }, { text: 'sublinhado', bold: false, italic: false, underline: true }, @@ -41,17 +43,17 @@ describe('PoHelperSafeParser', () => { }); it('should parse nested tags correctly', () => { - const result = parseHelperContent('negrito e itálico'); + const result = parseSafeText('negrito e itálico', allTags); expect(result).toEqual([{ text: 'negrito e itálico', bold: true, italic: true, underline: false }]); }); it('should parse multiple nested tags', () => { - const result = parseHelperContent('todos'); + const result = parseSafeText('todos', allTags); expect(result).toEqual([{ text: 'todos', bold: true, italic: true, underline: true }]); }); it('should handle mixed formatting in sequence', () => { - const result = parseHelperContent('negrito e itálico'); + const result = parseSafeText('negrito e itálico', allTags); expect(result).toEqual([ { text: 'negrito', bold: true, italic: false, underline: false }, { text: ' e ', bold: false, italic: false, underline: false }, @@ -60,27 +62,27 @@ describe('PoHelperSafeParser', () => { }); it('should sanitize disallowed tags (script)', () => { - const result = parseHelperContent('Texto seguro'); + const result = parseSafeText('Texto seguro', allTags); expect(result).toEqual([{ text: 'Texto alert("xss") seguro', bold: false, italic: false, underline: false }]); }); it('should sanitize disallowed tags (div, span, a)', () => { - const result = parseHelperContent('
conteúdo
texto link'); + const result = parseSafeText('
conteúdo
texto link', allTags); expect(result).toEqual([{ text: 'conteúdo texto link', bold: false, italic: false, underline: false }]); }); it('should sanitize tags with attributes', () => { - const result = parseHelperContent('negrito'); + const result = parseSafeText('negrito', allTags); expect(result).toEqual([{ text: 'negrito', bold: true, italic: false, underline: false }]); }); it('should handle unclosed tags gracefully', () => { - const result = parseHelperContent('negrito sem fechar'); + const result = parseSafeText('negrito sem fechar', allTags); expect(result).toEqual([{ text: 'negrito sem fechar', bold: true, italic: false, underline: false }]); }); it('should handle extra closing tags gracefully', () => { - const result = parseHelperContent('texto normal'); + const result = parseSafeText('texto normal', allTags); expect(result).toEqual([ { text: 'texto', bold: false, italic: false, underline: false }, { text: ' normal', bold: false, italic: false, underline: false } @@ -88,7 +90,7 @@ describe('PoHelperSafeParser', () => { }); it('should handle case-insensitive tags', () => { - const result = parseHelperContent('negrito itálico'); + const result = parseSafeText('negrito itálico', allTags); expect(result).toEqual([ { text: 'negrito', bold: true, italic: false, underline: false }, { text: ' ', bold: false, italic: false, underline: false }, @@ -97,17 +99,17 @@ describe('PoHelperSafeParser', () => { }); it('should prevent XSS via event handlers in allowed tags', () => { - const result = parseHelperContent('texto'); + const result = parseSafeText('texto', allTags); expect(result).toEqual([{ text: 'texto', bold: true, italic: false, underline: false }]); }); it('should prevent XSS via img tag with onerror', () => { - const result = parseHelperContent('texto'); + const result = parseSafeText('texto', allTags); expect(result).toEqual([{ text: 'texto', bold: false, italic: false, underline: false }]); }); it('should handle partially overlapping tags', () => { - const result = parseHelperContent('bold bold+italic italic'); + const result = parseSafeText('bold bold+italic italic', allTags); expect(result).toEqual([ { text: 'bold ', bold: true, italic: false, underline: false }, { text: 'bold+italic', bold: true, italic: true, underline: false }, @@ -116,7 +118,7 @@ describe('PoHelperSafeParser', () => { }); it('should parse as bold', () => { - const result = parseHelperContent('Texto importante normal'); + const result = parseSafeText('Texto importante normal', allTags); expect(result).toEqual([ { text: 'Texto ', bold: false, italic: false, underline: false }, { text: 'importante', bold: true, italic: false, underline: false }, @@ -125,23 +127,53 @@ describe('PoHelperSafeParser', () => { }); it('should parse as italic', () => { - const result = parseHelperContent('Texto enfatizado normal'); + const result = parseSafeText('Texto enfatizado normal', allTags); expect(result).toEqual([ { text: 'Texto ', bold: false, italic: false, underline: false }, { text: 'enfatizado', bold: false, italic: true, underline: false }, { text: ' normal', bold: false, italic: false, underline: false } ]); }); + }); + + describe('with restricted tags (only bold)', () => { + const boldOnly: Array = ['b', 'strong']; - it('should handle mixed , , , , together', () => { - const result = parseHelperContent('bold italic underline'); + it('should parse bold tags', () => { + const result = parseSafeText('Texto negrito normal', boldOnly); expect(result).toEqual([ - { text: 'bold', bold: true, italic: false, underline: false }, - { text: ' ', bold: false, italic: false, underline: false }, - { text: 'italic', bold: false, italic: true, underline: false }, - { text: ' ', bold: false, italic: false, underline: false }, - { text: 'underline', bold: false, italic: false, underline: true } + { text: 'Texto ', bold: false, italic: false, underline: false }, + { text: 'negrito', bold: true, italic: false, underline: false }, + { text: ' normal', bold: false, italic: false, underline: false } ]); }); + + it('should strip italic tags when not allowed', () => { + const result = parseSafeText('Texto itálico e negrito', boldOnly); + expect(result).toEqual([ + { text: 'Texto itálico e ', bold: false, italic: false, underline: false }, + { text: 'negrito', bold: true, italic: false, underline: false } + ]); + }); + + it('should strip underline tags when not allowed', () => { + const result = parseSafeText('sublinhado e forte', boldOnly); + expect(result).toEqual([ + { text: 'sublinhado e ', bold: false, italic: false, underline: false }, + { text: 'forte', bold: true, italic: false, underline: false } + ]); + }); + + it('should strip em tags when not allowed', () => { + const result = parseSafeText('enfatizado normal', boldOnly); + expect(result).toEqual([{ text: 'enfatizado normal', bold: false, italic: false, underline: false }]); + }); + }); + + describe('with no tags allowed (empty array)', () => { + it('should strip all formatting tags and return plain text', () => { + const result = parseSafeText('negrito itálico sublinhado', []); + expect(result).toEqual([{ text: 'negrito itálico sublinhado', bold: false, italic: false, underline: false }]); + }); }); }); diff --git a/projects/ui/src/lib/utils/safe-text-parser.ts b/projects/ui/src/lib/utils/safe-text-parser.ts new file mode 100644 index 0000000000..9a7444d1bd --- /dev/null +++ b/projects/ui/src/lib/utils/safe-text-parser.ts @@ -0,0 +1,81 @@ +/** + * Parser seguro de tags de formatação (``, ``, ``, ``, ``). + * Não utiliza `innerHTML`. Tags fora da whitelist são removidas (proteção XSS). + */ + +/** Fragmento de texto com flags de formatação. */ +export interface PoTextFragment { + text: string; + bold: boolean; + italic: boolean; + underline: boolean; +} + +/** Tags de formatação suportadas. */ +export type PoFormattingTag = 'b' | 'i' | 'u' | 'strong' | 'em'; + +const TAG_NORMALIZE_MAP: Record = { strong: 'b', em: 'i' }; +const TAG_REGEX = /<(\/?)(\w+)(?:\s[^>]*)?\/?>/gi; + +interface TagCounters { + b: number; + i: number; + u: number; +} + +function sanitizeContent(input: string, allowedTags: Array): string { + return input.replace(TAG_REGEX, (_match, closing, tagName) => { + const normalized = tagName.toLowerCase(); + if (allowedTags.includes(normalized)) { + const output = TAG_NORMALIZE_MAP[normalized] || normalized; + return closing ? `` : `<${output}>`; + } + return ''; + }); +} + +/** + * Faz o parsing seguro de uma string com tags de formatação. + * + * O consumidor declara quais tags aceita via `allowedTags` (obrigatório). + * Tags não listadas são removidas. Tags aninhadas são suportadas. + * + * @param content String com possíveis tags de formatação. + * @param allowedTags Tags permitidas pelo consumidor. + */ +export function parseSafeText(content: string, allowedTags: Array): Array { + if (!content) { + return []; + } + + const sanitized = sanitizeContent(content, allowedTags); + const fragments: Array = []; + const counters: TagCounters = { b: 0, i: 0, u: 0 }; + const parts = sanitized.split(/(<\/?[biu]>)/gi); + + for (const part of parts) { + if (!part) { + continue; + } + + const tagMatch = /^<(\/?)([biu])>$/i.exec(part); + + if (tagMatch) { + const tag = tagMatch[2].toLowerCase(); + if (tagMatch[1] === '/') { + counters[tag] = Math.max(0, counters[tag] - 1); + } else { + counters[tag]++; + } + } else { + fragments.push({ + text: part, + bold: counters.b > 0, + italic: counters.i > 0, + underline: counters.u > 0 + }); + } + } + + return fragments; +}