Skip to content
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
6 changes: 0 additions & 6 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -1872,12 +1872,6 @@ parameters:
count: 2
path: wcfsetup/install/files/lib/system/html/output/node/HtmlOutputNodeImg.class.php

-
message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
identifier: empty.notAllowed
count: 1
path: wcfsetup/install/files/lib/system/html/output/node/HtmlOutputNodePre.class.php

-
message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
identifier: empty.notAllowed
Expand Down
251 changes: 251 additions & 0 deletions wcfsetup/install/files/lib/system/code/SourceCodeRenderer.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
<?php

namespace wcf\system\code;

use wcf\system\bbcode\BBCodeHandler;
use wcf\system\Regex;
use wcf\system\WCF;

/**
* Renders source code listings including syntax highlighting.
*
* @author Marcel Werk
* @copyright 2001-2026 WoltLab GmbH
* @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
* @since 6.3
*/
class SourceCodeRenderer
{
/**
* aliases for highlighters that are not known by the client-side highlighter
* @var array<string, string>
*/
private const HIGHLIGHTER_ALIASES = [
'js' => 'javascript',
'c++' => 'cpp',
'tex' => 'latex',
'shell' => 'bash',
];

/**
* already used ids for line numbers to prevent duplicate ids in the output
* @var array<string, bool>
*/
private static array $codeIDs = [];

/**
* Renders the given source code. If no highlighter is provided, the used
* language is guessed based on the content.
*/
public function render(
string $content,
string $highlighter = '',
string $filename = '',
int $startLineNumber = 1,
string $codeIDPrefix = ''
): string {
$content = $this->trimContent($content);

if ($startLineNumber < 1) {
$startLineNumber = 1;
}

$highlighter = $this->getHighlighter($content, $highlighter);

$meta = BBCodeHandler::getInstance()->getHighlighterMeta();
$title = WCF::getLanguage()->get('wcf.bbcode.code');
if (isset($meta[$highlighter])) {
$title = $meta[$highlighter]['title'];
} else {
$highlighter = '';
}

$lines = $this->splitLines($content);

return WCF::getTPL()->render('wcf', 'shared_codeMetaCode', [
'codeID' => $this->getCodeID($codeIDPrefix, $content),
'startLineNumber' => $startLineNumber,
'content' => $lines,
'language' => $highlighter,
'filename' => $filename,
'title' => $title,
'lines' => \count($lines),
]);
}

/**
* Removes a leading and a trailing empty line from the given content.
*/
public function trimContent(string $content): string
{
$content = \preg_replace('/^\s*\n/', '', $content);

return \preg_replace('/\n\s*$/', '', $content);
}

/**
* Returns the highlighter that should be used for the given content. The
* highlighter is guessed if no highlighter is provided.
*/
public function getHighlighter(string $content, string $highlighter = ''): string
{
$highlighter = $this->normalizeHighlighter($highlighter);
if ($highlighter === '') {
$highlighter = $this->guessHighlighter($content);
}

return $highlighter;
}

/**
* Resolves known aliases of highlighter names.
*/
public function normalizeHighlighter(string $highlighter): string
{
return self::HIGHLIGHTER_ALIASES[$highlighter] ?? $highlighter;
}

/**
* Splits the content into single lines while preserving the line breaks.
*
* @return string[]
*/
public function splitLines(string $content): array
{
$lines = \explode("\n", $content);
$last = \array_pop($lines);
$lines = \array_map(static fn (string $line) => $line . "\n", $lines);
$lines[] = $last;

return $lines;
}

/**
* Returns a likely highlighter for the given content.
*/
public function guessHighlighter(string $content): string
{
// PHP at the beginning is almost surely PHP.
if (\str_starts_with($content, '<?php')) {
return 'php';
}

if (
\str_starts_with($content, 'SELECT')
|| \str_starts_with($content, 'UPDATE')
|| \str_starts_with($content, 'INSERT')
|| \str_starts_with($content, 'DELETE')
) {
return 'sql';
}

if (\str_contains($content, 'import java.')) {
return 'java';
}

if (\str_contains($content, 'using System;')) {
return 'csharp';
}

if (
\str_contains($content, "---")
&& \str_contains($content, "\n+++")
) {
return 'diff';
}

if (\str_contains($content, "\n#include ")) {
return 'c';
}

if (\str_starts_with($content, '#!/usr/bin/perl')) {
return 'perl';
}

if (
\str_starts_with($content, '#!/usr/bin/python')
|| \str_contains($content, 'def __init__(self')
|| Regex::compile("from (\\S+) import (\\S+)")->match($content) !== 0
) {
return 'python';
}

if (Regex::compile('^#!(/usr)?/bin/(ba|z)?sh')->match($content) !== 0) {
return 'bash';
}

if (
\str_starts_with($content, 'FROM')
&& \str_contains($content, "RUN")
) {
return 'docker';
}

if (
\stripos($content, "RewriteRule") !== false
|| \stripos($content, "RewriteEngine On") !== false
|| \stripos($content, "AuthUserFile") !== false
) {
return 'apacheconf';
}

if (\str_contains($content, '\\documentclass')) {
return 'latex';
}

// PHP somewhere later might not necessarily be PHP, it could also be
// a .patch or a Dockerfile.
if (\str_contains($content, '<?php')) {
return 'php';
}

if (
\str_contains($content, '{/if}')
&& (
\str_contains($content, '<div')
|| \str_contains($content, '<span')
)
) {
return 'smarty';
}

if (\str_contains($content, '<html')) {
return 'html';
}

if (\str_starts_with($content, '<?xml')) {
return 'xml';
}

if (\str_contains($content, '@mixin')) {
return 'scss';
}

if (\str_contains($content, '!important;')) {
return 'css';
}

if (\preg_match('/(^|\n)HTTP\\/[0-9]\\.[0-9] [0-9]{3}/', $content)) {
return 'http';
}

return '';
}

/**
* Returns a unique ID for the given code block.
*/
public function getCodeID(string $prefix, string $code): string
{
$i = -1;
// find an unused codeID
do {
$codeID = $prefix . \mb_substr(\sha1($code), 0, 6) . (++$i !== 0 ? '_' . $i : '');
} while (isset(self::$codeIDs[$codeID]));

// mark codeID as used
self::$codeIDs[$codeID] = true;

return $codeID;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,19 @@
namespace wcf\system\form\option;

use wcf\event\form\option\SharedConfigurationFormFieldCollecting;
use wcf\system\bbcode\BBCodeHandler;
use wcf\system\event\EventHandler;
use wcf\system\form\builder\field\AbstractNumericFormField;
use wcf\system\form\builder\field\BooleanFormField;
use wcf\system\form\builder\field\FloatFormField;
use wcf\system\form\builder\field\IFormField;
use wcf\system\form\builder\field\IntegerFormField;
use wcf\system\form\builder\field\SelectOptionsFormField;
use wcf\system\form\builder\field\SingleSelectionFormField;
use wcf\system\form\builder\field\TextFormField;
use wcf\system\form\builder\field\validation\FormFieldValidationError;
use wcf\system\form\builder\field\validation\FormFieldValidator;
use wcf\system\WCF;

/**
* Provides the available shared configuration form fields.
Expand Down Expand Up @@ -72,6 +75,12 @@ private function getDefaultFormFields(): array
'required' => BooleanFormField::create('required')
->label('wcf.form.option.shared.required')
->value(false),
'sourceCodeLanguage' => SingleSelectionFormField::create('sourceCodeLanguage')
->label('wcf.form.option.shared.sourceCodeLanguage')
->description('wcf.form.option.shared.sourceCodeLanguage.description')
->options($this->getSourceCodeLanguageOptions(), labelLanguageItems: false)
->filterable()
->nullable(),
'unit' => TextFormField::create('unit')
->label('wcf.form.option.shared.unit')
->addFieldClass('short'),
Expand All @@ -82,6 +91,23 @@ private function getDefaultFormFields(): array
];
}

/**
* Returns the list of available syntax highlighters.
*
* @return array<string, string>
*/
private function getSourceCodeLanguageOptions(): array
{
$options = [];
foreach (BBCodeHandler::getInstance()->getHighlighterMeta() as $identifier => $data) {
$options[$identifier] = $data['title'] . (\strtolower($data['title']) !== $identifier ? ' (' . $identifier . ')' : '');
}

\asort($options);

return ['' => WCF::getLanguage()->get('wcf.global.noSelection')] + $options;
}

/**
* Returns a validator that ensures that the maximum value of a form field is not
* smaller than the minimum value provided by the form field with the given id.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,23 @@ public function getId(): string
return 'sourceCode';
}

#[\Override]
public function getConfigurationFormFields(): array
{
return \array_merge(parent::getConfigurationFormFields(), ['sourceCodeLanguage']);
}

#[\Override]
public function getFormField(string $id, array $configuration = []): AbstractFormField
{
return SourceCodeFormField::create($id);
$formField = SourceCodeFormField::create($id);

$language = (string)($configuration['sourceCodeLanguage'] ?? '');
if (\in_array($language, SourceCodeFormField::LANGUAGES, true)) {
$formField->language($language);
}

return $formField;
}

#[\Override]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

namespace wcf\system\form\option\formatter;

use wcf\util\StringUtil;
use wcf\system\code\SourceCodeRenderer;

/**
* Formatter for source code values.
Expand All @@ -17,6 +17,10 @@ final class SourceCodeFormatter implements IFormOptionFormatter
#[\Override]
public function format(string $value, int $languageID, array $configuration): string
{
return '<pre style="overflow: auto">' . StringUtil::encodeHTML($value) . '</pre>';
return (new SourceCodeRenderer())->render(
$value,
(string)($configuration['sourceCodeLanguage'] ?? ''),
codeIDPrefix: 'formOption_'
);
}
}
Loading
Loading