├── .circleci
└── config.yml
├── .editorconfig
├── .env
├── .env.production
├── .eslintrc
├── .gitignore
├── .wp-env.json
├── .wp-env
└── .htaccess
├── LICENSE
├── README.md
├── codegen.yml
├── components
├── Blocks
│ ├── ClassicEditorBlock
│ │ └── ClassicEditorBlock.tsx
│ ├── Heading
│ │ └── Heading.tsx
│ ├── ImageBlock
│ │ └── ImageBlock.tsx
│ ├── List
│ │ └── List.tsx
│ ├── Paragraph
│ │ └── Paragraph.tsx
│ ├── Quote
│ │ ├── Quote.module.css
│ │ ├── Quote.test.tsx
│ │ └── Quote.tsx
│ ├── Table
│ │ └── Table.tsx
│ ├── UnsupportedBlock
│ │ ├── UnsupportedBlock.module.css
│ │ └── UnsupportedBlock.tsx
│ └── index.tsx
├── Card
│ ├── Card.module.css
│ └── Card.tsx
├── ClientOnly
│ └── ClientOnly.tsx
├── Image
│ ├── Image.test.tsx
│ └── Image.tsx
├── Loading
│ ├── Loading.module.css
│ └── Loading.tsx
├── Page
│ └── Page.tsx
├── PostContent
│ └── PostContent.tsx
├── PostList
│ └── PostList.tsx
├── SearchForm
│ ├── SearchForm.module.css
│ └── SearchForm.tsx
├── SiteFooter
│ └── SiteFooter.tsx
└── SiteHeader
│ └── SiteHeader.tsx
├── graphql
├── apollo-link.ts
├── apollo-provider.tsx
├── apollo.ts
├── fragments
│ ├── ContentBlock.graphql
│ ├── ContentNode.graphql
│ ├── ContentType.graphql
│ ├── MediaItem.graphql
│ └── PageInfo.graphql
└── queries
│ ├── AllContentTypes.graphql
│ ├── ContentNodeBySlug.graphql
│ ├── ContentNodePreviewById.graphql
│ ├── ContentNodesBySearchTerm.graphql
│ ├── ContentTypeByName.graphql
│ └── MediaItems.graphql
├── jest.config.js
├── jest.setup.js
├── lib
├── blocks.test.ts
├── blocks.ts
├── hooks
│ └── useInternalLinkRouting.ts
├── links.test.ts
├── links.ts
├── log.ts
└── redis
│ ├── client.ts
│ └── index.ts
├── middleware.ts
├── next-env.d.ts
├── next.config.js
├── package-lock.json
├── package.json
├── pages
├── [...slug].tsx
├── _app.tsx
├── api
│ ├── books.ts
│ ├── healthcheck.ts
│ └── robots.ts
├── index.tsx
├── latest
│ └── [content_type].tsx
├── media
│ ├── index.module.css
│ └── index.tsx
├── preview
│ └── [token]
│ │ └── [id].tsx
└── search
│ └── index.tsx
├── public
└── favicon.ico
├── styles
└── new.css
├── tsconfig.json
└── vip.config.js
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | ---
2 |
3 | version: 2.1
4 |
5 | orbs:
6 | node: circleci/node@5.0.0
7 |
8 | workflows:
9 | lint:
10 | jobs:
11 | - node/run:
12 | name: "lint"
13 | npm-run: lint
14 | matrix-tests:
15 | jobs:
16 | - node/test:
17 | name: "unit tests"
18 | matrix:
19 | parameters:
20 | version:
21 | - "18.9"
22 | - "16.17"
23 | - "14.20"
24 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | end_of_line = lf
6 | indent_style = tab
7 | insert_final_newline = true
8 | trim_trailing_whitespace = true
9 | indent_size = 2
10 |
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 | NEXT_PUBLIC_GRAPHQL_ENDPOINT="http://localhost:8888/graphql"
2 | NEXT_PUBLIC_SERVER_URL="http://localhost:3000"
3 |
--------------------------------------------------------------------------------
/.env.production:
--------------------------------------------------------------------------------
1 | NEXT_PUBLIC_GRAPHQL_ENDPOINT="https://wp-content-hub.go-vip.net/graphql"
2 | NEXT_PUBLIC_SERVER_URL="https://node-content-hub.go-vip.net"
3 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "jest/globals": true
4 | },
5 | "extends": [
6 | "eslint:recommended",
7 | "plugin:@typescript-eslint/eslint-recommended",
8 | "plugin:@typescript-eslint/recommended",
9 | "plugin:editorconfig/all",
10 | "plugin:jest/recommended",
11 | "next",
12 | "next/core-web-vitals"
13 | ],
14 | "plugins": [
15 | "editorconfig",
16 | "jest"
17 | ],
18 | "rules": {
19 | "editorconfig/indent": [
20 | "error",
21 | {
22 | "SwitchCase": 1
23 | }
24 | ],
25 | "@typescript-eslint/ban-ts-comment": [
26 | "error",
27 | {
28 | "ts-expect-error": "allow-with-description"
29 | }
30 | ],
31 | "@typescript-eslint/no-unused-vars": [
32 | "error",
33 | {
34 | "argsIgnorePattern": "^_",
35 | "varsIgnorePattern": "^_"
36 | }
37 | ]
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env.local
29 | .env.development.local
30 | .env.test.local
31 | .env.production.local
32 |
33 | # vercel
34 | .vercel
35 |
36 | # codegen
37 | /graphql/generated
38 |
--------------------------------------------------------------------------------
/.wp-env.json:
--------------------------------------------------------------------------------
1 | {
2 | "config": {
3 | "WP_HOME": "http://localhost:3000"
4 | },
5 | "plugins": [
6 | "Automattic/vip-decoupled-bundle"
7 | ],
8 | "mappings": {
9 | ".htaccess": ".wp-env/.htaccess"
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/.wp-env/.htaccess:
--------------------------------------------------------------------------------
1 | # Allow rewrites for GraphQL endpoint
2 |
3 | {props.title}
116 | ` element, evolved for the modern web. It includes a variety of built-in performance optimizations. Next.js will automatically determine the width and height of your image based on the imported file.
224 |
225 | For the API images, the `srcSet` property is automatically defined by the `deviceSizes` and `imageSizes` properties added to the `next.config.js` file. If you need to manually set the `srcSet` for a particular image, you should use the `
` HTML tag instead.
226 |
227 | ## Breaking changes from earlier Next.js versions
228 |
229 | - Webpack 4 support has been removed. See the [Webpack 5 upgrade documentation][webpack5] for more information.
230 | - The `target` option has been deprecated. If you are currently using the `target` option set to `serverless`, please read the [documentation on how to leverage the new output][output-file-tracing].
231 | - Next.js `Image` component changed its wrapping element. See the [documentation][image-optimization] for more information.
232 | - The minimum Node.js version has been bumped from `12.0.0` to `12.22.0` which is the first version of Node.js with native ES Modules support.
233 |
234 |
235 | [apollo]: https://www.apollographql.com
236 | [apollo-provider]: https://github.com/Automattic/vip-go-nextjs-skeleton/blob/725c0695ad603d2ecc8b56ff1c9f1cad95f5fe98/graphql/apollo-provider.tsx
237 | [block-attributes]: https://developer.wordpress.org/block-editor/reference-guides/block-api/block-attributes/
238 | [bundle]: https://github.com/Automattic/vip-decoupled-bundle
239 | [cache-config]: https://github.com/Automattic/vip-go-nextjs-skeleton/blob/725c0695ad603d2ecc8b56ff1c9f1cad95f5fe98/next.config.js#L34-L51
240 | [classic-editor]: https://wordpress.com/support/classic-editor-guide/
241 | [code-generation]: https://www.graphql-code-generator.com
242 | [content-blocks]: https://github.com/Automattic/vip-decoupled-bundle/blob/trunk/blocks/blocks.php
243 | [edge-runtime]: https://nextjs.org/docs/api-reference/edge-runtime
244 | [eslint-config]: https://github.com/Automattic/vip-go-nextjs-skeleton/blob/725c0695ad603d2ecc8b56ff1c9f1cad95f5fe98/.eslintrc
245 | [feed-redirect]: https://github.com/Automattic/vip-go-nextjs-skeleton/blob/725c0695ad603d2ecc8b56ff1c9f1cad95f5fe98/next.config.js#L95-L100
246 | [gutenberg]: https://developer.wordpress.org/block-editor/
247 | [healthcheck]: https://docs.wpvip.com/technical-references/vip-platform/node-js/#h-requirement-1-exposing-a-health-route
248 | [image-optimization]: https://nextjs.org/docs/basic-features/image-optimization#styling
249 | [jest]: https://jestjs.io
250 | [latest-content]: https://github.com/Automattic/vip-go-nextjs-skeleton/blob/725c0695ad603d2ecc8b56ff1c9f1cad95f5fe98/pages/latest/%5Bcontent_type%5D.tsx
251 | [lib-config]: https://github.com/Automattic/vip-go-nextjs-skeleton/blob/725c0695ad603d2ecc8b56ff1c9f1cad95f5fe98/lib/config.ts
252 | [link-listener]: https://github.com/Automattic/vip-go-nextjs-skeleton/blob/725c0695ad603d2ecc8b56ff1c9f1cad95f5fe98/lib/hooks/useInternalLinkRouting.ts
253 | [local-nextjs]: http://localhost:3000
254 | [local-wordpress]: http://localhost:8888/wp-admin
255 | [middleware]: https://nextjs.org/docs/middleware
256 | [nextjs]: https://nextjs.org
257 | [nextjs-custom-server]: https://nextjs.org/docs/advanced-features/custom-server
258 | [nextjs-eslint]: https://nextjs.org/docs/basic-features/eslint
259 | [nextjs-gsp]: https://nextjs.org/docs/basic-features/data-fetching#getstaticprops-static-generation
260 | [nextjs-gssp]: https://nextjs.org/docs/basic-features/data-fetching#getserversideprops-server-side-rendering
261 | [nextjs-image]: https://nextjs.org/docs/api-reference/next/image
262 | [nextjs-link]: https://nextjs.org/docs/api-reference/next/link
263 | [nextjs-ts]: https://nextjs.org/docs/basic-features/typescript
264 | [output-file-tracing]: https://nextjs.org/docs/advanced-features/output-file-tracing
265 | [page-cache]: https://docs.wpvip.com/technical-references/caching/page-cache/
266 | [parse-blocks]: https://github.com/WordPress/wordpress-develop/blob/5.8.1/src/wp-includes/blocks.php#L879-L891
267 | [post]: https://github.com/Automattic/vip-go-nextjs-skeleton/blob/725c0695ad603d2ecc8b56ff1c9f1cad95f5fe98/pages/%5B...slug%5D.tsx
268 | [post-content]: https://github.com/Automattic/vip-go-nextjs-skeleton/blob/725c0695ad603d2ecc8b56ff1c9f1cad95f5fe98/components/PostContent/PostContent.tsx
269 | [sitemap-google]: https://developers.google.com/search/docs/advanced/robots/robots_txt#sitemap
270 | [ts-config]: https://github.com/Automattic/vip-go-nextjs-skeleton/blob/725c0695ad603d2ecc8b56ff1c9f1cad95f5fe98/tsconfig.json
271 | [typescript]: https://www.typescriptlang.org
272 | [webpack5]: https://nextjs.org/docs/messages/webpack5
273 | [wpenv]: https://developer.wordpress.org/block-editor/reference-guides/packages/packages-env/
274 | [wpenv-credentials]: https://developer.wordpress.org/block-editor/reference-guides/packages/packages-env/#starting-the-environment
275 | [wpgraphql]: https://www.wpgraphql.com
276 | [wpvip]: https://wpvip.com
277 | [permalinks-plain]: https://wordpress.org/support/article/using-permalinks/#plain-permalinks
278 | [permalinks-setup]: https://wordpress.org/support/article/using-permalinks/#choosing-your-permalink-structure-1
279 |
--------------------------------------------------------------------------------
/codegen.yml:
--------------------------------------------------------------------------------
1 | overwrite: true
2 | schema: "${NEXT_PUBLIC_GRAPHQL_ENDPOINT}"
3 | documents: "graphql/**/*.graphql"
4 | generates:
5 | graphql/generated/index.tsx:
6 | plugins:
7 | - "typescript"
8 | - "typescript-operations"
9 | # https://www.graphql-code-generator.com/docs/plugins/typescript-react-apollo
10 | - "typescript-react-apollo"
11 | graphql/generated/fragmentMatcher.ts:
12 | plugins:
13 | - "fragment-matcher"
14 |
--------------------------------------------------------------------------------
/components/Blocks/ClassicEditorBlock/ClassicEditorBlock.tsx:
--------------------------------------------------------------------------------
1 | import { BlockProps } from '../index';
2 |
3 | export default function ClassicEditorBlock ( { block: { innerHTML } }: BlockProps ) {
4 | return
${text}
Socrates`, 8 | }; 9 | 10 | /** 11 | *does not have an implicit ARIA role that we can pass to 12 | * `getByRole`, so we need to be a little creative to get the top-level 13 | * rendered element. 14 | * 15 | * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/blockquote 16 | */ 17 | 18 | it( 'renders a blockquote with the expected HTML', () => { 19 | render( 20 |23 | ); 24 | 25 | const quote = screen.getByText( text ); 26 | 27 | expect( quote ).toBeInTheDocument(); 28 | expect( quote.parentElement.getAttribute('class') ).toEqual('container'); 29 | } ); 30 | 31 | it( 'renders a "large" quote via a className prop provided by Gutenberg', () => { 32 | render( 33 |37 | ); 38 | 39 | const quote = screen.getByText( text ); 40 | 41 | expect( quote ).toBeInTheDocument(); 42 | expect( quote.parentElement.getAttribute('class') ).toEqual('container large'); 43 | } ); 44 | } ); 45 | 46 | -------------------------------------------------------------------------------- /components/Blocks/Quote/Quote.tsx: -------------------------------------------------------------------------------- 1 | import { BlockProps } from '../index'; 2 | import styles from './Quote.module.css'; 3 | 4 | /** 5 | * This is a styled component, for the Gutenberg Quote Block 6 | * (Block documentation: https://gogutenberg.com/blocks/quote/) 7 | * 8 | * The WordPress block will have content (passed as innerHTML): 9 | *Before software can be reusable, it first has to be usable.
Ralph Johnson 10 | * And a className attribute that is (by default) either is-style-default or is-style-large 11 | * 12 | * The PostContent.tsx case for Quote will ensure both innerHTML and className are part of the props 13 | * 14 | * The Quote component is output as: 15 | *16 | *18 | * 19 | * Use a switch statement to assign the appropriate override style. The .container style is 20 | * our default styling and catch-all that should work even if there's no className. 21 | * 22 | * Quote.module.css contains two different styling options - default (the .container class) and large (the .large class). 23 | * 24 | * The large option results in a defined separation vertically between quote and citation, while the default 25 | * is much more compact. 26 | * 27 | * Since the style can be extended/customized within WordPress, the code here assumes there may be 28 | * other classNames -- but only supports the two current options. If additional classNames can be selected 29 | * from the WP admin UI, then declare the related styles in the CSS file and add a case statement for that style. 30 | */ 31 | 32 | type Props = BlockProps & { 33 | className?: string, 34 | }; 35 | 36 | export default function Quote ( { block: { innerHTML }, ...props } : Props ) { 37 | let style = styles.container; 38 | 39 | switch ( props.className ) { 40 | case 'is-style-large': 41 | style += ' ' + styles.large; 42 | break; 43 | // Add additional styles here 44 | default: 45 | // no additional class 46 | } 47 | 48 | return ( 49 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /components/Blocks/Table/Table.tsx: -------------------------------------------------------------------------------- 1 | import { BlockProps } from '../index'; 2 | 3 | export default function Table ( { block: { innerHTML }, ...props }: BlockProps ) { 4 | return ( 5 |Before software can be reusable, it first has to be usable.
Ralph Johnson 17 | *11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /components/Blocks/UnsupportedBlock/UnsupportedBlock.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | background-color: var(--nc-ac-1); 3 | margin: 1em 0; 4 | padding: 1em; 5 | } 6 | 7 | .title { 8 | margin-bottom: 0.75em; 9 | } 10 | -------------------------------------------------------------------------------- /components/Blocks/UnsupportedBlock/UnsupportedBlock.tsx: -------------------------------------------------------------------------------- 1 | import { BlockProps } from '../index'; 2 | import styles from './UnsupportedBlock.module.css'; 3 | 4 | export default function UnsupportedBlock ( { block: { name, tagName, attributes, innerBlocks, outerHTML } }: BlockProps ) { 5 | const html = outerHTML; 6 | 7 | return ( 8 |
9 |43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /components/Blocks/index.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentType } from 'react'; 2 | import { ContentBlock } from '@/graphql/generated'; 3 | import ClassicEditorBlock from './ClassicEditorBlock/ClassicEditorBlock'; 4 | import Heading from './Heading/Heading'; 5 | import ImageBlock from './ImageBlock/ImageBlock'; 6 | import List from './List/List'; 7 | import Paragraph from './Paragraph/Paragraph'; 8 | import Quote from './Quote/Quote'; 9 | import Table from './Table/Table'; 10 | 11 | export interface BlockProps { 12 | block: ContentBlock, 13 | } 14 | 15 | export type PostContentBlockMap = { 16 | [ key: string ]: ComponentTypeUnsupported block:
10 | { 11 | tagName && 12 |{name}
{tagName}
13 | } 14 | { 15 | html && 16 |17 | {html} 18 |19 | } 20 | { 21 | attributes.length > 0 && ( 22 |23 | { 24 | attributes.map( ( attr, i ) => ( 25 |
29 | ) 30 | } 31 | { 32 | innerBlocks.length > 0 && ( 33 |- {attr.name}: {attr.value || 'null'}
26 | ) ) 27 | } 28 |34 | { 35 | innerBlocks.map( ( block, i ) => ( 36 |
40 | ) 41 | } 42 |- {block.name}
37 | ) ) 38 | } 39 |; 17 | }; 18 | 19 | const defaultBlockMap: PostContentBlockMap = { 20 | 'core/classic-editor': ClassicEditorBlock, 21 | 'core/heading': Heading, 22 | 'core/image': ImageBlock, 23 | 'core/list': List, 24 | 'core/paragraph': Paragraph, 25 | 'core/quote': Quote, 26 | 'core/table': Table, 27 | }; 28 | 29 | export default defaultBlockMap; 30 | -------------------------------------------------------------------------------- /components/Card/Card.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | border: solid 1px #ccc; 3 | border-radius: 0.5em; 4 | margin: 1.5em 0; 5 | padding: 1em; 6 | } 7 | -------------------------------------------------------------------------------- /components/Card/Card.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import styles from './Card.module.css'; 3 | 4 | type Props = { 5 | children: ReactNode, 6 | }; 7 | 8 | export default function Card ( props: Props ) { 9 | return ( 10 | 11 | {props.children} 12 |13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /components/ClientOnly/ClientOnly.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useEffect, useState } from 'react'; 2 | 3 | export default function ClientOnly( props: { 4 | children: ReactNode, 5 | } ) { 6 | const [ hasMounted, setHasMounted ] = useState( false ); 7 | 8 | useEffect( () => { 9 | setHasMounted( true ); 10 | }, [] ); 11 | 12 | if ( ! hasMounted ) { 13 | return null; 14 | } 15 | 16 | return props.children; 17 | } 18 | -------------------------------------------------------------------------------- /components/Image/Image.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import Image from './Image'; 3 | import nextConfig from '../../next.config'; 4 | 5 | describe( 'Image', () => { 6 | const altText = 'A river otter plays with a set of nesting cups while floating on its back.'; 7 | const height = 200; 8 | const minWidth = nextConfig.images.deviceSizes[0]; 9 | const width = 200; 10 | 11 | describe( 'with a WordPress image URL', () => { 12 | const src = '/wp-content/uploads/you-otter-see-this.jpg'; 13 | 14 | it( 'uses native lazy-loading', () => { 15 | render( 16 | 22 | ); 23 | 24 | const image = screen.getByRole( 'img' ); 25 | 26 | expect( image ).toBeInTheDocument(); 27 | expect( image.getAttribute( 'loading' ) ).toEqual( 'lazy' ); 28 | } ); 29 | 30 | it( 'has its `src` transformed by the image loader when loaded', () => { 31 | render( 32 | 39 | ); 40 | 41 | const image = screen.getByRole( 'img' ); 42 | 43 | expect( image ).toBeInTheDocument(); 44 | expect( image.getAttribute( 'src' ) ).toEqual( `${src}?w=${minWidth}&q=75` ); 45 | } ); 46 | 47 | it('renders an img with alt text', () => { 48 | render( 49 | 55 | ); 56 | 57 | const image = screen.getByAltText( altText ); 58 | 59 | expect( image ).toBeInTheDocument(); 60 | } ); 61 | } ); 62 | 63 | describe( 'with a non-WordPress image URL', () => { 64 | const src = '/you-otter-see-this.jpg'; 65 | 66 | it( 'uses native lazy-loading', () => { 67 | render( 68 | 74 | ); 75 | 76 | const image = screen.getByRole( 'img' ); 77 | 78 | expect( image ).toBeInTheDocument(); 79 | expect( image.getAttribute( 'loading' ) ).toEqual( 'lazy' ); 80 | } ); 81 | 82 | it( 'has its `src` transformed by the default Next.js image loader when loaded', () => { 83 | render( 84 | 91 | ); 92 | 93 | const image = screen.getByRole( 'img' ); 94 | 95 | expect( image ).toBeInTheDocument(); 96 | expect( image.getAttribute( 'src' ) ).toEqual( `/_next/image?url=${encodeURIComponent(src)}&w=${minWidth}&q=75` ); 97 | } ); 98 | 99 | it('renders an image with alt text', () => { 100 | render( 101 | 107 | ); 108 | 109 | const image = screen.getByAltText( altText ); 110 | 111 | expect( image ).toBeInTheDocument(); 112 | } ); 113 | } ); 114 | } ); 115 | -------------------------------------------------------------------------------- /components/Image/Image.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps } from 'react'; 2 | import NextImage, { ImageLoader, ImageLoaderProps } from 'next/image'; 3 | import VipConfig from '../../vip.config'; 4 | 5 | /** 6 | * This component wraps Next's image component to provide an image loader. An 7 | * image loader allows us to add query parameters for VIP's image processor. 8 | * 9 | * https://nextjs.org/docs/api-reference/next/image#loader 10 | * https://docs.wpvip.com/technical-references/vip-go-files-system/image-transformation/ 11 | */ 12 | 13 | type Props = { 14 | src: string, 15 | srcset?: string, 16 | originalWidth?: number, 17 | originalHeight?: number, 18 | } & ComponentProps ; 19 | 20 | function wpImageLoader ( { quality, src, width }: ImageLoaderProps ): string { 21 | return `${src}?w=${width}&q=${quality || 75 }`; 22 | } 23 | 24 | export default function Image ( props: Props ) { 25 | const { originalHeight, originalWidth, ...imageProps } = props; 26 | const height = props.height || originalHeight; 27 | const width = props.width || originalWidth; 28 | 29 | if ( VipConfig.images.useHtmlTag && props.srcset ) { 30 | return ( 31 | // eslint-disable-next-line @next/next/no-img-element 32 | 37 | ); 38 | } 39 | 40 | // Only set a loader if it is actually needed. This avoids a Next.js warning: 41 | // https://nextjs.org/docs/messages/next-image-missing-loader-width 42 | let loader: ImageLoader; 43 | if ( props.src.includes( '/wp-content/uploads/' ) ) { 44 | loader = wpImageLoader; 45 | } 46 | 47 | return ( 48 |
54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /components/Loading/Loading.module.css: -------------------------------------------------------------------------------- 1 | /* Adapted from https://stephanwagner.me/only-css-loading-spinner */ 2 | 3 | @keyframes spin { 4 | to { 5 | transform: rotate(360deg); 6 | } 7 | } 8 | 9 | .container::before { 10 | animation: spin .75s linear infinite; 11 | border-radius: 50%; 12 | border: 4px solid #ccc; 13 | border-top-color: #000; 14 | box-sizing: border-box; 15 | content: ''; 16 | height: 36px; 17 | left: 50%; 18 | margin-left: -18px; 19 | margin-top: -18px; 20 | position: absolute; 21 | top: 50%; 22 | width: 36px; 23 | } 24 | -------------------------------------------------------------------------------- /components/Loading/Loading.tsx: -------------------------------------------------------------------------------- 1 | import styles from './Loading.module.css'; 2 | 3 | export default function Loading() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /components/Page/Page.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import Head from 'next/head'; 3 | import Loading from '@/components/Loading/Loading'; 4 | import SiteFooter from '@/components/SiteFooter/SiteFooter'; 5 | import SiteHeader from '@/components/SiteHeader/SiteHeader'; 6 | 7 | /** 8 | * A page component helps us to enforce consistent UI and SEO best practices 9 | * across the site. 10 | * 11 | * A loading state allows you to avoid rendering the children until the data 12 | * you need is ready. 13 | */ 14 | 15 | type Props = { 16 | children: ReactNode, 17 | canonicalLink?: string, 18 | feedLink?: string, 19 | headerLink?: ReactNode, 20 | loading?: boolean, 21 | ogTitle?: string, 22 | title: string, 23 | }; 24 | 25 | export default function Page( props: Props ) { 26 | const { 27 | loading = false, 28 | } = props; 29 | 30 | if ( loading ) { 31 | return ; 32 | } 33 | 34 | return ( 35 | <> 36 | 37 | {props.title} 38 | { 39 | props.ogTitle && 40 | 44 | } 45 | { 46 | props.canonicalLink && 47 | 48 | } 49 | { 50 | props.feedLink && 51 | 57 | } 58 | 59 |60 | 61 | 64 |{props.title}
62 | {props.children} 63 |65 | > 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /components/PostContent/PostContent.tsx: -------------------------------------------------------------------------------- 1 | import { ContentBlock } from '@/graphql/generated'; 2 | import { mapAttributesToProps } from '@/lib/blocks'; 3 | import defaultBlockMap, { PostContentBlockMap } from '@/components/Blocks'; 4 | import UnsupportedBlock from '@/components/Blocks/UnsupportedBlock/UnsupportedBlock'; 5 | 6 | type Props = { 7 | blocks: ContentBlock[], 8 | blockMapOverrides?: PostContentBlockMap, 9 | }; 10 | 11 | export default function PostContent( { blocks, blockMapOverrides = {} } : Props ) { 12 | // This is a functional component used to render the related component for each block on PostContent 13 | // 14 | // If you want to customize some component or create new ones, you can provide the blockMapOverrides prop to this component 15 | // with a mapping when you're rendering some page on next.js structure. 16 | // 17 | const blockMap: PostContentBlockMap = { 18 | ...defaultBlockMap, 19 | ...blockMapOverrides, 20 | }; 21 | 22 | return ( 23 | <> 24 | { 25 | blocks.map( ( block, i ) => { 26 | const attributesProps = mapAttributesToProps( block.attributes || [] ); 27 | const key = `block-${i}`; 28 | const Block = blockMap[ block.name ]; 29 | 30 | if ( Block ) { 31 | return ; 32 | } 33 | 34 | // In development, highlight unsupported blocks so that they get 35 | // visibility with developers. 36 | if ( 'development' === process.env.NODE_ENV ) { 37 | return ; 38 | } 39 | 40 | // In production, ignore unsupported blocks. 41 | return null; 42 | }) 43 | } 44 | > 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /components/PostList/PostList.tsx: -------------------------------------------------------------------------------- 1 | import { ContentNodeFieldsFragment } from '@/graphql/generated'; 2 | import Link from 'next/link'; 3 | import { getInternalLinkPathname } from '@/lib/links'; 4 | 5 | type Props = { 6 | nextPageLink?: string, 7 | posts: ContentNodeFieldsFragment[], 8 | previousPageLink?: string, 9 | }; 10 | 11 | export default function PostList( props: Props ) { 12 | return ( 13 | <> 14 | 15 | { 16 | props.posts.map( post => ( 17 |
23 |- 18 | {post.title} 19 |
20 | ) ) 21 | } 22 |24 | { 25 | props.previousPageLink && 26 | <> 27 | < Previous 28 | 29 | > 30 | } 31 | { 32 | props.nextPageLink && 33 | Next > 34 | } 35 |
36 | > 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /components/SearchForm/SearchForm.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | column-gap: 1em; 3 | display: grid; 4 | grid-template-columns: auto 100px; 5 | } 6 | 7 | .input { 8 | height: 100%; 9 | } 10 | -------------------------------------------------------------------------------- /components/SearchForm/SearchForm.tsx: -------------------------------------------------------------------------------- 1 | import { FormEvent, useRef } from 'react'; 2 | import { useRouter } from 'next/router'; 3 | import styles from './SearchForm.module.css'; 4 | 5 | type Props = { 6 | path: string, 7 | search: string, 8 | }; 9 | 10 | export default function SearchForm( props: Props ) { 11 | const ref = useRef(); 12 | const router = useRouter(); 13 | 14 | function onSubmit( evt: FormEvent ) { 15 | evt.preventDefault(); 16 | router.push( `${ props.path }?s=${ encodeURIComponent( ref.current.value ) }` ); 17 | } 18 | 19 | return ( 20 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /components/SiteFooter/SiteFooter.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | 3 | /** 4 | * Server-side data loading must take place in pages. If you need to load data 5 | * to render this component, you must pass it down as props (or load it client- 6 | * side.) 7 | */ 8 | export default function SiteFooter() { 9 | return ( 10 | <> 11 |
12 | 15 | > 16 | ); 17 | } 18 | 19 | -------------------------------------------------------------------------------- /components/SiteHeader/SiteHeader.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import Link from 'next/link'; 3 | 4 | type Props = { 5 | headerLink?: ReactNode, 6 | }; 7 | 8 | /** 9 | * Server-side data loading must take place in pages. If you need to load data 10 | * to render this component, you must pass it down as props (or load it client- 11 | * side.) 12 | */ 13 | export default function SiteHeader( props: Props ) { 14 | return ( 15 | <> 16 | 26 |
27 | > 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /graphql/apollo-link.ts: -------------------------------------------------------------------------------- 1 | import { ApolloLink, HttpLink, from } from '@apollo/client'; 2 | import { onError } from '@apollo/client/link/error'; 3 | import { log, logError, LogContext } from '@/lib/log'; 4 | 5 | const uri = process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT; 6 | 7 | export default function getApolloLink ( requestContext: LogContext = {} ) { 8 | // If endpoint is undefined, throw for visibility. 9 | if ( 'undefined' === typeof uri ) { 10 | throw new Error( 'GraphQL endpoint is undefined' ); 11 | } 12 | 13 | return from( [ 14 | // Error link to log GraphQL errors. 15 | onError( ( { graphQLErrors, networkError } ) => { 16 | if ( graphQLErrors ) { 17 | graphQLErrors.forEach( err => { 18 | const { locations, path } = err; 19 | const context = { 20 | locations: JSON.stringify( locations ), 21 | path: JSON.stringify( path ), 22 | }; 23 | 24 | logError( err, context, requestContext ); 25 | } ); 26 | } 27 | 28 | if ( networkError ) { 29 | logError( networkError, {}, requestContext ); 30 | } 31 | } ), 32 | 33 | // Custom ApolloLink to log successful queries for debugging. 34 | new ApolloLink( ( operation, forward ) => { 35 | const { operationName, setContext, variables } = operation; 36 | 37 | const debug = { 38 | operationName, 39 | variables: JSON.stringify( variables ), 40 | }; 41 | 42 | const startTime = Date.now(); 43 | 44 | setContext( ( { headers = {} } ) => ( { 45 | headers: { 46 | ...headers, 47 | // Here is where you can set custom request headers for your GraphQL 48 | // requests. If the request is client-side, it must be allowed by the 49 | // CORS policy in WPGraphQL. 50 | }, 51 | } ) ); 52 | 53 | return forward( operation ) 54 | .map( data => { 55 | const response = operation.getContext().response; 56 | const context = { 57 | ...debug, 58 | cacheStatus: response?.headers?.get( 'x-cache' ), 59 | cacheAge: response?.headers?.get( 'age' ), 60 | payloadSize: response?.body?.bytesWritten, 61 | requestDurationInMs: Date.now() - startTime, 62 | }; 63 | 64 | log( 'GraphQL request', context, requestContext ); 65 | 66 | return data; 67 | } ); 68 | } ), 69 | 70 | // Standard HttpLink to connect to GraphQL. 71 | new HttpLink( { uri } ), 72 | ] ); 73 | } 74 | -------------------------------------------------------------------------------- /graphql/apollo-provider.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { ApolloProvider } from '@apollo/client'; 3 | import getApolloClient from './apollo'; 4 | 5 | type Props = { 6 | children: ReactNode, 7 | }; 8 | 9 | /** 10 | * This is a provider that you can *optionally* use if you have a widespread need 11 | * to load data during the client-side render. It is not in use by default. 12 | * Wrapping your app (_app.tsx) in this provider will make Apollo available for 13 | * use in your React components (e.g., useQuery) but will increase the size of 14 | * your bundle. 15 | * 16 | * If you don't have a widespread need for this, stick to getStaticProps and 17 | * getServerSideProps and enjoy a smaller bundle and more performant site. :) 18 | * 19 | * If you need to load data client-side for just a few pages, consider wrapping 20 | * just those pages. Next.js's code splitting will ensure that your other pages 21 | * retain a smaller bundle size. 22 | */ 23 | export default function ClientSideApolloProvider( props: Props ) { 24 | return ( 25 |26 | {props.children} 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /graphql/apollo.ts: -------------------------------------------------------------------------------- 1 | import { ApolloClient, InMemoryCache } from '@apollo/client'; 2 | import { GetServerSidePropsContext, GetStaticPropsContext } from 'next'; 3 | import fragmentMatcher from '@/graphql/generated/fragmentMatcher'; 4 | import getApolloLink from './apollo-link'; 5 | import { generateRequestContext } from '@/lib/log'; 6 | 7 | let clientSideApolloClient: ApolloClient; 8 | 9 | const isServerSide = 'undefined' === typeof window; 10 | 11 | const { possibleTypes } = fragmentMatcher; 12 | 13 | /** 14 | * Server-side / static, Apollo client should be recreated for each request so 15 | * that the in-memory cache is not shared across requests. 16 | * 17 | * Client-side, Apollo client should be reused to benefit from the cache. 18 | * 19 | * This function detects whether it is being called in a server or browser 20 | * environment and returns a new instance or a shared instance saved in-memory. 21 | * 22 | * tl;dr: Just call this function whenever you need an Apollo client. :) 23 | * 24 | * If you are using this function inside `getServerSideProps`, pass the provided 25 | * context as the first parameter for additional detail in your logging. (Since 26 | * `getStaticProps` is run at build time, its context is not useful.) 27 | */ 28 | export default function getApolloClient ( serverSideContext?: GetServerSidePropsContext | GetStaticPropsContext ) { 29 | // Server-side / static: Return a new instance every time. 30 | if ( isServerSide ) { 31 | 32 | const requestContext = generateRequestContext(serverSideContext); 33 | 34 | return new ApolloClient( { 35 | cache: new InMemoryCache( { possibleTypes } ), 36 | link: getApolloLink( requestContext ), 37 | ssrMode: true, 38 | } ); 39 | } 40 | 41 | // Client-side: Create and store a single instance if it doesn't yet exist. 42 | if ( 'undefined' === typeof clientSideApolloClient ) { 43 | clientSideApolloClient = new ApolloClient( { 44 | cache: new InMemoryCache( { possibleTypes } ), 45 | link: getApolloLink(), 46 | } ); 47 | } 48 | 49 | return clientSideApolloClient; 50 | } 51 | -------------------------------------------------------------------------------- /graphql/fragments/ContentBlock.graphql: -------------------------------------------------------------------------------- 1 | fragment ContentBlockFields on ContentBlock { 2 | attributes { 3 | name 4 | value 5 | } 6 | innerHTML(removeWrappingTag: true) 7 | name 8 | tagName 9 | } 10 | -------------------------------------------------------------------------------- /graphql/fragments/ContentNode.graphql: -------------------------------------------------------------------------------- 1 | #import "./ContentType.graphql" 2 | #import "./ContentBlock.graphql" 3 | 4 | fragment ContentNodeFields on ContentNode { 5 | id 6 | ... on NodeWithContentEditor { 7 | contentBlocks { 8 | isGutenberg 9 | blocks { 10 | ...ContentBlockFields 11 | innerBlocks { 12 | ...ContentBlockFields 13 | } 14 | } 15 | } 16 | } 17 | contentType { 18 | node { 19 | id 20 | name 21 | } 22 | } 23 | databaseId 24 | dateGmt 25 | isPreview 26 | link 27 | modifiedGmt 28 | ... on NodeWithTitle { 29 | title 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /graphql/fragments/ContentType.graphql: -------------------------------------------------------------------------------- 1 | #import "../fragments/ContentType.graphql" 2 | #import "../fragments/PageInfo.graphql" 3 | 4 | fragment ContentTypeFields on ContentType { 5 | id 6 | contentNodes( 7 | after: $after 8 | before: $before 9 | first: $first 10 | last: $last 11 | ) { 12 | nodes { 13 | ...ContentNodeFields 14 | } 15 | pageInfo { 16 | ...PageInfo 17 | } 18 | } 19 | description 20 | # Currently restricted by WPGraphQL to `edit_post` cap. 21 | # label 22 | name 23 | } 24 | -------------------------------------------------------------------------------- /graphql/fragments/MediaItem.graphql: -------------------------------------------------------------------------------- 1 | fragment MediaItemFields on MediaItem { 2 | id 3 | altText 4 | caption 5 | databaseId 6 | date 7 | mediaDetails { 8 | height 9 | width 10 | } 11 | sourceUrl 12 | title 13 | uri 14 | } 15 | -------------------------------------------------------------------------------- /graphql/fragments/PageInfo.graphql: -------------------------------------------------------------------------------- 1 | fragment PageInfo on WPPageInfo { 2 | endCursor 3 | hasNextPage 4 | hasPreviousPage 5 | startCursor 6 | } 7 | -------------------------------------------------------------------------------- /graphql/queries/AllContentTypes.graphql: -------------------------------------------------------------------------------- 1 | #import "../fragments/ContentType.graphql" 2 | 3 | query AllContentTypes( 4 | $after: String 5 | $before: String 6 | $first: Int = 1 7 | $last: Int 8 | ) { 9 | contentTypes( 10 | first: 50 11 | ) { 12 | nodes { 13 | ...ContentTypeFields 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /graphql/queries/ContentNodeBySlug.graphql: -------------------------------------------------------------------------------- 1 | #import "../fragments/ContentNode.graphql" 2 | 3 | query ContentNodeBySlug( 4 | $slug: String! 5 | ) { 6 | contentNodes( 7 | where: { 8 | name: $slug 9 | } 10 | ) { 11 | nodes { 12 | ...ContentNodeFields 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /graphql/queries/ContentNodePreviewById.graphql: -------------------------------------------------------------------------------- 1 | #import "../fragments/ContentNode.graphql" 2 | 3 | query ContentNodePreviewById( 4 | $id: ID! 5 | ) { 6 | contentNode( 7 | asPreview: true 8 | id: $id 9 | idType: DATABASE_ID 10 | ) { 11 | ...ContentNodeFields 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /graphql/queries/ContentNodesBySearchTerm.graphql: -------------------------------------------------------------------------------- 1 | #import "../fragments/ContentNode.graphql" 2 | #import "../fragments/PageInfo.graphql" 3 | 4 | query ContentNodesBySearchTerm( 5 | $after: String 6 | $before: String 7 | $first: Int 8 | $last: Int 9 | $search: String! 10 | ) { 11 | contentNodes( 12 | after: $after 13 | before: $before 14 | first: $first 15 | last: $last 16 | where: { 17 | search: $search 18 | } 19 | ) { 20 | nodes { 21 | ...ContentNodeFields 22 | } 23 | pageInfo { 24 | ...PageInfo 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /graphql/queries/ContentTypeByName.graphql: -------------------------------------------------------------------------------- 1 | #import "../fragments/ContentNode.graphql" 2 | 3 | query ContentTypeByName( 4 | $after: String 5 | $before: String 6 | $first: Int 7 | $last: Int 8 | $name: ID! 9 | ) { 10 | contentType( 11 | id: $name 12 | idType: NAME 13 | ) { 14 | ...ContentTypeFields 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /graphql/queries/MediaItems.graphql: -------------------------------------------------------------------------------- 1 | #import "../fragments/MediaItem.graphql" 2 | #import "../fragments/PageInfo.graphql" 3 | 4 | query AllMediaItems( 5 | $after: String 6 | $first: Int = 10 7 | ) { 8 | mediaItems( 9 | after: $after 10 | first: $first 11 | ) { 12 | nodes { 13 | ...MediaItemFields 14 | } 15 | pageInfo { 16 | ...PageInfo 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const nextJest = require( 'next/jest' ); 2 | 3 | const createJestConfig = nextJest( { 4 | dir: './', 5 | } ); 6 | 7 | // Add any custom config to be passed to Jest 8 | /** @type {import('jest').Config} */ 9 | const customJestConfig = { 10 | moduleDirectories: ['node_modules', ' /'], 11 | moduleNameMapper: { 12 | "^@/(components|graphql|lib|pages|styles)/(.*)$": " /$1/$2", 13 | }, 14 | setupFilesAfterEnv: [' /jest.setup.js'], 15 | testEnvironment: 'jest-environment-jsdom', 16 | testMatch: [ 17 | '**/__tests__/**/*.+(ts|tsx|js)', 18 | '**/*.(spec|test).+(ts|tsx|js)' 19 | ], 20 | }; 21 | 22 | module.exports = createJestConfig( customJestConfig ); 23 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/extend-expect'; 2 | -------------------------------------------------------------------------------- /lib/blocks.test.ts: -------------------------------------------------------------------------------- 1 | import { mapAttributesToProps } from './blocks'; 2 | 3 | test( 'mapAttributesToProps: returns a props-like object', function () { 4 | const attributes = [ 5 | { 6 | name: 'foo', 7 | value: 'bar', 8 | }, 9 | { 10 | name: 'fizz', 11 | value: 'buzz', 12 | }, 13 | { 14 | name: 'far', 15 | value: 'near', 16 | }, 17 | ]; 18 | 19 | expect( mapAttributesToProps( attributes ) ).toEqual( { 20 | foo: 'bar', 21 | fizz: 'buzz', 22 | far: 'near', 23 | } ); 24 | } ); 25 | 26 | test( 'mapAttributesToProps: drops attributes that have falsy names or values', function () { 27 | const attributes = [ 28 | { 29 | value: 'bar', 30 | }, 31 | { 32 | name: 'foo', 33 | }, 34 | { 35 | name: 'far', 36 | value: null, 37 | }, 38 | ]; 39 | 40 | expect( mapAttributesToProps( attributes ) ).toEqual( {} ); 41 | } ); 42 | -------------------------------------------------------------------------------- /lib/blocks.ts: -------------------------------------------------------------------------------- 1 | import { ContentBlockAttribute } from '@/graphql/generated'; 2 | 3 | export type PostContentBlockAttributes = { 4 | [ key: string ]: string; 5 | }; 6 | 7 | /** 8 | * Map an array of ContentBlock attributes to an object that can used like props. 9 | */ 10 | export function mapAttributesToProps ( attributes: ContentBlockAttribute[] ): PostContentBlockAttributes { 11 | return attributes.reduce( ( acc, { name, value } ) => { 12 | // Drop attributes without a name or value. 13 | if ( ! name || ! value ) { 14 | return acc; 15 | } 16 | 17 | // Values are always strings, so we could cast some "special" strings to 18 | // their presumed types. For example, we could convert "false" to false, 19 | // "null" to null, etc. 20 | // 21 | // This could cause unexpected issues, so we will leave that exercise for 22 | // those that find a need for it. 23 | 24 | return Object.assign( acc, { [ name ]: value } ); 25 | }, {} ); 26 | } 27 | -------------------------------------------------------------------------------- /lib/hooks/useInternalLinkRouting.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useRouter } from 'next/router'; 3 | import { getInternalLinkPathname } from '@/lib/links'; 4 | 5 | /** 6 | * Using the Link component (next/link) for internal links instead of vanilla 7 | * tags allows Next.js to load a page client-side and re-render with React 8 | * instead of making a full round-trip for another server-rendered page. 9 | * 10 | * However, sometimes tags are embedded within markup that you need to render 11 | * directly with `dangerouslySetInnerHTML`. We can listen for clicks on these 12 | * links and, if they match our expectations for what constitues a relative link, 13 | * we can route them interally. 14 | */ 15 | export default function useLinkRouter() { 16 | const router = useRouter(); 17 | 18 | useEffect( () => { 19 | function captureLinks ( evt: MouseEvent ) { 20 | // Narrow type to HTMLAnchorElement. 21 | if ( ! ( evt.target instanceof HTMLAnchorElement ) ) { 22 | return; 23 | } 24 | 25 | // Determine if the link destination should be considered internal. 26 | const internalLinkPathname = getInternalLinkPathname( evt.target.href ); 27 | if ( internalLinkPathname ) { 28 | evt.preventDefault(); 29 | router.push( internalLinkPathname ); 30 | } 31 | } 32 | 33 | document.body.addEventListener( 'click', captureLinks ); 34 | 35 | return () => document.body.removeEventListener( 'click' , captureLinks ); 36 | }, [ router ] ); 37 | } 38 | -------------------------------------------------------------------------------- /lib/links.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | extractLastTokenFromRoute, 3 | getInternalLinkPathname, 4 | } from './links'; 5 | import { links } from '../vip.config'; 6 | 7 | jest.mock( '../vip.config', function () { 8 | return { 9 | links: { 10 | isInternalLink: jest.fn( () => true ), 11 | }, 12 | }; 13 | } ); 14 | 15 | describe( 'extractLastTokenFromRoute', () => { 16 | it( 'extracts the last token from an array of tokens', function () { 17 | const input = [ 'one', 'two', 'three', 'four', 'five' ]; 18 | expect( extractLastTokenFromRoute( input ) ).toBe( 'five' ); 19 | } ); 20 | 21 | it( 'returns a string', function () { 22 | expect( extractLastTokenFromRoute( 'bare string' ) ).toBe( 'bare string' ); 23 | } ); 24 | } ); 25 | 26 | describe( 'isInternalLink default implementation', () => { 27 | const unmockedVipConfig = jest.requireActual( '../vip.config' ); 28 | 29 | it( 'returns true for local hostnames', function () { 30 | const internalHostnames = [ 31 | '127.0.0.1', 32 | 'localhost', 33 | ]; 34 | 35 | internalHostnames.forEach( function ( hostname ) { 36 | expect( unmockedVipConfig.links.isInternalLink( hostname ) ).toBe( true ); 37 | } ); 38 | } ); 39 | 40 | it( 'returns false for non-local hostnames', function () { 41 | const externalHostnames = [ 42 | 'localhost.internal', 43 | 'local.host', 44 | 'localhost.com', 45 | 'mysite.example.com', 46 | 'mysite2.example.com', 47 | ]; 48 | 49 | externalHostnames.forEach( function ( hostname ) { 50 | expect( unmockedVipConfig.links.isInternalLink( hostname ) ).toBe( false ); 51 | } ); 52 | } ); 53 | } ); 54 | 55 | describe( 'getInternalLinkPathname', () => { 56 | const isInternalLink = ( links.isInternalLink as jest.MockedFunction ); 57 | 58 | beforeEach( () => { 59 | isInternalLink.mockClear(); 60 | } ); 61 | 62 | it( 'preserves the query string', function () { 63 | const internalLinkWithQuery = 'http://localhost/howdy.html?wave=true'; 64 | 65 | expect( getInternalLinkPathname( internalLinkWithQuery ) ).toEqual( '/howdy.html?wave=true' ); 66 | } ); 67 | 68 | it( 'returns the full URL for non-local URLs', function () { 69 | const externalLink = 'http://localhost/howdy.html'; 70 | 71 | isInternalLink.mockImplementation( () => false ); 72 | 73 | expect( getInternalLinkPathname( externalLink ) ).toEqual( externalLink ); 74 | } ); 75 | 76 | it( 'does nothing to malformed or non-HTTP URLs', function () { 77 | const malformedLinks = [ 78 | 'localhost', 79 | 'localhost/howdy.html', 80 | 'http://localhost:port/howdy.html', 81 | 'ftp://localhost/howdy.html', 82 | 'mailto:localhost', 83 | 'suzy@localhost', 84 | ]; 85 | 86 | malformedLinks.forEach( function ( link ) { 87 | expect( getInternalLinkPathname( link ) ).toBe( link ); 88 | } ); 89 | } ); 90 | } ); 91 | -------------------------------------------------------------------------------- /lib/links.ts: -------------------------------------------------------------------------------- 1 | import config from '../next.config'; 2 | import { links } from '../vip.config'; 3 | 4 | const basePathRemover = new RegExp( `^${config.basePath}/*` ); 5 | 6 | function removeBasePath( pathname: string ): string { 7 | if ( config.basePath ) { 8 | return pathname.replace( basePathRemover, '/' ); 9 | } 10 | 11 | return pathname; 12 | } 13 | 14 | /** 15 | * With dynamic routes, Next.js can pass a string or an array of strings. We 16 | * want either the singular string or the last item in the array of strings: 17 | * 18 | * [ '2021', '06', '10', 'my-chickens-let-me-show-you-them' ] 19 | * ^ we want this 20 | */ 21 | export function extractLastTokenFromRoute( routeQuery: string | string[] ): string { 22 | if ( ! Array.isArray( routeQuery ) ) { 23 | return routeQuery; 24 | } 25 | 26 | return routeQuery.slice().pop(); 27 | } 28 | 29 | /** 30 | * Get the correct pathname that respects Next.js config. 31 | */ 32 | function getCorrectPathname ( pathname: string ): string { 33 | // Respect config for `trailingSlash` to avoid infinite redirect loops. 34 | // Account for `basePath` to avoid broken links. 35 | const pathnameRespectingConfig = removeBasePath( pathname.replace( /\/+$/, '' ) ) 36 | if ( config.trailingSlash ) { 37 | return `${pathnameRespectingConfig}/`; 38 | } 39 | 40 | return pathnameRespectingConfig; 41 | } 42 | 43 | /** 44 | * Get the hostname of a URL. 45 | */ 46 | export function getHostname ( url: string ): string { 47 | try { 48 | const { hostname } = new URL( url ); 49 | 50 | return hostname; 51 | } catch ( err ) { /* continue */ } 52 | 53 | return url; 54 | } 55 | 56 | export function getInternalLinkPathname ( url: string ): string { 57 | try { 58 | const { hostname, pathname, protocol, search } = new URL( url ); 59 | 60 | // Determine if the link destination should be considered internal. If so, 61 | // return the relative path to this Next.js site. 62 | if ( [ 'http:', 'https:' ].includes( protocol ) && links.isInternalLink( hostname, pathname ) ) { 63 | return `${getCorrectPathname( pathname )}${search}`; 64 | } 65 | } catch ( err ) { /* continue */ } 66 | 67 | return url; 68 | } 69 | -------------------------------------------------------------------------------- /lib/log.ts: -------------------------------------------------------------------------------- 1 | import { GetServerSidePropsContext, GetStaticPropsContext } from 'next'; 2 | import { randomUUID } from 'crypto' 3 | 4 | enum LogLevel { 5 | DEBUG = 'DEBUG', 6 | INFO = 'INFO', 7 | WARN = 'WARN', 8 | ERROR = 'ERROR', 9 | } 10 | 11 | export type LogContext = { 12 | [ key: string ]: string | number, 13 | }; 14 | 15 | /** 16 | * Generate a new RequestContext that contains the path name that's been requested 17 | * along with the requestID that's added onto the request by the Automattic infrastructure. 18 | * 19 | * In the event that no requestID is found, such as during local development, a UUID is 20 | * generated instead in the form of local-UUID. A new one will be generated each time 21 | * this method is called. 22 | * 23 | * This is scoped to a request, and is meant for populating the requestContext for server side 24 | * requests to non-static resources. 25 | */ 26 | export function generateRequestContext( serverSideContext?: GetServerSidePropsContext | 27 | GetStaticPropsContext ) { 28 | let requestContext: LogContext = { }; 29 | 30 | if (( 'undefined' === typeof window ) && (( serverSideContext as GetServerSidePropsContext ).req !== undefined )) { 31 | const { req } = serverSideContext as GetServerSidePropsContext; 32 | 33 | const sourceId = req.headers[ 'x-request-id' ] || `local-${ randomUUID() }`; 34 | const pathName = req.url; 35 | 36 | requestContext = { 37 | sourceId: `${ sourceId }`, 38 | pathName, 39 | }; 40 | 41 | log( 'RequestContext has been generated', {}, requestContext ); 42 | } 43 | 44 | return requestContext; 45 | } 46 | 47 | export function log( 48 | message: string, 49 | context: LogContext, 50 | requestContext: LogContext = {}, 51 | level: LogLevel = LogLevel.INFO 52 | ) { 53 | console.log( JSON.stringify ( { 54 | context, 55 | level, 56 | message, 57 | requestContext, 58 | timestamp: Math.round( Date.now() / 1000 ), 59 | } ) ); 60 | } 61 | 62 | export function logError( 63 | err: Error, 64 | context: LogContext, 65 | requestContext: LogContext = {}, 66 | ) { 67 | const message = err.message || 'An unknown error occurred'; 68 | 69 | log( message, context, requestContext, LogLevel.ERROR ); 70 | } 71 | -------------------------------------------------------------------------------- /lib/redis/client.ts: -------------------------------------------------------------------------------- 1 | import RedisClient, { Redis } from 'ioredis' 2 | import { log } from '@/lib/log'; 3 | 4 | let redisClient: Redis; 5 | 6 | export default function getRedisClient (): Redis { 7 | if ( process.env.VIP_REDIS_PRIMARY && ! redisClient ) { 8 | const [ host, port ] = process.env.VIP_REDIS_PRIMARY.split( ':' ); 9 | const password = process.env.VIP_REDIS_PASSWORD; 10 | 11 | log( `Redis: Creating client for ${process.env.VIP_REDIS_PRIMARY}`, {} ); 12 | 13 | redisClient = new RedisClient( { 14 | host, 15 | password, 16 | port: parseInt( port, 10 ), 17 | } ); 18 | } 19 | 20 | return redisClient; 21 | } 22 | -------------------------------------------------------------------------------- /lib/redis/index.ts: -------------------------------------------------------------------------------- 1 | import getRedisClient from './client'; 2 | import { log } from '@/lib/log'; 3 | 4 | export async function getCacheObjectByKey ( key: string, ttl: number, fallback: () => Promise ) { 5 | const redisClient = getRedisClient(); 6 | 7 | function logRedisEvent ( message: string ) { 8 | log( `Redis: ${message}`, { key, ttl } ); 9 | } 10 | 11 | if ( ! redisClient ) { 12 | logRedisEvent( 'Not available!' ); 13 | } 14 | 15 | if ( redisClient ) { 16 | logRedisEvent( `Request key "${key}"` ); 17 | 18 | const cachedObject = await redisClient.get( key ); 19 | 20 | if ( cachedObject ) { 21 | logRedisEvent( `Found key "${key}"` ); 22 | 23 | return { 24 | source: 'cache', 25 | data: JSON.parse( cachedObject ) 26 | }; 27 | } 28 | } 29 | 30 | const fallbackObject = await fallback(); 31 | 32 | if ( redisClient ) { 33 | logRedisEvent( `Set key "${key}"` ); 34 | 35 | await redisClient.set( 36 | key, 37 | JSON.stringify( fallbackObject ), 38 | 'EX', 39 | ttl 40 | ); 41 | } 42 | 43 | return { 44 | source: 'fallback', 45 | data: fallbackObject, 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextMiddleware, NextRequest, NextResponse } from 'next/server'; 2 | 3 | // Next.js middleware 4 | // ================== 5 | // https://nextjs.org/docs/middleware 6 | 7 | export const middleware: NextMiddleware = ( req: NextRequest ) => { 8 | // Remove x-middleware-prefetch header to prevent VIP's infrastructure 9 | // from caching empty JSON responses on prefetched data for SSR pages. See the following URLs for more info: 10 | // https://github.com/vercel/next.js/discussions/45997 11 | // https://github.com/vercel/next.js/pull/45772 12 | // https://github.com/vercel/next.js/blob/v13.1.1/packages/next/server/base-server.ts#L1069 13 | const headers = new Headers(req.headers); 14 | headers.delete('x-middleware-prefetch'); 15 | 16 | // Required health check endpoint on VIP. Do not remove. 17 | if ( req.nextUrl.pathname === '/cache-healthcheck' ) { 18 | return NextResponse.rewrite( new URL( '/api/healthcheck', req.url ) ); 19 | } 20 | 21 | // Continue as normal through the Next.js lifecycle. 22 | return NextResponse.next({ 23 | request: { 24 | headers, 25 | }, 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | // next.config.js 2 | // ============== 3 | // https://nextjs.org/docs/api-reference/next.config.js/introduction 4 | // 5 | // IMPORTANT: next.config.js is not parsed by Webpack, Babel, or Typescript. 6 | // Avoid language features that are not available in your target Node.js version. 7 | // Do not change the file extenstion to .ts. 8 | // 9 | // Have fun! 🚀 10 | 11 | const { wordPressEndpoint } = require( './vip.config' ); 12 | 13 | // Next.js currently doesn't have a good way to match all paths including the 14 | // root, so we need to use a special regex path. 15 | const allPathsIncludingRoot = '/:path*{/}?'; 16 | 17 | module.exports = { 18 | // Base path 19 | // ========= 20 | // https://nextjs.org/docs/api-reference/next.config.js/basepath 21 | // 22 | // Setting a base path is not recommend because it prevents us from serving 23 | // files at the root, such as 'robots.txt'. 24 | basePath: '', 25 | 26 | // ESLint 27 | // ====== 28 | // https://nextjs.org/docs/basic-features/eslint 29 | eslint: { 30 | dirs: [ 31 | 'components', 32 | 'graphql', 33 | 'lib', 34 | 'pages', 35 | 'server', 36 | ], 37 | // Warning: Dangerously allow production builds to successfully complete even 38 | // if your project has ESLint errors. This allows us to keep ESLint as a 39 | // dev dependency. 40 | ignoreDuringBuilds: true, 41 | }, 42 | 43 | // Experimental options 44 | // ==================== 45 | // https://github.com/vercel/next.js/blob/canary/packages/next/server/config-shared.ts 46 | experimental: { 47 | // Disable writing to disk in ISR mode. VIP file system is read only, so this 48 | // avoids generating lots of noisy errors in the logs. ISR artifacts are 49 | // still cached in-memory. 50 | isrFlushToDisk: false, 51 | }, 52 | 53 | // Response headers 54 | // ================ 55 | // https://nextjs.org/docs/api-reference/next.config.js/headers 56 | async headers() { 57 | return [ 58 | // The default Cache-Control response headers provided by Next.js out of 59 | // the box do not work well with VIP's edge cache. This config rule gives 60 | // every non-static path a max-age of five minutes, providing much better 61 | // protection from traffic spikes and improving performance overall. 62 | // 63 | // Please do not lower this max-age without talking to VIP. :) 64 | // 65 | // This header is overwritten when using static-while-revalidate, so please 66 | // do not set revalidate lower than 300 without talking to VIP. :) :) 67 | { 68 | source: allPathsIncludingRoot, 69 | headers: [ 70 | { 71 | key: 'Cache-Control', 72 | value: 'public, max-age=300', 73 | }, 74 | ], 75 | }, 76 | ] 77 | }, 78 | 79 | // React strict mode 80 | // ================= 81 | // https://nextjs.org/docs/api-reference/next.config.js/react-strict-mode 82 | // 83 | // Be prepared for future breaking changes in React. 84 | reactStrictMode: true, 85 | 86 | // Redirects 87 | // ========= 88 | // https://nextjs.org/docs/api-reference/next.config.js/redirects 89 | async redirects() { 90 | return [ 91 | { 92 | source: allPathsIncludingRoot, 93 | destination: `${ wordPressEndpoint }/:path*`, 94 | has: [ 95 | { 96 | type: 'query', 97 | key: 'preview', 98 | value: 'true', 99 | }, 100 | ], 101 | permanent: false, 102 | }, 103 | { 104 | source: allPathsIncludingRoot, 105 | destination: `${ wordPressEndpoint }/:path*`, 106 | has: [ 107 | { 108 | type: 'query', 109 | key: 'p', 110 | }, 111 | ], 112 | permanent: false, 113 | }, 114 | ]; 115 | }, 116 | 117 | // Rewrites 118 | // ======== 119 | // https://nextjs.org/docs/api-reference/next.config.js/rewrites 120 | async rewrites() { 121 | return { 122 | // Since we have a fallback route defined at the root (`[[...slug]].tsx`), 123 | // we must apply rewrites before any Next.js routing. 124 | beforeFiles: [ 125 | // Dynamically serve robots.txt. 126 | { 127 | source: '/robots.txt', 128 | destination: '/api/robots', 129 | }, 130 | ], 131 | }; 132 | }, 133 | 134 | // Trailing slash 135 | // ============== 136 | // https://nextjs.org/docs/api-reference/next.config.js/trailing-slash 137 | // 138 | // Setting this value to `true` is not recommended at this time. 139 | // 140 | // Next.js has support for trailing slashes, but its implementation is buggy 141 | // and makes it difficult to satisfy VIP's required health check endpoint -- 142 | // which cannot support a trailing slash. Simply changing this value to `true` 143 | // will result in failed health checks and deploys on VIP. 144 | // 145 | // If your application requires trailing slashes, you will need to implement a 146 | // custom server so that the cache healthcheck endpoint is handled before 147 | // sending the request to Next.js. For an example, see this branch and the 148 | // Next.js documentation. 149 | // 150 | // https://github.com/Automattic/vip-go-nextjs-skeleton/tree/example/custom-server-trailing-slash 151 | // https://nextjs.org/docs/advanced-features/custom-server 152 | trailingSlash: false, 153 | 154 | // Image Optimization 155 | // ================== 156 | // https://nextjs.org/docs/basic-features/image-optimization 157 | // 158 | // The next/image, is an extension of the HTML element, evolved for 159 | // the modern web. It includes a variety of built-in performance 160 | // optimizations to help you achieve good Core Web Vitals. 161 | images: { 162 | // If you know the expected device widths of your users, you can specify a 163 | // list of device width breakpoints using the deviceSizes property here. 164 | // These widths are used to help the next/image component generate srcsets. 165 | deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], 166 | // The reason there are two separate lists is that imageSizes is only used 167 | // for images which provide a sizes prop, which indicates that the image 168 | // is less than the full width of the screen. Therefore, the sizes in 169 | // imageSizes should all be smaller than the smallest size in deviceSizes. 170 | imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], 171 | }, 172 | }; 173 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vip-go-nextjs-boilerplate", 3 | "version": "0.1.0", 4 | "private": true, 5 | "license": "MIT", 6 | "scripts": { 7 | "codegen": "graphql-codegen --config codegen.yml -r dotenv/config", 8 | "codegen:watch": "npm run codegen -- --watch", 9 | "dev": "next dev", 10 | "dev:debug": "NODE_OPTIONS='--inspect' next dev", 11 | "build": "next build", 12 | "lint": "next lint --ignore-path .gitignore", 13 | "lint:fix": "next lint --fix --ignore-path .gitignore", 14 | "prebuild": "npm run codegen", 15 | "predev": "npm run codegen:watch &", 16 | "start": "next start", 17 | "start:debug": "NODE_OPTIONS='--inspect' next dev", 18 | "test": "jest", 19 | "test:watch": "jest --watch", 20 | "vip:preflight": "npx @automattic/vip-go-preflight-checks" 21 | }, 22 | "dependencies": { 23 | "@apollo/client": "3.7.3", 24 | "@graphql-codegen/cli": "2.16.2", 25 | "@graphql-codegen/fragment-matcher": "3.3.3", 26 | "@graphql-codegen/typescript": "2.8.6", 27 | "@graphql-codegen/typescript-operations": "2.5.11", 28 | "@graphql-codegen/typescript-react-apollo": "3.3.7", 29 | "cross-fetch": "3.1.5", 30 | "dotenv": "16.0.3", 31 | "graphql": "16.6.0", 32 | "ioredis": "5.2.4", 33 | "next": "13.5.9", 34 | "react": "18.2.0", 35 | "react-dom": "18.2.0" 36 | }, 37 | "devDependencies": { 38 | "@testing-library/jest-dom": "5.16.5", 39 | "@testing-library/react": "13.4.0", 40 | "@types/jest": "29.2.5", 41 | "@types/react": "18.0.26", 42 | "eslint": "8.31.0", 43 | "eslint-config-next": "13.1.1", 44 | "eslint-plugin-editorconfig": "4.0.2", 45 | "eslint-plugin-jest": "27.2.0", 46 | "jest": "29.3.1", 47 | "jest-environment-jsdom": "29.3.1", 48 | "ts-jest": "29.0.3", 49 | "typescript": "4.9.4" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /pages/[...slug].tsx: -------------------------------------------------------------------------------- 1 | import { GetServerSideProps } from 'next'; 2 | import Page from '@/components/Page/Page'; 3 | import PostContent from '@/components/PostContent/PostContent'; 4 | import getApolloClient from '@/graphql/apollo'; 5 | import { 6 | ContentNodeBySlugDocument, 7 | ContentNodeBySlugQuery, 8 | ContentNodeFieldsFragment, 9 | } from '@/graphql/generated'; 10 | import { extractLastTokenFromRoute, getInternalLinkPathname } from '@/lib/links'; 11 | 12 | export type PostProps = { 13 | loading: boolean, 14 | post: ContentNodeFieldsFragment, 15 | }; 16 | 17 | export default function Post( props: PostProps ) { 18 | if ( 'MediaItem' === props.post.__typename ) { 19 | return null; 20 | } 21 | 22 | return ( 23 |
27 | 29 | ); 30 | } 31 | 32 | export const getServerSideProps: GetServerSideProps28 | = async ( context ) => { 33 | const queryOptions = { 34 | query: ContentNodeBySlugDocument, 35 | variables: { 36 | slug: extractLastTokenFromRoute( context.query.slug ), 37 | }, 38 | }; 39 | 40 | const { data, loading } = await getApolloClient( context ).query ( queryOptions ); 41 | 42 | // @TODO Disambiguate multiple slug matches. 43 | const post = data.contentNodes?.nodes?.[0]; 44 | 45 | // SEO: Resource not found pages must send a 404 response code. 46 | if ( ! loading && ! post ) { 47 | return { 48 | notFound: true, 49 | }; 50 | } 51 | 52 | // SEO: Redirect to canonical URL. 53 | const internalLinkPathname = getInternalLinkPathname( post.link ); 54 | const resolvedUrlWithoutQueryString = context.resolvedUrl.split( '?' )[0]; 55 | if ( ! loading && internalLinkPathname !== resolvedUrlWithoutQueryString ) { 56 | return { 57 | redirect: { 58 | destination: internalLinkPathname || post.link, 59 | permanent: false, 60 | }, 61 | }; 62 | } 63 | 64 | return { 65 | props: { 66 | loading, 67 | post, 68 | }, 69 | }; 70 | } 71 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import useInternalLinkRouting from '@/lib/hooks/useInternalLinkRouting'; 2 | import '@/styles/new.css'; 3 | 4 | export default function App( { Component, pageProps } ) { 5 | useInternalLinkRouting(); 6 | 7 | return ; 8 | } 9 | -------------------------------------------------------------------------------- /pages/api/books.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import fetch from 'cross-fetch'; 3 | import { log, logError } from '@/lib/log'; 4 | import { getCacheObjectByKey } from '@/lib/redis'; 5 | 6 | /** 7 | * Find out if the Internet Archive has a book. 8 | */ 9 | export default async function handler( req: NextApiRequest, res: NextApiResponse ) { 10 | // Don Quixote (Penguin Classics, English) 11 | // By MIGUEL DE CERVANTES SAAVEDRA 12 | // Introduction by Roberto Gonzalez Echevarria 13 | // Translated by John Rutherford 14 | // Notes by John Rutherford 15 | // https://www.penguinrandomhouse.com/books/286572/ 16 | const defaultIsbn = '0142437239'; 17 | 18 | const isbn = `${ req.query.isbn || defaultIsbn }`; 19 | const cacheKey = `internet_archive_isbn_${ isbn }`; 20 | const ttl = 30; 21 | 22 | // Fallback function to fetch the book when cache object is not available. 23 | async function fallback () { 24 | log( 'Fetching book by ISBN', { isbn } ); 25 | 26 | const baseUrl = 'https://archive.org/services/book/v1/do_we_have_it/'; 27 | const url = `${ baseUrl }?isbn=${ isbn }&include_unscanned_books=true`; 28 | const response = await fetch( url ); 29 | 30 | return response.json(); 31 | } 32 | 33 | try { 34 | const book = await getCacheObjectByKey( cacheKey, ttl, fallback ); 35 | return res.status( 200 ).send( book ); 36 | } catch ( err ) { 37 | logError( err, {} ); 38 | 39 | return res.status( 500 ).send( err ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /pages/api/healthcheck.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | 3 | export default async function handler( req: NextApiRequest, res: NextApiResponse ) { 4 | const isGET = [ 'get', 'head' ].includes( req.method.toLowerCase() ); 5 | if ( ! isGET ) { 6 | return res.status( 404 ).send( 'Not found' ); 7 | } 8 | 9 | res.setHeader( 'content-type', 'text/plain' ); 10 | 11 | return res.status( 200 ).send( 'ok' ); 12 | } 13 | -------------------------------------------------------------------------------- /pages/api/robots.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { wordPressEndpoint } from '../../vip.config'; 3 | 4 | const robotsTxt = ` 5 | User-agent: * 6 | 7 | Allow: * 8 | Disallow: /api/* 9 | 10 | Sitemap: ${wordPressEndpoint}/wp-sitemap.xml 11 | `.trim(); 12 | 13 | export default async function handler( req: NextApiRequest, res: NextApiResponse ) { 14 | const isGET = [ 'get', 'head' ].includes( req.method.toLowerCase() ); 15 | if ( ! isGET ) { 16 | return res.status( 404 ).send( 'Not found' ); 17 | } 18 | 19 | res.setHeader( 'content-type', 'text/plain' ); 20 | 21 | return res.status( 200 ).send( robotsTxt ); 22 | } 23 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { GetStaticProps } from 'next'; 2 | import Link from 'next/link'; 3 | import Card from '@/components/Card/Card'; 4 | import Page from '@/components/Page/Page'; 5 | import getApolloClient from '@/graphql/apollo'; 6 | import { 7 | AllContentTypesDocument, 8 | AllContentTypesQuery, 9 | ContentTypeFieldsFragment, 10 | } from '@/graphql/generated'; 11 | 12 | type Props = { 13 | contentTypes: ContentTypeFieldsFragment[], 14 | }; 15 | 16 | export default function Home( props: Props ) { 17 | return ( 18 | 21 | 50 | ); 51 | } 52 | 53 | export const getStaticProps: GetStaticPropsThis decoupled WordPress site is built with WordPress VIP’s Next.js boilerplate and decoupled plugin bundle. If you’re seeing this page, it means your decoupled site has been successfully deployed. Please take a moment to read through this introduction, which supplements our public documentation and the
22 | 23 | 30 | 31 |README
of this repo.Getting started
32 |This boilerplate provides some basic functionality out-of-the-box, allowing you to view and preview your content. Explore the pages linked below and examine the code and GraphQL queries that power them. Feel free to delete this sample code or extend it for your own purposes.
33 |34 | 47 |Your content
35 |36 | { 37 | props.contentTypes 38 | .map( contentType => ( 39 |
46 |- 40 | {contentType.name} 41 |
42 | ) ) 43 | } 44 |- media library
45 |Previewing
48 |Previewing unpublished posts or updates to published posts works out of the box. Simply click the “Preview” button in WordPress and you’ll be redirected to a one-time-use preview link on this decoupled site.
49 |= async ( context ) => { 54 | const queryOptions = { 55 | query: AllContentTypesDocument, 56 | }; 57 | 58 | const { data } = await getApolloClient( context ).query ( queryOptions ); 59 | 60 | const contentTypes = data.contentTypes.nodes || []; 61 | 62 | return { 63 | props: { 64 | contentTypes: contentTypes.filter( contentType => contentType.contentNodes.nodes.length ), 65 | }, 66 | }; 67 | }; 68 | -------------------------------------------------------------------------------- /pages/latest/[content_type].tsx: -------------------------------------------------------------------------------- 1 | import { GetServerSideProps } from 'next'; 2 | import Page from '@/components/Page/Page'; 3 | import PostList from '@/components/PostList/PostList'; 4 | import getApolloClient from '@/graphql/apollo'; 5 | import { 6 | ContentNodeFieldsFragment, 7 | ContentTypeByNameDocument, 8 | ContentTypeByNameQuery, 9 | ContentTypeByNameQueryVariables, 10 | } from '@/graphql/generated'; 11 | 12 | type Props = { 13 | loading: boolean, 14 | nextPageLink?: string, 15 | posts: ContentNodeFieldsFragment[], 16 | previousPageLink?: string, 17 | title: string, 18 | }; 19 | 20 | export default function ContentNodes( props: Props ) { 21 | return ( 22 | 26 | 32 | ); 33 | } 34 | 35 | type ContextParams = { 36 | content_type: string, 37 | } 38 | 39 | export const getServerSideProps: GetServerSideProps31 | = async ( context ) => { 40 | const queryParams = { ...context.query }; 41 | const variables: ContentTypeByNameQueryVariables = { 42 | name: context.params.content_type, 43 | }; 44 | 45 | // Process pagination requests 46 | if ( context.query.before ) { 47 | variables.before = `${ queryParams.before }`; 48 | variables.last = 10; 49 | } else { 50 | variables.after = `${ queryParams.after }`; 51 | variables.first = 10; 52 | } 53 | 54 | const queryOptions = { 55 | query: ContentTypeByNameDocument, 56 | variables, 57 | }; 58 | 59 | const { data, error, loading } = await getApolloClient( context ).query ( queryOptions ); 60 | 61 | const posts = data.contentType?.contentNodes?.nodes || []; 62 | const title = data.contentType?.description; 63 | 64 | // Extract pagination information and build pagination links. 65 | const { 66 | endCursor, 67 | hasNextPage, 68 | hasPreviousPage, 69 | startCursor, 70 | } = data.contentType?.contentNodes?.pageInfo || {}; 71 | 72 | let nextPageLink = null; 73 | if ( hasNextPage ) { 74 | const newQueryParams = new URLSearchParams( { ...queryParams, after: endCursor } ); 75 | newQueryParams.delete( 'before' ); 76 | nextPageLink = `?${ newQueryParams.toString() }`; 77 | } 78 | 79 | let previousPageLink = null; 80 | if ( hasPreviousPage ) { 81 | const newQueryParams = new URLSearchParams( { ...queryParams, before: startCursor } ); 82 | newQueryParams.delete( 'after' ); 83 | previousPageLink = `?${ newQueryParams.toString() }`; 84 | } 85 | 86 | // SEO: Resource not found pages must send a 404 response code. 87 | if ( error || ! loading && ! posts.length ) { 88 | return { 89 | notFound: true, 90 | }; 91 | } 92 | 93 | return { 94 | props: { 95 | loading, 96 | nextPageLink, 97 | posts, 98 | previousPageLink, 99 | title, 100 | }, 101 | }; 102 | } 103 | -------------------------------------------------------------------------------- /pages/media/index.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | column-gap: 1em; 3 | display: grid; 4 | grid-template-columns: 1fr 1fr 1fr; 5 | row-gap: 1em; 6 | } 7 | 8 | .image { 9 | object-fit: cover; 10 | } 11 | 12 | .image-link { 13 | background-color: #ccc; 14 | border: solid 1px #000; 15 | display: block; 16 | height: 100px; 17 | overflow: hidden; 18 | position: relative; 19 | } 20 | -------------------------------------------------------------------------------- /pages/media/index.tsx: -------------------------------------------------------------------------------- 1 | import { GetServerSideProps } from 'next'; 2 | import Image from '@/components/Image/Image'; 3 | import Page from '@/components/Page/Page'; 4 | import getApolloClient from '@/graphql/apollo'; 5 | import { 6 | AllMediaItemsDocument, 7 | AllMediaItemsQuery, 8 | MediaItemFieldsFragment, 9 | } from '@/graphql/generated'; 10 | import styles from './index.module.css'; 11 | 12 | type Props = { 13 | loading: boolean, 14 | mediaItems: MediaItemFieldsFragment[], 15 | }; 16 | 17 | export default function Media( props: Props ) { 18 | return ( 19 | 23 | 54 | ); 55 | } 56 | 57 | export const getServerSideProps: GetServerSideProps24 | { 25 | props.mediaItems.map( mediaItem => { 26 | const { 27 | altText = '', 28 | id, 29 | sourceUrl, 30 | } = mediaItem; 31 | 32 | // Each image is displayed in a fixed-height box of 100px. If the 33 | // actual height of the image is less than 100px, then use 34 | return ( 35 | 42 |53 |48 | 49 | ); 50 | } ) 51 | } 52 | = async ( context ) => { 58 | const queryOptions = { 59 | query: AllMediaItemsDocument, 60 | }; 61 | 62 | const { data, loading } = await getApolloClient( context ).query ( queryOptions ); 63 | 64 | const mediaItems = data.mediaItems?.nodes; 65 | 66 | return { 67 | props: { 68 | loading, 69 | mediaItems, 70 | }, 71 | }; 72 | } 73 | -------------------------------------------------------------------------------- /pages/preview/[token]/[id].tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { GetServerSideProps } from 'next'; 3 | import { FetchPolicy } from '@apollo/client'; 4 | import getApolloClient from '@/graphql/apollo'; 5 | import { 6 | ContentNodePreviewByIdDocument, 7 | ContentNodePreviewByIdQuery, 8 | } from '@/graphql/generated'; 9 | import Post from '@/pages/[...slug]'; 10 | 11 | // Mirror Post props. 12 | type PreviewProps = React.ComponentProps ; 13 | 14 | // Pass through props to Post component. This ensures that previews have parity 15 | // with published posts. 16 | export default function PostPreview( props: PreviewProps ) { 17 | return ; 18 | } 19 | 20 | export const getServerSideProps: GetServerSideProps = async ( context ) => { 21 | const queryOptions = { 22 | context: { 23 | headers: { 24 | // Echo the token provided in the URL as a request header, which will be 25 | // validated by the WPGraphQL Preview plugin. 26 | 'X-Preview-Token': context.query.token, 27 | }, 28 | }, 29 | // Do not cache preview query responses. 30 | fetchPolicy: 'no-cache' as FetchPolicy, 31 | query: ContentNodePreviewByIdDocument, 32 | variables: { 33 | id: context.query.id, 34 | }, 35 | }; 36 | 37 | const { data, loading } = await getApolloClient( context ).query ( queryOptions ); 38 | 39 | const post = data.contentNode; 40 | 41 | // SEO: Resource not found pages must send a 404 response code. 42 | if ( ! loading && ! post ) { 43 | return { 44 | notFound: true, 45 | }; 46 | } 47 | 48 | return { 49 | props: { 50 | loading, 51 | post, 52 | }, 53 | }; 54 | } 55 | -------------------------------------------------------------------------------- /pages/search/index.tsx: -------------------------------------------------------------------------------- 1 | import { GetServerSideProps } from 'next'; 2 | import Page from '@/components/Page/Page'; 3 | import PostList from '@/components/PostList/PostList'; 4 | import SearchForm from '@/components/SearchForm/SearchForm'; 5 | import getApolloClient from '@/graphql/apollo'; 6 | import { 7 | ContentNodeFieldsFragment, 8 | ContentNodesBySearchTermDocument, 9 | ContentNodesBySearchTermQuery, 10 | ContentNodesBySearchTermQueryVariables, 11 | } from '@/graphql/generated'; 12 | 13 | type Props = { 14 | loading: boolean, 15 | nextPageLink?: string, 16 | posts: ContentNodeFieldsFragment[], 17 | previousPageLink?: string, 18 | search: string, 19 | }; 20 | 21 | export default function Search( props: Props ) { 22 | return ( 23 | 27 | 37 | ); 38 | } 39 | 40 | type ContextParams = Record31 | 36 | ; 41 | 42 | export const getServerSideProps: GetServerSideProps = async ( context ) => { 43 | const queryParams = { ...context.query }; 44 | 45 | if ( ! queryParams.s ) { 46 | // The user has not searched yet. 47 | return { 48 | props: { 49 | loading: false, 50 | posts: [], 51 | search: '', 52 | }, 53 | }; 54 | } 55 | 56 | const search = `${ queryParams.s }`.trim(); 57 | const variables: ContentNodesBySearchTermQueryVariables = { 58 | search, 59 | }; 60 | 61 | // Process pagination requests 62 | if ( queryParams.before ) { 63 | variables.before = `${ queryParams.before }`; 64 | variables.last = 10; 65 | } else { 66 | variables.after = `${ queryParams.after }`; 67 | variables.first = 10; 68 | } 69 | 70 | const queryOptions = { 71 | query: ContentNodesBySearchTermDocument, 72 | variables, 73 | }; 74 | 75 | const { data, error, loading } = await getApolloClient( context ).query ( queryOptions ); 76 | 77 | if ( error ) { 78 | throw error; 79 | } 80 | 81 | const posts = data.contentNodes?.nodes || []; 82 | 83 | // Extract pagination information and build pagination links. 84 | const { 85 | endCursor, 86 | hasNextPage, 87 | hasPreviousPage, 88 | startCursor, 89 | } = data.contentNodes?.pageInfo || {}; 90 | 91 | let nextPageLink = null; 92 | if ( hasNextPage ) { 93 | const newQueryParams = new URLSearchParams( { ...queryParams, after: endCursor } ); 94 | newQueryParams.delete( 'before' ); 95 | nextPageLink = `?${ newQueryParams.toString() }`; 96 | } 97 | 98 | let previousPageLink = null; 99 | if ( hasPreviousPage ) { 100 | const newQueryParams = new URLSearchParams( { ...queryParams, before: startCursor } ); 101 | newQueryParams.delete( 'after' ); 102 | previousPageLink = `?${ newQueryParams.toString() }`; 103 | } 104 | 105 | return { 106 | props: { 107 | loading, 108 | nextPageLink, 109 | posts, 110 | previousPageLink, 111 | search, 112 | }, 113 | }; 114 | } 115 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/vip-go-nextjs-skeleton/a84fb794ab5c5f257f95d629326902363a2e501b/public/favicon.ico -------------------------------------------------------------------------------- /styles/new.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --nc-font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 3 | --nc-font-mono: Consolas, monaco, 'Ubuntu Mono', 'Liberation Mono', 'Courier New', Courier, monospace; 4 | --nc-tx-1: #000000; 5 | --nc-tx-2: #1A1A1A; 6 | --nc-bg-1: #FFFFFF; 7 | --nc-bg-2: #F6F8FA; 8 | --nc-bg-3: #E5E7EB; 9 | --nc-lk-1: #0070F3; 10 | --nc-lk-2: #0366D6; 11 | --nc-lk-tx: #FFFFFF; 12 | --nc-ac-1: #79FFE1; 13 | --nc-ac-tx: #0C4047; 14 | } 15 | 16 | @media (prefers-color-scheme: dark) { 17 | :root { 18 | --nc-tx-1: #ffffff; 19 | --nc-tx-2: #eeeeee; 20 | --nc-bg-1: #000000; 21 | --nc-bg-2: #111111; 22 | --nc-bg-3: #222222; 23 | --nc-lk-1: #3291FF; 24 | --nc-lk-2: #0070F3; 25 | --nc-lk-tx: #FFFFFF; 26 | --nc-ac-1: #7928CA; 27 | --nc-ac-tx: #FFFFFF; 28 | } 29 | } 30 | 31 | * { 32 | /* Reset margins and padding */ 33 | margin: 0; 34 | padding: 0; 35 | } 36 | 37 | address, 38 | area, 39 | article, 40 | aside, 41 | audio, 42 | blockquote, 43 | datalist, 44 | details, 45 | dl, 46 | fieldset, 47 | figure, 48 | form, 49 | input, 50 | iframe, 51 | img, 52 | meter, 53 | nav, 54 | ol, 55 | optgroup, 56 | option, 57 | output, 58 | p, 59 | pre, 60 | progress, 61 | ruby, 62 | section, 63 | table, 64 | textarea, 65 | ul, 66 | video { 67 | /* Margins for most elements */ 68 | margin-bottom: 1rem; 69 | } 70 | 71 | html,input,select,button { 72 | /* Set body font family and some finicky elements */ 73 | font-family: var(--nc-font-sans); 74 | } 75 | 76 | body { 77 | /* Center body in page */ 78 | margin: 0 auto; 79 | max-width: 750px; 80 | padding: 2rem; 81 | border-radius: 6px; 82 | overflow-x: hidden; 83 | word-break: break-word; 84 | overflow-wrap: break-word; 85 | background: var(--nc-bg-1); 86 | 87 | /* Main body text */ 88 | color: var(--nc-tx-2); 89 | font-size: 1.03rem; 90 | line-height: 1.5; 91 | } 92 | 93 | ::selection { 94 | /* Set background color for selected text */ 95 | background: var(--nc-ac-1); 96 | color: var(--nc-ac-tx); 97 | } 98 | 99 | h1,h2,h3,h4,h5,h6 { 100 | line-height: 1; 101 | color: var(--nc-tx-1); 102 | padding-top: .875rem; 103 | } 104 | 105 | h1, 106 | h2, 107 | h3 { 108 | color: var(--nc-tx-1); 109 | padding-bottom: 2px; 110 | margin-bottom: 8px; 111 | border-bottom: 1px solid var(--nc-bg-2); 112 | } 113 | 114 | h4, 115 | h5, 116 | h6 { 117 | margin-bottom: .3rem; 118 | } 119 | 120 | h1 { 121 | font-size: 2.25rem; 122 | } 123 | 124 | h2 { 125 | font-size: 1.85rem; 126 | } 127 | 128 | h3 { 129 | font-size: 1.55rem; 130 | } 131 | 132 | h4 { 133 | font-size: 1.25rem; 134 | } 135 | 136 | h5 { 137 | font-size: 1rem; 138 | } 139 | 140 | h6 { 141 | font-size: .875rem; 142 | } 143 | 144 | a { 145 | color: var(--nc-lk-1); 146 | } 147 | 148 | a:hover { 149 | color: var(--nc-lk-2); 150 | } 151 | 152 | abbr:hover { 153 | /* Set the '?' cursor while hovering an abbreviation */ 154 | cursor: help; 155 | } 156 | 157 | blockquote { 158 | padding: 1.5rem; 159 | background: var(--nc-bg-2); 160 | border-left: 5px solid var(--nc-bg-3); 161 | } 162 | 163 | abbr { 164 | cursor: help; 165 | } 166 | 167 | blockquote *:last-child { 168 | padding-bottom: 0; 169 | margin-bottom: 0; 170 | } 171 | 172 | header { 173 | background: var(--nc-bg-2); 174 | border-bottom: 1px solid var(--nc-bg-3); 175 | padding: 2rem 1.5rem; 176 | 177 | /* This sets the right and left margins to cancel out the body's margins. It's width is still the same, but the background stretches across the page's width. */ 178 | 179 | margin: -2rem calc(0px - (50vw - 50%)) 2rem; 180 | 181 | /* Shorthand for: 182 | 183 | margin-top: -2rem; 184 | margin-bottom: 2rem; 185 | 186 | margin-left: calc(0px - (50vw - 50%)); 187 | margin-right: calc(0px - (50vw - 50%)); */ 188 | 189 | padding-left: calc(50vw - 50%); 190 | padding-right: calc(50vw - 50%); 191 | } 192 | 193 | header h1, 194 | header h2, 195 | header h3 { 196 | padding-bottom: 0; 197 | border-bottom: 0; 198 | } 199 | 200 | header > *:first-child { 201 | margin-top: 0; 202 | padding-top: 0; 203 | } 204 | 205 | header > *:last-child { 206 | margin-bottom: 0; 207 | } 208 | 209 | a button, 210 | button, 211 | input[type="submit"], 212 | input[type="reset"], 213 | input[type="button"] { 214 | font-size: 1rem; 215 | display: inline-block; 216 | padding: 6px 12px; 217 | text-align: center; 218 | text-decoration: none; 219 | white-space: nowrap; 220 | background: var(--nc-lk-1); 221 | color: var(--nc-lk-tx); 222 | border: 0; 223 | border-radius: 4px; 224 | box-sizing: border-box; 225 | cursor: pointer; 226 | color: var(--nc-lk-tx); 227 | } 228 | 229 | a button[disabled], 230 | button[disabled], 231 | input[type="submit"][disabled], 232 | input[type="reset"][disabled], 233 | input[type="button"][disabled] { 234 | cursor: default; 235 | opacity: .5; 236 | 237 | /* Set the [X] cursor while hovering a disabled link */ 238 | cursor: not-allowed; 239 | } 240 | 241 | .button:focus, 242 | .button:hover, 243 | button:focus, 244 | button:hover, 245 | input[type="submit"]:focus, 246 | input[type="submit"]:hover, 247 | input[type="reset"]:focus, 248 | input[type="reset"]:hover, 249 | input[type="button"]:focus, 250 | input[type="button"]:hover { 251 | background: var(--nc-lk-2); 252 | } 253 | 254 | code, 255 | pre, 256 | kbd, 257 | samp { 258 | /* Set the font family for monospaced elements */ 259 | font-family: var(--nc-font-mono); 260 | } 261 | 262 | code, 263 | samp, 264 | kbd, 265 | pre { 266 | /* The main preformatted style. This is changed slightly across different cases. */ 267 | background: var(--nc-bg-2); 268 | border: 1px solid var(--nc-bg-3); 269 | border-radius: 4px; 270 | padding: 3px 6px; 271 | font-size: 0.9rem; 272 | } 273 | 274 | kbd { 275 | /* Makes the kbd element look like a keyboard key */ 276 | border-bottom: 3px solid var(--nc-bg-3); 277 | } 278 | 279 | pre { 280 | padding: 1rem 1.4rem; 281 | max-width: 100%; 282 | overflow: auto; 283 | } 284 | 285 | pre code { 286 | /* When is in a
, reset it's formatting to blend in */ 287 | background: inherit; 288 | font-size: inherit; 289 | color: inherit; 290 | border: 0; 291 | padding: 0; 292 | margin: 0; 293 | } 294 | 295 | code pre { 296 | /* Whenis in a, reset it's formatting to blend in */ 297 | display: inline; 298 | background: inherit; 299 | font-size: inherit; 300 | color: inherit; 301 | border: 0; 302 | padding: 0; 303 | margin: 0; 304 | } 305 | 306 | details { 307 | /* Make the
look more "clickable" */ 308 | padding: .6rem 1rem; 309 | background: var(--nc-bg-2); 310 | border: 1px solid var(--nc-bg-3); 311 | border-radius: 4px; 312 | } 313 | 314 | summary { 315 | /* Makes thelook more like a "clickable" link with the pointer cursor */ 316 | cursor: pointer; 317 | font-weight: bold; 318 | } 319 | 320 | details[open] { 321 | /* Adjust the
padding while open */ 322 | padding-bottom: .75rem; 323 | } 324 | 325 | details[open] summary { 326 | /* Adjust thepadding while open */ 327 | margin-bottom: 6px; 328 | } 329 | 330 | details[open]>*:last-child { 331 | /* Resets the bottom margin of the last element in thewhileis opened. This prevents double margins/paddings. */ 332 | margin-bottom: 0; 333 | } 334 | 335 | dt { 336 | font-weight: bold; 337 | } 338 | 339 | dd::before { 340 | /* Add an arrow to data table definitions */ 341 | content: '→ '; 342 | } 343 | 344 | hr { 345 | /* Reset the border of the
separator, then set a better line */ 346 | border: 0; 347 | border-bottom: 1px solid var(--nc-bg-3); 348 | margin: 1rem auto; 349 | } 350 | 351 | fieldset { 352 | margin-top: 1rem; 353 | padding: 2rem; 354 | border: 1px solid var(--nc-bg-3); 355 | border-radius: 4px; 356 | } 357 | 358 | legend { 359 | padding: auto .5rem; 360 | } 361 | 362 | table { 363 | /* border-collapse sets the table's elements to share borders, rather than floating as separate "boxes". */ 364 | border-collapse: collapse; 365 | width: 100% 366 | } 367 | 368 | td, 369 | th { 370 | border: 1px solid var(--nc-bg-3); 371 | text-align: left; 372 | padding: .5rem; 373 | } 374 | 375 | th { 376 | background: var(--nc-bg-2); 377 | } 378 | 379 | tr:nth-child(even) { 380 | /* Set every other cell slightly darker. Improves readability. */ 381 | background: var(--nc-bg-2); 382 | } 383 | 384 | table caption { 385 | font-weight: bold; 386 | margin-bottom: .5rem; 387 | } 388 | 389 | textarea { 390 | /* Don't let the