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:
@@ -395,6 +395,154 @@ function ucwords(input) {
|
|||||||
.join(' ');
|
.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
|
// OBJECT AND ARRAY UTILITIES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -1551,3 +1551,122 @@ function validate_short_url(?string $url): bool
|
|||||||
|
|
||||||
return true;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user