From cf9496fdbb966f6935f056e0a21333c1a28c9fd1 Mon Sep 17 00:00:00 2001 From: Johannes Obermair <48853629+johnnyomair@users.noreply.github.com> Date: Mon, 8 Jul 2024 12:25:32 +0200 Subject: [PATCH] Re-add support for Pages Router (#2179) --------- Co-authored-by: Niko Sams --- .changeset/config.json | 10 +- .changeset/popular-shirts-pretend.md | 7 + .changeset/pre.json | 1 + .env | 9 +- .github/workflows/lint.yml | 23 +- copy-schema-files.js | 3 + demo/api/src/app.module.ts | 1 + demo/site-pages/.babelrc | 4 + demo/site-pages/.eslintrc.cli.js | 3 + demo/site-pages/.eslintrc.json | 7 + demo/site-pages/.gitignore | 52 ++ demo/site-pages/.prettierrc.json | 6 + demo/site-pages/README.md | 30 ++ demo/site-pages/apollo.config.js | 9 + demo/site-pages/codegen.ts | 49 ++ demo/site-pages/ecosystem.config.js | 12 + demo/site-pages/intl-update.sh | 8 + demo/site-pages/lint-staged.config.js | 4 + demo/site-pages/next-env.d.ts | 5 + demo/site-pages/next.config.js | 61 +++ demo/site-pages/package.json | 80 +++ demo/site-pages/preBuild/.gitignore | 1 + .../preBuild/src/createRedirects.ts | 136 +++++ .../site-pages/preBuild/src/createRewrites.ts | 27 + .../createPublicGeneratedDirectory.ts | 14 + .../preBuild/src/publicGenerator/extract.ts | 37 ++ .../preBuild/src/publicGenerator/generate.ts | 10 + .../src/publicGenerator/robots.txt.ts | 15 + .../src/publicGenerator/sitemap.xml.ts | 108 ++++ demo/site-pages/public/arrow-grey-small.svg | 3 + demo/site-pages/public/arrow-left.svg | 3 + demo/site-pages/public/arrow-link.svg | 1 + demo/site-pages/public/arrow-right-black.svg | 3 + .../public/arrow-right-dark-grey.svg | 3 + demo/site-pages/public/arrow-right-grey.svg | 3 + demo/site-pages/public/arrow-right.svg | 3 + demo/site-pages/public/chevron-down.svg | 3 + demo/site-pages/public/chevron-right.svg | 3 + demo/site-pages/public/close.svg | 3 + demo/site-pages/public/favicon.ico | Bin 0 -> 15086 bytes demo/site-pages/public/filter.svg | 3 + demo/site-pages/public/mobile-menu.svg | 3 + demo/site-pages/public/mousescroll-body.svg | 9 + demo/site-pages/public/vercel.svg | 4 + demo/site-pages/server.js | 48 ++ demo/site-pages/src/blocks/AnchorBlock.tsx | 16 + .../src/blocks/ColorThemeContext.tsx | 13 + demo/site-pages/src/blocks/ColumnsBlock.tsx | 49 ++ demo/site-pages/src/blocks/DamImageBlock.tsx | 49 ++ .../src/blocks/FullWidthImageBlock.tsx | 42 ++ demo/site-pages/src/blocks/HeadlineBlock.tsx | 45 ++ demo/site-pages/src/blocks/LinkBlock.tsx | 50 ++ demo/site-pages/src/blocks/LinkListBlock.tsx | 12 + demo/site-pages/src/blocks/MediaBlock.tsx | 17 + .../src/blocks/PageContentBlock.tsx | 37 ++ .../site-pages/src/blocks/RichTextBlock.sc.ts | 24 + demo/site-pages/src/blocks/RichTextBlock.tsx | 88 ++++ demo/site-pages/src/blocks/SpaceBlock.tsx | 22 + demo/site-pages/src/blocks/TextImageBlock.tsx | 26 + demo/site-pages/src/blocks/TextLinkBlock.tsx | 16 + demo/site-pages/src/blocks/TwoListsBlock.tsx | 19 + .../src/components/Breadcrumbs.sc.ts | 26 + .../site-pages/src/components/Breadcrumbs.tsx | 42 ++ .../src/components/common/GridRoot.tsx | 6 + .../common/NextImageBottomPaddingFix.tsx | 10 + demo/site-pages/src/config.ts | 25 + .../documents/pages/blocks/TeaserBlock.tsx | 29 ++ demo/site-pages/src/header/Header.tsx | 99 ++++ demo/site-pages/src/header/PageLink.tsx | 69 +++ .../src/news/blocks/NewsLinkBlock.tsx | 20 + demo/site-pages/src/pageTypes/Page.tsx | 72 +++ demo/site-pages/src/pages/404.tsx | 13 + demo/site-pages/src/pages/[[...path]].tsx | 116 +++++ demo/site-pages/src/pages/_app.tsx | 82 +++ demo/site-pages/src/pages/_document.tsx | 52 ++ demo/site-pages/src/pages/api/site-preview.ts | 9 + demo/site-pages/src/pages/api/status.tsx | 7 + .../src/pages/block-preview/main-menu.tsx | 20 + .../src/pages/block-preview/page.tsx | 19 + .../predefinedPages/predefinedPagePaths.ts | 3 + demo/site-pages/src/redraft.d.ts | 25 + demo/site-pages/src/theme.ts | 102 ++++ .../src/topNavigation/TopNavigation.tsx | 85 +++ .../src/util/createGraphQLClient.ts | 44 ++ demo/site-pages/src/vendor.d.ts | 6 + demo/site-pages/tracing.js | 27 + demo/site-pages/tsconfig.json | 30 ++ demo/site-pages/tsconfig.preBuild.json | 18 + dev-pm.config.js | 20 + install.sh | 7 + packages/site/cms-site/src/index.ts | 1 + .../src/sitePreview/SitePreviewUtils.ts | 2 +- .../legacyPagesRouterSitePreviewApiHandler.ts | 50 ++ pnpm-lock.yaml | 490 ++++++++++++------ 94 files changed, 2814 insertions(+), 164 deletions(-) create mode 100644 .changeset/popular-shirts-pretend.md create mode 100644 demo/site-pages/.babelrc create mode 100644 demo/site-pages/.eslintrc.cli.js create mode 100644 demo/site-pages/.eslintrc.json create mode 100644 demo/site-pages/.gitignore create mode 100644 demo/site-pages/.prettierrc.json create mode 100644 demo/site-pages/README.md create mode 100644 demo/site-pages/apollo.config.js create mode 100644 demo/site-pages/codegen.ts create mode 100644 demo/site-pages/ecosystem.config.js create mode 100755 demo/site-pages/intl-update.sh create mode 100644 demo/site-pages/lint-staged.config.js create mode 100644 demo/site-pages/next-env.d.ts create mode 100644 demo/site-pages/next.config.js create mode 100644 demo/site-pages/package.json create mode 100644 demo/site-pages/preBuild/.gitignore create mode 100644 demo/site-pages/preBuild/src/createRedirects.ts create mode 100644 demo/site-pages/preBuild/src/createRewrites.ts create mode 100644 demo/site-pages/preBuild/src/publicGenerator/createPublicGeneratedDirectory.ts create mode 100644 demo/site-pages/preBuild/src/publicGenerator/extract.ts create mode 100644 demo/site-pages/preBuild/src/publicGenerator/generate.ts create mode 100644 demo/site-pages/preBuild/src/publicGenerator/robots.txt.ts create mode 100644 demo/site-pages/preBuild/src/publicGenerator/sitemap.xml.ts create mode 100644 demo/site-pages/public/arrow-grey-small.svg create mode 100644 demo/site-pages/public/arrow-left.svg create mode 100644 demo/site-pages/public/arrow-link.svg create mode 100644 demo/site-pages/public/arrow-right-black.svg create mode 100644 demo/site-pages/public/arrow-right-dark-grey.svg create mode 100644 demo/site-pages/public/arrow-right-grey.svg create mode 100644 demo/site-pages/public/arrow-right.svg create mode 100644 demo/site-pages/public/chevron-down.svg create mode 100644 demo/site-pages/public/chevron-right.svg create mode 100644 demo/site-pages/public/close.svg create mode 100644 demo/site-pages/public/favicon.ico create mode 100644 demo/site-pages/public/filter.svg create mode 100644 demo/site-pages/public/mobile-menu.svg create mode 100644 demo/site-pages/public/mousescroll-body.svg create mode 100644 demo/site-pages/public/vercel.svg create mode 100644 demo/site-pages/server.js create mode 100644 demo/site-pages/src/blocks/AnchorBlock.tsx create mode 100644 demo/site-pages/src/blocks/ColorThemeContext.tsx create mode 100644 demo/site-pages/src/blocks/ColumnsBlock.tsx create mode 100644 demo/site-pages/src/blocks/DamImageBlock.tsx create mode 100644 demo/site-pages/src/blocks/FullWidthImageBlock.tsx create mode 100644 demo/site-pages/src/blocks/HeadlineBlock.tsx create mode 100644 demo/site-pages/src/blocks/LinkBlock.tsx create mode 100644 demo/site-pages/src/blocks/LinkListBlock.tsx create mode 100644 demo/site-pages/src/blocks/MediaBlock.tsx create mode 100644 demo/site-pages/src/blocks/PageContentBlock.tsx create mode 100644 demo/site-pages/src/blocks/RichTextBlock.sc.ts create mode 100644 demo/site-pages/src/blocks/RichTextBlock.tsx create mode 100644 demo/site-pages/src/blocks/SpaceBlock.tsx create mode 100644 demo/site-pages/src/blocks/TextImageBlock.tsx create mode 100644 demo/site-pages/src/blocks/TextLinkBlock.tsx create mode 100644 demo/site-pages/src/blocks/TwoListsBlock.tsx create mode 100644 demo/site-pages/src/components/Breadcrumbs.sc.ts create mode 100644 demo/site-pages/src/components/Breadcrumbs.tsx create mode 100644 demo/site-pages/src/components/common/GridRoot.tsx create mode 100644 demo/site-pages/src/components/common/NextImageBottomPaddingFix.tsx create mode 100644 demo/site-pages/src/config.ts create mode 100644 demo/site-pages/src/documents/pages/blocks/TeaserBlock.tsx create mode 100644 demo/site-pages/src/header/Header.tsx create mode 100644 demo/site-pages/src/header/PageLink.tsx create mode 100644 demo/site-pages/src/news/blocks/NewsLinkBlock.tsx create mode 100644 demo/site-pages/src/pageTypes/Page.tsx create mode 100644 demo/site-pages/src/pages/404.tsx create mode 100644 demo/site-pages/src/pages/[[...path]].tsx create mode 100644 demo/site-pages/src/pages/_app.tsx create mode 100644 demo/site-pages/src/pages/_document.tsx create mode 100644 demo/site-pages/src/pages/api/site-preview.ts create mode 100644 demo/site-pages/src/pages/api/status.tsx create mode 100644 demo/site-pages/src/pages/block-preview/main-menu.tsx create mode 100644 demo/site-pages/src/pages/block-preview/page.tsx create mode 100644 demo/site-pages/src/predefinedPages/predefinedPagePaths.ts create mode 100644 demo/site-pages/src/redraft.d.ts create mode 100644 demo/site-pages/src/theme.ts create mode 100644 demo/site-pages/src/topNavigation/TopNavigation.tsx create mode 100644 demo/site-pages/src/util/createGraphQLClient.ts create mode 100644 demo/site-pages/src/vendor.d.ts create mode 100644 demo/site-pages/tracing.js create mode 100644 demo/site-pages/tsconfig.json create mode 100644 demo/site-pages/tsconfig.preBuild.json create mode 100644 packages/site/cms-site/src/sitePreview/pagesRouter/legacyPagesRouterSitePreviewApiHandler.ts diff --git a/.changeset/config.json b/.changeset/config.json index 45b61f743b..b96ac4673a 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,7 +7,15 @@ "access": "restricted", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": ["comet-storybook", "comet-demo-api", "comet-demo-admin", "comet-demo-admin-server", "comet-demo-site", "comet-docs"], + "ignore": [ + "comet-storybook", + "comet-demo-api", + "comet-demo-admin", + "comet-demo-admin-server", + "comet-demo-site", + "comet-demo-site-pages", + "comet-docs" + ], "snapshot": { "useCalculatedVersion": true } diff --git a/.changeset/popular-shirts-pretend.md b/.changeset/popular-shirts-pretend.md new file mode 100644 index 0000000000..89789b176a --- /dev/null +++ b/.changeset/popular-shirts-pretend.md @@ -0,0 +1,7 @@ +--- +"@comet/cms-site": minor +--- + +Add `legacyPagesRouterSitePreviewApiHandler` helper + +Used to enable the site preview (Preview Mode) for projects which use the Pages Router. This helper is added to ease migrating. New projects should use the App Router instead. diff --git a/.changeset/pre.json b/.changeset/pre.json index 3c4ce09772..60fceb7eda 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -6,6 +6,7 @@ "comet-demo-admin-server": "1.0.0", "comet-demo-api": "1.0.0", "comet-demo-site": "1.0.0", + "comet-demo-site-pages": "1.0.0", "comet-docs": "0.0.0", "@comet/admin": "6.10.0", "@comet/admin-babel-preset": "6.10.0", diff --git a/.env b/.env index ec18d89af2..b0fdec5190 100644 --- a/.env +++ b/.env @@ -53,7 +53,7 @@ DAM_SECRET=6a9e8a185b513363bc89ec0b96eed8f70c759bc86b97319f60365c4b7f8593dc # admin ADMIN_PORT=8000 ADMIN_URL=http://${DEV_DOMAIN:-localhost}:$ADMIN_PORT -SITES_CONFIG='{"main": {"url": "$SITE_URL", "preloginEnabled": false}, "secondary": {"url": "$SITE_URL", "preloginEnabled": false}}' +SITES_CONFIG='{"main": {"url": "$SITE_URL", "preloginEnabled": false}, "secondary": {"url": "$SITE_PAGES_URL", "preloginEnabled": false}}' # site SITE_PORT=3000 @@ -63,6 +63,13 @@ SITE_PRELOGIN_PASSWORD=password NEXT_PUBLIC_SITE_DOMAIN=main NEXT_PUBLIC_API_URL=$API_URL +# site-pages +SITE_PAGES_PORT=3001 +SITE_PAGES_URL=http://localhost:$SITE_PAGES_PORT +NEXT_PUBLIC_SITE_LANGUAGES=en,de +NEXT_PUBLIC_SITE_DEFAULT_LANGUAGE=en +NEXT_PUBLIC_SITE_PAGES_DOMAIN=secondary + # jaegertracing JAEGER_UI_PORT=16686 JAEGER_OLTP_PORT=4318 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d12e67b427..28a3e0463f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -32,21 +32,26 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} path: "demo/admin/lang/comet-lang" - - name: "Demo: Clone translations" + - name: "Demo Admin: Clone translations" uses: actions/checkout@v3 with: repository: vivid-planet/comet-demo-lang token: ${{ secrets.GITHUB_TOKEN }} path: "demo/admin/lang/comet-demo-lang" - - name: Initial lang content (TODO remove this step after all lang files are in the repo) - run: | - mkdir -p demo/admin/lang/comet-demo-lang/admin - test -f demo/admin/lang/comet-demo-lang/admin/en.json || echo "{}" > demo/admin/lang/comet-demo-lang/admin/en.json - test -f demo/admin/lang/comet-demo-lang/admin/de.json || echo "{}" > demo/admin/lang/comet-demo-lang/admin/de.json - mkdir -p demo/site/lang/comet-demo-lang/site - test -f demo/site/lang/comet-demo-lang/site/en.json || echo "{}" > demo/site/lang/comet-demo-lang/site/en.json - test -f demo/site/lang/comet-demo-lang/site/de.json || echo "{}" > demo/site/lang/comet-demo-lang/site/de.json + - name: "Demo Site: Clone translations" + uses: actions/checkout@v3 + with: + repository: vivid-planet/comet-demo-lang + token: ${{ secrets.GITHUB_TOKEN }} + path: "demo/site/lang/comet-demo-lang" + + - name: "Demo Site Pages: Clone translations" + uses: actions/checkout@v3 + with: + repository: vivid-planet/comet-demo-lang + token: ${{ secrets.GITHUB_TOKEN }} + path: "demo/site-pages/lang/comet-demo-lang" - uses: pnpm/action-setup@v4 diff --git a/copy-schema-files.js b/copy-schema-files.js index c6f9f8d1f7..b6a46bf148 100644 --- a/copy-schema-files.js +++ b/copy-schema-files.js @@ -9,9 +9,12 @@ const fs = require("fs"); fs.promises.copyFile("demo/api/block-meta.json", "demo/admin/block-meta.json"), fs.promises.copyFile("demo/api/block-meta.json", "demo/site/block-meta.json"), + fs.promises.copyFile("demo/api/block-meta.json", "demo/site-pages/block-meta.json"), fs.promises.copyFile("demo/api/schema.gql", "demo/admin/schema.gql"), fs.promises.copyFile("demo/api/schema.gql", "demo/site/schema.gql"), + fs.promises.copyFile("demo/api/schema.gql", "demo/site-pages/schema.gql"), fs.promises.copyFile("demo/api/src/comet-config.json", "demo/site/src/comet-config.json"), + fs.promises.copyFile("demo/api/src/comet-config.json", "demo/site-pages/src/comet-config.json"), fs.promises.copyFile("demo/api/src/comet-config.json", "demo/admin/src/comet-config.json"), ]); })(); diff --git a/demo/api/src/app.module.ts b/demo/api/src/app.module.ts index 860332721a..46a476c081 100644 --- a/demo/api/src/app.module.ts +++ b/demo/api/src/app.module.ts @@ -92,6 +92,7 @@ export class AppModule { { domain: "main", language: "de" }, { domain: "main", language: "en" }, { domain: "secondary", language: "en" }, + { domain: "secondary", language: "de" }, ], userService, accessControlService, diff --git a/demo/site-pages/.babelrc b/demo/site-pages/.babelrc new file mode 100644 index 0000000000..9555ba35c1 --- /dev/null +++ b/demo/site-pages/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["next/babel"], + "plugins": [["styled-components", { "ssr": true }]] +} diff --git a/demo/site-pages/.eslintrc.cli.js b/demo/site-pages/.eslintrc.cli.js new file mode 100644 index 0000000000..d2c214ff4c --- /dev/null +++ b/demo/site-pages/.eslintrc.cli.js @@ -0,0 +1,3 @@ +module.exports = { + extends: ["./.eslintrc.json"], +}; diff --git a/demo/site-pages/.eslintrc.json b/demo/site-pages/.eslintrc.json new file mode 100644 index 0000000000..a49736000d --- /dev/null +++ b/demo/site-pages/.eslintrc.json @@ -0,0 +1,7 @@ +{ + "extends": "@comet/eslint-config/nextjs", + "ignorePatterns": ["**/**/*.generated.ts", "dist/**"], + "rules": { + "@calm/react-intl/missing-formatted-message": "off" + } +} diff --git a/demo/site-pages/.gitignore b/demo/site-pages/.gitignore new file mode 100644 index 0000000000..e39009042b --- /dev/null +++ b/demo/site-pages/.gitignore @@ -0,0 +1,52 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel + +.env +schema.gql +schema.json +block-meta.json +src/**/*.generated.ts +preBuild/**/*.generated.ts + +public/sitemap.xml +public/robots.txt + +tsconfig.tsbuildinfo + +src/comet-config.json + +lang +lang-extracted +lang-compiled \ No newline at end of file diff --git a/demo/site-pages/.prettierrc.json b/demo/site-pages/.prettierrc.json new file mode 100644 index 0000000000..6cd561fb17 --- /dev/null +++ b/demo/site-pages/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "printWidth": 150, + "tabWidth": 4, + "trailingComma": "all", + "semi": true +} diff --git a/demo/site-pages/README.md b/demo/site-pages/README.md new file mode 100644 index 0000000000..5ce7f259c5 --- /dev/null +++ b/demo/site-pages/README.md @@ -0,0 +1,30 @@ +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/import?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/demo/site-pages/apollo.config.js b/demo/site-pages/apollo.config.js new file mode 100644 index 0000000000..0fd9d6a5b0 --- /dev/null +++ b/demo/site-pages/apollo.config.js @@ -0,0 +1,9 @@ +// eslint-disable-next-line no-undef +module.exports = { + client: { + service: { + name: "comet-demo-api", + localSchemaFile: "./schema.graphql", + }, + }, +}; diff --git a/demo/site-pages/codegen.ts b/demo/site-pages/codegen.ts new file mode 100644 index 0000000000..827c7acbc5 --- /dev/null +++ b/demo/site-pages/codegen.ts @@ -0,0 +1,49 @@ +import { CodegenConfig } from "@graphql-codegen/cli"; +import { readFileSync } from "fs"; +import { buildSchema } from "graphql"; + +const schema = buildSchema(readFileSync("./schema.gql").toString()); + +const rootBlocks = Object.keys(schema.getTypeMap()).filter((type) => type.endsWith("BlockData")); + +const config: CodegenConfig = { + schema: "schema.gql", + generates: { + "./src/graphql.generated.ts": { + plugins: [{ add: { content: `import { ${rootBlocks.sort().join(", ")} } from "./blocks.generated";` } }, "typescript"], + config: { + avoidOptionals: { + field: true, + }, + enumsAsTypes: true, + namingConvention: "keep", + scalars: rootBlocks.reduce((scalars, rootBlock) => ({ ...scalars, [rootBlock]: rootBlock }), { DateTime: "string" }), + typesPrefix: "GQL", + }, + }, + "./src/": { + documents: ["./src/**/!(*.generated).{tsx,ts}", "preBuild/src/**/!(*.generated).{ts,tsx}"], + preset: "near-operation-file", + presetConfig: { + extension: ".generated.ts", + baseTypesPath: "graphql.generated.ts", + }, + config: { + avoidOptionals: { + field: true, + }, + enumsAsTypes: true, + namingConvention: "keep", + scalars: rootBlocks.reduce((scalars, rootBlock) => ({ ...scalars, [rootBlock]: rootBlock }), {}), + typesPrefix: "GQL", + }, + plugins: [ + { add: { content: `import { ${rootBlocks.sort().join(", ")} } from "@src/blocks.generated";` } }, + "named-operations-object", + "typescript-operations", + ], + }, + }, +}; + +export default config; diff --git a/demo/site-pages/ecosystem.config.js b/demo/site-pages/ecosystem.config.js new file mode 100644 index 0000000000..8835f86f31 --- /dev/null +++ b/demo/site-pages/ecosystem.config.js @@ -0,0 +1,12 @@ +module.exports = { + apps: [ + { + name: "next-server", + script: "pnpm start", + }, + { + name: "next-server-internal-api", + script: "node next-server-internal-api.js", + }, + ], +}; diff --git a/demo/site-pages/intl-update.sh b/demo/site-pages/intl-update.sh new file mode 100755 index 0000000000..cfbfe5b6d3 --- /dev/null +++ b/demo/site-pages/intl-update.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env sh + +cd "$(dirname "$0")" || exit + +rm -rf ./lang/ +mkdir -p ./lang + +git clone https://github.com/vivid-planet/comet-demo-lang lang/comet-demo-lang diff --git a/demo/site-pages/lint-staged.config.js b/demo/site-pages/lint-staged.config.js new file mode 100644 index 0000000000..ded5de27f0 --- /dev/null +++ b/demo/site-pages/lint-staged.config.js @@ -0,0 +1,4 @@ +module.exports = { + "*.{ts,tsx,js,jsx,json,css,scss,md}": () => "pnpm lint:eslint", + "*.{ts,tsx}": () => "pnpm lint:tsc", +}; diff --git a/demo/site-pages/next-env.d.ts b/demo/site-pages/next-env.d.ts new file mode 100644 index 0000000000..4f11a03dc6 --- /dev/null +++ b/demo/site-pages/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/demo/site-pages/next.config.js b/demo/site-pages/next.config.js new file mode 100644 index 0000000000..ae6de57691 --- /dev/null +++ b/demo/site-pages/next.config.js @@ -0,0 +1,61 @@ +/* eslint-disable */ + +// @ts-check + +const cometConfig = require("./src/comet-config.json"); + +/** + * @type {import('next').NextConfig['i18n'] | undefined} + **/ +let i18n = undefined; + +if (process.env.SITE_IS_PREVIEW !== "true") { + if (!process.env.NEXT_PUBLIC_SITE_LANGUAGES) { + throw new Error("Missing environment variable NEXT_PUBLIC_SITE_LANGUAGES"); + } + + if (!process.env.NEXT_PUBLIC_SITE_DEFAULT_LANGUAGE) { + throw new Error("Missing environment variable NEXT_PUBLIC_SITE_DEFAULT_LANGUAGE"); + } + + i18n = { + locales: process.env.NEXT_PUBLIC_SITE_LANGUAGES.split(","), + defaultLocale: process.env.NEXT_PUBLIC_SITE_DEFAULT_LANGUAGE, + localeDetection: process.env.NODE_ENV === "development" ? false : undefined, + }; +} + +/** + * @type {import('next').NextConfig} + **/ +const nextConfig = { + rewrites: async () => { + if (process.env.NEXT_PUBLIC_SITE_IS_PREVIEW === "true") return []; + var rewrites = await require("./preBuild/build/preBuild/src/createRewrites").createRewrites(); + return rewrites; + }, + redirects: async () => { + if (process.env.NEXT_PUBLIC_SITE_IS_PREVIEW === "true") return []; + var redirects = await require("./preBuild/build/preBuild/src/createRedirects").createRedirects(); + return redirects; + }, + images: { + deviceSizes: cometConfig.dam.allowedImageSizes, + }, + webpack: (config, { buildId, dev, isServer, defaultLoaders, webpack }) => { + var path = require("path"); + + config.resolve.alias["@src"] = path.resolve(__dirname, "src/"); + + return config; + }, + i18n, + typescript: { + ignoreBuildErrors: process.env.NODE_ENV === "production", + }, + eslint: { + ignoreDuringBuilds: process.env.NODE_ENV === "production", + }, +}; + +module.exports = nextConfig; diff --git a/demo/site-pages/package.json b/demo/site-pages/package.json new file mode 100644 index 0000000000..ab435cbf16 --- /dev/null +++ b/demo/site-pages/package.json @@ -0,0 +1,80 @@ +{ + "name": "comet-demo-site-pages", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "run-s intl:compile && run-p gql:types generate-block-types && $npm_execpath build:preBuild", + "build:preBuild": "run-s clear:preBuild build:preBuild:tsc", + "build:preBuild:tsc": "tsc --project tsconfig.preBuild.json", + "build:publicGenerated": "node preBuild/build/preBuild/src/publicGenerator/generate.js", + "clear:preBuild": "rimraf preBuild/build", + "dev": "run-s intl:compile && run-p gql:types generate-block-types && $npm_execpath build:preBuild && NODE_OPTIONS='--inspect=localhost:9230' node server.js", + "export": "next export", + "extract:publicGenerated": "node preBuild/build/preBuild/src/publicGenerator/extract.js", + "generate-block-types": "comet generate-block-types", + "generate-block-types:watch": "chokidar -s \"**/block-meta.json\" -c \"$npm_execpath generate-block-types\"", + "gql:types": "graphql-codegen", + "gql:watch": "graphql-codegen --watch", + "intl:extract": "formatjs extract \"src/**/*.ts*\" --ignore **/*.d.ts --out-file lang-extracted/en.json --format simple", + "intl:compile": "formatjs compile-folder --format simple --ast lang/comet-demo-lang/site lang-compiled/", + "lint": "run-s intl:compile && run-p gql:types generate-block-types && run-p lint:eslint lint:tsc", + "lint:eslint": "eslint --max-warnings 0 --config ./.eslintrc.cli.js --ext .ts,.tsx,.js,.jsx,.json,.md src/ package.json", + "lint:tsc": "tsc --project .", + "lint:tscPreBuild": "tsc --project tsconfig.preBuild.json", + "next-build": "$npm_execpath build:publicGenerated && next build", + "serve": "npm run extract:publicGenerated && NODE_ENV=production node server.js" + }, + "dependencies": { + "@comet/cms-site": "workspace:*", + "@formatjs/cli": "^6.0.0", + "@googlemaps/js-api-loader": "^1.0.0", + "@googlemaps/markerclustererplus": "^1.0.0", + "@opentelemetry/api": "^1.4.0", + "@opentelemetry/auto-instrumentations-node": "^0.36.1", + "@opentelemetry/exporter-trace-otlp-http": "^0.35.1", + "@opentelemetry/sdk-node": "^0.35.1", + "babel-plugin-styled-components": "^1.0.0", + "express": "^4.0.0", + "fs-extra": "^9.0.0", + "graphql": "^15.0.0", + "graphql-request": "^3.0.0", + "graphql-tag": "^2.12.6", + "next": "^14.1.3", + "pure-react-carousel": "^1.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-intl": "^6.0.0", + "react-is": "^17.0.2", + "redraft": "^0.10.0", + "sitemap": "^6.0.0", + "styled-components": "^6.1.8", + "swiper": "^6.0.0", + "ts-node": "^10.0.0" + }, + "devDependencies": { + "@babel/core": "^7.0.0", + "@comet/cli": "workspace:*", + "@comet/eslint-config": "workspace:*", + "@graphql-codegen/add": "^3.0.0", + "@graphql-codegen/cli": "^2.0.0", + "@graphql-codegen/named-operations-object": "^2.0.0", + "@graphql-codegen/near-operation-file-preset": "^2.5.0", + "@graphql-codegen/typescript": "^2.0.0", + "@graphql-codegen/typescript-operations": "^2.0.0", + "@types/fs-extra": "^9.0.0", + "@types/google.maps": "^3.0.0", + "@types/node": "^18.0.0", + "@types/react": "^18.2.66", + "@types/react-dom": "^18.2.22", + "@types/react-is": "^17.0.0", + "chokidar-cli": "^2.0.0", + "cosmiconfig-toml-loader": "^1.0.0", + "dotenv-cli": "^4.0.0", + "eslint": "^8.0.0", + "npm-run-all": "^4.1.5", + "prettier": "^2.0.0", + "rimraf": "^3.0.0", + "tsconfig-paths": "^3.0.0", + "typescript": "^4.0.0" + } +} diff --git a/demo/site-pages/preBuild/.gitignore b/demo/site-pages/preBuild/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/demo/site-pages/preBuild/.gitignore @@ -0,0 +1 @@ +/build diff --git a/demo/site-pages/preBuild/src/createRedirects.ts b/demo/site-pages/preBuild/src/createRedirects.ts new file mode 100644 index 0000000000..9c83855f34 --- /dev/null +++ b/demo/site-pages/preBuild/src/createRedirects.ts @@ -0,0 +1,136 @@ +import { gql } from "graphql-request"; +import { Redirect } from "next/dist/lib/load-custom-routes"; + +import { ExternalLinkBlockData, InternalLinkBlockData, NewsLinkBlockData, RedirectsLinkBlockData } from "../../src/blocks.generated"; +import { domain } from "../../src/config"; +import createGraphQLClient from "../../src/util/createGraphQLClient"; +import { GQLRedirectsQuery, GQLRedirectsQueryVariables } from "./createRedirects.generated"; + +const createRedirects = async () => { + return [...(await createApiRedirects()), ...(await createInternalRedirects())]; +}; + +const redirectsQuery = gql` + query Redirects($scope: RedirectScopeInput!, $filter: RedirectFilter, $sort: [RedirectSort!], $offset: Int!, $limit: Int!) { + paginatedRedirects(scope: $scope, filter: $filter, sort: $sort, offset: $offset, limit: $limit) { + nodes { + sourceType + source + target + } + totalCount + } + } +`; + +function replaceRegexCharacters(value: string): string { + // escape ":" and "?", otherwise it is used for next.js regex path matching (https://nextjs.org/docs/pages/api-reference/next-config-js/redirects#regex-path-matching) + return value.replace(/[:?]/g, "\\$&"); +} + +export async function* getRedirects() { + let offset = 0; + const limit = 100; + + while (true) { + const { paginatedRedirects } = await createGraphQLClient().request(redirectsQuery, { + filter: { active: { equal: true } }, + sort: { field: "createdAt", direction: "DESC" }, + offset, + limit, + scope: { domain }, + }); + + yield* paginatedRedirects.nodes.map((redirect) => { + let source: string | undefined; + let destination: string | undefined; + let has: Redirect["has"]; + + if (redirect.sourceType === "path") { + // query parameters have to be defined with has, see: https://nextjs.org/docs/pages/api-reference/next-config-js/redirects#header-cookie-and-query-matching + if (redirect.source?.includes("?")) { + const searchParamsString = redirect.source.split("?").slice(1).join("?"); + const searchParams = new URLSearchParams(searchParamsString); + has = []; + + searchParams.forEach((value, key) => { + if (has) { + has.push({ type: "query", key, value: replaceRegexCharacters(value) }); + } + }); + source = replaceRegexCharacters(redirect.source.replace(searchParamsString, "")); + } else { + source = replaceRegexCharacters(redirect.source); + } + } + + const target = redirect.target as RedirectsLinkBlockData; + + if (target.block !== undefined) { + switch (target.block.type) { + case "internal": + destination = (target.block.props as InternalLinkBlockData).targetPage?.path; + break; + + case "external": + destination = (target.block.props as ExternalLinkBlockData).targetUrl; + break; + case "news": + if ((target.block.props as NewsLinkBlockData).id !== undefined) { + destination = `/news/${(target.block.props as NewsLinkBlockData).id}`; + } + + break; + } + } + + return { ...redirect, source, destination, has }; + }); + + if (offset + limit >= paginatedRedirects.totalCount) { + break; + } + + offset += limit; + } +} + +const createInternalRedirects = async (): Promise => { + if (process.env.ADMIN_URL === undefined) { + console.error(`Cannot create "/admin" redirect: Missing ADMIN_URL environment variable`); + return []; + } + + return [ + { + source: "/admin", + destination: process.env.ADMIN_URL, + permanent: false, + }, + ]; +}; +const createApiRedirects = async (): Promise => { + const apiUrl = process.env.API_URL_INTERNAL; + if (!apiUrl) { + console.error("No Environment Variable API_URL_INTERNAL available. Can not perform redirect config"); + return []; + } + + const redirects: Redirect[] = []; + + for await (const redirect of getRedirects()) { + const { source, destination, has } = redirect; + if (source?.toLowerCase() === destination?.toLowerCase()) { + console.warn(`Skipping redirect loop ${source} -> ${destination}`); + continue; + } + + if (source && destination) { + redirects.push({ source, destination, has, permanent: true }); + } + } + + return redirects; +}; + +export { createRedirects }; diff --git a/demo/site-pages/preBuild/src/createRewrites.ts b/demo/site-pages/preBuild/src/createRewrites.ts new file mode 100644 index 0000000000..2e5f3f2fe7 --- /dev/null +++ b/demo/site-pages/preBuild/src/createRewrites.ts @@ -0,0 +1,27 @@ +import { Rewrite } from "next/dist/lib/load-custom-routes"; + +import { getRedirects } from "./createRedirects"; + +const createRewrites = async () => { + const apiUrl = process.env.API_URL_INTERNAL; + if (!apiUrl) { + console.error("No Environment Variable API_URL_INTERNAL available. Can not perform redirect config"); + return { redirects: [], rewrites: [] }; + } + + const rewrites: Rewrite[] = []; + + for await (const redirect of getRedirects()) { + const { source, destination } = redirect; + + // A rewrite is created for each redirect where the source and destination differ only by casing (otherwise, this causes a redirection loop). + // For instance, a rewrite is created for the redirect /Example -> /example. + if (source && destination && source.toLowerCase() === destination.toLowerCase()) { + rewrites.push({ source, destination }); + } + } + + return rewrites; +}; + +export { createRewrites }; diff --git a/demo/site-pages/preBuild/src/publicGenerator/createPublicGeneratedDirectory.ts b/demo/site-pages/preBuild/src/publicGenerator/createPublicGeneratedDirectory.ts new file mode 100644 index 0000000000..c37bd6451c --- /dev/null +++ b/demo/site-pages/preBuild/src/publicGenerator/createPublicGeneratedDirectory.ts @@ -0,0 +1,14 @@ +import fs from "fs"; + +const createPublicGeneratedDirectory = () => { + const generatedDirectory = "./.next/public.generated/"; + + if (!fs.existsSync(generatedDirectory)) { + console.log(`✅ Successfully created temp directory: ${generatedDirectory}`); + + fs.mkdirSync(generatedDirectory, { recursive: true }); + } + return generatedDirectory; +}; + +export default createPublicGeneratedDirectory; diff --git a/demo/site-pages/preBuild/src/publicGenerator/extract.ts b/demo/site-pages/preBuild/src/publicGenerator/extract.ts new file mode 100644 index 0000000000..e94b08aa90 --- /dev/null +++ b/demo/site-pages/preBuild/src/publicGenerator/extract.ts @@ -0,0 +1,37 @@ +import fs from "fs-extra"; + +import createPublicGeneratedDirectory from "./createPublicGeneratedDirectory"; + +const main = async () => { + const generatedDirectory = createPublicGeneratedDirectory(); + const targetDirectory = `./public/`; + + fs.readdir(generatedDirectory, (err, files) => { + files.forEach((file) => { + const sourcePath = `${generatedDirectory}${file}`; + const targetPath = `${targetDirectory}${file}`; + + const stats = fs.statSync(sourcePath); // get stats to evalute if path is directory or file + + if (stats.isDirectory()) { + fs.copy(sourcePath, targetPath, (err) => { + if (err) { + console.log(`⛔️ error extracting directory ${targetPath}`); + throw err; + } + console.log(`✅ successfully extracted directory ${targetPath}`); + }); + } else { + fs.copyFile(sourcePath, targetPath, (err) => { + if (err) { + console.log(`⛔️ error extracting file ${targetPath}`); + throw err; + } + console.log(`✅ successfully extracted file ${targetPath}`); + }); + } + }); + }); +}; + +main(); diff --git a/demo/site-pages/preBuild/src/publicGenerator/generate.ts b/demo/site-pages/preBuild/src/publicGenerator/generate.ts new file mode 100644 index 0000000000..d5b9cfffa4 --- /dev/null +++ b/demo/site-pages/preBuild/src/publicGenerator/generate.ts @@ -0,0 +1,10 @@ +import robotsTxt from "./robots.txt"; +import sitemapXml from "./sitemap.xml"; + +const main = () => { + // create static files before next build + robotsTxt(); + sitemapXml(); +}; + +main(); diff --git a/demo/site-pages/preBuild/src/publicGenerator/robots.txt.ts b/demo/site-pages/preBuild/src/publicGenerator/robots.txt.ts new file mode 100644 index 0000000000..6ec0cb3970 --- /dev/null +++ b/demo/site-pages/preBuild/src/publicGenerator/robots.txt.ts @@ -0,0 +1,15 @@ +import fs from "fs"; + +import createPublicGeneratedDirectory from "./createPublicGeneratedDirectory"; + +const robotsTxt = () => { + const generatedDirectory = createPublicGeneratedDirectory(); + + const robots = `User-agent: * +Sitemap: ${process.env.SITE_URL}/sitemap.xml`; + const filePath = `${generatedDirectory}robots.txt`; + fs.writeFileSync(filePath, robots); + console.log(`✅ Successfully created robots.txt: ${filePath}`); +}; + +export default robotsTxt; diff --git a/demo/site-pages/preBuild/src/publicGenerator/sitemap.xml.ts b/demo/site-pages/preBuild/src/publicGenerator/sitemap.xml.ts new file mode 100644 index 0000000000..dd73a6af33 --- /dev/null +++ b/demo/site-pages/preBuild/src/publicGenerator/sitemap.xml.ts @@ -0,0 +1,108 @@ +import { createWriteStream } from "fs"; +import { gql } from "graphql-request/dist"; +import { resolve } from "path"; +import { SitemapStream } from "sitemap"; + +import { SeoBlockData } from "../../../src/blocks.generated"; +import createGraphQLClient from "../../../src/util/createGraphQLClient"; +import createPublicGeneratedDirectory from "./createPublicGeneratedDirectory"; +import { GQLSitemapPageDataQuery, GQLSitemapPageDataQueryVariables } from "./sitemap.xml.generated"; + +const sitemapPageDataQuery = gql` + query SitemapPageData($contentScope: PageTreeNodeScopeInput!) { + pageTreeNodeList(scope: $contentScope) { + id + path + documentType + document { + __typename + ... on DocumentInterface { + id + } + ... on Page { + updatedAt + seo + } + ... on Link { + updatedAt + } + } + } + } +`; + +const sitemapXml = async () => { + const generatedDirectory = createPublicGeneratedDirectory(); + const filePath = `${generatedDirectory}sitemap.xml`; + console.log("Start generating sitemap", process.env.API_URL_INTERNAL); + + if (!process.env.API_URL_INTERNAL) { + throw new Error("API_URL_INTERNAL not set as environment variable"); + } + const smStream = new SitemapStream({ + hostname: process.env.SITE_URL, + xmlns: { + news: false, + xhtml: false, + image: false, + video: false, + }, + }); + + smStream.pipe(createWriteStream(resolve(filePath))); + + const domain = process.env.NEXT_PUBLIC_SITE_PAGES_DOMAIN ?? ""; + const languages = process.env.NEXT_PUBLIC_SITE_LANGUAGES?.split(",") ?? []; + const defaultLanguage = process.env.NEXT_PUBLIC_SITE_DEFAULT_LANGUAGE ?? ""; + + // TODO: paging? + let siteMapEntryCreated = false; + + for (const language of languages) { + const { pageTreeNodeList } = await createGraphQLClient().request( + sitemapPageDataQuery, + { contentScope: { domain, language } }, + ); + for (const pageTreeNode of pageTreeNodeList) { + let path: string; + + if (language === defaultLanguage) { + path = pageTreeNode.path; + } else { + path = pageTreeNode.path === "/" ? `/${language}` : `/${language}${pageTreeNode.path}`; + } + + try { + if (pageTreeNode.document?.__typename === "Page") { + const seoBlockFragment: SeoBlockData = pageTreeNode.document.seo; + + if (!seoBlockFragment.noIndex) { + console.log(`+ add page to sitemap: ${path}`); + smStream.write({ + url: path, + priority: Number(seoBlockFragment.priority.replace("_", ".")), + changefreq: seoBlockFragment.changeFrequency, + lastmod: pageTreeNode.document.updatedAt, + }); + siteMapEntryCreated = true; + } else { + console.log(`(skip add page to sitemap: ${path} because of no index)`); + } + } else if (pageTreeNode.document?.__typename === "Link") { + console.log(`(skip add link to sitemap: ${path})`); + } + } catch (e) { + console.error(e); + console.error(`⛔️ Error adding page ${path} to sitemap`); + } + } + } + + if (siteMapEntryCreated) { + smStream.end(); + } + + console.log(`✅ Successfully created sitemap.xml: ${filePath}`); +}; + +export default sitemapXml; diff --git a/demo/site-pages/public/arrow-grey-small.svg b/demo/site-pages/public/arrow-grey-small.svg new file mode 100644 index 0000000000..a1e55fda5e --- /dev/null +++ b/demo/site-pages/public/arrow-grey-small.svg @@ -0,0 +1,3 @@ + + + diff --git a/demo/site-pages/public/arrow-left.svg b/demo/site-pages/public/arrow-left.svg new file mode 100644 index 0000000000..53b5f98079 --- /dev/null +++ b/demo/site-pages/public/arrow-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/demo/site-pages/public/arrow-link.svg b/demo/site-pages/public/arrow-link.svg new file mode 100644 index 0000000000..8a372c4b23 --- /dev/null +++ b/demo/site-pages/public/arrow-link.svg @@ -0,0 +1 @@ + diff --git a/demo/site-pages/public/arrow-right-black.svg b/demo/site-pages/public/arrow-right-black.svg new file mode 100644 index 0000000000..7690bb0360 --- /dev/null +++ b/demo/site-pages/public/arrow-right-black.svg @@ -0,0 +1,3 @@ + + + diff --git a/demo/site-pages/public/arrow-right-dark-grey.svg b/demo/site-pages/public/arrow-right-dark-grey.svg new file mode 100644 index 0000000000..85fc4aaa37 --- /dev/null +++ b/demo/site-pages/public/arrow-right-dark-grey.svg @@ -0,0 +1,3 @@ + + + diff --git a/demo/site-pages/public/arrow-right-grey.svg b/demo/site-pages/public/arrow-right-grey.svg new file mode 100644 index 0000000000..ed9e89264d --- /dev/null +++ b/demo/site-pages/public/arrow-right-grey.svg @@ -0,0 +1,3 @@ + + + diff --git a/demo/site-pages/public/arrow-right.svg b/demo/site-pages/public/arrow-right.svg new file mode 100644 index 0000000000..b8c14cd3a7 --- /dev/null +++ b/demo/site-pages/public/arrow-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/demo/site-pages/public/chevron-down.svg b/demo/site-pages/public/chevron-down.svg new file mode 100644 index 0000000000..851d963804 --- /dev/null +++ b/demo/site-pages/public/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/demo/site-pages/public/chevron-right.svg b/demo/site-pages/public/chevron-right.svg new file mode 100644 index 0000000000..206c29d819 --- /dev/null +++ b/demo/site-pages/public/chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/demo/site-pages/public/close.svg b/demo/site-pages/public/close.svg new file mode 100644 index 0000000000..adb2039540 --- /dev/null +++ b/demo/site-pages/public/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/demo/site-pages/public/favicon.ico b/demo/site-pages/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..4965832f2c9b0605eaa189b7c7fb11124d24e48a GIT binary patch literal 15086 zcmeHOOH5Q(7(R0cc?bh2AT>N@1PWL!LLfZKyG5c!MTHoP7_p!sBz0k$?pjS;^lmgJ zU6^i~bWuZYHL)9$wuvEKm~qo~(5=Lvx5&Hv;?X#m}i|`yaGY4gX+&b>tew;gcnRQA1kp zBbm04SRuuE{Hn+&1wk%&g;?wja_Is#1gKoFlI7f`Gt}X*-nsMO30b_J@)EFNhzd1QM zdH&qFb9PVqQOx@clvc#KAu}^GrN`q5oP(8>m4UOcp`k&xwzkTio*p?kI4BPtIwX%B zJN69cGsm=x90<;Wmh-bs>43F}ro$}Of@8)4KHndLiR$nW?*{Rl72JPUqRr3ta6e#A z%DTEbi9N}+xPtd1juj8;(CJt3r9NOgb>KTuK|z7!JB_KsFW3(pBN4oh&M&}Nb$Ee2 z$-arA6a)CdsPj`M#1DS>fqj#KF%0q?w50GN4YbmMZIoF{e1yTR=4ablqXHBB2!`wM z1M1ke9+<);|AI;f=2^F1;G6Wfpql?1d5D4rMr?#f(=hkoH)U`6Gb)#xDLjoKjp)1;Js@2Iy5yk zMXUqj+gyk1i0yLjWS|3sM2-1ECc;MAz<4t0P53%7se$$+5Ex`L5TQO_MMXXi04UDIU+3*7Ez&X|mj9cFYBXqM{M;mw_ zpw>azP*qjMyNSD4hh)XZt$gqf8f?eRSFX8VQ4Y+H3jAtvyTrXr`qHAD6`m;aYmH2zOhJC~_*AuT} zvUxC38|JYN94i(05R)dVKgUQF$}#cxV7xZ4FULqFCNX*Forhgp*yr6;DsIk=ub0Hv zpk2L{9Q&|uI^b<6@i(Y+iSxeO_n**4nRLc`P!3ld5jL=nZRw6;DEJ*1z6Pvg+eW|$lnnjO zjd|8>6l{i~UxI244CGn2kK@cJ|#ecwgSyt&HKA2)z zrOO{op^o*- + + diff --git a/demo/site-pages/public/mobile-menu.svg b/demo/site-pages/public/mobile-menu.svg new file mode 100644 index 0000000000..ccaa022d7f --- /dev/null +++ b/demo/site-pages/public/mobile-menu.svg @@ -0,0 +1,3 @@ + + + diff --git a/demo/site-pages/public/mousescroll-body.svg b/demo/site-pages/public/mousescroll-body.svg new file mode 100644 index 0000000000..efba80f1c6 --- /dev/null +++ b/demo/site-pages/public/mousescroll-body.svg @@ -0,0 +1,9 @@ + + + Path + + + + + + \ No newline at end of file diff --git a/demo/site-pages/public/vercel.svg b/demo/site-pages/public/vercel.svg new file mode 100644 index 0000000000..fbf0e25a65 --- /dev/null +++ b/demo/site-pages/public/vercel.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/demo/site-pages/server.js b/demo/site-pages/server.js new file mode 100644 index 0000000000..856c22df51 --- /dev/null +++ b/demo/site-pages/server.js @@ -0,0 +1,48 @@ +// server.js +const { createServer } = require("http"); +const { parse } = require("url"); +const next = require("next"); +const fs = require("fs"); + +const dev = process.env.NODE_ENV !== "production"; +const hostname = "localhost"; +const port = process.env.APP_PORT ?? process.env.SITE_PAGES_PORT ?? 3001; +const cdnEnabled = process.env.CDN_ENABLED === "true"; +const disableCdnOriginHeaderCheck = process.env.DISABLE_CDN_ORIGIN_HEADER_CHECK === "true"; +const cdnOriginHeader = process.env.CDN_ORIGIN_HEADER; + +// when using middleware `hostname` and `port` must be provided below +const app = next({ dev, hostname, port }); +const handle = app.getRequestHandler(); + +app.prepare() + .then(() => { + if (process.env.TRACING_ENABLED) { + require("./tracing"); + } + createServer(async (req, res) => { + try { + // Be sure to pass `true` as the second argument to `url.parse`. + // This tells it to parse the query portion of the URL. + const parsedUrl = parse(req.url, true); + + if (cdnEnabled && !disableCdnOriginHeaderCheck) { + const incomingCdnOriginHeader = req.headers["x-cdn-origin-check"]; + if (cdnOriginHeader !== incomingCdnOriginHeader) { + res.statusCode = 403; + res.end(); + return; + } + } + await handle(req, res, parsedUrl); + } catch (err) { + console.error("Error occurred handling", req.url, err); + res.statusCode = 500; + res.end("internal server error"); + } + }).listen(port, (err) => { + if (err) throw err; + console.log(`> Ready on http://localhost:${port}`); + }); + }) + .catch((error) => console.error(error)); diff --git a/demo/site-pages/src/blocks/AnchorBlock.tsx b/demo/site-pages/src/blocks/AnchorBlock.tsx new file mode 100644 index 0000000000..574a3e6520 --- /dev/null +++ b/demo/site-pages/src/blocks/AnchorBlock.tsx @@ -0,0 +1,16 @@ +import { PropsWithData, withPreview } from "@comet/cms-site"; +import { AnchorBlockData } from "@src/blocks.generated"; +import * as React from "react"; + +const AnchorBlock = withPreview( + ({ data: { name } }: PropsWithData) => { + if (name === undefined) { + return null; + } + + return
; + }, + { label: "Anchor" }, +); + +export { AnchorBlock }; diff --git a/demo/site-pages/src/blocks/ColorThemeContext.tsx b/demo/site-pages/src/blocks/ColorThemeContext.tsx new file mode 100644 index 0000000000..e186c4405a --- /dev/null +++ b/demo/site-pages/src/blocks/ColorThemeContext.tsx @@ -0,0 +1,13 @@ +import * as React from "react"; + +export type ColorTheme = "Default" | "GreyN1" | "GreyN2" | "GreyN3" | "DarkBlue"; + +const ColorThemeContext = React.createContext("Default"); + +export function ColorThemeProvider({ children, colorTheme }: React.PropsWithChildren<{ colorTheme: ColorTheme }>): React.ReactElement { + return {children}; +} + +export function useColorTheme(): ColorTheme { + return React.useContext(ColorThemeContext); +} diff --git a/demo/site-pages/src/blocks/ColumnsBlock.tsx b/demo/site-pages/src/blocks/ColumnsBlock.tsx new file mode 100644 index 0000000000..08d8cece72 --- /dev/null +++ b/demo/site-pages/src/blocks/ColumnsBlock.tsx @@ -0,0 +1,49 @@ +import { BlocksBlock, PropsWithData, SupportedBlocks, withPreview } from "@comet/cms-site"; +import { ColumnsBlockData, ColumnsContentBlockData } from "@src/blocks.generated"; +import * as React from "react"; +import styled from "styled-components"; + +import { DamImageBlock } from "./DamImageBlock"; +import { HeadlineBlock } from "./HeadlineBlock"; +import RichTextBlock from "./RichTextBlock"; +import SpaceBlock from "./SpaceBlock"; + +const supportedBlocks: SupportedBlocks = { + space: (props) => , + richtext: (props) => , + headline: (props) => , + image: (props) => , +}; + +const ColumnsContentBlock = withPreview( + ({ data }: PropsWithData) => { + return ; + }, + { label: "Columns content" }, +); + +const ColumnsBlock = withPreview( + ({ data: { layout, columns } }: PropsWithData) => { + const Root = layout === "one-column" ? OneColumnRoot : TwoColumnRoot; + return ( + + {columns.map((column) => ( + + ))} + + ); + }, + { label: "Columns" }, +); + +const OneColumnRoot = styled.div` + padding: 0 40px; +`; + +const TwoColumnRoot = styled.div` + display: grid; + grid-template-columns: repeat(2, 1fr); + column-gap: 40px; +`; + +export { ColumnsBlock }; diff --git a/demo/site-pages/src/blocks/DamImageBlock.tsx b/demo/site-pages/src/blocks/DamImageBlock.tsx new file mode 100644 index 0000000000..c60b8a4d4e --- /dev/null +++ b/demo/site-pages/src/blocks/DamImageBlock.tsx @@ -0,0 +1,49 @@ +import { PixelImageBlock, PreviewSkeleton, PropsWithData, SvgImageBlock, withPreview } from "@comet/cms-site"; +import { DamImageBlockData, PixelImageBlockData, SvgImageBlockData } from "@src/blocks.generated"; +import { NextImageBottomPaddingFix } from "@src/components/common/NextImageBottomPaddingFix"; +import { ImageProps } from "next/image"; +import * as React from "react"; + +type Props = PropsWithData & + Omit & { + aspectRatio: string | "inherit"; + } & ( + | { layout?: "fixed" | "intrinsic" } + // The sizes prop must be specified for images with layout "fill" or "responsive", as recommended in the next/image documentation + // https://nextjs.org/docs/api-reference/next/image#sizes + | { + layout?: "fill" | "responsive"; + sizes: string; + } + ); + +const DamImageBlock = withPreview( + ({ data: { block }, aspectRatio, layout = "intrinsic", ...imageProps }: Props) => { + if (!block) { + return ; + } + + if (block.type === "pixelImage") { + return ( + + + + ); + } else if (block.type === "svgImage") { + return ; + } else { + if (process.env.NODE_ENV === "development") { + return ( +
+                        Unknown type ({block.type}): {JSON.stringify(block.props)}
+                    
+ ); + } + + return null; + } + }, + { label: "Image" }, +); + +export { DamImageBlock }; diff --git a/demo/site-pages/src/blocks/FullWidthImageBlock.tsx b/demo/site-pages/src/blocks/FullWidthImageBlock.tsx new file mode 100644 index 0000000000..767233e9bb --- /dev/null +++ b/demo/site-pages/src/blocks/FullWidthImageBlock.tsx @@ -0,0 +1,42 @@ +import { OptionalBlock, PropsWithData, withPreview } from "@comet/cms-site"; +import { FullWidthImageBlockData } from "@src/blocks.generated"; +import * as React from "react"; +import styled from "styled-components"; + +import { DamImageBlock } from "./DamImageBlock"; +import RichTextBlock from "./RichTextBlock"; + +export const FullWidthImageBlock = withPreview( + ({ data: { image, content } }: PropsWithData) => { + return ( + + + ( + + + + )} + data={content} + /> + + ); + }, + { label: "Full Width Image" }, +); + +const Root = styled.div` + position: relative; +`; + +const Content = styled.div` + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + display: flex; + justify-content: center; + align-items: center; + text-align: center; +`; diff --git a/demo/site-pages/src/blocks/HeadlineBlock.tsx b/demo/site-pages/src/blocks/HeadlineBlock.tsx new file mode 100644 index 0000000000..74a157e638 --- /dev/null +++ b/demo/site-pages/src/blocks/HeadlineBlock.tsx @@ -0,0 +1,45 @@ +import { PropsWithData, withPreview } from "@comet/cms-site"; +import { HeadlineBlockData } from "@src/blocks.generated"; +import * as React from "react"; +import { Renderers } from "redraft"; + +import RichTextBlock from "./RichTextBlock"; + +const headlineTags: { [key: string]: React.ElementType } = { + "header-one": "h1", + "header-two": "h2", + "header-three": "h3", + "header-four": "h4", + "header-five": "h5", + "header-six": "h6", +}; + +const getHeadlineRenderers = (level: HeadlineBlockData["level"]) => { + const HeadlineTag = headlineTags[level]; + + const renderers: Renderers = { + inline: { + BOLD: (children, { key }) => {children}, + ITALIC: (children, { key }) => {children}, + }, + blocks: { + unstyled: (children, { keys }) => { + return children.map((child, index) => {child}); + }, + }, + }; + + return renderers; +}; + +export const HeadlineBlock = withPreview( + ({ data: { eyebrow, headline, level } }: PropsWithData) => { + return ( + <> + {eyebrow && {eyebrow}} + + + ); + }, + { label: "Headline" }, +); diff --git a/demo/site-pages/src/blocks/LinkBlock.tsx b/demo/site-pages/src/blocks/LinkBlock.tsx new file mode 100644 index 0000000000..db675392f6 --- /dev/null +++ b/demo/site-pages/src/blocks/LinkBlock.tsx @@ -0,0 +1,50 @@ +import { + DamFileDownloadLinkBlock, + ExternalLinkBlock, + InternalLinkBlock, + OneOfBlock, + PropsWithData, + SupportedBlocks, + withPreview, +} from "@comet/cms-site"; +import { LinkBlockData } from "@src/blocks.generated"; +import { NewsLinkBlock } from "@src/news/blocks/NewsLinkBlock"; +import * as React from "react"; + +const supportedBlocks: SupportedBlocks = { + internal: ({ children, title, ...props }) => ( + + {children} + + ), + external: ({ children, title, ...props }) => ( + + {children} + + ), + news: ({ children, title, ...props }) => ( + + {children} + + ), + damFileDownload: ({ children, title, ...props }) => ( + + {children} + + ), +}; + +interface LinkBlockProps extends PropsWithData { + children: React.ReactNode; +} + +export const LinkBlock = withPreview( + ({ data, children }: LinkBlockProps) => { + return ( + + {children} + + ); + }, + { label: "Link" }, +); diff --git a/demo/site-pages/src/blocks/LinkListBlock.tsx b/demo/site-pages/src/blocks/LinkListBlock.tsx new file mode 100644 index 0000000000..9c1e4db262 --- /dev/null +++ b/demo/site-pages/src/blocks/LinkListBlock.tsx @@ -0,0 +1,12 @@ +import { ListBlock, PropsWithData, withPreview } from "@comet/cms-site"; +import { LinkListBlockData } from "@src/blocks.generated"; +import * as React from "react"; + +import { TextLinkBlock } from "./TextLinkBlock"; + +export const LinkListBlock = withPreview( + ({ data }: PropsWithData) => { + return } />; + }, + { label: "Link list" }, +); diff --git a/demo/site-pages/src/blocks/MediaBlock.tsx b/demo/site-pages/src/blocks/MediaBlock.tsx new file mode 100644 index 0000000000..3f3f709a52 --- /dev/null +++ b/demo/site-pages/src/blocks/MediaBlock.tsx @@ -0,0 +1,17 @@ +import { DamVideoBlock, OneOfBlock, PropsWithData, SupportedBlocks, withPreview } from "@comet/cms-site"; +import { MediaBlockData } from "@src/blocks.generated"; +import * as React from "react"; + +import { DamImageBlock } from "./DamImageBlock"; + +const supportedBlocks: SupportedBlocks = { + image: (props) => , + video: (props) => , +}; + +export const MediaBlock = withPreview( + ({ data }: PropsWithData) => { + return ; + }, + { label: "Media" }, +); diff --git a/demo/site-pages/src/blocks/PageContentBlock.tsx b/demo/site-pages/src/blocks/PageContentBlock.tsx new file mode 100644 index 0000000000..c1ef71d488 --- /dev/null +++ b/demo/site-pages/src/blocks/PageContentBlock.tsx @@ -0,0 +1,37 @@ +import { BlocksBlock, DamVideoBlock, PropsWithData, SupportedBlocks, YouTubeVideoBlock } from "@comet/cms-site"; +import { PageContentBlockData } from "@src/blocks.generated"; +import { TeaserBlock } from "@src/documents/pages/blocks/TeaserBlock"; +import * as React from "react"; + +import { AnchorBlock } from "./AnchorBlock"; +import { ColumnsBlock } from "./ColumnsBlock"; +import { DamImageBlock } from "./DamImageBlock"; +import { FullWidthImageBlock } from "./FullWidthImageBlock"; +import { HeadlineBlock } from "./HeadlineBlock"; +import { LinkListBlock } from "./LinkListBlock"; +import { MediaBlock } from "./MediaBlock"; +import RichTextBlock from "./RichTextBlock"; +import SpaceBlock from "./SpaceBlock"; +import { TextImageBlock } from "./TextImageBlock"; +import { TwoListsBlock } from "./TwoListsBlock"; + +const supportedBlocks: SupportedBlocks = { + space: (props) => , + richtext: (props) => , + headline: (props) => , + image: (props) => , + textImage: (props) => , + damVideo: (props) => , + youTubeVideo: (props) => , + linkList: (props) => , + fullWidthImage: (props) => , + columns: (props) => , + anchor: (props) => , + media: (props) => , + twoLists: (props) => , + teaser: (props) => , +}; + +export const PageContentBlock: React.FC> = ({ data }) => { + return ; +}; diff --git a/demo/site-pages/src/blocks/RichTextBlock.sc.ts b/demo/site-pages/src/blocks/RichTextBlock.sc.ts new file mode 100644 index 0000000000..b579127014 --- /dev/null +++ b/demo/site-pages/src/blocks/RichTextBlock.sc.ts @@ -0,0 +1,24 @@ +import styled, { css } from "styled-components"; + +interface WrapperProps { + colorTheme?: "Default" | "GreyN1" | "GreyN2" | "GreyN3" | "DarkBlue"; +} + +export const Wrapper = styled.div` + ${({ colorTheme }) => + colorTheme === "DarkBlue" && + css` + color: white; + `} + + ${({ colorTheme }) => + colorTheme === "GreyN3" && + css` + color: white; + `} +`; + +export const UnorderedList = styled.ul` + margin: 40px 0; + padding-left: 30px; +`; diff --git a/demo/site-pages/src/blocks/RichTextBlock.tsx b/demo/site-pages/src/blocks/RichTextBlock.tsx new file mode 100644 index 0000000000..798bea861b --- /dev/null +++ b/demo/site-pages/src/blocks/RichTextBlock.tsx @@ -0,0 +1,88 @@ +import { hasRichTextBlockContent, PreviewSkeleton, PropsWithData, withPreview } from "@comet/cms-site"; +import { LinkBlockData, RichTextBlockData } from "@src/blocks.generated"; +import * as React from "react"; +import redraft, { Renderers } from "redraft"; +import styled from "styled-components"; + +import { LinkBlock } from "./LinkBlock"; + +const GreenCustomHeader: React.FC<{ children: React.ReactNode }> = ({ children }) =>

{children}

; + +export const DefaultStyleLink = styled.a` + color: ${({ theme }) => theme.colors.primary}; +`; + +/** + * Define the renderers + */ +const defaultRenderers: Renderers = { + /** + * Those callbacks will be called recursively to render a nested structure + */ + inline: { + // The key passed here is just an index based on rendering order inside a block + BOLD: (children, { key }) => {children}, + ITALIC: (children, { key }) => {children}, + }, + /** + * Blocks receive children and depth + * Note that children are an array of blocks with same styling, + */ + blocks: { + // Paragraph + unstyled: (children, { keys }) => children.map((child, idx) =>

{child}

), + // Headlines + "header-one": (children, { keys }) => children.map((child, idx) =>

{child}

), + "header-two": (children, { keys }) => children.map((child, idx) =>

{child}

), + "header-three": (children, { keys }) => children.map((child, idx) =>

{child}

), + "header-four": (children, { keys }) => children.map((child, idx) =>

{child}

), + "header-five": (children, { keys }) => children.map((child, idx) =>
{child}
), + "header-six": (children, { keys }) => children.map((child, idx) =>
{child}
), + // List + // or depth for nested lists + "unordered-list-item": (children, { depth, keys }) => ( +
    + {children.map((child, index) => ( +
  • {child}
  • + ))} +
+ ), + "ordered-list-item": (children, { depth, keys }) => ( +
    + {children.map((child, index) => ( +
  1. {child}
  2. + ))} +
+ ), + "header-custom-green": (children, { keys }) => children.map((child, idx) => {child}), + }, + /** + * Entities receive children and the entity data + */ + entities: { + // key is the entity key value from raw + LINK: (children, data, { key }) => { + return ( + + {children} + + ); + }, + }, +}; + +interface RichTextBlockProps extends PropsWithData { + renderers?: Renderers; +} + +const RichTextBlock: React.FC = ({ data, renderers = defaultRenderers }) => { + const rendered = redraft(data.draftContent, renderers); + + return ( + + {rendered} + + ); +}; + +export default withPreview(RichTextBlock, { label: "RichText" }); diff --git a/demo/site-pages/src/blocks/SpaceBlock.tsx b/demo/site-pages/src/blocks/SpaceBlock.tsx new file mode 100644 index 0000000000..001200a07d --- /dev/null +++ b/demo/site-pages/src/blocks/SpaceBlock.tsx @@ -0,0 +1,22 @@ +import { PropsWithData, withPreview } from "@comet/cms-site"; +import { DemoSpaceBlockData } from "@src/blocks.generated"; +import * as React from "react"; + +const SpaceMapping: Record = { + d150: 10, + d200: 20, + d250: 40, + d300: 60, + d350: 80, + d400: 100, + d450: 150, + d500: 200, + d550: 250, + d600: 300, +}; + +const SpaceBlock: React.FC> = ({ data: { spacing } }) => { + return
; +}; + +export default withPreview(SpaceBlock, { label: "Abstand" }); diff --git a/demo/site-pages/src/blocks/TextImageBlock.tsx b/demo/site-pages/src/blocks/TextImageBlock.tsx new file mode 100644 index 0000000000..1a93c2f62b --- /dev/null +++ b/demo/site-pages/src/blocks/TextImageBlock.tsx @@ -0,0 +1,26 @@ +import { PropsWithData, withPreview } from "@comet/cms-site"; +import { TextImageBlockData } from "@src/blocks.generated"; +import * as React from "react"; +import styled from "styled-components"; + +import { DamImageBlock } from "./DamImageBlock"; +import RichTextBlock from "./RichTextBlock"; + +export const TextImageBlock = withPreview( + ({ data: { text, image, imageAspectRatio, imagePosition } }: PropsWithData) => { + return ( + + {imagePosition === "left" && } + + {imagePosition === "right" && } + + ); + }, + { label: "Text/Image" }, +); + +const Root = styled.div` + display: grid; + grid-template-columns: repeat(2, 1fr); + column-gap: 20px; +`; diff --git a/demo/site-pages/src/blocks/TextLinkBlock.tsx b/demo/site-pages/src/blocks/TextLinkBlock.tsx new file mode 100644 index 0000000000..88e9e4d0be --- /dev/null +++ b/demo/site-pages/src/blocks/TextLinkBlock.tsx @@ -0,0 +1,16 @@ +import { PropsWithData, withPreview } from "@comet/cms-site"; +import { DemoTextLinkBlockData } from "@src/blocks.generated"; +import * as React from "react"; + +import { LinkBlock } from "./LinkBlock"; + +export const TextLinkBlock = withPreview( + ({ data: { link, text } }: PropsWithData) => { + return ( + + {text} + + ); + }, + { label: "Link" }, +); diff --git a/demo/site-pages/src/blocks/TwoListsBlock.tsx b/demo/site-pages/src/blocks/TwoListsBlock.tsx new file mode 100644 index 0000000000..36bffbe4d2 --- /dev/null +++ b/demo/site-pages/src/blocks/TwoListsBlock.tsx @@ -0,0 +1,19 @@ +import { ListBlock, PropsWithData, withPreview } from "@comet/cms-site"; +import { TwoListsBlockData } from "@src/blocks.generated"; + +import { HeadlineBlock } from "./HeadlineBlock"; + +const TwoListsBlock = withPreview( + ({ data: { list1, list2 } }: PropsWithData) => { + return ( + <> + } /> +
+ } /> + + ); + }, + { label: "Two Lists" }, +); + +export { TwoListsBlock }; diff --git a/demo/site-pages/src/components/Breadcrumbs.sc.ts b/demo/site-pages/src/components/Breadcrumbs.sc.ts new file mode 100644 index 0000000000..9fe80f90e5 --- /dev/null +++ b/demo/site-pages/src/components/Breadcrumbs.sc.ts @@ -0,0 +1,26 @@ +import styled from "styled-components"; + +export const Container = styled.div` + padding: 30px 0; + grid-column: 2 / 24; +`; + +export const Link = styled.a` + color: ${({ theme }) => theme.colors.n400}; + text-decoration: none; + + font-size: 14px; + + :last-child { + font-weight: 500; + color: ${({ theme }) => theme.colors.black}; + } +`; + +export const Divider = styled.span` + display: inline-block; + width: 15px; + height: 1px; + margin: 0 10px 5px 10px; + background-color: ${({ theme }) => theme.colors.n200}; +`; diff --git a/demo/site-pages/src/components/Breadcrumbs.tsx b/demo/site-pages/src/components/Breadcrumbs.tsx new file mode 100644 index 0000000000..22d67a7039 --- /dev/null +++ b/demo/site-pages/src/components/Breadcrumbs.tsx @@ -0,0 +1,42 @@ +import { GridRoot } from "@src/components/common/GridRoot"; +import { gql } from "graphql-request"; +import Link from "next/link"; +import * as React from "react"; + +import { GQLBreadcrumbsFragment } from "./Breadcrumbs.generated"; +import * as sc from "./Breadcrumbs.sc"; + +export const breadcrumbsFragment = gql` + fragment Breadcrumbs on PageTreeNode { + name + path + parentNodes { + name + path + } + } +`; + +const Breadcrumbs: React.FunctionComponent = ({ name, path, parentNodes }) => { + return ( + + {parentNodes.length > 0 && ( + + {parentNodes.map((parentNode) => ( + + + {parentNode.name} + + + + ))} + + {name} + + + )} + + ); +}; + +export default Breadcrumbs; diff --git a/demo/site-pages/src/components/common/GridRoot.tsx b/demo/site-pages/src/components/common/GridRoot.tsx new file mode 100644 index 0000000000..119afa89d9 --- /dev/null +++ b/demo/site-pages/src/components/common/GridRoot.tsx @@ -0,0 +1,6 @@ +import styled from "styled-components"; + +export const GridRoot = styled.div` + display: grid; + grid-template-columns: repeat(24, 1fr); +`; diff --git a/demo/site-pages/src/components/common/NextImageBottomPaddingFix.tsx b/demo/site-pages/src/components/common/NextImageBottomPaddingFix.tsx new file mode 100644 index 0000000000..0841725539 --- /dev/null +++ b/demo/site-pages/src/components/common/NextImageBottomPaddingFix.tsx @@ -0,0 +1,10 @@ +import styled from "styled-components"; + +// Workaround to remove space below image. See https://github.com/vercel/next.js/issues/18637#issuecomment-803028167 for more information. +// TODO consider adding this fix to PixelImageBlock directly. +export const NextImageBottomPaddingFix = styled.div` + > span, + > div > span { + vertical-align: top; + } +`; diff --git a/demo/site-pages/src/config.ts b/demo/site-pages/src/config.ts new file mode 100644 index 0000000000..2b10a9d71a --- /dev/null +++ b/demo/site-pages/src/config.ts @@ -0,0 +1,25 @@ +/* eslint-disable no-console */ + +export let domain = ""; + +if (!process.env.NEXT_PUBLIC_SITE_PAGES_DOMAIN) { + console.error('Environment variable NEXT_PUBLIC_SITE_DOMAIN not set, defaulting to ""'); +} else { + domain = process.env.NEXT_PUBLIC_SITE_PAGES_DOMAIN; +} + +export let languages: string[] = []; + +if (!process.env.NEXT_PUBLIC_SITE_LANGUAGES) { + console.error("Environment variable NEXT_PUBLIC_SITE_LANGUAGES not set, defaulting to []"); +} else { + languages = process.env.NEXT_PUBLIC_SITE_LANGUAGES.split(","); +} + +export let defaultLanguage = ""; + +if (!process.env.NEXT_PUBLIC_SITE_DEFAULT_LANGUAGE) { + console.error('Environment variable NEXT_PUBLIC_SITE_DEFAULT_LANGUAGE not set, defaulting to ""'); +} else { + defaultLanguage = process.env.NEXT_PUBLIC_SITE_DEFAULT_LANGUAGE; +} diff --git a/demo/site-pages/src/documents/pages/blocks/TeaserBlock.tsx b/demo/site-pages/src/documents/pages/blocks/TeaserBlock.tsx new file mode 100644 index 0000000000..6b56ee4b4b --- /dev/null +++ b/demo/site-pages/src/documents/pages/blocks/TeaserBlock.tsx @@ -0,0 +1,29 @@ +import { PropsWithData, withPreview } from "@comet/cms-site"; +import { TeaserBlockData } from "@src/blocks.generated"; +import { DamImageBlock } from "@src/blocks/DamImageBlock"; +import { HeadlineBlock } from "@src/blocks/HeadlineBlock"; +import { LinkListBlock } from "@src/blocks/LinkListBlock"; +import styled from "styled-components"; + +const TeaserBlock = withPreview( + ({ data: { headline, image, links, buttons } }: PropsWithData) => { + return ( + + + + + + + ); + }, + { label: "Teaser" }, +); + +const Root = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +`; + +export { TeaserBlock }; diff --git a/demo/site-pages/src/header/Header.tsx b/demo/site-pages/src/header/Header.tsx new file mode 100644 index 0000000000..da8d8f3d71 --- /dev/null +++ b/demo/site-pages/src/header/Header.tsx @@ -0,0 +1,99 @@ +import { gql } from "graphql-request"; +import * as React from "react"; +import styled from "styled-components"; + +import { GQLHeaderFragment } from "./Header.generated"; +import { PageLink, pageLinkFragment } from "./PageLink"; + +interface Props { + header: GQLHeaderFragment; +} + +function Header({ header }: Props): JSX.Element { + return ( + + + + ); +} + +const headerFragment = gql` + fragment Header on MainMenu { + items { + id + node { + id + name + ...PageLink + childNodes { + id + name + ...PageLink + } + } + } + } + + ${pageLinkFragment} +`; + +const Root = styled.header` + padding: 10px 20px; +`; + +const TopLevelNavigation = styled.ol` + display: flex; + list-style-type: none; + padding: 0; +`; + +const SubLevelNavigation = styled.ol` + display: none; + position: absolute; + min-width: 100px; + list-style-type: none; + padding: 5px; + background-color: white; + box-shadow: 0 4px 4px rgba(0, 0, 0, 0.1); +`; + +const TopLevelLinkContainer = styled.li` + position: relative; + + &:hover { + text-decoration: underline; + + & > ${SubLevelNavigation} { + display: block; + } + } +`; + +const Link = styled.a<{ $active: boolean }>` + text-decoration: none; + padding: 5px 10px; + color: ${({ $active, theme }) => ($active ? theme.colors.primary : theme.colors.black)}; + + &:hover { + text-decoration: underline; + } +`; + +export { Header, headerFragment }; diff --git a/demo/site-pages/src/header/PageLink.tsx b/demo/site-pages/src/header/PageLink.tsx new file mode 100644 index 0000000000..520485150c --- /dev/null +++ b/demo/site-pages/src/header/PageLink.tsx @@ -0,0 +1,69 @@ +import { LinkBlock } from "@src/blocks/LinkBlock"; +import { GQLPredefinedPage } from "@src/graphql.generated"; +import { predefinedPagePaths } from "@src/predefinedPages/predefinedPagePaths"; +import { gql } from "graphql-request"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import * as React from "react"; + +import { GQLPageLinkFragment } from "./PageLink.generated"; + +const pageLinkFragment = gql` + fragment PageLink on PageTreeNode { + path + documentType + document { + __typename + ... on Link { + content + } + ... on PredefinedPage { + type + } + } + } +`; + +interface Props { + page: GQLPageLinkFragment; + children: ((active: boolean) => React.ReactNode) | React.ReactNode; +} + +function PageLink({ page, children }: Props): JSX.Element | null { + const router = useRouter(); + const active = router.asPath === page.path; + + if (page.documentType === "Link") { + if (page.document === null || page.document.__typename !== "Link") { + return null; + } + + return {typeof children === "function" ? children(active) : children}; + } else if (page.documentType === "Page") { + return ( + + {typeof children === "function" ? children(active) : children} + + ); + } else if (page.documentType === "PredefinedPage") { + if (!page.document) { + return null; + } + + const type = (page.document as GQLPredefinedPage).type; + + return ( + + {typeof children === "function" ? children(active) : children} + + ); + } else { + if (process.env.NODE_ENV === "development") { + throw new Error(`Unknown documentType "${page.documentType}"`); + } + + return null; + } +} + +export { PageLink, pageLinkFragment }; diff --git a/demo/site-pages/src/news/blocks/NewsLinkBlock.tsx b/demo/site-pages/src/news/blocks/NewsLinkBlock.tsx new file mode 100644 index 0000000000..f9a97705c1 --- /dev/null +++ b/demo/site-pages/src/news/blocks/NewsLinkBlock.tsx @@ -0,0 +1,20 @@ +import { PropsWithData } from "@comet/cms-site"; +import { NewsLinkBlockData } from "@src/blocks.generated"; +import Link from "next/link"; +import * as React from "react"; + +type Props = PropsWithData & { title?: string }; + +function NewsLinkBlock({ data: { id }, children, title }: React.PropsWithChildren): JSX.Element | null { + if (id === undefined) { + return null; + } + + return ( + + {children} + + ); +} + +export { NewsLinkBlock }; diff --git a/demo/site-pages/src/pageTypes/Page.tsx b/demo/site-pages/src/pageTypes/Page.tsx new file mode 100644 index 0000000000..4cb38abb6a --- /dev/null +++ b/demo/site-pages/src/pageTypes/Page.tsx @@ -0,0 +1,72 @@ +import { SeoBlock } from "@comet/cms-site"; +import { PageContentBlock } from "@src/blocks/PageContentBlock"; +import Breadcrumbs, { breadcrumbsFragment } from "@src/components/Breadcrumbs"; +import { GQLPageTreeNodeScopeInput } from "@src/graphql.generated"; +import { Header, headerFragment } from "@src/header/Header"; +import { topMenuPageTreeNodeFragment, TopNavigation } from "@src/topNavigation/TopNavigation"; +import { gql, GraphQLClient } from "graphql-request"; +import Head from "next/head"; +import * as React from "react"; + +import { GQLPageQuery } from "./Page.generated"; + +// @TODO: Scope for menu should also be of type PageTreeNodeScopeInput +export const pageQuery = gql` + query Page($pageTreeNodeId: ID!, $domain: String!, $language: String!) { + pageContent: pageTreeNode(id: $pageTreeNodeId) { + document { + __typename + ... on Page { + content + seo + } + } + ...Breadcrumbs + } + + header: mainMenu(scope: { domain: $domain, language: $language }) { + ...Header + } + + topMenu(scope: { domain: $domain, language: $language }) { + ...TopMenuPageTreeNode + } + } + + ${breadcrumbsFragment} + ${headerFragment} + ${topMenuPageTreeNodeFragment} +`; + +export async function loader({ + client, + pageTreeNodeId, + scope, +}: { + client: GraphQLClient; + pageTreeNodeId: string; + scope: GQLPageTreeNodeScopeInput; +}): Promise { + return client.request(pageQuery, { + pageTreeNodeId, + domain: scope.domain, + language: scope.language, + }); +} + +export default function Page(props: GQLPageQuery): JSX.Element { + const document = props.pageContent?.document; + + return ( + <> + + + + {document?.__typename === "Page" && } + +
+ {props.pageContent && } + {document?.__typename === "Page" ?
{document.content && }
: null} + + ); +} diff --git a/demo/site-pages/src/pages/404.tsx b/demo/site-pages/src/pages/404.tsx new file mode 100644 index 0000000000..ea3178e390 --- /dev/null +++ b/demo/site-pages/src/pages/404.tsx @@ -0,0 +1,13 @@ +import * as React from "react"; + +interface NotFound404Props { + children: React.ReactNode; +} +export default function NotFound404({ children }: NotFound404Props): JSX.Element { + return ( + <> +

404 - Page Not Found

+ {children} + + ); +} diff --git a/demo/site-pages/src/pages/[[...path]].tsx b/demo/site-pages/src/pages/[[...path]].tsx new file mode 100644 index 0000000000..9d65be713c --- /dev/null +++ b/demo/site-pages/src/pages/[[...path]].tsx @@ -0,0 +1,116 @@ +import { SitePreviewParams } from "@comet/cms-site"; +import { domain } from "@src/config"; +import { GQLPage, GQLPageTreeNodeScopeInput } from "@src/graphql.generated"; +import NotFound404 from "@src/pages/404"; +import PageTypePage, { loader as pageTypePageLoader } from "@src/pageTypes/Page"; +import createGraphQLClient from "@src/util/createGraphQLClient"; +import { gql } from "graphql-request"; +import { GetStaticPaths, GetStaticProps, InferGetStaticPropsType } from "next"; +import { ParsedUrlQuery } from "querystring"; +import * as React from "react"; + +import { GQLPagesQuery, GQLPagesQueryVariables, GQLPageTypeQuery, GQLPageTypeQueryVariables } from "./[[...path]].generated"; + +interface PageProps { + documentType: string; + id: string; +} +export type PageUniversalProps = PageProps & GQLPage; + +export default function Page(props: InferGetStaticPropsType): JSX.Element { + if (!pageTypes[props.documentType]) { + return ( + +
+ unknown documentType: {props.documentType} +
+
+ ); + } + const { component: Component } = pageTypes[props.documentType]; + + return ; +} + +const pageTypeQuery = gql` + query PageType($path: String!, $scope: PageTreeNodeScopeInput!) { + pageTreeNodeByPath(path: $path, scope: $scope) { + id + documentType + } + } +`; + +const pageTypes = { + Page: { + component: PageTypePage, + loader: pageTypePageLoader, + }, +}; + +export const getStaticProps: GetStaticProps = async (context) => { + const { scope, previewData } = context.previewData ?? { scope: { domain, language: context.locale }, previewData: undefined }; + + const client = createGraphQLClient({ + includeInvisiblePages: context.preview, + includeInvisibleBlocks: previewData?.includeInvisible, + previewDamUrls: context.preview, + }); + const path = context.params?.path ?? ""; + //fetch pageType + const data = await client.request(pageTypeQuery, { + path: `/${Array.isArray(path) ? path.join("/") : path}`, + scope: scope as GQLPageTreeNodeScopeInput, //TODO fix type, the scope from parsePreviewParams() is not compatible with GQLPageTreeNodeScopeInput + }); + if (!data.pageTreeNodeByPath?.documentType) { + // eslint-disable-next-line no-console + console.log("got no data from api", data, path); + return { notFound: true }; + } + const pageTreeNodeId = data.pageTreeNodeByPath.id; + + //pageType dependent query + const { loader: loaderForPageType } = pageTypes[data.pageTreeNodeByPath.documentType]; + return { + props: { + ...(await loaderForPageType({ client, scope, pageTreeNodeId })), + documentType: data.pageTreeNodeByPath.documentType, + id: pageTreeNodeId, + }, + }; +}; + +const pagesQuery = gql` + query Pages($scope: PageTreeNodeScopeInput!) { + pageTreeNodeList(scope: $scope) { + id + path + documentType + } + } +`; + +export const getStaticPaths: GetStaticPaths = async ({ locales = [] }) => { + const paths: Array<{ params: { path: string[] }; locale: string }> = []; + + for (const locale of locales) { + const data = await createGraphQLClient().request(pagesQuery, { + scope: { domain, language: locale }, + }); + + paths.push( + ...data.pageTreeNodeList + .filter((page) => page.documentType === "Page") + .map((page) => { + const path = page.path.split("/"); + path.shift(); // Remove "" caused by leading slash + return { params: { path }, locale }; + }), + ); + } + + return { + paths, + fallback: false, + }; +}; diff --git a/demo/site-pages/src/pages/_app.tsx b/demo/site-pages/src/pages/_app.tsx new file mode 100644 index 0000000000..e3053e3a68 --- /dev/null +++ b/demo/site-pages/src/pages/_app.tsx @@ -0,0 +1,82 @@ +import { SitePreviewProvider } from "@comet/cms-site"; +import theme from "@src/theme"; +import { AppProps, NextWebVitalsMetric } from "next/app"; +import Head from "next/head"; +import { useRouter } from "next/router"; +import Script from "next/script"; +import * as React from "react"; +import { IntlProvider } from "react-intl"; +import { createGlobalStyle, ThemeProvider } from "styled-components"; + +const GlobalStyle = createGlobalStyle` + body { + margin: 0; + -webkit-text-size-adjust: none; + color: ${({ theme }) => theme.colors.textPrimary}; + font-family: ${({ theme }) => theme.fonts.primary}; + font-weight: 400; + } +`; + +declare global { + interface Window { + dataLayer: Record[]; + } +} + +export function reportWebVitals({ id, name, label, value }: NextWebVitalsMetric): void { + // https://nextjs.org/docs/advanced-features/measuring-performance#sending-results-to-analytics + if (process.env.NEXT_PUBLIC_GTM_ID) { + const event = { + event: "web-vitals", + event_category: label === "web-vital" ? "Web Vitals" : "Next.js custom metric", + event_action: name, + event_value: Math.round(name === "CLS" ? value * 1000 : value), // values must be integers + event_label: id, // id unique to current page load + non_interaction: true, // avoids affecting bounce rate. + }; + window.dataLayer.push(event); + } +} + +export default function App({ Component, pageProps }: AppProps): JSX.Element { + const router = useRouter(); + + return ( + // see https://github.com/vercel/next.js/tree/master/examples/with-react-intl + // for a complete strategy to couple next with react-intl + // defaultLocale prevents missing message warning for locale defined in code, + // see https://github.com/formatjs/formatjs/issues/251 + + + + {process.env.NEXT_PUBLIC_GTM_ID && ( +