Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
*/

import {describe, expect, it} from 'vitest';
import {getThemeInlineScript} from '../inlineScripts';
import {
getThemeInlineScript,
getAnnouncementBarInlineScript,
} from '../inlineScripts';

describe('inlineScripts', () => {
it('inline javascript for default options', () => {
Expand Down Expand Up @@ -40,4 +43,125 @@ describe('inlineScripts', () => {
}),
).toMatchSnapshot();
});

describe('getAnnouncementBarInlineScript', () => {
it('generates inline script with default announcement id and namespace', () => {
const script = getAnnouncementBarInlineScript({
siteStorage: {
type: 'localStorage',
namespace: '',
},
});
expect(script).toContain(
'localStorage.getItem(\'docusaurus.announcement.id\') !== "announcement-bar"',
);
expect(script).toContain(
"localStorage.getItem('docusaurus.announcement.dismiss') === 'true'",
);
expect(script).toContain(
"document.documentElement.setAttribute('data-announcement-bar-initially-dismissed', isDismissed())",
);
});

it('generates inline script with custom announcement id and custom namespace', () => {
const script = getAnnouncementBarInlineScript({
siteStorage: {
type: 'localStorage',
namespace: '.custom-namespace',
},
announcementBar: {
id: 'announcement-v2',
},
});
expect(script).toContain(
'localStorage.getItem(\'docusaurus.announcement.id.custom-namespace\') !== "announcement-v2"',
);
expect(script).toContain(
"localStorage.getItem('docusaurus.announcement.dismiss.custom-namespace') === 'true'",
);
});

it('evaluates to false when a new announcement id differs from dismissed one', () => {
const script = getAnnouncementBarInlineScript({
siteStorage: {
type: 'localStorage',
namespace: '',
},
announcementBar: {
id: 'announcement-v2',
},
});

const store = new Map<string, string>();
const mockLocalStorage = {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => store.set(key, value),
};
let attributeValue = '';
const mockDocument = {
documentElement: {
setAttribute: (_name: string, value: string | boolean) => {
attributeValue = String(value);
},
},
};

const fn = new Function('localStorage', 'document', script);

// Simulate a user who dismissed announcement-v1
mockLocalStorage.setItem('docusaurus.announcement.id', 'announcement-v1');
mockLocalStorage.setItem('docusaurus.announcement.dismiss', 'true');

fn(mockLocalStorage, mockDocument);
expect(attributeValue).toBe('false');

// Simulate missing stored announcement ID while dismissal flag is true
store.delete('docusaurus.announcement.id');
fn(mockLocalStorage, mockDocument);
expect(attributeValue).toBe('false');

// Simulate when user has dismissed current announcement-v2
mockLocalStorage.setItem('docusaurus.announcement.id', 'announcement-v2');

fn(mockLocalStorage, mockDocument);
expect(attributeValue).toBe('true');
});

it('escapes closing script tags in announcement id', () => {
const script = getAnnouncementBarInlineScript({
siteStorage: {
type: 'localStorage',
namespace: '',
},
announcementBar: {
id: 'x</script><script>alert(1)</script>',
},
});
expect(script).not.toContain('</script>');
expect(script).toContain('\\u003c/script>');

const store = new Map<string, string>();
const mockLocalStorage = {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => store.set(key, value),
};
let attributeValue = '';
const mockDocument = {
documentElement: {
setAttribute: (_name: string, value: string | boolean) => {
attributeValue = String(value);
},
},
};

const fn = new Function('localStorage', 'document', script);
mockLocalStorage.setItem(
'docusaurus.announcement.id',
'x</script><script>alert(1)</script>',
);
mockLocalStorage.setItem('docusaurus.announcement.dismiss', 'true');
fn(mockLocalStorage, mockDocument);
expect(attributeValue).toBe('true');
});
});
});
2 changes: 1 addition & 1 deletion packages/docusaurus-theme-classic/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export default function themeClassic(
innerHTML: `
${getThemeInlineScript({colorMode, siteStorage})}
${DataAttributeQueryStringInlineJavaScript}
${announcementBar ? getAnnouncementBarInlineScript({siteStorage}) : ''}
${announcementBar ? getAnnouncementBarInlineScript({siteStorage, announcementBar}) : ''}
`,
},
],
Expand Down
10 changes: 10 additions & 0 deletions packages/docusaurus-theme-classic/src/inlineScripts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,21 +76,31 @@ 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 AnnouncementBarDismissStorageKey = `docusaurus.announcement.dismiss${siteStorage.namespace}`;
const AnnouncementBarIdStorageKey = `docusaurus.announcement.id${siteStorage.namespace}`;
const AnnouncementBarDismissDataAttribute =
'data-announcement-bar-initially-dismissed';
const id = announcementBar?.id ?? 'announcement-bar';
// Escape '<' to prevent breaking out of inline <script> tags
// See https://redux.js.org/recipes/server-rendering/#security-considerations
const serializedId = JSON.stringify(id).replace(/</g, '\\u003c');

/* language=js */
return `(function() {
function isDismissed() {
try {
if (localStorage.getItem('${AnnouncementBarIdStorageKey}') !== ${serializedId}) {
return false;
}
return localStorage.getItem('${AnnouncementBarDismissStorageKey}') === 'true';
} catch (err) {}
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ const AnnouncementBarDismissStorage = createStorageSlot(
);
const IdStorage = createStorageSlot('docusaurus.announcement.id');

const isDismissedInStorage = () =>
const isDismissedInStorage = (id?: string) =>
(!id || IdStorage.get() === id) &&
AnnouncementBarDismissStorage.get() === 'true';
const setDismissedInStorage = (bool: boolean) =>
AnnouncementBarDismissStorage.set(String(bool));
Expand All @@ -48,19 +49,22 @@ function useContextValue(): ContextValue {
const [isClosed, setClosed] = useState(() =>
isBrowser
? // On client navigation: init with local storage value
isDismissedInStorage()
isDismissedInStorage(announcementBar?.id)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
: // On server/hydration: always visible to prevent layout shifts (will be hidden with css if needed)
false,
);
// Update state after hydration
useEffect(() => {
setClosed(isDismissedInStorage());
}, []);
setClosed(isDismissedInStorage(announcementBar?.id));
}, [announcementBar?.id]);

const handleClose = useCallback(() => {
if (announcementBar) {
IdStorage.set(announcementBar.id);
}
setDismissedInStorage(true);
setClosed(true);
}, []);
}, [announcementBar]);

useEffect(() => {
if (!announcementBar) {
Expand All @@ -77,7 +81,7 @@ function useContextValue(): ContextValue {
setDismissedInStorage(false);
}

if (isNewAnnouncement || !isDismissedInStorage()) {
if (isNewAnnouncement || !isDismissedInStorage(id)) {
setClosed(false);
}
}, [announcementBar]);
Expand Down