diff --git a/.changeset/quiet-ghosts-invite.md b/.changeset/quiet-ghosts-invite.md new file mode 100644 index 0000000000..2392a60e53 --- /dev/null +++ b/.changeset/quiet-ghosts-invite.md @@ -0,0 +1,5 @@ +--- +"@udecode/plate-yjs": minor +--- + +New plugin: yjs for collaboration. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aa2801692a..abd7cd6e0f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -97,7 +97,7 @@ jobs: runs-on: ubuntu-latest needs: - test - if: ${{ failure() }} + if: ${{ !github.event.pull_request.draft && failure() }} steps: - name: Notify diff --git a/apps/www/content/docs/collaboration.mdx b/apps/www/content/docs/collaboration.mdx new file mode 100644 index 0000000000..004f32c6ca --- /dev/null +++ b/apps/www/content/docs/collaboration.mdx @@ -0,0 +1,132 @@ +--- +title: Collaboration +description: Collaborate with others in a single document. +--- + + + +**Backend Development in Progress** - Demo Unavailable. + + + + + +## Features + +- The yjs plugin enables support for collaboration using [slate-yjs](https://docs.slate-yjs.dev/) and [Hocuspocus](https://docs.slate-yjs.dev/walkthroughs/collaboration-hocuspocus). + + + +## Frontend + +### Installation + +```bash +npm install @udecode/plate-yjs +``` + +### Usage + +```tsx +import { createComboboxPlugin } from '@udecode/plate-combobox'; +import { createMentionPlugin } from '@udecode/plate-mention'; + +const plugins = [ + // ...otherPlugins, + createYjsPlugin({ + options: { + hocuspocusProviderOptions: { + url, // hocuspocus url + name, // room name + }, + }, + }), +]; +``` + +## Backend + +Follow the backend instructions in [Hocuspocus docs](https://tiptap.dev/hocuspocus/getting-started). + +## API + +### createYjsPlugin + + + +Configuration for handling cursors in the editor. These options are passed +to the `withCursors` function. + +- [WithCursorsOptions API](https://docs.slate-yjs.dev/api/slate-yjs-core/cursor-plugin#withcursors). + + + +Configuration for the Hocuspocus provider. + +- [HocuspocusProviderConfiguration API](https://tiptap.dev/hocuspocus/provider/configuration). + + + +Optional configuration for the Yjs integration. These options are passed to +the `withYjs` function. + +- [WithYjsOptions API](https://docs.slate-yjs.dev/api/slate-yjs-core/yjs-plugin#withyjs). + + + +A flag indicating whether to disable the cursor feature in the editor. + + + +### useYjsStore + + + + A flag indicating whether the editor is connected to the Hocuspocus server. + + + A flag indicating whether the editor is synced with the Hocuspocus server. + + + +### withTYjs + +Higher-order function that wraps a Plate editor with Yjs support (`YjsEditorProps`), allowing for real-time collaborative editing. + +### CursorEditorProps + +Extends `YjsEditorProps` and [CursorEditor](https://docs.slate-yjs.dev/api/slate-yjs-core/cursor-plugin#cursoreditor). + +### PlateYjsEditorProps + + + +Extends `CursorEditorProps`. + + + + + + + +The Hocuspocus provider instance. + + + + + + + + + +### YHistoryEditorProps + +Extends `YjsEditorProps`, [YHistoryEditor](https://docs.slate-yjs.dev/api/slate-yjs-core/history-plugin#yhistoryeditor). + +### YjsEditorProps + +[YjsEditor API](https://docs.slate-yjs.dev/api/slate-yjs-core/yjs-plugin#yjseditor). diff --git a/apps/www/src/config/doc-to-package.ts b/apps/www/src/config/doc-to-package.ts index f7ce3aece9..d8df095941 100644 --- a/apps/www/src/config/doc-to-package.ts +++ b/apps/www/src/config/doc-to-package.ts @@ -5,6 +5,7 @@ const plateOverrides = { 'basic-marks': 'plate-basic-marks', 'block-selection': 'plate-selection', cloud: 'plate-cloud', + collaboration: 'plate-yjs', combobox: 'plate-combobox', comments: 'plate-comments', dnd: 'plate-dnd', diff --git a/apps/www/src/config/docs.ts b/apps/www/src/config/docs.ts index 82f4c57f39..d26f00e1dd 100644 --- a/apps/www/src/config/docs.ts +++ b/apps/www/src/config/docs.ts @@ -116,6 +116,11 @@ export const docsConfig: DocsConfig = { items: [], label: 'Element', }, + { + title: 'Collaboration', + href: '/docs/collaboration', + items: [], + }, { title: 'Combobox', href: '/docs/combobox', diff --git a/config/rollup.config.cjs b/config/rollup.config.cjs index e5f3a5d327..73f25bc445 100644 --- a/config/rollup.config.cjs +++ b/config/rollup.config.cjs @@ -61,6 +61,7 @@ const plugins = [ include: /node_modules/, namedExports: { 'react-is': ['typeOf', 'isElement', 'isForwardRef', 'isValidElementType'], + 'scheduler': ['unstable_runWithPriority', 'unstable_NormalPriority'], }, }), diff --git a/packages/yjs/.npmignore b/packages/yjs/.npmignore new file mode 100644 index 0000000000..7d3b305b17 --- /dev/null +++ b/packages/yjs/.npmignore @@ -0,0 +1,3 @@ +__tests__ +__test-utils__ +__mocks__ diff --git a/packages/yjs/README.md b/packages/yjs/README.md new file mode 100644 index 0000000000..82a2b02539 --- /dev/null +++ b/packages/yjs/README.md @@ -0,0 +1,12 @@ +# Plate yjs plugin + +This package implements the yjs plugin for Plate and Hocuspocus. + +## Documentation + +Check out +[Collaboration](https://platejs.org/docs/collaboration). + +## License + +[MIT](../../LICENSE) diff --git a/packages/yjs/package.json b/packages/yjs/package.json new file mode 100644 index 0000000000..bae9e84b75 --- /dev/null +++ b/packages/yjs/package.json @@ -0,0 +1,54 @@ +{ + "name": "@udecode/plate-yjs", + "version": "22.0.2", + "description": "Yjs plugin for Plate", + "license": "MIT", + "homepage": "https://plate.udecode.io", + "repository": { + "type": "git", + "url": "https://github.com/udecode/plate.git", + "directory": "packages/yjs" + }, + "bugs": { + "url": "https://github.com/udecode/plate/issues" + }, + "main": "dist/index.js", + "module": "dist/index.es.js", + "files": [ + "dist" + ], + "types": "dist/index.d.ts", + "scripts": { + "build": "yarn p:build", + "build:watch": "yarn p:build:watch", + "brl": "yarn p:brl", + "clean": "yarn p:clean", + "lint": "yarn p:lint", + "lint:fix": "yarn p:lint:fix", + "test": "yarn p:test", + "test:watch": "yarn p:test:watch", + "typecheck": "yarn p:typecheck" + }, + "dependencies": { + "@hocuspocus/provider": "^2.2.1", + "@slate-yjs/core": "^1.0.1", + "@udecode/plate-common": "22.0.2", + "yjs": "^13.5.42" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0", + "slate": ">=0.94.0", + "slate-history": ">=0.93.0", + "slate-react": ">=0.95.0" + }, + "keywords": [ + "plate", + "plugin", + "slate", + "yjs" + ], + "publishConfig": { + "access": "public" + } +} diff --git a/packages/yjs/src/RenderAboveEditableYjs.tsx b/packages/yjs/src/RenderAboveEditableYjs.tsx new file mode 100644 index 0000000000..43dc56045c --- /dev/null +++ b/packages/yjs/src/RenderAboveEditableYjs.tsx @@ -0,0 +1,29 @@ +import React, { FC, ReactNode, useEffect } from 'react'; +import { YjsEditor } from '@slate-yjs/core'; +import { PlateEditor, usePlateEditorRef, Value } from '@udecode/plate-common'; + +import { PlateYjsEditorProps } from './withPlateYjs'; +import { useYjsSelectors } from './yjsStore'; + +export const RenderAboveEditableYjs: FC<{ children: ReactNode }> = ({ + children, +}) => { + const editor = usePlateEditorRef(); + + const isSynced = useYjsSelectors.isSynced(); + + useEffect(() => { + editor.yjs.provider.connect(); + return () => editor.yjs.provider.disconnect(); + }, [editor.yjs.provider]); + + useEffect(() => { + YjsEditor.connect(editor as any); + return () => YjsEditor.disconnect(editor as any); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editor.yjs.provider.awareness, editor.yjs.provider.document]); + + if (!isSynced) return null; + + return <>{children}; +}; diff --git a/packages/yjs/src/createYjsPlugin.ts b/packages/yjs/src/createYjsPlugin.ts new file mode 100644 index 0000000000..2894ef19f6 --- /dev/null +++ b/packages/yjs/src/createYjsPlugin.ts @@ -0,0 +1,45 @@ +import { HocuspocusProviderConfiguration } from '@hocuspocus/provider'; +import { WithCursorsOptions } from '@slate-yjs/core'; +// eslint-disable-next-line import/no-unresolved +import { WithYjsOptions } from '@slate-yjs/core/dist/plugins/withYjs'; +import { + createPluginFactory, + PlateEditor, + UnknownObject, + Value, +} from '@udecode/plate-common'; + +import { RenderAboveEditableYjs } from './RenderAboveEditableYjs'; +import { PlateYjsEditorProps, withPlateYjs } from './withPlateYjs'; + +export type YjsPlugin = { + /** + * withCursors options + */ + cursorOptions?: WithCursorsOptions; + + /** + * HocuspocusProvider configuration + * @required + */ + hocuspocusProviderOptions?: HocuspocusProviderConfiguration; + + /** + * withYjs options + */ + yjsOptions?: WithYjsOptions; + + disableCursors?: boolean; +}; + +export const KEY_YJS = 'yjs'; + +export const createYjsPlugin = createPluginFactory< + YjsPlugin, + Value, + PlateEditor & PlateYjsEditorProps +>({ + key: KEY_YJS, + withOverrides: withPlateYjs, + renderAboveEditable: RenderAboveEditableYjs, +}); diff --git a/packages/yjs/src/index.ts b/packages/yjs/src/index.ts new file mode 100644 index 0000000000..e57904910d --- /dev/null +++ b/packages/yjs/src/index.ts @@ -0,0 +1,11 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './RenderAboveEditableYjs'; +export * from './createYjsPlugin'; +export * from './withPlateYjs'; +export * from './withTCursors'; +export * from './withTYHistory'; +export * from './withTYjs'; +export * from './yjsStore'; diff --git a/packages/yjs/src/withPlateYjs.ts b/packages/yjs/src/withPlateYjs.ts new file mode 100644 index 0000000000..abbe2241bd --- /dev/null +++ b/packages/yjs/src/withPlateYjs.ts @@ -0,0 +1,94 @@ +import { HocuspocusProvider } from '@hocuspocus/provider'; +import { + getPluginOptions, + PlateEditor, + UnknownObject, + Value, +} from '@udecode/plate-common'; +import * as Y from 'yjs'; + +import { KEY_YJS, YjsPlugin } from './createYjsPlugin'; +import { CursorEditorProps, withTCursors } from './withTCursors'; +import { withTYHistory } from './withTYHistory'; +import { withTYjs } from './withTYjs'; +import { yjsActions } from './yjsStore'; + +export interface PlateYjsEditorProps extends CursorEditorProps { + yjs: { + provider: HocuspocusProvider; + }; +} + +export const withPlateYjs = < + V extends Value = Value, + E extends PlateEditor = PlateEditor, + EE extends E & PlateYjsEditorProps = E & PlateYjsEditorProps, + TCursorData extends UnknownObject = UnknownObject, +>( + e: E +) => { + const editor = e as unknown as EE; + + const { + cursorOptions, + hocuspocusProviderOptions, + yjsOptions, + disableCursors, + } = getPluginOptions, V, E>(editor, KEY_YJS); + + if (!hocuspocusProviderOptions) { + throw new Error('HocuspocusProvider configuration is required'); + } + + /** + * Create a new websocket-provider instance. + * As long as this provider, or the connected ydoc, is not destroyed, + * the changes will be synced to other clients via the connected server. + */ + const provider = new HocuspocusProvider({ + ...hocuspocusProviderOptions, + onAwarenessChange() {}, + onConnect() { + yjsActions.isConnected(true); + hocuspocusProviderOptions.onConnect?.(); + }, + onSynced(data) { + yjsActions.isSynced(true); + hocuspocusProviderOptions.onSynced?.(data); + }, + onDisconnect(data) { + yjsActions.isConnected(false); + yjsActions.isSynced(false); + hocuspocusProviderOptions.onDisconnect?.(data); + }, + }); + + editor.yjs = { + provider: provider as any, + }; + + const sharedType = provider.document.get( + 'content', + Y.XmlText + ) as any as Y.XmlText; + + if (disableCursors) { + return withTYHistory( + withTYjs(editor, sharedType, { + autoConnect: false, + ...yjsOptions, + }) + ) as EE; + } + + return withTYHistory( + withTCursors( + withTYjs(editor, sharedType, { + autoConnect: false, + ...yjsOptions, + }), + provider.awareness, + cursorOptions + ) + ) as EE; +}; diff --git a/packages/yjs/src/withTCursors.ts b/packages/yjs/src/withTCursors.ts new file mode 100644 index 0000000000..9ce5b26137 --- /dev/null +++ b/packages/yjs/src/withTCursors.ts @@ -0,0 +1,26 @@ +import { CursorEditor, withCursors, WithCursorsOptions } from '@slate-yjs/core'; +import { TEditor, Value } from '@udecode/plate-common'; +import { Awareness } from 'y-protocols/awareness'; + +import { YjsEditorProps } from './withTYjs'; + +export type CursorEditorProps = YjsEditorProps & + Pick< + CursorEditor, + | 'awareness' + | 'cursorDataField' + | 'selectionStateField' + | 'sendCursorPosition' + | 'sendCursorData' + >; + +export const withTCursors = < + TCursorData extends Record, + V extends Value, + E extends TEditor & YjsEditorProps, + EE extends E & CursorEditorProps = E & CursorEditorProps, +>( + editor: E, + awareness: Awareness, + options?: WithCursorsOptions +) => withCursors(editor as any, awareness, options) as any as EE; diff --git a/packages/yjs/src/withTYHistory.ts b/packages/yjs/src/withTYHistory.ts new file mode 100644 index 0000000000..d62bca1b5b --- /dev/null +++ b/packages/yjs/src/withTYHistory.ts @@ -0,0 +1,21 @@ +import { withYHistory } from '@slate-yjs/core'; +import { + WithYHistoryOptions, + YHistoryEditor, + // eslint-disable-next-line import/no-unresolved +} from '@slate-yjs/core/dist/plugins/withYHistory'; +import { TEditor, Value } from '@udecode/plate-common'; + +import { YjsEditorProps } from './withTYjs'; + +export type YHistoryEditorProps = YjsEditorProps & + Pick; + +export const withTYHistory = < + V extends Value, + E extends TEditor & YjsEditorProps, + EE extends E & YHistoryEditorProps = E & YHistoryEditorProps, +>( + editor: E, + options?: WithYHistoryOptions +) => withYHistory(editor as any, options) as any as EE; diff --git a/packages/yjs/src/withTYjs.ts b/packages/yjs/src/withTYjs.ts new file mode 100644 index 0000000000..a01a7f3bab --- /dev/null +++ b/packages/yjs/src/withTYjs.ts @@ -0,0 +1,29 @@ +import { withYjs, YjsEditor } from '@slate-yjs/core'; +// eslint-disable-next-line import/no-unresolved +import { WithYjsOptions } from '@slate-yjs/core/dist/plugins/withYjs'; +import { TEditor, TOperation, Value } from '@udecode/plate-common'; +import * as Y from 'yjs'; + +export type YjsEditorProps = Pick< + YjsEditor, + | 'sharedRoot' + | 'localOrigin' + | 'positionStorageOrigin' + | 'applyRemoteEvents' + | 'flushLocalChanges' + | 'isLocalOrigin' + | 'connect' + | 'disconnect' +> & { + storeLocalChange: (op: TOperation) => void; +}; + +export const withTYjs = < + V extends Value, + E extends TEditor, + EE extends E & YjsEditorProps = E & YjsEditorProps, +>( + editor: E, + sharedRoot: Y.XmlText, + options?: WithYjsOptions +) => withYjs(editor as any, sharedRoot, options) as any as EE; diff --git a/packages/yjs/src/yjsStore.ts b/packages/yjs/src/yjsStore.ts new file mode 100644 index 0000000000..ea4fa01806 --- /dev/null +++ b/packages/yjs/src/yjsStore.ts @@ -0,0 +1,11 @@ +import { createStore } from '@udecode/zustood'; + +export const yjsStore = createStore('yjs')({ + isConnected: false, + isSynced: false, +}); + +export const yjsActions = yjsStore.set; +export const yjsSelectors = yjsStore.get; +export const useYjsSelectors = yjsStore.use; +export const useYjsStore = yjsStore.useStore; diff --git a/packages/yjs/tsconfig.json b/packages/yjs/tsconfig.json new file mode 100644 index 0000000000..425481e027 --- /dev/null +++ b/packages/yjs/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../config/tsconfig.build.json", + "compilerOptions": { + "declarationDir": "./dist", + "outDir": "./dist" + }, + "include": ["src"] +} diff --git a/yarn.lock b/yarn.lock index 99d8b8583a..040bde0175 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2893,6 +2893,30 @@ __metadata: languageName: node linkType: hard +"@hocuspocus/common@npm:^2.2.3": + version: 2.2.3 + resolution: "@hocuspocus/common@npm:2.2.3" + dependencies: + lib0: "npm:^0.2.47" + checksum: 1227bea1277d5205bf651db8772888d52a30b927941e7b01ef3faf8ab511478c1c1c36e7cf94b264d591f87993ba6a2b82dcbb081ca0b32d0480ca0ac6c78a55 + languageName: node + linkType: hard + +"@hocuspocus/provider@npm:^2.2.1": + version: 2.2.3 + resolution: "@hocuspocus/provider@npm:2.2.3" + dependencies: + "@hocuspocus/common": "npm:^2.2.3" + "@lifeomic/attempt": "npm:^3.0.2" + lib0: "npm:^0.2.47" + ws: "npm:^7.5.9" + peerDependencies: + y-protocols: ^1.0.5 + yjs: ^13.5.29 + checksum: dfccfded82e4a4a8ee7a4e062843dd398bbf7b3ad7f145225312a3b6f8a189af5339c7f7e3300c8ba4c5f9950654ab46f53b130f58b43dd68d17b630757a9011 + languageName: node + linkType: hard + "@humanwhocodes/config-array@npm:^0.11.10": version: 0.11.10 resolution: "@humanwhocodes/config-array@npm:0.11.10" @@ -3317,6 +3341,13 @@ __metadata: languageName: node linkType: hard +"@lifeomic/attempt@npm:^3.0.2": + version: 3.0.3 + resolution: "@lifeomic/attempt@npm:3.0.3" + checksum: e7c96552894c65ac61bde595ab7ca45ec6fbd08e2d760d8f0eb250d8a59b69713a9f6e207477bf713791b075c016d91aa589ab7d259de3ea942a02b19ecdc654 + languageName: node + linkType: hard + "@manypkg/find-root@npm:^1.1.0": version: 1.1.0 resolution: "@manypkg/find-root@npm:1.1.0" @@ -5540,6 +5571,18 @@ __metadata: languageName: node linkType: hard +"@slate-yjs/core@npm:^1.0.1": + version: 1.0.2 + resolution: "@slate-yjs/core@npm:1.0.2" + dependencies: + y-protocols: "npm:^1.0.5" + peerDependencies: + slate: ">=0.70.0" + yjs: ^13.5.29 + checksum: 87c43b81288741d28de595fee6084a94a3cf6c0c44d2bc519cf1612eba3460a77abf48d56134684fa8d829fbec6ddda829513178fe7633892fa861269189e75b + languageName: node + linkType: hard + "@swc/helpers@npm:0.5.1, @swc/helpers@npm:^0.5.0": version: 0.5.1 resolution: "@swc/helpers@npm:0.5.1" @@ -7346,6 +7389,23 @@ __metadata: languageName: unknown linkType: soft +"@udecode/plate-yjs@workspace:packages/yjs": + version: 0.0.0-use.local + resolution: "@udecode/plate-yjs@workspace:packages/yjs" + dependencies: + "@hocuspocus/provider": "npm:^2.2.1" + "@slate-yjs/core": "npm:^1.0.1" + "@udecode/plate-common": "npm:22.0.2" + yjs: "npm:^13.5.42" + peerDependencies: + react: ">=16.8.0" + react-dom: ">=16.8.0" + slate: ">=0.94.0" + slate-history: ">=0.93.0" + slate-react: ">=0.95.0" + languageName: unknown + linkType: soft + "@udecode/plate@workspace:^, @udecode/plate@workspace:packages/plate": version: 0.0.0-use.local resolution: "@udecode/plate@workspace:packages/plate" @@ -13809,6 +13869,13 @@ __metadata: languageName: node linkType: hard +"isomorphic.js@npm:^0.2.4": + version: 0.2.5 + resolution: "isomorphic.js@npm:0.2.5" + checksum: b6b1a40f9b9b1e3055934897ec7814af5995bf9b234f0cc6bb5aba89e6d59fdeb3391df46f6649b609d79334942acf78bb46c4d118dc9cbaf68a447c8a433907 + languageName: node + linkType: hard + "istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.0": version: 3.2.0 resolution: "istanbul-lib-coverage@npm:3.2.0" @@ -14887,6 +14954,18 @@ __metadata: languageName: node linkType: hard +"lib0@npm:^0.2.42, lib0@npm:^0.2.47, lib0@npm:^0.2.74": + version: 0.2.78 + resolution: "lib0@npm:0.2.78" + dependencies: + isomorphic.js: "npm:^0.2.4" + bin: + 0gentesthtml: bin/gentesthtml.js + 0serve: bin/0serve.js + checksum: f07c1833155ea9b64a60192c4ebb9edf55d9dff93289c9465aed98072f20b5206dcea2f84d53e86de1e5a320b44751079ec17fe8b78c1b3dd0bd8ac35e3c5f6a + languageName: node + linkType: hard + "liftoff@npm:^4.0.0": version: 4.0.0 resolution: "liftoff@npm:4.0.0" @@ -22913,6 +22992,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:^7.5.9": + version: 7.5.9 + resolution: "ws@npm:7.5.9" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 5a4f52060e2a65194c324e5506021c998444ef5740365f7f04a59da38d2da5229221f5ab6e7ceee0d5999d03c2c1c73164a5ebdafa481043edeae4c5c42f988c + languageName: node + linkType: hard + "ws@npm:^8.11.0": version: 8.13.0 resolution: "ws@npm:8.13.0" @@ -23115,6 +23209,15 @@ __metadata: languageName: node linkType: hard +"y-protocols@npm:^1.0.5": + version: 1.0.5 + resolution: "y-protocols@npm:1.0.5" + dependencies: + lib0: "npm:^0.2.42" + checksum: 2b1d3912f660c6134a85dc39fde1b8b954106aade917c57e94fc1f3bceb6a31d0759d280b4c9009427d17c8a16d81d8881e9a9a0c81260ef1ac4bc05cdd6cf4a + languageName: node + linkType: hard + "y18n@npm:^4.0.0": version: 4.0.3 resolution: "y18n@npm:4.0.3" @@ -23215,6 +23318,15 @@ __metadata: languageName: node linkType: hard +"yjs@npm:^13.5.42": + version: 13.6.7 + resolution: "yjs@npm:13.6.7" + dependencies: + lib0: "npm:^0.2.74" + checksum: 0b680d069b72cd9db118aa08f711ccd21b9f7cdd3e827449813bc5d57509c26b7683bc527e0fd48d77c34cda7ff31873d2df02626efb60e509fe42fa4c1953c6 + languageName: node + linkType: hard + "yn@npm:3.1.1": version: 3.1.1 resolution: "yn@npm:3.1.1"