;
+
+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
\nTwo
\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
\nTwo
", $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
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)));
+ }
+}