7 | The only page powered by DCAR in the Editions app is the crosswords
8 | player, which contains a list of recent crosswords and a way to play
9 | them.
10 |
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/lib/useShouldAdapt.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { shouldAdapt as checkShouldAdapt } from '../client/adaptiveSite';
3 |
4 | /**
5 | * A hook that reports whether we should adapt the current page
6 | * to address poor performance issues
7 | */
8 | export const useShouldAdapt = (): boolean => {
9 | const [shouldAdapt, setShouldAdapt] = useState(false);
10 |
11 | useEffect(() => {
12 | void checkShouldAdapt().then(setShouldAdapt);
13 | }, []);
14 |
15 | return shouldAdapt;
16 | };
17 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/static/icons/audio/skip-forward-15.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/types/renderingTarget.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * A type defining what where we are targeting a particular rendered
3 | * page to be shown
4 | *
5 | * This can be used to make decisions during rendering, where there
6 | * might be differences in the requirements of each target.
7 | *
8 | * Targets:
9 | * - Web => A full web browser, such as chrome or safari on a desktop computer, laptop or phone.
10 | * - Apps => A webview rendered within the Android or iOS live apps
11 | */
12 | export type RenderingTarget = 'Web' | 'Apps';
13 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # DCAR: Dotcom & Apps Rendering
2 |
3 | This repository contains the rendering logic for theguardian.com and for a subset of articles in the live apps.
4 |
5 | ## Run
6 |
7 | Go to [dotcom rendering](dotcom-rendering/README.md) for more details.
8 |
9 | ## Root actions
10 |
11 | Most commands are run from within the dotcom-rendering project but the following can be run from the root:
12 |
13 | ### Storybook
14 |
15 | `pnpm storybook` - Runs Storybook for all projects
16 | `pnpm build-storybook` - Builds Storybook for all projects
17 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/client/decidePublicPath.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Determine the path that webpack should use as base for dynamic imports
3 | *
4 | * @returns The webpack public path to use
5 | */
6 | export const decidePublicPath = (): string => {
7 | const isDev = process.env.NODE_ENV === 'development';
8 | const isLocalHost = window.location.hostname === 'localhost';
9 | // Use relative path if running locally or in CI
10 | return isDev || isLocalHost
11 | ? '/assets/'
12 | : `${window.guardian.config.frontendAssetsFullURL}assets/`;
13 | };
14 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/devServer/docs/interactive.tsx:
--------------------------------------------------------------------------------
1 | import { Available } from './available';
2 |
3 | export const Interactive = () => (
4 | <>
5 |
6 |
7 | Interactives are actually a kind of article
8 | , but DCAR has a separate endpoint for them for performance reasons.
9 | You can find some examples on the{' '}
10 |
11 | Interactives front
12 |
13 | .
14 |
7 | These pages are summaries of cricket matches, and contain various
8 | statistics about a match, including batters, bowlers, and the fall
9 | of wickets. They are typically reached from a link at the top of
10 | some recent{' '}
11 |
12 | cricket liveblogs
13 |
14 | , which are a kind of article.
15 |
16 | >
17 | );
18 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/static/icons/weather/weather-15.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/static/icons/weather/weather-16.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/dotcom-rendering/webpack/@types/webpack-messages/index.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'webpack-messages' {
2 | import type * as webpack from 'webpack';
3 |
4 | type Options = {
5 | name: string;
6 | onComplete?: (...args: unknown[]) => void;
7 | logger?: (msg: string) => void;
8 | };
9 |
10 | class WebpackMessages implements webpack.WebpackPluginInstance {
11 | constructor(options: Options);
12 |
13 | printError(str: string, arr: string[]): void;
14 |
15 | apply(compiler: webpack.Compiler): void;
16 | }
17 |
18 | // eslint-disable-next-line import/no-default-export -- it is how the module is exported
19 | export default WebpackMessages;
20 | }
21 |
--------------------------------------------------------------------------------
/dotcom-rendering/docs/contracts/002-viewer-body-selector.md:
--------------------------------------------------------------------------------
1 | # Viewer Body Selector
2 |
3 | ## What is the contract?
4 |
5 | We place the `article-body-viewer-selector` class on the container element for the article body.
6 |
7 | ## Where is it relied upon?
8 |
9 | [Here](https://github.com/guardian/editorial-viewer/blob/714862c72d8070e18715f01b04a64f2fd2500ff2/public/javascript/components/viewer.js#L252) is where the viewer from composer selects links from the article body of preview articles.
10 |
11 | ## Why is it required?
12 |
13 | Special behaviour is added to links in viewer to control whether or not the link opens in a new tab or within the iframe.
14 |
--------------------------------------------------------------------------------
/dotcom-rendering/fixtures/manual/noTopPicks.ts:
--------------------------------------------------------------------------------
1 | export const noTopPicks = {
2 | status: 'ok',
3 | page: 1,
4 | pages: 1,
5 | pageSize: 50,
6 | orderBy: 'oldest',
7 | discussion: {
8 | key: '/p/39f5z',
9 | webUrl: 'https://www.theguardian.com/science/grrlscientist/2012/aug/07/3',
10 | apiUrl: 'https://discussion.guardianapis.com/discussion-api/discussion//p/39f5z',
11 | commentCount: 496,
12 | topLevelCommentCount: 405,
13 | isClosedForComments: false,
14 | isClosedForRecommendation: false,
15 | isThreaded: true,
16 | title: 'Mystery bird: black-and-red broadbill, Cymbirhynchus macrorhynchos story',
17 | comments: [],
18 | },
19 | };
20 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/client/islands/onInteraction.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Use this function to delay execution of something until an element is interacted
3 | * with
4 | *
5 | * @param element : The html element that we want to wait for an interaction on;
6 | * @param callback : This is fired when the element is clicked on
7 | */
8 | export const onInteraction = (
9 | element: HTMLElement,
10 | callback: (e: HTMLElement) => Promise | void,
11 | ): void => {
12 | element.addEventListener(
13 | 'click',
14 | (e) => {
15 | if (e.target instanceof HTMLElement) {
16 | void callback(e.target);
17 | }
18 | },
19 | { once: true },
20 | );
21 | };
22 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/server/htmlCrosswordPageTemplate.ts:
--------------------------------------------------------------------------------
1 | interface Props {
2 | html: string;
3 | scriptTags: string[];
4 | }
5 |
6 | export const htmlCrosswordPageTemplate = (props: Props): string => {
7 | const { html, scriptTags } = props;
8 |
9 | return `
10 |
11 |
12 |
13 |
14 |
15 | ${scriptTags.join('\n')}
16 |
17 |
18 | ${html}
19 |
20 | `;
21 | };
22 |
--------------------------------------------------------------------------------
/.github/workflows/build-check.yml:
--------------------------------------------------------------------------------
1 | name: DCR Build Check
2 | on:
3 | push:
4 | paths-ignore:
5 | - 'dotcom-rendering/docs/**'
6 |
7 | jobs:
8 | build_check:
9 | name: DCR Build Check
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout code
13 | uses: actions/checkout@v6
14 |
15 | - name: Set up Node environment
16 | uses: ./.github/actions/setup-node-env
17 |
18 | - name: Generate production build
19 | run: make build
20 | working-directory: dotcom-rendering
21 |
22 | - name: Validate Build
23 | run: make buildCheck
24 | working-directory: dotcom-rendering
25 |
--------------------------------------------------------------------------------
/.github/workflows/deno.yml:
--------------------------------------------------------------------------------
1 | name: 🦕 Deno health
2 | on:
3 | pull_request:
4 | paths:
5 | - 'scripts/deno/**'
6 |
7 | jobs:
8 | deno:
9 | name: 🦕 Deno health
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v6
13 |
14 | # https://github.com/denoland/setup-deno#latest-stable-for-a-major
15 | - uses: denoland/setup-deno@v1
16 | with:
17 | deno-version: v1.44
18 |
19 | - name: Format
20 | run: deno fmt scripts/deno
21 |
22 | - name: Lint
23 | run: deno lint scripts/deno
24 |
25 | - name: Type-check
26 | run: deno check scripts/deno/**.ts
27 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/static/badges/GE2017Badge.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/components/Discussion/LoadingPicks.stories.tsx:
--------------------------------------------------------------------------------
1 | import { splitTheme } from '../../../.storybook/decorators/splitThemeDecorator';
2 | import { ArticleDesign, ArticleDisplay, Pillar } from '../../lib/articleFormat';
3 | import { LoadingPicks } from './LoadingPicks';
4 |
5 | export default {
6 | component: LoadingPicks,
7 | title: 'Discussion/LoadingPicks',
8 | decorators: [
9 | splitTheme([
10 | {
11 | theme: Pillar.Opinion,
12 | display: ArticleDisplay.Standard,
13 | design: ArticleDesign.Comment,
14 | },
15 | ]),
16 | ],
17 | };
18 |
19 | export const Default = () => ;
20 | Default.storyName = 'default';
21 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/components/FootballMatchList.test.tsx:
--------------------------------------------------------------------------------
1 | import { shouldRenderMatchLink } from './FootballMatchList';
2 |
3 | it('should render match link if fixture is within 72 hours', () => {
4 | const matchDateTime = new Date('2022-01-01T05:00:00Z');
5 | const now = new Date('2022-01-01T00:00:00Z');
6 |
7 | expect(shouldRenderMatchLink(matchDateTime, now)).toBe(true);
8 | });
9 |
10 | it('should not render match link if fixture is more than 72 hours away', () => {
11 | const matchDateTime = new Date('2022-01-04T00:00:01Z');
12 | const now = new Date('2022-01-01T00:00:00Z');
13 |
14 | expect(shouldRenderMatchLink(matchDateTime, now)).toBe(false);
15 | });
16 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/components/Masthead/Titlepiece/constants.ts:
--------------------------------------------------------------------------------
1 | import { space } from '@guardian/source/foundations';
2 |
3 | export const navInputCheckboxId = 'header-nav-input-checkbox';
4 | export const veggieBurgerId = 'header-veggie-burger';
5 | export const expandedMenuRootId = 'header-expanded-menu-root';
6 | export const expandedMenuId = 'header-expanded-menu';
7 |
8 | export const pillarLeftMarginPx = 6;
9 |
10 | export const pillarWidthsPx = {
11 | tablet: 108,
12 | leftCol: 125,
13 | wide: 136,
14 | };
15 |
16 | export const smallMobilePageMargin = '10px';
17 | export const pageMargin = `${space[5]}px`;
18 |
19 | export const minHeaderHeightPx = 126;
20 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/lib/buildNewsletterSignUpText.tsx:
--------------------------------------------------------------------------------
1 | const supportedFrequencyValues = ['daily', 'weekly', 'monthly', 'fortnightly'];
2 | const specialCasedValues: Record = {
3 | 'every weekday': 'daily',
4 | };
5 | export const buildDetailText = (input: string) => {
6 | const normalisedInput = input.toLowerCase().trim();
7 | const specialCasedValue = specialCasedValues[normalisedInput];
8 |
9 | if (specialCasedValue) {
10 | return `Free ${specialCasedValue} newsletter`;
11 | }
12 |
13 | if (supportedFrequencyValues.includes(normalisedInput)) {
14 | return `Free ${normalisedInput} newsletter`;
15 | }
16 |
17 | return 'Free newsletter';
18 | };
19 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/lib/lang.test.ts:
--------------------------------------------------------------------------------
1 | import { decideLanguage, decideLanguageDirection } from './lang';
2 |
3 | describe('decideLanguage', () => {
4 | test('returns undefined if input is "en"', () => {
5 | expect(decideLanguage('en')).toBe(undefined);
6 | });
7 |
8 | test('returns input if it is not "en"', () => {
9 | expect(decideLanguage('at')).toBe('at');
10 | expect(decideLanguage('fr')).toBe('fr');
11 | });
12 | });
13 |
14 | describe('describeLanguageDirection', () => {
15 | test('returns rtl if input is true', () => {
16 | expect(decideLanguageDirection(true)).toBe('rtl');
17 | expect(decideLanguageDirection(false)).toBe(undefined);
18 | });
19 | });
20 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/client/userFeatures/cookies/allowRejectAll.ts:
--------------------------------------------------------------------------------
1 | import { getCookie } from '@guardian/libs';
2 | import { userBenefitsDataIsUpToDate } from './userBenefitsExpiry';
3 |
4 | export const ALLOW_REJECT_ALL_COOKIE = 'gu_allow_reject_all';
5 |
6 | export const allowRejectAll = (isSignedIn: boolean): boolean =>
7 | getAllowRejectAllCookie() !== null || // If the user has an allow-reject-all cookie, respect it
8 | (isSignedIn && !userBenefitsDataIsUpToDate()); // If they are signed in and their benefits are out of date, allow reject all for now
9 |
10 | export const getAllowRejectAllCookie = (): string | null =>
11 | getCookie({ name: ALLOW_REJECT_ALL_COOKIE });
12 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/components/Discussion/LoadingComments.stories.tsx:
--------------------------------------------------------------------------------
1 | import { splitTheme } from '../../../.storybook/decorators/splitThemeDecorator';
2 | import { ArticleDesign, ArticleDisplay, Pillar } from '../../lib/articleFormat';
3 | import { LoadingComments } from './LoadingComments';
4 |
5 | export default {
6 | component: LoadingComments,
7 | title: 'Discussion/LoadingComments',
8 | decorators: [
9 | splitTheme([
10 | {
11 | theme: Pillar.Opinion,
12 | display: ArticleDisplay.Standard,
13 | design: ArticleDesign.Comment,
14 | },
15 | ]),
16 | ],
17 | };
18 |
19 | export const Default = () => ;
20 | Default.storyName = 'default';
21 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/components/Masthead/Titlepiece/Logo.tsx:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 | import { visuallyHidden } from '@guardian/source/foundations';
3 | import { SvgGuardianLogo } from '@guardian/source/react-components';
4 | import { nestedOphanComponents } from '../../../lib/ophan-helpers';
5 | import { palette } from '../../../palette';
6 |
7 | export const Logo = () => (
8 |
9 |
14 | The Guardian - Back to home
15 |
16 |
17 |
18 | );
19 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/lib/formatCount.ts:
--------------------------------------------------------------------------------
1 | import { isUndefined } from '@guardian/libs';
2 |
3 | export const formatCount = (
4 | count?: number,
5 | ): { short: string; long: string } => {
6 | if (isUndefined(count)) return { short: '…', long: '…' };
7 | if (count === 0) return { short: '0', long: '0' };
8 |
9 | const countAsInteger = Math.floor(count);
10 | const displayCountLong = Intl.NumberFormat('en-GB').format(countAsInteger);
11 | const displayCountShort =
12 | countAsInteger > 10000
13 | ? `${Math.round(countAsInteger / 1000)}k`
14 | : countAsInteger.toString();
15 |
16 | return {
17 | short: displayCountShort,
18 | long: displayCountLong,
19 | };
20 | };
21 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/lib/useAdBlockInUse.ts:
--------------------------------------------------------------------------------
1 | import { isAdBlockInUse } from '@guardian/commercial-core';
2 | import { useEffect, useState } from 'react';
3 |
4 | /**
5 | * @description
6 | * useAdBlockInUse provides a custom hook to integrate the isAdBlockInUse
7 | * promise into a react component
8 | * */
9 | export const useAdBlockInUse = (): boolean | undefined => {
10 | const [isInUse, setIsInUse] = useState();
11 | useEffect(() => {
12 | // eslint-disable-next-line @typescript-eslint/no-floating-promises
13 | isAdBlockInUse().then((blockerDetected) => {
14 | setIsInUse(blockerDetected);
15 | });
16 | }, []);
17 |
18 | return isInUse;
19 | };
20 |
--------------------------------------------------------------------------------
/dotcom-rendering/docs/architecture/018-react-hooks.md:
--------------------------------------------------------------------------------
1 | # Use React hooks
2 |
3 | ## Context
4 |
5 | We've avoided using React hooks for some time in order to ensure un-desired complexity is avoided in the code base. But as hooks are now standard fare in React applications, it makes sense to review our usage of them in DCR.
6 |
7 | ## Decision
8 |
9 | - Prefer non-stateful components if possible
10 | - Prefer React's official hooks to custom hooks
11 | - Avoid abstractions that could lead to hooks within hooks within hooks.
12 | - Prefer hooks to classes with component lifecycle methods
13 | - Try to build hooks that are generic and reusable
14 |
15 | ## Status
16 |
17 | ...
18 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/lib/useRequestSignUp.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { getNewslettersClient } from './bridgetApi';
3 |
4 | export const useRequestSignUp = (
5 | input: string,
6 | identityName: string,
7 | ): boolean | undefined => {
8 | const [isSuccess, setIsSuccess] = useState(undefined);
9 |
10 | useEffect(() => {
11 | if (input) {
12 | getNewslettersClient()
13 | .requestSignUp(input, identityName)
14 | .then((success) => {
15 | setIsSuccess(success);
16 | })
17 | .catch(() => {
18 | setIsSuccess(false);
19 | });
20 | }
21 | }, [input, identityName]);
22 |
23 | return isSuccess;
24 | };
25 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/devServer/docs/newsletters.tsx:
--------------------------------------------------------------------------------
1 | import { Available } from './available';
2 |
3 | export const Newsletters = () => (
4 | <>
5 |
6 |
7 | The{' '}
8 |
9 | all newsletters page
10 | {' '}
11 | is a list of the currently published Editorial email newsletters,
12 | and includes a way to sign up to several at once. It's powered by
13 | the Newsletters API and configured in the Newsletters Tool. The
14 | email newsletters themselves are rendered by{' '}
15 | email-rendering and frontend, not DCAR.
16 |
17 | >
18 | );
19 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/lib/revealStyles.ts:
--------------------------------------------------------------------------------
1 | import { css, keyframes } from '@emotion/react';
2 |
3 | /**
4 | * Sometimes we want to animate in new content using plain
5 | * javascript. We use these classes to do that.
6 | */
7 | export const revealStyles = css`
8 | /* We're using classnames here because we add and remove these classes
9 | using plain javascript */
10 | .reveal {
11 | animation: ${keyframes`
12 | 0% { opacity: 0; }
13 | 100% { opacity: 1; }
14 | `} 1s ease-out;
15 | }
16 | .reveal-slowly {
17 | animation: ${keyframes`
18 | 0% { opacity: 0; }
19 | 100% { opacity: 1; }
20 | `} 4s ease-out;
21 | }
22 | .pending {
23 | display: none;
24 | }
25 | `;
26 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/model/isLegacyTableOfContents.ts:
--------------------------------------------------------------------------------
1 | import type { FEElement } from '../types/content';
2 |
3 | const scriptUrls = [
4 | 'https://interactive.guim.co.uk/page-enhancers/nav/boot.js',
5 | 'https://uploads.guim.co.uk/2019/03/20/boot.js',
6 | 'https://uploads.guim.co.uk/2019/12/11/boot.js',
7 | 'https://interactive.guim.co.uk/testing/2020/11/voterSlideshow/boot.js',
8 | 'https://uploads.guim.co.uk/2021/10/15/boot.js',
9 | ];
10 |
11 | export const isLegacyTableOfContents = (element: FEElement): boolean =>
12 | element._type ===
13 | 'model.dotcomrendering.pageElements.InteractiveBlockElement' &&
14 | !!element.scriptUrl &&
15 | scriptUrls.includes(element.scriptUrl);
16 |
--------------------------------------------------------------------------------
/.github/workflows/permissions-advisor.yml:
--------------------------------------------------------------------------------
1 | name: Permissions Advisor
2 |
3 | permissions:
4 | actions: read
5 |
6 | on:
7 | workflow_dispatch:
8 | inputs:
9 | name:
10 | description: 'The name of the workflow file to analyze'
11 | required: true
12 | type: string
13 | count:
14 | description: 'How many last runs to analyze'
15 | required: false
16 | type: number
17 | default: 5
18 |
19 | jobs:
20 | advisor:
21 | runs-on: ubuntu-latest
22 | steps:
23 | - uses: GitHubSecurityLab/actions-permissions/advisor@v1
24 | with:
25 | name: ${{ inputs.name }}
26 | count: ${{ inputs.count }}
27 |
--------------------------------------------------------------------------------
/dotcom-rendering/docs/contracts/004-heatphan-selectors.md:
--------------------------------------------------------------------------------
1 | ## Heatphan selectors
2 |
3 | ## What is the contract?
4 |
5 | Within the article body, we add the following attributes to certain elements:
6 |
7 | - `data-heatphan-type`: This denotes the component or element type, eg "carousel"
8 |
9 | These are elements that heatphan needs to be able to find and modify for heatphan to function correctly.
10 |
11 | ## Where is it relied upon?
12 |
13 | It is relied upon by Ophan.
14 |
15 | ## Why is it required?
16 |
17 | This allows Heatphan to find elements and then adjusts the CSS/HTML of the page (typically a Front) in order to dynamically modify the appearance and structure of the element.
18 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/components/Masthead/Titlepiece/EditionDropdown.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta } from '@storybook/react-webpack5';
2 | import { EditionDropdown } from './EditionDropdown';
3 |
4 | const meta = {
5 | component: EditionDropdown,
6 | title: 'Components/Masthead/Titlepiece/EditionDropdown',
7 | parameters: {
8 | backgrounds: { default: 'dark' },
9 | layout: 'centered',
10 | chromatic: {
11 | disable: true,
12 | },
13 | },
14 | render: (args) => ,
15 | args: {
16 | editionId: 'UK',
17 | dataLinkName: 'test',
18 | },
19 | } satisfies Meta;
20 |
21 | export default meta;
22 |
23 | export const Default = {};
24 |
--------------------------------------------------------------------------------
/ab-testing/config/README.md:
--------------------------------------------------------------------------------
1 | # @guardian/ab-testing-config
2 |
3 | A/B test definitions and configuration for Guardian digital platforms.
4 |
5 | ## Purpose
6 |
7 | This package provides centralized A/B test configuration, validation and build scripts for ab testing on theguardian.com and associated platforms.
8 |
9 | ## Scripts
10 |
11 | - `pnpm build` - Generate distribution artifacts (`dist/mvts.json`, `dist/ab-tests.json`)
12 | - `pnpm validate` - Validate test configuration
13 | - `pnpm test` - Run unit tests
14 |
15 | ## Usage
16 |
17 | Other packages can import active tests:
18 |
19 | ```typescript
20 | import { activeABtests, allABTests } from "@guardian/ab-testing-config";
21 | ```
22 |
--------------------------------------------------------------------------------
/ab-testing/config/lib/fastly/dictionary.ts:
--------------------------------------------------------------------------------
1 | import type { UpdateDictionaryItemRequest } from "./client.ts";
2 | import type { FastlyService } from "./service.ts";
3 |
4 | export class FastlyDictionary {
5 | id: string;
6 | name: string;
7 | service: FastlyService;
8 |
9 | constructor(
10 | service: FastlyService,
11 | { id, name }: { id: string; name: string },
12 | ) {
13 | this.service = service;
14 | this.id = id;
15 | this.name = name;
16 | }
17 |
18 | async getItems() {
19 | return this.service.getDictionaryItems(this.id);
20 | }
21 |
22 | async updateItems(items: UpdateDictionaryItemRequest[]) {
23 | return this.service.updateDictionaryItems(this.id, items);
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/client/startup.ts:
--------------------------------------------------------------------------------
1 | import { log } from '@guardian/libs';
2 | import type { ScheduleOptions } from '../lib/scheduler';
3 | import { schedule } from '../lib/scheduler';
4 |
5 | const isPolyfilled = new Promise((resolve) => {
6 | if (window.guardian.mustardCut || window.guardian.polyfilled) {
7 | return resolve();
8 | }
9 | window.guardian.queue.push(resolve);
10 | });
11 |
12 | export const startup = async (
13 | name: string,
14 | task: () => Promise,
15 | options: ScheduleOptions,
16 | ): Promise => {
17 | await isPolyfilled;
18 | log('dotcom', `🎬 booting ${name}`);
19 | await schedule(name, task, options);
20 | log('dotcom', `🥾 booted ${name}`);
21 | };
22 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/devServer/send.tsx:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 | import type { RequestHandler } from 'express';
3 | import type { ReactNode } from 'react';
4 | import { renderToPipeableStream } from 'react-dom/server';
5 | import { Doc } from './docs/doc';
6 |
7 | export function sendReact(title: string, node: ReactNode): RequestHandler {
8 | return (req, res) => {
9 | const element = (
10 |
11 | {node}
12 |
13 | );
14 |
15 | const { pipe } = renderToPipeableStream(element, {
16 | onShellReady() {
17 | res.setHeader('content-type', 'text/html');
18 | pipe(res);
19 | },
20 | });
21 | };
22 | }
23 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/lib/querystring.test.ts:
--------------------------------------------------------------------------------
1 | import { constructQuery } from './querystring';
2 |
3 | describe('constructQuery', () => {
4 | it('constructs the correct query string from an object', () => {
5 | const testParams = {
6 | sens: 'f',
7 | si: 'f',
8 | vl: 333,
9 | cc: 'UK',
10 | s: 'sport',
11 | inskin: 'f',
12 | ct: 'article',
13 | url: '/sport/2017/sep/30/test-article',
14 | su: ['0'],
15 | pa: 'f',
16 | a: undefined,
17 | };
18 | const expectedQuery = `sens=f&si=f&vl=333&cc=UK&s=sport&inskin=f&ct=article&url=%2Fsport%2F2017%2Fsep%2F30%2Ftest-article&su=0&pa=f&a=undefined`;
19 | expect(constructQuery(testParams)).toBe(expectedQuery);
20 | });
21 | });
22 |
--------------------------------------------------------------------------------
/dotcom-rendering/docs/architecture/022-dynamic-imports.md:
--------------------------------------------------------------------------------
1 | # Support dynamic imports
2 |
3 | ## Context
4 |
5 | The newest versions of Javascript support `import` as a way to dynamically
6 | import modules. Modules live on a URL and can be loaded cross origin.
7 |
8 | They are therefore useful in a variety of contexts as a mechanism to lazy-load
9 | content.
10 |
11 | Browser-support is high (~90%) but not enough to forgo a polyfill.
12 |
13 | ## Decision
14 |
15 | Support `import` via polyfills for browsers that need them.
16 |
17 | As it is not possible to directly override `import`, dynamic import is exposed
18 | via `window.guardianPolyfilledImport`.
19 |
20 | ## Status
21 |
22 | Approved
23 |
24 | ...
25 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/lib/commercial-constants.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * The maximum number of fronts-banner ads that can be inserted on any front.
3 | * fronts-banner ads are inserted from the desktop breakpoint.
4 | */
5 | export const MAX_FRONTS_BANNER_ADS = 6;
6 |
7 | /**
8 | * The maximum number of fronts-banner ads that can be inserted on fronts with beta collections present.
9 | * fronts-banner ads are inserted from the desktop breakpoint.
10 | */
11 | export const MAX_FRONTS_BANNER_ADS_BETA = 8;
12 |
13 | /**
14 | * The maximum number of ads that can be inserted on any mobile front.
15 | * Mobile ads on fronts are inserted up until the tablet breakpoint.
16 | */
17 | export const MAX_FRONTS_MOBILE_ADS = 10;
18 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/lib/fetchEmail.ts:
--------------------------------------------------------------------------------
1 | import { getAuthState } from './identity';
2 |
3 | const getEmail = async (): Promise => {
4 | const authState = await getAuthState();
5 | return authState.idToken?.claims.email;
6 | };
7 |
8 | /**
9 | * Fetches a signed in user's email address from Okta token.
10 | * Returns null if no email address is found or if the request times out after 1 second.
11 | */
12 | export const lazyFetchEmailWithTimeout =
13 | (): (() => Promise) => () => {
14 | return new Promise((resolve) => {
15 | setTimeout(() => resolve(null), 1000);
16 | void getEmail().then((email) => {
17 | resolve(email ?? null);
18 | });
19 | });
20 | };
21 |
--------------------------------------------------------------------------------
/dotcom-rendering/docs/elements/Richlink.md:
--------------------------------------------------------------------------------
1 | # RichLink Element
2 |
3 | An element which contains a contiguous block of marked up body text.
4 |
5 | ## CAPI representation
6 |
7 | This has an `ElementType` of [RICH_LINK](https://github.com/guardian/content-api-models/blob/master/models/src/main/thrift/content/v1.thrift#L80) with fields described by [RichLinkElementFields](https://github.com/guardian/content-api-models/blob/master/models/src/main/thrift/content/v1.thrift#L599)
8 |
9 | ## Frontend Liveblog Representation
10 |
11 | This is represented in frontend by [RichLinkBlockElement](https://github.com/guardian/frontend/blob/9a2e342437858c621b39eda3ea459e893770af93/common/app/model/liveblog/BlockElement.scala#L44).
12 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/components/ShadyPie.tsx:
--------------------------------------------------------------------------------
1 | const params = new URLSearchParams();
2 | params.set(
3 | 'acquisitionData',
4 | JSON.stringify({
5 | componentType: 'ACQUISITIONS_OTHER',
6 | source: 'GUARDIAN_WEB',
7 | campaignCode: 'shady_pie_open_2019',
8 | componentId: 'shady_pie_open_2019',
9 | }),
10 | );
11 | params.set('INTCMP', 'shady_pie_open_2019');
12 |
13 | export const ShadyPie = () => {
14 | return (
15 |
19 |
24 |
25 | );
26 | };
27 |
--------------------------------------------------------------------------------
/dotcom-rendering/docs/development/storybook.md:
--------------------------------------------------------------------------------
1 | # Storybook
2 |
3 | We use Storybook to visualise our components in an isolated environment, where we can tweak the conditions as we want.
4 | We use Chromatic for visual regression testing of Storybook components.
5 |
6 | ## Rendering context
7 |
8 | We use context for static, global state in the dotcom-rendering app, so every story is wrapped in a context provider component. In the real world, our top component includes this context provider.
9 |
10 | In Storybook is largely invisible as it's hidden within the [configuration](dotcom-rendering/.storybook). There's a decorator configured to wrap around stories and log the context output to the console, for easier debugging.
11 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/client/userFeatures/cookies/userBenefitsExpiry.ts:
--------------------------------------------------------------------------------
1 | import { getCookie } from '@guardian/libs';
2 |
3 | export const USER_BENEFITS_EXPIRY_COOKIE = 'gu_user_benefits_expiry';
4 |
5 | export const getUserBenefitsExpiryCookie = (): string | null =>
6 | getCookie({ name: USER_BENEFITS_EXPIRY_COOKIE });
7 |
8 | export const userBenefitsDataNeedsRefreshing = (): boolean =>
9 | !userBenefitsDataIsUpToDate();
10 |
11 | export const userBenefitsDataIsUpToDate = (): boolean => {
12 | const cookieValue = getUserBenefitsExpiryCookie();
13 | if (!cookieValue) return false;
14 | const expiryTime = parseInt(cookieValue, 10);
15 | const timeNow = new Date().getTime();
16 | return timeNow < expiryTime;
17 | };
18 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/components/InteractivesNativePlatformWrapper.importable.tsx:
--------------------------------------------------------------------------------
1 | import { NativePlatform } from '@guardian/bridget';
2 | import { log } from '@guardian/libs';
3 | import { useEffect } from 'react';
4 | import { getInteractivesClient } from '../lib/bridgetApi';
5 |
6 | export const InteractivesNativePlatformWrapper = () => {
7 | useEffect(() => {
8 | void getInteractivesClient()
9 | .getNativePlatform()
10 | .then((platform) =>
11 | document.documentElement.setAttribute(
12 | 'data-app-os',
13 | NativePlatform[platform],
14 | ),
15 | )
16 | .catch((error) => {
17 | log('dotcom', 'getNativePlatform check failed:', error);
18 | });
19 | }, []);
20 | return null;
21 | };
22 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/lib/affiliateLinksUtils.ts:
--------------------------------------------------------------------------------
1 | /** A function to check if a URL represents an affiliate link */
2 | export const isSkimlink = (url?: string): boolean => {
3 | try {
4 | return !!url && new URL(url).host === 'go.skimresources.com';
5 | } catch (err: unknown) {
6 | // If not a valid URL, it won't be an affiliate link
7 | return false;
8 | }
9 | };
10 |
11 | /** A function to fetch the Skimlinks account ID from the URL to then pass it into the xcust*/
12 | export const getSkimlinksAccountId = (url?: string): string => {
13 | try {
14 | if (!url) return '';
15 | const parsedUrl = new URL(url);
16 | return parsedUrl.searchParams.get('id') ?? '';
17 | } catch {
18 | return '';
19 | }
20 | };
21 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/types/onwards.ts:
--------------------------------------------------------------------------------
1 | import type { FEFormat } from '../frontend/feArticle';
2 | import type { FETrailType } from './trails';
3 |
4 | /**
5 | * Onwards
6 | */
7 | export type FEOnwards = {
8 | heading: string;
9 | trails: FETrailType[];
10 | description?: string;
11 | url?: string;
12 | onwardsSource: OnwardsSource;
13 | format: FEFormat;
14 | isCuratedContent?: boolean;
15 | };
16 |
17 | export type OnwardsSource =
18 | | 'series'
19 | | 'more-on-this-story'
20 | | 'related-stories'
21 | | 'related-content'
22 | | 'more-media-in-section'
23 | | 'more-galleries'
24 | | 'curated-content'
25 | | 'newsletters-page'
26 | | 'unknown-source'; // We should never see this in the analytics data!
27 |
--------------------------------------------------------------------------------
/dotcom-rendering/docs/architecture/014-client-side-computation.md:
--------------------------------------------------------------------------------
1 | # Client Side Computation
2 |
3 | ## Context
4 |
5 | When preparing data for the rendering components we currently have up to three possible locations to do so: (1) the frontend Scala backend, (2) the dotcom rendering backend and (3) the end user's client side.
6 |
7 | In the interest of the user, we should avoid postponing computation to the client side and precompute data and state on either of the two backends whenever possible.
8 |
9 | ## Decision
10 |
11 | - Favour computation in frontend over computation in dotcom-rendering
12 | - Favour computation on dotcom-rendering server than computation on the client
13 |
14 | ## Status
15 |
16 | Approved
17 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/static/badges/australian-election-2019.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/components/QuoteIcon.tsx:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 |
3 | const base = css`
4 | height: 0.7em;
5 | width: auto;
6 | vertical-align: baseline;
7 | margin-right: 4px;
8 | `;
9 | type Props = {
10 | colour: string;
11 | };
12 |
13 | /**
14 | * An inline quote icon (“) sized to match the font size.
15 | */
16 | export const QuoteIcon = ({ colour }: Props) => (
17 | /* This viewBox is narrower than Source’s SvgQuote */
18 |
21 | );
22 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/static/icons/weather/weather-22.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/dotcom-rendering/src/components/LastUpdated.tsx:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 | import { textSans12 } from '@guardian/source/foundations';
3 | import { palette } from '../palette';
4 | import { DateTime } from './DateTime';
5 |
6 | const LastUpdated = ({ lastUpdated }: { lastUpdated: number }) => {
7 | return (
8 |