countdown-8.x-1.8/countdown.module
countdown.module
<?php
/**
* @file
* Countdown module for integrating countdown timers.
*/
declare(strict_types=1);
use Drupal\Core\Installer\InstallerKernel;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Url;
use Drupal\countdown\CountdownConstants;
use Drupal\countdown\Utility\ConfigAccessor;
/**
* Implements hook_help().
*/
function countdown_help($route_name, RouteMatchInterface $route_match) {
switch ($route_name) {
case 'help.page.countdown':
return _countdown_help_page();
case 'countdown.settings':
return '<p>' . t('Configure the default countdown library and display settings. The module will automatically detect installed libraries.') . '</p>';
}
}
/**
* Implements hook_theme().
*/
function countdown_theme($existing, $type, $theme, $path): array {
return [
'countdown_form' => [
'render element' => 'form',
],
'countdown_preview' => [
'variables' => [
'attributes' => [],
'config' => [],
'countdown_markup' => [],
'debug_info' => NULL,
],
'template' => 'countdown-preview',
'path' => $path . '/templates',
],
'countdown_meta' => [
'variables' => [
'attributes' => [],
'meta' => [],
],
'template' => 'countdown-meta',
'path' => $path . '/templates',
],
'countdown' => [
'variables' => [
'library' => NULL,
'target_date' => NULL,
'countdown_url' => NULL,
'countdown_event_name' => NULL,
'format' => NULL,
'attributes' => [],
'settings' => [],
],
'template' => 'countdown',
'path' => $path . '/templates',
],
];
}
/**
* Implements hook_preprocess_HOOK() for HTML.
*/
function countdown_preprocess_html(&$variables) {
// Get the current route match object.
$route_match = \Drupal::routeMatch();
// Check if the current route is the core countdown settings form.
if ($route_match->getRouteName() === 'countdown.settings') {
$variables['attributes']['class'][] = 'countdown-form';
}
}
/**
* Implements hook_library_info_build().
*/
function countdown_library_info_build() {
// Skip during installation.
if (InstallerKernel::installationAttempted()) {
return [];
}
try {
$library_manager = \Drupal::service('countdown.library_manager');
$active_library = $library_manager->getActiveLibrary();
// Core library is defined statically in countdown.libraries.yml.
if ($active_library !== 'countdown') {
$definition = $library_manager->getLibraryDefinition();
return !empty($definition) ? ['countdown.active' => $definition] : [];
}
}
catch (\Exception $e) {
\Drupal::logger('countdown')->error('Failed to build library definition: @message', [
'@message' => $e->getMessage(),
]);
}
return [];
}
/**
* Implements hook_library_info_alter().
*/
function countdown_library_info_alter(&$libraries, $extension) {
// Only process countdown libraries.
if ($extension !== 'countdown') {
return;
}
try {
$config_accessor = new ConfigAccessor(\Drupal::configFactory());
$method = $config_accessor->getLoadingMethod();
$plugin_manager = \Drupal::service('plugin.manager.countdown_library');
$debug_mode = $config_accessor->isDebugMode();
// Process each installed plugin.
foreach ($plugin_manager->getInstalledPlugins() as $plugin_id => $plugin) {
// Skip core library.
if ($plugin->getType() === 'core') {
continue;
}
// Handle LOCAL method - update paths if library is in non-default folder.
if ($method === 'local') {
// Get the actual installation path.
$library_path = $plugin->getLibraryPath();
if (!$library_path) {
continue;
}
// Extract the actual folder name from path.
// Examples:
// - /libraries/tick-master -> tick-master
// - /sites/default/libraries/tick-master -> tick-master.
preg_match('#/libraries/([^/]+)#', $library_path, $matches);
$actual_folder = $matches[1] ?? NULL;
// Skip if folder name can't be extracted or matches plugin_id.
if (!$actual_folder || $actual_folder === $plugin_id) {
continue;
}
// Replace all occurrences of /libraries/{plugin_id}/
// with /libraries/{actual_folder}/.
$search = '/libraries/' . $plugin_id . '/';
$replace = '/libraries/' . $actual_folder . '/';
foreach ($libraries as $library_name => &$library_def) {
// Accept this plugin's own libraries with a proper name boundary.
// Boundaries: '.', '_', '-', or end of string.
$belongs = (bool) preg_match(
'/^' . preg_quote($plugin_id, '/') . '(?:[._-]|$)/',
$library_name
);
// Accept Tick→Flip cross-view only when current plugin is "flip".
// Matches: tick_flip, tick.view.flip, tick-flip, tick.flip.min, etc.
if (!$belongs && $plugin_id === 'flip') {
if (preg_match('/^tick(?:[._-]?flip)(?:[._-]|$)/', $library_name)) {
$belongs = TRUE;
}
}
if (!$belongs) {
continue;
}
// Skip CDN libraries in local mode.
if (strpos($library_name, '_cdn') !== FALSE) {
continue;
}
// Update JS paths.
if (isset($library_def['js'])) {
$new_js = [];
foreach ($library_def['js'] as $path => $options) {
// Skip external URLs.
if (isset($options['type']) && $options['type'] === 'external') {
$new_js[$path] = $options;
}
else {
$new_js[str_replace($search, $replace, $path)] = $options;
}
}
$library_def['js'] = $new_js;
}
// Update CSS paths.
if (isset($library_def['css'])) {
foreach ($library_def['css'] as $category => &$css_files) {
$new_css = [];
foreach ($css_files as $path => $options) {
// Skip external URLs.
if (isset($options['type']) && $options['type'] === 'external') {
$new_css[$path] = $options;
}
else {
$new_css[str_replace($search, $replace, $path)] = $options;
}
}
$library_def['css'][$category] = $new_css;
}
}
}
// Log the change if debug mode is enabled.
if ($debug_mode) {
\Drupal::logger('countdown')->debug('Updated local paths for @plugin: "@search" to "@replace"', [
'@plugin' => $plugin_id,
'@search' => $search,
'@replace' => $replace,
]);
}
}
// Handle CDN method - update CDN URLs if provider is not jsdelivr.
elseif ($method === 'cdn') {
$cdn_provider = $config_accessor->getCdnProvider();
// Skip if using default jsdelivr (libraries use it by default).
if ($cdn_provider === 'jsdelivr') {
continue;
}
// Get CDN configuration from plugin.
$cdn_config = $plugin->getCdnConfig();
if (!$cdn_config || !isset($cdn_config[$cdn_provider])) {
continue;
}
$provider_urls = $cdn_config[$cdn_provider];
// Process all CDN libraries for this plugin.
foreach ($libraries as $library_name => &$library_def) {
// Process only CDN libraries related to this plugin.
if (strpos($library_name, $plugin_id . '_cdn') !== 0) {
continue;
}
// Determine if this is the main library or an extension.
$is_minified = strpos($library_name, '.min') !== FALSE;
// Update all external JS entries.
if (isset($library_def['js'])) {
$new_js = [];
foreach ($library_def['js'] as $old_url => $options) {
if (isset($options['type']) && $options['type'] === 'external') {
// Find appropriate replacement URL.
$new_url = $provider_urls['js'] ?? $old_url;
if ($is_minified && isset($provider_urls['js_min'])) {
$new_url = $provider_urls['js_min'];
}
$new_js[$new_url] = $options;
}
else {
$new_js[$old_url] = $options;
}
}
$library_def['js'] = $new_js;
}
// Update all external CSS entries.
if (isset($library_def['css'])) {
foreach ($library_def['css'] as $category => &$css_files) {
$new_css = [];
foreach ($css_files as $old_url => $options) {
if (isset($options['type']) && $options['type'] === 'external') {
// Find appropriate replacement URL.
$new_url = $provider_urls['css'] ?? $old_url;
if ($is_minified && isset($provider_urls['css_min'])) {
$new_url = $provider_urls['css_min'];
}
$new_css[$new_url] = $options;
}
else {
$new_css[$old_url] = $options;
}
}
$library_def['css'][$category] = $new_css;
}
}
// Handle Tick extensions specially.
if ($plugin_id === 'tick' && strpos($library_name, 'tick_') === 0) {
// Extract extension name (e.g., tick_boom_cdn -> boom).
preg_match('/tick_([^_]+)(?:_cdn)?/', $library_name, $ext_matches);
$ext_name = $ext_matches[1] ?? NULL;
if ($ext_name && method_exists($plugin, 'getAvailableExtensions')) {
$extensions = $plugin->getAvailableExtensions();
// Map library extension names to extension IDs.
$ext_id_map = [
'boom' => 'view_boom',
'dots' => 'view_dots',
'line' => 'view_line',
'swap' => 'view_swap',
'font' => [
'highres' => 'font_highres',
'lowres' => 'font_lowres',
],
];
// Determine the actual extension ID.
$ext_id = NULL;
if (isset($ext_id_map[$ext_name])) {
if (is_array($ext_id_map[$ext_name])) {
// For font extensions, check which one.
foreach ($ext_id_map[$ext_name] as $key => $id) {
if (strpos($library_name, $key) !== FALSE) {
$ext_id = $id;
break;
}
}
}
else {
$ext_id = $ext_id_map[$ext_name];
}
}
// Get extension CDN URLs.
if ($ext_id && isset($extensions[$ext_id]['cdn'][$cdn_provider])) {
$ext_cdn = $extensions[$ext_id]['cdn'][$cdn_provider];
// Update extension JS URL.
if (isset($library_def['js'])) {
$new_js = [];
foreach ($library_def['js'] as $old_url => $options) {
if (isset($options['type']) && $options['type'] === 'external') {
$new_url = is_array($ext_cdn) && isset($ext_cdn['js']) ? $ext_cdn['js'] : (is_string($ext_cdn) ? $ext_cdn : $old_url);
$new_js[$new_url] = $options;
}
else {
$new_js[$old_url] = $options;
}
}
$library_def['js'] = $new_js;
}
// Update extension CSS URL if exists.
if (isset($library_def['css']['theme']) && is_array($ext_cdn) && isset($ext_cdn['css'])) {
$new_css = [];
foreach ($library_def['css']['theme'] as $old_url => $options) {
if (isset($options['type']) && $options['type'] === 'external') {
$new_css[$ext_cdn['css']] = $options;
}
else {
$new_css[$old_url] = $options;
}
}
$library_def['css']['theme'] = $new_css;
}
}
}
}
}
// Log the CDN provider change if debug mode is enabled.
if ($debug_mode) {
\Drupal::logger('countdown')->debug('Updated CDN URLs for @plugin to use @provider', [
'@plugin' => $plugin_id,
'@provider' => $cdn_provider,
]);
}
}
}
}
catch (\Exception $e) {
\Drupal::logger('countdown')->error('Error in library_info_alter: @message', [
'@message' => $e->getMessage(),
]);
}
}
/**
* Implements hook_page_attachments().
*/
function countdown_page_attachments(array &$attachments) {
// Don't add the JavaScript and CSS during installation.
if (InstallerKernel::installationAttempted()) {
return;
}
// Don't add the JavaScript and CSS on specified paths or themes.
if (!_countdown_check_theme() || !_countdown_check_path()) {
return;
}
try {
// Load configuration via accessor.
$config_accessor = new ConfigAccessor(\Drupal::configFactory());
$library_manager = \Drupal::service('countdown.library_manager');
$plugin_manager = \Drupal::service('plugin.manager.countdown_library');
$active_library = $library_manager->getActiveLibrary();
if (!$config_accessor->isAutoLoadEnabled() || !$active_library || $active_library === 'none') {
return;
}
$plugin = $plugin_manager->getPlugin($active_library);
if (!$plugin) {
\Drupal::logger('countdown')->warning('Active library @library not found', [
'@library' => $active_library,
]);
return;
}
// Get attachment configuration from plugin.
$attachment_config = [
'method' => $config_accessor->getLoadingMethod(),
'variant' => $config_accessor->getBuildVariant(),
'cdn_provider' => $config_accessor->getCdnProvider(),
'rtl' => $config_accessor->isRtlEnabled(),
'debug' => $config_accessor->isDebugMode(),
];
// Let plugin build its attachments.
$plugin_attachments = $plugin->buildAttachments($attachment_config);
if (!empty($plugin_attachments)) {
$attachments = array_merge_recursive($attachments, $plugin_attachments);
if ($attachment_config['debug']) {
\Drupal::logger('countdown')->debug('Attached libraries: @libraries', [
'@libraries' => implode(', ', $plugin_attachments['#attached']['library'] ?? []),
]);
}
}
}
catch (\Exception $e) {
\Drupal::logger('countdown')->error('Failed to attach library: @message', [
'@message' => $e->getMessage(),
]);
}
}
/**
* Implements hook_cache_flush().
*
* Clears library discovery cache when Drupal caches are cleared.
* This ensures fresh detection of newly installed/updated libraries.
*/
function countdown_cache_flush() {
try {
\Drupal::service('countdown.library_discovery')->clearCache();
$config_accessor = new ConfigAccessor(\Drupal::configFactory());
if ($config_accessor->isDebugMode()) {
\Drupal::logger('countdown')->debug('Library discovery cache cleared.');
}
}
catch (\Exception $e) {
// Service might not be available during certain operations.
}
}
/**
* Prepare variables for countdown templates.
*/
function template_preprocess_countdown(&$variables) {
$attributes = &$variables['attributes'];
// Add library class.
if (!empty($variables['library'])) {
$attributes['class'][] = 'countdown-' . str_replace('_', '-', $variables['library']);
}
// Add data attributes for JavaScript initialization.
foreach (['target_date' => 'target', 'format' => 'format'] as $var => $data) {
if (!empty($variables[$var])) {
$attributes['data-countdown-' . $data] = $variables[$var];
}
}
// Add event data attributes if present.
if (!empty($variables['countdown_event_name'])) {
$attributes['data-countdown-event-name'] = $variables['countdown_event_name'];
}
if (!empty($variables['countdown_url'])) {
$attributes['data-countdown-event-url'] = $variables['countdown_url'];
// Process URL for safe output in template.
try {
// Try to create a URL object from the provided URL.
if (strpos($variables['countdown_url'], 'http://') === 0 || strpos($variables['countdown_url'], 'https://') === 0) {
// External URL.
$url = Url::fromUri($variables['countdown_url']);
}
else {
// Internal URL or path.
$url = Url::fromUserInput($variables['countdown_url']);
}
$variables['countdown_url'] = $url->toString();
}
catch (\Exception $e) {
// If URL parsing fails, sanitize it as a plain string.
$variables['countdown_url'] = check_url($variables['countdown_url']);
}
}
// Add settings as JSON.
if (!empty($variables['settings'])) {
$attributes['data-countdown-settings'] = json_encode($variables['settings']);
}
// Add the main countdown class.
$attributes['class'][] = 'countdown';
}
/* ========================================================================
* Internal helper functions
* ======================================================================== */
/**
* Generate help page content.
*/
function _countdown_help_page(): string {
$output = '<h3>' . t('About') . '</h3>';
$output .= '<p>' . t('The Countdown module provides flexible countdown timer functionality with support for multiple JavaScript libraries.') . '</p>';
$output .= '<h3>' . t('Features') . '</h3>';
$output .= '<ul>';
$features = [
t('Multiple countdown library support (FlipClock, FlipDown, etc.).'),
t('Countdown blocks for easy placement.'),
t('Field integration for content types.'),
t('Flexible configuration per timer.'),
t('Production/development build variants.'),
t('Multi-site support.'),
];
foreach ($features as $feature) {
$output .= '<li>' . $feature . '</li>';
}
$output .= '</ul>';
$output .= '<h3>' . t('Configuration') . '</h3>';
$output .= '<p>' . t('Configure the module at <a href=":config">Countdown settings</a>.', [':config' => Url::fromRoute('countdown.settings')->toString()]) . '</p>';
$output .= '<h3>' . t('Library Installation') . '</h3>';
$output .= '<p>' . t('Place countdown libraries in:') . '</p>';
$output .= '<ul>';
$output .= '<li><code>/libraries/[library_name]</code> ' . t('(Global)') . '</li>';
$output .= '<li><code>/sites/[site_name]/libraries/[library_name]</code> ' . t('(Site-specific)') . '</li>';
$output .= '</ul>';
return $output;
}
/**
* Check if countdown should be active for the current URL.
*
* @param \Symfony\Component\HttpFoundation\Request|null $request
* The request to use if provided, otherwise \Drupal::request() will be used.
* @param \Symfony\Component\HttpFoundation\RequestStack|null $request_stack
* The request stack.
*
* @return bool
* TRUE if countdown library should be active for the current page.
*/
function _countdown_check_path($request = NULL, $request_stack = NULL): bool {
// Use the provided request or get the current request.
if (!isset($request)) {
$request = \Drupal::request();
}
// Initialize match status as FALSE.
$page_match = FALSE;
// Check for the ?countdown=no parameter in the URL to deactivate library.
$query = $request->query;
if ($query->get(CountdownConstants::QUERY_PARAM_DISABLE) !== NULL && $query->get(CountdownConstants::QUERY_PARAM_DISABLE) == 'no') {
return $page_match;
}
// Load configuration via accessor.
$config_accessor = new ConfigAccessor(\Drupal::configFactory());
$page_visibility = $config_accessor->getPageVisibility();
// Get the list of pages where countdown should be active.
$pages = mb_strtolower(_countdown_array_to_string($page_visibility['pages']));
// If no specific pages are configured, Countdown is active on all pages.
if (!$pages) {
return TRUE;
}
// Use the provided request stack or get the current request stack.
if (!isset($request_stack)) {
$request_stack = \Drupal::requestStack();
}
$current_request = $request_stack->getCurrentRequest();
// Get the current path.
$path = \Drupal::service('path.current')->getPath($current_request);
// Do not trim the trailing slash if it is the complete path.
$path = $path === '/' ? $path : rtrim($path, '/');
// Get the current language code.
$langcode = \Drupal::languageManager()->getCurrentLanguage()->getId();
// Get the path alias and convert to lowercase.
$path_alias = mb_strtolower(\Drupal::service('path_alias.manager')->getAliasByPath($path, $langcode));
// Check if the path alias matches the configured pages.
$page_match = \Drupal::service('path.matcher')->matchPath($path_alias, $pages);
// If the path alias is different from the internal path,
// check the internal path as well.
if ($path_alias != $path) {
$page_match = $page_match || \Drupal::service('path.matcher')->matchPath($path, $pages);
}
// Negate the match if configured to load on all pages except those listed.
$page_match = $page_visibility['negate'] == 0 ? !$page_match : $page_match;
return $page_match;
}
/**
* Verify if the current theme is selected in the module settings.
*
* @return bool
* TRUE if the current theme is selected, otherwise FALSE.
*/
function _countdown_check_theme(): bool {
// Load configuration via accessor.
$config_accessor = new ConfigAccessor(\Drupal::configFactory());
$theme_visibility = $config_accessor->getThemeVisibility();
// Get the name of the active theme.
$active_theme = \Drupal::theme()->getActiveTheme()->getName();
// Retrieve the list of valid themes from the configuration.
$valid_themes = $theme_visibility['themes'];
// If no themes are configured, countdown should be active.
if (empty($valid_themes) || count($valid_themes) === 0) {
return TRUE;
}
// Get the visibility setting from the configuration.
$visibility = $theme_visibility['negate'];
// Check if the active theme is in the list of valid themes.
$theme_match = in_array($active_theme, $valid_themes);
// Determine the final match based on the visibility setting.
return !($visibility xor $theme_match);
}
/**
* Converts a text with lines (\n) into an array of non-empty trimmed lines.
*
* @param string $text
* The text to be converted into an array.
*
* @return array|null
* An array with non-empty trimmed lines or NULL if input is not a string.
*/
function _countdown_string_to_array(string $text): ?array {
// Normalize line endings to Unix style.
$text = str_replace("\r\n", "\n", $text);
// Split the text into lines and filter out empty lines.
$lines = array_filter(array_map('trim', explode("\n", $text)));
return $lines;
}
/**
* Converts an array of lines into a text with lines (\r\n).
*
* @param array $array
* The array to be converted into a text string.
*
* @return string|null
* A text string with lines separated by \r\n or NULL
* if input is not an array.
*/
function _countdown_array_to_string(array $array): ?string {
// Convert the array to a string with \r\n line endings.
return implode("\r\n", array_map('trim', $array));
}
