From 60165f5b42de9c3de4c081fa69ac402d2805b01e Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Fri, 29 May 2026 13:50:29 -0500 Subject: [PATCH 1/7] Refactors the legacy JS a bit --- packages/craftcms-legacy/cp/src/js/CP.js | 6 +- packages/craftcms-legacy/cp/src/js/Craft.js | 206 +----------- packages/craftcms-legacy/cp/src/js/UI.js | 2 +- resources/js/bootstrap/cp.ts | 1 - resources/js/cp.ts | 8 +- resources/js/shims/legacy-cp.ts | 30 ++ resources/js/shims/legacy-jquery.ts | 26 ++ resources/views/app.blade.php | 12 +- src/Cp/Cp.php | 333 ++++++++++++++++++-- src/View/LegacyAssets/CpAsset.php | 306 ++---------------- vite.config.js | 39 ++- 11 files changed, 450 insertions(+), 519 deletions(-) create mode 100644 resources/js/shims/legacy-cp.ts create mode 100644 resources/js/shims/legacy-jquery.ts diff --git a/packages/craftcms-legacy/cp/src/js/CP.js b/packages/craftcms-legacy/cp/src/js/CP.js index 3fee97996fe..a492ff4bb7b 100644 --- a/packages/craftcms-legacy/cp/src/js/CP.js +++ b/packages/craftcms-legacy/cp/src/js/CP.js @@ -1,4 +1,8 @@ -import {QueueService} from '@craftcms/cp'; +// Import from the deep service module rather than the package root. The root +// entry (`@craftcms/cp`) side-effect-registers WebAwesome components (e.g. +// `wa-icon`); pulling that into this separately-webpacked legacy bundle causes +// a duplicate custom-element registration when it loads alongside the Vite app. +import {QueueService} from '@craftcms/cp/services/Queue.ts.mjs'; /** global: Craft */ /** global: Garnish */ /** global: $ */ diff --git a/packages/craftcms-legacy/cp/src/js/Craft.js b/packages/craftcms-legacy/cp/src/js/Craft.js index afcd7b82c4a..0dd40bc77bf 100644 --- a/packages/craftcms-legacy/cp/src/js/Craft.js +++ b/packages/craftcms-legacy/cp/src/js/Craft.js @@ -1,3 +1,7 @@ +// Import from the deep module rather than the package root, which +// side-effect-registers WebAwesome components (e.g. `wa-icon`) and would +// duplicate those registrations in this separately-webpacked legacy bundle. +import {t, formatMessage} from '@craftcms/cp/utilities/translate.ts.mjs'; import * as d3 from 'd3'; /** global: Craft */ @@ -81,208 +85,12 @@ $.extend(Craft, { * @param {Object} params * @returns {string} */ - t: function (category, message, params) { - if ( - typeof Craft.translations[category] !== 'undefined' && - typeof Craft.translations[category][message] !== 'undefined' - ) { - message = Craft.translations[category][message]; - } - - if (params) { - return this.formatMessage(message, params); - } - - return message; + t: function (category, message, params = {}) { + return t(message, params, category = 'app', Craft.translations); }, formatMessage: function (pattern, args) { - let tokens; - if ((tokens = this._tokenizePattern(pattern)) === false) { - throw 'Message pattern is invalid.'; - } - for (let i = 0; i < tokens.length; i++) { - let token = tokens[i]; - if (typeof token === 'object') { - if ((tokens[i] = this._parseToken(token, args)) === false) { - throw 'Message pattern is invalid.'; - } - } - } - return tokens.join(''); - }, - - _tokenizePattern: function (pattern) { - let depth = 1, - start, - pos; - // Get an array of the string characters (factoring in 3+ byte chars) - const chars = [...pattern]; - if ((start = pos = chars.indexOf('{')) === -1) { - return [pattern]; - } - let tokens = [chars.slice(0, pos).join('')]; - while (true) { - let open = chars.indexOf('{', pos + 1); - let close = chars.indexOf('}', pos + 1); - if (open === -1) { - open = false; - } - if (close === -1) { - close = false; - } - if (open === false && close === false) { - break; - } - if (open === false) { - open = chars.length; - } - if (close > open) { - depth++; - pos = open; - } else { - depth--; - pos = close; - } - if (depth === 0) { - tokens.push( - chars - .slice(start + 1, pos) - .join('') - .split(',', 3) - ); - start = pos + 1; - tokens.push(chars.slice(start, open).join('')); - start = open; - } - - if (depth !== 0 && (open === false || close === false)) { - break; - } - } - if (depth !== 0) { - return false; - } - - return tokens; - }, - - _parseToken: function (token, args) { - // parsing pattern based on ICU grammar: - // http://icu-project.org/apiref/icu4c/classMessageFormat.html#details - const param = token[0].trim(); - if (typeof args[param] === 'undefined') { - return `{${token.join(',')}}`; - } - const arg = args[param]; - const type = typeof token[1] !== 'undefined' ? token[1].trim() : 'none'; - switch (type) { - case 'number': - return (() => { - let format = typeof token[2] !== 'undefined' ? token[2].trim() : null; - if (format !== null && format !== 'integer') { - throw `Message format 'number' is only supported for integer values.`; - } - let number = Craft.formatNumber(arg); - let pos; - if (format === null && (pos = `${arg}`.indexOf('.')) !== -1) { - number += `.${arg.substring(pos + 1)}`; - } - return number; - })(); - case 'none': - return arg; - case 'select': - return (() => { - /* http://icu-project.org/apiref/icu4c/classicu_1_1SelectFormat.html - selectStyle = (selector '{' message '}')+ - */ - if (typeof token[2] === 'undefined') { - return false; - } - let select = this._tokenizePattern(token[2]); - let c = select.length; - let message = false; - for (let i = 0; i + 1 < c; i++) { - if (Array.isArray(select[i]) || !Array.isArray(select[i + 1])) { - return false; - } - let selector = select[i++].trim(); - if ( - (message === false && selector === 'other') || - selector == arg - ) { - message = select[i].join(','); - } - } - if (message === false) { - return false; - } - return this.formatMessage(message, args); - })(); - case 'plural': - return (() => { - /* http://icu-project.org/apiref/icu4c/classicu_1_1PluralFormat.html - pluralStyle = [offsetValue] (selector '{' message '}')+ - offsetValue = "offset:" number - selector = explicitValue | keyword - explicitValue = '=' number // adjacent, no white space in between - keyword = [^[[:Pattern_Syntax:][:Pattern_White_Space:]]]+ - message: see MessageFormat - */ - if (typeof token[2] === 'undefined') { - return false; - } - let plural = this._tokenizePattern(token[2]); - const c = plural.length; - let message = false; - let offset = 0; - for (let i = 0; i + 1 < c; i++) { - if ( - typeof plural[i] === 'object' || - typeof plural[i + 1] !== 'object' - ) { - return false; - } - let selector = plural[i++].trim(); - let selectorChars = [...selector]; - - if (i === 1 && selector.substring(0, 7) === 'offset:') { - let pos = [...selector.replace(/[\n\r\t]/g, ' ')].indexOf(' ', 7); - if (pos === -1) { - throw 'Message pattern is invalid.'; - } - offset = parseInt(selectorChars.slice(7, pos).join('').trim()); - selector = selectorChars - .slice(pos + 1, pos + 1 + selectorChars.length) - .join('') - .trim(); - } - if ( - (message === false && selector === 'other') || - (selector[0] === '=' && - parseInt( - selectorChars.slice(1, 1 + selectorChars.length).join('') - ) === arg) || - (selector === 'one' && arg - offset === 1) - ) { - message = ( - typeof plural[i] === 'string' ? [plural[i]] : plural[i] - ) - .map((p) => { - return p.replace('#', arg - offset); - }) - .join(','); - } - } - if (message === false) { - return false; - } - return this.formatMessage(message, args); - })(); - default: - throw `Message format '${type}' is not supported.`; - } + return formatMessage(pattern, args); }, formatDate: function (date) { diff --git a/packages/craftcms-legacy/cp/src/js/UI.js b/packages/craftcms-legacy/cp/src/js/UI.js index 49a4b3d4219..549cb111d4d 100644 --- a/packages/craftcms-legacy/cp/src/js/UI.js +++ b/packages/craftcms-legacy/cp/src/js/UI.js @@ -1241,7 +1241,7 @@ Craft.ui = { return $(instance.list); }; - getAccessibleName = () => { + const getAccessibleName = () => { return $input.attr('aria-label'); }; diff --git a/resources/js/bootstrap/cp.ts b/resources/js/bootstrap/cp.ts index 9c88c783a8e..12bbbb46abe 100644 --- a/resources/js/bootstrap/cp.ts +++ b/resources/js/bootstrap/cp.ts @@ -84,7 +84,6 @@ const Cp = { app.provide(Queue, queue); app.provide(Axios, axios); app.provide(Config, config); - app.provide(Craft, config); app.component('QueueManager', QueueManager); app.component('QueueManagerToolbar', QueueManagerToolbar); diff --git a/resources/js/cp.ts b/resources/js/cp.ts index 532b1a50d75..a58937bd19f 100644 --- a/resources/js/cp.ts +++ b/resources/js/cp.ts @@ -1,11 +1,7 @@ +import './shims/legacy-cp'; // ⚠️ TEMP legacy globals — remove when server-HTML jQuery is gone import '@craftcms/cp'; import Cp from './bootstrap/cp.js'; import './modules/navigation/components/CpGlobalSidebar.js'; import './modules/navigation/components/CpQueueIndicator.js'; -window.Cp = { - ...(window.Cp || {}), - ...Cp, -}; - -console.log('window.Cp defined', window.Cp); +window.Cp = Cp; diff --git a/resources/js/shims/legacy-cp.ts b/resources/js/shims/legacy-cp.ts new file mode 100644 index 00000000000..3966633ba20 --- /dev/null +++ b/resources/js/shims/legacy-cp.ts @@ -0,0 +1,30 @@ +// Tailwind Reset (probably not necessary) +import '../../legacy/tailwindreset/dist/tailwind_reset'; + +// Animation blocker +import '../../legacy/animationblocker/dist/AnimationBlocker'; + +// Axios (should be removed) +import '../../legacy/axios/dist/axios'; + +// D3 (hopefully remove) +// import '../../legacy/d3/dist/' + +// Velocity +// import '../../legacy/velocity/dist/velocity'; + +// XRegex +// import '../../legacy/xregexp/dist/xregexp-all.js'; + +// IFrame resizer +import '../../legacy/iframeresizer/dist/iframeResizer'; + +// Fabric +// import '../../legacy/fabric/dist/fabric'; + +// JQuery (+ Garnish and plugins) +import './legacy-jquery.js'; + +// CP +import '../../legacy/cp/dist/cp'; + diff --git a/resources/js/shims/legacy-jquery.ts b/resources/js/shims/legacy-jquery.ts new file mode 100644 index 00000000000..244087e3b03 --- /dev/null +++ b/resources/js/shims/legacy-jquery.ts @@ -0,0 +1,26 @@ +// TEMPORARY: loads jQuery + jQuery UI (and any other legacy globals) for +// server-rendered HTML on Inertia pages that still contains inline jQuery code. +// +// To disable: comment out the import of this file in resources/js/cp.ts. +// To remove for good: delete resources/js/shims/ and that import line. +import '../../legacy/jquery/dist/jquery.js'; + +import '../../legacy/jquerytouchevents/dist/jquery.mobile-events'; +import '../../legacy/jqueryui/dist/jquery-ui'; +import '../../legacy/jquerypayment/dist/jquery.payment'; +import '../../legacy/fileupload/dist/jquery.fileupload'; +import '../../legacy/timepicker/dist/jquery.timepicker'; + +// jQuery UI base theme. Leave commented unless the server HTML actually needs +// jQuery UI's own widget styling — Craft's CP styles many of these already, and +// the base theme can conflict. +// import 'jquery-ui/dist/themes/base/jquery-ui.css'; + +// Add additional legacy jQuery plugins below as needed, e.g.: +// import 'some-jquery-plugin'; + +import '../../legacy/garnish/dist/garnish'; + +// Selectize (deprecated) +import '../../legacy/selectize/dist/selectize'; +import '../../legacy/selectize/dist/css/selectize.css'; diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index 100e7d6b0d1..25f1b1e0b03 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -3,13 +3,12 @@ + {!! $headHtml !!} {!! \CraftCms\Cms\Cp\Cp::viteScripts()->toHtml() !!} {!! app(\CraftCms\Cms\Plugin\Plugins::class)->getAssetsHtml() !!} - {{ config('app.name') }} @@ -17,11 +16,8 @@ {!! $bodyHtml !!} - diff --git a/src/Cp/Cp.php b/src/Cp/Cp.php index 0eb90474fd7..6ea8fba9293 100644 --- a/src/Cp/Cp.php +++ b/src/Cp/Cp.php @@ -5,40 +5,68 @@ namespace CraftCms\Cms\Cp; use CraftCms\Aliases\Aliases; +use CraftCms\Cms\Announcement\Announcements; +use CraftCms\Cms\Asset\AssetsHelper; +use CraftCms\Cms\Auth\Impersonation; +use CraftCms\Cms\Auth\Passkeys\Passkeys; use CraftCms\Cms\Cms; +use CraftCms\Cms\Config\GeneralConfig; +use CraftCms\Cms\Edition; +use CraftCms\Cms\Element\Contracts\ElementInterface; +use CraftCms\Cms\Field\Fields; +use CraftCms\Cms\Providers\AppServiceProvider; +use CraftCms\Cms\Section\Data\Section; +use CraftCms\Cms\Section\Enums\SectionType; +use CraftCms\Cms\Support\Api; +use CraftCms\Cms\Support\DateTimeHelper; +use CraftCms\Cms\Support\Facades\Elements; use CraftCms\Cms\Support\Facades\I18N; +use CraftCms\Cms\Support\Facades\Images; +use CraftCms\Cms\Support\Facades\Sections; +use CraftCms\Cms\Support\Facades\Sites; +use CraftCms\Cms\Support\Html; +use CraftCms\Cms\Support\Str; use CraftCms\Cms\Support\Url; +use CraftCms\Cms\Translation\Locale; +use CraftCms\Cms\Update\Updates; +use CraftCms\Cms\User\Elements\User; +use CraftCms\Cms\Utility\Utilities; +use CraftCms\Cms\Utility\Utilities\QueueManager; +use CraftCms\Cms\View\LegacyAssets\CpAsset; use Illuminate\Foundation\Vite; +use Illuminate\Pagination\Paginator; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Auth; use stdClass; +use function CraftCms\Cms\t; + readonly class Cp { /** - * @TODO Could/should all this data just be handled in an inertia middleware? - * We'll need to render all legacy pages with inertia in that case. + * The full Control Panel JavaScript configuration. + * + * This is the single source of truth for the data exposed to both the + * Inertia CP (`window.Cp` / the shared `craft.general` prop) and the legacy + * `window.Craft` object built by + * {@see CpAsset}. + * + * The bulk of the data is request/auth-dependent (see {@see self::craftData()}). + * The keys merged below are always present so the Inertia side never loses + * config it relies on, even on unauthenticated or non-CP requests. */ public static function config(): Collection { - $config = Cms::config(); - - return collect($config) - ->only([ - 'cpTrigger', - 'actionTrigger', - 'cpLogoUrl', - 'useEmailAsUsername', - 'rememberedUserSessionDuration', - 'defaultCpLocale', - 'runQueueAutomatically', - ]) + $generalConfig = Cms::config(); + + return collect(self::craftData()) ->merge([ - 'translations' => I18N::getAllTranslationsForLocale(app()->getLocale()) ?: new stdClass, - 'csrfTokenValue' => csrf_token(), - 'csrfTokenName' => '_token', - 'actionUrl' => Url::actionUrl(), + 'cpLogoUrl' => $generalConfig->cpLogoUrl, + 'cpTrigger' => $generalConfig->cpTrigger, 'cpUrl' => Url::cpUrl(), - 'baseUrl' => Url::url(), + 'defaultCpLocale' => $generalConfig->defaultCpLocale, + 'rememberedUserSessionDuration' => $generalConfig->rememberedUserSessionDuration, + 'runQueueAutomatically' => $generalConfig->runQueueAutomatically, ]); } @@ -56,4 +84,271 @@ public static function viteScripts(): Vite 'resources/js/cp.ts', ]); } + + /** + * Builds the Control Panel config data. + * + * The shape mirrors the legacy `window.Craft` object: a base set of keys is + * always present, CP-request-only keys are added for CP requests, and the + * large authenticated-user block is added when someone is logged in. + */ + private static function craftData(): array + { + $upToDate = Cms::isInstalled() && ! app(Updates::class)->areMigrationsPending(); + $generalConfig = Cms::config(); + $formattingLocale = I18N::getFormattingLocale(); + $locale = I18N::getLocale(); + $orientation = $locale->getOrientation(); + $currentUser = Auth::user(); + $primarySite = $upToDate ? Sites::getPrimarySite() : null; + + $data = [ + 'Solo' => Edition::Solo->value, + 'Team' => Edition::Team->value, + 'Pro' => Edition::Pro->value, + 'Enterprise' => Edition::Enterprise->value, + 'actionTrigger' => $generalConfig->actionTrigger, + 'actionUrl' => Url::actionUrl(), + 'asciiCharMap' => Str::asciiCharMap(true, app()->getLocale()), + 'baseApiUrl' => Api::craftApiEndpoint(), + 'baseSiteUrl' => Url::siteUrl(), + 'baseUrl' => Url::url(), + 'clientOs' => request()->clientOs(), + 'datepickerOptions' => self::datepickerOptions($formattingLocale, $locale), + 'defaultCookieOptions' => self::defaultCookieOptions(), + 'fileKinds' => AssetsHelper::getFileKinds(), + 'language' => app()->getLocale(), + 'left' => $orientation === 'ltr' ? 'left' : 'right', + 'maxPasswordLength' => AppServiceProvider::$maxPasswordLength, + 'minPasswordLength' => AppServiceProvider::$minPasswordLength, + 'orientation' => $orientation, + 'pageNum' => Paginator::resolveCurrentPage(Cms::config()->getPageTriggerParam()), + 'pageTrigger' => Cms::config()->getPageTriggerParam(), + 'path' => request()->craftPath(), + 'registeredAssetBundles' => [], // force encode as JS object + 'registeredJsFiles' => [], // force encode as JS object + 'right' => $orientation === 'ltr' ? 'right' : 'left', + 'systemUid' => Cms::systemUid(), + 'timepickerOptions' => self::timepickerOptions($formattingLocale, $orientation), + 'timezone' => Cms::timezone(), + 'tokenParam' => $generalConfig->tokenParam, + 'translations' => I18N::getAllTranslationsForLocale(app()->getLocale()) ?: new stdClass, + 'useEmailAsUsername' => $generalConfig->useEmailAsUsername, + ]; + + if (request()->isCpRequest()) { + $data += [ + 'announcements' => $upToDate ? app(Announcements::class)->get() : [], + 'baseCpUrl' => Url::cpUrl(), + 'cpTrigger' => $generalConfig->cpTrigger, + ]; + } + + $data += [ + 'csrfTokenName' => '_token', + 'csrfTokenValue' => csrf_token(), + ]; + + // If no one's logged in yet, leave it at that + if (! $currentUser) { + return $data; + } + + $elementTypeNames = []; + foreach (Elements::getAllElementTypes() as $elementType) { + /** @var class-string $elementType */ + $elementTypeNames[$elementType] = [ + $elementType::displayName(), + $elementType::pluralDisplayName(), + $elementType::lowerDisplayName(), + $elementType::pluralLowerDisplayName(), + ]; + } + + return $data + [ + 'allowAdminChanges' => $generalConfig->allowAdminChanges, + 'allowUpdates' => $generalConfig->allowUpdates, + 'allowUppercaseInSlug' => $generalConfig->allowUppercaseInSlug, + 'autosaveDrafts' => true, // @TODO: This should always be true in the frontend + 'apiParams' => app(Api::class)->apiParams, + 'appId' => config('app.name'), + 'autofocusPreferred' => $currentUser->getAutofocusPreferred(), + 'canAccessQueueManager' => app(Utilities::class)->checkAuthorization(QueueManager::class), + 'dataAttributes' => Html::$dataAttributes, + 'defaultIndexCriteria' => [], + 'disableAutofocus' => (bool) ( + $currentUser->getPreference('disableAutofocus') + ?? $generalConfig->accessibilityDefaults['disableAutofocus'] + ?? false + ), + 'edition' => Edition::get()->value, + 'elementTypeNames' => $elementTypeNames, + 'elevatedSessionDuration' => $generalConfig->elevatedSessionDuration, + 'fieldsWithoutContent' => app(Fields::class)->getFieldsWithoutContent(false)->pluck('handle')->all(), + 'handleCasing' => $generalConfig->handleCasing, + 'httpProxy' => self::httpProxy($generalConfig), + 'isImagick' => Images::getIsImagick(), + 'isMultiSite' => Sites::isMultiSite(), + 'limitAutoSlugsToAscii' => $generalConfig->limitAutoSlugsToAscii, + 'maxUploadSize' => AssetsHelper::getMaxUploadSize(), + 'notificationDuration' => (int) ( + $currentUser->getPreference('notificationDuration') + ?? $generalConfig->accessibilityDefaults['notificationDuration'] + ?? 5000 + ), + 'notificationPosition' => $currentUser->getPreference('notificationPosition') + ?? $generalConfig->accessibilityDefaults['notificationPosition'] + ?? 'end-start', + 'slideoutPosition' => $currentUser->getPreference('slideoutPosition') + ?? $generalConfig->accessibilityDefaults['slideoutPosition'] + ?? 'end', + 'previewIframeResizerOptions' => self::previewIframeResizerOptions($generalConfig), + 'primarySiteId' => $primarySite ? (int) $primarySite->id : null, + 'primarySiteLanguage' => $primarySite?->getLanguage(), + 'publishableSections' => $upToDate ? self::publishableSections($currentUser) : [], + 'runQueueAutomatically' => $generalConfig->runQueueAutomatically, + 'siteId' => $upToDate ? (app(RequestedSite::class)->get()->id ?? Sites::getCurrentSite()->id) : null, + 'sites' => self::sites(), + 'siteToken' => $generalConfig->siteToken, + 'slugWordSeparator' => $generalConfig->slugWordSeparator, + 'userEmail' => $currentUser->email, + 'userHasPasskeys' => app(Passkeys::class)->hasPasskeys(app(Impersonation::class)->getImpersonator() ?? $currentUser), + 'userId' => $currentUser->id, + 'userIsAdmin' => $currentUser->admin, + 'username' => $currentUser->username, + ]; + } + + private static function datepickerOptions(Locale $formattingLocale, Locale $locale): array + { + return [ + 'constrainInput' => false, + 'changeYear' => true, + 'dateFormat' => $formattingLocale->getDateFormat(Locale::LENGTH_SHORT, Locale::FORMAT_JUI), + 'dayNames' => $locale->getWeekDayNames(Locale::LENGTH_FULL), + 'dayNamesMin' => $locale->getWeekDayNames(Locale::LENGTH_ABBREVIATED), + 'dayNamesShort' => $locale->getWeekDayNames(Locale::LENGTH_SHORT), + 'firstDay' => DateTimeHelper::firstWeekDay(), + 'monthNames' => $locale->getMonthNames(Locale::LENGTH_FULL), + 'monthNamesShort' => $locale->getMonthNames(Locale::LENGTH_ABBREVIATED), + 'nextText' => t('Next'), + 'prevText' => t('Prev'), + 'yearRange' => 'c-100:c+100', + ]; + } + + private static function defaultCookieOptions(): array + { + return [ + 'path' => config('session.path', '/'), + 'domain' => config('session.domain'), + 'secure' => config('session.secure', false), + 'sameSite' => config('session.same_site', 'strict'), + ]; + } + + private static function httpProxy(GeneralConfig $generalConfig): ?array + { + if (! $generalConfig->httpProxy) { + return null; + } + + $parsed = parse_url($generalConfig->httpProxy); + + return array_filter([ + 'host' => $parsed['host'], + 'port' => $parsed['port'] ?? strtolower($parsed['scheme']) === 'http' ? 80 : 443, + 'auth' => array_filter([ + 'username' => $parsed['user'] ?? null, + 'password' => $parsed['pass'] ?? null, + ]), + 'protocol' => $parsed['scheme'], + ]); + } + + private static function previewIframeResizerOptions(GeneralConfig $generalConfig): array|null|false + { + if (! $generalConfig->useIframeResizer) { + return false; + } + + // Treat false as [] as well now that useIframeResizer exists + if (empty($generalConfig->previewIframeResizerOptions)) { + return null; + } + + return $generalConfig->previewIframeResizerOptions; + } + + private static function publishableSections(User $currentUser): array + { + $sections = []; + + foreach (Sections::getEditableSections() as $section) { + if ($section->type !== SectionType::Single && $currentUser->can("createEntries:$section->uid")) { + $sections[] = [ + 'entryTypes' => self::entryTypes($section), + 'handle' => $section->handle, + 'id' => (int) $section->id, + 'name' => t($section->name, category: 'site'), + 'sites' => $section->getSiteIds(), + 'type' => $section->type, + 'uid' => $section->uid, + 'canSave' => $currentUser->can("saveEntries:$section->uid"), + ]; + } + } + + return $sections; + } + + private static function entryTypes(Section $section): array + { + $types = []; + + foreach ($section->getEntryTypes() as $type) { + $types[] = [ + 'handle' => $type->handle, + 'id' => (int) $type->id, + 'name' => t($type->name, category: 'site'), + ]; + } + + return $types; + } + + private static function sites(): array + { + $sites = []; + + foreach (Sites::getAllSites() as $site) { + $sites[] = [ + 'handle' => $site->handle, + 'id' => (int) $site->id, + 'uid' => (string) $site->uid, + 'name' => t($site->getName(), category: 'site'), + ]; + } + + return $sites; + } + + private static function timepickerOptions(Locale $formattingLocale, string $orientation): array + { + // normalize the AM/PM names consistently with time2int() in jQuery Timepicker + $am = preg_replace('/[\s.]/', '', $formattingLocale->getAMName()); + $pm = preg_replace('/[\s.]/', '', $formattingLocale->getPMName()); + + return [ + 'closeOnWindowScroll' => false, + 'lang' => [ + 'AM' => $am, + 'am' => mb_strtolower((string) $am), + 'PM' => $pm, + 'pm' => mb_strtolower((string) $pm), + ], + 'orientation' => $orientation[0], + 'timeFormat' => $formattingLocale->getTimeFormat(Locale::LENGTH_SHORT, Locale::FORMAT_PHP), + ]; + } } diff --git a/src/View/LegacyAssets/CpAsset.php b/src/View/LegacyAssets/CpAsset.php index d95b6cca2c2..ba53f132e03 100644 --- a/src/View/LegacyAssets/CpAsset.php +++ b/src/View/LegacyAssets/CpAsset.php @@ -4,43 +4,13 @@ namespace CraftCms\Cms\View\LegacyAssets; -use CraftCms\Cms\Announcement\Announcements; -use CraftCms\Cms\Asset\AssetsHelper; -use CraftCms\Cms\Auth\Impersonation; -use CraftCms\Cms\Auth\Passkeys\Passkeys; -use CraftCms\Cms\Cms; -use CraftCms\Cms\Config\GeneralConfig; -use CraftCms\Cms\Cp\RequestedSite; -use CraftCms\Cms\Edition; -use CraftCms\Cms\Element\Contracts\ElementInterface; -use CraftCms\Cms\Field\Fields; -use CraftCms\Cms\Providers\AppServiceProvider; -use CraftCms\Cms\Section\Data\Section; -use CraftCms\Cms\Section\Enums\SectionType; -use CraftCms\Cms\Support\Api; -use CraftCms\Cms\Support\DateTimeHelper; -use CraftCms\Cms\Support\Facades\Elements; -use CraftCms\Cms\Support\Facades\I18N; -use CraftCms\Cms\Support\Facades\Images; -use CraftCms\Cms\Support\Facades\Sections; -use CraftCms\Cms\Support\Facades\Sites; -use CraftCms\Cms\Support\Html; +use CraftCms\Cms\Cp\Cp; use CraftCms\Cms\Support\Json; -use CraftCms\Cms\Support\Str; -use CraftCms\Cms\Support\Url; -use CraftCms\Cms\Translation\Locale; -use CraftCms\Cms\Update\Updates; -use CraftCms\Cms\User\Elements\User; -use CraftCms\Cms\Utility\Utilities; -use CraftCms\Cms\Utility\Utilities\QueueManager; use CraftCms\Cms\View\Enums\Position; use CraftCms\Cms\View\HtmlStack; -use Illuminate\Pagination\Paginator; use Illuminate\Support\Facades\Auth; -use stdClass; use function CraftCms\Cms\craftAsset; -use function CraftCms\Cms\t; /** * @deprecated @@ -102,270 +72,40 @@ public function register(HtmlStack $htmlStack): void ]); // Define the Craft object - $craftJson = Json::encode($this->_craftData()); + $craftJson = Json::encode($this->craftData()); $js = <<js($js, Position::Head); } - private function _craftData(): array + /** + * The legacy `window.Craft` config. + * + * The data now lives on {@see Cp::config()} (shared with the Inertia CP). + * That method is a superset, so we drop the keys it adds for the Inertia + * side that were never part of `window.Craft`. `cpTrigger` and + * `runQueueAutomatically` are also force-included by `Cp::config()` for + * Inertia, so they're stripped here unless the legacy conditions + * (CP request / authenticated user) actually apply. + */ + private function craftData(): array { - $upToDate = Cms::isInstalled() && ! app(Updates::class)->areMigrationsPending(); - $generalConfig = Cms::config(); - $formattingLocale = I18N::getFormattingLocale(); - $locale = I18N::getLocale(); - $orientation = $locale->getOrientation(); - $currentUser = Auth::user(); - $primarySite = $upToDate ? Sites::getPrimarySite() : null; - - $data = [ - 'Solo' => Edition::Solo->value, - 'Team' => Edition::Team->value, - 'Pro' => Edition::Pro->value, - 'Enterprise' => Edition::Enterprise->value, - 'actionTrigger' => $generalConfig->actionTrigger, - 'actionUrl' => Url::actionUrl(), - 'asciiCharMap' => Str::asciiCharMap(true, app()->getLocale()), - 'baseApiUrl' => Api::craftApiEndpoint(), - 'baseSiteUrl' => Url::siteUrl(), - 'baseUrl' => Url::url(), - 'clientOs' => request()->clientOs(), - 'datepickerOptions' => $this->_datepickerOptions($formattingLocale, $locale), - 'defaultCookieOptions' => $this->_defaultCookieOptions(), - 'fileKinds' => AssetsHelper::getFileKinds(), - 'language' => app()->getLocale(), - 'left' => $orientation === 'ltr' ? 'left' : 'right', - 'maxPasswordLength' => AppServiceProvider::$maxPasswordLength, - 'minPasswordLength' => AppServiceProvider::$minPasswordLength, - 'orientation' => $orientation, - 'pageNum' => Paginator::resolveCurrentPage(Cms::config()->getPageTriggerParam()), - 'pageTrigger' => Cms::config()->getPageTriggerParam(), - 'path' => request()->craftPath(), - 'registeredAssetBundles' => [], // force encode as JS object - 'registeredJsFiles' => [], // force encode as JS object - 'right' => $orientation === 'ltr' ? 'right' : 'left', - 'systemUid' => Cms::systemUid(), - 'timepickerOptions' => $this->_timepickerOptions($formattingLocale, $orientation), - 'timezone' => Cms::timezone(), - 'tokenParam' => $generalConfig->tokenParam, - 'translations' => I18N::getAllTranslationsForLocale(app()->getLocale()) ?: new stdClass, - 'useEmailAsUsername' => $generalConfig->useEmailAsUsername, - ]; - - if (request()->isCpRequest()) { - $data += [ - 'announcements' => $upToDate ? app(Announcements::class)->get() : [], - 'baseCpUrl' => Url::cpUrl(), - 'cpTrigger' => $generalConfig->cpTrigger, - ]; - } - - $data += [ - 'csrfTokenName' => '_token', - 'csrfTokenValue' => csrf_token(), - ]; - - // If no one's logged in yet, leave it at that - if (! $currentUser) { - return $data; - } - - $elementTypeNames = []; - foreach (Elements::getAllElementTypes() as $elementType) { - /** @var class-string $elementType */ - $elementTypeNames[$elementType] = [ - $elementType::displayName(), - $elementType::pluralDisplayName(), - $elementType::lowerDisplayName(), - $elementType::pluralLowerDisplayName(), - ]; - } - - return $data + [ - 'allowAdminChanges' => $generalConfig->allowAdminChanges, - 'allowUpdates' => $generalConfig->allowUpdates, - 'allowUppercaseInSlug' => $generalConfig->allowUppercaseInSlug, - 'autosaveDrafts' => true, // @TODO: This should always be true in the frontend - 'apiParams' => app(Api::class)->apiParams, - 'appId' => config('app.name'), - 'autofocusPreferred' => $currentUser->getAutofocusPreferred(), - 'canAccessQueueManager' => app(Utilities::class)->checkAuthorization(QueueManager::class), - 'dataAttributes' => Html::$dataAttributes, - 'defaultIndexCriteria' => [], - 'disableAutofocus' => (bool) ( - $currentUser->getPreference('disableAutofocus') - ?? $generalConfig->accessibilityDefaults['disableAutofocus'] - ?? false - ), - 'edition' => Edition::get()->value, - 'elementTypeNames' => $elementTypeNames, - 'elevatedSessionDuration' => $generalConfig->elevatedSessionDuration, - 'fieldsWithoutContent' => app(Fields::class)->getFieldsWithoutContent(false)->pluck('handle')->all(), - 'handleCasing' => $generalConfig->handleCasing, - 'httpProxy' => $this->_httpProxy($generalConfig), - 'isImagick' => Images::getIsImagick(), - 'isMultiSite' => Sites::isMultiSite(), - 'limitAutoSlugsToAscii' => $generalConfig->limitAutoSlugsToAscii, - 'maxUploadSize' => AssetsHelper::getMaxUploadSize(), - 'notificationDuration' => (int) ( - $currentUser->getPreference('notificationDuration') - ?? $generalConfig->accessibilityDefaults['notificationDuration'] - ?? 5000 - ), - 'notificationPosition' => $currentUser->getPreference('notificationPosition') - ?? $generalConfig->accessibilityDefaults['notificationPosition'] - ?? 'end-start', - 'slideoutPosition' => $currentUser->getPreference('slideoutPosition') - ?? $generalConfig->accessibilityDefaults['slideoutPosition'] - ?? 'end', - 'previewIframeResizerOptions' => $this->_previewIframeResizerOptions($generalConfig), - 'primarySiteId' => $primarySite ? (int) $primarySite->id : null, - 'primarySiteLanguage' => $primarySite?->getLanguage(), - 'publishableSections' => $upToDate ? $this->_publishableSections($currentUser) : [], - 'runQueueAutomatically' => $generalConfig->runQueueAutomatically, - 'siteId' => $upToDate ? (app(RequestedSite::class)->get()->id ?? Sites::getCurrentSite()->id) : null, - 'sites' => $this->_sites(), - 'siteToken' => $generalConfig->siteToken, - 'slugWordSeparator' => $generalConfig->slugWordSeparator, - 'userEmail' => $currentUser->email, - 'userHasPasskeys' => app(Passkeys::class)->hasPasskeys(app(Impersonation::class)->getImpersonator() ?? $currentUser), - 'userId' => $currentUser->id, - 'userIsAdmin' => $currentUser->admin, - 'username' => $currentUser->username, - ]; - } - - private function _datepickerOptions(Locale $formattingLocale, Locale $locale): array - { - return [ - 'constrainInput' => false, - 'changeYear' => true, - 'dateFormat' => $formattingLocale->getDateFormat(Locale::LENGTH_SHORT, Locale::FORMAT_JUI), - 'dayNames' => $locale->getWeekDayNames(Locale::LENGTH_FULL), - 'dayNamesMin' => $locale->getWeekDayNames(Locale::LENGTH_ABBREVIATED), - 'dayNamesShort' => $locale->getWeekDayNames(Locale::LENGTH_SHORT), - 'firstDay' => DateTimeHelper::firstWeekDay(), - 'monthNames' => $locale->getMonthNames(Locale::LENGTH_FULL), - 'monthNamesShort' => $locale->getMonthNames(Locale::LENGTH_ABBREVIATED), - 'nextText' => t('Next'), - 'prevText' => t('Prev'), - 'yearRange' => 'c-100:c+100', + $except = [ + 'cpLogoUrl', + 'cpUrl', + 'defaultCpLocale', + 'rememberedUserSessionDuration', ]; - } - - private function _defaultCookieOptions(): array - { - return [ - 'path' => config('session.path', '/'), - 'domain' => config('session.domain'), - 'secure' => config('session.secure', false), - 'sameSite' => config('session.same_site', 'strict'), - ]; - } - private function _httpProxy(GeneralConfig $generalConfig): ?array - { - if (! $generalConfig->httpProxy) { - return null; + if (! request()->isCpRequest()) { + $except[] = 'cpTrigger'; } - $parsed = parse_url($generalConfig->httpProxy); - - return array_filter([ - 'host' => $parsed['host'], - 'port' => $parsed['port'] ?? strtolower($parsed['scheme']) === 'http' ? 80 : 443, - 'auth' => array_filter([ - 'username' => $parsed['user'] ?? null, - 'password' => $parsed['pass'] ?? null, - ]), - 'protocol' => $parsed['scheme'], - ]); - } - - private function _previewIframeResizerOptions(GeneralConfig $generalConfig): array|null|false - { - if (! $generalConfig->useIframeResizer) { - return false; + if (! Auth::check()) { + $except[] = 'runQueueAutomatically'; } - // Treat false as [] as well now that useIframeResizer exists - if (empty($generalConfig->previewIframeResizerOptions)) { - return null; - } - - return $generalConfig->previewIframeResizerOptions; - } - - private function _publishableSections(User $currentUser): array - { - $sections = []; - - foreach (Sections::getEditableSections() as $section) { - if ($section->type !== SectionType::Single && $currentUser->can("createEntries:$section->uid")) { - $sections[] = [ - 'entryTypes' => $this->_entryTypes($section), - 'handle' => $section->handle, - 'id' => (int) $section->id, - 'name' => t($section->name, category: 'site'), - 'sites' => $section->getSiteIds(), - 'type' => $section->type, - 'uid' => $section->uid, - 'canSave' => $currentUser->can("saveEntries:$section->uid"), - ]; - } - } - - return $sections; - } - - private function _entryTypes(Section $section): array - { - $types = []; - - foreach ($section->getEntryTypes() as $type) { - $types[] = [ - 'handle' => $type->handle, - 'id' => (int) $type->id, - 'name' => t($type->name, category: 'site'), - ]; - } - - return $types; - } - - private function _sites(): array - { - $sites = []; - - foreach (Sites::getAllSites() as $site) { - $sites[] = [ - 'handle' => $site->handle, - 'id' => (int) $site->id, - 'uid' => (string) $site->uid, - 'name' => t($site->getName(), category: 'site'), - ]; - } - - return $sites; - } - - private function _timepickerOptions(Locale $formattingLocale, string $orientation): array - { - // normalize the AM/PM names consistently with time2int() in jQuery Timepicker - $am = preg_replace('/[\s.]/', '', $formattingLocale->getAMName()); - $pm = preg_replace('/[\s.]/', '', $formattingLocale->getPMName()); - - return [ - 'closeOnWindowScroll' => false, - 'lang' => [ - 'AM' => $am, - 'am' => mb_strtolower((string) $am), - 'PM' => $pm, - 'pm' => mb_strtolower((string) $pm), - ], - 'orientation' => $orientation[0], - 'timeFormat' => $formattingLocale->getTimeFormat(Locale::LENGTH_SHORT, Locale::FORMAT_PHP), - ]; + return Cp::config()->except($except)->all(); } } diff --git a/vite.config.js b/vite.config.js index 954e28037ea..9a26b172eab 100644 --- a/vite.config.js +++ b/vite.config.js @@ -92,6 +92,34 @@ function typescriptTransformer() { }; } +/** + * The vendored, webpack-built legacy bundles in `resources/legacy/` are UMD + * wrappers that pass top-level `this` as the global (e.g. `}(this, ...)`) and + * fall back to `this.jQuery` when there's no AMD/CommonJS environment. They were + * written to run as classic ';\n\n // Create a new iframe\n var $iframe = $('