Skip to content

Commit

Permalink
Merge branch 'master' into separate-awards-perf
Browse files Browse the repository at this point in the history
  • Loading branch information
wescopeland committed Jan 20, 2025
2 parents ae4c71b + bc9db6e commit 876651b
Show file tree
Hide file tree
Showing 18 changed files with 344 additions and 28 deletions.
41 changes: 38 additions & 3 deletions app/Support/Shortcode/Shortcode.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@

namespace App\Support\Shortcode;

use App\Models\Achievement;
use App\Models\System;
use App\Models\Ticket;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Thunder\Shortcode\Event\FilterShortcodesEvent;
Expand All @@ -32,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 @@ -148,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 @@ -315,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 All @@ -340,6 +365,16 @@ private function embedAchievement(int $id): string
return '';
}

if ($data['ConsoleID'] === System::Events) {
$achievement = Achievement::find($id);
if ($achievement->eventData?->source_achievement_id
&& $achievement->eventData->active_from > Carbon::now()) {
$data['Title'] = $data['AchievementTitle'] = 'Upcoming Challenge';
$data['Description'] = '?????';
$data['BadgeName'] = '00000';
}
}

return achievementAvatar($data, iconSize: 24);
}

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');
});
});
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 @@ -83,4 +83,71 @@ describe('Util: preProcessShortcodesInBody', () => {
// ASSERT
expect(result).toEqual(input);
});

it('normalizes escaped newlines to actual newlines', () => {
// ARRANGE
const input = 'first line↵\nsecond line';

// ACT
const result = preProcessShortcodesInBody(input);

// ASSERT
expect(result).toEqual('first line\nsecond line');
});

it('handles mixed escaped and actual newlines', () => {
// ARRANGE
const input = 'first line↵\nsecond line\nthird line↵\nfourth line';

// ACT
const result = preProcessShortcodesInBody(input);

// ASSERT
expect(result).toEqual('first line\nsecond line\nthird line\nfourth line');
});

it('normalizes line endings when content includes shortcodes', () => {
// ARRANGE
const input =
'Check out https://retroachievements.org/user/ScottAdams↵\nAnd also↵\nhttps://retroachievements.org/game/1234';

// ACT
const result = preProcessShortcodesInBody(input);

// ASSERT
expect(result).toEqual('Check out [user=ScottAdams]\nAnd also\n[game=1234]');
});

it('handles carriage returns and different line ending styles', () => {
// ARRANGE
const input = 'line1\r\nline2\rline3\nline4';

// ACT
const result = preProcessShortcodesInBody(input);

// ASSERT
expect(result).toEqual('line1\nline2\nline3\nline4');
});

it('preserves whitespace while normalizing line endings', () => {
// ARRANGE
const input = ' indented↵\n still indented ↵\n\n more space ';

// ACT
const result = preProcessShortcodesInBody(input);

// ASSERT
expect(result).toEqual(' indented\n still indented \n\n more space ');
});

it('preserves whitespace while normalizing encoded line endings', () => {
// ARRANGE
const input = ' indented\u21B5\n still indented \u21B5\n\n more space ';

// ACT
const result = preProcessShortcodesInBody(input);

// ASSERT
expect(result).toEqual(' indented\n still indented \n\n more space ');
});
});
Loading

0 comments on commit 876651b

Please sign in to comment.