diff --git a/lang/en/fieldtypes.php b/lang/en/fieldtypes.php index 3b75a19f65e..a78ffa23728 100644 --- a/lang/en/fieldtypes.php +++ b/lang/en/fieldtypes.php @@ -24,6 +24,14 @@ 'assets.dynamic_folder_pending_save' => 'This field will be available after saving.', 'assets.title' => 'Assets', 'asset_folders.config.container' => 'Choose which asset container to use for this field.', + 'autocomplete_editor.config.buttons' => 'Choose which buttons to show in the toolbar.', + 'autocomplete_editor.config.enable_line_breaks' => 'Allows adding line breaks to the content.', + 'autocomplete_editor.config.inline' => 'Disable all block-level elements like headings and lists.', + 'autocomplete_editor.config.markdown' => 'Allow formatting, stored as Markdown. When disabled, the content is plain text and mentions.', + 'autocomplete_editor.config.options' => 'Set the values and their optional labels available for autocompletion.', + 'autocomplete_editor.config.placeholder' => 'Text to display when the field is empty.', + 'autocomplete_editor.config.trigger' => 'The character that triggers the autocomplete suggestions.', + 'autocomplete_editor.title' => 'Autocomplete Editor', 'bard.config.allow_source' => 'Enable to view the HTML source code while writing.', 'bard.config.always_show_set_button' => 'Enable to always show the "Add Set" button.', 'bard.config.buttons' => 'Choose which buttons to show in the toolbar.', diff --git a/package-lock.json b/package-lock.json index 0ce01a34e0e..2bb7cb310e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,35 +14,38 @@ "@internationalized/date": "^3.7.0", "@shopify/draggable": "^1.0.0-beta.12", "@simplewebauthn/browser": "^13.2.2", - "@tiptap/core": "^3.0.0", - "@tiptap/extension-blockquote": "^3.0.0", - "@tiptap/extension-bold": "^3.0.0", - "@tiptap/extension-bullet-list": "^3.0.0", - "@tiptap/extension-character-count": "^3.0.0", - "@tiptap/extension-code": "^3.0.0", - "@tiptap/extension-code-block": "^3.0.0", - "@tiptap/extension-code-block-lowlight": "^3.0.0", - "@tiptap/extension-document": "^3.0.0", - "@tiptap/extension-hard-break": "^3.0.0", - "@tiptap/extension-heading": "^3.0.0", - "@tiptap/extension-history": "^3.0.0", - "@tiptap/extension-horizontal-rule": "^3.0.0", - "@tiptap/extension-italic": "^3.0.0", - "@tiptap/extension-list": "^3.0.0", - "@tiptap/extension-list-item": "^3.0.0", - "@tiptap/extension-ordered-list": "^3.0.0", - "@tiptap/extension-paragraph": "^3.0.0", - "@tiptap/extension-strike": "^3.0.0", - "@tiptap/extension-subscript": "^3.0.0", - "@tiptap/extension-superscript": "^3.0.0", - "@tiptap/extension-table": "^3.0.0", - "@tiptap/extension-text": "^3.0.0", - "@tiptap/extension-text-align": "^3.0.0", - "@tiptap/extension-typography": "^3.0.0", - "@tiptap/extension-underline": "^3.0.0", - "@tiptap/extensions": "^3.0.0", - "@tiptap/pm": "^3.0.0", - "@tiptap/vue-3": "^3.0.0", + "@tiptap/core": "^3.28.0", + "@tiptap/extension-blockquote": "^3.28.0", + "@tiptap/extension-bold": "^3.28.0", + "@tiptap/extension-bullet-list": "^3.28.0", + "@tiptap/extension-character-count": "^3.28.0", + "@tiptap/extension-code": "^3.28.0", + "@tiptap/extension-code-block": "^3.28.0", + "@tiptap/extension-code-block-lowlight": "^3.28.0", + "@tiptap/extension-document": "^3.28.0", + "@tiptap/extension-hard-break": "^3.28.0", + "@tiptap/extension-heading": "^3.28.0", + "@tiptap/extension-history": "^3.28.0", + "@tiptap/extension-horizontal-rule": "^3.28.0", + "@tiptap/extension-italic": "^3.28.0", + "@tiptap/extension-list": "^3.28.0", + "@tiptap/extension-list-item": "^3.28.0", + "@tiptap/extension-mention": "^3.28.0", + "@tiptap/extension-ordered-list": "^3.28.0", + "@tiptap/extension-paragraph": "^3.28.0", + "@tiptap/extension-strike": "^3.28.0", + "@tiptap/extension-subscript": "^3.28.0", + "@tiptap/extension-superscript": "^3.28.0", + "@tiptap/extension-table": "^3.28.0", + "@tiptap/extension-text": "^3.28.0", + "@tiptap/extension-text-align": "^3.28.0", + "@tiptap/extension-typography": "^3.28.0", + "@tiptap/extension-underline": "^3.28.0", + "@tiptap/extensions": "^3.28.0", + "@tiptap/markdown": "^3.29.2", + "@tiptap/pm": "^3.28.0", + "@tiptap/suggestion": "^3.28.0", + "@tiptap/vue-3": "^3.28.0", "alpinejs": "^3.1.1", "aria-hidden": "^1.2.6", "autosize": "~6.0.1", @@ -3253,6 +3256,21 @@ "@tiptap/extension-list": "3.31.3" } }, + "node_modules/@tiptap/extension-mention": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-mention/-/extension-mention-3.31.3.tgz", + "integrity": "sha512-ygOPc3nQijSMUTbEdMeng12P1szk3znubA9xNi84f31VnR18VjmU0we8SIOSSVwDfm1GppSjRER2qHmX/wGIIw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3", + "@tiptap/suggestion": "3.31.3" + } + }, "node_modules/@tiptap/extension-ordered-list": { "version": "3.31.3", "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.31.3.tgz", @@ -3400,6 +3418,35 @@ "@tiptap/pm": "3.31.3" } }, + "node_modules/@tiptap/markdown": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/markdown/-/markdown-3.31.3.tgz", + "integrity": "sha512-rBbYSxasseUoaBo15C8Yoaqafo7mJJzxwAwV9Z1OpLBvOroW9nQ0EbOdqaSYRw3/d9pP71B/3LNSzKyfyy+dpQ==", + "license": "MIT", + "dependencies": { + "marked": "^17.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/markdown/node_modules/marked": { + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.6.tgz", + "integrity": "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/@tiptap/pm": { "version": "3.31.3", "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.31.3.tgz", @@ -3425,6 +3472,21 @@ "url": "https://github.com/sponsors/ueberdosis" } }, + "node_modules/@tiptap/suggestion": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.31.3.tgz", + "integrity": "sha512-z1OQ/Yx6seMZehi1AcmNkyJ8qkHQzybArmCyRdmreS4YL9C4bPMBe5lbMfFsArYs+KD5JyHwps5j7J/YE5BIuw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, "node_modules/@tiptap/vue-3": { "version": "3.31.3", "resolved": "https://registry.npmjs.org/@tiptap/vue-3/-/vue-3-3.31.3.tgz", diff --git a/package.json b/package.json index fb151611697..e3e11219342 100644 --- a/package.json +++ b/package.json @@ -27,35 +27,38 @@ "@internationalized/date": "^3.7.0", "@shopify/draggable": "^1.0.0-beta.12", "@simplewebauthn/browser": "^13.2.2", - "@tiptap/core": "^3.0.0", - "@tiptap/extension-blockquote": "^3.0.0", - "@tiptap/extension-bold": "^3.0.0", - "@tiptap/extension-bullet-list": "^3.0.0", - "@tiptap/extension-character-count": "^3.0.0", - "@tiptap/extension-code": "^3.0.0", - "@tiptap/extension-code-block": "^3.0.0", - "@tiptap/extension-code-block-lowlight": "^3.0.0", - "@tiptap/extension-document": "^3.0.0", - "@tiptap/extension-hard-break": "^3.0.0", - "@tiptap/extension-heading": "^3.0.0", - "@tiptap/extension-history": "^3.0.0", - "@tiptap/extension-horizontal-rule": "^3.0.0", - "@tiptap/extension-italic": "^3.0.0", - "@tiptap/extension-list": "^3.0.0", - "@tiptap/extension-list-item": "^3.0.0", - "@tiptap/extension-ordered-list": "^3.0.0", - "@tiptap/extension-paragraph": "^3.0.0", - "@tiptap/extension-strike": "^3.0.0", - "@tiptap/extension-subscript": "^3.0.0", - "@tiptap/extension-superscript": "^3.0.0", - "@tiptap/extension-table": "^3.0.0", - "@tiptap/extension-text": "^3.0.0", - "@tiptap/extension-text-align": "^3.0.0", - "@tiptap/extension-typography": "^3.0.0", - "@tiptap/extension-underline": "^3.0.0", - "@tiptap/extensions": "^3.0.0", - "@tiptap/pm": "^3.0.0", - "@tiptap/vue-3": "^3.0.0", + "@tiptap/core": "^3.28.0", + "@tiptap/extension-blockquote": "^3.28.0", + "@tiptap/extension-bold": "^3.28.0", + "@tiptap/extension-bullet-list": "^3.28.0", + "@tiptap/extension-character-count": "^3.28.0", + "@tiptap/extension-code": "^3.28.0", + "@tiptap/extension-code-block": "^3.28.0", + "@tiptap/extension-code-block-lowlight": "^3.28.0", + "@tiptap/extension-document": "^3.28.0", + "@tiptap/extension-hard-break": "^3.28.0", + "@tiptap/extension-heading": "^3.28.0", + "@tiptap/extension-history": "^3.28.0", + "@tiptap/extension-horizontal-rule": "^3.28.0", + "@tiptap/extension-italic": "^3.28.0", + "@tiptap/extension-list": "^3.28.0", + "@tiptap/extension-list-item": "^3.28.0", + "@tiptap/extension-mention": "^3.28.0", + "@tiptap/extension-ordered-list": "^3.28.0", + "@tiptap/extension-paragraph": "^3.28.0", + "@tiptap/extension-strike": "^3.28.0", + "@tiptap/extension-subscript": "^3.28.0", + "@tiptap/extension-superscript": "^3.28.0", + "@tiptap/extension-table": "^3.28.0", + "@tiptap/extension-text": "^3.28.0", + "@tiptap/extension-text-align": "^3.28.0", + "@tiptap/extension-typography": "^3.28.0", + "@tiptap/extension-underline": "^3.28.0", + "@tiptap/extensions": "^3.28.0", + "@tiptap/markdown": "^3.29.2", + "@tiptap/pm": "^3.28.0", + "@tiptap/suggestion": "^3.28.0", + "@tiptap/vue-3": "^3.28.0", "alpinejs": "^3.1.1", "aria-hidden": "^1.2.6", "autosize": "~6.0.1", diff --git a/packages/cms/src/ui.js b/packages/cms/src/ui.js index 3f5150061c9..d8c18b5057e 100644 --- a/packages/cms/src/ui.js +++ b/packages/cms/src/ui.js @@ -1,6 +1,7 @@ export const { Alert, AuthCard, + AutocompleteEditor, Avatar, Badge, Button, diff --git a/resources/js/bootstrap/cms/ui.js b/resources/js/bootstrap/cms/ui.js index 68653dd3746..80635af5242 100644 --- a/resources/js/bootstrap/cms/ui.js +++ b/resources/js/bootstrap/cms/ui.js @@ -1,6 +1,7 @@ export { Alert, AuthCard, + AutocompleteEditor, Avatar, Badge, Button, diff --git a/resources/js/bootstrap/fieldtypes.js b/resources/js/bootstrap/fieldtypes.js index 414809b79b7..d123833942d 100644 --- a/resources/js/bootstrap/fieldtypes.js +++ b/resources/js/bootstrap/fieldtypes.js @@ -9,6 +9,7 @@ import ArrayFieldtype from '../components/fieldtypes/ArrayFieldtype.vue'; import AssetsFieldtype from '../components/fieldtypes/assets/AssetsFieldtype.vue'; import AssetsIndexFieldtype from '../components/fieldtypes/assets/AssetsIndexFieldtype.vue'; import AssetFolderFieldtype from '../components/fieldtypes/AssetFolderFieldtype.vue'; +import AutocompleteEditorFieldtype from '../components/fieldtypes/AutocompleteEditorFieldtype.vue'; import BardButtonsSettingFieldtype from '../components/fieldtypes/bard/BardButtonsSettingFieldtype.vue'; import BlueprintsFieldtype from '../components/fieldtypes/BlueprintsFieldtype.vue'; import ButtonGroupFieldtype from '../components/fieldtypes/ButtonGroupFieldtype.vue'; @@ -72,6 +73,7 @@ export default function registerFieldtypes(app) { app.component('assets-fieldtype', AssetsFieldtype); app.component('assets-fieldtype-index', AssetsIndexFieldtype); app.component('asset_folder-fieldtype', AssetFolderFieldtype); + app.component('autocomplete_editor-fieldtype', AutocompleteEditorFieldtype); app.component( 'bard-fieldtype', defineAsyncComponent(() => import('../components/fieldtypes/bard/BardFieldtype.vue')), diff --git a/resources/js/components/fieldtypes/AutocompleteEditorFieldtype.vue b/resources/js/components/fieldtypes/AutocompleteEditorFieldtype.vue new file mode 100644 index 00000000000..c90dc4e90bc --- /dev/null +++ b/resources/js/components/fieldtypes/AutocompleteEditorFieldtype.vue @@ -0,0 +1,55 @@ + + + diff --git a/resources/js/components/ui/AutocompleteEditor/AutocompleteEditor.vue b/resources/js/components/ui/AutocompleteEditor/AutocompleteEditor.vue new file mode 100644 index 00000000000..494654dd6fd --- /dev/null +++ b/resources/js/components/ui/AutocompleteEditor/AutocompleteEditor.vue @@ -0,0 +1,356 @@ + + + + + diff --git a/resources/js/components/ui/AutocompleteEditor/AutocompleteEditorToolbar.vue b/resources/js/components/ui/AutocompleteEditor/AutocompleteEditorToolbar.vue new file mode 100644 index 00000000000..b48a6f708ab --- /dev/null +++ b/resources/js/components/ui/AutocompleteEditor/AutocompleteEditorToolbar.vue @@ -0,0 +1,32 @@ + + + diff --git a/resources/js/components/ui/AutocompleteEditor/EditorToolbarButton.vue b/resources/js/components/ui/AutocompleteEditor/EditorToolbarButton.vue new file mode 100644 index 00000000000..b7781242c40 --- /dev/null +++ b/resources/js/components/ui/AutocompleteEditor/EditorToolbarButton.vue @@ -0,0 +1,25 @@ + + + diff --git a/resources/js/components/ui/AutocompleteEditor/MentionBadge.vue b/resources/js/components/ui/AutocompleteEditor/MentionBadge.vue new file mode 100644 index 00000000000..a0068ce383e --- /dev/null +++ b/resources/js/components/ui/AutocompleteEditor/MentionBadge.vue @@ -0,0 +1,24 @@ + + + diff --git a/resources/js/components/ui/AutocompleteEditor/SuggestionList.vue b/resources/js/components/ui/AutocompleteEditor/SuggestionList.vue new file mode 100644 index 00000000000..828729d7595 --- /dev/null +++ b/resources/js/components/ui/AutocompleteEditor/SuggestionList.vue @@ -0,0 +1,69 @@ + + + diff --git a/resources/js/components/ui/AutocompleteEditor/extensions/Autocomplete.js b/resources/js/components/ui/AutocompleteEditor/extensions/Autocomplete.js new file mode 100644 index 00000000000..0b7bf9b1b51 --- /dev/null +++ b/resources/js/components/ui/AutocompleteEditor/extensions/Autocomplete.js @@ -0,0 +1,80 @@ +import Mention from '@tiptap/extension-mention'; +import { VueNodeViewRenderer, VueRenderer } from '@tiptap/vue-3'; +import MentionBadge from '../MentionBadge.vue'; +import SuggestionList from '../SuggestionList.vue'; + +function renderSuggestion() { + let component; + let unmount; + + return { + onStart(props) { + component = new VueRenderer(SuggestionList, { + props, + editor: props.editor, + }); + + unmount = props.mount(component.element); + }, + + onUpdate(props) { + component?.updateProps(props); + }, + + onKeyDown(props) { + if (props.event.key === 'Escape') { + unmount?.(); + return true; + } + + return component?.ref?.onKeyDown(props) ?? false; + }, + + onExit() { + unmount?.(); + unmount = undefined; + component?.destroy(); + component = undefined; + }, + }; +} + +export const Autocomplete = Mention.extend({ + name: 'mention', + + addNodeView() { + return VueNodeViewRenderer(MentionBadge); + }, + + addOptions() { + return { + ...this.parent?.(), + HTMLAttributes: { class: 'autocomplete-mention' }, + isMissing: () => false, + renderText({ node }) { + return node.attrs.label ?? node.attrs.value ?? ''; + }, + renderHTML({ options, node }) { + return ['span', options.HTMLAttributes, node.attrs.label ?? node.attrs.value ?? '']; + }, + suggestion: { + render: renderSuggestion, + }, + }; + }, + + addAttributes() { + return { + value: { + default: null, + parseHTML: (element) => element.getAttribute('data-value'), + renderHTML: (attributes) => (attributes.value ? { 'data-value': attributes.value } : {}), + }, + label: { + default: null, + parseHTML: (element) => element.getAttribute('data-label'), + renderHTML: (attributes) => (attributes.label ? { 'data-label': attributes.label } : {}), + }, + }; + }, +}); diff --git a/resources/js/components/ui/AutocompleteEditor/extensions/Document.js b/resources/js/components/ui/AutocompleteEditor/extensions/Document.js new file mode 100644 index 00000000000..ef39c616da5 --- /dev/null +++ b/resources/js/components/ui/AutocompleteEditor/extensions/Document.js @@ -0,0 +1,7 @@ +import Document from '@tiptap/extension-document'; + +export const DocumentBlock = Document; + +export const DocumentInline = Document.extend({ + content: 'paragraph', +}); diff --git a/resources/js/components/ui/AutocompleteEditor/markdown.js b/resources/js/components/ui/AutocompleteEditor/markdown.js new file mode 100644 index 00000000000..eb8c99069d4 --- /dev/null +++ b/resources/js/components/ui/AutocompleteEditor/markdown.js @@ -0,0 +1,127 @@ +import { MarkdownManager } from '@tiptap/markdown'; +import Document from '@tiptap/extension-document'; +import Paragraph from '@tiptap/extension-paragraph'; +import Text from '@tiptap/extension-text'; +import Bold from '@tiptap/extension-bold'; +import Italic from '@tiptap/extension-italic'; +import Heading from '@tiptap/extension-heading'; +import HardBreak from '@tiptap/extension-hard-break'; +import { BulletList, OrderedList, ListItem } from '@tiptap/extension-list'; +import { Autocomplete } from './extensions/Autocomplete'; +import { MENTION_TOKEN } from './mentionToken'; + +// The serializer escapes inline syntax but not what only has meaning at the +// start of a line, so plain text like "- x" or "# x" would reload as a list or +// heading. `*` and `_` are already escaped by the serializer. +function escapeBlockMarkers(line, isFirstLine) { + const indent = line.match(/^[ \t]*/)[0]; + + // Four columns of indentation is a code block, which the schema drops. + // Leading spaces are otherwise kept by the parser, so only this needs + // an entity, which paragraph parsing turns back into the whitespace. + if (indent.replace(/\t/g, ' ').length >= 4) { + return (indent[0] === '\t' ? ' ' : ' ') + line.slice(1); + } + + const rest = line.slice(indent.length); + const orderedList = isFirstLine ? /^\d{1,9}[.)](?=[ \t]|$)/ : /^1[.)](?=[ \t]|$)/; + + const needsEscape = + /^#{1,6}(?=[ \t]|$)/.test(rest) || + /^[-+](?=[ \t]|$)/.test(rest) || + /^-[-\t ]*$/.test(rest) || + /^=+[ \t]*$/.test(rest) || + /^(?=[-:| \t]*\|)[-:| \t]*-[-:| \t]*$/.test(rest) || + orderedList.test(rest); + + if (!needsEscape) return line; + + // Escaping the first character of a numbered marker would leave the + // digits as plain text, so the delimiter is the part that gets escaped. + return indent + rest.replace(/^(\d{1,9})?(.)/, (_, digits = '', char) => `${digits}\\${char}`); +} + +const BlockSafeParagraph = Paragraph.extend({ + parseMarkdown(token, helpers) { + const tokens = (token.tokens || []).map((inline, i, all) => { + const startsLine = i === 0 || all[i - 1].type === 'br'; + + if (!startsLine || inline.type !== 'text') return inline; + + return { ...inline, text: inline.text.replace(/^&#(32|9);/, (_, code) => (code === '9' ? '\t' : ' ')) }; + }); + + return Paragraph.config.parseMarkdown({ ...token, tokens }, helpers); + }, + + renderMarkdown(node, helpers, context) { + const markdown = Paragraph.config.renderMarkdown(node, helpers, context); + + return markdown + .split('\n') + .map((line, i) => escapeBlockMarkers(line, i === 0)) + .join('\n'); + }, +}); + +// Markdown is how the fieldtype stores its value, not something the editor +// itself does, so this is declared here rather than on the shared extension. +const MarkdownAutocomplete = Autocomplete.extend({ + markdownTokenName: 'mention', + + markdownTokenizer: { + name: 'mention', + level: 'inline', + start: (src) => src.indexOf('[['), + tokenize: (src) => { + const match = MENTION_TOKEN.exec(src); + + if (!match) return; + + return { type: 'mention', raw: match[0], value: match[1] }; + }, + }, + + parseMarkdown: (token) => ({ type: 'mention', attrs: { value: token.value } }), + + // Brackets an author typed literally are escaped by the text serializer, + // so a mention rendering them raw is unambiguously ours. + renderMarkdown: (node) => `[[ ${node.attrs.value} ]]`, +}); + +// The default two-trailing-space hard break doesn't survive whitespace +// stripping (editors, linters, pre-commit hooks). A trailing backslash does. +const BackslashHardBreak = HardBreak.extend({ + renderMarkdown: () => '\\\n', +}); + +// Uses the full extension set (all heading levels, both list types) rather +// than the per-field configured subset, so a document containing content +// from a since-disabled button still round-trips. +const manager = new MarkdownManager({ + extensions: [ + Document, + BlockSafeParagraph, + Text, + Bold, + Italic, + Heading, + BackslashHardBreak, + BulletList, + OrderedList, + ListItem, + MarkdownAutocomplete, + ], +}); + +export function contentToMarkdown(content) { + return manager.serialize({ type: 'doc', content }); +} + +export function markdownToContent(markdown) { + // The markdown lexer throws on anything that isn't a string, which would + // take down the whole publish form. Treat unexpected values as empty. + if (typeof markdown !== 'string') return []; + + return manager.parse(markdown).content; +} diff --git a/resources/js/components/ui/AutocompleteEditor/mentionToken.js b/resources/js/components/ui/AutocompleteEditor/mentionToken.js new file mode 100644 index 00000000000..59d646bba53 --- /dev/null +++ b/resources/js/components/ui/AutocompleteEditor/mentionToken.js @@ -0,0 +1,7 @@ +export const MENTION_TOKEN = /^\[\[\s*([\w.-]+)\s*\]\]/; + +export function isValidMentionValue(value) { + if (typeof value !== 'string') return false; + + return MENTION_TOKEN.exec(`[[ ${value} ]]`)?.[1] === value; +} diff --git a/resources/js/components/ui/AutocompleteEditor/text.js b/resources/js/components/ui/AutocompleteEditor/text.js new file mode 100644 index 00000000000..c7fbf60c1a1 --- /dev/null +++ b/resources/js/components/ui/AutocompleteEditor/text.js @@ -0,0 +1,136 @@ +import { MENTION_TOKEN } from './mentionToken'; + +// Plain text is stored verbatim apart from two escapes, so that it never holds +// a backslash the author didn't type unless it's needed to disambiguate: +// +// - A literal `[[` is stored as `\[[`, so it can't be mistaken for a mention. +// - A run of backslashes directly before a `[[` (literal or mention) is doubled, +// so its parity says whether the `[[` is escaped. +// +// A literal `[` directly before a mention needs no escape. The decoder only +// treats `[[` as a mention when a token starts there, otherwise it emits a +// single `[` and looks again from the next character. + +function flatten(content) { + const items = []; + + (content ?? []).forEach((block, i) => { + if (i > 0) items.push('\n'); + + (block.content ?? []).forEach((node) => { + if (node.type === 'text') items.push(...node.text); + else if (node.type === 'hardBreak') items.push('\n'); + else if (node.type === 'mention') items.push({ value: node.attrs.value }); + }); + }); + + return items; +} + +const isMention = (item) => typeof item === 'object'; + +function startsDoubleBracket(items, i) { + return items[i] === '[' && (items[i + 1] === '[' || isMention(items[i + 1])); +} + +export function contentToText(content) { + const items = flatten(content); + let text = ''; + let i = 0; + + while (i < items.length) { + const item = items[i]; + + if (isMention(item)) { + text += `[[ ${item.value} ]]`; + i++; + } else if (item === '\\') { + let run = 0; + while (items[i + run] === '\\') run++; + + const next = i + run; + const ambiguous = isMention(items[next]) || startsDoubleBracket(items, next); + + text += '\\'.repeat(ambiguous ? run * 2 : run); + i = next; + } else if (item === '[' && items[i + 1] === '[') { + text += '\\[['; + i += 2; + } else { + text += item; + i++; + } + } + + return text; +} + +function decode(text) { + const items = []; + let pos = 0; + + const pushText = (string) => items.push(...string); + + while (pos < text.length) { + const at = text.indexOf('[[', pos); + + if (at === -1) { + pushText(text.slice(pos)); + break; + } + + let start = at; + while (start > pos && text[start - 1] === '\\') start--; + const run = at - start; + + pushText(text.slice(pos, start)); + + if (run % 2 === 1) { + pushText('\\'.repeat((run - 1) / 2) + '[['); + pos = at + 2; + continue; + } + + pushText('\\'.repeat(run / 2)); + + const match = MENTION_TOKEN.exec(text.slice(at)); + + if (match) { + items.push({ value: match[1] }); + pos = at + match[0].length; + } else { + items.push('['); + pos = at + 1; + } + } + + return items; +} + +export function textToContent(text) { + if (typeof text !== 'string' || text === '') return []; + + const content = []; + let buffer = ''; + + const flush = () => { + if (buffer) content.push({ type: 'text', text: buffer }); + buffer = ''; + }; + + decode(text).forEach((item) => { + if (isMention(item)) { + flush(); + content.push({ type: 'mention', attrs: { value: item.value } }); + } else if (item === '\n') { + flush(); + content.push({ type: 'hardBreak' }); + } else { + buffer += item; + } + }); + + flush(); + + return [content.length ? { type: 'paragraph', content } : { type: 'paragraph' }]; +} diff --git a/resources/js/components/ui/index.js b/resources/js/components/ui/index.js index 9527d5461c8..aaf0b950ff0 100644 --- a/resources/js/components/ui/index.js +++ b/resources/js/components/ui/index.js @@ -1,5 +1,6 @@ export { default as Alert } from './Alert.vue'; export { default as AuthCard } from './AuthCard.vue'; +export { default as AutocompleteEditor } from './AutocompleteEditor/AutocompleteEditor.vue'; export { default as Badge } from './Badge.vue'; export { default as Button } from './Button/Button.vue'; export { default as ButtonGroup } from './Button/Group.vue'; diff --git a/resources/js/stories/AutocompleteEditor.stories.ts b/resources/js/stories/AutocompleteEditor.stories.ts new file mode 100644 index 00000000000..bd5e55dc66b --- /dev/null +++ b/resources/js/stories/AutocompleteEditor.stories.ts @@ -0,0 +1,287 @@ +import type { Meta, StoryObj } from '@storybook/vue3'; +import { expect, fn, userEvent, waitFor, within } from 'storybook/test'; +import { AutocompleteEditor } from '@ui'; +import { ref } from 'vue'; + +const meta = { + title: 'Components/AutocompleteEditor', + component: AutocompleteEditor, + argTypes: { + 'update:modelValue': { + description: 'Event handler called when the editor content changes.', + table: { + category: 'events', + type: { summary: '(value: object[]) => void' }, + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const sampleOptions = [ + { value: 'jack', label: 'Jack McDade' }, + { value: 'jason', label: 'Jason Varga' }, + { value: 'jesse', label: 'Jesse Leite' }, + { value: 'duncan', label: 'Duncan McClean' }, +]; + +const defaultCode = ` + +`; + +export const Default: Story = { + render: () => ({ + components: { AutocompleteEditor }, + setup() { + const value = ref([]); + const options = sampleOptions; + return { value, options }; + }, + template: defaultCode, + }), +}; + +export const _DocsIntro: Story = { + tags: ['!dev'], + parameters: { + docs: { + source: { code: defaultCode }, + }, + }, + render: () => ({ + components: { AutocompleteEditor }, + setup() { + const value = ref([]); + const options = sampleOptions; + return { value, options }; + }, + template: defaultCode, + }), +}; + +const inlineCode = ` + +`; + +export const _Inline: Story = { + tags: ['!dev'], + parameters: { + docs: { + source: { code: inlineCode }, + description: { + story: 'Set `inline` to drop the toolbar and block-level nodes (headings, lists) for a single-line editing experience.', + }, + }, + }, + render: () => ({ + components: { AutocompleteEditor }, + setup() { + const value = ref([]); + const options = sampleOptions; + return { value, options }; + }, + template: inlineCode, + }), +}; + +const inlineLineBreaksCode = ` + +`; + +export const _InlineWithLineBreaks: Story = { + tags: ['!dev'], + parameters: { + docs: { + source: { code: inlineLineBreaksCode }, + description: { + story: 'Combine `inline` with `enableLineBreaks` to allow soft line breaks (Enter) without introducing paragraphs or other block nodes.', + }, + }, + }, + render: () => ({ + components: { AutocompleteEditor }, + setup() { + const value = ref([]); + const options = sampleOptions; + return { value, options }; + }, + template: inlineLineBreaksCode, + }), +}; + +const readOnlyValue = [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Assigned to ' }, + { type: 'mention', attrs: { value: 'jesse', label: 'Jesse Leite' } }, + { type: 'text', text: '. This field is read only.' }, + ], + }, +]; + +const readOnlyCode = ` + +`; + +export const _ReadOnly: Story = { + tags: ['!dev'], + parameters: { + docs: { + source: { code: readOnlyCode }, + }, + }, + render: () => ({ + components: { AutocompleteEditor }, + setup() { + const value = ref(readOnlyValue); + const options = sampleOptions; + return { value, options }; + }, + template: readOnlyCode, + }), +}; + +const autocompleteValue = [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Reach out to ' }, + { type: 'mention', attrs: { value: 'jason', label: 'Jason Varga' } }, + { type: 'text', text: ' for details. Type @ to mention someone else.' }, + ], + }, +]; + +const autocompleteCode = ` + +`; + +export const _Autocomplete: Story = { + tags: ['!dev'], + parameters: { + docs: { + source: { code: autocompleteCode }, + description: { + story: 'Type `@` to open the autocomplete list and insert a mention chip. This example starts with a pre-populated mention already in the content.', + }, + }, + }, + render: () => ({ + components: { AutocompleteEditor }, + setup() { + const value = ref(autocompleteValue); + const options = sampleOptions; + return { value, options }; + }, + template: autocompleteCode, + }), +}; + +const missingValue = [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Reach out to ' }, + { type: 'mention', attrs: { value: 'han' } }, + { type: 'text', text: ' or ' }, + { type: 'mention', attrs: { value: 'chewie' } }, + { type: 'text', text: ' about it.' }, + ], + }, +]; + +const missingCode = ` + +`; + +export const _MissingOptions: Story = { + tags: ['!dev'], + parameters: { + docs: { + source: { code: missingCode }, + description: { + story: 'A mention whose value is no longer in `options` is struck through to show it refers to something that no longer exists. With no option to take a label from, it shows the raw value.', + }, + }, + }, + render: () => ({ + components: { AutocompleteEditor }, + setup() { + const value = ref(missingValue); + const options = sampleOptions; + return { value, options }; + }, + template: missingCode, + }), +}; + +export const TestAutocompleteSelectViaEnterInlineNoBreaks: Story = { + tags: ['!dev', 'test'], + args: { + 'onUpdate:modelValue': fn(), + }, + render: (args) => ({ + components: { AutocompleteEditor }, + setup() { + const value = ref([]); + const options = sampleOptions; + return { value, options, onUpdate: args['onUpdate:modelValue'] }; + }, + template: ` + + `, + }), + play: async ({ canvasElement, args }) => { + const editable = canvasElement.querySelector('.ProseMirror') as HTMLElement; + await expect(editable).toBeTruthy(); + + await userEvent.click(editable); + await userEvent.keyboard('@jas'); + + await waitFor(() => { + expect(within(document.body).getByText('Jason Varga')).toBeTruthy(); + }); + + await userEvent.keyboard('{Enter}'); + + await waitFor(() => { + expect(args['onUpdate:modelValue']).toHaveBeenCalled(); + }); + + const lastCall = args['onUpdate:modelValue'].mock.calls.at(-1); + expect(JSON.stringify(lastCall[0])).toContain('"type":"mention"'); + expect(editable.textContent).toContain('Jason Varga'); + }, +}; diff --git a/resources/js/tests/Package.test.js b/resources/js/tests/Package.test.js index 7915047a531..4be1778b7c1 100644 --- a/resources/js/tests/Package.test.js +++ b/resources/js/tests/Package.test.js @@ -125,6 +125,7 @@ it('exports ui', async () => { const expectedCmsPackageExports = [ 'Alert', 'AuthCard', + 'AutocompleteEditor', 'Badge', 'Button', 'ButtonGroup', diff --git a/resources/js/tests/components/fieldtypes/AutocompleteEditorFieldtype.test.js b/resources/js/tests/components/fieldtypes/AutocompleteEditorFieldtype.test.js new file mode 100644 index 00000000000..fb73bba34a3 --- /dev/null +++ b/resources/js/tests/components/fieldtypes/AutocompleteEditorFieldtype.test.js @@ -0,0 +1,134 @@ +import { mount } from '@vue/test-utils'; +import { describe, expect, test } from 'vitest'; +import * as Globals from '@/bootstrap/globals'; +import AutocompleteEditorFieldtype from '@/components/fieldtypes/AutocompleteEditorFieldtype.vue'; +import AutocompleteEditor from '@/components/ui/AutocompleteEditor/AutocompleteEditor.vue'; + +Object.keys(Globals).forEach((fn) => (window[fn] = Globals[fn])); +window.__ = (key) => key; + +const options = [ + { value: 'first_name', label: 'First Name' }, + { value: 'email', label: 'Email' }, +]; + +async function mountField(value, config = {}) { + const wrapper = mount(AutocompleteEditorFieldtype, { + props: { + value, + handle: 'subject', + meta: { options }, + config: { buttons: ['bold', 'italic', 'h2', 'bulletlist'], ...config }, + }, + }); + + await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); + + return wrapper; +} + +const editorOf = (wrapper) => wrapper.findComponent(AutocompleteEditor); + +const paragraph = (...content) => [{ type: 'paragraph', content }]; + +function emitContent(wrapper, content) { + editorOf(wrapper).vm.$emit('update:modelValue', content); + + return wrapper.emitted('update:value').at(-1)[0]; +} + +test('markdown mode is the default and stores markdown', async () => { + const wrapper = await mountField('Hi **there** [[ first_name ]]'); + + expect(editorOf(wrapper).props('modelValue')).toEqual( + paragraph( + { type: 'text', text: 'Hi ' }, + { type: 'text', text: 'there', marks: [{ type: 'bold' }] }, + { type: 'text', text: ' ' }, + { type: 'mention', attrs: { value: 'first_name', label: 'First Name' } }, + ), + ); + + expect(emitContent(wrapper, paragraph({ type: 'text', text: '*a* [[' }))).toBe('\\*a\\* \\[\\['); +}); + +test('markdown mode keeps the toolbar and formatting', async () => { + const wrapper = await mountField('', { markdown: true }); + + expect(wrapper.findAll('button').map((button) => button.attributes('aria-label'))).toEqual([ + 'Bold', + 'Italic', + 'Heading 2', + 'Unordered List', + ]); + expect(Object.keys(editorOf(wrapper).vm.editor.schema.marks)).toEqual(['bold', 'italic']); +}); + +test('text mode stores plain text', async () => { + const wrapper = await mountField('**not bold** \\[[ x ]] [[ first_name ]]', { markdown: false }); + + expect(editorOf(wrapper).props('modelValue')).toEqual( + paragraph( + { type: 'text', text: '**not bold** [[ x ]] ' }, + { type: 'mention', attrs: { value: 'first_name', label: 'First Name' } }, + ), + ); + + expect(emitContent(wrapper, paragraph({ type: 'text', text: '*a* & [[' }))).toBe('*a* & \\[['); +}); + +test('text mode hides the toolbar and allows no formatting, even when buttons are configured', async () => { + const wrapper = await mountField('', { markdown: false }); + const { schema } = editorOf(wrapper).vm.editor; + + expect(editorOf(wrapper).props('inline')).toBe(true); + expect(editorOf(wrapper).props('buttons')).toEqual([]); + expect(wrapper.findAll('button')).toHaveLength(0); + expect(Object.keys(schema.marks)).toEqual([]); + expect(Object.keys(schema.nodes).sort()).toEqual(['doc', 'mention', 'paragraph', 'text']); +}); + +test('text mode forces inline even when configured as a block editor', async () => { + const wrapper = await mountField('', { markdown: false, inline: false }); + + expect(editorOf(wrapper).props('inline')).toBe(true); +}); + +test('text mode allows hard breaks when line breaks are enabled', async () => { + const wrapper = await mountField('one\ntwo', { markdown: false, enable_line_breaks: true }); + + expect(Object.keys(editorOf(wrapper).vm.editor.schema.nodes)).toContain('hardBreak'); + expect(editorOf(wrapper).props('modelValue')).toEqual( + paragraph({ type: 'text', text: 'one' }, { type: 'hardBreak' }, { type: 'text', text: 'two' }), + ); + expect(emitContent(wrapper, editorOf(wrapper).props('modelValue'))).toBe('one\ntwo'); +}); + +describe.each([ + ['markdown', {}], + ['text', { markdown: false }], +])('%s mode', (mode, config) => { + test('mention labels are hydrated from the options', async () => { + const wrapper = await mountField('[[ email ]]', config); + + expect(editorOf(wrapper).props('modelValue')).toEqual( + paragraph({ type: 'mention', attrs: { value: 'email', label: 'Email' } }), + ); + }); + + test('a mention with no matching option renders as a missing chip', async () => { + const wrapper = await mountField('[[ deleted ]]', config); + const badge = wrapper.find('[data-ui-badge]'); + + expect(badge.text()).toBe('deleted'); + expect(badge.classes()).toContain('line-through'); + }); + + test('the empty value renders an empty editor and stores nothing', async () => { + const wrapper = await mountField(null, config); + + expect(editorOf(wrapper).props('modelValue')).toEqual([]); + expect(emitContent(wrapper, [{ type: 'paragraph' }])).toBe(''); + }); +}); diff --git a/resources/js/tests/components/ui/AutocompleteEditor.test.js b/resources/js/tests/components/ui/AutocompleteEditor.test.js new file mode 100644 index 00000000000..755a2d0a859 --- /dev/null +++ b/resources/js/tests/components/ui/AutocompleteEditor.test.js @@ -0,0 +1,228 @@ +import { mount } from '@vue/test-utils'; +import { expect, test, vi } from 'vitest'; +import AutocompleteEditor from '@/components/ui/AutocompleteEditor/AutocompleteEditor.vue'; + +window.__ = (key) => key; + +const sampleOptions = [ + { value: 'jason', label: 'Jason Varga' }, + { value: 'jesse', label: 'Jesse Leite' }, +]; + +async function mountEditor(props = {}) { + const wrapper = mount(AutocompleteEditor, { + props: { modelValue: [], options: sampleOptions, ...props }, + }); + + // useEditor() initializes the tiptap instance asynchronously, so the + // ProseMirror DOM isn't guaranteed to exist after a single tick. + await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); + + return wrapper; +} + +function pressEnter(dom) { + dom.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); +} + +test('renders a toolbar button for each configured button', async () => { + const wrapper = await mountEditor({ buttons: ['bold', 'h2'] }); + + const labels = wrapper.findAll('button').map((button) => button.attributes('aria-label')); + + expect(labels).toEqual(['Bold', 'Heading 2']); +}); + +test('unknown button keys are ignored', async () => { + const wrapper = await mountEditor({ buttons: ['bold', 'not-a-real-button'] }); + + const labels = wrapper.findAll('button').map((button) => button.attributes('aria-label')); + + expect(labels).toEqual(['Bold']); +}); + +test('inline mode hides the toolbar', async () => { + const wrapper = await mountEditor({ inline: true, buttons: ['bold', 'italic'] }); + + expect(wrapper.findAll('button')).toHaveLength(0); +}); + +test('enableLineBreaks false blocks Enter in inline mode', async () => { + const wrapper = await mountEditor({ inline: true, enableLineBreaks: false }); + const { editor } = wrapper.vm; + + editor.commands.insertContent('hello'); + editor.commands.focus('end'); + pressEnter(wrapper.find('.ProseMirror').element); + await wrapper.vm.$nextTick(); + + expect(editor.getJSON().content).toEqual([ + { type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }, + ]); +}); + +test('enableLineBreaks true inserts a hard break in inline mode', async () => { + const wrapper = await mountEditor({ inline: true, enableLineBreaks: true }); + const { editor } = wrapper.vm; + + editor.commands.insertContent('hello'); + editor.commands.focus('end'); + pressEnter(wrapper.find('.ProseMirror').element); + await wrapper.vm.$nextTick(); + + expect(editor.getJSON().content).toEqual([ + { + type: 'paragraph', + content: [{ type: 'text', text: 'hello' }, { type: 'hardBreak' }], + }, + ]); +}); + +test('enableLineBreaks false still splits into a new paragraph in block mode', async () => { + const wrapper = await mountEditor({ inline: false, enableLineBreaks: false }); + const { editor } = wrapper.vm; + + editor.commands.insertContent('hello'); + editor.commands.focus('end'); + pressEnter(wrapper.find('.ProseMirror').element); + await wrapper.vm.$nextTick(); + + expect(editor.getJSON().content).toEqual([ + { type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }, + { type: 'paragraph' }, + ]); +}); + +test('enableLineBreaks true splits into a new paragraph in block mode', async () => { + const wrapper = await mountEditor({ inline: false, enableLineBreaks: true }); + const { editor } = wrapper.vm; + + editor.commands.insertContent('hello'); + editor.commands.focus('end'); + pressEnter(wrapper.find('.ProseMirror').element); + await wrapper.vm.$nextTick(); + + expect(editor.getJSON().content).toEqual([ + { type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }, + { type: 'paragraph' }, + ]); +}); + +test('inserting a mention produces a node with value and label attrs', async () => { + const wrapper = await mountEditor(); + const { editor } = wrapper.vm; + + editor.commands.insertContent({ type: 'mention', attrs: { value: 'jesse', label: 'Jesse Leite' } }); + + expect(editor.getJSON().content).toEqual([ + { + type: 'paragraph', + content: [{ type: 'mention', attrs: { value: 'jesse', label: 'Jesse Leite' } }], + }, + ]); +}); + +function mentionBadge(wrapper) { + return wrapper.find('[data-ui-badge]'); +} + +test('a mention matching an option renders as a normal chip', async () => { + const wrapper = await mountEditor({ + modelValue: [ + { type: 'paragraph', content: [{ type: 'mention', attrs: { value: 'jesse', label: 'Jesse Leite' } }] }, + ], + }); + + const badge = mentionBadge(wrapper); + + expect(badge.text()).toBe('Jesse Leite'); + expect(badge.classes()).not.toContain('line-through'); + expect(badge.attributes('title')).toBeUndefined(); +}); + +test('a mention with no matching option renders as a missing chip', async () => { + const wrapper = await mountEditor({ + modelValue: [ + { type: 'paragraph', content: [{ type: 'mention', attrs: { value: 'deleted' } }] }, + ], + }); + + const badge = mentionBadge(wrapper); + + expect(badge.text()).toBe('deleted'); + expect(badge.classes()).toContain('line-through'); + expect(badge.attributes('title')).toBe('This item no longer exists'); +}); + +test('nothing is considered missing when options are loaded on demand', async () => { + const wrapper = await mountEditor({ + options: [], + loadOptions: () => [], + modelValue: [{ type: 'paragraph', content: [{ type: 'mention', attrs: { value: 'deleted' } }] }], + }); + + expect(mentionBadge(wrapper).classes()).not.toContain('line-through'); +}); + +test('update:modelValue emits the ProseMirror content array', async () => { + const wrapper = await mountEditor(); + const { editor } = wrapper.vm; + + editor.commands.insertContent('hello'); + await wrapper.vm.$nextTick(); + + const emitted = wrapper.emitted('update:modelValue'); + + expect(emitted).toBeTruthy(); + expect(emitted.at(-1)[0]).toEqual([ + { type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }, + ]); +}); + +test('suggestion items are filtered by query against the option label', async () => { + const wrapper = await mountEditor(); + const { editor } = wrapper.vm; + + const mention = editor.extensionManager.extensions.find((extension) => extension.name === 'mention'); + + expect(mention.options.suggestion.items({ query: 'jes' })).toEqual([ + { value: 'jesse', label: 'Jesse Leite' }, + ]); + expect(mention.options.suggestion.items({ query: '' })).toEqual(sampleOptions); + expect(mention.options.suggestion.items({ query: 'zzz' })).toEqual([]); +}); + +test('options with values outside the token grammar are dropped from loaded suggestions', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const loadOptions = async () => [ + { value: 'ok', label: 'Ok' }, + { value: 'a b', label: 'Space' }, + { value: 'a]]b', label: 'Brackets' }, + ]; + const wrapper = await mountEditor({ options: [], loadOptions }); + const items = wrapper.vm.editor.extensionManager.extensions + .find((extension) => extension.name === 'mention') + .options.suggestion.items; + + expect(await items({ query: '' })).toEqual([{ value: 'ok', label: 'Ok' }]); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('[a b]')); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('[a]]b]')); + + warn.mockRestore(); +}); + +test('suggestion list never inserts a value outside the token grammar', async () => { + const SuggestionList = (await import('@/components/ui/AutocompleteEditor/SuggestionList.vue')).default; + const command = vi.fn(); + const list = mount(SuggestionList, { + props: { command, items: [{ value: 'a b', label: 'Bad' }, { value: 'ok', label: 'Ok' }] }, + }); + + const [bad, ok] = list.findAll('button'); + await bad.trigger('click'); + expect(command).not.toHaveBeenCalled(); + + await ok.trigger('click'); + expect(command).toHaveBeenCalledWith({ value: 'ok', label: 'Ok' }); +}); diff --git a/resources/js/tests/components/ui/AutocompleteEditorMarkdown.test.js b/resources/js/tests/components/ui/AutocompleteEditorMarkdown.test.js new file mode 100644 index 00000000000..864216773c8 --- /dev/null +++ b/resources/js/tests/components/ui/AutocompleteEditorMarkdown.test.js @@ -0,0 +1,342 @@ +import { expect, test } from 'vitest'; +import { contentToMarkdown, markdownToContent } from '@/components/ui/AutocompleteEditor/markdown'; +import { isValidMentionValue } from '@/components/ui/AutocompleteEditor/mentionToken'; + +test('a value that is not a string is treated as empty', () => { + const proseMirror = [{ type: 'paragraph', content: [{ type: 'text', text: 'legacy' }] }]; + + expect(markdownToContent(proseMirror)).toEqual([]); + expect(markdownToContent(null)).toEqual([]); + expect(markdownToContent(undefined)).toEqual([]); +}); + +test('a mention on its own round trips', () => { + const content = [{ type: 'paragraph', content: [{ type: 'mention', attrs: { value: 'first_name' } }] }]; + + const markdown = contentToMarkdown(content); + + expect(markdown).toBe('[[ first_name ]]'); + expect(markdownToContent(markdown)).toEqual(content); +}); + +test('inline content round trips to a plain markdown scalar', () => { + const content = [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Hi ' }, + { type: 'mention', attrs: { value: 'first_name' } }, + { type: 'text', text: ', thanks!' }, + ], + }, + ]; + + const markdown = contentToMarkdown(content); + + expect(markdown).toBe('Hi [[ first_name ]], thanks!'); + expect(markdownToContent(markdown)).toEqual(content); +}); + +test('block content with headings, marks, lists, and mentions round trips', () => { + const content = [ + { type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Thanks for getting in touch' }] }, + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Hi ' }, + { type: 'mention', attrs: { value: 'first_name' } }, + { type: 'text', text: ',' }, + ], + }, + { + type: 'paragraph', + content: [ + { type: 'text', text: 'We received your message and will reply to ' }, + { type: 'mention', attrs: { value: 'email' } }, + { type: 'text', text: ' within ' }, + { type: 'text', marks: [{ type: 'bold' }], text: 'two business days' }, + { type: 'text', text: '.' }, + ], + }, + { type: 'paragraph', content: [{ type: 'text', text: 'Here is what you sent us:' }] }, + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Subject: ' }, + { type: 'mention', attrs: { value: 'subject' } }, + ], + }, + ], + }, + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Message: ' }, + { type: 'mention', attrs: { value: 'message' } }, + ], + }, + ], + }, + ], + }, + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Thanks,' }, + { type: 'hardBreak' }, + { type: 'text', text: 'The Team' }, + ], + }, + ]; + + const markdown = contentToMarkdown(content); + + expect(markdown).toBe( + [ + '## Thanks for getting in touch', + '', + 'Hi [[ first_name ]],', + '', + 'We received your message and will reply to [[ email ]] within **two business days**.', + '', + 'Here is what you sent us:', + '', + '- Subject: [[ subject ]]', + '- Message: [[ message ]]', + '', + 'Thanks,\\', + 'The Team', + ].join('\n'), + ); + expect(markdownToContent(markdown)).toEqual(content); +}); + +test('a literal [[ ]] typed by an author round trips as text, not a mention', () => { + const content = [{ type: 'paragraph', content: [{ type: 'text', text: 'Type [[ first_name ]] literally' }] }]; + + const markdown = contentToMarkdown(content); + + expect(markdown).toBe('Type \\[\\[ first\\_name \\]\\] literally'); + expect(markdownToContent(markdown)).toEqual(content); +}); + +test('a real mention and a literal in the same line both round trip correctly', () => { + const content = [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Hi ' }, + { type: 'mention', attrs: { value: 'handle' } }, + { type: 'text', text: ', see [[ first_name ]] literally' }, + ], + }, + ]; + + const markdown = contentToMarkdown(content); + + expect(markdown).toBe('Hi [[ handle ]], see \\[\\[ first\\_name \\]\\] literally'); + expect(markdownToContent(markdown)).toEqual(content); +}); + +test('a literal before a real mention round trips correctly', () => { + const content = [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'See [[ first_name ]] then ' }, + { type: 'mention', attrs: { value: 'handle' } }, + ], + }, + ]; + + const markdown = contentToMarkdown(content); + + expect(markdown).toBe('See \\[\\[ first\\_name \\]\\] then [[ handle ]]'); + expect(markdownToContent(markdown)).toEqual(content); +}); + +test('escaped brackets parse to text rather than a mention', () => { + expect(markdownToContent('\\[\\[ first\\_name \\]\\]')).toEqual([ + { type: 'paragraph', content: [{ type: 'text', text: '[[ first_name ]]' }] }, + ]); + + // Underscores don't need escaping to be part of an escaped literal. + expect(markdownToContent('\\[\\[ first_name \\]\\]')).toEqual([ + { type: 'paragraph', content: [{ type: 'text', text: '[[ first_name ]]' }] }, + ]); +}); + +test('an escaped literal alongside a real mention parses in either order', () => { + expect(markdownToContent('Hi [[ handle ]], see \\[\\[ first\\_name \\]\\] literally')).toEqual([ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Hi ' }, + { type: 'mention', attrs: { value: 'handle' } }, + { type: 'text', text: ', see [[ first_name ]] literally' }, + ], + }, + ]); + + expect(markdownToContent('See \\[\\[ first\\_name \\]\\] then [[ handle ]]')).toEqual([ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'See [[ first_name ]] then ' }, + { type: 'mention', attrs: { value: 'handle' } }, + ], + }, + ]); +}); + +test('hard breaks survive trailing whitespace stripping', () => { + const content = [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Line one' }, + { type: 'hardBreak' }, + { type: 'text', text: 'Line two' }, + ], + }, + ]; + + const markdown = contentToMarkdown(content); + const stripped = markdown + .split('\n') + .map((line) => line.replace(/\s+$/, '')) + .join('\n'); + + expect(markdown).toBe(stripped); + expect(markdownToContent(stripped)).toEqual(content); +}); + +const textParagraph = (...lines) => ({ + type: 'paragraph', + content: lines.flatMap((text, i) => [ + ...(i > 0 ? [{ type: 'hardBreak' }] : []), + { type: 'text', text }, + ]), +}); + +test.each([ + ['- x'], + ['+ x'], + ['* x'], + ['1. x'], + ['1) x'], + ['12. x'], + ['# x'], + ['###### x'], + ['#'], + ['-'], + ['---'], + ['- - -'], + ['-- -'], + ['***'], + ['___'], + ['==='], + [' x'], + [' x'], + ['\tx'], + ['> x'], + ['```'], + ['~~~'], + ['1.5 apples'], + ['-5°C'], + ['+1 555'], + ['#hashtag'], + ['####### seven'], + ['2020. A year'], +])('text starting with a block marker round trips: %j', (text) => { + const content = [textParagraph(text)]; + + expect(markdownToContent(contentToMarkdown(content))).toEqual(content); +}); + +test.each([ + ['# x'], + ['- x'], + ['+ x'], + ['1. x'], + ['2. x'], + ['1) x'], + ['---'], + ['--'], + ['==='], + [' x'], + [' x'], + ['***'], + ['___'], + ['5°C'], + ['#hashtag'], + ['--|--'], + ['|-|-|'], + [':--|:-:'], + [' - x'], +])('text after a hard break starting with a block marker round trips: %j', (text) => { + const content = [textParagraph('Line one', text)]; + + expect(markdownToContent(contentToMarkdown(content))).toEqual(content); +}); + +test('every line after several hard breaks is escaped', () => { + const content = [textParagraph('# a', '- b', '1. c', '===', 'plain')]; + + expect(markdownToContent(contentToMarkdown(content))).toEqual(content); +}); + +test('block markers are only escaped when they would be parsed as one', () => { + expect(contentToMarkdown([textParagraph('1.5 apples')])).toBe('1.5 apples'); + expect(contentToMarkdown([textParagraph('-5°C')])).toBe('-5°C'); + expect(contentToMarkdown([textParagraph('#hashtag')])).toBe('#hashtag'); + expect(contentToMarkdown([textParagraph('Line one', '2. x')])).toBe('Line one\\\n2. x'); + expect(contentToMarkdown([textParagraph('# x')])).toBe('\\# x'); +}); + +test('a mention at the start of a line is not escaped', () => { + const content = [ + { + type: 'paragraph', + content: [ + { type: 'mention', attrs: { value: 'first_name' } }, + { type: 'hardBreak' }, + { type: 'mention', attrs: { value: 'email' } }, + ], + }, + ]; + + expect(contentToMarkdown(content)).toBe('[[ first_name ]]\\\n[[ email ]]'); + expect(markdownToContent(contentToMarkdown(content))).toEqual(content); +}); + +test('block markers are escaped inside list items', () => { + const content = [ + { + type: 'bulletList', + content: [{ type: 'listItem', content: [textParagraph('# x', '- y')] }], + }, + ]; + + expect(markdownToContent(contentToMarkdown(content))).toEqual(content); +}); + +test.each([ + ['first_name', true], + ['user.last-name', true], + ['a b', false], + ['a]]b', false], + ['', false], + [null, false], +])('mention value %j validity is %s', (value, valid) => { + expect(isValidMentionValue(value)).toBe(valid); +}); diff --git a/resources/js/tests/components/ui/AutocompleteEditorText.test.js b/resources/js/tests/components/ui/AutocompleteEditorText.test.js new file mode 100644 index 00000000000..28a960ada17 --- /dev/null +++ b/resources/js/tests/components/ui/AutocompleteEditorText.test.js @@ -0,0 +1,130 @@ +import { expect, test } from 'vitest'; +import { contentToText, textToContent } from '@/components/ui/AutocompleteEditor/text'; + +const text = (text) => ({ type: 'text', text }); +const mention = (value) => ({ type: 'mention', attrs: { value } }); +const hardBreak = { type: 'hardBreak' }; +const paragraph = (...content) => [{ type: 'paragraph', content }]; + +function expectRoundTrip(content, stored) { + expect(contentToText(content)).toBe(stored); + expect(textToContent(stored)).toEqual(content); +} + +test('the empty value round trips', () => { + expect(contentToText([])).toBe(''); + expect(contentToText([{ type: 'paragraph' }])).toBe(''); + expect(textToContent('')).toEqual([]); +}); + +test('a value that is not a string is treated as empty', () => { + expect(textToContent(null)).toEqual([]); + expect(textToContent(undefined)).toEqual([]); + expect(textToContent(paragraph(text('legacy')))).toEqual([]); +}); + +test('markdown special characters are stored verbatim', () => { + const special = '* _ # \\ ` - 1. > & < [ ] **bold** _em_ # Heading\\'; + + expectRoundTrip(paragraph(text(special)), special); +}); + +test('block markers at the start are stored verbatim', () => { + expectRoundTrip(paragraph(text('- item')), '- item'); + expectRoundTrip(paragraph(text('1. item')), '1. item'); + expectRoundTrip(paragraph(text('> quote')), '> quote'); + expectRoundTrip(paragraph(text('# heading')), '# heading'); +}); + +test('mentions are stored as tokens', () => { + expectRoundTrip(paragraph(text('Hi '), mention('first_name'), text(', thanks!')), 'Hi [[ first_name ]], thanks!'); +}); + +test('mentions at the start, the end, and next to each other round trip', () => { + expectRoundTrip(paragraph(mention('a')), '[[ a ]]'); + expectRoundTrip(paragraph(mention('a'), text(' and '), mention('b')), '[[ a ]] and [[ b ]]'); + expectRoundTrip(paragraph(mention('a'), mention('b'), mention('c.d-e')), '[[ a ]][[ b ]][[ c.d-e ]]'); +}); + +test('a literal [[ is escaped so it does not become a mention', () => { + expectRoundTrip(paragraph(text('[[ first_name ]]')), '\\[[ first_name ]]'); + expectRoundTrip(paragraph(text('a [[ b')), 'a \\[[ b'); + expectRoundTrip(paragraph(text('[[[[')), '\\[[\\[['); + expectRoundTrip(paragraph(text('[[[ a ]]')), '\\[[[ a ]]'); +}); + +test('a literal [[ next to a real mention round trips', () => { + expectRoundTrip(paragraph(text('[['), mention('a')), '\\[[[[ a ]]'); + expectRoundTrip(paragraph(text('[[ x ]] '), mention('a')), '\\[[ x ]] [[ a ]]'); + expectRoundTrip(paragraph(mention('a'), text('[[ x ]]')), '[[ a ]]\\[[ x ]]'); +}); + +test('a single bracket before a mention needs no escape', () => { + expectRoundTrip(paragraph(text('['), mention('a')), '[[[ a ]]'); + expectRoundTrip(paragraph(text('[[['), mention('a')), '\\[[[[[ a ]]'); + expectRoundTrip(paragraph(mention('a'), text(']')), '[[ a ]]]'); +}); + +test('backslashes are only escaped directly before a [[', () => { + expectRoundTrip(paragraph(text('C:\\path\\to [x]')), 'C:\\path\\to [x]'); + expectRoundTrip(paragraph(text('\\[ x ]')), '\\[ x ]'); +}); + +test('a backslash before a literal [[ round trips', () => { + expectRoundTrip(paragraph(text('\\[[ x ]]')), '\\\\\\[[ x ]]'); + expectRoundTrip(paragraph(text('\\\\[[ x ]]')), '\\\\\\\\\\[[ x ]]'); +}); + +test('a backslash before a real mention round trips', () => { + expectRoundTrip(paragraph(text('\\'), mention('a')), '\\\\[[ a ]]'); + expectRoundTrip(paragraph(text('\\\\'), mention('a')), '\\\\\\\\[[ a ]]'); + expectRoundTrip(paragraph(text('\\['), mention('a')), '\\\\[[[ a ]]'); +}); + +test('hard breaks are stored as newlines', () => { + expectRoundTrip(paragraph(text('one'), hardBreak, text('two'), hardBreak, mention('a')), 'one\ntwo\n[[ a ]]'); + expectRoundTrip(paragraph(text('a\\'), hardBreak, text('[[')), 'a\\\n\\[['); +}); + +test('content without hard breaks contains no newlines', () => { + expect(contentToText(paragraph(text('one two'), mention('a')))).toBe('one two[[ a ]]'); +}); + +test('separate paragraphs are joined with a newline', () => { + expect(contentToText([...paragraph(text('one')), ...paragraph(mention('a'))])).toBe('one\n[[ a ]]'); +}); + +test('hand written values with malformed tokens are kept as text', () => { + expect(textToContent('[[ not a token')).toEqual(paragraph(text('[[ not a token'))); + expect(textToContent('[[ a ]')).toEqual(paragraph(text('[[ a ]'))); + expect(textToContent('[[ a b ]]')).toEqual(paragraph(text('[[ a b ]]'))); +}); + +test('every short sequence of brackets, backslashes, mentions and breaks round trips', () => { + const alphabet = ['[', ']', '\\', ' ', 'x', 'mention', 'break']; + + function toContent(sequence) { + const content = []; + + sequence.forEach((item) => { + if (item === 'mention') content.push(mention('x')); + else if (item === 'break') content.push(hardBreak); + else if (content.at(-1)?.type === 'text') content.at(-1).text += item; + else content.push(text(item)); + }); + + return paragraph(...content); + } + + let sequences = [[]]; + + for (let length = 1; length <= 5; length++) { + sequences = sequences.flatMap((sequence) => alphabet.map((item) => [...sequence, item])); + + sequences.forEach((sequence) => { + const content = toContent(sequence); + + expect(textToContent(contentToText(content))).toEqual(content); + }); + } +}); diff --git a/src/Fieldtypes/AutocompleteEditor.php b/src/Fieldtypes/AutocompleteEditor.php new file mode 100644 index 00000000000..95bcdcd0ff6 --- /dev/null +++ b/src/Fieldtypes/AutocompleteEditor.php @@ -0,0 +1,215 @@ + [ + 'display' => __('Options'), + 'instructions' => __('statamic::fieldtypes.autocomplete_editor.config.options'), + 'type' => 'array', + 'expand' => true, + 'value_header' => __('Label').' ('.__('Optional').')', + 'field' => [ + 'type' => 'text', + ], + ], + 'trigger' => [ + 'display' => __('Trigger'), + 'instructions' => __('statamic::fieldtypes.autocomplete_editor.config.trigger'), + 'type' => 'text', + 'default' => '@', + 'width' => 50, + ], + 'placeholder' => [ + 'display' => __('Placeholder'), + 'instructions' => __('statamic::fieldtypes.autocomplete_editor.config.placeholder'), + 'type' => 'text', + 'width' => 50, + ], + 'buttons' => [ + 'display' => __('Buttons'), + 'instructions' => __('statamic::fieldtypes.autocomplete_editor.config.buttons'), + 'type' => 'checkboxes', + 'options' => [ + 'bold' => __('Bold'), + 'italic' => __('Italic'), + 'h1' => __('Heading 1'), + 'h2' => __('Heading 2'), + 'h3' => __('Heading 3'), + 'h4' => __('Heading 4'), + 'h5' => __('Heading 5'), + 'h6' => __('Heading 6'), + 'bulletlist' => __('Unordered List'), + 'orderedlist' => __('Ordered List'), + ], + 'default' => ['bold', 'italic', 'h2', 'h3', 'bulletlist', 'orderedlist'], + 'if' => ['markdown' => 'equals true'], + ], + 'markdown' => [ + 'display' => __('Markdown'), + 'instructions' => __('statamic::fieldtypes.autocomplete_editor.config.markdown'), + 'type' => 'toggle', + 'default' => true, + 'width' => 50, + ], + 'inline' => [ + 'display' => __('Inline'), + 'instructions' => __('statamic::fieldtypes.autocomplete_editor.config.inline'), + 'type' => 'toggle', + 'default' => false, + 'width' => 50, + 'if' => ['markdown' => 'equals true'], + ], + 'enable_line_breaks' => [ + 'display' => __('Enable Line Breaks'), + 'instructions' => __('statamic::fieldtypes.autocomplete_editor.config.enable_line_breaks'), + 'type' => 'toggle', + 'default' => false, + 'width' => 50, + 'if_any' => ['inline' => 'equals true', 'markdown' => 'equals false'], + ], + ]; + } + + public function preload(): array + { + return [ + 'options' => $this->getOptions(), + ]; + } + + protected function getOptions(): array + { + $options = $this->config('options') ?? []; + + if (array_is_list($options) && ! is_array(Arr::first($options))) { + $options = collect($options)->map(fn ($value) => ['key' => $value, 'value' => $value])->all(); + } + + if (Arr::isAssoc($options)) { + $options = collect($options)->map(fn ($value, $key) => ['key' => $key, 'value' => $value])->all(); + } + + return collect($options) + ->map(function ($item) { + if (! Mentions::isValidValue($value = (string) $item['key'])) { + throw new \InvalidArgumentException(sprintf( + 'Invalid autocomplete_editor option value [%s]. Values may only contain letters, numbers, underscores, hyphens and periods.', + $value + )); + } + + return ['value' => $value, 'label' => $item['value']]; + }) + ->values() + ->all(); + } + + public function process($value) + { + return $value ?: null; + } + + public function augment($value) + { + if (! $this->config('markdown', true)) { + return $value === '' ? null : $value; + } + + return static::parseMarkdown($value, (bool) $this->config('inline')); + } + + // Static so a consumer holding a raw string, rather than a Value, can get the + // same HTML without needing a field to read the config from. + public static function parseMarkdown($markdown, bool $inline = false): ?string + { + if ($markdown === null || $markdown === '') { + return null; + } + + $markdown = (string) $markdown; + $mentions = []; + + // An author's literal `\[\[ x \]\]` renders to `[[ x ]]`, which would be + // indistinguishable from a real mention in the HTML. Real tokens are swapped + // for alphanumeric sentinels (inert to the markdown parser) and the literals + // that remain get their brackets encoded so Mentions::replace() skips them. + // Backslashes before a real token (or a bracket touching one) are encoded + // too, since replace() would otherwise read them as the plain text escape. + do { + $nonce = bin2hex(random_bytes(8)); + } while (str_contains($markdown, $nonce)); + + $markdown = preg_replace_callback(Mentions::TOKEN, function ($matches) use (&$mentions, $nonce) { + $sentinel = 'mention'.$nonce.'n'.count($mentions).'e'; + $mentions[$sentinel] = $matches[0]; + + return $sentinel; + }, $markdown); + + $html = Markdown::parser('default') + ->newInstance(['html_input' => 'escape', 'allow_unsafe_links' => false]) + ->parse($markdown); + + $html = str_replace('[[', '[[', $html); + $html = preg_replace_callback( + '/(? str_repeat('\', strlen($matches[0])), + $html + ); + + // Mentions only resolve in text. One inside a tag, such as a link or image + // destination, is restored as a literal so a value can never end up in an + // attribute, where escaping alone can't stop a `javascript:` URL. + $literals = array_map(fn ($token) => str_replace('[[', '[[', $token), $mentions); + $html = preg_replace_callback('/<[^>]*>/', fn ($matches) => strtr($matches[0], $literals), $html); + + $html = strtr($html, $mentions); + + return $inline ? static::unwrapParagraph($html) : $html; + } + + // The HTML-context convenience over Mentions::replace(), which stays raw and + // context-free. Values are escaped because markdown output is always HTML. + // Htmlable is the opt-out for values that are already HTML. + public static function renderHtml(string $markdown, array $values, bool $inline = false, ?callable $using = null): string + { + if (($html = static::parseMarkdown($markdown, $inline)) === null) { + return ''; + } + + $escaped = array_map( + fn ($value) => $value instanceof Htmlable ? $value->toHtml() : nl2br(e((string) $value)), + $values + ); + + return Mentions::replace($html, $escaped, $using); + } + + private static function unwrapParagraph(string $html): string + { + $html = trim($html); + + if (! preg_match('/^

(.*)<\/p>$/s', $html, $matches)) { + return $html; + } + + return str_contains($matches[1], '

') ? $html : $matches[1]; + } +} diff --git a/src/Fieldtypes/AutocompleteEditor/Mentions.php b/src/Fieldtypes/AutocompleteEditor/Mentions.php new file mode 100644 index 00000000000..cbdb51330a7 --- /dev/null +++ b/src/Fieldtypes/AutocompleteEditor/Mentions.php @@ -0,0 +1,71 @@ + $value) { + $placeholders['mention'.$nonce.'n'.$i++.'e'] = $value; + } + + $template = self::resolve($content, array_combine(array_map('strval', array_keys($values)), array_keys($placeholders))); + + return strtr((string) $using($template), $placeholders); + } + + private static function resolve(string $content, array $values): string + { + return preg_replace_callback(self::RESOLVABLE, function ($matches) use ($values) { + $backslashes = str_repeat('\\', strlen($matches[1]) / 2); + + if ($matches[2] !== null) { + // Values are substituted verbatim. Escaping them for whatever context + // the result ends up in is deliberately the consumer's job. + return $backslashes.($values[$matches[2]] ?? ''); + } + + return $backslashes.($matches[3] !== null ? '[[' : '['); + }, $content, flags: PREG_UNMATCHED_AS_NULL); + } +} diff --git a/src/Providers/ExtensionServiceProvider.php b/src/Providers/ExtensionServiceProvider.php index 0c5afab4a43..7a1c2ca79a6 100644 --- a/src/Providers/ExtensionServiceProvider.php +++ b/src/Providers/ExtensionServiceProvider.php @@ -66,6 +66,7 @@ class ExtensionServiceProvider extends ServiceProvider 'asset_container' => Fieldtypes\AssetContainer::class, 'asset_folder' => Fieldtypes\AssetFolder::class, 'assets' => Fieldtypes\Assets\Assets::class, + 'autocomplete_editor' => Fieldtypes\AutocompleteEditor::class, 'bard' => Fieldtypes\Bard::class, 'bard_buttons_setting' => Fieldtypes\Bard\Buttons::class, 'blueprints' => Fieldtypes\Blueprints::class, diff --git a/tests/Fieldtypes/AutocompleteEditor/MentionsTest.php b/tests/Fieldtypes/AutocompleteEditor/MentionsTest.php new file mode 100644 index 00000000000..b1aa4d0708d --- /dev/null +++ b/tests/Fieldtypes/AutocompleteEditor/MentionsTest.php @@ -0,0 +1,362 @@ +assertSame( + 'Hi John, thanks!', + Mentions::replace('Hi [[ first_name ]], thanks!', ['first_name' => 'John']) + ); + } + + #[Test] + public function it_replaces_multiple_mentions() + { + $this->assertSame( + 'John Doe', + Mentions::replace('[[ first_name ]] [[ last_name ]]', [ + 'first_name' => 'John', + 'last_name' => 'Doe', + ]) + ); + } + + #[Test] + public function it_replaces_the_same_mention_more_than_once() + { + $this->assertSame( + 'John, oh John.', + Mentions::replace('[[ first_name ]], oh [[ first_name ]].', ['first_name' => 'John']) + ); + } + + #[Test] + public function whitespace_inside_the_brackets_is_optional() + { + $this->assertSame( + 'John John John', + Mentions::replace('[[first_name]] [[ first_name]] [[ first_name ]]', ['first_name' => 'John']) + ); + } + + #[Test] + public function handles_may_contain_word_characters_dots_and_dashes() + { + $this->assertSame( + 'a b c', + Mentions::replace('[[ one.two ]] [[ three-four ]] [[ five_6 ]]', [ + 'one.two' => 'a', + 'three-four' => 'b', + 'five_6' => 'c', + ]) + ); + } + + #[Test] + public function an_unknown_key_resolves_to_an_empty_string() + { + $this->assertSame( + 'Hi , thanks!', + Mentions::replace('Hi [[ first_name ]], thanks!', []) + ); + } + + #[Test] + public function an_escaped_literal_is_left_untouched() + { + $this->assertSame( + 'Type \[\[ first_name \]\] literally', + Mentions::replace('Type \[\[ first_name \]\] literally', ['first_name' => 'John']) + ); + } + + #[Test] + public function an_escaped_literal_is_left_untouched_alongside_a_real_mention() + { + $this->assertSame( + 'Hi John, see \[\[ first_name \]\] literally', + Mentions::replace('Hi [[ first_name ]], see \[\[ first_name \]\] literally', ['first_name' => 'John']) + ); + + $this->assertSame( + 'See \[\[ first_name \]\] then John', + Mentions::replace('See \[\[ first_name \]\] then [[ first_name ]]', ['first_name' => 'John']) + ); + } + + #[Test] + public function a_token_preceded_by_an_escaped_backslash_is_still_replaced() + { + $this->assertSame( + '\\John', + Mentions::replace('\\\\[[ first_name ]]', ['first_name' => 'John']) + ); + } + + #[Test] + public function an_escaped_plain_text_literal_is_unescaped_and_left_unresolved() + { + $this->assertSame( + '[[ first_name ]] then John', + Mentions::replace('\\[[ first_name ]] then [[ first_name ]]', ['first_name' => 'John']) + ); + } + + #[Test] + public function the_parity_of_a_backslash_run_before_brackets_decides_whether_they_are_escaped() + { + $values = ['a' => 'John']; + + $this->assertSame('\\[[ a ]]', Mentions::replace('\\\\\\[[ a ]]', $values)); + $this->assertSame('\\\\John', Mentions::replace('\\\\\\\\[[ a ]]', $values)); + $this->assertSame('\\\\[[ a ]]', Mentions::replace('\\\\\\\\\\[[ a ]]', $values)); + } + + #[Test] + public function a_bracket_directly_before_a_token_is_kept() + { + $values = ['a' => 'John']; + + $this->assertSame('[John', Mentions::replace('[[[ a ]]', $values)); + $this->assertSame('\\[John', Mentions::replace('\\\\[[[ a ]]', $values)); + $this->assertSame('[[[John', Mentions::replace('\\[[[[[ a ]]', $values)); + } + + #[Test] + public function an_escaped_literal_directly_before_a_token_is_unescaped_while_the_token_resolves() + { + $this->assertSame('[[John', Mentions::replace('\\[[[[ a ]]', ['a' => 'John'])); + } + + #[Test] + public function backslashes_not_touching_brackets_are_left_alone() + { + $this->assertSame( + 'C:\\path\\to \\[ x ] \\ John \\', + Mentions::replace('C:\\path\\to \\[ x ] \\ [[ a ]] \\', ['a' => 'John']) + ); + } + + #[Test] + public function a_half_escaped_token_is_left_untouched() + { + $this->assertSame( + '[[ first_name \]\]', + Mentions::replace('[[ first_name \]\]', ['first_name' => 'John']) + ); + } + + #[Test] + public function substituted_values_are_not_reprocessed() + { + $this->assertSame( + '[[ last_name ]]', + Mentions::replace('[[ first_name ]]', [ + 'first_name' => '[[ last_name ]]', + 'last_name' => 'Doe', + ]) + ); + } + + #[Test] + public function substituted_values_are_not_escaped() + { + $this->assertSame( + 'Hi John **Doe**', + Mentions::replace('Hi [[ first_name ]] [[ last_name ]]', [ + 'first_name' => 'John', + 'last_name' => '**Doe**', + ]) + ); + } + + #[Test] + public function it_leaves_content_without_mentions_alone() + { + $this->assertSame('Nothing to see here', Mentions::replace('Nothing to see here', ['first_name' => 'John'])); + $this->assertSame('', Mentions::replace('', ['first_name' => 'John'])); + } + + #[Test] + public function the_callback_receives_the_template_with_placeholders_instead_of_values() + { + $received = null; + + $result = Mentions::replace('Hi [[ first_name ]] [[ last_name ]]', [ + 'first_name' => 'John', + 'last_name' => 'Doe', + ], using: function ($template) use (&$received) { + $received = $template; + + return $template; + }); + + $this->assertSame('Hi John Doe', $result); + $this->assertStringNotContainsString('[[', $received); + $this->assertStringNotContainsString('John', $received); + $this->assertStringNotContainsString('Doe', $received); + $this->assertMatchesRegularExpression('/^Hi mention\w+n0e mention\w+n1e$/', $received); + } + + #[Test] + public function repeated_mentions_share_a_placeholder() + { + $received = null; + + Mentions::replace('[[ a ]] [[ a ]]', ['a' => 'John'], using: function ($template) use (&$received) { + $received = $template; + + return $template; + }); + + [$first, $second] = explode(' ', $received); + $this->assertSame($first, $second); + } + + #[Test] + public function a_mention_produced_by_the_callback_stays_literal() + { + $this->assertSame( + 'Hi [[ last_name ]] John', + Mentions::replace('Hi {{ x }} [[ first_name ]]', ['first_name' => 'John', 'last_name' => 'Doe'], using: fn ($template) => str_replace('{{ x }}', '[[ last_name ]]', $template)) + ); + } + + #[Test] + public function a_forged_placeholder_with_a_different_nonce_stays_literal() + { + $forged = 'mention'.str_repeat('0', 16).'n0e'; + + $this->assertSame( + "$forged John", + Mentions::replace('{{ x }} [[ a ]]', ['a' => 'John'], using: fn ($template) => str_replace('{{ x }}', $forged, $template)) + ); + } + + #[Test] + public function values_come_out_verbatim_when_using_a_callback() + { + $values = [ + 'a' => '{{ config:app.key }}', + 'b' => '[[ a ]]', + 'c' => 'C:\\path \\[[ x ]] \\\\', + ]; + + $this->assertSame( + '{{ config:app.key }} | [[ a ]] | C:\\path \\[[ x ]] \\\\', + Mentions::replace('[[ a ]] | [[ b ]] | [[ c ]]', $values, using: fn ($template) => $template) + ); + } + + #[Test] + public function escaped_literals_still_come_out_as_visible_brackets_when_using_a_callback() + { + $this->assertSame( + '[[ a ]] then John', + Mentions::replace('\\[[ a ]] then [[ a ]]', ['a' => 'John'], using: fn ($template) => $template) + ); + + $this->assertSame( + 'See [[ a ]] then John', + Mentions::replace('See [[ a ]] then [[ a ]]', ['a' => 'John'], using: fn ($template) => $template) + ); + } + + #[Test] + public function unknown_handles_resolve_to_an_empty_string_when_using_a_callback() + { + $this->assertSame( + 'Hi , thanks!', + Mentions::replace('Hi [[ nope ]], thanks!', [], using: fn ($template) => $template) + ); + + $this->assertSame( + 'Hi John, thanks!', + Mentions::replace('Hi [[ a ]], [[ nope ]] thanks!', ['a' => 'John'], using: fn ($template) => $template) + ); + } + + #[Test] + public function the_callback_may_drop_or_repeat_a_placeholder() + { + $values = ['a' => 'John', 'b' => 'Doe']; + + $this->assertSame( + 'Doe', + Mentions::replace('[[ a ]] [[ b ]]', $values, using: fn ($template) => explode(' ', $template)[1]) + ); + + $this->assertSame( + 'John John Doe', + Mentions::replace('[[ a ]] [[ b ]]', $values, using: function ($template) { + [$a, $b] = explode(' ', $template); + + return "$a $a $b"; + }) + ); + } + + #[Test] + public function the_callback_return_value_is_cast_to_a_string() + { + $this->assertSame('', Mentions::replace('[[ a ]]', ['a' => 'John'], using: fn () => null)); + } + + #[Test] + public function placeholders_are_distinct_with_ten_or_more_handles() + { + $values = []; + for ($i = 0; $i < 12; $i++) { + $values["h$i"] = "v$i"; + } + + $content = implode(' ', array_map(fn ($handle) => "[[ $handle ]]", array_keys($values))); + + $this->assertSame( + implode(' ', $values), + Mentions::replace($content, $values, using: fn ($template) => $template) + ); + } + + #[Test] + public function submitted_text_survives_antlers_in_a_plain_text_value() + { + $data = ['name' => '[[ secret ]] {{ config:app.key }} \\[[ secret ]] \\\\ C:\\x']; + + $this->assertSame( + 'Hi '.$data['name'].' from Jane', + Mentions::replace( + 'Hi {{ name }} from [[ who ]]', + ['who' => 'Jane', 'secret' => 'TOPSECRET'], + using: fn ($template) => (string) Antlers::parse($template, $data) + ) + ); + } + + #[Test] + public function submitted_text_survives_antlers_in_a_markdown_value() + { + $data = ['name' => '[[ secret ]] {{ config:app.key }} \\[[ secret ]] \\\\ C:\\x']; + + $html = AutocompleteEditor::parseMarkdown('Hi {{ name }} from [[ who ]] and \\[\\[ who \\]\\]', inline: true); + + $this->assertSame( + 'Hi '.$data['name'].' from Jane and [[ who ]]', + Mentions::replace( + $html, + ['who' => 'Jane', 'secret' => 'TOPSECRET'], + using: fn ($template) => (string) Antlers::parse($template, $data) + ) + ); + } +} diff --git a/tests/Fieldtypes/AutocompleteEditorTest.php b/tests/Fieldtypes/AutocompleteEditorTest.php new file mode 100644 index 00000000000..14c2bf285aa --- /dev/null +++ b/tests/Fieldtypes/AutocompleteEditorTest.php @@ -0,0 +1,552 @@ +assertFalse((new AutocompleteEditor)->selectable()); + } + + #[Test] + public function it_has_expected_config_defaults() + { + $fieldtype = $this->fieldtype(); + + $this->assertSame('@', $fieldtype->config('trigger')); + $this->assertNull($fieldtype->config('placeholder')); + $this->assertFalse($fieldtype->config('inline')); + $this->assertFalse($fieldtype->config('enable_line_breaks')); + $this->assertTrue($fieldtype->config('markdown')); + $this->assertSame( + ['bold', 'italic', 'h2', 'h3', 'bulletlist', 'orderedlist'], + $fieldtype->config('buttons') + ); + } + + #[Test] + public function config_values_can_be_overridden() + { + $fieldtype = $this->fieldtype([ + 'trigger' => '#', + 'inline' => true, + 'enable_line_breaks' => true, + 'buttons' => ['bold'], + ]); + + $this->assertSame('#', $fieldtype->config('trigger')); + $this->assertTrue($fieldtype->config('inline')); + $this->assertTrue($fieldtype->config('enable_line_breaks')); + $this->assertSame(['bold'], $fieldtype->config('buttons')); + } + + #[Test] + public function it_processes_null_and_empty_values_to_null() + { + $fieldtype = $this->fieldtype(); + + $this->assertNull($fieldtype->process(null)); + $this->assertNull($fieldtype->process('')); + } + + #[Test] + public function it_processes_a_markdown_string_unchanged() + { + $fieldtype = $this->fieldtype(); + + $this->assertSame('Hi [[ first_name ]], thanks!', $fieldtype->process('Hi [[ first_name ]], thanks!')); + } + + #[Test] + public function preload_normalizes_a_list_of_plain_string_options() + { + $fieldtype = $this->fieldtype([ + 'options' => ['red', 'green'], + ]); + + $this->assertSame( + [ + ['value' => 'red', 'label' => 'red'], + ['value' => 'green', 'label' => 'green'], + ], + $fieldtype->preload()['options'] + ); + } + + #[Test] + public function preload_normalizes_an_associative_array_of_options() + { + $fieldtype = $this->fieldtype([ + 'options' => ['red' => 'Red', 'green' => 'Green'], + ]); + + $this->assertSame( + [ + ['value' => 'red', 'label' => 'Red'], + ['value' => 'green', 'label' => 'Green'], + ], + $fieldtype->preload()['options'] + ); + } + + #[Test] + public function preload_normalizes_key_value_rows_from_the_array_fieldtype() + { + $fieldtype = $this->fieldtype([ + 'options' => [ + ['key' => 'red', 'value' => 'Red'], + ['key' => 'green', 'value' => 'Green'], + ], + ]); + + $this->assertSame( + [ + ['value' => 'red', 'label' => 'Red'], + ['value' => 'green', 'label' => 'Green'], + ], + $fieldtype->preload()['options'] + ); + } + + #[Test] + public function preload_accepts_values_matching_the_token_grammar() + { + $fieldtype = $this->fieldtype(['options' => ['first_name' => 'First', 'user.last-name' => 'Last']]); + + $this->assertSame(['first_name', 'user.last-name'], collect($fieldtype->preload()['options'])->pluck('value')->all()); + } + + #[Test] + public function preload_casts_numeric_values_to_strings() + { + $fieldtype = $this->fieldtype(['options' => [1 => 'One', 2 => 'Two']]); + + $this->assertSame(['1', '2'], collect($fieldtype->preload()['options'])->pluck('value')->all()); + } + + #[Test] + #[DataProvider('invalidOptionValues')] + public function preload_rejects_values_outside_the_token_grammar($value) + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('['.$value.']'); + + $this->fieldtype(['options' => [$value => 'Label']])->preload(); + } + + public static function invalidOptionValues() + { + return [ + 'space' => ['a b'], + 'closing brackets' => ['a]]b'], + 'empty' => [' '], + ]; + } + + #[Test] + public function preload_returns_an_empty_options_array_when_none_are_configured() + { + $fieldtype = $this->fieldtype(); + + $this->assertSame([], $fieldtype->preload()['options']); + } + + #[Test] + public function it_augments_markdown_to_html() + { + $fieldtype = $this->fieldtype(); + + $this->assertSame( + "

Hi John

\n", + $fieldtype->augment('Hi **John**') + ); + } + + #[Test] + public function it_augments_multiple_blocks_of_markdown() + { + $fieldtype = $this->fieldtype(); + + $this->assertSame( + "

One

\n

Two

\n", + $fieldtype->augment("One\n\nTwo") + ); + } + + #[Test] + public function it_unwraps_the_paragraph_when_inline() + { + $fieldtype = $this->fieldtype(['inline' => true]); + + $this->assertSame('Hi John', $fieldtype->augment('Hi **John**')); + } + + #[Test] + public function it_leaves_multiple_paragraphs_wrapped_when_inline() + { + $fieldtype = $this->fieldtype(['inline' => true]); + + $this->assertSame("

One

\n

Two

", $fieldtype->augment("One\n\nTwo")); + } + + #[Test] + public function it_augments_null_and_empty_values_to_null() + { + $fieldtype = $this->fieldtype(); + + $this->assertNull($fieldtype->augment(null)); + $this->assertNull($fieldtype->augment('')); + } + + #[Test] + public function augment_delegates_to_the_parse_markdown_method() + { + $this->assertSame(AutocompleteEditor::parseMarkdown('Hi **John**'), $this->fieldtype()->augment('Hi **John**')); + $this->assertSame(AutocompleteEditor::parseMarkdown('Hi **John**', inline: true), $this->fieldtype(['inline' => true])->augment('Hi **John**')); + } + + #[Test] + public function parse_markdown_unwraps_the_paragraph_when_inline() + { + $this->assertSame('

Hi John

', trim(AutocompleteEditor::parseMarkdown('Hi **John**'))); + $this->assertSame('Hi John', AutocompleteEditor::parseMarkdown('Hi **John**', inline: true)); + } + + #[Test] + public function parse_markdown_escapes_raw_html() + { + $this->assertSame( + '<script>alert(1)</script>', + trim(AutocompleteEditor::parseMarkdown('')) + ); + + $this->assertSame( + 'Hi <a href="https://example.com">x</a> <b>', + AutocompleteEditor::parseMarkdown('Hi x <b>', inline: true) + ); + } + + #[Test] + public function parse_markdown_drops_unsafe_links() + { + $html = AutocompleteEditor::parseMarkdown('[x](javascript:alert(1))', inline: true); + + $this->assertStringNotContainsString('javascript:', $html); + } + + #[Test] + public function augment_parses_markdown_without_a_field() + { + $this->assertSame('

Hi John

', trim((new AutocompleteEditor)->augment('Hi **John**'))); + } + + #[Test] + public function it_leaves_mention_tokens_alone_when_augmenting() + { + $fieldtype = $this->fieldtype(); + + $this->assertSame("

Hi [[ first_name ]]

\n", $fieldtype->augment('Hi [[ first_name ]]')); + } + + #[Test] + public function antlers_is_parsed_after_the_markdown_is_augmented() + { + $fieldtype = $this->fieldtype(['antlers' => true, 'inline' => true]); + + $value = new Value('Hi **{{ first_name }}**', 'test', $fieldtype); + + $this->assertSame( + 'Hi John', + (string) Antlers::parse('{{ test }}', ['test' => $value, 'first_name' => 'John']) + ); + } + + #[Test] + public function antlers_is_not_parsed_when_the_config_is_disabled() + { + $fieldtype = $this->fieldtype(['inline' => true]); + + $value = new Value('Hi {{ first_name }}', 'test', $fieldtype); + + $this->assertSame( + 'Hi {{ first_name }}', + (string) Antlers::parse('{{ test }}', ['test' => $value, 'first_name' => 'John']) + ); + } + + #[Test] + public function an_escaped_literal_survives_augmenting_while_a_real_mention_resolves() + { + $fieldtype = $this->fieldtype(); + + $html = $fieldtype->augment('Hi [[ first_name ]], type \\[\\[ first_name \\]\\] literally'); + + $this->assertSame( + "

Hi John, type [[ first_name ]] literally

\n", + html_entity_decode(Mentions::replace($html, ['first_name' => 'John'])) + ); + $this->assertStringContainsString('type [[ first_name ]] literally', $html); + $this->assertStringContainsString('Hi [[ first_name ]],', $html); + } + + #[Test] + public function an_escaped_literal_survives_augmenting_when_inline() + { + $fieldtype = $this->fieldtype(['inline' => true]); + + $html = $fieldtype->augment('[[ a ]] \\[\\[ a \\]\\] **[[ a ]]**'); + + $this->assertSame('1 [[ a ]] 1', Mentions::replace($html, ['a' => '1'])); + } + + #[Test] + public function an_escaped_literal_survives_antlers_and_mention_replacement() + { + $fieldtype = $this->fieldtype(['antlers' => true, 'inline' => true]); + + $value = new Value('Hi [[ name ]] \\[\\[ name \\]\\] {{ other }}', 'test', $fieldtype); + + $parsed = (string) Antlers::parse('{{ test }}', ['test' => $value, 'other' => 'X']); + + $this->assertSame('Hi John [[ name ]] X', Mentions::replace($parsed, ['name' => 'John'])); + } + + #[Test] + public function mention_tokens_keep_their_original_spacing_and_work_in_link_text_and_code() + { + $fieldtype = $this->fieldtype(['inline' => true]); + + $html = $fieldtype->augment('[[a]] [[ b ]] [[[ c ]]](https://example.com) `[[ d ]]`'); + + $this->assertSame('[[a]] [[ b ]] [[ c ]] [[ d ]]', $html); + } + + #[Test] + public function mentions_inside_tags_stay_literal() + { + $html = AutocompleteEditor::parseMarkdown('[x]([[ url ]] "[[ title ]]") ![[[ alt ]]]([[ src ]]) [y](https://example.com/?id=[[ id ]])', inline: true); + + $this->assertSame( + 'x [[ alt ]] y', + $html + ); + } + + #[Test] + public function values_are_limited_in_length() + { + $rules = (new Field('body', ['type' => 'autocomplete_editor']))->rules(); + + $this->assertTrue(Validator::make(['body' => str_repeat('a', 65535)], $rules)->passes()); + $this->assertTrue(Validator::make(['body' => null], $rules)->passes()); + $this->assertFalse(Validator::make(['body' => str_repeat('a', 65536)], $rules)->passes()); + } + + #[Test] + public function very_long_backslash_runs_render() + { + $run = str_repeat('\\', 200_000); + + $html = AutocompleteEditor::renderHtml($run.' x '.$run.'[[ a ]]', ['a' => '1'], inline: true); + + $this->assertSame(str_repeat('\\', 100_000).' x '.str_repeat('\', 100_000).'1', $html); + } + + #[Test] + public function a_mention_value_cannot_become_a_javascript_url() + { + $html = AutocompleteEditor::renderHtml('[x]([[ url ]])', ['url' => 'javascript:alert(1)']); + + $this->assertStringNotContainsString('javascript:', $html); + } + + #[Test] + public function an_escaped_backslash_before_a_real_token_still_resolves() + { + $fieldtype = $this->fieldtype(['inline' => true]); + + $html = $fieldtype->augment('\\\\[[ a ]]'); + + $this->assertSame('\[[ a ]]', $html); + $this->assertSame('\1', Mentions::replace($html, ['a' => '1'])); + } + + #[Test] + public function augmented_markdown_resolves_the_same_as_before_plain_text_escapes_existed() + { + $fieldtype = $this->fieldtype(['inline' => true]); + $values = ['a' => '1']; + + $cases = [ + '[[ a ]]' => '1', + 'x \\[\\[ a \\]\\] [[ a ]]' => 'x [[ a ]] 1', + '\\\\[[ a ]]' => '\1', + '\\\\\\\\[[ a ]]' => '\\1', + '\\[[[ a ]]' => '[1', + '\\\\\\[[[ a ]]' => '\[1', + '`\\[[ a ]]`' => '\1', + ]; + + foreach ($cases as $markdown => $expected) { + $this->assertSame($expected, Mentions::replace($fieldtype->augment($markdown), $values), $markdown); + } + } + + #[Test] + public function augmented_markdown_never_leaves_a_backslash_or_bracket_where_replace_would_treat_it_as_an_escape() + { + $fieldtype = $this->fieldtype(['inline' => true]); + $values = ['a' => '1']; + $alphabet = ['\\', '[', ']', ' ', 'x', '`', '[[ a ]]', '\\[\\[']; + + $sequences = ['']; + + for ($length = 1; $length <= 4; $length++) { + $sequences = collect($sequences)->flatMap(fn ($sequence) => collect($alphabet)->map(fn ($item) => $sequence.$item))->all(); + + foreach ($sequences as $markdown) { + $html = (string) $fieldtype->augment($markdown); + + $before = preg_replace_callback( + Mentions::TOKEN, + fn ($matches) => $values[$matches[1]] ?? '', + str_replace('\', '\\', $html) + ); + + $this->assertSame( + html_entity_decode($before), + html_entity_decode(Mentions::replace($html, $values)), + $markdown + ); + } + } + } + + #[Test] + public function text_resembling_the_sentinel_is_left_alone() + { + $fieldtype = $this->fieldtype(['inline' => true]); + + $this->assertSame('mentionabcn0e [[ a ]]', $fieldtype->augment('mentionabcn0e [[ a ]]')); + } + + #[Test] + public function it_augments_plain_text_unchanged_when_markdown_is_disabled() + { + $fieldtype = $this->fieldtype(['markdown' => false]); + + $value = "**Hi** _there_ # \\ `x` - 1. > Tom & Jerry \\[[ literal ]] [[ first_name ]]\nline two"; + + $this->assertSame($value, $fieldtype->augment($value)); + } + + #[Test] + public function it_augments_null_and_empty_plain_text_to_null() + { + $fieldtype = $this->fieldtype(['markdown' => false]); + + $this->assertNull($fieldtype->augment(null)); + $this->assertNull($fieldtype->augment('')); + } + + #[Test] + public function render_html_escapes_values() + { + $html = AutocompleteEditor::renderHtml('Hi [[ name ]]', ['name' => 'Tom & "Jerry" \'s'], inline: true); + + $this->assertSame('Hi <b>Tom</b> & "Jerry" 's', $html); + } + + #[Test] + public function render_html_turns_newlines_in_values_into_line_breaks() + { + $html = AutocompleteEditor::renderHtml('Hi [[ name ]]', ['name' => "Tom\nJerry"], inline: true); + + $this->assertSame("Hi Tom
\nJerry", $html); + } + + #[Test] + public function render_html_inserts_htmlable_values_as_is() + { + $html = AutocompleteEditor::renderHtml('Hi [[ name ]]', ['name' => new HtmlString("Tom\nJerry")], inline: true); + + $this->assertSame("Hi Tom\nJerry", $html); + } + + #[Test] + public function render_html_unwraps_the_paragraph_only_when_inline() + { + $this->assertSame('Hi Tom', AutocompleteEditor::renderHtml('Hi [[ name ]]', ['name' => 'Tom'], inline: true)); + $this->assertSame('

Hi Tom

', trim(AutocompleteEditor::renderHtml('Hi [[ name ]]', ['name' => 'Tom']))); + } + + #[Test] + public function render_html_resolves_unknown_handles_to_an_empty_string() + { + $this->assertSame('Hi ', AutocompleteEditor::renderHtml('Hi [[ nope ]]', ['name' => 'Tom'], inline: true)); + } + + #[Test] + public function render_html_keeps_an_authors_escaped_literal() + { + $html = AutocompleteEditor::renderHtml('\\[\\[ x \\]\\] and [[ x ]]', ['x' => 'X'], inline: true); + + $this->assertSame('[[ x ]] and X', $html); + } + + #[Test] + public function render_html_returns_an_empty_string_for_empty_markdown() + { + $this->assertSame('', AutocompleteEditor::renderHtml('', ['name' => 'Tom'])); + } + + #[Test] + public function render_html_passes_the_callback_through_without_exposing_values() + { + $data = ['name' => 'Tom', 'note' => 'a [[ secret ]] and {{ config:app.key }}']; + + $html = AutocompleteEditor::renderHtml( + 'Hi [[ name ]], {{ note }} / [[ note ]]', + ['name' => 'Tom', 'note' => $data['note'], 'secret' => 'S3CRET'], + inline: true, + using: fn ($template) => (string) Antlers::parse($template, $data) + ); + + $this->assertSame( + 'Hi Tom, a [[ secret ]] and {{ config:app.key }} / a [[ secret ]] and {{ config:app.key }}', + $html + ); + } + + #[Test] + public function render_html_matches_doing_it_by_hand() + { + $markdown = "# Hi [[ name ]]\n\n**Bold** [[ note ]] \\[\\[ x \\]\\] [[ missing ]]"; + $values = ['name' => 'Tom & Jerry', 'note' => "a\nb "]; + + $expected = Mentions::replace( + AutocompleteEditor::parseMarkdown($markdown), + ['name' => 'Tom & Jerry', 'note' => "a
\nb <i>"] + ); + + $this->assertSame($expected, AutocompleteEditor::renderHtml($markdown, $values)); + } + + private function fieldtype($config = []) + { + return (new AutocompleteEditor)->setField(new Field('test', array_merge(['type' => 'autocomplete_editor'], $config))); + } +}