diff --git a/.github/workflows/docs-validation.yml b/.github/workflows/docs-validation.yml index 77dad776b8..247eb39343 100644 --- a/.github/workflows/docs-validation.yml +++ b/.github/workflows/docs-validation.yml @@ -12,53 +12,53 @@ on: - main jobs: -link-check: - name: Broken link checker - runs-on: ubuntu-latest + link-check: + name: Broken link checker + runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 + steps: + - name: Checkout code + uses: actions/checkout@v4 - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "pnpm" + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "pnpm" - - name: Install dependencies - run: pnpm install --frozen-lockfile + - name: Install dependencies + run: pnpm install --frozen-lockfile - - name: Build static site - run: pnpm build + - name: Build static site + run: pnpm build - - name: Set up Rust - uses: actions-rs/toolchain@v1 - with: - toolchain: stable + - name: Set up Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable - - name: Install lychee - run: cargo install lychee + - name: Install lychee + run: cargo install lychee - - name: Check links - run: lychee --verbose --no-progress './out/**/*.html' + - name: Check links + run: lychee --verbose --no-progress './out/**/*.html' -code-validate: - name: Code snippet and GraphQL validation - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 + code-validate: + name: Code snippet and GraphQL validation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "pnpm" + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "pnpm" - - name: Install dependencies - run: pnpm install --frozen-lockfile + - name: Install dependencies + run: pnpm install --frozen-lockfile - - name: Run validation w/ annotations - run: pnpm lint:docs:ci + - name: Run validation w/ annotations + run: pnpm lint:docs:ci - - name: Validate code snippets - run: pnpm validate:snippets + - name: Validate code snippets + run: pnpm validate:snippets diff --git a/package.json b/package.json index 9e4aa524c1..a8635a544b 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,8 @@ "private": true, "packageManager": "pnpm@9.15.9", "scripts": { - "build": "next build && next-image-export-optimizer", - "serve": "pnpx serve out", "analyze": "ANALYZE=true next build", + "build": "next build && next-image-export-optimizer", "check:links": "lychee --verbose --no-progress './src/pages/**/*.mdx' --base https://graphql.org", "dev": "next", "format": "pnpm format:check --write", @@ -17,7 +16,9 @@ "lint:docs:ci": "eslint --ignore-path .gitignore src/pages/learn --format eslint-formatter-github", "postbuild": "next-sitemap", "prebuild": "tsx src/get-github-info.ts", - "test": "echo \"no tests\" && exit 1", + "serve": "pnpx serve out", + "test": "playwright test", + "test:ui": "playwright test --ui", "validate:snippets": "node scripts/validate-snippets.js" }, "dependencies": { @@ -72,9 +73,13 @@ "unist-util-visit": "^5.0.0", "use-query-params": "^2.2.1" }, + "optionalDependencies": { + "playwright": "^1.54.2" + }, "devDependencies": { "@graphql-eslint/eslint-plugin": "4.3.0", "@next/eslint-plugin-next": "^15.3.3", + "@playwright/test": "^1.54.2", "@svgr/webpack": "^8.1.0", "@types/codemirror": "5.60.16", "@types/hast": "3.0.4", @@ -99,6 +104,15 @@ "tsx": "^4.19.4", "typescript": "^5.8.3" }, + "browserslist": [ + "chrome >0 and last 2.5 years", + "edge >0 and last 2.5 years", + "safari >0 and last 2.5 years", + "firefox >0 and last 2.5 years", + "and_chr >0 and last 2.5 years", + "and_ff >0 and last 2.5 years", + "ios >0 and last 2.5 years" + ], "pnpm": { "patchedDependencies": { "nextra": "patches/nextra.patch" diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000000..de0ed7f7d4 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,31 @@ +import { defineConfig, devices } from "@playwright/test" + +/** + * @see https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + testDir: "./test/e2e", + outputDir: "./test/out", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: "html", + use: { + baseURL: "http://localhost:3000", + trace: "on-first-retry", + }, + + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + + webServer: { + command: "pnpm dev", + url: "http://localhost:3000", + reuseExistingServer: !process.env.CI, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28c5928305..b210f14fa8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,25 +90,25 @@ importers: version: 12.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next: specifier: ^14.2.22 - version: 14.2.29(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 14.2.29(@babel/core@7.26.0)(@playwright/test@1.54.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-image-export-optimizer: specifier: ^1.18.0 - version: 1.18.0(next@14.2.29(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 1.18.0(next@14.2.29(@babel/core@7.26.0)(@playwright/test@1.54.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) next-query-params: specifier: ^5.0.1 - version: 5.0.1(next@14.2.29(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(use-query-params@2.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + version: 5.0.1(next@14.2.29(@babel/core@7.26.0)(@playwright/test@1.54.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(use-query-params@2.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) next-sitemap: specifier: ^4.2.3 - version: 4.2.3(next@14.2.29(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + version: 4.2.3(next@14.2.29(@babel/core@7.26.0)(@playwright/test@1.54.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) next-with-less: specifier: ^3.0.1 - version: 3.0.1(less-loader@12.2.0(less@4.2.1))(less@4.2.1)(next@14.2.29(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + version: 3.0.1(less-loader@12.2.0(less@4.2.1))(less@4.2.1)(next@14.2.29(@babel/core@7.26.0)(@playwright/test@1.54.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) nextra: specifier: 3.3.1 - version: 3.3.1(patch_hash=ytqsrocfyxyupt7yg3khzgrhfe)(@types/react@18.3.23)(next@14.2.29(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3) + version: 3.3.1(patch_hash=ytqsrocfyxyupt7yg3khzgrhfe)(@types/react@18.3.23)(next@14.2.29(@babel/core@7.26.0)(@playwright/test@1.54.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3) nextra-theme-docs: specifier: 3.3.1 - version: 3.3.1(next@14.2.29(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nextra@3.3.1(patch_hash=ytqsrocfyxyupt7yg3khzgrhfe)(@types/react@18.3.23)(next@14.2.29(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 3.3.1(next@14.2.29(@babel/core@7.26.0)(@playwright/test@1.54.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nextra@3.3.1(patch_hash=ytqsrocfyxyupt7yg3khzgrhfe)(@types/react@18.3.23)(next@14.2.29(@babel/core@7.26.0)(@playwright/test@1.54.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) numbro: specifier: 2.5.0 version: 2.5.0 @@ -163,6 +163,10 @@ importers: use-query-params: specifier: ^2.2.1 version: 2.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + optionalDependencies: + playwright: + specifier: ^1.54.2 + version: 1.54.2 devDependencies: '@graphql-eslint/eslint-plugin': specifier: 4.3.0 @@ -170,6 +174,9 @@ importers: '@next/eslint-plugin-next': specifier: ^15.3.3 version: 15.3.3 + '@playwright/test': + specifier: ^1.54.2 + version: 1.54.2 '@svgr/webpack': specifier: ^8.1.0 version: 8.1.0(typescript@5.8.3) @@ -1564,6 +1571,11 @@ packages: resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@playwright/test@1.54.2': + resolution: {integrity: sha512-A+znathYxPf+72riFd1r1ovOLqsIIB0jKIoPjyK2kqEIe30/6jF6BC7QNluHuwUmsD2tv1XZVugN8GqfTMOxsA==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -3165,6 +3177,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4511,6 +4528,16 @@ packages: pkg-types@2.1.0: resolution: {integrity: sha512-wmJwA+8ihJixSoHKxZJRBQG1oY8Yr9pGLzRmSsNms0iNWyHHAlZCa7mmKiFR10YPZuz/2k169JiS/inOjBCZ2A==} + playwright-core@1.54.2: + resolution: {integrity: sha512-n5r4HFbMmWsB4twG7tJLDN9gmBUeSPcsBZiWSE4DnYz9mJMAFqr2ID7+eGC9kpEnxExJ1epttwR59LEWCk8mtA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.54.2: + resolution: {integrity: sha512-Hu/BMoA1NAdRUuulyvQC0pEqZ4vQbGfn8f7wPXcnqQmM+zct9UliKxsIkLNmz/ku7LElUNqmaiv1TG/aL5ACsw==} + engines: {node: '>=18'} + hasBin: true + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -7122,6 +7149,10 @@ snapshots: '@pkgr/core@0.1.1': {} + '@playwright/test@1.54.2': + dependencies: + playwright: 1.54.2 + '@polka/url@1.0.0-next.29': {} '@radix-ui/primitive@1.1.1': {} @@ -8981,6 +9012,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -10400,41 +10434,41 @@ snapshots: negotiator@1.0.0: {} - next-image-export-optimizer@1.18.0(next@14.2.29(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): + next-image-export-optimizer@1.18.0(next@14.2.29(@babel/core@7.26.0)(@playwright/test@1.54.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): dependencies: - next: 14.2.29(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 14.2.29(@babel/core@7.26.0)(@playwright/test@1.54.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 sharp: 0.33.5 typescript: 5.8.3 - next-query-params@5.0.1(next@14.2.29(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(use-query-params@2.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): + next-query-params@5.0.1(next@14.2.29(@babel/core@7.26.0)(@playwright/test@1.54.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(use-query-params@2.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): dependencies: - next: 14.2.29(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 14.2.29(@babel/core@7.26.0)(@playwright/test@1.54.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 tslib: 2.8.1 use-query-params: 2.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - next-sitemap@4.2.3(next@14.2.29(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): + next-sitemap@4.2.3(next@14.2.29(@babel/core@7.26.0)(@playwright/test@1.54.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): dependencies: '@corex/deepmerge': 4.0.43 '@next/env': 13.5.6 fast-glob: 3.3.3 minimist: 1.2.8 - next: 14.2.29(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 14.2.29(@babel/core@7.26.0)(@playwright/test@1.54.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-themes@0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - next-with-less@3.0.1(less-loader@12.2.0(less@4.2.1))(less@4.2.1)(next@14.2.29(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): + next-with-less@3.0.1(less-loader@12.2.0(less@4.2.1))(less@4.2.1)(next@14.2.29(@babel/core@7.26.0)(@playwright/test@1.54.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): dependencies: clone-deep: 4.0.1 less: 4.2.1 less-loader: 12.2.0(less@4.2.1) - next: 14.2.29(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 14.2.29(@babel/core@7.26.0)(@playwright/test@1.54.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - next@14.2.29(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next@14.2.29(@babel/core@7.26.0)(@playwright/test@1.54.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@next/env': 14.2.29 '@swc/helpers': 0.5.5 @@ -10455,25 +10489,26 @@ snapshots: '@next/swc-win32-arm64-msvc': 14.2.29 '@next/swc-win32-ia32-msvc': 14.2.29 '@next/swc-win32-x64-msvc': 14.2.29 + '@playwright/test': 1.54.2 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros - nextra-theme-docs@3.3.1(next@14.2.29(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nextra@3.3.1(patch_hash=ytqsrocfyxyupt7yg3khzgrhfe)(@types/react@18.3.23)(next@14.2.29(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + nextra-theme-docs@3.3.1(next@14.2.29(@babel/core@7.26.0)(@playwright/test@1.54.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nextra@3.3.1(patch_hash=ytqsrocfyxyupt7yg3khzgrhfe)(@types/react@18.3.23)(next@14.2.29(@babel/core@7.26.0)(@playwright/test@1.54.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@headlessui/react': 2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) clsx: 2.1.1 escape-string-regexp: 5.0.0 flexsearch: 0.7.43 - next: 14.2.29(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 14.2.29(@babel/core@7.26.0)(@playwright/test@1.54.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-themes: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - nextra: 3.3.1(patch_hash=ytqsrocfyxyupt7yg3khzgrhfe)(@types/react@18.3.23)(next@14.2.29(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3) + nextra: 3.3.1(patch_hash=ytqsrocfyxyupt7yg3khzgrhfe)(@types/react@18.3.23)(next@14.2.29(@babel/core@7.26.0)(@playwright/test@1.54.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) scroll-into-view-if-needed: 3.1.0 zod: 3.22.4 - nextra@3.3.1(patch_hash=ytqsrocfyxyupt7yg3khzgrhfe)(@types/react@18.3.23)(next@14.2.29(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3): + nextra@3.3.1(patch_hash=ytqsrocfyxyupt7yg3khzgrhfe)(@types/react@18.3.23)(next@14.2.29(@babel/core@7.26.0)(@playwright/test@1.54.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3): dependencies: '@formatjs/intl-localematcher': 0.5.10 '@headlessui/react': 2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -10496,7 +10531,7 @@ snapshots: mdast-util-gfm: 3.0.0 mdast-util-to-hast: 13.2.0 negotiator: 1.0.0 - next: 14.2.29(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 14.2.29(@babel/core@7.26.0)(@playwright/test@1.54.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) p-limit: 6.1.0 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -10766,6 +10801,14 @@ snapshots: exsolve: 1.0.7 pathe: 2.0.3 + playwright-core@1.54.2: {} + + playwright@1.54.2: + dependencies: + playwright-core: 1.54.2 + optionalDependencies: + fsevents: 2.3.2 + points-on-curve@0.2.0: {} points-on-path@0.2.1: diff --git a/public/images/next-image-export-optimizer-hashes.json b/public/images/next-image-export-optimizer-hashes.json index a837f1f8b4..0a24a53bb1 100644 --- a/public/images/next-image-export-optimizer-hashes.json +++ b/public/images/next-image-export-optimizer-hashes.json @@ -53,6 +53,7 @@ "/audience.f60c1c99.jpg": "pqx3E31xAO87mNEBlZKqCTX+LRiPlOuQThWQZf08A4A=", "/banner.10d4d66b.jpg": "9UJqBQ9RQu2sxDdJ5uaQr3crx2ZXrlOKMAmY82R8ZBA=", "/blur-bean-cropped.62af4aa2.webp": "rdPhhzi5e+RLv-u0B-uPkp-eCYnyGlO84Yn0zCLLG4c=", + "/blur-bean.21b930bd.webp": "eTUigN2JSyvccNXMnRwneZJ1YIeNnrVs3klseGSUa7o=", "/blur-bean.314cdc4a.webp": "YAysN2NZeYYWHNI8cFCabzsTifCknmbp-r+P1LAs1bE=", "/blur-bean.d5aa6d13.webp": "30xrtHSB6py7q6r2HxdKzm4gt8WoCiWRownamqyf3wM=", "/blur-bean.e3e29fde.webp": "P51vlY-ohTlEurj7zyND+Xs2UCfBaUZuNZhj9OEvmq4=", diff --git a/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-10.WEBP b/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-10.WEBP new file mode 100644 index 0000000000..c8cf6929c8 Binary files /dev/null and b/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-10.WEBP differ diff --git a/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-1080.WEBP b/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-1080.WEBP new file mode 100644 index 0000000000..16ba0a5a6b Binary files /dev/null and b/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-1080.WEBP differ diff --git a/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-1200.WEBP b/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-1200.WEBP new file mode 100644 index 0000000000..0d5d816a75 Binary files /dev/null and b/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-1200.WEBP differ diff --git a/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-128.WEBP b/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-128.WEBP new file mode 100644 index 0000000000..5a2907c765 Binary files /dev/null and b/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-128.WEBP differ diff --git a/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-16.WEBP b/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-16.WEBP new file mode 100644 index 0000000000..ef70ab3478 Binary files /dev/null and b/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-16.WEBP differ diff --git a/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-1920.WEBP b/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-1920.WEBP new file mode 100644 index 0000000000..ac4446c539 Binary files /dev/null and b/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-1920.WEBP differ diff --git a/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-256.WEBP b/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-256.WEBP new file mode 100644 index 0000000000..ca1c395cdb Binary files /dev/null and b/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-256.WEBP differ diff --git a/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-32.WEBP b/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-32.WEBP new file mode 100644 index 0000000000..bed65195de Binary files /dev/null and b/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-32.WEBP differ diff --git a/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-384.WEBP b/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-384.WEBP new file mode 100644 index 0000000000..a8e8886f4f Binary files /dev/null and b/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-384.WEBP differ diff --git a/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-48.WEBP b/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-48.WEBP new file mode 100644 index 0000000000..17ced7d2d4 Binary files /dev/null and b/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-48.WEBP differ diff --git a/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-64.WEBP b/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-64.WEBP new file mode 100644 index 0000000000..c9d764d993 Binary files /dev/null and b/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-64.WEBP differ diff --git a/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-640.WEBP b/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-640.WEBP new file mode 100644 index 0000000000..12271d7cf0 Binary files /dev/null and b/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-640.WEBP differ diff --git a/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-750.WEBP b/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-750.WEBP new file mode 100644 index 0000000000..399bcd1372 Binary files /dev/null and b/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-750.WEBP differ diff --git a/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-828.WEBP b/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-828.WEBP new file mode 100644 index 0000000000..0fb2767475 Binary files /dev/null and b/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-828.WEBP differ diff --git a/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-96.WEBP b/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-96.WEBP new file mode 100644 index 0000000000..9d62fe2f15 Binary files /dev/null and b/public/nextImageExportOptimizer/blur-bean.21b930bd-opt-96.WEBP differ diff --git a/src/components/footer/index.tsx b/src/components/footer/index.tsx index cb7363e365..90116e2290 100644 --- a/src/components/footer/index.tsx +++ b/src/components/footer/index.tsx @@ -50,7 +50,7 @@ const FOOTER_SECTIONS: FooterSection[] = [ { title: "GitHub", route: "https://github.com/graphql" }, { title: "Specification", - route: "/spec", + route: "https://spec.graphql.org", }, { title: "Libraries & Tools", route: "/code" }, { @@ -78,7 +78,7 @@ const FOOTER_SECTIONS: FooterSection[] = [ ), route: "/community/contribute/essential-links", }, - { title: "Landscape", route: "/landscape" }, + { title: "Landscape", route: "https://landscape.graphql.org" }, { title: "Shop", route: "https://store.graphql.org/" }, ], }, diff --git a/src/components/index-page/data-colocation/component-tree.tsx b/src/components/index-page/data-colocation/component-tree.tsx index d113383e3f..7b1aaeffcd 100644 --- a/src/components/index-page/data-colocation/component-tree.tsx +++ b/src/components/index-page/data-colocation/component-tree.tsx @@ -28,7 +28,7 @@ export const ComponentTree = forwardRef(function ComponentTree( -
+
@@ -36,7 +36,7 @@ export const ComponentTree = forwardRef(function ComponentTree(
-
+
@@ -44,7 +44,7 @@ export const ComponentTree = forwardRef(function ComponentTree(
-
+
@@ -53,7 +53,7 @@ export const ComponentTree = forwardRef(function ComponentTree(
-
+
diff --git a/src/components/index-page/graphql-advantages/productivity.tsx b/src/components/index-page/graphql-advantages/productivity.tsx
index de65cec2e7..68963f278d 100644
--- a/src/components/index-page/graphql-advantages/productivity.tsx
+++ b/src/components/index-page/graphql-advantages/productivity.tsx
@@ -12,7 +12,8 @@ export function ProductivityFigure() {
           muted
           loop
           playsInline
-          className="hidden dark:block"
+          // the video is irrelevant to screen readers as its soundless
+          aria-hidden
           // @ts-expect-error @types/react doesn't support fetchPriority yet
           fetchpriority="low"
         >
diff --git a/src/components/index-page/hero/index.tsx b/src/components/index-page/hero/index.tsx
index 7fbd517f4b..f1da8e3dc4 100644
--- a/src/components/index-page/hero/index.tsx
+++ b/src/components/index-page/hero/index.tsx
@@ -4,6 +4,7 @@ import { Button } from "@/app/conf/_design-system/button"
 import { ImageLoaded } from "@/app/conf/2025/components/image-loaded"
 
 import logoBlurred from "./logo-blurred.webp"
+import Head from "next/head"
 
 export function Hero() {
   return (
@@ -42,9 +43,17 @@ export function Hero() {
 function HeroStripes() {
   return (
     
+ + + +
diff --git a/src/components/index-page/join-the-community.tsx b/src/components/index-page/join-the-community.tsx index aefed5dcdd..f43ca690f7 100644 --- a/src/components/index-page/join-the-community.tsx +++ b/src/components/index-page/join-the-community.tsx @@ -5,7 +5,7 @@ import { DiscordIcon } from "@/icons" export function JoinTheCommunity() { return ( -
+

Join the community

diff --git a/src/components/index-page/powered-by-community.tsx b/src/components/index-page/powered-by-community.tsx index cea90386db..2aafcaaedc 100644 --- a/src/components/index-page/powered-by-community.tsx +++ b/src/components/index-page/powered-by-community.tsx @@ -4,7 +4,7 @@ import ArrowDownIcon from "@/app/conf/_design-system/pixelarticons/arrow-down.sv export function PoweredByCommunity() { return ( -
+

diff --git a/src/components/index-page/use-cases/blur-bean.webp b/src/components/index-page/use-cases/blur-bean.webp new file mode 100644 index 0000000000..0b3b1a0e96 Binary files /dev/null and b/src/components/index-page/use-cases/blur-bean.webp differ diff --git a/src/components/index-page/use-cases/index.tsx b/src/components/index-page/use-cases/index.tsx new file mode 100644 index 0000000000..53c6ad0320 --- /dev/null +++ b/src/components/index-page/use-cases/index.tsx @@ -0,0 +1,214 @@ +"use client" + +import clsx from "clsx" +import { useState, Fragment, ReactNode } from "react" + +import { Button } from "@/app/conf/_design-system/button" +import { StripesDecoration } from "@/app/conf/_design-system/stripes-decoration" +import ArrowDownIcon from "@/app/conf/_design-system/pixelarticons/arrow-down.svg?svgr" + +import blurBean from "./blur-bean.webp" + +type UseCase = { + label: string + description: ReactNode + cta: string + href: string +} + +const USE_CASES: UseCase[] = [ + { + label: "A large backend with many services", + description: + "GraphQL serves as a unified data layer across multiple services. This way you simplify API management and reduce dependencies between teams. It enables efficient data fetching while keeping the API surface flexible and maintainable.", + cta: "Best Practices for Large-Scale Systems", + href: "/learn/best-practices", + }, + { + label: "A mobile app", + description: ( + <> + GraphQL lets you request exactly what you need in one call with no + overfetching to preserve battery and work on slow networks. With + libraries like{" "} + + GraphCache + {" "} + your app can work offline on planes and trains, and versionless schema + evolution makes it easy to iterate without breaking old versions of the + app. + + ), + cta: "Performance Optimization", + href: "/learn/performance", + }, + { + label: "A frontend-heavy app with advanced UI needs", + description: + "GraphQL makes building complex UIs easier by allowing components to declare their data needs directly alongside their code with no performance hit. You can aggregate data from multiple services into a single request and maintain consistent state without creating custom endpoints for every view.", + cta: "GraphQL Queries", + href: "/learn/queries", + }, + { + label: "An app with real-time updates", + description: + "Replace polling and complex WebSocket management with GraphQL subscriptions. Your app gets notified instantly when data changes, using the same queries and types you already have. Real-time becomes part of your API instead of a separate system to maintain.", + cta: "Real-time with Subscriptions", + href: "/learn/subscriptions", + }, + { + label: "A simple full stack TypeScript app", + description: + "Define your GraphQL schema once and GraphQL Codegen does the rest. Your frontend gets perfectly typed API calls, your backend stays in sync, and any schema changes immediately show up as TypeScript errors throughout your app. Full-stack type safety reduces bugs and makes pivots and refactors easier.", + cta: "Schema-First Development", + href: "/learn/schema", + }, + { + label: "An AI-powered app", + description: + "Build apps with soft core using GraphQL MCP. Your schema documents itself, so AI agents can discover your API capabilities, understand data relationships, and generate valid queries without custom integration work.", + cta: "MCP GraphQL", + href: "https://github.com/graphql/graphql-mcp", + }, +] + +export function UseCases({ + className, + ...props +}: React.HTMLAttributes) { + const [selectedIndex, setSelectedIndex] = useState(0) + const selected = USE_CASES[selectedIndex] + + return ( +
+
+
+

Is GraphQL right for me?

+

+ Choose a use case most relevant for your project and learn how + GraphQL can help you build faster, modern solutions. +

+ +
+
+ {USE_CASES.map((useCase, i) => ( + + ))} +
+
+
+ +
+ +
+ {USE_CASES.map((useCase, i) => ( + + +
+
+

+ {useCase.label} +

+

+ {useCase.description} +

+
+ +
+
+
+
+ ))} +
+
+
+
+ ) +} + +function Stripes() { + const mask = `url(${blurBean.src})` + return ( +
+ +
+ ) +} + +function arrowsMoveSideways(event: React.KeyboardEvent) { + if (event.key === "ArrowLeft" || event.key === "ArrowUp") { + const previousElement = event.currentTarget.previousElementSibling + if (previousElement) { + event.preventDefault() + ;(previousElement as HTMLElement).focus() + } + } else if (event.key === "ArrowRight" || event.key === "ArrowDown") { + const nextElement = event.currentTarget.nextElementSibling + if (nextElement) { + event.preventDefault() + ;(nextElement as HTMLElement).focus() + } + } +} diff --git a/src/components/index-page/what-is-graphql/api-gateway-query.mdx b/src/components/index-page/what-is-graphql/api-gateway-query.mdx index f1925f12c5..ff554c21ef 100644 --- a/src/components/index-page/what-is-graphql/api-gateway-query.mdx +++ b/src/components/index-page/what-is-graphql/api-gateway-query.mdx @@ -1,4 +1,4 @@ -```graphql +```graphql word-wrap=false query getCity($city: String) { cities(name: $city) { population diff --git a/src/components/index-page/what-is-graphql/api-gateway-response.mdx b/src/components/index-page/what-is-graphql/api-gateway-response.mdx index bd36d0b64a..2e24120f72 100644 --- a/src/components/index-page/what-is-graphql/api-gateway-response.mdx +++ b/src/components/index-page/what-is-graphql/api-gateway-response.mdx @@ -1,4 +1,4 @@ -```json +```json word-wrap=false { "data": { "cities": [ diff --git a/src/components/index-page/what-is-graphql/wires.tsx b/src/components/index-page/what-is-graphql/wires.tsx index 65f5468d4c..ff17025579 100644 --- a/src/components/index-page/what-is-graphql/wires.tsx +++ b/src/components/index-page/what-is-graphql/wires.tsx @@ -544,6 +544,7 @@ export function Wires({ className }: { className?: string }) { aria-label={step === 2 ? "Show query again" : "Next step"} className="absolute inset-0 outline-none" /> +
+

) } @@ -586,3 +589,16 @@ function moveHighlightedToTop(index: number | undefined, nodes: ReactNode[]) { newNodes.push(nodes[index] as ReactNode) return newNodes } + +function Curtain() { + return ( +
+ ) +} diff --git a/src/components/marked/index.tsx b/src/components/marked/index.tsx index 9cf4345f0f..39730051bc 100644 --- a/src/components/marked/index.tsx +++ b/src/components/marked/index.tsx @@ -1,998 +1,47 @@ -// @ts-nocheck -/** - * marked - a Markdown parser - * Copyright (c) 2011-2013, Christopher Jeffrey. (MIT Licensed) - * https://github.com/chjj/marked - */ -import { createElement } from "react" - -import { MiniGraphiQL } from "./mini-graphiQL" +import dynamic from "next/dynamic" import { StarWarsSchema } from "./swapi-schema" import { UsersSchema } from "./users-schema" -export function Marked(props: { children: string }) { - return marked(props.children, props) -} - -/** - * Block-Level Grammar - */ - -const block = { - newline: /^\n+/, - code: /^( {4}[^\n]+\n*)+/, - fences: noop, - hr: /^( *[-*_]){3,} *(?:\n+|$)/, - heading: /^ *(#{1,6}) *([^\n]+?) *#* *(?:\n+|$)/, - nptable: noop, - lheading: /^([^\n]+)\n *(=|-){3,} *\n*/, - blockquote: /^( *>[^\n]+(\n[^\n]+)*\n*)+/, - list: /^( *)(bull) [\s\S]+?(?:hr|\n{2,}(?! )(?!\1bull )\n*|\s*$)/, - html: /^ *(?:comment|closed|closing) *(?:\n{2,}|\s*$)/, - def: /^ *\[([^\]]+)\]: *]+)>?(?: +["(]([^\n]+)[")])? *(?:\n+|$)/, - table: noop, - paragraph: /^((?:[^\n]+\n?(?!hr|heading|lheading|blockquote|tag|def))+)\n*/, - text: /^[^\n]+/, -} - -block.bullet = /(?:[*+-]|\d+\.)/ -block.item = /^( *)(bull) [^\n]*(?:\n(?!\1bull )[^\n]*)*/ -block.item = replace(block.item, "gm")(/bull/g, block.bullet)() - -block.list = replace(block.list)(/bull/g, block.bullet)( - "hr", - /\n+(?=(?: *[-*_]){3,} *(?:\n+|$))/, -)() - -block._tag = - "(?!(?:" + - "a|em|strong|small|s|cite|q|dfn|abbr|data|time|code" + - "|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo" + - "|span|br|wbr|ins|del|img)\\b)\\w+(?!:/|@)\\b" - -block.html = replace(block.html)("comment", //)( - "closed", - /<(tag)[\s\S]+?<\/\1>/, -)("closing", /])*?>/)(/tag/g, block._tag)() - -block.paragraph = replace(block.paragraph)("hr", block.hr)( - "heading", - block.heading, -)("lheading", block.lheading)("blockquote", block.blockquote)( - "tag", - "<" + block._tag, -)("def", block.def)() - -/** - * Normal Block Grammar - */ - -block.normal = merge({}, block) - -/** - * GFM Block Grammar - */ - -block.gfm = merge({}, block.normal, { - //fences: /^ *(`{3,}|~{3,}) *(\S+)? *\n([\s\S]+?)\s*\1 *(?:\n+|$)/, - fences: - /^ *(`{3,}|~{3,}) *([^\s{]+)?(?: *\{ *((?:\d+(?: *- *\d+)?(?: *, *\d+(?: *- *\d+)?)*) *)?\})? *\n([\s\S]+?)\s*\1 *(?:\n+|$)/, - paragraph: /^/, -}) - -block.gfm.paragraph = replace(block.paragraph)( - "(?!", - "(?!" + block.gfm.fences.source.replace("\\1", "\\2") + "|", -)() - -/** - * GFM + Tables Block Grammar - */ - -block.tables = merge({}, block.gfm, { - nptable: /^ *(\S.*\|.*)\n *([-:]+ *\|[-| :]*)\n((?:.*\|.*(?:\n|$))*)\n*/, - table: /^ *\|(.+)\n *\|( *[-:]+[-| :]*)\n((?: *\|.*(?:\n|$))*)\n*/, -}) - -/** - * Block Lexer - */ - -function Lexer(options) { - this.tokens = [] - this.tokens.links = {} - this.options = options || marked.defaults - this.rules = block.normal - - if (this.options.gfm) { - if (this.options.tables) { - this.rules = block.tables - } else { - this.rules = block.gfm - } - } -} - -/** - * Expose Block Rules - */ - -Lexer.rules = block - -/** - * Static Lex Method - */ - -Lexer.lex = function (src, options) { - const lexer = new Lexer(options) - return lexer.lex(src) -} - -/** - * Preprocessing - */ - -Lexer.prototype.lex = function (src) { - src = src - .replace(/\r\n|\r/g, "\n") - .replace(/\t/g, " ") - .replace(/\u00a0/g, " ") - .replace(/\u2424/g, "\n") - - return this.token(src, true) -} - -/** - * Lexing - */ - -Lexer.prototype.token = function (src, top) { - src = src.replace(/^ +$/gm, "") - let next, loose, cap, bull, b, item, space, i, l - - while (src) { - // newline - if ((cap = this.rules.newline.exec(src))) { - src = src.substring(cap[0].length) - if (cap[0].length > 1) { - this.tokens.push({ - type: "space", - }) - } - } - - // code - if ((cap = this.rules.code.exec(src))) { - src = src.substring(cap[0].length) - cap = cap[0].replace(/^ {4}/gm, "") - this.tokens.push({ - type: "code", - text: !this.options.pedantic ? cap.replace(/\n+$/, "") : cap, - }) - continue - } - - // fences (gfm) - if ((cap = this.rules.fences.exec(src))) { - src = src.substring(cap[0].length) - this.tokens.push({ - type: "code", - lang: cap[2], - line: cap[3], - text: cap[4], - }) - continue - } - - // heading - if ((cap = this.rules.heading.exec(src))) { - src = src.substring(cap[0].length) - this.tokens.push({ - type: "heading", - depth: cap[1].length, - text: cap[2], - }) - continue - } - - // table no leading pipe (gfm) - if (top && (cap = this.rules.nptable.exec(src))) { - src = src.substring(cap[0].length) - - item = { - type: "table", - header: cap[1].replace(/^ *| *\| *$/g, "").split(/ *\| */), - align: cap[2].replace(/^ *|\| *$/g, "").split(/ *\| */), - cells: cap[3].replace(/\n$/, "").split("\n"), - } - - for (i = 0; i < item.align.length; i++) { - if (/^ *-+: *$/.test(item.align[i])) { - item.align[i] = "right" - } else if (/^ *:-+: *$/.test(item.align[i])) { - item.align[i] = "center" - } else if (/^ *:-+ *$/.test(item.align[i])) { - item.align[i] = "left" - } else { - item.align[i] = null - } - } - - for (i = 0; i < item.cells.length; i++) { - item.cells[i] = item.cells[i].split(/ *\| */) - } - - this.tokens.push(item) - - continue - } - - // lheading - if ((cap = this.rules.lheading.exec(src))) { - src = src.substring(cap[0].length) - this.tokens.push({ - type: "heading", - depth: cap[2] === "=" ? 1 : 2, - text: cap[1], - }) - continue - } - - // hr - if ((cap = this.rules.hr.exec(src))) { - src = src.substring(cap[0].length) - this.tokens.push({ - type: "hr", - }) - continue - } - - // blockquote - if ((cap = this.rules.blockquote.exec(src))) { - src = src.substring(cap[0].length) - - this.tokens.push({ - type: "blockquote_start", - }) - - cap = cap[0].replace(/^ *> ?/gm, "") +const SCHEMA_MAP = { + StarWars: StarWarsSchema, + Users: UsersSchema, +} as const - // Pass `top` to keep the current - // "toplevel" state. This is exactly - // how markdown.pl works. - this.token(cap, top) +type SchemaKey = keyof typeof SCHEMA_MAP - this.tokens.push({ - type: "blockquote_end", - }) - - continue - } - - // list - if ((cap = this.rules.list.exec(src))) { - src = src.substring(cap[0].length) - bull = cap[2] - - this.tokens.push({ - type: "list_start", - ordered: bull.length > 1, - }) - - // Get each top-level item. - cap = cap[0].match(this.rules.item) - - next = false - l = cap.length - i = 0 - - for (; i < l; i++) { - item = cap[i] - - // Remove the list item's bullet - // so it is seen as the next token. - space = item.length - item = item.replace(/^ *([*+-]|\d+\.) +/, "") - - // Outdent whatever the - // list item contains. Hacky. - if (~item.indexOf("\n ")) { - space -= item.length - item = !this.options.pedantic - ? item.replace(new RegExp("^ {1," + space + "}", "gm"), "") - : item.replace(/^ {1,4}/gm, "") - } - - // Determine whether the next list item belongs here. - // Backpedal if it does not belong in this list. - if (this.options.smartLists && i !== l - 1) { - b = block.bullet.exec(cap[i + 1])[0] - if (bull !== b && !(bull.length > 1 && b.length > 1)) { - src = cap.slice(i + 1).join("\n") + src - i = l - 1 - } - } - - // Determine whether item is loose or not. - // Use: /(^|\n)(?! )[^\n]+\n\n(?!\s*$)/ - // for discount behavior. - loose = next || /\n\n(?!\s*$)/.test(item) - if (i !== l - 1) { - next = item[item.length - 1] === "\n" - if (!loose) loose = next - } - - this.tokens.push({ - type: loose ? "loose_item_start" : "list_item_start", - }) - - // Recurse. - this.token(item, false) - - this.tokens.push({ - type: "list_item_end", - }) - } - - this.tokens.push({ - type: "list_end", - }) - - continue - } - - // html - if ((cap = this.rules.html.exec(src))) { - src = src.substring(cap[0].length) - this.tokens.push({ - type: this.options.sanitize ? "paragraph" : "html", - pre: cap[1] === "pre" || cap[1] === "script", - text: cap[0], - }) - continue - } - - // def - if (top && (cap = this.rules.def.exec(src))) { - src = src.substring(cap[0].length) - this.tokens.links[cap[1].toLowerCase()] = { - href: cap[2], - title: cap[3], - } - continue - } - - // table (gfm) - if (top && (cap = this.rules.table.exec(src))) { - src = src.substring(cap[0].length) - - item = { - type: "table", - header: cap[1].replace(/^ *| *\| *$/g, "").split(/ *\| */), - align: cap[2].replace(/^ *|\| *$/g, "").split(/ *\| */), - cells: cap[3].replace(/(?: *\| *)?\n$/, "").split("\n"), - } - - for (i = 0; i < item.align.length; i++) { - if (/^ *-+: *$/.test(item.align[i])) { - item.align[i] = "right" - } else if (/^ *:-+: *$/.test(item.align[i])) { - item.align[i] = "center" - } else if (/^ *:-+ *$/.test(item.align[i])) { - item.align[i] = "left" - } else { - item.align[i] = null - } - } - - for (i = 0; i < item.cells.length; i++) { - item.cells[i] = item.cells[i] - .replace(/^ *\| *| *\| *$/g, "") - .split(/ *\| */) - } - - this.tokens.push(item) - - continue - } - - // top-level paragraph - if (top && (cap = this.rules.paragraph.exec(src))) { - src = src.substring(cap[0].length) - this.tokens.push({ - type: "paragraph", - text: cap[1][cap[1].length - 1] === "\n" ? cap[1].slice(0, -1) : cap[1], - }) - continue - } - - // text - if ((cap = this.rules.text.exec(src))) { - // Top-level should never reach here. - src = src.substring(cap[0].length) - this.tokens.push({ - type: "text", - text: cap[0], - }) - continue - } - - if (src) { - throw new Error("Infinite loop on byte: " + src.charCodeAt(0)) - } - } - - return this.tokens -} - -/** - * Inline-Level Grammar - */ - -const inline = { - escape: /^\\([\\`*{}[\]()#+\-.!_>])/, - autolink: /^<([^ >]+(@|:\/)[^ >]+)>/, - url: noop, - tag: /^|^<\/?\w+(?:"[^"]*"|'[^']*'|[^'">])*?>/, - link: /^!?\[(inside)\]\(href\)/, - reflink: /^!?\[(inside)\]\s*\[([^\]]*)\]/, - nolink: /^!?\[((?:\[[^\]]*\]|[^[\]])*)\]/, - strong: /^__([\s\S]+?)__(?!_)|^\*\*([\s\S]+?)\*\*(?!\*)/, - em: /^\b_((?:__|[\s\S])+?)_\b|^\*((?:\*\*|[\s\S])+?)\*(?!\*)/, - code: /^(`+)\s*([\s\S]*?[^`])\s*\1(?!`)/, - br: /^ {2,}\n(?!\s*$)/, - del: noop, - text: /^[\s\S]+?(?=[\\?(?:\s+['"]([\s\S]*?)['"])?\s*/ - -inline.link = replace(inline.link)("inside", inline._inside)( - "href", - inline._href, -)() - -inline.reflink = replace(inline.reflink)("inside", inline._inside)() - -/** - * Normal Inline Grammar - */ - -inline.normal = merge({}, inline) - -/** - * Pedantic Inline Grammar - */ - -inline.pedantic = merge({}, inline.normal, { - strong: /^__(?=\S)([\s\S]*?\S)__(?!_)|^\*\*(?=\S)([\s\S]*?\S)\*\*(?!\*)/, - em: /^_(?=\S)([\s\S]*?\S)_(?!_)|^\*(?=\S)([\s\S]*?\S)\*(?!\*)/, -}) - -/** - * GFM Inline Grammar - */ - -inline.gfm = merge({}, inline.normal, { - escape: replace(inline.escape)("])", "~|])")(), - url: /^(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/, - del: /^~~(?=\S)([\s\S]*?\S)~~/, - text: replace(inline.text)("]|", "~]|")("|", "|https?://|")(), -}) - -/** - * GFM + Line Breaks Inline Grammar - */ - -inline.breaks = merge({}, inline.gfm, { - br: replace(inline.br)("{2,}", "*")(), - text: replace(inline.gfm.text)("{2,}", "*")(), -}) - -/** - * Inline Lexer & Compiler - */ - -function InlineLexer(links, options) { - this.options = options || marked.defaults - this.links = links - this.rules = inline.normal - - if (!this.links) { - throw new Error("Tokens array requires a `links` property.") - } - - if (this.options.gfm) { - if (this.options.breaks) { - this.rules = inline.breaks - } else { - this.rules = inline.gfm - } - } else if (this.options.pedantic) { - this.rules = inline.pedantic - } -} +const MiniGraphiQL = dynamic(() => import("./mini-graphiQL"), { ssr: true }) -/** - * Expose Inline Rules - */ +export function Marked({ children }: { children: string }) { + const codeMatch = children.match(/```graphql\s*\n([\s\S]*?)```/) + const blockContent = codeMatch?.[1] + const [firstLine, ...rest] = (blockContent || "").split("\n") + // First line must contain the metadata JSON comment: # { … } + const metaMatch = firstLine.match(/^\s*#\s*({.*})\s*$/) -InlineLexer.rules = inline - -/** - * Static Lexing/Compiling Method - */ - -InlineLexer.output = function (src, links, options) { - const inline = new InlineLexer(links, options) - return inline.output(src) -} - -/** - * Lexing/Compiling - */ - -InlineLexer.prototype.output = function (src) { - const out = [] - let link, text, href, cap - - while (src) { - // escape - if ((cap = this.rules.escape.exec(src))) { - src = src.substring(cap[0].length) - out.push(cap[1]) - continue - } - - // autolink - if ((cap = this.rules.autolink.exec(src))) { - src = src.substring(cap[0].length) - if (cap[2] === "@") { - text = cap[1][6] === ":" ? cap[1].substring(7) : cap[1] - href = "mailto:" + text - } else { - text = cap[1] - href = text - } - - out.push( - createElement("a", { href: this.sanitizeUrl(href), key: href }, text), - ) - continue - } - - // url (gfm) - if ((cap = this.rules.url.exec(src))) { - src = src.substring(cap[0].length) - text = cap[1] - href = text - out.push( - createElement("a", { href: this.sanitizeUrl(href), key: href }, text), - ) - continue - } - - // tag - if ((cap = this.rules.tag.exec(src))) { - src = src.substring(cap[0].length) - // TODO(alpert): Don't escape if sanitize is false - out.push(cap[0]) - continue - } - - // link - if ((cap = this.rules.link.exec(src))) { - src = src.substring(cap[0].length) - out.push( - this.outputLink(cap, { - href: cap[2], - title: cap[3], - }), - ) - continue - } - - // reflink, nolink - if ( - (cap = this.rules.reflink.exec(src)) || - (cap = this.rules.nolink.exec(src)) - ) { - src = src.substring(cap[0].length) - link = (cap[2] || cap[1]).replace(/\s+/g, " ") - link = this.links[link.toLowerCase()] - if (!link || !link.href) { - out.push(...this.output(cap[0][0])) - src = cap[0].substring(1) + src - continue - } - out.push(this.outputLink(cap, link)) - continue - } - - // strong - if ((cap = this.rules.strong.exec(src))) { - src = src.substring(cap[0].length) - out.push( - createElement( - "strong", - { key: src.length }, - this.output(cap[2] || cap[1]), - ), - ) - continue - } - - // em - if ((cap = this.rules.em.exec(src))) { - src = src.substring(cap[0].length) - out.push( - createElement("em", { key: src.length }, this.output(cap[2] || cap[1])), - ) - continue - } - - // code - if ((cap = this.rules.code.exec(src))) { - src = src.substring(cap[0].length) - out.push(createElement("code", { key: src.length }, cap[2])) - continue - } - - // br - if ((cap = this.rules.br.exec(src))) { - src = src.substring(cap[0].length) - out.push(createElement("br", { key: src.length }, null)) - continue - } - - // del (gfm) - if ((cap = this.rules.del.exec(src))) { - src = src.substring(cap[0].length) - out.push(createElement("del", { key: src.length }, this.output(cap[1]))) - continue - } - - // text - if ((cap = this.rules.text.exec(src))) { - src = src.substring(cap[0].length) - out.push(this.smartypants(cap[0])) - continue - } - - if (src) { - throw new Error("Infinite loop on byte: " + src.charCodeAt(0)) - } - } - - return out -} - -/** - * Sanitize a URL for a link or image - */ - -InlineLexer.prototype.sanitizeUrl = function (url) { - if (this.options.sanitize) { - try { - const prot = decodeURIComponent(url) - .replace(/[^A-Za-z0-9:]/g, "") - .toLowerCase() - if (prot.indexOf("javascript:") === 0) { - return "#" - } - } catch (e) { - return "#" - } - } - return url -} - -/** - * Compile Link - */ - -InlineLexer.prototype.outputLink = function (cap, link) { - if (cap[0][0] !== "!") { - const shouldOpenInNewWindow = - link.href.charAt(0) !== "/" && link.href.charAt(0) !== "#" - - return createElement( - "a", - { - href: this.sanitizeUrl(link.href), - title: link.title, - target: shouldOpenInNewWindow ? "_blank" : null, - rel: shouldOpenInNewWindow ? "nofollow noopener noreferrer" : null, - key: link.href, - }, - this.output(cap[1]), - ) - } else { - return createElement( - "img", - { - src: this.sanitizeUrl(link.href), - alt: cap[1], - title: link.title, - key: link.href, - }, - null, + if (!metaMatch) { + throw new Error( + `Invalid GraphiQL metadata JSON: ${firstLine}. MiniGraphQL shouldn't be used here.`, ) } -} - -/** - * Smartypants Transformations - */ - -InlineLexer.prototype.smartypants = function (text) { - if (!this.options.smartypants) return text - return text - .replace(/--/g, "\u2014") - .replace(/'([^']*)'/g, "\u2018$1\u2019") - .replace(/"([^"]*)"/g, "\u201C$1\u201D") - .replace(/\.{3}/g, "\u2026") -} - -/** - * Parsing & Compiling - */ - -function Parser(options) { - this.tokens = [] - this.token = null - this.options = options || marked.defaults - this.usedSlugs = {} -} - -/** - * Static Parse Method - */ - -Parser.parse = function (src, options) { - const parser = new Parser(options) - return parser.parse(src) -} - -/** - * Parse Loop - */ - -Parser.prototype.parse = function (src) { - this.inline = new InlineLexer(src.links, this.options) - this.tokens = src.reverse() - - const out = [] - while (this.next()) { - out.push(this.tok()) - } - - return out -} - -/** - * Next Token - */ -Parser.prototype.next = function () { - return (this.token = this.tokens.pop()) -} - -/** - * Preview Next Token - */ - -Parser.prototype.peek = function () { - return this.tokens[this.tokens.length - 1] || 0 -} - -/** - * Parse Text Tokens - */ - -Parser.prototype.parseText = function () { - let body = this.token.text - - while (this.peek().type === "text") { - body += "\n" + this.next().text - } - - return this.inline.output(body) -} - -/** - * Parse Current Token - */ - -Parser.prototype.tok = function () { - switch (this.token.type) { - case "code": { - if (this.token.lang === "graphql") { - const lines = this.token.text.split("\n") - const firstLine = lines.shift().match(/^\s*#\s*({.*})$/) - if (firstLine) { - let metaData - try { - metaData = JSON.parse(firstLine[1]) - } catch (e) { - console.error("Invalid Metadata JSON:", firstLine[1]) - } - if (metaData) { - const query = lines.join("\n") - const variables = metaData.variables - ? JSON.stringify(metaData.variables, null, 2) - : "" - const schemaMap = { - StarWars: StarWarsSchema, - Users: UsersSchema, - } - const schema = schemaMap[metaData.schema || "StarWars"] - return ( - - ) - } - } - } - } - } -} - -function replace(regex, opt) { - regex = regex.source - opt = opt || "" - return function self(name, val) { - if (!name) return new RegExp(regex, opt) - val = val.source || val - val = val.replace(/(^|[^[])\^/g, "$1") - regex = regex.replace(name, val) - return self - } -} - -function noop() {} - -noop.exec = noop - -function merge(obj) { - let i = 1, - target, - key - - for (; i < arguments.length; i++) { - target = arguments[i] - for (key in target) { - if (Object.prototype.hasOwnProperty.call(target, key)) { - obj[key] = target[key] - } - } - } - - return obj -} - -/** - * Marked - */ - -function marked(src, opt, callback) { - if (callback || typeof opt === "function") { - if (!callback) { - callback = opt - opt = null - } - - if (opt) opt = merge({}, marked.defaults, opt) - - const highlight = opt.highlight - let tokens, - pending, - i = 0 - - try { - tokens = Lexer.lex(src, opt) - } catch (e) { - return callback(e) - } - - pending = tokens.length - - const done = function (hi) { - let out, err - - if (hi !== true) { - delete opt.highlight - } - - try { - out = Parser.parse(tokens, opt) - } catch (e) { - err = e - } - - opt.highlight = highlight - - return err ? callback(err) : callback(null, out) - } - - if (!highlight || highlight.length < 3) { - return done(true) - } - - if (!pending) return done() - - for (; i < tokens.length; i++) { - ;(function (token) { - if (token.type !== "code") { - return --pending || done() - } - return highlight(token.text, token.lang, function (err, code) { - if (code == null || code === token.text) { - return --pending || done() - } - token.text = code - token.escaped = true - --pending || done() - }) - })(tokens[i]) - } - - return - } + let meta: Metadata try { - if (opt) opt = merge({}, marked.defaults, opt) - return Parser.parse(Lexer.lex(src, opt), opt) - } catch (e) { - e.message += "\nPlease report this to https://github.com/chjj/marked." - if ((opt || marked.defaults).silent) { - return [ - createElement("p", null, "An error occurred:"), - createElement("pre", null, e.message), - ] - } - throw e + meta = JSON.parse(metaMatch[1]) as Metadata + } catch { + throw new Error(`Invalid GraphiQL metadata JSON: ${metaMatch[1]}`) } -} - -/** - * Options - */ -marked.options = marked.setOptions = function (opt) { - merge(marked.defaults, opt) - return marked -} + const query = rest.join("\n") + const variables = meta.variables + ? JSON.stringify(meta.variables, null, 2) + : "" + const schema = SCHEMA_MAP[meta.schema ?? "StarWars"] -marked.defaults = { - gfm: true, - tables: true, - breaks: false, - pedantic: false, - sanitize: false, - smartLists: false, - silent: false, - highlight: null, - langPrefix: "lang-", - smartypants: false, - paragraphFn: null, + return } - -/** - * Expose - */ - -marked.Parser = Parser -marked.parser = Parser.parse - -marked.Lexer = Lexer -marked.lexer = Lexer.lex - -marked.InlineLexer = InlineLexer -marked.inlineLexer = InlineLexer.output - -marked.parse = marked diff --git a/src/components/marked/mini-graphiQL.tsx b/src/components/marked/mini-graphiQL.tsx index 1ed44a30d1..37c1d3e8db 100644 --- a/src/components/marked/mini-graphiQL.tsx +++ b/src/components/marked/mini-graphiQL.tsx @@ -12,7 +12,13 @@ import { marked } from "marked" import { graphql, formatError, parse, typeFromAST } from "graphql" -export class MiniGraphiQL extends Component { +export type MiniGraphiQLProps = { + schema: GraphQLSchema + query: string + variables: string +} + +export default class MiniGraphiQL extends Component { // Lifecycle constructor(props) { diff --git a/test/e2e/graphql-interactive.spec.ts b/test/e2e/graphql-interactive.spec.ts new file mode 100644 index 0000000000..990db07467 --- /dev/null +++ b/test/e2e/graphql-interactive.spec.ts @@ -0,0 +1,144 @@ +import { test, expect, type Locator } from "@playwright/test" + +test.describe("interactive examples", () => { + test("adds appearsIn field to hero query and gets correct response", async ({ + page, + }) => { + await page.goto("/learn") + await page.waitForSelector(".CodeMirror", { timeout: 10000 }) + + const editors = page.locator(".miniGraphiQL") + let heroEditor: Locator | null = null + + for (let i = 0; i < (await editors.count()); i++) { + const editor = editors.nth(i) + const content = await editor.textContent() + if (content && content.includes("hero")) { + heroEditor = editor + break + } + } + + if (!heroEditor) { + throw new Error("Could not find hero GraphQL editor") + } + + const codeMirrorEditor = heroEditor.locator(".CodeMirror").first() + await expect(codeMirrorEditor).toBeVisible() + + await codeMirrorEditor.click() + + const codeLines = codeMirrorEditor.locator(".CodeMirror-line") + + // Find the line containing "name" and click after it + for (let i = 0; i < (await codeLines.count()); i++) { + const line = codeLines.nth(i) + const lineText = await line.textContent() + if (lineText && lineText.includes("name")) { + await line.click() + // Move to end of line + await page.keyboard.press("End") + // Add new line + await page.keyboard.press("Enter") + break + } + } + + await page.keyboard.type("ap") + await page.keyboard.press("Control+Space") + + const autoCompleteMenu = page.locator(".CodeMirror-hints") + await expect(autoCompleteMenu).toBeVisible({ timeout: 5000 }) + + const appearsInSuggestion = page + .locator(".CodeMirror-hints li") + .filter({ hasText: "appearsIn" }) + + if (await appearsInSuggestion.isVisible()) { + await appearsInSuggestion.click() + } else { + await page.keyboard.press("Enter") + } + + const resultViewer = heroEditor.locator(".result-window") + await expect(resultViewer).toBeVisible() + + await expect + .poll(async () => { + const resultContent = await resultViewer.textContent() + const jsonMatch = resultContent?.match(/\{[\s\S]*\}/) + if (jsonMatch) { + const responseJson = JSON.parse(jsonMatch[0]) + return responseJson + } + + return {} + }) + .toStrictEqual({ + data: { + hero: { + name: "R2-D2", + appearsIn: ["NEWHOPE", "EMPIRE", "JEDI"], + }, + }, + }) + }) + + test("edits variables and receives an expected mutation result", async ({ + page, + }) => { + await page.goto("/learn/mutations") + await page.waitForLoadState("networkidle") + + // Find the mutation example that has GraphiQL enabled + const editors = page.locator(".miniGraphiQL") + let mutationEditor: Locator | null = null + + for (let i = 0; i < (await editors.count()); i++) { + const editor = editors.nth(i) + const content = await editor.textContent() + if (content && content.includes("CreateReviewForEpisode")) { + mutationEditor = editor + break + } + } + + if (!mutationEditor) { + throw new Error("Could not find mutation GraphQL editor") + } + + const variableEditor = mutationEditor.locator(".variable-editor").first() + + if (await variableEditor.isVisible()) { + await variableEditor.click() + + await page.getByText('"This is a great movie!"').first().click() + await page.keyboard.press("ControlOrMeta+ArrowRight") + for (let i = 0; i < 4; i++) + await page.keyboard.press("Alt+Shift+ArrowLeft") + await page.keyboard.type('almost as good as Andor"') + + const resultViewer = mutationEditor.locator(".result-window") + await expect(resultViewer).toBeVisible() + + await expect + .poll(async () => { + const resultContent = await resultViewer.textContent() + const jsonMatch = resultContent?.match(/\{[\s\S]*\}/) + if (jsonMatch) { + const responseJson = JSON.parse(jsonMatch[0]) + return responseJson + } + return {} + }) + .toStrictEqual({ + data: { + createReview: { + stars: 5, + commentary: "This is almost as good as Andor", + }, + }, + }) + } + }) +}) diff --git a/vercel.json b/vercel.json index e777a27ba5..2959521ae3 100644 --- a/vercel.json +++ b/vercel.json @@ -577,8 +577,18 @@ }, { "source": "/schedule/", - "destination": "/conf/2024/schedule/", + "destination": "/conf/2025/schedule/", "permanent": false + }, + { + "source": "/spec", + "destination": "https://spec.graphql.org", + "permanent": true + }, + { + "source": "/landscape", + "destination": "https://landscape.graphql.org", + "permanent": true } ] }