diff --git a/src/app/components/jsx-helpers/raw-html.tsx b/src/app/components/jsx-helpers/raw-html.tsx index d07df019d..82eb743c7 100644 --- a/src/app/components/jsx-helpers/raw-html.tsx +++ b/src/app/components/jsx-helpers/raw-html.tsx @@ -1,5 +1,6 @@ import React from 'react'; import usePortalContext from '~/contexts/portal'; +import resolvePageLinks from '~/helpers/resolve-page-links'; // Making scripts work, per https://stackoverflow.com/a/47614491/392102 function activateScripts(el: HTMLElement) { @@ -55,7 +56,17 @@ export default function RawHTML({ activateScripts(ref.current); } }); - React.useLayoutEffect(() => rewriteLinks?.(ref.current as HTMLElement), [rewriteLinks]); + React.useLayoutEffect(() => { + if (!ref.current) { + return; + } + + rewriteLinks?.(ref.current); + + // Resolve internal page links + resolvePageLinks(ref.current).catch(() => { + }); + }, [rewriteLinks, html]); return React.createElement(Tag, { ref, diff --git a/src/app/helpers/resolve-page-links.ts b/src/app/helpers/resolve-page-links.ts new file mode 100644 index 000000000..a75d6d574 --- /dev/null +++ b/src/app/helpers/resolve-page-links.ts @@ -0,0 +1,76 @@ +import cmsFetch from '~/helpers/cms-fetch'; + +// Cache for resolved page URLs to avoid repeated API calls +const urlCache = new Map(); + +interface PageApiResponse { + html_url?: string; + meta?: { + html_url?: string; + }; +} + +async function getResolvedUrl(pageId: string): Promise { + const cachedUrl = urlCache.get(pageId); + + if (cachedUrl) { + return cachedUrl; + } + + const response = (await cmsFetch(`pages/${pageId}/`)) as PageApiResponse; + const resolvedUrl = response.html_url ?? response.meta?.html_url; + + if (!resolvedUrl) { + console.warn(`Page ${pageId} has no html_url in API response`); + return undefined; + } + + urlCache.set(pageId, resolvedUrl); + return resolvedUrl; +} + +async function resolvePageLink(link: HTMLAnchorElement): Promise { + const pageId = link.getAttribute('id'); + + if (!pageId) { + return; + } + + try { + const resolvedUrl = await getResolvedUrl(pageId); + + if (!resolvedUrl || link.getAttribute('href')) { + return; + } + + link.setAttribute('href', resolvedUrl); + } catch (err) { + // Log error but don't break the page + console.error(`Failed to resolve page link for id ${pageId}:`, err); + } +} + +/** + * Resolves internal page links that have linktype="page" and id attributes + * but are missing href attributes. Fetches the page metadata from CMS API + * and populates the href with the resolved URL. + * + * @param element - The container element to search for page links + */ +export default async function resolvePageLinks(element: HTMLElement | null | undefined): Promise { + if (!element) { + return; + } + + // Find all anchor tags with linktype="page" and an id attribute + const pageLinks = element.querySelectorAll('a[linktype="page"][id]'); + + if (pageLinks.length === 0) { + return; + } + + // Process all links in parallel + const promises = Array.from(pageLinks).map((link) => resolvePageLink(link)); + + await Promise.all(promises); +} diff --git a/test/src/components/jsx-helpers/raw-html.test.tsx b/test/src/components/jsx-helpers/raw-html.test.tsx new file mode 100644 index 000000000..6000be407 --- /dev/null +++ b/test/src/components/jsx-helpers/raw-html.test.tsx @@ -0,0 +1,132 @@ +import React from 'react'; +import {render, waitFor} from '@testing-library/preact'; +import {describe, it, expect, jest, beforeEach} from '@jest/globals'; +import RawHTML from '~/components/jsx-helpers/raw-html'; +import * as cmsFetchModule from '~/helpers/cms-fetch'; +import {PortalContextProvider} from '~/contexts/portal'; +import MemoryRouter from '~/../../test/helpers/future-memory-router'; + +// Mock the cmsFetch module +jest.mock('~/helpers/cms-fetch'); + +const mockCmsFetch = cmsFetchModule.default as jest.MockedFunction; + +function Component({html}: {html: string}) { + return ( + + + + + + ); +} + +describe('RawHTML with page links', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders normal HTML without page links', () => { + const html = '

Normal content with regular link

'; + const {container} = render(); + + expect(container.querySelector('a')?.getAttribute('href')).toBe('/test'); + expect(mockCmsFetch).not.toHaveBeenCalled(); + }); + + it('resolves internal page links', async () => { + const html = '

Check out this page for more info

'; + + mockCmsFetch.mockResolvedValue({ + meta: { + html_url: 'https://openstax.org/resolved-page' + } + }); + + const {container} = render(); + + // Wait for the async resolution to complete + await waitFor(() => { + const link = container.querySelector('a[linktype="page"]') as HTMLAnchorElement; + + expect(link.getAttribute('href')).toBe('https://openstax.org/resolved-page'); + }); + + expect(mockCmsFetch).toHaveBeenCalledWith('pages/560/'); + }); + + it('resolves multiple page links', async () => { + const html = ` +
+

First

+

Second

+
+ `; + + mockCmsFetch + .mockResolvedValueOnce({meta: {html_url: 'https://openstax.org/page-100'}}) + .mockResolvedValueOnce({meta: {html_url: 'https://openstax.org/page-200'}}); + + const {container} = render(); + + await waitFor(() => { + const links = container.querySelectorAll('a[linktype="page"]'); + + expect((links[0] as HTMLAnchorElement).getAttribute('href')).toBe('https://openstax.org/page-100'); + expect((links[1] as HTMLAnchorElement).getAttribute('href')).toBe('https://openstax.org/page-200'); + }); + + expect(mockCmsFetch).toHaveBeenCalledTimes(2); + }); + + it('handles API errors gracefully', async () => { + const html = '

broken link

'; + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + mockCmsFetch.mockRejectedValue(new Error('API error')); + + const {container} = render(); + + // Wait a bit for async operations to attempt + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalled(); + }); + + // Link should not have href attribute + const link = container.querySelector('a[linktype="page"]') as HTMLAnchorElement; + + expect(link.hasAttribute('href')).toBe(false); + + consoleErrorSpy.mockRestore(); + }); + + it('re-resolves links when html prop changes', async () => { + const html1 = '

First page

'; + const html2 = '

Second page

'; + + mockCmsFetch + .mockResolvedValueOnce({meta: {html_url: 'https://openstax.org/page-990'}}) + .mockResolvedValueOnce({meta: {html_url: 'https://openstax.org/page-991'}}); + + const {container, rerender} = render(); + + await waitFor(() => { + const link = container.querySelector('a[linktype="page"]') as HTMLAnchorElement; + + expect(link.getAttribute('href')).toBe('https://openstax.org/page-990'); + }); + + // Update the HTML prop + rerender(); + + await waitFor(() => { + const link = container.querySelector('a[linktype="page"]') as HTMLAnchorElement; + + expect(link.getAttribute('href')).toBe('https://openstax.org/page-991'); + }); + + expect(mockCmsFetch).toHaveBeenCalledWith('pages/990/'); + expect(mockCmsFetch).toHaveBeenCalledWith('pages/991/'); + }); +}); diff --git a/test/src/helpers/resolve-page-links.test.ts b/test/src/helpers/resolve-page-links.test.ts new file mode 100644 index 000000000..90d91cde5 --- /dev/null +++ b/test/src/helpers/resolve-page-links.test.ts @@ -0,0 +1,162 @@ +import {describe, it, expect, jest, beforeEach} from '@jest/globals'; +import resolvePageLinks from '~/helpers/resolve-page-links'; +import * as cmsFetchModule from '~/helpers/cms-fetch'; + +// Mock the cmsFetch module +jest.mock('~/helpers/cms-fetch'); + +const mockCmsFetch = cmsFetchModule.default as jest.MockedFunction; + +describe('resolvePageLinks', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns early if element is null', async () => { + await resolvePageLinks(null); + expect(mockCmsFetch).not.toHaveBeenCalled(); + }); + it('does nothing if there are no page links', async () => { + const element = document.createElement('div'); + + element.innerHTML = '

Some text normal link

'; + + await resolvePageLinks(element); + + expect(mockCmsFetch).not.toHaveBeenCalled(); + }); + + it('resolves a single page link', async () => { + const element = document.createElement('div'); + + element.innerHTML = '

Check out this page for more info

'; + + mockCmsFetch.mockResolvedValue({ + meta: { + html_url: 'https://openstax.org/some-page' + } + }); + + await resolvePageLinks(element); + + const link = element.querySelector('a[linktype="page"]') as HTMLAnchorElement; + + expect(mockCmsFetch).toHaveBeenCalledWith('pages/560/'); + expect(link.getAttribute('href')).toBe('https://openstax.org/some-page'); + }); + + it('resolves multiple page links', async () => { + const element = document.createElement('div'); + + element.innerHTML = ` +

Check out this page and that page

+ `; + + mockCmsFetch + .mockResolvedValueOnce({meta: {html_url: 'https://openstax.org/page-660'}}) + .mockResolvedValueOnce({meta: {html_url: 'https://openstax.org/page-661'}}); + + await resolvePageLinks(element); + + const links = element.querySelectorAll('a[linktype="page"]'); + + expect(mockCmsFetch).toHaveBeenCalledWith('pages/660/'); + expect(mockCmsFetch).toHaveBeenCalledWith('pages/661/'); + expect((links[0] as HTMLAnchorElement).getAttribute('href')).toBe('https://openstax.org/page-660'); + expect((links[1] as HTMLAnchorElement).getAttribute('href')).toBe('https://openstax.org/page-661'); + }); + + it('uses cache for repeated page IDs', async () => { + const element = document.createElement('div'); + + element.innerHTML = ` +

first link

+ `; + + mockCmsFetch.mockResolvedValue({ + meta: { + html_url: 'https://openstax.org/cached-page' + } + }); + + // First call + await resolvePageLinks(element); + + const firstCallCount = mockCmsFetch.mock.calls.length; + + expect(firstCallCount).toBeGreaterThanOrEqual(1); + expect((element.querySelector('a') as HTMLAnchorElement).getAttribute('href')) + .toBe('https://openstax.org/cached-page'); + + // Second call with same page ID + const element2 = document.createElement('div'); + + element2.innerHTML = '

second link

'; + + await resolvePageLinks(element2); + + // Should still have the same call count (using cache) + expect(mockCmsFetch.mock.calls.length).toBe(firstCallCount); + expect((element2.querySelector('a') as HTMLAnchorElement).getAttribute('href')) + .toBe('https://openstax.org/cached-page'); + }); + + it('handles API errors gracefully', async () => { + const element = document.createElement('div'); + + element.innerHTML = '

broken link

'; + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + mockCmsFetch.mockRejectedValueOnce(new Error('API error')); + + await resolvePageLinks(element); + + const link = element.querySelector('a[linktype="page"]') as HTMLAnchorElement; + + // Link should not have href attribute set (or it might be empty) + const href = link.getAttribute('href'); + + expect(href === null || href === '').toBe(true); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to resolve page link for id 880:', + expect.any(Error) + ); + + consoleErrorSpy.mockRestore(); + }); + + it('handles missing html_url in API response', async () => { + const element = document.createElement('div'); + + element.innerHTML = '

incomplete data

'; + + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + mockCmsFetch.mockResolvedValue({ + meta: { + // Missing html_url + } + }); + + await resolvePageLinks(element); + + const link = element.querySelector('a[linktype="page"]') as HTMLAnchorElement; + + // Link should not have href set + expect(link.hasAttribute('href')).toBe(false); + expect(consoleWarnSpy).toHaveBeenCalledWith('Page 888 has no html_url in API response'); + + consoleWarnSpy.mockRestore(); + }); + + it('ignores links without id attribute', async () => { + const element = document.createElement('div'); + + element.innerHTML = '

no id link

'; + + await resolvePageLinks(element); + + expect(mockCmsFetch).not.toHaveBeenCalled(); + }); +});