Repository: garmeeh/next-seo Branch: main Commit: f74b86bbeaa4 Files: 286 Total size: 1.8 MB Directory structure: gitextract_boozz7yy/ ├── .changeset/ │ ├── README.md │ └── config.json ├── .editorconfig ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ ├── dependabot.yml │ ├── pull_request_template.md │ └── workflows/ │ ├── changeset-check.yml │ ├── changesets.yml │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .husky/ │ └── pre-commit ├── .npmignore ├── .prettierignore ├── .vscode/ │ └── settings.json ├── ADDING_NEW_COMPONENTS.md ├── AGENTS.md ├── CHANGELOG.md ├── CLAUDE.md ├── CONTRIBUTING.md ├── CUSTOM_COMPONENTS.md ├── LICENSE ├── LICENSE.md ├── LIST.md ├── README.md ├── eslint.config.mjs ├── examples/ │ └── app-router-showcase/ │ ├── .gitignore │ ├── CLAUDE.md │ ├── README.md │ ├── app/ │ │ ├── aggregate-rating/ │ │ │ └── page.tsx │ │ ├── aggregate-rating-restaurant/ │ │ │ └── page.tsx │ │ ├── article/ │ │ │ └── page.tsx │ │ ├── blog-posting/ │ │ │ └── page.tsx │ │ ├── breadcrumb/ │ │ │ ├── advanced/ │ │ │ │ └── page.tsx │ │ │ ├── multiple/ │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── carousel-course/ │ │ │ └── page.tsx │ │ ├── carousel-movie/ │ │ │ └── page.tsx │ │ ├── carousel-recipe/ │ │ │ └── page.tsx │ │ ├── carousel-restaurant/ │ │ │ └── page.tsx │ │ ├── carousel-summary/ │ │ │ └── page.tsx │ │ ├── claim-review/ │ │ │ └── page.tsx │ │ ├── claim-review-advanced/ │ │ │ └── page.tsx │ │ ├── claim-review-organization/ │ │ │ └── page.tsx │ │ ├── course/ │ │ │ └── page.tsx │ │ ├── course-list/ │ │ │ └── page.tsx │ │ ├── course-list-summary/ │ │ │ └── page.tsx │ │ ├── creative-work/ │ │ │ └── page.tsx │ │ ├── creative-work-blog/ │ │ │ └── page.tsx │ │ ├── creative-work-multiple/ │ │ │ └── page.tsx │ │ ├── creative-work-news/ │ │ │ └── page.tsx │ │ ├── custom-podcast/ │ │ │ └── page.tsx │ │ ├── custom-service/ │ │ │ └── page.tsx │ │ ├── dataset/ │ │ │ └── page.tsx │ │ ├── dataset-advanced/ │ │ │ └── page.tsx │ │ ├── dataset-catalog/ │ │ │ └── page.tsx │ │ ├── dataset-nested/ │ │ │ └── page.tsx │ │ ├── discussion-forum/ │ │ │ └── page.tsx │ │ ├── discussion-forum-advanced/ │ │ │ └── page.tsx │ │ ├── discussion-forum-deleted/ │ │ │ └── page.tsx │ │ ├── employer-aggregate-rating/ │ │ │ └── page.tsx │ │ ├── employer-aggregate-rating-advanced/ │ │ │ └── page.tsx │ │ ├── employer-aggregate-rating-custom-scale/ │ │ │ └── page.tsx │ │ ├── event/ │ │ │ └── page.tsx │ │ ├── event-cancelled/ │ │ │ └── page.tsx │ │ ├── event-free/ │ │ │ └── page.tsx │ │ ├── event-rescheduled/ │ │ │ └── page.tsx │ │ ├── faq/ │ │ │ └── page.tsx │ │ ├── faq-advanced/ │ │ │ └── page.tsx │ │ ├── faq-health/ │ │ │ └── page.tsx │ │ ├── globals.css │ │ ├── howto/ │ │ │ └── page.tsx │ │ ├── howto-advanced/ │ │ │ └── page.tsx │ │ ├── image/ │ │ │ └── page.tsx │ │ ├── image-advanced/ │ │ │ └── page.tsx │ │ ├── image-multiple/ │ │ │ └── page.tsx │ │ ├── job-posting/ │ │ │ └── page.tsx │ │ ├── job-posting-advanced/ │ │ │ └── page.tsx │ │ ├── job-posting-remote/ │ │ │ └── page.tsx │ │ ├── jsonld-test-page/ │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── local-business/ │ │ │ └── page.tsx │ │ ├── merchant-return-policy/ │ │ │ └── page.tsx │ │ ├── merchant-return-policy-advanced/ │ │ │ └── page.tsx │ │ ├── merchant-return-policy-link/ │ │ │ └── page.tsx │ │ ├── mobile-app/ │ │ │ └── page.tsx │ │ ├── movie-carousel/ │ │ │ └── page.tsx │ │ ├── movie-carousel-advanced/ │ │ │ └── page.tsx │ │ ├── movie-carousel-summary/ │ │ │ └── page.tsx │ │ ├── news-article/ │ │ │ └── page.tsx │ │ ├── online-store/ │ │ │ └── page.tsx │ │ ├── online-store-loyalty/ │ │ │ └── page.tsx │ │ ├── organization/ │ │ │ └── page.tsx │ │ ├── organization-advanced/ │ │ │ └── page.tsx │ │ ├── organization-reviews/ │ │ │ └── page.tsx │ │ ├── page.module.css │ │ ├── page.tsx │ │ ├── product/ │ │ │ └── page.tsx │ │ ├── product-3d-model/ │ │ │ └── page.tsx │ │ ├── product-aggregate/ │ │ │ └── page.tsx │ │ ├── product-certification/ │ │ │ └── page.tsx │ │ ├── product-member-pricing/ │ │ │ └── page.tsx │ │ ├── product-review/ │ │ │ └── page.tsx │ │ ├── product-sale-pricing/ │ │ │ └── page.tsx │ │ ├── product-shipping-options/ │ │ │ └── page.tsx │ │ ├── product-unit-pricing/ │ │ │ └── page.tsx │ │ ├── product-variants/ │ │ │ └── page.tsx │ │ ├── product-variants-advanced/ │ │ │ └── page.tsx │ │ ├── product-variants-multipage/ │ │ │ └── page.tsx │ │ ├── product-with-return-policy/ │ │ │ └── page.tsx │ │ ├── profile/ │ │ │ └── page.tsx │ │ ├── profile-advanced/ │ │ │ └── page.tsx │ │ ├── profile-organization/ │ │ │ └── page.tsx │ │ ├── quiz/ │ │ │ └── page.tsx │ │ ├── quiz-advanced/ │ │ │ └── page.tsx │ │ ├── quiz-biology/ │ │ │ └── page.tsx │ │ ├── recipe/ │ │ │ └── page.tsx │ │ ├── recipe-advanced/ │ │ │ └── page.tsx │ │ ├── restaurant/ │ │ │ └── page.tsx │ │ ├── review/ │ │ │ └── page.tsx │ │ ├── review-advanced/ │ │ │ └── page.tsx │ │ ├── review-movie/ │ │ │ └── page.tsx │ │ ├── social-media-posting/ │ │ │ └── page.tsx │ │ ├── software-app/ │ │ │ └── page.tsx │ │ ├── software-app-paid/ │ │ │ └── page.tsx │ │ ├── store-with-departments/ │ │ │ └── page.tsx │ │ ├── test-arrays/ │ │ │ └── page.tsx │ │ ├── test-nested/ │ │ │ └── page.tsx │ │ ├── test-url-params/ │ │ │ └── page.tsx │ │ ├── vacation-rental/ │ │ │ └── page.tsx │ │ ├── vacation-rental-advanced/ │ │ │ └── page.tsx │ │ ├── vacation-rental-apartment/ │ │ │ └── page.tsx │ │ ├── video/ │ │ │ └── page.tsx │ │ ├── video-advanced/ │ │ │ └── page.tsx │ │ ├── video-clips/ │ │ │ └── page.tsx │ │ ├── video-game/ │ │ │ └── page.tsx │ │ ├── video-live/ │ │ │ └── page.tsx │ │ ├── video-seekto/ │ │ │ └── page.tsx │ │ └── web-app/ │ │ └── page.tsx │ ├── components/ │ │ └── custom/ │ │ ├── PodcastSeriesJsonLd.tsx │ │ └── ServiceJsonLd.tsx │ ├── eslint.config.mjs │ ├── next.config.ts │ ├── package.json │ └── tsconfig.json ├── package.json ├── playwright.config.ts ├── pnpm-workspace.yaml ├── repomix.config.json ├── src/ │ ├── components/ │ │ ├── .gitkeep │ │ ├── AggregateRatingJsonLd.test.tsx │ │ ├── AggregateRatingJsonLd.tsx │ │ ├── ArticleJsonLd.test.tsx │ │ ├── ArticleJsonLd.tsx │ │ ├── BreadcrumbJsonLd.test.tsx │ │ ├── BreadcrumbJsonLd.tsx │ │ ├── CLAUDE.md │ │ ├── CarouselJsonLd.test.tsx │ │ ├── CarouselJsonLd.tsx │ │ ├── ClaimReviewJsonLd.test.tsx │ │ ├── ClaimReviewJsonLd.tsx │ │ ├── CourseJsonLd.test.tsx │ │ ├── CourseJsonLd.tsx │ │ ├── CreativeWorkJsonLd.test.tsx │ │ ├── CreativeWorkJsonLd.tsx │ │ ├── DatasetJsonLd.test.tsx │ │ ├── DatasetJsonLd.tsx │ │ ├── DiscussionForumPostingJsonLd.test.tsx │ │ ├── DiscussionForumPostingJsonLd.tsx │ │ ├── EmployerAggregateRatingJsonLd.test.tsx │ │ ├── EmployerAggregateRatingJsonLd.tsx │ │ ├── EventJsonLd.test.tsx │ │ ├── EventJsonLd.tsx │ │ ├── FAQJsonLd.test.tsx │ │ ├── FAQJsonLd.tsx │ │ ├── HowToJsonLd.test.tsx │ │ ├── HowToJsonLd.tsx │ │ ├── ImageJsonLd.test.tsx │ │ ├── ImageJsonLd.tsx │ │ ├── JobPostingJsonLd.test.tsx │ │ ├── JobPostingJsonLd.tsx │ │ ├── LocalBusinessJsonLd.test.tsx │ │ ├── LocalBusinessJsonLd.tsx │ │ ├── MerchantReturnPolicyJsonLd.test.tsx │ │ ├── MerchantReturnPolicyJsonLd.tsx │ │ ├── MovieCarouselJsonLd.test.tsx │ │ ├── MovieCarouselJsonLd.tsx │ │ ├── OrganizationJsonLd.test.tsx │ │ ├── OrganizationJsonLd.tsx │ │ ├── ProductJsonLd.test.tsx │ │ ├── ProductJsonLd.tsx │ │ ├── ProfilePageJsonLd.test.tsx │ │ ├── ProfilePageJsonLd.tsx │ │ ├── QuizJsonLd.test.tsx │ │ ├── QuizJsonLd.tsx │ │ ├── RecipeJsonLd.test.tsx │ │ ├── RecipeJsonLd.tsx │ │ ├── ReviewJsonLd.test.tsx │ │ ├── ReviewJsonLd.tsx │ │ ├── SoftwareApplicationJsonLd.test.tsx │ │ ├── SoftwareApplicationJsonLd.tsx │ │ ├── VacationRentalJsonLd.test.tsx │ │ ├── VacationRentalJsonLd.tsx │ │ ├── VideoJsonLd.test.tsx │ │ └── VideoJsonLd.tsx │ ├── core/ │ │ ├── JsonLdScript.test.tsx │ │ └── JsonLdScript.tsx │ ├── index.ts │ ├── pages/ │ │ ├── README.md │ │ ├── core/ │ │ │ ├── __snapshots__/ │ │ │ │ └── buildTags.test.tsx.snap │ │ │ ├── buildTags.test.tsx │ │ │ └── buildTags.tsx │ │ ├── index.ts │ │ ├── types/ │ │ │ └── index.ts │ │ └── utils/ │ │ └── processors.ts │ ├── types/ │ │ ├── article.types.ts │ │ ├── breadcrumb.types.ts │ │ ├── carousel.types.ts │ │ ├── claimreview.types.ts │ │ ├── common.types.ts │ │ ├── course.types.ts │ │ ├── creativework.types.ts │ │ ├── dataset.types.ts │ │ ├── discussionforum.types.ts │ │ ├── employer-aggregate-rating.types.ts │ │ ├── event.types.ts │ │ ├── faq.types.ts │ │ ├── howto.types.ts │ │ ├── image.types.ts │ │ ├── index.ts │ │ ├── jobposting.types.ts │ │ ├── localbusiness.types.ts │ │ ├── merchantreturnpolicy.types.ts │ │ ├── movie-carousel.types.ts │ │ ├── organization.types.ts │ │ ├── product.types.ts │ │ ├── profile.types.ts │ │ ├── quiz.types.ts │ │ ├── recipe.types.ts │ │ ├── review.types.ts │ │ ├── softwareApplication.types.ts │ │ ├── vacationrental.types.ts │ │ └── video.types.ts │ └── utils/ │ ├── processors.export.ts │ ├── processors.test.ts │ ├── processors.ts │ ├── stringify.test.ts │ └── stringify.ts ├── tests/ │ ├── e2e/ │ │ ├── .gitkeep │ │ ├── CLAUDE.md │ │ ├── aggregateRatingJsonLd.e2e.spec.ts │ │ ├── articleJsonLd.e2e.spec.ts │ │ ├── breadcrumbJsonLd.e2e.spec.ts │ │ ├── carouselJsonLd.e2e.spec.ts │ │ ├── claimReviewJsonLd.e2e.spec.ts │ │ ├── courseJsonLd.e2e.spec.ts │ │ ├── creativeWorkJsonLd.e2e.spec.ts │ │ ├── customComponents.e2e.spec.ts │ │ ├── datasetJsonLd.e2e.spec.ts │ │ ├── discussionForumPostingJsonLd.e2e.spec.ts │ │ ├── employerAggregateRatingJsonLd.e2e.spec.ts │ │ ├── eventJsonLd.e2e.spec.ts │ │ ├── faqJsonLd.e2e.spec.ts │ │ ├── howtoJsonLd.e2e.spec.ts │ │ ├── imageJsonLd.e2e.spec.ts │ │ ├── jobPostingJsonLd.e2e.spec.ts │ │ ├── jsonLdScript.e2e.spec.ts │ │ ├── jsonValidation.e2e.spec.ts │ │ ├── localBusinessJsonLd.e2e.spec.ts │ │ ├── merchantReturnPolicyJsonLd.e2e.spec.ts │ │ ├── movieCarouselJsonLd.e2e.spec.ts │ │ ├── organizationJsonLd.e2e.spec.ts │ │ ├── productJsonLd.e2e.spec.ts │ │ ├── profilePageJsonLd.e2e.spec.ts │ │ ├── quizJsonLd.e2e.spec.ts │ │ ├── recipeJsonLd.e2e.spec.ts │ │ ├── reviewJsonLd.e2e.spec.ts │ │ ├── security.e2e.spec.ts │ │ ├── softwareApplicationJsonLd.e2e.spec.ts │ │ ├── vacationRentalJsonLd.e2e.spec.ts │ │ └── videoJsonLd.e2e.spec.ts │ └── unit/ │ └── setup.ts ├── tsconfig.json ├── tsup.config.ts └── vitest.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .changeset/README.md ================================================ # Changesets Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works with multi-package repos, or single-package repos to help you version and publish your code. You can find the full documentation for it [in our repository](https://github.com/changesets/changesets) We have a quick list of common questions to get you started engaging with this project in [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) ================================================ FILE: .changeset/config.json ================================================ { "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", "changelog": "@changesets/cli/changelog", "commit": false, "fixed": [], "linked": [], "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": ["app-router-showcase"] } ================================================ FILE: .editorconfig ================================================ root = true [*] indent_style = space indent_size = 2 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true [*.md] trim_trailing_whitespace = false ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: "" labels: "" assignees: "" --- **Describe the bug** A clear and concise description of what the bug is. **Reproduction** For issues to be triaged in a timely manner please provide a Codesandbox/Github of the issue in it's simplest reproduction. **Expected behavior** A clear and concise description of what you expected to happen. **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: "" labels: "" assignees: "" --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. Any links to Google or http://schema.org/ to support to validity in terms of SEO will be a great help. ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "npm" directory: "/" schedule: interval: "weekly" commit-message: prefix: "chore(deps)" open-pull-requests-limit: 5 ================================================ FILE: .github/pull_request_template.md ================================================ ================================================ FILE: .github/workflows/changeset-check.yml ================================================ name: Changeset Check on: pull_request: types: [opened, synchronize] jobs: changeset-check: name: Check for Changeset runs-on: ubuntu-latest if: github.event.pull_request.user.login != 'dependabot[bot]' && github.event.pull_request.user.login != 'github-actions[bot]' steps: - name: Checkout code uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm' - name: Install dependencies run: pnpm install --frozen-lockfile - name: Check for code changes id: code-changes run: | # Get list of changed files CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}..HEAD) # Check if any code files were changed (not just docs/config) CODE_CHANGED=false for file in $CHANGED_FILES; do # Check if file is a code file (not docs, config, or github workflows) if [[ "$file" =~ \.(ts|tsx|js|jsx)$ ]] && [[ ! "$file" =~ ^\.github/ ]] && [[ ! "$file" =~ \.(md|mdx)$ ]]; then CODE_CHANGED=true break fi done echo "code_changed=$CODE_CHANGED" >> $GITHUB_OUTPUT - name: Check for changeset if: steps.code-changes.outputs.code_changed == 'true' run: | pnpm changeset status --since=origin/${{ github.base_ref }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Comment on PR if changeset is missing if: failure() && steps.code-changes.outputs.code_changed == 'true' uses: actions/github-script@v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const body = `## 📝 Changeset Required This PR includes code changes but is missing a changeset. Changesets help us: - Track changes for release notes - Determine version bumps (patch/minor/major) - Credit contributors properly ### How to add a changeset: 1. Run \`pnpm changeset\` in your local environment 2. Select the type of change (patch/minor/major) 3. Write a brief description of your changes 4. Commit the generated changeset file ### Change types: - **patch**: Bug fixes, internal changes (0.0.X) - **minor**: New features, non-breaking changes (0.X.0) - **major**: Breaking changes (X.0.0) ### Example: \`\`\`bash $ pnpm changeset ? What kind of change is this? › patch ? Summary › Fixed TypeScript types for ArticleJsonLd component \`\`\` If this PR only contains documentation or non-code changes, you can ignore this message.`; // Check if we already commented const comments = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, }); const hasComment = comments.data.some(comment => comment.body.includes('## 📝 Changeset Required') ); if (!hasComment) { await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, body: body }); } - name: Skip message for non-code changes if: steps.code-changes.outputs.code_changed == 'false' run: echo "No code changes detected, changeset not required." ================================================ FILE: .github/workflows/changesets.yml ================================================ name: Changesets on: push: branches: - main concurrency: ${{ github.workflow }}-${{ github.ref }} permissions: contents: write pull-requests: write id-token: write jobs: build: name: Build Library runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: "pnpm" - name: Install dependencies run: pnpm install --frozen-lockfile - name: Build library run: pnpm build - name: Upload build artifacts uses: actions/upload-artifact@v4 with: name: dist path: dist retention-days: 1 lint: name: Lint Library runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: "pnpm" - name: Install dependencies run: pnpm install --frozen-lockfile - name: Run ESLint run: pnpm lint typecheck: name: Type Check Library runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: "pnpm" - name: Install dependencies run: pnpm install --frozen-lockfile - name: Run TypeScript type checking run: pnpm typecheck unit-tests: name: Unit Tests runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: "pnpm" - name: Install dependencies run: pnpm install --frozen-lockfile - name: Run unit tests run: pnpm test:unit example-lint: name: Lint Example App runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: "pnpm" - name: Install dependencies run: pnpm install --frozen-lockfile - name: Lint example app run: pnpm example:lint example-typecheck: name: Type Check Example App runs-on: ubuntu-latest needs: build steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: "pnpm" - name: Install dependencies run: pnpm install --frozen-lockfile - name: Download build artifacts uses: actions/download-artifact@v4 with: name: dist path: dist - name: Type check example app run: pnpm example:typecheck e2e-tests: name: E2E Tests runs-on: ubuntu-latest needs: build steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: "pnpm" - name: Install dependencies run: pnpm install --frozen-lockfile - name: Download build artifacts uses: actions/download-artifact@v4 with: name: dist path: dist - name: Build example app run: pnpm example:build - name: Install Playwright browsers run: npx playwright install --with-deps chromium - name: Run E2E tests run: pnpm test:e2e --project=chromium env: CI: true release: name: Create Release PR or Publish runs-on: ubuntu-latest needs: [ build, lint, typecheck, unit-tests, example-lint, example-typecheck, e2e-tests, ] outputs: published: ${{ steps.changesets.outputs.published }} publishedPackages: ${{ steps.changesets.outputs.publishedPackages }} steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: "pnpm" registry-url: "https://registry.npmjs.org" - name: Install dependencies run: pnpm install --frozen-lockfile - name: Download build artifacts uses: actions/download-artifact@v4 with: name: dist path: dist - name: Create Release Pull Request or Publish id: changesets uses: changesets/action@v1 with: title: "Release: Version Packages" commit: "chore: version packages" publish: pnpm release createGithubReleases: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} post-release: name: Post Release Actions runs-on: ubuntu-latest needs: release if: needs.release.outputs.published == 'true' steps: - name: Report Released Packages run: | echo "🎉 Released packages:" echo "${{ needs.release.outputs.publishedPackages }}" ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: pull_request: branches: [main] workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: build: name: Build Library runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: "pnpm" - name: Install dependencies run: pnpm install --frozen-lockfile - name: Build library run: pnpm build - name: Upload build artifacts uses: actions/upload-artifact@v4 with: name: dist path: dist retention-days: 1 lint: name: Lint Library runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: "pnpm" - name: Install dependencies run: pnpm install --frozen-lockfile - name: Run ESLint run: pnpm lint typecheck: name: Type Check Library runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: "pnpm" - name: Install dependencies run: pnpm install --frozen-lockfile - name: Run TypeScript type checking run: pnpm typecheck unit-tests: name: Unit Tests runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: "pnpm" - name: Install dependencies run: pnpm install --frozen-lockfile - name: Run unit tests run: pnpm test:unit - name: Upload coverage reports uses: actions/upload-artifact@v4 if: always() with: name: coverage-report path: coverage/ retention-days: 7 example-lint: name: Lint Example App runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: "pnpm" - name: Install dependencies run: pnpm install --frozen-lockfile - name: Lint example app run: pnpm example:lint example-typecheck: name: Type Check Example App runs-on: ubuntu-latest needs: build steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: "pnpm" - name: Install dependencies run: pnpm install --frozen-lockfile - name: Download build artifacts uses: actions/download-artifact@v4 with: name: dist path: dist - name: Type check example app run: pnpm example:typecheck e2e-tests: name: E2E Tests runs-on: ubuntu-latest needs: build steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: "pnpm" - name: Install dependencies run: pnpm install --frozen-lockfile - name: Download build artifacts uses: actions/download-artifact@v4 with: name: dist path: dist - name: Build example app run: pnpm example:build - name: Install Playwright browsers run: npx playwright install --with-deps chromium - name: Run E2E tests run: pnpm test:e2e --project=chromium env: CI: true - name: Upload Playwright report uses: actions/upload-artifact@v4 if: failure() with: name: playwright-report path: playwright-report/ retention-days: 7 all-checks: name: All CI Checks runs-on: ubuntu-latest needs: [ build, lint, typecheck, unit-tests, example-lint, example-typecheck, e2e-tests, ] if: always() steps: - name: Check all job statuses run: | if [[ "${{ contains(needs.*.result, 'failure') }}" == "true" ]]; then echo "One or more CI checks failed" exit 1 elif [[ "${{ contains(needs.*.result, 'cancelled') }}" == "true" ]]; then echo "One or more CI checks were cancelled" exit 1 else echo "All CI checks passed successfully" fi ================================================ FILE: .github/workflows/release.yml ================================================ name: Manual Release on: workflow_dispatch: inputs: version: description: "Version to release (e.g., 7.1.0)" required: true type: string tag: description: "NPM tag (latest, next, alpha, beta)" required: true type: choice default: "latest" options: - latest - next - alpha - beta permissions: contents: write id-token: write jobs: manual-release: name: Manual Release runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: "pnpm" registry-url: "https://registry.npmjs.org" - name: Install dependencies run: pnpm install --frozen-lockfile - name: Update package version run: | npm version ${{ github.event.inputs.version }} --no-git-tag-version echo "Updated package.json to version ${{ github.event.inputs.version }}" - name: Build package run: pnpm build - name: Run TypeScript type checking run: pnpm typecheck - name: Run linting run: pnpm lint - name: Run unit tests run: pnpm test:unit - name: Lint example app run: pnpm example:lint - name: Type check example app run: pnpm example:typecheck - name: Build example app run: pnpm example:build - name: Install Playwright browsers run: npx playwright install --with-deps chromium - name: Run E2E tests run: pnpm test:e2e --project=chromium env: CI: true - name: Publish to npm run: | echo "Publishing version ${{ github.event.inputs.version }} with tag '${{ github.event.inputs.tag }}'" pnpm publish --tag ${{ github.event.inputs.tag }} --no-git-checks env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Create Git tag run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git tag -a "v${{ github.event.inputs.version }}" -m "Release v${{ github.event.inputs.version }}" git push origin "v${{ github.event.inputs.version }}" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: tag_name: "v${{ github.event.inputs.version }}" name: "v${{ github.event.inputs.version }}" body: | ## Manual Release v${{ github.event.inputs.version }} Published to npm with tag: `${{ github.event.inputs.tag }}` Install with: ```bash npm install next-seo@${{ github.event.inputs.tag }} # or pnpm add next-seo@${{ github.event.inputs.tag }} ``` prerelease: ${{ github.event.inputs.tag != 'latest' }} generate_release_notes: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # Dependencies node_modules /.pnp .pnp.js # Testing coverage # Next.js .next/ build/ out/ # TypeScript *.tsbuildinfo # Misc .DS_Store *.pem # Logs npm-debug.log* yarn-debug.log* yarn-error.log* # Editor directories and files .idea .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json *.sublime-workspace # Husky .husky/_/ # SWC .swc/ *.log .history dist cypress/videos cypress/screenshots package-lock.json lib notes.md playwright-report repomix-output.xml .last-run.json .claude tasks/* test-results/* task.md local_docs/* ================================================ FILE: .husky/pre-commit ================================================ pnpm lint-staged ================================================ FILE: .npmignore ================================================ # Source code src/ examples/ # Config files .husky/ .github/ .vscode/ .editorconfig .prettierignore commitlint.config.js eslint.config.mjs playwright.config.ts tsconfig.json tsup.config.ts vitest.config.ts pnpm-workspace.yaml repomix.config.json # Documentation *.md !README.md # Test files tests/ test-results/ coverage/ **/*.test.* **/*.spec.* # Development files tasks/ schemas/ # Build artifacts that shouldn't be published *.log .DS_Store ================================================ FILE: .prettierignore ================================================ node_modules .next .swc coverage dist build package.json pnpm-lock.yaml # Ignore example project built files if any examples/app-router-showcase/.next examples/app-router-showcase/out ================================================ FILE: .vscode/settings.json ================================================ { "cSpell.words": ["noindex", "nofollow"], "typescript.tsdk": "node_modules/typescript/lib" } ================================================ FILE: ADDING_NEW_COMPONENTS.md ================================================ # Adding New Components to Next SEO This guide walks through the process of adding new JSON-LD structured data components to next-seo. We'll use the ArticleJsonLd component as a reference implementation. ## Table of Contents 1. [Research Phase](#1-research-phase) 2. [Type Definitions](#2-type-definitions) 3. [Component Implementation](#3-component-implementation) 4. [Export Configuration](#4-export-configuration) 5. [Unit Tests](#5-unit-tests) 6. [Documentation](#6-documentation) 7. [Example Pages](#7-example-pages) 8. [E2E Tests](#8-e2e-tests) 9. [Final Verification](#9-final-verification) ## 1. Research Phase Before implementing, thoroughly research the structured data specification: 1. **Visit Google's Documentation** - Go to [Google's Structured Data Gallery](https://developers.google.com/search/docs/appearance/structured-data/search-gallery) - Find the specific type you're implementing (e.g., Article, Product, Recipe) - Note all required and recommended properties 2. **Analyze Schema Types** - Identify all subtypes (e.g., Article has NewsArticle, BlogPosting, Blog) - Note property variations between types - Check for special formatting requirements (dates, images, etc.) 3. **Review Existing Implementation** - If updating from an older version, fetch the previous implementation - Identify any missing features or properties - Ensure backward compatibility where possible ## 2. Type Definitions Create comprehensive TypeScript types in `src/types/[component].types.ts`: ```typescript // src/types/article.types.ts import type { ImageObject, Person, Organization, Author } from "./common.types"; // Note: Common types like ImageObject, Person, Organization, and Author // are now defined in common.types.ts to avoid duplication // Base interface with common properties export interface ArticleBase { headline: string; url?: string; author?: Author | Author[]; datePublished?: string; dateModified?: string; image?: string | ImageObject | (string | ImageObject)[]; publisher?: Organization; description?: string; isAccessibleForFree?: boolean; mainEntityOfPage?: | string | { "@type": "WebPage"; "@id": string; }; } // Specific schema type interfaces export interface Article extends ArticleBase { "@type": "Article"; } export interface NewsArticle extends ArticleBase { "@type": "NewsArticle"; } export interface BlogPosting extends ArticleBase { "@type": "BlogPosting"; } // Component props type export type ArticleJsonLdProps = ( | Omit | Omit | Omit ) & { type?: "Article" | "NewsArticle" | "BlogPosting"; scriptId?: string; scriptKey?: string; }; ``` ### Key Patterns: - Use union types for flexible inputs (e.g., `string | Person | Organization`) - Support both single items and arrays where appropriate - Extend common interfaces to reduce duplication - Make all properties optional except truly required ones - Include component-specific props like `scriptId` and `scriptKey` - **Important**: Reuse types from `common.types.ts` for shared definitions like `ImageObject`, `Person`, `Organization`, and `Author` ### The @type Optional Pattern A core design principle of next-seo is that developers should not need to specify `@type` properties manually. This provides better developer experience while maintaining full Schema.org compliance. #### How It Works: 1. **Type Definitions**: Use `Omit` to create props that don't require `@type`: ```typescript export type ArticleJsonLdProps = ( | Omit | Omit | Omit ) & { type?: "Article" | "NewsArticle" | "BlogPosting"; // ... other props }; ``` 2. **Process Functions**: Automatically add the correct `@type` based on input: ```typescript // Developers can pass a simple string author="John Doe" // Process function converts it to a proper Person object processAuthor("John Doe") // → { "@type": "Person", name: "John Doe" } // Or pass an object without @type author={{ name: "John Doe", url: "https://example.com" }} // Process function adds @type intelligently processAuthor({...}) // → { "@type": "Person", name: "John Doe", url: "..." } ``` 3. **Intelligent Type Detection**: Process functions use property analysis to determine types: - Objects with `logo`, `address`, or `contactPoint` → Organization - Objects with `familyName` or `givenName` → Person - Default fallbacks ensure valid Schema.org output #### Benefits: - **Less Boilerplate**: Developers don't need to remember Schema.org type names - **Flexible Input**: Accept strings, objects with/without `@type` - **Type Safety**: Full TypeScript support throughout - **Forward Compatible**: Can still accept objects with `@type` if provided ## 3. Component Implementation Create the component in `src/components/[Component]JsonLd.tsx`: ```typescript // src/components/ArticleJsonLd.tsx import { JsonLdScript } from "~/core/JsonLdScript"; import type { ArticleJsonLdProps } from "~/types/article.types"; import { processAuthor, processImage } from "~/utils/processors"; // Note: Common processing functions like processAuthor and processImage // are now available in ~/utils/processors.ts to avoid duplication export default function ArticleJsonLd({ type = "Article", scriptId, scriptKey, headline, url, author, datePublished, dateModified, image, publisher, description, isAccessibleForFree, mainEntityOfPage, }: ArticleJsonLdProps) { const data = { "@context": "https://schema.org", "@type": type, headline, ...(url && { url }), ...(author && { author: Array.isArray(author) ? author.map(processAuthor) : processAuthor(author), }), ...(datePublished && { datePublished }), ...(dateModified && { dateModified }), // Apply defaults where appropriate ...(!dateModified && datePublished && { dateModified: datePublished }), ...(image && { image: Array.isArray(image) ? image.map(processImage) : processImage(image), }), ...(publisher && { publisher }), ...(description && { description }), ...(isAccessibleForFree !== undefined && { isAccessibleForFree }), ...(mainEntityOfPage && { mainEntityOfPage }), }; return ( ); } export type { ArticleJsonLdProps }; ``` ### Implementation Guidelines: - Use the existing `JsonLdScript` component for rendering (now with TypeScript generics support) - Process flexible inputs to proper schema format using shared utilities from `~/utils/processors` - Use object spread with conditional inclusion for optional properties - Handle arrays appropriately with `.map()` - Apply sensible defaults (e.g., dateModified defaults to datePublished) - Ensure boolean values are explicitly checked with `!== undefined` - **Always use process functions** for properties that accept flexible types (strings, objects with/without `@type`) - **Never require developers to specify `@type`** - the component should set the main `@type` from the `type` prop, and process functions should handle nested objects ## 4. Export Configuration Update `src/index.ts` to export your component: ```typescript export { JsonLdScript } from "./core/JsonLdScript"; export { default as ArticleJsonLd, type ArticleJsonLdProps, } from "./components/ArticleJsonLd"; // Add your new component here export const version = "7.0.0-alpha.0"; ``` ## 5. Unit Tests Create comprehensive tests in `src/components/[Component]JsonLd.test.tsx`: ```typescript import { render } from "@testing-library/react"; import { describe, it, expect } from "vitest"; import ArticleJsonLd from "./ArticleJsonLd"; describe("ArticleJsonLd", () => { it("renders basic Article with minimal props", () => { const { container } = render( ); const script = container.querySelector('script[type="application/ld+json"]'); expect(script).toBeTruthy(); const jsonData = JSON.parse(script!.textContent!); expect(jsonData).toEqual({ "@context": "https://schema.org", "@type": "Article", headline: "Test Article", datePublished: "2024-01-01T00:00:00.000Z", dateModified: "2024-01-01T00:00:00.000Z", // defaults to datePublished }); }); // Test each schema type it("renders NewsArticle type when specified", () => { // ... test implementation }); // Test flexible inputs it("handles string author", () => { // ... converts string to Person object }); it("handles multiple authors", () => { // ... test array handling }); // Test all properties it("handles all optional properties", () => { // ... comprehensive test with all props }); // Test edge cases it("handles isAccessibleForFree as false", () => { // ... ensure boolean false is included }); }); ``` ### Testing Checklist: - ✅ Basic rendering with minimal props - ✅ All schema type variations - ✅ String to object conversions - ✅ Array handling for authors and images - ✅ All optional properties - ✅ Default value application - ✅ Boolean value handling - ✅ Custom scriptId and scriptKey ## 6. Documentation Add comprehensive documentation to `README.md`: ````markdown ### ArticleJsonLd The `ArticleJsonLd` component helps you add structured data for articles, blog posts, and news articles to improve their appearance in search results. #### Basic Usage ```tsx import { ArticleJsonLd } from "next-seo"; ; ``` ```` #### Props | Property | Type | Description | | ---------- | --------------------------------------------- | ------------------------------------------ | | `type` | `"Article" \| "NewsArticle" \| "BlogPosting"` | The type of article. Defaults to "Article" | | `headline` | `string` | **Required.** The headline of the article | | ... | ... | ... | #### Best Practices 1. **Always include images**: Google recommends multiple aspect ratios 2. **Use ISO 8601 dates**: Include timezone information 3. **Multiple authors**: List all authors when applicable ```` ## 7. Example Pages Create example pages in `examples/app-router-showcase/app/[component]/page.tsx`: ```tsx import { ArticleJsonLd } from "next-seo"; export default function ArticlePage() { return (

Understanding Next.js App Router

{/* Article content */}
); } ```` Create examples for: - Basic usage (minimal props) - Advanced usage (all features) - Each schema type variation ## 8. E2E Tests Create Playwright tests in `tests/e2e/[component]JsonLd.e2e.spec.ts`: ### Important E2E Testing Guidelines **ALL E2E tests must use real example pages!** E2E tests should test the actual component behavior through real pages in the example app. Never mock or inject content in E2E tests. ❌ **DO NOT** use `page.route()` to inject mock HTML: ```typescript // BAD - This is not a real E2E test! await page.route("/test-page", async (route) => { await route.fulfill({ body: `...`, }); }); ``` ✅ **DO** create real example pages and test them: ```typescript // GOOD - Test real pages with actual components await page.goto("/article"); ``` ### Creating E2E Tests For every E2E test scenario, you must: 1. Create a real example page in `examples/app-router-showcase/app/` 2. Write the E2E test to navigate to that page 3. Test the actual rendered output ```typescript import { test, expect } from "@playwright/test"; test.describe("ArticleJsonLd", () => { test("renders basic Article structured data", async ({ page }) => { // Navigate to the real example page await page.goto("/article"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); expect(jsonLdScript).toBeTruthy(); const jsonData = JSON.parse(jsonLdScript!); // Verify all properties expect(jsonData["@context"]).toBe("https://schema.org"); expect(jsonData["@type"]).toBe("Article"); expect(jsonData.headline).toBe("Understanding Next.js App Router"); // ... test all properties }); test("properly escapes HTML entities in content", async ({ page }) => { // Navigate to a real example page with special characters await page.goto("/article-special-chars"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); // Verify JSON is valid and content is properly escaped const jsonData = JSON.parse(jsonLdScript!); expect(jsonData.headline).toContain("Special & Characters"); // Check that dangerous content is escaped in the raw JSON expect(jsonLdScript).toContain("\\u003C/script>"); }); }); ``` ### When to Create Additional Example Pages Create new example pages for: - Basic usage with minimal props - Advanced usage with all features - Each schema type variation (e.g., Article, NewsArticle, BlogPosting) - Special characters and HTML entities - Edge cases with unusual data - Different data combinations Example structure: ``` examples/app-router-showcase/app/ ├── article/ # Basic article example ├── article-advanced/ # All features ├── news-article/ # NewsArticle type ├── blog-posting/ # BlogPosting type └── article-special-chars/ # Special characters test ``` You should also add a valid JSON test in `tests/e2e/jsonValidation.e2e.spec.ts` ### Security and Escaping Tests **DO NOT add escape/security tests to individual component E2E tests!** Security testing for escaping dangerous sequences (like ``, HTML comments, etc.) is handled centrally in `tests/e2e/security.e2e.spec.ts`. This test file comprehensively covers: - Script tag injection prevention - HTML comment escaping - Edge cases with mixed dangerous patterns - Safe rendering in Next.js-like environments Individual component E2E tests should focus on: - Component-specific functionality - Correct data structure output - Schema type variations - Required and optional properties The escaping functionality is a core library feature handled by the `stringify` utility, not something each component needs to test individually. ## 9. Final Verification Before completing, run all quality checks: ```bash # 1. Run unit tests pnpm test:unit # 2. Type checking pnpm typecheck # 3. Linting pnpm lint # 4. Build the package pnpm build ``` Developer will run e2e manually as they can take a long time. ## Common Patterns and Best Practices ### Shared Utilities The library now provides shared utilities to avoid code duplication: 1. **Common Types** (`~/types/common.types.ts`): - `ImageObject`, `Person`, `Organization`, `Author` - Base interfaces like `Thing` 2. **Processing Functions** (`~/utils/processors.ts`): - `processAuthor(author: Author): Person | Organization` - `processImage(image: string | ImageObject): string | ImageObject` ### Flexible Input Processing Use the shared processing functions from `~/utils/processors`: ```typescript import { processAuthor, processImage } from "~/utils/processors"; // These functions handle string-to-object conversions automatically // and add the appropriate @type without developers needing to specify it ``` **Important**: Always create or use existing process functions for properties that can accept multiple formats. This maintains the pattern of not requiring developers to specify `@type` and ensures consistent behavior across all components. ### Conditional Property Inclusion Use object spread with conditional checks: ```typescript const data = { "@context": "https://schema.org", "@type": type, headline, ...(url && { url }), // Only include if truthy ...(isAccessibleForFree !== undefined && { isAccessibleForFree }), // Include false values }; ``` ### Default Values Apply sensible defaults where appropriate: ```typescript // If dateModified is not provided but datePublished is, use datePublished ...(!dateModified && datePublished && { dateModified: datePublished }), ``` ### Array Handling Support both single items and arrays: ```typescript ...(author && { author: Array.isArray(author) ? author.map(processAuthor) : processAuthor(author), }), ``` ## Troubleshooting ### Common Issues 1. **ESLint errors about unused React import** - Remove `import React from 'react'` - it's not needed with modern JSX transform 2. **Test failures with dateModified** - Remember that dateModified defaults to datePublished when not provided 3. **Boolean properties not appearing** - Use `!== undefined` check instead of truthy check for booleans 4. **Type errors with union types** - Ensure proper type guards in processing functions ## Checklist for New Components - [ ] Research Google's structured data documentation - [ ] Create comprehensive type definitions (reuse common types from `common.types.ts`) - [ ] Implement component using shared utilities from `~/utils/processors` - [ ] Update exports in src/index.ts - [ ] Write unit tests covering all scenarios - [ ] Add documentation to README.md - [ ] Create example pages for each variation - [ ] Write E2E tests (Double check guidelines!) - [ ] Run all quality checks (full sweep can be done via `pnpm test:sweep`) - [ ] Ensure backward compatibility if updating existing component - [ ] Check if any new processing functions should be added to shared utilities ================================================ FILE: AGENTS.md ================================================ # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview Next SEO is a plugin that makes managing SEO easier in Next.js projects. It's built with TypeScript and provides components for structured data (JSON-LD) and SEO management. ## Critical Rules You must check these after coming up with a plan [ ] Your plan adheres to the guide found in @ADDING_NEW_COMPONENTS.md [ ] Your plan adheres to the guidelines found below ## Development Commands ### Installation ```bash pnpm install ``` ### Build & Development ```bash pnpm dev # Watch mode with tsup pnpm build # Build library code ``` ### Code Quality ```bash pnpm lint # Run ESLint pnpm lint:fix # Fix ESLint issues pnpm format # Format with Prettier pnpm typecheck # Type checking with TypeScript ``` ### Testing ```bash pnpm test # Run typecheck + lint only pnpm test:unit # Run unit tests with Vitest pnpm test:unit:watch # Watch mode for unit tests pnpm coverage # Generate coverage report # Requires pnpm build to run first pnpm test:e2e # Run E2E tests with Playwright pnpm test:e2e:ui # Run E2E tests with UI ``` ### Example App ```bash pnpm example:dev # Run example app at localhost:3001 pnpm example:build # Build example app pnpm example:start # Start example app ``` ### Utilities ```bash pnpm clean # Clean build artifacts ``` ## Project Architecture ### Core Structure - **`/src`** - Library source code - **`/core`** - Core components like `JsonLdScript` - **`/types`** - TypeScript type definitions - **`/utils`** - Utility functions like `stringify` - **`/examples/app-router-showcase`** - Example Next.js app for testing - **`/tests`** - Test files - **`/unit`** - Unit tests (Vitest) - **`/e2e`** - E2E tests (Playwright) ### Build Configuration - **tsup** - For building the library (see `tsup.config.ts`) - Outputs both CommonJS and ESM formats - Path alias: `~` maps to `./src` ### Testing Setup - **Vitest** - Unit testing with React Testing Library - **Playwright** - E2E testing running against example app on port 3001 - Tests use `~` alias for imports ## Development Notes 1. All library code is in `/src` directory 2. The project uses pnpm workspaces with the example app 3. When developing, the example app auto-starts on port 3001 for E2E tests 4. Lint and format are automatically run on staged files via Husky 5. The library exports both CommonJS and ESM formats with TypeScript definitions 6. When adding a new component ALWAYS refer to the guide found in ADDING_NEW_COMPONENTS.md ## Key Patterns ### @type Optional Pattern Next SEO provides excellent developer experience by **never requiring developers to manually specify `@type` properties**. This is achieved through intelligent type definitions and process functions. #### How It Works: 1. **Type Definitions**: Component props use `Omit` to make `@type` optional 2. **Process Functions**: Automatically add the correct `@type` based on the input 3. **Flexible Inputs**: Accept strings, objects with/without `@type`, and arrays #### Example: ```typescript // Developers can write this: // Process functions transform it to valid Schema.org: { author: { "@type": "Person", name: "John Doe" }, publisher: { "@type": "Organization", name: "ACME Corp", logo: {...} } } ``` #### Benefits: - **Better DX**: No need to remember Schema.org type names - **Less Boilerplate**: Cleaner, more readable code - **Type Safety**: Full TypeScript support maintained - **Flexibility**: Still accepts objects with `@type` if provided #### Implementation Rules: 1. Always use process functions for properties accepting flexible types 2. Never require `@type` in component props 3. Use intelligent detection (e.g., `logo` property → Organization) 4. Provide sensible defaults in process functions This pattern is fundamental to the library's design and must be maintained in all components. ================================================ FILE: CHANGELOG.md ================================================ # next-seo ## 7.2.0 ### Minor Changes - 28c684e: Add `review` and `aggregateRating` props to OrganizationJsonLd component, matching the existing support in LocalBusinessJsonLd. Both are direct Schema.org Organization properties processed using shared utilities. ## 7.1.0 ### Minor Changes - d412e2b: Add HowToJsonLd component for structured data support - New `HowToJsonLd` component following Schema.org HowTo specification - Support for HowToStep, HowToSection, HowToDirection, and HowToTip types - HowToSupply and HowToTool for materials and equipment - Duration properties (prepTime, performTime, totalTime) in ISO 8601 format - estimatedCost as string or MonetaryAmount object - yield as string or QuantitativeValue - Video support via VideoObject ## 7.0.1 ### Patch Changes - 1db3648: Add JSDoc comment to internal type guard function ================================================ FILE: CLAUDE.md ================================================ # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview Next SEO is a plugin that makes managing SEO easier in Next.js projects. It's built with TypeScript and provides components for structured data (JSON-LD) and SEO management. ## Critical Rules You must check these after coming up with a plan [ ] Your plan adheres to the guide found in @ADDING_NEW_COMPONENTS.md [ ] Your plan adheres to the guidelines found below ## Development Commands ### Installation ```bash pnpm install ``` ### Build & Development ```bash pnpm dev # Watch mode with tsup pnpm build # Build library code ``` ### Code Quality ```bash pnpm lint # Run ESLint pnpm lint:fix # Fix ESLint issues pnpm format # Format with Prettier pnpm typecheck # Type checking with TypeScript ``` ### Testing ```bash pnpm test # Run typecheck + lint only pnpm test:unit # Run unit tests with Vitest pnpm test:unit:watch # Watch mode for unit tests pnpm coverage # Generate coverage report # Requires pnpm build to run first pnpm test:e2e # Run E2E tests with Playwright pnpm test:e2e:ui # Run E2E tests with UI ``` ### Example App ```bash pnpm example:dev # Run example app at localhost:3001 pnpm example:build # Build example app pnpm example:start # Start example app ``` ### Utilities ```bash pnpm clean # Clean build artifacts ``` ## Project Architecture ### Core Structure - **`/src`** - Library source code - **`/core`** - Core components like `JsonLdScript` - **`/types`** - TypeScript type definitions - **`/utils`** - Utility functions like `stringify` - **`/examples/app-router-showcase`** - Example Next.js app for testing - **`/tests`** - Test files - **`/unit`** - Unit tests (Vitest) - **`/e2e`** - E2E tests (Playwright) ### Build Configuration - **tsup** - For building the library (see `tsup.config.ts`) - Outputs both CommonJS and ESM formats - Path alias: `~` maps to `./src` ### Testing Setup - **Vitest** - Unit testing with React Testing Library - **Playwright** - E2E testing running against example app on port 3001 - Tests use `~` alias for imports ## Development Notes 1. All library code is in `/src` directory 2. The project uses pnpm workspaces with the example app 3. When developing, the example app auto-starts on port 3001 for E2E tests 4. Lint and format are automatically run on staged files via Husky 5. The library exports both CommonJS and ESM formats with TypeScript definitions 6. When adding a new component ALWAYS refer to the guide found in ADDING_NEW_COMPONENTS.md ## Key Patterns ### @type Optional Pattern Next SEO provides excellent developer experience by **never requiring developers to manually specify `@type` properties**. This is achieved through intelligent type definitions and process functions. #### How It Works: 1. **Type Definitions**: Component props use `Omit` to make `@type` optional 2. **Process Functions**: Automatically add the correct `@type` based on the input 3. **Flexible Inputs**: Accept strings, objects with/without `@type`, and arrays #### Example: ```typescript // Developers can write this: // Process functions transform it to valid Schema.org: { author: { "@type": "Person", name: "John Doe" }, publisher: { "@type": "Organization", name: "ACME Corp", logo: {...} } } ``` #### Benefits: - **Better DX**: No need to remember Schema.org type names - **Less Boilerplate**: Cleaner, more readable code - **Type Safety**: Full TypeScript support maintained - **Flexibility**: Still accepts objects with `@type` if provided #### Implementation Rules: 1. Always use process functions for properties accepting flexible types 2. Never require `@type` in component props 3. Use intelligent detection (e.g., `logo` property → Organization) 4. Provide sensible defaults in process functions This pattern is fundamental to the library's design and must be maintained in all components. ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to Next SEO Thank you for your interest in contributing to Next SEO! We are open to all and any contributions. This guide will help you get started. It is critical that you look over the guidance for new components [here](ADDING_NEW_COMPONENTS.md) ## 🚀 Quick Start 1. Fork the repository 2. Clone your fork: `git clone git@github.com:your-username/next-seo.git` 3. Install dependencies: `pnpm install` 4. Create a new branch: `git checkout -b feature/your-feature-name` 5. Make your changes 6. Add a changeset: `pnpm changeset` 7. Submit a pull request ## 📦 Development Setup ### Prerequisites - Node.js 20+ (LTS recommended) - pnpm 9+ (`npm install -g pnpm`) ### Installation ```bash # Clone the repository git clone git@github.com:garmeeh/next-seo.git cd next-seo # Install dependencies pnpm install # Start development (watch mode) pnpm dev ``` ### Available Commands ```bash pnpm dev # Watch mode development pnpm build # Build the library pnpm test # Run type checking and linting pnpm test:unit # Run unit tests pnpm test:e2e # Run E2E tests (requires build first) pnpm test:sweep # Run full test suite (CI equivalent) pnpm lint # Check linting pnpm format # Format code with Prettier ``` ## 📝 Adding a Changeset **Important:** All PRs with code changes require a changeset. This helps us track changes and automatically manage releases. ### What is a changeset? A changeset is a piece of information about changes made in a branch or commit. It includes: - What packages changed - What kind of change it was (major/minor/patch) - A description of the change for the changelog ### How to add a changeset: 1. After making your changes, run: `pnpm changeset` 2. Select the packages affected (usually just `next-seo`) 3. Choose the type of change: - **patch**: Bug fixes, documentation, internal changes (0.0.X) **Rarely use this, generally only for security, since this is an SEO package I don't want patches to slip through for people without validating** - **minor**: New features, non-breaking enhancements (0.X.0) **Most common** - **major**: Breaking changes (X.0.0) 4. Write a brief description of your changes 5. Commit the generated changeset file ### Example: ```bash $ pnpm changeset 🦋 Which packages would you like to include? › next-seo 🦋 Which packages should have a major bump? › (none) 🦋 Which packages should have a minor bump? › next-seo 🦋 The following packages will be patch bumped: 🦋 next-seo@minor 🦋 Please enter a summary for this change: 📝 Added support for RecipeJsonLd component with full Schema.org compliance ``` We recommend never using patch unless critical security bug This creates a markdown file in `.changeset/` that will be used to: - Update the package version - Generate changelog entries - Credit you as a contributor ### When is a changeset NOT required? - Documentation-only changes (README, etc.) - Changes to GitHub workflows - Changes to development tooling that don't affect the published package ## 🏗️ Project Guidelines ### For AI-Assisted Development This project leverages AI coding tools. If you're using tools like Claude or GitHub Copilot: - Refer to [CLAUDE.md](CLAUDE.md) for project-specific AI guidance - Refer to [ADDING_NEW_COMPONENTS.md](ADDING_NEW_COMPONENTS.md) for component development ### For Large Features If you're planning a large feature or refactor: 1. Open an issue first to discuss with maintainers 2. Provide comprehensive context in your issue/PR 3. Break down large changes into smaller, reviewable PRs if possible ## 🧪 Testing Requirements Before submitting a PR, ensure all tests pass: ```bash # Quick checks pnpm test # Type checking and linting pnpm test:unit # Unit tests # Full validation (what CI runs) pnpm test:sweep # Complete test suite ``` ## 🔄 Pull Request Process 1. **Fork & Clone**: Fork the repo and clone locally 2. **Branch**: Create a feature branch from `main` 3. **Develop**: Make your changes following our guidelines 4. **Changeset**: Add a changeset describing your changes 5. **Test**: Ensure all tests pass 6. **Push**: Push to your fork 7. **PR**: Open a pull request with a clear description ### PR Guidelines - Use clear, descriptive titles - Reference any related issues - Include examples if adding new features - Ensure CI passes before requesting review ## ❓ Questions? - Open a [Discussion](https://github.com/garmeeh/next-seo/discussions) for general questions - Check existing issues and PRs - Refer to the [README](./README.md) for usage documentation ## 📄 License By contributing, you agree that your contributions will be licensed under the same MIT License that covers this project. --- Thank you for contributing to Next SEO! Your efforts help make SEO easier for the Next.js community. ================================================ FILE: CUSTOM_COMPONENTS.md ================================================ # Creating Custom JSON-LD Components with Next SEO This guide shows you how to create your own structured data components using next-seo's core utilities, maintaining the same excellent developer experience as the built-in components. ## Table of Contents 1. [Quick Start](#quick-start) 2. [Core Concepts](#core-concepts) 3. [Using Built-in Processors](#using-built-in-processors) 4. [Creating Custom Processors](#creating-custom-processors) 5. [Advanced Patterns](#advanced-patterns) 6. [Best Practices](#best-practices) 7. [Real-World Examples](#real-world-examples) ## Quick Start Create a custom JSON-LD component in just a few lines: ```tsx import { JsonLdScript, processors } from "next-seo"; export function PodcastEpisodeJsonLd({ name, author, duration, url }) { const data = { "@context": "https://schema.org", "@type": "PodcastEpisode", name, ...(url && { url }), ...(duration && { duration }), ...(author && { author: processors.processAuthor(author) }), }; return ; } // Usage - no @type needed! ; ``` ## Core Concepts ### The JsonLdScript Component The `JsonLdScript` component is the foundation for rendering structured data: ```tsx import { JsonLdScript } from "next-seo"; ; ``` ### The @type Optional Pattern Next SEO's key principle: **developers should never need to specify @type manually**. This is achieved through intelligent processors that automatically add the correct Schema.org types. ```tsx // Your users write this: author="John Doe" // Your processor converts it to: { "@type": "Person", name: "John Doe" } ``` ### Processors Processors are functions that transform flexible inputs into properly typed Schema.org objects: ```tsx import { processors } from "next-seo"; // Use built-in processors for common types const author = processors.processAuthor("John Doe"); const image = processors.processImage({ url: "image.jpg", width: 800 }); const address = processors.processAddress("123 Main St"); ``` ## Using Built-in Processors Next SEO provides 60+ processors for common Schema.org types: ### People & Organizations ```tsx import { processors } from "next-seo"; // Flexible author input processors.processAuthor("Jane Doe"); // → Person processors.processAuthor({ name: "ACME Corp", logo: "..." }); // → Organization // Other people/org processors processors.processPublisher("Tech Publishing"); processors.processOrganizer({ name: "Event Co", url: "..." }); processors.processPerformer("Band Name"); ``` ### Media & Content ```tsx // Images - string URL or ImageObject processors.processImage("https://example.com/image.jpg"); processors.processImage({ url: "...", width: 800, height: 600 }); // Videos processors.processVideo({ name: "Tutorial", uploadDate: "2024-01-01", thumbnailUrl: "...", }); // Other media processors processors.processLogo("logo.jpg"); processors.processScreenshot({ url: "...", caption: "App screenshot" }); ``` ### Locations & Places ```tsx // Simple string becomes PostalAddress processors.processAddress("123 Main St, City, Country"); // Object with more details processors.processAddress({ streetAddress: "123 Main St", addressLocality: "San Francisco", addressRegion: "CA", postalCode: "94105", addressCountry: "US", }); // Places with geo coordinates processors.processPlace({ name: "Office", geo: { latitude: 37.7749, longitude: -122.4194 }, }); ``` ### Commerce & Offers ```tsx // Product offers processors.processProductOffer({ price: 29.99, priceCurrency: "USD", availability: "https://schema.org/InStock", }); // Return policies processors.processMerchantReturnPolicy({ returnPolicyCategory: "https://schema.org/MerchantReturnFiniteReturnWindow", merchantReturnDays: 30, }); ``` ## Creating Custom Processors ### Basic Custom Processor Create processors for your specific needs: ```tsx import { processors } from "next-seo"; // Custom processor for a podcast host function processHost(host: string | { name: string; bio?: string }) { if (typeof host === "string") { return { "@type": "Person", name: host, }; } // Use the generic helper for objects return processors.processSchemaType(host, "Person"); } // Use in your component export function PodcastJsonLd({ hosts, ...props }) { const data = { "@context": "https://schema.org", "@type": "PodcastSeries", ...(hosts && { host: Array.isArray(hosts) ? hosts.map(processHost) : processHost(hosts), }), }; return ; } ``` ### Advanced Custom Processor with Type Detection Intelligently determine the type based on input properties: ```tsx function processCreativeWork(work: string | Record) { if (typeof work === "string") { return { "@type": "CreativeWork", name: work, }; } // Already has @type? Return as-is if (work["@type"]) { return work; } // Detect type based on properties let type = "CreativeWork"; if ("isbn" in work) type = "Book"; else if ("director" in work) type = "Movie"; else if ("artist" in work) type = "MusicRecording"; return { "@type": type, ...work, }; } ``` ## Advanced Patterns ### Nested Processing Process nested structures recursively: ```tsx function processEventWithVenue(event: { name: string; venue?: string | { name: string; address?: string }; organizer?: string | { name: string }; }) { return { "@type": "Event", name: event.name, ...(event.venue && { location: typeof event.venue === "string" ? processors.processPlace(event.venue) : processors.processPlace({ ...event.venue, ...(event.venue.address && { address: processors.processAddress(event.venue.address), }), }), }), ...(event.organizer && { organizer: processors.processOrganizer(event.organizer), }), }; } ``` ### Conditional Properties Include properties only when they have values: ```tsx export function CustomProductJsonLd({ name, description, price, image, brand, reviews, aggregateRating, ...props }) { const data = { "@context": "https://schema.org", "@type": "Product", name, ...(description && { description }), ...(price && { offers: { "@type": "Offer", price, priceCurrency: "USD", }, }), ...(image && { image: Array.isArray(image) ? image.map(processors.processImage) : processors.processImage(image), }), ...(brand && { brand: processors.processBrand(brand) }), ...(reviews && { review: Array.isArray(reviews) ? reviews.map(processors.processReview) : processors.processReview(reviews), }), ...(aggregateRating && { aggregateRating: processors.processAggregateRating(aggregateRating), }), }; return ; } ``` ### Multiple Schema Types Support different schema types with a type prop: ```tsx type ScholarlyArticleType = | "ScholarlyArticle" | "MedicalScholarlyArticle" | "TechArticle"; export function ScholarlyArticleJsonLd({ type = "ScholarlyArticle", headline, author, datePublished, journal, doi, ...props }: { type?: ScholarlyArticleType; headline: string; author: string | Array; datePublished: string; journal?: string; doi?: string; }) { const data = { "@context": "https://schema.org", "@type": type, headline, datePublished, ...(author && { author: Array.isArray(author) ? author.map(processors.processAuthor) : processors.processAuthor(author), }), ...(journal && { isPartOf: { "@type": "PublicationIssue", name: journal, }, }), ...(doi && { identifier: processors.processIdentifier(doi) }), }; return ( ); } ``` ## Best Practices ### 1. Always Use Processors for Flexible Types ```tsx // ✅ Good - uses processor author: processors.processAuthor(author) // ❌ Bad - requires user to specify @type author: { "@type": "Person", ...author } ``` ### 2. Handle Arrays and Single Values ```tsx // Support both single and array inputs ...(tags && { keywords: Array.isArray(tags) ? tags.join(', ') : tags }) ``` ### 3. Apply Sensible Defaults ```tsx // Default dateModified to datePublished if not provided const data = { datePublished, dateModified: dateModified || datePublished, }; ``` ### 4. Use TypeScript for Better DX ```tsx interface ServiceJsonLdProps { name: string; provider?: string | Organization; areaServed?: string | string[]; serviceType?: string; scriptId?: string; scriptKey?: string; } ``` ### 5. Document Your Component ```tsx /** * ServiceJsonLd - Structured data for service offerings * * @example * */ export function ServiceJsonLd({ ... }) { ... } ``` ## Real-World Examples ### 1. Podcast Series with Episodes ```tsx import { JsonLdScript, processors } from "next-seo"; interface PodcastSeriesProps { name: string; description?: string; host?: string | Array; episodes?: Array<{ name: string; url?: string; duration?: string; datePublished?: string; }>; image?: string | { url: string; width?: number; height?: number }; scriptKey?: string; } export function PodcastSeriesJsonLd({ name, description, host, episodes, image, scriptKey = "podcast-series", }: PodcastSeriesProps) { const data = { "@context": "https://schema.org", "@type": "PodcastSeries", name, ...(description && { description }), ...(host && { host: Array.isArray(host) ? host.map((h) => typeof h === "string" ? { "@type": "Person", name: h } : processors.processAuthor(h), ) : typeof host === "string" ? { "@type": "Person", name: host } : processors.processAuthor(host), }), ...(image && { image: processors.processImage(image) }), ...(episodes && { episode: episodes.map((ep, index) => ({ "@type": "PodcastEpisode", name: ep.name, position: index + 1, ...(ep.url && { url: ep.url }), ...(ep.duration && { duration: ep.duration }), ...(ep.datePublished && { datePublished: ep.datePublished }), })), }), }; return ; } ``` ### 2. Real Estate Listing ```tsx import { JsonLdScript, processors, type ImageObject } from "next-seo"; interface RealEstateListingProps { name: string; description?: string; price: number; priceCurrency?: string; address: string | Record; images?: Array; numberOfRooms?: number; floorSize?: { value: number; unitCode: string }; yearBuilt?: number; scriptKey?: string; } export function RealEstateListingJsonLd({ name, description, price, priceCurrency = "USD", address, images, numberOfRooms, floorSize, yearBuilt, scriptKey = "real-estate", }: RealEstateListingProps) { const data = { "@context": "https://schema.org", "@type": "RealEstateListing", name, ...(description && { description }), offers: { "@type": "Offer", price, priceCurrency, }, address: processors.processAddress(address), ...(images && { image: images.map(processors.processImage), }), ...(numberOfRooms && { numberOfRooms }), ...(floorSize && { floorSize: processors.processQuantitativeValue(floorSize), }), ...(yearBuilt && { yearBuilt }), }; return ; } ``` ### 3. Service with Pricing Tiers ```tsx import { JsonLdScript, processors } from "next-seo"; interface ServiceWithPricingProps { name: string; provider: string | { name: string; url?: string }; description?: string; pricingTiers?: Array<{ name: string; price: number | { min: number; max: number }; features?: string[]; }>; areaServed?: string | string[]; scriptKey?: string; } export function ServiceWithPricingJsonLd({ name, provider, description, pricingTiers, areaServed, scriptKey = "service", }: ServiceWithPricingProps) { const data = { "@context": "https://schema.org", "@type": "Service", name, provider: processors.processOrganization(provider), ...(description && { description }), ...(pricingTiers && { hasOfferCatalog: { "@type": "OfferCatalog", name: `${name} Pricing`, itemListElement: pricingTiers.map((tier) => ({ "@type": "Offer", name: tier.name, ...(typeof tier.price === "number" ? { price: tier.price } : { priceSpecification: { "@type": "PriceSpecification", minPrice: tier.price.min, maxPrice: tier.price.max, priceCurrency: "USD", }, }), ...(tier.features && { description: tier.features.join(", "), }), })), }, }), ...(areaServed && { areaServed: Array.isArray(areaServed) ? areaServed : [areaServed], }), }; return ; } ``` ### 4. Educational Course with Modules ```tsx import { JsonLdScript, processors } from "next-seo"; interface CourseWithModulesProps { name: string; description: string; provider: string | { name: string; url?: string }; instructor?: string | Array; modules?: Array<{ name: string; description?: string; duration?: string; }>; price?: number; startDate?: string; endDate?: string; scriptKey?: string; } export function CourseWithModulesJsonLd({ name, description, provider, instructor, modules, price, startDate, endDate, scriptKey = "course", }: CourseWithModulesProps) { const data = { "@context": "https://schema.org", "@type": "Course", name, description, provider: processors.processProvider(provider), ...(instructor && { instructor: Array.isArray(instructor) ? instructor.map(processors.processAuthor) : processors.processAuthor(instructor), }), ...(modules && { hasCourseInstance: modules.map((module, index) => ({ "@type": "CourseInstance", name: module.name, courseMode: "online", position: index + 1, ...(module.description && { description: module.description }), ...(module.duration && { duration: module.duration }), })), }), ...(price !== undefined && { offers: { "@type": "Offer", price, priceCurrency: "USD", ...(startDate && { validFrom: startDate }), ...(endDate && { validThrough: endDate }), }, }), }; return ; } ``` ## Testing Your Components ### Unit Testing ```tsx import { render } from "@testing-library/react"; import { ServiceJsonLd } from "./ServiceJsonLd"; describe("ServiceJsonLd", () => { it("renders service with basic props", () => { const { container } = render( , ); const script = container.querySelector( 'script[type="application/ld+json"]', ); const data = JSON.parse(script.textContent); expect(data["@type"]).toBe("Service"); expect(data.name).toBe("Consulting Service"); expect(data.provider["@type"]).toBe("Organization"); }); }); ``` ### Validation Use Google's Rich Results Test to validate your structured data: 1. Deploy your page with the custom component 2. Visit [Google Rich Results Test](https://search.google.com/test/rich-results) 3. Enter your URL and check for errors ## Migration Guide If you're migrating from inline JSON-LD to next-seo custom components: ### Before (Inline JSON-LD) ```tsx tags in strings", () => { const data = { description: "This contains a tag", }; const result = stringify(data); // Should escape the closing script tag using Unicode expect(result).toContain("\\u003C/script>"); expect(result).not.toContain(""); // Should still parse correctly const parsed = JSON.parse(result); // When parsed, the escaped sequence should be interpreted correctly expect(parsed.description).toBe("This contains a tag"); }); it("escapes tags case-insensitively", () => { const data = { description: "This contains a tag in uppercase", }; const result = stringify(data); // Case-insensitive regex converts all to lowercase expect(result).toContain("\\u003C/script>"); expect(result).not.toContain(""); expect(result).not.toContain(""); }); it("escapes HTML comments", () => { const data = { description: "This has inside", }; const result = stringify(data); expect(result).toContain("\\u003C!--"); expect(result).toContain("--\\u003E"); expect(result).not.toContain(""); const parsed = JSON.parse(result); expect(parsed.description).toBe("This has inside"); }); it("escapes multiple dangerous sequences", () => { const data = { content: " Content with and and ", }; const result = stringify(data); expect(result).toContain("\\u003C!--"); expect(result).toContain("--\\u003E"); expect(result).toContain("\\u003C/script>"); // All tags are escaped the same way (case-insensitive) // Both and should be escaped to <\\/script const scriptCount = (result.match(/\\u003C\/script>/gi) || []).length; expect(scriptCount).toBe(2); }); it("escapes dangerous sequences in URLs", () => { const data = { // This is an edge case - URLs shouldn't normally contain these sequences url: "https://example.com/page?content=&comment=", }; const result = stringify(data); // Even in URLs, dangerous sequences should be escaped expect(result).toContain("\\u003C/script>"); expect(result).toContain("\\u003C!--"); expect(result).toContain("--\\u003E"); // But regular & should not be escaped expect(result).toContain("content=\\u003C/script>&comment="); expect(result).not.toContain("&"); }); }); describe("general behavior", () => { it("preserves special characters that are not dangerous", () => { const data = { title: "Article with emphasis & \"quotes\" and 'apostrophes'", price: "$99.99 < $100", }; const result = stringify(data); const parsed = JSON.parse(result); // These characters should NOT be escaped expect(result).toContain(""); expect(result).toContain(""); expect(result).toContain(" & "); expect(result).not.toContain("&"); expect(result).not.toContain("<"); expect(result).not.toContain(">"); expect(result).not.toContain("""); expect(result).not.toContain("'"); expect(parsed.title).toBe(data.title); expect(parsed.price).toBe(data.price); }); it("handles nested objects and arrays", () => { const data = { "@context": "https://schema.org", "@type": "Recipe", name: "Test Recipe", image: [ "https://example.com/image1.jpg?size=small&format=jpg", "https://example.com/image2.jpg?size=large&format=webp", ], author: { "@type": "Person", name: "Chef with in name", url: "https://example.com/chef?id=123&role=author", }, }; const result = stringify(data); const parsed = JSON.parse(result); // URLs in arrays should not be escaped expect(result).not.toContain("&"); // But dangerous sequences should be expect(result).toContain("\\u003C/script>"); expect(parsed.image[0]).toBe(data.image[0]); expect(parsed.image[1]).toBe(data.image[1]); expect(parsed.author.url).toBe(data.author.url); expect(parsed.author.name).toBe("Chef with in name"); }); it("omits null values", () => { const data = { name: "Test", value: null, nested: { prop: null, other: "value", }, }; const result = stringify(data); const parsed = JSON.parse(result); expect(parsed.value).toBeUndefined(); expect(parsed.nested.prop).toBeUndefined(); expect(parsed.nested.other).toBe("value"); }); it("handles boolean and number values", () => { const data = { isActive: true, count: 42, rating: 4.5, isFree: false, }; const result = stringify(data); const parsed = JSON.parse(result); expect(parsed.isActive).toBe(true); expect(parsed.count).toBe(42); expect(parsed.rating).toBe(4.5); expect(parsed.isFree).toBe(false); }); }); }); ================================================ FILE: src/utils/stringify.ts ================================================ /* eslint-disable */ // Some of the code below is borrowed from react-schemaorg after the author of the package // kindly reached out to let me know this was a better way of doing things. ❤️ // https://github.com/google/react-schemaorg/blob/main/src/json-ld.tsx#L173 type JsonValueScalar = string | boolean | number; type JsonValue = | JsonValueScalar | Array | { [key: string]: JsonValue }; type JsonReplacer = (_: string, value: JsonValue) => JsonValue | undefined; /** * A replacer for JSON.stringify to omit null values from JSON-LD. * The actual script tag safety escaping is done in post-processing. */ const safeJsonLdReplacer: JsonReplacer = (() => { return (_: string, value: JsonValue): JsonValue | undefined => { switch (typeof value) { case "object": // Omit null values. if (value === null) { return undefined; } return value; // JSON.stringify will recursively call replacer. case "number": case "boolean": case "bigint": case "string": return value; // Return all primitive values as-is default: { // We shouldn't expect other types. isNever(value); // JSON.stringify will remove this element. return undefined; } } }; })(); /** * Type guard to ensure exhaustive type checking. * @internal */ function isNever(_: never): void {} /** * Stringify data for safe embedding in HTML script elements. * * Per W3C specifications and security best practices, we need to escape sequences * that could break out of the script tag: * - sequences (case-insensitive) * - sequences (HTML comments) * * We do NOT escape standard HTML entities like &, <, >, ", ' as they are valid * within script tag content and escaping them breaks URLs with query parameters. * * The escaping is done on the final JSON string to ensure the JSON remains valid * and parseable while being safe for HTML embedding. * * References: * - https://www.w3.org/TR/json-ld11/#restrictions-for-contents-of-json-ld-script-elements * - https://github.com/w3c/json-ld-syntax/issues/100 */ export const stringify = (data: unknown) => { const jsonString = JSON.stringify(data, safeJsonLdReplacer); // Post-process the JSON string to escape dangerous sequences // This ensures the JSON remains valid while being safe for script tags // Use Unicode escape sequences to break up dangerous patterns // This prevents the HTML parser from recognizing them while keeping valid JSON return jsonString .replace(/<\/script>/gi, "\\u003C/script>") // Unicode escape for < .replace(//g, "--\\u003E"); // Unicode escape for > }; ================================================ FILE: tests/e2e/.gitkeep ================================================ ================================================ FILE: tests/e2e/CLAUDE.md ================================================ # E2E Testing Guidelines ## Critical Rules **ALL E2E tests must use real example pages!** Never mock or inject content in E2E tests. ❌ **NEVER** use `page.route()` to inject mock HTML ✅ **ALWAYS** test real pages from `examples/app-router-showcase/app/` ## Commands ```bash pnpm build # This must be run first to compile the library pnpm test:e2e # Run all E2E tests pnpm test:e2e:ui # Run with UI mode pnpm example:dev # Start example app on localhost:3001 ``` ## Test Structure 1. **Navigate to real page**: `await page.goto("/article")` 2. **Extract JSON-LD**: Use `page.locator('script[type="application/ld+json"]')` 3. **Verify properties**: Test all expected Schema.org properties ## Important Patterns ### Security Testing - DO NOT add escape/security tests to individual component tests - Security testing is handled centrally in `security.e2e.spec.ts` - Focus on component-specific functionality only ### Creating New Tests For each new component E2E test: 1. Create example pages in `examples/app-router-showcase/app/` 2. Create test file: `[component]JsonLd.e2e.spec.ts` 3. Add validation test in `jsonValidation.e2e.spec.ts` ### Example Pages Required - Basic usage (minimal props) - Advanced usage (all features) - Each schema type variation - Special characters test (if applicable) ## Test Pattern ```typescript test("renders basic [Component] structured data", async ({ page }) => { // Navigate to real example page await page.goto("/[component]"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); const jsonData = JSON.parse(jsonLdScript!); // Verify properties expect(jsonData["@context"]).toBe("https://schema.org"); expect(jsonData["@type"]).toBe("[Type]"); // ... test all properties }); ``` ## Notes - Tests run against example app on port 3001 - Example app auto-starts for E2E tests - Focus on testing actual rendered output - Verify JSON validity and structure ================================================ FILE: tests/e2e/aggregateRatingJsonLd.e2e.spec.ts ================================================ import { test, expect } from "@playwright/test"; test.describe("AggregateRatingJsonLd", () => { test("renders basic aggregate rating for product", async ({ page }) => { await page.goto("/aggregate-rating"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); expect(jsonLdScript).toBeTruthy(); const jsonData = JSON.parse(jsonLdScript!); expect(jsonData["@context"]).toBe("https://schema.org"); expect(jsonData["@type"]).toBe("AggregateRating"); expect(jsonData.itemReviewed).toEqual({ "@type": "Product", name: "Executive Anvil", }); expect(jsonData.ratingValue).toBe(4.4); expect(jsonData.ratingCount).toBe(89); }); test("renders restaurant aggregate rating with percentage scale", async ({ page, }) => { await page.goto("/aggregate-rating-restaurant"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); expect(jsonLdScript).toBeTruthy(); const jsonData = JSON.parse(jsonLdScript!); expect(jsonData["@type"]).toBe("AggregateRating"); expect(jsonData.itemReviewed).toMatchObject({ "@type": "LocalBusiness", name: "Legal Seafood", servesCuisine: "Seafood", priceRange: "$$$", telephone: "1234567", }); // Check address is properly formatted expect(jsonData.itemReviewed.address).toEqual({ "@type": "PostalAddress", streetAddress: "123 William St", addressLocality: "New York", addressRegion: "NY", postalCode: "10038", addressCountry: "US", }); // Check percentage-based rating expect(jsonData.ratingValue).toBe(88); expect(jsonData.bestRating).toBe(100); expect(jsonData.ratingCount).toBe(350); }); test("renders aggregate rating nested in product", async ({ page }) => { await page.goto("/review-advanced"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); const jsonData = JSON.parse(jsonLdScript!); // Product should have nested aggregateRating expect(jsonData["@type"]).toBe("Product"); expect(jsonData.aggregateRating).toBeDefined(); expect(jsonData.aggregateRating).toEqual({ "@type": "AggregateRating", ratingValue: 4.2, bestRating: 5, ratingCount: 150, reviewCount: 120, }); }); test("supports both ratingCount and reviewCount", async ({ page }) => { await page.goto("/review-advanced"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); const jsonData = JSON.parse(jsonLdScript!); const aggregateRating = jsonData.aggregateRating; // Should have both ratingCount and reviewCount expect(aggregateRating.ratingCount).toBe(150); expect(aggregateRating.reviewCount).toBe(120); }); test("properly handles various rating scales", async ({ page }) => { // Test default 1-5 scale await page.goto("/aggregate-rating"); let jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); let jsonData = JSON.parse(jsonLdScript!); // Default scale (no bestRating/worstRating specified means 1-5) expect(jsonData.ratingValue).toBe(4.4); expect(jsonData.bestRating).toBeUndefined(); // Not specified, defaults to 5 expect(jsonData.worstRating).toBeUndefined(); // Not specified, defaults to 1 // Test percentage scale (0-100) await page.goto("/aggregate-rating-restaurant"); jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); jsonData = JSON.parse(jsonLdScript!); expect(jsonData.ratingValue).toBe(88); expect(jsonData.bestRating).toBe(100); expect(jsonData.worstRating).toBeUndefined(); // Can be undefined for 0-100 scale }); }); ================================================ FILE: tests/e2e/articleJsonLd.e2e.spec.ts ================================================ import { test, expect } from "@playwright/test"; test.describe("ArticleJsonLd", () => { test("renders basic Article structured data", async ({ page }) => { await page.goto("/article"); // Find the JSON-LD script tag const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); expect(jsonLdScript).toBeTruthy(); const jsonData = JSON.parse(jsonLdScript!); // Verify basic Article properties expect(jsonData["@context"]).toBe("https://schema.org"); expect(jsonData["@type"]).toBe("Article"); expect(jsonData.headline).toBe("Understanding Next.js App Router"); expect(jsonData.url).toBe("https://example.com/articles/nextjs-app-router"); expect(jsonData.datePublished).toBe("2024-01-01T08:00:00+00:00"); expect(jsonData.dateModified).toBe("2024-01-01T08:00:00+00:00"); // Should default to datePublished expect(jsonData.author).toEqual({ "@type": "Person", name: "Sarah Johnson", }); expect(jsonData.image).toBe( "https://example.com/images/nextjs-article.jpg", ); expect(jsonData.description).toBe( "A comprehensive guide to Next.js App Router and its features", ); }); test("renders NewsArticle with multiple authors and images", async ({ page, }) => { await page.goto("/news-article"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); const jsonData = JSON.parse(jsonLdScript!); // Verify NewsArticle type expect(jsonData["@type"]).toBe("NewsArticle"); expect(jsonData.headline).toBe( "Breaking: Next.js 14 Released with Major Performance Improvements", ); // Verify multiple authors expect(jsonData.author).toHaveLength(2); expect(jsonData.author[0]).toEqual({ "@type": "Person", name: "Alex Chen", url: "https://example.com/authors/alex-chen", }); expect(jsonData.author[1]).toEqual({ "@type": "Person", name: "Maria Garcia", url: "https://example.com/authors/maria-garcia", }); // Verify multiple images expect(jsonData.image).toHaveLength(3); expect(jsonData.image).toContain( "https://example.com/images/nextjs-14-16x9.jpg", ); expect(jsonData.image).toContain( "https://example.com/images/nextjs-14-4x3.jpg", ); expect(jsonData.image).toContain( "https://example.com/images/nextjs-14-1x1.jpg", ); // Verify publisher with logo expect(jsonData.publisher).toEqual({ "@type": "Organization", name: "Tech News Daily", logo: { "@type": "ImageObject", url: "https://example.com/logo.png", width: 600, height: 60, }, }); // Verify dates expect(jsonData.datePublished).toBe("2024-01-15T10:00:00+00:00"); expect(jsonData.dateModified).toBe("2024-01-15T14:30:00+00:00"); // Verify accessibility expect(jsonData.isAccessibleForFree).toBe(true); }); test("renders BlogPosting with all properties", async ({ page }) => { await page.goto("/blog-posting"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); const jsonData = JSON.parse(jsonLdScript!); // Verify BlogPosting type expect(jsonData["@type"]).toBe("BlogPosting"); // Verify Organization author expect(jsonData.author).toEqual({ "@type": "Organization", name: "WebDev Solutions", url: "https://example.com", logo: "https://example.com/webdev-logo.png", }); // Verify ImageObject expect(jsonData.image).toEqual({ "@type": "ImageObject", url: "https://example.com/images/seo-tips-hero.jpg", width: 1920, height: 1080, caption: "SEO Tips for Web Developers", }); // Verify mainEntityOfPage expect(jsonData.mainEntityOfPage).toEqual({ "@type": "WebPage", "@id": "https://example.com/blog/seo-tips-web-development", }); // Verify premium content flag expect(jsonData.isAccessibleForFree).toBe(false); }); test("renders multiple JSON-LD scripts on the same page", async ({ page, }) => { // Navigate to a page and inject multiple ArticleJsonLd components await page.goto("/article"); // Count JSON-LD scripts const scriptsCount = await page .locator('script[type="application/ld+json"]') .count(); expect(scriptsCount).toBeGreaterThanOrEqual(1); // Verify each script contains valid JSON const scripts = await page .locator('script[type="application/ld+json"]') .all(); for (const script of scripts) { const content = await script.textContent(); expect(() => JSON.parse(content!)).not.toThrow(); } }); }); ================================================ FILE: tests/e2e/breadcrumbJsonLd.e2e.spec.ts ================================================ import { test, expect } from "@playwright/test"; test.describe("BreadcrumbJsonLd", () => { test("renders basic breadcrumb structured data", async ({ page }) => { await page.goto("/breadcrumb"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); expect(jsonLdScript).toBeTruthy(); const jsonData = JSON.parse(jsonLdScript!); // Verify structure expect(jsonData["@context"]).toBe("https://schema.org"); expect(jsonData["@type"]).toBe("BreadcrumbList"); expect(jsonData.itemListElement).toHaveLength(5); // Verify first item expect(jsonData.itemListElement[0]).toEqual({ "@type": "ListItem", position: 1, name: "Home", item: "https://example.com", }); // Verify middle item expect(jsonData.itemListElement[2]).toEqual({ "@type": "ListItem", position: 3, name: "Electronics", item: "https://example.com/products/electronics", }); // Verify last item (no URL) expect(jsonData.itemListElement[4]).toEqual({ "@type": "ListItem", position: 5, name: "iPhone 15 Pro", }); }); test("renders multiple breadcrumb trails", async ({ page }) => { await page.goto("/breadcrumb/multiple"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); expect(jsonLdScript).toBeTruthy(); const jsonData = JSON.parse(jsonLdScript!); // Should be an array of two BreadcrumbList objects expect(Array.isArray(jsonData)).toBe(true); expect(jsonData).toHaveLength(2); // Verify first trail expect(jsonData[0]["@context"]).toBe("https://schema.org"); expect(jsonData[0]["@type"]).toBe("BreadcrumbList"); expect(jsonData[0].itemListElement).toHaveLength(3); expect(jsonData[0].itemListElement[0].name).toBe("Books"); expect(jsonData[0].itemListElement[1].name).toBe("Science Fiction"); expect(jsonData[0].itemListElement[2].name).toBe("Award Winners"); // Verify second trail expect(jsonData[1]["@context"]).toBe("https://schema.org"); expect(jsonData[1]["@type"]).toBe("BreadcrumbList"); expect(jsonData[1].itemListElement).toHaveLength(2); expect(jsonData[1].itemListElement[0].name).toBe("Literature"); expect(jsonData[1].itemListElement[1].name).toBe("Award Winners"); }); test("renders with Thing objects and custom attributes", async ({ page }) => { await page.goto("/breadcrumb/advanced"); const jsonLdScript = await page .locator('script[type="application/ld+json"][id="blog-breadcrumb"]') .textContent(); expect(jsonLdScript).toBeTruthy(); const jsonData = JSON.parse(jsonLdScript!); // Verify Thing objects with @id expect(jsonData.itemListElement[1].item).toEqual({ "@id": "https://example.com/blog", }); expect(jsonData.itemListElement[2].item).toEqual({ "@id": "https://example.com/blog/technology", }); // Verify mixed usage (URL string for first item) expect(jsonData.itemListElement[0].item).toBe("https://example.com"); // Verify last item has no URL expect(jsonData.itemListElement[3].item).toBeUndefined(); }); }); ================================================ FILE: tests/e2e/carouselJsonLd.e2e.spec.ts ================================================ import { test, expect } from "@playwright/test"; test.describe("CarouselJsonLd", () => { test("renders summary page carousel structured data", async ({ page }) => { await page.goto("/carousel-summary"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); expect(jsonLdScript).toBeTruthy(); const jsonData = JSON.parse(jsonLdScript!); // Verify ItemList structure expect(jsonData["@context"]).toBe("https://schema.org"); expect(jsonData["@type"]).toBe("ItemList"); expect(jsonData.itemListElement).toHaveLength(5); // Verify first item (simple URL string) expect(jsonData.itemListElement[0]).toEqual({ "@type": "ListItem", position: 1, url: "https://example.com/recipe/chocolate-cookies", }); // Verify second item (auto-positioned) expect(jsonData.itemListElement[1]).toEqual({ "@type": "ListItem", position: 2, url: "https://example.com/recipe/banana-bread", }); // Verify third item (custom position) expect(jsonData.itemListElement[2]).toEqual({ "@type": "ListItem", position: 3, url: "https://example.com/recipe/apple-pie", }); // Verify positions are correct expect(jsonData.itemListElement[3].position).toBe(4); expect(jsonData.itemListElement[4].position).toBe(5); }); test("renders Course carousel with full data", async ({ page }) => { await page.goto("/carousel-course"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); expect(jsonLdScript).toBeTruthy(); const jsonData = JSON.parse(jsonLdScript!); expect(jsonData["@type"]).toBe("ItemList"); expect(jsonData.itemListElement).toHaveLength(4); // Verify first course with string provider const firstCourse = jsonData.itemListElement[0]; expect(firstCourse["@type"]).toBe("ListItem"); expect(firstCourse.position).toBe(1); expect(firstCourse.item["@type"]).toBe("Course"); expect(firstCourse.item.name).toBe("Introduction to Web Development"); expect(firstCourse.item.description).toContain("Learn the fundamentals"); expect(firstCourse.item.provider).toEqual({ "@type": "Organization", name: "Tech Academy Online", }); // Verify second course with object provider const secondCourse = jsonData.itemListElement[1]; expect(secondCourse.item.provider["@type"]).toBe("Organization"); expect(secondCourse.item.provider.name).toBe("Code School Pro"); expect(secondCourse.item.provider.url).toBe("https://example.com/school"); // Verify third course with provider having sameAs const thirdCourse = jsonData.itemListElement[2]; expect(thirdCourse.item.provider.sameAs).toEqual([ "https://twitter.com/devinstitute", ]); }); test("renders Movie carousel with directors and reviews", async ({ page, }) => { await page.goto("/carousel-movie"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); expect(jsonLdScript).toBeTruthy(); const jsonData = JSON.parse(jsonLdScript!); expect(jsonData["@type"]).toBe("ItemList"); expect(jsonData.itemListElement).toHaveLength(4); // Verify The Matrix movie const matrix = jsonData.itemListElement[0].item; expect(matrix["@type"]).toBe("Movie"); expect(matrix.name).toBe("The Matrix"); expect(Array.isArray(matrix.image)).toBe(true); expect(matrix.image).toHaveLength(2); expect(matrix.director).toEqual({ "@type": "Person", name: "The Wachowskis", }); expect(matrix.aggregateRating["@type"]).toBe("AggregateRating"); expect(matrix.aggregateRating.ratingValue).toBe(8.7); expect(matrix.review["@type"]).toBe("Review"); expect(matrix.review.author).toEqual({ "@type": "Person", name: "Film Critic Daily", }); // Verify Inception with object director const inception = jsonData.itemListElement[1].item; expect(inception.director["@type"]).toBe("Person"); expect(inception.director.name).toBe("Christopher Nolan"); expect(inception.director.url).toBe("https://example.com/directors/nolan"); // Verify Interstellar with multiple images const interstellar = jsonData.itemListElement[2].item; expect(interstellar.image).toHaveLength(3); expect(interstellar.review.reviewRating["@type"]).toBe("Rating"); expect(interstellar.review.reviewRating.bestRating).toBe(5); }); test("renders Recipe carousel with full recipe details", async ({ page }) => { await page.goto("/carousel-recipe"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); expect(jsonLdScript).toBeTruthy(); const jsonData = JSON.parse(jsonLdScript!); expect(jsonData["@type"]).toBe("ItemList"); expect(jsonData.itemListElement).toHaveLength(3); // Verify chocolate chip cookies const cookies = jsonData.itemListElement[0].item; expect(cookies["@type"]).toBe("Recipe"); expect(cookies.name).toBe("Perfect Chocolate Chip Cookies"); expect(Array.isArray(cookies.image)).toBe(true); expect(cookies.image).toHaveLength(3); expect(cookies.author).toEqual({ "@type": "Person", name: "Chef Sarah", }); expect(cookies.recipeYield).toBe("24 cookies"); expect(cookies.recipeIngredient).toHaveLength(9); expect(cookies.recipeInstructions).toHaveLength(8); expect(cookies.nutrition["@type"]).toBe("NutritionInformation"); expect(cookies.nutrition.calories).toBe("210 calories"); expect(cookies.aggregateRating["@type"]).toBe("AggregateRating"); // Verify banana bread with author and video const bananaBread = jsonData.itemListElement[1].item; expect(bananaBread.author).toEqual({ "@type": "Person", name: "Grandma Rose", }); expect(bananaBread.recipeInstructions["@type"]).toBe("HowToSection"); expect(bananaBread.recipeInstructions.itemListElement).toHaveLength(6); expect(bananaBread.video["@type"]).toBe("VideoObject"); expect(bananaBread.video.duration).toBe("PT8M"); // Verify pizza with HowToStep instructions const pizza = jsonData.itemListElement[2].item; expect(pizza.image["@type"]).toBe("ImageObject"); expect(pizza.image.width).toBe(1200); expect(pizza.recipeInstructions[0]["@type"]).toBe("HowToStep"); expect(pizza.recipeInstructions[0].name).toBe("Prepare dough"); }); test("renders Restaurant carousel with address and opening hours", async ({ page, }) => { await page.goto("/carousel-restaurant"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); expect(jsonLdScript).toBeTruthy(); const jsonData = JSON.parse(jsonLdScript!); expect(jsonData["@type"]).toBe("ItemList"); expect(jsonData.itemListElement).toHaveLength(4); // Verify Luigi's with string address const luigis = jsonData.itemListElement[0].item; expect(luigis["@type"]).toBe("Restaurant"); expect(luigis.name).toBe("Luigi's Italian Bistro"); expect(luigis.address).toBe("123 Main Street, New York, NY 10001"); expect(luigis.priceRange).toBe("$$$"); expect(Array.isArray(luigis.servesCuisine)).toBe(true); expect(luigis.servesCuisine).toContain("Italian"); expect(luigis.geo["@type"]).toBe("GeoCoordinates"); expect(luigis.geo.latitude).toBe(40.7489); expect(luigis.openingHoursSpecification).toHaveLength(3); expect(luigis.openingHoursSpecification[0]["@type"]).toBe( "OpeningHoursSpecification", ); expect(luigis.review["@type"]).toBe("Review"); expect(luigis.sameAs).toHaveLength(2); // Verify Sakura with PostalAddress object const sakura = jsonData.itemListElement[1].item; expect(sakura.address["@type"]).toBe("PostalAddress"); expect(sakura.address.streetAddress).toBe("456 Oak Avenue"); expect(sakura.address.addressLocality).toBe("San Francisco"); expect(sakura.address.postalCode).toBe("94102"); expect(sakura.openingHoursSpecification["@type"]).toBe( "OpeningHoursSpecification", ); // Verify Garden Terrace with multiple reviews const garden = jsonData.itemListElement[2].item; expect(garden.image["@type"]).toBe("ImageObject"); expect(Array.isArray(garden.review)).toBe(true); expect(garden.review).toHaveLength(2); expect(garden.review[0].author).toEqual({ "@type": "Person", name: "Michelin Guide", }); expect(garden.review[1].author["@type"]).toBe("Person"); // Verify Taco Paradise const taco = jsonData.itemListElement[3].item; expect(Array.isArray(taco.image)).toBe(true); expect(taco.image).toHaveLength(3); expect(taco.priceRange).toBe("$"); }); test("properly escapes special characters in carousel content", async ({ page, }) => { // Create a test page with special characters await page.goto("/carousel-recipe"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); // Should be valid JSON even with special characters in content expect(() => JSON.parse(jsonLdScript!)).not.toThrow(); // Check that content with quotes and special chars is properly handled const jsonData = JSON.parse(jsonLdScript!); expect(jsonData.itemListElement[0].item.recipeIngredient).toContain( "2 1/4 cups all-purpose flour", ); }); }); ================================================ FILE: tests/e2e/claimReviewJsonLd.e2e.spec.ts ================================================ import { test, expect } from "@playwright/test"; test.describe("ClaimReviewJsonLd", () => { test("renders basic ClaimReview structured data", async ({ page }) => { await page.goto("/claim-review"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); expect(jsonLdScript).toBeTruthy(); const jsonData = JSON.parse(jsonLdScript!); // Verify all properties expect(jsonData["@context"]).toBe("https://schema.org"); expect(jsonData["@type"]).toBe("ClaimReview"); expect(jsonData.claimReviewed).toBe("The world is flat"); expect(jsonData.url).toBe( "https://example.com/news/science/worldisflat.html", ); // Verify rating expect(jsonData.reviewRating).toEqual({ "@type": "Rating", ratingValue: 1, bestRating: 5, worstRating: 1, alternateName: "False", }); // Verify author expect(jsonData.author).toEqual({ "@type": "Person", name: "Example.com science watch", }); }); test("renders advanced ClaimReview with full claim details", async ({ page, }) => { await page.goto("/claim-review-advanced"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); expect(jsonLdScript).toBeTruthy(); const jsonData = JSON.parse(jsonLdScript!); // Verify organization author expect(jsonData.author).toEqual({ "@type": "Organization", name: "Example.com Science Watch", url: "https://example.com/science", logo: "https://example.com/logo.jpg", }); // Verify rating with name expect(jsonData.reviewRating.name).toBe("False"); // Verify itemReviewed expect(jsonData.itemReviewed).toBeTruthy(); expect(jsonData.itemReviewed["@type"]).toBe("Claim"); expect(jsonData.itemReviewed.author).toEqual({ "@type": "Organization", name: "Square World Society", sameAs: "https://example.flatworlders.com/we-know-that-the-world-is-flat", }); expect(jsonData.itemReviewed.datePublished).toBe("2024-06-20"); // Verify appearance expect(jsonData.itemReviewed.appearance).toEqual({ "@type": "OpinionNewsArticle", url: "https://example.com/news/a122121", headline: "Square Earth - Flat earthers for the Internet age", datePublished: "2024-06-22", author: { "@type": "Person", name: "T. Tellar", }, image: "https://example.com/photos/1x1/photo.jpg", publisher: { "@type": "Organization", name: "Skeptical News", logo: { "@type": "ImageObject", url: "https://example.com/logo.jpg", }, }, }); }); test("renders ClaimReview with organization author and firstAppearance", async ({ page, }) => { await page.goto("/claim-review-organization"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); expect(jsonLdScript).toBeTruthy(); const jsonData = JSON.parse(jsonLdScript!); // Verify claim expect(jsonData.claimReviewed).toBe("Climate change is not real"); // Verify rating expect(jsonData.reviewRating.alternateName).toBe("Pants on Fire"); // Verify organization author with detailed properties expect(jsonData.author["@type"]).toBe("Organization"); expect(jsonData.author.name).toBe("Climate Facts Organization"); expect(jsonData.author.logo).toEqual({ "@type": "ImageObject", url: "https://example.com/logo.png", width: 300, height: 60, }); expect(jsonData.author.sameAs).toEqual([ "https://twitter.com/climatefacts", "https://facebook.com/climatefacts", ]); // Verify itemReviewed with organization claim author expect(jsonData.itemReviewed.author).toEqual({ "@type": "Organization", name: "Climate Denial Institute", url: "https://example-denial.com", }); // Verify firstAppearance expect(jsonData.itemReviewed.firstAppearance).toEqual({ "@type": "CreativeWork", url: "https://example-denial.com/climate-hoax", headline: "The Great Climate Hoax Exposed", datePublished: "2024-07-01", author: { "@type": "Organization", name: "Climate Denial Institute", url: "https://example-denial.com", }, }); }); }); ================================================ FILE: tests/e2e/courseJsonLd.e2e.spec.ts ================================================ import { test, expect } from "@playwright/test"; test.describe("CourseJsonLd", () => { test("renders single Course structured data", async ({ page }) => { await page.goto("/course"); // Find the JSON-LD script tag const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); expect(jsonLdScript).toBeTruthy(); const jsonData = JSON.parse(jsonLdScript!); // Verify Course properties expect(jsonData["@context"]).toBe("https://schema.org"); expect(jsonData["@type"]).toBe("Course"); expect(jsonData.name).toBe( "Introduction to Computer Science and Programming", ); expect(jsonData.description).toBe( "This is an introductory CS course laying out the basics.", ); expect(jsonData.url).toBe("https://example.com/courses/intro-cs"); expect(jsonData.provider).toEqual({ "@type": "Organization", name: "University of Technology - Eureka", sameAs: "https://www.example.com", }); }); test("renders Course list with full data (all-in-one pattern)", async ({ page, }) => { await page.goto("/course-list"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); const jsonData = JSON.parse(jsonLdScript!); // Verify ItemList structure expect(jsonData["@context"]).toBe("https://schema.org"); expect(jsonData["@type"]).toBe("ItemList"); expect(jsonData.itemListElement).toHaveLength(3); // Verify first course const firstItem = jsonData.itemListElement[0]; expect(firstItem["@type"]).toBe("ListItem"); expect(firstItem.position).toBe(1); expect(firstItem.item).toEqual({ "@type": "Course", name: "Introduction to Computer Science and Programming", description: "This is an introductory CS course laying out the basics.", url: "https://example.com/courses#intro-to-cs", provider: { "@type": "Organization", name: "University of Technology - Example", sameAs: "https://www.example.com", }, }); // Verify second course const secondItem = jsonData.itemListElement[1]; expect(secondItem.position).toBe(2); expect(secondItem.item.name).toBe( "Intermediate Computer Science and Programming", ); expect(secondItem.item.description).toBe( "This CS course builds on the basics from the intro course.", ); // Verify third course const thirdItem = jsonData.itemListElement[2]; expect(thirdItem.position).toBe(3); expect(thirdItem.item.name).toBe( "Advanced Computer Science and Programming", ); expect(thirdItem.item.provider.name).toBe( "University of Technology - Eureka", ); }); test("renders Course list with URLs only (summary page pattern)", async ({ page, }) => { await page.goto("/course-list-summary"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); const jsonData = JSON.parse(jsonLdScript!); // Verify ItemList structure expect(jsonData["@context"]).toBe("https://schema.org"); expect(jsonData["@type"]).toBe("ItemList"); expect(jsonData.itemListElement).toHaveLength(5); // Verify URLs are preserved correctly const urls = jsonData.itemListElement.map( (item: { url: string }) => item.url, ); expect(urls).toEqual([ "https://example.com/courses/intro-programming", "https://example.com/courses/web-development", "https://example.com/courses/data-science", "https://example.com/courses/machine-learning", "https://example.com/courses/mobile-development", ]); // Verify each item has proper structure jsonData.itemListElement.forEach( ( item: { "@type": string; position: number; url: string; item?: unknown; }, index: number, ) => { expect(item["@type"]).toBe("ListItem"); expect(item.position).toBe(index + 1); expect(item.url).toBeTruthy(); expect(item.item).toBeUndefined(); // Should not have item property for summary pattern }, ); }); test("preserves URL query parameters in course URLs", async ({ page }) => { // Create a test page with query parameters await page.goto("/course"); // Inject a script to modify the CourseJsonLd await page.evaluate(() => { const script = document.querySelector( 'script[type="application/ld+json"]', ); if (script) { const data = JSON.parse(script.textContent || "{}"); data.url = "https://example.com/courses/test?utm_source=google&ref=home"; script.textContent = JSON.stringify(data); } }); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); const jsonData = JSON.parse(jsonLdScript!); expect(jsonData.url).toBe( "https://example.com/courses/test?utm_source=google&ref=home", ); }); }); ================================================ FILE: tests/e2e/creativeWorkJsonLd.e2e.spec.ts ================================================ import { test, expect } from "@playwright/test"; test.describe("CreativeWorkJsonLd", () => { test("renders basic Article with paywalled content", async ({ page }) => { await page.goto("/creative-work"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); expect(jsonLdScript).toBeTruthy(); const jsonData = JSON.parse(jsonLdScript!); // Verify basic structure expect(jsonData["@context"]).toBe("https://schema.org"); expect(jsonData["@type"]).toBe("Article"); expect(jsonData.headline).toBe( "Premium Article: Understanding Paywalled Content", ); expect(jsonData.url).toBe("https://example.com/articles/premium-content"); expect(jsonData.datePublished).toBe("2024-01-01T08:00:00+00:00"); expect(jsonData.dateModified).toBe("2024-01-02T10:00:00+00:00"); // Verify author processing expect(jsonData.author).toEqual({ "@type": "Person", name: "Sarah Johnson", }); // Verify publisher expect(jsonData.publisher).toEqual({ "@type": "Organization", name: "Premium Publications", logo: "https://example.com/logo.png", }); // Verify paywall marking expect(jsonData.isAccessibleForFree).toBe(false); expect(jsonData.hasPart).toEqual({ "@type": "WebPageElement", isAccessibleForFree: false, cssSelector: ".paywall", }); expect(jsonData.mainEntityOfPage).toBe("https://example.com/articles"); }); test("renders Article with multiple paywalled sections", async ({ page }) => { await page.goto("/creative-work-multiple"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); const jsonData = JSON.parse(jsonLdScript!); expect(jsonData["@type"]).toBe("Article"); expect(jsonData.headline).toBe( "In-Depth Analysis: Multiple Premium Sections", ); // Verify multiple authors expect(jsonData.author).toHaveLength(2); expect(jsonData.author[0]).toEqual({ "@type": "Person", name: "Dr. Emily Chen", }); expect(jsonData.author[1]["@type"]).toBe("Organization"); expect(jsonData.author[1].name).toBe("Research Institute"); // Verify multiple images expect(jsonData.image).toHaveLength(3); expect(jsonData.image[0]).toBe( "https://example.com/images/analysis-16x9.jpg", ); // Verify multiple paywalled sections expect(jsonData.hasPart).toHaveLength(2); expect(jsonData.hasPart[0]).toEqual({ "@type": "WebPageElement", isAccessibleForFree: false, cssSelector: ".section1", }); expect(jsonData.hasPart[1]).toEqual({ "@type": "WebPageElement", isAccessibleForFree: false, cssSelector: ".section2", }); }); test("renders NewsArticle with premium content", async ({ page }) => { await page.goto("/creative-work-news"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); const jsonData = JSON.parse(jsonLdScript!); expect(jsonData["@type"]).toBe("NewsArticle"); expect(jsonData.headline).toBe( "Breaking: Major Scientific Discovery Behind Paywall", ); // Verify author with URL expect(jsonData.author).toEqual({ "@type": "Person", name: "Jane Martinez", url: "https://example.com/journalists/jane-martinez", }); // Verify ImageObject expect(jsonData.image["@type"]).toBe("ImageObject"); expect(jsonData.image.url).toBe( "https://example.com/images/discovery-hero.jpg", ); expect(jsonData.image.width).toBe(1200); expect(jsonData.image.height).toBe(630); expect(jsonData.image.caption).toBe("Scientific breakthrough illustration"); // Verify publisher with logo ImageObject expect(jsonData.publisher.name).toBe("Global News Network"); expect(jsonData.publisher.logo["@type"]).toBe("ImageObject"); expect(jsonData.publisher.logo.url).toBe( "https://example.com/gnn-logo.png", ); // Verify paywall section expect(jsonData.isAccessibleForFree).toBe(false); expect(jsonData.hasPart).toEqual({ "@type": "WebPageElement", isAccessibleForFree: false, cssSelector: ".premium-news", }); // Verify mainEntityOfPage as WebPage expect(jsonData.mainEntityOfPage).toEqual({ "@type": "WebPage", "@id": "https://example.com/news/scientific-discovery", }); }); test("renders Blog with subscription content", async ({ page }) => { await page.goto("/creative-work-blog"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); const jsonData = JSON.parse(jsonLdScript!); expect(jsonData["@type"]).toBe("Blog"); expect(jsonData.name).toBe("Premium Tech Insights Blog"); expect(jsonData.url).toBe("https://example.com/blog"); expect(jsonData.description).toContain("premium technology blog"); // Verify it uses name instead of headline for Blog type expect(jsonData.headline).toBeUndefined(); // Verify author and publisher expect(jsonData.author["@type"]).toBe("Organization"); expect(jsonData.author.name).toBe("Tech Insights Team"); expect(jsonData.publisher.name).toBe("Tech Insights Publishing"); // Verify subscription marking expect(jsonData.isAccessibleForFree).toBe(false); expect(jsonData.hasPart).toEqual({ "@type": "WebPageElement", isAccessibleForFree: false, cssSelector: ".members-only", }); // Verify Blog doesn't auto-add dateModified expect(jsonData.datePublished).toBe("2024-01-01T00:00:00+00:00"); expect(jsonData.dateModified).toBeUndefined(); }); test("properly escapes special characters in content", async ({ page }) => { // Navigate to the creative-work page which has content with special characters await page.goto("/creative-work"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); // Verify JSON is valid even with special characters expect(() => JSON.parse(jsonLdScript!)).not.toThrow(); const jsonData = JSON.parse(jsonLdScript!); expect(jsonData.description).toContain("paywalled content"); }); test("verifies all CreativeWork types are supported", async ({ page }) => { // Test the basic creative-work page await page.goto("/creative-work"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); const jsonData = JSON.parse(jsonLdScript!); // Verify the component supports different types const supportedTypes = [ "CreativeWork", "Article", "NewsArticle", "Blog", "BlogPosting", "Comment", "Course", "HowTo", "Message", "Review", "WebPage", ]; // The current page uses "Article" type expect(supportedTypes).toContain(jsonData["@type"]); }); }); ================================================ FILE: tests/e2e/customComponents.e2e.spec.ts ================================================ import { test, expect } from "@playwright/test"; test.describe("Custom JSON-LD Components", () => { test("renders PodcastSeries structured data correctly", async ({ page }) => { await page.goto("/custom-podcast"); // Get the JSON-LD script content const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); expect(jsonLdScript).toBeTruthy(); const jsonData = JSON.parse(jsonLdScript!); // Verify basic structure expect(jsonData["@context"]).toBe("https://schema.org"); expect(jsonData["@type"]).toBe("PodcastSeries"); expect(jsonData.name).toBe("Tech Talk Weekly"); expect(jsonData.description).toBe( "Weekly discussions about technology trends and innovations", ); expect(jsonData.url).toBe("https://example.com/podcasts/tech-talk-weekly"); // Verify processor worked - host string became Person with @type expect(jsonData.host).toEqual({ "@type": "Person", name: "Sarah Johnson", }); // Verify image processor kept string as-is expect(jsonData.image).toBe("https://example.com/podcast-cover.jpg"); // Verify episodes array structure expect(jsonData.episode).toHaveLength(3); expect(jsonData.episode[0]).toEqual({ "@type": "PodcastEpisode", name: "Episode 1: AI Revolution", position: 1, duration: "PT30M", datePublished: "2024-01-01", description: "Exploring the latest developments in artificial intelligence", url: "https://example.com/episodes/ep1-ai-revolution", }); // Verify position is auto-generated expect(jsonData.episode[1].position).toBe(2); expect(jsonData.episode[2].position).toBe(3); }); test("renders Service structured data correctly", async ({ page }) => { await page.goto("/custom-service"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); expect(jsonLdScript).toBeTruthy(); const jsonData = JSON.parse(jsonLdScript!); // Verify basic structure expect(jsonData["@context"]).toBe("https://schema.org"); expect(jsonData["@type"]).toBe("Service"); expect(jsonData.name).toBe("Web Development Services"); expect(jsonData.serviceType).toBe("Professional Service"); expect(jsonData.description).toContain("Full-stack web development"); expect(jsonData.url).toBe("https://example.com/services/web-development"); // Verify processor worked - complex provider object became Organization with @type expect(jsonData.provider).toMatchObject({ "@type": "Organization", name: "Tech Solutions Inc", url: "https://example.com", logo: expect.any(String), address: expect.objectContaining({ "@type": "PostalAddress", streetAddress: "123 Tech Street", addressLocality: "San Francisco", addressRegion: "CA", postalCode: "94105", addressCountry: "US", }), }); // Verify areaServed is array expect(jsonData.areaServed).toEqual(["US", "CA", "UK", "AU"]); // Verify offers structure expect(jsonData.offers).toEqual({ "@type": "Offer", priceRange: "$$$", }); // Verify aggregateRating processor worked expect(jsonData.aggregateRating).toEqual({ "@type": "AggregateRating", ratingValue: 4.8, reviewCount: 127, bestRating: 5, worstRating: 1, }); }); test("processors handle flexible input types correctly", async ({ page }) => { // Test PodcastSeries with string inputs await page.goto("/custom-podcast"); let jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); let jsonData = JSON.parse(jsonLdScript!); // String host became Person object with @type expect(jsonData.host).toHaveProperty("@type", "Person"); expect(jsonData.host).toHaveProperty("name", "Sarah Johnson"); // String image URL stayed as string (processImage preserves strings) expect(typeof jsonData.image).toBe("string"); // Test Service with complex inputs await page.goto("/custom-service"); jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); jsonData = JSON.parse(jsonLdScript!); // Complex provider object has @type added expect(jsonData.provider).toHaveProperty("@type", "Organization"); // Nested address also has @type added by processor expect(jsonData.provider.address).toHaveProperty("@type", "PostalAddress"); // Single value or array handling for areaServed expect(Array.isArray(jsonData.areaServed)).toBe(true); }); test("custom components follow @type optional pattern", async ({ page }) => { // Verify that developers don't need to specify @type manually await page.goto("/custom-podcast"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); const jsonData = JSON.parse(jsonLdScript!); // All nested objects should have @type added automatically expect(jsonData["@type"]).toBe("PodcastSeries"); expect(jsonData.host["@type"]).toBe("Person"); jsonData.episode.forEach((ep: { "@type": string }) => { expect(ep["@type"]).toBe("PodcastEpisode"); }); // Service page await page.goto("/custom-service"); const serviceScript = await page .locator('script[type="application/ld+json"]') .textContent(); const serviceData = JSON.parse(serviceScript!); // Verify all types are properly set expect(serviceData["@type"]).toBe("Service"); expect(serviceData.provider["@type"]).toBe("Organization"); expect(serviceData.provider.address["@type"]).toBe("PostalAddress"); expect(serviceData.offers["@type"]).toBe("Offer"); expect(serviceData.aggregateRating["@type"]).toBe("AggregateRating"); }); test("JSON-LD output is valid and parseable", async ({ page }) => { // Test both custom component pages produce valid JSON const pages = ["/custom-podcast", "/custom-service"]; for (const pagePath of pages) { await page.goto(pagePath); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); expect(jsonLdScript).toBeTruthy(); // Should not throw when parsing let jsonData; expect(() => { jsonData = JSON.parse(jsonLdScript!); }).not.toThrow(); // Should have required schema.org properties expect(jsonData).toHaveProperty("@context", "https://schema.org"); expect(jsonData).toHaveProperty("@type"); expect(jsonData).toHaveProperty("name"); } }); test("custom components render correctly with partial data", async ({ page, }) => { // The example pages have all data, but this tests that the components // handle conditional properties correctly (all props except name are optional) // Both pages should render without errors await page.goto("/custom-podcast"); await expect(page.locator("h1")).toContainText("Tech Talk Weekly"); await page.goto("/custom-service"); await expect(page.locator("h1")).toContainText("Web Development Services"); // Verify no console errors const consoleMessages: string[] = []; page.on("console", (msg) => { if (msg.type() === "error") { consoleMessages.push(msg.text()); } }); await page.goto("/custom-podcast"); await page.goto("/custom-service"); expect(consoleMessages).toHaveLength(0); }); }); ================================================ FILE: tests/e2e/datasetJsonLd.e2e.spec.ts ================================================ import { test, expect } from "@playwright/test"; test.describe("DatasetJsonLd", () => { test("renders basic Dataset structured data", async ({ page }) => { await page.goto("/dataset"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); expect(jsonLdScript).toBeTruthy(); const jsonData = JSON.parse(jsonLdScript!); // Verify basic structure expect(jsonData["@context"]).toBe("https://schema.org"); expect(jsonData["@type"]).toBe("Dataset"); expect(jsonData.name).toBe("NCDC Storm Events Database"); expect(jsonData.description).toContain( "Storm Data is provided by the National Weather Service", ); expect(jsonData.url).toBe("https://example.com/dataset/storm-events"); expect(jsonData.isAccessibleForFree).toBe(true); expect(jsonData.keywords).toEqual([ "storm", "weather", "climate", "natural disasters", ]); // Verify creator expect(jsonData.creator).toEqual({ "@type": "Person", name: "NOAA", }); // Verify distribution expect(jsonData.distribution).toEqual({ "@type": "DataDownload", contentUrl: "https://www.ncdc.noaa.gov/stormevents/ftp.jsp", encodingFormat: "CSV", }); }); test("renders advanced Dataset with all features", async ({ page }) => { await page.goto("/dataset-advanced"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); const jsonData = JSON.parse(jsonLdScript!); // Verify comprehensive properties expect(jsonData.name).toBe("Global Climate Data 2020-2024"); expect(jsonData.sameAs).toEqual([ "https://doi.org/10.1000/182", "https://data.gov/dataset/climate-2020-2024", ]); // Verify identifiers expect(jsonData.identifier).toHaveLength(2); expect(jsonData.identifier[0]).toBe("https://doi.org/10.1000/182"); expect(jsonData.identifier[1]).toEqual({ "@type": "PropertyValue", value: "ark:/12345/fk1234", propertyID: "ARK", }); // Verify license expect(jsonData.license).toEqual({ "@type": "CreativeWork", name: "Creative Commons Zero v1.0 Universal", url: "https://creativecommons.org/publicdomain/zero/1.0/", }); // Verify creators array expect(jsonData.creator).toHaveLength(3); expect(jsonData.creator[0]["@type"]).toBe("Organization"); expect(jsonData.creator[0].name).toBe( "National Centers for Environmental Information", ); expect(jsonData.creator[0].contactPoint).toEqual({ "@type": "ContactPoint", contactType: "customer service", telephone: "+1-828-271-4800", email: "ncei.orders@noaa.gov", }); expect(jsonData.creator[1]).toEqual({ "@type": "Person", name: "Dr. Jane Smith", }); // Verify funder expect(jsonData.funder).toEqual({ "@type": "Organization", name: "National Science Foundation", sameAs: "https://ror.org/021nxhr62", }); // Verify included in data catalog expect(jsonData.includedInDataCatalog).toEqual({ "@type": "DataCatalog", name: "data.gov", url: "https://data.gov", }); // Verify distributions expect(jsonData.distribution).toHaveLength(3); expect(jsonData.distribution[0]).toEqual({ "@type": "DataDownload", contentUrl: "https://example.com/data/climate-2020-2024.csv", encodingFormat: "CSV", contentSize: "2.5GB", description: "Complete dataset in CSV format with all measurements", }); // Verify temporal and spatial coverage expect(jsonData.temporalCoverage).toBe("2020-01-01/2024-12-31"); expect(jsonData.spatialCoverage).toEqual({ "@type": "Place", name: "Global Coverage", geo: { "@type": "GeoShape", box: "-90 -180 90 180", }, }); // Verify measurement technique expect(jsonData.measurementTechnique).toEqual([ "Satellite observation", "Ground station measurements", "Weather balloon data", "Ocean buoy sensors", ]); // Verify variable measured expect(jsonData.variableMeasured).toHaveLength(5); expect(jsonData.variableMeasured[0]).toBe("temperature"); expect(jsonData.variableMeasured[2]).toEqual({ "@type": "PropertyValue", name: "Atmospheric Pressure", value: "hectopascals", propertyID: "PRES", }); // Verify other properties expect(jsonData.version).toBe("2.1"); expect(jsonData.alternateName).toEqual([ "Global Climate Dataset 2020-2024", "GCD-2024", "World Climate Data Collection", ]); expect(jsonData.citation).toHaveLength(2); }); test("renders Dataset with catalog inclusion", async ({ page }) => { await page.goto("/dataset-catalog"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); const jsonData = JSON.parse(jsonLdScript!); // Verify basic info expect(jsonData.name).toBe("Ocean Temperature Time Series 2023"); expect(jsonData.identifier).toBe("https://doi.org/10.5000/ocean-temp-2023"); // Verify creator as organization expect(jsonData.creator).toEqual({ "@type": "Organization", name: "Pacific Ocean Research Institute", url: "https://example.com/pori", logo: "https://example.com/pori-logo.png", }); // Verify data catalog inclusion expect(jsonData.includedInDataCatalog).toEqual({ "@type": "DataCatalog", name: "Pacific Ocean Climate Data Catalog", url: "https://example.com/pacific-climate-catalog", description: "Comprehensive collection of Pacific Ocean climate datasets", }); // Verify spatial coverage with GeoShape expect(jsonData.spatialCoverage).toEqual({ "@type": "Place", name: "Pacific Ocean", geo: { "@type": "GeoShape", box: "-60 120 60 -80", }, }); // Verify variable measured array expect(jsonData.variableMeasured).toHaveLength(2); expect(jsonData.variableMeasured[0]).toEqual({ "@type": "PropertyValue", name: "Sea Surface Temperature", value: "degrees Celsius", propertyID: "SST", }); }); test("renders Dataset with nested structure (hasPart)", async ({ page }) => { await page.goto("/dataset-nested"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); const jsonData = JSON.parse(jsonLdScript!); // Verify parent dataset expect(jsonData.name).toBe("World Climate Database 2020-2024"); expect(jsonData["@type"]).toBe("Dataset"); // Verify hasPart contains sub-datasets expect(jsonData.hasPart).toHaveLength(3); // Check first sub-dataset const firstSubDataset = jsonData.hasPart[0]; expect(firstSubDataset["@type"]).toBe("Dataset"); expect(firstSubDataset.name).toBe("North America Climate Data 2020-2024"); expect(firstSubDataset.creator).toEqual({ "@type": "Organization", name: "North American Weather Service", }); expect(firstSubDataset.distribution).toEqual({ "@type": "DataDownload", contentUrl: "https://example.com/data/na-climate.csv", encodingFormat: "CSV", }); expect(firstSubDataset.spatialCoverage).toEqual({ "@type": "Place", name: "North America", }); // Check second sub-dataset const secondSubDataset = jsonData.hasPart[1]; expect(secondSubDataset.name).toBe("Europe Climate Data 2020-2024"); expect(secondSubDataset.creator.name).toBe("European Climate Agency"); // Check third sub-dataset const thirdSubDataset = jsonData.hasPart[2]; expect(thirdSubDataset.name).toBe("Asia Pacific Climate Data 2020-2024"); expect(thirdSubDataset.spatialCoverage.name).toBe("Asia Pacific"); // Verify parent dataset properties expect(jsonData.distribution).toEqual({ "@type": "DataDownload", contentUrl: "https://example.com/data/world-climate-complete.zip", encodingFormat: "ZIP", contentSize: "12.5GB", description: "Complete aggregated dataset with all regional data", }); expect(jsonData.spatialCoverage).toBe("Global"); expect(jsonData.variableMeasured).toEqual([ "temperature", "precipitation", "wind speed", "humidity", ]); }); }); ================================================ FILE: tests/e2e/discussionForumPostingJsonLd.e2e.spec.ts ================================================ import { test, expect } from "@playwright/test"; test.describe("DiscussionForumPostingJsonLd", () => { test("renders basic DiscussionForumPosting structured data", async ({ page, }) => { await page.goto("/discussion-forum"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); expect(jsonLdScript).toBeTruthy(); const jsonData = JSON.parse(jsonLdScript!); // Verify basic structure expect(jsonData["@context"]).toBe("https://schema.org"); expect(jsonData["@type"]).toBe("DiscussionForumPosting"); expect(jsonData.headline).toBe("I went to the concert!"); expect(jsonData.text).toBe("Look at how cool this concert was!"); expect(jsonData.url).toBe("https://example.com/forum/very-popular-thread"); expect(jsonData.datePublished).toBe("2024-01-01T08:00:00+00:00"); // Verify author expect(jsonData.author).toEqual({ "@type": "Person", name: "Katie Pope", }); // Verify comments expect(jsonData.comment).toHaveLength(2); expect(jsonData.comment[0]).toEqual({ "@type": "Comment", text: "Who's the person you're with?", author: { "@type": "Person", name: "Saul Douglas", }, datePublished: "2024-01-01T09:46:02+00:00", }); expect(jsonData.comment[1]).toEqual({ "@type": "Comment", text: "That's my mom, isn't she cool?", author: { "@type": "Person", name: "Katie Pope", }, datePublished: "2024-01-01T09:50:25+00:00", }); }); test("renders advanced DiscussionForumPosting with all features", async ({ page, }) => { await page.goto("/discussion-forum-advanced"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); expect(jsonLdScript).toBeTruthy(); const jsonData = JSON.parse(jsonLdScript!); // Verify basic structure expect(jsonData["@context"]).toBe("https://schema.org"); expect(jsonData["@type"]).toBe("DiscussionForumPosting"); expect(jsonData.headline).toBe("Very Popular Thread About Concerts"); // Verify dates expect(jsonData.datePublished).toBe("2024-01-01T08:34:34+00:00"); expect(jsonData.dateModified).toBe("2024-01-01T09:00:00+00:00"); // Verify author with URL expect(jsonData.author).toEqual({ "@type": "Person", name: "Katie Pope", url: "https://example.com/user/katie-pope", }); // Verify images expect(jsonData.image).toHaveLength(2); expect(jsonData.image[0]).toBe("https://example.com/concert-photo1.jpg"); expect(jsonData.image[1]).toEqual({ "@type": "ImageObject", url: "https://example.com/concert-photo2.jpg", width: 1200, height: 800, caption: "The main stage", }); // Verify video expect(jsonData.video).toEqual({ "@type": "VideoObject", name: "Concert Highlights", contentUrl: "https://example.com/concert-video.mp4", uploadDate: "2024-01-02T10:00:00+00:00", thumbnailUrl: "https://example.com/concert-thumbnail.jpg", description: "Best moments from the concert", }); // Verify interaction statistics expect(jsonData.interactionStatistic).toHaveLength(3); expect(jsonData.interactionStatistic[0]).toEqual({ "@type": "InteractionCounter", interactionType: "https://schema.org/LikeAction", userInteractionCount: 127, }); // Verify isPartOf expect(jsonData.isPartOf).toEqual({ "@type": "CreativeWork", name: "Concert Discussions", url: "https://example.com/forum/concerts", }); // Verify sharedContent expect(jsonData.sharedContent).toEqual({ "@type": "WebPage", url: "https://example.com/concert-tickets", name: "Concert Venue Information", description: "Details about the venue and upcoming shows", }); // Verify nested comments expect(jsonData.comment).toHaveLength(2); expect(jsonData.comment[0].comment).toHaveLength(1); expect(jsonData.comment[0].comment[0]).toMatchObject({ "@type": "Comment", text: "Yes it should, it's a great post!", author: { "@type": "Person", name: "Happy Fan", }, }); // Verify comment with video expect(jsonData.comment[1].video).toMatchObject({ "@type": "VideoObject", name: "My Concert Video", }); }); test("renders SocialMediaPosting type", async ({ page }) => { await page.goto("/social-media-posting"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); expect(jsonLdScript).toBeTruthy(); const jsonData = JSON.parse(jsonLdScript!); // Verify it's SocialMediaPosting type expect(jsonData["@type"]).toBe("SocialMediaPosting"); // Verify author expect(jsonData.author).toEqual({ "@type": "Person", name: "TechInfluencer", url: "https://example.com/user/techinfluencer", }); // Verify shared content is processed correctly expect(jsonData.sharedContent).toEqual({ "@type": "WebPage", url: "https://example.com/ai-tool-review", name: "Revolutionary AI Tool for Content Creators", description: "A comprehensive review of the latest AI tool that's changing how we create content", }); // Verify multiple interaction statistics expect(jsonData.interactionStatistic).toHaveLength(3); const likeAction = jsonData.interactionStatistic.find( (stat: { interactionType: string }) => stat.interactionType === "https://schema.org/LikeAction", ); expect(likeAction.userInteractionCount).toBe(342); }); test("renders deleted DiscussionForumPosting", async ({ page }) => { await page.goto("/discussion-forum-deleted"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); expect(jsonLdScript).toBeTruthy(); const jsonData = JSON.parse(jsonLdScript!); // Verify deleted status expect(jsonData.creativeWorkStatus).toBe("Deleted"); expect(jsonData.headline).toBe("[Deleted Post]"); expect(jsonData.text).toBe( "This post has been removed by the author or moderators.", ); // Verify mix of normal and deleted comments expect(jsonData.comment).toHaveLength(3); // Check deleted comment expect(jsonData.comment[1].creativeWorkStatus).toBe("Deleted"); expect(jsonData.comment[1].text).toBe("[This comment has been deleted]"); // Check moderator comment (Organization author) expect(jsonData.comment[2].author).toEqual({ "@type": "Organization", name: "Forum Moderators", url: "https://example.com/moderators", }); }); }); ================================================ FILE: tests/e2e/employerAggregateRatingJsonLd.e2e.spec.ts ================================================ import { test, expect } from "@playwright/test"; test.describe("EmployerAggregateRatingJsonLd", () => { test("renders basic EmployerAggregateRating structured data", async ({ page, }) => { await page.goto("/employer-aggregate-rating"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); expect(jsonLdScript).toBeTruthy(); const jsonData = JSON.parse(jsonLdScript!); // Verify all properties expect(jsonData["@context"]).toBe("https://schema.org"); expect(jsonData["@type"]).toBe("EmployerAggregateRating"); expect(jsonData.itemReviewed).toEqual({ "@type": "Organization", name: "World's Best Coffee Shop", }); expect(jsonData.ratingValue).toBe(91); expect(jsonData.ratingCount).toBe(10561); expect(jsonData.reviewCount).toBeUndefined(); expect(jsonData.bestRating).toBeUndefined(); expect(jsonData.worstRating).toBeUndefined(); }); test("renders advanced EmployerAggregateRating with full Organization details", async ({ page, }) => { await page.goto("/employer-aggregate-rating-advanced"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); expect(jsonLdScript).toBeTruthy(); const jsonData = JSON.parse(jsonLdScript!); // Verify basic structure expect(jsonData["@context"]).toBe("https://schema.org"); expect(jsonData["@type"]).toBe("EmployerAggregateRating"); // Verify organization details expect(jsonData.itemReviewed["@type"]).toBe("Organization"); expect(jsonData.itemReviewed.name).toBe("TechCorp International"); expect(jsonData.itemReviewed.sameAs).toBe( "https://www.techcorp-international.example.com", ); expect(jsonData.itemReviewed.url).toBe( "https://www.techcorp-international.example.com", ); expect(jsonData.itemReviewed.logo).toEqual({ "@type": "ImageObject", url: "https://example.com/techcorp-logo.png", width: 600, height: 300, }); expect(jsonData.itemReviewed.description).toBe( "Leading technology company specializing in cloud solutions and AI", ); expect(jsonData.itemReviewed.telephone).toBe("+1-555-123-4567"); expect(jsonData.itemReviewed.email).toBe("careers@techcorp.example.com"); // Verify addresses expect(jsonData.itemReviewed.address).toHaveLength(2); expect(jsonData.itemReviewed.address[0]).toEqual({ "@type": "PostalAddress", streetAddress: "123 Innovation Way", addressLocality: "San Francisco", addressRegion: "CA", postalCode: "94105", addressCountry: "US", }); expect(jsonData.itemReviewed.address[1]).toEqual({ "@type": "PostalAddress", streetAddress: "456 Tech Park", addressLocality: "New York", addressRegion: "NY", postalCode: "10001", addressCountry: "US", }); // Verify numberOfEmployees expect(jsonData.itemReviewed.numberOfEmployees).toEqual({ "@type": "QuantitativeValue", value: 5000, }); // Verify ratings expect(jsonData.ratingValue).toBe(4.7); expect(jsonData.ratingCount).toBe(1842); expect(jsonData.reviewCount).toBe(1755); expect(jsonData.bestRating).toBe(5); expect(jsonData.worstRating).toBe(1); }); test("renders custom scale EmployerAggregateRating with percentage", async ({ page, }) => { await page.goto("/employer-aggregate-rating-custom-scale"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); expect(jsonLdScript).toBeTruthy(); const jsonData = JSON.parse(jsonLdScript!); // Verify structure expect(jsonData["@context"]).toBe("https://schema.org"); expect(jsonData["@type"]).toBe("EmployerAggregateRating"); // Verify organization expect(jsonData.itemReviewed["@type"]).toBe("Organization"); expect(jsonData.itemReviewed.name).toBe("Green Energy Corp"); expect(jsonData.itemReviewed.sameAs).toBe( "https://www.greenenergycorp.example.com", ); // Verify percentage rating expect(jsonData.ratingValue).toBe("85%"); expect(jsonData.reviewCount).toBe(432); expect(jsonData.ratingCount).toBeUndefined(); // Verify custom scale expect(jsonData.bestRating).toBe(100); expect(jsonData.worstRating).toBe(0); }); test("page content matches structured data", async ({ page }) => { await page.goto("/employer-aggregate-rating"); // Check visible content await expect( page.getByRole("heading", { name: "World's Best Coffee Shop - Employer Ratings", }), ).toBeVisible(); await expect(page.getByText("91")).toBeVisible(); await expect(page.getByText("out of 100")).toBeVisible(); await expect( page.getByText("Based on 10,561 employee ratings"), ).toBeVisible(); // Get structured data const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); const jsonData = JSON.parse(jsonLdScript!); // Verify structured data matches visible content expect(jsonData.itemReviewed.name).toBe("World's Best Coffee Shop"); expect(jsonData.ratingValue).toBe(91); expect(jsonData.ratingCount).toBe(10561); }); test("validates JSON-LD format is parseable", async ({ page }) => { await page.goto("/employer-aggregate-rating-advanced"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); // Should not throw expect(() => JSON.parse(jsonLdScript!)).not.toThrow(); // Verify it's valid JSON-LD const jsonData = JSON.parse(jsonLdScript!); expect(jsonData["@context"]).toBeDefined(); expect(jsonData["@type"]).toBeDefined(); }); }); ================================================ FILE: tests/e2e/eventJsonLd.e2e.spec.ts ================================================ import { test, expect } from "@playwright/test"; test.describe("EventJsonLd", () => { test("renders basic Event structured data", async ({ page }) => { await page.goto("/event"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); expect(jsonLdScript).toBeTruthy(); const jsonData = JSON.parse(jsonLdScript!); // Verify basic structure expect(jsonData["@context"]).toBe("https://schema.org"); expect(jsonData["@type"]).toBe("Event"); expect(jsonData.name).toBe("The Adventures of Kira and Morrison"); expect(jsonData.startDate).toBe("2025-07-21T19:00-05:00"); expect(jsonData.endDate).toBe("2025-07-21T23:00-05:00"); // Verify location expect(jsonData.location).toEqual({ "@type": "Place", name: "Snickerpark Stadium", address: { "@type": "PostalAddress", streetAddress: "100 West Snickerpark Dr", addressLocality: "Snickertown", postalCode: "19019", addressRegion: "PA", addressCountry: "US", }, }); // Verify images array expect(jsonData.image).toEqual([ "https://example.com/photos/1x1/photo.jpg", "https://example.com/photos/4x3/photo.jpg", "https://example.com/photos/16x9/photo.jpg", ]); // Verify offers expect(jsonData.offers).toEqual({ "@type": "Offer", url: "https://www.example.com/event_offer/12345_202403180430", price: 30, priceCurrency: "USD", availability: "https://schema.org/InStock", validFrom: "2024-05-21T12:00", }); // Verify performer expect(jsonData.performer).toEqual({ "@type": "PerformingGroup", name: "Kira and Morrison", }); // Verify organizer expect(jsonData.organizer).toEqual({ "@type": "Organization", name: "Kira and Morrison Music", url: "https://kiraandmorrisonmusic.com", }); // Verify description expect(jsonData.description).toBe( "The Adventures of Kira and Morrison is coming to Snickertown in a can't miss performance.", ); }); test("renders cancelled Event structured data", async ({ page }) => { await page.goto("/event-cancelled"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); expect(jsonLdScript).toBeTruthy(); const jsonData = JSON.parse(jsonLdScript!); expect(jsonData["@type"]).toBe("Event"); expect(jsonData.name).toBe("Summer Music Festival 2025"); expect(jsonData.eventStatus).toBe("https://schema.org/EventCancelled"); // Verify dates are preserved for cancelled events expect(jsonData.startDate).toBe("2025-08-15T12:00:00-05:00"); expect(jsonData.endDate).toBe("2025-08-17T23:00:00-05:00"); // Verify location is preserved expect(jsonData.location.name).toBe("City Park Amphitheater"); expect(jsonData.location.address.addressLocality).toBe("Austin"); // Verify offers show as sold out expect(jsonData.offers.availability).toBe("https://schema.org/SoldOut"); }); test("renders rescheduled Event structured data", async ({ page }) => { await page.goto("/event-rescheduled"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); expect(jsonLdScript).toBeTruthy(); const jsonData = JSON.parse(jsonLdScript!); expect(jsonData["@type"]).toBe("Event"); expect(jsonData.name).toBe("Tech Conference 2025: Future of AI"); expect(jsonData.eventStatus).toBe("https://schema.org/EventRescheduled"); // Verify new dates expect(jsonData.startDate).toBe("2025-09-20T09:00:00-07:00"); expect(jsonData.endDate).toBe("2025-09-22T17:00:00-07:00"); // Verify previous dates expect(jsonData.previousStartDate).toEqual([ "2025-03-15T09:00:00-07:00", "2025-06-10T09:00:00-07:00", ]); // Verify multiple offers expect(jsonData.offers).toHaveLength(2); expect(jsonData.offers[0].price).toBe(299); expect(jsonData.offers[1].price).toBe(599); // Verify multiple performers expect(jsonData.performer).toHaveLength(3); expect(jsonData.performer[0]["@type"]).toBe("Person"); expect(jsonData.performer[0].name).toBe("Dr. Sarah Chen"); expect(jsonData.performer[2]).toEqual({ "@type": "PerformingGroup", name: "Panel of Industry Experts", }); // Verify URL expect(jsonData.url).toBe("https://techconf2025.com"); }); test("renders free Event structured data", async ({ page }) => { await page.goto("/event-free"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); expect(jsonLdScript).toBeTruthy(); const jsonData = JSON.parse(jsonLdScript!); expect(jsonData["@type"]).toBe("Event"); expect(jsonData.name).toBe( "Community Coding Workshop: Introduction to Web Development", ); // Verify free event has price 0 expect(jsonData.offers.price).toBe(0); expect(jsonData.offers.priceCurrency).toBe("USD"); expect(jsonData.offers.availability).toBe("https://schema.org/InStock"); // Verify string performer is converted expect(jsonData.performer).toEqual({ "@type": "PerformingGroup", name: "Sarah Johnson", }); // Verify location expect(jsonData.location.name).toBe("Downtown Public Library"); expect(jsonData.location.address.addressLocality).toBe("Springfield"); expect(jsonData.location.address.addressRegion).toBe("IL"); }); test("supports custom script ID", async ({ page }) => { // Create a test page with custom script ID await page.evaluate(() => { const script = document.createElement("script"); script.type = "application/ld+json"; script.id = "event-custom-id"; script.textContent = JSON.stringify({ "@context": "https://schema.org", "@type": "Event", name: "Test Event", startDate: "2025-01-01T00:00:00", location: { "@type": "Place", address: { "@type": "PostalAddress", name: "Test" }, }, }); document.head.appendChild(script); }); const scriptWithId = await page.locator("#event-custom-id"); expect(await scriptWithId.count()).toBe(1); }); }); ================================================ FILE: tests/e2e/faqJsonLd.e2e.spec.ts ================================================ import { test, expect } from "@playwright/test"; test.describe("FAQJsonLd", () => { test("renders basic FAQ structured data", async ({ page }) => { await page.goto("/faq"); // Find the JSON-LD script tag const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); expect(jsonLdScript).toBeTruthy(); const jsonData = JSON.parse(jsonLdScript!); // Verify FAQPage structure expect(jsonData["@context"]).toBe("https://schema.org"); expect(jsonData["@type"]).toBe("FAQPage"); expect(jsonData.mainEntity).toHaveLength(4); // Verify first question const firstQuestion = jsonData.mainEntity[0]; expect(firstQuestion["@type"]).toBe("Question"); expect(firstQuestion.name).toBe("How to find an apprenticeship?"); expect(firstQuestion.acceptedAnswer["@type"]).toBe("Answer"); expect(firstQuestion.acceptedAnswer.text).toContain( "We provide an official service to search through available apprenticeships", ); // Verify second question const secondQuestion = jsonData.mainEntity[1]; expect(secondQuestion.name).toBe("Whom to contact?"); expect(secondQuestion.acceptedAnswer.text).toContain( "You can contact the apprenticeship office", ); // Verify all questions have proper structure jsonData.mainEntity.forEach( (question: { "@type": string; name: string; acceptedAnswer: { "@type": string; text: string }; }) => { expect(question["@type"]).toBe("Question"); expect(question.name).toBeTruthy(); expect(question.acceptedAnswer).toBeTruthy(); expect(question.acceptedAnswer["@type"]).toBe("Answer"); expect(question.acceptedAnswer.text).toBeTruthy(); }, ); }); test("renders FAQ with HTML content in answers", async ({ page }) => { await page.goto("/faq-advanced"); const jsonLdScript = await page .locator('script[type="application/ld+json"]') .textContent(); const jsonData = JSON.parse(jsonLdScript!); expect(jsonData["@type"]).toBe("FAQPage"); expect(jsonData.mainEntity).toHaveLength(4); // Check HTML content is preserved const firstQuestion = jsonData.mainEntity[0]; expect(firstQuestion.name).toBe( "What documents are required for application?", ); expect(firstQuestion.acceptedAnswer.text).toContain("

"); expect(firstQuestion.acceptedAnswer.text).toContain("