diff --git a/_routes.json b/_routes.json index 92fa286dfe8..d8dfb830c38 100644 --- a/_routes.json +++ b/_routes.json @@ -1,5 +1,5 @@ { "version": 1, - "include": ["/grapher/*", "/deleted/*", "/donation/*"], + "include": ["/grapher/*", "/deleted/*", "/donation/*", "/explorers/*"], "exclude": ["/grapher/_grapherRedirects.json"] } diff --git a/functions/_common/grapherRenderer.ts b/functions/_common/grapherRenderer.ts index 72d65b14410..42d2298770b 100644 --- a/functions/_common/grapherRenderer.ts +++ b/functions/_common/grapherRenderer.ts @@ -111,7 +111,7 @@ export const fetchAndRenderGrapher = async ( let initialized = false -async function renderSvgToPng(svg: string, options: ImageOptions) { +export async function renderSvgToPng(svg: string, options: ImageOptions) { if (!initialized) { await initializeSvg2Png(svg2png_wasm) initialized = true diff --git a/functions/_common/grapherTools.ts b/functions/_common/grapherTools.ts index f5bf29d5e38..de6bd1ed0db 100644 --- a/functions/_common/grapherTools.ts +++ b/functions/_common/grapherTools.ts @@ -142,3 +142,43 @@ export async function initGrapher( return grapher } + +/** + * Update og:url, og:image, and twitter:image meta tags to include the search parameters. + */ +export function rewriteMetaTags( + url: URL, + openGraphThumbnailUrl: string, + twitterThumbnailUrl: string, + page: Response +) { + // Take the origin (e.g. https://ourworldindata.org) from the canonical URL, which should appear before the image elements. + // If we fail to capture the origin, we end up with relative image URLs, which should also be okay. + let origin = "" + + const rewriter = new HTMLRewriter() + .on('meta[property="og:url"]', { + // Replace canonical URL, otherwise the preview image will not include the search parameters. + element: (element) => { + const canonicalUrl = element.getAttribute("content") + element.setAttribute("content", canonicalUrl + url.search) + try { + origin = new URL(canonicalUrl).origin + } catch (e) { + console.error("Error parsing canonical URL", e) + } + }, + }) + .on('meta[property="og:image"]', { + element: (element) => { + element.setAttribute("content", origin + openGraphThumbnailUrl) + }, + }) + .on('meta[name="twitter:image"]', { + element: (element) => { + element.setAttribute("content", origin + twitterThumbnailUrl) + }, + }) + + return rewriter.transform(page as unknown as Response) +} diff --git a/functions/explorers/[slug].ts b/functions/explorers/[slug].ts new file mode 100644 index 00000000000..71c0eca50d2 --- /dev/null +++ b/functions/explorers/[slug].ts @@ -0,0 +1,150 @@ +import { Env, Etag, extensions } from "../_common/env.js" +import { extractOptions } from "../_common/imageOptions.js" +import { buildExplorerProps, Explorer } from "@ourworldindata/explorer" +import { handlePageNotFound } from "../_common/redirectTools.js" +import { renderSvgToPng } from "../_common/grapherRenderer.js" +import { IRequestStrict, Router, error, cors, png } from "itty-router" +import { Bounds, Url } from "@ourworldindata/utils" +import { + getSelectedEntityNamesParam, + migrateSelectedEntityNamesParam, + SelectionArray, +} from "@ourworldindata/grapher" +import { rewriteMetaTags } from "../_common/grapherTools.js" + +const { preflight, corsify } = cors({ + allowMethods: ["GET", "OPTIONS", "HEAD"], +}) + +const router = Router< + IRequestStrict, + [URL, Env, Etag, EventContext>] +>({ + before: [preflight], + finally: [corsify], +}) +router + .get( + `/explorers/:slug${extensions.svg}`, + async (_, { searchParams }, env) => { + console.log("Handling explorer SVG thumbnail request") + return handleThumbnailRequest(searchParams, env, "svg") + } + ) + .get( + `/explorers/:slug${extensions.png}`, + async (_, { searchParams }, env) => { + console.log("Handling explorer PNG thumbnail request") + return handleThumbnailRequest(searchParams, env, "png") + } + ) + .get( + "/explorers/:slug", + async ({ params: { slug } }, { searchParams }, env) => { + console.log("Handling explorer HTML page request") + return handleHtmlPageRequest(slug, searchParams, env) + } + ) + .all("*", () => error(404, "Route not defined")) + +async function handleThumbnailRequest( + searchParams: URLSearchParams, + env: Env, + extension: "png" | "svg" +) { + const options = extractOptions(searchParams) + const url = env.url + url.href = url.href.replace(`.${extension}`, "") + const explorerPage = await env.ASSETS.fetch(url, { + redirect: "manual", + }) + + try { + const html = await explorerPage.text() + const queryStr = url.searchParams.toString() + const urlObj = Url.fromURL(url.toString()) + const [windowEntityNames] = [urlObj] + .map(migrateSelectedEntityNamesParam) + .map(getSelectedEntityNamesParam) + + const selection = new SelectionArray(windowEntityNames) + const bounds = new Bounds(0, 0, options.svgWidth, options.svgHeight) + const explorerProps = await buildExplorerProps( + html, + queryStr, + selection, + bounds + ) + const explorer = new Explorer(explorerProps) + explorer.updateGrapherFromExplorer() + while (!explorer.grapher.isReady) { + await new Promise((resolve) => setTimeout(resolve, 100)) + } + explorer.grapher.populateFromQueryParams(urlObj.queryParams) + const svg = explorer.grapher.generateStaticSvg() + if (extension === "svg") { + return new Response(svg, { + headers: { + "Content-Type": "image/svg+xml", + "Cache-Control": "public, max-age=600", + }, + }) + } else { + return png(await renderSvgToPng(svg, options)) + } + } catch (e) { + console.error(e) + return error(500, e) + } +} + +async function handleHtmlPageRequest( + slug: string, + _searchParams: URLSearchParams, + env: Env +) { + const url = env.url + + const explorerPage = await env.ASSETS.fetch(url, { + redirect: "manual", + }) + + if (explorerPage.status === 404) { + return handlePageNotFound(env, explorerPage) + } + + const openGraphThumbnailUrl = `/explorers/${slug}.png?imType=og${ + url.search ? "&" + url.search.slice(1) : "" + }` + const twitterThumbnailUrl = `/explorers/${slug}.png?imType=twitter${ + url.search ? "&" + url.search.slice(1) : "" + }` + + const explorerPageWithUpdatedMetaTags = rewriteMetaTags( + url, + openGraphThumbnailUrl, + twitterThumbnailUrl, + explorerPage + ) + + return explorerPageWithUpdatedMetaTags +} + +export const onRequest: PagesFunction = async (context) => { + context.passThroughOnException() + const { request, env } = context + const url = new URL(request.url) + + return router + .fetch( + request, + url, + { ...env, url }, + request.headers.get("if-none-match"), + context + ) + .catch(async (e) => { + console.log("Handling 404 for", url.pathname) + return error(500, e) + }) +} diff --git a/functions/grapher/[slug].ts b/functions/grapher/[slug].ts index 60c2772b1d0..0c901014f23 100644 --- a/functions/grapher/[slug].ts +++ b/functions/grapher/[slug].ts @@ -10,7 +10,10 @@ import { handlePageNotFound, getRedirectForUrl, } from "../_common/redirectTools.js" -import { fetchUnparsedGrapherConfig } from "../_common/grapherTools.js" +import { + fetchUnparsedGrapherConfig, + rewriteMetaTags, +} from "../_common/grapherTools.js" import { IRequestStrict, Router, StatusError, error, cors } from "itty-router" const { preflight, corsify } = cors({ @@ -149,36 +152,13 @@ async function handleHtmlPageRequest( url.search ? "&" + url.search.slice(1) : "" }` - // Take the origin (e.g. https://ourworldindata.org) from the canonical URL, which should appear before the image elements. - // If we fail to capture the origin, we end up with relative image URLs, which should also be okay. - let origin = "" - - // Rewrite the two meta tags that are used for a social media preview image. - const rewriter = new HTMLRewriter() - .on('meta[property="og:url"]', { - // Replace canonical URL, otherwise the preview image will not include the search parameters. - element: (element) => { - const canonicalUrl = element.getAttribute("content") - element.setAttribute("content", canonicalUrl + url.search) - try { - origin = new URL(canonicalUrl).origin - } catch (e) { - console.error("Error parsing canonical URL", e) - } - }, - }) - .on('meta[property="og:image"]', { - element: (element) => { - element.setAttribute("content", origin + openGraphThumbnailUrl) - }, - }) - .on('meta[name="twitter:image"]', { - element: (element) => { - element.setAttribute("content", origin + twitterThumbnailUrl) - }, - }) - - return rewriter.transform(grapherPageResp as unknown as Response) + const grapherPageWithUpdatedMetaTags = rewriteMetaTags( + url, + openGraphThumbnailUrl, + twitterThumbnailUrl, + grapherPageResp + ) + return grapherPageWithUpdatedMetaTags } async function handleConfigRequest( diff --git a/packages/@ourworldindata/explorer/src/Explorer.tsx b/packages/@ourworldindata/explorer/src/Explorer.tsx index 7d1b72d14b1..65498e161e7 100644 --- a/packages/@ourworldindata/explorer/src/Explorer.tsx +++ b/packages/@ourworldindata/explorer/src/Explorer.tsx @@ -90,6 +90,8 @@ export interface ExplorerProps extends SerializedGridProgram { bakedBaseUrl: string bakedGrapherUrl: string dataApiUrl: string + bounds?: Bounds + staticBounds?: Bounds } const LivePreviewComponent = (props: ExplorerProps) => { @@ -178,7 +180,10 @@ const renderLivePreviewVersion = (props: ExplorerProps) => { } const isNarrow = () => - window.screen.width < 450 || document.documentElement.clientWidth <= 800 + typeof window === "undefined" + ? false + : window.screen.width < 450 || + document.documentElement.clientWidth <= 800 @observer export class Explorer @@ -190,6 +195,16 @@ export class Explorer { analytics = new GrapherAnalytics() + constructor(props: ExplorerProps) { + super(props) + this.explorerProgram = ExplorerProgram.fromJson( + props + ).initDecisionMatrix(this.initialQueryParams) + this.grapher = new Grapher({ + bounds: props.bounds, + staticBounds: props.staticBounds, + }) + } // caution: do a ctrl+f to find untyped usages static renderSingleExplorerOnExplorerPage( program: ExplorerProps, @@ -465,7 +480,7 @@ export class Explorer this.explorerProgram.constructTable(slug) ) - @action.bound private updateGrapherFromExplorer() { + @action.bound updateGrapherFromExplorer() { switch (this.explorerProgram.chartCreationMode) { case ExplorerChartCreationMode.FromGrapherId: this.updateGrapherFromExplorerUsingGrapherId() @@ -525,7 +540,7 @@ export class Explorer ) } - @action.bound private updateGrapherFromExplorerUsingGrapherId() { + @action.bound updateGrapherFromExplorerUsingGrapherId() { const grapher = this.grapher if (!grapher) return @@ -554,7 +569,7 @@ export class Explorer grapher.downloadData() } - @action.bound private async updateGrapherFromExplorerUsingVariableIds() { + @action.bound async updateGrapherFromExplorerUsingVariableIds() { const grapher = this.grapher if (!grapher) return const { @@ -725,7 +740,7 @@ export class Explorer } } - @action.bound private updateGrapherFromExplorerUsingColumnSlugs() { + @action.bound updateGrapherFromExplorerUsingColumnSlugs() { const grapher = this.grapher if (!grapher) return const { tableSlug } = this.explorerProgram.explorerGrapherConfig diff --git a/packages/@ourworldindata/explorer/src/ExplorerConstants.ts b/packages/@ourworldindata/explorer/src/ExplorerConstants.ts index 9abb472f8bc..5f516f3b62a 100644 --- a/packages/@ourworldindata/explorer/src/ExplorerConstants.ts +++ b/packages/@ourworldindata/explorer/src/ExplorerConstants.ts @@ -60,6 +60,7 @@ export const UNSAVED_EXPLORER_PREVIEW_QUERYPARAMS = "UNSAVED_EXPLORER_PREVIEW_QUERYPARAMS" export const EMBEDDED_EXPLORER_DELIMITER = "\n//EMBEDDED_EXPLORER\n" +export const EXPLORER_CONSTANTS_DELIMITER = "\n//EXPLORER_CONSTANTS\n" export const EMBEDDED_EXPLORER_GRAPHER_CONFIGS = "\n//EMBEDDED_EXPLORER_GRAPHER_CONFIGS\n" export const EMBEDDED_EXPLORER_PARTIAL_GRAPHER_CONFIGS = diff --git a/packages/@ourworldindata/explorer/src/ExplorerUtils.ts b/packages/@ourworldindata/explorer/src/ExplorerUtils.ts new file mode 100644 index 00000000000..b6e537a49ef --- /dev/null +++ b/packages/@ourworldindata/explorer/src/ExplorerUtils.ts @@ -0,0 +1,58 @@ +import { SelectionArray } from "@ourworldindata/grapher" +import { Bounds, deserializeJSONFromHTML, isArray } from "@ourworldindata/utils" +import { + EMBEDDED_EXPLORER_DELIMITER, + EMBEDDED_EXPLORER_GRAPHER_CONFIGS, + EMBEDDED_EXPLORER_PARTIAL_GRAPHER_CONFIGS, + EXPLORER_CONSTANTS_DELIMITER, +} from "./ExplorerConstants.js" +import { ExplorerProps } from "./Explorer.js" + +export async function buildExplorerProps( + html: string, + queryStr: string, + selection: SelectionArray, + bounds?: Bounds +) { + const explorerConstants = deserializeJSONFromHTML( + html, + EXPLORER_CONSTANTS_DELIMITER + ) + let grapherConfigs = deserializeJSONFromHTML( + html, + EMBEDDED_EXPLORER_GRAPHER_CONFIGS + ) + let partialGrapherConfigs = deserializeJSONFromHTML( + html, + EMBEDDED_EXPLORER_PARTIAL_GRAPHER_CONFIGS + ) + if (isArray(grapherConfigs)) { + grapherConfigs = grapherConfigs.map((grapherConfig) => ({ + ...grapherConfig, + adminBaseUrl: explorerConstants.adminBaseUrl, + bakedGrapherURL: explorerConstants.bakedGrapherUrl, + })) + } + if (isArray(partialGrapherConfigs)) { + partialGrapherConfigs = partialGrapherConfigs.map((grapherConfig) => ({ + ...grapherConfig, + adminBaseUrl: explorerConstants.adminBaseUrl, + bakedGrapherURL: explorerConstants.bakedGrapherUrl, + })) + } + const props: ExplorerProps = { + ...deserializeJSONFromHTML(html, EMBEDDED_EXPLORER_DELIMITER), + isEmbeddedInAnOwidPage: true, + adminBaseUrl: explorerConstants.adminBaseUrl, + bakedBaseUrl: explorerConstants.bakedBaseUrl, + bakedGrapherUrl: explorerConstants.bakedGrapherUrl, + dataApiUrl: explorerConstants.dataApiUrl, + grapherConfigs, + partialGrapherConfigs, + queryStr, + selection: new SelectionArray(selection.selectedEntityNames), + bounds: bounds, + staticBounds: bounds, + } + return props +} diff --git a/packages/@ourworldindata/explorer/src/index.ts b/packages/@ourworldindata/explorer/src/index.ts index 534ab0e9c0f..70eb37f3a43 100644 --- a/packages/@ourworldindata/explorer/src/index.ts +++ b/packages/@ourworldindata/explorer/src/index.ts @@ -1,11 +1,15 @@ export { Explorer, type ExplorerProps } from "./Explorer.js" +export { buildExplorerProps } from "./ExplorerUtils.js" + export { DefaultNewExplorerSlug, EMBEDDED_EXPLORER_DELIMITER, EMBEDDED_EXPLORER_GRAPHER_CONFIGS, EMBEDDED_EXPLORER_PARTIAL_GRAPHER_CONFIGS, + EXPLORER_CONSTANTS_DELIMITER, EXPLORER_EMBEDDED_FIGURE_SELECTOR, + ExplorerChartCreationMode, ExplorerContainerId, ExplorerControlTypeRegex, EXPLORERS_GIT_CMS_FOLDER, @@ -16,7 +20,6 @@ export { type ChoiceMap, type ChoiceName, type ChoiceValue, - type ExplorerChartCreationMode, type ExplorerChoice, type ExplorerChoiceOption, type ExplorerChoiceParams, diff --git a/packages/@ourworldindata/explorer/tsconfig.test.json b/packages/@ourworldindata/explorer/tsconfig.test.json new file mode 100644 index 00000000000..713c2db89a8 --- /dev/null +++ b/packages/@ourworldindata/explorer/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "include": ["src/**/*"], + "exclude": ["**/*.stories.tsx"], + "extends": "../../../devTools/tsconfigs/tsconfig.base.json", + "compilerOptions": { + "composite": false, + "outDir": "./dist/tests" + } +} diff --git a/site/ExplorerPage.tsx b/site/ExplorerPage.tsx index f347f2a808c..2ee69f960ca 100644 --- a/site/ExplorerPage.tsx +++ b/site/ExplorerPage.tsx @@ -18,6 +18,7 @@ import { EXPLORERS_ROUTE_FOLDER, ExplorerProgram, ExplorerPageUrlMigrationSpec, + EXPLORER_CONSTANTS_DELIMITER, } from "@ourworldindata/explorer" import { Head } from "../site/Head.js" import { IFrameDetector } from "../site/IframeDetector.js" @@ -111,12 +112,15 @@ const partialGrapherConfigs = ${serializeJSONForHTML( const urlMigrationSpec = ${ urlMigrationSpec ? JSON.stringify(urlMigrationSpec) : "undefined" }; -const explorerConstants = ${JSON.stringify({ - adminBaseUrl: ADMIN_BASE_URL, - bakedBaseUrl: BAKED_BASE_URL, - bakedGrapherUrl: BAKED_GRAPHER_URL, - dataApiUrl: DATA_API_URL, - })} +const explorerConstants = ${serializeJSONForHTML( + { + adminBaseUrl: ADMIN_BASE_URL, + bakedBaseUrl: BAKED_BASE_URL, + bakedGrapherUrl: BAKED_GRAPHER_URL, + dataApiUrl: DATA_API_URL, + }, + EXPLORER_CONSTANTS_DELIMITER + )} window.Explorer.renderSingleExplorerOnExplorerPage(explorerProgram, grapherConfigs, partialGrapherConfigs, explorerConstants, urlMigrationSpec);` return ( diff --git a/site/multiembedder/MultiEmbedder.tsx b/site/multiembedder/MultiEmbedder.tsx index c9088a5a85c..385bc710a00 100644 --- a/site/multiembedder/MultiEmbedder.tsx +++ b/site/multiembedder/MultiEmbedder.tsx @@ -12,10 +12,8 @@ import { } from "@ourworldindata/grapher" import { Annotation, - deserializeJSONFromHTML, fetchText, getWindowUrl, - isArray, isPresent, Url, GrapherTabOption, @@ -27,15 +25,12 @@ import ReactDOM from "react-dom" import { Explorer, ExplorerProps, - EMBEDDED_EXPLORER_DELIMITER, - EMBEDDED_EXPLORER_GRAPHER_CONFIGS, - EMBEDDED_EXPLORER_PARTIAL_GRAPHER_CONFIGS, EXPLORER_EMBEDDED_FIGURE_SELECTOR, + buildExplorerProps, } from "@ourworldindata/explorer" import { GRAPHER_PREVIEW_CLASS } from "../SiteConstants.js" import { ADMIN_BASE_URL, - BAKED_BASE_URL, BAKED_GRAPHER_URL, DATA_API_URL, GRAPHER_DYNAMIC_CONFIG_URL, @@ -164,44 +159,11 @@ class MultiEmbedder { if (isExplorer) { const html = await fetchText(fullUrl) - let grapherConfigs = deserializeJSONFromHTML( + const props: ExplorerProps = await buildExplorerProps( html, - EMBEDDED_EXPLORER_GRAPHER_CONFIGS - ) - let partialGrapherConfigs = deserializeJSONFromHTML( - html, - EMBEDDED_EXPLORER_PARTIAL_GRAPHER_CONFIGS - ) - if (isArray(grapherConfigs)) { - grapherConfigs = grapherConfigs.map((grapherConfig) => ({ - ...grapherConfig, - adminBaseUrl: ADMIN_BASE_URL, - bakedGrapherURL: BAKED_GRAPHER_URL, - })) - } - if (isArray(partialGrapherConfigs)) { - partialGrapherConfigs = partialGrapherConfigs.map( - (grapherConfig) => ({ - ...grapherConfig, - adminBaseUrl: ADMIN_BASE_URL, - bakedGrapherURL: BAKED_GRAPHER_URL, - }) - ) - } - const props: ExplorerProps = { - ...common, - ...deserializeJSONFromHTML(html, EMBEDDED_EXPLORER_DELIMITER), - adminBaseUrl: ADMIN_BASE_URL, - bakedBaseUrl: BAKED_BASE_URL, - bakedGrapherUrl: BAKED_GRAPHER_URL, - dataApiUrl: DATA_API_URL, - grapherConfigs, - partialGrapherConfigs, queryStr, - selection: new SelectionArray( - this.selection.selectedEntityNames - ), - } + this.selection + ) if (props.selection) this.graphersAndExplorersToUpdate.add(props.selection) ReactDOM.render(, figure)