diff --git a/apps/www/content/docs/lint.mdx b/apps/www/content/docs/lint.mdx new file mode 100644 index 0000000000..14295a3a3c --- /dev/null +++ b/apps/www/content/docs/lint.mdx @@ -0,0 +1,97 @@ +--- +title: Lint +description: Lint your document with custom rules. +--- + + + +This package is experimental. Expect breaking changes in future releases. + + + + + + + +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 + + + +## 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. + + + +Map of words to their corresponding emoji suggestions. + + +Maximum number of emoji suggestions to provide. Default: `8` + + + +## API + +### resolveLintConfigs + +Merges multiple lint configurations into a single set of resolved rules. + + + + Array of lint configurations to merge. + + + + + + Object containing the resolved lint rules. + + diff --git a/apps/www/package.json b/apps/www/package.json index 4f73ba3fc1..cf3a2312e7 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -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:^", diff --git a/apps/www/public/r/styles/default/lint-demo.json b/apps/www/public/r/styles/default/lint-demo.json new file mode 100644 index 0000000000..e4c744b91a --- /dev/null +++ b/apps/www/public/r/styles/default/lint-demo.json @@ -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
\n \n \n \n
\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
\n \n \n \n \n \n
\n \n \n \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();\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;\n plugins?: any[];\n readOnly?: boolean;\n } & Omit = {},\n deps: any[] = []\n) => {\n return usePlateEditor(\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" +} \ No newline at end of file diff --git a/apps/www/public/r/styles/default/lint-emoji-demo.json b/apps/www/public/r/styles/default/lint-emoji-demo.json new file mode 100644 index 0000000000..2734ab6619 --- /dev/null +++ b/apps/www/public/r/styles/default/lint-emoji-demo.json @@ -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 replaceLintPlugin,\n} from '@udecode/plate-lint/react';\nimport { NodeIdPlugin } from '@udecode/plate-node-id';\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 });\n\n return (\n
\n \n \n \n
\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: [\n { id: editor.children[0].id as string },\n { id: editor.children[1].id as string },\n ],\n },\n {\n languageOptions: {\n parserOptions: {\n minLength: 4,\n },\n },\n targets: [{ id: editor.children[0].id as string }],\n },\n {\n settings: {\n emojiMap: emojiMap,\n maxSuggestions: 5,\n },\n },\n ]);\n };\n\n const runMax = () => {\n api.lint.run([\n replaceLintPlugin.configs.all,\n {\n languageOptions: {\n parserOptions: {\n maxLength: 4,\n },\n },\n settings: {\n emojiMap: emojiMap,\n },\n },\n ]);\n };\n\n const runAll = () => {\n api.lint.run([\n replaceLintPlugin.configs.all,\n {\n settings: {\n emojiMap: emojiMap,\n },\n },\n ]);\n };\n\n return (\n <>\n
\n \n \n \n \n
\n \n \n \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\ngemoji.forEach((emoji) => {\n const wordSources = new Map();\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(emoji);\n } else {\n emojis.splice(insertIndex, 0, emoji);\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-emoji-demo.tsx", + "target": "components/lint-emoji-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;\n plugins?: any[];\n readOnly?: boolean;\n } & Omit = {},\n deps: any[] = []\n) => {\n return usePlateEditor(\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-emoji-demo", + "registryDependencies": [ + "editor", + "button", + "lint-leaf", + "lint-popover" + ], + "type": "registry:example" +} \ No newline at end of file diff --git a/apps/www/public/r/styles/default/mode-toggle.json b/apps/www/public/r/styles/default/mode-toggle.json index a73e7d9523..2d77d9f78f 100644 --- a/apps/www/public/r/styles/default/mode-toggle.json +++ b/apps/www/public/r/styles/default/mode-toggle.json @@ -1,7 +1,7 @@ { "files": [ { - "content": "'use client';\n\nimport * as React from 'react';\n\nimport { MoonIcon, SunIcon } from 'lucide-react';\nimport { useTheme } from 'next-themes';\n\nimport { useMounted } from '@/hooks/use-mounted';\nimport { Button } from '@/components/plate-ui/button';\n\nexport default function ModeToggle() {\n const { setTheme, theme } = useTheme();\n\n const mounted = useMounted();\n\n return (\n setTheme(theme === 'dark' ? 'light' : 'dark')}\n >\n {mounted && theme === 'dark' ? (\n \n ) : (\n \n )}\n Toggle theme\n \n );\n}\n", + "content": "'use client';\n\nimport * as React from 'react';\n\nimport { MoonIcon, SunIcon } from 'lucide-react';\nimport { useTheme } from 'next-themes';\n\nimport { Button } from '@/components/plate-ui/button';\n\nexport default function ModeToggle() {\n const { setTheme, theme } = useTheme();\n\n return (\n setTheme(theme === 'dark' ? 'light' : 'dark')}\n >\n {theme === 'dark' ? (\n \n ) : (\n \n )}\n Toggle theme\n \n );\n}\n", "path": "example/mode-toggle.tsx", "target": "components/mode-toggle.tsx", "type": "registry:example" diff --git a/apps/www/src/__registry__/index.tsx b/apps/www/src/__registry__/index.tsx index 911bd6352e..ac75d2e57f 100644 --- a/apps/www/src/__registry__/index.tsx +++ b/apps/www/src/__registry__/index.tsx @@ -3733,6 +3733,26 @@ export const Index: Record = { subcategory: "", chunks: [] }, + "lint-demo": { + name: "lint-demo", + description: "", + type: "registry:example", + registryDependencies: ["editor","button","lint-leaf","lint-popover"], + files: [{ + path: "src/registry/default/example/lint-demo.tsx", + type: "registry:example", + target: "" + },{ + path: "src/registry/default/components/editor/use-create-editor.ts", + type: "registry:example", + target: "" + }], + component: React.lazy(() => import("@/registry/default/example/lint-demo.tsx")), + source: "", + category: "", + subcategory: "", + chunks: [] + }, "mode-toggle": { name: "mode-toggle", description: "", diff --git a/apps/www/src/config/docs-plugins.ts b/apps/www/src/config/docs-plugins.ts index 59e80a176d..e3229b6fc6 100644 --- a/apps/www/src/config/docs-plugins.ts +++ b/apps/www/src/config/docs-plugins.ts @@ -173,6 +173,12 @@ export const pluginsNavItems: SidebarNavItem[] = [ label: 'Element', title: 'Link', }, + { + description: 'Lint your document with custom rules.', + href: '/docs/lint', + label: 'New', + title: 'Lint', + }, { description: 'Organize nestable items in a bulleted or numbered list.', href: '/docs/list', diff --git a/apps/www/src/registry/default/example/lint-demo.tsx b/apps/www/src/registry/default/example/lint-demo.tsx new file mode 100644 index 0000000000..49a7b76eef --- /dev/null +++ b/apps/www/src/registry/default/example/lint-demo.tsx @@ -0,0 +1,285 @@ +'use client'; + +import { BasicMarksPlugin } from '@udecode/plate-basic-marks/react'; +import { Plate, useEditorPlugin } from '@udecode/plate-common/react'; +import { + ExperimentalLintPlugin, + caseLintPlugin, + replaceLintPlugin, +} from '@udecode/plate-lint/react'; +import { NodeIdPlugin } from '@udecode/plate-node-id'; +import { type Gemoji, gemoji } from 'gemoji'; + +import { + useCreateEditor, + viewComponents, +} from '@/registry/default/components/editor/use-create-editor'; +import { Button } from '@/registry/default/plate-ui/button'; +import { Editor, EditorContainer } from '@/registry/default/plate-ui/editor'; +import { LintLeaf } from '@/registry/default/plate-ui/lint-leaf'; +import { LintPopover } from '@/registry/default/plate-ui/lint-popover'; + +export default function LintEmojiDemo() { + const editor = useCreateEditor({ + override: { + components: viewComponents, + }, + plugins: [ + ExperimentalLintPlugin.configure({ + render: { + afterEditable: LintPopover, + node: LintLeaf, + }, + }), + NodeIdPlugin, + BasicMarksPlugin, + ], + value: [ + { + children: [ + { + text: "I'm happy to see my cat and dog. I love them even when I'm sad.", + }, + ], + type: 'p', + }, + { + children: [ + { + text: 'I like to eat pizza and ice cream.', + }, + ], + type: 'p', + }, + { + children: [ + { + text: 'hello world! this is a test. new sentence here. the cat is happy.', + }, + ], + type: 'p', + }, + ], + }); + + return ( +
+ + + +
+ ); +} + +function EmojiPlateEditorContent() { + const { api, editor } = useEditorPlugin(ExperimentalLintPlugin); + + const runFirst = () => { + api.lint.run([ + { + ...replaceLintPlugin.configs.all, + targets: [{ id: editor.children[0].id as string }], + }, + { + settings: { + replace: { + replaceMap: emojiMap, + }, + }, + }, + ]); + }; + + const runMax = () => { + api.lint.run([ + replaceLintPlugin.configs.all, + { + settings: { + replace: { + parserOptions: { + maxLength: 4, + }, + replaceMap: emojiMap, + }, + }, + }, + ]); + }; + + const runCase = () => { + api.lint.run([ + caseLintPlugin.configs.all, + { + settings: { + case: { + ignoredWords: ['iPhone', 'iOS', 'iPad'], + }, + }, + }, + ]); + }; + + const runBoth = () => { + api.lint.run([ + replaceLintPlugin.configs.all, + caseLintPlugin.configs.all, + { + settings: { + case: { + ignoredWords: ['iPhone', 'iOS', 'iPad'], + }, + replace: { + replaceMap: emojiMap, + }, + }, + }, + ]); + }; + + return ( + <> +
+ + + + + +
+ + + + + ); +} + +const excludeWords = new Set([ + 'a', + 'an', + 'and', + 'are', + 'as', + 'at', + 'be', + 'but', + 'by', + 'for', + 'from', + 'if', + 'in', + 'into', + 'is', + 'it', + 'no', + 'not', + 'of', + 'on', + 'or', + 'such', + 'that', + 'the', + 'their', + 'then', + 'there', + 'these', + 'they', + 'this', + 'to', + 'was', + 'was', + 'will', + 'with', +]); + +type WordSource = 'description' | 'exact_name' | 'name' | 'tag'; + +function splitWords(text: string): string[] { + return text.toLowerCase().split(/[^\d_a-z]+/); +} + +const emojiMap = new Map< + string, + (Gemoji & { text: string; type: 'emoji' })[] +>(); + +gemoji.forEach((emoji) => { + const wordSources = new Map(); + + // Priority 1: Exact name matches (highest priority) + emoji.names.forEach((name) => { + const nameLower = name.toLowerCase(); + splitWords(name).forEach((word) => { + if (!excludeWords.has(word)) { + // If the name is exactly this word, it gets highest priority + wordSources.set(word, word === nameLower ? 'exact_name' : 'name'); + } + }); + }); + + // Priority 3: Tags + emoji.tags.forEach((tag) => { + splitWords(tag).forEach((word) => { + if (!excludeWords.has(word) && !wordSources.has(word)) { + wordSources.set(word, 'tag'); + } + }); + }); + + // Priority 4: Description (lowest priority) + if (emoji.description) { + splitWords(emoji.description).forEach((word) => { + if (!excludeWords.has(word) && !wordSources.has(word)) { + wordSources.set(word, 'description'); + } + }); + } + + wordSources.forEach((source, word) => { + if (!emojiMap.has(word)) { + emojiMap.set(word, []); + } + + const emojis = emojiMap.get(word)!; + + const insertIndex = emojis.findIndex((e) => { + const existingSource = getWordSource(e, word); + + return source > existingSource; + }); + + if (insertIndex === -1) { + emojis.push({ + ...emoji, + text: emoji.emoji, + type: 'emoji', + }); + } else { + emojis.splice(insertIndex, 0, { + ...emoji, + text: emoji.emoji, + type: 'emoji', + }); + } + }); +}); + +function getWordSource(emoji: Gemoji, word: string): WordSource { + // Check for exact name match first + if (emoji.names.some((name) => name.toLowerCase() === word)) + return 'exact_name'; + // Then check for partial name matches + if (emoji.names.some((name) => splitWords(name).includes(word))) + return 'name'; + if (emoji.tags.some((tag) => splitWords(tag).includes(word))) return 'tag'; + + return 'description'; +} diff --git a/apps/www/src/registry/default/example/mode-toggle.tsx b/apps/www/src/registry/default/example/mode-toggle.tsx index a60fa954b9..f9d8af150f 100644 --- a/apps/www/src/registry/default/example/mode-toggle.tsx +++ b/apps/www/src/registry/default/example/mode-toggle.tsx @@ -5,14 +5,11 @@ import * as React from 'react'; import { MoonIcon, SunIcon } from 'lucide-react'; import { useTheme } from 'next-themes'; -import { useMounted } from '@/registry/default/hooks/use-mounted'; import { Button } from '@/registry/default/plate-ui/button'; export default function ModeToggle() { const { setTheme, theme } = useTheme(); - const mounted = useMounted(); - return (