Acronym UI
WordPress-плагин: оборачивает акронимы и сокращения в <abbr> с настраиваемой подсказкой.
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/geliorn/acronym-ui/archive/refs/heads/main.zipОборачивает акронимы и сокращения в постах и комментариях в <abbr title="…"> с настраиваемой
подсказкой: пунктирное подчёркивание, значок [?], тултип по hover/focus на десктопе и по клику/тапу
на мобильных. Переписан с нуля на основе Acronyms 2
(Chris Cox, GPLv2+) под современный PHP 8 / WordPress — с сохранением исходной идеи (глоссарий
терминов + замена в контенте/комментариях + управление списком в админке), но без её проблем.
Содержание
- Что изменилось относительно оригинала
- Архитектура и карта файлов
- Модель данных
- Алгоритм замены
- Настройки
- Админка: маршруты и защита
- Фронтенд: CSS и тултип
- Миграция и удаление
- Хуки для разработчиков
- Требования и известные ограничения
Что изменилось относительно оригинала
Оригинал (acronyms.php, 2.0.10) заменял акронимы одним preg_replace на каждый акроним из
словаря прямо по сырому HTML, с лукэраундами вида
(?!<[^<>]*?)(?<![?.&])\bACRONYM\b(?!:)(?![^<>]*?>). Эта конструкция защищает только от попадания
внутрь тега (в атрибуты) — но не от попадания в текст заголовка или ссылки, поэтому акронимы в
<h2>API</h2> или <a>API</a> подсвечивались точно так же, как в обычном абзаце. Плюс: тег
<acronym> для не-HTML5-тем (сам <acronym> убран из спецификации HTML5, так что ветка была мёртвым
кодом уже много лет), действия админки через $_GET, несогласованные capability-проверки
(edit_others_posts для страницы, manage_categories для удаления, ничего — для добавления/правки
кроме nonce), attribute_escape() вместо esc_attr(), вывод в таблицу без экранирования,
register_uninstall_hook на класс в главном файле, самодельная HTML-таблица с пагинацией.
Изменения в этой версии:
| Область | Было (Acronyms 2) | Стало (Acronym UI) |
|---|---|---|
| Разбор HTML | один preg_replace с лукэраундами на весь HTML, в цикле по каждому акронизму |
HTML разбирается на теги/текст-токены; замена только в тексте вне «запрещённых» тегов (class-aui-replacer.php) |
| Заголовки и ссылки | подсвечивались наравне с обычным текстом | исключены явно (h1–h6, a), см. Алгоритм замены |
| Производительность | N вызовов preg_replace за рендер (по числу акронимов в словаре) |
1 комбинированный regex-паттерн (alternation) на все акронимы, 1 проход по каждому текстовому токену |
| Повтор одного акронима | подсвечивалось каждое вхождение | настраиваемый лимит max_highlights (по умолчанию 1 — только первое вхождение на странице) |
| Тег разметки | <acronym> или <abbr> в зависимости от current_theme_supports('html5') |
всегда <abbr> |
| Подсказка на мобильных | нет (title не работает без hover) |
кастомный JS-тултип, открывается по тапу |
| Стилизация | нет вообще | пунктирное подчёркивание + значок [?], оба вкл/выкл и настраиваемы (цвет, глиф) |
| Изменение данных в админке | GET-запросы (add-acronym, edit-acronym, …) |
POST через admin-post.php, везде nonce + current_user_can('manage_options') |
| Список акронимов в админке | самодельная HTML-таблица + ручная пагинация | WP_List_Table (поиск, сортировка, пагинация, bulk-delete) |
| Удаление плагина | register_uninstall_hook на класс в главном файле |
отдельный uninstall.php |
| Данные старого плагина | — | переносятся один раз при активации, если найдены |
Архитектура и карта файлов
plugins/acronym-ui/
├── acronym-ui.php # заголовок плагина, константы, require, boot-функция, activation hook
├── uninstall.php # чистка опций при удалении плагина
├── README.md
├── includes/
│ ├── class-aui-repository.php # CRUD над словарём акронимов (опция aui_acronyms)
│ ├── class-aui-install.php # activation: дефолтный словарь + миграция со старого плагина
│ ├── class-aui-settings.php # опция aui_settings + enqueue фронтенд-ассетов + CSS-переменные
│ ├── class-aui-replacer.php # ядро: tag-aware замена текста в the_content/comment_text
│ ├── class-aui-list-table.php # WP_List_Table для списка акронимов (только is_admin())
│ └── class-aui-admin-page.php # страница Tools → Acronyms: форма + список + обработчики POST (только is_admin())
└── assets/
├── css/
│ ├── acronym-ui.css # фронтенд: подчёркивание, значок, бабл подсказки
│ └── admin.css # мелкие правки страницы Tools → Acronyms
└── js/
└── acronym-ui.js # тултип: hover/focus (десктоп) + click/tap (мобильные), без jQuery
class-aui-list-table.php и class-aui-admin-page.php подключаются в acronym-ui.php только внутри
if ( is_admin() ) — на фронтенде WordPress эти файлы вообще не загружаются (в том числе не тянется
wp-admin/includes/class-wp-list-table.php), чтобы не тратить память на каждый публичный запрос.
Классы без PHP-неймспейса, с префиксом AUI_ (как LAGR_ в соседнем llm-agent-github-repos —
единый стиль для плагинов в этом репозитории). Все классы статические (без состояния экземпляра,
кроме AUI_List_Table, которая обязана быть объектом по контракту WP_List_Table) и сами
регистрируют свои хуки в собственном init(), который дергается один раз из aui_boot()
(plugins_loaded).
Модель данных
Всё хранится в двух опциях wp_options (без отдельных таблиц — словарь и настройки не настолько
велики, чтобы это того стоило):
aui_acronyms— ассоциативный массив'ACRONYM' => 'Full text'. Владелец —AUI_Repository(get_all(),get(),save(),delete(),delete_many(),search(),count()). Ключи — акронимы как они появляются в тексте (регистрозависимо, как и в оригинале).aui_settings— ассоциативный массив настроек отображения (см. Настройки). Владелец —AUI_Settings(get_all(),get( $key, $default ),update( array $values ));get_all()всегда домешиваетdefaults()черезwp_parse_args(), так что частично сохранённая опция (после апгрейда плагина с новыми ключами) не ломает чтение.aui_migrated_from_legacy— служебный флаг (1), чтобы миграция со старого плагина (см. Миграция и удаление) выполнялась ровно один раз.
Алгоритм замены (AUI_Replacer::replace())
Метод — публичный статический AUI_Replacer::replace( string $html ): string, навешан на
the_content и comment_text (каждый — опционально, в зависимости от настроек) с приоритетом 20,
чтобы отработать после большинства других фильтров контента (шорткоды, embeds и т.п.), но до вывода.
- Построение паттерна (
build_pattern()): ключи словаря сортируются по убыванию длины (чтобыIE6матчился раньшеIEи не оставлял висящий6), экранируютсяpreg_quote()и собираются в один паттерн/\b(ACR1|ACR2|…)\b/u. Строится один раз на вызовreplace(), а не на каждый акроним — это и есть замена N проходовpreg_replaceиз оригинала на 1 проходpreg_replace_callbackпо каждому текстовому токену. - Токенизация:
preg_split('/(<[^>]+>)/', $html, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY)режет HTML на чередующиеся токены «тег» / «текст между тегами». - Обход токенов с счётчиком вложенности
$skip_depth:- Токен-тег: регэкспом достаётся имя тега и признак закрывающего (
/tag). Если имя есть вSKIP_TAGS,$skip_depthувеличивается на открывающем и уменьшается на закрывающем теге (не уходя ниже 0). Сам токен-тег всегда выводится как есть — подстановка никогда не трогает атрибуты. - Токен-текст: если
$skip_depth === 0(мы сейчас не внутри «запрещённого» тега) — к тексту применяетсяpreg_replace_callback()с собранным паттерном; иначе текст выводится без изменений.
- Токен-тег: регэкспом достаётся имя тега и признак закрывающего (
SKIP_TAGS(список тегов, внутри которых акронимы никогда не подсвечиваются, включая их потомков):a, h1–h6, abbr, script, style, code, pre, kbd, textarea, select, option, button, template, title.aиh1–h6закрывают явное требование «не срабатывать в заголовках и ссылках»;abbrне даёт задвоить уже вручную проставленную разметку; остальные — чтобы не портить программные примеры и служебную разметку. Список фильтруется черезapply_filters('aui_skip_tags', …).- Подстановка одного вхождения (
render_match()): если у акронима ещё не исчерпан лимитmax_highlights— счётчикself::$seen_counts[$acronym](статик-массив, живёт в рамках одного HTTP-запроса) увеличивается и возвращается<abbr class="aui-acronym" title="{fulltext}" tabindex="0">{acronym}</abbr>(оба значения экранированыesc_attr()/esc_html()); если лимит уже исчерпан — акроним возвращается как обычный текст без изменений. Итоговая разметка одного вхождения дополнительно проходит черезapply_filters('aui_acronym_html', …).
Счётчик $seen_counts общий для the_content и comment_text в рамках одного запроса — поэтому
лимит держится «на странице» целиком: и на архиве с несколькими постами, и на посте с комментариями
под ним, а не только в пределах одного отдельного вызова фильтра.
Пример (при max_highlights = 1, словарь по умолчанию содержит LLM → Large Language Model):
<!-- вход -->
<h2>Про LLM</h2>
<p>LLM — это <a href="/wiki/llm">LLM</a>, LLM встречается тут ещё раз.</p>
<!-- выход -->
<h2>Про LLM</h2>
<p><abbr class="aui-acronym" title="Large Language Model" tabindex="0">LLM</abbr> — это
<a href="/wiki/llm">LLM</a>, LLM встречается тут ещё раз.</p>
Заголовок и текст ссылки не тронуты; из трёх оставшихся вхождений в параграфе подсвечено только
первое (лимит — 1), второе и третье остаются обычным текстом несмотря на то, что они вне a/h2.
Настройки
Все настройки — под aui_settings, редактируются на Tools → Acronyms (AUI_Settings::defaults()):
| Ключ | По умолчанию | Назначение |
|---|---|---|
enable_content |
1 |
Подключать ли фильтр the_content |
enable_comments |
1 |
Подключать ли фильтр comment_text |
enable_underline |
1 |
Пунктирное подчёркивание (body.aui-no-underline снимает его через CSS) |
enable_icon |
1 |
Значок после акронима (body.aui-no-icon скрывает через content: none) |
icon_glyph |
[?] |
Текст значка, попадает в CSS-переменную --aui-icon-glyph |
underline_color |
#666666 |
Цвет подчёркивания (--aui-underline-color), валидируется sanitize_hex_color() |
icon_color |
#666666 |
Цвет значка (--aui-icon-color), валидируется sanitize_hex_color() |
max_highlights |
1 |
Сколько раз один акроним подсвечивается на странице; 0 — без ограничения |
enable_content/enable_comments читаются один раз при AUI_Replacer::init() (на plugins_loaded)
— решают, регистрировать ли сам фильтр; enable_underline/enable_icon/цвета/глиф читаются на каждый
показ страницы в AUI_Settings::enqueue_frontend_assets() и превращаются в инлайн `