Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/plate-floating-minor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@udecode/plate-floating': minor
---

Added `useVirtualRefState` hook for virtual reference positioning
11 changes: 11 additions & 0 deletions .changeset/plate-lint-minor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@udecode/plate-lint': minor
---

Added **Lint Plugin** - A comprehensive text validation system for Plate editors, similar to ESLint for document content.

- Added new lint plugin with customizable rule system
- Built-in rules for text case validation and find/replace functionality
- Real-time text validation with inline annotations and fix suggestions
- Popover UI for viewing and applying fixes
- Support for custom lint rules and parsers
11 changes: 11 additions & 0 deletions .changeset/slate-utils-minor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@udecode/slate-utils': minor
---

Added annotation utilities:
- `annotationToDecorations` - Convert single annotation to decorations
- `annotationsToDecorations` - Convert multiple annotations to decorations
- `getNextRange` - Find next range matching criteria
- `isSelectionInRange` - Check if selection intersects with range
- `parseNode` - Parse node content with custom parser options
- `replaceText` - Replace text at specified range
9 changes: 9 additions & 0 deletions apps/www/content/docs/components/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ Since Plate UI is not a component library, a changelog is maintained here.
Use the [CLI](https://platejs.org/docs/components/cli) to install the latest version of the components.


## December 2024 #17

### December 26 #17.1

https://github.com/udecode/plate/pull/3833/files
- Add `lint-leaf`, `lint-popover` - UI components for the new lint plugin
- `lint-leaf`: Renders text decorations with error/warning indicators for linted content
- `lint-popover`: Displays lint messages and fix suggestions in a popover interface

## November 2024 #16

### November 26 #16.9
Expand Down
97 changes: 97 additions & 0 deletions apps/www/content/docs/lint.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
---
title: Lint
description: Lint your document with custom rules.
---

<Callout>

This package is experimental. Expect breaking changes in future releases.

</Callout>

<ComponentPreview name="lint-demo" />

<PackageInfo>

The Lint feature allows you to enforce custom rules and configurations on your documents.

## Features

- Customizable linting config, plugins and rules with a similar API to ESLint
- Provides suggestions and fixes for each rule

</PackageInfo>

## Installation

```bash
npm install @udecode/plate-lint
```

## Usage

```tsx
import { resolveLintConfigs } from '@udecode/plate-lint/react';
import { emojiLintPlugin } from '@udecode/plate-lint/plugins';

const lintConfigs = resolveLintConfigs([
emojiLintPlugin.configs.all,
// ...otherConfigs
]);
```

- [resolveLintConfigs](/docs/components/resolve-lint-configs)

### Configuration

To configure the linting rules, you can use the `resolveLintConfigs` function to merge multiple configurations:

```tsx
const configs = [
emojiLintPlugin.configs.all,
{
languageOptions: {
parserOptions: {
minLength: 4,
},
},
settings: {
emojiMap: wordToEmojisMap,
maxSuggestions: 5,
},
},
];
```

### Plugins

#### EmojiLintPlugin

A plugin that provides linting rules for replacing text with emojis.

<APIOptions>
<APIItem name="emojiMap" type="Map<string, { emoji: string }[]>">
Map of words to their corresponding emoji suggestions.
</APIItem>
<APIItem name="maxSuggestions" type="number">
Maximum number of emoji suggestions to provide. Default: `8`
</APIItem>
</APIOptions>

## API

### resolveLintConfigs

Merges multiple lint configurations into a single set of resolved rules.

<APIParameters>
<APIItem name="configs" type="LintConfigArray">
Array of lint configurations to merge.
</APIItem>
</APIParameters>

<APIReturns>
<APIItem name="ResolvedLintRules">
Object containing the resolved lint rules.
</APIItem>
</APIReturns>
1 change: 1 addition & 0 deletions apps/www/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@
"@udecode/plate-layout": "workspace:^",
"@udecode/plate-line-height": "workspace:^",
"@udecode/plate-link": "workspace:^",
"@udecode/plate-lint": "workspace:^",
"@udecode/plate-list": "workspace:^",
"@udecode/plate-markdown": "workspace:^",
"@udecode/plate-media": "workspace:^",
Expand Down
38 changes: 38 additions & 0 deletions apps/www/public/r/styles/default/lint-demo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"dependencies": [
"@udecode/plate-lint",
"@udecode/plate-basic-marks",
"@udecode/plate-node-id"
],
"doc": {
"description": "Lint your document with emoji suggestions.",
"docs": [
{
"route": "/docs/lint",
"title": "Lint"
}
]
},
"files": [
{
"content": "'use client';\n\nimport { BasicMarksPlugin } from '@udecode/plate-basic-marks/react';\nimport { Plate, useEditorPlugin } from '@udecode/plate-common/react';\nimport {\n ExperimentalLintPlugin,\n caseLintPlugin,\n replaceLintPlugin,\n} from '@udecode/plate-lint/react';\nimport { NodeIdPlugin } from '@udecode/plate-node-id';\nimport { type Gemoji, gemoji } from 'gemoji';\n\nimport {\n useCreateEditor,\n viewComponents,\n} from '@/components/editor/use-create-editor';\nimport { Button } from '@/components/plate-ui/button';\nimport { Editor, EditorContainer } from '@/components/plate-ui/editor';\nimport { LintLeaf } from '@/components/plate-ui/lint-leaf';\nimport { LintPopover } from '@/components/plate-ui/lint-popover';\n\nexport default function LintEmojiDemo() {\n const editor = useCreateEditor({\n override: {\n components: viewComponents,\n },\n plugins: [\n ExperimentalLintPlugin.configure({\n render: {\n afterEditable: LintPopover,\n node: LintLeaf,\n },\n }),\n NodeIdPlugin,\n BasicMarksPlugin,\n ],\n value: [\n {\n children: [\n {\n text: \"I'm happy to see my cat and dog. I love them even when I'm sad.\",\n },\n ],\n type: 'p',\n },\n {\n children: [\n {\n text: 'I like to eat pizza and ice cream.',\n },\n ],\n type: 'p',\n },\n {\n children: [\n {\n text: 'hello world! this is a test. new sentence here. the cat is happy.',\n },\n ],\n type: 'p',\n },\n ],\n });\n\n return (\n <div className=\"mx-auto flex max-w-md flex-col items-center justify-center p-4\">\n <Plate editor={editor}>\n <EmojiPlateEditorContent />\n </Plate>\n </div>\n );\n}\n\nfunction EmojiPlateEditorContent() {\n const { api, editor } = useEditorPlugin(ExperimentalLintPlugin);\n\n const runFirst = () => {\n api.lint.run([\n {\n ...replaceLintPlugin.configs.all,\n targets: [{ id: editor.children[0].id as string }],\n },\n {\n settings: {\n replace: {\n replaceMap: emojiMap,\n },\n },\n },\n ]);\n };\n\n const runMax = () => {\n api.lint.run([\n replaceLintPlugin.configs.all,\n {\n settings: {\n replace: {\n parserOptions: {\n maxLength: 4,\n },\n replaceMap: emojiMap,\n },\n },\n },\n ]);\n };\n\n const runCase = () => {\n api.lint.run([\n caseLintPlugin.configs.all,\n {\n settings: {\n case: {\n ignoredWords: ['iPhone', 'iOS', 'iPad'],\n },\n },\n },\n ]);\n };\n\n const runBoth = () => {\n api.lint.run([\n replaceLintPlugin.configs.all,\n caseLintPlugin.configs.all,\n {\n settings: {\n case: {\n ignoredWords: ['iPhone', 'iOS', 'iPad'],\n },\n replace: {\n parserOptions: {\n maxLength: 4,\n },\n replaceMap: emojiMap,\n },\n },\n },\n ]);\n };\n\n return (\n <>\n <div className=\"mb-4 flex gap-4\">\n <Button size=\"md\" className=\"mb-4 px-4\" onClick={runFirst}>\n Emoji\n </Button>\n <Button size=\"md\" className=\"mb-4 px-4\" onClick={runMax}>\n Max Length\n </Button>\n <Button size=\"md\" className=\"mb-4 px-4\" onClick={runCase}>\n Case\n </Button>\n <Button size=\"md\" className=\"mb-4 px-4\" onClick={runBoth}>\n Both\n </Button>\n <Button size=\"md\" className=\"mb-4 px-4\" onClick={api.lint.reset}>\n Reset\n </Button>\n </div>\n <EditorContainer>\n <Editor variant=\"demo\" placeholder=\"Type...\" />\n </EditorContainer>\n </>\n );\n}\n\nconst excludeWords = new Set([\n 'a',\n 'an',\n 'and',\n 'are',\n 'as',\n 'at',\n 'be',\n 'but',\n 'by',\n 'for',\n 'from',\n 'if',\n 'in',\n 'into',\n 'is',\n 'it',\n 'no',\n 'not',\n 'of',\n 'on',\n 'or',\n 'such',\n 'that',\n 'the',\n 'their',\n 'then',\n 'there',\n 'these',\n 'they',\n 'this',\n 'to',\n 'was',\n 'was',\n 'will',\n 'with',\n]);\n\ntype WordSource = 'description' | 'exact_name' | 'name' | 'tag';\n\nfunction splitWords(text: string): string[] {\n return text.toLowerCase().split(/[^\\d_a-z]+/);\n}\n\nconst emojiMap = new Map<\n string,\n (Gemoji & { text: string; type: 'emoji' })[]\n>();\n\ngemoji.forEach((emoji) => {\n const wordSources = new Map<string, WordSource>();\n\n // Priority 1: Exact name matches (highest priority)\n emoji.names.forEach((name) => {\n const nameLower = name.toLowerCase();\n splitWords(name).forEach((word) => {\n if (!excludeWords.has(word)) {\n // If the name is exactly this word, it gets highest priority\n wordSources.set(word, word === nameLower ? 'exact_name' : 'name');\n }\n });\n });\n\n // Priority 3: Tags\n emoji.tags.forEach((tag) => {\n splitWords(tag).forEach((word) => {\n if (!excludeWords.has(word) && !wordSources.has(word)) {\n wordSources.set(word, 'tag');\n }\n });\n });\n\n // Priority 4: Description (lowest priority)\n if (emoji.description) {\n splitWords(emoji.description).forEach((word) => {\n if (!excludeWords.has(word) && !wordSources.has(word)) {\n wordSources.set(word, 'description');\n }\n });\n }\n\n wordSources.forEach((source, word) => {\n if (!emojiMap.has(word)) {\n emojiMap.set(word, []);\n }\n\n const emojis = emojiMap.get(word)!;\n\n const insertIndex = emojis.findIndex((e) => {\n const existingSource = getWordSource(e, word);\n\n return source > existingSource;\n });\n\n if (insertIndex === -1) {\n emojis.push({\n ...emoji,\n text: emoji.emoji,\n type: 'emoji',\n });\n } else {\n emojis.splice(insertIndex, 0, {\n ...emoji,\n text: emoji.emoji,\n type: 'emoji',\n });\n }\n });\n});\n\nfunction getWordSource(emoji: Gemoji, word: string): WordSource {\n // Check for exact name match first\n if (emoji.names.some((name) => name.toLowerCase() === word))\n return 'exact_name';\n // Then check for partial name matches\n if (emoji.names.some((name) => splitWords(name).includes(word)))\n return 'name';\n if (emoji.tags.some((tag) => splitWords(tag).includes(word))) return 'tag';\n\n return 'description';\n}\n",
"path": "example/lint-demo.tsx",
"target": "components/lint-demo.tsx",
"type": "registry:example"
},
{
"content": "'use client';\n\nimport type { Value } from '@udecode/plate-common';\n\nimport { withProps } from '@udecode/cn';\nimport { AIPlugin } from '@udecode/plate-ai/react';\nimport {\n BoldPlugin,\n CodePlugin,\n ItalicPlugin,\n StrikethroughPlugin,\n SubscriptPlugin,\n SuperscriptPlugin,\n UnderlinePlugin,\n} from '@udecode/plate-basic-marks/react';\nimport { BlockquotePlugin } from '@udecode/plate-block-quote/react';\nimport {\n CodeBlockPlugin,\n CodeLinePlugin,\n CodeSyntaxPlugin,\n} from '@udecode/plate-code-block/react';\nimport { CommentsPlugin } from '@udecode/plate-comments/react';\nimport {\n type CreatePlateEditorOptions,\n ParagraphPlugin,\n PlateLeaf,\n usePlateEditor,\n} from '@udecode/plate-common/react';\nimport { DatePlugin } from '@udecode/plate-date/react';\nimport { EmojiInputPlugin } from '@udecode/plate-emoji/react';\nimport { HEADING_KEYS } from '@udecode/plate-heading';\nimport { TocPlugin } from '@udecode/plate-heading/react';\nimport { HighlightPlugin } from '@udecode/plate-highlight/react';\nimport { HorizontalRulePlugin } from '@udecode/plate-horizontal-rule/react';\nimport { KbdPlugin } from '@udecode/plate-kbd/react';\nimport { ColumnItemPlugin, ColumnPlugin } from '@udecode/plate-layout/react';\nimport { LinkPlugin } from '@udecode/plate-link/react';\nimport {\n AudioPlugin,\n FilePlugin,\n ImagePlugin,\n MediaEmbedPlugin,\n PlaceholderPlugin,\n VideoPlugin,\n} from '@udecode/plate-media/react';\nimport {\n MentionInputPlugin,\n MentionPlugin,\n} from '@udecode/plate-mention/react';\nimport { SlashInputPlugin } from '@udecode/plate-slash-command/react';\nimport {\n TableCellHeaderPlugin,\n TableCellPlugin,\n TablePlugin,\n TableRowPlugin,\n} from '@udecode/plate-table/react';\nimport { TogglePlugin } from '@udecode/plate-toggle/react';\n\nimport { AILeaf } from '@/components/plate-ui/ai-leaf';\nimport { BlockquoteElement } from '@/components/plate-ui/blockquote-element';\nimport { CodeBlockElement } from '@/components/plate-ui/code-block-element';\nimport { CodeLeaf } from '@/components/plate-ui/code-leaf';\nimport { CodeLineElement } from '@/components/plate-ui/code-line-element';\nimport { CodeSyntaxLeaf } from '@/components/plate-ui/code-syntax-leaf';\nimport { ColumnElement } from '@/components/plate-ui/column-element';\nimport { ColumnGroupElement } from '@/components/plate-ui/column-group-element';\nimport { CommentLeaf } from '@/components/plate-ui/comment-leaf';\nimport { DateElement } from '@/components/plate-ui/date-element';\nimport { EmojiInputElement } from '@/components/plate-ui/emoji-input-element';\nimport { HeadingElement } from '@/components/plate-ui/heading-element';\nimport { HighlightLeaf } from '@/components/plate-ui/highlight-leaf';\nimport { HrElement } from '@/components/plate-ui/hr-element';\nimport { ImageElement } from '@/components/plate-ui/image-element';\nimport { KbdLeaf } from '@/components/plate-ui/kbd-leaf';\nimport { LinkElement } from '@/components/plate-ui/link-element';\nimport { MediaAudioElement } from '@/components/plate-ui/media-audio-element';\nimport { MediaEmbedElement } from '@/components/plate-ui/media-embed-element';\nimport { MediaFileElement } from '@/components/plate-ui/media-file-element';\nimport { MediaPlaceholderElement } from '@/components/plate-ui/media-placeholder-element';\nimport { MediaVideoElement } from '@/components/plate-ui/media-video-element';\nimport { MentionElement } from '@/components/plate-ui/mention-element';\nimport { MentionInputElement } from '@/components/plate-ui/mention-input-element';\nimport { ParagraphElement } from '@/components/plate-ui/paragraph-element';\nimport { withPlaceholders } from '@/components/plate-ui/placeholder';\nimport { SlashInputElement } from '@/components/plate-ui/slash-input-element';\nimport {\n TableCellElement,\n TableCellHeaderElement,\n} from '@/components/plate-ui/table-cell-element';\nimport { TableElement } from '@/components/plate-ui/table-element';\nimport { TableRowElement } from '@/components/plate-ui/table-row-element';\nimport { TocElement } from '@/components/plate-ui/toc-element';\nimport { ToggleElement } from '@/components/plate-ui/toggle-element';\nimport { withDraggables } from '@/components/plate-ui/with-draggables';\n\nimport { editorPlugins, viewPlugins } from './plugins/editor-plugins';\n\nexport const viewComponents = {\n [AudioPlugin.key]: MediaAudioElement,\n [BlockquotePlugin.key]: BlockquoteElement,\n [BoldPlugin.key]: withProps(PlateLeaf, { as: 'strong' }),\n [CodeBlockPlugin.key]: CodeBlockElement,\n [CodeLinePlugin.key]: CodeLineElement,\n [CodePlugin.key]: CodeLeaf,\n [CodeSyntaxPlugin.key]: CodeSyntaxLeaf,\n [ColumnItemPlugin.key]: ColumnElement,\n [ColumnPlugin.key]: ColumnGroupElement,\n [CommentsPlugin.key]: CommentLeaf,\n [DatePlugin.key]: DateElement,\n [FilePlugin.key]: MediaFileElement,\n [HEADING_KEYS.h1]: withProps(HeadingElement, { variant: 'h1' }),\n [HEADING_KEYS.h2]: withProps(HeadingElement, { variant: 'h2' }),\n [HEADING_KEYS.h3]: withProps(HeadingElement, { variant: 'h3' }),\n [HEADING_KEYS.h4]: withProps(HeadingElement, { variant: 'h4' }),\n [HEADING_KEYS.h5]: withProps(HeadingElement, { variant: 'h5' }),\n [HEADING_KEYS.h6]: withProps(HeadingElement, { variant: 'h6' }),\n [HighlightPlugin.key]: HighlightLeaf,\n [HorizontalRulePlugin.key]: HrElement,\n [ImagePlugin.key]: ImageElement,\n [ItalicPlugin.key]: withProps(PlateLeaf, { as: 'em' }),\n [KbdPlugin.key]: KbdLeaf,\n [LinkPlugin.key]: LinkElement,\n [MediaEmbedPlugin.key]: MediaEmbedElement,\n [MentionPlugin.key]: MentionElement,\n [ParagraphPlugin.key]: ParagraphElement,\n [PlaceholderPlugin.key]: MediaPlaceholderElement,\n [StrikethroughPlugin.key]: withProps(PlateLeaf, { as: 's' }),\n [SubscriptPlugin.key]: withProps(PlateLeaf, { as: 'sub' }),\n [SuperscriptPlugin.key]: withProps(PlateLeaf, { as: 'sup' }),\n [TableCellHeaderPlugin.key]: TableCellHeaderElement,\n [TableCellPlugin.key]: TableCellElement,\n [TablePlugin.key]: TableElement,\n [TableRowPlugin.key]: TableRowElement,\n [TocPlugin.key]: TocElement,\n [TogglePlugin.key]: ToggleElement,\n [UnderlinePlugin.key]: withProps(PlateLeaf, { as: 'u' }),\n [VideoPlugin.key]: MediaVideoElement,\n};\n\nexport const editorComponents = {\n ...viewComponents,\n [AIPlugin.key]: AILeaf,\n [EmojiInputPlugin.key]: EmojiInputElement,\n [MentionInputPlugin.key]: MentionInputElement,\n [SlashInputPlugin.key]: SlashInputElement,\n};\n\nexport const useCreateEditor = (\n {\n components,\n override,\n readOnly,\n ...options\n }: {\n components?: Record<string, any>;\n plugins?: any[];\n readOnly?: boolean;\n } & Omit<CreatePlateEditorOptions, 'plugins'> = {},\n deps: any[] = []\n) => {\n return usePlateEditor<Value, (typeof editorPlugins)[number]>(\n {\n override: {\n components: {\n ...(readOnly\n ? viewComponents\n : withPlaceholders(withDraggables(editorComponents))),\n ...components,\n },\n ...override,\n },\n plugins: (readOnly ? viewPlugins : editorPlugins) as any,\n ...options,\n },\n deps\n );\n};\n",
"path": "components/editor/use-create-editor.ts",
"target": "components/use-create-editor.ts",
"type": "registry:example"
}
],
"name": "lint-demo",
"registryDependencies": [
"editor",
"button",
"lint-leaf",
"lint-popover"
],
"type": "registry:example"
}
Loading