diff --git a/dotcom-rendering/index.d.ts b/dotcom-rendering/index.d.ts
index 4a609e3acfb..6646109f693 100644
--- a/dotcom-rendering/index.d.ts
+++ b/dotcom-rendering/index.d.ts
@@ -2,6 +2,9 @@
// 3rd party type declarations //
// ------------------------------
+type GuardianCrossword =
+ import('@guardian/react-crossword-next').CrosswordProps['data'];
+
declare module 'chromatic/isChromatic';
declare module 'dynamic-import-polyfill' {
diff --git a/dotcom-rendering/package.json b/dotcom-rendering/package.json
index f23dd22b3a4..52e6d61ec31 100644
--- a/dotcom-rendering/package.json
+++ b/dotcom-rendering/package.json
@@ -49,7 +49,7 @@
"@guardian/identity-auth-frontend": "4.0.0",
"@guardian/libs": "19.2.1",
"@guardian/ophan-tracker-js": "2.2.5",
- "@guardian/react-crossword-next": "npm:@guardian/react-crossword@0.0.0-canary-20241128162754",
+ "@guardian/react-crossword-next": "npm:@guardian/react-crossword@0.0.0-canary-20241209150926",
"@guardian/shimport": "1.0.2",
"@guardian/source": "8.0.0",
"@guardian/source-development-kitchen": "12.0.0",
diff --git a/dotcom-rendering/src/components/ArticleContainer.tsx b/dotcom-rendering/src/components/ArticleContainer.tsx
index 94e02c55792..5a1e46222e4 100644
--- a/dotcom-rendering/src/components/ArticleContainer.tsx
+++ b/dotcom-rendering/src/components/ArticleContainer.tsx
@@ -24,6 +24,9 @@ const articleWidth = (format: ArticleFormat) => {
}
`;
}
+ case ArticleDesign.Crossword:
+ /* The crossword player manages its own width; */
+ return null;
case ArticleDesign.Video:
case ArticleDesign.Audio:
return css`
diff --git a/dotcom-rendering/src/components/BylineLink.tsx b/dotcom-rendering/src/components/BylineLink.tsx
index 3c9c4beca07..e7c1eb745d0 100644
--- a/dotcom-rendering/src/components/BylineLink.tsx
+++ b/dotcom-rendering/src/components/BylineLink.tsx
@@ -1,6 +1,7 @@
import { isString } from '@guardian/libs';
import { Hide } from '@guardian/source/react-components';
import { DottedLines } from '@guardian/source-development-kitchen/react-components';
+import { Fragment } from 'react';
import { ArticleDesign, type ArticleFormat } from '../lib/articleFormat';
import {
getBylineComponentsFromTokens,
@@ -160,6 +161,10 @@ const getRenderedTokens = (
);
}
+ if (design === ArticleDesign.Crossword && renderedTokens.length > 0) {
+ return [Set by: , ...renderedTokens];
+ }
+
return renderedTokens;
};
diff --git a/dotcom-rendering/src/components/Crossword.importable.tsx b/dotcom-rendering/src/components/Crossword.importable.tsx
new file mode 100644
index 00000000000..87ad0d53796
--- /dev/null
+++ b/dotcom-rendering/src/components/Crossword.importable.tsx
@@ -0,0 +1,6 @@
+import { Crossword as ReactCrossword } from '@guardian/react-crossword-next';
+import type { CrosswordProps } from '@guardian/react-crossword-next';
+
+export const Crossword = ({ data }: CrosswordProps) => (
+
+);
diff --git a/dotcom-rendering/src/components/CrosswordInstructions.tsx b/dotcom-rendering/src/components/CrosswordInstructions.tsx
new file mode 100644
index 00000000000..1d59bdb966b
--- /dev/null
+++ b/dotcom-rendering/src/components/CrosswordInstructions.tsx
@@ -0,0 +1,28 @@
+import { css } from '@emotion/react';
+import {
+ textEgyptian17,
+ textEgyptianBold17,
+} from '@guardian/source/foundations';
+
+const instructionsStyles = css`
+ ${textEgyptian17};
+ white-space: pre-line;
+`;
+
+const headerStyles = css`
+ ${textEgyptianBold17};
+ white-space: pre-line;
+`;
+
+export const CrosswordInstructions = ({
+ instructions,
+ className,
+}: {
+ instructions: string;
+ className?: string;
+}) => (
+
+ Special instructions:
+ {instructions}
+
+);
diff --git a/dotcom-rendering/src/components/CrosswordLinks.tsx b/dotcom-rendering/src/components/CrosswordLinks.tsx
new file mode 100644
index 00000000000..363a70a4113
--- /dev/null
+++ b/dotcom-rendering/src/components/CrosswordLinks.tsx
@@ -0,0 +1,34 @@
+import { css } from '@emotion/react';
+import { isUndefined } from '@guardian/libs';
+import { textSans15 } from '@guardian/source/foundations';
+import { palette } from '../palette';
+
+const crosswordLinkStyles = css`
+ ${textSans15};
+
+ a {
+ color: ${palette('--standfirst-link-text')};
+ text-decoration: none;
+ :hover {
+ border-bottom: 1px solid ${palette('--standfirst-link-border')};
+ }
+ }
+`;
+
+export const CrosswordLinks = ({
+ crossword,
+ className,
+}: {
+ crossword: GuardianCrossword;
+ className?: string;
+}) => {
+ return (
+ isUndefined(crossword.pdf) || (
+
+
+ PDF version
+
+
+ )
+ );
+};
diff --git a/dotcom-rendering/src/layouts/CrosswordLayout.tsx b/dotcom-rendering/src/layouts/CrosswordLayout.tsx
new file mode 100644
index 00000000000..072420a8eb3
--- /dev/null
+++ b/dotcom-rendering/src/layouts/CrosswordLayout.tsx
@@ -0,0 +1,790 @@
+import { css } from '@emotion/react';
+import {
+ from,
+ palette as sourcePalette,
+ until,
+} from '@guardian/source/foundations';
+import { Hide } from '@guardian/source/react-components';
+import { StraightLines } from '@guardian/source-development-kitchen/react-components';
+import React from 'react';
+import { AdPortals } from '../components/AdPortals.importable';
+import { AdSlot, MobileStickyContainer } from '../components/AdSlot.web';
+import { AppsFooter } from '../components/AppsFooter.importable';
+import { ArticleBody } from '../components/ArticleBody';
+import { ArticleContainer } from '../components/ArticleContainer';
+import { ArticleHeadline } from '../components/ArticleHeadline';
+import { ArticleMetaApps } from '../components/ArticleMeta.apps';
+import { ArticleMeta } from '../components/ArticleMeta.web';
+import { ArticleTitle } from '../components/ArticleTitle';
+import { Carousel } from '../components/Carousel.importable';
+import { CrosswordInstructions } from '../components/CrosswordInstructions';
+import { CrosswordLinks } from '../components/CrosswordLinks';
+import { DecideLines } from '../components/DecideLines';
+import { DiscussionLayout } from '../components/DiscussionLayout';
+import { Footer } from '../components/Footer';
+import { GridItem } from '../components/GridItem';
+import { HeaderAdSlot } from '../components/HeaderAdSlot';
+import { Island } from '../components/Island';
+import { Masthead } from '../components/Masthead/Masthead';
+import { MostViewedFooterData } from '../components/MostViewedFooterData.importable';
+import { MostViewedFooterLayout } from '../components/MostViewedFooterLayout';
+import { OnwardsUpper } from '../components/OnwardsUpper.importable';
+import { RightColumn } from '../components/RightColumn';
+import { Section } from '../components/Section';
+import { SlotBodyEnd } from '../components/SlotBodyEnd.importable';
+import { Standfirst } from '../components/Standfirst';
+import { StickyBottomBanner } from '../components/StickyBottomBanner.importable';
+import { SubMeta } from '../components/SubMeta';
+import { SubNav } from '../components/SubNav.importable';
+import { type ArticleFormat, ArticleSpecial } from '../lib/articleFormat';
+import { canRenderAds } from '../lib/canRenderAds';
+import { getContributionsServiceUrl } from '../lib/contributions';
+import { decideTrail } from '../lib/decideTrail';
+import type { NavType } from '../model/extract-nav';
+import { palette as themePalette } from '../palette';
+import type { ArticleDeprecated } from '../types/article';
+import type { RenderingTarget } from '../types/renderingTarget';
+import { BannerWrapper, Stuck } from './lib/stickiness';
+
+const CrosswordGrid = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+);
+
+const maxWidth = css`
+ ${from.desktop} {
+ max-width: 620px;
+ }
+`;
+
+const stretchLines = css`
+ ${until.phablet} {
+ margin-left: -20px;
+ margin-right: -20px;
+ }
+ ${until.mobileLandscape} {
+ margin-left: -10px;
+ margin-right: -10px;
+ }
+`;
+
+interface CommonProps {
+ article: ArticleDeprecated;
+ format: ArticleFormat;
+ renderingTarget: RenderingTarget;
+}
+
+interface WebProps extends CommonProps {
+ NAV: NavType;
+ renderingTarget: 'Web';
+}
+
+interface AppsProps extends CommonProps {
+ renderingTarget: 'Apps';
+}
+
+export const CrosswordLayout = (props: WebProps | AppsProps) => {
+ const { article, format, renderingTarget } = props;
+ const {
+ config: { isPaidContent, host, hasSurveyAd },
+ } = article;
+
+ const isApps = renderingTarget === 'Apps';
+ const isWeb = renderingTarget === 'Web';
+
+ const showComments = article.isCommentable && !isPaidContent;
+
+ const { branding } = article.commercialProperties[article.editionId];
+
+ const contributionsServiceUrl = getContributionsServiceUrl(article);
+
+ const { absoluteServerTimes = false } = article.config.switches;
+
+ /**
+ * This property currently only applies to the header and merchandising slots
+ */
+ const renderAds = isWeb && canRenderAds(article);
+ return (
+ <>
+ {isWeb && (
+ <>
+
+ {renderAds && (
+
+
+
+
+
+ )}
+
+
+
+
+ {renderAds && hasSurveyAd && (
+
+ )}
+ >
+ )}
+
+ {isApps && (
+ <>
+
+
+
+ >
+ )}
+
+
+
+
+
+
+
+
+
+ {article.crossword && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ {isApps ? (
+ <>
+
+
+
+
+
+
+ >
+ ) : (
+
+ )}
+
+
+ {!!article.crossword?.instructions && (
+
+
+
+ )}
+
+
+
+
+
+
+
+ {renderAds ? (
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+ {renderAds && (
+
+ )}
+
+ {article.storyPackage && (
+
+ )}
+
+
+
+
+
+ {showComments && (
+
+ )}
+
+ {!isPaidContent && (
+
+ )}
+
+ {renderAds && (
+
+ )}
+
+
+ {isWeb && props.NAV.subNavSections && (
+
+ )}
+
+ {isWeb && (
+ <>
+
+
+
+
+
+
+
+
+ >
+ )}
+ {isApps && (
+
+ )}
+ >
+ );
+};
diff --git a/dotcom-rendering/src/layouts/DecideLayout.tsx b/dotcom-rendering/src/layouts/DecideLayout.tsx
index b55f4973791..9920cb9d19c 100644
--- a/dotcom-rendering/src/layouts/DecideLayout.tsx
+++ b/dotcom-rendering/src/layouts/DecideLayout.tsx
@@ -8,6 +8,7 @@ import type { ArticleDeprecated } from '../types/article';
import type { RenderingTarget } from '../types/renderingTarget';
import { AudioLayout } from './AudioLayout';
import { CommentLayout } from './CommentLayout';
+import { CrosswordLayout } from './CrosswordLayout';
import { FullPageInteractiveLayout } from './FullPageInteractiveLayout';
import { ImmersiveLayout } from './ImmersiveLayout';
import { InteractiveLayout } from './InteractiveLayout';
@@ -273,6 +274,15 @@ const DecideLayoutWeb = ({
NAV={NAV}
/>
);
+ case ArticleDesign.Crossword:
+ return (
+
+ );
default:
return (
): ArticleDesign => {
return ArticleDesign.Timeline;
case 'ProfileDesign':
return ArticleDesign.Profile;
+ case 'CrosswordDesign':
+ return ArticleDesign.Crossword;
default:
return ArticleDesign.Standard;
}
@@ -270,6 +273,8 @@ const designToFEDesign = (design: ArticleDesign): FEDesign => {
return 'TimelineDesign';
case ArticleDesign.Profile:
return 'ProfileDesign';
+ case ArticleDesign.Crossword:
+ return 'CrosswordDesign';
}
};
diff --git a/dotcom-rendering/src/lib/renderElement.tsx b/dotcom-rendering/src/lib/renderElement.tsx
index a220303bfc8..8bfe959409e 100644
--- a/dotcom-rendering/src/lib/renderElement.tsx
+++ b/dotcom-rendering/src/lib/renderElement.tsx
@@ -9,6 +9,7 @@ import { CartoonComponent } from '../components/CartoonComponent';
import { ChartAtom } from '../components/ChartAtom.importable';
import { CodeBlockComponent } from '../components/CodeBlockComponent';
import { CommentBlockComponent } from '../components/CommentBlockComponent';
+import { Crossword } from '../components/Crossword.importable';
import { DividerBlockComponent } from '../components/DividerBlockComponent';
import { DocumentBlockComponent } from '../components/DocumentBlockComponent.importable';
import { EmailSignUpWrapper } from '../components/EmailSignUpWrapper';
@@ -844,6 +845,14 @@ export const renderElement = ({
case 'model.dotcomrendering.pageElements.DisclaimerBlockElement': {
return ;
}
+ case 'model.dotcomrendering.pageElements.CrosswordElement':
+ return (
+
+
+
+
+
+ );
case 'model.dotcomrendering.pageElements.AudioBlockElement':
case 'model.dotcomrendering.pageElements.ContentAtomBlockElement':
case 'model.dotcomrendering.pageElements.GenericAtomBlockElement':
diff --git a/dotcom-rendering/src/server/handler.article.web.ts b/dotcom-rendering/src/server/handler.article.web.ts
index f98084f570a..25d071303f1 100644
--- a/dotcom-rendering/src/server/handler.article.web.ts
+++ b/dotcom-rendering/src/server/handler.article.web.ts
@@ -3,7 +3,7 @@ import { Standard as ExampleArticle } from '../../fixtures/generated/fe-articles
import { decideFormat } from '../lib/articleFormat';
import { enhanceBlocks } from '../model/enhanceBlocks';
import { validateAsArticleType, validateAsBlock } from '../model/validate';
-import { enhanceArticleType } from '../types/article';
+import { enhanceArticleType, enhanceCrossword } from '../types/article';
import type { FEBlocksRequest } from '../types/frontend';
import { makePrefetchHeader } from './lib/header';
import { recordTypeAndPlatform } from './lib/logging-store';
@@ -54,6 +54,18 @@ export const handleInteractive: RequestHandler = ({ body }, res) => {
res.status(200).set('Link', makePrefetchHeader(prefetchScripts)).send(html);
};
+export const handleCrossword: RequestHandler = ({ body }, res) => {
+ recordTypeAndPlatform('crossword', 'web');
+
+ const article = enhanceArticleType(body, 'Web');
+ const crosswordArticle = enhanceCrossword(article);
+ const { html, prefetchScripts } = renderHtml({
+ article: crosswordArticle,
+ });
+
+ res.status(200).set('Link', makePrefetchHeader(prefetchScripts)).send(html);
+};
+
export const handleBlocks: RequestHandler = ({ body }, res) => {
recordTypeAndPlatform('blocks');
const {
diff --git a/dotcom-rendering/src/server/server.dev.ts b/dotcom-rendering/src/server/server.dev.ts
index 0e9c0fb3762..1df15031170 100644
--- a/dotcom-rendering/src/server/server.dev.ts
+++ b/dotcom-rendering/src/server/server.dev.ts
@@ -10,6 +10,7 @@ import {
handleArticle,
handleArticleJson,
handleBlocks,
+ handleCrossword,
handleInteractive,
} from './handler.article.web';
import { handleEditionsCrossword } from './handler.editionsCrossword';
@@ -71,6 +72,8 @@ export const devServer = (): Handler => {
return handleInteractive(req, res, next);
case 'AMPInteractive':
return handleAMPArticle(req, res, next);
+ case 'Crossword':
+ return handleCrossword(req, res, next);
case 'Blocks':
return handleBlocks(req, res, next);
case 'Front':
diff --git a/dotcom-rendering/src/server/server.prod.ts b/dotcom-rendering/src/server/server.prod.ts
index 7704baaeed3..27cc8e39fa6 100644
--- a/dotcom-rendering/src/server/server.prod.ts
+++ b/dotcom-rendering/src/server/server.prod.ts
@@ -18,6 +18,7 @@ import {
handleArticleJson,
handleArticlePerfTest,
handleBlocks,
+ handleCrossword,
handleInteractive,
} from './handler.article.web';
import { handleEditionsCrossword } from './handler.editionsCrossword';
@@ -67,6 +68,7 @@ export const prodServer = (): void => {
app.post('/AMPArticle', logRenderTime, handleAMPArticle);
app.post('/Interactive', logRenderTime, handleInteractive);
app.post('/AMPInteractive', logRenderTime, handleAMPArticle);
+ app.post('/Crossword', logRenderTime, handleCrossword);
app.post('/Blocks', logRenderTime, handleBlocks);
app.post('/Front', logRenderTime, handleFront);
app.post('/FrontJSON', logRenderTime, handleFrontJson);
diff --git a/dotcom-rendering/src/types/article.ts b/dotcom-rendering/src/types/article.ts
index e1875c1f123..a15e90c8398 100644
--- a/dotcom-rendering/src/types/article.ts
+++ b/dotcom-rendering/src/types/article.ts
@@ -1,4 +1,9 @@
-import { type ArticleFormat, decideFormat } from '../lib/articleFormat';
+import { randomUUID } from 'node:crypto';
+import {
+ ArticleDesign,
+ type ArticleFormat,
+ decideFormat,
+} from '../lib/articleFormat';
import type { ImageForAppsLightbox } from '../model/appsLightboxImages';
import { appsLightboxImages } from '../model/appsLightboxImages';
import { buildLightboxImages } from '../model/buildLightboxImages';
@@ -30,6 +35,40 @@ export type Article = {
frontendData: ArticleDeprecated;
};
+export const enhanceCrossword = (article: Article): Article => {
+ if (article.frontendData.crossword) {
+ const element = {
+ _type: 'model.dotcomrendering.pageElements.CrosswordElement' as const,
+ crossword: article.frontendData.crossword,
+ };
+ return {
+ ...article,
+ format: { ...article.format, design: ArticleDesign.Crossword },
+ frontendData: {
+ ...article.frontendData,
+ blocks: [
+ {
+ id: randomUUID(),
+ elements: [element],
+ attributes: {
+ pinned: false,
+ keyEvent: false,
+ summary: false,
+ },
+ primaryDateLine:
+ article.frontendData.webPublicationDateDisplay,
+ secondaryDateLine:
+ article.frontendData
+ .webPublicationSecondaryDateDisplay,
+ },
+ ],
+ },
+ };
+ }
+
+ throw new TypeError('article did not contain a crossword');
+};
+
export const enhanceArticleType = (
data: FEArticleType,
renderingTarget: RenderingTarget,
diff --git a/dotcom-rendering/src/types/content.ts b/dotcom-rendering/src/types/content.ts
index d481eacf9c8..c809556a4bf 100644
--- a/dotcom-rendering/src/types/content.ts
+++ b/dotcom-rendering/src/types/content.ts
@@ -732,6 +732,12 @@ interface WitnessTypeBlockElement extends ThirdPartyEmbeddedContent {
| WitnessTypeDataVideo
| WitnessTypeDataText;
}
+
+export interface CrosswordElement {
+ _type: 'model.dotcomrendering.pageElements.CrosswordElement';
+ crossword: GuardianCrossword & { instructions?: string };
+}
+
export type FEElement =
| AdPlaceholderBlockElement
| AudioAtomBlockElement
@@ -791,7 +797,8 @@ export type FEElement =
| VideoYoutubeBlockElement
| VineBlockElement
| YoutubeBlockElement
- | WitnessTypeBlockElement;
+ | WitnessTypeBlockElement
+ | CrosswordElement;
// -------------------------------------
// Misc
diff --git a/dotcom-rendering/src/types/frontend.ts b/dotcom-rendering/src/types/frontend.ts
index 2f91fe527d7..42b66f9b773 100644
--- a/dotcom-rendering/src/types/frontend.ts
+++ b/dotcom-rendering/src/types/frontend.ts
@@ -126,6 +126,7 @@ export interface FEArticleType {
showTableOfContents: boolean;
lang?: string;
isRightToLeftLang?: boolean;
+ crossword?: GuardianCrossword & { instructions: string };
}
type PageTypeType = {
@@ -202,7 +203,8 @@ export type FEDesign =
| 'FullPageInteractiveDesign'
| 'NewsletterSignupDesign'
| 'TimelineDesign'
- | 'ProfileDesign'; // FEDisplay is the display information passed through from frontend (originating in the capi scala client) and dictates the displaystyle of the content e.g. Immersive
+ | 'ProfileDesign'
+ | 'CrosswordDesign'; // FEDisplay is the display information passed through from frontend (originating in the capi scala client) and dictates the displaystyle of the content e.g. Immersive
// https://github.com/guardian/content-api-scala-client/blob/master/client/src/main/scala/com.gu.contentapi.client/utils/format/Display.scala
export type FEDisplay =
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 100ac29439e..0e04b991a3f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -362,8 +362,8 @@ importers:
specifier: 2.2.5
version: 2.2.5
'@guardian/react-crossword-next':
- specifier: npm:@guardian/react-crossword@0.0.0-canary-20241128162754
- version: /@guardian/react-crossword@0.0.0-canary-20241128162754(@emotion/react@11.11.3)(@guardian/libs@19.2.1)(@guardian/source@8.0.0)(@types/react@18.3.1)(react-dom@18.3.1)(react@18.3.1)(typescript@5.5.3)
+ specifier: npm:@guardian/react-crossword@0.0.0-canary-20241209150926
+ version: /@guardian/react-crossword@0.0.0-canary-20241209150926(@emotion/react@11.11.3)(@guardian/libs@19.2.1)(@guardian/source@8.0.0)(@types/react@18.3.1)(react-dom@18.3.1)(react@18.3.1)(typescript@5.5.3)
'@guardian/shimport':
specifier: 1.0.2
version: 1.0.2
@@ -4256,8 +4256,8 @@ packages:
tslib: 2.6.2
dev: false
- /@guardian/react-crossword@0.0.0-canary-20241128162754(@emotion/react@11.11.3)(@guardian/libs@19.2.1)(@guardian/source@8.0.0)(@types/react@18.3.1)(react-dom@18.3.1)(react@18.3.1)(typescript@5.5.3):
- resolution: {integrity: sha512-RLmjgGUV6V1DCOKfprUi2SZhpTLw1TSxH7DDFP2maAZUmq6NpYgWZDbezY+m9+IHTuEQfLQYbyyJl0dCcj8bZQ==}
+ /@guardian/react-crossword@0.0.0-canary-20241209150926(@emotion/react@11.11.3)(@guardian/libs@19.2.1)(@guardian/source@8.0.0)(@types/react@18.3.1)(react-dom@18.3.1)(react@18.3.1)(typescript@5.5.3):
+ resolution: {integrity: sha512-nJ9vi454SqMynQ0UDz+jBmO/l7YxgVIq6Gvfyy4p/b5cnBNsxh8n4OZtIexhJx/dOhKYsMSPw/5KU8YOnVhF9A==}
peerDependencies:
'@emotion/react': ^11.11.3
'@guardian/libs': ^19.1.0