From 7a7846f3b702016fdc8ac1419018e862b8a07dc8 Mon Sep 17 00:00:00 2001 From: "superdoc-bot[bot]" <235763992+superdoc-bot[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:20:19 -0700 Subject: [PATCH 01/30] chore: merge stable into main (#2858) Co-authored-by: github-actions[bot] --- packages/react/package.json | 2 +- packages/sdk/langs/node/package.json | 2 +- .../node/platforms/sdk-darwin-arm64/package.json | 2 +- .../langs/node/platforms/sdk-darwin-x64/package.json | 2 +- .../node/platforms/sdk-linux-arm64/package.json | 2 +- .../langs/node/platforms/sdk-linux-x64/package.json | 2 +- .../node/platforms/sdk-windows-x64/package.json | 2 +- .../superdoc-sdk-cli-darwin-arm64/pyproject.toml | 2 +- .../superdoc-sdk-cli-darwin-x64/pyproject.toml | 2 +- .../superdoc-sdk-cli-linux-arm64/pyproject.toml | 2 +- .../superdoc-sdk-cli-linux-x64/pyproject.toml | 2 +- .../superdoc-sdk-cli-windows-x64/pyproject.toml | 2 +- packages/sdk/langs/python/pyproject.toml | 12 ++++++------ packages/sdk/package.json | 2 +- packages/sdk/version.json | 2 +- 15 files changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/react/package.json b/packages/react/package.json index 7ba671eab5..5acbc31285 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/react", - "version": "1.1.0", + "version": "1.1.1", "description": "Official React wrapper for the SuperDoc document editor", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/sdk/langs/node/package.json b/packages/sdk/langs/node/package.json index 911a8ca077..046396078a 100644 --- a/packages/sdk/langs/node/package.json +++ b/packages/sdk/langs/node/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/sdk", - "version": "1.5.0", + "version": "1.5.1", "private": false, "type": "module", "main": "./dist/index.cjs", diff --git a/packages/sdk/langs/node/platforms/sdk-darwin-arm64/package.json b/packages/sdk/langs/node/platforms/sdk-darwin-arm64/package.json index 146ddbcd79..7589fa83f4 100644 --- a/packages/sdk/langs/node/platforms/sdk-darwin-arm64/package.json +++ b/packages/sdk/langs/node/platforms/sdk-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/sdk-darwin-arm64", - "version": "1.5.0", + "version": "1.5.1", "os": [ "darwin" ], diff --git a/packages/sdk/langs/node/platforms/sdk-darwin-x64/package.json b/packages/sdk/langs/node/platforms/sdk-darwin-x64/package.json index 8fa05f369c..be9d9ba03f 100644 --- a/packages/sdk/langs/node/platforms/sdk-darwin-x64/package.json +++ b/packages/sdk/langs/node/platforms/sdk-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/sdk-darwin-x64", - "version": "1.5.0", + "version": "1.5.1", "os": [ "darwin" ], diff --git a/packages/sdk/langs/node/platforms/sdk-linux-arm64/package.json b/packages/sdk/langs/node/platforms/sdk-linux-arm64/package.json index a5ca06d8e9..f68cd34374 100644 --- a/packages/sdk/langs/node/platforms/sdk-linux-arm64/package.json +++ b/packages/sdk/langs/node/platforms/sdk-linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/sdk-linux-arm64", - "version": "1.5.0", + "version": "1.5.1", "os": [ "linux" ], diff --git a/packages/sdk/langs/node/platforms/sdk-linux-x64/package.json b/packages/sdk/langs/node/platforms/sdk-linux-x64/package.json index 684a79d618..c4884081f0 100644 --- a/packages/sdk/langs/node/platforms/sdk-linux-x64/package.json +++ b/packages/sdk/langs/node/platforms/sdk-linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/sdk-linux-x64", - "version": "1.5.0", + "version": "1.5.1", "os": [ "linux" ], diff --git a/packages/sdk/langs/node/platforms/sdk-windows-x64/package.json b/packages/sdk/langs/node/platforms/sdk-windows-x64/package.json index f12af5bd88..d41318a4e2 100644 --- a/packages/sdk/langs/node/platforms/sdk-windows-x64/package.json +++ b/packages/sdk/langs/node/platforms/sdk-windows-x64/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/sdk-windows-x64", - "version": "1.5.0", + "version": "1.5.1", "os": [ "win32" ], diff --git a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-arm64/pyproject.toml b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-arm64/pyproject.toml index f29f199050..620acbfa69 100644 --- a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-arm64/pyproject.toml +++ b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-arm64/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "superdoc-sdk-cli-darwin-arm64" -version = "1.5.0" +version = "1.5.1" description = "SuperDoc CLI binary for macOS ARM64 (Apple Silicon)" readme = "README.md" requires-python = ">=3.9" diff --git a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-x64/pyproject.toml b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-x64/pyproject.toml index 746d52fe59..7b979a794a 100644 --- a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-x64/pyproject.toml +++ b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-x64/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "superdoc-sdk-cli-darwin-x64" -version = "1.5.0" +version = "1.5.1" description = "SuperDoc CLI binary for macOS x64 (Intel)" readme = "README.md" requires-python = ">=3.9" diff --git a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-arm64/pyproject.toml b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-arm64/pyproject.toml index f197775322..c316b88715 100644 --- a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-arm64/pyproject.toml +++ b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-arm64/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "superdoc-sdk-cli-linux-arm64" -version = "1.5.0" +version = "1.5.1" description = "SuperDoc CLI binary for Linux ARM64" readme = "README.md" requires-python = ">=3.9" diff --git a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-x64/pyproject.toml b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-x64/pyproject.toml index 59d3ca3711..7e76dbc64a 100644 --- a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-x64/pyproject.toml +++ b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-x64/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "superdoc-sdk-cli-linux-x64" -version = "1.5.0" +version = "1.5.1" description = "SuperDoc CLI binary for Linux x64" readme = "README.md" requires-python = ">=3.9" diff --git a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-windows-x64/pyproject.toml b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-windows-x64/pyproject.toml index b68a0bd7a6..14af847e96 100644 --- a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-windows-x64/pyproject.toml +++ b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-windows-x64/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "superdoc-sdk-cli-windows-x64" -version = "1.5.0" +version = "1.5.1" description = "SuperDoc CLI binary for Windows x64" readme = "README.md" requires-python = ">=3.9" diff --git a/packages/sdk/langs/python/pyproject.toml b/packages/sdk/langs/python/pyproject.toml index a23b41efa2..c375cb3141 100644 --- a/packages/sdk/langs/python/pyproject.toml +++ b/packages/sdk/langs/python/pyproject.toml @@ -4,18 +4,18 @@ build-backend = "setuptools.build_meta" [project] name = "superdoc-sdk" -version = "1.5.0" +version = "1.5.1" description = "SuperDoc SDK (CLI-backed)" readme = "README.md" requires-python = ">=3.9" license = "AGPL-3.0" authors = [{ name = "SuperDoc" }] dependencies = [ - "superdoc-sdk-cli-darwin-arm64==1.5.0; platform_system == 'Darwin' and (platform_machine == 'arm64' or platform_machine == 'aarch64' or platform_machine == 'ARM64')", - "superdoc-sdk-cli-darwin-x64==1.5.0; platform_system == 'Darwin' and (platform_machine == 'x86_64' or platform_machine == 'AMD64' or platform_machine == 'amd64')", - "superdoc-sdk-cli-linux-x64==1.5.0; platform_system == 'Linux' and (platform_machine == 'x86_64' or platform_machine == 'AMD64' or platform_machine == 'amd64')", - "superdoc-sdk-cli-linux-arm64==1.5.0; platform_system == 'Linux' and (platform_machine == 'arm64' or platform_machine == 'aarch64' or platform_machine == 'ARM64')", - "superdoc-sdk-cli-windows-x64==1.5.0; platform_system == 'Windows' and (platform_machine == 'x86_64' or platform_machine == 'AMD64' or platform_machine == 'amd64')", + "superdoc-sdk-cli-darwin-arm64==1.5.1; platform_system == 'Darwin' and (platform_machine == 'arm64' or platform_machine == 'aarch64' or platform_machine == 'ARM64')", + "superdoc-sdk-cli-darwin-x64==1.5.1; platform_system == 'Darwin' and (platform_machine == 'x86_64' or platform_machine == 'AMD64' or platform_machine == 'amd64')", + "superdoc-sdk-cli-linux-x64==1.5.1; platform_system == 'Linux' and (platform_machine == 'x86_64' or platform_machine == 'AMD64' or platform_machine == 'amd64')", + "superdoc-sdk-cli-linux-arm64==1.5.1; platform_system == 'Linux' and (platform_machine == 'arm64' or platform_machine == 'aarch64' or platform_machine == 'ARM64')", + "superdoc-sdk-cli-windows-x64==1.5.1; platform_system == 'Windows' and (platform_machine == 'x86_64' or platform_machine == 'AMD64' or platform_machine == 'amd64')", ] [tool.setuptools] diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 3f1e17c3c4..45c3fb031a 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/sdk-workspace", - "version": "1.5.0", + "version": "1.5.1", "private": true, "type": "module", "scripts": { diff --git a/packages/sdk/version.json b/packages/sdk/version.json index 803a90e49e..a9e7259f65 100644 --- a/packages/sdk/version.json +++ b/packages/sdk/version.json @@ -1,3 +1,3 @@ { - "sdkVersion": "1.5.0" + "sdkVersion": "1.5.1" } From 8b1da54a062ee93b758c14d5bb92850507ec5f90 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 18 Apr 2026 13:52:12 -0300 Subject: [PATCH 02/30] docs(pm-adapter): note why w:bar is not normalized Word does not render w:bar borders on display (only preserves them on save). SuperDoc currently matches that behavior by omitting 'bar' from the normalized sides list. Add a note at the source of truth so future contributors don't re-add it without a real use case. --- packages/layout-engine/pm-adapter/src/attributes/borders.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/layout-engine/pm-adapter/src/attributes/borders.ts b/packages/layout-engine/pm-adapter/src/attributes/borders.ts index c1a78ba32f..3977700118 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/borders.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/borders.ts @@ -324,6 +324,11 @@ export function extractCellPadding(cellAttrs: Record): BoxSpaci export const normalizeParagraphBorders = (value: unknown): ParagraphAttrs['borders'] | undefined => { if (!value || typeof value !== 'object') return undefined; const source = value as Record; + // Note: w:bar is intentionally not in this list. We tested in Word and it + // never draws w:bar on screen — it just keeps the value in the file when + // saving. The spec lets apps skip it, and Word does. SuperDoc does too, by + // default. If you have a real document where bar needs to be drawn, open + // an issue with the use case before adding 'bar' here. const sides: Array<'top' | 'right' | 'bottom' | 'left' | 'between'> = ['top', 'right', 'bottom', 'left', 'between']; const borders: ParagraphAttrs['borders'] = {}; From 68dcff21a7357700ad414bd1a1c2d5930d90d622 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Vitor=20Verona=20Biazibetti?= Date: Sat, 18 Apr 2026 15:40:39 -0300 Subject: [PATCH 03/30] feat: support rendering of column line separators (#2088) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: support column line separators - Extracts 'w:sep' tag according to OOXML spec - Renders one separator for each page column ('w:col' / 'w:num') in DOM painter Closes #2067 * test: add docx file with line separator between columns enabled * fix: rendering color of separators * fix: reuse ColumnLayout and SINGLE_COLUMN_DEFAULT * fix: ensure column width larger than separator width * chore: lint * chore: fix imports and missing SINGLE_COLUMN_DEFAULT usage * chore: reuse type * fix: render column separators per region on pages with continuous breaks Two related fixes so the new column-line-separator feature works correctly on pages where a continuous section break changes column layout mid-page. 1. isColumnConfigChanging now compares withSeparator. Before, a sep-only toggle (count+gap unchanged) returned false, so no mid-page region was created and the toggle was silently dropped. Applied in both section-breaks.ts and the inline fallback in index.ts. 2. constraintBoundaries captured during layout are serialized onto a new page.columnRegions contract field. renderColumnSeparators now iterates regions and draws each separator bounded by its yStart/yEnd instead of painting a single full-page overlay. When no mid-page change occurs, columnRegions is omitted and the renderer falls back to page.columns (unchanged behavior). Verified by loading a fixture with 7 scenarios (2-col, 3-col, unequal widths, separator on/off, continuous breaks toggling the separator). Pages now show per-region separators tiled correctly; a 3-col region followed by a 2-col region no longer paints a shared full-page line. Out of scope here, tracked for follow-up: widths/equalWidth are still dropped at pm-adapter extractColumns, so unequal-width separators render at the equal-width midpoint; body-level w:sep is dropped at v2 docxImporter; there is no w:sep export. * test: cover DomPainter renderColumnSeparators 13 unit tests over the separator renderer. Splits coverage into the fallback path (page.columns only) and the region-aware path (page.columnRegions). Fallback path: pins the 2-col and 3-col geometry, and each early-return guard (withSeparator false/undefined, single column, missing margins, no columns at all, pathologically-small columnWidth). Region path: verifies per-region yStart/yEnd bounding, mixed regions (some draw, some skip for withSeparator=false or count<=1), zero-height regions, and that columnRegions wins when both it and page.columns are present. Previously renderColumnSeparators had zero DOM-level coverage — the region-aware refactor in the prior commit relied entirely on layout-engine tests that never exercised the DOM output. --------- Co-authored-by: Caio Pizzol --- .../contracts/src/column-layout.ts | 3 + packages/layout-engine/contracts/src/index.ts | 46 +++- .../layout-bridge/src/incrementalLayout.ts | 5 +- .../layout-engine/src/index.test.ts | 94 ++++++++ .../layout-engine/layout-engine/src/index.ts | 55 ++++- .../layout-engine/src/section-breaks.d.ts | 15 +- .../layout-engine/src/section-breaks.test.ts | 38 ++++ .../layout-engine/src/section-breaks.ts | 17 +- .../layout-engine/src/section-props.test.ts | 32 +++ .../layout-engine/src/section-props.ts | 12 +- .../src/renderer-column-separators.test.ts | 215 ++++++++++++++++++ .../painters/dom/src/renderer.ts | 58 +++++ .../pm-adapter/src/index.test.ts | 9 +- .../pm-adapter/src/sections/analysis.test.ts | 30 ++- .../src/sections/extraction.test.ts | 125 ++++++++++ .../pm-adapter/src/sections/extraction.ts | 20 +- .../pm-adapter/src/sections/index.ts | 2 +- .../pm-adapter/src/sections/types.ts | 6 +- .../layout/LayoutOptionParsing.ts | 12 +- tests/visual/columns-with-line-separator.docx | Bin 0 -> 7760 bytes 20 files changed, 748 insertions(+), 46 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/renderer-column-separators.test.ts create mode 100644 tests/visual/columns-with-line-separator.docx diff --git a/packages/layout-engine/contracts/src/column-layout.ts b/packages/layout-engine/contracts/src/column-layout.ts index e63e4e95d2..7dc794c592 100644 --- a/packages/layout-engine/contracts/src/column-layout.ts +++ b/packages/layout-engine/contracts/src/column-layout.ts @@ -19,6 +19,7 @@ export function cloneColumnLayout(columns?: ColumnLayout): ColumnLayout { gap: columns.gap, ...(Array.isArray(columns.widths) ? { widths: [...columns.widths] } : {}), ...(columns.equalWidth !== undefined ? { equalWidth: columns.equalWidth } : {}), + ...(columns.withSeparator !== undefined ? { withSeparator: columns.withSeparator } : {}), } : { count: 1, gap: 0 }; } @@ -62,6 +63,7 @@ export function normalizeColumnLayout( count: 1, gap: 0, width: Math.max(0, contentWidth), + ...(input?.withSeparator !== undefined ? { withSeparator: input.withSeparator } : {}), }; } @@ -70,6 +72,7 @@ export function normalizeColumnLayout( gap, ...(widths.length > 0 ? { widths } : {}), ...(input?.equalWidth !== undefined ? { equalWidth: input.equalWidth } : {}), + ...(input?.withSeparator !== undefined ? { withSeparator: input.withSeparator } : {}), width, }; } diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 69ca4dc0cc..4cc339e09c 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -987,10 +987,7 @@ export type SectionBreakBlock = { even?: string; odd?: string; }; - columns?: { - count: number; - gap: number; - widths?: number[]; + columns?: ColumnLayout & { equalWidth?: boolean; }; /** @@ -1478,10 +1475,28 @@ export type FlowBlock = export type ColumnLayout = { count: number; gap: number; + withSeparator?: boolean; widths?: number[]; equalWidth?: boolean; }; +/** + * A vertical region of a page that shares a single column configuration. + * + * Continuous section breaks can introduce multiple column configurations on the + * same page (see ECMA-376 §17.6.22 and §17.18.77). A page may therefore carry + * multiple regions stacked vertically. Consumers (e.g. DomPainter) use + * `yStart`/`yEnd` to bound any per-region overlays such as column separators. + */ +export type ColumnRegion = { + /** Inclusive top of the region, in pixels from the page top. */ + yStart: number; + /** Exclusive bottom of the region, in pixels from the page top. */ + yEnd: number; + /** Column configuration active within this region. */ + columns: ColumnLayout; +}; + /** A measured line within a block, output by the measurer. */ export type Line = { fromRun: number; @@ -1706,6 +1721,29 @@ export type Page = { * Sections are 0-indexed, matching the sectionIndex in SectionMetadata. */ sectionIndex?: number; + /** + * Column layout configuration for this page. + * + * Reflects the column configuration at page start. For pages with continuous + * section breaks that change column layout mid-page, use `columnRegions` for + * accurate per-region information. + * + * Used by the renderer to draw column separator lines when `withSeparator` + * is set to true. + */ + columns?: ColumnLayout; + /** + * Vertical column regions on this page, ordered top to bottom. + * + * Populated when continuous section breaks change column layout mid-page. Each + * region pairs a `{yStart, yEnd}` span with the column config active inside it + * (see ECMA-376 §17.6.22). Renderers should prefer this field over + * `columns` when drawing per-region overlays (e.g. column separators). + * + * If omitted, the page has a single column region and consumers can fall back + * to `columns`. + */ + columnRegions?: ColumnRegion[]; }; /** A paragraph fragment positioned on a page. */ diff --git a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts index 4f82c339fc..cafffe5c72 100644 --- a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts +++ b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts @@ -19,6 +19,7 @@ import { resolvePageNumberTokens, type NumberingContext, SEMANTIC_PAGE_HEIGHT_PX, + SINGLE_COLUMN_DEFAULT, } from '@superdoc/layout-engine'; import { remeasureParagraph } from './remeasure'; import { computeDirtyRegions } from './diff'; @@ -183,7 +184,7 @@ const resolvePageColumns = (layout: Layout, options: LayoutOptions, blocks?: Flo ); const contentWidth = pageSize.w - (marginLeft + marginRight); const sectionIndex = page.sectionIndex ?? 0; - const columnsConfig = sectionColumns.get(sectionIndex) ?? options.columns ?? { count: 1, gap: 0 }; + const columnsConfig = sectionColumns.get(sectionIndex) ?? options.columns ?? SINGLE_COLUMN_DEFAULT; const normalized = normalizeColumnsForFootnotes(columnsConfig, contentWidth); result.set(pageIndex, { ...normalized, left: marginLeft, contentWidth }); } @@ -1503,7 +1504,7 @@ export async function incrementalLayout( ); const pageContentWidth = pageSize.w - (marginLeft + marginRight); const fallbackColumns = normalizeColumnsForFootnotes( - options.columns ?? { count: 1, gap: 0 }, + options.columns ?? SINGLE_COLUMN_DEFAULT, pageContentWidth, ); const columns = pageColumns.get(pageIndex) ?? { diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index ab883680e2..e080a72196 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -228,6 +228,100 @@ describe('layoutDocument', () => { expect(layout.columns).toMatchObject({ count: 2, gap: 20 }); }); + it('sets "page.columns" with separator when column separator is enabled', () => { + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 40, right: 40, bottom: 40, left: 40 }, + columns: { count: 2, gap: 20, withSeparator: true }, + }; + const layout = layoutDocument([block], [makeMeasure([350, 350, 350])], options); + + expect(layout.pages).toHaveLength(1); + expect(layout.pages[0].columns).toEqual({ count: 2, gap: 20, withSeparator: true }); + expect(layout.columns).toMatchObject({ count: 2, gap: 20, withSeparator: true }); + }); + + it('does not set "page.columns" on single column layout', () => { + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 40, right: 40, bottom: 40, left: 40 }, + }; + const layout = layoutDocument([block], [makeMeasure([350])], options); + + expect(layout.pages).toHaveLength(1); + expect(layout.pages[0].columns).toBeUndefined(); + expect(layout.columns).toBeUndefined(); + }); + + it('sets "page.columns" without separator when column separator is not enabled', () => { + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 40, right: 40, bottom: 40, left: 40 }, + columns: { count: 2, gap: 20, withSeparator: false }, + }; + const layout = layoutDocument([block], [makeMeasure([350, 350, 350])], options); + + expect(layout.pages).toHaveLength(1); + expect(layout.pages[0].columns).toEqual({ count: 2, gap: 20, withSeparator: false }); + expect(layout.columns).toEqual({ count: 2, gap: 20, withSeparator: false }); + }); + + it('emits page.columnRegions for continuous section breaks that change column config mid-page', () => { + // Two sections on the same page: first 2-col with separator, then a + // continuous break that switches to 3-col still with separator. The + // layout engine should record a ConstraintBoundary and surface it on + // page.columnRegions so the renderer can bound each separator to the + // correct Y range. + const blocks: FlowBlock[] = [ + { kind: 'paragraph', id: 'intro', runs: [] }, + { + kind: 'sectionBreak', + id: 'sb-continuous', + type: 'continuous', + columns: { count: 3, gap: 20, withSeparator: true }, + }, + { kind: 'paragraph', id: 'body', runs: [] }, + ]; + const measures: Measure[] = [makeMeasure([30]), { kind: 'sectionBreak' }, makeMeasure([30, 30, 30])]; + + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 40, right: 40, bottom: 40, left: 40 }, + columns: { count: 2, gap: 20, withSeparator: true }, + }; + + const layout = layoutDocument(blocks, measures, options); + + expect(layout.pages).toHaveLength(1); + const regions = layout.pages[0].columnRegions; + expect(regions).toBeDefined(); + expect(regions!.length).toBeGreaterThanOrEqual(2); + // First region covers the initial 2-col layout from topMargin to the boundary. + expect(regions![0].yStart).toBe(40); + expect(regions![0].columns).toEqual({ count: 2, gap: 20, withSeparator: true }); + // Second region picks up the continuous break's 3-col config and ends at + // the bottom of the content area. + const last = regions![regions!.length - 1]; + expect(last.columns).toMatchObject({ count: 3, gap: 20, withSeparator: true }); + expect(last.yEnd).toBe(800 - 40); + // Regions must tile (no gaps, no overlap). + for (let i = 1; i < regions!.length; i++) { + expect(regions![i].yStart).toBe(regions![i - 1].yEnd); + } + }); + + it('omits page.columnRegions when no mid-page column change occurs', () => { + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 40, right: 40, bottom: 40, left: 40 }, + columns: { count: 2, gap: 20, withSeparator: true }, + }; + const layout = layoutDocument([block], [makeMeasure([350, 350, 350])], options); + + expect(layout.pages).toHaveLength(1); + expect(layout.pages[0].columnRegions).toBeUndefined(); + }); + it('applies spacing before and after paragraphs', () => { const spacingBlock: FlowBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index f2a384ee37..8729e109d1 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -1,5 +1,6 @@ import type { ColumnLayout, + ColumnRegion, FlowBlock, Fragment, HeaderFooterLayout, @@ -35,6 +36,7 @@ import { scheduleSectionBreak as scheduleSectionBreakExport, type SectionState, applyPendingToActive, + SINGLE_COLUMN_DEFAULT, } from './section-breaks.js'; import { layoutParagraphBlock } from './layout-paragraph.js'; import { layoutImageBlock } from './layout-image.js'; @@ -1001,14 +1003,18 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options if (block.orientation) next.pendingOrientation = block.orientation; const sectionType = block.type ?? 'continuous'; // Check if columns are changing: either explicitly to a different config, - // or implicitly resetting to single column (undefined = single column in OOXML) + // or implicitly resetting to single column (undefined = single column in OOXML). + // withSeparator must be compared because a sep-only toggle still needs a new + // column region so the renderer can draw (or stop drawing) the separator from + // the toggle point onward. const isColumnsChanging = (block.columns && (block.columns.count !== next.activeColumns.count || block.columns.gap !== next.activeColumns.gap || + Boolean(block.columns.withSeparator) !== Boolean(next.activeColumns.withSeparator) || block.columns.equalWidth !== next.activeColumns.equalWidth || !widthsEqual(block.columns.widths, next.activeColumns.widths))) || - (!block.columns && next.activeColumns.count > 1); + (!block.columns && (next.activeColumns.count > 1 || Boolean(next.activeColumns.withSeparator))); // Schedule section index change for next page (enables section-aware page numbering) const sectionIndexRaw = block.attrs?.sectionIndex; const metadataIndex = typeof sectionIndexRaw === 'number' ? sectionIndexRaw : Number(sectionIndexRaw ?? NaN); @@ -1074,6 +1080,11 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options if (activeOrientation) { page.orientation = activeOrientation; } + + if (activeColumns.count > 1) { + page.columns = { count: activeColumns.count, gap: activeColumns.gap, withSeparator: activeColumns.withSeparator }; + } + // Set vertical alignment from active section state if (activeVAlign && activeVAlign !== 'top') { page.vAlign = activeVAlign; @@ -2527,6 +2538,39 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options } } + // Serialize constraint boundaries into page.columnRegions so DomPainter can + // draw per-region overlays (e.g. column separator lines) bounded by the + // correct Y span. Continuous section breaks with a changed column config + // push boundaries into PageState.constraintBoundaries during layout; without + // this step the renderer only sees the page-start column config and would + // draw a single full-page separator across regions it no longer applies to. + for (const state of states) { + const boundaries = state.constraintBoundaries; + if (boundaries.length === 0) continue; + + const regions: ColumnRegion[] = []; + // First region spans from the top of the content area to the first boundary. + // Its columns come from page.columns (set at page creation before any + // mid-page region change) or fall back to a single-column default so the + // contract stays self-describing even when the page starts single-column. + const firstRegionColumns: ColumnLayout = state.page.columns ?? { count: 1, gap: 0 }; + regions.push({ + yStart: state.topMargin, + yEnd: boundaries[0].y, + columns: firstRegionColumns, + }); + for (let i = 0; i < boundaries.length; i++) { + const start = boundaries[i]; + const end = boundaries[i + 1]; + regions.push({ + yStart: start.y, + yEnd: end ? end.y : state.contentBottom, + columns: start.columns, + }); + } + state.page.columnRegions = regions; + } + return { pageSize, pages, @@ -2534,7 +2578,10 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options // after processing sections. Page/region-specific column changes are encoded // implicitly via fragment positions. Consumers should not assume this is // a static document-wide value. - columns: activeColumns.count > 1 ? { count: activeColumns.count, gap: activeColumns.gap } : undefined, + columns: + activeColumns.count > 1 + ? { count: activeColumns.count, gap: activeColumns.gap, withSeparator: activeColumns.withSeparator } + : undefined, }; } @@ -2961,3 +3008,5 @@ export type { NumberingContext, ResolvePageTokensResult } from './resolvePageTok // Table utilities consumed by layout-bridge and cross-package sync tests export { getCellLines, getEmbeddedRowLines } from './layout-table.js'; export { describeCellRenderBlocks, computeCellSliceContentHeight } from './table-cell-slice.js'; + +export { SINGLE_COLUMN_DEFAULT } from './section-breaks.js'; diff --git a/packages/layout-engine/layout-engine/src/section-breaks.d.ts b/packages/layout-engine/layout-engine/src/section-breaks.d.ts index a4fec8ae80..310d6e4690 100644 --- a/packages/layout-engine/layout-engine/src/section-breaks.d.ts +++ b/packages/layout-engine/layout-engine/src/section-breaks.d.ts @@ -1,4 +1,5 @@ -import type { SectionBreakBlock } from '@superdoc/contracts'; +import type { ColumnLayout, SectionBreakBlock } from '@superdoc/contracts'; + export type SectionState = { activeTopMargin: number; activeBottomMargin: number; @@ -20,14 +21,8 @@ export type SectionState = { w: number; h: number; } | null; - activeColumns: { - count: number; - gap: number; - }; - pendingColumns: { - count: number; - gap: number; - } | null; + activeColumns: ColumnLayout; + pendingColumns: ColumnLayout | null; activeOrientation: 'portrait' | 'landscape' | null; pendingOrientation: 'portrait' | 'landscape' | null; hasAnyPages: boolean; @@ -37,6 +32,7 @@ export type BreakDecision = { forceMidPageRegion: boolean; requiredParity?: 'even' | 'odd'; }; + /** * Schedule section break effects by updating pending/active state and returning a break decision. * This function is pure with respect to inputs/outputs and does not mutate external variables. @@ -56,6 +52,7 @@ export declare function scheduleSectionBreak( decision: BreakDecision; state: SectionState; }; + /** * Apply pending margins/pageSize/columns/orientation to active values at a page boundary and clear pending. */ diff --git a/packages/layout-engine/layout-engine/src/section-breaks.test.ts b/packages/layout-engine/layout-engine/src/section-breaks.test.ts index 209c601ecf..49f3143b1f 100644 --- a/packages/layout-engine/layout-engine/src/section-breaks.test.ts +++ b/packages/layout-engine/layout-engine/src/section-breaks.test.ts @@ -140,6 +140,44 @@ describe('scheduleSectionBreak', () => { expect(result.decision.forceMidPageRegion).toBe(false); expect(result.state.pendingColumns).toEqual({ count: 2, gap: 48 }); }); + + it('detects column change when only withSeparator toggles on', () => { + const state = createSectionState({ activeColumns: { count: 2, gap: 48, withSeparator: false } }); + const block = createSectionBreak({ + type: 'continuous', + columns: { count: 2, gap: 48, withSeparator: true }, + }); + + const result = scheduleSectionBreak(block, state, BASE_MARGINS); + + expect(result.decision.forceMidPageRegion).toBe(true); + expect(result.state.pendingColumns).toEqual({ count: 2, gap: 48, withSeparator: true }); + }); + + it('detects column change when only withSeparator toggles off', () => { + const state = createSectionState({ activeColumns: { count: 2, gap: 48, withSeparator: true } }); + const block = createSectionBreak({ + type: 'continuous', + columns: { count: 2, gap: 48, withSeparator: false }, + }); + + const result = scheduleSectionBreak(block, state, BASE_MARGINS); + + expect(result.decision.forceMidPageRegion).toBe(true); + expect(result.state.pendingColumns).toEqual({ count: 2, gap: 48, withSeparator: false }); + }); + + it('does not trigger mid-page region change when undefined and defined false match', () => { + const state = createSectionState({ activeColumns: { count: 2, gap: 48, withSeparator: false } }); + const block = createSectionBreak({ + type: 'continuous', + columns: { count: 2, gap: 48 }, + }); + + const result = scheduleSectionBreak(block, state, BASE_MARGINS); + + expect(result.decision.forceMidPageRegion).toBe(false); + }); }); describe('first section handling', () => { diff --git a/packages/layout-engine/layout-engine/src/section-breaks.ts b/packages/layout-engine/layout-engine/src/section-breaks.ts index 6eb911657f..3fce475a66 100644 --- a/packages/layout-engine/layout-engine/src/section-breaks.ts +++ b/packages/layout-engine/layout-engine/src/section-breaks.ts @@ -30,7 +30,7 @@ export type BreakDecision = { }; /** Default single-column configuration per OOXML spec (absence of w:cols element) */ -const SINGLE_COLUMN_DEFAULT: Readonly = { count: 1, gap: 0 }; +export const SINGLE_COLUMN_DEFAULT: Readonly = { count: 1, gap: 0 }; /** * Get the column configuration for a section break. @@ -38,7 +38,7 @@ const SINGLE_COLUMN_DEFAULT: Readonly = { count: 1, gap: 0 }; * Per OOXML spec, absence of element means single column layout. * * @param blockColumns - The columns property from the section break block (may be undefined) - * @returns Column configuration with count and gap + * @returns Column configuration with count, gap, and separator presence */ function getColumnConfig(blockColumns: ColumnLayout | undefined): ColumnLayout { return blockColumns ? cloneColumnLayout(blockColumns) : { ...SINGLE_COLUMN_DEFAULT }; @@ -56,17 +56,22 @@ function getColumnConfig(blockColumns: ColumnLayout | undefined): ColumnLayout { */ function isColumnConfigChanging(blockColumns: ColumnLayout | undefined, activeColumns: ColumnLayout): boolean { if (blockColumns) { - // Explicit column change + // Explicit column change: any of count, gap, separator presence, equalWidth, + // or widths differs. withSeparator must be included because a sep-only toggle + // still needs a new column region so the renderer can draw (or stop drawing) + // the separator from the toggle point onward. return ( blockColumns.count !== activeColumns.count || blockColumns.gap !== activeColumns.gap || + Boolean(blockColumns.withSeparator) !== Boolean(activeColumns.withSeparator) || blockColumns.equalWidth !== activeColumns.equalWidth || !widthsEqual(blockColumns.widths, activeColumns.widths) ); } - // No columns specified = reset to single column (OOXML default) - // This is a change only if currently in multi-column layout - return activeColumns.count > 1; + // No columns specified = reset to single column (OOXML default). + // This is a change if currently in multi-column layout, or if the separator was on + // (the reset implicitly turns it off). + return activeColumns.count > 1 || Boolean(activeColumns.withSeparator); } /** diff --git a/packages/layout-engine/layout-engine/src/section-props.test.ts b/packages/layout-engine/layout-engine/src/section-props.test.ts index 49dbfd4f30..0d9601c369 100644 --- a/packages/layout-engine/layout-engine/src/section-props.test.ts +++ b/packages/layout-engine/layout-engine/src/section-props.test.ts @@ -61,4 +61,36 @@ describe('computeNextSectionPropsAtBreak', () => { expect(snapshot?.columns).toEqual({ count: 2, gap: 48 }); expect(snapshot?.columns).not.toBe(sourceColumns); }); + + it('propagates withSeparator flag through section property snapshots', () => { + const sourceColumns = { count: 2, gap: 48, withSeparator: true }; + const blocks: FlowBlock[] = [sectionBreak({ id: 'sb-0', columns: sourceColumns })]; + const map = computeNextSectionPropsAtBreak(blocks); + const snapshot = map.get(0); + + expect(snapshot?.columns).toEqual({ count: 2, gap: 48, withSeparator: true }); + expect(snapshot?.columns).not.toBe(sourceColumns); + }); + + it('omits withSeparator from the snapshot when not set on source block', () => { + const sourceColumns = { count: 2, gap: 48 }; + const blocks: FlowBlock[] = [sectionBreak({ id: 'sb-0', columns: sourceColumns })]; + const map = computeNextSectionPropsAtBreak(blocks); + const snapshot = map.get(0); + + expect(snapshot?.columns).toEqual({ count: 2, gap: 48 }); + expect(snapshot?.columns?.withSeparator).toBeUndefined(); + }); + + it('propagates withSeparator from the next section in lookahead', () => { + const blocks: FlowBlock[] = [ + sectionBreak({ id: 'sb-0', columns: { count: 1, gap: 0 } }), + { kind: 'paragraph', id: 'p-1', runs: [] } as FlowBlock, + sectionBreak({ id: 'sb-2', columns: { count: 2, gap: 48, withSeparator: true } }), + ]; + const map = computeNextSectionPropsAtBreak(blocks); + + expect(map.get(0)?.columns).toEqual({ count: 2, gap: 48, withSeparator: true }); + expect(map.get(2)?.columns).toEqual({ count: 2, gap: 48, withSeparator: true }); + }); }); diff --git a/packages/layout-engine/layout-engine/src/section-props.ts b/packages/layout-engine/layout-engine/src/section-props.ts index 6c8c6c84c3..82bab70f88 100644 --- a/packages/layout-engine/layout-engine/src/section-props.ts +++ b/packages/layout-engine/layout-engine/src/section-props.ts @@ -1,4 +1,4 @@ -import type { FlowBlock, SectionVerticalAlign } from '@superdoc/contracts'; +import type { ColumnLayout, FlowBlock, SectionVerticalAlign } from '@superdoc/contracts'; /** * Section-level formatting properties that control page layout. @@ -16,7 +16,7 @@ import type { FlowBlock, SectionVerticalAlign } from '@superdoc/contracts'; export type SectionProps = { margins?: { header?: number; footer?: number; top?: number; right?: number; bottom?: number; left?: number }; pageSize?: { w: number; h: number }; - columns?: { count: number; gap: number }; + columns?: ColumnLayout; orientation?: 'portrait' | 'landscape'; vAlign?: SectionVerticalAlign; }; @@ -59,7 +59,7 @@ const _snapshotSectionProps = (block: FlowBlock): SectionProps | null => { } if (block.columns) { hasProps = true; - props.columns = { count: block.columns.count, gap: block.columns.gap }; + props.columns = { count: block.columns.count, gap: block.columns.gap, withSeparator: block.columns.withSeparator }; } if (block.orientation) { hasProps = true; @@ -135,7 +135,11 @@ export function computeNextSectionPropsAtBreak(blocks: FlowBlock[]): Map = {}): Page => ({ + number: 1, + fragments: [], + margins: { top: 96, right: 96, bottom: 96, left: 96 }, + ...overrides, +}); + +const buildLayout = (page: Page, pageSize = { w: 816, h: 1056 }): Layout => ({ + pageSize, + pages: [page], +}); + +const querySeparators = (mount: HTMLElement): HTMLDivElement[] => { + // Separators are the only 1px-wide absolutely-positioned divs added to a page. + // Scoping by the inline styles keeps this brittle-free against unrelated + // absolute-positioned overlays (rulers, selection, floats). + return Array.from(mount.querySelectorAll('div')).filter((el) => { + const s = el.style; + return s.position === 'absolute' && s.width === '1px' && s.backgroundColor === '#000000'; + }) as HTMLDivElement[]; +}; + +const paintOnce = (layout: Layout, mount: HTMLElement): void => { + const painter = createDomPainter({ blocks: [], measures: [] }); + painter.paint(layout, mount); +}; + +describe('DomPainter renderColumnSeparators', () => { + let mount: HTMLElement; + + beforeEach(() => { + mount = document.createElement('div'); + document.body.appendChild(mount); + }); + + afterEach(() => { + mount.remove(); + }); + + describe('fallback path (page.columns only)', () => { + it('draws a single separator centered in the gap for 2 equal columns', () => { + const page = buildPage({ columns: { count: 2, gap: 48, withSeparator: true } }); + paintOnce(buildLayout(page), mount); + + const seps = querySeparators(mount); + expect(seps).toHaveLength(1); + // pageWidth=816, margins=96 → contentWidth=624, columnWidth=(624-48)/2=288. + // separator x = leftMargin + columnWidth + gap/2 = 96 + 288 + 24 = 408. + expect(seps[0].style.left).toBe('408px'); + expect(seps[0].style.top).toBe('96px'); + // height = pageHeight - top - bottom = 1056 - 96 - 96 = 864. + expect(seps[0].style.height).toBe('864px'); + }); + + it('draws count-1 separators for 3 equal columns', () => { + const page = buildPage({ columns: { count: 3, gap: 48, withSeparator: true } }); + paintOnce(buildLayout(page), mount); + + const seps = querySeparators(mount); + expect(seps).toHaveLength(2); + // columnWidth = (624 - 48*2) / 3 = 176. + // sep 0: 96 + 176 + 48/2 = 296. sep 1: 96 + 2*176 + 48 + 48/2 = 520. + expect(seps.map((s) => s.style.left)).toEqual(['296px', '520px']); + }); + + it('renders nothing when withSeparator is false', () => { + const page = buildPage({ columns: { count: 2, gap: 48, withSeparator: false } }); + paintOnce(buildLayout(page), mount); + + expect(querySeparators(mount)).toHaveLength(0); + }); + + it('renders nothing when withSeparator is omitted (undefined)', () => { + const page = buildPage({ columns: { count: 2, gap: 48 } }); + paintOnce(buildLayout(page), mount); + + expect(querySeparators(mount)).toHaveLength(0); + }); + + it('renders nothing for single-column pages', () => { + const page = buildPage({ columns: { count: 1, gap: 0, withSeparator: true } }); + paintOnce(buildLayout(page), mount); + + expect(querySeparators(mount)).toHaveLength(0); + }); + + it('renders nothing when page has neither columns nor columnRegions', () => { + paintOnce(buildLayout(buildPage()), mount); + expect(querySeparators(mount)).toHaveLength(0); + }); + + it('renders nothing when page.margins is missing', () => { + const page: Page = { + number: 1, + fragments: [], + columns: { count: 2, gap: 48, withSeparator: true }, + }; + paintOnce(buildLayout(page), mount); + expect(querySeparators(mount)).toHaveLength(0); + }); + + it('renders nothing when columnWidth collapses to <=1px', () => { + // Pathological case: tiny page with a huge gap leaves no room for columns. + const page = buildPage({ + margins: { top: 10, right: 10, bottom: 10, left: 10 }, + columns: { count: 2, gap: 100, withSeparator: true }, + }); + paintOnce(buildLayout(page, { w: 110, h: 200 }), mount); + // contentWidth=90, columnWidth=(90-100)/2=-5 → guard fires. + expect(querySeparators(mount)).toHaveLength(0); + }); + }); + + describe('region-aware path (page.columnRegions)', () => { + it('draws per-region separators bounded by each region yStart/yEnd', () => { + const regions: ColumnRegion[] = [ + { yStart: 96, yEnd: 400, columns: { count: 2, gap: 48, withSeparator: true } }, + { yStart: 400, yEnd: 700, columns: { count: 3, gap: 48, withSeparator: true } }, + ]; + // page.columns is set to the first region's config (matches what the + // layout engine does); the renderer must prefer columnRegions. + const page = buildPage({ + columns: regions[0].columns, + columnRegions: regions, + }); + paintOnce(buildLayout(page), mount); + + const seps = querySeparators(mount); + // Region 0: 1 separator for 2-col. Region 1: 2 separators for 3-col. + expect(seps).toHaveLength(3); + + // Region 0 bounds. + expect(seps[0].style.top).toBe('96px'); + expect(seps[0].style.height).toBe('304px'); // 400 - 96 + expect(seps[0].style.left).toBe('408px'); + + // Region 1 bounds. + expect(seps[1].style.top).toBe('400px'); + expect(seps[1].style.height).toBe('300px'); // 700 - 400 + expect(seps[2].style.top).toBe('400px'); + expect(seps[2].style.height).toBe('300px'); + // 3-col positions computed fresh for region 1: 296px and 520px. + expect([seps[1].style.left, seps[2].style.left]).toEqual(['296px', '520px']); + }); + + it('skips regions whose withSeparator is false even if other regions render', () => { + const regions: ColumnRegion[] = [ + { yStart: 96, yEnd: 400, columns: { count: 2, gap: 48, withSeparator: true } }, + { yStart: 400, yEnd: 700, columns: { count: 2, gap: 48, withSeparator: false } }, + { yStart: 700, yEnd: 960, columns: { count: 2, gap: 48, withSeparator: true } }, + ]; + const page = buildPage({ columnRegions: regions }); + paintOnce(buildLayout(page), mount); + + const seps = querySeparators(mount); + expect(seps).toHaveLength(2); + // Only regions 0 and 2 produce output. + expect(seps.map((s) => s.style.top)).toEqual(['96px', '700px']); + expect(seps.map((s) => s.style.height)).toEqual(['304px', '260px']); + }); + + it('skips single-column regions', () => { + const regions: ColumnRegion[] = [ + { yStart: 96, yEnd: 400, columns: { count: 1, gap: 0, withSeparator: true } }, + { yStart: 400, yEnd: 700, columns: { count: 2, gap: 48, withSeparator: true } }, + ]; + const page = buildPage({ columnRegions: regions }); + paintOnce(buildLayout(page), mount); + + const seps = querySeparators(mount); + expect(seps).toHaveLength(1); + expect(seps[0].style.top).toBe('400px'); + }); + + it('skips regions with non-positive height', () => { + const regions: ColumnRegion[] = [ + { yStart: 96, yEnd: 96, columns: { count: 2, gap: 48, withSeparator: true } }, + { yStart: 96, yEnd: 500, columns: { count: 2, gap: 48, withSeparator: true } }, + ]; + const page = buildPage({ columnRegions: regions }); + paintOnce(buildLayout(page), mount); + + const seps = querySeparators(mount); + expect(seps).toHaveLength(1); + expect(seps[0].style.height).toBe('404px'); + }); + + it('prefers columnRegions over page.columns when both are present', () => { + // page.columns says "no separator", but columnRegions says "draw one". + // The regions should win — they represent the authoritative per-region + // state, page.columns only represents the page-start config. + const page = buildPage({ + columns: { count: 2, gap: 48, withSeparator: false }, + columnRegions: [{ yStart: 96, yEnd: 960, columns: { count: 2, gap: 48, withSeparator: true } }], + }); + paintOnce(buildLayout(page), mount); + + const seps = querySeparators(mount); + expect(seps).toHaveLength(1); + expect(seps[0].style.top).toBe('96px'); + expect(seps[0].style.height).toBe('864px'); + }); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 28115ea837..8839e631d2 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -2230,6 +2230,8 @@ export class DomPainter { ); }); this.renderDecorationsForPage(el, page, pageIndex); + this.renderColumnSeparators(el, page, width, height); + return el; } @@ -2310,6 +2312,60 @@ export class DomPainter { } } + private renderColumnSeparators(pageEl: HTMLElement, page: Page, pageWidth: number, pageHeight: number): void { + if (!this.doc) return; + if (!page.margins) return; + + const leftMargin = page.margins.left ?? 0; + const rightMargin = page.margins.right ?? 0; + const topMargin = page.margins.top ?? 0; + const bottomMargin = page.margins.bottom ?? 0; + const contentWidth = pageWidth - leftMargin - rightMargin; + + // Prefer columnRegions (per-region configs for pages with continuous + // section breaks that change column layout mid-page). Fall back to a + // single region derived from page.columns so pages without mid-page + // changes keep working unchanged. + const regions = + page.columnRegions ?? + (page.columns + ? [ + { + yStart: topMargin, + yEnd: pageHeight - bottomMargin, + columns: page.columns, + }, + ] + : []); + + for (const region of regions) { + const { columns, yStart, yEnd } = region; + if (!columns.withSeparator) continue; + if (columns.count <= 1) continue; + + const columnWidth = (contentWidth - columns.gap * (columns.count - 1)) / columns.count; + // Given the separator will have 1px width, ensure column has a larger width. + if (columnWidth <= 1) continue; + + const regionHeight = yEnd - yStart; + if (regionHeight <= 0) continue; + + for (let i = 0; i < columns.count - 1; i++) { + const separatorX = leftMargin + (i + 1) * columnWidth + i * columns.gap + columns.gap / 2; + const separatorEl = this.doc.createElement('div'); + + separatorEl.style.position = 'absolute'; + separatorEl.style.left = `${separatorX}px`; + separatorEl.style.top = `${yStart}px`; + separatorEl.style.height = `${regionHeight}px`; + separatorEl.style.width = '1px'; + separatorEl.style.backgroundColor = '#000000'; + separatorEl.style.pointerEvents = 'none'; + pageEl.appendChild(separatorEl); + } + } + } + private renderDecorationsForPage(pageEl: HTMLElement, page: Page, pageIndex: number): void { if (this.isSemanticFlow) return; this.renderDecorationSection(pageEl, page, pageIndex, 'header'); @@ -2816,6 +2872,8 @@ export class DomPainter { }); this.renderDecorationsForPage(el, page, pageIndex); + this.renderColumnSeparators(el, page, pageSize.w, pageSize.h); + return { element: el, fragments: fragmentStates }; } diff --git a/packages/layout-engine/pm-adapter/src/index.test.ts b/packages/layout-engine/pm-adapter/src/index.test.ts index 415dbb87b2..65d5456486 100644 --- a/packages/layout-engine/pm-adapter/src/index.test.ts +++ b/packages/layout-engine/pm-adapter/src/index.test.ts @@ -903,6 +903,7 @@ describe('toFlowBlocks', () => { expect((contentBreak as FlowBlock).columns).toEqual({ count: 2, gap: 101.53333333333333, + withSeparator: false, widths: [72, 497.26666666666665], equalWidth: false, }); @@ -1077,7 +1078,7 @@ describe('toFlowBlocks', () => { expect(multiColumnBreak).toBeDefined(); expect((multiColumnBreak as FlowBlock).attrs?.requirePageBoundary).toBeUndefined(); // Gap is in pixels (0.5in = 48px @96DPI) - expect((multiColumnBreak as FlowBlock).columns).toEqual({ count: 2, gap: 48 }); + expect((multiColumnBreak as FlowBlock).columns).toEqual({ count: 2, gap: 48, withSeparator: false }); }); it('interprets missing w:num in w:cols as a single-column layout change', () => { @@ -1110,7 +1111,7 @@ describe('toFlowBlocks', () => { const allBreaks = getSectionBreaks(blocks, { includeFirst: true }); const tailBreak = allBreaks.find((b) => b.attrs?.sectionIndex === 0); expect(tailBreak).toBeDefined(); - expect((tailBreak as never).columns).toEqual({ count: 1, gap: 48 }); + expect((tailBreak as never).columns).toEqual({ count: 1, gap: 48, withSeparator: false }); }); describe('Regression tests for section property bug fixes', () => { @@ -1158,7 +1159,7 @@ describe('toFlowBlocks', () => { expect(firstBreak).toBeDefined(); expect(secondBreak).toBeDefined(); // Both have w:space="720" which means single column - expect((firstBreak as FlowBlock).columns).toEqual({ count: 1, gap: 48 }); + expect((firstBreak as FlowBlock).columns).toEqual({ count: 1, gap: 48, withSeparator: false }); expect((secondBreak as FlowBlock).type).toBe('continuous'); // Second sectPr }); @@ -1198,7 +1199,7 @@ describe('toFlowBlocks', () => { // Should emit the section break despite paragraph having content expect(contentBreak).toBeDefined(); - expect((contentBreak as FlowBlock).columns).toEqual({ count: 2, gap: 48 }); + expect((contentBreak as FlowBlock).columns).toEqual({ count: 2, gap: 48, withSeparator: false }); }); it('detects column changes from single to multi to single column', () => { diff --git a/packages/layout-engine/pm-adapter/src/sections/analysis.test.ts b/packages/layout-engine/pm-adapter/src/sections/analysis.test.ts index 37c60496b5..ac860ce7e6 100644 --- a/packages/layout-engine/pm-adapter/src/sections/analysis.test.ts +++ b/packages/layout-engine/pm-adapter/src/sections/analysis.test.ts @@ -756,7 +756,7 @@ describe('analysis', () => { footerPx: 50, pageSizePx: { w: 12240, h: 15840 }, orientation: 'landscape', - columnsPx: { count: 2, gap: 100 }, + columnsPx: { count: 2, gap: 100, withSeparator: false }, headerRefs: { default: 'header1' }, footerRefs: { default: 'footer1' }, numbering: { format: 'decimal', start: 1 }, @@ -770,7 +770,7 @@ describe('analysis', () => { margins: { header: 100, footer: 50 }, pageSize: { w: 12240, h: 15840 }, orientation: 'landscape', - columns: { count: 2, gap: 100 }, + columns: { count: 2, gap: 100, withSeparator: false }, headerRefs: { default: 'header1' }, footerRefs: { default: 'footer1' }, numbering: { format: 'decimal', start: 1 }, @@ -962,6 +962,32 @@ describe('analysis', () => { expect(result!.numbering).toEqual({ format: 'decimal', start: 5 }); }); + it('should have column separator flag set to true when present in extracted data', () => { + const bodySectPr: SectPrElement = { type: 'element', name: 'w:sectPr' }; + + vi.mocked(extractionModule.extractSectionData).mockReturnValue({ + titlePg: false, + columnsPx: { count: 2, gap: 48, withSeparator: true }, + }); + + const result = createFinalSectionFromBodySectPr(bodySectPr, 0, 10, 0); + + expect(result!.columns).toEqual({ count: 2, gap: 48, withSeparator: true }); + }); + + it('should have column separator flag set to false when present as "false" in extracted data', () => { + const bodySectPr: SectPrElement = { type: 'element', name: 'w:sectPr' }; + + vi.mocked(extractionModule.extractSectionData).mockReturnValue({ + titlePg: false, + columnsPx: { count: 2, gap: 48, withSeparator: false }, + }); + + const result = createFinalSectionFromBodySectPr(bodySectPr, 0, 10, 0); + + expect(result!.columns).toEqual({ count: 2, gap: 48, withSeparator: false }); + }); + it('should respect body section type from extracted data', () => { const bodySectPr: SectPrElement = { type: 'element', name: 'w:sectPr' }; diff --git a/packages/layout-engine/pm-adapter/src/sections/extraction.test.ts b/packages/layout-engine/pm-adapter/src/sections/extraction.test.ts index 7f3fa6beec..ce6a124e96 100644 --- a/packages/layout-engine/pm-adapter/src/sections/extraction.test.ts +++ b/packages/layout-engine/pm-adapter/src/sections/extraction.test.ts @@ -250,6 +250,7 @@ describe('extraction', () => { expect(result?.columnsPx).toEqual({ count: 2, gap: 48, // 720 twips = 0.5 inches = 48 pixels + withSeparator: false, }); }); @@ -281,6 +282,7 @@ describe('extraction', () => { expect(result?.columnsPx).toEqual({ count: 2, gap: 101.53333333333333, + withSeparator: false, widths: [72, 497.26666666666665], equalWidth: false, }); @@ -374,6 +376,129 @@ describe('extraction', () => { }); }); + // ==================== extractSectionData - column separator (w:sep) tests ==================== + describe('extractSectionData - column separator', () => { + it('should include separator when w:sep="1"', () => { + const para: PMNode = { + type: 'paragraph', + attrs: { + paragraphProperties: { + sectPr: { + type: 'element', + name: 'w:sectPr', + elements: [ + { + name: 'w:cols', + attributes: { 'w:num': '2', 'w:space': '720', 'w:sep': '1' }, + }, + ], + }, + }, + }, + }; + const result = extractSectionData(para); + + expect(result).not.toBeNull(); + expect(result?.columnsPx).toEqual({ + count: 2, + gap: 48, + withSeparator: true, + }); + }); + + it('should include separator when w:sep="true"', () => { + const para: PMNode = { + type: 'paragraph', + attrs: { + paragraphProperties: { + sectPr: { + type: 'element', + name: 'w:sectPr', + elements: [ + { + name: 'w:cols', + attributes: { 'w:num': '2', 'w:space': '720', 'w:sep': 'true' }, + }, + ], + }, + }, + }, + }; + const result = extractSectionData(para); + + expect(result?.columnsPx?.withSeparator).toBe(true); + }); + + it('should include separator when w:sep="on"', () => { + const para: PMNode = { + type: 'paragraph', + attrs: { + paragraphProperties: { + sectPr: { + type: 'element', + name: 'w:sectPr', + elements: [ + { + name: 'w:cols', + attributes: { 'w:num': '2', 'w:space': '720', 'w:sep': 'on' }, + }, + ], + }, + }, + }, + }; + const result = extractSectionData(para); + + expect(result?.columnsPx?.withSeparator).toBe(true); + }); + + it('should not include separator when w:sep is absent', () => { + const para: PMNode = { + type: 'paragraph', + attrs: { + paragraphProperties: { + sectPr: { + type: 'element', + name: 'w:sectPr', + elements: [ + { + name: 'w:cols', + attributes: { 'w:num': '2', 'w:space': '720' }, + }, + ], + }, + }, + }, + }; + const result = extractSectionData(para); + + expect(result?.columnsPx).toEqual({ count: 2, gap: 48, withSeparator: false }); + }); + + it('should not include separator when w:sep="0"', () => { + const para: PMNode = { + type: 'paragraph', + attrs: { + paragraphProperties: { + sectPr: { + type: 'element', + name: 'w:sectPr', + elements: [ + { + name: 'w:cols', + attributes: { 'w:num': '2', 'w:space': '720', 'w:sep': '0' }, + }, + ], + }, + }, + }, + }; + const result = extractSectionData(para); + + expect(result?.columnsPx).toEqual({ count: 2, gap: 48, withSeparator: false }); + }); + }); + // ==================== parseColumnGap Tests ==================== describe('parseColumnGap', () => { it('should return default 0.5 inches when gapTwips is undefined', () => { diff --git a/packages/layout-engine/pm-adapter/src/sections/extraction.ts b/packages/layout-engine/pm-adapter/src/sections/extraction.ts index ca2e9979ef..da98daeec3 100644 --- a/packages/layout-engine/pm-adapter/src/sections/extraction.ts +++ b/packages/layout-engine/pm-adapter/src/sections/extraction.ts @@ -6,6 +6,7 @@ import type { PMNode } from '../types.js'; import type { ParagraphProperties, SectionVerticalAlign } from './types.js'; +import type { ColumnLayout } from '@superdoc/contracts'; const TWIPS_PER_INCH = 1440; const PX_PER_INCH = 96; @@ -42,6 +43,15 @@ export function parseColumnGap(gapTwips: string | number | undefined): number { return Number.isFinite(gap) ? gap / TWIPS_PER_INCH : DEFAULT_COLUMN_GAP_INCHES; } +/** + * Parse presence of column separator from w:sep attribute (can be '1', 'true' or 'on'). + * @param rawValue - Raw value from w:sep attribute + * @returns Presence of column separator + */ +export function parseColumnSeparator(rawValue: string | number | undefined): boolean { + return rawValue === '1' || rawValue === 'true' || rawValue === 'on' || rawValue === 1; +} + type SectionType = 'continuous' | 'nextPage' | 'evenPage' | 'oddPage'; type Orientation = 'portrait' | 'landscape'; type HeaderRefType = Partial>; @@ -209,13 +219,12 @@ function extractPageNumbering(elements: SectionElement[]): /** * Extract columns from element. */ -function extractColumns( - elements: SectionElement[], -): { count: number; gap: number; widths?: number[]; equalWidth?: boolean } | undefined { +function extractColumns(elements: SectionElement[]): ColumnLayout | undefined { const cols = elements.find((el) => el?.name === 'w:cols'); if (!cols?.attributes) return undefined; const count = parseColumnCount(cols.attributes['w:num'] as string | number | undefined); + const withSeparator = parseColumnSeparator(cols.attributes['w:sep'] as string | number | undefined); const equalWidthRaw = cols.attributes['w:equalWidth']; const equalWidth = equalWidthRaw === '0' || equalWidthRaw === 0 || equalWidthRaw === false @@ -233,9 +242,10 @@ function extractColumns( .filter((widthTwips) => Number.isFinite(widthTwips) && widthTwips > 0) .map((widthTwips) => (widthTwips / 1440) * PX_PER_INCH); - const result = { + const result: ColumnLayout = { count, gap: gapInches * PX_PER_INCH, + withSeparator, ...(widths.length > 0 ? { widths } : {}), ...(equalWidth !== undefined ? { equalWidth } : {}), }; @@ -308,7 +318,7 @@ export function extractSectionData(para: PMNode): { type?: SectionType; pageSizePx?: { w: number; h: number }; orientation?: Orientation; - columnsPx?: { count: number; gap: number; widths?: number[]; equalWidth?: boolean }; + columnsPx?: ColumnLayout; titlePg?: boolean; headerRefs?: HeaderRefType; footerRefs?: HeaderRefType; diff --git a/packages/layout-engine/pm-adapter/src/sections/index.ts b/packages/layout-engine/pm-adapter/src/sections/index.ts index b21f849c6a..64b41423fb 100644 --- a/packages/layout-engine/pm-adapter/src/sections/index.ts +++ b/packages/layout-engine/pm-adapter/src/sections/index.ts @@ -17,7 +17,7 @@ export type { export { SectionType, DEFAULT_PARAGRAPH_SECTION_TYPE, DEFAULT_BODY_SECTION_TYPE } from './types.js'; // Extraction -export { extractSectionData, parseColumnCount, parseColumnGap } from './extraction.js'; +export { extractSectionData, parseColumnCount, parseColumnGap, parseColumnSeparator } from './extraction.js'; // Analysis export { diff --git a/packages/layout-engine/pm-adapter/src/sections/types.ts b/packages/layout-engine/pm-adapter/src/sections/types.ts index bc8498ffed..a4134b48d0 100644 --- a/packages/layout-engine/pm-adapter/src/sections/types.ts +++ b/packages/layout-engine/pm-adapter/src/sections/types.ts @@ -5,6 +5,8 @@ * Includes section ranges, signatures, and OOXML structures. */ +import type { ColumnLayout } from '@superdoc/contracts'; + /** * Section types in Word documents. * Controls how section breaks create new pages. @@ -72,7 +74,7 @@ export type SectionSignature = { orientation?: 'portrait' | 'landscape'; headerRefs?: Partial>; footerRefs?: Partial>; - columnsPx?: { count: number; gap: number; widths?: number[]; equalWidth?: boolean }; + columnsPx?: ColumnLayout; numbering?: { format?: 'decimal' | 'lowerLetter' | 'upperLetter' | 'lowerRoman' | 'upperRoman' | 'numberInDash'; start?: number; @@ -105,7 +107,7 @@ export interface SectionRange { } | null; pageSize: { w: number; h: number } | null; orientation: 'portrait' | 'landscape' | null; - columns: { count: number; gap: number; widths?: number[]; equalWidth?: boolean } | null; + columns: ColumnLayout | null; type: SectionType; titlePg: boolean; headerRefs?: Partial>; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/layout/LayoutOptionParsing.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/layout/LayoutOptionParsing.ts index 43ac3cd7b2..f9d2b6f467 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/layout/LayoutOptionParsing.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/layout/LayoutOptionParsing.ts @@ -20,20 +20,23 @@ export function inchesToPx(value: unknown): number | undefined { /** * Parses column layout configuration from raw input. * - * Extracts column count and gap spacing from various possible property names, + * Extracts column count, gap spacing, and separator presence from various possible property names, * normalizing to a standard ColumnLayout object. Returns undefined for single-column * layouts (count <= 1) since they don't require special column handling. * * @param raw - Raw column configuration object with properties like count, num, or numberOfColumns - * @returns ColumnLayout with count and gap, or undefined if not multi-column or invalid + * @returns ColumnLayout with count, gap and separator presence, or undefined if not multi-column + * or invalid * * @remarks * - Returns undefined if raw is not an object * - Accepts count from: 'count', 'num', or 'numberOfColumns' properties * - Returns undefined if count <= 1 (single column doesn't need layout) * - Accepts gap from: 'space' or 'gap' properties (converted from inches to pixels) - * - Gap defaults to 0 if not provided or invalid + * - Accepts separator presence from: 'withSeparator' boolean property * - Column count is floored to nearest integer and minimum of 1 + * - Gap defaults to 0 if not provided or invalid + * - Separator presence defaults to false if not provided or not a boolean */ export function parseColumns(raw: unknown): ColumnLayout | undefined { if (!raw || typeof raw !== 'object') return undefined; @@ -44,5 +47,6 @@ export function parseColumns(raw: unknown): ColumnLayout | undefined { } const count = Math.max(1, Math.floor(rawCount)); const gap = inchesToPx(columnSource.space ?? columnSource.gap) ?? 0; - return { count, gap }; + const withSeparator = typeof columnSource.withSeparator === 'boolean' ? columnSource.withSeparator : false; + return { count, gap, withSeparator }; } diff --git a/tests/visual/columns-with-line-separator.docx b/tests/visual/columns-with-line-separator.docx new file mode 100644 index 0000000000000000000000000000000000000000..51d07e47f00da5d8bdcf362cbd7711cef203cdc8 GIT binary patch literal 7760 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 literal 0 HcmV?d00001 From bda5d4fbeff787cc9f38610837b73e808e4c6371 Mon Sep 17 00:00:00 2001 From: michaelreavant Date: Mon, 20 Apr 2026 14:16:54 +0200 Subject: [PATCH 04/30] fix(sdk): raise asyncio StreamReader buffer in Python AsyncHostTransport (#2760) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sdk): raise asyncio StreamReader buffer in Python AsyncHostTransport The Python async transport spawned the host CLI without passing a `limit=` to `asyncio.create_subprocess_exec`, so its stdout `StreamReader` inherited asyncio's default 64 KiB buffer. Every host response is written as a single newline-delimited JSON line, so any `cli.invoke` whose serialized result exceeds 64 KiB (e.g. `superdoc_get_content` on larger documents) caused `readline()` to raise `ValueError: Separator is not found, and chunk exceed the limit` inside `_reader_loop`. The exception was caught by the generic reader-loop handler and pending requests were rejected with the misleading `HOST_DISCONNECTED` error — even though the host process was still alive and healthy. Pass `limit=` to `create_subprocess_exec` and expose it as a new `stdout_buffer_limit_bytes` constructor option on `AsyncHostTransport`, threaded through `SuperDocAsyncRuntime` and `AsyncSuperDocClient`. The default of 64 MiB safely covers the host's own 32 MiB `DEFAULT_MAX_STDIN_BYTES` input cap with room for ~2x JSON expansion. `SyncHostTransport` is unaffected — it uses raw blocking `subprocess.Popen` which has no asyncio buffer limit. Adds a `TestAsyncLargeResponse` regression suite that: 1. Round-trips a 200 KB response through the default-configured transport. 2. Pins that an explicitly tightened `stdout_buffer_limit_bytes` still reproduces the original failure mode, guaranteeing the option is wired through to `create_subprocess_exec`. * fix(sdk): tear down host process on async reader-loop failure AsyncHostTransport._reader_loop caught reader exceptions by rejecting pending futures and flipping state to DISCONNECTED, but never killed self._process. Because dispose() early-returns on DISCONNECTED, any reader-loop failure left an orphaned host subprocess running with no public API to reap it. This is a pre-existing bug, but the previous commit made it easier to trip by exposing stdout_buffer_limit_bytes: any caller who sets it below their real response size hits the orphan path. Route both the buffer-overflow and generic-error branches through a new _schedule_cleanup helper that fires _cleanup() as a separate task (it can't be awaited inline — _cleanup cancels and awaits the reader task itself). _cleanup kills the process, waits on it, rejects pending, and only then transitions to DISCONNECTED, so a subsequent dispose() is a safe no-op instead of leaking the host. Also catch asyncio.LimitOverrunError / ValueError separately and surface HOST_PROTOCOL_ERROR with a "raise stdout_buffer_limit_bytes" hint plus the current limit in details. The previous HOST_DISCONNECTED code pointed users at the wrong problem since the host was still alive. Extends TestAsyncLargeResponse to assert HOST_PROTOCOL_ERROR, verify the hint is in the message, confirm the subprocess is actually reaped (returncode set, _process cleared), and that dispose() after an overflow is a safe no-op. * refactor(sdk): dedupe stdout_buffer_limit default and add wiring test Address review follow-ups on the async transport buffer-limit option. - Hoist DEFAULT_STDOUT_BUFFER_LIMIT_BYTES (64 MiB) to module scope in transport.py and reference it from AsyncHostTransport, the async runtime, and AsyncSuperDocClient so the default lives in one place instead of three copies of 64 * 1024 * 1024. - Add a short "raise if a single host response can exceed this size" comment on the client.py parameter so callers see the guidance at the public API boundary, not buried in transport.py. - Rename test_response_above_default_64kb_buffer to test_response_above_asyncio_default_streamreader_limit. 64 KiB is asyncio's default, not the SDK's (which is now 64 MiB), so the old name read backwards after this PR. - Add test_client_threads_stdout_buffer_limit_to_transport: builds AsyncSuperDocClient with a custom limit and asserts the value reaches AsyncHostTransport. Without this, a silent drop of the arg in client.py or runtime.py would leave the existing overflow test passing while the public API reverts to the asyncio 64 KiB default. * fix(sdk): mark transport DISPOSING synchronously on reader teardown Round-2 review follow-ups: - _schedule_cleanup now flips state to DISPOSING before scheduling the cleanup task. Previously, between the reader returning and the async _cleanup running, _ensure_connected's CONNECTED fast path would still accept invoke() calls; they then blocked on a future the dead reader could never resolve until watchdog_timeout_ms (default 30s). - Narrow the buffer-overflow catch to readline() only and drop asyncio.LimitOverrunError from the tuple. readline() re-raises LimitOverrunError as ValueError (it is not a ValueError subclass on any supported CPython), so the previous broad except could reclassify unrelated ValueErrors from dispatch as a buffer-limit error with a misleading remediation hint. Comment corrected to match. - Re-export DEFAULT_STDOUT_BUFFER_LIMIT_BYTES from superdoc/__init__.py so consumers tuning the option don't import from the implementation module. - Tighten test_host_crash to assert HOST_DISCONNECTED specifically and verify process teardown via the new _schedule_cleanup path. - Strengthen the dispose-after-overflow assertion to actually verify the no-op claim (state stays DISCONNECTED, _process stays None, a second dispose is also safe). Replace the timing-sensitive process.returncode read with await process.wait(). * fix(sdk): serialize teardown across reader, _kill_and_reset, and dispose Round-2 follow-up — addresses the residual race that the synchronous DISPOSING flip didn't cover. Before: `_kill_and_reset()` (called from `_send_request` on stdin write failure or watchdog timeout) `await`ed `_cleanup` directly. If a reader-triggered `_schedule_cleanup` was in flight, both ran concurrently and raced on `_reject_all_pending`'s read-then-clear of `self._pending` (futures added between snapshot and clear were leaked) and on `process.kill()`/`reader_task.cancel()`. `dispose()` similarly short-circuited on DISPOSING without waiting for the in-flight cleanup to finish — the caller saw "disposed" before the host was fully torn down. Now: - `_kill_and_reset` and `dispose` both check the cleanup-task slot and `await` an in-flight cleanup rather than starting a parallel one. Single-flight teardown across all three entry points. - `_cleanup` clears `self._cleanup_task` in `finally` when it owns the slot, so introspection doesn't surface a stale done handle and the next teardown gets a fresh slot. - `dispose()` after a reader-triggered cleanup now blocks until that cleanup finishes, restoring the "host fully torn down on return" contract. Tests: - `test_schedule_cleanup_dedupe_guard_drops_reentrant_call` — second `_schedule_cleanup` does not replace the in-flight task slot. - `test_overflow_during_dispose_does_not_schedule_cleanup` — `_stopping` suppression is honored. - `test_kill_and_reset_awaits_in_flight_cleanup` — `_kill_and_reset` observes the existing task instead of running a parallel `_cleanup`. - `test_dispose_waits_for_in_flight_cleanup` — `dispose()` blocks until reader-triggered cleanup completes before returning. 95 transport tests pass; 5 consecutive runs with PYTHONASYNCIODEBUG=1 show no flakes. * fix(sdk): close residual races in async transport teardown Two correctness regressions and three test gaps surfaced in the final-pass review of the cleanup-task lifecycle. **1. _ensure_connected race (HIGH).** The synchronous DISPOSING flip in _schedule_cleanup did not gate _ensure_connected, so a concurrent connect()/invoke() reaching _start_host during the DISPOSING window would reassign self._process and self._reader_task. The pending cleanup task then read those slots after its first await and killed the freshly-spawned process. Fix: drain self._cleanup_task at the top of _ensure_connected via asyncio.shield (so a cancelled caller doesn't abort the in-flight cleanup). **2. Cancellation propagation race (HIGH).** _kill_and_reset and dispose() awaited the cleanup task without asyncio.shield. When the caller (e.g. an invoke task at the watchdog branch) was cancelled, asyncio cancelled the awaited cleanup task too — _cleanup did not catch CancelledError around process.wait(), so teardown stopped before clearing _process / setting state. dispose() then saw DISPOSING with _cleanup_task=None and returned without finishing teardown, leaking the host process. Fix: wrap the awaited cleanup in asyncio.shield in both call sites; restructure _cleanup so it captures handles and sets state synchronously up-front, before any awaits, so observable state is always consistent. **3. Move _stopping guard into _schedule_cleanup.** The previous test_overflow_during_dispose_does_not_schedule_cleanup was tautological — it set _stopping=True and then re-checked the same condition in the test body before calling _schedule_cleanup, so the call never ran and the assertion passed trivially. Move the guard into _schedule_cleanup itself (it's the correct authoritative location anyway), remove the now-redundant call-site checks in _reader_loop, and rewrite the test to call _schedule_cleanup unconditionally with _stopping=True. The test now actually exercises the production guard. **4. Multi-pending-invoke overflow test.** Codex round-2 gap that remained open. Locks down that _reject_all_pending fails ALL pending futures with HOST_PROTOCOL_ERROR plus the actionable hint, not just the one whose response overflowed. **5. Async reconnect-after-buffer-overflow test.** Sync transport already had test_reconnect_after_failure; async only covered reconnect after explicit dispose. Validates that reader-triggered cleanup leaves the transport reusable for a fresh invoke without wedging _cleanup_task / _connecting / _process. Plus: replaced asyncio.sleep(0) with asyncio.Event-based synchronization in lifecycle tests (Codex/Opus medium — sleep(0) is implementation-defined under uvloop / Python scheduling changes); two new tests directly cover the round-3 races (test_ensure_connected_drains_in_flight_cleanup_before_spawn, test_kill_and_reset_caller_cancellation_does_not_cancel_cleanup). 99 transport tests pass; 5 consecutive runs with PYTHONASYNCIODEBUG=1 show no flakes; new tests pass under -W error::ResourceWarning. --------- Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Co-authored-by: Caio Pizzol --- .../sdk/langs/python/superdoc/__init__.py | 2 + packages/sdk/langs/python/superdoc/client.py | 5 + packages/sdk/langs/python/superdoc/runtime.py | 8 +- .../sdk/langs/python/superdoc/transport.py | 214 +++++++-- .../sdk/langs/python/tests/test_transport.py | 452 +++++++++++++++++- 5 files changed, 643 insertions(+), 38 deletions(-) diff --git a/packages/sdk/langs/python/superdoc/__init__.py b/packages/sdk/langs/python/superdoc/__init__.py index 9682645771..fa0b628a52 100644 --- a/packages/sdk/langs/python/superdoc/__init__.py +++ b/packages/sdk/langs/python/superdoc/__init__.py @@ -10,6 +10,7 @@ get_tool_catalog, list_tools, ) +from .transport import DEFAULT_STDOUT_BUFFER_LIMIT_BYTES __all__ = [ "SuperDocClient", @@ -17,6 +18,7 @@ "SuperDocDocument", "AsyncSuperDocDocument", "SuperDocError", + "DEFAULT_STDOUT_BUFFER_LIMIT_BYTES", "get_skill", "install_skill", "list_skills", diff --git a/packages/sdk/langs/python/superdoc/client.py b/packages/sdk/langs/python/superdoc/client.py index f187895b04..9291399518 100644 --- a/packages/sdk/langs/python/superdoc/client.py +++ b/packages/sdk/langs/python/superdoc/client.py @@ -22,6 +22,7 @@ DocOpenResult as GeneratedDocOpenResult, ) from .runtime import SuperDocAsyncRuntime, SuperDocSyncRuntime +from .transport import DEFAULT_STDOUT_BUFFER_LIMIT_BYTES UserIdentity = Dict[str, str] @@ -340,6 +341,9 @@ def __init__( request_timeout_ms: int | None = None, watchdog_timeout_ms: int = 30_000, max_queue_depth: int = 100, + # Raise if a single host response can exceed this size (e.g. reading + # very large documents); otherwise the default is safe. + stdout_buffer_limit_bytes: int = DEFAULT_STDOUT_BUFFER_LIMIT_BYTES, default_change_mode: Literal['direct', 'tracked'] | None = None, user: UserIdentity | None = None, ) -> None: @@ -350,6 +354,7 @@ def __init__( request_timeout_ms=request_timeout_ms, watchdog_timeout_ms=watchdog_timeout_ms, max_queue_depth=max_queue_depth, + stdout_buffer_limit_bytes=stdout_buffer_limit_bytes, default_change_mode=default_change_mode, user=user, ) diff --git a/packages/sdk/langs/python/superdoc/runtime.py b/packages/sdk/langs/python/superdoc/runtime.py index e303754502..25dc3727e0 100644 --- a/packages/sdk/langs/python/superdoc/runtime.py +++ b/packages/sdk/langs/python/superdoc/runtime.py @@ -14,7 +14,11 @@ from .embedded_cli import resolve_embedded_cli_path from .generated.contract import OPERATION_INDEX from .protocol import normalize_default_change_mode -from .transport import AsyncHostTransport, SyncHostTransport +from .transport import ( + DEFAULT_STDOUT_BUFFER_LIMIT_BYTES, + AsyncHostTransport, + SyncHostTransport, +) class SuperDocSyncRuntime: @@ -79,6 +83,7 @@ def __init__( request_timeout_ms: Optional[int] = None, watchdog_timeout_ms: int = 30_000, max_queue_depth: int = 100, + stdout_buffer_limit_bytes: int = DEFAULT_STDOUT_BUFFER_LIMIT_BYTES, default_change_mode: Optional[str] = None, user: Optional[Dict[str, str]] = None, ) -> None: @@ -93,6 +98,7 @@ def __init__( request_timeout_ms=request_timeout_ms, watchdog_timeout_ms=watchdog_timeout_ms, max_queue_depth=max_queue_depth, + stdout_buffer_limit_bytes=stdout_buffer_limit_bytes, default_change_mode=self._default_change_mode, user=user, ) diff --git a/packages/sdk/langs/python/superdoc/transport.py b/packages/sdk/langs/python/superdoc/transport.py index fec79e9c8e..d7c4a9ca0e 100644 --- a/packages/sdk/langs/python/superdoc/transport.py +++ b/packages/sdk/langs/python/superdoc/transport.py @@ -43,6 +43,12 @@ logger = logging.getLogger('superdoc.transport') +# Default stdout StreamReader buffer for the async transport. Host responses +# are single newline-delimited JSON lines, so this caps the largest individual +# response a caller can receive. Raise it if your workload routinely produces +# responses above this size (e.g. whole-document reads on very large docs). +DEFAULT_STDOUT_BUFFER_LIMIT_BYTES = 64 * 1024 * 1024 + # Opt-in debug logging via SUPERDOC_DEBUG=1 or SUPERDOC_LOG_LEVEL=debug. # Only configures the named logger — never mutates root logging config. _log_level = os.environ.get('SUPERDOC_LOG_LEVEL', '').lower() @@ -399,6 +405,7 @@ def __init__( request_timeout_ms: Optional[int] = None, watchdog_timeout_ms: int = 30_000, max_queue_depth: int = 100, + stdout_buffer_limit_bytes: int = DEFAULT_STDOUT_BUFFER_LIMIT_BYTES, default_change_mode: Optional[ChangeMode] = None, user: Optional[Dict[str, str]] = None, ) -> None: @@ -409,11 +416,13 @@ def __init__( self._request_timeout_ms = request_timeout_ms self._watchdog_timeout_ms = watchdog_timeout_ms self._max_queue_depth = max_queue_depth + self._stdout_buffer_limit_bytes = stdout_buffer_limit_bytes self._default_change_mode = default_change_mode self._user = user self._process: Optional[asyncio.subprocess.Process] = None self._reader_task: Optional[asyncio.Task] = None + self._cleanup_task: Optional[asyncio.Task] = None self._pending: Dict[int, asyncio.Future] = {} self._state = _State.DISCONNECTED self._next_request_id = 1 @@ -428,7 +437,22 @@ async def connect(self) -> None: async def dispose(self) -> None: """Gracefully shut down the host process.""" - if self._state == _State.DISCONNECTED or self._state == _State.DISPOSING: + if self._state == _State.DISCONNECTED: + return + if self._state == _State.DISPOSING: + # A reader-triggered cleanup is in flight (or an earlier teardown + # left state in DISPOSING briefly). Wait for it so the caller + # observes "host fully torn down" by the time dispose() returns. + # shield() so a cancelled dispose() doesn't interrupt _cleanup + # mid-flight and leak the host process. + existing = self._cleanup_task + if existing and not existing.done(): + try: + await asyncio.shield(existing) + except asyncio.CancelledError: + raise + except Exception: + pass return self._stopping = True @@ -507,6 +531,20 @@ async def invoke( async def _ensure_connected(self) -> None: """Lazy connect: spawn and handshake if not already connected.""" + # Drain any in-flight teardown before spawning a new host. Without + # this, a concurrent reader-triggered cleanup would still be running + # when _start_host reassigns self._process / self._reader_task; the + # cleanup task would then cancel the fresh reader and kill the fresh + # process. shield() so we don't cancel the cleanup if our caller is. + cleanup = self._cleanup_task + if cleanup and not cleanup.done(): + try: + await asyncio.shield(cleanup) + except asyncio.CancelledError: + raise + except Exception: + pass + if self._state == _State.CONNECTED and self._process and self._process.returncode is None: return @@ -531,12 +569,15 @@ async def _start_host(self) -> None: args = [*prefix_args, 'host', '--stdio'] try: + # ``limit`` raises asyncio's StreamReader buffer above its 64 KiB + # default; host responses are single JSON lines and can exceed it. self._process = await asyncio.create_subprocess_exec( command, *args, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, env={**os.environ, **self._env}, + limit=self._stdout_buffer_limit_bytes, ) logger.debug('Host spawned (pid=%s, bin=%s).', self._process.pid, self._cli_bin) except Exception as exc: @@ -582,7 +623,29 @@ async def _reader_loop(self) -> None: try: while True: - raw = await process.stdout.readline() + try: + raw = await process.stdout.readline() + except ValueError as exc: + # asyncio.StreamReader.readline() re-raises LimitOverrunError + # from readuntil() as ValueError when a single line exceeds + # `limit` (see CPython asyncio/streams.py). The host is still + # alive — schedule cleanup so a later dispose() doesn't + # short-circuit on DISCONNECTED state. Scoped to readline() + # only so unrelated ValueErrors from dispatch aren't + # reclassified as a buffer-limit error. _schedule_cleanup + # is a no-op when _stopping is set (graceful dispose path). + logger.debug('Reader loop buffer overflow: %s', exc) + self._schedule_cleanup(SuperDocError( + 'Host response exceeded stdout buffer limit. ' + 'Raise stdout_buffer_limit_bytes to accommodate larger responses.', + code=HOST_PROTOCOL_ERROR, + details={ + 'message': str(exc), + 'stdout_buffer_limit_bytes': self._stdout_buffer_limit_bytes, + }, + )) + return + if not raw: # EOF — process died. break @@ -614,16 +677,16 @@ async def _reader_loop(self) -> None: except Exception as exc: logger.debug('Reader loop error: %s', exc) - # Reader exited (EOF or error) — reject all pending futures. - if not self._stopping: - exit_code = process.returncode - error = SuperDocError( - 'Host process disconnected.', - code=HOST_DISCONNECTED, - details={'exit_code': exit_code, 'signal': None}, - ) - self._reject_all_pending(error) - self._state = _State.DISCONNECTED + # Reader exited (EOF or unexpected error) — tear down the process so + # no orphaned host is left running, then reject pending futures. + # _schedule_cleanup is a no-op when _stopping is set (graceful + # dispose path) so we don't race the dispose teardown. + exit_code = process.returncode + self._schedule_cleanup(SuperDocError( + 'Host process disconnected.', + code=HOST_DISCONNECTED, + details={'exit_code': exit_code, 'signal': None}, + )) async def _send_request(self, method: str, params: Any, watchdog_ms: int) -> Any: """Send a JSON-RPC request and await the matching response future.""" @@ -687,34 +750,88 @@ def _reject_all_pending(self, error: SuperDocError) -> None: future.set_exception(error) async def _kill_and_reset(self) -> None: - """Kill the host process and reset to DISCONNECTED.""" - await self._cleanup( - SuperDocError('Host process disconnected.', code=HOST_DISCONNECTED), - ) - - async def _cleanup(self, error: Optional[SuperDocError]) -> None: - """Cancel reader, kill process, reject pending, reset state.""" - if self._reader_task and not self._reader_task.done(): - self._reader_task.cancel() + """Kill the host process and reset to DISCONNECTED. + + Coordinates with `_schedule_cleanup` so callers (e.g. `_send_request` + on watchdog timeout or stdin write failure) don't run a parallel + `_cleanup` that races a reader-triggered cleanup on + `_reject_all_pending` and `process.kill`. If a cleanup is already in + flight, await it; otherwise own a fresh task in the same slot so a + later concurrent caller sees us instead of starting its own. + + shield() the await so caller cancellation (e.g. an `invoke()` task + that times out and is then cancelled by the user) does NOT propagate + into `_cleanup` — interrupting cleanup mid-flight would leak the + subprocess and wedge state in DISPOSING. + """ + existing = self._cleanup_task + if existing and not existing.done(): try: - await self._reader_task - except (asyncio.CancelledError, Exception): + await asyncio.shield(existing) + except asyncio.CancelledError: + raise + except Exception: pass - self._reader_task = None + return + self._state = _State.DISPOSING + task = asyncio.create_task(self._cleanup( + SuperDocError('Host process disconnected.', code=HOST_DISCONNECTED), + )) + self._cleanup_task = task + try: + await asyncio.shield(task) + except asyncio.CancelledError: + raise + except Exception: + pass + def _schedule_cleanup(self, error: SuperDocError) -> None: + """Fire-and-forget teardown from inside the reader task. + + Why a separate task: `_cleanup` cancels and awaits `self._reader_task`. + Awaiting it from inside the reader itself would deadlock — so we punt + to a fresh task, and by the time it runs the reader has already + returned (so cancel+await is a no-op). + + Synchronously flips state to DISPOSING so concurrent `invoke()` callers + observe the failed transport immediately rather than passing the + CONNECTED fast path and blocking on a future the dead reader can never + resolve until `watchdog_timeout_ms`. + + Skips when `_stopping` is set: a graceful `dispose()` is already + tearing down, and a parallel cleanup task would race on + `_reject_all_pending` and `process.kill`. + + Idempotent: if a cleanup is already in flight, subsequent errors are + dropped — the first one wins. Callers may observe completion via + `self._cleanup_task`. + """ + if self._stopping: + return + if self._cleanup_task and not self._cleanup_task.done(): + return + self._state = _State.DISPOSING + self._cleanup_task = asyncio.create_task(self._cleanup(error)) + + async def _cleanup(self, error: Optional[SuperDocError]) -> None: + """Cancel reader, kill process, reject pending, reset state. + + Capture handles and flip user-visible state SYNCHRONOUSLY at the top + before any awaits. That way, even if cancellation arrives during + `process.wait()`, observers see a consistent "torn down" transport + (state DISCONNECTED, _process None, pending futures rejected) rather + than a half-disposed one. The async work below is best-effort + process reaping. + """ + # Snapshot and clear before any await so concurrent callers see a + # fully torn-down transport from this point on. + reader_task = self._reader_task process = self._process - if process: - try: - process.kill() - except Exception: - pass - try: - await asyncio.wait_for(process.wait(), timeout=2) - except (asyncio.TimeoutError, Exception): - pass + self._reader_task = None self._process = None + self._state = _State.DISCONNECTED - if error: + if error is not None: self._reject_all_pending(error) else: # Dispose path — reject remaining with generic disconnect. @@ -722,4 +839,31 @@ async def _cleanup(self, error: Optional[SuperDocError]) -> None: SuperDocError('Host process was disposed.', code=HOST_DISCONNECTED), ) - self._state = _State.DISCONNECTED + try: + if reader_task and not reader_task.done(): + reader_task.cancel() + try: + await reader_task + except (asyncio.CancelledError, Exception): + pass + + if process: + try: + process.kill() + except Exception: + pass + try: + await asyncio.wait_for(process.wait(), timeout=2) + except (asyncio.TimeoutError, asyncio.CancelledError, Exception): + pass + finally: + # Release the task handle if we are the in-flight cleanup task, + # so introspection doesn't surface a stale done handle and the + # next teardown gets a fresh slot. Skip when called inline (e.g. + # from dispose) — that current task is not our cleanup task. + try: + current = asyncio.current_task() + except RuntimeError: + current = None + if current is not None and self._cleanup_task is current: + self._cleanup_task = None diff --git a/packages/sdk/langs/python/tests/test_transport.py b/packages/sdk/langs/python/tests/test_transport.py index d71bafa186..f54c0c7a71 100644 --- a/packages/sdk/langs/python/tests/test_transport.py +++ b/packages/sdk/langs/python/tests/test_transport.py @@ -24,7 +24,11 @@ HOST_TIMEOUT, SuperDocError, ) -from superdoc.transport import AsyncHostTransport, SyncHostTransport +from superdoc.transport import ( + DEFAULT_STDOUT_BUFFER_LIMIT_BYTES, + AsyncHostTransport, + SyncHostTransport, +) MOCK_HOST = os.path.join(os.path.dirname(__file__), 'mock_host.py') @@ -447,9 +451,24 @@ async def test_host_crash(self): try: transport = AsyncHostTransport(cli, startup_timeout_ms=5_000) await transport.connect() + process = transport._process + assert process is not None with pytest.raises(SuperDocError) as exc_info: await transport.invoke(_TEST_OP, {'query': 'test'}) - assert exc_info.value.code in (HOST_DISCONNECTED, HOST_TIMEOUT) + # The reader-loop EOF branch now goes through _schedule_cleanup, + # which rejects the pending future synchronously enough that the + # invoke() never has to fall back to the watchdog timeout. + assert exc_info.value.code == HOST_DISCONNECTED + + # Cleanup must tear the process down — pre-fix, the inline + # _reject_all_pending + state flip left the process orphaned. + cleanup_task = transport._cleanup_task + if cleanup_task is not None: + await cleanup_task + assert transport._process is None + assert transport.state == 'DISCONNECTED' + await process.wait() + assert process.returncode is not None finally: _cleanup_wrapper(cli) @@ -493,3 +512,432 @@ async def test_reuse_after_dispose(self): await transport.dispose() finally: _cleanup_wrapper(cli2) + + +class TestAsyncLargeResponse: + """Responses larger than the StreamReader buffer must not crash the reader.""" + + @pytest.mark.asyncio + async def test_response_above_asyncio_default_streamreader_limit(self): + big_payload = 'x' * (200 * 1024) + cli = _mock_cli_bin({ + 'handshake': 'ok', + 'responses': [{'data': {'content': big_payload}}], + }) + try: + transport = AsyncHostTransport(cli, startup_timeout_ms=5_000) + await transport.connect() + result = await transport.invoke(_TEST_OP, {'query': 'big'}) + assert result == {'content': big_payload} + assert transport.state == 'CONNECTED' + await transport.dispose() + finally: + _cleanup_wrapper(cli) + + @pytest.mark.asyncio + async def test_response_above_custom_buffer_limit_raises_protocol_error(self): + # Setting stdout_buffer_limit_bytes below the response size should + # surface HOST_PROTOCOL_ERROR (actionable) rather than + # HOST_DISCONNECTED (misleading — the host is still alive), and the + # error should carry a hint to raise the buffer limit. + from superdoc.errors import HOST_PROTOCOL_ERROR + + big_payload = 'x' * (200 * 1024) + cli = _mock_cli_bin({ + 'handshake': 'ok', + 'responses': [{'data': {'content': big_payload}}], + }) + try: + transport = AsyncHostTransport( + cli, + startup_timeout_ms=5_000, + stdout_buffer_limit_bytes=64 * 1024, + ) + await transport.connect() + process = transport._process + assert process is not None + with pytest.raises(SuperDocError) as exc_info: + await transport.invoke(_TEST_OP, {'query': 'big'}) + assert exc_info.value.code == HOST_PROTOCOL_ERROR + assert 'stdout_buffer_limit_bytes' in str(exc_info.value) + + # The host process must be torn down — not just the transport + # state flipped to DISCONNECTED. Otherwise dispose() short-circuits + # and leaves an orphaned host running. + cleanup_task = transport._cleanup_task + if cleanup_task is not None: + await cleanup_task + assert transport._process is None + assert transport.state == 'DISCONNECTED' + # The captured handle should be reaped by _cleanup; await wait() + # rather than reading returncode to avoid a CI-timing flake if the + # 2 s wait inside _cleanup didn't finish reaping in time. + await process.wait() + assert process.returncode is not None + + # dispose() after an overflow must be a safe no-op: state and + # process stay as cleanup left them, no exception is raised, and + # a second dispose() is also safe. + await transport.dispose() + assert transport.state == 'DISCONNECTED' + assert transport._process is None + await transport.dispose() + assert transport.state == 'DISCONNECTED' + finally: + _cleanup_wrapper(cli) + + @pytest.mark.asyncio + async def test_client_threads_stdout_buffer_limit_to_transport(self): + # End-to-end wiring check: the public AsyncSuperDocClient constructor + # must thread stdout_buffer_limit_bytes through SuperDocAsyncRuntime + # into AsyncHostTransport. Without this, a silent drop in client.py + # or runtime.py would leave the existing overflow test passing while + # the public API reverts to the asyncio 64 KiB default. + from superdoc.client import AsyncSuperDocClient + + cli = _mock_cli_bin({'handshake': 'ok'}) + try: + client = AsyncSuperDocClient( + env={'SUPERDOC_CLI_BIN': cli}, + stdout_buffer_limit_bytes=64 * 1024, + ) + transport = client._runtime._transport + assert transport._stdout_buffer_limit_bytes == 64 * 1024 + finally: + _cleanup_wrapper(cli) + + +class TestAsyncCleanupLifecycle: + """Lock down the cleanup-task slot so its load-bearing invariants don't + silently regress: the dedupe guard, the _stopping suppression branch, + the _kill_and_reset coordination with reader-triggered cleanup, and the + _ensure_connected drain that prevents stale cleanup from killing a + freshly-spawned host. + """ + + @pytest.mark.asyncio + async def test_schedule_cleanup_dedupe_guard_drops_reentrant_call(self): + # If a cleanup task is already in flight, a second _schedule_cleanup + # must NOT replace it — that would cancel the in-flight teardown + # mid-flight and could leak the host process. + cli = _mock_cli_bin({'handshake': 'ok'}) + try: + transport = AsyncHostTransport(cli, startup_timeout_ms=5_000) + await transport.connect() + + slow = asyncio.create_task(asyncio.sleep(0.5)) + transport._cleanup_task = slow + + transport._schedule_cleanup( + SuperDocError('second', code=HOST_DISCONNECTED), + ) + # Slot must still point at the original task — second call dropped. + assert transport._cleanup_task is slow + + slow.cancel() + try: + await slow + except (asyncio.CancelledError, Exception): + pass + transport._cleanup_task = None + await transport.dispose() + finally: + _cleanup_wrapper(cli) + + @pytest.mark.asyncio + async def test_schedule_cleanup_skipped_when_stopping(self): + # When `dispose()` is in progress, `_stopping` is set; the production + # guard inside `_schedule_cleanup` must short-circuit so a reader + # overflow doesn't race the graceful teardown. (Earlier iterations + # of this test were tautological because the test re-checked + # `_stopping` before calling `_schedule_cleanup`. This version calls + # it unconditionally and asserts the production guard fires.) + cli = _mock_cli_bin({'handshake': 'ok'}) + try: + transport = AsyncHostTransport(cli, startup_timeout_ms=5_000) + await transport.connect() + transport._stopping = True + assert transport._cleanup_task is None + + transport._schedule_cleanup( + SuperDocError('overflow', code=HOST_PROTOCOL_ERROR), + ) + assert transport._cleanup_task is None + assert transport.state == 'CONNECTED' + + transport._stopping = False + await transport.dispose() + finally: + _cleanup_wrapper(cli) + + @pytest.mark.asyncio + async def test_kill_and_reset_awaits_in_flight_cleanup(self): + # If a reader-triggered cleanup is already running, _kill_and_reset + # must await it rather than spin up a parallel _cleanup that would + # race on _reject_all_pending and process.kill. + cli = _mock_cli_bin({'handshake': 'ok'}) + try: + transport = AsyncHostTransport(cli, startup_timeout_ms=5_000) + await transport.connect() + + # Replace _cleanup with a tracking stub so we can count entries + # and verify the second call observes the first task instead of + # creating a fresh one. Use Events for deterministic ordering + # rather than asyncio.sleep(0) (which is implementation-defined + # under uvloop / Python scheduling changes). + entry_count = 0 + started = asyncio.Event() + release = asyncio.Event() + real_cleanup = transport._cleanup + + async def tracking_cleanup(error): + nonlocal entry_count + entry_count += 1 + started.set() + # First entry blocks until the test releases it; subsequent + # entries (if any) would race past — failure mode for the bug. + await release.wait() + await real_cleanup(error) + + transport._cleanup = tracking_cleanup # type: ignore[assignment] + + transport._schedule_cleanup( + SuperDocError('reader-overflow', code=HOST_PROTOCOL_ERROR), + ) + await asyncio.wait_for(started.wait(), timeout=2.0) + assert entry_count == 1 + assert transport._cleanup_task is not None + assert not transport._cleanup_task.done() + + kill_task = asyncio.create_task(transport._kill_and_reset()) + # Give kill_task a chance to enter — but it must NOT start a + # second _cleanup (which would re-fire `started`). + await asyncio.sleep(0.05) + assert entry_count == 1 + assert not kill_task.done() + + release.set() + await kill_task + assert entry_count == 1 + assert transport.state == 'DISCONNECTED' + assert transport._cleanup_task is None + finally: + _cleanup_wrapper(cli) + + @pytest.mark.asyncio + async def test_dispose_waits_for_in_flight_cleanup(self): + # `dispose()` called while a reader-triggered cleanup is in flight + # must wait for it to finish, so the caller observes "fully torn + # down" by the time dispose returns. + cli = _mock_cli_bin({'handshake': 'ok'}) + try: + transport = AsyncHostTransport(cli, startup_timeout_ms=5_000) + await transport.connect() + + started = asyncio.Event() + release = asyncio.Event() + real_cleanup = transport._cleanup + + async def slow_cleanup(error): + started.set() + await release.wait() + await real_cleanup(error) + + transport._cleanup = slow_cleanup # type: ignore[assignment] + + transport._schedule_cleanup( + SuperDocError('reader-overflow', code=HOST_PROTOCOL_ERROR), + ) + await asyncio.wait_for(started.wait(), timeout=2.0) + assert transport.state == 'DISPOSING' + + dispose_task = asyncio.create_task(transport.dispose()) + await asyncio.sleep(0.05) + # dispose must still be waiting on the cleanup task. + assert not dispose_task.done() + + release.set() + await dispose_task + assert transport.state == 'DISCONNECTED' + assert transport._process is None + assert transport._cleanup_task is None + finally: + _cleanup_wrapper(cli) + + @pytest.mark.asyncio + async def test_ensure_connected_drains_in_flight_cleanup_before_spawn(self): + # Round-3 regression: without this drain, `_start_host` reassigns + # `self._process` while a stale `_cleanup` task is still scheduled; + # the cleanup then kills the freshly-spawned process. + cli = _mock_cli_bin({'handshake': 'ok'}) + try: + transport = AsyncHostTransport(cli, startup_timeout_ms=5_000) + await transport.connect() + old_process = transport._process + assert old_process is not None + + started = asyncio.Event() + release = asyncio.Event() + real_cleanup = transport._cleanup + + async def slow_cleanup(error): + started.set() + await release.wait() + await real_cleanup(error) + + transport._cleanup = slow_cleanup # type: ignore[assignment] + + transport._schedule_cleanup( + SuperDocError('reader-overflow', code=HOST_PROTOCOL_ERROR), + ) + await asyncio.wait_for(started.wait(), timeout=2.0) + + connect_task = asyncio.create_task(transport.connect()) + await asyncio.sleep(0.05) + # connect() must be blocked on the in-flight cleanup, not racing + # ahead to spawn a fresh process the cleanup would then kill. + assert not connect_task.done() + + release.set() + await connect_task + new_process = transport._process + assert new_process is not None + assert new_process is not old_process + # The fresh process must NOT have been killed by the stale cleanup. + assert new_process.returncode is None + assert transport.state == 'CONNECTED' + await transport.dispose() + finally: + _cleanup_wrapper(cli) + + @pytest.mark.asyncio + async def test_kill_and_reset_caller_cancellation_does_not_cancel_cleanup(self): + # Round-3 regression: without `asyncio.shield`, cancelling the + # awaiter of `_kill_and_reset` propagates into the cleanup task, + # interrupting it mid-flight before `_process` is fully reaped and + # leaving state wedged in DISPOSING. + cli = _mock_cli_bin({'handshake': 'ok'}) + try: + transport = AsyncHostTransport(cli, startup_timeout_ms=5_000) + await transport.connect() + + started = asyncio.Event() + release = asyncio.Event() + real_cleanup = transport._cleanup + + async def slow_cleanup(error): + started.set() + try: + await release.wait() + except asyncio.CancelledError: + # If shield works, this should NOT fire. Re-raise so the + # test's assertion catches the regression. + raise + await real_cleanup(error) + + transport._cleanup = slow_cleanup # type: ignore[assignment] + + kill_task = asyncio.create_task(transport._kill_and_reset()) + await asyncio.wait_for(started.wait(), timeout=2.0) + + kill_task.cancel() + with pytest.raises(asyncio.CancelledError): + await kill_task + + # Cleanup must keep running despite kill_task being cancelled. + assert transport._cleanup_task is not None + assert not transport._cleanup_task.done() + + release.set() + await transport._cleanup_task + assert transport.state == 'DISCONNECTED' + assert transport._process is None + assert transport._cleanup_task is None + finally: + _cleanup_wrapper(cli) + + +class TestAsyncOverflowConcurrency: + """Concurrency scenarios for the buffer-overflow path.""" + + @pytest.mark.asyncio + async def test_overflow_rejects_all_pending_invokes(self): + # Codex/Opus round-3 gap: every pending future must be rejected with + # HOST_PROTOCOL_ERROR — not just the one whose response overflowed. + # A regression where _reject_all_pending only rejects pending[msg.id] + # would silently leave concurrent callers hanging until watchdog. + big_payload = 'x' * (200 * 1024) + cli = _mock_cli_bin({ + 'handshake': 'ok', + 'responses': [ + {'data': {'content': big_payload}}, + {'data': {'v': 2}}, + {'data': {'v': 3}}, + ], + }) + try: + transport = AsyncHostTransport( + cli, + startup_timeout_ms=5_000, + stdout_buffer_limit_bytes=64 * 1024, + watchdog_timeout_ms=10_000, + ) + await transport.connect() + tasks = [ + asyncio.ensure_future(transport.invoke(_TEST_OP, {'query': f'q{i}'})) + for i in range(3) + ] + results = await asyncio.gather(*tasks, return_exceptions=True) + assert all(isinstance(r, SuperDocError) for r in results), results + assert all(r.code == HOST_PROTOCOL_ERROR for r in results) + # Every error must carry the actionable hint, not just the first. + assert all('stdout_buffer_limit_bytes' in str(r) for r in results) + assert transport._pending == {} + assert transport.state == 'DISCONNECTED' + await transport.dispose() + finally: + _cleanup_wrapper(cli) + + @pytest.mark.asyncio + async def test_reconnect_after_buffer_overflow(self): + # Sync transport has test_reconnect_after_failure; async previously + # only had reconnect-after-explicit-dispose. After reader-triggered + # cleanup the transport must be reusable for a fresh invoke without + # leaving _cleanup_task / _connecting / _process in a wedged state. + cli1 = _mock_cli_bin({ + 'handshake': 'ok', + 'responses': [{'data': {'content': 'x' * (200 * 1024)}}], + }) + transport = None + try: + transport = AsyncHostTransport( + cli1, + startup_timeout_ms=5_000, + stdout_buffer_limit_bytes=64 * 1024, + ) + await transport.connect() + with pytest.raises(SuperDocError) as exc_info: + await transport.invoke(_TEST_OP, {'query': 'big'}) + assert exc_info.value.code == HOST_PROTOCOL_ERROR + cleanup_task = transport._cleanup_task + if cleanup_task is not None: + await cleanup_task + assert transport.state == 'DISCONNECTED' + assert transport._cleanup_task is None + finally: + _cleanup_wrapper(cli1) + + cli2 = _mock_cli_bin({ + 'handshake': 'ok', + 'responses': [{'data': {'v': 'reconnected'}}], + }) + try: + # Reuse the transport — point at a healthy host with default buffer. + transport._cli_bin = cli2 + transport._stdout_buffer_limit_bytes = DEFAULT_STDOUT_BUFFER_LIMIT_BYTES + result = await transport.invoke(_TEST_OP, {'query': 'again'}) + assert result == {'v': 'reconnected'} + assert transport.state == 'CONNECTED' + await transport.dispose() + finally: + _cleanup_wrapper(cli2) From 89a481ddd3591f0d0e355144b74fb6b5780b5067 Mon Sep 17 00:00:00 2001 From: "superdoc-bot[bot]" <235763992+superdoc-bot[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:10:16 -0300 Subject: [PATCH 05/30] docs: add michaelreavant to community contributors (#2866) Co-authored-by: github-actions[bot] --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index b49e7d46c0..badd228190 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,7 @@ Special thanks to these community members who have contributed code to SuperDoc: baristaGeek Anuj52 Abdeltoto +michaelreavant Want to see your avatar here? Check the [Contributing Guide](CONTRIBUTING.md) to get started. From 4ac72bfd4ff9738a02012367ad4f638c9be40b62 Mon Sep 17 00:00:00 2001 From: "superdoc-bot[bot]" <235763992+superdoc-bot[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:10:50 -0300 Subject: [PATCH 06/30] docs: add JoaaoVerona to community contributors (#2859) Co-authored-by: github-actions[bot] Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index badd228190..26429f3a37 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,7 @@ Special thanks to these community members who have contributed code to SuperDoc: baristaGeek Anuj52 Abdeltoto +JoaaoVerona michaelreavant Want to see your avatar here? Check the [Contributing Guide](CONTRIBUTING.md) to get started. From 41722f57874ca4b2e4c7daf5155e12b8702227b0 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 20 Apr 2026 10:18:30 -0300 Subject: [PATCH 07/30] fix: prevent text overlap when multi-column sections span pages (SD-1869) (#2676) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: use actual font size for tab runs instead of hardcoded 12pt default The tab measurement code hardcoded `maxFontSize: 12` for tab-created lines and tab runs, causing incorrect line height calculations when the surrounding text used a different font size. This also adds `fontSize: '0'` to line styles to eliminate the CSS strut that caused baseline misalignment between normal-flow and absolutely-positioned (tab-aligned) children. * fix: prevent text overlap when multi-column section spills across pages When a multi-column section's content spans multiple pages and columns have unequal heights, the next section's content was positioned at the shorter column's cursor Y instead of below all column content. This caused visible text overlap on page 2. Add maxCursorY to PageState to track the deepest Y reached across all columns. startMidPageRegion now uses this value so the next section begins below all column content. * fix: eliminate CSS strut misalignment between tab-segmented and normal lines Lines with tab stops use absolute positioning for text segments, which bypasses the CSS strut. Normal-flow lines inherit the browser's default 16px font-size, creating a strut that shifts text down ~1px via baseline alignment. This made tab-indented first lines appear shifted up relative to continuation lines. Zero the line container's font-size to remove the strut, and restore explicit font-size on the three child elements that inherit rather than set their own: empty-run caret placeholder (lineHeight), math run wrapper (run height), and field annotation wrapper (16px fallback). * test: add regression test for maxCursorY overlap fix (SD-1869) Verifies that mid-page column transitions start below the tallest column when columns have unequal heights. Uses a 3-col → 2-col transition to exercise the maxCursorY tracking without triggering the new-page guard (columnIndexBefore >= newColumns.count). * fix: use browser default font size for math run fallback run.height is a layout heuristic that can reach 80–100px for tall expressions (fractions, equation arrays). Using it as the wrapper's font-size made the plain-text fallback render at that size. Swap to BROWSER_DEFAULT_FONT_SIZE (16px) — MathML has its own scaling, so the value only affects the textContent fallback path. --------- Co-authored-by: Caio Pizzol --- .../layout-engine/src/index.test.ts | 72 ++++++++++ .../layout-engine/layout-engine/src/index.ts | 17 ++- .../layout-engine/src/layout-drawing.test.ts | 2 + .../layout-engine/src/layout-drawing.ts | 1 + .../layout-engine/src/layout-image.ts | 1 + .../src/layout-paragraph.test.ts | 3 + .../layout-engine/src/layout-paragraph.ts | 3 + .../layout-engine/src/layout-table.ts | 5 + .../layout-engine/src/paginator.ts | 7 + .../measuring/dom/src/index.test.ts | 85 ++++++++++++ .../layout-engine/measuring/dom/src/index.ts | 5 +- .../painters/dom/src/index.test.ts | 124 ++++++++++++++++++ .../painters/dom/src/renderer.ts | 24 +++- .../painters/dom/src/styles.test.ts | 15 ++- .../layout-engine/painters/dom/src/styles.ts | 16 +++ 15 files changed, 368 insertions(+), 12 deletions(-) diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index e080a72196..b73c27ee11 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -2013,6 +2013,78 @@ describe('layoutDocument', () => { expect(p3Fragment).toBeDefined(); expect(p3Fragment?.width).toBe(210); // Half width = two columns }); + + it('starts new region below tallest column when columns have unequal heights', () => { + // Regression test for SD-1869: when a multi-column section has unequal column + // heights, the next region must start below the TALLEST column, not the last + // column's cursor. Without the maxCursorY fix, the new region would start at + // the shorter column's bottom, overlapping the taller one. + // + // Uses a 3-col → 2-col transition because the layout engine forces a new page + // when reducing to fewer columns than the current column index (guard at + // columnIndexBefore >= newColumns.count). With 3→2, content in col1 + // (columnIndex=1) stays on the same page (1 < 2). + const toThreeColumns: FlowBlock = { + kind: 'sectionBreak', + id: 'sb-to-3col', + type: 'continuous', + columns: { count: 3, gap: 24 }, + margins: {}, + }; + const toTwoColumns: FlowBlock = { + kind: 'sectionBreak', + id: 'sb-to-2col', + type: 'continuous', + columns: { count: 2, gap: 48 }, + margins: {}, + }; + + const blocks: FlowBlock[] = [ + { kind: 'paragraph', id: 'p1', runs: [] }, // single column preamble + toThreeColumns, + { kind: 'paragraph', id: 'p-cols', runs: [] }, // 3 lines → col0 gets 2, col1 gets 1 + toTwoColumns, + { kind: 'paragraph', id: 'p-after', runs: [] }, // must start below tallest column + ]; + + // p-cols: 3 lines of 250px each (750px total) + // Available column height = 720 (page bottom) - 112 (region top) = 608px + // Column 0 fits lines 0+1 (500px), line 2 overflows to column 1 + // Column 0 bottom = 112 + 500 = 612 + // Column 1 bottom = 112 + 250 = 362 + const measures: Measure[] = [ + makeMeasure([40]), // p1 + { kind: 'sectionBreak' }, + makeMeasure([250, 250, 250]), // p-cols: 3 lines, 2 in col0 + 1 in col1 + { kind: 'sectionBreak' }, + makeMeasure([40]), // p-after + ]; + + const options: LayoutOptions = { + pageSize: { w: 612, h: 792 }, + margins: { top: 72, right: 72, bottom: 72, left: 72 }, + }; + + const layout = layoutDocument(blocks, measures, options); + + // Everything should fit on one page + expect(layout.pages.length).toBe(1); + + // p1 at y=72, height=40 → region for 3-col section starts at y=112 + const regionTop = 72 + 40; // 112 + + // Column 0: 2 lines × 250px = 500px → bottom at 112 + 500 = 612 + // Column 1: 1 line × 250px = 250px → bottom at 112 + 250 = 362 + const tallestColumnBottom = regionTop + 500; // 612 + + const page = layout.pages[0]; + const pAfter = page.fragments.find((f) => f.blockId === 'p-after'); + expect(pAfter).toBeDefined(); + + // KEY ASSERTION: p-after must start at or below the tallest column's bottom (612) + // Without the fix, it would start at 362 (column 1's bottom), overlapping column 0 + expect(pAfter!.y).toBeGreaterThanOrEqual(tallestColumnBottom); + }); }); describe('columnBreak with multi-column pages', () => { diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 8729e109d1..32a2aea6c1 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -1451,9 +1451,16 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options // Start a new mid-page region with different column configuration const startMidPageRegion = (state: PageState, newColumns: ColumnLayout): void => { - // Record the boundary at current Y position + // Use the maximum Y reached across all columns so the new region starts + // below ALL column content, not just the current column's cursor position. + // This prevents overlap when a multi-column section's columns have unequal heights. + const regionStartY = Math.max(state.cursorY, state.maxCursorY); + state.cursorY = regionStartY; + state.maxCursorY = regionStartY; + + // Record the boundary at the resolved Y position const boundary: ConstraintBoundary = { - y: state.cursorY, + y: regionStartY, columns: newColumns, }; state.constraintBoundaries.push(boundary); @@ -1465,7 +1472,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options layoutLog(`[Layout] *** COLUMNS CHANGED MID-PAGE ***`); layoutLog(` OLD activeColumns: ${JSON.stringify(activeColumns)}`); layoutLog(` NEW activeColumns: ${JSON.stringify(newColumns)}`); - layoutLog(` Current page: ${state.page.number}, cursorY: ${state.cursorY}`); + layoutLog(` Current page: ${state.page.number}, cursorY: ${state.cursorY}, maxCursorY: ${state.maxCursorY}`); // Update activeColumns so subsequent pages use this column configuration activeColumns = cloneColumnLayout(newColumns); @@ -1479,9 +1486,6 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options { left: activeLeftMargin, right: activeRightMargin }, activePageSize.w, ); - - // Note: We do NOT reset cursorY - content continues from current position - // This creates the mid-page region effect }; // Collect anchored drawings mapped to their anchor paragraphs @@ -2129,6 +2133,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options } } state.cursorY = tableBottomY; + state.maxCursorY = Math.max(state.maxCursorY, state.cursorY); } continue; } diff --git a/packages/layout-engine/layout-engine/src/layout-drawing.test.ts b/packages/layout-engine/layout-engine/src/layout-drawing.test.ts index 27a5f7f01e..d70415fad2 100644 --- a/packages/layout-engine/layout-engine/src/layout-drawing.test.ts +++ b/packages/layout-engine/layout-engine/src/layout-drawing.test.ts @@ -62,6 +62,7 @@ describe('layoutDrawingBlock', () => { constraintBoundaries: []; activeConstraintIndex: number; trailingSpacing: number; + maxCursorY: number; }; const createMockPageState = (overrides: Record = {}): MockPageState => @@ -76,6 +77,7 @@ describe('layoutDrawingBlock', () => { constraintBoundaries: [], activeConstraintIndex: -1, trailingSpacing: 0, + maxCursorY: 100, ...overrides, }) as MockPageState; diff --git a/packages/layout-engine/layout-engine/src/layout-drawing.ts b/packages/layout-engine/layout-engine/src/layout-drawing.ts index 12596f4da7..1ec149f646 100644 --- a/packages/layout-engine/layout-engine/src/layout-drawing.ts +++ b/packages/layout-engine/layout-engine/src/layout-drawing.ts @@ -140,4 +140,5 @@ export function layoutDrawingBlock({ state.page.fragments.push(fragment); state.cursorY += requiredHeight; + state.maxCursorY = Math.max(state.maxCursorY, state.cursorY); } diff --git a/packages/layout-engine/layout-engine/src/layout-image.ts b/packages/layout-engine/layout-engine/src/layout-image.ts index a1cfdc651e..ee14cae6b1 100644 --- a/packages/layout-engine/layout-engine/src/layout-image.ts +++ b/packages/layout-engine/layout-engine/src/layout-image.ts @@ -86,4 +86,5 @@ export function layoutImageBlock({ state.page.fragments.push(fragment); state.cursorY += requiredHeight; + state.maxCursorY = Math.max(state.maxCursorY, state.cursorY); } diff --git a/packages/layout-engine/layout-engine/src/layout-paragraph.test.ts b/packages/layout-engine/layout-engine/src/layout-paragraph.test.ts index ade89230e7..39220fe3cb 100644 --- a/packages/layout-engine/layout-engine/src/layout-paragraph.test.ts +++ b/packages/layout-engine/layout-engine/src/layout-paragraph.test.ts @@ -60,6 +60,7 @@ const makePageState = (): PageState => ({ trailingSpacing: 0, lastParagraphStyleId: undefined, lastParagraphContextualSpacing: false, + maxCursorY: 50, }); /** @@ -1271,6 +1272,7 @@ describe('layoutParagraphBlock - keepLines', () => { currentState = { ...state, cursorY: 50, // Reset to top of new page + maxCursorY: 50, page: { number: state.page.number + 1, fragments: [] }, trailingSpacing: 0, }; @@ -1420,6 +1422,7 @@ describe('layoutParagraphBlock - keepLines', () => { const advanceColumn = mock((state: PageState) => ({ ...state, cursorY: 50, + maxCursorY: 50, trailingSpacing: 0, page: { number: 2, fragments: [] }, })); diff --git a/packages/layout-engine/layout-engine/src/layout-paragraph.ts b/packages/layout-engine/layout-engine/src/layout-paragraph.ts index 598f1503c7..5bbce31c03 100644 --- a/packages/layout-engine/layout-engine/src/layout-paragraph.ts +++ b/packages/layout-engine/layout-engine/src/layout-paragraph.ts @@ -789,6 +789,7 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para if (neededSpacingBefore > 0) { state.cursorY += neededSpacingBefore; + state.maxCursorY = Math.max(state.maxCursorY, state.cursorY); if (spacingDebugEnabled) { spacingDebugLog('spacingBefore applied', { blockId: block.id, @@ -907,6 +908,7 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para state.page.fragments.push(fragment); state.cursorY += borderExpansion.top + fragmentHeight + borderExpansion.bottom; + state.maxCursorY = Math.max(state.maxCursorY, state.cursorY); lastState = state; fromLine = slice.toLine; } @@ -929,6 +931,7 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para appliedSpacingAfter = 0; } else { targetState.cursorY += spacingAfter; + targetState.maxCursorY = Math.max(targetState.maxCursorY, targetState.cursorY); } targetState.trailingSpacing = appliedSpacingAfter; if (spacingDebugEnabled) { diff --git a/packages/layout-engine/layout-engine/src/layout-table.ts b/packages/layout-engine/layout-engine/src/layout-table.ts index 7f37f86d40..101de88516 100644 --- a/packages/layout-engine/layout-engine/src/layout-table.ts +++ b/packages/layout-engine/layout-engine/src/layout-table.ts @@ -1239,6 +1239,7 @@ function layoutMonolithicTable(context: TableLayoutContext): void { applyTableFragmentPmRange(fragment, context.block, context.measure); state.page.fragments.push(fragment); state.cursorY += height; + state.maxCursorY = Math.max(state.maxCursorY, state.cursorY); } /** @@ -1389,6 +1390,7 @@ export function layoutTableBlock({ applyTableFragmentPmRange(fragment, block, measure); state.page.fragments.push(fragment); state.cursorY += height; + state.maxCursorY = Math.max(state.maxCursorY, state.cursorY); return; } @@ -1554,6 +1556,7 @@ export function layoutTableBlock({ applyTableFragmentPmRange(fragment, block, measure); state.page.fragments.push(fragment); state.cursorY += fragmentHeight; + state.maxCursorY = Math.max(state.maxCursorY, state.cursorY); } const rowComplete = !hasRemainingLinesAfterContinuation; @@ -1668,6 +1671,7 @@ export function layoutTableBlock({ applyTableFragmentPmRange(fragment, block, measure); state.page.fragments.push(fragment); state.cursorY += fragmentHeight; + state.maxCursorY = Math.max(state.maxCursorY, state.cursorY); pendingPartialRow = forcedPartialRow; samePagePartialContinuation = true; isTableContinuation = true; @@ -1717,6 +1721,7 @@ export function layoutTableBlock({ applyTableFragmentPmRange(fragment, block, measure); state.page.fragments.push(fragment); state.cursorY += fragmentHeight; + state.maxCursorY = Math.max(state.maxCursorY, state.cursorY); // Handle partial row tracking if (partialRow && !partialRow.isLastPart) { diff --git a/packages/layout-engine/layout-engine/src/paginator.ts b/packages/layout-engine/layout-engine/src/paginator.ts index a87d43b73c..f09597f3ad 100644 --- a/packages/layout-engine/layout-engine/src/paginator.ts +++ b/packages/layout-engine/layout-engine/src/paginator.ts @@ -20,6 +20,10 @@ export type PageState = { lastParagraphContextualSpacing: boolean; /** Border hash of the last paragraph for between-border group detection. */ lastParagraphBorderHash?: string; + /** Tracks the maximum cursorY reached across all columns on this page. + * Used when starting a mid-page region so the new section begins below + * all column content, not just the current column's cursor. */ + maxCursorY: number; }; export type PaginatorOptions = { @@ -107,6 +111,7 @@ export function createPaginator(opts: PaginatorOptions) { trailingSpacing: 0, lastParagraphStyleId: undefined, lastParagraphContextualSpacing: false, + maxCursorY: topMargin, }; states.push(state); pages.push(state.page); @@ -123,6 +128,8 @@ export function createPaginator(opts: PaginatorOptions) { const advanceColumn = (state: PageState): PageState => { const activeCols = getActiveColumnsForState(state); if (state.columnIndex < activeCols.count - 1) { + // Snapshot max Y before resetting cursor for the next column + state.maxCursorY = Math.max(state.maxCursorY, state.cursorY); state.columnIndex += 1; if (state.activeConstraintIndex >= 0 && state.constraintBoundaries[state.activeConstraintIndex]) { state.cursorY = state.constraintBoundaries[state.activeConstraintIndex].y; diff --git a/packages/layout-engine/measuring/dom/src/index.test.ts b/packages/layout-engine/measuring/dom/src/index.test.ts index c4d26ea236..b46e43fe0a 100644 --- a/packages/layout-engine/measuring/dom/src/index.test.ts +++ b/packages/layout-engine/measuring/dom/src/index.test.ts @@ -1881,6 +1881,91 @@ describe('measureBlock', () => { } } }); + + it('uses surrounding text font size for tab line height, not hardcoded 12', async () => { + // Regression: tab runs previously hardcoded maxFontSize=12, producing + // wrong line heights when the surrounding text used a larger font. + const largeFontBlock: FlowBlock = { + kind: 'paragraph', + id: 'tab-font-size-large', + runs: [ + { text: 'Hello', fontFamily: 'Arial', fontSize: 24 }, + { kind: 'tab', text: '\t', pmStart: 5, pmEnd: 6 }, + { text: 'World', fontFamily: 'Arial', fontSize: 24 }, + ], + attrs: {}, + }; + + const smallFontBlock: FlowBlock = { + kind: 'paragraph', + id: 'tab-font-size-small', + runs: [ + { text: 'Hello', fontFamily: 'Arial', fontSize: 10 }, + { kind: 'tab', text: '\t', pmStart: 5, pmEnd: 6 }, + { text: 'World', fontFamily: 'Arial', fontSize: 10 }, + ], + attrs: {}, + }; + + const largeMeasure = expectParagraphMeasure(await measureBlock(largeFontBlock, 1000)); + const smallMeasure = expectParagraphMeasure(await measureBlock(smallFontBlock, 1000)); + + expect(largeMeasure.lines).toHaveLength(1); + expect(smallMeasure.lines).toHaveLength(1); + + // The large-font paragraph must have a taller line than the small-font one. + // With the old hardcoded 12, both could collapse to similar heights. + expect(largeMeasure.lines[0].lineHeight).toBeGreaterThan(smallMeasure.lines[0].lineHeight); + }); + + it('uses fallback font size when tab is the first run (no preceding text)', async () => { + // When a tab starts a paragraph, lastFontSize should fall back to the + // first text run's font size, not a hardcoded default. + const block: FlowBlock = { + kind: 'paragraph', + id: 'tab-first-run', + runs: [ + { kind: 'tab', text: '\t', pmStart: 0, pmEnd: 1 }, + { text: 'After tab', fontFamily: 'Arial', fontSize: 20 }, + ], + attrs: {}, + }; + + const refBlock: FlowBlock = { + kind: 'paragraph', + id: 'no-tab-ref', + runs: [{ text: 'After tab', fontFamily: 'Arial', fontSize: 20 }], + attrs: {}, + }; + + const measure = expectParagraphMeasure(await measureBlock(block, 1000)); + const refMeasure = expectParagraphMeasure(await measureBlock(refBlock, 1000)); + + expect(measure.lines).toHaveLength(1); + // Line height should match or exceed the reference (same font size drives both) + expect(measure.lines[0].lineHeight).toBeGreaterThanOrEqual(refMeasure.lines[0].lineHeight); + }); + + it('tab-only line inherits font size from following text run', async () => { + // A line that contains only a tab should derive its height from the + // paragraph's font size context, not from a hardcoded 12pt. + const block: FlowBlock = { + kind: 'paragraph', + id: 'tab-only-line', + runs: [{ kind: 'tab', text: '\t', pmStart: 0, pmEnd: 1 }], + attrs: { + // paragraph-level font size hint via a nearby run + }, + }; + + const measure = expectParagraphMeasure(await measureBlock(block, 1000)); + + expect(measure.lines).toHaveLength(1); + // With the fallback font size (default 12 when no runs present), + // the line should still have a reasonable height + expect(measure.lines[0].lineHeight).toBeGreaterThan(0); + expect(measure.lines[0].maxFontSize).toBeGreaterThan(0); + }); }); describe('space-only runs', () => { diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index ded7439f9e..83e050abd2 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -1411,7 +1411,8 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P toRun: runIndex, toChar: 1, width: 0, - maxFontSize: 12, // Default font size for tabs + maxFontSize: lastFontSize, + maxFontInfo: hasSeenTextRun ? undefined : fallbackFontInfo, maxWidth: getEffectiveWidth(lines.length === 0 ? initialAvailableWidth : bodyContentWidth), segments: [], spaceCount: 0, @@ -1432,7 +1433,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P // Persist measured tab width on the TabRun for downstream consumers/tests (run as TabRun & { width?: number }).width = tabAdvance; - currentLine.maxFontSize = Math.max(currentLine.maxFontSize, 12); + currentLine.maxFontSize = Math.max(currentLine.maxFontSize, lastFontSize); currentLine.toRun = runIndex; currentLine.toChar = 1; // tab is a single character let currentLeader: LeaderDecoration | null = null; diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 2ba2389f57..3a1e8f57bf 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -1787,6 +1787,130 @@ describe('DomPainter', () => { const emptySpan = mount.querySelector('.superdoc-line span.superdoc-empty-run') as HTMLElement | null; expect(emptySpan?.dataset.pmStart).toBe('1'); expect(emptySpan?.dataset.pmEnd).toBe('1'); + // Empty-run must set explicit fontSize so it doesn't inherit fontSize:0 from the line + expect(emptySpan?.style.fontSize).toBe('18px'); + }); + + it('sets fallback fontSize on field annotation without explicit fontSize', () => { + const block: FlowBlock = { + kind: 'paragraph', + id: 'fa-no-fontsize', + runs: [ + { + kind: 'fieldAnnotation', + variant: 'text', + displayLabel: 'Client Name', + fieldId: 'F1', + fieldType: 'text', + fieldColor: '#980043', + pmStart: 0, + pmEnd: 1, + }, + ], + }; + const measure: Measure = { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 0, width: 100, ascent: 12, descent: 4, lineHeight: 20 }], + totalHeight: 20, + }; + const testLayout: Layout = { + pageSize: layout.pageSize, + pages: [ + { + number: 1, + fragments: [{ kind: 'para', blockId: 'fa-no-fontsize', fromLine: 0, toLine: 1, x: 10, y: 10, width: 200 }], + }, + ], + }; + const painter = createTestPainter({ blocks: [block], measures: [measure] }); + painter.paint(testLayout, mount); + + const annotation = mount.querySelector('.annotation') as HTMLElement | null; + // Must always set fontSize so it doesn't inherit fontSize:0 from the line. + // Falls back to 16px (browser default) when run has no explicit fontSize. + expect(annotation?.style.fontSize).toBe('16px'); + }); + + it('converts numeric fontSize to pt on field annotation', () => { + const block: FlowBlock = { + kind: 'paragraph', + id: 'fa-numeric-fontsize', + runs: [ + { + kind: 'fieldAnnotation', + variant: 'text', + displayLabel: 'Client Name', + fieldId: 'F1', + fieldType: 'text', + fieldColor: '#980043', + fontSize: 14, + pmStart: 0, + pmEnd: 1, + }, + ], + }; + const measure: Measure = { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 0, width: 100, ascent: 12, descent: 4, lineHeight: 20 }], + totalHeight: 20, + }; + const testLayout: Layout = { + pageSize: layout.pageSize, + pages: [ + { + number: 1, + fragments: [ + { kind: 'para', blockId: 'fa-numeric-fontsize', fromLine: 0, toLine: 1, x: 10, y: 10, width: 200 }, + ], + }, + ], + }; + const painter = createTestPainter({ blocks: [block], measures: [measure] }); + painter.paint(testLayout, mount); + + const annotation = mount.querySelector('.annotation') as HTMLElement | null; + // Numeric fontSize is converted to pt units. + expect(annotation?.style.fontSize).toBe('14pt'); + }); + + it('sets explicit fontSize on math run wrapper', () => { + const block: FlowBlock = { + kind: 'paragraph', + id: 'math-block', + runs: [ + { + kind: 'math', + ommlJson: {}, + textContent: 'x+1', + width: 40, + height: 14, + pmStart: 0, + pmEnd: 1, + }, + ], + }; + const measure: Measure = { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 0, width: 40, ascent: 10, descent: 4, lineHeight: 18 }], + totalHeight: 18, + }; + const testLayout: Layout = { + pageSize: layout.pageSize, + pages: [ + { + number: 1, + fragments: [{ kind: 'para', blockId: 'math-block', fromLine: 0, toLine: 1, x: 10, y: 10, width: 200 }], + }, + ], + }; + const painter = createTestPainter({ blocks: [block], measures: [measure] }); + painter.paint(testLayout, mount); + + const mathWrapper = mount.querySelector('.sd-math') as HTMLElement | null; + // Must set fontSize so fallback text doesn't inherit fontSize:0 from the line. + // Uses browser default (16px) rather than run.height, which would render tall + // expressions at 80–100px for the plain-text fallback path. + expect(mathWrapper?.style.fontSize).toBe('16px'); }); it('renders image fragments', () => { diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 8839e631d2..912c7db70b 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -84,6 +84,7 @@ import { import { assertFragmentPmPositions, assertPmPositions } from './pm-position-validation.js'; import { createRulerElement, ensureRulerStyles, generateRulerDefinitionFromPx } from './ruler/index.js'; import { + BROWSER_DEFAULT_FONT_SIZE, CLASS_NAMES, containerStyles, containerStylesHorizontal, @@ -5228,6 +5229,12 @@ export class DomPainter { // Let browser auto-size to MathML content; estimated dimensions are for layout only wrapper.style.minWidth = `${run.width}px`; wrapper.style.minHeight = `${run.height}px`; + // Restore font-size so the plain-text fallback renders at a reasonable size + // (the line container sets fontSize: 0 to eliminate the CSS strut). MathML + // has its own internal scaling, so this only matters for the textContent + // fallback path. run.height would make tall expressions (fractions, equation + // arrays) render at 80–100px — use the browser default instead. + wrapper.style.fontSize = BROWSER_DEFAULT_FONT_SIZE; wrapper.dataset.layoutEpoch = String(this.layoutEpoch ?? 0); const mathEl = convertOmmlToMathml(run.ommlJson, this.doc); @@ -5724,12 +5731,20 @@ export class DomPainter { } } - // Apply typography to the annotation element + // Apply typography to the annotation element. + // Always set a font-size so the annotation never inherits fontSize: 0 from + // the line container (which zeroes it to eliminate the CSS strut). When the + // run has no explicit fontSize, fall back to BROWSER_DEFAULT_FONT_SIZE (the + // browser default that was previously inherited before the strut fix). if (run.fontFamily) { annotation.style.fontFamily = run.fontFamily; } - if (run.fontSize) { - const fontSize = typeof run.fontSize === 'number' ? `${run.fontSize}pt` : run.fontSize; + { + const fontSize = run.fontSize + ? typeof run.fontSize === 'number' + ? `${run.fontSize}pt` + : run.fontSize + : BROWSER_DEFAULT_FONT_SIZE; annotation.style.fontSize = fontSize; } if (run.textColor) { @@ -5933,6 +5948,9 @@ export class DomPainter { if (lineRange.pmEnd != null) { span.dataset.pmEnd = String(lineRange.pmEnd); } + // Restore font-size so the   remains a visible caret target + // (the line container sets fontSize: 0 to eliminate the CSS strut). + span.style.fontSize = `${line.lineHeight}px`; span.innerHTML = ' '; el.appendChild(span); } diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index d1170bf9f4..bcdd59e772 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -1,5 +1,18 @@ import { describe, expect, it } from 'vitest'; -import { ensureSdtContainerStyles } from './styles.js'; +import { ensureSdtContainerStyles, lineStyles } from './styles.js'; + +describe('lineStyles', () => { + it('sets height and lineHeight from the argument', () => { + const styles = lineStyles(24); + expect(styles.height).toBe('24px'); + expect(styles.lineHeight).toBe('24px'); + }); + + it('sets fontSize to 0 to eliminate the CSS strut', () => { + const styles = lineStyles(20); + expect(styles.fontSize).toBe('0'); + }); +}); describe('ensureSdtContainerStyles', () => { it('suppresses structured-content hover backgrounds in viewing mode, including grouped hover', () => { diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index 2f8cb1e498..e5bd524427 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -1,5 +1,12 @@ import { DOM_CLASS_NAMES } from './constants.js'; +/** + * Fallback font-size applied to child elements inside a line container that + * carry no explicit fontSize. Matches the browser default so rendering is + * preserved after the strut-elimination fix (fontSize: '0' on lines). + */ +export const BROWSER_DEFAULT_FONT_SIZE = '16px'; + export const CLASS_NAMES = { container: 'superdoc-layout', page: 'superdoc-page', @@ -90,6 +97,15 @@ export const fragmentStyles: Partial = { export const lineStyles = (lineHeight: number): Partial => ({ lineHeight: `${lineHeight}px`, height: `${lineHeight}px`, + // Eliminate the CSS "strut" created by the inherited font-size (typically + // the browser default 16px). Without this, the strut shifts normal-flow + // inline children down via baseline alignment, while absolutely-positioned + // children (used for tab-aligned segments) are unaffected — causing + // tab-indented first lines to appear shifted up relative to continuation + // lines. All text-bearing child elements set their own explicit font-size; + // elements that don't (empty-run, math wrapper, field annotation wrapper) + // are patched individually in renderer.ts. + fontSize: '0', position: 'relative', display: 'block', whiteSpace: 'pre', From a9caa8e8666c71bb769a92c6d381886a08690ef6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:23:44 +0000 Subject: [PATCH 08/30] chore(deps): update dependency rollup to v4.60.2 (#2863) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> --- pnpm-lock.yaml | 646 ++++++++++++++++++++++++++++--------------------- 1 file changed, 376 insertions(+), 270 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index abffd22133..1cea47057f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -222,9 +222,6 @@ catalogs: remark-stringify: specifier: ^11.0.0 version: 11.0.0 - rollup: - specifier: ^4.31.0 - version: 4.60.0 rollup-plugin-copy: specifier: ^3.5.0 version: 3.5.0 @@ -288,9 +285,6 @@ catalogs: xml-js: specifier: 1.6.11 version: 1.6.11 - y-prosemirror: - specifier: ^1.3.7 - version: 1.3.7 y-protocols: specifier: ^1.0.6 version: 1.0.7 @@ -431,7 +425,7 @@ importers: version: 6.3.2(typanion@3.14.0) vite-plugin-node-polyfills: specifier: 'catalog:' - version: 0.25.0(rollup@4.60.1)(vite@7.3.1(@types/node@22.19.2)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 0.25.0(rollup@4.60.2)(vite@7.3.1(@types/node@22.19.2)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vitest: specifier: 'catalog:' version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.2)(esbuild@0.27.7)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) @@ -549,7 +543,7 @@ importers: version: 14.0.3 mintlify: specifier: 4.2.446 - version: 4.2.446(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@25.6.0)(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + version: 4.2.446(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@22.19.2)(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) remark-mdx: specifier: ^3.1.1 version: 3.1.1 @@ -757,7 +751,7 @@ importers: version: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite-plugin-vue-devtools: specifier: ^7.7.1 - version: 7.7.9(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(rollup@4.60.1)(vue@3.5.25(typescript@5.9.3)) + version: 7.7.9(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(rollup@4.60.2)(vue@3.5.25(typescript@5.9.3)) demos/fields: dependencies: @@ -2177,7 +2171,7 @@ importers: dependencies: nuxt: specifier: ^4.3.1 - version: 4.4.2(88781acac5190445449d2214489e2877) + version: 4.4.2(76e6888a58687ab8ea9dc0707b97cb56) superdoc: specifier: workspace:* version: link:../../../packages/superdoc @@ -2289,7 +2283,7 @@ importers: version: link:../superdoc tsup: specifier: 'catalog:' - version: 8.5.1(@microsoft/api-extractor@7.57.7(@types/node@22.19.2))(@swc/core@1.15.21)(jiti@2.6.1)(postcss@8.5.9)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + version: 8.5.1(@microsoft/api-extractor@7.57.7(@types/node@22.19.2))(@swc/core@1.15.21)(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: 'catalog:' version: 5.9.3 @@ -2338,7 +2332,7 @@ importers: version: 3.8.1 tsup: specifier: 'catalog:' - version: 8.5.1(@microsoft/api-extractor@7.57.7(@types/node@22.19.2))(@swc/core@1.15.21)(jiti@2.6.1)(postcss@8.5.9)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + version: 8.5.1(@microsoft/api-extractor@7.57.7(@types/node@22.19.2))(@swc/core@1.15.21)(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: 'catalog:' version: 5.9.3 @@ -2394,7 +2388,7 @@ importers: version: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite-plugin-dts: specifier: 'catalog:' - version: 4.5.4(@types/node@25.6.0)(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(rollup@4.60.1)(typescript@5.9.3) + version: 4.5.4(@types/node@25.6.0)(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(rollup@4.60.2)(typescript@5.9.3) vitest: specifier: 'catalog:' version: 3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@25.6.0)(esbuild@0.27.7)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) @@ -2499,7 +2493,7 @@ importers: version: 22.19.2 tsup: specifier: 'catalog:' - version: 8.5.1(@microsoft/api-extractor@7.57.7(@types/node@22.19.2))(@swc/core@1.15.21)(jiti@2.6.1)(postcss@8.5.9)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + version: 8.5.1(@microsoft/api-extractor@7.57.7(@types/node@22.19.2))(@swc/core@1.15.21)(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: 'catalog:' version: 5.9.3 @@ -2527,7 +2521,7 @@ importers: devDependencies: tsup: specifier: 'catalog:' - version: 8.5.1(@microsoft/api-extractor@7.57.7(@types/node@25.6.0))(@swc/core@1.15.21)(jiti@2.6.1)(postcss@8.5.9)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + version: 8.5.1(@microsoft/api-extractor@7.57.7(@types/node@25.6.0))(@swc/core@1.15.21)(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: 'catalog:' version: 5.9.3 @@ -2717,7 +2711,7 @@ importers: version: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite-plugin-dts: specifier: 'catalog:' - version: 4.5.4(@types/node@22.19.2)(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(rollup@4.60.1)(typescript@5.9.3) + version: 4.5.4(@types/node@22.19.2)(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(rollup@4.60.2)(typescript@5.9.3) vitest: specifier: 'catalog:' version: 3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@22.19.2)(esbuild@0.27.7)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) @@ -2736,7 +2730,7 @@ importers: version: 22.19.2 rollup: specifier: 'catalog:' - version: 4.60.0 + version: 4.60.2 typescript: specifier: 'catalog:' version: 5.9.3 @@ -2946,7 +2940,7 @@ importers: version: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite-plugin-node-polyfills: specifier: 'catalog:' - version: 0.25.0(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(rollup@4.60.1) + version: 0.25.0(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(rollup@4.60.2) vitest: specifier: 'catalog:' version: 3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@25.6.0)(esbuild@0.27.7)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) @@ -3046,7 +3040,7 @@ importers: version: 1.4.4 rollup-plugin-visualizer: specifier: 'catalog:' - version: 5.14.0(rollup@4.60.1) + version: 5.14.0(rollup@4.60.2) sirv: specifier: 'catalog:' version: 3.0.2 @@ -3058,10 +3052,10 @@ importers: version: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite-plugin-dts: specifier: 'catalog:' - version: 4.5.4(@types/node@25.6.0)(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(rollup@4.60.1)(typescript@5.9.3) + version: 4.5.4(@types/node@25.6.0)(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(rollup@4.60.2)(typescript@5.9.3) vite-plugin-node-polyfills: specifier: 'catalog:' - version: 0.25.0(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(rollup@4.60.1) + version: 0.25.0(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(rollup@4.60.2) vitest: specifier: 'catalog:' version: 3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@25.6.0)(esbuild@0.27.7)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) @@ -3127,7 +3121,7 @@ importers: version: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite-plugin-dts: specifier: 'catalog:' - version: 4.5.4(@types/node@25.6.0)(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(rollup@4.60.1)(typescript@5.9.3) + version: 4.5.4(@types/node@25.6.0)(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(rollup@4.60.2)(typescript@5.9.3) vitest: specifier: 'catalog:' version: 3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@25.6.0)(esbuild@0.27.7)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) @@ -6380,8 +6374,8 @@ packages: resolution: {integrity: sha512-kmnmVs32MFWbV5X6BInC1/TfCs7y1ugwxv1xHsAIj/DyUfoe7vtO0alRUgbQa57+yRGHBBjlNcEk33SCAt5/dA==} hasBin: true - '@microsoft/app-manifest@1.0.4': - resolution: {integrity: sha512-psoCu+dm3l24PH70FWeq8Sq1WYrGMmq7/sLWKdO/1gy5MCXLuuZJtSDdeEdG+EnPV/SZMGXP4g8GVg2KPoy1sQ==} + '@microsoft/app-manifest@1.0.5': + resolution: {integrity: sha512-4Fc83FdnhVHGqITn9wwyeqkFuaOi4CENJsDAdHg5iy+MeONN66obS4Png643c7rH3sL/ydycwHlDAGs1huKtVg==} '@microsoft/dev-tunnels-contracts@1.1.9': resolution: {integrity: sha512-OayhehwI+CnO0Wr53e29ZJZWGsNA5yVG7r54qmZSLc5HxA5Cozk4hP7EbYDCXkxh4NbQoT1dhTzC8bkRo+wWXw==} @@ -6389,8 +6383,8 @@ packages: '@microsoft/dev-tunnels-management@1.1.9': resolution: {integrity: sha512-wGuFEzvRiWZmDxQMGKEjOKhEIVnLiG6vRUuM9Hwqxpe/kbiyA2WiUyEVpniNPaaw8gDHTf9zJHnPNNj0JiL5mA==} - '@microsoft/m365-spec-parser@0.2.11': - resolution: {integrity: sha512-GyfGcxAk60UVMXx7HAMStpheTtsv/rzxgMLVpHkwiwEYGsmdNnQcvXzNy87ttjNhRNVIHUQKOipUlGazX20BbA==} + '@microsoft/m365-spec-parser@0.2.12': + resolution: {integrity: sha512-rTAqHxGckqakukxx3L4zTwmG5lkj5Hy7uHUV/2FCdtHBEu7/IaVpg5U/XrDWVLbtY0DmrBRSwcsSZuhu2UIW3A==} engines: {node: '>=10.0.0'} '@microsoft/teams-manifest@0.1.5': @@ -9296,8 +9290,8 @@ packages: cpu: [arm] os: [android] - '@rollup/rollup-android-arm-eabi@4.60.1': - resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} + '@rollup/rollup-android-arm-eabi@4.60.2': + resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==} cpu: [arm] os: [android] @@ -9306,8 +9300,8 @@ packages: cpu: [arm64] os: [android] - '@rollup/rollup-android-arm64@4.60.1': - resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} + '@rollup/rollup-android-arm64@4.60.2': + resolution: {integrity: sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==} cpu: [arm64] os: [android] @@ -9316,8 +9310,8 @@ packages: cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-arm64@4.60.1': - resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} + '@rollup/rollup-darwin-arm64@4.60.2': + resolution: {integrity: sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==} cpu: [arm64] os: [darwin] @@ -9326,8 +9320,8 @@ packages: cpu: [x64] os: [darwin] - '@rollup/rollup-darwin-x64@4.60.1': - resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} + '@rollup/rollup-darwin-x64@4.60.2': + resolution: {integrity: sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==} cpu: [x64] os: [darwin] @@ -9336,8 +9330,8 @@ packages: cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-arm64@4.60.1': - resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} + '@rollup/rollup-freebsd-arm64@4.60.2': + resolution: {integrity: sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==} cpu: [arm64] os: [freebsd] @@ -9346,8 +9340,8 @@ packages: cpu: [x64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.60.1': - resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} + '@rollup/rollup-freebsd-x64@4.60.2': + resolution: {integrity: sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==} cpu: [x64] os: [freebsd] @@ -9356,8 +9350,8 @@ packages: cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-gnueabihf@4.60.1': - resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': + resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} cpu: [arm] os: [linux] @@ -9366,8 +9360,8 @@ packages: cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.60.1': - resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} + '@rollup/rollup-linux-arm-musleabihf@4.60.2': + resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} cpu: [arm] os: [linux] @@ -9376,8 +9370,8 @@ packages: cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.60.1': - resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} + '@rollup/rollup-linux-arm64-gnu@4.60.2': + resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} cpu: [arm64] os: [linux] @@ -9386,8 +9380,8 @@ packages: cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.60.1': - resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} + '@rollup/rollup-linux-arm64-musl@4.60.2': + resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} cpu: [arm64] os: [linux] @@ -9396,8 +9390,8 @@ packages: cpu: [loong64] os: [linux] - '@rollup/rollup-linux-loong64-gnu@4.60.1': - resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} + '@rollup/rollup-linux-loong64-gnu@4.60.2': + resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} cpu: [loong64] os: [linux] @@ -9406,8 +9400,8 @@ packages: cpu: [loong64] os: [linux] - '@rollup/rollup-linux-loong64-musl@4.60.1': - resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} + '@rollup/rollup-linux-loong64-musl@4.60.2': + resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} cpu: [loong64] os: [linux] @@ -9416,8 +9410,8 @@ packages: cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-ppc64-gnu@4.60.1': - resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} + '@rollup/rollup-linux-ppc64-gnu@4.60.2': + resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} cpu: [ppc64] os: [linux] @@ -9426,8 +9420,8 @@ packages: cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-ppc64-musl@4.60.1': - resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} + '@rollup/rollup-linux-ppc64-musl@4.60.2': + resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} cpu: [ppc64] os: [linux] @@ -9436,8 +9430,8 @@ packages: cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.60.1': - resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} + '@rollup/rollup-linux-riscv64-gnu@4.60.2': + resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} cpu: [riscv64] os: [linux] @@ -9446,8 +9440,8 @@ packages: cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.60.1': - resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} + '@rollup/rollup-linux-riscv64-musl@4.60.2': + resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} cpu: [riscv64] os: [linux] @@ -9456,8 +9450,8 @@ packages: cpu: [s390x] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.60.1': - resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} + '@rollup/rollup-linux-s390x-gnu@4.60.2': + resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} cpu: [s390x] os: [linux] @@ -9466,8 +9460,8 @@ packages: cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.60.1': - resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} + '@rollup/rollup-linux-x64-gnu@4.60.2': + resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} cpu: [x64] os: [linux] @@ -9476,8 +9470,8 @@ packages: cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.60.1': - resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} + '@rollup/rollup-linux-x64-musl@4.60.2': + resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} cpu: [x64] os: [linux] @@ -9486,8 +9480,8 @@ packages: cpu: [x64] os: [openbsd] - '@rollup/rollup-openbsd-x64@4.60.1': - resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} + '@rollup/rollup-openbsd-x64@4.60.2': + resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} cpu: [x64] os: [openbsd] @@ -9496,8 +9490,8 @@ packages: cpu: [arm64] os: [openharmony] - '@rollup/rollup-openharmony-arm64@4.60.1': - resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} + '@rollup/rollup-openharmony-arm64@4.60.2': + resolution: {integrity: sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==} cpu: [arm64] os: [openharmony] @@ -9506,8 +9500,8 @@ packages: cpu: [arm64] os: [win32] - '@rollup/rollup-win32-arm64-msvc@4.60.1': - resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} + '@rollup/rollup-win32-arm64-msvc@4.60.2': + resolution: {integrity: sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==} cpu: [arm64] os: [win32] @@ -9516,8 +9510,8 @@ packages: cpu: [ia32] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.60.1': - resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} + '@rollup/rollup-win32-ia32-msvc@4.60.2': + resolution: {integrity: sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==} cpu: [ia32] os: [win32] @@ -9526,8 +9520,8 @@ packages: cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.60.1': - resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} + '@rollup/rollup-win32-x64-gnu@4.60.2': + resolution: {integrity: sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==} cpu: [x64] os: [win32] @@ -9536,8 +9530,8 @@ packages: cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.60.1': - resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} + '@rollup/rollup-win32-x64-msvc@4.60.2': + resolution: {integrity: sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==} cpu: [x64] os: [win32] @@ -11349,8 +11343,8 @@ packages: engines: {node: '>=10.0.0'} deprecated: this version has critical issues, please update to the latest version - '@xmldom/xmldom@0.8.12': - resolution: {integrity: sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==} + '@xmldom/xmldom@0.8.13': + resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} '@xmldom/xmldom@0.9.8': @@ -11900,8 +11894,8 @@ packages: axios@1.14.0: resolution: {integrity: sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==} - axios@1.15.0: - resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==} + axios@1.15.1: + resolution: {integrity: sha512-WOG+Jj8ZOvR0a3rAn+Tuf1UQJRxw5venr6DgdbJzngJE3qG7X0kL83CZGpdHMxEm+ZK3seAbvFsw4FfOfP9vxg==} axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} @@ -19147,6 +19141,10 @@ packages: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.10: + resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} + engines: {node: ^10 || ^12 || >=14} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -20145,8 +20143,8 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - rollup@4.60.1: - resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} + rollup@4.60.2: + resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -26333,6 +26331,16 @@ snapshots: chalk: 4.1.2 figures: 3.2.0 + '@inquirer/checkbox@4.3.2(@types/node@22.19.2)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@22.19.2) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.19.2) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.19.2 + '@inquirer/checkbox@4.3.2(@types/node@25.6.0)': dependencies: '@inquirer/ansi': 1.0.2 @@ -26358,6 +26366,13 @@ snapshots: '@inquirer/type': 1.5.5 chalk: 4.1.2 + '@inquirer/confirm@5.1.21(@types/node@22.19.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.2) + '@inquirer/type': 3.0.10(@types/node@22.19.2) + optionalDependencies: + '@types/node': 22.19.2 + '@inquirer/confirm@5.1.21(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26372,6 +26387,19 @@ snapshots: optionalDependencies: '@types/node': 18.19.130 + '@inquirer/core@10.3.2(@types/node@22.19.2)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.19.2) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.19.2 + '@inquirer/core@10.3.2(@types/node@25.6.0)': dependencies: '@inquirer/ansi': 1.0.2 @@ -26438,6 +26466,14 @@ snapshots: chalk: 4.1.2 external-editor: 3.1.0 + '@inquirer/editor@4.2.23(@types/node@22.19.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.2) + '@inquirer/external-editor': 1.0.3(@types/node@22.19.2) + '@inquirer/type': 3.0.10(@types/node@22.19.2) + optionalDependencies: + '@types/node': 22.19.2 + '@inquirer/editor@4.2.23(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26461,6 +26497,14 @@ snapshots: chalk: 4.1.2 figures: 3.2.0 + '@inquirer/expand@4.0.23(@types/node@22.19.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.2) + '@inquirer/type': 3.0.10(@types/node@22.19.2) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.19.2 + '@inquirer/expand@4.0.23(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26469,6 +26513,13 @@ snapshots: optionalDependencies: '@types/node': 25.6.0 + '@inquirer/external-editor@1.0.3(@types/node@22.19.2)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 22.19.2 + '@inquirer/external-editor@1.0.3(@types/node@25.6.0)': dependencies: chardet: 2.1.1 @@ -26493,6 +26544,13 @@ snapshots: '@inquirer/type': 1.5.5 chalk: 4.1.2 + '@inquirer/input@4.3.1(@types/node@22.19.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.2) + '@inquirer/type': 3.0.10(@types/node@22.19.2) + optionalDependencies: + '@types/node': 22.19.2 + '@inquirer/input@4.3.1(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26507,6 +26565,13 @@ snapshots: optionalDependencies: '@types/node': 18.19.130 + '@inquirer/number@3.0.23(@types/node@22.19.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.2) + '@inquirer/type': 3.0.10(@types/node@22.19.2) + optionalDependencies: + '@types/node': 22.19.2 + '@inquirer/number@3.0.23(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26521,6 +26586,14 @@ snapshots: ansi-escapes: 4.3.2 chalk: 4.1.2 + '@inquirer/password@4.0.23(@types/node@22.19.2)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@22.19.2) + '@inquirer/type': 3.0.10(@types/node@22.19.2) + optionalDependencies: + '@types/node': 22.19.2 + '@inquirer/password@4.0.23(@types/node@25.6.0)': dependencies: '@inquirer/ansi': 1.0.2 @@ -26541,6 +26614,21 @@ snapshots: '@inquirer/rawlist': 1.2.16 '@inquirer/select': 1.3.3 + '@inquirer/prompts@7.10.1(@types/node@22.19.2)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@22.19.2) + '@inquirer/confirm': 5.1.21(@types/node@22.19.2) + '@inquirer/editor': 4.2.23(@types/node@22.19.2) + '@inquirer/expand': 4.0.23(@types/node@22.19.2) + '@inquirer/input': 4.3.1(@types/node@22.19.2) + '@inquirer/number': 3.0.23(@types/node@22.19.2) + '@inquirer/password': 4.0.23(@types/node@22.19.2) + '@inquirer/rawlist': 4.1.11(@types/node@22.19.2) + '@inquirer/search': 3.2.2(@types/node@22.19.2) + '@inquirer/select': 4.4.2(@types/node@22.19.2) + optionalDependencies: + '@types/node': 22.19.2 + '@inquirer/prompts@7.10.1(@types/node@25.6.0)': dependencies: '@inquirer/checkbox': 4.3.2(@types/node@25.6.0) @@ -26556,20 +26644,20 @@ snapshots: optionalDependencies: '@types/node': 25.6.0 - '@inquirer/prompts@7.9.0(@types/node@25.6.0)': + '@inquirer/prompts@7.9.0(@types/node@22.19.2)': dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@25.6.0) - '@inquirer/confirm': 5.1.21(@types/node@25.6.0) - '@inquirer/editor': 4.2.23(@types/node@25.6.0) - '@inquirer/expand': 4.0.23(@types/node@25.6.0) - '@inquirer/input': 4.3.1(@types/node@25.6.0) - '@inquirer/number': 3.0.23(@types/node@25.6.0) - '@inquirer/password': 4.0.23(@types/node@25.6.0) - '@inquirer/rawlist': 4.1.11(@types/node@25.6.0) - '@inquirer/search': 3.2.2(@types/node@25.6.0) - '@inquirer/select': 4.4.2(@types/node@25.6.0) + '@inquirer/checkbox': 4.3.2(@types/node@22.19.2) + '@inquirer/confirm': 5.1.21(@types/node@22.19.2) + '@inquirer/editor': 4.2.23(@types/node@22.19.2) + '@inquirer/expand': 4.0.23(@types/node@22.19.2) + '@inquirer/input': 4.3.1(@types/node@22.19.2) + '@inquirer/number': 3.0.23(@types/node@22.19.2) + '@inquirer/password': 4.0.23(@types/node@22.19.2) + '@inquirer/rawlist': 4.1.11(@types/node@22.19.2) + '@inquirer/search': 3.2.2(@types/node@22.19.2) + '@inquirer/select': 4.4.2(@types/node@22.19.2) optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 22.19.2 '@inquirer/rawlist@1.2.16': dependencies: @@ -26577,6 +26665,14 @@ snapshots: '@inquirer/type': 1.5.5 chalk: 4.1.2 + '@inquirer/rawlist@4.1.11(@types/node@22.19.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.2) + '@inquirer/type': 3.0.10(@types/node@22.19.2) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.19.2 + '@inquirer/rawlist@4.1.11(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26585,6 +26681,15 @@ snapshots: optionalDependencies: '@types/node': 25.6.0 + '@inquirer/search@3.2.2(@types/node@22.19.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.2) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.19.2) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.19.2 + '@inquirer/search@3.2.2(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26602,6 +26707,16 @@ snapshots: chalk: 4.1.2 figures: 3.2.0 + '@inquirer/select@4.4.2(@types/node@22.19.2)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@22.19.2) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.19.2) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.19.2 + '@inquirer/select@4.4.2(@types/node@25.6.0)': dependencies: '@inquirer/ansi': 1.0.2 @@ -26625,6 +26740,10 @@ snapshots: dependencies: mute-stream: 1.0.0 + '@inquirer/type@3.0.10(@types/node@22.19.2)': + optionalDependencies: + '@types/node': 22.19.2 + '@inquirer/type@3.0.10(@types/node@25.6.0)': optionalDependencies: '@types/node': 25.6.0 @@ -27011,18 +27130,15 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@microsoft/app-manifest@1.0.4': + '@microsoft/app-manifest@1.0.5': dependencies: '@types/fs-extra': 11.0.4 - '@types/node-fetch': 2.6.13 '@types/strip-bom': 4.0.1 ajv: 8.18.0 ajv-draft-04: 1.0.0(ajv@8.18.0) ajv-formats: 3.0.1(ajv@8.18.0) - node-fetch: 2.7.0 + node-fetch: 3.3.2 strip-bom: 5.0.0 - transitivePeerDependencies: - - encoding '@microsoft/dev-tunnels-contracts@1.1.9': dependencies: @@ -27035,17 +27151,17 @@ snapshots: '@microsoft/dev-tunnels-management@1.1.9': dependencies: '@microsoft/dev-tunnels-contracts': 1.1.9 - axios: 1.15.0(debug@4.4.3) + axios: 1.15.1(debug@4.4.3) buffer: 5.7.1 debug: 4.4.3(supports-color@5.5.0) vscode-jsonrpc: 4.0.0 transitivePeerDependencies: - supports-color - '@microsoft/m365-spec-parser@0.2.11': + '@microsoft/m365-spec-parser@0.2.12': dependencies: '@apidevtools/swagger-parser': 10.1.1(openapi-types@12.1.3(patch_hash=3bfaffe38d4b2d54af3b18ab5b13a84d03ecaa632f70f51fb8fbff0ca788cb4f)) - '@microsoft/app-manifest': 1.0.4 + '@microsoft/app-manifest': 1.0.5 fs-extra: 11.3.4 js-yaml: 4.1.1 openapi-types: 12.1.3(patch_hash=3bfaffe38d4b2d54af3b18ab5b13a84d03ecaa632f70f51fb8fbff0ca788cb4f) @@ -27115,7 +27231,7 @@ snapshots: dependencies: '@azure/core-auth': 1.10.1 '@microsoft/teams-manifest': 0.1.5 - axios: 1.15.0(debug@4.4.3) + axios: 1.15.1(debug@4.4.3) chai: 4.5.0 jsonschema: 1.5.0 neverthrow: 3.2.0 @@ -27139,11 +27255,11 @@ snapshots: '@feathersjs/hooks': 0.6.5 '@microsoft/dev-tunnels-contracts': 1.1.9 '@microsoft/dev-tunnels-management': 1.1.9 - '@microsoft/m365-spec-parser': 0.2.11 + '@microsoft/m365-spec-parser': 0.2.12 '@microsoft/teamsfx-api': 0.23.1 adm-zip: 0.5.17 ajv: 8.18.0 - axios: 1.15.0(debug@4.4.3) + axios: 1.15.1(debug@4.4.3) axios-retry: 3.9.1 comment-json: 4.6.2 cryptr: 6.4.0 @@ -27192,11 +27308,11 @@ snapshots: '@microsoft/tsdoc@0.16.0': {} - '@mintlify/cli@4.0.1049(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@25.6.0)(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)': + '@mintlify/cli@4.0.1049(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@22.19.2)(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)': dependencies: - '@inquirer/prompts': 7.9.0(@types/node@25.6.0) + '@inquirer/prompts': 7.9.0(@types/node@22.19.2) '@mintlify/common': 1.0.813(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) - '@mintlify/link-rot': 3.0.983(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + '@mintlify/link-rot': 3.0.983(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) '@mintlify/models': 0.0.286 '@mintlify/prebuild': 1.0.954(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) '@mintlify/previewing': 4.0.1012(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) @@ -27209,7 +27325,7 @@ snapshots: front-matter: 4.0.2 fs-extra: 11.2.0 ink: 6.3.0(@types/react@19.2.14)(react@19.2.3) - inquirer: 12.3.0(@types/node@25.6.0) + inquirer: 12.3.0(@types/node@22.19.2) js-yaml: 4.1.0 mdast-util-mdx-jsx: 3.2.0 react: 19.2.3 @@ -27235,7 +27351,7 @@ snapshots: - utf-8-validate - yaml - '@mintlify/common@1.0.661(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(typescript@5.9.3)': + '@mintlify/common@1.0.661(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(typescript@5.9.3)': dependencies: '@asyncapi/parser': 3.4.0 '@mintlify/mdx': 3.0.4(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@5.9.3) @@ -27275,7 +27391,7 @@ snapshots: remark-parse: 11.0.0 remark-rehype: 11.1.1 remark-stringify: 11.0.0 - tailwindcss: 3.4.4(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3)) + tailwindcss: 3.4.4(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3)) unified: 11.0.5 unist-builder: 4.0.0 unist-util-map: 4.0.0 @@ -27359,12 +27475,12 @@ snapshots: - typescript - yaml - '@mintlify/link-rot@3.0.983(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)': + '@mintlify/link-rot@3.0.983(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)': dependencies: '@mintlify/common': 1.0.813(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) '@mintlify/prebuild': 1.0.954(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) '@mintlify/previewing': 4.0.1012(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) - '@mintlify/scraping': 4.0.522(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(typescript@5.9.3) + '@mintlify/scraping': 4.0.522(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(typescript@5.9.3) '@mintlify/validation': 0.1.640(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@5.9.3) fs-extra: 11.1.0 unist-util-visit: 4.1.2 @@ -27508,9 +27624,9 @@ snapshots: - utf-8-validate - yaml - '@mintlify/scraping@4.0.522(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(typescript@5.9.3)': + '@mintlify/scraping@4.0.522(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(typescript@5.9.3)': dependencies: - '@mintlify/common': 1.0.661(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(typescript@5.9.3) + '@mintlify/common': 1.0.661(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(typescript@5.9.3) '@mintlify/openapi-parser': 0.0.8 fs-extra: 11.1.1 hast-util-to-mdast: 10.1.0 @@ -28270,7 +28386,7 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxt/nitro-server@4.4.2(@azure/identity@4.13.1)(@azure/storage-blob@12.31.0)(@babel/core@7.29.0)(@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.0))(better-sqlite3@12.8.0)(db0@0.3.4(better-sqlite3@12.8.0)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(bun-types@1.3.12)(pg@8.20.0)(postgres@3.4.8)))(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(bun-types@1.3.12)(pg@8.20.0)(postgres@3.4.8))(ioredis@5.10.1)(magicast@0.5.2)(nuxt@4.4.2(88781acac5190445449d2214489e2877))(rolldown@1.0.0-rc.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1))(typescript@5.9.3)(xml2js@0.6.2)': + '@nuxt/nitro-server@4.4.2(@azure/identity@4.13.1)(@azure/storage-blob@12.31.0)(@babel/core@7.29.0)(@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.0))(better-sqlite3@12.8.0)(db0@0.3.4(better-sqlite3@12.8.0)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(bun-types@1.3.12)(pg@8.20.0)(postgres@3.4.8)))(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(bun-types@1.3.12)(pg@8.20.0)(postgres@3.4.8))(ioredis@5.10.1)(magicast@0.5.2)(nuxt@4.4.2(76e6888a58687ab8ea9dc0707b97cb56))(rolldown@1.0.0-rc.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1))(typescript@5.9.3)(xml2js@0.6.2)': dependencies: '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) '@nuxt/devalue': 2.0.2 @@ -28289,7 +28405,7 @@ snapshots: klona: 2.0.6 mocked-exports: 0.1.1 nitropack: 2.13.2(@azure/identity@4.13.1)(@azure/storage-blob@12.31.0)(better-sqlite3@12.8.0)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(bun-types@1.3.12)(pg@8.20.0)(postgres@3.4.8))(rolldown@1.0.0-rc.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1))(xml2js@0.6.2) - nuxt: 4.4.2(88781acac5190445449d2214489e2877) + nuxt: 4.4.2(76e6888a58687ab8ea9dc0707b97cb56) nypm: 0.6.5 ohash: 2.0.11 pathe: 2.0.3 @@ -28358,10 +28474,10 @@ snapshots: rc9: 3.0.0 std-env: 3.10.0 - '@nuxt/vite-builder@4.4.2(@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.0))(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(eslint@9.39.4(jiti@2.6.1))(less@4.4.2)(magicast@0.5.2)(meow@13.2.0)(nuxt@4.4.2(88781acac5190445449d2214489e2877))(optionator@0.9.4)(rolldown@1.0.0-rc.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1))(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1))(rollup@4.60.0))(rollup@4.60.0)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(vue-tsc@2.2.12(typescript@5.9.3))(vue@3.5.25(typescript@5.9.3))(yaml@2.8.3)': + '@nuxt/vite-builder@4.4.2(@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.0))(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(eslint@9.39.4(jiti@2.6.1))(less@4.4.2)(magicast@0.5.2)(meow@13.2.0)(nuxt@4.4.2(76e6888a58687ab8ea9dc0707b97cb56))(optionator@0.9.4)(rolldown@1.0.0-rc.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1))(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1))(rollup@4.60.2))(rollup@4.60.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(vue-tsc@2.2.12(typescript@5.9.3))(vue@3.5.25(typescript@5.9.3))(yaml@2.8.3)': dependencies: '@nuxt/kit': 4.4.2(magicast@0.5.2) - '@rollup/plugin-replace': 6.0.3(rollup@4.60.0) + '@rollup/plugin-replace': 6.0.3(rollup@4.60.2) '@vitejs/plugin-vue': 6.0.5(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.25(typescript@5.9.3)) '@vitejs/plugin-vue-jsx': 5.1.5(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.25(typescript@5.9.3)) autoprefixer: 10.4.27(postcss@8.5.8) @@ -28376,7 +28492,7 @@ snapshots: magic-string: 0.30.21 mlly: 1.8.2 mocked-exports: 0.1.1 - nuxt: 4.4.2(88781acac5190445449d2214489e2877) + nuxt: 4.4.2(76e6888a58687ab8ea9dc0707b97cb56) nypm: 0.6.5 pathe: 2.0.3 pkg-types: 2.3.0 @@ -28394,7 +28510,7 @@ snapshots: '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0) '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) rolldown: 1.0.0-rc.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) - rollup-plugin-visualizer: 7.0.1(rolldown@1.0.0-rc.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1))(rollup@4.60.0) + rollup-plugin-visualizer: 7.0.1(rolldown@1.0.0-rc.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1))(rollup@4.60.2) transitivePeerDependencies: - '@biomejs/biome' - '@emnapi/core' @@ -30643,13 +30759,13 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.4': {} - '@rollup/plugin-alias@6.0.0(rollup@4.60.0)': + '@rollup/plugin-alias@6.0.0(rollup@4.60.2)': optionalDependencies: - rollup: 4.60.0 + rollup: 4.60.2 - '@rollup/plugin-commonjs@29.0.2(rollup@4.60.0)': + '@rollup/plugin-commonjs@29.0.2(rollup@4.60.2)': dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.60.0) + '@rollup/pluginutils': 5.3.0(rollup@4.60.2) commondir: 1.0.1 estree-walker: 2.0.2 fdir: 6.5.0(picomatch@4.0.4) @@ -30657,219 +30773,203 @@ snapshots: magic-string: 0.30.21 picomatch: 4.0.4 optionalDependencies: - rollup: 4.60.0 - - '@rollup/plugin-inject@5.0.5(rollup@4.60.0)': - dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.60.0) - estree-walker: 2.0.2 - magic-string: 0.30.21 - optionalDependencies: - rollup: 4.60.0 + rollup: 4.60.2 - '@rollup/plugin-inject@5.0.5(rollup@4.60.1)': + '@rollup/plugin-inject@5.0.5(rollup@4.60.2)': dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.60.1) + '@rollup/pluginutils': 5.3.0(rollup@4.60.2) estree-walker: 2.0.2 magic-string: 0.30.21 optionalDependencies: - rollup: 4.60.1 + rollup: 4.60.2 - '@rollup/plugin-json@6.1.0(rollup@4.60.0)': + '@rollup/plugin-json@6.1.0(rollup@4.60.2)': dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.60.0) + '@rollup/pluginutils': 5.3.0(rollup@4.60.2) optionalDependencies: - rollup: 4.60.0 + rollup: 4.60.2 - '@rollup/plugin-node-resolve@16.0.3(rollup@4.60.0)': + '@rollup/plugin-node-resolve@16.0.3(rollup@4.60.2)': dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.60.0) + '@rollup/pluginutils': 5.3.0(rollup@4.60.2) '@types/resolve': 1.20.2 deepmerge: 4.3.1 is-module: 1.0.0 resolve: 1.22.11 optionalDependencies: - rollup: 4.60.0 + rollup: 4.60.2 - '@rollup/plugin-replace@6.0.3(rollup@4.60.0)': + '@rollup/plugin-replace@6.0.3(rollup@4.60.2)': dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.60.0) + '@rollup/pluginutils': 5.3.0(rollup@4.60.2) magic-string: 0.30.21 optionalDependencies: - rollup: 4.60.0 + rollup: 4.60.2 - '@rollup/plugin-terser@1.0.0(rollup@4.60.0)': + '@rollup/plugin-terser@1.0.0(rollup@4.60.2)': dependencies: serialize-javascript: 7.0.5 smob: 1.6.1 terser: 5.46.1 optionalDependencies: - rollup: 4.60.0 + rollup: 4.60.2 - '@rollup/pluginutils@5.3.0(rollup@4.60.0)': + '@rollup/pluginutils@5.3.0(rollup@4.60.2)': dependencies: '@types/estree': 1.0.8 estree-walker: 2.0.2 picomatch: 4.0.4 optionalDependencies: - rollup: 4.60.0 - - '@rollup/pluginutils@5.3.0(rollup@4.60.1)': - dependencies: - '@types/estree': 1.0.8 - estree-walker: 2.0.2 - picomatch: 4.0.4 - optionalDependencies: - rollup: 4.60.1 + rollup: 4.60.2 '@rollup/rollup-android-arm-eabi@4.60.0': optional: true - '@rollup/rollup-android-arm-eabi@4.60.1': + '@rollup/rollup-android-arm-eabi@4.60.2': optional: true '@rollup/rollup-android-arm64@4.60.0': optional: true - '@rollup/rollup-android-arm64@4.60.1': + '@rollup/rollup-android-arm64@4.60.2': optional: true '@rollup/rollup-darwin-arm64@4.60.0': optional: true - '@rollup/rollup-darwin-arm64@4.60.1': + '@rollup/rollup-darwin-arm64@4.60.2': optional: true '@rollup/rollup-darwin-x64@4.60.0': optional: true - '@rollup/rollup-darwin-x64@4.60.1': + '@rollup/rollup-darwin-x64@4.60.2': optional: true '@rollup/rollup-freebsd-arm64@4.60.0': optional: true - '@rollup/rollup-freebsd-arm64@4.60.1': + '@rollup/rollup-freebsd-arm64@4.60.2': optional: true '@rollup/rollup-freebsd-x64@4.60.0': optional: true - '@rollup/rollup-freebsd-x64@4.60.1': + '@rollup/rollup-freebsd-x64@4.60.2': optional: true '@rollup/rollup-linux-arm-gnueabihf@4.60.0': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': optional: true '@rollup/rollup-linux-arm-musleabihf@4.60.0': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.60.1': + '@rollup/rollup-linux-arm-musleabihf@4.60.2': optional: true '@rollup/rollup-linux-arm64-gnu@4.60.0': optional: true - '@rollup/rollup-linux-arm64-gnu@4.60.1': + '@rollup/rollup-linux-arm64-gnu@4.60.2': optional: true '@rollup/rollup-linux-arm64-musl@4.60.0': optional: true - '@rollup/rollup-linux-arm64-musl@4.60.1': + '@rollup/rollup-linux-arm64-musl@4.60.2': optional: true '@rollup/rollup-linux-loong64-gnu@4.60.0': optional: true - '@rollup/rollup-linux-loong64-gnu@4.60.1': + '@rollup/rollup-linux-loong64-gnu@4.60.2': optional: true '@rollup/rollup-linux-loong64-musl@4.60.0': optional: true - '@rollup/rollup-linux-loong64-musl@4.60.1': + '@rollup/rollup-linux-loong64-musl@4.60.2': optional: true '@rollup/rollup-linux-ppc64-gnu@4.60.0': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.60.1': + '@rollup/rollup-linux-ppc64-gnu@4.60.2': optional: true '@rollup/rollup-linux-ppc64-musl@4.60.0': optional: true - '@rollup/rollup-linux-ppc64-musl@4.60.1': + '@rollup/rollup-linux-ppc64-musl@4.60.2': optional: true '@rollup/rollup-linux-riscv64-gnu@4.60.0': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.60.1': + '@rollup/rollup-linux-riscv64-gnu@4.60.2': optional: true '@rollup/rollup-linux-riscv64-musl@4.60.0': optional: true - '@rollup/rollup-linux-riscv64-musl@4.60.1': + '@rollup/rollup-linux-riscv64-musl@4.60.2': optional: true '@rollup/rollup-linux-s390x-gnu@4.60.0': optional: true - '@rollup/rollup-linux-s390x-gnu@4.60.1': + '@rollup/rollup-linux-s390x-gnu@4.60.2': optional: true '@rollup/rollup-linux-x64-gnu@4.60.0': optional: true - '@rollup/rollup-linux-x64-gnu@4.60.1': + '@rollup/rollup-linux-x64-gnu@4.60.2': optional: true '@rollup/rollup-linux-x64-musl@4.60.0': optional: true - '@rollup/rollup-linux-x64-musl@4.60.1': + '@rollup/rollup-linux-x64-musl@4.60.2': optional: true '@rollup/rollup-openbsd-x64@4.60.0': optional: true - '@rollup/rollup-openbsd-x64@4.60.1': + '@rollup/rollup-openbsd-x64@4.60.2': optional: true '@rollup/rollup-openharmony-arm64@4.60.0': optional: true - '@rollup/rollup-openharmony-arm64@4.60.1': + '@rollup/rollup-openharmony-arm64@4.60.2': optional: true '@rollup/rollup-win32-arm64-msvc@4.60.0': optional: true - '@rollup/rollup-win32-arm64-msvc@4.60.1': + '@rollup/rollup-win32-arm64-msvc@4.60.2': optional: true '@rollup/rollup-win32-ia32-msvc@4.60.0': optional: true - '@rollup/rollup-win32-ia32-msvc@4.60.1': + '@rollup/rollup-win32-ia32-msvc@4.60.2': optional: true '@rollup/rollup-win32-x64-gnu@4.60.0': optional: true - '@rollup/rollup-win32-x64-gnu@4.60.1': + '@rollup/rollup-win32-x64-gnu@4.60.2': optional: true '@rollup/rollup-win32-x64-msvc@4.60.0': optional: true - '@rollup/rollup-win32-x64-msvc@4.60.1': + '@rollup/rollup-win32-x64-msvc@4.60.2': optional: true '@rtsao/scc@1.1.0': {} @@ -31550,7 +31650,7 @@ snapshots: '@stoplight/json-ref-readers@1.2.2': dependencies: - node-fetch: 2.7.0 + node-fetch: 2.6.7 tslib: 1.14.1 transitivePeerDependencies: - encoding @@ -32144,7 +32244,7 @@ snapshots: '@types/mute-stream@0.0.4': dependencies: - '@types/node': 22.19.15 + '@types/node': 20.19.39 '@types/nlcst@2.0.3': dependencies: @@ -32703,10 +32803,10 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vercel/nft@1.5.0(rollup@4.60.0)': + '@vercel/nft@1.5.0(rollup@4.60.2)': dependencies: '@mapbox/node-pre-gyp': 2.0.3 - '@rollup/pluginutils': 5.3.0(rollup@4.60.0) + '@rollup/pluginutils': 5.3.0(rollup@4.60.2) acorn: 8.16.0 acorn-import-attributes: 1.9.5(acorn@8.16.0) async-sema: 3.1.1 @@ -33489,7 +33589,7 @@ snapshots: '@xmldom/xmldom@0.8.11': optional: true - '@xmldom/xmldom@0.8.12': {} + '@xmldom/xmldom@0.8.13': {} '@xmldom/xmldom@0.9.8': {} @@ -34103,7 +34203,7 @@ snapshots: transitivePeerDependencies: - debug - axios@1.15.0(debug@4.4.3): + axios@1.15.1(debug@4.4.3): dependencies: follow-redirects: 1.16.0(debug@4.4.3) form-data: 4.0.5 @@ -38748,12 +38848,12 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - inquirer@12.3.0(@types/node@25.6.0): + inquirer@12.3.0(@types/node@22.19.2): dependencies: - '@inquirer/core': 10.3.2(@types/node@25.6.0) - '@inquirer/prompts': 7.10.1(@types/node@25.6.0) - '@inquirer/type': 3.0.10(@types/node@25.6.0) - '@types/node': 25.6.0 + '@inquirer/core': 10.3.2(@types/node@22.19.2) + '@inquirer/prompts': 7.10.1(@types/node@22.19.2) + '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@types/node': 22.19.2 ansi-escapes: 4.3.2 mute-stream: 2.0.0 run-async: 3.0.0 @@ -41125,9 +41225,9 @@ snapshots: dependencies: minipass: 7.1.3 - mintlify@4.2.446(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@25.6.0)(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): + mintlify@4.2.446(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@22.19.2)(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): dependencies: - '@mintlify/cli': 4.0.1049(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@25.6.0)(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + '@mintlify/cli': 4.0.1049(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@22.19.2)(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) transitivePeerDependencies: - '@radix-ui/react-popover' - '@types/node' @@ -41454,14 +41554,14 @@ snapshots: nitropack@2.13.2(@azure/identity@4.13.1)(@azure/storage-blob@12.31.0)(better-sqlite3@12.8.0)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(bun-types@1.3.12)(pg@8.20.0)(postgres@3.4.8))(rolldown@1.0.0-rc.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1))(xml2js@0.6.2): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 - '@rollup/plugin-alias': 6.0.0(rollup@4.60.0) - '@rollup/plugin-commonjs': 29.0.2(rollup@4.60.0) - '@rollup/plugin-inject': 5.0.5(rollup@4.60.0) - '@rollup/plugin-json': 6.1.0(rollup@4.60.0) - '@rollup/plugin-node-resolve': 16.0.3(rollup@4.60.0) - '@rollup/plugin-replace': 6.0.3(rollup@4.60.0) - '@rollup/plugin-terser': 1.0.0(rollup@4.60.0) - '@vercel/nft': 1.5.0(rollup@4.60.0) + '@rollup/plugin-alias': 6.0.0(rollup@4.60.2) + '@rollup/plugin-commonjs': 29.0.2(rollup@4.60.2) + '@rollup/plugin-inject': 5.0.5(rollup@4.60.2) + '@rollup/plugin-json': 6.1.0(rollup@4.60.2) + '@rollup/plugin-node-resolve': 16.0.3(rollup@4.60.2) + '@rollup/plugin-replace': 6.0.3(rollup@4.60.2) + '@rollup/plugin-terser': 1.0.0(rollup@4.60.2) + '@vercel/nft': 1.5.0(rollup@4.60.2) archiver: 7.0.1 c12: 3.3.3(magicast@0.5.2) chokidar: 5.0.0 @@ -41503,8 +41603,8 @@ snapshots: pkg-types: 2.3.0 pretty-bytes: 7.1.0 radix3: 1.1.2 - rollup: 4.60.0 - rollup-plugin-visualizer: 7.0.1(rolldown@1.0.0-rc.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1))(rollup@4.60.0) + rollup: 4.60.2 + rollup-plugin-visualizer: 7.0.1(rolldown@1.0.0-rc.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1))(rollup@4.60.2) scule: 1.3.0 semver: 7.7.4 serve-placeholder: 2.0.2 @@ -41821,16 +41921,16 @@ snapshots: optionalDependencies: chokidar: 5.0.0 - nuxt@4.4.2(88781acac5190445449d2214489e2877): + nuxt@4.4.2(76e6888a58687ab8ea9dc0707b97cb56): dependencies: '@dxup/nuxt': 0.4.0(magicast@0.5.2)(typescript@5.9.3) '@nuxt/cli': 3.34.0(@nuxt/schema@4.4.2)(cac@6.7.14)(magicast@0.5.2) '@nuxt/devtools': 3.2.4(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.25(typescript@5.9.3)) '@nuxt/kit': 4.4.2(magicast@0.5.2) - '@nuxt/nitro-server': 4.4.2(@azure/identity@4.13.1)(@azure/storage-blob@12.31.0)(@babel/core@7.29.0)(@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.0))(better-sqlite3@12.8.0)(db0@0.3.4(better-sqlite3@12.8.0)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(bun-types@1.3.12)(pg@8.20.0)(postgres@3.4.8)))(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(bun-types@1.3.12)(pg@8.20.0)(postgres@3.4.8))(ioredis@5.10.1)(magicast@0.5.2)(nuxt@4.4.2(88781acac5190445449d2214489e2877))(rolldown@1.0.0-rc.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1))(typescript@5.9.3)(xml2js@0.6.2) + '@nuxt/nitro-server': 4.4.2(@azure/identity@4.13.1)(@azure/storage-blob@12.31.0)(@babel/core@7.29.0)(@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.0))(better-sqlite3@12.8.0)(db0@0.3.4(better-sqlite3@12.8.0)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(bun-types@1.3.12)(pg@8.20.0)(postgres@3.4.8)))(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(bun-types@1.3.12)(pg@8.20.0)(postgres@3.4.8))(ioredis@5.10.1)(magicast@0.5.2)(nuxt@4.4.2(76e6888a58687ab8ea9dc0707b97cb56))(rolldown@1.0.0-rc.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1))(typescript@5.9.3)(xml2js@0.6.2) '@nuxt/schema': 4.4.2 '@nuxt/telemetry': 2.7.0(@nuxt/kit@4.4.2(magicast@0.5.2)) - '@nuxt/vite-builder': 4.4.2(@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.0))(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(eslint@9.39.4(jiti@2.6.1))(less@4.4.2)(magicast@0.5.2)(meow@13.2.0)(nuxt@4.4.2(88781acac5190445449d2214489e2877))(optionator@0.9.4)(rolldown@1.0.0-rc.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1))(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1))(rollup@4.60.0))(rollup@4.60.0)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(vue-tsc@2.2.12(typescript@5.9.3))(vue@3.5.25(typescript@5.9.3))(yaml@2.8.3) + '@nuxt/vite-builder': 4.4.2(@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.0))(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(eslint@9.39.4(jiti@2.6.1))(less@4.4.2)(magicast@0.5.2)(meow@13.2.0)(nuxt@4.4.2(76e6888a58687ab8ea9dc0707b97cb56))(optionator@0.9.4)(rolldown@1.0.0-rc.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1))(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1))(rollup@4.60.2))(rollup@4.60.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(vue-tsc@2.2.12(typescript@5.9.3))(vue@3.5.25(typescript@5.9.3))(yaml@2.8.3) '@unhead/vue': 2.1.12(vue@3.5.25(typescript@5.9.3)) '@vue/shared': 3.5.25 c12: 3.3.3(magicast@0.5.2) @@ -42124,7 +42224,7 @@ snapshots: office-addin-manifest-converter@0.4.1: dependencies: - '@xmldom/xmldom': 0.8.12 + '@xmldom/xmldom': 0.8.13 commander: 9.5.0 terser: 5.46.1 @@ -43074,13 +43174,13 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.8 - postcss-load-config@4.0.2(postcss@8.5.8)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3)): + postcss-load-config@4.0.2(postcss@8.5.8)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3)): dependencies: lilconfig: 3.1.3 yaml: 2.8.3 optionalDependencies: postcss: 8.5.8 - ts-node: 10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3) + ts-node: 10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3) postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.8)(tsx@4.21.0)(yaml@2.8.3): dependencies: @@ -43091,12 +43191,12 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 - postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.9)(tsx@4.21.0)(yaml@2.8.3): + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(yaml@2.8.3): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 2.6.1 - postcss: 8.5.9 + postcss: 8.5.10 tsx: 4.21.0 yaml: 2.8.3 @@ -43287,6 +43387,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.10: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -44960,16 +45066,16 @@ snapshots: globby: 10.0.1 is-plain-object: 3.0.1 - rollup-plugin-visualizer@5.14.0(rollup@4.60.1): + rollup-plugin-visualizer@5.14.0(rollup@4.60.2): dependencies: open: 8.4.2 picomatch: 4.0.4 source-map: 0.7.6 yargs: 17.7.2 optionalDependencies: - rollup: 4.60.1 + rollup: 4.60.2 - rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1))(rollup@4.60.0): + rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1))(rollup@4.60.2): dependencies: open: 11.0.0 picomatch: 4.0.4 @@ -44977,7 +45083,7 @@ snapshots: yargs: 18.0.0 optionalDependencies: rolldown: 1.0.0-rc.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) - rollup: 4.60.0 + rollup: 4.60.2 rollup@4.60.0: dependencies: @@ -45010,35 +45116,35 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.0 fsevents: 2.3.3 - rollup@4.60.1: + rollup@4.60.2: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.60.1 - '@rollup/rollup-android-arm64': 4.60.1 - '@rollup/rollup-darwin-arm64': 4.60.1 - '@rollup/rollup-darwin-x64': 4.60.1 - '@rollup/rollup-freebsd-arm64': 4.60.1 - '@rollup/rollup-freebsd-x64': 4.60.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.60.1 - '@rollup/rollup-linux-arm-musleabihf': 4.60.1 - '@rollup/rollup-linux-arm64-gnu': 4.60.1 - '@rollup/rollup-linux-arm64-musl': 4.60.1 - '@rollup/rollup-linux-loong64-gnu': 4.60.1 - '@rollup/rollup-linux-loong64-musl': 4.60.1 - '@rollup/rollup-linux-ppc64-gnu': 4.60.1 - '@rollup/rollup-linux-ppc64-musl': 4.60.1 - '@rollup/rollup-linux-riscv64-gnu': 4.60.1 - '@rollup/rollup-linux-riscv64-musl': 4.60.1 - '@rollup/rollup-linux-s390x-gnu': 4.60.1 - '@rollup/rollup-linux-x64-gnu': 4.60.1 - '@rollup/rollup-linux-x64-musl': 4.60.1 - '@rollup/rollup-openbsd-x64': 4.60.1 - '@rollup/rollup-openharmony-arm64': 4.60.1 - '@rollup/rollup-win32-arm64-msvc': 4.60.1 - '@rollup/rollup-win32-ia32-msvc': 4.60.1 - '@rollup/rollup-win32-x64-gnu': 4.60.1 - '@rollup/rollup-win32-x64-msvc': 4.60.1 + '@rollup/rollup-android-arm-eabi': 4.60.2 + '@rollup/rollup-android-arm64': 4.60.2 + '@rollup/rollup-darwin-arm64': 4.60.2 + '@rollup/rollup-darwin-x64': 4.60.2 + '@rollup/rollup-freebsd-arm64': 4.60.2 + '@rollup/rollup-freebsd-x64': 4.60.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.2 + '@rollup/rollup-linux-arm-musleabihf': 4.60.2 + '@rollup/rollup-linux-arm64-gnu': 4.60.2 + '@rollup/rollup-linux-arm64-musl': 4.60.2 + '@rollup/rollup-linux-loong64-gnu': 4.60.2 + '@rollup/rollup-linux-loong64-musl': 4.60.2 + '@rollup/rollup-linux-ppc64-gnu': 4.60.2 + '@rollup/rollup-linux-ppc64-musl': 4.60.2 + '@rollup/rollup-linux-riscv64-gnu': 4.60.2 + '@rollup/rollup-linux-riscv64-musl': 4.60.2 + '@rollup/rollup-linux-s390x-gnu': 4.60.2 + '@rollup/rollup-linux-x64-gnu': 4.60.2 + '@rollup/rollup-linux-x64-musl': 4.60.2 + '@rollup/rollup-openbsd-x64': 4.60.2 + '@rollup/rollup-openharmony-arm64': 4.60.2 + '@rollup/rollup-win32-arm64-msvc': 4.60.2 + '@rollup/rollup-win32-ia32-msvc': 4.60.2 + '@rollup/rollup-win32-x64-gnu': 4.60.2 + '@rollup/rollup-win32-x64-msvc': 4.60.2 fsevents: 2.3.3 rope-sequence@1.3.4: {} @@ -46283,7 +46389,7 @@ snapshots: - tsx - yaml - tailwindcss@3.4.4(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3)): + tailwindcss@3.4.4(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3)): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -46302,7 +46408,7 @@ snapshots: postcss: 8.5.8 postcss-import: 15.1.0(postcss@8.5.8) postcss-js: 4.1.0(postcss@8.5.8) - postcss-load-config: 4.0.2(postcss@8.5.8)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3)) + postcss-load-config: 4.0.2(postcss@8.5.8)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3)) postcss-nested: 6.2.0(postcss@8.5.8) postcss-selector-parser: 6.1.2 resolve: 1.22.11 @@ -46672,14 +46778,14 @@ snapshots: '@swc/core': 1.15.21 optional: true - ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3): + ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.12 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 25.6.0 + '@types/node': 22.19.2 acorn: 8.16.0 acorn-walk: 8.3.5 arg: 4.1.3 @@ -46704,7 +46810,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(@microsoft/api-extractor@7.57.7(@types/node@22.19.2))(@swc/core@1.15.21)(jiti@2.6.1)(postcss@8.5.9)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): + tsup@8.5.1(@microsoft/api-extractor@7.57.7(@types/node@22.19.2))(@swc/core@1.15.21)(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): dependencies: bundle-require: 5.1.0(esbuild@0.27.4) cac: 6.7.14 @@ -46715,7 +46821,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.9)(tsx@4.21.0)(yaml@2.8.3) + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(yaml@2.8.3) resolve-from: 5.0.0 rollup: 4.60.0 source-map: 0.7.6 @@ -46726,7 +46832,7 @@ snapshots: optionalDependencies: '@microsoft/api-extractor': 7.57.7(@types/node@22.19.2) '@swc/core': 1.15.21 - postcss: 8.5.9 + postcss: 8.5.10 typescript: 5.9.3 transitivePeerDependencies: - jiti @@ -46734,7 +46840,7 @@ snapshots: - tsx - yaml - tsup@8.5.1(@microsoft/api-extractor@7.57.7(@types/node@25.6.0))(@swc/core@1.15.21)(jiti@2.6.1)(postcss@8.5.9)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): + tsup@8.5.1(@microsoft/api-extractor@7.57.7(@types/node@25.6.0))(@swc/core@1.15.21)(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): dependencies: bundle-require: 5.1.0(esbuild@0.27.4) cac: 6.7.14 @@ -46745,7 +46851,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.9)(tsx@4.21.0)(yaml@2.8.3) + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(yaml@2.8.3) resolve-from: 5.0.0 rollup: 4.60.0 source-map: 0.7.6 @@ -46756,7 +46862,7 @@ snapshots: optionalDependencies: '@microsoft/api-extractor': 7.57.7(@types/node@25.6.0) '@swc/core': 1.15.21 - postcss: 8.5.9 + postcss: 8.5.10 typescript: 5.9.3 transitivePeerDependencies: - jiti @@ -47772,10 +47878,10 @@ snapshots: typescript: 5.9.3 vue-tsc: 2.2.12(typescript@5.9.3) - vite-plugin-dts@4.5.4(@types/node@22.19.2)(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(rollup@4.60.1)(typescript@5.9.3): + vite-plugin-dts@4.5.4(@types/node@22.19.2)(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(rollup@4.60.2)(typescript@5.9.3): dependencies: '@microsoft/api-extractor': 7.57.7(@types/node@22.19.2) - '@rollup/pluginutils': 5.3.0(rollup@4.60.1) + '@rollup/pluginutils': 5.3.0(rollup@4.60.2) '@volar/typescript': 2.4.28 '@vue/language-core': 2.2.0(typescript@5.9.3) compare-versions: 6.1.1 @@ -47791,10 +47897,10 @@ snapshots: - rollup - supports-color - vite-plugin-dts@4.5.4(@types/node@25.6.0)(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(rollup@4.60.1)(typescript@5.9.3): + vite-plugin-dts@4.5.4(@types/node@25.6.0)(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(rollup@4.60.2)(typescript@5.9.3): dependencies: '@microsoft/api-extractor': 7.57.7(@types/node@25.6.0) - '@rollup/pluginutils': 5.3.0(rollup@4.60.1) + '@rollup/pluginutils': 5.3.0(rollup@4.60.2) '@volar/typescript': 2.4.28 '@vue/language-core': 2.2.0(typescript@5.9.3) compare-versions: 6.1.1 @@ -47815,10 +47921,10 @@ snapshots: picocolors: 1.1.1 picomatch: 2.3.2 - vite-plugin-inspect@0.8.9(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(rollup@4.60.1): + vite-plugin-inspect@0.8.9(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(rollup@4.60.2): dependencies: '@antfu/utils': 0.7.10 - '@rollup/pluginutils': 5.3.0(rollup@4.60.1) + '@rollup/pluginutils': 5.3.0(rollup@4.60.2) debug: 4.4.3(supports-color@5.5.0) error-stack-parser-es: 0.1.5 fs-extra: 11.3.4 @@ -47848,23 +47954,23 @@ snapshots: transitivePeerDependencies: - supports-color - vite-plugin-node-polyfills@0.25.0(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(rollup@4.60.1): + vite-plugin-node-polyfills@0.25.0(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(rollup@4.60.2): dependencies: - '@rollup/plugin-inject': 5.0.5(rollup@4.60.1) + '@rollup/plugin-inject': 5.0.5(rollup@4.60.2) node-stdlib-browser: 1.3.1 vite: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - rollup - vite-plugin-node-polyfills@0.25.0(rollup@4.60.1)(vite@7.3.1(@types/node@22.19.2)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + vite-plugin-node-polyfills@0.25.0(rollup@4.60.2)(vite@7.3.1(@types/node@22.19.2)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: - '@rollup/plugin-inject': 5.0.5(rollup@4.60.1) + '@rollup/plugin-inject': 5.0.5(rollup@4.60.2) node-stdlib-browser: 1.3.1 vite: 7.3.1(@types/node@22.19.2)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - rollup - vite-plugin-vue-devtools@7.7.9(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(rollup@4.60.1)(vue@3.5.25(typescript@5.9.3)): + vite-plugin-vue-devtools@7.7.9(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(rollup@4.60.2)(vue@3.5.25(typescript@5.9.3)): dependencies: '@vue/devtools-core': 7.7.9(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.25(typescript@5.9.3)) '@vue/devtools-kit': 7.7.9 @@ -47872,7 +47978,7 @@ snapshots: execa: 9.6.1 sirv: 3.0.2 vite: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - vite-plugin-inspect: 0.8.9(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(rollup@4.60.1) + vite-plugin-inspect: 0.8.9(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(rollup@4.60.2) vite-plugin-vue-inspector: 5.4.0(rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) transitivePeerDependencies: - '@nuxt/kit' @@ -47910,8 +48016,8 @@ snapshots: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.9 - rollup: 4.60.1 + postcss: 8.5.10 + rollup: 4.60.2 tinyglobby: 0.2.16 optionalDependencies: '@types/node': 22.19.2 @@ -47929,8 +48035,8 @@ snapshots: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.9 - rollup: 4.60.1 + postcss: 8.5.10 + rollup: 4.60.2 tinyglobby: 0.2.16 optionalDependencies: '@types/node': 25.6.0 From 9c05a6fdcab819325cc5dc254dda3c73e486edc8 Mon Sep 17 00:00:00 2001 From: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:11:19 -0300 Subject: [PATCH 09/30] feat(corpus): seed worktree pulls from the primary repo via hardlink (#2867) Pulling the full corpus (~450 .docx files, ~100 MB) on every new worktree is wasteful: the bytes are identical to the primary repo already sitting on disk. Detect when we're in a git worktree via `git rev-parse --git-common-dir`, then hardlink any files the primary has into the worktree's corpus dir before hitting R2. Only files the primary is missing go over the network. --force still re-downloads from R2 (we unlink the destination before writing so the primary's inodes never get clobbered). --no-seed opts out for users who want the old behaviour. --- scripts/corpus/README.md | 14 +++++++ scripts/corpus/pull.mjs | 34 +++++++++++++++-- scripts/corpus/shared.mjs | 79 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 123 insertions(+), 4 deletions(-) diff --git a/scripts/corpus/README.md b/scripts/corpus/README.md index bcf560fa20..0ff102f4b5 100644 --- a/scripts/corpus/README.md +++ b/scripts/corpus/README.md @@ -24,6 +24,20 @@ pnpm corpus:update-registry `pnpm corpus:pull` now tolerates missing keys and prunes stale `registry.json` entries automatically. `pnpm corpus:pull` does not remove local files that no longer exist in R2; use `pnpm corpus:delete` when you want the shared corpus and local copy removed together. + +### Worktrees: seeding from the primary repo + +When `pnpm corpus:pull` runs inside a git worktree it first hardlinks anything +the primary repo already has on disk, then only goes to R2 for the rest. The +primary's bytes are already local so this is effectively instant for a fresh +worktree. + +- Hardlinks (not copies) — zero disk overhead, both worktrees see the same + inode. Falls back to copy automatically when the two checkouts live on + different filesystems. +- `--force` still re-downloads from R2. Destinations are unlinked before each + R2 write, so the primary's files are never clobbered. +- Pass `--no-seed` to skip the hardlink step and go straight to R2. `pnpm corpus:push` runs `superdoc-benchmark baseline --force` by default after upload. Set `SUPERDOC_CORPUS_SKIP_WORD_BASELINE=1` (or pass `--no-word-baseline`) to disable this behavior. diff --git a/scripts/corpus/pull.mjs b/scripts/corpus/pull.mjs index 4c6998dfd1..7acc067969 100644 --- a/scripts/corpus/pull.mjs +++ b/scripts/corpus/pull.mjs @@ -17,6 +17,7 @@ import { normalizePath, printCorpusEnvHint, saveRegistry, + seedCorpusFromPrimary, sortRegistryDocs, writeProgressBar, } from './shared.mjs'; @@ -48,6 +49,8 @@ Options: --match Substring filter (repeatable) --exclude Exclude filter (repeatable) --force Re-download files even if they already exist + --no-seed In a git worktree, skip hardlinking from the primary + repo's corpus before pulling from R2 --link-visual Point tests/visual/test-data at --dest via symlink --dry-run Print actions without downloading --quiet Suppress verbose logs; show only progress and summary @@ -65,6 +68,7 @@ function parseArgs(argv) { linkVisual: false, dryRun: false, quiet: false, + seedFromPrimary: true, }; for (let i = 0; i < argv.length; i += 1) { @@ -99,6 +103,10 @@ function parseArgs(argv) { args.force = true; continue; } + if (arg === '--no-seed') { + args.seedFromPrimary = false; + continue; + } if (arg === '--link-visual') { args.linkVisual = true; continue; @@ -247,6 +255,7 @@ async function main() { let downloaded = 0; let skipped = 0; + let seeded = 0; if (!args.quiet) { console.log(`[corpus] Source: ${corpus.source}`); @@ -254,6 +263,13 @@ async function main() { console.log(`[corpus] Corpus size: ${selectedDocs.length} documents`); } + // Fast path for git worktrees: hardlink from the primary repo's corpus + // before reaching for R2. Downloads below still run for anything the + // primary is missing, so --force or a fresh fixture still trigger R2. + if (args.seedFromPrimary && !args.force && !args.dryRun) { + seeded = seedCorpusFromPrimary(destinationRoot, selectedDocs, { quiet: args.quiet }); + } + if (corpus.source === REGISTRY_KEY && corpus.registry) { const allObjectKeys = await client.listObjects(''); const objectKeySet = new Set(allObjectKeys.map((key) => normalizePath(key).toLowerCase())); @@ -316,6 +332,14 @@ async function main() { const { relativePath, objectKey, destinationPath } = toDownload[idx]; try { + // Unlink before writing. If the destination is a hardlink to the + // primary repo's corpus (seeded above), wrangler's r2 object get + // would otherwise write through and mutate the primary's file. + try { + fs.rmSync(destinationPath, { force: true }); + } catch { + // swallow — the write below will surface any real permission issue + } await client.getObjectToFile(objectKey, destinationPath); downloaded += 1; } catch (error) { @@ -379,12 +403,16 @@ async function main() { const elapsed = Date.now() - startedAt; if (args.quiet) { - if (downloaded > 0) { - console.log(`[corpus] Synced ${downloaded} new document(s) in ${formatDurationMs(elapsed)}`); + if (downloaded > 0 || seeded > 0) { + const parts = []; + if (seeded > 0) parts.push(`${seeded} seeded`); + if (downloaded > 0) parts.push(`${downloaded} downloaded`); + console.log(`[corpus] Synced ${parts.join(' + ')} in ${formatDurationMs(elapsed)}`); } } else { + const seedPart = seeded > 0 ? `, Seeded: ${seeded}` : ''; console.log( - `[corpus] Done. Downloaded: ${downloaded}, Skipped: ${skipped}, Missing: ${missingRegistryPaths.length}, Elapsed: ${formatDurationMs(elapsed)}`, + `[corpus] Done. Downloaded: ${downloaded}${seedPart}, Skipped: ${skipped}, Missing: ${missingRegistryPaths.length}, Elapsed: ${formatDurationMs(elapsed)}`, ); } } finally { diff --git a/scripts/corpus/shared.mjs b/scripts/corpus/shared.mjs index 9fb38dacdc..e104ecf442 100644 --- a/scripts/corpus/shared.mjs +++ b/scripts/corpus/shared.mjs @@ -3,7 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import crypto from 'node:crypto'; import process from 'node:process'; -import { execFile as execFileCb } from 'node:child_process'; +import { execFile as execFileCb, execFileSync } from 'node:child_process'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { promisify } from 'node:util'; import { createRequire } from 'node:module'; @@ -468,6 +468,83 @@ export function ensureVisualTestDataSymlink(corpusRoot) { return { linked: true, changed: true, backupPath: null }; } +/** + * If the current repo is a git worktree, return the primary repo's root path. + * Uses `git rev-parse --git-common-dir`: in a worktree this resolves to + * `/.git/worktrees//..` pointing inside the primary's .git dir, + * while in a non-worktree checkout it resolves to the local `.git`. + * Returns null if we're not in a worktree, not in git, or git isn't available. + */ +export function findPrimaryRepoRoot() { + try { + const commonDir = execFileSync('git', ['rev-parse', '--git-common-dir'], { + cwd: REPO_ROOT, + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf8', + }).trim(); + const absoluteCommonDir = path.resolve(REPO_ROOT, commonDir); + const ownGitDir = path.resolve(REPO_ROOT, '.git'); + + // Non-worktree: common dir IS our own .git + if (absoluteCommonDir === ownGitDir) return null; + + // Worktree: common dir is the primary's .git (or a file pointer); its parent is the primary repo + const primaryRoot = path.dirname(absoluteCommonDir); + if (primaryRoot === REPO_ROOT) return null; + return primaryRoot; + } catch { + return null; + } +} + +/** + * Seed the worktree's corpus from the primary repo using hardlinks (falling + * back to copy across filesystems). Skips files that already exist at the + * destination and files the primary doesn't have. Returns the count seeded. + * + * Only runs when we're in a worktree AND the primary has corpus files. + */ +export function seedCorpusFromPrimary(destinationRoot, selectedDocs, { quiet = false } = {}) { + const primaryRoot = findPrimaryRepoRoot(); + if (!primaryRoot) return 0; + + // Follow the primary's default corpus layout. This is intentionally a fixed + // path — users with custom --dest in the primary can still fall back to R2. + const primaryCorpus = path.join(primaryRoot, path.basename(DEFAULT_CORPUS_ROOT)); + if (!fs.existsSync(primaryCorpus)) return 0; + + let seeded = 0; + let copyFallback = 0; + + for (const doc of selectedDocs) { + const relativePath = normalizePath(doc.relative_path); + if (!relativePath) continue; + + const primaryPath = path.join(primaryCorpus, relativePath); + const destinationPath = path.join(destinationRoot, relativePath); + + if (fs.existsSync(destinationPath)) continue; + if (!fs.existsSync(primaryPath)) continue; + + fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); + try { + fs.linkSync(primaryPath, destinationPath); + } catch { + // Hardlink fails across filesystems or for special files — copy instead + fs.copyFileSync(primaryPath, destinationPath); + copyFallback += 1; + } + seeded += 1; + } + + if (seeded > 0 && !quiet) { + const relPrimary = path.relative(REPO_ROOT, primaryRoot) || primaryRoot; + const method = copyFallback === seeded ? 'copied' : copyFallback > 0 ? 'hardlinked (with copy fallback)' : 'hardlinked'; + console.log(`[corpus] Seeded ${seeded} file(s) ${method} from primary repo: ${relPrimary}`); + } + return seeded; +} + export function applyPathFilters(paths, { filters = [], matches = [], excludes = [] } = {}) { const normalizedFilters = filters.map((value) => String(value).toLowerCase()).filter(Boolean); const normalizedMatches = matches.map((value) => String(value).toLowerCase()).filter(Boolean); From f229576082772230bf6af6451ba9d91eb4e25a85 Mon Sep 17 00:00:00 2001 From: Abdel ATIA <223786362+Abdeltoto@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:30:31 -0400 Subject: [PATCH 10/30] feat(math): implement m:box and m:borderBox converters (#2750) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(math): implement m:box and m:borderBox converters (closes #2605) Made-with: Cursor * fix(math): parse full ST_OnOff values in borderBox converter Made-with: Cursor * fix(math): fall back to mrow when borderBox hides all sides with no strikes Made-with: Cursor * fix(math): address review findings for m:box/m:borderBox - isOn now checks m:val === undefined instead of !el.attributes so elements with namespace-only attributes are still treated as on, matching the ST_OnOff default per §22.9.2.7. - convertBorderBox returns null for empty m:e, consistent with convertBox and convertFunction (no empty wrappers). - m:box JSDoc now reflects that boxPr semantics (opEmu, noBreak, aln, diff, argSz) are silently dropped — not "purely a grouping mechanism". - Registry comment drift fixed: m:box and m:borderBox moved into the Implemented block. - Tests: strike direction mapping (BLTR→up, TLBR→down, V), full ST_OnOff matrix (1/true/on/bare/0/false), tightened assertions to exact-string equality, pinned the current boxPr-drop behavior. * feat(math): polyfill MathML via CSS MathML Core (Chrome 109+, 2023) dropped — no browser paints it natively. Without this, m:borderBox content imports correctly but renders invisibly. Ship a small CSS polyfill that maps every notation token to borders or pseudo-element strike overlays: - box / top / bottom / left / right → CSS border sides - horizontalstrike / verticalstrike → ::after gradient layer (H or V) - updiagonalstrike / downdiagonalstrike → layered gradients via CSS custom properties so X patterns stack correctly Wired through the existing ensure*Styles pattern in renderer.ts. Zero bundle cost, no runtime polling, fully semantic (the DOM still says ). * fix(math): correct diagonal strike directions in menclose polyfill CSS linear-gradient direction keywords confusingly produce stripes perpendicular to the direction vector: - "to top right" progresses toward the top-right corner, which makes the visible color stripe run top-left to bottom-right ("\") - "to bottom right" progresses toward the bottom-right corner, which makes the stripe run bottom-left to top-right ("/") The polyfill had them swapped, so updiagonalstrike rendered as "\" and downdiagonalstrike as "/" — the opposite of what Word shows and what MathML 3 specifies. Swap the direction keywords and add a comment so the next reader doesn't re-flip them. * fix(math): wrap borderBox content in for horizontal row layout MathML Core does not define , so Chrome treats it as an unknown element and does not run the row-layout algorithm on its children. Each child rendered with display: block math and stacked vertically — a multi-element expression inside a borderBox (e.g. Annex L.6.1.3's a² = b² + c²) became a column of letters. Wrap the content in an inner before appending to . is in MathML Core, so the row layout runs on its children and everything stays inline. The outer remains the polyfill target for borders and strikes. * test(behavior): cover m:borderBox + menclose polyfill end-to-end Loads the 30-scenario fixture (sd-2750-borderbox.docx) and asserts: - every scenario produces the expected notation attribute in DOM order - multi-child content (Annex L.6.1.3: a² = b² + c²) renders as a horizontal row — width > 1.5× height, inner present, 5 children - ST_OnOff variants (1/true/on/bare/0/false) resolve correctly through the full import path, not just the unit converter - m:box silently drops boxPr (opEmu/noBreak/aln/diff) and emits - the menclose CSS polyfill stylesheet is injected into the document Runs across chromium/firefox/webkit. Complements the 53 unit tests by exercising the cross-package path: OMML import → pm-adapter → painter-dom → rendered MathML. --------- Co-authored-by: Caio Pizzol --- .../dom/src/features/math/converters/box.ts | 122 +++++++ .../dom/src/features/math/converters/index.ts | 1 + .../src/features/math/omml-to-mathml.test.ts | 341 ++++++++++++++++++ .../dom/src/features/math/omml-to-mathml.ts | 7 +- .../painters/dom/src/renderer.ts | 2 + .../layout-engine/painters/dom/src/styles.ts | 87 +++++ .../importing/fixtures/sd-2750-borderbox.docx | Bin 0 -> 12579 bytes .../importing/math-box-border-box.spec.ts | 146 ++++++++ 8 files changed, 703 insertions(+), 3 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/features/math/converters/box.ts create mode 100644 tests/behavior/tests/importing/fixtures/sd-2750-borderbox.docx create mode 100644 tests/behavior/tests/importing/math-box-border-box.spec.ts diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/box.ts b/packages/layout-engine/painters/dom/src/features/math/converters/box.ts new file mode 100644 index 0000000000..45dbf14bda --- /dev/null +++ b/packages/layout-engine/painters/dom/src/features/math/converters/box.ts @@ -0,0 +1,122 @@ +import type { MathObjectConverter } from '../types.js'; + +const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; + +/** + * Convert m:box (grouping container) to MathML . + * + * OMML structure: + * m:box → m:boxPr (optional), m:e (content) + * + * MathML output: + * content + * + * Per §22.1.2.13 / §22.1.2.14, m:box can carry boxPr children that affect + * layout and spacing — opEmu (operator emulator), noBreak (disallow line + * breaks), aln (alignment point), diff (differential spacing), argSz. These + * have no clean MathML equivalent and are currently dropped; the box + * degrades to a plain that preserves grouping but not the other + * semantics. Extend here when any of these need first-class support. + * + * @spec ECMA-376 §22.1.2.13, §22.1.2.14 + */ +export const convertBox: MathObjectConverter = (node, doc, convertChildren) => { + const elements = node.elements ?? []; + const base = elements.find((e) => e.name === 'm:e'); + + const mrow = doc.createElementNS(MATHML_NS, 'mrow'); + mrow.appendChild(convertChildren(base?.elements ?? [])); + + return mrow.childNodes.length > 0 ? mrow : null; +}; + +/** + * Convert m:borderBox (bordered box) to MathML . + * + * OMML structure: + * m:borderBox → m:borderBoxPr (optional: m:hideTop, m:hideBot, m:hideLeft, m:hideRight, + * m:strikeBLTR, m:strikeH, m:strikeTLBR, m:strikeV), + * m:e (content) + * + * MathML output: + * content + * + * By default all four borders are shown (notation="box"). Individual borders + * can be hidden via m:hide* flags, and diagonal/horizontal/vertical strikes + * can be added via m:strike* flags. + * + * @spec ECMA-376 §22.1.2.11 + */ +export const convertBorderBox: MathObjectConverter = (node, doc, convertChildren) => { + const elements = node.elements ?? []; + const props = elements.find((e) => e.name === 'm:borderBoxPr'); + const base = elements.find((e) => e.name === 'm:e'); + + /** + * OOXML ST_OnOff (§22.9.2.7): on when the element is present and either + * `m:val` is absent (spec default = 1) or equals "1" / "true". "on" is + * accepted for leniency — Annex L.6.1.3 uses that form even though the + * normative enum is {0, 1, true, false}. + * TODO: extract to a shared util when m:acc / m:phant / matrix m:tblLook land. + */ + const isOn = (el?: { attributes?: Record }) => { + if (!el) return false; + const val = el.attributes?.['m:val']; + if (val === undefined) return true; + return val === '1' || val === 'true' || val === 'on'; + }; + + const hideTop = props?.elements?.find((e) => e.name === 'm:hideTop'); + const hideBot = props?.elements?.find((e) => e.name === 'm:hideBot'); + const hideLeft = props?.elements?.find((e) => e.name === 'm:hideLeft'); + const hideRight = props?.elements?.find((e) => e.name === 'm:hideRight'); + const strikeBLTR = props?.elements?.find((e) => e.name === 'm:strikeBLTR'); + const strikeH = props?.elements?.find((e) => e.name === 'm:strikeH'); + const strikeTLBR = props?.elements?.find((e) => e.name === 'm:strikeTLBR'); + const strikeV = props?.elements?.find((e) => e.name === 'm:strikeV'); + + const notations: string[] = []; + + const allHidden = isOn(hideTop) && isOn(hideBot) && isOn(hideLeft) && isOn(hideRight); + + if (!allHidden) { + if (!isOn(hideTop) && !isOn(hideBot) && !isOn(hideLeft) && !isOn(hideRight)) { + notations.push('box'); + } else { + if (!isOn(hideTop)) notations.push('top'); + if (!isOn(hideBot)) notations.push('bottom'); + if (!isOn(hideLeft)) notations.push('left'); + if (!isOn(hideRight)) notations.push('right'); + } + } + + if (isOn(strikeBLTR)) notations.push('updiagonalstrike'); + if (isOn(strikeH)) notations.push('horizontalstrike'); + if (isOn(strikeTLBR)) notations.push('downdiagonalstrike'); + if (isOn(strikeV)) notations.push('verticalstrike'); + + const content = convertChildren(base?.elements ?? []); + + // Drop empty wrappers — matches convertBox / convertFunction. + if (content.childNodes.length === 0) return null; + + if (notations.length === 0) { + const mrow = doc.createElementNS(MATHML_NS, 'mrow'); + mrow.appendChild(content); + return mrow; + } + + // Wrap the content in an inner before placing it inside . + // MathML Core dropped , so Chrome treats it as unknown and does + // not apply row layout — each child would render as its own `block math` + // line, stacking vertically. An inner is a MathML Core element, so + // the row layout runs on its children and everything stays inline. + const innerMrow = doc.createElementNS(MATHML_NS, 'mrow'); + innerMrow.appendChild(content); + + const menclose = doc.createElementNS(MATHML_NS, 'menclose'); + menclose.setAttribute('notation', notations.join(' ')); + menclose.appendChild(innerMrow); + + return menclose; +}; diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/index.ts b/packages/layout-engine/painters/dom/src/features/math/converters/index.ts index fcc7916fcb..0af6795656 100644 --- a/packages/layout-engine/painters/dom/src/features/math/converters/index.ts +++ b/packages/layout-engine/painters/dom/src/features/math/converters/index.ts @@ -24,3 +24,4 @@ export { convertNary } from './nary.js'; export { convertPhantom } from './phantom.js'; export { convertGroupCharacter } from './group-character.js'; export { convertMatrix } from './matrix.js'; +export { convertBox, convertBorderBox } from './box.js'; diff --git a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts index c5a1345725..898ab4da70 100644 --- a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts +++ b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts @@ -3929,3 +3929,344 @@ describe('m:m converter', () => { expect(mtable!.textContent).toBe('ab'); }); }); +describe('m:box converter', () => { + it('converts m:box to ', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:box', + elements: [ + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + expect(result!.querySelector('mrow')).not.toBeNull(); + expect(result!.textContent).toBe('x'); + }); + + it('returns null for empty m:box', () => { + const omml = { + name: 'm:oMath', + elements: [{ name: 'm:box', elements: [] }], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).toBeNull(); + }); + + it('drops m:boxPr children (opEmu / noBreak / aln / diff are not yet mapped)', () => { + // Pins current scope: we render and silently ignore boxPr semantics. + // When opEmu or noBreak grow real MathML mappings, this test should fail + // and be updated — that failure is the point. + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:box', + elements: [ + { + name: 'm:boxPr', + elements: [ + { name: 'm:opEmu', attributes: { 'm:val': '1' } }, + { name: 'm:noBreak', attributes: { 'm:val': '1' } }, + { name: 'm:aln' }, + { name: 'm:diff', attributes: { 'm:val': '1' } }, + ], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '==' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + expect(result!.querySelector('mrow')).not.toBeNull(); + expect(result!.querySelector('menclose')).toBeNull(); + expect(result!.textContent).toBe('=='); + }); +}); + +describe('m:borderBox converter', () => { + it('converts m:borderBox to by default', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:borderBox', + elements: [ + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'E' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const menclose = result!.querySelector('menclose'); + expect(menclose).not.toBeNull(); + expect(menclose!.getAttribute('notation')).toBe('box'); + expect(menclose!.textContent).toBe('E'); + }); + + it('hides top and bottom sides (notation="left right")', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:borderBox', + elements: [ + { + name: 'm:borderBoxPr', + elements: [ + { name: 'm:hideTop', attributes: { 'm:val': '1' } }, + { name: 'm:hideBot', attributes: { 'm:val': '1' } }, + ], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + const menclose = result!.querySelector('menclose'); + expect(menclose).not.toBeNull(); + // Exact string — production order is top/bottom/left/right, so a side-swap regression fails here. + expect(menclose!.getAttribute('notation')).toBe('left right'); + }); + + it('adds strike notations', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:borderBox', + elements: [ + { + name: 'm:borderBoxPr', + elements: [ + { name: 'm:hideTop', attributes: { 'm:val': '1' } }, + { name: 'm:hideBot', attributes: { 'm:val': '1' } }, + { name: 'm:hideLeft', attributes: { 'm:val': '1' } }, + { name: 'm:hideRight', attributes: { 'm:val': '1' } }, + { name: 'm:strikeH', attributes: { 'm:val': '1' } }, + ], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'y' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + const menclose = result!.querySelector('menclose'); + expect(menclose).not.toBeNull(); + expect(menclose!.getAttribute('notation')).toBe('horizontalstrike'); + }); + + it('falls back to when all borders hidden and no strikes', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:borderBox', + elements: [ + { + name: 'm:borderBoxPr', + elements: [ + { name: 'm:hideTop', attributes: { 'm:val': '1' } }, + { name: 'm:hideBot', attributes: { 'm:val': '1' } }, + { name: 'm:hideLeft', attributes: { 'm:val': '1' } }, + { name: 'm:hideRight', attributes: { 'm:val': '1' } }, + ], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'q' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + const menclose = result!.querySelector('menclose'); + expect(menclose).toBeNull(); + expect(result!.textContent).toBe('q'); + }); + + // ── ST_OnOff variants (ECMA-376 §22.9.2.7) ──────────────────────────────── + // isOn accepts "1", "true", "on", and bare tags; rejects "0" / "false" / "off". + // Annex L.6.1.3 itself uses m:val="on" even though the normative enum is {0,1,true,false}. + + const makeBorderBox = (hideTopFlag: Record) => ({ + name: 'm:oMath', + elements: [ + { + name: 'm:borderBox', + elements: [ + { name: 'm:borderBoxPr', elements: [{ name: 'm:hideTop', ...hideTopFlag }] }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }], + }, + ], + }, + ], + }); + + it('treats m:val="true" as on (ST_OnOff)', () => { + const result = convertOmmlToMathml(makeBorderBox({ attributes: { 'm:val': 'true' } }), doc); + expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('bottom left right'); + }); + + it('treats m:val="on" as on (Annex L.6.1.3 form)', () => { + const result = convertOmmlToMathml(makeBorderBox({ attributes: { 'm:val': 'on' } }), doc); + expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('bottom left right'); + }); + + it('treats bare as on (spec default val=1)', () => { + const result = convertOmmlToMathml(makeBorderBox({}), doc); + expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('bottom left right'); + }); + + it('treats m:val="0" as off (top remains visible)', () => { + const result = convertOmmlToMathml(makeBorderBox({ attributes: { 'm:val': '0' } }), doc); + expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('box'); + }); + + it('treats m:val="false" as off', () => { + const result = convertOmmlToMathml(makeBorderBox({ attributes: { 'm:val': 'false' } }), doc); + expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('box'); + }); + + // ── Strike directions ───────────────────────────────────────────────────── + // BLTR (bottom-left → top-right = "/") maps to updiagonalstrike. + // TLBR (top-left → bottom-right = "\") maps to downdiagonalstrike. + // The directional naming is counter-intuitive — these tests pin it. + + const makeStrike = (strikeName: string) => ({ + name: 'm:oMath', + elements: [ + { + name: 'm:borderBox', + elements: [ + { + name: 'm:borderBoxPr', + elements: [ + { name: 'm:hideTop', attributes: { 'm:val': '1' } }, + { name: 'm:hideBot', attributes: { 'm:val': '1' } }, + { name: 'm:hideLeft', attributes: { 'm:val': '1' } }, + { name: 'm:hideRight', attributes: { 'm:val': '1' } }, + { name: strikeName, attributes: { 'm:val': '1' } }, + ], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }], + }, + ], + }, + ], + }); + + it('maps m:strikeBLTR to notation="updiagonalstrike" (/ direction)', () => { + const result = convertOmmlToMathml(makeStrike('m:strikeBLTR'), doc); + expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('updiagonalstrike'); + }); + + it('maps m:strikeTLBR to notation="downdiagonalstrike" (\\ direction)', () => { + const result = convertOmmlToMathml(makeStrike('m:strikeTLBR'), doc); + expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('downdiagonalstrike'); + }); + + it('maps m:strikeV to notation="verticalstrike"', () => { + const result = convertOmmlToMathml(makeStrike('m:strikeV'), doc); + expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('verticalstrike'); + }); + + it('combines multiple strikes in a fixed order', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:borderBox', + elements: [ + { + name: 'm:borderBoxPr', + elements: [ + { name: 'm:strikeBLTR', attributes: { 'm:val': '1' } }, + { name: 'm:strikeH', attributes: { 'm:val': '1' } }, + { name: 'm:strikeTLBR', attributes: { 'm:val': '1' } }, + { name: 'm:strikeV', attributes: { 'm:val': '1' } }, + ], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe( + 'box updiagonalstrike horizontalstrike downdiagonalstrike verticalstrike', + ); + }); + + it('combines partial hide flags with a strike (hideTop + strikeH)', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:borderBox', + elements: [ + { + name: 'm:borderBoxPr', + elements: [ + { name: 'm:hideTop', attributes: { 'm:val': '1' } }, + { name: 'm:strikeH', attributes: { 'm:val': '1' } }, + ], + }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('bottom left right horizontalstrike'); + }); + + it('returns null when m:e is empty (no bordered-but-empty )', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:borderBox', + elements: [{ name: 'm:borderBoxPr', elements: [{ name: 'm:strikeH', attributes: { 'm:val': '1' } }] }], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + // oMath still renders but has no children because borderBox dropped itself. + expect(result).toBeNull(); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts index e2f8f016f9..2f49e3d5f7 100644 --- a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts +++ b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts @@ -29,6 +29,8 @@ import { convertPhantom, convertGroupCharacter, convertMatrix, + convertBox, + convertBorderBox, } from './converters/index.js'; export const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; @@ -66,9 +68,8 @@ const MATH_OBJECT_REGISTRY: Record = { 'm:sSubSup': convertSubSuperscript, // Sub-superscript (both) 'm:sPre': convertPreSubSuperscript, // Pre-sub-superscript (left of base) - // ── Not yet implemented (community contributions welcome) ──────────────── - 'm:borderBox': null, // Border box (border around math content) - 'm:box': null, // Box (invisible grouping container) + 'm:borderBox': convertBorderBox, // Border box (border around math content) + 'm:box': convertBox, // Box (invisible grouping container) 'm:groupChr': convertGroupCharacter, // Group character (overbrace, underbrace) }; diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 912c7db70b..9dcaf5dda0 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -91,6 +91,7 @@ import { ensureFieldAnnotationStyles, ensureImageSelectionStyles, ensureLinkStyles, + ensureMathMencloseStyles, ensurePrintStyles, ensureSdtContainerStyles, ensureTrackChangeStyles, @@ -1675,6 +1676,7 @@ export class DomPainter { ensureFieldAnnotationStyles(doc); ensureSdtContainerStyles(doc); ensureImageSelectionStyles(doc); + ensureMathMencloseStyles(doc); if (!this.isSemanticFlow && this.options.ruler?.enabled) { ensureRulerStyles(doc); } diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index e5bd524427..b548d37107 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -646,12 +646,85 @@ const IMAGE_SELECTION_STYLES = ` } `; +const MATH_MENCLOSE_STYLES = ` +/* MathML polyfill. + * + * MathML 3 defined with borders, strikes, and other + * enclosure notations. MathML Core (the subset shipped in Chrome 109+, 2023) + * dropped — the WG moved its rendering to CSS/SVG. Firefox and + * WebKit also do not paint it. Without this polyfill, m:borderBox content + * imports correctly (the notation attribute is right) but renders invisibly. + * + * Each notation token is composable: "box horizontalstrike" draws the box + * border and a horizontal strike together. Diagonal strikes layer through + * CSS custom properties so X patterns (both diagonals) stack correctly. + * + * @spec MathML 3 §3.3.8 menclose + */ +menclose { + display: inline-block; + position: relative; + padding: 0.15em 0.25em; + + --sd-menclose-stroke: currentColor; + --sd-menclose-h: none; + --sd-menclose-v: none; + --sd-menclose-up: none; + --sd-menclose-down: none; +} + +menclose[notation~="box"] { border: 1px solid var(--sd-menclose-stroke); } +menclose[notation~="roundedbox"] { border: 1px solid var(--sd-menclose-stroke); border-radius: 0.3em; } +menclose[notation~="top"] { border-top: 1px solid var(--sd-menclose-stroke); } +menclose[notation~="bottom"] { border-bottom: 1px solid var(--sd-menclose-stroke); } +menclose[notation~="left"] { border-left: 1px solid var(--sd-menclose-stroke); } +menclose[notation~="right"] { border-right: 1px solid var(--sd-menclose-stroke); } + +menclose[notation~="horizontalstrike"] { + --sd-menclose-h: linear-gradient(var(--sd-menclose-stroke), var(--sd-menclose-stroke)) no-repeat center / 100% 1px; +} +menclose[notation~="verticalstrike"] { + --sd-menclose-v: linear-gradient(var(--sd-menclose-stroke), var(--sd-menclose-stroke)) no-repeat center / 1px 100%; +} +/* Gradient direction is perpendicular to the stripe it produces. + * "to bottom right" → stripe runs bottom-left → top-right (visually "/") = updiagonalstrike. + * "to top right" → stripe runs top-left → bottom-right (visually "\") = downdiagonalstrike. + */ +menclose[notation~="updiagonalstrike"] { + --sd-menclose-up: linear-gradient( + to bottom right, + transparent calc(50% - 0.5px), + var(--sd-menclose-stroke) calc(50% - 0.5px), + var(--sd-menclose-stroke) calc(50% + 0.5px), + transparent calc(50% + 0.5px) + ); +} +menclose[notation~="downdiagonalstrike"] { + --sd-menclose-down: linear-gradient( + to top right, + transparent calc(50% - 0.5px), + var(--sd-menclose-stroke) calc(50% - 0.5px), + var(--sd-menclose-stroke) calc(50% + 0.5px), + transparent calc(50% + 0.5px) + ); +} + +menclose::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: var(--sd-menclose-h), var(--sd-menclose-v), var(--sd-menclose-up), var(--sd-menclose-down); +} +`; + let printStylesInjected = false; let linkStylesInjected = false; let trackChangeStylesInjected = false; let sdtContainerStylesInjected = false; let fieldAnnotationStylesInjected = false; let imageSelectionStylesInjected = false; +let mathMencloseStylesInjected = false; export const ensurePrintStyles = (doc: Document | null | undefined) => { if (printStylesInjected || !doc) return; @@ -712,3 +785,17 @@ export const ensureImageSelectionStyles = (doc: Document | null | undefined) => doc.head?.appendChild(styleEl); imageSelectionStylesInjected = true; }; + +/** + * Injects the MathML polyfill into the document head. Required + * because no browser paints menclose natively (MathML Core dropped it). See + * MATH_MENCLOSE_STYLES for the full rationale. + */ +export const ensureMathMencloseStyles = (doc: Document | null | undefined) => { + if (mathMencloseStylesInjected || !doc) return; + const styleEl = doc.createElement('style'); + styleEl.setAttribute('data-superdoc-math-menclose-styles', 'true'); + styleEl.textContent = MATH_MENCLOSE_STYLES; + doc.head?.appendChild(styleEl); + mathMencloseStylesInjected = true; +}; diff --git a/tests/behavior/tests/importing/fixtures/sd-2750-borderbox.docx b/tests/behavior/tests/importing/fixtures/sd-2750-borderbox.docx new file mode 100644 index 0000000000000000000000000000000000000000..99495ef04cd3850a81b73ced2d8f65216c94f4ae GIT binary patch literal 12579 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', () => { + test('every scenario produces the expected notation attribute', async ({ superdoc }) => { + await superdoc.loadDocument(BORDERBOX_DOC); + await superdoc.waitForStable(); + + const notations = await superdoc.page.evaluate(() => + Array.from(document.querySelectorAll('menclose')).map((el) => el.getAttribute('notation')), + ); + + // Ordered — matches the scenario numbering inside the DOCX. + expect(notations).toEqual([ + 'box', // 1. default + 'box', // 2. empty borderBoxPr + 'bottom left right', // 3. m:val="1" + 'bottom left right', // 4. m:val="true" + 'bottom left right', // 5. m:val="on" (Annex L) + 'bottom left right', // 6. bare + 'box', // 7. m:val="0" → not hidden + 'box', // 8. m:val="false" + 'top left right', // 9. hideBot only + 'top bottom right', // 10. hideLeft only + 'top bottom left', // 11. hideRight only + 'bottom left', // 12. hideTop + hideRight (spec §22.1.2.12) + // 13 (all hidden, no strikes) → fallback, no menclose + 'updiagonalstrike', // 14. strikeBLTR → "/" + 'downdiagonalstrike', // 15. strikeTLBR → "\" + 'horizontalstrike', // 16 + 'verticalstrike', // 17 + 'updiagonalstrike downdiagonalstrike', // 18. X pattern + 'updiagonalstrike horizontalstrike downdiagonalstrike verticalstrike', // 19 + 'box horizontalstrike', // 20 + 'bottom left right horizontalstrike', // 21 + 'box downdiagonalstrike', // 22. Annex L.6.1.3 (m:val="on") + // 23–27 are m:box scenarios → , no menclose + 'box', // 28. m:box inside m:borderBox → outer menclose + 'box', // 29. m:borderBox inside m:box → inner menclose + 'left right', // 30. nested borderBox — outer + 'horizontalstrike', // 30. nested borderBox — inner + ]); + }); + + test('multi-child borderBox content renders as a horizontal row (Annex L.6.1.3)', async ({ superdoc }) => { + await superdoc.loadDocument(BORDERBOX_DOC); + await superdoc.waitForStable(); + + // Without the inner wrap, Chrome's MathML Core treats as + // unknown and each child renders with `display: block math` stacked + // vertically. This test asserts horizontal layout. + const layout = await superdoc.page.evaluate(() => { + const annex = Array.from(document.querySelectorAll('menclose')).find( + (el) => el.getAttribute('notation') === 'box downdiagonalstrike', + ); + if (!annex) return null; + const rect = annex.getBoundingClientRect(); + return { + wider_than_tall: rect.width > rect.height * 1.5, + hasInnerMrow: annex.children[0]?.localName === 'mrow', + innerChildCount: annex.children[0]?.children.length ?? 0, + }; + }); + + expect(layout).not.toBeNull(); + expect(layout!.wider_than_tall).toBe(true); + expect(layout!.hasInnerMrow).toBe(true); + expect(layout!.innerChildCount).toBe(5); // a², =, b², +, c² + }); + + test('ST_OnOff variants (1/true/on/bare/0/false) all resolve correctly', async ({ superdoc }) => { + await superdoc.loadDocument(BORDERBOX_DOC); + await superdoc.waitForStable(); + + // Scenarios 3-8 all share a single hideTop flag; only the m:val form differs. + // "1", "true", "on", and bare-tag → top hidden. "0" and "false" → top visible. + const notations = await superdoc.page.evaluate(() => { + const all = Array.from(document.querySelectorAll('menclose')).map((el) => el.getAttribute('notation')); + return all.slice(2, 8); // indexes 2..7 = scenarios 3..8 + }); + + // First four should all mean "top hidden" + expect(notations.slice(0, 4)).toEqual([ + 'bottom left right', // "1" + 'bottom left right', // "true" + 'bottom left right', // "on" + 'bottom left right', // bare tag + ]); + // Last two should mean "nothing hidden" → default box + expect(notations.slice(4)).toEqual(['box', 'box']); + }); + + test('m:box drops boxPr semantics and falls back to ', async ({ superdoc }) => { + await superdoc.loadDocument(BORDERBOX_DOC); + await superdoc.waitForStable(); + + // Scenarios 23-26 all produce (opEmu / noBreak / aln / diff currently ignored). + // Scenario 27 (empty m:box) should drop entirely. + const mrowOnlyTexts = await superdoc.page.evaluate(() => { + return Array.from(document.querySelectorAll('math')) + .filter((m) => !m.querySelector('menclose')) + .map((m) => m.textContent?.trim()); + }); + + // 13 (all-hides fallback), 23, 24, 25, 26 → mrow (5 total). 27 is dropped. + expect(mrowOnlyTexts).toEqual(['nobdr', '==', 'a==b', 'nbr', 'pAll']); + }); + + test('menclose polyfill stylesheet is injected', async ({ superdoc }) => { + await superdoc.loadDocument(BORDERBOX_DOC); + await superdoc.waitForStable(); + + // Without this stylesheet, borders and strikes are invisible in Chrome + // because MathML Core dropped . The polyfill lives in + // styles.ts → ensureMathMencloseStyles(). + const polyfill = await superdoc.page.evaluate(() => { + const style = document.querySelector('style[data-superdoc-math-menclose-styles]'); + if (!style) return null; + const css = style.textContent || ''; + return { + bytes: css.length, + hasBoxBorder: css.includes('notation~="box"') && css.includes('border:'), + hasUpDiagonal: css.includes('updiagonalstrike'), + hasDownDiagonal: css.includes('downdiagonalstrike'), + }; + }); + + expect(polyfill).not.toBeNull(); + expect(polyfill!.bytes).toBeGreaterThan(500); + expect(polyfill!.hasBoxBorder).toBe(true); + expect(polyfill!.hasUpDiagonal).toBe(true); + expect(polyfill!.hasDownDiagonal).toBe(true); + }); +}); From fc840db86ac5e0a0d7a9fd5fd4199aa0a9b94af2 Mon Sep 17 00:00:00 2001 From: ARTUR QUIRINO Date: Mon, 20 Apr 2026 14:03:12 -0300 Subject: [PATCH 11/30] fix: remove toolbar window listeners on unmount (#2783) * fix(super-editor): remove toolbar window listeners on unmount * fix: setup window listeners on onMounted and onActivated * test: assert Toolbar window listener cleanup * fix(super-editor): unmount toolbar Vue app on SuperToolbar.destroy Without this, the Toolbar component's onBeforeUnmount hook never runs when SuperDoc is destroyed, so the window resize/keydown listeners this PR registers are never removed. Verified in the dev app: dispatching a resize event after destroy still fired the toolbar handler; with the unmount call the handlers are cleanly removed. Also: - drop dead teardownWindowListeners() call inside setupWindowListeners (addEventListener is idempotent for the same type+listener pair) - add afterEach(vi.restoreAllMocks) to Toolbar.test.js so spies don't leak across tests if an assertion throws --------- Co-authored-by: Caio Pizzol --- .../v1/components/toolbar/Toolbar.test.js | 114 ++++++++++++++++++ .../editors/v1/components/toolbar/Toolbar.vue | 36 ++++-- .../v1/components/toolbar/super-toolbar.js | 4 +- 3 files changed, 142 insertions(+), 12 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/components/toolbar/Toolbar.test.js diff --git a/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.test.js b/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.test.js new file mode 100644 index 0000000000..27f497687d --- /dev/null +++ b/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.test.js @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { defineComponent, ref, KeepAlive } from 'vue'; +import Toolbar from './Toolbar.vue'; + +const ToolbarKeepAliveHost = defineComponent({ + components: { KeepAlive, Toolbar }, + setup() { + const visible = ref(true); + return { visible }; + }, + template: '', +}); + +function createMockToolbar() { + return { + config: { + toolbarGroups: ['left', 'center', 'right'], + toolbarButtonsExclude: [], + }, + getToolbarItemByGroup: () => [], + getToolbarItemByName: () => null, + onToolbarResize: vi.fn(), + emitCommand: vi.fn(), + overflowItems: [], + activeEditor: null, + }; +} + +describe('Toolbar', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('removes resize and keydown listeners on unmount (not only on KeepAlive deactivate)', () => { + const mockToolbar = createMockToolbar(); + const addSpy = vi.spyOn(window, 'addEventListener'); + const removeSpy = vi.spyOn(window, 'removeEventListener'); + + const wrapper = mount(Toolbar, { + global: { + stubs: { ButtonGroup: true }, + plugins: [ + (app) => { + app.config.globalProperties.$toolbar = mockToolbar; + }, + ], + }, + }); + + const resizeHandler = addSpy.mock.calls.find((c) => c[0] === 'resize')?.[1]; + const keydownHandler = addSpy.mock.calls.find((c) => c[0] === 'keydown')?.[1]; + expect(resizeHandler).toBeTypeOf('function'); + expect(keydownHandler).toBeTypeOf('function'); + + removeSpy.mockClear(); + wrapper.unmount(); + + expect(removeSpy).toHaveBeenCalledWith('resize', resizeHandler); + expect(removeSpy).toHaveBeenCalledWith('keydown', keydownHandler); + + addSpy.mockRestore(); + removeSpy.mockRestore(); + }); + + it('removes window listeners on KeepAlive deactivate and restores them on activate', async () => { + const mockToolbar = createMockToolbar(); + const addSpy = vi.spyOn(window, 'addEventListener'); + const removeSpy = vi.spyOn(window, 'removeEventListener'); + + const wrapper = mount(ToolbarKeepAliveHost, { + global: { + stubs: { ButtonGroup: true }, + plugins: [ + (app) => { + app.config.globalProperties.$toolbar = mockToolbar; + }, + ], + }, + }); + + const resizeHandler = addSpy.mock.calls.find((c) => c[0] === 'resize')?.[1]; + const keydownHandler = addSpy.mock.calls.find((c) => c[0] === 'keydown')?.[1]; + expect(resizeHandler).toBeTypeOf('function'); + expect(keydownHandler).toBeTypeOf('function'); + + addSpy.mockClear(); + removeSpy.mockClear(); + + wrapper.vm.visible = false; + await wrapper.vm.$nextTick(); + + expect(removeSpy).toHaveBeenCalledWith('resize', resizeHandler); + expect(removeSpy).toHaveBeenCalledWith('keydown', keydownHandler); + + addSpy.mockClear(); + removeSpy.mockClear(); + + wrapper.vm.visible = true; + await wrapper.vm.$nextTick(); + + expect(addSpy).toHaveBeenCalledWith('resize', resizeHandler); + expect(addSpy).toHaveBeenCalledWith('keydown', keydownHandler); + + removeSpy.mockClear(); + wrapper.unmount(); + + expect(removeSpy).toHaveBeenCalledWith('resize', resizeHandler); + expect(removeSpy).toHaveBeenCalledWith('keydown', keydownHandler); + + addSpy.mockRestore(); + removeSpy.mockRestore(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.vue b/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.vue index d2a03efca6..59ecd7ace3 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.vue +++ b/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.vue @@ -1,5 +1,14 @@