From a7d1d698f0a4879210359ceddd34cfa5cb68ce2c Mon Sep 17 00:00:00 2001 From: dandemchenko Date: Fri, 24 Apr 2026 17:33:10 +0400 Subject: [PATCH] feat: add incompatible warning page --- src/index.ts | 15 + src/plugins/incompatible-warning/constants.ts | 4 + .../defaultIncompatibleWarning.ts | 45 ++ src/plugins/incompatible-warning/index.ts | 286 ++++++++++ src/plugins/incompatible-warning/types.ts | 92 ++++ .../incompatible-warning/warningTemplate.ts | 503 ++++++++++++++++++ src/plugins/index.ts | 4 + src/render.test.ts | 37 +- src/types.ts | 6 +- src/utils/generateRenderContent.ts | 7 +- 10 files changed, 996 insertions(+), 3 deletions(-) create mode 100644 src/plugins/incompatible-warning/constants.ts create mode 100644 src/plugins/incompatible-warning/defaultIncompatibleWarning.ts create mode 100644 src/plugins/incompatible-warning/index.ts create mode 100644 src/plugins/incompatible-warning/types.ts create mode 100644 src/plugins/incompatible-warning/warningTemplate.ts diff --git a/src/index.ts b/src/index.ts index 1017b0e..ad99587 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,8 +8,23 @@ export { createLayoutPlugin, createUikitPlugin, createRemoteVersionsPlugin, + createIncompatibleWarningPlugin, createDefaultPlugins, } from './plugins/index.js'; +export { + INCOMPATIBLE_COOKIE, + defaultIncompatibleWarningTranslationMap, +} from './plugins/incompatible-warning/index.js'; +export type { + BrowserInfo, + I18n, + IncompatibleOptions, + ServiceIcon, + ServiceIconImage, + ServiceIconSvg, + TechnicalInfo, + TechnicalInfoItem, +} from './plugins/incompatible-warning/types.js'; export type { Base, diff --git a/src/plugins/incompatible-warning/constants.ts b/src/plugins/incompatible-warning/constants.ts new file mode 100644 index 0000000..413f420 --- /dev/null +++ b/src/plugins/incompatible-warning/constants.ts @@ -0,0 +1,4 @@ +export const INCOMPATIBLE_COOKIE = 'has_applied_incompatible_browser'; +export const INCOMPATIBLE_BUTTON_CLASS = 'incompatible__action'; +const SECONDS_IN_DAY = 60 * 60 * 24; +export const INCOMPATIBLE_COOKIE_EXPIRED = SECONDS_IN_DAY * 30; diff --git a/src/plugins/incompatible-warning/defaultIncompatibleWarning.ts b/src/plugins/incompatible-warning/defaultIncompatibleWarning.ts new file mode 100644 index 0000000..e59380b --- /dev/null +++ b/src/plugins/incompatible-warning/defaultIncompatibleWarning.ts @@ -0,0 +1,45 @@ +import type {I18n} from './types.js'; + +/** Default copy for the incompatible-browser warning (not tied to a specific product). */ +export const defaultIncompatibleWarningTranslationMap: I18n = { + ru: { + title: 'Ваш браузер устарел', + description: + 'Некоторые функции сервиса {{serviceName}} могут не работать, а браузер может быть небезопасен. Установите новый браузер:', + action: 'Попробовать всё равно', + browsers: [ + {caption: 'Yandex Browser', href: 'https://browser.yandex.ru/'}, + {caption: 'Google Chrome', href: 'https://www.google.ru/chrome/'}, + {caption: 'Safari', href: 'https://www.apple.com/ru/safari/'}, + {caption: 'Microsoft Edge', href: 'https://www.microsoft.com/ru-ru/edge'}, + {caption: 'Opera', href: 'https://www.opera.com/ru/computer'}, + {caption: 'Firefox', href: 'https://www.mozilla.org/ru/firefox/new/'}, + ], + currentBrowserTooltip: { + title: 'Текущая версия — {{version}}', + description: + 'Установленная версия браузера может работать некорректно и представлять угрозу безопасности данных. Обновите версию или установите другой браузер', + action: 'Установить {{browserName}}', + }, + }, + en: { + title: 'Your browser is out of date', + description: + 'Some features of the service {{serviceName}} may not work, and the browser may be insecure. Install a new browser:', + action: 'Try it anyway', + browsers: [ + {caption: 'Yandex Browser', href: 'https://browser.yandex.com/'}, + {caption: 'Google Chrome', href: 'https://www.google.com/chrome/'}, + {caption: 'Safari', href: 'https://www.apple.com/safari/'}, + {caption: 'Microsoft Edge', href: 'https://www.microsoft.com/en-us/edge'}, + {caption: 'Opera', href: 'https://www.opera.com/computer'}, + {caption: 'Firefox', href: 'https://www.mozilla.org/en-US/firefox/new/'}, + ], + currentBrowserTooltip: { + title: 'Current version — {{version}}', + description: + 'The installed browser version may not work correctly and may pose a threat to data security. Update the version or install a different browser', + action: 'Install {{browserName}}', + }, + }, +}; diff --git a/src/plugins/incompatible-warning/index.ts b/src/plugins/incompatible-warning/index.ts new file mode 100644 index 0000000..2b629ba --- /dev/null +++ b/src/plugins/incompatible-warning/index.ts @@ -0,0 +1,286 @@ +import htmlescape from 'htmlescape'; + +import type {Plugin} from '../../types.js'; + +import { + INCOMPATIBLE_BUTTON_CLASS, + INCOMPATIBLE_COOKIE, + INCOMPATIBLE_COOKIE_EXPIRED, +} from './constants.js'; +import {defaultIncompatibleWarningTranslationMap} from './defaultIncompatibleWarning.js'; +import type { + I18n, + IncompatibleOptions, + ServiceIcon, + ServiceIconSvg, + TechnicalInfo, +} from './types.js'; +import {getWarningStyleSheet, getWarningTemplate} from './warningTemplate.js'; + +export {INCOMPATIBLE_COOKIE} from './constants.js'; +export {defaultIncompatibleWarningTranslationMap} from './defaultIncompatibleWarning.js'; +export type { + BrowserInfo, + I18n, + IncompatibleOptions, + ServiceIcon, + ServiceIconImage, + ServiceIconSvg, + TechnicalInfo, + TechnicalInfoItem, +} from './types.js'; + +type WarningTemplateLink = NonNullable[0]['links']>[number]; +type CurrentBrowserTooltip = NonNullable; +type CurrentBrowserTooltipI18n = NonNullable; + +export function createIncompatibleWarningPlugin(): Plugin< + IncompatibleOptions, + 'incompatibleWarning' +> { + return { + name: 'incompatibleWarning', + apply({options, renderContent, commonOptions}) { + if (!options?.enable) { + return; + } + + const { + backgroundImgUrl, + inlineStyleSheets: extraInlineStyleSheets, + translationMap: translationMapInput, + serviceIcon: serviceIconInput, + technicalInfo: technicalInfoInput, + browser: currentBrowser, + serviceName: serviceNameInput, + } = options; + const {title, lang, isMobile} = commonOptions; + + const translationMap = translationMapInput ?? defaultIncompatibleWarningTranslationMap; + const warningLang = lang && lang in translationMap ? lang : 'en'; + const text = translationMap[warningLang] ?? translationMap.en; + + if (!text) { + throw new Error('Incompatible warning translation map must contain an "en" entry'); + } + + // Single source of truth for the service name across header, icon alt, and description. + const serviceName = serviceNameInput?.trim() || undefined; + const serviceNameForHeader = serviceName ? htmlescape.sanitize(serviceName) : undefined; + const serviceIconForHeader = serviceIconInput + ? mapServiceIconForHeader(serviceIconInput, serviceName ?? title) + : undefined; + + const root = getWarningTemplate({ + title: text.title, + description: applyServiceName(text.description, serviceName), + serviceName: serviceNameForHeader, + serviceIcon: serviceIconForHeader, + links: text.browsers.map((browser) => { + const isCurrent = isMatchingBrowserCaption( + browser.caption, + currentBrowser?.name, + ); + return { + ...browser, + isCurrent, + currentTooltip: isCurrent + ? buildCurrentBrowserTooltip( + text.currentBrowserTooltip, + currentBrowser?.version, + browser.caption, + browser.href, + ) + : undefined, + }; + }), + technicalInfo: mapTechnicalInfo(technicalInfoInput), + action: { + caption: text.action, + className: INCOMPATIBLE_BUTTON_CLASS, + }, + }); + + renderContent.styleSheets.length = 0; + renderContent.scripts.length = 0; + renderContent.inlineStyleSheets.length = 0; + renderContent.inlineScripts.length = 0; + renderContent.bodyContent.beforeRoot.length = 0; + renderContent.bodyContent.afterRoot.length = 0; + + renderContent.inlineStyleSheets.push(getWarningStyleSheet(isMobile, backgroundImgUrl)); + if (extraInlineStyleSheets?.length) { + renderContent.inlineStyleSheets.push(...extraInlineStyleSheets); + } + + renderContent.inlineScripts.push(getInlineScript()); + renderContent.bodyContent.root = root; + + return false; + }, + }; +} + +function isServiceIconSvg(icon: ServiceIcon): icon is ServiceIconSvg { + return 'svg' in icon && typeof icon.svg === 'string'; +} + +/** + * Substitutes the `{{serviceName}}` placeholder in the description with the given service name. + * When the name is non-empty, the placeholder is replaced verbatim with `name` + * (the surrounding whitespace from the translation copy is preserved as-is). + * When the name is empty, the placeholder along with adjacent whitespace is removed and + * spacing is normalized so that the sentence reads naturally regardless of whether the original + * copy had a leading/trailing space around the placeholder. + * The substituted value is wrapped in `` to highlight the service name in the warning copy + * — `description` is therefore inserted into the template as HTML. + * @param description Raw description string from the translation map. + * @param serviceName Pre-normalized (already trimmed) service name; `undefined` when not provided. + * @returns Description with the placeholder replaced. + */ +function applyServiceName(description: string, serviceName: string | undefined): string { + if (!serviceName) { + return description + .replace(/\s*\{\{serviceName\}\}\s*/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + } + return description.replace( + /\{\{serviceName\}\}/g, + `${htmlescape.sanitize(serviceName)}`, + ); +} + +/** + * Returns `true` when the localized browser caption corresponds to the user's current browser. + * Matching is case-insensitive and word-based, which lets short UA names like `Chrome` match + * captions like `Google Chrome`, `Edge` match `Microsoft Edge`, `Yandex` match `Yandex Browser`, + * while keeping unrelated entries (`Firefox`, `Safari`, `Opera`) distinct. + * @param caption Localized browser caption from the incompatible-browser links list. + * @param browserName Browser name detected from the current user agent. + * @returns `true` when `caption` matches `browserName`; otherwise `false`. + */ +function isMatchingBrowserCaption(caption: string, browserName: string | undefined): boolean { + if (!browserName) { + return false; + } + + const captionWords = new Set(caption.toLowerCase().split(/\s+/).filter(Boolean)); + const nameWords = browserName.toLowerCase().split(/\s+/).filter(Boolean); + + return nameWords.some((word) => captionWords.has(word)); +} + +/** + * Builds the popup copy shown on hover over the current-browser warning icon. + * The version is sanitized because it may ultimately originate from the user-agent string. + * @param copy Localized popup strings from the translation map. + * @param version Detected browser version; when missing, the popup is not rendered. + * @param browserName Localized name for the current browser (the matching row’s `caption`). + * @param actionHref URL the popup action button points to (typically the current browser's update link). + * @returns Pre-sanitized popup data, or `undefined` when translations or the version are missing. + */ +function buildCurrentBrowserTooltip( + copy: CurrentBrowserTooltipI18n | undefined, + version: string | undefined, + browserName: string, + actionHref: string, +): CurrentBrowserTooltip | undefined { + if (!copy || !version) { + return undefined; + } + + const safeVersion = htmlescape.sanitize(version); + const actionCaption = copy.action.replace('{{browserName}}', browserName); + + return { + title: copy.title.replace('{{version}}', safeVersion), + description: htmlescape.sanitize(copy.description), + action: { + caption: htmlescape.sanitize(actionCaption), + href: escapeHtmlAttributeValue(actionHref), + }, + }; +} + +function mapTechnicalInfo( + info: TechnicalInfo | undefined, +): Array<{label: string; value: string}> | undefined { + if (!info || !info.length) { + return undefined; + } + + const rows = info.reduce>((acc, {label, value}) => { + if (typeof value !== 'string' || !value.trim()) { + return acc; + } + acc.push({ + label: htmlescape.sanitize(label ?? ''), + value: htmlescape.sanitize(value), + }); + return acc; + }, []); + + return rows.length ? rows : undefined; +} + +/** + * Escapes a string for use inside double-quoted HTML attributes. + * @param value Raw text + * @returns Escaped attribute fragment + */ +function escapeHtmlAttributeValue(value: string): string { + return value + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); +} + +/** + * Builds the icon descriptor consumed by `getWarningTemplate` from the user-supplied service icon. + * @param icon Service icon descriptor (image or inline SVG) from plugin options. + * @param altFallback Alt text used when `icon.alt` is omitted. The caller picks the fallback + * (typically the trimmed `serviceName`, falling back to the page title when no service name is set). + * @returns Pre-sanitized icon descriptor, or `undefined` when the icon has no usable source. + */ +function mapServiceIconForHeader( + icon: ServiceIcon, + altFallback: string, +): + | {kind: 'image'; href: string; alt: string} + | {kind: 'svg'; svg: string; alt: string} + | undefined { + const altSource = icon.alt ?? altFallback; + const alt = escapeHtmlAttributeValue(altSource); + + if (isServiceIconSvg(icon)) { + const markup = icon.svg.trim(); + if (!markup) { + return undefined; + } + return {kind: 'svg', svg: markup, alt}; + } + + const href = icon.href.trim(); + if (!href) { + return undefined; + } + + return {kind: 'image', href: escapeHtmlAttributeValue(href), alt}; +} + +function getInlineScript(): string { + return ` + document.addEventListener('DOMContentLoaded', function() { + var button = document.querySelector('.${INCOMPATIBLE_BUTTON_CLASS}'); + + if (button) { + button.addEventListener('click', function() { + document.cookie = '${INCOMPATIBLE_COOKIE}=1; max-age=${INCOMPATIBLE_COOKIE_EXPIRED}; path=/'; + window.location.reload(); + }); + } + }); +`; +} diff --git a/src/plugins/incompatible-warning/types.ts b/src/plugins/incompatible-warning/types.ts new file mode 100644 index 0000000..28f81ba --- /dev/null +++ b/src/plugins/incompatible-warning/types.ts @@ -0,0 +1,92 @@ +export type I18n = Record< + string, + { + title: string; + /** + * Description text shown under the title. + * Supports the `{{serviceName}}` placeholder, replaced with the value passed via + * `IncompatibleOptions.serviceName` (or removed when the option is empty). + */ + description: string; + action: string; + browsers: { + caption: string; + href: string; + }[]; + /** + * Copy for the popup shown on hover over the current-browser warning icon. + * When omitted, the popup is not rendered (only the icon is shown). + */ + currentBrowserTooltip?: { + /** Title of the popup. Supports the `{{version}}` placeholder, replaced with the detected browser version. */ + title: string; + description: string; + /** + * Button label. Supports `{{browserName}}`, replaced with the localized name of the current + * browser row (the same as that row’s `caption`). + */ + action: string; + }; + } +>; + +/** Raster logo in the incompatible warning header (24×24). */ +export interface ServiceIconImage { + href: string; + /** Alternative text; falls back to the service name (or the page title when no service name is set) when omitted. */ + alt?: string; + /** @deprecated Header logo is always 24×24; kept for backward compatibility. */ + width?: number; + /** @deprecated See `width`. */ + height?: number; +} + +/** Inline SVG in the header (24×24). Markup is inserted as-is — use only trusted SVG. */ +export interface ServiceIconSvg { + /** Full `...` or equivalent markup. */ + svg: string; + /** Accessible name; falls back to the service name (or the page title when no service name is set) when omitted. */ + alt?: string; +} + +export type ServiceIcon = ServiceIconImage | ServiceIconSvg; + +/** A single row in the technical info block. */ +export interface TechnicalInfoItem { + label: string; + value: string; +} + +/** + * Technical request/environment details rendered under the browser list. + * Rendered in the given order; items with an empty `value` are skipped. + */ +export type TechnicalInfo = TechnicalInfoItem[]; + +/** Detected end-user browser. Used to display which browser triggered the stub page. */ +export interface BrowserInfo { + name: string; + version: string; +} + +export interface IncompatibleOptions { + enable?: boolean; + /** Optional URL of a background image used in the full-page (desktop) layout. When omitted, a neutral background color is used. */ + backgroundImgUrl?: string; + inlineStyleSheets?: string[]; + /** When omitted, the built-in default translation map from `defaultIncompatibleWarning` is used. */ + translationMap?: I18n; + /** Optional service logo in the card header (24×24, next to the page title). Omit to hide. */ + serviceIcon?: ServiceIcon; + /** Technical request details shown between the browser list and the action button. */ + technicalInfo?: TechnicalInfo; + /** Detected end-user browser name and version. */ + browser?: BrowserInfo; + /** + * Optional service name injected into the description via the `{{serviceName}}` placeholder. + * For example, with `serviceName: 'Arcanum'` the default Russian copy becomes + * "Некоторые функции сервиса Arcanum могут не работать…". + * When omitted or empty, the placeholder is removed without leaving extra whitespace. + */ + serviceName?: string; +} diff --git a/src/plugins/incompatible-warning/warningTemplate.ts b/src/plugins/incompatible-warning/warningTemplate.ts new file mode 100644 index 0000000..f1045a5 --- /dev/null +++ b/src/plugins/incompatible-warning/warningTemplate.ts @@ -0,0 +1,503 @@ +const SERVICE_LOGO_SIZE = 24; + +interface CurrentBrowserTooltipTemplate { + /** Pre-sanitized HTML. */ + title: string; + /** Pre-sanitized HTML. */ + description: string; + action: { + /** Pre-sanitized HTML. */ + caption: string; + /** Pre-sanitized attribute value. */ + href: string; + }; +} + +/** + * Inline warning triangle (yellow) shown next to the link of the user's current browser. + * When `tooltip` is provided, renders a CSS-only hover popup with version info and an update link. + * @param tooltip Pre-sanitized popup copy; when omitted, only the warning icon is rendered. + * @returns HTML markup for the icon (with an optional popup inside). + */ +const getCurrentBrowserWarningIconTemplate = (tooltip?: CurrentBrowserTooltipTemplate): string => { + const tooltipTemplate = tooltip + ? ` + + ${tooltip.title} + ${tooltip.description} + ${tooltip.action.caption} + + `.trim() + : ''; + + return ` + + +${tooltipTemplate} + +`.trim(); +}; + +interface WarningTemplateArgs { + title: string; + description: string; + serviceName?: string; + serviceIcon?: + | { + kind: 'image'; + href: string; + alt: string; + } + | { + kind: 'svg'; + svg: string; + alt: string; + }; + links?: Array<{ + href: string; + caption: string; + /** Marks the row that corresponds to the user's current browser; renders a warning icon. */ + isCurrent?: boolean; + /** When provided along with `isCurrent`, renders a hover popup next to the warning icon. */ + currentTooltip?: CurrentBrowserTooltipTemplate; + }>; + technicalInfo?: Array<{ + label: string; + value: string; + }>; + action?: { + caption: string; + className?: string; + }; +} + +export const getWarningTemplate = ({ + title, + serviceName, + serviceIcon, + description, + links, + technicalInfo, + action, +}: WarningTemplateArgs) => { + const showServiceHeader = Boolean(serviceIcon || serviceName); + + let serviceHeaderIconTemplate = ''; + if (serviceIcon) { + if (serviceIcon.kind === 'svg') { + if (serviceIcon.alt) { + serviceHeaderIconTemplate = ` + + `.trim(); + } else { + serviceHeaderIconTemplate = ` + + `.trim(); + } + } else { + serviceHeaderIconTemplate = ` + + `.trim(); + } + } + + const serviceHeaderTitleTemplate = serviceName + ? ` +
+ ${serviceName} +
+ `.trim() + : ''; + + const serviceHeaderTemplate = showServiceHeader + ? ` +
+
+ ${serviceHeaderIconTemplate} + ${serviceHeaderTitleTemplate} +
+
+ `.trim() + : ''; + + const linkArrayTemplate = + links && links.length + ? ` + + `.trim() + : ''; + + const technicalInfoTemplate = + technicalInfo && technicalInfo.length + ? ` +
+ ${technicalInfo + .map( + (item) => ` +
+
${item.label}:
+
${item.value}
+
+ `, + ) + .join('')} +
+ `.trim() + : ''; + + const actionTemplate = action + ? ` + + `.trim() + : ''; + + return ` +
+
+ ${serviceHeaderTemplate} +

+ ${title} +

+

+ ${description} +

+ ${linkArrayTemplate} + ${technicalInfoTemplate} + ${actionTemplate} +
+
+ `.trim(); +}; + +export const getWarningStyleSheet = (isMobile = false, backgroundImgUrl?: string): string => { + const desktopBackground = backgroundImgUrl + ? `background: url(${backgroundImgUrl}) no-repeat; + background-position: center; + background-size: cover;` + : `background-color: #f5f5f5;`; + + return ` +@font-face { + font-family: 'YS Text'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(https://yastatic.net/s3/home/fonts/ys/4/text-regular.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: 'YS Text'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(https://yastatic.net/s3/home/fonts/ys/4/text-bold.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +body { + font-family: 'YS Text', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif; + margin: 0; +} +.layout-warning__container { + width: 100%; + padding: 1px; + -webkit-box-sizing: border-box; + box-sizing: border-box; + position: relative; + + ${ + isMobile + ? '' + : ` + height: 800px; + height: 100vh; + ${desktopBackground} + ` + } +} +.layout-warning__info { + background: #fff; + border-radius: 32px; + padding: 20px 32px 32px 32px; + color: rgba(0, 0, 0, 0.85); + font-size: 13px; + line-height: 18px; + -webkit-box-sizing: border-box; + box-sizing: border-box; + + ${ + isMobile + ? '' + : ` + width: 384px; + -webkit-box-shadow: 0px 2px 52px rgba(0, 0, 0, 0.14); + box-shadow: 0px 2px 52px rgba(0, 0, 0, 0.14); + -webkit-transform: translate(-50%, -50%); + -ms-transform: translate(-50%, -50%); + transform: translate(-50%, -50%); + left: 50%; + top: 50%; + position: absolute; + max-width: 95%; + ` + } +} + +.layout-warning__service-header { + margin-bottom: 20px; +} + +.layout-warning__service-header-row { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -ms-flex-pack: center; + justify-content: center; + gap: 10px; +} +.layout-warning__service-logo { + display: block; + -ms-flex-negative: 0; + flex-shrink: 0; + width: ${SERVICE_LOGO_SIZE}px; + height: ${SERVICE_LOGO_SIZE}px; + -o-object-fit: contain; + object-fit: contain; +} +.layout-warning__service-logo_svg { + display: -webkit-inline-box; + display: -ms-inline-flexbox; + display: inline-flex; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -ms-flex-pack: center; + justify-content: center; + -webkit-box-sizing: border-box; + box-sizing: border-box; +} +.layout-warning__service-logo_svg svg { + display: block; + width: 100%; + height: 100%; +} +.layout-warning__service-name { + font-weight: 600; + font-size: 17px; + line-height: 24px; + color: rgba(0, 0, 0, 0.85); +} +.layout-warning__title { + font-weight: 500; + font-size: 20px; + line-height: 24px; + margin: 0 0 12px; + text-align: center; + color: rgba(0, 0, 0, 0.85); +} +.layout-warning__description { + margin-top: 0; + margin-bottom: 12px; +} +.layout-warning__links { + width: 100%; + padding: 0; + -webkit-box-sizing: border-box; + box-sizing: border-box; + margin-bottom: 4px; + margin-top: 0; + list-style: none; +} +.layout-warning__links::after { + clear: both; + display: block; + content: ''; +} +.layout-warning__link-item { + margin-bottom: 8px; + + ${ + isMobile + ? '' + : ` + width: 50%; + float: left; + ` + } +} +.layout-warning__link { + color: #4E79EB; + text-decoration: none; +} +.layout-warning__current-icon { + position: absolute; + display: inline-block; + margin-left: 8px; + vertical-align: middle; + line-height: 0; + padding: 4px; + border-radius: 4px; + background-color: #FFDB4D4D; + cursor: pointer; +} +.layout-warning__current-icon:hover { + z-index: 2; +} +.layout-warning__current-icon svg { + display: block; +} +.layout-warning__current-tip { + position: absolute; + top: 100%; + left: 50%; + margin-top: 4px; + -webkit-transform: translateX(-10%); + -ms-transform: translateX(-10%); + transform: translateX(-10%); + width: 282px; + max-width: 90vw; + padding: 16px; + -webkit-box-sizing: border-box; + box-sizing: border-box; + background: #fff; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 5px; + -webkit-box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14); + color: rgba(0, 0, 0, 0.85); + font-size: 13px; + line-height: 18px; + text-align: left; + z-index: 2; + visibility: hidden; + opacity: 0; +} +/* + * Transparent "bridge" over the 8px gap between the icon and the popup so the + * cursor never leaves the hover area while moving from one to the other — this + * prevents the popup from flickering or closing unexpectedly. + */ +.layout-warning__current-tip::before { + content: ''; + position: absolute; + top: -8px; + left: 0; + right: 0; + height: 8px; +} +.layout-warning__current-icon:hover .layout-warning__current-tip { + visibility: visible; + opacity: 1; +} +.layout-warning__current-tip-title { + display: block; + font-weight: 600; + font-size: 15px; + line-height: 20px; + margin-bottom: 12px; + color: rgba(0, 0, 0, 0.85); +} +.layout-warning__current-tip-description { + display: block; + margin-bottom: 16px; + color: rgba(0, 0, 0, 0.85); +} +.layout-warning__current-tip-action { + display: block; + width: 100%; + -webkit-box-sizing: border-box; + box-sizing: border-box; + padding: 10px 16px; + background: #5282FF; + color: #fff; + text-decoration: none; + text-align: center; + border-radius: 8px; + font-weight: 400; + font-size: 13px; + line-height: 18px; +} +.layout-warning__current-tip-action:hover { + background: #3D68D5; +} +.layout-warning__tech-info { + margin: 0 0 20px; + padding: 12px 16px; + border-radius: 8px; + background-color: rgba(0, 0, 0, 0.05); + color: rgba(0, 0, 0, 0.5); + font-family: Menlo, 'SF Mono', 'Consolas', 'Liberation Mono', monospace; + font-size: 12px; + line-height: 18px; + word-break: break-all; + -webkit-box-sizing: border-box; + box-sizing: border-box; +} +.layout-warning__tech-info-row { + display: block; +} +.layout-warning__tech-info-label { + display: inline-block; + margin:0; +} +.layout-warning__tech-info-value { + display: inline; + margin:0; +} +.layout-warning__action { + border: 1px solid #5282FF; + -webkit-box-sizing: border-box; + box-sizing: border-box; + border-radius: 8px; + padding: 9px 16px; + background: #5282FF; + cursor: pointer; + outline: none; + font-family: inherit; + width: 100%; + color: #fff; +} +.layout-warning__action:hover { + background-color: #4e79eb; +} +`.trim(); +}; diff --git a/src/plugins/index.ts b/src/plugins/index.ts index 7c5bc22..4731feb 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -1,4 +1,5 @@ import {createGoogleAnalyticsPlugin} from './google-analytics/index.js'; +import {createIncompatibleWarningPlugin} from './incompatible-warning/index.js'; import {createLayoutPlugin} from './layout/index.js'; import type {LayoutInitOptions} from './layout/index.js'; import {createRemoteVersionsPlugin} from './remote-versions/index.js'; @@ -7,6 +8,8 @@ import {createYandexMetrikaPlugin} from './yandex-metrika/index.js'; export function createDefaultPlugins({layout}: {layout: LayoutInitOptions}) { return [ + // Must run first: short-circuits the chain via `return false` to keep the stub page self-contained. + createIncompatibleWarningPlugin(), createGoogleAnalyticsPlugin(), createYandexMetrikaPlugin(), createUikitPlugin(), @@ -20,4 +23,5 @@ export { createLayoutPlugin, createUikitPlugin, createRemoteVersionsPlugin, + createIncompatibleWarningPlugin, }; diff --git a/src/render.test.ts b/src/render.test.ts index a8207af..e0aaf4f 100644 --- a/src/render.test.ts +++ b/src/render.test.ts @@ -1,4 +1,4 @@ -import {createUikitPlugin} from './plugins/index.js'; +import {createIncompatibleWarningPlugin, createUikitPlugin} from './plugins/index.js'; import {createRenderFunction} from './render.js'; import type {Link, Meta, Plugin, Script, Stylesheet} from './types.js'; @@ -113,3 +113,38 @@ test('should not modify users params', () => { expect(styleSheets).toEqual([]); expect(inlineStyleSheets).toEqual([]); }); + +test('should render incompatible browser stub page and skip plugins', () => { + const spy = jest.fn(); + const spyPlugin: Plugin = { + name: 'spyPlugin', + apply: spy, + }; + + const html = createRenderFunction([createIncompatibleWarningPlugin(), spyPlugin])({ + title: 'My App', + data: {secret: 'value'}, + scripts: [{src: 'app.js'}], + styleSheets: [{href: 'app.css'}], + pluginsOptions: { + incompatibleWarning: {enable: true}, + }, + }); + + expect(spy).not.toHaveBeenCalled(); + expect(html).toMatch('layout-warning__container'); + expect(html).not.toMatch('