Initial commit: WordPress plugins collection

This commit is contained in:
touche 2026-10-24 05:49:49 +03:00
commit d80dd91769
47 changed files with 3067 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
*.backup
.DS_Store
Thumbs.db

BIN
S1-floating-video.zip Normal file

Binary file not shown.

View File

@ -0,0 +1,83 @@
.floating-video-container {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 9999;
}
.floating-video-mini {
width: 120px;
height: 180px;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 6px 20px rgba(0,0,0,0.35);
background: #000;
position: relative;
}
.floating-video-mini video {
width: 100%;
height: 100%;
object-fit: cover;
}
.expand-btn, .close-btn {
position: absolute;
background: rgba(0,0,0,0.6);
color: white;
border: none;
border-radius: 50%;
width: 32px;
height: 32px;
font-size: 18px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.expand-btn { bottom: 8px; right: 44px; }
.close-btn { bottom: 8px; right: 8px; }
.expand-btn:hover, .close-btn:hover {
background: rgba(0,0,0,0.85);
transform: scale(1.15);
}
/* Модальное окно */
.video-modal {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.92);
z-index: 10000;
align-items: center;
justify-content: center;
padding: 20px;
box-sizing: border-box;
}
.modal-content {
position: relative;
max-width: 96vw;
max-height: 96vh;
}
.modal-content video {
max-width: 100%;
max-height: 90vh;
border-radius: 8px;
}
.modal-close {
position: absolute;
top: -45px;
right: 0;
background: none;
border: none;
color: white;
font-size: 48px;
cursor: pointer;
line-height: 1;
}

View File

@ -0,0 +1,35 @@
document.addEventListener('DOMContentLoaded', () => {
const mini = document.getElementById('miniPlayer');
const modal = document.getElementById('videoModal');
const expandBtn = mini.querySelector('.expand-btn');
const closeMini = mini.querySelector('.close-btn');
const modalClose = modal.querySelector('.modal-close');
// Развернуть видео
expandBtn.addEventListener('click', () => {
modal.style.display = 'flex';
// Можно раскомментировать, если хотите включить звук в полном экране:
// modal.querySelector('video').muted = false;
});
// Закрыть модальное окно
modalClose.addEventListener('click', () => {
modal.style.display = 'none';
});
modal.addEventListener('click', e => {
if (e.target === modal) modal.style.display = 'none';
});
// Полностью скрыть мини-плеер
closeMini.addEventListener('click', () => {
mini.style.display = 'none';
});
// Запускаем видео в мини-окне (на всякий случай)
const miniVideo = mini.querySelector('video');
miniVideo.play().catch(() => {
// Автозапуск заблокирован → почти всегда так в Chrome при первом посещении
console.log("Автозапуск заблокирован браузером");
});
});

View File

@ -0,0 +1,327 @@
<?php
/*
Plugin Name: S1 Floating Video
Plugin URI: https://1studi.ru
Description: Плавающее мини-видео в правом нижнем углу с модальным раскрытием
Version: 1.1.0
Author: S1
Text Domain: S1-floating-video
*/
// Защита от прямого доступа
if (!defined('ABSPATH')) {
exit;
}
define('EST_FLOATING_VIDEO_VERSION', '1.1.0');
define('EST_FLOATING_VIDEO_DIR', plugin_dir_path(__FILE__));
define('EST_FLOATING_VIDEO_URL', plugin_dir_url(__FILE__));
// -----------------------------
// Настройки
// -----------------------------
function est_floating_video_settings_init() {
// Регистрируем группу настроек
register_setting(
'est_floating_video_group',
'est_floating_video_options',
'est_floating_video_sanitize'
);
// Секция основных настроек
add_settings_section(
'est_main_section',
'Основные настройки плавающего видео',
'est_main_section_callback',
'est-floating-video'
);
// Поле: включить/выключить
add_settings_field(
'enabled',
'Включить видео',
'est_field_checkbox_cb',
'est-floating-video',
'est_main_section',
['id' => 'enabled']
);
// Поле: URL видео
add_settings_field(
'video_url',
'URL видео (mp4)',
'est_field_text_cb',
'est-floating-video',
'est_main_section',
['id' => 'video_url', 'type' => 'url', 'placeholder' => 'https://.../video.mp4']
);
// Поле: ширина мини-плеера
add_settings_field(
'width',
'Ширина мини-видео (px)',
'est_field_number_cb',
'est-floating-video',
'est_main_section',
['id' => 'width', 'min' => 80, 'max' => 300]
);
// Поле: высота мини-плеера
add_settings_field(
'height',
'Высота мини-видео (px)',
'est_field_number_cb',
'est-floating-video',
'est_main_section',
['id' => 'height', 'min' => 120, 'max' => 500]
);
// Поле: отступ снизу (bottom)
add_settings_field(
'bottom_offset',
'Отступ снизу (px) — полезно над чатом/кнопками',
'est_field_number_cb',
'est-floating-video',
'est_main_section',
['id' => 'bottom_offset', 'min' => 10, 'max' => 300]
);
// Поле: показывать на мобильных
add_settings_field(
'show_on_mobile',
'Показывать на мобильных устройствах',
'est_field_checkbox_cb',
'est-floating-video',
'est_main_section',
['id' => 'show_on_mobile']
);
}
add_action('admin_init', 'est_floating_video_settings_init');
// Колбэки для полей
function est_main_section_callback() {
echo '<p>Настройте отображение плавающего промо-видео.</p>';
}
function est_field_checkbox_cb($args) {
$options = get_option('est_floating_video_options', []);
$value = isset($options[$args['id']]) ? $options[$args['id']] : '1';
?>
<input type="checkbox" name="est_floating_video_options[<?php echo esc_attr($args['id']); ?>]" value="1" <?php checked(1, $value); ?> />
<?php
}
function est_field_text_cb($args) {
$options = get_option('est_floating_video_options', []);
$value = isset($options[$args['id']]) ? esc_attr($options[$args['id']]) : '';
?>
<input type="url" name="est_floating_video_options[<?php echo esc_attr($args['id']); ?>]" value="<?php echo $value; ?>" class="regular-text" placeholder="<?php echo esc_attr($args['placeholder'] ?? ''); ?>" />
<?php
}
function est_field_number_cb($args) {
$options = get_option('est_floating_video_options', []);
$value = isset($options[$args['id']]) ? intval($options[$args['id']]) : 120;
?>
<input type="number" name="est_floating_video_options[<?php echo esc_attr($args['id']); ?>]" value="<?php echo $value; ?>" min="<?php echo $args['min'] ?? 10; ?>" max="<?php echo $args['max'] ?? 500; ?>" style="width:120px;" />
px
<?php
}
// Санитизация
function est_floating_video_sanitize($input) {
$new_input = [];
$new_input['enabled'] = isset($input['enabled']) ? 1 : 0;
$new_input['video_url'] = esc_url_raw(trim($input['video_url'] ?? ''));
$new_input['width'] = max(80, min(300, intval($input['width'] ?? 120)));
$new_input['height'] = max(120, min(500, intval($input['height'] ?? 180)));
$new_input['bottom_offset'] = max(10, min(300, intval($input['bottom_offset'] ?? 20)));
$new_input['show_on_mobile'] = isset($input['show_on_mobile']) ? 1 : 0;
return $new_input;
}
// Добавляем страницу настроек в меню
function est_floating_video_admin_menu() {
add_options_page(
'Плавающее видео',
'Floating Video',
'manage_options',
'est-floating-video',
'est_floating_video_options_page'
);
}
add_action('admin_menu', 'est_floating_video_admin_menu');
// Вывод страницы настроек
function est_floating_video_options_page() {
if (!current_user_can('manage_options')) {
return;
}
?>
<div class="wrap">
<h1><?php echo esc_html(get_admin_page_title()); ?></h1>
<form action="options.php" method="post">
<?php
settings_fields('est_floating_video_group');
do_settings_sections('est-floating-video');
submit_button('Сохранить изменения');
?>
</form>
</div>
<?php
}
// -----------------------------
// Metabox для отдельных страниц/постов
// -----------------------------
function est_floating_video_add_meta_box() {
$post_types = get_post_types(['public' => true], 'names');
foreach ($post_types as $post_type) {
add_meta_box(
'est_floating_video_meta',
'Плавающее видео для этой записи',
'est_floating_video_meta_box_callback',
$post_type,
'side',
'high'
);
}
}
add_action('add_meta_boxes', 'est_floating_video_add_meta_box');
function est_floating_video_meta_box_callback($post) {
wp_nonce_field('est_floating_video_meta_nonce', 'est_floating_video_nonce');
$video_url = get_post_meta($post->ID, 'est_floating_video_url', true);
?>
<p>
<label for="est_floating_video_url">
URL видео (mp4) для этой страницы:<br>
<small>Оставьте пустым — будет использовано глобальное видео</small>
</label>
</p>
<input type="url"
id="est_floating_video_url"
name="est_floating_video_url"
value="<?php echo esc_attr($video_url); ?>"
style="width:100%;"
placeholder="https://.../video.mp4" />
<?php
}
function est_floating_video_save_meta($post_id) {
if (!isset($_POST['est_floating_video_nonce']) ||
!wp_verify_nonce($_POST['est_floating_video_nonce'], 'est_floating_video_meta_nonce')) {
return;
}
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
if (!current_user_can('edit_post', $post_id)) return;
$video_url = '';
if (isset($_POST['est_floating_video_url'])) {
$video_url = esc_url_raw(trim($_POST['est_floating_video_url']));
}
update_post_meta($post_id, 'est_floating_video_url', $video_url);
}
add_action('save_post', 'est_floating_video_save_meta');
// -----------------------------
// Фронтенд — вывод видео
// -----------------------------
function est_floating_video_enqueue() {
if (is_admin()) return;
$options = get_option('est_floating_video_options', []);
if (empty($options['enabled']) || empty($options['video_url'])) {
return;
}
wp_enqueue_style(
'est-floating-video',
EST_FLOATING_VIDEO_URL . 'assets/floating-video.css',
[],
EST_FLOATING_VIDEO_VERSION
);
wp_enqueue_script(
'est-floating-video',
EST_FLOATING_VIDEO_URL . 'assets/floating-video.js',
[],
EST_FLOATING_VIDEO_VERSION,
true
);
// Передаём настройки в JS (если захотите динамику позже)
wp_localize_script('est-floating-video', 'estVideoConfig', [
'enabled' => !empty($options['enabled']),
]);
}
add_action('wp_enqueue_scripts', 'est_floating_video_enqueue');
function est_floating_video_output() {
$options = get_option('est_floating_video_options', []);
// Глобальные настройки по умолчанию
$global_enabled = !empty($options['enabled']);
$show_on_mobile = isset($options['show_on_mobile']) ? (bool)$options['show_on_mobile'] : true;
$global_video_url = !empty($options['video_url']) ? $options['video_url'] : '';
// Определяем, нужно ли вообще показывать
if (!$global_enabled) {
return;
}
// Проверка мобильного устройства
if (wp_is_mobile() && !$show_on_mobile) {
return;
}
// Определяем видео: сначала индивидуальное, потом глобальное
$video_url = $global_video_url;
if (is_singular()) {
$custom_url = get_post_meta(get_the_ID(), 'est_floating_video_url', true);
if (!empty($custom_url)) {
$video_url = $custom_url;
}
}
// Если в итоге нет видео — выходим
if (empty($video_url)) {
return;
}
$width = $options['width'] ?? 120;
$height = $options['height'] ?? 180;
$bottom = $options['bottom_offset'] ?? 20;
$video_url = esc_url($video_url);
?>
<div class="floating-video-container" style="bottom: <?php echo esc_attr($bottom); ?>px;">
<div class="floating-video-mini" id="miniPlayer" style="width:<?php echo esc_attr($width); ?>px; height:<?php echo esc_attr($height); ?>px;">
<video autoplay muted loop playsinline>
<source src="<?php echo $video_url; ?>" type="video/mp4">
Ваш браузер не поддерживает видео.
</video>
<button class="expand-btn" title="Развернуть видео">↗</button>
<button class="close-btn" title="Закрыть">✕</button>
</div>
<div class="video-modal" id="videoModal">
<div class="modal-content">
<button class="modal-close">✕</button>
<video controls autoplay muted>
<source src="<?php echo $video_url; ?>" type="video/mp4">
</video>
</div>
</div>
</div>
<?php
}
add_action('wp_footer', 'est_floating_video_output', 30);

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
fill="white"
viewBox="0 0 24 24"
version="1.1"
id="svg4"
sodipodi:docname="arrow-up.svg"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs8" />
<sodipodi:namedview
id="namedview6"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="8.7708333"
inkscape:cx="-21.092637"
inkscape:cy="32.152019"
inkscape:window-width="1920"
inkscape:window-height="1027"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg4" />
<path
d="M12 5l-7 7h4v7h6v-7h4z"
id="path2"
style="fill:#999999" />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 B

View File

@ -0,0 +1,32 @@
jQuery(document).ready(function($) {
const btn = $('#cstt-scroll-top');
if (!btn.length) return;
// Устанавливаем класс позиции
btn.addClass('cstt-' + csttData.position);
// Устанавливаем изображение
const icon = btn.find('.cstt-icon');
if (csttData.custom_img) {
icon.css('background-image', `url(${csttData.custom_img})`);
} else {
icon.css('background-image', `url(${csttData.default_img})`);
}
// Показываем/скрываем кнопку
$(window).on('scroll', function() {
if ($(this).scrollTop() > 400) {
btn.addClass('show');
} else {
btn.removeClass('show');
}
});
// Клик → наверх
btn.on('click', function(e) {
e.preventDefault();
$('html, body').animate({scrollTop: 0}, 500);
});
});

View File

@ -0,0 +1,48 @@
#cstt-scroll-top {
position: fixed;
z-index: 9999;
width: 50px;
height: 50px;
background: #333;
color: #fff;
border-radius: 50%;
box-shadow: 0 4px 10px rgba(0,0,0,0.25);
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
}
#cstt-scroll-top.show {
opacity: 0.85;
visibility: visible;
}
#cstt-scroll-top:hover {
opacity: 1;
transform: scale(1.08);
}
/* Позиции */
.cstt-left-bottom { bottom: 25px; left: 25px; }
.cstt-right-bottom { bottom: 25px; right: 25px; }
.cstt-left-top { top: 25px; left: 25px; }
.cstt-right-top { top: 25px; right: 25px; }
.cstt-center-bottom {
bottom: 25px;
left: 50%;
transform: translateX(-50%);
}
/* Иконка */
.cstt-icon {
display: block;
width: 28px;
height: 28px;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}

View File

@ -0,0 +1,218 @@
<?php
/*
Plugin Name: S1 Custom Scroll To Top
Plugin URI: https://1studi.ru
Description: Простая кнопка «Наверх» с выбором позиции и кастомной иконкой
Version: 1.1.0
Author: S1
Author URI: https://1studi.ru
License: GPL-2.0+
Text Domain: s1-custom-scroll-to-top
Domain Path: /languages
*/
// Запрещаем прямой доступ
if (!defined('ABSPATH')) {
exit;
}
define('CSTT_VERSION', '1.1.0');
define('CSTT_DIR', plugin_dir_path(__FILE__));
define('CSTT_URL', plugin_dir_url(__FILE__));
/**
* Подключаем стили и скрипты на фронте
*/
function cstt_enqueue_frontend_assets() {
// Стили
wp_enqueue_style(
'cstt-style',
CSTT_URL . 'assets/style.css',
[],
CSTT_VERSION
);
// Скрипт
wp_enqueue_script(
'cstt-script',
CSTT_URL . 'assets/script.js',
['jquery'],
CSTT_VERSION,
true
);
// Передаём настройки в JS
$position = get_option('cstt_position', 'right-bottom');
$custom_img = get_option('cstt_custom_image', '');
wp_localize_script('cstt-script', 'csttData', [
'position' => $position,
'custom_img' => $custom_img ? esc_url($custom_img) : '',
'default_img' => CSTT_URL . 'assets/arrow-up.svg',
]);
}
add_action('wp_enqueue_scripts', 'cstt_enqueue_frontend_assets');
/**
* HTML кнопки (добавляем в footer)
*/
function cstt_render_button() {
?>
<a href="#" id="cstt-scroll-top" aria-label="Наверх">
<span class="cstt-icon"></span>
</a>
<?php
}
add_action('wp_footer', 'cstt_render_button', 999);
/**
* Настройки плагина
*/
function cstt_register_settings() {
register_setting('cstt_settings_group', 'cstt_position', [
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'default' => 'right-bottom'
]);
register_setting('cstt_settings_group', 'cstt_custom_image', [
'type' => 'string',
'sanitize_callback' => 'esc_url_raw',
]);
add_settings_section(
'cstt_main_section',
'Настройки кнопки «Наверх»',
'__return_false',
'cstt-settings'
);
add_settings_field(
'cstt_position',
'Положение кнопки',
'cstt_position_field_callback',
'cstt-settings',
'cstt_main_section'
);
add_settings_field(
'cstt_custom_image',
'Своя иконка (рекомендуется 64×64 px)',
'cstt_image_field_callback',
'cstt-settings',
'cstt_main_section'
);
}
add_action('admin_init', 'cstt_register_settings');
function cstt_position_field_callback() {
$value = get_option('cstt_position', 'right-bottom');
$positions = [
'left-bottom' => 'Левый нижний угол',
'right-bottom' => 'Правый нижний угол (по умолчанию)',
'left-top' => 'Левый верхний угол',
'right-top' => 'Правый верхний угол',
'center-bottom' => 'По центру снизу',
];
?>
<select name="cstt_position">
<?php foreach ($positions as $key => $label): ?>
<option value="<?= esc_attr($key) ?>" <?= selected($value, $key) ?>>
<?= esc_html($label) ?>
</option>
<?php endforeach; ?>
</select>
<?php
}
function cstt_image_field_callback() {
$value = get_option('cstt_custom_image', '');
$preview_style = $value ? 'block' : 'none';
?>
<div class="cstt-image-wrap">
<input type="url"
name="cstt_custom_image"
id="cstt_custom_image"
value="<?= esc_attr($value) ?>"
class="regular-text code"
placeholder="https://example.com/my-arrow-up.png"
style="width: 100%; max-width: 500px;" />
<p class="description">
Вставьте прямую ссылку на изображение (PNG, JPG, SVG, WebP).<br>
Рекомендуемый размер: 64×64 px или больше, квадратное, желательно с прозрачным фоном.
</p>
<div style="margin-top: 15px;">
<strong>Превью:</strong><br>
<img id="cstt-preview"
src="<?= esc_url($value) ?>"
style="max-width: 120px; height: auto; margin-top: 8px; display: <?= $preview_style ?>; border: 1px solid #ddd; border-radius: 4px;"
onerror="this.style.display='none'; this.nextElementSibling.style.display='block';"
/>
<span style="color: #d63638; display: <?= $value ? 'none' : 'block' ?>;">
Изображение не отображается (проверьте ссылку)
</span>
</div>
</div>
<script>
jQuery(document).ready(function($){
var input = $('#cstt_custom_image');
var preview = $('#cstt-preview');
// Обновляем превью при изменении поля
input.on('input change paste', function(){
var url = $(this).val().trim();
if (url) {
preview.attr('src', url).show();
} else {
preview.hide();
}
});
// Принудительно проверяем при загрузке страницы
if (input.val()) {
preview.attr('src', input.val()).show();
}
});
</script>
<?php
}
/**
* Добавляем страницу настроек в меню
*/
function cstt_add_admin_menu() {
add_options_page(
'Scroll To Top Настройки',
'Scroll To Top',
'manage_options',
'cstt-settings',
'cstt_options_page_html'
);
}
add_action('admin_menu', 'cstt_add_admin_menu');
function cstt_options_page_html() {
if (!current_user_can('manage_options')) return;
?>
<div class="wrap">
<h1>Настройки кнопки «Наверх»</h1>
<form method="post" action="options.php">
<?php
settings_fields('cstt_settings_group');
do_settings_sections('cstt-settings');
submit_button();
?>
</form>
</div>
<?php
}

Binary file not shown.

BIN
s1-disable-gutenberg.zip Normal file

Binary file not shown.

BIN
s1-disable-notices.zip Normal file

Binary file not shown.

Binary file not shown.

BIN
s1-gallery.zip Normal file

Binary file not shown.

38
s1-hide-admin/readme.txt Normal file
View File

@ -0,0 +1,38 @@
=== S1 Hide Admin ===
Plugin URI: https://1studi.ru
Author: S1
Author URI: https://1studi.ru
Requires at least: 5.5
Tested up to: 6.6
Stable tag: 1.0.0
License: GPLv2 or later
Очень простой и лёгкий плагин: скрывает wp-login.php и wp-admin за
собственным адресом-слагом.
== Как это работает ==
* Прямые запросы к wp-login.php и к /wp-admin/ (для неавторизованных
посетителей) отдают обычную 404 — вместо формы входа или редиректа
на неё.
* Настоящая страница входа доступна только по адресу
ваш-сайт.ru/ваш-слаг (задаётся в Настройки → S1 Hide Admin).
* Все ссылки, которые генерирует само ядро WordPress (кнопка выхода,
«забыли пароль», регистрация), автоматически подменяются на новый
адрес — искать и патчить тему не нужно.
* admin-ajax.php и admin-post.php никогда не блокируются — обычные
формы и AJAX на сайте продолжают работать как обычно.
* При активации плагина сразу устанавливается слаг по умолчанию —
"login", чтобы сайт не остался незащищённым до первого захода
в настройки. Смените его на что-то менее предсказуемое.
== Важно ==
Это простая мера "security through obscurity" — она прячет форму
входа от автоматического перебора и ботов-сканеров, но не заменяет
надёжный пароль, двухфакторную аутентификацию и своевременные
обновления WordPress.
Обязательно запишите новый адрес входа после смены слага. Восстановить
доступ, если слаг забыт, можно только переименовав/удалив файл
плагина через FTP или файловый менеджер хостинга.

View File

@ -0,0 +1,232 @@
<?php
/**
* Plugin Name: S1 Hide Admin
* Plugin URI: https://1studi.ru
* Description: Скрывает стандартные адреса входа (wp-login.php, wp-admin) за собственным адресом-слагом, который задаётся в настройках. Прямые обращения к wp-login.php и wp-admin для неавторизованных пользователей отдают обычную 404, вместо того чтобы показывать форму входа или редиректить на неё.
* Version: 1.0.0
* Author: S1
* Author URI: https://1studi.ru
* License: GPL v2 or later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: s1-hide-admin
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Прямой доступ запрещён.
}
class S1_Hide_Admin {
const OPTION_KEY = 's1_hide_admin_slug';
const DEFAULT_SLUG = 'login';
const NONCE_ACTION = 's1_hide_admin_save';
/** Флаг, чтобы отличить наш внутренний require wp-login.php от прямого хита по этому файлу. */
private static $serving_login = false;
public static function init() {
add_action( 'init', array( __CLASS__, 'maybe_intercept' ), 1 );
add_filter( 'site_url', array( __CLASS__, 'filter_login_url' ), 10, 3 );
add_filter( 'network_site_url', array( __CLASS__, 'filter_login_url' ), 10, 3 );
add_action( 'admin_menu', array( __CLASS__, 'add_settings_page' ) );
add_action( 'admin_init', array( __CLASS__, 'maybe_save_settings' ) );
}
/* ------------------------------------------------------------------ */
/* Перехват запросов на фронтенде */
/* ------------------------------------------------------------------ */
public static function maybe_intercept() {
// Никогда не трогаем AJAX, cron, REST и WP-CLI — это внутренние/служебные запросы.
if ( ( defined( 'DOING_AJAX' ) && DOING_AJAX )
|| ( defined( 'DOING_CRON' ) && DOING_CRON )
|| ( defined( 'REST_REQUEST' ) && REST_REQUEST )
|| ( defined( 'WP_CLI' ) && WP_CLI )
) {
return;
}
$request_path = self::get_request_path();
// admin-ajax.php и admin-post.php должны продолжать работать как обычно
// (в том числе для неавторизованных посетителей — формы, счётчики и т.п.).
$basename = basename( $request_path );
if ( in_array( $basename, array( 'admin-ajax.php', 'admin-post.php' ), true ) ) {
return;
}
$slug = self::get_slug();
$is_custom_slug = ( $request_path === $slug );
$is_real_wp_login = ( 'wp-login.php' === $basename );
// Запрос на наш кастомный адрес — показываем настоящую страницу логина.
if ( $is_custom_slug && ! self::$serving_login ) {
self::serve_login();
return; // serve_login() сама завершает работу через exit.
}
// Прямое обращение к wp-login.php, минуя наш внутренний require, — прячем.
if ( $is_real_wp_login && ! self::$serving_login ) {
self::force_404();
}
// Прямое обращение к /wp-admin/ без авторизации — тоже прячем,
// вместо того чтобы штатно редиректить на (уже скрытую) форму входа.
if ( self::is_wp_admin_path( $request_path ) && ! is_user_logged_in() ) {
self::force_404();
}
}
private static function serve_login() {
self::$serving_login = true;
require ABSPATH . 'wp-login.php';
exit;
}
private static function force_404() {
status_header( 404 );
nocache_headers();
wp_die(
'<h1>404</h1><p>Страница не найдена.</p>',
'404',
array( 'response' => 404 )
);
}
/** Путь текущего запроса относительно корня сайта, без query-строки, в нижнем регистре, без слэшей по краям. */
private static function get_request_path() {
$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : '';
$path = wp_parse_url( $request_uri, PHP_URL_PATH );
$path = is_string( $path ) ? $path : '';
$home_path = wp_parse_url( home_url( '/' ), PHP_URL_PATH );
$home_path = is_string( $home_path ) ? trim( $home_path, '/' ) : '';
$path = trim( $path, '/' );
// Если сайт установлен в подпапке — отрезаем этот префикс от пути запроса.
if ( $home_path && 0 === strpos( $path, $home_path ) ) {
$path = trim( substr( $path, strlen( $home_path ) ), '/' );
}
return strtolower( $path );
}
private static function is_wp_admin_path( $path ) {
return 'wp-admin' === $path || 0 === strpos( $path, 'wp-admin/' );
}
/* ------------------------------------------------------------------ */
/* Подмена ссылок, которые генерирует само ядро WordPress */
/* ------------------------------------------------------------------ */
/**
* wp_login_url(), wp_logout_url(), wp_registration_url(), wp_lostpassword_url()
* в ядре всегда вызывают site_url('wp-login.php', 'login'|'login_post') —
* подменяем результат на наш слаг, чтобы нигде на сайте не светился
* настоящий адрес wp-login.php.
*/
public static function filter_login_url( $url, $path, $scheme = null ) {
if ( in_array( $scheme, array( 'login', 'login_post' ), true ) ) {
$url = str_replace( 'wp-login.php', self::get_slug(), $url );
}
return $url;
}
/* ------------------------------------------------------------------ */
/* Настройки */
/* ------------------------------------------------------------------ */
public static function get_slug() {
$slug = get_option( self::OPTION_KEY, self::DEFAULT_SLUG );
return $slug ? $slug : self::DEFAULT_SLUG;
}
public static function add_settings_page() {
add_options_page(
'S1 Hide Admin',
'S1 Hide Admin',
'manage_options',
's1-hide-admin',
array( __CLASS__, 'render_settings_page' )
);
}
public static function maybe_save_settings() {
if ( empty( $_POST['s1_hide_admin_nonce'] ) || ! wp_verify_nonce( $_POST['s1_hide_admin_nonce'], self::NONCE_ACTION ) ) {
return;
}
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$slug = isset( $_POST['s1_hide_admin_slug'] ) ? sanitize_title( wp_unslash( $_POST['s1_hide_admin_slug'] ) ) : '';
// Запрещаем очевидно ломающие значения: пустое, wp-admin, wp-login(.php).
$forbidden = array( '', 'wp-admin', 'wp-login', 'wp-login.php' );
if ( in_array( $slug, $forbidden, true ) ) {
add_action( 'admin_notices', function () {
echo '<div class="notice notice-error"><p><strong>S1 Hide Admin:</strong> этот слаг использовать нельзя (пусто, wp-admin или wp-login). Настройка не сохранена.</p></div>';
} );
return;
}
update_option( self::OPTION_KEY, $slug );
add_action( 'admin_notices', function () {
echo '<div class="notice notice-success is-dismissible"><p>Адрес входа обновлён.</p></div>';
} );
}
public static function render_settings_page() {
$slug = self::get_slug();
$login_url = home_url( '/' . $slug );
?>
<div class="wrap">
<h1>S1 Hide Admin</h1>
<p>Стандартные адреса <code>wp-login.php</code> и <code>/wp-admin/</code> для неавторизованных посетителей скрыты — вместо них показывается обычная страница 404. Вход в панель управления возможен только по адресу ниже.</p>
<div class="notice notice-warning inline">
<p><strong>Важно:</strong> сохраните этот адрес, прежде чем закрывать страницу. Если вы забудете новый слаг, попасть в админку через обычный <code>wp-login.php</code> не получится — восстановить доступ можно будет только через переименование/удаление файла плагина по FTP или через файловый менеджер хостинга.</p>
</div>
<table class="form-table" role="presentation">
<tr>
<th scope="row">Текущий адрес входа</th>
<td><code><?php echo esc_html( $login_url ); ?></code></td>
</tr>
</table>
<form method="post" action="">
<?php wp_nonce_field( self::NONCE_ACTION, 's1_hide_admin_nonce' ); ?>
<table class="form-table" role="presentation">
<tr>
<th scope="row"><label for="s1_hide_admin_slug">Новый адрес (слаг)</label></th>
<td>
<code><?php echo esc_html( home_url( '/' ) ); ?></code>
<input type="text" id="s1_hide_admin_slug" name="s1_hide_admin_slug" value="<?php echo esc_attr( $slug ); ?>" class="regular-text" pattern="[a-z0-9\-_]+" required>
<p class="description">Только латинские буквы, цифры, дефис и подчёркивание. Нельзя использовать <code>wp-admin</code> или <code>wp-login</code>.</p>
</td>
</tr>
</table>
<?php submit_button( 'Сохранить' ); ?>
</form>
</div>
<?php
}
}
add_action( 'plugins_loaded', array( 'S1_Hide_Admin', 'init' ) );
/**
* При активации задаём слаг по умолчанию, если он ещё не задан, —
* чтобы сайт сразу оказался под защитой, а не оставался открытым
* до первого захода в настройки.
*/
function s1_hide_admin_activate() {
if ( false === get_option( S1_Hide_Admin::OPTION_KEY, false ) ) {
add_option( S1_Hide_Admin::OPTION_KEY, S1_Hide_Admin::DEFAULT_SLUG );
}
}
register_activation_hook( __FILE__, 's1_hide_admin_activate' );

Binary file not shown.

BIN
s1-seo-meta.zip Normal file

Binary file not shown.

View File

@ -0,0 +1,45 @@
<?php
/**
* Admin-only bootstrap: enqueues CSS and JS for the meta box and term forms.
*
* @package S1_SEO_Meta
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class S1_SEO_Meta_Admin {
public function __construct() {
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
}
/**
* Enqueue admin styles and scripts on relevant screens.
*
* @param string $hook_suffix Current admin page hook.
*/
public function enqueue_assets( $hook_suffix ) {
$screens = array( 'post.php', 'post-new.php', 'edit-tags.php', 'term.php' );
if ( ! in_array( $hook_suffix, $screens, true ) ) {
return;
}
wp_enqueue_style(
's1-seo-meta-admin',
S1_SEO_META_URL . 'admin/css/s1-seo-meta-admin.css',
array(),
S1_SEO_META_VERSION
);
wp_enqueue_script(
's1-seo-meta-admin',
S1_SEO_META_URL . 'admin/js/s1-seo-meta-admin.js',
array( 'jquery' ),
S1_SEO_META_VERSION,
true
);
}
}

View File

@ -0,0 +1,93 @@
/* ==========================================================================
S1 SEO Meta — Admin Styles
========================================================================== */
/* ---- Meta box wrapper --------------------------------------------------- */
.s1-seo-wrap {
padding: 4px 0;
}
.s1-seo-field {
margin-bottom: 16px;
}
.s1-seo-field:last-child {
margin-bottom: 0;
}
/* ---- Labels ------------------------------------------------------------- */
.s1-seo-field label,
.s1-seo-term-heading {
display: block;
font-weight: 600;
font-size: 13px;
margin-bottom: 6px;
color: #1d2327;
}
.s1-seo-term-heading {
font-size: 14px;
border-bottom: 1px solid #ddd;
padding-bottom: 8px;
margin-top: 4px;
}
/* ---- Inputs ------------------------------------------------------------- */
.s1-seo-field input[type="text"],
.s1-seo-field textarea {
width: 100%;
max-width: 100%;
box-sizing: border-box;
border: 1px solid #8c8f94;
border-radius: 4px;
padding: 8px 10px;
font-size: 13px;
line-height: 1.5;
color: #1d2327;
background: #fff;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
.s1-seo-field input[type="text"]:focus,
.s1-seo-field textarea:focus {
border-color: #2271b1;
box-shadow: 0 0 0 1px #2271b1;
outline: none;
}
.s1-seo-field textarea {
resize: vertical;
min-height: 70px;
}
/* ---- Character counter -------------------------------------------------- */
.s1-seo-counter {
font-size: 11px;
color: #646970;
margin-top: 5px;
}
.s1-seo-count {
font-weight: 600;
}
.s1-seo-count.s1-count-ok {
color: #00a32a; /* green */
}
.s1-seo-count.s1-count-warn {
color: #dba617; /* amber */
}
.s1-seo-count.s1-count-over {
color: #d63638; /* red */
}
/* ---- Term form tweaks --------------------------------------------------- */
.s1-seo-term-row td {
padding-bottom: 0 !important;
}
#s1-seo-meta-box .inside {
padding: 12px;
}

View File

@ -0,0 +1,56 @@
/**
* S1 SEO Meta — Admin Scripts
* Live character counter with colour feedback.
*/
( function ( $ ) {
'use strict';
/**
* Update a counter element with the current length and apply colour classes.
*
* @param {jQuery} $counter The <span> element showing the count.
* @param {number} len Current character length.
* @param {number} soft Ideal upper boundary (turns amber above this).
* @param {number} hard Hard limit (turns red at or above this).
*/
function updateCounter( $counter, len, soft, hard ) {
$counter.text( len );
$counter
.removeClass( 's1-count-ok s1-count-warn s1-count-over' )
.addClass(
len >= hard ? 's1-count-over' :
len >= soft ? 's1-count-warn' :
len > 0 ? 's1-count-ok' : ''
);
}
function initCounters() {
// Title field
var $titleInput = $( '#s1_seo_title' );
var $titleCounter = $( '#s1_seo_title_count' );
if ( $titleInput.length && $titleCounter.length ) {
// Initialise on page load.
updateCounter( $titleCounter, $titleInput.val().length, 50, 60 );
$titleInput.on( 'input', function () {
updateCounter( $titleCounter, $( this ).val().length, 50, 60 );
} );
}
// Description field
var $descInput = $( '#s1_seo_description' );
var $descCounter = $( '#s1_seo_desc_count' );
if ( $descInput.length && $descCounter.length ) {
updateCounter( $descCounter, $descInput.val().length, 120, 160 );
$descInput.on( 'input', function () {
updateCounter( $descCounter, $( this ).val().length, 120, 160 );
} );
}
}
$( document ).ready( initCounters );
} )( jQuery );

View File

@ -0,0 +1,109 @@
<?php
/**
* Outputs <title> and <meta name="description"> tags in the <head>.
*
* @package S1_SEO_Meta
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class S1_SEO_Meta_Output {
public function __construct() {
// Output meta tags as early as possible in wp_head.
add_action( 'wp_head', array( $this, 'output_meta_tags' ), 1 );
// Override the document <title> tag via wp_title (classic themes).
add_filter( 'wp_title', array( $this, 'filter_wp_title' ), 20, 1 );
// Override the document <title> tag via document_title_parts (block / modern themes).
add_filter( 'document_title_parts', array( $this, 'filter_document_title_parts' ), 20 );
}
/**
* Determine the SEO title and description for the current page and echo them.
*/
public function output_meta_tags() {
$seo_title = $this->get_current_title();
$seo_desc = $this->get_current_description();
if ( $seo_desc ) {
echo '<meta name="description" content="' . esc_attr( $seo_desc ) . '" />' . "\n";
}
}
/**
* Filter wp_title() for classic themes that use it.
*
* @param string $title Current title string.
* @return string
*/
public function filter_wp_title( $title ) {
$seo_title = $this->get_current_title();
return $seo_title ? $seo_title : $title;
}
/**
* Filter document_title_parts for modern themes that use add_theme_support('title-tag').
*
* @param array $parts Title parts array.
* @return array
*/
public function filter_document_title_parts( $parts ) {
$seo_title = $this->get_current_title();
if ( $seo_title ) {
// Replace the 'title' part; remove 'site' to avoid duplication.
$parts['title'] = $seo_title;
unset( $parts['tagline'], $parts['site'] );
}
return $parts;
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
/**
* Return the SEO title for whatever is currently being displayed.
*
* @return string Empty string when no custom title is set.
*/
private function get_current_title() {
if ( is_singular() ) {
return S1_SEO_Meta_Post::get_title( get_the_ID() );
}
if ( is_category() || is_tag() || is_tax() ) {
$term = get_queried_object();
if ( $term instanceof WP_Term ) {
return S1_SEO_Meta_Term::get_title( $term->term_id );
}
}
return '';
}
/**
* Return the SEO description for whatever is currently being displayed.
*
* @return string Empty string when no custom description is set.
*/
private function get_current_description() {
if ( is_singular() ) {
return S1_SEO_Meta_Post::get_description( get_the_ID() );
}
if ( is_category() || is_tag() || is_tax() ) {
$term = get_queried_object();
if ( $term instanceof WP_Term ) {
return S1_SEO_Meta_Term::get_description( $term->term_id );
}
}
return '';
}
}

View File

@ -0,0 +1,164 @@
<?php
/**
* Handles saving and retrieving SEO meta for posts, pages and custom post types.
*
* @package S1_SEO_Meta
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class S1_SEO_Meta_Post {
/**
* Meta key for SEO title.
*/
const META_TITLE = '_s1_seo_title';
/**
* Meta key for SEO description.
*/
const META_DESC = '_s1_seo_description';
public function __construct() {
add_action( 'add_meta_boxes', array( $this, 'add_meta_box' ) );
add_action( 'save_post', array( $this, 'save_meta' ), 10, 2 );
}
/**
* Register the meta box for all public post types.
*/
public function add_meta_box() {
$post_types = get_post_types( array( 'public' => true ), 'names' );
foreach ( $post_types as $post_type ) {
add_meta_box(
's1-seo-meta-box',
__( 'S1 SEO: Title & Description', 's1-seo-meta' ),
array( $this, 'render_meta_box' ),
$post_type,
'normal',
'high'
);
}
}
/**
* Render the meta box HTML.
*
* @param WP_Post $post Current post object.
*/
public function render_meta_box( $post ) {
wp_nonce_field( 's1_seo_meta_save', 's1_seo_meta_nonce' );
$seo_title = get_post_meta( $post->ID, self::META_TITLE, true );
$seo_desc = get_post_meta( $post->ID, self::META_DESC, true );
?>
<div class="s1-seo-wrap">
<div class="s1-seo-field">
<label for="s1_seo_title">
<?php esc_html_e( 'SEO Title', 's1-seo-meta' ); ?>
</label>
<input
type="text"
id="s1_seo_title"
name="s1_seo_title"
value="<?php echo esc_attr( $seo_title ); ?>"
placeholder="<?php echo esc_attr( get_the_title( $post->ID ) ); ?>"
maxlength="666"
/>
<div class="s1-seo-counter">
<span class="s1-seo-count" id="s1_seo_title_count">0</span>/60
<?php esc_html_e( 'characters (recommended: 50–60)', 's1-seo-meta' ); ?>
</div>
</div>
<div class="s1-seo-field">
<label for="s1_seo_description">
<?php esc_html_e( 'SEO Description', 's1-seo-meta' ); ?>
</label>
<textarea
id="s1_seo_description"
name="s1_seo_description"
rows="3"
maxlength="666"
placeholder="<?php esc_attr_e( 'Write a short description of this page…', 's1-seo-meta' ); ?>"
><?php echo esc_textarea( $seo_desc ); ?></textarea>
<div class="s1-seo-counter">
<span class="s1-seo-count" id="s1_seo_desc_count">0</span>/160
<?php esc_html_e( 'characters (recommended: 120–160)', 's1-seo-meta' ); ?>
</div>
</div>
</div>
<?php
}
/**
* Save SEO meta fields when the post is saved.
*
* @param int $post_id Post ID.
* @param WP_Post $post Post object.
*/
public function save_meta( $post_id, $post ) {
// Verify nonce.
if (
! isset( $_POST['s1_seo_meta_nonce'] ) ||
! wp_verify_nonce( sanitize_key( $_POST['s1_seo_meta_nonce'] ), 's1_seo_meta_save' )
) {
return;
}
// Skip autosaves and revisions.
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
if ( wp_is_post_revision( $post_id ) ) {
return;
}
// Check permissions.
$post_type_obj = get_post_type_object( $post->post_type );
if ( ! current_user_can( $post_type_obj->cap->edit_post, $post_id ) ) {
return;
}
// Save title.
if ( isset( $_POST['s1_seo_title'] ) ) {
update_post_meta(
$post_id,
self::META_TITLE,
sanitize_text_field( wp_unslash( $_POST['s1_seo_title'] ) )
);
}
// Save description.
if ( isset( $_POST['s1_seo_description'] ) ) {
update_post_meta(
$post_id,
self::META_DESC,
sanitize_textarea_field( wp_unslash( $_POST['s1_seo_description'] ) )
);
}
}
/**
* Static helper: retrieve SEO title for a given post.
*
* @param int $post_id Post ID.
* @return string
*/
public static function get_title( $post_id ) {
return (string) get_post_meta( $post_id, self::META_TITLE, true );
}
/**
* Static helper: retrieve SEO description for a given post.
*
* @param int $post_id Post ID.
* @return string
*/
public static function get_description( $post_id ) {
return (string) get_post_meta( $post_id, self::META_DESC, true );
}
}

View File

@ -0,0 +1,196 @@
<?php
/**
* Handles saving and retrieving SEO meta for taxonomy terms
* (categories, tags, and all public custom taxonomies).
*
* @package S1_SEO_Meta
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class S1_SEO_Meta_Term {
/**
* Option-table key prefix used to store term meta (WP < 4.4 fallback aware).
* From WP 4.4+ term meta is stored in wp_termmeta via get/update_term_meta().
*/
const META_TITLE = 's1_seo_title';
const META_DESC = 's1_seo_description';
public function __construct() {
add_action( 'init', array( $this, 'register_term_hooks' ) );
}
/**
* Register add/edit/save hooks for every public taxonomy.
*/
public function register_term_hooks() {
$taxonomies = get_taxonomies( array( 'public' => true ), 'names' );
foreach ( $taxonomies as $taxonomy ) {
// Fields on the "Add new term" form.
add_action( "{$taxonomy}_add_form_fields", array( $this, 'add_form_fields' ) );
// Fields on the "Edit term" form.
add_action( "{$taxonomy}_edit_form_fields", array( $this, 'edit_form_fields' ), 10, 2 );
// Save on create.
add_action( "created_{$taxonomy}", array( $this, 'save_term_meta' ), 10, 2 );
// Save on update.
add_action( "edited_{$taxonomy}", array( $this, 'save_term_meta' ), 10, 2 );
}
}
/**
* Render fields on the "Add new term" screen.
*
* @param string $taxonomy Current taxonomy slug.
*/
public function add_form_fields( $taxonomy ) {
wp_nonce_field( 's1_seo_term_save', 's1_seo_term_nonce' );
?>
<div class="form-field s1-seo-term-wrap">
<h3 class="s1-seo-term-heading"><?php esc_html_e( 'S1 SEO', 's1-seo-meta' ); ?></h3>
<div class="form-field">
<label for="s1_seo_title"><?php esc_html_e( 'SEO Title', 's1-seo-meta' ); ?></label>
<input
type="text"
id="s1_seo_title"
name="s1_seo_title"
value=""
maxlength="666"
/>
<p class="s1-seo-counter">
<span id="s1_seo_title_count">0</span>/60 <?php esc_html_e( 'characters', 's1-seo-meta' ); ?>
</p>
</div>
<div class="form-field">
<label for="s1_seo_description"><?php esc_html_e( 'SEO Description', 's1-seo-meta' ); ?></label>
<textarea
id="s1_seo_description"
name="s1_seo_description"
rows="3"
maxlength="666"
></textarea>
<p class="s1-seo-counter">
<span id="s1_seo_desc_count">0</span>/160 <?php esc_html_e( 'characters', 's1-seo-meta' ); ?>
</p>
</div>
</div>
<?php
}
/**
* Render fields on the "Edit term" screen.
*
* @param WP_Term $term Current term object.
* @param string $taxonomy Current taxonomy slug.
*/
public function edit_form_fields( $term, $taxonomy ) {
wp_nonce_field( 's1_seo_term_save', 's1_seo_term_nonce' );
$seo_title = get_term_meta( $term->term_id, self::META_TITLE, true );
$seo_desc = get_term_meta( $term->term_id, self::META_DESC, true );
?>
<tr class="form-field s1-seo-term-row">
<td colspan="2">
<h3 class="s1-seo-term-heading"><?php esc_html_e( 'S1 SEO', 's1-seo-meta' ); ?></h3>
</td>
</tr>
<tr class="form-field">
<th scope="row">
<label for="s1_seo_title"><?php esc_html_e( 'SEO Title', 's1-seo-meta' ); ?></label>
</th>
<td>
<input
type="text"
id="s1_seo_title"
name="s1_seo_title"
value="<?php echo esc_attr( $seo_title ); ?>"
placeholder="<?php echo esc_attr( $term->name ); ?>"
maxlength="60"
/>
<p class="description s1-seo-counter">
<span id="s1_seo_title_count"><?php echo esc_html( mb_strlen( $seo_title ) ); ?></span>/60
<?php esc_html_e( 'characters (recommended: 50–60)', 's1-seo-meta' ); ?>
</p>
</td>
</tr>
<tr class="form-field">
<th scope="row">
<label for="s1_seo_description"><?php esc_html_e( 'SEO Description', 's1-seo-meta' ); ?></label>
</th>
<td>
<textarea
id="s1_seo_description"
name="s1_seo_description"
rows="3"
maxlength="160"
><?php echo esc_textarea( $seo_desc ); ?></textarea>
<p class="description s1-seo-counter">
<span id="s1_seo_desc_count"><?php echo esc_html( mb_strlen( $seo_desc ) ); ?></span>/160
<?php esc_html_e( 'characters (recommended: 120–160)', 's1-seo-meta' ); ?>
</p>
</td>
</tr>
<?php
}
/**
* Save term SEO meta.
*
* @param int $term_id Term ID.
*/
public function save_term_meta( $term_id ) {
if (
! isset( $_POST['s1_seo_term_nonce'] ) ||
! wp_verify_nonce( sanitize_key( $_POST['s1_seo_term_nonce'] ), 's1_seo_term_save' )
) {
return;
}
if ( ! current_user_can( 'manage_categories' ) ) {
return;
}
if ( isset( $_POST['s1_seo_title'] ) ) {
update_term_meta(
$term_id,
self::META_TITLE,
sanitize_text_field( wp_unslash( $_POST['s1_seo_title'] ) )
);
}
if ( isset( $_POST['s1_seo_description'] ) ) {
update_term_meta(
$term_id,
self::META_DESC,
sanitize_textarea_field( wp_unslash( $_POST['s1_seo_description'] ) )
);
}
}
/**
* Static helper: retrieve SEO title for a term.
*
* @param int $term_id Term ID.
* @return string
*/
public static function get_title( $term_id ) {
return (string) get_term_meta( $term_id, self::META_TITLE, true );
}
/**
* Static helper: retrieve SEO description for a term.
*
* @param int $term_id Term ID.
* @return string
*/
public static function get_description( $term_id ) {
return (string) get_term_meta( $term_id, self::META_DESC, true );
}
}

View File

@ -0,0 +1,65 @@
# Copyright (C) 2024 S1
# This file is distributed under the same license as the S1 SEO Meta plugin.
msgid ""
msgstr ""
"Project-Id-Version: S1 SEO Meta 1.0.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-01-01 00:00:00+00:00\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"X-Generator: WP-CLI\n"
"X-Domain: s1-seo-meta\n"
#. Plugin Name of the plugin
msgid "S1 SEO Meta"
msgstr ""
#. Plugin URI of the plugin
msgid "https://github.com/S1/s1-seo-meta"
msgstr ""
#. Description of the plugin
msgid "Добавляет мета-теги Title и Description для страниц, публикаций, рубрик, категорий и других типов записей."
msgstr ""
#. Author of the plugin
msgid "S1"
msgstr ""
#: includes/class-s1-seo-meta-post.php
msgid "S1 SEO: Title & Description"
msgstr ""
#: includes/class-s1-seo-meta-post.php
#: includes/class-s1-seo-meta-term.php
msgid "SEO Title"
msgstr ""
#: includes/class-s1-seo-meta-post.php
#: includes/class-s1-seo-meta-term.php
msgid "SEO Description"
msgstr ""
#: includes/class-s1-seo-meta-post.php
msgid "characters (recommended: 50–60)"
msgstr ""
#: includes/class-s1-seo-meta-post.php
msgid "characters (recommended: 120–160)"
msgstr ""
#: includes/class-s1-seo-meta-post.php
msgid "Write a short description of this page…"
msgstr ""
#: includes/class-s1-seo-meta-term.php
msgid "S1 SEO"
msgstr ""
#: includes/class-s1-seo-meta-term.php
msgid "characters"
msgstr ""

48
s1-seo-meta73/readme.txt Normal file
View File

@ -0,0 +1,48 @@
=== S1 SEO Meta ===
Contributors: s1
Tags: seo, meta, title, description, meta tags
Requires at least: 5.8
Tested up to: 6.5
Requires PHP: 7.4
Stable tag: 1.0.0
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Adds custom SEO Title and Description meta fields to posts, pages, custom post types, categories, tags, and all public taxonomies.
== Description ==
S1 SEO Meta adds a lightweight, no-bloat SEO meta box to every post/page editor and taxonomy term screen in the WordPress admin. Simply fill in your desired **SEO Title** and **SEO Description** and the plugin will:
* Output `<meta name="description">` in the `<head>`.
* Override the page `<title>` tag (supports both classic `wp_title` and modern `document_title_parts` hooks).
* Show a live character counter with colour-coded feedback (green / amber / red).
Works with posts, pages, custom post types, categories, tags, and all registered public taxonomies.
== Installation ==
1. Upload the `s1-seo-meta` folder to `/wp-content/plugins/`.
2. Activate the plugin from the **Plugins** menu in WordPress admin.
3. Open any post, page, or term — the **S1 SEO: Title & Description** box will appear.
== Frequently Asked Questions ==
= Will this conflict with Yoast SEO or Rank Math? =
It may conflict if both plugins try to output a `<meta name="description">`. It is recommended to use only one SEO plugin at a time.
= Does it support custom post types? =
Yes. The meta box is added automatically to every post type registered with `public => true`.
= Does it support custom taxonomies? =
Yes. Fields appear on every taxonomy registered with `public => true`.
== Changelog ==
= 1.0.0 =
* Initial release.
== Upgrade Notice ==
= 1.0.0 =
First release.

View File

@ -0,0 +1,56 @@
<?php
/**
* Plugin Name: S1 SEO Meta
* Plugin URI: https://1studi.ru
* Description: Добавляет мета-теги Title и Description для страниц, публикаций, рубрик, категорий и других типов записей.
* Version: 1.0.1
* Requires at least: 5.8
* Requires PHP: 7.3
* Author: S1
* License: GPL v2 or later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: s1-seo-meta
* Domain Path: /languages
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
define( 'S1_SEO_META_VERSION', '1.0.0' );
define( 'S1_SEO_META_FILE', __FILE__ );
define( 'S1_SEO_META_DIR', plugin_dir_path( __FILE__ ) );
define( 'S1_SEO_META_URL', plugin_dir_url( __FILE__ ) );
/**
* Load plugin text domain for translations.
*/
function s1_seo_meta_load_textdomain() {
load_plugin_textdomain( 's1-seo-meta', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' );
}
add_action( 'plugins_loaded', 's1_seo_meta_load_textdomain' );
/**
* Load required files.
*/
require_once S1_SEO_META_DIR . 'includes/class-s1-seo-meta-post.php';
require_once S1_SEO_META_DIR . 'includes/class-s1-seo-meta-term.php';
require_once S1_SEO_META_DIR . 'includes/class-s1-seo-meta-output.php';
if ( is_admin() ) {
require_once S1_SEO_META_DIR . 'admin/class-s1-seo-meta-admin.php';
}
/**
* Initialise all plugin components.
*/
function s1_seo_meta_init() {
new S1_SEO_Meta_Post();
new S1_SEO_Meta_Term();
new S1_SEO_Meta_Output();
if ( is_admin() ) {
new S1_SEO_Meta_Admin();
}
}
add_action( 'init', 's1_seo_meta_init' );

Binary file not shown.

BIN
s1-seo-meta74.zip Normal file

Binary file not shown.

View File

@ -0,0 +1,45 @@
<?php
/**
* Admin-only bootstrap: enqueues CSS and JS for the meta box and term forms.
*
* @package S1_SEO_Meta
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class S1_SEO_Meta_Admin {
public function __construct() {
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
}
/**
* Enqueue admin styles and scripts on relevant screens.
*
* @param string $hook_suffix Current admin page hook.
*/
public function enqueue_assets( $hook_suffix ) {
$screens = array( 'post.php', 'post-new.php', 'edit-tags.php', 'term.php' );
if ( ! in_array( $hook_suffix, $screens, true ) ) {
return;
}
wp_enqueue_style(
's1-seo-meta-admin',
S1_SEO_META_URL . 'admin/css/s1-seo-meta-admin.css',
array(),
S1_SEO_META_VERSION
);
wp_enqueue_script(
's1-seo-meta-admin',
S1_SEO_META_URL . 'admin/js/s1-seo-meta-admin.js',
array( 'jquery' ),
S1_SEO_META_VERSION,
true
);
}
}

View File

@ -0,0 +1,108 @@
/* ==========================================================================
S1 SEO Meta — Admin Styles
========================================================================== */
/* ---- Meta box wrapper --------------------------------------------------- */
.s1-seo-wrap {
padding: 4px 0;
}
.s1-seo-field {
margin-bottom: 16px;
}
.s1-seo-field:last-child {
margin-bottom: 0;
}
/* ---- Labels ------------------------------------------------------------- */
.s1-seo-field label,
.s1-seo-term-heading {
display: block;
font-weight: 600;
font-size: 13px;
margin-bottom: 6px;
color: #1d2327;
}
.s1-seo-term-heading {
font-size: 14px;
border-bottom: 1px solid #ddd;
padding-bottom: 8px;
margin-top: 4px;
}
/* ---- Inputs ------------------------------------------------------------- */
.s1-seo-field input[type="text"],
.s1-seo-field textarea {
width: 100%;
max-width: 100%;
box-sizing: border-box;
border: 1px solid #8c8f94;
border-radius: 4px;
padding: 8px 10px;
font-size: 13px;
line-height: 1.5;
color: #1d2327;
background: #fff;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
.s1-seo-field input[type="text"]:focus,
.s1-seo-field textarea:focus {
border-color: #2271b1;
box-shadow: 0 0 0 1px #2271b1;
outline: none;
}
.s1-seo-field textarea {
resize: vertical;
min-height: 70px;
}
/* ---- Character counter -------------------------------------------------- */
.s1-seo-counter {
font-size: 11px;
color: #646970;
margin-top: 5px;
}
.s1-seo-count {
font-weight: 600;
}
.s1-seo-count.s1-count-ok {
color: #00a32a; /* green */
}
.s1-seo-count.s1-count-warn {
color: #dba617; /* amber */
}
.s1-seo-count.s1-count-over {
color: #d63638; /* red */
}
/* ---- Term form tweaks --------------------------------------------------- */
.s1-seo-term-row td {
padding-bottom: 0 !important;
}
#s1-seo-meta-box .inside {
padding: 12px;
}
/* ---- wp_editor styles for term forms ----------------------------------- */
.s1-seo-term-wrap .wp-editor-container {
border: 1px solid #8c8f94;
border-radius: 4px;
margin-top: 5px;
}
.s1-seo-term-wrap .wp-editor-area {
min-height: 100px;
}
.s1-seo-term-wrap .mce-toolbar {
border-bottom: 1px solid #ddd !important;
}

View File

@ -0,0 +1,111 @@
/**
* S1 SEO Meta — Admin Scripts
* Live character counter with colour feedback.
* Supports both regular textareas and TinyMCE editors (wp_editor).
*/
( function ( $ ) {
'use strict';
/**
* Update a counter element with the current length and apply colour classes.
*
* @param {jQuery} $counter The <span> element showing the count.
* @param {number} len Current character length.
* @param {number} soft Ideal upper boundary (turns amber above this).
* @param {number} hard Hard limit (turns red at or above this).
*/
function updateCounter( $counter, len, soft, hard ) {
$counter.text( len );
$counter
.removeClass( 's1-count-ok s1-count-warn s1-count-over' )
.addClass(
len >= hard ? 's1-count-over' :
len >= soft ? 's1-count-warn' :
len > 0 ? 's1-count-ok' : ''
);
}
/**
* Get content from TinyMCE editor or fallback to textarea.
*
* @param {string} editorId The ID of the editor/textarea.
* @return {string} The editor content.
*/
function getEditorContent( editorId ) {
if ( typeof tinymce !== 'undefined' && tinymce.get( editorId ) ) {
return tinymce.get( editorId ).getContent();
}
return $( '#' + editorId ).val() || '';
}
/**
* Count characters in HTML content (strips tags).
*
* @param {string} html HTML content.
* @return {number} Character count without HTML tags.
*/
function countTextChars( html ) {
var tempDiv = document.createElement( 'div' );
tempDiv.innerHTML = html;
return tempDiv.textContent || tempDiv.innerText || '';
}
function initCounters() {
// Title field (always regular input)
var $titleInput = $( '#s1_seo_title' );
var $titleCounter = $( '#s1_seo_title_count' );
if ( $titleInput.length && $titleCounter.length ) {
// Initialise on page load.
updateCounter( $titleCounter, $titleInput.val().length, 50, 60 );
$titleInput.on( 'input', function () {
updateCounter( $titleCounter, $( this ).val().length, 50, 60 );
} );
}
// Description field (may be TinyMCE or textarea)
var $descCounter = $( '#s1_seo_desc_count' );
if ( $descCounter.length ) {
var editorId = 's1_seo_description';
var initialContent = getEditorContent( editorId );
var initialCount = countTextChars( initialContent ).length;
// Initialise on page load.
updateCounter( $descCounter, initialCount, 120, 160 );
// Handle TinyMCE editor changes
if ( typeof tinymce !== 'undefined' ) {
tinymce.on( 'AddEditor', function( e ) {
if ( e.editor.id === editorId ) {
e.editor.on( 'keyup change', function() {
var content = e.editor.getContent();
var count = countTextChars( content ).length;
updateCounter( $descCounter, count, 120, 160 );
} );
}
} );
// If editor already exists
if ( tinymce.get( editorId ) ) {
tinymce.get( editorId ).on( 'keyup change', function() {
var content = tinymce.get( editorId ).getContent();
var count = countTextChars( content ).length;
updateCounter( $descCounter, count, 120, 160 );
} );
}
}
// Fallback for regular textarea
$( '#' + editorId ).on( 'input', function () {
var content = $( this ).val();
var count = countTextChars( content ).length;
updateCounter( $descCounter, count, 120, 160 );
} );
}
}
$( document ).ready( initCounters );
} )( jQuery );

View File

@ -0,0 +1,109 @@
<?php
/**
* Outputs <title> and <meta name="description"> tags in the <head>.
*
* @package S1_SEO_Meta
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class S1_SEO_Meta_Output {
public function __construct() {
// Output meta tags as early as possible in wp_head.
add_action( 'wp_head', array( $this, 'output_meta_tags' ), 1 );
// Override the document <title> tag via wp_title (classic themes).
add_filter( 'wp_title', array( $this, 'filter_wp_title' ), 20, 1 );
// Override the document <title> tag via document_title_parts (block / modern themes).
add_filter( 'document_title_parts', array( $this, 'filter_document_title_parts' ), 20 );
}
/**
* Determine the SEO title and description for the current page and echo them.
*/
public function output_meta_tags() {
$seo_title = $this->get_current_title();
$seo_desc = $this->get_current_description();
if ( $seo_desc ) {
echo '<meta name="description" content="' . esc_attr( $seo_desc ) . '" />' . "\n";
}
}
/**
* Filter wp_title() for classic themes that use it.
*
* @param string $title Current title string.
* @return string
*/
public function filter_wp_title( $title ) {
$seo_title = $this->get_current_title();
return $seo_title ? $seo_title : $title;
}
/**
* Filter document_title_parts for modern themes that use add_theme_support('title-tag').
*
* @param array $parts Title parts array.
* @return array
*/
public function filter_document_title_parts( $parts ) {
$seo_title = $this->get_current_title();
if ( $seo_title ) {
// Replace the 'title' part; remove 'site' to avoid duplication.
$parts['title'] = $seo_title;
unset( $parts['tagline'], $parts['site'] );
}
return $parts;
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
/**
* Return the SEO title for whatever is currently being displayed.
*
* @return string Empty string when no custom title is set.
*/
private function get_current_title() {
if ( is_singular() ) {
return S1_SEO_Meta_Post::get_title( get_the_ID() );
}
if ( is_category() || is_tag() || is_tax() ) {
$term = get_queried_object();
if ( $term instanceof WP_Term ) {
return S1_SEO_Meta_Term::get_title( $term->term_id );
}
}
return '';
}
/**
* Return the SEO description for whatever is currently being displayed.
*
* @return string Empty string when no custom description is set.
*/
private function get_current_description() {
if ( is_singular() ) {
return S1_SEO_Meta_Post::get_description( get_the_ID() );
}
if ( is_category() || is_tag() || is_tax() ) {
$term = get_queried_object();
if ( $term instanceof WP_Term ) {
return S1_SEO_Meta_Term::get_description( $term->term_id );
}
}
return '';
}
}

View File

@ -0,0 +1,164 @@
<?php
/**
* Handles saving and retrieving SEO meta for posts, pages and custom post types.
*
* @package S1_SEO_Meta
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class S1_SEO_Meta_Post {
/**
* Meta key for SEO title.
*/
const META_TITLE = '_s1_seo_title';
/**
* Meta key for SEO description.
*/
const META_DESC = '_s1_seo_description';
public function __construct() {
add_action( 'add_meta_boxes', array( $this, 'add_meta_box' ) );
add_action( 'save_post', array( $this, 'save_meta' ), 10, 2 );
}
/**
* Register the meta box for all public post types.
*/
public function add_meta_box() {
$post_types = get_post_types( array( 'public' => true ), 'names' );
foreach ( $post_types as $post_type ) {
add_meta_box(
's1-seo-meta-box',
__( 'S1 SEO: Title & Description', 's1-seo-meta' ),
array( $this, 'render_meta_box' ),
$post_type,
'normal',
'high'
);
}
}
/**
* Render the meta box HTML.
*
* @param WP_Post $post Current post object.
*/
public function render_meta_box( $post ) {
wp_nonce_field( 's1_seo_meta_save', 's1_seo_meta_nonce' );
$seo_title = get_post_meta( $post->ID, self::META_TITLE, true );
$seo_desc = get_post_meta( $post->ID, self::META_DESC, true );
?>
<div class="s1-seo-wrap">
<div class="s1-seo-field">
<label for="s1_seo_title">
<?php esc_html_e( 'SEO Title', 's1-seo-meta' ); ?>
</label>
<input
type="text"
id="s1_seo_title"
name="s1_seo_title"
value="<?php echo esc_attr( $seo_title ); ?>"
placeholder="<?php echo esc_attr( get_the_title( $post->ID ) ); ?>"
maxlength="666"
/>
<div class="s1-seo-counter">
<span class="s1-seo-count" id="s1_seo_title_count">0</span>/60
<?php esc_html_e( 'characters (recommended: 50–60)', 's1-seo-meta' ); ?>
</div>
</div>
<div class="s1-seo-field">
<label for="s1_seo_description">
<?php esc_html_e( 'SEO Description', 's1-seo-meta' ); ?>
</label>
<textarea
id="s1_seo_description"
name="s1_seo_description"
rows="3"
maxlength="666"
placeholder="<?php esc_attr_e( 'Write a short description of this page…', 's1-seo-meta' ); ?>"
><?php echo esc_textarea( $seo_desc ); ?></textarea>
<div class="s1-seo-counter">
<span class="s1-seo-count" id="s1_seo_desc_count">0</span>/160
<?php esc_html_e( 'characters (recommended: 120–160)', 's1-seo-meta' ); ?>
</div>
</div>
</div>
<?php
}
/**
* Save SEO meta fields when the post is saved.
*
* @param int $post_id Post ID.
* @param WP_Post $post Post object.
*/
public function save_meta( $post_id, $post ) {
// Verify nonce.
if (
! isset( $_POST['s1_seo_meta_nonce'] ) ||
! wp_verify_nonce( sanitize_key( $_POST['s1_seo_meta_nonce'] ), 's1_seo_meta_save' )
) {
return;
}
// Skip autosaves and revisions.
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
if ( wp_is_post_revision( $post_id ) ) {
return;
}
// Check permissions.
$post_type_obj = get_post_type_object( $post->post_type );
if ( ! current_user_can( $post_type_obj->cap->edit_post, $post_id ) ) {
return;
}
// Save title.
if ( isset( $_POST['s1_seo_title'] ) ) {
update_post_meta(
$post_id,
self::META_TITLE,
sanitize_text_field( wp_unslash( $_POST['s1_seo_title'] ) )
);
}
// Save description.
if ( isset( $_POST['s1_seo_description'] ) ) {
update_post_meta(
$post_id,
self::META_DESC,
sanitize_textarea_field( wp_unslash( $_POST['s1_seo_description'] ) )
);
}
}
/**
* Static helper: retrieve SEO title for a given post.
*
* @param int $post_id Post ID.
* @return string
*/
public static function get_title( $post_id ) {
return (string) get_post_meta( $post_id, self::META_TITLE, true );
}
/**
* Static helper: retrieve SEO description for a given post.
*
* @param int $post_id Post ID.
* @return string
*/
public static function get_description( $post_id ) {
return (string) get_post_meta( $post_id, self::META_DESC, true );
}
}

View File

@ -0,0 +1,236 @@
<?php
/**
* Handles saving and retrieving SEO meta for taxonomy terms
* (categories, tags, and all public custom taxonomies).
*
* @package S1_SEO_Meta
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class S1_SEO_Meta_Term {
/**
* Option-table key prefix used to store term meta (WP < 4.4 fallback aware).
* From WP 4.4+ term meta is stored in wp_termmeta via get/update_term_meta().
*/
const META_TITLE = 's1_seo_title';
const META_DESC = 's1_seo_description';
public function __construct() {
add_action( 'init', array( $this, 'register_term_hooks' ) );
}
/**
* Register add/edit/save hooks for every public taxonomy.
*/
public function register_term_hooks() {
$taxonomies = get_taxonomies( array( 'public' => true ), 'names' );
foreach ( $taxonomies as $taxonomy ) {
// Fields on the "Add new term" form.
add_action( "{$taxonomy}_add_form_fields", array( $this, 'add_form_fields' ) );
// Fields on the "Edit term" form.
add_action( "{$taxonomy}_edit_form_fields", array( $this, 'edit_form_fields' ), 10, 2 );
// Save on create.
add_action( "created_{$taxonomy}", array( $this, 'save_term_meta' ), 10, 2 );
// Save on update.
add_action( "edited_{$taxonomy}", array( $this, 'save_term_meta' ), 10, 2 );
}
}
/**
* Render fields on the "Add new term" screen.
*
* @param string $taxonomy Current taxonomy slug.
*/
public function add_form_fields( $taxonomy ) {
wp_nonce_field( 's1_seo_term_save', 's1_seo_term_nonce' );
?>
<div class="form-field s1-seo-term-wrap">
<h3 class="s1-seo-term-heading"><?php esc_html_e( 'S1 SEO', 's1-seo-meta' ); ?></h3>
<div class="form-field">
<label for="s1_seo_title"><?php esc_html_e( 'SEO Title', 's1-seo-meta' ); ?></label>
<input
type="text"
id="s1_seo_title"
name="s1_seo_title"
value=""
maxlength="666"
/>
<p class="s1-seo-counter">
<span id="s1_seo_title_count">0</span>/60 <?php esc_html_e( 'characters', 's1-seo-meta' ); ?>
</p>
</div>
<div class="form-field">
<label for="s1_seo_description"><?php esc_html_e( 'SEO Description', 's1-seo-meta' ); ?></label>
<?php
$settings = array(
'media_buttons' => false,
'textarea_rows' => 5,
'teeny' => true,
'tinymce' => array(
'toolbar1' => 'bold,italic,underline,link,unlink',
'toolbar2' => '',
),
);
wp_editor( '', 's1_seo_description', $settings );
?>
<p class="s1-seo-counter">
<span id="s1_seo_desc_count">0</span>/160 <?php esc_html_e( 'characters (recommended: 120–160)', 's1-seo-meta' ); ?>
</p>
</div>
</div>
<?php
}
/**
* Render fields on the "Edit term" screen.
*
* @param WP_Term $term Current term object.
* @param string $taxonomy Current taxonomy slug.
*/
public function edit_form_fields( $term, $taxonomy ) {
wp_nonce_field( 's1_seo_term_save', 's1_seo_term_nonce' );
$seo_title = get_term_meta( $term->term_id, self::META_TITLE, true );
$seo_desc = get_term_meta( $term->term_id, self::META_DESC, true );
?>
<tr class="form-field s1-seo-term-row">
<td colspan="2">
<h3 class="s1-seo-term-heading"><?php esc_html_e( 'S1 SEO', 's1-seo-meta' ); ?></h3>
</td>
</tr>
<tr class="form-field">
<th scope="row">
<label for="s1_seo_title"><?php esc_html_e( 'SEO Title', 's1-seo-meta' ); ?></label>
</th>
<td>
<input
type="text"
id="s1_seo_title"
name="s1_seo_title"
value="<?php echo esc_attr( $seo_title ); ?>"
placeholder="<?php echo esc_attr( $term->name ); ?>"
maxlength="60"
/>
<p class="description s1-seo-counter">
<span id="s1_seo_title_count"><?php echo esc_html( mb_strlen( $seo_title ) ); ?></span>/60
<?php esc_html_e( 'characters (recommended: 50–60)', 's1-seo-meta' ); ?>
</p>
</td>
</tr>
<tr class="form-field">
<th scope="row">
<label for="s1_seo_description"><?php esc_html_e( 'SEO Description', 's1-seo-meta' ); ?></label>
</th>
<td>
<?php
$settings = array(
'media_buttons' => false,
'textarea_rows' => 5,
'teeny' => true,
'tinymce' => array(
'toolbar1' => 'bold,italic,underline,link,unlink',
'toolbar2' => '',
),
);
wp_editor( $seo_desc, 's1_seo_description', $settings );
?>
<p class="description s1-seo-counter">
<span id="s1_seo_desc_count"><?php echo esc_html( mb_strlen( wp_strip_all_tags( $seo_desc ) ) ); ?></span>/160
<?php esc_html_e( 'characters (recommended: 120–160)', 's1-seo-meta' ); ?>
</p>
</td>
</tr>
<?php
}
/**
* Save term SEO meta.
*
* @param int $term_id Term ID.
*/
public function save_term_meta( $term_id ) {
if (
! isset( $_POST['s1_seo_term_nonce'] ) ||
! wp_verify_nonce( sanitize_key( $_POST['s1_seo_term_nonce'] ), 's1_seo_term_save' )
) {
return;
}
if ( ! current_user_can( 'manage_categories' ) ) {
return;
}
if ( isset( $_POST['s1_seo_title'] ) ) {
update_term_meta(
$term_id,
self::META_TITLE,
sanitize_text_field( wp_unslash( $_POST['s1_seo_title'] ) )
);
}
if ( isset( $_POST['s1_seo_description'] ) ) {
$description = wp_unslash( $_POST['s1_seo_description'] );
// Разрешаем безопасные HTML теги для SEO описания
$allowed_tags = array(
'a' => array(
'href' => array(),
'title' => array(),
'target' => array(),
),
'b' => array(),
'strong' => array(),
'i' => array(),
'em' => array(),
'br' => array(),
'p' => array(),
'span' => array(),
'ul' => array(),
'ol' => array(),
'li' => array(),
);
// Для администраторов разрешаем все теги, для остальных - только безопасные
if ( current_user_can( 'unfiltered_html' ) ) {
$sanitized = $description;
} else {
$sanitized = wp_kses( $description, $allowed_tags );
}
update_term_meta(
$term_id,
self::META_DESC,
$sanitized
);
}
}
/**
* Static helper: retrieve SEO title for a term.
*
* @param int $term_id Term ID.
* @return string
*/
public static function get_title( $term_id ) {
return (string) get_term_meta( $term_id, self::META_TITLE, true );
}
/**
* Static helper: retrieve SEO description for a term.
*
* @param int $term_id Term ID.
* @return string
*/
public static function get_description( $term_id ) {
return (string) get_term_meta( $term_id, self::META_DESC, true );
}
}

View File

@ -0,0 +1,65 @@
# Copyright (C) 2024 S1
# This file is distributed under the same license as the S1 SEO Meta plugin.
msgid ""
msgstr ""
"Project-Id-Version: S1 SEO Meta 1.0.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-01-01 00:00:00+00:00\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"X-Generator: WP-CLI\n"
"X-Domain: s1-seo-meta\n"
#. Plugin Name of the plugin
msgid "S1 SEO Meta"
msgstr ""
#. Plugin URI of the plugin
msgid "https://github.com/S1/s1-seo-meta"
msgstr ""
#. Description of the plugin
msgid "Добавляет мета-теги Title и Description для страниц, публикаций, рубрик, категорий и других типов записей."
msgstr ""
#. Author of the plugin
msgid "S1"
msgstr ""
#: includes/class-s1-seo-meta-post.php
msgid "S1 SEO: Title & Description"
msgstr ""
#: includes/class-s1-seo-meta-post.php
#: includes/class-s1-seo-meta-term.php
msgid "SEO Title"
msgstr ""
#: includes/class-s1-seo-meta-post.php
#: includes/class-s1-seo-meta-term.php
msgid "SEO Description"
msgstr ""
#: includes/class-s1-seo-meta-post.php
msgid "characters (recommended: 50–60)"
msgstr ""
#: includes/class-s1-seo-meta-post.php
msgid "characters (recommended: 120–160)"
msgstr ""
#: includes/class-s1-seo-meta-post.php
msgid "Write a short description of this page…"
msgstr ""
#: includes/class-s1-seo-meta-term.php
msgid "S1 SEO"
msgstr ""
#: includes/class-s1-seo-meta-term.php
msgid "characters"
msgstr ""

48
s1-seo-meta74/readme.txt Normal file
View File

@ -0,0 +1,48 @@
=== S1 SEO Meta ===
Contributors: s1
Tags: seo, meta, title, description, meta tags
Requires at least: 5.8
Tested up to: 6.5
Requires PHP: 7.4
Stable tag: 1.0.0
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Adds custom SEO Title and Description meta fields to posts, pages, custom post types, categories, tags, and all public taxonomies.
== Description ==
S1 SEO Meta adds a lightweight, no-bloat SEO meta box to every post/page editor and taxonomy term screen in the WordPress admin. Simply fill in your desired **SEO Title** and **SEO Description** and the plugin will:
* Output `<meta name="description">` in the `<head>`.
* Override the page `<title>` tag (supports both classic `wp_title` and modern `document_title_parts` hooks).
* Show a live character counter with colour-coded feedback (green / amber / red).
Works with posts, pages, custom post types, categories, tags, and all registered public taxonomies.
== Installation ==
1. Upload the `s1-seo-meta` folder to `/wp-content/plugins/`.
2. Activate the plugin from the **Plugins** menu in WordPress admin.
3. Open any post, page, or term — the **S1 SEO: Title & Description** box will appear.
== Frequently Asked Questions ==
= Will this conflict with Yoast SEO or Rank Math? =
It may conflict if both plugins try to output a `<meta name="description">`. It is recommended to use only one SEO plugin at a time.
= Does it support custom post types? =
Yes. The meta box is added automatically to every post type registered with `public => true`.
= Does it support custom taxonomies? =
Yes. Fields appear on every taxonomy registered with `public => true`.
== Changelog ==
= 1.0.0 =
* Initial release.
== Upgrade Notice ==
= 1.0.0 =
First release.

View File

@ -0,0 +1,56 @@
<?php
/**
* Plugin Name: S1 SEO Meta
* Plugin URI: https://1studi.ru
* Description: Добавляет мета-теги Title и Description для страниц, публикаций, рубрик, категорий и других типов записей. С визуальным редактором для описания категорий.
* Version: 1.1.0
* Requires at least: 5.8
* Requires PHP: 7.3
* Author: S1
* License: GPL v2 or later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: s1-seo-meta
* Domain Path: /languages
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
define( 'S1_SEO_META_VERSION', '1.1.0' );
define( 'S1_SEO_META_FILE', __FILE__ );
define( 'S1_SEO_META_DIR', plugin_dir_path( __FILE__ ) );
define( 'S1_SEO_META_URL', plugin_dir_url( __FILE__ ) );
/**
* Load plugin text domain for translations.
*/
function s1_seo_meta_load_textdomain() {
load_plugin_textdomain( 's1-seo-meta', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' );
}
add_action( 'plugins_loaded', 's1_seo_meta_load_textdomain' );
/**
* Load required files.
*/
require_once S1_SEO_META_DIR . 'includes/class-s1-seo-meta-post.php';
require_once S1_SEO_META_DIR . 'includes/class-s1-seo-meta-term.php';
require_once S1_SEO_META_DIR . 'includes/class-s1-seo-meta-output.php';
if ( is_admin() ) {
require_once S1_SEO_META_DIR . 'admin/class-s1-seo-meta-admin.php';
}
/**
* Initialise all plugin components.
*/
function s1_seo_meta_init() {
new S1_SEO_Meta_Post();
new S1_SEO_Meta_Term();
new S1_SEO_Meta_Output();
if ( is_admin() ) {
new S1_SEO_Meta_Admin();
}
}
add_action( 'init', 's1_seo_meta_init' );

Binary file not shown.

View File

@ -0,0 +1,102 @@
<?php
/*
Plugin Name: S1 show category description
Plugin URI: https://1studi.ru
Description: Выводит описание категории/рубрики на странице архива (по умолчанию WP его скрывает)
Version: 1.1.0
Author: S1
Author URI: https://1studi.ru
License: GPL-2.0+
Text Domain: s1-custom-scroll-to-top
Domain Path: /languages
*/
if (!defined('ABSPATH')) {
exit;
}
add_action('wp_head', function() {
// Можно добавить стили прямо в head (или подключить отдельный css)
// Раскомментируйте, если нужно
/*
echo '<style>
.category-description {
margin: 1.5em 0 2.5em;
padding: 1.2em 1.8em;
background: #f8f9fa;
border-left: 4px solid #0073aa;
border-radius: 4px;
line-height: 1.6;
}
.category-description p:last-child {
margin-bottom: 0;
}
</style>';
*/
}, 20);
/**
* Основной вывод описания
*/
function scd_show_category_description() {
// Показываем только на страницах архивов категорий (можно расширить)
if (!is_category() && !is_tag() && !is_tax()) {
return;
}
// Получаем описание
$description = get_the_archive_description();
if (empty($description)) {
return;
}
// Можно обернуть в свой класс или изменить тег
$output = '<div class="category-description entry-content">';
$output .= $description;
$output .= '</div>';
echo $output;
}
/**
* Вариант А — после заголовка архива (самый популярный)
*/
add_action('woocommerce_archive_description', 'scd_show_category_description', 15); // для woocommerce
add_action('generate_archive_title', 'scd_show_category_description', 20); // GeneratePress
add_action('astra_archive_header', 'scd_show_category_description', 15); // Astra
// Универсальный способ — если тема не использует специальные хуки
add_action('wp_head', function() {
if (is_category() || is_tag() || is_tax()) {
add_filter('the_content', 'scd_inject_description_to_content', 9);
}
});
function scd_inject_description_to_content($content) {
// Показываем только в начале основного контента архива
if (!is_main_query() || !in_the_loop()) {
return $content;
}
remove_filter('the_content', 'scd_inject_description_to_content', 9);
$description = get_the_archive_description();
if (empty($description)) {
return $content;
}
return '<div class="category-description">' . $description . '</div>' . $content;
}
/**
* Вариант Б — короткий код [category_description]
* Можно вставить в любой блок / виджет / шаблон
*/
add_shortcode('category_description', function() {
return get_the_archive_description();
});

View File

@ -0,0 +1,137 @@
<?php
/*
Plugin Name: Smart Sticky Header with Scroll Interval
Description: Sticky header с интервалом скролла и клонированием (оригинал остаётся на месте)
Version: 1.2.0
*/
add_action('admin_menu', function() {
add_options_page(
'Sticky Header Settings',
'Sticky Header',
'manage_options',
'smart-sticky-header',
'ssh_settings_page'
);
});
function ssh_settings_page() {
if (isset($_POST['save_settings'])) {
update_option('ssh_sticky_selector', sanitize_text_field($_POST['sticky_selector']));
update_option('ssh_start_scroll', intval($_POST['start_scroll']));
update_option('ssh_end_scroll', intval($_POST['end_scroll']));
echo '<div class="updated"><p>Настройки сохранены!</p></div>';
}
$selector = get_option('ssh_sticky_selector', '#masthead, .site-header, header');
$start_scroll = get_option('ssh_start_scroll', 100);
$end_scroll = get_option('ssh_end_scroll', 0);
?>
<div class="wrap">
<h1>Настройки Sticky Header</h1>
<form method="post">
<table class="form-table">
<tr>
<th><label>CSS-селектор хедера/меню</label></th>
<td>
<input type="text" name="sticky_selector" value="<?php echo esc_attr($selector) ?>" style="width:400px;" placeholder="#masthead, .site-header, header"/>
<p class="description">Можно несколько через запятую. Используй инспектор браузера для точного селектора.</p>
</td>
</tr>
<tr>
<th><label>Минимальный скролл (px)</label></th>
<td>
<input type="number" name="start_scroll" value="<?php echo esc_attr($start_scroll) ?>" min="0" style="width:100px;"/>
<p class="description">Sticky появляется после этого значения (default: 100).</p>
</td>
</tr>
<tr>
<th><label>Максимальный скролл (px)</label></th>
<td>
<input type="number" name="end_scroll" value="<?php echo esc_attr($end_scroll) ?>" min="0" style="width:100px;"/>
<p class="description">Sticky скрывается после этого значения. 0 — без лимита (default: 0).</p>
</td>
</tr>
</table>
<?php submit_button('Сохранить настройки', 'primary', 'save_settings'); ?>
</form>
</div>
<?php
}
add_action('wp_enqueue_scripts', function() {
if (is_admin()) return;
$selector = get_option('ssh_sticky_selector', '#masthead, .site-header, header');
$start_scroll = get_option('ssh_start_scroll', 100);
$end_scroll = get_option('ssh_end_scroll', 0);
// Выводим CSS и JS inline для простоты (можно вынести в файлы)
?>
<style id="ssh-sticky-css">
.ssh-sticky-clone {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
width: 100% !important;
z-index: 9998 !important;
background: #ffffff;
box-shadow: 0 3px 8px rgba(0,0,0,0.12);
opacity: 0;
transition: opacity 0.3s ease, transform 0.3s ease;
transform: translateY(-100%);
display: none; /* Initially hidden */
}
.ssh-sticky-clone.ssh-visible {
opacity: 1;
transform: translateY(0);
display: block !important;
}
body.admin-bar .ssh-sticky-clone {
top: 32px !important;
}
@media (max-width: 782px) {
body.admin-bar .ssh-sticky-clone {
top: 46px !important;
}
}
</style>
<script id="ssh-sticky-js">
document.addEventListener('DOMContentLoaded', function() {
const selectors = '<?php echo esc_js($selector); ?>'.split(',').map(s => s.trim());
const startScroll = <?php echo intval($start_scroll); ?>;
const endScroll = <?php echo intval($end_scroll); ?>;
let originalHeader = null;
for (let sel of selectors) {
originalHeader = document.querySelector(sel);
if (originalHeader) break;
}
if (!originalHeader) return; // Не нашли хедер
// Клонируем
const clone = originalHeader.cloneNode(true);
clone.classList.add('ssh-sticky-clone');
document.body.appendChild(clone);
// Функция проверки скролла
function checkScroll() {
const scrollY = window.scrollY;
if (scrollY >= startScroll && (endScroll === 0 || scrollY <= endScroll)) {
clone.classList.add('ssh-visible');
} else {
clone.classList.remove('ssh-visible');
}
}
// Слушатели
window.addEventListener('scroll', checkScroll);
window.addEventListener('resize', checkScroll); // На всякий случай
checkScroll(); // Инициализация
});
</script>
<?php
}, 999); // Высокий приоритет, чтобы после других скриптов