diff --git a/packages/docusaurus-theme-classic/src/__tests__/__snapshots__/inlineScripts.test.ts.snap b/packages/docusaurus-theme-classic/src/__tests__/__snapshots__/inlineScripts.test.ts.snap index 2cee3bc7aa98..fdf84f2bcb94 100644 --- a/packages/docusaurus-theme-classic/src/__tests__/__snapshots__/inlineScripts.test.ts.snap +++ b/packages/docusaurus-theme-classic/src/__tests__/__snapshots__/inlineScripts.test.ts.snap @@ -1,5 +1,47 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`inlineScripts > getAnnouncementBarInlineScript > generates inline javascript with announcement bar id 1`] = ` +"(function() { + function isDismissed() { + try { + if (localStorage.getItem('docusaurus.announcement.id') !== "announcement-1") { + return false; + } + return localStorage.getItem('docusaurus.announcement.dismiss') === 'true'; + } catch (err) {} + return false; + } + document.documentElement.setAttribute('data-announcement-bar-initially-dismissed', isDismissed()); +})();" +`; + +exports[`inlineScripts > getAnnouncementBarInlineScript > generates inline javascript with custom siteStorage namespace 1`] = ` +"(function() { + function isDismissed() { + try { + if (localStorage.getItem('docusaurus.announcement.id_custom_ns') !== "announcement-1") { + return false; + } + return localStorage.getItem('docusaurus.announcement.dismiss_custom_ns') === 'true'; + } catch (err) {} + return false; + } + document.documentElement.setAttribute('data-announcement-bar-initially-dismissed', isDismissed()); +})();" +`; + +exports[`inlineScripts > getAnnouncementBarInlineScript > generates inline javascript without announcementBar (fallback) 1`] = ` +"(function() { + function isDismissed() { + try { + return localStorage.getItem('docusaurus.announcement.dismiss') === 'true'; + } catch (err) {} + return false; + } + document.documentElement.setAttribute('data-announcement-bar-initially-dismissed', isDismissed()); +})();" +`; + exports[`inlineScripts > inline javascript for default mode and no switch 1`] = ` "(function() { function getSystemColorMode() { diff --git a/packages/docusaurus-theme-classic/src/__tests__/inlineScripts.test.ts b/packages/docusaurus-theme-classic/src/__tests__/inlineScripts.test.ts index 2690e0b09c89..98c75b7accae 100644 --- a/packages/docusaurus-theme-classic/src/__tests__/inlineScripts.test.ts +++ b/packages/docusaurus-theme-classic/src/__tests__/inlineScripts.test.ts @@ -6,7 +6,41 @@ */ import {describe, expect, it} from 'vitest'; -import {getThemeInlineScript} from '../inlineScripts'; +import { + getThemeInlineScript, + getAnnouncementBarInlineScript, +} from '../inlineScripts'; + +function executeInlineScript({ + script, + storage = {}, + throwsError = false, +}: { + script: string; + storage?: Record; + throwsError?: boolean; +}) { + const attributes: Record = {}; + const mockLocalStorage = { + getItem: (key: string) => { + if (throwsError) { + throw new Error('SecurityError: LocalStorage is disabled'); + } + return storage[key] ?? null; + }, + }; + const mockDocument = { + documentElement: { + setAttribute: (name: string, value: string | boolean) => { + attributes[name] = String(value); + }, + getAttribute: (name: string) => attributes[name] ?? null, + }, + }; + const fn = new Function('localStorage', 'document', script); + fn(mockLocalStorage, mockDocument); + return attributes; +} describe('inlineScripts', () => { it('inline javascript for default options', () => { @@ -40,4 +74,183 @@ describe('inlineScripts', () => { }), ).toMatchSnapshot(); }); + + describe('getAnnouncementBarInlineScript', () => { + const siteStorage = { + type: 'localStorage' as const, + namespace: '', + }; + + it('generates inline javascript with announcement bar id', () => { + expect( + getAnnouncementBarInlineScript({ + siteStorage, + announcementBar: { + id: 'announcement-1', + }, + }), + ).toMatchSnapshot(); + }); + + it('generates inline javascript with custom siteStorage namespace', () => { + expect( + getAnnouncementBarInlineScript({ + siteStorage: { + type: 'localStorage', + namespace: '_custom_ns', + }, + announcementBar: { + id: 'announcement-1', + }, + }), + ).toMatchSnapshot(); + }); + + it('generates inline javascript without announcementBar (fallback)', () => { + expect( + getAnnouncementBarInlineScript({ + siteStorage, + }), + ).toMatchSnapshot(); + }); + + it('treats announcement as not dismissed when storage is empty', () => { + const script = getAnnouncementBarInlineScript({ + siteStorage, + announcementBar: { + id: 'announcement-1', + }, + }); + const attributes = executeInlineScript({script, storage: {}}); + expect(attributes['data-announcement-bar-initially-dismissed']).toBe( + 'false', + ); + }); + + it('treats announcement as dismissed when stored id matches and dismiss is true', () => { + const script = getAnnouncementBarInlineScript({ + siteStorage, + announcementBar: { + id: 'announcement-1', + }, + }); + const attributes = executeInlineScript({ + script, + storage: { + 'docusaurus.announcement.id': 'announcement-1', + 'docusaurus.announcement.dismiss': 'true', + }, + }); + expect(attributes['data-announcement-bar-initially-dismissed']).toBe( + 'true', + ); + }); + + it('treats announcement as not dismissed when new id is configured after previous dismissal', () => { + const script = getAnnouncementBarInlineScript({ + siteStorage, + announcementBar: { + id: 'announcement-2', + }, + }); + const attributes = executeInlineScript({ + script, + storage: { + 'docusaurus.announcement.id': 'announcement-1', + 'docusaurus.announcement.dismiss': 'true', + }, + }); + expect(attributes['data-announcement-bar-initially-dismissed']).toBe( + 'false', + ); + }); + + it('treats announcement as not dismissed when dismiss is false even with matching id', () => { + const script = getAnnouncementBarInlineScript({ + siteStorage, + announcementBar: { + id: 'announcement-1', + }, + }); + const attributes = executeInlineScript({ + script, + storage: { + 'docusaurus.announcement.id': 'announcement-1', + 'docusaurus.announcement.dismiss': 'false', + }, + }); + expect(attributes['data-announcement-bar-initially-dismissed']).toBe( + 'false', + ); + }); + + it('respects siteStorage namespace during execution', () => { + const script = getAnnouncementBarInlineScript({ + siteStorage: { + type: 'localStorage', + namespace: '_my_docs', + }, + announcementBar: { + id: 'announcement-1', + }, + }); + const dismissedAttrs = executeInlineScript({ + script, + storage: { + 'docusaurus.announcement.id_my_docs': 'announcement-1', + 'docusaurus.announcement.dismiss_my_docs': 'true', + }, + }); + expect(dismissedAttrs['data-announcement-bar-initially-dismissed']).toBe( + 'true', + ); + + const otherStorageAttrs = executeInlineScript({ + script, + storage: { + 'docusaurus.announcement.id': 'announcement-1', + 'docusaurus.announcement.dismiss': 'true', + }, + }); + expect( + otherStorageAttrs['data-announcement-bar-initially-dismissed'], + ).toBe('false'); + }); + + it('escapes special characters in announcement bar id safely', () => { + const complexId = 'announcement-"special"-\'quotes\'-id'; + const script = getAnnouncementBarInlineScript({ + siteStorage, + announcementBar: { + id: complexId, + }, + }); + const attributes = executeInlineScript({ + script, + storage: { + 'docusaurus.announcement.id': complexId, + 'docusaurus.announcement.dismiss': 'true', + }, + }); + expect(attributes['data-announcement-bar-initially-dismissed']).toBe( + 'true', + ); + }); + + it('treats announcement as not dismissed when localStorage access throws', () => { + const script = getAnnouncementBarInlineScript({ + siteStorage, + announcementBar: { + id: 'announcement-1', + }, + }); + const attributes = executeInlineScript({ + script, + throwsError: true, + }); + expect(attributes['data-announcement-bar-initially-dismissed']).toBe( + 'false', + ); + }); + }); }); diff --git a/packages/docusaurus-theme-classic/src/index.ts b/packages/docusaurus-theme-classic/src/index.ts index 8c5a236835a3..b57e3bf69eb1 100644 --- a/packages/docusaurus-theme-classic/src/index.ts +++ b/packages/docusaurus-theme-classic/src/index.ts @@ -134,7 +134,7 @@ export default function themeClassic( innerHTML: ` ${getThemeInlineScript({colorMode, siteStorage})} ${DataAttributeQueryStringInlineJavaScript} -${announcementBar ? getAnnouncementBarInlineScript({siteStorage}) : ''} +${announcementBar ? getAnnouncementBarInlineScript({siteStorage, announcementBar}) : ''} `, }, ], diff --git a/packages/docusaurus-theme-classic/src/inlineScripts.ts b/packages/docusaurus-theme-classic/src/inlineScripts.ts index fe49094a52c4..d49b375d85cd 100644 --- a/packages/docusaurus-theme-classic/src/inlineScripts.ts +++ b/packages/docusaurus-theme-classic/src/inlineScripts.ts @@ -76,22 +76,36 @@ export const DataAttributeQueryStringInlineJavaScript = ` // the announcement bar asap (before React hydration) export function getAnnouncementBarInlineScript({ siteStorage, + announcementBar, }: { siteStorage: SiteStorage; + announcementBar?: { + id?: string; + }; }): string { // Duplicated constant. Unfortunately we can't import it from theme-common, as // we need to support older nodejs versions without ESM support // TODO: import from theme-common once we support Node.js with ESM support // + move all those announcementBar stuff there too + const AnnouncementBarIdStorageKey = `docusaurus.announcement.id${siteStorage.namespace}`; const AnnouncementBarDismissStorageKey = `docusaurus.announcement.dismiss${siteStorage.namespace}`; const AnnouncementBarDismissDataAttribute = 'data-announcement-bar-initially-dismissed'; + const announcementBarId = announcementBar?.id; /* language=js */ return `(function() { function isDismissed() { try { - return localStorage.getItem('${AnnouncementBarDismissStorageKey}') === 'true'; + ${ + announcementBarId !== undefined + ? `if (localStorage.getItem('${AnnouncementBarIdStorageKey}') !== ${JSON.stringify( + announcementBarId, + )}) { + return false; + }\n ` + : '' + }return localStorage.getItem('${AnnouncementBarDismissStorageKey}') === 'true'; } catch (err) {} return false; }