diff --git a/apps/playground/package.json b/apps/playground/package.json index ebe9a1ca4..e7c1aef8f 100644 --- a/apps/playground/package.json +++ b/apps/playground/package.json @@ -28,6 +28,7 @@ "@portabletext/plugin-paste-link": "workspace:*", "@portabletext/plugin-typeahead-picker": "workspace:*", "@portabletext/plugin-typography": "workspace:*", + "@portabletext/plugin-yjs": "workspace:*", "@portabletext/react": "^6.0.2", "@portabletext/toolbar": "workspace:*", "@xstate/react": "^6.0.0", @@ -41,6 +42,7 @@ "remeda": "^2.32.0", "shiki": "^3.17.0", "xstate": "^5.25.0", + "yjs": "^13.6.29", "zod": "^4.1.12" }, "devDependencies": { diff --git a/apps/playground/src/editor.tsx b/apps/playground/src/editor.tsx index 8217f25a3..5d779de10 100644 --- a/apps/playground/src/editor.tsx +++ b/apps/playground/src/editor.tsx @@ -75,10 +75,12 @@ import {Tooltip} from './primitives/tooltip' import {RangeDecorationButton} from './range-decoration-button' import {SlashCommandPickerPlugin} from './slash-command-picker' import {PortableTextToolbar} from './toolbar/portable-text-toolbar' +import {PlaygroundYjsPlugin} from './yjs-plugin' export function Editor(props: { editorRef: EditorActorRef rangeDecorations: RangeDecoration[] + editorIndex?: number }) { const value = useSelector(props.editorRef, (s) => s.context.value) const keyGenerator = useSelector( @@ -150,6 +152,12 @@ export function Editor(props: { ) : null} + {playgroundFeatureFlags.yjsMode ? ( + + ) : null} {featureFlags.emojiPickerPlugin ? : null} {featureFlags.mentionPickerPlugin ? : null} {featureFlags.slashCommandPlugin ? ( diff --git a/apps/playground/src/editors.tsx b/apps/playground/src/editors.tsx index 6290aa3a6..7283c93a3 100644 --- a/apps/playground/src/editors.tsx +++ b/apps/playground/src/editors.tsx @@ -29,11 +29,12 @@ export function Editors(props: {playgroundRef: PlaygroundActorRef}) { - {editors.map((editor) => ( + {editors.map((editor, index) => ( ))} diff --git a/apps/playground/src/feature-flags.ts b/apps/playground/src/feature-flags.ts index f22d97b6e..803606ba6 100644 --- a/apps/playground/src/feature-flags.ts +++ b/apps/playground/src/feature-flags.ts @@ -2,10 +2,14 @@ import {createContext} from 'react' export type PlaygroundFeatureFlags = { toolbar: boolean + yjsMode: boolean + yjsLatency: number } export const defaultPlaygroundFeatureFlags: PlaygroundFeatureFlags = { toolbar: true, + yjsMode: false, + yjsLatency: 0, } export const PlaygroundFeatureFlagsContext = diff --git a/apps/playground/src/header.tsx b/apps/playground/src/header.tsx index 75227f307..05072bf60 100644 --- a/apps/playground/src/header.tsx +++ b/apps/playground/src/header.tsx @@ -4,6 +4,7 @@ import { GithubIcon, MonitorIcon, MoonIcon, + NetworkIcon, PanelRightIcon, PlusIcon, SunIcon, @@ -110,6 +111,18 @@ export function Header(props: {playgroundRef: PlaygroundActorRef}) { Toolbar + { + props.playgroundRef.send({ + type: 'toggle feature flag', + flag: 'yjsMode', + }) + }} + > + + Yjs + { diff --git a/apps/playground/src/inspector.tsx b/apps/playground/src/inspector.tsx index dc3639e17..6b6c3f78e 100644 --- a/apps/playground/src/inspector.tsx +++ b/apps/playground/src/inspector.tsx @@ -1,5 +1,11 @@ import {useActorRef, useSelector} from '@xstate/react' -import {CheckIcon, CopyIcon, HistoryIcon, TrashIcon} from 'lucide-react' +import { + CheckIcon, + CopyIcon, + GitBranchIcon, + HistoryIcon, + TrashIcon, +} from 'lucide-react' import {useEffect, useState} from 'react' import {TooltipTrigger, type Key} from 'react-aria-components' import {highlightMachine} from './highlight-json-machine' @@ -13,8 +19,14 @@ import {Container} from './primitives/container' import {Spinner} from './primitives/spinner' import {Tab, TabList, TabPanel, Tabs} from './primitives/tabs' import {Tooltip} from './primitives/tooltip' +import {YjsTreeViewer} from './yjs-plugin' -type TabId = 'output' | 'patches' | 'react-preview' | 'markdown-preview' +type TabId = + | 'output' + | 'patches' + | 'react-preview' + | 'markdown-preview' + | 'yjs-tree' export function Inspector(props: {playgroundRef: PlaygroundActorRef}) { const [activeTab, setActiveTab] = useState('output') @@ -55,6 +67,12 @@ export function Inspector(props: {playgroundRef: PlaygroundActorRef}) { Markdown + + + + Y.Doc + + @@ -82,6 +100,11 @@ export function Inspector(props: {playgroundRef: PlaygroundActorRef}) { + + + + + ) } diff --git a/apps/playground/src/playground-machine.ts b/apps/playground/src/playground-machine.ts index 9b8833af6..f539ed627 100644 --- a/apps/playground/src/playground-machine.ts +++ b/apps/playground/src/playground-machine.ts @@ -295,6 +295,11 @@ export const playgroundMachine = setup({ actions: { 'broadcast patches': ({context, event}) => { assertEvent(event, 'editor.mutation') + // When Yjs mode is on, patches flow through the shared Y.Doc + // instead of being broadcast directly between editors + if (context.featureFlags.yjsMode) { + return + } context.editors.forEach((editor) => { editor.send({ type: 'patches', @@ -336,6 +341,10 @@ export const playgroundMachine = setup({ patchFeed: [], }), 'broadcast value': ({context}) => { + // When Yjs mode is on, value sync happens through the Y.Doc + if (context.featureFlags.yjsMode) { + return + } const value = context.value if (value !== null) { context.editors.forEach((editor) => { diff --git a/apps/playground/src/yjs-plugin.tsx b/apps/playground/src/yjs-plugin.tsx new file mode 100644 index 000000000..c322bd03b --- /dev/null +++ b/apps/playground/src/yjs-plugin.tsx @@ -0,0 +1,166 @@ +import {useEditor} from '@portabletext/editor' +import {createYjsPlugin} from '@portabletext/plugin-yjs' +import {useContext, useEffect, useState} from 'react' +import * as Y from 'yjs' +import {PlaygroundFeatureFlagsContext} from './feature-flags' + +// Shared Y.Doc — all editors connect to the same doc +const sharedYDoc = new Y.Doc() + +/** + * Playground Yjs plugin component. + * Connects an editor to the shared Y.Doc for CRDT sync. + */ +export function PlaygroundYjsPlugin(props: { + editorIndex: number + useLatency?: boolean +}) { + const featureFlags = useContext(PlaygroundFeatureFlagsContext) + const editor = useEditor() + + useEffect(() => { + if (!featureFlags.yjsMode) { + return + } + + const localOrigin = `editor-${props.editorIndex}` + + let yDoc: Y.Doc + let cleanup: (() => void) | undefined + + if (props.useLatency && featureFlags.yjsLatency > 0) { + // Latency simulation: each editor gets its own Y.Doc synced with delay + yDoc = new Y.Doc() + + const handleSharedUpdate = (update: Uint8Array, origin: unknown) => { + if (origin === localOrigin) { + return + } + setTimeout(() => { + Y.applyUpdate(yDoc, update) + }, featureFlags.yjsLatency) + } + + const handleLocalUpdate = (update: Uint8Array, origin: unknown) => { + if (origin === localOrigin) { + setTimeout(() => { + Y.applyUpdate(sharedYDoc, update, localOrigin) + }, featureFlags.yjsLatency) + } + } + + sharedYDoc.on('update', handleSharedUpdate) + yDoc.on('update', handleLocalUpdate) + + cleanup = () => { + sharedYDoc.off('update', handleSharedUpdate) + yDoc.off('update', handleLocalUpdate) + } + } else { + yDoc = sharedYDoc + } + + const plugin = createYjsPlugin({ + editor, + yDoc, + localOrigin, + }) + + plugin.connect() + + return () => { + plugin.disconnect() + cleanup?.() + } + }, [ + editor, + featureFlags.yjsMode, + featureFlags.yjsLatency, + props.editorIndex, + props.useLatency, + ]) + + return null +} + +/** + * Y.Doc viewer for the inspector panel. + * Shows the XmlFragment structure: blocks as XmlText with attributes and delta. + */ +export function YjsTreeViewer() { + const [tree, setTree] = useState('') + + useEffect(() => { + const update = () => { + const root = sharedYDoc.getXmlFragment('content') + const lines: string[] = [] + lines.push(`Y.Doc — XmlFragment "content" (${root.length} blocks)`) + lines.push('') + + for (let i = 0; i < root.length; i++) { + const child = root.get(i) + if (child instanceof Y.XmlText) { + const attrs = child.getAttributes() + const key = attrs._key ?? '?' + const type = attrs._type ?? '?' + const style = attrs.style ?? '' + const listItem = attrs.listItem ? ` [${attrs.listItem}]` : '' + + lines.push(`[${i}] ${type} _key="${key}" style="${style}"${listItem}`) + + // Show delta (spans) + const delta = child.toDelta() as Array<{ + insert: string | Y.XmlText + attributes?: Record + }> + for (const entry of delta) { + if (typeof entry.insert === 'string') { + const spanKey = entry.attributes?._key ?? '?' + const marks = entry.attributes?.marks ?? '[]' + const text = + entry.insert.length > 40 + ? `${entry.insert.slice(0, 40)}…` + : entry.insert + lines.push(` span _key="${spanKey}" marks=${marks}`) + lines.push(` "${text}"`) + } + } + + // Show markDefs if present + const markDefs = attrs.markDefs + if (markDefs && markDefs !== '[]') { + lines.push(` markDefs: ${markDefs}`) + } + + lines.push('') + } + } + + setTree(lines.join('\n')) + } + + update() + sharedYDoc.on('update', update) + return () => { + sharedYDoc.off('update', update) + } + }, []) + + return ( +
+      {tree || 'Y.Doc is empty. Enable Yjs mode and start typing.'}
+    
+ ) +} + +/** + * Reset the shared Y.Doc + */ +export function resetSharedYDoc() { + const root = sharedYDoc.getXmlFragment('content') + sharedYDoc.transact(() => { + if (root.length > 0) { + root.delete(0, root.length) + } + }) +} diff --git a/packages/plugin-yjs/biome.json b/packages/plugin-yjs/biome.json new file mode 100644 index 000000000..7d3391384 --- /dev/null +++ b/packages/plugin-yjs/biome.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.2.2/schema.json", + "extends": ["../../biome.json"], + "linter": { + "rules": { + "complexity": { + "useLiteralKeys": "off" + } + } + } +} diff --git a/packages/plugin-yjs/package.json b/packages/plugin-yjs/package.json new file mode 100644 index 000000000..80e8472e0 --- /dev/null +++ b/packages/plugin-yjs/package.json @@ -0,0 +1,64 @@ +{ + "name": "@portabletext/plugin-yjs", + "version": "0.0.1", + "description": "Yjs CRDT integration for the Portable Text Editor", + "keywords": [ + "portabletext", + "yjs", + "crdt", + "collaboration" + ], + "homepage": "https://portabletext.org", + "bugs": { + "url": "https://github.com/portabletext/editor/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/portabletext/editor.git", + "directory": "packages/plugin-yjs" + }, + "license": "MIT", + "author": "Sanity.io ", + "sideEffects": false, + "type": "module", + "exports": { + ".": { + "source": "./src/index.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "pkg-utils build --strict --check --clean", + "check:lint": "biome lint .", + "check:types": "tsc --noEmit --pretty", + "clean": "del .turbo && del dist && del node_modules", + "dev": "pkg-utils watch", + "lint:fix": "biome lint --write .", + "test": "vitest --run" + }, + "dependencies": { + "@sanity/diff-match-patch": "^3.2.0" + }, + "devDependencies": { + "@portabletext/editor": "workspace:^", + "@portabletext/patches": "workspace:^", + "@portabletext/schema": "workspace:^", + "@sanity/diff-match-patch": "^3.2.0", + "@sanity/pkg-utils": "^7.2.3", + "@sanity/tsconfig": "^2.1.0", + "typescript": "catalog:", + "vitest": "^3.2.3" + }, + "peerDependencies": { + "@portabletext/editor": "^1.0.0", + "@portabletext/patches": "^2.0.0", + "@portabletext/schema": "^1.0.0", + "yjs": "^13.0.0" + } +} diff --git a/packages/plugin-yjs/src/__tests__/apply-to-ydoc.test.ts b/packages/plugin-yjs/src/__tests__/apply-to-ydoc.test.ts new file mode 100644 index 000000000..c3496d60a --- /dev/null +++ b/packages/plugin-yjs/src/__tests__/apply-to-ydoc.test.ts @@ -0,0 +1,504 @@ +import {describe, expect, test} from 'vitest' +import * as Y from 'yjs' +import {applyPatchToYDoc, blockToYText} from '../apply-to-ydoc' +import {createKeyMap} from '../key-map' +import type {KeyMap} from '../types' + +/** + * Helper: create a Y.Doc with a single text block. + */ +function createDocWithBlock(opts: { + blockKey: string + spanKey: string + text: string + style?: string + marks?: string[] +}) { + const {blockKey, spanKey, text, style = 'normal', marks = []} = opts + const yDoc = new Y.Doc() + const root = yDoc.getXmlFragment('content') + const keyMap = createKeyMap() + + yDoc.transact(() => { + const yBlock = blockToYText( + { + _key: blockKey, + _type: 'block', + style, + markDefs: [], + children: [ + { + _key: spanKey, + _type: 'span', + text, + marks, + }, + ], + }, + keyMap, + ) + root.insert(0, [yBlock]) + }) + + const block = keyMap.getYText(blockKey)! + return {yDoc, root, block, keyMap} +} + +/** + * Helper: create a Y.Doc with two blocks. + */ +function createDocWithTwoBlocks() { + const yDoc = new Y.Doc() + const root = yDoc.getXmlFragment('content') + const keyMap = createKeyMap() + + yDoc.transact(() => { + const block1 = blockToYText( + { + _key: 'b1', + _type: 'block', + style: 'normal', + markDefs: [], + children: [{_key: 's1', _type: 'span', text: 'first', marks: []}], + }, + keyMap, + ) + const block2 = blockToYText( + { + _key: 'b2', + _type: 'block', + style: 'normal', + markDefs: [], + children: [{_key: 's2', _type: 'span', text: 'second', marks: []}], + }, + keyMap, + ) + root.insert(0, [block1, block2]) + }) + + return {yDoc, root, keyMap} +} + +/** + * Helper: get block text from delta (toString returns XML, not plain text). + */ +function getBlockText(keyMap: KeyMap, blockKey: string): string { + const yBlock = keyMap.getYText(blockKey) + if (!yBlock) { + return '' + } + const delta = yBlock.toDelta() as Array<{ + insert: string | Y.XmlText + }> + let text = '' + for (const entry of delta) { + if (typeof entry.insert === 'string') { + text += entry.insert + } + } + return text +} + +/** + * Helper: get block delta entries. + */ +function getBlockDelta( + keyMap: KeyMap, + blockKey: string, +): Array<{insert: string; attributes?: Record}> { + const yBlock = keyMap.getYText(blockKey) + if (!yBlock) { + return [] + } + return yBlock.toDelta() as Array<{ + insert: string + attributes?: Record + }> +} + +describe('blockToYText', () => { + test('converts a simple block to Y.XmlText', () => { + const yDoc = new Y.Doc() + const root = yDoc.getXmlFragment('test') + const keyMap = createKeyMap() + + let yBlock!: Y.XmlText + yDoc.transact(() => { + yBlock = blockToYText( + { + _key: 'b1', + _type: 'block', + style: 'normal', + markDefs: [], + children: [{_key: 's1', _type: 'span', text: 'hello', marks: []}], + }, + keyMap, + ) + root.insert(0, [yBlock]) + }) + + expect(yBlock.getAttribute('_key')).toBe('b1') + expect(yBlock.getAttribute('_type')).toBe('block') + expect(yBlock.getAttribute('style')).toBe('normal') + expect(yBlock.getAttribute('markDefs')).toBe('[]') + + const delta = yBlock.toDelta() as Array<{ + insert: string + attributes?: Record + }> + expect(delta).toHaveLength(1) + expect(delta[0]!.insert).toBe('hello') + expect(delta[0]!.attributes?.['_key']).toBe('s1') + expect(delta[0]!.attributes?.['marks']).toBe('[]') + + // KeyMap should be populated + expect(keyMap.getYText('b1')).toBe(yBlock) + expect(keyMap.getKey(yBlock)).toBe('b1') + }) + + test('converts a block with multiple spans', () => { + const yDoc = new Y.Doc() + const root = yDoc.getXmlFragment('test') + const keyMap = createKeyMap() + + let yBlock!: Y.XmlText + yDoc.transact(() => { + yBlock = blockToYText( + { + _key: 'b1', + _type: 'block', + style: 'normal', + markDefs: [ + {_key: 'link1', _type: 'link', href: 'https://example.com'}, + ], + children: [ + {_key: 's1', _type: 'span', text: 'hello ', marks: []}, + {_key: 's2', _type: 'span', text: 'world', marks: ['strong']}, + ], + }, + keyMap, + ) + root.insert(0, [yBlock]) + }) + + const delta = yBlock.toDelta() as Array<{ + insert: string + attributes?: Record + }> + expect(delta).toHaveLength(2) + expect(delta[0]!.insert).toBe('hello ') + expect(delta[0]!.attributes?.['_key']).toBe('s1') + expect(delta[1]!.insert).toBe('world') + expect(delta[1]!.attributes?.['_key']).toBe('s2') + expect(delta[1]!.attributes?.['marks']).toBe('["strong"]') + }) +}) + +describe('applyPatchToYDoc', () => { + describe('diffMatchPatch', () => { + test('applies text insertion at end of span', () => { + const {root, keyMap} = createDocWithBlock({ + blockKey: 'b1', + spanKey: 's1', + text: 'hello', + }) + + applyPatchToYDoc( + { + type: 'diffMatchPatch', + path: [{_key: 'b1'}, 'children', {_key: 's1'}, 'text'], + value: '@@ -1,5 +1,11 @@\n hello\n+ world\n', + }, + root, + keyMap, + ) + + expect(getBlockText(keyMap, 'b1')).toBe('hello world') + }) + + test('applies text insertion at beginning of span', () => { + const {root, keyMap} = createDocWithBlock({ + blockKey: 'b1', + spanKey: 's1', + text: 'world', + }) + + applyPatchToYDoc( + { + type: 'diffMatchPatch', + path: [{_key: 'b1'}, 'children', {_key: 's1'}, 'text'], + value: '@@ -1,5 +1,11 @@\n+hello \n world\n', + }, + root, + keyMap, + ) + + expect(getBlockText(keyMap, 'b1')).toBe('hello world') + }) + + test('applies text deletion', () => { + const {root, keyMap} = createDocWithBlock({ + blockKey: 'b1', + spanKey: 's1', + text: 'hello world', + }) + + applyPatchToYDoc( + { + type: 'diffMatchPatch', + path: [{_key: 'b1'}, 'children', {_key: 's1'}, 'text'], + value: '@@ -1,11 +1,5 @@\n hello\n- world\n', + }, + root, + keyMap, + ) + + expect(getBlockText(keyMap, 'b1')).toBe('hello') + }) + }) + + describe('set patch', () => { + test('sets block style', () => { + const {root, keyMap} = createDocWithBlock({ + blockKey: 'b1', + spanKey: 's1', + text: 'hello', + style: 'normal', + }) + + applyPatchToYDoc( + { + type: 'set', + path: [{_key: 'b1'}, 'style'], + value: 'h1', + }, + root, + keyMap, + ) + + const yBlock = keyMap.getYText('b1')! + expect(yBlock.getAttribute('style')).toBe('h1') + }) + + test('sets block listItem', () => { + const {root, keyMap} = createDocWithBlock({ + blockKey: 'b1', + spanKey: 's1', + text: 'hello', + }) + + applyPatchToYDoc( + { + type: 'set', + path: [{_key: 'b1'}, 'listItem'], + value: 'bullet', + }, + root, + keyMap, + ) + + const yBlock = keyMap.getYText('b1')! + expect(yBlock.getAttribute('listItem')).toBe('bullet') + }) + + test('sets span marks', () => { + const {root, keyMap} = createDocWithBlock({ + blockKey: 'b1', + spanKey: 's1', + text: 'hello', + marks: [], + }) + + applyPatchToYDoc( + { + type: 'set', + path: [{_key: 'b1'}, 'children', {_key: 's1'}, 'marks'], + value: ['strong'], + }, + root, + keyMap, + ) + + const delta = getBlockDelta(keyMap, 'b1') + expect(delta[0]!.attributes?.['marks']).toBe('["strong"]') + }) + + test('sets markDefs as JSON', () => { + const {root, keyMap} = createDocWithBlock({ + blockKey: 'b1', + spanKey: 's1', + text: 'hello', + }) + + const markDefs = [ + {_key: 'link1', _type: 'link', href: 'https://example.com'}, + ] + applyPatchToYDoc( + { + type: 'set', + path: [{_key: 'b1'}, 'markDefs'], + value: markDefs, + }, + root, + keyMap, + ) + + const yBlock = keyMap.getYText('b1')! + expect(yBlock.getAttribute('markDefs')).toBe(JSON.stringify(markDefs)) + }) + }) + + describe('unset patch', () => { + test('removes a block', () => { + const {root, keyMap} = createDocWithTwoBlocks() + + expect(root.length).toBe(2) + + applyPatchToYDoc( + { + type: 'unset', + path: [{_key: 'b2'}], + }, + root, + keyMap, + ) + + expect(root.length).toBe(1) + expect(keyMap.getYText('b2')).toBeUndefined() + }) + + test('removes a block attribute', () => { + const {root, keyMap} = createDocWithBlock({ + blockKey: 'b1', + spanKey: 's1', + text: 'hello', + }) + + // First set listItem + const yBlock = keyMap.getYText('b1')! + yBlock.setAttribute('listItem', 'bullet') + + applyPatchToYDoc( + { + type: 'unset', + path: [{_key: 'b1'}, 'listItem'], + }, + root, + keyMap, + ) + + expect(yBlock.getAttribute('listItem')).toBeUndefined() + }) + }) + + describe('insert patch', () => { + test('inserts a block after another', () => { + const {root, keyMap} = createDocWithBlock({ + blockKey: 'b1', + spanKey: 's1', + text: 'first', + }) + + applyPatchToYDoc( + { + type: 'insert', + path: [{_key: 'b1'}], + position: 'after', + items: [ + { + _key: 'b2', + _type: 'block', + style: 'normal', + markDefs: [], + children: [ + {_key: 's2', _type: 'span', text: 'second', marks: []}, + ], + }, + ], + }, + root, + keyMap, + ) + + expect(root.length).toBe(2) + expect(keyMap.getYText('b2')).toBeDefined() + expect(getBlockText(keyMap, 'b2')).toBe('second') + }) + + test('inserts a block before another', () => { + const {root, keyMap} = createDocWithBlock({ + blockKey: 'b1', + spanKey: 's1', + text: 'second', + }) + + applyPatchToYDoc( + { + type: 'insert', + path: [{_key: 'b1'}], + position: 'before', + items: [ + { + _key: 'b0', + _type: 'block', + style: 'normal', + markDefs: [], + children: [{_key: 's0', _type: 'span', text: 'first', marks: []}], + }, + ], + }, + root, + keyMap, + ) + + expect(root.length).toBe(2) + const firstBlock = root.get(0) as Y.XmlText + expect(firstBlock.getAttribute('_key')).toBe('b0') + }) + }) + + describe('setIfMissing patch', () => { + test('sets attribute if not present', () => { + const {root, keyMap} = createDocWithBlock({ + blockKey: 'b1', + spanKey: 's1', + text: 'hello', + }) + + applyPatchToYDoc( + { + type: 'setIfMissing', + path: [{_key: 'b1'}, 'listItem'], + value: 'bullet', + }, + root, + keyMap, + ) + + const yBlock = keyMap.getYText('b1')! + expect(yBlock.getAttribute('listItem')).toBe('bullet') + }) + + test('does not overwrite existing attribute', () => { + const {root, keyMap} = createDocWithBlock({ + blockKey: 'b1', + spanKey: 's1', + text: 'hello', + style: 'h1', + }) + + applyPatchToYDoc( + { + type: 'setIfMissing', + path: [{_key: 'b1'}, 'style'], + value: 'normal', + }, + root, + keyMap, + ) + + const yBlock = keyMap.getYText('b1')! + expect(yBlock.getAttribute('style')).toBe('h1') + }) + }) +}) diff --git a/packages/plugin-yjs/src/__tests__/edge-cases.test.ts b/packages/plugin-yjs/src/__tests__/edge-cases.test.ts new file mode 100644 index 000000000..c8f2ac9b3 --- /dev/null +++ b/packages/plugin-yjs/src/__tests__/edge-cases.test.ts @@ -0,0 +1,658 @@ +import {describe, expect, test} from 'vitest' +import * as Y from 'yjs' +import {applyPatchToYDoc, blockToYText} from '../apply-to-ydoc' +import {createKeyMap} from '../key-map' +import type {KeyMap} from '../types' + +/** + * Helper: get plain text from a block's delta. + */ +function getBlockText(yBlock: Y.XmlText): string { + const delta = yBlock.toDelta() as Array<{insert: string | Y.XmlText}> + let text = '' + for (const entry of delta) { + if (typeof entry.insert === 'string') { + text += entry.insert + } + } + return text +} + +/** + * Helper: get span entries from a block. + */ +function getSpans( + yBlock: Y.XmlText, +): Array<{text: string; key: string; marks: string[]}> { + const delta = yBlock.toDelta() as Array<{ + insert: string + attributes?: Record + }> + return delta + .filter((d) => typeof d.insert === 'string') + .map((d) => ({ + text: d.insert as string, + key: (d.attributes?.['_key'] as string) ?? '', + marks: JSON.parse((d.attributes?.['marks'] as string) ?? '[]'), + })) +} + +/** + * Helper: create a Y.Doc with blocks. + */ +function createDoc(blocks: Array>): { + yDoc: Y.Doc + root: Y.XmlFragment + keyMap: KeyMap +} { + const yDoc = new Y.Doc() + const root = yDoc.getXmlFragment('content') + const keyMap = createKeyMap() + + yDoc.transact(() => { + for (const block of blocks) { + const yBlock = blockToYText(block, keyMap) + root.insert(root.length, [yBlock]) + } + }) + + return {yDoc, root, keyMap} +} + +describe('edge cases: empty blocks', () => { + test('empty block converts to Y.XmlText with no delta', () => { + const {keyMap} = createDoc([ + { + _key: 'b1', + _type: 'block', + style: 'normal', + markDefs: [], + children: [{_key: 's1', _type: 'span', text: '', marks: []}], + }, + ]) + + const block = keyMap.getYText('b1')! + expect(getBlockText(block)).toBe('') + // Yjs optimizes away empty string inserts — no delta entries + const spans = getSpans(block) + expect(spans).toHaveLength(0) + }) + + test('insert text into empty block', () => { + const {root, keyMap} = createDoc([ + { + _key: 'b1', + _type: 'block', + style: 'normal', + markDefs: [], + children: [{_key: 's1', _type: 'span', text: '', marks: []}], + }, + ]) + + applyPatchToYDoc( + { + type: 'diffMatchPatch', + path: [{_key: 'b1'}, 'children', {_key: 's1'}, 'text'], + value: '@@ -0,0 +1,5 @@\n+hello\n', + }, + root, + keyMap, + ) + + expect(getBlockText(keyMap.getYText('b1')!)).toBe('hello') + }) +}) + +describe('edge cases: multiple spans', () => { + test('edit text in second span of multi-span block', () => { + const {root, keyMap} = createDoc([ + { + _key: 'b1', + _type: 'block', + style: 'normal', + markDefs: [], + children: [ + {_key: 's1', _type: 'span', text: 'hello ', marks: []}, + {_key: 's2', _type: 'span', text: 'world', marks: ['strong']}, + ], + }, + ]) + + // Edit the second span + applyPatchToYDoc( + { + type: 'diffMatchPatch', + path: [{_key: 'b1'}, 'children', {_key: 's2'}, 'text'], + value: '@@ -1,5 +1,11 @@\n world\n+ today\n', + }, + root, + keyMap, + ) + + const spans = getSpans(keyMap.getYText('b1')!) + expect(spans).toHaveLength(2) + expect(spans[0].text).toBe('hello ') + expect(spans[0].key).toBe('s1') + expect(spans[1].text).toBe('world today') + expect(spans[1].key).toBe('s2') + expect(spans[1].marks).toEqual(['strong']) + }) + + test('change marks on one span preserves others', () => { + const {root, keyMap} = createDoc([ + { + _key: 'b1', + _type: 'block', + style: 'normal', + markDefs: [], + children: [ + {_key: 's1', _type: 'span', text: 'hello ', marks: []}, + {_key: 's2', _type: 'span', text: 'world', marks: ['strong']}, + {_key: 's3', _type: 'span', text: '!', marks: []}, + ], + }, + ]) + + // Bold the first span + applyPatchToYDoc( + { + type: 'set', + path: [{_key: 'b1'}, 'children', {_key: 's1'}, 'marks'], + value: ['em'], + }, + root, + keyMap, + ) + + const spans = getSpans(keyMap.getYText('b1')!) + expect(spans).toHaveLength(3) + expect(spans[0].marks).toEqual(['em']) + expect(spans[1].marks).toEqual(['strong']) + expect(spans[2].marks).toEqual([]) + }) + + test('insert span between existing spans', () => { + const {root, keyMap} = createDoc([ + { + _key: 'b1', + _type: 'block', + style: 'normal', + markDefs: [], + children: [ + {_key: 's1', _type: 'span', text: 'hello', marks: []}, + {_key: 's3', _type: 'span', text: 'world', marks: []}, + ], + }, + ]) + + // Insert a span after s1 + applyPatchToYDoc( + { + type: 'insert', + path: [{_key: 'b1'}, 'children', {_key: 's1'}], + position: 'after', + items: [ + {_key: 's2', _type: 'span', text: ' beautiful ', marks: ['em']}, + ], + }, + root, + keyMap, + ) + + const spans = getSpans(keyMap.getYText('b1')!) + expect(spans).toHaveLength(3) + expect(spans[0].text).toBe('hello') + expect(spans[1].text).toBe(' beautiful ') + expect(spans[1].marks).toEqual(['em']) + expect(spans[2].text).toBe('world') + }) + + test('delete a span from multi-span block', () => { + const {root, keyMap} = createDoc([ + { + _key: 'b1', + _type: 'block', + style: 'normal', + markDefs: [], + children: [ + {_key: 's1', _type: 'span', text: 'hello ', marks: []}, + {_key: 's2', _type: 'span', text: 'beautiful ', marks: ['em']}, + {_key: 's3', _type: 'span', text: 'world', marks: []}, + ], + }, + ]) + + // Remove the middle span + applyPatchToYDoc( + { + type: 'unset', + path: [{_key: 'b1'}, 'children', {_key: 's2'}], + }, + root, + keyMap, + ) + + const spans = getSpans(keyMap.getYText('b1')!) + expect(spans).toHaveLength(2) + expect(spans[0].text).toBe('hello ') + expect(spans[1].text).toBe('world') + }) +}) + +describe('edge cases: block operations', () => { + test('insert multiple blocks at once', () => { + const {root, keyMap} = createDoc([ + { + _key: 'b1', + _type: 'block', + style: 'normal', + markDefs: [], + children: [{_key: 's1', _type: 'span', text: 'first', marks: []}], + }, + ]) + + applyPatchToYDoc( + { + type: 'insert', + path: [{_key: 'b1'}], + position: 'after', + items: [ + { + _key: 'b2', + _type: 'block', + style: 'normal', + markDefs: [], + children: [{_key: 's2', _type: 'span', text: 'second', marks: []}], + }, + { + _key: 'b3', + _type: 'block', + style: 'h1', + markDefs: [], + children: [{_key: 's3', _type: 'span', text: 'third', marks: []}], + }, + ], + }, + root, + keyMap, + ) + + expect(root.length).toBe(3) + expect(getBlockText(keyMap.getYText('b1')!)).toBe('first') + expect(getBlockText(keyMap.getYText('b2')!)).toBe('second') + expect(getBlockText(keyMap.getYText('b3')!)).toBe('third') + expect(keyMap.getYText('b3')!.getAttribute('style')).toBe('h1') + }) + + test('delete first block of multiple', () => { + const {root, keyMap} = createDoc([ + { + _key: 'b1', + _type: 'block', + style: 'normal', + markDefs: [], + children: [{_key: 's1', _type: 'span', text: 'first', marks: []}], + }, + { + _key: 'b2', + _type: 'block', + style: 'normal', + markDefs: [], + children: [{_key: 's2', _type: 'span', text: 'second', marks: []}], + }, + { + _key: 'b3', + _type: 'block', + style: 'normal', + markDefs: [], + children: [{_key: 's3', _type: 'span', text: 'third', marks: []}], + }, + ]) + + applyPatchToYDoc({type: 'unset', path: [{_key: 'b1'}]}, root, keyMap) + + expect(root.length).toBe(2) + expect(keyMap.getYText('b1')).toBeUndefined() + // Remaining blocks should be intact + const child0 = root.get(0) as Y.XmlText + expect(child0.getAttribute('_key')).toBe('b2') + }) + + test('full block replacement via set', () => { + const {root, keyMap} = createDoc([ + { + _key: 'b1', + _type: 'block', + style: 'normal', + markDefs: [], + children: [{_key: 's1', _type: 'span', text: 'old text', marks: []}], + }, + ]) + + applyPatchToYDoc( + { + type: 'set', + path: [{_key: 'b1'}], + value: { + _key: 'b1', + _type: 'block', + style: 'h2', + markDefs: [], + children: [ + {_key: 's1', _type: 'span', text: 'new text', marks: ['strong']}, + ], + }, + }, + root, + keyMap, + ) + + const block = keyMap.getYText('b1')! + expect(block.getAttribute('style')).toBe('h2') + expect(getBlockText(block)).toBe('new text') + const spans = getSpans(block) + expect(spans[0].marks).toEqual(['strong']) + }) +}) + +describe('edge cases: markDefs', () => { + test('set markDefs with link annotation', () => { + const {root, keyMap} = createDoc([ + { + _key: 'b1', + _type: 'block', + style: 'normal', + markDefs: [], + children: [ + {_key: 's1', _type: 'span', text: 'click ', marks: []}, + {_key: 's2', _type: 'span', text: 'here', marks: ['link1']}, + ], + }, + ]) + + applyPatchToYDoc( + { + type: 'set', + path: [{_key: 'b1'}, 'markDefs'], + value: [{_key: 'link1', _type: 'link', href: 'https://example.com'}], + }, + root, + keyMap, + ) + + const block = keyMap.getYText('b1')! + const markDefs = JSON.parse(block.getAttribute('markDefs') as string) + expect(markDefs).toHaveLength(1) + expect(markDefs[0]._key).toBe('link1') + expect(markDefs[0].href).toBe('https://example.com') + }) + + test('setIfMissing markDefs does not overwrite existing', () => { + const {root, keyMap} = createDoc([ + { + _key: 'b1', + _type: 'block', + style: 'normal', + markDefs: [ + {_key: 'link1', _type: 'link', href: 'https://original.com'}, + ], + children: [{_key: 's1', _type: 'span', text: 'hello', marks: []}], + }, + ]) + + applyPatchToYDoc( + { + type: 'setIfMissing', + path: [{_key: 'b1'}, 'markDefs'], + value: [{_key: 'link2', _type: 'link', href: 'https://new.com'}], + }, + root, + keyMap, + ) + + const block = keyMap.getYText('b1')! + const markDefs = JSON.parse(block.getAttribute('markDefs') as string) + expect(markDefs).toHaveLength(1) + expect(markDefs[0]._key).toBe('link1') + expect(markDefs[0].href).toBe('https://original.com') + }) +}) + +describe('edge cases: span split (multi-patch mutations)', () => { + test('bold selection splits span into two (truncate + insert)', () => { + const {root, keyMap} = createDoc([ + { + _key: 'b1', + _type: 'block', + style: 'normal', + markDefs: [], + children: [{_key: 's1', _type: 'span', text: 'hello world', marks: []}], + }, + ]) + + // PTE emits these patches when you select "world" and toggle bold: + // 1. Truncate the original span + // 2. Insert a new bold span after it + // Both should be applied atomically in one Y.Doc transaction + const yDoc = keyMap.getYText('b1')!.doc! + yDoc.transact(() => { + applyPatchToYDoc( + { + type: 'diffMatchPatch', + path: [{_key: 'b1'}, 'children', {_key: 's1'}, 'text'], + value: '@@ -3,9 +3,4 @@\n llo \n-world\n', + }, + root, + keyMap, + ) + + applyPatchToYDoc( + { + type: 'insert', + path: [{_key: 'b1'}, 'children', {_key: 's1'}], + position: 'after', + items: [ + {_key: 's2', _type: 'span', text: 'world', marks: ['strong']}, + ], + }, + root, + keyMap, + ) + }) + + const spans = getSpans(keyMap.getYText('b1')!) + expect(spans).toHaveLength(2) + expect(spans[0].text).toBe('hello ') + expect(spans[0].key).toBe('s1') + expect(spans[0].marks).toEqual([]) + expect(spans[1].text).toBe('world') + expect(spans[1].key).toBe('s2') + expect(spans[1].marks).toEqual(['strong']) + }) + + test('unbold merges spans back (delete span + extend text)', () => { + const {root, keyMap} = createDoc([ + { + _key: 'b1', + _type: 'block', + style: 'normal', + markDefs: [], + children: [ + {_key: 's1', _type: 'span', text: 'hello ', marks: []}, + {_key: 's2', _type: 'span', text: 'world', marks: ['strong']}, + ], + }, + ]) + + // PTE emits these when you select all and remove bold: + // 1. Extend s1 text to include "world" + // 2. Remove s2 + const yDoc = keyMap.getYText('b1')!.doc! + yDoc.transact(() => { + applyPatchToYDoc( + { + type: 'diffMatchPatch', + path: [{_key: 'b1'}, 'children', {_key: 's1'}, 'text'], + value: '@@ -3,4 +3,9 @@\n llo \n+world\n', + }, + root, + keyMap, + ) + + applyPatchToYDoc( + { + type: 'unset', + path: [{_key: 'b1'}, 'children', {_key: 's2'}], + }, + root, + keyMap, + ) + }) + + const spans = getSpans(keyMap.getYText('b1')!) + expect(spans).toHaveLength(1) + expect(spans[0].text).toBe('hello world') + expect(spans[0].key).toBe('s1') + expect(spans[0].marks).toEqual([]) + }) + + test('Enter key: split block into two', () => { + const {root, keyMap} = createDoc([ + { + _key: 'b1', + _type: 'block', + style: 'normal', + markDefs: [], + children: [{_key: 's1', _type: 'span', text: 'hello world', marks: []}], + }, + ]) + + // PTE emits these when you press Enter after "hello": + // 1. Truncate b1's span to "hello" + // 2. Insert new block b2 with " world" + const yDoc = keyMap.getYText('b1')!.doc! + yDoc.transact(() => { + applyPatchToYDoc( + { + type: 'diffMatchPatch', + path: [{_key: 'b1'}, 'children', {_key: 's1'}, 'text'], + value: '@@ -1,11 +1,5 @@\n hello\n- world\n', + }, + root, + keyMap, + ) + + applyPatchToYDoc( + { + type: 'insert', + path: [{_key: 'b1'}], + position: 'after', + items: [ + { + _key: 'b2', + _type: 'block', + style: 'normal', + markDefs: [], + children: [ + {_key: 's2', _type: 'span', text: ' world', marks: []}, + ], + }, + ], + }, + root, + keyMap, + ) + }) + + expect(root.length).toBe(2) + expect(getBlockText(keyMap.getYText('b1')!)).toBe('hello') + expect(getBlockText(keyMap.getYText('b2')!)).toBe(' world') + }) + + test('concurrent span split + text edit in same block', () => { + // Two connected docs + const yDoc1 = new Y.Doc() + const root1 = yDoc1.getXmlFragment('content') + const keyMap1 = createKeyMap() + + yDoc1.transact(() => { + const yBlock = blockToYText( + { + _key: 'b1', + _type: 'block', + style: 'normal', + markDefs: [], + children: [ + {_key: 's1', _type: 'span', text: 'hello world', marks: []}, + ], + }, + keyMap1, + ) + root1.insert(0, [yBlock]) + }) + + const yDoc2 = new Y.Doc() + const root2 = yDoc2.getXmlFragment('content') + const keyMap2 = createKeyMap() + Y.applyUpdate(yDoc2, Y.encodeStateAsUpdate(yDoc1)) + + // Populate keyMap2 + for (let i = 0; i < root2.length; i++) { + const child = root2.get(i) + if (child instanceof Y.XmlText) { + const key = child.getAttribute('_key') as string | undefined + if (key) keyMap2.set(key, child) + } + } + + // Client 1: bold "world" (span split) + yDoc1.transact(() => { + applyPatchToYDoc( + { + type: 'diffMatchPatch', + path: [{_key: 'b1'}, 'children', {_key: 's1'}, 'text'], + value: '@@ -3,9 +3,4 @@\n llo \n-world\n', + }, + root1, + keyMap1, + ) + applyPatchToYDoc( + { + type: 'insert', + path: [{_key: 'b1'}, 'children', {_key: 's1'}], + position: 'after', + items: [ + {_key: 's2', _type: 'span', text: 'world', marks: ['strong']}, + ], + }, + root1, + keyMap1, + ) + }, 'client1') + + // Client 2: append "!" to the text + yDoc2.transact(() => { + applyPatchToYDoc( + { + type: 'diffMatchPatch', + path: [{_key: 'b1'}, 'children', {_key: 's1'}, 'text'], + value: '@@ -6,5 +6,6 @@\n world\n+!\n', + }, + root2, + keyMap2, + ) + }, 'client2') + + // Sync + Y.applyUpdate(yDoc1, Y.encodeStateAsUpdate(yDoc2)) + Y.applyUpdate(yDoc2, Y.encodeStateAsUpdate(yDoc1)) + + // Both should converge — the exact text depends on Yjs merge order + // but both edits should be preserved + const text1 = getBlockText(keyMap1.getYText('b1')!) + const text2 = getBlockText(keyMap2.getYText('b1')!) + expect(text1).toBe(text2) + // Both "world" and "!" should be present somewhere + expect(text1).toContain('world') + }) +}) diff --git a/packages/plugin-yjs/src/__tests__/roundtrip.test.ts b/packages/plugin-yjs/src/__tests__/roundtrip.test.ts new file mode 100644 index 000000000..5aa3eae0c --- /dev/null +++ b/packages/plugin-yjs/src/__tests__/roundtrip.test.ts @@ -0,0 +1,444 @@ +import {describe, expect, test} from 'vitest' +import * as Y from 'yjs' +import {applyPatchToYDoc, blockToYText} from '../apply-to-ydoc' +import {createKeyMap} from '../key-map' +import type {KeyMap} from '../types' + +/** + * Helper: get plain text from a block's delta. + */ +function getBlockText(yBlock: Y.XmlText): string { + const delta = yBlock.toDelta() as Array<{insert: string | Y.XmlText}> + let text = '' + for (const entry of delta) { + if (typeof entry.insert === 'string') { + text += entry.insert + } + } + return text +} + +/** + * Helper: get block attributes as a plain object. + */ +function getBlockAttrs(yBlock: Y.XmlText): Record { + return yBlock.getAttributes() +} + +/** + * Helper: get span delta entries from a block. + */ +function getSpans( + yBlock: Y.XmlText, +): Array<{text: string; key: string; marks: string[]}> { + const delta = yBlock.toDelta() as Array<{ + insert: string + attributes?: Record + }> + return delta + .filter((d) => typeof d.insert === 'string') + .map((d) => ({ + text: d.insert as string, + key: (d.attributes?.['_key'] as string) ?? '', + marks: JSON.parse((d.attributes?.['marks'] as string) ?? '[]'), + })) +} + +/** + * Helper: create a Y.Doc with blocks and return root + keyMap. + */ +function createDoc(blocks: Array>): { + yDoc: Y.Doc + root: Y.XmlFragment + keyMap: KeyMap +} { + const yDoc = new Y.Doc() + const root = yDoc.getXmlFragment('content') + const keyMap = createKeyMap() + + yDoc.transact(() => { + for (const block of blocks) { + const yBlock = blockToYText(block, keyMap) + root.insert(root.length, [yBlock]) + } + }) + + return {yDoc, root, keyMap} +} + +/** + * Helper: sync two Y.Docs bidirectionally. + */ +function syncDocs(doc1: Y.Doc, doc2: Y.Doc): void { + Y.applyUpdate(doc1, Y.encodeStateAsUpdate(doc2)) + Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1)) +} + +/** + * Helper: create a connected pair of Y.Docs with the same initial state. + */ +function createConnectedDocs(blocks: Array>): { + doc1: Y.Doc + root1: Y.XmlFragment + keyMap1: KeyMap + doc2: Y.Doc + root2: Y.XmlFragment + keyMap2: KeyMap +} { + const {yDoc: doc1, root: root1, keyMap: keyMap1} = createDoc(blocks) + + const doc2 = new Y.Doc() + const root2 = doc2.getXmlFragment('content') + const keyMap2 = createKeyMap() + + // Sync initial state + Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1)) + + // Populate keyMap2 from synced doc + for (let i = 0; i < root2.length; i++) { + const child = root2.get(i) + if (child instanceof Y.XmlText) { + const key = child.getAttribute('_key') as string | undefined + if (key) { + keyMap2.set(key, child) + } + } + } + + return {doc1, root1, keyMap1, doc2, root2, keyMap2} +} + +describe('roundtrip: PT patch → Y.Doc → verify state', () => { + test('diffMatchPatch roundtrip: text insertion', () => { + const {root, keyMap} = createDoc([ + { + _key: 'b1', + _type: 'block', + style: 'normal', + markDefs: [], + children: [{_key: 's1', _type: 'span', text: 'hello', marks: []}], + }, + ]) + + applyPatchToYDoc( + { + type: 'diffMatchPatch', + path: [{_key: 'b1'}, 'children', {_key: 's1'}, 'text'], + value: '@@ -1,5 +1,11 @@\n hello\n+ world\n', + }, + root, + keyMap, + ) + + const block = keyMap.getYText('b1')! + expect(getBlockText(block)).toBe('hello world') + const spans = getSpans(block) + expect(spans).toHaveLength(1) + expect(spans[0].key).toBe('s1') + expect(spans[0].text).toBe('hello world') + }) + + test('set roundtrip: style change preserves text', () => { + const {root, keyMap} = createDoc([ + { + _key: 'b1', + _type: 'block', + style: 'normal', + markDefs: [], + children: [{_key: 's1', _type: 'span', text: 'hello', marks: []}], + }, + ]) + + applyPatchToYDoc( + {type: 'set', path: [{_key: 'b1'}, 'style'], value: 'h1'}, + root, + keyMap, + ) + + const block = keyMap.getYText('b1')! + expect(getBlockAttrs(block)['style']).toBe('h1') + expect(getBlockText(block)).toBe('hello') + }) + + test('insert + unset roundtrip: add then remove block', () => { + const {root, keyMap} = createDoc([ + { + _key: 'b1', + _type: 'block', + style: 'normal', + markDefs: [], + children: [{_key: 's1', _type: 'span', text: 'first', marks: []}], + }, + ]) + + // Insert a block + applyPatchToYDoc( + { + type: 'insert', + path: [{_key: 'b1'}], + position: 'after', + items: [ + { + _key: 'b2', + _type: 'block', + style: 'normal', + markDefs: [], + children: [{_key: 's2', _type: 'span', text: 'second', marks: []}], + }, + ], + }, + root, + keyMap, + ) + + expect(root.length).toBe(2) + + // Remove it + applyPatchToYDoc({type: 'unset', path: [{_key: 'b2'}]}, root, keyMap) + + expect(root.length).toBe(1) + expect(keyMap.getYText('b2')).toBeUndefined() + expect(getBlockText(keyMap.getYText('b1')!)).toBe('first') + }) + + test('marks roundtrip: bold then unbold', () => { + const {root, keyMap} = createDoc([ + { + _key: 'b1', + _type: 'block', + style: 'normal', + markDefs: [], + children: [{_key: 's1', _type: 'span', text: 'hello', marks: []}], + }, + ]) + + // Add bold + applyPatchToYDoc( + { + type: 'set', + path: [{_key: 'b1'}, 'children', {_key: 's1'}, 'marks'], + value: ['strong'], + }, + root, + keyMap, + ) + + let spans = getSpans(keyMap.getYText('b1')!) + expect(spans[0].marks).toEqual(['strong']) + + // Remove bold + applyPatchToYDoc( + { + type: 'set', + path: [{_key: 'b1'}, 'children', {_key: 's1'}, 'marks'], + value: [], + }, + root, + keyMap, + ) + + spans = getSpans(keyMap.getYText('b1')!) + expect(spans[0].marks).toEqual([]) + }) +}) + +describe('concurrent editing via Y.Doc sync', () => { + test('concurrent text edits in same block merge correctly', () => { + const {doc1, root1, keyMap1, doc2, keyMap2} = createConnectedDocs([ + { + _key: 'b1', + _type: 'block', + style: 'normal', + markDefs: [], + children: [{_key: 's1', _type: 'span', text: 'hello', marks: []}], + }, + ]) + + // Client 1: append " world" + doc1.transact(() => { + applyPatchToYDoc( + { + type: 'diffMatchPatch', + path: [{_key: 'b1'}, 'children', {_key: 's1'}, 'text'], + value: '@@ -1,5 +1,11 @@\n hello\n+ world\n', + }, + root1, + keyMap1, + ) + }, 'client1') + + // Client 2: prepend "dear " + const root2 = doc2.getXmlFragment('content') + doc2.transact(() => { + applyPatchToYDoc( + { + type: 'diffMatchPatch', + path: [{_key: 'b1'}, 'children', {_key: 's1'}, 'text'], + value: '@@ -1,5 +1,10 @@\n+dear \n hello\n', + }, + root2, + keyMap2, + ) + }, 'client2') + + // Before sync + expect(getBlockText(keyMap1.getYText('b1')!)).toBe('hello world') + expect(getBlockText(keyMap2.getYText('b1')!)).toBe('dear hello') + + // Sync + syncDocs(doc1, doc2) + + // Both should converge to the same text + const text1 = getBlockText(keyMap1.getYText('b1')!) + const text2 = getBlockText(keyMap2.getYText('b1')!) + expect(text1).toBe(text2) + // Yjs preserves both edits + expect(text1).toContain('dear') + expect(text1).toContain('hello') + expect(text1).toContain('world') + }) + + test('concurrent style changes: last write wins', () => { + const {doc1, root1, keyMap1, doc2, keyMap2} = createConnectedDocs([ + { + _key: 'b1', + _type: 'block', + style: 'normal', + markDefs: [], + children: [{_key: 's1', _type: 'span', text: 'hello', marks: []}], + }, + ]) + + // Client 1: change to h1 + doc1.transact(() => { + applyPatchToYDoc( + {type: 'set', path: [{_key: 'b1'}, 'style'], value: 'h1'}, + root1, + keyMap1, + ) + }, 'client1') + + // Client 2: change to h2 + const root2 = doc2.getXmlFragment('content') + doc2.transact(() => { + applyPatchToYDoc( + {type: 'set', path: [{_key: 'b1'}, 'style'], value: 'h2'}, + root2, + keyMap2, + ) + }, 'client2') + + // Sync + syncDocs(doc1, doc2) + + // Both should converge (one wins — Yjs uses last-writer-wins for attributes) + const style1 = keyMap1.getYText('b1')!.getAttribute('style') + const style2 = keyMap2.getYText('b1')!.getAttribute('style') + expect(style1).toBe(style2) + }) + + test('concurrent block insert and text edit', () => { + const {doc1, root1, keyMap1, doc2, keyMap2} = createConnectedDocs([ + { + _key: 'b1', + _type: 'block', + style: 'normal', + markDefs: [], + children: [{_key: 's1', _type: 'span', text: 'hello', marks: []}], + }, + ]) + + // Client 1: insert a new block + doc1.transact(() => { + applyPatchToYDoc( + { + type: 'insert', + path: [{_key: 'b1'}], + position: 'after', + items: [ + { + _key: 'b2', + _type: 'block', + style: 'normal', + markDefs: [], + children: [ + {_key: 's2', _type: 'span', text: 'new block', marks: []}, + ], + }, + ], + }, + root1, + keyMap1, + ) + }, 'client1') + + // Client 2: edit text in existing block + const root2 = doc2.getXmlFragment('content') + doc2.transact(() => { + applyPatchToYDoc( + { + type: 'diffMatchPatch', + path: [{_key: 'b1'}, 'children', {_key: 's1'}, 'text'], + value: '@@ -1,5 +1,11 @@\n hello\n+ world\n', + }, + root2, + keyMap2, + ) + }, 'client2') + + // Sync + syncDocs(doc1, doc2) + + // Both docs should have 2 blocks with the text edit preserved + expect(root1.length).toBe(2) + expect(root2.length).toBe(2) + expect(getBlockText(keyMap1.getYText('b1')!)).toBe('hello world') + }) + + test('concurrent block deletion and text edit', () => { + const {doc1, root1, keyMap1, doc2, keyMap2} = createConnectedDocs([ + { + _key: 'b1', + _type: 'block', + style: 'normal', + markDefs: [], + children: [{_key: 's1', _type: 'span', text: 'first', marks: []}], + }, + { + _key: 'b2', + _type: 'block', + style: 'normal', + markDefs: [], + children: [{_key: 's2', _type: 'span', text: 'second', marks: []}], + }, + ]) + + // Client 1: delete block b2 + doc1.transact(() => { + applyPatchToYDoc({type: 'unset', path: [{_key: 'b2'}]}, root1, keyMap1) + }, 'client1') + + // Client 2: edit text in block b1 + const root2 = doc2.getXmlFragment('content') + doc2.transact(() => { + applyPatchToYDoc( + { + type: 'diffMatchPatch', + path: [{_key: 'b1'}, 'children', {_key: 's1'}, 'text'], + value: '@@ -1,5 +1,14 @@\n first\n+ updated\n', + }, + root2, + keyMap2, + ) + }, 'client2') + + // Sync + syncDocs(doc1, doc2) + + // Both should have 1 block with the text edit + expect(root1.length).toBe(1) + expect(root2.length).toBe(1) + expect(getBlockText(keyMap1.getYText('b1')!)).toContain('first') + expect(getBlockText(keyMap1.getYText('b1')!)).toContain('updated') + }) +}) diff --git a/packages/plugin-yjs/src/__tests__/ydoc-to-patches.test.ts b/packages/plugin-yjs/src/__tests__/ydoc-to-patches.test.ts new file mode 100644 index 000000000..53f3c7c5c --- /dev/null +++ b/packages/plugin-yjs/src/__tests__/ydoc-to-patches.test.ts @@ -0,0 +1,322 @@ +import {describe, expect, test} from 'vitest' +import * as Y from 'yjs' +import {blockToYText} from '../apply-to-ydoc' +import {createKeyMap} from '../key-map' +import type {KeyMap} from '../types' +import {ydocToPatches} from '../ydoc-to-patches' + +/** + * Helper: create a Y.Doc with a single text block. + */ +function createDocWithBlock(opts: { + blockKey: string + spanKey: string + text: string + style?: string + marks?: string[] +}) { + const {blockKey, spanKey, text, style = 'normal', marks = []} = opts + const yDoc = new Y.Doc() + const root = yDoc.getXmlFragment('content') + const keyMap = createKeyMap() + + yDoc.transact(() => { + const yBlock = blockToYText( + { + _key: blockKey, + _type: 'block', + style, + markDefs: [], + children: [ + { + _key: spanKey, + _type: 'span', + text, + marks, + }, + ], + }, + keyMap, + ) + root.insert(0, [yBlock]) + }) + + const block = keyMap.getYText(blockKey)! + return {yDoc, root, block, keyMap} +} + +/** + * Helper: create a Y.Doc with two blocks. + */ +function createDocWithTwoBlocks() { + const yDoc = new Y.Doc() + const root = yDoc.getXmlFragment('content') + const keyMap = createKeyMap() + + yDoc.transact(() => { + const block1 = blockToYText( + { + _key: 'b1', + _type: 'block', + style: 'normal', + markDefs: [], + children: [{_key: 's1', _type: 'span', text: 'first', marks: []}], + }, + keyMap, + ) + const block2 = blockToYText( + { + _key: 'b2', + _type: 'block', + style: 'normal', + markDefs: [], + children: [{_key: 's2', _type: 'span', text: 'second', marks: []}], + }, + keyMap, + ) + root.insert(0, [block1, block2]) + }) + + return {yDoc, root, keyMap} +} + +/** + * Helper: collect patches from a Y.Doc mutation. + * Sets up observeDeep, runs the mutation with a remote origin, + * and returns the patches produced by ydocToPatches. + */ +function collectPatches(opts: { + root: Y.XmlFragment + keyMap: KeyMap + mutate: () => void +}) { + const {root, keyMap, mutate} = opts + const collectedPatches: Array = [] + + const handler = (events: Y.YEvent[], _transaction: Y.Transaction) => { + // In real usage, we'd skip local origin. Here we use a remote origin. + const patches = ydocToPatches(events, keyMap) + collectedPatches.push(...patches) + } + + root.observeDeep(handler) + + // Run mutation with a "remote" origin + const yDoc = root.doc! + yDoc.transact(() => { + mutate() + }, 'remote-editor') + + root.unobserveDeep(handler) + + return collectedPatches +} + +describe('ydocToPatches', () => { + describe('text changes → diffMatchPatch', () => { + test('text inserted at end of span produces diffMatchPatch', () => { + const {root, block, keyMap} = createDocWithBlock({ + blockKey: 'b1', + spanKey: 's1', + text: 'hello', + }) + + const patches = collectPatches({ + root, + keyMap, + mutate: () => { + // Insert " world" at position 5 with same span attributes + block.insert(5, ' world', { + _key: 's1', + _type: 'span', + marks: '[]', + }) + }, + }) + + expect(patches).toHaveLength(1) + expect(patches[0]).toMatchObject({ + type: 'diffMatchPatch', + path: [{_key: 'b1'}, 'children', {_key: 's1'}, 'text'], + }) + }) + + test('text inserted at beginning of span produces diffMatchPatch', () => { + const {root, block, keyMap} = createDocWithBlock({ + blockKey: 'b1', + spanKey: 's1', + text: 'world', + }) + + const patches = collectPatches({ + root, + keyMap, + mutate: () => { + block.insert(0, 'hello ', { + _key: 's1', + _type: 'span', + marks: '[]', + }) + }, + }) + + expect(patches).toHaveLength(1) + expect(patches[0]).toMatchObject({ + type: 'diffMatchPatch', + path: [{_key: 'b1'}, 'children', {_key: 's1'}, 'text'], + }) + }) + + test('text deleted from span produces diffMatchPatch', () => { + const {root, block, keyMap} = createDocWithBlock({ + blockKey: 'b1', + spanKey: 's1', + text: 'hello world', + }) + + const patches = collectPatches({ + root, + keyMap, + mutate: () => { + block.delete(5, 6) // delete " world" + }, + }) + + expect(patches).toHaveLength(1) + expect(patches[0]).toMatchObject({ + type: 'diffMatchPatch', + path: [{_key: 'b1'}, 'children', {_key: 's1'}, 'text'], + }) + }) + }) + + describe('block attribute changes → set patch', () => { + test('changing block style produces set patch', () => { + const {root, block, keyMap} = createDocWithBlock({ + blockKey: 'b1', + spanKey: 's1', + text: 'hello', + style: 'normal', + }) + + const patches = collectPatches({ + root, + keyMap, + mutate: () => { + block.setAttribute('style', 'h1') + }, + }) + + expect(patches).toHaveLength(1) + expect(patches[0]).toMatchObject({ + type: 'set', + path: [{_key: 'b1'}, 'style'], + value: 'h1', + }) + }) + + test('adding listItem produces set patch', () => { + const {root, block, keyMap} = createDocWithBlock({ + blockKey: 'b1', + spanKey: 's1', + text: 'hello', + }) + + const patches = collectPatches({ + root, + keyMap, + mutate: () => { + block.setAttribute('listItem', 'bullet') + }, + }) + + expect(patches).toHaveLength(1) + expect(patches[0]).toMatchObject({ + type: 'set', + path: [{_key: 'b1'}, 'listItem'], + value: 'bullet', + }) + }) + }) + + describe('span attribute changes → set patch', () => { + test('changing span marks produces set patch', () => { + const {root, block, keyMap} = createDocWithBlock({ + blockKey: 'b1', + spanKey: 's1', + text: 'hello', + marks: [], + }) + + const patches = collectPatches({ + root, + keyMap, + mutate: () => { + block.format(0, 5, {marks: '["strong"]'}) + }, + }) + + expect(patches).toHaveLength(1) + expect(patches[0]).toMatchObject({ + type: 'set', + path: [{_key: 'b1'}, 'children', {_key: 's1'}, 'marks'], + value: ['strong'], + }) + }) + }) + + describe('block structural changes', () => { + test('block deleted produces unset patch', () => { + const {root, keyMap} = createDocWithTwoBlocks() + + const patches = collectPatches({ + root, + keyMap, + mutate: () => { + root.delete(1, 1) // delete second block + }, + }) + + expect(patches).toHaveLength(1) + expect(patches[0]).toMatchObject({ + type: 'unset', + path: [{_key: 'b2'}], + }) + }) + + test('new block inserted produces insert patch', () => { + const {root, keyMap} = createDocWithBlock({ + blockKey: 'b1', + spanKey: 's1', + text: 'hello', + }) + + const patches = collectPatches({ + root, + keyMap, + mutate: () => { + const newBlock = blockToYText( + { + _key: 'b2', + _type: 'block', + style: 'normal', + markDefs: [], + children: [ + {_key: 's2', _type: 'span', text: 'new block', marks: []}, + ], + }, + keyMap, + ) + root.insert(1, [newBlock]) + }, + }) + + expect(patches).toHaveLength(1) + expect(patches[0]).toMatchObject({ + type: 'insert', + position: 'after', + path: [{_key: 'b1'}], + }) + }) + }) +}) diff --git a/packages/plugin-yjs/src/apply-to-ydoc.ts b/packages/plugin-yjs/src/apply-to-ydoc.ts new file mode 100644 index 000000000..f5af8a45b --- /dev/null +++ b/packages/plugin-yjs/src/apply-to-ydoc.ts @@ -0,0 +1,530 @@ +import type { + DiffMatchPatch, + InsertPatch, + Patch, + SetIfMissingPatch, + SetPatch, + UnsetPatch, +} from '@portabletext/patches' +import { + DIFF_DELETE, + DIFF_EQUAL, + DIFF_INSERT, + applyPatches as dmpApplyPatches, + makeDiff, + parsePatch, +} from '@sanity/diff-match-patch' +import * as Y from 'yjs' +import type {KeyMap} from './types' + +/** + * Convert a PT block to a Y.XmlText and register it in the KeyMap. + * + * Block structure in Y.Doc: + * - Y.XmlText with attributes: _key, _type, style, listItem, level, etc. + * - Delta content: spans as text inserts with attributes (_key, _type, marks) + * - markDefs stored as a JSON string attribute on the block + * + * @public + */ +export function blockToYText( + // biome-ignore lint/complexity/noBannedTypes: PT blocks are generic records + block: Record, + keyMap: KeyMap, +): Y.XmlText { + const yBlock = new Y.XmlText() + const blockKey = block['_key'] as string | undefined + + // Set block-level attributes + for (const [key, value] of Object.entries(block)) { + if (key === 'children' || key === 'markDefs') { + continue + } + yBlock.setAttribute(key, value) + } + + // Store markDefs as JSON attribute + if (Array.isArray(block['markDefs'])) { + yBlock.setAttribute('markDefs', JSON.stringify(block['markDefs'])) + } + + // Insert children as delta entries + const children = block['children'] as + | Array> + | undefined + if (Array.isArray(children)) { + let offset = 0 + for (const child of children) { + const text = (child['text'] as string) ?? '' + const attrs: Record = {} + for (const [key, value] of Object.entries(child)) { + if (key === 'text') { + continue + } + if (key === 'marks' && Array.isArray(value)) { + attrs[key] = JSON.stringify(value) + } else { + attrs[key] = value + } + } + yBlock.insert(offset, text, attrs) + offset += text.length + } + } + + if (blockKey) { + keyMap.set(blockKey, yBlock) + } + + return yBlock +} + +/** + * Resolve a block key from a patch path. + */ +function resolveBlockKey(path: unknown[]): string | undefined { + const first = path[0] + if (typeof first === 'object' && first !== null && '_key' in first) { + return (first as {_key: string})._key + } + return undefined +} + +/** + * Resolve a child (span) key from a patch path. + */ +function resolveChildKey(path: unknown[]): string | undefined { + if (path.length >= 3 && path[1] === 'children') { + const third = path[2] + if (typeof third === 'object' && third !== null && '_key' in third) { + return (third as {_key: string})._key + } + } + return undefined +} + +/** + * Find the offset range of a span within a block's Y.XmlText delta. + * Returns [startOffset, endOffset] or undefined if not found. + */ +function findSpanOffset( + yBlock: Y.XmlText, + spanKey: string, +): [number, number] | undefined { + const delta = yBlock.toDelta() as Array<{ + insert: string + attributes?: Record + }> + let offset = 0 + for (const entry of delta) { + const text = typeof entry.insert === 'string' ? entry.insert : '' + if (entry.attributes?.['_key'] === spanKey) { + return [offset, offset + text.length] + } + offset += text.length + } + return undefined +} + +/** + * Get the span attributes at a given offset in a block's delta. + */ +function getSpanAttrsAtOffset( + yBlock: Y.XmlText, + targetOffset: number, +): Record { + const delta = yBlock.toDelta() as Array<{ + insert: string + attributes?: Record + }> + let offset = 0 + for (const entry of delta) { + const text = typeof entry.insert === 'string' ? entry.insert : '' + if (offset + text.length > targetOffset || text.length === 0) { + return entry.attributes ?? {} + } + offset += text.length + } + return {} +} + +/** + * Find the index of a block in the root XmlFragment. + */ +function findBlockIndexInFragment( + root: Y.XmlFragment, + blockKey: string, +): number { + for (let i = 0; i < root.length; i++) { + const child = root.get(i) + if (child instanceof Y.XmlText && child.getAttribute('_key') === blockKey) { + return i + } + } + return -1 +} + +/** + * Apply a PT patch to the Y.Doc. + * + * @public + */ +export function applyPatchToYDoc( + patch: Patch, + root: Y.XmlFragment, + keyMap: KeyMap, +): void { + switch (patch.type) { + case 'diffMatchPatch': + applyDiffMatchPatch(patch, keyMap) + break + case 'set': + applySetPatch(patch, keyMap) + break + case 'unset': + applyUnsetPatch(patch, root, keyMap) + break + case 'insert': + applyInsertPatch(patch, root, keyMap) + break + case 'setIfMissing': + applySetIfMissingPatch(patch, keyMap) + break + } +} + +function applyDiffMatchPatch(patch: DiffMatchPatch, keyMap: KeyMap): void { + const blockKey = resolveBlockKey(patch.path) + const spanKey = resolveChildKey(patch.path) + if (!blockKey || !spanKey) { + return + } + + const yBlock = keyMap.getYText(blockKey) + if (!yBlock) { + return + } + + // Get current text for this span + const spanRange = findSpanOffset(yBlock, spanKey) + + if (!spanRange) { + // Span not in delta (empty span was optimized away by Yjs). + // Apply the DMP patch against empty string and insert the result. + const patches = parsePatch(patch.value) + const [newText] = dmpApplyPatches(patches, '', { + allowExceedingIndices: true, + }) + if (newText) { + // Insert at end of block with span attributes + const blockLength = yBlock + .toDelta() + .reduce( + (len: number, entry: {insert: string | Y.XmlText}) => + len + (typeof entry.insert === 'string' ? entry.insert.length : 1), + 0, + ) + yBlock.insert(blockLength, newText, { + _key: spanKey, + _type: 'span', + marks: '[]', + }) + } + return + } + + // Get text from delta (toString returns XML, not plain text) + const currentDelta = yBlock.toDelta() as Array<{ + insert: string + attributes?: Record + }> + let fullText = '' + for (const entry of currentDelta) { + if (typeof entry.insert === 'string') { + fullText += entry.insert + } + } + const currentText = fullText.slice(spanRange[0], spanRange[1]) + + // Read span attributes BEFORE modifying the Y.XmlText + const spanAttrs = getSpanAttrsAtOffset(yBlock, spanRange[0]) + + // Apply DMP patch to get new text + const patches = parsePatch(patch.value) + const [newText] = dmpApplyPatches(patches, currentText, { + allowExceedingIndices: true, + }) + + // Compute character-level diff and apply to Y.XmlText + const diffs = makeDiff(currentText, newText) + let offset = spanRange[0] + for (const [op, text] of diffs) { + if (op === DIFF_INSERT) { + yBlock.insert(offset, text, spanAttrs) + offset += text.length + } else if (op === DIFF_DELETE) { + yBlock.delete(offset, text.length) + } else if (op === DIFF_EQUAL) { + offset += text.length + } + } +} + +function applySetPatch(patch: SetPatch, keyMap: KeyMap): void { + const blockKey = resolveBlockKey(patch.path) + if (!blockKey) { + return + } + + const yBlock = keyMap.getYText(blockKey) + if (!yBlock) { + return + } + + if (patch.path.length === 1) { + // Full block replacement + const value = patch.value as Record + if (typeof value !== 'object' || value === null) { + return + } + + for (const [key, val] of Object.entries(value)) { + if (key === 'children' || key === 'markDefs') { + continue + } + yBlock.setAttribute(key, val) + } + + if (Array.isArray(value['markDefs'])) { + yBlock.setAttribute('markDefs', JSON.stringify(value['markDefs'])) + } + + if (Array.isArray(value['children'])) { + const currentLength = yBlock.toString().length + if (currentLength > 0) { + yBlock.delete(0, currentLength) + } + let offset = 0 + for (const child of value['children'] as Array>) { + const text = (child['text'] as string) ?? '' + const attrs: Record = {} + for (const [k, v] of Object.entries(child)) { + if (k === 'text') { + continue + } + if (k === 'marks' && Array.isArray(v)) { + attrs[k] = JSON.stringify(v) + } else { + attrs[k] = v + } + } + yBlock.insert(offset, text, attrs) + offset += text.length + } + } + return + } + + if (patch.path.length === 2) { + // Block property + const prop = patch.path[1] + if (typeof prop === 'string') { + if (prop === 'markDefs' && Array.isArray(patch.value)) { + yBlock.setAttribute(prop, JSON.stringify(patch.value)) + } else { + yBlock.setAttribute(prop, patch.value) + } + } + return + } + + // Span property + const spanKey = resolveChildKey(patch.path) + if (!spanKey) { + return + } + + const spanRange = findSpanOffset(yBlock, spanKey) + if (!spanRange) { + return + } + + if (patch.path.length === 4) { + const prop = patch.path[3] + if (typeof prop === 'string') { + if (prop === 'text' && typeof patch.value === 'string') { + const currentLength = spanRange[1] - spanRange[0] + if (currentLength > 0) { + yBlock.delete(spanRange[0], currentLength) + } + const attrs = getSpanAttrsAtOffset(yBlock, spanRange[0]) + yBlock.insert(spanRange[0], patch.value, attrs) + } else if (prop === 'marks') { + const value = Array.isArray(patch.value) + ? JSON.stringify(patch.value) + : patch.value + yBlock.format(spanRange[0], spanRange[1] - spanRange[0], { + [prop]: value, + }) + } else { + yBlock.format(spanRange[0], spanRange[1] - spanRange[0], { + [prop]: patch.value, + }) + } + } + } +} + +function applyUnsetPatch( + patch: UnsetPatch, + root: Y.XmlFragment, + keyMap: KeyMap, +): void { + const blockKey = resolveBlockKey(patch.path) + if (!blockKey) { + return + } + + if (patch.path.length === 1) { + // Remove block + const index = findBlockIndexInFragment(root, blockKey) + if (index !== -1) { + root.delete(index, 1) + } + keyMap.delete(blockKey) + return + } + + const yBlock = keyMap.getYText(blockKey) + if (!yBlock) { + return + } + + if (patch.path.length === 2) { + const prop = patch.path[1] + if (typeof prop === 'string') { + yBlock.removeAttribute(prop) + } + return + } + + // Remove span + const spanKey = resolveChildKey(patch.path) + if (!spanKey) { + return + } + + if (patch.path.length === 3) { + const spanRange = findSpanOffset(yBlock, spanKey) + if (spanRange) { + yBlock.delete(spanRange[0], spanRange[1] - spanRange[0]) + } + return + } + + if (patch.path.length === 4) { + const spanRange = findSpanOffset(yBlock, spanKey) + if (!spanRange) { + return + } + const prop = patch.path[3] + if (typeof prop === 'string') { + yBlock.format(spanRange[0], spanRange[1] - spanRange[0], { + [prop]: null, + }) + } + } +} + +function applyInsertPatch( + patch: InsertPatch, + root: Y.XmlFragment, + keyMap: KeyMap, +): void { + const blockKey = resolveBlockKey(patch.path) + + if (patch.path.length === 1 && blockKey) { + // Insert blocks before/after a reference block + const refIndex = findBlockIndexInFragment(root, blockKey) + if (refIndex === -1) { + return + } + + const insertIndex = patch.position === 'after' ? refIndex + 1 : refIndex + + for (let i = 0; i < patch.items.length; i++) { + const block = patch.items[i] as Record + const yBlock = blockToYText(block, keyMap) + root.insert(insertIndex + i, [yBlock]) + } + return + } + + // Insert spans into a block + if (patch.path.length >= 3 && blockKey) { + const yBlock = keyMap.getYText(blockKey) + if (!yBlock) { + return + } + + const spanKey = resolveChildKey(patch.path) + let insertOffset: number + + if (spanKey) { + const spanRange = findSpanOffset(yBlock, spanKey) + if (!spanRange) { + return + } + insertOffset = patch.position === 'after' ? spanRange[1] : spanRange[0] + } else { + insertOffset = yBlock.toString().length + } + + for (const item of patch.items) { + const child = item as Record + const text = (child['text'] as string) ?? '' + const attrs: Record = {} + for (const [key, value] of Object.entries(child)) { + if (key === 'text') { + continue + } + if (key === 'marks' && Array.isArray(value)) { + attrs[key] = JSON.stringify(value) + } else { + attrs[key] = value + } + } + yBlock.insert(insertOffset, text, attrs) + insertOffset += text.length + } + } +} + +function applySetIfMissingPatch( + patch: SetIfMissingPatch, + keyMap: KeyMap, +): void { + const blockKey = resolveBlockKey(patch.path) + if (!blockKey) { + return + } + + const yBlock = keyMap.getYText(blockKey) + if (!yBlock) { + return + } + + if (patch.path.length === 2) { + const prop = patch.path[1] + if (typeof prop === 'string') { + const existing = yBlock.getAttribute(prop) + if (existing === undefined) { + if (prop === 'markDefs' && Array.isArray(patch.value)) { + yBlock.setAttribute(prop, JSON.stringify(patch.value)) + } else { + yBlock.setAttribute(prop, patch.value) + } + } + } + } +} diff --git a/packages/plugin-yjs/src/index.ts b/packages/plugin-yjs/src/index.ts new file mode 100644 index 000000000..8d148293c --- /dev/null +++ b/packages/plugin-yjs/src/index.ts @@ -0,0 +1,5 @@ +export {createYjsPlugin} from './plugin' +export {applyPatchToYDoc, blockToYText} from './apply-to-ydoc' +export {createKeyMap} from './key-map' +export {ydocToPatches} from './ydoc-to-patches' +export type {KeyMap, YjsPluginConfig, YjsPluginInstance} from './types' diff --git a/packages/plugin-yjs/src/key-map.ts b/packages/plugin-yjs/src/key-map.ts new file mode 100644 index 000000000..a268d2c6c --- /dev/null +++ b/packages/plugin-yjs/src/key-map.ts @@ -0,0 +1,35 @@ +import type * as Y from 'yjs' +import type {KeyMap} from './types' + +/** + * Create a bidirectional lookup between PT `_key` and `Y.XmlText`. + * + * @public + */ +export function createKeyMap(): KeyMap { + const keyToYText = new Map() + const yTextToKey = new Map() + + return { + getYText(key: string): Y.XmlText | undefined { + return keyToYText.get(key) + }, + + getKey(yText: Y.XmlText): string | undefined { + return yTextToKey.get(yText) + }, + + set(key: string, yText: Y.XmlText): void { + keyToYText.set(key, yText) + yTextToKey.set(yText, key) + }, + + delete(key: string): void { + const yText = keyToYText.get(key) + if (yText) { + yTextToKey.delete(yText) + } + keyToYText.delete(key) + }, + } +} diff --git a/packages/plugin-yjs/src/plugin.ts b/packages/plugin-yjs/src/plugin.ts new file mode 100644 index 000000000..3d766305a --- /dev/null +++ b/packages/plugin-yjs/src/plugin.ts @@ -0,0 +1,207 @@ +import type {Patch} from '@portabletext/patches' +import type {PortableTextBlock} from '@portabletext/schema' +import * as Y from 'yjs' +import {applyPatchToYDoc, blockToYText} from './apply-to-ydoc' +import {createKeyMap} from './key-map' +import type {YjsPluginConfig, YjsPluginInstance} from './types' +import {ydocToPatches} from './ydoc-to-patches' + +/** + * Create a Yjs plugin for the Portable Text Editor. + * + * Uses Y.XmlFragment as the root container with Y.XmlText blocks: + * - Root XmlFragment contains blocks as Y.XmlText children + * - Each block's Y.XmlText has attributes (_key, _type, style, etc.) + * and delta content (spans as text inserts with _key, marks attributes) + * - Text edits use Y.Text character-level CRDT merging + * + * @public + */ +export function createYjsPlugin(config: YjsPluginConfig): YjsPluginInstance { + const {editor, yDoc, localOrigin = 'local'} = config + const root = yDoc.getXmlFragment('content') + const keyMap = createKeyMap() + + let isApplyingRemote = false + let subscriptions: Array<() => void> = [] + + /** + * Sync editor value to Y.Doc on connect. + * Only runs if Y.Doc is empty (first editor to connect wins). + */ + function syncInitialState(): void { + const snapshot = editor.getSnapshot().context.value + if (!snapshot || snapshot.length === 0) { + return + } + + // If Y.Doc already has content, populate keyMap from it + if (root.length > 0) { + for (let i = 0; i < root.length; i++) { + const child = root.get(i) + if (child instanceof Y.XmlText) { + const key = child.getAttribute('_key') as string | undefined + if (key) { + keyMap.set(key, child) + } + } + } + return + } + + // Y.Doc is empty — populate from editor value + yDoc.transact(() => { + for (const block of snapshot) { + const yBlock = blockToYText(block as Record, keyMap) + root.insert(root.length, [yBlock]) + } + }, localOrigin) + } + + function connect(): void { + syncInitialState() + + // 1. Local mutations → Y.Doc + const mutationSub = editor.on('mutation', (event) => { + if (isApplyingRemote) { + return + } + + const localPatches = event.patches.filter((p) => p.origin === 'local') + if (localPatches.length === 0) { + return + } + + yDoc.transact(() => { + for (const patch of localPatches) { + applyPatchToYDoc(patch, root, keyMap) + } + }, localOrigin) + }) + + // 2. Y.Doc changes → editor (granular PT patches) + const handleYjsEvents = ( + events: Y.YEvent[], + transaction: Y.Transaction, + ) => { + if (transaction.origin === localOrigin) { + return + } + + const patches = ydocToPatches(events, keyMap) + if (patches.length === 0) { + return + } + + const snapshot = rootToSnapshot(root) as + | Array + | undefined + + isApplyingRemote = true + try { + editor.send({ + type: 'patches', + patches: patches.map((p) => ({ + ...p, + origin: 'remote' as const, + })) as Array, + snapshot, + }) + } finally { + isApplyingRemote = false + } + } + + root.observeDeep(handleYjsEvents) + + subscriptions = [ + () => mutationSub.unsubscribe(), + () => root.unobserveDeep(handleYjsEvents), + ] + } + + function disconnect(): void { + for (const unsub of subscriptions) { + unsub() + } + subscriptions = [] + } + + return { + connect, + disconnect, + yDoc, + sharedRoot: root, + keyMap, + } +} + +/** + * Convert Y.Doc root to a PT snapshot. + * Temporary fallback until granular ydocToPatches is implemented. + */ +function rootToSnapshot( + root: Y.XmlFragment, +): Array> | undefined { + const blocks: Array> = [] + + for (let i = 0; i < root.length; i++) { + const child = root.get(i) + if (child instanceof Y.XmlText) { + blocks.push(yBlockToObject(child)) + } + } + + return blocks.length > 0 ? blocks : undefined +} + +function yBlockToObject(yBlock: Y.XmlText): Record { + const block: Record = {} + + const attrs = yBlock.getAttributes() + for (const [key, value] of Object.entries(attrs)) { + if (key === 'markDefs') { + try { + block[key] = JSON.parse(value as string) + } catch { + block[key] = [] + } + } else { + block[key] = value + } + } + + const blockDelta = yBlock.toDelta() as Array<{ + insert: string | Y.XmlText + attributes?: Record + }> + + const children: Array> = [] + for (const entry of blockDelta) { + if (typeof entry.insert === 'string') { + const child: Record = {text: entry.insert} + if (entry.attributes) { + for (const [key, value] of Object.entries(entry.attributes)) { + if (key === 'marks') { + try { + child[key] = JSON.parse(value as string) + } catch { + child[key] = [] + } + } else { + child[key] = value + } + } + } + children.push(child) + } + } + + block['children'] = children.length > 0 ? children : [{text: ''}] + + if (!block['markDefs']) { + block['markDefs'] = [] + } + + return block +} diff --git a/packages/plugin-yjs/src/types.ts b/packages/plugin-yjs/src/types.ts new file mode 100644 index 000000000..7e7532cd9 --- /dev/null +++ b/packages/plugin-yjs/src/types.ts @@ -0,0 +1,57 @@ +import type {Patch} from '@portabletext/patches' +import type {PortableTextBlock} from '@portabletext/schema' +import type * as Y from 'yjs' + +/** + * Bidirectional lookup between PT `_key` and `Y.XmlText`. + * + * Both directions need this: + * - PT patch → Y.Doc: find Y.XmlText by block `_key` + * - Y.Doc → PT patches: find `_key` from Y.XmlText reference + * + * Only tracks blocks (not spans). Spans are delta entries in + * their parent block's Y.XmlText and are resolved by offset. + * + * @public + */ +export interface KeyMap { + getYText(key: string): Y.XmlText | undefined + getKey(yText: Y.XmlText): string | undefined + set(key: string, yText: Y.XmlText): void + delete(key: string): void +} + +/** + * @public + */ +export interface YjsPluginConfig { + editor: { + on: ( + event: 'mutation', + listener: (event: { + type: 'mutation' + patches: Array + value: Array | undefined + }) => void, + ) => {unsubscribe: () => void} + send: (event: { + type: 'patches' + patches: Array + snapshot: Array | undefined + }) => void + getSnapshot: () => {context: {value: Array | undefined}} + } + yDoc: Y.Doc + localOrigin?: unknown +} + +/** + * @public + */ +export interface YjsPluginInstance { + connect: () => void + disconnect: () => void + yDoc: Y.Doc + sharedRoot: Y.XmlFragment + keyMap: KeyMap +} diff --git a/packages/plugin-yjs/src/ydoc-to-patches.ts b/packages/plugin-yjs/src/ydoc-to-patches.ts new file mode 100644 index 000000000..a8654f7a2 --- /dev/null +++ b/packages/plugin-yjs/src/ydoc-to-patches.ts @@ -0,0 +1,430 @@ +import {makePatches, stringifyPatches} from '@sanity/diff-match-patch' +import * as Y from 'yjs' +import type {KeyMap} from './types' + +interface DiffMatchPatchResult { + type: 'diffMatchPatch' + path: Array<{_key: string} | string> + value: string +} + +interface SetPatchResult { + type: 'set' + path: Array<{_key: string} | string> + value: unknown +} + +interface UnsetPatchResult { + type: 'unset' + path: Array<{_key: string} | string> +} + +interface InsertPatchResult { + type: 'insert' + path: Array<{_key: string} | string> + position: 'before' | 'after' + items: Array> +} + +type PatchResult = + | DiffMatchPatchResult + | SetPatchResult + | UnsetPatchResult + | InsertPatchResult + +/** + * Translate Y.Doc observeDeep events into PT patches. + * + * Called from the observeDeep handler when remote changes arrive. + * Produces PT patches that can be fed into the editor via + * `editor.send({ type: 'patches', patches, snapshot })`. + * + * @param events - Y.YEvent array from observeDeep + * @param keyMap - Bidirectional _key ↔ Y.XmlText lookup + * @returns Array of PT patches + * + * @public + */ +export function ydocToPatches( + events: Y.YEvent[], + keyMap: KeyMap, +): PatchResult[] { + const patches: PatchResult[] = [] + + for (const event of events) { + // Handle events on Y.XmlText (blocks) + if (event.target instanceof Y.XmlText) { + const blockKey = keyMap.getKey(event.target) + if (blockKey) { + // This is a known block — handle attribute and delta changes + patches.push(...handleBlockEvent(event, event.target, blockKey, keyMap)) + } + } + + // Handle events on Y.XmlFragment (root — block insertions/deletions) + if (event.target instanceof Y.XmlFragment) { + patches.push(...handleRootEvent(event, keyMap)) + } + } + + return patches +} + +/** + * Handle events on a block's Y.XmlText. + * Produces set/unset patches for attribute changes, + * and diffMatchPatch patches for text edits. + */ +function handleBlockEvent( + event: Y.YEvent, + target: Y.XmlText, + blockKey: string, + _keyMap: KeyMap, +): PatchResult[] { + const patches: PatchResult[] = [] + + // 1. Attribute changes → set/unset patches + if (event.keys && event.keys.size > 0) { + for (const [key, change] of event.keys) { + if (change.action === 'delete') { + patches.push({ + type: 'unset', + path: [{_key: blockKey}, key], + }) + } else { + // 'add' or 'update' + const rawValue = target.getAttribute(key) + const value = deserializeAttribute(key, rawValue) + patches.push({ + type: 'set', + path: [{_key: blockKey}, key], + value, + }) + } + } + } + + // 2. Delta changes → diffMatchPatch or set patches for span attributes + if (event.delta && event.delta.length > 0) { + patches.push(...handleBlockDelta(event, target, blockKey)) + } + + return patches +} + +/** + * Handle delta changes within a block. + * Text inserts/deletes → diffMatchPatch. + * Format changes (retain with attributes) → set on span marks. + */ +function handleBlockDelta( + event: Y.YEvent, + target: Y.XmlText, + blockKey: string, +): PatchResult[] { + const patches: PatchResult[] = [] + + // Get the CURRENT delta (after the change) to understand span layout + const currentDelta = target.toDelta() as Array<{ + insert: string | Y.XmlText + attributes?: Record + }> + + // Build span info from current delta + const spans: Array<{ + key: string + text: string + start: number + length: number + }> = [] + let offset = 0 + for (const entry of currentDelta) { + if (typeof entry.insert === 'string') { + const spanKey = entry.attributes?.['_key'] + if (spanKey) { + spans.push({ + key: spanKey, + text: entry.insert, + start: offset, + length: entry.insert.length, + }) + } + offset += entry.insert.length + } else { + // Inline element — skip for now + offset += 1 + } + } + + // Walk the event delta to determine what changed + const delta = event.delta as Array< + | {retain: number; attributes?: Record} + | {insert: string | Y.XmlText; attributes?: Record} + | {delete: number} + > + + // Track affected spans: we need to reconstruct old text to generate DMP + // Strategy: walk the delta and for each insert/delete, find the affected span + // and compute what the old text was + let currentPos = 0 + + for (const op of delta) { + if ('retain' in op) { + // Check for format changes (retain with attributes = span mark change) + if (op.attributes) { + const span = findSpanAtOffset(spans, currentPos) + if (span && op.attributes['marks'] !== undefined) { + const marksValue = deserializeAttribute( + 'marks', + op.attributes['marks'], + ) + patches.push({ + type: 'set', + path: [{_key: blockKey}, 'children', {_key: span.key}, 'marks'], + value: marksValue, + }) + } + } + currentPos += op.retain + } else if ('insert' in op) { + if (typeof op.insert === 'string') { + // Text was inserted — find which span it belongs to + const spanKey = op.attributes?.['_key'] + if (spanKey) { + const span = spans.find((s) => s.key === spanKey) + if (span) { + // Compute old text: current text minus the inserted portion + const insertOffsetInSpan = currentPos - span.start + const oldText = + span.text.slice(0, insertOffsetInSpan) + + span.text.slice(insertOffsetInSpan + op.insert.length) + + const dmpPatches = makePatches(oldText, span.text) + const dmpString = stringifyPatches(dmpPatches) + + patches.push({ + type: 'diffMatchPatch', + path: [{_key: blockKey}, 'children', {_key: spanKey}, 'text'], + value: dmpString, + }) + } + } + currentPos += op.insert.length + } else { + // Y.XmlText inserted — inline element, skip for now + currentPos += 1 + } + } else if ('delete' in op) { + // Text was deleted — find which span was affected + // The deleted text is at currentPos in the OLD document + // We need to find which span contained that position BEFORE the delete + // Since the current delta is AFTER the delete, we need to account for + // the fact that the span's text is now shorter + const span = findSpanAtOffset(spans, currentPos) + if (span) { + // The span's current text is the text AFTER deletion + // Old text = current text with the deleted chars re-inserted at currentPos + // But we don't know what was deleted... we can reconstruct from the event + const deletedContent = getDeletedTextFromEvent(event, currentPos) + const insertOffsetInSpan = currentPos - span.start + const oldText = + span.text.slice(0, insertOffsetInSpan) + + deletedContent + + span.text.slice(insertOffsetInSpan) + + const dmpPatches = makePatches(oldText, span.text) + const dmpString = stringifyPatches(dmpPatches) + + patches.push({ + type: 'diffMatchPatch', + path: [{_key: blockKey}, 'children', {_key: span.key}, 'text'], + value: dmpString, + }) + } + // Don't advance currentPos — deleted content is gone + } + } + + return patches +} + +/** + * Handle events on the root Y.XmlFragment. + * Block insertions → insert patches. + * Block deletions → unset patches. + */ +function handleRootEvent(event: Y.YEvent, keyMap: KeyMap): PatchResult[] { + const patches: PatchResult[] = [] + const root = event.target as Y.XmlFragment + + const delta = event.delta as Array< + {retain: number} | {insert: Array} | {delete: number} + > + + let blockIndex = 0 + + for (const op of delta) { + if ('retain' in op) { + blockIndex += op.retain + } else if ('insert' in op) { + if (Array.isArray(op.insert)) { + for (const yText of op.insert) { + if (yText instanceof Y.XmlText) { + const newBlockKey = yText.getAttribute('_key') as string | undefined + if (newBlockKey) { + // Register in keyMap + keyMap.set(newBlockKey, yText) + + // Build the block value + const blockValue = yTextToBlock(yText) + + // Find the previous block for relative positioning + const patch: InsertPatchResult = { + type: 'insert', + path: [{_key: newBlockKey}], + position: 'before', + items: [blockValue], + } + + if (blockIndex > 0) { + // Find the block before this position + const prevBlock = root.get(blockIndex - 1) + if (prevBlock instanceof Y.XmlText) { + const prevKey = prevBlock.getAttribute('_key') + if (prevKey) { + patch.path = [{_key: prevKey}] + patch.position = 'after' + } + } + } + + patches.push(patch) + } + } + blockIndex++ + } + } + } else if ('delete' in op) { + // Find deleted blocks from event changes + for (const item of event.changes.deleted) { + if (item.content.constructor.name === 'ContentType') { + const deletedType = (item.content as any).type + if (deletedType instanceof Y.XmlText) { + const deletedKey = keyMap.getKey(deletedType) + if (deletedKey) { + patches.push({ + type: 'unset', + path: [{_key: deletedKey}], + }) + keyMap.delete(deletedKey) + } + } + } + } + blockIndex += op.delete + } + } + + return patches +} + +/** + * Find which span contains the given character offset. + */ +function findSpanAtOffset( + spans: Array<{key: string; text: string; start: number; length: number}>, + offset: number, +): {key: string; text: string; start: number; length: number} | undefined { + for (const span of spans) { + if (offset >= span.start && offset < span.start + span.length) { + return span + } + } + // If offset is at the end of the last span, return the last span + if (spans.length > 0) { + const last = spans[spans.length - 1]! + if (offset === last.start + last.length) { + return last + } + } + return undefined +} + +/** + * Try to extract deleted text content from a Y.YTextEvent. + */ +function getDeletedTextFromEvent( + event: Y.YEvent, + _offset: number, +): string { + // Walk the deleted items to reconstruct the deleted text + let deletedText = '' + for (const item of event.changes.deleted) { + if (item.content.constructor.name === 'ContentString') { + deletedText += (item.content as any).str + } + } + return deletedText +} + +/** + * Deserialize a Y.XmlText attribute value. + * Some attributes are stored as JSON strings (markDefs, marks, level). + */ +function deserializeAttribute(key: string, value: unknown): unknown { + if (typeof value !== 'string') { + return value + } + + // Known JSON-encoded attributes + if (key === 'markDefs' || key === 'marks') { + try { + return JSON.parse(value) + } catch { + return value + } + } + + // level is stored as string but should be a number + if (key === 'level') { + const num = Number(value) + if (!Number.isNaN(num)) { + return num + } + } + + return value +} + +/** + * Convert a Y.XmlText block to a PT block object. + */ +function yTextToBlock(yText: Y.XmlText): Record { + const block: Record = {} + + const attrs = yText.getAttributes() + for (const [key, value] of Object.entries(attrs)) { + block[key] = deserializeAttribute(key, value) + } + + const delta = yText.toDelta() as Array<{ + insert: string | Y.XmlText + attributes?: Record + }> + + const children: Array> = [] + for (const entry of delta) { + if (typeof entry.insert === 'string') { + const child: Record = {text: entry.insert} + if (entry.attributes) { + for (const [key, value] of Object.entries(entry.attributes)) { + child[key] = deserializeAttribute(key, value) + } + } + children.push(child) + } + } + + block['children'] = children.length > 0 ? children : [{text: ''}] + return block +} diff --git a/packages/plugin-yjs/tsconfig.json b/packages/plugin-yjs/tsconfig.json new file mode 100644 index 000000000..d2a8a219a --- /dev/null +++ b/packages/plugin-yjs/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.settings", + "include": ["**/*.ts"], + "exclude": ["dist", "./node_modules", "**/__tests__/**"] +} diff --git a/packages/plugin-yjs/tsconfig.settings.json b/packages/plugin-yjs/tsconfig.settings.json new file mode 100644 index 000000000..37a34a190 --- /dev/null +++ b/packages/plugin-yjs/tsconfig.settings.json @@ -0,0 +1,10 @@ +{ + "extends": "@sanity/tsconfig/strictest", + "compilerOptions": { + "rootDir": ".", + "outDir": "./dist", + "skipLibCheck": true, + "lib": ["ES2022"], + "target": "ES2022" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5956288ec..793ce84e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -231,6 +231,9 @@ importers: '@portabletext/plugin-typography': specifier: workspace:* version: link:../../packages/plugin-typography + '@portabletext/plugin-yjs': + specifier: workspace:* + version: link:../../packages/plugin-yjs '@portabletext/react': specifier: ^6.0.2 version: 6.0.2(react@19.2.3) @@ -270,6 +273,9 @@ importers: xstate: specifier: ^5.25.0 version: 5.25.0 + yjs: + specifier: ^13.6.29 + version: 13.6.29 zod: specifier: ^4.1.12 version: 4.1.13 @@ -1100,6 +1106,37 @@ importers: specifier: ^4.0.18 version: 4.0.18(@types/node@20.19.25)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.2.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + packages/plugin-yjs: + dependencies: + '@sanity/diff-match-patch': + specifier: ^3.2.0 + version: 3.2.0 + yjs: + specifier: ^13.0.0 + version: 13.6.29 + devDependencies: + '@portabletext/editor': + specifier: workspace:* + version: link:../editor + '@portabletext/patches': + specifier: workspace:* + version: link:../patches + '@portabletext/schema': + specifier: workspace:^ + version: link:../schema + '@sanity/pkg-utils': + specifier: ^7.2.3 + version: 7.11.9(@types/babel__core@7.20.5)(@types/node@20.19.25)(babel-plugin-react-compiler@1.0.0)(typescript@5.9.3) + '@sanity/tsconfig': + specifier: ^2.1.0 + version: 2.1.0 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: ^3.2.3 + version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.25)(jiti@2.6.1)(jsdom@27.2.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + packages/racejar: dependencies: '@cucumber/cucumber-expressions': @@ -2553,16 +2590,29 @@ packages: '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + '@microsoft/api-extractor-model@7.30.7': + resolution: {integrity: sha512-TBbmSI2/BHpfR9YhQA7nH0nqVmGgJ0xH0Ex4D99/qBDAUpnhA2oikGmdXanbw9AWWY/ExBYIpkmY8dBHdla3YQ==} + '@microsoft/api-extractor-model@7.32.2': resolution: {integrity: sha512-Ussc25rAalc+4JJs9HNQE7TuO9y6jpYQX9nWD1DhqUzYPBr3Lr7O9intf+ZY8kD5HnIqeIRJX7ccCT0QyBy2Ww==} + '@microsoft/api-extractor@7.52.10': + resolution: {integrity: sha512-LhKytJM5ZJkbHQVfW/3o747rZUNs/MGg6j/wt/9qwwqEOfvUDTYXXxIBuMgrRXhJ528p41iyz4zjBVHZU74Odg==} + hasBin: true + '@microsoft/api-extractor@7.55.2': resolution: {integrity: sha512-1jlWO4qmgqYoVUcyh+oXYRztZde/pAi7cSVzBz/rc+S7CoVzDasy8QE13dx6sLG4VRo8SfkkLbFORR6tBw4uGQ==} hasBin: true + '@microsoft/tsdoc-config@0.17.1': + resolution: {integrity: sha512-UtjIFe0C6oYgTnad4q1QP4qXwLhe6tIpNTRStJ2RZEPIkqQPREAwE5spzVxsdn9UaEMUqhh0AqSx3X4nWAKXWw==} + '@microsoft/tsdoc-config@0.18.0': resolution: {integrity: sha512-8N/vClYyfOH+l4fLkkr9+myAoR6M7akc8ntBJ4DJdWH2b09uVfr71+LTMpNyG19fNqWDg8KEDZhx5wxuqHyGjw==} + '@microsoft/tsdoc@0.15.1': + resolution: {integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==} + '@microsoft/tsdoc@0.16.0': resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} @@ -2581,12 +2631,22 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@optimize-lodash/rollup-plugin@5.0.2': + resolution: {integrity: sha512-UWBD9/C5jO0rDAbiqrZqiTLPD0LOHG3DzBo8ubLTpNWY9xOz5f5+S2yuxG/7ICk8sx8K6pZ8O/jsAbFgjtfh6w==} + engines: {node: '>= 18'} + peerDependencies: + rollup: '>= 4.x' + '@optimize-lodash/rollup-plugin@6.0.0': resolution: {integrity: sha512-zTNVDRHGcezQCT2UVK7CSqJi7YKyM1YTJPvCkbE3NecHCsI/mdlizXGqcXgoshzyemsfscY7Tf8MmGnor2HAFg==} engines: {node: '>= 18'} peerDependencies: rollup: '>= 4.x' + '@optimize-lodash/transform@3.0.6': + resolution: {integrity: sha512-9+qMSaDpahC0+vX2ChM46/ls6a5Ankqs6RTLrHSaFpm7o1mFanP82e+jm9/0o5D660ueK8dWJGPCXQrBxBNNWA==} + engines: {node: '>= 12'} + '@optimize-lodash/transform@4.0.0': resolution: {integrity: sha512-/v61xAfj3e3g14UDP9qriYgkifLuSwALrhcSiY3SfuzzDJ5pRBsfp//Ih3daJlUMrODvB6IUk7dGfxgnRwcxjg==} engines: {node: '>= 12'} @@ -2594,9 +2654,16 @@ packages: '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + '@oxc-project/runtime@0.81.0': + resolution: {integrity: sha512-zm/LDVOq9FEmHiuM8zO4DWirv0VP2Tv2VsgaiHby9nvpq+FVrcqNYgv+TysLKOITQXWZj/roluTxFvpkHP0Iuw==} + engines: {node: '>=6.9.0'} + '@oxc-project/types@0.102.0': resolution: {integrity: sha512-8Skrw405g+/UJPKWJ1twIk3BIH2nXdiVlVNtYT23AXVwpsd79es4K+KYt06Fbnkc5BaTvk/COT2JuCLYdwnCdA==} + '@oxc-project/types@0.81.0': + resolution: {integrity: sha512-CnOqkybZK8z6Gx7Wb1qF7AEnSzbol1WwcIzxYOr8e91LytGOjo0wCpgoYWZo8sdbpqX+X+TJayIzo4Pv0R/KjA==} + '@oxc-project/types@0.99.0': resolution: {integrity: sha512-LLDEhXB7g1m5J+woRSgfKsFPS3LhR9xRhTeIoEBm5WrkwMxn6eZ0Ld0c0K5eHB57ChZX6I3uSmmLjZ8pcjlRcw==} @@ -3600,6 +3667,11 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + '@rolldown/binding-android-arm64@1.0.0-beta.32': + resolution: {integrity: sha512-Gs+313LfR4Ka3hvifdag9r44WrdKQaohya7ZXUXzARF7yx0atzFlVZjsvxtKAw1Vmtr4hB/RjUD1jf73SW7zDw==} + cpu: [arm64] + os: [android] + '@rolldown/binding-android-arm64@1.0.0-beta.52': resolution: {integrity: sha512-MBGIgysimZPqTDcLXI+i9VveijkP5C3EAncEogXhqfax6YXj1Tr2LY3DVuEOMIjWfMPMhtQSPup4fSTAmgjqIw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3612,6 +3684,11 @@ packages: cpu: [arm64] os: [android] + '@rolldown/binding-darwin-arm64@1.0.0-beta.32': + resolution: {integrity: sha512-W8oMqzGcI7wKPXUtS3WJNXzbghHfNiuM1UBAGpVb+XlUCgYRQJd2PRGP7D3WGql3rR3QEhUvSyAuCBAftPQw6Q==} + cpu: [arm64] + os: [darwin] + '@rolldown/binding-darwin-arm64@1.0.0-beta.52': resolution: {integrity: sha512-MmKeoLnKu1d9j6r19K8B+prJnIZ7u+zQ+zGQ3YHXGnr41rzE3eqQLovlkvoZnRoxDGPA4ps0pGiwXy6YE3lJyg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3624,6 +3701,11 @@ packages: cpu: [arm64] os: [darwin] + '@rolldown/binding-darwin-x64@1.0.0-beta.32': + resolution: {integrity: sha512-pM4c4sKUk37noJrnnDkJknLhCsfZu7aWyfe67bD0GQHfzAPjV16wPeD9CmQg4/0vv+5IfHYaa4VE536xbA+W0Q==} + cpu: [x64] + os: [darwin] + '@rolldown/binding-darwin-x64@1.0.0-beta.52': resolution: {integrity: sha512-qpHedvQBmIjT8zdnjN3nWPR2qjQyJttbXniCEKKdHeAbZG9HyNPBUzQF7AZZGwmS9coQKL+hWg9FhWzh2dZ2IA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3636,6 +3718,11 @@ packages: cpu: [x64] os: [darwin] + '@rolldown/binding-freebsd-x64@1.0.0-beta.32': + resolution: {integrity: sha512-M8SUgFlYb5kJJWcFC8gUMRiX4WLFxPKMed3SJ2YrxontgIrEcpizPU8nLNVsRYEStoSfKHKExpQw3OP6fm+5bw==} + cpu: [x64] + os: [freebsd] + '@rolldown/binding-freebsd-x64@1.0.0-beta.52': resolution: {integrity: sha512-dDp7WbPapj/NVW0LSiH/CLwMhmLwwKb3R7mh2kWX+QW85X1DGVnIEyKh9PmNJjB/+suG1dJygdtdNPVXK1hylg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3648,6 +3735,11 @@ packages: cpu: [x64] os: [freebsd] + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.32': + resolution: {integrity: sha512-FuQpbNC/hE//bvv29PFnk0AtpJzdPdYl5CMhlWPovd9g3Kc3lw9TrEPIbL7gRPUdhKAiq6rVaaGvOnXxsa0eww==} + cpu: [arm] + os: [linux] + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.52': resolution: {integrity: sha512-9e4l6vy5qNSliDPqNfR6CkBOAx6PH7iDV4OJiEJzajajGrVy8gc/IKKJUsoE52G8ud8MX6r3PMl97NfwgOzB7g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3660,6 +3752,12 @@ packages: cpu: [arm] os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.32': + resolution: {integrity: sha512-hRZygRlaGCjcNTNY9GV7dDI18sG1dK3cc7ujHq72LoDad23zFDUGMQjiSxHWK+/r92iMV+j2MiHbvzayxqynsg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.52': resolution: {integrity: sha512-V48oDR84feRU2KRuzpALp594Uqlx27+zFsT6+BgTcXOtu7dWy350J1G28ydoCwKB+oxwsRPx2e7aeQnmd3YJbQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3674,6 +3772,12 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.32': + resolution: {integrity: sha512-HzgT6h+CXLs+GKAU0Wvkt3rvcv0CmDBsDjlPhh4GHysOKbG9NjpKYX2zvjx671E9pGbTvcPpwy7gGsy7xpu+8g==} + cpu: [arm64] + os: [linux] + libc: [musl] + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.52': resolution: {integrity: sha512-ENLmSQCWqSA/+YN45V2FqTIemg7QspaiTjlm327eUAMeOLdqmSOVVyrQexJGNTQ5M8sDYCgVAig2Kk01Ggmqaw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3688,6 +3792,12 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.32': + resolution: {integrity: sha512-Ab/wbf6gdzphDbsg51UaxsC93foQ7wxhtg0SVCXd25BrV4MAJ1HoDtKN/f4h0maFmJobkqYub2DlmoasUzkvBg==} + cpu: [x64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.52': resolution: {integrity: sha512-klahlb2EIFltSUubn/VLjuc3qxp1E7th8ukayPfdkcKvvYcQ5rJztgx8JsJSuAKVzKtNTqUGOhy4On71BuyV8g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3702,6 +3812,12 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-x64-musl@1.0.0-beta.32': + resolution: {integrity: sha512-VoxqGEfh5A1Yx+zBp/FR5QwAbtzbuvky2SVc+ii4g1gLD4zww6mt/hPi5zG+b88zYPFBKHpxMtsz9cWqXU5V5Q==} + cpu: [x64] + os: [linux] + libc: [musl] + '@rolldown/binding-linux-x64-musl@1.0.0-beta.52': resolution: {integrity: sha512-UuA+JqQIgqtkgGN2c/AQ5wi8M6mJHrahz/wciENPTeI6zEIbbLGoth5XN+sQe2pJDejEVofN9aOAp0kaazwnVg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3716,6 +3832,11 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-openharmony-arm64@1.0.0-beta.32': + resolution: {integrity: sha512-qZ1ViyOUDGbiZrSAJ/FIAhYUElDfVxxFW6DLT/w4KeoZN3HsF4jmRP95mXtl51/oGrqzU9l9Q2f7/P4O/o2ZZA==} + cpu: [arm64] + os: [openharmony] + '@rolldown/binding-openharmony-arm64@1.0.0-beta.52': resolution: {integrity: sha512-1BNQW8u4ro8bsN1+tgKENJiqmvc+WfuaUhXzMImOVSMw28pkBKdfZtX2qJPADV3terx+vNJtlsgSGeb3+W6Jiw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3728,6 +3849,11 @@ packages: cpu: [arm64] os: [openharmony] + '@rolldown/binding-wasm32-wasi@1.0.0-beta.32': + resolution: {integrity: sha512-hEkG3wD+f3wytV0lqwb/uCrXc4r4Ny/DWJFJPfQR3VeMWplhWGgSHNwZc2Q7k86Yi36f9NNzzWmrIuvHI9lCVw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + '@rolldown/binding-wasm32-wasi@1.0.0-beta.52': resolution: {integrity: sha512-K/p7clhCqJOQpXGykrFaBX2Dp9AUVIDHGc+PtFGBwg7V+mvBTv/tsm3LC3aUmH02H2y3gz4y+nUTQ0MLpofEEg==} engines: {node: '>=14.0.0'} @@ -3738,6 +3864,11 @@ packages: engines: {node: '>=14.0.0'} cpu: [wasm32] + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.32': + resolution: {integrity: sha512-k3MvDf8SiA7uP2ikP0unNouJ2YCrnwi7xcVW+RDgMp5YXVr3Xu6svmT3HGn0tkCKUuPmf+uy8I5uiHt5qWQbew==} + cpu: [arm64] + os: [win32] + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.52': resolution: {integrity: sha512-a4EkXBtnYYsKipjS7QOhEBM4bU5IlR9N1hU+JcVEVeuTiaslIyhWVKsvf7K2YkQHyVAJ+7/A9BtrGqORFcTgng==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3750,12 +3881,22 @@ packages: cpu: [arm64] os: [win32] + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.32': + resolution: {integrity: sha512-wAi/FxGh7arDOUG45UmnXE1sZUa0hY4cXAO2qWAjFa3f7bTgz/BqwJ7XN5SUezvAJPNkME4fEpInfnBvM25a0w==} + cpu: [ia32] + os: [win32] + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.52': resolution: {integrity: sha512-5ZXcYyd4GxPA6QfbGrNcQjmjbuLGvfz6728pZMsQvGHI+06LT06M6TPtXvFvLgXtexc+OqvFe1yAIXJU1gob/w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.32': + resolution: {integrity: sha512-Ej0i4PZk8ltblZtzVK8ouaGUacUtxRmTm5S9794mdyU/tYxXjAJNseOfxrnHpMWKjMDrOKbqkPqJ52T9NR4LQQ==} + cpu: [x64] + os: [win32] + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.52': resolution: {integrity: sha512-tzpnRQXJrSzb8Z9sm97UD3cY0toKOImx+xRKsDLX4zHaAlRXWh7jbaKBePJXEN7gNw7Nm03PBNwphdtA8KSUYQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3771,6 +3912,9 @@ packages: '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + '@rolldown/pluginutils@1.0.0-beta.32': + resolution: {integrity: sha512-QReCdvxiUZAPkvp1xpAg62IeNzykOFA6syH2CnClif4YmALN1XKpB39XneL80008UbtMShthSVDKmrx05N1q/g==} + '@rolldown/pluginutils@1.0.0-beta.52': resolution: {integrity: sha512-/L0htLJZbaZFL1g9OHOblTxbCYIGefErJjtYOwgl9ZqNx27P3L0SDfjhhHIss32gu5NWgnxuT2a2Hnnv6QGHKA==} @@ -3780,6 +3924,15 @@ packages: '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + '@rollup/plugin-alias@5.1.1': + resolution: {integrity: sha512-PR9zDb+rOzkRb2VD+EuKB7UC41vU5DIwZ5qqCpk0KJudcWAyi8rvYOhS7+L5aZCspw1stTViLgN5v6FF1p5cgQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + '@rollup/plugin-alias@6.0.0': resolution: {integrity: sha512-tPCzJOtS7uuVZd+xPhoy5W4vThe6KWXNmsFCNktaAh5RTqcLiSfT4huPQIXkgJ6YCOjJHvecOAzQxLFhPxKr+g==} engines: {node: '>=20.19.0'} @@ -3802,6 +3955,15 @@ packages: rollup: optional: true + '@rollup/plugin-commonjs@28.0.9': + resolution: {integrity: sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA==} + engines: {node: '>=16.0.0 || 14 >= 14.17'} + peerDependencies: + rollup: ^2.68.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + '@rollup/plugin-commonjs@29.0.0': resolution: {integrity: sha512-U2YHaxR2cU/yAiwKJtJRhnyLk7cifnQw0zUpISsocBDoHDJn+HTV74ABqnwr5bEgWUwFZC9oFL6wLe21lHu5eQ==} engines: {node: '>=16.0.0 || 14 >= 14.17'} @@ -3977,6 +4139,14 @@ packages: cpu: [x64] os: [win32] + '@rushstack/node-core-library@5.14.0': + resolution: {integrity: sha512-eRong84/rwQUlATGFW3TMTYVyqL1vfW9Lf10PH+mVGfIb9HzU3h5AASNIw+axnBLjnD0n3rT5uQBwu9fvzATrg==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + '@rushstack/node-core-library@5.19.1': resolution: {integrity: sha512-ESpb2Tajlatgbmzzukg6zyAhH+sICqJR2CNXNhXcEbz6UGCQfrKCtkxOpJTftWc8RGouroHG0Nud1SJAszvpmA==} peerDependencies: @@ -3993,9 +4163,20 @@ packages: '@types/node': optional: true + '@rushstack/rig-package@0.5.3': + resolution: {integrity: sha512-olzSSjYrvCNxUFZowevC3uz8gvKr3WTpHQ7BkpjtRpA3wK+T0ybep/SRUMfr195gBzJm5gaXw0ZMgjIyHqJUow==} + '@rushstack/rig-package@0.6.0': resolution: {integrity: sha512-ZQmfzsLE2+Y91GF15c65L/slMRVhF6Hycq04D4TwtdGaUAbIXXg9c5pKA5KFU7M4QMaihoobp9JJYpYcaY3zOw==} + '@rushstack/terminal@0.15.4': + resolution: {integrity: sha512-OQSThV0itlwVNHV6thoXiAYZlQh4Fgvie2CzxFABsbO2MWQsI4zOh3LRNigYSTrmS+ba2j0B3EObakPzf/x6Zg==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + '@rushstack/terminal@0.19.5': resolution: {integrity: sha512-6k5tpdB88G0K7QrH/3yfKO84HK9ggftfUZ51p7fePyCE7+RLLHkWZbID9OFWbXuna+eeCFE7AkKnRMHMxNbz7Q==} peerDependencies: @@ -4004,6 +4185,9 @@ packages: '@types/node': optional: true + '@rushstack/ts-command-line@5.0.2': + resolution: {integrity: sha512-+AkJDbu1GFMPIU8Sb7TLVXDv/Q7Mkvx+wAjEl8XiXVVq+p1FmWW6M3LYpJMmoHNckSofeMecgWg5lfMwNAAsEQ==} + '@rushstack/ts-command-line@5.1.5': resolution: {integrity: sha512-YmrFTFUdHXblYSa+Xc9OO9FsL/XFcckZy0ycQ6q7VSBsVs5P0uD9vcges5Q9vctGlVdu27w+Ct6IuJ458V0cTQ==} @@ -4073,6 +4257,17 @@ packages: babel-plugin-react-compiler: optional: true + '@sanity/pkg-utils@7.11.9': + resolution: {integrity: sha512-3gcE2SOVe3UEVpbENuu6kSWi8HtfzpJXr+gkrFGbHZKBmtzk/SiCQ9nhPUg9VBanrAcBvgIhtuDd039fjr5f1A==} + engines: {node: '>=18.17.0'} + hasBin: true + peerDependencies: + babel-plugin-react-compiler: '*' + typescript: 5.8.x || 5.9.x + peerDependenciesMeta: + babel-plugin-react-compiler: + optional: true + '@sanity/pkg-utils@9.2.3': resolution: {integrity: sha512-hYkrs8fmqUTe5oXzkm9PHyztfoGKAsKVNCRlWEnPA/YG63780dXwyptbySCuwLHUy4cSX7oasxIqKCaalfD1HQ==} engines: {node: '>=20.19 <22 || >=22.12'} @@ -4529,9 +4724,23 @@ packages: '@vitest/browser': optional: true + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + '@vitest/expect@4.0.18': resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/mocker@4.0.18': resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} peerDependencies: @@ -4543,18 +4752,33 @@ packages: vite: optional: true + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/pretty-format@4.0.18': resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + '@vitest/runner@4.0.18': resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + '@vitest/snapshot@4.0.18': resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/spy@4.0.18': resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} @@ -4637,6 +4861,10 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + ansis@4.2.0: + resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + engines: {node: '>=14'} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -4803,6 +5031,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chai@6.2.1: resolution: {integrity: sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==} engines: {node: '>=18'} @@ -4830,6 +5062,10 @@ packages: chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -5004,6 +5240,10 @@ packages: babel-plugin-macros: optional: true + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -5160,6 +5400,11 @@ packages: esast-util-from-js@2.0.1: resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} + esbuild-register@3.6.0: + resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} + peerDependencies: + esbuild: '>=0.12 <1' + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -5363,6 +5608,10 @@ packages: resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} engines: {node: '>=18'} + find-up@3.0.0: + resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} + engines: {node: '>=6'} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -5492,6 +5741,11 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + glob@9.3.5: + resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} + engines: {node: '>=16 || 14 >=14.17'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -5813,6 +6067,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isomorphic.js@0.2.5: + resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -5881,6 +6138,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@3.14.2: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true @@ -5952,6 +6212,11 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lib0@0.2.117: + resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==} + engines: {node: '>=16'} + hasBin: true + lightningcss-android-arm64@1.30.2: resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} engines: {node: '>= 12.0.0'} @@ -6042,6 +6307,10 @@ packages: resolution: {integrity: sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==} engines: {node: '>=18.0.0'} + locate-path@3.0.0: + resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} + engines: {node: '>=6'} + locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -6069,6 +6338,9 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -6336,6 +6608,10 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@8.0.5: + resolution: {integrity: sha512-85MramurFFFSes0exAhJjto4tC4MpGWoktMZl+GYYBPwdpITzZmTKDJDrxhzg2bOyXGIPxlWvGl39tCcQBkuKA==} + engines: {node: '>=16 || 14 >=14.17'} + minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} @@ -6343,6 +6619,10 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@4.2.8: + resolution: {integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==} + engines: {node: '>=8'} + minipass@7.1.2: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} @@ -6485,6 +6765,10 @@ packages: resolution: {integrity: sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==} engines: {node: '>=18'} + p-locate@3.0.0: + resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} + engines: {node: '>=6'} + p-locate@4.1.0: resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} engines: {node: '>=8'} @@ -6556,6 +6840,10 @@ packages: path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@3.0.0: + resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} + engines: {node: '>=4'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -6575,6 +6863,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.1: resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} engines: {node: 20 || >=22} @@ -6590,6 +6882,10 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + piccolore@0.1.3: resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} @@ -6624,6 +6920,10 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + pkg-up@3.1.0: + resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} + engines: {node: '>=8'} + playwright-core@1.57.0: resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==} engines: {node: '>=18'} @@ -6700,6 +7000,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-bytes@5.6.0: + resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} + engines: {node: '>=6'} + pretty-bytes@7.1.0: resolution: {integrity: sha512-nODzvTiYVRGRqAOvE84Vk5JDPyyxsVk0/fbA/bq7RqlnhksGpset09XTxbpvLTIjoaF7K8Z8DG8yHtKGTPSYRw==} engines: {node: '>=20'} @@ -6995,11 +7299,32 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rimraf@4.4.1: + resolution: {integrity: sha512-Gk8NlF062+T9CqNGn6h4tls3k6T1+/nXdOcSZVikNVtlRdYpA7wRJJMoXmuvOnLW844rPjdQ7JgXCYM6PPC/og==} + engines: {node: '>=14'} + hasBin: true + rimraf@6.1.2: resolution: {integrity: sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==} engines: {node: 20 || >=22} hasBin: true + rolldown-plugin-dts@0.15.6: + resolution: {integrity: sha512-AxQlyx3Nszob5QLmVUjz/VnC5BevtUo0h8tliuE0egddss7IbtCBU7GOe7biRU0fJNRQJmQjPKXFcc7K98j3+w==} + engines: {node: '>=20.18.0'} + peerDependencies: + '@typescript/native-preview': '>=7.0.0-dev.20250601.1' + rolldown: ^1.0.0-beta.9 + typescript: ^5.0.0 + vue-tsc: ~3.0.3 + peerDependenciesMeta: + '@typescript/native-preview': + optional: true + typescript: + optional: true + vue-tsc: + optional: true + rolldown-plugin-dts@0.18.1: resolution: {integrity: sha512-uIgNMix6OI+6bSkw0nw6O+G/ydPRCWKwvvcEyL6gWkVkSFVGWWO23DX4ZYVOqC7w5u2c8uPY9Q74U0QCKvegFA==} engines: {node: '>=20.19.0'} @@ -7038,6 +7363,10 @@ packages: vue-tsc: optional: true + rolldown@1.0.0-beta.32: + resolution: {integrity: sha512-vxI2sPN07MMaoYKlFrVva5qZ1Y7DAZkgp7MQwTnyHt4FUMz9Sh+YeCzNFV9JYHI6ZNwoGWLCfCViE3XVsRC1cg==} + hasBin: true + rolldown@1.0.0-beta.52: resolution: {integrity: sha512-Hbnpljue+JhMJrlOjQ1ixp9me7sUec7OjFvS+A1Qm8k8Xyxmw3ZhxFu7LlSXW1s9AX3POE9W9o2oqCEeR5uDmg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -7276,6 +7605,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -7389,10 +7721,22 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + tinyrainbow@3.0.3: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + tldts-core@7.0.19: resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==} @@ -7746,6 +8090,11 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite@5.4.21: resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} engines: {node: ^18.0.0 || >=20.0.0} @@ -7879,6 +8228,34 @@ packages: '@types/react-dom': optional: true + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vitest@4.0.18: resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -8019,6 +8396,10 @@ packages: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yjs@13.6.29: + resolution: {integrity: sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -8046,6 +8427,12 @@ packages: typescript: ^4.9.4 || ^5.0.2 zod: ^3 + zod-validation-error@3.5.3: + resolution: {integrity: sha512-OT5Y8lbUadqVZCsnyFaTQ4/O2mys4tj7PqhdbBCp7McPwvIEKfPtdA6QfPeFQK2/Rz5LgwmAXRJTugBNBi0btw==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + zod-validation-error@4.0.2: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} engines: {node: '>=18.0.0'} @@ -9478,6 +9865,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@microsoft/api-extractor-model@7.30.7(@types/node@20.19.25)': + dependencies: + '@microsoft/tsdoc': 0.15.1 + '@microsoft/tsdoc-config': 0.17.1 + '@rushstack/node-core-library': 5.14.0(@types/node@20.19.25) + transitivePeerDependencies: + - '@types/node' + '@microsoft/api-extractor-model@7.32.2(@types/node@20.19.25)': dependencies: '@microsoft/tsdoc': 0.16.0 @@ -9486,6 +9881,24 @@ snapshots: transitivePeerDependencies: - '@types/node' + '@microsoft/api-extractor@7.52.10(@types/node@20.19.25)': + dependencies: + '@microsoft/api-extractor-model': 7.30.7(@types/node@20.19.25) + '@microsoft/tsdoc': 0.15.1 + '@microsoft/tsdoc-config': 0.17.1 + '@rushstack/node-core-library': 5.14.0(@types/node@20.19.25) + '@rushstack/rig-package': 0.5.3 + '@rushstack/terminal': 0.15.4(@types/node@20.19.25) + '@rushstack/ts-command-line': 5.0.2(@types/node@20.19.25) + lodash: 4.17.21 + minimatch: 10.0.3 + resolve: 1.22.11 + semver: 7.5.4 + source-map: 0.6.1 + typescript: 5.8.2 + transitivePeerDependencies: + - '@types/node' + '@microsoft/api-extractor@7.55.2(@types/node@20.19.25)': dependencies: '@microsoft/api-extractor-model': 7.32.2(@types/node@20.19.25) @@ -9505,6 +9918,13 @@ snapshots: transitivePeerDependencies: - '@types/node' + '@microsoft/tsdoc-config@0.17.1': + dependencies: + '@microsoft/tsdoc': 0.15.1 + ajv: 8.12.0 + jju: 1.4.0 + resolve: 1.22.11 + '@microsoft/tsdoc-config@0.18.0': dependencies: '@microsoft/tsdoc': 0.16.0 @@ -9512,6 +9932,8 @@ snapshots: jju: 1.4.0 resolve: 1.22.11 + '@microsoft/tsdoc@0.15.1': {} + '@microsoft/tsdoc@0.16.0': {} '@napi-rs/wasm-runtime@1.1.0': @@ -9533,12 +9955,23 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 + '@optimize-lodash/rollup-plugin@5.0.2(rollup@4.53.3)': + dependencies: + '@optimize-lodash/transform': 3.0.6 + '@rollup/pluginutils': 5.3.0(rollup@4.53.3) + rollup: 4.53.3 + '@optimize-lodash/rollup-plugin@6.0.0(rollup@4.53.3)': dependencies: '@optimize-lodash/transform': 4.0.0 '@rollup/pluginutils': 5.3.0(rollup@4.53.3) rollup: 4.53.3 + '@optimize-lodash/transform@3.0.6': + dependencies: + estree-walker: 2.0.2 + magic-string: 0.30.21 + '@optimize-lodash/transform@4.0.0': dependencies: estree-walker: 2.0.2 @@ -9546,8 +9979,12 @@ snapshots: '@oslojs/encoding@1.1.0': {} + '@oxc-project/runtime@0.81.0': {} + '@oxc-project/types@0.102.0': {} + '@oxc-project/types@0.81.0': {} + '@oxc-project/types@0.99.0': {} '@pagefind/darwin-arm64@1.4.0': @@ -10966,66 +11403,101 @@ snapshots: '@react-types/shared': 3.32.1(react@19.2.3) react: 19.2.3 - '@rolldown/binding-android-arm64@1.0.0-beta.52': + '@rolldown/binding-android-arm64@1.0.0-beta.32': + optional: true + + '@rolldown/binding-android-arm64@1.0.0-beta.52': optional: true '@rolldown/binding-android-arm64@1.0.0-beta.54': optional: true + '@rolldown/binding-darwin-arm64@1.0.0-beta.32': + optional: true + '@rolldown/binding-darwin-arm64@1.0.0-beta.52': optional: true '@rolldown/binding-darwin-arm64@1.0.0-beta.54': optional: true + '@rolldown/binding-darwin-x64@1.0.0-beta.32': + optional: true + '@rolldown/binding-darwin-x64@1.0.0-beta.52': optional: true '@rolldown/binding-darwin-x64@1.0.0-beta.54': optional: true + '@rolldown/binding-freebsd-x64@1.0.0-beta.32': + optional: true + '@rolldown/binding-freebsd-x64@1.0.0-beta.52': optional: true '@rolldown/binding-freebsd-x64@1.0.0-beta.54': optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.32': + optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.52': optional: true '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.54': optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.32': + optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.52': optional: true '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.54': optional: true + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.32': + optional: true + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.52': optional: true '@rolldown/binding-linux-arm64-musl@1.0.0-beta.54': optional: true + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.32': + optional: true + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.52': optional: true '@rolldown/binding-linux-x64-gnu@1.0.0-beta.54': optional: true + '@rolldown/binding-linux-x64-musl@1.0.0-beta.32': + optional: true + '@rolldown/binding-linux-x64-musl@1.0.0-beta.52': optional: true '@rolldown/binding-linux-x64-musl@1.0.0-beta.54': optional: true + '@rolldown/binding-openharmony-arm64@1.0.0-beta.32': + optional: true + '@rolldown/binding-openharmony-arm64@1.0.0-beta.52': optional: true '@rolldown/binding-openharmony-arm64@1.0.0-beta.54': optional: true + '@rolldown/binding-wasm32-wasi@1.0.0-beta.32': + dependencies: + '@napi-rs/wasm-runtime': 1.1.0 + optional: true + '@rolldown/binding-wasm32-wasi@1.0.0-beta.52': dependencies: '@napi-rs/wasm-runtime': 1.1.0 @@ -11036,15 +11508,24 @@ snapshots: '@napi-rs/wasm-runtime': 1.1.0 optional: true + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.32': + optional: true + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.52': optional: true '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.54': optional: true + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.32': + optional: true + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.52': optional: true + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.32': + optional: true + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.52': optional: true @@ -11053,12 +11534,18 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.27': {} + '@rolldown/pluginutils@1.0.0-beta.32': {} + '@rolldown/pluginutils@1.0.0-beta.52': {} '@rolldown/pluginutils@1.0.0-beta.54': {} '@rolldown/pluginutils@1.0.0-rc.3': {} + '@rollup/plugin-alias@5.1.1(rollup@4.53.3)': + optionalDependencies: + rollup: 4.53.3 + '@rollup/plugin-alias@6.0.0(rollup@4.53.3)': optionalDependencies: rollup: 4.53.3 @@ -11074,6 +11561,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@rollup/plugin-commonjs@28.0.9(rollup@4.53.3)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.53.3) + commondir: 1.0.1 + estree-walker: 2.0.2 + fdir: 6.5.0(picomatch@4.0.3) + is-reference: 1.2.1 + magic-string: 0.30.21 + picomatch: 4.0.3 + optionalDependencies: + rollup: 4.53.3 + '@rollup/plugin-commonjs@29.0.0(rollup@4.53.3)': dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.53.3) @@ -11191,6 +11690,19 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.53.3': optional: true + '@rushstack/node-core-library@5.14.0(@types/node@20.19.25)': + dependencies: + ajv: 8.13.0 + ajv-draft-04: 1.0.0(ajv@8.13.0) + ajv-formats: 3.0.1(ajv@8.13.0) + fs-extra: 11.3.2 + import-lazy: 4.0.0 + jju: 1.4.0 + resolve: 1.22.11 + semver: 7.5.4 + optionalDependencies: + '@types/node': 20.19.25 + '@rushstack/node-core-library@5.19.1(@types/node@20.19.25)': dependencies: ajv: 8.13.0 @@ -11208,11 +11720,23 @@ snapshots: optionalDependencies: '@types/node': 20.19.25 + '@rushstack/rig-package@0.5.3': + dependencies: + resolve: 1.22.11 + strip-json-comments: 3.1.1 + '@rushstack/rig-package@0.6.0': dependencies: resolve: 1.22.11 strip-json-comments: 3.1.1 + '@rushstack/terminal@0.15.4(@types/node@20.19.25)': + dependencies: + '@rushstack/node-core-library': 5.14.0(@types/node@20.19.25) + supports-color: 8.1.1 + optionalDependencies: + '@types/node': 20.19.25 + '@rushstack/terminal@0.19.5(@types/node@20.19.25)': dependencies: '@rushstack/node-core-library': 5.19.1(@types/node@20.19.25) @@ -11221,6 +11745,15 @@ snapshots: optionalDependencies: '@types/node': 20.19.25 + '@rushstack/ts-command-line@5.0.2(@types/node@20.19.25)': + dependencies: + '@rushstack/terminal': 0.15.4(@types/node@20.19.25) + '@types/argparse': 1.0.38 + argparse: 1.0.10 + string-argv: 0.3.2 + transitivePeerDependencies: + - '@types/node' + '@rushstack/ts-command-line@5.1.5(@types/node@20.19.25)': dependencies: '@rushstack/terminal': 0.19.5(@types/node@20.19.25) @@ -11364,6 +11897,65 @@ snapshots: - supports-color - vue-tsc + '@sanity/pkg-utils@7.11.9(@types/babel__core@7.20.5)(@types/node@20.19.25)(babel-plugin-react-compiler@1.0.0)(typescript@5.9.3)': + dependencies: + '@babel/core': 7.29.0 + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + '@babel/types': 7.29.0 + '@microsoft/api-extractor': 7.52.10(@types/node@20.19.25) + '@microsoft/tsdoc-config': 0.17.1 + '@optimize-lodash/rollup-plugin': 5.0.2(rollup@4.53.3) + '@rollup/plugin-alias': 5.1.1(rollup@4.53.3) + '@rollup/plugin-babel': 6.1.0(@babel/core@7.29.0)(@types/babel__core@7.20.5)(rollup@4.53.3) + '@rollup/plugin-commonjs': 28.0.9(rollup@4.53.3) + '@rollup/plugin-json': 6.1.0(rollup@4.53.3) + '@rollup/plugin-node-resolve': 16.0.3(rollup@4.53.3) + '@rollup/plugin-replace': 6.0.3(rollup@4.53.3) + '@rollup/plugin-terser': 0.4.4(rollup@4.53.3) + '@sanity/browserslist-config': 1.0.5 + '@vanilla-extract/rollup-plugin': 1.5.0(rollup@4.53.3) + browserslist: 4.28.1 + cac: 6.7.14 + chalk: 4.1.2 + chokidar: 4.0.3 + esbuild: 0.25.12 + esbuild-register: 3.6.0(esbuild@0.25.12) + find-config: 1.0.0 + get-latest-version: 5.1.0(debug@4.4.3) + git-url-parse: 16.1.0 + globby: 11.1.0 + jsonc-parser: 3.3.1 + lightningcss: 1.30.2 + mkdirp: 3.0.1 + outdent: 0.8.0 + pkg-up: 3.1.0 + prettier: 3.7.4 + pretty-bytes: 5.6.0 + prompts: 2.4.2 + recast: 0.23.11 + rimraf: 4.4.1 + rolldown: 1.0.0-beta.32 + rolldown-plugin-dts: 0.15.6(rolldown@1.0.0-beta.32)(typescript@5.9.3) + rollup: 4.53.3 + rollup-plugin-esbuild: 6.2.1(esbuild@0.25.12)(rollup@4.53.3) + rxjs: 7.8.2 + treeify: 1.1.0 + typescript: 5.9.3 + uuid: 11.1.0 + zod: 3.25.76 + zod-validation-error: 3.5.3(zod@3.25.76) + optionalDependencies: + babel-plugin-react-compiler: 1.0.0 + transitivePeerDependencies: + - '@types/babel__core' + - '@types/node' + - '@typescript/native-preview' + - babel-plugin-macros + - debug + - oxc-resolver + - supports-color + - vue-tsc + '@sanity/pkg-utils@9.2.3(@types/babel__core@7.20.5)(@types/node@20.19.25)(babel-plugin-react-compiler@1.0.0)(typescript@5.9.3)': dependencies: '@babel/core': 7.29.0 @@ -12042,6 +12634,14 @@ snapshots: optionalDependencies: '@vitest/browser': 4.0.18(vite@7.3.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18) + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + '@vitest/expect@4.0.18': dependencies: '@standard-schema/spec': 1.0.0 @@ -12051,6 +12651,14 @@ snapshots: chai: 6.2.1 tinyrainbow: 3.0.3 + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.18 @@ -12059,23 +12667,49 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + '@vitest/pretty-format@4.0.18': dependencies: tinyrainbow: 3.0.3 + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + '@vitest/runner@4.0.18': dependencies: '@vitest/utils': 4.0.18 pathe: 2.0.3 + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/snapshot@4.0.18': dependencies: '@vitest/pretty-format': 4.0.18 magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + '@vitest/spy@4.0.18': {} + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + '@vitest/utils@4.0.18': dependencies: '@vitest/pretty-format': 4.0.18 @@ -12150,6 +12784,8 @@ snapshots: ansi-styles@6.2.3: {} + ansis@4.2.0: {} + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -12426,6 +13062,14 @@ snapshots: ccount@2.0.1: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chai@6.2.1: {} chalk@4.1.2: @@ -12445,6 +13089,8 @@ snapshots: chardet@2.1.1: {} + check-error@2.1.3: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -12588,6 +13234,8 @@ snapshots: dedent@1.7.0: {} + deep-eql@5.0.2: {} + deep-extend@0.6.0: {} deep-is@0.1.4: {} @@ -12715,6 +13363,13 @@ snapshots: esast-util-from-estree: 2.0.0 vfile-message: 4.0.3 + esbuild-register@3.6.0(esbuild@0.25.12): + dependencies: + debug: 4.4.3 + esbuild: 0.25.12 + transitivePeerDependencies: + - supports-color + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -13025,6 +13680,10 @@ snapshots: find-up-simple@1.0.1: {} + find-up@3.0.0: + dependencies: + locate-path: 3.0.0 + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -13158,6 +13817,13 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 + glob@9.3.5: + dependencies: + fs.realpath: 1.0.0 + minimatch: 8.0.5 + minipass: 4.2.8 + path-scurry: 1.11.1 + globals@14.0.0: {} globals@15.15.0: {} @@ -13579,6 +14245,8 @@ snapshots: isexe@2.0.0: {} + isomorphic.js@0.2.5: {} + istanbul-lib-coverage@3.2.2: {} istanbul-lib-instrument@6.0.3: @@ -13704,6 +14372,8 @@ snapshots: js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@3.14.2: dependencies: argparse: 1.0.10 @@ -13781,6 +14451,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lib0@0.2.117: + dependencies: + isomorphic.js: 0.2.5 + lightningcss-android-arm64@1.30.2: optional: true @@ -13860,6 +14534,11 @@ snapshots: rfdc: 1.4.1 wrap-ansi: 9.0.2 + locate-path@3.0.0: + dependencies: + p-locate: 3.0.0 + path-exists: 3.0.0 + locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -13886,6 +14565,8 @@ snapshots: longest-streak@3.1.0: {} + loupe@3.2.1: {} + lru-cache@10.4.3: {} lru-cache@11.2.4: {} @@ -14437,12 +15118,18 @@ snapshots: dependencies: brace-expansion: 1.1.12 + minimatch@8.0.5: + dependencies: + brace-expansion: 2.0.2 + minimatch@9.0.5: dependencies: brace-expansion: 2.0.2 minimist@1.2.8: {} + minipass@4.2.8: {} + minipass@7.1.2: {} mkdirp@3.0.1: {} @@ -14561,6 +15248,10 @@ snapshots: dependencies: yocto-queue: 1.2.2 + p-locate@3.0.0: + dependencies: + p-limit: 2.3.0 + p-locate@4.1.0: dependencies: p-limit: 2.3.0 @@ -14645,6 +15336,8 @@ snapshots: path-browserify@1.0.1: {} + path-exists@3.0.0: {} + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -14655,6 +15348,11 @@ snapshots: path-parse@1.0.7: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + path-scurry@2.0.1: dependencies: lru-cache: 11.2.4 @@ -14666,6 +15364,8 @@ snapshots: pathe@2.0.3: {} + pathval@2.0.1: {} + piccolore@0.1.3: {} picocolors@1.1.1: {} @@ -14690,6 +15390,10 @@ snapshots: mlly: 1.8.0 pathe: 2.0.3 + pkg-up@3.1.0: + dependencies: + find-up: 3.0.0 + playwright-core@1.57.0: {} playwright@1.57.0: @@ -14752,6 +15456,8 @@ snapshots: prettier@3.7.4: {} + pretty-bytes@5.6.0: {} + pretty-bytes@7.1.0: {} pretty-format@30.2.0: @@ -15204,11 +15910,32 @@ snapshots: rfdc@1.4.1: {} + rimraf@4.4.1: + dependencies: + glob: 9.3.5 + rimraf@6.1.2: dependencies: glob: 13.0.0 package-json-from-dist: 1.0.1 + rolldown-plugin-dts@0.15.6(rolldown@1.0.0-beta.32)(typescript@5.9.3): + dependencies: + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + ast-kit: 2.2.0 + birpc: 2.8.0 + debug: 4.4.3 + dts-resolver: 2.1.3 + get-tsconfig: 4.13.0 + rolldown: 1.0.0-beta.32 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - oxc-resolver + - supports-color + rolldown-plugin-dts@0.18.1(rolldown@1.0.0-beta.52)(typescript@5.9.3): dependencies: '@babel/generator': 7.29.1 @@ -15243,6 +15970,28 @@ snapshots: transitivePeerDependencies: - oxc-resolver + rolldown@1.0.0-beta.32: + dependencies: + '@oxc-project/runtime': 0.81.0 + '@oxc-project/types': 0.81.0 + '@rolldown/pluginutils': 1.0.0-beta.32 + ansis: 4.2.0 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-beta.32 + '@rolldown/binding-darwin-arm64': 1.0.0-beta.32 + '@rolldown/binding-darwin-x64': 1.0.0-beta.32 + '@rolldown/binding-freebsd-x64': 1.0.0-beta.32 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.32 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.32 + '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.32 + '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.32 + '@rolldown/binding-linux-x64-musl': 1.0.0-beta.32 + '@rolldown/binding-openharmony-arm64': 1.0.0-beta.32 + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.32 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.32 + '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.32 + '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.32 + rolldown@1.0.0-beta.52: dependencies: '@oxc-project/types': 0.99.0 @@ -15282,6 +16031,17 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.54 '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.54 + rollup-plugin-esbuild@6.2.1(esbuild@0.25.12)(rollup@4.53.3): + dependencies: + debug: 4.4.3 + es-module-lexer: 1.7.0 + esbuild: 0.25.12 + get-tsconfig: 4.13.0 + rollup: 4.53.3 + unplugin-utils: 0.2.5 + transitivePeerDependencies: + - supports-color + rollup-plugin-esbuild@6.2.1(esbuild@0.27.1)(rollup@4.53.3): dependencies: debug: 4.4.3 @@ -15560,6 +16320,10 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -15667,8 +16431,14 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + tinyrainbow@3.0.3: {} + tinyspy@4.0.4: {} + tldts-core@7.0.19: {} tldts@7.0.19: @@ -15948,6 +16718,27 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-node@3.2.4(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite@5.4.21(@types/node@20.19.25)(lightningcss@1.30.2)(terser@5.44.1): dependencies: esbuild: 0.21.5 @@ -16006,6 +16797,49 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.25)(jiti@2.6.1)(jsdom@27.2.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.2.2 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 20.19.25 + jsdom: 27.2.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vitest@4.0.18(@types/node@20.19.25)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.2.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 @@ -16123,6 +16957,10 @@ snapshots: yargs-parser@21.1.1: {} + yjs@13.6.29: + dependencies: + lib0: 0.2.117 + yocto-queue@0.1.0: {} yocto-queue@1.2.2: {} @@ -16142,6 +16980,10 @@ snapshots: typescript: 5.9.3 zod: 3.25.76 + zod-validation-error@3.5.3(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod-validation-error@4.0.2(zod@4.1.13): dependencies: zod: 4.1.13