diff --git a/.github/workflows/github-ci.yml b/.github/workflows/github-ci.yml
index f77271c..33b2de8 100644
--- a/.github/workflows/github-ci.yml
+++ b/.github/workflows/github-ci.yml
@@ -34,13 +34,31 @@ 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
- name: Run coverage on main
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
@@ -75,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
@@ -97,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 }}
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/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/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/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/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/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..0abf9bb
--- /dev/null
+++ b/docs/plans/2026-03-08-preserve-markup-design.md
@@ -0,0 +1,240 @@
+# Preserve Markup Truncation Design
+
+**Date:** 2026-03-08
+
+**Problem Statement**
+
+`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.
+
+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
+- 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 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 force `MiddleTruncate` onto the new measurement path in this iteration
+
+## Public API Direction
+
+Keep the existing public components unchanged and continue to expose a single opt-in prop on `Truncate`:
+
+```ts
+interface TruncateProps {
+ preserveMarkup?: boolean
+}
+```
+
+Recommended behavior:
+
+- `preserveMarkup` defaults to `false`
+- `false` keeps the current plain-text engine unchanged
+- `true` enables a markup-preserving, style-aware measurement engine
+- `ShowMore` transparently forwards `preserveMarkup`
+- `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
+
+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.
+
+That means the root cause sits in the measurement layer, not the render layer.
+
+## Recommended Architecture
+
+`Truncate` keeps two internal engines:
+
+1. **Plain-text engine**
+ - default path
+ - current measurement behavior
+ - optimized for performance
+
+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:
+
+- text nodes
+- `a`
+- `span`
+- `strong`
+- `em`
+- `code`
+- inline classes
+- inline styles
+- components such as `linkify-react` that finally render standard inline DOM nodes
+
+### Phase 1 limitations
+
+- no guarantee for block elements
+- 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 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 a separate markup-specific layout algorithm
+
+### `MiddleTruncate`
+
+- remains on the existing path for now
+- can adopt the snapshot primitives later in a dedicated phase
+
+## Performance Strategy
+
+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
+- 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 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. 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
+
+- 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
+
+## Decision Summary
+
+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-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-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/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..2623d1e 100644
--- a/docs/src/components/examples/Data.tsx
+++ b/docs/src/components/examples/Data.tsx
@@ -159,6 +159,47 @@ 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 +248,44 @@ 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/Widgets.tsx b/docs/src/components/examples/Widgets.tsx
index 8910bb7..a7d424f 100644
--- a/docs/src/components/examples/Widgets.tsx
+++ b/docs/src/components/examples/Widgets.tsx
@@ -74,7 +74,9 @@ type SharedItemProps = Omit<
InputProps,
'type' | 'className' | 'onChange' | 'lang'
> &
- 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 (
-