Add unified string utilities to PHP and JS

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2026-01-28 18:37:22 +00:00
parent 6dde3bc4e7
commit ed9bf6f23c
2 changed files with 267 additions and 0 deletions

View File

@@ -395,6 +395,154 @@ function ucwords(input) {
.join(' ');
}
/**
* Make a string safe for use as a variable/function name
* @param {string} string - Input string
* @param {number} [max_length=64] - Maximum length
* @returns {string} Safe string
*/
function safe_string(string, max_length = 64) {
// Replace non-alphanumeric with underscores
string = String(string).replace(/[^a-zA-Z0-9_]+/g, '_');
// Ensure first character is not a number
if (string === '' || /^[0-9]/.test(string)) {
string = '_' + string;
}
// Trim to max length
return string.substring(0, max_length);
}
/**
* Convert snake_case to camelCase
* @param {string} string - Snake case string
* @param {boolean} [capitalize_first=false] - Whether to capitalize first letter (PascalCase)
* @returns {string} Camel case string
*/
function snake_to_camel(string, capitalize_first = false) {
let result = String(string).replace(/_([a-z])/g, (match, letter) => letter.toUpperCase());
if (capitalize_first && result.length > 0) {
result = result.charAt(0).toUpperCase() + result.slice(1);
} else if (!capitalize_first && result.length > 0) {
result = result.charAt(0).toLowerCase() + result.slice(1);
}
return result;
}
/**
* Convert camelCase to snake_case
* @param {string} string - Camel case string
* @returns {string} Snake case string
*/
function camel_to_snake(string) {
return String(string)
.replace(/^[A-Z]/, (letter) => letter.toLowerCase())
.replace(/[A-Z]/g, (letter) => '_' + letter.toLowerCase());
}
/**
* Common TLDs for domain detection in linkify functions
* @type {string}
*/
const LINKIFY_TLDS = 'com|org|net|edu|gov|io|co|me|info|biz|us|uk|ca|au|de|fr|es|it|nl|ru|jp|cn|in|br|mx|app|dev|xyz|online|site|tech|store|blog|shop';
/**
* Convert plain text to HTML with URLs converted to hyperlinks
*
* First escapes the text to HTML, then converts URLs (with protocols) and
* domain-like text (with known TLDs) into clickable hyperlinks.
*
* @param {string|null} content - Plain text content
* @param {boolean} [new_window=true] - Whether to add target="_blank" to links
* @returns {string} HTML with clickable links
*/
function linkify_text(content, new_window = true) {
if (content == null || content === '') {
return '';
}
// First escape HTML
const escaped = html(String(content));
return _linkify_content(escaped, new_window);
}
/**
* Convert URLs in HTML to hyperlinks, preserving existing links
*
* Converts URLs (with protocols) and domain-like text (with known TLDs)
* into clickable hyperlinks, but only for text not already inside <a> tags.
*
* @param {string|null} content - HTML content
* @param {boolean} [new_window=true] - Whether to add target="_blank" to links
* @returns {string} HTML with clickable links
*/
function linkify_html(content, new_window = true) {
if (content == null || content === '') {
return '';
}
// Split content into segments: inside <a> tags and outside
const pattern = /(<a\s[^>]*>.*?<\/a>)/gi;
const segments = String(content).split(pattern);
let result = '';
for (const segment of segments) {
// Check if this segment is an <a> tag
if (/^<a\s/i.test(segment)) {
// Already a link, keep as-is
result += segment;
} else {
// Not inside a link, linkify it
result += _linkify_content(segment, new_window);
}
}
return result;
}
/**
* Internal helper to convert URLs/domains to links in content
*
* @param {string} content - Content to process
* @param {boolean} new_window - Whether to add target="_blank"
* @returns {string} Content with URLs converted to links
* @private
*/
function _linkify_content(content, new_window) {
const target = new_window ? ' target="_blank" rel="noopener noreferrer"' : '';
// Pattern for URLs with protocol
const url_pattern = /(https?:\/\/[^\s<>\[\]()]+)/gi;
// Pattern for domain-like text
const domain_pattern = new RegExp(
'\\b((?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\\.)+(' + LINKIFY_TLDS + ')(?:\\/[^\\s<>\\[\\]()]*)?)\\b',
'gi'
);
// First, replace URLs with protocol
content = content.replace(url_pattern, (match) => {
// Clean trailing punctuation that's likely not part of URL
const url = match.replace(/[.,;:!?)'\"]+$/, '');
const trailing = match.slice(url.length);
return '<a href="' + url + '"' + target + '>' + url + '</a>' + trailing;
});
// Then, replace domain-like text (but not if already inside an href)
content = content.replace(domain_pattern, (match) => {
// Clean trailing punctuation
const domain = match.replace(/[.,;:!?)'\"]+$/, '');
const trailing = match.slice(domain.length);
return '<a href="https://' + domain + '"' + target + '>' + domain + '</a>' + trailing;
});
return content;
}
// ============================================================================
// OBJECT AND ARRAY UTILITIES
// ============================================================================

View File

@@ -1551,3 +1551,122 @@ function validate_short_url(?string $url): bool
return true;
}
/**
* Escape HTML and convert newlines to <br>
*
* Combines htmlspecialchars() and nl2br() for displaying user-generated
* plain text as HTML with preserved line breaks.
*
* @param string|null $str String to process
* @return string HTML-escaped string with line breaks
*/
function htmlbr(?string $str): string
{
if ($str === null || $str === '') {
return '';
}
return nl2br(htmlspecialchars($str, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
/**
* Common TLDs for domain detection in linkify functions
*/
define('LINKIFY_TLDS', 'com|org|net|edu|gov|io|co|me|info|biz|us|uk|ca|au|de|fr|es|it|nl|ru|jp|cn|in|br|mx|app|dev|xyz|online|site|tech|store|blog|shop');
/**
* Convert plain text to HTML with URLs converted to hyperlinks
*
* First escapes the text to HTML, then converts URLs (with protocols) and
* domain-like text (with known TLDs) into clickable hyperlinks.
*
* @param string|null $content Plain text content
* @param bool $new_window Whether to add target="_blank" to links
* @return string HTML with clickable links
*/
function linkify_text(?string $content, bool $new_window = true): string
{
if ($content === null || $content === '') {
return '';
}
// First escape HTML
$html = htmlspecialchars($content, ENT_QUOTES | ENT_HTML5, 'UTF-8');
return _linkify_content($html, $new_window);
}
/**
* Convert URLs in HTML to hyperlinks, preserving existing links
*
* Converts URLs (with protocols) and domain-like text (with known TLDs)
* into clickable hyperlinks, but only for text not already inside <a> tags.
*
* @param string|null $content HTML content
* @param bool $new_window Whether to add target="_blank" to links
* @return string HTML with clickable links
*/
function linkify_html(?string $content, bool $new_window = true): string
{
if ($content === null || $content === '') {
return '';
}
// Split content into segments: inside <a> tags and outside
// Pattern matches <a ...>...</a> including nested content
$pattern = '/(<a\s[^>]*>.*?<\/a>)/is';
$segments = preg_split($pattern, $content, -1, PREG_SPLIT_DELIM_CAPTURE);
$result = '';
foreach ($segments as $segment) {
// Check if this segment is an <a> tag (starts with <a and contains </a>)
if (preg_match('/^<a\s/i', $segment)) {
// Already a link, keep as-is
$result .= $segment;
} else {
// Not inside a link, linkify it
$result .= _linkify_content($segment, $new_window);
}
}
return $result;
}
/**
* Internal helper to convert URLs/domains to links in content
*
* @param string $content Content to process (should not contain <a> tags to linkify)
* @param bool $new_window Whether to add target="_blank"
* @return string Content with URLs converted to links
*/
function _linkify_content(string $content, bool $new_window): string
{
$target = $new_window ? ' target="_blank" rel="noopener noreferrer"' : '';
$tlds = LINKIFY_TLDS;
// Pattern for URLs with protocol
$url_pattern = '/(https?:\/\/[^\s<>\[\]()]+)/i';
// Pattern for domain-like text (domain.tld or subdomain.domain.tld with optional path)
$domain_pattern = '/\b((?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+(' . $tlds . ')(?:\/[^\s<>\[\]()]*)?)\b/i';
// First, replace URLs with protocol
$content = preg_replace_callback($url_pattern, function ($matches) use ($target) {
$url = $matches[1];
// Clean trailing punctuation that's likely not part of URL
$url = rtrim($url, '.,;:!?)\'\"');
return '<a href="' . $url . '"' . $target . '>' . $url . '</a>';
}, $content);
// Then, replace domain-like text (but not if already inside an href)
$content = preg_replace_callback($domain_pattern, function ($matches) use ($target) {
$domain = $matches[1];
// Clean trailing punctuation
$domain = rtrim($domain, '.,;:!?)\'\"');
// Don't linkify if it looks like it's already in an href attribute
return '<a href="https://' . $domain . '"' . $target . '>' . $domain . '</a>';
}, $content);
return $content;
}