From 03b0b51fd788403eda6e513a2ddfe6c9599bb1f2 Mon Sep 17 00:00:00 2001 From: chengpeiquan Date: Sun, 8 Mar 2026 20:45:34 +0800 Subject: [PATCH 1/9] feat(Truncate): add opt-in preserveMarkup rendering --- e2e/app/App.tsx | 57 +++++++ e2e/tests/components.spec.ts | 27 +++- eslint.config.js | 14 ++ src/Truncate/Truncate.tsx | 191 ++++++---------------- src/Truncate/engines/markup.tsx | 20 +++ src/Truncate/engines/plain-text.tsx | 166 +++++++++++++++++++ src/Truncate/markup/render.tsx | 237 ++++++++++++++++++++++++++++ src/Truncate/markup/snapshot.ts | 62 ++++++++ src/Truncate/{ => shared}/utils.tsx | 0 src/Truncate/types.ts | 10 ++ test/ShowMore.spec.tsx | 41 +++++ test/Truncate.spec.tsx | 171 +++++++++++++++++++- 12 files changed, 846 insertions(+), 150 deletions(-) create mode 100644 src/Truncate/engines/markup.tsx create mode 100644 src/Truncate/engines/plain-text.tsx create mode 100644 src/Truncate/markup/render.tsx create mode 100644 src/Truncate/markup/snapshot.ts rename src/Truncate/{ => shared}/utils.tsx (100%) diff --git a/e2e/app/App.tsx b/e2e/app/App.tsx index f776979..10cbd1c 100644 --- a/e2e/app/App.tsx +++ b/e2e/app/App.tsx @@ -8,6 +8,34 @@ const SHOW_MORE_TEXT = const FILE_NAME = 'Quarterly-operating-report-final-reviewed-version-2026.pdf' const RESIZE_TEXT = 'Resizing the container should force truncation to recalculate and produce a shorter visible result in the narrow state.' +const INLINE_CHINESE_RICH_TEXT = ( + <> + 从前有座山,山上有座庙,庙里有个老和尚,老和尚一边讲故事,一边指向 + + 文档链接 + + ,还强调这是 + + 重点样式文本 + + 。故事继续讲下去:从前有座山,山上有座庙,庙里有个老和尚,老和尚又提到了 + + 更多内容 + + ,然后继续讲从前有座山、山上有座庙、庙里有个老和尚的故事,让这段内容足够长,以便稳定触发裁剪并比较 + preserveMarkup 开关前后的折叠高度。 + +) const sectionStyle: React.CSSProperties = { display: 'grid', @@ -112,6 +140,35 @@ export const App: React.FC = () => { {RESIZE_TEXT} + +
+

Chinese preserve markup

+
+
+ + {INLINE_CHINESE_RICH_TEXT} + +
+ +
+ + {INLINE_CHINESE_RICH_TEXT} + +
+
+
) } diff --git a/e2e/tests/components.spec.ts b/e2e/tests/components.spec.ts index f81d54b..bf4ac1e 100644 --- a/e2e/tests/components.spec.ts +++ b/e2e/tests/components.spec.ts @@ -21,16 +21,19 @@ test('show-more expands and collapses', async ({ page }) => { await expect(page.getByTestId('show-more-state')).toHaveText('collapsed') const collapsedText = await page.getByTestId('show-more-example').innerText() + const showMoreExample = page.getByTestId('show-more-example') - await page.getByRole('link', { name: 'Expand' }).click() + await showMoreExample.getByRole('link', { name: 'Expand' }).click() await expect(page.getByTestId('show-more-state')).toHaveText('expanded') - await expect(page.getByRole('link', { name: 'Collapse' })).toBeVisible() + await expect( + showMoreExample.getByRole('link', { name: 'Collapse' }), + ).toBeVisible() const expandedText = await page.getByTestId('show-more-example').innerText() expect(expandedText.length).toBeGreaterThan(collapsedText.length) - await page.getByRole('link', { name: 'Collapse' }).click() + await showMoreExample.getByRole('link', { name: 'Collapse' }).click() await expect(page.getByTestId('show-more-state')).toHaveText('collapsed') }) @@ -66,3 +69,21 @@ test('truncate recalculates after resize controls change width', async ({ await page.getByRole('button', { name: 'Set wide' }).click() await expect(page.getByTestId('resize-width')).toHaveText('240') }) + +test('zh show-more preserveMarkup should not add an extra collapsed line', async ({ + page, +}) => { + await page.goto('/') + + const plain = page.getByTestId('zh-show-more-plain') + const markup = page.getByTestId('zh-show-more-markup') + + const plainHeight = await plain.evaluate( + (node) => node.getBoundingClientRect().height, + ) + const markupHeight = await markup.evaluate( + (node) => node.getBoundingClientRect().height, + ) + + expect(markupHeight).toBe(plainHeight) +}) diff --git a/eslint.config.js b/eslint.config.js index a4362cb..d7ffd2e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -32,4 +32,18 @@ export default defineFlatConfig([ name: getConfigName('ignore'), ignores: ['**/dist/**', '**/lib/**', '**/legacy/**'], }, + + { + name: getConfigName('test'), + files: [ + 'test/**/*.{js,mjs,ts,tsx}', + 'e2e/**/*.{js,mjs,ts,tsx}', + 'smoke/**/*.{js,mjs,ts,tsx,html}', + 'playwright.config.ts', + 'smoke/playwright.config.ts', + ], + rules: { + 'tailwindcss/no-custom-classname': 'off', + }, + }, ]) diff --git a/src/Truncate/Truncate.tsx b/src/Truncate/Truncate.tsx index ceb7ffc..ea3fbea 100644 --- a/src/Truncate/Truncate.tsx +++ b/src/Truncate/Truncate.tsx @@ -1,12 +1,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { getMarkupTruncation } from './engines/markup' +import { getPlainTextTruncation } from './engines/plain-text' +import { renderLine } from './shared/utils' import { type TruncateProps } from './types' -import { - getEllipsisWidth, - getMiddleTruncateFragments, - innerText, - renderLine, - trimRight, -} from './utils' export const Truncate: React.FC = ({ children, @@ -17,6 +13,7 @@ export const Truncate: React.FC = ({ separator = ' ', middle: middleTruncate = false, end: initialEnd = 5, + preserveMarkup = false, onTruncate, ...spanProps }) => { @@ -39,20 +36,16 @@ export const Truncate: React.FC = ({ }, [targetWidth]) const calcTWidth = useCallback((): number | undefined => { - // Calculation is no longer relevant, since node has been removed if (!targetRef.current?.parentElement) { return } const newTargetWidth = width || - // Floor the result to deal with browser subpixel precision Math.floor(targetRef.current.parentElement.getBoundingClientRect().width) - // Delay calculation until parent node is inserted to the document - // Mounting order in React is ChildComponent, ParentComponent if (!newTargetWidth) { - return window.requestAnimationFrame(() => calcTWidth(/* callback */)) + return window.requestAnimationFrame(() => calcTWidth()) } const style = window.getComputedStyle(targetRef.current) @@ -83,7 +76,6 @@ export const Truncate: React.FC = ({ return () => { window.removeEventListener('resize', calcTWidth) - window.cancelAnimationFrame(animationFrame) } }, [calcTWidth, animationFrame]) @@ -106,7 +98,6 @@ export const Truncate: React.FC = ({ [canvasContext], ) - // Adds stricter integer validation and resets invalid values to default value const defaultLines = useMemo(() => { if (!Number.isSafeInteger(initialLines) || initialLines < 0) return 0 return initialLines @@ -117,159 +108,67 @@ export const Truncate: React.FC = ({ [defaultLines, middleTruncate], ) - // Make sure the end position is negative or 0 const end = useMemo(() => { const absVal = Math.abs(initialEnd) const val = Number.isFinite(absVal) ? Math.floor(absVal) : 0 return val > 0 ? -val : val }, [initialEnd]) - const getLines = useCallback(() => { - const resultLines: Array = [] - const fullText = innerText(textRef.current, separator) - const textLines = fullText.split('\n').map((line) => line.split(separator)) - const ellipsisWidth = getEllipsisWidth(ellipsisRef.current) || 0 - - let didTruncate = true - - for (let line = 1; line <= lines; line++) { - const textWords = textLines[0] - - // Handle newline - if (textWords.length === 0) { - resultLines.push() - textLines.shift() - line-- - continue - } - - let resultLine: string | React.JSX.Element = - textWords.join(separator) || '' - if (measureWidth(resultLine) <= targetWidth) { - if (textLines.length === 1) { - // Line is end of text and fits without truncating - didTruncate = false - resultLines.push(resultLine) - break - } - } - - if (line === lines) { - // Binary search determining the longest possible line including truncate string - const textRest = textWords.join(separator) - - let lower = 0 - let upper = textRest.length - 1 - - while (lower <= upper) { - const middle = Math.floor((lower + upper) / 2) - const testLine = textRest.slice(0, middle + 1) + useEffect(() => { + const mounted = !!(targetRef.current && targetWidth) - if (measureWidth(testLine) + ellipsisWidth <= targetWidth) { - lower = middle + 1 + if (typeof window !== 'undefined' && mounted) { + if (lines > 0) { + const plainTextResult = getPlainTextTruncation({ + ellipsis, + ellipsisRef: ellipsisRef.current, + end, + lines, + measureWidth, + middleTruncate, + separator, + targetWidth, + textRef: textRef.current, + trimWhitespace, + }) + + if (preserveMarkup && !middleTruncate) { + if (plainTextResult.didTruncate) { + setRenderTextRef( + getMarkupTruncation({ + ellipsis, + node: textRef.current, + separator, + visibleTextLines: plainTextResult.visibleTextLines, + }), + ) } else { - upper = middle - 1 + setRenderTextRef(children) } - } - - let lastLineText = textRest.slice(0, lower) - - if (trimWhitespace) { - lastLineText = trimRight(lastLineText) - - // Remove blank lines from the end of text - while (!lastLineText.length && resultLines.length) { - const prevLine = resultLines.pop() - - if (prevLine && typeof prevLine === 'string') - lastLineText = trimRight(prevLine) - } - } - - if (middleTruncate && end !== 0) { - const { startFragment, endFragment } = getMiddleTruncateFragments({ - end, - lastLineText, - fullText, - targetWidth, - ellipsisWidth, - measureWidth, - }) - - resultLine = ( - - {startFragment} - {ellipsis} - {endFragment} - - ) } else { - resultLine = ( - - {lastLineText} - {ellipsis} - - ) - } - } else { - // Binary search determining when the line breaks - let lower = 0 - let upper = textWords.length - 1 - - while (lower <= upper) { - const middle = Math.floor((lower + upper) / 2) - - const testLine = textWords.slice(0, middle + 1).join(separator) - - if (measureWidth(testLine) <= targetWidth) { - lower = middle + 1 - } else { - upper = middle - 1 - } - } - - // The first word of this line is too long to fit it - if (lower === 0) { - // Jump to processing of last line - line = lines - 1 - continue + setRenderTextRef(plainTextResult.resultLines.map(renderLine)) } - resultLine = textWords.slice(0, lower).join(separator) - textLines[0].splice(0, lower) + truncate(plainTextResult.didTruncate) + } else { + setRenderTextRef(children) + truncate(false) } - - resultLines.push(resultLine) } - - truncate(didTruncate) - - return resultLines }, [ - separator, - truncate, + children, + ellipsis, + end, lines, measureWidth, + middleTruncate, + preserveMarkup, + separator, targetWidth, trimWhitespace, - end, - middleTruncate, - ellipsis, + truncate, ]) - useEffect(() => { - const mounted = !!(targetRef.current && targetWidth) - - if (typeof window !== 'undefined' && mounted) { - if (lines > 0) { - setRenderTextRef(getLines().map(renderLine)) - } else { - setRenderTextRef(children) - truncate(false) - } - } - }, [children, lines, targetWidth, getLines, truncate]) - return ( {renderTextRef} diff --git a/src/Truncate/engines/markup.tsx b/src/Truncate/engines/markup.tsx new file mode 100644 index 0000000..d2edfa0 --- /dev/null +++ b/src/Truncate/engines/markup.tsx @@ -0,0 +1,20 @@ +import { renderMarkupPrefix } from '../markup/render' +import { createMarkupSnapshot } from '../markup/snapshot' +import type React from 'react' + +interface MarkupTruncationOptions { + ellipsis: React.ReactNode + node: HTMLSpanElement | null + separator: string + visibleTextLines: string[] +} + +export const getMarkupTruncation = ({ + ellipsis, + node, + separator, + visibleTextLines, +}: MarkupTruncationOptions) => { + const snapshot = createMarkupSnapshot(node, separator) + return renderMarkupPrefix(snapshot, visibleTextLines, ellipsis) +} diff --git a/src/Truncate/engines/plain-text.tsx b/src/Truncate/engines/plain-text.tsx new file mode 100644 index 0000000..731ee17 --- /dev/null +++ b/src/Truncate/engines/plain-text.tsx @@ -0,0 +1,166 @@ +import React from 'react' +import { + getEllipsisWidth, + getMiddleTruncateFragments, + innerText, + trimRight, +} from '../shared/utils' + +export interface PlainTextTruncationOptions { + ellipsis: React.ReactNode + ellipsisRef: HTMLSpanElement | null + end: number + lines: number + measureWidth: (textVal: string) => number + middleTruncate: boolean + separator: string + targetWidth: number + textRef: HTMLSpanElement | null + trimWhitespace: boolean +} + +export interface PlainTextTruncationResult { + didTruncate: boolean + resultLines: Array + visibleText: string + visibleTextLines: string[] +} + +export const getPlainTextTruncation = ({ + ellipsis, + ellipsisRef, + end, + lines, + measureWidth, + middleTruncate, + separator, + targetWidth, + textRef, + trimWhitespace, +}: PlainTextTruncationOptions): PlainTextTruncationResult => { + const resultLines: Array = [] + const visibleTextLines: string[] = [] + const fullText = innerText(textRef, separator) + const textLines = fullText.split('\n').map((line) => line.split(separator)) + const ellipsisWidth = getEllipsisWidth(ellipsisRef) || 0 + + let didTruncate = true + + for (let line = 1; line <= lines; line++) { + const textWords = textLines[0] + + if (textWords.length === 0) { + resultLines.push() + visibleTextLines.push('') + textLines.shift() + line-- + continue + } + + let resultLine: string | React.JSX.Element = textWords.join(separator) || '' + let visibleLine = typeof resultLine === 'string' ? resultLine : '' + + if (measureWidth(visibleLine) <= targetWidth) { + if (textLines.length === 1) { + didTruncate = false + resultLines.push(resultLine) + visibleTextLines.push(visibleLine) + break + } + } + + if (line === lines) { + const textRest = textWords.join(separator) + + let lower = 0 + let upper = textRest.length - 1 + + while (lower <= upper) { + const middle = Math.floor((lower + upper) / 2) + const testLine = textRest.slice(0, middle + 1) + + if (measureWidth(testLine) + ellipsisWidth <= targetWidth) { + lower = middle + 1 + } else { + upper = middle - 1 + } + } + + let lastLineText = textRest.slice(0, lower) + + if (trimWhitespace) { + lastLineText = trimRight(lastLineText) + + while (!lastLineText.length && resultLines.length) { + const prevLine = resultLines.pop() + visibleTextLines.pop() + + if (prevLine && typeof prevLine === 'string') { + lastLineText = trimRight(prevLine) + } + } + } + + if (middleTruncate && end !== 0) { + const { startFragment, endFragment } = getMiddleTruncateFragments({ + end, + lastLineText, + fullText, + targetWidth, + ellipsisWidth, + measureWidth, + }) + + visibleLine = `${startFragment}${endFragment}` + resultLine = ( + + {startFragment} + {ellipsis} + {endFragment} + + ) + } else { + visibleLine = lastLineText + resultLine = ( + + {lastLineText} + {ellipsis} + + ) + } + } else { + let lower = 0 + let upper = textWords.length - 1 + + while (lower <= upper) { + const middle = Math.floor((lower + upper) / 2) + const testLine = textWords.slice(0, middle + 1).join(separator) + + if (measureWidth(testLine) <= targetWidth) { + lower = middle + 1 + } else { + upper = middle - 1 + } + } + + if (lower === 0) { + line = lines - 1 + continue + } + + visibleLine = textWords.slice(0, lower).join(separator) + resultLine = visibleLine + textLines[0].splice(0, lower) + } + + resultLines.push(resultLine) + visibleTextLines.push(visibleLine) + } + + return { + didTruncate, + resultLines, + visibleText: visibleTextLines.join('\n'), + visibleTextLines, + } +} diff --git a/src/Truncate/markup/render.tsx b/src/Truncate/markup/render.tsx new file mode 100644 index 0000000..1bdb711 --- /dev/null +++ b/src/Truncate/markup/render.tsx @@ -0,0 +1,237 @@ +import React from 'react' +import { type MarkupSnapshotNode } from './snapshot' + +const normalizeAttributeName = (name: string) => { + if (name === 'class') return 'className' + return name +} + +const parseStyleAttribute = (styleText: string) => { + return styleText + .split(';') + .map((declaration) => declaration.trim()) + .filter(Boolean) + .reduce>((styles, declaration) => { + const [rawProperty, ...rawValueParts] = declaration.split(':') + const property = rawProperty?.trim() + const value = rawValueParts.join(':').trim() + + if (!property || !value) return styles + + const reactProperty = property.replace(/-([a-z])/g, (_, char: string) => { + return char.toUpperCase() + }) + + styles[reactProperty] = value + return styles + }, {}) +} + +const normalizeAttributes = (attributes: Record) => { + return Object.entries(attributes).reduce>( + (result, [name, value]) => { + const normalizedName = normalizeAttributeName(name) + result[normalizedName] = + normalizedName === 'style' ? parseStyleAttribute(value) : value + return result + }, + {}, + ) +} + +const renderSnapshotNodes = ( + nodes: MarkupSnapshotNode[], + keyPrefix = 'markup', +): React.ReactNode[] => { + return nodes.flatMap((node, index) => { + const key = `${keyPrefix}-${index}` + + if (node.type === 'text') { + return node.text ? [node.text] : [] + } + + if (node.type === 'line-break') { + return [
] + } + + const children = renderSnapshotNodes(node.children, `${key}-child`) + + if (children.length === 0) { + return [] + } + + return [ + React.createElement( + node.tagName, + { + key, + ...normalizeAttributes(node.attributes), + }, + ...children, + ), + ] + }) +} + +interface SliceResult { + remaining: number + rest: MarkupSnapshotNode[] + taken: MarkupSnapshotNode[] +} + +const sliceSnapshotNode = ( + node: MarkupSnapshotNode, + remaining: number, +): SliceResult => { + if (remaining <= 0) { + return { + taken: [], + rest: [node], + remaining, + } + } + + if (node.type === 'text') { + if (node.text.length <= remaining) { + return { + taken: [node], + rest: [], + remaining: remaining - node.text.length, + } + } + + return { + taken: [{ ...node, text: node.text.slice(0, remaining) }], + rest: [{ ...node, text: node.text.slice(remaining) }], + remaining: 0, + } + } + + if (node.type === 'line-break') { + if (remaining >= 1) { + return { + taken: [node], + rest: [], + remaining: remaining - 1, + } + } + + return { + taken: [], + rest: [node], + remaining, + } + } + + const childResult = sliceSnapshotNodes(node.children, remaining) + + const taken = childResult.taken.length + ? [{ ...node, children: childResult.taken }] + : [] + const rest = childResult.rest.length + ? [{ ...node, children: childResult.rest }] + : [] + + return { + taken, + rest, + remaining: childResult.remaining, + } +} + +const sliceSnapshotNodes = ( + nodes: MarkupSnapshotNode[], + remaining: number, +): SliceResult => { + const taken: MarkupSnapshotNode[] = [] + const rest: MarkupSnapshotNode[] = [] + + let nextRemaining = remaining + + nodes.forEach((node) => { + if (nextRemaining <= 0) { + rest.push(node) + return + } + + const result = sliceSnapshotNode(node, nextRemaining) + + taken.push(...result.taken) + rest.push(...result.rest) + nextRemaining = result.remaining + }) + + return { + taken, + rest, + remaining: nextRemaining, + } +} + +const trimLeadingWhitespace = ( + nodes: MarkupSnapshotNode[], +): MarkupSnapshotNode[] => { + if (nodes.length === 0) return nodes + + const [firstNode, ...restNodes] = nodes + + if (firstNode.type === 'text') { + const trimmedText = firstNode.text.replace(/^\s+/, '') + + if (!trimmedText) { + return trimLeadingWhitespace(restNodes) + } + + return [{ ...firstNode, text: trimmedText }, ...restNodes] + } + + if (firstNode.type === 'element') { + const trimmedChildren = trimLeadingWhitespace(firstNode.children) + + if (trimmedChildren.length === 0) { + return trimLeadingWhitespace(restNodes) + } + + return [{ ...firstNode, children: trimmedChildren }, ...restNodes] + } + + return nodes +} + +export const renderMarkupPrefix = ( + nodes: MarkupSnapshotNode[], + visibleTextLines: string[], + ellipsis: React.ReactNode, +) => { + let remainingNodes = nodes + + return visibleTextLines.map((line, index) => { + const result = sliceSnapshotNodes(remainingNodes, line.length) + remainingNodes = trimLeadingWhitespace(result.rest) + + const renderedLine = renderSnapshotNodes( + result.taken, + `markup-line-${index}`, + ) + + if (index === visibleTextLines.length - 1) { + return ( + + {renderedLine} + {ellipsis} + + ) + } + + if (renderedLine.length === 0) { + return
+ } + + return ( + + {renderedLine} +
+
+ ) + }) +} diff --git a/src/Truncate/markup/snapshot.ts b/src/Truncate/markup/snapshot.ts new file mode 100644 index 0000000..c927ad7 --- /dev/null +++ b/src/Truncate/markup/snapshot.ts @@ -0,0 +1,62 @@ +export type MarkupSnapshotNode = + | { + text: string + type: 'text' + } + | { + type: 'line-break' + } + | { + attributes: Record + children: MarkupSnapshotNode[] + tagName: string + type: 'element' + } + +const normalizeText = (text: string, separator: string) => { + return text.replace(/\r\n|\r|\n/g, separator) +} + +const getAttributes = (element: Element) => { + return Array.from(element.attributes).reduce>( + (result, attribute) => { + result[attribute.name] = attribute.value + return result + }, + {}, + ) +} + +export const createMarkupSnapshot = ( + node: Node | null, + separator: string, +): MarkupSnapshotNode[] => { + if (!node) return [] + + return Array.from(node.childNodes).flatMap( + (childNode) => { + if (childNode instanceof HTMLBRElement) { + return [{ type: 'line-break' }] + } + + if (childNode.nodeType === Node.TEXT_NODE) { + const text = normalizeText(childNode.textContent || '', separator) + return text ? [{ type: 'text', text }] : [] + } + + if (childNode.nodeType === Node.ELEMENT_NODE) { + const element = childNode as Element + return [ + { + type: 'element', + tagName: element.tagName.toLowerCase(), + attributes: getAttributes(element), + children: createMarkupSnapshot(element, separator), + }, + ] + } + + return [] + }, + ) +} diff --git a/src/Truncate/utils.tsx b/src/Truncate/shared/utils.tsx similarity index 100% rename from src/Truncate/utils.tsx rename to src/Truncate/shared/utils.tsx diff --git a/src/Truncate/types.ts b/src/Truncate/types.ts index 9b3fdd7..bddede7 100644 --- a/src/Truncate/types.ts +++ b/src/Truncate/types.ts @@ -80,6 +80,16 @@ export interface TruncateProps extends DetailedHTMLProps { */ middle?: boolean + /** + * Preserve rendered inline markup in collapsed output when possible + * + * This option is opt-in because it performs more work than the default + * plain-text truncation path. + * + * @default false + */ + preserveMarkup?: boolean + /** * Number of characters to keep from the end of the text * diff --git a/test/ShowMore.spec.tsx b/test/ShowMore.spec.tsx index 46c60c4..143aa0a 100644 --- a/test/ShowMore.spec.tsx +++ b/test/ShowMore.spec.tsx @@ -99,6 +99,47 @@ describe('', () => { }) }) + it('should keep default collapsed rich content behavior without preserveMarkup', async () => { + const { container } = render( + + Hello{' '} + + link + {' '} + world and more + , + ) + + await waitFor(() => { + expect(getRootInnerText()).toBe(`Hello li${expandText}`) + }) + + expect(container.querySelector('a[href="/docs"]')).toBeNull() + }) + + it('should preserve inline markup in collapsed output when preserveMarkup is enabled', async () => { + const { container } = render( + + Hello{' '} + + link + {' '} + world and more + , + ) + + await waitFor(() => { + expect(getRootInnerText()).toBe(`Hello li${expandText}`) + }) + + const link = container.querySelector('a[href="/docs"]') + expect(link).toBeInTheDocument() + expect(link).toHaveClass('rich-link') + + const styledSpan = link?.querySelector('span') + expect(styledSpan).toHaveStyle({ color: 'red' }) + }) + describe('when toggle buttons clicked', () => { const message = 'This text should stop at here and not contain the next lines' diff --git a/test/Truncate.spec.tsx b/test/Truncate.spec.tsx index d3ca716..27cace2 100644 --- a/test/Truncate.spec.tsx +++ b/test/Truncate.spec.tsx @@ -5,12 +5,13 @@ import { renderToString } from 'react-dom/server' import ReactIs from 'react-is' import sinon from 'sinon' import { Truncate, type TruncateProps } from '@/Truncate' +import { createMarkupSnapshot } from '@/Truncate/markup/snapshot' import { getEllipsisWidth, getMiddleTruncateFragments, innerText, trimRight, -} from '@/Truncate/utils' +} from '@/Truncate/shared/utils' import { ellipsis, getMultiLineText, @@ -184,6 +185,129 @@ describe('', () => { }) }) + it('should still collapse rich children to plain text by default', async () => { + const { container } = render( + + Hello{' '} + + link + {' '} + world and more + , + ) + + await waitFor(() => { + expect(getRootInnerText()).toBe(`Hello link worl${ellipsis}`) + }) + + expect(container.querySelector('a[href="/docs"]')).toBeNull() + }) + + it('should preserve inline markup in collapsed output when preserveMarkup is enabled', async () => { + const { container } = render( + + Hello{' '} + + link + {' '} + world and more + , + ) + + await waitFor(() => { + expect(getRootInnerText()).toBe(`Hello link worl${ellipsis}`) + }) + + const link = container.querySelector('a[href="/docs"]') + expect(link).toBeInTheDocument() + expect(link).toHaveClass('rich-link') + + const styledSpan = link?.querySelector('span') + expect(styledSpan).toHaveStyle({ color: 'red' }) + }) + + it('should append a React ellipsis after preserved markup when preserveMarkup is enabled', async () => { + render( + … more}> + Hello link world and more + , + ) + + await waitFor(() => { + expect(getRootInnerText()).toContain('… more') + }) + + const root = document.querySelector('[role="root"]') as HTMLElement + const ellipsisLink = root.querySelector('a[href="/more"]') + expect(ellipsisLink).toBeInTheDocument() + }) + + it('should keep the same collapsed line breaks as plain text when preserveMarkup is enabled', async () => { + const richContent = ( + <> + This is a long inline rich text demo with a{' '} + + linked words + {' '} + and some highlighted text that + keeps flowing so the browser needs to clamp it across multiple + lines. + + ) + + const { container, rerender } = render( + {richContent}, + ) + + let plainTextCollapsed = '' + + await waitFor(() => { + plainTextCollapsed = getRootInnerText() + expect(plainTextCollapsed).toContain('\n') + }) + + rerender( + + {richContent} + , + ) + + await waitFor(() => { + expect(getRootInnerText()).toBe(plainTextCollapsed) + }) + + const link = container.querySelector('a[href="/docs"]') + expect(link).toBeInTheDocument() + }) + + it('should keep original rich children when preserveMarkup is enabled but truncation is not needed', async () => { + const richContent = ( + <> + Hello{' '} + + docs + {' '} + and styled text + + ) + + const { container } = render( + + {richContent} + , + ) + + await waitFor(() => { + expect(getRootInnerText()).toContain('Hello docs and') + }) + + const link = container.querySelector('a[href="/docs"]') + expect(link).toBeInTheDocument() + + const styledSpan = link?.parentElement?.querySelector('span') + expect(styledSpan).toHaveStyle({ color: 'red' }) + }) + it('should render without an error when the last line is exactly as wide as the container', () => { expect(() => { render( @@ -544,6 +668,51 @@ describe('', () => { }) }) + describe('createMarkupSnapshot', () => { + it('should capture text nodes, nested inline elements, br and attributes', () => { + const root = document.createElement('span') + root.innerHTML = + 'Hello li
world' + + expect(createMarkupSnapshot(root, separator)).toEqual([ + { + type: 'text', + text: 'Hello ', + }, + { + type: 'element', + tagName: 'a', + attributes: { + href: '/docs', + class: 'rich-link', + }, + children: [ + { + type: 'element', + tagName: 'span', + attributes: { + style: 'color: red;', + }, + children: [ + { + type: 'text', + text: 'li', + }, + ], + }, + ], + }, + { + type: 'line-break', + }, + { + type: 'text', + text: 'world', + }, + ]) + }) + }) + describe('trimRight', () => { it('should remove whitespace from the end of text', () => { expect(trimRight('some spaces here ')).toBe('some spaces here') From d720b24f153a865947c280a90a1aa2f76b46b898 Mon Sep 17 00:00:00 2001 From: chengpeiquan Date: Sun, 8 Mar 2026 20:46:00 +0800 Subject: [PATCH 2/9] docs(ShowMore): add preserveMarkup demo and guidance --- README.md | 2 + .../2026-03-08-preserve-markup-design.md | 160 ++++++++++++++ ...26-03-08-preserve-markup-implementation.md | 209 ++++++++++++++++++ ...03-08-truncate-internal-layering-design.md | 190 ++++++++++++++++ ...uncate-internal-layering-implementation.md | 126 +++++++++++ docs/src/components/examples/Data.tsx | 54 +++++ .../show-more/ControllableShowMore.tsx | 31 ++- docs/src/content/docs/reference/show-more.mdx | 4 +- docs/src/content/docs/reference/truncate.mdx | 17 ++ .../content/docs/zh/reference/show-more.mdx | 4 +- .../content/docs/zh/reference/truncate.mdx | 17 ++ docs/src/i18n/index.ts | 2 + 12 files changed, 812 insertions(+), 4 deletions(-) create mode 100644 docs/plans/2026-03-08-preserve-markup-design.md create mode 100644 docs/plans/2026-03-08-preserve-markup-implementation.md create mode 100644 docs/plans/2026-03-08-truncate-internal-layering-design.md create mode 100644 docs/plans/2026-03-08-truncate-internal-layering-implementation.md diff --git a/README.md b/README.md index fb032e8..b45aa72 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ Provides `Truncate`, `MiddleTruncate` and `ShowMore` React components for truncating multi-line spans and adding an ellipsis. +When you need the collapsed state to keep rendered inline markup such as links, classes, or inline styles, enable the opt-in `preserveMarkup` prop on `Truncate` or `ShowMore`. The default path remains optimized for plain-text truncation. + ## Installation With npm(or yarn, or pnpm): diff --git a/docs/plans/2026-03-08-preserve-markup-design.md b/docs/plans/2026-03-08-preserve-markup-design.md new file mode 100644 index 0000000..055bef1 --- /dev/null +++ b/docs/plans/2026-03-08-preserve-markup-design.md @@ -0,0 +1,160 @@ +# Preserve Markup Truncation Design + +**Date:** 2026-03-08 + +**Problem Statement** + +`Truncate` currently flattens `children` into plain text before computing the collapsed result. This makes the collapsed state lose rendered markup semantics such as links, inline styles, classes, and DOM structure produced by components like `linkify-react`. As a result: + +- issue `#22` cannot keep clickable links in the collapsed state +- issue `#26` loses `Linkify` styling in `ShowMore` while collapsed +- `ShowMore` and `MiddleTruncate` inherit the same limitation because they are thin wrappers around `Truncate` + +## Goals + +- Preserve rendered inline markup in the collapsed state when explicitly requested +- Keep the existing default performance profile for plain-text users +- Make `ShowMore` inherit the capability without new public components +- Prepare `MiddleTruncate` for phased support instead of forcing full parity immediately + +## Non-Goals + +- Do not guarantee perfect preservation of arbitrary React component identity +- Do not guarantee block-level layout truncation in the first phase +- Do not change the default truncation behavior for existing users +- Do not introduce a parallel public component family such as `MarkupTruncate` + +## Public API Direction + +Keep the existing public components unchanged and add one opt-in prop on `Truncate`: + +```ts +interface TruncateProps { + preserveMarkup?: boolean +} +``` + +Recommended behavior: + +- `preserveMarkup` defaults to `false` +- `false` keeps the current plain-text truncation engine +- `true` enables a markup-preserving engine that works from rendered DOM output +- `ShowMore` transparently forwards `preserveMarkup` +- `MiddleTruncate` is phased separately and should not promise full support in the first release + +## Why Not a New Public Component + +Creating a public `MarkupTruncate` would split the API surface and quickly raise follow-up questions such as whether `MarkupShowMore` and `MarkupMiddleTruncate` are also required. Keeping a single public `Truncate` with an opt-in flag gives users one mental model, keeps docs smaller, and isolates the performance cost to users who need the feature. + +## Internal Architecture + +`Truncate` remains the only public entry point but internally routes between two engines: + +1. **Plain-text engine** + - current logic + - default path + - optimized for performance + +2. **Markup engine** + - enabled only when `preserveMarkup === true` + - works from rendered DOM structure instead of flattened text + +Recommended internal split: + +- **Measurement layer** + - container width + - ellipsis width + - font measurement +- **Markup snapshot layer** + - traverse the rendered hidden node + - capture text nodes, inline elements, and line breaks + - keep rendered DOM semantics rather than React component identity +- **Truncation layer** + - compute the maximum visible range within width and line limits + - support end truncation first + - reuse the same snapshot model for later middle truncation +- **Render layer** + - rebuild the collapsed React output from the truncated snapshot + - append the ellipsis node while preserving inline structure as much as possible + +## Supported Scope + +### Phase 1 + +`Truncate` and `ShowMore` support markup preservation for rendered inline content, including typical output such as: + +- text nodes +- `a` +- `span` +- `strong` +- `em` +- `code` +- inline classes and inline styles +- components like `linkify-react` that finally render standard inline DOM nodes + +### Phase 1 Limitations + +- no guarantee for block elements +- no guarantee for complex interactive custom component behavior in collapsed state +- no guarantee for React refs or internal component state preservation +- no guarantee for exact equivalence of deeply nested custom component trees + +### Phase 2 + +Extend the markup engine to support `middle` truncation for `MiddleTruncate`, starting with simple inline markup only. + +## Component Responsibilities + +### `Truncate` + +- owns measurement +- owns truncation strategy selection +- owns collapsed-state rendering +- defines the official support boundary for markup preservation + +### `ShowMore` + +- owns expand/collapse state only +- forwards `preserveMarkup` to `Truncate` +- does not implement any extra markup compatibility logic + +### `MiddleTruncate` + +- remains a semantic wrapper around `Truncate` +- should reuse the same snapshot model and rendering layer +- can lag one phase behind `Truncate` and `ShowMore` + +## Performance Strategy + +Markup preservation is more expensive than the current plain-text approach because it requires DOM traversal, snapshot creation, truncation against a richer model, and node reconstruction. To avoid regressing users who only need plain-text truncation: + +- keep the plain-text engine as the default path +- make markup preservation strictly opt-in via `preserveMarkup` +- memoize snapshot work where possible +- skip collapsed-state computation when `ShowMore` is expanded +- reuse existing width measurement primitives where possible + +## Migration and Compatibility + +- existing users see no behavior change unless they opt in +- existing tests for plain-text behavior should remain valid +- docs must explain that markup preservation is best-effort for rendered inline markup, not full arbitrary React component preservation + +## Recommended Rollout + +1. Refactor `Truncate` just enough to support dual engines +2. Ship end truncation markup support behind `preserveMarkup` +3. Forward the prop through `ShowMore` +4. Document support boundaries and performance trade-offs +5. Add `MiddleTruncate` markup support in a separate phase + +## Risks + +- attribute and child-node preservation bugs during reconstruction +- resize-triggered recomputation cost in markup-heavy lists +- awkward edge cases when truncation cuts through nested inline structures +- over-promising support for arbitrary custom components in docs + +## Recommendation + +Adopt a single public `Truncate` API with an opt-in `preserveMarkup` prop, backed by an internal dual-engine architecture. Deliver `Truncate` and `ShowMore` first, then extend `MiddleTruncate` in a second phase once the shared snapshot and rendering primitives are stable. diff --git a/docs/plans/2026-03-08-preserve-markup-implementation.md b/docs/plans/2026-03-08-preserve-markup-implementation.md new file mode 100644 index 0000000..4642cc4 --- /dev/null +++ b/docs/plans/2026-03-08-preserve-markup-implementation.md @@ -0,0 +1,209 @@ +# Preserve Markup Truncation Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add opt-in markup-preserving truncation to `Truncate`, forward it through `ShowMore`, and keep the existing plain-text path as the default behavior. + +**Architecture:** Keep `src/Truncate/Truncate.tsx` as the single public entry point, but split its internal work into a plain-text engine and a markup engine selected by `preserveMarkup`. Build the markup path around a rendered DOM snapshot model so collapsed output can preserve inline links, classes, and styles produced by children like `linkify-react`. + +**Tech Stack:** React, TypeScript, Vitest, Testing Library, Happy DOM + +--- + +### Task 1: Add failing API and regression tests for the default path + +**Files:** +- Modify: `src/Truncate/types.ts` +- Modify: `test/Truncate.spec.tsx` +- Modify: `test/ShowMore.spec.tsx` + +**Step 1: Write the failing test** + +Add tests that assert: + +- `Truncate` accepts `preserveMarkup` +- default behavior without `preserveMarkup` still renders collapsed plain text +- `ShowMore` without `preserveMarkup` keeps its current collapsed behavior + +**Step 2: Run test to verify the baseline** + +Run: `pnpm test:run test/Truncate.spec.tsx test/ShowMore.spec.tsx` + +Expected: the new type or behavior assertions fail before implementation changes are made. + +### Task 2: Extract the current plain-text engine from `Truncate` + +**Files:** +- Modify: `src/Truncate/Truncate.tsx` +- Create: `src/Truncate/plain-text.tsx` +- Modify: `src/Truncate/utils.tsx` +- Test: `test/Truncate.spec.tsx` + +**Step 1: Write the failing refactor-safety test** + +Add targeted tests for existing behaviors that must not change: + +- multi-line truncation +- custom `ellipsis` +- `trimWhitespace` +- `middle` mode for the existing plain-text path + +**Step 2: Run the targeted suite** + +Run: `pnpm test:run test/Truncate.spec.tsx` + +Expected: all new assertions describe current behavior and fail only if the refactor changes it. + +**Step 3: Move the plain-text logic** + +Extract the current `innerText`-based truncation flow from `src/Truncate/Truncate.tsx` into a dedicated internal module, keeping behavior unchanged. + +**Step 4: Verify the refactor** + +Run: `pnpm test:run test/Truncate.spec.tsx` + +Expected: all plain-text tests pass after the extraction. + +### Task 3: Add the markup snapshot model + +**Files:** +- Create: `src/Truncate/markup-snapshot.ts` +- Modify: `src/Truncate/utils.tsx` +- Test: `test/Truncate.spec.tsx` + +**Step 1: Write the failing unit tests** + +Add focused tests for a snapshot helper that walks rendered children and captures: + +- text nodes +- nested inline elements +- `br` +- inline class and style attributes on preserved elements + +**Step 2: Run the targeted suite** + +Run: `pnpm test:run test/Truncate.spec.tsx` + +Expected: snapshot helper tests fail because the helper does not exist yet. + +**Step 3: Implement the minimal snapshot layer** + +Create a serializable internal representation for rendered inline markup and a helper to build it from the hidden DOM node. + +**Step 4: Verify the helper** + +Run: `pnpm test:run test/Truncate.spec.tsx` + +Expected: snapshot tests pass and plain-text tests stay green. + +### Task 4: Implement markup-preserving end truncation in `Truncate` + +**Files:** +- Modify: `src/Truncate/Truncate.tsx` +- Create: `src/Truncate/markup-truncate.tsx` +- Create: `src/Truncate/render-markup.tsx` +- Modify: `src/Truncate/types.ts` +- Test: `test/Truncate.spec.tsx` + +**Step 1: Write the failing behavior tests** + +Add tests that render collapsed `Truncate preserveMarkup` content containing: + +- an anchor element that stays clickable in the DOM +- nested styled spans whose classes or inline styles remain present +- a ReactNode ellipsis appended after preserved markup + +**Step 2: Run the targeted suite** + +Run: `pnpm test:run test/Truncate.spec.tsx` + +Expected: the new `preserveMarkup` cases fail while plain-text tests still pass. + +**Step 3: Implement the markup engine** + +Route `Truncate` through a new markup path when `preserveMarkup` is true. Use the snapshot model to compute the visible range for end truncation and rebuild the collapsed output while preserving inline DOM semantics. + +**Step 4: Verify the behavior** + +Run: `pnpm test:run test/Truncate.spec.tsx` + +Expected: markup-preservation cases pass and the plain-text suite remains green. + +### Task 5: Forward `preserveMarkup` through `ShowMore` + +**Files:** +- Modify: `src/ShowMore/types.ts` +- Modify: `src/ShowMore/ShowMore.tsx` +- Test: `test/ShowMore.spec.tsx` + +**Step 1: Write the failing tests** + +Add tests that render `ShowMore preserveMarkup` with inline rich content and assert: + +- collapsed output preserves anchor elements and inline styles +- expanded output still renders the original children directly +- toggling between collapsed and expanded states keeps behavior stable + +**Step 2: Run the targeted suite** + +Run: `pnpm test:run test/ShowMore.spec.tsx` + +Expected: the `preserveMarkup` scenarios fail before the prop is forwarded. + +**Step 3: Implement the forwarding path** + +Ensure `ShowMore` passes the prop through to `Truncate` without adding its own markup-specific rendering rules. + +**Step 4: Verify the suite** + +Run: `pnpm test:run test/ShowMore.spec.tsx` + +Expected: `ShowMore` passes the new markup tests and existing toggle behavior tests. + +### Task 6: Document the new opt-in behavior + +**Files:** +- Modify: `README.md` +- Modify: `docs/src/content/docs/reference/truncate.mdx` +- Modify: `docs/src/content/docs/reference/show-more.mdx` +- Modify: `docs/src/content/docs/zh/reference/truncate.mdx` +- Modify: `docs/src/content/docs/zh/reference/show-more.mdx` + +**Step 1: Write the docs update** + +Document: + +- the new `preserveMarkup` prop +- default performance-first behavior +- supported inline markup scope +- known limitations for arbitrary custom components and block elements + +**Step 2: Verify the docs build path that matters** + +Run: `pnpm test:run test/Truncate.spec.tsx test/ShowMore.spec.tsx` + +Expected: code-facing validation remains green after the docs edits. + +### Task 7: Run focused verification and note the deferred scope + +**Files:** +- Test: `test/Truncate.spec.tsx` +- Test: `test/ShowMore.spec.tsx` +- Reference: `src/MiddleTruncate/MiddleTruncate.tsx` +- Reference: `test/MiddleTruncate.spec.tsx` + +**Step 1: Run focused tests** + +Run: `pnpm test:run test/Truncate.spec.tsx test/ShowMore.spec.tsx test/MiddleTruncate.spec.tsx` + +Expected: `Truncate` and `ShowMore` pass with the new feature, and `MiddleTruncate` remains unaffected by the phase-one change. + +**Step 2: Run broader verification** + +Run: `pnpm test:run` + +Expected: the unit test suite passes without regressions. If unrelated failures appear, record them separately instead of broadening the current feature scope. + +**Step 3: Record the deferred work** + +Add a short follow-up note in the implementation summary or release notes that `MiddleTruncate` markup preservation remains a separate second-phase task. diff --git a/docs/plans/2026-03-08-truncate-internal-layering-design.md b/docs/plans/2026-03-08-truncate-internal-layering-design.md new file mode 100644 index 0000000..bb5196e --- /dev/null +++ b/docs/plans/2026-03-08-truncate-internal-layering-design.md @@ -0,0 +1,190 @@ +# Truncate Internal Layering Design + +**Date:** 2026-03-08 + +**Scope** + +This design only reorganizes the internal structure of `src/Truncate/`. It does not expand scope into `ShowMore` or `MiddleTruncate`, because both are thin wrappers and do not need early abstraction pressure. + +## Problem Statement + +`Truncate` has evolved from a mostly plain-text truncation component into a dual-engine component: + +- default plain-text truncation path +- opt-in markup-preserving truncation path via `preserveMarkup` + +The new behavior is already separated by responsibility at the code level, but the files are still mostly flat under `src/Truncate/`. That makes it harder to understand which files are public API, which ones are engines, and which ones are markup-specific internals. + +## Goals + +- Keep the public API unchanged +- Improve discoverability inside `src/Truncate/` +- Make `Truncate.tsx` read like an orchestration layer, not a feature dump +- Separate plain-text and markup paths more clearly +- Create a directory structure that can support future phase-two work for markup-aware middle truncation + +## Non-Goals + +- No new public components +- No behavior changes +- No early restructuring of `ShowMore` or `MiddleTruncate` +- No additional abstraction such as hooks, factories, or contexts unless clearly needed later + +## Options Considered + +### Option A: Keep a flat directory and only rename files + +**Pros** +- Smallest possible diff +- Low movement cost + +**Cons** +- File count still grows in one place +- The difference between engine files, markup files, and shared helpers remains implicit +- Future `MiddleTruncate` markup support will likely make the flat structure noisy again + +**Decision** +- Rejected + +### Option B: Add light internal layering under `src/Truncate/` + +**Pros** +- Clearer mental model without over-engineering +- Keeps `Truncate.tsx` as a simple public entry point +- Gives markup-specific code a home without exposing it publicly +- Supports future reuse for phase-two markup work + +**Cons** +- Requires moving files and updating imports +- Slightly more initial churn than renaming only + +**Decision** +- Recommended + +### Option C: Fully feature-style nested module tree + +**Pros** +- Most formal long-term modularity + +**Cons** +- Too heavy for current project size +- Risks turning a cleanup into a framework-style re-architecture +- Adds indirection before it adds enough value + +**Decision** +- Rejected for now + +## Recommended Structure + +```text +src/Truncate/ + Truncate.tsx + index.ts + types.ts + engines/ + plain-text.tsx + markup.tsx + markup/ + snapshot.ts + render.tsx + shared/ + utils.tsx +``` + +## Responsibilities + +### `Truncate.tsx` + +- Remains the only public component implementation +- Owns prop parsing, measurement lifecycle, engine selection, and `onTruncate` +- Must not contain detailed snapshot or reconstruction logic + +### `engines/plain-text.tsx` + +- Owns plain-text truncation behavior +- Can depend on shared helpers +- Must not depend on markup internals + +### `engines/markup.tsx` + +- Owns markup-preserving truncation behavior +- Can depend on `markup/*` +- Must not own React lifecycle or DOM measurement concerns beyond its direct inputs + +### `markup/snapshot.ts` + +- Converts rendered DOM into an internal snapshot structure +- Knows about inline DOM semantics only +- Must not know about lines, `onTruncate`, or public component concerns + +### `markup/render.tsx` + +- Converts a truncated snapshot back into React output +- Handles attribute normalization needed for React rendering +- Must stay independent from engine selection logic + +### `shared/utils.tsx` + +- Contains only helpers shared across multiple paths +- Candidate contents: + - `innerText` + - `trimRight` + - `getEllipsisWidth` + - `getMiddleTruncateFragments` + - `renderLine` +- Must not depend on engines or markup modules + +## Dependency Rules + +Allowed direction: + +- `Truncate.tsx` -> `engines/*`, `shared/*`, `types.ts` +- `engines/plain-text.tsx` -> `shared/*` +- `engines/markup.tsx` -> `markup/*` +- `markup/*` -> local helpers only +- `shared/*` -> no upward dependencies + +Disallowed direction: + +- `shared/*` -> `engines/*` +- `shared/*` -> `markup/*` +- `markup/*` -> `engines/*` + +The rule is simple: dependencies should flow downward only. + +## Why This Is the Right Size + +This structure improves readability without over-designing: + +- public entry remains obvious +- engine split is visible +- markup-specific internals stop polluting the top-level directory +- future phase-two work has a natural home + +At the same time, it avoids premature patterns such as deeply nested modules, contexts, or hook-only decomposition. + +## Migration Strategy + +1. Move files into the new directories without changing behavior +2. Update imports only +3. Run focused tests for `Truncate`, `ShowMore`, and `MiddleTruncate` +4. Run full unit tests and lint +5. Stop after structure is stabilized + +## Risk Management + +Primary risks: + +- broken relative import paths +- minor lint issues from import ordering after file moves +- accidental behavior changes if movement and refactor are mixed together + +Mitigation: + +- keep the change as a pure structure move +- avoid opportunistic rewrites while relocating files +- rely on existing passing tests as regression coverage + +## Recommendation + +Proceed with a light internal layering refactor for `src/Truncate/` only. Keep the public API and behavior unchanged, move files into `engines/`, `markup/`, and `shared/`, and stop once the directory semantics are clear and tests are green. diff --git a/docs/plans/2026-03-08-truncate-internal-layering-implementation.md b/docs/plans/2026-03-08-truncate-internal-layering-implementation.md new file mode 100644 index 0000000..42fbb4c --- /dev/null +++ b/docs/plans/2026-03-08-truncate-internal-layering-implementation.md @@ -0,0 +1,126 @@ +# Truncate Internal Layering Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Reorganize `src/Truncate/` into a light internal directory structure without changing public API or runtime behavior. + +**Architecture:** Keep `src/Truncate/Truncate.tsx` as the single public component implementation, but move engine-specific logic into `engines/`, markup-specific internals into `markup/`, and reusable helpers into `shared/`. This is a structure-only refactor backed by existing tests. + +**Tech Stack:** React, TypeScript, Vitest, ESLint + +--- + +### Task 1: Move shared helpers into `shared/` + +**Files:** +- Create: `src/Truncate/shared/` +- Move: `src/Truncate/utils.tsx` -> `src/Truncate/shared/utils.tsx` +- Modify: imports that currently reference `src/Truncate/utils.tsx` +- Test: `test/Truncate.spec.tsx` + +**Step 1: Perform the move** + +Move `utils.tsx` into `shared/utils.tsx` without changing its contents. + +**Step 2: Update imports** + +Point all existing imports to the new shared path. + +**Step 3: Run focused verification** + +Run: `pnpm test:run test/Truncate.spec.tsx` + +Expected: `Truncate` tests still pass after the path move. + +### Task 2: Move engines into `engines/` + +**Files:** +- Create: `src/Truncate/engines/` +- Move: `src/Truncate/plain-text.tsx` -> `src/Truncate/engines/plain-text.tsx` +- Move: `src/Truncate/markup-truncate.tsx` -> `src/Truncate/engines/markup.tsx` +- Modify: `src/Truncate/Truncate.tsx` +- Test: `test/Truncate.spec.tsx` +- Test: `test/ShowMore.spec.tsx` + +**Step 1: Perform the moves** + +Move the plain-text and markup engine files into `engines/`. + +**Step 2: Update imports** + +Update `Truncate.tsx` and any tests or utilities that reference the old engine paths. + +**Step 3: Run focused verification** + +Run: `pnpm test:run test/Truncate.spec.tsx test/ShowMore.spec.tsx` + +Expected: both suites pass with no behavior changes. + +### Task 3: Move markup internals into `markup/` + +**Files:** +- Create: `src/Truncate/markup/` +- Move: `src/Truncate/markup-snapshot.ts` -> `src/Truncate/markup/snapshot.ts` +- Move: `src/Truncate/render-markup.tsx` -> `src/Truncate/markup/render.tsx` +- Modify: `src/Truncate/engines/markup.tsx` +- Modify: tests that import snapshot helpers directly +- Test: `test/Truncate.spec.tsx` + +**Step 1: Perform the moves** + +Move the snapshot and render helpers into the new `markup/` directory. + +**Step 2: Update imports** + +Update the markup engine and snapshot tests to use the new locations. + +**Step 3: Run focused verification** + +Run: `pnpm test:run test/Truncate.spec.tsx` + +Expected: snapshot-related tests and markup-preservation tests remain green. + +### Task 4: Clean up top-level `Truncate` exports and imports + +**Files:** +- Modify: `src/Truncate/Truncate.tsx` +- Modify: `src/Truncate/index.ts` +- Reference: `src/index.ts` + +**Step 1: Verify top-level API remains stable** + +Ensure public exports still expose the same component and public types only. + +**Step 2: Normalize local imports** + +Make sure the top-level `Truncate.tsx` now clearly reads as an orchestration file that imports from `engines/` and `shared/`. + +**Step 3: Run API-facing verification** + +Run: `pnpm test:run test/Truncate.spec.tsx test/ShowMore.spec.tsx test/MiddleTruncate.spec.tsx` + +Expected: all wrapper-facing behavior remains unchanged. + +### Task 5: Run full verification and stop + +**Files:** +- Reference: `src/Truncate/**` +- Test: `test/Truncate.spec.tsx` +- Test: `test/ShowMore.spec.tsx` +- Test: `test/MiddleTruncate.spec.tsx` + +**Step 1: Run lint** + +Run: `pnpm lint` + +Expected: import ordering and path updates are clean. + +**Step 2: Run full unit tests** + +Run: `pnpm test:run` + +Expected: all current unit tests pass with no behavior regressions. + +**Step 3: Stop after the move** + +Do not introduce extra abstraction beyond the agreed directory layering. If a new cleanup idea appears, capture it separately instead of extending this refactor. diff --git a/docs/src/components/examples/Data.tsx b/docs/src/components/examples/Data.tsx index f5f891c..2a06b68 100644 --- a/docs/src/components/examples/Data.tsx +++ b/docs/src/components/examples/Data.tsx @@ -159,6 +159,34 @@ export const StringText: React.FC = () => ( ) +export const InlineRichText: React.FC = () => ( + <> + This is a long inline rich text demo with a{' '} + + truncate.js.org + {' '} + link, some highlighted text, and + more narrative content that keeps flowing so the collapsed state reliably + reaches the line limit. This is a long inline rich text demo with another{' '} + + www.google.bg + {' '} + link, more styled words, and + enough extra content to keep the example comfortably truncated in the live + demo. + +) + export const ShorterStringText: React.FC = () => ( <> Lorem ipsum dolor sit amet, consectetur yahoo.com adipiscing elit, sed do @@ -207,6 +235,32 @@ export const ChineseStringText: React.FC = () => ( ) +export const InlineChineseRichText: React.FC = () => ( + <> + 从前有座山,山上有座庙,庙里有个老和尚,老和尚一边讲故事,一边指向{' '} + + truncate.js.org + + ,还强调这是 重点样式文本 + 。故事继续讲下去:从前有座山,山上有座庙,庙里有个老和尚,老和尚又提到了{' '} + + www.google.bg + + ,然后继续讲从前有座山、山上有座庙、庙里有个老和尚的故事,让这段内容足够长,以便在 + live demo 里稳定触发裁剪并观察 preserveMarkup 的差异。 + +) + export const ShorterChineseStringText: React.FC = () => ( <> 从前有座山,山上有座庙,庙里有个老和尚,老和尚在给小和尚讲故事,故事讲的是从前有座山,山上有座庙,庙里有个老和尚,老和尚在给小和尚讲故事,故事讲的是从前有座山,山上有座庙,庙里有个老和尚,老和尚在给小和尚讲故事,故事讲的是从前有座山,山上有座庙,庙里有个老和尚,老和尚在给小和尚讲故事,故事讲的是从前有座山,山上有座庙,庙里有个老和尚,老和尚在给小和尚讲故事,故事讲的是从前有座山,山上有座庙,庙里有个老和尚,老和尚在给小和尚讲故事,故事讲的是从前有座山,山上有座庙,庙里有个老和尚,老和尚在给小和尚讲故事,故事讲的是从前有座山,山上有座庙,庙里有个老和尚,老和尚在给小和尚讲故事。 diff --git a/docs/src/components/examples/show-more/ControllableShowMore.tsx b/docs/src/components/examples/show-more/ControllableShowMore.tsx index 824307d..20319b8 100644 --- a/docs/src/components/examples/show-more/ControllableShowMore.tsx +++ b/docs/src/components/examples/show-more/ControllableShowMore.tsx @@ -4,6 +4,10 @@ import { type ShowMoreToggleLinesFn, } from '@re-dev/react-truncate' import React, { useMemo, useRef, useState } from 'react' +import { + InlineChineseRichText, + InlineRichText, +} from '@/components/examples/Data' import { DEFAULT_CUSTOM_VALUE, DEFAULT_HTML_VALUE, @@ -42,6 +46,7 @@ export const ControllableShowMore: React.FC<{ const [lines, setLines] = useState(DEFAULT_LINES_VALUE) const [html, setHtml] = useState(DEFAULT_HTML_VALUE) const [custom, setCustom] = useState(DEFAULT_CUSTOM_VALUE) + const [preserveMarkup, setPreserveMarkup] = useState(false) const ref = useRef(null) @@ -49,7 +54,13 @@ export const ControllableShowMore: React.FC<{ ref.current?.toggleLines(e) } - const { refreshKey } = useRefreshKey([width, lines, html, custom]) + const { refreshKey } = useRefreshKey([ + width, + lines, + html, + custom, + preserveMarkup, + ]) return ( <> @@ -81,6 +92,13 @@ export const ControllableShowMore: React.FC<{ onChange={(v) => setHtml(v)} /> + setPreserveMarkup(v)} + /> + @@ -105,7 +124,15 @@ export const ControllableShowMore: React.FC<{ ) : undefined } > - + {html ? ( + isZh ? ( + + ) : ( + + ) + ) : ( + + )}
diff --git a/docs/src/content/docs/reference/show-more.mdx b/docs/src/content/docs/reference/show-more.mdx index cfd4831..39eac09 100644 --- a/docs/src/content/docs/reference/show-more.mdx +++ b/docs/src/content/docs/reference/show-more.mdx @@ -18,6 +18,8 @@ A "Show More" component, when the content exceeds the set number of display line Use it like a normal component, just pass the long text as the `children` prop. +`ShowMore` inherits the opt-in `preserveMarkup` prop from `Truncate`. Enable it when the collapsed state needs to keep rendered inline markup such as links or inline styles. + @@ -248,6 +250,6 @@ export type ShowMoreRef = { ## Live demo -Here is a basic example that could theoretically be suitable for any project. Adjust the slider to control how it displays in different situations. +Here is a basic example that could theoretically be suitable for any project. Adjust the controls to compare plain text, rich text, custom buttons, and preserveMarkup in different situations. diff --git a/docs/src/content/docs/reference/truncate.mdx b/docs/src/content/docs/reference/truncate.mdx index c6a5309..9d719b0 100644 --- a/docs/src/content/docs/reference/truncate.mdx +++ b/docs/src/content/docs/reference/truncate.mdx @@ -9,6 +9,8 @@ A basic component for cropping text. Usually there is no need to use it directly Use it like a normal component, just pass the long text as the `children` prop. +If you need the collapsed state to keep rendered inline markup such as links, classes, or inline styles, enable `preserveMarkup`. This mode is opt-in because it does more work than the default plain-text truncation path. + ```tsx import React from 'react' import { Truncate } from '@re-dev/react-truncate' @@ -32,6 +34,14 @@ const MyComponent: React.FC = () => { } ``` +```tsx + + Hello docs and more content here + +``` + +`preserveMarkup` is best-effort for rendered inline content. It is intended for inline elements such as `a`, `span`, `strong`, `em`, `code`, or components that finally render standard inline DOM nodes. Block layout and arbitrary custom component behavior are not guaranteed to be preserved in the collapsed state. + Hint: (Generally with React) if you want to preserve newlines from plain text, you need to do as follows: ```js @@ -142,6 +152,13 @@ export interface TruncateProps extends DetailedHTMLProps { */ middle?: boolean + /** + * Preserve rendered inline markup in collapsed output when possible + * + * @default false + */ + preserveMarkup?: boolean + /** * Number of characters to keep from the end of the text * diff --git a/docs/src/content/docs/zh/reference/show-more.mdx b/docs/src/content/docs/zh/reference/show-more.mdx index 16ec14f..243dc90 100644 --- a/docs/src/content/docs/zh/reference/show-more.mdx +++ b/docs/src/content/docs/zh/reference/show-more.mdx @@ -18,6 +18,8 @@ import { 像普通组件一样使用它,只需将长文本作为 `children` prop 传递。 +`ShowMore` 会继承 `Truncate` 的 `preserveMarkup` 能力。如果折叠态需要保留链接、类名或内联样式等已渲染的内联富文本,可按需启用它。 + @@ -239,6 +241,6 @@ export type ShowMoreRef = { ## 现场演示 -这是一个理论上适用于任何项目的基本示例,调整滑块,控制它在不同情况下的显示变化。 +这是一个理论上适用于任何项目的基本示例,可通过调节各项开关和滑块,对比纯文本、富文本、自定义按钮以及 preserveMarkup 在不同情况下的表现。 diff --git a/docs/src/content/docs/zh/reference/truncate.mdx b/docs/src/content/docs/zh/reference/truncate.mdx index 21871cf..7b5f664 100644 --- a/docs/src/content/docs/zh/reference/truncate.mdx +++ b/docs/src/content/docs/zh/reference/truncate.mdx @@ -9,6 +9,8 @@ description: Usage of the `Truncate` component. 像普通的组件一样使用它,只需要将长文本作为 `children` prop 传递。 +如果希望折叠态尽量保留已经渲染出来的内联富文本结构,例如链接、类名、内联样式等,可以启用 `preserveMarkup` 。该能力为按需开启,因为它比默认的纯文本裁剪路径有更高的计算开销。 + ```tsx import React from 'react' import { Truncate } from '@re-dev/react-truncate' @@ -32,6 +34,14 @@ const MyComponent: React.FC = () => { } ``` +```tsx + + Hello docs and more content here + +``` + +`preserveMarkup` 主要面向已经渲染为标准内联 DOM 的内容,例如 `a`、`span`、`strong`、`em`、`code`,以及最终输出这些节点的组件。对于块级布局和任意自定义组件在折叠态下的完整行为,不保证完全保留。 + 提示:(通常使用 React)如果你想保留纯文本中的换行符,你需要执行以下操作: ```js @@ -135,6 +145,13 @@ export interface TruncateProps extends DetailedHTMLProps { */ middle?: boolean + /** + * 尽可能在折叠态保留已渲染的内联标记结构 + * + * @default false + */ + preserveMarkup?: boolean + /** * 从文本末尾开始保留的字符数 * diff --git a/docs/src/i18n/index.ts b/docs/src/i18n/index.ts index a012cc5..ab20cb6 100644 --- a/docs/src/i18n/index.ts +++ b/docs/src/i18n/index.ts @@ -18,6 +18,7 @@ export const translations = { 'example.lines': 'Default Lines:', 'example.html': 'Rich Text:', 'example.custom': 'Custom Buttons:', + 'example.preserveMarkup': 'Preserve Markup:', 'example.end': 'End Position:', 'example.dialogTitle': 'Here is the full content', @@ -38,6 +39,7 @@ export const translations = { 'example.lines': '默认行数:', 'example.html': '富文本:', 'example.custom': '自定义按钮:', + 'example.preserveMarkup': '保留富文本:', 'example.end': '结束位置:', 'example.dialogTitle': '这里是完整的内容', From 3719968ed832c7ffa6604a53bdd74d409714e8d7 Mon Sep 17 00:00:00 2001 From: chengpeiquan Date: Sun, 8 Mar 2026 21:58:43 +0800 Subject: [PATCH 3/9] feat(Truncate): make preserveMarkup measurement style-aware --- .../2026-03-08-preserve-markup-design.md | 234 ++++++++---- ...kup-style-aware-docs-e2e-implementation.md | 277 ++++++++++++++ src/Truncate/Truncate.tsx | 81 ++-- src/Truncate/engines/markup.tsx | 345 +++++++++++++++++- src/Truncate/markup/render.tsx | 111 ++++-- 5 files changed, 908 insertions(+), 140 deletions(-) create mode 100644 docs/plans/2026-03-08-preserve-markup-style-aware-docs-e2e-implementation.md diff --git a/docs/plans/2026-03-08-preserve-markup-design.md b/docs/plans/2026-03-08-preserve-markup-design.md index 055bef1..0abf9bb 100644 --- a/docs/plans/2026-03-08-preserve-markup-design.md +++ b/docs/plans/2026-03-08-preserve-markup-design.md @@ -4,29 +4,33 @@ **Problem Statement** -`Truncate` currently flattens `children` into plain text before computing the collapsed result. This makes the collapsed state lose rendered markup semantics such as links, inline styles, classes, and DOM structure produced by components like `linkify-react`. As a result: +`Truncate` currently uses the plain-text engine to decide the collapsed range, then optionally reconstructs inline DOM when `preserveMarkup` is enabled. This fixes semantic loss such as dropped links, classes, and inline styles, but it still inherits the measurement blind spots of plain-text truncation. -- issue `#22` cannot keep clickable links in the collapsed state -- issue `#26` loses `Linkify` styling in `ShowMore` while collapsed -- `ShowMore` and `MiddleTruncate` inherit the same limitation because they are thin wrappers around `Truncate` +That remaining gap matters in real usage: + +- inline styles such as `font-weight`, `font-style`, `letter-spacing`, or custom font stacks can change line wrapping +- nested inline markup such as `a > span`, `strong`, `code`, or linkified output can occupy more width than the flattened plain-text measurement predicts +- the collapsed result can therefore overflow into an extra rendered line even when the algorithm expected it to fit + +This is especially visible in docs demos, where users compare examples side by side and immediately notice when `preserveMarkup` says “3 lines” but the browser renders 4. ## Goals - Preserve rendered inline markup in the collapsed state when explicitly requested -- Keep the existing default performance profile for plain-text users -- Make `ShowMore` inherit the capability without new public components -- Prepare `MiddleTruncate` for phased support instead of forcing full parity immediately +- Make `preserveMarkup` measurement style-aware instead of relying on plain-text width guesses +- Keep the current default performance profile for users who do not opt into `preserveMarkup` +- Add stable docs-page E2E coverage for the library’s own live demos so obvious regressions are caught before release ## Non-Goals -- Do not guarantee perfect preservation of arbitrary React component identity +- Do not change the default plain-text truncation behavior for existing users +- Do not promise perfect preservation of arbitrary React component identity, refs, or internal state - Do not guarantee block-level layout truncation in the first phase -- Do not change the default truncation behavior for existing users -- Do not introduce a parallel public component family such as `MarkupTruncate` +- Do not force `MiddleTruncate` onto the new measurement path in this iteration ## Public API Direction -Keep the existing public components unchanged and add one opt-in prop on `Truncate`: +Keep the existing public components unchanged and continue to expose a single opt-in prop on `Truncate`: ```ts interface TruncateProps { @@ -37,51 +41,107 @@ interface TruncateProps { Recommended behavior: - `preserveMarkup` defaults to `false` -- `false` keeps the current plain-text truncation engine -- `true` enables a markup-preserving engine that works from rendered DOM output +- `false` keeps the current plain-text engine unchanged +- `true` enables a markup-preserving, style-aware measurement engine - `ShowMore` transparently forwards `preserveMarkup` -- `MiddleTruncate` is phased separately and should not promise full support in the first release +- `MiddleTruncate` remains out of scope for this style-aware phase + +## Why The Existing Hybrid Approach Is Not Enough + +The current hybrid approach is: + +1. use the plain-text engine to compute visible text lines +2. rebuild preserved markup from a DOM snapshot -## Why Not a New Public Component +This is not sufficient because the truncation boundary is already wrong before markup reconstruction begins. Once the browser applies real inline styles, the supposedly safe prefix may wrap differently and spill into one more line. -Creating a public `MarkupTruncate` would split the API surface and quickly raise follow-up questions such as whether `MarkupShowMore` and `MarkupMiddleTruncate` are also required. Keeping a single public `Truncate` with an opt-in flag gives users one mental model, keeps docs smaller, and isolates the performance cost to users who need the feature. +That means the root cause sits in the measurement layer, not the render layer. -## Internal Architecture +## Recommended Architecture -`Truncate` remains the only public entry point but internally routes between two engines: +`Truncate` keeps two internal engines: 1. **Plain-text engine** - - current logic - default path + - current measurement behavior - optimized for performance -2. **Markup engine** - - enabled only when `preserveMarkup === true` - - works from rendered DOM structure instead of flattened text - -Recommended internal split: - -- **Measurement layer** - - container width - - ellipsis width - - font measurement -- **Markup snapshot layer** - - traverse the rendered hidden node - - capture text nodes, inline elements, and line breaks - - keep rendered DOM semantics rather than React component identity -- **Truncation layer** - - compute the maximum visible range within width and line limits - - support end truncation first - - reuse the same snapshot model for later middle truncation -- **Render layer** - - rebuild the collapsed React output from the truncated snapshot - - append the ellipsis node while preserving inline structure as much as possible +2. **Style-aware markup engine** + - used only when `preserveMarkup === true && middle !== true` + - computes truncation from rendered DOM structure and actual browser layout + - reconstructs collapsed output from a markup snapshot + +### Internal Layers + +#### 1. Snapshot layer + +- traverse the hidden rendered node +- capture text nodes, inline elements, and `br` +- preserve rendered DOM semantics such as `href`, `class`, `style`, and nested inline structure +- avoid trying to preserve React component identity + +#### 2. Style-aware measurement layer + +- build a hidden measurement container that inherits the relevant width and text layout constraints +- render candidate collapsed output using the preserved snapshot plus ellipsis +- measure actual browser layout with DOM APIs such as `Range` and `getClientRects()` or equivalent rendered-height checks +- determine whether the candidate fits within the requested number of lines + +#### 3. Search layer + +- binary-search the maximum visible prefix that still fits with the ellipsis included +- reuse the snapshot tree while varying only the visible text boundary +- support end truncation first + +#### 4. Render layer + +- rebuild the collapsed React output from the chosen snapshot prefix +- append the ellipsis node after preserved markup +- keep inline structure intact wherever possible + +## Measurement Strategy Details + +### Why real DOM measurement + +The browser already knows the true width impact of: + +- `font-weight` +- `font-style` +- `letter-spacing` +- inline `style` +- nested `span`, `strong`, `em`, `code`, `a` +- third-party renderers that output standard inline DOM + +Trying to approximate these effects from flattened text is fragile. Measuring the actual candidate DOM is more expensive, but it directly matches what the user sees. + +### Proposed fit check + +For each candidate prefix: + +1. render the visible prefix plus ellipsis into the hidden measurement container +2. inspect the rendered layout +3. treat the candidate as valid only if it stays within the requested line count + +Preferred implementation direction: + +- use actual DOM layout from a hidden but measurable container +- use line-aware APIs such as `Range#getClientRects()` when that gives stable line counts +- fall back to container height checks only when line-rect counting is insufficient + +### Constraints for stability + +The measurement container should: + +- share the target width +- inherit text styles from the visible root +- stay measurable while visually hidden +- avoid affecting page layout or user interaction ## Supported Scope ### Phase 1 -`Truncate` and `ShowMore` support markup preservation for rendered inline content, including typical output such as: +`Truncate` and `ShowMore` support markup preservation for rendered inline content, including: - text nodes - `a` @@ -89,72 +149,92 @@ Recommended internal split: - `strong` - `em` - `code` -- inline classes and inline styles -- components like `linkify-react` that finally render standard inline DOM nodes +- inline classes +- inline styles +- components such as `linkify-react` that finally render standard inline DOM nodes -### Phase 1 Limitations +### Phase 1 limitations - no guarantee for block elements -- no guarantee for complex interactive custom component behavior in collapsed state -- no guarantee for React refs or internal component state preservation -- no guarantee for exact equivalence of deeply nested custom component trees - -### Phase 2 - -Extend the markup engine to support `middle` truncation for `MiddleTruncate`, starting with simple inline markup only. +- no guarantee for custom component behavior beyond the DOM they render +- no guarantee for refs or component state preservation in collapsed output +- no style-aware `middle` truncation yet ## Component Responsibilities ### `Truncate` -- owns measurement -- owns truncation strategy selection -- owns collapsed-state rendering -- defines the official support boundary for markup preservation +- owns engine selection +- owns measurement strategy +- owns collapsed rendering +- defines the official support boundary for `preserveMarkup` ### `ShowMore` - owns expand/collapse state only - forwards `preserveMarkup` to `Truncate` -- does not implement any extra markup compatibility logic +- does not implement a separate markup-specific layout algorithm ### `MiddleTruncate` -- remains a semantic wrapper around `Truncate` -- should reuse the same snapshot model and rendering layer -- can lag one phase behind `Truncate` and `ShowMore` +- remains on the existing path for now +- can adopt the snapshot primitives later in a dedicated phase ## Performance Strategy -Markup preservation is more expensive than the current plain-text approach because it requires DOM traversal, snapshot creation, truncation against a richer model, and node reconstruction. To avoid regressing users who only need plain-text truncation: +Markup preservation remains more expensive than plain-text truncation because it requires DOM traversal, candidate rendering, and repeated layout checks. + +To avoid regressing the default path: - keep the plain-text engine as the default path -- make markup preservation strictly opt-in via `preserveMarkup` -- memoize snapshot work where possible -- skip collapsed-state computation when `ShowMore` is expanded -- reuse existing width measurement primitives where possible +- only enable style-aware measurement when `preserveMarkup === true` +- skip this engine for `middle` truncation in the first phase +- avoid repeated work when `ShowMore` is expanded +- reuse the snapshot representation across binary-search iterations + +## Docs-Page E2E Strategy + +The docs site is the library’s public contract in action. If its live demos visibly overflow, users will assume the library is broken even if unit tests pass. + +Add browser E2E coverage against the real docs preview site for the pages that demonstrate this feature: + +- `/reference/truncate/` +- `/reference/show-more/` +- `/zh/reference/show-more/` + +### E2E design principles + +- add stable `data-testid` anchors to the live demo containers and key preserved nodes +- use fixed demo width, fixed content, and fixed line-height where needed to keep tests deterministic +- assert behavior, not implementation details + +### Core docs assertions + +- `preserveMarkup` collapsed output does not render an extra line compared with the intended line budget +- preserved collapsed output still contains expected inline nodes such as links or styled spans +- `ShowMore` expands and collapses correctly in docs demos +- at least one Chinese docs example covers the previous extra-line regression path ## Migration and Compatibility - existing users see no behavior change unless they opt in -- existing tests for plain-text behavior should remain valid -- docs must explain that markup preservation is best-effort for rendered inline markup, not full arbitrary React component preservation +- existing plain-text tests stay valid +- docs should explicitly describe `preserveMarkup` as opt-in, more expensive, and best-effort for rendered inline markup ## Recommended Rollout -1. Refactor `Truncate` just enough to support dual engines -2. Ship end truncation markup support behind `preserveMarkup` -3. Forward the prop through `ShowMore` -4. Document support boundaries and performance trade-offs -5. Add `MiddleTruncate` markup support in a separate phase +1. Replace the markup engine’s plain-text-derived boundary with style-aware measurement +2. Keep snapshot/render primitives but rebase them on the new fit-check loop +3. Preserve `ShowMore` compatibility by forwarding the prop unchanged +4. Add stable docs-page E2E for the actual live demos +5. Defer `MiddleTruncate` style-aware support to a later phase ## Risks -- attribute and child-node preservation bugs during reconstruction -- resize-triggered recomputation cost in markup-heavy lists -- awkward edge cases when truncation cuts through nested inline structures -- over-promising support for arbitrary custom components in docs +- binary-search candidate rendering may become expensive in markup-heavy lists +- line counting can differ across environments if tests depend on unstable fonts or container styles +- nested inline reconstruction may still reveal edge cases when truncation cuts inside deeply styled content -## Recommendation +## Decision Summary -Adopt a single public `Truncate` API with an opt-in `preserveMarkup` prop, backed by an internal dual-engine architecture. Deliver `Truncate` and `ShowMore` first, then extend `MiddleTruncate` in a second phase once the shared snapshot and rendering primitives are stable. +Keep `preserveMarkup` opt-in, but make it truly style-aware by measuring real rendered DOM instead of deriving boundaries from flattened plain text. Back the feature with docs-page E2E coverage so the project’s own demos cannot silently regress into obvious overflow bugs. diff --git a/docs/plans/2026-03-08-preserve-markup-style-aware-docs-e2e-implementation.md b/docs/plans/2026-03-08-preserve-markup-style-aware-docs-e2e-implementation.md new file mode 100644 index 0000000..1fe36d0 --- /dev/null +++ b/docs/plans/2026-03-08-preserve-markup-style-aware-docs-e2e-implementation.md @@ -0,0 +1,277 @@ +# Preserve Markup Style-Aware Measurement and Docs E2E Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace the current plain-text-derived `preserveMarkup` boundary with style-aware DOM measurement, and add stable docs-page Playwright coverage for the real docs demos. + +**Architecture:** Keep `Truncate` as the single public entry point with two internal engines. The default engine remains the current plain-text path. The opt-in `preserveMarkup` path switches to a style-aware markup engine that measures actual rendered DOM in a hidden container, binary-searches the largest fitting prefix, and re-renders the collapsed output from the markup snapshot. Add a separate Playwright config for docs preview so the reference pages are tested as they are actually published. + +**Tech Stack:** React 19, TypeScript, Playwright, Astro preview, DOM Range/layout measurement, Vitest + +--- + +### Task 1: Lock down the style-aware regression in unit tests + +**Files:** +- Modify: `test/Truncate.spec.tsx` +- Modify: `test/ShowMore.spec.tsx` + +**Step 1: Write the failing tests** + +Add focused tests that fail with the current hybrid approach: + +- `Truncate preserveMarkup` with nested inline styles whose rendered width causes a plain-text estimate to spill into an extra line +- `ShowMore preserveMarkup` collapsed content that should stay within the requested line count even when inline markup widens the text +- a regression case where preserved markup still keeps `href`, `class`, and `style` while fitting the target line budget + +Use deterministic container width and line-height so the assertions are not tied to incidental layout. + +**Step 2: Run the targeted suite to verify failure** + +Run: `pnpm test:run test/Truncate.spec.tsx test/ShowMore.spec.tsx` + +Expected: the new style-aware cases fail with the current implementation, while existing non-markup cases stay green. + +**Step 3: Keep the failure evidence small and focused** + +Do not broaden the test scope yet. Only prove the bug exists in the current measurement model. + +**Step 4: Commit** + +```bash +git add test/Truncate.spec.tsx test/ShowMore.spec.tsx +git commit -m "test: capture style-aware preserveMarkup regressions" +``` + +### Task 2: Refactor the markup engine around real DOM fit checks + +**Files:** +- Modify: `src/Truncate/Truncate.tsx` +- Modify: `src/Truncate/engines/markup.tsx` +- Modify: `src/Truncate/markup/render.tsx` +- Modify: `src/Truncate/markup/snapshot.ts` +- Modify: `src/Truncate/types.ts` + +**Step 1: Write the failing helper-level tests if needed** + +If the new engine needs isolated helper coverage, add narrowly scoped assertions in `test/Truncate.spec.tsx` for: + +- snapshot prefix slicing +- leading whitespace handling before the ellipsis +- counting rendered lines from a hidden measurement container + +**Step 2: Run the targeted suite to verify failure** + +Run: `pnpm test:run test/Truncate.spec.tsx` + +Expected: helper assertions fail before the new fit-check helpers exist. + +**Step 3: Implement the style-aware measurement path** + +Update the markup engine so it: + +- no longer depends on plain-text `visibleTextLines` +- builds a preserved snapshot from the hidden rendered node +- renders candidate prefixes plus ellipsis into a hidden measurement container +- counts whether the candidate fits inside the requested line budget using actual DOM layout +- binary-searches the largest fitting prefix +- returns reconstructed collapsed markup that preserves inline DOM semantics + +Keep this path gated behind `preserveMarkup === true && middle !== true`. + +**Step 4: Run the targeted suite to verify it passes** + +Run: `pnpm test:run test/Truncate.spec.tsx test/ShowMore.spec.tsx` + +Expected: the new style-aware cases pass and existing preserveMarkup behavior continues to preserve links, classes, styles, and ReactNode ellipsis. + +**Step 5: Commit** + +```bash +git add src/Truncate/Truncate.tsx src/Truncate/engines/markup.tsx src/Truncate/markup/render.tsx src/Truncate/markup/snapshot.ts src/Truncate/types.ts test/Truncate.spec.tsx test/ShowMore.spec.tsx +git commit -m "feat: make preserveMarkup measurement style-aware" +``` + +### Task 3: Add stable docs demo anchors for Playwright + +**Files:** +- Modify: `docs/src/components/examples/show-more/ControllableShowMore.tsx` +- Modify: `docs/src/components/examples/Widgets.tsx` +- Modify: `docs/src/components/examples/Data.tsx` +- Modify: `docs/src/content/docs/reference/truncate.mdx` +- Modify: `docs/src/content/docs/reference/show-more.mdx` +- Modify: `docs/src/content/docs/zh/reference/show-more.mdx` + +**Step 1: Write the failing docs-page tests first** + +Create tests that expect stable docs-page selectors to exist before adding them. + +Required selectors should cover: + +- the docs demo root +- the preserveMarkup toggle +- the rendered collapsed content container +- a preserved inline link or styled span inside the demo +- current expanded/collapsed state for `ShowMore` + +**Step 2: Run the docs-page tests to verify failure** + +Run: `pnpm test:e2e:docs --list` + +Expected: either the docs-page test file is missing or the selectors do not exist yet. + +**Step 3: Add deterministic docs demo hooks** + +Update the docs demos so Playwright can assert them reliably: + +- add stable `data-testid` attributes +- keep demo content fixed and intentionally truncatable +- ensure the demo container uses deterministic width and line-height values for the regression cases + +Do not add test-only user-facing copy. Prefer structural hooks and fixed example layout. + +**Step 4: Run the docs build-facing checks** + +Run: `pnpm -F docs build` + +Expected: docs compile successfully with the new demo anchors. + +**Step 5: Commit** + +```bash +git add docs/src/components/examples/show-more/ControllableShowMore.tsx docs/src/components/examples/Widgets.tsx docs/src/components/examples/Data.tsx docs/src/content/docs/reference/truncate.mdx docs/src/content/docs/reference/show-more.mdx docs/src/content/docs/zh/reference/show-more.mdx +git commit -m "test: add stable docs demo hooks for preserveMarkup" +``` + +### Task 4: Add a docs-preview Playwright suite + +**Files:** +- Create: `playwright.docs.config.ts` +- Create: `e2e/docs-global-setup.mjs` +- Create: `e2e/docs-global-teardown.mjs` +- Create: `e2e/tests/docs-pages.spec.ts` +- Modify: `package.json` + +**Step 1: Write the failing docs-page tests** + +Add Playwright coverage for these scenarios against the real docs preview server: + +- `/reference/truncate/`: `preserveMarkup` collapsed output keeps preserved inline markup and does not exceed the intended line budget +- `/reference/show-more/`: toggling preserveMarkup keeps collapsed output stable and `ShowMore` still expands and collapses +- `/zh/reference/show-more/`: the Chinese preserveMarkup demo does not grow an extra collapsed line + +Assert against stable `data-testid` hooks and measured element heights rather than brittle prose. + +**Step 2: Run the docs-page suite to verify failure** + +Run: `pnpm test:e2e:docs e2e/tests/docs-pages.spec.ts` + +Expected: FAIL because the docs Playwright config and preview server setup do not exist yet. + +**Step 3: Implement the docs-preview harness** + +Add a dedicated Playwright config that: + +- builds and previews the Astro docs site +- serves it on a dedicated port +- tears the preview process down cleanly +- keeps source-component E2E and docs-page E2E separate + +Expose a root script such as: + +```json +{ + "scripts": { + "test:e2e:docs": "playwright test --config playwright.docs.config.ts" + } +} +``` + +**Step 4: Run the docs-page suite to verify it passes** + +Run: `pnpm test:e2e:docs e2e/tests/docs-pages.spec.ts` + +Expected: PASS for the real docs preview scenarios. + +**Step 5: Commit** + +```bash +git add playwright.docs.config.ts e2e/docs-global-setup.mjs e2e/docs-global-teardown.mjs e2e/tests/docs-pages.spec.ts package.json +git commit -m "test: cover preserveMarkup in docs preview e2e" +``` + +### Task 5: Update public docs for the opt-in cost and support boundary + +**Files:** +- Modify: `README.md` +- Modify: `docs/src/content/docs/reference/truncate.mdx` +- Modify: `docs/src/content/docs/reference/show-more.mdx` +- Modify: `docs/src/content/docs/zh/reference/truncate.mdx` +- Modify: `docs/src/content/docs/zh/reference/show-more.mdx` + +**Step 1: Write the docs changes** + +Clarify: + +- `preserveMarkup` is opt-in +- style-aware measurement is more expensive than the plain-text default path +- support is aimed at rendered inline DOM, not arbitrary component identity +- docs demos are representative regression cases, not full guarantee of every third-party renderer + +**Step 2: Run the relevant validation** + +Run: `pnpm -F docs build` + +Expected: docs build succeeds after the copy updates. + +**Step 3: Commit** + +```bash +git add README.md docs/src/content/docs/reference/truncate.mdx docs/src/content/docs/reference/show-more.mdx docs/src/content/docs/zh/reference/truncate.mdx docs/src/content/docs/zh/reference/show-more.mdx +git commit -m "docs: clarify style-aware preserveMarkup behavior" +``` + +### Task 6: Run focused and broader verification + +**Files:** +- Verify only + +**Step 1: Run focused unit tests** + +Run: `pnpm test:run test/Truncate.spec.tsx test/ShowMore.spec.tsx` + +Expected: PASS. + +**Step 2: Run source harness E2E** + +Run: `pnpm test:e2e e2e/tests/components.spec.ts` + +Expected: PASS. + +**Step 3: Run docs-page E2E** + +Run: `pnpm test:e2e:docs e2e/tests/docs-pages.spec.ts` + +Expected: PASS. + +**Step 4: Run broader regression checks** + +Run: + +```bash +pnpm test:run +pnpm build +``` + +Expected: PASS. If unrelated failures appear, record them separately instead of expanding scope. + +**Step 5: Review the diff** + +Run: + +```bash +git status --short +git diff --stat +``` + +Expected: only the preserveMarkup measurement, docs demo, and docs-page E2E files are changed. diff --git a/src/Truncate/Truncate.tsx b/src/Truncate/Truncate.tsx index ea3fbea..a43ba04 100644 --- a/src/Truncate/Truncate.tsx +++ b/src/Truncate/Truncate.tsx @@ -117,44 +117,51 @@ export const Truncate: React.FC = ({ useEffect(() => { const mounted = !!(targetRef.current && targetWidth) - if (typeof window !== 'undefined' && mounted) { - if (lines > 0) { - const plainTextResult = getPlainTextTruncation({ - ellipsis, - ellipsisRef: ellipsisRef.current, - end, - lines, - measureWidth, - middleTruncate, - separator, - targetWidth, - textRef: textRef.current, - trimWhitespace, - }) - - if (preserveMarkup && !middleTruncate) { - if (plainTextResult.didTruncate) { - setRenderTextRef( - getMarkupTruncation({ - ellipsis, - node: textRef.current, - separator, - visibleTextLines: plainTextResult.visibleTextLines, - }), - ) - } else { - setRenderTextRef(children) - } - } else { - setRenderTextRef(plainTextResult.resultLines.map(renderLine)) - } - - truncate(plainTextResult.didTruncate) - } else { - setRenderTextRef(children) - truncate(false) - } + if (typeof window === 'undefined' || !mounted) { + return + } + + if (lines <= 0) { + setRenderTextRef(children) + truncate(false) + return } + + const plainTextResult = getPlainTextTruncation({ + ellipsis, + ellipsisRef: ellipsisRef.current, + end, + lines, + measureWidth, + middleTruncate, + separator, + targetWidth, + textRef: textRef.current, + trimWhitespace, + }) + + if (preserveMarkup && !middleTruncate) { + const markupResult = getMarkupTruncation({ + fallbackDidTruncate: plainTextResult.didTruncate, + fallbackVisibleTextLines: plainTextResult.visibleTextLines, + ellipsis, + ellipsisNode: ellipsisRef.current, + lines, + node: textRef.current, + rootNode: targetRef.current, + separator, + trimWhitespace, + }) + + setRenderTextRef( + markupResult.didTruncate ? markupResult.result : children, + ) + truncate(markupResult.didTruncate) + return + } + + setRenderTextRef(plainTextResult.resultLines.map(renderLine)) + truncate(plainTextResult.didTruncate) }, [ children, ellipsis, diff --git a/src/Truncate/engines/markup.tsx b/src/Truncate/engines/markup.tsx index d2edfa0..cdabc14 100644 --- a/src/Truncate/engines/markup.tsx +++ b/src/Truncate/engines/markup.tsx @@ -1,20 +1,355 @@ -import { renderMarkupPrefix } from '../markup/render' +import { + getSnapshotTextLength, + renderMarkupLines, + renderMarkupPrefix, +} from '../markup/render' import { createMarkupSnapshot } from '../markup/snapshot' import type React from 'react' interface MarkupTruncationOptions { ellipsis: React.ReactNode + ellipsisNode: HTMLSpanElement | null + fallbackDidTruncate: boolean + fallbackVisibleTextLines: string[] + lines: number node: HTMLSpanElement | null + rootNode: HTMLSpanElement | null separator: string - visibleTextLines: string[] + trimWhitespace: boolean +} + +interface MarkupTruncationResult { + didTruncate: boolean + result: React.ReactNode +} + +const normalizeText = (text: string, separator: string) => { + return text.replace(/\r\n|\r|\n/g, separator) +} + +const parsePixelValue = (value: string) => { + const parsed = Number.parseFloat(value) + return Number.isFinite(parsed) ? parsed : 0 +} + +const getAvailableWidth = ( + rootNode: HTMLSpanElement | null, + fallbackWidth: number, +) => { + const parent = rootNode?.parentElement + + if (!parent) { + return fallbackWidth + } + + const rectWidth = parent.getBoundingClientRect().width + const style = window.getComputedStyle(parent) + const horizontalPadding = + parsePixelValue(style.paddingLeft) + parsePixelValue(style.paddingRight) + const horizontalBorder = + parsePixelValue(style.borderLeftWidth) + + parsePixelValue(style.borderRightWidth) + + const contentWidth = Math.floor( + rectWidth - horizontalPadding - horizontalBorder, + ) + + return contentWidth > 0 ? contentWidth : fallbackWidth +} + +const createMeasureRoot = ( + rootNode: HTMLSpanElement | null, + width: number, +): HTMLSpanElement => { + const measureRoot = document.createElement('span') + const style = rootNode ? window.getComputedStyle(rootNode) : null + + measureRoot.setAttribute('aria-hidden', 'true') + measureRoot.style.position = 'fixed' + measureRoot.style.top = '0' + measureRoot.style.left = '0' + measureRoot.style.visibility = 'hidden' + measureRoot.style.pointerEvents = 'none' + measureRoot.style.display = 'block' + measureRoot.style.margin = '0' + measureRoot.style.padding = '0' + measureRoot.style.border = '0' + measureRoot.style.boxSizing = 'content-box' + measureRoot.style.width = `${Math.max(width, 1)}px` + measureRoot.style.whiteSpace = style?.whiteSpace || 'normal' + measureRoot.style.wordBreak = style?.wordBreak || 'normal' + measureRoot.style.overflowWrap = style?.overflowWrap || 'break-word' + measureRoot.style.font = style?.font || '' + measureRoot.style.fontFamily = style?.fontFamily || '' + measureRoot.style.fontSize = style?.fontSize || '' + measureRoot.style.fontStyle = style?.fontStyle || '' + measureRoot.style.fontWeight = style?.fontWeight || '' + measureRoot.style.letterSpacing = style?.letterSpacing || '' + measureRoot.style.lineHeight = style?.lineHeight || '' + measureRoot.style.wordSpacing = style?.wordSpacing || '' + measureRoot.style.textTransform = style?.textTransform || '' + measureRoot.style.textIndent = style?.textIndent || '' + measureRoot.style.direction = style?.direction || '' + + return measureRoot +} + +const appendEllipsisClone = ( + fragment: DocumentFragment, + ellipsisNode: HTMLSpanElement | null, +) => { + if (!ellipsisNode) return + + Array.from(ellipsisNode.childNodes).forEach((childNode) => { + fragment.appendChild(childNode.cloneNode(true)) + }) +} + +const trimTrailingCloneWhitespace = (node: Node) => { + const childNodes = Array.from(node.childNodes) + + for (let index = childNodes.length - 1; index >= 0; index -= 1) { + const childNode = childNodes[index] + + if (childNode instanceof HTMLBRElement) { + childNode.remove() + continue + } + + if (childNode.nodeType === Node.TEXT_NODE) { + const trimmedText = (childNode.textContent || '').replace(/\s+$/, '') + + if (!trimmedText) { + childNode.remove() + continue + } + + childNode.textContent = trimmedText + return + } + + if (childNode.nodeType === Node.ELEMENT_NODE) { + trimTrailingCloneWhitespace(childNode) + + if (childNode.childNodes.length === 0) { + childNode.remove() + continue + } + + return + } + + childNode.remove() + } +} + +const truncateCloneToLength = ( + node: Node, + remaining: number, + separator: string, +): number => { + const childNodes = Array.from(node.childNodes) + let nextRemaining = remaining + + childNodes.forEach((childNode) => { + if (nextRemaining <= 0) { + childNode.remove() + return + } + + if (childNode instanceof HTMLBRElement) { + nextRemaining -= 1 + return + } + + if (childNode.nodeType === Node.TEXT_NODE) { + const normalized = normalizeText(childNode.textContent || '', separator) + + if (normalized.length <= nextRemaining) { + childNode.textContent = normalized + nextRemaining -= normalized.length + return + } + + childNode.textContent = normalized.slice(0, nextRemaining) + nextRemaining = 0 + return + } + + if (childNode.nodeType === Node.ELEMENT_NODE) { + nextRemaining = truncateCloneToLength(childNode, nextRemaining, separator) + + if (childNode.childNodes.length === 0) { + childNode.remove() + } + + return + } + + childNode.remove() + }) + + return nextRemaining +} + +const createCandidateFragment = ({ + ellipsisNode, + node, + separator, + trimWhitespace, + visibleLength, +}: { + ellipsisNode: HTMLSpanElement | null + node: HTMLSpanElement + separator: string + trimWhitespace: boolean + visibleLength?: number +}) => { + const fragment = document.createDocumentFragment() + const clone = node.cloneNode(true) as HTMLSpanElement + + if (typeof visibleLength === 'number') { + truncateCloneToLength(clone, visibleLength, separator) + + if (trimWhitespace) { + trimTrailingCloneWhitespace(clone) + } + } + + Array.from(clone.childNodes).forEach((childNode) => { + fragment.appendChild(childNode) + }) + + if (typeof visibleLength === 'number') { + appendEllipsisClone(fragment, ellipsisNode) + } + + return fragment +} + +const getMeasuredHeight = ( + measureRoot: HTMLSpanElement, + content: DocumentFragment, +) => { + measureRoot.replaceChildren(content) + return measureRoot.getBoundingClientRect().height } export const getMarkupTruncation = ({ ellipsis, + ellipsisNode, + fallbackDidTruncate, + fallbackVisibleTextLines, + lines, node, + rootNode, separator, - visibleTextLines, -}: MarkupTruncationOptions) => { + trimWhitespace, +}: MarkupTruncationOptions): MarkupTruncationResult => { + if (!node) { + return { + didTruncate: false, + result: null, + } + } + const snapshot = createMarkupSnapshot(node, separator) - return renderMarkupPrefix(snapshot, visibleTextLines, ellipsis) + const totalLength = getSnapshotTextLength(snapshot) + const fallbackResult = { + didTruncate: fallbackDidTruncate, + result: fallbackDidTruncate + ? renderMarkupLines(snapshot, fallbackVisibleTextLines, ellipsis) + : null, + } + + if (totalLength === 0) { + return { + didTruncate: false, + result: null, + } + } + + const measureRoot = createMeasureRoot( + rootNode, + getAvailableWidth( + rootNode, + rootNode?.parentElement?.getBoundingClientRect().width || 0, + ), + ) + + document.body.appendChild(measureRoot) + + try { + const singleLineFragment = document.createDocumentFragment() + singleLineFragment.appendChild(document.createTextNode('A')) + + const rawSingleLineHeight = getMeasuredHeight( + measureRoot, + singleLineFragment, + ) + + if (rawSingleLineHeight === 0) { + return fallbackResult + } + + const singleLineHeight = Math.max(rawSingleLineHeight, 1) + const maxAllowedHeight = singleLineHeight * lines + 0.5 + const fullContentHeight = getMeasuredHeight( + measureRoot, + createCandidateFragment({ + ellipsisNode, + node, + separator, + trimWhitespace, + }), + ) + + if (fullContentHeight === 0) { + return fallbackResult + } + + if (fullContentHeight <= maxAllowedHeight) { + return { + didTruncate: false, + result: null, + } + } + + let low = 0 + let high = totalLength + let bestLength = 0 + + while (low <= high) { + const mid = Math.floor((low + high) / 2) + const candidateHeight = getMeasuredHeight( + measureRoot, + createCandidateFragment({ + ellipsisNode, + node, + separator, + trimWhitespace, + visibleLength: mid, + }), + ) + + if (candidateHeight <= maxAllowedHeight) { + bestLength = mid + low = mid + 1 + } else { + high = mid - 1 + } + } + + return { + didTruncate: true, + result: renderMarkupPrefix( + snapshot, + bestLength, + ellipsis, + trimWhitespace, + ), + } + } finally { + measureRoot.remove() + } } diff --git a/src/Truncate/markup/render.tsx b/src/Truncate/markup/render.tsx index 1bdb711..e495051 100644 --- a/src/Truncate/markup/render.tsx +++ b/src/Truncate/markup/render.tsx @@ -39,28 +39,33 @@ const normalizeAttributes = (attributes: Record) => { ) } -const renderSnapshotNodes = ( +export const renderSnapshotNodes = ( nodes: MarkupSnapshotNode[], keyPrefix = 'markup', ): React.ReactNode[] => { - return nodes.flatMap((node, index) => { + return nodes.reduce((result, node, index) => { const key = `${keyPrefix}-${index}` if (node.type === 'text') { - return node.text ? [node.text] : [] + if (node.text) { + result.push(node.text) + } + + return result } if (node.type === 'line-break') { - return [
] + result.push(
) + return result } const children = renderSnapshotNodes(node.children, `${key}-child`) if (children.length === 0) { - return [] + return result } - return [ + result.push( React.createElement( node.tagName, { @@ -69,8 +74,10 @@ const renderSnapshotNodes = ( }, ...children, ), - ] - }) + ) + + return result + }, []) } interface SliceResult { @@ -125,21 +132,18 @@ const sliceSnapshotNode = ( const childResult = sliceSnapshotNodes(node.children, remaining) - const taken = childResult.taken.length - ? [{ ...node, children: childResult.taken }] - : [] - const rest = childResult.rest.length - ? [{ ...node, children: childResult.rest }] - : [] - return { - taken, - rest, + taken: childResult.taken.length + ? [{ ...node, children: childResult.taken }] + : [], + rest: childResult.rest.length + ? [{ ...node, children: childResult.rest }] + : [], remaining: childResult.remaining, } } -const sliceSnapshotNodes = ( +export const sliceSnapshotNodes = ( nodes: MarkupSnapshotNode[], remaining: number, ): SliceResult => { @@ -155,7 +159,6 @@ const sliceSnapshotNodes = ( } const result = sliceSnapshotNode(node, nextRemaining) - taken.push(...result.taken) rest.push(...result.rest) nextRemaining = result.remaining @@ -168,7 +171,7 @@ const sliceSnapshotNodes = ( } } -const trimLeadingWhitespace = ( +export const trimLeadingWhitespace = ( nodes: MarkupSnapshotNode[], ): MarkupSnapshotNode[] => { if (nodes.length === 0) return nodes @@ -198,7 +201,56 @@ const trimLeadingWhitespace = ( return nodes } -export const renderMarkupPrefix = ( +export const trimTrailingWhitespace = ( + nodes: MarkupSnapshotNode[], +): MarkupSnapshotNode[] => { + if (nodes.length === 0) return nodes + + const headNodes = nodes.slice(0, -1) + const lastNode = nodes.at(-1) + + if (!lastNode) { + return nodes + } + + if (lastNode.type === 'line-break') { + return trimTrailingWhitespace(headNodes) + } + + if (lastNode.type === 'text') { + const trimmedText = lastNode.text.replace(/\s+$/, '') + + if (!trimmedText) { + return trimTrailingWhitespace(headNodes) + } + + return [...headNodes, { ...lastNode, text: trimmedText }] + } + + const trimmedChildren = trimTrailingWhitespace(lastNode.children) + + if (trimmedChildren.length === 0) { + return trimTrailingWhitespace(headNodes) + } + + return [...headNodes, { ...lastNode, children: trimmedChildren }] +} + +export const getSnapshotTextLength = (nodes: MarkupSnapshotNode[]): number => { + return nodes.reduce((total, node) => { + if (node.type === 'text') { + return total + node.text.length + } + + if (node.type === 'line-break') { + return total + 1 + } + + return total + getSnapshotTextLength(node.children) + }, 0) +} + +export const renderMarkupLines = ( nodes: MarkupSnapshotNode[], visibleTextLines: string[], ellipsis: React.ReactNode, @@ -235,3 +287,20 @@ export const renderMarkupPrefix = ( ) }) } + +export const renderMarkupPrefix = ( + nodes: MarkupSnapshotNode[], + visibleLength: number, + ellipsis: React.ReactNode, + trimWhitespace = false, +) => { + const result = sliceSnapshotNodes(nodes, visibleLength) + const visibleNodes = trimWhitespace + ? trimTrailingWhitespace(result.taken) + : result.taken + + return [ + ...renderSnapshotNodes(visibleNodes, 'markup-prefix'), + {ellipsis}, + ] +} From bbf732b2bed26e4a81bf2f0a35bb24d1034191cb Mon Sep 17 00:00:00 2001 From: chengpeiquan Date: Sun, 8 Mar 2026 22:15:26 +0800 Subject: [PATCH 4/9] test: add preserveMarkup docs and browser regressions --- ...3-08-docs-e2e-ci-speedup-implementation.md | 176 ++++++++++++++++++ docs/src/components/examples/Data.tsx | 33 +++- docs/src/components/examples/Widgets.tsx | 13 +- .../show-more/ControllableShowMore.tsx | 16 +- docs/src/components/ui/Dialog.tsx | 2 +- e2e/app/App.tsx | 56 ++++++ e2e/docs-global-setup.mjs | 72 +++++++ e2e/docs-global-teardown.mjs | 21 +++ e2e/tests/components.spec.ts | 22 +++ e2e/tests/docs-pages.spec.ts | 109 +++++++++++ eslint.config.js | 8 + package.json | 1 + playwright.docs.config.ts | 16 ++ 13 files changed, 535 insertions(+), 10 deletions(-) create mode 100644 docs/plans/2026-03-08-docs-e2e-ci-speedup-implementation.md create mode 100644 e2e/docs-global-setup.mjs create mode 100644 e2e/docs-global-teardown.mjs create mode 100644 e2e/tests/docs-pages.spec.ts create mode 100644 playwright.docs.config.ts diff --git a/docs/plans/2026-03-08-docs-e2e-ci-speedup-implementation.md b/docs/plans/2026-03-08-docs-e2e-ci-speedup-implementation.md new file mode 100644 index 0000000..fa737ad --- /dev/null +++ b/docs/plans/2026-03-08-docs-e2e-ci-speedup-implementation.md @@ -0,0 +1,176 @@ +# Docs E2E CI Speedup Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Reduce CI time for docs-page E2E by separating docs build work from docs preview test execution, so the docs suite does not rebuild the library and docs site inside Playwright setup. + +**Architecture:** Keep `pnpm test:e2e:docs` as the convenient all-in-one local entry point, but split CI-facing responsibilities into two layers: a build layer that produces `dist/` and `docs/dist/`, and a preview-only Playwright layer that only launches `astro preview` against prebuilt assets. Wire CI to build once, then run docs E2E against the prebuilt output. Avoid artifact orchestration for now; rely on job-local reuse inside the same checkout to keep the change small and stable. + +**Tech Stack:** pnpm, Playwright, Astro preview, GitHub Actions, Node.js 20 + +--- + +### Task 1: Add a preview-only docs E2E path + +**Files:** +- Modify: `e2e/docs-global-setup.mjs` +- Modify: `package.json` +- Test: `playwright.docs.config.ts` + +**Step 1: Write the failing expectation** + +Define the intended split in commands: + +```bash +pnpm test:e2e:docs +pnpm test:e2e:docs:preview e2e/tests/docs-pages.spec.ts +``` + +The first remains all-in-one. The second must only preview and test already-built docs. + +**Step 2: Run command to verify it fails** + +Run: `pnpm test:e2e:docs:preview --list` +Expected: FAIL because the script does not exist yet. + +**Step 3: Write minimal implementation** + +Update the docs E2E entrypoints so: + +- `test:e2e:docs` still works as the all-in-one developer command +- `test:e2e:docs:preview` runs the same Playwright config with an env flag such as `SKIP_DOCS_E2E_BUILD=1` +- `e2e/docs-global-setup.mjs` skips `pnpm build:lib` and `pnpm -F docs build` when that env flag is set +- the setup still always starts and health-checks `astro preview` + +**Step 4: Run command to verify it passes** + +Run: `pnpm test:e2e:docs:preview --list` +Expected: PASS and list the docs-page specs. + +**Step 5: Commit** + +```bash +git add package.json e2e/docs-global-setup.mjs +git commit -m "test: split docs e2e preview from build" +``` + +### Task 2: Add a dedicated build command for CI reuse + +**Files:** +- Modify: `package.json` + +**Step 1: Write the failing expectation** + +Define a single explicit command that CI can run once before docs-page Playwright: + +```bash +pnpm build:docs:e2e +``` + +It should build both the library output and the docs static output needed by preview. + +**Step 2: Run command to verify it fails** + +Run: `pnpm build:docs:e2e` +Expected: FAIL because the script does not exist yet. + +**Step 3: Write minimal implementation** + +Add a script such as: + +```json +{ + "scripts": { + "build:docs:e2e": "run-s build:lib build:docs" + } +} +``` + +Keep it small and explicit. Do not over-abstract the build matrix. + +**Step 4: Run command to verify it passes** + +Run: `pnpm build:docs:e2e` +Expected: PASS and produce `dist/` plus `docs/dist/`. + +**Step 5: Commit** + +```bash +git add package.json +git commit -m "build: add docs e2e build entrypoint" +``` + +### Task 3: Rewire CI so docs E2E reuses the prebuilt output + +**Files:** +- Modify: `.github/workflows/github-ci.yml` + +**Step 1: Write the failing checklist** + +The `verify` job should satisfy this updated sequence: + +- install dependencies once +- install Playwright Chromium once +- run `pnpm test:run` +- run `pnpm test:e2e` +- run `pnpm build:docs:e2e` +- run `pnpm test:e2e:docs:preview e2e/tests/docs-pages.spec.ts` +- only then continue to coverage or downstream jobs + +**Step 2: Compare current workflow to the checklist** + +Inspect: `.github/workflows/github-ci.yml` +Expected: FAIL because docs-page E2E is not yet wired as a preview-only post-build step. + +**Step 3: Write minimal implementation** + +Update CI so the `verify` job: + +- builds docs-page E2E assets once with `pnpm build:docs:e2e` +- runs docs-page Playwright via `pnpm test:e2e:docs:preview e2e/tests/docs-pages.spec.ts` +- does not trigger a second docs rebuild inside Playwright setup + +Do not add artifacts or extra jobs yet. + +**Step 4: Verify the workflow definition** + +Run: `sed -n '1,260p' .github/workflows/github-ci.yml` +Expected: the verify job clearly builds once and runs preview-only docs E2E after that build. + +**Step 5: Commit** + +```bash +git add .github/workflows/github-ci.yml +git commit -m "ci: reuse built docs output for docs e2e" +``` + +### Task 4: Run focused verification for the split flow + +**Files:** +- Verify only + +**Step 1: Run the build command** + +Run: `pnpm build:docs:e2e` +Expected: PASS. + +**Step 2: Run preview-only docs E2E** + +Run: `pnpm test:e2e:docs:preview e2e/tests/docs-pages.spec.ts` +Expected: PASS without rebuilding docs inside Playwright setup. + +**Step 3: Run the all-in-one command** + +Run: `pnpm test:e2e:docs e2e/tests/docs-pages.spec.ts` +Expected: PASS so local developer ergonomics remain unchanged. + +**Step 4: Review changed files** + +Run: + +```bash +git status --short +git diff --stat +``` + +Expected: only docs E2E entrypoints, scripts, and CI wiring changed for this speedup task. diff --git a/docs/src/components/examples/Data.tsx b/docs/src/components/examples/Data.tsx index 2a06b68..2623d1e 100644 --- a/docs/src/components/examples/Data.tsx +++ b/docs/src/components/examples/Data.tsx @@ -163,6 +163,7 @@ export const InlineRichText: React.FC = () => ( <> This is a long inline rich text demo with a{' '} ( > truncate.js.org {' '} - link, some highlighted text, and - more narrative content that keeps flowing so the collapsed state reliably - reaches the line limit. This is a long inline rich text demo with another{' '} + link, some{' '} + + highlighted text + + , and more narrative content that keeps flowing so the collapsed state + reliably reaches the line limit. This is a long inline rich text demo with + another{' '} ( <> 从前有座山,山上有座庙,庙里有个老和尚,老和尚一边讲故事,一边指向{' '} ( > truncate.js.org - ,还强调这是 重点样式文本 + ,还强调这是{' '} + + 重点样式文本 + 。故事继续讲下去:从前有座山,山上有座庙,庙里有个老和尚,老和尚又提到了{' '} & - ExampleFormLabelProps + ExampleFormLabelProps & { + 'data-testid'?: string + } interface FormRangeProps extends SharedItemProps { onChange: (v: number) => void @@ -122,16 +124,23 @@ const FormSwitch: React.FC = ({ onChange, ...rests }) => { + const switchTestId = rests['data-testid'] as string | undefined + return ( - + 文档链接 + + ,以及 + + 加宽样式片段 + + ,后面再接上一段足够长的说明文字,用来稳定触发 preserveMarkup + 在样式感知测量上的回归场景。 + +) + const sectionStyle: React.CSSProperties = { display: 'grid', gap: '8px', @@ -169,6 +196,35 @@ export const App: React.FC = () => { + +
+

Style-aware preserve markup

+
+
+ + {STYLE_AWARE_RICH_TEXT} + +
+ +
+ + {STYLE_AWARE_RICH_TEXT} + +
+
+
) } diff --git a/e2e/docs-global-setup.mjs b/e2e/docs-global-setup.mjs new file mode 100644 index 0000000..948f453 --- /dev/null +++ b/e2e/docs-global-setup.mjs @@ -0,0 +1,72 @@ +import { execSync, spawn } from 'node:child_process' +import { readFileSync, rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const PORT = 4175 +const PID_FILE = '/tmp/react-truncate-docs-preview.pid' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const repoRoot = path.resolve(__dirname, '..') +const docsRoot = path.resolve(repoRoot, 'docs') + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +function stopExistingServer() { + try { + const pid = Number.parseInt(readFileSync(PID_FILE, 'utf8'), 10) + + if (!Number.isNaN(pid)) { + try { + process.kill(-pid, 'SIGTERM') + } catch { + process.kill(pid, 'SIGTERM') + } + } + } catch { + // No previous server to stop. + } + + rmSync(PID_FILE, { force: true }) +} + +export default async function globalSetup() { + stopExistingServer() + + execSync('pnpm build:lib', { + cwd: repoRoot, + stdio: 'inherit', + }) + + execSync('pnpm -F docs build', { + cwd: repoRoot, + stdio: 'inherit', + }) + + const child = spawn( + 'pnpm', + ['exec', 'astro', 'preview', '--host', '127.0.0.1', '--port', String(PORT)], + { + cwd: docsRoot, + detached: true, + stdio: 'ignore', + }, + ) + + child.unref() + writeFileSync(PID_FILE, String(child.pid)) + + for (let attempt = 0; attempt < 80; attempt += 1) { + try { + execSync(`curl -sf http://127.0.0.1:${PORT}/ > /dev/null`, { + cwd: repoRoot, + stdio: 'ignore', + }) + return + } catch { + await sleep(500) + } + } + + throw new Error(`Timed out waiting for the docs preview server on port ${PORT}`) +} diff --git a/e2e/docs-global-teardown.mjs b/e2e/docs-global-teardown.mjs new file mode 100644 index 0000000..ce2d0ec --- /dev/null +++ b/e2e/docs-global-teardown.mjs @@ -0,0 +1,21 @@ +import { readFileSync, rmSync } from 'node:fs' + +const PID_FILE = '/tmp/react-truncate-docs-preview.pid' + +export default function globalTeardown() { + try { + const pid = Number.parseInt(readFileSync(PID_FILE, 'utf8'), 10) + + if (!Number.isNaN(pid)) { + try { + process.kill(-pid, 'SIGTERM') + } catch { + process.kill(pid, 'SIGTERM') + } + } + } catch { + // Server already stopped or never started. + } + + rmSync(PID_FILE, { force: true }) +} diff --git a/e2e/tests/components.spec.ts b/e2e/tests/components.spec.ts index bf4ac1e..c693053 100644 --- a/e2e/tests/components.spec.ts +++ b/e2e/tests/components.spec.ts @@ -87,3 +87,25 @@ test('zh show-more preserveMarkup should not add an extra collapsed line', async expect(markupHeight).toBe(plainHeight) }) + +test('style-aware preserveMarkup should not add an extra collapsed line', async ({ + page, +}) => { + await page.goto('/') + + const plain = page.locator( + '[data-testid="style-aware-plain"] [data-testid="show-more-root"]', + ) + const markup = page.locator( + '[data-testid="style-aware-markup"] [data-testid="show-more-root"]', + ) + + const plainHeight = await plain.evaluate( + (node) => node.getBoundingClientRect().height, + ) + const markupHeight = await markup.evaluate( + (node) => node.getBoundingClientRect().height, + ) + + expect(markupHeight).toBe(plainHeight) +}) diff --git a/e2e/tests/docs-pages.spec.ts b/e2e/tests/docs-pages.spec.ts new file mode 100644 index 0000000..af5e9f3 --- /dev/null +++ b/e2e/tests/docs-pages.spec.ts @@ -0,0 +1,109 @@ +import { expect, test } from '@playwright/test' + +const setRangeValue = async (page, testId: string, value: number) => { + await page.getByTestId(testId).evaluate((node, nextValue) => { + const input = node as HTMLInputElement + input.value = String(nextValue) + input.dispatchEvent(new Event('input', { bubbles: true })) + input.dispatchEvent(new Event('change', { bubbles: true })) + }, value) +} + +const setCheckbox = async (page, testId: string, checked: boolean) => { + const input = page.getByTestId(`${testId}-input`) + + if ((await input.isChecked()) !== checked) { + await page.getByTestId(testId).click() + } +} + +const getContentHeight = (page, testId: string) => { + return page.getByTestId(testId).evaluate((node) => { + return node.getBoundingClientRect().height + }) +} + +test('docs show-more demo preserves inline markup without extra lines in English', async ({ + page, +}) => { + await page.goto('/reference/show-more/') + + await setRangeValue(page, 'docs-show-more-demo-en-width', 60) + await setRangeValue(page, 'docs-show-more-demo-en-lines', 3) + await setCheckbox(page, 'docs-show-more-demo-en-html', true) + await setCheckbox(page, 'docs-show-more-demo-en-custom', false) + await setCheckbox(page, 'docs-show-more-demo-en-preserve-markup', false) + + const plainHeight = await getContentHeight( + page, + 'docs-show-more-demo-en-content', + ) + await expect( + page + .getByTestId('docs-show-more-demo-en-content') + .locator('[data-testid="docs-inline-rich-link-en"]'), + ).toHaveCount(0) + + await setCheckbox(page, 'docs-show-more-demo-en-preserve-markup', true) + + const markupHeight = await getContentHeight( + page, + 'docs-show-more-demo-en-content', + ) + + expect(markupHeight).toBeLessThanOrEqual(plainHeight) + + await expect( + page + .getByTestId('docs-show-more-demo-en-content') + .locator('[data-testid="docs-inline-rich-link-en"]'), + ).toHaveCount(1) + await expect( + page + .getByTestId('docs-show-more-demo-en-content') + .locator('[data-testid="docs-inline-rich-accent-en"]'), + ).toHaveCount(1) + + const demo = page.getByTestId('docs-show-more-demo-en') + + await demo.getByRole('link', { name: 'Expand' }).click() + await expect(demo.getByRole('link', { name: 'Collapse' })).toBeVisible() +}) + +test('docs show-more demo preserves inline markup without extra lines in Chinese', async ({ + page, +}) => { + await page.goto('/zh/reference/show-more/') + + await setRangeValue(page, 'docs-show-more-demo-zh-width', 60) + await setRangeValue(page, 'docs-show-more-demo-zh-lines', 3) + await setCheckbox(page, 'docs-show-more-demo-zh-html', true) + await setCheckbox(page, 'docs-show-more-demo-zh-custom', false) + await setCheckbox(page, 'docs-show-more-demo-zh-preserve-markup', false) + + const plainHeight = await getContentHeight( + page, + 'docs-show-more-demo-zh-content', + ) + await expect( + page + .getByTestId('docs-show-more-demo-zh-content') + .locator('[data-testid="docs-inline-rich-link-zh"]'), + ).toHaveCount(0) + + await setCheckbox(page, 'docs-show-more-demo-zh-preserve-markup', true) + + const markupHeight = await getContentHeight( + page, + 'docs-show-more-demo-zh-content', + ) + + expect(markupHeight).toBeLessThanOrEqual(plainHeight) + + const demo = page.getByTestId('docs-show-more-demo-zh') + + await demo.getByRole('link', { name: /(?:展开|Expand)/ }).click() + await expect( + demo.getByRole('link', { name: /(?:收起|Collapse)/ }), + ).toBeVisible() +}) diff --git a/eslint.config.js b/eslint.config.js index d7ffd2e..c43c060 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -33,6 +33,14 @@ export default defineFlatConfig([ ignores: ['**/dist/**', '**/lib/**', '**/legacy/**'], }, + { + name: getConfigName('docs'), + files: ['docs/**/*.{js,mjs,ts,tsx,astro}'], + rules: { + 'tailwindcss/no-custom-classname': 'off', + }, + }, + { name: getConfigName('test'), files: [ diff --git a/package.json b/package.json index db9e49e..3dfd1bd 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "test": "vitest", "test:run": "vitest run", "test:e2e": "playwright test", + "test:e2e:docs": "playwright test --config playwright.docs.config.ts", "test:e2e:ui": "playwright test --ui", "smoke:prepare": "node ./smoke/scripts/prepare-smoke-package.mjs", "test:smoke": "playwright test --config smoke/playwright.config.ts", diff --git a/playwright.docs.config.ts b/playwright.docs.config.ts new file mode 100644 index 0000000..e99eb8b --- /dev/null +++ b/playwright.docs.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: './e2e/tests', + testMatch: 'docs-pages.spec.ts', + globalSetup: './e2e/docs-global-setup.mjs', + globalTeardown: './e2e/docs-global-teardown.mjs', + use: { + baseURL: 'http://127.0.0.1:4175', + trace: 'on-first-retry', + viewport: { + width: 1280, + height: 960, + }, + }, +}) From 3c0b7620ff3487105220d99b99b791b1940fe556 Mon Sep 17 00:00:00 2001 From: chengpeiquan Date: Sun, 8 Mar 2026 22:30:30 +0800 Subject: [PATCH 5/9] feat(ci): reuse built docs output for docs e2e --- .github/workflows/github-ci.yml | 7 +++++-- e2e/docs-global-setup.mjs | 24 +++++++++++++++--------- e2e/docs-global-teardown.mjs | 1 + package.json | 2 ++ 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/.github/workflows/github-ci.yml b/.github/workflows/github-ci.yml index f77271c..556a851 100644 --- a/.github/workflows/github-ci.yml +++ b/.github/workflows/github-ci.yml @@ -34,8 +34,11 @@ jobs: - name: Run source e2e tests run: pnpm test:e2e - - name: Build library and docs - run: pnpm build + - name: Build docs E2E assets + run: pnpm build:docs:e2e + + - name: Run docs page e2e tests + run: pnpm test:e2e:docs:preview e2e/tests/docs-pages.spec.ts - name: Run coverage on main if: github.event_name == 'push' && github.ref == 'refs/heads/main' diff --git a/e2e/docs-global-setup.mjs b/e2e/docs-global-setup.mjs index 948f453..a8f18df 100644 --- a/e2e/docs-global-setup.mjs +++ b/e2e/docs-global-setup.mjs @@ -1,6 +1,7 @@ import { execSync, spawn } from 'node:child_process' import { readFileSync, rmSync, writeFileSync } from 'node:fs' import path from 'node:path' +import process from 'node:process' import { fileURLToPath } from 'node:url' const PORT = 4175 @@ -9,6 +10,7 @@ const PID_FILE = '/tmp/react-truncate-docs-preview.pid' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const repoRoot = path.resolve(__dirname, '..') const docsRoot = path.resolve(repoRoot, 'docs') +const skipBuild = process.env.SKIP_DOCS_E2E_BUILD === '1' const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) @@ -33,15 +35,17 @@ function stopExistingServer() { export default async function globalSetup() { stopExistingServer() - execSync('pnpm build:lib', { - cwd: repoRoot, - stdio: 'inherit', - }) + if (!skipBuild) { + execSync('pnpm build:lib', { + cwd: repoRoot, + stdio: 'inherit', + }) - execSync('pnpm -F docs build', { - cwd: repoRoot, - stdio: 'inherit', - }) + execSync('pnpm -F docs build', { + cwd: repoRoot, + stdio: 'inherit', + }) + } const child = spawn( 'pnpm', @@ -68,5 +72,7 @@ export default async function globalSetup() { } } - throw new Error(`Timed out waiting for the docs preview server on port ${PORT}`) + throw new Error( + `Timed out waiting for the docs preview server on port ${PORT}`, + ) } diff --git a/e2e/docs-global-teardown.mjs b/e2e/docs-global-teardown.mjs index ce2d0ec..11fb3cf 100644 --- a/e2e/docs-global-teardown.mjs +++ b/e2e/docs-global-teardown.mjs @@ -1,4 +1,5 @@ import { readFileSync, rmSync } from 'node:fs' +import process from 'node:process' const PID_FILE = '/tmp/react-truncate-docs-preview.pid' diff --git a/package.json b/package.json index 3dfd1bd..ec02edb 100644 --- a/package.json +++ b/package.json @@ -31,12 +31,14 @@ "build": "run-s build:*", "build:lib": "tsup", "build:docs": "pnpm -F docs build", + "build:docs:e2e": "run-s build:lib build:docs", "gen:changelog": "pnpm exec changelog", "gen:release": "pnpm exec release", "test": "vitest", "test:run": "vitest run", "test:e2e": "playwright test", "test:e2e:docs": "playwright test --config playwright.docs.config.ts", + "test:e2e:docs:preview": "cross-env SKIP_DOCS_E2E_BUILD=1 playwright test --config playwright.docs.config.ts", "test:e2e:ui": "playwright test --ui", "smoke:prepare": "node ./smoke/scripts/prepare-smoke-package.mjs", "test:smoke": "playwright test --config smoke/playwright.config.ts", From deeb666df35e3c458ce4f6acfde513063f5c2b2d Mon Sep 17 00:00:00 2001 From: chengpeiquan Date: Sun, 8 Mar 2026 22:38:11 +0800 Subject: [PATCH 6/9] feat(ci): reuse built docs output across verify and deploy --- .github/workflows/github-ci.yml | 37 +++++++++++++++++---------------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/.github/workflows/github-ci.yml b/.github/workflows/github-ci.yml index 556a851..43311c3 100644 --- a/.github/workflows/github-ci.yml +++ b/.github/workflows/github-ci.yml @@ -44,6 +44,21 @@ jobs: if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: pnpm coverage + - name: Upload coverage to Codecov + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: site-build + if-no-files-found: error + path: | + dist + docs/dist + smoke: if: github.event_name == 'push' && github.ref == 'refs/heads/main' needs: verify @@ -78,20 +93,11 @@ jobs: - name: Checkout uses: actions/checkout@v6 - - name: Setup pnpm - uses: pnpm/action-setup@v4 - - - name: Setup Node.js - uses: actions/setup-node@v6 + - name: Download build artifacts + uses: actions/download-artifact@v4 with: - node-version: 20 - cache: pnpm - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build library and docs - run: pnpm build + name: site-build + path: . - name: Deploy docs uses: crazy-max/ghaction-github-pages@v4 @@ -100,8 +106,3 @@ jobs: build_dir: docs/dist env: GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5 - with: - token: ${{ secrets.CODECOV_TOKEN }} From c061df62f41a09cbd9030793407bb1181a028055 Mon Sep 17 00:00:00 2001 From: chengpeiquan Date: Sun, 8 Mar 2026 22:46:00 +0800 Subject: [PATCH 7/9] release: v0.6.0 --- CHANGELOG.md | 15 +++++ commitlint.config.js | 20 +++++- docs/src/content/docs/reference/truncate.mdx | 62 +++++++++---------- .../content/docs/zh/reference/truncate.mdx | 32 ++++------ package.json | 4 +- src/Truncate/types.ts | 1 + 6 files changed, 76 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b239305..8e44ed2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +# [0.6.0](https://github.com/remanufacturing/react-truncate/compare/v0.5.2...v0.6.0) (2026-03-08) + + +### Bug Fixes + +* **truncate:** cover middle truncation edge cases ([2a4ce82](https://github.com/remanufacturing/react-truncate/commit/2a4ce82b27b4cd343a2b90c863a6412e47bcb78d)) + + +### Features + +* **Truncate:** add opt-in preserveMarkup rendering ([03b0b51](https://github.com/remanufacturing/react-truncate/commit/03b0b51fd788403eda6e513a2ddfe6c9599bb1f2)) +* **Truncate:** make preserveMarkup measurement style-aware ([3719968](https://github.com/remanufacturing/react-truncate/commit/3719968ed832c7ffa6604a53bdd74d409714e8d7)) + + + ## [0.5.2](https://github.com/remanufacturing/react-truncate/compare/v0.5.1...v0.5.2) (2025-08-19) diff --git a/commitlint.config.js b/commitlint.config.js index d179c69..849b125 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -1,3 +1,21 @@ +import defaultConfig from '@commitlint/config-conventional' +import { RuleConfigSeverity } from '@commitlint/types' + +const baseTypes = defaultConfig.rules['type-enum'][2] + export default { - extends: ['@commitlint/config-conventional'], + ...defaultConfig, + rules: { + ...defaultConfig.rules, + 'type-enum': [ + RuleConfigSeverity.Error, + 'always', + [ + ...baseTypes, + 'release', // Release new version + 'wip', // Work in Progress + 'deprecated', // Deprecated API + ], + ], + }, } diff --git a/docs/src/content/docs/reference/truncate.mdx b/docs/src/content/docs/reference/truncate.mdx index 9d719b0..a552482 100644 --- a/docs/src/content/docs/reference/truncate.mdx +++ b/docs/src/content/docs/reference/truncate.mdx @@ -86,25 +86,24 @@ export interface TruncateProps extends DetailedHTMLProps { ellipsis?: React.ReactNode /** - * Specifies how many lines of text should be preserved - * until it gets truncated. + * Specifies how many lines of text should be preserved until it gets + * truncated. * * 1. If not an safe integer, it will default to `0` * 2. If less than `0` , it will default to `0` * 3. If the value is `0` , it means not truncated * - * @description Option conflict considerations: - * When the `middle` option is enabled, this option will always be `1` - * - * @since v0.4.0 add safe and positive integer check + * Option conflict considerations: When the `middle` option is enabled, this + * option will always be `1` * + * @since V0.4.0 add safe and positive integer check * @default 1 */ lines?: number /** - * If `true` , whitespace will be removed from before the ellipsis - * e.g. `words …` will become `words…` instead + * If `true` , whitespace will be removed from before the ellipsis e.g. `words + * …` will become `words…` instead * * @default false */ @@ -113,29 +112,26 @@ export interface TruncateProps extends DetailedHTMLProps { /** * Specify the width of the outer element, * - * If specified, the calculation of the content - * will be based on this number. + * If specified, the calculation of the content will be based on this number. * - * If not specified, it will be obtained based on - * the component's `parentElement.getBoundingClientRect().width` + * If not specified, it will be obtained based on the component's + * `parentElement.getBoundingClientRect().width` */ width?: number /** * The separator for word segmentation * - * By default, text is assumed to use whitespace - * as a word segmentation convention (e.g. English), - * - * However, it may not be suitable for all languages. - * Different languages can specify other symbols - * according to usage habits. + * By default, text is assumed to use whitespace as a word segmentation + * convention (e.g. English), * - * For example, when it comes to Chinese content, - * you can pass in an empty string to get better calculation results. + * However, it may not be suitable for all languages. Different languages can + * specify other symbols according to usage habits. * - * @since v0.2.0 + * For example, when it comes to Chinese content, you can pass in an empty + * string to get better calculation results. * + * @since V0.2.0 * @default ' ' */ separator?: string @@ -143,11 +139,10 @@ export interface TruncateProps extends DetailedHTMLProps { /** * Whether to truncate in the middle * - * @description Option conflict considerations: - * When this option is enabled, the `lines` option will always be `1` - * - * @since v0.3.0 + * Option conflict considerations: When this option is enabled, the `lines` + * option will always be `1` * + * @since V0.3.0 * @default false */ middle?: boolean @@ -155,6 +150,7 @@ export interface TruncateProps extends DetailedHTMLProps { /** * Preserve rendered inline markup in collapsed output when possible * + * @since V0.6.0 * @default false */ preserveMarkup?: boolean @@ -162,18 +158,16 @@ export interface TruncateProps extends DetailedHTMLProps { /** * Number of characters to keep from the end of the text * - * Always rounded down via `Math.floor`, - * and always treated as a position relative to the end, - * regardless of positive or negative. - * - * But if the end position exceeds the text length, - * the truncation position will be processed at the end. + * Always rounded down via `Math.floor`, and always treated as a position + * relative to the end, regardless of positive or negative. * - * @description Option take effect considerations: - * This option will only take effect, when the `middle` option is enabled + * But if the end position exceeds the text length, the truncation position + * will be processed at the end. * - * @since v0.3.0 + * Option take effect considerations: This option will only take effect, when + * the `middle` option is enabled * + * @since V0.3.0 * @default 5 */ end?: number diff --git a/docs/src/content/docs/zh/reference/truncate.mdx b/docs/src/content/docs/zh/reference/truncate.mdx index 7b5f664..76d82e5 100644 --- a/docs/src/content/docs/zh/reference/truncate.mdx +++ b/docs/src/content/docs/zh/reference/truncate.mdx @@ -92,18 +92,15 @@ export interface TruncateProps extends DetailedHTMLProps { * 2. 如果小于 `0` ,则默认为 `0` * 3. 如果值为 `0` ,则表示不被截断 * - * @description 选项冲突注意事项: - * 当启用`middle`选项时,该选项将始终为 `1` - * - * @since v0.4.0 添加安全整数和正整数检查 + * 选项冲突注意事项: 当启用`middle`选项时,该选项将始终为 `1` * + * @since V0.4.0 添加安全整数和正整数检查 * @default 1 */ lines?: number /** - * 如果为 `true` ,省略号之前的空格将被删除 - * 例如: `words …` 将成为 `words…` 而不是 + * 如果为 `true` ,省略号之前的空格将被删除 例如: `words …` 将成为 `words…` 而不是 * * @default false */ @@ -114,8 +111,7 @@ export interface TruncateProps extends DetailedHTMLProps { * * 如果指定,则内容的计算将基于此数字。 * - * 不指定的情况下将根据组件的 - * `parentElement.getBoundingClientRect().width` 获取 + * 不指定的情况下将根据组件的 `parentElement.getBoundingClientRect().width` 获取 */ width?: number @@ -124,11 +120,9 @@ export interface TruncateProps extends DetailedHTMLProps { * * 默认情况下,假定文本使用空格作为分词约定(例如英语), * - * 但是不一定适合全部语言,不同的语言可以根据用语习惯指定其他符号, - * 例如中文内容时,可传入空字符串,从而得到更好的计算结果。 - * - * @since v0.2.0 + * 但是不一定适合全部语言,不同的语言可以根据用语习惯指定其他符号, 例如中文内容时,可传入空字符串,从而得到更好的计算结果。 * + * @since V0.2.0 * @default ' ' */ separator?: string @@ -136,11 +130,9 @@ export interface TruncateProps extends DetailedHTMLProps { /** * 启用在中间位置截断 * - * @description 选项冲突注意事项: - * 当启用此选项时,`lines` 选项将始终为 `1` - * - * @since v0.3.0 + * 选项冲突注意事项: 当启用此选项时,`lines` 选项将始终为 `1` * + * @since V0.3.0 * @default false */ middle?: boolean @@ -148,6 +140,7 @@ export interface TruncateProps extends DetailedHTMLProps { /** * 尽可能在折叠态保留已渲染的内联标记结构 * + * @since V0.6.0 * @default false */ preserveMarkup?: boolean @@ -159,11 +152,9 @@ export interface TruncateProps extends DetailedHTMLProps { * * 如果结束位置超出了文本长度时,截断位置会被处理在末尾。 * - * @description 选项生效注意事项: - * 该选项仅在 `middle` 选项启用时才会生效 - * - * @since v0.3.0 + * 选项生效注意事项: 该选项仅在 `middle` 选项启用时才会生效 * + * @since V0.3.0 * @default 5 */ end?: number @@ -172,7 +163,6 @@ export interface TruncateProps extends DetailedHTMLProps { * 触发截断行为时的回调函数 * * @param didTruncate - 是否发生截断 - * */ onTruncate?: (didTruncate: boolean) => void } diff --git a/package.json b/package.json index ec02edb..570aee0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@re-dev/react-truncate", - "version": "0.5.2", + "version": "0.6.0", "description": "Provides `Truncate`, `MiddleTruncate` and `ShowMore` React components for truncating multi-line spans and adding an ellipsis.", "author": "chengpeiquan ", "license": "MIT", @@ -112,4 +112,4 @@ "eslint --fix" ] } -} +} \ No newline at end of file diff --git a/src/Truncate/types.ts b/src/Truncate/types.ts index bddede7..0b42a37 100644 --- a/src/Truncate/types.ts +++ b/src/Truncate/types.ts @@ -86,6 +86,7 @@ export interface TruncateProps extends DetailedHTMLProps { * This option is opt-in because it performs more work than the default * plain-text truncation path. * + * @since V0.6.0 * @default false */ preserveMarkup?: boolean From 6479848e1eacfd64668104b328d51d1ff5285621 Mon Sep 17 00:00:00 2001 From: chengpeiquan Date: Sun, 8 Mar 2026 22:58:19 +0800 Subject: [PATCH 8/9] fix(ci): isolate docs e2e suite --- ...3-08-e2e-suite-isolation-implementation.md | 57 +++++++++++++++++++ e2e/{tests => docs}/docs-pages.spec.ts | 0 playwright.docs.config.ts | 3 +- 3 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 docs/plans/2026-03-08-e2e-suite-isolation-implementation.md rename e2e/{tests => docs}/docs-pages.spec.ts (100%) diff --git a/docs/plans/2026-03-08-e2e-suite-isolation-implementation.md b/docs/plans/2026-03-08-e2e-suite-isolation-implementation.md new file mode 100644 index 0000000..440fe9c --- /dev/null +++ b/docs/plans/2026-03-08-e2e-suite-isolation-implementation.md @@ -0,0 +1,57 @@ +# E2E Suite Isolation Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Prevent docs-only Playwright specs from ever being picked up by the main source E2E suite again. + +**Architecture:** Split Playwright specs by responsibility at the filesystem level. Keep source-app browser tests under `e2e/tests` for the main Vite-backed config, and move docs-page browser tests into `e2e/docs` for the Astro-preview-backed config. This removes suite overlap without depending on `testIgnore` filters. + +**Tech Stack:** Playwright, pnpm, Astro preview, Vite + +--- + +### Task 1: Separate docs specs from source specs + +**Files:** +- Create: `e2e/docs/docs-pages.spec.ts` +- Modify: `playwright.docs.config.ts` +- Modify: `playwright.config.ts` +- Remove: `e2e/tests/docs-pages.spec.ts` + +**Step 1: Write the failing expectation** + +Define the intended suite boundaries: + +```bash +pnpm test:e2e --list +pnpm test:e2e:docs:preview --list +``` + +The first should list only source-app tests. The second should list only docs-page tests. + +**Step 2: Run commands to show current overlap risk** + +Run: `pnpm test:e2e --list` +Expected: PASS but currently relies on `testIgnore`, proving the suite boundary is config-based instead of directory-based. + +**Step 3: Write minimal implementation** + +- Move `docs-pages.spec.ts` into `e2e/docs/` +- Point `playwright.docs.config.ts` at `./e2e/docs` +- Remove the temporary `testIgnore` from `playwright.config.ts` + +**Step 4: Run commands to verify the split** + +Run: `pnpm test:e2e --list` +Expected: PASS and list only `components.spec.ts` + +Run: `pnpm test:e2e:docs:preview e2e/docs/docs-pages.spec.ts --workers=1` +Expected: PASS and run only the 2 docs-page tests + +**Step 5: Commit** + +```bash +git add playwright.config.ts playwright.docs.config.ts e2e/docs/docs-pages.spec.ts +git rm e2e/tests/docs-pages.spec.ts +git commit -m "test: isolate docs e2e suite" +``` diff --git a/e2e/tests/docs-pages.spec.ts b/e2e/docs/docs-pages.spec.ts similarity index 100% rename from e2e/tests/docs-pages.spec.ts rename to e2e/docs/docs-pages.spec.ts diff --git a/playwright.docs.config.ts b/playwright.docs.config.ts index e99eb8b..0cf13d4 100644 --- a/playwright.docs.config.ts +++ b/playwright.docs.config.ts @@ -1,8 +1,7 @@ import { defineConfig } from '@playwright/test' export default defineConfig({ - testDir: './e2e/tests', - testMatch: 'docs-pages.spec.ts', + testDir: './e2e/docs', globalSetup: './e2e/docs-global-setup.mjs', globalTeardown: './e2e/docs-global-teardown.mjs', use: { From c3e4ec309468a986438ddd99b61859a04ae3a843 Mon Sep 17 00:00:00 2001 From: chengpeiquan Date: Sun, 8 Mar 2026 23:16:29 +0800 Subject: [PATCH 9/9] fix(ci): fix docs e2e test path --- .github/workflows/github-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/github-ci.yml b/.github/workflows/github-ci.yml index 43311c3..33b2de8 100644 --- a/.github/workflows/github-ci.yml +++ b/.github/workflows/github-ci.yml @@ -38,7 +38,7 @@ jobs: run: pnpm build:docs:e2e - name: Run docs page e2e tests - run: pnpm test:e2e:docs:preview e2e/tests/docs-pages.spec.ts + run: pnpm test:e2e:docs:preview - name: Run coverage on main if: github.event_name == 'push' && github.ref == 'refs/heads/main'