Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

shortcode support for [quote] #3074

Merged
merged 7 commits into from
Jan 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions app/Support/Shortcode/Shortcode.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public function __construct()
->add('code', fn (ShortcodeInterface $s) => $this->renderCode($s))
->add('url', fn (ShortcodeInterface $s) => $this->renderUrlLink($s))
->add('link', fn (ShortcodeInterface $s) => $this->renderLink($s))
->add('quote', fn (ShortcodeInterface $s) => $this->renderQuote($s))
->add('spoiler', fn (ShortcodeInterface $s) => $this->renderSpoiler($s))
->add('ach', fn (ShortcodeInterface $s) => $this->embedAchievement((int) ($s->getBbCode() ?: $s->getContent())))
->add('game', fn (ShortcodeInterface $s) => $this->embedGame((int) ($s->getBbCode() ?: $s->getContent())))
Expand Down Expand Up @@ -151,11 +152,11 @@ public static function stripAndClamp(
'~\[ticket(=)?(\d+)]~i' => 'Ticket $2',

// Fragments: opening tags without closing tags.
'~\[(b|i|u|s|img|code|url|link|spoiler|ach|game|ticket|user)\b[^\]]*?\]~i' => '',
'~\[(b|i|u|s|img|code|url|link|spoiler|ach|game|ticket|user)\b[^\]]*?$~i' => '...',
'~\[(b|i|u|s|img|code|url|link|quote|spoiler|ach|game|ticket|user)\b[^\]]*?\]~i' => '',
'~\[(b|i|u|s|img|code|url|link|quote|spoiler|ach|game|ticket|user)\b[^\]]*?$~i' => '...',

// Fragments: closing tags without opening tags.
'~\[/?(b|i|u|s|img|code|url|link|spoiler|ach|game|ticket|user)\]~i' => '',
'~\[/?(b|i|u|s|img|code|url|link|quote|spoiler|ach|game|ticket|user)\]~i' => '',
];

foreach ($stripPatterns as $stripPattern => $replacement) {
Expand Down Expand Up @@ -318,6 +319,27 @@ private function renderCode(ShortcodeInterface $shortcode): string
return '<pre class="codetags">' . str_replace('<br>', '', $shortcode->getContent() ?? '') . '</pre>';
}

private function renderQuote(ShortcodeInterface $shortcode): string
{
$content = trim($shortcode->getContent() ?? '');

// $content will contain a leading and trailing <br> if the [quote] tag is on a separate line.
//
// [quote]
// This is a quote.
// [/quote]
//
// We don't want that extra whitespace in the output, so strip them. Leave any intermediary <br>s.
if (str_starts_with($content, '<br>')) {
$content = substr($content, 4);
}
if (str_ends_with($content, '<br>')) {
$content = substr($content, 0, -4);
}

return '<span class="quotedtext">' . $content . '</span>';
}

private function renderSpoiler(ShortcodeInterface $shortcode): string
{
$content = $shortcode->getContent() ?? '';
Expand Down
1 change: 1 addition & 0 deletions lang/en_US.json
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,7 @@
"Underline": "Underline",
"Strikethrough": "Strikethrough",
"Code": "Code",
"Quote": "Quote",
"Spoiler": "Spoiler",
"Image": "Image",
"Link": "Link",
Expand Down
11 changes: 11 additions & 0 deletions resources/css/devbox.css
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@
box-sizing: border-box;
}

@layer components {
.quotedtext {
border-left: 3px solid var(--text-color-muted);
padding: 4px 6px 5px 8px;
margin: 3px 0px;
background: var(--box-bg-color);
width: 100%;
display: inline-block;
}
}

.devbox > .spoiler {
display: none;
border-style: dashed;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,35 @@ describe('Component: ShortcodeCode', () => {
expect(spanEl).toHaveClass('font-mono');
expect(spanEl).toHaveClass('codetags');
});

it('removes leading line breaks in the output', () => {
// ARRANGE
render(
<ShortcodeCode>
<br />
test content
</ShortcodeCode>,
);

// ASSERT
const spanEl = screen.getByText(/test/i);
expect(spanEl).toBeVisible();
expect(spanEl.innerHTML).toEqual('test content');
});

it('retains inner line breaks in the output', () => {
// ARRANGE
render(
<ShortcodeCode>
test
<br />
content
</ShortcodeCode>,
);

// ASSERT
const spanEl = screen.getByText(/test/i);
expect(spanEl).toBeVisible();
expect(spanEl.innerHTML).toEqual('test<br>content');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,22 @@ interface ShortcodeCodeProps {
}

export const ShortcodeCode: FC<ShortcodeCodeProps> = ({ children }) => {
if (Array.isArray(children)) {
// Remove any leading newlines.
const processedChildren = children.filter((child, index) => {
if (index === 0 && typeof child === 'object' && 'type' in child && child.type === 'br') {
return false;
}
// Remove leading <br>s and empty strings until we find content.
let isLeadingWhitespace = true;
const processedChildren = (Array.isArray(children) ? children : [children]).filter((node) => {
if (isLeadingWhitespace) {
const isObjectNode = typeof node === 'object' && node !== null;
const isEmptyObject = isObjectNode && !Object.keys(node as object).length;
const isBRElement = isObjectNode && 'type' in node && node.type === 'br';
const isEmptyString = typeof node === 'string' && node.trim() === '';

return true;
});
isLeadingWhitespace = isEmptyObject || isBRElement || isEmptyString;
}

return <span className="codetags font-mono">{processedChildren}</span>;
}
// If it isn't leading whitespace, it can stay.
// Otherwise, it's filtered out.
return !isLeadingWhitespace;
});

return <span className="codetags">{children}</span>;
return <span className="codetags mb-3 font-mono">{processedChildren}</span>;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { render, screen } from '@/test';

import { ShortcodeQuote } from './ShortcodeQuote';

describe('Component: ShortcodeQuote', () => {
it('renders without crashing', () => {
// ARRANGE
const { container } = render(<ShortcodeQuote>Test content</ShortcodeQuote>);

// ASSERT
expect(container).toBeTruthy();
});

it('given a simple string child, renders it inside a span with the quotedtext class', () => {
// ARRANGE
render(<ShortcodeQuote>test content</ShortcodeQuote>);

// ASSERT
const spanEl = screen.getByText(/test content/i);

expect(spanEl).toBeVisible();
expect(spanEl).toHaveClass('quotedtext');
});

it('removes leading line breaks in the output', () => {
// ARRANGE
render(
<ShortcodeQuote>
<br />
test content
</ShortcodeQuote>,
);

// ASSERT
const spanEl = screen.getByText(/test/i);
expect(spanEl).toBeVisible();
expect(spanEl.innerHTML).toEqual('test content');
});

it('retains inner line breaks in the output', () => {
// ARRANGE
render(
<ShortcodeQuote>
test
<br />
content
</ShortcodeQuote>,
);

// ASSERT
const spanEl = screen.getByText(/test/i);
expect(spanEl).toBeVisible();
expect(spanEl.innerHTML).toEqual('test<br>content');
});
});
wescopeland marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { FC, ReactNode } from 'react';

interface ShortcodeQuoteProps {
children: ReactNode;
}

export const ShortcodeQuote: FC<ShortcodeQuoteProps> = ({ children }) => {
// Remove leading <br>s and empty strings until we find content.
let isLeadingWhitespace = true;
const processedChildren = (Array.isArray(children) ? children : [children]).filter((node) => {
if (isLeadingWhitespace) {
const isObjectNode = typeof node === 'object' && node !== null;
const isEmptyObject = isObjectNode && !Object.keys(node as object).length;
const isBRElement = isObjectNode && 'type' in node && node.type === 'br';
const isEmptyString = typeof node === 'string' && node.trim() === '';

isLeadingWhitespace = isEmptyObject || isBRElement || isEmptyString;
}

// If it isn't leading whitespace, it can stay.
// Otherwise, it's filtered out.
return !isLeadingWhitespace;
});

return <span className="quotedtext mb-3">{processedChildren}</span>;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './ShortcodeQuote';
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,20 @@ describe('Component: ShortcodeRenderer', () => {
expect(textEl).toHaveClass('codetags');
});

it('given a body with a quote tag, renders the quote shortcode component', () => {
// ARRANGE
const body = '[quote]this is some stuff inside quote tags[/quote]';

render(<ShortcodeRenderer body={body} />);

// ASSERT
const textEl = screen.getByText(/this is some stuff/i);

expect(textEl).toBeVisible();
expect(textEl.nodeName).toEqual('SPAN');
expect(textEl).toHaveClass('quotedtext');
});

it('given a body with a spoiler tag, renders the spoiler shortcode component', () => {
// ARRANGE
const body = '[spoiler]this is a spoiler![/spoiler]';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ShortcodeAch } from './ShortcodeAch';
import { ShortcodeCode } from './ShortcodeCode';
import { ShortcodeGame } from './ShortcodeGame';
import { ShortcodeImg } from './ShortcodeImg';
import { ShortcodeQuote } from './ShortcodeQuote';
import { ShortcodeSpoiler } from './ShortcodeSpoiler';
import { ShortcodeTicket } from './ShortcodeTicket';
import { ShortcodeUrl } from './ShortcodeUrl';
Expand Down Expand Up @@ -41,6 +42,11 @@ const retroachievementsPreset = presetReact.extend((tags) => ({
tag: ShortcodeCode,
}),

quote: (node) => ({
...node,
tag: ShortcodeQuote,
}),

spoiler: (node) => ({
...node,
tag: ShortcodeSpoiler,
Expand Down Expand Up @@ -111,6 +117,7 @@ export const ShortcodeRenderer: FC<ShortcodeRendererProps> = ({ body }) => {
'u',
's',
'code',
'quote',
'spoiler',
'img',
'url',
Expand Down
2 changes: 2 additions & 0 deletions resources/js/features/forums/hooks/useShortcodesList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
LuEyeOff,
LuItalic,
LuLink,
LuQuote,
LuStrikethrough,
LuUnderline,
} from 'react-icons/lu';
Expand All @@ -23,6 +24,7 @@ export function useShortcodesList() {
{ icon: LuUnderline, t_label: t('Underline'), start: '[u]', end: '[/u]' },
{ icon: LuStrikethrough, t_label: t('Strikethrough'), start: '[s]', end: '[/s]' },
{ icon: LuCode2, t_label: t('Code'), start: '[code]', end: '[/code]' },
{ icon: LuQuote, t_label: t('Quote'), start: '[quote]', end: '[/quote]' },
{ icon: LuEyeOff, t_label: t('Spoiler'), start: '[spoiler]', end: '[/spoiler]' },
{ icon: BsImageFill, t_label: t('Image'), start: '[img=', end: ']' },
{ icon: LuLink, t_label: t('Link'), start: '[url=', end: ']' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,13 @@
{{-- x-tooltip="tooltip" --}} title="{{ __('Code') }}">
<x-fas-code />
</button>
<button type="button" class="btn" onclick="injectShortcode('{{ $id }}', '[spoiler]', '[/spoiler]')"
<button type="button" class="btn" onclick="injectShortcode('{{ $id }}', '[quote]', '[/quote]')"
{{-- x-tooltip="tooltip" --}} title="{{ __('Quote') }}">
<x-fas-quote-right />
</button>
<button type="button" class="btn" onclick="injectShortcode('{{ $id }}', '[spoiler]', '[/spoiler]')"
{{-- x-tooltip="tooltip" --}} title="{{ __('Spoiler') }}">
Spoiler
<x-fas-eye-slash />
</button>
<button type="button" class="btn" onclick="injectShortcode('{{ $id }}', '[img=', ']')"
{{-- x-tooltip="tooltip" --}} title="{{ __('Image') }}">
Expand Down
5 changes: 5 additions & 0 deletions tests/Feature/Community/ShortcodeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ public function testStripAndClampFormatters(): void
Shortcode::stripAndClamp('[code]Hello[/code]')
);

$this->assertSame(
'Hello',
Shortcode::stripAndClamp('[quote]Hello[/quote]')
);

$this->assertSame(
'Hello',
Shortcode::stripAndClamp('[url=abc.xyz]Hello[/url]')
Expand Down